Compare commits
46 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 |
@@ -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
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|
||||||
# Models per tier (override Phase 1 defaults if needed)
|
# Models per tier (override Phase 1 defaults if needed)
|
||||||
LLM_MODEL_TIER_S=anthropic/claude-opus-4-7
|
LLM_MODEL_TIER_S=google/gemini-3-flash-preview
|
||||||
LLM_MODEL_TIER_A=anthropic/claude-sonnet-4-6
|
LLM_MODEL_TIER_A=deepseek/deepseek-v4-flash
|
||||||
LLM_MODEL_TIER_B=anthropic/claude-sonnet-4-6
|
LLM_MODEL_TIER_B=deepseek/deepseek-v4-flash
|
||||||
LLM_MODEL_TIER_C=qwen/qwen-2.5-72b-instruct
|
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 config
|
||||||
RUN_NAME=phase1-spike-001
|
RUN_NAME=phase1-spike-001
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
SERIES_DIR=./series
|
SERIES_DIR=./series
|
||||||
DB_PATH=./runs.db
|
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
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Env / secrets
|
# Env / secrets
|
||||||
.env
|
.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,6 +1,6 @@
|
|||||||
# Multi_Swarm_Coevolutive
|
# Multi_Swarm_Coevolutive
|
||||||
|
|
||||||
Proof-of-concept di sistema co-evolutivo multi-agente per trading quantitativo. Un genetic algorithm fa evolvere una popolazione di agenti LLM (Hypothesis swarm) che generano strategie di trading espresse in JSON strutturato; un layer Falsification deterministico le backtesta su dati storici BTC-PERPETUAL via Cerbero MCP; un layer Adversarial euristico le sottopone a red-team checks; la fitness combina Deflated Sharpe Ratio (Bailey & López 2014), Sharpe normalizzato e penalizzazione di drawdown. Il tutto è ispirato alla filosofia di Renaissance Technologies adattata a un contesto retail single-author con LLM agents.
|
Proof-of-concept di sistema co-evolutivo multi-agente per trading quantitativo. Un genetic algorithm fa evolvere una popolazione di agenti LLM (Hypothesis swarm) che generano strategie di trading espresse in JSON strutturato; un layer Falsification deterministico le backtesta su dati storici (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
|
## Repository
|
||||||
|
|
||||||
@@ -12,22 +12,22 @@ git clone ssh://git@git.tielogic.xyz:222/Adriano/Multi_Swarm_Coevolutive.git
|
|||||||
|
|
||||||
## Stato del progetto
|
## Stato del progetto
|
||||||
|
|
||||||
**Phase 1 (lean spike) completata** il 10 maggio 2026 con tutti i 5 hard gate passati (loop convergence, parse success 100%, top-5 ratio 1116x, entropy 0.914, costo $0.069 vs cap $700). Decisione strategica: **GO Phase 2** con tre aggiustamenti (Adversarial soglie più strette, speciation, walk-forward 70/30).
|
**Phase 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:
|
||||||
|
|
||||||
**Phase 1.5 (tactical hardening) in corso**: Adversarial layer rinforzato con soglie più strette (`overtrading` a `n_bars/20`, `undertrading` HIGH se `n<10`) e due nuovi check HIGH (`flat_too_long` se signal flat >95% bar, `fees_eat_alpha` se fees > 50% del gross PnL). Killa le strategie degeneri del run v5 (top-1 era flat 99.8% del tempo e ha sottoperformato BTC B&H di −103 punti percentuali).
|
- `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.
|
||||||
|
|
||||||
Documenti chiave:
|
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.
|
- [Decisione strategica](docs/superpowers/specs/2026-05-09-decisione-strategica-design.md) — perché Phase 1 prima, Phase 2 poi, Phase 3 forward-test.
|
||||||
- [Piano implementativo Phase 1](docs/superpowers/plans/2026-05-09-phase1-lean-spike.md) — 38 task TDD-driven.
|
- [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).
|
||||||
- [Decision memo gate Phase 1](docs/decisions/2026-05-10-gate-phase1.md) — valutazione formale dei 5 hard gate.
|
- [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).
|
||||||
- [Technical report Phase 1](docs/reports/2026-05-10-phase1-technical-report.md) — risultati, ispezione top genomi, threats to validity.
|
|
||||||
|
|
||||||
Documenti di contesto pre-implementazione:
|
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).
|
||||||
|
|
||||||
- `00_documento_zero.md` — framework concettuale (Renaissance → swarm co-evolutivo LLM).
|
|
||||||
- `coevolutive_swarm_system.md` — design Filone A (sistema completo, 12-18 mesi).
|
|
||||||
- `poc_trading_swarm.md` — design Filone B (PoC trading, fonte di Phase 1).
|
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
@@ -36,63 +36,73 @@ src/multi_swarm/
|
|||||||
├── config.py Settings Pydantic (.env)
|
├── config.py Settings Pydantic (.env)
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── cerbero_ohlcv.py OHLCV loader via Cerbero MCP + cache parquet
|
│ ├── cerbero_ohlcv.py OHLCV loader via Cerbero MCP + cache parquet
|
||||||
│ └── splits.py Walk-forward expanding splits
|
│ └── splits.py Walk-forward expanding splits (Phase 2.6)
|
||||||
├── backtest/
|
├── backtest/
|
||||||
│ ├── orders.py Side/Order/Position/Trade
|
│ ├── orders.py Side/Order/Position/Trade
|
||||||
│ └── engine.py Event-driven backtest, 1-bar exec delay
|
│ └── engine.py Event-driven backtest, 1-bar exec delay
|
||||||
├── metrics/
|
├── metrics/
|
||||||
│ ├── basic.py Sharpe, max drawdown, total return
|
│ ├── basic.py Sharpe, max drawdown, total return
|
||||||
│ └── dsr.py Deflated Sharpe Ratio (Bailey & López 2014)
|
│ ├── dsr.py Deflated Sharpe Ratio (Bailey & López 2014)
|
||||||
|
│ └── diversity.py Entropy/diversity metrics popolazione (Phase 2.5)
|
||||||
├── cerbero/
|
├── cerbero/
|
||||||
│ ├── client.py HTTP client (bearer + bot-tag + retry tenacity)
|
│ ├── client.py HTTP client (bearer + bot-tag + retry tenacity)
|
||||||
│ └── tools.py Wrapper tool MCP (sma/rsi/atr/macd/realized_vol/funding)
|
│ └── tools.py Wrapper tool MCP (sma/rsi/atr/macd/realized_vol/funding)
|
||||||
├── protocol/
|
├── protocol/
|
||||||
│ ├── grammar.py Vocabolario operatori, indicatori, feature
|
│ ├── grammar.py Vocabolario operatori, indicatori, feature (incl. hour/dow/is_weekend)
|
||||||
│ ├── parser.py json.loads → AST dataclass tipizzato
|
│ ├── parser.py json.loads → AST dataclass tipizzato
|
||||||
│ ├── validator.py Arity checks, no-nesting indicators, whitelist
|
│ ├── validator.py Arity checks, no-nesting indicators, whitelist
|
||||||
│ └── compiler.py AST → Callable[[df], Series[Side]]
|
│ └── compiler.py AST → Callable[[df], Series[Side]]
|
||||||
├── genome/
|
├── genome/
|
||||||
│ ├── hypothesis.py HypothesisAgentGenome (id deterministico)
|
│ ├── hypothesis.py HypothesisAgentGenome (id deterministico)
|
||||||
│ ├── mutation.py 4 operatori (temp, lookback, features, style)
|
│ ├── 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
|
│ └── crossover.py Uniform crossover
|
||||||
├── llm/
|
├── llm/
|
||||||
│ ├── client.py Unified LLMClient via OpenRouter (tier S/A/B/C/D)
|
│ ├── client.py Unified LLMClient via OpenRouter (tier S/A/B/C/D)
|
||||||
│ └── cost_tracker.py Pricing per tier, breakdown
|
│ └── cost_tracker.py Pricing per tier, breakdown + call_kind tracking
|
||||||
├── agents/
|
├── agents/
|
||||||
│ ├── hypothesis.py LLM call + JSON extract + retry-with-feedback
|
│ ├── hypothesis.py LLM call + JSON extract + retry-with-feedback
|
||||||
│ ├── falsification.py Compile → backtest → DSR
|
│ ├── falsification.py Compile → backtest → DSR
|
||||||
│ ├── adversarial.py Red-team heuristics (no_trades/degenerate/over/under)
|
│ ├── adversarial.py Red-team heuristics (5 check HIGH parametrici via CLI)
|
||||||
│ └── market_summary.py Stats di mercato per il prompt
|
│ └── market_summary.py Stats di mercato per il prompt
|
||||||
├── ga/
|
├── ga/
|
||||||
│ ├── selection.py Tournament + elitism
|
│ ├── selection.py Tournament + elitism
|
||||||
│ ├── fitness.py v1 continua: dsr + tanh(sharpe) × penalty(dd)
|
│ ├── fitness.py v1 continua + v2 soft-kill + combined IS/OOS opt-in
|
||||||
│ ├── loop.py next_generation step
|
│ ├── loop.py next_generation step
|
||||||
│ ├── summary.py median/max/p90/entropy per gen
|
│ ├── summary.py median/max/p90/entropy per gen
|
||||||
│ └── initial.py Popolazione iniziale (6 cognitive style)
|
│ └── initial.py Popolazione iniziale (6 cognitive style)
|
||||||
├── persistence/
|
├── persistence/
|
||||||
│ ├── schema.py SQLite DDL: 6 tabelle + 3 indici
|
│ ├── schema.py SQLite DDL: 6 tabelle GA + 5 tabelle paper_trading_*
|
||||||
│ └── repository.py CRUD per runs/genomes/evals/cost/findings/gen_summary
|
│ └── 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/
|
├── orchestrator/
|
||||||
│ └── run.py End-to-end pipeline + persistence
|
│ └── run.py End-to-end pipeline GA + persistence
|
||||||
└── dashboard/
|
└── dashboard/
|
||||||
├── streamlit_app.py Hub multipage
|
├── nicegui_app.py NiceGUI dashboard (overview / convergence / genomes)
|
||||||
├── data.py Lettura runs.db per le pagine
|
└── data.py Lettura runs.db per le pagine
|
||||||
├── aquarium.py Helper canvas HTML5 (fish data + JS template)
|
|
||||||
└── pages/
|
|
||||||
├── 01_overview.py Run + metriche aggregate
|
|
||||||
├── 02_ga_convergence.py Fitness convergence + entropy plot
|
|
||||||
├── 03_genomes.py Top-10 + ispezione system_prompt
|
|
||||||
└── 04_aquarium.py Acquario 2D con click → info + lineage
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Stack: Python 3.13, uv, pytest+pytest-mock+responses, openai SDK (verso OpenRouter), requests+tenacity, pandas+numpy+scipy, sqlmodel+sqlite, streamlit+plotly.
|
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
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
cp .env.example .env # compilare CERBERO_*_TOKEN e OPENROUTER_API_KEY
|
cp .env.example .env # compilare CERBERO_*_TOKEN e OPENROUTER_API_KEY
|
||||||
uv run pytest # verifica che tutto installi (141 test attesi)
|
uv run pytest # ~180 test attesi (unit + integration)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Variabili .env richieste
|
### Variabili .env richieste
|
||||||
@@ -108,17 +118,17 @@ CERBERO_BOT_TAG=swarm-poc-phase1
|
|||||||
OPENROUTER_API_KEY=<sk-or-v1-...>
|
OPENROUTER_API_KEY=<sk-or-v1-...>
|
||||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|
||||||
# Modelli per tier (override dei default se serve)
|
# Modelli per tier (default Phase 2.5+: qwen-2.5-72b per tier C, vedi .env.example per gli altri)
|
||||||
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_C=qwen/qwen-2.5-72b-instruct
|
LLM_MODEL_TIER_C=qwen/qwen-2.5-72b-instruct
|
||||||
LLM_MODEL_TIER_D=meta-llama/llama-3.3-70b-instruct
|
|
||||||
|
# Deploy Docker (vedi sezione Deploy)
|
||||||
|
DOMAIN_NAME=tielogic.xyz
|
||||||
|
SWARM_DASHBOARD_PORT=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cerbero MCP
|
### Cerbero MCP
|
||||||
|
|
||||||
Phase 1 fetcha OHLCV via Cerbero MCP (sostituisce ccxt). Avviare Cerbero locale prima di un run reale:
|
Tutti i fetch OHLCV passano da Cerbero MCP (sostituisce ccxt). In sviluppo locale:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/adriano/Documenti/Git_XYZ/CerberoSuite/Cerbero_mcp
|
cd /home/adriano/Documenti/Git_XYZ/CerberoSuite/Cerbero_mcp
|
||||||
@@ -126,50 +136,91 @@ uv sync
|
|||||||
uv run cerbero-mcp # ascolta su porta da .env (default 9001 se 9000 è occupato)
|
uv run cerbero-mcp # ascolta su porta da .env (default 9001 se 9000 è occupato)
|
||||||
```
|
```
|
||||||
|
|
||||||
In alternativa usare il VPS esistente `https://cerbero-mcp.tielogic.xyz` (richiede bearer).
|
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
|
## Comandi principali
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Quality gates
|
# Quality gates
|
||||||
uv run pytest # tutti i test (141 PASSED attesi)
|
uv run pytest # tutti i test
|
||||||
uv run pytest tests/unit -v # solo unit
|
uv run pytest tests/unit -v # solo unit
|
||||||
uv run pytest tests/integration -v # solo integration
|
uv run pytest tests/integration -v # solo integration (richiedono Cerbero + OpenRouter)
|
||||||
uv run ruff check src/ tests/ scripts/
|
uv run ruff check src/ tests/ scripts/
|
||||||
uv run mypy src/ scripts/
|
uv run mypy src/ scripts/
|
||||||
|
|
||||||
# Smoke run (MockLLM + OHLCV sintetico, no API calls)
|
# Smoke run (MockLLM + OHLCV sintetico, no API calls)
|
||||||
uv run python scripts/smoke_run.py
|
uv run python scripts/smoke_run.py
|
||||||
|
|
||||||
# Run reale Phase 1 (Cerbero + OpenRouter, ~$0.07 per run K=20 10gen)
|
# Run reale Phase 1/2 (Cerbero + OpenRouter, ~$0.07 per run K=20 10gen)
|
||||||
uv run python scripts/run_phase1.py \
|
uv run python scripts/run_phase1.py \
|
||||||
--name phase1-run-XXX \
|
--name run-XXX \
|
||||||
--exchange deribit --symbol BTC-PERPETUAL --timeframe 1h \
|
--exchange deribit --symbol BTC-PERPETUAL --timeframe 1h \
|
||||||
--start 2024-01-01T00:00:00+00:00 \
|
--start 2024-01-01T00:00:00+00:00 \
|
||||||
--end 2026-01-01T00:00:00+00:00 \
|
--end 2026-01-01T00:00:00+00:00 \
|
||||||
--population-size 20 --n-generations 10
|
--population-size 20 --n-generations 10 \
|
||||||
|
--prompt-mutation-weight 0.30 --fitness-v2
|
||||||
|
|
||||||
# Dashboard
|
# Backtest standalone di una strategia JSON su range esteso
|
||||||
DB_PATH=./runs.db uv run streamlit run src/multi_swarm/dashboard/streamlit_app.py
|
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
|
## Dashboard
|
||||||
|
|
||||||
Streamlit multipage su `http://localhost:8501` (override con `--server.port`):
|
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).
|
- **Overview** (`/`): lista runs, status, costo, metriche aggregate evaluations (parse success %, top fitness, median).
|
||||||
- **GA Convergence**: fitness median/max/p90 per generazione, entropy con hline a soglia gate (0.5).
|
- **GA Convergence** (`/convergence`): fitness median/max/p90 per generazione, entropy con hline a soglia gate (0.5).
|
||||||
- **Genomes**: top-10 ordinati per fitness, click su row per ispezione system_prompt + raw_text JSON strategy.
|
- **Genomes** (`/genomes`): top-K ordinati per fitness, click su riga per ispezione system_prompt + raw_text JSON strategy.
|
||||||
- **Aquarium**: visualizzazione 2D canvas HTML5 con un pesce per agente; dimensione ∝ fitness, colore per cognitive_style, halo sui top-3, click su pesce → panel info completo + lineage BFS (parents → grandparents → ...).
|
|
||||||
|
|
||||||
## Costi tipici Phase 1
|
In produzione gira dentro Docker dietro Traefik su `https://swarm.${DOMAIN_NAME}` — vedi sezione Deploy.
|
||||||
|
|
||||||
Tier C (qwen-2.5-72b via OpenRouter): ~$0.40/1M token. Run K=20 × 10gen ≈ $0.07. Phase 1 totale (5 run incluse iterazioni bug-fix): $0.19.
|
## Deploy
|
||||||
|
|
||||||
Per Phase 2 con tier mix B/C (Sonnet 4.6 = $3/$15 input/output) stima: $3-15 per ablation completa.
|
`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
|
## 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.
|
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`. Nessun feature branch in Phase 1 (single author, lean spike). Phase 2 valuterà feature branch per ablation paralleli.
|
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,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
-1
@@ -16,9 +16,10 @@ dependencies = [
|
|||||||
"requests>=2.32",
|
"requests>=2.32",
|
||||||
"tenacity>=9.0",
|
"tenacity>=9.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"streamlit>=1.40",
|
|
||||||
"plotly>=5.24",
|
"plotly>=5.24",
|
||||||
"pyarrow>=18.0",
|
"pyarrow>=18.0",
|
||||||
|
"nicegui>=3.11.1",
|
||||||
|
"yfinance>=1.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[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("--end", default="2026-01-01T00:00:00+00:00")
|
||||||
p.add_argument("--fees-bp", type=float, default=5.0)
|
p.add_argument("--fees-bp", type=float, default=5.0)
|
||||||
p.add_argument("--n-trials-dsr", type=int, default=50)
|
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()
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +149,18 @@ def main() -> None:
|
|||||||
fees_bp=args.fees_bp,
|
fees_bp=args.fees_bp,
|
||||||
n_trials_dsr=args.n_trials_dsr,
|
n_trials_dsr=args.n_trials_dsr,
|
||||||
db_path=settings.db_path,
|
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)
|
run_id = run_phase1(cfg, ohlcv=ohlcv, llm=llm)
|
||||||
|
|||||||
@@ -59,8 +59,17 @@ class AdversarialReport:
|
|||||||
class AdversarialAgent:
|
class AdversarialAgent:
|
||||||
"""Agente hand-crafted che applica check euristici a una strategia."""
|
"""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._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:
|
def review(self, strategy: Strategy, ohlcv: pd.DataFrame) -> AdversarialReport:
|
||||||
signal_fn = compile_strategy(strategy)
|
signal_fn = compile_strategy(strategy)
|
||||||
@@ -111,12 +120,15 @@ class AdversarialAgent:
|
|||||||
# Undertrading: < 10 trade -> HIGH (Phase 1.5: era < 5 MEDIUM).
|
# Undertrading: < 10 trade -> HIGH (Phase 1.5: era < 5 MEDIUM).
|
||||||
# Sample size troppo piccolo per distinguere edge da rumore: e'
|
# Sample size troppo piccolo per distinguere edge da rumore: e'
|
||||||
# un "lucky shot" non riproducibile out-of-sample.
|
# un "lucky shot" non riproducibile out-of-sample.
|
||||||
if n_trades < 10:
|
if n_trades < self._undertrading_threshold:
|
||||||
report.findings.append(
|
report.findings.append(
|
||||||
Finding(
|
Finding(
|
||||||
name="undertrading",
|
name="undertrading",
|
||||||
severity=Severity.HIGH,
|
severity=Severity.HIGH,
|
||||||
detail=f"only {n_trades} trades — likely lucky shot (<10 over training)",
|
detail=(
|
||||||
|
f"only {n_trades} trades — likely lucky shot "
|
||||||
|
f"(<{self._undertrading_threshold} over training)"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,12 +140,15 @@ class AdversarialAgent:
|
|||||||
n_active = int(((signals == Side.LONG) | (signals == Side.SHORT)).sum())
|
n_active = int(((signals == Side.LONG) | (signals == Side.SHORT)).sum())
|
||||||
n_flat_or_nan = n_bars - n_active
|
n_flat_or_nan = n_bars - n_active
|
||||||
flat_ratio = n_flat_or_nan / n_bars if n_bars > 0 else 1.0
|
flat_ratio = n_flat_or_nan / n_bars if n_bars > 0 else 1.0
|
||||||
if flat_ratio > 0.95:
|
if flat_ratio > self._flat_too_long_threshold:
|
||||||
report.findings.append(
|
report.findings.append(
|
||||||
Finding(
|
Finding(
|
||||||
name="flat_too_long",
|
name="flat_too_long",
|
||||||
severity=Severity.HIGH,
|
severity=Severity.HIGH,
|
||||||
detail=f"Signal flat for {flat_ratio * 100:.1f}% of bars (>95% threshold)",
|
detail=(
|
||||||
|
f"Signal flat for {flat_ratio * 100:.1f}% of bars "
|
||||||
|
f"(>{self._flat_too_long_threshold * 100:.0f}% threshold)"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -163,7 +178,7 @@ class AdversarialAgent:
|
|||||||
# Se gross_pnl <= 0 il check non si applica (gia' perdente).
|
# Se gross_pnl <= 0 il check non si applica (gia' perdente).
|
||||||
gross_pnl = sum(t.gross_pnl for t in result.trades)
|
gross_pnl = sum(t.gross_pnl for t in result.trades)
|
||||||
total_fees = sum(t.fees for t in result.trades)
|
total_fees = sum(t.fees for t in result.trades)
|
||||||
if gross_pnl > 0 and total_fees / gross_pnl > 0.5:
|
if gross_pnl > 0 and total_fees / gross_pnl > self._fees_eat_alpha_threshold:
|
||||||
report.findings.append(
|
report.findings.append(
|
||||||
Finding(
|
Finding(
|
||||||
name="fees_eat_alpha",
|
name="fees_eat_alpha",
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import openai
|
||||||
|
|
||||||
from ..genome.hypothesis import HypothesisAgentGenome
|
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.parser import ParseError, Strategy, parse_strategy
|
||||||
from ..protocol.validator import ValidationError, validate_strategy
|
from ..protocol.validator import ValidationError, validate_strategy
|
||||||
|
|
||||||
@@ -84,9 +86,44 @@ Leaf - indicatori (calcolati su close):
|
|||||||
Leaf - feature OHLCV:
|
Leaf - feature OHLCV:
|
||||||
{{"kind": "feature", "name": "open|high|low|close|volume"}}
|
{{"kind": "feature", "name": "open|high|low|close|volume"}}
|
||||||
|
|
||||||
|
Leaf - feature TEMPORALI (sempre accessibili, UTC):
|
||||||
|
{{"kind": "feature", "name": "hour"}} // range 0-23
|
||||||
|
{{"kind": "feature", "name": "dow"}} // range 0-6 (lun=0, dom=6)
|
||||||
|
{{"kind": "feature", "name": "is_weekend"}} // 0 o 1
|
||||||
|
{{"kind": "feature", "name": "minute_of_hour"}} // range 0-59
|
||||||
|
|
||||||
|
Esempi di gating temporale:
|
||||||
|
// Solo durante la sessione US (14:00-22:00 UTC)
|
||||||
|
{{"op": "and", "args": [
|
||||||
|
{{"op": "gt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 14}}]}},
|
||||||
|
{{"op": "lt", "args": [{{"kind": "feature", "name": "hour"}}, {{"kind": "literal", "value": 22}}]}}
|
||||||
|
]}}
|
||||||
|
|
||||||
|
// Solo nel weekend (sab+dom)
|
||||||
|
{{"op": "eq", "args": [{{"kind": "feature", "name": "is_weekend"}}, {{"kind": "literal", "value": 1}}]}}
|
||||||
|
|
||||||
Leaf - letterale numerico:
|
Leaf - letterale numerico:
|
||||||
{{"kind": "literal", "value": 70.0}}
|
{{"kind": "literal", "value": 70.0}}
|
||||||
|
|
||||||
|
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
|
VINCOLI
|
||||||
- Gli indicator NON sono annidabili: 'params' accetta solo numeri, mai altri nodi.
|
- 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.
|
- Le regole sono valutate in ordine; la prima che matcha vince per ogni timestamp.
|
||||||
@@ -258,7 +295,20 @@ class HypothesisAgent:
|
|||||||
previous_error=errors[-1],
|
previous_error=errors[-1],
|
||||||
)
|
)
|
||||||
|
|
||||||
completion = self._llm.complete(genome, system=system, user=user)
|
try:
|
||||||
|
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)
|
completions.append(completion)
|
||||||
last_raw = completion.text
|
last_raw = completion.text
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
openrouter_api_key: SecretStr
|
||||||
|
|
||||||
llm_model_tier_s: str = "anthropic/claude-opus-4-7"
|
llm_model_tier_s: str = "google/gemini-3-flash-preview"
|
||||||
llm_model_tier_a: str = "anthropic/claude-sonnet-4-6"
|
llm_model_tier_a: str = "deepseek/deepseek-v4-flash"
|
||||||
llm_model_tier_b: str = "anthropic/claude-sonnet-4-6"
|
llm_model_tier_b: str = "deepseek/deepseek-v4-flash"
|
||||||
llm_model_tier_c: str = "qwen/qwen-2.5-72b-instruct"
|
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"
|
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
run_name: str = "phase1-spike-001"
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -52,3 +53,121 @@ def genomes_df(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return pd.DataFrame(flat)
|
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"]
|
||||||
|
|||||||
@@ -1,71 +1,108 @@
|
|||||||
"""Fitness function v1 della Phase 1.
|
"""Fitness function della Phase 1/2.
|
||||||
|
|
||||||
Combina :class:`FalsificationReport` (metriche di robustezza) e
|
Combina :class:`FalsificationReport` (metriche di robustezza) e
|
||||||
:class:`AdversarialReport` (findings euristici) in uno scalare ``>= 0`` che il
|
:class:`AdversarialReport` (findings euristici) in uno scalare ``>= 0`` che il
|
||||||
GA usa per selezione e ranking.
|
GA usa per selezione e ranking.
|
||||||
|
|
||||||
Versione v1: rispetto alla v0 (DSR meno penalita' lineare di drawdown, clamp
|
**v1** (default, backward compat): ogni finding ``HIGH`` azzera la fitness.
|
||||||
a zero) la formula e' continua e quasi sempre strettamente positiva, in modo
|
Kill-switch hard a 360 gradi.
|
||||||
da fornire un gradient anche su strategie mediocri o con Sharpe negativo.
|
|
||||||
Restano due kill-switch hard (no-trade, finding HIGH adversarial) che azzerano
|
**v2** (opt-in via ``hard_kill_findings``): solo findings nel set ``hard_kill``
|
||||||
la fitness.
|
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::
|
Formula::
|
||||||
|
|
||||||
sharpe_norm = 0.5 * (tanh(sharpe) + 1.0) # in [0, 1]
|
sharpe_norm = 0.5 * (tanh(sharpe) + 1.0) # in [0, 1]
|
||||||
base = dsr_weight * dsr + sharpe_weight * sharpe_norm
|
base = dsr_weight * dsr + sharpe_weight * sharpe_norm
|
||||||
penalty = 1.0 / (1.0 + drawdown_penalty * max_drawdown)
|
dd_penalty = 1.0 / (1.0 + drawdown_penalty * max_drawdown)
|
||||||
fitness = max(0.0, base * penalty)
|
adv_penalty = 1.0 (v1) o 1/(1+soft*n_soft_high) (v2)
|
||||||
|
fitness = max(0.0, base * dd_penalty * adv_penalty)
|
||||||
Con i default ``dsr_weight = sharpe_weight = 0.5`` la base e' in ``[0, 1]`` e
|
|
||||||
``penalty`` in ``(0, 1]``: fitness e' bounded in ``[0, 1]`` per input sani e
|
|
||||||
mai esattamente zero finche' Sharpe e' finito e ``max_dd`` finito.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from ..agents.adversarial import AdversarialReport, Severity
|
from ..agents.adversarial import AdversarialReport, Severity
|
||||||
from ..agents.falsification import FalsificationReport
|
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(
|
def compute_fitness(
|
||||||
falsification: FalsificationReport,
|
falsification: FalsificationReport,
|
||||||
adversarial: AdversarialReport,
|
adversarial: AdversarialReport,
|
||||||
drawdown_penalty: float = 1.0,
|
drawdown_penalty: float = 1.0,
|
||||||
dsr_weight: float = 0.5,
|
dsr_weight: float = 0.5,
|
||||||
sharpe_weight: float = 0.5,
|
sharpe_weight: float = 0.5,
|
||||||
|
hard_kill_findings: Iterable[str] | None = None,
|
||||||
|
adversarial_soft_penalty: float = 0.4,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Calcola la fitness scalare di una strategia (v1, continua).
|
"""Calcola la fitness scalare di una strategia.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
falsification: report con DSR, Sharpe, max_drawdown, n_trades.
|
falsification: report con DSR, Sharpe, max_drawdown, n_trades.
|
||||||
adversarial: report con eventuali findings euristici.
|
adversarial: report con eventuali findings euristici.
|
||||||
drawdown_penalty: peso del max drawdown nel denominatore della
|
drawdown_penalty: peso del max drawdown nel denominatore della
|
||||||
penalita' moltiplicativa (default 1.0). Valori piu' alti
|
penalita' moltiplicativa (default 1.0).
|
||||||
penalizzano piu' severamente strategie con DD alto.
|
|
||||||
dsr_weight: peso del DSR nella base (default 0.5).
|
dsr_weight: peso del DSR nella base (default 0.5).
|
||||||
sharpe_weight: peso dello Sharpe normalizzato nella base
|
sharpe_weight: peso dello Sharpe normalizzato nella base
|
||||||
(default 0.5).
|
(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:
|
Returns:
|
||||||
Fitness ``>= 0``. Zero indica strategia da scartare (no-trade o
|
Fitness ``>= 0``. Zero indica strategia da scartare (no-trade o
|
||||||
kill adversarial). Valori tipici per strategie sane: ``[0.05, 1.0]``.
|
kill adversarial).
|
||||||
|
|
||||||
Logica:
|
|
||||||
1. ``n_trades == 0`` → 0 (nessuna evidenza, sega subito).
|
|
||||||
2. Almeno un finding ``HIGH`` adversarial → 0 (kill).
|
|
||||||
3. Altrimenti combina DSR e ``tanh(sharpe)`` normalizzato in
|
|
||||||
``[0, 1]``, modulato da una penalita' continua del drawdown
|
|
||||||
``1 / (1 + k * max_dd)``.
|
|
||||||
"""
|
"""
|
||||||
if falsification.n_trades == 0:
|
if falsification.n_trades == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
if any(f.severity == Severity.HIGH for f in adversarial.findings):
|
|
||||||
return 0.0
|
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
|
||||||
|
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)))
|
dsr = max(0.0, min(1.0, float(falsification.dsr)))
|
||||||
sharpe_norm = 0.5 * (math.tanh(float(falsification.sharpe)) + 1.0)
|
sharpe_norm = 0.5 * (math.tanh(float(falsification.sharpe)) + 1.0)
|
||||||
base = dsr_weight * dsr + sharpe_weight * sharpe_norm
|
base = dsr_weight * dsr + sharpe_weight * sharpe_norm
|
||||||
penalty = 1.0 / (1.0 + drawdown_penalty * float(falsification.max_drawdown))
|
dd_penalty = 1.0 / (1.0 + drawdown_penalty * float(falsification.max_drawdown))
|
||||||
return max(0.0, float(base * penalty))
|
return max(0.0, float(base * dd_penalty * adv_penalty))
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from ..genome.crossover import uniform_crossover
|
from ..genome.crossover import uniform_crossover
|
||||||
from ..genome.hypothesis import HypothesisAgentGenome
|
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
|
from .selection import elite_select, tournament_select
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ class GAConfig:
|
|||||||
elite_k: int
|
elite_k: int
|
||||||
tournament_k: int
|
tournament_k: int
|
||||||
p_crossover: float
|
p_crossover: float
|
||||||
|
prompt_mutation_weight: float = 0.0 # Phase 2.5: opt-in LLM mutator
|
||||||
|
|
||||||
|
|
||||||
def next_generation(
|
def next_generation(
|
||||||
@@ -22,8 +24,18 @@ def next_generation(
|
|||||||
fitnesses: dict[str, float],
|
fitnesses: dict[str, float],
|
||||||
cfg: GAConfig,
|
cfg: GAConfig,
|
||||||
rng: random.Random,
|
rng: random.Random,
|
||||||
|
llm: Any | None = None,
|
||||||
|
cost_tracker: Any | None = None,
|
||||||
|
repo: Any | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
) -> list[HypothesisAgentGenome]:
|
) -> 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(
|
new_pop: list[HypothesisAgentGenome] = list(
|
||||||
elite_select(population, fitnesses, cfg.elite_k)
|
elite_select(population, fitnesses, cfg.elite_k)
|
||||||
)
|
)
|
||||||
@@ -35,7 +47,14 @@ def next_generation(
|
|||||||
child = uniform_crossover(p1, p2, rng)
|
child = uniform_crossover(p1, p2, rng)
|
||||||
else:
|
else:
|
||||||
parent = tournament_select(population, fitnesses, cfg.tournament_k, rng)
|
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)
|
new_pop.append(child)
|
||||||
|
|
||||||
return new_pop[: cfg.population_size]
|
return new_pop[: cfg.population_size]
|
||||||
|
|||||||
@@ -75,3 +75,31 @@ MUTATION_OPS = (
|
|||||||
def random_mutate(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome:
|
def random_mutate(g: HypothesisAgentGenome, rng: random.Random) -> HypothesisAgentGenome:
|
||||||
op = rng.choice(MUTATION_OPS)
|
op = rng.choice(MUTATION_OPS)
|
||||||
return op(g, rng)
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import openai
|
import openai
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
@@ -14,22 +15,25 @@ from tenacity import (
|
|||||||
from ..genome.hypothesis import HypothesisAgentGenome, ModelTier
|
from ..genome.hypothesis import HypothesisAgentGenome, ModelTier
|
||||||
|
|
||||||
# Modelli configurati per Phase 1 — tutti via OpenRouter
|
# Modelli configurati per Phase 1 — tutti via OpenRouter
|
||||||
MODEL_TIER_S = "anthropic/claude-opus-4-7"
|
MODEL_TIER_S = "google/gemini-3-flash-preview"
|
||||||
MODEL_TIER_A = "anthropic/claude-sonnet-4-6"
|
MODEL_TIER_A = "deepseek/deepseek-v4-flash"
|
||||||
MODEL_TIER_B = "anthropic/claude-sonnet-4-6"
|
MODEL_TIER_B = "deepseek/deepseek-v4-flash"
|
||||||
MODEL_TIER_C = "qwen/qwen-2.5-72b-instruct"
|
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"
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
class EmptyCompletionError(RuntimeError):
|
class EmptyCompletionError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Errori transient: retry. RateLimit/Auth/InvalidRequest: NO retry.
|
# 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], ...] = (
|
_RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
||||||
openai.APIConnectionError,
|
openai.APIConnectionError,
|
||||||
openai.APITimeoutError,
|
openai.APITimeoutError,
|
||||||
openai.InternalServerError,
|
openai.InternalServerError,
|
||||||
|
openai.RateLimitError,
|
||||||
EmptyCompletionError,
|
EmptyCompletionError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +48,10 @@ class CompletionResult:
|
|||||||
|
|
||||||
|
|
||||||
class LLMClient:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
openrouter_api_key: str,
|
openrouter_api_key: str,
|
||||||
@@ -53,6 +61,7 @@ class LLMClient:
|
|||||||
model_tier_c: str = MODEL_TIER_C,
|
model_tier_c: str = MODEL_TIER_C,
|
||||||
model_tier_d: str = MODEL_TIER_D,
|
model_tier_d: str = MODEL_TIER_D,
|
||||||
openrouter_base_url: str = OPENROUTER_BASE_URL,
|
openrouter_base_url: str = OPENROUTER_BASE_URL,
|
||||||
|
provider_ignore: tuple[str, ...] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.model_tier_s = model_tier_s
|
self.model_tier_s = model_tier_s
|
||||||
self.model_tier_a = model_tier_a
|
self.model_tier_a = model_tier_a
|
||||||
@@ -60,6 +69,9 @@ class LLMClient:
|
|||||||
self.model_tier_c = model_tier_c
|
self.model_tier_c = model_tier_c
|
||||||
self.model_tier_d = model_tier_d
|
self.model_tier_d = model_tier_d
|
||||||
self.openrouter_base_url = openrouter_base_url
|
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] = {
|
self._tier_models: dict[ModelTier, str] = {
|
||||||
ModelTier.S: model_tier_s,
|
ModelTier.S: model_tier_s,
|
||||||
ModelTier.A: model_tier_a,
|
ModelTier.A: model_tier_a,
|
||||||
@@ -67,11 +79,17 @@ class LLMClient:
|
|||||||
ModelTier.C: model_tier_c,
|
ModelTier.C: model_tier_c,
|
||||||
ModelTier.D: model_tier_d,
|
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(
|
@retry(
|
||||||
stop=stop_after_attempt(3),
|
stop=stop_after_attempt(5),
|
||||||
wait=wait_exponential(multiplier=1.0, min=2.0, max=10.0),
|
wait=wait_exponential(multiplier=2.0, min=2.0, max=30.0),
|
||||||
retry=retry_if_exception_type(_RETRYABLE_EXCEPTIONS),
|
retry=retry_if_exception_type(_RETRYABLE_EXCEPTIONS),
|
||||||
reraise=True,
|
reraise=True,
|
||||||
)
|
)
|
||||||
@@ -83,6 +101,9 @@ class LLMClient:
|
|||||||
max_tokens: int = 2000,
|
max_tokens: int = 2000,
|
||||||
) -> CompletionResult:
|
) -> CompletionResult:
|
||||||
model = self._tier_models[genome.model_tier]
|
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(
|
resp = self._client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[
|
messages=[
|
||||||
@@ -92,6 +113,7 @@ class LLMClient:
|
|||||||
temperature=genome.temperature,
|
temperature=genome.temperature,
|
||||||
top_p=genome.top_p,
|
top_p=genome.top_p,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
|
extra_body=extra_body or None,
|
||||||
)
|
)
|
||||||
if not resp.choices or resp.choices[0].message.content is None:
|
if not resp.choices or resp.choices[0].message.content is None:
|
||||||
raise EmptyCompletionError(f"empty response from {model}")
|
raise EmptyCompletionError(f"empty response from {model}")
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from typing import Any
|
|||||||
from ..genome.hypothesis import ModelTier
|
from ..genome.hypothesis import ModelTier
|
||||||
|
|
||||||
PRICE_PER_M_TOKENS: dict[ModelTier, dict[str, float]] = {
|
PRICE_PER_M_TOKENS: dict[ModelTier, dict[str, float]] = {
|
||||||
ModelTier.S: {"input": 15.00, "output": 75.00},
|
ModelTier.S: {"input": 0.50, "output": 3.00},
|
||||||
ModelTier.A: {"input": 3.00, "output": 15.00},
|
ModelTier.A: {"input": 0.14, "output": 0.28},
|
||||||
ModelTier.B: {"input": 3.00, "output": 15.00},
|
ModelTier.B: {"input": 0.14, "output": 0.28},
|
||||||
ModelTier.C: {"input": 0.40, "output": 0.40},
|
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
|
input_tokens: int
|
||||||
output_tokens: int
|
output_tokens: int
|
||||||
cost_usd: float
|
cost_usd: float
|
||||||
|
call_kind: str = "hypothesis" # "hypothesis" | "mutation"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -43,6 +44,7 @@ class CostTracker:
|
|||||||
tier: ModelTier,
|
tier: ModelTier,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
call_kind: str = "hypothesis",
|
||||||
) -> CostRecord:
|
) -> CostRecord:
|
||||||
cost = estimate_cost(input_tokens, output_tokens, tier)
|
cost = estimate_cost(input_tokens, output_tokens, tier)
|
||||||
rec = CostRecord(
|
rec = CostRecord(
|
||||||
@@ -53,6 +55,7 @@ class CostTracker:
|
|||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
output_tokens=output_tokens,
|
output_tokens=output_tokens,
|
||||||
cost_usd=cost,
|
cost_usd=cost,
|
||||||
|
call_kind=call_kind,
|
||||||
)
|
)
|
||||||
self.records.append(rec)
|
self.records.append(rec)
|
||||||
return rec
|
return rec
|
||||||
@@ -61,16 +64,25 @@ class CostTracker:
|
|||||||
by_tier: dict[str, dict[str, float]] = defaultdict(
|
by_tier: dict[str, dict[str, float]] = defaultdict(
|
||||||
lambda: {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
|
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:
|
for r in self.records:
|
||||||
t = r.tier.value
|
t = r.tier.value
|
||||||
by_tier[t]["calls"] += 1
|
by_tier[t]["calls"] += 1
|
||||||
by_tier[t]["input_tokens"] += r.input_tokens
|
by_tier[t]["input_tokens"] += r.input_tokens
|
||||||
by_tier[t]["output_tokens"] += r.output_tokens
|
by_tier[t]["output_tokens"] += r.output_tokens
|
||||||
by_tier[t]["cost_usd"] += r.cost_usd
|
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 {
|
return {
|
||||||
"calls": len(self.records),
|
"calls": len(self.records),
|
||||||
"input_tokens": sum(r.input_tokens for r in self.records),
|
"input_tokens": sum(r.input_tokens for r in self.records),
|
||||||
"output_tokens": sum(r.output_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),
|
"cost_usd": sum(r.cost_usd for r in self.records),
|
||||||
"by_tier": dict(by_tier),
|
"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
|
fees_bp: float = 5.0
|
||||||
n_trials_dsr: int = 50
|
n_trials_dsr: int = 50
|
||||||
db_path: Path = field(default_factory=lambda: Path("./runs.db"))
|
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(
|
def run_phase1(
|
||||||
@@ -71,13 +89,27 @@ def run_phase1(
|
|||||||
}
|
}
|
||||||
run_id = repo.create_run(name=cfg.run_name, config=config_dict)
|
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)
|
hypothesis_agent = HypothesisAgent(llm=llm)
|
||||||
falsification_agent = FalsificationAgent(
|
falsification_agent = FalsificationAgent(
|
||||||
fees_bp=cfg.fees_bp, n_trials_dsr=cfg.n_trials_dsr
|
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()
|
cost_tracker = CostTracker()
|
||||||
|
|
||||||
population = build_initial_population(
|
population = build_initial_population(
|
||||||
@@ -90,6 +122,7 @@ def run_phase1(
|
|||||||
elite_k=cfg.elite_k,
|
elite_k=cfg.elite_k,
|
||||||
tournament_k=cfg.tournament_k,
|
tournament_k=cfg.tournament_k,
|
||||||
p_crossover=cfg.p_crossover,
|
p_crossover=cfg.p_crossover,
|
||||||
|
prompt_mutation_weight=cfg.prompt_mutation_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -134,8 +167,8 @@ def run_phase1(
|
|||||||
fitnesses[genome.id] = 0.0
|
fitnesses[genome.id] = 0.0
|
||||||
continue
|
continue
|
||||||
|
|
||||||
fals = falsification_agent.evaluate(proposal.strategy, ohlcv)
|
fals = falsification_agent.evaluate(proposal.strategy, train_ohlcv)
|
||||||
adv = adversarial_agent.review(proposal.strategy, ohlcv)
|
adv = adversarial_agent.review(proposal.strategy, train_ohlcv)
|
||||||
for finding in adv.findings:
|
for finding in adv.findings:
|
||||||
repo.save_adversarial_finding(
|
repo.save_adversarial_finding(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -144,7 +177,36 @@ def run_phase1(
|
|||||||
severity=finding.severity.value,
|
severity=finding.severity.value,
|
||||||
detail=finding.detail,
|
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(
|
repo.save_evaluation(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
genome_id=genome.id,
|
genome_id=genome.id,
|
||||||
@@ -173,7 +235,48 @@ def run_phase1(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if gen < cfg.n_generations - 1:
|
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(
|
repo.complete_run(
|
||||||
run_id, total_cost=repo.total_cost(run_id), status="completed"
|
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)
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
conn.executescript(SCHEMA_SQL)
|
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
|
@staticmethod
|
||||||
def _now() -> str:
|
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]]:
|
def list_evaluations(self, run_id: str) -> list[dict[str, Any]]:
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -184,12 +227,13 @@ class Repository:
|
|||||||
input_tokens: int,
|
input_tokens: int,
|
||||||
output_tokens: int,
|
output_tokens: int,
|
||||||
cost_usd: float,
|
cost_usd: float,
|
||||||
|
call_kind: str = "hypothesis",
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO cost_records
|
"""INSERT INTO cost_records
|
||||||
(run_id, agent_id, ts, tier, input_tokens, output_tokens, cost_usd)
|
(run_id, agent_id, ts, tier, input_tokens, output_tokens, cost_usd, call_kind)
|
||||||
VALUES (?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
run_id,
|
run_id,
|
||||||
agent_id,
|
agent_id,
|
||||||
@@ -198,6 +242,7 @@ class Repository:
|
|||||||
input_tokens,
|
input_tokens,
|
||||||
output_tokens,
|
output_tokens,
|
||||||
cost_usd,
|
cost_usd,
|
||||||
|
call_kind,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ CREATE TABLE IF NOT EXISTS evaluations (
|
|||||||
parse_error TEXT,
|
parse_error TEXT,
|
||||||
raw_text TEXT,
|
raw_text TEXT,
|
||||||
eval_ts TEXT NOT NULL,
|
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),
|
PRIMARY KEY (run_id, genome_id),
|
||||||
FOREIGN KEY (run_id) REFERENCES runs(id)
|
FOREIGN KEY (run_id) REFERENCES runs(id)
|
||||||
);
|
);
|
||||||
@@ -58,6 +63,7 @@ CREATE TABLE IF NOT EXISTS cost_records (
|
|||||||
input_tokens INTEGER NOT NULL,
|
input_tokens INTEGER NOT NULL,
|
||||||
output_tokens INTEGER NOT NULL,
|
output_tokens INTEGER NOT NULL,
|
||||||
cost_usd REAL NOT NULL,
|
cost_usd REAL NOT NULL,
|
||||||
|
call_kind TEXT NOT NULL DEFAULT 'hypothesis',
|
||||||
FOREIGN KEY (run_id) REFERENCES runs(id)
|
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)
|
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_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_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_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);
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ INDICATOR_FNS: dict[str, Any] = {
|
|||||||
"macd": _ind_macd,
|
"macd": _ind_macd,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = {
|
||||||
|
"hour": lambda idx: pd.Series(idx.hour, index=idx, dtype="int64"),
|
||||||
|
"dow": lambda idx: pd.Series(idx.dayofweek, index=idx, dtype="int64"),
|
||||||
|
"is_weekend": lambda idx: pd.Series((idx.dayofweek >= 5).astype("int64"), index=idx),
|
||||||
|
"minute_of_hour": lambda idx: pd.Series(idx.minute, index=idx, dtype="int64"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _to_series(value: float, df: pd.DataFrame) -> pd.Series:
|
def _to_series(value: float, df: pd.DataFrame) -> pd.Series:
|
||||||
"""Broadcast a numeric literal across the DataFrame index."""
|
"""Broadcast a numeric literal across the DataFrame index."""
|
||||||
@@ -134,6 +141,8 @@ def _eval_bool_arg(node: Node, df: pd.DataFrame) -> pd.Series:
|
|||||||
|
|
||||||
def _eval_node(node: Node, df: pd.DataFrame) -> pd.Series:
|
def _eval_node(node: Node, df: pd.DataFrame) -> pd.Series:
|
||||||
if isinstance(node, FeatureNode):
|
if isinstance(node, FeatureNode):
|
||||||
|
if node.name in _TIME_FEATURE_FNS:
|
||||||
|
return _TIME_FEATURE_FNS[node.name](df.index)
|
||||||
return df[node.name]
|
return df[node.name]
|
||||||
|
|
||||||
if isinstance(node, IndicatorNode):
|
if isinstance(node, IndicatorNode):
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ KNOWN_INDICATORS: frozenset[str] = frozenset(
|
|||||||
{"sma", "rsi", "atr", "macd", "realized_vol"}
|
{"sma", "rsi", "atr", "macd", "realized_vol"}
|
||||||
)
|
)
|
||||||
KNOWN_FEATURES: frozenset[str] = frozenset(
|
KNOWN_FEATURES: frozenset[str] = frozenset(
|
||||||
{"open", "high", "low", "close", "volume"}
|
{"open", "high", "low", "close", "volume",
|
||||||
|
"hour", "dow", "is_weekend", "minute_of_hour"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convenience union (utile a validator / parser).
|
# Convenience union (utile a validator / parser).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -100,3 +100,35 @@ def test_e2e_minimal_run_completes(
|
|||||||
assert len(gens) == 2
|
assert len(gens) == 2
|
||||||
evals = repo.list_evaluations(run_id)
|
evals = repo.list_evaluations(run_id)
|
||||||
assert len(evals) >= 5 # almeno una popolazione
|
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"
|
|
||||||
@@ -194,6 +194,49 @@ def test_undertrading_under_10_is_high(monkeypatch: pytest.MonkeyPatch,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
def test_overtrading_with_tighter_threshold(monkeypatch: pytest.MonkeyPatch,
|
||||||
ohlcv: pd.DataFrame) -> None:
|
ohlcv: pd.DataFrame) -> None:
|
||||||
"""n_trades > n_bars/20 -> MEDIUM overtrading (Phase 1.5: era /5)."""
|
"""n_trades > n_bars/20 -> MEDIUM overtrading (Phase 1.5: era /5)."""
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ def test_settings_llm_model_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
|
|
||||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
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_s == "google/gemini-3-flash-preview"
|
||||||
assert s.llm_model_tier_a == "anthropic/claude-sonnet-4-6"
|
assert s.llm_model_tier_a == "deepseek/deepseek-v4-flash"
|
||||||
assert s.llm_model_tier_b == "anthropic/claude-sonnet-4-6"
|
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_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"
|
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():
|
def test_estimate_cost_tier_b():
|
||||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.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():
|
def test_tracker_accumulates():
|
||||||
@@ -34,17 +34,17 @@ def test_tracker_per_tier_breakdown():
|
|||||||
|
|
||||||
def test_estimate_cost_tier_s():
|
def test_estimate_cost_tier_s():
|
||||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.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():
|
def test_estimate_cost_tier_a():
|
||||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.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():
|
def test_estimate_cost_tier_d():
|
||||||
cost = estimate_cost(input_tokens=1_000_000, output_tokens=1_000_000, tier=ModelTier.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():
|
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"):
|
for tier_letter in ("S", "A", "B", "C", "D"):
|
||||||
assert tier_letter in summary["by_tier"]
|
assert tier_letter in summary["by_tier"]
|
||||||
assert summary["by_tier"][tier_letter]["calls"] == 1
|
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
|
||||||
|
)
|
||||||
@@ -89,3 +89,66 @@ def test_fitness_normalizes_drawdown() -> None:
|
|||||||
]
|
]
|
||||||
for prev, curr in pairwise(fitnesses):
|
for prev, curr in pairwise(fitnesses):
|
||||||
assert prev > curr, f"non monotona: {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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
|
|
||||||
from multi_swarm.agents.hypothesis import HypothesisAgent, MarketSummary
|
from multi_swarm.agents.hypothesis import HypothesisAgent, MarketSummary
|
||||||
from multi_swarm.genome.hypothesis import HypothesisAgentGenome, ModelTier
|
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:
|
def make_summary() -> MarketSummary:
|
||||||
@@ -214,3 +214,37 @@ def test_hypothesis_agent_no_retry_when_first_succeeds(mocker): # type: ignore[
|
|||||||
assert proposal.n_attempts == 1
|
assert proposal.n_attempts == 1
|
||||||
assert len(proposal.completions) == 1
|
assert len(proposal.completions) == 1
|
||||||
assert fake_llm.complete.call_count == 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.output_tokens == 150
|
||||||
assert out.tier == ModelTier.B
|
assert out.tier == ModelTier.B
|
||||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
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.model == "anthropic/claude-sonnet-4-6"
|
assert out.model == "deepseek/deepseek-v4-flash"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
@@ -75,7 +75,7 @@ def test_completion_retries_on_connection_error(mocker):
|
|||||||
with pytest.raises(openai.APIConnectionError):
|
with pytest.raises(openai.APIConnectionError):
|
||||||
client.complete(g, system="sys", user="usr")
|
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):
|
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()
|
fake_openai.chat.completions.create.assert_called_once()
|
||||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
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.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):
|
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()
|
fake_openai.chat.completions.create.assert_called_once()
|
||||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
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.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):
|
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()
|
fake_openai.chat.completions.create.assert_called_once()
|
||||||
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
|
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.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):
|
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]}"
|
||||||
@@ -106,3 +106,150 @@ def test_compile_two_rules_priority(ohlcv: pd.DataFrame) -> None:
|
|||||||
signals = fn(ohlcv)
|
signals = fn(ohlcv)
|
||||||
last = signals.iloc[-1]
|
last = signals.iloc[-1]
|
||||||
assert last == Side.LONG # close finale e' 120, regola 1 matcha
|
assert last == Side.LONG # close finale e' 120, regola 1 matcha
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_hour_feature_returns_index_hour(ohlcv: pd.DataFrame) -> None:
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "gt",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "hour"},
|
||||||
|
{"kind": "literal", "value": -1.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
# All rows have hour >= 0 > -1, so all entry-long.
|
||||||
|
assert (signal == Side.LONG).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_dow_feature_monday_is_zero(ohlcv: pd.DataFrame) -> None:
|
||||||
|
# 2024-01-01 is Monday -> dow=0; eq(dow, 0) gates LONG on Monday rows only.
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "eq",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "dow"},
|
||||||
|
{"kind": "literal", "value": 0.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
monday_rows = signal[signal.index.dayofweek == 0]
|
||||||
|
other_rows = signal[signal.index.dayofweek != 0]
|
||||||
|
assert (monday_rows == Side.LONG).all()
|
||||||
|
assert (other_rows == Side.FLAT).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_is_weekend_returns_zero_one(ohlcv: pd.DataFrame) -> None:
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "eq",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "is_weekend"},
|
||||||
|
{"kind": "literal", "value": 1.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
weekend = signal[signal.index.dayofweek >= 5]
|
||||||
|
weekdays = signal[signal.index.dayofweek < 5]
|
||||||
|
assert (weekend == Side.LONG).all()
|
||||||
|
assert (weekdays == Side.FLAT).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_minute_of_hour_zero_on_1h_timeframe(ohlcv: pd.DataFrame) -> None:
|
||||||
|
# Fixture has freq=1h, so minute_of_hour is 0 on every row.
|
||||||
|
# eq(minute_of_hour, 0.0) -> LONG on every row.
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "eq",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "minute_of_hour"},
|
||||||
|
{"kind": "literal", "value": 0.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
assert (signal == Side.LONG).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_with_temporal_gating_compiles_and_executes(ohlcv: pd.DataFrame) -> None:
|
||||||
|
# Rule: entry-long if hour > 14 AND close > sma(20).
|
||||||
|
# close in fixture is strictly increasing, so close > sma(20) holds after warmup.
|
||||||
|
# entry-long should appear only on rows with hour > 14.
|
||||||
|
src = json.dumps(
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"op": "and",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"op": "gt",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "hour"},
|
||||||
|
{"kind": "literal", "value": 14.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "gt",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "close"},
|
||||||
|
{"kind": "indicator", "name": "sma", "params": [20]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"action": "entry-long",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
fn = compile_strategy(ast)
|
||||||
|
signal = fn(ohlcv)
|
||||||
|
|
||||||
|
# Bars with hour <= 14: never LONG (temporal gate blocks).
|
||||||
|
morning = signal[signal.index.hour <= 14]
|
||||||
|
assert (morning == Side.FLAT).all()
|
||||||
|
|
||||||
|
# Bars with hour > 14 AND past SMA warmup (>=20 bars): LONG.
|
||||||
|
afternoon_warm = signal[(signal.index.hour > 14) & (np.arange(len(signal)) >= 20)]
|
||||||
|
assert (afternoon_warm == Side.LONG).all()
|
||||||
|
|||||||
@@ -151,3 +151,33 @@ def test_feature_unknown_column_fails() -> None:
|
|||||||
ast = parse_strategy(src)
|
ast = parse_strategy(src)
|
||||||
with pytest.raises(ValidationError, match="unknown feature"):
|
with pytest.raises(ValidationError, match="unknown feature"):
|
||||||
validate_strategy(ast)
|
validate_strategy(ast)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["hour", "dow", "is_weekend", "minute_of_hour"])
|
||||||
|
def test_validator_accepts_temporal_feature(name: str) -> None:
|
||||||
|
src = _wrap(
|
||||||
|
{
|
||||||
|
"op": "gt",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": name},
|
||||||
|
{"kind": "literal", "value": 0.0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
validate_strategy(ast) # no exception
|
||||||
|
|
||||||
|
|
||||||
|
def test_validator_rejects_temporal_typo() -> None:
|
||||||
|
src = _wrap(
|
||||||
|
{
|
||||||
|
"op": "gt",
|
||||||
|
"args": [
|
||||||
|
{"kind": "feature", "name": "weekday"},
|
||||||
|
{"kind": "literal", "value": 0.0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ast = parse_strategy(src)
|
||||||
|
with pytest.raises(ValidationError, match="unknown feature"):
|
||||||
|
validate_strategy(ast)
|
||||||
|
|||||||
Reference in New Issue
Block a user