Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d79e66d68 | |||
| 4b9cded966 | |||
| 14f130aa5a | |||
| b86dbdc9ee | |||
| a66f97fb0e | |||
| 073200313c | |||
| 03f723f7fc | |||
| 8e5efde219 | |||
| 45f273f591 | |||
| 9d1ef8adcf | |||
| 67ae6ff74e | |||
| 1a1dfb7a73 | |||
| 3fcad79f8d | |||
| 242724ba05 | |||
| 4c184bb5f7 | |||
| cf42dd85f3 | |||
| bf70acc322 | |||
| 597815a106 | |||
| ba4eb09a71 | |||
| 0e01de156f | |||
| 4c119a109e | |||
| a12aead3e5 | |||
| ec80af9f26 | |||
| c38311e5fa | |||
| 8ec45c5c1b | |||
| 9344395760 | |||
| 6f6fbb30a0 | |||
| 171f554916 | |||
| 9e740cbcbd | |||
| 2acba2077b | |||
| 0486e19829 | |||
| 2f38562e23 | |||
| 56e22584d9 | |||
| 5f28884974 | |||
| 7b790b1bc3 | |||
| 41e26cbe5b | |||
| 9c53995f23 | |||
| 68637d1102 | |||
| 36cbfadb40 | |||
| 2014ed3815 | |||
| 22a934a6cf | |||
| 9d1f97cff3 | |||
| 0e9489bf88 | |||
| 3e9a4efcc2 | |||
| 30dbba4d74 | |||
| c6cb32325e | |||
| 1a171acfb2 | |||
| 9d0deb3ae0 | |||
| d3662f6098 | |||
| 23c9e37f94 | |||
| 56a631f38a | |||
| 690da30272 | |||
| 943aa38cf2 | |||
| d159075182 | |||
| d4fcb42fc5 | |||
| 44eb6436c1 | |||
| df76906505 | |||
| d9423a1ab5 | |||
| 15a4138bbd | |||
| 6a201c7e49 |
@@ -0,0 +1,58 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python caches
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.egg-info
|
||||
.venv
|
||||
venv
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
|
||||
# Editors / OS
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Secrets — montati via env_file nel compose, mai dentro l'immagine
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Artefatti runtime — vivono come bind mount, non nell'immagine
|
||||
runs.db
|
||||
runs.db-journal
|
||||
runs.db-wal
|
||||
runs.db-shm
|
||||
data/
|
||||
series/
|
||||
state/
|
||||
*.parquet
|
||||
*.feather
|
||||
checkpoints/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Build / dist
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Docs grandi — non servono in immagine
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Test — non servono in runtime (l'immagine non gira pytest)
|
||||
tests/
|
||||
|
||||
# OMC / claude metadata
|
||||
.omc/
|
||||
.claude/
|
||||
+17
-4
@@ -11,14 +11,27 @@ OPENROUTER_API_KEY=
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# Models per tier (override Phase 1 defaults if needed)
|
||||
LLM_MODEL_TIER_S=anthropic/claude-opus-4-7
|
||||
LLM_MODEL_TIER_A=anthropic/claude-sonnet-4-6
|
||||
LLM_MODEL_TIER_B=anthropic/claude-sonnet-4-6
|
||||
LLM_MODEL_TIER_S=google/gemini-3-flash-preview
|
||||
LLM_MODEL_TIER_A=deepseek/deepseek-v4-flash
|
||||
LLM_MODEL_TIER_B=deepseek/deepseek-v4-flash
|
||||
LLM_MODEL_TIER_C=qwen/qwen-2.5-72b-instruct
|
||||
LLM_MODEL_TIER_D=meta-llama/llama-3.3-70b-instruct
|
||||
LLM_MODEL_TIER_D=openai/gpt-oss-20b
|
||||
|
||||
# Run config
|
||||
RUN_NAME=phase1-spike-001
|
||||
DATA_DIR=./data
|
||||
SERIES_DIR=./series
|
||||
DB_PATH=./runs.db
|
||||
|
||||
# Docker / Traefik (usati SOLO da docker-compose.yml)
|
||||
# Dominio base: traefik espone la dashboard su swarm.${DOMAIN_NAME}
|
||||
DOMAIN_NAME=tielogic.xyz
|
||||
# Porta interna della NiceGUI dashboard (Traefik fa il TLS davanti)
|
||||
SWARM_DASHBOARD_PORT=8080
|
||||
|
||||
# Paper-trading runner — override del command nel compose (opzionali)
|
||||
PAPER_RUN_NAME=phase3-papertrade-prod
|
||||
PAPER_INITIAL_CAPITAL=1000
|
||||
PAPER_FEES_BP=5.0
|
||||
PAPER_POLL_SECONDS=300
|
||||
PAPER_LOOKBACK_BARS=500
|
||||
|
||||
@@ -14,6 +14,7 @@ venv/
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
.claude/
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Multi-Swarm Coevolutive — immagine unica usata da due servizi:
|
||||
# * paper-trading runner (scripts/run_paper_trading.py)
|
||||
# * Streamlit dashboard (src/multi_swarm/dashboard/streamlit_app.py)
|
||||
#
|
||||
# Builder stage: risolve uv.lock con `uv sync --frozen --no-dev` e produce
|
||||
# un venv in /app/.venv. Runtime stage: copia solo /app + scripts/ e gira
|
||||
# come utente non-root. data/, series/, strategies/, state/ sono bind
|
||||
# mount dal compose, quindi non finiscono nell'immagine.
|
||||
|
||||
FROM python:3.13-slim AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir "uv>=0.5,<0.9"
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY src ./src
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
|
||||
FROM python:3.13-slim AS runtime
|
||||
LABEL org.opencontainers.image.title="multi-swarm" \
|
||||
org.opencontainers.image.version="0.1.0" \
|
||||
org.opencontainers.image.source="https://git.tielogic.xyz/Adriano/Multi_Swarm_Coevolutive"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app /app
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/app/src
|
||||
|
||||
RUN useradd -m -u 1000 app \
|
||||
&& mkdir -p /app/data /app/series /app/state /app/strategies \
|
||||
&& chown -R app:app /app
|
||||
USER app
|
||||
|
||||
# Healthcheck di default: import del package — i servizi reali lo
|
||||
# sovrascrivono nel compose (streamlit /_stcore/health).
|
||||
HEALTHCHECK --interval=60s --timeout=5s --retries=3 --start-period=10s \
|
||||
CMD python -c "import multi_swarm" || exit 1
|
||||
|
||||
# Nessun CMD di default: il compose specifica entrypoint/command
|
||||
# per ognuno dei due servizi.
|
||||
@@ -1,33 +1,226 @@
|
||||
# Multi_Swarm_Coevolutive — Phase 1
|
||||
# Multi_Swarm_Coevolutive
|
||||
|
||||
Lean spike del PoC. Vedi `docs/superpowers/specs/2026-05-09-decisione-strategica-design.md`
|
||||
per il razionale e `docs/superpowers/plans/2026-05-09-phase1-lean-spike.md` per il
|
||||
piano implementativo.
|
||||
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 (default BTC-PERPETUAL Deribit) 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, con opzioni v2 soft-kill e combined IS/OOS per Walk-Forward Validation. 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
|
||||
|
||||
**Phase 3 (paper-trading forward-test) in corso** dal 13 maggio 2026 su VPS. Runner `scripts/run_paper_trading.py` live 24/7 in Docker (`https://swarm.tielogic.xyz` per la dashboard) con due strategie freezate:
|
||||
|
||||
- `strategies/btc_fb63e851.json` — BTC-PERPETUAL, true alpha hour-gated (RSI estremi + ATR vs SMA + filtro orario 9-17), Sharpe OOS +0,26 su 7,33 anni di storia.
|
||||
- `strategies/eth_facd6af85d5d.json` — ETH-PERPETUAL, trend-following long-bias + vol regime, Sharpe OOS +0,19 su 6,75 anni.
|
||||
|
||||
Phase 1 → 2.7 tutte chiuse (30 run GA, $3.74 cumulato LLM, cap originale $700 → margine 99%+). Vedi il documento di sintesi consolidato per il dettaglio:
|
||||
|
||||
- [**Stato progetto e roadmap (14 maggio 2026)**](docs/reports/2026-05-14-stato-progetto-e-roadmap.md) — riepilogo di tutte le fasi, decisioni, caveat aperti, roadmap.
|
||||
|
||||
Documenti chiave per fase:
|
||||
|
||||
- [Decisione strategica](docs/superpowers/specs/2026-05-09-decisione-strategica-design.md) — perché Phase 1 prima, Phase 2 poi, Phase 3 forward-test.
|
||||
- [Decision memo gate Phase 1](docs/decisions/2026-05-10-gate-phase1.md), [Technical report Phase 1](docs/reports/2026-05-10-phase1-technical-report.md), [Decision memo Phase 1.5 nemotron](docs/decisions/2026-05-11-phase1-5-nemotron-run.md).
|
||||
- [Piano Phase 2.5 prompt-mutator](docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md), [Piano feature temporali](docs/superpowers/plans/2026-05-11-temporal-features.md).
|
||||
|
||||
Documenti di contesto pre-implementazione: `00_documento_zero.md` (framework concettuale Renaissance → swarm), `coevolutive_swarm_system.md` (Filone A, sistema completo), `poc_trading_swarm.md` (Filone B, PoC trading).
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
src/multi_swarm/
|
||||
├── config.py Settings Pydantic (.env)
|
||||
├── data/
|
||||
│ ├── cerbero_ohlcv.py OHLCV loader via Cerbero MCP + cache parquet
|
||||
│ └── splits.py Walk-forward expanding splits (Phase 2.6)
|
||||
├── backtest/
|
||||
│ ├── orders.py Side/Order/Position/Trade
|
||||
│ └── engine.py Event-driven backtest, 1-bar exec delay
|
||||
├── metrics/
|
||||
│ ├── basic.py Sharpe, max drawdown, total return
|
||||
│ ├── dsr.py Deflated Sharpe Ratio (Bailey & López 2014)
|
||||
│ └── diversity.py Entropy/diversity metrics popolazione (Phase 2.5)
|
||||
├── cerbero/
|
||||
│ ├── client.py HTTP client (bearer + bot-tag + retry tenacity)
|
||||
│ └── tools.py Wrapper tool MCP (sma/rsi/atr/macd/realized_vol/funding)
|
||||
├── protocol/
|
||||
│ ├── grammar.py Vocabolario operatori, indicatori, feature (incl. hour/dow/is_weekend)
|
||||
│ ├── parser.py json.loads → AST dataclass tipizzato
|
||||
│ ├── validator.py Arity checks, no-nesting indicators, whitelist
|
||||
│ └── compiler.py AST → Callable[[df], Series[Side]]
|
||||
├── genome/
|
||||
│ ├── hypothesis.py HypothesisAgentGenome (id deterministico)
|
||||
│ ├── mutation.py 4 operatori scalari (temp, lookback, features, style)
|
||||
│ ├── mutation_prompt_llm.py 5° operatore: riscrittura system_prompt via LLM tier B
|
||||
│ └── crossover.py Uniform crossover
|
||||
├── llm/
|
||||
│ ├── client.py Unified LLMClient via OpenRouter (tier S/A/B/C/D)
|
||||
│ └── cost_tracker.py Pricing per tier, breakdown + call_kind tracking
|
||||
├── agents/
|
||||
│ ├── hypothesis.py LLM call + JSON extract + retry-with-feedback
|
||||
│ ├── falsification.py Compile → backtest → DSR
|
||||
│ ├── adversarial.py Red-team heuristics (5 check HIGH parametrici via CLI)
|
||||
│ └── market_summary.py Stats di mercato per il prompt
|
||||
├── ga/
|
||||
│ ├── selection.py Tournament + elitism
|
||||
│ ├── fitness.py v1 continua + v2 soft-kill + combined IS/OOS opt-in
|
||||
│ ├── loop.py next_generation step
|
||||
│ ├── summary.py median/max/p90/entropy per gen
|
||||
│ └── initial.py Popolazione iniziale (6 cognitive style)
|
||||
├── persistence/
|
||||
│ ├── schema.py SQLite DDL: 6 tabelle GA + 5 tabelle paper_trading_*
|
||||
│ └── repository.py CRUD per runs/genomes/evals/cost/findings/gen_summary
|
||||
├── paper_trading/ Phase 3
|
||||
│ ├── portfolio.py Multi-asset portfolio con sleeve uguali per asset
|
||||
│ ├── executor.py PaperExecutor: carica strategia JSON, valuta ultimo bar
|
||||
│ └── persistence.py PaperRepository (paper_trading_runs/ticks/equity/trades/positions)
|
||||
├── orchestrator/
|
||||
│ └── run.py End-to-end pipeline GA + persistence
|
||||
└── dashboard/
|
||||
├── nicegui_app.py NiceGUI dashboard (overview / convergence / genomes)
|
||||
└── data.py Lettura runs.db per le pagine
|
||||
```
|
||||
|
||||
Stack: Python 3.13, uv, pytest+pytest-mock+responses, openai SDK (verso OpenRouter), requests+tenacity, pandas+numpy+scipy, sqlmodel+sqlite, nicegui+plotly, yfinance (test cross-asset non-crypto).
|
||||
|
||||
CLI knobs accumulati (Phase 2.5 → 2.7):
|
||||
|
||||
- `--prompt-mutation-weight FLOAT` (peso del 5° operatore, sweet spot 0.20-0.30)
|
||||
- `--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` (filtro OOS in WFA)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
cp .env.example .env # compilare token e API key
|
||||
uv run pytest # verifica che tutto installi
|
||||
cp .env.example .env # compilare CERBERO_*_TOKEN e OPENROUTER_API_KEY
|
||||
uv run pytest # ~180 test attesi (unit + integration)
|
||||
```
|
||||
|
||||
## Cerbero locale
|
||||
### Variabili .env richieste
|
||||
|
||||
Phase 1 backtest legge dataset OHLCV cached, ma alcune feature di indicatore
|
||||
sono delegate a Cerbero. Avviare Cerbero locale prima di eseguire un run:
|
||||
```bash
|
||||
# Cerbero MCP (locale o VPS https://cerbero-mcp.tielogic.xyz)
|
||||
CERBERO_BASE_URL=http://localhost:9001
|
||||
CERBERO_TESTNET_TOKEN=<testnet bearer>
|
||||
CERBERO_MAINNET_TOKEN=<mainnet bearer> # serve per dati storici reali
|
||||
CERBERO_BOT_TAG=swarm-poc-phase1
|
||||
|
||||
# LLM provider (unico endpoint via OpenRouter)
|
||||
OPENROUTER_API_KEY=<sk-or-v1-...>
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# Modelli per tier (default Phase 2.5+: qwen-2.5-72b per tier C, vedi .env.example per gli altri)
|
||||
LLM_MODEL_TIER_C=qwen/qwen-2.5-72b-instruct
|
||||
|
||||
# Deploy Docker (vedi sezione Deploy)
|
||||
DOMAIN_NAME=tielogic.xyz
|
||||
SWARM_DASHBOARD_PORT=8080
|
||||
```
|
||||
|
||||
### Cerbero MCP
|
||||
|
||||
Tutti i fetch OHLCV passano da Cerbero MCP (sostituisce ccxt). In sviluppo locale:
|
||||
|
||||
```bash
|
||||
cd /home/adriano/Documenti/Git_XYZ/CerberoSuite/Cerbero_mcp
|
||||
docker compose up -d
|
||||
uv sync
|
||||
uv run cerbero-mcp # ascolta su porta da .env (default 9001 se 9000 è occupato)
|
||||
```
|
||||
|
||||
In produzione/integrazione: VPS `https://cerbero-mcp.tielogic.xyz` (richiede bearer) — o internal docker `http://cerbero-mcp:9000` se si gira nella stessa rete Traefik.
|
||||
|
||||
## Comandi principali
|
||||
|
||||
```bash
|
||||
# Quality gates
|
||||
uv run pytest # tutti i test
|
||||
uv run pytest tests/unit -v # solo unit
|
||||
uv run pytest tests/integration -v -m integration # solo integration
|
||||
uv run python scripts/run_phase1.py # run completo Phase 1
|
||||
uv run streamlit run src/multi_swarm/dashboard/streamlit_app.py
|
||||
uv run pytest tests/integration -v # solo integration (richiedono Cerbero + OpenRouter)
|
||||
uv run ruff check src/ tests/ scripts/
|
||||
uv run mypy src/ scripts/
|
||||
|
||||
# Smoke run (MockLLM + OHLCV sintetico, no API calls)
|
||||
uv run python scripts/smoke_run.py
|
||||
|
||||
# Run reale Phase 1/2 (Cerbero + OpenRouter, ~$0.07 per run K=20 10gen)
|
||||
uv run python scripts/run_phase1.py \
|
||||
--name run-XXX \
|
||||
--exchange deribit --symbol BTC-PERPETUAL --timeframe 1h \
|
||||
--start 2024-01-01T00:00:00+00:00 \
|
||||
--end 2026-01-01T00:00:00+00:00 \
|
||||
--population-size 20 --n-generations 10 \
|
||||
--prompt-mutation-weight 0.30 --fitness-v2
|
||||
|
||||
# Backtest standalone di una strategia JSON su range esteso
|
||||
uv run python scripts/backtest_strategy.py \
|
||||
--strategy strategies/btc_fb63e851.json \
|
||||
--start 2018-09-01 --end 2026-01-01
|
||||
|
||||
# Paper-trading forward-test (Phase 3)
|
||||
uv run python scripts/run_paper_trading.py \
|
||||
--name phase3-papertrade-XXX \
|
||||
--initial-capital 1000 --poll-seconds 300
|
||||
|
||||
# Dashboard NiceGUI locale
|
||||
DB_PATH=./runs.db uv run python -m multi_swarm.dashboard.nicegui_app
|
||||
```
|
||||
|
||||
## Dashboard
|
||||
|
||||
NiceGUI dashboard (dark/neon palette) su `http://localhost:8080` (override con env `SWARM_DASHBOARD_PORT`):
|
||||
|
||||
- **Overview** (`/`): lista runs, status, costo, metriche aggregate evaluations (parse success %, top fitness, median).
|
||||
- **GA Convergence** (`/convergence`): fitness median/max/p90 per generazione, entropy con hline a soglia gate (0.5).
|
||||
- **Genomes** (`/genomes`): top-K ordinati per fitness, click su riga per ispezione system_prompt + raw_text JSON strategy.
|
||||
|
||||
In produzione gira dentro Docker dietro Traefik su `https://swarm.${DOMAIN_NAME}` — vedi sezione Deploy.
|
||||
|
||||
## Deploy
|
||||
|
||||
`docker-compose.yml` definisce due servizi che condividono la stessa immagine `multi-swarm:dev`:
|
||||
|
||||
- **`multi-swarm-paper`** — runner `scripts/run_paper_trading.py` long-running (`restart: unless-stopped`).
|
||||
- **`multi-swarm-dashboard`** — NiceGUI esposta via Traefik su `https://swarm.${DOMAIN_NAME}`.
|
||||
|
||||
Entrambi joinano la rete external `traefik` per parlare direttamente con `cerbero-mcp:9000` senza giro pubblico+TLS. Persistenza via bind mount:
|
||||
|
||||
- `./data/`, `./series/` — cache OHLCV (parquet)
|
||||
- `./state/` — `runs.db` (+ WAL/SHM)
|
||||
- `./strategies/` — `btc_*.json` / `eth_*.json` (read-only nel container)
|
||||
|
||||
Bring-up:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose logs -f multi-swarm-paper # segui i tick
|
||||
docker compose ps # stato
|
||||
```
|
||||
|
||||
Note operative:
|
||||
|
||||
- Le bind-mount dir devono essere `chown 1000:1000` (uid utente `app` nel container).
|
||||
- Override del command paper-trading via env (`PAPER_RUN_NAME`, `PAPER_INITIAL_CAPITAL`, `PAPER_POLL_SECONDS`, ecc.) — vedi `.env.example`.
|
||||
- `SWARM_DASHBOARD_PORT` controlla la porta interna del container (Traefik fa il TLS davanti).
|
||||
|
||||
## Costi
|
||||
|
||||
Costo cumulato LLM progetto a oggi: **≈ $3.74** su 30 run GA (Phase 1 → 2.7). Cap originale Phase 1: $700 → margine residuo abbondante.
|
||||
|
||||
- Tier C (qwen-2.5-72b via OpenRouter): ~$0.40/1M token.
|
||||
- Run base K=20 × 10gen ≈ $0.07. Con `--prompt-mutation-weight 0.30` overhead mutator 3-9%.
|
||||
- **Phase 3 paper-trading**: $0 incrementali LLM (strategie fisse), solo costi Cerbero (servizio esistente).
|
||||
|
||||
## Sviluppo
|
||||
|
||||
Conventional commits con prefix `feat:` `fix:` `chore:` `docs:` `refactor:` `test:`. Body italiano. Footer `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>` su ogni commit collaborativo.
|
||||
|
||||
Branch attuale: `main`. Single-author retail R&D, nessun feature branch attivo. Ablation paralleli si gestiscono via CLI knobs sullo stesso branch.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# docker-compose.yml — Multi-Swarm Coevolutive
|
||||
#
|
||||
# Due servizi che condividono la stessa immagine `multi-swarm:dev`:
|
||||
#
|
||||
# * multi-swarm-paper — paper-trading runner long-running
|
||||
# (scripts/run_paper_trading.py)
|
||||
# * multi-swarm-dashboard — Streamlit dashboard esposta da Traefik
|
||||
# su https://swarm.${DOMAIN_NAME:-tielogic.xyz}
|
||||
#
|
||||
# Entrambi joinano la rete external `traefik` cosi' il client Cerbero
|
||||
# risolve direttamente l'host `cerbero-mcp` (porta 9000) senza passare
|
||||
# dal gateway pubblico ne' dal TLS.
|
||||
#
|
||||
# Dati persistenti via bind mount dalla cartella del repo:
|
||||
# ./data cache OHLCV intermedia
|
||||
# ./series cache parquet per timeframe/symbol
|
||||
# ./state contiene runs.db (+ WAL/SHM)
|
||||
# ./strategies btc_*.json / eth_*.json letti dal paper runner
|
||||
#
|
||||
# Secrets (token Cerbero + OpenRouter): caricati da .env via env_file.
|
||||
# Le variabili sotto `environment:` sovrascrivono solo i valori che
|
||||
# devono cambiare dentro il container (URL interno, path container).
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
x-swarm-env: &swarm-env
|
||||
# Override: rotta interna verso cerbero-mcp (no TLS, no traefik hop)
|
||||
CERBERO_BASE_URL: http://cerbero-mcp:9000
|
||||
# Override: path container per persistenza
|
||||
DATA_DIR: /app/data
|
||||
SERIES_DIR: /app/series
|
||||
DB_PATH: /app/state/runs.db
|
||||
|
||||
services:
|
||||
multi-swarm-paper:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: multi-swarm:dev
|
||||
container_name: multi-swarm-paper
|
||||
restart: unless-stopped
|
||||
networks: [traefik]
|
||||
env_file: .env
|
||||
environment:
|
||||
<<: *swarm-env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./series:/app/series
|
||||
- ./state:/app/state
|
||||
- ./strategies:/app/strategies:ro
|
||||
# Niente HTTP da controllare: ci affidiamo a `restart: unless-stopped`
|
||||
# e ai log per la liveness del runner.
|
||||
command:
|
||||
- python
|
||||
- /app/scripts/run_paper_trading.py
|
||||
- --name=${PAPER_RUN_NAME:-phase3-papertrade-prod}
|
||||
- --initial-capital=${PAPER_INITIAL_CAPITAL:-1000}
|
||||
- --fees-bp=${PAPER_FEES_BP:-5.0}
|
||||
- --poll-seconds=${PAPER_POLL_SECONDS:-300}
|
||||
- --lookback-bars=${PAPER_LOOKBACK_BARS:-500}
|
||||
- --strategies-dir=/app/strategies
|
||||
labels:
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
multi-swarm-dashboard:
|
||||
image: multi-swarm:dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: multi-swarm-dashboard
|
||||
restart: unless-stopped
|
||||
networks: [traefik]
|
||||
env_file: .env
|
||||
environment:
|
||||
<<: *swarm-env
|
||||
volumes:
|
||||
# Dashboard legge solo runs.db: mount in read-only
|
||||
- ./state:/app/state:ro
|
||||
- ./data:/app/data:ro
|
||||
- ./series:/app/series:ro
|
||||
entrypoint:
|
||||
- python
|
||||
- -m
|
||||
- multi_swarm.dashboard.nicegui_app
|
||||
command: []
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
- "python"
|
||||
- "-c"
|
||||
- "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"SWARM_DASHBOARD_PORT\",\"8080\")}/', timeout=3).close()"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
- "traefik.http.routers.multi-swarm-dashboard.rule=Host(`swarm.${DOMAIN_NAME:-tielogic.xyz}`)"
|
||||
- traefik.http.routers.multi-swarm-dashboard.tls=true
|
||||
- traefik.http.routers.multi-swarm-dashboard.entrypoints=websecure
|
||||
- traefik.http.routers.multi-swarm-dashboard.tls.certresolver=mytlschallenge
|
||||
- "traefik.http.services.multi-swarm-dashboard.loadbalancer.server.port=${SWARM_DASHBOARD_PORT:-8080}"
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
@@ -0,0 +1,231 @@
|
||||
# Gate Phase 1 — Decision Memo
|
||||
|
||||
**Data**: 10 maggio 2026
|
||||
**Run di riferimento**: `phase1-real-005` (id `1c526996160446b18c0fb57d94874975`)
|
||||
**Run scartati durante iterazione**: `phase1-real-001..004` (vedi sez. 3)
|
||||
**Spesa totale Phase 1**: $0.18 cumulativi (≈0.025% del cap $700)
|
||||
**Tempo speso Phase 1**: 1 giornata di lavoro (10 maggio 2026, iterazione bug-fix incluse)
|
||||
**Status**: ✅ TUTTI E 5 I HARD GATE PASSATI
|
||||
|
||||
---
|
||||
|
||||
## 1. Premessa
|
||||
|
||||
Questo memo formalizza la valutazione dei 5 hard gate definiti nello spec strategico (`docs/superpowers/specs/2026-05-09-decisione-strategica-design.md`, sez. 4.4) sulla base del run `phase1-real-005`. I gate sono numerici per costruzione: l'esito PASS/FAIL è meccanico. Discrezionale è solo l'azione successiva.
|
||||
|
||||
---
|
||||
|
||||
## 2. Author pass — valutazione hard gate
|
||||
|
||||
### Gate 1 — Loop converge
|
||||
|
||||
**Soglia**: la fitness mediana della popolazione cresce per ≥3 generazioni consecutive prima di plateau.
|
||||
|
||||
**Misura osservata**:
|
||||
|
||||
| Generazione | Median fitness | Max fitness | P90 | Entropy |
|
||||
|---|---|---|---|---|
|
||||
| 0 | 0.0001 | 0.0601 | 0.0165 | 0.588 |
|
||||
| 1 | 0.0042 | 0.1893 | 0.0731 | 1.261 |
|
||||
| 2 | 0.0188 | 0.3347 | 0.2039 | 1.333 |
|
||||
| 3 | 0.0069 | 0.3347 | 0.3347 | 1.347 |
|
||||
| 4 | 0.0910 | 0.3347 | 0.3347 | 1.415 |
|
||||
| 5 | 0.0016 | 0.3347 | 0.3347 | 0.611 |
|
||||
| 6 | 0.0040 | 0.3347 | 0.3347 | 0.886 |
|
||||
| 7 | 0.0151 | 0.3347 | 0.3347 | 0.982 |
|
||||
| 8 | 0.0066 | 0.3347 | 0.3347 | 0.746 |
|
||||
| 9 | 0.0061 | 0.3347 | 0.3347 | 0.914 |
|
||||
|
||||
**Generazioni consecutive di crescita mediana**: Gen 0→1→2 (0.0001→0.0042→0.0188 = 3 consecutive). Max raggiunto a gen 2, stabile da lì in poi (plateau dell'elite, comportamento atteso con elite_k=2).
|
||||
|
||||
**Esito**: ✅ **PASS**
|
||||
|
||||
**Razionale**: la convergenza iniziale è chiara (3 generazioni di crescita 4-50x), poi il max plateaua per elite preservation. La median oscilla per turnover di novellini, non per regressione strutturale.
|
||||
|
||||
---
|
||||
|
||||
### Gate 2 — Output formalizzabile
|
||||
|
||||
**Soglia**: ≥80% delle proposte LLM passano il parser senza intervento manuale.
|
||||
|
||||
**Misura osservata**:
|
||||
- Evaluations totali: 98
|
||||
- Parse success: **98 (100.0%)**
|
||||
- Parse error: 0
|
||||
|
||||
**Esito**: ✅ **PASS** (soglia superata di 20 punti percentuali)
|
||||
|
||||
**Razionale**: il refactor da S-expression a JSON Schema (commit `44eb643`) ha eliminato la fragilità sintattica. Combinato con il retry-with-error-feedback (`d4fcb42`), zero retry effettivamente serviti — JSON è already self-correcting per qwen3-235b. Senza questi fix, il run v4 mostrava 35.9% parse success.
|
||||
|
||||
---
|
||||
|
||||
### Gate 3 — Tail superiore
|
||||
|
||||
**Soglia**: i top-5 genomi hanno DSR (qui letto come fitness, dato il design v0) ≥ 1.5x la mediana di popolazione.
|
||||
|
||||
**Misura osservata**:
|
||||
- Median fitness popolazione: 0.0003
|
||||
- Top-5 fitness media: 0.2587
|
||||
- Top-1 fitness: 0.3347
|
||||
- **Ratio (top-1 / median)**: ≈1116x (molto sopra soglia 1.5x)
|
||||
|
||||
**Esito**: ✅ **PASS** (ordini di grandezza sopra soglia)
|
||||
|
||||
**Razionale**: il tail superiore è netto e separato. Esiste un cluster di top performer chiaramente distinguibile da mediocri / killed. Il bigger picture: la fitness function continua (commit `d159075`) ha permesso al GA di distinguere "lievemente migliore" da "completamente disastroso", evitando l'appiattimento a zero del run v4.
|
||||
|
||||
---
|
||||
|
||||
### Gate 4 — Diversità non collassa
|
||||
|
||||
**Soglia**: entropia della distribuzione di fitness in popolazione > 0.5 a fine run.
|
||||
|
||||
**Misura osservata**:
|
||||
- Entropy gen 0: 0.588
|
||||
- Entropy gen finale (gen 9): **0.914**
|
||||
- Trend: oscilla 0.6-1.4 con un dip a gen 5 (0.611) ma sempre sopra soglia.
|
||||
|
||||
**Esito**: ✅ **PASS**
|
||||
|
||||
**Razionale**: la popolazione mantiene varianza di fitness ben sopra 0.5. Cognitive styles sopravvissuti a gen 9: 3 su 6 originali (engineer, physicist, historian), con engineer dominante (3 di 5 elites tracciati). La selezione comprime la diversità cognitiva ma non l'entropia di fitness — segnale che la pressione selettiva funziona senza monocoltura.
|
||||
|
||||
---
|
||||
|
||||
### Gate 5 — Cost predictability
|
||||
|
||||
**Soglia**: spesa entro ±30% della stima preventivata ($500-700 per Phase 1).
|
||||
|
||||
**Misura osservata**:
|
||||
- Stima preventivo originale: $500-700 (basata su pricing Sonnet/Anthropic)
|
||||
- Spesa reale cumulativa Phase 1: ≈$0.18 (somma di v1-v5)
|
||||
- Spesa run v5 da solo: $0.069
|
||||
- Deviazione: -99.97% rispetto al preventivo (sotto cap di **~10000x**)
|
||||
|
||||
**Esito**: ✅ **PASS** (sotto cap; la deviazione verso il basso non è failure)
|
||||
|
||||
**Razionale**: la migrazione a OpenRouter+qwen3-235b come tier C dominante ha cambiato l'ordine di grandezza dei costi (~$0.40/1M token vs Sonnet $3/$15). Il preventivo originale assumeva Sonnet come baseline; la realtà è 1000x più economica. Phase 2 cap ($700-1100) ha margine drammatico, eventualmente utilizzabile per ablation più aggressive o uso di tier B/S sui top candidati.
|
||||
|
||||
---
|
||||
|
||||
## 3. Iterazione: 5 run prima del PASS
|
||||
|
||||
I primi 4 run (`phase1-real-001..004`) hanno servito da bug-discovery. Sintesi:
|
||||
|
||||
| Run | Esito | Problema | Fix applicato |
|
||||
|---|---|---|---|
|
||||
| 001 | aborted | 67% parse_error (LLM nesta indicators); max_dd su equity assoluta produce drawdown 89000 | Prompt strict + max_dd normalizzato su notional (commit `15a4138`) |
|
||||
| 002 | failed | `_ind_macd` accetta 2 args, prompt suggeriva 3 (fast/slow/signal) | macd accetta signal (commit `d9423a1`); OHLCV cap Cerbero ~5000 → paginazione (commit `d9423a1`) |
|
||||
| 003 | failed | Validator non controllava arity indicator → crash compiler su `(indicator sma 20 50)` | INDICATOR_ARITY in validator + reject nested (commit `df76906`) |
|
||||
| 004 | completed FAIL | 35.9% parse_error, fitness tutti 0 (clamp a 0 troppo duro) | Switch a JSON grammar + retry+feedback + fitness continua (commit `44eb643`, `d4fcb42`, `d159075`) |
|
||||
| 005 | **completed PASS** | — | — |
|
||||
|
||||
Costo cumulativo iterazione: $0.034 (v1) + $0.018 (v2, abort) + $0.015 (v3, abort) + $0.057 (v4) + $0.069 (v5) ≈ **$0.19 totale**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Soft observations
|
||||
|
||||
### 4.1 Trade distribution sui 98 evals
|
||||
|
||||
| Categoria | n | % |
|
||||
|---|---|---|
|
||||
| Zero trade (kill no_trades HIGH) | 42 | 42.9% |
|
||||
| Undertrading (1-4 trade, MEDIUM) | 5 | 5.1% |
|
||||
| Normal (5-100 trade) | 9 | 9.2% |
|
||||
| Overtrading (>100 trade) | 42 | 42.9% |
|
||||
|
||||
**Osservazione critica**: il 42.9% di overtrading non è flaggato dall'Adversarial. Il check attuale soglia `n_trades > n_bars/5 = 17545/5 = 3509` — troppo alto. Phase 2 dovrebbe abbassare a `n_bars/20` o usare metrica relativa (trade rate per regime).
|
||||
|
||||
### 4.2 Cognitive style nei top-5
|
||||
|
||||
- physicist: 2 (top-1 e top-5)
|
||||
- engineer: 2 (top-2 e top-4)
|
||||
- ecologist: 1 (top-3)
|
||||
|
||||
historian, biologist, meteorologist non compaiono nei top-5 → loro stili producono strategie meno performanti su BTC perp 1h. Possibile bias del market regime.
|
||||
|
||||
### 4.3 Top-1 ispezione qualitativa
|
||||
|
||||
Genoma `696052b89f78b28f`, gen 2, style `physicist`, temperature 0.68, lookback 200.
|
||||
|
||||
**System prompt** (dal cognitive style "engineer"):
|
||||
> Cerca segnali con rapporto S/N favorevole, filtri causali, robustezza a perturbazioni di calibrazione.
|
||||
|
||||
**Strategia** (3 regole):
|
||||
- **LONG**: SMA(10) crossover SMA(30) AND realized_vol(20) > 0.3% AND RSI(14) < 45.
|
||||
- **SHORT**: SMA(10) crossunder SMA(30) AND realized_vol(20) > 0.3% AND RSI(14) > 55.
|
||||
- **EXIT**: (RSI > 70 AND close crossover SMA(50)) OR realized_vol < 0.1%.
|
||||
|
||||
**Lettura**: trend-following SMA-cross modulato da filtro volatilità (entra solo in regimi con volatilità sopra soglia, esce in regime troppo calmo) e momentum RSI come confirmation/contrarian. Pattern economicamente plausibile, non casuale. 33 trade su 2 anni = uno ogni 22 giorni, sample size modesto ma coerente con strategia trend-following.
|
||||
|
||||
Sharpe 0.381 è positivo ma modesto. Top-2 ed altri top hanno solo 1 trade ("lucky shot" non flaggato come HIGH dall'Adversarial).
|
||||
|
||||
### 4.4 Diversità apparente vs reale
|
||||
|
||||
I top-2 hanno fitness e metriche identiche (0.3347 fit, DSR 0.0021, Sharpe 0.381, max_dd 0.0215, 33 trade). Possibile che siano elite duplicati nelle generazioni successive oppure due genomi distinti che hanno convergencе sulla stessa strategia. Verifica per Phase 2: cluster signal correlation fra top-K e contare specie effettive.
|
||||
|
||||
---
|
||||
|
||||
## 5. Author pass — conclusione
|
||||
|
||||
**Esito complessivo author pass**: ✅ **PASS** su tutti 5 hard gate.
|
||||
|
||||
**Decisione raccomandata dall'autore**: **GO Phase 2** con tre aggiustamenti consigliati:
|
||||
|
||||
1. **Adversarial layer più severo su overtrading/undertrading**: 42.9% di overtrading silenzioso è scope creep di problemi reali. Soglia overtrading da `n_bars/5` a `n_bars/20`; undertrading da `<5 trade` a `<10 trade su training`.
|
||||
|
||||
2. **Speciation in Phase 2**: cognitive style scendono da 6 a 3 a gen 9. Aggiungere protezione esplicita per specie (≥2 specie minimo, ognuna con quota tournament protetta) per evitare monocoltura ai stili dominanti.
|
||||
|
||||
3. **OOS walk-forward critico**: Phase 1 era in-sample. Tutti i top genomi vanno ri-valutati su hold-out 2026 prima di assegnare fitness in Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 6. Review pass — red team adversarial
|
||||
|
||||
**Modalità review pass**: subagent red-team self-review da parte dell'autore (Adriano Dal Pastro) + co-author Claude Opus 4.7. Fresh-eyes 24h non applicato data l'urgenza di chiudere Phase 1.
|
||||
|
||||
**Critiche strutturate**:
|
||||
|
||||
1. **Cherry-picking**: dei 5 run, 1 ha passato i gate (v5). Il fatto che siano serviti 4 cicli di bug-fix prima del PASS è LEGITTIMO bug-fixing di un sistema nuovo (parse/grammar/fitness math). NON è cherry-picking di seed o config: gli stessi `--seed 42 --population-size 20 --n-generations 10` hanno girato in tutti i run. Cherry-picking sarebbe stato escludere v4 (FAIL) dall'analisi: v4 è citato esplicitamente in §3.
|
||||
|
||||
2. **Statistical robustness**: il DSR è calcolato correttamente (Bailey & López 2014 implementation in `metrics/dsr.py`) con `n_trials=50` per Bonferroni-equivalent deflation. Tuttavia il top-1 ha DSR 0.0021 → praticamente zero significatività. La fitness 0.3347 viene dal contributo `tanh(sharpe)` non da DSR. **Implicazione**: il "successo" del Gate 3 è guidato da Sharpe non da DSR. Non è un PASS spurio (la fitness è ben definita), ma il segnale alpha vero (DSR) è marginale.
|
||||
|
||||
3. **Overfitting in-sample**: tutto il backtest è sullo stesso range 2024-2026. Il top-1 ha Sharpe 0.38 in-sample. Quanto sopravvive in OOS? Sconosciuto. Phase 2 deve misurare gap in-sample/OOS prima di trarre conclusioni alpha-related.
|
||||
|
||||
4. **Trade frequency sospetta nei top**: top-3, top-4, top-5 hanno 1 trade ognuno. Fitness 0.18-0.25 per "una posizione lucky" è artefatto della fitness function continua (sharpe positivo o leggermente negativo + dd minimo). Adversarial undertrading è MEDIUM non HIGH → non killato. Phase 2 deve promuovere undertrading a HIGH quando `n_trades < 10`.
|
||||
|
||||
5. **Cost trap inverso**: $0.069 è ridicolmente basso. Tentazione di Phase 2 di scalare drasticamente (K=100, gen=30, tutto tier B). Resistere: rispetto al cap Phase 2 $700-1100, una 10x dell'attuale = $0.69 ancora trascurabile, ma con tier B (3/15 vs 0.40/0.40) = $7-15 = serio scaling. Disciplina budget Phase 2 invariata.
|
||||
|
||||
**Contro-evidenze raccolte / fix applicati**:
|
||||
- Punto 2 (DSR marginale): documentato esplicitamente. Phase 2 può introdurre `dsr_weight` più alto nella fitness se si vuole pesare la significatività statistica sopra il puro Sharpe.
|
||||
- Punto 4 (undertrading): aggiunto a "aggiustamenti raccomandati" sez. 5.
|
||||
- Punto 3 (OOS): aggiunto a "aggiustamenti raccomandati" sez. 5.
|
||||
|
||||
---
|
||||
|
||||
## 7. Decisione finale
|
||||
|
||||
**Decisione**: ✅ **GO Phase 2** con scope identico allo spec strategico (sez. 5) e tre aggiustamenti integrativi:
|
||||
|
||||
1. Adversarial layer: overtrading/undertrading soglie più stringenti.
|
||||
2. Speciation di base: protezione cognitive style minimum-2 con quota tournament.
|
||||
3. Walk-forward 70/30 con hold-out Q1-Q2 2026 intoccabile.
|
||||
|
||||
**Razionale finale**: tutti i 5 hard gate sono passati con margini ampi su 4/5 (entropy, parse, cost, top-vs-median), margine sufficiente su gate 1 (3 gen di crescita iniziale). Le critiche red team identificate sono incorporate come aggiustamenti Phase 2, non blocker. Il codebase è robusto, modulare, testato (141 PASSED, ruff/mypy strict clean), pronto per estensione.
|
||||
|
||||
**Spesa Phase 1 vs cap**: $0.19 vs $700 cap = 0.027% utilizzato. Margine drammatico per Phase 2.
|
||||
|
||||
**Tempo Phase 1 vs cap**: 1 giorno calendar (vs 4-6 settimane stimati). Velocità da PoC singolo autore + LLM-assisted coding, non scalabile a Phase 2 che ha lavoro di research integrate (DSR multi-testing rigoroso, walk-forward, RF baseline).
|
||||
|
||||
**Documenti correlati prodotti**:
|
||||
- `docs/reports/2026-05-10-phase1-technical-report.md` (report tecnico)
|
||||
- `docs/superpowers/specs/2026-05-09-decisione-strategica-design.md` (spec strategico — sez. 5 contiene scope Phase 2)
|
||||
- `docs/superpowers/plans/2026-05-09-phase1-lean-spike.md` (plan implementativo Phase 1)
|
||||
|
||||
**Prossimi step suggeriti**:
|
||||
1. Aggiornare lo spec strategico con esito Phase 1 (sez. 11 "decisioni risolte").
|
||||
2. Avviare il design di Phase 2 (subagent `superpowers:writing-plans` su un nuovo spec Phase 2 che integra i 3 aggiustamenti).
|
||||
3. Eseguire i 3 aggiustamenti come piccoli fix Phase 1.5 (Adversarial soglie, speciation, walk-forward), poi run di smoke Phase 1.5 per confermare effetto.
|
||||
|
||||
---
|
||||
|
||||
*Memo finalizzato 10 maggio 2026. Versione 1.0.*
|
||||
@@ -0,0 +1,117 @@
|
||||
# Phase 1.5 — Run nemotron tier C — Decision Memo
|
||||
|
||||
**Data**: 11 maggio 2026
|
||||
**Run di riferimento**: `phase1.5-nemotron-001` (id `434c417e2b6f42bb8cf32514e5d0db1d`)
|
||||
**Tier LLM**: C → `nvidia/nemotron-3-super-120b-a12b:free`
|
||||
**Durata wallclock**: 2 h 26 min (08:15 → 10:11 UTC, gen 0 → gen 9)
|
||||
**Spesa totale**: $0.1244 (price-table tier C; il modello effettivo è `:free` su OpenRouter, ma il cost tracker applica la pricing nominale del tier)
|
||||
**Status**: ✅ Completato, ma esito strategico **NO-GO** sulla configurazione corrente
|
||||
|
||||
---
|
||||
|
||||
## 1. Premessa
|
||||
|
||||
Il run `phase1.5-nemotron-001` è la prima esecuzione end-to-end del loop GA con:
|
||||
|
||||
- l'Adversarial layer aggiornato in Phase 1.5 (commits `56a631f` + `d3662f6`), con tre nuovi check HIGH (`flat_too_long`, `fees_eat_alpha`, `time_in_market_too_high`) più i due esistenti rinforzati;
|
||||
- il tier C ribindato a `nvidia/nemotron-3-super-120b-a12b:free`, modello scelto in benchmark contro sette alternative per stabilità JSON e costo nullo;
|
||||
- il fix `EmptyCompletionError` su `llm/client.py` (commit `9d0deb3`) introdotto durante la stessa sessione per gestire le risposte vuote che alcuni provider `:free` ritornano sporadicamente.
|
||||
|
||||
L'obiettivo dichiarato del run era verificare se il nuovo budget di vincoli adversarial — più stretto del v5 — fosse compatibile con la capacità generativa di nemotron, e se la popolazione riuscisse a esplorare una zona di fitness positiva non degenere.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hard gate Phase 1 — ripercorrenza
|
||||
|
||||
I 5 hard gate originali (definiti nello spec strategico di Phase 1) sono stati rivalutati su questo run come sanity check, non come passaggio formale di gate.
|
||||
|
||||
| # | Gate | Soglia | Misura | Esito |
|
||||
|---|------|--------|--------|-------|
|
||||
| 1 | Loop converge | mediana cresce ≥3 gen consecutive | Gen 0→8: median oscilla tra 0.0 e 0.0073 senza crescita strutturale | ❌ FAIL |
|
||||
| 2 | Parse success | ≥80% proposte LLM parse-OK | 81/89 = **91.0%** | ✅ PASS |
|
||||
| 3 | Top-5 ratio | top-5 fitness ≥10× mediana | top-5 = 0.0162–0.0215; mediana ≈ 0 → ratio indefinito | ⚠️ N/A |
|
||||
| 4 | Entropy | ≥0.5 a fine run | 0.845 alla gen 9 | ✅ PASS |
|
||||
| 5 | Budget | costo ≤ cap | $0.1244 vs cap $700 (0.02%) | ✅ PASS |
|
||||
|
||||
Il gate critico è il numero 1. La popolazione non converge: il `max_fitness` resta inchiodato a `0.0215` dalla generazione 0 fino alla 9, segnale che l'elite preservation cattura un singolo genoma poco peggiore degli altri ma altrettanto inadatto, mentre il resto della popolazione non riesce a superarlo. La mediana è zero in 9 generazioni su 10 (singolo picco a 0.0073 in gen 8).
|
||||
|
||||
---
|
||||
|
||||
## 3. Lettura dei top genomi
|
||||
|
||||
I cinque genomi a fitness più alta hanno tutti caratteristiche economicamente disastrose:
|
||||
|
||||
| Genome ID | Fitness | DSR | Sharpe | Total return | n_trades |
|
||||
|-----------|---------|-----|--------|--------------|----------|
|
||||
| `0e1f9d7af25cfd6a` | 0.0215 | 0.000 | −1.083 | −115.9% | 385 |
|
||||
| `85a8116ab2cd2735` | 0.0215 | 0.000 | −1.083 | −115.9% | 385 |
|
||||
| `92aae563277b6f21` | 0.0193 | 0.000 | −1.129 | −131.0% | 597 |
|
||||
| `01d0ca99bbdd7320` | 0.0180 | 0.000 | −1.112 | −131.7% | 602 |
|
||||
| `194b096f7edab53c` | 0.0162 | 0.000 | −1.154 | −150.7% | 369 |
|
||||
|
||||
Il fatto che **DSR sia zero per tutti i top-5** indica che nessuna strategia passa il deflation test di Bailey & López 2014: il loop non sta generando proposte con edge statistico anche solo apparente. Il valore di fitness positivo che li seleziona deriva interamente dal termine `tanh(sharpe) × penalty(dd)` della fitness v1, che resta debolmente non nullo anche per Sharpe negativi grazie alla penalty di drawdown e a saturazioni numeriche. I primi due genomi hanno fitness identico a 0.0215 e total return identico — verosimilmente lo stesso elite riproposto a generazioni adiacenti.
|
||||
|
||||
---
|
||||
|
||||
## 4. Adversarial findings — il sistema fa il suo lavoro
|
||||
|
||||
Il layer Adversarial Phase 1.5 ha emesso 98 finding sul run:
|
||||
|
||||
| Severità | Check | Conteggio |
|
||||
|----------|-------|-----------|
|
||||
| HIGH | `fees_eat_alpha` (nuovo P1.5) | 35 |
|
||||
| MEDIUM | `overtrading` | 19 |
|
||||
| HIGH | `no_trades` | 16 |
|
||||
| HIGH | `flat_too_long` (nuovo P1.5) | 15 |
|
||||
| HIGH | `time_in_market_too_high` (nuovo P1.5) | 8 |
|
||||
| HIGH | `undertrading` | 4 |
|
||||
| HIGH | `degenerate` | 1 |
|
||||
|
||||
Il dato saliente è che i tre check introdotti in Phase 1.5 — `fees_eat_alpha`, `flat_too_long`, `time_in_market_too_high` — sono effettivamente attivi e killano strategie. In particolare `fees_eat_alpha` è la categoria più popolata: 35 occorrenze HIGH. Esempi tipici dai detail dei finding:
|
||||
|
||||
- `Fees $17073.82 = 2032.6% of gross $840.00`;
|
||||
- `Fees $70646.03 = 12671.9% of gross $557.50`;
|
||||
- `Signal flat for 98.8% of bars (>95% threshold)`.
|
||||
|
||||
Il messaggio è netto: il pool di strategie generato da nemotron, ai prompt e ai gradi di libertà attuali, oscilla tra due estremi degeneri — strategie inattive (flat 98%+) e strategie iperattive (overtrading + fee che divorano l'alpha lordo). Phase 1.5 cattura entrambi gli estremi, ma il loop GA non ha materiale di partenza sano da cui evolvere.
|
||||
|
||||
---
|
||||
|
||||
## 5. Decisione
|
||||
|
||||
**Esito**: NO-GO sulla combinazione `tier C = nemotron` + `Phase 1.5 adversarial` come configurazione di Phase 2.
|
||||
|
||||
Le ragioni a supporto della decisione sono tre.
|
||||
|
||||
Primo, la convergenza è assente per nove generazioni consecutive, non un plateau di selezione raggiunto dopo una fase di salita. Non si tratta cioè di un loop che ha già trovato il suo ottimo e lo conserva, ma di un loop che non ne ha trovato uno.
|
||||
|
||||
Secondo, la distanza dal baseline Phase 1 v5 è di un ordine di grandezza: max fitness `0.0215` qui contro `0.3347` nel run di gate Phase 1, mediana che oscilla sullo zero contro una mediana attorno a `0.005`–`0.09`. Nemotron, in questa configurazione, sta producendo proposte qualitativamente più povere di qwen-2.5-72b nello stesso schema operativo.
|
||||
|
||||
Terzo, i finding adversarial non puntano a un bug del sistema ma a una mancanza di edge nelle proposte. Il loop sta sanzionando correttamente — il problema è a monte, nella generazione.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tre direzioni per Phase 2
|
||||
|
||||
Tre opzioni si configurano per il passo successivo. Vanno valutate prima di una nuova esecuzione, non in parallelo a essa.
|
||||
|
||||
**Direzione A — Riportare tier C a `qwen/qwen-2.5-72b-instruct`** (configurazione di gate Phase 1). Il run di riferimento `phase1-real-005` è già un baseline noto: max fitness `0.3347`, top genome problematico (flat 99.8%) ma generato sotto Phase 1 adversarial. Rilanciare lo stesso pool con Phase 1.5 adversarial isolerebbe l'effetto del solo hardening sul medesimo motore generativo, senza confondere variabili. Questo è il percorso più informativo nel breve.
|
||||
|
||||
**Direzione B — Mantenere nemotron ma rilassare i prompt di Hypothesis**. L'ipotesi alternativa è che il prompting attuale, calibrato su qwen, sia troppo terso o troppo vincolato per la modalità di ragionamento di nemotron. Iterare due o tre versioni del prompt — più esempi few-shot, vincoli espliciti su `n_trades` minimo e `time_in_market` target — può cambiare radicalmente la qualità dell'output senza cambiare il modello.
|
||||
|
||||
**Direzione C — Sostituire il tier C con un modello a pagamento di fascia comparabile**. Tra i benchmark precedenti, `deepseek/deepseek-v4-flash` è già usato come tier A/B nel file `.env`; promuoverlo a tier C significa accettare una spesa marginale (stima $1–3 per run di 10 gen × 20 pop) in cambio di una qualità generativa nota.
|
||||
|
||||
La preferenza dell'operatore per modelli cost-conscious orienta verso A o B. La direzione C resta utile come benchmark di controllo se A e B fallissero a loro volta.
|
||||
|
||||
---
|
||||
|
||||
## 7. Operazioni di pulizia eseguite contestualmente
|
||||
|
||||
- Il run zombie `phase1-real-008` (id `6ebcff9f7f6544c18ced50313cf72ca9`, marcato `running` da 07:11 UTC senza processo associato) è stato chiuso a `status='failed'` direttamente in `runs.db`, per evitare contaminazione delle query di dashboard.
|
||||
- Il commit `9d0deb3` (`fix(llm): handle empty completions + missing usage`) è già su `main`. Il `client.py` ora tratta `resp.choices == []` e `resp.usage is None` come errori retryable invece che assertion failure: precondizione necessaria per qualsiasi run successivo su provider `:free`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Note per chi legge
|
||||
|
||||
Questo memo è un documento di decisione, non un rapporto tecnico completo. Il rapporto tecnico esteso del run può essere ricostruito da `runs.db` interrogando le tabelle `runs`, `generations`, `evaluations`, `adversarial_findings`, `cost_records` con `run_id='434c417e2b6f42bb8cf32514e5d0db1d'`. Il design Phase 1.5 e le motivazioni delle soglie adversarial restano definiti nel commit `56a631f` e nei suoi file di test.
|
||||
@@ -0,0 +1,282 @@
|
||||
# Phase 1 Lean Spike — Rapporto Tecnico
|
||||
|
||||
**Autore**: Adriano Dal Pastro
|
||||
**Data**: 10 maggio 2026
|
||||
**Versione**: 1.0 (finalizzato)
|
||||
**Status**: ✅ Phase 1 chiusa, tutti 5 hard gate passati
|
||||
|
||||
**Documenti correlati**:
|
||||
- `docs/superpowers/specs/2026-05-09-decisione-strategica-design.md` (decisione strategica B3)
|
||||
- `docs/superpowers/plans/2026-05-09-phase1-lean-spike.md` (piano implementativo)
|
||||
- `docs/decisions/2026-05-10-gate-phase1.md` (decision memo finale)
|
||||
|
||||
---
|
||||
|
||||
## 1. Setup sperimentale
|
||||
|
||||
L'obiettivo della Phase 1 lean spike è dimostrare che il loop tecnico (LLM hypothesis → backtest falsification → adversarial check → GA selection) funziona end-to-end e produce output formalizzabile. I cinque hard gate definiti nello spec sez. 4.4 misurano feasibility, non alpha edge — quella è valutazione di Phase 2.
|
||||
|
||||
### 1.1 Configurazione del run di riferimento
|
||||
|
||||
Il run `phase1-real-005` (id `1c526996160446b18c0fb57d94874975`) è il primo a superare tutti i gate dopo 4 iterazioni di bug-fix (vedi sez. 3 del decision memo).
|
||||
|
||||
| Parametro | Valore |
|
||||
|---|---|
|
||||
| Population size (K) | 20 |
|
||||
| Generazioni | 10 |
|
||||
| Elite k | 2 |
|
||||
| Tournament k | 3 |
|
||||
| Crossover probability | 0.5 |
|
||||
| Random seed | 42 |
|
||||
| Symbol | BTC-PERPETUAL (Deribit) |
|
||||
| Timeframe | 1h |
|
||||
| Range storico | 2024-01-01 → 2026-01-01 (2 anni, 17545 candele) |
|
||||
| Fees backtest | 5 basis points |
|
||||
| n_trials_dsr | 50 |
|
||||
| Tier LLM dominante | C (qwen3-235b-a22b-2507 via OpenRouter) |
|
||||
| Cerbero MCP endpoint | http://localhost:9001 (locale) |
|
||||
| Durata wall-clock | 29 minuti |
|
||||
| Costo LLM | $0.069 |
|
||||
|
||||
### 1.2 Stack tecnologico
|
||||
|
||||
Python 3.13, uv 0.10.9. Test framework: pytest + pytest-mock + responses. Persistence: sqlite3 + sqlmodel. Parsing strategia: `json.loads` con dataclass-based AST. Analytics: pandas + numpy + scipy. LLM: openai SDK con base URL OpenRouter (route unica per tutti i tier S/A/B/C/D). HTTP: requests + tenacity. Dashboard: streamlit + plotly + canvas HTML5 custom.
|
||||
|
||||
### 1.3 Architettura del run
|
||||
|
||||
L'orchestrator (`src/multi_swarm/orchestrator/run.py`, 184 righe) coordina la pipeline end-to-end:
|
||||
|
||||
1. **OHLCV loading**: `CerberoOHLCVLoader` chiama `mcp-deribit/tools/get_historical` paginando in chunk da 4500 barre (cap soft Deribit ~5000). Cache parquet su sha1 della query — il run v5 ha riusato cache popolata dai run precedenti, fetch istantaneo.
|
||||
2. **Market summary**: statistiche return (mean, std, skew, kurt) + classificazione regime volatilità.
|
||||
3. **Initial population**: 20 genomi distribuiti uniformemente sui 6 cognitive style (physicist, biologist, historian, meteorologist, ecologist, engineer), temperature random in [0.7, 1.2], lookback random in {100, 150, 200, 300}.
|
||||
4. **Per ogni generazione (10 totali)**:
|
||||
- **Hypothesis**: chiamata LLM con prompt SYSTEM (regole grammar) + USER (market summary). Output JSON estratto via regex fence ```json. Se parse/validation fallisce: retry 1x con error message nel prompt utente.
|
||||
- **Falsification**: AST compilato in `Callable[[df], Series[Side]]`, backtest event-driven con 1-bar exec delay, calcolo Sharpe + Deflated Sharpe (Bailey & López 2014, n_trials=50).
|
||||
- **Adversarial**: 4 check euristici (no_trades, degenerate, overtrading, undertrading).
|
||||
- **Fitness**: `0.5*dsr + 0.25*(tanh(sharpe)+1)` × `1/(1+max_dd)`, range [0, ~1]. Kill (=0) su zero trade o HIGH adversarial finding.
|
||||
- **Next generation**: elitism 2 + tournament 3 + 50% crossover / 50% mutation.
|
||||
5. **Persistence SQLite**: ogni genome, evaluation, cost_record, adversarial_finding, generation summary persistito con indici per query rapide della dashboard.
|
||||
|
||||
### 1.4 Caveat metodologici noti
|
||||
|
||||
- **In-sample**: il backtest in Phase 1 lean spike non usa walk-forward; tutto il range 2024-2026 viene usato sia per la generazione delle ipotesi sia per la loro valutazione. La sopravvivenza out-of-sample è esplicitamente fuori scope di Phase 1 (gate Phase 2 #2).
|
||||
- **Compiler con indicatori built-in**: il compiler JSON-based (`src/multi_swarm/protocol/compiler.py`) calcola RSI, SMA, ATR, MACD, realized_vol localmente con pandas. `CerberoTools` è plumbed ma non chiamato durante l'esecuzione delle strategie — è disponibile per agenti future-tense ma il fitness Phase 1 dipende solo dagli indicatori locali.
|
||||
- **RSI epsilon-floor**: il compiler ha un epsilon sul `roll_down` per evitare RSI=100 esatto su serie monotonicamente crescenti (artefatto matematico irrilevante su dati reali ma documentato).
|
||||
- **Top-1 strategia con DSR marginale**: vedi sez. 3.
|
||||
|
||||
---
|
||||
|
||||
## 2. Loop convergence
|
||||
|
||||
### 2.1 Fitness per generazione
|
||||
|
||||
| Gen | Median | Max | 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 |
|
||||
|
||||
### 2.2 Lettura
|
||||
|
||||
**Convergenza tre-step iniziale**: gen 0→1→2 mostra crescita mediana 4x-50x (0.0001 → 0.0042 → 0.0188) e crescita max 3x-6x (0.06 → 0.19 → 0.33). Gate 1 PASS su questa finestra.
|
||||
|
||||
**Plateau dell'elite da gen 2**: max stabile a 0.3347 per le restanti 7 generazioni — comportamento atteso con `elite_k=2` che preserva il top performer attraverso le generazioni. P90 si allinea al max da gen 3, segno che almeno 2 elite mantengono la top fitness.
|
||||
|
||||
**Median oscillante**: dopo il picco a gen 4 (0.091), la median fluttua fra 0.0016 e 0.0151 nelle generazioni successive. Causa: turnover stocastico della popolazione (mutation + crossover) introduce genomi nuovi, alcuni dei quali parse correctly ma falliscono Adversarial (no_trades) e si attestano a fitness 0, abbassando la median. Non è regressione strutturale del GA.
|
||||
|
||||
**Entropy**: oscilla 0.6-1.4 dopo gen 0, sempre sopra soglia 0.5 → diversità di fitness preservata anche durante plateau dell'elite.
|
||||
|
||||
---
|
||||
|
||||
## 3. Top-5 genomi: ispezione qualitativa
|
||||
|
||||
| Rank | Genome ID | Gen | Style | Fitness | DSR | Sharpe | Max DD | Trades | Temp |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | `696052b8...` | 2 | physicist | 0.3347 | 0.0021 | 0.381 | 0.0215 | 33 | 0.68 |
|
||||
| 2 | `169376a2...` | 1 | engineer | 0.3347 | 0.0021 | 0.381 | 0.0215 | 33 | 0.78 |
|
||||
| 3 | `eb0265ad...` | 3 | ecologist | 0.2453 | 0.0006 | −0.019 | 0.0011 | 1 | 1.14 |
|
||||
| 4 | `38d4c1d9...` | 1 | engineer | 0.1893 | 0.0001 | −0.245 | 0.0028 | 1 | 0.82 |
|
||||
| 5 | `3e355975...` | 1 | physicist | 0.1893 | 0.0001 | −0.245 | 0.0028 | 1 | 0.78 |
|
||||
|
||||
### 3.1 Top-1 strategia (ispezione approfondita)
|
||||
|
||||
**System prompt** (engineer): *"Cerca segnali con rapporto S/N favorevole, filtri causali, robustezza a perturbazioni di calibrazione."*
|
||||
|
||||
**Strategia JSON** (3 regole, evaluation in ordine):
|
||||
|
||||
- **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(14) > 70` AND `close crossover SMA(50)`) OR `realized_vol(20) < 0.1%`.
|
||||
|
||||
**Lettura economica**: trend-following SMA-cross fast/slow modulato da filtro volatilità (entra solo quando il regime è abbastanza mosso, esce quando è troppo calmo) e filtro RSI come momentum confirmation (long solo se non già ipercomprato; short solo se non già ipervenduto). L'EXIT è sofisticato: esce su overbought confermato da break sopra MA50, OPPURE su collasso di volatilità.
|
||||
|
||||
**Performance**: 33 trade su 17545 candele (1 trade ogni 532 candele = 1 ogni 22 giorni). Sharpe positivo modesto, max drawdown 2.15% (basso). DSR praticamente zero (0.0021) — il segnale non è statisticamente significativo dopo correzione multiple testing, perché 33 trade su 2 anni è sample piccolo.
|
||||
|
||||
**Plausibilità**: pattern economicamente sensato, non casuale. Reminiscente di strategie trend-following classiche (Donchian, turtle-style) con filtri di regime. Lo stile cognitivo "engineer" (S/N favorable, filtri causali) si riflette nella struttura.
|
||||
|
||||
### 3.2 Top-2/3/4/5 brevemente
|
||||
|
||||
- Top-2 è una replica funzionale di Top-1 con metriche identiche. Plausibile elite duplicato o convergenza indipendente sulla stessa strategia (verifica per Phase 2: signal correlation fra duplicati).
|
||||
- Top-3, 4, 5 hanno **1 trade ciascuno** su 2 anni. Sono "lucky shot": una posizione tenuta a lungo che casualmente termina con leggera vincita. Adversarial flagga MEDIUM `undertrading` ma non HIGH, quindi sopravvivono. La fitness function continua dà loro valore non-zero perché `tanh(sharpe)` è leggermente sopra 0.5 e penalty drawdown è quasi 1.0 (max_dd <0.5%).
|
||||
|
||||
### 3.3 Ratio top-1 / median
|
||||
|
||||
Median fitness su 98 evals: 0.0003.
|
||||
Top-1 fitness: 0.3347.
|
||||
**Ratio**: 1116x — Gate 3 soddisfatto con margine drammatico (soglia 1.5x).
|
||||
|
||||
---
|
||||
|
||||
## 4. Parser failure modes
|
||||
|
||||
### 4.1 Statistiche aggregate v5
|
||||
|
||||
- Evaluations totali: 98
|
||||
- Parse success: **98 (100.0%)**
|
||||
- Parse failure: **0 (0.0%)**
|
||||
|
||||
### 4.2 Confronto con iterazioni precedenti
|
||||
|
||||
| Run | Grammar | Parse success | Note |
|
||||
|---|---|---|---|
|
||||
| v1 | S-expression | 33% | LLM nesta indicators non supportati |
|
||||
| v4 | S-expression (con arity check post-fix) | 36% | 89 di 98 errori = `indicator nested` |
|
||||
| v5 | **JSON Schema** | **100%** | Refactor commit `44eb643` |
|
||||
|
||||
Il salto da 36% a 100% deriva interamente dal cambio di grammar. JSON è natively supported dal training dei modelli LLM moderni; S-expression è esotica e induce hallucination di sintassi creative.
|
||||
|
||||
### 4.3 Retry-with-feedback (commit `d4fcb42`)
|
||||
|
||||
Il sistema accetta 1 retry con error feedback. Nel run v5 il retry **non è mai stato usato** (zero retry per parse, dato il 100% di success). Il retry rimane comunque architetturalmente presente per Phase 2 / casi edge.
|
||||
|
||||
---
|
||||
|
||||
## 5. Costi reali vs preventivo
|
||||
|
||||
### 5.1 Breakdown costi LLM v5
|
||||
|
||||
| Tier | Calls | Input tokens | Output tokens | Cost USD |
|
||||
|---|---|---|---|---|
|
||||
| C (qwen3-235b) | 113 | 112369 | 60060 | $0.069 |
|
||||
|
||||
### 5.2 Costo cumulativo Phase 1 (5 run, inclusi bug-fix iterations)
|
||||
|
||||
| Run | Cost | Note |
|
||||
|---|---|---|
|
||||
| v1 (aborted) | $0.034 | 67% parse_error, max_dd bug |
|
||||
| v2 (aborted) | $0.018 | macd 3 args, OHLCV cap discovery |
|
||||
| v3 (aborted) | $0.015 | crash su indicator arity |
|
||||
| v4 (completed FAIL) | $0.057 | 36% parse, fitness tutti 0 |
|
||||
| v5 (completed PASS) | $0.069 | tutti gate passati |
|
||||
| **Totale Phase 1** | **$0.193** | — |
|
||||
|
||||
### 5.3 Confronto con preventivo
|
||||
|
||||
- Preventivo originale (basato su pricing Anthropic Sonnet): $500-700.
|
||||
- Spesa reale Phase 1 totale: **$0.19**.
|
||||
- Deviazione: −99.97%.
|
||||
|
||||
La differenza non è dovuta a underuse — il run v5 ha fatto 113 chiamate LLM = full saturazione del budget previsto di calls. È un cambio di ordine di grandezza nei prezzi dovuto al pricing aggressivo di OpenRouter per modelli open-weights (qwen3-235b è 7.5x più economico di Sonnet su input, 37x su output). Il preventivo originale era calibrato su Sonnet 4.6.
|
||||
|
||||
### 5.4 Implicazioni per Phase 2
|
||||
|
||||
Il margine economico permette di pianificare Phase 2 con maggiore aggressività senza superare il cap ($700-1100):
|
||||
- K=40 (×2), gen=15 (×1.5), tier mix 30% B / 70% C, ablation runs multiple.
|
||||
- Estrapolazione lineare conservativa: $0.07 × 2 × 1.5 × ~3 (tier B factor) × 5 (ablation) = ~$3 totali. Possibile spingere a $30-50 senza preoccupazioni se serve per ablation più ricche.
|
||||
|
||||
**Rischio cost-trap inverso**: tentazione di sovra-dimensionare Phase 2 perché "tanto costa nulla". Mantenere disciplina budget invariata — investire i $700 cap in PIÙ ablation, non in run più grandi.
|
||||
|
||||
---
|
||||
|
||||
## 6. Diversity metrics
|
||||
|
||||
### 6.1 Entropy fitness per generazione
|
||||
|
||||
Vedi tabella sez. 2.1 colonna entropy. Mai sotto 0.5, picco a gen 4 (1.415).
|
||||
|
||||
### 6.2 Cognitive style sopravvissuti gen 9
|
||||
|
||||
| Stile | Count gen 9 | Avg fitness | Note |
|
||||
|---|---|---|---|
|
||||
| engineer | 3 | 0.0 | Dominante numericamente ma fitness 0 (genomi recent, non valutati su elite) |
|
||||
| physicist | 1 | 0.0598 | Solo presente nel top-K |
|
||||
| historian | 1 | 0.0002 | — |
|
||||
| biologist | 0 | — | Estinto |
|
||||
| meteorologist | 0 | — | Estinto |
|
||||
| ecologist | 0 | — | Estinto |
|
||||
|
||||
**Lettura**: pressione selettiva ha eliminato 3 di 6 stili cognitivi alla generazione finale. Engineer è dominante numericamente, physicist domina nel valore (l'unico con fitness >0 della popolazione "live" gen 9). Phase 2 deve introdurre speciation esplicita per evitare questo collasso (minimum 2-3 specie protette).
|
||||
|
||||
### 6.3 Trade distribution sui 98 evals
|
||||
|
||||
| Categoria | n | % |
|
||||
|---|---|---|
|
||||
| Zero trade (HIGH no_trades, kill) | 42 | 42.9% |
|
||||
| Undertrading (1-4 trade, MEDIUM) | 5 | 5.1% |
|
||||
| Normal (5-100 trade) | 9 | 9.2% |
|
||||
| Overtrading (>100 trade, NON flaggato) | 42 | 42.9% |
|
||||
|
||||
**Issue identificato**: il 42.9% di overtrading non viene catturato dall'Adversarial perché la soglia attuale è `n_trades > n_bars/5 = 3509` — troppo alta per essere triggerata su 1000-2000 trade. Phase 2 dovrebbe abbassare a `n_bars/20 = 877` o usare metrica relativa al regime.
|
||||
|
||||
### 6.4 Adversarial findings totali
|
||||
|
||||
| Finding | Severity | Count |
|
||||
|---|---|---|
|
||||
| no_trades | HIGH | 42 |
|
||||
| undertrading | MEDIUM | 5 |
|
||||
|
||||
Niente `degenerate` né `overtrading` flaggato. Il primo è raro (richiede strategia sempre-LONG o sempre-SHORT puro), il secondo soffre della soglia troppo alta.
|
||||
|
||||
---
|
||||
|
||||
## 7. Threats to validity
|
||||
|
||||
Lista esplicita dei limiti metodologici da non sovra-interpretare:
|
||||
|
||||
1. **In-sample fitting**: tutto il backtest è in-sample. Il top-1 ha Sharpe 0.38 ottenuto guardando i dati su cui è stato selezionato. Phase 2 (walk-forward + hold-out Q1-Q2 2026 intoccabile) misura overfitting reale.
|
||||
2. **Tier C unico**: nessun confronto contro tier B/S. Possibile underperformance del LLM economico vs Sonnet/Opus. Phase 2 introduce ablation multi-tier.
|
||||
3. **Adversarial hand-crafted**: 4 check euristici (no_trades, degenerate, overtrading, undertrading). Phase 2 introduce 5 prompt LLM-driven dedicati (data snooping, lookahead, regime fragility, crowding, transaction cost erosion).
|
||||
4. **Fitness function v1**: lineare in DSR + tanh(Sharpe) normalizzato + drawdown moltiplicativa. Non multi-livello (per-team, anti-collusion). Phase 2 introduce.
|
||||
5. **No speciation, no novelty bonus**: cognitive style scendono da 6 a 3 a gen 9. Phase 2 deve mitigare.
|
||||
6. **DSR del top-1 = 0.0021**: il "successo" del Gate 3 è guidato da Sharpe (positivo modesto), non da significatività statistica vera. Senza walk-forward + multiple testing rigoroso, non si può affermare alpha edge.
|
||||
7. **Top-3/4/5 sono "lucky shot" 1-trade**: la fitness function continua li promuove perché drawdown bassissimo + sharpe leggermente negativo, ma sono artefatti. Phase 2 promuove undertrading a HIGH se `n_trades < 10`.
|
||||
8. **Cerbero/Deribit data quality**: nessuna detection di gap, outlier, exchange downtime. Da affrontare prima di forward-test (Phase 3).
|
||||
9. **Cost predictability inverso**: Phase 2 deve resistere alla tentazione di sovra-dimensionare perché Phase 1 è costata $0.19.
|
||||
|
||||
---
|
||||
|
||||
## 8. Conclusioni e implicazioni per Phase 2
|
||||
|
||||
**Hard gate sintesi**: ✅ 5 su 5 passati.
|
||||
|
||||
**Decisione finale**: **GO Phase 2** (formalizzata nel decision memo).
|
||||
|
||||
**Apprendimenti chiave per Phase 2**:
|
||||
|
||||
1. **JSON >> S-expression** per grammar LLM-generated. Phase 2 non rivisita.
|
||||
2. **Fitness continua è essenziale** per dare gradient al GA, ma può promuovere strategie degeneri (1-trade) che vanno killate diversamente.
|
||||
3. **OpenRouter qwen3-235b** è sorprendentemente capace per generare strategie strutturate, dato un prompt schema-rigoroso. Tier B (Sonnet) potrebbe non essere necessario al 30% come pianificato; ablation Phase 2 misurerà il vero contributo.
|
||||
4. **Cerbero MCP come single source of truth** funziona: paginazione, cache parquet, audit log integrati senza fragility.
|
||||
5. **Bug-fix discovery via run reale** è efficiente: 4 cicli, ognuno ha esposto un problema specifico (max_dd math, macd arity, validator arity, fitness clamp, grammar choice). Phase 2 può aspettarsi pattern simile per nuove componenti (speciation edge cases, OOS overfitting, multi-tier dispatch).
|
||||
|
||||
**Riusabilità del codebase Phase 1**: il design modulare (data, backtest, metrics, cerbero, protocol, genome, llm, agents, ga, persistence, orchestrator, dashboard) è riusabile direttamente. Estensioni Phase 2:
|
||||
- `ga/speciation.py` (nuovo) — clustering cosine similarity prompt, quota tournament per specie.
|
||||
- `ga/fitness.py` — versione v2 con novelty bonus + per-team aggregation.
|
||||
- `orchestrator/run.py` — integrazione walk-forward.
|
||||
- `agents/adversarial_llm.py` (nuovo) — 5 prompt LLM-driven.
|
||||
- `baseline/random_forest.py` (nuovo) — RF baseline per benchmark.
|
||||
|
||||
**Costo stimato Phase 2**: $3-15 (estrapolazione molto conservativa). Cap rimane $700-1100 invariato per disciplina.
|
||||
|
||||
**Tempo stimato Phase 2**: 4-6 settimane di lavoro calendar, includendo i 3 aggiustamenti del decision memo (Adversarial soglie, speciation, walk-forward).
|
||||
|
||||
---
|
||||
|
||||
*Documento finalizzato 10 maggio 2026. Versione 1.0.*
|
||||
@@ -0,0 +1,286 @@
|
||||
# Multi-Swarm Coevolutivo — Stato del progetto e roadmap
|
||||
|
||||
*Data del documento: 14 maggio 2026 — branch `main` allineato a commit `45f273f`.*
|
||||
|
||||
Questo documento riepiloga l'intero percorso del proof-of-concept Multi-Swarm Coevolutive dalla Phase 1 (lean spike) fino allo stato corrente di entrata in Phase 3 (paper-trading forward-test). È inteso come punto di sincronizzazione per riprendere il lavoro: cosa è stato deciso, cosa ha funzionato, cosa no, e quali sono le prossime mosse plausibili.
|
||||
|
||||
---
|
||||
|
||||
## 1. Quadro sintetico
|
||||
|
||||
| Fase | Periodo | Stato | Esito |
|
||||
|------|---------|-------|-------|
|
||||
| **Phase 1** — lean spike | 9-10 maggio 2026 | ✅ chiusa | GO Phase 2 (5/5 hard gate) |
|
||||
| **Phase 1.5** — adversarial hardening | 11 maggio 2026 | ✅ chiusa | NO-GO sulla combo nemotron, hardening conservato |
|
||||
| **Phase 2** — feature temporali + qwen3-235b | 11 maggio 2026 | ✅ chiusa | NO-GO sul modello (rollback a qwen-2.5-72b) |
|
||||
| **Phase 2.5** — LLM prompt mutator | 11-12 maggio 2026 | ✅ chiusa | Operator integrato, sweet spot weight 0.20-0.30 |
|
||||
| **Phase 2.6** — Walk-Forward Validation | 12-13 maggio 2026 | ✅ chiusa | WFA 70/30 introdotta, min-trades parametrico |
|
||||
| **Phase 2.7** — portabilità cross-asset (BTC/ETH/SOL) | 13 maggio 2026 | ✅ chiusa | BTC strong, ETH adequate, SOL failure |
|
||||
| **Phase 3** — paper-trading forward-test | 13-14 maggio 2026 | 🟢 in corso | Runner BTC+ETH operativo, smoke OK |
|
||||
|
||||
Dal punto di vista del DB locale: 30 run GA completate, costo cumulato LLM **≈ $3.74**, due paper-trading run avviati (`phase3-smoke-001`, `phase3-papertrade-001`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1 — lean spike (chiusa 10 maggio)
|
||||
|
||||
### Obiettivo
|
||||
Validare end-to-end l'idea co-evolutiva: GA → popolazione di prompt LLM → strategie JSON → backtest deterministico → fitness → selezione. Cinque hard gate vincolanti.
|
||||
|
||||
### Risultato
|
||||
Run di riferimento `phase1-real-005` su BTC-PERPETUAL Deribit 1h, 2024-01-01 → 2026-01-01, K=20, 10 generazioni, **costo $0.069 in 29 minuti**.
|
||||
|
||||
| Hard gate | Soglia | Misurato | Esito |
|
||||
|-----------|--------|----------|-------|
|
||||
| Loop convergence | median sale | 0.0001 → 0.0188 in 3 gen | ✓ |
|
||||
| Parse success | ≥ 95% | 100% (98/98) post refactor JSON | ✓ |
|
||||
| Top-5 vs median | ≥ 10× | 1116× | ✓ |
|
||||
| Entropy fitness gen 9 | ≥ 0.5 | 0.914 | ✓ |
|
||||
| Costo totale | ≤ $700 | $0.069 | ✓ |
|
||||
|
||||
Iterazione: 5 run prima del PASS, ognuna ha scoperto un bug strutturale (max_dd su equity assoluta, cap Cerbero 5000 candele, validator arity, switch grammar S-expr→JSON, fitness clip-to-0 troppo dura).
|
||||
|
||||
### Caveat critico
|
||||
Il top-1 ha reso **+2.66% in 2 anni vs B&H BTC +106%**, essendo *flat* nel 99,8% del tempo. Conferma che la fitness v1 premiava "non-strategie" sicure invece di alpha vero. Da qui la Phase 1.5.
|
||||
|
||||
### Documenti chiave
|
||||
- `docs/decisions/2026-05-10-gate-phase1.md` — decision memo.
|
||||
- `docs/reports/2026-05-10-phase1-technical-report.md` — report tecnico.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 1.5 — adversarial hardening (chiusa 11 maggio)
|
||||
|
||||
Quattro nuovi check `HIGH` aggiunti all'agente Adversarial per killare strategie degeneri:
|
||||
|
||||
1. `overtrading` ricalibrato `n_bars/20` (era `n_bars/5`).
|
||||
2. `undertrading` promosso a HIGH se `n_trades < 10`.
|
||||
3. `flat_too_long` (nuovo HIGH) — segnale flat > 95% bar.
|
||||
4. `fees_eat_alpha` (nuovo HIGH) — `fees / |gross_pnl| > 0.5` con gross positivo.
|
||||
5. `time_in_market_too_high` (nuovo HIGH) — segnale LONG||SHORT > 80% bar (kill leveraged-B&H camuffato).
|
||||
|
||||
**Run di test `phase1.5-nemotron-001`** (tier C nemotron, 2h26', $0.12) → **NO-GO**: max fitness 0.0215 stagnante, median 0 su 9 gen, top-5 con DSR=0 e Sharpe ≈ −1.1. I check Phase 1.5 funzionavano (98 findings emessi); il problema era il modello: prompt calibrato su qwen, nemotron produceva materiale qualitativamente più povero.
|
||||
|
||||
Bugfix collaterale (`9d0deb3`): `EmptyCompletionError` reso retryable + gestione `resp.usage=None` per provider `:free`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 2 — feature temporali + tier C qwen3-235b (chiusa 11 maggio)
|
||||
|
||||
Due lavori in parallelo, esiti opposti:
|
||||
|
||||
**4.1 Feature temporali in protocol layer** — `KNOWN_FEATURES` esteso con `hour`, `dow`, `is_weekend`, `minute_of_hour`. Compiler dispatcher temporale (`9d1f97c`), validator parametrizzato, integration test gating temporale+SMA. Few-shot example nel prompt Hypothesis. **Successo strutturale**: tutte le top strategie successive sfruttano questo asset.
|
||||
|
||||
**4.2 Upgrade tier C a `qwen/qwen-2.5-72b-instruct` → `qwen3-235b-a22b`** — run `phase2-qwen3-001`: max fitness 0.0238 stuck per 8 gen, entropy 0.199 stuck per 7 gen, 4 dei 5 top genomi con fitness/Sharpe/DD identici. Il **run controllo** identico ma con qwen-2.5-72b: 0.0311 (+30%), median raggiunge top in 4 gen, entropy 0.85, ½ tempo e costo. **Rollback a qwen-2.5-72b** (`8ec45c5`).
|
||||
|
||||
Lezione consolidata: il prompt è calibrato sulla famiglia qwen-2.5; un modello "più nuovo / più grande" non è automaticamente meglio se il prompt non viene ricalibrato in parallelo.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2.5 — operator `mutate_prompt_llm` (chiusa 12 maggio)
|
||||
|
||||
Quinto operatore di mutazione che riscrive il `system_prompt` via LLM tier B (`deepseek-v4-flash`) anziché perturbare scalari. Sei istruzioni atomiche: `tighten_threshold`, `swap_comparator`, `add_condition`, `remove_condition`, `change_timeframe`, `add_temporal_gate`. Validation gate (lunghezza ≥ 50, keyword tecnica, diff Levenshtein > 5%) + fallback `random_mutate`. Dispatcher pesato `weighted_random_mutate` (CLI `--prompt-mutation-weight`, default 0.0).
|
||||
|
||||
### Sweet spot empirico (seed 42, pop 20, 10 gen)
|
||||
|
||||
| weight | max fit | median fin | Sharpe top | trades | verdetto |
|
||||
|--------|---------|-----------|-----------|--------|----------|
|
||||
| 0.00 | 0.0311 | 0.0000 | −1.08 | 274 | baseline |
|
||||
| **0.30** | **0.1012** ⭐ | **0.0745** | **−0.25** | 62 | sweet spot (ma seed-lucky) |
|
||||
| 0.50 | 0.0311 | 0.0000 | −1.08 | 274 | regressione |
|
||||
|
||||
### Validazione robustezza
|
||||
Confronti seed multipli (7, 99, 123) hanno mostrato che il **+225%** del run 004 era **outlier seed-specific**. Beneficio medio reale del prompt-mutator: **+10–23%** sopra baseline. La leva più affidabile e seed-indipendente è risultata `fees_eat_alpha_threshold 0.7` (anziché 0.5): +23% stabile, Sharpe top −0.70 vs −1.08.
|
||||
|
||||
### Combo vincente (pop=30 + weight=0.30 + fees=0.7)
|
||||
Run `pop30-combo-001`: max fitness 0.0459 (+48% vs control), **median finale = max** (convergenza ≥50% pop), Sharpe top −0.63, 226 trades. Mutator overhead ≈ 5,4% del costo totale.
|
||||
|
||||
### Cost attribution (Task 6)
|
||||
`cost_records.call_kind` (`hypothesis` / `mutation`) attivo da `ba4eb09`. Permette breakdown costo per operatore: il prompt-mutator costa 3-9% del totale, trascurabile.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2.6 — Walk-Forward Validation (chiusa 13 maggio)
|
||||
|
||||
Aggiunte tre leve metodologiche:
|
||||
|
||||
- **WFA 70/30**: split temporale `train/OOS` con OOS intoccato durante GA, valutato solo a fine run.
|
||||
- **`--min-trades-threshold`** parametrico: filtra survivors con n_trades insufficiente prima del ranking.
|
||||
- **Fitness v2 soft-kill** (`cf42dd8`): solo `no_trades` + `degenerate` + `undertrading` azzerano hard. Altri HIGH applicano penalty moltiplicativa `1/(1+0.4·n)` (1 HIGH = 0,71×, 2 = 0,56×, 3 = 0,45×). CLI `--fitness-v2` + `--fitness-soft-penalty`.
|
||||
- **Pattern guidance nel system prompt** (`67ae6ff`): forma curve attese + criterio ripetibilità.
|
||||
- **Fitness multi-obiettivo** (`1a1dfb7`): `combined = α·IS + (1−α)·OOS` opt-in.
|
||||
|
||||
Effetto cumulativo: la pipeline produce strategie con migliore generalizzazione cross-split senza dover degradare le adversarial hard.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 2.7 — backtest 7 anni e portabilità cross-asset (chiusa 13 maggio)
|
||||
|
||||
### 7.1 Validazione 7,33 anni su BTC
|
||||
|
||||
Backtest dei top genome scoperti sulle varie sotto-fasi sui **64.297 bar 1h** completi (2018-09-01 → 2026-01-01), fees 5 bp:
|
||||
|
||||
| Genome | Origine | Total P/L 7y | CAGR | Sharpe ann | MaxDD | Verdetto |
|
||||
|--------|---------|--------------|------|-----------|-------|----------|
|
||||
| `5226503a` | run004 outlier 2y bull | **−310,69%** | wiped out | −0,155 | 280,9% | crash totale OOS |
|
||||
| `e52604ba` | flat-ablation top 2y | −37,17% | −6,14% | −0,063 | 182,0% | SMA non generalizza |
|
||||
| `ec06a3d4` | fitness-v2-combo top 2y | +142,51% | +12,85% | +0,229 | 64,9% | hour-gated regge |
|
||||
| `4e1be9fa` | 7y-v2-WFA top IS | +67,60% | +7,30% | +0,240 | 79,1% | top IS ingannevole |
|
||||
| `63411199` | 7y-v2-WFA top OOS | **+660,11%** | **+31,88%** | +0,238 | 77,1% | leveraged-B&H camuffato |
|
||||
| **`fb63e851`** ⭐ | 7y multi-seed99 top OOS | +130,37% | +12,06% | **+0,264** | **54,8%** | **true alpha** |
|
||||
|
||||
Conclusioni:
|
||||
- Il top by `fit_IS` è sistematicamente ingannevole su orizzonti lunghi.
|
||||
- Pattern SMA-puri collassano cross-regime.
|
||||
- Pattern *hour-gated* (filtri intraday) reggono cross-regime.
|
||||
- `fb63e851` è il candidato più robusto: 4 AND × 2 rule × filtro intraday → attiva l'1-2% del tempo, Sharpe cross-regime più alto.
|
||||
|
||||
### 7.2 Portabilità BTC → ETH → SOL
|
||||
|
||||
Tre run **identici** (`population=30`, `n_gen=10`, `prompt_mutation_weight=0.30`, fitness v2, WFA 70/30, `fees_eat_alpha_threshold=0.7`, undertrading 20) su Deribit perpetuals.
|
||||
|
||||
| Asset | Storia | Top OOS Sharpe | Verdetto |
|
||||
|-------|--------|----------------|----------|
|
||||
| **BTC** | 7,33 y | `fb63e851` +0,50 OOS (+20,16%) | **STRONG** |
|
||||
| **ETH** | 6,75 y | `facd6af85d5d` +0,19 OOS (+16,14%) | **ADEQUATE** |
|
||||
| **SOL** | 3,00 y | **0 survivors / 247 evals** | **FAILURE** |
|
||||
|
||||
Pattern scoperti **divergenti**: BTC = mean reversion intraday contrarian; ETH = trend-following long-bias + vol regime. **Non esiste "una strategia universale"**: la metodologia (GA + WFA + adversarial v2) è portabile, il pattern no. SOL ha fallito per finestra dati troppo corta (3y) e regime bull-only post-FTX.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 3 — paper-trading forward-test (in corso)
|
||||
|
||||
### Componenti implementati (`45f273f`)
|
||||
|
||||
Modulo `src/multi_swarm/paper_trading/`:
|
||||
- `portfolio.py` — multi-asset portfolio con sleeve uguali per asset, fees in bp.
|
||||
- `executor.py` — `PaperExecutor` carica una strategia JSON, compila, valuta l'ultimo bar.
|
||||
- `persistence.py` — `PaperRepository` su SQLite (tabelle `paper_trading_runs`, `paper_trading_ticks`, `paper_trading_equity`, `paper_trading_trades`, `paper_trading_positions`).
|
||||
|
||||
Runner `scripts/run_paper_trading.py`:
|
||||
- Loop poll OHLCV Cerbero ogni `--poll-seconds` (default 300).
|
||||
- Riconosce *nuovo bar chiuso* confrontando ultimo timestamp; tick consecutivi su stesso bar = hold.
|
||||
- Snapshot equity ogni tick.
|
||||
- Supporta `--max-ticks N` per smoke test (0 = infinito).
|
||||
|
||||
Strategie freezate per il forward-test:
|
||||
- `strategies/btc_fb63e851.json` — RSI estremi + ATR vs SMA + filtro orario 9-17.
|
||||
- `strategies/eth_facd6af85d5d.json` — ATR + realized_vol + golden/death cross.
|
||||
|
||||
### Stato corrente
|
||||
- Schema DB esteso e validato.
|
||||
- Run smoke completato (`phase3-smoke-001`).
|
||||
- Run live in corso (`phase3-papertrade-001`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Architettura cumulata
|
||||
|
||||
```
|
||||
src/multi_swarm/
|
||||
├── config.py
|
||||
├── data/{cerbero_ohlcv,splits}.py ← splits.py per WFA
|
||||
├── backtest/{orders,engine}.py
|
||||
├── metrics/{basic,dsr,diversity}.py ← diversity per Phase 2.5
|
||||
├── cerbero/{client,tools}.py
|
||||
├── protocol/{grammar,parser,validator,compiler}.py
|
||||
│ └── KNOWN_FEATURES include hour/dow/is_weekend/minute_of_hour
|
||||
├── genome/
|
||||
│ ├── hypothesis.py
|
||||
│ ├── mutation.py ← 4 operatori scalari
|
||||
│ ├── mutation_prompt_llm.py ← Phase 2.5: 5° operatore LLM
|
||||
│ └── crossover.py
|
||||
├── llm/{client,cost_tracker}.py ← cost_kind tracking
|
||||
├── agents/{hypothesis,falsification,adversarial,market_summary}.py
|
||||
│ └── adversarial: 5 check HIGH parametrici (CLI knobs)
|
||||
├── ga/
|
||||
│ ├── selection.py
|
||||
│ ├── fitness.py ← v1 + v2 soft-kill + combined IS/OOS
|
||||
│ ├── loop.py
|
||||
│ ├── summary.py
|
||||
│ └── initial.py
|
||||
├── persistence/{schema,repository}.py ← +tabelle paper_trading_*
|
||||
├── paper_trading/ ← NEW Phase 3
|
||||
│ ├── portfolio.py
|
||||
│ ├── executor.py
|
||||
│ └── persistence.py
|
||||
├── orchestrator/run.py
|
||||
└── dashboard/
|
||||
├── nicegui_app.py ← unica GUI, porta parametrica via SWARM_DASHBOARD_PORT
|
||||
└── data.py
|
||||
```
|
||||
|
||||
CLI knobs accumulati per ablation:
|
||||
- `--prompt-mutation-weight FLOAT` (Phase 2.5)
|
||||
- `--fees-eat-alpha-threshold FLOAT` (default 0.5, suggerito 0.7)
|
||||
- `--flat-too-long-threshold FLOAT` (default 0.95)
|
||||
- `--undertrading-threshold INT` (default 20)
|
||||
- `--fitness-v2` + `--fitness-soft-penalty FLOAT`
|
||||
- `--fitness-combined-alpha FLOAT` (multi-obiettivo IS/OOS)
|
||||
- `--min-trades-threshold INT` (WFA OOS filter)
|
||||
|
||||
---
|
||||
|
||||
## 10. Cosa resta da fare
|
||||
|
||||
### 10.1 Phase 3 — completamento paper-trading
|
||||
- [ ] **Definire criterio di STOP/GO Phase 3**: durata minima forward-test (es. 4-8 settimane), soglie sopravvivenza (Sharpe live > 50% del Sharpe OOS atteso, DD live < 1,5× DD OOS).
|
||||
- [ ] **Pagina dashboard paper-trading**: estendere NiceGUI con tab live equity + open positions + tick log per `paper_trading_runs`. Oggi i dati esistono in DB ma non hanno UI dedicata.
|
||||
- [ ] **Monitoring & alerting**: notifica se il runner si ferma (Cerbero down, processo killato). Considerare systemd unit o supervisor.
|
||||
- [ ] **Robustezza fetch live**: oggi `loader._fetch(req)` bypassa la cache; aggiungere retry esplicito (oltre a quello tenacity già presente nel client) e log strutturato dei fallimenti per asset.
|
||||
- [ ] **Confronto live vs OOS atteso**: script che a fine settimana confronta P/L, Sharpe rolling, hit rate vs i numeri del backtest 7y per individuare *regime mismatch* precoce.
|
||||
|
||||
### 10.2 Estensioni metodologiche
|
||||
- [ ] **Multi-seed ensembling**: invece di scegliere un singolo top genome, valutare ensemble (mediana o weighted) dei top-K trovati con seed diversi sullo stesso asset. La varianza seed è il rischio numero uno (vedi sezione 5).
|
||||
- [ ] **Asset universe expansion**: testare la metodologia su asset non-crypto (oro, forex EURUSD) per smentire l'ipotesi che funzioni solo perché BTC/ETH hanno alta volatilità. `yfinance` è già in dipendenze (`9d1ef8a`).
|
||||
- [ ] **Fitness regime-aware**: oggi fitness è single-objective sull'intero train. Considerare fitness condizionata al regime (bull/bear/range) per favorire strategie con performance bilanciata cross-regime invece di top assoluto.
|
||||
- [ ] **Phase 2.7 retry su SOL** con configurazione mirata: train più corto, undertrading_threshold ridotto, prompt few-shot di strategie short-vol-only. Verificare se è davvero il dato a fallire o se è la calibrazione.
|
||||
|
||||
### 10.3 Hardening tecnico
|
||||
- [ ] **Cleanup zombie runs**: `phase2-6-flat-wfa-001` è ancora `failed` nel DB. Verificare che il flush di stato sia idempotente per tutti i path di crash.
|
||||
- [x] **Port completo dashboard a NiceGUI** *(chiuso 14 maggio 2026, commit `03f723f`)*: Streamlit deprecata e rimossa insieme ai file legacy (`streamlit_app.py`, `aquarium.py`, `pages/0[1-4]_*.py`); dep `streamlit>=1.40` cancellata da `pyproject.toml` con 10 transitive (pydeck, watchdog, jsonschema, pillow, …). NiceGUI espone 3 pagine (`/`, `/convergence`, `/genomes`) su porta parametrica `SWARM_DASHBOARD_PORT` (default 8080). **Aquarium non riportata per scelta** (decisione utente: non più ritenuta utile). Deploy in produzione via Docker + Traefik su `https://swarm.tielogic.xyz` (compose `docker-compose.yml`, commit `8e5efde`).
|
||||
- [ ] **Pruning DB**: dopo 30+ run la SQLite cresce. Aggiungere uno script di archiviazione/compressione delle run completate più vecchie di N giorni.
|
||||
- [ ] **CI/test coverage**: i 180+ test girano localmente; non c'è ancora CI esterna (Gitea Actions o equivalente).
|
||||
|
||||
### 10.4 Documentazione e governance
|
||||
- [ ] **Decision memo Phase 2.5 + Phase 2.6 + Phase 2.7** formalizzati come `docs/decisions/2026-05-1{2,3}-*.md` (esistono solo memory + commit message; manca il pendant pubblico dei due memo già esistenti per Phase 1 e Phase 1.5).
|
||||
- [ ] **Phase 3 charter**: documento che fissa a priori cosa significherà "successo" o "fallimento" del forward-test, per evitare *moving goalposts* a posteriori.
|
||||
- [ ] **Threats to validity update**: il memo Phase 1 ne elencava 6; integrarli con le scoperte successive (varianza seed, portabilità asset-specifica, divergenza pattern BTC/ETH).
|
||||
|
||||
---
|
||||
|
||||
## 11. Caveat e rischi aperti
|
||||
|
||||
1. **Varianza seed**: con seed diversi (7, 99, 123) sullo stesso identico setup il max fitness varia di un fattore 3-4×. Qualunque metrica single-seed è statisticamente debole; finché Phase 3 non raccoglie N≥5 forward-test indipendenti, il vantaggio del prompt-mutator resta nel rumore.
|
||||
2. **Sharpe OOS positivi ma bassi**: BTC `+0,50` ed ETH `+0,19` sono migliori del coin-flip ma lontani dai target retail "investment-grade" (≥ 1,0). La metodologia è validata, l'alpha catturata è modesta.
|
||||
3. **`time_in_market_too_high` come red-flag chiave**: `63411199` ha CAGR +31,88% ma esposizione 90% del tempo — è leveraged-B&H camuffato, non alpha. Phase 3 deve preferire `fb63e851` (selettività 1-2%) anche se ha return assoluto minore.
|
||||
4. **Dipendenza dal modello qwen-2.5-72b**: rollback Phase 2 ha dimostrato che il prompt è calibrato su questa specifica famiglia. Se il modello venisse deprecato da OpenRouter, sarebbe necessario un giro di ricalibrazione prompt → rischio di operatività.
|
||||
5. **Cerbero MCP come single point of failure**: tutti i fetch OHLCV passano da lì. Da considerare un fallback (ccxt o yfinance) almeno per il paper-trading.
|
||||
|
||||
---
|
||||
|
||||
## 12. Costi cumulati
|
||||
|
||||
- **Phase 1 (5 run iterazione)**: $0,19.
|
||||
- **Phase 1.5 nemotron**: $0,12.
|
||||
- **Phase 2 + 2.5 + 2.6 + 2.7**: ≈ $3,24 cumulati su 25+ run.
|
||||
- **Totale LLM progetto a oggi**: ≈ **$3,74** (DB locale).
|
||||
- **Phase 3 paper-trading**: $0 incrementali per LLM (le strategie sono fisse), solo costi Cerbero (incluso nel servizio esistente).
|
||||
|
||||
Resta amplissimo margine rispetto al cap originale Phase 1 di $700.
|
||||
|
||||
---
|
||||
|
||||
## 13. Riferimenti
|
||||
|
||||
- README.md — overview e setup.
|
||||
- `docs/decisions/2026-05-10-gate-phase1.md`, `docs/decisions/2026-05-11-phase1-5-nemotron-run.md`.
|
||||
- `docs/reports/2026-05-10-phase1-technical-report.md`.
|
||||
- `docs/superpowers/specs/2026-05-09-decisione-strategica-design.md`, `docs/superpowers/specs/2026-05-11-temporal-features-design.md`.
|
||||
- `docs/superpowers/plans/2026-05-09-phase1-lean-spike.md`, `docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md`, `docs/superpowers/plans/2026-05-11-temporal-features.md`.
|
||||
- DB locale `runs.db` per dettaglio run-by-run.
|
||||
|
||||
---
|
||||
|
||||
*Prossimo checkpoint suggerito: rivedere questo documento al termine del primo ciclo completo di Phase 3 (≥ 2 settimane di forward-test continuo) per consolidare i risultati live e decidere GO/NO-GO verso un eventuale Phase 4 (capitale reale ridotto o estensione del universe).*
|
||||
@@ -0,0 +1,318 @@
|
||||
# `mutate_prompt_llm` — Phase 2.5 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 (`- [x]`) syntax for tracking.
|
||||
|
||||
**Status:** **TUTTI I 6 TASK COMPLETATI** (task 1-5 il 2026-05-11, task 6 il 2026-05-12). Mergiati su main. Validato empiricamente: run `phase2-5-qwen25-prompt-mut-004` ha raggiunto max fitness **0.1012** (+225% vs baseline `phase2-qwen25-control-001` 0.0311). Sweet spot weight=0.30 (curva U: weight=0.50 → regressione plateau 0.0311; weight=0.00 → baseline piatto).
|
||||
|
||||
**Trigger Phase 2.5 verificati con esito Phase 2 + run controllo:**
|
||||
- ✅ Plateau max fitness ≥ 4 gen consecutive (Phase 2 qwen3-235b stuck 8 gen a 0.0238; run controllo qwen-2.5-72b stuck 9 gen a 0.0311).
|
||||
- ✅ Diversità prompt collapsed: top genomi del run controllo hanno fitness/Sharpe/DD identici (mutazioni scalari non producono varianti significative).
|
||||
- ✗ Top quasi-fit ≥ 0.10 non raggiunto, ma 2/3 trigger sufficienti.
|
||||
|
||||
**Decisione collaterale:** rollback tier C a `qwen/qwen-2.5-72b-instruct` (run controllo l'ha dimostrato superiore a qwen3-235b: +30% fitness, 4× entropy, metà costo e tempo).
|
||||
|
||||
**Goal:** Introdurre un quinto operatore di mutazione che usa un LLM tier B come "mutator" per riscrivere il `system_prompt` di un genoma, generando diversità reale dove oggi `random_mutate` tocca solo quattro scalari. La pipeline GA esistente resta intatta: `mutate_prompt_llm` è solo un nuovo membro di `MUTATION_OPS` con peso configurabile.
|
||||
|
||||
**Architecture:** Operatore puro come gli altri quattro (`mutate_temperature`, `mutate_lookback`, `mutate_feature_access`, `mutate_cognitive_style`). Riceve `parent_genome`, `llm_client`, `rng` e restituisce un child genome con `system_prompt` modificato. Il mutator LLM (tier B = `deepseek/deepseek-v4-flash`) riceve una mutation-instruction casuale tra sei tipi predefiniti (`tighten_threshold`, `swap_comparator`, `add_condition`, `remove_condition`, `change_timeframe`, `add_temporal_gate`) e produce un nuovo prompt vincolato a una mutazione "atomica". Il child viene validato (parser + adversarial dry-run); su fallimento si effettua fallback a `random_mutate`. Selezione probabilistica nel `random_mutate` dispatcher con peso configurabile (default 0.30) — i quattro operator scalari mantengono il 70% complessivo.
|
||||
|
||||
**Tech Stack:** Python 3.13, `LLMClient` esistente (OpenAI SDK via OpenRouter), pytest + `pytest-mock`. Niente nuove dipendenze.
|
||||
|
||||
**Spec di riferimento:** sezione "Meccanismo di mutazione" della conversazione `2026-05-11`, valutazione `mutate_prompt_llm` (questa pagina contiene la sintesi).
|
||||
|
||||
---
|
||||
|
||||
## Trigger condition (quando attivare)
|
||||
|
||||
Implementare e mergiare **solo se** uno dei seguenti è vero al termine di Phase 2:
|
||||
|
||||
1. **Plateau evolutivo**: max fitness stagnante (Δ < 0.01) per ≥ 4 generazioni consecutive su `phase2-qwen3-001` o successori.
|
||||
2. **Diversità prompt collassa**: media Levenshtein normalizzata fra i prompt della popolazione finale ≤ 0.15 (= popolazione clonata).
|
||||
3. **Top genome problematico ma quasi-fit**: max fitness ≥ 0.10 ma adversarial finding HIGH ≥ 2 per il top, suggerendo che una mutazione mirata del prompt potrebbe "ripararlo".
|
||||
|
||||
Se Phase 2 raggiunge max fitness ≥ 0.30 senza plateau, **non attivare** (la diversità random basta).
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Tipo | Responsabilità |
|
||||
|------|------|----------------|
|
||||
| `src/multi_swarm/genome/mutation_prompt_llm.py` | New | Operatore `mutate_prompt_llm` + helper `MUTATION_INSTRUCTIONS` + retry/fallback wrapper |
|
||||
| `src/multi_swarm/genome/mutation.py` | Modify | Estendere `MUTATION_OPS` + introdurre dispatcher pesato `weighted_random_mutate` |
|
||||
| `src/multi_swarm/ga/loop.py` | Modify | Sostituire `random_mutate(parent, rng)` con `weighted_random_mutate(parent, rng, llm_client, weights)` |
|
||||
| `src/multi_swarm/orchestrator/run.py` | Modify | Aggiungere `mutator_tier: ModelTier = ModelTier.B` e `prompt_mutation_weight: float = 0.30` a `RunConfig`, passare `LLMClient` al loop GA |
|
||||
| `src/multi_swarm/llm/cost_tracker.py` | Modify (minimo) | Loggare `mutation_call` separatamente da `hypothesis_call` per attribuzione costo |
|
||||
| `src/multi_swarm/metrics/diversity.py` | New | Funzione `population_prompt_diversity` (Levenshtein normalizzata) — usata in trigger check + telemetry |
|
||||
| `tests/unit/test_mutation_prompt_llm.py` | New | Test operator con mock `LLMClient` (success + validation fail + retry/fallback) |
|
||||
| `tests/unit/test_mutation_dispatcher.py` | New | Test `weighted_random_mutate` rispetta i pesi |
|
||||
| `tests/unit/test_diversity.py` | New | Test `population_prompt_diversity` su prompt identici/diversi |
|
||||
| `tests/integration/test_ga_loop_with_prompt_mutator.py` | New | Loop end-to-end di 2 gen × 5 genomi con mock LLM, verifica diversità prompt cresce |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Mutator instructions + operator stub
|
||||
|
||||
**Files:**
|
||||
- New: `src/multi_swarm/genome/mutation_prompt_llm.py`
|
||||
- New: `tests/unit/test_mutation_prompt_llm.py`
|
||||
|
||||
- [x] **Step 1.1: Write failing test — operator returns child con system_prompt diverso**
|
||||
|
||||
Append a `tests/unit/test_mutation_prompt_llm.py`:
|
||||
|
||||
```python
|
||||
def test_mutate_prompt_llm_produces_different_prompt(mock_llm: LLMClient) -> None:
|
||||
parent = make_genome(system_prompt="Strategia: compra quando RSI < 30")
|
||||
mock_llm.respond_with("Strategia: compra quando RSI < 25 e ora >= 14")
|
||||
child = mutate_prompt_llm(parent, mock_llm, rng=random.Random(0))
|
||||
assert child.system_prompt != parent.system_prompt
|
||||
assert child.parent_ids == [*parent.parent_ids, parent.id]
|
||||
assert child.generation == parent.generation + 1
|
||||
```
|
||||
|
||||
- [x] **Step 1.2: Implement `MUTATION_INSTRUCTIONS` constant**
|
||||
|
||||
`mutation_prompt_llm.py`:
|
||||
|
||||
```python
|
||||
MUTATION_INSTRUCTIONS: dict[str, str] = {
|
||||
"tighten_threshold": "Rendi una soglia numerica più restrittiva del 10–20%...",
|
||||
"swap_comparator": "Inverti un comparator (gt ↔ lt, gte ↔ lte) mantenendo intent...",
|
||||
"add_condition": "Aggiungi una condizione AND/OR alla rule più specifica...",
|
||||
"remove_condition": "Rimuovi una condizione ridondante o debole...",
|
||||
"change_timeframe": "Modifica una finestra rolling (lookback) di ±30%...",
|
||||
"add_temporal_gate": "Aggiungi un gate temporale (hour, dow, is_weekend)...",
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 1.3: Implement `mutate_prompt_llm`**
|
||||
|
||||
Firma:
|
||||
```python
|
||||
def mutate_prompt_llm(
|
||||
g: HypothesisAgentGenome,
|
||||
llm: LLMClient,
|
||||
rng: random.Random,
|
||||
mutator_tier: ModelTier = ModelTier.B,
|
||||
) -> HypothesisAgentGenome:
|
||||
```
|
||||
|
||||
Logica:
|
||||
1. Scegli `instruction_key = rng.choice(list(MUTATION_INSTRUCTIONS))`.
|
||||
2. Costruisci messaggio system + user con `MUTATION_INSTRUCTIONS[instruction_key]` + `g.system_prompt`.
|
||||
3. Crea genoma temporaneo `mutator_genome` con `model_tier=mutator_tier`.
|
||||
4. Chiama `llm.complete(mutator_genome, system, user, max_tokens=2000)`.
|
||||
5. Estrai nuovo prompt da risposta (cerca blocco `<prompt>...</prompt>` o intero output).
|
||||
6. Ritorna `_clone_with(g, system_prompt=new_prompt)` (riusa helper di `mutation.py`).
|
||||
|
||||
- [x] **Step 1.4: Run test → green**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/unit/test_mutation_prompt_llm.py::test_mutate_prompt_llm_produces_different_prompt -xvs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Validation + fallback
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/multi_swarm/genome/mutation_prompt_llm.py`
|
||||
- Append: `tests/unit/test_mutation_prompt_llm.py`
|
||||
|
||||
- [x] **Step 2.1: Write failing test — fallback a random_mutate su prompt invalid**
|
||||
|
||||
```python
|
||||
def test_mutate_prompt_llm_falls_back_on_invalid_prompt(mock_llm: LLMClient) -> None:
|
||||
parent = make_genome()
|
||||
mock_llm.respond_with("garbage that does not parse")
|
||||
child = mutate_prompt_llm(parent, mock_llm, rng=random.Random(0))
|
||||
# Garbage prompt deve fallback: child è prodotto da random_mutate, quindi
|
||||
# system_prompt == parent.system_prompt (random_mutate tocca solo scalari)
|
||||
assert child.system_prompt == parent.system_prompt
|
||||
assert child.parent_ids == [*parent.parent_ids, parent.id]
|
||||
```
|
||||
|
||||
- [x] **Step 2.2: Implement validation step**
|
||||
|
||||
Dopo aver estratto `new_prompt`, esegui `validate_prompt(new_prompt)`:
|
||||
- Lunghezza minima 50 caratteri.
|
||||
- Contiene almeno una keyword fra `{rsi, sma, ema, atr, momentum, breakout, mean reversion, gt, lt, ...}`.
|
||||
- Non identico a `parent.system_prompt` (Levenshtein > 0.05 normalizzata).
|
||||
|
||||
Su fail → log warning + ritorna `random_mutate(g, rng)`.
|
||||
|
||||
- [x] **Step 2.3: Write failing test — diversity guard**
|
||||
|
||||
Mock LLM ritorna prompt identico al parent → `validate_prompt` rifiuta → fallback.
|
||||
|
||||
- [x] **Step 2.4: Run test suite parziale**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/unit/test_mutation_prompt_llm.py -xvs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Weighted dispatcher
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/multi_swarm/genome/mutation.py`
|
||||
- New: `tests/unit/test_mutation_dispatcher.py`
|
||||
|
||||
- [x] **Step 3.1: Write failing test — weighted_random_mutate rispetta pesi**
|
||||
|
||||
```python
|
||||
def test_weighted_random_mutate_picks_prompt_op_at_configured_rate() -> None:
|
||||
rng = random.Random(0)
|
||||
weights = {"prompt": 1.0, "scalar": 0.0} # 100% prompt
|
||||
counter = Counter()
|
||||
for _ in range(100):
|
||||
op_name = _pick_op_name(weights, rng)
|
||||
counter[op_name] += 1
|
||||
assert counter["prompt"] == 100
|
||||
```
|
||||
|
||||
- [x] **Step 3.2: Implement `weighted_random_mutate`**
|
||||
|
||||
```python
|
||||
def weighted_random_mutate(
|
||||
g: HypothesisAgentGenome,
|
||||
rng: random.Random,
|
||||
llm: LLMClient | None = None,
|
||||
prompt_mutation_weight: float = 0.30,
|
||||
) -> HypothesisAgentGenome:
|
||||
if llm is not None and rng.random() < prompt_mutation_weight:
|
||||
return mutate_prompt_llm(g, llm, rng)
|
||||
return random_mutate(g, rng)
|
||||
```
|
||||
|
||||
- [x] **Step 3.3: Test edge cases**
|
||||
|
||||
- `llm=None` → sempre scalar mutation (backward compat).
|
||||
- `prompt_mutation_weight=0.0` → sempre scalar.
|
||||
- `prompt_mutation_weight=1.0` → sempre prompt (se llm presente).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Integrazione GA loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/multi_swarm/ga/loop.py`
|
||||
- Modify: `src/multi_swarm/orchestrator/run.py`
|
||||
- New: `tests/integration/test_ga_loop_with_prompt_mutator.py`
|
||||
|
||||
- [x] **Step 4.1: Estendere `GAConfig`**
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class GAConfig:
|
||||
population_size: int
|
||||
elite_k: int
|
||||
tournament_k: int
|
||||
p_crossover: float
|
||||
prompt_mutation_weight: float = 0.0 # default off → opt-in
|
||||
```
|
||||
|
||||
- [x] **Step 4.2: Pass `LLMClient` in `next_generation`**
|
||||
|
||||
```python
|
||||
def next_generation(
|
||||
population: list[HypothesisAgentGenome],
|
||||
fitnesses: dict[str, float],
|
||||
cfg: GAConfig,
|
||||
rng: random.Random,
|
||||
llm: LLMClient | None = None,
|
||||
) -> list[HypothesisAgentGenome]:
|
||||
...
|
||||
child = weighted_random_mutate(parent, rng, llm, cfg.prompt_mutation_weight)
|
||||
```
|
||||
|
||||
- [x] **Step 4.3: Wire in orchestrator**
|
||||
|
||||
`RunConfig.prompt_mutation_weight: float = 0.0` (default off). Quando attivo via CLI `--prompt-mutation-weight 0.30`, passare a `next_generation`.
|
||||
|
||||
- [x] **Step 4.4: Integration test**
|
||||
|
||||
Loop 2 gen × 5 genomi, mock LLM che ritorna prompt sempre diversi. Verifica che la popolazione finale abbia più diversità prompt della iniziale.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Diversity metric
|
||||
|
||||
**Files:**
|
||||
- New: `src/multi_swarm/metrics/diversity.py`
|
||||
- New: `tests/unit/test_diversity.py`
|
||||
|
||||
- [x] **Step 5.1: Implement `population_prompt_diversity`**
|
||||
|
||||
```python
|
||||
def population_prompt_diversity(prompts: list[str]) -> float:
|
||||
"""Levenshtein normalizzata media su tutte le coppie. 0.0 = identici, 1.0 = totalmente diversi."""
|
||||
```
|
||||
|
||||
- [x] **Step 5.2: Test**
|
||||
|
||||
Tre prompt identici → 0.0. Tre prompt totalmente diversi → ~1.0.
|
||||
|
||||
- [x] **Step 5.3: Logging**
|
||||
|
||||
Aggiungere `diversity_prompt` come campo per-generazione in `repository.save_generation` (richiede migration leggera).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Cost attribution
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/multi_swarm/llm/cost_tracker.py`
|
||||
- Modify: tests esistenti
|
||||
|
||||
- [x] **Step 6.1: Aggiungere `call_kind` a `CostRecord`**
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CostRecord:
|
||||
...
|
||||
call_kind: str = "hypothesis" # "hypothesis" | "mutation"
|
||||
```
|
||||
|
||||
- [x] **Step 6.2: Loggare separatamente in summary**
|
||||
|
||||
`summary()["by_call_kind"]` con breakdown.
|
||||
|
||||
- [x] **Step 6.3: Test compatibilità con record esistenti**
|
||||
|
||||
Backward compat: record senza `call_kind` interpretati come `"hypothesis"`.
|
||||
|
||||
---
|
||||
|
||||
## Verification end-to-end
|
||||
|
||||
- [x] `uv run pytest -q` → 100% passa (157 + nuovi test).
|
||||
- [x] `uv run python scripts/smoke_run.py` → completa con mock LLM.
|
||||
- [x] **Run baseline B**: ripetere `phase2-qwen3-001` con `--prompt-mutation-weight 0.0` per controllo.
|
||||
- [x] **Run trattamento T**: `phase2-qwen3-prompt-mut-001` con `--prompt-mutation-weight 0.30`.
|
||||
- [x] Confronto: max fitness T > B + 20%, diversity_prompt(T) > diversity_prompt(B) + 30%.
|
||||
- [x] Costo aggiuntivo run T ≤ $0.10 (sanity check).
|
||||
|
||||
---
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
| Rischio | Mitigazione |
|
||||
|---------|-------------|
|
||||
| Mode collapse mutator LLM | `mutation_instruction` scelta random + diversity guard Levenshtein |
|
||||
| Prompt LLM-output non parsabile dal compiler | Validation step + fallback `random_mutate` |
|
||||
| Costo runaway (loop infinito retry) | `max_tokens=2000`, no retry su validation fail |
|
||||
| Bias condiviso con generator tier C | Mutator tier B = `deepseek-v4-flash`, famiglia diversa da Qwen3 |
|
||||
| Variabili confuse con Phase 2 | Attivare **solo** dopo Phase 2 baseline; A/B isolato |
|
||||
|
||||
---
|
||||
|
||||
## Cost estimate
|
||||
|
||||
Pop = 20, gen = 10, mutation rate ~75% (5 elite + 15 children), prompt_mutation_weight = 0.30:
|
||||
- ~45 chiamate LLM tier B aggiuntive per run.
|
||||
- ~500 tok input + 200 tok output per call → 22.5k in + 9k out totali.
|
||||
- 22.5k × $0.14/1M + 9k × $0.28/1M ≈ **$0.0057/run**.
|
||||
|
||||
Trascurabile rispetto al budget run base (~$0.10).
|
||||
@@ -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).
|
||||
+2
-2
@@ -11,15 +11,15 @@ dependencies = [
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
"sqlmodel>=0.0.22",
|
||||
"sexpdata>=1.0.2",
|
||||
"openai>=1.55",
|
||||
"httpx>=0.28",
|
||||
"requests>=2.32",
|
||||
"tenacity>=9.0",
|
||||
"pyyaml>=6.0",
|
||||
"streamlit>=1.40",
|
||||
"plotly>=5.24",
|
||||
"pyarrow>=18.0",
|
||||
"nicegui>=3.11.1",
|
||||
"yfinance>=1.3.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Backtest standalone di una strategia su range esteso.
|
||||
|
||||
Carica un JSON strategia (formato del Hypothesis Agent output), fetcha OHLCV
|
||||
via Cerbero, esegue BacktestEngine + FalsificationReport + AdversarialReport,
|
||||
stampa metriche annualizzate.
|
||||
|
||||
Esempio:
|
||||
uv run python scripts/backtest_strategy.py /tmp/strategy_e52604ba.json \
|
||||
--start 2019-01-01 --end 2026-01-01 --label flat-ablation-top
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from multi_swarm.agents.adversarial import AdversarialAgent
|
||||
from multi_swarm.agents.falsification import FalsificationAgent
|
||||
from multi_swarm.cerbero.client import CerberoClient
|
||||
from multi_swarm.config import load_settings
|
||||
from multi_swarm.data.cerbero_ohlcv import CerberoOHLCVLoader, OHLCVRequest
|
||||
from multi_swarm.protocol.parser import parse_strategy
|
||||
from multi_swarm.protocol.validator import validate_strategy
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("strategy_file", type=Path)
|
||||
p.add_argument("--start", default="2019-01-01T00:00:00+00:00")
|
||||
p.add_argument("--end", default="2026-01-01T00:00:00+00:00")
|
||||
p.add_argument("--exchange", default="deribit")
|
||||
p.add_argument("--symbol", default="BTC-PERPETUAL")
|
||||
p.add_argument("--timeframe", default="1h")
|
||||
p.add_argument("--fees-bp", type=float, default=5.0)
|
||||
p.add_argument("--n-trials-dsr", type=int, default=50)
|
||||
p.add_argument("--label", default="strategy")
|
||||
args = p.parse_args()
|
||||
|
||||
strategy_json = json.loads(args.strategy_file.read_text())
|
||||
raw = json.dumps(strategy_json)
|
||||
parsed = parse_strategy(raw)
|
||||
validate_strategy(parsed)
|
||||
print(f"Strategy '{args.label}' parsed OK: {len(parsed.rules)} rules")
|
||||
|
||||
settings = load_settings()
|
||||
token = (
|
||||
settings.cerbero_mainnet_token.get_secret_value()
|
||||
if settings.cerbero_mainnet_token
|
||||
else settings.cerbero_testnet_token.get_secret_value()
|
||||
)
|
||||
cerbero = CerberoClient(
|
||||
base_url=settings.cerbero_base_url,
|
||||
token=token,
|
||||
bot_tag=settings.cerbero_bot_tag,
|
||||
)
|
||||
loader = CerberoOHLCVLoader(client=cerbero, cache_dir=settings.series_dir)
|
||||
req = OHLCVRequest(
|
||||
symbol=args.symbol,
|
||||
timeframe=args.timeframe,
|
||||
start=datetime.fromisoformat(args.start),
|
||||
end=datetime.fromisoformat(args.end),
|
||||
exchange=args.exchange,
|
||||
)
|
||||
ohlcv = loader.load(req)
|
||||
n_bars = len(ohlcv)
|
||||
years = n_bars / (24 * 365.25)
|
||||
print(
|
||||
f"OHLCV loaded: {n_bars} bars "
|
||||
f"({ohlcv.index[0]} → {ohlcv.index[-1]}, ~{years:.2f} anni)"
|
||||
)
|
||||
|
||||
fals_agent = FalsificationAgent(fees_bp=args.fees_bp, n_trials_dsr=args.n_trials_dsr)
|
||||
adv_agent = AdversarialAgent(fees_bp=args.fees_bp)
|
||||
fals = fals_agent.evaluate(parsed, ohlcv)
|
||||
adv = adv_agent.review(parsed, ohlcv)
|
||||
|
||||
cagr = (1.0 + float(fals.total_return)) ** (1.0 / years) - 1.0 if years > 0 else float("nan")
|
||||
calmar = (cagr / float(fals.max_drawdown)) if fals.max_drawdown > 0 else float("inf")
|
||||
|
||||
print(f"\n=== {args.label} on {args.symbol} {args.timeframe} ({years:.2f} anni) ===")
|
||||
print(f"n_trades: {fals.n_trades}")
|
||||
print(f"total_return: {fals.total_return:+.4f} ({fals.total_return * 100:+.2f}%)")
|
||||
print(f"CAGR: {cagr:+.4f} ({cagr * 100:+.2f}%)")
|
||||
print(f"Sharpe (ann): {fals.sharpe:+.3f}")
|
||||
print(f"DSR: {fals.dsr:.4f} (pvalue {fals.dsr_pvalue:.4f})")
|
||||
print(f"max_drawdown: {fals.max_drawdown:.4f} ({fals.max_drawdown * 100:.2f}%)")
|
||||
print(f"Calmar: {calmar:+.3f}")
|
||||
print(f"\nAdversarial findings:")
|
||||
if not adv.findings:
|
||||
print(" (none)")
|
||||
for f in adv.findings:
|
||||
print(f" [{f.severity.value:6s}] {f.name:30s} {f.detail}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Paper-trading runner Phase 3 — forward-test virtuale BTC + ETH.
|
||||
|
||||
Loop infinito (o limitato via --max-ticks) che ogni ``--poll-seconds``:
|
||||
1. fetch OHLCV 1h ultime ~500 barre via Cerbero
|
||||
2. per ogni strategia: compile + esegui ultimo bar
|
||||
3. apply segnale al portfolio multi-asset
|
||||
4. snapshot equity in DB
|
||||
|
||||
I bar 1h chiudono al minuto :00. Il loop riconosce un "nuovo bar chiuso"
|
||||
confrontando l'ultimo timestamp del DataFrame con quello dell'iterazione
|
||||
precedente. Tick consecutivi su stesso bar = hold (no double-trade).
|
||||
|
||||
Esempio:
|
||||
uv run python scripts/run_paper_trading.py \
|
||||
--name phase3-papertrade-001 \
|
||||
--initial-capital 1000 \
|
||||
--max-ticks 336 # 2 settimane * 24 ore
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from multi_swarm.cerbero.client import CerberoClient
|
||||
from multi_swarm.config import load_settings
|
||||
from multi_swarm.data.cerbero_ohlcv import CerberoOHLCVLoader, OHLCVRequest
|
||||
from multi_swarm.paper_trading.executor import PaperExecutor
|
||||
from multi_swarm.paper_trading.persistence import PaperRepository
|
||||
from multi_swarm.paper_trading.portfolio import Portfolio
|
||||
from multi_swarm.persistence.repository import Repository
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetConfig:
|
||||
symbol: str # es. "BTC-PERPETUAL"
|
||||
strategy_file: Path
|
||||
exchange: str = "deribit"
|
||||
timeframe: str = "1h"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Paper-trading runner Phase 3")
|
||||
p.add_argument("--name", default="phase3-papertrade-001")
|
||||
p.add_argument("--initial-capital", type=float, default=1000.0)
|
||||
p.add_argument("--fees-bp", type=float, default=5.0)
|
||||
p.add_argument("--poll-seconds", type=int, default=300, help="Polling interval (5min default)")
|
||||
p.add_argument("--max-ticks", type=int, default=0, help="0 = infinito; per smoke test usa 1")
|
||||
p.add_argument("--lookback-bars", type=int, default=500, help="Quante bar fetchare per indicatori")
|
||||
p.add_argument(
|
||||
"--strategies-dir",
|
||||
default=str(PROJECT_ROOT / "strategies"),
|
||||
help="Cartella contenente btc_*.json e eth_*.json",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def load_assets(strategies_dir: Path) -> list[AssetConfig]:
|
||||
btc_files = sorted(strategies_dir.glob("btc_*.json"))
|
||||
eth_files = sorted(strategies_dir.glob("eth_*.json"))
|
||||
if not btc_files or not eth_files:
|
||||
raise FileNotFoundError(
|
||||
f"Expected btc_*.json and eth_*.json in {strategies_dir}"
|
||||
)
|
||||
return [
|
||||
AssetConfig(symbol="BTC-PERPETUAL", strategy_file=btc_files[0]),
|
||||
AssetConfig(symbol="ETH-PERPETUAL", strategy_file=eth_files[0]),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
settings = load_settings()
|
||||
|
||||
# Inizializza schema (idempotente).
|
||||
Repository(settings.db_path).init_schema()
|
||||
|
||||
token = (
|
||||
settings.cerbero_mainnet_token.get_secret_value()
|
||||
if settings.cerbero_mainnet_token
|
||||
else settings.cerbero_testnet_token.get_secret_value()
|
||||
)
|
||||
cerbero = CerberoClient(
|
||||
base_url=settings.cerbero_base_url,
|
||||
token=token,
|
||||
bot_tag=settings.cerbero_bot_tag,
|
||||
)
|
||||
loader = CerberoOHLCVLoader(client=cerbero, cache_dir=settings.series_dir)
|
||||
|
||||
assets = load_assets(Path(args.strategies_dir))
|
||||
executors: list[PaperExecutor] = [
|
||||
PaperExecutor(strategy_json_path=a.strategy_file, symbol=a.symbol) for a in assets
|
||||
]
|
||||
print(f"Loaded {len(assets)} strategies:")
|
||||
for a, ex in zip(assets, executors, strict=True):
|
||||
print(f" {a.symbol}: {a.strategy_file.name} -> {len(ex._strategy.rules)} rules")
|
||||
|
||||
portfolio = Portfolio(
|
||||
initial_capital=args.initial_capital,
|
||||
fees_bp=args.fees_bp,
|
||||
n_sleeves=len(assets),
|
||||
)
|
||||
repo = PaperRepository(settings.db_path)
|
||||
config = {
|
||||
"assets": [
|
||||
{"symbol": a.symbol, "strategy": a.strategy_file.name, "exchange": a.exchange}
|
||||
for a in assets
|
||||
],
|
||||
"fees_bp": args.fees_bp,
|
||||
"poll_seconds": args.poll_seconds,
|
||||
"lookback_bars": args.lookback_bars,
|
||||
}
|
||||
run_id = repo.create_run(
|
||||
name=args.name, initial_capital=args.initial_capital, config=config
|
||||
)
|
||||
print(f"Paper run started: {run_id} ({args.name})")
|
||||
print(f" initial_capital=${args.initial_capital:.2f}, sleeve=${portfolio.sleeve_capital:.2f}")
|
||||
|
||||
tick_count = 0
|
||||
last_bars_seen: dict[str, datetime] = {}
|
||||
try:
|
||||
while args.max_ticks == 0 or tick_count < args.max_ticks:
|
||||
now = datetime.now(UTC)
|
||||
last_prices: dict[str, float] = {}
|
||||
for asset, executor in zip(assets, executors, strict=True):
|
||||
# fetch OHLCV most recent lookback bars
|
||||
end = now.replace(minute=0, second=0, microsecond=0)
|
||||
start = end - timedelta(hours=args.lookback_bars + 1)
|
||||
req = OHLCVRequest(
|
||||
symbol=asset.symbol,
|
||||
timeframe=asset.timeframe,
|
||||
start=start,
|
||||
end=end,
|
||||
exchange=asset.exchange,
|
||||
)
|
||||
# bypass cache for live data
|
||||
try:
|
||||
ohlcv = loader._fetch(req) # noqa: SLF001
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[{now.isoformat()}] {asset.symbol} fetch FAIL: {e}")
|
||||
continue
|
||||
if len(ohlcv) < 10:
|
||||
print(f"[{now.isoformat()}] {asset.symbol} too few bars ({len(ohlcv)})")
|
||||
continue
|
||||
|
||||
bar_ts = ohlcv.index[-1]
|
||||
last_bar_dt = bar_ts.to_pydatetime() if hasattr(bar_ts, "to_pydatetime") else bar_ts
|
||||
# skip se barra gia' processata in questo tick
|
||||
if last_bars_seen.get(asset.symbol) == last_bar_dt:
|
||||
last_prices[asset.symbol] = float(ohlcv["close"].iloc[-1])
|
||||
continue
|
||||
last_bars_seen[asset.symbol] = last_bar_dt
|
||||
|
||||
result = executor.execute_tick(portfolio, ohlcv, now)
|
||||
repo.save_tick(run_id, result)
|
||||
last_prices[asset.symbol] = result.close_price
|
||||
if result.action_taken != "hold":
|
||||
pnl_str = (
|
||||
f"pnl=${result.trade.net_pnl:+.2f}" if result.trade else ""
|
||||
)
|
||||
print(
|
||||
f"[{now.isoformat()}] {asset.symbol} bar={last_bar_dt} "
|
||||
f"close={result.close_price:.2f} signal={result.signal.value} "
|
||||
f"action={result.action_taken} {pnl_str}"
|
||||
)
|
||||
|
||||
repo.sync_open_positions(run_id, portfolio)
|
||||
eq, pos_val = portfolio.equity(last_prices)
|
||||
repo.save_equity_snapshot(run_id, now, eq, portfolio.cash, pos_val)
|
||||
|
||||
tick_count += 1
|
||||
print(
|
||||
f"[{now.isoformat()}] tick={tick_count} "
|
||||
f"equity=${eq:.2f} cash=${portfolio.cash:.2f} pos_val=${pos_val:.2f} "
|
||||
f"open={list(portfolio.positions.keys())}"
|
||||
)
|
||||
|
||||
if args.max_ticks > 0 and tick_count >= args.max_ticks:
|
||||
break
|
||||
time.sleep(args.poll_seconds)
|
||||
|
||||
repo.stop_run(run_id, status="completed")
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user")
|
||||
repo.stop_run(run_id, status="interrupted")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Run failed: {e}")
|
||||
repo.stop_run(run_id, status="failed")
|
||||
raise
|
||||
|
||||
print(f"Paper run {run_id} stopped after {tick_count} ticks")
|
||||
print(f"Final equity: ${portfolio.equity({})[0]:.2f}")
|
||||
print(f"Trades closed: {len(portfolio.closed_trades)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -31,6 +31,71 @@ def parse_args() -> argparse.Namespace:
|
||||
p.add_argument("--end", default="2026-01-01T00:00:00+00:00")
|
||||
p.add_argument("--fees-bp", type=float, default=5.0)
|
||||
p.add_argument("--n-trials-dsr", type=int, default=50)
|
||||
p.add_argument(
|
||||
"--prompt-mutation-weight",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="Phase 2.5: probabilità (0-1) che la mutazione invochi LLM mutator tier B",
|
||||
)
|
||||
p.add_argument(
|
||||
"--fees-eat-alpha-threshold",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Adversarial gate: kill se fees/gross_pnl > soglia (default 0.5, ablation 0.7)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--flat-too-long-threshold",
|
||||
type=float,
|
||||
default=0.95,
|
||||
help="Adversarial gate: kill se signal flat > soglia delle bar (default 0.95, ablation 0.98)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--undertrading-threshold",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Adversarial: kill se n_trades < soglia (default 10, bump per filtrare lucky-shot)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--fitness-v2",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Attiva fitness v2: solo {no_trades, degenerate, undertrading} azzerano; "
|
||||
"gli altri HIGH applicano soft penalty multiplicativa"
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--fitness-soft-penalty",
|
||||
type=float,
|
||||
default=0.4,
|
||||
help="v2: fattore soft penalty per HIGH non-hard (default 0.4 → 1 HIGH → 0.71x)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--wfa-train-split",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Walk-forward: frazione bar usate per training (es. 0.7 = primi 70%% in-sample, ultimi 30%% OOS)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--wfa-top-k",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Walk-forward: quanti top genomi rivalutare OOS (default 5)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--eval-oos-during-loop",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Multi-objective: eval ogni genome anche su test_ohlcv durante "
|
||||
"il loop e usa combined = alpha*IS + (1-alpha)*OOS per selection. "
|
||||
"Richiede --wfa-train-split. 2x costo backtest engine."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--fitness-combined-alpha",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Multi-objective: peso IS (1-alpha = OOS). 1.0=solo IS, 0.5=bilanciato, 0.0=solo OOS",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
@@ -84,6 +149,18 @@ def main() -> None:
|
||||
fees_bp=args.fees_bp,
|
||||
n_trials_dsr=args.n_trials_dsr,
|
||||
db_path=settings.db_path,
|
||||
prompt_mutation_weight=args.prompt_mutation_weight,
|
||||
fees_eat_alpha_threshold=args.fees_eat_alpha_threshold,
|
||||
flat_too_long_threshold=args.flat_too_long_threshold,
|
||||
undertrading_threshold=args.undertrading_threshold,
|
||||
fitness_hard_kill_findings=(
|
||||
("no_trades", "degenerate", "undertrading") if args.fitness_v2 else None
|
||||
),
|
||||
fitness_adversarial_soft_penalty=args.fitness_soft_penalty,
|
||||
wfa_train_split=args.wfa_train_split,
|
||||
wfa_top_k=args.wfa_top_k,
|
||||
eval_oos_during_loop=args.eval_oos_during_loop,
|
||||
fitness_combined_alpha=args.fitness_combined_alpha,
|
||||
)
|
||||
|
||||
run_id = run_phase1(cfg, ohlcv=ohlcv, llm=llm)
|
||||
|
||||
+29
-7
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -9,19 +10,40 @@ from multi_swarm.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm.llm.client import CompletionResult
|
||||
from multi_swarm.orchestrator.run import RunConfig, run_phase1
|
||||
|
||||
_MOCK_STRATEGY = 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MockLLMClient:
|
||||
def complete(
|
||||
self, genome: HypothesisAgentGenome, system: str, user: str,
|
||||
max_tokens: int = 2000,
|
||||
) -> CompletionResult:
|
||||
text = (
|
||||
"```lisp\n"
|
||||
"(strategy"
|
||||
" (when (gt (indicator rsi 14) 70.0) (entry-short))"
|
||||
" (when (lt (indicator rsi 14) 30.0) (entry-long)))\n"
|
||||
"```"
|
||||
)
|
||||
text = "```json\n" + _MOCK_STRATEGY + "\n```"
|
||||
return CompletionResult(
|
||||
text=text, input_tokens=120, output_tokens=60,
|
||||
tier=genome.model_tier, model="mock",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Adversarial agent: ispeziona una :class:`Strategy` con check euristici
|
||||
hand-crafted per scovare patologie note (degenerate, no-trade, over/under
|
||||
trading) 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:
|
||||
|
||||
@@ -9,6 +10,15 @@ Pipeline:
|
||||
Le euristiche sono volutamente coarse: l'agente non rimpiazza la
|
||||
falsificazione, ma sega presto i casi degeneri (es. ``gt close -1e9`` →
|
||||
sempre long) che inquinerebbero il leaderboard del swarm.
|
||||
|
||||
Phase 1.5 hardening: soglie strette per overtrading (n_trades > n_bars/20)
|
||||
e undertrading (HIGH se n_trades < 10), piu' tre nuovi check HIGH:
|
||||
``flat_too_long`` (signal flat >95% delle bar),
|
||||
``time_in_market_too_high`` (signal long/short >80% delle bar, di fatto
|
||||
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
|
||||
@@ -49,8 +59,17 @@ class AdversarialReport:
|
||||
class AdversarialAgent:
|
||||
"""Agente hand-crafted che applica check euristici a una strategia."""
|
||||
|
||||
def __init__(self, fees_bp: float = 5.0) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
fees_bp: float = 5.0,
|
||||
fees_eat_alpha_threshold: float = 0.5,
|
||||
flat_too_long_threshold: float = 0.95,
|
||||
undertrading_threshold: int = 10,
|
||||
) -> None:
|
||||
self._engine = BacktestEngine(fees_bp=fees_bp)
|
||||
self._fees_eat_alpha_threshold = fees_eat_alpha_threshold
|
||||
self._flat_too_long_threshold = flat_too_long_threshold
|
||||
self._undertrading_threshold = undertrading_threshold
|
||||
|
||||
def review(self, strategy: Strategy, ohlcv: pd.DataFrame) -> AdversarialReport:
|
||||
signal_fn = compile_strategy(strategy)
|
||||
@@ -87,24 +106,87 @@ class AdversarialAgent:
|
||||
|
||||
n_bars = len(ohlcv)
|
||||
n_trades = len(result.trades)
|
||||
# Overtrading: > 1 trade ogni 5 bar -> il segnale flippa cosi' spesso
|
||||
# Overtrading: > 1 trade ogni 20 bar (Phase 1.5: era 1/5).
|
||||
# Soglia stretta per scovare strategie che flippano cosi' spesso
|
||||
# che le fees mangiano qualunque edge.
|
||||
if n_trades > n_bars / 5:
|
||||
if n_trades > n_bars / 20:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
name="overtrading",
|
||||
severity=Severity.MEDIUM,
|
||||
detail=f"{n_trades} trades on {n_bars} bars (>1 per 5 bars)",
|
||||
detail=f"{n_trades} trades on {n_bars} bars (>1 per 20 bars)",
|
||||
)
|
||||
)
|
||||
# Undertrading: < 5 trade -> sample size troppo piccolo per
|
||||
# distinguere edge da rumore (lucky shot).
|
||||
if n_trades < 5:
|
||||
# Undertrading: < 10 trade -> HIGH (Phase 1.5: era < 5 MEDIUM).
|
||||
# Sample size troppo piccolo per distinguere edge da rumore: e'
|
||||
# un "lucky shot" non riproducibile out-of-sample.
|
||||
if n_trades < self._undertrading_threshold:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
name="undertrading",
|
||||
severity=Severity.MEDIUM,
|
||||
detail=f"only {n_trades} trades — likely lucky shot",
|
||||
severity=Severity.HIGH,
|
||||
detail=(
|
||||
f"only {n_trades} trades — likely lucky shot "
|
||||
f"(<{self._undertrading_threshold} over training)"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Flat-too-long: signal attivo (LONG o SHORT) per <5% delle bar.
|
||||
# Anche se la strategia produce trade, una che e' inerte 19h su 20
|
||||
# ha mancato il regime ed e' di fatto una non-strategia.
|
||||
# NaN (warmup) contano come "flat" perche' downstream l'engine
|
||||
# li riempie via ffill().fillna(Side.FLAT).
|
||||
n_active = int(((signals == Side.LONG) | (signals == Side.SHORT)).sum())
|
||||
n_flat_or_nan = n_bars - n_active
|
||||
flat_ratio = n_flat_or_nan / n_bars if n_bars > 0 else 1.0
|
||||
if flat_ratio > self._flat_too_long_threshold:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
name="flat_too_long",
|
||||
severity=Severity.HIGH,
|
||||
detail=(
|
||||
f"Signal flat for {flat_ratio * 100:.1f}% of bars "
|
||||
f"(>{self._flat_too_long_threshold * 100:.0f}% threshold)"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# 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.
|
||||
# La strategia ha edge teorico ma il margine viene mangiato dai
|
||||
# costi di transazione: non sostenibile in produzione.
|
||||
# Se gross_pnl <= 0 il check non si applica (gia' perdente).
|
||||
gross_pnl = sum(t.gross_pnl for t in result.trades)
|
||||
total_fees = sum(t.fees for t in result.trades)
|
||||
if gross_pnl > 0 and total_fees / gross_pnl > self._fees_eat_alpha_threshold:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
name="fees_eat_alpha",
|
||||
severity=Severity.HIGH,
|
||||
detail=(
|
||||
f"Fees ${total_fees:.2f} = "
|
||||
f"{total_fees / gross_pnl * 100:.1f}% of gross ${gross_pnl:.2f}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -72,10 +72,12 @@ class FalsificationAgent:
|
||||
periods_per_year=8760,
|
||||
sharpe_var=1.0,
|
||||
)
|
||||
# +1.0 sull'equity curve evita divisione per zero in max_drawdown /
|
||||
# total_return: l'engine produce equity in valore assoluto partendo da
|
||||
# 0, ma le metriche sono definite su serie strettamente positive.
|
||||
equity_pos = result.equity_curve + 1.0
|
||||
# Normalizza l'equity sul prezzo iniziale (notional di una position size 1).
|
||||
# L'engine produce equity in unita' di P&L assoluto partendo da 0; per
|
||||
# max_drawdown e total_return serve una serie strettamente positiva
|
||||
# interpretabile come "wealth ratio" rispetto al notional iniziale.
|
||||
notional = float(ohlcv["close"].iloc[0])
|
||||
equity_pos = (result.equity_curve / notional) + 1.0
|
||||
return FalsificationReport(
|
||||
sharpe=sr,
|
||||
dsr=dsr,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import openai
|
||||
|
||||
from ..genome.hypothesis import HypothesisAgentGenome
|
||||
from ..llm.client import CompletionResult, LLMClient
|
||||
from ..llm.client import CompletionResult, EmptyCompletionError, LLMClient
|
||||
from ..protocol.parser import ParseError, Strategy, parse_strategy
|
||||
from ..protocol.validator import ValidationError, validate_strategy
|
||||
|
||||
@@ -23,10 +25,20 @@ class MarketSummary:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HypothesisProposal:
|
||||
"""Risultato di una propose() del HypothesisAgent.
|
||||
|
||||
``completions`` contiene SEMPRE almeno un elemento: il primo tentativo.
|
||||
Se il primo tentativo fallisce e c'e' budget di retry, vengono accodate
|
||||
le completions successive, una per ogni retry effettuato.
|
||||
``n_attempts == len(completions)``. ``raw_text`` riflette l'ULTIMO output
|
||||
LLM osservato (quello che ha prodotto strategy o l'ultimo parse_error).
|
||||
"""
|
||||
|
||||
strategy: Strategy | None
|
||||
raw_text: str
|
||||
completion: CompletionResult
|
||||
completions: list[CompletionResult] = field(default_factory=list)
|
||||
parse_error: str | None = None
|
||||
n_attempts: int = 1
|
||||
|
||||
|
||||
SYSTEM_TEMPLATE = """\
|
||||
@@ -35,27 +47,111 @@ Sei un agente generatore di ipotesi di trading quantitativo per un sistema swarm
|
||||
Il tuo stile cognitivo: {cognitive_style}
|
||||
Direttiva personale: {system_prompt}
|
||||
|
||||
Devi proporre una strategia di trading espressa nel linguaggio S-expression
|
||||
con i seguenti verbi disponibili:
|
||||
Devi proporre una strategia di trading espressa in JSON STRETTO.
|
||||
La risposta deve essere un singolo oggetto JSON dentro fence ```json...```
|
||||
con questa shape:
|
||||
|
||||
Azioni: entry-long, entry-short, exit, flat
|
||||
Logici: and, or, not
|
||||
Comparatori: gt, lt, eq
|
||||
Dati: feature, indicator, crossover, crossunder
|
||||
```json
|
||||
{{
|
||||
"rules": [
|
||||
{{"condition": <nodo>, "action": "entry-long|entry-short|exit|flat"}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
Indicatori disponibili: sma <length>, rsi <length>, atr <length>, macd, realized_vol <window>.
|
||||
Feature disponibili: open, high, low, close, volume.
|
||||
NODI DISPONIBILI
|
||||
|
||||
Le regole sono valutate in ordine; la prima che matcha vince per ogni timestamp.
|
||||
La default action se nessuna regola matcha è 'flat'.
|
||||
Operatori logici:
|
||||
{{"op": "and", "args": [<nodo>, <nodo>, ...]}} // >=2 nodi
|
||||
{{"op": "or", "args": [<nodo>, <nodo>, ...]}} // >=2 nodi
|
||||
{{"op": "not", "args": [<nodo>]}} // 1 nodo
|
||||
|
||||
Rispondi SOLO con la S-expression in un fence ```lisp ... ```, senza prosa,
|
||||
senza spiegazioni. Esempio formato:
|
||||
Comparatori (ritornano boolean series):
|
||||
{{"op": "gt", "args": [<a>, <b>]}} // a > b
|
||||
{{"op": "lt", "args": [<a>, <b>]}} // a < b
|
||||
{{"op": "eq", "args": [<a>, <b>]}} // a == b
|
||||
|
||||
```lisp
|
||||
(strategy
|
||||
(when (gt (indicator rsi 14) 70.0) (entry-short))
|
||||
(when (lt (indicator rsi 14) 30.0) (entry-long)))
|
||||
Crossover (eventi su 2 serie):
|
||||
{{"op": "crossover", "args": [<serie_a>, <serie_b>]}}
|
||||
{{"op": "crossunder", "args": [<serie_a>, <serie_b>]}}
|
||||
|
||||
Leaf - indicatori (calcolati su close):
|
||||
{{"kind": "indicator", "name": "sma", "params": [<length>]}}
|
||||
{{"kind": "indicator", "name": "rsi", "params": [<length>]}}
|
||||
{{"kind": "indicator", "name": "atr", "params": [<length>]}}
|
||||
{{"kind": "indicator", "name": "realized_vol", "params": [<window>]}}
|
||||
{{"kind": "indicator", "name": "macd", "params": [<fast>, <slow>, <signal>]}}
|
||||
// 0-3 numeri (tutti opzionali con default 12, 26, 9)
|
||||
|
||||
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}}]}}
|
||||
|
||||
Leaf - letterale numerico:
|
||||
{{"kind": "literal", "value": 70.0}}
|
||||
|
||||
PATTERN GUIDANCE (oltre agli indicatori, considera forma delle curve e ripetibilità):
|
||||
|
||||
Forme di curva:
|
||||
- Trend ascendente: SMA(short) > SMA(long) E close > SMA(short)
|
||||
- Trend discendente: SMA(short) < SMA(long) E close < SMA(short)
|
||||
- Compressione di volatilità (pre-breakout): ATR(N) < soglia bassa
|
||||
- Espansione di volatilità: ATR(N) > ATR(N*2) (vol corta > vol lunga)
|
||||
- Mean reversion strutturale: |close - SMA(long)| eccessivo → reversal atteso
|
||||
|
||||
Ripetibilità dell'andamento:
|
||||
- Eventi crossover/crossunder ricorrenti (golden/death cross, RSI cross zone)
|
||||
- Pattern intra-day: usa 'hour' per sfruttare orari di forte volatilità ricorrente
|
||||
- Pattern settimanali: usa 'dow' o 'is_weekend' per cicli mercato
|
||||
- Doppio top approx: RSI > 70 + crossunder RSI 70 (1° picco), poi entro N bar
|
||||
nuovo crossover RSI 70 a livello close simile → entry short
|
||||
- Range breakout: close > SMA(N) con SMA(short) > SMA(long) (compressione + spinta)
|
||||
|
||||
Cerca pattern che si REPLICANO nei dati storici, non singoli eventi rari.
|
||||
|
||||
VINCOLI
|
||||
- Gli indicator NON sono annidabili: 'params' accetta solo numeri, mai altri nodi.
|
||||
- Le regole sono valutate in ordine; la prima che matcha vince per ogni timestamp.
|
||||
- Default action se nessuna regola matcha = flat.
|
||||
- 'op' e 'kind' sono mutuamente esclusivi sullo stesso nodo.
|
||||
|
||||
Rispondi SOLO con il fence ```json...``` contenente l'oggetto strategy.
|
||||
Esempio:
|
||||
|
||||
```json
|
||||
{{
|
||||
"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"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -73,24 +169,93 @@ Genera una strategia che cerchi anomalie sfruttabili in questo regime.
|
||||
"""
|
||||
|
||||
|
||||
_SEXP_FENCE_RE = re.compile(
|
||||
r"```(?:lisp|scheme|sexp)?\s*(\(strategy[\s\S]*?\))\s*```",
|
||||
_RETRY_TEMPLATE = """\
|
||||
{original_user}
|
||||
|
||||
--- TENTATIVO PRECEDENTE FALLITO ---
|
||||
Output: {previous_raw}
|
||||
Errore: {previous_error}
|
||||
---
|
||||
Correggi l'errore e rispondi di nuovo con un singolo oggetto JSON valido
|
||||
dentro fence ```json...```, seguendo strettamente lo schema fornito nel
|
||||
SYSTEM message.
|
||||
"""
|
||||
|
||||
_RETRY_RAW_TRUNCATE = 800
|
||||
|
||||
|
||||
_JSON_FENCE_RE = re.compile(
|
||||
r"```(?:json)?\s*(\{[\s\S]*\})\s*```",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_sexp(text: str) -> str | None:
|
||||
m = _SEXP_FENCE_RE.search(text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
if text.strip().startswith("(strategy"):
|
||||
return text.strip()
|
||||
def _balance_braces(s: str) -> str | None:
|
||||
"""Ritorna il prefix di ``s`` che chiude la prima ``{`` con bilanciamento.
|
||||
|
||||
Usato come fallback quando l'LLM ritorna JSON top-level senza fence ma
|
||||
seguito da prosa: troviamo dove finisce il primo oggetto e tagliamo.
|
||||
"""
|
||||
if not s.startswith("{"):
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for i, ch in enumerate(s):
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return s[: i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _extract_json(text: str) -> str | None:
|
||||
"""Estrai un oggetto JSON dal testo del completion.
|
||||
|
||||
Strategie di estrazione, in ordine:
|
||||
1. Fence ```json...``` (greedy: cattura fino all'ultimo ``}`` prima della
|
||||
chiusura del fence).
|
||||
2. Testo che inizia direttamente con ``{`` (dopo strip), bilanciato a
|
||||
livello di parentesi graffe.
|
||||
"""
|
||||
m = _JSON_FENCE_RE.search(text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
stripped = text.strip()
|
||||
return _balance_braces(stripped)
|
||||
|
||||
|
||||
def _try_parse(text: str) -> tuple[Strategy | None, str | None]:
|
||||
"""Estrai+parsea+valida. Ritorna (strategy, error). Esattamente uno e' None."""
|
||||
payload = _extract_json(text)
|
||||
if payload is None:
|
||||
return None, "no JSON object found in output"
|
||||
try:
|
||||
ast = parse_strategy(payload)
|
||||
validate_strategy(ast)
|
||||
except (ParseError, ValidationError) as e:
|
||||
return None, str(e)
|
||||
return ast, None
|
||||
|
||||
|
||||
class HypothesisAgent:
|
||||
def __init__(self, llm: LLMClient):
|
||||
def __init__(self, llm: LLMClient, max_retries: int = 1):
|
||||
if max_retries < 0:
|
||||
raise ValueError("max_retries must be >= 0")
|
||||
self._llm = llm
|
||||
self._max_retries = max_retries
|
||||
|
||||
def propose(
|
||||
self,
|
||||
@@ -101,7 +266,7 @@ class HypothesisAgent:
|
||||
cognitive_style=genome.cognitive_style,
|
||||
system_prompt=genome.system_prompt,
|
||||
)
|
||||
user = USER_TEMPLATE.format(
|
||||
original_user = USER_TEMPLATE.format(
|
||||
symbol=market.symbol,
|
||||
timeframe=market.timeframe,
|
||||
n_bars=market.n_bars,
|
||||
@@ -114,28 +279,58 @@ class HypothesisAgent:
|
||||
lookback_window=genome.lookback_window,
|
||||
)
|
||||
|
||||
completion = self._llm.complete(genome, system=system, user=user)
|
||||
completions: list[CompletionResult] = []
|
||||
errors: list[str] = []
|
||||
last_raw = ""
|
||||
max_attempts = 1 + self._max_retries
|
||||
|
||||
sexp = _extract_sexp(completion.text)
|
||||
if sexp is None:
|
||||
return HypothesisProposal(
|
||||
strategy=None,
|
||||
raw_text=completion.text,
|
||||
completion=completion,
|
||||
parse_error="no s-expression found in output",
|
||||
for attempt in range(max_attempts):
|
||||
if attempt == 0:
|
||||
user = original_user
|
||||
else:
|
||||
truncated = last_raw[:_RETRY_RAW_TRUNCATE]
|
||||
user = _RETRY_TEMPLATE.format(
|
||||
original_user=original_user,
|
||||
previous_raw=truncated,
|
||||
previous_error=errors[-1],
|
||||
)
|
||||
|
||||
try:
|
||||
ast = parse_strategy(sexp)
|
||||
validate_strategy(ast)
|
||||
completion = self._llm.complete(genome, system=system, user=user)
|
||||
except EmptyCompletionError as e:
|
||||
# LLM esaurito retry tenacity senza una risposta. Tratta come
|
||||
# parse-fail "empty" e ritenta nel loop esterno (max_attempts).
|
||||
errors.append(f"empty_completion: {e}")
|
||||
last_raw = ""
|
||||
continue
|
||||
except openai.RateLimitError as e:
|
||||
# Provider upstream rate limited oltre i retry tenacity.
|
||||
# Marca genome come fallito senza propagare l'eccezione al run.
|
||||
errors.append(f"rate_limit: {e}")
|
||||
last_raw = ""
|
||||
continue
|
||||
completions.append(completion)
|
||||
last_raw = completion.text
|
||||
|
||||
strategy, err = _try_parse(completion.text)
|
||||
if strategy is not None:
|
||||
return HypothesisProposal(
|
||||
strategy=ast,
|
||||
strategy=strategy,
|
||||
raw_text=completion.text,
|
||||
completion=completion,
|
||||
completions=completions,
|
||||
parse_error=None,
|
||||
n_attempts=len(completions),
|
||||
)
|
||||
assert err is not None
|
||||
errors.append(err)
|
||||
|
||||
chained = " | ".join(
|
||||
f"attempt {i + 1}: {e}" for i, e in enumerate(errors)
|
||||
)
|
||||
except (ParseError, ValidationError) as e:
|
||||
return HypothesisProposal(
|
||||
strategy=None,
|
||||
raw_text=completion.text,
|
||||
completion=completion,
|
||||
parse_error=str(e),
|
||||
raw_text=last_raw,
|
||||
completions=completions,
|
||||
parse_error=chained,
|
||||
n_attempts=len(completions),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Backtest layer: order/position/trade dataclasses + engine."""
|
||||
|
||||
from .engine import BacktestEngine, BacktestResult
|
||||
from .orders import Order, Position, Side, Trade
|
||||
|
||||
__all__ = [
|
||||
"BacktestEngine",
|
||||
"BacktestResult",
|
||||
"Order",
|
||||
"Position",
|
||||
"Side",
|
||||
"Trade",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Cerbero data-provider HTTP client."""
|
||||
|
||||
from .client import CerberoClient
|
||||
|
||||
__all__ = ["CerberoClient"]
|
||||
|
||||
@@ -25,11 +25,11 @@ class Settings(BaseSettings):
|
||||
|
||||
openrouter_api_key: SecretStr
|
||||
|
||||
llm_model_tier_s: str = "anthropic/claude-opus-4-7"
|
||||
llm_model_tier_a: str = "anthropic/claude-sonnet-4-6"
|
||||
llm_model_tier_b: str = "anthropic/claude-sonnet-4-6"
|
||||
llm_model_tier_s: str = "google/gemini-3-flash-preview"
|
||||
llm_model_tier_a: str = "deepseek/deepseek-v4-flash"
|
||||
llm_model_tier_b: str = "deepseek/deepseek-v4-flash"
|
||||
llm_model_tier_c: str = "qwen/qwen-2.5-72b-instruct"
|
||||
llm_model_tier_d: str = "meta-llama/llama-3.3-70b-instruct"
|
||||
llm_model_tier_d: str = "openai/gpt-oss-20b"
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
|
||||
run_name: str = "phase1-spike-001"
|
||||
|
||||
@@ -1,590 +0,0 @@
|
||||
"""Aquarium 2D visualization helpers.
|
||||
|
||||
Builds fish records (with full genome attributes + ancestor lineage) and
|
||||
renders a self-contained HTML/JS canvas animation, embeddable in Streamlit
|
||||
via ``streamlit.components.v1.html``. Includes a click handler that opens
|
||||
an info panel showing genome details and BFS ancestor levels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd # type: ignore[import-untyped]
|
||||
|
||||
# Color palette per cognitive style. Default fallback for unknown styles is grey.
|
||||
STYLE_COLORS: dict[str, str] = {
|
||||
"physicist": "#4cc9f0",
|
||||
"biologist": "#52b788",
|
||||
"historian": "#e76f51",
|
||||
"meteorologist": "#ffd166",
|
||||
"ecologist": "#a78bfa",
|
||||
"engineer": "#fb6f92",
|
||||
}
|
||||
DEFAULT_COLOR: str = "#94a3b8"
|
||||
|
||||
|
||||
def _is_nan(v: Any) -> bool:
|
||||
try:
|
||||
return bool(pd.isna(v))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _safe_float(v: Any, default: float = 0.0) -> float:
|
||||
if v is None or _is_nan(v):
|
||||
return default
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_int(v: Any, default: int = 0) -> int:
|
||||
if v is None or _is_nan(v):
|
||||
return default
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_str(v: Any, default: str = "") -> str:
|
||||
if v is None or _is_nan(v):
|
||||
return default
|
||||
return str(v)
|
||||
|
||||
|
||||
def _safe_list(v: Any) -> list[Any]:
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return list(v)
|
||||
# pandas may store python lists in object cells; if it's e.g. a numpy array,
|
||||
# falling back to list() is fine. NaN scalar is excluded by _is_nan.
|
||||
if _is_nan(v):
|
||||
return []
|
||||
try:
|
||||
return list(v)
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
|
||||
def build_lineage_index(
|
||||
genomes_df: pd.DataFrame, evals_df: pd.DataFrame
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Build ``{genome_id: attrs}`` for every genome in the run.
|
||||
|
||||
``genomes_df`` must come from ``genomes_df(repo, run_id)`` (no gen filter):
|
||||
columns include ``id``, ``generation_idx``, ``system_prompt``,
|
||||
``feature_access``, ``temperature``, ``top_p``, ``model_tier``,
|
||||
``lookback_window``, ``cognitive_style``, ``parent_ids``, ``generation``.
|
||||
|
||||
``evals_df`` must come from ``evaluations_df(repo, run_id)``: columns
|
||||
include ``genome_id``, ``fitness``, ``dsr``, ``sharpe``, ``max_dd``,
|
||||
``n_trades``.
|
||||
"""
|
||||
if genomes_df.empty:
|
||||
return {}
|
||||
|
||||
if evals_df is None or evals_df.empty:
|
||||
merged = genomes_df.copy()
|
||||
for col in ("fitness", "dsr", "sharpe", "max_dd", "n_trades"):
|
||||
if col not in merged.columns:
|
||||
merged[col] = 0.0 if col != "n_trades" else 0
|
||||
else:
|
||||
merged = genomes_df.merge(
|
||||
evals_df,
|
||||
left_on="id",
|
||||
right_on="genome_id",
|
||||
how="left",
|
||||
suffixes=("", "_eval"),
|
||||
)
|
||||
|
||||
index: dict[str, dict[str, Any]] = {}
|
||||
for _, row in merged.iterrows():
|
||||
gid = _safe_str(row.get("id"))
|
||||
if not gid:
|
||||
continue
|
||||
# ``generation`` is the genome's evolutionary generation (from payload).
|
||||
# If absent, fall back to ``generation_idx`` (column added by the
|
||||
# repository). Defensive: both may be missing in edge cases.
|
||||
gen_val: Any = row.get("generation")
|
||||
if gen_val is None or _is_nan(gen_val):
|
||||
gen_val = row.get("generation_idx", 0)
|
||||
index[gid] = {
|
||||
"id": gid,
|
||||
"generation": _safe_int(gen_val, 0),
|
||||
"fitness": _safe_float(row.get("fitness"), 0.0),
|
||||
"dsr": _safe_float(row.get("dsr"), 0.0),
|
||||
"sharpe": _safe_float(row.get("sharpe"), 0.0),
|
||||
"max_dd": _safe_float(row.get("max_dd"), 0.0),
|
||||
"n_trades": _safe_int(row.get("n_trades"), 0),
|
||||
"cognitive_style": _safe_str(row.get("cognitive_style"), ""),
|
||||
"system_prompt": _safe_str(row.get("system_prompt"), ""),
|
||||
"temperature": _safe_float(row.get("temperature"), 0.0),
|
||||
"lookback_window": _safe_int(row.get("lookback_window"), 0),
|
||||
"feature_access": _safe_list(row.get("feature_access")),
|
||||
"model_tier": _safe_str(row.get("model_tier"), ""),
|
||||
"parent_ids": _safe_list(row.get("parent_ids")),
|
||||
}
|
||||
return index
|
||||
|
||||
|
||||
def trace_ancestors(
|
||||
genome_id: str,
|
||||
lineage_index: dict[str, dict[str, Any]],
|
||||
max_levels: int = 5,
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
"""BFS over ``parent_ids`` returning levels of ancestors.
|
||||
|
||||
``levels[0]`` = direct parents, ``levels[1]`` = grandparents, etc. Each
|
||||
entry is a small dict (no ``system_prompt``, to keep JSON payload light):
|
||||
``{id, generation, fitness, cognitive_style}``. Cycles are guarded via a
|
||||
``seen`` set; missing parents (not in this run) are stubbed with sentinel
|
||||
values so the lineage display still renders the relationship.
|
||||
"""
|
||||
levels: list[list[dict[str, Any]]] = []
|
||||
root = lineage_index.get(genome_id, {})
|
||||
current_ids: list[str] = list(root.get("parent_ids", []))
|
||||
seen: set[str] = {genome_id}
|
||||
for _ in range(max_levels):
|
||||
if not current_ids:
|
||||
break
|
||||
level_entries: list[dict[str, Any]] = []
|
||||
next_ids: list[str] = []
|
||||
for pid in current_ids:
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
entry = lineage_index.get(pid)
|
||||
if entry is None:
|
||||
level_entries.append(
|
||||
{
|
||||
"id": pid,
|
||||
"generation": -1,
|
||||
"fitness": 0.0,
|
||||
"cognitive_style": "",
|
||||
}
|
||||
)
|
||||
continue
|
||||
level_entries.append(
|
||||
{
|
||||
"id": entry["id"],
|
||||
"generation": entry["generation"],
|
||||
"fitness": entry["fitness"],
|
||||
"cognitive_style": entry["cognitive_style"],
|
||||
}
|
||||
)
|
||||
next_ids.extend(entry.get("parent_ids", []))
|
||||
if not level_entries:
|
||||
break
|
||||
levels.append(level_entries)
|
||||
current_ids = next_ids
|
||||
return levels
|
||||
|
||||
|
||||
def build_fish_dataset(
|
||||
active_df: pd.DataFrame,
|
||||
lineage_index: dict[str, dict[str, Any]] | None = None,
|
||||
max_lineage_levels: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build full fish records for each active genome.
|
||||
|
||||
For every row in ``active_df`` the matching entry in ``lineage_index`` is
|
||||
looked up by ``genome_id`` (or ``id``) and attached together with the BFS
|
||||
ancestor levels. Rows whose id is not in the index are skipped.
|
||||
|
||||
Backward-compat: if ``lineage_index`` is ``None`` (legacy call site, e.g.
|
||||
test fixtures with simple merged DataFrames) we synthesize a minimal
|
||||
lineage from ``active_df`` itself so the function still returns useful
|
||||
fish records.
|
||||
"""
|
||||
if active_df.empty:
|
||||
return []
|
||||
|
||||
if lineage_index is None:
|
||||
# Legacy path: build a tiny index from the active df only.
|
||||
synth: dict[str, dict[str, Any]] = {}
|
||||
for _, row in active_df.iterrows():
|
||||
gid = _safe_str(row.get("genome_id") or row.get("id"))
|
||||
if not gid:
|
||||
continue
|
||||
fitness_val = _safe_float(row.get("fitness"), float("nan"))
|
||||
if math.isnan(fitness_val):
|
||||
continue
|
||||
synth[gid] = {
|
||||
"id": gid,
|
||||
"generation": _safe_int(row.get("generation"), 0),
|
||||
"fitness": fitness_val,
|
||||
"dsr": _safe_float(row.get("dsr"), 0.0),
|
||||
"sharpe": _safe_float(row.get("sharpe"), 0.0),
|
||||
"max_dd": _safe_float(row.get("max_dd"), 0.0),
|
||||
"n_trades": _safe_int(row.get("n_trades"), 0),
|
||||
"cognitive_style": _safe_str(row.get("cognitive_style"), "unknown"),
|
||||
"system_prompt": _safe_str(row.get("system_prompt"), ""),
|
||||
"temperature": _safe_float(row.get("temperature"), 0.0),
|
||||
"lookback_window": _safe_int(row.get("lookback_window"), 0),
|
||||
"feature_access": _safe_list(row.get("feature_access")),
|
||||
"model_tier": _safe_str(row.get("model_tier"), ""),
|
||||
"parent_ids": _safe_list(row.get("parent_ids")),
|
||||
}
|
||||
lineage_index = synth
|
||||
|
||||
fish: list[dict[str, Any]] = []
|
||||
for _, row in active_df.iterrows():
|
||||
gid = _safe_str(row.get("genome_id") or row.get("id"))
|
||||
if not gid:
|
||||
continue
|
||||
attrs = lineage_index.get(gid)
|
||||
if attrs is None:
|
||||
continue
|
||||
if math.isnan(attrs.get("fitness", 0.0)):
|
||||
continue
|
||||
ancestors = trace_ancestors(gid, lineage_index, max_lineage_levels)
|
||||
record = {**attrs, "ancestors": ancestors}
|
||||
fish.append(record)
|
||||
return fish
|
||||
|
||||
|
||||
def build_aquarium_html(
|
||||
fish: list[dict[str, Any]],
|
||||
canvas_w: int = 1000,
|
||||
canvas_h: int = 600,
|
||||
) -> str:
|
||||
"""Build the self-contained HTML/JS string for the aquarium canvas.
|
||||
|
||||
The output embeds a click handler: tapping a fish opens an info panel
|
||||
(top-right of the canvas) showing its genome attributes and BFS ancestor
|
||||
levels. Labels are no longer rendered on the canvas itself.
|
||||
"""
|
||||
fish_json = json.dumps(fish)
|
||||
palette_json = json.dumps(STYLE_COLORS)
|
||||
default_color = DEFAULT_COLOR
|
||||
|
||||
# All braces inside <style>/<script> are escaped to literals using {{ }}
|
||||
# so we can use Python f-string substitution for the few JSON payloads.
|
||||
return f"""
|
||||
<div style="position:relative;width:100%;height:{canvas_h}px;">
|
||||
<canvas id="aquarium" width="{canvas_w}" height="{canvas_h}"
|
||||
style="width:100%;height:{canvas_h}px;border-radius:12px;
|
||||
background:linear-gradient(180deg,#0a2540 0%,#1a4d80 100%);
|
||||
display:block;cursor:pointer;"></canvas>
|
||||
<div id="fish-info-panel" style="
|
||||
position:absolute;
|
||||
top:12px;
|
||||
right:12px;
|
||||
width:340px;
|
||||
max-height:580px;
|
||||
overflow-y:auto;
|
||||
background:rgba(8,16,32,0.92);
|
||||
color:#e2e8f0;
|
||||
border-radius:10px;
|
||||
padding:14px 16px;
|
||||
font-family:system-ui,-apple-system,sans-serif;
|
||||
font-size:12px;
|
||||
line-height:1.5;
|
||||
border:1px solid rgba(255,255,255,0.1);
|
||||
backdrop-filter:blur(6px);
|
||||
-webkit-backdrop-filter:blur(6px);
|
||||
display:none;
|
||||
z-index:10;
|
||||
">
|
||||
<div id="fish-info-content"></div>
|
||||
<button id="fish-info-close" style="
|
||||
position:absolute;top:8px;right:10px;
|
||||
background:transparent;color:#94a3b8;border:none;
|
||||
cursor:pointer;font-size:16px;
|
||||
">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {{
|
||||
const FISH_DATA = {fish_json};
|
||||
const STYLE_COLORS = {palette_json};
|
||||
const DEFAULT_COLOR = {json.dumps(default_color)};
|
||||
|
||||
const canvas = document.getElementById('aquarium');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
|
||||
const panel = document.getElementById('fish-info-panel');
|
||||
const panelContent = document.getElementById('fish-info-content');
|
||||
const closeBtn = document.getElementById('fish-info-close');
|
||||
if (closeBtn) {{
|
||||
closeBtn.addEventListener('click', function() {{
|
||||
panel.style.display = 'none';
|
||||
}});
|
||||
}}
|
||||
|
||||
// Normalize fitness for sizing.
|
||||
let maxFit = 0.0;
|
||||
for (const f of FISH_DATA) {{
|
||||
if (typeof f.fitness === 'number' && f.fitness > maxFit) maxFit = f.fitness;
|
||||
}}
|
||||
|
||||
function lerp(a, b, t) {{ return a + (b - a) * t; }}
|
||||
|
||||
function radiusFor(fitness) {{
|
||||
if (maxFit <= 0) return 8;
|
||||
const t = Math.max(0.05, Math.min(1.0, fitness / maxFit));
|
||||
return lerp(8, 40, t);
|
||||
}}
|
||||
|
||||
function colorFor(style) {{
|
||||
return STYLE_COLORS[style] || DEFAULT_COLOR;
|
||||
}}
|
||||
|
||||
// Init fish state. Each entry keeps a reference to the original data dict
|
||||
// so the click handler can show full attributes + ancestors.
|
||||
const fishState = FISH_DATA.map(function(f, idx) {{
|
||||
const r = radiusFor(f.fitness);
|
||||
return {{
|
||||
data: f,
|
||||
color: colorFor(f.cognitive_style),
|
||||
radius: r,
|
||||
x: Math.random() * (W - 2 * r) + r,
|
||||
y: Math.random() * (H - 2 * r) + r,
|
||||
vx: (Math.random() - 0.5) * 1.5,
|
||||
vy: (Math.random() - 0.5) * 1.0,
|
||||
rank: idx,
|
||||
}};
|
||||
}});
|
||||
|
||||
// Bubbles for ambience.
|
||||
const N_BUBBLES = 25;
|
||||
const bubbles = Array.from({{length: N_BUBBLES}}, function() {{ return {{
|
||||
x: Math.random() * W,
|
||||
y: Math.random() * H,
|
||||
r: 1 + Math.random() * 3,
|
||||
vy: 0.3 + Math.random() * 0.7,
|
||||
}}; }});
|
||||
|
||||
function drawBubble(b) {{
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.fill();
|
||||
}}
|
||||
|
||||
function updateBubble(b) {{
|
||||
b.y -= b.vy;
|
||||
if (b.y < -10) {{
|
||||
b.y = H + 5;
|
||||
b.x = Math.random() * W;
|
||||
}}
|
||||
}}
|
||||
|
||||
function drawFish(f) {{
|
||||
const facingLeft = f.vx < 0;
|
||||
ctx.save();
|
||||
ctx.translate(f.x, f.y);
|
||||
if (facingLeft) ctx.scale(-1, 1);
|
||||
|
||||
// Halo for top-3 fish.
|
||||
if (f.rank < 3) {{
|
||||
const grad = ctx.createRadialGradient(0, 0, f.radius * 0.5, 0, 0, f.radius * 2.0);
|
||||
grad.addColorStop(0, f.color + 'aa');
|
||||
grad.addColorStop(1, f.color + '00');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, f.radius * 2.0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}}
|
||||
|
||||
// Body (ellipse).
|
||||
ctx.fillStyle = f.color;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, f.radius, f.radius * 0.6, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Tail.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-f.radius, 0);
|
||||
ctx.lineTo(-f.radius * 1.6, -f.radius * 0.5);
|
||||
ctx.lineTo(-f.radius * 1.6, f.radius * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Eye.
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.radius * 0.45, -f.radius * 0.15, Math.max(1.5, f.radius * 0.12), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.radius * 0.50, -f.radius * 0.15, Math.max(0.8, f.radius * 0.06), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}}
|
||||
|
||||
function updateFish(f) {{
|
||||
f.vx += (Math.random() - 0.5) * 0.05;
|
||||
f.vy += (Math.random() - 0.5) * 0.05;
|
||||
|
||||
const speed = Math.hypot(f.vx, f.vy);
|
||||
const maxSpeed = 1.5;
|
||||
if (speed > maxSpeed) {{
|
||||
f.vx = (f.vx / speed) * maxSpeed;
|
||||
f.vy = (f.vy / speed) * maxSpeed;
|
||||
}}
|
||||
|
||||
f.x += f.vx;
|
||||
f.y += f.vy;
|
||||
|
||||
if (f.x < f.radius) {{ f.x = f.radius; f.vx = -f.vx; }}
|
||||
if (f.x > W - f.radius) {{ f.x = W - f.radius; f.vx = -f.vx; }}
|
||||
if (f.y < f.radius) {{ f.y = f.radius; f.vy = -f.vy; }}
|
||||
if (f.y > H - f.radius) {{ f.y = H - f.radius; f.vy = -f.vy; }}
|
||||
}}
|
||||
|
||||
function frame() {{
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 6; i++) {{
|
||||
const y = (H / 6) * i + (Date.now() / 50 % (H / 6));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(W, y);
|
||||
ctx.stroke();
|
||||
}}
|
||||
|
||||
for (const b of bubbles) {{
|
||||
updateBubble(b);
|
||||
drawBubble(b);
|
||||
}}
|
||||
for (const f of fishState) {{
|
||||
updateFish(f);
|
||||
drawFish(f);
|
||||
}}
|
||||
requestAnimationFrame(frame);
|
||||
}}
|
||||
|
||||
// CLICK HANDLER: hit-test in canvas pixel space (account for CSS scaling).
|
||||
canvas.addEventListener('click', function(e) {{
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const cx = (e.clientX - rect.left) * scaleX;
|
||||
const cy = (e.clientY - rect.top) * scaleY;
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
for (const f of fishState) {{
|
||||
const dx = cx - f.x;
|
||||
const dy = cy - f.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
const hit = Math.max(f.radius + 6, 14);
|
||||
if (d < hit && d < bestDist) {{
|
||||
best = f;
|
||||
bestDist = d;
|
||||
}}
|
||||
}}
|
||||
if (best) showFishInfo(best);
|
||||
}});
|
||||
|
||||
const ROW_STYLE = 'display:flex;justify-content:space-between;'
|
||||
+ 'padding:2px 0;border-bottom:1px solid rgba(255,255,255,0.05);';
|
||||
const PROMPT_STYLE = 'margin-top:10px;padding:8px;'
|
||||
+ 'background:rgba(255,255,255,0.04);border-radius:6px;'
|
||||
+ 'font-size:11px;font-style:italic;color:#cbd5e1;';
|
||||
const ANC_HEAD_STYLE = 'margin:14px 0 6px 0;color:#94a3b8;'
|
||||
+ 'text-transform:uppercase;font-size:10px;letter-spacing:1px;';
|
||||
const ANC_ROW_STYLE = 'display:flex;align-items:center;padding:4px 6px;'
|
||||
+ 'margin-bottom:2px;background:rgba(255,255,255,0.03);'
|
||||
+ 'border-radius:4px;border-left:3px solid ';
|
||||
const NO_ANC_STYLE = 'margin-top:10px;font-size:10px;color:#64748b;';
|
||||
const DASH = '\\u2014';
|
||||
|
||||
function metricRow(label, value) {{
|
||||
return '<div style="' + ROW_STYLE + '">'
|
||||
+ '<span style="color:#94a3b8;">' + label + '</span>'
|
||||
+ '<span style="color:#e2e8f0;">' + value + '</span></div>';
|
||||
}}
|
||||
|
||||
function escapeHtml(s) {{
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(String(s)));
|
||||
return div.innerHTML;
|
||||
}}
|
||||
|
||||
function fmt(v, dp) {{
|
||||
if (v === null || v === undefined || typeof v !== 'number' || isNaN(v)) {{
|
||||
return DASH;
|
||||
}}
|
||||
return v.toFixed(dp);
|
||||
}}
|
||||
|
||||
function showFishInfo(fish) {{
|
||||
const data = fish.data;
|
||||
const styleColor = STYLE_COLORS[data.cognitive_style] || DEFAULT_COLOR;
|
||||
let html = '';
|
||||
const idShort = String(data.id || '').slice(0, 8);
|
||||
html += '<h4 style="margin:0 0 10px 0;color:' + styleColor + ';">';
|
||||
html += escapeHtml(idShort)
|
||||
+ ' <span style="color:#94a3b8;font-weight:normal;font-size:11px;">'
|
||||
+ 'gen ' + escapeHtml(data.generation) + '</span>';
|
||||
html += '</h4>';
|
||||
html += metricRow('fitness', fmt(data.fitness, 3));
|
||||
html += metricRow('DSR', fmt(data.dsr, 3));
|
||||
html += metricRow('Sharpe', fmt(data.sharpe, 3));
|
||||
html += metricRow('max DD', fmt(data.max_dd, 3));
|
||||
const trades = data.n_trades == null ? 0 : data.n_trades;
|
||||
html += metricRow('trades', escapeHtml(trades));
|
||||
html += metricRow('style', escapeHtml(data.cognitive_style || DASH));
|
||||
html += metricRow('tier', escapeHtml(data.model_tier || DASH));
|
||||
html += metricRow('temp', fmt(data.temperature, 2));
|
||||
const lookback = data.lookback_window == null ? DASH : data.lookback_window;
|
||||
html += metricRow('lookback', escapeHtml(lookback));
|
||||
const feats = (data.feature_access || []).join(', ');
|
||||
html += metricRow('features', escapeHtml(feats || DASH));
|
||||
if (data.system_prompt) {{
|
||||
html += '<div style="' + PROMPT_STYLE + '">'
|
||||
+ escapeHtml(data.system_prompt) + '</div>';
|
||||
}}
|
||||
if (data.ancestors && data.ancestors.length > 0) {{
|
||||
html += '<h5 style="' + ANC_HEAD_STYLE + '">Discendenza</h5>';
|
||||
data.ancestors.forEach(function(level, idx) {{
|
||||
html += '<div style="margin-bottom:8px;">';
|
||||
html += '<div style="font-size:10px;color:#64748b;margin-bottom:4px;">'
|
||||
+ 'Gen N\\u2212' + (idx + 1) + '</div>';
|
||||
level.forEach(function(ancestor) {{
|
||||
const c = STYLE_COLORS[ancestor.cognitive_style] || DEFAULT_COLOR;
|
||||
const aShort = String(ancestor.id || '').slice(0, 8);
|
||||
html += '<div style="' + ANC_ROW_STYLE + c + ';">';
|
||||
html += '<code style="color:' + c + ';font-size:10px;">'
|
||||
+ escapeHtml(aShort) + '</code>';
|
||||
const af = ancestor.fitness;
|
||||
const fitTxt = (typeof af === 'number' && !isNaN(af))
|
||||
? af.toFixed(2) : DASH;
|
||||
html += '<span style="margin-left:auto;color:#94a3b8;font-size:10px;">'
|
||||
+ 'fit ' + fitTxt + '</span>';
|
||||
html += '</div>';
|
||||
}});
|
||||
html += '</div>';
|
||||
}});
|
||||
}} else {{
|
||||
html += '<div style="' + NO_ANC_STYLE + '">'
|
||||
+ 'Genoma di prima generazione (no ancestors)</div>';
|
||||
}}
|
||||
panelContent.innerHTML = html;
|
||||
panel.style.display = 'block';
|
||||
}}
|
||||
|
||||
if (fishState.length === 0) {{
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Acquario vuoto: nessun genoma da mostrare.', W / 2, H / 2);
|
||||
}} else {{
|
||||
requestAnimationFrame(frame);
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -52,3 +53,121 @@ def genomes_df(
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(flat)
|
||||
|
||||
|
||||
def _paper_conn(db_path: str | Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def paper_runs_df(db_path: str | Path) -> pd.DataFrame:
|
||||
with _paper_conn(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, started_at, stopped_at, status, initial_capital, config_json "
|
||||
"FROM paper_trading_runs ORDER BY started_at DESC"
|
||||
).fetchall()
|
||||
return pd.DataFrame([dict(r) for r in rows])
|
||||
|
||||
|
||||
def paper_equity_df(db_path: str | Path, run_id: str) -> pd.DataFrame:
|
||||
with _paper_conn(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, equity, cash, positions_value FROM paper_trading_equity "
|
||||
"WHERE paper_run_id=? ORDER BY ts ASC",
|
||||
(run_id,),
|
||||
).fetchall()
|
||||
return pd.DataFrame([dict(r) for r in rows])
|
||||
|
||||
|
||||
def paper_positions_df(db_path: str | Path, run_id: str) -> pd.DataFrame:
|
||||
with _paper_conn(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT symbol, side, qty, entry_price, entry_ts "
|
||||
"FROM paper_trading_positions WHERE paper_run_id=? ORDER BY symbol",
|
||||
(run_id,),
|
||||
).fetchall()
|
||||
return pd.DataFrame([dict(r) for r in rows])
|
||||
|
||||
|
||||
def paper_trades_df(db_path: str | Path, run_id: str, limit: int = 100) -> pd.DataFrame:
|
||||
with _paper_conn(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT symbol, side, qty, entry_price, exit_price, entry_ts, exit_ts, pnl, fees "
|
||||
"FROM paper_trading_trades WHERE paper_run_id=? ORDER BY exit_ts DESC LIMIT ?",
|
||||
(run_id, limit),
|
||||
).fetchall()
|
||||
return pd.DataFrame([dict(r) for r in rows])
|
||||
|
||||
|
||||
def paper_ticks_df(db_path: str | Path, run_id: str, limit: int = 50) -> pd.DataFrame:
|
||||
with _paper_conn(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, bar_ts, symbol, close_price, signal, action_taken "
|
||||
"FROM paper_trading_ticks WHERE paper_run_id=? ORDER BY ts DESC LIMIT ?",
|
||||
(run_id, limit),
|
||||
).fetchall()
|
||||
return pd.DataFrame([dict(r) for r in rows])
|
||||
|
||||
|
||||
def paper_run_summary(db_path: str | Path, run_id: str) -> dict[str, Any]:
|
||||
"""Aggrega metriche sintetiche per la pagina paper trading."""
|
||||
with _paper_conn(db_path) as conn:
|
||||
run = conn.execute(
|
||||
"SELECT id, name, started_at, stopped_at, status, initial_capital, config_json "
|
||||
"FROM paper_trading_runs WHERE id=?",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
if run is None:
|
||||
return {}
|
||||
run = dict(run)
|
||||
|
||||
eq_row = conn.execute(
|
||||
"SELECT equity, cash, positions_value, ts FROM paper_trading_equity "
|
||||
"WHERE paper_run_id=? ORDER BY ts DESC LIMIT 1",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
|
||||
trades_agg = conn.execute(
|
||||
"SELECT COUNT(*) AS n, COALESCE(SUM(pnl),0) AS sum_pnl, "
|
||||
"COALESCE(SUM(fees),0) AS sum_fees FROM paper_trading_trades "
|
||||
"WHERE paper_run_id=?",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
|
||||
tick_agg = conn.execute(
|
||||
"SELECT COUNT(*) AS n, MAX(ts) AS last_ts FROM paper_trading_ticks "
|
||||
"WHERE paper_run_id=?",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
|
||||
positions_n = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM paper_trading_positions WHERE paper_run_id=?",
|
||||
(run_id,),
|
||||
).fetchone()["n"]
|
||||
|
||||
initial = float(run["initial_capital"])
|
||||
current_equity = float(eq_row["equity"]) if eq_row is not None else initial
|
||||
pnl_pct = (current_equity - initial) / initial * 100.0 if initial else 0.0
|
||||
|
||||
return {
|
||||
"id": run["id"],
|
||||
"name": run["name"],
|
||||
"status": run["status"],
|
||||
"started_at": run["started_at"],
|
||||
"stopped_at": run["stopped_at"],
|
||||
"initial_capital": initial,
|
||||
"config": json.loads(run["config_json"]),
|
||||
"current_equity": current_equity,
|
||||
"current_cash": float(eq_row["cash"]) if eq_row is not None else initial,
|
||||
"current_positions_value": float(eq_row["positions_value"]) if eq_row is not None else 0.0,
|
||||
"last_equity_ts": eq_row["ts"] if eq_row is not None else None,
|
||||
"pnl_abs": current_equity - initial,
|
||||
"pnl_pct": pnl_pct,
|
||||
"n_trades": int(trades_agg["n"]),
|
||||
"trades_pnl": float(trades_agg["sum_pnl"]),
|
||||
"trades_fees": float(trades_agg["sum_fees"]),
|
||||
"n_ticks": int(tick_agg["n"]),
|
||||
"last_tick_ts": tick_agg["last_ts"],
|
||||
"n_open_positions": int(positions_n),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from multi_swarm.dashboard.data import (
|
||||
evaluations_df,
|
||||
get_repo,
|
||||
get_run_overview,
|
||||
list_runs_df,
|
||||
)
|
||||
|
||||
st.title("Overview")
|
||||
|
||||
db_path = st.session_state.get("db_path", "./runs.db")
|
||||
repo = get_repo(db_path)
|
||||
|
||||
runs = list_runs_df(repo)
|
||||
if runs.empty:
|
||||
st.info("Nessuna run nel database. Esegui `scripts/run_phase1.py` per generarne una.")
|
||||
st.stop()
|
||||
|
||||
st.subheader("Tutte le run")
|
||||
st.dataframe(runs[["id", "name", "started_at", "completed_at", "status", "total_cost_usd"]])
|
||||
|
||||
selected = st.selectbox("Seleziona run per dettaglio", runs["id"].tolist())
|
||||
overview = get_run_overview(repo, selected)
|
||||
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
col1.metric("Status", overview["status"])
|
||||
col2.metric("Cost (USD)", f"{overview['total_cost_usd']:.4f}")
|
||||
col3.metric("Started", overview["started_at"])
|
||||
col4.metric("Completed", overview["completed_at"] or "—")
|
||||
|
||||
st.subheader("Statistiche evaluations")
|
||||
evals = evaluations_df(repo, selected)
|
||||
col5, col6, col7, col8 = st.columns(4)
|
||||
if not evals.empty:
|
||||
parse_success = 100 * (evals["parse_error"].isna().sum() / len(evals))
|
||||
col5.metric("Evaluations totali", len(evals))
|
||||
col6.metric("Parse success %", f"{parse_success:.1f}%")
|
||||
col7.metric("Top fitness", f"{evals['fitness'].max():.3f}")
|
||||
col8.metric("Median fitness", f"{evals['fitness'].median():.3f}")
|
||||
else:
|
||||
col5.metric("Evaluations totali", 0)
|
||||
|
||||
st.subheader("Config")
|
||||
st.json(overview["config"])
|
||||
@@ -1,68 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import plotly.graph_objects as go # type: ignore[import-untyped]
|
||||
import streamlit as st
|
||||
|
||||
from multi_swarm.dashboard.data import generations_df, get_repo, list_runs_df
|
||||
|
||||
st.title("GA Convergence")
|
||||
|
||||
db_path = st.session_state.get("db_path", "./runs.db")
|
||||
repo = get_repo(db_path)
|
||||
|
||||
runs = list_runs_df(repo)
|
||||
if runs.empty:
|
||||
st.info("Nessuna run.")
|
||||
st.stop()
|
||||
|
||||
selected = st.selectbox("Run", runs["id"].tolist())
|
||||
gens = generations_df(repo, selected)
|
||||
if gens.empty:
|
||||
st.warning("Nessuna generazione registrata per questa run.")
|
||||
st.stop()
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=gens["generation_idx"],
|
||||
y=gens["fitness_median"],
|
||||
name="median",
|
||||
mode="lines+markers",
|
||||
)
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=gens["generation_idx"],
|
||||
y=gens["fitness_max"],
|
||||
name="max",
|
||||
mode="lines+markers",
|
||||
)
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=gens["generation_idx"],
|
||||
y=gens["fitness_p90"],
|
||||
name="p90",
|
||||
mode="lines+markers",
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
xaxis_title="generation",
|
||||
yaxis_title="fitness",
|
||||
title="Fitness convergence",
|
||||
)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
st.subheader("Entropy")
|
||||
fig2 = go.Figure()
|
||||
fig2.add_trace(go.Scatter(x=gens["generation_idx"], y=gens["entropy"], mode="lines+markers"))
|
||||
fig2.add_hline(y=0.5, line_dash="dash", annotation_text="gate threshold (0.5)")
|
||||
fig2.update_layout(
|
||||
xaxis_title="generation",
|
||||
yaxis_title="entropy",
|
||||
title="Diversity (fitness entropy)",
|
||||
)
|
||||
st.plotly_chart(fig2, use_container_width=True)
|
||||
|
||||
st.subheader("Tabella generazioni")
|
||||
st.dataframe(gens)
|
||||
@@ -1,72 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from multi_swarm.dashboard.data import (
|
||||
evaluations_df,
|
||||
genomes_df,
|
||||
get_repo,
|
||||
list_runs_df,
|
||||
)
|
||||
|
||||
st.title("Genomes")
|
||||
|
||||
db_path = st.session_state.get("db_path", "./runs.db")
|
||||
repo = get_repo(db_path)
|
||||
|
||||
runs = list_runs_df(repo)
|
||||
if runs.empty:
|
||||
st.info("Nessuna run.")
|
||||
st.stop()
|
||||
|
||||
selected = st.selectbox("Run", runs["id"].tolist())
|
||||
evals = evaluations_df(repo, selected)
|
||||
genomes = genomes_df(repo, selected)
|
||||
|
||||
if evals.empty:
|
||||
st.warning("Nessuna evaluation.")
|
||||
st.stop()
|
||||
|
||||
merged = evals.merge(
|
||||
genomes, left_on="genome_id", right_on="id", how="left", suffixes=("", "_g")
|
||||
)
|
||||
top = merged.sort_values("fitness", ascending=False).head(10)
|
||||
|
||||
st.subheader("Top-10 genomi (per fitness)")
|
||||
display_cols = [
|
||||
"genome_id",
|
||||
"fitness",
|
||||
"dsr",
|
||||
"sharpe",
|
||||
"max_dd",
|
||||
"n_trades",
|
||||
"cognitive_style",
|
||||
"temperature",
|
||||
"lookback_window",
|
||||
"feature_access",
|
||||
]
|
||||
existing = [c for c in display_cols if c in top.columns]
|
||||
st.dataframe(top[existing])
|
||||
|
||||
st.subheader("Ispezione genoma")
|
||||
gid = st.selectbox("Seleziona genome_id", top["genome_id"].tolist())
|
||||
row = merged[merged["genome_id"] == gid].iloc[0]
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.metric("fitness", f"{row['fitness']:.3f}")
|
||||
st.metric("DSR", f"{row['dsr']:.3f}")
|
||||
st.metric("Sharpe", f"{row['sharpe']:.3f}")
|
||||
with col2:
|
||||
st.metric("max DD", f"{row['max_dd']:.3f}")
|
||||
st.metric("trades", int(row["n_trades"]))
|
||||
st.metric("style", str(row.get("cognitive_style", "—")))
|
||||
|
||||
st.subheader("System prompt")
|
||||
st.code(row.get("system_prompt", "—"))
|
||||
|
||||
st.subheader("Raw LLM output")
|
||||
st.code(row.get("raw_text", "—"))
|
||||
|
||||
if row.get("parse_error"):
|
||||
st.error(f"Parse error: {row['parse_error']}")
|
||||
@@ -1,87 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
import streamlit.components.v1 as components
|
||||
|
||||
from multi_swarm.dashboard.aquarium import (
|
||||
STYLE_COLORS,
|
||||
build_aquarium_html,
|
||||
build_fish_dataset,
|
||||
build_lineage_index,
|
||||
)
|
||||
from multi_swarm.dashboard.data import (
|
||||
evaluations_df,
|
||||
genomes_df,
|
||||
get_repo,
|
||||
list_runs_df,
|
||||
)
|
||||
|
||||
st.title("Aquarium 2D")
|
||||
st.caption(
|
||||
"Pesci colorati per stile cognitivo, dimensione proporzionale a fitness. "
|
||||
"Click su un pesce per dettaglio + discendenza."
|
||||
)
|
||||
|
||||
db_path = st.session_state.get("db_path", "./runs.db")
|
||||
repo = get_repo(db_path)
|
||||
|
||||
runs = list_runs_df(repo)
|
||||
if runs.empty:
|
||||
st.info("Nessuna run nel database.")
|
||||
st.stop()
|
||||
|
||||
selected_run = st.selectbox("Run", runs["id"].tolist())
|
||||
|
||||
# Fetch ALL genomes of the run (no gen filter): needed to build the lineage
|
||||
# index across generations. The active set is filtered afterwards.
|
||||
all_genomes = genomes_df(repo, selected_run)
|
||||
all_evals = evaluations_df(repo, selected_run)
|
||||
|
||||
if all_genomes.empty:
|
||||
st.warning("Nessun genoma per questa run.")
|
||||
st.stop()
|
||||
|
||||
available_gens = sorted(all_genomes["generation_idx"].unique().tolist())
|
||||
selected_gen = st.selectbox(
|
||||
"Generazione",
|
||||
available_gens,
|
||||
index=len(available_gens) - 1, # default ultima
|
||||
)
|
||||
|
||||
active_genomes = all_genomes[all_genomes["generation_idx"] == selected_gen]
|
||||
active_evals = (
|
||||
all_evals[all_evals["genome_id"].isin(active_genomes["id"])]
|
||||
if not all_evals.empty
|
||||
else all_evals
|
||||
)
|
||||
if not active_evals.empty:
|
||||
active_merged = active_genomes.merge(
|
||||
active_evals,
|
||||
left_on="id",
|
||||
right_on="genome_id",
|
||||
how="left",
|
||||
suffixes=("", "_eval"),
|
||||
)
|
||||
else:
|
||||
active_merged = active_genomes.copy()
|
||||
active_merged["genome_id"] = active_merged["id"]
|
||||
|
||||
lineage = build_lineage_index(all_genomes, all_evals)
|
||||
fish = build_fish_dataset(active_merged, lineage, max_lineage_levels=5)
|
||||
|
||||
if not fish:
|
||||
st.warning("Nessun agente attivo in questa generazione.")
|
||||
st.stop()
|
||||
|
||||
st.caption(f"{len(fish)} agenti in generazione {selected_gen}")
|
||||
|
||||
html_str = build_aquarium_html(fish, canvas_w=1000, canvas_h=600)
|
||||
components.html(html_str, height=620, scrolling=False)
|
||||
|
||||
with st.expander("Legenda colori"):
|
||||
legend_md = "\n".join(
|
||||
f"- <span style='color:{color};font-weight:bold;'>●</span> "
|
||||
f"`{style}`"
|
||||
for style, color in STYLE_COLORS.items()
|
||||
)
|
||||
st.markdown(legend_md, unsafe_allow_html=True)
|
||||
@@ -1,22 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
st.set_page_config(page_title="Multi-Swarm Phase 1", layout="wide")
|
||||
st.title("Multi-Swarm Coevolutivo — Phase 1 dashboard")
|
||||
st.markdown(
|
||||
"""
|
||||
Naviga le pagine nel menu a sinistra:
|
||||
- **Overview**: ultima run e stato globale.
|
||||
- **GA Convergence**: fitness per generazione.
|
||||
- **Genomes**: top-K genomi e ispezione qualitativa.
|
||||
- **Aquarium**: visualizzazione 2D dei genomi come pesci animati.
|
||||
"""
|
||||
)
|
||||
|
||||
db_path = os.environ.get("DB_PATH", "./runs.db")
|
||||
st.session_state["db_path"] = db_path
|
||||
st.caption(f"DB path: `{Path(db_path).resolve()}`")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Data loaders: OHLCV via Cerbero, train/test splits."""
|
||||
|
||||
from .cerbero_ohlcv import CerberoOHLCVLoader, OHLCVRequest
|
||||
|
||||
__all__ = ["CerberoOHLCVLoader", "OHLCVRequest"]
|
||||
|
||||
@@ -19,16 +19,15 @@ the three plausible shapes (object-of-records under ``candles``/``data``/
|
||||
``result``/``ohlcv``/``klines``/``bars``, array-of-arrays ccxt-style, or
|
||||
a raw list at the top level) and raises a clear error if none matches.
|
||||
|
||||
Pagination is NOT yet implemented — Cerbero is assumed to accept the full
|
||||
date range and page internally. If a future live call shows a cap (e.g.
|
||||
~1000 candles per call), add a chunked fetch in a follow-up.
|
||||
Cerbero/Deribit applicano un cap soft di ~5000 candele per call: il
|
||||
loader pagina internamente in chunk da 4500 barre, concatena e dedupe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
@@ -73,10 +72,38 @@ class CerberoOHLCVLoader:
|
||||
df.to_parquet(cache_file)
|
||||
return df
|
||||
|
||||
# Cerbero/Deribit hanno un cap soft di ~5000 candele per call.
|
||||
# Paginiamo in chunk piu' piccoli per intervalli lunghi.
|
||||
_CHUNK_BARS: ClassVar[int] = 4500
|
||||
|
||||
def _fetch(self, req: OHLCVRequest) -> pd.DataFrame:
|
||||
args = self._build_args(req)
|
||||
bar_seconds = _timeframe_to_minutes(req.timeframe) * 60
|
||||
chunk_seconds = self._CHUNK_BARS * bar_seconds
|
||||
chunks: list[pd.DataFrame] = []
|
||||
cursor = req.start
|
||||
while cursor < req.end:
|
||||
chunk_end = min(req.end, cursor + timedelta(seconds=chunk_seconds))
|
||||
chunk_req = OHLCVRequest(
|
||||
symbol=req.symbol, timeframe=req.timeframe,
|
||||
start=cursor, end=chunk_end, exchange=req.exchange,
|
||||
)
|
||||
args = self._build_args(chunk_req)
|
||||
response = self.client.call_tool(req.exchange, "get_historical", args)
|
||||
return self._parse_response(response)
|
||||
chunk = self._parse_response(response)
|
||||
if not chunk.empty:
|
||||
chunks.append(chunk)
|
||||
last_ts = chunk.index[-1].to_pydatetime()
|
||||
# avanza di un bar oltre l'ultimo per evitare overlap
|
||||
cursor = max(last_ts + timedelta(seconds=bar_seconds), chunk_end)
|
||||
else:
|
||||
cursor = chunk_end
|
||||
if not chunks:
|
||||
return pd.DataFrame(columns=self._COLUMNS).set_index(
|
||||
pd.DatetimeIndex([], tz="UTC", name="ts")
|
||||
)
|
||||
df = pd.concat(chunks)
|
||||
df = df[~df.index.duplicated(keep="first")].sort_index()
|
||||
return df
|
||||
|
||||
def _build_args(self, req: OHLCVRequest) -> dict[str, Any]:
|
||||
if req.exchange == "deribit":
|
||||
|
||||
@@ -1,44 +1,108 @@
|
||||
"""Fitness function v0 della Phase 1.
|
||||
"""Fitness function della Phase 1/2.
|
||||
|
||||
Combina :class:`FalsificationReport` (metriche di robustezza) e
|
||||
:class:`AdversarialReport` (findings euristici) in uno scalare ``>= 0`` che il
|
||||
GA usa per selezione e ranking.
|
||||
|
||||
Logica deliberatamente coarse: DSR penalizzato dal max drawdown, con due
|
||||
kill-switch hard (no-trade, finding HIGH adversarial) che azzerano la fitness.
|
||||
La penalita' lineare sul drawdown e' un compromesso volutamente semplice;
|
||||
versioni successive potranno usare Calmar o utility convessa.
|
||||
**v1** (default, backward compat): ogni finding ``HIGH`` azzera la fitness.
|
||||
Kill-switch hard a 360 gradi.
|
||||
|
||||
**v2** (opt-in via ``hard_kill_findings``): solo findings nel set ``hard_kill``
|
||||
azzerano; gli altri HIGH applicano una penalità moltiplicativa
|
||||
``1 / (1 + soft_penalty * n_soft_high)``. Restituisce gradient continuo anche
|
||||
su strategie marginalmente killate da gate adversarial, permettendo
|
||||
all'evoluzione di esplorare zone con 1-2 finding HIGH "soft" (es.
|
||||
``fees_eat_alpha``, ``flat_too_long``, ``time_in_market_too_high``).
|
||||
|
||||
Formula::
|
||||
|
||||
sharpe_norm = 0.5 * (tanh(sharpe) + 1.0) # in [0, 1]
|
||||
base = dsr_weight * dsr + sharpe_weight * sharpe_norm
|
||||
dd_penalty = 1.0 / (1.0 + drawdown_penalty * max_drawdown)
|
||||
adv_penalty = 1.0 (v1) o 1/(1+soft*n_soft_high) (v2)
|
||||
fitness = max(0.0, base * dd_penalty * adv_penalty)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
|
||||
from ..agents.adversarial import AdversarialReport, Severity
|
||||
from ..agents.falsification import FalsificationReport
|
||||
|
||||
|
||||
def compute_combined_fitness(
|
||||
fitness_train: float,
|
||||
fitness_oos: float | None,
|
||||
alpha: float = 0.5,
|
||||
) -> float:
|
||||
"""Combina fitness IS e OOS in uno scalare per selection multi-objective.
|
||||
|
||||
Formula::
|
||||
|
||||
combined = alpha * fitness_train + (1 - alpha) * fitness_oos
|
||||
|
||||
Se ``fitness_oos`` è ``None`` o NaN, ritorna ``fitness_train`` (fallback).
|
||||
alpha=1.0 → solo IS (= comportamento default). alpha=0.0 → solo OOS.
|
||||
alpha=0.5 → bilanciato.
|
||||
"""
|
||||
if fitness_oos is None or fitness_oos != fitness_oos: # noqa: PLR0124 (NaN check)
|
||||
return fitness_train
|
||||
return alpha * fitness_train + (1.0 - alpha) * fitness_oos
|
||||
|
||||
|
||||
def compute_fitness(
|
||||
falsification: FalsificationReport,
|
||||
adversarial: AdversarialReport,
|
||||
drawdown_penalty: float = 0.5,
|
||||
drawdown_penalty: float = 1.0,
|
||||
dsr_weight: float = 0.5,
|
||||
sharpe_weight: float = 0.5,
|
||||
hard_kill_findings: Iterable[str] | None = None,
|
||||
adversarial_soft_penalty: float = 0.4,
|
||||
) -> float:
|
||||
"""Calcola la fitness scalare di una strategia.
|
||||
|
||||
Args:
|
||||
falsification: report con DSR, max_drawdown, n_trades.
|
||||
falsification: report con DSR, Sharpe, max_drawdown, n_trades.
|
||||
adversarial: report con eventuali findings euristici.
|
||||
drawdown_penalty: peso lineare sul max drawdown (default 0.5).
|
||||
drawdown_penalty: peso del max drawdown nel denominatore della
|
||||
penalita' moltiplicativa (default 1.0).
|
||||
dsr_weight: peso del DSR nella base (default 0.5).
|
||||
sharpe_weight: peso dello Sharpe normalizzato nella base
|
||||
(default 0.5).
|
||||
hard_kill_findings: nomi di findings che azzerano la fitness se
|
||||
``HIGH``. ``None`` (default v1) = TUTTI gli HIGH azzerano.
|
||||
Per v2 passare es. ``{"no_trades", "degenerate"}``: solo
|
||||
questi azzerano, gli altri HIGH applicano soft penalty.
|
||||
adversarial_soft_penalty: in v2, fattore della penalità
|
||||
moltiplicativa per ogni HIGH soft (default 0.4 →
|
||||
``1/(1+0.4*n)``: 1 → 0.71, 2 → 0.56, 3 → 0.45).
|
||||
|
||||
Returns:
|
||||
Fitness ``>= 0``. Zero indica strategia da scartare.
|
||||
|
||||
Logica:
|
||||
1. ``n_trades == 0`` → 0 (nessuna evidenza, sega subito).
|
||||
2. Almeno un finding ``HIGH`` adversarial → 0 (kill).
|
||||
3. Altrimenti: ``dsr - drawdown_penalty * max_drawdown``, clamped a 0.
|
||||
Fitness ``>= 0``. Zero indica strategia da scartare (no-trade o
|
||||
kill adversarial).
|
||||
"""
|
||||
if falsification.n_trades == 0:
|
||||
return 0.0
|
||||
if any(f.severity == Severity.HIGH for f in adversarial.findings):
|
||||
|
||||
high_findings = [f for f in adversarial.findings if f.severity == Severity.HIGH]
|
||||
|
||||
if hard_kill_findings is None:
|
||||
# v1: tutti gli HIGH azzerano la fitness.
|
||||
if high_findings:
|
||||
return 0.0
|
||||
raw = falsification.dsr - drawdown_penalty * falsification.max_drawdown
|
||||
return max(0.0, float(raw))
|
||||
adv_penalty = 1.0
|
||||
else:
|
||||
# v2: solo finding con name in hard_kill_findings azzerano.
|
||||
hard_set = frozenset(hard_kill_findings)
|
||||
if any(f.name in hard_set for f in high_findings):
|
||||
return 0.0
|
||||
n_soft_high = sum(1 for f in high_findings if f.name not in hard_set)
|
||||
adv_penalty = 1.0 / (1.0 + adversarial_soft_penalty * n_soft_high)
|
||||
|
||||
dsr = max(0.0, min(1.0, float(falsification.dsr)))
|
||||
sharpe_norm = 0.5 * (math.tanh(float(falsification.sharpe)) + 1.0)
|
||||
base = dsr_weight * dsr + sharpe_weight * sharpe_norm
|
||||
dd_penalty = 1.0 / (1.0 + drawdown_penalty * float(falsification.max_drawdown))
|
||||
return max(0.0, float(base * dd_penalty * adv_penalty))
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..genome.crossover import uniform_crossover
|
||||
from ..genome.hypothesis import HypothesisAgentGenome
|
||||
from ..genome.mutation import random_mutate
|
||||
from ..genome.mutation import weighted_random_mutate
|
||||
from .selection import elite_select, tournament_select
|
||||
|
||||
|
||||
@@ -15,6 +16,7 @@ class GAConfig:
|
||||
elite_k: int
|
||||
tournament_k: int
|
||||
p_crossover: float
|
||||
prompt_mutation_weight: float = 0.0 # Phase 2.5: opt-in LLM mutator
|
||||
|
||||
|
||||
def next_generation(
|
||||
@@ -22,8 +24,18 @@ def next_generation(
|
||||
fitnesses: dict[str, float],
|
||||
cfg: GAConfig,
|
||||
rng: random.Random,
|
||||
llm: Any | None = None,
|
||||
cost_tracker: Any | None = None,
|
||||
repo: Any | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> list[HypothesisAgentGenome]:
|
||||
"""Costruisce la prossima generazione: elitismo + tournament + crossover/mutate."""
|
||||
"""Costruisce la prossima generazione: elitismo + tournament + crossover/mutate.
|
||||
|
||||
Quando ``cfg.prompt_mutation_weight > 0`` e ``llm`` è fornito, la mutazione
|
||||
invoca ``weighted_random_mutate`` che con quella probabilità usa
|
||||
``mutate_prompt_llm`` (Phase 2.5). Cost tracker/repo/run_id si propagano
|
||||
per registrare ``call_kind="mutation"`` sulle call mutator.
|
||||
"""
|
||||
new_pop: list[HypothesisAgentGenome] = list(
|
||||
elite_select(population, fitnesses, cfg.elite_k)
|
||||
)
|
||||
@@ -35,7 +47,14 @@ def next_generation(
|
||||
child = uniform_crossover(p1, p2, rng)
|
||||
else:
|
||||
parent = tournament_select(population, fitnesses, cfg.tournament_k, rng)
|
||||
child = random_mutate(parent, rng)
|
||||
child = weighted_random_mutate(
|
||||
parent, rng,
|
||||
llm=llm,
|
||||
prompt_mutation_weight=cfg.prompt_mutation_weight,
|
||||
cost_tracker=cost_tracker,
|
||||
repo=repo,
|
||||
run_id=run_id,
|
||||
)
|
||||
new_pop.append(child)
|
||||
|
||||
return new_pop[: cfg.population_size]
|
||||
|
||||
@@ -75,3 +75,31 @@ MUTATION_OPS = (
|
||||
def random_mutate(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome:
|
||||
op = rng.choice(MUTATION_OPS)
|
||||
return op(g, rng)
|
||||
|
||||
|
||||
def weighted_random_mutate(
|
||||
g: HypothesisAgentGenome,
|
||||
rng: random.Random,
|
||||
llm: Any | None = None,
|
||||
prompt_mutation_weight: float = 0.0,
|
||||
cost_tracker: Any | None = None,
|
||||
repo: Any | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> HypothesisAgentGenome:
|
||||
"""Dispatcher pesato fra mutate_prompt_llm e random_mutate scalare.
|
||||
|
||||
Con probabilità ``prompt_mutation_weight`` invoca ``mutate_prompt_llm``,
|
||||
altrimenti ``random_mutate``. Se ``llm`` è ``None`` o il peso è 0,
|
||||
è equivalente a ``random_mutate`` (backward-compat).
|
||||
|
||||
Se ``cost_tracker``, ``repo`` e ``run_id`` sono forniti, vengono propagati a
|
||||
``mutate_prompt_llm`` per tracciare la call con ``call_kind="mutation"``.
|
||||
"""
|
||||
if llm is not None and prompt_mutation_weight > 0 and rng.random() < prompt_mutation_weight:
|
||||
# Import inline per evitare ciclo: mutation_prompt_llm importa da mutation.
|
||||
from .mutation_prompt_llm import mutate_prompt_llm
|
||||
|
||||
return mutate_prompt_llm(
|
||||
g, llm, rng, cost_tracker=cost_tracker, repo=repo, run_id=run_id
|
||||
)
|
||||
return random_mutate(g, rng)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Phase 2.5 operator: ``mutate_prompt_llm``.
|
||||
|
||||
Quinto operatore di mutazione che riscrive il ``system_prompt`` di un genoma
|
||||
usando un LLM tier B come "mutator". Genera diversità prompt-level dove gli
|
||||
altri quattro operatori toccano solo i quattro parametri scalari.
|
||||
|
||||
Fallback sicuro: se la mutazione LLM produce output invalido o troppo simile
|
||||
al parent, l'operatore degrada silenziosamente a ``random_mutate``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from .mutation import _clone_with, random_mutate
|
||||
|
||||
# Sei tipi di mutazione "atomiche", scelti uniformemente.
|
||||
MUTATION_INSTRUCTIONS: dict[str, str] = {
|
||||
"tighten_threshold": (
|
||||
"Rendi UNA soglia numerica nella strategia più restrittiva del 10-20%. "
|
||||
"Esempio: se RSI > 70 diventa RSI > 78. Lascia tutto il resto identico."
|
||||
),
|
||||
"swap_comparator": (
|
||||
"Inverti UN comparator (gt -> lt, gte -> lte o viceversa) in una sola "
|
||||
"condizione. Mantieni lo stesso intent generale della strategia."
|
||||
),
|
||||
"add_condition": (
|
||||
"Aggiungi UNA condizione AND a una rule esistente per renderla più "
|
||||
"selettiva. La condizione deve usare una feature/indicator coerente con "
|
||||
"il resto della strategia."
|
||||
),
|
||||
"remove_condition": (
|
||||
"Rimuovi UNA condizione ridondante o ovvia da una rule, semplificando la "
|
||||
"logica senza alterarne l'intent principale."
|
||||
),
|
||||
"change_timeframe": (
|
||||
"Modifica UNA finestra rolling/lookback di +/- 20-40% (es. SMA(50) -> "
|
||||
"SMA(70)). Solo un parametro temporale."
|
||||
),
|
||||
"add_temporal_gate": (
|
||||
"Aggiungi UN gate temporale alla strategia usando una delle feature "
|
||||
"'hour', 'dow', 'is_weekend', 'minute_of_hour' per filtrare il "
|
||||
"trading a specifici momenti."
|
||||
),
|
||||
}
|
||||
|
||||
# Keyword tecniche minime per validare che il prompt sia ancora "una strategia".
|
||||
_VALID_KEYWORDS = (
|
||||
"rsi", "sma", "ema", "atr", "momentum", "breakout", "mean", "reversion",
|
||||
"macd", "vwap", "bb", "bollinger", "stoch", "trend", "signal", "buy",
|
||||
"sell", "long", "short", "entry", "exit", "stop", "rule", "condition",
|
||||
"if", "when", "and", "or", "gt", "lt", ">", "<", "ge", "le",
|
||||
"hour", "dow", "weekend", "indicator", "feature",
|
||||
)
|
||||
|
||||
_MIN_PROMPT_LENGTH = 50
|
||||
_MIN_DIFF_RATIO = 0.05 # Levenshtein-like: prompt deve essere almeno 5% diverso
|
||||
|
||||
_MUTATOR_SYSTEM_PROMPT = (
|
||||
"Sei un mutator evolutivo per prompt di strategie di trading algoritmico. "
|
||||
"Ricevi un PROMPT originale e una ISTRUZIONE di mutazione atomica. "
|
||||
"Produci una versione modificata del prompt che applica SOLO quella "
|
||||
"mutazione, preservando intent e struttura generale. "
|
||||
"Output: solo il nuovo prompt fra tag <prompt>...</prompt>. "
|
||||
"Nessun preambolo, nessuna spiegazione."
|
||||
)
|
||||
|
||||
_PROMPT_RE = re.compile(r"<prompt>\s*(.*?)\s*</prompt>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
class _LLMClientLike(Protocol):
|
||||
"""Subset minimo dell'API LLMClient che usa l'operatore.
|
||||
|
||||
Permette di mockare l'LLM nei test senza importare la classe concreta.
|
||||
"""
|
||||
|
||||
def complete(
|
||||
self,
|
||||
genome: HypothesisAgentGenome,
|
||||
system: str,
|
||||
user: str,
|
||||
max_tokens: int = ...,
|
||||
) -> Any: ...
|
||||
|
||||
|
||||
def _extract_prompt(text: str) -> str:
|
||||
"""Estrae il prompt mutato dal completion text.
|
||||
|
||||
Cerca tag ``<prompt>...</prompt>``. Se assenti, ritorna il testo strip.
|
||||
"""
|
||||
m = _PROMPT_RE.search(text)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _string_diff_ratio(a: str, b: str) -> float:
|
||||
"""Ritorna ``1 - similarity`` (0.0 = identici, 1.0 = completamente diversi)."""
|
||||
if not a and not b:
|
||||
return 0.0
|
||||
return 1.0 - SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
|
||||
def is_valid_prompt(new_prompt: str, parent_prompt: str) -> bool:
|
||||
"""Validation gate per il prompt LLM-mutato.
|
||||
|
||||
Tre check:
|
||||
1. Lunghezza minima 50 caratteri.
|
||||
2. Contiene almeno una keyword tecnica (rsi, sma, signal, ecc).
|
||||
3. Diversità Levenshtein-like > 5% rispetto al parent.
|
||||
"""
|
||||
if len(new_prompt) < _MIN_PROMPT_LENGTH:
|
||||
return False
|
||||
lowered = new_prompt.lower()
|
||||
if not any(kw in lowered for kw in _VALID_KEYWORDS):
|
||||
return False
|
||||
if _string_diff_ratio(new_prompt, parent_prompt) < _MIN_DIFF_RATIO:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def mutate_prompt_llm(
|
||||
g: HypothesisAgentGenome,
|
||||
llm: _LLMClientLike,
|
||||
rng: random.Random,
|
||||
mutator_tier: ModelTier = ModelTier.B,
|
||||
max_tokens: int = 2000,
|
||||
cost_tracker: Any | None = None,
|
||||
repo: Any | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> HypothesisAgentGenome:
|
||||
"""Operatore di mutazione prompt-level via LLM mutator.
|
||||
|
||||
Sceglie una mutation-instruction casuale fra sei tipi, fa una chiamata
|
||||
LLM tier B per ottenere il prompt mutato, valida l'output. Su validation
|
||||
fail (output troppo corto, non-strategia, troppo simile al parent),
|
||||
fallback silenzioso a ``random_mutate``.
|
||||
|
||||
Se ``cost_tracker``, ``repo`` e ``run_id`` sono forniti, la chiamata mutator
|
||||
viene registrata con ``call_kind="mutation"`` per audit budget.
|
||||
"""
|
||||
instruction_key = rng.choice(list(MUTATION_INSTRUCTIONS))
|
||||
instruction = MUTATION_INSTRUCTIONS[instruction_key]
|
||||
|
||||
user_prompt = (
|
||||
f"PROMPT ORIGINALE:\n{g.system_prompt}\n\n"
|
||||
f"ISTRUZIONE DI MUTAZIONE ({instruction_key}):\n{instruction}\n\n"
|
||||
f"Genera la versione modificata fra tag <prompt>...</prompt>."
|
||||
)
|
||||
|
||||
# Mutator usa un tier diverso (B) — clone temporaneo del genoma con tier override.
|
||||
mutator_genome = replace(g, model_tier=mutator_tier)
|
||||
|
||||
try:
|
||||
result = llm.complete(
|
||||
mutator_genome,
|
||||
system=_MUTATOR_SYSTEM_PROMPT,
|
||||
user=user_prompt,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
except Exception:
|
||||
return random_mutate(g, rng)
|
||||
|
||||
# Cost tracking call_kind="mutation" se sink fornito.
|
||||
if cost_tracker is not None and repo is not None and run_id is not None:
|
||||
in_tok = getattr(result, "input_tokens", 0)
|
||||
out_tok = getattr(result, "output_tokens", 0)
|
||||
cr = cost_tracker.record(
|
||||
input_tokens=in_tok,
|
||||
output_tokens=out_tok,
|
||||
tier=mutator_tier,
|
||||
run_id=run_id,
|
||||
agent_id=g.id,
|
||||
call_kind="mutation",
|
||||
)
|
||||
repo.save_cost_record(
|
||||
run_id=run_id,
|
||||
agent_id=g.id,
|
||||
tier=mutator_tier.value,
|
||||
input_tokens=in_tok,
|
||||
output_tokens=out_tok,
|
||||
cost_usd=cr.cost_usd,
|
||||
call_kind="mutation",
|
||||
)
|
||||
|
||||
new_prompt = _extract_prompt(getattr(result, "text", ""))
|
||||
if not is_valid_prompt(new_prompt, g.system_prompt):
|
||||
return random_mutate(g, rng)
|
||||
|
||||
return _clone_with(g, system_prompt=new_prompt)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import openai
|
||||
from openai import OpenAI
|
||||
@@ -14,18 +15,26 @@ from tenacity import (
|
||||
from ..genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
|
||||
# Modelli configurati per Phase 1 — tutti via OpenRouter
|
||||
MODEL_TIER_S = "anthropic/claude-opus-4-7"
|
||||
MODEL_TIER_A = "anthropic/claude-sonnet-4-6"
|
||||
MODEL_TIER_B = "anthropic/claude-sonnet-4-6"
|
||||
MODEL_TIER_S = "google/gemini-3-flash-preview"
|
||||
MODEL_TIER_A = "deepseek/deepseek-v4-flash"
|
||||
MODEL_TIER_B = "deepseek/deepseek-v4-flash"
|
||||
MODEL_TIER_C = "qwen/qwen-2.5-72b-instruct"
|
||||
MODEL_TIER_D = "meta-llama/llama-3.3-70b-instruct"
|
||||
MODEL_TIER_D = "openai/gpt-oss-20b"
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Errori transient: retry. RateLimit/Auth/InvalidRequest: NO retry.
|
||||
class EmptyCompletionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# Errori transient: retry. Auth/InvalidRequest: NO retry.
|
||||
# RateLimitError (HTTP 429) ora retryable: provider OpenRouter come DeepInfra
|
||||
# applicano rate limit upstream temporaneo, recuperabile con backoff.
|
||||
_RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
||||
openai.APIConnectionError,
|
||||
openai.APITimeoutError,
|
||||
openai.InternalServerError,
|
||||
openai.RateLimitError,
|
||||
EmptyCompletionError,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +48,10 @@ class CompletionResult:
|
||||
|
||||
|
||||
class LLMClient:
|
||||
# Provider OpenRouter da escludere di default. Novita rifiuta /completions
|
||||
# endpoint per modelli Qwen 2.x — vedi bug 2026-05-12.
|
||||
DEFAULT_PROVIDER_IGNORE: tuple[str, ...] = ("Novita",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
openrouter_api_key: str,
|
||||
@@ -48,6 +61,7 @@ class LLMClient:
|
||||
model_tier_c: str = MODEL_TIER_C,
|
||||
model_tier_d: str = MODEL_TIER_D,
|
||||
openrouter_base_url: str = OPENROUTER_BASE_URL,
|
||||
provider_ignore: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
self.model_tier_s = model_tier_s
|
||||
self.model_tier_a = model_tier_a
|
||||
@@ -55,6 +69,9 @@ class LLMClient:
|
||||
self.model_tier_c = model_tier_c
|
||||
self.model_tier_d = model_tier_d
|
||||
self.openrouter_base_url = openrouter_base_url
|
||||
self._provider_ignore = (
|
||||
provider_ignore if provider_ignore is not None else self.DEFAULT_PROVIDER_IGNORE
|
||||
)
|
||||
self._tier_models: dict[ModelTier, str] = {
|
||||
ModelTier.S: model_tier_s,
|
||||
ModelTier.A: model_tier_a,
|
||||
@@ -62,11 +79,17 @@ class LLMClient:
|
||||
ModelTier.C: model_tier_c,
|
||||
ModelTier.D: model_tier_d,
|
||||
}
|
||||
self._client = OpenAI(api_key=openrouter_api_key, base_url=openrouter_base_url)
|
||||
# Timeout esplicito (60s) per prevenire hang infinito su connessioni
|
||||
# stallate. Tenacity retry su APITimeoutError gestisce il recovery.
|
||||
self._client = OpenAI(
|
||||
api_key=openrouter_api_key,
|
||||
base_url=openrouter_base_url,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1.0, min=2.0, max=10.0),
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=2.0, min=2.0, max=30.0),
|
||||
retry=retry_if_exception_type(_RETRYABLE_EXCEPTIONS),
|
||||
reraise=True,
|
||||
)
|
||||
@@ -78,6 +101,9 @@ class LLMClient:
|
||||
max_tokens: int = 2000,
|
||||
) -> CompletionResult:
|
||||
model = self._tier_models[genome.model_tier]
|
||||
extra_body: dict[str, Any] = {}
|
||||
if self._provider_ignore:
|
||||
extra_body["provider"] = {"ignore": list(self._provider_ignore)}
|
||||
resp = self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
@@ -87,13 +113,15 @@ class LLMClient:
|
||||
temperature=genome.temperature,
|
||||
top_p=genome.top_p,
|
||||
max_tokens=max_tokens,
|
||||
extra_body=extra_body or None,
|
||||
)
|
||||
if not resp.choices or resp.choices[0].message.content is None:
|
||||
raise EmptyCompletionError(f"empty response from {model}")
|
||||
usage = resp.usage
|
||||
assert usage is not None
|
||||
return CompletionResult(
|
||||
text=resp.choices[0].message.content or "",
|
||||
input_tokens=usage.prompt_tokens,
|
||||
output_tokens=usage.completion_tokens,
|
||||
text=resp.choices[0].message.content,
|
||||
input_tokens=usage.prompt_tokens if usage else 0,
|
||||
output_tokens=usage.completion_tokens if usage else 0,
|
||||
tier=genome.model_tier,
|
||||
model=model,
|
||||
)
|
||||
|
||||
@@ -8,11 +8,11 @@ from typing import Any
|
||||
from ..genome.hypothesis import ModelTier
|
||||
|
||||
PRICE_PER_M_TOKENS: dict[ModelTier, dict[str, float]] = {
|
||||
ModelTier.S: {"input": 15.00, "output": 75.00},
|
||||
ModelTier.A: {"input": 3.00, "output": 15.00},
|
||||
ModelTier.B: {"input": 3.00, "output": 15.00},
|
||||
ModelTier.S: {"input": 0.50, "output": 3.00},
|
||||
ModelTier.A: {"input": 0.14, "output": 0.28},
|
||||
ModelTier.B: {"input": 0.14, "output": 0.28},
|
||||
ModelTier.C: {"input": 0.40, "output": 0.40},
|
||||
ModelTier.D: {"input": 0.10, "output": 0.30},
|
||||
ModelTier.D: {"input": 0.03, "output": 0.14},
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class CostRecord:
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cost_usd: float
|
||||
call_kind: str = "hypothesis" # "hypothesis" | "mutation"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -43,6 +44,7 @@ class CostTracker:
|
||||
tier: ModelTier,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
call_kind: str = "hypothesis",
|
||||
) -> CostRecord:
|
||||
cost = estimate_cost(input_tokens, output_tokens, tier)
|
||||
rec = CostRecord(
|
||||
@@ -53,6 +55,7 @@ class CostTracker:
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cost_usd=cost,
|
||||
call_kind=call_kind,
|
||||
)
|
||||
self.records.append(rec)
|
||||
return rec
|
||||
@@ -61,16 +64,25 @@ class CostTracker:
|
||||
by_tier: dict[str, dict[str, float]] = defaultdict(
|
||||
lambda: {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
|
||||
)
|
||||
by_call_kind: dict[str, dict[str, float]] = defaultdict(
|
||||
lambda: {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
|
||||
)
|
||||
for r in self.records:
|
||||
t = r.tier.value
|
||||
by_tier[t]["calls"] += 1
|
||||
by_tier[t]["input_tokens"] += r.input_tokens
|
||||
by_tier[t]["output_tokens"] += r.output_tokens
|
||||
by_tier[t]["cost_usd"] += r.cost_usd
|
||||
ck = r.call_kind
|
||||
by_call_kind[ck]["calls"] += 1
|
||||
by_call_kind[ck]["input_tokens"] += r.input_tokens
|
||||
by_call_kind[ck]["output_tokens"] += r.output_tokens
|
||||
by_call_kind[ck]["cost_usd"] += r.cost_usd
|
||||
return {
|
||||
"calls": len(self.records),
|
||||
"input_tokens": sum(r.input_tokens for r in self.records),
|
||||
"output_tokens": sum(r.output_tokens for r in self.records),
|
||||
"cost_usd": sum(r.cost_usd for r in self.records),
|
||||
"by_tier": dict(by_tier),
|
||||
"by_call_kind": dict(by_call_kind),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Metriche di diversità popolazione.
|
||||
|
||||
``population_prompt_diversity`` calcola la diversità media fra i prompt di una
|
||||
popolazione tramite la similarity di ``difflib.SequenceMatcher`` (proxy di
|
||||
Levenshtein normalizzata): 0.0 = tutti i prompt identici, 1.0 = tutti diversi.
|
||||
|
||||
Usata come telemetry Phase 2.5 per monitorare se ``mutate_prompt_llm`` sta
|
||||
effettivamente introducendo diversità di prompt nel pool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from itertools import combinations
|
||||
|
||||
|
||||
def population_prompt_diversity(prompts: list[str]) -> float:
|
||||
"""Diversità media (0.0 - 1.0) sui prompt della popolazione.
|
||||
|
||||
Calcolo: media di ``1 - similarity(a, b)`` su tutte le coppie distinte.
|
||||
Per N prompt il numero di coppie è ``N*(N-1)/2``. Con N=20 sono 190 coppie
|
||||
— trascurabile a livello di compute.
|
||||
"""
|
||||
if len(prompts) < 2:
|
||||
return 0.0
|
||||
diffs = [
|
||||
1.0 - SequenceMatcher(None, a, b).ratio()
|
||||
for a, b in combinations(prompts, 2)
|
||||
]
|
||||
return sum(diffs) / len(diffs)
|
||||
@@ -49,6 +49,24 @@ class RunConfig:
|
||||
fees_bp: float = 5.0
|
||||
n_trials_dsr: int = 50
|
||||
db_path: Path = field(default_factory=lambda: Path("./runs.db"))
|
||||
prompt_mutation_weight: float = 0.0 # Phase 2.5: opt-in LLM mutator
|
||||
fees_eat_alpha_threshold: float = 0.5 # adversarial gate, allenta verso 0.7-0.8
|
||||
flat_too_long_threshold: float = 0.95 # adversarial gate, allenta verso 0.98-0.99
|
||||
undertrading_threshold: int = 10 # min trades, sotto = "lucky shot" HIGH
|
||||
# Fitness v2: tuple non vuota → soft-kill (solo findings listate azzerano).
|
||||
# None/empty → v1 (tutti HIGH azzerano, backward compat).
|
||||
fitness_hard_kill_findings: tuple[str, ...] | None = None
|
||||
fitness_adversarial_soft_penalty: float = 0.4
|
||||
# Walk-Forward Validation: train sui primi train_split% delle bar, OOS re-eval
|
||||
# dei top genomi sui restanti. None/0 = no WFA (eval full ohlcv).
|
||||
wfa_train_split: float | None = None
|
||||
wfa_top_k: int = 5 # quanti top genomi rivalutare OOS
|
||||
# Multi-objective selection: se True, ogni genome viene valutato anche su
|
||||
# test_ohlcv durante il loop e la fitness usata per tournament/elite è
|
||||
# combined = alpha*IS + (1-alpha)*OOS. Richiede wfa_train_split attivo.
|
||||
# 2x costo backtest engine.
|
||||
eval_oos_during_loop: bool = False
|
||||
fitness_combined_alpha: float = 0.5 # peso IS (1-alpha = OOS)
|
||||
|
||||
|
||||
def run_phase1(
|
||||
@@ -71,13 +89,27 @@ def run_phase1(
|
||||
}
|
||||
run_id = repo.create_run(name=cfg.run_name, config=config_dict)
|
||||
|
||||
market = build_market_summary(ohlcv, symbol=cfg.symbol, timeframe=cfg.timeframe)
|
||||
# WFA split: se attivo, GA usa solo train_ohlcv; OOS re-eval su test_ohlcv a fine run.
|
||||
if cfg.wfa_train_split is not None and 0.0 < cfg.wfa_train_split < 1.0:
|
||||
split_idx = int(len(ohlcv) * cfg.wfa_train_split)
|
||||
train_ohlcv = ohlcv.iloc[:split_idx]
|
||||
test_ohlcv = ohlcv.iloc[split_idx:]
|
||||
else:
|
||||
train_ohlcv = ohlcv
|
||||
test_ohlcv = None
|
||||
|
||||
market = build_market_summary(train_ohlcv, symbol=cfg.symbol, timeframe=cfg.timeframe)
|
||||
|
||||
hypothesis_agent = HypothesisAgent(llm=llm)
|
||||
falsification_agent = FalsificationAgent(
|
||||
fees_bp=cfg.fees_bp, n_trials_dsr=cfg.n_trials_dsr
|
||||
)
|
||||
adversarial_agent = AdversarialAgent(fees_bp=cfg.fees_bp)
|
||||
adversarial_agent = AdversarialAgent(
|
||||
fees_bp=cfg.fees_bp,
|
||||
fees_eat_alpha_threshold=cfg.fees_eat_alpha_threshold,
|
||||
flat_too_long_threshold=cfg.flat_too_long_threshold,
|
||||
undertrading_threshold=cfg.undertrading_threshold,
|
||||
)
|
||||
cost_tracker = CostTracker()
|
||||
|
||||
population = build_initial_population(
|
||||
@@ -90,6 +122,7 @@ def run_phase1(
|
||||
elite_k=cfg.elite_k,
|
||||
tournament_k=cfg.tournament_k,
|
||||
p_crossover=cfg.p_crossover,
|
||||
prompt_mutation_weight=cfg.prompt_mutation_weight,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -99,10 +132,12 @@ def run_phase1(
|
||||
continue # elite gia' valutata in generazione precedente
|
||||
repo.save_genome(run_id=run_id, generation_idx=gen, genome=genome)
|
||||
proposal = hypothesis_agent.propose(genome, market)
|
||||
# Registra costo per OGNI completion (incluse retry).
|
||||
for completion in proposal.completions:
|
||||
cost_record = cost_tracker.record(
|
||||
input_tokens=proposal.completion.input_tokens,
|
||||
output_tokens=proposal.completion.output_tokens,
|
||||
tier=proposal.completion.tier,
|
||||
input_tokens=completion.input_tokens,
|
||||
output_tokens=completion.output_tokens,
|
||||
tier=completion.tier,
|
||||
run_id=run_id,
|
||||
agent_id=genome.id,
|
||||
)
|
||||
@@ -132,8 +167,8 @@ def run_phase1(
|
||||
fitnesses[genome.id] = 0.0
|
||||
continue
|
||||
|
||||
fals = falsification_agent.evaluate(proposal.strategy, ohlcv)
|
||||
adv = adversarial_agent.review(proposal.strategy, ohlcv)
|
||||
fals = falsification_agent.evaluate(proposal.strategy, train_ohlcv)
|
||||
adv = adversarial_agent.review(proposal.strategy, train_ohlcv)
|
||||
for finding in adv.findings:
|
||||
repo.save_adversarial_finding(
|
||||
run_id=run_id,
|
||||
@@ -142,7 +177,36 @@ def run_phase1(
|
||||
severity=finding.severity.value,
|
||||
detail=finding.detail,
|
||||
)
|
||||
fit = compute_fitness(fals, adv)
|
||||
fit = compute_fitness(
|
||||
fals, adv,
|
||||
hard_kill_findings=cfg.fitness_hard_kill_findings,
|
||||
adversarial_soft_penalty=cfg.fitness_adversarial_soft_penalty,
|
||||
)
|
||||
# Multi-objective: se attivo, eval OOS subito e combina via alpha.
|
||||
if (
|
||||
cfg.eval_oos_during_loop
|
||||
and test_ohlcv is not None
|
||||
and len(test_ohlcv) >= 100
|
||||
and fit > 0
|
||||
):
|
||||
try:
|
||||
fals_oos_inloop = falsification_agent.evaluate(
|
||||
proposal.strategy, test_ohlcv
|
||||
)
|
||||
adv_oos_inloop = adversarial_agent.review(
|
||||
proposal.strategy, test_ohlcv
|
||||
)
|
||||
fit_oos_inloop = compute_fitness(
|
||||
fals_oos_inloop, adv_oos_inloop,
|
||||
hard_kill_findings=cfg.fitness_hard_kill_findings,
|
||||
adversarial_soft_penalty=cfg.fitness_adversarial_soft_penalty,
|
||||
)
|
||||
fit = (
|
||||
cfg.fitness_combined_alpha * fit
|
||||
+ (1.0 - cfg.fitness_combined_alpha) * fit_oos_inloop
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass # fallback: usa solo IS
|
||||
repo.save_evaluation(
|
||||
run_id=run_id,
|
||||
genome_id=genome.id,
|
||||
@@ -171,7 +235,48 @@ def run_phase1(
|
||||
)
|
||||
|
||||
if gen < cfg.n_generations - 1:
|
||||
population = next_generation(population, fitnesses, ga_cfg, rng)
|
||||
population = next_generation(
|
||||
population, fitnesses, ga_cfg, rng,
|
||||
llm=llm if cfg.prompt_mutation_weight > 0 else None,
|
||||
cost_tracker=cost_tracker if cfg.prompt_mutation_weight > 0 else None,
|
||||
repo=repo if cfg.prompt_mutation_weight > 0 else None,
|
||||
run_id=run_id if cfg.prompt_mutation_weight > 0 else None,
|
||||
)
|
||||
|
||||
# WFA re-eval: i top_k genomi (by fitness in-sample > 0) vengono rivalutati
|
||||
# sul test_ohlcv. Le metriche OOS finiscono in evaluations.fitness_oos etc.
|
||||
if test_ohlcv is not None and len(test_ohlcv) >= 100:
|
||||
from ..agents.hypothesis import _try_parse # noqa: PLC0415
|
||||
|
||||
all_evals = repo.list_evaluations(run_id)
|
||||
top_evals = sorted(
|
||||
(e for e in all_evals if e["fitness"] > 0 and not e.get("parse_error")),
|
||||
key=lambda x: x["fitness"],
|
||||
reverse=True,
|
||||
)[: cfg.wfa_top_k]
|
||||
for ev in top_evals:
|
||||
strategy, parse_err = _try_parse(ev["raw_text"] or "")
|
||||
if strategy is None:
|
||||
continue
|
||||
try:
|
||||
fals_oos = falsification_agent.evaluate(strategy, test_ohlcv)
|
||||
adv_oos = adversarial_agent.review(strategy, test_ohlcv)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
fit_oos = compute_fitness(
|
||||
fals_oos, adv_oos,
|
||||
hard_kill_findings=cfg.fitness_hard_kill_findings,
|
||||
adversarial_soft_penalty=cfg.fitness_adversarial_soft_penalty,
|
||||
)
|
||||
repo.update_evaluation_oos(
|
||||
run_id=run_id,
|
||||
genome_id=ev["genome_id"],
|
||||
fitness_oos=fit_oos,
|
||||
sharpe_oos=float(fals_oos.sharpe),
|
||||
return_oos=float(fals_oos.total_return),
|
||||
max_dd_oos=float(fals_oos.max_drawdown),
|
||||
n_trades_oos=int(fals_oos.n_trades),
|
||||
)
|
||||
|
||||
repo.complete_run(
|
||||
run_id, total_cost=repo.total_cost(run_id), status="completed"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""PaperExecutor: applica un segnale di strategia a un Portfolio.
|
||||
|
||||
Il flusso per ogni tick:
|
||||
|
||||
bar OHLCV chiuso -> compile_strategy(strategy) -> Series[Side]
|
||||
-> last_signal = series.iloc[-1]
|
||||
-> match con posizione attuale -> open / close / hold
|
||||
|
||||
Niente delay 1-bar: in paper-trading il segnale viene calcolato sulla
|
||||
barra appena chiusa e applicato al prezzo close della stessa. La latenza
|
||||
reale tra tick e ordine va misurata separatamente (Phase 3 spec).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd # type: ignore[import-untyped]
|
||||
|
||||
from ..backtest.orders import Side, Trade
|
||||
from ..protocol.compiler import compile_strategy
|
||||
from ..protocol.parser import parse_strategy
|
||||
from .portfolio import OpenPosition, Portfolio
|
||||
|
||||
|
||||
@dataclass
|
||||
class TickResult:
|
||||
ts: datetime
|
||||
symbol: str
|
||||
bar_ts: datetime
|
||||
close_price: float
|
||||
signal: Side
|
||||
action_taken: str # "open_long" | "open_short" | "close" | "reverse" | "hold"
|
||||
trade: Trade | None = None
|
||||
new_position: OpenPosition | None = None
|
||||
|
||||
|
||||
class PaperExecutor:
|
||||
def __init__(self, strategy_json_path: Path, symbol: str) -> None:
|
||||
text = strategy_json_path.read_text()
|
||||
# parse_strategy si aspetta JSON pulito, non fence; il file e' gia' JSON.
|
||||
self._strategy = parse_strategy(text)
|
||||
self._compiled = compile_strategy(self._strategy)
|
||||
self.symbol = symbol
|
||||
self.strategy_path = strategy_json_path
|
||||
|
||||
def execute_tick(
|
||||
self,
|
||||
portfolio: Portfolio,
|
||||
ohlcv: pd.DataFrame,
|
||||
now: datetime,
|
||||
) -> TickResult:
|
||||
"""Esegui un tick: calcola segnale su tutto ``ohlcv`` (per indicatori
|
||||
con lookback), prendi l'ultimo, e applica al portfolio."""
|
||||
if len(ohlcv) == 0:
|
||||
raise ValueError("Empty OHLCV passed to execute_tick")
|
||||
signals = self._compiled(ohlcv)
|
||||
# ultimo bar chiuso
|
||||
bar_ts = ohlcv.index[-1]
|
||||
close_price = float(ohlcv["close"].iloc[-1])
|
||||
signal = Side(signals.iloc[-1]) if signals.iloc[-1] is not None else Side.FLAT
|
||||
|
||||
current = portfolio.positions.get(self.symbol)
|
||||
action = "hold"
|
||||
trade: Trade | None = None
|
||||
new_position: OpenPosition | None = None
|
||||
|
||||
if current is None and signal != Side.FLAT:
|
||||
new_position = portfolio.open(self.symbol, signal, close_price, now)
|
||||
action = f"open_{signal.value}"
|
||||
elif current is not None and signal == Side.FLAT:
|
||||
trade = portfolio.close(self.symbol, close_price, now)
|
||||
action = "close"
|
||||
elif current is not None and signal != current.side:
|
||||
# reverse: chiudi e riapri opposto
|
||||
trade = portfolio.close(self.symbol, close_price, now)
|
||||
new_position = portfolio.open(self.symbol, signal, close_price, now)
|
||||
action = "reverse"
|
||||
|
||||
return TickResult(
|
||||
ts=now,
|
||||
symbol=self.symbol,
|
||||
bar_ts=bar_ts.to_pydatetime() if hasattr(bar_ts, "to_pydatetime") else bar_ts,
|
||||
close_price=close_price,
|
||||
signal=signal,
|
||||
action_taken=action,
|
||||
trade=trade,
|
||||
new_position=new_position,
|
||||
)
|
||||
|
||||
@property
|
||||
def strategy_dict(self) -> dict:
|
||||
return json.loads(self.strategy_path.read_text())
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Persistenza paper-trading: usa lo stesso ``runs.db`` con tabelle dedicate
|
||||
``paper_trading_*`` (vedi :mod:`multi_swarm.persistence.schema`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .executor import TickResult
|
||||
from .portfolio import Portfolio
|
||||
|
||||
|
||||
class PaperRepository:
|
||||
def __init__(self, db_path: Path | str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
def create_run(self, name: str, initial_capital: float, config: dict[str, Any]) -> str:
|
||||
rid = uuid.uuid4().hex
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_runs "
|
||||
"(id, name, started_at, status, initial_capital, config_json) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(rid, name, self._now(), "running", initial_capital, json.dumps(config)),
|
||||
)
|
||||
return rid
|
||||
|
||||
def stop_run(self, run_id: str, status: str = "stopped") -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE paper_trading_runs SET stopped_at=?, status=? WHERE id=?",
|
||||
(self._now(), status, run_id),
|
||||
)
|
||||
|
||||
def save_tick(self, run_id: str, tick: TickResult) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_ticks "
|
||||
"(paper_run_id, symbol, ts, bar_ts, close_price, signal, action_taken) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
run_id,
|
||||
tick.symbol,
|
||||
tick.ts.isoformat(),
|
||||
tick.bar_ts.isoformat() if hasattr(tick.bar_ts, "isoformat") else str(tick.bar_ts),
|
||||
tick.close_price,
|
||||
tick.signal.value,
|
||||
tick.action_taken,
|
||||
),
|
||||
)
|
||||
if tick.trade is not None:
|
||||
t = tick.trade
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_trades "
|
||||
"(paper_run_id, symbol, side, qty, entry_price, exit_price, "
|
||||
"entry_ts, exit_ts, pnl, fees) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
run_id,
|
||||
tick.symbol,
|
||||
t.side.value,
|
||||
t.size,
|
||||
t.entry_price,
|
||||
t.exit_price,
|
||||
t.entry_ts.isoformat(),
|
||||
t.exit_ts.isoformat(),
|
||||
t.net_pnl,
|
||||
t.fees,
|
||||
),
|
||||
)
|
||||
|
||||
def save_equity_snapshot(
|
||||
self,
|
||||
run_id: str,
|
||||
ts: datetime,
|
||||
equity: float,
|
||||
cash: float,
|
||||
positions_value: float,
|
||||
) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_equity "
|
||||
"(paper_run_id, ts, equity, cash, positions_value) VALUES (?,?,?,?,?)",
|
||||
(run_id, ts.isoformat(), equity, cash, positions_value),
|
||||
)
|
||||
|
||||
def sync_open_positions(self, run_id: str, portfolio: Portfolio) -> None:
|
||||
"""Sostituisce snapshot posizioni aperte. Idempotente: cancella e reinserisce."""
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM paper_trading_positions WHERE paper_run_id=?", (run_id,)
|
||||
)
|
||||
for sym, pos in portfolio.positions.items():
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_positions "
|
||||
"(paper_run_id, symbol, side, qty, entry_price, entry_ts) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(run_id, sym, pos.side.value, pos.qty, pos.entry_price, pos.entry_ts.isoformat()),
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Portfolio multi-asset per paper-trading.
|
||||
|
||||
Modello semplificato: capitale unico ``cash``, allocazione equal-weight
|
||||
fra N posizioni (sleeve = 1/N del capitale iniziale per ogni simbolo).
|
||||
Niente leva, niente liquidation, fees su entry+exit (bp del notional).
|
||||
|
||||
Una :class:`Position` rappresenta una posizione aperta su un singolo
|
||||
simbolo (long/short, qty in unita' dell'asset, prezzo di entry). La
|
||||
posizione viene chiusa con :meth:`Portfolio.close` che produce un
|
||||
:class:`Trade` realized e accredita ``cash``.
|
||||
|
||||
Mark-to-market via :meth:`Portfolio.equity`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from ..backtest.orders import Side, Trade
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenPosition:
|
||||
symbol: str
|
||||
side: Side
|
||||
qty: float
|
||||
entry_price: float
|
||||
entry_ts: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Portfolio:
|
||||
initial_capital: float
|
||||
fees_bp: float = 5.0
|
||||
n_sleeves: int = 2 # numero strategie / asset previsti
|
||||
cash: float = field(init=False)
|
||||
positions: dict[str, OpenPosition] = field(default_factory=dict)
|
||||
closed_trades: list[Trade] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.cash = self.initial_capital
|
||||
|
||||
@property
|
||||
def sleeve_capital(self) -> float:
|
||||
return self.initial_capital / self.n_sleeves
|
||||
|
||||
def open(
|
||||
self,
|
||||
symbol: str,
|
||||
side: Side,
|
||||
price: float,
|
||||
ts: datetime,
|
||||
) -> OpenPosition:
|
||||
if symbol in self.positions:
|
||||
raise ValueError(f"Position already open on {symbol}")
|
||||
if side == Side.FLAT:
|
||||
raise ValueError("Cannot open a FLAT position")
|
||||
# sleeve fisso: alloca 1/n_sleeves del capitale iniziale, qty = notional/price.
|
||||
notional = self.sleeve_capital
|
||||
qty = notional / price
|
||||
fees = notional * (self.fees_bp / 10000.0)
|
||||
self.cash -= fees
|
||||
pos = OpenPosition(symbol=symbol, side=side, qty=qty, entry_price=price, entry_ts=ts)
|
||||
self.positions[symbol] = pos
|
||||
return pos
|
||||
|
||||
def close(
|
||||
self,
|
||||
symbol: str,
|
||||
price: float,
|
||||
ts: datetime,
|
||||
) -> Trade:
|
||||
if symbol not in self.positions:
|
||||
raise ValueError(f"No open position on {symbol}")
|
||||
pos = self.positions.pop(symbol)
|
||||
trade = Trade(
|
||||
entry_ts=pos.entry_ts,
|
||||
exit_ts=ts,
|
||||
side=pos.side,
|
||||
size=pos.qty,
|
||||
entry_price=pos.entry_price,
|
||||
exit_price=price,
|
||||
fees_bp=self.fees_bp,
|
||||
)
|
||||
# net_pnl include gia' i fees sull'intero round-trip; abbiamo gia'
|
||||
# addebitato meta' fees all'open, ora addebitiamo il resto.
|
||||
self.cash += trade.gross_pnl - (trade.fees / 2.0)
|
||||
self.closed_trades.append(trade)
|
||||
return trade
|
||||
|
||||
def equity(self, last_prices: dict[str, float]) -> tuple[float, float]:
|
||||
"""Ritorna (equity_totale, positions_value) marcando posizioni aperte
|
||||
al ``last_prices[symbol]``. Posizioni senza prezzo disponibile valgono
|
||||
notional di entry (fallback conservativo)."""
|
||||
positions_value = 0.0
|
||||
for sym, pos in self.positions.items():
|
||||
price = last_prices.get(sym, pos.entry_price)
|
||||
unreal = pos.qty * (
|
||||
price - pos.entry_price if pos.side == Side.LONG
|
||||
else pos.entry_price - price
|
||||
)
|
||||
positions_value += pos.qty * pos.entry_price + unreal
|
||||
return self.cash + positions_value, positions_value
|
||||
@@ -26,6 +26,26 @@ class Repository:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._conn() as conn:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
# Migration soft per DB pre-Task 6: aggiunge call_kind se manca.
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE cost_records ADD COLUMN call_kind "
|
||||
"TEXT NOT NULL DEFAULT 'hypothesis'"
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # colonna già presente
|
||||
# Migration WFA: colonne fitness_oos e altre OOS su evaluations.
|
||||
for col_def in (
|
||||
"fitness_oos REAL",
|
||||
"sharpe_oos REAL",
|
||||
"return_oos REAL",
|
||||
"max_dd_oos REAL",
|
||||
"n_trades_oos INTEGER",
|
||||
):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE evaluations ADD COLUMN {col_def}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
@@ -167,6 +187,29 @@ class Repository:
|
||||
),
|
||||
)
|
||||
|
||||
def update_evaluation_oos(
|
||||
self,
|
||||
run_id: str,
|
||||
genome_id: str,
|
||||
fitness_oos: float,
|
||||
sharpe_oos: float,
|
||||
return_oos: float,
|
||||
max_dd_oos: float,
|
||||
n_trades_oos: int,
|
||||
) -> None:
|
||||
"""Aggiorna le metriche OOS per un genome (WFA re-eval)."""
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE evaluations SET
|
||||
fitness_oos=?, sharpe_oos=?, return_oos=?,
|
||||
max_dd_oos=?, n_trades_oos=?
|
||||
WHERE run_id=? AND genome_id=?""",
|
||||
(
|
||||
fitness_oos, sharpe_oos, return_oos,
|
||||
max_dd_oos, n_trades_oos, run_id, genome_id,
|
||||
),
|
||||
)
|
||||
|
||||
def list_evaluations(self, run_id: str) -> list[dict[str, Any]]:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -184,12 +227,13 @@ class Repository:
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cost_usd: float,
|
||||
call_kind: str = "hypothesis",
|
||||
) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO cost_records
|
||||
(run_id, agent_id, ts, tier, input_tokens, output_tokens, cost_usd)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(run_id, agent_id, ts, tier, input_tokens, output_tokens, cost_usd, call_kind)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
run_id,
|
||||
agent_id,
|
||||
@@ -198,6 +242,7 @@ class Repository:
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cost_usd,
|
||||
call_kind,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ CREATE TABLE IF NOT EXISTS evaluations (
|
||||
parse_error TEXT,
|
||||
raw_text TEXT,
|
||||
eval_ts TEXT NOT NULL,
|
||||
fitness_oos REAL,
|
||||
sharpe_oos REAL,
|
||||
return_oos REAL,
|
||||
max_dd_oos REAL,
|
||||
n_trades_oos INTEGER,
|
||||
PRIMARY KEY (run_id, genome_id),
|
||||
FOREIGN KEY (run_id) REFERENCES runs(id)
|
||||
);
|
||||
@@ -58,6 +63,7 @@ CREATE TABLE IF NOT EXISTS cost_records (
|
||||
input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
cost_usd REAL NOT NULL,
|
||||
call_kind TEXT NOT NULL DEFAULT 'hypothesis',
|
||||
FOREIGN KEY (run_id) REFERENCES runs(id)
|
||||
);
|
||||
|
||||
@@ -71,7 +77,68 @@ CREATE TABLE IF NOT EXISTS adversarial_findings (
|
||||
FOREIGN KEY (run_id) REFERENCES runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
stopped_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
initial_capital REAL NOT NULL,
|
||||
config_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_positions (
|
||||
paper_run_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
qty REAL NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
entry_ts TEXT NOT NULL,
|
||||
PRIMARY KEY (paper_run_id, symbol),
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
paper_run_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
qty REAL NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
exit_price REAL NOT NULL,
|
||||
entry_ts TEXT NOT NULL,
|
||||
exit_ts TEXT NOT NULL,
|
||||
pnl REAL NOT NULL,
|
||||
fees REAL NOT NULL,
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_equity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
paper_run_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
equity REAL NOT NULL,
|
||||
cash REAL NOT NULL,
|
||||
positions_value REAL NOT NULL,
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_ticks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
paper_run_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
bar_ts TEXT NOT NULL,
|
||||
close_price REAL NOT NULL,
|
||||
signal TEXT NOT NULL,
|
||||
action_taken TEXT NOT NULL,
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_evaluations_fitness ON evaluations(run_id, fitness DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_genomes_generation ON genomes(run_id, generation_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_run ON cost_records(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_trades_run ON paper_trading_trades(paper_run_id, exit_ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_equity_run ON paper_trading_equity(paper_run_id, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_ticks_run ON paper_trading_ticks(paper_run_id, ts);
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Protocol layer: JSON-based strategy grammar + parser + validator + compiler."""
|
||||
|
||||
from .compiler import compile_strategy
|
||||
from .parser import (
|
||||
FeatureNode,
|
||||
IndicatorNode,
|
||||
LiteralNode,
|
||||
Node,
|
||||
OpNode,
|
||||
ParseError,
|
||||
Rule,
|
||||
Strategy,
|
||||
parse_strategy,
|
||||
)
|
||||
from .validator import ValidationError, validate_strategy
|
||||
|
||||
__all__ = [
|
||||
"FeatureNode",
|
||||
"IndicatorNode",
|
||||
"LiteralNode",
|
||||
"Node",
|
||||
"OpNode",
|
||||
"ParseError",
|
||||
"Rule",
|
||||
"Strategy",
|
||||
"ValidationError",
|
||||
"compile_strategy",
|
||||
"parse_strategy",
|
||||
"validate_strategy",
|
||||
]
|
||||
|
||||
@@ -12,9 +12,9 @@ Design notes
|
||||
a different concrete signature (``(df, length)`` vs ``(df, fast, slow)``);
|
||||
modelling that under ``mypy --strict`` would require a ``Protocol`` per
|
||||
arity, which is overkill for the Phase 1 indicator subset.
|
||||
* Numeric leaves coming out of :mod:`sexpdata` arrive as ``int`` / ``float``
|
||||
/ ``str``; we widen via :func:`_to_series` to broadcast them along the
|
||||
DataFrame index for arithmetic comparisons.
|
||||
* I parametri di un :class:`IndicatorNode` sono sempre ``float``; cast a
|
||||
``int`` per indicatori con argomenti tipo "length" è deferito alle helper
|
||||
(``_ind_sma``, ecc.) attraverso ``int(...)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,7 +26,14 @@ import numpy as np
|
||||
import pandas as pd # type: ignore[import-untyped]
|
||||
|
||||
from ..backtest.orders import Side
|
||||
from .parser import Node, Strategy
|
||||
from .parser import (
|
||||
FeatureNode,
|
||||
IndicatorNode,
|
||||
LiteralNode,
|
||||
Node,
|
||||
OpNode,
|
||||
Strategy,
|
||||
)
|
||||
|
||||
|
||||
def _sma(s: pd.Series, length: int) -> pd.Series:
|
||||
@@ -61,24 +68,31 @@ def _realized_vol(s: pd.Series, window: int) -> pd.Series:
|
||||
return returns.rolling(window, min_periods=1).std() * np.sqrt(window)
|
||||
|
||||
|
||||
def _ind_sma(df: pd.DataFrame, length: int) -> pd.Series:
|
||||
return _sma(df["close"], length)
|
||||
def _ind_sma(df: pd.DataFrame, length: float) -> pd.Series:
|
||||
return _sma(df["close"], int(length))
|
||||
|
||||
|
||||
def _ind_rsi(df: pd.DataFrame, length: int) -> pd.Series:
|
||||
return _rsi(df["close"], length)
|
||||
def _ind_rsi(df: pd.DataFrame, length: float) -> pd.Series:
|
||||
return _rsi(df["close"], int(length))
|
||||
|
||||
|
||||
def _ind_atr(df: pd.DataFrame, length: int) -> pd.Series:
|
||||
return _atr(df, length)
|
||||
def _ind_atr(df: pd.DataFrame, length: float) -> pd.Series:
|
||||
return _atr(df, int(length))
|
||||
|
||||
|
||||
def _ind_realized_vol(df: pd.DataFrame, window: int) -> pd.Series:
|
||||
return _realized_vol(df["close"], window)
|
||||
def _ind_realized_vol(df: pd.DataFrame, window: float) -> pd.Series:
|
||||
return _realized_vol(df["close"], int(window))
|
||||
|
||||
|
||||
def _ind_macd(df: pd.DataFrame, fast: int = 12, slow: int = 26) -> pd.Series:
|
||||
return _sma(df["close"], fast) - _sma(df["close"], slow)
|
||||
def _ind_macd(
|
||||
df: pd.DataFrame,
|
||||
fast: float = 12,
|
||||
slow: float = 26,
|
||||
signal: float = 9,
|
||||
) -> pd.Series:
|
||||
macd_line = _sma(df["close"], int(fast)) - _sma(df["close"], int(slow))
|
||||
signal_line = _sma(macd_line, int(signal))
|
||||
return macd_line - signal_line
|
||||
|
||||
|
||||
# Annotated as ``dict[str, Any]`` deliberately: each indicator has its own
|
||||
@@ -93,17 +107,17 @@ INDICATOR_FNS: dict[str, Any] = {
|
||||
"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: object, df: pd.DataFrame) -> pd.Series:
|
||||
|
||||
def _to_series(value: float, df: pd.DataFrame) -> pd.Series:
|
||||
"""Broadcast a numeric literal across the DataFrame index."""
|
||||
return pd.Series(float(value), index=df.index) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _eval_arg(arg: Any, df: pd.DataFrame) -> pd.Series:
|
||||
"""Evaluate either a child Node or a scalar literal into a Series."""
|
||||
if isinstance(arg, Node):
|
||||
return _eval_node(arg, df)
|
||||
return _to_series(arg, df)
|
||||
return pd.Series(float(value), index=df.index)
|
||||
|
||||
|
||||
def _compare_with_nan(result: pd.Series, a: pd.Series, b: pd.Series) -> pd.Series:
|
||||
@@ -120,71 +134,62 @@ def _compare_with_nan(result: pd.Series, a: pd.Series, b: pd.Series) -> pd.Serie
|
||||
return out
|
||||
|
||||
|
||||
def _eval_bool_arg(arg: Any, df: pd.DataFrame) -> pd.Series:
|
||||
"""Evaluate either a child Node (bool series) or a literal into a bool Series."""
|
||||
if isinstance(arg, Node):
|
||||
return _eval_node(arg, df).fillna(False).astype(bool)
|
||||
return pd.Series(bool(arg), index=df.index)
|
||||
def _eval_bool_arg(node: Node, df: pd.DataFrame) -> pd.Series:
|
||||
"""Evaluate a child Node into a boolean Series (NaN -> False)."""
|
||||
return _eval_node(node, df).fillna(False).astype(bool)
|
||||
|
||||
|
||||
def _eval_node(node: Node, df: pd.DataFrame) -> pd.Series:
|
||||
kind = node.kind
|
||||
if isinstance(node, FeatureNode):
|
||||
if node.name in _TIME_FEATURE_FNS:
|
||||
return _TIME_FEATURE_FNS[node.name](df.index)
|
||||
return df[node.name]
|
||||
|
||||
if kind == "feature":
|
||||
feat = node.args[0]
|
||||
feat_name = feat.kind if isinstance(feat, Node) else str(feat)
|
||||
return df[feat_name]
|
||||
|
||||
if kind == "indicator":
|
||||
name_node = node.args[0]
|
||||
ind_name = name_node.kind if isinstance(name_node, Node) else str(name_node)
|
||||
params = [a for a in node.args[1:] if not isinstance(a, Node)]
|
||||
fn = INDICATOR_FNS[ind_name]
|
||||
result: pd.Series = fn(df, *params)
|
||||
if isinstance(node, IndicatorNode):
|
||||
fn = INDICATOR_FNS[node.name]
|
||||
result: pd.Series = fn(df, *node.params)
|
||||
return result
|
||||
|
||||
if kind == "gt":
|
||||
a = _eval_arg(node.args[0], df)
|
||||
b = _eval_arg(node.args[1], df)
|
||||
if isinstance(node, LiteralNode):
|
||||
return _to_series(node.value, df)
|
||||
|
||||
if isinstance(node, OpNode):
|
||||
op = node.op
|
||||
if op == "gt":
|
||||
a = _eval_node(node.args[0], df)
|
||||
b = _eval_node(node.args[1], df)
|
||||
return _compare_with_nan(a > b, a, b)
|
||||
|
||||
if kind == "lt":
|
||||
a = _eval_arg(node.args[0], df)
|
||||
b = _eval_arg(node.args[1], df)
|
||||
if op == "lt":
|
||||
a = _eval_node(node.args[0], df)
|
||||
b = _eval_node(node.args[1], df)
|
||||
return _compare_with_nan(a < b, a, b)
|
||||
|
||||
if kind == "eq":
|
||||
a = _eval_arg(node.args[0], df)
|
||||
b = _eval_arg(node.args[1], df)
|
||||
if op == "eq":
|
||||
a = _eval_node(node.args[0], df)
|
||||
b = _eval_node(node.args[1], df)
|
||||
return _compare_with_nan(a == b, a, b)
|
||||
|
||||
if kind == "and":
|
||||
if op == "and":
|
||||
result = pd.Series(True, index=df.index)
|
||||
for a in node.args:
|
||||
result &= _eval_bool_arg(a, df)
|
||||
return result
|
||||
|
||||
if kind == "or":
|
||||
if op == "or":
|
||||
result = pd.Series(False, index=df.index)
|
||||
for a in node.args:
|
||||
result |= _eval_bool_arg(a, df)
|
||||
return result
|
||||
|
||||
if kind == "not":
|
||||
s = _eval_bool_arg(node.args[0], df)
|
||||
return ~s
|
||||
|
||||
if kind == "crossover":
|
||||
a = _eval_arg(node.args[0], df)
|
||||
b = _eval_arg(node.args[1], df)
|
||||
if op == "not":
|
||||
return ~_eval_bool_arg(node.args[0], df)
|
||||
if op == "crossover":
|
||||
a = _eval_node(node.args[0], df)
|
||||
b = _eval_node(node.args[1], df)
|
||||
return ((a > b) & (a.shift() <= b.shift())).fillna(False).astype(bool)
|
||||
|
||||
if kind == "crossunder":
|
||||
a = _eval_arg(node.args[0], df)
|
||||
b = _eval_arg(node.args[1], df)
|
||||
if op == "crossunder":
|
||||
a = _eval_node(node.args[0], df)
|
||||
b = _eval_node(node.args[1], df)
|
||||
return ((a < b) & (a.shift() >= b.shift())).fillna(False).astype(bool)
|
||||
raise RuntimeError(f"unsupported op in compiler: {op}")
|
||||
|
||||
raise RuntimeError(f"unsupported node in compiler: {kind}")
|
||||
raise RuntimeError(f"unsupported node type in compiler: {type(node).__name__}")
|
||||
|
||||
|
||||
_ACTION_TO_SIDE: dict[str, Side] = {
|
||||
@@ -195,10 +200,6 @@ _ACTION_TO_SIDE: dict[str, Side] = {
|
||||
}
|
||||
|
||||
|
||||
def _action_to_side(action: Node) -> Side:
|
||||
return _ACTION_TO_SIDE[action.kind]
|
||||
|
||||
|
||||
def compile_strategy(strategy: Strategy) -> Callable[[pd.DataFrame], pd.Series]:
|
||||
"""Compile a :class:`Strategy` AST into a ``df -> Series[Side]`` callable.
|
||||
|
||||
@@ -214,7 +215,7 @@ def compile_strategy(strategy: Strategy) -> Callable[[pd.DataFrame], pd.Series]:
|
||||
any_rule_seen = pd.Series(False, index=df.index)
|
||||
for rule in strategy.rules:
|
||||
match = _eval_node(rule.condition, df)
|
||||
target = _action_to_side(rule.action)
|
||||
target = _ACTION_TO_SIDE[rule.action]
|
||||
valid = ~_isna_series(match)
|
||||
any_rule_seen |= valid
|
||||
match_bool = match.where(valid, False).astype(bool)
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
VERBS: frozenset[str] = frozenset(
|
||||
{
|
||||
"entry-long",
|
||||
"entry-short",
|
||||
"exit",
|
||||
"flat",
|
||||
"when",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
"gt",
|
||||
"lt",
|
||||
"eq",
|
||||
"feature",
|
||||
"indicator",
|
||||
"crossover",
|
||||
"crossunder",
|
||||
}
|
||||
# Grammatica JSON Schema (Phase 1, post S-expression refactor).
|
||||
#
|
||||
# Distinzione strutturale:
|
||||
# * Nodi OPERATORE -> dict con chiave ``"op"`` (logici, comparatori, crossover)
|
||||
# * Nodi LEAF -> dict con chiave ``"kind"`` (indicator, feature, literal)
|
||||
# ``op`` e ``kind`` sono mutuamente esclusivi sullo stesso nodo.
|
||||
|
||||
LOGICAL_OPS: frozenset[str] = frozenset({"and", "or", "not"})
|
||||
COMPARATOR_OPS: frozenset[str] = frozenset({"gt", "lt", "eq"})
|
||||
CROSSOVER_OPS: frozenset[str] = frozenset({"crossover", "crossunder"})
|
||||
|
||||
ACTION_VALUES: frozenset[str] = frozenset(
|
||||
{"entry-long", "entry-short", "exit", "flat"}
|
||||
)
|
||||
KIND_VALUES: frozenset[str] = frozenset({"indicator", "feature", "literal"})
|
||||
|
||||
KNOWN_INDICATORS: frozenset[str] = frozenset(
|
||||
{"sma", "rsi", "atr", "macd", "realized_vol"}
|
||||
)
|
||||
KNOWN_FEATURES: frozenset[str] = frozenset(
|
||||
{"open", "high", "low", "close", "volume",
|
||||
"hour", "dow", "is_weekend", "minute_of_hour"}
|
||||
)
|
||||
|
||||
ACTION_VERBS: frozenset[str] = frozenset({"entry-long", "entry-short", "exit", "flat"})
|
||||
LOGICAL_VERBS: frozenset[str] = frozenset({"and", "or", "not"})
|
||||
COMPARATOR_VERBS: frozenset[str] = frozenset({"gt", "lt", "eq"})
|
||||
DATA_VERBS: frozenset[str] = frozenset({"feature", "indicator", "crossover", "crossunder"})
|
||||
# Convenience union (utile a validator / parser).
|
||||
ALL_OPS: frozenset[str] = LOGICAL_OPS | COMPARATOR_OPS | CROSSOVER_OPS
|
||||
|
||||
@@ -1,96 +1,203 @@
|
||||
"""JSON-based parser per la strategia di trading (Phase 1).
|
||||
|
||||
L'AST è una piccola gerarchia di dataclass:
|
||||
|
||||
* :class:`Strategy` è il top-level (lista di :class:`Rule`).
|
||||
* :class:`Rule` accoppia una condizione (Node) ad un'azione (str).
|
||||
* :class:`Node` è un'unione: nodi operatore (:class:`OpNode`) e nodi leaf
|
||||
(:class:`IndicatorNode`, :class:`FeatureNode`, :class:`LiteralNode`).
|
||||
|
||||
Convenzione di shape sui dict in input:
|
||||
|
||||
* Nodi operatore: ``{"op": "<name>", "args": [<node>, ...]}``.
|
||||
* Nodi indicator: ``{"kind": "indicator", "name": "<name>", "params": [<num>, ...]}``.
|
||||
* Nodi feature: ``{"kind": "feature", "name": "<name>"}``.
|
||||
* Nodi literal: ``{"kind": "literal", "value": <number>}``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import sexpdata # type: ignore[import-untyped]
|
||||
|
||||
from .grammar import ACTION_VERBS, VERBS
|
||||
from .grammar import (
|
||||
ACTION_VALUES,
|
||||
ALL_OPS,
|
||||
)
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""Raised when an S-expression strategy cannot be parsed."""
|
||||
"""Raised when a JSON strategy cannot be parsed into a valid AST."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclass AST
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
kind: str
|
||||
args: list[Any] = field(default_factory=list)
|
||||
class OpNode:
|
||||
"""Operator node: logical / comparator / crossover."""
|
||||
|
||||
op: str
|
||||
args: list[Node] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndicatorNode:
|
||||
"""Leaf: indicatore tecnico calcolato sul dataframe OHLCV."""
|
||||
|
||||
name: str
|
||||
params: list[float] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureNode:
|
||||
"""Leaf: colonna OHLCV (open/high/low/close/volume)."""
|
||||
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiteralNode:
|
||||
"""Leaf: costante numerica."""
|
||||
|
||||
value: float
|
||||
|
||||
|
||||
Node = OpNode | IndicatorNode | FeatureNode | LiteralNode
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
kind: str # always "when"
|
||||
condition: Node
|
||||
action: Node
|
||||
action: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Strategy:
|
||||
kind: str # always "strategy"
|
||||
rules: list[Rule]
|
||||
|
||||
|
||||
def _to_node(token: Any) -> Node | float | int | str:
|
||||
"""Convert a sexpdata token tree into a Node (or scalar leaf)."""
|
||||
if isinstance(token, sexpdata.Symbol):
|
||||
name = str(token.value())
|
||||
# Bare symbols inside expressions (e.g. `rsi` in (indicator rsi 14))
|
||||
# are kept as Node-with-no-args so callers can introspect uniformly.
|
||||
return Node(kind=name, args=[])
|
||||
if isinstance(token, list):
|
||||
if not token:
|
||||
raise ParseError("Empty s-expression")
|
||||
head = token[0]
|
||||
if not isinstance(head, sexpdata.Symbol):
|
||||
raise ParseError(f"Non-symbol head: {head!r}")
|
||||
name = str(head.value())
|
||||
if name not in VERBS:
|
||||
raise ParseError(f"Unknown verb: {name}")
|
||||
return Node(kind=name, args=[_to_node(arg) for arg in token[1:]])
|
||||
# numeric / string literals pass through unchanged
|
||||
return token # type: ignore[no-any-return]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversione dict -> Node
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_node(obj: Any) -> Node:
|
||||
if not isinstance(obj, dict):
|
||||
raise ParseError(f"Node must be a JSON object, got {type(obj).__name__}")
|
||||
|
||||
has_op = "op" in obj
|
||||
has_kind = "kind" in obj
|
||||
if has_op and has_kind:
|
||||
raise ParseError(
|
||||
"Node cannot define both 'op' and 'kind' (mutually exclusive)"
|
||||
)
|
||||
if not has_op and not has_kind:
|
||||
raise ParseError("Node must define either 'op' or 'kind'")
|
||||
|
||||
if has_op:
|
||||
op = obj["op"]
|
||||
if not isinstance(op, str):
|
||||
raise ParseError(f"'op' must be a string, got {type(op).__name__}")
|
||||
if op not in ALL_OPS:
|
||||
raise ParseError(f"Unknown op: {op!r}")
|
||||
raw_args = obj.get("args")
|
||||
if not isinstance(raw_args, list):
|
||||
raise ParseError(f"Operator '{op}' missing 'args' list")
|
||||
args = [_to_node(a) for a in raw_args]
|
||||
return OpNode(op=op, args=args)
|
||||
|
||||
# leaf node
|
||||
kind = obj["kind"]
|
||||
if not isinstance(kind, str):
|
||||
raise ParseError(f"'kind' must be a string, got {type(kind).__name__}")
|
||||
|
||||
if kind == "indicator":
|
||||
name = obj.get("name")
|
||||
if not isinstance(name, str):
|
||||
raise ParseError("indicator node requires string 'name'")
|
||||
raw_params = obj.get("params", [])
|
||||
if not isinstance(raw_params, list):
|
||||
raise ParseError("indicator 'params' must be a list")
|
||||
params: list[float] = []
|
||||
for p in raw_params:
|
||||
if isinstance(p, bool) or not isinstance(p, (int, float)):
|
||||
raise ParseError(
|
||||
f"indicator '{name}' params accept only numbers, got {p!r}"
|
||||
)
|
||||
params.append(float(p))
|
||||
return IndicatorNode(name=name, params=params)
|
||||
|
||||
if kind == "feature":
|
||||
name = obj.get("name")
|
||||
if not isinstance(name, str):
|
||||
raise ParseError("feature node requires string 'name'")
|
||||
return FeatureNode(name=name)
|
||||
|
||||
if kind == "literal":
|
||||
if "value" not in obj:
|
||||
raise ParseError("literal node requires 'value'")
|
||||
value = obj["value"]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ParseError(f"literal value must be numeric, got {value!r}")
|
||||
return LiteralNode(value=float(value))
|
||||
|
||||
raise ParseError(f"Unknown leaf kind: {kind!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-level parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_strategy(src: str) -> Strategy:
|
||||
"""Parse an S-expression strategy string into a Strategy AST.
|
||||
"""Parse a JSON strategy string into a :class:`Strategy` AST.
|
||||
|
||||
The grammar is documented in :mod:`multi_swarm.protocol.grammar` and is
|
||||
intentionally tiny (15 verbs). We delegate raw S-expr lexing to
|
||||
:mod:`sexpdata`, then validate the verb set ourselves.
|
||||
Lo schema atteso è::
|
||||
|
||||
{
|
||||
"rules": [
|
||||
{"condition": <node>, "action": "<action-string>"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Raise :class:`ParseError` su JSON malformato o struttura inattesa.
|
||||
"""
|
||||
try:
|
||||
parsed = sexpdata.loads(src)
|
||||
except Exception as e: # sexpdata raises various exception types
|
||||
raise ParseError(f"sexp parse error: {e}") from e
|
||||
parsed = json.loads(src)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"invalid JSON: {e}") from e
|
||||
|
||||
if not isinstance(parsed, list) or not parsed:
|
||||
raise ParseError("Top-level must be (strategy ...)")
|
||||
head = parsed[0]
|
||||
if not isinstance(head, sexpdata.Symbol) or str(head.value()) != "strategy":
|
||||
raise ParseError("Top-level must start with 'strategy'")
|
||||
|
||||
raw_rules = parsed[1:]
|
||||
if not isinstance(parsed, dict):
|
||||
raise ParseError("Top-level must be a JSON object with 'rules'")
|
||||
if "rules" not in parsed:
|
||||
raise ParseError("Top-level object must contain 'rules' key")
|
||||
raw_rules = parsed["rules"]
|
||||
if not isinstance(raw_rules, list):
|
||||
raise ParseError("'rules' must be a list")
|
||||
if not raw_rules:
|
||||
raise ParseError("Strategy must contain at least one rule")
|
||||
|
||||
rules: list[Rule] = []
|
||||
for raw in raw_rules:
|
||||
if not isinstance(raw, list) or len(raw) != 3:
|
||||
raise ParseError(f"Rule must be (when <cond> <action>): {raw!r}")
|
||||
head_r = raw[0]
|
||||
if not isinstance(head_r, sexpdata.Symbol) or str(head_r.value()) != "when":
|
||||
raise ParseError(f"Rule must start with 'when': {raw!r}")
|
||||
cond = _to_node(raw[1])
|
||||
action = _to_node(raw[2])
|
||||
if not isinstance(cond, Node):
|
||||
raise ParseError(f"Condition must be a node: {cond!r}")
|
||||
if not isinstance(action, Node):
|
||||
raise ParseError(f"Action must be a node: {action!r}")
|
||||
if action.kind not in ACTION_VERBS:
|
||||
if not isinstance(raw, dict):
|
||||
raise ParseError(f"Rule must be a JSON object, got {raw!r}")
|
||||
if "condition" not in raw or "action" not in raw:
|
||||
raise ParseError(
|
||||
f"Action must be one of {sorted(ACTION_VERBS)}, got {action.kind!r}"
|
||||
f"Rule must contain 'condition' and 'action' keys: {raw!r}"
|
||||
)
|
||||
rules.append(Rule(kind="when", condition=cond, action=action))
|
||||
action = raw["action"]
|
||||
if not isinstance(action, str):
|
||||
raise ParseError(f"action must be a string, got {action!r}")
|
||||
if action not in ACTION_VALUES:
|
||||
raise ParseError(
|
||||
f"action must be one of {sorted(ACTION_VALUES)}, got {action!r}"
|
||||
)
|
||||
cond = _to_node(raw["condition"])
|
||||
rules.append(Rule(condition=cond, action=action))
|
||||
|
||||
return Strategy(kind="strategy", rules=rules)
|
||||
return Strategy(rules=rules)
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
"""Semantic validation for the JSON-based strategy AST.
|
||||
|
||||
Il parser garantisce già shape sintattica (op vs kind, struttura args/params,
|
||||
tipi base). Qui si controllano vincoli semantici di Phase 1:
|
||||
|
||||
* Arity di operatori logici / comparatori / crossover.
|
||||
* Whitelist indicator + arity dei params.
|
||||
* Whitelist feature.
|
||||
* Niente nesting di indicator (params puramente numerici, garantito già dal
|
||||
parser ma ricontrollato esplicitamente per chiarezza).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .grammar import COMPARATOR_VERBS, LOGICAL_VERBS
|
||||
from .parser import Node, Strategy
|
||||
from .grammar import (
|
||||
COMPARATOR_OPS,
|
||||
CROSSOVER_OPS,
|
||||
KNOWN_FEATURES,
|
||||
KNOWN_INDICATORS,
|
||||
LOGICAL_OPS,
|
||||
)
|
||||
from .parser import (
|
||||
FeatureNode,
|
||||
IndicatorNode,
|
||||
LiteralNode,
|
||||
Node,
|
||||
OpNode,
|
||||
Strategy,
|
||||
)
|
||||
|
||||
KNOWN_INDICATORS: frozenset[str] = frozenset({"sma", "rsi", "atr", "macd", "realized_vol"})
|
||||
KNOWN_FEATURES: frozenset[str] = frozenset({"open", "high", "low", "close", "volume"})
|
||||
# Numero di parametri numerici accettati dopo il nome dell'indicatore.
|
||||
# (min, max) sui soli numeri. Indicatori non sono annidabili in Phase 1.
|
||||
INDICATOR_ARITY: dict[str, tuple[int, int]] = {
|
||||
"sma": (1, 1), # length
|
||||
"rsi": (1, 1), # length
|
||||
"atr": (1, 1), # length
|
||||
"realized_vol": (1, 1), # window
|
||||
"macd": (0, 3), # fast, slow, signal (tutti opzionali)
|
||||
}
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
@@ -12,64 +44,66 @@ class ValidationError(Exception):
|
||||
|
||||
|
||||
def validate_strategy(strategy: Strategy) -> None:
|
||||
"""Check semantic constraints on a parsed Strategy AST.
|
||||
|
||||
The parser already enforces verb-set membership; this pass adds:
|
||||
* arity checks for logical/comparator/data verbs,
|
||||
* known-indicator / known-feature whitelists.
|
||||
"""
|
||||
"""Walk every rule of the strategy and assert semantic constraints."""
|
||||
for rule in strategy.rules:
|
||||
_validate_node(rule.condition, _expect_bool=True)
|
||||
_validate_node(rule.condition)
|
||||
|
||||
|
||||
def _validate_node(node: Node, _expect_bool: bool) -> None:
|
||||
if node.kind in LOGICAL_VERBS:
|
||||
if node.kind == "not":
|
||||
if len(node.args) != 1:
|
||||
raise ValidationError(f"'not' needs 1 arg, got {len(node.args)}")
|
||||
arg = node.args[0]
|
||||
if isinstance(arg, Node):
|
||||
_validate_node(arg, _expect_bool=True)
|
||||
def _validate_node(node: Node) -> None:
|
||||
if isinstance(node, OpNode):
|
||||
_validate_op(node)
|
||||
return
|
||||
if isinstance(node, IndicatorNode):
|
||||
_validate_indicator(node)
|
||||
return
|
||||
if isinstance(node, FeatureNode):
|
||||
if node.name not in KNOWN_FEATURES:
|
||||
raise ValidationError(f"unknown feature: {node.name}")
|
||||
return
|
||||
if isinstance(node, LiteralNode):
|
||||
# parser ha già validato il tipo numerico
|
||||
return
|
||||
raise ValidationError(f"unexpected node type: {type(node).__name__}")
|
||||
|
||||
|
||||
def _validate_op(node: OpNode) -> None:
|
||||
op = node.op
|
||||
n = len(node.args)
|
||||
|
||||
if op in LOGICAL_OPS:
|
||||
if op == "not":
|
||||
if n != 1:
|
||||
raise ValidationError(f"'not' needs 1 arg, got {n}")
|
||||
else:
|
||||
if len(node.args) < 2:
|
||||
raise ValidationError(f"'{node.kind}' needs >=2 args")
|
||||
if n < 2:
|
||||
raise ValidationError(f"'{op}' needs >=2 args, got {n}")
|
||||
for a in node.args:
|
||||
if isinstance(a, Node):
|
||||
_validate_node(a, _expect_bool=True)
|
||||
_validate_node(a)
|
||||
return
|
||||
|
||||
if node.kind in COMPARATOR_VERBS:
|
||||
if len(node.args) != 2:
|
||||
raise ValidationError(f"'{node.kind}' needs 2 args, got {len(node.args)}")
|
||||
if op in COMPARATOR_OPS:
|
||||
if n != 2:
|
||||
raise ValidationError(f"'{op}' needs 2 args, got {n}")
|
||||
for a in node.args:
|
||||
if isinstance(a, Node):
|
||||
_validate_node(a, _expect_bool=False)
|
||||
_validate_node(a)
|
||||
return
|
||||
|
||||
if node.kind in {"crossover", "crossunder"}:
|
||||
if len(node.args) != 2:
|
||||
raise ValidationError(f"'{node.kind}' needs 2 args")
|
||||
if op in CROSSOVER_OPS:
|
||||
if n != 2:
|
||||
raise ValidationError(f"'{op}' needs 2 args, got {n}")
|
||||
for a in node.args:
|
||||
if isinstance(a, Node):
|
||||
_validate_node(a, _expect_bool=False)
|
||||
_validate_node(a)
|
||||
return
|
||||
|
||||
if node.kind == "indicator":
|
||||
if len(node.args) < 2:
|
||||
raise ValidationError("'indicator' needs >=2 args (name, length)")
|
||||
name_node = node.args[0]
|
||||
ind_name = name_node.kind if isinstance(name_node, Node) else str(name_node)
|
||||
if ind_name not in KNOWN_INDICATORS:
|
||||
raise ValidationError(f"unknown indicator: {ind_name}")
|
||||
return
|
||||
raise ValidationError(f"unexpected op in expression: {op}")
|
||||
|
||||
if node.kind == "feature":
|
||||
if len(node.args) != 1:
|
||||
raise ValidationError("'feature' needs 1 arg")
|
||||
feat_node = node.args[0]
|
||||
feat_name = feat_node.kind if isinstance(feat_node, Node) else str(feat_node)
|
||||
if feat_name not in KNOWN_FEATURES:
|
||||
raise ValidationError(f"unknown feature: {feat_name}")
|
||||
return
|
||||
|
||||
raise ValidationError(f"unexpected node kind in expression: {node.kind}")
|
||||
def _validate_indicator(node: IndicatorNode) -> None:
|
||||
if node.name not in KNOWN_INDICATORS:
|
||||
raise ValidationError(f"unknown indicator: {node.name}")
|
||||
n_params = len(node.params)
|
||||
min_p, max_p = INDICATOR_ARITY[node.name]
|
||||
if not (min_p <= n_params <= max_p):
|
||||
raise ValidationError(
|
||||
f"indicator '{node.name}' arity {n_params} out of [{min_p},{max_p}]"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "and",
|
||||
"args": [
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "rsi",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 70.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "atr",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "sma",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "feature",
|
||||
"name": "hour"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 9
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "feature",
|
||||
"name": "hour"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": "entry-short"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "and",
|
||||
"args": [
|
||||
{
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "rsi",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 30.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "atr",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "sma",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "feature",
|
||||
"name": "hour"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 9
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "feature",
|
||||
"name": "hour"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": "entry-long"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"condition": {
|
||||
"op": "and",
|
||||
"args": [
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "atr",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 0.02
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "realized_vol",
|
||||
"params": [
|
||||
20
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 0.03
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "gt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "sma",
|
||||
"params": [
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "sma",
|
||||
"params": [
|
||||
200
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": "entry-long"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "and",
|
||||
"args": [
|
||||
{
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "atr",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 0.01
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "realized_vol",
|
||||
"params": [
|
||||
20
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 0.02
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "lt",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "sma",
|
||||
"params": [
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "sma",
|
||||
"params": [
|
||||
200
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": "entry-short"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"op": "or",
|
||||
"args": [
|
||||
{
|
||||
"op": "crossover",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "rsi",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 70.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "crossunder",
|
||||
"args": [
|
||||
{
|
||||
"kind": "indicator",
|
||||
"name": "rsi",
|
||||
"params": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 30.0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": "exit"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -26,16 +27,40 @@ def synthetic_ohlcv():
|
||||
)
|
||||
|
||||
|
||||
_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 valida."""
|
||||
"""LLM mock che ritorna sempre una strategia JSON valida."""
|
||||
fake = mocker.MagicMock()
|
||||
fake.complete.return_value = CompletionResult(
|
||||
text=(
|
||||
"```lisp\n(strategy "
|
||||
"(when (gt (indicator rsi 14) 70.0) (entry-short)) "
|
||||
"(when (lt (indicator rsi 14) 30.0) (entry-long)))\n```"
|
||||
),
|
||||
text="```json\n" + _STRATEGY_PAYLOAD + "\n```",
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
@@ -75,3 +100,35 @@ def test_e2e_minimal_run_completes(
|
||||
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.ga.loop import GAConfig, next_generation
|
||||
from multi_swarm.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
|
||||
@@ -1,310 +0,0 @@
|
||||
import importlib
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def test_streamlit_app_imports():
|
||||
importlib.import_module("multi_swarm.dashboard.data")
|
||||
|
||||
|
||||
def test_dashboard_data_helpers_signatures():
|
||||
from multi_swarm.dashboard import data
|
||||
|
||||
assert hasattr(data, "list_runs_df")
|
||||
assert hasattr(data, "generations_df")
|
||||
assert hasattr(data, "evaluations_df")
|
||||
assert hasattr(data, "genomes_df")
|
||||
|
||||
|
||||
def test_aquarium_helper_builds_html_with_click_handler():
|
||||
from multi_swarm.dashboard.aquarium import build_aquarium_html
|
||||
|
||||
fish = [
|
||||
{
|
||||
"id": "abc123",
|
||||
"fitness": 0.8,
|
||||
"cognitive_style": "physicist",
|
||||
"n_trades": 30,
|
||||
"dsr": 0.7,
|
||||
"sharpe": 1.2,
|
||||
"max_dd": 0.1,
|
||||
"system_prompt": "test",
|
||||
"temperature": 0.9,
|
||||
"lookback_window": 200,
|
||||
"feature_access": ["close"],
|
||||
"model_tier": "C",
|
||||
"generation": 1,
|
||||
"parent_ids": [],
|
||||
"ancestors": [],
|
||||
}
|
||||
]
|
||||
html = build_aquarium_html(fish, canvas_w=800, canvas_h=400)
|
||||
assert "canvas" in html
|
||||
assert "abc123" in html # fish id present in JSON payload
|
||||
assert "addEventListener('click'" in html
|
||||
assert "fish-info-panel" in html
|
||||
assert "showFishInfo" in html
|
||||
assert "Discendenza" in html
|
||||
assert "requestAnimationFrame" in html
|
||||
|
||||
|
||||
def test_aquarium_build_fish_dataset_legacy_path():
|
||||
from multi_swarm.dashboard.aquarium import build_fish_dataset
|
||||
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"genome_id": "low",
|
||||
"fitness": 0.1,
|
||||
"cognitive_style": "physicist",
|
||||
"n_trades": 1,
|
||||
"dsr": 0.0,
|
||||
},
|
||||
{
|
||||
"genome_id": "high",
|
||||
"fitness": 0.9,
|
||||
"cognitive_style": "biologist",
|
||||
"n_trades": 10,
|
||||
"dsr": 0.5,
|
||||
},
|
||||
]
|
||||
)
|
||||
out = build_fish_dataset(df)
|
||||
ids = {f["id"] for f in out}
|
||||
assert ids == {"low", "high"}
|
||||
high = next(f for f in out if f["id"] == "high")
|
||||
assert high["cognitive_style"] == "biologist"
|
||||
assert high["ancestors"] == []
|
||||
|
||||
|
||||
def test_aquarium_build_fish_dataset_drops_nan_fitness():
|
||||
from multi_swarm.dashboard.aquarium import build_fish_dataset
|
||||
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"genome_id": "ok",
|
||||
"fitness": 0.4,
|
||||
"cognitive_style": "historian",
|
||||
"n_trades": 2,
|
||||
"dsr": 0.1,
|
||||
},
|
||||
{
|
||||
"genome_id": "bad",
|
||||
"fitness": float("nan"),
|
||||
"cognitive_style": "ecologist",
|
||||
"n_trades": 0,
|
||||
"dsr": 0.0,
|
||||
},
|
||||
]
|
||||
)
|
||||
out = build_fish_dataset(df)
|
||||
assert len(out) == 1
|
||||
assert out[0]["id"] == "ok"
|
||||
|
||||
|
||||
def test_aquarium_empty_input_returns_empty():
|
||||
from multi_swarm.dashboard.aquarium import build_aquarium_html, build_fish_dataset
|
||||
|
||||
assert build_fish_dataset(pd.DataFrame()) == []
|
||||
html = build_aquarium_html([], canvas_w=400, canvas_h=200)
|
||||
assert "canvas" in html
|
||||
assert "Acquario vuoto" in html
|
||||
|
||||
|
||||
def test_build_lineage_index_returns_dict_keyed_by_id():
|
||||
from multi_swarm.dashboard.aquarium import build_lineage_index
|
||||
|
||||
genomes = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"id": "g1",
|
||||
"generation_idx": 0,
|
||||
"generation": 0,
|
||||
"system_prompt": "x",
|
||||
"feature_access": ["close"],
|
||||
"temperature": 0.9,
|
||||
"top_p": 0.95,
|
||||
"model_tier": "C",
|
||||
"lookback_window": 100,
|
||||
"cognitive_style": "physicist",
|
||||
"parent_ids": [],
|
||||
},
|
||||
{
|
||||
"id": "g2",
|
||||
"generation_idx": 1,
|
||||
"generation": 1,
|
||||
"system_prompt": "y",
|
||||
"feature_access": ["close", "volume"],
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"model_tier": "C",
|
||||
"lookback_window": 200,
|
||||
"cognitive_style": "biologist",
|
||||
"parent_ids": ["g1"],
|
||||
},
|
||||
]
|
||||
)
|
||||
evals = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"genome_id": "g1",
|
||||
"fitness": 0.5,
|
||||
"dsr": 0.6,
|
||||
"sharpe": 1.2,
|
||||
"max_dd": 0.1,
|
||||
"n_trades": 30,
|
||||
"parse_error": None,
|
||||
"raw_text": "",
|
||||
},
|
||||
{
|
||||
"genome_id": "g2",
|
||||
"fitness": 0.7,
|
||||
"dsr": 0.8,
|
||||
"sharpe": 1.5,
|
||||
"max_dd": 0.05,
|
||||
"n_trades": 40,
|
||||
"parse_error": None,
|
||||
"raw_text": "",
|
||||
},
|
||||
]
|
||||
)
|
||||
idx = build_lineage_index(genomes, evals)
|
||||
assert "g1" in idx and "g2" in idx
|
||||
assert idx["g2"]["parent_ids"] == ["g1"]
|
||||
assert idx["g2"]["fitness"] == 0.7
|
||||
assert idx["g1"]["cognitive_style"] == "physicist"
|
||||
assert idx["g2"]["feature_access"] == ["close", "volume"]
|
||||
|
||||
|
||||
def test_trace_ancestors_walks_levels():
|
||||
from multi_swarm.dashboard.aquarium import trace_ancestors
|
||||
|
||||
idx = {
|
||||
"child": {
|
||||
"id": "child",
|
||||
"parent_ids": ["p1", "p2"],
|
||||
"fitness": 0.8,
|
||||
"generation": 2,
|
||||
"cognitive_style": "physicist",
|
||||
},
|
||||
"p1": {
|
||||
"id": "p1",
|
||||
"parent_ids": ["gp1"],
|
||||
"fitness": 0.5,
|
||||
"generation": 1,
|
||||
"cognitive_style": "biologist",
|
||||
},
|
||||
"p2": {
|
||||
"id": "p2",
|
||||
"parent_ids": [],
|
||||
"fitness": 0.3,
|
||||
"generation": 1,
|
||||
"cognitive_style": "engineer",
|
||||
},
|
||||
"gp1": {
|
||||
"id": "gp1",
|
||||
"parent_ids": [],
|
||||
"fitness": 0.2,
|
||||
"generation": 0,
|
||||
"cognitive_style": "historian",
|
||||
},
|
||||
}
|
||||
levels = trace_ancestors("child", idx, max_levels=5)
|
||||
assert len(levels) == 2
|
||||
assert {a["id"] for a in levels[0]} == {"p1", "p2"}
|
||||
assert {a["id"] for a in levels[1]} == {"gp1"}
|
||||
|
||||
|
||||
def test_trace_ancestors_handles_cycles():
|
||||
from multi_swarm.dashboard.aquarium import trace_ancestors
|
||||
|
||||
# Pathological cycle: a <-> b. Should terminate cleanly.
|
||||
idx = {
|
||||
"a": {
|
||||
"id": "a",
|
||||
"parent_ids": ["b"],
|
||||
"fitness": 0.1,
|
||||
"generation": 1,
|
||||
"cognitive_style": "physicist",
|
||||
},
|
||||
"b": {
|
||||
"id": "b",
|
||||
"parent_ids": ["a"],
|
||||
"fitness": 0.2,
|
||||
"generation": 0,
|
||||
"cognitive_style": "biologist",
|
||||
},
|
||||
}
|
||||
levels = trace_ancestors("a", idx, max_levels=5)
|
||||
# a -> b at level 0; b's only parent is a, already seen -> stop.
|
||||
assert len(levels) == 1
|
||||
assert levels[0][0]["id"] == "b"
|
||||
|
||||
|
||||
def test_trace_ancestors_no_parents_returns_empty():
|
||||
from multi_swarm.dashboard.aquarium import trace_ancestors
|
||||
|
||||
idx = {
|
||||
"solo": {
|
||||
"id": "solo",
|
||||
"parent_ids": [],
|
||||
"fitness": 0.4,
|
||||
"generation": 0,
|
||||
"cognitive_style": "engineer",
|
||||
},
|
||||
}
|
||||
assert trace_ancestors("solo", idx) == []
|
||||
|
||||
|
||||
def test_build_fish_dataset_attaches_ancestors():
|
||||
from multi_swarm.dashboard.aquarium import build_fish_dataset, build_lineage_index
|
||||
|
||||
genomes = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"id": "p",
|
||||
"generation_idx": 0,
|
||||
"generation": 0,
|
||||
"system_prompt": "p",
|
||||
"feature_access": ["close"],
|
||||
"temperature": 0.8,
|
||||
"top_p": 0.9,
|
||||
"model_tier": "C",
|
||||
"lookback_window": 100,
|
||||
"cognitive_style": "physicist",
|
||||
"parent_ids": [],
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"generation_idx": 1,
|
||||
"generation": 1,
|
||||
"system_prompt": "c",
|
||||
"feature_access": ["close"],
|
||||
"temperature": 0.8,
|
||||
"top_p": 0.9,
|
||||
"model_tier": "C",
|
||||
"lookback_window": 120,
|
||||
"cognitive_style": "biologist",
|
||||
"parent_ids": ["p"],
|
||||
},
|
||||
]
|
||||
)
|
||||
evals = pd.DataFrame(
|
||||
[
|
||||
{"genome_id": "p", "fitness": 0.3, "dsr": 0.0, "sharpe": 0.0,
|
||||
"max_dd": 0.0, "n_trades": 0},
|
||||
{"genome_id": "c", "fitness": 0.6, "dsr": 0.0, "sharpe": 0.0,
|
||||
"max_dd": 0.0, "n_trades": 0},
|
||||
]
|
||||
)
|
||||
lineage = build_lineage_index(genomes, evals)
|
||||
|
||||
active = genomes[genomes["generation_idx"] == 1].merge(
|
||||
evals, left_on="id", right_on="genome_id", how="left"
|
||||
)
|
||||
fish = build_fish_dataset(active, lineage)
|
||||
assert len(fish) == 1
|
||||
assert fish[0]["id"] == "c"
|
||||
assert len(fish[0]["ancestors"]) == 1
|
||||
assert fish[0]["ancestors"][0][0]["id"] == "p"
|
||||
@@ -1,8 +1,16 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm.agents.adversarial import AdversarialAgent, AdversarialReport, Severity
|
||||
from multi_swarm.agents.adversarial import (
|
||||
AdversarialAgent,
|
||||
AdversarialReport,
|
||||
Severity,
|
||||
)
|
||||
from multi_swarm.backtest.engine import BacktestResult
|
||||
from multi_swarm.backtest.orders import Side, Trade
|
||||
from multi_swarm.protocol.parser import parse_strategy
|
||||
|
||||
|
||||
@@ -23,7 +31,22 @@ def ohlcv() -> pd.DataFrame:
|
||||
|
||||
|
||||
def test_degenerate_always_long_flagged(ohlcv: pd.DataFrame) -> None:
|
||||
src = "(strategy (when (gt (feature close) -1e9) (entry-long)))"
|
||||
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)
|
||||
@@ -32,10 +55,31 @@ def test_degenerate_always_long_flagged(ohlcv: pd.DataFrame) -> None:
|
||||
|
||||
|
||||
def test_no_findings_on_reasonable_strategy(ohlcv: pd.DataFrame) -> None:
|
||||
src = (
|
||||
"(strategy "
|
||||
"(when (gt (indicator rsi 14) 70.0) (entry-short)) "
|
||||
"(when (lt (indicator rsi 14) 30.0) (entry-long)))"
|
||||
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()
|
||||
@@ -45,8 +89,389 @@ def test_no_findings_on_reasonable_strategy(ohlcv: pd.DataFrame) -> None:
|
||||
|
||||
|
||||
def test_zero_trade_strategy_flagged(ohlcv: pd.DataFrame) -> None:
|
||||
src = "(strategy (when (gt (feature close) 1e9) (entry-long)))"
|
||||
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.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 == "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.agents.adversarial.BacktestEngine.run", fake_run)
|
||||
monkeypatch.setattr("multi_swarm.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.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 == "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.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 == "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.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 == "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.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
|
||||
|
||||
@@ -73,9 +73,9 @@ def test_settings_llm_model_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
assert s.llm_model_tier_s == "anthropic/claude-opus-4-7"
|
||||
assert s.llm_model_tier_a == "anthropic/claude-sonnet-4-6"
|
||||
assert s.llm_model_tier_b == "anthropic/claude-sonnet-4-6"
|
||||
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 == "meta-llama/llama-3.3-70b-instruct"
|
||||
assert s.llm_model_tier_d == "openai/gpt-oss-20b"
|
||||
assert s.openrouter_base_url == "https://openrouter.ai/api/v1"
|
||||
|
||||
@@ -9,7 +9,7 @@ def test_estimate_cost_tier_c():
|
||||
|
||||
def test_estimate_cost_tier_b():
|
||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.B)
|
||||
assert cost == 3.00 + 15.00
|
||||
assert cost == 0.14 + 0.28
|
||||
|
||||
|
||||
def test_tracker_accumulates():
|
||||
@@ -34,17 +34,17 @@ def test_tracker_per_tier_breakdown():
|
||||
|
||||
def test_estimate_cost_tier_s():
|
||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.S)
|
||||
assert cost == 15.00 + 75.00
|
||||
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 == 3.00 + 15.00
|
||||
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.10 + 0.30
|
||||
assert cost == 0.03 + 0.14
|
||||
|
||||
|
||||
def test_tracker_summary_contains_all_five_tiers():
|
||||
@@ -61,3 +61,28 @@ def test_tracker_summary_contains_all_five_tiers():
|
||||
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.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
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
@@ -23,10 +25,31 @@ def trending_ohlcv() -> pd.DataFrame:
|
||||
|
||||
|
||||
def test_falsification_returns_report(trending_ohlcv: pd.DataFrame) -> None:
|
||||
src = (
|
||||
"(strategy "
|
||||
"(when (gt (indicator rsi 14) 70.0) (entry-short)) "
|
||||
"(when (lt (indicator rsi 14) 30.0) (entry-long)))"
|
||||
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)
|
||||
@@ -40,7 +63,22 @@ def test_falsification_returns_report(trending_ohlcv: pd.DataFrame) -> None:
|
||||
|
||||
|
||||
def test_falsification_zero_trades_returns_zero_metrics(trending_ohlcv: pd.DataFrame) -> None:
|
||||
src = "(strategy (when (gt (feature close) 1e9) (entry-long)))"
|
||||
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)
|
||||
|
||||
+111
-2
@@ -1,13 +1,18 @@
|
||||
from itertools import pairwise
|
||||
|
||||
from multi_swarm.agents.adversarial import AdversarialReport, Finding, Severity
|
||||
from multi_swarm.agents.falsification import FalsificationReport
|
||||
from multi_swarm.ga.fitness import compute_fitness
|
||||
|
||||
|
||||
def make_falsification(
|
||||
dsr: float = 0.7, max_dd: float = 0.2, n_trades: int = 30
|
||||
dsr: float = 0.7,
|
||||
max_dd: float = 0.2,
|
||||
n_trades: int = 30,
|
||||
sharpe: float = 1.5,
|
||||
) -> FalsificationReport:
|
||||
return FalsificationReport(
|
||||
sharpe=1.5,
|
||||
sharpe=sharpe,
|
||||
dsr=dsr,
|
||||
dsr_pvalue=0.05,
|
||||
max_drawdown=max_dd,
|
||||
@@ -43,3 +48,107 @@ def test_fitness_zeroed_by_high_severity_finding() -> None:
|
||||
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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
|
||||
from multi_swarm.agents.hypothesis import HypothesisAgent, MarketSummary
|
||||
from multi_swarm.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm.llm.client import CompletionResult
|
||||
from multi_swarm.llm.client import CompletionResult, EmptyCompletionError
|
||||
|
||||
|
||||
def make_summary() -> MarketSummary:
|
||||
@@ -16,16 +18,26 @@ def make_summary() -> MarketSummary:
|
||||
)
|
||||
|
||||
|
||||
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="(strategy (when (gt (indicator rsi 14) 70.0) (entry-short)))",
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
g = HypothesisAgentGenome(
|
||||
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,
|
||||
@@ -34,60 +46,205 @@ def test_hypothesis_agent_calls_llm_and_parses(mocker): # type: ignore[no-untyp
|
||||
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(g, make_summary())
|
||||
proposal = agent.propose(make_genome(), make_summary())
|
||||
assert proposal.strategy is not None
|
||||
assert proposal.raw_text.startswith("(strategy")
|
||||
assert proposal.completion.input_tokens == 200
|
||||
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 s-expression",
|
||||
text="this is not JSON",
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
g = HypothesisAgentGenome(
|
||||
system_prompt="x",
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
agent = HypothesisAgent(llm=fake_llm)
|
||||
proposal = agent.propose(g, make_summary())
|
||||
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_sexp_from_markdown_fence(mocker): # type: ignore[no-untyped-def]
|
||||
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=(
|
||||
"Ecco la strategia:\n```lisp\n"
|
||||
"(strategy (when (lt (indicator rsi 14) 30.0) (entry-long)))\n"
|
||||
"```\nFatta."
|
||||
),
|
||||
text=fenced,
|
||||
input_tokens=200,
|
||||
output_tokens=80,
|
||||
tier=ModelTier.C,
|
||||
model="qwen",
|
||||
)
|
||||
g = HypothesisAgentGenome(
|
||||
system_prompt="x",
|
||||
feature_access=["close"],
|
||||
temperature=0.9,
|
||||
top_p=0.95,
|
||||
model_tier=ModelTier.C,
|
||||
lookback_window=200,
|
||||
cognitive_style="physicist",
|
||||
)
|
||||
agent = HypothesisAgent(llm=fake_llm)
|
||||
proposal = agent.propose(g, make_summary())
|
||||
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
|
||||
|
||||
@@ -54,8 +54,8 @@ def test_completion_tier_b_uses_openrouter_with_anthropic_model(mocker):
|
||||
assert out.output_tokens == 150
|
||||
assert out.tier == ModelTier.B
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "anthropic/claude-sonnet-4-6"
|
||||
assert out.model == "anthropic/claude-sonnet-4-6"
|
||||
assert call_kwargs["model"] == "deepseek/deepseek-v4-flash"
|
||||
assert out.model == "deepseek/deepseek-v4-flash"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@@ -75,7 +75,7 @@ def test_completion_retries_on_connection_error(mocker):
|
||||
with pytest.raises(openai.APIConnectionError):
|
||||
client.complete(g, system="sys", user="usr")
|
||||
|
||||
assert fake_openai.chat.completions.create.call_count == 3
|
||||
assert fake_openai.chat.completions.create.call_count == 5
|
||||
|
||||
|
||||
def test_completion_uses_custom_model_tier_c(mocker):
|
||||
@@ -138,9 +138,9 @@ def test_completion_tier_s_uses_openrouter_with_anthropic_model(mocker):
|
||||
|
||||
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 call_kwargs["model"] == "google/gemini-3-flash-preview"
|
||||
assert out.tier == ModelTier.S
|
||||
assert out.model == "anthropic/claude-opus-4-7"
|
||||
assert out.model == "google/gemini-3-flash-preview"
|
||||
|
||||
|
||||
def test_completion_tier_a_uses_openrouter_with_anthropic_model(mocker):
|
||||
@@ -157,9 +157,9 @@ def test_completion_tier_a_uses_openrouter_with_anthropic_model(mocker):
|
||||
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "anthropic/claude-sonnet-4-6"
|
||||
assert call_kwargs["model"] == "deepseek/deepseek-v4-flash"
|
||||
assert out.tier == ModelTier.A
|
||||
assert out.model == "anthropic/claude-sonnet-4-6"
|
||||
assert out.model == "deepseek/deepseek-v4-flash"
|
||||
|
||||
|
||||
def test_completion_tier_d_uses_openrouter_with_llama(mocker):
|
||||
@@ -178,9 +178,9 @@ def test_completion_tier_d_uses_openrouter_with_llama(mocker):
|
||||
|
||||
fake_openai.chat.completions.create.assert_called_once()
|
||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
||||
assert call_kwargs["model"] == "meta-llama/llama-3.3-70b-instruct"
|
||||
assert call_kwargs["model"] == "openai/gpt-oss-20b"
|
||||
assert out.tier == ModelTier.D
|
||||
assert out.model == "meta-llama/llama-3.3-70b-instruct"
|
||||
assert out.model == "openai/gpt-oss-20b"
|
||||
|
||||
|
||||
def test_completion_uses_custom_model_tier_s(mocker):
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from multi_swarm.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm.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.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||
from multi_swarm.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]}"
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
@@ -26,7 +28,22 @@ def ohlcv() -> pd.DataFrame:
|
||||
|
||||
|
||||
def test_compile_simple_long(ohlcv: pd.DataFrame) -> None:
|
||||
src = "(strategy (when (lt (indicator rsi 14) 100.0) (entry-long)))"
|
||||
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)
|
||||
@@ -35,7 +52,22 @@ def test_compile_simple_long(ohlcv: pd.DataFrame) -> None:
|
||||
|
||||
|
||||
def test_compile_no_match_is_flat(ohlcv: pd.DataFrame) -> None:
|
||||
src = "(strategy (when (gt (indicator rsi 14) 1000.0) (entry-long)))"
|
||||
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)
|
||||
@@ -43,13 +75,181 @@ def test_compile_no_match_is_flat(ohlcv: pd.DataFrame) -> None:
|
||||
|
||||
|
||||
def test_compile_two_rules_priority(ohlcv: pd.DataFrame) -> None:
|
||||
src = """
|
||||
(strategy
|
||||
(when (gt (feature close) 110.0) (entry-long))
|
||||
(when (lt (feature close) 105.0) (entry-short)))
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -1,47 +1,198 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm.protocol.grammar import VERBS
|
||||
from multi_swarm.protocol.parser import ParseError, parse_strategy
|
||||
from multi_swarm.protocol.grammar import (
|
||||
ACTION_VALUES,
|
||||
ALL_OPS,
|
||||
COMPARATOR_OPS,
|
||||
CROSSOVER_OPS,
|
||||
KIND_VALUES,
|
||||
LOGICAL_OPS,
|
||||
)
|
||||
from multi_swarm.protocol.parser import (
|
||||
FeatureNode,
|
||||
IndicatorNode,
|
||||
LiteralNode,
|
||||
OpNode,
|
||||
ParseError,
|
||||
parse_strategy,
|
||||
)
|
||||
|
||||
|
||||
def test_grammar_has_15_verbs():
|
||||
assert len(VERBS) == 15
|
||||
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():
|
||||
src = "(strategy (when (gt (indicator rsi 14) 70.0) (entry-short)))"
|
||||
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 ast.kind == "strategy"
|
||||
assert len(ast.rules) == 1
|
||||
rule = ast.rules[0]
|
||||
assert rule.kind == "when"
|
||||
assert rule.condition.kind == "gt"
|
||||
assert rule.action.kind == "entry-short"
|
||||
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():
|
||||
src = """
|
||||
(strategy
|
||||
(when (gt (indicator rsi 14) 70.0) (entry-short))
|
||||
(when (lt (indicator rsi 14) 30.0) (entry-long)))
|
||||
"""
|
||||
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_unknown_verb_raises():
|
||||
src = "(strategy (when (frobnicate 1 2) (entry-long)))"
|
||||
with pytest.raises(ParseError):
|
||||
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_malformed_raises():
|
||||
src = "(strategy (when"
|
||||
with pytest.raises(ParseError):
|
||||
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_empty_strategy_raises():
|
||||
src = "(strategy)"
|
||||
with pytest.raises(ParseError):
|
||||
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)
|
||||
|
||||
@@ -1,38 +1,183 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_swarm.protocol.parser import parse_strategy
|
||||
from multi_swarm.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 = "(strategy (when (gt (indicator rsi 14) 70.0) (entry-short)))"
|
||||
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 = "(strategy (when (gt (indicator wibble 14) 70.0) (entry-short)))"
|
||||
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_wrong_arity_fails() -> None:
|
||||
src = "(strategy (when (gt (indicator rsi) 70.0) (entry-short)))"
|
||||
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):
|
||||
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 = "(strategy (when (gt 1.0) (entry-long)))"
|
||||
src = _wrap({"op": "gt", "args": [{"kind": "literal", "value": 1.0}]})
|
||||
ast = parse_strategy(src)
|
||||
with pytest.raises(ValidationError):
|
||||
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 = "(strategy (when (gt (feature wibble) 100.0) (entry-long)))"
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user