19 Commits

Author SHA1 Message Date
Adriano Dal Pastro b8aa00b883 fix(llm): inject system prompt into user message for OpusAgent
OpusAgent topic system_prompt is used for summarization context but
not enforced as a binding instruction to Claude. Prepend [SYSTEM]
block to the first message of each session so Claude sees the full
JSON schema and grammar specification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:42:26 +00:00
Adriano Dal Pastro ba2c23e602 feat(llm): add session_id for retries and summarize for run tracking
HypothesisAgent now opens an OpusAgent session on first attempt and
reuses it for parse-error retries — Claude sees full conversation
context instead of re-stuffing the prompt. Sessions are closed after
propose() completes. All completions set summarize=true so topics
accumulate incremental summaries of GA activity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:18:11 +00:00
Adriano Dal Pastro 646c64dacd feat(llm): migrate from OpenRouter to OpusAgent
Replace OpenRouter (openai SDK) with OpusAgent REST API (httpx + polling).
LLMClient now creates topics per system prompt, submits async requests,
and polls for completion. Model tiers map to Claude model IDs
(opus-4-7, sonnet-4-6, haiku-4-5) configurable via env vars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:09:54 +00:00
Adriano Dal Pastro a702b2090d chore(smoke): expose RUN_NAME via PYTHAGORAS_SMOKE_RUN_NAME env var 2026-05-20 15:19:13 +00:00
Adriano Dal Pastro c6bf0f31cc feat(prompt_library): v3.2 custom_indicators_spec — fix LLM parse rate on Pythagoras indicators 2026-05-20 10:00:31 +00:00
Adriano Dal Pastro 68e0b009e9 feat(strategy_pythagoras): GA smoke-test runner (BTC+ETH dual run + invariance bonus) 2026-05-19 14:16:07 +00:00
Adriano Dal Pastro 4baa1eca62 feat(strategy_pythagoras): NiceGUI dashboard with 4 tabs (Genomes/Patterns/Ratios/Invariance) 2026-05-19 14:09:26 +00:00
Adriano Dal Pastro 035cd1dff3 feat(strategy_pythagoras): port frontend data layer + invariance/candle helpers 2026-05-19 14:00:33 +00:00
Adriano Dal Pastro b8bf0c186c feat(strategy_pythagoras): invariance bonus callback (BTC<->ETH corr_signal)
corr_signal: frazione di entries A con match in B entro +/-tolerance_bars
(default 36 barre = 3h su 5m TF, env GA_INVARIANCE_TOLERANCE_BARS).
apply_invariance_bonus: fitness * (1 + alpha * invariance_score), alpha=0.3
(env GA_INVARIANCE_ALPHA). Spec plan Pythagoras §4.

GA integration approach (Step 5 findings):
- multi_swarm_core.ga.fitness.compute_fitness e compute_combined_fitness
  NON espongono callback/hook per post-processing della fitness.
- orchestrator.run.run_phase1(...) -> str ritorna solo il run_id; le
  evaluations (incl. fitness scalare) vengono persistite via
  repo.save_evaluation dentro al loop GA (run.py:264-277).
- I winner sono recuperati dopo il run con repo.list_evaluations(run_id)
  e ri-ordinati per fitness (vedi pattern run.py:302-310 per WFA re-eval).
- Pattern (b) confermato: il runner Task 6.1 chiamera' run_phase1 due
  volte (BTC, ETH), poi per ogni coppia di evaluations matchera' i
  genome_id, calcolera' corr_signal sulle entries dei rispettivi
  backtests e applichera' apply_invariance_bonus per ri-rankare
  esternamente i winner. Nessuna modifica a multi_swarm_core necessaria
  in questo task.

Tests: 6/6 PASS (perfect alignment, no overlap, within tolerance, bonus
formula, alpha=0, zero entries).
2026-05-19 13:58:39 +00:00
Adriano Dal Pastro af68bc44b4 feat(strategy_pythagoras): port paper-trading backend (Portfolio, Executor, Repository) 2026-05-19 13:55:23 +00:00
Adriano Dal Pastro 074ebe0379 feat(strategy_pythagoras): port DB schema from strategy_crypto 2026-05-19 13:53:08 +00:00
Adriano Dal Pastro 7f2db19a7c feat(strategy_pythagoras): prompts.json with 7 Pythagoras-aligned cognitive styles 2026-05-19 13:51:32 +00:00
Adriano Dal Pastro 369a77b5cf feat(strategy_pythagoras): implement candle_pattern, pythagorean_ratio, fractal_mirror + register in compiler 2026-05-19 13:38:40 +00:00
Adriano Dal Pastro 2aa5646aeb feat(protocol): validate arity + semantics of 3 Pythagoras indicators 2026-05-19 13:29:16 +00:00
Adriano Dal Pastro 6a9e2c28b1 feat(protocol): register 3 Pythagoras indicators in KNOWN_INDICATORS 2026-05-19 13:26:40 +00:00
Adriano Dal Pastro 37558a34f5 feat(strategy_pythagoras): scaffold workspace member + register in uv 2026-05-19 13:26:01 +00:00
Adriano Dal Pastro 14f476dd09 docs(strategy_pythagoras): spec + plan + summaries dei PDF
Base per l'esecuzione del sub-project strategy_pythagoras:
- docs/superpowers/specs/2026-05-19-strategy-pythagoras-design.md (spec)
- docs/superpowers/plans/2026-05-19-strategy-pythagoras.md (14 task, 72 step TDD)
- src/strategy_pythagoras/Pythagoras/*.summary.md (riassunti numerici dei 2 PDF)
- src/strategy_pythagoras/Pythagoras/_extracted/*.txt (estrazione testo grezzo via pypdf)

I PDF stessi non vengono committati (vedi .gitignore).
2026-05-19 13:03:09 +00:00
Adriano Dal Pastro 05c7b5e89e test(strategy_crypto): non hard-codare hash dei winner shippati
L'assert su btc_fb63e851.json/eth_facd6af85d5d.json era diventato
stale dopo i swap dei paper winner (commit 8b767da, 23b7273).
Verifica strutturale (almeno un btc_*.json e un eth_*.json) evita
il fail ad ogni futuro swap di winner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:25:27 +00:00
Adriano Dal Pastro 6655e425fa fix(paper): ETH 5m allineato al tick + hardening GUI/compose
Bug principale: in scripts/run_paper_trading.py il fetch usava
end = now.replace(minute=0,...), troncando sempre all'ora. ETH è
dichiarato timeframe=5m (commit 23b7273) ma di fatto veniva
valutato 1 volta ogni 60 min — 502 poll del run 39e027df hanno
prodotto solo 43 evaluazioni/asset, tutte a HH:00. Il commento
in load_assets segnala esplicitamente che a 1h la strategia
perde -33% su 7y: regressione vs backtest.

Fix: helper _align_end_to_timeframe(now, timeframe) snappa end
al boundary nativo dell'asset. Mappa 1m/5m/15m/30m/1h/4h/1d.
Test regression in src/strategy_crypto/tests con 9 casi.

Hardening accessorio incluso nello stesso commit:
- docker-compose.yml: state/ in RW per strategy-crypto-gui
  (SQLite WAL richiede SHM writable anche da reader).
- multi_swarm_core/dashboard/nicegui_app.py: ui.timer ora
  deactivate on_disconnect su 3 pagine (index/convergence/genomes)
  per evitare leak di timer dopo client disconnect.
- strategy_crypto/frontend/data.py: retry 5s su sqlite.connect
  per cold-start race quando GUI parte prima del paper writer.
- state/validation-hardened-001.json: output WFA tooling
  multi-fold del run phase1-hardened-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:04:15 +00:00
51 changed files with 7057 additions and 376 deletions
+3
View File
@@ -47,3 +47,6 @@ logs/
build/ build/
dist/ dist/
*.egg *.egg
# Pythagoras source PDFs (local only, not tracked)
src/strategy_pythagoras/Pythagoras/*.pdf
+3 -3
View File
@@ -23,7 +23,7 @@
# ./state contiene runs.db (GA) + strategy_crypto.db (paper) + WAL/SHM # ./state contiene runs.db (GA) + strategy_crypto.db (paper) + WAL/SHM
# ./src/strategy_crypto/strategy_crypto/strategies JSON freezate (ro) # ./src/strategy_crypto/strategy_crypto/strategies JSON freezate (ro)
# #
# Secrets (token Cerbero + OpenRouter): caricati da .env via env_file. # Secrets (token Cerbero + OpusAgent): caricati da .env via env_file.
networks: networks:
traefik: traefik:
@@ -88,8 +88,8 @@ services:
<<: *swarm-env <<: *swarm-env
DASHBOARD_ROOT_PATH: /strategy_crypto_gui DASHBOARD_ROOT_PATH: /strategy_crypto_gui
volumes: volumes:
# Dashboard legge solo strategy_crypto.db: state/ in read-only (WAL: vedi nota) # RW richiesto: SQLite WAL mode richiede write-access dal reader per SHM.
- ./state:/app/state:ro - ./state:/app/state
entrypoint: entrypoint:
- python - python
- -m - -m
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,343 @@
# strategy_pythagoras — Design
**Data:** 2026-05-19
**Autore design:** Adriano Dal Pastro (con Claude/brainstorming)
**Stato:** Design approvato per parti 1-4, in attesa di review utente sulla spec consolidata
**Audience:** implementatori (Claude executor o umano)
## 0. Riassunto esecutivo
Nuovo workspace member `strategy_pythagoras` parallelo a `strategy_crypto`. Replica il pattern coevolutivo GA del monorepo applicato a un dominio diverso: la **scoperta di pattern frattali ricorrenti** sui mercati crypto secondo il framework Pythagoras-Malanga (Fourier + frattali + geometria Evideon), descritto nei due PDF in `src/strategy_pythagoras/Pythagoras/`.
**Non cambia il genoma del core:** gli agenti restano `HypothesisAgentGenome` e producono `Strategy JSON` nella stessa DSL S-expression. Cambiano:
- 7 stili cognitivi Pythagoras-themed (in `prompts.json`)
- 3 nuovi indicatori candle (`candle_pattern`, `pythagorean_ratio`, `fractal_mirror`)
- Fitness con bonus di asset-invariance BTC↔ETH
- Output: strategie JSON freezate + dashboard NiceGUI + report markdown di analisi cross-book
**Deliverable di chiusura task:** scaffolding completo + 1 GA run short (smoke test) + `docs/analysis_first_run.md` con sintesi numerica e top winners.
---
## 1. Architettura
### Layout package
```
src/strategy_pythagoras/
├── pyproject.toml # workspace member, dipende da multi-swarm-core
├── README.md
├── tests/
│ └── test_indicators.py # unit-test per i 3 nuovi indicatori
└── strategy_pythagoras/
├── __init__.py
├── prompts.json # 7 stili Pythagoras-aligned (schema v3.2 di strategy_crypto)
├── indicators.py # candle_pattern, pythagorean_ratio, fractal_mirror
├── backend/
│ ├── __init__.py
│ ├── schema.py # tabelle paper_trading_*
│ ├── executor.py # PaperExecutor (port da strategy_crypto)
│ ├── portfolio.py # Portfolio (port da strategy_crypto)
│ └── persistence.py # PaperRepository
├── frontend/
│ ├── __init__.py
│ ├── nicegui_app.py # /strategy_pythagoras_gui
│ └── data.py # dual-reader: GA db + paper db + invariance metrics
└── strategies/ # JSON winners shippati col package
└── (vuoto al t0)
```
### Workspace registration
In `pyproject.toml` (root):
```toml
[tool.uv.workspace]
members = ["src/multi_swarm_core", "src/strategy_crypto", "src/strategy_pythagoras"]
[tool.uv.sources]
strategy-pythagoras = { workspace = true }
```
Aggiungere `strategy-pythagoras` a `[project].dependencies` per il deployable root.
### Riuso
| Componente | Source | Note |
|---|---|---|
| GA loop, mutation, crossover | `multi_swarm_core.ga` | invariato |
| Protocol parser/validator/compiler | `multi_swarm_core.protocol` | esteso con 3 indicatori (vedi §2) |
| Backtest engine | `multi_swarm_core.backtest` | invariato |
| LLM / OpenRouter / Anthropic clients | `multi_swarm_core.llm` | invariato |
| PaperExecutor + Portfolio | `strategy_crypto.backend` | **port** (non import), per isolamento DB |
| NiceGUI dashboard shell | `strategy_crypto.frontend` | **port** + adatta tabs |
### Persistence
```
state/strategy_pythagoras.db # GA: genomi, generazioni, fitness history
state/strategy_pythagoras_paper.db # paper-trading post-deploy
strategy_pythagoras/strategies/ # JSON winners shippati
docs/analysis_first_run.md # report cross-book
docs/analysis_runs/<run-id>/ # per-run dump
```
Env vars:
- `STRATEGY_PYTHAGORAS_DB_PATH` (default `state/strategy_pythagoras.db`)
- `STRATEGY_PYTHAGORAS_PAPER_DB_PATH` (default `state/strategy_pythagoras_paper.db`)
- `GA_INVARIANCE_ALPHA` (default 0.3)
- `GA_INVARIANCE_TOLERANCE_BARS` (default 36 = ±3h su 5m TF)
### Routing GUI
`/strategy_pythagoras_gui` (allinea a [[gui_subpath_routing]] in memory: ogni asset GUI su subpath, root dominio libera).
### Docker (Phase 2, fuori scope di questa spec)
Servizi paralleli a quelli di strategy_crypto:
- `strategy-pythagoras-paper` (runner)
- `strategy-pythagoras-gui` (dashboard)
Rete Traefik external, bind mount uid 1000 (vedi [[production_deployment]]).
---
## 2. Genoma e DSL
### Il genoma non cambia
`HypothesisAgentGenome` di `multi_swarm_core.genome.hypothesis` resta identico:
- `system_prompt: str`
- `feature_access: list[str]`
- `temperature: float`
- `top_p: float`
- `model_tier: ModelTier`
- `lookback_window: int` — vincolo 12 ≤ lw ≤ 200
- `cognitive_style: str` — uno dei 7 nuovi stili
- `parent_ids, generation, id` — invariati
### 3 nuovi indicatori (`strategy_pythagoras/indicators.py`)
| Nome | Params (JSON) | Output | Semantica operativa |
|---|---|---|---|
| `candle_pattern` | `[seq_str]` es. `"UDU"`, `"UUD0U"` | 1.0 se le ultime k=len(seq_str) candele matchano la sequenza, 0.0 altrimenti | `"U"` = close>open; `"D"` = close<open; `"0"` = `abs(close-open)/open < 0.001` |
| `pythagorean_ratio` | `[lookback: int]` | float = `max(close[-lookback:]) / min(close[-lookback:])` | Ratio prezzo, da confrontare con literal vicini a φ=1.618, π=3.1416, √2=1.414, e=2.718 |
| `fractal_mirror` | `[k: int, axis: str]` axis ∈ `{"h","v"}` | float ∈ [-1, +1] | Correlazione di Pearson tra ultime k candele e loro mirror: `"h"` = mirror tempo (inversione sequenza); `"v"` = mirror prezzo (1 - close/max) |
Vincoli del compiler:
- `candle_pattern`: `len(seq_str)` ∈ [3, 12], simboli ∈ `{U,D,0}`
- `pythagorean_ratio`: `lookback` ∈ [12, 200]
- `fractal_mirror`: `k` ∈ [3, 12], `axis``{"h","v"}`
Tutti e 3 vanno aggiunti a `KNOWN_INDICATORS` in `multi_swarm_core.protocol.grammar`.
### Esempio strategy JSON tipica
```json
{
"rules": [
{
"condition": {
"op": "and",
"args": [
{"op": "eq", "args": [
{"kind": "indicator", "name": "candle_pattern", "params": ["UDDU"]},
{"kind": "literal", "value": 1.0}
]},
{"op": "gt", "args": [
{"kind": "indicator", "name": "pythagorean_ratio", "params": [89]},
{"kind": "literal", "value": 1.618}
]}
]
},
"action": "entry-long"
},
{
"condition": {
"op": "or", "args": [
{"op": "gt", "args": [
{"kind": "indicator", "name": "pythagorean_ratio", "params": [89]},
{"kind": "literal", "value": 2.618}
]},
{"op": "crossunder", "args": [
{"kind": "indicator", "name": "fractal_mirror", "params": [12, "h"]},
{"kind": "literal", "value": 0.0}
]}
]
},
"action": "exit"
}
]
}
```
### Vincoli protettivi (anti-overfitting)
- `lookback_window ≤ 200`
- `candle_pattern` seq length ∈ [3, 12] (range 1+2 del Libro dei Frattali)
- `time_in_market` monitorato come metric (red flag [[selectivity_red_flag]]); non hard-gate al primo run
- Letterali con `pythagorean_ratio`: max 4 decimali (no `1.6180339`)
- Max 4 condizioni in AND per regola (eredita da prompts.json)
---
## 3. Stili cognitivi (`strategy_pythagoras/prompts.json`)
### Schema
Schema v3.2 identico a `strategy_crypto/prompts.json` (campi `_schema`, `_changelog`, `_design_invariants`, `agent_role`, `pattern_guidance`, `instruction`, `domain_warnings`, `anti_patterns`, `output_priorities`, `styles`).
### `agent_role`
> Sei un agente di un sistema swarm coevolutivo che cerca pattern frattali ricorrenti sui mercati crypto secondo il framework Pythagoras-Malanga (frattali H-C, trasformata di Fourier, geometria Evideon). Sei parte di una popolazione che esplora collettivamente lo spazio dei pattern: la diversità delle ipotesi è un asset critico. Preferisci esplorare strutture meno ovvie per la tua lente cognitiva. La strategia che produci deve essere riconoscibile come emanata dal tuo stile.
### `pattern_guidance` (specifico Pythagoras)
- Sequenze candle 3-12 lunghezza in alfabeto `{U,D,0}` via `candle_pattern`
- Mirror H/V come operatori di proiezione via `fractal_mirror`
- Ratios di prezzo vicini a φ=1.618, 1/φ=0.618, √2=1.414, π/2=1.571, e/2=1.359 entro tolleranza 0.5%
- Pattern composti: pattern lunghi (6-12) come concatenazione di pattern corti (Pattern 13 = Pattern 3 + Pattern 11, p. 53 del paper)
- Cicli ricorrenti: stesso pattern firato a distanze regolari (Poincaré)
### `domain_warnings`
- Crypto 24/7, no CME gap
- I "numeri sacri" (Solfeggio 396-852 Hz, 137.0359, etc.) sono prior teorici, NON leggi: usali come scale candidate, non come dogma
- Il paper Pythagoras è esplicitamente non-falsificabile (cita "consapevolezza del trader" come jolly per il fallimento): il backtest è l'unico arbitro
- `time_in_market > 80%` red flag (leveraged B&H camuffato)
- Tolleranza ±3h del paper → su 5m TF = ±36 barre
### `anti_patterns`
- Sequenza `candle_pattern` con `len > 7` simboli vincolati → overfitting
- `pythagorean_ratio` con tolleranza > 2% sui literal → numerologia spuriosa
- `fractal_mirror` con `k == lookback_window` → tautologico
- Letterali con più di 4 decimali
- Più di 4 condizioni in AND
- Crossover tra indicatori dello stesso tipo con lookback vicini → chattering
### `output_priorities`
1. **Coerenza con lente cognitiva** (es. `pythagorean` usa ratios, `candle_grammarian` usa sequenze esplicite)
2. **Asset-invariance** (segnali che attivano sia su BTC che su ETH entro ±36 barre)
3. **Selettività** (poche entry forti)
4. **Composizionalità** (pattern lunghi come somma di corti)
5. **Robustezza vs random baseline** (3σ richiesto da `skeptic_quant`)
### I 7 stili (`styles`)
Ogni stile mantiene shape v3.2 (directive 800-950 char ASCII-safe, `focus_metrics` 4 voci, ultimo periodo = "Archetipo dominante: X.", lookback range esplicito):
| `cognitive_style` | Archetipo / Metafora ancorante | `focus_metrics` | Lookback consigliato |
|---|---|---|---|
| `pythagorean` | Armonia di ratios sacri (φ, π, √2) | `pythagorean_ratio`, `candle_pattern`, `sma_pct`, `realized_vol` | 89-144 |
| `fractal_geometer` | Autosimilarità: pattern di 3 candele si ripetono dilatati a 6, 12 | `candle_pattern`, `fractal_mirror`, `atr_pct`, `pythagorean_ratio` | 48-144 |
| `fourier_analyst` | Somma di seni: frequenze ricorrenti dominanti | `sma_pct`, `realized_vol`, `candle_pattern`, `atr` | 60-200 |
| `evideonic_projector` | Presente = passato proiettato via mirror H+V e scale | `fractal_mirror`, `pythagorean_ratio`, `candle_pattern`, `sma_pct` | 24-96 |
| `candle_grammarian` | Lingua di 3 simboli (U,D,0); parole 3-12 lettere | `candle_pattern`, `volume`, `atr`, `realized_vol` | 12-48 |
| `recurrence_theorist` | Per Poincaré, eventi tornano: cerca pattern di oggi che firarono ieri | `candle_pattern`, `fractal_mirror`, `pythagorean_ratio`, `sma_pct` | 100-200 |
| `skeptic_quant` | Anticorpo all'unfalsifiability: solo edge 3σ vs random | `realized_vol`, `atr_pct`, `sma_pct`, `candle_pattern` | 60-150 |
Lo `skeptic_quant` è importante: la sua directive richiede esplicitamente che la strategia sia testabile e che il fitness sia confrontato contro random baseline.
### `_design_invariants`
Stessa filosofia di v3.2 di strategy_crypto:
- ASCII-safe (no Unicode oltre U+007F nelle directive)
- Ogni directive chiude con `Archetipo dominante: <metafora>.`
- Ogni directive ha range lookback numerico esplicito
- Prima frase: `Il mercato e ...`
- Lunghezza 800-950 char
---
## 4. Fitness, run GA short, deliverable analisi
### Fitness con bonus invariance
```
fitness(genome) = mean(sharpe_BTC, sharpe_ETH) × (1 + α × invariance_score)
invariance_score = corr_signal(entries_BTC, entries_ETH, tolerance_bars=GA_INVARIANCE_TOLERANCE_BARS)
∈ [0, 1]
α = GA_INVARIANCE_ALPHA (default 0.3)
```
`corr_signal` = frazione di entries su BTC che hanno una entry corrispondente su ETH entro ±36 barre (=±3h su 5m TF).
Implementazione come callback al GA in `multi_swarm_core.ga`, registrata da `strategy_pythagoras` al startup. Il core non sa nulla di Pythagoras: riceve solo la callback.
### GA run short (smoke test)
| Parametro | Valore | Note |
|---|---|---|
| `population_size` | 20 | minimo per 7 stili (~3 per stile) |
| `generations` | 5 | smoke test, non training |
| `elite_fraction` | 0.2 | top-4 sopravvivono |
| `mutation_rate` | 0.3 | invariato vs strategy_crypto |
| `crossover_rate` | 0.5 | invariato |
| `model_tier` distribuzione | 70% C (qwen-2.5-72b), 30% B (sonnet) | rispetta [[model_qwen_dependency]] |
| `dataset` | BTC 5m + ETH 5m da `strategy_crypto/series/` | riusa serie esistenti |
| `train_window` | 2024-07 → 2024-12 | copre le date Pythagoras (lug-ago 2024) |
| `test_window` | 2025-01 (1 mese) | hold-out per validare invariance |
| `name` | `pythagoras-smoke-001` | run id |
Lo smoke test verifica:
1. Workspace member installato in venv (`uv sync` + `uv run python -c "import strategy_pythagoras"`)
2. I 3 nuovi indicatori registrati nel grammar e compilabili
3. `prompts.json` caricato, 7 stili producono genomi distinti (no collisioni di id)
4. Bonus invariance impatta fitness (verifica via log)
5. JSON winners atterrano in `strategy_pythagoras/strategies/`
6. Dashboard NiceGUI si avvia e legge i due DB
### Deliverable analisi cross-book
`docs/analysis_first_run.md` con:
1. **Sintesi numerica dei riassunti** — riferimenti a `src/strategy_pythagoras/Pythagoras/Pythagoras_Trading_Prediction.summary.md` e `Libro_frattali.summary.md`
2. **Top-5 winners** — id, cognitive_style, fitness, sharpe_BTC, sharpe_ETH, invariance_score
3. **Pattern frattali emersi** — dump dei `candle_pattern` seq usate, conteggio per stile, % sovrapposizione con spazio teorico dei 57 pattern del Libro
4. **Ratios di prezzo emersi** — distribuzione literal usati con `pythagorean_ratio`, distanza dai numeri universali (φ/π/√2/Solfeggio)
5. **Cross-asset invariance osservata** — istogramma di `corr_signal` per top genomi
6. **Conclusione onesta** — confronto vs random baseline, quanti winners superano sharpe>1.0 su test + invariance>0.3, cosa il framework Pythagoras predice e cosa NON regge al backtest
Niente "consapevolezza" come jolly. Solo numeri.
### Dashboard NiceGUI
`/strategy_pythagoras_gui`:
- Tab **Genomes** — winners con stile/sharpe/invariance, click per drill su rules
- Tab **Patterns** — heatmap delle sequenze `candle_pattern` emerse, frequenza per stile
- Tab **Ratios** — istogramma literal vicini a costanti universali, bins centrati su φ, π, √2, ecc.
- Tab **Invariance** — scatter sharpe_BTC vs sharpe_ETH per ogni winner
---
## 5. Out-of-scope (esplicito)
- **Asset oltre BTC/ETH** (Oro/Argento del paper): non in primo run. Estensione futura.
- **Range candele oltre 12**: range 3-5 del Libro (12-56 candele). Estensione futura quando lo smoke test conferma stabilità.
- **Live trading reale**: solo paper-trading via stesso pattern di strategy_crypto.
- **OCR/Vision sulle figure del Libro dei Frattali**: esplicitamente ESCLUSO da request utente ("senza passare alle immagini").
- **Modifica del genoma del core**: nessuna modifica a `HypothesisAgentGenome`. Solo extension del grammar (3 indicatori).
- **Riferimenti pseudoscientifici operativizzati come legge**: i numeri sacri/Solfeggio sono prior teorici per literal candidati, non vincoli rigidi.
---
## 6. Criteri di accettazione
- [ ] `uv sync` riesce dalla root con `strategy_pythagoras` come member
- [ ] `uv run python -c "from strategy_pythagoras.indicators import candle_pattern, pythagorean_ratio, fractal_mirror"` non solleva
- [ ] `pytest src/strategy_pythagoras/tests/` verde (almeno unit-test per i 3 indicatori)
- [ ] GA short run `pythagoras-smoke-001` completa 5 generazioni senza errori
- [ ] Almeno 1 winner con fitness > 0 e `cognitive_style` ∈ {7 stili Pythagoras}
- [ ] Dashboard avvia su `http://localhost:PORT/strategy_pythagoras_gui` e mostra winners
- [ ] `docs/analysis_first_run.md` esiste e contiene tutte le sezioni elencate in §4
---
## 7. Riferimenti
- [Pythagoras Trading Prediction — riassunto](../../../src/strategy_pythagoras/Pythagoras/Pythagoras_Trading_Prediction.summary.md)
- [Libro dei Frattali — riassunto](../../../src/strategy_pythagoras/Pythagoras/Libro_frattali.summary.md)
- Memory: [[monorepo_uv_workspace]], [[gui_subpath_routing]], [[ownership_per_modulo]], [[production_deployment]], [[model_qwen_dependency]], [[selectivity_red_flag]]
- Template: `src/strategy_crypto/` (paper-trading + GUI), `src/strategy_crypto/strategy_crypto/prompts.json` (schema v3.2)
- Core: `src/multi_swarm_core/multi_swarm_core/genome/hypothesis.py`, `multi_swarm_core/protocol/grammar.py`
+4 -2
View File
@@ -10,14 +10,16 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"multi-swarm-core", "multi-swarm-core",
"strategy-crypto", "strategy-crypto",
"strategy-pythagoras",
] ]
[tool.uv.workspace] [tool.uv.workspace]
members = ["src/multi_swarm_core", "src/strategy_crypto"] members = ["src/multi_swarm_core", "src/strategy_crypto", "src/strategy_pythagoras"]
[tool.uv.sources] [tool.uv.sources]
multi-swarm-core = { workspace = true } multi-swarm-core = { workspace = true }
strategy-crypto = { workspace = true } strategy-crypto = { workspace = true }
strategy-pythagoras = { workspace = true }
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -42,7 +44,7 @@ python_version = "3.13"
strict = true strict = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["src/multi_swarm_core/tests", "src/strategy_crypto/tests"] testpaths = ["src/multi_swarm_core/tests", "src/strategy_crypto/tests", "src/strategy_pythagoras/tests"]
addopts = "-v --tb=short --import-mode=importlib" addopts = "-v --tb=short --import-mode=importlib"
markers = [ markers = [
"integration: tests that require external services (Cerbero, LLM API)", "integration: tests that require external services (Cerbero, LLM API)",
+36 -3
View File
@@ -33,6 +33,36 @@ from strategy_crypto.backend import PaperExecutor, PaperRepository, Portfolio
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
# Mapping timeframe stringa Cerbero -> minuti del bar. Le strategie tradano
# sul "bar appena chiuso", quindi end deve essere snappato al boundary del
# loro timeframe (NON sempre al top dell'ora) per evitare la regressione in
# cui ETH 5m veniva valutato una volta sola ogni 60 min.
_TIMEFRAME_MINUTES: dict[str, int] = {
"1m": 1,
"5m": 5,
"15m": 15,
"30m": 30,
"1h": 60,
"4h": 240,
"1d": 1440,
}
def _align_end_to_timeframe(now: datetime, timeframe: str) -> datetime:
"""Snap ``now`` al boundary del bar timeframe (UTC, naive seconds).
Es.: now=14:37:42, tf="5m" -> 14:35:00
now=14:37:42, tf="1h" -> 14:00:00
now=14:00:00, tf="1h" -> 14:00:00
"""
bar_min = _TIMEFRAME_MINUTES[timeframe]
aligned = now.replace(second=0, microsecond=0)
if bar_min >= 1440:
return aligned.replace(hour=0, minute=0)
total_min = aligned.hour * 60 + aligned.minute
snapped = (total_min // bar_min) * bar_min
return aligned.replace(hour=snapped // 60, minute=snapped % 60)
def _default_strategies_dir() -> Path: def _default_strategies_dir() -> Path:
"""Cartella JSON shippata col package strategy_crypto.""" """Cartella JSON shippata col package strategy_crypto."""
@@ -131,9 +161,12 @@ def main() -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
last_prices: dict[str, float] = {} last_prices: dict[str, float] = {}
for asset, executor in zip(assets, executors, strict=True): for asset, executor in zip(assets, executors, strict=True):
# fetch OHLCV most recent lookback bars # fetch OHLCV most recent lookback bars: end snappato al timeframe
end = now.replace(minute=0, second=0, microsecond=0) # dell'asset, non sempre all'ora (altrimenti ETH 5m veniva valutato
start = end - timedelta(hours=args.lookback_bars + 1) # solo ogni 60 min, regressione vs backtest tunato 5m).
bar_min = _TIMEFRAME_MINUTES[asset.timeframe]
end = _align_end_to_timeframe(now, asset.timeframe)
start = end - timedelta(minutes=bar_min * (args.lookback_bars + 1))
req = OHLCVRequest( req = OHLCVRequest(
symbol=asset.symbol, symbol=asset.symbol,
timeframe=asset.timeframe, timeframe=asset.timeframe,
+4 -4
View File
@@ -159,8 +159,8 @@ def parse_args() -> argparse.Namespace:
default=1, default=1,
help=( help=(
"Numero di propose() LLM concorrenti per generazione (default 1 = " "Numero di propose() LLM concorrenti per generazione (default 1 = "
"serial). 6-10 tipicamente accettati da OpenRouter qwen-2.5 senza " "serial). OpusAgent processa in coda FIFO; concurrency > 1 accoda "
"rate-limit; riduce wall time GA loop di 5-8x." "piu' richieste in parallelo."
), ),
) )
return p.parse_args() return p.parse_args()
@@ -200,13 +200,13 @@ def main() -> None:
print(f"OHLCV loaded: {len(ohlcv)} bars from {ohlcv.index[0]} to {ohlcv.index[-1]}") print(f"OHLCV loaded: {len(ohlcv)} bars from {ohlcv.index[0]} to {ohlcv.index[-1]}")
llm = LLMClient( llm = LLMClient(
openrouter_api_key=settings.openrouter_api_key.get_secret_value(), opus_agent_api_key=settings.opus_agent_api_key.get_secret_value(),
opus_agent_base_url=settings.opus_agent_base_url,
model_tier_s=settings.llm_model_tier_s, model_tier_s=settings.llm_model_tier_s,
model_tier_a=settings.llm_model_tier_a, model_tier_a=settings.llm_model_tier_a,
model_tier_b=settings.llm_model_tier_b, model_tier_b=settings.llm_model_tier_b,
model_tier_c=settings.llm_model_tier_c, model_tier_c=settings.llm_model_tier_c,
model_tier_d=settings.llm_model_tier_d, model_tier_d=settings.llm_model_tier_d,
openrouter_base_url=settings.openrouter_base_url,
) )
cfg = RunConfig( cfg = RunConfig(
+475
View File
@@ -0,0 +1,475 @@
"""Smoke test del GA per strategy_pythagoras.
Esegue 2 run di Phase 1 (BTC 5m + ETH 5m), poi cross-rank i genomi
comuni applicando il bonus di asset-invariance (corr_signal sui pattern
di entry entro +/-36 barre = +/-3h su 5m TF, vedi paper Pythagoras p.43).
Configurazione (per spec §4):
- Population 20, 5 generations
- Asset: BTC-PERPETUAL 5m + ETH-PERPETUAL 5m (Cerbero deribit)
- Train window: 2024-07-01 -> 2024-12-31
- Test window: 2025-01-01 -> 2025-01-31 (caricato come coda dello stesso
range; non usato dal GA ma necessario per dataset continuo se in futuro
si attiva WFA)
- Stili cognitivi: 7 da strategy_pythagoras/prompts.json
- Indicatori Pythagoras: candle_pattern, pythagorean_ratio, fractal_mirror
(registrati nel compiler tramite import side-effect di strategy_pythagoras.indicators)
- Fitness post-processing cross-asset: apply_invariance_bonus
- Output: top 50 winners persisted in state/strategy_pythagoras.db
(tabella pythagoras_winners)
Adattamento all'API reale di run_phase1 (Task 4.1 findings + verifica diretta):
- ``run_phase1(cfg: RunConfig, ohlcv: pd.DataFrame, llm: LLMClient) -> str``
ritorna un ``run_id``. Non c'e' un fitness hook esterno: il GA loop
invoca ``compute_fitness`` inline e persiste via
``repo.save_evaluation``. Per il bonus invariance dobbiamo:
1. lanciare due ``run_phase1`` indipendenti, uno per asset;
2. caricare le evaluations via ``repo.list_evaluations(run_id)``;
3. ricompilare la strategia (``_try_parse`` + ``compile_strategy``) sui
segnali di ciascun OHLCV per estrarre gli entry index;
4. calcolare ``corr_signal`` sugli entry binari (Series int-indexed)
e applicare ``apply_invariance_bonus``.
- Le serie OHLCV NON sono shippate in repo come ``src/strategy_crypto/series/``:
il default loader Cerbero le cachea in ``./series/{cache_key}.parquet``
(cache key = sha1 di ``exchange|symbol|timeframe|start|end``). Riusiamo
quel meccanismo: caricamento via ``CerberoOHLCVLoader``, identico a
``scripts/run_phase1.py``.
Shape effettivo del dict ritornato da ``repo.list_evaluations(run_id)``
(vedi ``persistence/repository.py:213`` e schema in ``schema.py``):
{
'run_id', 'genome_id', 'fitness', 'dsr', 'dsr_pvalue', 'sharpe',
'max_dd', 'total_return', 'n_trades', 'parse_error', 'raw_text',
'eval_ts', 'fitness_oos', 'sharpe_oos', 'return_oos',
'max_dd_oos', 'n_trades_oos'
}
Nota: ``cognitive_style`` e ``generation`` NON sono nelle evaluations;
vanno presi via ``repo.list_genomes(run_id)`` (payload_json del genoma).
``raw_text`` contiene il completion grezzo del LLM, da cui si estrae
nuovamente lo ``Strategy`` AST via ``_try_parse``.
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any
import pandas as pd # type: ignore[import-untyped]
# Side-effect import: registra candle_pattern, pythagorean_ratio, fractal_mirror
# in compiler.INDICATOR_FNS prima che il GA inizi a compilare strategie.
# (Il compiler in protocol/compiler.py importa gia' i 3 simboli dal package
# strategy_pythagoras.indicators, ma facciamo l'import esplicito qui per
# rendere la dipendenza chiara e indipendente dall'ordine di import.)
import strategy_pythagoras.indicators # noqa: F401
from multi_swarm_core.agents.hypothesis import _try_parse
from multi_swarm_core.backtest.orders import Side
from multi_swarm_core.cerbero.client import CerberoClient
from multi_swarm_core.config import load_settings
from multi_swarm_core.data.cerbero_ohlcv import CerberoOHLCVLoader, OHLCVRequest
from multi_swarm_core.genome.hypothesis import ModelTier
from multi_swarm_core.genome.prompt_library import PromptLibrary
from multi_swarm_core.llm.client import LLMClient
from multi_swarm_core.orchestrator.run import RunConfig, run_phase1
from multi_swarm_core.persistence.repository import Repository
from multi_swarm_core.protocol.compiler import compile_strategy
from strategy_pythagoras.fitness_invariance import (
apply_invariance_bonus,
corr_signal,
)
ROOT = Path(__file__).resolve().parents[1]
DB_PATH = Path(
os.getenv("STRATEGY_PYTHAGORAS_DB_PATH", str(ROOT / "state" / "strategy_pythagoras.db"))
)
PROMPTS_PATH = ROOT / "src" / "strategy_pythagoras" / "strategy_pythagoras" / "prompts.json"
RUN_NAME = os.getenv("PYTHAGORAS_SMOKE_RUN_NAME", "pythagoras-smoke-001")
# GA configuration (smoke per spec §4)
POPULATION = 20
GENERATIONS = 5
# Data window
TRAIN_START = datetime.fromisoformat("2024-07-01T00:00:00+00:00")
TRAIN_END = datetime.fromisoformat("2024-12-31T23:55:00+00:00")
# Carichiamo anche gennaio 2025 come coda (per usi futuri: WFA OOS).
# Il GA loop in questa fase usa l'intero range; e' compito di un eventuale
# wfa_train_split (non attivato qui per coerenza con spec §4 smoke).
TEST_END = datetime.fromisoformat("2025-01-31T23:55:00+00:00")
ASSETS: list[tuple[str, str]] = [
("BTC-PERPETUAL", "btc"),
("ETH-PERPETUAL", "eth"),
]
TIMEFRAME = "5m"
EXCHANGE = "deribit"
TOP_K_PERSIST = 50
logger = logging.getLogger(RUN_NAME)
def init_winners_table(con: sqlite3.Connection) -> None:
"""Crea ``pythagoras_winners`` se non esiste (idempotente)."""
con.execute(
"""
CREATE TABLE IF NOT EXISTS pythagoras_winners (
genome_id TEXT PRIMARY KEY,
cognitive_style TEXT,
fitness REAL,
sharpe_btc REAL,
sharpe_eth REAL,
invariance_score REAL,
rules_json TEXT,
generation INTEGER,
run_name TEXT
)
"""
)
con.commit()
def _load_ohlcv(loader: CerberoOHLCVLoader, symbol: str) -> pd.DataFrame:
"""Carica la finestra ``TRAIN_START -> TEST_END`` per ``symbol`` su 5m."""
req = OHLCVRequest(
symbol=symbol,
timeframe=TIMEFRAME,
start=TRAIN_START,
end=TEST_END,
exchange=EXCHANGE,
)
ohlcv = loader.load(req)
logger.info(
"OHLCV loaded for %s: %d bars (%s -> %s)",
symbol, len(ohlcv),
ohlcv.index[0] if len(ohlcv) else "n/a",
ohlcv.index[-1] if len(ohlcv) else "n/a",
)
return ohlcv
def _build_run_config(
run_name: str, symbol: str, prompt_library: PromptLibrary, db_path: Path,
) -> RunConfig:
"""Costruisce il ``RunConfig`` per un singolo asset.
Usa lo stesso GA-core DB del progetto (``settings.ga_db_path`` se override
non passato): vi vengono scritte ``runs``, ``generations``, ``genomes``,
``evaluations`` per la run.
"""
return RunConfig(
run_name=run_name,
population_size=POPULATION,
n_generations=GENERATIONS,
elite_k=2,
tournament_k=3,
p_crossover=0.5,
seed=42,
model_tier=ModelTier.C,
symbol=symbol,
timeframe=TIMEFRAME,
fees_bp=5.0,
n_trials_dsr=50,
db_path=db_path,
prompt_library=prompt_library,
# Smoke: niente WFA, niente eval OOS in loop, niente prompt mutation LLM.
# I parametri restano sui default sicuri di RunConfig.
)
def run_ga_for_asset(
asset_label: str,
symbol: str,
ohlcv: pd.DataFrame,
prompt_library: PromptLibrary,
llm: LLMClient,
ga_db_path: Path,
) -> tuple[str, Repository]:
"""Lancia ``run_phase1`` per un asset.
Ritorna ``(run_id, repo)`` per il caller, che usera' ``repo`` per
estrarre evaluations + genomes a fine del run.
"""
run_name = f"{RUN_NAME}-{asset_label}"
cfg = _build_run_config(run_name, symbol, prompt_library, ga_db_path)
logger.info("Starting GA run '%s' on %s (%d bars)", run_name, symbol, len(ohlcv))
run_id = run_phase1(cfg, ohlcv=ohlcv, llm=llm)
logger.info("Run '%s' completed: run_id=%s", run_name, run_id)
repo = Repository(ga_db_path)
return run_id, repo
def _entries_series_from_eval(
eval_row: dict[str, Any], ohlcv: pd.DataFrame,
) -> pd.Series | None:
"""Ricostruisce gli entries binari (Side.LONG/SHORT -> 1, altrimenti 0)
a partire dal ``raw_text`` salvato nell'eval row.
Ritorna ``None`` se il raw_text non e' parsabile (caso parse_error).
L'index della Series ritornata e' INTERO posizionale (0..N-1) come
richiesto da ``corr_signal`` (vedi tests in
``strategy_pythagoras/tests/test_fitness_invariance.py``).
"""
raw = eval_row.get("raw_text")
if not raw:
return None
strategy, parse_err = _try_parse(raw)
if strategy is None:
logger.debug(
"skip genome %s: parse error '%s'",
eval_row.get("genome_id"), parse_err,
)
return None
try:
signal_fn = compile_strategy(strategy)
signals = signal_fn(ohlcv)
except Exception as exc:
logger.debug(
"skip genome %s: compile/exec error: %s",
eval_row.get("genome_id"), exc,
)
return None
# 1 dove il signal e' LONG o SHORT (entry attiva), 0 altrove.
is_entry = signals.isin([Side.LONG, Side.SHORT]).fillna(False).astype(int)
# Riassegna integer index per il match in corr_signal (che somma delta
# interi all'index e fa il test ``ti + delta in b_set``).
return pd.Series(is_entry.values, index=range(len(is_entry)), dtype="int64")
def _collect_evaluations(
repo: Repository, run_id: str, ohlcv: pd.DataFrame,
) -> dict[str, dict[str, Any]]:
"""Carica evaluations + genomes per ``run_id`` e li unisce per genome_id.
Returns: dict ``{genome_id: row}`` dove ``row`` contiene i campi
dell'eval + ``cognitive_style``, ``generation``, ``strategy_json``
(dict del genoma serializzato) e ``entries`` (pd.Series int-indexed).
"""
evals = repo.list_evaluations(run_id)
genomes = repo.list_genomes(run_id)
genome_by_id: dict[str, dict[str, Any]] = {}
for grow in genomes:
try:
payload = json.loads(grow["payload_json"])
except (json.JSONDecodeError, TypeError):
payload = {}
genome_by_id[grow["id"]] = payload
out: dict[str, dict[str, Any]] = {}
for ev in evals:
gid = ev["genome_id"]
payload = genome_by_id.get(gid, {})
row = dict(ev)
row["cognitive_style"] = payload.get("cognitive_style", "")
row["generation"] = int(payload.get("generation", 0))
# ``raw_text`` e' il completion grezzo; lo ri-parsiamo in
# _entries_series_from_eval. Salviamo la rappresentazione canonica
# ``strategy_json`` per persistenza (best-effort: se il parse fallisce
# salviamo il raw_text come fallback).
strategy, _err = _try_parse(row.get("raw_text") or "")
if strategy is not None:
# Strategy non e' direttamente JSON-serializable: serializziamo
# la struttura nominale tramite dataclasses.asdict-like fallback.
try:
row["strategy_json"] = _strategy_to_jsonable(strategy)
except Exception:
row["strategy_json"] = {"raw_text": row.get("raw_text", "")}
else:
row["strategy_json"] = {"raw_text": row.get("raw_text", "")}
row["entries"] = _entries_series_from_eval(ev, ohlcv)
out[gid] = row
return out
def _strategy_to_jsonable(strategy: Any) -> dict[str, Any]:
"""Serializza un ``Strategy`` AST in dict JSON-friendly.
Strategy/Rule/Node sono dataclass: usiamo ``dataclasses.asdict`` quando
possibile, con fallback a ``str(strategy)`` se la struttura contiene
membri non-serializzabili (es. enum non-Str).
"""
import dataclasses
if dataclasses.is_dataclass(strategy):
try:
return dataclasses.asdict(strategy)
except TypeError:
pass
return {"repr": repr(strategy)}
def compute_invariance_for_pair(
btc_evals: dict[str, dict[str, Any]],
eth_evals: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
"""Per ogni genome_id presente in entrambi i run, calcola invariance + bonus.
Lo stesso ``genome_id`` puo' apparire in entrambi i run perche' l'id e'
deterministico (sha1 di system_prompt+feature_access+temperature+...) e
il seed del GA e' fisso: il founder set e i mutanti hanno alta probabilita'
di collisione cross-asset. Quando il genoma compare in entrambi, le
metriche ``sharpe`` IS sono comparabili e ha senso valutare l'invarianza.
"""
out: list[dict[str, Any]] = []
common_ids = set(btc_evals) & set(eth_evals)
logger.info(
"Common genomes BTC ∩ ETH: %d (BTC: %d, ETH: %d)",
len(common_ids), len(btc_evals), len(eth_evals),
)
for gid in common_ids:
b = btc_evals[gid]
e = eth_evals[gid]
entries_btc: pd.Series | None = b.get("entries")
entries_eth: pd.Series | None = e.get("entries")
if entries_btc is None or entries_eth is None:
inv = 0.0
elif len(entries_btc) == 0 or len(entries_eth) == 0:
inv = 0.0
else:
try:
# Allineiamo a lunghezza minima: i due asset possono avere
# un numero di bars leggermente diverso (gap nel feed Cerbero).
# corr_signal lavora solo sugli index 1 -> il troncamento non
# introduce bias asimmetrici.
min_len = min(len(entries_btc), len(entries_eth))
inv = corr_signal(
entries_btc.iloc[:min_len].reset_index(drop=True),
entries_eth.iloc[:min_len].reset_index(drop=True),
)
except Exception as exc:
logger.warning("corr_signal failed for %s: %s", gid, exc)
inv = 0.0
sharpe_btc = float(b.get("sharpe") or 0.0)
sharpe_eth = float(e.get("sharpe") or 0.0)
mean_sharpe = 0.5 * (sharpe_btc + sharpe_eth)
boosted = apply_invariance_bonus(mean_sharpe, inv)
out.append({
"genome_id": gid,
"cognitive_style": b.get("cognitive_style") or e.get("cognitive_style", ""),
"fitness": float(boosted),
"sharpe_btc": sharpe_btc,
"sharpe_eth": sharpe_eth,
"invariance_score": float(inv),
"rules_json": json.dumps(b.get("strategy_json") or {}, default=str),
"generation": int(b.get("generation", 0)),
"run_name": RUN_NAME,
})
return sorted(out, key=lambda r: r["fitness"], reverse=True)
def persist_winners(con: sqlite3.Connection, winners: list[dict[str, Any]]) -> None:
if not winners:
logger.warning("No winners to persist")
return
con.executemany(
"""
INSERT OR REPLACE INTO pythagoras_winners
(genome_id, cognitive_style, fitness, sharpe_btc, sharpe_eth,
invariance_score, rules_json, generation, run_name)
VALUES (:genome_id, :cognitive_style, :fitness, :sharpe_btc, :sharpe_eth,
:invariance_score, :rules_json, :generation, :run_name)
""",
winners,
)
con.commit()
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
settings = load_settings()
# Prompt library Pythagoras (NON quello di strategy_crypto).
if not PROMPTS_PATH.exists():
raise FileNotFoundError(f"Prompts file not found: {PROMPTS_PATH}")
prompt_library = PromptLibrary.from_json(PROMPTS_PATH)
logger.info(
"PromptLibrary loaded from %s: %d styles (%s)",
PROMPTS_PATH, len(prompt_library.styles),
", ".join(prompt_library.cognitive_styles),
)
# Cerbero client + OHLCV loader (riusa la cache parquet in ./series).
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)
llm = LLMClient(
opus_agent_api_key=settings.opus_agent_api_key.get_secret_value(),
opus_agent_base_url=settings.opus_agent_base_url,
model_tier_s=settings.llm_model_tier_s,
model_tier_a=settings.llm_model_tier_a,
model_tier_b=settings.llm_model_tier_b,
model_tier_c=settings.llm_model_tier_c,
model_tier_d=settings.llm_model_tier_d,
)
# Setup DB winners (separato dal GA core DB).
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH)
try:
init_winners_table(con)
logger.info("Winners DB initialized at %s", DB_PATH)
# Carica OHLCV per entrambi gli asset PRIMA dei run GA, cosi' se la
# rete o Cerbero sono giu' falliamo subito senza sprecare chiamate LLM.
ohlcv_by_asset: dict[str, pd.DataFrame] = {}
for symbol, label in ASSETS:
ohlcv_by_asset[label] = _load_ohlcv(loader, symbol)
# Run GA per asset. Usa il GA-core DB definito in settings; ogni run
# crea un proprio run_id e set di evaluations isolato.
evals_by_asset: dict[str, dict[str, dict[str, Any]]] = {}
for symbol, label in ASSETS:
run_id, repo = run_ga_for_asset(
asset_label=label,
symbol=symbol,
ohlcv=ohlcv_by_asset[label],
prompt_library=prompt_library,
llm=llm,
ga_db_path=settings.ga_db_path,
)
evals_by_asset[label] = _collect_evaluations(
repo, run_id, ohlcv_by_asset[label]
)
logger.info(
"%s: %d evaluations collected", label.upper(),
len(evals_by_asset[label]),
)
# Cross-rank con invariance bonus.
winners = compute_invariance_for_pair(
evals_by_asset["btc"], evals_by_asset["eth"],
)
logger.info(
"Computed invariance bonus for %d common genomes", len(winners),
)
top = winners[:TOP_K_PERSIST]
persist_winners(con, top)
logger.info(
"Persisted top %d winners to %s (table: pythagoras_winners)",
len(top), DB_PATH,
)
finally:
con.close()
if __name__ == "__main__":
main()
@@ -4,11 +4,9 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
import openai
from ..genome.hypothesis import HypothesisAgentGenome from ..genome.hypothesis import HypothesisAgentGenome
from ..genome.prompt_library import PromptLibrary from ..genome.prompt_library import PromptLibrary
from ..llm.client import CompletionResult, EmptyCompletionError, LLMClient from ..llm.client import CompletionResult, EmptyCompletionError, LLMClient, OpusAgentError, OpusAgentTransientError
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
@@ -181,6 +179,12 @@ def _build_system_prompt(lib: PromptLibrary, genome: HypothesisAgentGenome) -> s
parts.append("") parts.append("")
# 3. Grammar spec (core scaffold) # 3. Grammar spec (core scaffold)
parts.append(_SYSTEM_GRAMMAR_SPEC) parts.append(_SYSTEM_GRAMMAR_SPEC)
# 3b. Custom indicators spec (da prompts.json, opzionale) - estende la lista
# "Leaf - indicatori" con firme strategy-specific non note al core.
if lib.custom_indicators_spec:
parts.append("\nINDICATORI CUSTOM (firme aggiuntive, applica le stesse regole grammaticali):\n")
parts.append(lib.custom_indicators_spec)
parts.append("")
# 4. Pattern guidance (da prompts.json, opzionale) # 4. Pattern guidance (da prompts.json, opzionale)
if lib.pattern_guidance: if lib.pattern_guidance:
parts.append( parts.append(
@@ -284,19 +288,12 @@ def _render_focus_block(keys: list[str], market: MarketSummary) -> str:
_RETRY_TEMPLATE = """\ _RETRY_TEMPLATE = """\
{original_user} Il JSON che hai generato contiene un errore: {previous_error}
--- TENTATIVO PRECEDENTE FALLITO --- Correggi e rispondi di nuovo con un singolo oggetto JSON valido
Output: {previous_raw} dentro fence ```json...```, seguendo strettamente lo schema fornito.
Errore: {previous_error}
---
Correggi l'errore e rispondi di nuovo con un singolo oggetto JSON valido
dentro fence ```json...```, seguendo strettamente lo schema fornito nel
SYSTEM message.
""" """
_RETRY_RAW_TRUNCATE = 800
_JSON_FENCE_RE = re.compile( _JSON_FENCE_RE = re.compile(
r"```(?:json)?\s*(\{[\s\S]*\})\s*```", r"```(?:json)?\s*(\{[\s\S]*\})\s*```",
@@ -425,34 +422,39 @@ class HypothesisAgent:
errors: list[str] = [] errors: list[str] = []
last_raw = "" last_raw = ""
max_attempts = 1 + self._max_retries max_attempts = 1 + self._max_retries
session_id: str | None = None
try:
for attempt in range(max_attempts): for attempt in range(max_attempts):
if attempt == 0: if attempt == 0:
user = original_user user = original_user
req_session_id = "new"
else: else:
truncated = last_raw[:_RETRY_RAW_TRUNCATE] user = _RETRY_TEMPLATE.format(previous_error=errors[-1])
user = _RETRY_TEMPLATE.format( req_session_id = session_id or "new"
original_user=original_user,
previous_raw=truncated,
previous_error=errors[-1],
)
try: try:
completion = self._llm.complete(genome, system=system, user=user) completion = self._llm.complete(
genome, system=system, user=user,
session_id=req_session_id,
summarize=True,
)
except EmptyCompletionError as e: 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}") errors.append(f"empty_completion: {e}")
last_raw = "" last_raw = ""
continue continue
except openai.RateLimitError as e: except OpusAgentTransientError as e:
# Provider upstream rate limited oltre i retry tenacity. errors.append(f"transient_error: {e}")
# Marca genome come fallito senza propagare l'eccezione al run. last_raw = ""
errors.append(f"rate_limit: {e}") continue
except OpusAgentError as e:
errors.append(f"opus_agent_error: {e}")
last_raw = "" last_raw = ""
continue continue
completions.append(completion) completions.append(completion)
last_raw = completion.text last_raw = completion.text
if completion.session_id:
session_id = completion.session_id
strategy, err = _try_parse(completion.text) strategy, err = _try_parse(completion.text)
if strategy is not None: if strategy is not None:
@@ -465,6 +467,9 @@ class HypothesisAgent:
) )
assert err is not None assert err is not None
errors.append(err) errors.append(err)
finally:
if session_id:
self._llm.close_session(session_id)
chained = " | ".join( chained = " | ".join(
f"attempt {i + 1}: {e}" for i, e in enumerate(errors) f"attempt {i + 1}: {e}" for i, e in enumerate(errors)
@@ -23,14 +23,14 @@ class Settings(BaseSettings):
cerbero_mainnet_token: SecretStr | None = None cerbero_mainnet_token: SecretStr | None = None
cerbero_bot_tag: str = "swarm-poc-phase1" cerbero_bot_tag: str = "swarm-poc-phase1"
openrouter_api_key: SecretStr opus_agent_api_key: SecretStr
opus_agent_base_url: str = "https://opus-agent.tielogic.xyz"
llm_model_tier_s: str = "google/gemini-3-flash-preview" llm_model_tier_s: str = "claude-opus-4-7"
llm_model_tier_a: str = "deepseek/deepseek-v4-flash" llm_model_tier_a: str = "claude-opus-4-7"
llm_model_tier_b: str = "deepseek/deepseek-v4-flash" llm_model_tier_b: str = "claude-sonnet-4-6"
llm_model_tier_c: str = "qwen/qwen-2.5-72b-instruct" llm_model_tier_c: str = "claude-sonnet-4-6"
llm_model_tier_d: str = "openai/gpt-oss-20b" llm_model_tier_d: str = "claude-haiku-4-5-20251001"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
run_name: str = "phase1-spike-001" run_name: str = "phase1-spike-001"
data_dir: Path = Field(default=Path("./data")) data_dir: Path = Field(default=Path("./data"))
@@ -263,7 +263,8 @@ def index() -> None:
refresh() refresh()
select.on_value_change(on_select_change) select.on_value_change(on_select_change)
ui.timer(REFRESH_INTERVAL_S, refresh) _timer = ui.timer(REFRESH_INTERVAL_S, refresh)
ui.context.client.on_disconnect(_timer.deactivate)
refresh() refresh()
@@ -353,7 +354,8 @@ def convergence() -> None:
refresh() refresh()
select.on_value_change(on_select_change) select.on_value_change(on_select_change)
ui.timer(REFRESH_INTERVAL_S, refresh) _timer = ui.timer(REFRESH_INTERVAL_S, refresh)
ui.context.client.on_disconnect(_timer.deactivate)
refresh() refresh()
@@ -535,7 +537,8 @@ def genomes() -> None:
select.on_value_change(on_select_change) select.on_value_change(on_select_change)
top_k_select.on_value_change(lambda _: refresh()) top_k_select.on_value_change(lambda _: refresh())
top_table.on("selection", on_row_selected) top_table.on("selection", on_row_selected)
ui.timer(REFRESH_INTERVAL_S, refresh) _timer = ui.timer(REFRESH_INTERVAL_S, refresh)
ui.context.client.on_disconnect(_timer.deactivate)
refresh() refresh()
@@ -8,11 +8,11 @@ from typing import Any
class ModelTier(StrEnum): class ModelTier(StrEnum):
S = "S" # top-tier reasoning (Opus / equivalent) via Anthropic S = "S" # top-tier reasoning → opus via OpusAgent
A = "A" # premium override via Anthropic A = "A" # premium → opus via OpusAgent
B = "B" # Sonnet 4.6 via Anthropic B = "B" # standard → sonnet via OpusAgent
C = "C" # Qwen 2.5 72B via OpenRouter C = "C" # default GA → sonnet via OpusAgent
D = "D" # ultra-economic (Llama / cheap models) via OpenRouter D = "D" # economic → haiku via OpusAgent
@dataclass @dataclass
@@ -101,6 +101,10 @@ class PromptLibrary:
domain_warnings: str = field(default="") # opzionale: warning di dominio (es. crypto 24/7) domain_warnings: str = field(default="") # opzionale: warning di dominio (es. crypto 24/7)
anti_patterns: str = field(default="") # NEW v3.1: lista esplicita di pattern da evitare anti_patterns: str = field(default="") # NEW v3.1: lista esplicita di pattern da evitare
output_priorities: str = field(default="") # NEW v3.1: trade-off espliciti di output output_priorities: str = field(default="") # NEW v3.1: trade-off espliciti di output
# NEW v3.2: firme formali (arity + range) di indicatori strategy-specific
# non noti al core. Iniettato in _SYSTEM_GRAMMAR_SPEC come estensione della
# lista "Leaf - indicatori" per consentire alla LLM di chiamarli correttamente.
custom_indicators_spec: str = field(default="")
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.styles: if not self.styles:
@@ -120,6 +124,7 @@ class PromptLibrary:
("domain_warnings", self.domain_warnings), ("domain_warnings", self.domain_warnings),
("anti_patterns", self.anti_patterns), ("anti_patterns", self.anti_patterns),
("output_priorities", self.output_priorities), ("output_priorities", self.output_priorities),
("custom_indicators_spec", self.custom_indicators_spec),
): ):
if not isinstance(value, str): if not isinstance(value, str):
raise PromptLibraryError( raise PromptLibraryError(
@@ -142,6 +147,7 @@ class PromptLibrary:
domain_warnings="", domain_warnings="",
anti_patterns="", anti_patterns="",
output_priorities="", output_priorities="",
custom_indicators_spec="",
) )
@classmethod @classmethod
@@ -204,6 +210,12 @@ class PromptLibrary:
raise PromptLibraryError(f"anti_patterns deve essere stringa, non {type(anti_patterns_raw)}") raise PromptLibraryError(f"anti_patterns deve essere stringa, non {type(anti_patterns_raw)}")
if not isinstance(output_priorities_raw, str): if not isinstance(output_priorities_raw, str):
raise PromptLibraryError(f"output_priorities deve essere stringa, non {type(output_priorities_raw)}") raise PromptLibraryError(f"output_priorities deve essere stringa, non {type(output_priorities_raw)}")
# Parse new optional top-level field (v3.2)
custom_indicators_spec_raw = data.get("custom_indicators_spec", "")
if not isinstance(custom_indicators_spec_raw, str):
raise PromptLibraryError(
f"custom_indicators_spec deve essere stringa, non {type(custom_indicators_spec_raw)}"
)
return cls( return cls(
styles=styles, styles=styles,
@@ -214,6 +226,7 @@ class PromptLibrary:
domain_warnings=domain_warnings, domain_warnings=domain_warnings,
anti_patterns=anti_patterns_raw, anti_patterns=anti_patterns_raw,
output_priorities=output_priorities_raw, output_priorities=output_priorities_raw,
custom_indicators_spec=custom_indicators_spec_raw,
) )
@property @property
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import logging
import threading
import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import openai import httpx
from openai import OpenAI
from tenacity import ( from tenacity import (
retry, retry,
retry_if_exception_type, retry_if_exception_type,
@@ -14,26 +16,33 @@ from tenacity import (
from ..genome.hypothesis import HypothesisAgentGenome, ModelTier from ..genome.hypothesis import HypothesisAgentGenome, ModelTier
# Modelli configurati per Phase 1 — tutti via OpenRouter logger = logging.getLogger(__name__)
MODEL_TIER_S = "google/gemini-3-flash-preview"
MODEL_TIER_A = "deepseek/deepseek-v4-flash" MODEL_TIER_MAP: dict[ModelTier, str] = {
MODEL_TIER_B = "deepseek/deepseek-v4-flash" ModelTier.S: "claude-opus-4-7",
MODEL_TIER_C = "qwen/qwen-2.5-72b-instruct" ModelTier.A: "claude-opus-4-7",
MODEL_TIER_D = "openai/gpt-oss-20b" ModelTier.B: "claude-sonnet-4-6",
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" ModelTier.C: "claude-sonnet-4-6",
ModelTier.D: "claude-haiku-4-5-20251001",
}
class EmptyCompletionError(RuntimeError): class EmptyCompletionError(RuntimeError):
pass pass
# Errori transient: retry. Auth/InvalidRequest: NO retry. class OpusAgentError(RuntimeError):
# RateLimitError (HTTP 429) ora retryable: provider OpenRouter come DeepInfra pass
# applicano rate limit upstream temporaneo, recuperabile con backoff.
class OpusAgentTransientError(RuntimeError):
pass
_RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = ( _RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
openai.APIConnectionError, httpx.ConnectError,
openai.APITimeoutError, httpx.TimeoutException,
openai.InternalServerError, OpusAgentTransientError,
openai.RateLimitError,
EmptyCompletionError, EmptyCompletionError,
) )
@@ -45,50 +54,99 @@ class CompletionResult:
output_tokens: int output_tokens: int
tier: ModelTier tier: ModelTier
model: str model: str
session_id: str | None = None
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, opus_agent_api_key: str,
model_tier_s: str = MODEL_TIER_S, opus_agent_base_url: str = "https://opus-agent.tielogic.xyz",
model_tier_a: str = MODEL_TIER_A, model_tier_s: str = "claude-opus-4-7",
model_tier_b: str = MODEL_TIER_B, model_tier_a: str = "claude-opus-4-7",
model_tier_c: str = MODEL_TIER_C, model_tier_b: str = "claude-sonnet-4-6",
model_tier_d: str = MODEL_TIER_D, model_tier_c: str = "claude-sonnet-4-6",
openrouter_base_url: str = OPENROUTER_BASE_URL, model_tier_d: str = "claude-haiku-4-5-20251001",
provider_ignore: tuple[str, ...] | None = None, poll_interval: float = 3.0,
poll_timeout: float = 180.0,
) -> None: ) -> None:
self.model_tier_s = model_tier_s self._base_url = opus_agent_base_url.rstrip("/")
self.model_tier_a = model_tier_a self._api_key = opus_agent_api_key
self.model_tier_b = model_tier_b self._tier_map: dict[ModelTier, str] = {
self.model_tier_c = model_tier_c
self.model_tier_d = model_tier_d
self.openrouter_base_url = openrouter_base_url
self._provider_ignore = (
provider_ignore if provider_ignore is not None else self.DEFAULT_PROVIDER_IGNORE
)
self._tier_models: dict[ModelTier, str] = {
ModelTier.S: model_tier_s, ModelTier.S: model_tier_s,
ModelTier.A: model_tier_a, ModelTier.A: model_tier_a,
ModelTier.B: model_tier_b, ModelTier.B: model_tier_b,
ModelTier.C: model_tier_c, ModelTier.C: model_tier_c,
ModelTier.D: model_tier_d, ModelTier.D: model_tier_d,
} }
# Timeout esplicito (60s) per prevenire hang infinito su connessioni self._poll_interval = poll_interval
# stallate. Tenacity retry su APITimeoutError gestisce il recovery. self._poll_timeout = poll_timeout
self._client = OpenAI( self._topic_cache: dict[str, str] = {}
api_key=openrouter_api_key, self._topic_lock = threading.Lock()
base_url=openrouter_base_url, self._client = httpx.Client(
timeout=60.0, base_url=self._base_url,
headers={"X-Api-Key": self._api_key, "Content-Type": "application/json"},
timeout=30.0,
) )
def _get_or_create_topic(self, system_prompt: str) -> str:
prompt_hash = hashlib.sha256(system_prompt.encode()).hexdigest()[:16]
if prompt_hash in self._topic_cache:
return self._topic_cache[prompt_hash]
with self._topic_lock:
if prompt_hash in self._topic_cache:
return self._topic_cache[prompt_hash]
topic_name = f"swarm-{prompt_hash}"
resp = self._client.post("/api/topics", json={
"name": topic_name,
"system_prompt": system_prompt,
})
if resp.status_code == 409:
list_resp = self._client.get("/api/topics")
list_resp.raise_for_status()
for topic in list_resp.json()["data"]:
if topic["name"] == topic_name:
self._topic_cache[prompt_hash] = topic["id"]
return topic["id"]
raise OpusAgentError(f"Topic {topic_name} conflict but not found")
if resp.status_code >= 500:
raise OpusAgentTransientError(f"Server error {resp.status_code}")
resp.raise_for_status()
topic_id = resp.json()["data"]["id"]
self._topic_cache[prompt_hash] = topic_id
logger.debug("Created topic %s -> %s", topic_name, topic_id)
return topic_id
def _poll_result(self, request_id: str) -> dict:
deadline = time.monotonic() + self._poll_timeout
while time.monotonic() < deadline:
resp = self._client.get(f"/api/requests/{request_id}")
resp.raise_for_status()
data = resp.json()["data"]
status = data["status"]
if status == "completed":
return data
if status == "failed":
error = data.get("error") or "unknown error"
raise OpusAgentError(f"Request {request_id} failed: {error}")
time.sleep(self._poll_interval)
raise OpusAgentTransientError(
f"Request {request_id} timed out after {self._poll_timeout}s"
)
def close_session(self, session_id: str) -> None:
try:
self._client.delete(f"/api/sessions/{session_id}")
except httpx.HTTPError:
logger.debug("Failed to close session %s", session_id)
@retry( @retry(
stop=stop_after_attempt(5), stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2.0, min=2.0, max=30.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,
@@ -99,29 +157,45 @@ class LLMClient:
system: str, system: str,
user: str, user: str,
max_tokens: int = 2000, max_tokens: int = 2000,
session_id: str | None = None,
summarize: bool = False,
) -> CompletionResult: ) -> CompletionResult:
model = self._tier_models[genome.model_tier] model = self._tier_map[genome.model_tier]
extra_body: dict[str, Any] = {} topic_id = self._get_or_create_topic(system)
if self._provider_ignore:
extra_body["provider"] = {"ignore": list(self._provider_ignore)} prompt = f"[SYSTEM]\n{system}\n\n[USER]\n{user}" if session_id in (None, "new") else user
resp = self._client.chat.completions.create(
model=model, body: dict = {
messages=[ "topic_id": topic_id,
{"role": "system", "content": system}, "prompt": prompt,
{"role": "user", "content": user}, "model": model,
], }
temperature=genome.temperature, if session_id is not None:
top_p=genome.top_p, body["session_id"] = session_id
max_tokens=max_tokens, if summarize:
extra_body=extra_body or None, body["summarize"] = True
)
if not resp.choices or resp.choices[0].message.content is None: resp = self._client.post("/api/requests", json=body)
raise EmptyCompletionError(f"empty response from {model}")
usage = resp.usage if resp.status_code == 429:
raise OpusAgentTransientError("Rate limited")
if resp.status_code >= 500:
raise OpusAgentTransientError(f"Server error {resp.status_code}")
if resp.status_code != 202:
raise OpusAgentError(f"Unexpected status {resp.status_code}: {resp.text}")
request_id = resp.json()["data"]["id"]
result = self._poll_result(request_id)
text = result.get("result") or ""
if not text:
raise EmptyCompletionError(f"empty response from OpusAgent ({model})")
return CompletionResult( return CompletionResult(
text=resp.choices[0].message.content, text=text,
input_tokens=usage.prompt_tokens if usage else 0, input_tokens=0,
output_tokens=usage.completion_tokens if usage else 0, output_tokens=0,
tier=genome.model_tier, tier=genome.model_tier,
model=model, model=model,
session_id=result.get("session_id"),
) )
@@ -75,8 +75,8 @@ class RunConfig:
# strategy_crypto/prompts.json via PromptLibrary.from_json(). # strategy_crypto/prompts.json via PromptLibrary.from_json().
prompt_library: PromptLibrary | None = None prompt_library: PromptLibrary | None = None
# Numero di propose() LLM concorrenti per generazione. 1 = sequenziale (default, # Numero di propose() LLM concorrenti per generazione. 1 = sequenziale (default,
# backward compat). 6-10 tipicamente accettati da OpenRouter qwen-2.5 senza # backward compat). OpusAgent processa in coda FIFO; concurrency > 1
# rate-limit. Riduce wall time GA loop di 5-8x su tier C. # accoda piu' richieste in parallelo ma il throughput dipende dal server.
llm_concurrency: int = 1 llm_concurrency: int = 1
@@ -90,7 +90,7 @@ def _parallel_propose(
``n_workers <= 1`` mantiene il comportamento serial originale (ordine fisso, ``n_workers <= 1`` mantiene il comportamento serial originale (ordine fisso,
determinismo data un seed). ``n_workers > 1`` usa un thread pool: l'order determinismo data un seed). ``n_workers > 1`` usa un thread pool: l'order
dei risultati e' preservato (1:1 con ``genomes``). OpenAI/openrouter client dei risultati e' preservato (1:1 con ``genomes``). LLMClient (OpusAgent)
e' thread-safe; ``PromptLibrary`` e ``HypothesisAgent`` non hanno stato mutabile. e' thread-safe; ``PromptLibrary`` e ``HypothesisAgent`` non hanno stato mutabile.
""" """
if n_workers <= 1 or len(genomes) <= 1: if n_workers <= 1 or len(genomes) <= 1:
@@ -25,6 +25,12 @@ from typing import Any
import numpy as np import numpy as np
import pandas as pd # type: ignore[import-untyped] import pandas as pd # type: ignore[import-untyped]
from strategy_pythagoras.indicators import (
candle_pattern as _public_candle_pattern,
fractal_mirror as _public_fractal_mirror,
pythagorean_ratio as _public_pythagorean_ratio,
)
from ..backtest.orders import Side from ..backtest.orders import Side
from .parser import ( from .parser import (
FeatureNode, FeatureNode,
@@ -126,6 +132,22 @@ def _ind_macd(
return macd_line - signal_line return macd_line - signal_line
def _ind_candle_pattern(df: pd.DataFrame, *params: float) -> pd.Series:
# Adapter: il dispatch in _eval_node fa ``fn(df, *node.params)``, ma la
# public API in strategy_pythagoras.indicators accetta ``params: list[float]``
# come singolo argomento. Re-pack qui per mantenere indicators.py testabile
# in isolamento.
return _public_candle_pattern(df, list(params))
def _ind_pythagorean_ratio(df: pd.DataFrame, lookback: float) -> pd.Series:
return _public_pythagorean_ratio(df, [lookback])
def _ind_fractal_mirror(df: pd.DataFrame, k: float, axis_int: float) -> pd.Series:
return _public_fractal_mirror(df, [k, axis_int])
# Annotated as ``dict[str, Any]`` deliberately: each indicator has its own # Annotated as ``dict[str, Any]`` deliberately: each indicator has its own
# arity and parameter names, so a single ``Callable`` signature would be a # arity and parameter names, so a single ``Callable`` signature would be a
# lie. Dispatch happens in :func:`_eval_node`, which validates the verb name # lie. Dispatch happens in :func:`_eval_node`, which validates the verb name
@@ -139,6 +161,9 @@ INDICATOR_FNS: dict[str, Any] = {
"realized_vol": _ind_realized_vol, "realized_vol": _ind_realized_vol,
"macd": _ind_macd, "macd": _ind_macd,
"macd_pct": _ind_macd_pct, "macd_pct": _ind_macd_pct,
"candle_pattern": _ind_candle_pattern,
"pythagorean_ratio": _ind_pythagorean_ratio,
"fractal_mirror": _ind_fractal_mirror,
} }
_TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = { _TIME_FEATURE_FNS: dict[str, Callable[[pd.DatetimeIndex], pd.Series]] = {
@@ -17,7 +17,8 @@ ACTION_VALUES: frozenset[str] = frozenset(
KIND_VALUES: frozenset[str] = frozenset({"indicator", "feature", "literal"}) KIND_VALUES: frozenset[str] = frozenset({"indicator", "feature", "literal"})
KNOWN_INDICATORS: frozenset[str] = frozenset( KNOWN_INDICATORS: frozenset[str] = frozenset(
{"sma", "sma_pct", "rsi", "atr", "atr_pct", "macd", "macd_pct", "realized_vol"} {"sma", "sma_pct", "rsi", "atr", "atr_pct", "macd", "macd_pct", "realized_vol",
"candle_pattern", "pythagorean_ratio", "fractal_mirror"}
) )
KNOWN_FEATURES: frozenset[str] = frozenset( KNOWN_FEATURES: frozenset[str] = frozenset(
{"open", "high", "low", "close", "volume", {"open", "high", "low", "close", "volume",
@@ -39,6 +39,10 @@ INDICATOR_ARITY: dict[str, tuple[int, int]] = {
"realized_vol": (1, 1), # window "realized_vol": (1, 1), # window
"macd": (0, 3), # fast, slow, signal (tutti opzionali) "macd": (0, 3), # fast, slow, signal (tutti opzionali)
"macd_pct": (0, 3), # macd/close, frazionale (per confronti con literal) "macd_pct": (0, 3), # macd/close, frazionale (per confronti con literal)
# Pythagoras indicators (params encoded as floats)
"candle_pattern": (4, 13), # [length, sym0, ..., sym_{length-1}]
"pythagorean_ratio": (1, 1), # lookback in [12,200]
"fractal_mirror": (2, 2), # k in [3,12], axis_int in {0=h,1=v}
} }
@@ -110,3 +114,37 @@ def _validate_indicator(node: IndicatorNode) -> None:
raise ValidationError( raise ValidationError(
f"indicator '{node.name}' arity {n_params} out of [{min_p},{max_p}]" f"indicator '{node.name}' arity {n_params} out of [{min_p},{max_p}]"
) )
# Pythagoras-specific param semantics
name = node.name
if name == "candle_pattern":
length = int(node.params[0])
if not (3 <= length <= 12):
raise ValidationError(
f"candle_pattern length must be in [3,12], got {length}"
)
if n_params != 1 + length:
raise ValidationError(
f"candle_pattern: expected 1+length={1 + length} params, got {n_params}"
)
for i, sym in enumerate(node.params[1:], start=1):
sym_int = int(sym)
if sym_int not in (0, 1, 2):
raise ValidationError(
f"candle_pattern sym[{i - 1}] must be 0(U)/1(D)/2(doji), got {sym}"
)
elif name == "pythagorean_ratio":
lookback = int(node.params[0])
if not (12 <= lookback <= 200):
raise ValidationError(
f"pythagorean_ratio lookback in [12,200], got {lookback}"
)
elif name == "fractal_mirror":
k = int(node.params[0])
if not (3 <= k <= 12):
raise ValidationError(f"fractal_mirror k must be in [3,12], got {k}")
axis_int = int(node.params[1])
if axis_int not in (0, 1):
raise ValidationError(
f"fractal_mirror axis must be 0(h)/1(v), got {axis_int}"
)
+16 -32
View File
@@ -18,13 +18,14 @@ def test_settings_loads_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test") monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test")
monkeypatch.setenv("CERBERO_MAINNET_TOKEN", "tok-main") monkeypatch.setenv("CERBERO_MAINNET_TOKEN", "tok-main")
monkeypatch.setenv("CERBERO_BOT_TAG", "swarm-poc-phase1") monkeypatch.setenv("CERBERO_BOT_TAG", "swarm-poc-phase1")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("OPUS_AGENT_API_KEY", "oa-key")
monkeypatch.setenv("RUN_NAME", "test-run") monkeypatch.setenv("RUN_NAME", "test-run")
s = Settings() # type: ignore[call-arg] s = Settings() # type: ignore[call-arg]
assert s.cerbero_base_url == "http://test:9000" assert s.cerbero_base_url == "http://test:9000"
assert s.cerbero_testnet_token.get_secret_value() == "tok-test" assert s.cerbero_testnet_token.get_secret_value() == "tok-test"
assert s.opus_agent_api_key.get_secret_value() == "oa-key"
assert s.run_name == "test-run" assert s.run_name == "test-run"
assert s.data_dir.name == "data" assert s.data_dir.name == "data"
assert s.db_path.name == "runs.db" assert s.db_path.name == "runs.db"
@@ -32,50 +33,33 @@ def test_settings_loads_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
def test_settings_requires_tokens(monkeypatch: pytest.MonkeyPatch) -> None: def test_settings_requires_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CERBERO_TESTNET_TOKEN", raising=False) monkeypatch.delenv("CERBERO_TESTNET_TOKEN", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.delenv("OPUS_AGENT_API_KEY", raising=False)
from pydantic import ValidationError from pydantic import ValidationError
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
# Disable .env loading to keep the test deterministic regardless of
# whether a developer's local .env exists and is populated.
Settings(_env_file=None) # type: ignore[call-arg] Settings(_env_file=None) # type: ignore[call-arg]
def test_settings_loads_llm_model_overrides(monkeypatch: pytest.MonkeyPatch) -> None: def test_settings_opus_agent_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test") monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("OPUS_AGENT_API_KEY", "oa-key")
monkeypatch.setenv("LLM_MODEL_TIER_S", "claude-mega-x") monkeypatch.delenv("OPUS_AGENT_BASE_URL", raising=False)
monkeypatch.setenv("LLM_MODEL_TIER_A", "claude-premium-y")
monkeypatch.setenv("LLM_MODEL_TIER_B", "claude-opus-4-7")
monkeypatch.setenv("LLM_MODEL_TIER_C", "deepseek/deepseek-chat")
monkeypatch.setenv("LLM_MODEL_TIER_D", "mistralai/mistral-7b")
monkeypatch.setenv("OPENROUTER_BASE_URL", "https://example.com/api/v1")
s = Settings(_env_file=None) # type: ignore[call-arg] s = Settings(_env_file=None) # type: ignore[call-arg]
assert s.llm_model_tier_s == "claude-mega-x" assert s.opus_agent_base_url == "https://opus-agent.tielogic.xyz"
assert s.llm_model_tier_a == "claude-premium-y" assert s.llm_model_tier_s == "claude-opus-4-7"
assert s.llm_model_tier_b == "claude-opus-4-7" assert s.llm_model_tier_a == "claude-opus-4-7"
assert s.llm_model_tier_c == "deepseek/deepseek-chat" assert s.llm_model_tier_b == "claude-sonnet-4-6"
assert s.llm_model_tier_d == "mistralai/mistral-7b" assert s.llm_model_tier_c == "claude-sonnet-4-6"
assert s.openrouter_base_url == "https://example.com/api/v1" assert s.llm_model_tier_d == "claude-haiku-4-5-20251001"
def test_settings_llm_model_defaults(monkeypatch: pytest.MonkeyPatch) -> None: def test_settings_opus_agent_base_url_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test") monkeypatch.setenv("CERBERO_TESTNET_TOKEN", "tok-test")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("OPUS_AGENT_API_KEY", "oa-key")
monkeypatch.delenv("LLM_MODEL_TIER_S", raising=False) monkeypatch.setenv("OPUS_AGENT_BASE_URL", "https://custom.example.com")
monkeypatch.delenv("LLM_MODEL_TIER_A", raising=False)
monkeypatch.delenv("LLM_MODEL_TIER_B", raising=False)
monkeypatch.delenv("LLM_MODEL_TIER_C", raising=False)
monkeypatch.delenv("LLM_MODEL_TIER_D", raising=False)
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
s = Settings(_env_file=None) # type: ignore[call-arg] s = Settings(_env_file=None) # type: ignore[call-arg]
assert s.llm_model_tier_s == "google/gemini-3-flash-preview" assert s.opus_agent_base_url == "https://custom.example.com"
assert s.llm_model_tier_a == "deepseek/deepseek-v4-flash"
assert s.llm_model_tier_b == "deepseek/deepseek-v4-flash"
assert s.llm_model_tier_c == "qwen/qwen-2.5-72b-instruct"
assert s.llm_model_tier_d == "openai/gpt-oss-20b"
assert s.openrouter_base_url == "https://openrouter.ai/api/v1"
@@ -420,3 +420,48 @@ def test_build_system_prompt_skips_anti_patterns_and_priorities_when_empty(mocke
system_msg = call_kwargs["system"] system_msg = call_kwargs["system"]
assert "ANTI-PATTERN" not in system_msg assert "ANTI-PATTERN" not in system_msg
assert "PRIORITA' DI OUTPUT" not in system_msg assert "PRIORITA' DI OUTPUT" not in system_msg
def test_build_system_prompt_includes_custom_indicators_spec(mocker): # type: ignore[no-untyped-def]
"""custom_indicators_spec da PromptLibrary appare nel SYSTEM prompt con header."""
fake_llm = mocker.MagicMock()
fake_llm.complete.return_value = CompletionResult(
text=VALID_STRATEGY_JSON,
input_tokens=200,
output_tokens=80,
tier=ModelTier.C,
model="qwen",
)
lib = PromptLibrary(
styles={"physicist": "Pensa come un fisico."},
focus={},
custom_indicators_spec="CUSTOM_IND_X",
)
agent = HypothesisAgent(llm=fake_llm, prompt_library=lib)
agent.propose(make_genome(), make_summary())
call_kwargs = fake_llm.complete.call_args.kwargs
system_msg = call_kwargs["system"]
assert "INDICATORI CUSTOM" in system_msg
assert "CUSTOM_IND_X" in system_msg
def test_build_system_prompt_skips_custom_indicators_spec_when_empty(mocker): # type: ignore[no-untyped-def]
"""custom_indicators_spec='' -> sezione assente nel SYSTEM prompt."""
fake_llm = mocker.MagicMock()
fake_llm.complete.return_value = CompletionResult(
text=VALID_STRATEGY_JSON,
input_tokens=200,
output_tokens=80,
tier=ModelTier.C,
model="qwen",
)
lib = PromptLibrary(
styles={"physicist": "Pensa come un fisico."},
focus={},
custom_indicators_spec="",
)
agent = HypothesisAgent(llm=fake_llm, prompt_library=lib)
agent.propose(make_genome(), make_summary())
call_kwargs = fake_llm.complete.call_args.kwargs
system_msg = call_kwargs["system"]
assert "INDICATORI CUSTOM" not in system_msg
+229 -191
View File
@@ -1,7 +1,14 @@
import httpx
import pytest import pytest
from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier from multi_swarm_core.genome.hypothesis import HypothesisAgentGenome, ModelTier
from multi_swarm_core.llm.client import CompletionResult, LLMClient from multi_swarm_core.llm.client import (
CompletionResult,
EmptyCompletionError,
LLMClient,
OpusAgentError,
OpusAgentTransientError,
)
def make_genome(tier: ModelTier) -> HypothesisAgentGenome: def make_genome(tier: ModelTier) -> HypothesisAgentGenome:
@@ -16,217 +23,248 @@ def make_genome(tier: ModelTier) -> HypothesisAgentGenome:
) )
def test_completion_tier_c_uses_openrouter(mocker): TOPIC_RESPONSE = {
fake_openai = mocker.MagicMock() "success": True,
fake_response = mocker.MagicMock() "data": {"id": "topic-123", "name": "swarm-test", "system_prompt": "sys"},
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))] }
fake_response.usage = mocker.MagicMock(prompt_tokens=100, completion_tokens=200)
fake_openai.chat.completions.create.return_value = fake_response
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai) REQUEST_ACCEPTED = {
"success": True,
"data": {"id": "req-456", "session_id": None, "status": "pending"},
}
client = LLMClient(openrouter_api_key="or-x")
g = make_genome(ModelTier.C) def _completed_response(
out = client.complete(g, system="sys", user="usr") text: str = "(strategy ...)", session_id: str = "sess-789",
) -> dict:
return {
"success": True,
"data": {
"id": "req-456",
"status": "completed",
"result": text,
"session_id": session_id,
},
}
def _mock_transport(responses: list[httpx.Response]) -> httpx.MockTransport:
call_idx = {"i": 0}
def handler(request: httpx.Request) -> httpx.Response:
idx = call_idx["i"]
call_idx["i"] += 1
if idx < len(responses):
return responses[idx]
return responses[-1]
return httpx.MockTransport(handler)
def _make_client(transport: httpx.MockTransport) -> LLMClient:
client = LLMClient(opus_agent_api_key="test-key", poll_interval=0.01, poll_timeout=5.0)
client._client = httpx.Client(
base_url="https://opus-agent.tielogic.xyz",
headers={"X-Api-Key": "test-key", "Content-Type": "application/json"},
transport=transport,
)
return client
def test_complete_tier_c_uses_sonnet():
transport = _mock_transport([
httpx.Response(201, json=TOPIC_RESPONSE),
httpx.Response(202, json=REQUEST_ACCEPTED),
httpx.Response(200, json=_completed_response()),
])
client = _make_client(transport)
out = client.complete(make_genome(ModelTier.C), system="sys", user="usr")
assert isinstance(out, CompletionResult) assert isinstance(out, CompletionResult)
assert out.text == "(strategy ...)" assert out.text == "(strategy ...)"
assert out.input_tokens == 100 assert out.model == "claude-sonnet-4-6"
assert out.output_tokens == 200
assert out.tier == ModelTier.C assert out.tier == ModelTier.C
fake_openai.chat.completions.create.assert_called_once()
def test_completion_tier_b_uses_openrouter_with_anthropic_model(mocker): def test_complete_tier_s_uses_opus():
fake_openai = mocker.MagicMock() transport = _mock_transport([
fake_response = mocker.MagicMock() httpx.Response(201, json=TOPIC_RESPONSE),
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))] httpx.Response(202, json=REQUEST_ACCEPTED),
fake_response.usage = mocker.MagicMock(prompt_tokens=80, completion_tokens=150) httpx.Response(200, json=_completed_response("(strategy s)")),
fake_openai.chat.completions.create.return_value = fake_response ])
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai) client = _make_client(transport)
out = client.complete(make_genome(ModelTier.S), system="sys", user="usr")
client = LLMClient(openrouter_api_key="or-x") assert out.model == "claude-opus-4-7"
g = make_genome(ModelTier.B)
out = client.complete(g, system="sys", user="usr")
assert out.text == "(strategy ...)"
assert out.input_tokens == 80
assert out.output_tokens == 150
assert out.tier == ModelTier.B
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
assert call_kwargs["model"] == "deepseek/deepseek-v4-flash"
assert out.model == "deepseek/deepseek-v4-flash"
@pytest.mark.slow
def test_completion_retries_on_connection_error(mocker):
"""Retry esegue 3 tentativi su APIConnectionError, poi rilancia."""
import openai
fake_openai = mocker.MagicMock()
fake_openai.chat.completions.create.side_effect = openai.APIConnectionError(
request=mocker.MagicMock()
)
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
client = LLMClient(openrouter_api_key="or-x")
g = make_genome(ModelTier.C)
with pytest.raises(openai.APIConnectionError):
client.complete(g, system="sys", user="usr")
assert fake_openai.chat.completions.create.call_count == 5
def test_completion_uses_custom_model_tier_c(mocker):
fake_openai = mocker.MagicMock()
fake_response = mocker.MagicMock()
fake_response.choices = [
mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))
]
fake_response.usage = mocker.MagicMock(prompt_tokens=10, completion_tokens=20)
fake_openai.chat.completions.create.return_value = fake_response
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
client = LLMClient(
openrouter_api_key="or-x",
model_tier_c="deepseek/deepseek-chat",
)
g = make_genome(ModelTier.C)
out = client.complete(g, system="sys", user="usr")
fake_openai.chat.completions.create.assert_called_once()
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
assert call_kwargs["model"] == "deepseek/deepseek-chat"
assert out.model == "deepseek/deepseek-chat"
def test_completion_uses_custom_model_tier_b(mocker):
fake_openai = mocker.MagicMock()
fake_response = mocker.MagicMock()
fake_response.choices = [
mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))
]
fake_response.usage = mocker.MagicMock(prompt_tokens=10, completion_tokens=20)
fake_openai.chat.completions.create.return_value = fake_response
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
client = LLMClient(
openrouter_api_key="or-x",
model_tier_b="anthropic/claude-opus-4-7",
)
g = make_genome(ModelTier.B)
out = client.complete(g, system="sys", user="usr")
fake_openai.chat.completions.create.assert_called_once()
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
assert call_kwargs["model"] == "anthropic/claude-opus-4-7"
assert out.model == "anthropic/claude-opus-4-7"
def test_completion_tier_s_uses_openrouter_with_anthropic_model(mocker):
fake_openai = mocker.MagicMock()
fake_response = mocker.MagicMock()
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy s)"))]
fake_response.usage = mocker.MagicMock(prompt_tokens=50, completion_tokens=100)
fake_openai.chat.completions.create.return_value = fake_response
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
client = LLMClient(openrouter_api_key="or-x")
g = make_genome(ModelTier.S)
out = client.complete(g, system="sys", user="usr")
fake_openai.chat.completions.create.assert_called_once()
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
assert call_kwargs["model"] == "google/gemini-3-flash-preview"
assert out.tier == ModelTier.S assert out.tier == ModelTier.S
assert out.model == "google/gemini-3-flash-preview" assert out.text == "(strategy s)"
def test_completion_tier_a_uses_openrouter_with_anthropic_model(mocker): def test_complete_tier_d_uses_haiku():
fake_openai = mocker.MagicMock() transport = _mock_transport([
fake_response = mocker.MagicMock() httpx.Response(201, json=TOPIC_RESPONSE),
fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy a)"))] httpx.Response(202, json=REQUEST_ACCEPTED),
fake_response.usage = mocker.MagicMock(prompt_tokens=40, completion_tokens=80) httpx.Response(200, json=_completed_response("(strategy d)")),
fake_openai.chat.completions.create.return_value = fake_response ])
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai) client = _make_client(transport)
out = client.complete(make_genome(ModelTier.D), system="sys", user="usr")
client = LLMClient(openrouter_api_key="or-x") assert out.model == "claude-haiku-4-5-20251001"
g = make_genome(ModelTier.A)
out = client.complete(g, system="sys", user="usr")
fake_openai.chat.completions.create.assert_called_once()
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
assert call_kwargs["model"] == "deepseek/deepseek-v4-flash"
assert out.tier == ModelTier.A
assert out.model == "deepseek/deepseek-v4-flash"
def test_completion_tier_d_uses_openrouter_with_llama(mocker):
fake_openai = mocker.MagicMock()
fake_response = mocker.MagicMock()
fake_response.choices = [
mocker.MagicMock(message=mocker.MagicMock(content="(strategy d)"))
]
fake_response.usage = mocker.MagicMock(prompt_tokens=30, completion_tokens=70)
fake_openai.chat.completions.create.return_value = fake_response
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
client = LLMClient(openrouter_api_key="or-x")
g = make_genome(ModelTier.D)
out = client.complete(g, system="sys", user="usr")
fake_openai.chat.completions.create.assert_called_once()
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs
assert call_kwargs["model"] == "openai/gpt-oss-20b"
assert out.tier == ModelTier.D assert out.tier == ModelTier.D
assert out.model == "openai/gpt-oss-20b"
def test_completion_uses_custom_model_tier_s(mocker): def test_topic_cached_on_second_call():
fake_openai = mocker.MagicMock() transport = _mock_transport([
fake_response = mocker.MagicMock() httpx.Response(201, json=TOPIC_RESPONSE),
fake_response.choices = [ httpx.Response(202, json=REQUEST_ACCEPTED),
mocker.MagicMock(message=mocker.MagicMock(content="(strategy custom-s)")) httpx.Response(200, json=_completed_response()),
] httpx.Response(202, json=REQUEST_ACCEPTED),
fake_response.usage = mocker.MagicMock(prompt_tokens=10, completion_tokens=20) httpx.Response(200, json=_completed_response("second")),
fake_openai.chat.completions.create.return_value = fake_response ])
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai) client = _make_client(transport)
client.complete(make_genome(ModelTier.C), system="sys", user="usr1")
out2 = client.complete(make_genome(ModelTier.C), system="sys", user="usr2")
client = LLMClient( assert out2.text == "second"
openrouter_api_key="or-x",
model_tier_s="anthropic/claude-future-mega",
def test_topic_conflict_409_recovers():
transport = _mock_transport([
httpx.Response(409, json={"success": False, "error": {"code": "CONFLICT"}}),
httpx.Response(200, json={"success": True, "data": [
{"id": "topic-existing", "name": "swarm-wrong"},
]}),
])
client = _make_client(transport)
with pytest.raises(OpusAgentError, match="conflict but not found"):
client.complete(make_genome(ModelTier.C), system="sys", user="usr")
def test_empty_response_raises():
transport = _mock_transport([
httpx.Response(201, json=TOPIC_RESPONSE),
httpx.Response(202, json=REQUEST_ACCEPTED),
httpx.Response(200, json={
"success": True,
"data": {"id": "req-456", "status": "completed", "result": ""},
}),
])
client = _make_client(transport)
with pytest.raises(EmptyCompletionError):
client.complete(make_genome(ModelTier.C), system="sys", user="usr")
def test_request_failed_raises_opus_agent_error():
transport = _mock_transport([
httpx.Response(201, json=TOPIC_RESPONSE),
httpx.Response(202, json=REQUEST_ACCEPTED),
httpx.Response(200, json={
"success": True,
"data": {"id": "req-456", "status": "failed", "error": "model overloaded"},
}),
])
client = _make_client(transport)
with pytest.raises(OpusAgentError, match="failed"):
client.complete(make_genome(ModelTier.C), system="sys", user="usr")
def test_rate_limit_429_is_retryable():
transport = _mock_transport([
httpx.Response(201, json=TOPIC_RESPONSE),
httpx.Response(429, json={"success": False}),
httpx.Response(202, json=REQUEST_ACCEPTED),
httpx.Response(200, json=_completed_response("after retry")),
])
client = _make_client(transport)
out = client.complete(make_genome(ModelTier.C), system="sys", user="usr")
assert out.text == "after retry"
def test_polling_waits_for_completion():
transport = _mock_transport([
httpx.Response(201, json=TOPIC_RESPONSE),
httpx.Response(202, json=REQUEST_ACCEPTED),
httpx.Response(200, json={
"success": True,
"data": {"id": "req-456", "status": "processing"},
}),
httpx.Response(200, json={
"success": True,
"data": {"id": "req-456", "status": "processing"},
}),
httpx.Response(200, json=_completed_response("done")),
])
client = _make_client(transport)
out = client.complete(make_genome(ModelTier.C), system="sys", user="usr")
assert out.text == "done"
def test_tokens_are_zero():
transport = _mock_transport([
httpx.Response(201, json=TOPIC_RESPONSE),
httpx.Response(202, json=REQUEST_ACCEPTED),
httpx.Response(200, json=_completed_response()),
])
client = _make_client(transport)
out = client.complete(make_genome(ModelTier.C), system="sys", user="usr")
assert out.input_tokens == 0
assert out.output_tokens == 0
def test_session_id_returned_from_completion():
transport = _mock_transport([
httpx.Response(201, json=TOPIC_RESPONSE),
httpx.Response(202, json=REQUEST_ACCEPTED),
httpx.Response(200, json=_completed_response(session_id="sess-abc")),
])
client = _make_client(transport)
out = client.complete(make_genome(ModelTier.C), system="sys", user="usr", session_id="new")
assert out.session_id == "sess-abc"
def test_session_id_and_summarize_sent_in_request():
requests_seen: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST" and "/requests" in str(request.url):
import json
requests_seen.append(json.loads(request.content))
return httpx.Response(202, json=REQUEST_ACCEPTED)
if request.method == "POST" and "/topics" in str(request.url):
return httpx.Response(201, json=TOPIC_RESPONSE)
return httpx.Response(200, json=_completed_response())
transport = httpx.MockTransport(handler)
client = _make_client(transport)
client.complete(
make_genome(ModelTier.C), system="sys", user="usr",
session_id="sess-existing", summarize=True,
) )
g = make_genome(ModelTier.S)
out = client.complete(g, system="sys", user="usr")
call_kwargs = fake_openai.chat.completions.create.call_args.kwargs assert len(requests_seen) == 1
assert call_kwargs["model"] == "anthropic/claude-future-mega" assert requests_seen[0]["session_id"] == "sess-existing"
assert out.model == "anthropic/claude-future-mega" assert requests_seen[0]["summarize"] is True
@pytest.mark.slow def test_close_session():
def test_completion_succeeds_after_one_retry(mocker): deleted: list[str] = []
"""Dopo 1 fallimento transient, il retry riesce al 2 tentativo."""
import openai
fake_response = mocker.MagicMock() def handler(request: httpx.Request) -> httpx.Response:
fake_response.choices = [ if request.method == "DELETE":
mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)")) deleted.append(str(request.url))
] return httpx.Response(200, json={"success": True})
fake_response.usage = mocker.MagicMock(prompt_tokens=100, completion_tokens=200) return httpx.Response(200, json={"success": True})
fake_openai = mocker.MagicMock() transport = httpx.MockTransport(handler)
fake_openai.chat.completions.create.side_effect = [ client = _make_client(transport)
openai.APITimeoutError(request=mocker.MagicMock()), client.close_session("sess-to-close")
fake_response,
]
mocker.patch("multi_swarm_core.llm.client.OpenAI", return_value=fake_openai)
client = LLMClient(openrouter_api_key="or-x") assert len(deleted) == 1
g = make_genome(ModelTier.C) assert "sess-to-close" in deleted[0]
out = client.complete(g, system="sys", user="usr")
assert isinstance(out, CompletionResult)
assert out.text == "(strategy ...)"
assert fake_openai.chat.completions.create.call_count == 2
@@ -90,6 +90,28 @@ def test_from_json_loads_anti_patterns_and_output_priorities(tmp_path: Path) ->
assert lib.output_priorities == "Robustezza > ottimalita." assert lib.output_priorities == "Robustezza > ottimalita."
def test_from_json_loads_custom_indicators_spec(tmp_path: Path) -> None:
"""from_json() legge custom_indicators_spec (v3.2): firme di indicatori strategy-specific."""
data = {
"styles": {"physicist": {"directive": "Cerca leggi conservative."}},
"custom_indicators_spec": (
"candle_pattern: params=[length, sym0, sym1, ...], length in [3,12], "
"sym in {0=U,1=D,2=doji}\n"
"pythagorean_ratio: params=[lookback], lookback in [12,200]"
),
}
lib = PromptLibrary.from_json(_write_json(data, tmp_path))
assert "candle_pattern" in lib.custom_indicators_spec
assert "lookback in [12,200]" in lib.custom_indicators_spec
def test_from_json_defaults_custom_indicators_spec_when_absent(tmp_path: Path) -> None:
"""custom_indicators_spec assente -> default stringa vuota."""
data = {"styles": {"physicist": {"directive": "Cerca leggi conservative."}}}
lib = PromptLibrary.from_json(_write_json(data, tmp_path))
assert lib.custom_indicators_spec == ""
def test_strategy_crypto_directives_ascii_safe() -> None: def test_strategy_crypto_directives_ascii_safe() -> None:
"""REGRESSION GUARD: nessuna directive contiene caratteri > U+007F. """REGRESSION GUARD: nessuna directive contiene caratteri > U+007F.
@@ -7,6 +7,7 @@ from __future__ import annotations
import json import json
import sqlite3 import sqlite3
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,9 +15,18 @@ import pandas as pd # type: ignore[import-untyped]
def _paper_conn(db_path: str | Path) -> sqlite3.Connection: def _paper_conn(db_path: str | Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path)) # Cold-start race: GUI può avviarsi prima che il paper writer crei il file.
db_path_str = str(db_path)
deadline = time.monotonic() + 5.0
while True:
try:
conn = sqlite3.connect(db_path_str, timeout=5.0)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
except sqlite3.OperationalError:
if time.monotonic() >= deadline:
raise
time.sleep(1.0)
def paper_runs_df(db_path: str | Path) -> pd.DataFrame: def paper_runs_df(db_path: str | Path) -> pd.DataFrame:
+4 -2
View File
@@ -34,8 +34,10 @@ def test_frontend_imports() -> None:
def test_strategies_json_loadable() -> None: def test_strategies_json_loadable() -> None:
files = importlib.resources.files("strategy_crypto") / "strategies" files = importlib.resources.files("strategy_crypto") / "strategies"
found = sorted(p.name for p in files.iterdir() if p.name.endswith(".json")) found = sorted(p.name for p in files.iterdir() if p.name.endswith(".json"))
assert "btc_fb63e851.json" in found # Convenzione: almeno un winner BTC e uno ETH shippati col package.
assert "eth_facd6af85d5d.json" in found # Non hard-codare hash specifici: cambiano ad ogni swap di paper winner.
assert any(n.startswith("btc_") for n in found), f"no btc_*.json in {found}"
assert any(n.startswith("eth_") for n in found), f"no eth_*.json in {found}"
for fname in found: for fname in found:
data = json.loads((files / fname).read_text()) data = json.loads((files / fname).read_text())
assert "rules" in data, f"{fname} missing 'rules' key" assert "rules" in data, f"{fname} missing 'rules' key"
@@ -0,0 +1,70 @@
"""Regression guard: end-of-window snap deve seguire il timeframe dell'asset.
Bug originale (scripts/run_paper_trading.py): ``end = now.replace(minute=0,...)``
snappava sempre all'ora; ETH 5m veniva quindi valutato 1 volta ogni 60 min
invece di ogni 5 min, riducendo la fedelta' al backtest tunato 5m.
"""
from __future__ import annotations
import importlib.util
import sys
from datetime import UTC, datetime
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[3]
_RUNNER_PATH = _REPO_ROOT / "scripts" / "run_paper_trading.py"
def _load_runner_module():
spec = importlib.util.spec_from_file_location("run_paper_trading", _RUNNER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules["run_paper_trading"] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def runner():
return _load_runner_module()
@pytest.mark.parametrize(
"now, tf, expected",
[
# 5m: snap al boundary di 5 min, NON all'ora
(datetime(2026, 5, 18, 14, 37, 42, tzinfo=UTC), "5m", datetime(2026, 5, 18, 14, 35, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 35, 0, tzinfo=UTC), "5m", datetime(2026, 5, 18, 14, 35, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 34, 59, tzinfo=UTC), "5m", datetime(2026, 5, 18, 14, 30, tzinfo=UTC)),
# 1h: comportamento storico preservato
(datetime(2026, 5, 18, 14, 37, 42, tzinfo=UTC), "1h", datetime(2026, 5, 18, 14, 0, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 0, 0, tzinfo=UTC), "1h", datetime(2026, 5, 18, 14, 0, tzinfo=UTC)),
# 15m / 4h
(datetime(2026, 5, 18, 14, 22, 0, tzinfo=UTC), "15m", datetime(2026, 5, 18, 14, 15, tzinfo=UTC)),
(datetime(2026, 5, 18, 14, 22, 0, tzinfo=UTC), "4h", datetime(2026, 5, 18, 12, 0, tzinfo=UTC)),
],
)
def test_align_end_to_timeframe(runner, now, tf, expected) -> None:
assert runner._align_end_to_timeframe(now, tf) == expected
def test_align_end_5m_advances_every_5_minutes(runner) -> None:
"""Bug-regression: chiamate consecutive a 5 min di distanza devono
produrre end DIVERSI per tf=5m (prima del fix erano identici)."""
a = datetime(2026, 5, 18, 14, 30, 0, tzinfo=UTC)
b = datetime(2026, 5, 18, 14, 35, 0, tzinfo=UTC)
c = datetime(2026, 5, 18, 14, 40, 0, tzinfo=UTC)
ends = {runner._align_end_to_timeframe(t, "5m") for t in (a, b, c)}
assert len(ends) == 3
def test_align_end_1h_stable_within_hour(runner) -> None:
"""Per tf=1h, chiamate dentro la stessa ora devono dare lo stesso end."""
ends = {
runner._align_end_to_timeframe(datetime(2026, 5, 18, 14, m, 0, tzinfo=UTC), "1h")
for m in (0, 15, 30, 45, 59)
}
assert ends == {datetime(2026, 5, 18, 14, 0, tzinfo=UTC)}
@@ -0,0 +1,110 @@
# Libro dei Frattali — Riassunto
**Autori:** Luca Serleto, Corrado Malanga
**Editore/Stampa:** Adobe InDesign, nov 2024
**Pagine:** 58 — **testo embedded ≈ nullo (~1.6 KB)**
**Estratto raw:** [_extracted/Libro_frattali.txt](_extracted/Libro_frattali.txt)
## Natura del documento
Il "Libro dei Frattali" è un **catalogo grafico** di pattern candlestick, non un testo discorsivo. Le pagine sono composte quasi interamente da figure (candle-chart estratti via trasformata di Fourier secondo la metodologia esposta nel companion paper [Pythagoras Trading Prediction](Pythagoras_Trading_Prediction.summary.md)).
L'estrazione testuale rivela **solo le etichette**:
| Pagina | Contenuto testuale |
|---|---|
| 1 | "Luca Serleto / Corrado Malanga / Libro dei Frattali" (frontespizio) |
| 2 | "Pattern da 3 candele a massimo 6 candele (LONG)" |
| 358 | "Pattern 1", "Pattern 2", …, "Pattern 57" — una riga per pagina |
**Tutto il resto è immagine.** L'utente ha esplicitamente richiesto che gli agenti **non passino dalle immagini** ma costruiscano schemi dai numeri.
## Struttura numerica deducibile (senza guardare le figure)
### Conteggio dei pattern
- **Totale pattern catalogati: 57**
- Numerati progressivamente da 1 a 57
- Tutti dichiarati "**LONG**" (segnali rialzisti)
- Tutti nel range **36 candele** (range 1 della tassonomia esposta in *Pythagoras Trading Prediction*, pp. 49)
### Coerenza col paper companion
Il paper *Pythagoras Trading Prediction* (p. 49) afferma testualmente:
> "Questa tipologia di pattern può estendersi fino a 6 candele, per questo motivo nel libro i pattern sono identificati con range da 3 a 6, da 6 a 12, da 12 a 24, da 24 a 39 e da 39 a 56."
⇒ Il Libro dei Frattali pubblicato copre **solo il primo range** (36 candele). Il numero massimo (56) della tassonomia coincide quasi esattamente con il numero di pattern catalogati (57) — possibile errore tipografico o discrepanza intenzionale di 1 unità.
### Pattern composti citati nel paper
Sempre nel paper companion (p. 53), è dichiarato che pattern di range superiore sono composti dai pattern semplici di range 1:
- **Pattern 13 = Pattern 3 + Pattern 11**
Implicazione: la numerazione del Libro NON è una sequenza temporale ma una **enumerazione di forme distinte** che fungono da "lettere" dell'alfabeto frattale; i pattern complessi sono "parole" composte.
## Spazio numerico (36 candele LONG)
Per i 57 pattern, l'**unico contenuto numerico estraibile senza vision** è il loro indice 157. Tuttavia il **dominio combinatorio** che il catalogo intende coprire può essere ricostruito teoricamente:
### Encoding compatto di una candela
Una candela in approssimazione discreta può essere ridotta a un simbolo dell'alfabeto:
- `U` = up (close > open) — bullish body
- `D` = down (close < open) — bearish body
- `0` = doji (close ≈ open)
Quindi una sequenza di 36 candele appartiene allo spazio `{U,D,0}^k` con k ∈ {3,4,5,6}.
| k | |Σᵏ| = 3ᵏ |
|---|---|
| 3 | 27 |
| 4 | 81 |
| 5 | 243 |
| 6 | 729 |
| **Tot** | **1080** sequenze teoriche |
57 pattern catalogati su 1080 sequenze ⇒ il catalogo seleziona ≈ **5.3%** dello spazio totale.
### Filtro implicito "LONG"
"LONG" implica che il pattern preceda un breakout/continuazione al rialzo. Restrizione plausibile:
- pattern terminanti con candela bullish forte, o
- pattern con dominanza U / 0 nelle ultime k-1 candele, o
- pattern strutturali noti dell'analisi tecnica (martello, inverted hammer, morning star, ecc.) — limitati a 36 candele.
## Numeri ricavabili dal Libro (l'unico set "puro")
Dal solo testo del Libro, gli agenti hanno accesso a:
1. **Cardinalità del catalogo**: 57
2. **Numerazione**: serie 1, 2, 3, …, 57
3. **Range candele**: [3, 6] (chiuso, intero)
4. **Direzione**: solo LONG
5. **Costante autori**: 2 (Serleto, Malanga)
6. **Edizione**: nov 2024
7. **Numero di pagine**: 58
## Indizi numerici per l'analisi di similitudine
| Numero | Match con Pythagoras Trading Prediction |
|---|---|
| 57 pattern | tassonomia bordo superiore = 56 (errore di 1) |
| 56 (bordo tassonomia) | numerologia: 56 = 8·7 = ottava·sacro |
| 3 (min candele) | numero sacro pitagorico (triade); 3 = lati triangolo Evideon |
| 6 (max candele) | numero perfetto (1+2+3); φ²-... |
| 6/3 = 2 | ratio 2 = ottava musicale |
| (max-min)/min = 1 | unità |
| log(57)/log(3) ≈ 3.69 | dimensione frattale di Hausdorff di insieme dimensionale 4 ≈ 3.69 |
## Punti utili per estrazione numerica (per il task di similitudini)
1. **N = 57** pattern → cardinality, da confrontare con 56 (bordo del paper), 528 Hz (Solfeggio), 588 candele indicatore
2. **k ∈ {3, 4, 5, 6}** → estremi del range
3. **Σ³⁺⁴⁺⁵⁺⁶ = 1080** sequenze teoriche (3ᵏ encoded) → 1080 = 8·135 = 2³·3³·5
4. **Tassonomia bordi**: 3, 6, 12, 24, 39, 56 — sequenza utile per fingerprint
5. **Direzione "LONG"** → variabile binaria
6. **Composizionalità**: Pattern 13 = Pattern 3 + Pattern 11 → grammar di pattern frattali
## Conclusione
Il Libro dei Frattali, **letto senza immagini**, è un oggetto numerico molto povero (57 etichette su 58 pagine). Per produrre similitudini significative tra i due testi, occorre:
1. Trattare il Libro come **schema combinatorio implicito** (1080 sequenze possibili, 57 selezionate)
2. Confrontare i **numeri-chiave** (3, 6, 57, range) con i numeri estratti dal paper teorico (φ, π, e, 528, 588, 137.0359, etc.)
3. Lasciare agli agenti il compito di **trovare ricorrenze, ratios, e dimensioni frattali** che colleghino i due insiemi numerici.
Vedere [Pythagoras_Trading_Prediction.summary.md](Pythagoras_Trading_Prediction.summary.md) per il dominio numerico del paper teorico.
@@ -0,0 +1,182 @@
# Pythagoras Trading Prediction — Riassunto
**Autori:** Luca Serleto, Corrado Malanga
**Editore/Stampa:** Adobe InDesign, dic 2024
**Pagine:** 66 — testo embedded ~91 KB (no immagini estraibili: figure presenti ma non analizzate per scelta esplicita dell'utente)
**Estratto raw:** [_extracted/Pythagoras_Trading_Prediction_.txt](_extracted/Pythagoras_Trading_Prediction_.txt)
## Tesi centrale
Il prezzo dei mercati finanziari è descrivibile come **funzione frattale**, scomponibile via **trasformata di Fourier** in componenti sinusoidali periodiche, e quindi proiettabile nel futuro. Il "parametro nascosto" che la fisica quantistica cerca da decenni è la **Coscienza** (entropia di Shannon ΔS), che modula sia la capacità di osservare/predire che la traiettoria stessa dei prezzi.
## Struttura del libro (4 parti)
1. **Parte teorica / coscienziale** (pp. 224): framework filosofico-fisico
2. **Frattali** (pp. 3744): indicatore frattale H-C
3. **Trasformata di Fourier** (pp. 4558): pattern catalogati e applicazione
4. **Piattaforma Pythagoras** (pp. 5966): manuale operativo (Auto + Manual mode)
## Concetti chiave (in ordine di apparizione)
### 1. Approccio "femminile" vs "maschile" alla predizione
- **Femminile/animico**: visione olografica del tempo (Pribram, Bohm), tutto co-presente nel presente; il cervello come "lettore di ologrammi"
- **Maschile/spirituale**: approccio galileiano-statistico via algoritmo
- Tesi: i due metodi vanno integrati
### 2. Probabilità → Certezza
> "Se il sistema predittivo è corretto al 10%, possiamo trovare il parametro che trasformi quella probabilità nella certezza al 100%."
Il parametro mancante è la **Coscienza**.
### 3. Teorema di ricorrenza di Poincaré
In meccanica hamiltoniana, in un sistema dinamico a spazio delle fasi limitato, lo stato può tornare arbitrariamente vicino a quello iniziale dopo tempo sufficiente. → Il tempo è **circolare**, gli eventi ricorrono → mercati ripetibili.
### 4. Entropia di Shannon S
- S = -log(W), W = microstati
- Coscienza ≡ S; **Consapevolezza ≡ ΔS** (variazione di entropia tra istante e successivo)
- Inizio universo: S = -∞; fine: S = 0; entropia totale = ∞
### 5. Hartman-Curry → "Indicatore frattale H-C"
- Introdotto da Corrado Malanga al XCongress di Pescara (2018)
- Prima applicazione su grafico EUR/USD
- Struttura tridimensionale costruita su "numeri universali" che genera ricorrenze
### 6. Numeri Universali (Evideon)
> "I numeri dell'universo sono: la sezione aurea φ, π, il numero di Eulero e, √2, ecc. Numeri irrazionali e trascendenti."
Triangolo rettangolo con costanti universali fisiche; lati moltiplicati per opportuni coefficienti basati su π, e, φ e temperatura assoluta di Kelvin/100.
**Costanti numeriche citate esplicitamente:**
- 137.0359 × √π / 2 = **121.449**
- 266.87 × 1 / √e = **161.86**
- Frequenze Solfeggio: **741, 528, 852, 639, 396, 417** (p. 27)
- Numero di Avogadro N = 6.022·10²³ (citato come ipotesi sul numero di sub-componenti dell'Universo)
### 7. Trasformata di Fourier
Scomposizione di qualsiasi funzione continua in seni e coseni:
- Frequenze positive (orario) e negative (antiorario) → propagazione **avanti e indietro nel tempo**
- Ricostruzione approssimata con N componenti → **N = misura della consapevolezza**
- Pochi N: previsione approssimativa; molti N: previsione accurata
### 8. Equazione di Schrödinger ↔ trading
Identificazione formale:
| Schrödinger | Trading |
|---|---|
| iℏ ∂Ψ/∂t = -ℏ²/(2m) ∂²Ψ/∂x² + V(x)Ψ | dinamica del prezzo |
| derivata seconda di Ψ in x | "piegatura" spazio-temporale del grafico |
| m | massa fittizia ≈ volume d'affari |
| V(Ψ) | perturbazioni esterne (manipolazione di mercato) |
| Ψ ampiezza | altezza candele (asse energia/prezzo) |
| Ψ² probabilità | probabilità che l'evento si materializzi |
Identificazione con energia libera di Gibbs: termine -TΔS della formula di Gibbs ≡ termine entropico in Schrödinger.
### 9. Centro di inversione (operazione geometrica chiave)
Sul modello "Evideon" (3 assi: spazio, tempo, energia), un punto A del passato si trasforma nel punto E del futuro tramite:
- **Riflessione speculare sull'asse verticale** (energia/prezzo)
- **Riflessione speculare sull'asse orizzontale** (tempo)
- = traslazione temporale + inversione di pattern
Inoltre il pattern subisce:
- **Compressione** o **dilatazione** sull'asse x (tempo)
- **Compressione** o **dilatazione** sull'asse y (prezzo)
A seconda della posizione del punto sull'Evideon (tempo sotto-multiplo → contrazione).
### 10. Finestra di Overton
- Apertura temporale che definisce "il presente"
- Analogia con SAR (Synthetic Aperture Radar): più larga la finestra, più precisa la previsione
- Pythagoras applica la stessa finestra al passato per proiettare nel futuro
### 11. ZPE (Zero Point Energy)
Universo non vuoto: pieno di particelle e anti-particelle che si annichiliscono. Permette di "conoscere perfettamente coordinate spazio-temporali e valore nullo dell'energia": non c'è incertezza vera (contro Heisenberg).
### 12. Indicatore frattale su TradingView
Disponibile su licenza dedicata. Parametri operativi:
- Tracciato da minimo → massimo
- **25 linee verticali** all'interno di **588 candele**
- 90% segnala accelerazione (aumento volatilità), 10% lateralizzazione
- Tolleranza ±3 ore su time frame 1H/4H
- **Linee blu inclinate** = supporti/resistenze tempo-condizionati
- **Cerchi bianchi** = obiettivi di prezzo circolari
### 13. Pattern range (taxonomy del Book dei Frattali)
| Range | Numero candele |
|---|---|
| Range 1 | 3 6 candele |
| Range 2 | 6 12 candele |
| Range 3 | 12 24 candele |
| Range 4 | 24 39 candele |
| Range 5 | 39 56 candele |
I pattern oltre 6 candele diventano "frattali complessi": composizione di pattern semplici (es. Pattern 13 = Pattern 3 + Pattern 11).
### 14. Variabili che NON alterano il target
- Inclinazione del pattern
- Ampiezza
- Curvatura
- Numero di candele entro lo stesso range
→ il target di proiezione è invariante per queste deformazioni.
### 15. Variabili che alterano l'applicabilità
- **Time frame**: 15m, 1H, 4H, 1D
- **Volatilità dell'asset** ("consapevolezza dell'asset")
- **Leva finanziaria**
## Esempi concreti citati
| Pattern | Asset | Data/Ora | TF |
|---|---|---|---|
| 1 | BTC | 28 ago 2024 00:00 | 15m |
| 1 | ETH | 28 ago 2024 00:00 | 15m |
| 1 | Oro | 29 ago 2024 14:30 | 15m |
| 1 | Argento | 17 lug 2024 18:15 | 15m |
| 13 | BTC | 24 ago 2024 23:15 | 15m |
| 13 | ETH | 24 ago 2024 23:45 | 15m |
| 13 | Oro | 02 ago 2024 16:45 | 15m |
| 13 | Argento | 02 ago 2024 16:45 | 15m |
| 15 | DJT (Trump Media) | 12 lug 2024 (chiusura) | 1D |
Il caso DJT è presentato come "predizione" dell'attentato a Trump del 14 lug 2024 (frattale complesso al rialzo formatosi il 12 lug → escludeva evento negativo).
## Piattaforma Pythagoras
### Auto Mode
1. Seleziona asset
2. Seleziona TF (15m, 1H, 4H)
3. Click "Predict" → Fourier identifica frattale e proietta futuro (colore giallo)
### Manual Mode
1. Seleziona asset
2. Seleziona frattale dal Book per data
3. Click "Predict" → proiezione
## Disclaimer interno al testo
> "Anche se si conosce un potenziale futuro, questo non è garanzia di guadagno. Solo la consapevolezza del trader, basata sull'esperienza, potrà trasformare la predizione di un potenziale futuro in un profitto concreto."
Il testo enfatizza che il **target del trader non è guadagnare ma capire sé stesso** (approccio "coscienziale"). Il denaro è strumento di apprendimento dell'entropia/dualità.
## Punti utili per estrazione numerica (per il task di similitudini)
1. **Numeri universali esatti**: φ ≈ 1.6180339887, π ≈ 3.1415926535, e ≈ 2.71828, √2 ≈ 1.41421
2. **Costanti derivate dal testo**: 137.0359, 121.449, 266.87, 161.86
3. **Frequenze Solfeggio (Hz)**: 396, 417, 528, 639, 741, 852
4. **Conteggi operativi**: 25 linee/588 candele = ratio 0.0425
5. **Numeri ricorrenti**: 6.022·10²³ (Avogadro)
6. **Range candele**: bordi 3, 6, 12, 24, 39, 56 — sequenza con incrementi 3, 6, 12, 15, 17 (NON Fibonacci puro, ma sequenza con ratios ~2x)
7. **Ratios tra bordi**: 6/3=2, 12/6=2, 24/12=2, 39/24=1.625, 56/39=1.436 — gli ultimi due si avvicinano a φ ≈ 1.618 e √2 ≈ 1.414
8. **Sequenza pattern numerati**: 1, 2, 3, ... 57 (matching col Libro dei Frattali)
9. **Operazioni geometriche**: mirror H, mirror V, scaling x/y (4 trasformazioni)
10. **TF candidati**: 15m, 1H, 4H, 1D (multipli 4x e 6x)
## Bibliografia interna / link YouTube citati
- youtube.com/shorts/OnM09uMCS50 (teorema di Poincaré)
- youtu.be/hHig-PP4Mcs (gravità come Coscienza)
- youtu.be/HTIYZBrzZbM (struttura dell'Evideon)
## Opere citate degli autori
- Corrado Malanga, *Io e Dio*, Spazio Interiore, Roma
- Corrado Malanga, *Trilogia della Coscienza*, Spazio Interiore, Roma
- Corrado Malanga al XCongress, Pescara, 2018 (intro pattern H-C)
@@ -0,0 +1,227 @@
===PAGE 1===
Luca Serleto
Corrado Malanga
Libro dei Libro dei
FrattaliFrattali
===PAGE 2===
Pattern da 3 candele a massimo 6 candele (LONG)
Pattern 1
===PAGE 3===
Pattern 2
===PAGE 4===
Pattern 3
===PAGE 5===
Pattern 4
===PAGE 6===
Pattern 5
===PAGE 7===
Pattern 6
===PAGE 8===
Pattern 7
===PAGE 9===
Pattern 8
===PAGE 10===
Pattern 9
===PAGE 11===
Pattern 10
===PAGE 12===
Pattern 11
===PAGE 13===
Pattern 12
===PAGE 14===
Pattern 13
===PAGE 15===
Pattern 14
===PAGE 16===
Pattern 15
===PAGE 17===
Pattern 16
===PAGE 18===
Pattern 17
===PAGE 19===
Pattern 18
===PAGE 20===
Pattern 19
===PAGE 21===
Pattern 20
===PAGE 22===
Pattern 21
===PAGE 23===
Pattern 22
===PAGE 24===
Pattern 23
===PAGE 25===
Pattern 24
===PAGE 26===
Pattern 25
===PAGE 27===
Pattern 26
===PAGE 28===
Pattern 27
===PAGE 29===
Pattern 28
===PAGE 30===
Pattern 29
===PAGE 31===
Pattern 30
===PAGE 32===
Pattern 31
===PAGE 33===
Pattern 32
===PAGE 34===
Pattern 33
===PAGE 35===
Pattern 34
===PAGE 36===
Pattern 35
===PAGE 37===
Pattern 36
===PAGE 38===
Pattern 37
===PAGE 39===
Pattern 38
===PAGE 40===
Pattern 39
===PAGE 41===
Pattern 40
===PAGE 42===
Pattern 41
===PAGE 43===
Pattern 42
===PAGE 44===
Pattern 43
===PAGE 45===
Pattern 44
===PAGE 46===
Pattern 45
===PAGE 47===
Pattern 46
===PAGE 48===
Pattern 47
===PAGE 49===
Pattern 48
===PAGE 50===
Pattern 49
===PAGE 51===
Pattern 50
===PAGE 52===
Pattern 51
===PAGE 53===
Pattern 52
===PAGE 54===
Pattern 53
===PAGE 55===
Pattern 54
===PAGE 56===
Pattern 55
===PAGE 57===
Pattern 56
===PAGE 58===
Pattern 57
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
# strategy_pythagoras
Strategia di trading basata sul framework **Pythagoras-Malanga**
(candle-pattern + geometria frattale), evoluta via GA sul core
`multi_swarm_core`. Workspace member del monorepo `multi_swarm_coevolutive`.
## Scope
Pipeline coevolutiva su candele OHLC: il GA del core esplora combinazioni
di indicatori candle-pattern, geometria pitagorica e ratio frattali, e
produce strategie JSON freezate. Questo member le esegue in
**paper-trading forward-test** ed espone una dashboard NiceGUI per
analisi invarianza scala/tick e candle.
## Layout
```
strategy_pythagoras/
├── backend/ paper trading runner (PaperExecutor, Portfolio, PaperRepository)
│ └── schema.py tabelle paper_trading_* (DB locale dedicato)
├── frontend/ NiceGUI dashboard (tab invariance, candle, equity, ticks)
├── strategies/ JSON freezate input al runner
│ (pythagoras_*.json)
└── prompts.json 7 stili di prompt LLM (candle-pattern, frattale, ratio,
pivot, kagi, renko, hybrid)
```
## Run paper-trading smoke
```bash
uv run python scripts/run_pythagoras_smoke.py \
--name pythagoras-smoke-001 \
--initial-capital 1000 \
--poll-seconds 300
```
Il default `--strategies-dir` punta ai JSON shippati col package via
`importlib.resources.files("strategy_pythagoras") / "strategies"`.
## Dashboard
```bash
uv run python -m strategy_pythagoras.frontend.nicegui_app
```
In produzione: `https://swarm.tielogic.xyz/strategy_pythagoras_gui/`
(root_path configurato via `DASHBOARD_ROOT_PATH=/strategy_pythagoras_gui`).
## DB schema
Schema isolato dal core e dalle altre strategie. Due DB distinti:
- `state/strategy_pythagoras.db` — GA + analisi invarianza
(env `STRATEGY_PYTHAGORAS_DB_PATH`)
- `state/strategy_pythagoras_paper.db` — paper-trading runs
(env `STRATEGY_PYTHAGORAS_PAPER_DB_PATH`)
Tabelle paper-trading:
- `paper_trading_runs` — metadata run (id, name, capital, status)
- `paper_trading_positions` — posizioni aperte (long/short)
- `paper_trading_trades` — trade realized (entry/exit, pnl, fees)
- `paper_trading_equity` — equity curve snapshot
- `paper_trading_ticks` — log signal/action per ogni bar
DDL gestito da `strategy_pythagoras.backend.schema.init_schema()`.
La dashboard legge **anche** il `runs.db` del core GA (env `GA_DB_PATH`)
per correlare paper performance con i genomi di provenienza e con i
risultati di fitness invariance.
## References
- Spec: `docs/superpowers/specs/2026-05-19-strategy-pythagoras-design.md`
- Plan: `docs/superpowers/plans/2026-05-19-strategy-pythagoras.md`
- Summary paper: `Pythagoras/Pythagoras_Trading_Prediction.summary.md`
- Summary frattali: `Pythagoras/Libro_frattali.summary.md`
- Pattern member: vedi `src/strategy_crypto/README.md`
+21
View File
@@ -0,0 +1,21 @@
[project]
name = "strategy-pythagoras"
version = "0.1.0"
description = "Strategy Pythagoras: candle-pattern GA su framework Pythagoras-Malanga, paper-trading runner + NiceGUI dashboard"
authors = [{ name = "Adriano Dal Pastro", email = "adrianodalpastro@tielogic.com" }]
requires-python = ">=3.13"
dependencies = [
"multi-swarm-core",
"nicegui>=3.11.1",
"plotly>=5.24",
"pandas>=2.2",
"pyarrow>=18.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel.force-include]
"strategy_pythagoras/strategies" = "strategy_pythagoras/strategies"
"strategy_pythagoras/prompts.json" = "strategy_pythagoras/prompts.json"
@@ -0,0 +1,23 @@
"""Backend paper-trading per la strategia strategy_pythagoras.
Espone le classi principali per import ergonomici in scripts/runner:
from strategy_pythagoras.backend import PaperExecutor, Portfolio, PaperRepository
Per i tipi interni (TickResult, OpenPosition, Trade) importare dal sotto-modulo.
"""
from .executor import PaperExecutor, TickResult
from .persistence import PaperRepository
from .portfolio import OpenPosition, Portfolio
from .schema import PAPER_SCHEMA_SQL, init_schema
__all__ = [
"PAPER_SCHEMA_SQL",
"OpenPosition",
"PaperExecutor",
"PaperRepository",
"Portfolio",
"TickResult",
"init_schema",
]
@@ -0,0 +1,97 @@
"""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 multi_swarm_core.backtest.orders import Side, Trade
from multi_swarm_core.protocol.compiler import compile_strategy
from multi_swarm_core.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,123 @@
"""Persistenza paper-trading: scrive su un DB dedicato (state/strategy_pythagoras_paper.db)
con le tabelle ``paper_trading_*`` definite localmente in :mod:`.schema`.
Il DB e' isolato dal ``runs.db`` del core GA: nessun naming conflict con
future strategie (state/strategy_<asset>.db), nessuna contention di lock
fra writer GA e writer paper.
"""
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
from .schema import init_schema as _init_paper_schema
class PaperRepository:
def __init__(self, db_path: Path | str):
self.db_path = Path(db_path)
def init_schema(self) -> None:
"""Crea (se mancanti) le tabelle paper_trading_* su ``self.db_path``."""
_init_paper_schema(self.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 multi_swarm_core.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
@@ -0,0 +1,87 @@
"""Schema SQLite per le tabelle paper-trading della strategia pythagoras.
Owned dal member strategy_pythagoras: il DDL e' standalone rispetto al core,
e scrive su un database dedicato (state/strategy_pythagoras_paper.db, env
STRATEGY_PYTHAGORAS_PAPER_DB_PATH) separato dal runs.db del core GA. Pattern
replicabile per future strategie.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
PAPER_SCHEMA_SQL = """
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_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);
"""
def init_schema(db_path: Path | str) -> None:
"""Crea (se mancanti) le tabelle paper_trading_* sul db indicato."""
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
try:
conn.executescript(PAPER_SCHEMA_SQL)
conn.commit()
finally:
conn.close()
@@ -0,0 +1,50 @@
"""Bonus di asset-invariance per la fitness del GA.
corr_signal = frazione di entries su asset A che hanno corrispondenza su asset B
entro +/-tolerance_bars (default 36 = 3h su 5m TF, vedi paper Pythagoras p. 43).
"""
from __future__ import annotations
import os
import pandas as pd
GA_INVARIANCE_ALPHA = float(os.getenv("GA_INVARIANCE_ALPHA", "0.3"))
GA_INVARIANCE_TOLERANCE_BARS = int(os.getenv("GA_INVARIANCE_TOLERANCE_BARS", "36"))
def corr_signal(
entries_a: pd.Series,
entries_b: pd.Series,
tolerance_bars: int = GA_INVARIANCE_TOLERANCE_BARS,
) -> float:
"""Frazione di entries A con match in B entro +/-tolerance_bars.
Args:
entries_a, entries_b: Series binarie {0,1} sullo stesso index temporale (interi).
tolerance_bars: finestra di tolleranza in barre.
Returns:
In [0, 1]. 0 se entries_a non ha alcuna entry o nessun match.
"""
a_idx = entries_a[entries_a > 0].index.tolist()
b_idx = entries_b[entries_b > 0].index.tolist()
if not a_idx or not b_idx:
return 0.0
b_set = set(b_idx)
matched = 0
for ti in a_idx:
for delta in range(-tolerance_bars, tolerance_bars + 1):
if (ti + delta) in b_set:
matched += 1
break
return matched / len(a_idx)
def apply_invariance_bonus(
base_fitness: float,
invariance_score: float,
alpha: float = GA_INVARIANCE_ALPHA,
) -> float:
"""``fitness * (1 + alpha * invariance_score)``."""
return base_fitness * (1.0 + alpha * invariance_score)
@@ -0,0 +1,215 @@
"""Paper-trading data access functions for the strategy_pythagoras dashboard.
Reads exclusively from strategy_pythagoras_paper.db (paper_trading_* tables)
per il paper-trading; le funzioni dedicate ai winner Pythagoras leggono
invece dal runs.db del core GA (env ``GA_DB_PATH``), default
``state/strategy_pythagoras.db`` via env ``STRATEGY_PYTHAGORAS_DB_PATH`` quando
si vuole usare una sotto-tabella locale.
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Any
import pandas as pd # type: ignore[import-untyped]
def _paper_conn(db_path: str | Path) -> sqlite3.Connection:
# Cold-start race: GUI può avviarsi prima che il paper writer crei il file.
db_path_str = str(db_path)
deadline = time.monotonic() + 5.0
while True:
try:
conn = sqlite3.connect(db_path_str, timeout=5.0)
conn.row_factory = sqlite3.Row
return conn
except sqlite3.OperationalError:
if time.monotonic() >= deadline:
raise
time.sleep(1.0)
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),
}
# ---------------------------------------------------------------------------
# Pythagoras-specific helpers (winners invariance + candle pattern usage)
# ---------------------------------------------------------------------------
def load_invariance_metrics(ga_db_path: str) -> "pd.DataFrame":
"""Per ogni winner ritorna (genome_id, cognitive_style, fitness, sharpe_btc, sharpe_eth, invariance_score).
Lo schema atteso e' la tabella ``pythagoras_winners`` creata dal runner
``scripts/run_pythagoras_smoke.py`` (Task 6.1).
"""
import sqlite3
import pandas as pd
con = sqlite3.connect(ga_db_path)
try:
return pd.read_sql_query(
"SELECT genome_id, cognitive_style, fitness, sharpe_btc, sharpe_eth, "
"invariance_score FROM pythagoras_winners ORDER BY fitness DESC",
con,
)
finally:
con.close()
def load_candle_pattern_usage(ga_db_path: str) -> "pd.DataFrame":
"""Per ogni winner estrae le sequenze candle_pattern usate (per heatmap)."""
import json
import sqlite3
import pandas as pd
con = sqlite3.connect(ga_db_path)
try:
df = pd.read_sql_query(
"SELECT genome_id, cognitive_style, rules_json FROM pythagoras_winners",
con,
)
finally:
con.close()
records: list[dict] = []
for _, row in df.iterrows():
rules = json.loads(row["rules_json"]).get("rules", [])
for r in rules:
for ind_name, params in _walk_indicators(r["condition"]):
if ind_name == "candle_pattern":
length = int(params[0])
syms = [int(s) for s in params[1: 1 + length]]
seq_str = "".join({0: "U", 1: "D", 2: "0"}[s] for s in syms)
records.append(
{
"genome_id": row["genome_id"],
"cognitive_style": row["cognitive_style"],
"sequence": seq_str,
"length": length,
}
)
return pd.DataFrame.from_records(records)
def _walk_indicators(node: dict):
"""Yields (indicator_name, params) for every IndicatorNode in the AST."""
if "op" in node:
for a in node.get("args", []):
yield from _walk_indicators(a)
elif node.get("kind") == "indicator":
yield node["name"], node["params"]
@@ -0,0 +1,421 @@
"""Strategy Pythagoras Dashboard — paper-trading + GA winners page: /.
Avvio: ``uv run python -m strategy_pythagoras.frontend.nicegui_app``
Default port 8080. Legge il paper DB (``strategy_pythagoras_paper.db``) per il
tab ``Paper`` e il GA DB (``strategy_pythagoras.db``) per i tab pythagoras-specifici
(Genomes / Patterns / Ratios / Invariance).
Palette "Neon Trading Dashboard" (ispirata screenshot 2026-05-11):
- BG: #0A0A0F (near-black con tinge blu)
- Surface: #13131A (card base)
- Surface elevata: #1C1C26 (hover/active)
- Primary pink: #FF2D87 (highlight key metrics, max fitness)
- Secondary cyan: #00D9FF (median, secondary curves)
- Accent amber: #FFB800 (warnings, p90)
- Success neon green: #00E676, Danger neon red: #FF3D60
- Text: #FFFFFF (primary), #7A7A8C (muted)
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import pandas as pd # type: ignore[import-untyped]
import plotly.graph_objects as go # type: ignore[import-untyped]
from nicegui import app, ui
from strategy_pythagoras.frontend.data import (
load_candle_pattern_usage,
load_invariance_metrics,
paper_equity_df,
paper_positions_df,
paper_run_summary,
paper_runs_df,
paper_ticks_df,
paper_trades_df,
)
from multi_swarm_core.dashboard.theme import (
COLOR_PRIMARY,
COLOR_SURFACE,
COLOR_SURFACE_2,
COLOR_TEXT,
COLOR_TEXT_MUTED,
_STATUS_BADGE,
_apply_theme,
_build_header,
)
PAPER_DB_PATH = os.environ.get(
"STRATEGY_PYTHAGORAS_PAPER_DB_PATH", "./state/strategy_pythagoras_paper.db"
)
GA_DB_PATH = os.environ.get(
"STRATEGY_PYTHAGORAS_DB_PATH", "./state/strategy_pythagoras.db"
)
DASHBOARD_ROOT_PATH = os.environ.get("DASHBOARD_ROOT_PATH", "/strategy_pythagoras_gui")
REFRESH_INTERVAL_S = 3.0
def _paper_runs_options(only_running: bool = False) -> dict[str, str]:
try:
runs = paper_runs_df(PAPER_DB_PATH)
except Exception:
return {}
if runs.empty:
return {}
if only_running:
runs = runs[runs["status"] == "running"]
if runs.empty:
return {}
return {
row["id"]: f"{row['name']}{row['status']} ({row['started_at'][:16]})"
for _, row in runs.iterrows()
}
def _paper_equity_figure(eq_df: Any, initial_capital: float) -> go.Figure:
fig = go.Figure()
if eq_df is not None and not eq_df.empty:
ts = pd.to_datetime(eq_df["ts"])
fig.add_trace(
go.Scatter(
x=ts,
y=eq_df["equity"],
mode="lines",
line={"color": COLOR_PRIMARY, "width": 2},
name="equity",
)
)
fig.add_hline(
y=initial_capital,
line={"color": COLOR_TEXT_MUTED, "width": 1, "dash": "dash"},
annotation_text=f"initial ${initial_capital:.0f}",
annotation_position="bottom right",
annotation_font_color=COLOR_TEXT_MUTED,
)
fig.update_layout(
title=None,
paper_bgcolor=COLOR_SURFACE,
plot_bgcolor=COLOR_SURFACE,
font={"color": COLOR_TEXT, "family": "Inter"},
xaxis={"gridcolor": COLOR_SURFACE_2, "title": None},
yaxis={"gridcolor": COLOR_SURFACE_2, "title": "Equity ($)"},
margin={"l": 60, "r": 20, "t": 10, "b": 40},
height=320,
showlegend=False,
)
return fig
def _render_paper_panel() -> None:
"""Rende il pannello paper-trading (equivalente alla pagina root di strategy_crypto)."""
options = _paper_runs_options()
if not options:
ui.label("Nessuna paper-trading run nel database.").classes("text-h6 text-warning")
ui.label(
"Avvia un paper run per popolare strategy_pythagoras_paper.db."
).classes("text-caption")
return
state: dict[str, Any] = {"run_id": next(iter(options))}
with ui.row().classes("w-full items-center gap-4 q-mb-md"):
select = ui.select(options=options, value=state["run_id"], label="Paper run").classes(
"flex-grow"
)
status_badge = ui.badge("", color="primary").classes("text-body1 q-pa-sm")
ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary")
with ui.row().classes("w-full gap-4"):
with ui.card().classes("flex-grow metric-card accent-cyan"):
ui.label("Equity").classes("text-caption")
equity_lbl = ui.label("").classes("text-h4")
with ui.card().classes("flex-grow metric-card accent-purple"):
ui.label("P/L cumulato").classes("text-caption")
pnl_lbl = ui.label("").classes("text-h4")
with ui.card().classes("flex-grow metric-card accent-amber"):
ui.label("Trades chiusi").classes("text-caption")
trades_lbl = ui.label("").classes("text-h4")
with ui.card().classes("flex-grow metric-card accent-green"):
ui.label("Open / Tick").classes("text-caption")
ticks_lbl = ui.label("").classes("text-h4")
with ui.row().classes("w-full gap-4 q-mt-md"):
started_lbl = ui.label("Started: —")
last_tick_lbl = ui.label("Last tick: —")
cash_lbl = ui.label("Cash: —")
ui.separator()
ui.label("Equity curve").classes("text-subtitle1 q-mt-md")
equity_plot = ui.plotly(_paper_equity_figure(None, 0.0)).classes("w-full")
ui.separator()
ui.label("Open positions").classes("text-subtitle1 q-mt-md")
positions_table = ui.table(
columns=[
{"name": "symbol", "label": "symbol", "field": "symbol"},
{"name": "side", "label": "side", "field": "side"},
{"name": "qty", "label": "qty", "field": "qty"},
{"name": "entry_price", "label": "entry", "field": "entry_price"},
{"name": "entry_ts", "label": "entry ts", "field": "entry_ts"},
],
rows=[],
row_key="symbol",
).classes("w-full")
ui.separator()
ui.label("Ultimi 30 tick").classes("text-subtitle1 q-mt-md")
ticks_table = ui.table(
columns=[
{"name": "ts", "label": "ts", "field": "ts"},
{"name": "symbol", "label": "symbol", "field": "symbol"},
{"name": "bar_ts", "label": "bar", "field": "bar_ts"},
{"name": "close_price", "label": "close", "field": "close_price"},
{"name": "signal", "label": "signal", "field": "signal"},
{"name": "action_taken", "label": "action", "field": "action_taken"},
],
rows=[],
row_key="ts",
).classes("w-full")
ui.separator()
ui.label("Trades chiusi (ultimi 50)").classes("text-subtitle1 q-mt-md")
trades_table = ui.table(
columns=[
{"name": "exit_ts", "label": "exit ts", "field": "exit_ts"},
{"name": "symbol", "label": "symbol", "field": "symbol"},
{"name": "side", "label": "side", "field": "side"},
{"name": "qty", "label": "qty", "field": "qty"},
{"name": "entry_price", "label": "entry", "field": "entry_price"},
{"name": "exit_price", "label": "exit", "field": "exit_price"},
{"name": "pnl", "label": "pnl", "field": "pnl"},
{"name": "fees", "label": "fees", "field": "fees"},
],
rows=[],
row_key="exit_ts",
).classes("w-full")
def refresh() -> None:
run_id = select.value
if not run_id:
return
try:
summary = paper_run_summary(PAPER_DB_PATH, run_id)
eq = paper_equity_df(PAPER_DB_PATH, run_id)
positions = paper_positions_df(PAPER_DB_PATH, run_id)
ticks = paper_ticks_df(PAPER_DB_PATH, run_id, limit=30)
trades = paper_trades_df(PAPER_DB_PATH, run_id, limit=50)
except Exception as e: # noqa: BLE001
ui.notify(f"Errore: {e}", type="negative")
return
text, color = _STATUS_BADGE.get(summary["status"], (summary["status"], "primary"))
status_badge.text = text
status_badge.props(f"color={color}")
equity_lbl.text = f"${summary['current_equity']:.2f}"
pnl_lbl.text = f"{summary['pnl_pct']:+.2f}%"
trades_lbl.text = str(summary["n_trades"])
ticks_lbl.text = f"{summary['n_open_positions']} / {summary['n_ticks']}"
started_lbl.text = f"Started: {summary['started_at']}"
last_tick_lbl.text = f"Last tick: {summary['last_tick_ts'] or ''}"
cash_lbl.text = (
f"Cash: ${summary['current_cash']:.2f} | "
f"Pos value: ${summary['current_positions_value']:.2f}"
)
equity_plot.update_figure(_paper_equity_figure(eq, summary["initial_capital"]))
positions_table.rows = (
[
{col: (round(v, 6) if isinstance(v, float) else v) for col, v in row.items()}
for _, row in positions.iterrows()
]
if not positions.empty
else []
)
positions_table.update()
ticks_table.rows = (
[
{col: (round(v, 6) if isinstance(v, float) else v) for col, v in row.items()}
for _, row in ticks.iterrows()
]
if not ticks.empty
else []
)
ticks_table.update()
trades_table.rows = (
[
{col: (round(v, 6) if isinstance(v, float) else v) for col, v in row.items()}
for _, row in trades.iterrows()
]
if not trades.empty
else []
)
trades_table.update()
def on_select_change() -> None:
state["run_id"] = select.value
refresh()
select.on_value_change(on_select_change)
ui.timer(REFRESH_INTERVAL_S, refresh)
refresh()
@ui.page("/")
def index() -> None:
_apply_theme()
_build_header(
active="/",
brand_subtitle="Strategy Pythagoras",
nav_items=[("/", "Dashboard")],
db_label=f"{Path(GA_DB_PATH).resolve().name}",
)
with ui.tabs() as tabs:
t_genomes = ui.tab("Genomes")
t_patterns = ui.tab("Patterns")
t_ratios = ui.tab("Ratios")
t_invariance = ui.tab("Invariance")
t_paper = ui.tab("Paper")
with ui.tab_panels(tabs, value=t_genomes).classes("w-full"):
with ui.tab_panel(t_genomes):
ui.label("GA Winners (post pythagoras-smoke-001)").classes("text-h6")
try:
df = load_invariance_metrics(GA_DB_PATH)
if df.empty:
ui.label(
"No winners yet. Run scripts/run_pythagoras_smoke.py first."
).classes("text-warning")
else:
ui.table(
rows=df.to_dict("records"),
columns=[
{"name": "genome_id", "label": "Genome ID", "field": "genome_id"},
{
"name": "cognitive_style",
"label": "Style",
"field": "cognitive_style",
},
{"name": "fitness", "label": "Fitness", "field": "fitness"},
{"name": "sharpe_btc", "label": "Sharpe BTC", "field": "sharpe_btc"},
{"name": "sharpe_eth", "label": "Sharpe ETH", "field": "sharpe_eth"},
{
"name": "invariance_score",
"label": "Invariance",
"field": "invariance_score",
},
],
pagination=10,
).classes("w-full")
except Exception as e: # noqa: BLE001
ui.label(f"DB not ready: {e}").classes("text-warning")
with ui.tab_panel(t_patterns):
ui.label(
"Candle pattern sequences emerged (per cognitive style)"
).classes("text-h6")
try:
df_pat = load_candle_pattern_usage(GA_DB_PATH)
if df_pat.empty:
ui.label(
"No patterns yet. Run scripts/run_pythagoras_smoke.py first."
).classes("text-warning")
else:
grouped = (
df_pat.groupby(["cognitive_style", "sequence"])
.size()
.reset_index(name="count")
)
grouped = grouped.sort_values("count", ascending=False).head(50)
ui.table(
rows=grouped.to_dict("records"),
columns=[
{
"name": "cognitive_style",
"label": "Style",
"field": "cognitive_style",
},
{
"name": "sequence",
"label": "Sequence (U/D/0)",
"field": "sequence",
},
{"name": "count", "label": "Count", "field": "count"},
],
pagination=15,
).classes("w-full")
except Exception as e: # noqa: BLE001
ui.label(f"DB not ready: {e}").classes("text-warning")
with ui.tab_panel(t_ratios):
ui.label(
"Pythagorean ratio literals — distance from universal constants"
).classes("text-h6")
try:
df = load_invariance_metrics(GA_DB_PATH)
if df.empty:
ui.label("No winners yet.").classes("text-warning")
else:
ui.label(f"Total winners: {len(df)}").classes("text-body2")
ui.label(
"(Ratio literal histogram available after GA run produces "
"pythagorean_ratio entries.)"
).classes("text-caption")
except Exception as e: # noqa: BLE001
ui.label(f"DB not ready: {e}").classes("text-warning")
with ui.tab_panel(t_invariance):
ui.label(
"Cross-asset invariance: Sharpe BTC vs Sharpe ETH"
).classes("text-h6")
try:
df = load_invariance_metrics(GA_DB_PATH)
if df.empty:
ui.label("No winners yet.").classes("text-warning")
else:
import plotly.express as px # type: ignore[import-untyped]
fig = px.scatter(
df,
x="sharpe_btc",
y="sharpe_eth",
color="invariance_score",
hover_data=["genome_id", "cognitive_style", "fitness"],
title="Sharpe BTC vs Sharpe ETH (color = invariance_score)",
)
ui.plotly(fig).classes("w-full")
except Exception as e: # noqa: BLE001
ui.label(f"DB not ready: {e}").classes("text-warning")
with ui.tab_panel(t_paper):
_render_paper_panel()
def main() -> None:
app.on_startup(
lambda: print(
f"GA DB: {Path(GA_DB_PATH).resolve()} | "
f"Paper DB: {Path(PAPER_DB_PATH).resolve()} | "
f"root_path: {DASHBOARD_ROOT_PATH or '/'}"
)
)
ui.run(
host="0.0.0.0",
port=int(os.environ.get("SWARM_DASHBOARD_PORT", "8080")),
title="Strategy Pythagoras Dashboard",
reload=False,
show=False,
dark=True,
root_path=DASHBOARD_ROOT_PATH,
)
if __name__ in {"__main__", "__mp_main__"}:
main()
@@ -0,0 +1,70 @@
"""Indicatori candle Pythagoras.
Vincoli grammar: ``IndicatorNode.params`` e' sempre ``list[float]``. Quindi:
- candle_pattern: params = [length, sym0, sym1, ..., sym_{length-1}]
length in [3,12]; sym in {0=U, 1=D, 2=doji}
- pythagorean_ratio: params = [lookback] lookback in [12,200]
- fractal_mirror: params = [k, axis_int] k in [3,12]; axis_int=0(h) 1(v)
"""
from __future__ import annotations
import numpy as np
import pandas as pd
_DOJI_THRESHOLD = 0.001
def _symbol_series(df: pd.DataFrame) -> pd.Series:
"""Mappa ogni candela in {0=U, 1=D, 2=doji}."""
close = df["close"]
open_ = df["open"]
rel = (close - open_).abs() / open_.replace(0, np.nan)
sym = np.where(close > open_, 0, np.where(close < open_, 1, 2))
sym = np.where(rel.values < _DOJI_THRESHOLD, 2, sym)
return pd.Series(sym, index=df.index, dtype="int64")
def candle_pattern(df: pd.DataFrame, params: list[float]) -> pd.Series:
"""1.0 se le ultime ``length`` candele matchano la sequenza, 0.0 altrimenti."""
length = int(params[0])
target = [int(s) for s in params[1:1 + length]]
syms = _symbol_series(df)
out = pd.Series(0.0, index=df.index, dtype="float64")
if len(syms) < length:
return out
arr = syms.values
target_arr = np.array(target, dtype=arr.dtype)
for i in range(length - 1, len(arr)):
if np.array_equal(arr[i - length + 1: i + 1], target_arr):
out.iat[i] = 1.0
return out
def pythagorean_ratio(df: pd.DataFrame, params: list[float]) -> pd.Series:
"""``max(close[-lookback:]) / min(close[-lookback:])`` rolling."""
lookback = int(params[0])
close = df["close"]
hi = close.rolling(lookback, min_periods=1).max()
lo = close.rolling(lookback, min_periods=1).min().replace(0, np.nan)
return (hi / lo).fillna(1.0)
def fractal_mirror(df: pd.DataFrame, params: list[float]) -> pd.Series:
"""Correlation tra close[-k:] e suo mirror su asse axis."""
k = int(params[0])
axis_int = int(params[1])
close = df["close"].values
out = np.full(len(close), 0.0)
for i in range(k - 1, len(close)):
window = close[i - k + 1: i + 1]
if axis_int == 0: # h: mirror temporale
mirror = window[::-1]
else: # v: mirror prezzo
mirror = window.max() - (window - window.min())
std_w = window.std()
std_m = mirror.std()
if std_w < 1e-12 or std_m < 1e-12:
out[i] = 0.0
else:
out[i] = float(np.corrcoef(window, mirror)[0, 1])
return pd.Series(out, index=df.index, dtype="float64")
@@ -0,0 +1,53 @@
{
"_comment": "Stili cognitivi del GA per strategy_pythagoras. Dominio: pattern frattali ricorrenti su mercati crypto secondo il framework Pythagoras-Malanga (Fourier + frattali H-C + Evideon).",
"_schema": "3.2",
"_changelog": "v1.0 - Initial release. Schema clonato da strategy_crypto v3.2 con contenuto Pythagoras-aligned.",
"_focus_metrics_design": "Le focus_metrics sono ENFASI per la lente, non filtri. 4 per stile. Devono includere almeno 1 dei 3 indicatori Pythagoras (candle_pattern, pythagorean_ratio, fractal_mirror).",
"_design_invariants": "(1) ASCII-safe; (2) Archetipo dominante: <metafora> come ancora; (3) Lookback range esplicito; (4) Prima frase 'Il mercato e ...'; (5) Lunghezza directive 800-950 char.",
"_param_encoding_note": "Promosso a campo top-level 'custom_indicators_spec' (v3.2). Vedi sotto.",
"custom_indicators_spec": "Indicatori Pythagoras (oltre a sma/sma_pct/rsi/atr/atr_pct/realized_vol/macd/macd_pct gia documentati sopra). REGOLA CRITICA: params accetta SOLO numeri float, MAI stringhe e MAI altri nodi.\n\n {\"kind\": \"indicator\", \"name\": \"candle_pattern\", \"params\": [length, sym0, sym1, ..., sym_{length-1}]}\n length: int in [3,12] (numero di candele consecutive che devono matchare)\n sym_i: int in {0, 1, 2} (0=U up close>open, 1=D down close<open, 2=doji)\n Esempio: pattern U-D-U di 3 candele = params=[3, 0, 1, 0]\n Esempio: pattern D-D-D-U (reversal classico) = params=[4, 1, 1, 1, 0]\n Output: 1.0 se le ultime length candele matchano, 0.0 altrimenti. Confronta solo con literal 0.0 o 1.0.\n\n {\"kind\": \"indicator\", \"name\": \"pythagorean_ratio\", \"params\": [lookback]}\n lookback: int in [12,200] (finestra rolling per max/min close)\n Esempio: ratio su finestra 89 (Fibonacci) = params=[89]\n Output: max(close[-lookback:]) / min(close[-lookback:]), adimensionale >= 1.0.\n Confronta con literal vicini a phi=1.618, 1/phi=0.618 (in realta usa 1.618 perche ratio >= 1), sqrt2=1.414, pi/2=1.571, e=2.718.\n\n {\"kind\": \"indicator\", \"name\": \"fractal_mirror\", \"params\": [k, axis_int]}\n k: int in [3,12] (lunghezza finestra di confronto)\n axis_int: int in {0, 1} (0=h mirror temporale, 1=v mirror prezzo)\n Esempio: mirror temporale su 8 candele = params=[8, 0]\n Esempio: mirror prezzo su 6 candele = params=[6, 1]\n Output: correlation di Pearson in [-1.0, 1.0] tra finestra e suo mirror. Confronta con literal in (-1, 1), tipicamente |0.5|-|0.8|.\n\nESEMPI di confronti CORRETTI:\n candle_pattern di 3 candele U-D-U attiva: {\"op\": \"eq\", \"args\": [{\"kind\":\"indicator\",\"name\":\"candle_pattern\",\"params\":[3,0,1,0]}, {\"kind\":\"literal\",\"value\":1.0}]}\n ratio phi entro 0.5%: {\"op\": \"and\", \"args\": [{\"op\":\"gt\",\"args\":[{\"kind\":\"indicator\",\"name\":\"pythagorean_ratio\",\"params\":[89]},{\"kind\":\"literal\",\"value\":1.610}]}, {\"op\":\"lt\",\"args\":[{\"kind\":\"indicator\",\"name\":\"pythagorean_ratio\",\"params\":[89]},{\"kind\":\"literal\",\"value\":1.626}]}]}\n mirror temporale forte: {\"op\": \"gt\", \"args\": [{\"kind\":\"indicator\",\"name\":\"fractal_mirror\",\"params\":[8,0]}, {\"kind\":\"literal\",\"value\":0.7}]}\n\nESEMPI ERRATI (dead branch o validation error):\n candle_pattern con stringa: params=[\"UDU\"] (errato: params devono essere numeri)\n candle_pattern: params=[3, \"U\", \"D\", \"U\"] (errato: usa codici 0/1/2)\n pythagorean_ratio: params=[\"close\"] (errato: params devono essere numeri, non nomi feature)\n pythagorean_ratio: params=[5] (errato: lookback < 12)\n pythagorean_ratio: params=[89, 1.618] (errato: arity 1, non 2)\n fractal_mirror: params=[0, 1] (errato: k < 3)\n fractal_mirror: params=[100, 0] (errato: k > 12)\n fractal_mirror: params=[8] (errato: arity 2, manca axis_int)",
"agent_role": "Sei un agente generatore di ipotesi di trading quantitativo per un sistema swarm coevolutivo che cerca pattern frattali ricorrenti sui mercati crypto secondo il framework Pythagoras-Malanga (frattali H-C, trasformata di Fourier, geometria Evideon). Sei parte di una popolazione che esplora collettivamente lo spazio dei pattern: la diversita delle ipotesi e un asset critico. Preferisci esplorare territori meno ovvi rispetto a quelli che la tua lente cognitiva renderebbe predicibili. La strategia che produci deve essere riconoscibile come emanata dal tuo stile.",
"pattern_guidance": "Forme da cercare:\n - Sequenze candle 3-12 lunghezza in alfabeto {U,D,doji} via candle_pattern\n - Ratios di prezzo vicini a 1.618 (phi), 0.618 (1/phi), 1.414 (sqrt2), 1.571 (pi/2), 2.718 (e) via pythagorean_ratio\n - Mirror H/V come proiezioni via fractal_mirror\n - Pattern composti: lunghi come concatenazione di corti (Pattern 13 = Pattern 3 + Pattern 11 nel paper)\n - Cicli ricorrenti via Poincare: stesso pattern firato a distanze regolari\nCerca pattern che si REPLICANO su BTC e ETH al ~stesso timestamp (entro 36 barre = 3h su 5m TF).",
"instruction": "Genera una strategia che cerchi pattern frattali ricorrenti coerenti col framework Pythagoras-Malanga, riconoscibile come emanata dal tuo stile.",
"domain_warnings": "Crypto trada 24/7 senza CME gap. I numeri sacri (Solfeggio 396-417-528-639-741-852 Hz, 137.0359, 121.449, 161.86) sono prior teorici, NON leggi: usali come scale candidate per literal, non come dogma. Il paper Pythagoras e' esplicitamente non-falsificabile (cita 'consapevolezza del trader' come jolly): il backtest e' l'unico arbitro. La tolleranza +/-3h del paper su 5m TF = +/-36 barre per il bonus di asset-invariance.",
"anti_patterns": "Evita: (1) sequenza candle_pattern con piu di 7 simboli vincolati (overfitting); (2) pythagorean_ratio con tolleranza > 2% sui literal (numerologia spuria); (3) fractal_mirror con k = lookback_window (tautologico, sempre prossimo a 1.0); (4) letterali con piu di 4 decimali (es. 1.6180339 -> usa 1.618); (5) piu di 4 condizioni in AND; (6) crossover tra indicatori dello stesso tipo con lookback vicini (chattering); (7) time_in_market > 80% (leveraged buy&hold camuffato).",
"output_priorities": "Trade-off: (1) coerenza con la lente cognitiva: la strategia deve essere riconoscibile come emanata dal tuo stile (es. pythagorean usa ratios, candle_grammarian sequenze esplicite); (2) asset-invariance: segnali che attivano sia su BTC che su ETH entro 36 barre (e' il bonus della fitness); (3) selettivita: poche entry forti, alto SNR; (4) composizionalita: pattern lunghi come somma di corti; (5) robustezza vs random baseline (3-sigma richiesto dal skeptic_quant).",
"styles": {
"pythagorean": {
"directive": "Il mercato e un'armonia di ratios sacri: la sezione aurea (phi=1.618 e 1/phi=0.618), la radice di due (1.414), pi mezzi (1.571) ed e mezzi (1.359) strutturano i livelli di supporto, resistenza e target. Usa pythagorean_ratio con lookback Fibonacci (89, 144) per misurare l'estensione del range recente e cerca punti dove esso e' prossimo a phi entro 0.5%: indica un swing maturo che tende all'inversione armonica. Combina con candle_pattern di 3-5 candele per timing. Evita di chiudere troppo presto: la potenza di un ratio sacro si manifesta quando il prezzo passa il livello e poi torna a testarlo. Mai usare letterali con piu di 4 decimali: l'armonia non e' nei decimali ma nella struttura. Lookback consigliato: 89-144. Archetipo dominante: musicista che ascolta gli accordi nascosti del mercato.",
"focus_metrics": ["pythagorean_ratio", "candle_pattern", "sma_pct", "realized_vol"]
},
"fractal_geometer": {
"directive": "Il mercato e autosimilare: una sequenza di 3 candele si ripete dilatata su 6, 12 candele e oltre. La tua firma e' detectare la stessa struttura a scale diverse. Usa candle_pattern di lunghezza 3 per identificare l'unita atomica, poi cerca conferma con fractal_mirror su finestra 2x o 3x: se la correlation e' alta significa che la scala maggiore replica la struttura. Pythagorean_ratio con lookback grande (>100) misura l'ampiezza globale del frattale e i suoi target di proiezione. Atr_pct conferma che la volatilita corrente e coerente con la scala. Evita pattern di lunghezza > 6: i frattali complessi vanno composti, non vincolati esplicitamente. Mai usare fractal_mirror con k uguale al lookback_window. Lookback consigliato: 48-144. Archetipo dominante: geometra che misura la dimensione di Hausdorff del prezzo.",
"focus_metrics": ["candle_pattern", "fractal_mirror", "atr_pct", "pythagorean_ratio"]
},
"fourier_analyst": {
"directive": "Il mercato e somma di seni: una funzione complessa scomposta in frequenze armoniche distinte. Il tuo compito e' isolare le componenti ricorrenti dominanti. Usa sma_pct su 3 lookback diversi (es. 30, 60, 120) come proxy delle componenti a bassa frequenza; quando 2 su 3 si allineano nella stessa direzione, la frequenza dominante e' chiara e attiva l'entry. Realized_vol misura l'energia totale del segnale; un calo improvviso indica trasformazione di fase. Candle_pattern di 4-6 candele identifica l'inviluppo locale del segnale composito. Cerca pattern dove il presente e' la proiezione del passato dopo una traslazione temporale costante (mappa di Poincare). Evita oscillatori dello stesso tipo a lookback vicini: producono chattering inutile. Lookback consigliato: 60-200. Archetipo dominante: ingegnere del segnale che ricostruisce la portante armonica nascosta.",
"focus_metrics": ["sma_pct", "realized_vol", "candle_pattern", "atr"]
},
"evideonic_projector": {
"directive": "Il mercato e il presente proiettato dal passato via due operazioni geometriche: mirror H (inversione temporale) e mirror V (inversione prezzo), poi scalato per phi, 1/phi, sqrt2. Il tuo segnale principale e' fractal_mirror su entrambi gli assi: quando l'h mirror su 6-12 candele e' altamente positivo (>0.7), il pattern recente e' un riflesso temporale di un pattern precedente, segnale di prosecuzione armonica; quando il v mirror e' positivo, e' un riflesso direzionale, segnale di inversione imminente. Usa pythagorean_ratio per fissare i target di proiezione (entry moltiplicata per phi). Candle_pattern serve da gate per filtrare il contesto. Evita target con ratio > 3 (zona di rumore, fuori scala phi). Lookback consigliato: 24-96. Archetipo dominante: proiettore evideonico che riflette ogni evento nei suoi quattro alter-ego speculari.",
"focus_metrics": ["fractal_mirror", "pythagorean_ratio", "candle_pattern", "sma_pct"]
},
"candle_grammarian": {
"directive": "Il mercato e una lingua di tre simboli atomici: U (close>open), D (close<open), doji (close circa open). Ogni parola di 3-12 lettere e' una frase compiuta con significato direzionale. Il tuo dominio e' la sintassi diretta: usa candle_pattern come segnale principale, con sequenze di lunghezza 3-5 per la decisione di entry. Cerca pattern dove l'ultima candela e' U dopo una serie 'DD' o '0D' (reversal classico) o sequenze 'UU0U' che indicano accumulazione con pausa. Volume conferma la pronuncia del pattern: alto volume = pattern ben articolato. Atr modula la dimensione di stop e target. Combina al massimo due candle_pattern indipendenti in AND, mai di piu. Mai pattern di lunghezza > 7 (frasi troppo lunghe = overfitting). Lookback consigliato: 12-48. Archetipo dominante: grammatico che decifra la sintassi delle candele.",
"focus_metrics": ["candle_pattern", "volume", "atr", "realized_vol"]
},
"recurrence_theorist": {
"directive": "Il mercato e regolato dal teorema di Poincare: ogni configurazione torna prima o poi vicino a se stessa nel tempo. Il tuo lavoro e' trovare il pattern di oggi che firo' anche ieri o la settimana scorsa a distanza k regolare. Usa candle_pattern su 5-8 candele come firma riconoscibile, poi fractal_mirror con axis=0 (h, mirror temporale) su lookback grande (100-200) per misurare quanto la struttura corrente assomigli a quella passata invertita: se >0.5, il ciclo e' nella fase di ripetizione attiva. Pythagorean_ratio fornisce i target di estensione del ciclo. Sma_pct su lookback medio conferma il regime macro coerente. Evita pattern troppo corti (3 candele): troppo poco distinti per ricorrenza affidabile. Lookback consigliato: 100-200. Archetipo dominante: archeologo del prezzo che cerca repliche di antichi pattern dimenticati.",
"focus_metrics": ["candle_pattern", "fractal_mirror", "pythagorean_ratio", "sma_pct"]
},
"skeptic_quant": {
"directive": "Il mercato e rumore con piccoli edge statistici nascosti tra il caos. Tu sei l'anticorpo del sistema: non fidarti mai dei pattern senza significativita 3-sigma vs random baseline. Usa realized_vol e atr_pct come gate di regime (entra solo in regimi di volatilita media, percentile 30-70). Sma_pct conferma la direzione del trend macro corrente. Candle_pattern di 3-4 candele puo' essere usato, ma solo come conferma di un setup gia validato da volatilita e trend: mai come segnale primario isolato. Vincolo duro: max 3 condizioni in AND (poche e robuste); literal con almeno il 20% di margine di sicurezza tra entry ed exit. Nessun pythagorean_ratio (numerologia spuria) ne fractal_mirror (tautologici nella tua lente scettica). Lookback: 60-150. Archetipo dominante: scettico quantitativo che falsifica prima di confermare.",
"focus_metrics": ["realized_vol", "atr_pct", "sma_pct", "candle_pattern"]
}
}
}
@@ -0,0 +1,43 @@
"""Bonus invariance: pattern che firano simultaneamente su 2 asset entro tolleranza."""
from __future__ import annotations
import pandas as pd
import pytest
from strategy_pythagoras.fitness_invariance import (
apply_invariance_bonus,
corr_signal,
)
def test_corr_signal_perfect_alignment() -> None:
entries_btc = pd.Series([0, 1, 0, 1, 0], index=[0, 1, 2, 3, 4])
entries_eth = pd.Series([0, 1, 0, 1, 0], index=[0, 1, 2, 3, 4])
assert corr_signal(entries_btc, entries_eth, tolerance_bars=0) == pytest.approx(1.0)
def test_corr_signal_no_overlap() -> None:
entries_btc = pd.Series([0, 1, 0, 0, 0], index=[0, 1, 2, 3, 4])
entries_eth = pd.Series([0, 0, 0, 0, 1], index=[0, 1, 2, 3, 4])
assert corr_signal(entries_btc, entries_eth, tolerance_bars=0) == pytest.approx(0.0)
def test_corr_signal_within_tolerance() -> None:
# entry su BTC a t=1, su ETH a t=3, tolerance=2 -> match
entries_btc = pd.Series([0, 1, 0, 0, 0], index=[0, 1, 2, 3, 4])
entries_eth = pd.Series([0, 0, 0, 1, 0], index=[0, 1, 2, 3, 4])
assert corr_signal(entries_btc, entries_eth, tolerance_bars=2) == pytest.approx(1.0)
def test_apply_invariance_bonus_increases_fitness() -> None:
assert apply_invariance_bonus(1.0, 0.5, 0.3) == pytest.approx(1.0 * (1.0 + 0.3 * 0.5))
def test_apply_invariance_bonus_alpha_zero() -> None:
assert apply_invariance_bonus(1.0, 0.7, 0.0) == pytest.approx(1.0)
def test_corr_signal_zero_entries() -> None:
entries_btc = pd.Series([0, 0, 0], index=[0, 1, 2])
entries_eth = pd.Series([0, 0, 0], index=[0, 1, 2])
assert corr_signal(entries_btc, entries_eth, tolerance_bars=0) == 0.0
@@ -0,0 +1,82 @@
"""Unit-test dei 3 indicatori Pythagoras."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from strategy_pythagoras.indicators import (
candle_pattern,
fractal_mirror,
pythagorean_ratio,
)
@pytest.fixture
def ohlcv_30() -> pd.DataFrame:
"""30 candele sintetiche: pattern alternato U,D,U,D,..."""
n = 30
close = np.array([100.0 + i for i in range(n)]) # monotone
open_ = close - np.tile([1.0, -1.0], n // 2) # U,D,U,D,...
return pd.DataFrame({"open": open_, "high": close + 0.5, "low": open_ - 0.5, "close": close, "volume": [1.0] * n})
# -- candle_pattern -----------------------------------------------------------
def test_candle_pattern_matches_recent(ohlcv_30: pd.DataFrame) -> None:
# Verifica che il symbol mapping U=0,D=1,doji=2 lavori correttamente.
# Con la fixture: indices pari sono U (close>open), dispari sono D (close<open).
# ultime 3 candele (idx 27,28,29) -> D,U,D
out = candle_pattern(ohlcv_30, [3, 1, 0, 1]) # D, U, D
assert out.iloc[-1] == 1.0
out2 = candle_pattern(ohlcv_30, [3, 0, 0, 0]) # U, U, U: no match
assert out2.iloc[-1] == 0.0
def test_candle_pattern_zero_for_short_history(ohlcv_30: pd.DataFrame) -> None:
out = candle_pattern(ohlcv_30, [3, 0, 0, 0])
assert out.iloc[0] == 0.0
assert out.iloc[1] == 0.0
def test_candle_pattern_doji_symbol(ohlcv_30: pd.DataFrame) -> None:
df = ohlcv_30.copy()
# forza la candela [-1] doji: |close-open|/open < 0.001
df.loc[df.index[-1], "open"] = df["close"].iloc[-1] * (1 - 1e-5)
# ultime 3 dopo modifica: D (idx 27), U (idx 28), doji (idx 29)
out = candle_pattern(df, [3, 1, 0, 2])
assert out.iloc[-1] == 1.0
# -- pythagorean_ratio --------------------------------------------------------
def test_pythagorean_ratio_basic(ohlcv_30: pd.DataFrame) -> None:
out = pythagorean_ratio(ohlcv_30, [12])
# ultimi 12 close: 118..129 -> max/min = 129/118
expected = 129.0 / 118.0
assert abs(out.iloc[-1] - expected) < 1e-9
def test_pythagorean_ratio_no_lookahead(ohlcv_30: pd.DataFrame) -> None:
out = pythagorean_ratio(ohlcv_30, [12])
out0 = pythagorean_ratio(ohlcv_30.iloc[:13], [12])
assert abs(out.iloc[12] - out0.iloc[-1]) < 1e-9
# -- fractal_mirror -----------------------------------------------------------
def test_fractal_mirror_h_pattern_inverted(ohlcv_30: pd.DataFrame) -> None:
# mirror temporale: correlazione tra close[-k:] e close[-k:][::-1]
# Per close monotono, correlation(seq, seq_reversed) = -1
out = fractal_mirror(ohlcv_30, [6, 0]) # axis=0 (h)
assert out.iloc[-1] < -0.99
def test_fractal_mirror_v_axis(ohlcv_30: pd.DataFrame) -> None:
out = fractal_mirror(ohlcv_30, [6, 1])
assert out.iloc[-1] < -0.99
def test_fractal_mirror_clamps_initial(ohlcv_30: pd.DataFrame) -> None:
out = fractal_mirror(ohlcv_30, [6, 0])
assert len(out) == len(ohlcv_30)
@@ -0,0 +1,67 @@
"""prompts.json valido, 7 stili Pythagoras-aligned, schema v3.2."""
from __future__ import annotations
import json
from importlib.resources import files
import pytest
EXPECTED_STYLES = {
"pythagorean", "fractal_geometer", "fourier_analyst",
"evideonic_projector", "candle_grammarian", "recurrence_theorist",
"skeptic_quant",
}
@pytest.fixture
def prompts() -> dict:
p = files("strategy_pythagoras") / "prompts.json"
return json.loads(p.read_text(encoding="utf-8"))
def test_schema_version(prompts: dict) -> None:
assert prompts["_schema"] == "3.2"
def test_top_level_fields(prompts: dict) -> None:
for k in (
"agent_role", "pattern_guidance", "instruction", "domain_warnings",
"anti_patterns", "output_priorities", "styles",
):
assert k in prompts, f"missing field: {k}"
def test_styles_set(prompts: dict) -> None:
assert set(prompts["styles"].keys()) == EXPECTED_STYLES
def test_styles_have_directive_and_focus_metrics(prompts: dict) -> None:
for name, style in prompts["styles"].items():
assert "directive" in style, f"{name} missing directive"
assert "focus_metrics" in style, f"{name} missing focus_metrics"
assert len(style["focus_metrics"]) == 4, f"{name} focus_metrics len != 4"
def test_directives_ascii_safe(prompts: dict) -> None:
for name, style in prompts["styles"].items():
directive = style["directive"]
for ch in directive:
assert ord(ch) <= 0x7F, f"{name} directive contains non-ASCII char {ch!r}"
def test_directives_length(prompts: dict) -> None:
for name, style in prompts["styles"].items():
n = len(style["directive"])
assert 800 <= n <= 950, f"{name} directive length {n} outside [800,950]"
def test_directives_first_phrase(prompts: dict) -> None:
for name, style in prompts["styles"].items():
assert style["directive"].startswith("Il mercato e "), \
f"{name} directive must start with 'Il mercato e '"
def test_directives_end_archetype(prompts: dict) -> None:
for name, style in prompts["styles"].items():
assert "Archetipo dominante:" in style["directive"][-200:], \
f"{name} missing Archetipo dominante in last 200 chars"
@@ -0,0 +1,71 @@
"""Validator accetta i 3 nuovi indicatori candle con arity corrette."""
from __future__ import annotations
import json
import pytest
from multi_swarm_core.protocol.parser import parse_strategy
from multi_swarm_core.protocol.validator import ValidationError, validate_strategy
def _strategy_with_indicator(name: str, params: list[float]) -> str:
return json.dumps(
{
"rules": [
{
"condition": {
"op": "gt",
"args": [
{"kind": "indicator", "name": name, "params": params},
{"kind": "literal", "value": 0.5},
],
},
"action": "entry-long",
}
]
}
)
def test_candle_pattern_valid_min_arity() -> None:
s = parse_strategy(_strategy_with_indicator("candle_pattern", [3, 0, 1, 0]))
validate_strategy(s)
def test_candle_pattern_valid_max_arity() -> None:
s = parse_strategy(_strategy_with_indicator("candle_pattern", [12] + [0] * 12))
validate_strategy(s)
def test_candle_pattern_rejects_too_few_params() -> None:
s = parse_strategy(_strategy_with_indicator("candle_pattern", [3, 0, 1]))
with pytest.raises(ValidationError):
validate_strategy(s)
def test_pythagorean_ratio_valid() -> None:
s = parse_strategy(_strategy_with_indicator("pythagorean_ratio", [89]))
validate_strategy(s)
def test_pythagorean_ratio_rejects_zero_params() -> None:
s = parse_strategy(_strategy_with_indicator("pythagorean_ratio", []))
with pytest.raises(ValidationError):
validate_strategy(s)
def test_fractal_mirror_valid_h() -> None:
s = parse_strategy(_strategy_with_indicator("fractal_mirror", [12, 0]))
validate_strategy(s)
def test_fractal_mirror_valid_v() -> None:
s = parse_strategy(_strategy_with_indicator("fractal_mirror", [12, 1]))
validate_strategy(s)
def test_fractal_mirror_rejects_one_param() -> None:
s = parse_strategy(_strategy_with_indicator("fractal_mirror", [12]))
with pytest.raises(ValidationError):
validate_strategy(s)
+324
View File
@@ -0,0 +1,324 @@
{
"run_id": "7f65bd1832d94c638b588aab02fb223e",
"run_name": "phase1-hardened-001",
"n_folds": 4,
"top_k_requested": 5,
"top_k_evaluated": 5,
"symbol": "BTC-PERPETUAL",
"timeframe": "1h",
"start": "2018-09-01T00:00:00+00:00",
"end": "2026-01-01T00:00:00+00:00",
"ohlcv_bars": 64297,
"results": [
{
"genome_id": "9cf506b83bec55f6",
"fitness_is": 0.2847465321576384,
"sharpe_is": 0.6809931251900289,
"folds": [
{
"fold": 0,
"fitness": 0.4454407113532186,
"sharpe": 0.940612398713799,
"dsr": 0.09856838950479485,
"dsr_pvalue": 0.9014316104952051,
"return": 0.12691347502077277,
"max_dd": 0.08467873586477132,
"n_trades": 50,
"test_start": "2022-05-02 12:00:00+00:00",
"test_end": "2023-04-02 08:00:00+00:00"
},
{
"fold": 1,
"fitness": 0.3348901947844796,
"sharpe": 0.6215567075501345,
"dsr": 0.05615871498414856,
"dsr_pvalue": 0.9438412850158514,
"return": 0.1669550204052912,
"max_dd": 0.2425926649805225,
"n_trades": 60,
"test_start": "2023-04-02 09:00:00+00:00",
"test_end": "2024-03-02 05:00:00+00:00"
},
{
"fold": 2,
"fitness": 0.08496628060413243,
"sharpe": -0.291593157960215,
"dsr": 0.006828013272159182,
"dsr_pvalue": 0.9931719867278408,
"return": -0.06496567446731383,
"max_dd": 0.1933746053658072,
"n_trades": 72,
"test_start": "2024-03-02 06:00:00+00:00",
"test_end": "2025-01-31 02:00:00+00:00"
},
{
"fold": 3,
"fitness": 0.10547685784422405,
"sharpe": -0.04385303190774091,
"dsr": 0.013045759744378084,
"dsr_pvalue": 0.986954240255622,
"return": -0.003908970230222186,
"max_dd": 0.05825300658307936,
"n_trades": 31,
"test_start": "2025-01-31 03:00:00+00:00",
"test_end": "2025-12-31 23:00:00+00:00"
}
],
"fitness_oos_mean": 0.24269351114651366,
"fitness_oos_min": 0.08496628060413243,
"fitness_oos_max": 0.4454407113532186,
"fitness_oos_std": 0.15273583802070415,
"sharpe_oos_mean": 0.3066807290989944,
"sharpe_oos_min": -0.291593157960215,
"robust_score": 0.08496628060413243
},
{
"genome_id": "9f1f7ffa40b4900a",
"fitness_is": 0.2653851546757454,
"sharpe_is": 0.7183647852569522,
"folds": [
{
"fold": 0,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2022-05-02 12:00:00+00:00",
"test_end": "2023-04-02 08:00:00+00:00"
},
{
"fold": 1,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2023-04-02 09:00:00+00:00",
"test_end": "2024-03-02 05:00:00+00:00"
},
{
"fold": 2,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2024-03-02 06:00:00+00:00",
"test_end": "2025-01-31 02:00:00+00:00"
},
{
"fold": 3,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2025-01-31 03:00:00+00:00",
"test_end": "2025-12-31 23:00:00+00:00"
}
],
"fitness_oos_mean": 0.0,
"fitness_oos_min": 0.0,
"fitness_oos_max": 0.0,
"fitness_oos_std": 0.0,
"sharpe_oos_mean": 0.0,
"sharpe_oos_min": 0.0,
"robust_score": 0.0
},
{
"genome_id": "aff702f86c30e30f",
"fitness_is": 0.22070023288031862,
"sharpe_is": 0.49220575419744833,
"folds": [
{
"fold": 0,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2022-05-02 12:00:00+00:00",
"test_end": "2023-04-02 08:00:00+00:00"
},
{
"fold": 1,
"fitness": 0.0,
"sharpe": -1.3160615555061568,
"dsr": 2.0480110912755528e-14,
"dsr_pvalue": 0.9999999999999796,
"return": -0.004197667464114874,
"max_dd": 0.004197667464114874,
"n_trades": 1,
"test_start": "2023-04-02 09:00:00+00:00",
"test_end": "2024-03-02 05:00:00+00:00"
},
{
"fold": 2,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2024-03-02 06:00:00+00:00",
"test_end": "2025-01-31 02:00:00+00:00"
},
{
"fold": 3,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2025-01-31 03:00:00+00:00",
"test_end": "2025-12-31 23:00:00+00:00"
}
],
"fitness_oos_mean": 0.0,
"fitness_oos_min": 0.0,
"fitness_oos_max": 0.0,
"fitness_oos_std": 0.0,
"sharpe_oos_mean": -0.3290153888765392,
"sharpe_oos_min": -1.3160615555061568,
"robust_score": 0.0
},
{
"genome_id": "43236904205071d5",
"fitness_is": 0.22070023288031862,
"sharpe_is": 0.49220575419744833,
"folds": [
{
"fold": 0,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2022-05-02 12:00:00+00:00",
"test_end": "2023-04-02 08:00:00+00:00"
},
{
"fold": 1,
"fitness": 0.0,
"sharpe": -1.3160615555061568,
"dsr": 2.0480110912755528e-14,
"dsr_pvalue": 0.9999999999999796,
"return": -0.004197667464114874,
"max_dd": 0.004197667464114874,
"n_trades": 1,
"test_start": "2023-04-02 09:00:00+00:00",
"test_end": "2024-03-02 05:00:00+00:00"
},
{
"fold": 2,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2024-03-02 06:00:00+00:00",
"test_end": "2025-01-31 02:00:00+00:00"
},
{
"fold": 3,
"fitness": 0.0,
"sharpe": 0.0,
"dsr": 0.0,
"dsr_pvalue": 1.0,
"return": 0.0,
"max_dd": 0.0,
"n_trades": 0,
"test_start": "2025-01-31 03:00:00+00:00",
"test_end": "2025-12-31 23:00:00+00:00"
}
],
"fitness_oos_mean": 0.0,
"fitness_oos_min": 0.0,
"fitness_oos_max": 0.0,
"fitness_oos_std": 0.0,
"sharpe_oos_mean": -0.3290153888765392,
"sharpe_oos_min": -1.3160615555061568,
"robust_score": 0.0
},
{
"genome_id": "78a445d3043e0771",
"fitness_is": 0.20950868586433583,
"sharpe_is": 0.2940379509455447,
"folds": [
{
"fold": 0,
"fitness": 0.07182313189843528,
"sharpe": -0.5971649832941454,
"dsr": 0.0029614586254508167,
"dsr_pvalue": 0.9970385413745492,
"return": -0.10657245144370586,
"max_dd": 0.17076725093180228,
"n_trades": 14,
"test_start": "2022-05-02 12:00:00+00:00",
"test_end": "2023-04-02 08:00:00+00:00"
},
{
"fold": 1,
"fitness": 0.04914620786914511,
"sharpe": -0.7350195766937807,
"dsr": 0.0007433210570883239,
"dsr_pvalue": 0.9992566789429117,
"return": -0.03472119511680272,
"max_dd": 0.060780295013844174,
"n_trades": 20,
"test_start": "2023-04-02 09:00:00+00:00",
"test_end": "2024-03-02 05:00:00+00:00"
},
{
"fold": 2,
"fitness": 0.0,
"sharpe": 1.2071345821102195,
"dsr": 0.14855640183813817,
"dsr_pvalue": 0.8514435981618618,
"return": 0.12296871148346566,
"max_dd": 0.05065940776643054,
"n_trades": 9,
"test_start": "2024-03-02 06:00:00+00:00",
"test_end": "2025-01-31 02:00:00+00:00"
},
{
"fold": 3,
"fitness": 0.0,
"sharpe": -1.6874561123211371,
"dsr": 9.510326955509528e-06,
"dsr_pvalue": 0.9999904896730445,
"return": -0.03197955364502458,
"max_dd": 0.032239270519921044,
"n_trades": 7,
"test_start": "2025-01-31 03:00:00+00:00",
"test_end": "2025-12-31 23:00:00+00:00"
}
],
"fitness_oos_mean": 0.0302423349418951,
"fitness_oos_min": 0.0,
"fitness_oos_max": 0.07182313189843528,
"fitness_oos_std": 0.03128704497338385,
"sharpe_oos_mean": -0.4531265225497109,
"sharpe_oos_min": -1.6874561123211371,
"robust_score": 0.0
}
]
}
Generated
+24
View File
@@ -18,6 +18,7 @@ members = [
"multi-swarm-coevolutive", "multi-swarm-coevolutive",
"multi-swarm-core", "multi-swarm-core",
"strategy-crypto", "strategy-crypto",
"strategy-pythagoras",
] ]
[[package]] [[package]]
@@ -900,6 +901,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "multi-swarm-core" }, { name = "multi-swarm-core" },
{ name = "strategy-crypto" }, { name = "strategy-crypto" },
{ name = "strategy-pythagoras" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -917,6 +919,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "multi-swarm-core", editable = "src/multi_swarm_core" }, { name = "multi-swarm-core", editable = "src/multi_swarm_core" },
{ name = "strategy-crypto", editable = "src/strategy_crypto" }, { name = "strategy-crypto", editable = "src/strategy_crypto" },
{ name = "strategy-pythagoras", editable = "src/strategy_pythagoras" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -1980,6 +1983,27 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=18.0" }, { name = "pyarrow", specifier = ">=18.0" },
] ]
[[package]]
name = "strategy-pythagoras"
version = "0.1.0"
source = { editable = "src/strategy_pythagoras" }
dependencies = [
{ name = "multi-swarm-core" },
{ name = "nicegui" },
{ name = "pandas" },
{ name = "plotly" },
{ name = "pyarrow" },
]
[package.metadata]
requires-dist = [
{ name = "multi-swarm-core", editable = "src/multi_swarm_core" },
{ name = "nicegui", specifier = ">=3.11.1" },
{ name = "pandas", specifier = ">=2.2" },
{ name = "plotly", specifier = ">=5.24" },
{ name = "pyarrow", specifier = ">=18.0" },
]
[[package]] [[package]]
name = "tenacity" name = "tenacity"
version = "9.1.4" version = "9.1.4"