diff --git a/scripts/run_phase1.py b/scripts/run_phase1.py index 17c1d48..f454815 100644 --- a/scripts/run_phase1.py +++ b/scripts/run_phase1.py @@ -49,6 +49,20 @@ def parse_args() -> argparse.Namespace: default=0.95, help="Adversarial gate: kill se signal flat > soglia delle bar (default 0.95, ablation 0.98)", ) + p.add_argument( + "--fitness-v2", + action="store_true", + help=( + "Attiva fitness v2: solo {no_trades, degenerate} azzerano; " + "gli altri HIGH applicano soft penalty multiplicativa" + ), + ) + p.add_argument( + "--fitness-soft-penalty", + type=float, + default=0.4, + help="v2: fattore soft penalty per HIGH non-hard (default 0.4 → 1 HIGH → 0.71x)", + ) return p.parse_args() @@ -105,6 +119,10 @@ def main() -> None: prompt_mutation_weight=args.prompt_mutation_weight, fees_eat_alpha_threshold=args.fees_eat_alpha_threshold, flat_too_long_threshold=args.flat_too_long_threshold, + fitness_hard_kill_findings=( + ("no_trades", "degenerate") if args.fitness_v2 else None + ), + fitness_adversarial_soft_penalty=args.fitness_soft_penalty, ) run_id = run_phase1(cfg, ohlcv=ohlcv, llm=llm) diff --git a/src/multi_swarm/ga/fitness.py b/src/multi_swarm/ga/fitness.py index c2380d0..58196f7 100644 --- a/src/multi_swarm/ga/fitness.py +++ b/src/multi_swarm/ga/fitness.py @@ -1,30 +1,32 @@ -"""Fitness function v1 della Phase 1. +"""Fitness function della Phase 1/2. Combina :class:`FalsificationReport` (metriche di robustezza) e :class:`AdversarialReport` (findings euristici) in uno scalare ``>= 0`` che il GA usa per selezione e ranking. -Versione v1: rispetto alla v0 (DSR meno penalita' lineare di drawdown, clamp -a zero) la formula e' continua e quasi sempre strettamente positiva, in modo -da fornire un gradient anche su strategie mediocri o con Sharpe negativo. -Restano due kill-switch hard (no-trade, finding HIGH adversarial) che azzerano -la fitness. +**v1** (default, backward compat): ogni finding ``HIGH`` azzera la fitness. +Kill-switch hard a 360 gradi. + +**v2** (opt-in via ``hard_kill_findings``): solo findings nel set ``hard_kill`` +azzerano; gli altri HIGH applicano una penalità moltiplicativa +``1 / (1 + soft_penalty * n_soft_high)``. Restituisce gradient continuo anche +su strategie marginalmente killate da gate adversarial, permettendo +all'evoluzione di esplorare zone con 1-2 finding HIGH "soft" (es. +``fees_eat_alpha``, ``flat_too_long``, ``time_in_market_too_high``). Formula:: sharpe_norm = 0.5 * (tanh(sharpe) + 1.0) # in [0, 1] base = dsr_weight * dsr + sharpe_weight * sharpe_norm - penalty = 1.0 / (1.0 + drawdown_penalty * max_drawdown) - fitness = max(0.0, base * penalty) - -Con i default ``dsr_weight = sharpe_weight = 0.5`` la base e' in ``[0, 1]`` e -``penalty`` in ``(0, 1]``: fitness e' bounded in ``[0, 1]`` per input sani e -mai esattamente zero finche' Sharpe e' finito e ``max_dd`` finito. + dd_penalty = 1.0 / (1.0 + drawdown_penalty * max_drawdown) + adv_penalty = 1.0 (v1) o 1/(1+soft*n_soft_high) (v2) + fitness = max(0.0, base * dd_penalty * adv_penalty) """ from __future__ import annotations import math +from collections.abc import Iterable from ..agents.adversarial import AdversarialReport, Severity from ..agents.falsification import FalsificationReport @@ -36,36 +38,51 @@ def compute_fitness( drawdown_penalty: float = 1.0, dsr_weight: float = 0.5, sharpe_weight: float = 0.5, + hard_kill_findings: Iterable[str] | None = None, + adversarial_soft_penalty: float = 0.4, ) -> float: - """Calcola la fitness scalare di una strategia (v1, continua). + """Calcola la fitness scalare di una strategia. Args: falsification: report con DSR, Sharpe, max_drawdown, n_trades. adversarial: report con eventuali findings euristici. drawdown_penalty: peso del max drawdown nel denominatore della - penalita' moltiplicativa (default 1.0). Valori piu' alti - penalizzano piu' severamente strategie con DD alto. + penalita' moltiplicativa (default 1.0). dsr_weight: peso del DSR nella base (default 0.5). sharpe_weight: peso dello Sharpe normalizzato nella base (default 0.5). + hard_kill_findings: nomi di findings che azzerano la fitness se + ``HIGH``. ``None`` (default v1) = TUTTI gli HIGH azzerano. + Per v2 passare es. ``{"no_trades", "degenerate"}``: solo + questi azzerano, gli altri HIGH applicano soft penalty. + adversarial_soft_penalty: in v2, fattore della penalità + moltiplicativa per ogni HIGH soft (default 0.4 → + ``1/(1+0.4*n)``: 1 → 0.71, 2 → 0.56, 3 → 0.45). Returns: Fitness ``>= 0``. Zero indica strategia da scartare (no-trade o - kill adversarial). Valori tipici per strategie sane: ``[0.05, 1.0]``. - - Logica: - 1. ``n_trades == 0`` → 0 (nessuna evidenza, sega subito). - 2. Almeno un finding ``HIGH`` adversarial → 0 (kill). - 3. Altrimenti combina DSR e ``tanh(sharpe)`` normalizzato in - ``[0, 1]``, modulato da una penalita' continua del drawdown - ``1 / (1 + k * max_dd)``. + kill adversarial). """ if falsification.n_trades == 0: return 0.0 - if any(f.severity == Severity.HIGH for f in adversarial.findings): - return 0.0 + + high_findings = [f for f in adversarial.findings if f.severity == Severity.HIGH] + + if hard_kill_findings is None: + # v1: tutti gli HIGH azzerano la fitness. + if high_findings: + return 0.0 + adv_penalty = 1.0 + else: + # v2: solo finding con name in hard_kill_findings azzerano. + hard_set = frozenset(hard_kill_findings) + if any(f.name in hard_set for f in high_findings): + return 0.0 + n_soft_high = sum(1 for f in high_findings if f.name not in hard_set) + adv_penalty = 1.0 / (1.0 + adversarial_soft_penalty * n_soft_high) + dsr = max(0.0, min(1.0, float(falsification.dsr))) sharpe_norm = 0.5 * (math.tanh(float(falsification.sharpe)) + 1.0) base = dsr_weight * dsr + sharpe_weight * sharpe_norm - penalty = 1.0 / (1.0 + drawdown_penalty * float(falsification.max_drawdown)) - return max(0.0, float(base * penalty)) + dd_penalty = 1.0 / (1.0 + drawdown_penalty * float(falsification.max_drawdown)) + return max(0.0, float(base * dd_penalty * adv_penalty)) diff --git a/src/multi_swarm/orchestrator/run.py b/src/multi_swarm/orchestrator/run.py index 66ee388..b9aed56 100644 --- a/src/multi_swarm/orchestrator/run.py +++ b/src/multi_swarm/orchestrator/run.py @@ -52,6 +52,10 @@ class RunConfig: prompt_mutation_weight: float = 0.0 # Phase 2.5: opt-in LLM mutator fees_eat_alpha_threshold: float = 0.5 # adversarial gate, allenta verso 0.7-0.8 flat_too_long_threshold: float = 0.95 # adversarial gate, allenta verso 0.98-0.99 + # Fitness v2: tuple non vuota → soft-kill (solo findings listate azzerano). + # None/empty → v1 (tutti HIGH azzerano, backward compat). + fitness_hard_kill_findings: tuple[str, ...] | None = None + fitness_adversarial_soft_penalty: float = 0.4 def run_phase1( @@ -152,7 +156,11 @@ def run_phase1( severity=finding.severity.value, detail=finding.detail, ) - fit = compute_fitness(fals, adv) + fit = compute_fitness( + fals, adv, + hard_kill_findings=cfg.fitness_hard_kill_findings, + adversarial_soft_penalty=cfg.fitness_adversarial_soft_penalty, + ) repo.save_evaluation( run_id=run_id, genome_id=genome.id, diff --git a/tests/unit/test_fitness.py b/tests/unit/test_fitness.py index c4fc9b5..85df8d3 100644 --- a/tests/unit/test_fitness.py +++ b/tests/unit/test_fitness.py @@ -89,3 +89,66 @@ def test_fitness_normalizes_drawdown() -> None: ] for prev, curr in pairwise(fitnesses): assert prev > curr, f"non monotona: {fitnesses}" + + +# --- Fitness v2 (soft-kill opt-in) --- + + +def test_fitness_v2_soft_high_not_zero() -> None: + """v2: un finding HIGH soft NON azzera, applica solo soft penalty.""" + f = make_falsification(dsr=0.5, sharpe=1.0, max_dd=0.2) + a = AdversarialReport( + findings=[Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x")] + ) + v2 = compute_fitness(f, a, hard_kill_findings=("no_trades", "degenerate")) + v1 = compute_fitness(f, a) + assert v1 == 0.0 + assert v2 > 0.0 + + +def test_fitness_v2_hard_kill_still_zero() -> None: + """v2: finding HIGH in hard_kill_findings azzera comunque.""" + f = make_falsification() + a = AdversarialReport( + findings=[Finding(name="degenerate", severity=Severity.HIGH, detail="x")] + ) + v2 = compute_fitness(f, a, hard_kill_findings=("no_trades", "degenerate")) + assert v2 == 0.0 + + +def test_fitness_v2_multiple_soft_high_penalty_increases() -> None: + """v2: più HIGH soft → penalty cumulativa più severa.""" + f = make_falsification(dsr=0.5, sharpe=1.0, max_dd=0.2) + soft = ("no_trades", "degenerate") + one_soft = AdversarialReport( + findings=[Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x")] + ) + three_soft = AdversarialReport( + findings=[ + Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x"), + Finding(name="flat_too_long", severity=Severity.HIGH, detail="x"), + Finding(name="time_in_market_too_high", severity=Severity.HIGH, detail="x"), + ] + ) + v_one = compute_fitness(f, one_soft, hard_kill_findings=soft) + v_three = compute_fitness(f, three_soft, hard_kill_findings=soft) + assert v_one > v_three > 0.0 + + +def test_fitness_v2_no_findings_equals_v1() -> None: + """v2 senza findings produce esattamente lo stesso valore di v1 (adv_penalty=1.0).""" + f = make_falsification(dsr=0.7, sharpe=1.5, max_dd=0.2) + a = AdversarialReport() + v1 = compute_fitness(f, a) + v2 = compute_fitness(f, a, hard_kill_findings=("no_trades", "degenerate")) + assert v1 == v2 + + +def test_fitness_v2_default_v1_backward_compat() -> None: + """Senza hard_kill_findings (None) comportamento identico a v1: tutti HIGH azzerano.""" + f = make_falsification() + a = AdversarialReport( + findings=[Finding(name="fees_eat_alpha", severity=Severity.HIGH, detail="x")] + ) + assert compute_fitness(f, a) == 0.0 # v1 default + assert compute_fitness(f, a, hard_kill_findings=None) == 0.0 # esplicito None = v1