← FORGE

Il Manuale di forgedge

Una guida completa e pratica alla libreria Python FORGE (Feature-Oriented Rule Generation Engine).

Versione coperta: forgedge==0.3.0. Ogni esempio di codice in questo manuale è stato eseguito realmente contro la libreria presente in questo repository; ogni numero citato da un'esecuzione è un valore reale e verificato, non un'illustrazione inventata. Quando il manuale afferma qualcosa che gli autori stessi hanno documentato (a differenza di qualcosa dedotto dalla lettura del codice), lo dichiara esplicitamente.


Indice

  1. Introduzione
  2. Perché esiste FORGE
  3. Quando usarla — e quando no
  4. Concetti fondamentali
  5. Installazione
  6. Il primo esempio funzionante
  7. Quick start: un'esecuzione completa della pipeline
  8. Anatomia del workflow
  9. API e componenti principali
  10. Configurazione
  11. Gestione degli errori
  12. Casi d'uso progressivi
  13. Lavorare con i dati presenti in questo repository
  14. Scelte di design
  15. Comportamenti opt-in
  16. Trade-off
  17. Performance e scalabilità
  18. Testing
  19. Integrare forgedge in un'applicazione reale
  20. Un'architettura production-ready
  21. Troubleshooting
  22. Best practice
  23. Anti-pattern
  24. FAQ
  25. Glossario
  26. Reference API (consultazione rapida)

1. Introduzione

forgedge è una libreria Python che prende una tabella di dati di prezzo storici più indicatori tecnici (una KPI Table) e scopre sistematicamente regole di trading booleane — espressioni come rsi_14 < 31.2 AND spread_ema_9_25 < -0.0118 — che hanno un potere predittivo misurabile e confermato out-of-sample sul movimento futuro del prezzo.

Lo fa attraverso una pipeline a cinque stadi, ognuno dei quali risponde a esattamente una domanda e passa allo stadio successivo un oggetto formale e ispezionabile:

KPI Table  →  [Market Context]  →  [Event Discovery]  →  [Alpha Discovery]  →  [Rule Discovery]  →  [Rule Registry]
 (input)         tag di regime       eventi booleani       target economico     EDGE / NON-EDGE      catalogo + report

L'output di una sessione forgedge non è una previsione e non è un modello addestrato. È un insieme di specifiche formali di regole — una condizione booleana, una direzione (long/short), un periodo di detenzione, una percentuale di take-profit, e un verdetto sostenuto da statistiche walk-forward out-of-sample. Cosa farne di questa specifica (costruire un generatore di segnali, collegarla a un motore di esecuzione, revisionarla manualmente) è interamente a tua discrezione; forgedge non piazza ordini, non parla con un exchange e non conserva alcuno stato di posizione.

Questo manuale presuppone che tu sia uno sviluppatore Python competente che non ha mai visto questa libreria prima. Non presuppone che tu conosca già il gergo della finanza quantitativa — ogni termine (KPI Table, evento, alpha, walk-forward, ConsistencyGate, rotation null...) viene introdotto prima di essere usato. Alla fine, dovresti essere in grado di installare la libreria, eseguire una sessione di discovery completa su dati reali, comprendere ogni parametro di configurazione principale e cosa costa cambiarlo, e avere un piano concreto per integrare forgedge in un'applicazione più grande.


2. Perché esiste FORGE

La documentazione stessa della libreria (README.md) enuncia il problema direttamente:

"La ricerca sistematica di edge soffre di tre problemi ricorrenti: look-ahead bias — soglie di evento calibrate osservando rendimenti che già 'conoscono' il futuro prima della discovery; ottimizzazione in-sample — soglie e orizzonti tarati sulla stessa finestra usata per la valutazione producono backtest circolari; separazione operativa mancante — l'evidenza statistica di potere predittivo non è la stessa cosa della profittabilità sotto commissioni e meccaniche d'ordine reali."

L'architettura è la risposta a questi tre problemi, non un effetto collaterale. La guida architetturale approfondita inclusa nel repository (src/forgedge/docs/README.md, in italiano) lo dichiara come un vincolo esplicito e non negoziabile, non come una convenzione di codifica:

"FORGE mantiene una separazione netta tra tre domini operativi. Ogni confine è un vincolo architetturale, non una convenzione." I tre domini sono: struttura temporale (Event Discovery osserva solo il pattern temporale degli indicatori), potere predittivo statistico (Alpha Discovery è il primo stadio che legge un rendimento forward), e operatività (Rule Discovery e Rule Registry sono gli unici stadi che conoscono commissioni, fill e drawdown).

Concretamente, questo significa:

Il nome e la metafora

La guida architetturale del repository è esplicita sul fatto che il nome sia una metafora deliberata, non un gioco di parole su un acronimo: "Come una fucina metallurgica trasforma il minerale grezzo in uno strumento lavorato attraverso fasi successive, FORGE trasforma una tabella di indicatori tecnici in regole booleane operative attraverso quattro moduli in sequenza — senza mai fare assunzioni sul sistema di esecuzione che le utilizzerà." (src/forgedge/docs/README.md)


3. Quando usarla — e quando no

Usa forgedge quando...

NON usare forgedge per...


4. Concetti fondamentali

Prima di toccare qualsiasi API, costruisci questo modello mentale. forgedge è organizzata attorno a tre concetti formali, ciascuno il deliverable di uno stadio della pipeline, più un concetto di input e uno di supporto:

Concetto Prodotto da Risponde a
KPI Table tu (o forgedge.kpi_builder) "Quali dati sto passando alla pipeline?"
Regime Modulo 0 — Market Context "In che tipo di condizione di mercato si trova la barra t?"
Evento (EventCandidate) Modulo 1 — Event Discovery "Questa condizione sull'indicatore è stabile e ripetibile nel tempo?"
Alpha (AlphaContract) Modulo 2 — Alpha Discovery "Dato che l'evento si è attivato, cosa succede statisticamente nelle barre successive?"
Regola (RuleDiscoveryResponse) Modulo 3 — Rule Discovery "Questo alpha è effettivamente profittevole con meccaniche d'ordine realistiche?"

KPI Table

Un pandas.DataFrame con:

forgedge classifica automaticamente ogni colonna non-timestamp (numerica continua, discreta/binaria, categoriale) e decide come trasformarla — non le dici tu quali colonne usare come feature.

Evento

Un evento è una condizione booleana su barre storiche — es. rsi_14 < 31.2 o la composizione AND rsi_14 < 31.2 AND spread_ema_9_25 < -0.0118 — scoperta puramente dalla struttura temporale di una o più colonne indicatore, senza mai guardare un rendimento forward. Le sue soglie sono distribuzionali (percentili della storia stessa dell'indicatore su quello specifico asset) piuttosto che costanti hardcoded, ed è ciò che rende trasferibile la procedura di discovery tra asset diversi anche se la soglia numerica risultante differisce per asset. Le soglie di un evento sono immutabili nel momento in cui Event Discovery le ha fissate.

Alpha

Un alpha è la risposta empirica a: dato che questo evento si è appena attivato, cosa succede, in media, nelle prossime h barre? Alpha Discovery deriva — mai assume — tre cose per ogni evento: il miglior periodo di detenzione h*, una direzione (long o short), e un livello di take-profit sell_pct. Misura il potere predittivo (Information Coefficient, lift sopra il base rate, Cohen's d) e lo conferma su una coda out-of-sample. L'unica cosa che può rigettare in modo definitivo un alpha è una direzione indeterminata — nessun orizzonte nella griglia scansionata produce un vantaggio finito e a segno determinato. Ogni altra debolezza statistica (IC basso, lift basso, conferma OOS fallita) diventa un diagnostico non bloccante che abbassa il voto in lettere (A-D) del contratto invece di rigettarlo del tutto.

Regola

Una regola è il verdetto operativo su un contratto alpha, prodotto simulando un backtest realistico — ingresso a ordine limite, uscita a take-profit, uno stop basato sull'orizzonte, commissioni per lato — e poi validando il set di parametri selezionato su uno split walk-forward out-of-sample rolling. Il verdetto è una di quattro stringhe: "EDGE", "PARTIAL-EDGE", "NON-EDGE", o "INSUFFICIENT-DATA" (§8, §9, §15). Rule Discovery è esplicitamente l'unico giudice economico della pipeline — un alpha con un profilo statistico perfetto può comunque risultare NON-EDGE.

Flusso, input e invarianti in sintesi

   KPI Table (fornita da te, o costruita con kpi_builder)
        │
        ▼
   Modulo 0 — classifica il regime di mercato di ogni barra
        │  output: + 'regime' (categoriale ordinata a 5 livelli), + 'regime_stable'
        ▼
   Modulo 1 — estrae eventi booleani SOLO dalla storia degli indicatori
        │  output: list[EventCandidate]   (soglie ormai congelate)
        ▼
   Modulo 2 — deriva un target economico per evento, misura il potere predittivo
        │  output: list[AlphaContract]    (h*, direzione, sell_pct — tutti derivati)
        ▼
   Modulo 3 — backtest realistico + validazione walk-forward OOS
        │  output: EDGE / PARTIAL-EDGE / NON-EDGE / INSUFFICIENT-DATA
        ▼
   Modulo 4 — dedup, test di generalizzazione cross-ticker, catalogo + report HTML

I dati fluiscono sempre solo in avanti. Nessun modulo successivo torna a toccare gli interni di un modulo precedente, e — questa è l'invariante da interiorizzare prima di scrivere qualsiasi codice contro questa libreria — nessun modulo può accedere a informazioni che, cronologicamente, il suo predecessore non avrebbe potuto avere. Questa singola regola è ciò per cui esiste l'intera architettura.


5. Installazione

forgedge richiede Python ≥ 3.9 e dipende solo da pandas>=1.5 e numpy>=1.23 (da pyproject.toml). Non ha alcuna dipendenza opzionale necessaria per la pipeline principale.

pip install forgedge

Per eseguire la suite di test o contribuire, installa l'extra dev:

pip install "forgedge[dev]"     # aggiunge pytest>=7.0

Se lavori da un clone di questo repository (come sono stati verificati gli esempi di questo manuale):

git clone https://github.com/mattcond/forgedge
cd forgedge
pip install -e ".[dev]"

Alcune cose che forgedge non richiede, che potresti aspettarti: nessun scipy, nessun statsmodels, nessun database, nessun file di configurazione, nessuna variabile d'ambiente, nessun accesso di rete, nessuna GPU. È una libreria stateless, di puro calcolo — tutto ciò di cui ha bisogno è ciò che le passi in memoria.

Se prevedi di leggere il fixture parquet usato in questo manuale (tests/fixtures/ADA_1D_TRAIN.parquet), ti serve anche un motore parquet — forgedge stessa non lo richiede, ma pandas.read_parquet sì:

pip install pyarrow

Verificare l'installazione:

import forgedge
print(forgedge.__version__)   # "0.3.0" al momento della scrittura

6. Il primo esempio funzionante

Questo è il più piccolo pezzo di codice possibile che dimostra l'idea centrale della libreria: scoprire un evento booleano dalla storia di un indicatore, senza alcun rendimento forward coinvolto. Usa il Modulo 1 (Event Discovery) in isolamento, su una piccola tabella sintetica, così puoi vedere esattamente com'è fatto un EventCandidate prima che entri in gioco qualunque nozione di profittabilità.

import numpy as np
import pandas as pd
from forgedge import EventDiscovery

# Una KPI Table minima: 500 barre orarie con un oscillatore simile a RSI.
rng = np.random.default_rng(42)
n = 500
kpi = pd.DataFrame({
    "open_dt": pd.date_range("2024-01-01", periods=n, freq="1h"),
    "close": 100 * np.cumprod(1 + rng.normal(0, 0.004, n)),
    "close_rsi_14": np.clip(50 + rng.normal(0, 15, n).cumsum() * 0.05, 1, 99),
})

ed = EventDiscovery(kpi)
candidates = ed.run()

print(f"{len(candidates)} candidati evento hanno superato il Consistency Gate")

c = candidates[0]
print("expression:", c.expression)
print("attivazioni:", c.activation_stats.n_activations)
print("trade/mese medi:", round(c.activation_stats.mean_tpm, 3))

Output verificato (l'autore di questo manuale ha eseguito esattamente questo codice contro esattamente questo repository — i tuoi numeri potrebbero differire leggermente con una versione diversa di numpy, dato che il flusso dell'RNG può cambiare tra versioni, ma la forma è rappresentativa):

309 candidati evento hanno superato il Consistency Gate
expression: close crosses_below 95.45
attivazioni: 10
trade/mese medi: 10.0

candidates[0] è un evento di tipo "crossing" costruito direttamente sulla colonna grezza close, non — come si potrebbe immaginare — uno costruito su close_rsi_14. Controllare ed.get_classifications() su questa esatta esecuzione rivela il perché, ed è una sorpresa genuinamente utile:

print(ed.get_classifications())
# {'close': ColumnClassification(..., is_scale_free=True, ...),
#  'close_rsi_14': ColumnClassification(..., is_scale_free=False, ...)}

Questo è l'opposto dell'intuizione naturale (di sicuro un oscillatore limitato è "scale-free" e una serie di prezzo grezza no?). È un risultato reale e verificato, e vale la pena usarlo per correggere quell'intuizione invece di nasconderlo: il test di scale-free (TypeClassifier._is_scale_free, event_discovery/classifier.py) non guarda se una serie è limitata in [0,100]. Divide la serie in blocchi e controlla se il range di valori di ogni blocco si sovrappone abbastanza con quello degli altri (support_overlap_threshold, default 0.5) — cioè "questa serie occupa più o meno lo stesso range per tutto il campione, o deriva verso nuovi livelli nel tempo?" Il close_rsi_14 sintetico di questo esempio giocattolo è stato costruito come 50 + rng.normal(0,15,n).cumsum()*0.05 — un wobble cumulativo lento che deriva attraverso il suo range limitato in modo a blocchi, non stazionario — quindi ha fallito quel test, mentre il random walk geometrico lievemente derivante di close è risultato passarlo su questo specifico seed. La lezione si generalizza oltre questo esempio giocattolo: la classificazione scale-free è un vero test statistico sui tuoi dati effettivi, non un lookup basato sul nome della colonna o sui suoi limiti nominali — un vero RSI calcolato correttamente probabilmente si classificherebbe diversamente, ma non dovresti assumere la classificazione di nessuna colonna senza controllare ed.get_classifications().

Cosa è successo, internamente

  1. EventDiscovery(kpi) ha classificato entrambe le colonne come CONTINUOUS, poi ha eseguito il test scale-free su ciascuna indipendentemente — vedi il riquadro sopra per cosa misura effettivamente quel test. Il flag scale-free governa se una colonna è idonea a costruire feature ratio/spread contro altre colonne della stessa famiglia (Step 2 sotto); non determina se una colonna può avere propri eventi diretti di soglia/crossing, ed è per questo che close — pur non essendo ciò che chiameresti casualmente "scale-free" — ha comunque prodotto il candidato close crosses_below 95.45 in cima alla lista di questa esecuzione.
  2. Ha generato versioni trasformate di ciascuna colonna: rank percentile mobile, z-score mobile e delta semplici, su diverse lunghezze di finestra.
  3. Per ogni serie (base o trasformata), ha provato un catalogo di soglie distribuzionali (es. il 10° percentile della storia stessa di quella serie) e soglie teoriche (livelli z-score fissi come −2.0), ognuna delle quali produce un'espressione booleana candidata — un evento "crossing" sul valore grezzo di una colonna (come con close qui), oppure qualcosa nella forma close_rsi_14 < P10 [P10=...] per una serie trasformata (presente tra i 309 candidati di questa esecuzione — filtra con [c for c in candidates if "rsi" in c.expression] per trovarne uno).
  4. Ogni candidato è passato attraverso il Consistency Gate: si attiva abbastanza spesso (min_tpm), in modo abbastanza consistente tra i mesi (non bursty — dispersion_margin nella modalità di conteggio "episode" di default, max_dispersion in modalità "bar"; i due campi non si sovrappongono, #205), e con abbastanza osservazioni totali da essere statisticamente significativo? I candidati che falliscono vengono scartati silenziosamente — non diventano mai oggetti EventCandidate.
  5. In nessun punto di tutto questo si è guardato il valore futuro di close. Il gate ispeziona solo quando l'evento si è attivato, mai cosa è successo dopo.

Configurazione implicita da notare

Hai chiamato EventDiscovery(kpi) senza argomento config=. Questo significa che sono stati usati silenziosamente i default di DiscoveryConfig():

Nessuna di queste scelte ha coinvolto il rendimento forward di close — quel concetto non esiste ancora a questo stadio della pipeline.


7. Quick start: un'esecuzione completa della pipeline

La sezione 6 ha mostrato un modulo in isolamento. Questa sezione esegue l'intera pipeline con la singola funzione orchestratrice, forge(), su dati reali inclusi in questo repository: tests/fixtures/ADA_1D_TRAIN.parquet — 882 barre giornaliere OHLCV di ADA (Cardano) più 22 colonne di indicatori tecnici precalcolate, che coprono dal 2024-01-01 al 2026-05-31. Questo è lo stesso fixture su cui la suite di regressione golden della libreria stessa fissa i propri valori attesi.

import pandas as pd
from forgedge import forge

kpi = pd.read_parquet("tests/fixtures/ADA_1D_TRAIN.parquet")
print(kpi.shape)                      # (882, 26)
print(list(kpi.columns)[:6])          # ['open_dt', 'high', 'low', 'close', 'open', 'close_ret_03']

result = forge(kpi, ticker="ADAUSDC", timeframe="1D", progress=False)

print("Candidati M1:", len(result.candidates))
print("Promossi M2: ", len(result.promoted))
print("Risposte M3: ", len(result.rule_responses))
print("tradabili (edges()):", len(result.edges()))

Output verificato (l'autore di questo manuale ha eseguito esattamente questo codice contro esattamente questo repository):

(882, 26)
['open_dt', 'high', 'low', 'close', 'open', 'close_ret_03']
Candidati M1: 5356
Promossi M2:   392
Risposte M3:   392
tradabili (edges()): 40

(Ri-verificato dopo #217: il pre-screen di _early_elimination sulla prima finestra di train del walk-forward (§9, "Un floor irraggiungibile è una finestra, non un verdetto") ri-derivava il proprio floor di trade con una formula tarata per uno span di selezione completo, non per quella singola finestra breve — scartando erroneamente alcuni contratti prima ancora che raggiungessero un walk-forward completo. M1/M2 non sono toccati (5356/392, invariati da #205); su M3, tradabili (edges()) è passato da 32 a 40 e il Counter dei verdetti sotto si è mosso con lui — ognuno di questi 8 contratti in più è ora valutato correttamente invece di essere scartato in anticipo su un floor che la sua stessa prima finestra non era mai stata dimensionata per raggiungere.)

Interpretare l'output

from collections import Counter
print(Counter(r.verdict for _, r in result.rule_responses))
# Counter({'NON-EDGE': 344, 'PARTIAL-EDGE': 40, 'INSUFFICIENT-DATA': 8})

Zero verdetti EDGE pieni non è un bug e non è un segno che la libreria "non funzioni" — è il gate della rotation null di default (§14-15) che fa esattamente ciò per cui è progettato. Guarda il singolo miglior candidato PARTIAL-EDGE su questi dati:

partial = [(c, r) for c, r in result.rule_responses if r.verdict == "PARTIAL-EDGE"]
partial.sort(key=lambda x: x[1].in_sample_summary.profit_factor, reverse=True)
c, r = partial[0]
print(c.event_expression, "|", c.direction)
print(r.in_sample_summary.profit_factor, r.in_sample_summary.total_trades)
print(r.walk_forward.consistency, r.walk_forward.oos_summary.profit_factor)
print(r.rejection_reasons)
print(r.entry_optimization.selected_entry, r.entry_optimization.adopted)

Output verificato:

pr_diffnorm_close_vol12_vol24_48 < 0.0833333 | short
176.7489 50
0.75 5.2793
['active_months 12/23 = 52% < 80%', 'search-level rotation null not cleared (rotation_p=1.0000 > 0.05)']
limit True

Questa regola ha un profit factor in-sample eccezionale (176.7 — in-sample quasi ogni trade tranne qualche eccezione è stato vincente), un walk-forward positivo nel 75% delle finestre di test, e un profit factor OOS di 5.3 — ed è comunque limitata a PARTIAL-EDGE. Le rejection_reasons dicono esattamente perché: è attiva solo nel 52% dei mesi della sua finestra (sotto la soglia di copertura dell'80%), e — la ragione più importante — il suo p-value della rotation null a livello di ricerca è 1.0, cioè il test null di rotazione randomizzata proprio di FORGE (§14) ha scoperto che una versione ruotata e disaccoppiata dall'esito della stessa ricerca fa altrettanto bene o meglio. La riga entry_optimization mostra il default entry_mode="auto" (§9, §15) al lavoro: ha valutato questa regola a un ingresso a mercato per primo, poi ha provato un ingresso a limite e lo ha adottato perché il punto limite ha retto out-of-sample. Questo è la libreria che è onesta sulla dimensione del proprio spazio di ricerca, non un falso negativo.

Cosa ha fatto forge(), che non le hai chiesto esplicitamente

Questo è importante, ed è la prima cosa che sorprende i nuovi utilizzatori. forge(kpi, ticker="ADAUSDC", timeframe="1D") senza ulteriore configurazione ha fatto silenziosamente tutto quanto segue:

  1. Risolto l'intero bundle di configurazione tramite il resolver centrale dei parametri e verificato la sua coerenza interna via config_report() — loggato a livello INFO, e salvato su ForgeResult.coherence — prima di eseguire qualunque cosa. Con strict=True (il default) un finding di livello FAIL solleva immediatamente ValueError invece di lasciare che la run produca un muro di rigetti non interpretabili (§9, §15).
  2. Eseguito il Modulo 0 (Market Context) perché non hai passato run_market_context=False e la tabella non aveva già una colonna regime.
  3. Sostituito una horizon_grid calibrata sul giornaliero, per Alpha Discovery, su ogni percorso di codice, non solo su quello "nessun AlphaConfig esplicito" (la issue #196 ha chiuso quel divario) — AlphaConfig.horizon_grid stessa ora ha default UNSET ed è risolta dal timeframe dichiarato dalla sessione, quindi passare un AlphaConfig per cambiare qualcos'altro non tiene più silenziosamente la griglia calibrata sull'orario.
  4. Arricchito la griglia di orizzonti di ogni evento con punti aggiuntivi attorno a 0.5×/1×/2× la finestra dell'indicatore dominante di quell'evento (AlphaConfig.horizon_enrichment, attivo di default) — un'unione con la griglia base, mai una restrizione.
  5. Eseguito la rotation null veloce a livello di ricerca (fast_null=True di default) e annotato rotation_p/rotation_threshold su ogni contratto promosso — questo è esattamente ciò che ha prodotto il tetto PARTIAL-EDGE appena visto.
  6. Costruito un TimeBudget condiviso e purgato per Event/Alpha Discovery (§15) anche se non hai passato alcun argomento time_budget=.
  7. Registrato un HypothesisLedger su result.ledger, contando quanto fosse effettivamente ampia la superficie di ricerca della sessione.
  8. Eseguito Rule Discovery su tutti i 392 contratti promossi con selection_mode="walk_forward" (il default) — cioè i parametri operativi pubblicati provengono solo dall'interno delle finestre di train walk-forward, mai da uno sguardo alla finestra di test finale.
  9. Valutato ogni regola con entry_mode="auto" (il default dalla issue #185, in sostituzione di "limit") — un verdetto a ingresso di mercato è autoritativo, e un ingresso a limite viene pubblicato solo quando supera tre condizioni out-of-sample (§9, §15).
  10. Misurato il conteggio di episodi e la concurrency dei trade per ogni backtest (resp.in_sample_summary.n_episodes / .mean_concurrent_positions / .max_concurrent_positions, issue #168) — run_backtest apre un trade su ogni barra attiva senza controllo di stato flat, quindi le posizioni possono sovrapporsi e lo fanno; questi campi sono ciò che permette di distinguere un conteggio di trade nominale da quello effettivo, rilevante per il capitale (§9).
  11. Saltato il Modulo 4 (Rule Registry) — non perché tu l'abbia disabilitato, ma perché RuleRegistry.from_forge_results ha bisogno di più ticker per dire qualcosa sulla generalizzazione cross-ticker; con una chiamata forge() a singolo ticker viene comunque eseguito e produce un registry, ma ogni regola è classificata ISOLATED (§9).

Nessuno dei punti 3-10 è qualcosa che hai configurato tu. Sono tutti default scelti dagli autori della libreria specificamente affinché il percorso "quick start" e il percorso "configurato a mano" non divergano silenziosamente in onestà. §14-15 spiegano ciascuno di questi in profondità, incluso quali puoi disattivare e cosa costa farlo.


8. Anatomia del workflow

Questa sezione percorre i cinque moduli end-to-end, con abbastanza dettaglio meccanico da poter prevedere cosa farà cambiare un input — non solo come si chiama il metodo pubblico di ogni modulo.

Modulo 0 — Market Context

Input: la KPI Table. Output: la stessa tabella più regime (una categoriale ordinata: STRONG_BEAR < BEAR < NEUTRAL < BULL < STRONG_BULL) e regime_stable (bool).

Internamente, il classificatore di default (EMAProxyClassifier) calcola ratio = ema_short / ema_long e lo colloca in bucket contro quattro soglie (default in modalità fissa [0.975, 0.990, 1.010, 1.025]). La parte interessante è da dove vengono le finestre EMA: di default (auto_window=True), il modulo stima l'half-life locale di un processo mean-reverting Ornstein-Uhlenbeck dalla serie di prezzo stessa (via regressione Hurst/OU: dP_t = const + kappa·P_{t-1} + ε, half_life = -log(2)/log(1+kappa)), poi deriva long_period = round(half_life) e short_period = round(half_life × 0.435). Solo se questa stima non converge, ricade sui default fissi 9/25. mc.window_resolution["source"] dice cosa è successo: "hurst_ou" (convergenza), "fallback", o "configured" (hai impostato auto_window=False e dato finestre esplicite).

regime_stable è True solo una volta che il regime corrente si è mantenuto per almeno stable_window (default 12) barre consecutive — le prime 11 barre di una transizione di regime fresca sono regime_stable=False.

Importante: Event Discovery (Modulo 1) non legge affatto regime — è presente nella tabella ma ignorato durante gli Step 0-5. Solo l'analisi di sensibilità al regime di Alpha Discovery (Step 5 della propria pipeline) lo legge.

Modulo 1 — Event Discovery

È una pipeline interna a cinque step (EventDiscovery.run()), ed è utile conoscere gli step per nome perché i messaggi di errore e i parametri di configurazione vi fanno riferimento:

  1. Classificazione delle colonne (TypeClassifier) — ogni colonna non-timestamp viene etichettata CONTINUOUS, BINARY (esattamente 2 valori distinti), o CATEGORICAL (non numerica, o numerica con ≤ max_categorical_classes valori distinti, default 20). Le colonne continue ricevono un ulteriore flag "scale-free": questa serie è limitata/intrinseca (RSI, una percentuale) o dipendente dal livello di prezzo (una EMA grezza)? L'euristica è deliberatamente conservativa — preferisce perdere una serie scale-free (falso negativo) piuttosto che etichettare erroneamente come scale-free una serie dipendente dal prezzo (falso positivo), perché quest'ultima genererebbe soglie contaminate dal livello di prezzo assoluto.
  2. Generazione di feature (FeatureGenerator) — deriva nuove colonne di arità 2-3: ratio (a/b), spread percentuali ((a-b)/b), differenze normalizzate ((a-b)/σ(a-b)), %B di Bollinger, e posizione-nel-range. La maggior parte delle combinazioni di arità 2 accoppia colonne della stessa famiglia semantica (due EMA, non una EMA e un RSI), ma il generator ha anche diversi pairing di arità 2 dedicati e a scope ristretto oltre a questa regola generale, ciascuno aggiunto per colmare uno specifico limite che la regola di famiglia non poteva raggiungere: - coppie OHLC cross-colonna, cross-tempo — es. "la chiusura di oggi sopra il minimo di ieri" (ogni coppia ordinata di basi OHLC grezze presenti, contro una copia laggata, agli stessi lag usati altrove per le trasformazioni delta); sempre attivo. - una linea MACD contro la propria signal line — abbinate per (base, fast, slow) condivisi, non dal raggruppamento generico same-family, perché una linea deve accoppiarsi con la propria signal line, mai con quella di una configurazione MACD non correlata; si attiva solo se hai abilitato "macd" in build_features() (disabilitato di default, §9). - variazione % di prezzo contro variazione % di volume allo stesso lookback — un segnale di divergenza prezzo-volume (es. "nuovo massimo di prezzo su volume in calo"); si attiva solo se la tua KPI Table porta una colonna di rendimento basata sul volume, che non fa parte della config default di kpi_builder. - le sei colonne di geometria di candle_features() tra loro, e contro close_natr_N — queste colonne dal nome nudo (body, gap, …) non rispettano affatto la convenzione {base}_{indicatore}_{periodo}, quindi senza questo pairing dedicato potrebbero essere usate solo standalone; accoppiate contro l'ATR normalizzato (natr), mai quello grezzo, per evitare di reintrodurre una dipendenza dal livello di prezzo. - un indicatore price-scale (solo SMA/EMA/WMA/HMA) contro una base OHLC grezza laggata — es. close_sma_12[t] > low[t-3]; deliberatamente ristretto alle famiglie di indicatori price-scale (un ratio contro colonne RSI/volatilità/rendimento/drawdown/Bollinger non sarebbe dimensionalmente sensato) e alla sola operazione ratio_. Attivo di default, con un proprio set di lag — DiscoveryConfig.indicator_lag_cross_lags, default (1, 3) (§10) — distinto dal set di lag generale delle trasformazioni delta.

Tutte queste tranne la prima sono aggiunte genuinamente recenti (arrivate dopo che gran parte del walkthrough del Modulo 1 di questo manuale era già stata redatta, e corrette qui dopo aver riletto direttamente il sorgente attuale, secondo la disciplina di verifica propria di questo manuale, §21). Quelle vincolate a una config kpi_builder non-default (MACD, rendimenti di volume, ATR/NATR) non costano nulla su una KPI Table di default; il pairing indicatore-vs-OHLC-base-laggata è incondizionato e aggiunge un costo misurabile — quantificato in §17. 3. Trasformazioni temporali (TransformLayer) — ogni feature dagli step 1-2 riceve anche versioni con rank percentile mobile, z-score mobile e delta semplici, su diverse lunghezze di finestra (48/96/168 barre per pctrank/zscore; 1/3/6/12 barre per delta). 4. Generazione eventi (EventGenerator) — per ogni coppia (feature, trasformazione), prova un catalogo di soglie distribuzionali (percentili p3…p97 della storia stessa di quella serie) e — solo per le serie z-scored — soglie teoriche fisse (±1.0, ±1.5, ±2.0). Ogni soglia produce sia un evento "persistente" (attivo su ogni barra in cui vale la condizione) sia un evento "crossing" (attivo solo sulla barra in cui la condizione diventa vera per la prima volta). 5. Consistency Gate (§10, §15) — ogni candidato evento grezzo deve superare un filtro di rate/dispersione prima di diventare un EventCandidate. Gli eventi singoli che superano il gate vengono poi combinati a coppie (o triple, se max_and_components=3) con AND, e ogni composizione viene ripresentata allo stesso gate — solo le composizioni che a loro volta lo superano diventano candidati AND-composti. Questo sotto-step di composizione gira solo quando max_and_components>1, il che dall'issue #254 Fase 8 non è più il default (DiscoveryConfig() ora ha default max_and_components=1, composizione disattivata): il percorso di default di forge() (two_pass_composition=True) compone a valle invece, dopo la prima passata di grading di Alpha Discovery — vedi il nuovo step 8 nell'esempio del Modulo 2 più sotto. Quanto segue descrive il percorso legacy (ancora pienamente supportato), attivo quando si alza max_and_components manualmente — su EventDiscovery usato standalone, come negli esempi §6-§7 di questo manuale, o via forge(two_pass_composition=False). "Lo stesso gate" è letterale, non solo le stesse soglie: la ri-sottomissione batch di ANDComposer e il percorso a singolo evento chiamano entrambi un'unica decisione di gate condivisa, sensibile alla modalità — così una coppia/tripla composta viene giudicata su rate/conteggio/dispersione per episodio sotto il default event_counting="episode" esattamente come lo sarebbe un evento singolo, e non da una reimplementazione separata solo a barra (#226). Quel calcolo batch per episodio è a sua volta vincolato in memoria indipendentemente dalla lunghezza del dataset (#228): con un gate M1 permissivo (un min_tpm basso, incluso il default della libreria stessa) la maggior parte di un batch di candidati supera il pre-filtro economico e raggiunge il costoso calcolo per episodio a piena dimensione, quindi ANDComposer dimensiona i propri batch interni a partire da un budget di memoria fisso invece che da una costante calibrata su storie brevi — un dettaglio implementativo, non qualcosa da configurare, ma il motivo per cui un run pluriennale su dati orari-o-più-fini non rischia un crash per esaurimento di memoria che uno più breve non rischierebbe. Sotto lo stesso gate M1 permissivo, anche quali coppie/triple specifiche riempiono _MAX_PAIRS/_MAX_TRIPLES era strutturalmente distorto (#230): l'ordine di enumerazione pre-fix esauriva tutte le coppie che coinvolgevano un singolo evento del pool prima di provarne mai un secondo, quindi tutte le 2000 coppie trattenute potevano condividere lo stesso componente — permutare quell'ordine (con un seed fisso, così gli eventi composti restano riproducibili run dopo run) ha risolto il problema, e ANDComposer.compose() ha anche guadagnato un parametro opzionale max_constituent_jaccard per rifiutare una coppia i cui due costituenti si sovrappongono troppo, a costo zero. Generare i candidati grezzi (step 4 sopra) e sottoporli al gate erano due passaggi completamente separati — la serie di attivazione di ogni candidato grezzo restava in memoria per tutto il run, anche per quelli che fallivano il gate — il che su una KPI Table ricca poteva esaurire la memoria disponibile prima ancora che M1 finisse (#232): misurato 9,5 GB per le sole serie di 50.874 candidati grezzi su una tabella a 186 colonne, prima che il gate ne liberasse anche solo uno. Generazione e gate sono ora interlacciati per batch, liberando subito la serie di un candidato che fallisce (4,2x meno memoria trattenuta, misurato sulla stessa tabella) — DiscoveryConfig.retain_raw_events ha default False dalla Fase 8 (era True) e libera la serie di attivazione di un candidato che fallisce il gate non appena viene gateizzato, invece di mantenere in memoria l'intera popolazione pre-gate per tutta la durata del run (~2x RSS di picco più basso end-to-end su scala realistica, oltre alla cifra 4,2x sopra); impostarlo esplicitamente a True su una DiscoveryConfig passata a TargetOptimizer, che legge EventDiscovery.raw_events direttamente e fissa questo valore da sé sulla propria config di fallback — il percorso standard di forge() non legge mai quella proprietà.

Nulla in questi cinque step legge un rendimento forward. Questo è il singolo fatto più importante su questo modulo.

event_distribution_report (#215). config_report() (§9, §11) valida la coerenza della configurazione senza mai toccare i tuoi dati — quindi non può accorgersi del caso in cui un preset sia internamente coerente ma rigetti comunque ogni candidato che uno specifico asset produce davvero. Finora questo si manifestava solo con un log spoglio "M1 Event Discovery — 0 candidate(s)", facile da scambiare per una pipeline rotta invece che per "queste soglie e le statistiche degli eventi di questo asset sono in disaccordo." EventDiscovery.run() ora popola sempre ed.event_distribution_report (str | None, None prima di .run()): un riepilogo testuale della distribuzione di tpm/dispersione osservata su ogni candidato grezzo valutato dal Consistency Gate, confrontata con le soglie configurate. Sotto un tasso di sopravvivenza al gate del 15% suggerisce anche parametri concreti alla mediana osservata (min_tpm, più dispersion_margin o max_dispersion a seconda di event_counting). La riga di stage M1 di forge() porta questo testo ogni volta che Event Discovery viene eseguito davvero (l'iniezione manuale via manual_events= salta .run() del tutto, quindi mantiene la vecchia riga col solo conteggio); result.event_discovery.event_distribution_report lo espone senza costo aggiuntivo.

GateParams.tpm_mode="ranged" (docs/analysis/ranged_tpm_and_market_alignment_proposal.md §2, opt-in, solo conteggio "episode"). Ogni modalità del gate vista finora tratta min_tpm come un floor a senso unico — "almeno così spesso." Impostare tpm_mode="ranged" lo trasforma nel centro di una banda invece: un evento che si attiva troppo spesso viene ora rigettato anch'esso, non solo uno troppo raro. La banda è [min_tpm - tolleranza, min_tpm + tolleranza] (troncata a 0); tpm_tolerance lasciato a UNSET (il default) deriva la mezza-larghezza dalla tolleranza di dispersione della sessione stessa invece che da un valore che scegli tu — σ = sqrt(eff_max_dispersion · min_tpm / n_total_months), tolleranza = 1.959964·σ (il quantile normale a due code al 95%), riusando lo stesso eff_max_dispersion di sessione già calcolato dal criterio di burstiness (deliberatamente quello di sessione, non l'episode_id del singolo evento — accoppiarlo a quest'ultimo premierebbe un evento già bursty con più tolleranza sul tasso, il verso sbagliato per un filtro di qualità). Fissa tpm_tolerance esplicitamente invece per una banda letterale senza alcuna statistica coinvolta — min_tpm=4.0, tpm_tolerance=2.0 significa sempre [2.0, 6.0], su qualunque dataset. Un evento con n_episodes == 0 viene sempre rigettato indipendentemente dalla banda; il criterio di burstiness esistente (episode_id <= eff_max_dispersion) non è toccato — la modalità ranged aggiunge un vincolo sul livello del tasso, non rilassa né sostituisce quello sulla sua regolarità. Combinare tpm_mode="ranged" con event_counting="bar" solleva ValueError — la modalità è definita solo per il conteggio per episodio.

from forgedge.event_discovery.consistency_gate import _eff_max_dispersion, _tpm_band
from forgedge.event_discovery.models import GateParams

n_total_months = 29.37   # la lunghezza di sessione del fixture ADA di questo manuale, §7/§9
p = GateParams(tpm_mode="ranged")   # min_tpm=0.5, dispersion_margin=1.3 (default)
eff = _eff_max_dispersion(n_total_months, p.dispersion_margin)
lo, hi = _tpm_band(p, eff, n_total_months)

Verificato, sulla stessa lunghezza di sessione del fixture ADA di questo manuale: eff_max_dispersion == 1.9146, banda derivata al min_tpm=0.5 di default è [0.146, 0.854] episodi/mese — circa ±0.35 attorno al floor, su una sessione così breve. Imposta invece GateParams(tpm_mode="ranged", min_tpm=4.0, tpm_tolerance=2.0) e la banda è esattamente [2.0, 6.0], indipendentemente dalla lunghezza della sessione, come promette la formula a banda letterale.

Modulo 2 — Alpha Discovery

Data la lista di candidati dal Modulo 1, e per la prima volta nella pipeline, questo modulo legge il percorso futuro del prezzo. Per ogni candidato evento:

  1. Derivazione di orizzonte e direzione. Per ogni orizzonte h nella griglia (eventualmente arricchita), calcola mean_advantage[h] (il vantaggio medio orientato del rendimento forward delle barre attive rispetto a tutte le barre) e un punteggio di selezione dell'orizzonte |mean_advantage[h]| / sqrt(h) — una deflazione "Sharpe-like" che evita di favorire orizzonti brevi solo perché il denominatore della loro t-statistic è più piccolo. h* = argmax_h score[h]. La direzione è "long" se mean_advantage[h*] > 0, "short" se < 0, e "undetermined" se nessun orizzonte nella griglia produce affatto un vantaggio finito e a segno determinato, se |z_h*| è sotto min_direction_t (default 0.5), o — sotto il default require_significant_direction=True — se nessuno dei due test statistici dello step 9 sotto trova l'edge distinguibile dal rumore (False ripristina il comportamento più vecchio, guidato solo dall'argmax).
  2. Derivazione del take-profit. sell_pct = max(quantile(MFE, mfe_quantile=0.5), mfe_floor=0.005), dove MFE è la massima escursione favorevole entro h* barre da ogni barra attiva. Questo è un numero derivato, non un input di configurazione.
  3. Misurazione del potere predittivo (IS): Information Coefficient (correlazione di Spearman tra la feature grezza e il rendimento forward, calcolata una volta e messa in cache per (feature, orizzonte)), win rate/lift sopra il base rate, Cohen's d, un t-test a una coda.
  4. Conferma OOS (quando train_ratio < 1.0, default 0.7): lo stesso target derivato viene replicato sulla coda tenuta fuori, e passa se ha abbastanza attivazioni, un vantaggio orientato positivo, e un p-value abbastanza basso. Fallire qui è un diagnostico non bloccante, non un rigetto.
  5. Sensibilità al regime — IC per regime e win rate, con una classificazione dependency_type (agnostic/conditional/specific/broken/unknown).
  6. Scoring composito — la formula esatta attuale (AlphaConfig.score_weights, default (0.20, 0.25, 0.15, 0.25, 0.15) per (ic, lift, cohens_d, z, breadth)): composite = (w_ic·ic_norm + w_lift·lift_norm + w_d·d_norm + w_z·z_norm + w_breadth·breadth) / Σw, dove ic_norm = clamp01(|IC|/0.10), lift_norm = clamp01(lift/0.30), d_norm = clamp(cohens_d/0.80, -1, 1) (con segno — un effetto avverso trascina in basso il punteggio invece di essere clippato a zero), z_norm = clamp01(|z*|/3.0) (z* è l'eccesso standardizzato sulla rotation-null a h*, cioè il rapporto edge-rumore), e breadth è il termine di ampiezza di regime, scartato (rinormalizzando i pesi restanti) quando non c'è informazione di regime disponibile. Seguono due aggiustamenti: un target statistically_weak — h* selezionato fuori dall'insieme di orizzonti BH-significativi — moltiplica il composito per statistically_weak_penalty (default 0.6), e una conferma OOS positiva aggiunge oos_bonus (default 0.05). Il risultato è clippato in [0,1] e mappato su un voto in lettere da A (≥0.75) a D (<0.25).
  7. Compilazione del contratto. Tutti i candidati con una direzione determinata diventano oggetti AlphaContract con status="HYPOTHESIS"; ogni altra metrica sopra aggiunge solo una stringa a diagnostics — non blocca mai la promozione, e rejection_reasons resta vuoto su un contratto promosso. Questo è dichiarato come un principio di design deliberato: le debolezze statistiche "alimentano il voto, non scartano — Rule Discovery è l'unico giudice economico" (src/forgedge/docs/README.md).
  8. Composizione guidata dal grado (issue #254, forge(two_pass_composition=True), il default dalla Fase 8) — gli step 1-7 sopra girano effettivamente due volte sotto la configurazione di default di forge(). La prima passata assegna un voto A-D a ogni candidato evento 1D del Modulo 1 (la lettera dello step 6, dal composite score). forgedge.composition.grade_guided_compose() accoppia (e, con GradePairingConfig.max_components=3, mette in tripla) i candidati votati usando quella lettera come criterio di pairing — stesso grado per primo, poi un grado adiacente via uno schema radice+partner (default A↔{A,B}, B↔{B,C}, C↔{C,D}) — invece del criterio puramente strutturale di tpm/dispersione/transform_key del Modulo 1. Il caso empirico a supporto (AMZN 1D, docs/analysis/issue_254_two_pass_composition_plan.md): il pairing strutturale produceva 256 candidati e 5 PARTIAL-EDGE/EDGE (1,95%); il pairing guidato dal grado sugli stessi dati ne produceva ~1400+ e 122 (~8-9%) — il voto, che incorpora già informazione sul ritorno futuro, si è rivelato un segnale di pairing molto più informativo della correlazione strutturale. I candidati composti ottengono event_id nuovi e vengono valutati da zero da una seconda passata indipendente di Alpha Discovery (di nuovo gli step 1-7) — nulla viene ereditato dal voto o dal target derivato di un costituente. L'output pooled di quella seconda passata (candidati composti più, di default, i singoli originali — GradePairingConfig.include_singles_in_pass2=True) è ciò su cui opera realmente ogni modulo a valle (il ledger delle ipotesi, il null di rotazione, il Modulo 3, il Modulo 4); ForgeResult.grading_candidates/.grading_contracts mantengono disponibili per audit gli artefatti pre-composizione della prima passata, e ForgeResult.composition_timing riporta il costo in wall-clock di ogni stadio. Passa two_pass_composition=False per saltare del tutto la prima passata e tornare alla composizione single-pass propria del Modulo 1 (step 5 sopra) — la validazione delle Fasi 5/6 ha trovato che il miglioramento del tasso di edge del design a due passate generalizza su ogni asset 1D testato (8/8) senza essere peggiore su 1H, il che è il motivo per cui è il default invece di un'opzione.
  9. Etichettatura di allineamento al mercato e la via di promozione AUC (docs/analysis/ranged_tpm_and_market_alignment_proposal.md §3-§4, attiva di default da quando è stata implementata). Il test di significatività BH-FDR dello step 1 (h_sig) guarda un orizzonte alla volta — un edge reale ma diffuso su tutta la griglia, senza un singolo orizzonte individualmente significativo, viene perso allo stesso modo in cui un candidato con un picco isolato ma rumoroso a un solo orizzonte passerebbe. Due aggiunte, calcolate per ogni candidato indipendentemente dal fatto che una direzione sia mai stata assegnata, e che non toccano mai lift né il voto basato su lift — un diagnostico di allineamento al mercato sovrapposto a ciò che lo step 1 già fa, non un suo sostituto: - Stadio (a), test di significatività sull'intera griglia. Integra il tasso di eccesso per barra, pesato a trapezio (Δ_h / h — il solo Δ_h cumulato sembrerebbe spuriamente "momentum" per qualunque edge persistente ma piatto, per pura aritmetica del rendimento cumulato) sull'intera griglia degli orizzonti e lo testa contro la stessa infrastruttura di rotation-null che h_sig già usa (p_auc, PromotionThresholds.auc_max_p, default 0.10). Un candidato può ora essere promosso da uno dei due test — AlphaContract.promotion_route registra quale: "z_score" (solo BH-FDR), "auc" (solo lo stadio (a) — il caso che il solo test dello step 1 avrebbe lasciato "undetermined"), "both", o None (nessuno dei due, oppure direction=="undetermined"). Per la via "auc" da sola, h* non è argmax|z_h| (quel profilo è per costruzione piatto e diffuso, quindi l'argmax può cadere su un punto poco rappresentativo) ma una regola "elbow": il primo orizzonte in cui il tasso marginale per barra smette di muoversi nella stessa direzione del trend dell'intera griglia, camminando verso l'esterno dall'orizzonte più corto. - Stadio (b), direzione del profilo — calcolato solo quando lo stadio (a) è significativo. La pendenza di Spearman (rho, soglia PromotionThresholds.rho_momentum_threshold, default 0.5) del tasso per barra lungo la griglia degli orizzonti etichetta AlphaContract.nature: "momentum-aligned" (il tasso cresce con l'orizzonte), "mean-reversion-aligned" (il tasso si affievolisce/inverte), "idiosyncratic" (nessuna pendenza chiara), o "non_significativo" (lo stadio (a) non era significativo — il default, e sempre il caso sulla via "z_score" da sola). - horizon_at_boundary (bool, replicato su AlphaContract accanto a nature/promotion_route) risponde a "h* è ancorato al bordo della griglia testata per una ragione che regge al controllo, o è genuinamente a un picco interno?" — su "z_score"/"both" richiede due incrementi consecutivi del punteggio avvicinandosi all'ultimo orizzonte con punteggio finito della griglia (un solo incremento è trattato come rumore); su "auc" è il flag di bordo della stessa regola elbow. Controintuitivamente, non declassa nature — empiricamente correla di più con i candidati genuinamente "momentum-aligned", non di meno (un vero trend-follower è, per definizione, un profilo che non ha smesso di crescere quando la griglia finisce) — trattali come due fatti indipendenti e combinabili su un contratto, mai come una gerarchia.

Verificato, forge_preset("balanced", timeframe="1D") sul fixture ADA, pipeline forge() di default completa (2162 contratti totali):

```python from collections import Counter route = Counter(c.promotion_route for c in result.contracts) # {None: 1643, 'both': 313, 'z_score': 145, 'auc': 61}

nature = Counter(c.derived_target.nature for c in result.contracts if c.promotion_route) # {'mean-reversion-aligned': 284, 'non_significativo': 145, # == i 145 contratti solo "z_score" # 'idiosyncratic': 75, 'momentum-aligned': 15} # 284+75+15 == 313+61, la popolazione auc/both

# h e tasso di bordo, per via di promozione: # both n=313 h mediano=2 tasso horizon_at_boundary=15.3% # z_score n=145 h mediano=2 tasso horizon_at_boundary=16.6% # auc n=61 h mediano=5 tasso horizon_at_boundary=23.0% ```

I 61 contratti solo "auc" sono esattamente quelli che require_significant_direction=False avrebbe lasciato "undetermined" prima — la chiamata forge() di default su questo asset ora deriva una direzione, un nature, e (per 284+75+15=374 di essi) un'etichetta di allineamento al mercato che prima non derivava, senza alcun cambiamento nella derivazione di direction per ogni altro contratto.

Modulo 3 — Rule Discovery

Dato un AlphaContract promosso e il suo EventCandidate di origine, Rule Discovery simula un backtest realistico di esecuzione ordini e lo valida out of sample. Non ri-ottimizza le soglie dell'evento né sovrascrive il target derivato — usa derived_target.holding_period_h/sell_pct solo come centro di una griglia di parametri operativi da esplorare.

Le meccaniche di esecuzione, esattamente:

  1. Su una barra di segnale, viene piazzato un ordine limite a anchor × (1 − buy_drop_pct) (long) o anchor × (1 + buy_drop_pct) (short).
  2. Se il prezzo tocca quel limite entro buy_delay_bar barre, si riempie; altrimenti l'ordine viene cancellato (da qui viene il fill rate).
  3. Dopo un fill, la posizione si chiude a qualunque cosa avvenga prima: la prima barra che chiude oltre il livello di take-profit, o la chiusura della barra target_h dopo il fill (lo "stop di orizzonte").

Una sottigliezza da interiorizzare con precisione: target_h conta le barre dopo la barra di fill, e il gap segnale→fill è sempre esattamente 1 barra (non puoi agire sulla chiusura di una barra prima che sia avvenuta) — quindi lo span totale segnale-uscita è 1 + target_h barre. target_h=0 è legale e significa "esci alla chiusura stessa della barra di fill", non "nessun orizzonte".

La griglia di combinazioni (buy_drop_pct, sell_pct, target_h, buy_delay_bar) viene vagliata in-sample, la combinazione con il punteggio migliore diventa il punto operativo, e quel punto operativo fisso viene poi rivalidato su uno split walk-forward rolling (finestre di train che si espandono o scorrono, n_splits finestre di test concatenate in un unico "track record OOS onesto"). La validazione statistica sopra questo include un Deflated Sharpe Ratio, un controllo di stabilità temporale (PF prima metà vs. seconda metà), e una scomposizione per dipendenza dal regime.

La logica del verdetto (gate esatti, dai default di SelectionCriteria) è coperta in §9 e §15 — versione breve: NON-EDGE è un qualsiasi fallimento rigido (troppo pochi trade, PF IS sotto 1.5, PF OOS sotto 1.0, expectancy non significativa); EDGE richiede il superamento di ogni gate di un insieme più stringente incluso il controllo della rotation null; qualsiasi cosa superi il pavimento NON-EDGE ma non ogni gate EDGE è PARTIAL-EDGE; e INSUFFICIENT-DATA (§15) è un quarto verdetto che declassa un EDGE/PARTIAL-EDGE altrimenti valido quando l'evidenza OOS aggregata è statisticamente troppo debole per sostenere l'affermazione, indipendentemente da quanto buona appaia la stima puntuale.

Modulo 4 — Rule Registry

Ingerisce ogni submission di regola EDGE/PARTIAL-EDGE (NON-EDGE viene silenziosamente saltato, non è un errore) in un RuleDocument. Calcola due matrici di correlazione tra tutti i documenti (overlap di Jaccard delle date di attivazione; correlazione di Spearman dei gain su un asse di date condiviso e zero-paddato), segnala — ma non elimina mai — il più debole di ogni coppia di regole quasi-duplicate (Jaccard ≥ overlap_threshold, default 0.70), e riesegue ogni regola su ogni altro ticker della sessione con le sue soglie assolute ri-percentilate sulla distribuzione di quel ticker. Una regola è GENERIC se supera la barra di profit factor cross-ticker su almeno generic_ratio_threshold (default esattamente 2/3) degli altri ticker testati; altrimenti PARTIAL, SPECIFIC, o (se segnalata come duplicato) ISOLATED. Il registry è interamente stateless tra sessioni — la tabella piatta esportata o il report HTML sono gli unici artefatti di persistenza che produce.


9. API e componenti principali

Questa sezione copre le classi e funzioni principali rivolte all'utente — non un elenco esaustivo (quello è §26), ma abbastanza per scrivere codice reale contro ogni modulo. Ogni firma qui sotto è citata dal sorgente attuale, non parafrasata.

L'orchestratore

forge(
    kpi_table: pd.DataFrame, *,
    ticker: str | None = None, asset: str = "ASSET", timeframe: str = "1H",
    market_context_config: MarketContextConfig | None = None,
    event_discovery_config: DiscoveryConfig | None = None,
    alpha_config: AlphaConfig | None = None,
    rotation_calibration: RotationConfig | None = None,
    fast_null: bool = True,
    time_budget: TimeBudget | None = None,
    rule_discovery_config: RuleDiscoveryConfig | None = None,
    registry_config: RegistryConfig | None = None,
    manual_events: list[CustomEvent] | None = None,
    run_market_context: bool = True,
    run_rule_discovery: bool = True,
    run_registry: bool = True,
    only_validated_events: bool = False,
    rule_discovery_grades: Iterable[str] | None = None,
    progress: bool = True,
) -> ForgeResult

manual_events e event_discovery_config sono mutuamente esclusivi — passare entrambi solleva ValueError. ticker ricade su alpha_config.asset, poi su asset, quando non passato esplicitamente.

forge_multi(frames_by_ticker: dict[str, pd.DataFrame], *, registry_config=None, progress=True, **forge_kwargs) -> tuple[dict[str, ForgeResult], RuleRegistry] esegue forge() una volta per ticker e mette in comune ogni regola tradabile in un unico registry cross-ticker — è il modo naturale per ottenere dal Modulo 4 regole genuinamente classificate GENERIC.

ForgeResult, campo per campo

Il valore di ritorno di forge() è un'unica dataclass piatta — nessun annidamento, ogni artefatto intermedio della run raggiungibile direttamente da result. Questa sezione è un riferimento completo campo per campo, ogni voce verificata contro il sorgente attuale (dataclasses.fields(ForgeResult)) e, dove utile, contro una run reale, così vedi tipi e forme effettivi invece di una loro descrizione.

Campo Tipo Significato
ticker str la stringa ticker con cui la run è stata etichettata — ticker= se dato, altrimenti alpha_config.asset, altrimenti asset
enriched pd.DataFrame KPI Table dopo Market Context (aggiunge regime/regime_stable, o invariata se run_market_context=False)
event_frame pd.DataFrame \| None frame post-pipeline di Event Discovery — passa questo, non enriched, a qualunque cosa richieda le colonne derivate ratio/spread/trasformazione a cui le espressioni degli eventi fanno riferimento; None solo se M1 stesso non è mai girato (gira sempre sotto forge() a meno di iniettare manual_events, nel qual caso è comunque impostato dalla valutazione manuale degli eventi)
candidates list[EventCandidate] ogni output del Modulo 1 sopravvissuto al Consistency Gate
contracts list[AlphaContract] ogni output del Modulo 2, promossi e rifiutati allo stesso modo — stessa lunghezza di candidates, un contratto per candidato
promoted list[AlphaContract] il sottoinsieme di contracts con status="HYPOTHESIS"
rule_responses list[tuple[AlphaContract, RuleDiscoveryResponse]] una coppia (contract, response) per contratto promosso, se M3 è stato eseguito (run_rule_discovery=True, il default) — stessa lunghezza di promoted
registry RuleRegistry \| None output del Modulo 4, se eseguito (run_registry=True, il default)
market_context MarketContext \| None l'istanza live del Modulo 0, per .distribution()/.window_resolution — None se run_market_context=False
event_discovery EventDiscovery \| None l'istanza live del Modulo 1 — .summary(), .df, .event_distribution_report (#215, sopra)
alpha_discovery AlphaDiscovery \| None l'istanza live del Modulo 2 — .summary(), .market_structure
calibration CalibrationReport \| None il report della rotation null (fast_null=True, il default, oppure un rotation_calibration= esplicito) — .tippett_p, .real_stats, .null_arrays
ledger HypothesisLedger \| None contabilità della superficie di ricerca: m1_candidates, m2_horizons, m2_promoted, m3_grid_cells, m2_return_tests — non una correzione, solo una registrazione di quanto grande fosse la ricerca della sessione
time_budget TimeBudget \| None lo split IS/OOS a indice-barra effettivo, purgato, usato dalla sessione — n_bars, split, horizon_bars, purge_bars, embargo_bars
context PipelineContext \| None i fatti di sessione risolti da cui sono stati riempiti i campi UNSET di ogni modulo — timeframe, nomi colonne schema, fee, policy statistica
resolution ResolutionTrace \| None ogni campo di config che il resolver ha derivato — default → risolto, con la regola che si è attivata e gli input letti; .describe() per un riassunto in una riga
coherence ConfigReport \| None il controllo di coerenza con cui la run è stata eseguita (il supporto di strict=True) — .findings, .has_critical, .one_line(); .trace è lo stesso oggetto di .resolution, non una copia

Verificato, sul fixture ADA (forge(kpi, ticker="ADAUSDC", timeframe="1D", progress=False)):

ticker              = 'ADAUSDC'
enriched.shape      = (882, 28)
event_frame.shape   = (882, 174)                 # 174 — ogni feature derivata generata da Event Discovery
len(candidates)     = 5356      len(contracts) = 5356      len(promoted) = 392
len(rule_responses) = 392
ledger    = HypothesisLedger(m1_candidates=5356, m2_horizons=6, m2_promoted=392,
                              m3_grid_cells=15, m2_return_tests=38097)
time_budget = TimeBudget(n_bars=882, split=617, horizon_bars=24, purge_bars=24,
                          embargo_bars=0, event_split=882)
context   = PipelineContext(timeframe='1D', timeframe_declared=True, timestamp_col='open_dt',
                             close_col='close', regime_col='regime', regime_stable_col='regime_stable',
                             fee_per_side=0.002, alpha=0.05, min_sample=10, target_rate_tpm=None,
                             rate_retention=1.0, bars_per_episode=1.76, cross_pf_retention=0.8,
                             net_gain_retention=0.5, only_validated_events=False,
                             n_bars=882, span_months=29.37, inferred_bar_hours=24.0)
resolution.describe() = 'resolution: 30 field(s) derived, 0 left at their documented default'
coherence.one_line()  = due finding WARN — m1_is_window_too_short (lo span IS è corto di
                         ciò che gate_params.min_episodes=10 richiede a min_tpm=0.5 con un
                         margine di Poisson al 95 %) e m3_stricter_than_m1 (il min_tpm di M3
                         legge più severo di quello di M1 una volta convertito nella stessa
                         unità) — informativo, dato che forge(strict=True) solleva solo su
                         un finding FAIL, mai su un WARN

Due cose da notare in questo dump: context/resolution/coherence non sono sempre popolati — sono None se la run non ha mai raggiunto il resolver (non un percorso forge() normale, ma possibile se costruisci ForgeResult a mano) — e i due finding WARN su coherence sono esattamente il tipo di segnale «la pipeline ti dice attorno a cosa sta silenziosamente lavorando» che §14-15 descrivono: nulla è fallito, ma il messaggio nomina esattamente quale manopola una storia più lunga o un min_tpm diverso dovrebbero toccare.

Metodi (tutti di sola lettura, calcolati su richiesta dai campi sopra — nulla è cachato oltre a ciò che è già su result):

result.edges()           # list[tuple[AlphaContract, RuleDiscoveryResponse]]
                          #   coppie dove response.is_edge — verdetto EDGE o PARTIAL-EDGE
result.validated_rules()  # list[RuleDiscoveryResponse] che porta un validated_rule non None
                          #   — EDGE + PARTIAL-EDGE + INSUFFICIENT-DATA (che mantiene il suo
                          #   ValidatedRule per una futura ri-valutazione); NON-EDGE mai
result.submissions()      # list[RuleSubmission] — esattamente le risposte EDGE/PARTIAL-EDGE,
                          #   già pronte per essere ingestite da RuleRegistry.from_forge_results()
result.summary()          # pd.DataFrame, una riga per candidato M1 (non per contratto promosso)

Verificato sulla stessa run: len(result.edges()) == 40 (combacia col Counter del §7 — EDGE+PARTIAL-EDGE), len(result.validated_rules()) == 48 (40 + le 8 risposte INSUFFICIENT-DATA, l'unico punto in cui edges() e validated_rules() divergono — un verdetto può portare una regola validata per una futura ri-valutazione senza essere tradabile ora), len(result.submissions()) == 40 (popolazione identica a edges(), dato che l'ingestione del Modulo 4 è definita sullo stesso gate EDGE/PARTIAL-EDGE). result.summary() è (5356, 34) — una riga per candidato, non per contratto promosso, con colonne:

alpha_id, status, promoted, event_candidate_id, expression, pattern_family,
holding_period_h, sell_pct, direction, mean_advantage, feature, ic, ic_p_value,
ic_admitted, rolling_ic_stable, n_activations, win_rate, base_rate, lift,
fwd_return_mean, cohens_d, t_stat, p_value, fdr_promoted, oos_passed, oos_p_value,
oos_lift, regime_dependency, regime_breadth, composite_score, grade,
rejection_reasons, diagnostics, rule_verdict

rule_verdict è l'unica colonna che summary() aggiunge oltre a ciò che i campi propri di AlphaContract già portano — "EDGE"/"PARTIAL-EDGE"/"NON-EDGE"/"INSUFFICIENT-DATA" quando M3 è girato per quel candidato, NaN per un candidato mai promosso oltre M2 (M3 vede solo promoted, quindi la maggior parte delle 5356 righe qui è NaN — filtra prima con summary()[summary()["promoted"]] se vuoi solo quelle che Rule Discovery ha davvero toccato).

lookup_by_id() — restringere un ForgeResult a una sola regola

lookup_by_id(result: ForgeResult, event_id: Iterable[str] = None, alpha_id: Iterable[str] = None) -> list[ForgeResult]

Una funzione a livello di modulo (from forgedge import lookup_by_id), non un metodo di ForgeResult — restituisce un nuovo ForgeResult (avvolto in una lista di un elemento) con candidates/contracts/promoted/rule_responses filtrati in modo coerente ai soli event_id/alpha_id dati, mentre ogni altro campo (enriched, ticker, registry, context, …) viene riportato invariato via dataclasses.replace. event_id e alpha_id sono mutuamente esclusivi — passarli entrambi, o nessuno dei due, solleva ValueError.

Il motivo per cui restituisce una lista invece di un ForgeResult nudo è la componibilità: il risultato è un Iterable[ForgeResult] drop-in, quindi alimenta direttamente ogni funzione di forgedge.playground e forgedge.deployment.promotion_gate()/export_rules() senza codice di collegamento — il modo naturale per far girare una di quelle su una sola regola invece che su un'intera sessione:

from forgedge import lookup_by_id
from forgedge.deployment import export_rules

alpha_id = result.edges()[0][0].alpha_id     # un qualunque AlphaContract.alpha_id che hai già
one_rule = lookup_by_id(result, alpha_id=[alpha_id])
export_rules(one_rule, "exported_rules/", promotable_only=False)

Verificato, sulla stessa run ADA sopra, isolando la sua prima voce di edges() (alpha_id incorpora la data della run, quindi la stringa esatta cambia da un giorno all'altro — "ALPHA-ADAUSDC-1D-260908-337" il giorno in cui è stato eseguito):

filtered = lookup_by_id(result, alpha_id=[alpha_id])
len(filtered) == 1
len(filtered[0].candidates) == len(filtered[0].contracts) == len(filtered[0].promoted) == len(filtered[0].rule_responses) == 1
filtered[0].enriched.shape == result.enriched.shape   # i campi non correlati restano intatti

Un event_id/alpha_id che non trova corrispondenza produce liste vuote su ogni campo filtrato — mai un errore — quindi lookup_by_id(result, event_id=["typo"]) è un modo sicuro per controllare "questo id esiste davvero" senza un try/except.

Preset

forge_preset(preset: str, timeframe: str, asset: str = "ASSET",
             train_ratio: float = 0.70, **overrides
             ) -> tuple[DiscoveryConfig, AlphaConfig, RuleDiscoveryConfig]

preset è uno tra PRESETS = ["sniper", "balanced", "sweep", "burst"]. Restituisce una tripla — un oggetto config calibrato per modulo (M1/M2/M3) — pretarato per un profilo di ricerca, e scalato per il timeframe che passi. preset_info(preset=None) stampa i parametri numerici risolti per uno o tutti i preset.

Modulo 0 — MarketContext

MarketContext(kpi_table: pd.DataFrame, config: MarketContextConfig | None = None)
mc.run() -> pd.DataFrame
mc.distribution()          # quota di barre per regime, per diagnostica
mc.window_resolution       # {"source": "hurst_ou" | "fallback" | "configured", ...}

Modulo 1 — EventDiscovery, EventCandidate, CustomEvent

EventDiscovery(kpi_table: pd.DataFrame, config: DiscoveryConfig | None = None,
                time_budget: TimeBudget | None = None)
ed.run() -> list[EventCandidate]
ed.df           # frame post-pipeline — passa questo ad AlphaDiscovery, non kpi_table
ed.summary()    # pd.DataFrame, una riga per candidato
ed.event_distribution_report   # str | None — popolato da run(), vedi sotto (#215)

event_distribution_report. config_report() (sotto) valida la coerenza della configurazione senza mai toccare i tuoi dati, quindi non può cogliere il caso in cui un preset è internamente coerente ma respinge comunque ogni candidato che uno specifico asset produce davvero — cosa che prima emergeva solo come una nuda riga di log "M1 Event Discovery — 0 candidate(s)", indistinguibile da una pipeline rotta. run() ora popola sempre ed.event_distribution_report (None prima): un riassunto in testo semplice della distribuzione tpm/dispersione osservata davvero su ogni candidato grezzo valutato dal Consistency Gate, contro le soglie configurate — sotto un tasso di sopravvivenza al gate del 15% suggerisce anche parametri concreti al valore mediano osservato. La riga di log dello stadio M1 di forge() porta questo testo ogni volta che Event Discovery gira davvero (l'iniezione manuale di eventi via manual_events= salta .run() e mantiene la vecchia riga col solo conteggio); result.event_discovery.event_distribution_report lo espone su un ForgeResult a costo zero.

Gli attributi importanti di un EventCandidate: event_id, expression (la condizione booleana, come stringa), event_formula (una resa human-readable), sql_expression (una traduzione SQL compatibile con DuckDB della stessa condizione, utile se vuoi valutare l'evento fuori da Python), components, activation_stats (n_activations, n_active_months, zero_months, max_monthly_share, mean_tpm), consistency_gate (un GateResult), validation (un ValidationResult, solo se il walk-forward era configurato). Il suo metodo .apply(df) -> pd.Series[bool] rivaluta deterministicamente le soglie memorizzate su qualsiasi nuovo frame — nessuna ricalibrazione, nessun look-ahead — e .persist(path) dà un round-trip pickle completo (l'unico metodo che la documentazione descrive come completamente invertibile; la forma JSON di .to_dict() non lo è).

CustomEvent(formula: str, name: str = "")

Per iniettare manualmente una tua ipotesi (es. CustomEvent("close_rsi_14 < 30")) invece di eseguire la discovery automatica. Le formule sono valutate con pandas.DataFrame.eval(). Un CustomEvent attraversa comunque il Consistency Gate, ma un fallimento registra solo un warning — non viene mai scartato. La composizione AND non viene eseguita sugli eventi iniettati manualmente. Si usa via forge(..., manual_events=[...]) — mutuamente esclusivo con event_discovery_config.

Modulo 2 — AlphaDiscovery, AlphaContract

AlphaDiscovery(kpi_table_or_ed_df: pd.DataFrame, candidates: list[EventCandidate],
                config: AlphaConfig, time_budget: TimeBudget | None = None)
ad.run() -> list[AlphaContract]
ad.promoted_contracts(min_lift: float | None = None) -> list[AlphaContract]
ad.summary()   # pd.DataFrame, ordinato per composite score

Fondamentale: passa ed.df (il frame post-pipeline di Event Discovery), non la KPI Table originale — porta già le colonne ratio/spread/trasformazione derivate a cui le espressioni degli eventi fanno riferimento.

Gli attributi importanti di un AlphaContract: alpha_id, status ("HYPOTHESIS"/"REJECTED"), event_candidate_id (rimanda all'EventCandidate di origine), derived_target (holding_period_h, sell_pct, direction, base_rate, mean_advantage), oos_validation, event_stats (win_rate, lift, cohens_d, p_value), regime_analysis, alpha_score (composite_score, grade), rejection_reasons (solo cause bloccanti — vuoto su un contratto promosso), diagnostics (osservazioni non bloccanti che pesano sul grade; normalmente non vuoto sui contratti promossi), rotation_p/rotation_threshold (impostati dalla rotation null a livello di ricerca).

Alternativa al Modulo 2 — TargetOptimizer

Un secondo modo, standalone, di arrivare al Modulo 2, per la domanda inversa: non «cosa predice questo evento?» ma «quali eventi predicono questo target, fissato in anticipo?». Fornisci tu il target — un orizzonte e un rendimento minimo — e TargetOptimizer mina direttamente gli atomi grezzi e le composizioni AND di Event Discovery contro quel target, usando uno score di lift a due proporzioni invece della pipeline derive-per-evento di Alpha Discovery.

from forgedge import TargetOptimizer
from forgedge.alpha_discovery.models import TargetConfig

cfg = TargetConfig(horizon=10, min_return=0.05, side="long")   # +5% entro 10 barre, long
opt = TargetOptimizer(train_df, cfg)
# la DiscoveryConfig propria di opt, quando non passata esplicitamente, ha
# default DiscoveryConfig(train_ratio=1.0, max_and_components=2, retain_raw_events=True)
# -- fissato esplicitamente (issue #254 Fase 8), disaccoppiato dai default
# di classe di DiscoveryConfig (1 / False, calibrati per il percorso
# two-pass di forge()), perché TargetOptimizer compone gli atomi prima
# che sia visto un ritorno qualsiasi e legge ed.raw_events direttamente.
ranked = opt.run() -> pd.DataFrame       # colonne: event_id, n_components, expression,
                                          # n_activations, win_rate_event, win_rate_base, lift, z_score
opt.base_rate           # win rate incondizionato del target, impostato solo dopo run()
opt.candidates           # la list[EventCandidate] sottostante, ordinata come `ranked`

oos = opt.validate_oos(full_df, top_k=10) -> pd.DataFrame   # aggiunge colonne *_oos per le righe top-k
contracts = opt.discover_alpha(config: AlphaConfig | None = None) -> list[AlphaContract]

Campi e default di TargetConfig: horizon: int, min_return: float, side: str ("long"/"short", obbligatorio, nessun default), min_activations: int = 10, min_lift_atoms: float = 1.0, min_lift_result: float = 1.0, target_mode: str = "proj", trend_sma_mult: float = 2.0. Anche qui target_mode ha default "proj" — lo stesso scoring a eccesso-sul-trend che §8/§9/§15 descrivono per AlphaConfig — quindi TargetConfig(horizon=20, min_return=0.10, side="long") senza un target_mode esplicito misura «batti la SMA di trend a 2×20 barre del 10%», non «il close si è mosso +10% in 20 barre». Passa target_mode="abs" per la lettura letterale. min_lift_atoms/min_lift_result/min_activations sono soglie di potatura specifiche dei due passaggi di lift-pruning interni a TargetOptimizer (prima e dopo la composizione AND) — la modalità a target fisso di Alpha Discovery le ignora.

Run verificata, sui primi 600 bar della fixture ADA (horizon=10, min_return=0.05, side="long", target_mode di default "proj"):

run() -> 2235 righe, base_rate = 0.5775
  event_id                       expression                                                         n_act  win_event  win_base  lift   z
  EVT-close_ret_03-PRxZS-1916    pr_close_ret_03_48 > 0.916667 AND zs_close_ema_12_48 < -1            17     1.000      0.5775   1.731  3.53
  EVT-close_ret_03-PRxZS-1912    pr_close_ret_03_48 > 0.916667 AND zs_close_bb_upper_20_48 < -1        22     1.000      0.5775   1.731  4.01

validate_oos(full_df, top_k=5) — le stesse due righe, ora con colonne *_oos:
  n_activations_oos  low_sample_oos  win_rate_oos  base_rate_oos  lift_oos  z_oos
  34                 False           0.7941        0.5872         1.352     2.45
  35                 False           0.9143        0.5872         1.557     3.93

Due meccaniche da conoscere prima di fidarti dei numeri OOS: il passaggio di scoring di validate_oos() bypassa completamente min_activations (è fissato a 1 per il calcolo effettivo di lift/z; il min_activations configurato guida solo il flag diagnostico low_sample_oos, che non filtra nulla), e riassegna l'ordine delle righe di full_df sull'indice del target invece di allinearsi per etichetta d'indice — sicuro solo se cand.apply(full_df) preserva l'ordine originale delle righe di full_df, cosa che avviene per la pipeline standard ma va tenuta a mente se costruisci full_df in modo insolito.

Il fatto che vale la pena documentare esplicitamente: TargetOptimizer sta fuori dal resolver di coerenza dei parametri. target_optimizer.py non importa nulla da forgedge.resolver né da forgedge.unset — a differenza di ogni altro modulo toccato dall'audit #173–#185, è stato scritto prima di quel livello, o deliberatamente tenuto fuori. TargetConfig di per sé non ha campi UNSET (ogni default è un letterale concreto), quindi non ne è toccato in ogni caso — ma l'AlphaConfig interno che discover_alpha() costruisce non passa mai per il resolver di sessione di forge(), dato che TargetOptimizer "non tocca forge() né ForgeResult" per design. Qualsiasi campo con sentinella UNSET su quell'AlphaConfig (horizon_grid, bars_per_day, …) ricade sul default interno di coalesce() di AlphaDiscovery in ogni punto di lettura, non sul valore calibrato per timeframe che forge() avrebbe iniettato — su dati giornalieri o più lenti è la stessa trappola della griglia oraria che §9 e §21 descrivono per Alpha Discovery, raggiunta da una porta diversa. discover_alpha() inoltre sovrascrive silenziosamente config.fixed_target, config.target_mode e config.trend_sma_mult su qualsiasi AlphaConfig gli passi, anche se avevi impostato tu stesso fixed_target — è deliberato (mantiene coerenti lo scoring IS e OOS con il TargetConfig che ha guidato run()), ma significa che l'argomento config= non può essere usato per puntare a un target fisso diverso da quello già passato a TargetOptimizer.__init__.

Modulo 3 — RuleDiscovery, RuleDiscoveryResponse

RuleDiscovery(event_frame: pd.DataFrame, contract: AlphaContract, candidate: EventCandidate,
               config: RuleDiscoveryConfig | None = None)
resp = rd.run() -> RuleDiscoveryResponse

L'event_candidate che passi deve essere quello a cui contract.event_candidate_id effettivamente punta, o il costruttore solleva ValueError.

Sovrapposizione — quanto capitale costano questi numeri

run_backtest apre una posizione su ogni barra attiva, senza controllo di stato flat. È deliberato e resta così: è una politica legittima che presuppone capitale, e l'economia riportata è riproducibile dal vivo dato abbastanza capitale per finanziare le posizioni concorrenti. Quello che mancava era un modo per sapere quanto capitale sia (issue #168) — i report portavano una frase fissa sulle «posizioni sovrapposte» senza alcun numero, quindi una regola che richiede 1× il capitale di una posizione e una che ne richiede 12× si leggevano identiche.

BacktestSummary ora lo misura sul ledger che i parametri pubblicati producono davvero:

campo a quale domanda risponde
n_episodes quanto spesso scatta questo segnale?
mean_concurrent_positions quando lavora, quante posizioni sto finanziando?
max_concurrent_positions posso proprio metterla in produzione sul mio conto?

trades (da return_trades=True) porta un episode_id per riga, quindi trades.groupby("episode_id").size() è disponibile senza reimplementare il raggruppamento.

Episodi e concorrenza sono misure diverse e in generale non coincidono. Gli episodi raggruppano per segnale — una serie di cinque barre con RSI < 30 è una cosa che accade, non cinque. La concorrenza raggruppa per percorso di prezzo — e trade di episodi nettamente distinti si sovrappongono comunque quando l'orizzonte di holding supera il gap fra loro. Nel caso della #168: 120 barre di segnale, 76 episodi, e una media di 3.71 posizioni concorrenti.

Quale serva dipende dalla domanda: dimensionare il capitale → concorrenza; quanto spesso scatta un segnale → episodi; inferenza statistica → concorrenza, perché trade sovrapposti condividono un percorso di prezzo e non sono osservazioni indipendenti. total_trades / mean_concurrent_positions è la dimensione campionaria che la sovrapposizione sostiene davvero (118 nominali → ≈32 effettivi in quel caso). Le conseguenze inferenziali sono affare della

177; questa è la misura che le serve.

forgedge.episodes espone le primitive — episode_starts, episode_ids, concurrency — per chi le vuole direttamente.

Economia nominale, inferenza effettiva

La sovrapposizione misurata nella sezione precedente ha una conseguenza che va oltre il dimensionamento del capitale, ed è quella che cambia i verdetti.

Trade sovrapposti condividono un percorso di prezzo, quindi non sono osservazioni indipendenti. Ogni quantità che fa un'affermazione di confidenza consumava il conteggio nominale come se lo fossero (F16):

quantità prima ora
total_trades, PF, expectancy, net gain nominali nominali — è l'economia, non si tocca
errore standard e gradi di libertà del t-test nominali effettivi
n_obs della deflated_sharpe nominali effettivi
expectancy_mde / power gate nominali effettivi

StatisticalValidation.n_effective è total_trades / mean_concurrent_positions, misurato dalla geometria a barre del ledger stesso invece che passato — così non c'è modo di consegnargli un numero che descrive altri trade. Nel caso della #168, 118 trade nominali contro ≈32 effettivi:

t-test:  t scala come sqrt(n)  →  sovrastimata di 1.93×
         t=2.6 (p≈0.005) è in realtà t=1.35 (p≈0.09)
DSR:     n_obs 118 → 32 a n_trials=15, correzione 0.820 → 0.741

Il t-test è il canale serio: max_ttest_p è uno dei tre gate hard che producono NON-EDGE, quindi la pipeline ammetteva regole su una significatività sovrastimata. Le stime puntuali usano ancora ogni trade — più osservazioni affinano davvero la stima di una media; quello che non fanno, quando si sovrappongono, è affinarla alla velocità di sqrt(n).

Quando il ledger non porta la geometria a barre la correzione non è calcolabile, e si usa il conteggio nominale con n_effective = nan a dirlo. Una sovrapposizione non misurata non è prova di assenza di sovrapposizione, ma inventare una correzione sarebbe peggio che riportare il numero non corretto e ammetterlo.

Un floor irraggiungibile è una finestra, non un verdetto

Tre gate confrontavano un floor assoluto con una finestra la cui lunghezza era fissata da parametri indipendenti, mentre il tasso di arrivo era fissato da un terzo. Quando finestra × tasso < floor, ogni candidato fallisce per costruzione — e la pipeline lo riportava come un muro di rejection, indistinguibile da «il segnale è brutto».

Misurato sulla stessa fixture: pass rate dei fold da 0.7 % a 54.2 %, candidati OOS-stabili da 0.5 % a 52.4 %, con i fallimenti residui attribuiti a tasso e dispersione — i criteri che avrebbero sempre dovuto decidere. - La finestra di discovery di M1 (#206, F1 un livello sopra il caso del fold appena visto). min_episodes è assoluto e indipendente da min_tpm, quindi implica anch'esso una finestra di discovery in-sample — il naive min_episodes / min_tpm la soddisfa solo in aspettativa, l'errore di #173 che ricompare qui. Al tasso di default di "sniper" (0.3 episodi/mese) la finestra che raggiunge min_episodes=10 con confidenza al 95 % è di 53.3 mesi (4.44 anni), non i "≥2 anni" che la descrizione del preset dichiarava prima di questa misura. m1_is_window_too_short di config_report() (WARN, non FAIL — un candidato sopra il tasso configurato può comunque superare il floor su meno storia) segnala il gap quando lo span è noto. min_episodes è ora differenziato per preset invece di un unico default fisso: "sniper" tiene 10 (il rigore statistico è lo scopo del preset, quindi si è corretta la descrizione, non il floor), "sweep" — che condivide il tasso basso di "sniper" ma non la sua promessa di precisione — lo abbassa a 5, coerente con l'essere permissivo per design e delegare già il rigore al RotationCalibrator a valle. - Il pre-screen di M3, sulla propria prima finestra (#217, la stessa confusione finestra/floor che ricompare una terza volta, un livello sotto min_train_months). min_train_months dimensiona la prima finestra di train del walk-forward per raggiungere esattamente 10 trade a criteria.min_tpm — non n_months × min_tpm trade. Il pre-screen veloce di selection_mode="walk_forward" (Step 2.3, _early_elimination) ri-derivava il floor di trade con quella seconda formula, non correlata, sulla stessa finestra breve invece di riusare il 10 per cui era stata dimensionata — innocuo a min_tpm basso, dove le due formule concordano approssimativamente, ma a un tasso effettivo alto (es. min_tpm=35.2, il tasso bar-equivalente a cui risolve forge_preset("balanced", "1H")) la finestra resta comunque arrotondata al suo floor di 1 mese mentre il requisito ri-derivato sale a 35 — sette volte il conteggio di trade per cui la finestra era mai stata dimensionata. Riprodotto su dati reali (SUIUSDC/1H): 9 contratti che avevano già superato la rotation null sono stati scartati con "total_trades 0 < 35 (1mo × 35.2 tpm, not significant)" nonostante 255-674 attivazioni sull'intero span IS. Il pre-screen su quella prima finestra ora prende un floor fisso esplicito invece di ri-derivarne uno; il floor proprio di _decide() (sull'intero span di selezione, dove n_months × min_tpm è la domanda giusta — «questo candidato ha mantenuto il tasso dichiarato sull'intera storia che aveva davvero?») resta intatto.

Modalità d'ingresso — cosa misura il verdetto

entry_mode ha default "auto" (era "limit" prima della #185), e vale la pena capire il cambio perché muove i verdetti.

In modalità "limit" la griglia varia buy_drop_pct, quindi l'entrata a limite fa due lavori insieme: meccanica d'ordine e ottimizzatore del prezzo d'ingresso. Uno sconto più profondo riempie di rado e — qui sta il punto — solo sui percorsi che sono tornati giù a prenderlo. Il profit factor sale su un sottoinsieme di trade che non è la popolazione tradeable. È il fill confound, e sotto "limit" il verdetto misura in parte il prezzo d'ingresso invece del segnale.

"auto" separa le due letture:

Le condizioni, tutte misurate su quel replay:

# condizione cosa impedisce
1 fill_rate >= min_fill_rate_opt un PF gonfiato da fill rari
2 opportunity_sharpe >= quello del market un punto che trada meno per un edge poco migliore
3 net_gain >= min_net_gain_retention × quello del market un µ minuscolo con un σ minuscolo

La condizione 2 usa uno Sharpe diverso da quello su StatisticalValidation, e la differenza è tutto il criterio. validate() annualizza per capacità — bars_per_year / avg_holding_bars, quanti periodi di holding non sovrapposti stanno in un anno. È il denominatore giusto per «quanto è buona questa regola», perché non la premia per l'accidente di essersi attivata spesso. Ma i due punti operativi stanno sulla stessa regola e tengono la posizione per la stessa durata, quindi la capacità è identica per entrambi e il fattore sqrt si semplifica: il confronto collassa sullo Sharpe per trade, esattamente la metrica cieca all'opportunità da cui il criterio vuole uscire.

opportunity_sharpe conta invece i trade realizzati — (µ/σ) × sqrt(trade per anno) — quindi dimezzare i trade costa sqrt(2) ≈ 1.41× che la qualità per trade deve battere. Sull'esempio della issue:

market limite rapporto
Sharpe per trade 0.267 0.375 1.41×
annualizzato per capacità 5.095 7.164 1.41× — invariato
opportunity_sharpe 1.461 1.299 0.89×
rendimento totale 48% 36% 0.75×

La lettura per capacità adotta un punto che rende un quarto in meno.

Entrambi i punti sono riportati per intero su RuleDiscoveryResponse.entry_optimization — ciascuno con la propria regola, il proprio summary fuori campione e le proprie statistiche, più failed_condition che nomina quale condizione ha fermato l'adozione. Il walk-forward del punto limite è un replay (reoptimise=False), quindi non aggiunge selezione né n_trials; la sua DSR porta il proprio conteggio di tentativi più alto (celle Stage 1 + Stage 2) come metrica assoluta, mentre il gate min_dsr legge sempre quella del punto market — il verdetto non paga mai per lo Stage 2.

"limit" resta pienamente supportata ed è la scelta giusta quando l'ordine a limite è la strategia, non un raffinamento dell'esecuzione.

Gli attributi importanti di una RuleDiscoveryResponse: verdict ("EDGE"|"PARTIAL-EDGE"|"NON-EDGE"|"INSUFFICIENT-DATA"), is_edge (vero per i primi due), rejection_reasons, validated_rule (porta .params, un BacktestParams), in_sample_summary (total_trades, profit_factor, win_rate_pct, expectancy, tpm_mu, n_episodes, mean_concurrent_positions, max_concurrent_positions — campi di overlap dei trade, §15), execution_envelope (.conservative/.optimistic — vedi §17), walk_forward (.oos_summary, .consistency), statistical_validation (.temporal_stability, .deflated_sharpe), regime_analysis, excursion (MAE/MFE), entry_optimization (popolato solo sotto entry_mode="auto" — il default — e solo una volta che il verdetto Stage-1 a mercato è EDGE/PARTIAL-EDGE; None sotto "market"/"limit" e sotto una run "auto" il cui verdetto a mercato è NON-EDGE, §15).

text_report(resp)/html_report(resp) (importabili anche direttamente da forgedge) costruiscono report human-readable/HTML da una response — ogni parametro della regola, statistiche IS/OOS, dettaglio per-fold del walk-forward, validazione statistica e motivi di rigetto. Passa il contratto d'origine come contract= per includere nello stesso report rotation_p/rotation_threshold (un campo del Modulo 2 che RuleDiscoveryResponse non duplica). rule_summary_report(resp, *, contract=None, fmt="text"|"html", save=False, path=None) incapsula entrambi in un'unica chiamata scopribile — save=True scrive il report su path (default f"{resp.alpha_id}.{txt|html}") e restituisce comunque la stringa, quindi print(rule_summary_report(resp, save=True)) è una riga sola. resp.to_dict() dà una forma serializzabile in JSON. Diverso da rule_performance_report() più sotto, che ri-simula le regole su candele fresche per un report a grafici in stile monitoraggio, non renderizza il verdetto già prodotto da M3.

Un contratto concreto, campo per campo

Il quick start del §7 stampa una manciata di campi di un caso PARTIAL-EDGE. Qui è lo stesso caso, ma ogni oggetto scaricato per intero — il repr() reale di un vero EventCandidate, AlphaContract e RuleDiscoveryResponse, verificato sulla fixture ADA, così puoi vedere cosa contengono davvero questi oggetti invece di doverlo dedurre dalla lista degli attributi qui sopra.

c, r = partial[0]   # stessa ricerca del §7
cand = next(x for x in result.candidates if x.event_id == c.event_candidate_id)

EventCandidate — la condizione booleana, senza ancora alcuna nozione di cosa succede dopo che si attiva:

event_id:          EVT-close_vol_12-DL-0327
expression:         delta_close_vol_12_6 < -0.018035
components:         [EventComponent(source_feature='close_vol_12', transform='delta',
                       transform_params={'lag': 6}, transformed_col='delta_close_vol_12_6',
                       threshold=-0.018035, threshold_type='distributional_p10',
                       direction='below', event_type='threshold', ...)]
activation_stats:   ActivationStats(n_activations=88, n_active_months=20, zero_months=9,
                       max_monthly_share=0.1364, mean_tpm=3.0345, index_of_dispersion=4.2484,
                       n_episodes=20, episode_index_of_dispersion=0.425, n_eff=47.0588)

Nota cosa non c'è: nessun rendimento, nessuna direzione, nessun win rate. close_vol_12 (volatilità mobile a 12 barre del close) è scesa di più del cambio a 6 barre del suo 10° percentile, 88 volte in 20 mesi attivi — questo è tutto ciò che Event Discovery sa di questo pattern. threshold_type='distributional_p10' è la spia che -0.018035 è stato scelto come 10° percentile di questo specifico asset, non a mano.

AlphaContract — la stessa condizione, ora con un target economico derivato e un grade statistico:

alpha_id:            ALPHA-ADAUSDC-1D-260823-327
event_candidate_id:  EVT-close_vol_12-DL-0327
status:              HYPOTHESIS
direction:           short
promoted:            True
derived_target:      DerivedTarget(holding_period_h=12, sell_pct=0.11244, direction='short',
                        mean_advantage=-0.10176,
                        score_by_h={1: 0.547, 2: 0.820, 3: 0.951, 5: 1.117, 6: 1.143,
                                    7: 1.389, 10: 1.926, 12: 2.445, 24: 1.901},
                        p_value_by_h={..., 12: 0.00810, ...}, h_sig=(12,),
                        statistically_weak=False, fixed_target=False)
event_stats:         EventStats(n_activations=61, win_rate=0.4590, base_rate=0.3174,
                        lift=0.1417, fwd_return_mean=0.0850, cohens_d=0.7731,
                        t_stat=4.6311, p_value=2.23e-06)
oos_validation:       OOSValidation(n_bars=265, n_activations=23, mean_advantage=0.05516,
                        t_stat=-0.3306, p_value=0.6294, win_rate=0.3478, base_rate=0.3953,
                        lift=-0.0474, passed=False, min_detectable_effect=0.2810)
alpha_score:          AlphaScore(ic_magnitude=0.01324, lift=0.1417, cohens_d=0.7731,
                        regime_breadth=0.2, composite_score=0.5233, grade='B')
rejection_reasons:    []
diagnostics:          ['IC weak (|IC|=0.0132 < 0.02 or p=0.7452)',
                        'OOS weak (p=0.6294 vs 0.1, mean_adv=0.05516, n_act=23)']
rotation_p:           1.0   rotation_threshold: 5.8726   fdr_promoted: True

Leggilo come descrive il §14: event_stats/cohens_d/t_stat sono forti (p≈2e-6) — lo score all'orizzonte 12 in derived_target.score_by_h è il punto migliore della griglia e supera il proprio test di significatività. Ma diagnostics (non bloccante, pesa sul grade) segnala già un IC di Spearman debole e una ri-verifica OOS a livello di contratto debole — nessuna delle due blocca la promozione (rejection_reasons è vuoto; solo direction=="undetermined" l'avrebbe bloccata), ma entrambe abbassano il grade a B e anticipano esattamente ciò che il giudizio OOS indipendente di Rule Discovery, qui sotto, troverà.

Questo dump precede i campi nature/horizon_at_boundary/promotion_route dello step 9 del §8 — su un contratto catturato oggi, h_sig=(12,) non vuoto significa promotion_route="z_score" o "both" (a seconda che anche il test AUC sull'intera griglia dello step 9 sia passato), nature="non_significativo" a meno che lo stadio (a) non lo sia, e horizon_at_boundary=False qui dato che h*=12 sta ben dentro la griglia, non al bordo h=24.

RuleDiscoveryResponse — backtest realistico, walk-forward OOS, il verdetto economico:

verdict:              PARTIAL-EDGE
validated_rule.params: BacktestParams(direction='short', buy_type='market', buy_drop_pct=0.01,
                          buy_delay_bar=1, sell_pct=0.1324, target_h=12, fee=0.002,
                          early_stopping=True)
in_sample_summary:    BacktestSummary(total_trades=72, win_rate_pct=0.7917, profit_factor=6.892,
                          expectancy=0.06793, n_episodes=15, mean_concurrent_positions=3.758,
                          max_concurrent_positions=12, tpm_mu=3.130, active_months=13, n_months=23)
statistical_validation: StatisticalValidation(ttest_winrate_p=3.8e-05, ttest_expectancy_p=4.1e-04,
                          deflated_sharpe=3.781, sharpe_ratio=5.521, n_trials_tested=15,
                          temporal_stability='PASS', pf_first_half=6.887, pf_second_half=6.896,
                          n_effective=19.158)
walk_forward.consistency:        1.0   (4/4 finestre di test profittevoli)
walk_forward.oos_summary:        BacktestSummary(total_trades=57, win_rate_pct=0.7895,
                                     profit_factor=5.499, expectancy=0.06067)
walk_forward.n_splits_in_sample: 3     (su 4 — vedi sotto)
entry_optimization:   EntryOptimization(selected_entry='market', adopted=False,
                          failed_condition='sharpe', authoritative='market',
                          market_opportunity_sharpe=4.498, limit_opportunity_sharpe=4.365,
                          limit_buy_drop_pct=0.008,
                          reason="limit @ buy_drop=0.008 did not hold its risk-adjusted return "
                                 "per unit of time out-of-sample (4.365 < market 4.498); "
                                 "kept market operating point")
rejection_reasons:    ['active_months 13/23 = 57% < 80%',
                        'search-level rotation null not cleared (rotation_p=1.0000 > 0.05)']

Tre cose che questo dump rende concrete e che la lista degli attributi da sola non può:

Le formule esatte dietro due numeri citati in tutto questo manuale, dato che nessuno dei due è la versione «da manuale» che il nome suggerisce:

Modulo 4 — RuleRegistry

RuleRegistry(submissions: list[RuleSubmission], frames: dict[str, pd.DataFrame],
              config: RegistryConfig | None = None)
RuleRegistry.from_forge_results(results: dict[str, ForgeResult], config=None)   # entry point preferito
reg = registry.run()
reg.summary(); reg.flat_table(); reg.documents; reg.matrices   # .jaccard, .spearman
reg.export("rules.xlsx")             # o .csv, secondo RegistryConfig.export_format
reg.html_report(timeframe="1H")      # HTML autocontenuto, SVG inline, nessuna CDN

frames deve contenere i frame post-Event-Discovery (cioè ForgeResult.event_frame per ticker), non KPI Table grezze — il replay cross-ticker ha bisogno delle colonne feature derivate a cui le espressioni delle regole fanno riferimento.

Il default di reg.flat_table() non filtra nulla. apply_filters=False di default — una chiamata semplice a registry.flat_table() include duplicati e regole non generiche indipendentemente da RegistryConfig.export_duplicates/.export_non_generic. Quei flag valgono solo tramite reg.flat_table(apply_filters=True) o reg.export(...)/reg.html_report(...) (che li applicano internamente). È una scelta di design deliberata — «FORGE mostra la complessità invece di nasconderla» — ma significa che una prima chiamata a flat_table() con più righe del previsto non è un bug.

Come una regola attraversa i ticker: ricalibrazione, non riuso. Testare RULE_ADA_01 su BTCUSDC non può significare applicare la soglia letterale di ADA (-0.018035 nell'esempio sopra) ai numeri di BTC — le distribuzioni dei due asset non condividono una scala. Prima del replay, recalibrate_candidate() copia in profondità il candidato e, per ogni componente che porta una soglia (i componenti categoriali/binari restano invariati — non hanno una soglia distribuzionale da spostare), fa due letture contro le distribuzioni empiriche del ticker sorgente e di quello target:

  1. Sul ticker sorgente: trova il percentile che la soglia originale occupa — q = mean(serie_sorgente <= soglia).
  2. Sul ticker target: legge il valore a quello stesso percentile — serie_target.quantile(q).

Quindi una regola scoperta come «10° percentile del Δvol a 6 barre di ADA» viene testata su BTC come «10° percentile del Δvol a 6 barre di BTC stesso», non come il numero letterale di ADA. Le trasformazioni relative (rolling_pctrank/rolling_zscore) sono vicine a un no-op sotto questa ricalibrazione, essendo già auto-normalizzanti; le soglie a trasformazione identity si spostano per costruzione. Il candidato ricalibrato è un oggetto nuovo con l'event_series in cache esplicitamente svuotata, così il replay rivaluta sempre sul frame target invece di rischiare una cache stantia del ticker sorgente.

La genericità è una classificazione a quattro livelli, guidata dal criterio PASS/FAIL a due parti che il §10 introduce già:

classificazione condizione significato
GENERIC passa sul 100% degli altri ticker testati funziona ovunque testato
PARTIAL passa su ≥ generic_ratio_threshold (2/3 di default) ma < 100% funziona sulla maggioranza
SPECIFIC passa su alcuni ma < generic_ratio_threshold resta per lo più legata al ticker d'origine
ISOLATED non passa su nessuno, oppure nella sessione esiste un solo ticker (niente contro cui testare) non generalizza, o non è testabile

Ogni singolo PASS/FAIL per ticker è il criterio a due parti del §10 (pf_other >= cross_pf_threshold AND pf_other >= retention × pf_home); classification/is_generic sono il roll-up su ogni altro ticker della sessione. CrossTickerResult.bar registra la soglia PF esatta (max(cross_pf_threshold, retention × pf_home)) che quella specifica regola doveva superare su quel target specifico — riportata per ogni risultato proprio perché un lettore possa distinguere «non tradeable lì» da «ha ceduto troppo del suo edge di casa» invece di doverlo dedurre da un solo flag pass/fail.

La deduplicazione segnala una catena, non cancella mai. Due regole con overlap Jaccard strettamente maggiore di overlap_threshold (0.70 di default) sulle loro date di attivazione vengono confrontate per profit factor sul ticker d'origine; quella più debole (PF strettamente più basso — una parità esatta di PF non domina in nessuna direzione) viene marcata is_duplicate=True, duplicate_of=<il rule_id del vicino più forte più vicino> — il più vicino, non la singola regola più forte dell'intero registro, così che una catena ordinata per PF (R01 ← R02 ← R03) resti una catena invece di collassare ogni regola più debole sull'unica migliore globale. Niente viene mai rimosso da reg.documents o dalla flat_table() di default — il §14 spiega perché: una coppia segnalata come sovrapposta non è necessariamente ridondante, può essere conferma indipendente dello stesso edge via due meccanismi diversi, e la decisione di tenerla o scartarla resta tua.

KPI Builder — costruire una KPI Table da candele grezze

Tutto il resto di questo manuale assume che tu abbia già una KPI Table — un pandas.DataFrame con una colonna close, una sorgente datetime, e abbastanza colonne indicatore perché Event Discovery abbia qualcosa da minare. Se quello che hai davvero sono candele OHLCV grezze da un exchange o un fornitore dati, forgedge.kpi_builder è la prima cosa da chiamare, prima ancora di toccare forge(). È un toolkit autocontenuto a tre funzioni — nulla qui parla internamente con forge(), e nulla in forge() richiede di averlo usato; puoi costruire una KPI Table con qualunque mezzo tu voglia, purché il risultato abbia close e un timestamp.

Un percorso completo, partendo da niente più che OHLCV grezzo

Questo è deliberatamente il caso più basico: candele orarie nel formato in cui un'API REST di un exchange te le restituisce — timestamp in epoch-millisecondi, nessuna colonna derivata, niente di specifico per FORGE nella forma. Le tre righe qui sotto sono mostrate solo per la forma; la tabella reale usata per l'output verificato più sotto ne ha 500.

import pandas as pd

candles = pd.DataFrame({
    "open_time": [1704067200000, 1704070800000, 1704074400000],  # epoch ms
    "open":  [100.14, 100.39, 99.20],
    "high":  [100.32, 100.61, 100.14],
    "low":   [99.97,  99.08,  98.80],
    "close": [100.31, 99.27,  100.02],
    "volume": [1169.4, 2779.8, 3188.0],
})   # una tabella reale ha tante righe quante barre — 500 nella run sotto

Tre chiamate trasformano questo in una tabella che forge() può consumare:

from forgedge import build_features, candle_features, lag_features, summary_report

kpi = build_features(candles, timestamp_col="open_time")
kpi = candle_features(kpi)
kpi = lag_features(kpi, "close", "close_ema_09", periods=[1, 2, 3])

rep = summary_report(kpi, timeframe="1H", verbose=False, return_report=True)
print(rep.one_line())   # check opt-in sulla qualità dei dati — non blocca mai da solo, §9/§11

Output verificato, su 500 barre orarie sintetiche ma internamente coerenti costruite così:

build_features   : (500, 6)  -> (500, 116)   # +color, +open_dt, +109 colonne indicatore
candle_features   : (500, 116) -> (500, 122)  # +body, upper_wick, lower_wick, close_pos, range_pct, gap
lag_features      : (500, 122) -> (500, 128)  # +close_prev_{01,02,03}, close_ema_09_prev_{01,02,03}
summary_report    : "data quality OK"

Quella tabella è ora una vera KPI Table — passala direttamente a forge():

from forgedge import forge
result = forge(kpi, ticker="DEMO", timeframe="1H", progress=False)
print(len(result.candidates), len(result.promoted))   # 26252  1108

Niente in questo è specificamente costruito per la dimostrazione: è esattamente la sequenza build_features → candle_features → lag_features che il Caso d'uso 2 del §12 e lo script examples/kpi_builder_usage.py del repository stesso seguono, eseguita qui su dati sintetici invece che reali così puoi vederla funzionare da capo a fondo senza aver bisogno di una fonte dati. Se hai dati reali con esattamente la stessa forma (una colonna timestamp più open/high/low/close, opzionalmente volume), questo è letteralmente tutto ciò che serve per arrivare a forge().

Cosa fa ciascuna funzione, parametro per parametro

kpi = build_features(candles, config=None, *, timestamp_col, output_timestamp_col="open_dt",
                      timestamp_unit="ms", add_color=True, sort_output=True) -> pd.DataFrame
kpi = candle_features(kpi, *, order_on="open_dt", add_gap=True, round_to=5) -> pd.DataFrame
kpi = lag_features(kpi, *cols, periods=(1,2,3), like=None, order_on="open_dt") -> pd.DataFrame

build_features(candles, config, *, ...) calcola un insieme configurabile di indicatori base (SMA, EMA, RSI, Bollinger Bands, ATR, MACD, min/max mobile, rendimenti, volatilità, max-drawdown) da OHLCV grezzo, e deriva la colonna timestamp open_dt che forge() si aspetta.

candle_features(df, *, ...) aggiunge sei colonne di geometria candlestick scale-free — vedi la tabella sotto per cosa significa ciascuna. order_on="open_dt" è la colonna usata per calcolare gap (che richiede il close della barra precedente); add_gap=False salta quella singola colonna se non serve (es. su dati con gap di sessione reali che non vuoi trattati come geometria della candela); round_to=5 controlla l'arrotondamento decimale su ogni colonna calcolata.

lag_features(df, *cols, periods=(1,2,3), like=None, order_on="open_dt") aggiunge copie shiftate {col}_prev_{NN} di colonne che esistono già su df — gira dopo build_features/candle_features proprio perché tu stia ritardando nomi di colonna reali e ispezionabili invece di dover predire in anticipo la convenzione di naming interna di FORGE. Due modi per selezionare cosa ritardare, usabili insieme: elenca nomi espliciti come argomenti posizionali (lag_features(kpi, "close", "close_ema_09"); un typo solleva KeyError nominando le colonne che esistono davvero), oppure passa like="_ema_" per ritardare ogni colonna il cui nome contiene quella sottostringa (order_on stessa non viene mai selezionata così, anche se dovesse combaciare). periods accetta un singolo int o una sequenza — periods=[1, 2, 3] (il default) produce tre colonne ritardate per colonna selezionata, periods=6 ne produce una.

Cosa deve avere la tua tabella di input

Solo timestamp_col e close sono mai strettamente richiesti — close perché ogni modulo a valle misura rendimenti forward su di essa. open/high/low servono a pezzi specifici (candle_features richiede tutte e quattro le colonne OHLC e solleva KeyError nominando quelle mancanti; add_color richiede open+close; l'indicatore atr richiede high+low oltre a qualunque columns tu l'abbia configurato) ma sono altrimenti opzionali — una serie di soli close costruisce comunque una KPI Table valida, seppure più piccola. volume è opzionale ovunque: il config di default pacchettizzato la referenzia (per moving_average), ma una colonna che il config referenzia e candles non ha viene saltata con un logger.warning, mai un errore — questo è precisamente il motivo per cui un input solo-OHLC, senza alcun volume, è sempre sicuro da passare a build_features invariato. Se non sei sicuro che i tuoi dati grezzi siano puliti prima di spendere una run di forge() su di essi, summary_report() (usato nel percorso sopra) è il check opt-in costruito proprio per questo — vedi §9/§11 per cosa cerca e §13 per un esempio reale di come ha colto un CSV genuinamente malformato.

Configurare quali indicatori vengono costruiti

Un config è una mappatura semplice: {nome_indicatore: {"enabled": bool, "params": {"periods": [...], "columns": [...]}}}. Tre modi equivalenti per fornirne uno:

kpi = build_features(candles, timestamp_col="open_time")                    # None -> default pacchettizzato
kpi = build_features(candles, {"rsi": {"enabled": True,                     # un dict semplice
                                        "params": {"periods": [14], "columns": ["close"]}}},
                      timestamp_col="open_time")
kpi = build_features(candles, "my_config.yaml", timestamp_col="open_time")  # un percorso YAML (richiede PyYAML)

Una chiave "enabled" assente ha default True (solo "enabled": False disattiva un blocco); un blocco "params" senza "periods"/"columns" produce liste vuote, di fatto un no-op per quell'indicatore; un nome di indicatore che la libreria non riconosce viene saltato con un warning, non un errore, quindi un typo in un config scritto a mano degrada silenziosamente invece di sollevare — controlla il log se una colonna che ti aspettavi non compare mai. Un blocco "lagging", se presente (es. copiato da un config più vecchio), viene esplicitamente ignorato con una riga di log informativa — il lag ora vive in lag_features(), non in questo config. Il config di riferimento pacchettizzato è distribuito assieme al sorgente come file YAML modificabile e commentato (kpi_builder/default_enricher.yaml) con la stessa struttura che ha DEFAULT_CONFIG sotto — copialo come punto di partenza per il tuo file invece di scriverne uno da zero. load_kpi_config(path) (usata internamente quando passi uno str/Path, o chiamabile direttamente) solleva ImportError con un messaggio chiaro se PyYAML non è installato, e FileNotFoundError se path non esiste.

Convenzione di naming delle colonne, importante: perché una colonna sia riconosciuta come parte di una coppia ratio same-family dal feature generator di Event Discovery, il nome deve seguire {base}_{indicatore}_{periodo} con base ∈ {close, high, low, open, volume} e indicatore ∈ {ema, sma, rsi, dema, tema, wma, hma, mdd, atr, natr} (o i pattern di naming dedicati per Bollinger/volatilità/rendimento/MACD). Una colonna che non rispetta questa convenzione funziona comunque come feature standalone e può comunque essere raggiunta da uno degli altri pairing di arità 2 a scope più ristretto descritti in §8 (coppie OHLC cross-tempo, MACD-vs-signal, rendimento prezzo-vs-volume, coppie di geometria di candle_features(), indicatore-vs-OHLC-base-laggata) — è specificamente dal raggruppamento generico same-family che resta esclusa. Se un indicatore custom che hai aggiunto non compare mai composto con nulla nei candidati di Event Discovery, controlla prima il nome contro questa convenzione.

L'insieme di indicatori di default, e le sue formule esatte

Il pacchetto di default di build_features(candles, config=None, ...) (DEFAULT_CONFIG, una voce per famiglia di indicatore) è ciò che gira quando passi config=None — è quello che il caso d'uso 2 del §12 ha ottenuto quando ha chiesto invece una configurazione più piccola ed esplicita:

indicatore periodi di default colonne di default abilitato
moving_average (SMA) 3, 9, 12, 25, 96, 168 close, high, low, volume sì
ema 3, 9, 12, 25, 96, 168 close, high, low sì
volatility 5, 12, 24, 96, 168 close sì
min / max 3, 6, 12, 24, 48, 96, 168 close, high, low sì
return 1, 3, 6, 12, 24, 48, 96, 168 close sì
rsi 14, 25 close sì
bollinger_bands 20 close sì
max_drawdown 12, 24, 48, 120, 240 close sì
atr 14, 28 close (+ richiede high/low) no
macd 12, 26, 9 (fast, slow, signal) close no

Verificato, su 400 barre orarie sintetiche: build_features(candles, timestamp_col="open_time") senza config (cioè il default pacchettizzato) non produce nessuna colonna *_atr_*/*_macd_* — conferma che atr/macd sono davvero spente di default. Passare una config che le abilita esplicitamente —

CONFIG = {"atr": {"enabled": True, "params": {"periods": [14, 28], "columns": ["close"]}},
          "macd": {"enabled": True, "params": {"periods": [12, 26, 9], "columns": ["close"]}}}
kpi = build_features(candles, CONFIG, timestamp_col="open_time")

aggiunge esattamente close_atr_14, close_atr_28, close_natr_14, close_natr_28, close_macd_12_26, close_macd_12_26_signal_09, close_macd_12_26_hist_09 — sette colonne per due blocchi di indicatori. Due dettagli da sapere prima di attivarle:

Le sei colonne di geometria candlestick che aggiunge candle_features()

Ciascuna è scale-free (un rapporto, non una quantità in unità di prezzo), quindi un asset da $0.01 e uno da $60.000 producono valori direttamente confrontabili:

colonna formula significato
body (close − open) / (high − low) dimensione del corpo con segno — positiva su una barra rialzista, negativa su una ribassista
upper_wick (high − max(open, close)) / (high − low) ombra superiore
lower_wick (min(open, close) − low) / (high − low) ombra inferiore
close_pos (close − low) / (high − low) dove è atterrato il close nel range della propria barra, 0 al minimo, 1 al massimo
range_pct (high − low) / close ampiezza della barra relativa al prezzo
gap (open − prev_close) / prev_close gap di apertura rispetto al close della barra precedente (solo se add_gap=True, il default)

Per costruzione abs(body) + upper_wick + lower_wick == 1 su qualunque barra non piatta. Tutte e sei dividono per (high − low) (o per prev_close per gap), quindi una barra completamente piatta (high == low) o un close precedente pari a zero produce NaN invece di un errore di divisione — atteso su dati a bassissima liquidità, non un bug da aggirare.

pattern_features() — il livello opt-in dei pattern nominati

from forgedge import pattern_features
kpi = pattern_features(kpi)   # aggiunge una colonna categoriale: candle_pattern

Dieci formazioni nominate a una, due e tre barre (HAMMER, DOJI, SHOOTING_STAR, MARUBOZU, BULLISH/BEARISH_ENGULFING, BULLISH/BEARISH_HARAMI, MORNING_STAR, EVENING_STAR), ciascuna una regola geometrica fissa sulle stesse frazioni body/wick che calcola candle_features(), coalescate in una sola colonna categoriale (vince il primo match, controllato in questo ordine fisso di precedenza: morning_star, evening_star, bullish_engulfing, bearish_engulfing, bullish_harami, bearish_harami, marubozu, hammer, shooting_star, doji) — deliberatamente non dieci colonne booleane separate, perché una colonna booleana sparsa per pattern avrebbe di norma ≤1 valore distinto non nullo e verrebbe scartata silenziosamente dal type classifier di FORGE prima ancora di arrivare a Event Discovery.

Verificato, su 400 barre OHLCV sintetiche: candle_pattern.value_counts() → None: 182, DOJI: 49, SHOOTING_STAR: 41, HAMMER: 35, MARUBOZU: 33, EVENING_STAR: 31, MORNING_STAR: 20, BEARISH_HARAMI: 4, BULLISH_ENGULFING: 3, BULLISH_HARAMI: 1, BEARISH_ENGULFING: 1 — circa il 54% delle barre non porta alcun pattern nominato, e i pattern a una barra (DOJI/SHOOTING_STAR/HAMMER/MARUBOZU) dominano il resto, come atteso da regole geometriche che non richiedono conferma cross-barra. Questa funzione è esplicitamente fuori dalla pipeline raccomandata (build_features → candle_features → lag_features): il docstring del modulo inquadra la geometria continua di candle_features() come l'input preferito per la scoperta di soglie asset-adattive propria di FORGE, e i pattern nominati come soglie fisse, scelte a mano, più adatte a testare un'ipotesi specifica (la tabella opt-in del §15 elenca già questo trade-off). I pattern multi-barra inoltre richiedono order_on (o un DatetimeIndex) — a differenza di candle_features()/lag_features(), che ricadono sull'assunzione che il frame sia già in ordine cronologico, pattern_features() solleva KeyError invece di indovinare silenziosamente l'ordine delle righe per una formazione multi-barra.

L'altro pairing opt-in a livello di KPI: indicator_lag_cross_lags

Non è una funzione di kpi_builder, ma una leva del Modulo 1 (DiscoveryConfig) che ha senso solo nel contesto di cosa kpi_builder ha prodotto: il feature generator di Event Discovery testa sempre le colonne indicatore price-scale (famiglie SMA/EMA/WMA/HMA) contro colonne OHLC base shiftate nel tempo — es. close_sma_12[t] > low[t-3] — ai lag DiscoveryConfig.indicator_lag_cross_lags, default (1, 3). Questo pairing è attivo di default ed è indipendente da cosa abiliti nella config di build_features(); passa indicator_lag_cross_lags=() per disattivarlo del tutto.

Costo verificato, su una KPI Table di 400 barre con 2 periodi EMA (una tabella piccola — il §17 riporta un costo misurato molto più grande, +24% di runtime / +21% di candidati, su una tabella a 36 colonne): EventDiscovery(..., indicator_lag_cross_lags=(1,3)) (il default) → 6987 candidati; la stessa tabella con indicator_lag_cross_lags=() → 6841 candidati — una differenza reale e misurabile nella dimensione della superficie di ricerca dovuta solo a questo pairing sempre attivo, che cresce con quante colonne indicatore price-scale e periodi porta la tua KPI Table.

Errori comuni al primo utilizzo, da sapere in anticipo

Coerenza della configurazione — config_report

config_report(event_discovery=None, alpha=None, rule_discovery=None,
              registry=None, market_context=None, *, ctx=None, kpi=None,
              timeframe="1H") -> ConfigReport

summary_report valida i dati; config_report valida la configurazione, con lo stesso vocabolario Finding. Risponde a due domande in un solo output, perché hanno senso solo insieme: con che configurazione sto per girare e quella configurazione è internamente soddisfacibile.

Sia lui sia forge() passano per lo stesso resolver, quindi ciò che il report mostra è per costruzione ciò che la pipeline eseguirà — rep.configs sono gli oggetti veri, non una ricostruzione. Non solleva mai, non avvisa mai e non muta mai ciò che riceve.

rep = config_report(disc, alpha, rd, kpi=kpi, timeframe="1D")
print(rep.to_text())          # la resolution trace, poi la diagnostica
if rep.has_critical:
    raise ValueError(rep.one_line())

Quattordici vincoli, ciascuno una relazione fra materializzazioni di un parametro latente. Tre sono FAIL — riservati a una configurazione che rende uno stage strutturalmente incapace di produrre un verdetto: wf_bucket_too_short (issue #173), m1_oos_fold_too_short, oos_span_too_short. Gli altri undici sono WARN, incluso only_validated_events_inert (#250): scatta quando si richiede forge(only_validated_events=True) ma la configurazione event_discovery risolta non può effettivamente far girare il walk-forward proprio di M1 (walk_forward non impostato, oppure train_ratio>=1.0 — entrambi veri per l'output di forge_preset(), che fissa train_ratio=1.0 per M1 di proposito), la condizione per cui il filtro non scarta silenziosamente nulla. Ogni messaggio porta il valore da impostare, non solo il fallimento.

forge(strict=True) è il default, ed è un cambiamento di comportamento. Un FAIL ora solleva ValueError invece di girare. Un run del genere non può dirti nulla: ogni candidato viene eliminato per ragioni di configurazione, e il muro di rejection che ne esce è indistinguibile da "il segnale è brutto", che è proprio ciò che stavi cercando di misurare. Passa strict=False per degradare tutto a UserWarning e girare comunque. Le incoerenze non critiche sono sempre avvisi, mai errori. Nessun verdetto cambia — cambia che alcuni run non partono più.

Noto: forge_preset("balanced", "1D") è attualmente segnalato. Ai valori standard del preset min_train_months=6 × criteria.min_tpm=0.80 = 4.8 contro un floor di 10 — la F2 dell'audit, e il motivo per cui su dati giornalieri si vede l'early-elimination di massa. Viene risolto derivando min_train_months dal tasso (issue #177); fino ad allora i run col preset su daily richiedono strict=False.

Cosa riempie il resolver

Un campo lasciato stare non è un valore: è una domanda a cui risponde la sessione. Questi sono i campi il cui default arriva ora dalla sessione invece che dal corpo di una classe — impostane uno e ogni modulo che legge la stessa grandezza lo segue.

parametro latente campi in cui si materializzava default risolto
la colonna timestamp timestamp_col su M1 / M2 / M3 / M4 "open_dt"
la serie dei prezzi AlphaConfig.close_col, BacktestParams.{target_col, buy_price_anchor} "close"
le colonne di regime AlphaConfig.{regime_col, regime_stable_col} "regime" / "regime_stable"
la base di costo AlphaConfig.fee_per_side, BacktestParams.fee 0.002
l'asticella di genericità RegistryConfig.{cross_pf_threshold, min_cross_pf_retention} 1.5 / 0.8

Due di questi erano bug veri, non ordine formale. AlphaConfig.fee_per_side stampigliava il contratto mentre BacktestParams.fee addebitava il backtest, e niente li collegava: AlphaConfig(fee_per_side=0.0005) produceva contratti che documentavano 5 bp e backtest che ne addebitavano 20 — in silenzio, perché i due concordavano solo condividendo un default. E forge_preset(timestamp_col="ts") configurava solo M1, quindi M2 falliva più avanti chiedendo un valore che credevi di aver già dato.

La propagazione non è simmetrica alla raccolta, in un punto deliberato, e la ragione va detta con precisione. buy_price_anchor non è un campo di schema: nomina il livello di riferimento a cui si applica l'offset del limite — buy_price = anchor × (1 ∓ buy_drop_pct) — e qualsiasi colonna numerica della tabella candele è ammessa, indicatori derivati compresi. buy_price_anchor= "close_sma_3" con buy_drop_pct=0.10 è il modo di dire «metti un limite al 90% della SMA a 3 barre»; il motore non ha altro modo di esprimerlo.

Quindi l'anchor viene riempito dalla colonna prezzo — il suo livello di riferimento di default è il close, e rinominare la colonna deve portarselo dietro — ma non semina mai il contesto, e non viene confrontato con close_col. Seminare da lì rispingerebbe "close_sma_3" dentro AlphaConfig.close_col e farebbe misurare a M2 i rendimenti futuri su una media mobile. target_col è diverso — l'uscita a orizzonte dev'essere prezzata sulla serie su cui M2 ha misurato i rendimenti — quindi lì un disaccordo viene segnalato.

La genericità è ora un test di trasferibilità, non di qualità. cross_pf_threshold aveva default 2.0 indipendente da M3, mentre partial_min_profit_factor ammette le regole a 1.5 — quindi una regola PARTIAL-EDGE avrebbe dovuto fare meglio fuori casa che in casa per essere generica, e l'intera classe era esclusa dalla genericità per costruzione. Il verdetto è ora PASS ⟺ pf ≥ floor AND pf ≥ retention × pf_casa: la metà assoluta chiede è tradeable là, quella relativa chiede trasferisce. La qualità resta sul verdetto M3 e sul grade, dove il registro già la registra. CrossTickerResult.bar riporta il numero contro cui ogni verdetto è stato misurato.

Qualità dei dati — summary_report

summary_report(df: pd.DataFrame, *, timestamp_col="open_dt",
                price_cols=("open","high","low","close"), timeframe=None,
                return_high_move=0.5, top_n=5, verbose=True,
                return_report=False) -> DataQualityReport | None

Un controllo diagnostico economico e puramente consultivo sulle colonne di prezzo — non solleva mai eccezioni e non blocca mai la pipeline. Controlla schema/NaN/infiniti, coerenza di scala del prezzo (magnitudini miste — un sintomo comune di un bug nel feed dati), coerenza interna OHLC, outlier sui rendimenti (uno z-score robusto basato su MAD più una soglia sul movimento assoluto), e continuità temporale (gap, timestamp duplicati o fuori ordine). Ogni finding è un Finding(level, code, message) con level ∈ {"OK","WARN","FAIL"}; DataQualityReport espone .worst, .has_critical, .has_warnings, .one_line(), .to_text(), e la lista completa .findings.

Time budget, hypothesis ledger, calibrazione

TimeBudget.build(n_bars: int, train_ratio: float = 0.7, horizon_bars: int = 0,
                  purge_bars: int | None = None, embargo_bars: int = 0) -> TimeBudget

HypothesisLedger (result.ledger) — semplice contabilità (m1_candidates, m2_horizons, m2_promoted, m3_grid_cells, m2_surface, total_surface), non un meccanismo di correzione.

FastRotationNull(event_frame, candidates, alpha_config, time_budget=None).run(promoted) -> CalibrationReport
RotationCalibrator(event_frame, candidates, alpha_config, time_budget=None).run(promoted, RotationConfig(...)) -> CalibrationReport

Entrambi coperti in profondità in §14-15.

forgedge.playground — helper di analisi sopra ForgeResult

Tutto quanto visto finora in questa sezione legge da una singola chiamata a forge() e chiede "cosa ha prodotto questa run." forgedge.playground è un livello diverso: un piccolo insieme, in crescita, di funzioni di sola lettura che prendono R, l'insieme di oggetti ForgeResult che una sessione di ricerca accumula — più ticker, più preset, più run ripetute nel tempo — e pongono domande trasversali sul comportamento della pipeline stessa a cui nessun singolo campo di una singola run può rispondere da solo. Non tocca mai la pipeline: ogni funzione legge solo attributi già presenti su ForgeResult (.enriched, .rule_responses, .candidates, .contracts, .ticker, …) e restituisce un pandas.DataFrame.

from forgedge.playground import *   # l'import previsto — vedi forgedge/playground/__init__.py

Due scelte di design valgono per ogni funzione qui e conviene interiorizzarle una volta sola invece che per-funzione:

Questo modulo è esplicitamente uno strato diagnostico, non un'API core stabile al livello di forge() o RuleDiscovery. Segue una checklist di casi d'uso (issue #237) — tutti gli 11 sono oggi implementati:

Funzione Modulo Domanda a cui risponde
regime_transitions(results) M0 Con che frequenza — e dopo quanto poco — flippa regime? Un confine "nervoso" che flippa ogni barra o due inquina qualunque evento M1 condizionato al regime a valle.
regime_time_share(results) M0 Quale quota della propria storia passa ciascun ticker in ciascun regime? Un asset di fatto prigioniero di un solo regime fa sembrare generiche regole che in realtà sono regime-specifiche — non c'è mai stato abbastanza di un altro regime presente per dimostrarlo.
dead_event_candidates(results) M1→M2 Quali EventCandidate sopravvissuti al gate non producono mai un contratto actionable a valle? Etichetta ciascuno "dead" (zero contratti), "undetermined_only", o "actionable" — quantifica lo spreco M1→M2.
gate_survival_observed(results) M1 Per ogni candidato grezzo pre-gate, le statistiche osservate del Consistency Gate (tpm, dispersione, passato/fallito) affiancate alle soglie configurate — così un mismatch preset/asset è diagnosticabile prima di manifestarsi come "0 candidati" in M2.
discard_reasons_by_grade(results, grade="A") M2→M3 Perché Rule Discovery emette verdetto NON-EDGE proprio sui contratti alpha di un dato grade? Esplode rejection_reasons una riga per ragione, abbinata a entry_optimization.failed_condition quando presente.
undetermined_direction_by_family(results) M1→M2 Quali famiglie di feature sorgente (RSI, EMA, coppie cross-colonna, …) alimentano eventi che Alpha Discovery non riesce sistematicamente a orientare (direction="undetermined")? Una riga per componente di EventCandidate, così una famiglia che compare solo dentro un evento AND composto viene comunque contata.
diagnostics_vs_verdict(results) M2→M3 Quali AlphaContract.diagnostics non bloccanti di M2 correlano con un NON-EDGE in M3 più tardi, per grado — candidati a essere promossi da semplice FYI a un vero gate.
lottery_only_winners(results) M3 Quali contratti PARTIAL-EDGE hanno superato ogni gate economico/statistico e hanno perso solo il rotation null a livello di ricerca — contro contratti ancora genuinamente deboli su PF/DSR/consistenza OOS.
classification_by_grade(registries) M4 Un grade alpha più alto generalizza davvero meglio cross-ticker? Lega RuleDocument.classification (GENERIC/PARTIAL/SPECIFIC/ISOLATED) al grade di origine. Prende Iterable[RuleRegistry], non ForgeResult — vedi sotto.
duplicate_clusters(registries) M4 Quanto pesa la deduplicazione, e quali regole sopravvissute la assorbono? Una riga per RuleDocument con is_duplicate/duplicate_of. Prende anch'essa Iterable[RuleRegistry].
conversion_funnel(results) trasversale La popolazione end-to-end candidati → contratti → promossi → edge per ticker, in un'unica tabella long — l'unico caso d'uso non ancorato a un singolo modulo.

classification_by_grade/duplicate_clusters sono le due funzioni che deviano dalla regola "sempre Iterable[ForgeResult]" sotto: la classificazione cross-ticker vive sul RuleRegistry pooled che forge_multi() restituisce separatamente (ogni ForgeResult.registry per-ticker è None su quel percorso) — passa [result.registry] per una run a singolo ticker, o [registry] per uno pooled.

Verificato, mettendo in pool due run forge() (ADAUSDC, e una seconda serie sintetica etichettata BTCUSDC) in un'unica lista results = [result_ada, result_btc]:

from forgedge.playground import regime_transitions, regime_time_share, undetermined_direction_by_family

rt = regime_transitions(results)
print(rt.shape)                                            # (236, 6)
print(rt[rt["run_length_before"] <= 2].groupby("ticker").size())
# ADAUSDC    45
# BTCUSDC    41

share = regime_time_share(results)
top = share.sort_values("share", ascending=False).groupby("ticker").head(1)
print(top[["ticker", "regime", "share"]].to_string(index=False))
#  ticker      regime    share
# BTCUSDC STRONG_BEAR 0.438776
# ADAUSDC STRONG_BEAR 0.407029

fam = undetermined_direction_by_family([result_ada])
rate = fam.groupby("family")["direction"].apply(lambda s: (s == "undetermined").mean())
print(rate.sort_values(ascending=False))
# family
# cross_triple    0.945455
# ret             0.915367
# cross_pair      0.915001
# vol             0.903101
# other           0.892857
# mdd             0.850000

Il tasso di undetermined su questo fixture sta in una fascia stretta 85-95% su ogni famiglia raggiunta — nessuna famiglia spicca come affidabilmente orientabile, ed è esattamente il tipo di fatto visibile solo mettendo in pool ogni componente di ogni contratto e ponendo la domanda in formato long su tutti insieme, non da nessun singolo AlphaContract. (Questo esempio è anche una vera illustrazione del perché questo modulo resta trattato come diagnostico e non stabile anche con checklist completa: la logica di bucketing delle famiglie aveva essa stessa un bug — EventComponent.source_cols risultava non vuoto anche per feature native, di arità 1, quindi ogni componente nativo veniva silenziosamente instradato nel ramo delle feature cross e questa tabella non mostrava alcuna famiglia rsi/ema/ret/… ma solo cross_pair/cross_triple/other. Corretto dispatchando su len(source_cols) invece che sulla sua truthiness; i numeri sopra sono della funzione corretta.)

Ogni funzione ha una regola specifica e deliberata su "saltare vs sollevare eccezione", sempre documentata nel proprio docstring invece di lasciata implicita — es. regime_transitions/regime_time_share saltano silenziosamente qualunque risultato il cui frame .enriched non abbia una colonna regime (Market Context disabilitato) invece di sollevare, e undetermined_direction_by_family salta silenziosamente un contratto il cui event_candidate_id non si risolve contro result.candidates invece di sollevare un KeyError. Nessuna delle due è un bug da aggirare — vedi i docstring del modulo stesso, o src/forgedge/docs/specs/playground_it.md per il riferimento completo all'utilizzo (firme, colonne restituite, ogni regola di skip, esempi verificati per tutte e 10 le funzioni, incluse le sei aggiunte dopo il primo passaggio di questo manuale), per sapere esattamente quali funzioni saltano cosa e perché. src/forgedge/docs/modules/Playground.md copre invece la motivazione di design e gli algoritmi interni.

forgedge.deployment — dall'analisi alla produzione

forgedge.playground risponde a "perché la pipeline si è comportata così"; non decide mai cosa succede dopo. forgedge.deployment è il modulo gemello che riprende da lì: dati i contratti tradeable prodotti da una sessione, decide quali sono abbastanza solidi da andare live, li esporta su disco in un formato replicabile, e indicizza l'export per un job di monitoraggio periodico. A differenza di tutto ciò che precede, questo modulo ha effetti reali — è nato infatti dentro forgedge.playground (issue #245) ed è stato spostato nel proprio pacchetto top-level una volta che quel nome ha smesso di essere onesto per qualcosa che scrive file e prende decisioni di go/no-go (PR #247):

from forgedge.deployment import PromotionGateConfig, promotion_gate, export_rules, monitoring_manifest

La sequenza prevista è forge() → promotion_gate() [filtra] → export_rules() [scrive su disco, sulle sole regole promuovibili] → monitoring_manifest() [indicizza l'export]. PromotionGateConfig blocca su flag indipendenti con una combinazione di default ponderata: un PARTIAL-EDGE bloccato solo dal rotation only non è bloccato per default (block_rotation_only=False — di solito un compromesso accettabile, non un campanello d'allarme), mentre una regola duplicata o classificata "ISOLATED" cross-ticker è bloccata per default, e una soglia di walk_forward.consistency di 0.5 (la stessa soglia che la pipeline usa internamente) si applica sempre a meno di require_consistency=False. min_fold_stability_score (#253, default None — spento) aggiunge una soglia opzionale su mean(fold_pf) - std(fold_pf) calcolato sui profit factor per-fold dello stesso walk-forward (ciascuno prima limitato a fold_pf_cap, default 10.0): una regola può superare un walk-forward PF aggregato sano solo perché un singolo fold ad alta varianza domina l'aggregato, mentre la sua performance fold-per-fold è in realtà instabile e tende a collassare su dati genuinamente freschi — questo cattura direttamente quel caso, invece di guardare solo il numero aggregato. Ogni flag è sempre calcolato e riportato indipendentemente dal fatto che partecipi alla colonna finale promotable, così disattivare un controllo non fa mai perdere visibilità su cosa avrebbe segnalato.

Verificato, su un pool forge_multi() su ADAUSDC e una seconda serie sintetica BTCUSDC (forge_multi(), non un forge() per ticker come nell'esempio del regime sopra, così da avere un RuleRegistry cross-ticker pooled genuino):

from forgedge import forge_multi
from forgedge.deployment import promotion_gate, export_rules

results, registry = forge_multi({"ADAUSDC": kpi_ada, "BTCUSDC": kpi_btc}, timeframe="1D")

gate = promotion_gate(list(results.values()), registries=[registry])
print(gate.shape)                              # (96, 10)
print(gate.groupby("ticker")["promotable"].sum())
# ADAUSDC    0
# BTCUSDC    5

exported = export_rules(list(results.values()), "exported_rules/", registries=[registry])
print(len(exported))                           # 5 — una riga per contratto effettivamente scritto su disco

Ogni contratto ADAUSDC è bloccato su questo fixture — oltre metà dei contratti in pool sono duplicati e la maggior parte si classifica "ISOLATED" sul replay cross-ticker (entrambi bloccati per default), quindi è il gate che fa il suo lavoro conservativo per design, non un bug.

Riferimento completo su parametri/ritorni, tabella completa dei campi di PromotionGateConfig, e ogni esempio verificato: src/forgedge/docs/specs/deployment_it.md. Motivazione di design — perché la sequenza è fissa, perché solo export_rules tocca il filesystem: src/forgedge/docs/modules/Deployment.md.

forgedge.experiment — pipeline di esperimenti costruite su forge()

forgedge.playground legge cosa una run ha già prodotto; forgedge.deployment agisce su quel risultato. forgedge.experiment è un terzo tipo di modulo gemello: esegue una propria pipeline multi-fase sopra forge(), né di sola lettura né orientato alla produzione. StepWiseDiscovery, la sua prima classe, risponde a una domanda più esplorativa e mirata di una singola chiamata a forge(): partendo dalle regole a condizione singola più forti e confermate sull'hold-out, si può far crescere ciascuna, una condizione AND alla volta, cercando solo nella sotto-popolazione che già seleziona una seconda dimensione? — una ricerca locale per seme, in contrapposizione all'unico passaggio globale di forge() (inclusa la sua composizione a due passate guidata dal grade, §8).

from forgedge.experiment import StepWiseDiscovery, StepWiseDiscoveryConfig

L'algoritmo, in ordine:

  1. Risolvere una volta sola. Le configurazioni del chiamante (o quelle di default) — event_discovery_config/alpha_config/rule_discovery_config — vengono risolte una sola volta tramite forgedge.config_report(), contro la sola forma della KPI table completa (lunghezza/span — mai i suoi valori, quindi risolvere prima dello split successivo non introduce alcun look-ahead). Ogni chiamata interna a EventDiscovery/AlphaDiscovery/RuleDiscovery che la ricerca effettua a valle riusa queste stesse configurazioni risolte, mai una nuova configurazione parzialmente UNSET — un EventDiscovery/AlphaDiscovery costruito standalone altrimenti ricade su una calibrazione oraria per qualunque campo che conta le barre (horizon_grid e simili) lasciato non impostato, indipendentemente dal timeframe reale — la stessa trappola che i walkthrough dei Moduli 1/2 di questo manuale segnalano per l'uso standalone diretto, più sopra in questa sezione. StepWiseDiscovery la chiude per costruzione, allo stesso modo in cui lo fa internamente forge().
  2. Separare un hold-out esterno. Indipendente da, e in aggiunta a, qualunque split train/test interno che le configurazioni passate già usano — holdout_df non viene mai letto da alcuna chiamata di discovery, solo dalla riconferma sull'hold-out. Dimensionato di default dal alpha_config.horizon_grid/embargo_bars risolto (StepWiseDiscoveryConfig.outer_horizon_bars/outer_embargo_bars, None — sovrascrivibile con un valore specifico del dominio, es. una scala derivata dalla semivita OU, se se ne ha una).
  3. Iterazione 1 — un'unica chiamata a forge() su search_df da sola, con la composizione forzata a spenta (è compito di questa classe stessa): ogni candidato/contratto/verdetto a condizione singola che la KPI table supporta.
  4. Selezione dei semi — classifica i risultati dell'iterazione 1 e ne accetta fino a config.n_seeds, ciascuno richiedendo una famiglia di feature distinta, conferma sull'hold-out, e diversità da ogni seme già accettato (Jaccard sull'attivazione booleana, più un controllo di correlazione continua — forgedge.experiment.redundancy — così che un rapporto su ATR e il suo analogo su NATR sotto nomi diversi non spendano ciascuno un'intera catena di ricerca indipendente sulla stessa informazione).
  5. Partition & compose, per ogni seme, fino a config.depth: partiziona search_df sulle righe attualmente attive della catena, cerca in quella partizione una seconda condizione tra figli puntuali (costruiti direttamente sulla partizione) e figli a trasformazione rolling (pctrank/zscore/delta — recuperati calcolando la trasformazione sull'INTERA search_df e calibrando solo la soglia sulla partizione, evitando il look-ahead che un approccio ingenuo "trasformazione sulla partizione" introdurrebbe su una partizione non contigua), scarta ogni candidato ridondante con un componente già nella catena, riprova ogni figlio confermato sull'hold-out in ordine di classifica finché uno si compone in un target derivabile da Alpha Discovery, e mette a stato la composizione solo una volta che essa stessa si riconferma sull'hold-out — un tentativo di composizione fallito non sovrascrive mai un risultato più superficiale genuinamente confermato.

Niente di tutto questo è specifico per asset o timeframe — la calibrazione di fee/mfe_floor, quali periodi degli indicatori sono entrati nella KPI table, e quanto embargo esterno riservare sono tutte decisioni del chiamante, fatte tramite le configurazioni passate. Questa classe possiede solo l'algoritmo di ricerca.

Verificato, sul fixture di riferimento di questo repository tests/fixtures/ADA_1D_TRAIN.parquet (882 barre), DiscoveryConfig/AlphaConfig/RuleDiscoveryConfig di default, StepWiseDiscoveryConfig(n_seeds=2, depth=2):

import pandas as pd
from forgedge.experiment import StepWiseDiscovery, StepWiseDiscoveryConfig

kpi = pd.read_parquet("tests/fixtures/ADA_1D_TRAIN.parquet")
engine = StepWiseDiscovery(kpi, ticker="ADAUSDC", timeframe="1D",
                            config=StepWiseDiscoveryConfig(n_seeds=2, depth=2))
result = engine.run()
print(result.summary()[["chain", "expression", "depth_reached", "verdict"]].to_string(index=False))
                                        chain                                                            expression  depth_reached  verdict
seed1:diffnorm_close_vol_vol|rolling_pctrank                          pr_diffnorm_close_vol12_vol24_168 < 0.172619              0  PARTIAL-EDGE
          seed2:ratio_close_ema_ema|identity  (ratio_close_ema03_ema12 < 0.952059) AND (zs_close_ema_03_96 < -1.5)              1  NON-EDGE

seed1 resta una PARTIAL-EDGE a condizione singola — l'unica composizione tentata a profondità 0 non ha superato la conferma sull'hold-out, quindi la catena riporta l'ultimo stato genuinamente confermato (il seme stesso) invece del tentativo fallito. seed2 raggiunge la profondità 1: il suo RuleDiscoveryResponse.verdict a livello di ricerca è NON-EDGE, ma la regola composta a due condizioni si riconferma sulla valutazione concatenata search+holdout (expectancy media sui fold walk-forward post-holdout +0.01574 > 0) — la stessa sfumatura "il verdetto dice una cosa, la riconferma sull'hold-out ne dice un'altra" che confirms_on_holdout è costruita per catturare, riportata onestamente invece di essere collassata in un singolo booleano. Nessuna delle due catene raggiunge la profondità 2 su questo fixture — un esito negativo onesto, non un errore: come il resto di forgedge, questo modulo non forza mai un risultato positivo su una configurazione che non lo sostiene.

Riferimento completo su parametri/ritorni e ogni esempio verificato: src/forgedge/docs/specs/experiment_en.md (experiment_it.md per l'italiano). Motivazione di design — perché la risoluzione della configurazione avviene una sola volta, perché la ridondanza viene controllata sia dentro una catena sia tra i semi, il fix sull'ordine di reporting che protegge uno stato più superficiale confermato: src/forgedge/docs/modules/Experiment.md. Un esempio eseguibile end-to-end sui dati OHLCV bundled di questo repository: examples/step_wise_discovery_usage.py.


10. Configurazione

Ogni modulo accetta una dataclass che porta i suoi parametri. Questa sezione copre quelli che più probabilmente andrai effettivamente a tarare, con i default citati esattamente dal sorgente.

DiscoveryConfig (Modulo 1)

Campo Default Significato
gate_params GateParams() soglie del Consistency Gate — vedi sotto
max_categorical_classes 20 oltre questo numero di valori distinti, una colonna non numerica viene scartata, non one-hot-encoded
timestamp_col "open_dt"
max_and_components 1 (era 2 pre-#254 Fase 8) 1=solo singoli (il Modulo 1 non compone nulla — il default, precondizione per forge(two_pass_composition=True)), 2=+coppie, 3=+coppie+triple (percorso legacy single-pass, o un chiamante con una propria fase di composizione)
retain_raw_events False (era True pre-#254 Fase 8) mantiene EventDiscovery.raw_events (popolazione pre-gate) in memoria dopo .run() — serve solo a TargetOptimizer, che lo imposta esplicitamente a True sulla propria config di fallback
train_ratio 1.0 <1.0 riserva una coda per la validazione walk-forward opzionale del Modulo 1 stesso
walk_forward None imposta un EventWalkForwardConfig per abilitare la validazione OOS a livello di evento (§15 — opt-in)
diversity_gate_enabled False soppressione opt-in dei quasi-duplicati (§15)
diversity_threshold 0.85 soglia Jaccard per il diversity gate, quando abilitato
indicator_lag_cross_lags (1, 3) set di lag per il pairing indicatore-price-scale-vs-OHLC-base-laggata (§8); passa () per disabilitare del tutto questo pairing

Parametri di composizione propri di forge() (non campi di DiscoveryConfig — a livello orchestratore, §9):

Campo Default Significato
two_pass_composition True (issue #254 Fase 8) composizione guidata dal grado dopo la prima passata di Alpha Discovery, al posto della composizione propria del Modulo 1 guidata da max_and_components (§8, esempio Modulo 2, step 8). False riproduce esattamente il comportamento single-pass pre-#254
grade_pairing_config None → GradePairingConfig() schema di pairing/budget quando two_pass_composition=True; vedi GradePairingConfig sotto

GradePairingConfig (forgedge.composition, letto solo quando two_pass_composition=True):

Campo Default Significato
max_components 2 3 compone anche triple (#254 Fase 4)
adjacency {"A": ("A","B"), "B": ("B","C"), "C": ("C","D")} con quali gradi può accoppiarsi un grado radice — simmetrico
per_stratum_pair_cap 100 rappresentazione minima garantita per strato di grado (round-robin, non un tetto rigido)
per_stratum_triple_cap 50 idem, per le triple
include_singles_in_pass2 True raggruppa i candidati 1D originali insieme a quelli composti per la seconda passata
max_constituent_jaccard None inoltrato a ANDComposer.compose() — rifiuta una coppia i cui costituenti si sovrappongono troppo

GateParams (il Consistency Gate):

Campo Default Significato
min_tpm 0.5 minimo di trigger medi al mese (l'unità dipende da event_counting)
max_dispersion 1.5 massimo Index of Dispersion consentito (Var/Mean dei conteggi mensili) — solo modalità "bar"; non letto in modalità "episode" (#205)
dispersion_margin 1.3 solo modalità "episode": moltiplicatore sopra il floor χ² di Poisson (eff = margine × floor), non un ID assoluto — non letto in modalità "bar" (#205)
event_counting "episode" "episode" conta run massimali di attivazioni consecutive; "bar" conta ogni singola barra (§15)
min_episodes 10 floor assoluto sul conteggio di episodi, solo modalità "episode" — implica una finestra di discovery in-sample che dipende anche da min_tpm (m1_is_window_too_short, #206)
episode_gap 1 gap massimo in barre che appartiene ancora allo stesso episodio
tpm_mode "floor" "ranged" trasforma min_tpm nel centro di una banda invece di un floor a senso unico — opt-in, solo modalità "episode", §15
tpm_tolerance UNSET mezza-larghezza della banda "ranged"; lasciata UNSET, derivata dalla tolleranza di dispersione della sessione — vedi §15

AlphaConfig (Modulo 2)

Campo Default Significato
horizon_grid (1,2,3,4,6,8,12,16,24,36,48) calibrata sull'orario — vedi la trappola sui dati giornalieri in §21
train_ratio 0.7 split IS/OOS per la conferma propria di Alpha Discovery
embargo_bars 0 buffer OOS extra opt-in, §15
horizon_enrichment (0.5, 1.0, 2.0) attivo di default; aggiunge orizzonti attorno alla finestra dominante propria di ogni evento, §15
thresholds PromotionThresholds() soglie statistiche che guidano il voto, non un gate rigido (eccetto la direzione)
fee_per_side 0.002 registrata per Rule Discovery e addebitata da lui — non applicata qui (M2 non nettizza le fee), ma è lo stesso valore, non più una copia indipendente
target_mode "proj" scoring eccesso-sopra-trend di default per eventi long, §15
trend_sma_mult 2.0 moltiplicatore della finestra SMA di trend per target_mode="proj"
use_stable_regime_only False opt-in, restringe l'analisi di regime alle barre regime_stable=True

PromotionThresholds: ic_min_abs=0.02, ic_max_p=0.05, min_lift=0.08, min_cohens_d=0.15, use_fdr=True, fdr_q=0.10, oos_max_p=0.10, min_direction_t=0.5, require_significant_direction=True, auc_max_p=0.10, rho_momentum_threshold=0.5 (questi ultimi due, §15, contano solo quando require_significant_direction=True). Solo direction=="undetermined" blocca la promozione — ogni altra soglia qui informa il voto A-D.

RuleDiscoveryConfig (Modulo 3)

Campo Default Significato
base_params BacktestParams() punto operativo seme
grid costruita automaticamente griglia di ricerca attorno al target derivato del contratto quando lasciata vuota
walk_forward RuleWalkForwardConfig() n_splits=4, min_train_months=6, purge_bars=None (→ orizzonte testato)
criteria SelectionCriteria() gate del verdetto — vedi §15
entry_mode "auto" "auto"/"market"/"limit", §15
selection_mode "walk_forward" punto operativo scelto solo dentro le finestre di train; "full_sample" è il comportamento legacy

Default di BacktestParams: direction="long", buy_type="limit", buy_drop_pct=0.010, buy_delay_bar=6, sell_pct=0.040, target_h=24, fee=0.002, early_stopping=True.

Default di SelectionCriteria che più probabilmente toccherai: min_profit_factor=2.0, min_win_rate=0.55, min_pf_score_tpm=0.30, min_fill_rate_opt=0.80 (questi quattro sono differenziati per preset da forge_preset() — più severi su "sniper", più permissivi su "sweep" — vedi §16, #207), min_tpm=2.0 (l'unico gate di frequenza — il floor sui trade eseguiti è max(10, n_months × min_tpm), non un conteggio fisso), min_fill_rate=0.40, min_dsr=1.0, max_rotation_p=0.05, power_gate=True (§15), early_elimination=True (imposta False per forzare la diagnostica completa anche su un NON-EDGE filtrato velocemente).

RegistryConfig (Modulo 4)

overlap_threshold=0.70 (soglia Jaccard di dedup), cross_pf_threshold=1.5 + min_cross_pf_retention=0.8 (le due metà del PASS cross-ticker — floor assoluto, e frazione del PF di casa mantenuta), generic_ratio_threshold=2/3 (passalo come 2/3, non 0.67 — una regola che passa esattamente 2-su-3 ticker ha rapporto 0.6666..., che supera >= 2/3 ma non >= 0.67; la documentazione segnala esplicitamente questo problema di precisione), export_format="excel".

Configurare via preset invece

Invece di assemblare DiscoveryConfig/AlphaConfig/RuleDiscoveryConfig a mano, forge_preset(preset, timeframe, asset, train_ratio=0.70, **overrides) restituisce tutti e tre pretarati per un profilo di ricerca nominato:

from forgedge import forge, forge_preset

disc_cfg, alpha_cfg, rd_cfg = forge_preset("balanced", timeframe="1D", asset="ADAUSDC")
result = forge(kpi, ticker="ADAUSDC", timeframe="1D",
               event_discovery_config=disc_cfg, alpha_config=alpha_cfg,
               rule_discovery_config=rd_cfg)
Preset Profilo
"sniper" Eventi rari, regolari, ad alta precisione, regole semplici. Richiede una finestra IS lunga (≥2 anni su 1D). Da non abbinare al rotation calibrator (troppo pochi eventi per calibrare).
"balanced" Frequenza moderata, default sensato per la maggior parte di asset/timeframe.
"sweep" Ricerca ampia e permissiva — progettata per abbinarsi a rotation_calibration=RotationConfig(k>=100) e a un filtro min_lift a valle.
"burst" Eventi concentrati nel tempo (cambio di regime, momentum). Alta dispersione esplicitamente tollerata.

Override accettati per nome: lato M1 — min_tpm, max_dispersion, dispersion_margin, min_episodes, max_and_components, timestamp_col, event_counting; lato M2 — min_lift, min_cohens_d, fdr_q, oos_max_p, horizon_grid, bars_per_day; lato M3 — rd_min_tpm, min_profit_factor, min_win_rate, min_pf_score_tpm, min_fill_rate_opt. Una chiave di override non riconosciuta solleva TypeError.


11. Gestione degli errori

forgedge non ha una gerarchia di eccezioni custom — ogni errore sollevato è un built-in Python semplice (ValueError, KeyError, RuntimeError, TypeError, ImportError, FileNotFoundError). Non c'è nulla di specifico a forgedge da catturare selettivamente; cattura i built-in.

Filosofia di validazione

Non c'è nessuna singola validazione di schema anticipata della KPI Table. forge() stessa non chiama summary_report né controlla le colonne prima di iniziare — la validazione è lazy e distribuita: ogni modulo valida le specifiche colonne/sorgente timestamp di cui ha bisogno, esattamente quando gli servono, e fallisce rapidamente con ValueError/KeyError se mancano. L'unica eccezione deliberata è kpi_builder.build_features(), che salta silenziosamente (con un logger.warning, non un'eccezione) qualunque indicatore le cui colonne di input richieste non siano presenti — così le candele solo-OHLC sono sempre sicure da passare, anche contro una config che chiede anche indicatori basati sul volume.

summary_report() (§9) è la risposta della libreria a "voglio validare prima di impegnarmi in una run" — ma è interamente opt-in: non solleva mai, non blocca mai, e non viene mai chiamata automaticamente. Se vuoi uno stop rigido su dati cattivi, lo scrivi tu:

rep = summary_report(kpi, return_report=True, verbose=False)
if rep.has_critical:
    raise ValueError(f"Risolvi i problemi sui dati prima: {rep.one_line()}")

Eccezioni comuni, verificate

Non sono parafrasate — ognuna è stata effettivamente scatenata e il suo messaggio catturato.

from forgedge import forge, CustomEvent, DiscoveryConfig

forge(kpi, manual_events=[CustomEvent("close < 50")], event_discovery_config=DiscoveryConfig())
# ValueError: manual_events and event_discovery_config are mutually exclusive.
# Pass one or the other, not both.
from forgedge import forge, DiscoveryConfig

forge(kpi, event_discovery_config=DiscoveryConfig(max_and_components=2))
# two_pass_composition ha default True (issue #254 Fase 8) e richiede
# max_and_components <= 1 sull'event_discovery_config che riceve —
# la composizione deve avvenire in un solo posto, non in entrambi:
# ValueError: two_pass_composition=True requires
# event_discovery_config.max_and_components <= 1 (composition happens in
# the two-pass stage instead); pass max_and_components<=1, or
# two_pass_composition=False to use Module 1's own composition.
from forgedge import AlphaDiscovery, AlphaConfig

ad = AlphaDiscovery(kpi, [], AlphaConfig())
ad.promoted_contracts()
# RuntimeError: Call run() before promoted_contracts().

Questo pattern RuntimeError "chiama prima .run()" è consistente tra MarketContext.distribution(), EventDiscovery.summary(), AlphaDiscovery.summary()/.promoted_contracts(), RuleDiscovery.grid_summary(), e TargetOptimizer.validate_oos()/.discover_alpha() — se lo vedi, hai chiamato un accessor prima del corrispondente .run().

from forgedge.event_discovery.models import GateParams
GateParams(min_act=50, min_months=8, max_conc=0.40, min_tpm=2.0)
# TypeError: GateParams.__init__() got an unexpected keyword argument 'min_act'

Questo caso merita una menzione specifica: min_act/min_months/max_conc erano campi di una API precedente di GateParams. Diversi script sotto examples/ in questo repository (alpha_discovery_usage.py, extended_usage.py, kpi_table_1d.py, search_rotation_calibration.py, lowfreq_null_diagnostic.py, lowfreq_endpoint_diagnostic.py) costruiscono ancora GateParams in questo modo vecchio e sollevano esattamente questo TypeError se eseguiti contro la versione attuale della libreria, come installata in questo repository. Questo manuale lo segnala esplicitamente invece di aggirarlo silenziosamente: se copi codice da quegli script di esempio, traduci GateParams(min_act=..., min_months=..., max_conc=..., min_tpm=...) ai campi attuali (min_tpm, max_dispersion, event_counting, min_episodes, episode_gap — §10). examples/kpi_builder_usage.py non ha questo problema — è stato verificato funzionare correttamente end-to-end contro l'API attuale (§13).

Tabella riassuntiva di cosa solleva cosa

Eccezione Trigger tipico
ValueError stringa enum-like non valida (direction, target_mode, buy_type, entry_mode, selection_mode, threshold_mode, timeframe, preset, …), campo di config numerico fuori range, argomenti mutuamente esclusivi di forge(), two_pass_composition=True con event_discovery_config.max_and_components>1, coppia contratto/candidato non corrispondente passata a RuleDiscovery
KeyError una colonna richiesta manca — colonne OHLC, timestamp_col, source_col, un nome di pattern candlestick sconosciuto
RuntimeError un accessor chiamato prima di .run()
TypeError tipo di input errato a build_features/lag_features, o una chiave forge_preset(**overrides) non riconosciuta
ImportError load_kpi_config() chiamata con un percorso YAML ma PyYAML non è installato
FileNotFoundError load_kpi_config() con un percorso che non esiste

Warning da non ignorare

forgedge usa warnings.warn (non eccezioni) per situazioni valide ma probabilmente non volute:

Comportamenti degradati ma non fatali da conoscere

Alcune condizioni non sono né errori né warning — cambiano il comportamento silenziosamente, registrate solo a livello INFO/DEBUG o come stringa diagnostica:


12. Casi d'uso progressivi

Caso d'uso 1 — Hello World: scoprire un evento

Già coperto per intero in §6. Il paradigma da portare via: Event Discovery trova struttura senza mai guardare cosa succede dopo. Tutto il resto della libreria si costruisce sopra quel singolo evento.

Caso d'uso 2 — Un workflow realistico a singolo asset, da candele grezze

Uno scenario più completo: hai candele OHLCV grezze (non ancora una KPI Table), vuoi che gli indicatori siano calcolati per te, e vuoi eseguire la pipeline statistica completa fino ad Alpha Discovery. Questo è examples/kpi_builder_usage.py di questo repository, verificato funzionare correttamente, senza modifiche, contro la libreria attuale:

from forgedge import (
    build_features, candle_features, lag_features,
    summary_report, forge, forge_preset,
)

# candles: un DataFrame con open_time (epoch ms), open, high, low, close, volume
summary_report(candles, timeframe="1D")   # check pre-flight opt-in, non blocca mai

DEMO_CONFIG = {
    "ema":             {"enabled": True, "params": {"periods": [9, 25, 50], "columns": ["close"]}},
    "rsi":             {"enabled": True, "params": {"periods": [14], "columns": ["close"]}},
    "bollinger_bands": {"enabled": True, "params": {"periods": [20], "columns": ["close"]}},
    "min":             {"enabled": True, "params": {"periods": [24], "columns": ["close"]}},
    "max":             {"enabled": True, "params": {"periods": [24], "columns": ["close"]}},
    "return":          {"enabled": True, "params": {"periods": [1, 6, 24], "columns": ["close"]}},
}

kpi = build_features(candles, DEMO_CONFIG, timestamp_col="open_time")
kpi = candle_features(kpi)
kpi = lag_features(kpi, "close", "color", like="_ema_", periods=[1, 2, 3])

disc, alpha, rd = forge_preset("balanced", timeframe="1D", asset="DEMO")
result = forge(kpi, ticker="DEMO", timeframe="1D",
               event_discovery_config=disc, alpha_config=alpha,
               run_rule_discovery=False, progress=False)

print(f"M1 candidati = {len(result.candidates)}  M2 promossi = {len(result.promoted)}")

Output verificato (eseguendo questo script così com'è, su 2000 barre di candele sintetiche generate dallo script stesso):

build_features  : (2000, 21)  (indicatori base + open_dt + color)
candle_features : (2000, 27)  (+ body, upper_wick, lower_wick, close_pos, range_pct, gap)
lag_features    : (2000, 42)  (+ 15 colonne *_prev_NN)
forge(kpi)      : M1 candidati = 5015  M2 promossi = 1091

Interpretazione: build_features ha trasformato 6 colonne grezze in 21 (gli indicatori richiesti più open_dt e color). candle_features ha aggiunto 6 colonne di geometria scale-free. lag_features ha aggiunto 15 copie shiftate. La KPI Table risultante a 42 colonne ha prodotto oltre 5000 candidati evento e poco più di mille contratti promossi — su dati random-walk sintetici, il che è a sua volta un risultato istruttivo (§16, §21): un random walk dovrebbe produrre un gran numero di candidati statisticamente "significativi in apparenza" ma economicamente privi di senso, ed è esattamente per questo che a valle esistono Rule Discovery e la rotation null.

Caso d'uso 3 — Dati reali del repository: ADA e AMZN

Coperto per intero in §7 (quick start ADA) e §13 (entrambi i dataset in profondità, incluso il workflow di pulizia del CSV grezzo di AMZN).

Caso d'uso 4 — Configurazione avanzata

Quattro assi di configurazione che è probabile ti servano davvero, ognuno mostrato come diff reale rispetto al default:

a) Allentare il Consistency Gate per un dataset più corto o a frequenza minore.

from forgedge import EventDiscovery, DiscoveryConfig
from forgedge.event_discovery.models import GateParams

# Il default GateParams(min_tpm=0.5, dispersion_margin=1.3) è già piuttosto
# permissivo; alzare min_tpm scambia via eventi rari/marginali per
# potere statistico per evento (vedi il trade-off frequenza-vs-selettività di §16).
# `max_dispersion` non è la leva sulla dispersione qui — `event_counting`
# di default è "episode", dove governa `dispersion_margin` (#205).
config = DiscoveryConfig(gate_params=GateParams(min_tpm=1.5, dispersion_margin=1.8))
ed = EventDiscovery(kpi, config=config)

b) Passare il modello di ingresso di Rule Discovery da limite a mercato.

from forgedge import RuleDiscovery, RuleDiscoveryConfig

# "limit" (default) può soffrire di un "fill confound": un limite profondo,
# raramente riempito, può mostrare un PF gonfiato su un piccolo sottoinsieme
# non rappresentativo di trade. "market" isola l'edge del segnale dal
# meccanismo di ingresso interamente.
config = RuleDiscoveryConfig(entry_mode="market")
resp = RuleDiscovery(event_frame, contract, candidate, config=config).run()

c) Eseguire il calibratore di rotazione completo e campionato invece di quello veloce di default — con il preset "sweep", come la documentazione raccomanda di abbinarli.

from forgedge import forge, RotationConfig

disc, alpha, rd = forge_preset("sweep", timeframe="1D", asset="ADAUSDC")
result = forge(kpi, ticker="ADAUSDC", timeframe="1D",
               event_discovery_config=disc, alpha_config=alpha, rule_discovery_config=rd,
               rotation_calibration=RotationConfig(k=100))   # sostituisce fast_null
promoted = result.alpha_discovery.promoted_contracts(min_lift=0.05)

d) Passare lo scoring del target binario di Alpha Discovery da eccesso-sul-trend ("proj", il default) a rendimento assoluto ("abs").

from forgedge import AlphaConfig
config = AlphaConfig(asset="ADAUSDC", timeframe="1D", target_mode="abs")

Per ognuno: cosa cambia, perché esiste, e il costo di cambiarlo sono coperti punto per punto in §15 (comportamenti opt-in) e §16 (trade-off) — questa sezione è il "come", quelle sono il "perché".

Caso d'uso 5 — Errori e dati problematici

Dati non validi: una tabella vuota.

import pandas as pd
from forgedge import summary_report

rep = summary_report(pd.DataFrame(columns=["open", "high", "low", "close"]),
                      verbose=False, return_report=True)
print(rep.worst, [f.code for f in rep.findings])
# FAIL ['empty']

summary_report non solleva mai — riporta. Se la tua applicazione ha bisogno di uno stop rigido, lo scrivi tu: if rep.has_critical: raise ValueError(rep.one_line()).

Una configurazione genuinamente obsoleta e incompatibile (un bug reale riproducibile oggi).

from forgedge.event_discovery.models import GateParams
GateParams(min_act=50, min_months=8, max_conc=0.40, min_tpm=2.0)
# TypeError: GateParams.__init__() got an unexpected keyword argument 'min_act'

Questa è la vecchia API di GateParams, che diversi script examples/*.py di questo repository usano ancora. Vedi §11 per la spiegazione completa e quali script sono coinvolti.

Configurazione mutuamente esclusiva.

from forgedge import forge, CustomEvent, DiscoveryConfig
forge(kpi, manual_events=[CustomEvent("close < 50")], event_discovery_config=DiscoveryConfig())
# ValueError: manual_events and event_discovery_config are mutually exclusive.

La trappola silenziosa "nessun risultato" più comune: direction="undetermined".

Non è un'eccezione — è un contratto con status="REJECTED" e una ragione di rifiuto specifica. È l'unico gate di rigetto rigido in Alpha Discovery (§8, §9), e di gran lunga la ragione più comune per cui un evento apparentemente promettente non diventa mai una regola utilizzabile. Succede quando nessun orizzonte nella griglia produce un vantaggio forward medio finito e a segno determinato — comunemente perché:

rejected = [c for c in result.contracts if not c.promoted]
for c in rejected[:3]:
    print(c.event_candidate_id, c.rejection_reasons)
# ogni contratto rifiutato ha "no derivable target" nelle rejection_reasons
# — l'unica ragione di rigetto a gate rigido che questo modulo produce

Un nome di colonna feature non conforme.

kpi["my_custom_signal"] = ...   # non corrisponde a {base}_{indicatore}_{periodo}

Questo non solleva né avverte. my_custom_signal funziona comunque come feature standalone di Event Discovery — semplicemente non viene mai accoppiata in una feature ratio/spread con un'altra colonna (§9). Se ti aspettavi di vedere ratio_my_custom_signal_something nella lista di candidati e non c'è, questa convenzione di naming è la prima cosa da controllare, non un bug da segnalare.

Caso d'uso 6 — Un'applicazione realistica: un servizio di monitoraggio attorno a una sessione di discovery

Questo abbozza come l'output di una sessione diventa qualcosa che un sistema più grande consuma, usando solo chiamate forgedge reali e verificate (il codice "wrapper" — MonitoringService, la classe di storage — è codice applicativo illustrativo, non parte della libreria; §19 è esplicita su questo confine).

import pandas as pd
from forgedge import forge, RuleSpec, rule_performance_report

class MonitoringService:
    """Avvolge una sessione di discovery forge() e la espone per il monitoraggio continuo."""

    def __init__(self, kpi_table: pd.DataFrame, ticker: str, timeframe: str):
        self.result = forge(kpi_table, ticker=ticker, timeframe=timeframe, progress=False)
        # RuleSpec.from_forge_result(): una spec per ogni regola tradabile (EDGE/PARTIAL-EDGE),
        # ciascuna con i params e il candidate necessari per riprodurla in seguito.
        self.specs = RuleSpec.from_forge_result(self.result)

    def published_rules(self) -> list[str]:
        return [s.name for s in self.specs]

    def health_report_html(self, fresh_candles: pd.DataFrame) -> str:
        # Riproduce ogni regola pubblicata deterministicamente su fresh_candles
        # tramite lo stesso percorso EventCandidate.apply() che usa Rule Discovery —
        # fresh_candles non deve essere la tabella di scoperta.
        return rule_performance_report(self.specs, fresh_candles,
                                        title=f"{self.result.ticker} monitoring")


svc = MonitoringService(kpi, ticker="ADAUSDC", timeframe="1D")
print(f"{len(svc.published_rules())} regole pubblicate: {svc.published_rules()[:3]} ...")
html = svc.health_report_html(kpi)   # in produzione: tabella di scoperta + barre genuinamente nuove

Output verificato sul fixture ADA: 40 regole pubblicate: ['RULE_ADA_01', 'RULE_ADA_02', 'RULE_ADA_03'] ... (combacia col conteggio attuale di result.edges(), §7/§9), e un report HTML generato di ~5 MB (grafici SVG inline, nessuna risorsa esterna — sicuro da salvare o inviare via email come singolo file). I nomi RuleSpec seguono la stessa convenzione RULE_{TICKER}_{NN} che il Modulo 4 usa internamente, anche quando non passi affatto per RuleRegistry.


13. Lavorare con i dati presenti in questo repository

Due dataset reali sono inclusi in questo repository, più un artefatto derivato. Nessuno dei due è un giocattolo — entrambi sono dati di mercato genuini.

tests/fixtures/ADA_1D_TRAIN.parquet

882 barre giornaliere OHLCV di ADAUSDC (Cardano), dal 2024-01-01 al 2026-05-31, con 22 colonne indicatore precalcolate già allegate: close_ret_{03,12,96} (rendimenti), Bollinger Bands (close_bb_{mid,upper,lower,width}_20), un max-drawdown mobile (close_mdd_48), EMA su due finestre sia su close sia su low (con tre copie shiftate ciascuna, _prev_01..03), e volatilità mobile (close_vol_{05,12,24}). Questo è esattamente il fixture su cui la suite di regressione golden della libreria stessa (tests/test_golden.py) fissa i propri valori attesi — cioè se esegui forge() con gli argomenti mostrati in §7, stai riproducendo a mano parte della suite di test della libreria stessa.

import pandas as pd
kpi = pd.read_parquet("tests/fixtures/ADA_1D_TRAIN.parquet")

Nessun preprocessing necessario — questa tabella è già una KPI Table valida (ha close, ha open_dt come datetime64[ns], ordinata cronologicamente). Questo è il dataset usato in §7 e nei Casi d'uso 4-6 di §12.

examples/data/AMZN_1D.csv

1378 barre giornaliere di AMZN (Amazon), un export da un provider di dati finanziari in un formato comune ma non direttamente compatibile con forge():

import pandas as pd
raw = pd.read_csv("examples/data/AMZN_1D.csv")
print(raw.columns.tolist())
# ['Date', 'Price', 'Open', 'High', 'Low', 'Vol.', 'Change %']
print(raw.head(2))
#          Date   Price    Open    High     Low     Vol. Change %
# 0  06/30/2026  238.71  237.50  241.53  237.57   31.50M   -0.60%
# 1  06/29/2026  240.14  234.22  249.71  233.80   77.62M    3.20%

Nota cosa non va in questa tabella, dal punto di vista di forgedge: il prezzo di chiusura si chiama "Price", non "close"; il volume è una stringa con un suffisso di unità ("31.50M"); le date sono in ordine decrescente (più recente per prima) e memorizzate come stringhe MM/DD/YYYY, non una colonna o un indice datetime64. Questo è realistico, ed è esattamente il tipo di problema che summary_report è pensata per catturare prima di sprecare una run di forge(). Il walkthrough completo del repository stesso (examples/forge_amzn_walkthrough.ipynb), che questo manuale ha verificato cella per cella, mostra la pulizia necessaria:

raw.columns = [c.strip().lower().replace(".", "").replace(" ", "_") for c in raw.columns]
raw = raw.rename(columns={"price": "close", "vol": "volume", "change_%": "chg_pct"})

raw["open_dt"] = pd.to_datetime(raw["date"], format="%m/%d/%Y")
raw = raw.sort_values("open_dt").reset_index(drop=True)   # ordine ascendente — richiesto

for col in ["open", "high", "low", "close"]:
    raw[col] = pd.to_numeric(raw[col].astype(str).str.replace(",", ""), errors="coerce")

raw["volume"] = (raw["volume"].astype(str).str.replace("M", "e6").str.replace("B", "e9"))
raw["volume"] = pd.to_numeric(raw["volume"], errors="coerce")

candles = raw[["open_dt", "open", "high", "low", "close", "volume"]]

Dopo la pulizia, il notebook esegue summary_report(candles, timestamp_col="open_dt", timeframe="1D", return_report=True) e — vale la pena citarlo direttamente perché è un riscontro genuino e reale, non un esempio didattico costruito — fa emergere un'anomalia reale: "l'ultima barra del dataset (2026-06-30, la barra 'di oggi' al momento dell'estrazione) ha open leggermente sotto il low registrato — sintomo tipico di una barra ancora incompleta (sessione di mercato in corso al momento dello scarico del CSV)." Questa è un'illustrazione genuinamente utile e reale del perché si esegue summary_report prima, non dopo, una sessione di discovery: una barra finale incompleta non è una ragione per diffidare dell'intero dataset, ma è una ragione per considerare di scartare quella singola riga prima di eseguire Event Discovery, dato che la relazione OHLC di una barra parziale viola l'assunzione che ogni altra barra della tabella soddisfa.

Il notebook procede poi attraverso build_features() (con atr/macd esplicitamente abilitati, a differenza della config demo del Caso d'uso 2), forge_preset("sweep", ...), e una chiamata forge() completa, arrivando — su dati AMZN reali — a esattamente 2 segnali PARTIAL-EDGE e zero EDGE pieni, su 3035 candidati M1 e 508 contratti promossi M2. L'interpretazione dello stesso notebook (§14 copre il "perché" in profondità) è esplicita e vale la pena citarla: "Il risultato onesto: quasi nessun edge, ed è la cosa giusta."

kpi_table_1d.csv (radice del repository)

Un artefatto di output pre-generato, non un dataset di input — è ciò che examples/kpi_table_1d.py produce confrontando il percorso di event-discovery proprio di FORGE contro il TargetOptimizer standalone (§9) su dati giornalieri ADA attraverso tre finestre temporali (2024 in-sample, 2025 e 2026 out-of-sample). Le sue 19 colonne (ID, Pipeline, EVENT_ID, EXPRESSION, IS 2024_WR, IS 2024_PF, ...) sono un esempio lavorato di confronto tra due strategie di discovery e il loro decadimento out-of-sample fianco a fianco — utile da leggere come forma di output di riferimento, non qualcosa che devi rigenerare per usare questo manuale.

Notebook

Da notebooks/01_event_discovery.ipynb a 06_rule_registry.ipynb percorrono ogni modulo individualmente e in profondità; notebooks/hurst.ipynb è un approfondimento dedicato sulla stima dell'half-life Hurst/OU che Market Context usa per la selezione automatica delle finestre EMA (§8). examples/forge_amzn_walkthrough.ipynb (usato per tutta questa sezione) è l'unico costruito interamente attorno a un dataset incluso in questo repository ed è la cosa più vicina a un walkthrough canonico, completamente riproducibile, su dati reali.


14. Scelte di design

Questa sezione distingue tre categorie in modo esplicito: (documentato) — il ragionamento dichiarato dagli autori stessi, citato o parafrasato da vicino dalla documentazione del repository; (misurato) — un risultato reale, riportato da un'esecuzione su dati reali, non un benchmark inventato; (inferito) — la lettura del codice fatta da questo manuale, chiaramente segnalata come tale, mai presentata come un fatto dichiarato dagli autori.

La separazione a tre domini è architetturale, non convenzionale (documentato)

Già introdotta in §2. La guida architetturale del repository è esplicita sul fatto che sia un vincolo, non una scelta di stile, e dà la ragione specifica: tenere il rendimento forward fuori da Event Discovery "elimina una categoria intera di look-ahead bias" — per costruzione, non per disciplina.

"La rigidità a monte compra libertà a valle" (documentato)

Un trade-off dichiarato esplicitamente, nelle parole degli autori stessi, in src/forgedge/docs/README.md: "La rigidità a monte è la garanzia che ciò che arriva ad Alpha Discovery sia una misura genuina — non un artefatto di ottimizzazione sul target. Compra la libertà di esplorare liberamente a valle perché le soglie sottostanti non sono state contaminate da nessuna scelta economica." Questa è la motivazione per cui le soglie di Event Discovery sono immutabili: il costo (non puoi tarare una soglia a posteriori, anche se sei sicuro che una leggermente diversa funzionerebbe meglio) è ciò che rende tutto a valle affidabile.

Le misure statistiche informano il voto; solo la direzione blocca la promozione (documentato)

Anche questo esplicito nella stessa fonte: le debolezze statistiche in Alpha Discovery "alimentano il voto, non scartano — Rule Discovery è l'unico giudice economico". Questa è una scelta di design genuinamente non ovvia su cui vale la pena soffermarsi: un'implementazione ingenua rigetterebbe un candidato per IC debole o lift basso. forgedge invece lascia passare al Modulo 3 tutto ciò che ha una direzione determinata, e tratta le debolezze statistiche puramente come informazione che (a) abbassa il voto in lettere e (b) compare verbatim in rejection_reasons — anche su contratti che sono stati promossi. Il risultato, verificato in §7: il miglior candidato PARTIAL-EDGE del quick start ADA porta un voto A e una lista di diagnostici non bloccanti, insieme all'unico gate (rotation null) che ha effettivamente limitato il suo verdetto.

Anti-goal — cosa gli autori hanno esplicitamente deciso di non costruire (documentato)

Il documento di analisi funzionale (docs/analysis/forge2_functional_analysis.md) dichiara quattro direzioni rifiutate con motivazioni, verbatim:

La rotation null esiste perché l'audit degli stessi autori ha trovato che i conteggi di promozione non erano affidabili a bassa frequenza (documentato + misurato)

Questa è la scelta di design più importante da capire se vuoi fidarti dell'output di forgedge, ed è sostenuta da numeri reali che gli autori riportano dal proprio audit (docs/analysis/lowfreq_robustness.md, su ADA_1D_FULL.parquet, 901 barre giornaliere):

"ADA REALE ha promosso 58 alpha da 2542 candidati su una finestra in-sample 2024; il rumore phase-randomized (5 run, stessa ricetta, stesse statistiche, autocorrelazione preservata ma qualsiasi predittività reale distrutta) ha promosso 146 ± 45 (range 101-207). Il rumore viene promosso più spesso dell'asset reale."

E, uno stadio più a valle, al livello dei verdetti tradabili EDGE/PARTIAL-EDGE:

"Il rumore puro guadagna comunque ~2-3 EDGE su 12 testati (~20% di pavimento di falsi positivi per regola)."

Questa è la diagnosi degli stessi autori di una debolezza reale in una versione della pipeline prima che la rotation null esistesse come default. La correzione — FastRotationNull, da allora attiva di default — è descritta nello stesso documento con il proprio costo ed effetto misurato: "calcola la rotation null esatta a livello di ricerca su ogni offset circolare (cross-correlazione FFT, ~1 s su questo dataset — niente K, niente seed)... Su questo dataset ADA il search p è ≈ 0.70: ogni EDGE precedente viene onestamente limitato a PARTIAL-EDGE." Quest'ultima frase è un riscontro genuinamente notevole e autocritico: gli autori hanno eseguito la propria correzione contro i propri risultati migliori precedenti e hanno riportato che li ha declassati tutti.

Il design è stato scelto per mantenere identico il contratto rivolto all'utente correggendo l'onestà interna (documentato)

Il documento di analisi funzionale inquadra questo esplicitamente come il vincolo guida dell'intero redesign: "ripartendo da zero, cosa va tenuto e cosa va riprogettato — senza cambiare il contratto con l'utente?" E la sintesi finale dichiara la diagnosi in modo netto: "il sistema conta benissimo le proprie prove dentro ogni modulo, ma nessuno conta le prove dell'intera catena — e sui dati lenti questo trasforma il multiple testing in verdetti EDGE che il rumore puro sa replicare 1 volta su 5." La conseguenza pratica per te come utente: ogni default descritto in §15 sotto che sembra rendere la pipeline più conservativa (la rotation null, il power gate, gli split temporali purgati) esiste specificamente per chiudere quel divario, e nessuno di essi è stato aggiunto cambiando la firma di chiamata di forge() in modo incompatibile.

Perché la griglia di orizzonti doveva diventare consapevole del timeframe (documentato + misurato)

lowfreq_robustness.md identifica questo come un punto debole specifico, referenziato nel codice: "horizon_grid non è scalata sulla frequenza... forge(..., timeframe="1D") la usa invariata → periodi di detenzione fino a 48 giorni. A differenza di MarketContext / Hurst / rolling-IC / bars-per-year, che si auto-scalano, la griglia di orizzonti è una trappola silenziosa." Questo è il motivo per cui §7 di questo manuale dedica un paragrafo al fatto che forge() sostituisca una griglia giornaliera per te — è una correzione per un bug documentato e precedentemente reale, non una feature cosmetica.

Perché la deduplicazione solo segnala, non elimina mai (documentato)

src/forgedge/docs/README.md dà una ragione specifica e non ovvia per cui due regole strutturalmente sovrapposte potrebbero valere la pena di essere entrambe tenute: "Una coppia INDEPENDENT_CONFIRMATION non è un duplicato da scartare — è la conferma indipendente dello stesso edge da due meccanismi diversi." (Nota: la classificazione a due livelli che questo passaggio descrive — DUPLICATE_STRUCTURAL/DUPLICATE_BEHAVIORAL/INDEPENDENT_CONFIRMATION — è elencata nella roadmap dello stesso repository come non ancora implementata; il comportamento attuale, effettivamente distribuito, è il più semplice flag binario is_duplicate descritto in §8/§9. Questo manuale segnala esplicitamente questo divario invece di descrivere una feature pianificata come attuale.)


15. Comportamenti opt-in

Questa è la sezione che il compito ha chiesto di trattare con particolare cura. "Opt-in" qui significa specificamente: un comportamento che non avviene a meno che tu non imposti esplicitamente un valore non-default. Diversi default sotto (la rotation null, il purging, il power gate, l'arricchimento degli orizzonti) sono essi stessi attivi di default e devono essere esplicitamente disattivati — sono segnalati come "default-on, opt-out" invece di essere impropriamente classificati come opt-in.

Feature Default Come abilitarla Vantaggio Costo / trade-off Quando usarla
Walk-forward OOS a livello di evento (DiscoveryConfig.walk_forward) None (off) DiscoveryConfig(train_ratio=0.8, walk_forward=EventWalkForwardConfig(n_splits=3, min_pass_rate=0.6)) Conferma che la struttura temporale di un evento — non ancora il suo potere predittivo — è stabile su più finestre OOS prima ancora di arrivare ad Alpha Discovery Meno barre disponibili per la mining IS di Event Discovery; aggiunge tempo di esecuzione al Modulo 1 Sospetti che il tuo catalogo di indicatori possa fare overfitting su un regime e vuoi un primo filtro prima degli stadi (molto più costosi) di Alpha/Rule Discovery
Diversity Gate (DiscoveryConfig.diversity_gate_enabled) False DiscoveryConfig(diversity_gate_enabled=True, diversity_threshold=0.85) Scarta eventi singoli quasi-duplicati (Jaccard ≥ soglia sulle date di attivazione) prima della composizione AND, così i candidati composti non vengono sprecati su coppie ridondanti Può scartare un evento genuinamente distinto che risulta correlato strutturalmente a un altro su questa specifica finestra; aggiunge un passaggio di confronto O(n²)-simile sui singoli che superano il gate Cataloghi di indicatori ampi dove hai osservato molti candidati quasi-identici sopravvivere al gate
event_counting="bar" (GateParams) "episode" GateParams(event_counting="bar") Riproduce esattamente il comportamento del gate pre-#134 (conteggio per episodio), barra per barra Stati persistenti multi-barra (es. un tratto di 3-5 barre con RSI<30) gonfiano la varianza dei conteggi mensili e possono venire rigettati a torto — questa è la ragione documentata per cui "episode" è diventato il default Ti serve riproducibilità byte-per-byte con una sessione pre-episode-counting, o hai una ragione specifica per pesare per conteggio di barre grezze
tpm_mode="ranged" (GateParams) "floor" GateParams(tpm_mode="ranged") (solo conteggio "episode" — solleva ValueError sotto event_counting="bar") min_tpm diventa il centro di una banda ([min_tpm - tolleranza, min_tpm + tolleranza]) invece di un floor a senso unico, così un evento che si attiva troppo spesso viene rigettato anch'esso, non solo uno che si attiva troppo raramente — utile per isolare pattern "a cadenza regolare" e scartare condizioni quasi sempre attive, quasi degeneri tpm_tolerance lasciato a UNSET deriva la mezza-larghezza dalla tolleranza di dispersione della sessione (docs/analysis/ranged_tpm_and_market_alignment_proposal.md §2) — una sessione con storia breve o min_tpm basso ottiene una banda larga, che può ammettere eventi che un gate in modalità floor avrebbe rigettato come troppo rari; un evento con n_episodes == 0 viene sempre rigettato indipendentemente dalla banda Vuoi eventi a una frequenza target approssimativa, non "almeno" — es. calibrare una cadenza di trade attesa specifica invece di massimizzare il volume di candidati
require_significant_direction=False (PromotionThresholds) True (attivo di default, opt-out) AlphaConfig(thresholds=PromotionThresholds(require_significant_direction=False)) Ripristina il comportamento legacy: argmax\|z_h\| assegna sempre una direzione (soggetta solo a min_direction_t), indipendentemente dal fatto che qualche test statistico abbia trovato l'orizzonte distinguibile dalla rotation null — statistically_weak continua a segnalare prove deboli, ma non blocca mai Il default (True) è esso stesso un vero cambio di comportamento rispetto a prima che questo venisse aggiunto: un candidato ora ha bisogno o di un orizzonte individualmente significativo per BH-FDR (h_sig) o di un test AUC significativo sull'intera griglia (p_auc < auc_max_p, §9) per ottenere una direzione — disattivarlo riapre il rischio di "coin-flip su un picco rumoroso" che quel gate esiste per chiudere Riprodurre risultati precedenti al gate AUC, o una sessione di debug in cui vuoi vedere cosa avrebbe scelto argmax\|z_h\| a prescindere dalla significatività
target_mode="abs" (AlphaConfig, TargetConfig) "proj" AlphaConfig(target_mode="abs") Valuta il target binario sul rendimento forward grezzo, non sull'eccesso-sopra-trend — più semplice, corrisponde alle convenzioni di backtest "da manuale" "proj" esiste specificamente perché un evento long che cavalca una deriva di bull market non venga accreditato del trend del mercato come se fosse l'edge dell'evento; "abs" reintroduce quel rischio Confronto con una baseline pre-"proj", o lavoro su un asset/periodo dove il trend-following è esplicitamente la strategia che vuoi misurare, non filtrare via
AlphaConfig.embargo_bars / RuleWalkForwardConfig.embargo_bars 0 AlphaConfig(embargo_bars=5) Aggiunge un buffer di quarantena dell'autocorrelazione seriale all'inizio della finestra OOS, oltre a ciò che il purging da solo rimuove Riduce ulteriormente il campione OOS, che — combinato con una coda OOS giornaliera già sottile — può spingere un contratto verso INSUFFICIENT-DATA Asset con autocorrelazione a breve lag nota e forte oltre a ciò che la larghezza di purge (= orizzonte massimo) già copre
RotationCalibrator (esplicito, via rotation_calibration=) None (sostituito dal FastRotationNull di default) forge(..., rotation_calibration=RotationConfig(k=100)) Calibrazione multi-yardstick (composite, lift, t-stat) via una combinazione di Tippett min-p — cattura statistiche discriminanti che il singolo yardstick abs_z di FastRotationNull può perdere ~K × il costo di un passaggio di Alpha Discovery (dell'ordine di secondi-minuti per estrazione su dati reali, secondo la misura dello stesso documento di design "~4 s/estrazione") vs. i ~1 secondo totali di FastRotationNull Abbinato esplicitamente al preset "sweep"; ogni volta che vuoi l'oggetto report completo, non solo l'annotazione pass/fail che fast_null lascia su ogni contratto
fast_null=False fast_null=True (default-on, opt-out) forge(..., fast_null=False) Salta interamente il controllo rotation null di default — più veloce, e un contratto altrimenti limitato può raggiungere un verdetto EDGE pieno Reintroduce esattamente il problema "il rumore viene promosso quasi quanto il segnale" che §14 documenta con numeri reali — non è un parametro di performance da attivare/disattivare con leggerezza Solo per debug/prototipazione, o quando eseguirai comunque il RotationCalibrator completo separatamente e non vuoi l'annotazione del passaggio veloce
time_budget= (TimeBudget esplicito) None — ma il purging resta attivo di default anche senza forge(..., time_budget=TimeBudget.build(n_bars=len(kpi), horizon_bars=48, embargo_bars=5)) Un unico asse IS/OOS condiviso ed esplicito tra Event e Alpha Discovery, con un embargo controllabile Più da configurare correttamente; sbagliare l'argomento horizon_bars sbaglia anche la larghezza di purge Pipeline multi-modulo costruite a mano (non via forge()) dove serve che gli split di Event e Alpha Discovery coincidano esattamente
purge_bars=0 (opt-out dal purging attivo di default) larghezza di purge = max(horizon_grid) (default-on) TimeBudget.build(..., purge_bars=0), o RuleWalkForwardConfig(purge_bars=0) per M3 Riproduce esattamente i risultati numerici pre-purging (utile per confrontare con run vecchie, o i vecchi valori golden-test della libreria stessa) Reintroduce un look-ahead reale, anche se di solito piccolo: le barre IS il cui orizzonte forward attraversa nel OOS non vengono più escluse Solo quando serve specificamente riproducibilità storica, non per uso normale
power_gate=False (SelectionCriteria) True (default-on, opt-out) RuleDiscoveryConfig(criteria=SelectionCriteria(power_gate=False)) Un verdetto che altrimenti verrebbe declassato a INSUFFICIENT-DATA per mancanza di potere statistico OOS viene lasciato passare come EDGE/PARTIAL-EDGE Perdi il segnale proprio della pipeline che l'evidenza OOS è troppo debole per fidarsi della stima puntuale — un contratto può sembrare tradabile puramente perché nessuno ha controllato se la dimensione del campione potesse sostenere l'affermazione Praticamente mai per una decisione live; solo per ispezionare quale sarebbe stato il verdetto non filtrato
entry_mode="limit" (uscire dalla valutazione a due stadi di default) "auto" (default dalla #185) RuleDiscoveryConfig(entry_mode="limit") La griglia ottimizza buy_drop_pct come parte del verdetto, quindi una strategia il cui edge è l'ordine a limite stesso viene misurata come una cosa sola invece che divisa in segnale + esecuzione Reintroduce il fill confound nel verdetto: uno sconto più profondo riempie solo sui percorsi che sono tornati a prenderlo, quindi il PF sale su un sottoinsieme che non è la popolazione tradeable Quando l'ordine a limite è davvero la strategia. Per un segnale che stai cercando di misurare, "auto" separa le due letture e pubblica comunque il punto limite quando se lo merita
selection_mode="full_sample" "walk_forward" RuleDiscoveryConfig(selection_mode="full_sample") Ripristina il comportamento legacy: il punto operativo viene scelto vagliando l'intera tabella, non solo le finestre di train walk-forward Il profit factor in-sample e i parametri operativi pubblicati possono essere influenzati da dati che poi diventano parte della finestra di test OOS — una fuga reale e documentata che la modalità walk-forward esiste per chiudere Solo compatibilità legacy; la ragione documentata per evitarlo in lavoro nuovo è esplicita nel sorgente
AlphaConfig.horizon_enrichment=None (opt-out) (0.5, 1.0, 2.0) (default-on) AlphaConfig(horizon_enrichment=None) Restringe Alpha Discovery strettamente alla horizon_grid base, senza aggiunte per-evento Secondo la misurazione degli stessi autori, 34 su 247 alpha promossi sul dataset ADA hanno trovato il loro orizzonte migliore solo grazie a questo arricchimento — disattivarlo li perderebbe silenziosamente Riprodurre una baseline pre-arricchimento, o quando hai una ragione specifica per cui la griglia base non deve essere estesa
pattern_features() (una chiamata a funzione separata, non un flag di config) non chiamata from forgedge import pattern_features; kpi = pattern_features(kpi) Aggiunge un'unica colonna categoriale candle_pattern (dieci formazioni con nome: HAMMER, DOJI, pattern di engulfing, …) che attraversa forge() end-to-end come eventi one-hot I pattern con nome codificano soglie fisse scelte dall'uomo; la geometria continua di candle_features() è preferita per la discovery automatica specificamente perché FORGE deriva le proprie soglie asset-adattive invece Lavoro manuale/esplorativo dove vuoi specificamente testare ipotesi su pattern con nome, non per il percorso di discovery automatica di default
use_stable_regime_only=True (AlphaConfig) False AlphaConfig(use_stable_regime_only=True) L'analisi di sensibilità al regime (Step 5 di Alpha Discovery) considera solo le barre in cui il regime si è mantenuto per ≥ stable_window barre — statistiche per-regime più pulite Meno osservazioni per regime, che può portare min_regime_obs (default 10) fuori portata per i regimi meno comuni Hai osservato barre di transizione di regime contaminare la scomposizione per-regime con rumore del regime precedente
early_elimination=False (SelectionCriteria) True RuleDiscoveryConfig(criteria=SelectionCriteria(early_elimination=False)) Forza l'esecuzione della pipeline completa di walk-forward e diagnostica anche su una regola che fallisce lo screen rapido in-sample — utile per report uniformi anche sulle regole NON-EDGE Compute significativamente maggiore per regola (l'intero scopo dell'eliminazione precoce è saltare quel lavoro sulle regole che falliranno comunque) Scenari di audit/reporting dove vuoi il comportamento OOS di ogni regola popolato, non solo quello dei sopravvissuti

16. Trade-off

Questi sono i trade-off effettivamente visibili nel codice e nella documentazione di design — non una lista generica.

Automazione vs. controllo. forge() prende un gran numero di decisioni per te di default (scalatura della griglia di orizzonti, la rotation null, il purging, l'arricchimento degli orizzonti) specificamente affinché una run "quick start" e una run "curata attentamente" non divergano silenziosamente in onestà statistica (§14). Il costo è che un lettore alla prima esperienza non può davvero prevedere il comportamento completo di forge() dalla sola firma di chiamata — la lista "cosa ha fatto forge() che non hai chiesto" di §7 esiste perché quel divario è reale, non perché questo manuale sia insolitamente meticoloso.

Frequenza vs. selettività, nel Consistency Gate. L'analisi di calibrazione della libreria stessa (docs/analysis/search_rotation_calibration.md) riporta questo esplicitamente, con numeri reali, come un trade-off genuino piuttosto che un bug da correggere: alzare min_tpm da 1.5 a 3.0 ha fatto scendere i candidati estratti da 2621 a 584 e il lift del miglior alpha reale da 0.707 a 0.287 — ma il candidato sopravvissuto è passato da 9 attivazioni in-sample (che fallivano OOS: win rate 0.78 IS → 0.40 OOS) a 29 attivazioni (che passavano OOS, p=0.000). Nelle loro stesse parole: "min_tpm più alto abbassa il lift... scambi 'estremo ma fragile' per 'modesto ma confermabile'." Non esiste un valore di default di min_tpm semplicemente "corretto" — è una manopola tra "eventi rari con edge dall'aspetto drammatico ma statisticamente fragili" ed "eventi frequenti con edge modesti ma ben confermati", e quale estremo vuoi dipende da quanti dati in-sample hai effettivamente.

Rigore statistico vs. costo in tempo di calcolo. Il FastRotationNull di default è stato progettato specificamente per rendere questo trade-off quasi gratuito (≈1 secondo, secondo la misura dello stesso documento di design, contro i ≈4 secondi per estrazione del RotationCalibrator a K=100) — ma paga quella velocità coprendo un solo yardstick (abs_z), dove il calibratore completo ne copre diversi combinati via il metodo di Tippett. Se abs_z non è la statistica che avrebbe discriminato i tuoi dati specifici (il documento di calibrazione mostra che questo cambia in base a min_tpm, cioè in base alla tua stessa configurazione), la copertura a singolo yardstick della null veloce è un divario reale, non ipotetico.

Validazione eager vs. lazy. Non c'è un singolo passaggio di validazione dello schema sulla tua KPI Table (§11). Questo compra la capacità di passare una tabella arbitraria ed evolutiva senza pre-registrarne lo schema da nessuna parte — ma significa che una colonna malformata emerge come KeyError/ValueError potenzialmente diversi moduli dentro una chiamata forge(), non alla porta. summary_report() esiste specificamente per lasciarti scegliere la validazione eager quando la vuoi, senza forzarla su ogni chiamata.

Riproducibilità vs. performance in Rule Discovery. SelectionCriteria.early_elimination=True (default) scarta una regola da ulteriore elaborazione nel momento in cui fallisce uno screen in-sample economico — è un vantaggio di performance reale (l'intero scopo è evitare di eseguire walk-forward e validazione statistica completa su regole che falliranno comunque), ma significa che le regole NON-EDGE portano di default diagnostiche incomplete (nessun walk_forward popolato). La voce early_elimination=False di §15 è esattamente la via d'uscita per quando il reporting uniforme conta più della velocità.

Purezza delle dipendenze vs. rischio di reimplementazione. L'anti-goal esplicito "niente dipendenze statistiche esterne" (§14) significa che correlazione di Spearman, t-test, funzione beta incompleta, e controllo FDR di Benjamini-Hochberg sono tutti scritti a mano in numpy invece di essere delegati a scipy.stats/statsmodels. Il beneficio dichiarato è l'auditabilità — puoi leggere esattamente cosa calcola forgedge senza addentrarti nel sorgente di una libreria esterna molto più grande. Il rischio corrispondente (questa è un'inferenza chiaramente propria di questo manuale, non dichiarata) è che queste primitive non ereditano automaticamente i decenni di irrobustimento sui casi limite di scipy; l'estesa suite di test (§18) è presumibilmente la mitigazione, anche se i documenti di design non lo inquadrano esplicitamente in questi termini.


17. Performance e scalabilità

Nulla in questa sezione è un benchmark inventato. Ogni numero è misurato e riportato nella documentazione del repository stesso, oppure è un'osservazione qualitativa di complessità tratta direttamente dal codice.

Misurazioni riportate (da docs/analysis/ e dalle esecuzioni verificate da questo manuale):

Osservazioni di complessità dal codice (lettura propria di questo manuale, non un benchmark dichiarato):

Nessuna GPU, nessun calcolo distribuito, nessun I/O asincrono da nessuna parte nella libreria — ogni modulo è codice numpy/pandas sincrono, single-process, single-thread. Se ti serve eseguire la discovery su molti ticker, forge_multi() li esegue comunque sequenzialmente, un ticker alla volta (§9); parallelizzare tra ticker o tra i backtest per-contratto di Rule Discovery è squisitamente una responsabilità a livello applicativo (§19-20), non qualcosa che la libreria fa per te.


18. Testing

La suite di test del repository stesso è il miglior modello per testare codice che usa forgedge — è sostanziale (586 funzioni di test su 15 file, testpaths = ["tests"] in pyproject.toml) e le sue convenzioni sono abbastanza coerenti da valere la pena adottarle direttamente.

Eseguirla

pip install -e ".[dev]"      # pytest>=7.0
pytest                        # l'intera suite
pytest tests/test_rule_discovery.py                          # un modulo
pytest tests/test_forge.py::TestForgeManualEvents             # una classe
pytest tests/test_forge.py::TestForgeManualEvents::test_mutual_exclusion_raises   # un test
pytest tests/test_golden.py                                    # solo i pin di regressione
pytest -k golden                                                # per keyword

Lo stile della casa, osservato direttamente nella suite

python def test_events_come_from_stored_series_not_apply(self, monkeypatch): """Percorso veloce: la serie di attivazione in cache deve essere riusata, apply() non deve girare.""" def _boom(self, frame): raise AssertionError("Alpha Discovery ha ricalcolato un evento via apply()") monkeypatch.setattr(EventCandidate, "apply", _boom) ...

Ogni altro test costruisce serie di prezzo sintetiche reali e seedate con np.random.default_rng(seed) ed esegue il codice reale della pipeline contro di esse — non c'è I/O finto da mockare, perché la libreria non ne ha. - Dati sintetici deterministici, seedati e documentati nel motivo sono l'idioma dominante delle fixture — quasi ogni file di test definisce il proprio helper locale in stile _make_kpi_table()/_ohlc_kpi_table(), ciascuno con una docstring che spiega perché è stata scelta quella specifica forma di segnale (es. "feat basso predice un rendimento positivo alla barra successiva, quindi gli eventi su feat dovrebbero alzare il win rate"). tests/conftest.py fornisce esattamente due fixture condivise, module-scoped (kpi_4380, kpi_8760 — tabelle orarie sintetiche di ~6 e ~12 mesi) per i test che non hanno bisogno di una forma di segnale su misura. - Un unico file fixture reale per il test di regressione: tests/fixtures/ADA_1D_TRAIN.parquet (§13), usato dalla fixture session-scoped forge_result di test_golden.py, che esegue forge() esattamente una volta e deriva decine di asserzioni a livello di singolo campo da quell'unica esecuzione — isolando quale stadio della pipeline si è rotto senza rieseguire la pipeline per ogni asserzione. - pytest.approx(..., rel=...) è l'idioma standard per fissare i float — mai uguaglianza nuda su un float. pytest.raises(..., match=...) e pytest.warns(...) sono usati in modo consistente (~53 usi su 8 file) per fissare messaggi esatti di errore/warning, non solo i tipi di eccezione. - @pytest.mark.parametrize è usato esattamente una volta nell'intera suite (test_target_optimizer.py) — la convenzione stessa del codebase favorisce fortemente un metodo di test esplicito e nominato per comportamento invece di tabelle parametrizzate, anche per casi strutturalmente simili.

Un esempio lavorato di golden test, che illustra il pattern

# tests/test_golden.py (struttura, condensata)
@pytest.fixture(scope="session")
def forge_result():
    kpi = pd.read_parquet(FIXTURE_PATH)   # tests/fixtures/ADA_1D_TRAIN.parquet
    return forge(kpi, ticker="ADAUSDC", timeframe="1D",
                 run_rule_discovery=True, run_registry=False, progress=False)

class TestGoldenEventDiscovery:
    def test_n_activations(self, golden_candidate):
        assert golden_candidate.activation_stats.n_activations == 27  # int, pytest.approx non serve
    def test_mean_tpm(self, golden_candidate):
        assert golden_candidate.activation_stats.mean_tpm == pytest.approx(0.931034, rel=1e-4)

I commenti inline dello stesso file documentano la storia dei valori golden: sono stati ri-fissati più volte man mano che cambiamenti legittimi della pipeline venivano introdotti (il default del conteggio per episodio, la griglia di orizzonti scalata sul timeframe, il cambio di selezione walk-forward, il power gate). Questo è il workflow inteso, e vale la pena adottarlo per i tuoi test costruiti sopra forgedge: un golden test che si rompe è atteso su un cambiamento di comportamento legittimo — la risposta corretta è ri-fissare il valore con un commento che spiega perché, non assumere che il test (o il cambiamento) sia sbagliato.

Cosa testare nel tuo codice che chiama forgedge


19. Integrare forgedge in un'applicazione reale

forgedge è una libreria di ricerca, e questa sezione è esplicita, per tutta la sua estensione, sul confine tra cosa dà la libreria e cosa la tua applicazione deve costruire sopra di essa. Nulla sotto è un'API forgedge — è la guida architetturale di questo manuale, chiaramente separata dalla reference API di §9.

Cosa dà forgedge

Cosa deve costruire la tua applicazione

Uno schizzo di servizio minimale

Questo compone solo chiamate forgedge reali e verificate, avvolte in codice applicativo illustrativo (non di libreria):

import pandas as pd
from dataclasses import dataclass
from forgedge import forge, ForgeResult, RuleSpec, RuleDiscovery

@dataclass
class DiscoverySession:
    """Wrapper a livello applicativo: una run forge() più ciò che serve per monitorarla in seguito."""
    ticker: str
    timeframe: str
    result: ForgeResult
    specs: list[RuleSpec]

    @classmethod
    def run(cls, kpi_table: pd.DataFrame, ticker: str, timeframe: str) -> "DiscoverySession":
        result = forge(kpi_table, ticker=ticker, timeframe=timeframe, progress=False)
        specs = RuleSpec.from_forge_result(result)
        return cls(ticker=ticker, timeframe=timeframe, result=result, specs=specs)

    def persist(self, store) -> None:
        # 'store' è IL TUO strato di persistenza — forgedge non ne ha uno.
        # AlphaContract.to_contract_dict() / RuleDiscoveryResponse.to_dict()
        # sono i mattoni serializzabili in JSON.
        rows = [
            {"ticker": self.ticker, "rule_id": spec.name,
             "contract": next(c for c in self.result.promoted
                               if c.event_candidate_id == spec.candidate.event_id).to_contract_dict()}
            for spec in self.specs
        ]
        store.save_rules(self.ticker, rows)

    def check_signals(self, fresh_bars: pd.DataFrame) -> list[str]:
        """Quali regole pubblicate si stanno attivando sulle barre più recenti, ora."""
        active = []
        for spec in self.specs:
            fires = spec.candidate.apply(fresh_bars).fillna(0).astype(bool)
            if len(fires) and fires.iloc[-1]:
                active.append(spec.name)
        return active

    def revalidate(self, eval_df: pd.DataFrame) -> dict[str, str]:
        """Ricontrolla se ogni regola pubblicata regge ancora, su dati di scoperta + nuovi.
        Usa RuleDiscovery (non AlphaDiscovery) — vedi §21 per perché questa distinzione conta."""
        by_id = {c.event_id: c for c in self.result.candidates}
        verdicts = {}
        for spec in self.specs:
            contract = next(c for c in self.result.promoted
                             if c.event_candidate_id == spec.candidate.event_id)
            cand = by_id[contract.event_candidate_id]
            resp = RuleDiscovery(eval_df, contract, cand).run()
            verdicts[spec.name] = resp.verdict
        return verdicts

Questo è genuinamente eseguibile contro il fixture ADA:

kpi = pd.read_parquet("tests/fixtures/ADA_1D_TRAIN.parquet")
session = DiscoverySession.run(kpi, ticker="ADAUSDC", timeframe="1D")
print(f"{len(session.specs)} regole pubblicate")     # 40
print(session.check_signals(kpi))                     # regole attive sull'ultima barra del fixture

20. Un'architettura production-ready

Adattando il pattern generale a ciò che questo codebase effettivamente supporta — uno stadio di ricerca/discovery stateless, che alimenta uno strato di persistenza e monitoraggio che la libreria non fornisce:

                     ┌───────────────────────────┐
                     │  Servizio di ingestione    │   ← responsabilità applicativa:
                     │  (client exchange/vendor)  │     chiavi API, retry, rate limit
                     └─────────────┬─────────────┘
                                   │  candele OHLCV grezze
                                   ▼
                     ┌───────────────────────────┐
                     │  forgedge.kpi_builder      │   build_features / candle_features /
                     │  (feature engineering)     │   lag_features  →  KPI Table
                     └─────────────┬─────────────┘
                                   │
                                   ▼
                     ┌───────────────────────────┐
                     │  forgedge.summary_report   │   gate qualità dati opt-in
                     │  (qualità dati)            │   (l'app decide: bloccare o avvisare)
                     └─────────────┬─────────────┘
                                   │  KPI Table validata
                                   ▼
              ┌────────────────────────────────────────────┐
              │        forgedge.forge()  (discovery)        │   job schedulato — NON sul path
              │  M0 Market Context → M1 Event Discovery →   │   di richiesta; una run per ticker
              │  M2 Alpha Discovery → M3 Rule Discovery →   │   per ciclo di ri-discovery
              │  M4 Rule Registry (forge_multi per molti)   │   (settimanale/mensile, non per-richiesta)
              └─────────────────────┬────────────────────────┘
                                   │  ForgeResult (candidates, contracts,
                                   │  rule_responses, calibration, ledger)
                                   ▼
                     ┌───────────────────────────┐
                     │ Persistenza applicativa    │   ← IL TUO database. forgedge non ne ha.
                     │ (catalogo regole, versioni)│     Salva dict RuleSpec/AlphaContract,
                     └─────────────┬─────────────┘     pickle .persist() dei candidati se serve.
                                   │
                     ┌─────────────┴─────────────┐
                     ▼                           ▼
        ┌─────────────────────┐      ┌─────────────────────────┐
        │  Worker di            │      │  UI di review/reporting  │
        │  monitoraggio          │      │  rule_performance_report │
        │  (schedulato, esamina  │      │  → HTML, servito o       │
        │  candele fresche,      │      │    inviato a un umano    │
        │  EventCandidate        │      └─────────────────────────┘
        │  .apply() +            │
        │  replay RuleDiscovery) │
        └──────────┬────────────┘
                    │  eventi "segnale attivo" / verdetto-cambiato
                    ▼
        ┌─────────────────────┐
        │ Sistema di alerting/  │   ← interamente esterno a forgedge, per
        │ esecuzione (review    │     scelta esplicita di design (§2, §19)
        │ umana, o un sistema    │
        │ di order management   │
        │ separato)              │
        └─────────────────────┘

Flusso end-to-end, e cosa è libreria vs. applicazione a ogni passo

  1. Ingestione (applicazione) → candele grezze.
  2. Feature engineering (forgedge.kpi_builder — libreria) → KPI Table.
  3. Gate di qualità dati (forgedge.summary_report — funzione di libreria, ma la decisione di bloccare su has_critical è logica applicativa; la libreria non blocca mai da sé).
  4. Discovery (forge()/forge_multi() — libreria) — questo è lo stadio costoso, con un'apparenza di stato ma in realtà puro. Dovrebbe girare come un job schedulato in background, non inline in un path di richiesta: §17 ha stabilito che non c'è I/O asincrono né parallelismo interno alla libreria, quindi una chiamata forge() blocca il thread chiamante per tutto il tempo che i backtest walk-forward per-contratto di Rule Discovery richiedono.
  5. Persistenza (applicazione) — i contratti promossi, le risposte di regola, e il report ledger/calibrazione di un ForgeResult sono il tuo record durevole. AlphaContract.to_contract_dict() e RuleDiscoveryResponse.to_dict() danno dict pronti per JSON da salvare; EventCandidate.persist(path) dà un round-trip pickle completo se serve ricostruire l'oggetto live in seguito (il suo metodo .apply()) piuttosto che solo i suoi numeri.
  6. Monitoraggio (applicazione, costruita su primitive di libreria) — un processo schedulato separato che, su ogni nuovo batch di candele, chiama EventCandidate.apply() per il flag "si sta attivando proprio ora" e RuleDiscovery(...).run() (non AlphaDiscovery — §21) per "il verdetto regge ancora". rule_performance_report() è l'unico artefatto built-in della libreria per una versione human-readable di questo.
  7. Alerting/esecuzione (interamente applicazione/esterno) — fuori dallo scope della libreria per scelta esplicita di design.

Osservabilità, versioning e gestione delle risorse — supporto libreria vs. responsabilità applicativa

Aspetto Supporto libreria Responsabilità applicativa
Logging forge() logga ogni stadio della pipeline a livello INFO via il modulo logging standard indipendentemente dal flag progress — logging.basicConfig(level=logging.INFO) lo espone Instradare quei log al tuo aggregatore; correlare i log di una run con il suo ForgeResult persistito
Report di avanzamento progress=True (default) stampa un tracker di stadio e una progress bar tqdm (o un fallback senza dipendenze) su stderr Esporre l'avanzamento in una UI, se presente
Errori Eccezioni built-in semplici, nessuna gerarchia custom (§11) Catturarle al confine del tuo job di discovery; decidere la policy retry vs. fail
Metriche Nessuna built-in — HypothesisLedger/CalibrationReport sono dati strutturati che puoi trasformare tu stesso in metriche Esportare ledger.m2_surface, calibration.tippett_p, la distribuzione dei conteggi per verdetto, ecc. al tuo sistema di metriche
Timeout Nessuno — una chiamata forge() gira fino al completamento o solleva un'eccezione Avvolgere la chiamata nel tuo timeout/cancellazione se uno scheduler di job lo richiede
Retry Nessuno — la libreria è deterministica dato lo stesso input, quindi un retry ingenuo è sicuro ma inutile a meno che l'input stesso non fosse il problema La logica di retry appartiene allo strato di ingestione dati, dove i fallimenti I/O transitori avvengono realmente
Caching Nessuno — ogni chiamata forge() ricalcola tutto dalla KPI Table che passi Cachare ForgeResult intermedi (o almeno event_frame) chiave per hash dell'input, se rieseguí la discovery su dati in gran parte invariati
Segreti Nessuno — la libreria non prende credenziali di alcun tipo Le chiavi API per la tua sorgente dati vivono interamente nel tuo strato di ingestione, mai vicino a forgedge
Versioning forgedge.__version__; i golden test della libreria stessa dimostrano che il comportamento può cambiare tra versioni anche a config fissa (§14, §18) Fissa la tua versione di forgedge; riesegui i tuoi test di regressione (§18) all'upgrade, aspettandoti che alcuni valori fissati necessitino legittimamente un ri-fissaggio
Rollback Nessuno — stateless, nulla da annullare dentro la libreria Il rollback significa tornare a un catalogo di regole precedentemente persistito nel tuo database

21. Troubleshooting

Ogni voce: sintomo → causa probabile → come verificarla → correzione → come prevenirla in futuro.

"Zero (o quasi zero) candidati di Event Discovery, solo \"M1 Event Discovery — 0 candidate(s)\" nel log"

"Zero contratti promossi, o ogni contratto è direction='undetermined'"

"Ho eseguito uno script examples/*.py e ho ottenuto TypeError: GateParams.__init__() got an unexpected keyword argument 'min_act'"

"Tutti i miei verdetti sono PARTIAL-EDGE, mai EDGE pieno"

"L'output di RuleRegistry ha colonne classification/is_generic vuote"

"Una colonna feature custom non compare mai combinata con nulla"

"summary_report segnala scale_mixed su dati di cui sono sicuro"

"RuleDiscovery.__init__ solleva ValueError su un candidato/contratto non corrispondente"

"Rieseguire Alpha Discovery su nuovi dati per 'verificare se l'edge regge ancora' dà risultati peggiori o insensati"

"Ho impostato GridSpec.buy_delay_bar (o mi aspettavo che la griglia lo tarasse) e il punto operativo non è mai cambiato"

"Uno Sharpe o una Deflated Sharpe che ho calcolato chiamando validate() direttamente sembra sbagliato su dati giornalieri/a 15 minuti"

"Due run identiche di forge() su dati identici promuovono un numero leggermente diverso di contratti, o scelgono una regola diversa come migliore"


22. Best practice


23. Anti-pattern


24. FAQ

forgedge piazza ordini o si connette a un exchange? No. Non ha alcuna capacità di esecuzione, per scelta esplicita di design (§2, §3, §19). Produce specifiche di regole; un sistema di esecuzione che costruisci separatamente le implementa.

Perché la mia run di forge() ha promosso molti contratti ma è finita con pochissimi (o zero) verdetti EDGE pieni? È molto probabilmente il controllo rotation null di default che fa esattamente ciò per cui è pensato (§14, §21) — controlla rejection_reasons per "search-level rotation null not cleared" prima di assumere che qualcosa sia sbagliato.

Posso usare forgedge su dati a 1 minuto o tick-level? Va divisa per tipo di dato, perché le due metà della domanda hanno risposte diverse. Le barre a 1 minuto restano OHLCV aggregate, calibrate esattamente come 15m/5m (_TFClass raggruppa tutto ciò che è sotto 1 ora in un'unica classe calibrata, internamente chiamata "hft", con una propria griglia di orizzonti e una propria tolleranza di dispersione — più stretta); nulla nel codice isola 1 minuto come non supportato. Ciò che è genuinamente non testato è che nessun esempio funzionante, analisi di calibrazione, o lo studio di robustezza a bassa frequenza della libreria stessa scende sotto le barre orarie-giornaliere, quindi questo manuale non può verificare il comportamento lì da evidenza diretta. I dati tick (nessuna aggregazione in barre) sono il caso più difficile descritto nella sezione "non usare per" del §3: finestre in-sample molto brevi introducono gli stessi problemi di potere statistico che §16 e §21 descrivono per i dati giornalieri, probabilmente in modo più acuto, e nulla nel repository testa quel regime.

Perché la horizon_grid di default della classe AlphaConfig sembra sbagliata per i miei dati giornalieri? Perché è calibrata sull'orario per progetto, e forge() (non AlphaConfig stessa) sostituisce automaticamente una griglia calibrata sul giornaliero per te quando timeframe è un giorno o più lento (§7, §9, §14). Costruire AlphaDiscovery direttamente bypassa quella sostituzione.

L'output di forge() è deterministico? Dato lo stesso DataFrame di input e la stessa configurazione, sì — non c'è casualità nella logica propria di Event/Alpha/Rule Discovery. RotationCalibrator (non il FastRotationNull di default, che è esatto/esaustivo) usa un RNG seedato (RotationConfig.seed, default 20260624) per le sue estrazioni campionate, quindi anch'esso è riproducibile dato lo stesso seed.

forgedge persiste qualcosa tra le run? No — esplicitamente e per scelta di design (gli anti-goal di §14, §19). Ogni chiamata forge() è una funzione pura del suo input; la persistenza è interamente responsabilità della tua applicazione.

Qual è la differenza tra un EventCandidate, un AlphaContract, e una RuleDiscoveryResponse? Sono gli output rispettivamente dei Moduli 1, 2 e 3 (§4, §8) — una condizione booleana senza ancora significato economico; la stessa condizione più un target economico derivato e un voto statistico; e infine un verdetto backtestato e validato walk-forward. Una RuleSpec (§9, §12) è un quarto oggetto, più leggero, che raggruppa ciò che serve per riprodurre in seguito una regola tradabile.

Perché il min_tpm di default di GateParams è così basso (0.5)? È deliberatamente permissivo così che il Consistency Gate non scarti eventi genuinamente rari-ma-reali prima ancora che raggiungano i controlli statistici molto più potenti di Alpha Discovery (§10, §16). Se scopri che Event Discovery produce troppi candidati di bassa qualità, alzare min_tpm è la leva prevista (il trade-off frequenza-vs-selettività di §16) — ma capisci il trade-off prima di farlo.


25. Glossario


26. Reference API (consultazione rapida)

Questo è un indice compatto, non un sostituto della trattazione più completa di §9-10. Ogni voce nomina il modulo in cui vive.

Simbolo Modulo Scopo in una riga
forge() forgedge esegue l'intera pipeline end to end
forge_multi() forgedge forge() per ticker + un registry cross-ticker unificato
ForgeResult forgedge il valore di ritorno di forge() — ogni artefatto intermedio
forge_preset() / preset_info() / PRESETS forgedge triple pretarate (DiscoveryConfig, AlphaConfig, RuleDiscoveryConfig)
MarketContext / MarketContextConfig / EMAProxyConfig forgedge Modulo 0 — classificazione del regime
EventDiscovery / DiscoveryConfig / EventCandidate / CustomEvent forgedge Modulo 1 — estrazione eventi
AlphaDiscovery / AlphaConfig / AlphaContract / PromotionThresholds forgedge Modulo 2 — derivazione del target e misura del potere predittivo
RuleDiscovery / RuleDiscoveryConfig / RuleDiscoveryResponse / BacktestParams / SelectionCriteria forgedge Modulo 3 — backtest realistico e verdetto walk-forward
RuleRegistry / RegistryConfig / RuleSubmission / RuleDocument forgedge Modulo 4 — dedup, cross-ticker, catalogo
build_features() / candle_features() / lag_features() / pattern_features() forgedge da candele grezze a KPI Table
summary_report() / DataQualityReport / Finding forgedge diagnostica qualità dati opt-in
TimeBudget forgedge split IS/OOS purgato/embargato condiviso
HypothesisLedger forgedge contabilità della superficie di ricerca
FastRotationNull / RotationCalibrator / RotationConfig / CalibrationReport forgedge calibrazione per multiple-testing a livello di ricerca
TargetOptimizer / TargetConfig forgedge workflow alternativo standalone target-first
RuleSpec / rule_performance_report() forgedge riproduce regole pubblicate su nuove candele, report HTML
text_report() / html_report() / rule_summary_report() forgedge (e forgedge.rule_discovery) report human/HTML da una RuleDiscoveryResponse; rule_summary_report(..., save=True) scrive su disco
run_backtest() forgedge.rule_discovery il motore di backtest a singola configurazione sottostante

Per la lista completa dei campi e default di ogni dataclass, vedi §10 (quelli che più probabilmente andrai a tarare) o direttamente il sorgente — ogni default citato in questo manuale è stato verificato contro il pacchetto installato al momento della scrittura.


Questo manuale è stato scritto leggendo il codice sorgente di forgedge, la sua suite di test, la sua stessa documentazione e le note di analisi di design, ed eseguendo codice reale contro la versione della libreria installata da questo repository. Dove un'affermazione riflette il ragionamento dichiarato dagli autori stessi piuttosto che la lettura del codice fatta da questo manuale, è citata e attribuita come tale in tutto il testo. L'edizione inglese gemella, docs/manual-en.md, copre contenuti identici.