feat(phase-2.5): Task 6 cost_kind attribution + fees_eat_alpha threshold CLI

Task 6 del piano Phase 2.5 (deferito → ora completato):
- CostRecord: nuovo campo call_kind (default "hypothesis")
- CostTracker.record: accetta call_kind opzionale, summary include
  by_call_kind breakdown (hypothesis vs mutation)
- Schema cost_records: aggiunta colonna call_kind TEXT NOT NULL DEFAULT
  'hypothesis' + migration soft via ALTER TABLE in init_schema (silently
  catched per DB pre-Task 6)
- Repository.save_cost_record: nuova arg call_kind opzionale
- mutate_prompt_llm: accetta cost_tracker/repo/run_id opzionali e logga
  la call mutator con call_kind="mutation" quando sink presente
- weighted_random_mutate, next_generation: propagano cost sink
- orchestrator.run_phase1: passa cost_tracker+repo+run_id a
  next_generation solo se prompt_mutation_weight > 0

Esposto fees_eat_alpha_threshold come parametro AdversarialAgent
(default 0.5 = comportamento Phase 1.5 invariato), propagato via
RunConfig.fees_eat_alpha_threshold e flag CLI
--fees-eat-alpha-threshold. Abilita ablation con soglia 0.7-0.8 senza
modificare codice — adversarial finding dominante in tutti i run
Phase 2/2.5 (50+ HIGH per run).

Tests (+4 → 186 totale):
- test_cost_tracker: default call_kind="hypothesis"; breakdown
  by_call_kind con hypothesis+mutation
- test_mutation_prompt_llm: logging mutation cost con sink completo;
  backward compat senza sink (no errore)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 10:42:13 +02:00
parent 0e01de156f
commit ba4eb09a71
11 changed files with 183 additions and 8 deletions
+7 -2
View File
@@ -59,8 +59,13 @@ class AdversarialReport:
class AdversarialAgent:
"""Agente hand-crafted che applica check euristici a una strategia."""
def __init__(self, fees_bp: float = 5.0) -> None:
def __init__(
self,
fees_bp: float = 5.0,
fees_eat_alpha_threshold: float = 0.5,
) -> None:
self._engine = BacktestEngine(fees_bp=fees_bp)
self._fees_eat_alpha_threshold = fees_eat_alpha_threshold
def review(self, strategy: Strategy, ohlcv: pd.DataFrame) -> AdversarialReport:
signal_fn = compile_strategy(strategy)
@@ -163,7 +168,7 @@ class AdversarialAgent:
# Se gross_pnl <= 0 il check non si applica (gia' perdente).
gross_pnl = sum(t.gross_pnl for t in result.trades)
total_fees = sum(t.fees for t in result.trades)
if gross_pnl > 0 and total_fees / gross_pnl > 0.5:
if gross_pnl > 0 and total_fees / gross_pnl > self._fees_eat_alpha_threshold:
report.findings.append(
Finding(
name="fees_eat_alpha",
+11 -2
View File
@@ -25,12 +25,16 @@ def next_generation(
cfg: GAConfig,
rng: random.Random,
llm: Any | None = None,
cost_tracker: Any | None = None,
repo: Any | None = None,
run_id: str | None = None,
) -> list[HypothesisAgentGenome]:
"""Costruisce la prossima generazione: elitismo + tournament + crossover/mutate.
Quando ``cfg.prompt_mutation_weight > 0`` e ``llm`` è fornito, la mutazione
invoca ``weighted_random_mutate`` che con quella probabilità usa
``mutate_prompt_llm`` (Phase 2.5).
``mutate_prompt_llm`` (Phase 2.5). Cost tracker/repo/run_id si propagano
per registrare ``call_kind="mutation"`` sulle call mutator.
"""
new_pop: list[HypothesisAgentGenome] = list(
elite_select(population, fitnesses, cfg.elite_k)
@@ -44,7 +48,12 @@ def next_generation(
else:
parent = tournament_select(population, fitnesses, cfg.tournament_k, rng)
child = weighted_random_mutate(
parent, rng, llm=llm, prompt_mutation_weight=cfg.prompt_mutation_weight
parent, rng,
llm=llm,
prompt_mutation_weight=cfg.prompt_mutation_weight,
cost_tracker=cost_tracker,
repo=repo,
run_id=run_id,
)
new_pop.append(child)
+9 -1
View File
@@ -82,16 +82,24 @@ def weighted_random_mutate(
rng: random.Random,
llm: Any | None = None,
prompt_mutation_weight: float = 0.0,
cost_tracker: Any | None = None,
repo: Any | None = None,
run_id: str | None = None,
) -> HypothesisAgentGenome:
"""Dispatcher pesato fra mutate_prompt_llm e random_mutate scalare.
Con probabilità ``prompt_mutation_weight`` invoca ``mutate_prompt_llm``,
altrimenti ``random_mutate``. Se ``llm`` è ``None`` o il peso è 0,
è equivalente a ``random_mutate`` (backward-compat).
Se ``cost_tracker``, ``repo`` e ``run_id`` sono forniti, vengono propagati a
``mutate_prompt_llm`` per tracciare la call con ``call_kind="mutation"``.
"""
if llm is not None and prompt_mutation_weight > 0 and rng.random() < prompt_mutation_weight:
# Import inline per evitare ciclo: mutation_prompt_llm importa da mutation.
from .mutation_prompt_llm import mutate_prompt_llm
return mutate_prompt_llm(g, llm, rng)
return mutate_prompt_llm(
g, llm, rng, cost_tracker=cost_tracker, repo=repo, run_id=run_id
)
return random_mutate(g, rng)
@@ -130,6 +130,9 @@ def mutate_prompt_llm(
rng: random.Random,
mutator_tier: ModelTier = ModelTier.B,
max_tokens: int = 2000,
cost_tracker: Any | None = None,
repo: Any | None = None,
run_id: str | None = None,
) -> HypothesisAgentGenome:
"""Operatore di mutazione prompt-level via LLM mutator.
@@ -137,6 +140,9 @@ def mutate_prompt_llm(
LLM tier B per ottenere il prompt mutato, valida l'output. Su validation
fail (output troppo corto, non-strategia, troppo simile al parent),
fallback silenzioso a ``random_mutate``.
Se ``cost_tracker``, ``repo`` e ``run_id`` sono forniti, la chiamata mutator
viene registrata con ``call_kind="mutation"`` per audit budget.
"""
instruction_key = rng.choice(list(MUTATION_INSTRUCTIONS))
instruction = MUTATION_INSTRUCTIONS[instruction_key]
@@ -160,6 +166,28 @@ def mutate_prompt_llm(
except Exception:
return random_mutate(g, rng)
# Cost tracking call_kind="mutation" se sink fornito.
if cost_tracker is not None and repo is not None and run_id is not None:
in_tok = getattr(result, "input_tokens", 0)
out_tok = getattr(result, "output_tokens", 0)
cr = cost_tracker.record(
input_tokens=in_tok,
output_tokens=out_tok,
tier=mutator_tier,
run_id=run_id,
agent_id=g.id,
call_kind="mutation",
)
repo.save_cost_record(
run_id=run_id,
agent_id=g.id,
tier=mutator_tier.value,
input_tokens=in_tok,
output_tokens=out_tok,
cost_usd=cr.cost_usd,
call_kind="mutation",
)
new_prompt = _extract_prompt(getattr(result, "text", ""))
if not is_valid_prompt(new_prompt, g.system_prompt):
return random_mutate(g, rng)
+12
View File
@@ -30,6 +30,7 @@ class CostRecord:
input_tokens: int
output_tokens: int
cost_usd: float
call_kind: str = "hypothesis" # "hypothesis" | "mutation"
@dataclass
@@ -43,6 +44,7 @@ class CostTracker:
tier: ModelTier,
run_id: str,
agent_id: str,
call_kind: str = "hypothesis",
) -> CostRecord:
cost = estimate_cost(input_tokens, output_tokens, tier)
rec = CostRecord(
@@ -53,6 +55,7 @@ class CostTracker:
input_tokens=input_tokens,
output_tokens=output_tokens,
cost_usd=cost,
call_kind=call_kind,
)
self.records.append(rec)
return rec
@@ -61,16 +64,25 @@ class CostTracker:
by_tier: dict[str, dict[str, float]] = defaultdict(
lambda: {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
)
by_call_kind: dict[str, dict[str, float]] = defaultdict(
lambda: {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0}
)
for r in self.records:
t = r.tier.value
by_tier[t]["calls"] += 1
by_tier[t]["input_tokens"] += r.input_tokens
by_tier[t]["output_tokens"] += r.output_tokens
by_tier[t]["cost_usd"] += r.cost_usd
ck = r.call_kind
by_call_kind[ck]["calls"] += 1
by_call_kind[ck]["input_tokens"] += r.input_tokens
by_call_kind[ck]["output_tokens"] += r.output_tokens
by_call_kind[ck]["cost_usd"] += r.cost_usd
return {
"calls": len(self.records),
"input_tokens": sum(r.input_tokens for r in self.records),
"output_tokens": sum(r.output_tokens for r in self.records),
"cost_usd": sum(r.cost_usd for r in self.records),
"by_tier": dict(by_tier),
"by_call_kind": dict(by_call_kind),
}
+8 -1
View File
@@ -50,6 +50,7 @@ class RunConfig:
n_trials_dsr: int = 50
db_path: Path = field(default_factory=lambda: Path("./runs.db"))
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
def run_phase1(
@@ -78,7 +79,10 @@ def run_phase1(
falsification_agent = FalsificationAgent(
fees_bp=cfg.fees_bp, n_trials_dsr=cfg.n_trials_dsr
)
adversarial_agent = AdversarialAgent(fees_bp=cfg.fees_bp)
adversarial_agent = AdversarialAgent(
fees_bp=cfg.fees_bp,
fees_eat_alpha_threshold=cfg.fees_eat_alpha_threshold,
)
cost_tracker = CostTracker()
population = build_initial_population(
@@ -178,6 +182,9 @@ def run_phase1(
population = next_generation(
population, fitnesses, ga_cfg, rng,
llm=llm if cfg.prompt_mutation_weight > 0 else None,
cost_tracker=cost_tracker if cfg.prompt_mutation_weight > 0 else None,
repo=repo if cfg.prompt_mutation_weight > 0 else None,
run_id=run_id if cfg.prompt_mutation_weight > 0 else None,
)
repo.complete_run(
+12 -2
View File
@@ -26,6 +26,14 @@ class Repository:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with self._conn() as conn:
conn.executescript(SCHEMA_SQL)
# Migration soft per DB pre-Task 6: aggiunge call_kind se manca.
try:
conn.execute(
"ALTER TABLE cost_records ADD COLUMN call_kind "
"TEXT NOT NULL DEFAULT 'hypothesis'"
)
except sqlite3.OperationalError:
pass # colonna già presente
@staticmethod
def _now() -> str:
@@ -184,12 +192,13 @@ class Repository:
input_tokens: int,
output_tokens: int,
cost_usd: float,
call_kind: str = "hypothesis",
) -> None:
with self._conn() as conn:
conn.execute(
"""INSERT INTO cost_records
(run_id, agent_id, ts, tier, input_tokens, output_tokens, cost_usd)
VALUES (?,?,?,?,?,?,?)""",
(run_id, agent_id, ts, tier, input_tokens, output_tokens, cost_usd, call_kind)
VALUES (?,?,?,?,?,?,?,?)""",
(
run_id,
agent_id,
@@ -198,6 +207,7 @@ class Repository:
input_tokens,
output_tokens,
cost_usd,
call_kind,
),
)
+1
View File
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS cost_records (
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cost_usd REAL NOT NULL,
call_kind TEXT NOT NULL DEFAULT 'hypothesis',
FOREIGN KEY (run_id) REFERENCES runs(id)
);