refactor(layout): docs+tests core sotto modulo, cleanup superflui, README strategy
Ownership per modulo:
- Move docs/ root → src/multi_swarm_core/docs/{design,decisions,reports}/
* 00_documento_zero.md + coevolutive_swarm_system.md → docs/design/
* decisions/* → docs/decisions/
* reports/2026-05-14-stato-progetto-e-roadmap.md → docs/reports/
- Move tests/ root → src/multi_swarm_core/tests/
Cleanup superflui consumati (audit trail preservato in docs/decisions):
- poc_trading_swarm.md (POC superato — Phase 3 attiva in prod)
- docs/reports/2026-05-10-phase1-technical-report.md (superato dal 14-mag)
- docs/superpowers/plans/*.md (3 file, plan consumati)
- docs/superpowers/specs/*.md (2 file, spec consumate)
- tests/unit/paper_trading/ (vuota, paper_trading e' migrato in strategy_crypto)
- Directory docs/ root cancellata
NEW: src/strategy_crypto/README.md — overview strategia (scope, layout,
run, DB schema, pattern N strategie future)
Root resta minima: README.md, pyproject.toml, docker-compose.yml,
Dockerfile, .env*, uv.lock + data/series/state/scripts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
# Gate Phase 1 — Decision Memo
|
||||
|
||||
**Data**: 10 maggio 2026
|
||||
**Run di riferimento**: `phase1-real-005` (id `1c526996160446b18c0fb57d94874975`)
|
||||
**Run scartati durante iterazione**: `phase1-real-001..004` (vedi sez. 3)
|
||||
**Spesa totale Phase 1**: $0.18 cumulativi (≈0.025% del cap $700)
|
||||
**Tempo speso Phase 1**: 1 giornata di lavoro (10 maggio 2026, iterazione bug-fix incluse)
|
||||
**Status**: ✅ TUTTI E 5 I HARD GATE PASSATI
|
||||
|
||||
---
|
||||
|
||||
## 1. Premessa
|
||||
|
||||
Questo memo formalizza la valutazione dei 5 hard gate definiti nello spec strategico (`docs/superpowers/specs/2026-05-09-decisione-strategica-design.md`, sez. 4.4) sulla base del run `phase1-real-005`. I gate sono numerici per costruzione: l'esito PASS/FAIL è meccanico. Discrezionale è solo l'azione successiva.
|
||||
|
||||
---
|
||||
|
||||
## 2. Author pass — valutazione hard gate
|
||||
|
||||
### Gate 1 — Loop converge
|
||||
|
||||
**Soglia**: la fitness mediana della popolazione cresce per ≥3 generazioni consecutive prima di plateau.
|
||||
|
||||
**Misura osservata**:
|
||||
|
||||
| Generazione | Median fitness | Max fitness | P90 | Entropy |
|
||||
|---|---|---|---|---|
|
||||
| 0 | 0.0001 | 0.0601 | 0.0165 | 0.588 |
|
||||
| 1 | 0.0042 | 0.1893 | 0.0731 | 1.261 |
|
||||
| 2 | 0.0188 | 0.3347 | 0.2039 | 1.333 |
|
||||
| 3 | 0.0069 | 0.3347 | 0.3347 | 1.347 |
|
||||
| 4 | 0.0910 | 0.3347 | 0.3347 | 1.415 |
|
||||
| 5 | 0.0016 | 0.3347 | 0.3347 | 0.611 |
|
||||
| 6 | 0.0040 | 0.3347 | 0.3347 | 0.886 |
|
||||
| 7 | 0.0151 | 0.3347 | 0.3347 | 0.982 |
|
||||
| 8 | 0.0066 | 0.3347 | 0.3347 | 0.746 |
|
||||
| 9 | 0.0061 | 0.3347 | 0.3347 | 0.914 |
|
||||
|
||||
**Generazioni consecutive di crescita mediana**: Gen 0→1→2 (0.0001→0.0042→0.0188 = 3 consecutive). Max raggiunto a gen 2, stabile da lì in poi (plateau dell'elite, comportamento atteso con elite_k=2).
|
||||
|
||||
**Esito**: ✅ **PASS**
|
||||
|
||||
**Razionale**: la convergenza iniziale è chiara (3 generazioni di crescita 4-50x), poi il max plateaua per elite preservation. La median oscilla per turnover di novellini, non per regressione strutturale.
|
||||
|
||||
---
|
||||
|
||||
### Gate 2 — Output formalizzabile
|
||||
|
||||
**Soglia**: ≥80% delle proposte LLM passano il parser senza intervento manuale.
|
||||
|
||||
**Misura osservata**:
|
||||
- Evaluations totali: 98
|
||||
- Parse success: **98 (100.0%)**
|
||||
- Parse error: 0
|
||||
|
||||
**Esito**: ✅ **PASS** (soglia superata di 20 punti percentuali)
|
||||
|
||||
**Razionale**: il refactor da S-expression a JSON Schema (commit `44eb643`) ha eliminato la fragilità sintattica. Combinato con il retry-with-error-feedback (`d4fcb42`), zero retry effettivamente serviti — JSON è already self-correcting per qwen3-235b. Senza questi fix, il run v4 mostrava 35.9% parse success.
|
||||
|
||||
---
|
||||
|
||||
### Gate 3 — Tail superiore
|
||||
|
||||
**Soglia**: i top-5 genomi hanno DSR (qui letto come fitness, dato il design v0) ≥ 1.5x la mediana di popolazione.
|
||||
|
||||
**Misura osservata**:
|
||||
- Median fitness popolazione: 0.0003
|
||||
- Top-5 fitness media: 0.2587
|
||||
- Top-1 fitness: 0.3347
|
||||
- **Ratio (top-1 / median)**: ≈1116x (molto sopra soglia 1.5x)
|
||||
|
||||
**Esito**: ✅ **PASS** (ordini di grandezza sopra soglia)
|
||||
|
||||
**Razionale**: il tail superiore è netto e separato. Esiste un cluster di top performer chiaramente distinguibile da mediocri / killed. Il bigger picture: la fitness function continua (commit `d159075`) ha permesso al GA di distinguere "lievemente migliore" da "completamente disastroso", evitando l'appiattimento a zero del run v4.
|
||||
|
||||
---
|
||||
|
||||
### Gate 4 — Diversità non collassa
|
||||
|
||||
**Soglia**: entropia della distribuzione di fitness in popolazione > 0.5 a fine run.
|
||||
|
||||
**Misura osservata**:
|
||||
- Entropy gen 0: 0.588
|
||||
- Entropy gen finale (gen 9): **0.914**
|
||||
- Trend: oscilla 0.6-1.4 con un dip a gen 5 (0.611) ma sempre sopra soglia.
|
||||
|
||||
**Esito**: ✅ **PASS**
|
||||
|
||||
**Razionale**: la popolazione mantiene varianza di fitness ben sopra 0.5. Cognitive styles sopravvissuti a gen 9: 3 su 6 originali (engineer, physicist, historian), con engineer dominante (3 di 5 elites tracciati). La selezione comprime la diversità cognitiva ma non l'entropia di fitness — segnale che la pressione selettiva funziona senza monocoltura.
|
||||
|
||||
---
|
||||
|
||||
### Gate 5 — Cost predictability
|
||||
|
||||
**Soglia**: spesa entro ±30% della stima preventivata ($500-700 per Phase 1).
|
||||
|
||||
**Misura osservata**:
|
||||
- Stima preventivo originale: $500-700 (basata su pricing Sonnet/Anthropic)
|
||||
- Spesa reale cumulativa Phase 1: ≈$0.18 (somma di v1-v5)
|
||||
- Spesa run v5 da solo: $0.069
|
||||
- Deviazione: -99.97% rispetto al preventivo (sotto cap di **~10000x**)
|
||||
|
||||
**Esito**: ✅ **PASS** (sotto cap; la deviazione verso il basso non è failure)
|
||||
|
||||
**Razionale**: la migrazione a OpenRouter+qwen3-235b come tier C dominante ha cambiato l'ordine di grandezza dei costi (~$0.40/1M token vs Sonnet $3/$15). Il preventivo originale assumeva Sonnet come baseline; la realtà è 1000x più economica. Phase 2 cap ($700-1100) ha margine drammatico, eventualmente utilizzabile per ablation più aggressive o uso di tier B/S sui top candidati.
|
||||
|
||||
---
|
||||
|
||||
## 3. Iterazione: 5 run prima del PASS
|
||||
|
||||
I primi 4 run (`phase1-real-001..004`) hanno servito da bug-discovery. Sintesi:
|
||||
|
||||
| Run | Esito | Problema | Fix applicato |
|
||||
|---|---|---|---|
|
||||
| 001 | aborted | 67% parse_error (LLM nesta indicators); max_dd su equity assoluta produce drawdown 89000 | Prompt strict + max_dd normalizzato su notional (commit `15a4138`) |
|
||||
| 002 | failed | `_ind_macd` accetta 2 args, prompt suggeriva 3 (fast/slow/signal) | macd accetta signal (commit `d9423a1`); OHLCV cap Cerbero ~5000 → paginazione (commit `d9423a1`) |
|
||||
| 003 | failed | Validator non controllava arity indicator → crash compiler su `(indicator sma 20 50)` | INDICATOR_ARITY in validator + reject nested (commit `df76906`) |
|
||||
| 004 | completed FAIL | 35.9% parse_error, fitness tutti 0 (clamp a 0 troppo duro) | Switch a JSON grammar + retry+feedback + fitness continua (commit `44eb643`, `d4fcb42`, `d159075`) |
|
||||
| 005 | **completed PASS** | — | — |
|
||||
|
||||
Costo cumulativo iterazione: $0.034 (v1) + $0.018 (v2, abort) + $0.015 (v3, abort) + $0.057 (v4) + $0.069 (v5) ≈ **$0.19 totale**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Soft observations
|
||||
|
||||
### 4.1 Trade distribution sui 98 evals
|
||||
|
||||
| Categoria | n | % |
|
||||
|---|---|---|
|
||||
| Zero trade (kill no_trades HIGH) | 42 | 42.9% |
|
||||
| Undertrading (1-4 trade, MEDIUM) | 5 | 5.1% |
|
||||
| Normal (5-100 trade) | 9 | 9.2% |
|
||||
| Overtrading (>100 trade) | 42 | 42.9% |
|
||||
|
||||
**Osservazione critica**: il 42.9% di overtrading non è flaggato dall'Adversarial. Il check attuale soglia `n_trades > n_bars/5 = 17545/5 = 3509` — troppo alto. Phase 2 dovrebbe abbassare a `n_bars/20` o usare metrica relativa (trade rate per regime).
|
||||
|
||||
### 4.2 Cognitive style nei top-5
|
||||
|
||||
- physicist: 2 (top-1 e top-5)
|
||||
- engineer: 2 (top-2 e top-4)
|
||||
- ecologist: 1 (top-3)
|
||||
|
||||
historian, biologist, meteorologist non compaiono nei top-5 → loro stili producono strategie meno performanti su BTC perp 1h. Possibile bias del market regime.
|
||||
|
||||
### 4.3 Top-1 ispezione qualitativa
|
||||
|
||||
Genoma `696052b89f78b28f`, gen 2, style `physicist`, temperature 0.68, lookback 200.
|
||||
|
||||
**System prompt** (dal cognitive style "engineer"):
|
||||
> Cerca segnali con rapporto S/N favorevole, filtri causali, robustezza a perturbazioni di calibrazione.
|
||||
|
||||
**Strategia** (3 regole):
|
||||
- **LONG**: SMA(10) crossover SMA(30) AND realized_vol(20) > 0.3% AND RSI(14) < 45.
|
||||
- **SHORT**: SMA(10) crossunder SMA(30) AND realized_vol(20) > 0.3% AND RSI(14) > 55.
|
||||
- **EXIT**: (RSI > 70 AND close crossover SMA(50)) OR realized_vol < 0.1%.
|
||||
|
||||
**Lettura**: trend-following SMA-cross modulato da filtro volatilità (entra solo in regimi con volatilità sopra soglia, esce in regime troppo calmo) e momentum RSI come confirmation/contrarian. Pattern economicamente plausibile, non casuale. 33 trade su 2 anni = uno ogni 22 giorni, sample size modesto ma coerente con strategia trend-following.
|
||||
|
||||
Sharpe 0.381 è positivo ma modesto. Top-2 ed altri top hanno solo 1 trade ("lucky shot" non flaggato come HIGH dall'Adversarial).
|
||||
|
||||
### 4.4 Diversità apparente vs reale
|
||||
|
||||
I top-2 hanno fitness e metriche identiche (0.3347 fit, DSR 0.0021, Sharpe 0.381, max_dd 0.0215, 33 trade). Possibile che siano elite duplicati nelle generazioni successive oppure due genomi distinti che hanno convergencе sulla stessa strategia. Verifica per Phase 2: cluster signal correlation fra top-K e contare specie effettive.
|
||||
|
||||
---
|
||||
|
||||
## 5. Author pass — conclusione
|
||||
|
||||
**Esito complessivo author pass**: ✅ **PASS** su tutti 5 hard gate.
|
||||
|
||||
**Decisione raccomandata dall'autore**: **GO Phase 2** con tre aggiustamenti consigliati:
|
||||
|
||||
1. **Adversarial layer più severo su overtrading/undertrading**: 42.9% di overtrading silenzioso è scope creep di problemi reali. Soglia overtrading da `n_bars/5` a `n_bars/20`; undertrading da `<5 trade` a `<10 trade su training`.
|
||||
|
||||
2. **Speciation in Phase 2**: cognitive style scendono da 6 a 3 a gen 9. Aggiungere protezione esplicita per specie (≥2 specie minimo, ognuna con quota tournament protetta) per evitare monocoltura ai stili dominanti.
|
||||
|
||||
3. **OOS walk-forward critico**: Phase 1 era in-sample. Tutti i top genomi vanno ri-valutati su hold-out 2026 prima di assegnare fitness in Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 6. Review pass — red team adversarial
|
||||
|
||||
**Modalità review pass**: subagent red-team self-review da parte dell'autore (Adriano Dal Pastro) + co-author Claude Opus 4.7. Fresh-eyes 24h non applicato data l'urgenza di chiudere Phase 1.
|
||||
|
||||
**Critiche strutturate**:
|
||||
|
||||
1. **Cherry-picking**: dei 5 run, 1 ha passato i gate (v5). Il fatto che siano serviti 4 cicli di bug-fix prima del PASS è LEGITTIMO bug-fixing di un sistema nuovo (parse/grammar/fitness math). NON è cherry-picking di seed o config: gli stessi `--seed 42 --population-size 20 --n-generations 10` hanno girato in tutti i run. Cherry-picking sarebbe stato escludere v4 (FAIL) dall'analisi: v4 è citato esplicitamente in §3.
|
||||
|
||||
2. **Statistical robustness**: il DSR è calcolato correttamente (Bailey & López 2014 implementation in `metrics/dsr.py`) con `n_trials=50` per Bonferroni-equivalent deflation. Tuttavia il top-1 ha DSR 0.0021 → praticamente zero significatività. La fitness 0.3347 viene dal contributo `tanh(sharpe)` non da DSR. **Implicazione**: il "successo" del Gate 3 è guidato da Sharpe non da DSR. Non è un PASS spurio (la fitness è ben definita), ma il segnale alpha vero (DSR) è marginale.
|
||||
|
||||
3. **Overfitting in-sample**: tutto il backtest è sullo stesso range 2024-2026. Il top-1 ha Sharpe 0.38 in-sample. Quanto sopravvive in OOS? Sconosciuto. Phase 2 deve misurare gap in-sample/OOS prima di trarre conclusioni alpha-related.
|
||||
|
||||
4. **Trade frequency sospetta nei top**: top-3, top-4, top-5 hanno 1 trade ognuno. Fitness 0.18-0.25 per "una posizione lucky" è artefatto della fitness function continua (sharpe positivo o leggermente negativo + dd minimo). Adversarial undertrading è MEDIUM non HIGH → non killato. Phase 2 deve promuovere undertrading a HIGH quando `n_trades < 10`.
|
||||
|
||||
5. **Cost trap inverso**: $0.069 è ridicolmente basso. Tentazione di Phase 2 di scalare drasticamente (K=100, gen=30, tutto tier B). Resistere: rispetto al cap Phase 2 $700-1100, una 10x dell'attuale = $0.69 ancora trascurabile, ma con tier B (3/15 vs 0.40/0.40) = $7-15 = serio scaling. Disciplina budget Phase 2 invariata.
|
||||
|
||||
**Contro-evidenze raccolte / fix applicati**:
|
||||
- Punto 2 (DSR marginale): documentato esplicitamente. Phase 2 può introdurre `dsr_weight` più alto nella fitness se si vuole pesare la significatività statistica sopra il puro Sharpe.
|
||||
- Punto 4 (undertrading): aggiunto a "aggiustamenti raccomandati" sez. 5.
|
||||
- Punto 3 (OOS): aggiunto a "aggiustamenti raccomandati" sez. 5.
|
||||
|
||||
---
|
||||
|
||||
## 7. Decisione finale
|
||||
|
||||
**Decisione**: ✅ **GO Phase 2** con scope identico allo spec strategico (sez. 5) e tre aggiustamenti integrativi:
|
||||
|
||||
1. Adversarial layer: overtrading/undertrading soglie più stringenti.
|
||||
2. Speciation di base: protezione cognitive style minimum-2 con quota tournament.
|
||||
3. Walk-forward 70/30 con hold-out Q1-Q2 2026 intoccabile.
|
||||
|
||||
**Razionale finale**: tutti i 5 hard gate sono passati con margini ampi su 4/5 (entropy, parse, cost, top-vs-median), margine sufficiente su gate 1 (3 gen di crescita iniziale). Le critiche red team identificate sono incorporate come aggiustamenti Phase 2, non blocker. Il codebase è robusto, modulare, testato (141 PASSED, ruff/mypy strict clean), pronto per estensione.
|
||||
|
||||
**Spesa Phase 1 vs cap**: $0.19 vs $700 cap = 0.027% utilizzato. Margine drammatico per Phase 2.
|
||||
|
||||
**Tempo Phase 1 vs cap**: 1 giorno calendar (vs 4-6 settimane stimati). Velocità da PoC singolo autore + LLM-assisted coding, non scalabile a Phase 2 che ha lavoro di research integrate (DSR multi-testing rigoroso, walk-forward, RF baseline).
|
||||
|
||||
**Documenti correlati prodotti**:
|
||||
- `docs/reports/2026-05-10-phase1-technical-report.md` (report tecnico)
|
||||
- `docs/superpowers/specs/2026-05-09-decisione-strategica-design.md` (spec strategico — sez. 5 contiene scope Phase 2)
|
||||
- `docs/superpowers/plans/2026-05-09-phase1-lean-spike.md` (plan implementativo Phase 1)
|
||||
|
||||
**Prossimi step suggeriti**:
|
||||
1. Aggiornare lo spec strategico con esito Phase 1 (sez. 11 "decisioni risolte").
|
||||
2. Avviare il design di Phase 2 (subagent `superpowers:writing-plans` su un nuovo spec Phase 2 che integra i 3 aggiustamenti).
|
||||
3. Eseguire i 3 aggiustamenti come piccoli fix Phase 1.5 (Adversarial soglie, speciation, walk-forward), poi run di smoke Phase 1.5 per confermare effetto.
|
||||
|
||||
---
|
||||
|
||||
*Memo finalizzato 10 maggio 2026. Versione 1.0.*
|
||||
@@ -0,0 +1,117 @@
|
||||
# Phase 1.5 — Run nemotron tier C — Decision Memo
|
||||
|
||||
**Data**: 11 maggio 2026
|
||||
**Run di riferimento**: `phase1.5-nemotron-001` (id `434c417e2b6f42bb8cf32514e5d0db1d`)
|
||||
**Tier LLM**: C → `nvidia/nemotron-3-super-120b-a12b:free`
|
||||
**Durata wallclock**: 2 h 26 min (08:15 → 10:11 UTC, gen 0 → gen 9)
|
||||
**Spesa totale**: $0.1244 (price-table tier C; il modello effettivo è `:free` su OpenRouter, ma il cost tracker applica la pricing nominale del tier)
|
||||
**Status**: ✅ Completato, ma esito strategico **NO-GO** sulla configurazione corrente
|
||||
|
||||
---
|
||||
|
||||
## 1. Premessa
|
||||
|
||||
Il run `phase1.5-nemotron-001` è la prima esecuzione end-to-end del loop GA con:
|
||||
|
||||
- l'Adversarial layer aggiornato in Phase 1.5 (commits `56a631f` + `d3662f6`), con tre nuovi check HIGH (`flat_too_long`, `fees_eat_alpha`, `time_in_market_too_high`) più i due esistenti rinforzati;
|
||||
- il tier C ribindato a `nvidia/nemotron-3-super-120b-a12b:free`, modello scelto in benchmark contro sette alternative per stabilità JSON e costo nullo;
|
||||
- il fix `EmptyCompletionError` su `llm/client.py` (commit `9d0deb3`) introdotto durante la stessa sessione per gestire le risposte vuote che alcuni provider `:free` ritornano sporadicamente.
|
||||
|
||||
L'obiettivo dichiarato del run era verificare se il nuovo budget di vincoli adversarial — più stretto del v5 — fosse compatibile con la capacità generativa di nemotron, e se la popolazione riuscisse a esplorare una zona di fitness positiva non degenere.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hard gate Phase 1 — ripercorrenza
|
||||
|
||||
I 5 hard gate originali (definiti nello spec strategico di Phase 1) sono stati rivalutati su questo run come sanity check, non come passaggio formale di gate.
|
||||
|
||||
| # | Gate | Soglia | Misura | Esito |
|
||||
|---|------|--------|--------|-------|
|
||||
| 1 | Loop converge | mediana cresce ≥3 gen consecutive | Gen 0→8: median oscilla tra 0.0 e 0.0073 senza crescita strutturale | ❌ FAIL |
|
||||
| 2 | Parse success | ≥80% proposte LLM parse-OK | 81/89 = **91.0%** | ✅ PASS |
|
||||
| 3 | Top-5 ratio | top-5 fitness ≥10× mediana | top-5 = 0.0162–0.0215; mediana ≈ 0 → ratio indefinito | ⚠️ N/A |
|
||||
| 4 | Entropy | ≥0.5 a fine run | 0.845 alla gen 9 | ✅ PASS |
|
||||
| 5 | Budget | costo ≤ cap | $0.1244 vs cap $700 (0.02%) | ✅ PASS |
|
||||
|
||||
Il gate critico è il numero 1. La popolazione non converge: il `max_fitness` resta inchiodato a `0.0215` dalla generazione 0 fino alla 9, segnale che l'elite preservation cattura un singolo genoma poco peggiore degli altri ma altrettanto inadatto, mentre il resto della popolazione non riesce a superarlo. La mediana è zero in 9 generazioni su 10 (singolo picco a 0.0073 in gen 8).
|
||||
|
||||
---
|
||||
|
||||
## 3. Lettura dei top genomi
|
||||
|
||||
I cinque genomi a fitness più alta hanno tutti caratteristiche economicamente disastrose:
|
||||
|
||||
| Genome ID | Fitness | DSR | Sharpe | Total return | n_trades |
|
||||
|-----------|---------|-----|--------|--------------|----------|
|
||||
| `0e1f9d7af25cfd6a` | 0.0215 | 0.000 | −1.083 | −115.9% | 385 |
|
||||
| `85a8116ab2cd2735` | 0.0215 | 0.000 | −1.083 | −115.9% | 385 |
|
||||
| `92aae563277b6f21` | 0.0193 | 0.000 | −1.129 | −131.0% | 597 |
|
||||
| `01d0ca99bbdd7320` | 0.0180 | 0.000 | −1.112 | −131.7% | 602 |
|
||||
| `194b096f7edab53c` | 0.0162 | 0.000 | −1.154 | −150.7% | 369 |
|
||||
|
||||
Il fatto che **DSR sia zero per tutti i top-5** indica che nessuna strategia passa il deflation test di Bailey & López 2014: il loop non sta generando proposte con edge statistico anche solo apparente. Il valore di fitness positivo che li seleziona deriva interamente dal termine `tanh(sharpe) × penalty(dd)` della fitness v1, che resta debolmente non nullo anche per Sharpe negativi grazie alla penalty di drawdown e a saturazioni numeriche. I primi due genomi hanno fitness identico a 0.0215 e total return identico — verosimilmente lo stesso elite riproposto a generazioni adiacenti.
|
||||
|
||||
---
|
||||
|
||||
## 4. Adversarial findings — il sistema fa il suo lavoro
|
||||
|
||||
Il layer Adversarial Phase 1.5 ha emesso 98 finding sul run:
|
||||
|
||||
| Severità | Check | Conteggio |
|
||||
|----------|-------|-----------|
|
||||
| HIGH | `fees_eat_alpha` (nuovo P1.5) | 35 |
|
||||
| MEDIUM | `overtrading` | 19 |
|
||||
| HIGH | `no_trades` | 16 |
|
||||
| HIGH | `flat_too_long` (nuovo P1.5) | 15 |
|
||||
| HIGH | `time_in_market_too_high` (nuovo P1.5) | 8 |
|
||||
| HIGH | `undertrading` | 4 |
|
||||
| HIGH | `degenerate` | 1 |
|
||||
|
||||
Il dato saliente è che i tre check introdotti in Phase 1.5 — `fees_eat_alpha`, `flat_too_long`, `time_in_market_too_high` — sono effettivamente attivi e killano strategie. In particolare `fees_eat_alpha` è la categoria più popolata: 35 occorrenze HIGH. Esempi tipici dai detail dei finding:
|
||||
|
||||
- `Fees $17073.82 = 2032.6% of gross $840.00`;
|
||||
- `Fees $70646.03 = 12671.9% of gross $557.50`;
|
||||
- `Signal flat for 98.8% of bars (>95% threshold)`.
|
||||
|
||||
Il messaggio è netto: il pool di strategie generato da nemotron, ai prompt e ai gradi di libertà attuali, oscilla tra due estremi degeneri — strategie inattive (flat 98%+) e strategie iperattive (overtrading + fee che divorano l'alpha lordo). Phase 1.5 cattura entrambi gli estremi, ma il loop GA non ha materiale di partenza sano da cui evolvere.
|
||||
|
||||
---
|
||||
|
||||
## 5. Decisione
|
||||
|
||||
**Esito**: NO-GO sulla combinazione `tier C = nemotron` + `Phase 1.5 adversarial` come configurazione di Phase 2.
|
||||
|
||||
Le ragioni a supporto della decisione sono tre.
|
||||
|
||||
Primo, la convergenza è assente per nove generazioni consecutive, non un plateau di selezione raggiunto dopo una fase di salita. Non si tratta cioè di un loop che ha già trovato il suo ottimo e lo conserva, ma di un loop che non ne ha trovato uno.
|
||||
|
||||
Secondo, la distanza dal baseline Phase 1 v5 è di un ordine di grandezza: max fitness `0.0215` qui contro `0.3347` nel run di gate Phase 1, mediana che oscilla sullo zero contro una mediana attorno a `0.005`–`0.09`. Nemotron, in questa configurazione, sta producendo proposte qualitativamente più povere di qwen-2.5-72b nello stesso schema operativo.
|
||||
|
||||
Terzo, i finding adversarial non puntano a un bug del sistema ma a una mancanza di edge nelle proposte. Il loop sta sanzionando correttamente — il problema è a monte, nella generazione.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tre direzioni per Phase 2
|
||||
|
||||
Tre opzioni si configurano per il passo successivo. Vanno valutate prima di una nuova esecuzione, non in parallelo a essa.
|
||||
|
||||
**Direzione A — Riportare tier C a `qwen/qwen-2.5-72b-instruct`** (configurazione di gate Phase 1). Il run di riferimento `phase1-real-005` è già un baseline noto: max fitness `0.3347`, top genome problematico (flat 99.8%) ma generato sotto Phase 1 adversarial. Rilanciare lo stesso pool con Phase 1.5 adversarial isolerebbe l'effetto del solo hardening sul medesimo motore generativo, senza confondere variabili. Questo è il percorso più informativo nel breve.
|
||||
|
||||
**Direzione B — Mantenere nemotron ma rilassare i prompt di Hypothesis**. L'ipotesi alternativa è che il prompting attuale, calibrato su qwen, sia troppo terso o troppo vincolato per la modalità di ragionamento di nemotron. Iterare due o tre versioni del prompt — più esempi few-shot, vincoli espliciti su `n_trades` minimo e `time_in_market` target — può cambiare radicalmente la qualità dell'output senza cambiare il modello.
|
||||
|
||||
**Direzione C — Sostituire il tier C con un modello a pagamento di fascia comparabile**. Tra i benchmark precedenti, `deepseek/deepseek-v4-flash` è già usato come tier A/B nel file `.env`; promuoverlo a tier C significa accettare una spesa marginale (stima $1–3 per run di 10 gen × 20 pop) in cambio di una qualità generativa nota.
|
||||
|
||||
La preferenza dell'operatore per modelli cost-conscious orienta verso A o B. La direzione C resta utile come benchmark di controllo se A e B fallissero a loro volta.
|
||||
|
||||
---
|
||||
|
||||
## 7. Operazioni di pulizia eseguite contestualmente
|
||||
|
||||
- Il run zombie `phase1-real-008` (id `6ebcff9f7f6544c18ced50313cf72ca9`, marcato `running` da 07:11 UTC senza processo associato) è stato chiuso a `status='failed'` direttamente in `runs.db`, per evitare contaminazione delle query di dashboard.
|
||||
- Il commit `9d0deb3` (`fix(llm): handle empty completions + missing usage`) è già su `main`. Il `client.py` ora tratta `resp.choices == []` e `resp.usage is None` come errori retryable invece che assertion failure: precondizione necessaria per qualsiasi run successivo su provider `:free`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Note per chi legge
|
||||
|
||||
Questo memo è un documento di decisione, non un rapporto tecnico completo. Il rapporto tecnico esteso del run può essere ricostruito da `runs.db` interrogando le tabelle `runs`, `generations`, `evaluations`, `adversarial_findings`, `cost_records` con `run_id='434c417e2b6f42bb8cf32514e5d0db1d'`. Il design Phase 1.5 e le motivazioni delle soglie adversarial restano definiti nel commit `56a631f` e nei suoi file di test.
|
||||
@@ -0,0 +1,323 @@
|
||||
# Documento Zero — Da Renaissance a Swarm Co-Evolutivo
|
||||
|
||||
**Autore**: Adriano Dal Pastro
|
||||
**Data**: Maggio 2026
|
||||
**Status**: Sintesi concettuale di partenza
|
||||
**Versione**: 0.1
|
||||
**Documenti correlati**:
|
||||
- `coevolutive_swarm_system.md` (sistema completo)
|
||||
- `poc_trading_swarm.md` (proof of concept trading)
|
||||
|
||||
---
|
||||
|
||||
## 1. Scopo di questo documento
|
||||
|
||||
Questo è il **documento zero** della serie. Non descrive un'implementazione, descrive il **ragionamento di partenza** che ha portato all'architettura proposta negli altri due documenti.
|
||||
|
||||
Lo scopo è fissare il framework concettuale, così che quando riprendiamo il lavoro tra giorni o settimane il contesto non vada perso. Risponde alla domanda: *perché stiamo facendo questo, e perché in questo modo specifico?*
|
||||
|
||||
---
|
||||
|
||||
## 2. Il punto di partenza: Renaissance Technologies
|
||||
|
||||
### 2.1 Cosa è Renaissance
|
||||
|
||||
Hedge fund fondato 1978 da Jim Simons (matematico puro, ex code-breaker NSA, premio Oswald Veblen 1976 in geometria). Sede Long Island. Staff composto principalmente da PhD in fisica, matematica, statistica, signal processing — **zero economisti tradizionali**. Simons è morto a maggio 2024.
|
||||
|
||||
### 2.2 La filosofia opposta a LTCM
|
||||
|
||||
| LTCM | Renaissance |
|
||||
|---------------------------------|--------------------------------------|
|
||||
| Parte da teoria (Black-Scholes) | Parte dai dati |
|
||||
| Cerca conferme | Cerca anomalie |
|
||||
| Modello statico | Modello che si aggiorna ogni giorno |
|
||||
| Crolla quando realtà ≠ teoria | Cambia quando realtà cambia |
|
||||
|
||||
### 2.3 Come opera
|
||||
|
||||
- Non cerca pattern ovvi (spariscono subito quando tutti li vedono)
|
||||
- Cerca decine di anomalie sottili, ognuna con edge minuscolo ma statisticamente significativo. Sommate → edge stabile e potente.
|
||||
- Sistema 100% automatico. Nessun umano decide trade. Simons: "umano = variabile non controllabile".
|
||||
- Ingerisce terabyte di dati al giorno. Scarta pattern morti, incorpora nuovi.
|
||||
- Pattern usati negli '80 oggi non funzionano più — quando un pattern diventa noto, sparisce.
|
||||
|
||||
### 2.4 Numeri (Medallion Fund, fondo interno)
|
||||
|
||||
- ~66% lordo annuo per 30+ anni consecutivi
|
||||
- ~39% netto dopo le fee mostruose (5% management + 44% performance)
|
||||
- Mai un anno in perdita. Compreso 1998 mentre LTCM bruciava 5 miliardi
|
||||
- $100 nel 1988 → $8.4 miliardi oggi (vs $2400 in S&P)
|
||||
- Confronto con Buffett: Buffett 20%/anno = "il migliore al mondo". Simons 3x più di Buffett, per 2x più anni
|
||||
|
||||
---
|
||||
|
||||
## 3. La domanda critica: cosa è replicabile?
|
||||
|
||||
### 3.1 Cosa NON è replicabile
|
||||
|
||||
Il 66% lordo del Medallion non viene da "matematici geniali". Viene da fattori strutturali che retail non può replicare:
|
||||
|
||||
- **Latency e infrastruttura**: co-location, feed di mercato grezzi, esecuzione sub-millisecondo. Il 70% dell'edge sui pattern intraday è banalmente velocità.
|
||||
- **Capacity cap**: Medallion è chiuso a circa $10B perché l'edge non scala. Le anomalie sottili che sfruttano si saturano. Le strategie vere di Renaissance non solo non sono pubbliche — sono *inutili* a chi le rendesse pubbliche.
|
||||
- **Dataset proprietari**: decenni di tick data puliti, dati alternativi, e soprattutto un team di 100+ ricercatori che fa SOLO data cleaning. Il loro vero IP è la qualità del dato, non il modello.
|
||||
- **Risk management con leva istituzionale**: prime broker relationship, dimensioni di hedging fuori portata retail.
|
||||
|
||||
Replicare Renaissance retail è come voler replicare TSMC con una stampante 3D. Non è una questione di intelligenza, è di scala.
|
||||
|
||||
### 3.2 Cosa È replicabile (tradotto in principi operativi)
|
||||
|
||||
I principi filosofici di Renaissance, applicati al contesto LLM-agent + retail:
|
||||
|
||||
1. **"Cerca anomalie, non conferme"** → il prompt non chiede all'agente di valutare se "le condizioni sono buone per X", chiede di identificare regimi in cui la struttura dei dati devia da pattern storici, e adattare la strategia.
|
||||
|
||||
2. **"Edge piccoli sommati"** → su crypto retail un singolo edge da 50bp/trade è realistico, 500bp no. L'architettura deve permettere molte posizioni indipendenti piccole. Renaissance fa migliaia di trade/giorno proprio per questo.
|
||||
|
||||
3. **"Modello che si aggiorna"** → per un sistema LLM questo è sottile: il modello *non si aggiorna* tra una chiamata e l'altra. L'aggiornamento deve essere fuori dal modello, in un layer di state/memory.
|
||||
|
||||
4. **"Umano = variabile non controllabile"** → l'analogo è che *l'LLM stesso* è una variabile non controllabile (temperatura, bias di addestramento). L'LLM deve generare ipotesi, ma le decisioni execute/no-execute devono essere filtrate da regole deterministiche.
|
||||
|
||||
### 3.3 Il vero takeaway: survivorship bias
|
||||
|
||||
La cosa più importante che Simons ripeteva: per ogni Renaissance esistono centinaia di fondi quant chiusi silenziosamente. La differenza spesso non è il modello, è la **disciplina di spegnere strategie quando l'edge svanisce**. Avere metriche oggettive di "questa strategia è morta" che fanno killare il branch automaticamente, senza decisioni emotive.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lo shift mentale: dagli umani agli agenti illimitati
|
||||
|
||||
### 4.1 La differenza categoriale
|
||||
|
||||
Renaissance ha 300 ricercatori. Con LLM agents si possono istanziare 3000 agenti in parallelo a costo marginale quasi zero. Questo non è "più della stessa cosa", è una **categoria diversa di problema**.
|
||||
|
||||
Renaissance deve essere selettiva su quali ipotesi testare perché ogni PhD è caro e lento. Con LLM agents no. Si può permettere di testare ipotesi *stupide*, perché 99.9% di stupide + 0.1% di geniali > 100% di "ragionevoli".
|
||||
|
||||
Cambia tutto il framework. Le strategie quant tradizionali partono da un'ipotesi economica/strutturale e cercano evidenza. Si può fare il contrario: **brute force di ipotesi generate da LLM, filtrate da realtà**.
|
||||
|
||||
### 4.2 Il salto verso l'evoluzione
|
||||
|
||||
Se hai migliaia di agenti che generano ipotesi, il problema diventa: come scoprire quali stili cognitivi producono ipotesi migliori? Risposta: **lascia che siano evolutivamente selezionati**. Non scrivi tu il prompt ottimale, lo scopri attraverso pressione selettiva.
|
||||
|
||||
Questo è il salto concettuale: passare da prompt engineering (artigianato umano) a prompt evolution (ricerca automatica nello spazio dei prompt).
|
||||
|
||||
### 4.3 Il salto ulteriore: linguaggio emergente
|
||||
|
||||
Se gli agenti devono comunicare tra loro, il linguaggio naturale umano è probabilmente subottimale. È stato ottimizzato per vincoli umani (vocal tract, memoria di lavoro limitata, ambiguità sociale utile) che non si applicano agli LLM.
|
||||
|
||||
Si può co-evolvere il **protocollo di comunicazione** insieme agli agenti. Il risultato è un sistema dove agenti e linguaggio si adattano insieme — analogo all'evoluzione del linguaggio scientifico umano (notazione vettoriale + pensiero vettoriale evolvono insieme), ma compresso in giorni invece che millenni.
|
||||
|
||||
---
|
||||
|
||||
## 5. L'architettura emergente: tre layer cognitivi
|
||||
|
||||
Dalla riflessione su Renaissance + LLM swarm + evoluzione, emerge un'architettura specifica con tre ruoli funzionali distinti.
|
||||
|
||||
### 5.1 Hypothesis Layer
|
||||
|
||||
**Ruolo**: generazione creativa di ipotesi su anomalie. Alta temperatura, stili cognitivi diversi, ricerca esplorativa.
|
||||
|
||||
**Diversità cognitiva intenzionale**: agenti con prompt che li fanno "pensare come un fisico", "come un biologo evolutivo", "come uno storico", "come un meteorologo". Stili diversi → ipotesi diverse.
|
||||
|
||||
### 5.2 Falsification Layer
|
||||
|
||||
**Ruolo**: testare rigorosamente. Bassa temperatura, focus su statistica deterministica.
|
||||
|
||||
**Funzione critica**: questo NON è LLM puro. È codice tradizionale (backtesting, walk-forward, multiple testing correction) orchestrato da LLM. L'LLM non deve mai *valutare* se una strategia funziona, deve *eseguire* test rigorosi e leggere risultati numerici.
|
||||
|
||||
### 5.3 Adversarial Layer
|
||||
|
||||
**Ruolo**: red team epistemico. Cerca data snooping, lookahead bias, regime fragility, crowding. Paranoia strutturale.
|
||||
|
||||
**Per ogni strategia che sopravvive**, l'agente fa l'avvocato del diavolo: "perché questa strategia *deve* fallire?". Solo strategie che sopravvivono al red team passano alla fase successiva.
|
||||
|
||||
### 5.4 Perché tre e non uno
|
||||
|
||||
I tre ruoli hanno requisiti cognitivi opposti:
|
||||
- Hypothesis vuole creatività, alto temperature
|
||||
- Falsification vuole rigore, low temperature
|
||||
- Adversarial vuole paranoia, medium temperature
|
||||
|
||||
Un singolo agente non può fare tutti e tre bene. Specializzarli e farli collaborare produce risultati qualitativamente migliori, e abilita evoluzione separata di ogni nicchia.
|
||||
|
||||
---
|
||||
|
||||
## 6. I tre filoni che si sono sviluppati
|
||||
|
||||
Dal framework concettuale base si sono sviluppati tre filoni, ognuno documentato (o da documentare) separatamente.
|
||||
|
||||
### 6.1 Filone A — Sistema completo
|
||||
|
||||
Co-evoluzione di **quattro popolazioni**: tre layer cognitivi + il protocollo di comunicazione.
|
||||
|
||||
**Caratteristiche**:
|
||||
- Co-evolution multi-species
|
||||
- Speciation (NEAT-style)
|
||||
- Idiom emergence nel protocollo
|
||||
- Register specialization per layer
|
||||
- Tier multi-model (Claude + Qwen + Llama via OpenRouter) come risorsa di diversità cognitiva
|
||||
- Human-in-the-loop calibration
|
||||
|
||||
**Portata**: 12-18 mesi a impegno significativo. Sistema generale applicabile a multipli domini (trading, offerte commerciali, code review).
|
||||
|
||||
**Documento di riferimento**: `coevolutive_swarm_system.md`
|
||||
|
||||
### 6.2 Filone B — PoC trading focalizzato
|
||||
|
||||
Versione semplificata per validare empiricamente il concetto prima di committere al sistema completo.
|
||||
|
||||
**Caratteristiche**:
|
||||
- Solo Hypothesis layer evolve (Falsification e Adversarial hand-crafted)
|
||||
- Protocollo fisso (no co-evolution del linguaggio)
|
||||
- Focus su trading BTC/ETH con storico multi-anno
|
||||
- Baseline Random Forest per anti-illusion check
|
||||
- Decision triggers oggettivi per go/iterate/pivot
|
||||
|
||||
**Portata**: 3-4 mesi. Costo ~$2-4K LLM.
|
||||
|
||||
**Documento di riferimento**: `poc_trading_swarm.md`
|
||||
|
||||
### 6.3 Filone C — Applicazioni non-trading
|
||||
|
||||
Stesso framework architetturale applicato a domini con ground truth diverso:
|
||||
- Generazione offerte commerciali Tielogic (dataset implicito: offerte passate firmate vs no)
|
||||
- Code review automatico (dataset: PR storiche con bug noti)
|
||||
- Documentazione Swagger (fitness: copertura + qualità)
|
||||
- Scrittura documentazione tecnica
|
||||
|
||||
**Portata**: parallelizzabile con Filone A. Validazione che il sistema generalizzi.
|
||||
|
||||
**Documento di riferimento**: da scrivere se si decide di approfondire.
|
||||
|
||||
---
|
||||
|
||||
## 7. Le quattro idee non-ovvie
|
||||
|
||||
Sintesi delle intuizioni emerse durante la riflessione, in ordine di profondità:
|
||||
|
||||
### 7.1 Diversità cognitiva via tier multi-model
|
||||
|
||||
Non è solo "tier economici come compromesso". È che modelli diversi (Claude, Qwen, Llama, DeepSeek) hanno bias di addestramento diversi e producono ipotesi qualitativamente diverse sullo stesso dato. Per ricerca esplorativa è oro. Manterrei il multi-tier *anche se Anthropic dimezzasse i prezzi domani*.
|
||||
|
||||
### 7.2 Il protocollo di comunicazione come risorsa evolvibile
|
||||
|
||||
Forzare gli agenti a comunicare in linguaggio naturale è una scelta arbitraria, probabilmente subottimale. Co-evolvere protocollo + agenti significa che i pattern cognitivi che il sistema scopre dipendono da quali pensieri sono *cheap da esprimere*. E il protocollo evolve per rendere cheap i pensieri che si rivelano utili. Loop di co-adattamento, come grammatica e pensiero scientifico nelle culture umane.
|
||||
|
||||
### 7.3 Vincolo di leggibilità non opzionale
|
||||
|
||||
Senza vincoli, gli agenti convergono a protocolli incomprensibili. Per safety, audit, compliance, debugging — la fitness del protocollo deve includere `human_unreadability_score` con λ esplicito. Il sistema vive sul Pareto front efficienza/interpretabilità, scelto consapevolmente.
|
||||
|
||||
### 7.4 Survivorship bias narrativo
|
||||
|
||||
Sia chiaro: Renaissance è UN risultato di una distribuzione di tentativi. Per ogni Renaissance ci sono centinaia di fondi quant morti silenziosamente. Lo stesso vale per i sistemi LLM-based: la maggior parte non funzionerà. Il PoC esiste proprio per essere un esperimento *honestly designed to fail* se l'idea non regge. La baseline non-LLM (Random Forest) è il guardrail anti-illusione.
|
||||
|
||||
---
|
||||
|
||||
## 8. Le quattro trappole strutturali
|
||||
|
||||
In ordine di gravità, le trappole che possono distruggere qualsiasi versione del sistema:
|
||||
|
||||
### 8.1 Goodhart's Law su steroidi
|
||||
|
||||
L'evoluzione è una macchina ottimizzatrice cieca e troverà *qualunque* exploit della fitness function. Mitigazione: fitness multi-livello, human-in-the-loop, audit periodici dei top performer.
|
||||
|
||||
### 8.2 Multiple testing massiccio
|
||||
|
||||
Con migliaia di ipotesi testate, false scoperte a valanga. Mitigazione: Deflated Sharpe Ratio (Bailey & López de Prado), Bonferroni aggressiva, hold-out set finale intoccabile.
|
||||
|
||||
### 8.3 Communication degeneration
|
||||
|
||||
Senza vincoli, agenti convergono a comunicazione minimale e incomprensibile. Mitigazione: penalty leggibilità, parser strict, audit umano, italian rendering automatico.
|
||||
|
||||
### 8.4 Convergenza prematura / collasso di diversità
|
||||
|
||||
Senza speciation e novelty bonus, dopo 50 generazioni saturi su un ottimo locale. Mitigazione: speciation, novelty search, immigrazione random periodica, island model.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisione strategica: tre opzioni
|
||||
|
||||
Le opzioni concrete davanti, in ordine di rischio crescente:
|
||||
|
||||
**Opzione C — Research dive** (1-2 mesi)
|
||||
- Paper review approfondito (PromptBreeder, NEAT, MAP-Elites, emergent communication)
|
||||
- Proof-of-concept minimo (1 layer, 1 popolazione, protocol semplice)
|
||||
- Output: decisione informata se vale tutto
|
||||
|
||||
**Opzione B — Smart spike** (3-4 mesi) ← **scelta preferita per partire**
|
||||
- PoC trading completo (`poc_trading_swarm.md`)
|
||||
- Infrastruttura riutilizzabile
|
||||
- Output: validazione empirica + infrastruttura per espansione
|
||||
|
||||
**Opzione A — Big bet** (12-18 mesi)
|
||||
- Sistema completo (`coevolutive_swarm_system.md`)
|
||||
- Co-evolution piena, applicazioni multi-dominio
|
||||
- Output: sistema di ricerca/produzione, possibile paper
|
||||
|
||||
**Razionale per partire da B**: minimizza opportunity cost rispetto agli altri progetti (Tielogic, robotics, OptionScalping), produce infrastruttura riutilizzabile, dà criteri oggettivi per decidere se procedere con A.
|
||||
|
||||
---
|
||||
|
||||
## 10. Perché questo progetto, perché ora
|
||||
|
||||
### 10.1 Posizione strategica
|
||||
|
||||
Adriano è in una posizione strana e fortunata:
|
||||
- Background ingegneristico solido (C, C++, C#, Python da 30 anni)
|
||||
- Use case reali multipli (trading, offerte commerciali, robotica Tielogic)
|
||||
- Motivazione applicativa (non puramente accademica)
|
||||
- Familiarità con LLM tooling (Claude Code attivo nel workflow)
|
||||
- Capacità di lavorare full-stack (dal Rust backend al frontend React)
|
||||
|
||||
### 10.2 Stato del campo
|
||||
|
||||
Il filone "evolved structured communication languages for LLM multi-agent systems with interpretability constraints" non è saturo. La community è bloccata su due estremi: JSON+natural language (boring), o emergent communication su small models (non scalato a LLM moderni). C'è spazio per contributi reali.
|
||||
|
||||
### 10.3 Tempistica delle infrastrutture
|
||||
|
||||
Maggio 2026: i prezzi dei modelli sono scesi abbastanza da rendere economicamente fattibile un GA con migliaia di chiamate. OpenRouter offre routing multi-model trasparente. Anthropic ha prompt caching aggressivo. Tooling open-source maturo (DEAP, DSPy, TextGrad). **Tre anni fa questo progetto sarebbe stato troppo costoso. Tre anni avanti potrebbe essere già commoditizzato**.
|
||||
|
||||
### 10.4 Honest assessment
|
||||
|
||||
Detto tutto questo: il progetto è ambizioso, non garantito, e in competizione di tempo con altri progetti reali (Tielogic clienti, OptionScalping, robotics). La via responsabile è l'**Opzione B**: validare con il PoC, decidere a posteriori se vale espandere. Big bet su sistema completo solo dopo evidenza empirica positiva.
|
||||
|
||||
---
|
||||
|
||||
## 11. Stato del lavoro al 9 maggio 2026
|
||||
|
||||
**Completato**:
|
||||
- Framework concettuale (questo documento)
|
||||
- Design completo sistema (`coevolutive_swarm_system.md`)
|
||||
- Design completo PoC (`poc_trading_swarm.md`)
|
||||
|
||||
**Decisioni aperte prima di partire**:
|
||||
1. Quale opzione (A/B/C)?
|
||||
2. Se B, quale dominio iniziale (trading vs offerte commerciali)?
|
||||
3. Hardware locale vs cloud?
|
||||
4. Budget LLM iniziale committed?
|
||||
5. Cadenza review umana sostenibile?
|
||||
|
||||
**Prossimi passi se si decide GO sul PoC (Opzione B)**:
|
||||
- Settimane 1-3: setup + dataset (vedi roadmap PoC sez. 11)
|
||||
- Decisioni infrastruttura (data provider, storage, compute)
|
||||
- Cap budget chiaro e milestone bi-settimanali
|
||||
|
||||
---
|
||||
|
||||
## 12. Tracciabilità della catena di ragionamento
|
||||
|
||||
Per riferimento futuro, questa è la sequenza logica che ha portato all'architettura proposta:
|
||||
|
||||
1. **Renaissance come modello**: dati > teoria, anomalie > conferme, automatico > umano
|
||||
2. **Cosa è replicabile**: principi filosofici sì, infrastruttura no
|
||||
3. **LLM agents shift**: scala diversa rende possibile brute force di ipotesi
|
||||
4. **Tre layer cognitivi**: Hypothesis + Falsification + Adversarial come specializzazione funzionale necessaria
|
||||
5. **Evoluzione genetica**: scoprire prompt ottimali invece di scriverli
|
||||
6. **Co-evolution multi-popolazione**: i tre layer evolvono insieme
|
||||
7. **Linguaggio evolutivo**: il protocollo di comunicazione è artefatto evolvibile
|
||||
8. **Tier multi-model**: diversità cognitiva + economia
|
||||
9. **Vincoli di leggibilità**: non opzionali per safety/audit
|
||||
10. **PoC come deviazione strategica**: validare prima di committere full
|
||||
|
||||
Ogni step è un pezzo del ragionamento che vale la pena ricordare. Se uno step si rivela sbagliato, gli step successivi vanno rivisti.
|
||||
|
||||
---
|
||||
|
||||
*Documento da aggiornare se la direzione strategica cambia. Versione 0.1 — pre-implementazione.*
|
||||
@@ -0,0 +1,660 @@
|
||||
# Sistema Co-Evolutivo Multi-Agente con Linguaggio Emergente
|
||||
|
||||
**Autore**: Adriano Dal Pastro
|
||||
**Data**: Maggio 2026
|
||||
**Status**: Design document — pre-implementazione
|
||||
**Versione**: 0.1
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Sistema di ricerca quantitativa basato su **co-evoluzione di quattro popolazioni**: tre layer cognitivi (Hypothesis / Falsification / Adversarial) e un protocollo di comunicazione che evolve insieme agli agenti. L'obiettivo non è "un trading bot più intelligente", ma un'**architettura cognitiva ecologica** capace di scoprire pattern e concetti non programmati esplicitamente.
|
||||
|
||||
L'introduzione di un **tier multi-model** (Anthropic Claude tramite API + modelli economici tipo Qwen via OpenRouter) cambia radicalmente l'economia del sistema: rende fattibili popolazioni 5-10x più grandi e numero di generazioni 3-5x più alto a parità di budget, abilitando regimi evolutivi prima irraggiungibili per progetti retail.
|
||||
|
||||
**Domini di applicazione iniziali**:
|
||||
- Trading derivati crypto (Deribit options, Hyperliquid perps) — caso primario
|
||||
- Generazione offerte commerciali Tielogic — caso secondario, ad alto ROI immediato
|
||||
- Code review e generazione documentazione — caso terziario
|
||||
|
||||
---
|
||||
|
||||
## 2. Architettura del Sistema
|
||||
|
||||
### 2.1 Visione d'insieme
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ PROTOCOL GENOME (PG) │
|
||||
│ - syntax (fixed) │
|
||||
│ - vocabulary (evolves) │
|
||||
│ - idioms (emerge) │
|
||||
│ - registers (specialize)│
|
||||
└────────────┬────────────┘
|
||||
│ specifies
|
||||
▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ HYPOTHESIS │ │ FALSIFICATION│ │ ADVERSARIAL │
|
||||
│ SWARM │◄──►│ SWARM │◄──►│ SWARM │
|
||||
│ │ │ │ │ │
|
||||
│ K_h agents │ │ K_f agents │ │ K_a agents │
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ SHARED ENVIRONMENT │
|
||||
│ - market data │
|
||||
│ - backtest engine │
|
||||
│ - message bus │
|
||||
│ - audit log │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ FITNESS COMPUTATION│
|
||||
│ - per-agent │
|
||||
│ - per-team │
|
||||
│ - per-protocol │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Le quattro popolazioni co-evolventi
|
||||
|
||||
#### 2.2.1 Hypothesis Swarm
|
||||
|
||||
**Ruolo**: generare ipotesi su anomalie di mercato. Alta temperatura, stili cognitivi diversi, ricerca esplorativa.
|
||||
|
||||
**Genoma**:
|
||||
```python
|
||||
@dataclass
|
||||
class HypothesisAgentGenome:
|
||||
system_prompt: str # ruolo cognitivo
|
||||
feature_access: list[str] # subset feature disponibili
|
||||
temperature: float # 0.7 - 1.3 tipico
|
||||
top_p: float
|
||||
model_tier: ModelTier # vedi sez. 4
|
||||
lookback_window: int # giorni di storico visibili
|
||||
cognitive_style: str # "physicist", "biologist", "historian"...
|
||||
protocol_dialect_bias: dict # preferenze su verbi del protocollo
|
||||
```
|
||||
|
||||
**Output atteso**: ipotesi formalizzate nel protocollo, tipo:
|
||||
```
|
||||
PROPOSE_HYPOTHESIS(
|
||||
pattern="vol_skew_pre_FOMC",
|
||||
conditions=[regime=normal, days_to_event<=7],
|
||||
predicted_effect=skew_widening,
|
||||
confidence=0.6,
|
||||
rationale_ref=#analysis_142
|
||||
)
|
||||
```
|
||||
|
||||
#### 2.2.2 Falsification Swarm
|
||||
|
||||
**Ruolo**: testare rigorosamente le ipotesi. Bassa temperatura, focus su statistica, walk-forward, multiple testing correction.
|
||||
|
||||
**Genoma**:
|
||||
```python
|
||||
@dataclass
|
||||
class FalsificationAgentGenome:
|
||||
system_prompt: str
|
||||
statistical_test_preferences: list[str] # ["bonferroni", "BH", "white_reality_check"]
|
||||
rigor_level: float # quanto è severa la soglia
|
||||
walk_forward_config: WalkForwardConfig
|
||||
temperature: float # 0.1 - 0.4 tipico
|
||||
model_tier: ModelTier
|
||||
```
|
||||
|
||||
**Caratteristica importante**: il falsification layer NON valuta soggettivamente. Esegue test deterministici e li interpreta. Questo lo rende perfetto per modelli economici.
|
||||
|
||||
#### 2.2.3 Adversarial Swarm
|
||||
|
||||
**Ruolo**: red team epistemico. Cerca data snooping, lookahead bias, regime fragility, crowding. Paranoia strutturale.
|
||||
|
||||
**Genoma**:
|
||||
```python
|
||||
@dataclass
|
||||
class AdversarialAgentGenome:
|
||||
system_prompt: str
|
||||
attack_vocabulary: list[str] # tipi di attacchi noti
|
||||
paranoia_level: float # quanto aggressivo
|
||||
historical_failure_db: str # quali falsi positivi ricordare
|
||||
temperature: float # 0.5 - 0.9 tipico
|
||||
model_tier: ModelTier
|
||||
```
|
||||
|
||||
#### 2.2.4 Protocol Genome
|
||||
|
||||
**Ruolo**: meta-popolazione. Non è un agente, è la specifica del linguaggio che gli agenti usano.
|
||||
|
||||
**Genoma**:
|
||||
```python
|
||||
@dataclass
|
||||
class ProtocolGenome:
|
||||
syntax: Grammar # FISSA: S-expr like
|
||||
vocabulary: dict[str, VerbDefinition] # EVOLVE
|
||||
idioms: list[Macro] # EMERGONO
|
||||
registers: dict[Layer, VocabSubset] # EMERGONO
|
||||
type_system: TypeSchema # EVOLVE lentamente
|
||||
confidence_grammar: ConfidenceSchema # come esprimere uncertainty
|
||||
```
|
||||
|
||||
**Sintassi base** (immutabile per garantire parsabilità):
|
||||
```
|
||||
expression := (verb arg*)
|
||||
arg := atom | expression | reference
|
||||
reference := #identifier
|
||||
atom := number | string | symbol | typed_value
|
||||
typed_value := <type:value:metadata>
|
||||
```
|
||||
|
||||
**Vocabolario iniziale** (15-20 verbi base, poi evolve):
|
||||
```
|
||||
PROPOSE_HYPOTHESIS, ASSERT_EVIDENCE, CHALLENGE,
|
||||
REFINE, CITE, COMMIT, REJECT, QUERY,
|
||||
EXPRESS_UNCERTAINTY, REGIME_CONDITION, FEATURE_DRIFT,
|
||||
SAMPLE_REQUEST, REPLAY_REQUEST, BIND_VARIABLE, REPORT_RESULT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Meccanismi Evolutivi
|
||||
|
||||
### 3.1 Operatori genetici per agenti
|
||||
|
||||
**Mutazione del system prompt**:
|
||||
- Strategia A (LLM-as-mutator): un modello tier-medio riscrive il prompt con istruzione "modifica un aspetto cognitivo mantenendo intent". Output sempre coerente.
|
||||
- Strategia B (structured): il prompt è scomposto in sezioni (role, context, instructions, constraints, examples, output_format), mutazione opera dentro una sezione.
|
||||
- **Scelta**: ibrido B+C, per garantire validità sintattica e ricchezza semantica.
|
||||
|
||||
**Crossover di feature set**: classico, 50/50 da due parent.
|
||||
|
||||
**Mutazione di iperparametri**: gaussiana su temperature/top_p, discreta su model_tier (raramente).
|
||||
|
||||
**Mutazione di model_tier**: rara (5%), perché cambia significativamente costi e capability. Vedi sez. 4.
|
||||
|
||||
### 3.2 Operatori genetici per protocollo
|
||||
|
||||
**Mutazione di vocabolario**:
|
||||
- Add verb: il sistema propone nuovo verbo basato su pattern di uso (frasi ricorrenti che meriterebbero verbo dedicato)
|
||||
- Remove verb: verbi sotto soglia di uso vengono deprecati
|
||||
- Modify schema: aggiungi/rimuovi argomenti di un verbo esistente
|
||||
- Rename: alias semantici emergono
|
||||
|
||||
**Idiom emergence** (la parte più interessante):
|
||||
- Il sistema rileva sequenze di verbi che appaiono frequentemente insieme con alta correlazione a fitness team
|
||||
- Queste sequenze diventano "idioms" — macro che possono essere espanse o usate come unità
|
||||
- Esempio: `[CHALLENGE + REFINE + RE-CHALLENGE]` ricorrente → idiom `STRESS_TEST(target=#X, depth=3)`
|
||||
|
||||
**Register specialization**:
|
||||
- Tracking di quali verbi vengono usati prevalentemente da quale layer
|
||||
- Se un verbo è usato >80% da un layer specifico, viene marcato come "register-specific"
|
||||
- Riduce overhead cognitivo: un agente non deve imparare verbi che non userà
|
||||
|
||||
### 3.3 Selezione e riproduzione
|
||||
|
||||
**Selection method**: tournament selection (k=5), più robusto di roulette wheel su fitness rumorose.
|
||||
|
||||
**Speciation** (NEAT-style):
|
||||
- Cluster agenti per similarità semantica del prompt (embedding + cosine distance)
|
||||
- Fitness sharing dentro la specie
|
||||
- Effetto: nicchie cognitive diverse coesistono (mean-reversion, momentum, vol-arb, ecc.)
|
||||
|
||||
**Reproduction mix**:
|
||||
- 60% crossover tra parent della stessa specie
|
||||
- 25% mutazione singolo parent
|
||||
- 10% inter-species crossover (rare, può produrre breakthrough)
|
||||
- 5% immigrazione (agenti random nuovi, anti-stagnazione)
|
||||
|
||||
**Elitismo**: top 5% per popolazione sopravvive non modificato.
|
||||
|
||||
### 3.4 Co-evolution dynamics
|
||||
|
||||
Le quattro popolazioni evolvono in **frequenze diverse**:
|
||||
|
||||
| Popolazione | Frequenza evoluzione | Motivo |
|
||||
|---------------|----------------------|-------------------------------------------|
|
||||
| Hypothesis | Ogni generazione | Esplorazione massiva |
|
||||
| Falsification | Ogni generazione | Adattamento ai nuovi tipi di ipotesi |
|
||||
| Adversarial | Ogni 2 generazioni | Più stabile, paranoia richiede consistenza |
|
||||
| Protocol | Ogni 5 generazioni | Cambiamenti lenti, evita instabilità |
|
||||
|
||||
**Razionale**: il protocollo cambia lentamente perché è il "terreno comune". Cambi rapidi del protocollo destabilizzano tutte le popolazioni che ci comunicano. È analogo a come la grammatica evolve più lentamente del lessico nelle lingue umane.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tier Multi-Model: l'innovazione economica
|
||||
|
||||
### 4.1 Il problema dei costi
|
||||
|
||||
Con solo Claude Sonnet/Opus, una run completa di 500 generazioni costa $30-50K. Fattibile ma limitante: non puoi sperimentare liberamente, ogni run è un evento.
|
||||
|
||||
### 4.2 La soluzione: stratificazione per ruolo cognitivo
|
||||
|
||||
Non tutti gli agenti hanno bisogno di un modello frontier. Diversi ruoli hanno diversi requisiti cognitivi.
|
||||
|
||||
**Tier definiti**:
|
||||
|
||||
| Tier | Modello esempio | Provider | Costo (in/out per Mtok) | Use case |
|
||||
|--------|-----------------------------------|-------------|-------------------------|-----------------------------------------|
|
||||
| Tier-S | Claude Opus 4.7 | Anthropic | $15 / $75 | Hypothesis creativi, ricerca breakthrough|
|
||||
| Tier-A | Claude Sonnet 4.6 | Anthropic | $3 / $15 | Adversarial sofisticati, reasoning denso |
|
||||
| Tier-B | Qwen3-Max / DeepSeek-V3 | OpenRouter | $0.5-1 / $1-3 | Falsification statistica, executors |
|
||||
| Tier-C | Qwen3-30B / Llama 3.3 70B | OpenRouter | $0.1-0.3 / $0.3-0.6 | Massa Hypothesis esplorativi, mutators |
|
||||
| Tier-D | Modelli small open-source | OpenRouter | $0.05-0.1 | Validation parsing, formatting checks |
|
||||
|
||||
**Nota sui prezzi**: i numeri sopra sono indicativi al maggio 2026, ma OpenRouter aggiorna continuamente — verificare prima di run reali. La gerarchia di costi è stabile, le cifre esatte fluttuano.
|
||||
|
||||
### 4.3 Allocazione cognitiva per layer
|
||||
|
||||
| Layer | Tier-S | Tier-A | Tier-B | Tier-C | Tier-D |
|
||||
|---------------|--------|--------|--------|--------|--------|
|
||||
| Hypothesis | 5% | 15% | 30% | 50% | 0% |
|
||||
| Falsification | 0% | 10% | 60% | 30% | 0% |
|
||||
| Adversarial | 10% | 40% | 40% | 10% | 0% |
|
||||
| Protocol mut. | 0% | 30% | 50% | 20% | 0% |
|
||||
| Validators | 0% | 0% | 0% | 0% | 100% |
|
||||
|
||||
**Razionale per layer**:
|
||||
|
||||
- **Hypothesis** beneficia di diversità cognitiva. Pochi Tier-S per breakthrough rari, massa Tier-C per esplorazione ampia. Modelli diversi hanno bias cognitivi diversi (Qwen vs Claude vs Llama producono ipotesi qualitativamente diverse — questo è feature, non bug).
|
||||
- **Falsification** è principalmente esecuzione di test statistici e interpretazione di numeri. Tier-B/C bastano. Errori qui sono più costosi però (falso negativo = strategia cattiva passa) → nessun Tier-D.
|
||||
- **Adversarial** richiede reasoning sofisticato (riconoscere lookahead bias subtle, regime dependency). Più peso su Tier-A/B.
|
||||
- **Validators**: parser, formattatori, basic checks → Tier-D è perfetto, costo trascurabile.
|
||||
|
||||
### 4.4 Cross-model diversity come risorsa
|
||||
|
||||
Punto **non ovvio ma importante**: avere modelli da provider diversi nelle popolazioni produce *diversità cognitiva intrinseca*. Claude e Qwen e Llama sono stati addestrati su corpus diversi, con RLHF diverse, con bias diversi. Per ricerca esplorativa questo è oro.
|
||||
|
||||
Esempio concreto: su ipotesi crypto, Qwen tende a privilegiare considerazioni di market microstructure che vengono dalla sua forte esposizione a contenuti tech cinesi. Claude tende a essere più cauto sulle estrapolazioni statistiche. Llama produce associazioni più libere. Mescolarli porta a portafoglio di ipotesi più ricco di quanto produrrebbe una sola famiglia di modelli, anche se tutti i modelli fossero al top tier.
|
||||
|
||||
Questo è un argomento *non economico* per il multi-tier. Vale la pena anche se Anthropic abbassasse drasticamente i prezzi.
|
||||
|
||||
### 4.5 Economia rivista
|
||||
|
||||
Con tier multi-model:
|
||||
|
||||
- Generazione singola con popolazione K=300 (vs K=180 originale): ~$15-25 (vs $50-80)
|
||||
- 500 generazioni: $7-12K (vs $25-40K)
|
||||
- Risparmio: ~3x su scala operativa
|
||||
- **Oppure** a parità di budget: K=900, 1500 generazioni → regime evolutivo qualitativamente diverso
|
||||
|
||||
A regime evolutivo "alto" (K grandi, generazioni numerose) si manifestano fenomeni che a regime basso non appaiono: speciation stabile, idiom emergence frequente, breakthrough rari ma cumulativi.
|
||||
|
||||
### 4.6 Considerazioni operative su OpenRouter
|
||||
|
||||
**Vantaggi**:
|
||||
- Singolo endpoint per N modelli, switching facile
|
||||
- Fallback automatico se un provider è giù
|
||||
- Pricing trasparente, no enterprise contracts richiesti
|
||||
- Rate limits aggregati ragionevoli
|
||||
|
||||
**Svantaggi**:
|
||||
- Latenza variabile (modelli ospitati da provider diversi)
|
||||
- Quality fluctuations (alcuni provider hanno hosting subottimo)
|
||||
- Mancanza di prompt caching per modelli non-Anthropic (Claude rimane vantaggio per long-context)
|
||||
- Privacy policy variabili per modello (verificare per dati sensibili — non un problema per dati di mercato pubblici)
|
||||
|
||||
**Configurazione consigliata**:
|
||||
```python
|
||||
class ModelRouter:
|
||||
def call(self, agent_genome, messages):
|
||||
tier = agent_genome.model_tier
|
||||
if tier == ModelTier.S:
|
||||
return anthropic_client.call("claude-opus-4-7", messages)
|
||||
elif tier == ModelTier.A:
|
||||
return anthropic_client.call("claude-sonnet-4-6", messages)
|
||||
elif tier == ModelTier.B:
|
||||
return openrouter.call("qwen/qwen3-max", messages)
|
||||
elif tier == ModelTier.C:
|
||||
return openrouter.call("qwen/qwen3-30b-instruct", messages)
|
||||
elif tier == ModelTier.D:
|
||||
return openrouter.call("meta-llama/llama-3.3-8b", messages)
|
||||
```
|
||||
|
||||
**Fallback policy**: se Tier-C fallisce 3 volte di fila (timeout, malformed output), promuovi temporaneamente a Tier-B per quell'agente. Loggato per evitare gaming evolutivo della failure rate.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fitness Function (la parte critica)
|
||||
|
||||
### 5.1 Tre livelli di fitness
|
||||
|
||||
**Per agent** (selezione intra-popolazione):
|
||||
```
|
||||
agent_fitness = quality_of_contributions
|
||||
- communication_overhead
|
||||
- cost_penalty (proporzionale al tier usato)
|
||||
- genealogy_redundancy_penalty
|
||||
```
|
||||
|
||||
Il `cost_penalty` è cruciale: senza di esso, l'evoluzione tende a promuovere Tier-S su tutto. Con esso, gli agenti devono "guadagnarsi" il diritto a tier costosi mostrando contribuzioni proporzionali.
|
||||
|
||||
**Per team** (combinazioni h+f+a che funzionano insieme):
|
||||
```
|
||||
team_fitness = OOS_alpha_discovered
|
||||
+ bug_caught_rate
|
||||
- time_to_consensus
|
||||
- false_positive_rate
|
||||
- total_cost_per_episode
|
||||
```
|
||||
|
||||
**Per protocol** (vocabolario/idiomi che meritano di rimanere):
|
||||
```
|
||||
protocol_fitness = avg_team_fitness_using_this_protocol
|
||||
/ avg_message_length
|
||||
- λ * human_unreadability_score
|
||||
+ idiom_compression_ratio
|
||||
- vocabulary_bloat_penalty
|
||||
```
|
||||
|
||||
### 5.2 Human-in-the-loop calibration
|
||||
|
||||
**Non opzionale**. Ogni 20 generazioni:
|
||||
- Sample top 10 ipotesi → judgment umano (insight reale vs gameificazione)
|
||||
- Sample top 10 idiomi protocol → judgment umano (concetto sensato vs gibberish funzionale)
|
||||
- Sample top 5 attacchi adversarial → judgment umano (argument valido vs sofisma)
|
||||
|
||||
Il giudizio umano retroattivamente penalizza pattern fraudolenti. Questo è il **vincolo che impedisce divergenza creativa-ma-inutile**.
|
||||
|
||||
### 5.3 Anti-gaming measures
|
||||
|
||||
**Trappole noti e mitigazioni**:
|
||||
|
||||
| Trappola | Manifestazione | Mitigazione |
|
||||
|---------------------------|-----------------------------------------------|------------------------------------------------|
|
||||
| Communication degeneration| Messaggi minimali tipo "42" | Penalty su brevità eccessiva + parser test |
|
||||
| Layer collapse | Popolazioni convergono a strategie omogenee | Speciation forte, novelty bonus, task rotation|
|
||||
| Protocol overfitting | Idiomi specifici al task corrente | Rotation di task durante evoluzione |
|
||||
| Adversarial collusion | Layer si rinforzano in modo perverso | Fitness globale del team su PnL OOS reale |
|
||||
| Tier inflation | Tutti gli agenti migrano a Tier-S | Cost penalty proporzionale nel agent_fitness |
|
||||
| Backtest exploit | Sfruttare bug del backtest engine | Audit periodici dei top performer + adversarial test sintetici |
|
||||
|
||||
---
|
||||
|
||||
## 6. Stack Tecnico
|
||||
|
||||
### 6.1 Linguaggi e framework
|
||||
|
||||
- **Backbone Python** per orchestrazione, GA, agent management (DEAP framework)
|
||||
- **Rust** per backtest engine (performance critica, deterministica)
|
||||
- **PostgreSQL + pgvector** per genomi, embedding, audit log
|
||||
- **Redis** per message bus tra agenti durante episodio
|
||||
- **Docker Compose** per deployment locale
|
||||
- **Anthropic SDK** + **OpenRouter API** via httpx
|
||||
- **Ray** o **asyncio + aiohttp** per parallelizzazione agenti
|
||||
|
||||
### 6.2 Storage schema (essenziale)
|
||||
|
||||
```sql
|
||||
-- Genomi con genealogia completa
|
||||
CREATE TABLE agent_genomes (
|
||||
id UUID PRIMARY KEY,
|
||||
layer TEXT NOT NULL,
|
||||
generation INT NOT NULL,
|
||||
species_id UUID,
|
||||
parent_ids UUID[],
|
||||
genome_data JSONB,
|
||||
embedding VECTOR(1536),
|
||||
fitness FLOAT,
|
||||
created_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE protocol_genomes (
|
||||
id UUID PRIMARY KEY,
|
||||
generation INT,
|
||||
vocabulary JSONB,
|
||||
idioms JSONB,
|
||||
fitness FLOAT
|
||||
);
|
||||
|
||||
-- Audit log dei messaggi (cresce velocemente, considerare retention)
|
||||
CREATE TABLE message_log (
|
||||
id UUID PRIMARY KEY,
|
||||
episode_id UUID,
|
||||
timestamp TIMESTAMPTZ,
|
||||
source_agent_id UUID,
|
||||
protocol_id UUID,
|
||||
raw_message TEXT,
|
||||
parsed_ast JSONB,
|
||||
italian_render TEXT, -- per audit umano
|
||||
parse_valid BOOLEAN
|
||||
);
|
||||
|
||||
-- Episodi (run di team)
|
||||
CREATE TABLE episodes (
|
||||
id UUID PRIMARY KEY,
|
||||
generation INT,
|
||||
team_composition JSONB, -- {h: agent_id, f: agent_id, a: agent_id}
|
||||
protocol_id UUID,
|
||||
task_id UUID,
|
||||
fitness FLOAT,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Risultati discovery (per review umana)
|
||||
CREATE TABLE discoveries (
|
||||
id UUID PRIMARY KEY,
|
||||
episode_id UUID,
|
||||
discovery_type TEXT, -- "alpha", "idiom", "attack_pattern"
|
||||
content JSONB,
|
||||
human_review_status TEXT, -- pending/valid/gaming/spurious
|
||||
human_notes TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### 6.3 Esempio di message bus auditato
|
||||
|
||||
```python
|
||||
class AuditedMessageBus:
|
||||
async def send(self, source_agent: AgentGenome,
|
||||
target_layer: Layer,
|
||||
message: str,
|
||||
protocol: ProtocolGenome) -> bool:
|
||||
# 1. Parse with current protocol
|
||||
try:
|
||||
ast = protocol.parse(message)
|
||||
except ParseError as e:
|
||||
self.log_invalid(source_agent, message, e)
|
||||
return False # silent drop
|
||||
|
||||
# 2. Type check
|
||||
if not protocol.type_check(ast):
|
||||
self.log_invalid(source_agent, message, "type_error")
|
||||
return False
|
||||
|
||||
# 3. Italian rendering for audit
|
||||
italian = protocol.render_italian(ast)
|
||||
|
||||
# 4. Log
|
||||
await self.log_message(
|
||||
source=source_agent.id,
|
||||
target=target_layer,
|
||||
raw=message,
|
||||
ast=ast,
|
||||
italian=italian
|
||||
)
|
||||
|
||||
# 5. Deliver
|
||||
await self.deliver(target_layer, ast)
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Roadmap di Implementazione
|
||||
|
||||
### 7.1 Fasi
|
||||
|
||||
**Fase 0 — Validazione concettuale (1 mese)**
|
||||
- Paper review approfondito (PromptBreeder, NEAT, MAP-Elites, emergent communication literature)
|
||||
- Proof-of-concept minimo: 3 agenti hand-crafted (uno per layer), protocollo S-expr fisso, 10 ipotesi su dataset limitato
|
||||
- Decisione go/no-go basata su: il sistema produce *qualcosa* di sensato? Vale espandere?
|
||||
|
||||
**Fase 1 — Infrastruttura (2 mesi)**
|
||||
- Message bus auditato (Python + Redis)
|
||||
- Parser e validator del protocollo (Python, con grammar formale)
|
||||
- Backtest engine deterministico (Rust + PyO3 bindings)
|
||||
- Storage schema (PostgreSQL + pgvector)
|
||||
- Multi-model router (Anthropic + OpenRouter)
|
||||
- Logging, monitoring, dashboard base
|
||||
|
||||
**Fase 2 — GA single-population (1 mese)**
|
||||
- DEAP setup
|
||||
- Evoluzione di solo Hypothesis layer (Falsification e Adversarial hand-crafted)
|
||||
- Protocollo fisso
|
||||
- Verifica anti-gaming sulla fitness function
|
||||
- Calibrazione operatori genetici
|
||||
|
||||
**Fase 3 — Multi-population co-evolution (2 mesi)**
|
||||
- GA su tutte e tre le popolazioni di agenti
|
||||
- Speciation, novelty search
|
||||
- Protocol ancora fisso
|
||||
- Task rotation per evitare overfitting
|
||||
|
||||
**Fase 4 — Protocol evolution (2 mesi)**
|
||||
- Aggiunta della quarta popolazione (protocollo)
|
||||
- Idiom emergence detection
|
||||
- Register specialization
|
||||
- Sistema completo end-to-end
|
||||
|
||||
**Fase 5 — Tuning e scaling (3-4 mesi)**
|
||||
- Esperimenti su task diversi (trading, offerte commerciali, code review)
|
||||
- Ablation studies (tier multi-model davvero aiuta? Quanto?)
|
||||
- Long-run stability analysis
|
||||
- Documentazione e potenziale paper
|
||||
|
||||
**Totale realistico**: 11-12 mesi a impegno significativo (3-4 giorni/settimana). 18+ mesi a impegno part-time leggero.
|
||||
|
||||
### 7.2 Tre opzioni decisionali
|
||||
|
||||
**Opzione A — Big bet**: progetto principale per 12-18 mesi. Risultato: sistema completo, possibile paper, applicabile a multipli domini. Rischio: opportunity cost alto rispetto a Tielogic e altri progetti.
|
||||
|
||||
**Opzione B — Smart spike**: solo Fasi 0-2 in 3-4 mesi. Output: infrastruttura solida + GA single-population funzionante su Hypothesis. Da lì si decide se espandere. Rischio basso, valore concreto.
|
||||
|
||||
**Opzione C — Research dive**: solo Fase 0 in 1 mese. Decisione informata se vale la pena tutto. Rischio minimo, output principalmente conoscenza.
|
||||
|
||||
---
|
||||
|
||||
## 8. Stima Costi (con tier multi-model)
|
||||
|
||||
### 8.1 Costi LLM per fase
|
||||
|
||||
| Fase | Generazioni | Pop totale | Costo stimato |
|
||||
|------|-------------|------------|---------------|
|
||||
| Fase 0 (PoC) | 0 (no GA) | 3 hand-crafted | $50-100 |
|
||||
| Fase 2 | 50 | 100 | $400-700 |
|
||||
| Fase 3 | 200 | 200 | $3-5K |
|
||||
| Fase 4-5 | 500-1000 | 300-500 | $10-18K |
|
||||
|
||||
**Totale fasi 1-5**: **$15-25K** in costi LLM (vs $30-50K senza tier multi-model).
|
||||
|
||||
### 8.2 Altri costi
|
||||
|
||||
- **Compute (server backtest, storage)**: $200-500/mese durante run attivi
|
||||
- **Storage (TB di logs)**: $50-100/mese a regime
|
||||
- **Tooling/infra setup**: ~$500 una tantum
|
||||
|
||||
### 8.3 Costi nascosti
|
||||
|
||||
- **Tempo umano**: la voce più importante. Calibrare fitness, fare review, debuggare gaming = molte ore/settimana durante run attivi
|
||||
- **Iterazioni**: il primo run completo non funzionerà. Pianificare 2-3 run completi come parte del budget
|
||||
- **Modelli che cambiano**: durante il progetto i prezzi e le capacità dei modelli evolveranno. Il tier multi-model può solo migliorare nel tempo
|
||||
|
||||
---
|
||||
|
||||
## 9. Domini di Applicazione
|
||||
|
||||
### 9.1 Trading derivati crypto (primario)
|
||||
|
||||
- Task: identificare anomalie su Deribit options + Hyperliquid perps
|
||||
- Fitness team: PnL OOS su walk-forward, Sharpe deflated, drawdown control
|
||||
- Vantaggio: ground truth oggettivo (PnL), dataset abbondante, regime variations naturali
|
||||
- Rischio: overfitting al regime di mercato durante training
|
||||
|
||||
### 9.2 Offerte commerciali Tielogic (secondario)
|
||||
|
||||
- Task: generare offerte tipo OFF-2026-XXX a partire da requisiti cliente
|
||||
- Fitness team: qualità tecnica + commerciale + adesione template + tasso di conversione (firma cliente)
|
||||
- Vantaggio: dataset di offerte passate (20-30) come ground truth implicito
|
||||
- ROI immediato: ridurre tempo di stesura primi-draft significativamente
|
||||
- Vincolo: dataset piccolo, attenzione a overfitting
|
||||
|
||||
### 9.3 Code review e documentazione (terziario)
|
||||
|
||||
- Task: generare review utili su PR, generare documentazione Swagger di qualità
|
||||
- Fitness: bug catch rate su PR storiche, copertura/qualità docs
|
||||
- Vantaggio: integrabile nel workflow Claude Code esistente
|
||||
- Estensibilità: dopo trading, è il dominio più scalabile
|
||||
|
||||
### 9.4 Generalizzabilità del protocollo
|
||||
|
||||
Domanda di ricerca aperta: il protocollo evoluto su trading **trasferisce** ad altri domini, o ogni dominio sviluppa il suo dialetto?
|
||||
|
||||
Ipotesi: la struttura generale (PROPOSE/CHALLENGE/REFINE/COMMIT) trasferisce, il vocabolario specifico no. Un'analisi sistematica di questo è materiale da paper.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risk Register
|
||||
|
||||
| Rischio | Probabilità | Impatto | Mitigazione |
|
||||
|-----------------------------|-------------|---------|----------------------------------------------|
|
||||
| Fitness gameabile | Alta | Critico | Multi-livello fitness, human-in-the-loop, audit |
|
||||
| Costi over-budget | Media | Alto | Tier multi-model, monitoring real-time, kill switch |
|
||||
| Convergenza prematura | Media | Alto | Speciation, novelty search, immigrazione |
|
||||
| Communication degeneration | Alta | Alto | Penalty leggibilità, parser strict, audit umano |
|
||||
| Time over-budget | Alta | Medio | Opzione B/C come fallback |
|
||||
| Modelli base cambiano | Certa | Medio | Re-evolution periodica, model_tier come gene |
|
||||
| Backtest engine bug exploit | Media | Critico | Adversarial test del backtest, code audit |
|
||||
| Burnout su side project | Alta | Critico | Roadmap realistica, milestone settimanali |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
1. **Quanto è veramente diverso il sistema dal lavoro accademico esistente?** Necessario lit review serio prima di Fase 1.
|
||||
2. **Il protocollo evoluto generalizza tra domini?** Domanda empirica, pubblicabile.
|
||||
3. **Tier multi-model produce diversità cognitiva utile o solo riduce costi?** Ablation study cruciale.
|
||||
4. **Co-evoluzione 4-popolazioni è stabile o tende a oscillare?** Non scontato, esperienza empirica necessaria.
|
||||
5. **Quanto del successo dipende dal task specifico (trading) vs essere generale?** Solo applicazione multi-dominio risponde.
|
||||
6. **Quanto è il vero MVP minimo per validare il concetto?** Probabilmente Fase 0 + minima Fase 2 con singola popolazione.
|
||||
|
||||
---
|
||||
|
||||
## 12. Decisione Richiesta
|
||||
|
||||
A questo punto del design, le decisioni concrete da prendere prima di partire:
|
||||
|
||||
1. **Opzione A, B, o C?** — determina l'investimento di tempo
|
||||
2. **Dominio iniziale: trading o offerte commerciali?** — trading ha dataset migliore, offerte ROI più rapido
|
||||
3. **Hardware locale vs cloud?** — i backtest pesanti richiedono compute, decidere infrastructure
|
||||
4. **Budget LLM iniziale committed?** — anche solo Fase 0 richiede $50-100 spendibile senza ansia
|
||||
5. **Cadenza di review umana sostenibile?** — ore/settimana realisticamente disponibili
|
||||
|
||||
---
|
||||
|
||||
## Appendice A — Bibliografia di riferimento
|
||||
|
||||
**Da leggere prima di Fase 1**:
|
||||
- PromptBreeder (DeepMind 2023): meta-evolution di prompt
|
||||
- NEAT (Stanley & Miikkulainen 2002): speciation in neural evolution
|
||||
- MAP-Elites (Mouret & Clune 2015): quality-diversity
|
||||
- Novelty Search (Lehman & Stanley 2011): esplorazione vs sfruttamento
|
||||
- DSPy (Khattab et al. 2023): ottimizzazione modulare di prompt
|
||||
- TextGrad (Stanford 2024): backpropagation testuale
|
||||
- Emergent Communication in Cooperative Multi-Agent RL (Foerster et al., Lazaridou et al. 2016-2020)
|
||||
- Deflated Sharpe Ratio (Bailey & López de Prado 2014): per fitness trading
|
||||
- Multiple testing in finance literature (López de Prado various)
|
||||
|
||||
**Da consultare durante**:
|
||||
- Anthropic prompt engineering documentation (per pattern caching)
|
||||
- OpenRouter API docs (per multi-model routing)
|
||||
- DEAP documentation (framework GA Python)
|
||||
- Ray documentation (parallelizzazione)
|
||||
|
||||
---
|
||||
|
||||
*Documento da aggiornare iterativamente. Questa è v0.1, scritta prima di qualunque implementazione.*
|
||||
@@ -0,0 +1,286 @@
|
||||
# Multi-Swarm Coevolutivo — Stato del progetto e roadmap
|
||||
|
||||
*Data del documento: 14 maggio 2026 — branch `main` allineato a commit `45f273f`.*
|
||||
|
||||
Questo documento riepiloga l'intero percorso del proof-of-concept Multi-Swarm Coevolutive dalla Phase 1 (lean spike) fino allo stato corrente di entrata in Phase 3 (paper-trading forward-test). È inteso come punto di sincronizzazione per riprendere il lavoro: cosa è stato deciso, cosa ha funzionato, cosa no, e quali sono le prossime mosse plausibili.
|
||||
|
||||
---
|
||||
|
||||
## 1. Quadro sintetico
|
||||
|
||||
| Fase | Periodo | Stato | Esito |
|
||||
|------|---------|-------|-------|
|
||||
| **Phase 1** — lean spike | 9-10 maggio 2026 | ✅ chiusa | GO Phase 2 (5/5 hard gate) |
|
||||
| **Phase 1.5** — adversarial hardening | 11 maggio 2026 | ✅ chiusa | NO-GO sulla combo nemotron, hardening conservato |
|
||||
| **Phase 2** — feature temporali + qwen3-235b | 11 maggio 2026 | ✅ chiusa | NO-GO sul modello (rollback a qwen-2.5-72b) |
|
||||
| **Phase 2.5** — LLM prompt mutator | 11-12 maggio 2026 | ✅ chiusa | Operator integrato, sweet spot weight 0.20-0.30 |
|
||||
| **Phase 2.6** — Walk-Forward Validation | 12-13 maggio 2026 | ✅ chiusa | WFA 70/30 introdotta, min-trades parametrico |
|
||||
| **Phase 2.7** — portabilità cross-asset (BTC/ETH/SOL) | 13 maggio 2026 | ✅ chiusa | BTC strong, ETH adequate, SOL failure |
|
||||
| **Phase 3** — paper-trading forward-test | 13-14 maggio 2026 | 🟢 in corso | Runner BTC+ETH operativo, smoke OK |
|
||||
|
||||
Dal punto di vista del DB locale: 30 run GA completate, costo cumulato LLM **≈ $3.74**, due paper-trading run avviati (`phase3-smoke-001`, `phase3-papertrade-001`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1 — lean spike (chiusa 10 maggio)
|
||||
|
||||
### Obiettivo
|
||||
Validare end-to-end l'idea co-evolutiva: GA → popolazione di prompt LLM → strategie JSON → backtest deterministico → fitness → selezione. Cinque hard gate vincolanti.
|
||||
|
||||
### Risultato
|
||||
Run di riferimento `phase1-real-005` su BTC-PERPETUAL Deribit 1h, 2024-01-01 → 2026-01-01, K=20, 10 generazioni, **costo $0.069 in 29 minuti**.
|
||||
|
||||
| Hard gate | Soglia | Misurato | Esito |
|
||||
|-----------|--------|----------|-------|
|
||||
| Loop convergence | median sale | 0.0001 → 0.0188 in 3 gen | ✓ |
|
||||
| Parse success | ≥ 95% | 100% (98/98) post refactor JSON | ✓ |
|
||||
| Top-5 vs median | ≥ 10× | 1116× | ✓ |
|
||||
| Entropy fitness gen 9 | ≥ 0.5 | 0.914 | ✓ |
|
||||
| Costo totale | ≤ $700 | $0.069 | ✓ |
|
||||
|
||||
Iterazione: 5 run prima del PASS, ognuna ha scoperto un bug strutturale (max_dd su equity assoluta, cap Cerbero 5000 candele, validator arity, switch grammar S-expr→JSON, fitness clip-to-0 troppo dura).
|
||||
|
||||
### Caveat critico
|
||||
Il top-1 ha reso **+2.66% in 2 anni vs B&H BTC +106%**, essendo *flat* nel 99,8% del tempo. Conferma che la fitness v1 premiava "non-strategie" sicure invece di alpha vero. Da qui la Phase 1.5.
|
||||
|
||||
### Documenti chiave
|
||||
- `docs/decisions/2026-05-10-gate-phase1.md` — decision memo.
|
||||
- `docs/reports/2026-05-10-phase1-technical-report.md` — report tecnico.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 1.5 — adversarial hardening (chiusa 11 maggio)
|
||||
|
||||
Quattro nuovi check `HIGH` aggiunti all'agente Adversarial per killare strategie degeneri:
|
||||
|
||||
1. `overtrading` ricalibrato `n_bars/20` (era `n_bars/5`).
|
||||
2. `undertrading` promosso a HIGH se `n_trades < 10`.
|
||||
3. `flat_too_long` (nuovo HIGH) — segnale flat > 95% bar.
|
||||
4. `fees_eat_alpha` (nuovo HIGH) — `fees / |gross_pnl| > 0.5` con gross positivo.
|
||||
5. `time_in_market_too_high` (nuovo HIGH) — segnale LONG||SHORT > 80% bar (kill leveraged-B&H camuffato).
|
||||
|
||||
**Run di test `phase1.5-nemotron-001`** (tier C nemotron, 2h26', $0.12) → **NO-GO**: max fitness 0.0215 stagnante, median 0 su 9 gen, top-5 con DSR=0 e Sharpe ≈ −1.1. I check Phase 1.5 funzionavano (98 findings emessi); il problema era il modello: prompt calibrato su qwen, nemotron produceva materiale qualitativamente più povero.
|
||||
|
||||
Bugfix collaterale (`9d0deb3`): `EmptyCompletionError` reso retryable + gestione `resp.usage=None` per provider `:free`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 2 — feature temporali + tier C qwen3-235b (chiusa 11 maggio)
|
||||
|
||||
Due lavori in parallelo, esiti opposti:
|
||||
|
||||
**4.1 Feature temporali in protocol layer** — `KNOWN_FEATURES` esteso con `hour`, `dow`, `is_weekend`, `minute_of_hour`. Compiler dispatcher temporale (`9d1f97c`), validator parametrizzato, integration test gating temporale+SMA. Few-shot example nel prompt Hypothesis. **Successo strutturale**: tutte le top strategie successive sfruttano questo asset.
|
||||
|
||||
**4.2 Upgrade tier C a `qwen/qwen-2.5-72b-instruct` → `qwen3-235b-a22b`** — run `phase2-qwen3-001`: max fitness 0.0238 stuck per 8 gen, entropy 0.199 stuck per 7 gen, 4 dei 5 top genomi con fitness/Sharpe/DD identici. Il **run controllo** identico ma con qwen-2.5-72b: 0.0311 (+30%), median raggiunge top in 4 gen, entropy 0.85, ½ tempo e costo. **Rollback a qwen-2.5-72b** (`8ec45c5`).
|
||||
|
||||
Lezione consolidata: il prompt è calibrato sulla famiglia qwen-2.5; un modello "più nuovo / più grande" non è automaticamente meglio se il prompt non viene ricalibrato in parallelo.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2.5 — operator `mutate_prompt_llm` (chiusa 12 maggio)
|
||||
|
||||
Quinto operatore di mutazione che riscrive il `system_prompt` via LLM tier B (`deepseek-v4-flash`) anziché perturbare scalari. Sei istruzioni atomiche: `tighten_threshold`, `swap_comparator`, `add_condition`, `remove_condition`, `change_timeframe`, `add_temporal_gate`. Validation gate (lunghezza ≥ 50, keyword tecnica, diff Levenshtein > 5%) + fallback `random_mutate`. Dispatcher pesato `weighted_random_mutate` (CLI `--prompt-mutation-weight`, default 0.0).
|
||||
|
||||
### Sweet spot empirico (seed 42, pop 20, 10 gen)
|
||||
|
||||
| weight | max fit | median fin | Sharpe top | trades | verdetto |
|
||||
|--------|---------|-----------|-----------|--------|----------|
|
||||
| 0.00 | 0.0311 | 0.0000 | −1.08 | 274 | baseline |
|
||||
| **0.30** | **0.1012** ⭐ | **0.0745** | **−0.25** | 62 | sweet spot (ma seed-lucky) |
|
||||
| 0.50 | 0.0311 | 0.0000 | −1.08 | 274 | regressione |
|
||||
|
||||
### Validazione robustezza
|
||||
Confronti seed multipli (7, 99, 123) hanno mostrato che il **+225%** del run 004 era **outlier seed-specific**. Beneficio medio reale del prompt-mutator: **+10–23%** sopra baseline. La leva più affidabile e seed-indipendente è risultata `fees_eat_alpha_threshold 0.7` (anziché 0.5): +23% stabile, Sharpe top −0.70 vs −1.08.
|
||||
|
||||
### Combo vincente (pop=30 + weight=0.30 + fees=0.7)
|
||||
Run `pop30-combo-001`: max fitness 0.0459 (+48% vs control), **median finale = max** (convergenza ≥50% pop), Sharpe top −0.63, 226 trades. Mutator overhead ≈ 5,4% del costo totale.
|
||||
|
||||
### Cost attribution (Task 6)
|
||||
`cost_records.call_kind` (`hypothesis` / `mutation`) attivo da `ba4eb09`. Permette breakdown costo per operatore: il prompt-mutator costa 3-9% del totale, trascurabile.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2.6 — Walk-Forward Validation (chiusa 13 maggio)
|
||||
|
||||
Aggiunte tre leve metodologiche:
|
||||
|
||||
- **WFA 70/30**: split temporale `train/OOS` con OOS intoccato durante GA, valutato solo a fine run.
|
||||
- **`--min-trades-threshold`** parametrico: filtra survivors con n_trades insufficiente prima del ranking.
|
||||
- **Fitness v2 soft-kill** (`cf42dd8`): solo `no_trades` + `degenerate` + `undertrading` azzerano hard. Altri HIGH applicano penalty moltiplicativa `1/(1+0.4·n)` (1 HIGH = 0,71×, 2 = 0,56×, 3 = 0,45×). CLI `--fitness-v2` + `--fitness-soft-penalty`.
|
||||
- **Pattern guidance nel system prompt** (`67ae6ff`): forma curve attese + criterio ripetibilità.
|
||||
- **Fitness multi-obiettivo** (`1a1dfb7`): `combined = α·IS + (1−α)·OOS` opt-in.
|
||||
|
||||
Effetto cumulativo: la pipeline produce strategie con migliore generalizzazione cross-split senza dover degradare le adversarial hard.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 2.7 — backtest 7 anni e portabilità cross-asset (chiusa 13 maggio)
|
||||
|
||||
### 7.1 Validazione 7,33 anni su BTC
|
||||
|
||||
Backtest dei top genome scoperti sulle varie sotto-fasi sui **64.297 bar 1h** completi (2018-09-01 → 2026-01-01), fees 5 bp:
|
||||
|
||||
| Genome | Origine | Total P/L 7y | CAGR | Sharpe ann | MaxDD | Verdetto |
|
||||
|--------|---------|--------------|------|-----------|-------|----------|
|
||||
| `5226503a` | run004 outlier 2y bull | **−310,69%** | wiped out | −0,155 | 280,9% | crash totale OOS |
|
||||
| `e52604ba` | flat-ablation top 2y | −37,17% | −6,14% | −0,063 | 182,0% | SMA non generalizza |
|
||||
| `ec06a3d4` | fitness-v2-combo top 2y | +142,51% | +12,85% | +0,229 | 64,9% | hour-gated regge |
|
||||
| `4e1be9fa` | 7y-v2-WFA top IS | +67,60% | +7,30% | +0,240 | 79,1% | top IS ingannevole |
|
||||
| `63411199` | 7y-v2-WFA top OOS | **+660,11%** | **+31,88%** | +0,238 | 77,1% | leveraged-B&H camuffato |
|
||||
| **`fb63e851`** ⭐ | 7y multi-seed99 top OOS | +130,37% | +12,06% | **+0,264** | **54,8%** | **true alpha** |
|
||||
|
||||
Conclusioni:
|
||||
- Il top by `fit_IS` è sistematicamente ingannevole su orizzonti lunghi.
|
||||
- Pattern SMA-puri collassano cross-regime.
|
||||
- Pattern *hour-gated* (filtri intraday) reggono cross-regime.
|
||||
- `fb63e851` è il candidato più robusto: 4 AND × 2 rule × filtro intraday → attiva l'1-2% del tempo, Sharpe cross-regime più alto.
|
||||
|
||||
### 7.2 Portabilità BTC → ETH → SOL
|
||||
|
||||
Tre run **identici** (`population=30`, `n_gen=10`, `prompt_mutation_weight=0.30`, fitness v2, WFA 70/30, `fees_eat_alpha_threshold=0.7`, undertrading 20) su Deribit perpetuals.
|
||||
|
||||
| Asset | Storia | Top OOS Sharpe | Verdetto |
|
||||
|-------|--------|----------------|----------|
|
||||
| **BTC** | 7,33 y | `fb63e851` +0,50 OOS (+20,16%) | **STRONG** |
|
||||
| **ETH** | 6,75 y | `facd6af85d5d` +0,19 OOS (+16,14%) | **ADEQUATE** |
|
||||
| **SOL** | 3,00 y | **0 survivors / 247 evals** | **FAILURE** |
|
||||
|
||||
Pattern scoperti **divergenti**: BTC = mean reversion intraday contrarian; ETH = trend-following long-bias + vol regime. **Non esiste "una strategia universale"**: la metodologia (GA + WFA + adversarial v2) è portabile, il pattern no. SOL ha fallito per finestra dati troppo corta (3y) e regime bull-only post-FTX.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 3 — paper-trading forward-test (in corso)
|
||||
|
||||
### Componenti implementati (`45f273f`)
|
||||
|
||||
Modulo `src/multi_swarm/paper_trading/`:
|
||||
- `portfolio.py` — multi-asset portfolio con sleeve uguali per asset, fees in bp.
|
||||
- `executor.py` — `PaperExecutor` carica una strategia JSON, compila, valuta l'ultimo bar.
|
||||
- `persistence.py` — `PaperRepository` su SQLite (tabelle `paper_trading_runs`, `paper_trading_ticks`, `paper_trading_equity`, `paper_trading_trades`, `paper_trading_positions`).
|
||||
|
||||
Runner `scripts/run_paper_trading.py`:
|
||||
- Loop poll OHLCV Cerbero ogni `--poll-seconds` (default 300).
|
||||
- Riconosce *nuovo bar chiuso* confrontando ultimo timestamp; tick consecutivi su stesso bar = hold.
|
||||
- Snapshot equity ogni tick.
|
||||
- Supporta `--max-ticks N` per smoke test (0 = infinito).
|
||||
|
||||
Strategie freezate per il forward-test:
|
||||
- `strategies/btc_fb63e851.json` — RSI estremi + ATR vs SMA + filtro orario 9-17.
|
||||
- `strategies/eth_facd6af85d5d.json` — ATR + realized_vol + golden/death cross.
|
||||
|
||||
### Stato corrente
|
||||
- Schema DB esteso e validato.
|
||||
- Run smoke completato (`phase3-smoke-001`).
|
||||
- Run live in corso (`phase3-papertrade-001`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Architettura cumulata
|
||||
|
||||
```
|
||||
src/multi_swarm/
|
||||
├── config.py
|
||||
├── data/{cerbero_ohlcv,splits}.py ← splits.py per WFA
|
||||
├── backtest/{orders,engine}.py
|
||||
├── metrics/{basic,dsr,diversity}.py ← diversity per Phase 2.5
|
||||
├── cerbero/{client,tools}.py
|
||||
├── protocol/{grammar,parser,validator,compiler}.py
|
||||
│ └── KNOWN_FEATURES include hour/dow/is_weekend/minute_of_hour
|
||||
├── genome/
|
||||
│ ├── hypothesis.py
|
||||
│ ├── mutation.py ← 4 operatori scalari
|
||||
│ ├── mutation_prompt_llm.py ← Phase 2.5: 5° operatore LLM
|
||||
│ └── crossover.py
|
||||
├── llm/{client,cost_tracker}.py ← cost_kind tracking
|
||||
├── agents/{hypothesis,falsification,adversarial,market_summary}.py
|
||||
│ └── adversarial: 5 check HIGH parametrici (CLI knobs)
|
||||
├── ga/
|
||||
│ ├── selection.py
|
||||
│ ├── fitness.py ← v1 + v2 soft-kill + combined IS/OOS
|
||||
│ ├── loop.py
|
||||
│ ├── summary.py
|
||||
│ └── initial.py
|
||||
├── persistence/{schema,repository}.py ← +tabelle paper_trading_*
|
||||
├── paper_trading/ ← NEW Phase 3
|
||||
│ ├── portfolio.py
|
||||
│ ├── executor.py
|
||||
│ └── persistence.py
|
||||
├── orchestrator/run.py
|
||||
└── dashboard/
|
||||
├── nicegui_app.py ← unica GUI, porta parametrica via SWARM_DASHBOARD_PORT
|
||||
└── data.py
|
||||
```
|
||||
|
||||
CLI knobs accumulati per ablation:
|
||||
- `--prompt-mutation-weight FLOAT` (Phase 2.5)
|
||||
- `--fees-eat-alpha-threshold FLOAT` (default 0.5, suggerito 0.7)
|
||||
- `--flat-too-long-threshold FLOAT` (default 0.95)
|
||||
- `--undertrading-threshold INT` (default 20)
|
||||
- `--fitness-v2` + `--fitness-soft-penalty FLOAT`
|
||||
- `--fitness-combined-alpha FLOAT` (multi-obiettivo IS/OOS)
|
||||
- `--min-trades-threshold INT` (WFA OOS filter)
|
||||
|
||||
---
|
||||
|
||||
## 10. Cosa resta da fare
|
||||
|
||||
### 10.1 Phase 3 — completamento paper-trading
|
||||
- [ ] **Definire criterio di STOP/GO Phase 3**: durata minima forward-test (es. 4-8 settimane), soglie sopravvivenza (Sharpe live > 50% del Sharpe OOS atteso, DD live < 1,5× DD OOS).
|
||||
- [ ] **Pagina dashboard paper-trading**: estendere NiceGUI con tab live equity + open positions + tick log per `paper_trading_runs`. Oggi i dati esistono in DB ma non hanno UI dedicata.
|
||||
- [ ] **Monitoring & alerting**: notifica se il runner si ferma (Cerbero down, processo killato). Considerare systemd unit o supervisor.
|
||||
- [ ] **Robustezza fetch live**: oggi `loader._fetch(req)` bypassa la cache; aggiungere retry esplicito (oltre a quello tenacity già presente nel client) e log strutturato dei fallimenti per asset.
|
||||
- [ ] **Confronto live vs OOS atteso**: script che a fine settimana confronta P/L, Sharpe rolling, hit rate vs i numeri del backtest 7y per individuare *regime mismatch* precoce.
|
||||
|
||||
### 10.2 Estensioni metodologiche
|
||||
- [ ] **Multi-seed ensembling**: invece di scegliere un singolo top genome, valutare ensemble (mediana o weighted) dei top-K trovati con seed diversi sullo stesso asset. La varianza seed è il rischio numero uno (vedi sezione 5).
|
||||
- [ ] **Asset universe expansion**: testare la metodologia su asset non-crypto (oro, forex EURUSD) per smentire l'ipotesi che funzioni solo perché BTC/ETH hanno alta volatilità. `yfinance` è già in dipendenze (`9d1ef8a`).
|
||||
- [ ] **Fitness regime-aware**: oggi fitness è single-objective sull'intero train. Considerare fitness condizionata al regime (bull/bear/range) per favorire strategie con performance bilanciata cross-regime invece di top assoluto.
|
||||
- [ ] **Phase 2.7 retry su SOL** con configurazione mirata: train più corto, undertrading_threshold ridotto, prompt few-shot di strategie short-vol-only. Verificare se è davvero il dato a fallire o se è la calibrazione.
|
||||
|
||||
### 10.3 Hardening tecnico
|
||||
- [ ] **Cleanup zombie runs**: `phase2-6-flat-wfa-001` è ancora `failed` nel DB. Verificare che il flush di stato sia idempotente per tutti i path di crash.
|
||||
- [x] **Port completo dashboard a NiceGUI** *(chiuso 14 maggio 2026, commit `03f723f`)*: Streamlit deprecata e rimossa insieme ai file legacy (`streamlit_app.py`, `aquarium.py`, `pages/0[1-4]_*.py`); dep `streamlit>=1.40` cancellata da `pyproject.toml` con 10 transitive (pydeck, watchdog, jsonschema, pillow, …). NiceGUI espone 3 pagine (`/`, `/convergence`, `/genomes`) su porta parametrica `SWARM_DASHBOARD_PORT` (default 8080). **Aquarium non riportata per scelta** (decisione utente: non più ritenuta utile). Deploy in produzione via Docker + Traefik su `https://swarm.tielogic.xyz` (compose `docker-compose.yml`, commit `8e5efde`).
|
||||
- [ ] **Pruning DB**: dopo 30+ run la SQLite cresce. Aggiungere uno script di archiviazione/compressione delle run completate più vecchie di N giorni.
|
||||
- [ ] **CI/test coverage**: i 180+ test girano localmente; non c'è ancora CI esterna (Gitea Actions o equivalente).
|
||||
|
||||
### 10.4 Documentazione e governance
|
||||
- [ ] **Decision memo Phase 2.5 + Phase 2.6 + Phase 2.7** formalizzati come `docs/decisions/2026-05-1{2,3}-*.md` (esistono solo memory + commit message; manca il pendant pubblico dei due memo già esistenti per Phase 1 e Phase 1.5).
|
||||
- [ ] **Phase 3 charter**: documento che fissa a priori cosa significherà "successo" o "fallimento" del forward-test, per evitare *moving goalposts* a posteriori.
|
||||
- [ ] **Threats to validity update**: il memo Phase 1 ne elencava 6; integrarli con le scoperte successive (varianza seed, portabilità asset-specifica, divergenza pattern BTC/ETH).
|
||||
|
||||
---
|
||||
|
||||
## 11. Caveat e rischi aperti
|
||||
|
||||
1. **Varianza seed**: con seed diversi (7, 99, 123) sullo stesso identico setup il max fitness varia di un fattore 3-4×. Qualunque metrica single-seed è statisticamente debole; finché Phase 3 non raccoglie N≥5 forward-test indipendenti, il vantaggio del prompt-mutator resta nel rumore.
|
||||
2. **Sharpe OOS positivi ma bassi**: BTC `+0,50` ed ETH `+0,19` sono migliori del coin-flip ma lontani dai target retail "investment-grade" (≥ 1,0). La metodologia è validata, l'alpha catturata è modesta.
|
||||
3. **`time_in_market_too_high` come red-flag chiave**: `63411199` ha CAGR +31,88% ma esposizione 90% del tempo — è leveraged-B&H camuffato, non alpha. Phase 3 deve preferire `fb63e851` (selettività 1-2%) anche se ha return assoluto minore.
|
||||
4. **Dipendenza dal modello qwen-2.5-72b**: rollback Phase 2 ha dimostrato che il prompt è calibrato su questa specifica famiglia. Se il modello venisse deprecato da OpenRouter, sarebbe necessario un giro di ricalibrazione prompt → rischio di operatività.
|
||||
5. **Cerbero MCP come single point of failure**: tutti i fetch OHLCV passano da lì. Da considerare un fallback (ccxt o yfinance) almeno per il paper-trading.
|
||||
|
||||
---
|
||||
|
||||
## 12. Costi cumulati
|
||||
|
||||
- **Phase 1 (5 run iterazione)**: $0,19.
|
||||
- **Phase 1.5 nemotron**: $0,12.
|
||||
- **Phase 2 + 2.5 + 2.6 + 2.7**: ≈ $3,24 cumulati su 25+ run.
|
||||
- **Totale LLM progetto a oggi**: ≈ **$3,74** (DB locale).
|
||||
- **Phase 3 paper-trading**: $0 incrementali per LLM (le strategie sono fisse), solo costi Cerbero (incluso nel servizio esistente).
|
||||
|
||||
Resta amplissimo margine rispetto al cap originale Phase 1 di $700.
|
||||
|
||||
---
|
||||
|
||||
## 13. Riferimenti
|
||||
|
||||
- README.md — overview e setup.
|
||||
- `docs/decisions/2026-05-10-gate-phase1.md`, `docs/decisions/2026-05-11-phase1-5-nemotron-run.md`.
|
||||
- `docs/reports/2026-05-10-phase1-technical-report.md`.
|
||||
- `docs/superpowers/specs/2026-05-09-decisione-strategica-design.md`, `docs/superpowers/specs/2026-05-11-temporal-features-design.md`.
|
||||
- `docs/superpowers/plans/2026-05-09-phase1-lean-spike.md`, `docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md`, `docs/superpowers/plans/2026-05-11-temporal-features.md`.
|
||||
- DB locale `runs.db` per dettaglio run-by-run.
|
||||
|
||||
---
|
||||
|
||||
*Prossimo checkpoint suggerito: rivedere questo documento al termine del primo ciclo completo di Phase 3 (≥ 2 settimane di forward-test continuo) per consolidare i risultati live e decidere GO/NO-GO verso un eventuale Phase 4 (capitale reale ridotto o estensione del universe).*
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.genome.hypothesis import ModelTier
|
||||
from multi_swarm_core.llm.client import CompletionResult
|
||||
from multi_swarm_core.orchestrator.run import RunConfig, run_phase1
|
||||
from multi_swarm_core.persistence.repository import Repository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def synthetic_ohlcv():
|
||||
idx = pd.date_range("2024-01-01", periods=500, freq="1h", tz="UTC")
|
||||
close = 100 + np.cumsum(np.random.RandomState(0).normal(0.01, 1.0, 500))
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": close,
|
||||
"high": close + 0.5,
|
||||
"low": close - 0.5,
|
||||
"close": close,
|
||||
"volume": 1.0,
|
||||
},
|
||||
index=idx,
|
||||
)
|
||||
|
||||
|
||||
_STRATEGY_PAYLOAD = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 30.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_llm(mocker):
|
||||
"""LLM mock che ritorna sempre una strategia JSON valida."""
|
||||
fake = mocker.MagicMock()
|
||||
fake.complete.return_value = CompletionResult(
|
||||
text="```json\n" + _STRATEGY_PAYLOAD + "\n```",
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def test_e2e_minimal_run_completes(
|
||||
tmp_path: Path,
|
||||
synthetic_ohlcv,
|
||||
fake_llm,
|
||||
mocker,
|
||||
):
|
||||
cfg = RunConfig(
|
||||
run_name="e2e-test",
|
||||
population_size=5,
|
||||
n_generations=2,
|
||||
elite_k=1,
|
||||
tournament_k=2,
|
||||
p_crossover=0.5,
|
||||
seed=42,
|
||||
model_tier=ModelTier.C,
|
||||
symbol="BTC/USDT",
|
||||
timeframe="1h",
|
||||
fees_bp=5.0,
|
||||
n_trials_dsr=10,
|
||||
db_path=tmp_path / "runs.db",
|
||||
)
|
||||
|
||||
run_id = run_phase1(cfg, ohlcv=synthetic_ohlcv, llm=fake_llm)
|
||||
|
||||
repo = Repository(db_path=tmp_path / "runs.db")
|
||||
run = repo.get_run(run_id)
|
||||
assert run["status"] == "completed"
|
||||
gens = repo.list_generations(run_id)
|
||||
assert len(gens) == 2
|
||||
evals = repo.list_evaluations(run_id)
|
||||
assert len(evals) >= 5 # almeno una popolazione
|
||||
|
||||
|
||||
def test_e2e_wfa_populates_fitness_oos(
|
||||
tmp_path: Path,
|
||||
synthetic_ohlcv,
|
||||
fake_llm,
|
||||
mocker,
|
||||
):
|
||||
"""WFA: train_split=0.7 → top genomi devono avere fitness_oos popolato."""
|
||||
cfg = RunConfig(
|
||||
run_name="e2e-wfa-test",
|
||||
population_size=5,
|
||||
n_generations=2,
|
||||
elite_k=1,
|
||||
tournament_k=2,
|
||||
p_crossover=0.5,
|
||||
seed=42,
|
||||
model_tier=ModelTier.C,
|
||||
symbol="BTC/USDT",
|
||||
timeframe="1h",
|
||||
fees_bp=5.0,
|
||||
n_trials_dsr=10,
|
||||
db_path=tmp_path / "runs.db",
|
||||
wfa_train_split=0.7,
|
||||
wfa_top_k=3,
|
||||
)
|
||||
run_id = run_phase1(cfg, ohlcv=synthetic_ohlcv, llm=fake_llm)
|
||||
repo = Repository(db_path=tmp_path / "runs.db")
|
||||
evals = repo.list_evaluations(run_id)
|
||||
# Almeno 1 genome con fitness > 0 deve avere fitness_oos popolato.
|
||||
oos_evals = [e for e in evals if e.get("fitness_oos") is not None]
|
||||
assert len(oos_evals) >= 1, f"Nessun OOS popolato; evals={evals}"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Integration test Phase 2.5: GA loop con LLM mutator attivo.
|
||||
|
||||
Verifica che ``next_generation`` con ``prompt_mutation_weight > 0`` e ``llm``
|
||||
fornito produca figli con system_prompt mutato dall'LLM (e non solo scalari).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
from multi_swarm_core.ga.loop import GAConfig, next_generation
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
|
||||
_PROMPT_TEMPLATES = (
|
||||
"Strategia mean-reversion 1h. Entry long RSI(14) < 30 e close > SMA(50). Stop 2%.",
|
||||
"Strategia momentum breakout. Entry long close > SMA(20) e ATR(14) crescente.",
|
||||
"Strategia trend-following 4h. Long SMA(20) > SMA(50). Short opposito.",
|
||||
)
|
||||
|
||||
|
||||
def _make_pop(n: int) -> list[HypothesisAgentGenome]:
|
||||
return [
|
||||
HypothesisAgentGenome(
|
||||
system_prompt=_PROMPT_TEMPLATES[i % len(_PROMPT_TEMPLATES)],
|
||||
feature_access=["close", "high"],
|
||||
temperature=0.9 + 0.01 * i,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Result:
|
||||
text: str
|
||||
|
||||
|
||||
class _MutatorLLM:
|
||||
"""Mock che produce un prompt diverso (e valido) a ogni call."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def complete(self, genome, system, user, max_tokens: int = 2000) -> _Result:
|
||||
self.calls += 1
|
||||
# Prompt sempre diverso per garantire validation pass.
|
||||
return _Result(
|
||||
text=(
|
||||
f"<prompt>Strategia evolved #{self.calls}. Entry long quando "
|
||||
f"RSI(14) < {25 + self.calls % 10} e close > SMA({40 + self.calls}). "
|
||||
f"Exit short quando momentum decade. Trade rule {self.calls}.</prompt>"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_loop_with_prompt_mutator_produces_prompt_diversity() -> None:
|
||||
"""Con weight 1.0 + crossover 0 (solo mutation), tutti i child non-elite
|
||||
devono avere system_prompt diverso dai parent (LLM-mutated)."""
|
||||
rng = random.Random(0)
|
||||
pop = _make_pop(5)
|
||||
fitnesses = {g.id: 0.0 for g in pop}
|
||||
cfg = GAConfig(
|
||||
population_size=5,
|
||||
elite_k=1,
|
||||
tournament_k=2,
|
||||
p_crossover=0.0, # nessun crossover → tutta la non-elite è mutation
|
||||
prompt_mutation_weight=1.0,
|
||||
)
|
||||
llm = _MutatorLLM()
|
||||
|
||||
new_pop = next_generation(pop, fitnesses, cfg, rng, llm=llm)
|
||||
|
||||
assert len(new_pop) == 5
|
||||
# 4 non-elite figli, tutti con prompt evoluti.
|
||||
parent_prompts = {g.system_prompt for g in pop}
|
||||
evolved = [g for g in new_pop[1:] if g.system_prompt not in parent_prompts]
|
||||
assert len(evolved) >= 3, f"Solo {len(evolved)} figli con prompt mutato"
|
||||
assert llm.calls >= 4
|
||||
|
||||
|
||||
def test_loop_backward_compat_no_llm_no_prompt_mutation() -> None:
|
||||
"""Default weight=0.0 + llm=None → comportamento identico a Phase 2."""
|
||||
rng = random.Random(0)
|
||||
pop = _make_pop(5)
|
||||
fitnesses = {g.id: float(i) for i, g in enumerate(pop)}
|
||||
cfg = GAConfig(
|
||||
population_size=5,
|
||||
elite_k=1,
|
||||
tournament_k=2,
|
||||
p_crossover=0.0,
|
||||
prompt_mutation_weight=0.0,
|
||||
)
|
||||
|
||||
new_pop = next_generation(pop, fitnesses, cfg, rng, llm=None)
|
||||
|
||||
assert len(new_pop) == 5
|
||||
# Nessun child con prompt diverso dai parent: solo mutazioni scalari.
|
||||
parent_prompts = {g.system_prompt for g in pop}
|
||||
for child in new_pop:
|
||||
assert child.system_prompt in parent_prompts
|
||||
@@ -0,0 +1,477 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.agents.adversarial import (
|
||||
AdversarialAgent,
|
||||
AdversarialReport,
|
||||
Severity,
|
||||
)
|
||||
from multi_swarm_core.backtest.engine import BacktestResult
|
||||
from multi_swarm_core.backtest.orders import Side, Trade
|
||||
from multi_swarm_core.protocol.parser import parse_strategy
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ohlcv() -> pd.DataFrame:
|
||||
idx = pd.date_range("2024-01-01", periods=500, freq="1h", tz="UTC")
|
||||
close = 100 + np.cumsum(np.random.RandomState(0).normal(0.0, 1.0, 500))
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": close,
|
||||
"high": close + 0.5,
|
||||
"low": close - 0.5,
|
||||
"close": close,
|
||||
"volume": 1.0,
|
||||
},
|
||||
index=idx,
|
||||
)
|
||||
|
||||
|
||||
def test_degenerate_always_long_flagged(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "literal", "value": -1e9},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
assert isinstance(report, AdversarialReport)
|
||||
assert any(f.name == "degenerate" and f.severity == Severity.HIGH for f in report.findings)
|
||||
|
||||
|
||||
def test_no_findings_on_reasonable_strategy(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 30.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
high_findings = [f for f in report.findings if f.severity == Severity.HIGH]
|
||||
assert len(high_findings) == 0
|
||||
|
||||
|
||||
def test_zero_trade_strategy_flagged(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "literal", "value": 1e9},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
assert any(f.name == "no_trades" for f in report.findings)
|
||||
|
||||
|
||||
# AST minimale valido (parser-acceptable). Usato nei test che monkeypatchano
|
||||
# compile_strategy/BacktestEngine.run: il contenuto della strategia e'
|
||||
# irrilevante perche' il signal/result viene iniettato.
|
||||
_MINIMAL_STRATEGY_SRC = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "literal", "value": 0.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_trade(
|
||||
entry_ts: pd.Timestamp,
|
||||
exit_ts: pd.Timestamp,
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
side: Side = Side.LONG,
|
||||
fees_bp: float = 5.0,
|
||||
) -> Trade:
|
||||
return Trade(
|
||||
entry_ts=entry_ts.to_pydatetime() if hasattr(entry_ts, "to_pydatetime") else entry_ts,
|
||||
exit_ts=exit_ts.to_pydatetime() if hasattr(exit_ts, "to_pydatetime") else exit_ts,
|
||||
side=side,
|
||||
size=1.0,
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
fees_bp=fees_bp,
|
||||
)
|
||||
|
||||
|
||||
def test_undertrading_under_10_is_high(monkeypatch: pytest.MonkeyPatch,
|
||||
ohlcv: pd.DataFrame) -> None:
|
||||
"""5 trade su 500 bar -> HIGH undertrading (Phase 1.5: era MEDIUM <5)."""
|
||||
fake_trades = [
|
||||
_make_trade(
|
||||
ohlcv.index[i * 50],
|
||||
ohlcv.index[i * 50 + 10],
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
fake_signals = pd.Series(
|
||||
[Side.LONG] * 250 + [Side.FLAT] * 250, index=ohlcv.index, dtype=object
|
||||
)
|
||||
|
||||
def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def]
|
||||
return BacktestResult(
|
||||
equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"),
|
||||
returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"),
|
||||
trades=fake_trades,
|
||||
)
|
||||
|
||||
def fake_compile(strategy): # type: ignore[no-untyped-def]
|
||||
return lambda df: fake_signals
|
||||
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.BacktestEngine.run", fake_run
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.compile_strategy", fake_compile
|
||||
)
|
||||
|
||||
src = _MINIMAL_STRATEGY_SRC
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
assert any(
|
||||
f.name == "undertrading" and f.severity == Severity.HIGH
|
||||
for f in report.findings
|
||||
)
|
||||
|
||||
|
||||
def test_undertrading_threshold_parametric(monkeypatch: pytest.MonkeyPatch,
|
||||
ohlcv: pd.DataFrame) -> None:
|
||||
"""undertrading_threshold=25 → 15 trade vengono killati come HIGH."""
|
||||
fake_trades = [
|
||||
_make_trade(
|
||||
ohlcv.index[i * 30],
|
||||
ohlcv.index[i * 30 + 10],
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
)
|
||||
for i in range(15)
|
||||
]
|
||||
fake_signals = pd.Series(
|
||||
[Side.LONG] * 250 + [Side.FLAT] * 250, index=ohlcv.index, dtype=object
|
||||
)
|
||||
|
||||
def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def]
|
||||
return BacktestResult(
|
||||
equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"),
|
||||
returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"),
|
||||
trades=fake_trades,
|
||||
)
|
||||
|
||||
def fake_compile(strategy): # type: ignore[no-untyped-def]
|
||||
return lambda df: fake_signals
|
||||
|
||||
monkeypatch.setattr("multi_swarm_core.agents.adversarial.BacktestEngine.run", fake_run)
|
||||
monkeypatch.setattr("multi_swarm_core.agents.adversarial.compile_strategy", fake_compile)
|
||||
|
||||
ast = parse_strategy(_MINIMAL_STRATEGY_SRC)
|
||||
# Default threshold 10: 15 trade NON killato
|
||||
agent_default = AdversarialAgent()
|
||||
rep_default = agent_default.review(ast, ohlcv)
|
||||
assert not any(f.name == "undertrading" for f in rep_default.findings)
|
||||
# Threshold 25: 15 trade killato
|
||||
agent_strict = AdversarialAgent(undertrading_threshold=25)
|
||||
rep_strict = agent_strict.review(ast, ohlcv)
|
||||
assert any(
|
||||
f.name == "undertrading" and f.severity == Severity.HIGH
|
||||
for f in rep_strict.findings
|
||||
)
|
||||
|
||||
|
||||
def test_overtrading_with_tighter_threshold(monkeypatch: pytest.MonkeyPatch,
|
||||
ohlcv: pd.DataFrame) -> None:
|
||||
"""n_trades > n_bars/20 -> MEDIUM overtrading (Phase 1.5: era /5)."""
|
||||
# 500 bar / 20 = 25. Forziamo 30 trade.
|
||||
n = 30
|
||||
fake_trades = [
|
||||
_make_trade(
|
||||
ohlcv.index[i * 10],
|
||||
ohlcv.index[i * 10 + 5],
|
||||
entry_price=100.0,
|
||||
exit_price=100.5,
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
# Signal alternato per evitare flat_too_long: 50% LONG, 50% FLAT.
|
||||
fake_signals = pd.Series(
|
||||
[Side.LONG if i % 2 == 0 else Side.FLAT for i in range(len(ohlcv))],
|
||||
index=ohlcv.index,
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def]
|
||||
return BacktestResult(
|
||||
equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"),
|
||||
returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"),
|
||||
trades=fake_trades,
|
||||
)
|
||||
|
||||
def fake_compile(strategy): # type: ignore[no-untyped-def]
|
||||
return lambda df: fake_signals
|
||||
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.BacktestEngine.run", fake_run
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.compile_strategy", fake_compile
|
||||
)
|
||||
|
||||
src = _MINIMAL_STRATEGY_SRC
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
assert any(
|
||||
f.name == "overtrading" and f.severity == Severity.MEDIUM
|
||||
for f in report.findings
|
||||
)
|
||||
|
||||
|
||||
def test_flat_too_long_flagged(monkeypatch: pytest.MonkeyPatch,
|
||||
ohlcv: pd.DataFrame) -> None:
|
||||
"""Signal flat per >95% delle bar -> HIGH flat_too_long."""
|
||||
n_bars = len(ohlcv)
|
||||
# 96% flat: 480 FLAT + 20 LONG = 96% flat ratio
|
||||
n_active = 20
|
||||
sig_values = [Side.LONG] * n_active + [Side.FLAT] * (n_bars - n_active)
|
||||
fake_signals = pd.Series(sig_values, index=ohlcv.index, dtype=object)
|
||||
# 15 trade per evitare undertrading HIGH.
|
||||
fake_trades = [
|
||||
_make_trade(
|
||||
ohlcv.index[i * 30],
|
||||
ohlcv.index[i * 30 + 1],
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
)
|
||||
for i in range(15)
|
||||
]
|
||||
|
||||
def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def]
|
||||
return BacktestResult(
|
||||
equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"),
|
||||
returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"),
|
||||
trades=fake_trades,
|
||||
)
|
||||
|
||||
def fake_compile(strategy): # type: ignore[no-untyped-def]
|
||||
return lambda df: fake_signals
|
||||
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.BacktestEngine.run", fake_run
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.compile_strategy", fake_compile
|
||||
)
|
||||
|
||||
src = _MINIMAL_STRATEGY_SRC
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
assert any(
|
||||
f.name == "flat_too_long" and f.severity == Severity.HIGH
|
||||
for f in report.findings
|
||||
)
|
||||
|
||||
|
||||
def test_fees_eat_alpha_flagged(monkeypatch: pytest.MonkeyPatch,
|
||||
ohlcv: pd.DataFrame) -> None:
|
||||
"""gross_pnl > 0 ma fees > 50% del lordo -> HIGH fees_eat_alpha."""
|
||||
# Costruisco trade con gross piccolo e fees alti via fees_bp esagerato.
|
||||
# entry=100, exit=100.05, size=1 -> gross=0.05
|
||||
# fees_bp=200 (2%) su (100+100.05)*1*200/10000 = 4.001 fees per trade
|
||||
# In aggregato: gross=15*0.05=0.75, fees=15*4.001=60 -> ratio enorme.
|
||||
n = 15
|
||||
fake_trades = [
|
||||
_make_trade(
|
||||
ohlcv.index[i * 30],
|
||||
ohlcv.index[i * 30 + 1],
|
||||
entry_price=100.0,
|
||||
exit_price=100.05,
|
||||
fees_bp=200.0,
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
# Signal misto per evitare flat_too_long. 50% attivo.
|
||||
fake_signals = pd.Series(
|
||||
[Side.LONG if i % 2 == 0 else Side.FLAT for i in range(len(ohlcv))],
|
||||
index=ohlcv.index,
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def]
|
||||
return BacktestResult(
|
||||
equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"),
|
||||
returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"),
|
||||
trades=fake_trades,
|
||||
)
|
||||
|
||||
def fake_compile(strategy): # type: ignore[no-untyped-def]
|
||||
return lambda df: fake_signals
|
||||
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.BacktestEngine.run", fake_run
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.compile_strategy", fake_compile
|
||||
)
|
||||
|
||||
src = _MINIMAL_STRATEGY_SRC
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
assert any(
|
||||
f.name == "fees_eat_alpha" and f.severity == Severity.HIGH
|
||||
for f in report.findings
|
||||
)
|
||||
|
||||
|
||||
def test_time_in_market_too_high_flagged(monkeypatch: pytest.MonkeyPatch,
|
||||
ohlcv: pd.DataFrame) -> None:
|
||||
"""Signal LONG per >80% delle bar -> HIGH time_in_market_too_high."""
|
||||
n_bars = len(ohlcv)
|
||||
# 90% LONG, 10% FLAT iniziali (warmup-like) per evitare degenerate.
|
||||
n_flat = int(n_bars * 0.10)
|
||||
sig_values = [Side.FLAT] * n_flat + [Side.LONG] * (n_bars - n_flat)
|
||||
fake_signals = pd.Series(sig_values, index=ohlcv.index, dtype=object)
|
||||
# 15 trade per evitare undertrading HIGH.
|
||||
fake_trades = [
|
||||
_make_trade(
|
||||
ohlcv.index[i * 30],
|
||||
ohlcv.index[i * 30 + 1],
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
)
|
||||
for i in range(15)
|
||||
]
|
||||
|
||||
def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def]
|
||||
return BacktestResult(
|
||||
equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"),
|
||||
returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"),
|
||||
trades=fake_trades,
|
||||
)
|
||||
|
||||
def fake_compile(strategy): # type: ignore[no-untyped-def]
|
||||
return lambda df: fake_signals
|
||||
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.BacktestEngine.run", fake_run
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.compile_strategy", fake_compile
|
||||
)
|
||||
|
||||
src = _MINIMAL_STRATEGY_SRC
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
assert any(
|
||||
f.name == "time_in_market_too_high" and f.severity == Severity.HIGH
|
||||
for f in report.findings
|
||||
)
|
||||
|
||||
|
||||
def test_reasonable_balanced_strategy_not_flagged(monkeypatch: pytest.MonkeyPatch,
|
||||
ohlcv: pd.DataFrame) -> None:
|
||||
"""Mix ~50% flat, ~25% long, ~25% short: no HIGH sui gate temporali."""
|
||||
n_bars = len(ohlcv)
|
||||
# Pattern ciclico: 2 flat, 1 long, 1 short per ogni gruppo da 4 bar.
|
||||
# Risultato: ~50% FLAT, ~25% LONG, ~25% SHORT. flat_ratio=0.5 < 0.95,
|
||||
# active_ratio=0.5 < 0.80.
|
||||
pattern = [Side.FLAT, Side.FLAT, Side.LONG, Side.SHORT]
|
||||
sig_values = [pattern[i % 4] for i in range(n_bars)]
|
||||
fake_signals = pd.Series(sig_values, index=ohlcv.index, dtype=object)
|
||||
# 15 trade per evitare undertrading HIGH.
|
||||
fake_trades = [
|
||||
_make_trade(
|
||||
ohlcv.index[i * 30],
|
||||
ohlcv.index[i * 30 + 1],
|
||||
entry_price=100.0,
|
||||
exit_price=101.0,
|
||||
)
|
||||
for i in range(15)
|
||||
]
|
||||
|
||||
def fake_run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: # type: ignore[no-untyped-def]
|
||||
return BacktestResult(
|
||||
equity_curve=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="equity"),
|
||||
returns=pd.Series([0.0] * len(ohlcv), index=ohlcv.index, name="returns"),
|
||||
trades=fake_trades,
|
||||
)
|
||||
|
||||
def fake_compile(strategy): # type: ignore[no-untyped-def]
|
||||
return lambda df: fake_signals
|
||||
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.BacktestEngine.run", fake_run
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"multi_swarm_core.agents.adversarial.compile_strategy", fake_compile
|
||||
)
|
||||
|
||||
src = _MINIMAL_STRATEGY_SRC
|
||||
ast = parse_strategy(src)
|
||||
agent = AdversarialAgent()
|
||||
report = agent.review(ast, ohlcv)
|
||||
# I due gate temporali non devono triggerare.
|
||||
names = [f.name for f in report.findings]
|
||||
assert "flat_too_long" not in names
|
||||
assert "time_in_market_too_high" not in names
|
||||
@@ -0,0 +1,56 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.backtest.engine import BacktestEngine
|
||||
from multi_swarm_core.backtest.orders import Side
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trending_ohlcv() -> pd.DataFrame:
|
||||
idx = pd.date_range("2024-01-01", periods=100, freq="1h", tz="UTC")
|
||||
close = np.linspace(100, 120, 100)
|
||||
df = pd.DataFrame(
|
||||
{"open": close, "high": close + 0.5, "low": close - 0.5, "close": close, "volume": 1.0},
|
||||
index=idx,
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
def test_engine_no_signals_zero_pnl(trending_ohlcv: pd.DataFrame) -> None:
|
||||
signals = pd.Series([Side.FLAT] * len(trending_ohlcv), index=trending_ohlcv.index)
|
||||
engine = BacktestEngine(fees_bp=5.0)
|
||||
result = engine.run(trending_ohlcv, signals)
|
||||
assert result.equity_curve.iloc[-1] == pytest.approx(0.0)
|
||||
assert len(result.trades) == 0
|
||||
|
||||
|
||||
def test_engine_long_in_uptrend_makes_profit(trending_ohlcv: pd.DataFrame) -> None:
|
||||
signals = pd.Series([Side.LONG] * len(trending_ohlcv), index=trending_ohlcv.index)
|
||||
engine = BacktestEngine(fees_bp=5.0)
|
||||
result = engine.run(trending_ohlcv, signals)
|
||||
assert result.equity_curve.iloc[-1] > 0
|
||||
assert len(result.trades) == 1
|
||||
assert result.trades[0].side == Side.LONG
|
||||
|
||||
|
||||
def test_engine_position_flips_on_side_change(trending_ohlcv: pd.DataFrame) -> None:
|
||||
half = len(trending_ohlcv) // 2
|
||||
signals = pd.Series(
|
||||
[Side.LONG] * half + [Side.SHORT] * (len(trending_ohlcv) - half),
|
||||
index=trending_ohlcv.index,
|
||||
)
|
||||
engine = BacktestEngine(fees_bp=5.0)
|
||||
result = engine.run(trending_ohlcv, signals)
|
||||
assert len(result.trades) == 2
|
||||
assert result.trades[0].side == Side.LONG
|
||||
assert result.trades[1].side == Side.SHORT
|
||||
|
||||
|
||||
def test_engine_fees_are_subtracted(trending_ohlcv: pd.DataFrame) -> None:
|
||||
signals = pd.Series([Side.LONG] * len(trending_ohlcv), index=trending_ohlcv.index)
|
||||
engine_no_fees = BacktestEngine(fees_bp=0.0)
|
||||
engine_fees = BacktestEngine(fees_bp=10.0)
|
||||
r1 = engine_no_fees.run(trending_ohlcv, signals)
|
||||
r2 = engine_fees.run(trending_ohlcv, signals)
|
||||
assert r1.equity_curve.iloc[-1] > r2.equity_curve.iloc[-1]
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.backtest.orders import Order, Position, Side, Trade
|
||||
|
||||
|
||||
def test_order_validates_side() -> None:
|
||||
o = Order(ts=datetime(2024, 1, 1, tzinfo=UTC), side=Side.LONG, size=1.0)
|
||||
assert o.side == Side.LONG
|
||||
|
||||
|
||||
def test_position_pnl_long() -> None:
|
||||
pos = Position(side=Side.LONG, entry_price=100.0, size=2.0)
|
||||
assert pos.unrealized_pnl(110.0) == pytest.approx(20.0)
|
||||
assert pos.unrealized_pnl(90.0) == pytest.approx(-20.0)
|
||||
|
||||
|
||||
def test_position_pnl_short() -> None:
|
||||
pos = Position(side=Side.SHORT, entry_price=100.0, size=2.0)
|
||||
assert pos.unrealized_pnl(110.0) == pytest.approx(-20.0)
|
||||
assert pos.unrealized_pnl(90.0) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_trade_realized_pnl_with_fees() -> None:
|
||||
t = Trade(
|
||||
entry_ts=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
exit_ts=datetime(2024, 1, 2, tzinfo=UTC),
|
||||
side=Side.LONG,
|
||||
size=1.0,
|
||||
entry_price=100.0,
|
||||
exit_price=110.0,
|
||||
fees_bp=5.0,
|
||||
)
|
||||
# gross 10, fees = 5bp * (100+110) = 0.0005 * 210 = 0.105
|
||||
assert t.gross_pnl == pytest.approx(10.0)
|
||||
assert t.fees == pytest.approx(0.105)
|
||||
assert t.net_pnl == pytest.approx(9.895)
|
||||
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from multi_swarm_core.cerbero.client import CerberoClient
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_call_tool_passes_bearer_and_bot_tag() -> None:
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://test:9000/mcp-deribit/tools/get_iv_rank",
|
||||
json={"iv_rank": 0.42},
|
||||
status=200,
|
||||
)
|
||||
client = CerberoClient(
|
||||
base_url="http://test:9000", token="tok-xyz", bot_tag="swarm-poc-phase1"
|
||||
)
|
||||
result = client.call_tool("deribit", "get_iv_rank", {"symbol": "BTC-PERPETUAL"})
|
||||
assert result == {"iv_rank": 0.42}
|
||||
req = responses.calls[0].request
|
||||
assert req.headers["Authorization"] == "Bearer tok-xyz"
|
||||
assert req.headers["X-Bot-Tag"] == "swarm-poc-phase1"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_call_tool_raises_on_error() -> None:
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://test:9000/mcp-deribit/tools/get_iv_rank",
|
||||
json={"error": "bad"},
|
||||
status=400,
|
||||
)
|
||||
client = CerberoClient(
|
||||
base_url="http://test:9000", token="tok-xyz", bot_tag="swarm-poc-phase1"
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
client.call_tool("deribit", "get_iv_rank", {})
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for CerberoOHLCVLoader (mocked CerberoClient)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.data.cerbero_ohlcv import CerberoOHLCVLoader, OHLCVRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_records_object() -> list[dict[str, float | int]]:
|
||||
base = int(datetime(2024, 1, 1, tzinfo=UTC).timestamp() * 1000)
|
||||
return [
|
||||
{
|
||||
"ts": base + i * 3600 * 1000,
|
||||
"open": 40000 + i,
|
||||
"high": 40100 + i,
|
||||
"low": 39900 + i,
|
||||
"close": 40050 + i,
|
||||
"volume": 100.0 + i,
|
||||
}
|
||||
for i in range(48)
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_records_array() -> list[list[float | int]]:
|
||||
base = int(datetime(2024, 1, 1, tzinfo=UTC).timestamp() * 1000)
|
||||
return [
|
||||
[base + i * 3600 * 1000, 40000 + i, 40100 + i, 39900 + i, 40050 + i, 100.0 + i]
|
||||
for i in range(48)
|
||||
]
|
||||
|
||||
|
||||
def test_loader_parses_object_records(
|
||||
tmp_path: Path, mocker, sample_records_object
|
||||
) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_object}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
df = loader.load(
|
||||
OHLCVRequest(
|
||||
symbol="BTC-PERPETUAL",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
exchange="deribit",
|
||||
)
|
||||
)
|
||||
|
||||
assert list(df.columns) == ["open", "high", "low", "close", "volume"]
|
||||
assert len(df) == 48
|
||||
assert df.index.tz is not None
|
||||
fake_client.call_tool.assert_called_once_with(
|
||||
"deribit",
|
||||
"get_historical",
|
||||
{
|
||||
"instrument": "BTC-PERPETUAL",
|
||||
"start_date": "2024-01-01T00:00:00+00:00",
|
||||
"end_date": "2024-01-03T00:00:00+00:00",
|
||||
"resolution": "1h",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_loader_parses_array_records(
|
||||
tmp_path: Path, mocker, sample_records_array
|
||||
) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_array}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
df = loader.load(
|
||||
OHLCVRequest(
|
||||
symbol="BTC-PERPETUAL",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
assert len(df) == 48
|
||||
|
||||
|
||||
def test_loader_uses_cache_on_second_call(
|
||||
tmp_path: Path, mocker, sample_records_object
|
||||
) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_object}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
req = OHLCVRequest(
|
||||
symbol="BTC-PERPETUAL",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
)
|
||||
df1 = loader.load(req)
|
||||
fake_client.call_tool.reset_mock()
|
||||
df2 = loader.load(req)
|
||||
assert fake_client.call_tool.call_count == 0
|
||||
pd.testing.assert_frame_equal(df1, df2)
|
||||
|
||||
|
||||
def test_loader_unsupported_exchange_raises(tmp_path: Path, mocker) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
req = OHLCVRequest(
|
||||
symbol="X",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 2, tzinfo=UTC),
|
||||
exchange="kraken",
|
||||
)
|
||||
with pytest.raises(ValueError, match="unsupported exchange"):
|
||||
loader.load(req)
|
||||
|
||||
|
||||
def test_loader_bybit_args(tmp_path: Path, mocker, sample_records_object) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_object}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
loader.load(
|
||||
OHLCVRequest(
|
||||
symbol="BTCUSDT",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
exchange="bybit",
|
||||
)
|
||||
)
|
||||
args = fake_client.call_tool.call_args.args
|
||||
assert args[0] == "bybit"
|
||||
assert args[1] == "get_historical"
|
||||
payload = args[2]
|
||||
assert payload["symbol"] == "BTCUSDT"
|
||||
assert payload["interval"] == 60
|
||||
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.cerbero.tools import CerberoTools
|
||||
|
||||
|
||||
def test_tools_dispatch_sma(mocker):
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"value": 100.0}
|
||||
t = CerberoTools(fake_client)
|
||||
out = t.sma(exchange="bybit", symbol="BTCUSDT", timeframe="1h", length=20)
|
||||
fake_client.call_tool.assert_called_once_with(
|
||||
"bybit", "sma", {"symbol": "BTCUSDT", "timeframe": "1h", "length": 20}
|
||||
)
|
||||
assert out == {"value": 100.0}
|
||||
|
||||
|
||||
def test_tools_dispatch_rsi(mocker):
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"value": 55.0}
|
||||
t = CerberoTools(fake_client)
|
||||
out = t.rsi(exchange="bybit", symbol="BTCUSDT", timeframe="1h", length=14)
|
||||
fake_client.call_tool.assert_called_once_with(
|
||||
"bybit", "rsi", {"symbol": "BTCUSDT", "timeframe": "1h", "length": 14}
|
||||
)
|
||||
assert out == {"value": 55.0}
|
||||
|
||||
|
||||
def test_tools_unknown_raises(mocker):
|
||||
fake_client = mocker.MagicMock()
|
||||
t = CerberoTools(fake_client)
|
||||
with pytest.raises(AttributeError):
|
||||
t.nonexistent_tool() # type: ignore[attr-defined]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for multi_swarm_core.config.Settings.
|
||||
|
||||
Note on .env isolation:
|
||||
The happy-path test relies on monkeypatch.setenv to provide values.
|
||||
The "requires tokens" test forces _env_file=None when constructing Settings,
|
||||
so that a developer's local .env (if present and populated) cannot mask the
|
||||
absence of required env vars. This keeps the test deterministic both in CI
|
||||
(no .env) and in local dev (.env may exist).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.config import Settings
|
||||
|
||||
|
||||
def test_settings_loads_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CERBERO_BASE_URL", "http://test:9000")
|
||||
monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test")
|
||||
monkeypatch.setenv("CERBERO_MAINNET_TOKEN", "tok-main")
|
||||
monkeypatch.setenv("CERBERO_BOT_TAG", "swarm-poc-phase1")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("RUN_NAME", "test-run")
|
||||
|
||||
s = Settings() # type: ignore[call-arg]
|
||||
|
||||
assert s.cerbero_base_url == "http://test:9000"
|
||||
assert s.cerbero_testnet_token.get_secret_value() == "tok-test"
|
||||
assert s.run_name == "test-run"
|
||||
assert s.data_dir.name == "data"
|
||||
assert s.db_path.name == "runs.db"
|
||||
|
||||
|
||||
def test_settings_requires_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CERBERO_TESTNET_TOKEN", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
# Disable .env loading to keep the test deterministic regardless of
|
||||
# whether a developer's local .env exists and is populated.
|
||||
Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_settings_loads_llm_model_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("LLM_MODEL_TIER_S", "claude-mega-x")
|
||||
monkeypatch.setenv("LLM_MODEL_TIER_A", "claude-premium-y")
|
||||
monkeypatch.setenv("LLM_MODEL_TIER_B", "claude-opus-4-7")
|
||||
monkeypatch.setenv("LLM_MODEL_TIER_C", "deepseek/deepseek-chat")
|
||||
monkeypatch.setenv("LLM_MODEL_TIER_D", "mistralai/mistral-7b")
|
||||
monkeypatch.setenv("OPENROUTER_BASE_URL", "https://example.com/api/v1")
|
||||
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
assert s.llm_model_tier_s == "claude-mega-x"
|
||||
assert s.llm_model_tier_a == "claude-premium-y"
|
||||
assert s.llm_model_tier_b == "claude-opus-4-7"
|
||||
assert s.llm_model_tier_c == "deepseek/deepseek-chat"
|
||||
assert s.llm_model_tier_d == "mistralai/mistral-7b"
|
||||
assert s.openrouter_base_url == "https://example.com/api/v1"
|
||||
|
||||
|
||||
def test_settings_llm_model_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.delenv("LLM_MODEL_TIER_S", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL_TIER_A", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL_TIER_B", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL_TIER_C", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL_TIER_D", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
|
||||
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
assert s.llm_model_tier_s == "google/gemini-3-flash-preview"
|
||||
assert s.llm_model_tier_a == "deepseek/deepseek-v4-flash"
|
||||
assert s.llm_model_tier_b == "deepseek/deepseek-v4-flash"
|
||||
assert s.llm_model_tier_c == "qwen/qwen-2.5-72b-instruct"
|
||||
assert s.llm_model_tier_d == "openai/gpt-oss-20b"
|
||||
assert s.openrouter_base_url == "https://openrouter.ai/api/v1"
|
||||
@@ -0,0 +1,88 @@
|
||||
from multi_swarm_core.genome.hypothesis import ModelTier
|
||||
from multi_swarm_core.llm.cost_tracker import CostTracker, estimate_cost
|
||||
|
||||
|
||||
def test_estimate_cost_tier_c():
|
||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.C)
|
||||
assert cost == 0.40 + 0.40
|
||||
|
||||
|
||||
def test_estimate_cost_tier_b():
|
||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.B)
|
||||
assert cost == 0.14 + 0.28
|
||||
|
||||
|
||||
def test_tracker_accumulates():
|
||||
t = CostTracker()
|
||||
t.record(input_tokens=10_000, output_tokens=20_000, tier=ModelTier.C, run_id="r", agent_id="a")
|
||||
t.record(input_tokens=5_000, output_tokens=15_000, tier=ModelTier.C, run_id="r", agent_id="b")
|
||||
summary = t.summary()
|
||||
assert summary["calls"] == 2
|
||||
assert summary["input_tokens"] == 15_000
|
||||
assert summary["output_tokens"] == 35_000
|
||||
assert summary["cost_usd"] > 0
|
||||
|
||||
|
||||
def test_tracker_per_tier_breakdown():
|
||||
t = CostTracker()
|
||||
t.record(input_tokens=10_000, output_tokens=10_000, tier=ModelTier.C, run_id="r", agent_id="a")
|
||||
t.record(input_tokens=10_000, output_tokens=10_000, tier=ModelTier.B, run_id="r", agent_id="b")
|
||||
summary = t.summary()
|
||||
assert "C" in summary["by_tier"]
|
||||
assert "B" in summary["by_tier"]
|
||||
|
||||
|
||||
def test_estimate_cost_tier_s():
|
||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.S)
|
||||
assert cost == 0.50 + 3.00
|
||||
|
||||
|
||||
def test_estimate_cost_tier_a():
|
||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.A)
|
||||
assert cost == 0.14 + 0.28
|
||||
|
||||
|
||||
def test_estimate_cost_tier_d():
|
||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.D)
|
||||
assert cost == 0.03 + 0.14
|
||||
|
||||
|
||||
def test_tracker_summary_contains_all_five_tiers():
|
||||
t = CostTracker()
|
||||
for tier in (ModelTier.S, ModelTier.A, ModelTier.B, ModelTier.C, ModelTier.D):
|
||||
t.record(
|
||||
input_tokens=1_000,
|
||||
output_tokens=1_000,
|
||||
tier=tier,
|
||||
run_id="r",
|
||||
agent_id=f"a-{tier.value}",
|
||||
)
|
||||
summary = t.summary()
|
||||
for tier_letter in ("S", "A", "B", "C", "D"):
|
||||
assert tier_letter in summary["by_tier"]
|
||||
assert summary["by_tier"][tier_letter]["calls"] == 1
|
||||
|
||||
|
||||
def test_tracker_default_call_kind_is_hypothesis():
|
||||
t = CostTracker()
|
||||
rec = t.record(input_tokens=10, output_tokens=10, tier=ModelTier.C, run_id="r", agent_id="a")
|
||||
assert rec.call_kind == "hypothesis"
|
||||
summary = t.summary()
|
||||
assert "hypothesis" in summary["by_call_kind"]
|
||||
assert summary["by_call_kind"]["hypothesis"]["calls"] == 1
|
||||
assert "mutation" not in summary["by_call_kind"]
|
||||
|
||||
|
||||
def test_tracker_by_call_kind_breakdown():
|
||||
t = CostTracker()
|
||||
t.record(input_tokens=100, output_tokens=200, tier=ModelTier.C, run_id="r", agent_id="a")
|
||||
t.record(input_tokens=100, output_tokens=200, tier=ModelTier.C, run_id="r", agent_id="a")
|
||||
t.record(
|
||||
input_tokens=50, output_tokens=80, tier=ModelTier.B,
|
||||
run_id="r", agent_id="parent-x", call_kind="mutation",
|
||||
)
|
||||
summary = t.summary()
|
||||
assert summary["by_call_kind"]["hypothesis"]["calls"] == 2
|
||||
assert summary["by_call_kind"]["mutation"]["calls"] == 1
|
||||
assert summary["by_call_kind"]["mutation"]["input_tokens"] == 50
|
||||
assert summary["by_call_kind"]["mutation"]["output_tokens"] == 80
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from multi_swarm_core.metrics.diversity import population_prompt_diversity
|
||||
|
||||
|
||||
def test_empty_or_single_prompt_zero_diversity() -> None:
|
||||
assert population_prompt_diversity([]) == 0.0
|
||||
assert population_prompt_diversity(["solo prompt"]) == 0.0
|
||||
|
||||
|
||||
def test_identical_prompts_zero_diversity() -> None:
|
||||
prompts = ["Strategia RSI < 30 long"] * 5
|
||||
assert population_prompt_diversity(prompts) == 0.0
|
||||
|
||||
|
||||
def test_completely_different_prompts_high_diversity() -> None:
|
||||
prompts = [
|
||||
"AAAAAA AAAA AAAAA",
|
||||
"BBBBBB BBBB BBBBB",
|
||||
"CCCCCC CCCC CCCCC",
|
||||
"DDDDDD DDDD DDDDD",
|
||||
]
|
||||
d = population_prompt_diversity(prompts)
|
||||
# SequenceMatcher considera spazi e lunghezza simili → similarity > 0
|
||||
# anche su stringhe completamente "diverse". Soglia realistica: 0.8.
|
||||
assert d > 0.8
|
||||
|
||||
|
||||
def test_partial_overlap_intermediate_diversity() -> None:
|
||||
prompts = [
|
||||
"Strategia momentum 1h con RSI",
|
||||
"Strategia momentum 1h con SMA",
|
||||
"Strategia momentum 4h con RSI",
|
||||
]
|
||||
d = population_prompt_diversity(prompts)
|
||||
assert 0.05 < d < 0.5
|
||||
|
||||
|
||||
def test_diversity_symmetric() -> None:
|
||||
prompts_a = ["x", "yy", "zzz"]
|
||||
prompts_b = ["zzz", "x", "yy"]
|
||||
assert (
|
||||
abs(population_prompt_diversity(prompts_a)
|
||||
- population_prompt_diversity(prompts_b)) < 1e-9
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.agents.falsification import FalsificationAgent, FalsificationReport
|
||||
from multi_swarm_core.protocol.parser import parse_strategy
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trending_ohlcv() -> pd.DataFrame:
|
||||
idx = pd.date_range("2024-01-01", periods=500, freq="1h", tz="UTC")
|
||||
close = 100 + np.cumsum(np.random.RandomState(0).normal(0.01, 1.0, 500))
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": close,
|
||||
"high": close + 0.5,
|
||||
"low": close - 0.5,
|
||||
"close": close,
|
||||
"volume": 1.0,
|
||||
},
|
||||
index=idx,
|
||||
)
|
||||
|
||||
|
||||
def test_falsification_returns_report(trending_ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 30.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
agent = FalsificationAgent(fees_bp=5.0, n_trials_dsr=20)
|
||||
report = agent.evaluate(ast, trending_ohlcv)
|
||||
assert isinstance(report, FalsificationReport)
|
||||
assert isinstance(report.sharpe, float)
|
||||
assert isinstance(report.dsr, float)
|
||||
assert 0.0 <= report.dsr <= 1.0
|
||||
assert isinstance(report.max_drawdown, float)
|
||||
assert isinstance(report.n_trades, int)
|
||||
|
||||
|
||||
def test_falsification_zero_trades_returns_zero_metrics(trending_ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "literal", "value": 1e9},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
agent = FalsificationAgent(fees_bp=5.0, n_trials_dsr=20)
|
||||
report = agent.evaluate(ast, trending_ohlcv)
|
||||
assert report.n_trades == 0
|
||||
assert report.sharpe == 0.0
|
||||
@@ -0,0 +1,154 @@
|
||||
from itertools import pairwise
|
||||
|
||||
from multi_swarm_core.agents.adversarial import AdversarialReport, Finding, Severity
|
||||
from multi_swarm_core.agents.falsification import FalsificationReport
|
||||
from multi_swarm_core.ga.fitness import compute_fitness
|
||||
|
||||
|
||||
def make_falsification(
|
||||
dsr: float = 0.7,
|
||||
max_dd: float = 0.2,
|
||||
n_trades: int = 30,
|
||||
sharpe: float = 1.5,
|
||||
) -> FalsificationReport:
|
||||
return FalsificationReport(
|
||||
sharpe=sharpe,
|
||||
dsr=dsr,
|
||||
dsr_pvalue=0.05,
|
||||
max_drawdown=max_dd,
|
||||
total_return=0.3,
|
||||
n_trades=n_trades,
|
||||
n_bars=500,
|
||||
)
|
||||
|
||||
|
||||
def test_fitness_zero_trades_is_zero() -> None:
|
||||
f = make_falsification(n_trades=0)
|
||||
a = AdversarialReport()
|
||||
assert compute_fitness(f, a) == 0.0
|
||||
|
||||
|
||||
def test_fitness_increases_with_dsr() -> None:
|
||||
a = AdversarialReport()
|
||||
f1 = make_falsification(dsr=0.5)
|
||||
f2 = make_falsification(dsr=0.9)
|
||||
assert compute_fitness(f2, a) > compute_fitness(f1, a)
|
||||
|
||||
|
||||
def test_fitness_decreases_with_drawdown() -> None:
|
||||
a = AdversarialReport()
|
||||
f1 = make_falsification(max_dd=0.1)
|
||||
f2 = make_falsification(max_dd=0.4)
|
||||
assert compute_fitness(f1, a) > compute_fitness(f2, a)
|
||||
|
||||
|
||||
def test_fitness_zeroed_by_high_severity_finding() -> None:
|
||||
f = make_falsification()
|
||||
a = AdversarialReport(
|
||||
findings=[Finding(name="degenerate", severity=Severity.HIGH, detail="x")]
|
||||
)
|
||||
assert compute_fitness(f, a) == 0.0
|
||||
|
||||
|
||||
def test_fitness_continuous_signal_for_mediocre() -> None:
|
||||
"""Strategie mediocri (DSR ~0, Sharpe negativo) hanno comunque fitness>0
|
||||
e la meno cattiva e' preferita."""
|
||||
a = AdversarialReport()
|
||||
less_bad = make_falsification(dsr=0.001, sharpe=-0.5, max_dd=0.3)
|
||||
worse = make_falsification(dsr=0.001, sharpe=-2.0, max_dd=0.3)
|
||||
f_less = compute_fitness(less_bad, a)
|
||||
f_worse = compute_fitness(worse, a)
|
||||
assert f_less > 0.0
|
||||
assert f_worse > 0.0
|
||||
assert f_less > f_worse
|
||||
|
||||
|
||||
def test_fitness_bounded() -> None:
|
||||
"""Fitness e' bounded in [0, 2.0] per input tipici."""
|
||||
a = AdversarialReport()
|
||||
cases = [
|
||||
make_falsification(dsr=0.0, sharpe=-5.0, max_dd=0.0),
|
||||
make_falsification(dsr=0.0, sharpe=0.0, max_dd=0.0),
|
||||
make_falsification(dsr=0.5, sharpe=1.0, max_dd=0.2),
|
||||
make_falsification(dsr=0.9, sharpe=2.0, max_dd=0.15),
|
||||
make_falsification(dsr=1.0, sharpe=5.0, max_dd=0.0),
|
||||
make_falsification(dsr=1.0, sharpe=10.0, max_dd=5.0),
|
||||
]
|
||||
for f in cases:
|
||||
v = compute_fitness(f, a)
|
||||
assert 0.0 <= v <= 2.0, f"fitness {v} fuori range per {f}"
|
||||
|
||||
|
||||
def test_fitness_normalizes_drawdown() -> None:
|
||||
"""Con DSR e Sharpe fissi, fitness e' monotona decrescente in max_dd."""
|
||||
a = AdversarialReport()
|
||||
dds = [0.0, 0.1, 0.5, 1.0, 2.0, 5.0]
|
||||
fitnesses = [
|
||||
compute_fitness(make_falsification(dsr=0.5, sharpe=1.0, max_dd=dd), a)
|
||||
for dd in dds
|
||||
]
|
||||
for prev, curr in pairwise(fitnesses):
|
||||
assert prev > curr, f"non monotona: {fitnesses}"
|
||||
|
||||
|
||||
# --- Fitness v2 (soft-kill opt-in) ---
|
||||
|
||||
|
||||
def test_fitness_v2_soft_high_not_zero() -> None:
|
||||
"""v2: un finding HIGH soft NON azzera, applica solo soft penalty."""
|
||||
f = make_falsification(dsr=0.5, sharpe=1.0, max_dd=0.2)
|
||||
a = AdversarialReport(
|
||||
findings=[Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x")]
|
||||
)
|
||||
v2 = compute_fitness(f, a, hard_kill_findings=("no_trades", "degenerate"))
|
||||
v1 = compute_fitness(f, a)
|
||||
assert v1 == 0.0
|
||||
assert v2 > 0.0
|
||||
|
||||
|
||||
def test_fitness_v2_hard_kill_still_zero() -> None:
|
||||
"""v2: finding HIGH in hard_kill_findings azzera comunque."""
|
||||
f = make_falsification()
|
||||
a = AdversarialReport(
|
||||
findings=[Finding(name="degenerate", severity=Severity.HIGH, detail="x")]
|
||||
)
|
||||
v2 = compute_fitness(f, a, hard_kill_findings=("no_trades", "degenerate"))
|
||||
assert v2 == 0.0
|
||||
|
||||
|
||||
def test_fitness_v2_multiple_soft_high_penalty_increases() -> None:
|
||||
"""v2: più HIGH soft → penalty cumulativa più severa."""
|
||||
f = make_falsification(dsr=0.5, sharpe=1.0, max_dd=0.2)
|
||||
soft = ("no_trades", "degenerate")
|
||||
one_soft = AdversarialReport(
|
||||
findings=[Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x")]
|
||||
)
|
||||
three_soft = AdversarialReport(
|
||||
findings=[
|
||||
Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x"),
|
||||
Finding(name="flat_too_long", severity=Severity.HIGH, detail="x"),
|
||||
Finding(name="time_in_market_too_high", severity=Severity.HIGH, detail="x"),
|
||||
]
|
||||
)
|
||||
v_one = compute_fitness(f, one_soft, hard_kill_findings=soft)
|
||||
v_three = compute_fitness(f, three_soft, hard_kill_findings=soft)
|
||||
assert v_one > v_three > 0.0
|
||||
|
||||
|
||||
def test_fitness_v2_no_findings_equals_v1() -> None:
|
||||
"""v2 senza findings produce esattamente lo stesso valore di v1 (adv_penalty=1.0)."""
|
||||
f = make_falsification(dsr=0.7, sharpe=1.5, max_dd=0.2)
|
||||
a = AdversarialReport()
|
||||
v1 = compute_fitness(f, a)
|
||||
v2 = compute_fitness(f, a, hard_kill_findings=("no_trades", "degenerate"))
|
||||
assert v1 == v2
|
||||
|
||||
|
||||
def test_fitness_v2_default_v1_backward_compat() -> None:
|
||||
"""Senza hard_kill_findings (None) comportamento identico a v1: tutti HIGH azzerano."""
|
||||
f = make_falsification()
|
||||
a = AdversarialReport(
|
||||
findings=[Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x")]
|
||||
)
|
||||
assert compute_fitness(f, a) == 0.0 # v1 default
|
||||
assert compute_fitness(f, a, hard_kill_findings=None) == 0.0 # esplicito None = v1
|
||||
@@ -0,0 +1,27 @@
|
||||
import random
|
||||
|
||||
from multi_swarm_core.ga.initial import build_initial_population
|
||||
from multi_swarm_core.genome.hypothesis import ModelTier
|
||||
|
||||
|
||||
def test_initial_population_size():
|
||||
pop = build_initial_population(k=20, model_tier=ModelTier.C, rng=random.Random(0))
|
||||
assert len(pop) == 20
|
||||
|
||||
|
||||
def test_initial_population_unique_ids():
|
||||
pop = build_initial_population(k=20, model_tier=ModelTier.C, rng=random.Random(0))
|
||||
ids = {g.id for g in pop}
|
||||
assert len(ids) == 20
|
||||
|
||||
|
||||
def test_initial_population_covers_all_styles():
|
||||
pop = build_initial_population(k=12, model_tier=ModelTier.C, rng=random.Random(0))
|
||||
styles = {g.cognitive_style for g in pop}
|
||||
assert len(styles) == 6
|
||||
|
||||
|
||||
def test_initial_population_generation_zero():
|
||||
pop = build_initial_population(k=20, model_tier=ModelTier.C, rng=random.Random(0))
|
||||
assert all(g.generation == 0 for g in pop)
|
||||
assert all(g.parent_ids == [] for g in pop)
|
||||
@@ -0,0 +1,45 @@
|
||||
import random
|
||||
|
||||
from multi_swarm_core.ga.loop import GAConfig, next_generation
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
|
||||
|
||||
def make(idx: int) -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt=f"p-{idx}",
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=100,
|
||||
cognitive_style="x",
|
||||
)
|
||||
|
||||
|
||||
def test_next_generation_size_preserved() -> None:
|
||||
population = [make(i) for i in range(20)]
|
||||
fitnesses = {g.id: float(i) for i, g in enumerate(population)}
|
||||
cfg = GAConfig(population_size=20, elite_k=2, tournament_k=3, p_crossover=0.5)
|
||||
new_pop = next_generation(population, fitnesses, cfg, rng=random.Random(0))
|
||||
assert len(new_pop) == 20
|
||||
|
||||
|
||||
def test_next_generation_includes_elites() -> None:
|
||||
population = [make(i) for i in range(20)]
|
||||
fitnesses = {g.id: float(i) for i, g in enumerate(population)}
|
||||
cfg = GAConfig(population_size=20, elite_k=2, tournament_k=3, p_crossover=0.5)
|
||||
new_pop = next_generation(population, fitnesses, cfg, rng=random.Random(0))
|
||||
elite_ids = {
|
||||
g.id for g in sorted(population, key=lambda g: fitnesses[g.id], reverse=True)[:2]
|
||||
}
|
||||
new_ids = {g.id for g in new_pop}
|
||||
assert elite_ids.issubset(new_ids)
|
||||
|
||||
|
||||
def test_next_generation_increments_generation_for_offspring() -> None:
|
||||
population = [make(i) for i in range(20)]
|
||||
fitnesses = {g.id: float(i) for i, g in enumerate(population)}
|
||||
cfg = GAConfig(population_size=20, elite_k=2, tournament_k=3, p_crossover=0.5)
|
||||
new_pop = next_generation(population, fitnesses, cfg, rng=random.Random(0))
|
||||
new_offspring = [g for g in new_pop if g.id not in {p.id for p in population}]
|
||||
assert all(g.generation > 0 for g in new_offspring)
|
||||
@@ -0,0 +1,26 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.ga.summary import generation_summary
|
||||
|
||||
|
||||
def test_summary_basic_stats():
|
||||
fitnesses = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
||||
s = generation_summary(fitnesses, n_bins=5)
|
||||
assert s["median"] == pytest.approx(0.45, abs=0.05)
|
||||
assert s["max"] == pytest.approx(0.9)
|
||||
assert 0.0 <= s["entropy"] <= math.log(5) + 0.01
|
||||
|
||||
|
||||
def test_summary_uniform_high_entropy():
|
||||
fitnesses = [0.1 * i for i in range(20)]
|
||||
s_uniform = generation_summary(fitnesses, n_bins=5)
|
||||
s_concentrated = generation_summary([0.5] * 20, n_bins=5)
|
||||
assert s_uniform["entropy"] > s_concentrated["entropy"]
|
||||
|
||||
|
||||
def test_summary_p90():
|
||||
fitnesses = list(range(100))
|
||||
s = generation_summary([float(x) for x in fitnesses], n_bins=10)
|
||||
assert 88.0 <= s["p90"] <= 91.0
|
||||
@@ -0,0 +1,44 @@
|
||||
import random
|
||||
|
||||
from multi_swarm_core.genome.crossover import uniform_crossover
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
|
||||
|
||||
def make(name: str) -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt=f"prompt-{name}",
|
||||
feature_access=["close"] if name == "A" else ["close", "volume"],
|
||||
temperature=0.7 if name == "A" else 1.1,
|
||||
top_p=0.9,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=100 if name == "A" else 300,
|
||||
cognitive_style="physicist" if name == "A" else "biologist",
|
||||
)
|
||||
|
||||
|
||||
def test_crossover_lineage() -> None:
|
||||
p1 = make("A")
|
||||
p2 = make("B")
|
||||
rng = random.Random(0)
|
||||
child = uniform_crossover(p1, p2, rng)
|
||||
assert sorted(child.parent_ids[-2:]) == sorted([p1.id, p2.id])
|
||||
assert child.generation == max(p1.generation, p2.generation) + 1
|
||||
|
||||
|
||||
def test_crossover_inherits_each_field_from_one_parent() -> None:
|
||||
p1 = make("A")
|
||||
p2 = make("B")
|
||||
rng = random.Random(0)
|
||||
child = uniform_crossover(p1, p2, rng)
|
||||
assert child.system_prompt in (p1.system_prompt, p2.system_prompt)
|
||||
assert child.temperature in (p1.temperature, p2.temperature)
|
||||
assert child.lookback_window in (p1.lookback_window, p2.lookback_window)
|
||||
assert child.cognitive_style in (p1.cognitive_style, p2.cognitive_style)
|
||||
|
||||
|
||||
def test_crossover_deterministic_with_same_seed() -> None:
|
||||
p1 = make("A")
|
||||
p2 = make("B")
|
||||
c1 = uniform_crossover(p1, p2, random.Random(42))
|
||||
c2 = uniform_crossover(p1, p2, random.Random(42))
|
||||
assert c1.to_dict() == c2.to_dict()
|
||||
@@ -0,0 +1,69 @@
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
|
||||
|
||||
def test_genome_creation_defaults():
|
||||
g = HypothesisAgentGenome(
|
||||
system_prompt="Pensa come un fisico.",
|
||||
feature_access=["close", "volume"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
assert g.id is not None
|
||||
assert g.parent_ids == []
|
||||
assert g.generation == 0
|
||||
|
||||
|
||||
def test_genome_serialization_roundtrip():
|
||||
g = HypothesisAgentGenome(
|
||||
system_prompt="Pensa come un biologo.",
|
||||
feature_access=["close", "high", "low"],
|
||||
temperature=1.1,
|
||||
top_p=0.9,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=300,
|
||||
cognitive_style="biologist",
|
||||
parent_ids=["abc"],
|
||||
generation=5,
|
||||
)
|
||||
payload = g.to_dict()
|
||||
g2 = HypothesisAgentGenome.from_dict(payload)
|
||||
assert g2.system_prompt == g.system_prompt
|
||||
assert g2.feature_access == g.feature_access
|
||||
assert g2.temperature == g.temperature
|
||||
assert g2.parent_ids == g.parent_ids
|
||||
assert g2.generation == g.generation
|
||||
assert g2.id == g.id
|
||||
|
||||
|
||||
def test_genome_id_is_deterministic_on_content():
|
||||
g1 = HypothesisAgentGenome(
|
||||
system_prompt="X", feature_access=["close"], temperature=0.5,
|
||||
top_p=0.9, model_tier=ModelTier.C, lookback_window=100, cognitive_style="x",
|
||||
)
|
||||
g2 = HypothesisAgentGenome(
|
||||
system_prompt="X", feature_access=["close"], temperature=0.5,
|
||||
top_p=0.9, model_tier=ModelTier.C, lookback_window=100, cognitive_style="x",
|
||||
)
|
||||
assert g1.id == g2.id
|
||||
|
||||
|
||||
def test_genome_all_tiers_serde_roundtrip():
|
||||
"""Tutti i 5 tier (S, A, B, C, D) sopravvivono a to_dict/from_dict."""
|
||||
for tier in (ModelTier.S, ModelTier.A, ModelTier.B, ModelTier.C, ModelTier.D):
|
||||
g = HypothesisAgentGenome(
|
||||
system_prompt="prompt",
|
||||
feature_access=["close"],
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
model_tier=tier,
|
||||
lookback_window=128,
|
||||
cognitive_style="generic",
|
||||
)
|
||||
payload = g.to_dict()
|
||||
assert payload["model_tier"] == tier.value
|
||||
g2 = HypothesisAgentGenome.from_dict(payload)
|
||||
assert g2.model_tier == tier
|
||||
assert g2.id == g.id
|
||||
@@ -0,0 +1,61 @@
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm_core.genome.mutation import (
|
||||
COGNITIVE_STYLES,
|
||||
FEATURE_POOL,
|
||||
mutate_cognitive_style,
|
||||
mutate_feature_access,
|
||||
mutate_lookback,
|
||||
mutate_temperature,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_genome() -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt="x",
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
|
||||
|
||||
def test_mutate_temperature_within_bounds(base_genome: HypothesisAgentGenome) -> None:
|
||||
rng = random.Random(0)
|
||||
for _ in range(50):
|
||||
new = mutate_temperature(base_genome, rng)
|
||||
assert 0.6 <= new.temperature <= 1.3
|
||||
|
||||
|
||||
def test_mutate_lookback_within_bounds(base_genome: HypothesisAgentGenome) -> None:
|
||||
rng = random.Random(0)
|
||||
for _ in range(50):
|
||||
new = mutate_lookback(base_genome, rng)
|
||||
assert 50 <= new.lookback_window <= 500
|
||||
|
||||
|
||||
def test_mutate_feature_access_changes_set(base_genome: HypothesisAgentGenome) -> None:
|
||||
rng = random.Random(0)
|
||||
new = mutate_feature_access(base_genome, rng)
|
||||
assert set(new.feature_access) != set(base_genome.feature_access) or len(FEATURE_POOL) == 1
|
||||
assert all(f in FEATURE_POOL for f in new.feature_access)
|
||||
assert len(new.feature_access) >= 1
|
||||
|
||||
|
||||
def test_mutate_cognitive_style_uses_pool(base_genome: HypothesisAgentGenome) -> None:
|
||||
rng = random.Random(0)
|
||||
new = mutate_cognitive_style(base_genome, rng)
|
||||
assert new.cognitive_style in COGNITIVE_STYLES
|
||||
|
||||
|
||||
def test_mutation_preserves_lineage(base_genome: HypothesisAgentGenome) -> None:
|
||||
rng = random.Random(0)
|
||||
new = mutate_temperature(base_genome, rng)
|
||||
assert base_genome.id in new.parent_ids
|
||||
assert new.id != base_genome.id
|
||||
@@ -0,0 +1,250 @@
|
||||
import json
|
||||
|
||||
from multi_swarm_core.agents.hypothesis import HypothesisAgent, MarketSummary
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm_core.llm.client import CompletionResult, EmptyCompletionError
|
||||
|
||||
|
||||
def make_summary() -> MarketSummary:
|
||||
return MarketSummary(
|
||||
symbol="BTC/USDT",
|
||||
timeframe="1h",
|
||||
n_bars=1000,
|
||||
return_mean=0.0001,
|
||||
return_std=0.01,
|
||||
skew=0.1,
|
||||
kurtosis=3.5,
|
||||
volatility_regime="high",
|
||||
)
|
||||
|
||||
|
||||
VALID_STRATEGY_JSON = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_genome() -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt="Pensa come un fisico.",
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
|
||||
|
||||
def test_hypothesis_agent_calls_llm_and_parses(mocker): # type: ignore[no-untyped-def]
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.return_value = CompletionResult(
|
||||
text=VALID_STRATEGY_JSON,
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
agent = HypothesisAgent(llm=fake_llm)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is not None
|
||||
assert proposal.completions[0].input_tokens == 200
|
||||
assert proposal.n_attempts == 1
|
||||
fake_llm.complete.assert_called_once()
|
||||
|
||||
|
||||
def test_hypothesis_agent_returns_none_on_parse_error(mocker): # type: ignore[no-untyped-def]
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.return_value = CompletionResult(
|
||||
text="this is not JSON",
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
agent = HypothesisAgent(llm=fake_llm, max_retries=0)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is None
|
||||
assert proposal.parse_error is not None
|
||||
assert proposal.n_attempts == 1
|
||||
assert fake_llm.complete.call_count == 1
|
||||
|
||||
|
||||
def test_hypothesis_agent_extracts_json_from_markdown_fence(mocker): # type: ignore[no-untyped-def]
|
||||
fenced = (
|
||||
"Ecco la strategia:\n```json\n"
|
||||
+ VALID_STRATEGY_JSON
|
||||
+ "\n```\nFatta."
|
||||
)
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.return_value = CompletionResult(
|
||||
text=fenced,
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
agent = HypothesisAgent(llm=fake_llm)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is not None
|
||||
|
||||
|
||||
def test_hypothesis_agent_returns_error_on_invalid_strategy(mocker): # type: ignore[no-untyped-def]
|
||||
bad = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "wibble", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.return_value = CompletionResult(
|
||||
text=bad,
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
agent = HypothesisAgent(llm=fake_llm, max_retries=0)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is None
|
||||
assert proposal.parse_error is not None
|
||||
assert "wibble" in proposal.parse_error or "unknown" in proposal.parse_error
|
||||
|
||||
|
||||
def test_hypothesis_agent_retries_on_parse_error_and_succeeds(mocker): # type: ignore[no-untyped-def]
|
||||
"""Primo output malformato → secondo output valido → strategia accettata."""
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.side_effect = [
|
||||
CompletionResult(
|
||||
text="this is not JSON at all",
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
),
|
||||
CompletionResult(
|
||||
text="```json\n" + VALID_STRATEGY_JSON + "\n```",
|
||||
input_tokens=300,
|
||||
output_tokens=120,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
),
|
||||
]
|
||||
agent = HypothesisAgent(llm=fake_llm, max_retries=1)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is not None
|
||||
assert proposal.n_attempts == 2
|
||||
assert len(proposal.completions) == 2
|
||||
assert proposal.completions[0].input_tokens == 200
|
||||
assert proposal.completions[1].input_tokens == 300
|
||||
assert fake_llm.complete.call_count == 2
|
||||
# Il secondo prompt user deve contenere il marker corrective.
|
||||
second_call_kwargs = fake_llm.complete.call_args_list[1].kwargs
|
||||
assert "TENTATIVO PRECEDENTE FALLITO" in second_call_kwargs["user"]
|
||||
assert "this is not JSON at all" in second_call_kwargs["user"]
|
||||
|
||||
|
||||
def test_hypothesis_agent_gives_up_after_max_retries(mocker): # type: ignore[no-untyped-def]
|
||||
"""Entrambi i tentativi falliscono → strategy None, errori concatenati."""
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.side_effect = [
|
||||
CompletionResult(
|
||||
text="garbage attempt 1",
|
||||
input_tokens=200,
|
||||
output_tokens=50,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
),
|
||||
CompletionResult(
|
||||
text="garbage attempt 2",
|
||||
input_tokens=250,
|
||||
output_tokens=60,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
),
|
||||
]
|
||||
agent = HypothesisAgent(llm=fake_llm, max_retries=1)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is None
|
||||
assert proposal.n_attempts == 2
|
||||
assert len(proposal.completions) == 2
|
||||
assert fake_llm.complete.call_count == 2
|
||||
assert proposal.parse_error is not None
|
||||
assert "attempt 1" in proposal.parse_error
|
||||
assert "attempt 2" in proposal.parse_error
|
||||
# raw_text deve riflettere l'ULTIMO output (non il primo).
|
||||
assert proposal.raw_text == "garbage attempt 2"
|
||||
|
||||
|
||||
def test_hypothesis_agent_no_retry_when_first_succeeds(mocker): # type: ignore[no-untyped-def]
|
||||
"""Primo tentativo OK → nessun retry, anche con max_retries=1 di default."""
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.return_value = CompletionResult(
|
||||
text=VALID_STRATEGY_JSON,
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
agent = HypothesisAgent(llm=fake_llm) # default max_retries=1
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is not None
|
||||
assert proposal.n_attempts == 1
|
||||
assert len(proposal.completions) == 1
|
||||
assert fake_llm.complete.call_count == 1
|
||||
|
||||
|
||||
def test_hypothesis_agent_retries_on_empty_completion(mocker): # type: ignore[no-untyped-def]
|
||||
"""LLMClient esaurisce retry tenacity → propose ritenta nel loop max_attempts."""
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.side_effect = [
|
||||
EmptyCompletionError("empty response from qwen"),
|
||||
CompletionResult(
|
||||
text=VALID_STRATEGY_JSON,
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
),
|
||||
]
|
||||
agent = HypothesisAgent(llm=fake_llm, max_retries=2)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is not None
|
||||
assert fake_llm.complete.call_count == 2
|
||||
# n_attempts conta solo le completions arrivate (skipping empty failures).
|
||||
assert len(proposal.completions) == 1
|
||||
|
||||
|
||||
def test_hypothesis_agent_returns_failed_proposal_on_only_empty_completions(mocker): # type: ignore[no-untyped-def]
|
||||
"""Tutti i tentativi sollevano EmptyCompletionError → proposal con strategy None."""
|
||||
fake_llm = mocker.MagicMock()
|
||||
fake_llm.complete.side_effect = EmptyCompletionError("empty response")
|
||||
agent = HypothesisAgent(llm=fake_llm, max_retries=2)
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is None
|
||||
assert proposal.parse_error is not None
|
||||
assert "empty_completion" in proposal.parse_error
|
||||
# 3 tentativi tutti falliti.
|
||||
assert fake_llm.complete.call_count == 3
|
||||
@@ -0,0 +1,232 @@
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm_core.llm.client import CompletionResult, LLMClient
|
||||
|
||||
|
||||
def make_genome(tier: ModelTier) -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt="x",
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=tier,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
|
||||
|
||||
def test_completion_tier_c_uses_openrouter(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=100, completion_tokens=200)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(openrouter_api_key="or-x")
|
||||
g = make_genome(ModelTier.C)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
assert isinstance(out, CompletionResult)
|
||||
assert out.text == "(strategy ...)"
|
||||
assert out.input_tokens == 100
|
||||
assert out.output_tokens == 200
|
||||
assert out.tier == ModelTier.C
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
|
||||
|
||||
def test_completion_tier_b_uses_openrouter_with_anthropic_model(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=80, completion_tokens=150)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(openrouter_api_key="or-x")
|
||||
g = make_genome(ModelTier.B)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
assert out.text == "(strategy ...)"
|
||||
assert out.input_tokens == 80
|
||||
assert out.output_tokens == 150
|
||||
assert out.tier == ModelTier.B
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "deepseek/deepseek-v4-flash"
|
||||
assert out.model == "deepseek/deepseek-v4-flash"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_completion_retries_on_connection_error(mocker):
|
||||
"""Retry esegue 3 tentativi su APIConnectionError, poi rilancia."""
|
||||
import openai
|
||||
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_openai.chat.completions.create.side_effect = openai.APIConnectionError(
|
||||
request=mocker.MagicMock()
|
||||
)
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(openrouter_api_key="or-x")
|
||||
g = make_genome(ModelTier.C)
|
||||
|
||||
with pytest.raises(openai.APIConnectionError):
|
||||
client.complete(g, system="sys", user="usr")
|
||||
|
||||
assert fake_openai.chat.completions.create.call_count == 5
|
||||
|
||||
|
||||
def test_completion_uses_custom_model_tier_c(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [
|
||||
mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))
|
||||
]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=10, completion_tokens=20)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(
|
||||
openrouter_api_key="or-x",
|
||||
model_tier_c="deepseek/deepseek-chat",
|
||||
)
|
||||
g = make_genome(ModelTier.C)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "deepseek/deepseek-chat"
|
||||
assert out.model == "deepseek/deepseek-chat"
|
||||
|
||||
|
||||
def test_completion_uses_custom_model_tier_b(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [
|
||||
mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))
|
||||
]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=10, completion_tokens=20)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(
|
||||
openrouter_api_key="or-x",
|
||||
model_tier_b="anthropic/claude-opus-4-7",
|
||||
)
|
||||
g = make_genome(ModelTier.B)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "anthropic/claude-opus-4-7"
|
||||
assert out.model == "anthropic/claude-opus-4-7"
|
||||
|
||||
|
||||
def test_completion_tier_s_uses_openrouter_with_anthropic_model(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy s)"))]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=50, completion_tokens=100)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(openrouter_api_key="or-x")
|
||||
g = make_genome(ModelTier.S)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "google/gemini-3-flash-preview"
|
||||
assert out.tier == ModelTier.S
|
||||
assert out.model == "google/gemini-3-flash-preview"
|
||||
|
||||
|
||||
def test_completion_tier_a_uses_openrouter_with_anthropic_model(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy a)"))]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=40, completion_tokens=80)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(openrouter_api_key="or-x")
|
||||
g = make_genome(ModelTier.A)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "deepseek/deepseek-v4-flash"
|
||||
assert out.tier == ModelTier.A
|
||||
assert out.model == "deepseek/deepseek-v4-flash"
|
||||
|
||||
|
||||
def test_completion_tier_d_uses_openrouter_with_llama(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [
|
||||
mocker.MagicMock(message=mocker.MagicMock(content="(strategy d)"))
|
||||
]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=30, completion_tokens=70)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(openrouter_api_key="or-x")
|
||||
g = make_genome(ModelTier.D)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "openai/gpt-oss-20b"
|
||||
assert out.tier == ModelTier.D
|
||||
assert out.model == "openai/gpt-oss-20b"
|
||||
|
||||
|
||||
def test_completion_uses_custom_model_tier_s(mocker):
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [
|
||||
mocker.MagicMock(message=mocker.MagicMock(content="(strategy custom-s)"))
|
||||
]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=10, completion_tokens=20)
|
||||
fake_openai.chat.completions.create.return_value = fake_response
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(
|
||||
openrouter_api_key="or-x",
|
||||
model_tier_s="anthropic/claude-future-mega",
|
||||
)
|
||||
g = make_genome(ModelTier.S)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "anthropic/claude-future-mega"
|
||||
assert out.model == "anthropic/claude-future-mega"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_completion_succeeds_after_one_retry(mocker):
|
||||
"""Dopo 1 fallimento transient, il retry riesce al 2 tentativo."""
|
||||
import openai
|
||||
|
||||
fake_response = mocker.MagicMock()
|
||||
fake_response.choices = [
|
||||
mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))
|
||||
]
|
||||
fake_response.usage = mocker.MagicMock(prompt_tokens=100, completion_tokens=200)
|
||||
|
||||
fake_openai = mocker.MagicMock()
|
||||
fake_openai.chat.completions.create.side_effect = [
|
||||
openai.APITimeoutError(request=mocker.MagicMock()),
|
||||
fake_response,
|
||||
]
|
||||
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
|
||||
|
||||
client = LLMClient(openrouter_api_key="or-x")
|
||||
g = make_genome(ModelTier.C)
|
||||
out = client.complete(g, system="sys", user="usr")
|
||||
|
||||
assert isinstance(out, CompletionResult)
|
||||
assert out.text == "(strategy ...)"
|
||||
assert fake_openai.chat.completions.create.call_count == 2
|
||||
@@ -0,0 +1,33 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from multi_swarm_core.agents.market_summary import build_market_summary
|
||||
|
||||
|
||||
def test_build_summary_basic() -> None:
|
||||
idx = pd.date_range("2024-01-01", periods=200, freq="1h", tz="UTC")
|
||||
np.random.seed(0)
|
||||
close = 100 + np.cumsum(np.random.normal(0, 1, 200))
|
||||
df = pd.DataFrame(
|
||||
{"open": close, "high": close + 0.5, "low": close - 0.5, "close": close, "volume": 1.0},
|
||||
index=idx,
|
||||
)
|
||||
s = build_market_summary(df, symbol="BTC/USDT", timeframe="1h")
|
||||
assert s.symbol == "BTC/USDT"
|
||||
assert s.timeframe == "1h"
|
||||
assert s.n_bars == 200
|
||||
assert isinstance(s.return_mean, float)
|
||||
assert isinstance(s.return_std, float)
|
||||
assert s.volatility_regime in {"low", "medium", "high"}
|
||||
|
||||
|
||||
def test_volatility_regime_high_for_volatile() -> None:
|
||||
idx = pd.date_range("2024-01-01", periods=200, freq="1h", tz="UTC")
|
||||
np.random.seed(0)
|
||||
close = 100 + np.cumsum(np.random.normal(0, 5.0, 200)) # alta vol
|
||||
df = pd.DataFrame(
|
||||
{"open": close, "high": close + 0.5, "low": close - 0.5, "close": close, "volume": 1.0},
|
||||
index=idx,
|
||||
)
|
||||
s = build_market_summary(df, symbol="BTC/USDT", timeframe="1h")
|
||||
assert s.volatility_regime in {"medium", "high"}
|
||||
@@ -0,0 +1,40 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.metrics.basic import max_drawdown, sharpe_ratio, total_return
|
||||
|
||||
|
||||
def test_sharpe_zero_returns():
|
||||
r = pd.Series([0.0] * 100)
|
||||
assert sharpe_ratio(r, periods_per_year=8760) == 0.0
|
||||
|
||||
|
||||
def test_sharpe_positive_returns():
|
||||
np.random.seed(42)
|
||||
r = pd.Series(np.random.normal(0.001, 0.01, 1000))
|
||||
s = sharpe_ratio(r, periods_per_year=8760)
|
||||
assert s > 0
|
||||
|
||||
|
||||
def test_sharpe_negative_returns():
|
||||
np.random.seed(42)
|
||||
r = pd.Series(np.random.normal(-0.001, 0.01, 1000))
|
||||
s = sharpe_ratio(r, periods_per_year=8760)
|
||||
assert s < 0
|
||||
|
||||
|
||||
def test_max_drawdown_monotonic_up():
|
||||
eq = pd.Series([100.0, 105.0, 110.0, 115.0, 120.0])
|
||||
assert max_drawdown(eq) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_max_drawdown_known_curve():
|
||||
eq = pd.Series([100.0, 110.0, 90.0, 95.0, 105.0])
|
||||
# peak 110, trough 90, drawdown = (110-90)/110 ≈ 0.1818
|
||||
assert max_drawdown(eq) == pytest.approx(20.0 / 110.0)
|
||||
|
||||
|
||||
def test_total_return():
|
||||
eq = pd.Series([100.0, 110.0, 105.0, 120.0])
|
||||
assert total_return(eq) == pytest.approx(0.20)
|
||||
@@ -0,0 +1,32 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from multi_swarm_core.metrics.dsr import deflated_sharpe_ratio, expected_max_sharpe
|
||||
|
||||
|
||||
def test_expected_max_sharpe_grows_with_n_trials():
|
||||
e1 = expected_max_sharpe(n_trials=1, sharpe_var=1.0)
|
||||
e10 = expected_max_sharpe(n_trials=10, sharpe_var=1.0)
|
||||
e100 = expected_max_sharpe(n_trials=100, sharpe_var=1.0)
|
||||
assert e1 < e10 < e100
|
||||
|
||||
|
||||
def test_dsr_zero_when_sharpe_equals_expected_max():
|
||||
np.random.seed(0)
|
||||
returns = pd.Series(np.random.normal(0, 0.01, 500))
|
||||
_dsr, p = deflated_sharpe_ratio(
|
||||
returns, n_trials=10, periods_per_year=8760, sharpe_var=0.0
|
||||
)
|
||||
# Con sharpe_var=0 e Sharpe stimato vicino a 0, p-value deve essere alto.
|
||||
assert 0.0 <= p <= 1.0
|
||||
|
||||
|
||||
def test_dsr_significant_for_strong_sharpe():
|
||||
np.random.seed(42)
|
||||
returns = pd.Series(np.random.normal(0.005, 0.005, 1000))
|
||||
dsr, p = deflated_sharpe_ratio(
|
||||
returns, n_trials=5, periods_per_year=8760, sharpe_var=1.0
|
||||
)
|
||||
# Sharpe atteso > 0 e p-value basso
|
||||
assert dsr > 0
|
||||
assert p < 0.5
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm_core.genome.mutation import weighted_random_mutate
|
||||
|
||||
_PROMPT = (
|
||||
"Strategia mean-reversion 1h BTC. Entry long quando RSI(14) < 30 e "
|
||||
"close > SMA(50). Exit short quando RSI(14) > 70."
|
||||
)
|
||||
|
||||
|
||||
def _make_genome() -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt=_PROMPT,
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _R:
|
||||
text: str
|
||||
|
||||
|
||||
class _AlwaysMutateLLM:
|
||||
"""Mock LLM che ritorna sempre un prompt mutato valido."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def complete(self, genome, system, user, max_tokens: int = 2000) -> _R:
|
||||
self.calls += 1
|
||||
return _R(
|
||||
text=(
|
||||
"<prompt>Strategia momentum 1h BTC. Entry long quando close > "
|
||||
f"SMA(70) e ATR(14) crescente. Exit con stop loss 3% (call #{self.calls}).</prompt>"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_weighted_dispatcher_zero_weight_never_calls_llm() -> None:
|
||||
llm = _AlwaysMutateLLM()
|
||||
rng = random.Random(0)
|
||||
parent = _make_genome()
|
||||
|
||||
for _ in range(50):
|
||||
weighted_random_mutate(parent, rng, llm=llm, prompt_mutation_weight=0.0)
|
||||
|
||||
assert llm.calls == 0
|
||||
|
||||
|
||||
def test_weighted_dispatcher_full_weight_always_calls_llm() -> None:
|
||||
llm = _AlwaysMutateLLM()
|
||||
rng = random.Random(0)
|
||||
parent = _make_genome()
|
||||
|
||||
for _ in range(20):
|
||||
child = weighted_random_mutate(
|
||||
parent, rng, llm=llm, prompt_mutation_weight=1.0
|
||||
)
|
||||
assert child.system_prompt != parent.system_prompt
|
||||
|
||||
assert llm.calls == 20
|
||||
|
||||
|
||||
def test_weighted_dispatcher_none_llm_falls_back_to_scalar() -> None:
|
||||
"""Senza llm passato (backward compat) → solo mutazione scalare."""
|
||||
rng = random.Random(0)
|
||||
parent = _make_genome()
|
||||
|
||||
for _ in range(50):
|
||||
child = weighted_random_mutate(parent, rng, llm=None, prompt_mutation_weight=0.5)
|
||||
assert child.system_prompt == parent.system_prompt
|
||||
|
||||
|
||||
def test_weighted_dispatcher_distribution_30_70() -> None:
|
||||
"""Su 1000 estrazioni con weight=0.3 il prompt mutator deve essere chiamato ~300 volte."""
|
||||
llm = _AlwaysMutateLLM()
|
||||
rng = random.Random(123)
|
||||
parent = _make_genome()
|
||||
|
||||
counter: Counter[str] = Counter()
|
||||
for _ in range(1000):
|
||||
child = weighted_random_mutate(
|
||||
parent, rng, llm=llm, prompt_mutation_weight=0.3
|
||||
)
|
||||
if child.system_prompt != parent.system_prompt:
|
||||
counter["prompt"] += 1
|
||||
else:
|
||||
counter["scalar"] += 1
|
||||
|
||||
# 30% ± 5% tolerance
|
||||
assert 250 <= counter["prompt"] <= 350, f"prompt mutations: {counter['prompt']}"
|
||||
assert 650 <= counter["scalar"] <= 750, f"scalar mutations: {counter['scalar']}"
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm_core.genome.mutation_prompt_llm import (
|
||||
MUTATION_INSTRUCTIONS,
|
||||
_extract_prompt,
|
||||
is_valid_prompt,
|
||||
mutate_prompt_llm,
|
||||
)
|
||||
|
||||
_BASE_PROMPT = (
|
||||
"Strategia mean-reversion 1h su BTC. Entry long quando RSI(14) < 30 e "
|
||||
"close > SMA(50). Exit short quando RSI(14) > 70. Stop loss 2%."
|
||||
)
|
||||
|
||||
|
||||
def _make_genome(prompt: str = _BASE_PROMPT) -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt=prompt,
|
||||
feature_access=["close", "high", "low"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeResult:
|
||||
text: str
|
||||
|
||||
|
||||
class _FakeLLM:
|
||||
"""Mock LLMClient: ritorna una risposta configurata in input."""
|
||||
|
||||
def __init__(self, response_text: str = "", raise_exc: bool = False) -> None:
|
||||
self.response_text = response_text
|
||||
self.raise_exc = raise_exc
|
||||
self.last_call: dict[str, object] | None = None
|
||||
|
||||
def complete(
|
||||
self,
|
||||
genome: HypothesisAgentGenome,
|
||||
system: str,
|
||||
user: str,
|
||||
max_tokens: int = 2000,
|
||||
) -> _FakeResult:
|
||||
self.last_call = {
|
||||
"genome_tier": genome.model_tier,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if self.raise_exc:
|
||||
raise RuntimeError("simulated LLM failure")
|
||||
return _FakeResult(text=self.response_text)
|
||||
|
||||
|
||||
def test_extract_prompt_from_tag() -> None:
|
||||
raw = "preambolo blah\n<prompt>Strategia RSI > 75 short, SMA(60) trend.</prompt>\nblabla"
|
||||
assert _extract_prompt(raw) == "Strategia RSI > 75 short, SMA(60) trend."
|
||||
|
||||
|
||||
def test_extract_prompt_no_tag_returns_stripped_text() -> None:
|
||||
raw = " Strategia momentum breakout su 1h con ATR(14) "
|
||||
assert _extract_prompt(raw) == "Strategia momentum breakout su 1h con ATR(14)"
|
||||
|
||||
|
||||
def test_is_valid_prompt_accepts_proper_strategy() -> None:
|
||||
new = (
|
||||
"Strategia mean-reversion 1h su BTC. Entry long quando RSI(14) < 25 e "
|
||||
"close > SMA(50). Exit short quando RSI(14) > 75."
|
||||
)
|
||||
assert is_valid_prompt(new, _BASE_PROMPT) is True
|
||||
|
||||
|
||||
def test_is_valid_prompt_rejects_too_short() -> None:
|
||||
assert is_valid_prompt("short", _BASE_PROMPT) is False
|
||||
|
||||
|
||||
def test_is_valid_prompt_rejects_no_strategy_keywords() -> None:
|
||||
bad = "Questo è un testo a caso che parla del meteo di domani e della pioggia."
|
||||
assert is_valid_prompt(bad, _BASE_PROMPT) is False
|
||||
|
||||
|
||||
def test_is_valid_prompt_rejects_identical_prompt() -> None:
|
||||
assert is_valid_prompt(_BASE_PROMPT, _BASE_PROMPT) is False
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_produces_mutated_child() -> None:
|
||||
mutated = (
|
||||
"Strategia mean-reversion 1h su BTC. Entry long quando RSI(14) < 25 e "
|
||||
"close > SMA(70). Exit short quando RSI(14) > 78."
|
||||
)
|
||||
llm = _FakeLLM(response_text=f"<prompt>{mutated}</prompt>")
|
||||
parent = _make_genome()
|
||||
rng = random.Random(0)
|
||||
|
||||
child = mutate_prompt_llm(parent, llm, rng)
|
||||
|
||||
assert child.system_prompt == mutated
|
||||
assert child.id != parent.id
|
||||
assert child.parent_ids == [*parent.parent_ids, parent.id]
|
||||
assert child.generation == parent.generation + 1
|
||||
assert child.model_tier == ModelTier.C # tier C preservato sul child
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_uses_mutator_tier_b_for_llm_call() -> None:
|
||||
mutated = (
|
||||
"Strategia momentum breakout 1h. Entry long quando close > SMA(60) e "
|
||||
"ATR(14) crescente. Exit con stop loss 3%."
|
||||
)
|
||||
llm = _FakeLLM(response_text=f"<prompt>{mutated}</prompt>")
|
||||
parent = _make_genome()
|
||||
rng = random.Random(0)
|
||||
|
||||
mutate_prompt_llm(parent, llm, rng)
|
||||
|
||||
assert llm.last_call is not None
|
||||
assert llm.last_call["genome_tier"] == ModelTier.B
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_falls_back_on_invalid_output() -> None:
|
||||
"""Output troppo corto -> fallback random_mutate (cambia uno scalare)."""
|
||||
llm = _FakeLLM(response_text="<prompt>nope</prompt>")
|
||||
parent = _make_genome()
|
||||
rng = random.Random(42)
|
||||
|
||||
child = mutate_prompt_llm(parent, llm, rng)
|
||||
|
||||
# random_mutate preserva system_prompt, cambia uno dei 4 scalari/style.
|
||||
assert child.system_prompt == parent.system_prompt
|
||||
assert child.parent_ids == [*parent.parent_ids, parent.id]
|
||||
assert child.generation == parent.generation + 1
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_falls_back_on_identical_output() -> None:
|
||||
llm = _FakeLLM(response_text=f"<prompt>{_BASE_PROMPT}</prompt>")
|
||||
parent = _make_genome()
|
||||
rng = random.Random(42)
|
||||
|
||||
child = mutate_prompt_llm(parent, llm, rng)
|
||||
|
||||
assert child.system_prompt == parent.system_prompt
|
||||
assert child.generation == parent.generation + 1
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_falls_back_on_llm_exception() -> None:
|
||||
llm = _FakeLLM(raise_exc=True)
|
||||
parent = _make_genome()
|
||||
rng = random.Random(7)
|
||||
|
||||
child = mutate_prompt_llm(parent, llm, rng)
|
||||
|
||||
# Fallback random_mutate sempre produce un child valido.
|
||||
assert child.system_prompt == parent.system_prompt
|
||||
assert child.generation == parent.generation + 1
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_logs_mutation_cost_when_sink_provided() -> None:
|
||||
"""Quando cost_tracker+repo+run_id sono forniti, la call mutator viene loggata
|
||||
con call_kind='mutation' sia in memoria sia nel repo."""
|
||||
mutated = (
|
||||
"Strategia RSI 1h evolved. Entry long quando RSI(14) < 28 e close > "
|
||||
"SMA(50). Exit short quando RSI(14) > 72."
|
||||
)
|
||||
|
||||
class _R:
|
||||
text = f"<prompt>{mutated}</prompt>"
|
||||
input_tokens = 350
|
||||
output_tokens = 140
|
||||
|
||||
class _FakeLLMCosted:
|
||||
def complete(self, genome, system, user, max_tokens=2000):
|
||||
return _R()
|
||||
|
||||
tracker_calls = []
|
||||
repo_calls = []
|
||||
|
||||
class _FakeTracker:
|
||||
def record(self, **kw):
|
||||
tracker_calls.append(kw)
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(cost_usd=0.0042)
|
||||
|
||||
class _FakeRepo:
|
||||
def save_cost_record(self, **kw):
|
||||
repo_calls.append(kw)
|
||||
|
||||
parent = _make_genome()
|
||||
child = mutate_prompt_llm(
|
||||
parent, _FakeLLMCosted(), random.Random(0),
|
||||
cost_tracker=_FakeTracker(), repo=_FakeRepo(), run_id="run-xyz",
|
||||
)
|
||||
assert child.system_prompt == mutated
|
||||
assert len(tracker_calls) == 1
|
||||
assert tracker_calls[0]["call_kind"] == "mutation"
|
||||
assert tracker_calls[0]["tier"] == ModelTier.B
|
||||
assert tracker_calls[0]["run_id"] == "run-xyz"
|
||||
assert tracker_calls[0]["agent_id"] == parent.id
|
||||
assert tracker_calls[0]["input_tokens"] == 350
|
||||
assert tracker_calls[0]["output_tokens"] == 140
|
||||
|
||||
assert len(repo_calls) == 1
|
||||
assert repo_calls[0]["call_kind"] == "mutation"
|
||||
assert repo_calls[0]["tier"] == "B"
|
||||
assert repo_calls[0]["cost_usd"] == 0.0042
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_no_logging_without_sink() -> None:
|
||||
"""Senza cost_tracker+repo+run_id → niente logging cost (backward compat)."""
|
||||
mutated = (
|
||||
"Strategia RSI 1h evoluta. Entry long quando RSI(14) < 25 e close > "
|
||||
"SMA(60). Exit short quando RSI(14) > 75 e ATR rising."
|
||||
)
|
||||
llm = _FakeLLM(response_text=f"<prompt>{mutated}</prompt>")
|
||||
parent = _make_genome()
|
||||
# Non solleva (anche se 0 sink forniti)
|
||||
child = mutate_prompt_llm(parent, llm, random.Random(0))
|
||||
assert child.system_prompt == mutated
|
||||
|
||||
|
||||
def test_mutate_prompt_llm_picks_one_of_six_instructions() -> None:
|
||||
"""Verifica che il system message dell'LLM includa una delle 6 istruzioni."""
|
||||
mutated = (
|
||||
"Strategia RSI 1h. Entry long quando RSI(14) < 28 e close > SMA(50). "
|
||||
"Exit short quando RSI(14) > 72."
|
||||
)
|
||||
llm = _FakeLLM(response_text=f"<prompt>{mutated}</prompt>")
|
||||
parent = _make_genome()
|
||||
|
||||
mutate_prompt_llm(parent, llm, random.Random(0))
|
||||
|
||||
assert llm.last_call is not None
|
||||
user_text = str(llm.last_call["user"])
|
||||
matched_keys = [k for k in MUTATION_INSTRUCTIONS if k in user_text]
|
||||
assert len(matched_keys) >= 1, f"User prompt non contiene istruzione: {user_text[:200]}"
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.backtest.orders import Side
|
||||
from multi_swarm_core.protocol.compiler import compile_strategy
|
||||
from multi_swarm_core.protocol.parser import parse_strategy
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ohlcv() -> pd.DataFrame:
|
||||
idx = pd.date_range("2024-01-01", periods=200, freq="1h", tz="UTC")
|
||||
close = np.linspace(100, 120, 200)
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": close,
|
||||
"high": close + 0.5,
|
||||
"low": close - 0.5,
|
||||
"close": close,
|
||||
"volume": 1.0,
|
||||
},
|
||||
index=idx,
|
||||
)
|
||||
|
||||
|
||||
def test_compile_simple_long(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 100.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signals = fn(ohlcv)
|
||||
assert isinstance(signals, pd.Series)
|
||||
assert (signals == Side.LONG).all() or (signals.dropna() == Side.LONG).all()
|
||||
|
||||
|
||||
def test_compile_no_match_is_flat(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 1000.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signals = fn(ohlcv)
|
||||
assert (signals == Side.FLAT).any()
|
||||
|
||||
|
||||
def test_compile_two_rules_priority(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "literal", "value": 110.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "literal", "value": 105.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signals = fn(ohlcv)
|
||||
last = signals.iloc[-1]
|
||||
assert last == Side.LONG # close finale e' 120, regola 1 matcha
|
||||
|
||||
|
||||
def test_compile_hour_feature_returns_index_hour(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "hour"},
|
||||
{"kind": "literal", "value": -1.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signal = fn(ohlcv)
|
||||
# All rows have hour >= 0 > -1, so all entry-long.
|
||||
assert (signal == Side.LONG).all()
|
||||
|
||||
|
||||
def test_compile_dow_feature_monday_is_zero(ohlcv: pd.DataFrame) -> None:
|
||||
# 2024-01-01 is Monday -> dow=0; eq(dow, 0) gates LONG on Monday rows only.
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "eq",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "dow"},
|
||||
{"kind": "literal", "value": 0.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signal = fn(ohlcv)
|
||||
monday_rows = signal[signal.index.dayofweek == 0]
|
||||
other_rows = signal[signal.index.dayofweek != 0]
|
||||
assert (monday_rows == Side.LONG).all()
|
||||
assert (other_rows == Side.FLAT).all()
|
||||
|
||||
|
||||
def test_compile_is_weekend_returns_zero_one(ohlcv: pd.DataFrame) -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "eq",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "is_weekend"},
|
||||
{"kind": "literal", "value": 1.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signal = fn(ohlcv)
|
||||
weekend = signal[signal.index.dayofweek >= 5]
|
||||
weekdays = signal[signal.index.dayofweek < 5]
|
||||
assert (weekend == Side.LONG).all()
|
||||
assert (weekdays == Side.FLAT).all()
|
||||
|
||||
|
||||
def test_compile_minute_of_hour_zero_on_1h_timeframe(ohlcv: pd.DataFrame) -> None:
|
||||
# Fixture has freq=1h, so minute_of_hour is 0 on every row.
|
||||
# eq(minute_of_hour, 0.0) -> LONG on every row.
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "eq",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "minute_of_hour"},
|
||||
{"kind": "literal", "value": 0.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signal = fn(ohlcv)
|
||||
assert (signal == Side.LONG).all()
|
||||
|
||||
|
||||
def test_rule_with_temporal_gating_compiles_and_executes(ohlcv: pd.DataFrame) -> None:
|
||||
# Rule: entry-long if hour > 14 AND close > sma(20).
|
||||
# close in fixture is strictly increasing, so close > sma(20) holds after warmup.
|
||||
# entry-long should appear only on rows with hour > 14.
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "and",
|
||||
"args": [
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "hour"},
|
||||
{"kind": "literal", "value": 14.0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "indicator", "name": "sma", "params": [20]},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
fn = compile_strategy(ast)
|
||||
signal = fn(ohlcv)
|
||||
|
||||
# Bars with hour <= 14: never LONG (temporal gate blocks).
|
||||
morning = signal[signal.index.hour <= 14]
|
||||
assert (morning == Side.FLAT).all()
|
||||
|
||||
# Bars with hour > 14 AND past SMA warmup (>=20 bars): LONG.
|
||||
afternoon_warm = signal[(signal.index.hour > 14) & (np.arange(len(signal)) >= 20)]
|
||||
assert (afternoon_warm == Side.LONG).all()
|
||||
@@ -0,0 +1,198 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.protocol.grammar import (
|
||||
ACTION_VALUES,
|
||||
ALL_OPS,
|
||||
COMPARATOR_OPS,
|
||||
CROSSOVER_OPS,
|
||||
KIND_VALUES,
|
||||
LOGICAL_OPS,
|
||||
)
|
||||
from multi_swarm_core.protocol.parser import (
|
||||
FeatureNode,
|
||||
IndicatorNode,
|
||||
LiteralNode,
|
||||
OpNode,
|
||||
ParseError,
|
||||
parse_strategy,
|
||||
)
|
||||
|
||||
|
||||
def test_grammar_constant_sets() -> None:
|
||||
assert LOGICAL_OPS == {"and", "or", "not"}
|
||||
assert COMPARATOR_OPS == {"gt", "lt", "eq"}
|
||||
assert CROSSOVER_OPS == {"crossover", "crossunder"}
|
||||
assert KIND_VALUES == {"indicator", "feature", "literal"}
|
||||
assert ACTION_VALUES == {"entry-long", "entry-short", "exit", "flat"}
|
||||
assert ALL_OPS == LOGICAL_OPS | COMPARATOR_OPS | CROSSOVER_OPS
|
||||
|
||||
|
||||
def test_parse_simple_strategy() -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
assert len(ast.rules) == 1
|
||||
rule = ast.rules[0]
|
||||
assert rule.action == "entry-short"
|
||||
assert isinstance(rule.condition, OpNode)
|
||||
assert rule.condition.op == "gt"
|
||||
assert isinstance(rule.condition.args[0], IndicatorNode)
|
||||
assert rule.condition.args[0].name == "rsi"
|
||||
assert rule.condition.args[0].params == [14.0]
|
||||
assert isinstance(rule.condition.args[1], LiteralNode)
|
||||
assert rule.condition.args[1].value == 70.0
|
||||
|
||||
|
||||
def test_parse_multiple_rules() -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-short",
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 30.0},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
assert len(ast.rules) == 2
|
||||
|
||||
|
||||
def test_parse_feature_leaf() -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "crossover",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "close"},
|
||||
{"kind": "indicator", "name": "sma", "params": [50]},
|
||||
],
|
||||
},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
cond = ast.rules[0].condition
|
||||
assert isinstance(cond, OpNode) and cond.op == "crossover"
|
||||
assert isinstance(cond.args[0], FeatureNode)
|
||||
assert cond.args[0].name == "close"
|
||||
|
||||
|
||||
def test_parse_unknown_op_raises() -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {"op": "frobnicate", "args": [1, 2]},
|
||||
"action": "entry-long",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
with pytest.raises(ParseError, match="Unknown op"):
|
||||
parse_strategy(src)
|
||||
|
||||
|
||||
def test_parse_invalid_action_raises() -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {"kind": "literal", "value": 1.0},
|
||||
"action": "buy-now",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
with pytest.raises(ParseError, match="action"):
|
||||
parse_strategy(src)
|
||||
|
||||
|
||||
def test_parse_malformed_json_raises() -> None:
|
||||
with pytest.raises(ParseError, match="invalid JSON"):
|
||||
parse_strategy("{this is not json")
|
||||
|
||||
|
||||
def test_parse_top_level_array_raises() -> None:
|
||||
with pytest.raises(ParseError, match="JSON object"):
|
||||
parse_strategy("[1, 2, 3]")
|
||||
|
||||
|
||||
def test_parse_missing_rules_key_raises() -> None:
|
||||
with pytest.raises(ParseError, match="rules"):
|
||||
parse_strategy(json.dumps({"foo": "bar"}))
|
||||
|
||||
|
||||
def test_parse_empty_rules_raises() -> None:
|
||||
with pytest.raises(ParseError, match="at least one"):
|
||||
parse_strategy(json.dumps({"rules": []}))
|
||||
|
||||
|
||||
def test_parse_node_with_both_op_and_kind_raises() -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {"op": "gt", "kind": "indicator", "args": []},
|
||||
"action": "flat",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
with pytest.raises(ParseError, match="mutually exclusive"):
|
||||
parse_strategy(src)
|
||||
|
||||
|
||||
def test_parse_indicator_with_nested_node_raises() -> None:
|
||||
src = json.dumps(
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"kind": "indicator",
|
||||
"name": "sma",
|
||||
"params": [{"kind": "literal", "value": 14}],
|
||||
},
|
||||
"action": "flat",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
with pytest.raises(ParseError, match="params"):
|
||||
parse_strategy(src)
|
||||
@@ -0,0 +1,183 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.protocol.parser import parse_strategy
|
||||
from multi_swarm_core.protocol.validator import ValidationError, validate_strategy
|
||||
|
||||
|
||||
def _wrap(condition: dict, action: str = "entry-long") -> str:
|
||||
return json.dumps({"rules": [{"condition": condition, "action": action}]})
|
||||
|
||||
|
||||
def test_valid_strategy_passes() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
},
|
||||
action="entry-short",
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
validate_strategy(ast) # no exception
|
||||
|
||||
|
||||
def test_indicator_unknown_name_fails() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "wibble", "params": [14]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="unknown indicator"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_indicator_arity_too_few_fails() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": []},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="arity"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_indicator_arity_too_many_fails() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "rsi", "params": [14, 28]},
|
||||
{"kind": "literal", "value": 70.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="arity"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_macd_arity_zero_to_three_ok() -> None:
|
||||
for params in [[], [12], [12, 26], [12, 26, 9]]:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "macd", "params": params},
|
||||
{"kind": "literal", "value": 0.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_macd_arity_four_fails() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "indicator", "name": "macd", "params": [1, 2, 3, 4]},
|
||||
{"kind": "literal", "value": 0.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="arity"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_comparator_wrong_arity_fails() -> None:
|
||||
src = _wrap({"op": "gt", "args": [{"kind": "literal", "value": 1.0}]})
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="needs 2 args"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_logical_not_arity_fails() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "not",
|
||||
"args": [
|
||||
{"kind": "literal", "value": 1.0},
|
||||
{"kind": "literal", "value": 2.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="'not' needs 1"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_logical_and_arity_fails() -> None:
|
||||
src = _wrap({"op": "and", "args": [{"kind": "literal", "value": 1.0}]})
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="and"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_crossover_wrong_arity_fails() -> None:
|
||||
src = _wrap(
|
||||
{"op": "crossover", "args": [{"kind": "literal", "value": 1.0}]}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="crossover"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
def test_feature_unknown_column_fails() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "wibble"},
|
||||
{"kind": "literal", "value": 100.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="unknown feature"):
|
||||
validate_strategy(ast)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["hour", "dow", "is_weekend", "minute_of_hour"])
|
||||
def test_validator_accepts_temporal_feature(name: str) -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": name},
|
||||
{"kind": "literal", "value": 0.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
validate_strategy(ast) # no exception
|
||||
|
||||
|
||||
def test_validator_rejects_temporal_typo() -> None:
|
||||
src = _wrap(
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{"kind": "feature", "name": "weekday"},
|
||||
{"kind": "literal", "value": 0.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError, match="unknown feature"):
|
||||
validate_strategy(ast)
|
||||
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm_core.persistence.repository import Repository
|
||||
|
||||
|
||||
def make_genome(idx: int) -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt=f"p-{idx}", feature_access=["close"], temperature=0.9,
|
||||
top_p=0.95, model_tier=ModelTier.C, lookback_window=100, cognitive_style="x",
|
||||
)
|
||||
|
||||
|
||||
def test_repository_creates_schema(tmp_path: Path):
|
||||
repo = Repository(db_path=tmp_path / "runs.db")
|
||||
repo.init_schema()
|
||||
assert (tmp_path / "runs.db").exists()
|
||||
|
||||
|
||||
def test_repository_create_run_and_get(tmp_path: Path):
|
||||
repo = Repository(db_path=tmp_path / "runs.db")
|
||||
repo.init_schema()
|
||||
run_id = repo.create_run(name="phase1-test", config={"k": 20})
|
||||
run = repo.get_run(run_id)
|
||||
assert run["name"] == "phase1-test"
|
||||
assert json.loads(run["config_json"])["k"] == 20
|
||||
|
||||
|
||||
def test_repository_save_genome_and_evaluation(tmp_path: Path):
|
||||
repo = Repository(db_path=tmp_path / "runs.db")
|
||||
repo.init_schema()
|
||||
run_id = repo.create_run(name="t", config={})
|
||||
g = make_genome(0)
|
||||
repo.save_genome(run_id=run_id, generation_idx=0, genome=g)
|
||||
repo.save_evaluation(
|
||||
run_id=run_id, genome_id=g.id, fitness=0.5, dsr=0.7, dsr_pvalue=0.05,
|
||||
sharpe=1.5, max_dd=0.2, total_return=0.3, n_trades=30,
|
||||
parse_error=None, raw_text="(strategy ...)",
|
||||
)
|
||||
evals = repo.list_evaluations(run_id)
|
||||
assert len(evals) == 1
|
||||
assert evals[0]["fitness"] == 0.5
|
||||
|
||||
|
||||
def test_repository_save_generation_summary(tmp_path: Path):
|
||||
repo = Repository(db_path=tmp_path / "runs.db")
|
||||
repo.init_schema()
|
||||
run_id = repo.create_run(name="t", config={})
|
||||
repo.save_generation_summary(
|
||||
run_id=run_id, generation_idx=0, n_genomes=20,
|
||||
fitness_median=0.3, fitness_max=0.8, fitness_p90=0.7, entropy=0.85,
|
||||
)
|
||||
gens = repo.list_generations(run_id)
|
||||
assert len(gens) == 1
|
||||
assert gens[0]["fitness_max"] == 0.8
|
||||
@@ -0,0 +1,42 @@
|
||||
import random
|
||||
|
||||
from multi_swarm_core.ga.selection import elite_select, tournament_select
|
||||
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
|
||||
|
||||
def make(idx: int) -> HypothesisAgentGenome:
|
||||
return HypothesisAgentGenome(
|
||||
system_prompt=f"p-{idx}",
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=100,
|
||||
cognitive_style="x",
|
||||
)
|
||||
|
||||
|
||||
def test_tournament_picks_best_in_sample() -> None:
|
||||
population = [make(i) for i in range(10)]
|
||||
fitnesses = {g.id: float(i) for i, g in enumerate(population)}
|
||||
rng = random.Random(0)
|
||||
winner = tournament_select(population, fitnesses, k=5, rng=rng)
|
||||
assert isinstance(winner, HypothesisAgentGenome)
|
||||
assert fitnesses[winner.id] >= 0.0
|
||||
|
||||
|
||||
def test_tournament_size_one_is_random() -> None:
|
||||
population = [make(i) for i in range(10)]
|
||||
fitnesses = {g.id: float(i) for i, g in enumerate(population)}
|
||||
rng = random.Random(0)
|
||||
picks = [tournament_select(population, fitnesses, k=1, rng=rng) for _ in range(50)]
|
||||
distinct = {p.id for p in picks}
|
||||
assert len(distinct) > 1
|
||||
|
||||
|
||||
def test_elite_select_returns_top_k() -> None:
|
||||
population = [make(i) for i in range(10)]
|
||||
fitnesses = {g.id: float(i) for i, g in enumerate(population)}
|
||||
elites = elite_select(population, fitnesses, k=3)
|
||||
elite_fitnesses = sorted([fitnesses[g.id] for g in elites], reverse=True)
|
||||
assert elite_fitnesses == [9.0, 8.0, 7.0]
|
||||
@@ -0,0 +1,40 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm_core.data.splits import expanding_walk_forward
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daily_index():
|
||||
return pd.date_range("2024-01-01", "2024-12-31", freq="D", tz="UTC")
|
||||
|
||||
|
||||
def test_expanding_split_count(daily_index: pd.DatetimeIndex):
|
||||
splits = expanding_walk_forward(
|
||||
daily_index, train_ratio=0.7, n_folds=4, min_train_days=30
|
||||
)
|
||||
assert len(splits) == 4
|
||||
|
||||
|
||||
def test_expanding_split_train_grows(daily_index: pd.DatetimeIndex):
|
||||
splits = expanding_walk_forward(
|
||||
daily_index, train_ratio=0.7, n_folds=4, min_train_days=30
|
||||
)
|
||||
train_lengths = [len(s.train_idx) for s in splits]
|
||||
assert train_lengths == sorted(train_lengths)
|
||||
assert train_lengths[0] < train_lengths[-1]
|
||||
|
||||
|
||||
def test_no_overlap_train_test(daily_index: pd.DatetimeIndex):
|
||||
splits = expanding_walk_forward(
|
||||
daily_index, train_ratio=0.7, n_folds=4, min_train_days=30
|
||||
)
|
||||
for s in splits:
|
||||
assert s.train_idx[-1] < s.test_idx[0]
|
||||
|
||||
|
||||
def test_min_train_days_respected():
|
||||
idx = pd.date_range("2024-01-01", "2024-02-15", freq="D", tz="UTC")
|
||||
splits = expanding_walk_forward(idx, train_ratio=0.7, n_folds=2, min_train_days=20)
|
||||
for s in splits:
|
||||
assert len(s.train_idx) >= 20
|
||||
@@ -0,0 +1,65 @@
|
||||
# strategy_crypto
|
||||
|
||||
Strategia di test su asset crypto (BTC/ETH perpetual) basata sul core
|
||||
`multi_swarm_core`. Workspace member del monorepo `multi_swarm_coevolutive`.
|
||||
|
||||
## Scope
|
||||
|
||||
Esegue **paper-trading forward-test** (Phase 3) di strategie JSON freezate,
|
||||
prodotte dal pipeline evolutivo del core. Espone una dashboard NiceGUI
|
||||
read-only per il monitoraggio in tempo reale.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
strategy_crypto/
|
||||
├── backend/ paper trading runner (PaperExecutor, Portfolio, PaperRepository)
|
||||
│ └── schema.py tabelle paper_trading_* (DB locale)
|
||||
├── frontend/ NiceGUI dashboard (dual-DB reader: GA + paper)
|
||||
└── strategies/ JSON freezate input al runner
|
||||
(btc_*.json, eth_*.json)
|
||||
```
|
||||
|
||||
## Run paper-trading
|
||||
|
||||
```bash
|
||||
uv run python scripts/run_paper_trading.py \
|
||||
--name phase3-papertrade-001 \
|
||||
--initial-capital 1000 \
|
||||
--poll-seconds 300
|
||||
```
|
||||
|
||||
Il default `--strategies-dir` punta ai JSON shippati col package via
|
||||
`importlib.resources.files("strategy_crypto") / "strategies"`.
|
||||
|
||||
## Dashboard
|
||||
|
||||
```bash
|
||||
uv run python -m strategy_crypto.frontend.nicegui_app
|
||||
```
|
||||
|
||||
In produzione: `https://swarm.tielogic.xyz/strategy_crypto_gui/` (root_path
|
||||
configurato via `DASHBOARD_ROOT_PATH=/strategy_crypto_gui`).
|
||||
|
||||
## DB schema
|
||||
|
||||
Schema isolato dal core in `state/strategy_crypto.db` (env
|
||||
`STRATEGY_CRYPTO_DB_PATH`). Tabelle:
|
||||
|
||||
- `paper_trading_runs` — metadata run (id, name, capital, status)
|
||||
- `paper_trading_positions` — posizioni aperte (long/short)
|
||||
- `paper_trading_trades` — trade realized (entry/exit, pnl, fees)
|
||||
- `paper_trading_equity` — equity curve snapshot
|
||||
- `paper_trading_ticks` — log signal/action per ogni bar
|
||||
|
||||
DDL gestito da `strategy_crypto.backend.schema.init_schema()`.
|
||||
|
||||
La dashboard legge **anche** il `runs.db` del core GA (env `GA_DB_PATH`)
|
||||
per correlare paper performance con i genomi di provenienza.
|
||||
|
||||
## Pattern per future strategie
|
||||
|
||||
`strategy_<asset>/` mantiene la stessa shape: `backend/`, `frontend/`,
|
||||
`strategies/`, `tests/`, `docs/` (opzionale). DB dedicato `state/strategy_<asset>.db`.
|
||||
Servizi Docker dedicati `strategy-<asset>-paper` + `strategy-<asset>-gui`,
|
||||
GUI su `/strategy_<asset>_gui`.
|
||||
Reference in New Issue
Block a user