Compare commits
13 Commits
56a631f38a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 68637d1102 | |||
| 36cbfadb40 | |||
| 2014ed3815 | |||
| 22a934a6cf | |||
| 9d1f97cff3 | |||
| 0e9489bf88 | |||
| 3e9a4efcc2 | |||
| 30dbba4d74 | |||
| c6cb32325e | |||
| 1a171acfb2 | |||
| 9d0deb3ae0 | |||
| d3662f6098 | |||
| 23c9e37f94 |
@@ -2,10 +2,20 @@
|
|||||||
|
|
||||||
Proof-of-concept di sistema co-evolutivo multi-agente per trading quantitativo. Un genetic algorithm fa evolvere una popolazione di agenti LLM (Hypothesis swarm) che generano strategie di trading espresse in JSON strutturato; un layer Falsification deterministico le backtesta su dati storici BTC-PERPETUAL via Cerbero MCP; un layer Adversarial euristico le sottopone a red-team checks; la fitness combina Deflated Sharpe Ratio (Bailey & López 2014), Sharpe normalizzato e penalizzazione di drawdown. Il tutto è ispirato alla filosofia di Renaissance Technologies adattata a un contesto retail single-author con LLM agents.
|
Proof-of-concept di sistema co-evolutivo multi-agente per trading quantitativo. Un genetic algorithm fa evolvere una popolazione di agenti LLM (Hypothesis swarm) che generano strategie di trading espresse in JSON strutturato; un layer Falsification deterministico le backtesta su dati storici BTC-PERPETUAL via Cerbero MCP; un layer Adversarial euristico le sottopone a red-team checks; la fitness combina Deflated Sharpe Ratio (Bailey & López 2014), Sharpe normalizzato e penalizzazione di drawdown. Il tutto è ispirato alla filosofia di Renaissance Technologies adattata a un contesto retail single-author con LLM agents.
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
Gitea Tielogic (privato, accesso SSH):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.tielogic.xyz:222/Adriano/Multi_Swarm_Coevolutive.git
|
||||||
|
```
|
||||||
|
|
||||||
## Stato del progetto
|
## Stato del progetto
|
||||||
|
|
||||||
**Phase 1 (lean spike) completata** il 10 maggio 2026 con tutti i 5 hard gate passati (loop convergence, parse success 100%, top-5 ratio 1116x, entropy 0.914, costo $0.069 vs cap $700). Decisione strategica: **GO Phase 2** con tre aggiustamenti (Adversarial soglie più strette, speciation, walk-forward 70/30).
|
**Phase 1 (lean spike) completata** il 10 maggio 2026 con tutti i 5 hard gate passati (loop convergence, parse success 100%, top-5 ratio 1116x, entropy 0.914, costo $0.069 vs cap $700). Decisione strategica: **GO Phase 2** con tre aggiustamenti (Adversarial soglie più strette, speciation, walk-forward 70/30).
|
||||||
|
|
||||||
|
**Phase 1.5 (tactical hardening) in corso**: Adversarial layer rinforzato con soglie più strette (`overtrading` a `n_bars/20`, `undertrading` HIGH se `n<10`) e due nuovi check HIGH (`flat_too_long` se signal flat >95% bar, `fees_eat_alpha` se fees > 50% del gross PnL). Killa le strategie degeneri del run v5 (top-1 era flat 99.8% del tempo e ha sottoperformato BTC B&H di −103 punti percentuali).
|
||||||
|
|
||||||
Documenti chiave:
|
Documenti chiave:
|
||||||
|
|
||||||
- [Decisione strategica](docs/superpowers/specs/2026-05-09-decisione-strategica-design.md) — perché Phase 1 prima, Phase 2 poi, Phase 3 forward-test.
|
- [Decisione strategica](docs/superpowers/specs/2026-05-09-decisione-strategica-design.md) — perché Phase 1 prima, Phase 2 poi, Phase 3 forward-test.
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Phase 1.5 — Run nemotron tier C — Decision Memo
|
||||||
|
|
||||||
|
**Data**: 11 maggio 2026
|
||||||
|
**Run di riferimento**: `phase1.5-nemotron-001` (id `434c417e2b6f42bb8cf32514e5d0db1d`)
|
||||||
|
**Tier LLM**: C → `nvidia/nemotron-3-super-120b-a12b:free`
|
||||||
|
**Durata wallclock**: 2 h 26 min (08:15 → 10:11 UTC, gen 0 → gen 9)
|
||||||
|
**Spesa totale**: $0.1244 (price-table tier C; il modello effettivo è `:free` su OpenRouter, ma il cost tracker applica la pricing nominale del tier)
|
||||||
|
**Status**: ✅ Completato, ma esito strategico **NO-GO** sulla configurazione corrente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Premessa
|
||||||
|
|
||||||
|
Il run `phase1.5-nemotron-001` è la prima esecuzione end-to-end del loop GA con:
|
||||||
|
|
||||||
|
- l'Adversarial layer aggiornato in Phase 1.5 (commits `56a631f` + `d3662f6`), con tre nuovi check HIGH (`flat_too_long`, `fees_eat_alpha`, `time_in_market_too_high`) più i due esistenti rinforzati;
|
||||||
|
- il tier C ribindato a `nvidia/nemotron-3-super-120b-a12b:free`, modello scelto in benchmark contro sette alternative per stabilità JSON e costo nullo;
|
||||||
|
- il fix `EmptyCompletionError` su `llm/client.py` (commit `9d0deb3`) introdotto durante la stessa sessione per gestire le risposte vuote che alcuni provider `:free` ritornano sporadicamente.
|
||||||
|
|
||||||
|
L'obiettivo dichiarato del run era verificare se il nuovo budget di vincoli adversarial — più stretto del v5 — fosse compatibile con la capacità generativa di nemotron, e se la popolazione riuscisse a esplorare una zona di fitness positiva non degenere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hard gate Phase 1 — ripercorrenza
|
||||||
|
|
||||||
|
I 5 hard gate originali (definiti nello spec strategico di Phase 1) sono stati rivalutati su questo run come sanity check, non come passaggio formale di gate.
|
||||||
|
|
||||||
|
| # | Gate | Soglia | Misura | Esito |
|
||||||
|
|---|------|--------|--------|-------|
|
||||||
|
| 1 | Loop converge | mediana cresce ≥3 gen consecutive | Gen 0→8: median oscilla tra 0.0 e 0.0073 senza crescita strutturale | ❌ FAIL |
|
||||||
|
| 2 | Parse success | ≥80% proposte LLM parse-OK | 81/89 = **91.0%** | ✅ PASS |
|
||||||
|
| 3 | Top-5 ratio | top-5 fitness ≥10× mediana | top-5 = 0.0162–0.0215; mediana ≈ 0 → ratio indefinito | ⚠️ N/A |
|
||||||
|
| 4 | Entropy | ≥0.5 a fine run | 0.845 alla gen 9 | ✅ PASS |
|
||||||
|
| 5 | Budget | costo ≤ cap | $0.1244 vs cap $700 (0.02%) | ✅ PASS |
|
||||||
|
|
||||||
|
Il gate critico è il numero 1. La popolazione non converge: il `max_fitness` resta inchiodato a `0.0215` dalla generazione 0 fino alla 9, segnale che l'elite preservation cattura un singolo genoma poco peggiore degli altri ma altrettanto inadatto, mentre il resto della popolazione non riesce a superarlo. La mediana è zero in 9 generazioni su 10 (singolo picco a 0.0073 in gen 8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Lettura dei top genomi
|
||||||
|
|
||||||
|
I cinque genomi a fitness più alta hanno tutti caratteristiche economicamente disastrose:
|
||||||
|
|
||||||
|
| Genome ID | Fitness | DSR | Sharpe | Total return | n_trades |
|
||||||
|
|-----------|---------|-----|--------|--------------|----------|
|
||||||
|
| `0e1f9d7af25cfd6a` | 0.0215 | 0.000 | −1.083 | −115.9% | 385 |
|
||||||
|
| `85a8116ab2cd2735` | 0.0215 | 0.000 | −1.083 | −115.9% | 385 |
|
||||||
|
| `92aae563277b6f21` | 0.0193 | 0.000 | −1.129 | −131.0% | 597 |
|
||||||
|
| `01d0ca99bbdd7320` | 0.0180 | 0.000 | −1.112 | −131.7% | 602 |
|
||||||
|
| `194b096f7edab53c` | 0.0162 | 0.000 | −1.154 | −150.7% | 369 |
|
||||||
|
|
||||||
|
Il fatto che **DSR sia zero per tutti i top-5** indica che nessuna strategia passa il deflation test di Bailey & López 2014: il loop non sta generando proposte con edge statistico anche solo apparente. Il valore di fitness positivo che li seleziona deriva interamente dal termine `tanh(sharpe) × penalty(dd)` della fitness v1, che resta debolmente non nullo anche per Sharpe negativi grazie alla penalty di drawdown e a saturazioni numeriche. I primi due genomi hanno fitness identico a 0.0215 e total return identico — verosimilmente lo stesso elite riproposto a generazioni adiacenti.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Adversarial findings — il sistema fa il suo lavoro
|
||||||
|
|
||||||
|
Il layer Adversarial Phase 1.5 ha emesso 98 finding sul run:
|
||||||
|
|
||||||
|
| Severità | Check | Conteggio |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| HIGH | `fees_eat_alpha` (nuovo P1.5) | 35 |
|
||||||
|
| MEDIUM | `overtrading` | 19 |
|
||||||
|
| HIGH | `no_trades` | 16 |
|
||||||
|
| HIGH | `flat_too_long` (nuovo P1.5) | 15 |
|
||||||
|
| HIGH | `time_in_market_too_high` (nuovo P1.5) | 8 |
|
||||||
|
| HIGH | `undertrading` | 4 |
|
||||||
|
| HIGH | `degenerate` | 1 |
|
||||||
|
|
||||||
|
Il dato saliente è che i tre check introdotti in Phase 1.5 — `fees_eat_alpha`, `flat_too_long`, `time_in_market_too_high` — sono effettivamente attivi e killano strategie. In particolare `fees_eat_alpha` è la categoria più popolata: 35 occorrenze HIGH. Esempi tipici dai detail dei finding:
|
||||||
|
|
||||||
|
- `Fees $17073.82 = 2032.6% of gross $840.00`;
|
||||||
|
- `Fees $70646.03 = 12671.9% of gross $557.50`;
|
||||||
|
- `Signal flat for 98.8% of bars (>95% threshold)`.
|
||||||
|
|
||||||
|
Il messaggio è netto: il pool di strategie generato da nemotron, ai prompt e ai gradi di libertà attuali, oscilla tra due estremi degeneri — strategie inattive (flat 98%+) e strategie iperattive (overtrading + fee che divorano l'alpha lordo). Phase 1.5 cattura entrambi gli estremi, ma il loop GA non ha materiale di partenza sano da cui evolvere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Decisione
|
||||||
|
|
||||||
|
**Esito**: NO-GO sulla combinazione `tier C = nemotron` + `Phase 1.5 adversarial` come configurazione di Phase 2.
|
||||||
|
|
||||||
|
Le ragioni a supporto della decisione sono tre.
|
||||||
|
|
||||||
|
Primo, la convergenza è assente per nove generazioni consecutive, non un plateau di selezione raggiunto dopo una fase di salita. Non si tratta cioè di un loop che ha già trovato il suo ottimo e lo conserva, ma di un loop che non ne ha trovato uno.
|
||||||
|
|
||||||
|
Secondo, la distanza dal baseline Phase 1 v5 è di un ordine di grandezza: max fitness `0.0215` qui contro `0.3347` nel run di gate Phase 1, mediana che oscilla sullo zero contro una mediana attorno a `0.005`–`0.09`. Nemotron, in questa configurazione, sta producendo proposte qualitativamente più povere di qwen-2.5-72b nello stesso schema operativo.
|
||||||
|
|
||||||
|
Terzo, i finding adversarial non puntano a un bug del sistema ma a una mancanza di edge nelle proposte. Il loop sta sanzionando correttamente — il problema è a monte, nella generazione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tre direzioni per Phase 2
|
||||||
|
|
||||||
|
Tre opzioni si configurano per il passo successivo. Vanno valutate prima di una nuova esecuzione, non in parallelo a essa.
|
||||||
|
|
||||||
|
**Direzione A — Riportare tier C a `qwen/qwen-2.5-72b-instruct`** (configurazione di gate Phase 1). Il run di riferimento `phase1-real-005` è già un baseline noto: max fitness `0.3347`, top genome problematico (flat 99.8%) ma generato sotto Phase 1 adversarial. Rilanciare lo stesso pool con Phase 1.5 adversarial isolerebbe l'effetto del solo hardening sul medesimo motore generativo, senza confondere variabili. Questo è il percorso più informativo nel breve.
|
||||||
|
|
||||||
|
**Direzione B — Mantenere nemotron ma rilassare i prompt di Hypothesis**. L'ipotesi alternativa è che il prompting attuale, calibrato su qwen, sia troppo terso o troppo vincolato per la modalità di ragionamento di nemotron. Iterare due o tre versioni del prompt — più esempi few-shot, vincoli espliciti su `n_trades` minimo e `time_in_market` target — può cambiare radicalmente la qualità dell'output senza cambiare il modello.
|
||||||
|
|
||||||
|
**Direzione C — Sostituire il tier C con un modello a pagamento di fascia comparabile**. Tra i benchmark precedenti, `deepseek/deepseek-v4-flash` è già usato come tier A/B nel file `.env`; promuoverlo a tier C significa accettare una spesa marginale (stima $1–3 per run di 10 gen × 20 pop) in cambio di una qualità generativa nota.
|
||||||
|
|
||||||
|
La preferenza dell'operatore per modelli cost-conscious orienta verso A o B. La direzione C resta utile come benchmark di controllo se A e B fallissero a loro volta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Operazioni di pulizia eseguite contestualmente
|
||||||
|
|
||||||
|
- Il run zombie `phase1-real-008` (id `6ebcff9f7f6544c18ced50313cf72ca9`, marcato `running` da 07:11 UTC senza processo associato) è stato chiuso a `status='failed'` direttamente in `runs.db`, per evitare contaminazione delle query di dashboard.
|
||||||
|
- Il commit `9d0deb3` (`fix(llm): handle empty completions + missing usage`) è già su `main`. Il `client.py` ora tratta `resp.choices == []` e `resp.usage is None` come errori retryable invece che assertion failure: precondizione necessaria per qualsiasi run successivo su provider `:free`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Note per chi legge
|
||||||
|
|
||||||
|
Questo memo è un documento di decisione, non un rapporto tecnico completo. Il rapporto tecnico esteso del run può essere ricostruito da `runs.db` interrogando le tabelle `runs`, `generations`, `evaluations`, `adversarial_findings`, `cost_records` con `run_id='434c417e2b6f42bb8cf32514e5d0db1d'`. Il design Phase 1.5 e le motivazioni delle soglie adversarial restano definiti nel commit `56a631f` e nei suoi file di test.
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
# Feature temporali nella grammatica Hypothesis — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Aggiungere quattro feature temporali (`hour`, `dow`, `is_weekend`, `minute_of_hour`) alla grammatica delle strategie Hypothesis come `FeatureNode`, universalmente accessibili a ogni genoma e usabili con i comparator esistenti.
|
||||||
|
|
||||||
|
**Architecture:** Estensione puramente additiva. La whitelist `KNOWN_FEATURES` in `protocol/grammar.py` cresce da 5 a 9 nomi. Il dispatcher di `FeatureNode` in `protocol/compiler.py` acquisisce un branch prioritario che mappa i nomi temporali a serie derivate da `df.index` (DatetimeIndex UTC). Il prompt template di `agents/hypothesis.py` riceve due esempi few-shot. Nessuna modifica a parser, mutation/crossover, genome dataclass.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.13, pandas (DatetimeIndex), pytest. Esecuzione via `uv run`. Repository: `/home/adriano/Documenti/Git_XYZ/Multi_Swarm_Coevolutive`.
|
||||||
|
|
||||||
|
**Spec di riferimento:** `docs/superpowers/specs/2026-05-11-temporal-features-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Tipo | Responsabilità |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `src/multi_swarm/protocol/grammar.py` | Modify | Estendere `KNOWN_FEATURES` |
|
||||||
|
| `src/multi_swarm/protocol/compiler.py` | Modify | Aggiungere `_TIME_FEATURE_FNS` + branch in `_eval_node` |
|
||||||
|
| `src/multi_swarm/agents/hypothesis.py` | Modify | Estendere prompt template con sezione feature temporali + 2 esempi |
|
||||||
|
| `tests/unit/test_protocol_validator.py` | Modify | +2 test (accept/reject) |
|
||||||
|
| `tests/unit/test_protocol_compiler.py` | Modify | +5 test (4 feature + 1 integrazione) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Grammar extension + validator tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/multi_swarm/protocol/grammar.py:21-23`
|
||||||
|
- Modify: `tests/unit/test_protocol_validator.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 1.1: Write failing test — validator accepts temporal features**
|
||||||
|
|
||||||
|
Append to `tests/unit/test_protocol_validator.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_validator_accepts_temporal_features() -> None:
|
||||||
|
for name in ("hour", "dow", "is_weekend", "minute_of_hour"):
|
||||||
|
src = _wrap(
|
||||||
|
{
|
||||||
|
"op": "gt",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": name},
|
||||||
|
{"kind": "literal", "value": 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},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
with pytest.raises(ValidationError, match="unknown feature"):
|
||||||
|
validate_strategy(ast)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_validator.py::test_validator_accepts_temporal_features tests/unit/test_protocol_validator.py::test_validator_rejects_temporal_typo -v`
|
||||||
|
Expected: First test FAILs with `ValidationError: unknown feature: hour`. Second test PASSes already (weekday is unknown today too).
|
||||||
|
|
||||||
|
- [ ] **Step 1.3: Extend `KNOWN_FEATURES` whitelist**
|
||||||
|
|
||||||
|
Edit `src/multi_swarm/protocol/grammar.py`, lines 21-23:
|
||||||
|
|
||||||
|
```python
|
||||||
|
KNOWN_FEATURES: frozenset[str] = frozenset(
|
||||||
|
{"open", "high", "low", "close", "volume",
|
||||||
|
"hour", "dow", "is_weekend", "minute_of_hour"}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.4: Run tests to verify both pass**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_validator.py -v`
|
||||||
|
Expected: All tests PASS (both new tests + all pre-existing ones).
|
||||||
|
|
||||||
|
- [ ] **Step 1.5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/multi_swarm/protocol/grammar.py tests/unit/test_protocol_validator.py
|
||||||
|
git commit -m "feat(protocol): extend KNOWN_FEATURES with temporal feature names"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Compiler — `hour` feature
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/multi_swarm/protocol/compiler.py:135-137`
|
||||||
|
- Modify: `tests/unit/test_protocol_compiler.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 2.1: Write failing test for `hour`**
|
||||||
|
|
||||||
|
Append to `tests/unit/test_protocol_compiler.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
# Tutte le righe hanno hour >= 0 > -1, quindi tutte entry-long
|
||||||
|
assert (signal == Side.LONG).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2.2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_compiler.py::test_compile_hour_feature_returns_index_hour -v`
|
||||||
|
Expected: FAIL with `KeyError: 'hour'` (df has no `hour` column, dispatcher falls into `df[name]`).
|
||||||
|
|
||||||
|
- [ ] **Step 2.3: Add `_TIME_FEATURE_FNS` and dispatcher branch**
|
||||||
|
|
||||||
|
Edit `src/multi_swarm/protocol/compiler.py`. Insert after line 108 (end of `INDICATOR_FNS`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = {
|
||||||
|
"hour": lambda idx: pd.Series(idx.hour, index=idx, dtype="int64"),
|
||||||
|
"dow": lambda idx: pd.Series(idx.dayofweek, index=idx, dtype="int64"),
|
||||||
|
"is_weekend": lambda idx: pd.Series((idx.dayofweek >= 5).astype("int64"), index=idx),
|
||||||
|
"minute_of_hour": lambda idx: pd.Series(idx.minute, index=idx, dtype="int64"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then modify `_eval_node` at line 135-137. Replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _eval_node(node: Node, df: pd.DataFrame) -> pd.Series:
|
||||||
|
if isinstance(node, FeatureNode):
|
||||||
|
return df[node.name]
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _eval_node(node: Node, df: pd.DataFrame) -> pd.Series:
|
||||||
|
if isinstance(node, FeatureNode):
|
||||||
|
if node.name in _TIME_FEATURE_FNS:
|
||||||
|
return _TIME_FEATURE_FNS[node.name](df.index)
|
||||||
|
return df[node.name]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2.4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_compiler.py::test_compile_hour_feature_returns_index_hour -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2.5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/multi_swarm/protocol/compiler.py tests/unit/test_protocol_compiler.py
|
||||||
|
git commit -m "feat(protocol): dispatcher temporal features (hour) in compiler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Compiler — `dow` and `is_weekend` tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/unit/test_protocol_compiler.py` (append)
|
||||||
|
|
||||||
|
Nessuna modifica al sorgente: `_TIME_FEATURE_FNS` definito in Task 2 contiene già le quattro funzioni. Questi test verificano semantica e copertura.
|
||||||
|
|
||||||
|
- [ ] **Step 3.1: Add `dow` test**
|
||||||
|
|
||||||
|
Append to `tests/unit/test_protocol_compiler.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_compile_dow_feature_monday_is_zero(ohlcv: pd.DataFrame) -> None:
|
||||||
|
# 2024-01-01 e' un lunedi -> dow=0; gating eq dow 0 deve dare LONG su monday only.
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "eq",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "dow"},
|
||||||
|
{"kind": "literal", "value": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
# ohlcv fixture: 200h da 2024-01-01 00:00 UTC -> primo lunedi e' bar 0..23
|
||||||
|
monday_hours = signal[(signal.index.dayofweek == 0)]
|
||||||
|
other_hours = signal[(signal.index.dayofweek != 0)]
|
||||||
|
assert (monday_hours == Side.LONG).all()
|
||||||
|
assert (other_hours == Side.FLAT).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.2: Add `is_weekend` test**
|
||||||
|
|
||||||
|
Append:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"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()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.3: Run both tests**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_compiler.py::test_compile_dow_feature_monday_is_zero tests/unit/test_protocol_compiler.py::test_compile_is_weekend_returns_zero_one -v`
|
||||||
|
Expected: Both PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3.4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/unit/test_protocol_compiler.py
|
||||||
|
git commit -m "test(protocol): compiler semantica dow + is_weekend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Compiler — `minute_of_hour` test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/unit/test_protocol_compiler.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 4.1: Add `minute_of_hour` test**
|
||||||
|
|
||||||
|
Append:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_compile_minute_of_hour_zero_on_1h_timeframe(ohlcv: pd.DataFrame) -> None:
|
||||||
|
# Fixture ohlcv ha freq=1h, quindi tutti i minute_of_hour sono 0.
|
||||||
|
# gating eq minute_of_hour 0 -> LONG su TUTTE le righe.
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "eq",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "minute_of_hour"},
|
||||||
|
{"kind": "literal", "value": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
assert (signal == Side.LONG).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.2: Run test**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_compiler.py::test_compile_minute_of_hour_zero_on_1h_timeframe -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4.3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/unit/test_protocol_compiler.py
|
||||||
|
git commit -m "test(protocol): compiler semantica minute_of_hour su 1h"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Compiler — integrazione con regola completa
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/unit/test_protocol_compiler.py` (append)
|
||||||
|
|
||||||
|
- [ ] **Step 5.1: Add integration test**
|
||||||
|
|
||||||
|
Append:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_rule_with_temporal_gating_compiles_and_executes(ohlcv: pd.DataFrame) -> None:
|
||||||
|
# Regola: entry-long se hour > 14 AND close > sma(20).
|
||||||
|
# close in fixture e' lineare crescente, quindi close > sma(20) e' True dopo warmup.
|
||||||
|
# entry-long deve apparire solo nelle bar con hour > 14.
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "and",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"op": "gt",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "hour"},
|
||||||
|
{"kind": "literal", "value": 14},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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)
|
||||||
|
|
||||||
|
# Bar con hour <= 14: mai LONG (gating temporale blocca).
|
||||||
|
morning = signal[signal.index.hour <= 14]
|
||||||
|
assert (morning == Side.FLAT).all()
|
||||||
|
|
||||||
|
# Bar con hour > 14 e dopo warmup sma (>=20 bar dall'inizio): LONG.
|
||||||
|
afternoon_warm = signal[(signal.index.hour > 14) & (np.arange(len(signal)) >= 20)]
|
||||||
|
assert (afternoon_warm == Side.LONG).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5.2: Run test**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_compiler.py::test_rule_with_temporal_gating_compiles_and_executes -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5.3: Run full compiler + validator test suite to check regressions**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_protocol_compiler.py tests/unit/test_protocol_validator.py -v`
|
||||||
|
Expected: All tests PASS (pre-existing + new). Nessun test rotto.
|
||||||
|
|
||||||
|
- [ ] **Step 5.4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/unit/test_protocol_compiler.py
|
||||||
|
git commit -m "test(protocol): integration test gating temporale + sma"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Update Hypothesis prompt
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/multi_swarm/agents/hypothesis.py:84-85`
|
||||||
|
|
||||||
|
- [ ] **Step 6.1: Edit prompt template**
|
||||||
|
|
||||||
|
In `src/multi_swarm/agents/hypothesis.py`, alla riga 84-85 sostituire:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Leaf - feature OHLCV:
|
||||||
|
{{"kind": "feature", "name": "open|high|low|close|volume"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
con:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Leaf - feature OHLCV:
|
||||||
|
{{"kind": "feature", "name": "open|high|low|close|volume"}}
|
||||||
|
|
||||||
|
Leaf - feature TEMPORALI (sempre accessibili, UTC):
|
||||||
|
{{"kind": "feature", "name": "hour"}} // range 0-23
|
||||||
|
{{"kind": "feature", "name": "dow"}} // range 0-6 (lun=0, dom=6)
|
||||||
|
{{"kind": "feature", "name": "is_weekend"}} // 0 o 1
|
||||||
|
{{"kind": "feature", "name": "minute_of_hour"}} // range 0-59
|
||||||
|
|
||||||
|
Esempi di gating temporale:
|
||||||
|
// Solo durante la sessione US (14:00-22:00 UTC)
|
||||||
|
{{"op": "and", "args": [
|
||||||
|
{{"op": "gt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 14}}]}},
|
||||||
|
{{"op": "lt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 22}}]}}
|
||||||
|
]}}
|
||||||
|
|
||||||
|
// Solo nel weekend (sab+dom)
|
||||||
|
{{"op": "eq", "args": [{{"kind": "feature", "name": "is_weekend"}}, {{"kind": "literal", "value": 1}}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6.2: Run existing hypothesis tests to verify prompt format still valid**
|
||||||
|
|
||||||
|
Run: `uv run pytest tests/unit/test_hypothesis_agent.py -v`
|
||||||
|
Expected: All tests PASS. Il template `{feature_access}` continua a funzionare perché non lo abbiamo toccato.
|
||||||
|
|
||||||
|
- [ ] **Step 6.3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/multi_swarm/agents/hypothesis.py
|
||||||
|
git commit -m "feat(hypothesis): aggiungi feature temporali al prompt con 2 esempi few-shot"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Smoke run end-to-end
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Nessuna modifica al codice.
|
||||||
|
|
||||||
|
Validazione che il loop intero giri con la grammatica estesa: carica OHLCV, genera 4 genomi, compila, backtesta, valuta DSR, applica Adversarial, persiste.
|
||||||
|
|
||||||
|
- [ ] **Step 7.1: Run smoke script**
|
||||||
|
|
||||||
|
Run: `uv run python -m scripts.smoke_run`
|
||||||
|
Expected: completamento senza eccezioni, output finale contenente `Smoke run completed`.
|
||||||
|
|
||||||
|
- [ ] **Step 7.2: Inspect at least one generated genome for temporal feature usage**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LATEST=$(sqlite3 runs.db "SELECT id FROM runs WHERE name LIKE 'smoke%' ORDER BY started_at DESC LIMIT 1;")
|
||||||
|
sqlite3 runs.db "SELECT genome_id, substr(raw_text, 1, 600) FROM evaluations WHERE run_id='$LATEST' LIMIT 4;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: 4 righe raw_text JSON. Almeno 1 dovrebbe contenere `"name": "hour"`, `"name": "dow"`, `"name": "is_weekend"`, o `"name": "minute_of_hour"`. Se 0/4 usano feature temporali, il prompt non è abbastanza eloquente — apri un follow-up per iterare il prompt (non bloccante per questa PR).
|
||||||
|
|
||||||
|
- [ ] **Step 7.3: Push branch + open PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -8 # verifica 6 commit dei Task 1-6
|
||||||
|
git push origin HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Aprire PR con titolo `feat: feature temporali nella grammatica Hypothesis` referenziando lo spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (autore del piano)
|
||||||
|
|
||||||
|
- Tutti i 7 hard requirement dello spec (`grammar`, `compiler`, `prompt`, 4 feature, integration test, smoke, backward compat) sono coperti dai Task 1-7.
|
||||||
|
- Nessun placeholder `TBD`/`TODO`.
|
||||||
|
- Tipi consistenti: `_TIME_FEATURE_FNS` definito una volta in Task 2 e referenziato implicitamente dai tester nei Task 3-5 senza bisogno di re-definizione.
|
||||||
|
- Test pre-esistenti non vengono toccati; il Task 5 include `pytest` sull'intera suite del protocollo come regression check.
|
||||||
|
- Backward compat: `KNOWN_FEATURES` cresce, il branch OHLCV resta invariato → genomi vecchi restano validi senza migrazione DB.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Feature temporali nella grammatica Hypothesis — Design
|
||||||
|
|
||||||
|
**Data**: 11 maggio 2026
|
||||||
|
**Status**: design approvato dall'operatore, pronto per writing-plans
|
||||||
|
**Scope target**: Phase 2
|
||||||
|
**Riferimenti**: `docs/decisions/2026-05-11-phase1-5-nemotron-run.md` (memo che ha originato la discussione)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Motivazione
|
||||||
|
|
||||||
|
Le strategie LLM-generate da Phase 1 operano in modo time-blind: la grammatica espone solo OHLCV (`open`, `high`, `low`, `close`, `volume`) e indicatori tecnici (`sma`, `rsi`, `atr`, `macd`, `realized_vol`) calcolati sopra. Non esiste alcuna feature che permetta al genoma di condizionare il comportamento sull'orario o sul giorno della settimana.
|
||||||
|
|
||||||
|
Questo è un limite strutturale rispetto a BTC-PERPETUAL su Cerbero, dove esistono effetti temporali sistematici:
|
||||||
|
|
||||||
|
- apertura USA (14:30 UTC) e Europa (08:00 UTC) generano volatilità sistematica;
|
||||||
|
- apertura/chiusura settimanale crypto (Sabato/Domenica vs. resto della settimana) ha liquidità diversa e basis funding diverso;
|
||||||
|
- la sessione asiatica overnight presenta pattern di trend reversal noti.
|
||||||
|
|
||||||
|
Il design seguente aggiunge alla grammatica quattro feature temporali — `hour`, `dow`, `is_weekend`, `minute_of_hour` — universalmente accessibili a ogni genoma, lasciando inalterati i meccanismi di mutation/crossover esistenti.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decisioni di design
|
||||||
|
|
||||||
|
Le seguenti scelte sono state ratificate in fase di brainstorming.
|
||||||
|
|
||||||
|
**Quattro feature, non una.** `hour` da sola coprirebbe l'80% dei casi, ma `dow` cattura un asse ortogonale (weekend effect) e `is_weekend` è una scorciatoia espressiva utile al LLM. `minute_of_hour` è incluso per disponibilità futura (timeframe 5m/15m in Phase 2+), inerte sui dati 1h attuali.
|
||||||
|
|
||||||
|
**Accesso universale, non soggetto a `feature_access`.** Le feature temporali sono sempre disponibili a ogni genoma, indipendentemente dal subset OHLCV randomizzato in `ga/initial.py` e mutato da `mutate_feature_access`. Motivo: vogliamo che ogni genoma possa testarle; passarle attraverso `FEATURE_POOL` rischia di lasciarle inutilizzate in metà della popolazione e vanificare l'esperimento. Il prompt indica esplicitamente che sono "sempre accessibili", separate dalla sezione `{feature_access}` del template.
|
||||||
|
|
||||||
|
**Riuso di `FeatureNode`, niente nuovo tipo AST.** Le feature temporali entrano nella stessa whitelist `KNOWN_FEATURES` di OHLCV e usano la stessa shape JSON `{"kind": "feature", "name": "..."}`. Il dispatcher in `compiler.py` discrimina per nome. Alternativa scartata: introdurre `TimeFeatureNode` separato. Avrebbe dato type-safety formale ma richiesto modifiche a parser, validator, JSON shape, prompt — costo eccessivo per beneficio puramente strutturale, dato che semanticamente "ora del giorno" e "prezzo close" sono entrambi attributi della riga.
|
||||||
|
|
||||||
|
**Few-shot examples nel prompt.** L'istruzione minimale (solo nomi) lascia troppo spazio a interpretazioni errate (es. `dow=7` per domenica all'italiana, `hour` in fuso locale invece che UTC). Due esempi concreti — un gating intraday `gt hour 14 AND lt hour 22`, un gating settimanale `eq is_weekend 1` — fissano la semantica al costo di ~200 token addizionali per call.
|
||||||
|
|
||||||
|
**Out-of-range non è errore di validazione.** Il LLM potrebbe emettere `gt hour 25` o `eq dow 7`. Il validator non li intercetta: tecnicamente sono `LiteralNode(value=...)` numerici legali. La condizione sarà semplicemente sempre falsa e l'Adversarial layer (`flat_too_long`, `no_trades`) sanzionerà i genomi che ne sono dipendenti. Aggiungere un check range esplicito sarebbe over-engineering per un caso che il sistema già gestisce.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architettura — modifiche file-by-file
|
||||||
|
|
||||||
|
Cinque file toccati. Nessun nuovo modulo.
|
||||||
|
|
||||||
|
### `src/multi_swarm/protocol/grammar.py`
|
||||||
|
|
||||||
|
Estendere `KNOWN_FEATURES` da 5 a 9 nomi:
|
||||||
|
|
||||||
|
```python
|
||||||
|
KNOWN_FEATURES: frozenset[str] = frozenset(
|
||||||
|
{"open", "high", "low", "close", "volume",
|
||||||
|
"hour", "dow", "is_weekend", "minute_of_hour"}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nessun'altra modifica al file. Il validator legge da qui automaticamente.
|
||||||
|
|
||||||
|
### `src/multi_swarm/protocol/compiler.py`
|
||||||
|
|
||||||
|
Aggiungere un dizionario di derivazioni temporali ed estendere il dispatcher di `FeatureNode` con un branch prioritario:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = {
|
||||||
|
"hour": lambda idx: pd.Series(idx.hour, index=idx, dtype="int64"),
|
||||||
|
"dow": lambda idx: pd.Series(idx.dayofweek, index=idx, dtype="int64"),
|
||||||
|
"is_weekend": lambda idx: pd.Series((idx.dayofweek >= 5).astype("int64"), index=idx),
|
||||||
|
"minute_of_hour": lambda idx: pd.Series(idx.minute, index=idx, dtype="int64"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# nel branch FeatureNode di _eval_node:
|
||||||
|
if isinstance(node, FeatureNode):
|
||||||
|
if node.name in _TIME_FEATURE_FNS:
|
||||||
|
return _TIME_FEATURE_FNS[node.name](df.index)
|
||||||
|
return df[node.name]
|
||||||
|
```
|
||||||
|
|
||||||
|
Il branch OHLCV preesistente (`return df[node.name]`) resta invariato come fallback per i nomi non temporali. Si assume `df.index` di tipo `DatetimeIndex` UTC, già garantito da `CerberoOHLCVLoader`.
|
||||||
|
|
||||||
|
### `src/multi_swarm/agents/hypothesis.py`
|
||||||
|
|
||||||
|
Aggiungere nel prompt template, dopo la sezione "Leaf - feature OHLCV" (intorno a riga 84), una sezione "Leaf - feature TEMPORALI" con i quattro nomi, i loro range, e due esempi few-shot completi (gating sessione US, gating weekend). Mantenere la sezione separata da `{feature_access}` e dichiarare esplicitamente che le feature temporali sono "sempre accessibili". Contenuto preciso definito nella sezione 5 di questo spec.
|
||||||
|
|
||||||
|
### `tests/protocol/test_compiler.py`
|
||||||
|
|
||||||
|
Cinque test nuovi:
|
||||||
|
|
||||||
|
1. `test_compile_hour_feature_returns_index_hour` — DataFrame 24-bar con index orario, `FeatureNode("hour")` restituisce serie `[0,1,...,23]`.
|
||||||
|
2. `test_compile_dow_feature_lunedi_is_zero` — verifica convenzione pandas (lunedì → 0, domenica → 6).
|
||||||
|
3. `test_compile_is_weekend_returns_zero_one` — sabato e domenica → 1, altri → 0.
|
||||||
|
4. `test_compile_minute_of_hour_zero_on_1h_timeframe` — su index 1h tutti gli output sono 0 (test di regressione del comportamento atteso).
|
||||||
|
5. `test_rule_with_temporal_gating_compiles_and_executes` — integrazione: regola `entry-long if hour > 14 AND close > sma(20)`, verifica che `Side.LONG` appaia solo nelle bar con `hour > 14`.
|
||||||
|
|
||||||
|
### `tests/protocol/test_validator.py`
|
||||||
|
|
||||||
|
Due test nuovi:
|
||||||
|
|
||||||
|
1. `test_validator_accepts_temporal_features` — i quattro nuovi nomi non sollevano `ValidationError`.
|
||||||
|
2. `test_validator_rejects_temporal_typo` — `FeatureNode("weekday")` solleva `ValidationError`.
|
||||||
|
|
||||||
|
Test esistenti non devono cambiare. L'aggiunta è puramente additiva.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Contratto delle feature
|
||||||
|
|
||||||
|
| Feature | Tipo | Range | Derivazione pandas |
|
||||||
|
|---------|------|-------|---------------------|
|
||||||
|
| `hour` | int64 | 0–23 | `df.index.hour` |
|
||||||
|
| `dow` | int64 | 0–6 (lun=0) | `df.index.dayofweek` |
|
||||||
|
| `is_weekend` | int64 | 0 o 1 | `(df.index.dayofweek >= 5).astype(int)` |
|
||||||
|
| `minute_of_hour` | int64 | 0–59 | `df.index.minute` |
|
||||||
|
|
||||||
|
L'indice del DataFrame è UTC tz-aware per costruzione (`CerberoOHLCVLoader`). I valori temporali sono quindi in UTC, non in fuso locale italiano. Questa scelta è coerente con la convenzione di prezzi e timestamp del progetto e con la natura globale del mercato crypto.
|
||||||
|
|
||||||
|
I confronti tipici emessi dal LLM saranno della forma `{"op": "gt", "args": [{"kind": "feature", "name": "hour"}, {"kind": "literal", "value": 14}]}`. Funzionano via broadcasting numpy senza modifiche a comparator o operator nodes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frammento di prompt aggiunto
|
||||||
|
|
||||||
|
Da inserire in `hypothesis.py` dopo l'attuale sezione "Leaf - feature OHLCV":
|
||||||
|
|
||||||
|
```text
|
||||||
|
Leaf - feature TEMPORALI (sempre accessibili, UTC):
|
||||||
|
{{"kind": "feature", "name": "hour"}} range 0-23
|
||||||
|
{{"kind": "feature", "name": "dow"}} range 0-6 (lun=0, dom=6)
|
||||||
|
{{"kind": "feature", "name": "is_weekend"}} 0 o 1
|
||||||
|
{{"kind": "feature", "name": "minute_of_hour"}} range 0-59
|
||||||
|
|
||||||
|
Esempi di gating temporale:
|
||||||
|
// Solo durante la sessione US (14:00-22:00 UTC)
|
||||||
|
{{"op": "and", "args": [
|
||||||
|
{{"op": "gt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 14}}]}},
|
||||||
|
{{"op": "lt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 22}}]}}
|
||||||
|
]}}
|
||||||
|
|
||||||
|
// Solo nel weekend (sab+dom)
|
||||||
|
{{"op": "eq", "args": [{{"kind": "feature", "name": "is_weekend"}}, {{"kind": "literal", "value": 1}}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Il blocco va inserito **prima** della frase corrente "Feature accessibili dal tuo genoma: {feature_access}", per chiarire che `{feature_access}` riguarda solo OHLCV mentre le temporali sono universali.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Backward compatibility e impatto sui run esistenti
|
||||||
|
|
||||||
|
Tutti i genomi esistenti nei `runs.db` storici (Phase 1, Phase 1.5 nemotron, Phase 1.5 grok in corso) usano solo feature OHLCV. Con la grammatica estesa restano validi: il validator continua ad accettarli, il compiler li gestisce nel branch OHLCV invariato.
|
||||||
|
|
||||||
|
Non c'è quindi alcuna migrazione di dati. I run vecchi possono essere ri-letti dalla dashboard senza modifiche. La distinzione "run pre/post feature temporali" sarà tracciata implicitamente dalla data del commit di merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Validazione end-to-end
|
||||||
|
|
||||||
|
Dopo il merge dei cinque file, la procedura di validazione è:
|
||||||
|
|
||||||
|
1. Esecuzione test suite completa (`uv run pytest`) — i 7 nuovi test devono passare, nessun test esistente deve rompersi.
|
||||||
|
2. `scripts/smoke_run.py` con `population_size=4, n_generations=1` per verificare che il loop end-to-end completi (caricamento OHLCV → generazione genome → compile → backtest → DSR → adversarial → persistenza). Tempo atteso ~2 minuti.
|
||||||
|
3. Ispezione manuale di almeno 1 genoma generato post-merge: verificare che il LLM abbia effettivamente usato almeno una feature temporale tra le sue regole. Se in 4 genomi nessuno usa feature temporali, ri-esaminare il prompt.
|
||||||
|
|
||||||
|
Non è previsto un confronto ablation formale (con/senza feature temporali) in questo spec — è un'attività di Phase 2 separata che andrà pianificata in un proprio spec quando si avvierà il run di valutazione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of scope
|
||||||
|
|
||||||
|
I seguenti elementi sono esplicitamente fuori dallo scope di questo spec e dovranno essere oggetto di design dedicato se desiderati:
|
||||||
|
|
||||||
|
- **Feature temporali con segno periodico** (es. `sin_hour`, `cos_dow`): utili per regressioni continue, non per regole booleane GA-based. Skip.
|
||||||
|
- **Feature di sessione discreta** (es. `session=us|europe|asia`): derivabili componendo `hour` con comparator, non necessario aggiungere come feature primitiva.
|
||||||
|
- **Time-zone configurabile**: rimane fissa UTC. Cambiare implica refactor del loader OHLCV.
|
||||||
|
- **Validator range-check** (es. rifiutare `gt(dow, 6)`): sanzionato già dal loop GA via fitness e Adversarial.
|
||||||
|
- **Modifica del meccanismo `mutate_feature_access`**: invariato. Le feature temporali non entrano nel pool mutabile.
|
||||||
|
- **Indicatori temporali** (es. `time_since_last_high`): richiede stato persistente, fuori dal modello stateless attuale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Stima di sforzo
|
||||||
|
|
||||||
|
Implementazione: ~120 LOC (60 di codice + 60 di test) in 5 file. Complessità bassa.
|
||||||
|
|
||||||
|
TDD-driven: scrivere prima i 7 test, verificare che falliscano, poi aggiungere whitelist + dispatcher + prompt. Tempo stimato: 2-3 ore di lavoro continuo, validation smoke run inclusa.
|
||||||
|
|
||||||
|
Costo prompt addizionale per call: ~200 token. Su un run da 200 call, ~40k token aggiuntivi → impatto economico trascurabile (<$0.05 con qualsiasi tier).
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Adversarial agent: ispeziona una :class:`Strategy` con check euristici
|
"""Adversarial agent: ispeziona una :class:`Strategy` con check euristici
|
||||||
hand-crafted per scovare patologie note (degenerate, no-trade, over/under
|
hand-crafted per scovare patologie note (degenerate, no-trade, over/under
|
||||||
trading, flat-too-long, fees-eat-alpha) prima del training vero e proprio.
|
trading, flat-too-long, time-in-market-too-high, fees-eat-alpha) prima
|
||||||
|
del training vero e proprio.
|
||||||
|
|
||||||
Pipeline:
|
Pipeline:
|
||||||
|
|
||||||
@@ -11,10 +12,13 @@ falsificazione, ma sega presto i casi degeneri (es. ``gt close -1e9`` →
|
|||||||
sempre long) che inquinerebbero il leaderboard del swarm.
|
sempre long) che inquinerebbero il leaderboard del swarm.
|
||||||
|
|
||||||
Phase 1.5 hardening: soglie strette per overtrading (n_trades > n_bars/20)
|
Phase 1.5 hardening: soglie strette per overtrading (n_trades > n_bars/20)
|
||||||
e undertrading (HIGH se n_trades < 10), piu' due nuovi check HIGH:
|
e undertrading (HIGH se n_trades < 10), piu' tre nuovi check HIGH:
|
||||||
``flat_too_long`` (signal flat >95% delle bar) e ``fees_eat_alpha``
|
``flat_too_long`` (signal flat >95% delle bar),
|
||||||
(fees > 50% del gross_pnl positivo). Killano le strategie "lucky shot"
|
``time_in_market_too_high`` (signal long/short >80% delle bar, di fatto
|
||||||
e quelle con margine sottile non sostenibile in produzione.
|
leveraged buy-and-hold con funding/tail-risk cumulato) e
|
||||||
|
``fees_eat_alpha`` (fees > 50% del gross_pnl positivo). Killano le
|
||||||
|
strategie "lucky shot", le sempre-in-market e quelle con margine sottile
|
||||||
|
non sostenibile in produzione.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -133,6 +137,26 @@ class AdversarialAgent:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Time-in-market-too-high: signal LONG o SHORT >80% delle bar.
|
||||||
|
# Simmetrico opposto di flat_too_long: una strategia sempre-in-market
|
||||||
|
# e' di fatto leveraged buy-and-hold, esposta a funding cumulato su
|
||||||
|
# perp (paid ogni 8h), tail risk eventi notturni/weekend, nessuna
|
||||||
|
# opportunity-cost flexibility. Sweet spot fitness positiva: 5-80%
|
||||||
|
# time in market (combinato con flat_too_long).
|
||||||
|
active_ratio = n_active / n_bars if n_bars > 0 else 0.0
|
||||||
|
if active_ratio > 0.80:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
name="time_in_market_too_high",
|
||||||
|
severity=Severity.HIGH,
|
||||||
|
detail=(
|
||||||
|
f"Signal long/short for {active_ratio * 100:.1f}% of bars "
|
||||||
|
"(>80% threshold); esposizione cumulativa funding + tail risk, "
|
||||||
|
"di fatto leveraged B&H"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Fees-eat-alpha: gross_pnl > 0 ma fees > 50% del lordo.
|
# Fees-eat-alpha: gross_pnl > 0 ma fees > 50% del lordo.
|
||||||
# La strategia ha edge teorico ma il margine viene mangiato dai
|
# La strategia ha edge teorico ma il margine viene mangiato dai
|
||||||
# costi di transazione: non sostenibile in produzione.
|
# costi di transazione: non sostenibile in produzione.
|
||||||
|
|||||||
@@ -84,6 +84,22 @@ Leaf - indicatori (calcolati su close):
|
|||||||
Leaf - feature OHLCV:
|
Leaf - feature OHLCV:
|
||||||
{{"kind": "feature", "name": "open|high|low|close|volume"}}
|
{{"kind": "feature", "name": "open|high|low|close|volume"}}
|
||||||
|
|
||||||
|
Leaf - feature TEMPORALI (sempre accessibili, UTC):
|
||||||
|
{{"kind": "feature", "name": "hour"}} // range 0-23
|
||||||
|
{{"kind": "feature", "name": "dow"}} // range 0-6 (lun=0, dom=6)
|
||||||
|
{{"kind": "feature", "name": "is_weekend"}} // 0 o 1
|
||||||
|
{{"kind": "feature", "name": "minute_of_hour"}} // range 0-59
|
||||||
|
|
||||||
|
Esempi di gating temporale:
|
||||||
|
// Solo durante la sessione US (14:00-22:00 UTC)
|
||||||
|
{{"op": "and", "args": [
|
||||||
|
{{"op": "gt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 14}}]}},
|
||||||
|
{{"op": "lt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 22}}]}}
|
||||||
|
]}}
|
||||||
|
|
||||||
|
// Solo nel weekend (sab+dom)
|
||||||
|
{{"op": "eq", "args": [{{"kind": "feature", "name": "is_weekend"}}, {{"kind": "literal", "value": 1}}]}}
|
||||||
|
|
||||||
Leaf - letterale numerico:
|
Leaf - letterale numerico:
|
||||||
{{"kind": "literal", "value": 70.0}}
|
{{"kind": "literal", "value": 70.0}}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,16 @@ MODEL_TIER_C = "qwen/qwen-2.5-72b-instruct"
|
|||||||
MODEL_TIER_D = "meta-llama/llama-3.3-70b-instruct"
|
MODEL_TIER_D = "meta-llama/llama-3.3-70b-instruct"
|
||||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
class EmptyCompletionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Errori transient: retry. RateLimit/Auth/InvalidRequest: NO retry.
|
# Errori transient: retry. RateLimit/Auth/InvalidRequest: NO retry.
|
||||||
_RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
_RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
||||||
openai.APIConnectionError,
|
openai.APIConnectionError,
|
||||||
openai.APITimeoutError,
|
openai.APITimeoutError,
|
||||||
openai.InternalServerError,
|
openai.InternalServerError,
|
||||||
|
EmptyCompletionError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,12 +93,13 @@ class LLMClient:
|
|||||||
top_p=genome.top_p,
|
top_p=genome.top_p,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
|
if not resp.choices or resp.choices[0].message.content is None:
|
||||||
|
raise EmptyCompletionError(f"empty response from {model}")
|
||||||
usage = resp.usage
|
usage = resp.usage
|
||||||
assert usage is not None
|
|
||||||
return CompletionResult(
|
return CompletionResult(
|
||||||
text=resp.choices[0].message.content or "",
|
text=resp.choices[0].message.content,
|
||||||
input_tokens=usage.prompt_tokens,
|
input_tokens=usage.prompt_tokens if usage else 0,
|
||||||
output_tokens=usage.completion_tokens,
|
output_tokens=usage.completion_tokens if usage else 0,
|
||||||
tier=genome.model_tier,
|
tier=genome.model_tier,
|
||||||
model=model,
|
model=model,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ INDICATOR_FNS: dict[str, Any] = {
|
|||||||
"macd": _ind_macd,
|
"macd": _ind_macd,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = {
|
||||||
|
"hour": lambda idx: pd.Series(idx.hour, index=idx, dtype="int64"),
|
||||||
|
"dow": lambda idx: pd.Series(idx.dayofweek, index=idx, dtype="int64"),
|
||||||
|
"is_weekend": lambda idx: pd.Series((idx.dayofweek >= 5).astype("int64"), index=idx),
|
||||||
|
"minute_of_hour": lambda idx: pd.Series(idx.minute, index=idx, dtype="int64"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _to_series(value: float, df: pd.DataFrame) -> pd.Series:
|
def _to_series(value: float, df: pd.DataFrame) -> pd.Series:
|
||||||
"""Broadcast a numeric literal across the DataFrame index."""
|
"""Broadcast a numeric literal across the DataFrame index."""
|
||||||
@@ -134,6 +141,8 @@ def _eval_bool_arg(node: Node, df: pd.DataFrame) -> pd.Series:
|
|||||||
|
|
||||||
def _eval_node(node: Node, df: pd.DataFrame) -> pd.Series:
|
def _eval_node(node: Node, df: pd.DataFrame) -> pd.Series:
|
||||||
if isinstance(node, FeatureNode):
|
if isinstance(node, FeatureNode):
|
||||||
|
if node.name in _TIME_FEATURE_FNS:
|
||||||
|
return _TIME_FEATURE_FNS[node.name](df.index)
|
||||||
return df[node.name]
|
return df[node.name]
|
||||||
|
|
||||||
if isinstance(node, IndicatorNode):
|
if isinstance(node, IndicatorNode):
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ KNOWN_INDICATORS: frozenset[str] = frozenset(
|
|||||||
{"sma", "rsi", "atr", "macd", "realized_vol"}
|
{"sma", "rsi", "atr", "macd", "realized_vol"}
|
||||||
)
|
)
|
||||||
KNOWN_FEATURES: frozenset[str] = frozenset(
|
KNOWN_FEATURES: frozenset[str] = frozenset(
|
||||||
{"open", "high", "low", "close", "volume"}
|
{"open", "high", "low", "close", "volume",
|
||||||
|
"hour", "dow", "is_weekend", "minute_of_hour"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convenience union (utile a validator / parser).
|
# Convenience union (utile a validator / parser).
|
||||||
|
|||||||
@@ -338,3 +338,97 @@ def test_fees_eat_alpha_flagged(monkeypatch: pytest.MonkeyPatch,
|
|||||||
f.name == "fees_eat_alpha" and f.severity == Severity.HIGH
|
f.name == "fees_eat_alpha" and f.severity == Severity.HIGH
|
||||||
for f in report.findings
|
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.agents.adversarial.BacktestEngine.run", fake_run
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"multi_swarm.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.agents.adversarial.BacktestEngine.run", fake_run
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"multi_swarm.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
|
||||||
|
|||||||
@@ -106,3 +106,150 @@ def test_compile_two_rules_priority(ohlcv: pd.DataFrame) -> None:
|
|||||||
signals = fn(ohlcv)
|
signals = fn(ohlcv)
|
||||||
last = signals.iloc[-1]
|
last = signals.iloc[-1]
|
||||||
assert last == Side.LONG # close finale e' 120, regola 1 matcha
|
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()
|
||||||
|
|||||||
@@ -151,3 +151,33 @@ def test_feature_unknown_column_fails() -> None:
|
|||||||
ast = parse_strategy(src)
|
ast = parse_strategy(src)
|
||||||
with pytest.raises(ValidationError, match="unknown feature"):
|
with pytest.raises(ValidationError, match="unknown feature"):
|
||||||
validate_strategy(ast)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user