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
+18
View File
@@ -49,6 +49,20 @@ def parse_args() -> argparse.Namespace:
default=0.95, default=0.95,
help="Adversarial gate: kill se signal flat > soglia delle bar (default 0.95, ablation 0.98)", 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() return p.parse_args()
@@ -105,6 +119,10 @@ def main() -> None:
prompt_mutation_weight=args.prompt_mutation_weight, prompt_mutation_weight=args.prompt_mutation_weight,
fees_eat_alpha_threshold=args.fees_eat_alpha_threshold, fees_eat_alpha_threshold=args.fees_eat_alpha_threshold,
flat_too_long_threshold=args.flat_too_long_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) run_id = run_phase1(cfg, ohlcv=ohlcv, llm=llm)
+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 Combina :class:`FalsificationReport` (metriche di robustezza) e
:class:`AdversarialReport` (findings euristici) in uno scalare ``>= 0`` che il :class:`AdversarialReport` (findings euristici) in uno scalare ``>= 0`` che il
GA usa per selezione e ranking. GA usa per selezione e ranking.
Versione v1: rispetto alla v0 (DSR meno penalita' lineare di drawdown, clamp **v1** (default, backward compat): ogni finding ``HIGH`` azzera la fitness.
a zero) la formula e' continua e quasi sempre strettamente positiva, in modo Kill-switch hard a 360 gradi.
da fornire un gradient anche su strategie mediocri o con Sharpe negativo.
Restano due kill-switch hard (no-trade, finding HIGH adversarial) che azzerano **v2** (opt-in via ``hard_kill_findings``): solo findings nel set ``hard_kill``
la fitness. 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:: Formula::
sharpe_norm = 0.5 * (tanh(sharpe) + 1.0) # in [0, 1] sharpe_norm = 0.5 * (tanh(sharpe) + 1.0) # in [0, 1]
base = dsr_weight * dsr + sharpe_weight * sharpe_norm base = dsr_weight * dsr + sharpe_weight * sharpe_norm
penalty = 1.0 / (1.0 + drawdown_penalty * max_drawdown) dd_penalty = 1.0 / (1.0 + drawdown_penalty * max_drawdown)
fitness = max(0.0, base * penalty) adv_penalty = 1.0 (v1) o 1/(1+soft*n_soft_high) (v2)
fitness = max(0.0, base * dd_penalty * adv_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.
""" """
from __future__ import annotations from __future__ import annotations
import math import math
from collections.abc import Iterable
from ..agents.adversarial import AdversarialReport, Severity from ..agents.adversarial import AdversarialReport, Severity
from ..agents.falsification import FalsificationReport from ..agents.falsification import FalsificationReport
@@ -36,36 +38,51 @@ def compute_fitness(
drawdown_penalty: float = 1.0, drawdown_penalty: float = 1.0,
dsr_weight: float = 0.5, dsr_weight: float = 0.5,
sharpe_weight: float = 0.5, sharpe_weight: float = 0.5,
hard_kill_findings: Iterable[str] | None = None,
adversarial_soft_penalty: float = 0.4,
) -> float: ) -> float:
"""Calcola la fitness scalare di una strategia (v1, continua). """Calcola la fitness scalare di una strategia.
Args: Args:
falsification: report con DSR, Sharpe, max_drawdown, n_trades. falsification: report con DSR, Sharpe, max_drawdown, n_trades.
adversarial: report con eventuali findings euristici. adversarial: report con eventuali findings euristici.
drawdown_penalty: peso del max drawdown nel denominatore della drawdown_penalty: peso del max drawdown nel denominatore della
penalita' moltiplicativa (default 1.0). Valori piu' alti penalita' moltiplicativa (default 1.0).
penalizzano piu' severamente strategie con DD alto.
dsr_weight: peso del DSR nella base (default 0.5). dsr_weight: peso del DSR nella base (default 0.5).
sharpe_weight: peso dello Sharpe normalizzato nella base sharpe_weight: peso dello Sharpe normalizzato nella base
(default 0.5). (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: Returns:
Fitness ``>= 0``. Zero indica strategia da scartare (no-trade o Fitness ``>= 0``. Zero indica strategia da scartare (no-trade o
kill adversarial). Valori tipici per strategie sane: ``[0.05, 1.0]``. kill adversarial).
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)``.
""" """
if falsification.n_trades == 0: if falsification.n_trades == 0:
return 0.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))) dsr = max(0.0, min(1.0, float(falsification.dsr)))
sharpe_norm = 0.5 * (math.tanh(float(falsification.sharpe)) + 1.0) sharpe_norm = 0.5 * (math.tanh(float(falsification.sharpe)) + 1.0)
base = dsr_weight * dsr + sharpe_weight * sharpe_norm base = dsr_weight * dsr + sharpe_weight * sharpe_norm
penalty = 1.0 / (1.0 + drawdown_penalty * float(falsification.max_drawdown)) dd_penalty = 1.0 / (1.0 + drawdown_penalty * float(falsification.max_drawdown))
return max(0.0, float(base * penalty)) 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 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 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 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( def run_phase1(
@@ -152,7 +156,11 @@ def run_phase1(
severity=finding.severity.value, severity=finding.severity.value,
detail=finding.detail, 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( repo.save_evaluation(
run_id=run_id, run_id=run_id,
genome_id=genome.id, genome_id=genome.id,
+63
View File
@@ -89,3 +89,66 @@ def test_fitness_normalizes_drawdown() -> None:
] ]
for prev, curr in pairwise(fitnesses): for prev, curr in pairwise(fitnesses):
assert prev > curr, f"non monotona: {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