478 lines
18 KiB
Python
478 lines
18 KiB
Python
"""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 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()
|