feat(fitness): v2 soft-kill opt-in (HIGH non hard non azzerano, applicano penalty)

Aggiunto parametro hard_kill_findings opzionale a compute_fitness.
None (default) = v1 backward-compat: ogni HIGH azzera la fitness.
tuple non vuota = v2: solo finding con name nel set azzerano; gli altri
HIGH applicano penalità moltiplicativa
adv_penalty = 1 / (1 + soft_penalty * n_soft_high)
(default soft_penalty=0.4 → 1 HIGH soft = 0.71x, 2 = 0.56x, 3 = 0.45x).

Motivazione: tutti i run Phase 2/2.5 mostrano 55-87 finding HIGH dominanti
da fees_eat_alpha + flat_too_long. La fitness v1 azzera ogni genome con
anche solo 1 HIGH → median sempre 0 perché quasi tutti i genomi sopravvivono
in modo binario (top integro vs zerati). v2 fornisce gradient continuo:
strategie 'quasi-buone' restano valutabili e il GA può evolverle.

Hard kill v2 default: {"no_trades", "degenerate"} (la strategia letteralmente
non funziona — niente da salvare). Tutti gli altri HIGH (fees_eat_alpha,
flat_too_long, time_in_market_too_high, undertrading, overtrading) → soft.

RunConfig: fitness_hard_kill_findings (None = v1, tuple = v2) +
fitness_adversarial_soft_penalty (default 0.4). CLI flag --fitness-v2 imposta
hard_kill_findings = ("no_trades", "degenerate") e
--fitness-soft-penalty per il fattore custom.

+5 test (12 totali in test_fitness, 191 totale suite):
- v2 soft HIGH non azzera
- v2 hard kill ancora azzera
- v2 cumulativo: più soft HIGH = penalty più severa
- v2 senza findings = identico a v1
- backward compat hard_kill_findings=None = v1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 13:52:22 +02:00
parent bf70acc322
commit cf42dd85f3
4 changed files with 134 additions and 28 deletions
+44 -27
View File
@@ -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))
+9 -1
View File
@@ -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,