"""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 = "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 client (qwen-2.5-72b tier C come da spec progetto - vedi MEMORY: # cambiare modello senza ricalibrare = regressione dimostrata). llm = LLMClient( openrouter_api_key=settings.openrouter_api_key.get_secret_value(), 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, openrouter_base_url=settings.openrouter_base_url, ) # 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()