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:
Adriano Dal Pastro
2026-05-15 17:59:28 +00:00
parent 2b5da4d1fc
commit 289df4b81f
49 changed files with 65 additions and 7805 deletions
@@ -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.01620.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 $13 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: **+1023%** 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
+65
View File
@@ -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`.