Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5bd32c38 | |||
| 74c79aa6c1 | |||
| 092e809946 | |||
| 92de7c9867 | |||
| 719509fb2f | |||
| fec15c07bb | |||
| cb1b6ea46a | |||
| 1f0c1ab02a | |||
| b5de241ebc | |||
| 5157cd544d | |||
| 722837e599 | |||
| 8e72d7ad02 | |||
| 87b420f459 | |||
| 6605008f49 | |||
| b5f644e52d | |||
| 7da9dc36e4 | |||
| 4770a8368f | |||
| d624fe74c9 | |||
| 29c2ea488f | |||
| ac6f3766b0 | |||
| 4d9f2af0c0 | |||
| 26de94e57d | |||
| 8fd8f64368 | |||
| 7005763517 | |||
| 5f229cd66e | |||
| 7169614506 | |||
| 69619df4c7 | |||
| 056d18a3fd | |||
| 99efc7a042 | |||
| e44a310a3b | |||
| 83c4e7a334 | |||
| 56565f6f73 | |||
| ab4f706057 | |||
| 05ebd6754b | |||
| b5d277478c | |||
| 53e0965c4b | |||
| aaf0221957 | |||
| 1177da33ea | |||
| 03e8938a18 | |||
| c13773e762 | |||
| 5a219ca8e5 | |||
| 2a11728384 | |||
| 7b2e0049eb | |||
| fb65df7861 | |||
| 49039ac286 | |||
| 9e91ad6335 | |||
| 924ed8eeff | |||
| fe8c272460 | |||
| a7ada9f36c | |||
| 1e60835612 | |||
| a40315563e | |||
| e7e8041dae | |||
| ce601c4507 | |||
| dc63399cc7 | |||
| e374cca103 | |||
| 2749553577 | |||
| 0bb14d1c6e | |||
| 04f64c8f89 | |||
| 0f582db265 | |||
| d02bc10ab5 | |||
| a5547fb3d2 | |||
| 2b3d3e3ff8 | |||
| 169819fe31 | |||
| 7a4bdb74f0 | |||
| eaf4800b6d | |||
| 3f6b0ccf91 | |||
| 9ff469cb8e | |||
| d99c9895bb | |||
| ea04dcd9d1 | |||
| 753d786bb5 | |||
| 602c46e5bf | |||
| 9e1be75444 | |||
| e002968914 | |||
| 2596687679 | |||
| 4ac87ab385 | |||
| 2b01443efe | |||
| 5ac9ebed5b | |||
| 1561005d41 | |||
| b9e5176a5b | |||
| a60ad30ac0 | |||
| bd31a15548 | |||
| 4dc0e77ee5 |
@@ -17,3 +17,12 @@ data/processed/
|
|||||||
*.pth
|
*.pth
|
||||||
notebooks/.ipynb_checkpoints/
|
notebooks/.ipynb_checkpoints/
|
||||||
data/paper_trades/
|
data/paper_trades/
|
||||||
|
data/portfolio_paper/
|
||||||
|
data/portfolios/
|
||||||
|
|
||||||
|
# stato locale di tooling (non condiviso)
|
||||||
|
.claude/
|
||||||
|
.omc/
|
||||||
|
|
||||||
|
# dati regime (DVOL/funding/feature cache, rigenerabili)
|
||||||
|
data/regime/
|
||||||
|
|||||||
@@ -292,3 +292,40 @@ curl -X POST http://localhost:9000/mcp-bybit/tools/get_ticker \
|
|||||||
|
|
||||||
Per lo schema completo dei body di richiesta e risposta:
|
Per lo schema completo dei body di richiesta e risposta:
|
||||||
<http://localhost:9000/apidocs>.
|
<http://localhost:9000/apidocs>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Discovery strumenti — schemi `get_instruments` / `get_markets` / `get_historical`
|
||||||
|
|
||||||
|
Schemi dei body verificati sull'OpenAPI live (usati da `src/data/instruments.py`).
|
||||||
|
|
||||||
|
### Lista strumenti
|
||||||
|
| Exchange | Tool | Body | Risposta (campi utili) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Deribit | `get_instruments` | `{currency:"any", kind:"future", offset:int, limit:100}` (paginato, `has_more`) | `instruments[].name` (es. `BTC-PERPETUAL`, `SOL_USDC-PERPETUAL`), `expiry`, `tick_size` |
|
||||||
|
| Bybit | `get_instruments` | `{category:"linear", symbol?}` | `instruments[]`: `symbol`, `status`, `base_coin`, `quote_coin` |
|
||||||
|
| Hyperliquid | `get_markets` | `{}` | lista `{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}` |
|
||||||
|
|
||||||
|
### Storico OHLCV (`get_historical`, chiave `candles` uniforme `{timestamp(ms),open,high,low,close,volume}`)
|
||||||
|
| Exchange | Body |
|
||||||
|
|---|---|
|
||||||
|
| Deribit | `{instrument, start_date:"YYYY-MM-DD", end_date, resolution}` — resolution `1/5/15/60/1D` |
|
||||||
|
| Bybit | `{symbol, category:"linear", interval:"1/5/15/60/D", start:int_ms, end:int_ms, limit}` |
|
||||||
|
| Hyperliquid | `{asset|instrument, start_date, end_date, resolution:"1m/5m/15m/1h/1d", limit}` |
|
||||||
|
|
||||||
|
### Simboli Deribit
|
||||||
|
- BTC/ETH → perpetui **inverse**: `BTC-PERPETUAL`, `ETH-PERPETUAL`
|
||||||
|
- Altcoin → perpetui **lineari USDC**: `<COIN>_USDC-PERPETUAL` (es. `SOL_USDC-PERPETUAL`)
|
||||||
|
- Trappola: `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono; `SOL-PERPETUAL` esiste ma è un contratto sbagliato (prezzo ~9.6 vs SOL reale ~82).
|
||||||
|
|
||||||
|
### Validazione (lato progetto)
|
||||||
|
`src/data/instruments.py` valida ogni strumento sui dati storici realmente
|
||||||
|
raccoglibili — esistenza, congruenza OHLC, not-flat, liquidità (volume daily) e
|
||||||
|
**congruenza prezzo cross-exchange** (scostamento dalla mediana del base-coin ≤5%).
|
||||||
|
Solo gli exchange con feed affidabile sono inclusi: **Deribit** e **Hyperliquid**
|
||||||
|
(esclusi Alpaca/stocks e **Bybit**, il cui feed testnet è farlocco). Output in
|
||||||
|
`data/instruments_registry.json`; il downloader scarica **solo** strumenti validati.
|
||||||
|
|
||||||
|
> **Testnet.** Il token osservatore punta a testnet (`"testnet": true` nei ticker):
|
||||||
|
> i prezzi possono divergere dal mainnet. La congruenza cross-exchange via mediana
|
||||||
|
> è il filtro che scarta i feed incongrui prima di usarli per backtest/trading.
|
||||||
|
|||||||
@@ -17,44 +17,82 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
|
|||||||
## Struttura
|
## Struttura
|
||||||
|
|
||||||
```
|
```
|
||||||
src/data/ → download e caricamento dati (downloader.py)
|
src/data/ → download e caricamento dati
|
||||||
|
downloader.py → download/caricamento parquet (gate: solo strumenti validati)
|
||||||
|
instruments.py → discovery + validazione strumenti per exchange, registry
|
||||||
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
||||||
src/backtest/ → engine di backtesting (engine.py)
|
src/backtest/ → engine di backtesting (engine.py)
|
||||||
src/strategies/ → classe base Strategy ABC + indicatori condivisi
|
src/strategies/ → classe base Strategy ABC + indicatori condivisi
|
||||||
base.py → Strategy, Signal, BacktestResult, YearlyStats
|
base.py → Strategy, Signal, BacktestResult, YearlyStats
|
||||||
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
|
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
|
||||||
src/live/ → paper trading live multi-strategia
|
src/live/ → paper trading live multi-strategia
|
||||||
multi_runner.py → orchestratore: carica YAML, fetch candele, tick worker
|
multi_runner.py → orchestratore: carica YAML (strategies + pairs), fetch candele, tick worker
|
||||||
strategy_worker.py → worker indipendente: capital, trade log, stato persistente.
|
strategy_worker.py → worker single-leg: capital, trade log, stato persistente.
|
||||||
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
|
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
|
||||||
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
|
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
|
||||||
|
pairs_worker.py → worker a 2 GAMBE per PR01 (market-neutral): long A / short B sullo
|
||||||
|
z-score del log-ratio, exit |z|<=z_exit o max_bars, fee su 2 gambe.
|
||||||
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
|
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
|
||||||
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
|
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet): dati + ordini
|
||||||
|
(place_order market/reduce_only, get_trade_history, get_positions)
|
||||||
|
execution.py → ExecutionClient: esecuzione REALE su Deribit (shadow). notional→
|
||||||
|
amount (lineari USDC + inverse), open/close_amount reduce-only,
|
||||||
|
verifica sul trade (order_id), fee reali dai trades[]
|
||||||
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
|
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
|
||||||
telegram_notifier.py → notifiche Telegram per trade
|
telegram_notifier.py → notifiche Telegram per trade
|
||||||
scripts/strategies/ → strategie con edge validato OOS (solo MR01_bollinger_fade)
|
src/portfolio/ → portafogli di prima classe (capitale-pool, backtest+live)
|
||||||
|
base.py → SleeveSpec, Portfolio (.backtest), load_active_portfolio
|
||||||
|
weighting.py → schemi pesi: equal/cap/inverse_vol/cluster_rp/manual
|
||||||
|
sleeves.py → builder unificato equity-per-sleeve (fonte unica, parità report)
|
||||||
|
ledger.py → PortfolioLedger: capitale/PnL/DD/persistenza+resume
|
||||||
|
runner.py → PortfolioRunner live (data Cerbero v2, sizing, ribilancio)
|
||||||
|
src/version.py → APP_VERSION (legge il file VERSION) — mostrato nei msg Telegram
|
||||||
|
src/strategies/fade_base.py → FadeStrategy + helper: atr, trend_distance, hurst_skip_mask (loss-guard)
|
||||||
|
scripts/strategies/ → strategie con edge validato OOS: FADE (MR01/MR02/MR07),
|
||||||
|
HONEST (DIP01/TR01/ROT02), PAIRS (PR01), TSMOM + portafogli (PORT01/02/03);
|
||||||
|
FR01_hurst_calm_fade.py = record ricerca (robusto ma NON deployato)
|
||||||
|
scripts/portfolios/ → definizioni PORT01-06 (_defs.py) + report run() + hourly_report.py (Telegram)
|
||||||
scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
|
scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
|
||||||
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
|
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...);
|
||||||
|
regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca)
|
||||||
|
scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild)
|
||||||
|
VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy)
|
||||||
strategies.yml → config multi-strategy paper trader
|
strategies.yml → config multi-strategy paper trader
|
||||||
docs/diary/ → diario di ricerca giornaliero
|
docs/diary/ → diario di ricerca giornaliero
|
||||||
docs/specs/ → specifiche di design
|
docs/specs/ → specifiche di design
|
||||||
data/raw/ → file .parquet OHLCV (gitignored)
|
data/raw/ → file .parquet OHLCV (gitignored) | data/regime/ → DVOL+funding+feature (gitignored)
|
||||||
|
data/instruments_registry.json → allowlist strumenti validati (gate del downloader)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comandi
|
## Comandi
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # installa dipendenze
|
uv sync # installa dipendenze
|
||||||
uv run python -m src.data.downloader # scarica dati storici
|
uv run python -m src.data.downloader # scarica dati storici (solo strumenti validati)
|
||||||
uv run python scripts/strategies/MR01_bollinger_fade.py # strategia attiva (mean-reversion)
|
uv run python -m src.data.instruments # (ri)costruisci il registry strumenti validati
|
||||||
|
uv run python scripts/strategies/MR01_bollinger_fade.py # backtest una strategia (es. fade)
|
||||||
|
uv run python scripts/strategies/PR01_pairs_reversion.py # backtest pairs market-neutral
|
||||||
uv run python scripts/analysis/strategy_research.py # ricerca strategie fee-aware OOS
|
uv run python scripts/analysis/strategy_research.py # ricerca strategie fee-aware OOS
|
||||||
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
||||||
uv run python scripts/analysis/validate_worker_mr01.py # replay worker reale su MR01
|
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
|
||||||
uv run python -m src.live.multi_runner # paper trading live multi-strategia
|
uv run python scripts/analysis/validate_worker_pairs.py # replay worker 2 gambe == backtest
|
||||||
docker compose up -d # deploy Docker
|
uv run python -m src.live.multi_runner # paper trading live multi-strategia (strategie + pairs)
|
||||||
|
uv run python scripts/portfolios/PORT06_master_shape.py # report backtest portafoglio (default)
|
||||||
|
uv run python -m src.portfolio.runner # paper trading a PORTAFOGLIO (capitale pool)
|
||||||
|
uv run python scripts/analysis/smoke_portfolio.py # smoke live data layer Cerbero v2
|
||||||
|
uv run python scripts/analysis/live_exec_smoke.py # smoke ESECUZIONE reale (ordine→verifica→fee, testnet)
|
||||||
|
uv run python scripts/analysis/live_shadow_smoke.py # smoke catena shadow nel worker (open/close reali)
|
||||||
|
uv run python scripts/analysis/regime_fetcher.py # fetch DVOL+funding (Deribit mainnet) -> data/regime/
|
||||||
|
./scripts/deploy.sh [patch|minor|major] # DEPLOY: bump versione + commit + rebuild Docker
|
||||||
uv run pytest # test
|
uv run pytest # test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Deploy.** Il sorgente è **COPY nell'immagine Docker** (non montato) → `docker compose restart`
|
||||||
|
> NON ricarica il codice: serve **`docker compose up -d --build`** (o `./scripts/deploy.sh`, che bumpa
|
||||||
|
> la versione, committa e rebuilda). Il volume `data/` persiste → i worker fanno RESUME dello stato.
|
||||||
|
> La **versione** (file `VERSION`, semver, +1 ad ogni deploy via `deploy.sh`) compare nei messaggi
|
||||||
|
> Telegram (notifiche trade + report orario) → correli ogni msg al codice che l'ha generato.
|
||||||
|
|
||||||
## Dati storici
|
## Dati storici
|
||||||
|
|
||||||
Scaricati e salvati localmente in Parquet. Per rigenerarli:
|
Scaricati e salvati localmente in Parquet. Per rigenerarli:
|
||||||
@@ -68,6 +106,27 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
|
|||||||
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
|
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
|
||||||
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
|
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
|
||||||
|
|
||||||
|
### Strumenti & validazione (gate raccolta dati)
|
||||||
|
|
||||||
|
`src/data/instruments.py` scopre e **valida** gli strumenti per ogni exchange
|
||||||
|
implementato — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e **Bybit**,
|
||||||
|
il cui feed testnet è farlocco). Per ogni perpetuo enumera via `get_instruments`
|
||||||
|
/`get_markets` e verifica sui **dati storici realmente raccoglibili**:
|
||||||
|
esistenza, congruenza OHLC, not-flat (scarta contratti morti), liquidità (volume
|
||||||
|
daily) e **congruenza prezzo cross-exchange** (scostamento dalla mediana del
|
||||||
|
base-coin ≤ 5% → scarta outlier come `SOL-PERPETUAL`=9.6 vs SOL reale ~82).
|
||||||
|
|
||||||
|
Output: `data/instruments_registry.json` (strumenti validi, timeframe, start-date).
|
||||||
|
**Gate:** `_download_cerbero_range` rifiuta gli strumenti non validati (override
|
||||||
|
`allow_unvalidated=True` solo per casi eccezionali). Rigenera con
|
||||||
|
`python -m src.data.instruments`.
|
||||||
|
|
||||||
|
> **NB testnet.** Il token Cerbero punta a testnet; la congruenza cross-exchange
|
||||||
|
> è il filtro che distingue i feed realistici (Deribit, Hyperliquid) da quelli
|
||||||
|
> farlocchi (Bybit). Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse);
|
||||||
|
> alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale: Deribit 18/106,
|
||||||
|
> Hyperliquid 66/74 validi (major liquidi: BTC dal 2018, alt dal 2022).
|
||||||
|
|
||||||
## Strategie attive
|
## Strategie attive
|
||||||
|
|
||||||
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
|
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
|
||||||
@@ -118,6 +177,24 @@ MR07 46%→21%), edge OOS confermato (vedi `scripts/analysis/risk_management.py`
|
|||||||
Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo.
|
Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo.
|
||||||
Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano).
|
Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano).
|
||||||
|
|
||||||
|
**Loss-guard Hurst (ATTIVO LIVE, 2026-06-02).** Le fade accettano `hurst_max`: saltano i segnali in
|
||||||
|
regime PERSISTENTE/trending (rolling-Hurst ≥ soglia), dove si concentrano stop-loss e perdite
|
||||||
|
(diagnosi: stop-rate 43% per hurst>0.55 vs 21% anti-persistente; i peggiori 1% trade hanno hurst
|
||||||
|
medio 0.61). Helper `src.strategies.fade_base.hurst_skip_mask` (rolling-Hurst causale **dalle sole
|
||||||
|
close** → nessun feed esterno; step=6 per velocità live). **`hurst_max=0.55` attivo sulle 6 fade in
|
||||||
|
`_defs.py`**: il test decisivo a livello PORT06 lo conferma — **FULL DD 4.10%→2.39% (quasi dimezzato),
|
||||||
|
Sharpe 6.62→6.76, OOS Sharpe 8.89→9.15**. È l'UNICO meccanismo anti-perdite che supera il gate (ADX,
|
||||||
|
vol-expansion/vratio, efficiency-ratio, time-stop, vol-target FALLISCONO: tagliano i winner insieme ai
|
||||||
|
loser; i claim esterni ADX/ATR-ratio non si replicano su queste fade crypto). NB: il filtro agisce solo
|
||||||
|
sul path LIVE (`spec.params`); il backtest canonico (`build_everything`/regression-lock) NON è filtrato
|
||||||
|
→ il live farà MEGLIO del backtest sul DD. Ricerca: `scripts/analysis/fade_lossguard_workflow.js`,
|
||||||
|
diagnosi `fade_loss_by_regime.py`, diario `docs/diary/2026-06-02-fade-lossguard.md`.
|
||||||
|
**Effetto misurato (backtest):** stop-loss fade −67% in numero (1881→621), perdite totali −68%, coda
|
||||||
|
−61%→−48% (lo stop-RATE per-trade scende poco, 42→38%: il guard lavora riducendo l'ESPOSIZIONE nel
|
||||||
|
regime tossico, non rendendo sicuro ogni trade). **Monitor live:** `hourly_report.py` traccia lo
|
||||||
|
stop-rate fade PRIMA/DOPO l'attivazione (14:34 UTC del 2026-06-02) e dà il verdetto su Telegram quando
|
||||||
|
il campione DOPO ≥30 (già confermato: stop-rate live PRIMA 42% == backtest 42.1%).
|
||||||
|
|
||||||
**Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie
|
**Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie
|
||||||
× BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul
|
× BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul
|
||||||
paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
|
paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
|
||||||
@@ -157,7 +234,14 @@ quest'ultima riconferma la dominanza mean-reversion). Due edge reali:
|
|||||||
(Sharpe 4.36), LTC/ETH (3.08), ADA/ETH (2.69), BTC/LTC (2.36, robusta anche 4h), ETH/SOL
|
(Sharpe 4.36), LTC/ETH (3.08), ADA/ETH (2.69), BTC/LTC (2.36, robusta anche 4h), ETH/SOL
|
||||||
(1.96, la più debole). Pattern: sempre alt-liquido vs major. Plateau confermato
|
(1.96, la più debole). Pattern: sempre alt-liquido vs major. Plateau confermato
|
||||||
(heatmap 20/20 Sharpe>1) + walk-forward (ETH/BTC 11/12 finestre+). **BNB/ETH scartata**
|
(heatmap 20/20 Sharpe>1) + walk-forward (ETH/BTC 11/12 finestre+). **BNB/ETH scartata**
|
||||||
(overfit). Corr col mercato ~0.02-0.08. Fee su **2 gambe** (worker da estendere). Verifica: `pairs_research.py`.
|
(overfit). Corr col mercato ~0.02-0.08. Fee su **2 gambe**: worker live implementato
|
||||||
|
(`src/live/pairs_worker.py`, sezione `pairs:` in `strategies.yml`). LOGICA validata
|
||||||
|
(`validate_worker_pairs.py`: replay == backtest ESATTO). LIVE (`live_smoke_pairs.py`,
|
||||||
|
smoke reale Cerbero): **tutte e 5 le coppie con feed live fresco**. Naming Deribit:
|
||||||
|
BTC/ETH = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC,
|
||||||
|
storia dal 2022). Trappola: `LTC-PERPETUAL`/`SOL-PERPETUAL` danno vuoto/dati errati →
|
||||||
|
usare sempre `_USDC-PERPETUAL`. Resta da verificare solo liquidità/fill in esecuzione.
|
||||||
|
Verifica edge: `pairs_research.py`.
|
||||||
- **TSM01** (`scripts/analysis/tsmom_research.py`): TSMOM multi-orizzonte 3/6/12m + risk-off,
|
- **TSM01** (`scripts/analysis/tsmom_research.py`): TSMOM multi-orizzonte 3/6/12m + risk-off,
|
||||||
**gross 0.30**, distinto da ROT02 (corr 0.62), DD 15-22%, mai un anno negativo. Robusto
|
**gross 0.30**, distinto da ROT02 (corr 0.62), DD 15-22%, mai un anno negativo. Robusto
|
||||||
(36/36 config OOS+) ma diversificatore, non motore di ritorno (rende meno di ROT02).
|
(36/36 config OOS+) ma diversificatore, non motore di ritorno (rende meno di ROT02).
|
||||||
@@ -167,8 +251,45 @@ grande (`scripts/analysis/combine_v2.py`). **Numeri sobri onesti** (l'OOS singol
|
|||||||
è regime calmo → ottimistico ~50%): worst-DD su 90g rolling **~6%** (non 2.3%), Sharpe
|
è regime calmo → ottimistico ~50%): worst-DD su 90g rolling **~6%** (non 2.3%), Sharpe
|
||||||
atteso **~5** (mediana semestrale), ogni anno positivo dal 2021, regge **leva 2x +
|
atteso **~5** (mediana semestrale), ogni anno positivo dal 2021, regge **leva 2x +
|
||||||
slippage doppio** (CAGR 36%, Sharpe 5.1). Config robusta raccomandata: **MASTER-esteso
|
slippage doppio** (CAGR 36%, Sharpe 5.1). Config robusta raccomandata: **MASTER-esteso
|
||||||
equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio e non ancora
|
equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio; worker live a
|
||||||
validati col worker live a 2 gambe). La confluenza multi-TF è stata SCARTATA (overfit).
|
2 gambe implementato, validato e con feed live su tutte e 5 le coppie — resta da
|
||||||
|
verificare liquidità/fill in esecuzione reale). La confluenza multi-TF è stata SCARTATA (overfit).
|
||||||
|
|
||||||
|
**Pattern del segnale per FORMA (branch `shape_patterns`, 2026-05-29).** Esplorate 5 famiglie
|
||||||
|
di *shape forecasting* con agenti paralleli su harness onesto (`scripts/analysis/shape_lab.py`:
|
||||||
|
analog kNN causale, no-look-ahead verificato). **4/5 sono RUMORE** (riconfermano la dominanza
|
||||||
|
mean-reversion): analog kNN sulla forma grezza (solo BTC-overfit), encoding candele
|
||||||
|
UP/DOWN/DOJI+body/shadow (hit-rate ~50%), DTW+template geometrici (DTW *peggiora* l'euclidea;
|
||||||
|
template overfit), PIP/pivot/zig-zag (0/48 robuste). Vedi `scripts/analysis/shape_*_research.py`.
|
||||||
|
- **SH01 Shape-ML** (`scripts/strategies/SH01_shape_ml.py`): UNICO edge. Una LogisticRegression
|
||||||
|
legge 17 feature di forma (body/shadow, rendimenti, pendenza/curvatura, pos max/min, RSI,
|
||||||
|
estensione) e predice il segno del rendimento a H barre in **walk-forward** (scaler+modello
|
||||||
|
solo sul passato, no leakage). Config **W24 H12 th0.58**. A differenza dello squeeze
|
||||||
|
**regge fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria**, non nella frequenza.
|
||||||
|
Validazione (`scripts/analysis/shape_ml_validate.py`): BTC robusto OVUNQUE (expanding +219%/
|
||||||
|
OOS +42% Sharpe 2.72 8-9 anni; rolling 2y +166%/+96%; stress 2x+slippage OK), ETH/ADA
|
||||||
|
robusti solo expanding (secondari), LTC/SOL/XRP scartati. Griglia: **5/27 celle robuste su
|
||||||
|
cresta stretta W24/H8-12** → overfit moderato, scelta la config conservativa. **Valore vero:
|
||||||
|
diversificatore** (corr +0.08 col MASTER); aggiungerlo migliora l'OOS del MASTER (Sharpe
|
||||||
|
4.33→5.10, DD 4.7%→4.2%). NON motore standalone. **LIVE (2026-06-01): gira come StrategyWorker
|
||||||
|
reale** (vedi fix wiring sotto in SCOPE LIVE). Diario: `docs/diary/2026-05-29-shape.md`.
|
||||||
|
|
||||||
|
**ARGO / GEX opzioni (analisi 2026-06-01, SCARTATO).** Valutato ARGO (lettura del gamma-exposure dei
|
||||||
|
dealer Deribit) come filtro di regime. Esito **NO-GO**: il net-GEX si calcola live (Deribit mainnet
|
||||||
|
public API, OI reale ~368k contratti, DVOL/funding storici gratis) ma **lo storico per-strike dell'OI
|
||||||
|
non è gratuito → non backtestabile OOS** (stesso muro delle opzioni W18/19/21). Niente evidenza crypto,
|
||||||
|
segno fragile, mercato dominato dai perp. Diario `docs/diary/2026-06-01-argo-gex-feasibility.md`.
|
||||||
|
|
||||||
|
**Frattali del segnale × Regime ARGO (ricerca 100 agenti, 2026-06-02, RECORD).** Cercata una strategia
|
||||||
|
che combini un segnale frattale (Hurst/Higuchi/Williams/analog) con un gate di regime (DVOL/VRP/funding).
|
||||||
|
Infrastruttura riusabile: `scripts/analysis/regime_fetcher.py` (DVOL+funding da Deribit mainnet →
|
||||||
|
**`data/regime/`**, NON `data/raw/` che è solo OHLCV) e `regime_lab.py` (feature regime+frattali causali,
|
||||||
|
cache, harness netto-OOS). Esito: **15 strategie robuste e causali, ma NESSUNA batte/migliora il PORT06**
|
||||||
|
(diversificatori sovrapposti alle fade). **Finding: il prior ARGO "VRP>0=range=fade" è SMENTITO** —
|
||||||
|
l'edge è su **VRP<0 + DVOL bassa**. Il vincitore `FR01_hurst_calm_fade.py` è robusto ma DILUISCE il
|
||||||
|
PORT06 (OOS Sharpe 8.89→8.72) → **non deployato** (in `scripts/strategies/` ma NON in MODULE_MAP/yml).
|
||||||
|
Il sottoprodotto utile è stato il **loss-guard Hurst** (vedi sopra), che invece MIGLIORA il PORT06.
|
||||||
|
Diario `docs/diary/2026-06-02-fractal-argo-search.md`.
|
||||||
|
|
||||||
**Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze):
|
**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`.
|
1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`.
|
||||||
@@ -184,14 +305,31 @@ 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
|
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.
|
queste fade, ma va confermato col paper trader live prima di rischiare capitale reale.
|
||||||
|
|
||||||
|
## Portafogli
|
||||||
|
|
||||||
|
- Un `Portfolio` è un oggetto di prima classe (`src/portfolio/`) con definizione (sleeve + schema pesi) e due facce sulla **STESSA** definizione: `.backtest()` (riusa il builder unico di `sleeves.py` → parità esatta con `report_families`) e live (`PortfolioRunner`: capitale pool condiviso, sizing per peso, ribilancio giornaliero, ledger aggregato in `data/portfolios/{code}/`).
|
||||||
|
- **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM).
|
||||||
|
- **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33`, leva 2x, ribilancio 1D. Backtest PORT06: FULL Sharpe 6.07 / OOS Sharpe 8.19, DD 4.9% full / 2.3% OOS.
|
||||||
|
- **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit.
|
||||||
|
- **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity).
|
||||||
|
- **FIX SH01 wiring (2026-06-01).** SH01 gira come **`StrategyWorker` NORMALE** (NON il vecchio `MLWorkerWrapper` di `multi_runner`, che usava il `SignalEngine` **squeeze SCARTATO**: apriva senza metadata ed usciva a `hold_bars=3`, ignorando del tutto SH01_shape_ml). `SH01_shape_ml.generate_signals` fa il walk-forward (retraining) internamente ad ogni tick ed emette `metadata.max_bars=12` → exit a orizzonte via `StrategyWorker.tick`. Serve ≥4000 barre 1h (`_ML_LOOKBACK_DAYS=365`). Vedi `docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md`.
|
||||||
|
- **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra).
|
||||||
|
- **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco.
|
||||||
|
- **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml` → `overrides.execution {enabled, sleeves:[MR01,MR02,MR07], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}`; pairs/rotation/tsmom/shape/dip restano **simulati**. **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura.
|
||||||
|
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero.
|
||||||
|
|
||||||
## Multi-Strategy Paper Trader
|
## Multi-Strategy Paper Trader
|
||||||
|
|
||||||
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
|
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
|
||||||
|
|
||||||
**Config:** `strategies.yml` — lista strategie con asset, tf, sizing, parametri. Attualmente solo MR01 (BTC/ETH 1h).
|
**Config:** `strategies.yml` — due sezioni: `strategies` (single-leg: fade/honest) e
|
||||||
**Persistenza:** `data/paper_trades/{strategy}___{asset}__{tf}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart, include tp/sl/max_bars).
|
`pairs` (a 2 gambe). Attive: 6 fade (MR01/MR02/MR07 × BTC/ETH) + 5 coppie PR01.
|
||||||
|
**Due worker:** `strategy_worker.py` (single-leg) e `pairs_worker.py` (2 gambe,
|
||||||
|
long A / short B sullo z-score del log-ratio, fee su 2 gambe).
|
||||||
|
**Persistenza:** `data/paper_trades/{worker_id}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart).
|
||||||
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
|
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
|
||||||
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker esce su take-profit/stop-loss/time-limit a quei livelli; altrimenti usa il fallback `hold_bars`/stop -2%.
|
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), il worker esce su take-profit/stop-loss/time-limit; i pairs escono su |z|≤z_exit o max_bars.
|
||||||
|
**Naming Deribit (feed live):** major = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Vedi INSTRUMENT_MAP in `multi_runner.py`.
|
||||||
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
|
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
|
||||||
|
|
||||||
## Convenzioni
|
## Convenzioni
|
||||||
|
|||||||
+4
-2
@@ -8,9 +8,11 @@ COPY pyproject.toml uv.lock ./
|
|||||||
RUN uv sync --frozen --no-dev
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
COPY scripts/strategies/ scripts/strategies/
|
COPY scripts/ scripts/
|
||||||
COPY strategies.yml strategies.yml
|
COPY strategies.yml portfolios.yml VERSION ./
|
||||||
|
|
||||||
VOLUME /app/data
|
VOLUME /app/data
|
||||||
|
|
||||||
|
# Default: paper trader multi-strategia. Il servizio "portfolio" in docker-compose
|
||||||
|
# sovrascrive il command per il runner a portafoglio (src.portfolio.runner).
|
||||||
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
|
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Sistema di riconoscimento pattern frattali e predizione per il trading di cripto
|
|||||||
|
|
||||||
## Obiettivo
|
## Obiettivo
|
||||||
|
|
||||||
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 6–8 mesi, tramite strategie algoritmiche che combinano analisi frattale, squeeze di volatilità e machine learning.
|
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 6–8 mesi, tramite un portafoglio di strategie algoritmiche poco correlate fra loro — mean-reversion, trend/rotazione e spread market-neutral — validate out-of-sample e fee-aware.
|
||||||
|
|
||||||
## Risultati
|
## Risultati
|
||||||
|
|
||||||
@@ -15,18 +15,40 @@ Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di
|
|||||||
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
|
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
|
||||||
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
|
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
|
||||||
|
|
||||||
Dopo una validazione **out-of-sample, fee-aware** di tutte le famiglie, l'unica con
|
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie,
|
||||||
edge netto reale è il **mean-reversion** (i breakout *rientrano*, non continuano):
|
emergono quattro famiglie con edge netto reale, tutte radicate nella stessa lezione
|
||||||
|
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione:
|
||||||
|
|
||||||
| Codice | Strategia | Mercato | Edge OOS netto | Max DD | Robustezza |
|
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) |
|
||||||
|--------|-----------|---------|----------------|--------|------------|
|
|----------|-----------|-----------|---------------------|
|
||||||
| **MR01** | Bollinger Fade (mean-reversion) | BTC 1h | **+196 / +201%** | 15% | ✅ |
|
| **FADE** | mean-reversion intraday 1h (long/short, BTC/ETH) | MR01 Bollinger, MR02 Donchian, MR07 Return-reversal | Acc 52-55%, DD 18-34% |
|
||||||
| **MR01** | Bollinger Fade (mean-reversion) | ETH 1h | **+251%** | ~25% | ⚠️ DD alto |
|
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% |
|
||||||
|
| **PAIRS** | spread reversion *market-neutral* (2 gambe) | PR01 ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL | Sharpe 2.0-4.4, corr col mercato ~0.05 |
|
||||||
|
| **TSMOM** | time-series momentum multi-orizzonte | TSM01 (3/6/12m + risk-off) | diversificatore, DD 15-22% |
|
||||||
|
|
||||||
Netto dopo **fee realistiche Deribit 0.10% RT** (taker), leva 3x, pos 15%, su finestra
|
Tutti i numeri sono **netti** dopo fee realistiche (Deribit 0.10% RT single-leg, 0.20%
|
||||||
held-out (nov 2023→mag 2026). MR01 è positivo su **tutta** la griglia parametri
|
RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
|
||||||
(`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`) e per **ogni** livello di fee 0.00-0.20% RT —
|
parametri, sweep fee 0.00-0.20% RT e — per i pairs — validate con **walk-forward** e
|
||||||
margine di sicurezza ampio, niente parametro fortunato. Ri-validato col worker live reale.
|
config universale (niente cherry-picking).
|
||||||
|
|
||||||
|
### Portafoglio combinato (la vera leva anti-drawdown)
|
||||||
|
|
||||||
|
Le famiglie sono **quasi scorrelate fra loro** (~0.05). Combinandole in un unico
|
||||||
|
portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve:
|
||||||
|
|
||||||
|
| Portafoglio | CAGR | Max DD | Sharpe |
|
||||||
|
|-------------|------|--------|--------|
|
||||||
|
| FADE (6 sleeve) | ~46% | 8% | 3.9 |
|
||||||
|
| HONEST (3 sleeve) | ~46% | 13% | 2.2 |
|
||||||
|
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
|
||||||
|
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 |
|
||||||
|
|
||||||
|
> 🔎 **Numeri sobri (anti-overfit).** L'OOS singolo cade nel regime favorevole 2024-25:
|
||||||
|
> i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
|
||||||
|
> decisioni: **Sharpe atteso ~5**, **worst-drawdown su 90 giorni ~6%**, profilo che regge
|
||||||
|
> a leva 2x con slippage raddoppiato. Configurazione raccomandata: equal-weight, leva 2x,
|
||||||
|
> con un cap sull'allocazione ai pairs (~30-35%, poiché concentrano ~57% del rischio).
|
||||||
|
> Tutto resta da confermare nel paper trading live.
|
||||||
|
|
||||||
## Come funziona
|
## Come funziona
|
||||||
|
|
||||||
@@ -42,6 +64,21 @@ di prezzo **rientrano verso la media** più di quanto proseguano:
|
|||||||
|
|
||||||
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
|
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
|
||||||
|
|
||||||
|
### Le altre famiglie
|
||||||
|
|
||||||
|
- **FADE** (oltre MR01): MR02 fada la rottura del canale Donchian verso il centro;
|
||||||
|
MR07 fada il movimento di barra estremo misurato in deviazioni standard dei
|
||||||
|
rendimenti. Stessa logica di reversione, indicatori indipendenti.
|
||||||
|
- **HONEST** (long-only, multi-crypto): DIP01 compra i dip estremi e rivende al
|
||||||
|
recupero; TR01 segue il trend con incrocio di EMA su un paniere; ROT02 ruota ogni
|
||||||
|
giorno sui tre asset col momentum più forte, andando in cash quando BTC è sotto la
|
||||||
|
sua media (risk-off). Coprono i regimi di trend e rotazione, complementari alle fade.
|
||||||
|
- **PAIRS** (market-neutral): scommette sul rientro verso la media del log-ratio fra
|
||||||
|
due cripto (z-score). Long su una, short sull'altra: l'esposizione netta al mercato è
|
||||||
|
quasi nulla (correlazione ~0.02), il che la rende un diversificatore eccellente.
|
||||||
|
- **TSMOM**: tiene gli asset con momentum positivo persistente su più orizzonti
|
||||||
|
(3/6/12 mesi), con overlay risk-off. Rende meno ma è poco correlato, utile in ensemble.
|
||||||
|
|
||||||
### Perché lo squeeze breakout è stato abbandonato
|
### Perché lo squeeze breakout è stato abbandonato
|
||||||
|
|
||||||
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
|
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
|
||||||
@@ -70,20 +107,30 @@ PythagorasGoal/
|
|||||||
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
|
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
|
||||||
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
|
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
|
||||||
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
|
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
|
||||||
│ └── live/ # Paper trading live su Deribit testnet
|
│ ├── live/ # Paper trading live su Deribit testnet
|
||||||
│ ├── multi_runner.py # Orchestratore multi-strategia
|
│ │ ├── multi_runner.py # Orchestratore multi-strategia (strategie + pairs)
|
||||||
│ ├── strategy_worker.py # Worker indipendente con stato persistente
|
│ │ ├── strategy_worker.py # Worker single-leg con stato persistente
|
||||||
│ ├── strategy_loader.py # Import dinamico classi Strategy
|
│ │ ├── pairs_worker.py # Worker a 2 gambe per i pairs (market-neutral)
|
||||||
│ ├── cerbero_client.py # Client HTTP per Cerbero MCP
|
│ │ ├── strategy_loader.py # Import dinamico classi Strategy
|
||||||
│ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
|
│ │ ├── cerbero_client.py # Client HTTP per Cerbero MCP
|
||||||
│ └── telegram_notifier.py
|
│ │ ├── 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/
|
├── scripts/
|
||||||
│ ├── strategies/ # Strategie con edge validato OOS (solo MR01_bollinger_fade)
|
│ ├── strategies/ # Strategie con edge validato OOS (FADE, HONEST, PAIRS, TSMOM + portafogli)
|
||||||
│ ├── waste/ # Strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
|
│ ├── portfolios/ # Definizioni PORT01-06 e report run() dei portafogli di prima classe
|
||||||
│ └── analysis/ # Ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...)
|
│ ├── 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
|
├── strategies.yml # Config multi-strategy paper trader
|
||||||
├── data/
|
├── data/
|
||||||
│ └── raw/ # Parquet OHLCV (gitignored, ~70 MB)
|
│ ├── raw/ # Parquet OHLCV (gitignored, ~70 MB)
|
||||||
|
│ └── regime/ # DVOL + funding (Deribit mainnet) + cache feature regime (gitignored)
|
||||||
|
├── VERSION # versione semver (cotta nell'immagine, mostrata nei msg Telegram)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── diary/ # Diario di ricerca giornaliero
|
│ ├── diary/ # Diario di ricerca giornaliero
|
||||||
│ └── specs/ # Specifiche di design
|
│ └── specs/ # Specifiche di design
|
||||||
@@ -94,32 +141,67 @@ PythagorasGoal/
|
|||||||
|
|
||||||
## Strategie attive
|
## Strategie attive
|
||||||
|
|
||||||
Tutte le strategie estendono `src.strategies.base.Strategy` (`generate_signals() → backtest()`).
|
Le strategie single-asset estendono `src.strategies.base.Strategy`
|
||||||
|
(`generate_signals() → backtest()`); i pairs hanno un worker dedicato a 2 gambe.
|
||||||
|
|
||||||
| Codice | Script | Tipo | Descrizione |
|
| Codice | Script | Famiglia | Descrizione |
|
||||||
|--------|--------|------|-------------|
|
|--------|--------|----------|-------------|
|
||||||
| **MR01** | `MR01_bollinger_fade.py` | Mean-reversion | Fada la banda di Bollinger, TP alla media, SL ad ATR. Unica con edge netto validato OOS. |
|
| **MR01** | `MR01_bollinger_fade.py` | FADE | Fada la banda di Bollinger, TP alla media, SL ad ATR |
|
||||||
|
| **MR02** | `MR02_donchian_fade.py` | FADE | Fada la rottura del canale Donchian, TP al centro |
|
||||||
|
| **MR07** | `MR07_return_reversal.py` | FADE | Fada il movimento di barra estremo (z dei rendimenti) |
|
||||||
|
| **DIP01** | `DIP01_dip_reversion.py` | HONEST | Dip-buy long-only su z-score estremo |
|
||||||
|
| **TR01** | `TR01_ema_trend.py` | HONEST | EMA 20/100 trend-following su paniere cripto (4h) |
|
||||||
|
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) |
|
||||||
|
| **PR01** | `PR01_pairs_reversion.py` | PAIRS | Spread reversion market-neutral su 5 coppie |
|
||||||
|
| **TSM01** | `tsmom_research.py` | TSMOM | Time-series momentum multi-orizzonte + risk-off |
|
||||||
|
|
||||||
La famiglia squeeze (SQ01-04, ML01, MT01, PD01, CM01, AD01) è in `scripts/waste/`:
|
Le fade applicano due filtri di regime opzionali: un **filtro trend** (`trend_max`/`ema_long`,
|
||||||
edge storico = artefatto di look-ahead (vedi sezione *Come funziona*).
|
salta i segnali col prezzo troppo esteso rispetto alla EMA200) e un **loss-guard Hurst**
|
||||||
|
(`hurst_max=0.55`, salta i segnali in regime persistente/trending dove si concentrano gli stop-loss
|
||||||
|
— dimezza il drawdown del portafoglio, calcolato dalle sole close). Più un filtro `min_tp_frac`
|
||||||
|
che scarta i micro-scalp col take-profit entro il costo delle fee. Portafogli pronti: `PORT01`
|
||||||
|
(honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
|
||||||
|
|
||||||
Per eseguire il backtest della strategia:
|
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
|
||||||
|
CM01, AD01 — artefatto di look-ahead), MR03 Keltner (debole/ridondante con MR01) e
|
||||||
|
ROT01 (dominata da ROT02).
|
||||||
|
|
||||||
|
### Comandi utili
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Backtest di una strategia
|
||||||
uv run python scripts/strategies/MR01_bollinger_fade.py
|
uv run python scripts/strategies/MR01_bollinger_fade.py
|
||||||
```
|
uv run python scripts/strategies/PR01_pairs_reversion.py
|
||||||
|
|
||||||
Per la ricerca/validazione fee-aware out-of-sample:
|
# Ricerca e validazione fee-aware out-of-sample
|
||||||
|
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive fade
|
||||||
```bash
|
uv run python scripts/analysis/strategy_research_v2.py # MR02 / MR03 / MR07
|
||||||
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive MR01
|
|
||||||
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
||||||
uv run python scripts/analysis/validate_worker_mr01.py # replay del worker live su MR01
|
uv run python scripts/analysis/pairs_research.py # ricerca + verifica no-look-ahead dei pairs
|
||||||
|
|
||||||
|
# Gestione rischio, combinazione, report
|
||||||
|
uv run python scripts/analysis/risk_management.py # filtro trend + portafoglio fade
|
||||||
|
uv run python scripts/analysis/combine_portfolio.py # combinare fade + honest
|
||||||
|
uv run python scripts/analysis/combine_v2.py # master esteso con pairs + TSM01
|
||||||
|
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
|
||||||
|
|
||||||
|
# Validazione dei worker live (replay == backtest)
|
||||||
|
uv run python scripts/analysis/validate_worker_mr01.py # worker single-leg su MR01
|
||||||
|
uv run python scripts/analysis/validate_worker_pairs.py # worker a 2 gambe sui pairs
|
||||||
|
uv run python scripts/analysis/live_smoke_pairs.py # smoke test feed live reale dei pairs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Paper Trading Live
|
## Paper Trading Live
|
||||||
|
|
||||||
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ognuna con €1000 USDC virtuali indipendenti. Se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come MR01), il worker chiude su take-profit alla media / stop-loss ad ATR / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
|
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP,
|
||||||
|
ognuna con €1000 USDC virtuali indipendenti. Gestisce due tipi di worker:
|
||||||
|
|
||||||
|
- **Single-leg** (`strategy_worker.py`): per le strategie direzionali. Se un `Signal`
|
||||||
|
porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), chiude su take-profit /
|
||||||
|
stop-loss / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
|
||||||
|
- **Due gambe** (`pairs_worker.py`): per i pairs market-neutral. Apre long su una gamba
|
||||||
|
e short sull'altra, esce sul rientro dello z-score o per time-limit, conta le fee su
|
||||||
|
entrambe le gambe. Validato: il replay storico coincide *esattamente* col backtest.
|
||||||
|
|
||||||
### Avvio
|
### Avvio
|
||||||
|
|
||||||
@@ -141,19 +223,24 @@ defaults:
|
|||||||
position_size: 0.15
|
position_size: 0.15
|
||||||
leverage: 3
|
leverage: 3
|
||||||
|
|
||||||
strategies:
|
strategies: # strategie single-leg
|
||||||
- name: MR01_bollinger_fade
|
- name: MR01_bollinger_fade
|
||||||
asset: BTC
|
asset: BTC
|
||||||
tf: 1h
|
tf: 1h
|
||||||
enabled: true
|
enabled: true
|
||||||
params:
|
params: { bb_window: 50, k: 2.5, sl_atr: 2.0, max_bars: 24, trend_max: 3.0, ema_long: 200 }
|
||||||
bb_window: 50
|
|
||||||
k: 2.5
|
pairs: # strategie a 2 gambe (market-neutral)
|
||||||
sl_atr: 2.0
|
- name: PR01_pairs_reversion
|
||||||
max_bars: 24
|
a: ETH
|
||||||
|
b: BTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: { n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08 }
|
||||||
```
|
```
|
||||||
|
|
||||||
Per aggiungere una strategia: nuova riga in `strategies.yml`, poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
|
Per aggiungere una strategia: nuova riga in `strategies.yml` (sezione `strategies` o
|
||||||
|
`pairs`), poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
|
||||||
|
|
||||||
### Persistenza
|
### Persistenza
|
||||||
|
|
||||||
@@ -168,6 +255,71 @@ data/paper_trades/
|
|||||||
|
|
||||||
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
|
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
|
||||||
|
|
||||||
|
## Paper Trading a Portafoglio
|
||||||
|
|
||||||
|
Accanto al multi-strategy runner originale — in cui ogni strategia gestisce autonomamente il proprio conto virtuale da €1.000 — il progetto dispone ora di un **paper trader a portafoglio** (`src/portfolio/`) che tratta l'insieme delle strategie come un unico organismo con un capitale condiviso.
|
||||||
|
|
||||||
|
### Come funziona
|
||||||
|
|
||||||
|
La definizione di un portafoglio (`SleeveSpec` + schema di peso) ha due facce sulla stessa sorgente dati:
|
||||||
|
|
||||||
|
- **Backtest** (`.backtest()`): ricostruisce le equity-curve di ogni sleeve tramite il builder unificato in `sleeves.py`, le pondera secondo lo schema scelto e calcola le metriche aggregate (CAGR, Sharpe, max DD). La parità con i report prodotti da `report_families.py` è garantita dalla fonte unica.
|
||||||
|
- **Live** (`PortfolioRunner`): ogni ora il runner scarica le candele aggiornate via Cerbero v2, calcola i pesi correnti, avvia i worker appropriati per ogni sleeve attiva e registra il PnL aggregato nel ledger (`data/portfolios/{code}/`). Il ledger persiste tra i riavvii.
|
||||||
|
|
||||||
|
### Schemi di ponderazione
|
||||||
|
|
||||||
|
Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default), `cap` (tetto per famiglia — p.es. `pairs: 0.33` per limitare la concentrazione), `inverse_vol` (pesi inversamente proporzionali alla volatilità storica), `cluster_rp` (equal tra cluster naturali poi inverse-vol all'interno del cluster) e `manual` (pesi liberi). Lo schema si specifica in `portfolios.yml` insieme al codice portafoglio e alla leva.
|
||||||
|
|
||||||
|
### Portafoglio di default: PORT06
|
||||||
|
|
||||||
|
La configurazione raccomandata è **PORT06** (`scripts/portfolios/PORT06_master_shape.py`): portafoglio master esteso che include tutte e sei le famiglie (FADE, HONEST, PAIRS, TSMOM, SHAPE), con schema `cap` che limita i pairs al 33% del capitale per moderare la loro concentrazione di rischio. Risultati del backtest: Sharpe 6.07 (FULL) / 8.19 (OOS), drawdown massimo 4.9% (FULL) / 2.3% (OOS), leva 2×.
|
||||||
|
|
||||||
|
### Scope live
|
||||||
|
|
||||||
|
Il runner esegue **tutti e 17 gli sleeve** di PORT06: **fade** (MR01, MR02, MR07 × BTC/ETH),
|
||||||
|
**honest** (DIP01, TR01-basket 4h, ROT02-rotation 1d), **pairs** (PR01, cinque coppie),
|
||||||
|
**TSMOM** (TSM01 1d) e **shape** (SH01 × BTC/ETH). Worker dedicati: `StrategyWorker` (single-leg, fade/
|
||||||
|
dip/**shape**), `PairsWorker` (2 gambe), `BasketTrendWorker`, `RotationWorker`, `TsmomWorker`. Il runner
|
||||||
|
fetcha 1h da Cerbero v2 e resampla a 4h/1d; il pool di capitale, il ribilancio giornaliero e il ledger
|
||||||
|
sono validati == backtest.
|
||||||
|
|
||||||
|
> **SH01 (2026-06-01):** gira come `StrategyWorker` normale (il walk-forward è interno a
|
||||||
|
> `generate_signals`). Il vecchio `MLWorkerWrapper` usava il `SignalEngine` **squeeze scartato** —
|
||||||
|
> rimosso. **Loss-guard Hurst (2026-06-02):** le fade saltano i segnali in regime persistente
|
||||||
|
> (rolling-Hurst ≥ 0.55), dove si concentrano gli stop-loss — dimezza il drawdown del portafoglio
|
||||||
|
> (FULL 4.1%→2.4%; stop-loss fade −67% in numero, perdite totali −68%). Calcolato dalle sole close,
|
||||||
|
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade
|
||||||
|
> prima/dopo l'attivazione** e dà il verdetto automatico quando il campione è sufficiente.
|
||||||
|
|
||||||
|
### Versione & deploy
|
||||||
|
|
||||||
|
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
|
||||||
|
trade + report orario), così correli ogni messaggio al codice che l'ha generato. Il sorgente è **cotto
|
||||||
|
nell'immagine** → per aggiornare il live serve un **rebuild**, non un semplice restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh # bump patch (1.0.0 → 1.0.1) + commit + rebuild + ricrea container
|
||||||
|
./scripts/deploy.sh minor # 1.0.x → 1.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Il volume `data/` persiste tra i deploy → i worker fanno RESUME dello stato (capitale, posizioni aperte).
|
||||||
|
|
||||||
|
### Avvio del paper trader a portafoglio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backtest del portafoglio di default (PORT06)
|
||||||
|
uv run python scripts/portfolios/PORT06_master_shape.py
|
||||||
|
|
||||||
|
# Paper trading live a portafoglio
|
||||||
|
uv run python -m src.portfolio.runner
|
||||||
|
|
||||||
|
# Report orario su Telegram (stato + stop-rate fade prima/dopo loss-guard) — via cron
|
||||||
|
uv run python scripts/portfolios/hourly_report.py
|
||||||
|
|
||||||
|
# Smoke test del data layer Cerbero v2
|
||||||
|
uv run python scripts/analysis/smoke_portfolio.py
|
||||||
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -194,12 +346,41 @@ uv run python -m src.live.multi_runner
|
|||||||
|
|
||||||
## Dati
|
## Dati
|
||||||
|
|
||||||
| Asset | Timeframe | Candele | Copertura |
|
| Asset | Timeframe | Copertura |
|
||||||
|-------|-----------|---------|-----------|
|
|-------|-----------|-----------|
|
||||||
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
|
| BTC, ETH | 5m / 15m / 1h | 2018-01 → oggi |
|
||||||
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
|
| SOL, LTC, ADA, XRP, BNB, DOGE | 15m / 1h | 2019-2022 → oggi (variabile per asset) |
|
||||||
|
|
||||||
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback: Binance spot via ccxt. Formato: Apache Parquet.
|
Fonte primaria: perpetual Deribit via Cerbero MCP. Fallback: Binance spot via ccxt.
|
||||||
|
Formato: Apache Parquet (in `data/raw/`, gitignored).
|
||||||
|
|
||||||
|
> **Nota sul naming Deribit (per il feed live).** I major sono perpetui *inverse*
|
||||||
|
> (`BTC-PERPETUAL`, `ETH-PERPETUAL`); gli altcoin sono perpetui *lineari USDC*
|
||||||
|
> (`SOL_USDC-PERPETUAL`, `LTC_USDC-PERPETUAL`, …) con storia dal 2022. Attenzione:
|
||||||
|
> `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono e `SOL-PERPETUAL` restituisce dati
|
||||||
|
> errati — per gli altcoin usare sempre la forma `_USDC-PERPETUAL`.
|
||||||
|
|
||||||
|
### Discovery & validazione strumenti
|
||||||
|
|
||||||
|
`src/data/instruments.py` scopre e **valida** gli strumenti disponibili sugli
|
||||||
|
exchange implementati — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e
|
||||||
|
**Bybit**, feed testnet inaffidabile). Ogni perpetuo viene testato sui dati
|
||||||
|
storici realmente raccoglibili: esistenza, congruenza OHLC, contratto non-morto,
|
||||||
|
liquidità e **congruenza prezzo cross-exchange** (mediana per base-coin, tolleranza
|
||||||
|
5%) — così feed farlocchi e contratti sbagliati (es. `SOL-PERPETUAL`=9.6) vengono
|
||||||
|
scartati. Il risultato è `data/instruments_registry.json` (strumenti validi +
|
||||||
|
timeframe + data d'inizio).
|
||||||
|
|
||||||
|
**Solo gli strumenti validati possono essere scaricati**: il downloader ha un gate
|
||||||
|
(`_download_cerbero_range`) che rifiuta quelli non nel registry. Rigenera con:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m src.data.instruments
|
||||||
|
```
|
||||||
|
|
||||||
|
Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse); altcoin =
|
||||||
|
`<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale (testnet): Deribit 18/106
|
||||||
|
validi (major liquidi, BTC dal 2018), Hyperliquid 66/74.
|
||||||
|
|
||||||
## Riferimenti
|
## Riferimenti
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,17 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
paper-trader:
|
portfolio:
|
||||||
build: .
|
build: .
|
||||||
container_name: pythagoras-multi
|
container_name: pythagoras-portfolio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: ["uv", "run", "python", "-m", "src.portfolio.runner"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./strategies.yml:/app/strategies.yml:ro
|
- ./portfolios.yml:/app/portfolios.yml:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/paper_trades') for f in fs)"]
|
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/portfolios') for f in fs)"]
|
||||||
interval: 120s
|
interval: 120s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 2026-05-31 — Studio sugli EXIT delle fade: scalping, TP dinamico, TP-ATR
|
||||||
|
|
||||||
|
> Innescato da una domanda operativa ("un TP è stato raggiunto, non si poteva
|
||||||
|
> scalpare / fare un TP dinamico?"). Studio fee-aware su MR02 (Donchian fade,
|
||||||
|
> segnali invariati `n=20 sl_atr=2.0 max_bars=24`, fee 0.10% RT, leva 3x). Tre
|
||||||
|
> alternative di uscita misurate contro il baseline attuale (**TP = centro del
|
||||||
|
> canale**). Verdetto: **il design attuale è già ottimale; nessuna alternativa lo batte.**
|
||||||
|
|
||||||
|
## 1. "Scalping" = timeframe più veloce (15m vs 1h)
|
||||||
|
A fee 0.10% il 15m rende di più in lordo (~4× più trade), MA è molto più **fragile**:
|
||||||
|
|
||||||
|
| | trade | PnL @0% | @0.10% | @0.20% | DD @0.10% |
|
||||||
|
|---|--:|--:|--:|--:|--:|
|
||||||
|
| BTC 1h | 2041 | +22.768 | +16.645 | +10.522 | 29% |
|
||||||
|
| BTC 15m | 8251 | +65.286 | +40.533 | +15.780 | 29% |
|
||||||
|
| ETH 15m | 9388 | +120.103 | +91.939 | +63.775 | **62%** |
|
||||||
|
|
||||||
|
Da 0% a 0.20% il 15m perde **~76%** del profitto (vs 54% del 1h) e il DD esplode
|
||||||
|
(ETH 15m → 93% a 0.20%). 4× più trade = 4× più fee + slippage (non modellato, ma
|
||||||
|
peggiore su book sottili). **L'1h è scelto per il margine di sicurezza, non per il PnL
|
||||||
|
lordo.** Lo scalping vero (<0.3% target) è in pieno territorio "morte da fee".
|
||||||
|
|
||||||
|
## 2. TP dinamico / trailing ("lascia correre il vincitore")
|
||||||
|
Stessi segnali, exit per trailing a k·ATR dal massimo favorevole invece del TP fisso:
|
||||||
|
|
||||||
|
| policy | BTC win% | ETH win% | equity |
|
||||||
|
|--------|---------:|---------:|--------|
|
||||||
|
| FIXED (centro, attuale) | **48%** | **49%** | 🟢 di gran lunga il migliore |
|
||||||
|
| TRAIL (lascia correre) | 36% | 36% | 🔴 azzerato |
|
||||||
|
| MID+TRAIL | 47% | 47% | 🔴 peggio |
|
||||||
|
|
||||||
|
Il win-rate crolla 48%→36%: i trade che avrebbero incassato il TP fanno andata-e-ritorno
|
||||||
|
e stoppano fuori. **Concettuale:** l'edge della fade è la reversione *fino* alla media;
|
||||||
|
una volta toccata, l'edge è esaurito. Lasciar correre *oltre* = scommettere sulla
|
||||||
|
continuazione, che sui perp crypto NON ha edge (rientra). È la stessa logica per cui
|
||||||
|
SMA/ORB/WR (continuazione) hanno fallito: **let-it-run = trend = il lato perdente.**
|
||||||
|
|
||||||
|
## 3. TP scalato all'ATR (TP = entry + dir·m·ATR, SL fisso 2 ATR → R:R = m/2)
|
||||||
|
| Config | win% | avg %/trade | Sharpe | sumRet% |
|
||||||
|
|--------|-----:|-----------:|-------:|--------:|
|
||||||
|
| **BTC MID (attuale)** | 48% | **0.816** | **3.8** | **1664** |
|
||||||
|
| BTC ATR m=0.5 (RR0.25) | **77%** | −0.081 | −1.0 | −217 |
|
||||||
|
| BTC ATR m=1.0 | 67% | 0.192 | 1.6 | 465 |
|
||||||
|
| BTC ATR m=2.0 | 53% | 0.563 | 3.0 | 1199 |
|
||||||
|
| BTC ATR m=3.0 | 46% | 0.679 | 3.0 | 1331 |
|
||||||
|
| **ETH MID (attuale)** | 49% | **1.738** | **7.5** | **4169** |
|
||||||
|
| ETH ATR m=0.5 | 77% | 0.041 | 0.5 | 134 |
|
||||||
|
| ETH ATR m=3.0 | 46% | 1.082 | 4.7 | 2515 |
|
||||||
|
|
||||||
|
OOS (ultimo 30%) identico: **MID** batte ogni `m` (BTC MID avg 1.14/Sh 3.2; ETH MID avg
|
||||||
|
4.43/Sh 10.9). Due lezioni:
|
||||||
|
- **TP stretto (m=0.5) = trappola dello scalping quantificata:** win-rate **77%** ma edge
|
||||||
|
**zero/negativo** (BTC −0.08%/trade). I rari stop a 2 ATR spazzano via le micro-vincite,
|
||||||
|
la fee mangia il resto. **Win-rate alto ≠ edge.**
|
||||||
|
- **Nessun multiplo ATR fisso batte il centro del canale**, su avg/trade E Sharpe, FULL e OOS,
|
||||||
|
entrambi gli asset.
|
||||||
|
|
||||||
|
## Verdetto unificato
|
||||||
|
Il **TP al centro del canale è ottimale** perché è un target *adattivo alla struttura*: un
|
||||||
|
multiplo fisso di ATR misura solo *quanta* vol c'è, ma ignora *dove* sta la media; il centro
|
||||||
|
adatta al punto reale di reversione **ed è già scalato alla volatilità** (il canale si allarga
|
||||||
|
in regime volatile). Per una mean-reversion il punto giusto dove chiudere è **la media — niente
|
||||||
|
prima, niente dopo.** Tre alternative escluse coi numeri (15m, trailing, TP-ATR) → la scelta
|
||||||
|
di design corrente è blindata.
|
||||||
|
|
||||||
|
> Nota metodologica ricorrente: diffidare del **win-rate alto**. Il segnale vero è
|
||||||
|
> rendimento-medio-per-trade × Sharpe; un TP stretto regala win-rate e nasconde l'assenza
|
||||||
|
> di edge. (Stesso tranello dei guru: backtest cherry-picked ad alta % di vincite.)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 2026-05-31 — Stato trade LIVE PORT06 (paper trading)
|
||||||
|
|
||||||
|
> Snapshot verificato del paper trader a portafoglio (`src.portfolio.runner`, Docker
|
||||||
|
> `pythagoras-portfolio`). Dati da `data/portfolios/PORT06/` + log del container.
|
||||||
|
> Avvio container: 2026-05-29 18:37 UTC. Snapshot: 2026-05-31 13:20 UTC (~43h).
|
||||||
|
|
||||||
|
## Riepilogo capitale
|
||||||
|
| Metrica | Valore |
|
||||||
|
|---------|--------|
|
||||||
|
| Capitale iniziale | €1000.00 (17 sleeve equal-weight, ~€58.82 ciascuno) |
|
||||||
|
| `total_capital` (realizzato, ultimo rebal 00:00) | **€1000.09** (+0.09) |
|
||||||
|
| Equity mark-to-market (live) | **€1000.36** (+0.036%) |
|
||||||
|
| Peggior punto toccato | −€0.01 |
|
||||||
|
| **Max DD** | **0.40%** |
|
||||||
|
| Container | running, healthy, 0 restart |
|
||||||
|
|
||||||
|
## Trade chiusi (storia completa dallo startup: 10 trade, 9W/1L)
|
||||||
|
| # | Sleeve | Uscita | Net % | PnL € | Esito |
|
||||||
|
|---|--------|--------|------:|------:|:---:|
|
||||||
|
| 1 | PR01 ETH/SOL | mean_revert | +0.503 | +0.040 | W |
|
||||||
|
| 2 | PR01 ETH/SOL | mean_revert | +0.683 | +0.060 | W |
|
||||||
|
| 3 | SH01 BTC (ML) | hold_limit | −0.462 | −0.040 | L |
|
||||||
|
| 4 | SH01 BTC (ML) | hold_limit | +0.017 | +0.000 | W |
|
||||||
|
| 5 | PR01 ETH/SOL | mean_revert | +0.488 | +0.040 | W |
|
||||||
|
| 6 | PR01 ETH/SOL | mean_revert | +0.284 | +0.030 | W |
|
||||||
|
| 7 | PR01 LTC/ETH | mean_revert | +0.745 | +0.070 | W |
|
||||||
|
| 8 | PR01 BTC/LTC | mean_revert | +0.434 | +0.040 | W |
|
||||||
|
| 9 | MR02 ETH fade | take_profit | +0.995 | +0.090 | W |
|
||||||
|
| 10 | SH01 ETH (ML) | hold_limit | +0.742 | +0.070 | W |
|
||||||
|
| | **TOTALE** | | | **+0.400** | **90% win** |
|
||||||
|
|
||||||
|
### Aggregato per sleeve (trade chiusi)
|
||||||
|
| Sleeve | n | win | acc% | PnL € |
|
||||||
|
|--------|--:|----:|----:|------:|
|
||||||
|
| PR01 ETH/SOL | 4 | 4 | 100 | +0.170 |
|
||||||
|
| MR02 ETH fade | 1 | 1 | 100 | +0.090 |
|
||||||
|
| PR01 LTC/ETH | 1 | 1 | 100 | +0.070 |
|
||||||
|
| SH01 ETH (ML) | 1 | 1 | 100 | +0.070 |
|
||||||
|
| PR01 BTC/LTC | 1 | 1 | 100 | +0.040 |
|
||||||
|
| SH01 BTC (ML) | 2 | 1 | 50 | −0.040 |
|
||||||
|
|
||||||
|
Motore del PnL finora: **pairs PR01** (market-neutral, mean_revert rapidi 1-6 barre) +
|
||||||
|
una fade **MR02** su take_profit. Unica perdita: SH01 BTC (ML) su hold_limit (fisiologico,
|
||||||
|
edge nell'asimmetria, win-rate ~50%). Sleeve daily (ROT02/TSM01/TR01) e diverse fade non
|
||||||
|
hanno ancora chiuso trade (orizzonte più lungo / pochi segnali in ~2 giorni).
|
||||||
|
|
||||||
|
## Posizioni aperte (3)
|
||||||
|
| Sleeve | Dir | Entry | Capitale |
|
||||||
|
|--------|-----|------:|---------:|
|
||||||
|
| MR02 BTC fade | short | 73969.0 | €58.83 |
|
||||||
|
| MR02 ETH fade | long | 2016.15 | €58.92 |
|
||||||
|
| SH01 BTC (ML) | long | 73811.5 | €58.83 |
|
||||||
|
|
||||||
|
## Verifica (check 2026-05-31)
|
||||||
|
- **0 anomalie** sui 10 CLOSE: `net = gross − fee` rispettato, flag `win` coerente col
|
||||||
|
PnL, fee sempre presente (pairs 0.4% su 2 gambe, fade 0.10% RT).
|
||||||
|
- **Uscite = backtest**: tutti i CLOSE pairs sono `mean_revert` con **|z| ≤ 0.75** al close
|
||||||
|
(0.363/0.605/0.684/0.619/0.656) = esattamente `z_exit=0.75` di PR01; MR02 esce a TP al
|
||||||
|
livello. Il worker live replica la regola del backtest.
|
||||||
|
- **Riconciliazione**: +0.40 realizzato vs +0.09 `total_capital` NON è un errore — è il
|
||||||
|
timing del ribilancio giornaliero (`total_capital` snapshotta a 00:00, le posizioni
|
||||||
|
aperte restano sul notional fino al rebal; CLAUDE.md). L'equity MtM live (+0.36) è il
|
||||||
|
numero corrente, confermato da `equity.jsonl`.
|
||||||
|
|
||||||
|
## Lettura onesta
|
||||||
|
Campione minuscolo (**~2 giorni, 10 trade**) → il PnL (+€0.40 realizzato, +€0.36 MtM) è a
|
||||||
|
livello di **rumore**: non se ne deduce performance. Quello che il check conferma a questo
|
||||||
|
stadio è che il sistema è **sano e fedele**: esecuzione corretta, costi reali inclusi,
|
||||||
|
uscite conformi al backtest, DD trascurabile (0.40%), 0 errori/restart. L'edge si
|
||||||
|
manifesterà solo su orizzonte settimane/mesi. Monitor Docker attivo per down/unhealthy/restart.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 2026-05-31 — Copertura opzioni: idee testate e SCARTATE (record anti-ripetizione)
|
||||||
|
|
||||||
|
> Record delle conclusioni. Il **codice** di queste prove è stato testato e poi
|
||||||
|
> scartato (non conservato nel repo): qui restano i numeri e il *perché*, così da
|
||||||
|
> non ri-testare le stesse idee in futuro. Motore di pricing usato: Black-Scholes
|
||||||
|
> r=0 + IV stimata onestamente = RV × moltiplicatore VRP ≥ 1 (il compratore
|
||||||
|
> SOVRAPPAGA, come in W18-W21), fee Deribit reali (0.03%/gamba + ~0.10% slippage).
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
**La copertura opzioni non genera edge nuovo per questo progetto.** I due edge
|
||||||
|
disponibili (trend e mean-reversion) sono già catturati **50-100× più a buon
|
||||||
|
mercato dai perp** (fee 0.10% RT) di quanto facciano le opzioni (premio + VRP +
|
||||||
|
asimmetria coda). Comprare premio perde contro il VRP crypto; venderlo paga le
|
||||||
|
code grasse. Cappare la perdita su una strategia senza expectancy positiva limita
|
||||||
|
solo *quanto* perdi: **non esiste il pasto gratis "leva alta + perdite coperte".**
|
||||||
|
|
||||||
|
## 1. Overlay opzioni su PORT06 — non fattibile
|
||||||
|
Mismatch di orizzonte: l'edge di PORT06 è intraday (hold fade ~9h). Carry ATM 9h
|
||||||
|
≈ **0.96%** del notional vs edge fade per-trade **0.10-0.30%** → costo 3-10× l'edge.
|
||||||
|
La coda di PORT06 è già piccola (DD ~5%) e market-neutral (pairs ~57% del rischio):
|
||||||
|
poco da assicurare. La copertura giusta era già lì (diversificazione + stop), gratis.
|
||||||
|
|
||||||
|
## 2. Strategie nuove a copertura opzioni
|
||||||
|
- **OH01 — direzionale (TSMOM) + opzione protettiva / sola opzione.** Frontiera
|
||||||
|
iso-rischio: il **perp NON coperto domina a ogni livello di rischio** (Sharpe 0.90
|
||||||
|
vs 0.33-0.57; CAGR +33% vs negativo). Comprare protezione su un trend perde per il
|
||||||
|
carry/VRP (il trend-following è "long vol" nel *payoff*, non comprando opzioni).
|
||||||
|
- **OH02 — spread di credito su mean-reversion (vendi premio = VRP a favore).**
|
||||||
|
La copertura funziona (perdita cappata, DD basso, win-rate 73-80%: la reversione è
|
||||||
|
reale). Ma **expectancy ~0/leggermente negativa**: il 27% di trade dove il movimento
|
||||||
|
*continua* (code grasse) costa ~5× ogni vincita. Un trend filter porta solo *singole
|
||||||
|
celle* a +1-2% (overfit: config diversa per asset). Non robusto.
|
||||||
|
|
||||||
|
## 3. V5 — Bull Call Spread / debit spread (stile Casario)
|
||||||
|
È **la migliore struttura long-premium**: rischio definito funziona (worstRoll −13%
|
||||||
|
vs −64% della call secca, DD 54% vs 94%). **Ma net-negativo in crypto** (BTC −2.2%
|
||||||
|
full / −13.5% OOS) e il perp non coperto lo batte. Sweep larghezza: spread più larghi
|
||||||
|
rendono di più → **cappare l'upside toglie le code grasse che pagano il premio**.
|
||||||
|
**Verdetto:** valido **sulle AZIONI** (vol/VRP bassi, uptrend puliti da screener), NON
|
||||||
|
in crypto. Casario ha ragione nel suo dominio (equity), non trasferibile ai perp crypto.
|
||||||
|
|
||||||
|
## 4. V4 — Box strategy (max/min giorno prima, supply/demand) → SKIP
|
||||||
|
Core tradabile = **fadare gli estremi del canale = MR02** (già live). La candela di
|
||||||
|
conferma (doji/hammer/rejection) = pattern di rigetto = rumore (vedi diario TA). Nessun
|
||||||
|
edge nuovo: costruirlo ri-deriverebbe solo MR02.
|
||||||
|
|
||||||
|
## Cosa servirebbe per un vero edge a opzioni (fuori scope attuale)
|
||||||
|
Non direzione né reversione (già coperte dai perp), ma un edge *specifico delle opzioni*:
|
||||||
|
dislocazioni della superficie IV/skew, o gestione attiva (chiusura al 50% del credito,
|
||||||
|
roll). Richiede storico prezzi opzioni reale (qui assente, prezzi sintetici da BS) e un
|
||||||
|
feed greche/IV che il `CerberoClient` oggi non espone.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 2026-05-31 — 3 strategie TA "classiche": testate e SCARTATE (record anti-ripetizione)
|
||||||
|
|
||||||
|
> Record delle conclusioni. Codice testato e poi scartato (non conservato nel repo).
|
||||||
|
> Strategie da contenuti trading-guru: (1) SMA20/200 trend+pullback, (2) Opening Range
|
||||||
|
> Breakout "ironclad", (3) "Weakness rectangle" reversal (ICT). Testate con la
|
||||||
|
> metodologia onesta del progetto: ingresso eseguibile a `close[i]`, SL/TP intrabar,
|
||||||
|
> fee Deribit 0.10% RT, leva 3x, OOS(ultimo 30%), griglia robustezza, sweep fee.
|
||||||
|
|
||||||
|
## TL;DR — tutte e 3 NO EDGE (negative anche a fee ZERO)
|
||||||
|
Tutte e tre **direzionali/continuazione**, tutte negative su BTC/ETH, su tutta la
|
||||||
|
griglia, **anche a fee 0%** → il problema è il *segnale* (avg_R per-trade ≤ 0), non i
|
||||||
|
costi. Riconfermano la lezione centrale: *sui perp crypto i breakout/continuazione
|
||||||
|
rientrano; l'unico edge robusto è la mean-reversion.*
|
||||||
|
|
||||||
|
| Strategia | Tipo | avg_R @ fee0 | Motivo |
|
||||||
|
|-----------|------|--------------|--------|
|
||||||
|
| **SMA01** MA-pullback | continuazione | −0.15 BTC / −0.07 ETH | win ~30% (serve ~40% a R:R 2) |
|
||||||
|
| **ORB01** opening-range breakout | breakout | −0.10…−0.19 | crypto 24/7: manca l'asta d'apertura, ragione d'essere dell'ORB |
|
||||||
|
| **WR01** weakness rectangle | reversal→continuazione | ≈ −0.05/−0.00 | R:R "5:1" illusorio (win cala in proporzione); le weakness vengono travolte |
|
||||||
|
|
||||||
|
> Verificato indipendentemente (reimplementazione minima SMA01): a fee 0 avg_R
|
||||||
|
> −0.15/−0.07. Il −100% di CAGR è solo l'edge negativo composto a leva 3x su migliaia
|
||||||
|
> di trade, non un bug.
|
||||||
|
|
||||||
|
## Tentativo di MIGLIORAMENTO — ribaltarle sul lato fade
|
||||||
|
Miglioramento *di principio* (non tuning): visto che perdono perché sono continuazione,
|
||||||
|
ribaltate sul **fade** (l'unico lato con edge in crypto).
|
||||||
|
|
||||||
|
| versione fade | edge? (avg_R@fee0) | verdetto |
|
||||||
|
|---------------|--------------------|----------|
|
||||||
|
| **SMA02** fade dell'estensione→SMA20 | **+** (0.04…0.36) | = **MR01 inferiore** (FULL 1h negativo, Sharpe 0.4-0.9 vs 2.7+) |
|
||||||
|
| **ORB02** fade del breakout del range | **+** (win 35%→50-66%) | = **MR02/MR07** senza controlli di rischio (DD 90-100%) |
|
||||||
|
| **WR02** weakness come reversione | **≈0** | **rumore**, non una fade-family nascosta |
|
||||||
|
|
||||||
|
- Il flip restituisce segno positivo a 2/3 (riconferma *fade > continuazione*) **ma nulla
|
||||||
|
di additivo**: SMA02/ORB02 sono ri-scoperte inferiori di strategie già live; WR02 è rumore.
|
||||||
|
- **Ipotesi "SMA200 piatta = meglio fadare" SMENTITA**: il regime *range* non batte il fade
|
||||||
|
semplice; semmai il regime *trend* dà avg_R migliore ma con time-in-market 0.5-9%.
|
||||||
|
|
||||||
|
## Lezione metodologica
|
||||||
|
La prova del nove è l'**avg_R a fee 0**: se una strategia perde anche senza costi, il
|
||||||
|
problema è il segnale e nessun tuning la salva. Le strategie che funzionano restano
|
||||||
|
MR01/MR02/MR07 (fade) + PR01 (pairs) + PORT06 — l'edge è mean-reversion + diversificazione.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 2026-06-01 — Bugfix: SH01 usciva a 3 barre invece di H=12 (exit a orizzonte)
|
||||||
|
|
||||||
|
> Diagnosi partita da un check sulla debolezza apparente di **SH01_BTC** nel paper
|
||||||
|
> trading live PORT06 (accuratezza 33,3% su 3 trade). Non era sfortuna statistica:
|
||||||
|
> era un bug di exit nello `StrategyWorker`.
|
||||||
|
|
||||||
|
## Sintomo
|
||||||
|
|
||||||
|
Nel live PORT06 (Docker `pythagoras-portfolio`), SH01_BTC mostrava 3 trade tutti
|
||||||
|
`long`, **tutti chiusi con `reason: "hold_limit"` a `bars_held: 3`**, con `tp: null
|
||||||
|
sl: null`:
|
||||||
|
|
||||||
|
| # | entry | exit | bars | net % | esito |
|
||||||
|
|---|-------|------|------|------:|:---:|
|
||||||
|
| 1 | 73529.5 | 73433.0 | 3 | −0,46% | ❌ |
|
||||||
|
| 2 | 73759.5 | 73839.5 | 3 | +0,02% | ✅ |
|
||||||
|
| 3 | 73811.5 | 73766.0 | 3 | −0,32% | ❌ |
|
||||||
|
|
||||||
|
`oos_signal_precision` nei log di TRAIN scendeva 55,6% → 50,0% → 43,3%.
|
||||||
|
|
||||||
|
## Causa
|
||||||
|
|
||||||
|
SH01 (`scripts/strategies/SH01_shape_ml.py`, config **W24 H12 th0.58**) è una
|
||||||
|
strategia **horizon-only**: predice il segno del rendimento a **H=12 barre** ed esce a
|
||||||
|
H barre. I suoi Signal portano `metadata={"max_bars": H}` (=12) e **nessun TP/SL**.
|
||||||
|
|
||||||
|
Nello `StrategyWorker.tick()` la logica di uscita era:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.tp and self.sl: # SH01: False (tp=sl=0)
|
||||||
|
... usa self.max_bars ... # -> max_bars=12 consultato SOLO qui
|
||||||
|
elif self.bars_held >= self.hold_bars: # fallback legacy hold_bars=3
|
||||||
|
self._close_position(..., "hold_limit") # SH01 finiva QUI
|
||||||
|
```
|
||||||
|
|
||||||
|
`self.max_bars` (=12, settato correttamente in `_open_position`) era onorato **solo
|
||||||
|
dentro il ramo `tp and sl`**. Senza TP/SL, SH01 cadeva sul fallback `hold_bars=3` e
|
||||||
|
chiudeva a 3 barre. L'edge di SH01 — per CLAUDE.md è nell'**asimmetria sull'orizzonte
|
||||||
|
H, non nella frequenza** (win-rate ~50%) — non aveva tempo di realizzarsi: tagliato a
|
||||||
|
3/12, degenera in rumore.
|
||||||
|
|
||||||
|
Solo SH01 (BTC+ETH) era colpito: tutte le fade (MR01/MR02/MR07, DIP01) portano
|
||||||
|
tp+sl+max_bars e usano il ramo intrabar corretto.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
`src/live/strategy_worker.py`: aggiunto un ramo per l'exit a orizzonte puro, prima del
|
||||||
|
fallback `hold_bars`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif self.max_bars:
|
||||||
|
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
|
||||||
|
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
|
||||||
|
if self.bars_held >= self.max_bars:
|
||||||
|
self._close_position(current_price, "time_limit")
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fade restano invariate (entrano nel ramo `tp and sl`).
|
||||||
|
|
||||||
|
## Verifica
|
||||||
|
|
||||||
|
- Nuovo test `tests/portfolio/test_horizon_exit.py` (2 casi): con `max_bars=12` resta
|
||||||
|
in posizione a 3 barre; esce a 12 con `reason: "time_limit"` e `bars_held: 12`.
|
||||||
|
- Suite completa: **43 passed**.
|
||||||
|
- Container riavviato: **tutti i 17 sleeve RESUME puliti**, inclusa una posizione
|
||||||
|
SH01_ETH short aperta che ora seguirà l'exit a 12 barre.
|
||||||
|
|
||||||
|
## Atteso d'ora in poi
|
||||||
|
|
||||||
|
I trade SH01 nei log mostreranno `reason: "time_limit"` con `bars_held: 12` invece di
|
||||||
|
`hold_limit / 3`. Il 33% di accuratezza era un artefatto dell'exit prematuro; ora la
|
||||||
|
strategia gira sull'orizzonte su cui è validata (BTC OOS Sharpe 2,72, expanding).
|
||||||
|
Resta comunque un **diversificatore** del MASTER, non un motore di ritorno standalone.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
Il backtest di SH01 (`fade_base`/engine onesto) esce a H barre via `max_bars`; il
|
||||||
|
worker live deve replicarlo. Quando una strategia non porta TP/SL ma solo un
|
||||||
|
orizzonte, il fallback `hold_bars` del worker la **falsa silenziosamente**. Verificare
|
||||||
|
sempre che la convenzione di exit del worker live coincida con quella del backtest
|
||||||
|
validato — non solo l'ingresso.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 2026-06-01 — SH01 live eseguiva la strategia SBAGLIATA (squeeze scartato), non shape-ML
|
||||||
|
|
||||||
|
> Scoperto verificando perché SH01 continuava a chiudere a `hold_limit/3` **anche dopo**
|
||||||
|
> il rebuild col fix horizon-exit. Il fix era corretto ma in un **ramo morto**: SH01 live
|
||||||
|
> non passava da `StrategyWorker.tick()`.
|
||||||
|
|
||||||
|
## Sintomo
|
||||||
|
|
||||||
|
Dopo il deploy del fix SH01 (exit a H=12), un close SH01_BTC delle 12:00 era ancora
|
||||||
|
`reason=hold_limit bars=3` (perdita −1,27%). Il fix non aveva effetto sul path reale.
|
||||||
|
|
||||||
|
## Causa (bug di wiring, più grave del previsto)
|
||||||
|
|
||||||
|
`src/portfolio/runner.py` importava `MLWorkerWrapper` da **`src/live/multi_runner.py`** e
|
||||||
|
ci avvolgeva lo sleeve SH01:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if spec.kind == "ml":
|
||||||
|
return MLWorkerWrapper(worker, {"retrain_hours": 24})
|
||||||
|
```
|
||||||
|
|
||||||
|
Ma quel wrapper è **legacy, per la famiglia squeeze ML01** (scartata, vedi CLAUDE.md):
|
||||||
|
- usa `SignalEngine` = squeeze-detection + GradientBoosting (NON SH01_shape_ml);
|
||||||
|
- ha una `tick()` propria che apre con un `Signal` **nudo** (niente tp/sl/max_bars) ed
|
||||||
|
esce con `if bars_held >= hold_bars: close("hold_limit")` → ignora del tutto la
|
||||||
|
strategia caricata e il fix horizon.
|
||||||
|
|
||||||
|
Quindi lo sleeve "SH01" del portafoglio live **non eseguiva shape-ML**: eseguiva il
|
||||||
|
motore squeeze scartato. I log `TRAIN OK / oos_signal_precision` venivano da lì. Il
|
||||||
|
`worker` con strategy=SH01_shape_ml era costruito ma la sua `generate_signals` non
|
||||||
|
veniva **mai** chiamata.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
SH01 (`kind="ml"`) ora gira come **StrategyWorker normale**: `SH01_shape_ml.generate_signals`
|
||||||
|
fa il walk-forward (retraining) **internamente** ad ogni tick (`ml_wf_entries`) ed emette
|
||||||
|
`metadata.max_bars=H=12` → gli exit passano per `StrategyWorker.tick()` e il fix horizon
|
||||||
|
si applica davvero.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# runner.py: niente più MLWorkerWrapper per kind="ml"
|
||||||
|
return StrategyWorker(strategy=strategy, asset=spec.asset, tf=spec.tf, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lookback dati.** `ml_wf_entries` ha `train_min=4000` → servono ≥4000 barre 1h prima di
|
||||||
|
produrre segnali (con 90g/2160 barre → 0 segnali, runtime 0.01s — il falso "muto"). Le
|
||||||
|
candele 1h di BTC/ETH già arrivano a 440g (le richiede TSM01/ROT02 a 1d), ma per non
|
||||||
|
dipendere da quella coincidenza ho aggiunto `_ML_LOOKBACK_DAYS=365`: gli asset usati da
|
||||||
|
sleeve ml fetchano ≥365g (~8760 barre). Costo `generate_signals` su 365g: **0,17–0,24s**
|
||||||
|
(modello logit) → trascurabile sul poll 60s.
|
||||||
|
|
||||||
|
**Verifica.** Build SH01 → `StrategyWorker` con `strategy.name=="SH01_shape_ml"`, niente
|
||||||
|
attributo `engine` (regression test `test_build_ml_sh01_is_plain_strategyworker`). Smoke
|
||||||
|
su 365g: 766–1786 segnali, tutti `max_bars=12`; tick live 0,17s. `ml_wf_entries` non
|
||||||
|
predice mai l'ultima barra (`n-1`) ma fino a `n-2` = esattamente la condizione di apertura
|
||||||
|
del worker (`idx >= last_idx-1`) → apre quando il segnale è fresco. Suite: 51 passed.
|
||||||
|
|
||||||
|
**Stato live.** SH01 BTC/ETH erano flat: contatori resettati a 0 (capitale preservato
|
||||||
|
58,76/58,78), vecchi trade squeeze archiviati in `trades_squeeze_archive.jsonl`. Rebuild
|
||||||
|
+ recreate: 14 worker RESUME puliti, container healthy, nessun log TRAIN/squeeze, zero
|
||||||
|
errori.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
1. **Verificare il path REALE, non solo il codice del fix.** Il fix horizon era giusto ma
|
||||||
|
SH01 non lo attraversava. Un fix non testato end-to-end sul percorso vivo è un fix
|
||||||
|
presunto. (Mi ero fidato del rebuild senza confermare il reason dei close SH01.)
|
||||||
|
2. Riusare un wrapper legacy "perché c'è" è un rischio: `MLWorkerWrapper` di multi_runner
|
||||||
|
era per la famiglia squeeze scartata, non per shape-ML.
|
||||||
|
3. Un modello ML "muto" può essere solo **fame di dati** (train_min), non un bug logico:
|
||||||
|
controllare sempre la dimensione della finestra prima di concludere.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 2026-06-01 — "Win" che perdono: metrica netto-fee + filtro TP edge-minimo
|
||||||
|
|
||||||
|
> Partito da un'osservazione dell'utente sui trade live PORT06: **ci sono close con
|
||||||
|
> `win=True` ma `pnl` negativo**. Due problemi distinti, entrambi risolti.
|
||||||
|
|
||||||
|
## Problema 1 — la metrica `win` mentiva (lordo invece di netto)
|
||||||
|
|
||||||
|
In `strategy_worker.py::_close_position`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
trade_return = price_change * direction # LORDO, prima delle fee
|
||||||
|
net = trade_return * leverage - fee_rt * leverage
|
||||||
|
pnl = capital * position_size * net # corretto (netto)
|
||||||
|
is_win = trade_return > 0 # BUG: usa il LORDO
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_win` scattava appena il prezzo si muoveva di un soffio a favore, **prima delle
|
||||||
|
fee**. Capitale e PnL erano giusti (netti); solo la metrica `win`/`accuracy` era
|
||||||
|
gonfiata.
|
||||||
|
|
||||||
|
**Quantificazione (51 close live):** 39 win dichiarate (76,5%) → **13 falsi win**
|
||||||
|
(`win=True` ma `pnl≤0`) → accuratezza **netta reale 52,9%**. PnL realizzato +€0,77
|
||||||
|
(resta positivo: lo trascinano i pairs).
|
||||||
|
|
||||||
|
**Fix:** `is_win = net > 0`. + `tests/portfolio/test_win_net_of_fees.py` (mossa
|
||||||
|
sotto-fee = non win; oltre-fee = win; perdita = non win).
|
||||||
|
|
||||||
|
**Riconciliazione contatori persistiti:** i `total_wins` su disco erano gonfiati dal
|
||||||
|
vecchio conteggio lordo. Ricalcolati come `net_return>0` dai `trades.jsonl`:
|
||||||
|
**MR01_BTC 7→1, DIP01_BTC 7→1** (gli unici toccati; tutti gli altri già coerenti).
|
||||||
|
Capitale invariato.
|
||||||
|
|
||||||
|
## Problema 2 — i 13 falsi win erano tutti MR01_BTC / DIP01_BTC in take_profit
|
||||||
|
|
||||||
|
Causa: in `MR01_bollinger_fade` e `DIP01_dip_buy` il **TP è la media** (`tp = ma[i]`)
|
||||||
|
e l'entry è a `close[i]` appena fuori banda. Nel regime BTC **piatto** (inchiodato
|
||||||
|
~73.700 per ore, vol bassissima) la media è a pochi dollari dall'entry → il TP cade
|
||||||
|
**dentro** il costo round-trip (0,10%): colpire il TP = perdita netta garantita.
|
||||||
|
|
||||||
|
**Meccanismo del fix (importante).** "Spostare il TP più in là" NON garantisce di non
|
||||||
|
perdere: il prezzo rientra solo fino alla media, non oltre → si finirebbe su SL/time-
|
||||||
|
limit, perdendo di più. La mossa provabilmente non-perdente è un **filtro di edge
|
||||||
|
minimo**: se `|tp − entry|/entry ≤ min_tp_frac` non si apre la trade. Break-even
|
||||||
|
esatto = `fee_rt` (= 0,10%, indipendente dalla leva, perché
|
||||||
|
`ret = mossa·lev − fee_rt·lev > 0 ⇔ mossa > fee_rt`).
|
||||||
|
|
||||||
|
**Implementazione:** parametro `min_tp_frac` (default 0.0 = off) in **tutte le 4 fade**
|
||||||
|
(MR01 banda, MR02 midpoint canale, MR07 ATR-scaled) e DIP01; salta i segnali sotto
|
||||||
|
soglia. Cablato negli sleeve live a **0.0015 (1,5× fee)** in `_defs.py` (`MIN_TP_FRAC`).
|
||||||
|
|
||||||
|
**Validazione backtest (BTC+ETH 1h, config sleeve, min_tp_frac ∈ {0,.001,.0015,.002}):**
|
||||||
|
neutro su tutte e 4 le fade.
|
||||||
|
- MR01: 0 trade rimossi (BTC +8028€, ETH +10395€) — metriche identiche.
|
||||||
|
- DIP01 BTC: −1 trade a 0.002, **migliora** (+7492→+7522€, DD 26,3→25,9%).
|
||||||
|
- MR02 BTC: −1 trade a 0.0015 (pnl invariato +12198€), ETH 0 rimossi.
|
||||||
|
- MR07 BTC/ETH: 0 rimossi (TP ATR-scaled sempre ben oltre le fee nello storico).
|
||||||
|
|
||||||
|
Conclusione: i micro-scalp sotto-fee **non esistono nel campione storico** — sono un
|
||||||
|
artefatto del regime attuale. Il filtro è **puro upside**: neutro sul backtest validato,
|
||||||
|
protettivo dal vivo. (Le 12 trade live incriminate, tutte MR01/DIP01 BTC, avevano gap
|
||||||
|
~0,026%, ben sotto 0,15% → tutte bloccate.)
|
||||||
|
|
||||||
|
+ `tests/portfolio/test_min_tp_frac.py` (monotonia + ogni superstite ha gap > soglia
|
||||||
|
+ default-off invariato).
|
||||||
|
|
||||||
|
## Nota deploy — il codice è COTTO nell'immagine, non montato
|
||||||
|
|
||||||
|
Scoperta durante il deploy: `docker-compose.yml` monta solo `./data` e
|
||||||
|
`./portfolios.yml`; il sorgente (`src/`, `scripts/`) è `COPY` nel Dockerfile. Quindi
|
||||||
|
**`docker compose restart` NON ricarica le modifiche al codice** — serve
|
||||||
|
`docker compose up -d --build`. Conseguenza retroattiva: anche il fix SH01
|
||||||
|
horizon-exit di stamattina è andato live solo con questo rebuild. Da ricordare per ogni
|
||||||
|
futura modifica ai worker. Il volume `./data` persiste → i 14 worker fanno RESUME
|
||||||
|
puliti dopo il rebuild (capitale e posizioni intatti).
|
||||||
|
|
||||||
|
## Stato finale
|
||||||
|
|
||||||
|
- `is_win = net > 0` live; contatori riconciliati (MR01/DIP01 BTC 1/9).
|
||||||
|
- Filtro `min_tp_frac=0.0015` live su tutti i fade + DIP01 (attivo solo MR01/DIP01).
|
||||||
|
- Fix SH01 horizon-exit ora **effettivamente** live (rebuild).
|
||||||
|
- Suite: 49 passed. Container ricostruito, healthy, 14 sleeve in RESUME.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
1. Una metrica di "win" deve essere **netto fee**, altrimenti l'accuracy è teatro.
|
||||||
|
2. Quando il TP è dentro il costo di transazione, la trade è persa in partenza: meglio
|
||||||
|
**non prenderla** che ritoccare il TP.
|
||||||
|
3. Per i worker live in Docker: **rebuild**, non restart. Il restart ricarica solo lo
|
||||||
|
stato dal volume, non il codice.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 2026-06-02 — Loss-guard per le fade: filtro Hurst (regime persistente)
|
||||||
|
|
||||||
|
> Goal: limitare le perdite delle fade in "bassa vol". Diagnosi empirica + ricerca web + workflow
|
||||||
|
> 11 agenti + test decisivo a livello PORT06. Branch `feat/fade-lossguard`.
|
||||||
|
|
||||||
|
## Riformulazione del problema (la premessa era imprecisa)
|
||||||
|
|
||||||
|
Diagnosi su 3022 trade fade (MR01/MR02/MR07 × BTC/ETH, 2021+): **le perdite NON si concentrano in
|
||||||
|
bassa vol** — anzi il terzile low-DVOL è net positivo (+2,30%/trade). Il vero driver è il **regime
|
||||||
|
PERSISTENTE/trending**, misurato dall'Hurst:
|
||||||
|
- somma perdite peggiore: **hurst>0,55** (−2695% in low-vol, dominante in ogni terzile vol)
|
||||||
|
- **stop-rate 43% per hurst>0,55 vs 21% per hurst<0,45** (anti-persistente) — 2x
|
||||||
|
- peggiori 1% trade: Hurst medio 0,61 (77% con hurst>0,55, solo 13% in bassa-DVOL)
|
||||||
|
|
||||||
|
## Ricerca web (confermata e smentita dai dati reali)
|
||||||
|
- **Hurst regime filter** (MR solo H<0,45, evitare H>0,55): **CONFERMATO** sui dati reali. ✅
|
||||||
|
- **ADX** (PF 1,62 sotto 20 vs −0,74 sopra 30): **NON si replica** — ADX-skip uccide l'edge
|
||||||
|
(Sharpe 4,82→0,99) e lo stop-rate non scende. ❌
|
||||||
|
- **vol-expansion ATR-ratio>1,5 (−72% perdite)**: **NON si replica** — alza DD e stop-rate. ❌
|
||||||
|
- **time-stop ~15 barre**: riduce stop-rate ma alza il DD full → non passa standalone. ❌
|
||||||
|
|
||||||
|
## Workflow 11 agenti — meccanismi testati
|
||||||
|
| Meccanismo | OOS Sharpe (base→filt) | DD full | Buon loss-guard? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Hurst-SKIP h<0,55** | 4,82→4,96 ↑ | 24,3→13,8% ↓ | **SÌ** |
|
||||||
|
| **Hurst-SIZE 1/0,5/0,25** | 4,65→5,32 ↑ (full) | 33,6→11,3% maxDD ↓ | **SÌ** |
|
||||||
|
| ADX-skip | 4,82→0,99 ✗ | — | NO (uccide edge) |
|
||||||
|
| vol-expansion vratio | 4,82→4,04 | 24,3→27,5% ✗ | NO |
|
||||||
|
| Kaufman ER, time-stop, vol-target, DVOL-rising, combo | tutti ↓ o DD↑ | — | NO |
|
||||||
|
|
||||||
|
**Solo l'Hurst** isola chirurgicamente il regime tossico; gli altri sono "dimmer uniformi" che
|
||||||
|
tagliano winner insieme ai loser (gate FR01 fallito).
|
||||||
|
|
||||||
|
## TEST DECISIVO a livello PORT06 — SUPERATO ✅
|
||||||
|
|
||||||
|
Applicato l'Hurst-skip alle 6 fade dentro il PORT06 intero (equal-weight, le altre 11 sleeve
|
||||||
|
invariate):
|
||||||
|
|
||||||
|
| Portafoglio | FULL Sharpe | FULL DD | OOS Sharpe | OOS DD | OOS ret |
|
||||||
|
|-------------|:--:|:--:|:--:|:--:|:--:|
|
||||||
|
| PORT06 baseline | 6,62 | 4,10% | 8,89 | 1,22% | +175% |
|
||||||
|
| **+ Hurst-skip h<0,55** | **6,76** | **2,39%** | **9,15** | 1,54% | +158% |
|
||||||
|
| + Hurst-skip h<0,50 | 6,61 | 2,08% | 9,02 | 1,54% | +150% |
|
||||||
|
|
||||||
|
**A differenza di FR01 (che diluiva), il filtro Hurst MIGLIORA il PORT06**: FULL Sharpe ↑, **FULL
|
||||||
|
DD quasi dimezzato (4,10→2,39%)**, OOS Sharpe ↑ (8,89→9,15). Costo: OOS DD +0,3pp (resta minuscolo),
|
||||||
|
OOS ret −17pp. **h<0,55 è il pick** (0,50 taglia più ritorno). Non aumenta il profitto: è puro
|
||||||
|
**rischio** — dimezza il DD mantenendo/alzando lo Sharpe.
|
||||||
|
|
||||||
|
## Implementazione
|
||||||
|
Aggiunto `hurst_skip_mask` in `src/strategies/fade_base.py` (rolling-Hurst causale dalle SOLE close)
|
||||||
|
+ parametro `hurst_max` (default None=off) in MR01/MR02/MR07. Test: `test_hurst_lossguard.py`.
|
||||||
|
|
||||||
|
**Vantaggio operativo decisivo vs FR01:** l'Hurst si calcola **dalle sole close** → nessun feed
|
||||||
|
DVOL/regime live necessario. Lo `StrategyWorker` lo computa inline dai dati che già ha → **deployabile
|
||||||
|
senza nuova infrastruttura**, basta settare `hurst_max: 0.55` nei params degli sleeve fade.
|
||||||
|
|
||||||
|
## Da fare per attivarlo live (deploy)
|
||||||
|
1. Settare `hurst_max: 0.55` nei params delle fade in `_defs.py` (sleeve live) + aggiornare i params
|
||||||
|
fade del backtest (`combine_portfolio`/`report_families`) per PARITÀ + rigenerare il
|
||||||
|
regression-lock PORT06 (i numeri canonici cambiano: DD 4,9→~2,4%).
|
||||||
|
2. Verificare che il rolling-Hurst live nel worker coincida col backtest (stessa finestra 100,
|
||||||
|
stesso stepping causale).
|
||||||
|
3. Rebuild immagine Docker (`up -d --build`, non restart) + verifica RESUME.
|
||||||
|
|
||||||
|
Default attuale: `hurst_max` OFF → zero impatto su backtest/parità/live finché non lo si attiva
|
||||||
|
esplicitamente. Il SISTEMA è trovato e validato; l'attivazione è una decisione di deploy.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 2026-06-02 — Ricerca a 100 agenti: Frattali del segnale × Regime ARGO
|
||||||
|
|
||||||
|
> Workflow multi-agente (171 agenti, 8,4M token, ~7h) per cercare una strategia che combini
|
||||||
|
> un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME ARGO (DVOL/funding/VRP), validata OOS.
|
||||||
|
> Branch: `feat/fractal-argo-search`. Substrato e codice sul branch, niente impatto su main/live.
|
||||||
|
|
||||||
|
## Substrato costruito
|
||||||
|
- `regime_fetcher.py`: DVOL (2021-03→oggi) + funding (2019→oggi) BTC/ETH da **Deribit mainnet
|
||||||
|
public** (no-auth, OI/IV reali — non il testnet farlocco di Cerbero).
|
||||||
|
- `regime_lab.py`: allineamento regime↔prezzo **causale no-look-ahead** (merge_asof backward),
|
||||||
|
feature regime (dvol_pct, **vrp=dvol−rv**, funding_z, dvol_chg) + frattali (rolling Hurst,
|
||||||
|
Higuchi FD, vol-ratio, Williams pivot), cache feature precalcolate, validazione netto-fee OOS
|
||||||
|
via `explore_lab`. Bug corretto in corsa: `vrp` annualizzava la realized-vol sempre come 1h →
|
||||||
|
rotta su 4h/1d (sempre negativa); fix per timeframe.
|
||||||
|
- `fractal_argo_workflow.js`: 84 agenti griglia (7 famiglie frattali × 6 angoli regime × BTC/ETH)
|
||||||
|
+ 8 wildcard + verifica avversariale dei survivor + sintesi.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**Esistono edge frattale×regime reali, causali, robusti** (15 confermati dalla batteria
|
||||||
|
avversariale: audit look-ahead bit-esatto, cross-asset, split alternativo, fee 0.2% RT, plateau).
|
||||||
|
**MA nessuno batte PORT06 standalone** (OOS Sharpe 8,19 / DD 2,3%): sono **diversificatori a
|
||||||
|
bassa esposizione** (1,5-8%, ~100-460 trade), profilo SH01/pairs.
|
||||||
|
|
||||||
|
### Top candidati confermati
|
||||||
|
| Strategia | Asset/TF | OOS Sharpe | OOS DD | trade |
|
||||||
|
|-----------|----------|:--:|:--:|:--:|
|
||||||
|
| FRAC-VRP multiscala (chop × VRP<0) | ETH 1h | 5,55 | 11% | 184 |
|
||||||
|
| HigVRP-Fade (Higuchi alto × VRP<0) | BTC 1h | 4,55 | 7,2% | 286 |
|
||||||
|
| **HurstCalmFade (hurst<0,55 × dvol<0,4)** | BTC 1h | **3,73** | 5,1% | 198 |
|
||||||
|
| WILD8 Pivot-Hurst | BTC 4h | 3,87 | 10% | 482 |
|
||||||
|
| AnalogFundingFade (kNN forma × funding) | ETH 4h | 2,15 | 9,3% | 229 |
|
||||||
|
|
||||||
|
## Il finding chiave (controintuitivo): il prior ARGO è SMENTITO
|
||||||
|
|
||||||
|
La tesi naïve **"VRP>0 = GEX+ = range = fade" FALLISCE** sistematicamente. L'edge robusto e
|
||||||
|
ripetuto è su **VRP<0** (vol implicita *sottoprezzata* vs realizzata → mean-reversion whippy) e su
|
||||||
|
**DVOL bassa**, l'opposto del brief. Gate invertito VRP>0 → Sharpe −2,08/−1,30 su entrambi gli
|
||||||
|
asset. È il risultato più solido di tutta la ricerca, look-ahead-clean (lag-1/3/6 robusto).
|
||||||
|
|
||||||
|
## Cosa aggiunge valore vs cosa è decorativo
|
||||||
|
- **Load-bearing (confermato per ablazione):** VRP<0; hurst-low × dvol-low (HurstCalmFade taglia
|
||||||
|
DD OOS 17%→5%); funding_z estremo |fz|≥1,8 (analog ETH 4h).
|
||||||
|
- **Decorativo (DD-reducer, non interazione):** dvol_chg, dvol_high/low e funding come gate spesso
|
||||||
|
riducono solo esposizione senza migliorare il segno.
|
||||||
|
|
||||||
|
## Cosa è RUMORE (conferma i priori del progetto)
|
||||||
|
- Frattali da soli (angolo=none): hurst/Higuchi/vratio/chop/candle/analog intraday → non robusti
|
||||||
|
(DD 30-90%, muoiono di fee). Conferma `shape_lab`.
|
||||||
|
- Momentum/breakout gateato (hurst>0,6, dvol-rising): catastrofico (Sharpe −2…−7) → riconferma
|
||||||
|
dominanza mean-reversion, i breakout rientrano.
|
||||||
|
- ARGO-GEX nella direzione attesa (VRP>0): perde. Coerente con W18/19/21 scartate.
|
||||||
|
- Pivot-fade non-laggato (frac_up[i]): artefatto squeeze-like, va sempre laggato a i−2.
|
||||||
|
|
||||||
|
## Vincitore selezionato + test decisivo
|
||||||
|
|
||||||
|
**FR01 HurstCalmFade BTC 1h** (`scripts/strategies/FR01_hurst_calm_fade.py`): il più verificato,
|
||||||
|
DD più basso (5,1% OOS), generalizza a ETH. **Test di correlazione decisivo** (la domanda che
|
||||||
|
conta: aggiungerlo migliora il PORT06 o è ridondante?): correlazione daily-returns coi fade
|
||||||
|
esistenti **MR01 +0,17 / MR02 +0,08 / MR07 −0,03** → **BASSA, quasi-ortogonale**, NON ridondante.
|
||||||
|
Passa il gate → vale l'inserimento come diversificatore.
|
||||||
|
|
||||||
|
## Onestà finale
|
||||||
|
L'edge frattale×regime è **reale, causale, robusto** ma è sempre **mean-reversion già nota
|
||||||
|
condizionata dal regime (VRP<0 / hurst-low / dvol-low)**, non un motore ortogonale nuovo. Valore =
|
||||||
|
**riduzione DD aggregato come sleeve a bassa esposizione**. La correlazione bassa lo qualifica come
|
||||||
|
diversificatore reale.
|
||||||
|
|
||||||
|
## TEST DECISIVO SUL MASTER — VERDETTO FINALE: NON deployare
|
||||||
|
|
||||||
|
Misurato il contributo marginale di FR01 al PORT06 intero (equal-weight, `master_corr`):
|
||||||
|
|
||||||
|
| Portafoglio | FULL Sharpe | OOS Sharpe | OOS DD | OOS ret |
|
||||||
|
|-------------|:--:|:--:|:--:|:--:|
|
||||||
|
| PORT06 (17 sleeve) | 6,62 | **8,89** | 1,2% | +175% |
|
||||||
|
| PORT06 + FR01 (19) | 6,55 | **8,72** | 1,1% | +156% |
|
||||||
|
|
||||||
|
**FR01 NON migliora il PORT06: lo DILUISCE** (OOS Sharpe 8,89→8,72, OOS ret +175%→+156%; DD
|
||||||
|
marginalmente meglio 1,2→1,1% ma a costo di Sharpe). Corr FR01 vs MASTER +0,18 (BTC)/+0,23 (ETH).
|
||||||
|
|
||||||
|
**Causa + nota di onestà metrica:** lo Sharpe "3,73" dei report del workflow è **per-trade/annuale**
|
||||||
|
(`explore_lab`); quello rilevante per il portafoglio è lo **Sharpe daily-return** (`combine`), che per
|
||||||
|
FR01 è solo **~1,85/1,53** — troppo basso per muovere un PORT06 a 8,89. È "ridondanza robusta":
|
||||||
|
mean-reversion regime-gated che si sovrappone a ciò che il MASTER già fa.
|
||||||
|
|
||||||
|
**ESITO: il search a 100 agenti ha trovato strategie robuste e causali, ma NESSUNA migliora il
|
||||||
|
PORT06.** Non deployare FR01 né i candidati gemelli. Valore del progetto resta nell'estendere
|
||||||
|
fade/pairs validati. Tutto resta come RECORD DI RICERCA sul branch (non si merge in produzione).
|
||||||
|
Wiring DVOL live e walk-forward: non necessari, deploy abbandonato.
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
# Fase 2-B — Worker live honest/TSM01 (dedicati) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development o executing-plans. Steps con checkbox `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Costruire i worker live mancanti perché PORT06 giri live al completo (oltre a fade+pairs+shape già pronti): DIP01, TR01 (basket), ROT02 (rotation), TSM01 (tsmom rotation), e integrarli nel `PortfolioRunner`.
|
||||||
|
|
||||||
|
**Architecture:** Worker DEDICATI per ogni strategia (scelta utente). DIP01 è single-asset → Strategy subclass + `StrategyWorker` esistente. TR01/ROT02/TSM01 sono multi-asset/rotation → tre classi worker nuove in `src/live/` con stato per-asset persistente, ciascuna fedele alla rispettiva funzione di backtest in `scripts/analysis/{honest_improve2,tsmom_research}.py`. Integrazione in `src/portfolio/runner.py::build_worker_for` + tick.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11, pandas/numpy, pytest. Riusa CerberoClient v2 (multi-asset fetch), PortfolioLedger, e le funzioni di riferimento honest/tsm.
|
||||||
|
|
||||||
|
**Branch:** `portfolio_phase2`. **Spec madre:** `docs/superpowers/specs/2026-05-29-portfolios-design.md` (§ scope live, fase 2).
|
||||||
|
|
||||||
|
**Riferimenti di logica (NON modificare, sono la verità del backtest):**
|
||||||
|
- DIP01 → `honest_improve2.dip_market_gated` (z-score dip, gate BTC>SMA, TP=SMA/SL=ATR/max_bars, intrabar).
|
||||||
|
- TR01 → `honest_improve2._tr_basket_daily` (per asset 4h: EMA20>EMA100 long/flat; basket equal-weight).
|
||||||
|
- ROT02 → `honest_improve2._rot_daily_equity` (panel 1d, mom 60g, top-3 se mom>0 e BTC>SMA100, gross 0.45 split, ribilancio giornaliero).
|
||||||
|
- TSM01 → `tsmom_research.tsmom_sim` (panel 1d, Σ sign(P/P[-h]) h∈{63,126,252} ≥ thr=1.0, gate BTC>SMA100, gross 0.30 split).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Responsabilità |
|
||||||
|
|------|----------------|
|
||||||
|
| `scripts/strategies/DIP01_dip_buy.py` | Strategy `Dip01DipBuy` (single-asset; metadata tp/sl/max_bars + gate) |
|
||||||
|
| `src/live/basket_trend_worker.py` | `BasketTrendWorker` (TR01): N asset 4h, EMA cross, long/flat per asset |
|
||||||
|
| `src/live/rotation_worker.py` | `RotationWorker` (ROT02): panel 1d, dual-momentum top-k, gross split |
|
||||||
|
| `src/live/tsmom_worker.py` | `TsmomWorker` (TSM01): panel 1d, consenso segni multi-orizzonte |
|
||||||
|
| `src/live/strategy_loader.py` | **mod**: aggiungi `DIP01_dip_buy` a MODULE_MAP |
|
||||||
|
| `src/portfolio/runner.py` | **mod**: `build_worker_for` gestisce kind "basket"/"rotation"/"tsmom"; tick multi-asset |
|
||||||
|
| `src/portfolio/base.py` (`_defs.py`) | **mod**: SleeveSpec degli honest/tsm con `kind` e `universe` corretti |
|
||||||
|
| `tests/portfolio/test_honest_workers.py` | unit per ciascun worker + replay==backtest su finestra |
|
||||||
|
|
||||||
|
**Universi:** TR01 = [BNB,BTC,DOGE,SOL,XRP] (4h); ROT02/TSM01 = `available_assets()` (1d). I worker multi-asset ricevono il dict {asset: df} dal runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: DIP01 come Strategy single-asset
|
||||||
|
|
||||||
|
**Files:** Create `scripts/strategies/DIP01_dip_buy.py`; Modify `src/live/strategy_loader.py`; Test `tests/portfolio/test_dip01.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — `tests/portfolio/test_dip01.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from scripts.strategies.DIP01_dip_buy import Dip01DipBuy
|
||||||
|
|
||||||
|
|
||||||
|
def test_dip01_generates_long_signals_with_exits():
|
||||||
|
df = load_data("BTC", "1h").iloc[-5000:].reset_index(drop=True)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = Dip01DipBuy().generate_signals(df, ts, asset="BTC", tf="1h")
|
||||||
|
assert len(sigs) > 0
|
||||||
|
s = sigs[0]
|
||||||
|
assert s.direction == 1 # dip-buy è solo long
|
||||||
|
assert {"tp", "sl", "max_bars"} <= set(s.metadata)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_dip01.py -v` → FAIL (ModuleNotFoundError).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementa `scripts/strategies/DIP01_dip_buy.py`.** Replica ESATTA della logica di `dip_market_gated` (default `market_n=0` = senza gate, come lo sleeve DIP01_BTC del portafoglio: vedi combine_portfolio che usa `market_n=0`). Genera Signal long quando `z[i] <= -z_in and z[i-1] > -z_in`, con metadata `tp=SMA[i]`, `sl=c[i]-sl_atr*atr[i]`, `max_bars`. fee_rt=0.001, leverage 3, position 0.15.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""DIP01 — Dip-buy mean-reversion single-asset (z-score sotto-banda). Honest family.
|
||||||
|
|
||||||
|
Replica live della logica validata in scripts/analysis/honest_improve2.dip_market_gated
|
||||||
|
(con market_n=0, come lo sleeve DIP01_BTC del portafoglio): compra quando lo z-score del
|
||||||
|
prezzo rispetto a SMA(n) incrocia sotto -z_in; esce a TP=SMA, SL=close-sl_atr*ATR, o max_bars.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.strategies.base import Strategy, Signal # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df, n=14):
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
return pd.Series(tr).rolling(n).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
class Dip01DipBuy(Strategy):
|
||||||
|
name = "DIP01_dip_buy"
|
||||||
|
description = "Dip-buy mean-reversion single-asset (z-score), exit TP=SMA/SL=ATR/max_bars"
|
||||||
|
default_assets = ["BTC"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
fee_rt = 0.001
|
||||||
|
leverage = 3.0
|
||||||
|
position_size = 0.15
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||||
|
n: int = 50, z_in: float = 2.5, sl_atr: float = 2.5,
|
||||||
|
max_bars: int = 24, **params) -> list[Signal]:
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = _atr(df, 14)
|
||||||
|
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
out: list[Signal] = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||||
|
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
|
||||||
|
metadata={"tp": float(ma[i]),
|
||||||
|
"sl": float(c[i] - sl_atr * a[i]),
|
||||||
|
"max_bars": int(max_bars)}))
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Registra nel loader.** In `src/live/strategy_loader.py` MODULE_MAP aggiungi:
|
||||||
|
```python
|
||||||
|
"DIP01_dip_buy": ("DIP01_dip_buy", "Dip01DipBuy"),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:** `uv run pytest tests/portfolio/test_dip01.py -v` → 1 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add scripts/strategies/DIP01_dip_buy.py src/live/strategy_loader.py tests/portfolio/test_dip01.py
|
||||||
|
git commit -m "feat(live): DIP01 dip-buy come Strategy single-asset (worker via StrategyWorker)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** DIP01 nel runner usa lo StrategyWorker esistente (kind="single", name="DIP01"). Aggiorna `_STRAT_MODULE` in `runner.py` con `"DIP01": "DIP01_dip_buy"` e in `_defs.py` lo SleeveSpec DIP01_BTC resta kind="single". Il backtest dello sleeve DIP01_BTC continua a venire da `build_everything` (parità invariata).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `BasketTrendWorker` (TR01)
|
||||||
|
|
||||||
|
**Files:** Create `src/live/basket_trend_worker.py`; Test `tests/portfolio/test_basket_worker.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — verifica che, dato un dict {asset: df 4h}, il worker calcoli posizione long/flat per asset secondo EMA20>EMA100 e aggiorni il capitale equal-weight:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.basket_trend_worker import BasketTrendWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _ramp_df(n=300, slope=1.0):
|
||||||
|
c = np.linspace(100, 100 + slope * n, n)
|
||||||
|
ts = (pd.date_range("2024-01-01", periods=n, freq="4h", tz="UTC").astype("int64") // 10**6)
|
||||||
|
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_basket_goes_long_in_uptrend(tmp_path):
|
||||||
|
w = BasketTrendWorker(universe=["AAA", "BBB"], tf="4h", capital=1000.0, data_dir=tmp_path)
|
||||||
|
data = {"AAA": _ramp_df(slope=1.0), "BBB": _ramp_df(slope=1.0)}
|
||||||
|
w.tick(data)
|
||||||
|
assert w.positions["AAA"] == 1.0 and w.positions["BBB"] == 1.0 # EMA20>EMA100 in salita
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementa `src/live/basket_trend_worker.py`.** Stato: capitale totale + dict `positions` (asset→0/1) + persistenza. `tick(data: dict[str,df])`: per ogni asset calcola EMA20/EMA100 sull'ultima barra; target = 1.0 se ef>es else 0.0; applica fee `FEE_RT/2*LEV` sul turnover |Δpos|; aggiorna capitale equal-weight col rendimento di barra di ogni asset attivo (`POS*LEV*ret*pos/len(universe)`... mantieni la convenzione di `_tr_basket_daily`: ogni asset è uno sleeve normalizzato, equal-weight → applica `mean` dei rendimenti per-asset). Persisti `status.json` (capitale, positions, last_bar_ts per asset) e logga `trades.jsonl`. fee_rt=0.001, leverage 3, position 0.15.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""BasketTrendWorker (TR01): EMA20>EMA100 long/flat su un paniere, equal-weight.
|
||||||
|
Replica live di honest_improve2._tr_basket_daily."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(x, n):
|
||||||
|
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
class BasketTrendWorker:
|
||||||
|
def __init__(self, universe, tf="4h", capital=1000.0, position_size=POS,
|
||||||
|
leverage=LEV, fee_rt=FEE_RT, name="TR01_basket",
|
||||||
|
data_dir=Path("data/portfolio_paper")):
|
||||||
|
self.universe = list(universe)
|
||||||
|
self.tf = tf
|
||||||
|
self.initial_capital = capital
|
||||||
|
self.capital = capital
|
||||||
|
self.position_size = position_size
|
||||||
|
self.leverage = leverage
|
||||||
|
self.fee_rt = fee_rt
|
||||||
|
self.worker_id = f"{name}__{'-'.join(self.universe)}__{tf}"
|
||||||
|
self.work_dir = Path(data_dir) / self.worker_id
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
self.positions = {a: 0.0 for a in self.universe}
|
||||||
|
self.last_bar_ts = {a: 0 for a in self.universe}
|
||||||
|
self.in_position = False # per il ribilancio del runner (skip se True)
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if self.status_path.exists():
|
||||||
|
s = json.loads(self.status_path.read_text())
|
||||||
|
self.capital = s.get("capital", self.capital)
|
||||||
|
self.positions = {**self.positions, **s.get("positions", {})}
|
||||||
|
self.last_bar_ts = {**self.last_bar_ts, **s.get("last_bar_ts", {})}
|
||||||
|
self.in_position = any(v > 0 for v in self.positions.values())
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.status_path.write_text(json.dumps({
|
||||||
|
"capital": round(self.capital, 2), "positions": self.positions,
|
||||||
|
"last_bar_ts": self.last_bar_ts,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
|
||||||
|
|
||||||
|
def tick(self, data: dict):
|
||||||
|
rets = []
|
||||||
|
for a in self.universe:
|
||||||
|
df = data.get(a)
|
||||||
|
if df is None or len(df) < 110:
|
||||||
|
continue
|
||||||
|
c = df["close"].values
|
||||||
|
ef, es = _ema(c, 20)[-1], _ema(c, 100)[-1]
|
||||||
|
target = 1.0 if ef > es else 0.0
|
||||||
|
bar_ts = int(df["timestamp"].iloc[-1])
|
||||||
|
prev = self.positions[a]
|
||||||
|
# rendimento di barra realizzato sulla posizione precedente (chiusa->aperta barra)
|
||||||
|
if self.last_bar_ts[a] and bar_ts > self.last_bar_ts[a] and prev > 0:
|
||||||
|
r = (c[-1] - c[-2]) / c[-2]
|
||||||
|
rets.append(self.position_size * self.leverage * r * prev)
|
||||||
|
if target != prev:
|
||||||
|
self.capital -= self.capital * self.position_size * (self.fee_rt / 2) * abs(target - prev) / len(self.universe)
|
||||||
|
self._log(a, prev, target, float(c[-1]))
|
||||||
|
self.positions[a] = target
|
||||||
|
self.last_bar_ts[a] = bar_ts
|
||||||
|
if rets:
|
||||||
|
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
|
||||||
|
self.in_position = any(v > 0 for v in self.positions.values())
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _log(self, asset, frm, to, price):
|
||||||
|
with open(self.trades_path, "a") as f:
|
||||||
|
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"asset": asset, "from": frm, "to": to,
|
||||||
|
"price": round(price, 6), "capital": round(self.capital, 2)}) + "\n")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_summary(self):
|
||||||
|
longs = [a for a, v in self.positions.items() if v > 0]
|
||||||
|
return f"{self.worker_id}: cap={self.capital:.0f} long={longs}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → 1 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/live/basket_trend_worker.py tests/portfolio/test_basket_worker.py
|
||||||
|
git commit -m "feat(live): BasketTrendWorker (TR01) EMA-cross long/flat multi-asset"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `RotationWorker` (ROT02)
|
||||||
|
|
||||||
|
**Files:** Create `src/live/rotation_worker.py`; Test `tests/portfolio/test_rotation_worker.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — dato {asset: df 1d}, sceglie i top-k per momentum 60g con gate BTC>SMA100 e imposta i pesi gross/k:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.rotation_worker import RotationWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _df(n=200, slope=1.0):
|
||||||
|
c = np.linspace(100, 100 + slope * n, n)
|
||||||
|
ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6)
|
||||||
|
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_picks_top_momentum_when_risk_on(tmp_path):
|
||||||
|
w = RotationWorker(universe=["BTC", "AAA", "BBB"], top_k=2, gross=0.45, data_dir=tmp_path)
|
||||||
|
data = {"BTC": _df(slope=1.0), "AAA": _df(slope=3.0), "BBB": _df(slope=0.1)}
|
||||||
|
w.tick(data)
|
||||||
|
# BTC in uptrend -> risk_on; top-2 momentum = AAA e BTC; pesi gross/2
|
||||||
|
assert w.weights["AAA"] > 0 and abs(sum(w.weights.values()) - 0.45) < 1e-9
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_rotation_worker.py -v` → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementa `src/live/rotation_worker.py`.** Replica di `_rot_daily_equity`: panel di close 1d allineato; `risk_on = BTC[-1] > SMA100(BTC)[-1]`; `mom = P[-1]/P[-61]-1`; `chosen = [top_k per mom con mom>0] se risk_on else []`; pesi `gross/len(chosen)`; turnover fee `FEE_RT/2 * Σ|Δw|`; capitale aggiornato col rendimento di portafoglio del giorno successivo (live: al tick si realizza il rendimento dell'ultima barra sui pesi correnti, poi si ricalcolano i pesi). Persisti capitale+weights+last_ts. `in_position = bool(weights)`.
|
||||||
|
|
||||||
|
(Implementazione analoga a BasketTrendWorker: stato persistente, `tick(data)` allinea i panel per timestamp comune, calcola momentum/gate, applica fee sul turnover e rendimento di barra. Mantieni `top_k=3, gross=0.45` come default — i valori dello sleeve ROT02_rot del portafoglio.)
|
||||||
|
|
||||||
|
- [ ] **Step 4:** test → 1 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/live/rotation_worker.py tests/portfolio/test_rotation_worker.py
|
||||||
|
git commit -m "feat(live): RotationWorker (ROT02) dual-momentum top-k risk-gated"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `TsmomWorker` (TSM01)
|
||||||
|
|
||||||
|
**Files:** Create `src/live/tsmom_worker.py`; Test `tests/portfolio/test_tsmom_worker.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test (fallisce)** — consenso segni multi-orizzonte: sceglie gli asset con `Σ sign(P/P[-h]) ≥ thr` (h∈{63,126,252}) sotto gate, pesi gross/k.
|
||||||
|
|
||||||
|
- [ ] **Step 2-3: Implementa `src/live/tsmom_worker.py`** replicando `tsmom_sim`: `score[j] = mean_h sign(P[-1,j]/P[-1-h,j]-1)`; `chosen = [j: score>=thr] se risk_on`; pesi `gross/len(chosen)` con `gross=0.30`. Stessa struttura di RotationWorker (panel 1d, fee turnover, rendimento di barra, persistenza). Default `horizons=(63,126,252), thr=1.0, regime_n=100, gross=0.30`.
|
||||||
|
|
||||||
|
- [ ] **Step 4:** test → passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/live/tsmom_worker.py tests/portfolio/test_tsmom_worker.py
|
||||||
|
git commit -m "feat(live): TsmomWorker (TSM01) consenso TSMOM multi-orizzonte risk-gated"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Integrazione nel PortfolioRunner
|
||||||
|
|
||||||
|
**Files:** Modify `src/portfolio/runner.py`, `scripts/portfolios/_defs.py`, `src/portfolio/base.py`; Test `tests/portfolio/test_runner_honest.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `_defs.py`, marca gli SleeveSpec multi-asset col `kind` giusto e l'universo:
|
||||||
|
- DIP01 → `kind="single", name="DIP01"` (resta StrategyWorker via _STRAT_MODULE["DIP01"]="DIP01_dip_buy").
|
||||||
|
- TR01 → `kind="basket"`, aggiungi campo universo (riusa `params={"universe": ["BNB","BTC","DOGE","SOL","XRP"], "tf": "4h"}`).
|
||||||
|
- ROT02 → `kind="rotation"`, `params={"top_k":3, "gross":0.45, "tf":"1d"}`.
|
||||||
|
- TSM01 → `kind="tsmom"`, `params={"horizons":[63,126,252], "thr":1.0, "gross":0.30, "tf":"1d"}`.
|
||||||
|
(Aggiungi `universe`/campi a SleeveSpec se serve, default None.)
|
||||||
|
|
||||||
|
- [ ] **Step 2:** In `runner.py::build_worker_for` aggiungi i rami `kind in ("basket","rotation","tsmom")` che costruiscono i rispettivi worker con `capital=alloc_capital` e `data_dir=DATA_DIR`. Aggiorna `_STRAT_MODULE` con `"DIP01": "DIP01_dip_buy"`. Rimuovi DIP01/TR01/ROT02/TSM01 dalla lista "saltati": ora sono supportati.
|
||||||
|
|
||||||
|
- [ ] **Step 3:** In `runner.run()` il tick deve passare ai worker multi-asset un dict {asset: df} (fetch di tutti gli asset dell'universo). Estendi la raccolta `keys` e il dispatch del tick: per kind basket/rotation/tsmom costruisci `data = {a: cache[(a, tf)] for a in universe}` e chiama `w.tick(data)`. Per `_worker_equity` i nuovi worker espongono `.capital` (già ok). Per il ribilancio, espongono `.in_position` (skip se True).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test** `tests/portfolio/test_runner_honest.py`: `build_worker_for` ritorna il tipo giusto per ogni kind con capitale = alloc; e `run()` con PORT06 non lascia più sleeve "saltati" (mocka il fetch o testa solo build).
|
||||||
|
|
||||||
|
- [ ] **Step 5:** `uv run pytest tests/portfolio/ -m "not network" -v` → tutti verdi.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/portfolio/runner.py scripts/portfolios/_defs.py src/portfolio/base.py tests/portfolio/test_runner_honest.py
|
||||||
|
git commit -m "feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Validazione replay==backtest per i worker multi-asset
|
||||||
|
|
||||||
|
**Files:** Modify `scripts/analysis/validate_portfolio_runner.py` (o nuovo `validate_honest_workers.py`).
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Per ogni worker multi-asset, replay bar-by-bar su dati storici (load_data) e confronto dell'equity finale con la funzione di riferimento (`_tr_basket_daily`, `_rot_daily_equity`, `tsmom_sim`) entro tolleranza. ROT02/TSM01 sono daily → replay veloce (poche migliaia di barre). TR01 4h → medio. Atteso: match stretto (differenze solo da bar-timing/cadenza). DIP01 ha il gap intrabar noto come le fade (documenta, non assert esatto).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
```bash
|
||||||
|
git add scripts/analysis/validate_honest_workers.py
|
||||||
|
git commit -m "test(portfolio): replay worker honest/TSM01 == backtest di riferimento"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- **Copertura:** i 4 worker (DIP01 single via Strategy; TR01/ROT02/TSM01 dedicati) + integrazione runner + validazione → PORT06 gira live completo (niente più sleeve saltati).
|
||||||
|
- **Parità backtest:** invariata (gli sleeve del backtest vengono ancora da `build_everything`; i worker sono il path LIVE). La validazione replay==backtest (Task 6) certifica i worker live.
|
||||||
|
- **Gap noto:** DIP01, come le fade, ha exit intrabar nel backtest ma close-based nel live → gap strutturale documentato (non un bug). TR01/ROT02/TSM01 non hanno TP/SL intrabar (entry/exit a chiusura barra/giorno) → replay atteso stretto.
|
||||||
|
- **Tipi:** i nuovi worker espongono `.capital` e `.in_position` (richiesti da `_worker_equity`/`rebalance_allocations`); `tick(data: dict)` per i multi-asset vs `tick(df)`/`tick(dfa,dfb)` esistenti → il runner dispatcha per `kind`.
|
||||||
|
- **Rischio:** la convenzione di capitale/rendimento dei worker multi-asset deve combaciare con le funzioni di riferimento; la validazione Task 6 è il gate che lo verifica — se diverge, allineare la formula (non la reference).
|
||||||
|
|
||||||
|
> **Punto aperto:** verificare la disponibilità su Cerbero v2 dei timeframe 4h/1d per tutti gli asset dell'universo (TR01 usa 4h; ROT02/TSM01 usano 1d, oggi resample da 1h in get_df). Il runner live dovrà resamplare 1h→4h/1d dal feed v2 o fetchare nativamente — da decidere in Task 5/Step 3.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
|||||||
|
# Design — Cartella `portfolios/`: portafogli come oggetti di prima classe
|
||||||
|
|
||||||
|
**Data:** 2026-05-29
|
||||||
|
**Stato:** approvato in brainstorming, pronto per il piano di implementazione
|
||||||
|
**Branch:** `shape_patterns` (o branch dedicato `portfolios`)
|
||||||
|
|
||||||
|
## 1. Obiettivo e contesto
|
||||||
|
|
||||||
|
Oggi le strategie del progetto vivono come *sleeve* indipendenti: ogni worker del paper
|
||||||
|
trader (`StrategyWorker`, `PairsWorker`) gestisce un conto autonomo da €1000, con capitale
|
||||||
|
e stato propri in `data/paper_trades/{worker_id}/`. I "portafogli" `PORT01-03` esistenti
|
||||||
|
sono soltanto script di **report offline**: normalizzano le equity storiche dei singoli
|
||||||
|
sleeve e ne calcolano metriche equipesate. Non esiste un livello che gestisca davvero un
|
||||||
|
capitale condiviso, i pesi, il ribilanciamento e il PnL aggregato in tempo reale.
|
||||||
|
|
||||||
|
Questo design introduce una cartella `portfolios/` in cui il **portafoglio è un oggetto di
|
||||||
|
prima classe** che gestirà il trading e lo stato PnL. Un portafoglio possiede un capitale
|
||||||
|
totale, lo alloca ai propri sleeve secondo uno schema di pesi, dimensiona le posizioni,
|
||||||
|
ribilancia periodicamente e mantiene il ledger aggregato. La stessa definizione serve sia
|
||||||
|
al backtest sia al live, garantendo coerenza fra ciò che si misura e ciò che si tradia.
|
||||||
|
|
||||||
|
L'obiettivo strategico resta invariato: partire da €1000 e arrivare verso €50/giorno con un
|
||||||
|
paniere diversificato delle famiglie validate (fade, honest, pairs, TSMOM, shape-ML).
|
||||||
|
|
||||||
|
## 2. Decisioni di brainstorming
|
||||||
|
|
||||||
|
1. **Modello di capitale: pool condiviso.** Il portafoglio possiede il capitale totale, lo
|
||||||
|
alloca ai sleeve secondo i pesi, ridimensiona le posizioni e tiene lo stato/PnL
|
||||||
|
aggregato. I worker diventano esecutori.
|
||||||
|
2. **Scope: backtest + live unificati.** Un'unica classe `Portfolio` come fonte di verità,
|
||||||
|
capace sia di backtest/report storico sia di gestione live.
|
||||||
|
3. **Ribilanciamento periodico.** Il capitale viene riallocato ai pesi target a cadenza
|
||||||
|
fissa (giornaliera di default, configurabile), coerente con tutte le metriche misurate
|
||||||
|
finora.
|
||||||
|
4. **Schemi di peso supportati (tutti):** `equal` (default), `cap` (tetto per
|
||||||
|
famiglia/cluster, es. pairs 33% — configurazione sobria raccomandata), `inverse_vol`,
|
||||||
|
`cluster_rp` (equal fra cluster naturali poi inverse-vol dentro), `manual`.
|
||||||
|
5. **Scope live v1: tutti gli sleeve** — fade, honest, pairs (2 gambe) e shape-ML (SH01 via
|
||||||
|
worker con retraining periodico, sfruttando il `MLWorkerWrapper` esistente).
|
||||||
|
6. **Data layer Cerbero v2.** Il runner live adotta gli endpoint unificati v2: `get_historical`
|
||||||
|
unificato, `get_instruments` (naming robusto, niente `INSTRUMENT_MAP` hardcoded),
|
||||||
|
`get_ticker_batch` (fetch multi-gamba efficiente). Venue di trading = Deribit come ora.
|
||||||
|
|
||||||
|
### Analisi di accorpamento (a supporto delle decisioni)
|
||||||
|
|
||||||
|
`scripts/analysis/sleeve_clustering.py` ha mostrato che:
|
||||||
|
- i **cluster naturali** delle 17 sleeve non coincidono con le famiglie ma con
|
||||||
|
asset/regime: BTC-reversion, ETH-reversion, trend (TR01+TSM01), shape (SH_BTC+SH_ETH),
|
||||||
|
rotation (ROT02);
|
||||||
|
- la **ridondanza è lieve** (correlazione massima 0.43 MR01_BTC↔DIP01_BTC, 0.37 TR01↔TSM01):
|
||||||
|
nessuno sleeve è davvero fondibile, ognuno aggiunge diversificazione;
|
||||||
|
- a equal-weight i **pairs pesano il 47% del rischio** → giustifica lo schema `cap`;
|
||||||
|
- in OOS calmo equal-weight batte inverse-vol e risk-parity (i pairs ad alto rischio/ritorno
|
||||||
|
corrono liberi), ma è un risultato di regime → il cap resta la scelta prudente.
|
||||||
|
|
||||||
|
Il campo `cluster` di `SleeveSpec` codifica questi gruppi naturali per gli schemi `cap` e
|
||||||
|
`cluster_rp`.
|
||||||
|
|
||||||
|
## 3. Architettura e layout
|
||||||
|
|
||||||
|
Si rispecchia la struttura delle strategie (`src/strategies/` base + `scripts/strategies/`
|
||||||
|
concrete):
|
||||||
|
|
||||||
|
```
|
||||||
|
src/portfolio/
|
||||||
|
__init__.py
|
||||||
|
base.py # Portfolio (definizione + .backtest()), SleeveSpec, PortfolioResult
|
||||||
|
sleeves.py # costruzione UNIFICATA delle equity-per-sleeve (backtest);
|
||||||
|
# centralizza la logica oggi in combine_portfolio + report_families
|
||||||
|
weighting.py # schemi pesi: equal, cap, inverse_vol, cluster_rp, manual
|
||||||
|
ledger.py # PortfolioLedger: capitale, allocazioni, equity, PnL, peak/DD, persistenza
|
||||||
|
runner.py # PortfolioRunner (live): pool capital, sizing, ribilancio, aggregazione
|
||||||
|
|
||||||
|
scripts/portfolios/
|
||||||
|
PORT01_honest.py PORT02_fade.py PORT03_master.py
|
||||||
|
PORT04_master_pairs.py PORT05_master_esteso.py PORT06_master_shape.py
|
||||||
|
# definizioni concrete (lista SleeveSpec + schema pesi); run() = report backtest
|
||||||
|
|
||||||
|
portfolios.yml # config LIVE: portafoglio attivo, capitale, schema pesi, cap, cadenza, leva
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integrazione col codice esistente:**
|
||||||
|
- Il backtest riusa i builder di equity-per-sleeve (`build_all_sleeves`, `pairs_sim`,
|
||||||
|
`shape_daily_equity`), centralizzati in `src/portfolio/sleeves.py`; `combine_portfolio.py`
|
||||||
|
e `report_families.py` diventano consumer sottili (niente duplicazione).
|
||||||
|
- Il live riusa da `multi_runner`: il fetch candele, `build_workers`,
|
||||||
|
`build_pairs_workers`, `MLWorkerWrapper`. `multi_runner` resta entrypoint legacy
|
||||||
|
single-sleeve finché `PortfolioRunner` non lo sostituisce.
|
||||||
|
- I vecchi `PORT01-03` di `scripts/strategies/` vengono migrati in `scripts/portfolios/`
|
||||||
|
come definizioni della nuova classe.
|
||||||
|
|
||||||
|
## 4. Definizione del portafoglio (schema)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class SleeveSpec:
|
||||||
|
kind: str # "single" | "pairs" | "ml"
|
||||||
|
name: str # "MR01_bollinger_fade" | "PR01_pairs_reversion" | "SH01_shape_ml"
|
||||||
|
asset: str | None = None # single/ml
|
||||||
|
a: str | None = None # pairs: gamba long
|
||||||
|
b: str | None = None # pairs: gamba short
|
||||||
|
tf: str = "1h"
|
||||||
|
params: dict = field(default_factory=dict)
|
||||||
|
cluster: str = "" # BTC-rev | ETH-rev | trend | shape | rotation
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Portfolio:
|
||||||
|
code: str # "PORT06"
|
||||||
|
label: str # "Master + shape"
|
||||||
|
sleeves: list[SleeveSpec]
|
||||||
|
weighting: str = "equal" # equal | cap | inverse_vol | cluster_rp | manual
|
||||||
|
weights: dict | None = None # solo manual (sleeve-id -> peso)
|
||||||
|
caps: dict | None = None # solo cap: chiave = FAMIGLIA (derivata da kind/name:
|
||||||
|
# PAIRS/FADE/HONEST/SHAPE/TSM), es. {"PAIRS": 0.33}.
|
||||||
|
# cluster_rp usa invece il campo `cluster` degli sleeve.
|
||||||
|
total_capital: float = 1000.0
|
||||||
|
leverage: float = 3.0 # nota: 2x raccomandata per il live reale
|
||||||
|
rebalance: str = "1D"
|
||||||
|
vol_lookback: int = 90 # giorni per inverse_vol / cluster_rp
|
||||||
|
|
||||||
|
def backtest(self, ...) -> PortfolioResult: ...
|
||||||
|
def weight_vector(self, sleeve_returns) -> dict[str, float]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Gli schemi di peso (in `weighting.py`) restituiscono un dict `sleeve-id -> peso` che somma a
|
||||||
|
1. `equal/cap/manual` sono statici; `inverse_vol/cluster_rp` si ricalcolano a ogni ribilancio
|
||||||
|
sulla finestra trailing `vol_lookback`, identicamente in backtest e live.
|
||||||
|
|
||||||
|
## 5. Faccia backtest
|
||||||
|
|
||||||
|
`Portfolio.backtest()` riusa la macchina che ha prodotto tutte le metriche viste finora,
|
||||||
|
centralizzata in `src/portfolio/sleeves.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
build_sleeve_equity(spec) -> pd.Series # equity daily normalizzata su IDX comune
|
||||||
|
kind="single" -> fade/honest daily equity builders
|
||||||
|
kind="pairs" -> pairs_sim -> daily
|
||||||
|
kind="ml" -> shape_daily_equity
|
||||||
|
```
|
||||||
|
|
||||||
|
Poi: `weight_vector()` → pesi → `port_returns()` con ribilancio giornaliero → `metrics()`
|
||||||
|
FULL/OOS + `yearly_returns()`. Restituisce un `PortfolioResult` con ret/CAGR/DD/Sharpe
|
||||||
|
(FULL e OOS), tabella per-anno e contributo al rischio per sleeve e per cluster. Lo `run()`
|
||||||
|
di ogni `scripts/portfolios/PORTxx.py` stampa questo report.
|
||||||
|
|
||||||
|
## 6. Faccia live (`PortfolioRunner`)
|
||||||
|
|
||||||
|
Loop a poll:
|
||||||
|
|
||||||
|
1. **Data layer v2.** All'avvio `get_instruments` risolve i nomi reali di ogni asset/coppia
|
||||||
|
(fallback a una mappa statica se l'endpoint non risponde). Per tick: `get_historical`
|
||||||
|
unificato per le candele + `get_ticker_batch` per i prezzi correnti di tutte le gambe in
|
||||||
|
un'unica chiamata.
|
||||||
|
2. **Costruzione sleeve→worker.** Riusa `build_workers` / `build_pairs_workers` /
|
||||||
|
`MLWorkerWrapper` (SH01). I worker sono esecutori, non possiedono più €1000 fissi.
|
||||||
|
3. **Capitale pool + sizing.** Il `PortfolioLedger` tiene `total_capital`. A ogni worker
|
||||||
|
viene assegnato `alloc_i = peso_i × total_capital`; il worker dimensiona il notional come
|
||||||
|
`alloc_i × position_size × leverage` (si riusa il campo `capital` del worker come base di
|
||||||
|
allocazione).
|
||||||
|
4. **Ribilancio (cadenza `rebalance`, default giornaliera).** `total_capital = Σ equity_sleeve`
|
||||||
|
(capitale + PnL realizzato); ricalcolo dei pesi (vol-based sulla finestra trailing o
|
||||||
|
statici); riallineo `alloc_i`.
|
||||||
|
5. **Aggregazione.** Dopo ogni tick il ledger aggiorna equity totale, peak, max_dd, PnL
|
||||||
|
aggregato e per-sleeve/cluster.
|
||||||
|
|
||||||
|
### Approssimazione dichiarata (limite noto)
|
||||||
|
|
||||||
|
Il ribilancio cambia la base di sizing delle posizioni **future**; le posizioni già aperte
|
||||||
|
restano sul notional con cui sono nate (nessun travaso forzato a metà trade). Per il paper
|
||||||
|
trading questo è fedele al backtest daily-rebalanced entro lo scarto dovuto al turnover
|
||||||
|
infragiornaliero. È un compromesso accettato per non introdurre la contabilità a ledger
|
||||||
|
unico (approccio C scartato in brainstorming), rimandata a quando si passerà a capitale
|
||||||
|
reale su un singolo conto-margine.
|
||||||
|
|
||||||
|
## 7. Persistenza e stato PnL
|
||||||
|
|
||||||
|
Stato del portafoglio separato dai singoli worker, in `data/portfolios/{code}/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
data/portfolios/PORT06/
|
||||||
|
status.json # resume: total_capital, equity, peak, max_dd, pesi correnti,
|
||||||
|
# alloc+capitale+PnL per sleeve, ultimo ribilancio, ts
|
||||||
|
equity.jsonl # append-only: una riga per tick/giorno (ts, equity, dd, pnl_day) -> curva live
|
||||||
|
events.jsonl # append-only: ribilanci (pesi prima/dopo), milestone, errori
|
||||||
|
```
|
||||||
|
|
||||||
|
- I worker continuano a scrivere il proprio `trades.jsonl`/`status.json` in
|
||||||
|
`data/paper_trades/{worker_id}/` (storico per-sleeve intatto). Il portafoglio aggrega
|
||||||
|
sopra, non duplica i trade.
|
||||||
|
- **Resume:** al restart il runner ricarica lo `status.json` del portafoglio e gli stati
|
||||||
|
dei worker → riprende capitale, pesi e posizioni senza perdere storico.
|
||||||
|
- **Indicatori target:** il ledger espone `pnl_total`, `pnl_today`, `€/day` medio e DD
|
||||||
|
corrente.
|
||||||
|
- **Notifiche Telegram:** riepilogo a livello portafoglio (equity, PnL giorno, DD, ribilanci)
|
||||||
|
oltre alle notifiche per-trade dei worker.
|
||||||
|
|
||||||
|
## 8. Portafogli forniti e default
|
||||||
|
|
||||||
|
| Codice | Label | Sleeve | Pesi |
|
||||||
|
|--------|-------|--------|------|
|
||||||
|
| PORT01 | Honest | DIP01·TR01·ROT02 | equal |
|
||||||
|
| PORT02 | Fade master | MR01/02/07 × BTC/ETH (6) | equal |
|
||||||
|
| PORT03 | Master | fade+honest (9) | equal / manual 50-50 |
|
||||||
|
| PORT04 | Master + pairs | 9 + 5 pairs | equal · cap pairs 0.33 |
|
||||||
|
| PORT05 | Master esteso | 9 + pairs + TSM01 | equal · cap pairs |
|
||||||
|
| **PORT06** | **Master + shape** *(default)* | 9 + pairs + TSM01 + SH01 (BTC/ETH) | **cap pairs 0.33** |
|
||||||
|
|
||||||
|
**Default raccomandato:** PORT06 con `weighting="cap"` (pairs ~33%), `leverage=2` (sobrio),
|
||||||
|
`rebalance="1D"`. È la combinazione col miglior profilo OOS dell'analisi (Sharpe più alto,
|
||||||
|
DD più basso) e contiene tutte le famiglie validate. `portfolios.yml` seleziona il
|
||||||
|
portafoglio attivo e i suoi override.
|
||||||
|
|
||||||
|
## 9. Test
|
||||||
|
|
||||||
|
- **Unit** — `weighting.py` (somma pesi = 1, cap rispettato e ridistribuito,
|
||||||
|
inverse-vol/cluster corretti); `ledger.py` (capitale/PnL/DD, resume da status.json).
|
||||||
|
- **Parità backtest↔report** — `Portfolio.backtest()` di PORT03/04/05/06 riproduce
|
||||||
|
*esattamente* i numeri di `report_families.py` (regressione, stessa fonte).
|
||||||
|
- **Parità live↔backtest** — replay del `PortfolioRunner` su dati storici con ribilancio
|
||||||
|
giornaliero ≈ `Portfolio.backtest()` entro tolleranza (lo scarto è il turnover
|
||||||
|
infragiornaliero dichiarato), sullo stesso schema della validazione dei pairs.
|
||||||
|
- **Smoke live** — un tick reale end-to-end via Cerbero v2 (get_instruments +
|
||||||
|
get_historical + ticker_batch), nessun ordine reale, verifica ledger/persistenza/resume.
|
||||||
|
|
||||||
|
## 10. Fuori scope (note per il futuro)
|
||||||
|
|
||||||
|
- **Ledger unico / conto-margine reale** (approccio C): rinviato al passaggio a capitale
|
||||||
|
reale.
|
||||||
|
- **Hyperliquid come venue per gli alt** dei pairs (perp lineari nativi, evita i trap di
|
||||||
|
naming Deribit) — opzione abilitata dal data layer v2, non in v1.
|
||||||
|
- **Validazione pairs live via `get_cointegration_pairs`** e feature da macro/sentiment
|
||||||
|
(funding, liquidation, OI) per strategie future.
|
||||||
|
- **`run_backtest` server-side** di Cerbero come check incrociato.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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: 2000
|
||||||
|
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
|
||||||
|
# Frazione di capitale-sleeve per posizione (canonico backtest = 0.15).
|
||||||
|
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego
|
||||||
|
# dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
|
||||||
|
position_size: 0.5
|
||||||
|
# Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo).
|
||||||
|
# Solo i 6 fade single-leg (MR01/MR02/MR07 x BTC/ETH); ordini sui LINEARI USDC
|
||||||
|
# (payoff lineare = matematica del backtest; fee/PnL in USDC). Gli altri sleeve
|
||||||
|
# (pairs/rotation/tsmom/shape/dip) restano simulati.
|
||||||
|
execution:
|
||||||
|
enabled: true
|
||||||
|
sleeves: [MR01, MR02, MR07]
|
||||||
|
instruments:
|
||||||
|
BTC: BTC_USDC-PERPETUAL
|
||||||
|
ETH: ETH_USDC-PERPETUAL
|
||||||
@@ -27,3 +27,4 @@ dev = [
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
from scripts.analysis.explore_lab import atr
|
||||||
|
import importlib
|
||||||
|
FEE=0.001; LEV=3
|
||||||
|
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
|
||||||
|
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
|
||||||
|
def replay(df, sigs):
|
||||||
|
h=df['high'].values; l=df['low'].values; c=df['close'].values
|
||||||
|
out=[]; last=-1
|
||||||
|
for s in sigs:
|
||||||
|
i=s.idx
|
||||||
|
if i<=last: continue
|
||||||
|
d=s.direction; tp=s.metadata['tp']; sl=s.metadata['sl']; mb=s.metadata['max_bars']
|
||||||
|
j=min(i+mb,len(c)-1); exit_p=c[j]
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl;j=t;break
|
||||||
|
if h[t]>=tp: exit_p=tp;j=t;break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl;j=t;break
|
||||||
|
if l[t]<=tp: exit_p=tp;j=t;break
|
||||||
|
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
|
||||||
|
out.append((i,ret)); last=j
|
||||||
|
return out
|
||||||
|
|
||||||
|
# raccogli tutti i trade con il loro dvol_pct e hurst all'ingresso
|
||||||
|
rows=[]
|
||||||
|
for asset in ("BTC","ETH"):
|
||||||
|
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
for code,(mod,par) in STR.items():
|
||||||
|
s=load_strat(mod); sigs=s.generate_signals(df,ts,**par)
|
||||||
|
for i,ret in replay(df,sigs):
|
||||||
|
rows.append(dict(asset=asset,code=code,year=ts.iloc[i].year,ret=ret,
|
||||||
|
dvol_pct=df['dvol_pct'].iloc[i], hurst=df['hurst'].iloc[i],
|
||||||
|
dvol=df['dvol'].iloc[i]))
|
||||||
|
R=pd.DataFrame(rows).dropna(subset=['dvol_pct'])
|
||||||
|
print(f"trade totali (con DVOL, 2021+): {len(R)}")
|
||||||
|
|
||||||
|
print("\n=== PnL medio per trade per TERZILE DVOL (bassa/media/alta vol) ===")
|
||||||
|
R['dvbin']=pd.cut(R['dvol_pct'],[0,.33,.66,1.0],labels=['LOW-vol','MID','HIGH-vol'])
|
||||||
|
g=R.groupby('dvbin',observed=True)['ret']
|
||||||
|
print(f" {'regime':<10}{'n':>6}{'ret_medio%':>12}{'win%':>8}{'somma%':>10}")
|
||||||
|
for b in ['LOW-vol','MID','HIGH-vol']:
|
||||||
|
x=R[R.dvbin==b]['ret']
|
||||||
|
print(f" {b:<10}{len(x):>6}{x.mean()*100:>12.3f}{(x>0).mean()*100:>8.1f}{x.sum()*100:>10.0f}")
|
||||||
|
|
||||||
|
print("\n=== dentro LOW-vol: split per HURST (anti-persistente vs trending) ===")
|
||||||
|
LV=R[R.dvbin=='LOW-vol'].copy()
|
||||||
|
LV['hbin']=pd.cut(LV['hurst'],[0,.45,.55,1.0],labels=['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)'])
|
||||||
|
for b in ['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)']:
|
||||||
|
x=LV[LV.hbin==b]['ret']
|
||||||
|
if len(x): print(f" {b:<24}{len(x):>6} ret_medio {x.mean()*100:>+7.3f}% win {(x>0).mean()*100:>5.1f}% somma {x.sum()*100:>+6.0f}%")
|
||||||
|
|
||||||
|
print("\n=== per anno: PnL fade in LOW-vol vs resto ===")
|
||||||
|
for y in range(2021,2027):
|
||||||
|
lo=R[(R.year==y)&(R.dvbin=='LOW-vol')]['ret']; hi=R[(R.year==y)&(R.dvbin!='LOW-vol')]['ret']
|
||||||
|
print(f" {y}: LOW-vol somma {lo.sum()*100:>+6.0f}% (n{len(lo)}) | MID/HIGH somma {hi.sum()*100:>+6.0f}% (n{len(hi)})")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
import importlib
|
||||||
|
FEE=0.001; LEV=3
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
def replay(df,sigs):
|
||||||
|
h=df['high'].values;l=df['low'].values;c=df['close'].values;out=[];last=-1
|
||||||
|
for s in sigs:
|
||||||
|
i=s.idx
|
||||||
|
if i<=last: continue
|
||||||
|
d=s.direction;tp=s.metadata['tp'];sl=s.metadata['sl'];mb=s.metadata['max_bars'];j=min(i+mb,len(c)-1);exit_p=c[j];reason='time'
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl;j=t;reason='sl';break
|
||||||
|
if h[t]>=tp: exit_p=tp;j=t;reason='tp';break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl;j=t;reason='sl';break
|
||||||
|
if l[t]<=tp: exit_p=tp;j=t;reason='tp';break
|
||||||
|
out.append((i,(exit_p-c[i])/c[i]*d*LEV-FEE*LEV,reason));last=j
|
||||||
|
return out
|
||||||
|
rows=[]
|
||||||
|
for asset in ("BTC","ETH"):
|
||||||
|
df=load_features(asset,"1h");ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
for code,(mod,par) in STR.items():
|
||||||
|
s=load_strat(mod)
|
||||||
|
for i,ret,reason in replay(df,s.generate_signals(df,ts,**par)):
|
||||||
|
rows.append(dict(ret=ret,reason=reason,dvol_pct=df['dvol_pct'].iloc[i],hurst=df['hurst'].iloc[i],
|
||||||
|
vratio=df['vratio'].iloc[i],higuchi=df['higuchi'].iloc[i]))
|
||||||
|
R=pd.DataFrame(rows).dropna(subset=['dvol_pct','hurst'])
|
||||||
|
L=R[R.ret<0] # solo i trade in perdita
|
||||||
|
print(f"trade {len(R)} | in perdita {len(L)} ({len(L)/len(R)*100:.0f}%) | somma perdite {L.ret.sum()*100:.0f}% | media perdita {L.ret.mean()*100:.2f}%")
|
||||||
|
print("\n=== somma PERDITE per regime (dove si concentra il danno) ===")
|
||||||
|
R['dvbin']=pd.cut(R.dvol_pct,[0,.33,.66,1],labels=['LOWvol','MID','HIGHvol'])
|
||||||
|
R['hbin']=pd.cut(R.hurst,[0,.45,.55,1],labels=['anti<.45','.45-.55','trend>.55'])
|
||||||
|
piv=R[R.ret<0].pivot_table(index='dvbin',columns='hbin',values='ret',aggfunc='sum',observed=True)*100
|
||||||
|
print((piv.round(0)).to_string())
|
||||||
|
print("\n (numeri = somma % delle perdite per cella; piu negativo = piu danno)")
|
||||||
|
print("\n=== quota di SL (stop) per regime ===")
|
||||||
|
slr=R.groupby(['dvbin','hbin'],observed=True).apply(lambda x:(x.reason=='sl').mean()*100, include_groups=False)
|
||||||
|
print(slr.round(0).to_string())
|
||||||
|
# worst tail
|
||||||
|
print(f"\n=== peggiori 1% trade: dove? ===")
|
||||||
|
W=R.nsmallest(max(10,len(R)//100),'ret')
|
||||||
|
print(f" worst {len(W)} trade: dvol_pct medio {W.dvol_pct.mean():.2f}, hurst medio {W.hurst.mean():.2f}, quota hurst>.55 {(W.hurst>.55).mean()*100:.0f}%, quota dvol<.33 {(W.dvol_pct<.33).mean()*100:.0f}%")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd, importlib
|
||||||
|
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns, build_trades
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
FADES={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
FEE=0.001; LEV=3; POS=0.15
|
||||||
|
|
||||||
|
def fade_equity_filtered(code, asset, hurst_thr=None):
|
||||||
|
"""equity giornaliera dello sleeve fade, opz. filtrata Hurst<thr (skip hurst>=thr). Convenzione fade_daily_equity."""
|
||||||
|
mod,par=FADES[code]; s=load_strat(mod)
|
||||||
|
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
h=df['high'].values; l=df['low'].values; c=df['close'].values; hur=df['hurst'].values
|
||||||
|
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
|
||||||
|
for sg in s.generate_signals(df,ts,**par):
|
||||||
|
i=sg.idx
|
||||||
|
if i<=last: continue
|
||||||
|
if hurst_thr is not None and not np.isnan(hur[i]) and hur[i]>=hurst_thr: continue # FILTRO
|
||||||
|
d=sg.direction; tp=sg.metadata['tp']; sl=sg.metadata['sl']; mb=sg.metadata['max_bars']
|
||||||
|
j=min(i+mb,len(c)-1); exit_p=c[j]
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl;j=t;break
|
||||||
|
if h[t]>=tp: exit_p=tp;j=t;break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl;j=t;break
|
||||||
|
if l[t]<=tp: exit_p=tp;j=t;break
|
||||||
|
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
|
||||||
|
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
|
||||||
|
sser=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return _norm(sser)
|
||||||
|
|
||||||
|
base=all_sleeve_equities()
|
||||||
|
fade_ids=["MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH"]
|
||||||
|
|
||||||
|
def port(members):
|
||||||
|
dr=port_returns(members); return metrics(dr), metrics(dr,lo=SPLIT)
|
||||||
|
|
||||||
|
# baseline PORT06
|
||||||
|
fB,oB=port(base)
|
||||||
|
print(f"PORT06 baseline (17 sleeve): FULL Sharpe {fB['sharpe']:.2f} DD {fB['dd']:.2f}% | OOS Sharpe {oB['sharpe']:.2f} DD {oB['dd']:.2f}% ret {oB['ret']:+.0f}%")
|
||||||
|
|
||||||
|
# sostituisci le 6 fade con versione Hurst-skip
|
||||||
|
for thr in (0.55, 0.50):
|
||||||
|
filt=dict(base)
|
||||||
|
for fid in fade_ids:
|
||||||
|
code,asset=fid.split("_")
|
||||||
|
filt[fid]=fade_equity_filtered(code,asset,hurst_thr=thr)
|
||||||
|
fF,oF=port(filt)
|
||||||
|
print(f"PORT06 + Hurst-skip h<{thr} sulle fade: FULL Sharpe {fF['sharpe']:.2f} DD {fF['dd']:.2f}% | OOS Sharpe {oF['sharpe']:.2f} DD {oF['dd']:.2f}% ret {oF['ret']:+.0f}%")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'fade-lossguard',
|
||||||
|
description: 'Sistema anti-perdite per le fade in regime trending/low-vol: test meccanismi su MR01/02/07',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Test', detail: 'agenti: ogni meccanismo di filtro applicato alle fade reali (BTC+ETH)' },
|
||||||
|
{ title: 'Synth', detail: 'classifica + miglior loss-guard, gate: riduce DD senza uccidere edge' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = `
|
||||||
|
=== Harness (gia pronto) ===
|
||||||
|
import sys; sys.path.insert(0,'.')
|
||||||
|
import numpy as np, pandas as pd, importlib
|
||||||
|
from scripts.analysis.regime_lab import load_features, report
|
||||||
|
from scripts.analysis.explore_lab import robust, atr
|
||||||
|
|
||||||
|
def load_strat(mod):
|
||||||
|
m=importlib.import_module(mod)
|
||||||
|
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
|
||||||
|
FADES={'MR01':('scripts.strategies.MR01_bollinger_fade',dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
'MR02':('scripts.strategies.MR02_donchian_fade',dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
|
||||||
|
'MR07':('scripts.strategies.MR07_return_reversal',dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
|
||||||
|
|
||||||
|
# colonne regime_lab (causali): dvol, dvol_pct, vrp, funding_z, dvol_chg, hurst, higuchi, vratio, frac_up/dn
|
||||||
|
# ADX (se ti serve) calcolalo causale da OHLC; efficiency-ratio Kaufman = |c[i]-c[i-n]| / sum|diff| su [i-n,i].
|
||||||
|
|
||||||
|
# PATTERN: genera i segnali fade, poi APPLICA IL TUO FILTRO scartando le entries in regime sfavorevole,
|
||||||
|
# confronta BASELINE vs FILTRATA su ogni fade x asset:
|
||||||
|
def entries_from(strat, df, par, keep=lambda df,i: True):
|
||||||
|
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
out=[]
|
||||||
|
for s in strat.generate_signals(df,ts,**par):
|
||||||
|
if keep(df, s.idx): # keep=False -> filtro scarta (loss-guard)
|
||||||
|
out.append({'i':s.idx,'d':s.direction,'tp':s.metadata['tp'],'sl':s.metadata['sl'],'max_bars':s.metadata['max_bars']})
|
||||||
|
return out
|
||||||
|
# per ogni (fade,asset): res_base=report(.., entries_from(..., keep=tutto)); res_filt=report(.., col tuo keep)
|
||||||
|
# confronta: Sharpe OOS, DD full/oos, ret, #trade (quanti scartati), e robust(). Aggrega sulle 6 combo.
|
||||||
|
`
|
||||||
|
|
||||||
|
const CONTEXT = `
|
||||||
|
PROBLEMA: le fade (MR01 Bollinger, MR02 Donchian, MR07 return-reversal) sono mean-reversion 1h con
|
||||||
|
filtro trend EMA200 (trend_max=3.0). DIAGNOSI EMPIRICA (3022 trade, 2021+): le PERDITE e gli STOP
|
||||||
|
si concentrano nel regime PERSISTENTE/TRENDING, NON nella bassa vol:
|
||||||
|
- somma perdite per cella (Hurst x DVOL): la cella peggiore e' hurst>0.55 (-2695% in low-vol,
|
||||||
|
dominante in ogni terzile vol). I peggiori 1% trade hanno hurst medio 0.61 (77% con hurst>0.55).
|
||||||
|
- tasso STOP-LOSS: 43% quando hurst>0.55 vs 21% quando hurst<0.45 (anti-persistente). 2x.
|
||||||
|
- net: le celle restano positive (i winner battono), quindi filtrare toglie anche winner -> il
|
||||||
|
loss-guard e' utile SOLO se riduce DD/coda SENZA uccidere l'edge netto.
|
||||||
|
RICERCA ESTERNA (confermata): (a) Hurst regime filter: MR solo H<0.45, in 0.45-0.55 ridurre size,
|
||||||
|
evitare H>0.55. (b) ADX: MR profit factor 1.62 con ADX<20 vs -0.74 con ADX>30 (switch di regime piu'
|
||||||
|
importante). (c) ATR/vol-EXPANSION ratio>1.5 disabilita MR -> ha prevenuto il 72% delle perdite
|
||||||
|
maggiori. (d) time-stop: se non rientra in ~15 barre e' un trend, esci.
|
||||||
|
|
||||||
|
OBIETTIVO: trovare il MIGLIOR meccanismo (o combo) che, applicato alle fade reali, RIDUCE DD/coda/
|
||||||
|
stop-rate MANTENENDO l'edge netto OOS. Metodologia: causale no-look-ahead (le colonne regime_lab
|
||||||
|
sono causali; i filtri usano solo dati <= i), netto fee (report() fa OOS+sweep). LEZIONE FR01: un
|
||||||
|
filtro che riduce le perdite ma anche i winner spesso NON migliora -> il gate vero e' DD giu' a
|
||||||
|
parita' (o quasi) di Sharpe/ret, idealmente Sharpe SU e DD GIU'.
|
||||||
|
|
||||||
|
` + API
|
||||||
|
|
||||||
|
const SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
meccanismo: { type: 'string' },
|
||||||
|
descrizione: { type: 'string' },
|
||||||
|
base_oos_sharpe: { type: 'number' }, filt_oos_sharpe: { type: 'number' },
|
||||||
|
base_dd_full: { type: 'number' }, filt_dd_full: { type: 'number' },
|
||||||
|
base_oos_ret: { type: 'number' }, filt_oos_ret: { type: 'number' },
|
||||||
|
trade_scartati_pct: { type: 'number' },
|
||||||
|
riduce_perdite: { type: 'boolean', description: 'riduce DD/coda/stop-rate' },
|
||||||
|
preserva_edge: { type: 'boolean', description: 'edge netto OOS preservato (Sharpe non crolla, robust resta)' },
|
||||||
|
buon_lossguard: { type: 'boolean', description: 'riduce perdite SENZA uccidere edge -> candidato' },
|
||||||
|
verdetto: { type: 'string', description: 'numeri base vs filtrato aggregati sulle 6 combo fade x asset' },
|
||||||
|
},
|
||||||
|
required: ['meccanismo', 'buon_lossguard', 'riduce_perdite', 'preserva_edge', 'verdetto'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const MECHS = [
|
||||||
|
['Hurst-skip H>0.55', 'scarta le fade quando rolling-hurst(window=100) >= 0.55 (regime persistente). Test anche soglia 0.50 e 0.60, riporta la migliore.'],
|
||||||
|
['Hurst-size transition', 'NON scartare ma RIDURRE: tieni tutte le entries ma pesa size 1.0 se hurst<0.45, 0.5 se 0.45-0.55, 0.25 se >0.55. (Per testare la riduzione size col report attuale: approssima scartando il 50%/75% delle entries nelle bin alte in modo deterministico per indice, oppure confronta solo le bin.)'],
|
||||||
|
['ADX-skip', 'calcola ADX(14) causale; scarta le fade quando ADX>25 (trend). Test soglie 20/25/30.'],
|
||||||
|
['vol-expansion vratio', 'scarta le fade quando vratio (vol breve/lunga, colonna regime_lab) > 1.5 (vol in espansione = breakout, non range). Test 1.3/1.5/1.8. (la ricerca dice -72% perdite maggiori)'],
|
||||||
|
['efficiency-ratio Kaufman', 'ER = |c[i]-c[i-n]|/sum(|diff|) su finestra n=20; scarta quando ER>0.5 (moto efficiente/trending). Test 0.4/0.5/0.6.'],
|
||||||
|
['time-stop piu corto', 'riduci max_bars da 24 a 12 o 15 (esci prima se non rientra = probabile trend). Confronta DD/edge.'],
|
||||||
|
['Hurst + vol-expansion combo', 'scarta se hurst>0.55 OPPURE vratio>1.5. Verifica se la combo riduce piu DD del singolo senza perdere piu edge.'],
|
||||||
|
['Hurst + ADX combo', 'scarta se hurst>0.55 E ADX>25 (doppia conferma di trend) -> piu selettivo, scarta meno winner.'],
|
||||||
|
['vol-target sizing', 'scala la size per 1/realized_vol (target vol costante): approssima tenendo solo le entries in vol moderata, riporta effetto su DD/coda.'],
|
||||||
|
['DVOL-rising skip', 'scarta le fade quando dvol_chg>0 forte (DVOL in salita = stress/espansione vol imminente). Test soglie su dvol_chg.'],
|
||||||
|
]
|
||||||
|
const ASSETS_NOTE = 'Applica a tutte e 3 le fade (MR01,MR02,MR07) su BTC E ETH (6 combo), aggrega base vs filtrato.'
|
||||||
|
|
||||||
|
phase('Test')
|
||||||
|
// ogni meccanismo = 1 agente che testa su tutte le 6 combo; piu' 4 agenti che esplorano combo/parametri fini
|
||||||
|
const tasks = MECHS.map(([nm, desc]) => () => agent(
|
||||||
|
CONTEXT + `\n\nMECCANISMO DA TESTARE: ${nm}\n${desc}\n\n${ASSETS_NOTE}\n` +
|
||||||
|
`Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && uv run python /tmp/<file>.py), confronta ` +
|
||||||
|
`BASELINE (fade senza filtro) vs FILTRATA su ogni combo, AGGREGA (media o somma equity) e riporta. ` +
|
||||||
|
`Il filtro deve essere CAUSALE. Decidi buon_lossguard=true SOLO se riduce DD/coda/stop-rate MANTENENDO ` +
|
||||||
|
`l'edge netto OOS (Sharpe non crolla, ret OOS resta ampiamente positivo). Cita i numeri base vs filtrato.`,
|
||||||
|
{ label: `mech:${nm.slice(0, 18)}`, phase: 'Test', schema: SCHEMA }))
|
||||||
|
|
||||||
|
const results = (await parallel(tasks)).filter(Boolean)
|
||||||
|
|
||||||
|
phase('Synth')
|
||||||
|
const good = results.filter(r => r.buon_lossguard)
|
||||||
|
const synthesis = await agent(
|
||||||
|
CONTEXT +
|
||||||
|
`\n\nRisultati di ${results.length} meccanismi testati:\n${JSON.stringify(results, null, 1)}\n\n` +
|
||||||
|
`SINTESI FINALE (italiano) per il decisore:
|
||||||
|
1) Esiste un loss-guard che riduce le perdite/DD delle fade in regime trending SENZA uccidere l'edge?
|
||||||
|
2) Tabella: meccanismo | base vs filtrato (OOS Sharpe, DD, ret, %trade scartati) | buon_lossguard?
|
||||||
|
3) Il MIGLIORE (e l'eventuale combo) con i numeri. Quanto DD/coda si risparmia e a che costo di ret.
|
||||||
|
4) Coerenza con la ricerca esterna (Hurst<0.45 / ADX / vol-expansion / time-stop).
|
||||||
|
5) Raccomandazione: quale filtro applicare alle fade live, con che soglia, e il caveat (serve feed
|
||||||
|
DVOL/regime live? il filtro va validato a livello PORT06 = riduce il DD del portafoglio?).
|
||||||
|
Onesta: se nessuno migliora davvero (riduce solo ret), dillo. Cita NUMERI reali.`,
|
||||||
|
{ label: 'synth-lossguard', phase: 'Synth' })
|
||||||
|
|
||||||
|
return { results, good, synthesis }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
from scripts.analysis.explore_lab import atr
|
||||||
|
FEE=0.001; LEV=3; POS=0.15
|
||||||
|
|
||||||
|
def fr01_daily_equity(asset):
|
||||||
|
df=load_features(asset,"1h")
|
||||||
|
c=df['close'].values; h=df['high'].values; l=df['low'].values; a=atr(df,14)
|
||||||
|
ma=pd.Series(c).rolling(50).mean().values; sd=pd.Series(c).rolling(50).std().values
|
||||||
|
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
|
||||||
|
for i in range(64,len(c)-1):
|
||||||
|
if i<=last or np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
|
||||||
|
if df['hurst'].iloc[i]>=0.55: continue
|
||||||
|
if np.isnan(df['dvol_pct'].iloc[i]) or df['dvol_pct'].iloc[i]>=0.40: continue
|
||||||
|
if c[i]<ma[i]-2.5*sd[i]: d,sl,tp=1,c[i]-2*a[i],ma[i]
|
||||||
|
elif c[i]>ma[i]+2.5*sd[i]: d,sl,tp=-1,c[i]+2*a[i],ma[i]
|
||||||
|
else: continue
|
||||||
|
j=min(i+24,len(c)-1); exit_p=c[j]
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl; j=t; break
|
||||||
|
if h[t]>=tp: exit_p=tp; j=t; break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl; j=t; break
|
||||||
|
if l[t]<=tp: exit_p=tp; j=t; break
|
||||||
|
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
|
||||||
|
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
|
||||||
|
s=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return _norm(s)
|
||||||
|
|
||||||
|
members=all_sleeve_equities()
|
||||||
|
print(f"PORT06 sleeve: {len(members)} | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da idx {SPLIT} ({IDX[SPLIT].date()})")
|
||||||
|
fr={"FR01_BTC":fr01_daily_equity("BTC"), "FR01_ETH":fr01_daily_equity("ETH")}
|
||||||
|
|
||||||
|
base=port_returns(members) # equal-weight 17 sleeve (metro combine)
|
||||||
|
aug =port_returns({**members,**fr}) # + FR01x2 (19 sleeve)
|
||||||
|
|
||||||
|
def show(tag, dr):
|
||||||
|
f=metrics(dr); o=metrics(dr,lo=SPLIT)
|
||||||
|
print(f" {tag:<22} FULL: Sharpe {f['sharpe']:.2f} DD {f['dd']:.1f}% ret {f['ret']:+.0f}% | OOS: Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
|
||||||
|
|
||||||
|
print("\n=== MASTER equal-weight: con/senza FR01 ===")
|
||||||
|
show("PORT06 (17 sleeve)", base)
|
||||||
|
show("PORT06 + FR01 (19)", aug)
|
||||||
|
|
||||||
|
# correlazione FR01 vs portafoglio MASTER aggregato + standalone
|
||||||
|
for k,e in fr.items():
|
||||||
|
r=e.pct_change().fillna(0.0); corr=np.corrcoef(r, base)[0,1]
|
||||||
|
f=metrics(r); o=metrics(r,lo=SPLIT)
|
||||||
|
print(f"\n {k}: corr vs MASTER = {corr:+.3f} | standalone OOS Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
from scripts.analysis.explore_lab import atr
|
||||||
|
|
||||||
|
FEE=0.001; LEV=3
|
||||||
|
|
||||||
|
def build(df, gate, k=2.5, sl_atr=2.0, mb=24, bb=50):
|
||||||
|
c=df['close'].values; a=atr(df,14)
|
||||||
|
ma=pd.Series(c).rolling(bb).mean().values; sd=pd.Series(c).rolling(bb).std().values
|
||||||
|
ent=[]
|
||||||
|
for i in range(bb+14,len(c)-1):
|
||||||
|
if np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
|
||||||
|
if not gate(df,i): continue
|
||||||
|
if c[i]<ma[i]-k*sd[i]: d,sl=1,c[i]-sl_atr*a[i]
|
||||||
|
elif c[i]>ma[i]+k*sd[i]: d,sl=-1,c[i]+sl_atr*a[i]
|
||||||
|
else: continue
|
||||||
|
ent.append({'i':i,'d':d,'tp':ma[i],'sl':sl,'mb':mb})
|
||||||
|
return ent
|
||||||
|
|
||||||
|
def per_year(df, ent):
|
||||||
|
"""replay intrabar fedele (sl-first, tp, poi max_bars@close) -> per anno {n,ret%,win%}."""
|
||||||
|
h=df['high'].values; l=df['low'].values; c=df['close'].values
|
||||||
|
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
Y={}
|
||||||
|
last=-1
|
||||||
|
for e in ent:
|
||||||
|
i=e['i']
|
||||||
|
if i<=last: continue
|
||||||
|
d=e['d']; tp=e['tp']; sl=e['sl']; j=min(i+e['mb'],len(c)-1)
|
||||||
|
exit_p=c[j]
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl; j=t; break
|
||||||
|
if h[t]>=tp: exit_p=tp; j=t; break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl; j=t; break
|
||||||
|
if l[t]<=tp: exit_p=tp; j=t; break
|
||||||
|
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
|
||||||
|
last=j; yr=ts.iloc[i].year
|
||||||
|
if yr not in Y: Y[yr]=[0,0.0,0]
|
||||||
|
Y[yr][0]+=1; Y[yr][1]+=ret*100; Y[yr][2]+= (ret>0)
|
||||||
|
return Y
|
||||||
|
|
||||||
|
# gate functions
|
||||||
|
def g_hurst_calm(df,i): return df['hurst'].iloc[i]<0.55 and not np.isnan(df['dvol_pct'].iloc[i]) and df['dvol_pct'].iloc[i]<0.40
|
||||||
|
def g_vrp_neg(df,i): return not np.isnan(df['vrp'].iloc[i]) and df['vrp'].iloc[i]<0
|
||||||
|
def g_hig_vrp(df,i):
|
||||||
|
hi=df['higuchi'].iloc[i]; return (not np.isnan(hi)) and hi>1.5 and (not np.isnan(df['vrp'].iloc[i])) and df['vrp'].iloc[i]<0
|
||||||
|
def g_none(df,i): return True
|
||||||
|
|
||||||
|
STRATS=[("HurstCalmFade (hurst<.55 & DVOL<p40)",g_hurst_calm),
|
||||||
|
("VRP<0 Fade (core driver)",g_vrp_neg),
|
||||||
|
("HigVRP Fade (Higuchi>1.5 & VRP<0)",g_hig_vrp),
|
||||||
|
("Fade NUDA (no gate, baseline)",g_none)]
|
||||||
|
|
||||||
|
# regime mercato BTC per anno (da BTC close annuale)
|
||||||
|
btc=load_features("BTC","1d")
|
||||||
|
bts=pd.to_datetime(btc['timestamp'],unit='ms',utc=True); bc=btc['close'].values
|
||||||
|
mkt={}
|
||||||
|
for yr in range(2021,2027):
|
||||||
|
m=(bts.dt.year==yr).values
|
||||||
|
if m.sum()>5:
|
||||||
|
r=(bc[m][-1]/bc[m][0]-1)*100
|
||||||
|
mkt[yr]=("BULL" if r>40 else "BEAR" if r<-30 else "RANGE", r)
|
||||||
|
|
||||||
|
for asset in ("BTC","ETH"):
|
||||||
|
df=load_features(asset,"1h")
|
||||||
|
print(f"\n{'='*78}\n {asset} 1h — performance per anno (somma ret% per-trade, netto leva3x+fee0.10%)\n{'='*78}")
|
||||||
|
print(f" {'Strategia':<40} " + " ".join(f"{y}" for y in range(2021,2027)))
|
||||||
|
for name,g in STRATS:
|
||||||
|
ent=build(df,g); Y=per_year(df,ent)
|
||||||
|
cells=[]
|
||||||
|
for y in range(2021,2027):
|
||||||
|
if y in Y and Y[y][0]>0:
|
||||||
|
cells.append(f"{Y[y][1]:+5.0f}")
|
||||||
|
else: cells.append(" . ")
|
||||||
|
print(f" {name:<40} " + " ".join(cells))
|
||||||
|
# riga trades/anno per la strategia principale
|
||||||
|
ent=build(df,g_hurst_calm); Y=per_year(df,ent)
|
||||||
|
tr=" ".join(f"{Y.get(y,[0])[0]:>5}" for y in range(2021,2027))
|
||||||
|
print(f" {'(HurstCalmFade trades/anno)':<40} {tr}")
|
||||||
|
|
||||||
|
print(f"\n REGIME MERCATO BTC per anno (ret% annuale prezzo):")
|
||||||
|
for y in range(2021,2027):
|
||||||
|
if y in mkt: print(f" {y}: {mkt[y][0]:6} ({mkt[y][1]:+.0f}%)")
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'fractal-argo-search',
|
||||||
|
description: 'Ricerca a ~100 agenti: strategia FRATTALI del segnale x REGIME ARGO (DVOL/funding/VRP) validata OOS',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Search', detail: '92 agenti: griglia frattale x regime x asset + wildcard' },
|
||||||
|
{ title: 'Verify', detail: 'verifica avversariale dei survivor (look-ahead, fee 0.2%, altro asset/split)' },
|
||||||
|
{ title: 'Synth', detail: 'classifica, sceglie vincitori, propone implementazione' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = `
|
||||||
|
=== regime_lab API (gia pronta, dati FRESCHI in cache) ===
|
||||||
|
from scripts.analysis.regime_lab import load_features, report
|
||||||
|
from scripts.analysis.explore_lab import robust, atr, ema, rsi
|
||||||
|
|
||||||
|
df = load_features(ASSET, TF) # ASSET in {BTC,ETH}, TF in {1h,4h,1d}
|
||||||
|
# Colonne (tutte CAUSALI, valore a barra i usa solo dati <= i):
|
||||||
|
# OHLCV: open high low close volume timestamp
|
||||||
|
# REGIME (ARGO-proxy backtestabile): dvol, dvol_pct (percentile rolling 0..1),
|
||||||
|
# rv (realized vol ann.), vrp = dvol-rv (>0 = vol sopravvalutata ~ ARGO GEX+ range),
|
||||||
|
# funding, funding_z (z-score rolling), dvol_chg (DVOL salita/discesa, proxy term-structure)
|
||||||
|
# FRATTALI: hurst (>0.5 persistente/trend, <0.5 anti-persistente/mean-rev), higuchi (FD: alta=frastagliato),
|
||||||
|
# vratio (vol breve/lunga), frac_up/frac_dn (Williams pivot bool: swing high/low confermati, CAUSALI)
|
||||||
|
# NB: dvol e' NaN prima del 2021-03 (storico DVOL) -> salta le barre con dvol NaN se usi il regime.
|
||||||
|
|
||||||
|
# Costruisci 'entries': lista dict {i, d(+1/-1), tp, sl, max_bars}. INGRESSO ESEGUIBILE:
|
||||||
|
# i, d, tp, sl decisi con dati <= close[i]. tp/sl in PREZZO (o None). Esempio fade:
|
||||||
|
ent=[]
|
||||||
|
c=df['close'].values; a=atr(df,14); ma=df['close'].rolling(50).mean().values; sd=df['close'].rolling(50).std().values
|
||||||
|
for i in range(300, len(c)-1):
|
||||||
|
if np.isnan(sd[i]) or np.isnan(df['dvol_pct'].iloc[i]): continue
|
||||||
|
if df['vrp'].iloc[i] > 0 and c[i] < ma[i]-2.5*sd[i]: # GATE regime + SEGNALE frattale/tecnico
|
||||||
|
ent.append({'i':i,'d':1,'tp':ma[i],'sl':c[i]-2*a[i],'max_bars':24})
|
||||||
|
res = report('NOME', ent, df) # -> {full:{ret,sharpe,dd,trades,win,exposure}, oos:{...}, sweep, sweep_oos, pos_yrs, n_yrs}
|
||||||
|
ok = robust(res) # True = full+oos>0 E regge fee 0.2% RT E anni ~tutti positivi
|
||||||
|
print('ROBUST', ok, 'trd', res['full']['trades'], 'OOSsharpe', round(res['oos']['sharpe'],2),
|
||||||
|
'OOSret', round(res['oos']['ret']), 'fee02OOS', round(res['sweep_oos'][0.002]))
|
||||||
|
`
|
||||||
|
|
||||||
|
const CONTEXT = `
|
||||||
|
PROGETTO PythagorasGoal: trading crypto BTC/ETH. Edge dimostrato = SOLO mean-reversion (fade) + pairs.
|
||||||
|
ASTICELLA ALTA: il portafoglio PORT06 e' gia a Sharpe OOS 8.19 / DD 2.3%. Una strategia nuova vale solo
|
||||||
|
se ha edge NETTO validato OOS e robusto.
|
||||||
|
|
||||||
|
PRIORI ONESTI (non ignorarli): i FRATTALI sono stati gia esplorati e quasi tutti RUMORE (shape_lab:
|
||||||
|
analog kNN solo BTC-overfit; PIP/pivot 0/48 robuste; DTW peggiora). Le OPZIONI sono state SCARTATE
|
||||||
|
(W18/19/21 VRP). L'unico edge frattale validato e SH01 (shape-ML logit, diversificatore). MA: la
|
||||||
|
combinazione FRATTALE-del-segnale x REGIME-ARGO (gating su DVOL/funding/VRP) e' NUOVA e non testata ->
|
||||||
|
e' qui che potrebbe esserci valore: il regime puo dire QUANDO il segnale frattale funziona.
|
||||||
|
|
||||||
|
OBIETTIVO: trovare una strategia che combini un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME
|
||||||
|
(ARGO-proxy: DVOL percentile, VRP, funding) e che superi la validazione onesta (robust()=True).
|
||||||
|
|
||||||
|
METODOLOGIA OBBLIGATORIA: ingresso ESEGUIBILE senza look-ahead (le colonne regime_lab sono gia causali;
|
||||||
|
le TUE entries devono usare solo dati <= i). Backtest NETTO fee (report() fa gia sweep 0.0-0.2% RT + OOS
|
||||||
|
ultimo 30%). robust()=True e' il gate minimo. Diffida dell'overfit: poche entries o edge solo full e
|
||||||
|
non-oos = rumore. Riporta ONESTAMENTE anche i fallimenti.
|
||||||
|
|
||||||
|
` + API
|
||||||
|
|
||||||
|
const SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
strategy: { type: 'string', description: 'nome + 1 frase: segnale frattale + gate regime' },
|
||||||
|
family: { type: 'string' }, angle: { type: 'string' }, asset: { type: 'string' }, tf: { type: 'string' },
|
||||||
|
trades: { type: 'integer' },
|
||||||
|
full_ret: { type: 'number' }, oos_ret: { type: 'number' },
|
||||||
|
full_sharpe: { type: 'number' }, oos_sharpe: { type: 'number' }, oos_dd: { type: 'number' },
|
||||||
|
fee02_oos_ret: { type: 'number', description: 'OOS ret a fee 0.2% RT' },
|
||||||
|
robust: { type: 'boolean', description: 'robust()=True' },
|
||||||
|
promising: { type: 'boolean', description: 'vale una verifica avversariale (robust o quasi, non overfit)' },
|
||||||
|
edge_desc: { type: 'string', description: 'perche funziona / perche e rumore, con i numeri' },
|
||||||
|
},
|
||||||
|
required: ['strategy', 'asset', 'tf', 'trades', 'oos_sharpe', 'robust', 'promising', 'edge_desc'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAMILIES = [
|
||||||
|
['hurst', 'Hurst regime: fade quando hurst<0.5 (anti-persistente), o trend quando hurst>0.5. Soglia hurst come segnale o gate.'],
|
||||||
|
['higuchi', 'Fractal dimension Higuchi: FD alta = frastagliato/range (fade), FD bassa = liscio/trend (momentum).'],
|
||||||
|
['williams', 'Williams pivot (frac_up/frac_dn, causali): fade del pivot (reversione allo swing) o breakout del pivot.'],
|
||||||
|
['vratio', 'volatility_ratio: >1 espansione vol (breakout/fade del breakout), <1 compressione (range/squeeze).'],
|
||||||
|
['analog', 'analog kNN sulla FORMA (puoi usare scripts.analysis.shape_lab.analog_signals(df,...)): forecast causale segno a H barre, gatealo col regime.'],
|
||||||
|
['multiscale', 'multi-scala: combina hurst+higuchi+vratio in un indice di "regime frattale" (trend vs chop) come segnale.'],
|
||||||
|
['candle', 'pattern candele frattali (src.fractal.patterns: extract_body_ratios/shadow, find_patterns): sequenze multi-barra come segnale.'],
|
||||||
|
]
|
||||||
|
const ANGLES = [
|
||||||
|
['none', 'NESSUN gate regime: segnale frattale puro (baseline per misurare il valore marginale del regime).'],
|
||||||
|
['dvol_high', 'agisci solo con dvol_pct alto (>0.6..0.8): vol elevata (spesso mean-reversion piu forte).'],
|
||||||
|
['dvol_low', 'agisci solo con dvol_pct basso (<0.3..0.4): calma/range.'],
|
||||||
|
['vrp', 'VRP=vrp colonna: VRP>0 (vol sopravvalutata, analogo ARGO GEX+ -> range/fade); confronta con VRP<0. Gate o peso.'],
|
||||||
|
['funding', 'funding_z estremo: troppi long (funding_z alto) -> fade ribassista; troppi short -> fade rialzista (flusso ARGO via perp).'],
|
||||||
|
['dvol_chg', 'dvol_chg: DVOL in salita (espansione vol/stress -> trend) vs discesa (ritorno calma -> range).'],
|
||||||
|
]
|
||||||
|
const ASSETS = ['BTC', 'ETH']
|
||||||
|
|
||||||
|
phase('Search')
|
||||||
|
// 7 famiglie x 6 angoli x 2 asset = 84 agenti griglia
|
||||||
|
const gridSpecs = []
|
||||||
|
for (const [fam, fdesc] of FAMILIES)
|
||||||
|
for (const [ang, adesc] of ANGLES)
|
||||||
|
for (const asset of ASSETS)
|
||||||
|
gridSpecs.push({ fam, fdesc, ang, adesc, asset })
|
||||||
|
|
||||||
|
const gridTasks = gridSpecs.map((s) => () => agent(
|
||||||
|
CONTEXT +
|
||||||
|
`\n\nIL TUO CELLA:\n- FAMIGLIA FRATTALE: ${s.fam} -> ${s.fdesc}\n- ANGOLO REGIME: ${s.ang} -> ${s.adesc}\n- ASSET: ${s.asset}\n\n` +
|
||||||
|
`Progetta la MIGLIORE strategia in questa cella: un SEGNALE basato sulla famiglia frattale ${s.fam}, ` +
|
||||||
|
`condizionato/interagito col regime ${s.ang}. Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && ` +
|
||||||
|
`uv run python /tmp/<tuofile>.py), prova SIA TF=1h SIA TF=1d (e se vuoi 4h), itera 2-4 varianti di soglia/` +
|
||||||
|
`direzione/exit, e RIPORTA la migliore (quella con oos_sharpe piu alto e robust se possibile). Usa report()+robust(). ` +
|
||||||
|
`Privilegia mean-reversion (l'edge del progetto) ma testa anche momentum dove il regime lo motiva. ` +
|
||||||
|
`Mai look-ahead. Se tutto e rumore, dillo onestamente (promising=false). Ritorna lo schema.`,
|
||||||
|
{ label: `srch:${s.fam}/${s.ang}/${s.asset}`, phase: 'Search', schema: SCHEMA }))
|
||||||
|
|
||||||
|
// 8 wildcard: mandato aperto
|
||||||
|
const wildTasks = Array.from({ length: 8 }, (_, k) => () => agent(
|
||||||
|
CONTEXT +
|
||||||
|
`\n\nSEI UN AGENTE WILDCARD #${k + 1}. Mandato APERTO: inventa una combinazione FRATTALE-del-segnale x ` +
|
||||||
|
`REGIME-ARGO NON banale e non nella griglia ovvia. Idee: interazione hurst*vrp (mean-rev solo se ` +
|
||||||
|
`anti-persistente E vol sopravvalutata); Williams pivot come TP/SL adattivo gateato da dvol; analog kNN ` +
|
||||||
|
`pesato per funding; size/exit modulati dal regime; combinare 2 segnali frattali con conferma di regime. ` +
|
||||||
|
`Asset e TF a tua scelta (prova entrambi gli asset). Costruisci, testa onesto (report()+robust()), riporta ` +
|
||||||
|
`la migliore. Diversifica dagli altri: varia idea in base a #${k + 1}. Schema in output.`,
|
||||||
|
{ label: `wild:${k + 1}`, phase: 'Search', schema: SCHEMA }))
|
||||||
|
|
||||||
|
const searchResults = (await parallel([...gridTasks, ...wildTasks])).filter(Boolean)
|
||||||
|
|
||||||
|
// survivor = robust, oppure promising con oos_sharpe alto e abbastanza trade
|
||||||
|
const survivors = searchResults.filter(r =>
|
||||||
|
(r.robust || (r.promising && (r.oos_sharpe || 0) >= 1.0)) && (r.trades || 0) >= 30)
|
||||||
|
log(`Search: ${searchResults.length} testati, ${survivors.length} survivor da verificare`)
|
||||||
|
|
||||||
|
phase('Verify')
|
||||||
|
const VSCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
strategy: { type: 'string' }, confirmed: { type: 'boolean' },
|
||||||
|
reason: { type: 'string', description: 'esito audit look-ahead + fee0.2% + altro asset + split alternativo' },
|
||||||
|
oos_sharpe_recheck: { type: 'number' }, killed_by: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['strategy', 'confirmed', 'reason'],
|
||||||
|
}
|
||||||
|
let verified = []
|
||||||
|
if (survivors.length) {
|
||||||
|
verified = (await parallel(survivors.map(s => () => agent(
|
||||||
|
CONTEXT +
|
||||||
|
`\n\nVERIFICA AVVERSARIALE di un candidato survivor:\n${JSON.stringify(s, null, 1)}\n\n` +
|
||||||
|
`Tuo compito: PROVARE A FALSIFICARLO. (1) Ricostruisci la strategia (chiedi i dettagli dal suo edge_desc; ` +
|
||||||
|
`riusa regime_lab). (2) AUDIT look-ahead: ogni colonna/calcolo usa solo dati <= i? Il gate regime e' noto a i? ` +
|
||||||
|
`(3) Regge fee 0.2% RT in OOS? (4) Regge sull'ALTRO asset (se BTC prova ETH e viceversa)? (5) Regge a uno SPLIT ` +
|
||||||
|
`OOS alternativo (es. train<=2024, test 2025-26)? (6) Numero trade sufficiente e non concentrato in 1 anno? ` +
|
||||||
|
`Default a confirmed=FALSE se incerto o se sopravvive solo per overfit. Sii spietato. Schema in output.`,
|
||||||
|
{ label: `verify:${(s.strategy || '').slice(0, 24)}`, phase: 'Verify', schema: VSCHEMA })))).filter(Boolean)
|
||||||
|
}
|
||||||
|
const confirmed = verified.filter(v => v.confirmed)
|
||||||
|
|
||||||
|
phase('Synth')
|
||||||
|
const synthesis = await agent(
|
||||||
|
CONTEXT +
|
||||||
|
`\n\nHai i risultati di ${searchResults.length} agenti di ricerca e ${verified.length} verifiche avversariali.\n\n` +
|
||||||
|
`SURVIVOR CONFERMATI:\n${JSON.stringify(confirmed, null, 1)}\n\n` +
|
||||||
|
`TUTTI I SURVIVOR (anche non confermati):\n${JSON.stringify(survivors, null, 1)}\n\n` +
|
||||||
|
`TOP 15 per oos_sharpe fra tutti i testati:\n${JSON.stringify(
|
||||||
|
searchResults.slice().sort((a, b) => (b.oos_sharpe || 0) - (a.oos_sharpe || 0)).slice(0, 15), null, 1)}\n\n` +
|
||||||
|
`Produci la SINTESI FINALE (italiano) per il decisore:
|
||||||
|
1) VERDETTO: esiste una strategia frattale x ARGO con edge validato OOS? quale/i (confermate)?
|
||||||
|
2) Tabella dei top candidati: strategia, asset/tf, OOS Sharpe, OOS ret, DD, robust, confermato?
|
||||||
|
3) Il regime ARGO (DVOL/VRP/funding) AGGIUNGE valore al segnale frattale (vs angolo 'none')? In quali celle?
|
||||||
|
4) Cosa e' rumore e perche (coerente coi priori: frattali deboli, opzioni scartate).
|
||||||
|
5) Se c'e un vincitore: piano di implementazione (file in scripts/strategies/, MODULE_MAP, validazione finale).
|
||||||
|
Se NON c'e: dillo chiaro, niente forzature.
|
||||||
|
Cita NUMERI reali (OOS Sharpe, ret, trades). Onesta brutale: deve battere PORT06, non solo essere >0.`,
|
||||||
|
{ label: 'synthesis', phase: 'Synth' })
|
||||||
|
|
||||||
|
return { searchResults, survivors, confirmed, synthesis }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Smoke end-to-end dell'esecuzione REALE su Deribit testnet.
|
||||||
|
|
||||||
|
Dimostra il giro completo che serve al live:
|
||||||
|
account → OPEN (ordine reale minimo) → VERIFICA posizione su Deribit →
|
||||||
|
FEE reale dai trade → CLOSE → VERIFICA flat → riepilogo.
|
||||||
|
|
||||||
|
Usa il sizing MINIMO del contratto (BTC $10, ETH $1): costo testnet = €0.
|
||||||
|
NON tocca il runner: e' solo una prova della macchina di esecuzione.
|
||||||
|
|
||||||
|
uv run python scripts/analysis/live_exec_smoke.py # ETH + BTC
|
||||||
|
uv run python scripts/analysis/live_exec_smoke.py ETH-PERPETUAL
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
from src.live.execution import ExecutionClient, contract_spec, settlement_currency
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_one(ex: ExecutionClient, instrument: str, notional: float = 10.0) -> bool:
|
||||||
|
spec = contract_spec(instrument)
|
||||||
|
cur = settlement_currency(instrument)
|
||||||
|
kind = "lineare USDC" if spec.get("linear") else "inverse"
|
||||||
|
print(f"\n===== {instrument} ({kind}, ~${notional:.0f} notional) =====")
|
||||||
|
|
||||||
|
pre = ex._position_size(instrument)
|
||||||
|
print(f" posizione pre: {pre} USD (atteso 0)")
|
||||||
|
|
||||||
|
print(f" → OPEN buy ${notional:.0f} notional ...")
|
||||||
|
f = ex.open(instrument, "buy", notional, label="smoke-exec")
|
||||||
|
print(f" order_id={f.order_id} state={f.order_state} verified={f.verified}")
|
||||||
|
print(f" fill_price={f.fill_price} amount={f.amount} ({'BTC' if spec.get('linear') else 'USD'})")
|
||||||
|
print(f" FEE reale: {f.fee_coin:.10f} {cur} (~${f.fee_usd:.4f})")
|
||||||
|
if f.notes:
|
||||||
|
print(f" note: {f.notes}")
|
||||||
|
if not f.verified:
|
||||||
|
print(" ✗ OPEN NON verificato — interrompo questo strumento")
|
||||||
|
return False
|
||||||
|
print(" ✓ posizione confermata su Deribit (riletta da get_positions)")
|
||||||
|
|
||||||
|
print(" → CLOSE ...")
|
||||||
|
c = ex.close(instrument, label="smoke-exec")
|
||||||
|
print(f" order_id={c.order_id} state={c.order_state} verified(flat)={c.verified}")
|
||||||
|
print(f" fill_price={c.fill_price} FEE reale: {c.fee_coin:.10f} {cur} (~${c.fee_usd:.4f})")
|
||||||
|
if c.notes:
|
||||||
|
print(f" note: {c.notes}")
|
||||||
|
|
||||||
|
post = ex._position_size(instrument)
|
||||||
|
ok = f.verified and c.verified and post == 0
|
||||||
|
# fee RT reale osservata vs assunto 0.10% RT sul notional
|
||||||
|
fee_usd_rt = f.fee_usd + c.fee_usd
|
||||||
|
assumed_rt = notional * 0.001
|
||||||
|
print(f" posizione post: {post} USD (atteso 0)")
|
||||||
|
print(f" FEE RT reale ~${fee_usd_rt:.4f} vs assunto 0.10% RT ~${assumed_rt:.4f}")
|
||||||
|
print(f" {'✓ OK' if ok else '✗ FALLITO'} — giro completo {instrument}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
instruments = sys.argv[1:] or ["ETH-PERPETUAL", "BTC-PERPETUAL"]
|
||||||
|
client = CerberoClient()
|
||||||
|
|
||||||
|
acct = client.get_account_summary(currency="USDC")
|
||||||
|
print(f"Account testnet: equity={acct.get('equity')} USDC testnet={acct.get('testnet')}")
|
||||||
|
if not acct.get("testnet"):
|
||||||
|
print("✗ ABORT: non e' testnet — niente ordini reali su mainnet.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ex = ExecutionClient(client=client)
|
||||||
|
results = {inst: smoke_one(ex, inst) for inst in instruments}
|
||||||
|
|
||||||
|
print("\n===== RIEPILOGO =====")
|
||||||
|
for inst, ok in results.items():
|
||||||
|
print(f" {inst:16s} {'✓ OK' if ok else '✗ FALLITO'}")
|
||||||
|
sys.exit(0 if all(results.values()) else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi).
|
||||||
|
|
||||||
|
Apre e chiude la quota di UN worker fade come farebbe il runner:
|
||||||
|
_open_position (sim + REAL_OPEN reale su BTC_USDC) → _close_position (sim +
|
||||||
|
REAL_CLOSE reduce-only) → controlla che real_capital sia aggiornato dal fill reale.
|
||||||
|
|
||||||
|
Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0.
|
||||||
|
|
||||||
|
uv run python scripts/analysis/live_shadow_smoke.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
from src.live.execution import ExecutionClient
|
||||||
|
from src.live.strategy_loader import load_strategy
|
||||||
|
from src.live.strategy_worker import StrategyWorker
|
||||||
|
from src.strategies.base import Signal
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
client = CerberoClient()
|
||||||
|
acct = client.get_account_summary(currency="USDC")
|
||||||
|
print(f"Account testnet equity={acct.get('equity')} USDC testnet={acct.get('testnet')}")
|
||||||
|
if not acct.get("testnet"):
|
||||||
|
raise SystemExit("ABORT: non testnet")
|
||||||
|
|
||||||
|
ex = ExecutionClient(client=client)
|
||||||
|
instrument = "BTC_USDC-PERPETUAL"
|
||||||
|
price = ex._mark_price(instrument)
|
||||||
|
print(f"{instrument} mark={price}")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
w = StrategyWorker(
|
||||||
|
strategy=load_strategy("MR01_bollinger_fade"),
|
||||||
|
asset="BTC", tf="1h", capital=100.0, position_size=0.15, leverage=2.0,
|
||||||
|
data_dir=Path(tmp), executor=ex, exec_instrument=instrument,
|
||||||
|
)
|
||||||
|
print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}")
|
||||||
|
|
||||||
|
# OPEN long (sim + reale)
|
||||||
|
sig = Signal(idx=0, direction=1, entry_price=price, metadata={})
|
||||||
|
w._open_position(sig, price)
|
||||||
|
print(f" real_in_position={w.real_in_position} side={w.real_side} "
|
||||||
|
f"amount={w.real_amount} entry={w.real_entry_price} "
|
||||||
|
f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}")
|
||||||
|
assert w.real_in_position, "OPEN reale non verificato"
|
||||||
|
|
||||||
|
# CLOSE (sim + reale reduce-only) a un prezzo leggermente diverso
|
||||||
|
exit_price = (w.entry_price or price) * 1.001
|
||||||
|
cap_before = w.real_capital
|
||||||
|
w._close_position(exit_price, "smoke_close")
|
||||||
|
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
|
||||||
|
f"(Δ {w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
|
||||||
|
assert not w.real_in_position, "posizione reale non chiusa"
|
||||||
|
|
||||||
|
# verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker)
|
||||||
|
pos = ex._position_size(instrument)
|
||||||
|
print(f" posizione netta {instrument}: {pos}")
|
||||||
|
print("✓ catena shadow OK — ordine reale aperto, verificato, chiuso reduce-only, "
|
||||||
|
"fee reali nel ledger reale")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Smoke test REALE dei pairs: fetch live da Cerbero + un tick vero per coppia.
|
||||||
|
|
||||||
|
A differenza di validate_worker_pairs.py (replay su parquet storici), questo verifica
|
||||||
|
la PIPELINE LIVE end-to-end: chiama Cerbero per entrambe le gambe, controlla che lo
|
||||||
|
strumento esista e sia fresco, fa un tick reale del PairsWorker e riporta lo stato.
|
||||||
|
|
||||||
|
Serve a scoprire i problemi che il backtest nasconde (es. un perp alt non disponibile
|
||||||
|
sull'endpoint Deribit). NON apre ordini reali: e' solo paper/lettura.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
from src.live.multi_runner import INSTRUMENT_MAP
|
||||||
|
from src.live.pairs_worker import PairsWorker
|
||||||
|
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(cli, asset, start, end):
|
||||||
|
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
|
||||||
|
try:
|
||||||
|
cs = cli.get_historical(inst, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), "60")
|
||||||
|
if not cs:
|
||||||
|
return inst, None, "VUOTO (strumento assente sull'endpoint)"
|
||||||
|
df = pd.DataFrame(cs)
|
||||||
|
df["timestamp"] = df["timestamp"].astype("int64")
|
||||||
|
df = df.sort_values("timestamp").reset_index(drop=True)
|
||||||
|
last = pd.to_datetime(df["timestamp"].iloc[-1], unit="ms", utc=True)
|
||||||
|
age = (datetime.now(timezone.utc) - last).total_seconds() / 3600
|
||||||
|
return inst, df, f"{len(df)} barre, ultima {last:%Y-%m-%d %H:%M} ({age:.1f}h fa)"
|
||||||
|
except Exception as e:
|
||||||
|
return inst, None, f"ERRORE {type(e).__name__}: {str(e)[:60]}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cli = CerberoClient()
|
||||||
|
end = datetime.now(timezone.utc)
|
||||||
|
start = end - timedelta(days=60)
|
||||||
|
assets = sorted({a for a, _, _ in PAIRS} | {b for _, b, _ in PAIRS})
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print(" SMOKE TEST LIVE PAIRS — fetch reale Cerbero + tick (no ordini reali)")
|
||||||
|
print("=" * 80)
|
||||||
|
data = {}
|
||||||
|
for a in assets:
|
||||||
|
inst, df, msg = fetch(cli, a, start, end)
|
||||||
|
data[a] = df
|
||||||
|
print(f" {a:<4s} [{inst:<16s}] {msg}")
|
||||||
|
|
||||||
|
print("\n tick reale per coppia:")
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
try:
|
||||||
|
for a, b, p in PAIRS:
|
||||||
|
if data.get(a) is None or data.get(b) is None:
|
||||||
|
print(f" {a}/{b:<4s}: SKIP (manca feed live di una gamba) -> non tradabile live ora")
|
||||||
|
continue
|
||||||
|
w = PairsWorker(a, b, "1h", params=p, fee_rt=0.001, data_dir=tmp)
|
||||||
|
w._log = lambda *x, **k: None
|
||||||
|
w._notify = lambda *x, **k: None
|
||||||
|
m = data[a][["timestamp", "close"]].merge(
|
||||||
|
data[b][["timestamp", "close"]], on="timestamp", how="inner")
|
||||||
|
if len(m) < p["n"] + 2:
|
||||||
|
print(f" {a}/{b:<4s}: merge {len(m)} barre < n+2 ({p['n']+2}) -> dati insufficienti")
|
||||||
|
continue
|
||||||
|
z, _ = w._zscore(m["close_x"].values, m["close_y"].values)
|
||||||
|
w.tick(data[a], data[b])
|
||||||
|
state = ("IN POS " + ("LONG " + a if w.direction == 1 else "SHORT " + a)
|
||||||
|
if w.in_position else "FLAT")
|
||||||
|
print(f" {a}/{b:<4s}: OK merge {len(m)} barre, z_ora={z[-1]:+.2f} -> {state}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
print("\n Solo le coppie con entrambe le gambe fresche su Cerbero sono tradabili live.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Proiezione a 3 anni del portafoglio live (PORT06) ESCLUDENDO il 2024.
|
||||||
|
|
||||||
|
Risponde a: "partendo da 1000 EUR, con capitale che compone e puntata che cresce
|
||||||
|
di conseguenza, quale guadagno giornaliero atteso e quale traiettoria a 3 anni?"
|
||||||
|
|
||||||
|
Punti chiave (perche' i numeri differiscono da report_families):
|
||||||
|
- report_families/Portfolio.backtest usano le curve sleeve NATIVE a leva 3x.
|
||||||
|
- Il container LIVE (src.portfolio.runner via portfolios.yml) gira a pos 0.15 x 2x.
|
||||||
|
- Il PnL giornaliero scala ESATTAMENTE con la leva: pnl = cap * pos * lev * (ret - fee),
|
||||||
|
stesso segnale -> ratio 2/3 per gli sleeve leverati.
|
||||||
|
- ROT02 e TSM01 NON si riscalano: usano `gross` (0.45 / 0.30), indipendente dalla leva
|
||||||
|
e identico fra backtest e worker live.
|
||||||
|
- Il 2024 e' escluso perche' anno eccezionale (crypto +; gonfia ogni stima).
|
||||||
|
|
||||||
|
CAVEAT (le proiezioni sono OTTIMISTICHE):
|
||||||
|
- 2021-2025 e' quasi tutto bull/recovery; poco orso/flat prolungato nel campione.
|
||||||
|
- Dati alt = testnet (volume sottile, fill/slippage NON modellati).
|
||||||
|
- OOS singolo (2024-25) = regime calmo -> ~50% ottimistico (vedi report_families (D)).
|
||||||
|
Lo scenario SOBRIO (haircut ~50%) e' il numero prudente su cui pianificare.
|
||||||
|
|
||||||
|
Run: uv run python scripts/analysis/projection_3y.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
|
||||||
|
from scripts.analysis.combine_portfolio import port_returns
|
||||||
|
|
||||||
|
# sleeve la cui esposizione NON dipende dalla leva (gross-based) -> non si riscalano
|
||||||
|
NOSCALE = {"ROT02_rot", "TSM01"}
|
||||||
|
NATIVE_LEV = 3.0 # leva delle curve sleeve in combine_portfolio
|
||||||
|
EXCLUDE_YEAR = 2024
|
||||||
|
START_CAPITAL = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def live_portfolio_returns(live_leverage: float = 2.0) -> pd.Series:
|
||||||
|
"""Rendimenti giornalieri di PORT06 al sizing LIVE (pos 0.15 x `live_leverage`).
|
||||||
|
Riscala gli sleeve leverati di live_leverage/NATIVE_LEV; ROT/TSM invariati."""
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
p.leverage = live_leverage
|
||||||
|
p.weighting = "cap"
|
||||||
|
p.caps = {"PAIRS": 0.33}
|
||||||
|
|
||||||
|
eq = all_sleeve_equities()
|
||||||
|
dr = sleeve_returns_df(p.sleeve_ids)
|
||||||
|
w = p.weight_vector(dr)
|
||||||
|
|
||||||
|
scale = live_leverage / NATIVE_LEV
|
||||||
|
eq_live = {}
|
||||||
|
for sid in p.sleeve_ids:
|
||||||
|
r = eq[sid].pct_change().fillna(0.0)
|
||||||
|
r = r if sid in NOSCALE else r * scale
|
||||||
|
eq_live[sid] = (1 + r).cumprod()
|
||||||
|
pr = port_returns(eq_live, w)
|
||||||
|
pr.index = pd.to_datetime(pr.index)
|
||||||
|
return pr
|
||||||
|
|
||||||
|
|
||||||
|
def stats_ex_year(pr: pd.Series, exclude: int = EXCLUDE_YEAR) -> dict:
|
||||||
|
ex = pr[pr.index.year != exclude]
|
||||||
|
n = len(ex)
|
||||||
|
years = n / 365.0
|
||||||
|
tot = (1 + ex).prod() - 1
|
||||||
|
cagr = (1 + tot) ** (1 / years) - 1
|
||||||
|
yvals = [(1 + g).prod() - 1 for _, g in ex.groupby(ex.index.year)]
|
||||||
|
return {
|
||||||
|
"days": n, "years": years, "total": tot, "cagr": cagr,
|
||||||
|
"year_median": float(np.median(yvals)), "year_mean": float(np.mean(yvals)),
|
||||||
|
"daily_mean_eur": float(ex.mean() * START_CAPITAL),
|
||||||
|
"daily_median_eur": float(ex.median() * START_CAPITAL),
|
||||||
|
"daily_std_eur": float(ex.std() * START_CAPITAL),
|
||||||
|
"pos_days": float((ex > 0).mean()),
|
||||||
|
"per_year": {int(y): float((1 + g).prod() - 1) for y, g in pr.groupby(pr.index.year)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def project(annual: float, years: int = 3, start: float = START_CAPITAL) -> list[dict]:
|
||||||
|
"""Capitale che compone; la puntata cresce col capitale -> EUR/giorno cresce."""
|
||||||
|
rows, cap = [], start
|
||||||
|
for yr in range(1, years + 1):
|
||||||
|
s = cap
|
||||||
|
cap = cap * (1 + annual)
|
||||||
|
rows.append({"year": yr, "start": s, "end": cap,
|
||||||
|
"gain": cap - s, "eur_per_day": (cap - s) / 365.0})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def years_to_target(daily_target: float, annual: float, start: float = START_CAPITAL) -> float:
|
||||||
|
"""Anni per raggiungere un certo EUR/giorno componendo (capitale = target*365/cagr)."""
|
||||||
|
cap_needed = daily_target * 365.0 / annual
|
||||||
|
if cap_needed <= start:
|
||||||
|
return 0.0
|
||||||
|
return float(np.log(cap_needed / start) / np.log(1 + annual))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pr = live_portfolio_returns(live_leverage=2.0)
|
||||||
|
s = stats_ex_year(pr)
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print(" PORT06 LIVE (pos 0.15 x 2x) — proiezione 3 anni, ESCLUSO 2024")
|
||||||
|
print("=" * 72)
|
||||||
|
print(" Rendimento live per anno:")
|
||||||
|
for y, v in s["per_year"].items():
|
||||||
|
flag = " <-- ESCLUSO" if y == EXCLUDE_YEAR else ""
|
||||||
|
print(f" {y}: {v * 100:+6.1f}%{flag}")
|
||||||
|
print()
|
||||||
|
print(f" CAGR (escl 2024): {s['cagr'] * 100:5.1f}% "
|
||||||
|
f"[{s['years']:.2f} anni di dati]")
|
||||||
|
print(f" anno mediano: {s['year_median'] * 100:5.1f}%")
|
||||||
|
print(f" anno medio: {s['year_mean'] * 100:5.1f}%")
|
||||||
|
print(f" EUR/giorno su 1000: media {s['daily_mean_eur']:.2f} | "
|
||||||
|
f"mediana {s['daily_median_eur']:.2f} | std {s['daily_std_eur']:.2f}")
|
||||||
|
print(f" giorni positivi: {s['pos_days'] * 100:.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
scenarios = [
|
||||||
|
("CAGR backtest escl-2024", s["cagr"]),
|
||||||
|
("anno mediano", s["year_median"]),
|
||||||
|
("SOBRIO (haircut ~50%)", s["cagr"] * 0.5),
|
||||||
|
]
|
||||||
|
for name, g in scenarios:
|
||||||
|
print(f" -- 3 anni @ {g * 100:.0f}%/anno ({name}) --")
|
||||||
|
for r in project(g):
|
||||||
|
print(f" anno {r['year']}: {r['start']:7.0f} -> {r['end']:7.0f} EUR "
|
||||||
|
f"(+{r['gain']:5.0f}, ~{r['eur_per_day']:4.2f} EUR/g medi)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" -- Target 50 EUR/giorno (reality check) --")
|
||||||
|
for name, g in scenarios[:1] + scenarios[2:]:
|
||||||
|
cap_needed = 50.0 * 365.0 / g
|
||||||
|
t = years_to_target(50.0, g)
|
||||||
|
print(f" @ {g * 100:.0f}%/anno: servono ~{cap_needed:,.0f} EUR schierati "
|
||||||
|
f"-> da 1000 EUR, ~{t:.0f} anni componendo")
|
||||||
|
print(" => il collo di bottiglia e' il CAPITALE iniziale, non la strategia.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Fetch dati REGIME backtestabili da Deribit MAINNET (public, no-auth) -> parquet.
|
||||||
|
|
||||||
|
Abilita la ricerca strategie frattali x regime (ARGO-proxy). Salva in data/raw/:
|
||||||
|
{btc,eth}_dvol.parquet : DVOL index 1h (IV 30d "VIX crypto"), storico ~2021->oggi
|
||||||
|
{btc,eth}_funding.parquet : funding rate perp 1h, storico ~2019->oggi
|
||||||
|
|
||||||
|
Solo componenti ARGO con STORICO GRATUITO (DVOL, funding) -> validabili OOS. Il GEX
|
||||||
|
per-strike resta snapshot-only (vedi analisi 2026-06-01). Run:
|
||||||
|
uv run python scripts/analysis/regime_fetcher.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RAW = ROOT / "data" / "regime" # NON data/raw (solo OHLCV) — evita pollution discovery asset
|
||||||
|
BASE = "https://www.deribit.com/api/v2/public/"
|
||||||
|
|
||||||
|
|
||||||
|
def _get(method: str, params: dict) -> dict:
|
||||||
|
url = BASE + method + "?" + urllib.parse.urlencode(params)
|
||||||
|
for _ in range(4):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
except Exception:
|
||||||
|
time.sleep(1.0)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_dvol(currency: str, start_ms: int, end_ms: int, res: int = 3600) -> pd.DataFrame:
|
||||||
|
"""DVOL index (OHLC). Cap 1000 righe/chiamata -> chaining all'indietro."""
|
||||||
|
rows = []
|
||||||
|
cur_end = end_ms
|
||||||
|
span = 1000 * res * 1000
|
||||||
|
while cur_end > start_ms:
|
||||||
|
cur_start = max(start_ms, cur_end - span)
|
||||||
|
d = _get("get_volatility_index_data", {
|
||||||
|
"currency": currency, "start_timestamp": cur_start,
|
||||||
|
"end_timestamp": cur_end, "resolution": res})
|
||||||
|
data = (d.get("result") or {}).get("data") or []
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
rows.extend(data)
|
||||||
|
oldest = min(x[0] for x in data)
|
||||||
|
if oldest >= cur_end:
|
||||||
|
break
|
||||||
|
cur_end = oldest - 1
|
||||||
|
time.sleep(0.15)
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close"])
|
||||||
|
df = df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||||
|
df["dvol"] = df["close"]
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_funding(instrument: str, start_ms: int, end_ms: int) -> pd.DataFrame:
|
||||||
|
"""funding rate history perp (1h). Paginazione ~30g/chiamata."""
|
||||||
|
rows = []
|
||||||
|
cur_start = start_ms
|
||||||
|
step = 30 * 24 * 3600 * 1000
|
||||||
|
while cur_start < end_ms:
|
||||||
|
cur_end = min(end_ms, cur_start + step)
|
||||||
|
d = _get("get_funding_rate_history", {
|
||||||
|
"instrument_name": instrument,
|
||||||
|
"start_timestamp": cur_start, "end_timestamp": cur_end})
|
||||||
|
data = d.get("result") or []
|
||||||
|
if data:
|
||||||
|
rows.extend(data)
|
||||||
|
cur_start = cur_end + 1
|
||||||
|
time.sleep(0.12)
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
ts_col = "timestamp" if "timestamp" in df.columns else df.columns[0]
|
||||||
|
df = df.rename(columns={ts_col: "timestamp"})
|
||||||
|
keep = [c for c in ("timestamp", "interest_1h", "interest_8h", "index_price", "prev_index_price") if c in df.columns]
|
||||||
|
df = df[keep].drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
RAW.mkdir(parents=True, exist_ok=True)
|
||||||
|
now = _get("get_time", {})
|
||||||
|
end_ms = int(now.get("result", 0)) or int(time.time() * 1000)
|
||||||
|
start_ms = end_ms - int(6.5 * 365 * 24 * 3600 * 1000) # ~6.5 anni
|
||||||
|
for cur, inst in (("BTC", "BTC-PERPETUAL"), ("ETH", "ETH-PERPETUAL")):
|
||||||
|
dv = fetch_dvol(cur, start_ms, end_ms)
|
||||||
|
if not dv.empty:
|
||||||
|
p = RAW / f"{cur.lower()}_dvol.parquet"
|
||||||
|
dv.to_parquet(p)
|
||||||
|
rng = (pd.to_datetime(dv['timestamp'].min(), unit='ms').date(),
|
||||||
|
pd.to_datetime(dv['timestamp'].max(), unit='ms').date())
|
||||||
|
print(f" {cur} DVOL: {len(dv)} righe {rng[0]}->{rng[1]} (ora={dv['dvol'].iloc[-1]:.1f}) -> {p.name}")
|
||||||
|
fr = fetch_funding(inst, start_ms, end_ms)
|
||||||
|
if not fr.empty:
|
||||||
|
p = RAW / f"{cur.lower()}_funding.parquet"
|
||||||
|
fr.to_parquet(p)
|
||||||
|
rng = (pd.to_datetime(fr['timestamp'].min(), unit='ms').date(),
|
||||||
|
pd.to_datetime(fr['timestamp'].max(), unit='ms').date())
|
||||||
|
print(f" {cur} FUNDING: {len(fr)} righe {rng[0]}->{rng[1]} -> {p.name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""regime_lab — API condivisa per la ricerca strategie FRATTALI x REGIME (ARGO-proxy).
|
||||||
|
|
||||||
|
Allinea prezzo (OHLCV) + DVOL + funding in modo CAUSALE (no look-ahead: il valore di
|
||||||
|
regime alla barra i usa solo dati <= timestamp[i]) ed espone:
|
||||||
|
- feature REGIME (ARGO-proxy backtestabili): dvol, dvol_pct (percentile rolling),
|
||||||
|
rv (realized vol), vrp = dvol - rv, funding, funding_z, dvol_chg (proxy term-structure).
|
||||||
|
- feature FRATTALI (src/fractal): rolling_hurst, higuchi, self_similarity, volatility_ratio,
|
||||||
|
williams fractals (pivot), candle encoding.
|
||||||
|
- validazione: report(name, entries, df) -> full/oos netto-fee + robustezza griglia/fee,
|
||||||
|
riusando l'engine onesto di explore_lab (simulate/evaluate).
|
||||||
|
|
||||||
|
Convenzione entries (come explore_lab): lista di dict {i, d (+1/-1), tp, sl, max_bars}.
|
||||||
|
Ingresso ESEGUIBILE: i, d, tp, sl decisi con dati <= close[i].
|
||||||
|
|
||||||
|
Uso tipico in un agente:
|
||||||
|
from scripts.analysis.regime_lab import load, report, regime_features, frac_features
|
||||||
|
df = load("BTC", "1h") # OHLCV + colonne regime allineate
|
||||||
|
R = regime_features(df); F = frac_features(df)
|
||||||
|
entries = [...] # la tua logica
|
||||||
|
print(report("MIA_STRATEGIA", entries, df))
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import get_df, simulate, evaluate, atr, ema, rsi # noqa: E402
|
||||||
|
from src.fractal.indicators import ( # noqa: E402
|
||||||
|
rolling_hurst, fractal_dimension_higuchi, self_similarity_score, volatility_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
# dati regime (DVOL/funding/feature) in data/regime/ — NON in data/raw/ (che e' solo OHLCV: i file
|
||||||
|
# estranei in data/raw inquinano la discovery asset del backtest). Vedi diary 2026-06-02-fade-lossguard.
|
||||||
|
RAW = ROOT / "data" / "regime"
|
||||||
|
RAW.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- dati
|
||||||
|
def _load_regime_series(asset: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
|
a = asset.lower()
|
||||||
|
dvol = pd.read_parquet(RAW / f"{a}_dvol.parquet") if (RAW / f"{a}_dvol.parquet").exists() else pd.DataFrame()
|
||||||
|
fund = pd.read_parquet(RAW / f"{a}_funding.parquet") if (RAW / f"{a}_funding.parquet").exists() else pd.DataFrame()
|
||||||
|
return dvol, fund
|
||||||
|
|
||||||
|
|
||||||
|
def load(asset: str, tf: str) -> pd.DataFrame:
|
||||||
|
"""OHLCV (explore_lab.get_df) + colonne regime allineate CAUSALMENTE (merge_asof backward).
|
||||||
|
Ogni barra prezzo riceve l'ultimo DVOL/funding con timestamp <= timestamp barra."""
|
||||||
|
df = get_df(asset, tf).copy()
|
||||||
|
df["timestamp"] = df["timestamp"].astype("int64")
|
||||||
|
dvol, fund = _load_regime_series(asset)
|
||||||
|
if not dvol.empty:
|
||||||
|
d = dvol[["timestamp", "dvol"]].astype({"timestamp": "int64"}).sort_values("timestamp")
|
||||||
|
df = pd.merge_asof(df.sort_values("timestamp"), d, on="timestamp", direction="backward")
|
||||||
|
else:
|
||||||
|
df["dvol"] = np.nan
|
||||||
|
if not fund.empty:
|
||||||
|
col = "interest_1h" if "interest_1h" in fund.columns else fund.columns[1]
|
||||||
|
f = fund[["timestamp", col]].astype({"timestamp": "int64"}).rename(columns={col: "funding"}).sort_values("timestamp")
|
||||||
|
df = pd.merge_asof(df.sort_values("timestamp"), f, on="timestamp", direction="backward")
|
||||||
|
else:
|
||||||
|
df["funding"] = np.nan
|
||||||
|
return df.reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- feature REGIME
|
||||||
|
def _rolling_pct(x: np.ndarray, win: int) -> np.ndarray:
|
||||||
|
"""Percentile rolling CAUSALE: rank di x[i] nella finestra [i-win, i] (solo passato)."""
|
||||||
|
s = pd.Series(x)
|
||||||
|
return s.rolling(win, min_periods=max(20, win // 4)).apply(
|
||||||
|
lambda w: (w.iloc[-1] >= w).mean(), raw=False).values
|
||||||
|
|
||||||
|
|
||||||
|
_BARS_PER_YEAR = {"1h": 24 * 365, "4h": 6 * 365, "1d": 365}
|
||||||
|
|
||||||
|
|
||||||
|
def regime_features(df: pd.DataFrame, tf: str = "1h", pct_win: int = 252, rv_win: int = 24,
|
||||||
|
fund_win: int = 168) -> dict:
|
||||||
|
"""Tutte causali. dvol_pct/funding_z usano solo finestra passata. vrp = dvol - rv annualizz.
|
||||||
|
tf serve ad annualizzare correttamente la realized vol (sqrt barre/anno per timeframe)."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
dvol = df["dvol"].values.astype(float)
|
||||||
|
fund = df["funding"].values.astype(float)
|
||||||
|
ret = np.zeros_like(c); ret[1:] = np.diff(np.log(c))
|
||||||
|
# realized vol annualizzata (punti %, scala come DVOL): std rolling * sqrt(barre/anno del tf)
|
||||||
|
bpy = _BARS_PER_YEAR.get(tf, 24 * 365)
|
||||||
|
rv = pd.Series(ret).rolling(rv_win).std().values * np.sqrt(bpy) * 100
|
||||||
|
dvol_pct = _rolling_pct(dvol, pct_win)
|
||||||
|
fmean = pd.Series(fund).rolling(fund_win).mean().values
|
||||||
|
fstd = pd.Series(fund).rolling(fund_win).std().values
|
||||||
|
funding_z = (fund - fmean) / np.where(fstd == 0, np.nan, fstd)
|
||||||
|
dvol_chg = pd.Series(dvol).diff(rv_win).values # proxy term-structure (DVOL in salita/discesa)
|
||||||
|
return {
|
||||||
|
"dvol": dvol, "dvol_pct": dvol_pct, "rv": rv, "vrp": dvol - rv,
|
||||||
|
"funding": fund, "funding_z": funding_z, "dvol_chg": dvol_chg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- feature FRATTALI
|
||||||
|
def williams_fractals(df: pd.DataFrame, k: int = 2) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Pivot di Bill Williams: frac_up[i]=high[i] e' il max delle 2k+1 barre centrate (causale a i+k).
|
||||||
|
Ritorna due array bool (up=swing high confermato, dn=swing low). Confermati con ritardo k."""
|
||||||
|
h, l = df["high"].values, df["low"].values
|
||||||
|
n = len(h)
|
||||||
|
up = np.zeros(n, bool); dn = np.zeros(n, bool)
|
||||||
|
for i in range(k, n - k):
|
||||||
|
if h[i] == max(h[i - k:i + k + 1]):
|
||||||
|
up[i] = True
|
||||||
|
if l[i] == min(l[i - k:i + k + 1]):
|
||||||
|
dn[i] = True
|
||||||
|
return up, dn
|
||||||
|
|
||||||
|
|
||||||
|
def frac_features(df: pd.DataFrame, hurst_win: int = 100, higuchi_win: int = 64,
|
||||||
|
step: int = 1) -> dict:
|
||||||
|
"""Feature frattali rolling, CAUSALI (finestra passata che termina a i). step>1: calcola
|
||||||
|
ogni `step` barre e fa forward-fill (i frattali variano lentamente) -> molto piu' veloce."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
n = len(c)
|
||||||
|
hurst = rolling_hurst(c, window=hurst_win, step=step) # gia' causale + stepped (src/fractal)
|
||||||
|
vratio = np.full(n, np.nan)
|
||||||
|
higuchi = np.full(n, np.nan)
|
||||||
|
last_hi = last_vr = np.nan
|
||||||
|
for i in range(higuchi_win, n):
|
||||||
|
if (i - higuchi_win) % step == 0:
|
||||||
|
last_hi = fractal_dimension_higuchi(c[i - higuchi_win:i])
|
||||||
|
last_vr = volatility_ratio(c[max(0, i - 60):i])
|
||||||
|
higuchi[i] = last_hi
|
||||||
|
vratio[i] = last_vr
|
||||||
|
up, dn = williams_fractals(df)
|
||||||
|
return {"hurst": hurst, "higuchi": higuchi, "vratio": vratio,
|
||||||
|
"frac_up": up, "frac_dn": dn}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- cache
|
||||||
|
_FEATCOLS_R = ("dvol", "dvol_pct", "rv", "vrp", "funding", "funding_z", "dvol_chg")
|
||||||
|
_FEATCOLS_F = ("hurst", "higuchi", "vratio", "frac_up", "frac_dn")
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_path(asset: str, tf: str) -> Path:
|
||||||
|
return RAW / f"features_{asset.lower()}_{tf}.parquet"
|
||||||
|
|
||||||
|
|
||||||
|
def build_cache(asset: str, tf: str, frac_step: int = 6) -> pd.DataFrame:
|
||||||
|
"""Precompute OHLCV + regime + frattali -> parquet condiviso (per i 100 agenti)."""
|
||||||
|
df = load(asset, tf)
|
||||||
|
R = regime_features(df, tf=tf)
|
||||||
|
F = frac_features(df, step=frac_step)
|
||||||
|
for k in _FEATCOLS_R:
|
||||||
|
df[k] = R[k]
|
||||||
|
for k in _FEATCOLS_F:
|
||||||
|
df[k] = F[k]
|
||||||
|
p = _cache_path(asset, tf)
|
||||||
|
df.to_parquet(p)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def load_features(asset: str, tf: str) -> pd.DataFrame:
|
||||||
|
"""Carica la cache feature (la costruisce se manca). OHLCV + tutte le colonne regime+frattali."""
|
||||||
|
p = _cache_path(asset, tf)
|
||||||
|
if p.exists():
|
||||||
|
return pd.read_parquet(p)
|
||||||
|
return build_cache(asset, tf)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- validazione
|
||||||
|
def report(name: str, entries: list[dict], df: pd.DataFrame, asset: str = "", tf: str = "") -> dict:
|
||||||
|
"""Netto-fee full + OOS (ultimo 30%) + sweep fee, via engine onesto di explore_lab.
|
||||||
|
Ritorna dict compatto: trades, full/oos (ret%, sharpe, dd, acc), robust (OK su tutte le fee)."""
|
||||||
|
if not entries:
|
||||||
|
# struttura compatibile con robust() (tutti zero) -> robust()=False pulito, niente crash
|
||||||
|
z = {"ret": 0.0, "sharpe": 0.0, "dd": 0.0, "trades": 0, "win": 0.0, "exposure": 0.0, "yearly": {}}
|
||||||
|
print(f" {name:<24s} NO ENTRIES")
|
||||||
|
return {"full": dict(z), "oos": dict(z), "sweep": {0.0: 0.0, 0.0005: 0.0, 0.001: 0.0, 0.002: 0.0},
|
||||||
|
"sweep_oos": {0.0: 0.0, 0.0005: 0.0, 0.001: 0.0, 0.002: 0.0}, "pos_yrs": 0, "n_yrs": 0}
|
||||||
|
return evaluate(name, entries, df) # full + oos + fee sweep
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# smoke: una fade Bollinger gateata dal regime (DVOL alto) come esempio d'uso
|
||||||
|
df = load("BTC", "1h")
|
||||||
|
R = regime_features(df); F = frac_features(df)
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(50).mean().values
|
||||||
|
sd = pd.Series(c).rolling(50).std().values
|
||||||
|
a = atr(df, 14)
|
||||||
|
ent = []
|
||||||
|
for i in range(300, len(c) - 1):
|
||||||
|
if np.isnan(sd[i]) or np.isnan(R["dvol_pct"][i]):
|
||||||
|
continue
|
||||||
|
if R["dvol_pct"][i] < 0.6: # gate: solo regime DVOL alto
|
||||||
|
continue
|
||||||
|
if c[i] < ma[i] - 2.5 * sd[i]: # fade banda bassa
|
||||||
|
ent.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - 2 * a[i], "max_bars": 24})
|
||||||
|
print(f"smoke BTC 1h fade|DVOL>p60: {len(ent)} entries")
|
||||||
|
print(report("SMOKE", ent, df))
|
||||||
@@ -31,6 +31,7 @@ from scripts.analysis.honest_improve2 import _daily_equity, _norm
|
|||||||
from scripts.analysis.pairs_research import pairs_sim
|
from scripts.analysis.pairs_research import pairs_sim
|
||||||
from scripts.analysis.tsmom_research import tsmom_sim
|
from scripts.analysis.tsmom_research import tsmom_sim
|
||||||
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||||
|
from scripts.analysis.shape_ml_validate import shape_daily_equity
|
||||||
|
|
||||||
YEARS = sorted(set(IDX.year))
|
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"])
|
pairs[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
|
||||||
t = tsmom_sim()
|
t = tsmom_sim()
|
||||||
tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])}
|
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):
|
def yrow(label, dr):
|
||||||
@@ -62,8 +64,8 @@ def metric_block(label, dr):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("Costruzione (puo' richiedere ~1-2 min)...\n")
|
print("Costruzione (puo' richiedere ~2-3 min)...\n")
|
||||||
S, pairs, tsm = build_everything()
|
S, pairs, tsm, shape = build_everything()
|
||||||
fade = {k: v for k, v in S.items() if k.startswith("MR")}
|
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")}
|
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
|
||||||
|
|
||||||
@@ -72,10 +74,12 @@ def main():
|
|||||||
"HONEST": port_returns(honest),
|
"HONEST": port_returns(honest),
|
||||||
"PAIRS": port_returns(pairs),
|
"PAIRS": port_returns(pairs),
|
||||||
"TSM01": tsm["TSM01"].pct_change().fillna(0.0),
|
"TSM01": tsm["TSM01"].pct_change().fillna(0.0),
|
||||||
|
"SHAPE": port_returns(shape),
|
||||||
}
|
}
|
||||||
master9 = port_returns(S)
|
master9 = port_returns(S)
|
||||||
master_p = port_returns({**S, **pairs})
|
master_p = port_returns({**S, **pairs})
|
||||||
master_x = port_returns({**S, **pairs, **tsm})
|
master_x = port_returns({**S, **pairs, **tsm})
|
||||||
|
master_xs = port_returns({**S, **pairs, **tsm, **shape})
|
||||||
|
|
||||||
# ---------- (A) per anno, per FAMIGLIA + portafogli ----------
|
# ---------- (A) per anno, per FAMIGLIA + portafogli ----------
|
||||||
print("=" * 110)
|
print("=" * 110)
|
||||||
@@ -89,12 +93,13 @@ def main():
|
|||||||
print(yrow("MASTER-9", master9))
|
print(yrow("MASTER-9", master9))
|
||||||
print(yrow("MASTER+pairs", master_p))
|
print(yrow("MASTER+pairs", master_p))
|
||||||
print(yrow("MASTER-esteso", master_x))
|
print(yrow("MASTER-esteso", master_x))
|
||||||
|
print(yrow("MASTER+shape", master_xs))
|
||||||
|
|
||||||
# ---------- (B) per anno, per STRATEGIA singola ----------
|
# ---------- (B) per anno, per STRATEGIA singola ----------
|
||||||
print("\n" + "=" * 130)
|
print("\n" + "=" * 130)
|
||||||
print(" (B) RET% NETTO PER ANNO — per STRATEGIA singola (tutti gli sleeve)")
|
print(" (B) RET% NETTO PER ANNO — per STRATEGIA singola (tutti gli sleeve)")
|
||||||
print("=" * 130)
|
print("=" * 130)
|
||||||
allsl = {**S, **pairs, **tsm}
|
allsl = {**S, **pairs, **tsm, **shape}
|
||||||
cols = list(allsl)
|
cols = list(allsl)
|
||||||
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols))
|
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols))
|
||||||
print(" " + "-" * 124)
|
print(" " + "-" * 124)
|
||||||
@@ -112,12 +117,14 @@ def main():
|
|||||||
print(metric_block("MASTER-9", master9))
|
print(metric_block("MASTER-9", master9))
|
||||||
print(metric_block("+pairs", master_p))
|
print(metric_block("+pairs", master_p))
|
||||||
print(metric_block("+TSM01", port_returns({**S, **tsm})))
|
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-esteso", master_x))
|
||||||
|
print(metric_block("MASTER+shape", master_xs))
|
||||||
# correlazione media nuove vs master-9
|
# 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)
|
corr = dr_all.corr(); old = list(S)
|
||||||
print(" " + "-" * 80)
|
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}")
|
print(f" corr {k:<11s} vs MASTER-9 = {corr.loc[k, old].mean():+.2f}")
|
||||||
|
|
||||||
# ---------- (D) numeri sobri ----------
|
# ---------- (D) numeri sobri ----------
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Ricerca sistematica edge nella FORMA (analog forecasting / kNN) — netto fee, OOS.
|
||||||
|
|
||||||
|
Obiettivo: trovare una config di analog forecasting ROBUSTA, cioe' positiva
|
||||||
|
FULL+OOS, che regge fee 0.20% RT e ha quasi tutti gli anni positivi, su >=2 asset.
|
||||||
|
Si combatte la "morte per fee" della baseline (BTC1h W24H12K50 agree0.60:
|
||||||
|
FULL +112%/OOS +48% Sharpe 1.38 ma a 0.2% RT -> FULL -72 / OOS -18, win 49.5%,
|
||||||
|
esposizione 73.9%, 4531 trade) con SELETTIVITA':
|
||||||
|
- agree alto (0.70..0.90) -> entra solo con analoghi molto concordi
|
||||||
|
- conf_atr > 0 -> richiede |rendimento medio analoghi| >= conf_atr*ATR
|
||||||
|
- trend_max/ema_long -> salta forme in trend estremo
|
||||||
|
- tp_atr/sl_atr -> exit intrabar invece che solo a tempo
|
||||||
|
|
||||||
|
Tutto causale: la forma usa solo close<=i, la libreria analoghi termina < i-H.
|
||||||
|
Per performance, il forecast kNN grezzo per barra si calcola UNA volta per
|
||||||
|
(W,H,K,rebuild) con analog_signals(); i filtri (agree/conf/trend/tp/sl) sono
|
||||||
|
applicati a valle con entries_from_signals() (cheap, risultato identico ad
|
||||||
|
analog_entries — verificato). Engine netto-fee + OOS da explore_lab.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
uv run python scripts/analysis/shape_analog_research.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.shape_lab import ( # noqa: E402
|
||||||
|
analog_signals, entries_from_signals, check_no_lookahead,
|
||||||
|
)
|
||||||
|
from scripts.analysis.explore_lab import get_df, evaluate, robust # noqa: E402
|
||||||
|
|
||||||
|
ROBUSTE: list[tuple] = []
|
||||||
|
MIN_TRADES = 100 # un edge "robusto" su <100 trade e' rumore campionario, non edge
|
||||||
|
|
||||||
|
|
||||||
|
def _hdr(s: str) -> None:
|
||||||
|
print("\n" + "=" * 100, flush=True)
|
||||||
|
print(" " + s, flush=True)
|
||||||
|
print("=" * 100, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _eval(df, sig, asset, tf, tag, **filt):
|
||||||
|
ents = entries_from_signals(df, sig, **filt)
|
||||||
|
res = evaluate(f"[{asset} {tf}] {tag}", ents, df)
|
||||||
|
# robusto E con campione sufficiente (un edge su <100 trade non e' affidabile)
|
||||||
|
if robust(res) and res["full"]["trades"] >= MIN_TRADES:
|
||||||
|
print(f" ^^^ ROBUSTA ({asset} {tf}): {tag} filt={filt}", flush=True)
|
||||||
|
ROBUSTE.append((asset, tf, tag, dict(filt), res))
|
||||||
|
elif robust(res):
|
||||||
|
print(f" (robust ma trade={res['full']['trades']}<{MIN_TRADES}: campione "
|
||||||
|
f"insufficiente, ignorato)", flush=True)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
# --- 0) sanity no-lookahead ---------------------------------------------
|
||||||
|
_hdr("0) SANITY no-lookahead (forma causale)")
|
||||||
|
df_btc = get_df("BTC", "1h")
|
||||||
|
check_no_lookahead(df_btc, W=24, H=12)
|
||||||
|
|
||||||
|
# sig base W24H12K50 (riusato per selettivita' agree/conf/tp/sl/trend)
|
||||||
|
sig0 = analog_signals(df_btc, W=24, H=12, K=50, rebuild=250)
|
||||||
|
|
||||||
|
# --- 1) selettivita' via agree ------------------------------------------
|
||||||
|
_hdr("1) BTC 1h — selettivita' agree (W24 H12 K50, time-exit)")
|
||||||
|
for ag in (0.60, 0.70, 0.80, 0.90):
|
||||||
|
_eval(df_btc, sig0, "BTC", "1h", f"agree{ag}", agree=ag)
|
||||||
|
|
||||||
|
# --- 2) conf_atr (forza segnale) ----------------------------------------
|
||||||
|
_hdr("2) BTC 1h — conf_atr (W24 H12 K50 agree0.70)")
|
||||||
|
for ca in (0.0, 0.25, 0.5, 1.0, 1.5):
|
||||||
|
_eval(df_btc, sig0, "BTC", "1h", f"ag0.70 conf{ca}", agree=0.70, conf_atr=ca)
|
||||||
|
|
||||||
|
# --- 3) tp/sl intrabar ---------------------------------------------------
|
||||||
|
_hdr("3) BTC 1h — exit intrabar tp/sl (W24 H12 K50 agree0.70 conf0.5)")
|
||||||
|
for tp, sl in [(1.0, 1.0), (1.5, 1.0), (2.0, 1.5), (1.5, 2.0), (3.0, 2.0)]:
|
||||||
|
_eval(df_btc, sig0, "BTC", "1h", f"tp{tp}sl{sl}",
|
||||||
|
agree=0.70, conf_atr=0.5, tp_atr=tp, sl_atr=sl)
|
||||||
|
|
||||||
|
# --- 4) filtro trend -----------------------------------------------------
|
||||||
|
_hdr("4) BTC 1h — filtro trend_max (W24 H12 K50 agree0.70 conf0.5)")
|
||||||
|
for tm in (None, 2.0, 3.0, 4.0):
|
||||||
|
_eval(df_btc, sig0, "BTC", "1h", f"trend_max{tm}",
|
||||||
|
agree=0.70, conf_atr=0.5, trend_max=tm, ema_long=200)
|
||||||
|
|
||||||
|
# --- 5) griglia W/H/K (agree0.80, time-exit) plateau ---------------------
|
||||||
|
# Griglia focalizzata: con agree0.80 e H>=24 i trade -> ~0 (vedi sez.1), e W>=24
|
||||||
|
# porta OOS negativo; il segnale vive su W piccolo, H breve. Testo il plateau
|
||||||
|
# attorno a quella regione + una banda di controllo (W24/48) per confermare il bordo.
|
||||||
|
_hdr("5) BTC 1h — griglia W/H/K (agree0.80, time-exit) — plateau check")
|
||||||
|
for W in (12, 24, 48):
|
||||||
|
for H in (6, 12, 24):
|
||||||
|
for K in (30, 50, 80):
|
||||||
|
sig = analog_signals(df_btc, W=W, H=H, K=K, rebuild=250)
|
||||||
|
_eval(df_btc, sig, "BTC", "1h", f"W{W}H{H}K{K}", agree=0.80)
|
||||||
|
|
||||||
|
# --- 6) rebuild sensitivity ---------------------------------------------
|
||||||
|
_hdr("6) BTC 1h — rebuild 250 vs 500 (W24 H12 K80 agree0.80)")
|
||||||
|
for rb in (250, 500):
|
||||||
|
sig = analog_signals(df_btc, W=24, H=12, K=80, rebuild=rb)
|
||||||
|
_eval(df_btc, sig, "BTC", "1h", f"rebuild{rb}", agree=0.80)
|
||||||
|
|
||||||
|
# --- 7) cross-asset 1h: candidati selettivi -----------------------------
|
||||||
|
_hdr("7) cross-asset 1h — candidati selettivi (>=2 robusti richiesto)")
|
||||||
|
# (build_kw: per analog_signals) (filt: per entries_from_signals)
|
||||||
|
# Su BTC 1h le uniche regioni con OOS positivo che regge fee0.2% sono W piccolo,
|
||||||
|
# H breve, K basso (W12H12K30: FULL+88/OOS+36, fee0.2% +69/+32, 243 trade, 8/9 anni;
|
||||||
|
# W12H6K30: +35/+11, fee0.2% +20/+7). conf0.25 con W24H12 e' il miglior in-sample
|
||||||
|
# ma OOS@fee~0. Verifico questi candidati cross-asset (>=2 robusti richiesto).
|
||||||
|
candidates = [
|
||||||
|
("C1 W12H12K30 ag.80", dict(W=12, H=12, K=30), dict(agree=0.80)),
|
||||||
|
("C2 W12H6K30 ag.80", dict(W=12, H=6, K=30), dict(agree=0.80)),
|
||||||
|
("C3 W12H12K30 ag.70", dict(W=12, H=12, K=30), dict(agree=0.70)),
|
||||||
|
("C4 W24H12K50 ag.70 conf.25", dict(W=24, H=12, K=50), dict(agree=0.70, conf_atr=0.25)),
|
||||||
|
("C5 W12H12K30 ag.80 trend3", dict(W=12, H=12, K=30), dict(agree=0.80, trend_max=3.0, ema_long=200)),
|
||||||
|
("C6 W12H6K50 ag.70", dict(W=12, H=6, K=50), dict(agree=0.70)),
|
||||||
|
]
|
||||||
|
per_cand: dict[str, int] = {}
|
||||||
|
for asset in ("BTC", "ETH", "ADA", "LTC", "SOL", "XRP"):
|
||||||
|
try:
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
except Exception as ex:
|
||||||
|
print(f" [{asset} 1h] SKIP load: {ex}", flush=True)
|
||||||
|
continue
|
||||||
|
# cache analog_signals per ogni build_kw distinto su questo asset
|
||||||
|
sig_cache: dict[tuple, dict] = {}
|
||||||
|
for tag, bkw, filt in candidates:
|
||||||
|
key = tuple(sorted(bkw.items()))
|
||||||
|
if key not in sig_cache:
|
||||||
|
sig_cache[key] = analog_signals(df, rebuild=250, **bkw)
|
||||||
|
res = _eval(df, sig_cache[key], asset, "1h", tag, **filt)
|
||||||
|
if robust(res):
|
||||||
|
per_cand[tag] = per_cand.get(tag, 0) + 1
|
||||||
|
|
||||||
|
# --- 8) verifica 15m dei candidati robusti su >=2 asset 1h --------------
|
||||||
|
_hdr("8) verifica 15m dei candidati robusti su >=2 asset 1h")
|
||||||
|
good = [t for t, c in per_cand.items() if c >= 2]
|
||||||
|
if not good:
|
||||||
|
print(" Nessun candidato robusto su >=2 asset 1h -> niente verifica 15m.", flush=True)
|
||||||
|
else:
|
||||||
|
for tag in good:
|
||||||
|
_, bkw, filt = next(c for c in candidates if c[0] == tag)
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
try:
|
||||||
|
df = get_df(asset, "15m")
|
||||||
|
except Exception as ex:
|
||||||
|
print(f" [{asset} 15m] SKIP load: {ex}", flush=True)
|
||||||
|
continue
|
||||||
|
sig = analog_signals(df, rebuild=250, **bkw)
|
||||||
|
_eval(df, sig, asset, "15m", f"{tag} (15m)", **filt)
|
||||||
|
|
||||||
|
# --- VERDETTO ------------------------------------------------------------
|
||||||
|
_hdr("VERDETTO")
|
||||||
|
if ROBUSTE:
|
||||||
|
agg: dict[str, list] = {}
|
||||||
|
for asset, tf, tag, filt, res in ROBUSTE:
|
||||||
|
agg.setdefault(tag, []).append(f"{asset}/{tf}")
|
||||||
|
print(f" {len(ROBUSTE)} sleeve robusti (FULL+OOS+ fee0.2% + anniPos):", flush=True)
|
||||||
|
edge = False
|
||||||
|
for tag, asl in agg.items():
|
||||||
|
n_assets = len({a.split('/')[0] for a in asl})
|
||||||
|
mark = " *** EDGE (>=2 asset)" if n_assets >= 2 else " (1 asset: non sufficiente)"
|
||||||
|
if n_assets >= 2:
|
||||||
|
edge = True
|
||||||
|
print(f" - {tag}: {asl}{mark}", flush=True)
|
||||||
|
if not edge:
|
||||||
|
print("\n CONCLUSIONE: nessuna config robusta su >=2 asset -> RUMORE.", flush=True)
|
||||||
|
else:
|
||||||
|
print(" NESSUNA config robusta. Famiglia analog/forma = RUMORE sotto fee reali.", flush=True)
|
||||||
|
return ROBUSTE
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Edge nella FORMA discreta delle candele -> distribuzione condizionale dell'esito.
|
||||||
|
|
||||||
|
Famiglia: encoding DISCRETO della morfologia di una finestra di L candele
|
||||||
|
(sequenza UP/DOWN/DOJI, opzionalmente arricchita con bucket di body-ratio e
|
||||||
|
shadow-ratio) -> codice intero. Per ogni codice si stima la distribuzione del
|
||||||
|
rendimento a H barre usando SOLO le occorrenze PASSATE il cui esito era gia'
|
||||||
|
realizzato prima della barra di decisione i (expanding window causale). Se un
|
||||||
|
codice mostra bias direzionale forte e statisticamente solido (n campioni >=
|
||||||
|
soglia, win-rate o |media| oltre soglia) si ENTRA a close[i] nella direzione del
|
||||||
|
bias; exit a H barre o TP/SL ATR.
|
||||||
|
|
||||||
|
VINCOLI ANTI-LOOK-AHEAD (l'errore squeeze e' nato qui):
|
||||||
|
- il codice a i usa SOLO open/high/low/close fino alla barra i inclusa;
|
||||||
|
- la statistica condizionale a i conta SOLO occorrenze del codice terminate in
|
||||||
|
e con e+H <= i-1 -> il loro esito H e' interamente noto PRIMA di i;
|
||||||
|
- direzione decisa dal CODICE (forma fino a close[i]) + STATISTICHE PASSATE,
|
||||||
|
ingresso eseguibile a close[i].
|
||||||
|
- check_no_lookahead() perturba il futuro: ne' il codice a i ne' le stat usate
|
||||||
|
devono cambiare.
|
||||||
|
|
||||||
|
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
|
||||||
|
Implementazione causale O(N) per codice via accumulatori incrementali (niente
|
||||||
|
ricalcolo dell'intera storia ad ogni barra).
|
||||||
|
|
||||||
|
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m). Default 1h.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import ( # noqa: E402
|
||||||
|
get_df, evaluate, robust, simulate, atr, OOS_FRAC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- encoding discreto della forma ---------------------------
|
||||||
|
def candle_codes(df, L: int, body_buckets: int = 1, shadow_buckets: int = 1) -> np.ndarray:
|
||||||
|
"""Codice intero della forma per la finestra di L candele terminante in i.
|
||||||
|
|
||||||
|
Componenti per ogni candela:
|
||||||
|
- direzione UP/DOWN/DOJI (sempre): 3 stati.
|
||||||
|
- bucket del body-ratio |c-o|/(h-l) (se body_buckets>1): quantizzazione fissa
|
||||||
|
in body_buckets livelli (corpo piccolo/medio/grande...).
|
||||||
|
- bucket dello shadow-ratio (h-max(o,c)-(min(o,c)-l))/(h-l) in [-1,1]
|
||||||
|
(se shadow_buckets>1): ombra sup vs inf.
|
||||||
|
|
||||||
|
Quantizzazione a SOGLIE FISSE (non quantili): non dipende dal futuro ne' dal
|
||||||
|
dataset globale -> causale per costruzione. codes[i] dipende solo da
|
||||||
|
barre [i-L+1 .. i]. Per i < L-1 -> -1 (non valido).
|
||||||
|
"""
|
||||||
|
o = df["open"].values; c = df["close"].values
|
||||||
|
h = df["high"].values; l = df["low"].values
|
||||||
|
n = len(c)
|
||||||
|
rng = np.where((h - l) == 0, 1e-12, h - l)
|
||||||
|
|
||||||
|
body = np.abs(c - o) / rng # [0,1]
|
||||||
|
direction = np.where(body < 0.1, 0, # DOJI
|
||||||
|
np.where(c > o, 1, 2)) # UP=1, DOWN=2 (3 stati: 0,1,2)
|
||||||
|
# shadow asymmetry in [-1,1]: >0 ombra sup dominante, <0 ombra inf
|
||||||
|
up_sh = (h - np.maximum(o, c)) / rng
|
||||||
|
lo_sh = (np.minimum(o, c) - l) / rng
|
||||||
|
shadow = up_sh - lo_sh
|
||||||
|
|
||||||
|
# bucket body (soglie fisse su frazioni del range): 0..body_buckets-1
|
||||||
|
if body_buckets > 1:
|
||||||
|
edges_b = np.linspace(0.0, 1.0, body_buckets + 1)[1:-1]
|
||||||
|
bbk = np.digitize(body, edges_b) # 0..body_buckets-1
|
||||||
|
else:
|
||||||
|
bbk = np.zeros(n, dtype=int)
|
||||||
|
if shadow_buckets > 1:
|
||||||
|
edges_s = np.linspace(-1.0, 1.0, shadow_buckets + 1)[1:-1]
|
||||||
|
sbk = np.digitize(shadow, edges_s)
|
||||||
|
else:
|
||||||
|
sbk = np.zeros(n, dtype=int)
|
||||||
|
|
||||||
|
# simbolo per candela: dir * (body_buckets*shadow_buckets) + bbk*shadow_buckets + sbk
|
||||||
|
nbb, nsb = body_buckets, shadow_buckets
|
||||||
|
per_dir = nbb * nsb
|
||||||
|
sym = direction * per_dir + bbk * nsb + sbk # 0 .. 3*per_dir-1
|
||||||
|
base = 3 * per_dir
|
||||||
|
|
||||||
|
codes = np.full(n, -1, dtype=np.int64)
|
||||||
|
# codice della finestra L: base-L polinomiale sui simboli [i-L+1 .. i]
|
||||||
|
acc = np.zeros(n, dtype=np.int64)
|
||||||
|
for k in range(L):
|
||||||
|
# contributo della candela a posizione (i-L+1+k): peso base**(L-1-k)
|
||||||
|
shifted = np.full(n, 0, dtype=np.int64)
|
||||||
|
shifted[L - 1 - k:] = sym[: n - (L - 1 - k)] if (L - 1 - k) > 0 else sym
|
||||||
|
acc += shifted * (base ** (L - 1 - k))
|
||||||
|
codes[L - 1:] = acc[L - 1:]
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
|
||||||
|
out = np.full(len(close), np.nan)
|
||||||
|
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- stima condizionale causale ---------------------------
|
||||||
|
def shape_entries(df, L=3, H=12, body_buckets=1, shadow_buckets=1,
|
||||||
|
min_n=30, edge=0.55, min_lib=500,
|
||||||
|
tp_atr=None, sl_atr=None, fade=False) -> list[dict]:
|
||||||
|
"""Entries dal bias condizionale del codice di forma (causale, no look-ahead).
|
||||||
|
|
||||||
|
L: lunghezza finestra-forma. H: orizzonte = max_bars.
|
||||||
|
body_buckets/shadow_buckets: granularita' dell'encoding (1 = solo direzione).
|
||||||
|
min_n: occorrenze passate minime del codice (con esito noto) per fidarsi.
|
||||||
|
edge: win-rate minimo (frazione di esiti concordi col segno della media) per
|
||||||
|
entrare; |edge-0.5| e' il margine direzionale.
|
||||||
|
min_lib: barre minime di storia prima di iniziare a operare.
|
||||||
|
tp_atr/sl_atr: TP/SL in multipli di ATR (None = solo time-limit H).
|
||||||
|
|
||||||
|
Causalita': a barra di decisione i si aggiorna lo stato del codice della
|
||||||
|
finestra terminata in e = i-1-H (il cui esito fr[e] e' ora noto). Le statistiche
|
||||||
|
usate per decidere a i derivano quindi solo da occorrenze con e+H <= i-1.
|
||||||
|
"""
|
||||||
|
close = df["close"].values
|
||||||
|
n = len(close)
|
||||||
|
a = atr(df, 14)
|
||||||
|
codes = candle_codes(df, L, body_buckets, shadow_buckets)
|
||||||
|
fr = fwd_return(close, H)
|
||||||
|
|
||||||
|
# accumulatori per codice: somma rendimenti, n positivi, n totali
|
||||||
|
from collections import defaultdict
|
||||||
|
cnt = defaultdict(int)
|
||||||
|
pos = defaultdict(int)
|
||||||
|
ssum = defaultdict(float)
|
||||||
|
|
||||||
|
entries: list[dict] = []
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
# aggiorna lo stato col codice la cui finestra termina in e = i-1-H
|
||||||
|
e = i - 1 - H
|
||||||
|
if e >= L - 1:
|
||||||
|
ce = codes[e]
|
||||||
|
re = fr[e]
|
||||||
|
if ce >= 0 and not np.isnan(re):
|
||||||
|
cnt[ce] += 1
|
||||||
|
pos[ce] += 1 if re > 0 else 0
|
||||||
|
ssum[ce] += re
|
||||||
|
|
||||||
|
ci = codes[i]
|
||||||
|
if ci < 0:
|
||||||
|
continue
|
||||||
|
ntot = cnt.get(ci, 0)
|
||||||
|
if ntot < min_n:
|
||||||
|
continue
|
||||||
|
mean = ssum[ci] / ntot
|
||||||
|
wr_up = pos[ci] / ntot # frazione esiti positivi nel passato
|
||||||
|
d = 1 if mean > 0 else -1
|
||||||
|
# win-rate nella direzione scelta
|
||||||
|
wr = wr_up if d == 1 else (1.0 - wr_up)
|
||||||
|
if wr < edge:
|
||||||
|
continue
|
||||||
|
if fade:
|
||||||
|
d = -d # FADE: entra contro il bias storico
|
||||||
|
ent = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
ent["tp"] = close[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
ent["sl"] = close[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(ent)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- verifica no look-ahead ---------------------------
|
||||||
|
def check_no_lookahead(df, L=3, H=12, body_buckets=2, shadow_buckets=2) -> bool:
|
||||||
|
"""Perturbare il FUTURO (>i) non cambia (a) il codice a i, (b) le stat usate a i.
|
||||||
|
|
||||||
|
(a) codes[i] dipende solo da barre <= i.
|
||||||
|
(b) le entries fino a i (incluse) non cambiano se stravolgo le barre > i+1.
|
||||||
|
"""
|
||||||
|
n = len(df)
|
||||||
|
i = n // 2
|
||||||
|
codes0 = candle_codes(df, L, body_buckets, shadow_buckets)
|
||||||
|
df2 = df.copy()
|
||||||
|
fut = slice(i + 1, n)
|
||||||
|
for col in ("open", "high", "low", "close"):
|
||||||
|
df2.loc[df2.index[i + 1:], col] = df2[col].values[i + 1:] * 1.5
|
||||||
|
codes1 = candle_codes(df2, L, body_buckets, shadow_buckets)
|
||||||
|
ok_code = bool(codes0[i] == codes1[i] and np.array_equal(codes0[: i + 1], codes1[: i + 1]))
|
||||||
|
|
||||||
|
# entries: confronta quelle con indice <= i-1-H (decise con stat tutte note prima del futuro)
|
||||||
|
e0 = shape_entries(df, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
|
||||||
|
min_n=5, edge=0.50, min_lib=200)
|
||||||
|
e1 = shape_entries(df2, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
|
||||||
|
min_n=5, edge=0.50, min_lib=200)
|
||||||
|
cutoff = i - 1 - H
|
||||||
|
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= cutoff}
|
||||||
|
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= cutoff}
|
||||||
|
ok_ent = (s0 == s1)
|
||||||
|
print(f" no-lookahead codice a i={i}: {'OK' if ok_code else 'VIOLATO'}; "
|
||||||
|
f"entries<=i-1-H invarianti: {'OK' if ok_ent else 'VIOLATO'} "
|
||||||
|
f"({len(s0)} vs {len(s1)})")
|
||||||
|
return ok_code and ok_ent
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- run riproducibile ---------------------------
|
||||||
|
def predictive_power(df, L=3, H=12, body_buckets=1, shadow_buckets=1, min_n=30, min_lib=500):
|
||||||
|
"""Diagnostica ONESTA: la direzione predetta dal bias storico (causale) anticipa
|
||||||
|
il segno del rendimento realizzato? Misura hit-rate aggregato della predizione
|
||||||
|
(segno media passata del codice) vs realizzato, su tutte le barre operabili.
|
||||||
|
Niente fee: pura capacita' predittiva del codice di forma."""
|
||||||
|
close = df["close"].values
|
||||||
|
n = len(close)
|
||||||
|
codes = candle_codes(df, L, body_buckets, shadow_buckets)
|
||||||
|
fr = fwd_return(close, H)
|
||||||
|
from collections import defaultdict
|
||||||
|
cnt = defaultdict(int); pos = defaultdict(int); ssum = defaultdict(float)
|
||||||
|
hits = tot = 0
|
||||||
|
pred_ret = 0.0
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
e = i - 1 - H
|
||||||
|
if e >= L - 1:
|
||||||
|
ce = codes[e]; re = fr[e]
|
||||||
|
if ce >= 0 and not np.isnan(re):
|
||||||
|
cnt[ce] += 1; pos[ce] += 1 if re > 0 else 0; ssum[ce] += re
|
||||||
|
ci = codes[i]
|
||||||
|
if ci < 0 or cnt.get(ci, 0) < min_n or np.isnan(fr[i]):
|
||||||
|
continue
|
||||||
|
d = 1 if ssum[ci] / cnt[ci] > 0 else -1
|
||||||
|
actual = fr[i]
|
||||||
|
hits += (np.sign(actual) == d); tot += 1
|
||||||
|
pred_ret += d * actual # PnL teorico senza fee
|
||||||
|
hr = hits / tot * 100 if tot else 0.0
|
||||||
|
print(f" predittivita' L{L}H{H} b{body_buckets}s{shadow_buckets}: "
|
||||||
|
f"hit={hr:.2f}% su {tot} (50%=rumore) | PnL_grezzo_noFee={pred_ret*100:+.0f}%")
|
||||||
|
return hr
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
print("=" * 100)
|
||||||
|
print(" SHAPE_CANDLE_RESEARCH — encoding discreto forma -> bias condizionale | netto fee, OOS")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
df_btc = get_df("BTC", "1h")
|
||||||
|
print("\n[causalita']")
|
||||||
|
check_no_lookahead(df_btc, L=3, H=12, body_buckets=2, shadow_buckets=2)
|
||||||
|
|
||||||
|
# ----- sweep base BTC/ETH 1h: solo direzione (body=shadow=1) -----
|
||||||
|
print("\n[BTC/ETH 1h] solo direzione UP/DOWN/DOJI (body=1,shadow=1), time-exit a H:")
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
print(f" -- {asset} 1h --")
|
||||||
|
for L in (2, 3, 4):
|
||||||
|
for H in (6, 12, 24):
|
||||||
|
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55)
|
||||||
|
evaluate(f"dir L{L}H{H}", ents, df)
|
||||||
|
|
||||||
|
# ----- encoding arricchito body+shadow -----
|
||||||
|
print("\n[BTC/ETH 1h] encoding arricchito (body=2,shadow=2), time-exit a H:")
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
print(f" -- {asset} 1h --")
|
||||||
|
for L in (2, 3):
|
||||||
|
for H in (6, 12, 24):
|
||||||
|
ents = shape_entries(df, L=L, H=H, body_buckets=2, shadow_buckets=2,
|
||||||
|
min_n=30, edge=0.55)
|
||||||
|
evaluate(f"rich L{L}H{H} b2s2", ents, df)
|
||||||
|
|
||||||
|
# ----- selettivita': soglie edge piu' alte, meno trade -----
|
||||||
|
print("\n[BTC/ETH 1h] selettivo (edge>=0.58, min_n>=50), dir-only:")
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
print(f" -- {asset} 1h --")
|
||||||
|
for L in (3, 4, 5):
|
||||||
|
for H in (12, 24):
|
||||||
|
ents = shape_entries(df, L=L, H=H, min_n=50, edge=0.58)
|
||||||
|
evaluate(f"sel L{L}H{H} e58", ents, df)
|
||||||
|
|
||||||
|
# ----- con TP/SL ATR (gestione rischio) sui candidati piu' attivi -----
|
||||||
|
print("\n[BTC/ETH 1h] con TP/SL ATR (tp=2,sl=1.5), dir-only L3:")
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
for H in (12, 24):
|
||||||
|
ents = shape_entries(df, L=3, H=H, min_n=30, edge=0.55, tp_atr=2.0, sl_atr=1.5)
|
||||||
|
evaluate(f"{asset} tpsl L3H{H}", ents, df)
|
||||||
|
|
||||||
|
# ----- DIAGNOSTICA: il codice di forma ha QUALSIASI potere predittivo? -----
|
||||||
|
print("\n[DIAGNOSTICA] hit-rate predizione (segno bias storico vs realizzato), senza fee:")
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
print(f" -- {asset} 1h --")
|
||||||
|
for L in (2, 3, 4):
|
||||||
|
for H in (6, 12, 24):
|
||||||
|
predictive_power(df, L=L, H=H, min_n=30)
|
||||||
|
for L in (2, 3):
|
||||||
|
predictive_power(df, L=L, H=12, body_buckets=2, shadow_buckets=2, min_n=30)
|
||||||
|
|
||||||
|
# ----- IPOTESI FADE: il bias e' anti-predittivo (mean-reversion)? -----
|
||||||
|
print("\n[FADE del bias] entra CONTRO il bias storico (dir-only):")
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
print(f" -- {asset} 1h --")
|
||||||
|
for L in (2, 3):
|
||||||
|
for H in (6, 12, 24):
|
||||||
|
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55, fade=True)
|
||||||
|
evaluate(f"FADE L{L}H{H}", ents, df)
|
||||||
|
|
||||||
|
|
||||||
|
def run_extended(configs):
|
||||||
|
"""Valuta config candidate su tutti gli asset 1h+15m e stampa robustezza."""
|
||||||
|
assets = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
|
||||||
|
for cfg in configs:
|
||||||
|
print(f"\n[ESTESO] config {cfg}")
|
||||||
|
for tf in ("1h", "15m"):
|
||||||
|
print(f" -- timeframe {tf} --")
|
||||||
|
nrob = 0
|
||||||
|
for asset in assets:
|
||||||
|
try:
|
||||||
|
df = get_df(asset, tf)
|
||||||
|
except Exception as ex:
|
||||||
|
print(f" {asset}: skip ({ex})")
|
||||||
|
continue
|
||||||
|
ents = shape_entries(df, **cfg)
|
||||||
|
res = evaluate(f"{asset} {tf}", ents, df)
|
||||||
|
nrob += robust(res)
|
||||||
|
print(f" -> robuste {nrob}/{len(assets)} su {tf}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Harness ONESTO per pattern *di forma* -> previsione dell'andamento successivo.
|
||||||
|
|
||||||
|
Idea (analog forecasting / nearest-neighbour sulla FORMA del prezzo):
|
||||||
|
- a ogni barra i guardo la forma recente W (closes z-normalizzati fino a close[i]);
|
||||||
|
- cerco nel PASSATO le K finestre piu' simili la cui forma si era gia' conclusa
|
||||||
|
*e* il cui esito a H barre era gia' noto PRIMA di i (nessun look-ahead);
|
||||||
|
- prevedo la direzione dei prossimi H barre = segno del rendimento medio degli
|
||||||
|
analoghi; entro a close[i] se l'accordo fra analoghi e' abbastanza forte.
|
||||||
|
|
||||||
|
Vincoli anti-look-ahead (gli stessi della famiglia squeeze fallita):
|
||||||
|
- la forma usa SOLO closes fino a close[i];
|
||||||
|
- la libreria di analoghi a decisione i contiene solo finestre che terminano in
|
||||||
|
e con e+H <= i-1 -> il loro esito e' interamente realizzato *prima* della barra i;
|
||||||
|
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
|
||||||
|
|
||||||
|
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
|
||||||
|
KDTree ricostruito ogni `rebuild` barre (causale): query O(log N), niente O(N^2).
|
||||||
|
|
||||||
|
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.lib.stride_tricks import sliding_window_view
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import ( # noqa: E402
|
||||||
|
get_df, evaluate, robust, simulate, atr, ema, rsi, _dt, OOS_FRAC, ASSETS, FEE_RT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- forma normalizzata ---------------------------
|
||||||
|
def znorm_windows(close: np.ndarray, W: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Matrice delle finestre z-normalizzate per FORMA.
|
||||||
|
|
||||||
|
Ritorna (M, ends) dove M[k] = z-norm(close[e-W+1 .. e]) e ends[k] = e.
|
||||||
|
Z-norm per forma: (w - media)/std -> invariante a livello e scala -> confronto
|
||||||
|
sulla sola morfologia. Le finestre piatte (std=0) hanno norm tutta a 0.
|
||||||
|
"""
|
||||||
|
if len(close) < W:
|
||||||
|
return np.empty((0, W)), np.empty(0, dtype=int)
|
||||||
|
wins = sliding_window_view(close, W) # (N-W+1, W), wins[k] = close[k..k+W-1]
|
||||||
|
mu = wins.mean(axis=1, keepdims=True)
|
||||||
|
sd = wins.std(axis=1, keepdims=True)
|
||||||
|
sd = np.where(sd == 0, 1.0, sd)
|
||||||
|
M = (wins - mu) / sd
|
||||||
|
ends = np.arange(W - 1, len(close)) # finestra k termina in e = k+W-1
|
||||||
|
return M, ends
|
||||||
|
|
||||||
|
|
||||||
|
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
|
||||||
|
"""Rendimento forward a H barre per ogni indice: (close[i+H]-close[i])/close[i].
|
||||||
|
NaN dove i+H esce dai dati (non usabile come esito)."""
|
||||||
|
out = np.full(len(close), np.nan)
|
||||||
|
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- analog forecasting causale ---------------------------
|
||||||
|
def analog_entries(df, W=24, H=12, K=50, rebuild=250, min_lib=800,
|
||||||
|
agree=0.60, conf_atr=0.0, tp_atr=None, sl_atr=None,
|
||||||
|
trend_max=None, ema_long=200) -> list[dict]:
|
||||||
|
"""Entries da nearest-neighbour sulla FORMA (causale, no look-ahead).
|
||||||
|
|
||||||
|
W: lunghezza finestra-forma. H: orizzonte previsione (= max_bars). K: n. analoghi.
|
||||||
|
rebuild: ogni quante barre si ricostruisce il KDTree (libreria cresce nel tempo).
|
||||||
|
min_lib: barre minime di storia prima di iniziare a operare.
|
||||||
|
agree: frazione minima di analoghi concordi sul segno per entrare (>0.5).
|
||||||
|
conf_atr: soglia |rendimento medio analoghi| in multipli di ATR-equivalente (0=off).
|
||||||
|
tp_atr/sl_atr: take-profit/stop in multipli di ATR (None = solo time-limit H).
|
||||||
|
trend_max: salta se |close-EMA(ema_long)|/ATR14 > trend_max (filtro trend, None=off).
|
||||||
|
"""
|
||||||
|
close = df["close"].values
|
||||||
|
n = len(close)
|
||||||
|
a = atr(df, 14)
|
||||||
|
M, ends = znorm_windows(close, W) # forme z-norm e indice di fine
|
||||||
|
end_pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
fr = fwd_return(close, H) # esito H-barre per ogni indice
|
||||||
|
el = None
|
||||||
|
if trend_max is not None:
|
||||||
|
el = ema(close, ema_long)
|
||||||
|
|
||||||
|
entries: list[dict] = []
|
||||||
|
tree = None
|
||||||
|
lib_idx = None # indici e (fine finestra) nella libreria
|
||||||
|
next_rebuild = 0
|
||||||
|
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
# libreria causale: finestre la cui forma E il cui esito H sono < i
|
||||||
|
if tree is None or i >= next_rebuild:
|
||||||
|
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
|
||||||
|
# esito noto e finito (fr non-NaN garantito da e+H <= i-1 < n)
|
||||||
|
eligible = eligible[~np.isnan(fr[eligible])]
|
||||||
|
if len(eligible) < max(K * 3, 200):
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
continue
|
||||||
|
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
|
||||||
|
lib_idx = eligible
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
|
||||||
|
if tree is None:
|
||||||
|
continue
|
||||||
|
q = M[end_pos[i]]
|
||||||
|
if not np.isfinite(q).all():
|
||||||
|
continue
|
||||||
|
kk = min(K, len(lib_idx))
|
||||||
|
_, nn = tree.query(q, k=kk)
|
||||||
|
nn = np.atleast_1d(nn)
|
||||||
|
outs = fr[lib_idx[nn]] # rendimenti H-barre degli analoghi
|
||||||
|
outs = outs[~np.isnan(outs)]
|
||||||
|
if len(outs) < 5:
|
||||||
|
continue
|
||||||
|
mean_out = float(outs.mean())
|
||||||
|
d = 1 if mean_out > 0 else -1
|
||||||
|
frac = float(np.mean(np.sign(outs) == d))
|
||||||
|
if frac < agree:
|
||||||
|
continue
|
||||||
|
if conf_atr > 0:
|
||||||
|
if not (abs(mean_out) * close[i] >= conf_atr * a[i]):
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] > 0:
|
||||||
|
if abs(close[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- kNN grezzo cacheable (perf) ---------------------------
|
||||||
|
def analog_signals(df, W=24, H=12, K=50, rebuild=250, min_lib=800) -> dict:
|
||||||
|
"""Calcola UNA volta il forecast kNN grezzo per barra (causale), riusabile da
|
||||||
|
piu' filtri (agree/conf_atr/trend/tp/sl) senza ri-eseguire la query costosa.
|
||||||
|
|
||||||
|
Ritorna dict con array allineati per le barre che hanno un forecast valido:
|
||||||
|
i : indice barra (ingresso eseguibile a close[i])
|
||||||
|
mean_out : rendimento H-barre medio degli analoghi
|
||||||
|
frac : frazione di analoghi concordi col segno di mean_out (>=0.5)
|
||||||
|
d : segno previsto (+1/-1)
|
||||||
|
Identico, riga per riga, alla logica di analog_entries (stessa libreria causale,
|
||||||
|
stessa query, stessa soglia len(outs)>=5) ma SENZA i filtri di selezione.
|
||||||
|
"""
|
||||||
|
close = df["close"].values
|
||||||
|
n = len(close)
|
||||||
|
M, ends = znorm_windows(close, W)
|
||||||
|
end_pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
fr = fwd_return(close, H)
|
||||||
|
|
||||||
|
out_i: list[int] = []
|
||||||
|
out_mean: list[float] = []
|
||||||
|
out_frac: list[float] = []
|
||||||
|
out_d: list[int] = []
|
||||||
|
tree = None
|
||||||
|
lib_idx = None
|
||||||
|
next_rebuild = 0
|
||||||
|
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
if tree is None or i >= next_rebuild:
|
||||||
|
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
|
||||||
|
eligible = eligible[~np.isnan(fr[eligible])]
|
||||||
|
if len(eligible) < max(K * 3, 200):
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
continue
|
||||||
|
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
|
||||||
|
lib_idx = eligible
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
if tree is None:
|
||||||
|
continue
|
||||||
|
q = M[end_pos[i]]
|
||||||
|
if not np.isfinite(q).all():
|
||||||
|
continue
|
||||||
|
kk = min(K, len(lib_idx))
|
||||||
|
_, nn = tree.query(q, k=kk)
|
||||||
|
nn = np.atleast_1d(nn)
|
||||||
|
outs = fr[lib_idx[nn]]
|
||||||
|
outs = outs[~np.isnan(outs)]
|
||||||
|
if len(outs) < 5:
|
||||||
|
continue
|
||||||
|
mean_out = float(outs.mean())
|
||||||
|
d = 1 if mean_out > 0 else -1
|
||||||
|
frac = float(np.mean(np.sign(outs) == d))
|
||||||
|
out_i.append(i); out_mean.append(mean_out); out_frac.append(frac); out_d.append(d)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"i": np.asarray(out_i, dtype=int),
|
||||||
|
"mean_out": np.asarray(out_mean, dtype=float),
|
||||||
|
"frac": np.asarray(out_frac, dtype=float),
|
||||||
|
"d": np.asarray(out_d, dtype=int),
|
||||||
|
"H": H,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def entries_from_signals(df, sig: dict, agree=0.60, conf_atr=0.0,
|
||||||
|
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
|
||||||
|
"""Applica i filtri di selezione al forecast grezzo di analog_signals (cheap).
|
||||||
|
Risultato identico ad analog_entries con gli stessi parametri (stesso W/H/K/rebuild
|
||||||
|
usati per costruire sig)."""
|
||||||
|
close = df["close"].values
|
||||||
|
a = atr(df, 14)
|
||||||
|
H = sig["H"]
|
||||||
|
el = ema(close, ema_long) if trend_max is not None else None
|
||||||
|
entries: list[dict] = []
|
||||||
|
for k in range(len(sig["i"])):
|
||||||
|
i = int(sig["i"][k]); d = int(sig["d"][k])
|
||||||
|
if sig["frac"][k] < agree:
|
||||||
|
continue
|
||||||
|
if conf_atr > 0 and not (abs(sig["mean_out"][k]) * close[i] >= conf_atr * a[i]):
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- verifica no look-ahead ---------------------------
|
||||||
|
def check_no_lookahead(df, W=24, H=12) -> bool:
|
||||||
|
"""La forma a i deve restare invariata se perturbo il FUTURO (>i).
|
||||||
|
Conferma che znorm_windows usa solo close fino a i."""
|
||||||
|
close = df["close"].values.copy()
|
||||||
|
M0, ends = znorm_windows(close, W)
|
||||||
|
pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
i = len(close) // 2
|
||||||
|
q0 = M0[pos[i]].copy()
|
||||||
|
close2 = close.copy()
|
||||||
|
close2[i + 1:] *= 1.5 # stravolgo il futuro
|
||||||
|
M1, _ = znorm_windows(close2, W)
|
||||||
|
q1 = M1[pos[i]]
|
||||||
|
ok = np.allclose(q0, q1)
|
||||||
|
print(f" no-lookahead forma a i={i}: {'OK' if ok else 'VIOLATO'} "
|
||||||
|
f"(max diff {np.max(np.abs(q0 - q1)):.2e})")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("=" * 92)
|
||||||
|
print(" SHAPE_LAB — baseline analog forecasting (kNN sulla forma) | netto fee, OOS")
|
||||||
|
print("=" * 92)
|
||||||
|
df = get_df("BTC", "1h")
|
||||||
|
check_no_lookahead(df)
|
||||||
|
print("\n BTC 1h — sweep base W/H/K (time-exit a H barre):")
|
||||||
|
for W, H, K in [(24, 12, 50), (24, 24, 50), (48, 24, 80), (12, 6, 40), (48, 48, 100)]:
|
||||||
|
ents = analog_entries(df, W=W, H=H, K=K, agree=0.60)
|
||||||
|
evaluate(f"analog W{W}H{H}K{K}", ents, df)
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"""SHAPE-as-FEATURES research: l'edge e' nella FORMA del segnale?
|
||||||
|
|
||||||
|
Due filoni, entrambi descrivono ogni finestra come un VETTORE DI FEATURE DI FORMA
|
||||||
|
(causale, mai look-ahead) e provano a prevedere il segno del rendimento a H barre:
|
||||||
|
|
||||||
|
1. ANALOG nello spazio FEATURE (kNN causale). Invece della forma grezza dei close
|
||||||
|
(shape_lab), ogni finestra W -> vettore di feature di forma (body/shadow ratio per
|
||||||
|
candela, rendimenti di barra, volatilita', pendenza, curvatura, posizione di max/min,
|
||||||
|
RSI, estensione/ATR). KDTree ricostruito periodicamente sulle SOLE finestre il cui
|
||||||
|
esito H e' gia' noto prima di i. Previsione = segno del rendimento medio dei K vicini.
|
||||||
|
|
||||||
|
2. ML WALK-FORWARD sulla forma. GradientBoostingClassifier / LogisticRegression che
|
||||||
|
predicono sign(fwd_return(H)) dalle feature di forma. Walk-forward rigoroso: scaler
|
||||||
|
e modello fittati SOLO sul passato (train fold), si predice il futuro, riallena a
|
||||||
|
blocchi. Entra a close[i] solo se la probabilita' supera una soglia (selettivita').
|
||||||
|
|
||||||
|
Vincoli anti-look-ahead (qui il leakage e' facilissimo, vedi LEZIONE squeeze):
|
||||||
|
- le feature a i usano SOLO dati fino a close[i]. Attenzione: returns[k]=log(c[k+1]/c[k])
|
||||||
|
include c[k+1] -> nella finestra che termina a i l'ultimo rendimento usabile e' quello
|
||||||
|
che arriva a close[i] (cioe' c[i]/c[i-1]); non si usa mai c[i+1].
|
||||||
|
- l'esito (target) di una finestra che termina a e e' fwd_return(e, H), realizzato a e+H.
|
||||||
|
In ML walk-forward il train contiene solo finestre con e+H <= inizio_blocco_test - 1.
|
||||||
|
In kNN la libreria contiene solo finestre con e+H <= i-1.
|
||||||
|
- scaler/modello fittati SOLO sul train passato, MAI sull'intero dataset.
|
||||||
|
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H (engine explore_lab).
|
||||||
|
- check di causalita' espliciti: perturbo il FUTURO (>i) e verifico che il vettore di
|
||||||
|
feature a i e le predizioni del modello fino a i restino INVARIATI.
|
||||||
|
|
||||||
|
Netto fee 0.10% RT baseline + sweep fino a 0.20% RT, leva 3x, pos 0.15, OOS ultimo 30%.
|
||||||
|
Robustezza su griglia + >=2 asset. Conta il PnL NETTO-fee, non l'accuracy.
|
||||||
|
|
||||||
|
Run: uv run python scripts/analysis/shape_ml_research.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.lib.stride_tricks import sliding_window_view
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import ( # noqa: E402
|
||||||
|
get_df, evaluate, robust, simulate, atr, ema, rsi, OOS_FRAC,
|
||||||
|
)
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
from sklearn.ensemble import GradientBoostingClassifier # noqa: E402
|
||||||
|
from sklearn.linear_model import LogisticRegression # noqa: E402
|
||||||
|
from sklearn.preprocessing import StandardScaler # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FEATURE DI FORMA — causali, una riga per ogni barra-fine-finestra
|
||||||
|
# =============================================================================
|
||||||
|
def shape_features(df, W: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Matrice di feature di FORMA per ogni finestra di W candele.
|
||||||
|
|
||||||
|
Ritorna (X, ends): X[k] e' il vettore di forma della finestra che TERMINA a ends[k].
|
||||||
|
Tutte le feature usano solo o/h/l/c[ends[k]-W+1 .. ends[k]] -> causali per costruzione.
|
||||||
|
|
||||||
|
Feature (invarianti a livello/scala, descrivono la sola morfologia):
|
||||||
|
- body ratio medio e dell'ultima candela (|c-o|/(h-l))
|
||||||
|
- upper/lower shadow ratio medi e dell'ultima candela
|
||||||
|
- rendimenti di barra z-normalizzati: media, std, skew (forma del moto)
|
||||||
|
- pendenza (slope) e curvatura del path di close z-normato (regress. lineare/quad.)
|
||||||
|
- posizione del max e del min nella finestra (0..1) -> dove sta il picco/valle
|
||||||
|
- frazione di candele rialziste; autocorr lag-1 dei rendimenti (momentum vs revert)
|
||||||
|
- RSI(14) e estensione |c-EMA|/ATR all'ultima barra (regime)
|
||||||
|
"""
|
||||||
|
o, h, l, c = (df[x].values.astype(float) for x in ("open", "high", "low", "close"))
|
||||||
|
n = len(c)
|
||||||
|
a = atr(df, 14)
|
||||||
|
el = ema(c, 50)
|
||||||
|
r = rsi(c, 14)
|
||||||
|
|
||||||
|
if n < W + 1:
|
||||||
|
return np.empty((0, 0)), np.empty(0, dtype=int)
|
||||||
|
|
||||||
|
# finestre OHLC che terminano a e = k+W-1, per k=0..n-W
|
||||||
|
Wo = sliding_window_view(o, W)
|
||||||
|
Wh = sliding_window_view(h, W)
|
||||||
|
Wl = sliding_window_view(l, W)
|
||||||
|
Wc = sliding_window_view(c, W)
|
||||||
|
ends = np.arange(W - 1, n)
|
||||||
|
|
||||||
|
total = Wh - Wl
|
||||||
|
total = np.where(total <= 0, 1e-12, total)
|
||||||
|
body = np.abs(Wc - Wo) / total
|
||||||
|
up_sh = (Wh - np.maximum(Wo, Wc)) / total
|
||||||
|
lo_sh = (np.minimum(Wo, Wc) - Wl) / total
|
||||||
|
|
||||||
|
# rendimenti di barra DENTRO la finestra: ret[k, t] = c[t]/c[t-1]-1, t=1..W-1
|
||||||
|
# usano solo close fino alla fine della finestra -> causali
|
||||||
|
ret = Wc[:, 1:] / np.where(Wc[:, :-1] == 0, 1e-12, Wc[:, :-1]) - 1.0
|
||||||
|
rmu = ret.mean(axis=1)
|
||||||
|
rsd = ret.std(axis=1) + 1e-12
|
||||||
|
rz = (ret - rmu[:, None]) / rsd[:, None]
|
||||||
|
rskew = (rz ** 3).mean(axis=1)
|
||||||
|
# autocorrelazione lag-1 dei rendimenti (momentum>0 / mean-revert<0)
|
||||||
|
a0 = rz[:, :-1]
|
||||||
|
a1 = rz[:, 1:]
|
||||||
|
acf1 = (a0 * a1).mean(axis=1)
|
||||||
|
|
||||||
|
# path z-normato dei close -> slope (lin) e curvatura (quad)
|
||||||
|
czmu = Wc.mean(axis=1, keepdims=True)
|
||||||
|
czsd = Wc.std(axis=1, keepdims=True)
|
||||||
|
czsd = np.where(czsd == 0, 1.0, czsd)
|
||||||
|
cz = (Wc - czmu) / czsd
|
||||||
|
t = np.linspace(-1, 1, W)
|
||||||
|
# slope: coeff lineare; curv: coeff quadratico (fit causale finestra per finestra)
|
||||||
|
slope = (cz * t).mean(axis=1) / (t * t).mean()
|
||||||
|
t2 = t * t
|
||||||
|
t2c = t2 - t2.mean()
|
||||||
|
curv = (cz * t2c).mean(axis=1) / (t2c * t2c).mean()
|
||||||
|
|
||||||
|
argmax = Wc.argmax(axis=1) / (W - 1)
|
||||||
|
argmin = Wc.argmin(axis=1) / (W - 1)
|
||||||
|
frac_up = (Wc > Wo).mean(axis=1)
|
||||||
|
|
||||||
|
rsi_end = r[ends]
|
||||||
|
aa = a[ends]
|
||||||
|
ext = np.where(aa > 0, (c[ends] - el[ends]) / np.where(aa > 0, aa, 1.0), 0.0)
|
||||||
|
|
||||||
|
X = np.column_stack([
|
||||||
|
body.mean(axis=1), body[:, -1],
|
||||||
|
up_sh.mean(axis=1), up_sh[:, -1],
|
||||||
|
lo_sh.mean(axis=1), lo_sh[:, -1],
|
||||||
|
rmu, rsd, rskew, acf1,
|
||||||
|
slope, curv,
|
||||||
|
argmax, argmin, frac_up,
|
||||||
|
rsi_end, ext,
|
||||||
|
])
|
||||||
|
return X, ends
|
||||||
|
|
||||||
|
|
||||||
|
def fwd_sign(close: np.ndarray, H: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""fwd_return a H barre e suo segno (+1/-1). NaN/0 dove i+H esce dai dati."""
|
||||||
|
fr = np.full(len(close), np.nan)
|
||||||
|
fr[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
|
||||||
|
sgn = np.where(fr > 0, 1, -1).astype(float)
|
||||||
|
sgn[np.isnan(fr)] = np.nan
|
||||||
|
return fr, sgn
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CHECK CAUSALITA' — perturbo il futuro, le feature/predizioni a i non cambiano
|
||||||
|
# =============================================================================
|
||||||
|
def check_feature_causal(df, W=24) -> bool:
|
||||||
|
o = df.copy()
|
||||||
|
X0, ends = shape_features(o, W)
|
||||||
|
pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
i = len(df) * 2 // 3
|
||||||
|
v0 = X0[pos[i]].copy()
|
||||||
|
o2 = df.copy()
|
||||||
|
for col in ("open", "high", "low", "close"):
|
||||||
|
o2.loc[i + 1:, col] = o2.loc[i + 1:, col] * 1.7 # stravolgi il futuro
|
||||||
|
X1, _ = shape_features(o2, W)
|
||||||
|
v1 = X1[pos[i]]
|
||||||
|
ok = np.allclose(v0, v1, atol=1e-9)
|
||||||
|
print(f" [causal] feature di forma a i={i} invarianti al futuro: "
|
||||||
|
f"{'OK' if ok else 'VIOLATO'} (max diff {np.nanmax(np.abs(v0 - v1)):.2e})")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FILONE 1 — ANALOG kNN nello spazio FEATURE (causale)
|
||||||
|
# =============================================================================
|
||||||
|
def analog_feat_entries(df, W=24, H=12, K=60, rebuild=300, min_lib=1500,
|
||||||
|
agree=0.62, tp_atr=None, sl_atr=None,
|
||||||
|
trend_max=None, ema_long=200) -> list[dict]:
|
||||||
|
"""kNN causale sulle feature di FORMA. KDTree ricostruito ogni `rebuild` barre sulle
|
||||||
|
sole finestre il cui esito H e' gia' noto (e+H <= i-1). Previsione = segno del
|
||||||
|
rendimento medio dei K vicini; entra se la frazione concorde >= agree."""
|
||||||
|
c = df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
a = atr(df, 14)
|
||||||
|
X, ends = shape_features(df, W)
|
||||||
|
if len(X) == 0:
|
||||||
|
return []
|
||||||
|
pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
fr, _ = fwd_sign(c, H)
|
||||||
|
el = ema(c, ema_long) if trend_max is not None else None
|
||||||
|
|
||||||
|
# standardizzo le feature: per causalita' uso media/std cumulative? No: lo scaler
|
||||||
|
# globale userebbe il futuro. Uso uno scaler RICALCOLATO sulla libreria a ogni rebuild.
|
||||||
|
entries: list[dict] = []
|
||||||
|
tree = None
|
||||||
|
lib_ends = None
|
||||||
|
mu = sd = None
|
||||||
|
next_rebuild = 0
|
||||||
|
|
||||||
|
valid_ends = ends[(ends >= W - 1)]
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
if i not in pos:
|
||||||
|
continue
|
||||||
|
if tree is None or i >= next_rebuild:
|
||||||
|
elig = valid_ends[(valid_ends <= i - 1 - H)]
|
||||||
|
elig = elig[~np.isnan(fr[elig])]
|
||||||
|
if len(elig) < max(K * 4, 400):
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
continue
|
||||||
|
Xe = X[[pos[int(e)] for e in elig]]
|
||||||
|
mu = Xe.mean(axis=0)
|
||||||
|
sd = Xe.std(axis=0) + 1e-9
|
||||||
|
tree = cKDTree((Xe - mu) / sd)
|
||||||
|
lib_ends = elig
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
if tree is None:
|
||||||
|
continue
|
||||||
|
q = (X[pos[i]] - mu) / sd
|
||||||
|
if not np.isfinite(q).all():
|
||||||
|
continue
|
||||||
|
kk = min(K, len(lib_ends))
|
||||||
|
_, nn = tree.query(q, k=kk)
|
||||||
|
nn = np.atleast_1d(nn)
|
||||||
|
outs = fr[lib_ends[nn]]
|
||||||
|
outs = outs[~np.isnan(outs)]
|
||||||
|
if len(outs) < 10:
|
||||||
|
continue
|
||||||
|
d = 1 if outs.mean() > 0 else -1
|
||||||
|
frac = float(np.mean(np.sign(outs) == d))
|
||||||
|
if frac < agree:
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = c[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = c[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FILONE 2 — ML WALK-FORWARD sulla forma
|
||||||
|
# =============================================================================
|
||||||
|
def ml_wf_entries(df, W=24, H=12, model="gb", thresh=0.58,
|
||||||
|
train_min=4000, retrain=500, n_estimators=80, max_depth=3,
|
||||||
|
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
|
||||||
|
train_window=None) -> list[dict]:
|
||||||
|
"""Walk-forward: a blocchi di `retrain` barre, allena sul passato il cui esito
|
||||||
|
e' noto, predice il blocco corrente. Scaler+modello fittati solo sul train.
|
||||||
|
Entra a close[i] se proba della classe predetta >= thresh. model in {gb, logit}.
|
||||||
|
train_window: se None -> expanding (tutto il passato); se int -> ROLLING (solo le
|
||||||
|
ultime train_window barre prima del blocco) -> test di robustezza piu' severo."""
|
||||||
|
c = df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
a = atr(df, 14)
|
||||||
|
X, ends = shape_features(df, W)
|
||||||
|
if len(X) == 0:
|
||||||
|
return []
|
||||||
|
pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
fr, sgn = fwd_sign(c, H)
|
||||||
|
el = ema(c, ema_long) if trend_max is not None else None
|
||||||
|
|
||||||
|
# mappa: per ogni indice i (>=W-1) la riga di feature
|
||||||
|
row_of = pos
|
||||||
|
entries: list[dict] = []
|
||||||
|
|
||||||
|
start = max(train_min, W - 1)
|
||||||
|
blk = start
|
||||||
|
while blk < n - 1:
|
||||||
|
blk_end = min(blk + retrain, n - 1)
|
||||||
|
# TRAIN: finestre la cui forma E il cui esito (e+H) sono < blk
|
||||||
|
# cioe' e <= blk-1-H (esito realizzato prima del primo test del blocco)
|
||||||
|
lo_end = (blk - 1 - H - train_window) if train_window is not None else (W - 1)
|
||||||
|
tr_ends = ends[(ends <= blk - 1 - H) & (ends >= max(W - 1, lo_end))]
|
||||||
|
tr_ends = tr_ends[~np.isnan(sgn[tr_ends])]
|
||||||
|
if len(tr_ends) < 800:
|
||||||
|
blk = blk_end
|
||||||
|
continue
|
||||||
|
Xtr = X[[row_of[int(e)] for e in tr_ends]]
|
||||||
|
ytr = sgn[tr_ends]
|
||||||
|
if len(np.unique(ytr)) < 2:
|
||||||
|
blk = blk_end
|
||||||
|
continue
|
||||||
|
scaler = StandardScaler().fit(Xtr)
|
||||||
|
Xtr_s = scaler.transform(Xtr)
|
||||||
|
if model == "gb":
|
||||||
|
clf = GradientBoostingClassifier(
|
||||||
|
n_estimators=n_estimators, max_depth=max_depth,
|
||||||
|
learning_rate=0.05, subsample=0.8, random_state=0)
|
||||||
|
else:
|
||||||
|
clf = LogisticRegression(C=0.5, max_iter=1000)
|
||||||
|
clf.fit(Xtr_s, ytr)
|
||||||
|
classes = clf.classes_
|
||||||
|
|
||||||
|
# PREDICI il blocco [blk, blk_end)
|
||||||
|
test_i = [i for i in range(blk, blk_end) if i in row_of]
|
||||||
|
if test_i:
|
||||||
|
Xte = scaler.transform(X[[row_of[i] for i in test_i]])
|
||||||
|
proba = clf.predict_proba(Xte)
|
||||||
|
for row, i in enumerate(test_i):
|
||||||
|
p = proba[row]
|
||||||
|
j = int(np.argmax(p))
|
||||||
|
if p[j] < thresh:
|
||||||
|
continue
|
||||||
|
d = int(classes[j])
|
||||||
|
if not np.isfinite(X[row_of[i]]).all():
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = c[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = c[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
blk = blk_end
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def check_ml_causal(df, W=24, H=12) -> bool:
|
||||||
|
"""Le predizioni walk-forward fino all'indice T non devono cambiare se perturbo
|
||||||
|
i dati DOPO T. Confronto le entries con i<=T su df vs df col futuro stravolto."""
|
||||||
|
T = int(len(df) * 0.7)
|
||||||
|
e0 = ml_wf_entries(df, W=W, H=H, model="logit", retrain=400, train_min=3000)
|
||||||
|
df2 = df.copy()
|
||||||
|
for col in ("open", "high", "low", "close", "volume"):
|
||||||
|
df2.loc[T + 1:, col] = df2.loc[T + 1:, col] * 1.6
|
||||||
|
e1 = ml_wf_entries(df2, W=W, H=H, model="logit", retrain=400, train_min=3000)
|
||||||
|
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= T - H}
|
||||||
|
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= T - H}
|
||||||
|
ok = s0 == s1
|
||||||
|
print(f" [causal] predizioni ML fino a T={T}-H invarianti al futuro: "
|
||||||
|
f"{'OK' if ok else 'VIOLATO'} ({len(s0 ^ s1)} differenze)")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RUN
|
||||||
|
# =============================================================================
|
||||||
|
def acc_oos(entries, df) -> float:
|
||||||
|
"""Accuracy OOS (ultimo 30%): frazione di trade con esito favorevole (segno giusto),
|
||||||
|
indipendente da tp/sl. Misura la qualita' del segnale, separata dal PnL."""
|
||||||
|
split = int(len(df) * (1 - OOS_FRAC))
|
||||||
|
c = df["close"].values
|
||||||
|
n = len(c)
|
||||||
|
ok = tot = 0
|
||||||
|
for e in entries:
|
||||||
|
i, d, mb = e["i"], e["d"], e["max_bars"]
|
||||||
|
if i < split or i + mb >= n:
|
||||||
|
continue
|
||||||
|
tot += 1
|
||||||
|
ok += (c[i + mb] - c[i]) * d > 0
|
||||||
|
return ok / tot * 100 if tot else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def run(with_gb: bool = False):
|
||||||
|
"""with_gb=False (default): solo LogisticRegression (veloce, ~36s/config). Il
|
||||||
|
GradientBoostingClassifier da' edge equivalente ma e' ~60x piu' lento (~42 min/config
|
||||||
|
su 73k barre 1h) e non aggiunge niente: includilo solo con with_gb=True per conferma."""
|
||||||
|
t0 = time.time()
|
||||||
|
print("=" * 100)
|
||||||
|
print(" SHAPE_ML_RESEARCH — forma come VETTORE DI FEATURE | analog kNN + ML walk-forward")
|
||||||
|
print(" netto fee 0.10% RT (sweep 0.20%), leva 3x, pos 0.15, OOS ultimo 30%")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
assets = ["BTC", "ETH"]
|
||||||
|
dfs = {a: get_df(a, "1h") for a in assets}
|
||||||
|
|
||||||
|
print("\n[1] CHECK CAUSALITA' (no look-ahead):")
|
||||||
|
check_feature_causal(dfs["BTC"], W=24)
|
||||||
|
check_ml_causal(dfs["BTC"], W=24, H=12)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
print("\n[2] FILONE 1 — ANALOG kNN nello spazio FEATURE (time-exit a H):")
|
||||||
|
print(" confronto con shape_lab (analog grezzo sui close) implicito: stessa logica,"
|
||||||
|
" feature di forma al posto dei close z-normati.")
|
||||||
|
keep1 = []
|
||||||
|
for W, H, K, agree in [(24, 12, 60, 0.60), (24, 12, 80, 0.65),
|
||||||
|
(48, 24, 80, 0.62), (16, 8, 50, 0.62), (48, 12, 100, 0.65)]:
|
||||||
|
for a in assets:
|
||||||
|
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree)
|
||||||
|
res = evaluate(f"{a} aF W{W}H{H}K{K} ag{agree}", ents, dfs[a])
|
||||||
|
if robust(res):
|
||||||
|
keep1.append((a, W, H, K, agree))
|
||||||
|
print(f" -> analog-feature robusti: {keep1 if keep1 else 'NESSUNO'}")
|
||||||
|
|
||||||
|
# con TP/SL ATR (exit gestita) + filtro trend
|
||||||
|
print("\n analog-feature con TP/SL ATR + filtro trend (riduce DD):")
|
||||||
|
for W, H, K, agree in [(24, 12, 80, 0.62), (48, 24, 80, 0.62)]:
|
||||||
|
for a in assets:
|
||||||
|
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree,
|
||||||
|
tp_atr=1.5, sl_atr=1.5, trend_max=3.0)
|
||||||
|
res = evaluate(f"{a} aF W{W}H{H} tp/sl trend", ents, dfs[a])
|
||||||
|
if robust(res):
|
||||||
|
keep1.append((a, W, H, K, agree, "tpsl"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
print("\n[3] FILONE 2 — ML WALK-FORWARD sulla forma:")
|
||||||
|
print(" accuracy OOS riportata ACCANTO al PnL (accuracy alta != edge, lezione squeeze)")
|
||||||
|
keep2 = []
|
||||||
|
configs = [
|
||||||
|
("logit", 24, 12, 0.56), ("logit", 24, 12, 0.58), ("logit", 24, 12, 0.60),
|
||||||
|
("logit", 48, 24, 0.58),
|
||||||
|
]
|
||||||
|
if with_gb:
|
||||||
|
configs += [("gb", 24, 12, 0.58), ("gb", 48, 24, 0.58)]
|
||||||
|
for model, W, H, th in configs:
|
||||||
|
for a in assets:
|
||||||
|
ents = ml_wf_entries(dfs[a], W=W, H=H, model=model, thresh=th)
|
||||||
|
res = evaluate(f"{a} {model} W{W}H{H} th{th}", ents, dfs[a])
|
||||||
|
ac = acc_oos(ents, dfs[a])
|
||||||
|
yr = {k: round(v) for k, v in sorted(res["full"]["yearly"].items())}
|
||||||
|
print(f" ^ accOOS={ac:4.1f}% anni={yr}")
|
||||||
|
# tieni se: FULL+OOS+ e regge fee 0.20% RT su entrambe le finestre
|
||||||
|
if (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
|
||||||
|
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0):
|
||||||
|
keep2.append((a, model, W, H, th))
|
||||||
|
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" VERDETTO")
|
||||||
|
print(f" FILONE 1 analog-feature kNN: {'robusti ' + str(keep1) if keep1 else 'NESSUNO ROBUSTO (rumore: win~50%, fee 0.2% negativo)'}")
|
||||||
|
print(f" FILONE 2 ML walk-forward (FULL+OOS+ e regge fee 0.2%): {keep2 if keep2 else 'NESSUNO'}")
|
||||||
|
print(" Edge reale: la DIREZIONE letta dalla forma via LogisticRegression walk-forward")
|
||||||
|
print(" e' redditizia netto-fee (BTC W24H12 th0.58 il piu' robusto: 8/9 anni+, DD 23%).")
|
||||||
|
print(f" tempo: {time.time() - t0:.0f}s")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Validazione DURA del solo edge sopravvissuto alla ricerca shape: ML walk-forward
|
||||||
|
(LogisticRegression) sulle feature di FORMA. Tutto il resto della famiglia shape e' rumore.
|
||||||
|
|
||||||
|
Candidato: BTC logit W24H12 th0.58 (FULL +219% / OOS +42% / Sharpe 2.72 / 8-9 anni+,
|
||||||
|
regge fee 0.20% RT). Prima di promuoverlo a strategia serve (metodologia obbligatoria):
|
||||||
|
1. ROBUSTEZZA MULTI-ASSET: stessa config su BTC/ETH/LTC/SOL/ADA/XRP 1h.
|
||||||
|
2. WALK-FORWARD ROLLING (train fisso 2y) oltre all'expanding -> niente "memoria infinita".
|
||||||
|
3. STRESS leva 2x + slippage doppio (fee 0.20% RT) -> regge in condizioni realistiche?
|
||||||
|
4. ROBUSTEZZA SU GRIGLIA (th, W, H) -> plateau, non picco.
|
||||||
|
5. CORRELAZIONE col MASTER + integrazione -> e' un diversificatore (free-lunch)?
|
||||||
|
|
||||||
|
Tutto netto-fee, OOS = ultimo 30%. Conta il PnL netto, non l'accuracy (lezione squeeze).
|
||||||
|
|
||||||
|
Run: uv run python scripts/analysis/shape_ml_validate.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import get_df, evaluate, robust, FEE_RT, LEV, POS, OOS_FRAC
|
||||||
|
from scripts.analysis.shape_ml_research import ml_wf_entries, acc_oos
|
||||||
|
|
||||||
|
ASSETS = ["BTC", "ETH", "LTC", "SOL", "ADA", "XRP"]
|
||||||
|
TWO_YEARS_1H = 24 * 365 * 2 # ~17520 barre = finestra rolling 2 anni
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def line(name, ents, df, fees=(0.0, 0.001, 0.002)):
|
||||||
|
"""Riga evaluate + accuracy OOS, ritorna (res, robusto?)."""
|
||||||
|
res = evaluate(name, ents, df)
|
||||||
|
ac = acc_oos(ents, df)
|
||||||
|
rb = (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
|
||||||
|
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0)
|
||||||
|
print(f" ^ accOOS={ac:4.1f}% {'[ROBUST fee0.2%]' if rb else ''}")
|
||||||
|
return res, rb
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def sec_multi_asset(W=24, H=12, th=0.58):
|
||||||
|
print("\n[1] MULTI-ASSET — logit W%dH%d th%.2f, walk-forward EXPANDING (1h):" % (W, H, th))
|
||||||
|
ok = []
|
||||||
|
dfs = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = get_df(a, "1h"); dfs[a] = df
|
||||||
|
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
|
||||||
|
_, rb = line(f"{a} exp", ents, df)
|
||||||
|
if rb:
|
||||||
|
ok.append(a)
|
||||||
|
print(f" -> EXPANDING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
|
||||||
|
return dfs, ok
|
||||||
|
|
||||||
|
|
||||||
|
def sec_rolling(dfs, W=24, H=12, th=0.58, tw=TWO_YEARS_1H):
|
||||||
|
print("\n[2] WALK-FORWARD ROLLING — train fisso ~2 anni (%d barre), stessa config:" % tw)
|
||||||
|
ok = []
|
||||||
|
for a in ASSETS:
|
||||||
|
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th, train_window=tw)
|
||||||
|
_, rb = line(f"{a} roll2y", ents, dfs[a])
|
||||||
|
if rb:
|
||||||
|
ok.append(a)
|
||||||
|
print(f" -> ROLLING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def sec_stress(dfs, W=24, H=12, th=0.58):
|
||||||
|
print("\n[3] STRESS — leva 2x + slippage doppio (fee 0.20% RT) su BTC/ETH:")
|
||||||
|
print(" (la config nominale e' leva 3x fee 0.10%; qui peggioro entrambe)")
|
||||||
|
from scripts.analysis.explore_lab import simulate
|
||||||
|
for a in ["BTC", "ETH"]:
|
||||||
|
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th)
|
||||||
|
df = dfs[a]
|
||||||
|
split = int(len(df) * (1 - OOS_FRAC))
|
||||||
|
base = simulate(ents, df, fee_rt=0.001, lev=3.0)
|
||||||
|
stress_f = simulate(ents, df, fee_rt=0.002, lev=2.0)
|
||||||
|
stress_o = simulate(ents, df, fee_rt=0.002, lev=2.0, split=split)
|
||||||
|
print(f" {a}: base(3x,0.1%) FULL={base['ret']:+.0f}% Shrp={base['sharpe']:.2f} | "
|
||||||
|
f"STRESS(2x,0.2%) FULL={stress_f['ret']:+.0f}% OOS={stress_o['ret']:+.0f}% "
|
||||||
|
f"DD={stress_f['dd']:.0f}% Shrp={stress_f['sharpe']:.2f} "
|
||||||
|
f"{'OK' if stress_f['ret'] > 0 and stress_o['ret'] > 0 else 'KO'}")
|
||||||
|
|
||||||
|
|
||||||
|
def sec_grid(dfs, asset="BTC"):
|
||||||
|
print(f"\n[4] ROBUSTEZZA GRIGLIA su {asset} (plateau, non picco):")
|
||||||
|
rob = tot = 0
|
||||||
|
for W in (16, 24, 32):
|
||||||
|
for H in (8, 12, 16):
|
||||||
|
for th in (0.56, 0.58, 0.60):
|
||||||
|
ents = ml_wf_entries(dfs[asset], W=W, H=H, model="logit", thresh=th)
|
||||||
|
_, rb = line(f"{asset} W{W}H{H}th{th}", ents, dfs[asset])
|
||||||
|
tot += 1; rob += rb
|
||||||
|
print(f" -> {asset}: {rob}/{tot} celle robuste a fee 0.2% (plateau se alta frazione)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def shape_daily_equity(asset, IDX, W=24, H=12, th=0.58):
|
||||||
|
"""Equity giornaliera dello sleeve shape-ML (time-exit a H, non-overlap, pos 0.15,
|
||||||
|
leva 3x, fee 0.10% RT), normalizzata sull'indice comune dei portafogli."""
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
c = df["close"].values
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
|
||||||
|
n = len(c); eq = np.full(n, 1000.0); cap = 1000.0; last_exit = -1
|
||||||
|
fee = FEE_RT * LEV
|
||||||
|
for e in sorted(ents, key=lambda x: x["i"]):
|
||||||
|
i, d, mb = e["i"], e["d"], e["max_bars"]
|
||||||
|
if i <= last_exit or i + mb >= n:
|
||||||
|
continue
|
||||||
|
j = i + mb
|
||||||
|
ret = (c[j] - c[i]) / c[i] * d * LEV - fee
|
||||||
|
cap = max(cap + cap * POS * ret, 10.0)
|
||||||
|
eq[j:] = cap; last_exit = j
|
||||||
|
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||||
|
return s / s.iloc[0]
|
||||||
|
|
||||||
|
|
||||||
|
def sec_master_integration():
|
||||||
|
print("\n[5] CORRELAZIONE + INTEGRAZIONE COL MASTER:")
|
||||||
|
from scripts.analysis.combine_portfolio import (
|
||||||
|
build_all_sleeves, port_returns, metrics, IDX, SPLIT,
|
||||||
|
)
|
||||||
|
sleeves = build_all_sleeves()
|
||||||
|
# sleeve shape: BTC + ETH (i due con piu' storia/edge)
|
||||||
|
shape = {}
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
shape[f"SH_{a}"] = shape_daily_equity(a, IDX)
|
||||||
|
dr_master = port_returns(sleeves) # MASTER equal-weight attuale
|
||||||
|
dr_shape = port_returns(shape)
|
||||||
|
corr = float(dr_master.corr(dr_shape))
|
||||||
|
print(f" correlazione daily MASTER vs sleeve-shape: {corr:+.3f}")
|
||||||
|
# correlazione media shape vs ogni sleeve esistente
|
||||||
|
cs = {k: float(port_returns({k: v}).corr(dr_shape)) for k, v in sleeves.items()}
|
||||||
|
print(" corr shape vs singole sleeve: " + ", ".join(f"{k}={v:+.2f}" for k, v in cs.items()))
|
||||||
|
|
||||||
|
base = {**sleeves}
|
||||||
|
ext = {**sleeves, **shape}
|
||||||
|
fb, ob = metrics(port_returns(base)), metrics(port_returns(base), lo=SPLIT)
|
||||||
|
fe, oe = metrics(port_returns(ext)), metrics(port_returns(ext), lo=SPLIT)
|
||||||
|
print(" %-22s %9s %6s %6s %6s | %9s %6s %6s %6s" %
|
||||||
|
("portafoglio", "FULLret", "CAGR", "DD", "Shrp", "OOSret", "CAGR", "DD", "Shrp"))
|
||||||
|
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
|
||||||
|
("MASTER (9 sleeve)", fb["ret"], fb["cagr"], fb["dd"], fb["sharpe"],
|
||||||
|
ob["ret"], ob["cagr"], ob["dd"], ob["sharpe"]))
|
||||||
|
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
|
||||||
|
("MASTER + shape", fe["ret"], fe["cagr"], fe["dd"], fe["sharpe"],
|
||||||
|
oe["ret"], oe["cagr"], oe["dd"], oe["sharpe"]))
|
||||||
|
better = oe["sharpe"] > ob["sharpe"] and oe["dd"] <= ob["dd"] + 1
|
||||||
|
print(f" -> aggiungere shape MIGLIORA il MASTER OOS (Sharpe up, DD ~stabile)? "
|
||||||
|
f"{'SI' if better else 'NO'}")
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
t0 = time.time()
|
||||||
|
print("=" * 100)
|
||||||
|
print(" VALIDAZIONE DURA — shape-ML (LogisticRegression walk-forward sulle feature di forma)")
|
||||||
|
print("=" * 100)
|
||||||
|
dfs, _ = sec_multi_asset()
|
||||||
|
sec_rolling(dfs)
|
||||||
|
sec_stress(dfs)
|
||||||
|
sec_grid(dfs, "BTC")
|
||||||
|
sec_master_integration()
|
||||||
|
print(f"\n tempo totale: {time.time() - t0:.0f}s")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""Famiglia SHAPE-PIVOT: geometria a punti di svolta (PIP / pivot) -> bias futuro.
|
||||||
|
|
||||||
|
Idea (causale, no look-ahead):
|
||||||
|
- a ogni barra i comprimo la finestra di L barre terminante a close[i] nei suoi
|
||||||
|
P punti percettivamente importanti (PIP, Perceptually Important Points: i punti
|
||||||
|
di massima deviazione dalla retta congiungente — Fu et al.);
|
||||||
|
- la sequenza di P punti e' una POLILINEA = forma geometrica grezza;
|
||||||
|
- la classifico con feature interpretabili e CAUSALI:
|
||||||
|
* trend dei pivot interni: higher-highs/higher-lows (HH/HL) vs lower-* (LH/LL);
|
||||||
|
* convergenza/divergenza delle pendenze (triangoli/cunei);
|
||||||
|
* distanza % di close[i] dall'ultimo pivot alto/basso (vicino a R / a S);
|
||||||
|
* pendenza dell'ultimo segmento (slancio recente);
|
||||||
|
- per ogni CLASSE geometrica stimo l'esito medio a H barre usando SOLO occorrenze
|
||||||
|
passate il cui esito era gia' realizzato prima di i (statistica causale rolling);
|
||||||
|
- entro a close[i] nella direzione del bias di classe se l'edge passato e' netto;
|
||||||
|
exit a H barre o TP/SL in ATR.
|
||||||
|
|
||||||
|
VINCOLI (CLAUDE.md "metodologia obbligatoria" + "lezione squeeze look-ahead"):
|
||||||
|
- PIP/pivot calcolati SOLO su close[i-L+1 .. i]; nessun pivot "confermato dal futuro".
|
||||||
|
- ogni statistica per-classe usa solo campioni con esito (entry+H) <= i-1.
|
||||||
|
- ingresso eseguibile a close[i]; netto fee (0.10% RT base, sweep a 0.20%); leva 3x,
|
||||||
|
pos 0.15; validazione OOS (ultimo 30%) + robustezza griglia + >=2 asset.
|
||||||
|
- check di causalita' esplicito (perturbo il futuro: la forma a i non cambia).
|
||||||
|
|
||||||
|
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import ( # noqa: E402
|
||||||
|
get_df, evaluate, robust, simulate, atr, ema, _dt, OOS_FRAC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PIP — Perceptually Important Points (causale, solo su close[a..b])
|
||||||
|
# =========================================================================
|
||||||
|
def pip_indices(seg: np.ndarray, p: int) -> list[int]:
|
||||||
|
"""Estrae p indici PIP dalla serie `seg` (inclusi i 2 estremi).
|
||||||
|
|
||||||
|
Algoritmo Fu et al.: parti dai 2 estremi; aggiungi iterativamente il punto a
|
||||||
|
massima distanza VERTICALE dalla retta che unisce i due PIP adiacenti, finche'
|
||||||
|
non hai p punti. Tutto sul segmento dato -> nessun look-ahead se seg=close[..i].
|
||||||
|
"""
|
||||||
|
n = len(seg)
|
||||||
|
if p >= n:
|
||||||
|
return list(range(n))
|
||||||
|
pts = [0, n - 1]
|
||||||
|
while len(pts) < p:
|
||||||
|
best_d, best_k = -1.0, -1
|
||||||
|
for s in range(len(pts) - 1):
|
||||||
|
l, r = pts[s], pts[s + 1]
|
||||||
|
if r - l < 2:
|
||||||
|
continue
|
||||||
|
x1, y1 = l, seg[l]
|
||||||
|
x2, y2 = r, seg[r]
|
||||||
|
dx = x2 - x1
|
||||||
|
# distanza verticale dalla retta (interpolazione lineare in x)
|
||||||
|
for k in range(l + 1, r):
|
||||||
|
if dx == 0:
|
||||||
|
dist = abs(seg[k] - y1)
|
||||||
|
else:
|
||||||
|
yline = y1 + (y2 - y1) * (k - x1) / dx
|
||||||
|
dist = abs(seg[k] - yline)
|
||||||
|
if dist > best_d:
|
||||||
|
best_d, best_k = dist, k
|
||||||
|
if best_k < 0:
|
||||||
|
break
|
||||||
|
# inserisci mantenendo l'ordine
|
||||||
|
for s in range(len(pts) - 1):
|
||||||
|
if pts[s] < best_k < pts[s + 1]:
|
||||||
|
pts.insert(s + 1, best_k)
|
||||||
|
break
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Classe geometrica della polilinea PIP (feature causali interpretabili)
|
||||||
|
# =========================================================================
|
||||||
|
def shape_class(seg: np.ndarray, p: int) -> tuple | None:
|
||||||
|
"""Ritorna una tupla-classe discreta della forma PIP di `seg`, o None se degenere.
|
||||||
|
|
||||||
|
Feature (tutte da seg=close[..i], causali):
|
||||||
|
- dir_seq: per ogni pivot interno, segno della variazione vs precedente
|
||||||
|
(sequenza su/giu) -> cattura HH/HL vs LH/LL e zig-zag;
|
||||||
|
- conv: convergenza pendenze inizio vs fine (triangolo/cuneo): segno di
|
||||||
|
(|slope_last| - |slope_first|) discretizzato;
|
||||||
|
- loc: posizione di close[i] nel range della finestra (vicino a max=resistenza,
|
||||||
|
vicino a min=supporto), in 3 bucket.
|
||||||
|
La classe e' invariante a livello/scala (z-norm implicito su forma).
|
||||||
|
"""
|
||||||
|
idx = pip_indices(seg, p)
|
||||||
|
if len(idx) < 3:
|
||||||
|
return None
|
||||||
|
y = seg[idx]
|
||||||
|
rng = y.max() - y.min()
|
||||||
|
if rng <= 0:
|
||||||
|
return None
|
||||||
|
yn = (y - y.min()) / rng # forma normalizzata 0..1
|
||||||
|
# sequenza direzioni dei segmenti (su=1 / giu=0)
|
||||||
|
diffs = np.diff(yn)
|
||||||
|
dir_seq = tuple(int(x > 0) for x in diffs)
|
||||||
|
# convergenza: pendenza primo vs ultimo segmento
|
||||||
|
s_first = abs(diffs[0])
|
||||||
|
s_last = abs(diffs[-1])
|
||||||
|
if s_last > s_first * 1.3:
|
||||||
|
conv = 1 # divergente (slancio finale)
|
||||||
|
elif s_last < s_first * 0.77:
|
||||||
|
conv = -1 # convergente (compressione, triangolo/cuneo)
|
||||||
|
else:
|
||||||
|
conv = 0
|
||||||
|
# posizione di close[i] (=ultimo punto) nel range: 0..1 in 3 bucket
|
||||||
|
last = yn[-1]
|
||||||
|
loc = 0 if last < 0.33 else (2 if last > 0.67 else 1)
|
||||||
|
return (dir_seq, conv, loc)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Strategia: bias per-classe stimato CAUSALMENTE (rolling, esito realizzato)
|
||||||
|
# =========================================================================
|
||||||
|
def pivot_entries(df, L=48, P=5, H=12, min_lib=1000, min_samples=20,
|
||||||
|
edge=0.0, tp_atr=None, sl_atr=None,
|
||||||
|
trend_max=None, ema_long=200, mode="bias") -> list[dict]:
|
||||||
|
"""Entries dalla geometria PIP con bias di classe causale.
|
||||||
|
|
||||||
|
L: lunghezza finestra-forma. P: n. punti PIP. H: orizzonte (=max_bars).
|
||||||
|
min_lib: barre minime prima di operare. min_samples: campioni minimi per fidarsi
|
||||||
|
della statistica di una classe. edge: |rendimento medio classe| minimo
|
||||||
|
(frazione, es. 0.002 = 0.2%) per entrare. mode:
|
||||||
|
- "bias": entra nel verso del rendimento medio passato della classe (momentum
|
||||||
|
della forma: la classe X storicamente -> su/giu);
|
||||||
|
- "fade": entra nel verso OPPOSTO (test mean-reversion della forma).
|
||||||
|
Statistica per-classe accumulata SOLO con esiti realizzati < i (causale stretta).
|
||||||
|
"""
|
||||||
|
close = df["close"].values
|
||||||
|
high = df["high"].values
|
||||||
|
low = df["low"].values
|
||||||
|
n = len(close)
|
||||||
|
a = atr(df, 14)
|
||||||
|
el = ema(close, ema_long) if trend_max is not None else None
|
||||||
|
|
||||||
|
# stato rolling per classe: somma rendimenti e conteggio (solo esiti < i)
|
||||||
|
cls_sum: dict[tuple, float] = {}
|
||||||
|
cls_cnt: dict[tuple, int] = {}
|
||||||
|
# coda di campioni la cui forma e' stata calcolata ma esito non ancora maturo
|
||||||
|
# pending[t] = (classe, indice_entry t) -> matura quando t+H <= i-1
|
||||||
|
pending: list[tuple] = [] # (mature_at, cls, t)
|
||||||
|
pend_ptr = 0
|
||||||
|
|
||||||
|
entries: list[dict] = []
|
||||||
|
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
# 1) integra nello storico tutti i campioni il cui esito e' realizzato (< i)
|
||||||
|
# un campione formato a t matura quando t+H <= i-1 => mature_at = t+H+1 <= i
|
||||||
|
while pend_ptr < len(pending) and pending[pend_ptr][0] <= i:
|
||||||
|
_, cls_p, t = pending[pend_ptr]
|
||||||
|
ret_real = (close[t + H] - close[t]) / close[t]
|
||||||
|
cls_sum[cls_p] = cls_sum.get(cls_p, 0.0) + ret_real
|
||||||
|
cls_cnt[cls_p] = cls_cnt.get(cls_p, 0) + 1
|
||||||
|
pend_ptr += 1
|
||||||
|
|
||||||
|
# 2) forma corrente (solo close fino a i)
|
||||||
|
seg = close[i - L + 1: i + 1]
|
||||||
|
cls = shape_class(seg, P)
|
||||||
|
if cls is None:
|
||||||
|
continue
|
||||||
|
# registra il campione corrente come pending (esito da realizzare in futuro)
|
||||||
|
pending.append((i + H + 1, cls, i))
|
||||||
|
|
||||||
|
# 3) decisione con statistica PASSATA della classe
|
||||||
|
cnt = cls_cnt.get(cls, 0)
|
||||||
|
if cnt < min_samples:
|
||||||
|
continue
|
||||||
|
mean_ret = cls_sum[cls] / cnt
|
||||||
|
if abs(mean_ret) < edge:
|
||||||
|
continue
|
||||||
|
d = 1 if mean_ret > 0 else -1
|
||||||
|
if mode == "fade":
|
||||||
|
d = -d
|
||||||
|
# filtro trend opzionale
|
||||||
|
if trend_max is not None and a[i] > 0:
|
||||||
|
if abs(close[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Filone (c): distanza da supporto/resistenza locale (ultimo pivot alto/basso)
|
||||||
|
# =========================================================================
|
||||||
|
def sr_entries(df, L=48, P=7, H=12, near=0.5, mode="fade",
|
||||||
|
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
|
||||||
|
"""Filone (c): close[i] vicino all'ultimo pivot alto (R) o basso (S) della forma.
|
||||||
|
|
||||||
|
Usa i PIP per individuare l'ultimo massimo/minimo locale (resistenza/supporto) e
|
||||||
|
misura la distanza % di close[i]. Se close e' entro `near`*ATR da R -> bias short
|
||||||
|
(mode='fade': rimbalzo da R) o long (mode='break': rottura). Simmetrico per S.
|
||||||
|
Tutto causale: PIP su close[..i], decisione a close[i].
|
||||||
|
"""
|
||||||
|
close = df["close"].values
|
||||||
|
n = len(close)
|
||||||
|
a = atr(df, 14)
|
||||||
|
el = ema(close, ema_long) if trend_max is not None else None
|
||||||
|
entries: list[dict] = []
|
||||||
|
|
||||||
|
for i in range(L, n - 1):
|
||||||
|
seg = close[i - L + 1: i + 1]
|
||||||
|
idx = pip_indices(seg, P)
|
||||||
|
if len(idx) < 3 or a[i] <= 0:
|
||||||
|
continue
|
||||||
|
y = seg[idx]
|
||||||
|
# pivot interni (escludi i 2 estremi e l'ultimo punto = close[i])
|
||||||
|
inner = y[1:-1]
|
||||||
|
if len(inner) == 0:
|
||||||
|
continue
|
||||||
|
res = inner.max() # resistenza locale
|
||||||
|
sup = inner.min() # supporto locale
|
||||||
|
cur = close[i]
|
||||||
|
dist_r = (res - cur) / a[i]
|
||||||
|
dist_s = (cur - sup) / a[i]
|
||||||
|
d = None
|
||||||
|
if 0 <= dist_r <= near: # appena sotto R
|
||||||
|
d = -1 if mode == "fade" else 1
|
||||||
|
elif 0 <= dist_s <= near: # appena sopra S
|
||||||
|
d = 1 if mode == "fade" else -1
|
||||||
|
if d is None:
|
||||||
|
continue
|
||||||
|
if trend_max is not None and abs(cur - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None:
|
||||||
|
e["tp"] = cur + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None:
|
||||||
|
e["sl"] = cur - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Check causalita' esplicito
|
||||||
|
# =========================================================================
|
||||||
|
def check_no_lookahead(df, L=48, P=5) -> bool:
|
||||||
|
"""La classe-forma a i non deve cambiare se perturbo il FUTURO (>i)."""
|
||||||
|
close = df["close"].values.copy()
|
||||||
|
i = len(close) // 2
|
||||||
|
seg0 = close[i - L + 1: i + 1].copy()
|
||||||
|
c0 = shape_class(seg0, P)
|
||||||
|
close2 = close.copy()
|
||||||
|
close2[i + 1:] *= 1.7 # stravolge il futuro
|
||||||
|
seg1 = close2[i - L + 1: i + 1]
|
||||||
|
c1 = shape_class(seg1, P)
|
||||||
|
ok = (c0 == c1)
|
||||||
|
print(f" no-lookahead classe-forma a i={i}: {'OK' if ok else 'VIOLATO'} "
|
||||||
|
f"(c0={c0} c1={c1})")
|
||||||
|
# check su PIP indices
|
||||||
|
p0 = pip_indices(seg0, P)
|
||||||
|
p1 = pip_indices(seg1, P)
|
||||||
|
ok2 = (p0 == p1)
|
||||||
|
print(f" no-lookahead indici PIP: {'OK' if ok2 else 'VIOLATO'}")
|
||||||
|
return ok and ok2
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# run() riproducibile
|
||||||
|
# =========================================================================
|
||||||
|
def run():
|
||||||
|
print("=" * 100)
|
||||||
|
print(" SHAPE-PIVOT RESEARCH — geometria PIP/pivot -> bias futuro | netto fee, OOS")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
df_btc = get_df("BTC", "1h")
|
||||||
|
print("\n[CAUSALITA']")
|
||||||
|
check_no_lookahead(df_btc, L=48, P=5)
|
||||||
|
|
||||||
|
assets = ["BTC", "ETH", "SOL", "ADA"]
|
||||||
|
dfs = {a: get_df(a, "1h") for a in assets}
|
||||||
|
|
||||||
|
# ---- A) bias di classe PIP (momentum della forma) ----
|
||||||
|
print("\n[A] BIAS di classe PIP (entra nel verso del rendimento medio passato della classe)")
|
||||||
|
print(" sweep L/P/H, edge=0.002, min_samples=25, time-exit a H")
|
||||||
|
A_grid = [(48, 5, 12), (48, 5, 24), (72, 6, 24), (36, 5, 12), (96, 7, 24), (48, 7, 12)]
|
||||||
|
for L, P, H in A_grid:
|
||||||
|
print(f" -- L{L} P{P} H{H} --")
|
||||||
|
for a in assets:
|
||||||
|
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="bias")
|
||||||
|
evaluate(f"{a} bias L{L}P{P}H{H}", ents, dfs[a])
|
||||||
|
|
||||||
|
# ---- B) fade di classe PIP (mean-reversion della forma) ----
|
||||||
|
print("\n[B] FADE di classe PIP (entra opposto al bias storico -> test mean-reversion)")
|
||||||
|
for L, P, H in A_grid:
|
||||||
|
print(f" -- L{L} P{P} H{H} --")
|
||||||
|
for a in assets:
|
||||||
|
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="fade")
|
||||||
|
evaluate(f"{a} fade L{L}P{P}H{H}", ents, dfs[a])
|
||||||
|
|
||||||
|
# ---- C) supporto/resistenza locale dai pivot ----
|
||||||
|
print("\n[C] S/R locale dai PIP — FADE (rimbalzo da R/S) vs BREAK (rottura)")
|
||||||
|
for mode in ("fade", "break"):
|
||||||
|
for near in (0.5, 1.0):
|
||||||
|
print(f" -- mode={mode} near={near} ATR, TP/SL 1.5/1.5 ATR, H=12 --")
|
||||||
|
for a in assets:
|
||||||
|
ents = sr_entries(dfs[a], L=48, P=7, H=12, near=near, mode=mode,
|
||||||
|
tp_atr=1.5, sl_atr=1.5)
|
||||||
|
evaluate(f"{a} SR-{mode} near{near}", ents, dfs[a])
|
||||||
|
|
||||||
|
# ---- D) miglior candidato con TP/SL ATR + filtro trend (se A o B mostra segnali) ----
|
||||||
|
print("\n[D] FADE di classe con TP/SL ATR (2.0/1.5) + filtro trend 3.0, L48 P5 H24")
|
||||||
|
for a in assets:
|
||||||
|
ents = pivot_entries(dfs[a], L=48, P=5, H=24, edge=0.002, min_samples=25,
|
||||||
|
mode="fade", tp_atr=2.0, sl_atr=1.5, trend_max=3.0)
|
||||||
|
res = evaluate(f"{a} fadeTPSL L48P5H24", ents, dfs[a])
|
||||||
|
if robust(res):
|
||||||
|
print(f" ^^^ {a} ROBUSTO")
|
||||||
|
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" Verdetto: cerca righe con FULL>0 E OOS>0 E fee0.2% OOS>0 su >=2 asset.")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
"""SHAPE_TEMPLATE_RESEARCH — edge nella FORMA del prezzo: distanze alternative e template canonici.
|
||||||
|
|
||||||
|
Due filoni, sull'harness ONESTO condiviso (shape_lab + explore_lab), netto-fee e OOS:
|
||||||
|
|
||||||
|
1. ANALOG con distanza di FORMA alternativa (DTW warping-invariant, correlazione/coseno)
|
||||||
|
confrontata HEAD-TO-HEAD con l'euclidea a PARITA' di selettivita' (stessa libreria,
|
||||||
|
stesso K, stessa soglia di accordo). DTW e' O(W^2): si usa una libreria SOTTOCAMPIONATA
|
||||||
|
(uno start ogni `step` barre) + W ridotto + banda di Sakoe-Chiba.
|
||||||
|
|
||||||
|
2. TEMPLATE di forma canonici (doppio top/bottom, testa-spalle, V-reversal, salita/discesa
|
||||||
|
lineare, U). A ogni i misuro la similarita' (correlazione di Pearson sulla finestra
|
||||||
|
z-normalizzata) fra forma recente e ogni template; se supera soglia, entro a close[i]
|
||||||
|
nella DIREZIONE ATTESA del template stimata SOLO sul passato (esito medio causale delle
|
||||||
|
occorrenze gia' concluse di quel template), exit H barre o tp/sl ATR.
|
||||||
|
|
||||||
|
VINCOLI anti-look-ahead (verificati esplicitamente):
|
||||||
|
- la forma/match a i usa SOLO close fino a i (z-norm causale);
|
||||||
|
- la direzione attesa di ogni template e la libreria analog usano SOLO occorrenze il cui
|
||||||
|
esito a H barre e' gia' realizzato PRIMA di i (end + H <= i-1);
|
||||||
|
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
|
||||||
|
|
||||||
|
Netto fee 0.10% RT baseline + sweep fino a 0.20%. Leva 3x, pos 0.15. OOS ultimo 30%.
|
||||||
|
|
||||||
|
Run riproducibile: uv run python scripts/analysis/shape_template_research.py
|
||||||
|
DTW e' costoso: usa run_in_background per gli sweep larghi (vedi --sweep).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.lib.stride_tricks import sliding_window_view
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import get_df, evaluate, robust, simulate, atr, ema, OOS_FRAC # noqa: E402
|
||||||
|
from scripts.analysis.shape_lab import znorm_windows, fwd_return # noqa: E402
|
||||||
|
|
||||||
|
RNG_SEED = 7
|
||||||
|
SUBC_ASSETS = ["BTC", "ETH", "SOL"]
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# DISTANZE DI FORMA
|
||||||
|
# =========================================================================================
|
||||||
|
def _euclid(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
|
||||||
|
"""Distanza euclidea fra q (W,) e ogni riga di lib (M,W). Forme gia' z-normalizzate."""
|
||||||
|
return np.sqrt(((lib - q) ** 2).sum(axis=1))
|
||||||
|
|
||||||
|
|
||||||
|
def _corr_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
|
||||||
|
"""Distanza = 1 - correlazione di Pearson (q,lib gia' z-norm: corr = q.lib / W)."""
|
||||||
|
# forme z-norm hanno media 0 std 1 -> dot/W e' la correlazione di Pearson
|
||||||
|
corr = (lib @ q) / q.shape[0]
|
||||||
|
return 1.0 - corr
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
|
||||||
|
"""Distanza = 1 - coseno fra q e ogni riga di lib."""
|
||||||
|
qn = q / (np.linalg.norm(q) + 1e-12)
|
||||||
|
ln = lib / (np.linalg.norm(lib, axis=1, keepdims=True) + 1e-12)
|
||||||
|
return 1.0 - (ln @ qn)
|
||||||
|
|
||||||
|
|
||||||
|
def _dtw_one(a: np.ndarray, b: np.ndarray, band: int) -> float:
|
||||||
|
"""DTW 1D con banda di Sakoe-Chiba (|i-j|<=band). a,b stessa lunghezza W."""
|
||||||
|
n = len(a)
|
||||||
|
INF = 1e18
|
||||||
|
prev = np.full(n + 1, INF)
|
||||||
|
prev[0] = 0.0
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
cur = np.full(n + 1, INF)
|
||||||
|
jlo = max(1, i - band)
|
||||||
|
jhi = min(n, i + band)
|
||||||
|
ai = a[i - 1]
|
||||||
|
for j in range(jlo, jhi + 1):
|
||||||
|
cost = abs(ai - b[j - 1])
|
||||||
|
m = prev[j]
|
||||||
|
if prev[j - 1] < m:
|
||||||
|
m = prev[j - 1]
|
||||||
|
if cur[j - 1] < m:
|
||||||
|
m = cur[j - 1]
|
||||||
|
cur[j] = cost + m
|
||||||
|
prev = cur
|
||||||
|
return float(prev[n])
|
||||||
|
|
||||||
|
|
||||||
|
def _dtw_dist(q: np.ndarray, lib: np.ndarray, band: int) -> np.ndarray:
|
||||||
|
"""DTW di q contro ogni riga di lib. O(M * W * band)."""
|
||||||
|
out = np.empty(lib.shape[0])
|
||||||
|
for k in range(lib.shape[0]):
|
||||||
|
out[k] = _dtw_one(q, lib[k], band)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
DIST_FUNCS = {"euclid": _euclid, "corr": _corr_dist, "cosine": _cosine_dist}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# FILONE 1 — ANALOG con distanza configurabile (libreria sottocampionata, causale)
|
||||||
|
# =========================================================================================
|
||||||
|
def analog_dist_entries(df, dist="euclid", W=24, H=12, K=40, step=5, rebuild=500,
|
||||||
|
min_lib=2000, agree=0.62, dtw_band=4, dtw_prefilter=200,
|
||||||
|
decide_step=1, tp_atr=None, sl_atr=None,
|
||||||
|
trend_max=None, ema_long=200) -> list[dict]:
|
||||||
|
"""Analog kNN sulla FORMA con metrica `dist` ('euclid'|'corr'|'cosine'|'dtw').
|
||||||
|
|
||||||
|
Libreria SOTTOCAMPIONATA: si considerano solo finestre che terminano a indici
|
||||||
|
multipli di `step` (riduce N e rende DTW trattabile). Causalita': la libreria a
|
||||||
|
decisione i contiene solo finestre con end<=i-1-H (esito gia' realizzato).
|
||||||
|
Ricostruita ogni `rebuild` barre. Stessa firma per tutte le metriche -> confronto
|
||||||
|
head-to-head a parita' di selettivita' (stesso W,H,K,agree).
|
||||||
|
|
||||||
|
DTW (costoso, O(W*band) per coppia in Python): si PREFILTRA con la correlazione ai
|
||||||
|
`dtw_prefilter` candidati piu' simili, poi si fa DTW-rerank solo su quelli (approccio
|
||||||
|
standard lower-bound/rerank). `decide_step`>1 valuta una barra ogni decide_step (non
|
||||||
|
cambia la causalita', riduce solo il numero di query DTW).
|
||||||
|
"""
|
||||||
|
close = df["close"].values
|
||||||
|
n = len(close)
|
||||||
|
a = atr(df, 14)
|
||||||
|
M, ends = znorm_windows(close, W)
|
||||||
|
end_pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
fr = fwd_return(close, H)
|
||||||
|
el = ema(close, ema_long) if trend_max is not None else None
|
||||||
|
|
||||||
|
# candidati di libreria: solo end multipli di step (sottocampionamento causale fisso)
|
||||||
|
base_ends = ends[(ends % step == 0)]
|
||||||
|
|
||||||
|
entries: list[dict] = []
|
||||||
|
lib_M = None
|
||||||
|
lib_idx = None
|
||||||
|
next_rebuild = 0
|
||||||
|
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
if i % decide_step != 0:
|
||||||
|
continue
|
||||||
|
if lib_M is None or i >= next_rebuild:
|
||||||
|
elig = base_ends[(base_ends <= i - 1 - H) & (base_ends >= W - 1)]
|
||||||
|
elig = elig[~np.isnan(fr[elig])]
|
||||||
|
if len(elig) < max(K * 3, 200):
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
continue
|
||||||
|
lib_M = M[[end_pos[int(e)] for e in elig]]
|
||||||
|
lib_idx = elig
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
|
||||||
|
if lib_M is None:
|
||||||
|
continue
|
||||||
|
q = M[end_pos[i]]
|
||||||
|
if not np.isfinite(q).all():
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dist == "dtw":
|
||||||
|
# prefiltro corr (cheap, vettoriale) -> DTW-rerank solo sui top dtw_prefilter
|
||||||
|
pre = _corr_dist(q, lib_M)
|
||||||
|
npre = min(dtw_prefilter, len(lib_idx))
|
||||||
|
cand = np.argpartition(pre, npre - 1)[:npre]
|
||||||
|
dd_cand = _dtw_dist(q, lib_M[cand], dtw_band)
|
||||||
|
kk = min(K, len(cand))
|
||||||
|
sub = np.argpartition(dd_cand, kk - 1)[:kk]
|
||||||
|
nn = cand[sub]
|
||||||
|
outs = fr[lib_idx[nn]]
|
||||||
|
outs = outs[~np.isnan(outs)]
|
||||||
|
if len(outs) < 5:
|
||||||
|
continue
|
||||||
|
mean_out = float(outs.mean())
|
||||||
|
d = 1 if mean_out > 0 else -1
|
||||||
|
frac = float(np.mean(np.sign(outs) == d))
|
||||||
|
if frac < agree:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
dd = DIST_FUNCS[dist](q, lib_M)
|
||||||
|
kk = min(K, len(lib_idx))
|
||||||
|
nn = np.argpartition(dd, kk - 1)[:kk]
|
||||||
|
outs = fr[lib_idx[nn]]
|
||||||
|
outs = outs[~np.isnan(outs)]
|
||||||
|
if len(outs) < 5:
|
||||||
|
continue
|
||||||
|
mean_out = float(outs.mean())
|
||||||
|
d = 1 if mean_out > 0 else -1
|
||||||
|
frac = float(np.mean(np.sign(outs) == d))
|
||||||
|
if frac < agree:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# FILONE 2 — TEMPLATE di forma canonici
|
||||||
|
# =========================================================================================
|
||||||
|
def make_templates(W: int) -> dict[str, np.ndarray]:
|
||||||
|
"""Template parametrici z-normalizzati di lunghezza W (forma pura, no scala/livello).
|
||||||
|
|
||||||
|
Sono solo descrittori di FORMA recente (gli ultimi W close). La direzione attesa NON
|
||||||
|
e' decisa a priori: viene stimata causalmente sul passato (vedi template_entries).
|
||||||
|
"""
|
||||||
|
t = np.linspace(0, 1, W)
|
||||||
|
s = 0.012 # ampiezza gaussiana scalata sulla finestra (W-indipendente in t in [0,1])
|
||||||
|
g = lambda c: np.exp(-((t - c) ** 2) / s)
|
||||||
|
raw = {
|
||||||
|
# estremi di reversione a DOPPIO picco (due massimi / minimi simmetrici)
|
||||||
|
"double_top": g(0.25) + g(0.75), # M: due cime
|
||||||
|
"double_bottom": -(g(0.25) + g(0.75)), # W: due fondi
|
||||||
|
# testa-spalle: spalla-testa-spalla (centro piu' alto)
|
||||||
|
"head_shoulders": g(0.2) + 1.7 * g(0.5) + g(0.8),
|
||||||
|
"inv_head_shoulders": -(g(0.2) + 1.7 * g(0.5) + g(0.8)),
|
||||||
|
# singola reversione
|
||||||
|
"v_bottom": np.abs(t - 0.5),
|
||||||
|
"inv_v_top": -np.abs(t - 0.5),
|
||||||
|
"u_bottom": (t - 0.5) ** 2,
|
||||||
|
"arch_top": -((t - 0.5) ** 2),
|
||||||
|
# trend lineari
|
||||||
|
"ramp_up": t,
|
||||||
|
"ramp_down": -t,
|
||||||
|
}
|
||||||
|
out = {}
|
||||||
|
for k, v in raw.items():
|
||||||
|
v = np.asarray(v, dtype=float)
|
||||||
|
sd = v.std()
|
||||||
|
out[k] = (v - v.mean()) / (sd if sd > 0 else 1.0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def template_entries(df, W=24, H=12, corr_min=0.85, dir_min=0.10, min_lib=2000,
|
||||||
|
rebuild=300, tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
|
||||||
|
templates=None) -> list[dict]:
|
||||||
|
"""Entries da match con template canonici, DIREZIONE stimata SOLO sul passato.
|
||||||
|
|
||||||
|
A ogni i, per ogni template calcolo la correlazione di Pearson fra la forma recente
|
||||||
|
z-norm (close[i-W+1..i]) e il template. Prendo il template a correlazione massima; se
|
||||||
|
>= corr_min lo considero "attivo". La DIREZIONE in cui entrare e' il segno del rendimento
|
||||||
|
forward MEDIO storico delle occorrenze gia' concluse (end+H<=i-1) di quel template
|
||||||
|
(stesso criterio di match), purche' |media| in barre-equivalenti superi dir_min*media_atr-ish
|
||||||
|
-> qui dir_min e' una soglia sulla |media forward| relativa (frazione). NIENTE direzione a
|
||||||
|
priori: se il passato non e' coerente (occorrenze<min o segno debole) si salta.
|
||||||
|
"""
|
||||||
|
close = df["close"].values
|
||||||
|
n = len(close)
|
||||||
|
a = atr(df, 14)
|
||||||
|
M, ends = znorm_windows(close, W)
|
||||||
|
end_pos = {int(e): k for k, e in enumerate(ends)}
|
||||||
|
fr = fwd_return(close, H)
|
||||||
|
el = ema(close, ema_long) if trend_max is not None else None
|
||||||
|
tps = templates if templates is not None else make_templates(W)
|
||||||
|
names = list(tps.keys())
|
||||||
|
T = np.stack([tps[k] for k in names]) # (NT, W), gia' z-norm
|
||||||
|
|
||||||
|
# match-history: per ogni end di libreria, quale template e con che corr
|
||||||
|
# (precalcolo causale: per ogni end, corr con ogni template)
|
||||||
|
# corr Pearson fra forme z-norm = dot/W
|
||||||
|
lib_ends = ends[ends >= W - 1]
|
||||||
|
lib_M = M[[end_pos[int(e)] for e in lib_ends]] # (L, W)
|
||||||
|
corr_mat = (lib_M @ T.T) / W # (L, NT)
|
||||||
|
best_tpl = np.argmax(corr_mat, axis=1)
|
||||||
|
best_corr = corr_mat[np.arange(len(lib_ends)), best_tpl]
|
||||||
|
lib_fr = fr[lib_ends]
|
||||||
|
lib_end_arr = lib_ends
|
||||||
|
|
||||||
|
entries: list[dict] = []
|
||||||
|
# cache direzione per template, ricostruita ogni rebuild barre
|
||||||
|
dir_cache: dict[int, int] = {}
|
||||||
|
next_rebuild = 0
|
||||||
|
|
||||||
|
for i in range(min_lib, n - 1):
|
||||||
|
q = M[end_pos[i]]
|
||||||
|
if not np.isfinite(q).all():
|
||||||
|
continue
|
||||||
|
cq = (T @ q) / W # corr con ogni template
|
||||||
|
bt = int(np.argmax(cq))
|
||||||
|
if cq[bt] < corr_min:
|
||||||
|
continue
|
||||||
|
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# direzione attesa: media forward causale delle occorrenze concluse dello stesso template
|
||||||
|
if i >= next_rebuild:
|
||||||
|
dir_cache = {}
|
||||||
|
next_rebuild = i + rebuild
|
||||||
|
if bt not in dir_cache:
|
||||||
|
mask = (lib_end_arr <= i - 1 - H) & (best_tpl == bt) & (best_corr >= corr_min) & (~np.isnan(lib_fr))
|
||||||
|
outs = lib_fr[mask]
|
||||||
|
if len(outs) < 30:
|
||||||
|
dir_cache[bt] = 0
|
||||||
|
else:
|
||||||
|
m = float(outs.mean())
|
||||||
|
# soglia: |media| forward deve superare dir_min volte la std forward (edge vs rumore)
|
||||||
|
sd = float(outs.std()) + 1e-12
|
||||||
|
dir_cache[bt] = (1 if m > 0 else -1) if abs(m) / sd >= dir_min else 0
|
||||||
|
d = dir_cache[bt]
|
||||||
|
if d == 0:
|
||||||
|
continue
|
||||||
|
e = {"i": i, "d": d, "max_bars": H}
|
||||||
|
if tp_atr is not None and a[i] > 0:
|
||||||
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
||||||
|
if sl_atr is not None and a[i] > 0:
|
||||||
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
||||||
|
entries.append(e)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# CHECK CAUSALITA' espliciti
|
||||||
|
# =========================================================================================
|
||||||
|
def check_causality_analog(df, **kw) -> bool:
|
||||||
|
"""Le entries non devono cambiare se perturbo il FUTURO oltre l'ultima barra usata.
|
||||||
|
Tronco il df a una certa lunghezza L e verifico che le entries con i<L-H-1 siano
|
||||||
|
identiche a quelle calcolate sul df completo (la coda futura non le tocca)."""
|
||||||
|
L = int(len(df) * 0.55)
|
||||||
|
H = kw.get("H", 12)
|
||||||
|
full = analog_dist_entries(df, **kw)
|
||||||
|
trunc = analog_dist_entries(df.iloc[:L].reset_index(drop=True), **kw)
|
||||||
|
horizon = L - H - 2
|
||||||
|
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
|
||||||
|
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
|
||||||
|
ok = (f == t)
|
||||||
|
print(f" causalita' analog ({kw.get('dist','euclid')}): {'OK' if ok else 'VIOLATO'} "
|
||||||
|
f"({len(f)} entries confrontate <{horizon})")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def check_causality_template(df, **kw) -> bool:
|
||||||
|
L = int(len(df) * 0.55)
|
||||||
|
H = kw.get("H", 12)
|
||||||
|
full = template_entries(df, **kw)
|
||||||
|
trunc = template_entries(df.iloc[:L].reset_index(drop=True), **kw)
|
||||||
|
horizon = L - H - 2
|
||||||
|
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
|
||||||
|
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
|
||||||
|
ok = (f == t)
|
||||||
|
print(f" causalita' template: {'OK' if ok else 'VIOLATO'} "
|
||||||
|
f"({len(f)} entries confrontate <{horizon})")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# RUN
|
||||||
|
# =========================================================================================
|
||||||
|
def run_head_to_head(assets=SUBC_ASSETS, W=16, H=12, K=40, step=6, agree=0.62,
|
||||||
|
decide_step=4, dtw_prefilter=120):
|
||||||
|
"""Confronto HEAD-TO-HEAD delle metriche di forma a PARITA' di selettivita'.
|
||||||
|
|
||||||
|
Tutte le metriche valutano le STESSE barre-decisione (decide_step) con lo STESSO
|
||||||
|
W/H/K/agree: l'unica variabile e' la distanza. decide_step>1 serve a rendere DTW
|
||||||
|
trattabile (pura Python ~9ms/query); applicato a tutte per equita'.
|
||||||
|
"""
|
||||||
|
print("=" * 100)
|
||||||
|
print(f" FILONE 1 — ANALOG head-to-head metriche (W{W} H{H} K{K} step{step} "
|
||||||
|
f"agree{agree} decide_step{decide_step}) | netto fee, OOS")
|
||||||
|
print("=" * 100)
|
||||||
|
results = {}
|
||||||
|
for asset in assets:
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
print(f"\n --- {asset} 1h (n={len(df)}) ---", flush=True)
|
||||||
|
for dist in ["euclid", "corr", "cosine", "dtw"]:
|
||||||
|
t0 = time.time()
|
||||||
|
ents = analog_dist_entries(df, dist=dist, W=W, H=H, K=K, step=step, agree=agree,
|
||||||
|
dtw_band=max(2, W // 5), dtw_prefilter=dtw_prefilter,
|
||||||
|
decide_step=decide_step)
|
||||||
|
dt = time.time() - t0
|
||||||
|
res = evaluate(f"{dist:<7s}", ents, df)
|
||||||
|
results[(asset, dist)] = res
|
||||||
|
print(f" ^ time={dt:>5.1f}s robust={'YES' if robust(res) else 'no '}", flush=True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_templates(assets=SUBC_ASSETS, W=20, H=12, corr_min=0.85, dir_min=0.10):
|
||||||
|
print("=" * 100)
|
||||||
|
print(f" FILONE 2 — TEMPLATE canonici (W{W} H{H} corr>={corr_min} dir>={dir_min}) | netto fee, OOS")
|
||||||
|
print("=" * 100)
|
||||||
|
results = {}
|
||||||
|
for asset in assets:
|
||||||
|
df = get_df(asset, "1h")
|
||||||
|
print(f"\n --- {asset} 1h (n={len(df)}) ---")
|
||||||
|
for cm in [0.80, 0.85, 0.90]:
|
||||||
|
ents = template_entries(df, W=W, H=H, corr_min=cm, dir_min=dir_min)
|
||||||
|
res = evaluate(f"corr_min={cm}", ents, df)
|
||||||
|
results[(asset, cm)] = res
|
||||||
|
print(f" ^ robust={'YES' if robust(res) else 'no '}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_sweep():
|
||||||
|
"""Sweep largo (lento per via di DTW). Usa run_in_background."""
|
||||||
|
print("=" * 100)
|
||||||
|
print(" SWEEP LARGO — analog griglia W/H/K/step x metriche + template griglia")
|
||||||
|
print("=" * 100)
|
||||||
|
for W in [16, 20, 28]:
|
||||||
|
for H in [8, 12, 24]:
|
||||||
|
print(f"\n##### W={W} H={H} #####")
|
||||||
|
run_head_to_head(W=W, H=H, K=40, step=6, agree=0.62)
|
||||||
|
for W in [16, 20, 28]:
|
||||||
|
for H in [8, 12, 24]:
|
||||||
|
print(f"\n##### TEMPLATE W={W} H={H} #####")
|
||||||
|
run_templates(W=W, H=H, corr_min=0.85, dir_min=0.10)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
np.random.seed(RNG_SEED)
|
||||||
|
print("#" * 100)
|
||||||
|
print(" SHAPE_TEMPLATE_RESEARCH — distanze di forma alternative + template canonici")
|
||||||
|
print("#" * 100)
|
||||||
|
# 1) check causalita' espliciti
|
||||||
|
print("\n[CAUSALITA']")
|
||||||
|
dfb = get_df("BTC", "1h")
|
||||||
|
check_causality_analog(dfb, dist="euclid", W=20, H=12, K=40, step=6, min_lib=2000)
|
||||||
|
check_causality_analog(dfb, dist="dtw", W=16, H=12, K=40, step=8, min_lib=2000,
|
||||||
|
dtw_band=3, decide_step=20)
|
||||||
|
check_causality_template(dfb, W=20, H=12, corr_min=0.85)
|
||||||
|
# 2) head-to-head metriche
|
||||||
|
print()
|
||||||
|
run_head_to_head()
|
||||||
|
# 3) template
|
||||||
|
print()
|
||||||
|
run_templates()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--sweep" in sys.argv:
|
||||||
|
run_sweep()
|
||||||
|
elif "--templates" in sys.argv:
|
||||||
|
run_templates()
|
||||||
|
elif "--h2h" in sys.argv:
|
||||||
|
run_head_to_head()
|
||||||
|
else:
|
||||||
|
run()
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Analisi di ACCORPAMENTO degli sleeve: le strategie possono essere raggruppate
|
||||||
|
meglio o diversamente rispetto all'attuale "per famiglia"?
|
||||||
|
|
||||||
|
Costruisce le 17 sleeve daily (FADE 6 + HONEST 3 + PAIRS 5 + TSM01 + SHAPE 2),
|
||||||
|
e risponde con evidenza a:
|
||||||
|
1. CORRELAZIONE: matrice completa -> quali sleeve sono ridondanti (corr alta)?
|
||||||
|
2. CLUSTER: clustering gerarchico sulla distanza 1-corr -> i gruppi NATURALI
|
||||||
|
coincidono con le famiglie o no?
|
||||||
|
3. RISCHIO: contributo di ogni sleeve alla volatilita' del portafoglio equal-weight
|
||||||
|
-> chi domina il rischio (e va cappato)?
|
||||||
|
4. PESI: confronto equal-weight vs inverse-vol vs risk-parity (per cluster) su
|
||||||
|
ritorno/DD/Sharpe FULL e OOS.
|
||||||
|
|
||||||
|
Tutto netto fee, leva 3x, finestra comune 2021-2026, OOS = ultimo 30%.
|
||||||
|
Run: uv run python scripts/analysis/sleeve_clustering.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scipy.cluster.hierarchy import linkage, fcluster
|
||||||
|
from scipy.spatial.distance import squareform
|
||||||
|
|
||||||
|
from scripts.analysis.report_families import build_everything
|
||||||
|
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
|
||||||
|
|
||||||
|
|
||||||
|
def daily_matrix(sleeves: dict) -> pd.DataFrame:
|
||||||
|
return pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in sleeves.items()})
|
||||||
|
|
||||||
|
|
||||||
|
def risk_contributions(dr: pd.DataFrame, w: np.ndarray) -> np.ndarray:
|
||||||
|
"""Contributo % di ogni sleeve alla varianza del portafoglio (w'Σ)."""
|
||||||
|
cov = dr.cov().values
|
||||||
|
port_var = float(w @ cov @ w)
|
||||||
|
mrc = cov @ w # marginal risk contribution
|
||||||
|
rc = w * mrc # risk contribution (somma = port_var)
|
||||||
|
return rc / port_var * 100 if port_var > 0 else rc
|
||||||
|
|
||||||
|
|
||||||
|
def inv_vol(dr: pd.DataFrame) -> np.ndarray:
|
||||||
|
v = dr.std().values
|
||||||
|
inv = np.where(v > 0, 1.0 / v, 0.0)
|
||||||
|
return inv / inv.sum()
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_risk_parity(dr: pd.DataFrame, labels: np.ndarray) -> dict:
|
||||||
|
"""Peso: equal fra i CLUSTER, poi inverse-vol DENTRO ogni cluster.
|
||||||
|
Diversifica per gruppo-naturale invece che per sleeve -> non sovrappesa cluster affollati."""
|
||||||
|
cols = list(dr.columns)
|
||||||
|
w = np.zeros(len(cols))
|
||||||
|
clusters = sorted(set(labels))
|
||||||
|
per_cluster = 1.0 / len(clusters)
|
||||||
|
for cl in clusters:
|
||||||
|
idx = [i for i, lb in enumerate(labels) if lb == cl]
|
||||||
|
sub = dr.iloc[:, idx]
|
||||||
|
iv = inv_vol(sub)
|
||||||
|
for j, i in enumerate(idx):
|
||||||
|
w[i] = per_cluster * iv[j]
|
||||||
|
return {cols[i]: w[i] for i in range(len(cols))}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Costruzione 17 sleeve (~2-3 min)...\n")
|
||||||
|
S, pairs, tsm, shape = build_everything()
|
||||||
|
all_sl = {**S, **pairs, **tsm, **shape}
|
||||||
|
dr = daily_matrix(all_sl)
|
||||||
|
cols = list(dr.columns)
|
||||||
|
n = len(cols)
|
||||||
|
|
||||||
|
fam_of = {}
|
||||||
|
for k in cols:
|
||||||
|
if k.startswith("MR"):
|
||||||
|
fam_of[k] = "FADE"
|
||||||
|
elif k.startswith("PR_"):
|
||||||
|
fam_of[k] = "PAIRS"
|
||||||
|
elif k.startswith("SH_"):
|
||||||
|
fam_of[k] = "SHAPE"
|
||||||
|
elif k == "TSM01":
|
||||||
|
fam_of[k] = "TSM"
|
||||||
|
else:
|
||||||
|
fam_of[k] = "HONEST"
|
||||||
|
|
||||||
|
# ---------- 1. correlazione ----------
|
||||||
|
print("=" * 100)
|
||||||
|
print(" (1) MATRICE DI CORRELAZIONE daily fra sleeve")
|
||||||
|
print("=" * 100)
|
||||||
|
corr = dr.corr()
|
||||||
|
short = [c.replace("_", "")[:8] for c in cols]
|
||||||
|
print(" " + "".join(f"{s[:6]:>7s}" for s in short))
|
||||||
|
for i, c in enumerate(cols):
|
||||||
|
print(f" {short[i]:<6s}" + "".join(f"{corr.iloc[i, j]:>7.2f}" for j in range(n)))
|
||||||
|
|
||||||
|
# coppie piu' correlate (candidati all'accorpamento)
|
||||||
|
print("\n Coppie piu' correlate (>0.5 -> ridondanza potenziale):")
|
||||||
|
pairs_corr = []
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
pairs_corr.append((corr.iloc[i, j], cols[i], cols[j]))
|
||||||
|
pairs_corr.sort(reverse=True)
|
||||||
|
for cc, a, b in pairs_corr[:12]:
|
||||||
|
flag = " <-- stessa famiglia" if fam_of[a] == fam_of[b] else " <-- CROSS-famiglia"
|
||||||
|
print(f" {a:<11s} {b:<11s} {cc:+.2f}{flag if cc > 0.5 else ''}")
|
||||||
|
|
||||||
|
# ---------- 2. cluster ----------
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" (2) CLUSTERING GERARCHICO (distanza = 1-corr) — i gruppi naturali")
|
||||||
|
print("=" * 100)
|
||||||
|
dist = 1.0 - corr.values
|
||||||
|
np.fill_diagonal(dist, 0.0)
|
||||||
|
dist = (dist + dist.T) / 2
|
||||||
|
Z = linkage(squareform(dist, checks=False), method="average")
|
||||||
|
for thr in (0.85, 0.95):
|
||||||
|
labels = fcluster(Z, t=thr, criterion="distance")
|
||||||
|
groups: dict[int, list] = {}
|
||||||
|
for c, lb in zip(cols, labels):
|
||||||
|
groups.setdefault(lb, []).append(c)
|
||||||
|
print(f"\n taglio a distanza {thr} (corr>{1-thr:.2f}) -> {len(groups)} cluster:")
|
||||||
|
for lb, members in sorted(groups.items()):
|
||||||
|
fams = {fam_of[m] for m in members}
|
||||||
|
print(f" C{lb}: {', '.join(members)} [{'/'.join(sorted(fams))}]")
|
||||||
|
|
||||||
|
# ---------- 3. rischio ----------
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" (3) CONTRIBUTO AL RISCHIO (equal-weight) — chi domina la volatilita'")
|
||||||
|
print("=" * 100)
|
||||||
|
w_eq = np.ones(n) / n
|
||||||
|
rc = risk_contributions(dr, w_eq)
|
||||||
|
order = np.argsort(rc)[::-1]
|
||||||
|
print(f" {'sleeve':<12s}{'peso%':>7s}{'risk%':>7s} famiglia")
|
||||||
|
for i in order:
|
||||||
|
print(f" {cols[i]:<12s}{w_eq[i]*100:>7.1f}{rc[i]:>7.1f} {fam_of[cols[i]]}")
|
||||||
|
# rischio per famiglia
|
||||||
|
print("\n contributo al rischio per FAMIGLIA (equal-weight sleeve):")
|
||||||
|
fam_rc: dict[str, float] = {}
|
||||||
|
for i, c in enumerate(cols):
|
||||||
|
fam_rc[fam_of[c]] = fam_rc.get(fam_of[c], 0.0) + rc[i]
|
||||||
|
for f, v in sorted(fam_rc.items(), key=lambda x: -x[1]):
|
||||||
|
print(f" {f:<8s} {v:>5.1f}%")
|
||||||
|
|
||||||
|
# ---------- 4. schemi di peso ----------
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
print(" (4) SCHEMI DI PESO a confronto | FULL ret/DD/Sharpe | OOS ret/DD/Sharpe")
|
||||||
|
print("=" * 100)
|
||||||
|
labels95 = fcluster(Z, t=0.95, criterion="distance")
|
||||||
|
|
||||||
|
schemes = {
|
||||||
|
"equal-weight": {c: 1.0 / n for c in cols},
|
||||||
|
"inverse-vol": {cols[i]: inv_vol(dr)[i] for i in range(n)},
|
||||||
|
"cluster-risk-parity": cluster_risk_parity(dr, labels95),
|
||||||
|
}
|
||||||
|
print(f" {'schema':<22s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s} | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 78)
|
||||||
|
for nm, w in schemes.items():
|
||||||
|
dserved = port_returns(all_sl, w)
|
||||||
|
f, o = metrics(dserved), metrics(dserved, lo=SPLIT)
|
||||||
|
print(f" {nm:<22s}{f['ret']:>+9.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f} | "
|
||||||
|
f"{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
|
||||||
|
|
||||||
|
print("\n Lettura: se i cluster naturali != famiglie, conviene pesare per CLUSTER (rischio)")
|
||||||
|
print(" invece che per famiglia. Se inverse-vol/risk-parity battono equal-weight in OOS,")
|
||||||
|
print(" l'accorpamento attuale (equal-weight per sleeve) e' migliorabile.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Smoke reale: un giro di fetch v2 + build worker + un tick del portafoglio attivo.
|
||||||
|
NON apre ordini reali (paper). Verifica data layer v2 + sizing + ledger."""
|
||||||
|
import sys, shutil, tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.portfolio.base import load_active_portfolio
|
||||||
|
from src.portfolio.ledger import PortfolioLedger
|
||||||
|
from src.portfolio.runner import build_worker_for, _worker_equity
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
from src.live.multi_runner import INSTRUMENT_MAP
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
p = load_active_portfolio(PROJECT_ROOT / "portfolios.yml")
|
||||||
|
ledger = PortfolioLedger(p.code, total_capital=p.total_capital, data_dir=tmp)
|
||||||
|
alloc = ledger.allocate({s.sid: 1.0 / len(p.sleeves) for s in p.sleeves})
|
||||||
|
client = CerberoClient()
|
||||||
|
print(f"Portafoglio attivo: {p.code} ({p.label}) — {len(p.sleeves)} sleeve, leva {p.leverage}x")
|
||||||
|
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
|
||||||
|
ok = 0
|
||||||
|
for s in p.sleeves[:3]:
|
||||||
|
asset = s.asset or s.a
|
||||||
|
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
|
||||||
|
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
|
||||||
|
end.strftime("%Y-%m-%d"), s.tf)
|
||||||
|
print(f" {s.sid:<12s} {inst:<18s} candele={len(candles)}")
|
||||||
|
ok += len(candles) > 0
|
||||||
|
print(f"OK: {ok}/3 sleeve con feed v2 fresco. Ledger equity iniziale={ledger.equity}")
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Demo numerica: il worker fade col NUOVO exit intrabar riproduce il backtest intrabar?
|
||||||
|
|
||||||
|
Replay bar-by-bar dello StrategyWorker (MR01 Bollinger fade) su una finestra storica e
|
||||||
|
confronto del rendimento col backtest di riferimento build_trades (che esce intrabar su
|
||||||
|
high/low al livello). Filtro trend disattivato in entrambi per isolare l'effetto-exit.
|
||||||
|
|
||||||
|
Atteso: dopo il fix (worker esce su high/low al livello, SL prioritario, come build_trades)
|
||||||
|
il rendimento del worker ≈ backtest. Prima del fix (exit solo sul close) divergeva.
|
||||||
|
|
||||||
|
Run: uv run python scripts/analysis/validate_fade_intrabar.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile, shutil
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from src.live.strategy_worker import StrategyWorker
|
||||||
|
from src.live.strategy_loader import load_strategy
|
||||||
|
from scripts.analysis.risk_management import bollinger_fade, build_trades
|
||||||
|
|
||||||
|
CORE = dict(n=50, k=2.5, sl_atr=2.0, max_bars=24) # MR01, niente filtro trend
|
||||||
|
POS = 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def backtest_return(df) -> tuple[float, int]:
|
||||||
|
ents = bollinger_fade(df, **CORE)
|
||||||
|
trades = build_trades(ents, df, trend_max=None) # intrabar, no trend filter
|
||||||
|
cap = 1000.0
|
||||||
|
for _, _, ret in trades:
|
||||||
|
cap = max(cap + cap * POS * ret, 10.0)
|
||||||
|
return (cap / 1000 - 1) * 100, len(trades)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_replay_return(df) -> tuple[float, int]:
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
try:
|
||||||
|
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
|
||||||
|
capital=1000.0, params=dict(CORE), data_dir=tmp)
|
||||||
|
# niente I/O per tick (replay veloce)
|
||||||
|
w._save_state = lambda *a, **k: None
|
||||||
|
w._log = lambda *a, **k: None
|
||||||
|
w._notify = lambda *a, **k: None
|
||||||
|
n = len(df)
|
||||||
|
for i in range(101, n):
|
||||||
|
w.tick(df.iloc[: i + 1])
|
||||||
|
return (w.capital / 1000 - 1) * 100, w.total_trades
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
df = load_data("BTC", "1h").iloc[-4000:].reset_index(drop=True)
|
||||||
|
print("=" * 84)
|
||||||
|
print(" DEMO exit intrabar — worker fade MR01 (replay) vs backtest intrabar | BTC 1h, 4000 barre")
|
||||||
|
print("=" * 84)
|
||||||
|
bt_ret, bt_n = backtest_return(df)
|
||||||
|
wk_ret, wk_n = worker_replay_return(df)
|
||||||
|
gap = wk_ret - bt_ret
|
||||||
|
print(f" backtest build_trades : {bt_ret:+.1f}% ({bt_n} trade)")
|
||||||
|
print(f" worker replay (intrabar): {wk_ret:+.1f}% ({wk_n} trade)")
|
||||||
|
print(f" gap = {gap:+.1f} punti % -> {'OK (allineato)' if abs(gap) < max(abs(bt_ret) * 0.10, 3) else 'DIVERGE'}")
|
||||||
|
print("\n Col vecchio exit close-only il worker divergeva (usciva tardi/altrove);")
|
||||||
|
print(" ora esce su high/low al livello come il backtest -> gap ridotto al bar-timing residuo.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Validazione dei worker live multi-asset (TR01/ROT02/TSM01): il replay bar-by-bar del
|
||||||
|
worker riproduce la funzione di backtest di riferimento?
|
||||||
|
|
||||||
|
Replay onesto: si alimenta il worker con finestre crescenti dei dati storici (stesso
|
||||||
|
universo e stessa config della reference) e si confronta il rendimento finale con la
|
||||||
|
funzione di riferimento. Non si pretende parità al centesimo (differenze attese da
|
||||||
|
bar-timing e dalla convenzione capitale-singolo vs media-di-equity), ma il tracking
|
||||||
|
deve essere stretto e dello stesso segno/ordine di grandezza.
|
||||||
|
|
||||||
|
Riferimenti:
|
||||||
|
TR01 -> honest_improve2._tr_basket_daily
|
||||||
|
ROT02 -> honest_improve2._rot_daily_equity
|
||||||
|
TSM01 -> tsmom_research.tsmom_sim
|
||||||
|
|
||||||
|
Run: uv run python scripts/analysis/validate_honest_workers.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.analysis.explore_lab import get_df
|
||||||
|
from scripts.analysis.honest_lab import available_assets
|
||||||
|
from src.live.basket_trend_worker import BasketTrendWorker
|
||||||
|
from src.live.rotation_worker import RotationWorker
|
||||||
|
from src.live.tsmom_worker import TsmomWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _aligned_panel(assets, tf):
|
||||||
|
"""{asset: df get_df} -> DataFrame allineato sui timestamp comuni (timestamp + close per asset)."""
|
||||||
|
frames = {}
|
||||||
|
for a in assets:
|
||||||
|
try:
|
||||||
|
d = get_df(a, tf)[["timestamp", "close"]].rename(columns={"close": a})
|
||||||
|
frames[a] = d
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
panel = None
|
||||||
|
for a, f in frames.items():
|
||||||
|
panel = f if panel is None else panel.merge(f, on="timestamp", how="inner")
|
||||||
|
return panel.sort_values("timestamp").reset_index(drop=True), list(frames)
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_df(panel, a):
|
||||||
|
"""df OHLCV minimale (close = open = ...) per un asset dal panel allineato."""
|
||||||
|
c = panel[a].values
|
||||||
|
return pd.DataFrame({"timestamp": panel["timestamp"].values,
|
||||||
|
"open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def replay(worker, panel, cols, start):
|
||||||
|
"""Replay bar-by-bar: a ogni step feed delle finestre crescenti. Ritorna ret% finale."""
|
||||||
|
n = len(panel)
|
||||||
|
for i in range(start, n):
|
||||||
|
sub = panel.iloc[: i + 1]
|
||||||
|
data = {a: _asset_df(sub, a) for a in cols}
|
||||||
|
worker.tick(data)
|
||||||
|
return (worker.capital / worker.initial_capital - 1) * 100
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import tempfile, shutil
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
print("=" * 92)
|
||||||
|
print(" VALIDAZIONE worker live multi-asset (replay vs backtest di riferimento)")
|
||||||
|
print("=" * 92)
|
||||||
|
try:
|
||||||
|
# ---- ROT02 ----
|
||||||
|
from scripts.analysis.honest_improve2 import _rot_daily_equity
|
||||||
|
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
|
||||||
|
ref_rot = (_rot_daily_equity(idx).iloc[-1] - 1) * 100
|
||||||
|
uni = available_assets()
|
||||||
|
panel, cols = _aligned_panel(uni, "1d")
|
||||||
|
wr = RotationWorker(universe=cols, top_k=3, gross=0.45, tf="1d",
|
||||||
|
capital=1000.0, data_dir=tmp)
|
||||||
|
rot = replay(wr, panel, cols, start=101)
|
||||||
|
print(f" ROT02 worker={rot:+.0f}% reference={ref_rot:+.0f}% "
|
||||||
|
f"univ={len(cols)} barre={len(panel)}")
|
||||||
|
|
||||||
|
# ---- TSM01 ----
|
||||||
|
from scripts.analysis.tsmom_research import tsmom_sim
|
||||||
|
ref_tsm = tsmom_sim()["ret"]
|
||||||
|
wt = TsmomWorker(universe=cols, horizons=(63, 126, 252), thr=1.0, gross=0.30,
|
||||||
|
tf="1d", capital=1000.0, data_dir=tmp)
|
||||||
|
tsm = replay(wt, panel, cols, start=253)
|
||||||
|
print(f" TSM01 worker={tsm:+.0f}% reference={ref_tsm:+.0f}%")
|
||||||
|
|
||||||
|
# ---- TR01 ----
|
||||||
|
from scripts.analysis.honest_improve2 import _tr_basket_daily
|
||||||
|
tr_assets = ["BNB", "BTC", "DOGE", "SOL", "XRP"]
|
||||||
|
ref_tr = (_tr_basket_daily(tr_assets, idx).iloc[-1] - 1) * 100
|
||||||
|
panel4, cols4 = _aligned_panel(tr_assets, "4h")
|
||||||
|
wb = BasketTrendWorker(universe=cols4, tf="4h", capital=1000.0, data_dir=tmp)
|
||||||
|
tr = replay(wb, panel4, cols4, start=101)
|
||||||
|
print(f" TR01 worker={tr:+.0f}% reference={ref_tr:+.0f}% "
|
||||||
|
f"univ={len(cols4)} barre={len(panel4)}")
|
||||||
|
|
||||||
|
print("\n NB: il worker tiene UN capitale unico (compounding del paniere), la reference")
|
||||||
|
print(" media equity normalizzate per-asset -> differenza di convenzione attesa, non un bug.")
|
||||||
|
print(" Validazione = stesso segno e ordine di grandezza, tracking ragionevole.")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Validazione del PortfolioRunner: il modello capitale-POOL + ribilancio giornaliero +
|
||||||
|
ledger aggregato si comporta come il backtest (Portfolio.backtest)?
|
||||||
|
|
||||||
|
Il runner aggiunge UN livello sopra i worker già validati: pooling del capitale, sizing
|
||||||
|
per peso, ribilancio giornaliero, aggregazione nel ledger. Questo script valida QUEL
|
||||||
|
livello in modo deterministico ed esatto, separando le due fonti di (eventuale) divergenza:
|
||||||
|
|
||||||
|
(1) AGGREGAZIONE pool+ribilancio == port_returns (la matematica del backtest).
|
||||||
|
Replay giornaliero: total_capital=1000; ogni giorno alloca alloc_i = peso_i*total
|
||||||
|
(ribilancio), ogni sleeve rende r_i sulla sua quota, total_next = Σ alloc_i*(1+r_i).
|
||||||
|
Questo è esattamente il daily-rebalance pesato di port_returns -> deve coincidere
|
||||||
|
al centesimo. Validato anche attraverso il PortfolioLedger reale (allocate/update/DD).
|
||||||
|
|
||||||
|
(2) FEDELTÀ per-worker (live tick vs backtest dello sleeve): NON è compito di questo
|
||||||
|
script (è il livello sotto). Stato noto:
|
||||||
|
- PAIRS : esatto (scripts/analysis/validate_worker_pairs.py: replay==backtest).
|
||||||
|
- FADE : APPROSSIMATO. Il backtest fade è intrabar (TP/SL su high/low della barra),
|
||||||
|
il live StrategyWorker controlla solo il close corrente -> gap live-vs-
|
||||||
|
backtest strutturale (non un bug del runner). Quantificato qui sotto su
|
||||||
|
una finestra recente per un singolo sleeve, come ordine di grandezza.
|
||||||
|
- SHAPE : walk-forward (SH01), exit a tempo: il tick close-based coincide col
|
||||||
|
backtest a tempo (no intrabar TP/SL) a meno del bar-timing.
|
||||||
|
|
||||||
|
Run: uv run python scripts/analysis/validate_portfolio_runner.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
from src.portfolio.ledger import PortfolioLedger
|
||||||
|
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
|
||||||
|
LIVE_NAMES = ("MR01", "MR02", "MR07", "SH01")
|
||||||
|
|
||||||
|
|
||||||
|
def live_ids(p) -> list[str]:
|
||||||
|
return [s.sid for s in p.sleeves if s.kind == "pairs" or s.name in LIVE_NAMES]
|
||||||
|
|
||||||
|
|
||||||
|
def replay_pool_ledger(ids: list[str], weights: dict[str, float], tmp: Path) -> pd.Series:
|
||||||
|
"""Replay giornaliero del modello del runner attraverso il PortfolioLedger REALE:
|
||||||
|
ogni giorno ribilancia (alloc=peso*total), applica il rendimento giornaliero di ogni
|
||||||
|
sleeve, aggrega. Ritorna la serie di equity totale (indicizzata per data)."""
|
||||||
|
eq = all_sleeve_equities()
|
||||||
|
rets = pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
|
||||||
|
ledger = PortfolioLedger("VALIDATE", total_capital=1000.0, data_dir=tmp)
|
||||||
|
sleeve_cap = {i: weights[i] * ledger.total_capital for i in ids}
|
||||||
|
out = []
|
||||||
|
for day, row in rets.iterrows():
|
||||||
|
# ribilancio giornaliero: rialloca al peso target sul capitale totale corrente
|
||||||
|
ledger.total_capital = sum(sleeve_cap.values())
|
||||||
|
alloc = ledger.allocate(weights)
|
||||||
|
sleeve_cap = {i: alloc[i] for i in ids}
|
||||||
|
# applica il rendimento del giorno a ogni sleeve
|
||||||
|
sleeve_cap = {i: sleeve_cap[i] * (1.0 + row[i]) for i in ids}
|
||||||
|
ledger.update_equity(sleeve_cap)
|
||||||
|
out.append((day, ledger.equity))
|
||||||
|
return pd.Series([v for _, v in out], index=[d for d, _ in out])
|
||||||
|
|
||||||
|
|
||||||
|
def check_aggregation(p):
|
||||||
|
ids = live_ids(p)
|
||||||
|
dr = sleeve_returns_df(ids)
|
||||||
|
weights = W.weight_vector(p.weighting, ids, dr, weights=p.weights, caps=p.caps,
|
||||||
|
clusters={s.sid: (s.cluster or s.sid) for s in p.sleeves}, lookback=p.vol_lookback)
|
||||||
|
# riferimento: la matematica del backtest (daily-rebalance pesato)
|
||||||
|
eq = all_sleeve_equities()
|
||||||
|
members = {i: eq[i] for i in ids}
|
||||||
|
ref_dr = port_returns(members, weights)
|
||||||
|
ref_equity = 1000.0 * (1.0 + ref_dr).cumprod()
|
||||||
|
|
||||||
|
import tempfile, shutil
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
try:
|
||||||
|
run_equity = replay_pool_ledger(ids, weights, tmp)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
# allinea (replay parte dal 2o giorno per via del pct_change iniziale a 0)
|
||||||
|
a, b = ref_equity.align(run_equity, join="inner")
|
||||||
|
rel_err = float((a - b).abs().max() / a.abs().max())
|
||||||
|
end_ref, end_run = float(a.iloc[-1]), float(b.iloc[-1])
|
||||||
|
print(" [1] AGGREGAZIONE pool+ribilancio (ledger reale) vs port_returns backtest:")
|
||||||
|
print(f" equity finale backtest={end_ref:,.2f} runner-replay={end_run:,.2f}")
|
||||||
|
# 1e-6 = identici a fini pratici (il residuo è accumulo floating-point su ~2000 giorni)
|
||||||
|
print(f" errore relativo max sulla curva = {rel_err:.2e} -> {'OK (identici)' if rel_err < 1e-6 else 'DIVERGE'}")
|
||||||
|
return rel_err < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def check_fade_fidelity_magnitude(p):
|
||||||
|
"""Ordine di grandezza del gap fade live(close) vs backtest(intrabar) su finestra recente.
|
||||||
|
NON è una parità (gap strutturale noto): solo per quantificarlo onestamente."""
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from scripts.analysis.risk_management import strats_for, build_trades, INIT
|
||||||
|
asset = "BTC"
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
df = df.iloc[-24 * 365:].reset_index(drop=True) # ~ultimo anno
|
||||||
|
fn, params = strats_for(asset)["MR01"]
|
||||||
|
trades = build_trades(fn(df, **params), df, trend_max=3.0)
|
||||||
|
bt_ret = 0.0
|
||||||
|
cap = INIT
|
||||||
|
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||||
|
cap = max(cap + cap * 0.15 * ret, 10.0)
|
||||||
|
bt_ret = (cap / INIT - 1) * 100
|
||||||
|
print(" [2] FEDELTÀ per-worker (gap noto, NON compito del runner):")
|
||||||
|
print(f" PAIRS : esatto (validate_worker_pairs.py)")
|
||||||
|
print(f" FADE : backtest intrabar MR01 {asset} ultimo anno = {bt_ret:+.1f}% "
|
||||||
|
f"(il live close-based diverge: vedi nota nel docstring)")
|
||||||
|
print(f" SHAPE : exit a tempo -> tick close coincide col backtest a meno del bar-timing")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
print("=" * 92)
|
||||||
|
print(" VALIDAZIONE PortfolioRunner — PORT06 (sleeve LIVE: fade+pairs+shape)")
|
||||||
|
print("=" * 92)
|
||||||
|
ok = check_aggregation(p)
|
||||||
|
print()
|
||||||
|
check_fade_fidelity_magnitude(p)
|
||||||
|
print()
|
||||||
|
print(" VERDETTO:")
|
||||||
|
print(f" livello POOL+RIBILANCIO+LEDGER del runner == backtest: {'CERTIFICATO' if ok else 'DA RIVEDERE'}")
|
||||||
|
print(" fedeltà per-worker: pairs esatta; fade approssimata (gap intrabar noto); shape a tempo ok")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Valida il PairsWorker: replay bar-per-bar sui dati storici == backtest pairs_sim?
|
||||||
|
|
||||||
|
Come validate_worker_mr01 per MR01: alimenta il PairsWorker con finestre trailing
|
||||||
|
crescenti (simula il feed live) e confronta trade/capitale finale col backtest di
|
||||||
|
riferimento scripts/analysis/pairs_research.pairs_sim. Se combaciano, la semantica
|
||||||
|
live (z-score causale, exit |z|<=z_exit o max_bars, fee 2 gambe) e' fedele.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.live.pairs_worker import PairsWorker
|
||||||
|
from scripts.analysis.pairs_research import aligned, pairs_sim
|
||||||
|
from scripts.strategies.PR01_pairs_reversion import PAIRS
|
||||||
|
|
||||||
|
WINDOW = 60 # finestra trailing minima (>= n+2): z[i] corretto, replay veloce
|
||||||
|
|
||||||
|
|
||||||
|
def replay(a: str, b: str, params: dict, data_dir: Path) -> PairsWorker:
|
||||||
|
m = aligned(a, b)
|
||||||
|
df_a = m[["timestamp"]].copy(); df_a["close"] = m["close_a"].values
|
||||||
|
df_b = m[["timestamp"]].copy(); df_b["close"] = m["close_b"].values
|
||||||
|
w = PairsWorker(a, b, "1h", params=params, fee_rt=0.001, data_dir=data_dir)
|
||||||
|
# replay veloce: niente I/O su file / log / notifiche ad ogni tick (servono solo le metriche finali)
|
||||||
|
w._save_state = lambda: None
|
||||||
|
w._log = lambda *a, **k: None
|
||||||
|
w._notify = lambda *a, **k: None
|
||||||
|
n = w.n
|
||||||
|
for k in range(n + 2, len(m) + 1):
|
||||||
|
lo = max(0, k - WINDOW)
|
||||||
|
w.tick(df_a.iloc[lo:k], df_b.iloc[lo:k])
|
||||||
|
# chiudi eventuale posizione aperta a fine serie (come fa il backtest col troncamento)
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 96)
|
||||||
|
print(" VALIDAZIONE PairsWorker — replay live vs backtest pairs_sim (fee 0.20% RT/coppia)")
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" {'coppia':<10s}{'WORKER cap':>12s}{'trd':>5s}{'win%':>6s} | {'BACKTEST cap':>13s}{'trd':>5s}{'win%':>6s} match?")
|
||||||
|
print(" " + "-" * 88)
|
||||||
|
# Sottoinsieme rappresentativo: il codice del worker e' identico per ogni coppia,
|
||||||
|
# quindi 2 coppie con strutture diverse (alt/major e major/alt) bastano a provare
|
||||||
|
# l'equivalenza. ~135s/coppia su 73k barre orarie. Per validarle tutte: usa PAIRS.
|
||||||
|
subset = [pp for pp in PAIRS if (pp[0], pp[1]) in {("ETH", "BTC"), ("BTC", "LTC")}]
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="pairs_validate_"))
|
||||||
|
try:
|
||||||
|
for a, b, p in subset:
|
||||||
|
w = replay(a, b, p, tmp)
|
||||||
|
bt = pairs_sim(a, b, **p)
|
||||||
|
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
|
||||||
|
cap_match = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
|
||||||
|
trd_match = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
|
||||||
|
ok = "OK" if (cap_match and trd_match) else "DIFF"
|
||||||
|
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
|
||||||
|
print(f" {a+'/'+b:<10s}{w.capital:>12.0f}{w.total_trades:>5d}{ww:>6.1f} | "
|
||||||
|
f"{bt_cap:>13.0f}{bt['trades']:>5d}{bt['win']:>6.1f} {ok}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
print(" " + "-" * 88)
|
||||||
|
print(" match = capitale entro 2% e trade entro 2% del backtest. Differenze minime sono")
|
||||||
|
print(" attese (gestione bar finale/troncamento), ma la semantica deve coincidere.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Incrementa la versione (semver) nel file VERSION. Default: patch +1.
|
||||||
|
Uso: uv run python scripts/bump_version.py [major|minor|patch] (default patch)
|
||||||
|
Stampa la nuova versione. Usato da scripts/deploy.sh ad ogni deploy."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VF = Path(__file__).resolve().parents[1] / "VERSION"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
part = sys.argv[1] if len(sys.argv) > 1 else "patch"
|
||||||
|
cur = VF.read_text().strip() if VF.exists() else "0.0.0"
|
||||||
|
try:
|
||||||
|
major, minor, patch = (int(x) for x in cur.split("."))
|
||||||
|
except Exception:
|
||||||
|
major, minor, patch = 0, 0, 0
|
||||||
|
if part == "major":
|
||||||
|
major, minor, patch = major + 1, 0, 0
|
||||||
|
elif part == "minor":
|
||||||
|
minor, patch = minor + 1, 0
|
||||||
|
else:
|
||||||
|
patch += 1
|
||||||
|
new = f"{major}.{minor}.{patch}"
|
||||||
|
VF.write_text(new + "\n")
|
||||||
|
print(new)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy del paper trader a portafoglio: bumpa la VERSIONE, committa, rebuilda l'immagine e
|
||||||
|
# ricrea il container. La versione (es. v1.0.1) compare nei messaggi Telegram -> sai quale
|
||||||
|
# codice ha generato quale msg, e aumenta ad OGNI deploy.
|
||||||
|
#
|
||||||
|
# Uso: ./scripts/deploy.sh [major|minor|patch] (default patch)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PART="${1:-patch}"
|
||||||
|
NEW=$(uv run python scripts/bump_version.py "$PART")
|
||||||
|
echo ">> nuova versione: v$NEW"
|
||||||
|
|
||||||
|
git add VERSION
|
||||||
|
git commit -q -m "release: v$NEW" || true
|
||||||
|
|
||||||
|
echo ">> rebuild immagine + ricrea container"
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
sleep 8
|
||||||
|
docker compose ps --format "{{.Status}}"
|
||||||
|
echo ">> deploy v$NEW completato"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""PORT01 — Honest (default). Report backtest del portafoglio."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
|
||||||
|
|
||||||
|
CODE = "PORT01"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
p = PORTFOLIOS[CODE]
|
||||||
|
r = p.backtest()
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" {p.code} — {p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
|
||||||
|
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
|
||||||
|
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
|
||||||
|
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
|
||||||
|
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
|
||||||
|
sorted(r.risk.items(), key=lambda x: -x[1])})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""PORT02 — Fade master (default). Report backtest del portafoglio."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
|
||||||
|
|
||||||
|
CODE = "PORT02"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
p = PORTFOLIOS[CODE]
|
||||||
|
r = p.backtest()
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" {p.code} — {p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
|
||||||
|
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
|
||||||
|
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
|
||||||
|
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
|
||||||
|
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
|
||||||
|
sorted(r.risk.items(), key=lambda x: -x[1])})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""PORT03 — Master (default). Report backtest del portafoglio."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
|
||||||
|
|
||||||
|
CODE = "PORT03"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
p = PORTFOLIOS[CODE]
|
||||||
|
r = p.backtest()
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" {p.code} — {p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
|
||||||
|
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
|
||||||
|
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
|
||||||
|
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
|
||||||
|
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
|
||||||
|
sorted(r.risk.items(), key=lambda x: -x[1])})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""PORT04 — Master + pairs (default). Report backtest del portafoglio."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
|
||||||
|
|
||||||
|
CODE = "PORT04"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
p = PORTFOLIOS[CODE]
|
||||||
|
r = p.backtest()
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" {p.code} — {p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
|
||||||
|
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
|
||||||
|
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
|
||||||
|
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
|
||||||
|
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
|
||||||
|
sorted(r.risk.items(), key=lambda x: -x[1])})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""PORT05 — Master esteso (default). Report backtest del portafoglio."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
|
||||||
|
|
||||||
|
CODE = "PORT05"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
p = PORTFOLIOS[CODE]
|
||||||
|
r = p.backtest()
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" {p.code} — {p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
|
||||||
|
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
|
||||||
|
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
|
||||||
|
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
|
||||||
|
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
|
||||||
|
sorted(r.risk.items(), key=lambda x: -x[1])})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""PORT06 — Master + shape (default). Report backtest del portafoglio."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
|
||||||
|
|
||||||
|
CODE = "PORT06"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
p = PORTFOLIOS[CODE]
|
||||||
|
r = p.backtest()
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" {p.code} — {p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
|
||||||
|
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
|
||||||
|
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
|
||||||
|
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
|
||||||
|
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
|
||||||
|
sorted(r.risk.items(), key=lambda x: -x[1])})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Definizioni canoniche dei portafogli (tutti i tipi visti finora)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.portfolio.base import Portfolio, SleeveSpec # noqa: E402
|
||||||
|
|
||||||
|
# Universo live tradabile (8 asset con feed Cerbero v2 + parquet). ROT02/TSM01 ci ruotano sopra.
|
||||||
|
UNIVERSE8 = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
|
||||||
|
|
||||||
|
# Edge minimo (solo live): salta i fade/dip il cui TP cade entro 1.5x il costo round-trip
|
||||||
|
# (perdenti garantiti in regime piatto). Neutro sul backtest storico (0 trade rimossi su
|
||||||
|
# MR01, +leggero su DIP01), protettivo dal vivo. Solo MR01/DIP01 leggono il param;
|
||||||
|
# MR02/MR07 lo ignorano (**params). Vedi docs/diary/2026-06-01-tp-min-edge.md.
|
||||||
|
MIN_TP_FRAC = 0.0015
|
||||||
|
|
||||||
|
# Loss-guard Hurst (live): salta le fade in regime PERSISTENTE/trending (rolling-Hurst >= 0.55),
|
||||||
|
# dove si concentrano stop-loss e perdite (stop-rate 43% vs 21% anti-persistente). DIMEZZA il DD
|
||||||
|
# del PORT06 (FULL 4.10%->2.39%) alzando lo Sharpe. Calcolato dalle SOLE close (no feed esterno).
|
||||||
|
# Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md.
|
||||||
|
HURST_MAX = 0.55
|
||||||
|
|
||||||
|
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev",
|
||||||
|
params={"min_tp_frac": MIN_TP_FRAC, "hurst_max": HURST_MAX})
|
||||||
|
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
|
||||||
|
HONEST = [
|
||||||
|
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
|
||||||
|
SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev",
|
||||||
|
params={"min_tp_frac": MIN_TP_FRAC}),
|
||||||
|
SleeveSpec(kind="basket", name="TR01", sid="TR01_basket", cluster="trend",
|
||||||
|
params={"universe": ["BNB", "BTC", "DOGE", "SOL", "XRP"], "tf": "4h"}),
|
||||||
|
SleeveSpec(kind="rotation", name="ROT02", sid="ROT02_rot", cluster="rotation",
|
||||||
|
params={"universe": UNIVERSE8, "tf": "1d", "top_k": 3, "gross": 0.45}),
|
||||||
|
]
|
||||||
|
PAIRS = [
|
||||||
|
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC", cluster="ETH-rev"),
|
||||||
|
SleeveSpec(kind="pairs", name="PR01", sid="PR_LTCETH", a="LTC", b="ETH", cluster="ETH-rev"),
|
||||||
|
SleeveSpec(kind="pairs", name="PR01", sid="PR_ADAETH", a="ADA", b="ETH", cluster="ETH-rev"),
|
||||||
|
SleeveSpec(kind="pairs", name="PR01", sid="PR_BTCLTC", a="BTC", b="LTC", cluster="BTC-rev"),
|
||||||
|
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHSOL", a="ETH", b="SOL", cluster="ETH-rev"),
|
||||||
|
]
|
||||||
|
TSM = [SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01", cluster="trend",
|
||||||
|
params={"universe": UNIVERSE8, "tf": "1d",
|
||||||
|
"horizons": [63, 126, 252], "thr": 1.0, "gross": 0.30})]
|
||||||
|
SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape")
|
||||||
|
for a in ("BTC", "ETH")]
|
||||||
|
|
||||||
|
PORTFOLIOS = {
|
||||||
|
"PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"),
|
||||||
|
"PORT02": Portfolio("PORT02", "Fade master", FADE, weighting="equal"),
|
||||||
|
"PORT03": Portfolio("PORT03", "Master", FADE + HONEST, weighting="equal"),
|
||||||
|
"PORT04": Portfolio("PORT04", "Master + pairs", FADE + HONEST + PAIRS,
|
||||||
|
weighting="cap", caps={"PAIRS": 0.33}),
|
||||||
|
"PORT05": Portfolio("PORT05", "Master esteso", FADE + HONEST + PAIRS + TSM,
|
||||||
|
weighting="cap", caps={"PAIRS": 0.33}),
|
||||||
|
"PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE,
|
||||||
|
weighting="cap", caps={"PAIRS": 0.33}, leverage=2.0),
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Confronto di tutti i portafogli PORT01-06 (backtest in un solo processo).
|
||||||
|
|
||||||
|
all_sleeve_equities() è cache-ata: la build (fade+honest+pairs+tsm+shape) avviene una
|
||||||
|
volta sola, poi i 6 backtest la riusano. Stampa una tabella FULL/OOS e i pesi/rischio.
|
||||||
|
Run: uv run python scripts/portfolios/compare_all.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
print("=" * 104)
|
||||||
|
print(" CONFRONTO PORTAFOGLI PORT01-06 | netto fee, finestra comune 2021-2026, OOS = ultimo 30%")
|
||||||
|
print("=" * 104)
|
||||||
|
print(f" {'code':<7s}{'label':<16s}{'n':>3s}{'pesi':>9s}"
|
||||||
|
f"{'FULLret':>9s}{'CAGR':>6s}{'DD':>6s}{'Shrp':>6s} |"
|
||||||
|
f"{'OOSret':>8s}{'oDD':>6s}{'oShrp':>7s}")
|
||||||
|
print(" " + "-" * 100)
|
||||||
|
rows = []
|
||||||
|
for code in ("PORT01", "PORT02", "PORT03", "PORT04", "PORT05", "PORT06"):
|
||||||
|
p = PORTFOLIOS[code]
|
||||||
|
r = p.backtest()
|
||||||
|
cap = f"{p.weighting}" + (f"{int(p.caps['PAIRS']*100)}" if p.caps else "")
|
||||||
|
print(f" {p.code:<7s}{p.label:<16s}{len(p.sleeves):>3d}{cap:>9s}"
|
||||||
|
f"{r.full['ret']:>+9.0f}{r.full['cagr']:>6.0f}{r.full['dd']:>6.1f}{r.full['sharpe']:>6.2f} |"
|
||||||
|
f"{r.oos['ret']:>+8.0f}{r.oos['dd']:>6.1f}{r.oos['sharpe']:>7.2f}")
|
||||||
|
rows.append((code, r))
|
||||||
|
|
||||||
|
# miglior per Sharpe OOS e per DD OOS
|
||||||
|
best_sharpe = max(rows, key=lambda x: x[1].oos["sharpe"])
|
||||||
|
best_dd = min(rows, key=lambda x: x[1].oos["dd"])
|
||||||
|
print(" " + "-" * 100)
|
||||||
|
print(f" miglior Sharpe OOS: {best_sharpe[0]} ({best_sharpe[1].oos['sharpe']:.2f}) "
|
||||||
|
f"miglior DD OOS: {best_dd[0]} ({best_dd[1].oos['dd']:.1f}%)")
|
||||||
|
print("\n PORT06 (default) — top contributi al rischio:")
|
||||||
|
r6 = dict(rows)["PORT06"]
|
||||||
|
top = sorted(r6.risk.items(), key=lambda x: -x[1])[:6]
|
||||||
|
print(" " + " ".join(f"{k}={v:.0f}%" for k, v in top))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Report orario PORT06 -> Telegram.
|
||||||
|
|
||||||
|
Legge lo stato persistito del paper trader a portafoglio (data/portfolio_paper/*/ +
|
||||||
|
data/portfolios/PORT06/status.json) e invia su Telegram:
|
||||||
|
1) trade CHIUSI: positivi/negativi (netto fee) con breakdown per motivo e PnL;
|
||||||
|
2) trade IN CORSO (posizioni aperte);
|
||||||
|
3) PnL realizzato totale + equity mark-to-market.
|
||||||
|
|
||||||
|
Eseguibile standalone (es. da cron orario):
|
||||||
|
cd /opt/docker/PythagorasGoal && uv run python scripts/portfolios/hourly_report.py
|
||||||
|
|
||||||
|
Carica .env da solo (cron non eredita l'env del container). Legge file world-readable
|
||||||
|
scritti dal container; non tocca lo stato del trader.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import glob
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PAPER = ROOT / "data" / "portfolio_paper"
|
||||||
|
PORT_STATUS = ROOT / "data" / "portfolios" / "PORT06" / "status.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env():
|
||||||
|
"""Carica TELEGRAM_* da .env nell'os.environ (cron non li ha)."""
|
||||||
|
import os
|
||||||
|
envf = ROOT / ".env"
|
||||||
|
if not envf.exists():
|
||||||
|
return
|
||||||
|
for line in envf.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
os.environ.setdefault(k.strip(), v.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _short(wid: str) -> str:
|
||||||
|
"""SH01_shape_ml__BTC__1h -> SH01/BTC ; PR01_..._ETH_SOL__1h -> PR01/ETH_SOL."""
|
||||||
|
parts = wid.split("__")
|
||||||
|
code = parts[0].split("_")[0]
|
||||||
|
tag = parts[1] if len(parts) > 1 else ""
|
||||||
|
return f"{code}/{tag}" if tag else code
|
||||||
|
|
||||||
|
|
||||||
|
# --- monitor loss-guard Hurst: stop-rate fade PRIMA/DOPO l'attivazione (hurst_max=0.55, v1.0.0) ---
|
||||||
|
LOSSGUARD_SINCE = "2026-06-02T14:34:30"
|
||||||
|
FADE_PREFIXES = ("MR01", "MR02", "MR07") # le 3 fade con hurst_max attivo
|
||||||
|
LOSSGUARD_MIN_SAMPLE = 30
|
||||||
|
|
||||||
|
|
||||||
|
def lossguard_section() -> str:
|
||||||
|
before = [0, 0] # [closes, stops] prima dell'attivazione
|
||||||
|
after = [0, 0] # dopo
|
||||||
|
for sp in glob.glob(str(PAPER / "*" / "status.json")):
|
||||||
|
wid = Path(sp).parent.name
|
||||||
|
if not wid.startswith(FADE_PREFIXES):
|
||||||
|
continue
|
||||||
|
tp = Path(sp).parent / "trades.jsonl"
|
||||||
|
if not tp.exists():
|
||||||
|
continue
|
||||||
|
for line in tp.read_text().splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
ev = json.loads(line)
|
||||||
|
if ev.get("event") != "CLOSE":
|
||||||
|
continue
|
||||||
|
b = after if ev.get("ts", "") >= LOSSGUARD_SINCE else before
|
||||||
|
b[0] += 1
|
||||||
|
if ev.get("reason") == "stop_loss":
|
||||||
|
b[1] += 1
|
||||||
|
|
||||||
|
def rate(b):
|
||||||
|
return b[1] / b[0] * 100 if b[0] else 0.0
|
||||||
|
|
||||||
|
L = [f"🛡️ <b>Loss-guard Hurst</b> (fade, dal {LOSSGUARD_SINCE[:16].replace('T', ' ')} UTC)"]
|
||||||
|
L.append(f" stop-rate PRIMA {rate(before):.0f}% (n={before[0]}) → DOPO {rate(after):.0f}% (n={after[0]})")
|
||||||
|
if after[0] >= LOSSGUARD_MIN_SAMPLE:
|
||||||
|
delta = rate(before) - rate(after)
|
||||||
|
L.append(f" VERDETTO (n≥{LOSSGUARD_MIN_SAMPLE}): {delta:+.0f}pp → "
|
||||||
|
f"{'✅ riduce gli stop' if delta > 0 else '⚠️ nessuna riduzione'}")
|
||||||
|
else:
|
||||||
|
L.append(f" campione DOPO {after[0]}/{LOSSGUARD_MIN_SAMPLE} → verdetto rimandato")
|
||||||
|
return "\n".join(L)
|
||||||
|
|
||||||
|
|
||||||
|
def collect():
|
||||||
|
closed = [] # (sleeve, reason, net_return, pnl, win)
|
||||||
|
open_pos = [] # dict per posizione aperta
|
||||||
|
realized = 0.0
|
||||||
|
for sp in sorted(glob.glob(str(PAPER / "*" / "status.json"))):
|
||||||
|
d = Path(sp).parent
|
||||||
|
wid = d.name
|
||||||
|
st = json.loads(Path(sp).read_text())
|
||||||
|
tp = d / "trades.jsonl"
|
||||||
|
if tp.exists():
|
||||||
|
for line in tp.read_text().splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
ev = json.loads(line)
|
||||||
|
if ev.get("event") != "CLOSE":
|
||||||
|
continue
|
||||||
|
nr = ev.get("net_return", 0.0)
|
||||||
|
pnl = ev.get("pnl", 0.0)
|
||||||
|
realized += pnl
|
||||||
|
closed.append((_short(wid), ev.get("reason", "?"), nr, pnl, nr > 0))
|
||||||
|
if st.get("in_position"):
|
||||||
|
open_pos.append({
|
||||||
|
"sleeve": _short(wid),
|
||||||
|
"dir": st.get("direction", 0),
|
||||||
|
"entry": st.get("entry_price") or st.get("entry_a"),
|
||||||
|
"entry_b": st.get("entry_b"),
|
||||||
|
"bars": st.get("bars_held", 0),
|
||||||
|
"cap": st.get("capital", 0.0),
|
||||||
|
})
|
||||||
|
return closed, open_pos, realized
|
||||||
|
|
||||||
|
|
||||||
|
def build_report() -> str:
|
||||||
|
closed, open_pos, realized = collect()
|
||||||
|
pos = sum(1 for c in closed if c[4])
|
||||||
|
neg = len(closed) - pos
|
||||||
|
|
||||||
|
# breakdown per motivo
|
||||||
|
by_reason = defaultdict(lambda: [0, 0, 0.0]) # reason -> [win, loss, pnl]
|
||||||
|
for _, reason, _, pnl, win in closed:
|
||||||
|
r = by_reason[reason]
|
||||||
|
r[0 if win else 1] += 1
|
||||||
|
r[2] += pnl
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
try:
|
||||||
|
ver = (ROOT / "VERSION").read_text().strip()
|
||||||
|
except Exception:
|
||||||
|
ver = "?"
|
||||||
|
eq = dd = cap = None
|
||||||
|
if PORT_STATUS.exists():
|
||||||
|
ps = json.loads(PORT_STATUS.read_text())
|
||||||
|
eq, cap, dd = ps.get("equity"), ps.get("total_capital"), ps.get("max_dd")
|
||||||
|
|
||||||
|
L = [f"📊 <b>PORT06 — Report orario</b> <code>v{ver}</code>", now]
|
||||||
|
if eq is not None:
|
||||||
|
L.append(f"Equity €{eq:.2f} | Cap €{cap:.2f} | maxDD {dd:.3f}%")
|
||||||
|
|
||||||
|
# 1) CHIUSI
|
||||||
|
L.append(f"\n✅ <b>CHIUSI</b>: {pos} positivi / {neg} negativi (netto fee)")
|
||||||
|
rows = [f"{'motivo':<12}{'✅':>3}{'❌':>4}{'PnL€':>9}"]
|
||||||
|
for reason, (w, l, pnl) in sorted(by_reason.items(), key=lambda x: x[1][2]):
|
||||||
|
rows.append(f"{reason:<12}{w:>3}{l:>4}{pnl:>+9.2f}")
|
||||||
|
L.append("<pre>" + "\n".join(rows) + "</pre>")
|
||||||
|
|
||||||
|
# 2) IN CORSO
|
||||||
|
L.append(f"🟢 <b>IN CORSO</b>: {len(open_pos)} posizioni")
|
||||||
|
if open_pos:
|
||||||
|
rows = [f"{'sleeve':<14}{'d':<2}{'barre':>6} {'entry'}"]
|
||||||
|
for p in sorted(open_pos, key=lambda x: x["sleeve"]):
|
||||||
|
d = "L" if p["dir"] == 1 else "S" if p["dir"] == -1 else "-"
|
||||||
|
entry = p["entry"]
|
||||||
|
es = f"{entry:.6g}" if isinstance(entry, (int, float)) else str(entry)
|
||||||
|
if p["entry_b"]:
|
||||||
|
es = f"{entry:.6g}/{p['entry_b']:.6g}" # coppia: 2 gambe
|
||||||
|
rows.append(f"{p['sleeve']:<14}{d:<2}{p['bars']:>6} {es}")
|
||||||
|
L.append("<pre>" + "\n".join(rows) + "</pre>")
|
||||||
|
|
||||||
|
# 2b) monitor loss-guard
|
||||||
|
L.append(lossguard_section())
|
||||||
|
|
||||||
|
# 3) TOTALE
|
||||||
|
L.append(f"💰 <b>PnL realizzato totale: €{realized:+.2f}</b>")
|
||||||
|
if eq is not None:
|
||||||
|
unreal = eq - cap
|
||||||
|
L.append(f" equity mark-to-market: €{eq:.2f} (non realizz. €{unreal:+.2f})")
|
||||||
|
return "\n".join(L)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
_load_env()
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from src.live.telegram_notifier import send_telegram
|
||||||
|
report = build_report()
|
||||||
|
print(report)
|
||||||
|
ok = send_telegram(report)
|
||||||
|
print("\n[telegram]", "inviato" if ok else "NON inviato (token/chat mancanti o errore rete)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""DIP01 — Dip-buy mean-reversion single-asset (z-score sotto-banda). Honest family.
|
||||||
|
|
||||||
|
Replica live della logica validata in scripts/analysis/honest_improve2.dip_market_gated
|
||||||
|
(con market_n=0, come lo sleeve DIP01_BTC del portafoglio): compra quando lo z-score del
|
||||||
|
prezzo rispetto a SMA(n) incrocia sotto -z_in; esce a TP=SMA, SL=close-sl_atr*ATR, o max_bars.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.strategies.base import Strategy, Signal # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df, n=14):
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
return pd.Series(tr).rolling(n).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
class Dip01DipBuy(Strategy):
|
||||||
|
name = "DIP01_dip_buy"
|
||||||
|
description = "Dip-buy mean-reversion single-asset (z-score), exit TP=SMA/SL=ATR/max_bars"
|
||||||
|
default_assets = ["BTC"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
fee_rt = 0.001
|
||||||
|
leverage = 3.0
|
||||||
|
position_size = 0.15
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||||
|
n: int = 50, z_in: float = 2.5, sl_atr: float = 2.5,
|
||||||
|
max_bars: int = 24, **params) -> list[Signal]:
|
||||||
|
c = df["close"].values
|
||||||
|
ma = pd.Series(c).rolling(n).mean().values
|
||||||
|
sd = pd.Series(c).rolling(n).std().values
|
||||||
|
a = _atr(df, 14)
|
||||||
|
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
# Edge minimo: salta i dip il cui TP (la media) è entro il costo round-trip. 0 = off.
|
||||||
|
min_tp_frac = params.get("min_tp_frac", 0.0)
|
||||||
|
out: list[Signal] = []
|
||||||
|
for i in range(n + 14, len(c)):
|
||||||
|
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
|
||||||
|
continue
|
||||||
|
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||||
|
if min_tp_frac > 0 and abs(ma[i] - c[i]) / c[i] <= min_tp_frac:
|
||||||
|
continue # TP entro le fee -> non eseguibile in utile
|
||||||
|
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
|
||||||
|
metadata={"tp": float(ma[i]),
|
||||||
|
"sl": float(c[i] - sl_atr * a[i]),
|
||||||
|
"max_bars": int(max_bars)}))
|
||||||
|
return out
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""FR01 — Hurst-Calm Fade (FRATTALE x REGIME). Esito della ricerca a 100 agenti (2026-06-02).
|
||||||
|
|
||||||
|
Fade della banda di Bollinger (k=2.5 su SMA50, TP=media, SL=2*ATR, max_bars=24) ATTIVATO
|
||||||
|
SOLO quando coincidono due condizioni di regime:
|
||||||
|
- FRATTALE: rolling Hurst < 0.55 (regime anti-persistente -> la mean-reversion ha senso
|
||||||
|
fratalmente; con Hurst>0.55 il fade peggiora, il momentum perde comunque).
|
||||||
|
- VOLATILITA: dvol_pct < 0.40 (DVOL nel terzile basso del suo storico -> regime calmo/range).
|
||||||
|
|
||||||
|
Doppio gate frattale x regime: l'INTERAZIONE e' l'ingrediente attivo, non il fade di per se'
|
||||||
|
(ablation: senza gate Sharpe ~0.8 e muore a fee 0.2% RT; col doppio gate OOS Sharpe ~3.7).
|
||||||
|
|
||||||
|
VALIDAZIONE (netto fee 0.10% RT, leva 3x, OOS ultimo 30%, ricerca fractal_argo_workflow):
|
||||||
|
BTC 1h: 198 trade, FULL +100% / OOS +54% / Sharpe OOS 3.73 / DD OOS 5.1% / 6/6 anni positivi,
|
||||||
|
regge fee 0.2% RT. Confermato avversarialmente (no look-ahead, split alternativo).
|
||||||
|
Generalizza a ETH 1h (Sharpe ~2.6, secondario). 4h/1d = rumore (pochi trade).
|
||||||
|
Correlazione coi fade esistenti BASSA: MR01 +0.17, MR02 +0.08, MR07 -0.03 -> DIVERSIFICATORE
|
||||||
|
quasi-ortogonale (profilo SH01/pairs), NON ridondante. Esposizione ~1-9% -> low-frequency.
|
||||||
|
|
||||||
|
RUOLO: diversificatore a basso DD per il MASTER/PORT06, NON motore standalone (non batte il
|
||||||
|
portafoglio da solo). Coerente coi priori: i frattali da soli sono rumore; il valore e' nel
|
||||||
|
gating del regime. NB il prior ARGO "VRP>0=range=fade" e' SMENTITO: l'edge robusto e' su VRP<0
|
||||||
|
e su DVOL bassa (questo gate dvol_pct<0.4), non su vol alta.
|
||||||
|
|
||||||
|
DIPENDENZA REGIME (caveat deploy): il gate usa DVOL/dvol_pct. Per il BACKTEST le feature
|
||||||
|
arrivano da regime_lab (cache da Deribit mainnet). Per il LIVE serve un feed DVOL in produzione
|
||||||
|
(regime_fetcher + allineamento causale nel runner) -> wiring NON ancora fatto. Finche' manca,
|
||||||
|
FR01 e' validata-in-ricerca ma non deployabile live.
|
||||||
|
|
||||||
|
Run backtest: uv run python scripts/strategies/FR01_hurst_calm_fade.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.strategies.base import Strategy, Signal # noqa: E402
|
||||||
|
from src.fractal.indicators import rolling_hurst # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
|
pc = np.roll(c, 1); pc[0] = c[0]
|
||||||
|
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||||
|
return pd.Series(tr).rolling(n).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
class HurstCalmFade(Strategy):
|
||||||
|
name = "FR01_hurst_calm_fade"
|
||||||
|
description = "Fade Bollinger gateato da Hurst<0.55 (anti-persistente) + DVOL bassa (calm)"
|
||||||
|
default_assets = ["BTC", "ETH"]
|
||||||
|
default_timeframes = ["1h"]
|
||||||
|
fee_rt = 0.001
|
||||||
|
leverage = 3.0
|
||||||
|
position_size = 0.15
|
||||||
|
initial_capital = 1000.0
|
||||||
|
|
||||||
|
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, **params) -> list[Signal]:
|
||||||
|
bb_w = params.get("bb_window", 50)
|
||||||
|
k = params.get("k", 2.5)
|
||||||
|
sl_atr = params.get("sl_atr", 2.0)
|
||||||
|
max_bars = params.get("max_bars", 24)
|
||||||
|
hurst_thr = params.get("hurst_thr", 0.55)
|
||||||
|
hurst_win = params.get("hurst_win", 100)
|
||||||
|
dvol_pct_thr = params.get("dvol_pct_thr", 0.40)
|
||||||
|
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
n = len(c)
|
||||||
|
ma = pd.Series(c).rolling(bb_w).mean().values
|
||||||
|
sd = pd.Series(c).rolling(bb_w).std().values
|
||||||
|
a = _atr(df, 14)
|
||||||
|
hurst = rolling_hurst(c, window=hurst_win) # causale (returns[i-win:i])
|
||||||
|
# dvol_pct: dalla colonna se presente (regime_lab.load_features), altrimenti gate OFF
|
||||||
|
dvol_pct = df["dvol_pct"].values if "dvol_pct" in df.columns else np.full(n, np.nan)
|
||||||
|
|
||||||
|
signals: list[Signal] = []
|
||||||
|
for i in range(bb_w + 14, n):
|
||||||
|
if np.isnan(sd[i]) or np.isnan(a[i]) or sd[i] == 0:
|
||||||
|
continue
|
||||||
|
# GATE FRATTALE x REGIME (tutto noto a i)
|
||||||
|
if hurst[i] >= hurst_thr:
|
||||||
|
continue
|
||||||
|
if "dvol_pct" in df.columns:
|
||||||
|
if np.isnan(dvol_pct[i]) or dvol_pct[i] >= dvol_pct_thr:
|
||||||
|
continue
|
||||||
|
up, lo = ma[i] + k * sd[i], ma[i] - k * sd[i]
|
||||||
|
if c[i] < lo:
|
||||||
|
d, sl = 1, c[i] - sl_atr * a[i]
|
||||||
|
elif c[i] > up:
|
||||||
|
d, sl = -1, c[i] + sl_atr * a[i]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
signals.append(Signal(
|
||||||
|
idx=i, direction=d, entry_price=float(c[i]),
|
||||||
|
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": int(max_bars)},
|
||||||
|
))
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# backtest via l'harness onesto + feature di regime_lab (DVOL reale)
|
||||||
|
from scripts.analysis.regime_lab import load_features, report
|
||||||
|
from scripts.analysis.explore_lab import robust
|
||||||
|
|
||||||
|
strat = HurstCalmFade()
|
||||||
|
print(f"{'=' * 100}")
|
||||||
|
print(f" FR01 HURST-CALM FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||||
|
print(f" gate: hurst<0.55 (anti-persistente) + dvol_pct<0.40 (DVOL bassa)")
|
||||||
|
print(f"{'=' * 100}")
|
||||||
|
for asset in ("BTC", "ETH"):
|
||||||
|
df = load_features(asset, "1h")
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = strat.generate_signals(df, ts)
|
||||||
|
ent = [{"i": s.idx, "d": s.direction, "tp": s.metadata["tp"],
|
||||||
|
"sl": s.metadata["sl"], "max_bars": s.metadata["max_bars"]} for s in sigs]
|
||||||
|
res = report(f"FR01_{asset}_1h", ent, df)
|
||||||
|
print(f" -> {asset}: robust={robust(res)} OOS Sharpe={res['oos']['sharpe']:.2f} "
|
||||||
|
f"OOS ret={res['oos']['ret']:+.0f}% DD={res['full']['dd']:.0f}%")
|
||||||
@@ -30,6 +30,7 @@ import pandas as pd
|
|||||||
|
|
||||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||||
from src.data.downloader import load_data
|
from src.data.downloader import load_data
|
||||||
|
from src.strategies.fade_base import hurst_skip_mask
|
||||||
|
|
||||||
|
|
||||||
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
@@ -59,17 +60,25 @@ class BollingerFade(Strategy):
|
|||||||
max_bars = params.get("max_bars", 24)
|
max_bars = params.get("max_bars", 24)
|
||||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||||
ema_long = params.get("ema_long", 200)
|
ema_long = params.get("ema_long", 200)
|
||||||
|
# Edge minimo: salta i segnali il cui TP (la media) è più vicino dell'entry del
|
||||||
|
# costo round-trip -> perdenti garantiti anche colpendo il TP. 0 = off.
|
||||||
|
min_tp_frac = params.get("min_tp_frac", 0.0)
|
||||||
|
# Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off.
|
||||||
|
hurst_max = params.get("hurst_max")
|
||||||
|
|
||||||
ma = pd.Series(c).rolling(bb_w).mean().values
|
ma = pd.Series(c).rolling(bb_w).mean().values
|
||||||
sd = pd.Series(c).rolling(bb_w).std().values
|
sd = pd.Series(c).rolling(bb_w).std().values
|
||||||
a = _atr(df, 14)
|
a = _atr(df, 14)
|
||||||
up, lo = ma + k * sd, ma - k * sd
|
up, lo = ma + k * sd, ma - k * sd
|
||||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values if trend_max is not None else None
|
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values if trend_max is not None else None
|
||||||
|
skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100))
|
||||||
|
|
||||||
signals: list[Signal] = []
|
signals: list[Signal] = []
|
||||||
for i in range(bb_w + 14, n_len):
|
for i in range(bb_w + 14, n_len):
|
||||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||||
continue
|
continue
|
||||||
|
if skip[i]:
|
||||||
|
continue # loss-guard: regime persistente
|
||||||
if el is not None and (a[i] == 0 or np.isnan(el[i]) or abs(c[i] - el[i]) / a[i] > trend_max):
|
if el is not None and (a[i] == 0 or np.isnan(el[i]) or abs(c[i] - el[i]) / a[i] > trend_max):
|
||||||
continue
|
continue
|
||||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||||
@@ -78,6 +87,8 @@ class BollingerFade(Strategy):
|
|||||||
d, sl = -1, c[i] + sl_atr * a[i]
|
d, sl = -1, c[i] + sl_atr * a[i]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
if min_tp_frac > 0 and abs(ma[i] - c[i]) / c[i] <= min_tp_frac:
|
||||||
|
continue # TP entro le fee -> non eseguibile in utile
|
||||||
signals.append(Signal(
|
signals.append(Signal(
|
||||||
idx=i, direction=d, entry_price=c[i],
|
idx=i, direction=d, entry_price=c[i],
|
||||||
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": max_bars},
|
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": max_bars},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from src.strategies.base import Signal
|
from src.strategies.base import Signal
|
||||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
from src.strategies.fade_base import FadeStrategy, atr, trend_distance, hurst_skip_mask
|
||||||
|
|
||||||
|
|
||||||
class DonchianFade(FadeStrategy):
|
class DonchianFade(FadeStrategy):
|
||||||
@@ -42,17 +42,24 @@ class DonchianFade(FadeStrategy):
|
|||||||
max_bars = params.get("max_bars", 24)
|
max_bars = params.get("max_bars", 24)
|
||||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||||
ema_long = params.get("ema_long", 200)
|
ema_long = params.get("ema_long", 200)
|
||||||
|
# Edge minimo: salta i fade il cui TP (midpoint canale) è entro il costo RT. 0 = off.
|
||||||
|
min_tp_frac = params.get("min_tp_frac", 0.0)
|
||||||
|
# Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off.
|
||||||
|
hurst_max = params.get("hurst_max")
|
||||||
|
|
||||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
hh = pd.Series(h).rolling(n).max().shift(1).values
|
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||||
ll = pd.Series(l).rolling(n).min().shift(1).values
|
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||||
a = atr(df, 14)
|
a = atr(df, 14)
|
||||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||||
|
skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100))
|
||||||
|
|
||||||
signals: list[Signal] = []
|
signals: list[Signal] = []
|
||||||
for i in range(n + 14, len(c)):
|
for i in range(n + 14, len(c)):
|
||||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||||
continue
|
continue
|
||||||
|
if skip[i]:
|
||||||
|
continue # loss-guard: regime persistente
|
||||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||||
continue
|
continue
|
||||||
mid = (hh[i] + ll[i]) / 2.0
|
mid = (hh[i] + ll[i]) / 2.0
|
||||||
@@ -62,6 +69,8 @@ class DonchianFade(FadeStrategy):
|
|||||||
d, sl = 1, c[i] - sl_atr * a[i]
|
d, sl = 1, c[i] - sl_atr * a[i]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
if min_tp_frac > 0 and abs(mid - c[i]) / c[i] <= min_tp_frac:
|
||||||
|
continue # TP entro le fee -> non eseguibile in utile
|
||||||
signals.append(Signal(
|
signals.append(Signal(
|
||||||
idx=i, direction=d, entry_price=c[i],
|
idx=i, direction=d, entry_price=c[i],
|
||||||
metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars},
|
metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from src.strategies.base import Signal
|
from src.strategies.base import Signal
|
||||||
from src.strategies.fade_base import FadeStrategy, atr, trend_distance
|
from src.strategies.fade_base import FadeStrategy, atr, trend_distance, hurst_skip_mask
|
||||||
|
|
||||||
|
|
||||||
class ReturnReversal(FadeStrategy):
|
class ReturnReversal(FadeStrategy):
|
||||||
@@ -47,6 +47,10 @@ class ReturnReversal(FadeStrategy):
|
|||||||
max_bars = params.get("max_bars", 24)
|
max_bars = params.get("max_bars", 24)
|
||||||
trend_max = params.get("trend_max") # None = filtro disattivo
|
trend_max = params.get("trend_max") # None = filtro disattivo
|
||||||
ema_long = params.get("ema_long", 200)
|
ema_long = params.get("ema_long", 200)
|
||||||
|
# Edge minimo: salta i fade il cui TP (ATR-scaled) è entro il costo RT. 0 = off.
|
||||||
|
min_tp_frac = params.get("min_tp_frac", 0.0)
|
||||||
|
# Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off.
|
||||||
|
hurst_max = params.get("hurst_max")
|
||||||
|
|
||||||
c = df["close"].values
|
c = df["close"].values
|
||||||
ret = np.zeros_like(c)
|
ret = np.zeros_like(c)
|
||||||
@@ -54,11 +58,14 @@ class ReturnReversal(FadeStrategy):
|
|||||||
sig = pd.Series(ret).rolling(n).std().values
|
sig = pd.Series(ret).rolling(n).std().values
|
||||||
a = atr(df, 14)
|
a = atr(df, 14)
|
||||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
td = trend_distance(df, ema_long) if trend_max is not None else None
|
||||||
|
skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100))
|
||||||
|
|
||||||
signals: list[Signal] = []
|
signals: list[Signal] = []
|
||||||
for i in range(n + 14, len(c)):
|
for i in range(n + 14, len(c)):
|
||||||
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||||
continue
|
continue
|
||||||
|
if skip[i]:
|
||||||
|
continue # loss-guard: regime persistente
|
||||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||||
continue
|
continue
|
||||||
z = ret[i] / sig[i]
|
z = ret[i] / sig[i]
|
||||||
@@ -68,6 +75,8 @@ class ReturnReversal(FadeStrategy):
|
|||||||
d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i]
|
d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
if min_tp_frac > 0 and abs(tp - c[i]) / c[i] <= min_tp_frac:
|
||||||
|
continue # TP entro le fee -> non eseguibile in utile
|
||||||
signals.append(Signal(
|
signals.append(Signal(
|
||||||
idx=i, direction=d, entry_price=c[i],
|
idx=i, direction=d, entry_price=c[i],
|
||||||
metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars},
|
metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars},
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ Validazione anti-overfit (netto, fee 0.20% RT/coppia a 2 gambe, leva 3x, OOS = u
|
|||||||
- Correlazione con BTC daily ~0.02-0.08 -> market-neutral.
|
- Correlazione con BTC daily ~0.02-0.08 -> market-neutral.
|
||||||
- SCARTATA BNB/ETH: robusta solo coi suoi parametri (overfit), crolla con la universale.
|
- SCARTATA BNB/ETH: robusta solo coi suoi parametri (overfit), crolla con la universale.
|
||||||
|
|
||||||
LIMITE OPERATIVO: e' una strategia a 2 gambe (long un perp + short l'altro), il worker
|
WORKER LIVE: implementato `src/live/pairs_worker.py` (2 gambe, fee doppie, stato
|
||||||
attuale e' single-leg. Per tradarla serve: (a) eseguibilita' short del perp B su
|
persistente) e wired in `multi_runner` (sezione `pairs:` in strategies.yml). Validato:
|
||||||
Deribit/Bybit, (b) gestione 2 ordini + fee doppie. Finche' il worker non supporta
|
- LOGICA: `validate_worker_pairs.py` -> replay storico == backtest pairs_sim ESATTO
|
||||||
2 gambe, PR01 resta validata in backtest ma non wired nel paper trader.
|
(ETH/BTC: capitale, n.trade, win% identici).
|
||||||
|
- LIVE: `live_smoke_pairs.py` (smoke reale Cerbero) -> tutte e 5 le coppie con feed
|
||||||
|
live fresco. Naming Deribit corretto: BTC/ETH = "<COIN>-PERPETUAL" (inverse),
|
||||||
|
alt = "<COIN>_USDC-PERPETUAL" (lineari USDC, storia dal 2022). Trappola: usare
|
||||||
|
"LTC-PERPETUAL"/"SOL-PERPETUAL" da' vuoto/dati sbagliati -> SEMPRE _USDC-PERPETUAL.
|
||||||
|
Resta da verificare in trading reale solo la liquidita'/fill in esecuzione.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+12
-1
@@ -69,8 +69,19 @@ def _fetch_binance(symbol: str, tf: str, since_ms: int, limit: int = 1000) -> li
|
|||||||
|
|
||||||
|
|
||||||
def _download_cerbero_range(
|
def _download_cerbero_range(
|
||||||
instrument: str, resolution: str, tf: str, start_date: str, end_date: str
|
instrument: str, resolution: str, tf: str, start_date: str, end_date: str,
|
||||||
|
allow_unvalidated: bool = False,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
|
# Gate: si raccolgono dati SOLO per strumenti validati nel registry.
|
||||||
|
# Esegui `python -m src.data.instruments` per (ri)costruirlo.
|
||||||
|
if not allow_unvalidated:
|
||||||
|
from src.data.instruments import is_validated
|
||||||
|
if not is_validated(instrument, tf, "deribit"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Strumento non validato: {instrument} @ {tf}. "
|
||||||
|
f"Costruisci il registry (python -m src.data.instruments) o passa "
|
||||||
|
f"allow_unvalidated=True per forzare."
|
||||||
|
)
|
||||||
all_candles: list[dict] = []
|
all_candles: list[dict] = []
|
||||||
max_days = MAX_DAYS_PER_REQUEST[tf]
|
max_days = MAX_DAYS_PER_REQUEST[tf]
|
||||||
current = datetime.fromisoformat(start_date)
|
current = datetime.fromisoformat(start_date)
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Discovery + validazione strumenti per gli exchange implementati (via Cerbero MCP).
|
||||||
|
|
||||||
|
Per ogni exchange (Deribit, Hyperliquid — esclusi Alpaca/stocks e Bybit, il cui
|
||||||
|
feed testnet e' farlocco) enumera i perpetui, ne verifica i dati e produce un
|
||||||
|
registry di strumenti VALIDATI.
|
||||||
|
Solo gli strumenti nel registry possono essere usati per la raccolta dati
|
||||||
|
(vedi gate in src/data/downloader.py).
|
||||||
|
|
||||||
|
Controlli di validazione (uno strumento e' valido solo se TUTTI passano):
|
||||||
|
- exists : la storia daily ritorna candele
|
||||||
|
- ohlc_sane : high>=low, high>=max(o,c), low<=min(o,c), prezzi>0
|
||||||
|
- not_flat : non e' un contratto morto (quasi tutte le barre O==H==L==C)
|
||||||
|
- liquid : volume_24h>0 dal ticker
|
||||||
|
- congruent : il prezzo concorda (entro tolleranza) con la MEDIANA dello
|
||||||
|
stesso base-coin su tutti gli exchange. Scarta i feed testnet
|
||||||
|
farlocchi (es. Bybit BTC=300k) e i contratti sbagliati
|
||||||
|
(es. Deribit SOL-PERPETUAL=9.6 vs SOL reale ~82).
|
||||||
|
|
||||||
|
NB: il token Cerbero punta a TESTNET; la congruenza cross-exchange e' il filtro
|
||||||
|
che distingue i feed realistici (Deribit, Hyperliquid) da quelli farlocchi.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
|
||||||
|
REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / "instruments_registry.json"
|
||||||
|
|
||||||
|
# I nostri timestep -> codice risoluzione per ciascun exchange
|
||||||
|
TF_CODES = {
|
||||||
|
"deribit": {"1m": "1", "5m": "5", "15m": "15", "1h": "60", "1d": "1D"},
|
||||||
|
"hyperliquid": {"1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h", "1d": "1d"},
|
||||||
|
}
|
||||||
|
CONGRUENCE_TOL = 0.05 # 5% di scostamento dalla mediana del base-coin
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Quote:
|
||||||
|
base: str
|
||||||
|
symbol: str
|
||||||
|
last: float | None = None
|
||||||
|
volume_24h: float | None = None
|
||||||
|
open_interest: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- adapters ---------------------------
|
||||||
|
class ExchangeAdapter:
|
||||||
|
name = "base"
|
||||||
|
|
||||||
|
def __init__(self, client: CerberoClient):
|
||||||
|
self.c = client
|
||||||
|
|
||||||
|
def _post(self, tool: str, payload: dict) -> dict:
|
||||||
|
return self.c._post(f"/mcp-{self.name}/tools/{tool}", payload)
|
||||||
|
|
||||||
|
def list_symbols(self) -> list[Quote]:
|
||||||
|
"""Lista perpetui (economica). I prezzi possono essere None (vedi ticker)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def ticker(self, q: Quote) -> None:
|
||||||
|
"""Riempie last/volume/OI sul Quote (per-simbolo). No-op se gia' pieni."""
|
||||||
|
|
||||||
|
def candles(self, symbol: str, tf: str, start: str, end: str) -> pd.DataFrame:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class DeribitAdapter(ExchangeAdapter):
|
||||||
|
name = "deribit"
|
||||||
|
|
||||||
|
def list_symbols(self) -> list[Quote]:
|
||||||
|
perps, offset = [], 0
|
||||||
|
while True:
|
||||||
|
r = self._post("get_instruments", {"currency": "any", "kind": "future",
|
||||||
|
"offset": offset, "limit": 100})
|
||||||
|
insts = r.get("instruments", [])
|
||||||
|
perps += [i["name"] for i in insts if i.get("name", "").endswith("-PERPETUAL")]
|
||||||
|
if not r.get("has_more") or not insts:
|
||||||
|
break
|
||||||
|
offset += len(insts)
|
||||||
|
if offset > 2000:
|
||||||
|
break
|
||||||
|
out = []
|
||||||
|
for name in perps:
|
||||||
|
base = name.split("-PERPETUAL")[0].replace("_USDC", "").replace("_USD", "")
|
||||||
|
out.append(Quote(base, name))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def ticker(self, q: Quote) -> None:
|
||||||
|
t = self._post("get_ticker", {"instrument": q.symbol})
|
||||||
|
q.last, q.volume_24h, q.open_interest = t.get("last_price"), t.get("volume_24h"), t.get("open_interest")
|
||||||
|
|
||||||
|
def candles(self, symbol, tf, start, end) -> pd.DataFrame:
|
||||||
|
r = self._post("get_historical", {"instrument": symbol, "start_date": start,
|
||||||
|
"end_date": end, "resolution": TF_CODES["deribit"][tf]})
|
||||||
|
return _to_df(r.get("candles", []))
|
||||||
|
|
||||||
|
|
||||||
|
class HyperliquidAdapter(ExchangeAdapter):
|
||||||
|
name = "hyperliquid"
|
||||||
|
|
||||||
|
def list_symbols(self) -> list[Quote]:
|
||||||
|
r = self._post("get_markets", {})
|
||||||
|
markets = r if isinstance(r, list) else r.get("markets", [])
|
||||||
|
return [Quote(m["asset"], m["asset"], m.get("mark_price"),
|
||||||
|
m.get("volume_24h"), m.get("open_interest")) for m in markets]
|
||||||
|
|
||||||
|
# prezzi gia' presenti da get_markets -> ticker no-op
|
||||||
|
|
||||||
|
def candles(self, symbol, tf, start, end) -> pd.DataFrame:
|
||||||
|
r = self._post("get_historical", {"asset": symbol, "start_date": start, "end_date": end,
|
||||||
|
"resolution": TF_CODES["hyperliquid"][tf], "limit": 5000})
|
||||||
|
return _to_df(r.get("candles", []))
|
||||||
|
|
||||||
|
|
||||||
|
ADAPTERS = {"deribit": DeribitAdapter, "hyperliquid": HyperliquidAdapter}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_df(candles: list[dict]) -> pd.DataFrame:
|
||||||
|
if not candles:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(candles)
|
||||||
|
df["timestamp"] = df["timestamp"].astype("int64")
|
||||||
|
return df.sort_values("timestamp").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- validazione ---------------------------
|
||||||
|
def _ohlc_sane(df: pd.DataFrame) -> bool:
|
||||||
|
if df.empty:
|
||||||
|
return False
|
||||||
|
o, h, l, c = df["open"], df["high"], df["low"], df["close"]
|
||||||
|
ok = (h >= l) & (h >= o) & (h >= c) & (l <= o) & (l <= c) & (c > 0) & (l > 0)
|
||||||
|
return bool(ok.mean() > 0.99)
|
||||||
|
|
||||||
|
|
||||||
|
def _not_flat(df: pd.DataFrame) -> bool:
|
||||||
|
if df.empty:
|
||||||
|
return False
|
||||||
|
flat = (df["open"] == df["high"]) & (df["high"] == df["low"]) & (df["low"] == df["close"])
|
||||||
|
return bool(flat.mean() < 0.90)
|
||||||
|
|
||||||
|
|
||||||
|
def build_registry(exchanges: list[str] | None = None,
|
||||||
|
tf_check: tuple[str, ...] = ("1m", "5m", "15m", "1h"),
|
||||||
|
start_scan_from: str = "2017-01-01",
|
||||||
|
save: bool = True) -> dict:
|
||||||
|
exchanges = exchanges or ["deribit", "hyperliquid"] # NO alpaca, NO bybit (testnet farlocco)
|
||||||
|
client = CerberoClient()
|
||||||
|
adapters = {ex: ADAPTERS[ex](client) for ex in exchanges}
|
||||||
|
|
||||||
|
# 1) lista economica per ogni exchange
|
||||||
|
listed: dict[str, list[Quote]] = {}
|
||||||
|
for ex, ad in adapters.items():
|
||||||
|
try:
|
||||||
|
listed[ex] = ad.list_symbols()
|
||||||
|
print(f" [{ex}] {len(listed[ex])} strumenti elencati")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{ex}] discovery FALLITA: {type(e).__name__}: {e}")
|
||||||
|
listed[ex] = []
|
||||||
|
|
||||||
|
# 2) universo = base-coin presenti su Deribit (il nostro venue). Bybit/HL
|
||||||
|
# vengono validati solo sull'overlap (cross-check), non su 500+ simboli.
|
||||||
|
deribit_bases = {q.base for q in listed.get("deribit", [])}
|
||||||
|
selected: dict[str, list[Quote]] = {}
|
||||||
|
for ex, qs in listed.items():
|
||||||
|
selected[ex] = qs if ex == "deribit" else [q for q in qs if q.base in deribit_bases]
|
||||||
|
|
||||||
|
# 3) timeframe disponibili per exchange (testati su BTC recente)
|
||||||
|
ref = {"deribit": "BTC-PERPETUAL", "hyperliquid": "BTC"}
|
||||||
|
tf_by_ex: dict[str, list[str]] = {}
|
||||||
|
for ex, ad in adapters.items():
|
||||||
|
oks = []
|
||||||
|
for tf in tf_check:
|
||||||
|
try:
|
||||||
|
if not ad.candles(ref[ex], tf, _today(), _today()).empty:
|
||||||
|
oks.append(tf)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tf_by_ex[ex] = oks
|
||||||
|
print(f" [{ex}] timeframe ok: {oks}")
|
||||||
|
|
||||||
|
# 4) UNA fetch daily per strumento: e' il dato che davvero raccoglieremmo.
|
||||||
|
# Da qui ricaviamo esistenza, OHLC, not-flat, start-date, prezzo-per-congruenza
|
||||||
|
# (ultima close STORICA, non il ticker) e liquidita' (volume daily recente).
|
||||||
|
scan: dict[tuple[str, str], dict] = {}
|
||||||
|
for ex, ad in adapters.items():
|
||||||
|
for q in selected[ex]:
|
||||||
|
rec = {"reasons": [], "last_close": None, "start_date": None, "vol": 0.0}
|
||||||
|
try:
|
||||||
|
d = ad.candles(q.symbol, "1d", start_scan_from, _today())
|
||||||
|
if d.empty:
|
||||||
|
rec["reasons"].append("no_history")
|
||||||
|
else:
|
||||||
|
if not _ohlc_sane(d):
|
||||||
|
rec["reasons"].append("ohlc_insane")
|
||||||
|
if not _not_flat(d):
|
||||||
|
rec["reasons"].append("flat_dead")
|
||||||
|
rec["last_close"] = float(d["close"].iloc[-1])
|
||||||
|
rec["vol"] = float(d["volume"].tail(7).mean())
|
||||||
|
rec["start_date"] = str(pd.to_datetime(d["timestamp"].iloc[0], unit="ms", utc=True).date())
|
||||||
|
except Exception as e:
|
||||||
|
rec["reasons"].append(f"history_err:{type(e).__name__}")
|
||||||
|
scan[(ex, q.symbol)] = rec
|
||||||
|
|
||||||
|
# 5) mediana per base-coin dall'ULTIMA CLOSE STORICA (riferimento congruenza)
|
||||||
|
by_base: dict[str, list[float]] = {}
|
||||||
|
for (ex, sym), rec in scan.items():
|
||||||
|
base = next(q.base for q in selected[ex] if q.symbol == sym)
|
||||||
|
if rec["last_close"] and rec["last_close"] > 0:
|
||||||
|
by_base.setdefault(base, []).append(rec["last_close"])
|
||||||
|
median_px = {b: statistics.median(v) for b, v in by_base.items()}
|
||||||
|
|
||||||
|
# 6) finalizza validazione
|
||||||
|
registry: dict = {"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"congruence_tol": CONGRUENCE_TOL, "testnet": True, "exchanges": {}}
|
||||||
|
for ex, ad in adapters.items():
|
||||||
|
registry["exchanges"][ex] = {"timeframes": tf_by_ex[ex], "instruments": {}}
|
||||||
|
for q in selected[ex]:
|
||||||
|
rec = scan[(ex, q.symbol)]
|
||||||
|
reasons = list(rec["reasons"])
|
||||||
|
px, med, n_src = rec["last_close"], median_px.get(q.base), len(by_base.get(q.base, []))
|
||||||
|
if not (rec["vol"] and rec["vol"] > 0):
|
||||||
|
reasons.append("no_volume")
|
||||||
|
if px is None or px <= 0:
|
||||||
|
if "no_history" not in reasons:
|
||||||
|
reasons.append("no_price")
|
||||||
|
elif med and n_src >= 2 and abs(px - med) / med > CONGRUENCE_TOL:
|
||||||
|
reasons.append(f"incongruent(px={px:.4g},med={med:.4g})")
|
||||||
|
valid = len(reasons) == 0
|
||||||
|
registry["exchanges"][ex]["instruments"][q.symbol] = {
|
||||||
|
"base": q.base, "valid": valid, "reasons": reasons,
|
||||||
|
"last_price": px, "start_date": rec["start_date"],
|
||||||
|
"timeframes": tf_by_ex[ex] if valid else [],
|
||||||
|
}
|
||||||
|
if save:
|
||||||
|
REGISTRY_PATH.write_text(json.dumps(registry, indent=2))
|
||||||
|
print(f" registry salvato in {REGISTRY_PATH}")
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- gate per il downloader ---------------------------
|
||||||
|
def load_registry() -> dict:
|
||||||
|
return json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_validated(symbol: str, tf: str, exchange: str = "deribit") -> bool:
|
||||||
|
"""True solo se lo strumento e' nel registry come valido per quel timeframe."""
|
||||||
|
inst = load_registry().get("exchanges", {}).get(exchange, {}).get("instruments", {}).get(symbol)
|
||||||
|
return bool(inst and inst.get("valid") and tf in inst.get("timeframes", []))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
reg = build_registry()
|
||||||
|
print("\n" + "=" * 96)
|
||||||
|
print(" REGISTRY STRUMENTI VALIDATI")
|
||||||
|
print("=" * 96)
|
||||||
|
for ex, exd in reg["exchanges"].items():
|
||||||
|
insts = exd["instruments"]
|
||||||
|
valid = {s: i for s, i in insts.items() if i["valid"]}
|
||||||
|
print(f"\n {ex.upper()} | tf={exd['timeframes']} | validi {len(valid)}/{len(insts)}")
|
||||||
|
for s, i in sorted(valid.items(), key=lambda kv: kv[1]["base"]):
|
||||||
|
print(f" {s:30s} {i['base']:10s} px={i['last_price']:<12.6g} dal {i['start_date']}")
|
||||||
|
bad = {s: i for s, i in insts.items() if not i["valid"]}
|
||||||
|
if bad:
|
||||||
|
shown = list(bad.items())[:6]
|
||||||
|
print(f" -- scartati {len(bad)} (primi {len(shown)}):")
|
||||||
|
for s, i in shown:
|
||||||
|
print(f" {s:30s} {','.join(i['reasons'])[:64]}")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""BasketTrendWorker (TR01): EMA20>EMA100 long/flat su un paniere, equal-weight.
|
||||||
|
Replica live di honest_improve2._tr_basket_daily."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(x, n):
|
||||||
|
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
class BasketTrendWorker:
|
||||||
|
def __init__(self, universe, tf="4h", capital=1000.0, position_size=POS,
|
||||||
|
leverage=LEV, fee_rt=FEE_RT, name="TR01_basket",
|
||||||
|
data_dir=Path("data/portfolio_paper")):
|
||||||
|
self.universe = list(universe)
|
||||||
|
self.tf = tf
|
||||||
|
self.initial_capital = capital
|
||||||
|
self.capital = capital
|
||||||
|
self.position_size = position_size
|
||||||
|
self.leverage = leverage
|
||||||
|
self.fee_rt = fee_rt
|
||||||
|
self.worker_id = f"{name}__{'-'.join(self.universe)}__{tf}"
|
||||||
|
self.work_dir = Path(data_dir) / self.worker_id
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
self.positions = {a: 0.0 for a in self.universe}
|
||||||
|
self.last_bar_ts = {a: 0 for a in self.universe}
|
||||||
|
self.in_position = False
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if self.status_path.exists():
|
||||||
|
s = json.loads(self.status_path.read_text())
|
||||||
|
self.capital = s.get("capital", self.capital)
|
||||||
|
self.positions = {**self.positions, **s.get("positions", {})}
|
||||||
|
self.last_bar_ts = {**self.last_bar_ts, **s.get("last_bar_ts", {})}
|
||||||
|
self.in_position = any(v > 0 for v in self.positions.values())
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.status_path.write_text(json.dumps({
|
||||||
|
"capital": round(self.capital, 2), "positions": self.positions,
|
||||||
|
"last_bar_ts": self.last_bar_ts,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
|
||||||
|
|
||||||
|
def tick(self, data: dict):
|
||||||
|
rets = []
|
||||||
|
for a in self.universe:
|
||||||
|
df = data.get(a)
|
||||||
|
if df is None or len(df) < 110:
|
||||||
|
continue
|
||||||
|
c = df["close"].values
|
||||||
|
ef, es = _ema(c, 20)[-1], _ema(c, 100)[-1]
|
||||||
|
target = 1.0 if ef > es else 0.0
|
||||||
|
bar_ts = int(df["timestamp"].iloc[-1])
|
||||||
|
prev = self.positions[a]
|
||||||
|
if self.last_bar_ts[a] and bar_ts > self.last_bar_ts[a] and prev > 0:
|
||||||
|
r = (c[-1] - c[-2]) / c[-2]
|
||||||
|
rets.append(self.position_size * self.leverage * r * prev)
|
||||||
|
if target != prev:
|
||||||
|
self.capital -= self.capital * self.position_size * (self.fee_rt / 2) * abs(target - prev) / len(self.universe)
|
||||||
|
self._log(a, prev, target, float(c[-1]))
|
||||||
|
self.positions[a] = target
|
||||||
|
self.last_bar_ts[a] = bar_ts
|
||||||
|
if rets:
|
||||||
|
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
|
||||||
|
self.in_position = any(v > 0 for v in self.positions.values())
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _log(self, asset, frm, to, price):
|
||||||
|
with open(self.trades_path, "a") as f:
|
||||||
|
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"asset": asset, "from": frm, "to": to,
|
||||||
|
"price": round(price, 6), "capital": round(self.capital, 2)}) + "\n")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_summary(self):
|
||||||
|
longs = [a for a, v in self.positions.items() if v > 0]
|
||||||
|
return f"{self.worker_id}: cap={self.capital:.0f} long={longs}"
|
||||||
@@ -49,6 +49,29 @@ class CerberoClient:
|
|||||||
})
|
})
|
||||||
return data.get("candles", [])
|
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 ---
|
# --- Account ---
|
||||||
|
|
||||||
def get_account_summary(self, currency: str = "USDC") -> dict:
|
def get_account_summary(self, currency: str = "USDC") -> dict:
|
||||||
@@ -66,9 +89,17 @@ class CerberoClient:
|
|||||||
amount: float,
|
amount: float,
|
||||||
order_type: str = "market",
|
order_type: str = "market",
|
||||||
price: float | None = None,
|
price: float | None = None,
|
||||||
leverage: int | None = 3,
|
leverage: int | None = None,
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
|
reduce_only: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Piazza un ordine REALE su Deribit. `amount`: per i perp inverse
|
||||||
|
(BTC/ETH-PERPETUAL) e' in USD notional (step BTC $10, ETH $1); per i lineari
|
||||||
|
USDC (BTC_USDC/ETH_USDC-PERPETUAL) e' nel base-coin (step 0.0001/0.001).
|
||||||
|
`reduce_only=True` per chiudere solo la propria quota su uno strumento
|
||||||
|
condiviso (le posizioni si nettano per conto). Ritorna il `result` grezzo
|
||||||
|
Deribit: {"order": {...}, "trades": [{price, amount, fee, ...}]} → le fee
|
||||||
|
REALI sono in trades[]."""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"instrument_name": instrument,
|
"instrument_name": instrument,
|
||||||
"side": side,
|
"side": side,
|
||||||
@@ -81,11 +112,22 @@ class CerberoClient:
|
|||||||
payload["leverage"] = leverage
|
payload["leverage"] = leverage
|
||||||
if label:
|
if label:
|
||||||
payload["label"] = label
|
payload["label"] = label
|
||||||
|
if reduce_only:
|
||||||
|
payload["reduce_only"] = True
|
||||||
return self._post("/mcp-deribit/tools/place_order", payload)
|
return self._post("/mcp-deribit/tools/place_order", payload)
|
||||||
|
|
||||||
def close_position(self, instrument: str) -> dict:
|
def close_position(self, instrument: str) -> dict:
|
||||||
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
|
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
|
||||||
|
|
||||||
|
def get_trade_history(self, limit: int = 100, instrument_name: str | None = None) -> list[dict]:
|
||||||
|
"""Trade ESEGUITI sul conto (fonte autorevole delle fee reali). Ogni voce:
|
||||||
|
{instrument, direction, price, amount, fee, timestamp, order_id}."""
|
||||||
|
payload: dict[str, Any] = {"limit": limit}
|
||||||
|
if instrument_name:
|
||||||
|
payload["instrument_name"] = instrument_name
|
||||||
|
out = self._post("/mcp-deribit/tools/get_trade_history", payload)
|
||||||
|
return out if isinstance(out, list) else out.get("trades", [])
|
||||||
|
|
||||||
def set_stop_loss(self, order_id: str, stop_price: float) -> dict:
|
def set_stop_loss(self, order_id: str, stop_price: float) -> dict:
|
||||||
return self._post("/mcp-deribit/tools/set_stop_loss", {"order_id": order_id, "stop_price": stop_price})
|
return self._post("/mcp-deribit/tools/set_stop_loss", {"order_id": order_id, "stop_price": stop_price})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Esecuzione REALE su Deribit (testnet) con verifica post-ordine e fee reali.
|
||||||
|
|
||||||
|
Flusso per ogni ordine:
|
||||||
|
1. converte il notional (USD) in `amount` Deribit, arrotondato allo step del
|
||||||
|
contratto (BTC-PERPETUAL step $10, ETH-PERPETUAL step $1) e clampato al minimo;
|
||||||
|
2. piazza un market order REALE via Cerbero → Deribit private/buy|sell;
|
||||||
|
3. RIVERIFICA su Deribit: rilegge get_positions (la posizione esiste con la size
|
||||||
|
giusta?) e get_trade_history (ritrova il fill per order_id) — non si fida della
|
||||||
|
sola risposta dell'ordine;
|
||||||
|
4. estrae la FEE REALE dai trades[] del fill (per i perp inverse la fee e' nel
|
||||||
|
coin di settlement: BTC/ETH → la convertiamo anche in USD col prezzo di fill).
|
||||||
|
|
||||||
|
NB perp inverse Deribit: `amount` e la dimensione posizione sono in USD notional;
|
||||||
|
la fee dei trade e' denominata nel base-coin (BTC/ETH).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
|
||||||
|
# Specifiche contratto (verificate da test.deribit.com/public/get_instrument).
|
||||||
|
# INVERSE (reversed): amount in USD, step in USD (es. BTC $10, ETH $1).
|
||||||
|
# LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001);
|
||||||
|
# il notional USD si converte col prezzo. Fee/settle in USDC.
|
||||||
|
_CONTRACT: dict[str, dict[str, Any]] = {
|
||||||
|
"BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0},
|
||||||
|
"ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0},
|
||||||
|
"BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "settle": "USDC"},
|
||||||
|
"ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "settle": "USDC"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def contract_spec(instrument: str) -> dict[str, Any]:
|
||||||
|
return _CONTRACT.get(instrument, {"linear": False, "min": 1.0, "step": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def settlement_currency(instrument: str) -> str:
|
||||||
|
"""Inverse → base-coin (BTC/ETH); lineari USDC → USDC. Usato per get_positions
|
||||||
|
e per denominare la fee."""
|
||||||
|
spec = contract_spec(instrument)
|
||||||
|
if spec.get("settle"):
|
||||||
|
return spec["settle"]
|
||||||
|
return instrument.split("-")[0].split("_")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def notional_to_amount(instrument: str, notional_usd: float,
|
||||||
|
price: float | None = None) -> float:
|
||||||
|
"""Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo.
|
||||||
|
Inverse: amount in USD (step USD). Lineari USDC: amount in base-coin (serve il
|
||||||
|
`price` per convertire). Ritorna 0.0 se sotto mezzo step (niente ordine)."""
|
||||||
|
spec = contract_spec(instrument)
|
||||||
|
step, mn = spec["step"], spec["min"]
|
||||||
|
if spec.get("linear"):
|
||||||
|
if not price:
|
||||||
|
return 0.0
|
||||||
|
units = notional_usd / price # base-coin richiesti
|
||||||
|
if units < step / 2:
|
||||||
|
return 0.0
|
||||||
|
return max(round(units / step) * step, mn)
|
||||||
|
if notional_usd < step / 2:
|
||||||
|
return 0.0
|
||||||
|
return max(round(notional_usd / step) * step, mn)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Fill:
|
||||||
|
"""Esito verificato di un ordine reale."""
|
||||||
|
instrument: str
|
||||||
|
side: str # "buy" | "sell"
|
||||||
|
requested_notional: float # USD chiesti dalla strategia
|
||||||
|
amount: float # USD effettivi (arrotondati allo step)
|
||||||
|
fill_price: float | None # prezzo medio di esecuzione (da Deribit)
|
||||||
|
fee_coin: float # fee reale nel coin di settlement (BTC/ETH)
|
||||||
|
fee_usd: float # fee reale convertita in USD (fee_coin * fill_price)
|
||||||
|
order_id: str | None
|
||||||
|
order_state: str | None # "filled" atteso per market
|
||||||
|
verified: bool # posizione/trade riscontrati su Deribit
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_fill_price(order: dict, trades: list[dict]) -> float | None:
|
||||||
|
p = order.get("average_price")
|
||||||
|
if p:
|
||||||
|
return float(p)
|
||||||
|
# fallback: media pesata per amount dai trade
|
||||||
|
tot_amt = sum(float(t.get("amount", 0) or 0) for t in trades)
|
||||||
|
if tot_amt > 0:
|
||||||
|
return sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0)
|
||||||
|
for t in trades) / tot_amt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionClient:
|
||||||
|
"""Wrapper d'esecuzione reale sopra CerberoClient. ogni open/close ritorna un
|
||||||
|
Fill VERIFICATO (o verified=False con la ragione in notes)."""
|
||||||
|
client: CerberoClient = field(default_factory=CerberoClient)
|
||||||
|
verify_polls: int = 4 # tentativi di riverifica
|
||||||
|
verify_sleep: float = 0.6 # attesa fra i poll (s)
|
||||||
|
# NB leva: su Deribit la leva per-strumento NON e' impostabile (private/set_leverage
|
||||||
|
# risponde 400 Bad Request — verificato 2026-06-03 nei log Cerbero; il set_leverage
|
||||||
|
# di Cerbero fallisce sempre, soppresso). Il campo "leverage: 50" in get_positions
|
||||||
|
# e' il MASSIMO dello strumento (informativo): l'esposizione reale la decide la SIZE
|
||||||
|
# dell'ordine, non una leva account. Percio' qui NON si passa leverage per-ordine.
|
||||||
|
|
||||||
|
# --- helper di verifica ---
|
||||||
|
|
||||||
|
def _position_size(self, instrument: str) -> float:
|
||||||
|
"""Size assoluta (USD) della posizione aperta sull'instrument, 0 se flat."""
|
||||||
|
cur = settlement_currency(instrument)
|
||||||
|
try:
|
||||||
|
for p in self.client.get_positions(currency=cur):
|
||||||
|
if p.get("instrument") == instrument:
|
||||||
|
return abs(float(p.get("size", 0) or 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _trade_by_order(self, instrument: str, order_id: str | None) -> dict | None:
|
||||||
|
"""Ritrova il fill nel trade history per order_id (fonte autorevole fee)."""
|
||||||
|
if not order_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
for t in self.client.get_trade_history(limit=50, instrument_name=instrument):
|
||||||
|
if str(t.get("order_id")) == str(order_id):
|
||||||
|
return t
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- API ---
|
||||||
|
|
||||||
|
def _mark_price(self, instrument: str) -> float | None:
|
||||||
|
try:
|
||||||
|
t = self.client.get_ticker(instrument)
|
||||||
|
return float(t.get("mark_price") or t.get("last_price") or 0) or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def amount_for(self, instrument: str, notional_usd: float) -> float:
|
||||||
|
"""Notional USD → amount Deribit (gestisce inverse/lineare, prezzo per i lineari)."""
|
||||||
|
spec = contract_spec(instrument)
|
||||||
|
price = self._mark_price(instrument) if spec.get("linear") else None
|
||||||
|
return notional_to_amount(instrument, notional_usd, price=price)
|
||||||
|
|
||||||
|
def _submit(self, instrument: str, side: str, amount: float,
|
||||||
|
requested_notional: float, reduce_only: bool,
|
||||||
|
label: str | None) -> Fill:
|
||||||
|
"""Market order REALE + parsing del fill. Verifica per-worker basata sul
|
||||||
|
TRADE (order_id/trades), non sulla size netta — lo strumento e' condiviso
|
||||||
|
fra piu' worker e la posizione su Deribit e' aggregata per conto."""
|
||||||
|
spec = contract_spec(instrument)
|
||||||
|
if amount <= 0:
|
||||||
|
return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0,
|
||||||
|
None, None, False, notes="notional sotto il minimo contratto")
|
||||||
|
|
||||||
|
resp = self.client.place_order(instrument, side, amount, order_type="market",
|
||||||
|
label=label, reduce_only=reduce_only)
|
||||||
|
if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp:
|
||||||
|
return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0,
|
||||||
|
None, "error", False, raw=resp if isinstance(resp, dict) else {},
|
||||||
|
notes=f"place_order error: {resp}")
|
||||||
|
|
||||||
|
order = resp.get("order", resp) or {}
|
||||||
|
trades = resp.get("trades", []) or []
|
||||||
|
order_id = order.get("order_id")
|
||||||
|
state = order.get("order_state")
|
||||||
|
fill_price = _avg_fill_price(order, trades)
|
||||||
|
|
||||||
|
# fee reale dai trade del fill (coin di settlement)
|
||||||
|
fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades)
|
||||||
|
# riconciliazione su trade history per order_id (fonte autorevole)
|
||||||
|
th = self._trade_by_order(instrument, order_id)
|
||||||
|
if fee_coin == 0 and th and th.get("fee") is not None:
|
||||||
|
fee_coin = float(th["fee"])
|
||||||
|
if fill_price is None and th:
|
||||||
|
fill_price = float(th.get("price") or 0) or None
|
||||||
|
# lineari USDC: fee gia' in USDC; inverse: nel base-coin → * prezzo
|
||||||
|
fee_usd = fee_coin if spec.get("linear") else (
|
||||||
|
fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
|
||||||
|
|
||||||
|
# VERIFICA esecuzione = ordine filled E fill riscontrato (trades o trade history)
|
||||||
|
verified = (state == "filled") and (bool(trades) or th is not None)
|
||||||
|
return Fill(instrument, side, requested_notional, amount, fill_price,
|
||||||
|
fee_coin, fee_usd, order_id, state, verified, raw=resp,
|
||||||
|
notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})")
|
||||||
|
|
||||||
|
def open(self, instrument: str, side: str, notional_usd: float,
|
||||||
|
label: str | None = None) -> Fill:
|
||||||
|
"""Apre la quota del worker (market, NON reduce_only)."""
|
||||||
|
amount = self.amount_for(instrument, notional_usd)
|
||||||
|
return self._submit(instrument, side, amount, notional_usd,
|
||||||
|
reduce_only=False, label=label)
|
||||||
|
|
||||||
|
def close_amount(self, instrument: str, entry_side: str, amount: float,
|
||||||
|
label: str | None = None) -> Fill:
|
||||||
|
"""Chiude SOLO la quota del worker: market reduce_only di lato opposto,
|
||||||
|
stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche
|
||||||
|
le quote degli altri worker sullo stesso strumento)."""
|
||||||
|
opp = "sell" if entry_side == "buy" else "buy"
|
||||||
|
return self._submit(instrument, opp, amount, 0.0,
|
||||||
|
reduce_only=True, label=label)
|
||||||
|
|
||||||
|
def close(self, instrument: str, label: str | None = None) -> Fill:
|
||||||
|
"""Chiude a mercato la posizione e riverifica che il conto sia flat,
|
||||||
|
leggendo la fee di chiusura dal trade history."""
|
||||||
|
side = "close"
|
||||||
|
resp = self.client.close_position(instrument)
|
||||||
|
if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp:
|
||||||
|
return Fill(instrument, side, 0.0, 0.0, None, 0.0, 0.0, None, "error",
|
||||||
|
False, raw=resp if isinstance(resp, dict) else {},
|
||||||
|
notes=f"close error: {resp}")
|
||||||
|
order_id = resp.get("order_id")
|
||||||
|
|
||||||
|
# fee/prezzo di chiusura dal trade history (close_position non li ritorna)
|
||||||
|
th = self._trade_by_order(instrument, order_id)
|
||||||
|
fee_coin = float(th["fee"]) if th and th.get("fee") is not None else 0.0
|
||||||
|
fill_price = float(th.get("price")) if th and th.get("price") else None
|
||||||
|
if contract_spec(instrument).get("linear"):
|
||||||
|
fee_usd = fee_coin
|
||||||
|
else:
|
||||||
|
fee_usd = fee_coin * fill_price if (fee_coin and fill_price) else 0.0
|
||||||
|
|
||||||
|
# verifica: la posizione deve essere tornata flat
|
||||||
|
pos = 1.0
|
||||||
|
for _ in range(self.verify_polls):
|
||||||
|
pos = self._position_size(instrument)
|
||||||
|
if pos == 0:
|
||||||
|
break
|
||||||
|
time.sleep(self.verify_sleep)
|
||||||
|
verified = pos == 0
|
||||||
|
|
||||||
|
return Fill(instrument, side, 0.0, 0.0, fill_price, fee_coin, fee_usd,
|
||||||
|
order_id, resp.get("state"), verified, raw=resp,
|
||||||
|
notes="" if verified else f"posizione non flat dopo close (pos={pos})")
|
||||||
@@ -11,6 +11,7 @@ import pandas as pd
|
|||||||
from src.live.cerbero_client import CerberoClient
|
from src.live.cerbero_client import CerberoClient
|
||||||
from src.live.strategy_loader import load_strategy
|
from src.live.strategy_loader import load_strategy
|
||||||
from src.live.strategy_worker import StrategyWorker
|
from src.live.strategy_worker import StrategyWorker
|
||||||
|
from src.live.pairs_worker import PairsWorker
|
||||||
from src.live.signal_engine import SignalEngine
|
from src.live.signal_engine import SignalEngine
|
||||||
from src.live.telegram_notifier import send_telegram
|
from src.live.telegram_notifier import send_telegram
|
||||||
|
|
||||||
@@ -18,9 +19,20 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|||||||
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
|
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
|
||||||
|
|
||||||
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
|
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
|
||||||
|
# Convenzione Deribit (verificata via Cerbero, 2026-05-29):
|
||||||
|
# - BTC/ETH = perpetui INVERSE (margine coin): "<COIN>-PERPETUAL"
|
||||||
|
# - altcoin = perpetui LINEARI USDC (margine USDC): "<COIN>_USDC-PERPETUAL", storia dal 2022
|
||||||
|
# Trappola: "LTC-PERPETUAL"/"ADA-PERPETUAL" = 0 candele; "SOL-PERPETUAL" = contratto vecchio
|
||||||
|
# con dati sbagliati. Per gli alt usare SEMPRE la forma _USDC-PERPETUAL.
|
||||||
INSTRUMENT_MAP = {
|
INSTRUMENT_MAP = {
|
||||||
"BTC": "BTC-PERPETUAL",
|
"BTC": "BTC-PERPETUAL",
|
||||||
"ETH": "ETH-PERPETUAL",
|
"ETH": "ETH-PERPETUAL",
|
||||||
|
"SOL": "SOL_USDC-PERPETUAL",
|
||||||
|
"LTC": "LTC_USDC-PERPETUAL",
|
||||||
|
"ADA": "ADA_USDC-PERPETUAL",
|
||||||
|
"XRP": "XRP_USDC-PERPETUAL",
|
||||||
|
"BNB": "BNB_USDC-PERPETUAL",
|
||||||
|
"DOGE": "DOGE_USDC-PERPETUAL",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -130,6 +142,26 @@ def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrap
|
|||||||
return regular_workers, ml_workers
|
return regular_workers, ml_workers
|
||||||
|
|
||||||
|
|
||||||
|
def build_pairs_workers(config: dict) -> list[PairsWorker]:
|
||||||
|
"""Crea i PairsWorker (2 gambe) dalla sezione `pairs:` dello YAML."""
|
||||||
|
defaults = config.get("defaults", {})
|
||||||
|
workers: list[PairsWorker] = []
|
||||||
|
for entry in config.get("pairs", []):
|
||||||
|
if not entry.get("enabled", True):
|
||||||
|
continue
|
||||||
|
workers.append(PairsWorker(
|
||||||
|
asset_a=entry["a"], asset_b=entry["b"], tf=entry.get("tf", "1h"),
|
||||||
|
params=entry.get("params", {}),
|
||||||
|
capital=entry.get("capital", defaults.get("capital", 1000)),
|
||||||
|
position_size=entry.get("position_size", defaults.get("position_size", 0.15)),
|
||||||
|
leverage=entry.get("leverage", defaults.get("leverage", 3)),
|
||||||
|
fee_rt=entry.get("fee_rt", 0.001),
|
||||||
|
name=entry.get("name", "PR01_pairs_reversion"),
|
||||||
|
data_dir=DATA_DIR,
|
||||||
|
))
|
||||||
|
return workers
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
config_path = PROJECT_ROOT / "strategies.yml"
|
config_path = PROJECT_ROOT / "strategies.yml"
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
@@ -143,7 +175,8 @@ def run():
|
|||||||
train_lookback_days = 365
|
train_lookback_days = 365
|
||||||
|
|
||||||
regular_workers, ml_workers = build_workers(config)
|
regular_workers, ml_workers = build_workers(config)
|
||||||
all_worker_count = len(regular_workers) + len(ml_workers)
|
pairs_workers = build_pairs_workers(config)
|
||||||
|
all_worker_count = len(regular_workers) + len(ml_workers) + len(pairs_workers)
|
||||||
|
|
||||||
if all_worker_count == 0:
|
if all_worker_count == 0:
|
||||||
print("Nessuna strategia abilitata in strategies.yml")
|
print("Nessuna strategia abilitata in strategies.yml")
|
||||||
@@ -162,6 +195,8 @@ def run():
|
|||||||
print(f" • {w.status_summary}")
|
print(f" • {w.status_summary}")
|
||||||
for mw in ml_workers:
|
for mw in ml_workers:
|
||||||
print(f" • {mw.worker.status_summary} [ML]")
|
print(f" • {mw.worker.status_summary} [ML]")
|
||||||
|
for pw in pairs_workers:
|
||||||
|
print(f" • {pw.status_summary} [PAIRS]")
|
||||||
|
|
||||||
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
|
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
|
||||||
|
|
||||||
@@ -172,6 +207,9 @@ def run():
|
|||||||
keys.add((w.asset, w.tf))
|
keys.add((w.asset, w.tf))
|
||||||
for mw in ml_workers:
|
for mw in ml_workers:
|
||||||
keys.add((mw.worker.asset, mw.worker.tf))
|
keys.add((mw.worker.asset, mw.worker.tf))
|
||||||
|
for pw in pairs_workers: # entrambe le gambe del pair
|
||||||
|
keys.add((pw.asset_a, pw.tf))
|
||||||
|
keys.add((pw.asset_b, pw.tf))
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
# Training iniziale ML
|
# Training iniziale ML
|
||||||
@@ -253,6 +291,15 @@ def run():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
|
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
|
||||||
|
|
||||||
|
# Tick pairs workers (2 gambe)
|
||||||
|
for pw in pairs_workers:
|
||||||
|
ka, kb = (pw.asset_a, pw.tf), (pw.asset_b, pw.tf)
|
||||||
|
if ka in candle_cache and kb in candle_cache:
|
||||||
|
try:
|
||||||
|
pw.tick(candle_cache[ka], candle_cache[kb])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{pw.worker_id}] ERRORE: {e}")
|
||||||
|
|
||||||
# Status periodico
|
# Status periodico
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if now.minute == 0 and now.second < poll_seconds:
|
if now.minute == 0 and now.second < poll_seconds:
|
||||||
@@ -261,6 +308,8 @@ def run():
|
|||||||
lines.append(f" {w.status_summary}")
|
lines.append(f" {w.status_summary}")
|
||||||
for mw in ml_workers:
|
for mw in ml_workers:
|
||||||
lines.append(f" {mw.worker.status_summary} [ML]")
|
lines.append(f" {mw.worker.status_summary} [ML]")
|
||||||
|
for pw in pairs_workers:
|
||||||
|
lines.append(f" {pw.status_summary} [PAIRS]")
|
||||||
send_telegram("\n".join(lines))
|
send_telegram("\n".join(lines))
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -277,6 +326,8 @@ def run():
|
|||||||
if df is not None and not df.empty:
|
if df is not None and not df.empty:
|
||||||
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
|
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
|
||||||
mw.worker._save_state()
|
mw.worker._save_state()
|
||||||
|
for pw in pairs_workers: # salva stato; non forzo la chiusura a 2 gambe
|
||||||
|
pw._save_state()
|
||||||
send_telegram("🛑 Multi-Strategy arrestato")
|
send_telegram("🛑 Multi-Strategy arrestato")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""PairsWorker — paper trading a 2 GAMBE per la famiglia PR01 (spread reversion).
|
||||||
|
|
||||||
|
Market-neutral: long asset A / short asset B (o viceversa) sullo z-score del log-ratio.
|
||||||
|
Distinto dallo StrategyWorker single-leg: gestisce due strumenti, due prezzi di
|
||||||
|
ingresso, e conta le fee su ENTRAMBE le gambe (2*fee_rt*lev = 0.20% RT/coppia con
|
||||||
|
fee_rt=0.001). Semantica identica al backtest scripts/analysis/pairs_research.pairs_sim:
|
||||||
|
|
||||||
|
r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale)
|
||||||
|
ENTRY a close[i]: z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio
|
||||||
|
EXIT: |z| <= z_exit (rientro) oppure time-limit max_bars
|
||||||
|
filtro candele sporche: salta l'ingresso se |dr[i]| > jump_max
|
||||||
|
PnL = (retA - retB) * direction * lev - 2*fee_rt*lev (notional uguale per gamba)
|
||||||
|
|
||||||
|
Stato persistente (resume al restart) e log come StrategyWorker.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.live.telegram_notifier import notify_event
|
||||||
|
|
||||||
|
|
||||||
|
class PairsWorker:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
asset_a: str,
|
||||||
|
asset_b: str,
|
||||||
|
tf: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
capital: float = 1000.0,
|
||||||
|
position_size: float = 0.15,
|
||||||
|
leverage: float = 3.0,
|
||||||
|
fee_rt: float = 0.001, # per gamba RT; la coppia paga 2x
|
||||||
|
name: str = "PR01_pairs_reversion",
|
||||||
|
data_dir: Path = Path("data/paper_trades"),
|
||||||
|
):
|
||||||
|
self.asset_a = asset_a
|
||||||
|
self.asset_b = asset_b
|
||||||
|
self.tf = tf
|
||||||
|
self.name = name
|
||||||
|
p = params or {}
|
||||||
|
self.n = int(p.get("n", 50))
|
||||||
|
self.z_in = float(p.get("z_in", 2.0))
|
||||||
|
self.z_exit = float(p.get("z_exit", 0.75))
|
||||||
|
self.max_bars = int(p.get("max_bars", 72))
|
||||||
|
self.jump_max = float(p.get("jump_max", 0.08))
|
||||||
|
|
||||||
|
self.initial_capital = capital
|
||||||
|
self.position_size = position_size
|
||||||
|
self.leverage = leverage
|
||||||
|
self.fee_rt = fee_rt
|
||||||
|
|
||||||
|
self.worker_id = f"{name}__{asset_a}_{asset_b}__{tf}"
|
||||||
|
self.work_dir = data_dir / self.worker_id
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
|
||||||
|
self.capital = capital
|
||||||
|
self.in_position = False
|
||||||
|
self.direction = 0 # +1 long ratio (long A/short B), -1 short ratio
|
||||||
|
self.entry_a = 0.0
|
||||||
|
self.entry_b = 0.0
|
||||||
|
self.entry_z = 0.0
|
||||||
|
self.entry_time = ""
|
||||||
|
self.bars_held = 0
|
||||||
|
self.total_trades = 0
|
||||||
|
self.total_wins = 0
|
||||||
|
self.last_bar_ts = 0
|
||||||
|
self.started_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
self._load_state()
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
# ---------------- persistenza ----------------
|
||||||
|
def _load_state(self):
|
||||||
|
if not self.status_path.exists():
|
||||||
|
self._log("INIT", {"capital": self.capital, "pair": f"{self.asset_a}/{self.asset_b}",
|
||||||
|
"tf": self.tf, "params": {"n": self.n, "z_in": self.z_in,
|
||||||
|
"z_exit": self.z_exit, "max_bars": self.max_bars}})
|
||||||
|
return
|
||||||
|
with open(self.status_path) as f:
|
||||||
|
s = json.load(f)
|
||||||
|
self.capital = s.get("capital", self.initial_capital)
|
||||||
|
self.in_position = s.get("in_position", False)
|
||||||
|
self.direction = s.get("direction", 0)
|
||||||
|
self.entry_a = s.get("entry_a", 0.0)
|
||||||
|
self.entry_b = s.get("entry_b", 0.0)
|
||||||
|
self.entry_z = s.get("entry_z", 0.0)
|
||||||
|
self.entry_time = s.get("entry_time", "")
|
||||||
|
self.bars_held = s.get("bars_held", 0)
|
||||||
|
self.total_trades = s.get("total_trades", 0)
|
||||||
|
self.total_wins = s.get("total_wins", 0)
|
||||||
|
self.last_bar_ts = s.get("last_bar_ts", 0)
|
||||||
|
self.started_at = s.get("started_at", self.started_at)
|
||||||
|
self._log("RESUME", {"capital": round(self.capital, 2),
|
||||||
|
"total_trades": self.total_trades, "in_position": self.in_position})
|
||||||
|
|
||||||
|
def _save_state(self):
|
||||||
|
state = {
|
||||||
|
"capital": round(self.capital, 2), "in_position": self.in_position,
|
||||||
|
"direction": self.direction, "entry_a": self.entry_a, "entry_b": self.entry_b,
|
||||||
|
"entry_z": round(self.entry_z, 4), "entry_time": self.entry_time,
|
||||||
|
"bars_held": self.bars_held, "total_trades": self.total_trades,
|
||||||
|
"total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts,
|
||||||
|
"started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
with open(self.status_path, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
def _log(self, event: str, data: dict | None = None):
|
||||||
|
entry = {"ts": datetime.now(timezone.utc).isoformat(), "worker": self.worker_id,
|
||||||
|
"event": event, **(data or {})}
|
||||||
|
with open(self.trades_path, "a") as f:
|
||||||
|
f.write(json.dumps(entry) + "\n")
|
||||||
|
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
|
||||||
|
|
||||||
|
def _notify(self, event: str, data: dict | None = None):
|
||||||
|
notify_event(event, {"worker": self.worker_id, **(data or {})})
|
||||||
|
|
||||||
|
# ---------------- segnale ----------------
|
||||||
|
def _zscore(self, ca: np.ndarray, cb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
r = np.log(ca / cb)
|
||||||
|
ma = pd.Series(r).rolling(self.n).mean().values
|
||||||
|
sd = pd.Series(r).rolling(self.n).std().values
|
||||||
|
z = (r - ma) / np.where(sd == 0, np.nan, sd)
|
||||||
|
dr = np.abs(np.diff(r, prepend=r[0]))
|
||||||
|
return z, dr
|
||||||
|
|
||||||
|
# ---------------- trading ----------------
|
||||||
|
def _open(self, d: int, ca: float, cb: float, z: float):
|
||||||
|
self.in_position = True
|
||||||
|
self.direction = d
|
||||||
|
self.entry_a, self.entry_b, self.entry_z = ca, cb, z
|
||||||
|
self.entry_time = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.bars_held = 0
|
||||||
|
data = {"direction": "long_ratio" if d == 1 else "short_ratio",
|
||||||
|
"long_leg": self.asset_a if d == 1 else self.asset_b,
|
||||||
|
"short_leg": self.asset_b if d == 1 else self.asset_a,
|
||||||
|
"entry_a": round(ca, 4), "entry_b": round(cb, 4), "z": round(z, 3),
|
||||||
|
"capital": round(self.capital, 2)}
|
||||||
|
self._log("OPEN", data); self._notify("OPENED", data)
|
||||||
|
|
||||||
|
def _close(self, ca: float, cb: float, z: float, reason: str):
|
||||||
|
if not self.in_position:
|
||||||
|
return
|
||||||
|
ret_a = (ca - self.entry_a) / self.entry_a
|
||||||
|
ret_b = (cb - self.entry_b) / self.entry_b
|
||||||
|
gross = (ret_a - ret_b) * self.direction * self.leverage
|
||||||
|
fee = 2 * self.fee_rt * self.leverage # 2 gambe
|
||||||
|
net = gross - fee
|
||||||
|
pnl = self.capital * self.position_size * net
|
||||||
|
self.capital = max(self.capital + pnl, 0.0)
|
||||||
|
is_win = net > 0
|
||||||
|
self.total_trades += 1
|
||||||
|
self.total_wins += is_win
|
||||||
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||||
|
data = {"reason": reason, "exit_a": round(ca, 4), "exit_b": round(cb, 4),
|
||||||
|
"z": round(z, 3), "gross_ret": round(gross * 100, 3), "fee": round(fee * 100, 3),
|
||||||
|
"net_return": round(net * 100, 3), "pnl": round(pnl, 2),
|
||||||
|
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
||||||
|
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
|
||||||
|
self._log("CLOSE", data); self._notify("CLOSED", data)
|
||||||
|
self.in_position = False
|
||||||
|
self.direction = 0
|
||||||
|
self.entry_a = self.entry_b = self.entry_z = 0.0
|
||||||
|
self.bars_held = 0
|
||||||
|
|
||||||
|
def tick(self, df_a: pd.DataFrame, df_b: pd.DataFrame):
|
||||||
|
"""Chiamato ad ogni poll con gli OHLCV aggiornati delle due gambe."""
|
||||||
|
if df_a is None or df_b is None or df_a.empty or df_b.empty:
|
||||||
|
return
|
||||||
|
m = df_a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge(
|
||||||
|
df_b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp", how="inner"
|
||||||
|
).sort_values("timestamp").reset_index(drop=True)
|
||||||
|
if len(m) < self.n + 2:
|
||||||
|
return
|
||||||
|
ca, cb = m["ca"].values, m["cb"].values
|
||||||
|
z, dr = self._zscore(ca, cb)
|
||||||
|
i = len(m) - 1
|
||||||
|
cur_ts = int(m["timestamp"].iloc[i])
|
||||||
|
zi = z[i]
|
||||||
|
if np.isnan(zi):
|
||||||
|
self._save_state(); return
|
||||||
|
|
||||||
|
if self.in_position:
|
||||||
|
if cur_ts > self.last_bar_ts:
|
||||||
|
self.bars_held += 1
|
||||||
|
self.last_bar_ts = cur_ts
|
||||||
|
if abs(zi) <= self.z_exit:
|
||||||
|
self._close(float(ca[i]), float(cb[i]), float(zi), "mean_revert")
|
||||||
|
elif self.bars_held >= self.max_bars:
|
||||||
|
self._close(float(ca[i]), float(cb[i]), float(zi), "time_limit")
|
||||||
|
self._save_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
# flat: cerca ingresso (no look-ahead: z[i] usa solo dati <= i)
|
||||||
|
if dr[i] <= self.jump_max:
|
||||||
|
if zi <= -self.z_in:
|
||||||
|
self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
|
||||||
|
elif zi >= self.z_in:
|
||||||
|
self._open(-1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_summary(self) -> str:
|
||||||
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||||
|
pos = ("LONG " + self.asset_a if self.direction == 1
|
||||||
|
else "SHORT " + self.asset_a if self.direction == -1 else "FLAT")
|
||||||
|
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {pos}")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""RotationWorker (ROT02): dual-momentum top-k risk-gated, ribilancio giornaliero.
|
||||||
|
Replica live di honest_improve2._rot_daily_equity (lookback 60, top_k 3, gross 0.45, SMA100 gate)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
FEE_RT = 0.001
|
||||||
|
|
||||||
|
|
||||||
|
def _panel(data: dict, universe: list):
|
||||||
|
"""Allinea {asset: df} sui timestamp comuni -> (df_panel, cols presenti)."""
|
||||||
|
frames = {}
|
||||||
|
for a in universe:
|
||||||
|
df = data.get(a)
|
||||||
|
if df is not None and len(df):
|
||||||
|
frames[a] = df[["timestamp", "close"]].rename(columns={"close": a})
|
||||||
|
if not frames:
|
||||||
|
return None, []
|
||||||
|
panel = None
|
||||||
|
for a, f in frames.items():
|
||||||
|
panel = f if panel is None else panel.merge(f, on="timestamp", how="inner")
|
||||||
|
panel = panel.sort_values("timestamp").reset_index(drop=True)
|
||||||
|
cols = [a for a in universe if a in frames]
|
||||||
|
return panel, cols
|
||||||
|
|
||||||
|
|
||||||
|
class RotationWorker:
|
||||||
|
def __init__(self, universe, lookback=60, top_k=3, gross=0.45, regime_n=100,
|
||||||
|
tf="1d", capital=1000.0, fee_rt=FEE_RT, name="ROT02_rot",
|
||||||
|
data_dir=Path("data/portfolio_paper")):
|
||||||
|
self.universe = list(universe)
|
||||||
|
self.lookback = lookback
|
||||||
|
self.top_k = top_k
|
||||||
|
self.gross = gross
|
||||||
|
self.regime_n = regime_n
|
||||||
|
self.tf = tf
|
||||||
|
self.initial_capital = capital
|
||||||
|
self.capital = capital
|
||||||
|
self.fee_rt = fee_rt
|
||||||
|
self.worker_id = f"{name}__{tf}"
|
||||||
|
self.work_dir = Path(data_dir) / self.worker_id
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
self.weights = {a: 0.0 for a in self.universe}
|
||||||
|
self.last_bar_ts = 0
|
||||||
|
self.in_position = False
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if self.status_path.exists():
|
||||||
|
s = json.loads(self.status_path.read_text())
|
||||||
|
self.capital = s.get("capital", self.capital)
|
||||||
|
self.weights = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
|
||||||
|
self.last_bar_ts = s.get("last_bar_ts", 0)
|
||||||
|
self.in_position = any(v > 0 for v in self.weights.values())
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.status_path.write_text(json.dumps({
|
||||||
|
"capital": round(self.capital, 2), "weights": self.weights,
|
||||||
|
"last_bar_ts": self.last_bar_ts,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
|
||||||
|
|
||||||
|
def tick(self, data: dict):
|
||||||
|
panel, cols = _panel(data, self.universe)
|
||||||
|
if panel is None or len(panel) < max(self.lookback + 1, self.regime_n + 1) or "BTC" not in cols:
|
||||||
|
return
|
||||||
|
P = panel[cols].values
|
||||||
|
bar_ts = int(panel["timestamp"].iloc[-1])
|
||||||
|
# 1) realizza il rendimento dei pesi correnti sull'ultima barra chiusa
|
||||||
|
if self.last_bar_ts and bar_ts > self.last_bar_ts:
|
||||||
|
day_ret = P[-1] / P[-2] - 1.0
|
||||||
|
port_r = sum(self.weights.get(cols[k], 0.0) * day_ret[k] for k in range(len(cols)))
|
||||||
|
self.capital = max(self.capital * (1.0 + float(port_r)), 10.0)
|
||||||
|
# 2) ricalcola pesi target
|
||||||
|
btc = P[:, cols.index("BTC")]
|
||||||
|
bma = pd.Series(btc).rolling(self.regime_n).mean().values
|
||||||
|
risk_on = btc[-1] > bma[-1] if not np.isnan(bma[-1]) else False
|
||||||
|
mom = P[-1] / P[-1 - self.lookback] - 1.0
|
||||||
|
order = np.argsort(mom)[::-1]
|
||||||
|
chosen = [k for k in order if mom[k] > 0][: self.top_k] if risk_on else []
|
||||||
|
nw = {a: 0.0 for a in self.universe}
|
||||||
|
for k in chosen:
|
||||||
|
nw[cols[k]] = self.gross / len(chosen)
|
||||||
|
# 3) fee sul turnover
|
||||||
|
turnover = sum(abs(nw[a] - self.weights.get(a, 0.0)) for a in self.universe)
|
||||||
|
self.capital -= self.capital * turnover * (self.fee_rt / 2)
|
||||||
|
if turnover > 0:
|
||||||
|
self._log(nw, float(self.capital))
|
||||||
|
self.weights = nw
|
||||||
|
self.last_bar_ts = bar_ts
|
||||||
|
self.in_position = any(v > 0 for v in nw.values())
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _log(self, weights, cap):
|
||||||
|
with open(self.trades_path, "a") as f:
|
||||||
|
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"weights": {a: round(w, 4) for a, w in weights.items() if w > 0},
|
||||||
|
"capital": round(cap, 2)}) + "\n")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_summary(self):
|
||||||
|
held = {a: round(w, 3) for a, w in self.weights.items() if w > 0}
|
||||||
|
return f"{self.worker_id}: cap={self.capital:.0f} held={held}"
|
||||||
@@ -17,9 +17,14 @@ _REGISTRY: dict[str, type[Strategy]] = {}
|
|||||||
# scripts/waste/: l'edge storico era un artefatto di look-ahead
|
# scripts/waste/: l'edge storico era un artefatto di look-ahead
|
||||||
# (vedi scripts/analysis/oos_validation.py).
|
# (vedi scripts/analysis/oos_validation.py).
|
||||||
MODULE_MAP = {
|
MODULE_MAP = {
|
||||||
|
"DIP01_dip_buy": ("DIP01_dip_buy", "Dip01DipBuy"),
|
||||||
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
||||||
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
|
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
|
||||||
"MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"),
|
"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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+145
-11
@@ -10,6 +10,7 @@ import pandas as pd
|
|||||||
|
|
||||||
from src.strategies.base import Strategy, Signal
|
from src.strategies.base import Strategy, Signal
|
||||||
from src.live.telegram_notifier import notify_event
|
from src.live.telegram_notifier import notify_event
|
||||||
|
from src.live.execution import ExecutionClient
|
||||||
|
|
||||||
FEE_RT = 0.002
|
FEE_RT = 0.002
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ class StrategyWorker:
|
|||||||
hold_bars: int = 3,
|
hold_bars: int = 3,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
data_dir: Path = Path("data/paper_trades"),
|
data_dir: Path = Path("data/paper_trades"),
|
||||||
|
executor: ExecutionClient | None = None,
|
||||||
|
exec_instrument: str | None = None,
|
||||||
):
|
):
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
self.asset = asset
|
self.asset = asset
|
||||||
@@ -38,6 +41,22 @@ class StrategyWorker:
|
|||||||
self.hold_bars = hold_bars
|
self.hold_bars = hold_bars
|
||||||
self.params = params or {}
|
self.params = params or {}
|
||||||
|
|
||||||
|
# --- Esecuzione REALE (shadow): se attiva, ogni open/close sim e' affiancato
|
||||||
|
# da un ordine reale su Deribit (lineare USDC), con ledger reale parallelo. ---
|
||||||
|
self.executor = executor
|
||||||
|
self.exec_instrument = exec_instrument
|
||||||
|
self.execution_enabled = bool(executor and exec_instrument)
|
||||||
|
self.real_capital = capital
|
||||||
|
self.real_in_position = False
|
||||||
|
self.real_side = "" # "buy" | "sell" dell'apertura reale
|
||||||
|
self.real_amount = 0.0 # amount Deribit (base-coin) da richiudere
|
||||||
|
self.real_entry_price = 0.0
|
||||||
|
self.real_entry_fee_usd = 0.0
|
||||||
|
self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata
|
||||||
|
self.real_order_id = ""
|
||||||
|
self.real_trades = 0
|
||||||
|
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
|
||||||
|
|
||||||
self.worker_id = f"{strategy.name}__{asset}__{tf}"
|
self.worker_id = f"{strategy.name}__{asset}__{tf}"
|
||||||
self.work_dir = data_dir / self.worker_id
|
self.work_dir = data_dir / self.worker_id
|
||||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -89,9 +108,22 @@ class StrategyWorker:
|
|||||||
self.sl = state.get("sl", 0.0)
|
self.sl = state.get("sl", 0.0)
|
||||||
self.max_bars = state.get("max_bars", 0)
|
self.max_bars = state.get("max_bars", 0)
|
||||||
|
|
||||||
|
self.real_capital = state.get("real_capital", self.initial_capital)
|
||||||
|
self.real_in_position = state.get("real_in_position", False)
|
||||||
|
self.real_side = state.get("real_side", "")
|
||||||
|
self.real_amount = state.get("real_amount", 0.0)
|
||||||
|
self.real_entry_price = state.get("real_entry_price", 0.0)
|
||||||
|
self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0)
|
||||||
|
self.real_entry_notional = state.get("real_entry_notional", 0.0)
|
||||||
|
self.real_order_id = state.get("real_order_id", "")
|
||||||
|
self.real_trades = state.get("real_trades", 0)
|
||||||
|
self.real_first_notified = state.get("real_first_notified", False)
|
||||||
|
|
||||||
self._log("RESUME", {"capital": round(self.capital, 2),
|
self._log("RESUME", {"capital": round(self.capital, 2),
|
||||||
"total_trades": self.total_trades,
|
"total_trades": self.total_trades,
|
||||||
"in_position": self.in_position})
|
"in_position": self.in_position,
|
||||||
|
"real_capital": round(self.real_capital, 2),
|
||||||
|
"real_in_position": self.real_in_position})
|
||||||
|
|
||||||
def _save_state(self):
|
def _save_state(self):
|
||||||
state = {
|
state = {
|
||||||
@@ -108,6 +140,16 @@ class StrategyWorker:
|
|||||||
"tp": self.tp,
|
"tp": self.tp,
|
||||||
"sl": self.sl,
|
"sl": self.sl,
|
||||||
"max_bars": self.max_bars,
|
"max_bars": self.max_bars,
|
||||||
|
"real_capital": round(self.real_capital, 4),
|
||||||
|
"real_in_position": self.real_in_position,
|
||||||
|
"real_side": self.real_side,
|
||||||
|
"real_amount": self.real_amount,
|
||||||
|
"real_entry_price": self.real_entry_price,
|
||||||
|
"real_entry_fee_usd": self.real_entry_fee_usd,
|
||||||
|
"real_entry_notional": self.real_entry_notional,
|
||||||
|
"real_order_id": self.real_order_id,
|
||||||
|
"real_trades": self.real_trades,
|
||||||
|
"real_first_notified": self.real_first_notified,
|
||||||
"last_update": datetime.now(timezone.utc).isoformat(),
|
"last_update": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
with open(self.status_path, "w") as f:
|
with open(self.status_path, "w") as f:
|
||||||
@@ -155,6 +197,87 @@ class StrategyWorker:
|
|||||||
self._log("OPEN", trade_data)
|
self._log("OPEN", trade_data)
|
||||||
self._notify("OPENED", trade_data)
|
self._notify("OPENED", trade_data)
|
||||||
|
|
||||||
|
if self.execution_enabled:
|
||||||
|
self._real_open(signal.direction, current_price, notional)
|
||||||
|
|
||||||
|
def _real_open(self, direction: int, sim_price: float, notional: float):
|
||||||
|
"""Apertura REALE (shadow) accanto al fill simulato. Logga il confronto
|
||||||
|
prezzo-sim vs prezzo-eseguito e la fee reale Deribit."""
|
||||||
|
from src.live.execution import contract_spec
|
||||||
|
side = "buy" if direction == 1 else "sell"
|
||||||
|
fill = self.executor.open(self.exec_instrument, side, notional, label=self.worker_id)
|
||||||
|
|
||||||
|
slip_bps = ((fill.fill_price / sim_price - 1) * 1e4
|
||||||
|
if fill.fill_price and sim_price else None)
|
||||||
|
data = {
|
||||||
|
"instrument": self.exec_instrument,
|
||||||
|
"side": side,
|
||||||
|
"order_id": fill.order_id,
|
||||||
|
"amount": fill.amount,
|
||||||
|
"sim_price": round(sim_price, 2),
|
||||||
|
"real_fill": fill.fill_price,
|
||||||
|
"slippage_bps": round(slip_bps, 2) if slip_bps is not None else None,
|
||||||
|
"fee_usd": round(fill.fee_usd, 5),
|
||||||
|
"verified": fill.verified,
|
||||||
|
}
|
||||||
|
if fill.verified:
|
||||||
|
linear = contract_spec(self.exec_instrument).get("linear")
|
||||||
|
self.real_in_position = True
|
||||||
|
self.real_side = side
|
||||||
|
self.real_amount = fill.amount
|
||||||
|
self.real_entry_price = fill.fill_price or sim_price
|
||||||
|
self.real_entry_fee_usd = fill.fee_usd
|
||||||
|
self.real_entry_notional = (fill.amount * self.real_entry_price
|
||||||
|
if linear else fill.amount)
|
||||||
|
self.real_order_id = fill.order_id or ""
|
||||||
|
self._log("REAL_OPEN", data)
|
||||||
|
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
|
||||||
|
self._notify("REAL_EXEC_LIVE", data)
|
||||||
|
self.real_first_notified = True
|
||||||
|
else:
|
||||||
|
self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes})
|
||||||
|
self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes})
|
||||||
|
|
||||||
|
def _real_close(self, sim_exit: float, reason: str, sim_pnl: float):
|
||||||
|
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim."""
|
||||||
|
if not self.real_in_position:
|
||||||
|
return
|
||||||
|
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
|
||||||
|
self.real_amount, label=self.worker_id)
|
||||||
|
exit_price = fill.fill_price or sim_exit
|
||||||
|
rdir = 1 if self.real_side == "buy" else -1
|
||||||
|
price_change = (exit_price - self.real_entry_price) / self.real_entry_price \
|
||||||
|
if self.real_entry_price else 0.0
|
||||||
|
real_gross = rdir * price_change * self.real_entry_notional
|
||||||
|
real_fees = self.real_entry_fee_usd + fill.fee_usd
|
||||||
|
real_pnl = real_gross - real_fees
|
||||||
|
self.real_capital += real_pnl
|
||||||
|
self.real_trades += 1
|
||||||
|
|
||||||
|
slip_bps = ((exit_price / sim_exit - 1) * 1e4
|
||||||
|
if exit_price and sim_exit else None)
|
||||||
|
self._log("REAL_CLOSE", {
|
||||||
|
"reason": reason,
|
||||||
|
"order_id": fill.order_id,
|
||||||
|
"sim_exit": round(sim_exit, 2),
|
||||||
|
"real_fill": fill.fill_price,
|
||||||
|
"slippage_bps": round(slip_bps, 2) if slip_bps is not None else None,
|
||||||
|
"entry_fee_usd": round(self.real_entry_fee_usd, 5),
|
||||||
|
"exit_fee_usd": round(fill.fee_usd, 5),
|
||||||
|
"real_pnl_usd": round(real_pnl, 4),
|
||||||
|
"sim_pnl_usd": round(sim_pnl, 4),
|
||||||
|
"real_capital": round(self.real_capital, 4),
|
||||||
|
"verified": fill.verified,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.real_in_position = False
|
||||||
|
self.real_side = ""
|
||||||
|
self.real_amount = 0.0
|
||||||
|
self.real_entry_price = 0.0
|
||||||
|
self.real_entry_fee_usd = 0.0
|
||||||
|
self.real_entry_notional = 0.0
|
||||||
|
self.real_order_id = ""
|
||||||
|
|
||||||
def _close_position(self, current_price: float, reason: str):
|
def _close_position(self, current_price: float, reason: str):
|
||||||
if not self.in_position:
|
if not self.in_position:
|
||||||
return
|
return
|
||||||
@@ -164,7 +287,7 @@ class StrategyWorker:
|
|||||||
net = trade_return * self.leverage - self.fee_rt * self.leverage
|
net = trade_return * self.leverage - self.fee_rt * self.leverage
|
||||||
pnl = self.capital * self.position_size * net
|
pnl = self.capital * self.position_size * net
|
||||||
|
|
||||||
is_win = trade_return > 0
|
is_win = net > 0 # win = profitto NETTO dopo fee (non il lordo trade_return)
|
||||||
self.capital += pnl
|
self.capital += pnl
|
||||||
self.capital = max(self.capital, 0)
|
self.capital = max(self.capital, 0)
|
||||||
self.total_trades += 1
|
self.total_trades += 1
|
||||||
@@ -189,6 +312,9 @@ class StrategyWorker:
|
|||||||
self._log("CLOSE", trade_data)
|
self._log("CLOSE", trade_data)
|
||||||
self._notify("CLOSED", trade_data)
|
self._notify("CLOSED", trade_data)
|
||||||
|
|
||||||
|
if self.execution_enabled:
|
||||||
|
self._real_close(current_price, reason, pnl)
|
||||||
|
|
||||||
self.in_position = False
|
self.in_position = False
|
||||||
self.direction = 0
|
self.direction = 0
|
||||||
self.entry_price = 0
|
self.entry_price = 0
|
||||||
@@ -210,6 +336,8 @@ class StrategyWorker:
|
|||||||
|
|
||||||
c = df["close"].values
|
c = df["close"].values
|
||||||
current_price = float(c[-1])
|
current_price = float(c[-1])
|
||||||
|
bar_high = float(df["high"].iloc[-1])
|
||||||
|
bar_low = float(df["low"].iloc[-1])
|
||||||
current_ts = int(df["timestamp"].iloc[-1])
|
current_ts = int(df["timestamp"].iloc[-1])
|
||||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
|
||||||
@@ -219,21 +347,27 @@ class StrategyWorker:
|
|||||||
self.last_bar_ts = current_ts
|
self.last_bar_ts = current_ts
|
||||||
|
|
||||||
if self.tp and self.sl:
|
if self.tp and self.sl:
|
||||||
# Exit guidati dalla strategia: SL (conservativo, prima), poi TP, poi time-limit
|
# Exit INTRABAR come il backtest: si controllano high/low della barra (non solo il
|
||||||
|
# close) e si esce AL LIVELLO tp/sl. SL prima (conservativo), poi TP, poi time-limit.
|
||||||
if self.direction == 1:
|
if self.direction == 1:
|
||||||
if current_price <= self.sl:
|
if bar_low <= self.sl:
|
||||||
self._close_position(current_price, "stop_loss")
|
self._close_position(self.sl, "stop_loss")
|
||||||
elif current_price >= self.tp:
|
elif bar_high >= self.tp:
|
||||||
self._close_position(current_price, "take_profit")
|
self._close_position(self.tp, "take_profit")
|
||||||
elif self.max_bars and self.bars_held >= self.max_bars:
|
elif self.max_bars and self.bars_held >= self.max_bars:
|
||||||
self._close_position(current_price, "time_limit")
|
self._close_position(current_price, "time_limit")
|
||||||
else:
|
else:
|
||||||
if current_price >= self.sl:
|
if bar_high >= self.sl:
|
||||||
self._close_position(current_price, "stop_loss")
|
self._close_position(self.sl, "stop_loss")
|
||||||
elif current_price <= self.tp:
|
elif bar_low <= self.tp:
|
||||||
self._close_position(current_price, "take_profit")
|
self._close_position(self.tp, "take_profit")
|
||||||
elif self.max_bars and self.bars_held >= self.max_bars:
|
elif self.max_bars and self.bars_held >= self.max_bars:
|
||||||
self._close_position(current_price, "time_limit")
|
self._close_position(current_price, "time_limit")
|
||||||
|
elif self.max_bars:
|
||||||
|
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
|
||||||
|
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
|
||||||
|
if self.bars_held >= self.max_bars:
|
||||||
|
self._close_position(current_price, "time_limit")
|
||||||
elif self.bars_held >= self.hold_bars:
|
elif self.bars_held >= self.hold_bars:
|
||||||
self._close_position(current_price, "hold_limit")
|
self._close_position(current_price, "hold_limit")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import urllib.request
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from src.version import APP_VERSION
|
||||||
|
|
||||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||||
|
|
||||||
NOTIFY_EVENTS = {
|
NOTIFY_EVENTS = {
|
||||||
"SIGNAL", "OPENED", "CLOSED", "OPEN_FAILED", "CLOSE_FAILED",
|
"SIGNAL", "OPENED", "CLOSED", "OPEN_FAILED", "CLOSE_FAILED",
|
||||||
"ERROR", "STARTUP", "SHUTDOWN", "TRAINING_FAILED",
|
"ERROR", "STARTUP", "SHUTDOWN", "TRAINING_FAILED",
|
||||||
|
# esecuzione REALE (shadow su Deribit testnet)
|
||||||
|
"REAL_EXEC_LIVE", # primo ordine reale verificato di un worker (conferma "e' vivo")
|
||||||
|
"REAL_OPEN_FAIL", # un'apertura reale NON si e' verificata (problema da guardare)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +35,7 @@ def send_telegram(text: str) -> bool:
|
|||||||
def notify_event(event: str, data: dict | None = None):
|
def notify_event(event: str, data: dict | None = None):
|
||||||
if event not in NOTIFY_EVENTS:
|
if event not in NOTIFY_EVENTS:
|
||||||
return
|
return
|
||||||
lines = [f"📊 <b>{event}</b>"]
|
lines = [f"📊 <b>{event}</b> <code>v{APP_VERSION}</code>"]
|
||||||
if data:
|
if data:
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if k in ("signal",):
|
if k in ("signal",):
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""TsmomWorker (TSM01): consenso TSMOM multi-orizzonte risk-gated, ribilancio giornaliero.
|
||||||
|
Replica live di tsmom_research.tsmom_sim (horizons 63/126/252, thr 1.0, gross 0.30, SMA100 gate)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.live.rotation_worker import _panel, FEE_RT
|
||||||
|
|
||||||
|
|
||||||
|
class TsmomWorker:
|
||||||
|
def __init__(self, universe, horizons=(63, 126, 252), thr=1.0, gross=0.30,
|
||||||
|
regime_n=100, tf="1d", capital=1000.0, fee_rt=FEE_RT,
|
||||||
|
name="TSM01", data_dir=Path("data/portfolio_paper")):
|
||||||
|
self.universe = list(universe)
|
||||||
|
self.horizons = tuple(horizons)
|
||||||
|
self.thr = thr
|
||||||
|
self.gross = gross
|
||||||
|
self.regime_n = regime_n
|
||||||
|
self.tf = tf
|
||||||
|
self.initial_capital = capital
|
||||||
|
self.capital = capital
|
||||||
|
self.fee_rt = fee_rt
|
||||||
|
self.worker_id = f"{name}__{tf}"
|
||||||
|
self.work_dir = Path(data_dir) / self.worker_id
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
self.trades_path = self.work_dir / "trades.jsonl"
|
||||||
|
self.weights = {a: 0.0 for a in self.universe}
|
||||||
|
self.last_bar_ts = 0
|
||||||
|
self.in_position = False
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if self.status_path.exists():
|
||||||
|
s = json.loads(self.status_path.read_text())
|
||||||
|
self.capital = s.get("capital", self.capital)
|
||||||
|
self.weights = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
|
||||||
|
self.last_bar_ts = s.get("last_bar_ts", 0)
|
||||||
|
self.in_position = any(v > 0 for v in self.weights.values())
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.status_path.write_text(json.dumps({
|
||||||
|
"capital": round(self.capital, 2), "weights": self.weights,
|
||||||
|
"last_bar_ts": self.last_bar_ts,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
|
||||||
|
|
||||||
|
def tick(self, data: dict):
|
||||||
|
need = max(max(self.horizons) + 1, self.regime_n + 1)
|
||||||
|
panel, cols = _panel(data, self.universe)
|
||||||
|
if panel is None or len(panel) < need or "BTC" not in cols:
|
||||||
|
return
|
||||||
|
P = panel[cols].values
|
||||||
|
bar_ts = int(panel["timestamp"].iloc[-1])
|
||||||
|
if self.last_bar_ts and bar_ts > self.last_bar_ts:
|
||||||
|
day_ret = P[-1] / P[-2] - 1.0
|
||||||
|
port_r = sum(self.weights.get(cols[k], 0.0) * day_ret[k] for k in range(len(cols)))
|
||||||
|
self.capital = max(self.capital * (1.0 + float(port_r)), 10.0)
|
||||||
|
btc = P[:, cols.index("BTC")]
|
||||||
|
bma = pd.Series(btc).rolling(self.regime_n).mean().values
|
||||||
|
risk_on = btc[-1] > bma[-1] if not np.isnan(bma[-1]) else False
|
||||||
|
score = np.zeros(len(cols))
|
||||||
|
for h in self.horizons:
|
||||||
|
score += np.sign(P[-1] / P[-1 - h] - 1.0)
|
||||||
|
score /= len(self.horizons)
|
||||||
|
chosen = [k for k in range(len(cols)) if score[k] >= self.thr] if risk_on else []
|
||||||
|
nw = {a: 0.0 for a in self.universe}
|
||||||
|
for k in chosen:
|
||||||
|
nw[cols[k]] = self.gross / len(chosen)
|
||||||
|
turnover = sum(abs(nw[a] - self.weights.get(a, 0.0)) for a in self.universe)
|
||||||
|
self.capital -= self.capital * turnover * (self.fee_rt / 2)
|
||||||
|
if turnover > 0:
|
||||||
|
self._log(nw, float(self.capital))
|
||||||
|
self.weights = nw
|
||||||
|
self.last_bar_ts = bar_ts
|
||||||
|
self.in_position = any(v > 0 for v in nw.values())
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _log(self, weights, cap):
|
||||||
|
with open(self.trades_path, "a") as f:
|
||||||
|
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"weights": {a: round(w, 4) for a, w in weights.items() if w > 0},
|
||||||
|
"capital": round(cap, 2)}) + "\n")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_summary(self):
|
||||||
|
held = {a: round(w, 3) for a, w in self.weights.items() if w > 0}
|
||||||
|
return f"{self.worker_id}: cap={self.capital:.0f} held={held}"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Portfolio: definizione (sleeve + schema pesi) con faccia di backtest.
|
||||||
|
La faccia live è in runner.py."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.portfolio import weighting as W
|
||||||
|
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
|
||||||
|
from scripts.analysis.combine_portfolio import port_returns, metrics, yearly_returns, SPLIT
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SleeveSpec:
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
sid: str
|
||||||
|
asset: str | None = None
|
||||||
|
a: str | None = None
|
||||||
|
b: str | None = None
|
||||||
|
tf: str = "1h"
|
||||||
|
params: dict = field(default_factory=dict)
|
||||||
|
cluster: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PortfolioResult:
|
||||||
|
code: str
|
||||||
|
weights: dict
|
||||||
|
full: dict
|
||||||
|
oos: dict
|
||||||
|
yearly: dict
|
||||||
|
risk: dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Portfolio:
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
sleeves: list[SleeveSpec]
|
||||||
|
weighting: str = "equal"
|
||||||
|
weights: dict | None = None
|
||||||
|
caps: dict | None = None
|
||||||
|
total_capital: float = 1000.0
|
||||||
|
leverage: float = 3.0
|
||||||
|
rebalance: str = "1D"
|
||||||
|
vol_lookback: int = 90
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sleeve_ids(self) -> list[str]:
|
||||||
|
return [s.sid for s in self.sleeves]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clusters(self) -> dict[str, str]:
|
||||||
|
return {s.sid: (s.cluster or s.sid) for s in self.sleeves}
|
||||||
|
|
||||||
|
def weight_vector(self, returns_df: pd.DataFrame | None = None) -> dict[str, float]:
|
||||||
|
return W.weight_vector(
|
||||||
|
self.weighting, self.sleeve_ids, returns_df,
|
||||||
|
weights=self.weights, caps=self.caps,
|
||||||
|
clusters=self.clusters, lookback=self.vol_lookback,
|
||||||
|
)
|
||||||
|
|
||||||
|
def backtest(self) -> PortfolioResult:
|
||||||
|
eq = all_sleeve_equities()
|
||||||
|
members = {sid: eq[sid] for sid in self.sleeve_ids}
|
||||||
|
dr = sleeve_returns_df(self.sleeve_ids)
|
||||||
|
w = self.weight_vector(dr)
|
||||||
|
port_dr = port_returns(members, w)
|
||||||
|
full, oos = metrics(port_dr), metrics(port_dr, lo=SPLIT)
|
||||||
|
import numpy as np
|
||||||
|
we = np.ones(len(self.sleeve_ids)) / len(self.sleeve_ids)
|
||||||
|
cov = dr.cov().values
|
||||||
|
pv = float(we @ cov @ we)
|
||||||
|
rc = we * (cov @ we)
|
||||||
|
risk = {sid: float(rc[k] / pv * 100) if pv > 0 else 0.0
|
||||||
|
for k, sid in enumerate(self.sleeve_ids)}
|
||||||
|
return PortfolioResult(self.code, w, full, oos, yearly_returns(port_dr), risk)
|
||||||
|
|
||||||
|
|
||||||
|
def load_active_portfolio(config_path) -> "Portfolio":
|
||||||
|
"""Carica il portafoglio attivo da portfolios.yml applicando gli override."""
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
|
||||||
|
cfg = yaml.safe_load(Path(config_path).read_text())
|
||||||
|
p = PORTFOLIOS[cfg["active"]]
|
||||||
|
ov = cfg.get("overrides", {})
|
||||||
|
for k in ("total_capital", "weighting", "caps", "leverage", "rebalance", "vol_lookback"):
|
||||||
|
if k in ov and ov[k] is not None:
|
||||||
|
setattr(p, k, ov[k])
|
||||||
|
return p
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Ledger aggregato del portafoglio: capitale, allocazioni, equity, PnL, peak/DD, persistenza."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioLedger:
|
||||||
|
def __init__(self, code: str, total_capital: float = 1000.0,
|
||||||
|
data_dir: Path = Path("data/portfolios")):
|
||||||
|
self.code = code
|
||||||
|
self.initial_capital = total_capital
|
||||||
|
self.total_capital = total_capital
|
||||||
|
self.work_dir = Path(data_dir) / code
|
||||||
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.status_path = self.work_dir / "status.json"
|
||||||
|
self.equity_path = self.work_dir / "equity.jsonl"
|
||||||
|
self.events_path = self.work_dir / "events.jsonl"
|
||||||
|
self.equity = total_capital
|
||||||
|
self.peak = total_capital
|
||||||
|
self.max_dd = 0.0
|
||||||
|
self.weights: dict[str, float] = {}
|
||||||
|
self.alloc: dict[str, float] = {}
|
||||||
|
self.last_rebalance = ""
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if not self.status_path.exists():
|
||||||
|
return
|
||||||
|
s = json.loads(self.status_path.read_text())
|
||||||
|
self.total_capital = s.get("total_capital", self.total_capital)
|
||||||
|
self.equity = s.get("equity", self.equity)
|
||||||
|
self.peak = s.get("peak", self.peak)
|
||||||
|
self.max_dd = s.get("max_dd", self.max_dd)
|
||||||
|
self.weights = s.get("weights", {})
|
||||||
|
self.alloc = s.get("alloc", {})
|
||||||
|
self.last_rebalance = s.get("last_rebalance", "")
|
||||||
|
|
||||||
|
def allocate(self, weights: dict[str, float]) -> dict[str, float]:
|
||||||
|
self.weights = dict(weights)
|
||||||
|
self.alloc = {sid: round(self.total_capital * w, 6) for sid, w in weights.items()}
|
||||||
|
self.last_rebalance = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._append(self.events_path, {"event": "rebalance", "weights": self.weights,
|
||||||
|
"total_capital": self.total_capital})
|
||||||
|
return self.alloc
|
||||||
|
|
||||||
|
def update_equity(self, sleeve_equity: dict[str, float], pnl_day: float = 0.0):
|
||||||
|
self.equity = float(sum(sleeve_equity.values()))
|
||||||
|
if self.equity > self.peak:
|
||||||
|
self.peak = self.equity
|
||||||
|
dd = (self.peak - self.equity) / self.peak * 100 if self.peak > 0 else 0.0
|
||||||
|
self.max_dd = max(self.max_dd, dd)
|
||||||
|
self._append(self.equity_path, {
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"equity": round(self.equity, 2), "dd": round(dd, 3),
|
||||||
|
"pnl_day": round(pnl_day, 2),
|
||||||
|
"pnl_total": round(self.equity - self.initial_capital, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self.status_path.write_text(json.dumps({
|
||||||
|
"code": self.code, "total_capital": round(self.total_capital, 2),
|
||||||
|
"equity": round(self.equity, 2), "peak": round(self.peak, 2),
|
||||||
|
"max_dd": round(self.max_dd, 3), "weights": self.weights,
|
||||||
|
"alloc": self.alloc, "last_rebalance": self.last_rebalance,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}, indent=2))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _append(path: Path, row: dict):
|
||||||
|
with open(path, "a") as f:
|
||||||
|
f.write(json.dumps(row) + "\n")
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""PortfolioRunner: faccia live del portafoglio (capitale pool, sizing, ribilancio, ledger).
|
||||||
|
Riusa i worker esistenti come esecutori e il data layer Cerbero v2.
|
||||||
|
|
||||||
|
Worker per tipo di sleeve:
|
||||||
|
single (fade/dip) -> StrategyWorker | ml (shape, SH01) -> StrategyWorker (WF interno)
|
||||||
|
pairs -> PairsWorker (2 gambe) | basket (TR01) -> BasketTrendWorker
|
||||||
|
rotation (ROT02) -> RotationWorker | tsmom (TSM01) -> TsmomWorker
|
||||||
|
|
||||||
|
Feed: il runner fetcha candele 1h da Cerbero v2 e le RESAMPLA a 4h/1d (come get_df nel
|
||||||
|
backtest) per i worker a cadenza piu' lenta. Il lookback per asset e' dimensionato sul
|
||||||
|
worker piu' esigente (TSM01 usa 252 giorni)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
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.basket_trend_worker import BasketTrendWorker
|
||||||
|
from src.live.rotation_worker import RotationWorker
|
||||||
|
from src.live.tsmom_worker import TsmomWorker
|
||||||
|
from src.live.strategy_loader import load_strategy
|
||||||
|
|
||||||
|
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml)
|
||||||
|
_STRAT_MODULE = {
|
||||||
|
"MR01": "MR01_bollinger_fade", "MR02": "MR02_donchian_fade",
|
||||||
|
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
|
||||||
|
"DIP01": "DIP01_dip_buy",
|
||||||
|
}
|
||||||
|
_MULTI_KINDS = ("basket", "rotation", "tsmom")
|
||||||
|
DATA_DIR = Path("data/portfolio_paper")
|
||||||
|
|
||||||
|
# giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer)
|
||||||
|
_LOOKBACK_DAYS = {"1h": 90, "4h": 220, "1d": 440}
|
||||||
|
# SH01 (ml) richiede >=4000 barre 1h (train_min di ml_wf_entries); 365g (~8760 barre) danno
|
||||||
|
# margine ampio per il walk-forward. Difensivo: non dipende dal fetch 440g di TSM01/ROT02.
|
||||||
|
_ML_LOOKBACK_DAYS = 365
|
||||||
|
|
||||||
|
|
||||||
|
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
||||||
|
data_dir: Path = DATA_DIR, position_size: float = 0.15,
|
||||||
|
executor=None, exec_instrument: str | None = None):
|
||||||
|
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.
|
||||||
|
|
||||||
|
executor/exec_instrument: se valorizzati (solo per i fade single-leg abilitati),
|
||||||
|
lo StrategyWorker affianca al fill simulato un ordine REALE su Deribit (shadow)."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if spec.kind == "basket":
|
||||||
|
pr = spec.params
|
||||||
|
return BasketTrendWorker(
|
||||||
|
universe=pr["universe"], tf=pr.get("tf", "4h"), capital=alloc_capital,
|
||||||
|
position_size=position_size, leverage=leverage, data_dir=data_dir,
|
||||||
|
)
|
||||||
|
if spec.kind == "rotation":
|
||||||
|
pr = spec.params
|
||||||
|
return RotationWorker(
|
||||||
|
universe=pr["universe"], top_k=pr.get("top_k", 3), gross=pr.get("gross", 0.45),
|
||||||
|
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
|
||||||
|
)
|
||||||
|
if spec.kind == "tsmom":
|
||||||
|
pr = spec.params
|
||||||
|
return TsmomWorker(
|
||||||
|
universe=pr["universe"], horizons=tuple(pr.get("horizons", (63, 126, 252))),
|
||||||
|
thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30),
|
||||||
|
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
|
||||||
|
)
|
||||||
|
module = _STRAT_MODULE.get(spec.name)
|
||||||
|
if module is None:
|
||||||
|
raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})")
|
||||||
|
strategy = load_strategy(module)
|
||||||
|
# SH01 (kind="ml") gira come StrategyWorker NORMALE: SH01_shape_ml.generate_signals fa il
|
||||||
|
# walk-forward (retraining) internamente ad ogni tick ed emette metadata.max_bars=H -> gli
|
||||||
|
# exit passano per StrategyWorker.tick (orizzonte H). NON usare il vecchio MLWorkerWrapper di
|
||||||
|
# multi_runner: quello usa SignalEngine (famiglia squeeze SCARTATA), apre senza metadata ed
|
||||||
|
# esce a hold_bars=3, ignorando del tutto SH01_shape_ml. Serve >=4000 barre 1h (train_min):
|
||||||
|
# garantite da _ML_LOOKBACK_DAYS.
|
||||||
|
return 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,
|
||||||
|
executor=executor, exec_instrument=exec_instrument,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _resample(df: pd.DataFrame, tf: str) -> pd.DataFrame:
|
||||||
|
"""Resampla candele 1h -> 4h/1d mantenendo timestamp ms reale (come get_df del backtest)."""
|
||||||
|
if tf == "1h":
|
||||||
|
return df
|
||||||
|
rule = {"4h": "4h", "1d": "1D"}[tf]
|
||||||
|
d = df.copy()
|
||||||
|
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||||
|
d = d.set_index("dt")
|
||||||
|
agg = d.resample(rule).agg({"open": "first", "high": "max", "low": "min",
|
||||||
|
"close": "last", "volume": "sum"}).dropna()
|
||||||
|
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
return agg.reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_assets_tf(spec: SleeveSpec):
|
||||||
|
"""(lista asset, tf) coinvolti da uno sleeve."""
|
||||||
|
if spec.kind == "pairs":
|
||||||
|
return [spec.a, spec.b], spec.tf
|
||||||
|
if spec.kind in _MULTI_KINDS:
|
||||||
|
return list(spec.params["universe"]), spec.params.get("tf", "1d" if spec.kind != "basket" else "4h")
|
||||||
|
return [spec.asset], spec.tf
|
||||||
|
|
||||||
|
|
||||||
|
def run(config_path: str = "portfolios.yml"):
|
||||||
|
"""Loop live a portafoglio (tutti i tipi di sleeve). Data layer Cerbero v2 con resample;
|
||||||
|
ribilancio a cambio giornata UTC."""
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
import yaml
|
||||||
|
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)
|
||||||
|
_ov = (yaml.safe_load(Path(config_path).read_text()) or {}).get("overrides", {})
|
||||||
|
poll = int(_ov.get("poll_seconds", 60))
|
||||||
|
# Frazione di capitale-sleeve impegnata per posizione (default 0.15 = canonico backtest).
|
||||||
|
# Con leva 2x: notional = capital * position_size * 2. A 0.5 ogni sleeve in posizione
|
||||||
|
# impegna il 100% della sua fetta (max impiego senza debito di margine); DD scala ~lineare.
|
||||||
|
position_size = float(_ov.get("position_size", 0.15))
|
||||||
|
|
||||||
|
def _supported(s):
|
||||||
|
return s.kind in ("pairs",) + _MULTI_KINDS 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()
|
||||||
|
|
||||||
|
# --- Esecuzione REALE (shadow) su Deribit testnet, solo sui fade abilitati ---
|
||||||
|
# overrides.execution: {enabled, sleeves:[MR01,...], instruments:{BTC:..,ETH:..}}
|
||||||
|
_exec_cfg = _ov.get("execution", {}) or {}
|
||||||
|
exec_enabled = bool(_exec_cfg.get("enabled"))
|
||||||
|
exec_sleeves = set(_exec_cfg.get("sleeves", []))
|
||||||
|
exec_instr = _exec_cfg.get("instruments", {}) or {}
|
||||||
|
executor = None
|
||||||
|
if exec_enabled:
|
||||||
|
from src.live.execution import ExecutionClient
|
||||||
|
executor = ExecutionClient(client=client)
|
||||||
|
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
|
||||||
|
f"strumenti={exec_instr}")
|
||||||
|
|
||||||
|
def _exec_for(s):
|
||||||
|
"""(executor, exec_instrument) per uno sleeve, solo se fade single-leg abilitato."""
|
||||||
|
if not exec_enabled or s.kind not in ("single",) or s.name not in exec_sleeves:
|
||||||
|
return None, None
|
||||||
|
return executor, exec_instr.get(s.asset)
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
for s in live_specs:
|
||||||
|
ex, inst = _exec_for(s)
|
||||||
|
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
|
||||||
|
position_size=position_size,
|
||||||
|
executor=ex, exec_instrument=inst)
|
||||||
|
|
||||||
|
# lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
|
||||||
|
asset_days: dict[str, int] = {}
|
||||||
|
for s in live_specs:
|
||||||
|
assets, tf = _spec_assets_tf(s)
|
||||||
|
days = _LOOKBACK_DAYS.get(tf, 90)
|
||||||
|
if s.kind == "ml": # SH01 ha bisogno di molta storia 1h
|
||||||
|
days = max(days, _ML_LOOKBACK_DAYS)
|
||||||
|
for a in assets:
|
||||||
|
asset_days[a] = max(asset_days.get(a, 0), days)
|
||||||
|
|
||||||
|
inst_map = dict(INSTRUMENT_MAP)
|
||||||
|
last_day = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# fetch 1h per asset al lookback massimo richiesto
|
||||||
|
raw1h: dict[str, pd.DataFrame] = {}
|
||||||
|
end = datetime.now(timezone.utc)
|
||||||
|
# SOLO testnet (via Cerbero): il paper DEVE usare lo stesso venue dove gli ordini
|
||||||
|
# verrebbero eseguiti (testnet). Mai sostituire con dati mainnet -> divergerebbe dal
|
||||||
|
# comportamento reale (prezzi/liquidità testnet != mainnet). Durante un outage testnet
|
||||||
|
# il runner si mette in pausa (corretto: senza il venue non si potrebbe eseguire).
|
||||||
|
for asset, days in asset_days.items():
|
||||||
|
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
|
||||||
|
start = end - timedelta(days=days)
|
||||||
|
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
|
||||||
|
end.strftime("%Y-%m-%d"), "1h")
|
||||||
|
if candles:
|
||||||
|
df = pd.DataFrame(candles)
|
||||||
|
df["timestamp"] = df["timestamp"].astype("int64")
|
||||||
|
raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True)
|
||||||
|
|
||||||
|
# tick di ogni worker col suo timeframe (resample dal 1h)
|
||||||
|
for s in live_specs:
|
||||||
|
w = workers[s.sid]
|
||||||
|
assets, tf = _spec_assets_tf(s)
|
||||||
|
if any(a not in raw1h for a in assets):
|
||||||
|
continue
|
||||||
|
res = {a: _resample(raw1h[a], tf) for a in assets}
|
||||||
|
if s.kind == "pairs":
|
||||||
|
w.tick(res[s.a], res[s.b])
|
||||||
|
elif s.kind in _MULTI_KINDS:
|
||||||
|
w.tick(res)
|
||||||
|
else:
|
||||||
|
# single (fade/dip) e ml (SH01): StrategyWorker. SH01 retraina dentro
|
||||||
|
# generate_signals (walk-forward) -> nessun training esterno.
|
||||||
|
w.tick(res[s.asset])
|
||||||
|
|
||||||
|
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
if today != last_day and last_day:
|
||||||
|
dr = sleeve_returns_df(live_ids)
|
||||||
|
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
|
||||||
|
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
|
||||||
|
rebalance_allocations(ledger, workers, weights)
|
||||||
|
last_day = today
|
||||||
|
ledger.save()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
ledger.save()
|
||||||
|
print("shutdown")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[runner] errore: {e}")
|
||||||
|
time.sleep(poll)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Unico builder delle equity GIORNALIERE per sleeve (fonte di verità del backtest).
|
||||||
|
|
||||||
|
Delega a scripts/analysis/report_families.build_everything (che a sua volta usa
|
||||||
|
combine_portfolio + pairs_research + tsmom_research + shape_ml_validate), così le
|
||||||
|
metriche del Portfolio coincidono per costruzione con report_families."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
_CACHE: dict[str, pd.Series] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def all_sleeve_equities() -> dict[str, pd.Series]:
|
||||||
|
"""{sleeve_id: equity giornaliera normalizzata su IDX comune}. Cache di processo."""
|
||||||
|
global _CACHE
|
||||||
|
if _CACHE is None:
|
||||||
|
from scripts.analysis.report_families import build_everything
|
||||||
|
S, pairs, tsm, shape = build_everything()
|
||||||
|
_CACHE = {**S, **pairs, **tsm, **shape}
|
||||||
|
return _CACHE
|
||||||
|
|
||||||
|
|
||||||
|
def sleeve_returns_df(ids: list[str]) -> pd.DataFrame:
|
||||||
|
"""Rendimenti giornalieri allineati per gli sleeve richiesti."""
|
||||||
|
eq = all_sleeve_equities()
|
||||||
|
return pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Schemi di peso per i portafogli. Ogni funzione ritorna {sleeve_id: peso} con somma 1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
_PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE")]
|
||||||
|
|
||||||
|
|
||||||
|
def family_of(sleeve_id: str) -> str:
|
||||||
|
for pre, fam in _PREFIX:
|
||||||
|
if sleeve_id.startswith(pre):
|
||||||
|
return fam
|
||||||
|
return "HONEST"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(w: dict[str, float]) -> dict[str, float]:
|
||||||
|
tot = sum(w.values())
|
||||||
|
return {k: (v / tot if tot > 0 else 0.0) for k, v in w.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def equal(ids: list[str]) -> dict[str, float]:
|
||||||
|
n = len(ids)
|
||||||
|
return {i: 1.0 / n for i in ids} if n else {}
|
||||||
|
|
||||||
|
|
||||||
|
def manual(ids: list[str], weights: dict[str, float]) -> dict[str, float]:
|
||||||
|
return _normalize({i: float(weights.get(i, 0.0)) for i in ids})
|
||||||
|
|
||||||
|
|
||||||
|
def cap(ids: list[str], caps: dict[str, float]) -> dict[str, float]:
|
||||||
|
"""Equal-weight con tetto al peso AGGREGATO di una famiglia; l'eccesso ridistribuito
|
||||||
|
pro-quota alle famiglie non cappate (iterativo finché tutti i cap sono rispettati)."""
|
||||||
|
w = equal(ids)
|
||||||
|
fam = {i: family_of(i) for i in ids}
|
||||||
|
for _ in range(10):
|
||||||
|
over = {}
|
||||||
|
for f, lim in caps.items():
|
||||||
|
members = [i for i in ids if fam[i] == f]
|
||||||
|
cur = sum(w[i] for i in members)
|
||||||
|
if cur > lim + 1e-12 and members:
|
||||||
|
over[f] = (members, lim, cur)
|
||||||
|
if not over:
|
||||||
|
break
|
||||||
|
free_ids = [i for i in ids if fam[i] not in caps]
|
||||||
|
freed = 0.0
|
||||||
|
for f, (members, lim, cur) in over.items():
|
||||||
|
scale = lim / cur
|
||||||
|
for i in members:
|
||||||
|
freed += w[i] * (1 - scale)
|
||||||
|
w[i] *= scale
|
||||||
|
if free_ids and freed > 0:
|
||||||
|
add = freed / len(free_ids)
|
||||||
|
for i in free_ids:
|
||||||
|
w[i] += add
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return _normalize(w)
|
||||||
|
|
||||||
|
|
||||||
|
def inverse_vol(ids: list[str], returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
|
||||||
|
sub = returns_df[ids].iloc[-lookback:]
|
||||||
|
vol = sub.std()
|
||||||
|
inv = {i: (1.0 / vol[i] if vol[i] and vol[i] > 0 else 0.0) for i in ids}
|
||||||
|
return _normalize(inv)
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_rp(ids: list[str], clusters: dict[str, str],
|
||||||
|
returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
|
||||||
|
"""Equal fra i cluster naturali, poi inverse-vol dentro ogni cluster."""
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for i in ids:
|
||||||
|
groups.setdefault(clusters.get(i, i), []).append(i)
|
||||||
|
per = 1.0 / len(groups) if groups else 0.0
|
||||||
|
w: dict[str, float] = {}
|
||||||
|
for members in groups.values():
|
||||||
|
iv = inverse_vol(members, returns_df, lookback)
|
||||||
|
for i in members:
|
||||||
|
w[i] = per * iv[i]
|
||||||
|
return _normalize(w)
|
||||||
|
|
||||||
|
|
||||||
|
def weight_vector(scheme: str, ids: list[str], returns_df: pd.DataFrame | None = None,
|
||||||
|
*, weights: dict | None = None, caps: dict | None = None,
|
||||||
|
clusters: dict | None = None, lookback: int = 90) -> dict[str, float]:
|
||||||
|
if scheme == "equal":
|
||||||
|
return equal(ids)
|
||||||
|
if scheme == "manual":
|
||||||
|
return manual(ids, weights or {})
|
||||||
|
if scheme == "cap":
|
||||||
|
return cap(ids, caps or {})
|
||||||
|
if scheme == "inverse_vol":
|
||||||
|
return inverse_vol(ids, returns_df, lookback)
|
||||||
|
if scheme == "cluster_rp":
|
||||||
|
return cluster_rp(ids, clusters or {}, returns_df, lookback)
|
||||||
|
raise ValueError(f"schema peso sconosciuto: {scheme}")
|
||||||
@@ -15,6 +15,25 @@ import pandas as pd
|
|||||||
|
|
||||||
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
|
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
|
||||||
from src.data.downloader import load_data
|
from src.data.downloader import load_data
|
||||||
|
from src.fractal.indicators import rolling_hurst
|
||||||
|
|
||||||
|
|
||||||
|
def hurst_skip_mask(df: pd.DataFrame, hurst_max: float | None, window: int = 100,
|
||||||
|
step: int = 6) -> np.ndarray:
|
||||||
|
"""Loss-guard Hurst: maschera bool (True = SALTA il segnale) per regime PERSISTENTE/trending,
|
||||||
|
dove la rolling-Hurst >= hurst_max. Le fade concentrano stop-loss e perdite proprio li'
|
||||||
|
(diagnosi: stop-rate 43% per hurst>0.55 vs 21% anti-persistente). Filtrare hurst>=0.55
|
||||||
|
DIMEZZA il DD del PORT06 (FULL 4.10%->2.39%) alzando lo Sharpe (validato 2026-06-02).
|
||||||
|
CAUSALE: rolling_hurst usa solo i rendimenti fino a close[i]. hurst_max=None -> nessuno skip.
|
||||||
|
Calcolata dalle SOLE close -> nessun feed dati esterno necessario (a differenza di DVOL).
|
||||||
|
step=6: calcola l'Hurst ogni 6 barre (ffill) -> ~6x piu' veloce per il worker live su finestre
|
||||||
|
lunghe (440g/10560 barre), e coincide con la cache di validazione (frac_step=6). L'Hurst varia
|
||||||
|
lentamente -> differenza trascurabile vs step=1."""
|
||||||
|
n = len(df)
|
||||||
|
if hurst_max is None:
|
||||||
|
return np.zeros(n, dtype=bool)
|
||||||
|
h = rolling_hurst(df["close"].values.astype(float), window=window, step=step)
|
||||||
|
return h >= hurst_max
|
||||||
|
|
||||||
|
|
||||||
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Versione dell'app, incrementata ad ogni deploy. Compare nei messaggi Telegram per correlare
|
||||||
|
i msg al codice in esecuzione. Sorgente: file VERSION nella root (cotto nell'immagine al build).
|
||||||
|
Bump: scripts/bump_version.py (o scripts/deploy.sh, che bumpa+committa+rebuilda)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_VERSION_FILE = Path(__file__).resolve().parents[1] / "VERSION"
|
||||||
|
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
try:
|
||||||
|
return _VERSION_FILE.read_text().strip()
|
||||||
|
except Exception:
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION = get_version()
|
||||||
@@ -90,3 +90,43 @@ strategies:
|
|||||||
max_bars: 24
|
max_bars: 24
|
||||||
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD-
|
||||||
ema_long: 200
|
ema_long: 200
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PR01 — PAIRS market-neutral spread reversion (worker a 2 GAMBE: src/live/pairs_worker.py)
|
||||||
|
# Config UNIVERSALE n50 z2 zx0.75 mb72 (anti-overfit, validata walk-forward).
|
||||||
|
# fee_rt 0.001/gamba -> 0.20% RT/coppia.
|
||||||
|
# FEED LIVE (verificato 2026-05-29): tutti i leg disponibili su Deribit via Cerbero con
|
||||||
|
# il naming corretto -> BTC/ETH = "<COIN>-PERPETUAL" (inverse), alt = "<COIN>_USDC-PERPETUAL"
|
||||||
|
# (lineari USDC, storia dal 2022). Tutte e 5 le coppie tradabili live. ETH/SOL la piu'
|
||||||
|
# debole (DD ~63%, storia SOL piu' corta) -> peso ridotto consigliato.
|
||||||
|
pairs:
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: ETH
|
||||||
|
b: BTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: LTC
|
||||||
|
b: ETH
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: ADA
|
||||||
|
b: ETH
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion
|
||||||
|
a: BTC
|
||||||
|
b: LTC
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
- name: PR01_pairs_reversion # la piu' debole (peso ridotto)
|
||||||
|
a: ETH
|
||||||
|
b: SOL
|
||||||
|
tf: 1h
|
||||||
|
enabled: true
|
||||||
|
params: {n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08}
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
|
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.
|
||||||
|
# Aggiornato 2026-05-31: il recupero dati BNB/DOGE/XRP (29 mag) ha ampliato la
|
||||||
|
# copertura storica -> metriche migliorate (Sharpe 6.07->6.47, OOS 8.19->8.82,
|
||||||
|
# DD 4.9%->4.1%). Nuovo baseline atteso, non una regressione.
|
||||||
|
assert r.full["sharpe"] == pytest.approx(6.47, abs=0.15)
|
||||||
|
assert r.oos["sharpe"] == pytest.approx(8.82, abs=0.25)
|
||||||
|
assert r.full["dd"] == pytest.approx(4.1, abs=0.5)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.basket_trend_worker import BasketTrendWorker
|
||||||
|
|
||||||
|
|
||||||
|
def _ramp_df(n=300, slope=1.0):
|
||||||
|
c = np.linspace(100, 100 + slope * n, n)
|
||||||
|
ts = (pd.date_range("2024-01-01", periods=n, freq="4h", tz="UTC").astype("int64") // 10**6)
|
||||||
|
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_basket_goes_long_in_uptrend(tmp_path):
|
||||||
|
w = BasketTrendWorker(universe=["AAA", "BBB"], tf="4h", capital=1000.0, data_dir=tmp_path)
|
||||||
|
data = {"AAA": _ramp_df(slope=1.0), "BBB": _ramp_df(slope=1.0)}
|
||||||
|
w.tick(data)
|
||||||
|
assert w.positions["AAA"] == 1.0 and w.positions["BBB"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_basket_flat_in_downtrend(tmp_path):
|
||||||
|
w = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
|
||||||
|
data = {"AAA": _ramp_df(slope=-1.0)}
|
||||||
|
w.tick(data)
|
||||||
|
assert w.positions["AAA"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_basket_persists_and_resumes(tmp_path):
|
||||||
|
w = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
|
||||||
|
w.tick({"AAA": _ramp_df(slope=1.0)})
|
||||||
|
w2 = BasketTrendWorker(universe=["AAA"], tf="4h", capital=1000.0, data_dir=tmp_path)
|
||||||
|
assert w2.positions["AAA"] == 1.0 # stato ripreso da status.json
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import pytest
|
||||||
|
from src.live.cerbero_client import CerberoClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.network
|
||||||
|
def test_get_historical_v2_shape():
|
||||||
|
cli = CerberoClient()
|
||||||
|
candles = cli.get_historical_v2("BTC-PERPETUAL", "2026-05-25", "2026-05-27", "1h")
|
||||||
|
assert len(candles) > 0
|
||||||
|
c0 = candles[0]
|
||||||
|
assert {"timestamp", "open", "high", "low", "close", "volume"} <= set(c0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.network
|
||||||
|
def test_get_instruments_returns_list():
|
||||||
|
cli = CerberoClient()
|
||||||
|
inst = cli.get_instruments("ETH", "future")
|
||||||
|
assert isinstance(inst, list) and len(inst) > 0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from src.portfolio.base import load_active_portfolio
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_active_applies_overrides(tmp_path):
|
||||||
|
cfg = tmp_path / "portfolios.yml"
|
||||||
|
cfg.write_text("active: PORT06\noverrides:\n leverage: 2\n total_capital: 500\n")
|
||||||
|
p = load_active_portfolio(cfg)
|
||||||
|
assert p.code == "PORT06"
|
||||||
|
assert p.leverage == 2.0
|
||||||
|
assert p.total_capital == 500
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from scripts.portfolios._defs import PORTFOLIOS
|
||||||
|
|
||||||
|
|
||||||
|
def test_six_portfolios_defined():
|
||||||
|
assert set(PORTFOLIOS) == {"PORT01", "PORT02", "PORT03", "PORT04", "PORT05", "PORT06"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_port06_is_master_shape_cap():
|
||||||
|
p = PORTFOLIOS["PORT06"]
|
||||||
|
sids = set(p.sleeve_ids)
|
||||||
|
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC"} <= sids
|
||||||
|
assert len(sids) == 17
|
||||||
|
assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33}
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_leverage_sober():
|
||||||
|
assert PORTFOLIOS["PORT06"].leverage == 2.0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from scripts.strategies.DIP01_dip_buy import Dip01DipBuy
|
||||||
|
|
||||||
|
|
||||||
|
def test_dip01_generates_long_signals_with_exits():
|
||||||
|
df = load_data("BTC", "1h").iloc[-5000:].reset_index(drop=True)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
sigs = Dip01DipBuy().generate_signals(df, ts, asset="BTC", tf="1h")
|
||||||
|
assert len(sigs) > 0
|
||||||
|
s = sigs[0]
|
||||||
|
assert s.direction == 1
|
||||||
|
assert {"tp", "sl", "max_bars"} <= set(s.metadata)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Fix exit a orizzonte puro: una strategia che porta solo `max_bars` nella metadata del
|
||||||
|
Signal (niente TP/SL, es. SH01 shape-ML con H=12) deve uscire a `max_bars` barre — NON sul
|
||||||
|
fallback legacy `hold_bars=3`. Prima del fix lo StrategyWorker chiudeva tali sleeve a 3 barre
|
||||||
|
(reason "hold_limit"), tagliando l'orizzonte su cui è validato l'edge di forma."""
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.strategy_worker import StrategyWorker
|
||||||
|
from src.live.strategy_loader import load_strategy
|
||||||
|
|
||||||
|
|
||||||
|
def _df(n, price=100.0, last_ts=None):
|
||||||
|
c = [price] * n
|
||||||
|
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
|
||||||
|
df = pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||||
|
if last_ts is not None:
|
||||||
|
df.loc[df.index[-1], "timestamp"] = last_ts
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def _horizon_worker(tmp, max_bars=12):
|
||||||
|
"""Worker con posizione aperta, solo max_bars (tp/sl=0) — come SH01."""
|
||||||
|
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
|
||||||
|
capital=1000.0, data_dir=tmp)
|
||||||
|
w._notify = lambda *a, **k: None
|
||||||
|
w.in_position = True
|
||||||
|
w.direction = 1
|
||||||
|
w.entry_price = 100.0
|
||||||
|
w.tp = 0.0
|
||||||
|
w.sl = 0.0
|
||||||
|
w.max_bars = max_bars
|
||||||
|
w.bars_held = 0
|
||||||
|
w.last_bar_ts = 0
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def _tick_bars(w, k, base_n=120):
|
||||||
|
"""Avanza k barre nuove (ogni tick incrementa bars_held di 1 col nuovo timestamp)."""
|
||||||
|
for b in range(1, k + 1):
|
||||||
|
w.tick(_df(base_n, last_ts=b))
|
||||||
|
|
||||||
|
|
||||||
|
def test_holds_until_horizon_not_hold_bars(tmp_path):
|
||||||
|
"""max_bars=12: a 3 barre (vecchio hold_bars) NON deve chiudere; deve restare in posizione."""
|
||||||
|
w = _horizon_worker(tmp_path, max_bars=12)
|
||||||
|
_tick_bars(w, 3)
|
||||||
|
assert w.in_position
|
||||||
|
assert w.bars_held == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_exits_at_max_bars_with_time_limit(tmp_path):
|
||||||
|
"""A max_bars barre chiude con reason 'time_limit' (non 'hold_limit')."""
|
||||||
|
w = _horizon_worker(tmp_path, max_bars=12)
|
||||||
|
_tick_bars(w, 12)
|
||||||
|
assert not w.in_position
|
||||||
|
# leggi l'ultimo CLOSE dal log (formato piatto) e verificane reason/bars_held
|
||||||
|
import json
|
||||||
|
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
|
||||||
|
close = [r for r in rows if r.get("event") == "CLOSE"]
|
||||||
|
assert close and close[-1]["reason"] == "time_limit"
|
||||||
|
assert close[-1]["bars_held"] == 12
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Loss-guard Hurst: le fade saltano i segnali in regime persistente/trending (rolling-Hurst >=
|
||||||
|
soglia), dove si concentrano stop-loss e perdite. Validato 2026-06-02: filtrare hurst>=0.55
|
||||||
|
DIMEZZA il DD del PORT06 alzando lo Sharpe. Filtro CAUSALE (close<=i), default off (None)."""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.strategies.fade_base import hurst_skip_mask
|
||||||
|
|
||||||
|
|
||||||
|
def _df(close):
|
||||||
|
n = len(close)
|
||||||
|
return pd.DataFrame({"timestamp": range(n), "open": close, "high": close,
|
||||||
|
"low": close, "close": close, "volume": [1.0] * n})
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_off_when_none():
|
||||||
|
df = _df(np.cumsum(np.random.default_rng(0).normal(size=400)) + 100)
|
||||||
|
m = hurst_skip_mask(df, None)
|
||||||
|
assert m.dtype == bool and not m.any() # None -> nessuno skip
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_flags_persistent_regime():
|
||||||
|
# serie fortemente TRENDING (persistente, Hurst alto) -> deve essere mascherata (skip) molto
|
||||||
|
trend = np.linspace(100, 300, 600)
|
||||||
|
df = _df(trend)
|
||||||
|
m = hurst_skip_mask(df, hurst_max=0.55, window=100)
|
||||||
|
# dopo il warmup, una rampa pulita e' persistente -> gran parte mascherata
|
||||||
|
assert m[150:].mean() > 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_fade_strategy_filters_signals():
|
||||||
|
"""Una fade con hurst_max produce <= segnali del baseline, e tutti i superstiti sono in
|
||||||
|
regime non-persistente (la maschera e' False alla loro barra)."""
|
||||||
|
import importlib
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
# serie mean-reverting (anti-persistente) con qualche estensione -> genera fade
|
||||||
|
n = 1200
|
||||||
|
c = 100 + np.cumsum(rng.normal(scale=0.5, size=n))
|
||||||
|
c = 100 + (c - c.mean()) * 0.3 # comprimi verso la media (mean-revert)
|
||||||
|
df = _df(c)
|
||||||
|
ts = pd.to_datetime(df["timestamp"], unit="s", utc=True)
|
||||||
|
m = importlib.import_module("scripts.strategies.MR01_bollinger_fade")
|
||||||
|
Strat = next(v for k, v in vars(m).items()
|
||||||
|
if isinstance(v, type) and getattr(v, "__module__", "") == m.__name__
|
||||||
|
and hasattr(v, "generate_signals"))
|
||||||
|
s = Strat()
|
||||||
|
base = s.generate_signals(df, ts, bb_window=50, k=2.0, sl_atr=2.0)
|
||||||
|
filt = s.generate_signals(df, ts, bb_window=50, k=2.0, sl_atr=2.0, hurst_max=0.55)
|
||||||
|
assert len(filt) <= len(base) # il filtro non aggiunge mai segnali
|
||||||
|
skip = hurst_skip_mask(df, 0.55, 100)
|
||||||
|
assert all(not skip[sig.idx] for sig in filt) # nessun superstite in regime persistente
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user