← FORGE

The forgedge Manual

A complete, practical guide to the FORGE (Feature-Oriented Rule Generation Engine) Python library.

Version covered: forgedge==0.3.0. Every code example in this manual was executed against the actual library in this repository; every number quoted from a run is a real, verified value, not an invented illustration. Where the manual states something the authors themselves documented (as opposed to something inferred from reading the code), it says so explicitly.


Table of contents

  1. Introduction
  2. Why FORGE Exists
  3. When to Use It — and When Not To
  4. Core Concepts
  5. Installation
  6. Your First Working Example
  7. Quick Start: A Full Pipeline Run
  8. Anatomy of the Workflow
  9. Main API and Components
  10. Configuration
  11. Error Handling
  12. Progressive Use Cases
  13. Working with the Data in This Repository
  14. Design Choices
  15. Opt-in Behaviors
  16. Trade-offs
  17. Performance and Scalability
  18. Testing
  19. Integrating forgedge into a Real Application
  20. A Production-Ready Architecture
  21. Troubleshooting
  22. Best Practices
  23. Anti-patterns
  24. FAQ
  25. Glossary
  26. API Reference (Quick Lookup)

1. Introduction

forgedge is a Python library that takes a table of historical price data plus technical indicators (a KPI Table) and systematically discovers boolean trading rules — expressions like rsi_14 < 31.2 AND spread_ema_9_25 < -0.0118 — that have measurable, out-of-sample-confirmed predictive power over future price movement.

It does this through a five-stage pipeline, each stage answering exactly one question and handing a formal, inspectable object to the next stage:

KPI Table  →  [Market Context]  →  [Event Discovery]  →  [Alpha Discovery]  →  [Rule Discovery]  →  [Rule Registry]
 (input)         regime tag         boolean events        economic target       EDGE / NON-EDGE      catalog + report

The output of a forgedge session is not a prediction and not a trained model. It is a set of formal rule specifications — a boolean condition, a direction (long/short), a holding period, a take-profit percentage, and a verdict backed by walk-forward out-of-sample statistics. What you do with that specification (build a signal generator, wire it into an execution engine, review it manually) is entirely up to you; forgedge does not place orders, does not talk to an exchange, and holds no position state.

This manual assumes you are a competent Python developer who has never seen this library before. It does not assume you know quantitative finance jargon in advance — every term (KPI Table, event, alpha, walk-forward, ConsistencyGate, rotation null...) is introduced before it is used. By the end, you should be able to install the library, run a full discovery session on real data, understand every major configuration knob and what it costs you to change, and have a concrete plan for wiring forgedge into a larger application.


2. Why FORGE Exists

The library's own documentation (README.md) states the problem directly:

"Systematic edge research suffers from three recurring problems: look-ahead bias — event thresholds calibrated while observing returns already 'know' the future before discovery; in-sample optimisation — thresholds and horizons tuned on the same window used for evaluation produce circular backtests; missing operational separation — statistical evidence of predictive power is not the same as profitability under real fees and order mechanics."

The architecture is the answer to those three problems, not a side effect of it. The deep architectural guide shipped in the repository (src/forgedge/docs/README.md, Italian) states this as an explicit, non-negotiable constraint rather than a coding convention:

(translated) "FORGE maintains a strict separation between three operational domains. Every boundary is an architectural constraint, not a convention." The three domains are: temporal structure (Event Discovery observes only the timing pattern of indicators), statistical predictiveness (Alpha Discovery is the first stage that reads a forward return), and operability (Rule Discovery and Rule Registry are the only stages that know about fees, fills, and drawdown).

Concretely, this means:

The name and metaphor

The repository's architecture guide is explicit that the name is a deliberate metaphor, not an acronym pun: (translated) "Like a metallurgical forge turns raw ore into a worked tool through successive stages, FORGE turns a table of technical indicators into operational boolean rules through four sequential modules — without ever making assumptions about the execution system that will use them." (src/forgedge/docs/README.md)


3. When to Use It — and When Not To

Use forgedge when...

Do NOT use forgedge for...


4. Core Concepts

Before touching any API, build this mental model. forgedge is organized around three formal concepts, each the deliverable of one stage of the pipeline, plus one input concept and one supporting concept:

Concept Produced by Answers
KPI Table you (or forgedge.kpi_builder) "What data am I feeding the pipeline?"
Regime Module 0 — Market Context "What kind of market condition is bar t in?"
Event (EventCandidate) Module 1 — Event Discovery "Is this indicator condition stable and repeatable over time?"
Alpha (AlphaContract) Module 2 — Alpha Discovery "Given the event fired, what happens statistically in the following bars?"
Rule (RuleDiscoveryResponse) Module 3 — Rule Discovery "Is this alpha actually profitable under realistic order mechanics?"

KPI Table

A pandas.DataFrame with:

forgedge classifies every non-timestamp column automatically (continuous numeric, discrete/binary, categorical) and decides how to transform it — you don't tell it which columns are "features to use."

Event

An event is a boolean condition on historical bars — e.g. rsi_14 < 31.2 or the AND-composition rsi_14 < 31.2 AND spread_ema_9_25 < -0.0118 — discovered purely from the temporal structure of one or more indicator columns, without ever looking at a forward return. Its thresholds are distributional (percentiles of the indicator's own history on this specific asset) rather than hardcoded constants, which is what makes the same discovery procedure transferable across assets even though the resulting numeric threshold differs per asset. An event's thresholds are immutable the moment Event Discovery has fixed them.

Alpha

An alpha is the empirical answer to: given that this event just fired, what happens, on average, over the next h bars? Alpha Discovery derives — never assumes — three things per event: the best holding period h*, a direction (long or short), and a take-profit level sell_pct. It measures predictive power (Information Coefficient, lift over base rate, Cohen's d) and confirms it on an out-of-sample tail. The only thing that can hard-reject an alpha is an undetermined direction — no horizon in the scanned grid produces a finite, sign-determined advantage. Every other statistical weakness (low IC, low lift, failed OOS confirmation) becomes a non-blocking diagnostic that lowers the contract's letter grade (A–D) instead of rejecting it outright.

Rule

A rule is the operational verdict on an alpha contract, produced by simulating a realistic backtest — limit-order entry, take-profit exit, a horizon-based stop, per-side fees — and then validating the selected parameter set on a rolling walk-forward out-of-sample split. The verdict is one of four strings: "EDGE", "PARTIAL-EDGE", "NON-EDGE", or "INSUFFICIENT-DATA" (§9, §15). Rule Discovery is explicitly the pipeline's only economic judge — an alpha with a perfect statistical profile can still come out NON-EDGE.

Flow, inputs, and invariants at a glance

   KPI Table (you provide, or build with kpi_builder)
        │
        ▼
   Module 0 — classify each bar's market regime
        │  output: + 'regime' (5-level ordered categorical), + 'regime_stable'
        ▼
   Module 1 — mine boolean events from indicator history ONLY
        │  output: list[EventCandidate]   (thresholds now frozen)
        ▼
   Module 2 — derive an economic target per event, measure predictive power
        │  output: list[AlphaContract]    (h*, direction, sell_pct — all derived)
        ▼
   Module 3 — realistic backtest + walk-forward OOS validation
        │  output: EDGE / PARTIAL-EDGE / NON-EDGE / INSUFFICIENT-DATA
        ▼
   Module 4 — dedup, cross-ticker generalisation test, catalog + HTML report

Data only ever flows forward. No later module reaches back into an earlier module's internals, and — this is the invariant worth internalizing before writing any code against this library — no module can access information that, chronologically, its predecessor could not have had. That single rule is what the whole architecture exists to enforce.


5. Installation

forgedge requires Python ≥ 3.9 and depends only on pandas>=1.5 and numpy>=1.23 (from pyproject.toml). It has no optional dependency needed for the core pipeline.

pip install forgedge

For running the test suite or contributing, install the dev extra:

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

If you're working from a clone of this repository (as this manual's examples were verified):

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

A few things forgedge does not need that you might expect: no scipy, no statsmodels, no database, no configuration file, no environment variables, no network access, no GPU. It's a stateless, pure-computation library — everything it needs is what you hand it in memory.

If you plan to read the parquet fixture used throughout this manual (tests/fixtures/ADA_1D_TRAIN.parquet), you also need a parquet engine — forgedge itself doesn't require one, but pandas.read_parquet does:

pip install pyarrow

Verifying the install:

import forgedge
print(forgedge.__version__)   # "0.3.0" at the time of writing

6. Your First Working Example

This is the smallest possible piece of code that demonstrates the library's central idea: discovering a boolean event from an indicator's own history, with no forward return involved at all. It uses Module 1 (Event Discovery) in isolation, on a tiny synthetic table, so you can see exactly what an EventCandidate looks like before anything about profitability enters the picture.

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

# A minimal KPI Table: 500 hourly bars with one RSI-like oscillator.
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)} event candidates passed the Consistency Gate")

c = candidates[0]
print("expression:", c.expression)
print("activations:", c.activation_stats.n_activations)
print("mean trades/month:", round(c.activation_stats.mean_tpm, 3))

Verified output (this manual's author ran this exact code against this exact repository — your own numbers may differ slightly with a different numpy version, since the RNG stream can shift across versions, but the shape is representative):

309 event candidates passed the Consistency Gate
expression: close crosses_below 95.45
activations: 10
mean trades/month: 10.0

candidates[0] happens to be a "crossing" event built directly on the raw close column, not, as you might guess, one built on close_rsi_14. Checking ed.get_classifications() on this exact run reveals why, and it's a genuinely useful surprise:

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

That's the opposite of the intuitive guess (surely a bounded oscillator is "scale-free" and a raw price series isn't?). It's a real, verified result, and it's worth using to correct that intuition rather than papering over it: the scale-free test (TypeClassifier._is_scale_free, event_discovery/classifier.py) does not look at whether a series happens to be bounded in [0,100]. It splits the series into blocks and checks whether each block's value range overlaps enough with the others' (support_overlap_threshold, default 0.5) — i.e. "does this series occupy roughly the same range throughout the sample, or does it drift to new levels over time?" This toy example's synthetic close_rsi_14 was built as 50 + rng.normal(0,15,n).cumsum()*0.05 — a slow cumulative wobble that drifts across its clipped range in a blocky, non-stationary way — so it failed that test, while the mildly-drifting geometric random walk in close happened to pass it on this particular seed. The lesson generalizes beyond this toy example: scale-free classification is a genuine statistical test on your actual data, not a lookup based on the column's name or its nominal bounds — a real RSI computed correctly would very likely classify differently, but you shouldn't assume any column's classification without checking ed.get_classifications().

What just happened, internally

  1. EventDiscovery(kpi) classified both columns as CONTINUOUS, then ran the scale-free test on each independently — see the box above for what that test actually measures. The scale-free flag governs whether a column is eligible to build ratio/spread features against other same-family columns (Step 2 below); it does not gate whether a column can have its own direct threshold/crossing events, which is why close — despite not being what you'd casually call "scale-free" — still produced the close crosses_below 95.45 candidate that topped this run's list.
  2. It generated transformed versions of each column: rolling percentile rank, rolling z-score, and simple deltas, over several window lengths.
  3. For each (base or transformed) series, it tried a catalog of distributional thresholds (e.g. the 10th percentile of that series' own history) and theoretical thresholds (fixed z-score levels like −2.0), each producing a candidate boolean expression — a "crossing" event on a raw column's own value (as with close here), or something shaped like close_rsi_14 < P10 [P10=...] for a transformed series (present among this run's 309 candidates — filter with [c for c in candidates if "rsi" in c.expression] to find one).
  4. Every candidate was passed through the Consistency Gate: does it fire often enough (min_tpm), consistently enough across months (not bursty — dispersion_margin in the default "episode" counting mode, max_dispersion in "bar" mode; the two fields don't overlap, #205), and with enough total observations to be statistically meaningful? Candidates that fail are silently discarded — they never become EventCandidate objects.
  5. At no point did any of this look at close's future value. The gate only ever inspects when the event fired, never what happened afterward.

Implicit configuration you should notice

You called EventDiscovery(kpi) with no config= argument. That means DiscoveryConfig() defaults were used silently:

None of these choices involved the forward return of close — that concept doesn't exist yet at this stage of the pipeline.


7. Quick Start: A Full Pipeline Run

Section 6 showed one module in isolation. This section runs the entire pipeline with the single orchestrator function, forge(), on real data included in this repository: tests/fixtures/ADA_1D_TRAIN.parquet — 882 daily bars of ADA (Cardano) OHLCV plus 22 precomputed technical-indicator columns, spanning 2024-01-01 to 2026-05-31. This is the same fixture the library's own golden regression tests pin their expected values against.

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("M1 candidates:", len(result.candidates))
print("M2 promoted:  ", len(result.promoted))
print("M3 responses: ", len(result.rule_responses))
print("tradeable (edges()):", len(result.edges()))

Verified output (this manual's author ran this exact code against this exact repository):

(882, 26)
['open_dt', 'high', 'low', 'close', 'open', 'close_ret_03']
M1 candidates: 5356
M2 promoted:   392
M3 responses:  392
tradeable (edges()): 40

(Re-verified after #217: _early_elimination's pre-screen on the walk-forward's first train window (§9's "An unreachable floor is a window, not a verdict") used to re-derive its trade floor with a formula sized for a full selection span, not that one short window — wrongly discarding some contracts before they ever reached a full walk-forward. M1/M2 are unaffected (5356/392, unchanged from #205); on M3, tradeable (edges()) moved 32 → 40 and the verdict Counter below moved with it — every one of those 8 additional contracts is now correctly evaluated rather than short-circuited on a floor its own first window was never sized to clear.)

Interpreting the 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 full EDGE verdicts is not a bug and not a sign the library "isn't working" — it is the default rotation-null gate (§14–15) doing exactly what it's designed to do. Look at the single best PARTIAL-EDGE candidate on this data:

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)

Verified output:

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

This rule has an outstanding in-sample profit factor (176.7 — every in-sample trade but a handful was a winner), a walk-forward that is positive in 75% of test windows, and an OOS profit factor of 5.3 — and it is still capped at PARTIAL-EDGE. The rejection_reasons tell you exactly why: it's only active in 52% of the months in its window (below the 80% coverage bar), and — the more important reason — its search-level rotation-null p-value is 1.0, meaning FORGE's own randomized-rotation null test (§14) found that a purely rotated, outcome-decoupled version of the search does just as well or better. The entry_optimization line shows the default entry_mode="auto" (§9, §15) at work: it evaluated this rule at a market entry first, then tried a limit entry and adopted it because the limit point held up out-of-sample. This is the library being honest about the size of its own search space, not a false negative.

What forge() did, that you didn't ask it to do explicitly

This is important, and it's the first thing that surprises new users. forge(kpi, ticker="ADAUSDC", timeframe="1D") with no further config silently did all of the following:

  1. Resolved the whole configuration bundle through the central parameter resolver and checked it for internal coherence via config_report() — logged as INFO, and stored on ForgeResult.coherence — before running anything. With strict=True (the default) a FAIL-level finding raises ValueError immediately instead of letting the run produce a wall of uninterpretable rejections (§9, §15).
  2. Ran Module 0 (Market Context) since you didn't pass run_market_context=False and the table had no regime column already.
  3. Substituted a daily-calibrated horizon_grid for Alpha Discovery on every code path, not only the "no explicit AlphaConfig" one (issue #196 closed that gap) — AlphaConfig.horizon_grid itself now defaults to UNSET and is resolved from the session's declared timeframe, so passing an AlphaConfig to change something unrelated no longer silently keeps the hourly-calibrated grid.
  4. Enriched each event's horizon grid with additional points around 0.5×/1×/2× that event's own dominant indicator window (AlphaConfig.horizon_enrichment, on by default) — a union with the base grid, never a restriction.
  5. Ran the fast search-level rotation null (fast_null=True by default) and annotated rotation_p/rotation_threshold on every promoted contract — this is exactly what produced the PARTIAL-EDGE cap you just saw.
  6. Built a shared, purged TimeBudget for Event/Alpha Discovery (§15) even though you passed no time_budget= argument.
  7. Recorded a HypothesisLedger on result.ledger, tallying how large the session's search surface actually was.
  8. Ran Rule Discovery on all 392 promoted contracts with selection_mode="walk_forward" (the default) — meaning the published operating parameters came from inside walk-forward train windows only, never from a peek at the final test window.
  9. Evaluated every rule with entry_mode="auto" (the default since issue #185, replacing "limit") — a market-entry verdict is authoritative, and a limit entry is published only when it clears three out-of-sample conditions (§9, §15).
  10. Measured episode count and trade concurrency for every backtest (resp.in_sample_summary.n_episodes / .mean_concurrent_positions / .max_concurrent_positions, issue #168) — run_backtest opens a trade on every active bar with no flat-state check, so positions can and do overlap; these fields are what let you tell a nominal trade count from the effective, capital-relevant one (§9).
  11. Skipped Module 4 (Rule Registry) — not because you disabled it, but because RuleRegistry.from_forge_results needs multiple tickers to say anything about cross-ticker generalisation; with a single-ticker forge() call it still runs and produces a registry, but every rule is classified ISOLATED (§9).

None of items 3–10 are things you configured. They are all defaults chosen by the library's authors specifically so that the "quick start" path and the "hand-tuned" path don't silently diverge in honesty. §14–15 explain each of these in depth, including which ones you can turn off and what it costs you to do so.


8. Anatomy of the Workflow

This section walks through the five modules end to end, in enough mechanical detail that you can predict what changing an input will do — not just what each module's public method is called.

Module 0 — Market Context

Input: the KPI Table. Output: the same table plus regime (an ordered categorical: STRONG_BEAR < BEAR < NEUTRAL < BULL < STRONG_BULL) and regime_stable (bool).

Internally, the default classifier (EMAProxyClassifier) computes ratio = ema_short / ema_long and buckets it against four thresholds (fixed-mode defaults [0.975, 0.990, 1.010, 1.025]). The interesting part is where the EMA windows come from: by default (auto_window=True), the module estimates the local half-life of an Ornstein-Uhlenbeck mean-reverting process from the price series itself (via a Hurst/OU regression: dP_t = const + kappa·P_{t-1} + ε, half_life = -log(2)/log(1+kappa)), then derives long_period = round(half_life) and short_period = round(half_life × 0.435). Only if that estimate fails to converge does it fall back to the fixed defaults of 9/25. mc.window_resolution["source"] tells you which happened: "hurst_ou" (converged), "fallback", or "configured" (you set auto_window=False and gave explicit windows).

regime_stable is True only once the current regime has held for at least stable_window (default 12) consecutive bars — the first 11 bars of a fresh regime transition are regime_stable=False.

Important: Event Discovery (Module 1) does not read regime at all — it's present in the table but ignored during Steps 0–5. Only Alpha Discovery's regime-sensitivity analysis (Step 5 of its own pipeline) reads it.

Module 1 — Event Discovery

This is a five-step internal pipeline (EventDiscovery.run()), and it's worth knowing the steps by name because error messages and configuration knobs reference them:

  1. Column classification (TypeClassifier) — every non-timestamp column is labeled CONTINUOUS, BINARY (exactly 2 distinct values), or CATEGORICAL (non-numeric, or numeric with ≤ max_categorical_classes distinct values, default 20). Continuous columns get a further "scale-free" flag: is this series bounded/intrinsic (RSI, a percentage) or price-level-dependent (a raw EMA)? The heuristic is deliberately conservative — it prefers to miss a scale-free series (false negative) over wrongly calling a price-dependent one scale-free (false positive), because the latter would generate thresholds contaminated by absolute price level.
  2. Feature generation (FeatureGenerator) — derives new columns of arity 2–3: ratios (a/b), percentage spreads ((a-b)/b), normalized differences ((a-b)/σ(a-b)), Bollinger %B, and position-in-range. The bulk of arity-2 combinations pair columns in the same semantic family (two EMAs, not an EMA and an RSI), but the generator also has several dedicated, narrowly-scoped arity-2 pairings beyond that general rule, each added to close a specific gap the family rule couldn't reach: - cross-column, cross-time OHLC pairs — e.g. "today's close above yesterday's low" (every ordered pair of raw OHLC bases present, against a lagged copy, at the same lags used elsewhere for delta transforms); always on. - a MACD line against its own signal line — matched by shared (base, fast, slow), not the generic same-family grouping, because a line must pair with its own signal, never an unrelated MACD configuration's; only fires if you enabled "macd" in build_features() (disabled by default, §9). - price % change against volume % change at the same lookback — a price-vs-volume divergence signal (e.g. "new price high on falling volume"); only fires if your KPI Table carries a volume-based return column, which is not part of the default kpi_builder config. - candle_features()'s six geometry columns against each other, and against close_natr_N — these bare-named columns (body, gap, …) don't match the {base}_{indicator}_{period} convention at all, so without this dedicated pairing they could only ever be used standalone; paired against the normalized ATR (natr), never the raw one, to avoid reintroducing a price-level dependency. - a price-scale indicator (SMA/EMA/WMA/HMA only) against a lagged raw OHLC base — e.g. close_sma_12[t] > low[t-3]; deliberately restricted to price-scale indicator families (a ratio against RSI/volatility/return/drawdown/Bollinger columns wouldn't be dimensionally sound) and to the ratio_ operation only. On by default, with its own lag set — DiscoveryConfig.indicator_lag_cross_lags, default (1, 3) (§10) — distinct from the general delta-transform lag set.

All of these except the first are genuinely recent additions (landed after most of this manual's Module 1 walkthrough was drafted, and corrected here after re-reading the current source directly, per this manual's own verification discipline, §21). The ones gated on a non-default kpi_builder config (MACD, volume returns, ATR/NATR) cost nothing on a default KPI Table; the indicator-vs-lagged-OHLC-base pairing is unconditional and does add measurable cost — quantified in §17. 3. Temporal transforms (TransformLayer) — every feature from steps 1–2 additionally gets rolling-percentile-rank, rolling-z-score, and simple-delta versions over several window lengths (48/96/168 bars for pctrank/zscore; 1/3/6/12 bars for delta). 4. Event generation (EventGenerator) — for every (feature, transform) pair, tries a catalog of distributional thresholds (percentiles p3…p97 of that series' own history) and — for z-scored series only — fixed theoretical thresholds (±1.0, ±1.5, ±2.0). Each threshold produces both a "persistent" event (active on every bar the condition holds) and a "crossing" event (active only on the bar where the condition first becomes true). 5. Consistency Gate (§10, §15) — every raw event candidate must pass a rate/dispersion filter before it becomes an EventCandidate. Gate-passing single events are then combined pairwise (or in triples, if max_and_components=3) with AND, and each composition is re-submitted to the same gate — only compositions that themselves pass become AND-composed candidates. This composition sub-step only runs when max_and_components>1, which since issue #254 Phase 8 is no longer the default (DiscoveryConfig() now defaults to max_and_components=1, composition off): forge()'s own default path (two_pass_composition=True) composes downstream instead, after Alpha Discovery's first grading pass — see the new step 8 in the Module 2 walkthrough below. What follows describes the (still fully supported) legacy path, active when you raise max_and_components yourself — on EventDiscovery used standalone, as throughout this manual's §6-§7 examples, or via forge(two_pass_composition=False). "The same gate" is literal, not just the same thresholds: ANDComposer's batched re-submission and the single-event path both call one shared, mode-aware gate decision, so a composed pair/triple is judged by episode rate/count/dispersion under the default event_counting="episode" exactly as a single event would be, not by a separate bar-only reimplementation (#226). That batched episode computation is itself memory-bounded regardless of dataset length (#228): under a permissive M1 gate (a low min_tpm, including the library's own default) most of a candidate batch survives the cheap pre-filter and reaches the expensive episode computation at full batch size, so ANDComposer sizes its internal batches from a fixed memory budget rather than a constant tuned for short histories — an implementation detail, not something you configure, but the reason a multi-year hourly-or-finer run doesn't risk an out-of-memory crash that a shorter one wouldn't. Under that same permissive M1 gate, which specific pairs/triples fill _MAX_PAIRS/_MAX_TRIPLES also used to be structurally skewed (#230): the pre-fix enumeration order exhausted every pair involving one single pool event before ever trying a second one, so all 2000 kept pairs could share the same component — permuting that order (a fixed seed, so composed events stay reproducible run to run) fixed it, and ANDComposer.compose() also gained an opt-in max_constituent_jaccard parameter to reject a pair whose two constituents overlap too much, at no extra cost. Generating raw candidates (step 4 above) and gating them used to be two fully separate passes — every raw candidate's own activation series stayed resident for the whole run, gate-failing ones included — which on a wide KPI Table could exceed available memory before M1 even finished (#232): measured 9.5 GB for 50874 raw candidates' series alone on a 186-column table, before the gate freed any of it. Generation and gating are now interleaved per batch instead, freeing a failing candidate's series immediately (4.2x less memory retained, measured on the same table) — DiscoveryConfig.retain_raw_events defaults to False since Phase 8 (was True) and frees a gate-failing candidate's activation series as soon as it's gated instead of keeping the pre-gate population resident for the whole run (~2x lower peak RSS end-to-end at realistic scale, on top of the 4.2x figure above); set it True explicitly on a DiscoveryConfig you hand to TargetOptimizer, which reads EventDiscovery.raw_events directly and pins this itself on its own fallback config — the standard forge() path never reads that property.

Nothing in these five steps reads a forward return. That is the single most important fact about this module.

event_distribution_report (#215). config_report() (§9, §11) validates configuration coherence without ever touching your data — so it cannot catch the case where a preset is internally coherent yet still rejects every candidate a specific asset actually produces. That used to surface only as a bare "M1 Event Discovery — 0 candidate(s)" log line, easy to mistake for a broken pipeline rather than "these thresholds and this asset's event statistics disagree." EventDiscovery.run() now always populates ed.event_distribution_report (str | None, None before .run()): a plain-text summary of the tpm/dispersion distribution actually observed across every raw candidate the Consistency Gate evaluated, against the configured thresholds. Below a 15% gate-survival rate it also suggests concrete parameters at the observed median (min_tpm, plus dispersion_margin or max_dispersion depending on event_counting). forge()'s M1 stage line carries this text whenever Event Discovery actually ran (manual event injection via manual_events= skips .run() entirely, so keeps the old bare-count line); result.event_discovery.event_distribution_report exposes it at no extra cost.

GateParams.tpm_mode="ranged" (docs/analysis/ranged_tpm_and_market_alignment_proposal.md §2, opt-in, "episode" counting only). Every gate mode above treats min_tpm as a one-sided floor — "at least this often." Setting tpm_mode="ranged" turns it into the centre of a band instead: an event firing too often is now rejected too, not just one too rare. The band is [min_tpm - tolerance, min_tpm + tolerance] (clipped at 0); tpm_tolerance left at UNSET (the default) derives the half-width from the session's own dispersion tolerance rather than a value you choose — σ = sqrt(eff_max_dispersion · min_tpm / n_total_months), tolerance = 1.959964·σ (the two-sided 95% normal quantile), reusing the same session-level eff_max_dispersion the burstiness criterion already computes (deliberately the session's, not the individual event's own episode_id — coupling it to the latter would reward an already-bursty event with more rate tolerance, backwards for a quality filter). Set tpm_tolerance explicitly instead for a literal band with no statistics involved — min_tpm=4.0, tpm_tolerance=2.0 always means [2.0, 6.0], on any dataset. An event with n_episodes == 0 is always rejected regardless of the band; the existing burstiness criterion (episode_id <= eff_max_dispersion) is unaffected — ranged mode adds a constraint on the rate's level, it doesn't relax or replace the one on its regularity. Combining tpm_mode="ranged" with event_counting="bar" raises ValueError — the mode is only defined for episode counting.

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

n_total_months = 29.37   # this manual's ADA fixture session length, §7/§9
p = GateParams(tpm_mode="ranged")   # min_tpm=0.5, dispersion_margin=1.3 (defaults)
eff = _eff_max_dispersion(n_total_months, p.dispersion_margin)
lo, hi = _tpm_band(p, eff, n_total_months)

Verified, on this manual's own ADA fixture session length: eff_max_dispersion == 1.9146, derived band at the default min_tpm=0.5 is [0.146, 0.854] episodes/month — roughly ±0.35 around the floor, on a session this short. Set GateParams(tpm_mode="ranged", min_tpm=4.0, tpm_tolerance=2.0) instead and the band is exactly [2.0, 6.0], independent of session length, as the literal-band formula promises.

Module 2 — Alpha Discovery

Given the candidate list from Module 1, and for the first time in the pipeline, this module reads the forward price path. Per event candidate:

  1. Horizon and direction derivation. For every horizon h in the (possibly enriched) grid, it computes mean_advantage[h] (the oriented average forward-return advantage of active bars vs. all bars) and a horizon-selection score |mean_advantage[h]| / sqrt(h) — a "Sharpe-like" deflation that avoids favoring short horizons just because their t-statistic denominator is smaller. h* = argmax_h score[h]. Direction is "long" if mean_advantage[h*] > 0, "short" if < 0, and "undetermined" if no horizon in the grid produces a finite, sign-determined advantage at all, if |z_h*| is below min_direction_t (default 0.5), or — under the default require_significant_direction=True — if neither statistical test in step 9 below finds the edge distinguishable from noise (False restores the older, purely argmax-driven behavior).
  2. Take-profit derivation. sell_pct = max(quantile(MFE, mfe_quantile=0.5), mfe_floor=0.005), where MFE is the maximum favorable excursion within h* bars of every active bar. This is a derived number, not a config input.
  3. Predictive-power measurement (IS): Information Coefficient (Spearman correlation between the raw feature and the forward return, computed once and cached per (feature, horizon)), win rate / lift over base rate, Cohen's d, a one-sided t-test.
  4. OOS confirmation (when train_ratio < 1.0, default is 0.7): the same derived target is replayed on the held-out tail, and passes if it has enough activations, a positive oriented advantage, and a low enough p-value. Failing this is a non-blocking diagnostic, not a rejection.
  5. Regime sensitivity — per-regime IC and win rate, with a dependency_type classification (agnostic/conditional/specific/broken/unknown).
  6. Composite scoring — the exact current formula (AlphaConfig.score_weights, default (0.20, 0.25, 0.15, 0.25, 0.15) for (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, where ic_norm = clamp01(|IC|/0.10), lift_norm = clamp01(lift/0.30), d_norm = clamp(cohens_d/0.80, -1, 1) (signed — an adverse effect drags the score down rather than clipping to zero), z_norm = clamp01(|z*|/3.0) (z* is the rotation-null-standardised excess at h*, i.e. the edge-to-noise ratio), and breadth is the regime breadth term, dropped (with the remaining weights renormalised) when no regime information is available. Two adjustments follow: a statistically_weak target — h* selected outside the BH-significant horizon set — multiplies the composite by statistically_weak_penalty (default 0.6), and a passing OOS confirmation adds oos_bonus (default 0.05). The result is clamped to [0,1] and mapped to a letter grade A (≥0.75) through D (<0.25).
  7. Contract compilation. All candidates with a determined direction become AlphaContract objects with status="HYPOTHESIS"; every other metric above only ever appends a string to diagnostics — it never blocks promotion, and rejection_reasons stays empty on a promoted contract. This is stated as a deliberate design principle: statistical weaknesses "feed the grade, they don't gate/reject — Rule Discovery is the sole economic judge" (src/forgedge/docs/README.md, translated).
  8. Grade-guided composition (issue #254, forge(two_pass_composition=True), the default since Phase 8) — steps 1-7 above are actually run twice under forge()'s default configuration. Pass 1 grades every 1D event candidate from Module 1 A-D (the letter grade from step 6's composite score). forgedge.composition.grade_guided_compose() then pairs (and, with GradePairingConfig.max_components=3, triples) graded candidates using that letter as the pairing criterion — same grade first, then an adjacent grade via a root+partner scheme (default A↔{A,B}, B↔{B,C}, C↔{C,D}) — instead of Module 1's own purely structural tpm/dispersion/transform_key similarity. The empirical case for this (AMZN 1D, docs/analysis/issue_254_two_pass_composition_plan.md): structural pairing produced 256 candidates and 5 PARTIAL-EDGE/EDGE (1.95%); grade-guided pairing on the same data produced ~1400+ candidates and 122 (~8-9%) — the letter grade, which already encodes information about the forward return, turned out to be a far more informative pairing signal than structural correlation ever was. Composed candidates get fresh event_ids and are evaluated from scratch by a second, independent Alpha Discovery pass (steps 1-7 again) — nothing is inherited from a constituent's own grade or derived target. That second pass's pooled output (composed candidates plus, by default, the original singles — GradePairingConfig.include_singles_in_pass2=True) is what every downstream module (the hypothesis ledger, the rotation null, Module 3, Module 4) actually operates on; ForgeResult.grading_candidates/.grading_contracts keep pass 1's pre-composition artefacts available for audit, and ForgeResult.composition_timing reports each stage's wall-clock cost. Pass two_pass_composition=False to skip pass 1 entirely and fall back to Module 1's own single-pass composition (step 5 above) — the Phase 5/6 validation found the two-pass design's edge-rate improvement generalised across every 1D asset tested (8/8) while being no worse on 1H, which is why it is the default rather than an opt-in.
  9. Market-alignment labeling and the AUC promotion route (docs/analysis/ranged_tpm_and_market_alignment_proposal.md §3-§4, default-on since this was implemented). Step 1's BH-FDR significance test (h_sig) looks at one horizon at a time — a real edge spread thinly across the whole grid, with no single horizon individually significant, gets missed the same way a candidate with a genuinely noisy one-horizon spike gets through. Two additions, computed for every candidate regardless of whether a direction was ever assigned, and never touching lift/lift-based grading — a market-alignment diagnostic layered on top of what step 1 already does, not a replacement for it: - Stage (a), whole-grid significance test. Integrates the trapezoidal-weighted per-bar excess rate (Δ_h / h — the cumulated Δ_h alone would spuriously look "momentum" for any persistent-but-flat edge, purely from cumulative-return arithmetic) across the entire horizon grid and tests it against the same rotation-null machinery h_sig already uses (p_auc, PromotionThresholds.auc_max_p, default 0.10). A candidate can now clear promotion via either test — AlphaContract.promotion_route records which: "z_score" (BH-FDR only), "auc" (stage (a) only — the case step 1's test alone would have left "undetermined"), "both", or None (neither, or direction=="undetermined"). For the "auc"-only route, h* is not argmax|z_h| (that profile is flat and diffuse by construction, so the argmax can land on an unrepresentative point) but an "elbow" rule: the first horizon where the marginal per-bar rate stops moving in the same direction as the whole-grid trend, walking outward from the shortest horizon. - Stage (b), direction of the profile — only computed when stage (a) is significant. The Spearman slope (rho, threshold PromotionThresholds.rho_momentum_threshold, default 0.5) of the per-bar rate along the horizon grid labels AlphaContract.nature: "momentum-aligned" (rate grows with horizon), "mean-reversion-aligned" (rate fades/reverses), "idiosyncratic" (no clear slope), or "non_significativo" (stage (a) wasn't significant — the default, and always the case on the "z_score"-only route). - horizon_at_boundary (bool, mirrored onto AlphaContract alongside nature/promotion_route) answers "is h* pinned to the edge of the tested grid for a reason that survives scrutiny, or genuinely at an interior peak?" — on "z_score"/"both" it requires two consecutive score increases approaching the grid's last horizon with a finite score (one increase alone is treated as noise); on "auc" it's the elbow rule's own boundary flag. Counterintuitively, it does not discount nature — empirically it correlates more with genuinely "momentum-aligned" candidates, not less (a true trend-follower is, by definition, a profile that hasn't stopped growing when the grid runs out) — treat the two as independent, combinable facts about a contract, never a hierarchy.

Verified, forge_preset("balanced", timeframe="1D") on the ADA fixture, full default forge() pipeline (2162 total contracts):

```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, # == the 145 "z_score"-only contracts # 'idiosyncratic': 75, 'momentum-aligned': 15} # 284+75+15 == 313+61, the auc/both population

# h and boundary rate, by route: # both n=313 median h=2 horizon_at_boundary rate=15.3% # z_score n=145 median h=2 horizon_at_boundary rate=16.6% # auc n=61 median h=5 horizon_at_boundary rate=23.0% ```

The 61 "auc"-only contracts are exactly the ones require_significant_direction=False would previously have left "undetermined" — this asset's default forge() call now derives a direction, nature, and (for 284+75+15=374 of them) a market-alignment label it did not before, with zero change to direction's derivation itself for every other contract.

Module 3 — Rule Discovery

Given a promoted AlphaContract and its originating EventCandidate, Rule Discovery simulates a realistic order-execution backtest and validates it out of sample. It does not re-optimize the event's thresholds or override the derived target — it uses derived_target.holding_period_h/sell_pct only as the center of an operational parameter grid to search.

The execution mechanics, exactly:

  1. On a signal bar, a limit order is placed at anchor × (1 − buy_drop_pct) (long) or anchor × (1 + buy_drop_pct) (short).
  2. If price touches that limit within buy_delay_bar bars, it fills; otherwise the order is cancelled (this is where fill rate comes from).
  3. After a fill, the position closes at whichever comes first: the first bar closing past the take-profit level, or the close of bar target_h after the fill (the "horizon stop").

A subtlety worth internalizing precisely: target_h counts bars after the fill bar, and the signal→fill gap is always exactly 1 bar (you cannot act on a bar's own close before it has happened) — so the total signal-to-exit span is 1 + target_h bars. target_h=0 is legal and means "exit at the fill bar's own close," not "no horizon."

The grid of (buy_drop_pct, sell_pct, target_h, buy_delay_bar) combinations is screened in-sample, the best-scoring combination becomes the operating point, and that fixed operating point is then re-validated on a rolling walk-forward split (train windows expand or roll, n_splits test windows are concatenated into one "honest OOS track record"). Statistical validation on top of this includes a Deflated Sharpe Ratio, temporal-stability check (first-half vs. second-half PF), and regime-dependency breakdown.

The verdict logic (exact gates, from SelectionCriteria defaults) is covered in §9 and §15 — the short version: NON-EDGE is any hard failure (too few trades, IS PF below 1.5, OOS PF below 1.0, expectancy not significant); EDGE requires clearing every one of a stricter set of gates including the rotation-null check; anything that clears the NON-EDGE floor but not every EDGE gate is PARTIAL-EDGE; and INSUFFICIENT-DATA (§15) is a fourth verdict that demotes a would-be EDGE/PARTIAL-EDGE when the pooled OOS evidence is statistically too thin to support the claim, regardless of how good the point estimate looks.

Module 4 — Rule Registry

Ingests every EDGE/PARTIAL-EDGE rule submission (NON-EDGE is silently skipped, not an error) into a RuleDocument. It computes two correlation matrices across all documents (Jaccard overlap of activation dates; Spearman correlation of gains on a shared, zero-padded date axis), flags — but never deletes — the weaker of any pair of near-duplicate rules (Jaccard ≥ overlap_threshold, default 0.70), and replays every rule on every other ticker in the session with its absolute thresholds re-percentiled onto that ticker's own distribution. A rule is GENERIC if it clears the cross-ticker profit-factor bar on at least generic_ratio_threshold (default exactly 2/3) of the other tickers tested; otherwise PARTIAL, SPECIFIC, or (if it was flagged a duplicate) ISOLATED. The registry is entirely stateless across sessions — the exported flat table or HTML report is the only persistence artefact it produces.


9. Main API and Components

This section covers the primary, user-facing classes and functions — not an exhaustive listing (that's §26), but enough to write real code against every module. Every signature below is quoted from the current source, not paraphrased.

The orchestrator

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 and event_discovery_config are mutually exclusive — passing both raises ValueError. ticker falls back to alpha_config.asset, then to asset, when not given explicitly.

forge_multi(frames_by_ticker: dict[str, pd.DataFrame], *, registry_config=None, progress=True, **forge_kwargs) -> tuple[dict[str, ForgeResult], RuleRegistry] runs forge() once per ticker and pools every tradeable rule into one cross-ticker registry — this is the natural way to get genuinely GENERIC-classified rules out of Module 4.

ForgeResult, field by field

forge()'s return value is a single, flat dataclass — no nesting, every intermediate artefact of the run reachable directly off result. This section is a complete field-by-field reference, each entry verified against the current source (dataclasses.fields(ForgeResult)) and, where useful, against a real run so you see actual types and shapes rather than a description of them.

Field Type Meaning
ticker str the ticker string the run was labelled with — ticker= if given, else alpha_config.asset, else asset
enriched pd.DataFrame KPI Table after Market Context (adds regime/regime_stable, or unchanged if run_market_context=False)
event_frame pd.DataFrame \| None Event Discovery's post-pipeline frame — pass this, not enriched, to anything that needs the derived ratio/spread/transform columns event expressions reference; None only if M1 itself never ran (it always does under forge() unless you inject manual_events, in which case it's still set from the manual-event evaluation)
candidates list[EventCandidate] every Module 1 output that survived the Consistency Gate
contracts list[AlphaContract] every Module 2 output, promoted and rejected alike — same length as candidates, one contract per candidate
promoted list[AlphaContract] the subset of contracts with status="HYPOTHESIS"
rule_responses list[tuple[AlphaContract, RuleDiscoveryResponse]] one (contract, response) pair per promoted contract, if M3 ran (run_rule_discovery=True, the default) — same length as promoted
registry RuleRegistry \| None Module 4 output, if it ran (run_registry=True, the default)
market_context MarketContext \| None the live Module 0 instance, for .distribution()/.window_resolution — None if run_market_context=False
event_discovery EventDiscovery \| None the live Module 1 instance — .summary(), .df, .event_distribution_report (#215, above)
alpha_discovery AlphaDiscovery \| None the live Module 2 instance — .summary(), .market_structure
calibration CalibrationReport \| None the rotation-null report (fast_null=True, the default, or an explicit rotation_calibration=) — .tippett_p, .real_stats, .null_arrays
ledger HypothesisLedger \| None search-surface bookkeeping: m1_candidates, m2_horizons, m2_promoted, m3_grid_cells, m2_return_tests — not a correction, just a record of how large the session's search was
time_budget TimeBudget \| None the effective, purged IS/OOS bar-index split the session used — n_bars, split, horizon_bars, purge_bars, embargo_bars
context PipelineContext \| None the resolved session facts every module's UNSET fields were filled in from — timeframe, schema column names, fee, statistical policy
resolution ResolutionTrace \| None every config field the resolver derived — default → resolved, with the rule that fired and the inputs it read; .describe() for a one-line summary
coherence ConfigReport \| None the coherence check the run executed with (strict=True's backing) — .findings, .has_critical, .one_line(); .trace is the same object as .resolution, not a copy

Verified, on the ADA fixture (forge(kpi, ticker="ADAUSDC", timeframe="1D", progress=False)):

ticker              = 'ADAUSDC'
enriched.shape      = (882, 28)
event_frame.shape   = (882, 174)                 # 174 — every derived feature Event Discovery generated
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()  = two WARN findings — m1_is_window_too_short (the IS span is short of
                         what gate_params.min_episodes=10 needs at min_tpm=0.5 with a 95% Poisson
                         margin) and m3_stricter_than_m1 (M3's min_tpm reads stricter than M1's
                         once converted to the same unit) — informational, since forge(strict=True)
                         only ever raises on a FAIL finding, never a WARN

Two things worth noticing in that dump: context/resolution/coherence are not always populated — they're None if the run never reached the resolver (not a normal forge() path, but possible if you construct ForgeResult by hand) — and the two WARN findings on coherence are exactly the kind of "the pipeline tells you what it's silently working around" signal §14–15 describe: nothing failed, but the message names precisely which knob a longer history or a different min_tpm would need to touch.

Methods (all read-only, computed on demand from the fields above — nothing cached beyond what's already on result):

result.edges()           # list[tuple[AlphaContract, RuleDiscoveryResponse]]
                          #   pairs where response.is_edge — verdict EDGE or PARTIAL-EDGE
result.validated_rules()  # list[RuleDiscoveryResponse] carrying a non-None validated_rule
                          #   — EDGE + PARTIAL-EDGE + INSUFFICIENT-DATA (which keeps its
                          #   ValidatedRule for future re-evaluation); NON-EDGE never does
result.submissions()      # list[RuleSubmission] — exactly the EDGE/PARTIAL-EDGE responses,
                          #   pre-packaged as what RuleRegistry.from_forge_results() ingests
result.summary()          # pd.DataFrame, one row per M1 candidate (not per promoted contract)

Verified on the same run: len(result.edges()) == 40 (matches the Counter in §7 — EDGE+PARTIAL-EDGE), len(result.validated_rules()) == 48 (40 + the 8 INSUFFICIENT-DATA responses, which is the one place edges() and validated_rules() diverge — a verdict can carry a validated rule for future re-evaluation without being tradeable now), len(result.submissions()) == 40 (identical population to edges(), since Module 4 ingestion is defined on the same EDGE/PARTIAL-EDGE gate). result.summary() is (5356, 34) — one row per candidate, not per promoted contract, with columns:

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 is the one column summary() adds beyond what AlphaContract's own fields already carry — "EDGE"/"PARTIAL-EDGE"/"NON-EDGE"/"INSUFFICIENT-DATA" when M3 ran for that candidate, NaN for a candidate never promoted past M2 (M3 only sees promoted, so most of the 5356 rows are NaN here — filter summary()[summary()["promoted"]] first if you only want the ones Rule Discovery actually touched).

lookup_by_id() — narrowing a ForgeResult down to one rule

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

A module-level function (from forgedge import lookup_by_id), not a ForgeResult method — it returns a new ForgeResult (wrapped in a single-element list) with candidates/contracts/promoted/rule_responses filtered consistently down to the given event_id(s) or alpha_id(s), while every other field (enriched, ticker, registry, context, …) is carried over unchanged via dataclasses.replace. event_id and alpha_id are mutually exclusive — passing both, or neither, raises ValueError.

The point of returning a list rather than a bare ForgeResult is composability: the result is a drop-in Iterable[ForgeResult], so it feeds directly into every forgedge.playground function and into forgedge.deployment.promotion_gate()/export_rules() with no glue code — the natural way to run any of those on exactly one rule instead of a whole session:

from forgedge import lookup_by_id
from forgedge.deployment import export_rules

alpha_id = result.edges()[0][0].alpha_id     # any AlphaContract.alpha_id you already have
one_rule = lookup_by_id(result, alpha_id=[alpha_id])
export_rules(one_rule, "exported_rules/", promotable_only=False)

Verified, on the same ADA run as above, isolating its first edges() entry (alpha_id embeds the run date, so the exact string differs run to run — "ALPHA-ADAUSDC-1D-260908-337" the day this was run):

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   # unrelated fields untouched

An event_id/alpha_id that matches nothing yields empty lists on every filtered field — never an error — so lookup_by_id(result, event_id=["typo"]) is a safe way to check "does this id exist at all" without a try/except.

Presets

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

preset is one of PRESETS = ["sniper", "balanced", "sweep", "burst"]. This returns a triple — one calibrated config object per module (M1/M2/M3) — pre-tuned for a search profile, and scaled for the timeframe you pass. preset_info(preset=None) prints the resolved numeric parameters for one or all presets.

Module 0 — MarketContext

MarketContext(kpi_table: pd.DataFrame, config: MarketContextConfig | None = None)
mc.run() -> pd.DataFrame
mc.distribution()          # bar-share per regime, for diagnostics
mc.window_resolution       # {"source": "hurst_ou" | "fallback" | "configured", ...}

Module 1 — EventDiscovery, EventCandidate, CustomEvent

EventDiscovery(kpi_table: pd.DataFrame, config: DiscoveryConfig | None = None,
                time_budget: TimeBudget | None = None)
ed.run() -> list[EventCandidate]
ed.df           # post-pipeline frame — pass this to AlphaDiscovery, not kpi_table
ed.summary()    # pd.DataFrame, one row per candidate
ed.event_distribution_report   # str | None — populated by run(), see below (#215)

event_distribution_report. config_report() (below) validates configuration coherence without ever touching your data, so it cannot catch the case where a preset is internally coherent yet still rejects every candidate a specific asset actually produces — that used to surface only as a bare "M1 Event Discovery — 0 candidate(s)" log line, indistinguishable from a broken pipeline. run() now always populates ed.event_distribution_report (None before it): a plain-text summary of the tpm/dispersion distribution actually observed across every raw candidate the Consistency Gate evaluated, against the configured thresholds — below a 15% gate-survival rate it also suggests concrete parameters at the observed median. forge()'s M1 stage log line carries this text whenever Event Discovery actually ran (manual event injection via manual_events= skips .run() and keeps the old bare-count line); result.event_discovery.event_distribution_report exposes it on a ForgeResult at no extra cost.

An EventCandidate's important attributes: event_id, expression (the boolean condition, as a string), event_formula (a human-readable rendering), sql_expression (a DuckDB-compatible SQL translation of the same condition, useful if you want to evaluate the event outside Python), components, activation_stats (n_activations, n_active_months, zero_months, max_monthly_share, mean_tpm), consistency_gate (a GateResult), validation (a ValidationResult, only if walk-forward was configured). Its method .apply(df) -> pd.Series[bool] deterministically re-evaluates the stored thresholds on any new frame — no recalibration, no look-ahead — and .persist(path) gives a full pickle round-trip (the only method the docs describe as fully invertible; .to_dict()'s JSON form is not).

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

For manually injecting your own hypothesis (e.g. CustomEvent("close_rsi_14 < 30")) instead of running automatic discovery. Formulas are evaluated with pandas.DataFrame.eval(). A CustomEvent still crosses the Consistency Gate, but a failure only logs a warning — it is never dropped. AND composition is not performed on manually injected events. Use via forge(..., manual_events=[...]) — mutually exclusive with event_discovery_config.

Module 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, sorted by composite score

Critical: pass ed.df (Event Discovery's post-pipeline frame), not the original KPI Table — it already carries the derived ratio/spread/transform columns the event expressions reference.

An AlphaContract's important attributes: alpha_id, status ("HYPOTHESIS"/"REJECTED"), event_candidate_id (links back to the originating EventCandidate), 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 (blocking causes only — empty on a promoted contract), diagnostics (non-blocking observations that feed the grade; routinely non-empty on a promoted contract), rotation_p/rotation_threshold (set by the search-level rotation null).

Module 2 alternative — TargetOptimizer

A second, standalone way to reach Module 2, for the inverse question: not "what does this event predict?" but "which events predict this specific, fixed target?" You supply the target — a horizon and a minimum return — and TargetOptimizer mines Event Discovery's raw atoms and AND-compositions directly against it, using a two-proportion lift score rather than Alpha Discovery's derive-per-event pipeline.

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

cfg = TargetConfig(horizon=10, min_return=0.05, side="long")   # +5% within 10 bars, long
opt = TargetOptimizer(train_df, cfg)
# opt's own DiscoveryConfig, when not passed explicitly, defaults to
# DiscoveryConfig(train_ratio=1.0, max_and_components=2, retain_raw_events=True)
# -- pinned explicitly (issue #254 Phase 8), decoupled from DiscoveryConfig's
# own class defaults (1 / False, calibrated for forge()'s two-pass path
# instead), since TargetOptimizer composes atoms before any return is seen
# and reads ed.raw_events directly.
ranked = opt.run() -> pd.DataFrame       # columns: event_id, n_components, expression,
                                          # n_activations, win_rate_event, win_rate_base, lift, z_score
opt.base_rate           # unconditional win rate of the target, set only after run()
opt.candidates           # the underlying list[EventCandidate], ranked to match `ranked`'s order

oos = opt.validate_oos(full_df, top_k=10) -> pd.DataFrame   # adds *_oos columns for the top-k rows
contracts = opt.discover_alpha(config: AlphaConfig | None = None) -> list[AlphaContract]

TargetConfig fields and defaults: horizon: int, min_return: float, side: str ("long"/"short", required, no 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. target_mode defaults to "proj" here too — the same trend-excess scoring §8/§9/§15 describe for AlphaConfig — so TargetConfig(horizon=20, min_return=0.10, side="long") without an explicit target_mode measures "beat the 2×20-bar trend SMA by 10%," not "close moved +10% in 20 bars." Pass target_mode="abs" for the literal reading. min_lift_atoms/min_lift_result/min_activations are pruning thresholds specific to TargetOptimizer's own two lift-pruning passes (before and after AND composition) — Alpha Discovery's fixed-target mode ignores them.

Verified run, on the ADA fixture's first 600 bars (horizon=10, min_return=0.05, side="long", target-mode default "proj"):

run() -> 2235 rows, 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) — same two rows, now with *_oos columns:
  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

Two mechanics worth knowing before you rely on the OOS numbers: validate_oos()'s scoring pass bypasses min_activations entirely (it's hardcoded to 1 for the actual lift/z computation; the configured min_activations only drives the low_sample_oos diagnostic flag, which does not filter anything), and it reassigns full_df's row order onto the target's index rather than aligning by index label — safe only if cand.apply(full_df) preserves full_df's original row order, which it does for the standard pipeline but is worth knowing if you construct full_df unusually.

The one fact worth documenting explicitly: TargetOptimizer sits outside the parameter-coherence resolver. target_optimizer.py imports nothing from forgedge.resolver or forgedge.unset — unlike every other module touched by the #173–#185 audit, it was written before, or deliberately kept outside, that layer. TargetConfig itself has no UNSET fields (every default is a concrete literal), so it's unaffected either way — but discover_alpha()'s internal AlphaConfig() is built without ever going through forge()'s session resolver, since TargetOptimizer "does not touch forge() or ForgeResult" by design. Any UNSET-sentinel field on that AlphaConfig (horizon_grid, bars_per_day, …) falls back to AlphaDiscovery's own internal coalesce() default at each read site, not the timeframe-calibrated value forge() would have injected — on daily-or-slower data this is the same hourly-grid footgun §9's Alpha Discovery pitfall and §21 describe, reached through a different door. discover_alpha() also silently overwrites config.fixed_target, config.target_mode, and config.trend_sma_mult on whatever AlphaConfig you pass it, even if you set fixed_target yourself — this is deliberate (it keeps IS and OOS scoring consistent with the TargetConfig that drove run()), but it means a caller cannot use the config= argument to target a different fixed target than the one already fed to TargetOptimizer.__init__.

Module 3 — RuleDiscovery, RuleDiscoveryResponse

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

The event_candidate you pass must be the one contract.event_candidate_id actually points to, or the constructor raises ValueError.

Overlap — how much capital these numbers take

run_backtest opens a position on every active bar, with no flat-state check. That is deliberate and stays that way: it is a legitimate, capital-permitting policy, and the reported economics are reproducible live given enough capital to fund the concurrent positions. What was missing was any way to find out how much capital that is (issue #168) — the reports carried a fixed sentence about "overlapping positions" with no number attached, so a rule needing 1× the capital of a single position and one needing 12× read identically.

BacktestSummary now measures it on the ledger the published parameters actually produce:

field question it answers
n_episodes how often does this signal fire?
mean_concurrent_positions when it is working, how many positions am I funding?
max_concurrent_positions can I deploy this at all on my account?

trades (from return_trades=True) carries an episode_id per row, so trades.groupby("episode_id").size() is available without reimplementing the grouping.

Episodes and concurrency are different measures and generally disagree. Episodes group by signal — a five-bar RSI < 30 stretch is one thing happening, not five. Concurrency groups by price path — and trades from clearly separate episodes still overlap whenever the holding period outruns the gap between them. On the case in #168: 120 signal bars, 76 episodes, and a mean of 3.71 concurrent positions.

Which one you want depends on the question: capital sizing → concurrency; how often a signal fires → episodes; statistical inference → concurrency, because overlapping trades share a price path and are not independent observations. total_trades / mean_concurrent_positions is the sample size the overlap actually supports (118 nominal → ≈32 effective in that case). The inferential consequences are #177's business; this is the measurement it needs.

forgedge.episodes exposes the primitives — episode_starts, episode_ids, concurrency — for callers who want them directly.

Nominal economics, effective inference

The overlap the previous section measures has a consequence beyond capital sizing, and it is the one that changes verdicts.

Overlapping trades share a price path, so they are not independent observations. Every quantity that makes a confidence statement was consuming the nominal trade count as if they were (F16):

quantity before now
total_trades, PF, expectancy, net gain nominal nominal — this is the economics, untouched
t-test standard error and degrees of freedom nominal effective
deflated_sharpe's n_obs nominal effective
expectancy_mde / the power gate nominal effective

StatisticalValidation.n_effective is total_trades / mean_concurrent_positions, measured from the ledger's own bar geometry rather than passed in — there is then no way to hand it a number describing a different set of trades. On the case in #168, 118 nominal trades against ≈32 effective:

t-test:  t scales as sqrt(n)  →  overstated 1.93x
         t=2.6 (p≈0.005) is really t=1.35 (p≈0.09)
DSR:     n_obs 118 → 32 at n_trials=15, correction 0.820 → 0.741

The t-test is the serious channel: max_ttest_p is one of the three hard gates producing NON-EDGE, so the pipeline was admitting rules on overstated significance. The point estimates still use every trade — more observations do sharpen the estimate of a mean; what they do not do, when they overlap, is sharpen it as fast as sqrt(n).

When the ledger carries no bar geometry the correction cannot be computed, and the nominal count is used with n_effective = nan saying so. An unmeasured overlap is not evidence of no overlap, but inventing a correction would be worse than reporting the uncorrected number and admitting it.

An unreachable floor is a window, not a verdict

Three gates compared an absolute floor against a window whose length was fixed by unrelated parameters, while the arrival rate was fixed by a third. When window x rate < floor, every candidate fails by construction — and the pipeline reported that as a wall of rejections, indistinguishable from "the signal is bad".

Measured on the same fixture: fold pass rate 0.7% → 54.2%, OOS-stable candidates 0.5% → 52.4%, with the remaining failures attributed to rate and dispersion — the criteria that were always meant to decide. - M1's discovery window (#206, F1 one level up from the fold case above). min_episodes is absolute and unrelated to min_tpm, so it implies an in-sample discovery window too — the naive min_episodes / min_tpm satisfies it only in expectation, #173's mistake recurring here. At "sniper"'s stock rate (0.3 episodes/month) the window that actually clears min_episodes=10 with 95% confidence is 53.3 months (4.44 years), not the "≥2 anni" the preset's own description used to claim before this was measured. config_report()'s m1_is_window_too_short (WARN, not FAIL — a candidate above the configured rate can still clear the floor on less data) names the gap when the span is known. min_episodes is now preset-parametrized rather than one flat default: "sniper" keeps 10 (statistical rigor is the point, so the description was corrected instead of the floor), "sweep" — sharing "sniper"'s low rate but none of its precision claim — lowers it to 5, consistent with being permissive by design and already deferring rigor to the RotationCalibrator downstream. - M3's own pre-screen, on its own first window (#217, the same window/floor confusion recurring a third time, one level down from min_train_months). min_train_months sizes the walk-forward's first train window to reach exactly 10 trades at criteria.min_tpm — not n_months × min_tpm trades. selection_mode="walk_forward"'s fast pre-screen (Step 2.3, _early_elimination) re-derived the trade floor with that second, unrelated formula on that same short window instead of reusing the 10 it was sized for — harmless at low min_tpm, where the two formulas roughly agree, but at a high effective rate (e.g. min_tpm=35.2, the bar-equivalent rate forge_preset("balanced", "1H") resolves to) the window still rounds to its 1-month floor while the re-derived requirement climbs to 35 — seven times the trade count the window was ever sized to supply. Reproduced on real data (SUIUSDC/1H): 9 contracts that had already cleared the rotation null were discarded with "total_trades 0 < 35 (1mo × 35.2 tpm, not significant)" despite 255–674 activations over the full IS span. The pre-screen on that first window now takes an explicit fixed floor instead of re-deriving one; _decide()'s own floor (over the full selection span, where n_months × min_tpm is the right question — "did this candidate keep its declared rate over the history it actually had?") is untouched.

Entry mode — what the verdict measures

entry_mode defaults to "auto" (it was "limit" before #185), and the change is worth understanding because it moves verdicts.

In "limit" mode the grid varies buy_drop_pct, so the limit entry does two jobs at once: order mechanic and entry-price optimiser. A deeper discount fills less often and — this is the part that matters — only on the paths that came back down to it. The profit factor rises on a subset of trades that is not the tradeable population. That is the fill confound, and under "limit" the verdict partly measures the entry price rather than the signal.

"auto" splits the two readings apart:

The adoption conditions, all measured on that replay:

# condition what it stops
1 fill_rate >= min_fill_rate_opt a PF inflated by rare fills
2 opportunity_sharpe >= market's a point that trades less for a slightly better edge
3 net_gain >= min_net_gain_retention × market's a tiny µ with a tiny σ

Condition 2 uses a different Sharpe from the one on StatisticalValidation, and the difference is the whole criterion. validate() annualises by capacity — bars_per_year / avg_holding_bars, how many non-overlapping holding periods fit in a year. That is the right denominator for "how good is this rule", because it does not reward a rule for the accident of firing often. But both operating points sit on the same rule and hold for the same length, so capacity is identical for the two and the sqrt factor cancels: the comparison collapses onto the per-trade Sharpe, which is exactly the opportunity-blind metric the criterion exists to escape.

opportunity_sharpe counts realised trades instead — (µ/σ) × sqrt(trades per year) — so halving the trades costs sqrt(2) ≈ 1.41× that the per-trade edge has to beat. On the issue's own worked example:

market limit ratio
Sharpe per trade 0.267 0.375 1.41×
annualised by capacity 5.095 7.164 1.41× — unchanged
opportunity_sharpe 1.461 1.299 0.89×
total return 48% 36% 0.75×

The capacity reading adopts a point that earns a quarter less.

Both points are reported in full on RuleDiscoveryResponse.entry_optimization — each with its own rule, out-of-sample summary and statistics, plus failed_condition naming which condition stopped an adoption. The limit point's walk-forward is a replay (reoptimise=False), so it adds no selection and no n_trials; its Deflated Sharpe carries its own larger trial count (Stage 1 + Stage 2 cells) as an absolute metric, while the min_dsr gate always reads the market point's — the verdict never pays for Stage 2.

"limit" remains fully supported and is the right choice when the limit order is the strategy rather than an execution refinement.

A RuleDiscoveryResponse's important attributes: verdict ("EDGE"|"PARTIAL-EDGE"|"NON-EDGE"|"INSUFFICIENT-DATA"), is_edge (true for the first two), rejection_reasons, validated_rule (carries .params, a BacktestParams), in_sample_summary (total_trades, profit_factor, win_rate_pct, expectancy, tpm_mu, n_episodes, mean_concurrent_positions, max_concurrent_positions — trade-overlap fields, §15), execution_envelope (.conservative/.optimistic — see §17), walk_forward (.oos_summary, .consistency), statistical_validation (.temporal_stability, .deflated_sharpe), regime_analysis, excursion (MAE/MFE), entry_optimization (only populated under entry_mode="auto" — the default — and only once the Stage-1 market-mode verdict is EDGE/PARTIAL-EDGE; None under "market"/"limit" and under a "auto" run whose market verdict is NON-EDGE, §15).

text_report(resp)/html_report(resp) (also importable straight from top-level forgedge) build human-readable/HTML reports from a response — every rule parameter, IS/OOS stat, walk-forward per-split breakdown, statistical validation and rejection reason it carries. Pass the originating AlphaContract as contract= to fold its rotation_p/rotation_threshold (a Modulo 2 field RuleDiscoveryResponse doesn't duplicate) into the same report. rule_summary_report(resp, *, contract=None, fmt="text"|"html", save=False, path=None) wraps both of these in one discoverable call — save=True writes the report to path (default f"{resp.alpha_id}.{txt|html}") and still returns the string, so print(rule_summary_report(resp, save=True)) is one line. resp.to_dict() gives a JSON-serializable form. This is distinct from rule_performance_report() below, which replays rules on fresh candles for a monitoring-style chart report rather than rendering the verdict M3 already produced.

A concrete contract, field by field

§7's quick start prints a handful of fields from one PARTIAL-EDGE case. Here is the same case again, but every object dumped in full — the actual repr() of a real EventCandidate, AlphaContract and RuleDiscoveryResponse, verified on the ADA fixture, so you can see exactly what these objects hold rather than inferring it from the attribute lists above.

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

EventCandidate — the boolean condition, with no notion yet of what happens after it fires:

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)

Note what is not here: no return, no direction, no win rate. close_vol_12 (12-bar rolling volatility of close) dropped by more than the 10th-percentile 6-bar change, 88 times across 20 active months — that is the entirety of what Event Discovery knows about this pattern. threshold_type='distributional_p10' is the tell that -0.018035 was chosen as this asset's own 10th percentile, not hand-picked.

AlphaContract — the same condition, now with a derived economic target and a statistical grade:

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

Read this the way §14 describes: event_stats/cohens_d/t_stat are strong (p≈2e-6) — the horizon-12 score in derived_target.score_by_h is the grid's best point and clears its own significance test. But diagnostics (non-blocking, feeding the grade) already flags a weak Spearman IC and a weak contract-level OOS re-check — neither blocks promotion (rejection_reasons is empty; only direction=="undetermined" would have), but both lower the letter grade to B and foreshadow exactly what Rule Discovery's own, independent OOS judgment below will find.

This dump predates §8 step 9's nature/horizon_at_boundary/promotion_route fields — on a contract captured today, h_sig=(12,) non-empty means promotion_route="z_score" or "both" (depending on whether the whole-grid AUC test in step 9 also cleared), nature="non_significativo" unless stage (a) is, and horizon_at_boundary=False here since h*=12 sits well inside the grid, not at h=24's edge.

RuleDiscoveryResponse — realistic backtest, walk-forward OOS, the economic verdict:

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 test windows profitable)
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     (of 4 — see below)
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)']

Three things this dump makes concrete that the attribute list above cannot:

The exact formulas behind two numbers you'll see quoted throughout this manual, since neither is the "textbook" version its name suggests:

Module 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)   # preferred entry point
reg = registry.run()
reg.summary(); reg.flat_table(); reg.documents; reg.matrices   # .jaccard, .spearman
reg.export("rules.xlsx")             # or .csv, per RegistryConfig.export_format
reg.html_report(timeframe="1H")      # self-contained HTML, inline SVG, no CDN

frames must be the post-Event-Discovery frames (i.e. ForgeResult.event_frame per ticker), not raw KPI Tables — the cross-ticker replay needs the derived feature columns the rule expressions reference.

reg.flat_table()'s default does not filter. apply_filters=False by default — a plain registry.flat_table() call includes duplicates and non-generic rules regardless of RegistryConfig.export_duplicates/export_non_generic. Those flags only take effect via reg.flat_table(apply_filters=True) or reg.export(...)/reg.html_report(...) (which apply them internally). This is a deliberate design choice — "FORGE shows complexity rather than hiding it" — but it means a first flat_table() call showing more rows than you expected is not a bug.

How a rule crosses tickers: recalibration, not reuse. Testing RULE_ADA_01 on BTCUSDC cannot mean applying ADA's literal threshold (-0.018035 in the example above) to BTC's numbers — the two assets' distributions don't share a scale. Before replay, recalibrate_candidate() deep-copies the candidate and, for every threshold-bearing component (categorical/binary components are left untouched — there is no distributional threshold to move), does two lookups against the empirical distributions of the source and target ticker:

  1. On the source ticker: find the percentile the original threshold occupies — q = mean(source_series <= threshold).
  2. On the target ticker: read off the value at that same percentile — target_series.quantile(q).

So a rule discovered as "10th percentile of ADA's 6-bar Δvol" is tested on BTC as "10th percentile of BTC's own 6-bar Δvol," not as the literal ADA number. Relative (rolling_pctrank/rolling_zscore) transforms are close to a no-op under this recalibration, since they're already self-normalising; identity-transform thresholds shift by construction. The recalibrated candidate is a fresh object with its cached event_series explicitly cleared, so replay always re-evaluates on the target frame rather than risking a stale source-ticker cache.

Genericity is a four-tier classification, driven by the two-part PASS/FAIL criterion §10 already introduces:

classification condition meaning
GENERIC passes on 100% of other tickers tested works everywhere tested
PARTIAL passes on ≥ generic_ratio_threshold (2/3 by default) but < 100% works on the majority
SPECIFIC passes on some but < generic_ratio_threshold mostly ties to its home ticker
ISOLATED passes on none, or only one ticker exists in the whole session (nothing to test against) does not generalise, or cannot be tested

Each individual per-ticker PASS/FAIL is the two-part criterion from §10 (pf_other >= cross_pf_threshold AND pf_other >= retention × pf_home); classification/is_generic are the roll-up across every other ticker in the session. CrossTickerResult.bar records the exact PF floor (max(cross_pf_threshold, retention × pf_home)) that specific rule had to clear on that specific target — reported per result precisely so a reader can tell "not tradeable there" from "gave up too much of its home edge" apart, rather than inferring it from a single pass/fail flag.

Deduplication flags a chain, never deletes. Two rules with Jaccard overlap strictly greater than overlap_threshold (0.70 default) on their activation dates are compared by home-ticker profit factor; the weaker one (strictly lower PF — an exact PF tie never dominates either direction) is marked is_duplicate=True, duplicate_of=<the closest stronger neighbour's rule_id> — the closest, not the single strongest rule in the registry, so that a PF-ordered chain (R01 ← R02 ← R03) stays a chain rather than collapsing every weaker rule onto the single global best. Nothing is ever removed from reg.documents or the default flat_table() — §14 explains why: a pair flagged as overlapping isn't necessarily redundant, it can be independent confirmation of the same edge via two different mechanisms, and the decision to keep or discard is left to you.

KPI Builder — building a KPI Table from raw candles

Everything else in this manual assumes you already have a KPI Table — a pandas.DataFrame with a close column, a datetime source, and enough indicator columns for Event Discovery to have something to mine. If what you actually have is raw OHLCV candles from an exchange or a data vendor, forgedge.kpi_builder is the first thing you call, before you ever touch forge(). It is a self-contained, three-function toolkit — nothing here talks to forge() internally, and nothing in forge() requires you to have used it; you can build a KPI Table by any means you like, as long as the result has close and a timestamp.

A complete walkthrough, starting from nothing but raw OHLCV

This is deliberately the most basic case: hourly candles the way an exchange REST API hands them back — epoch-millisecond timestamps, no derived columns, nothing FORGE-specific about the shape at all. The three rows below are shown for shape; the actual table used for the verified output further down has 500 of them.

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],
})   # a real table has as many rows as bars — 500 in the run below

Three calls turn this into a table forge() can consume:

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())   # opt-in data-quality check — never blocks on its own, §9/§11

Verified output, on 500 synthetic-but-internally-consistent hourly bars built this way:

build_features   : (500, 6)  -> (500, 116)   # +color, +open_dt, +109 indicator columns
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"

That table is now a real KPI Table — pass it straight to forge():

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

Nothing about this is special-cased for the walkthrough: this is the exact build_features → candle_features → lag_features sequence §12's Use Case 2 and the repository's own examples/kpi_builder_usage.py follow, run here on synthetic instead of real data so you can see it work end to end without needing a data source. If you have real data with the exact same shape (a timestamp column plus open/high/low/close, optionally volume), this is literally all you need to reach forge().

What each function does, parameter by parameter

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, *, ...) computes a configurable set of base indicators (SMA, EMA, RSI, Bollinger Bands, ATR, MACD, rolling min/max, returns, volatility, max-drawdown) from raw OHLCV, and derives the open_dt timestamp column forge() expects.

candle_features(df, *, ...) adds six scale-free candlestick-geometry columns — see the table below for what each one means. order_on="open_dt" is the column used to compute gap (which needs the previous bar's close); add_gap=False skips that one column if you don't need it (e.g. on data with real session gaps you don't want treated as candle geometry); round_to=5 controls decimal rounding on every computed column.

lag_features(df, *cols, periods=(1,2,3), like=None, order_on="open_dt") appends {col}_prev_{NN} shifted copies of columns that already exist on df — it runs after build_features/candle_features specifically so you're lagging real, inspectable column names instead of having to predict FORGE's internal naming convention up front. Two ways to select what to lag, usable together: list explicit names as positional arguments (lag_features(kpi, "close", "close_ema_09"); a typo raises KeyError naming the columns that do exist), or pass like="_ema_" to lag every column whose name contains that substring (order_on itself is never selected this way, even if it happens to match). periods accepts a single int or a sequence — periods=[1, 2, 3] (the default) produces three lagged columns per selected column, periods=6 produces one.

What your input table needs to have

Only timestamp_col and close are ever strictly required — close because every module downstream measures forward returns on it. open/high/low are needed by specific pieces (candle_features needs all four OHLC columns and raises KeyError naming whichever are missing; add_color needs open+close; the atr indicator needs high+low in addition to whatever columns you configured it for) but are otherwise optional — a close-only series still builds a valid, if smaller, KPI Table. volume is optional everywhere: the packaged default config references it (for moving_average), but a column config references and candles doesn't have is skipped with a logger.warning, never an error — this is specifically why OHLC-only input, with no volume at all, is always safe to pass through build_features unmodified. If you're not sure your raw data is clean before spending a forge() run on it, summary_report() (used in the walkthrough above) is the opt-in check built for exactly that — see §9/§11 for what it looks for and §13 for a real example of it catching a genuinely malformed CSV.

Configuring which indicators get built

A config is a plain mapping: {indicator_name: {"enabled": bool, "params": {"periods": [...], "columns": [...]}}}. Three equivalent ways to supply one:

kpi = build_features(candles, timestamp_col="open_time")                    # None -> packaged default
kpi = build_features(candles, {"rsi": {"enabled": True,                     # a plain dict
                                        "params": {"periods": [14], "columns": ["close"]}}},
                      timestamp_col="open_time")
kpi = build_features(candles, "my_config.yaml", timestamp_col="open_time")  # a YAML path (needs PyYAML)

A missing "enabled" key defaults to True (only "enabled": False turns a block off); a "params" block with no "periods"/"columns" produces empty lists, effectively a no-op for that indicator; an indicator name the library doesn't recognise is skipped with a warning, not an error, so a typo in a hand-written config degrades quietly rather than raising — check the log if a column you expected never shows up. A "lagging" block, if present (e.g. copied from an older config), is explicitly ignored with an informational log line — lagging lives in lag_features() now, not in this config. The packaged reference config ships alongside the source as an editable, commented YAML file (kpi_builder/default_enricher.yaml) with the same structure DEFAULT_CONFIG below has — copy it as a starting point for your own file rather than writing one from scratch. load_kpi_config(path) (used internally when you pass a str/Path, or callable directly) raises ImportError with a clear message if PyYAML isn't installed, and FileNotFoundError if path doesn't exist.

Column-naming convention that matters: for a column to be recognised as part of a same-family ratio pair by Event Discovery's feature generator, its name must match {base}_{indicator}_{period} with base ∈ {close, high, low, open, volume} and indicator ∈ {ema, sma, rsi, dema, tema, wma, hma, mdd, atr, natr} (or the dedicated Bollinger/volatility/return/MACD naming patterns). A column that doesn't match this convention still works as a standalone feature and can still be reached by one of the other, more narrowly-scoped arity-2 pairings described in §8 (cross-time OHLC pairs, MACD-vs-signal, price-vs-volume return, candle_features() geometry pairs, indicator-vs-lagged-OHLC-base) — it's specifically the generic same-family grouping it opts out of. If a custom indicator you added never shows up composed with anything in Event Discovery's candidates, check its name against this convention first.

The default indicator set, and its exact formulas

build_features(candles, config=None, ...)'s packaged default (DEFAULT_CONFIG, one entry per indicator family) is what runs when you pass config=None — this is what §12's Use Case 2 got when it asked for a smaller, explicit config instead:

indicator default periods default columns enabled
moving_average (SMA) 3, 9, 12, 25, 96, 168 close, high, low, volume yes
ema 3, 9, 12, 25, 96, 168 close, high, low yes
volatility 5, 12, 24, 96, 168 close yes
min / max 3, 6, 12, 24, 48, 96, 168 close, high, low yes
return 1, 3, 6, 12, 24, 48, 96, 168 close yes
rsi 14, 25 close yes
bollinger_bands 20 close yes
max_drawdown 12, 24, 48, 120, 240 close yes
atr 14, 28 close (+ requires high/low) no
macd 12, 26, 9 (fast, slow, signal) close no

Verified, on 400 hourly synthetic bars: build_features(candles, timestamp_col="open_time") with no config (i.e. the packaged default) produces no *_atr_*/*_macd_* columns at all — confirming atr/macd really do ship off. Passing a config that explicitly enables them —

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")

adds exactly 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 — seven columns for two indicator blocks. Two details worth knowing before you turn either on:

The six candlestick-geometry columns candle_features() adds

Each is scale-free (a ratio, not a price-unit quantity), so a $0.01 asset and a $60,000 one produce directly comparable values:

column formula meaning
body (close − open) / (high − low) signed body size — positive on a bullish bar, negative on a bearish one
upper_wick (high − max(open, close)) / (high − low) upper shadow
lower_wick (min(open, close) − low) / (high − low) lower shadow
close_pos (close − low) / (high − low) where the close landed within the bar's own range, 0 at the low, 1 at the high
range_pct (high − low) / close bar amplitude relative to price
gap (open − prev_close) / prev_close opening gap versus the previous bar's close (only if add_gap=True, the default)

By construction abs(body) + upper_wick + lower_wick == 1 on any non-flat bar. All six divide by (high − low) (or by prev_close for gap), so a completely flat bar (high == low) or a zero previous close produces NaN rather than a division error — expected on very low-liquidity data, not a bug to work around.

pattern_features() — the opt-in named-pattern layer

from forgedge import pattern_features
kpi = pattern_features(kpi)   # adds one categorical column: candle_pattern

Ten named single-, two- and three-bar formations (HAMMER, DOJI, SHOOTING_STAR, MARUBOZU, BULLISH/BEARISH_ENGULFING, BULLISH/BEARISH_HARAMI, MORNING_STAR, EVENING_STAR), each a fixed geometric rule on the same body/wick fractions candle_features() computes, coalesced into one categorical column (first match wins, checked in that fixed precedence order: morning_star, evening_star, bullish_engulfing, bearish_engulfing, bullish_harami, bearish_harami, marubozu, hammer, shooting_star, doji) — deliberately not ten separate boolean columns, because a sparse per-pattern boolean would routinely have ≤1 distinct non-null value and get silently dropped by FORGE's own type classifier before it ever reached Event Discovery.

Verified, on 400 bars of synthetic OHLCV: 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 — roughly 54% of bars carry no named pattern at all, and the single-bar patterns (DOJI/SHOOTING_STAR/HAMMER/MARUBOZU) dominate the rest, as expected from geometric rules with no cross-bar confirmation required. This function is explicitly not part of the recommended pipeline (build_features → candle_features → lag_features): the module docstring frames candle_features()'s continuous geometry as the preferred input for FORGE's own asset-adaptive threshold discovery, and named patterns as fixed, human-chosen thresholds better suited to testing a specific hypothesis (§15's opt-in table already lists the trade-off). Multi-bar patterns additionally require order_on (or a DatetimeIndex) to exist — unlike candle_features()/lag_features(), which fall back to assuming the frame is already chronologically ordered, pattern_features() raises KeyError rather than silently guessing at row order for a multi-bar formation.

The other opt-in KPI-level pairing: indicator_lag_cross_lags

Not a kpi_builder function at all, but a Module 1 (DiscoveryConfig) knob that only makes sense in the context of what kpi_builder produced: Event Discovery's feature generator always tests price-scale indicator columns (SMA/EMA/WMA/HMA families) against time-shifted base OHLC columns — e.g. close_sma_12[t] > low[t-3] — at lags DiscoveryConfig.indicator_lag_cross_lags, default (1, 3). This pairing is on by default and independent of anything you enable in build_features()'s config; pass indicator_lag_cross_lags=() to disable it entirely.

Verified cost, on a 400-bar KPI Table with 2 EMA periods (a small table — §17 reports a much larger measured cost, +24% runtime / +21% candidates, on a 36-column table): EventDiscovery(..., indicator_lag_cross_lags=(1,3)) (the default) → 6987 candidates; the same table with indicator_lag_cross_lags=() → 6841 candidates — a real, measurable difference in search-surface size purely from this one always-on pairing, growing with how many price-scale indicator columns and periods your KPI Table carries.

First-time mistakes worth knowing about upfront

Configuration coherence — 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 validates the data; config_report validates the configuration, in the same Finding vocabulary. It answers two questions in one output, because they only make sense together: with what configuration am I about to run, and is that configuration internally satisfiable.

Both it and forge() go through the same resolver, so what the report shows is by construction what the pipeline will execute — rep.configs are the actual objects, not a reconstruction. It never raises, never warns and never mutates what it was handed.

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

Fourteen constraints, each relating materialisations of one latent parameter. Three are FAIL — reserved for a configuration that makes a stage structurally incapable of producing a verdict: wf_bucket_too_short (issue #173), m1_oos_fold_too_short, oos_span_too_short. The other eleven are WARN, including only_validated_events_inert (#250): fires when forge(only_validated_events=True) is requested but the resolved event_discovery config cannot actually run M1's own walk-forward (walk_forward unset, or train_ratio>=1.0 — both true of forge_preset()'s output, which fixes M1's train_ratio=1.0 on purpose), the condition under which the filter silently drops nothing at all. Every message carries the value to set, not just the failure.

forge(strict=True) is the default, and this is a behaviour change. A FAIL now raises ValueError instead of running. Such a run cannot tell you anything: every candidate is eliminated for configuration reasons and the resulting wall of rejections is indistinguishable from "the signal is bad", which is what you were trying to measure. Pass strict=False to downgrade everything to UserWarning and run anyway. Non-critical incoherences are always warnings, never errors. No verdict changes — what changes is that some runs no longer start.

Known: forge_preset("balanced", "1D") is currently flagged. At stock preset values min_train_months=6 × criteria.min_tpm=0.80 = 4.8 against a floor of 10 — the audit's F2, and the reason daily-data users see mass early-elimination. It is fixed by deriving min_train_months from the rate (issue #177); until then, daily preset runs need strict=False.

What the resolver fills in

A field left alone is not a value: it is a question the session answers. These are the fields whose default now comes from the session rather than from a class body — set any one of them and every module that reads the same quantity follows.

latent parameter fields it materialised as resolved default
the timestamp column timestamp_col on M1 / M2 / M3 / M4 "open_dt"
the price series AlphaConfig.close_col, BacktestParams.{target_col, buy_price_anchor} "close"
the regime columns AlphaConfig.{regime_col, regime_stable_col} "regime" / "regime_stable"
the cost basis AlphaConfig.fee_per_side, BacktestParams.fee 0.002
the genericity bar RegistryConfig.{cross_pf_threshold, min_cross_pf_retention} 1.5 / 0.8

Two of these were live bugs rather than tidiness. AlphaConfig.fee_per_side stamped the contract while BacktestParams.fee charged the backtest and nothing connected them, so AlphaConfig(fee_per_side=0.0005) produced contracts documenting 5 bp and a backtest charging 20 — silently, since the two agreed only by sharing a default. And forge_preset(timestamp_col="ts") configured M1 alone, so M2 failed later asking for a value you believed you had already given.

Propagation is not symmetric with seeding, in one deliberate place, and the reason is worth stating precisely. buy_price_anchor is not a schema field: it names the reference level the limit offset is applied to — buy_price = anchor × (1 ∓ buy_drop_pct) — and any numeric column on the candle table is legal there, including a derived indicator. buy_price_anchor= "close_sma_3" with buy_drop_pct=0.10 is how you say "place a limit at 90% of the 3-bar SMA"; the engine has no other way to express it.

So the anchor is filled in from the price column — its default reference level is the close, and renaming the column has to carry that along — but it never seeds the context, and it is not checked for equality against close_col. Seeding from it would push "close_sma_3" back out into AlphaConfig.close_col and have M2 measure forward returns on a moving average. target_col is different — the horizon exit must be priced on the series M2 measured returns on — so a disagreement there is reported.

Genericity is now a transfer test, not a quality test. cross_pf_threshold used to default to 2.0 independently of M3, while partial_min_profit_factor admits rules at 1.5 — so a PARTIAL-EDGE rule had to do better away from home than at home to be called generic, and the entire class was excluded from genericity by construction. The verdict is now PASS ⟺ pf ≥ floor AND pf ≥ retention × pf_home: the absolute half asks is it tradeable there, the relative half asks does it transfer. Quality stays on the M3 verdict and the grade, where the registry already records it. CrossTickerResult.bar reports the number each verdict was measured against.

Data quality — 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

A cheap, purely advisory diagnostic pass over price columns — it never raises and never blocks the pipeline. It checks schema/NaNs/infinities, price-scale consistency (mixed magnitudes — a common symptom of a data-feed bug), OHLC internal consistency, return outliers (a MAD-based robust z-score plus an absolute-move threshold), and time continuity (gaps, duplicate or out-of-order timestamps). Every finding is a Finding(level, code, message) with level ∈ {"OK","WARN","FAIL"}; DataQualityReport exposes .worst, .has_critical, .has_warnings, .one_line(), .to_text(), and the full .findings list.

Time budget, hypothesis ledger, calibration

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) — plain bookkeeping (m1_candidates, m2_horizons, m2_promoted, m3_grid_cells, m2_surface, total_surface), not a correction mechanism.

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

Both are covered in depth in §14–15.

forgedge.playground — analysis helpers over ForgeResult

Everything so far in this section reads from one forge() call and asks "what did this run produce." forgedge.playground is a different layer: a small, growing set of read-only functions that take R, the set of ForgeResult objects a whole research session accumulates — several tickers, several presets, several re-runs over time — and ask cross-cutting questions about the pipeline's own behaviour that no single run's fields answer on their own. It never touches the pipeline: every function only reads attributes already sitting on ForgeResult (.enriched, .rule_responses, .candidates, .contracts, .ticker, …) and returns a pandas.DataFrame.

from forgedge.playground import *   # the intended import — see forgedge/playground/__init__.py

Two design choices apply to every function here and are worth internalising once rather than per-function:

This module is explicitly a diagnostic layer, not a stable core API on the level of forge() or RuleDiscovery. It follows a tracking checklist of use cases (issue #237) — all 11 are now implemented:

Function Module Question it answers
regime_transitions(results) M0 How often — and after how short a run — does regime flip? A boundary that flips every bar or two is "nervous," and pollutes any M1 event conditioned on regime downstream.
regime_time_share(results) M0 What share of its history does each ticker spend in each regime? An asset effectively imprisoned in one regime makes rules discovered on it look generic when they're actually regime-specific — there was never enough of another regime present to prove otherwise.
dead_event_candidates(results) M1→M2 Which gate-surviving EventCandidates never turn into an actionable contract downstream? Labels each "dead" (zero contracts), "undetermined_only", or "actionable" — quantifying M1→M2 waste.
gate_survival_observed(results) M1 For every raw pre-gate candidate, the observed Consistency Gate statistics (tpm, dispersion, pass/fail) alongside the configured thresholds — so a preset/asset mismatch is diagnosable before it shows up as "0 candidates" in M2.
discard_reasons_by_grade(results, grade="A") M2→M3 Why does Rule Discovery verdict NON-EDGE specifically on alpha contracts of a given letter grade? Explodes rejection_reasons one row per reason, paired with entry_optimization.failed_condition when present.
undetermined_direction_by_family(results) M1→M2 Which source-feature families (RSI, EMA, cross-column pairs, …) feed events that Alpha Discovery systematically can't orient (direction="undetermined")? One row per EventCandidate component, so a family that only ever appears inside a composed AND-event is still counted.
diagnostics_vs_verdict(results) M2→M3 Which non-blocking M2 AlphaContract.diagnostics correlate with a later M3 NON-EDGE, stratified by grade — candidates to promote from FYI into an actual gate.
lottery_only_winners(results) M3 Which PARTIAL-EDGE contracts cleared every economic/statistical gate and lost only the search-level rotation null — versus contracts still genuinely weak on PF/DSR/OOS consistency.
classification_by_grade(registries) M4 Does a higher alpha grade actually generalise better cross-ticker? Links RuleDocument.classification (GENERIC/PARTIAL/SPECIFIC/ISOLATED) to its originating grade. Takes Iterable[RuleRegistry], not ForgeResult — see below.
duplicate_clusters(registries) M4 How much dedup weight is there, and which surviving rules absorb it? One row per RuleDocument with is_duplicate/duplicate_of. Also takes Iterable[RuleRegistry].
conversion_funnel(results) cross-module The end-to-end candidates → contracts → promoted → edges population per ticker, in one long table — the only use case not anchored to a single module.

classification_by_grade/duplicate_clusters are the two functions that deviate from the "always Iterable[ForgeResult]" rule below: cross-ticker classification lives on the pooled RuleRegistry that forge_multi() returns separately (each per-ticker ForgeResult.registry is None on that path) — pass [result.registry] for a single-ticker run, or [registry] for a pooled one.

Verified, pooling two forge() runs (ADAUSDC, and a second synthetic series labelled BTCUSDC) into one results = [result_ada, result_btc] list:

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

The undetermined rate on this fixture sits in a narrow 85–95% band across every family reached — no single family stands out as reliably orientable, which is itself the kind of fact only visible by pooling every contract's components and asking the question long-format across all of them, not from any one AlphaContract. (This example also doubles as a real illustration of why this module is treated as diagnostic rather than stable even with a complete checklist: the family-bucketing logic itself had a bug — EventComponent.source_cols turned out to be non-empty even for native, arity-1 features, so every native component was silently misrouted into the cross-feature branch and this table showed no rsi/ema/ret/… family at all, only cross_pair/cross_triple/other. Fixed by dispatching on len(source_cols) instead of its truthiness; the numbers above are from the corrected function.)

Each function has a specific, deliberate "skip vs. raise" rule, always documented in its own docstring rather than left implicit — e.g. regime_transitions/regime_time_share silently skip any result whose .enriched frame has no regime column (Market Context disabled) instead of raising, and undetermined_direction_by_family silently skips a contract whose event_candidate_id doesn't resolve against result.candidates rather than raising a KeyError. Neither is a bug to route around — see the module's own docstrings, or src/forgedge/docs/specs/playground_en.md for the full usage reference (signatures, return columns, every skip rule, verified examples for all 10 functions, including the six added after this manual's initial pass), for exactly which functions skip what and why. src/forgedge/docs/modules/Playground.md covers the design rationale and internal algorithms instead.

forgedge.deployment — from analysis to production

forgedge.playground answers "why did the pipeline behave this way"; it never decides what happens next. forgedge.deployment is the sibling module that picks up from there: given the tradeable contracts a session produced, it gates which ones are solid enough to go live, exports them to disk in a replayable format, and indexes the export for a periodic monitoring job. Unlike everything above, this module has real effects — it was in fact born inside forgedge.playground (issue #245) and moved to its own top-level package once that stopped being an honest name for something that writes files and makes go/no-go decisions (PR #247):

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

The intended sequence is forge() → promotion_gate() [filter] → export_rules() [write to disk, on the promotable rules only] → monitoring_manifest() [index the export]. PromotionGateConfig blocks on independent flags by default-tuned combination: a rotation-only PARTIAL-EDGE miss is not blocked by default (block_rotation_only=False — usually an acceptable trade-off, not a red flag), while a duplicate or cross-ticker-"ISOLATED" rule is blocked by default, and a walk_forward.consistency floor of 0.5 (the same floor the pipeline itself uses internally) always applies unless require_consistency=False. min_fold_stability_score (#253, default None — off) adds an optional floor on mean(fold_pf) - std(fold_pf) over the walk-forward's own per-fold profit factors (each capped at fold_pf_cap, default 10.0): a rule can clear a healthy pooled walk-forward PF purely because one high-variance fold dominates the aggregate while its fold-to-fold performance is actually erratic and prone to collapsing on genuinely fresh data — this catches that case directly instead of only looking at the pooled number. Every flag is always computed and reported regardless of whether it participates in the final promotable column, so turning a check off never loses visibility into what it would have flagged.

Verified, on a forge_multi() pool over ADAUSDC and a second, synthetic BTCUSDC series (forge_multi(), not a plain forge() per ticker like the regime example above, so a genuine pooled cross-ticker RuleRegistry is available):

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 — one row per contract actually written to disk

Every ADAUSDC contract is blocked on this fixture — over half of the pooled contracts are duplicates and most classify "ISOLATED" on cross-ticker replay (both blocked by default), so this is the gate being conservative by design, not a bug.

Full parameter/return reference, PromotionGateConfig's complete field table, and every verified example: src/forgedge/docs/specs/deployment_en.md. Design rationale — why the sequence is fixed, why only export_rules touches the filesystem: src/forgedge/docs/modules/Deployment.md.

forgedge.experiment — pipelines of experiments built on forge()

forgedge.playground reads what a run already produced; forgedge.deployment acts on it. forgedge.experiment is a third kind of sibling: it runs its own multi-stage pipeline on top of forge(), neither read-only nor production-facing. StepWiseDiscovery, its first class, answers a narrower, more exploratory question than a single forge() call: starting from the strongest hold-out-confirmed single-condition rules, can each one be grown, one AND-condition at a time, by searching only the sub-population it already selects for a second dimension? — a per-seed local search, as opposed to forge()'s own single global pass (including its grade-guided two-pass composition, §8).

from forgedge.experiment import StepWiseDiscovery, StepWiseDiscoveryConfig

The algorithm, in order:

  1. Resolve once. The caller's (or default) event_discovery_config/alpha_config/rule_discovery_config are resolved a single time via forgedge.config_report(), against the full KPI table's shape only (length/span — never its values, so resolving before the split below introduces no look-ahead). Every internal EventDiscovery/AlphaDiscovery/RuleDiscovery call the search makes downstream reuses these same resolved configs, never a fresh, partially-UNSET one — a hand-built EventDiscovery/AlphaDiscovery constructed standalone otherwise falls back to an hourly calibration for any bar-counting field (horizon_grid and similar) left unset, regardless of the real timeframe — the same trap this manual's own Module 1/2 walkthroughs warn about for direct standalone use, earlier in this section. StepWiseDiscovery closes it by construction, the same way forge() itself does internally.
  2. Split an outer hold-out. Independent of, and in addition to, whatever internal train/test split the passed-in configs already use — holdout_df is never read by any discovery call, only by hold-out re-confirmation. Sized from the resolved alpha_config.horizon_grid/embargo_bars by default (StepWiseDiscoveryConfig.outer_horizon_bars/outer_embargo_bars, None — override with a domain-specific value, e.g. an OU-half-life-derived scale, if you have one).
  3. Iteration 1 — one forge() call on search_df alone, composition forced off (that is this class's own job): every single-condition candidate/contract/verdict the KPI table supports.
  4. Seed selection — rank iteration 1's results and accept up to config.n_seeds, each requiring a distinct feature family, hold-out confirmation, and diversity from every seed already accepted (Jaccard on boolean activation, plus a continuous-correlation check — forgedge.experiment.redundancy — so an ATR-ratio and its NATR analogue under different names don't each spend a full independent search chain on the same information).
  5. Partition & compose, per seed, up to config.depth: partition search_df to the chain's currently active rows, search that partition for a second condition among pointwise children (built directly on the partition) and rolling-transform children (pctrank/zscore/delta — recovered by computing the transform on the FULL search_df and calibrating only the threshold on the partition, avoiding the look-ahead a naive "transform on the partition" approach would introduce on a non-contiguous partition), reject anything redundant with a component already in the chain, retry every hold-out-confirmed child in ranked order until one composes into an Alpha-Discovery-derivable target, and commit the composed state only once it itself re-confirms on the hold-out — a failed composition attempt never overwrites a genuinely confirmed shallower result.

None of this is asset- or timeframe-specific — fee/mfe_floor calibration, which indicator periods went into the KPI table, and how much outer embargo to reserve are all caller decisions, made via the configs passed in. This class owns the search algorithm only.

Verified, on this repo's own tests/fixtures/ADA_1D_TRAIN.parquet reference fixture (882 bars), default DiscoveryConfig/AlphaConfig/RuleDiscoveryConfig, 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 stays a single-condition PARTIAL-EDGE — the one composition attempted at depth 0 failed hold-out confirmation, so the chain reports the last genuinely confirmed state (the seed itself) rather than the failed attempt. seed2 reaches depth 1: its search-level RuleDiscoveryResponse.verdict is NON-EDGE, but the composed two-condition rule re-confirms on the concatenated search+holdout evaluation (mean expectancy across post-holdout walk-forward folds +0.01574 > 0) — the same "verdict says one thing, hold-out re-evaluation says another" nuance confirms_on_holdout is built to catch, reported honestly rather than collapsed into a single boolean. Neither chain reaches depth 2 on this fixture — an honest negative, not an error: like the rest of forgedge, this module never forces a positive result on a configuration that doesn't support one.

Full parameter/return reference and every verified example: src/forgedge/docs/specs/experiment_en.md. Design rationale — why config resolution happens exactly once, why redundancy is checked both within a chain and across seeds, the reporting-order fix that protects a confirmed shallower state: src/forgedge/docs/modules/Experiment.md. A runnable, end-to-end example against this repo's own bundled OHLCV data: examples/step_wise_discovery_usage.py.


10. Configuration

Every module accepts a dataclass carrying its knobs. This section covers the ones you're most likely to actually tune, with defaults quoted exactly from source.

DiscoveryConfig (Module 1)

Field Default Meaning
gate_params GateParams() Consistency Gate thresholds — see below
max_categorical_classes 20 above this many distinct values, a non-numeric column is dropped, not one-hot-encoded
timestamp_col "open_dt"
max_and_components 1 (was 2 pre-#254 Phase 8) 1=singles only (Module 1 composes nothing — the default, precondition for forge(two_pass_composition=True)), 2=+pairs, 3=+pairs+triples (legacy single-pass path, or a caller with its own composition stage)
retain_raw_events False (was True pre-#254 Phase 8) keep EventDiscovery.raw_events (pre-gate population) resident after .run() — needed only by TargetOptimizer, which sets it True explicitly on its own fallback config
train_ratio 1.0 <1.0 reserves a tail for Module 1's own (optional) walk-forward validation
walk_forward None set a EventWalkForwardConfig to enable event-level OOS validation (§15 — opt-in)
diversity_gate_enabled False opt-in near-duplicate suppression (§15)
diversity_threshold 0.85 Jaccard bar for the diversity gate, when enabled
indicator_lag_cross_lags (1, 3) lag set for the price-scale-indicator-vs-lagged-OHLC-base feature pairing (§8); pass () to disable that pairing entirely

forge()'s own composition knobs (not DiscoveryConfig fields — orchestrator-level, §9):

Field Default Meaning
two_pass_composition True (issue #254 Phase 8) grade-guided composition after Alpha Discovery's first pass, replacing Module 1's own max_and_components-driven composition (§8, Module 2 walkthrough step 8). False reproduces the pre-#254 single-pass behaviour exactly
grade_pairing_config None → GradePairingConfig() pairing scheme/budget when two_pass_composition=True; see GradePairingConfig below

GradePairingConfig (forgedge.composition, only read when two_pass_composition=True):

Field Default Meaning
max_components 2 3 also composes triples (#254 Phase 4)
adjacency {"A": ("A","B"), "B": ("B","C"), "C": ("C","D")} which grades a root grade may pair with — symmetric
per_stratum_pair_cap 100 guaranteed minimum representation per grade stratum (round-robin, not a hard ceiling)
per_stratum_triple_cap 50 same, for triples
include_singles_in_pass2 True pool the original 1D candidates alongside composed ones for pass 2
max_constituent_jaccard None forwarded to ANDComposer.compose() — reject a pair whose constituents overlap too much

GateParams (the Consistency Gate):

Field Default Meaning
min_tpm 0.5 minimum average triggers per month (unit depends on event_counting)
max_dispersion 1.5 maximum allowed Index of Dispersion (Var/Mean of monthly counts) — "bar" mode only; unread in "episode" mode (#205)
dispersion_margin 1.3 "episode" mode only: multiplier over the Poisson χ² floor (eff = margin x floor), not an absolute ID — unread in "bar" mode (#205)
event_counting "episode" "episode" counts maximal runs of consecutive activations; "bar" counts every individual bar (§15)
min_episodes 10 absolute floor on episode count, "episode" mode only — implies an in-sample discovery window depending on min_tpm too (m1_is_window_too_short, #206)
episode_gap 1 max bar-gap that still belongs to the same episode
tpm_mode "floor" "ranged" turns min_tpm into the centre of a band instead of a one-sided floor — opt-in, "episode" mode only, §15
tpm_tolerance UNSET half-width of the "ranged" band; left UNSET, derived from the session's own dispersion tolerance — see §15

AlphaConfig (Module 2)

Field Default Meaning
horizon_grid (1,2,3,4,6,8,12,16,24,36,48) hourly-calibrated — see the daily-data pitfall in §21
train_ratio 0.7 IS/OOS split for Alpha Discovery's own confirmation
embargo_bars 0 opt-in extra OOS buffer, §15
horizon_enrichment (0.5, 1.0, 2.0) on by default; adds horizons around each event's own dominant window, §15
thresholds PromotionThresholds() statistical thresholds that drive the grade, not a hard gate (except direction)
fee_per_side 0.002 recorded for Rule Discovery and charged by it — not applied here (M2 does not net fees out), but it is the same value, no longer an independent copy
target_mode "proj" excess-over-trend scoring by default for long events, §15
trend_sma_mult 2.0 trend SMA window multiplier for target_mode="proj"
use_stable_regime_only False opt-in, restricts regime analysis to regime_stable=True bars

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 (the last two, §15, only matter when require_significant_direction=True). Only direction=="undetermined" blocks promotion — every other threshold here informs the A–D grade.

RuleDiscoveryConfig (Module 3)

Field Default Meaning
base_params BacktestParams() seed operating point
grid auto-built search grid around the contract's derived target when left empty
walk_forward RuleWalkForwardConfig() n_splits=4, min_train_months=6, purge_bars=None (→ horizon under test)
criteria SelectionCriteria() verdict gates — see §15
entry_mode "auto" "auto"/"market"/"limit", §15
selection_mode "walk_forward" operating point chosen inside train windows only; "full_sample" is the legacy behavior

BacktestParams defaults: 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.

SelectionCriteria defaults you're most likely to touch: min_profit_factor=2.0, min_win_rate=0.55, min_pf_score_tpm=0.30, min_fill_rate_opt=0.80 (these four are preset-parametrized by forge_preset() — stricter on "sniper", looser on "sweep" — see §16, #207), min_tpm=2.0 (the sole frequency gate — the executed-trade floor is max(10, n_months × min_tpm), not a fixed count), min_fill_rate=0.40, min_dsr=1.0, max_rotation_p=0.05, power_gate=True (§15), early_elimination=True (set False to force full diagnostics even on a fast-screened NON-EDGE).

RegistryConfig (Module 4)

overlap_threshold=0.70 (Jaccard dedup bar), cross_pf_threshold=1.5 + min_cross_pf_retention=0.8 (the two halves of the cross-ticker PASS — absolute floor, and fraction of the home PF retained), generic_ratio_threshold=2/3 (pass this as 2/3, not 0.67 — a rule passing exactly 2-of-3 tickers has ratio 0.6666..., which clears >= 2/3 but not >= 0.67; the docs flag this precision issue explicitly), export_format="excel".

Configuring via presets instead

Rather than assembling DiscoveryConfig/AlphaConfig/RuleDiscoveryConfig by hand, forge_preset(preset, timeframe, asset, train_ratio=0.70, **overrides) returns all three pre-tuned for a named search profile:

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 Profile
"sniper" Rare, regular, high-precision events, simple rules. Needs a long IS window (≥2 years on 1D). Do not pair with the rotation calibrator (too few events to calibrate against).
"balanced" Moderate frequency, sensible default for most assets/timeframes.
"sweep" Wide, permissive search — designed to pair with rotation_calibration=RotationConfig(k>=100) and a min_lift filter downstream.
"burst" Time-concentrated events (regime-change, momentum). High dispersion explicitly tolerated.

overrides accepted by name: M1 side — min_tpm, max_dispersion, dispersion_margin, min_episodes, max_and_components, timestamp_col, event_counting; M2 side — min_lift, min_cohens_d, fdr_q, oos_max_p, horizon_grid, bars_per_day; M3 side — rd_min_tpm, min_profit_factor, min_win_rate, min_pf_score_tpm, min_fill_rate_opt. An unrecognized override key raises TypeError.


11. Error Handling

forgedge has no custom exception hierarchy — every raised error is a plain Python built-in (ValueError, KeyError, RuntimeError, TypeError, ImportError, FileNotFoundError). There is nothing forgedge-specific to catch selectively; catch the built-ins.

Validation philosophy

There is no single up-front schema validation of the KPI Table. forge() itself does not call summary_report or check columns before starting — validation is lazy and distributed: each module validates the specific columns/timestamp source it needs, exactly when it needs them, and fails fast with ValueError/KeyError if they're missing. The one deliberate exception is kpi_builder.build_features(), which silently skips (with a logger.warning, not an exception) any indicator whose required input columns aren't present — so OHLC-only candles are always safe to pass in, even against a config that also asks for volume-based indicators.

summary_report() (§9) is the library's answer to "I want to validate before I commit to a run" — but it is entirely opt-in: it never raises, never blocks, and is never called automatically. If you want a hard stop on bad data, you write that check yourself:

rep = summary_report(kpi, return_report=True, verbose=False)
if rep.has_critical:
    raise ValueError(f"Fix data issues first: {rep.one_line()}")

Common exceptions, verified

These are not paraphrased — each was actually triggered and its message captured.

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 defaults to True (issue #254 Phase 8) and requires
# max_and_components <= 1 on the event_discovery_config it's given —
# composition happens in exactly one place, not both:
# 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().

This "call .run() first" RuntimeError pattern is consistent across MarketContext.distribution(), EventDiscovery.summary(), AlphaDiscovery.summary()/.promoted_contracts(), RuleDiscovery.grid_summary(), and TargetOptimizer.validate_oos()/.discover_alpha() — if you see it, you called an accessor before the corresponding .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'

This one is worth calling out specifically: min_act/min_months/max_conc were fields of an older GateParams API. Several of the scripts under examples/ in this repository (alpha_discovery_usage.py, extended_usage.py, kpi_table_1d.py, search_rotation_calibration.py, lowfreq_null_diagnostic.py, lowfreq_endpoint_diagnostic.py) still construct GateParams this old way and will raise this exact TypeError if you run them against the current library version, as installed in this repository. This manual flags it explicitly rather than silently working around it: if you copy code from those example scripts, translate GateParams(min_act=..., min_months=..., max_conc=..., min_tpm=...) to the current fields (min_tpm, max_dispersion, event_counting, min_episodes, episode_gap — §10). examples/kpi_builder_usage.py does not have this problem — it was verified to run correctly end to end against the current API (§13).

Summary table of what raises what

Exception Typical trigger
ValueError invalid enum-like string (direction, target_mode, buy_type, entry_mode, selection_mode, threshold_mode, timeframe, preset, …), out-of-range numeric config field, mutually-exclusive forge() arguments, two_pass_composition=True with event_discovery_config.max_and_components>1, mismatched contract/candidate pair passed to RuleDiscovery
KeyError a required column is missing — OHLC columns, timestamp_col, source_col, an unknown candlestick pattern name
RuntimeError an accessor called before .run()
TypeError wrong input type to build_features/lag_features, or an unrecognized forge_preset(**overrides) key
ImportError load_kpi_config() called with a YAML path but PyYAML isn't installed
FileNotFoundError load_kpi_config() given a path that doesn't exist

Warnings you should not ignore

forgedge uses warnings.warn (not exceptions) for situations that are valid but probably not what you meant:

Degraded-but-non-fatal behavior worth knowing

Some conditions are neither errors nor warnings — they change behavior silently, logged only at INFO/DEBUG or recorded as a diagnostic string:


12. Progressive Use Cases

Use Case 1 — Hello World: discovering one event

Already covered in full in §6. The paradigm to take away: Event Discovery finds structure without ever looking at what happens next. Everything else in the library builds on top of that one event.

Use Case 2 — A realistic single-asset workflow, from raw candles

A more complete scenario: you have raw OHLCV candles (not yet a KPI Table), you want indicators computed for you, and you want to run the full statistical pipeline through Alpha Discovery. This is examples/kpi_builder_usage.py from this repository, verified to run correctly, unmodified, against the current library:

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

# candles: a DataFrame with open_time (epoch ms), open, high, low, close, volume
summary_report(candles, timeframe="1D")   # opt-in pre-flight check, never blocks

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 candidates = {len(result.candidates)}  M2 promoted = {len(result.promoted)}")

Verified output (running this script as-is, on 2000 bars of synthetic candle data generated by the script itself):

build_features  : (2000, 21)  (base indicators + open_dt + color)
candle_features : (2000, 27)  (+ body, upper_wick, lower_wick, close_pos, range_pct, gap)
lag_features    : (2000, 42)  (+ 15 *_prev_NN columns)
forge(kpi)      : M1 candidates = 5015  M2 promoted = 1091

Interpretation: build_features turned 6 raw columns into 21 (the requested indicators plus open_dt and color). candle_features added 6 scale-free geometry columns. lag_features added 15 shifted copies. The resulting 42-column KPI Table produced over 5000 event candidates and just over a thousand promoted contracts — on synthetic random-walk data, which is itself an instructive result (§16, §21): a random walk should produce a large number of statistically "significant-looking" but economically meaningless candidates, which is exactly why Rule Discovery and the rotation null exist downstream.

Use Case 3 — Real repository data: ADA and AMZN

Covered fully in §7 (ADA quick start) and §13 (both datasets in depth, including the AMZN raw-CSV cleaning workflow).

Use Case 4 — Advanced configuration

Four configuration axes you're likely to actually need to change, each shown as a real diff from the default:

a) Loosen the Consistency Gate for a shorter or lower-frequency dataset.

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

# Default GateParams(min_tpm=0.5, dispersion_margin=1.3) is already fairly
# permissive; raising min_tpm trades away rare/marginal events for
# statistical power per event (see §16's frequency-vs-selectivity trade-off).
# `max_dispersion` is not the dispersion knob here — `event_counting`
# defaults to "episode", where `dispersion_margin` governs instead (#205).
config = DiscoveryConfig(gate_params=GateParams(min_tpm=1.5, dispersion_margin=1.8))
ed = EventDiscovery(kpi, config=config)

b) Switch Rule Discovery's entry model from limit to market orders.

from forgedge import RuleDiscovery, RuleDiscoveryConfig

# "limit" (default) can suffer a "fill confound": a deep, rarely-filled limit
# can show an inflated PF on a small, non-representative subset of trades.
# "market" isolates the signal's edge from the entry mechanism entirely.
config = RuleDiscoveryConfig(entry_mode="market")
resp = RuleDiscovery(event_frame, contract, candidate, config=config).run()

c) Run the full, sampled rotation calibrator instead of the default fast one — with the "sweep" preset, as the docs recommend pairing them.

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))   # supersedes fast_null
promoted = result.alpha_discovery.promoted_contracts(min_lift=0.05)

d) Switch Alpha Discovery's binary-target scoring from trend-excess ("proj", the default) to absolute return ("abs").

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

For each: what changes, why it exists, and the cost of changing it are covered per-item in §15 (opt-in behaviors) and §16 (trade-offs) — this section is the "how," those are the "why."

Use Case 5 — Errors and problematic data

Invalid data: an empty table.

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 never raises — it reports. If your application needs a hard stop, you write it: if rep.has_critical: raise ValueError(rep.one_line()).

A genuinely stale, incompatible configuration (a real bug you can reproduce today).

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'

This is the old GateParams API, which several of this repository's own examples/*.py scripts still use. See §11 for the full explanation and which scripts are affected.

Mutually exclusive configuration.

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.

The most common silent "no results" trap: direction="undetermined".

This isn't an exception — it's a contract with status="REJECTED" and one specific rejection reason. It's the single hard rejection gate in Alpha Discovery (§8, §9), and by far the most common reason a promising-looking event never becomes a usable rule. It happens when no horizon in the grid produces a finite, sign-determined average forward advantage — commonly because:

rejected = [c for c in result.contracts if not c.promoted]
for c in rejected[:3]:
    print(c.event_candidate_id, c.rejection_reasons)
# every rejected contract's rejection_reasons contains "no derivable target"
# — the only hard-gate rejection reason this module produces

A non-conforming feature-column name.

kpi["my_custom_signal"] = ...   # doesn't match {base}_{indicator}_{period}

This does not raise or warn. my_custom_signal still works as a standalone Event Discovery feature — it simply never gets paired into a ratio/spread feature with another column (§9). If you expected to see ratio_my_custom_signal_something in the candidate list and it's not there, this naming convention is the first thing to check, not a bug report.

Use Case 6 — A realistic application: a monitoring service around a discovery session

This sketches how a session's output becomes something a larger system consumes, using only real, verified forgedge calls (the "wrapping" code — MonitoringService, the storage class — is illustrative application code, not part of the library; §19 is explicit about that boundary).

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

class MonitoringService:
    """Wraps one forge() discovery session and exposes it for ongoing monitoring."""

    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(): one spec per tradeable (EDGE/PARTIAL-EDGE) rule,
        # each carrying the params + candidate needed to replay it later.
        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:
        # Replays every published rule deterministically on fresh_candles via the
        # same EventCandidate.apply() path Rule Discovery itself uses — fresh_candles
        # need not be the discovery table.
        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())} published rules: {svc.published_rules()[:3]} ...")
html = svc.health_report_html(kpi)   # in production: discovery table + genuinely new bars

Verified output on the ADA fixture: 40 published rules: ['RULE_ADA_01', 'RULE_ADA_02', 'RULE_ADA_03'] ... (matches result.edges()'s current count, §7/§9), and a generated HTML report of ~5 MB (inline SVG charts, no external resources — safe to store or email as a single file). RuleSpec names follow the same RULE_{TICKER}_{NN} convention Module 4 uses internally, even when you're not going through RuleRegistry at all.


13. Working with the Data in This Repository

Two real datasets ship with this repository, plus one derived artefact. Neither is a toy — both are genuine market data.

tests/fixtures/ADA_1D_TRAIN.parquet

882 daily OHLCV bars of ADAUSDC (Cardano), 2024-01-01 to 2026-05-31, with 22 additional precomputed indicator columns already attached: close_ret_{03,12,96} (returns), Bollinger Bands (close_bb_{mid,upper,lower,width}_20), a rolling max-drawdown (close_mdd_48), EMAs at two windows on both close and low (with three lagged copies each, _prev_01..03), and rolling volatility (close_vol_{05,12,24}). This is the exact fixture the library's own tests/test_golden.py regression suite pins its expected values against — meaning if you run forge() on it with the arguments shown in §7, you are reproducing part of the library's own test suite by hand.

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

No preprocessing needed — this table is already a valid KPI Table (has close, has open_dt as datetime64[ns], chronologically sorted). This is the dataset used throughout §7 and §12's Use Cases 4–6.

examples/data/AMZN_1D.csv

1378 daily bars of AMZN (Amazon), an export from a financial-data provider in a common but not directly forge()-compatible raw format:

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%

Note what's wrong with this table, from forgedge's point of view: the close price is called "Price", not "close"; volume is a string with a unit suffix ("31.50M"); dates are descending (most recent first) and stored as MM/DD/YYYY strings, not a datetime64 column or index. This is realistic, and it is exactly the kind of table summary_report is meant to catch problems in before you waste a forge() run on it. The repository's own worked walkthrough (examples/forge_amzn_walkthrough.ipynb), which this manual verified cell-by-cell, shows the necessary cleaning:

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)   # ascending order — required

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"]]

After cleaning, the notebook runs summary_report(candles, timestamp_col="open_dt", timeframe="1D", return_report=True) and — this is worth quoting directly because it is a genuine, real finding, not a manufactured teaching example — it surfaces a real anomaly: (translated from the notebook's own markdown) "the last bar of the dataset (2026-06-30, 'today's bar' at the moment of extraction) has open slightly below the recorded low — a typical symptom of a still-incomplete bar (the market session was still open when the CSV was downloaded)." This is a genuinely useful, real-world illustration of why you run summary_report before, not after, a discovery session: an incomplete trailing bar is not a reason to distrust your whole dataset, but it is a reason to consider dropping that one row before running Event Discovery, since a partial bar's OHLC relationship violates the assumption every other bar in the table satisfies.

The notebook then proceeds through build_features() (with atr/macd explicitly enabled, unlike the demo config in Use Case 2), forge_preset("sweep", ...), and a full forge() call, landing — on real AMZN data — at exactly 2 PARTIAL-EDGE signals and zero full EDGE, out of 3035 M1 candidates and 508 M2 promoted contracts. The notebook's own interpretation of this (§14 covers the "why" in depth) is explicit and worth quoting: (translated) "The honest result: almost no edge, and that's the right outcome."

kpi_table_1d.csv (repository root)

A pre-generated output artefact, not an input dataset — it's what examples/kpi_table_1d.py produces after comparing FORGE's own event-discovery path against the standalone TargetOptimizer (§9) on ADA daily data across three time windows (2024 in-sample, 2025 and 2026 out-of-sample). Its 19 columns (ID, Pipeline, EVENT_ID, EXPRESSION, IS 2024_WR, IS 2024_PF, ...) are a worked example of comparing two discovery strategies' out-of-sample decay side by side — useful to read as a reference output shape, not something you need to regenerate to use this manual.

Notebooks

notebooks/01_event_discovery.ipynb through 06_rule_registry.ipynb walk each module individually and in depth; notebooks/hurst.ipynb is a dedicated deep-dive on the Hurst/OU half-life estimation Market Context uses for automatic EMA window selection (§8). examples/forge_amzn_walkthrough.ipynb (used throughout this section) is the only one built entirely around a dataset shipped in this repository and is the closest thing to a canonical, fully-reproducible, real-data walkthrough.


14. Design Choices

This section distinguishes three categories throughout, marked explicitly: (documented) — the authors' own stated reasoning, quoted or closely paraphrased from repository documentation; (measured) — a real, reported result from a run on real data, not an invented benchmark; (inferred) — this manual's own reading of the code, clearly flagged as such, never presented as a fact the authors stated.

The three-domain separation is architectural, not conventional (documented)

Already introduced in §2. The repository's architecture guide is explicit that this is a constraint, not a style choice, and gives the specific reason: keeping the forward return out of Event Discovery "elimina una categoria intera di look-ahead bias" — eliminates an entire category of look-ahead bias, by construction, rather than by discipline.

"Rigidity upstream buys freedom downstream" (documented)

A trade-off stated explicitly, in the authors' own words, in src/forgedge/docs/README.md: (translated) "Upstream rigidity is the guarantee that what reaches Alpha Discovery is a genuine measurement — not an artefact of optimization against the target. It buys the freedom to explore freely downstream, because the underlying thresholds were never contaminated by any economic choice." This is the rationale for why Event Discovery's thresholds are immutable: the cost (you can't tune a threshold after the fact, even if you're sure a slightly different one would work better) is what makes everything downstream of it trustworthy.

Statistical measures inform the grade; only direction gates promotion (documented)

Also explicit in the same source: statistical weaknesses in Alpha Discovery "feed the grade, they don't gate/reject — Rule Discovery is the sole economic judge." This is a genuinely non-obvious design choice worth dwelling on: a naive implementation would reject a candidate for weak IC or low lift. forgedge instead lets everything with a determined direction through to Module 3, and treats the statistical weaknesses purely as information that (a) lowers the letter grade and (b) shows up verbatim in rejection_reasons — even on contracts that were promoted. The result, verified in §7: the ADA quick-start's best PARTIAL-EDGE candidate carries a grade of A and a list of non-blocking diagnostics, alongside the one gate (rotation null) that actually capped its verdict.

Anti-goals — what the authors explicitly decided not to build (documented)

The functional-analysis document (docs/analysis/forge2_functional_analysis.md) states four rejected directions with reasons, verbatim (translated):

The rotation null exists because the library's own audit found its promotion counts were unreliable at low frequency (documented + measured)

This is the most important design decision to understand if you're going to trust forgedge's output, and it is backed by real numbers the authors report from their own audit (docs/analysis/lowfreq_robustness.md, on ADA_1D_FULL.parquet, 901 daily bars):

"REAL ADA promoted 58 alphas from 2542 candidates on a 2024 in-sample window; phase-randomized noise (5 runs, same recipe, same statistics, autocorrelation preserved but any real predictability destroyed) promoted 146 ± 45 (range 101–207). Noise is promoted more often than the real asset."

And, one stage further downstream, at the level of tradeable EDGE/PARTIAL-EDGE verdicts:

"Pure noise still earns ~2–3 EDGEs out of the top 12 tested (~20% per-rule false-positive floor)."

This is the authors' own diagnosis of a real weakness in a version of the pipeline before the rotation null existed as a default. The fix — FastRotationNull, on by default since — is described in the same document with its own measured cost and effect: "computes the exact search-level rotation null over every circular offset (FFT cross-correlation, ~1 s on this dataset — no K, no seed)... On this ADA dataset the search p is ≈ 0.70: every former EDGE is honestly capped at PARTIAL-EDGE." That last sentence is a genuinely striking piece of self-critical, verified reporting: the authors ran their own fix against their own best-looking prior results and reported that it downgraded every one of them.

The design was chosen to keep the user-facing contract identical while fixing internal honesty (documented)

The functional-analysis document frames this explicitly as the guiding constraint of the whole redesign: (translated) "starting from scratch, what should be kept vs. redesigned — without changing the contract with the user?" And the closing synthesis states the diagnosis plainly: (translated) "the system counts its own evidence very well inside each module, but nobody counts the evidence of the entire chain — and on slow data this turns multiple testing into EDGE verdicts that pure noise can replicate one time in five." The practical upshot for you as a user: every default described in §15 below that sounds like it's making the pipeline more conservative (the rotation null, the power gate, purged time splits) exists specifically to close that gap, and every one of them was added without changing forge()'s call signature in a breaking way.

Why the horizon grid needed to become timeframe-aware (documented + measured)

lowfreq_robustness.md identifies this as one specific, code-referenced weak point: "horizon_grid is not frequency-scaled... forge(..., timeframe="1D") uses it verbatim → holding periods up to 48 days. Unlike MarketContext / Hurst / rolling-IC / bars-per-year, which do auto-scale, the horizon grid is a silent footgun." This is why §7 of this manual spends a paragraph on the fact that forge() substitutes a daily grid for you — it's a fix for a documented, previously-real bug, not a cosmetic feature.

Why deduplication only flags, never deletes (documented)

src/forgedge/docs/README.md gives a specific, non-obvious reason two structurally overlapping rules might both be worth keeping: (translated) "A pair flagged INDEPENDENT_CONFIRMATION is not a duplicate to discard — it's independent confirmation of the same edge via two different mechanisms." (Note: the two-level classification this passage describes — DUPLICATE_STRUCTURAL/DUPLICATE_BEHAVIORAL/INDEPENDENT_CONFIRMATION — is listed in the repository's own roadmap as not yet implemented; the current, shipped behavior is the simpler binary is_duplicate flag described in §8/§9. This manual flags that gap explicitly rather than describing a planned feature as current.)


15. Opt-in Behaviors

This is the section the task description asked to treat with particular care. "Opt-in" here means specifically: a behavior that does not happen unless you explicitly set a non-default value. Several defaults below (the rotation null, purging, the power gate, horizon enrichment) are themselves on by default and must be explicitly turned off — these are flagged as "default-on, opt-out" rather than miscategorized as opt-in.

Feature Default How to enable Benefit Cost / trade-off When to use it
Event-level walk-forward OOS (DiscoveryConfig.walk_forward) None (off) DiscoveryConfig(train_ratio=0.8, walk_forward=EventWalkForwardConfig(n_splits=3, min_pass_rate=0.6)) Confirms an event's temporal structure — not yet its predictive power — is stable across multiple OOS windows before you even reach Alpha Discovery Fewer bars available for Event Discovery's own IS mining; adds Module 1 runtime You suspect your indicator catalog might be overfitting to one regime and want a first-pass filter before the (much more expensive) Alpha/Rule Discovery stages
Diversity Gate (DiscoveryConfig.diversity_gate_enabled) False DiscoveryConfig(diversity_gate_enabled=True, diversity_threshold=0.85) Drops near-duplicate single events (Jaccard ≥ threshold on activation dates) before AND composition, so composed candidates aren't wasted on redundant pairs Can discard a genuinely distinct event that happens to correlate structurally with another on this specific window; adds an O(n²)-ish comparison pass over gate-passing singles Large indicator catalogs where you've observed many near-identical candidates surviving the gate
event_counting="bar" (GateParams) "episode" GateParams(event_counting="bar") Reproduces the pre-#134 (episode-counting) gate behavior exactly, bar for bar Persistent multi-bar states (e.g. a 3–5 bar RSI<30 stretch) inflate monthly-count variance and can get wrongly rejected — this is the documented reason "episode" became the default You need byte-for-byte reproducibility with a pre-episode-counting session, or you have a specific reason to weight by raw bar count
tpm_mode="ranged" (GateParams) "floor" GateParams(tpm_mode="ranged") ("episode" counting only — raises ValueError under event_counting="bar") min_tpm becomes the centre of a band ([min_tpm - tolerance, min_tpm + tolerance]) instead of a one-sided floor, so an event that fires too often is rejected too, not just one that fires too rarely — useful for isolating "regular-cadence" patterns and screening out near-degenerate, almost-always-active conditions tpm_tolerance left UNSET derives the half-width from the session's own dispersion tolerance (docs/analysis/ranged_tpm_and_market_alignment_proposal.md §2) — a session with a short history or a low min_tpm gets a wide band, which can admit events a floor-mode gate would have rejected as too rare; an event with n_episodes == 0 is always rejected regardless of the band You want events at roughly a target frequency, not "at least" one — e.g. calibrating a specific expected trade cadence rather than maximizing candidate volume
require_significant_direction=False (PromotionThresholds) True (default-on, opt-out) AlphaConfig(thresholds=PromotionThresholds(require_significant_direction=False)) Restores the legacy behavior: argmax\|z_h\| always assigns a direction (subject only to min_direction_t), regardless of whether any statistical test found the horizon distinguishable from the rotation null — statistically_weak still flags thin evidence, but it never blocks The default (True) is itself a real behavior change from before this was added: a candidate now needs either an individually BH-FDR-significant horizon (h_sig) or a significant whole-grid AUC test (p_auc < auc_max_p, §9) to get a direction at all — turning this off reopens the "coin-flip on a noisy peak" risk that gate exists to close Reproducing pre-AUC-gate results, or a debugging session where you want to see what argmax\|z_h\| would have picked regardless of significance
target_mode="abs" (AlphaConfig, TargetConfig) "proj" AlphaConfig(target_mode="abs") Scores the binary target on raw forward return, not excess-over-trend — simpler, matches "textbook" backtest conventions "proj" exists specifically so a long event riding a bull-market drift isn't credited with the market's own trend as if it were the event's edge; "abs" reintroduces that risk Comparing against a pre-"proj" baseline, or working with an asset/period where trend-following is explicitly the strategy you want measured, not filtered out
AlphaConfig.embargo_bars / RuleWalkForwardConfig.embargo_bars 0 AlphaConfig(embargo_bars=5) Adds a serial-correlation quarantine buffer at the start of the OOS window, beyond what purging alone removes Shrinks the OOS sample further, which — combined with an already-thin daily-data OOS tail — can push a contract toward INSUFFICIENT-DATA Assets with known strong short-lag autocorrelation beyond what the purge width (= max horizon) already accounts for
RotationCalibrator (explicit, via rotation_calibration=) None (superseded by the default FastRotationNull) forge(..., rotation_calibration=RotationConfig(k=100)) Multi-yardstick (composite, lift, t-stats) calibration via a Tippett min-p combination — catches discriminating statistics FastRotationNull's single abs_z yardstick can miss ~K × the cost of one Alpha Discovery pass (roughly seconds-to-minutes per draw on real data, per the design doc's own measurement of "~4 s/draw") vs. FastRotationNull's ~1 second total Paired explicitly with the "sweep" preset; whenever you want the full report object, not just the pass/fail annotation fast_null leaves on each contract
fast_null=False fast_null=True (default-on, opt-out) forge(..., fast_null=False) Skips the default rotation-null check entirely — faster, and a would-be-capped contract can reach a full EDGE verdict Reintroduces exactly the "noise gets promoted as often as signal" problem §14 documents with real numbers — this is not a performance knob to flip casually Debugging/prototyping only, or when you're going to run the full RotationCalibrator separately anyway and don't want the fast pass's annotation
time_budget= (explicit TimeBudget) None — but purging is still on by default even without it forge(..., time_budget=TimeBudget.build(n_bars=len(kpi), horizon_bars=48, embargo_bars=5)) One shared, explicit IS/OOS axis across Event and Alpha Discovery, with a controllable embargo More to configure correctly; get the horizon_bars argument wrong and the purge width is wrong too Multi-module pipelines built by hand (not via forge()) where you need Event and Alpha Discovery's splits to agree exactly
purge_bars=0 (opting out of the default-on purge) purge width = max(horizon_grid) (default-on) TimeBudget.build(..., purge_bars=0), or RuleWalkForwardConfig(purge_bars=0) for M3 Reproduces pre-purging numeric results exactly (useful for comparing against old runs, or the library's own older golden-test values) Reintroduces a real, if usually small, look-ahead: IS bars whose forward window crosses into OOS are no longer excluded Only when you specifically need historical reproducibility, not for normal use
power_gate=False (SelectionCriteria) True (default-on, opt-out) RuleDiscoveryConfig(criteria=SelectionCriteria(power_gate=False)) A verdict that would otherwise be demoted to INSUFFICIENT-DATA for lacking OOS statistical power is allowed through as EDGE/PARTIAL-EDGE You lose the pipeline's own signal that the OOS evidence is too thin to trust the point estimate — a contract can look tradeable purely because nobody checked whether the sample size could support the claim Essentially never for a live decision; only for inspecting what the un-gated verdict would have been
entry_mode="limit" (opting out of the default two-stage evaluation) "auto" (default since #185) RuleDiscoveryConfig(entry_mode="limit") The grid optimises buy_drop_pct as part of the verdict, so a strategy whose edge is the limit order itself is measured as one thing rather than split into signal + execution Reintroduces the fill confound into the verdict: a deeper discount fills only on the paths that came back to it, so the PF rises on a subset that is not the tradeable population When the limit order genuinely is the strategy. For a signal you are trying to measure, "auto" separates the two readings and still publishes the limit point when it earns it
selection_mode="full_sample" "walk_forward" RuleDiscoveryConfig(selection_mode="full_sample") Restores the legacy behavior: the operating point is chosen by screening the entire table, not just walk-forward train windows The IS profit factor and the published operating parameters can be influenced by data that later becomes part of the OOS test window — a documented, real leak the walk-forward mode exists to close Legacy compatibility only; the documented reason to avoid it in new work is explicit in the source
AlphaConfig.horizon_enrichment=None (opting out) (0.5, 1.0, 2.0) (default-on) AlphaConfig(horizon_enrichment=None) Restricts Alpha Discovery strictly to the base horizon_grid, with no per-event additions On the authors' own measurement, 34 of 247 promoted alphas on the ADA dataset found their best horizon only because of this enrichment — turning it off would silently lose those Reproducing a pre-enrichment baseline, or when you have a specific reason the base grid must not be extended
pattern_features() (a separate function call, not a config flag) not called from forgedge import pattern_features; kpi = pattern_features(kpi) Adds a single categorical candle_pattern column (ten named formations: HAMMER, DOJI, engulfing patterns, …) that flows end-to-end through forge() as one-hot events Named patterns encode fixed, human-chosen thresholds; candle_features()'s continuous geometry is preferred for automatic discovery specifically because FORGE derives its own asset-adaptive thresholds instead Manual/exploratory work where you specifically want to test named-pattern hypotheses, not for the default automatic-discovery path
use_stable_regime_only=True (AlphaConfig) False AlphaConfig(use_stable_regime_only=True) Regime-sensitivity analysis (Step 5 of Alpha Discovery) only considers bars where the regime has held for ≥ stable_window bars — cleaner per-regime statistics Fewer observations per regime, which can push min_regime_obs (default 10) out of reach for less common regimes You've observed regime-transition bars contaminating the per-regime breakdown with noise from the previous regime
early_elimination=False (SelectionCriteria) True RuleDiscoveryConfig(criteria=SelectionCriteria(early_elimination=False)) Forces the full walk-forward and diagnostics pipeline to run even on a rule that fails the fast in-sample screen — useful for uniform reporting across NON-EDGE rules too Meaningfully more compute per rule (the whole point of early elimination is to skip that work on rules that are already going to fail) Auditing/reporting scenarios where you want every rule's OOS behavior populated, not just the survivors'

16. Trade-offs

These are the trade-offs actually visible in the code and the design documentation — not a generic list.

Automation vs. control. forge() makes a large number of decisions for you by default (horizon grid scaling, the rotation null, purging, horizon enrichment) specifically so a "quick start" run and a "carefully tuned" run don't silently diverge in statistical honesty (§14). The cost is that a first-time reader genuinely cannot predict forge()'s full behavior from its call signature alone — §7's "what forge() did that you didn't ask for" list exists because that gap is real, not because this manual is being unusually thorough.

Frequency vs. selectivity, in the Consistency Gate. The library's own calibration analysis (docs/analysis/search_rotation_calibration.md) reports this explicitly, with real numbers, as a genuine trade-off rather than a bug to fix: raising min_tpm from 1.5 to 3.0 dropped candidates mined from 2621 to 584 and dropped the best real alpha's lift from 0.707 to 0.287 — but the surviving candidate went from 9 in-sample activations (which failed OOS: win rate 0.78 IS → 0.40 OOS) to 29 activations (which passed OOS, p=0.000). Their own words: (translated) "higher min_tpm lowers lift... you trade 'extreme but fragile' for 'modest but confirmable'." There is no default value of min_tpm that is simply "correct" — it's a dial between "rare events with dramatic-looking but statistically fragile edges" and "frequent events with modest but well-confirmed edges," and which end of it you want depends on how much in-sample data you actually have.

Statistical rigor vs. wall-clock cost. The default FastRotationNull was specifically engineered to make this trade-off nearly free (≈1 second, per the design doc's own measurement, vs. the RotationCalibrator's ≈4 seconds per draw at K=100) — but it pays for that speed by covering only one yardstick (abs_z), where the full calibrator covers several combined via Tippett's method. If abs_z isn't the statistic that would have discriminated your particular data (the calibration doc shows this switches depending on min_tpm, i.e. depending on your own configuration), the fast null's single-yardstick coverage is a real, not hypothetical, gap.

Eager vs. lazy validation. There is no single schema-validation pass over your KPI Table (§11). This buys you the ability to pass an arbitrary, evolving table without pre-registering its schema anywhere — but it means a malformed column surfaces as a KeyError/ValueError potentially several modules deep into a forge() call, rather than at the door. summary_report() exists specifically to let you opt into eager validation when you want it, without forcing it on every call.

Reproducibility vs. performance in Rule Discovery. SelectionCriteria.early_elimination=True (default) discards a rule from further processing the moment it fails a cheap in-sample screen — this is a real performance win (the whole point is to avoid running walk-forward and full statistical validation on rules that are already going to fail), but it means NON-EDGE rules by default carry incomplete diagnostics (no populated walk_forward). §15's early_elimination=False entry is exactly the escape hatch for when uniform reporting matters more than speed.

Purity of dependencies vs. reimplementation risk. The explicit anti-goal of "no external statistical dependencies" (§14) means Spearman correlation, the t-test, the incomplete beta function, and Benjamini-Hochberg FDR control are all hand-rolled in numpy rather than delegated to scipy.stats/statsmodels. The stated benefit is auditability — you can read exactly what forgedge computes without stepping into a much larger external library's source. The (this manual's own, clearly inferred, not stated) corresponding risk is that these primitives don't automatically inherit scipy's decades of edge-case hardening; the extensive test suite (§18) is presumably the mitigation, though the design docs don't frame it that way explicitly.


17. Performance and Scalability

Nothing in this section is an invented benchmark. Every number is either measured and reported in the repository's own documentation, or a qualitative complexity observation drawn directly from the code.

Reported measurements (from docs/analysis/ and this manual's own verified runs):

Complexity observations from the code (this manual's own reading, not a stated benchmark):

No GPU, no distributed computing, no async I/O anywhere in the library — every module is synchronous, single-process, single-threaded numpy/pandas code. If you need to run discovery across many tickers, forge_multi() still runs them sequentially, one ticker at a time (§9); parallelizing across tickers or across Rule Discovery's per-contract backtests is squarely an application-level concern (§19–20), not something the library does for you.


18. Testing

The repository's own test suite is the best template for testing code that uses forgedge — it's substantial (586 test functions across 15 files, testpaths = ["tests"] in pyproject.toml) and its conventions are consistent enough to be worth adopting directly.

Running it

pip install -e ".[dev]"      # pytest>=7.0
pytest                        # whole suite
pytest tests/test_rule_discovery.py                          # one module
pytest tests/test_forge.py::TestForgeManualEvents             # one class
pytest tests/test_forge.py::TestForgeManualEvents::test_mutual_exclusion_raises   # one test
pytest tests/test_golden.py                                    # just the regression pins
pytest -k golden                                                # by keyword

The house style, as observed directly in the suite

python def test_events_come_from_stored_series_not_apply(self, monkeypatch): """Fast path: cached activation series must be reused, apply() must not run.""" def _boom(self, frame): raise AssertionError("Alpha Discovery recomputed an event via apply()") monkeypatch.setattr(EventCandidate, "apply", _boom) ...

Every other test builds real, seeded synthetic price series with np.random.default_rng(seed) and runs the actual pipeline code against them — there is no fake I/O to mock, because the library has none. - Deterministic, seeded, purpose-documented synthetic data is the dominant fixture idiom — nearly every test file defines its own local _make_kpi_table()/_ohlc_kpi_table()-style helper, each with a docstring explaining why that specific signal shape was chosen (e.g. "low feat predicts a positive next-bar return, so events on feat should lift the win rate"). tests/conftest.py provides exactly two shared, module-scoped fixtures (kpi_4380, kpi_8760 — synthetic hourly tables of ~6 and ~12 months) for tests that don't need a bespoke signal shape. - One real fixture file for regression testing: tests/fixtures/ADA_1D_TRAIN.parquet (§13), used by test_golden.py's session-scoped forge_result fixture, which runs forge() exactly once and derives dozens of individual field-level assertions from that single run — isolating which pipeline stage broke without re-running the pipeline per assertion. - pytest.approx(..., rel=...) is the standard idiom for pinning floats — never bare equality on a float. pytest.raises(..., match=...) and pytest.warns(...) are used consistently (~53 uses across 8 files) to pin exact error/warning messages, not just exception types. - @pytest.mark.parametrize is used exactly once in the whole suite (test_target_optimizer.py) — the codebase's own convention strongly favors one explicit, named test method per behavior over parametrized tables, even for structurally similar cases.

A worked golden-test example, illustrating the pattern

# tests/test_golden.py (structure, condensed)
@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  # pytest.approx not needed, int
    def test_mean_tpm(self, golden_candidate):
        assert golden_candidate.activation_stats.mean_tpm == pytest.approx(0.931034, rel=1e-4)

The file's own inline comments document the golden values' history: they've been re-pinned multiple times as legitimate pipeline changes landed (the episode-counting default, the timeframe-scaled horizon grid, the walk-forward selection change, the power gate). This is the intended workflow, and it's worth adopting for your own tests built on top of forgedge: a golden test breaking is expected on a legitimate behavior change — the correct response is to re-pin the value with a comment explaining why, not to assume the test (or the change) is wrong.

What to test in your own code that calls forgedge


19. Integrating forgedge into a Real Application

forgedge is a research library, and this section is explicit, throughout, about the boundary between what the library gives you and what your application must build on top of it. Nothing below is a forgedge API — it is this manual's guidance on architecture, clearly separated from §9's API reference.

What forgedge gives you

What your application must build

A minimal service sketch

This composes only real, verified forgedge calls, wrapped in illustrative (not library) application code:

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

@dataclass
class DiscoverySession:
    """Application-level wrapper: one forge() run plus what it takes to monitor it later."""
    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' is YOUR persistence layer — forgedge has none.
        # AlphaContract.to_contract_dict() / RuleDiscoveryResponse.to_dict()
        # are the JSON-serializable building blocks.
        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]:
        """Which published rules are firing on the latest bars, right now."""
        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]:
        """Re-check whether each published rule still holds, on discovery-plus-new data.
        Uses RuleDiscovery (not AlphaDiscovery) — see §21 for why that distinction matters."""
        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

This is genuinely runnable against the ADA fixture:

kpi = pd.read_parquet("tests/fixtures/ADA_1D_TRAIN.parquet")
session = DiscoverySession.run(kpi, ticker="ADAUSDC", timeframe="1D")
print(f"{len(session.specs)} published rules")     # 40
print(session.check_signals(kpi))                    # rules firing on the fixture's own last bar

20. A Production-Ready Architecture

Adapting the general pattern to what this codebase actually supports — a stateless research/discovery step, feeding a persistence and monitoring layer the library does not provide:

                     ┌───────────────────────────┐
                     │   Data ingestion service   │   ← application responsibility:
                     │  (exchange/vendor client)  │     API keys, retries, rate limits
                     └─────────────┬─────────────┘
                                   │  raw OHLCV candles
                                   ▼
                     ┌───────────────────────────┐
                     │  forgedge.kpi_builder      │   build_features / candle_features /
                     │  (feature engineering)     │   lag_features  →  KPI Table
                     └─────────────┬─────────────┘
                                   │
                                   ▼
                     ┌───────────────────────────┐
                     │  forgedge.summary_report   │   opt-in pre-flight data-quality gate
                     │  (data quality gate)       │   (application decides: block or warn)
                     └─────────────┬─────────────┘
                                   │  validated KPI Table
                                   ▼
              ┌────────────────────────────────────────────┐
              │         forgedge.forge()  (discovery)       │   scheduled job — NOT request-path;
              │  M0 Market Context → M1 Event Discovery →   │   one run per ticker per re-discovery
              │  M2 Alpha Discovery → M3 Rule Discovery →   │   cycle (weekly/monthly, not per-request)
              │  M4 Rule Registry (forge_multi for many)    │
              └─────────────────────┬────────────────────────┘
                                   │  ForgeResult (candidates, contracts,
                                   │  rule_responses, calibration, ledger)
                                   ▼
                     ┌───────────────────────────┐
                     │   Application persistence  │   ← YOUR database. forgedge has none.
                     │  (rule catalog, versioned) │     Store RuleSpec/AlphaContract dicts,
                     └─────────────┬─────────────┘     candidate .persist() pickles if needed.
                                   │
                     ┌─────────────┴─────────────┐
                     ▼                           ▼
        ┌─────────────────────┐      ┌─────────────────────────┐
        │  Monitoring worker    │      │  Review / reporting UI   │
        │  (scheduled, polls    │      │  rule_performance_report │
        │  fresh candles,       │      │  → HTML, served or       │
        │  EventCandidate       │      │    emailed to a human    │
        │  .apply() +           │      └─────────────────────────┘
        │  RuleDiscovery replay)│
        └──────────┬────────────┘
                    │  "signal active" / verdict-changed events
                    ▼
        ┌─────────────────────┐
        │  Alerting / execution │   ← entirely external to forgedge, by
        │  system (human review,│     explicit design (§2, §19)
        │  or a separate order- │
        │  management system)   │
        └─────────────────────┘

End-to-end flow, and what's library vs. application at each step

  1. Ingestion (application) → raw candles.
  2. Feature engineering (forgedge.kpi_builder — library) → KPI Table.
  3. Data-quality gate (forgedge.summary_report — library function, but the decision to block on has_critical is application logic; the library never blocks on its own).
  4. Discovery (forge()/forge_multi() — library) — this is the expensive, stateful-feeling-but-actually-pure step. It should run as a scheduled background job, not inline in a request path: §17 established there's no async I/O and no parallelism inside the library, so a forge() call blocks its calling thread for as long as Rule Discovery's per-contract walk-forward backtests take.
  5. Persistence (application) — the ForgeResult's promoted contracts, rule responses, and the ledger/calibration report are your durable record. AlphaContract.to_contract_dict() and RuleDiscoveryResponse.to_dict() give you JSON-ready dicts to store; EventCandidate.persist(path) gives a full pickle round-trip if you need to reconstruct the live object later (its .apply() method) rather than just its numbers.
  6. Monitoring (application, built on library primitives) — a separate scheduled process that, on each new batch of candles, calls EventCandidate.apply() for the "is it firing right now" flag and RuleDiscovery(...).run() (not AlphaDiscovery — §21) for "does the verdict still hold." rule_performance_report() is the library's one built-in artefact for a human-readable version of this.
  7. Alerting/execution (entirely application/external) — outside the library's scope by explicit design.

Observability, versioning, and resource management — library support vs. application responsibility

Concern Library support Application responsibility
Logging forge() logs every pipeline stage at INFO via the standard logging module regardless of the progress flag — logging.basicConfig(level=logging.INFO) surfaces it Routing those logs to your log aggregator; correlating a run's logs with its persisted ForgeResult
Progress reporting progress=True (default) prints a stage tracker and a tqdm progress bar (or a dependency-free fallback) to stderr Surfacing progress in a UI, if any
Errors Plain built-in exceptions, no custom hierarchy (§11) Catching them at the boundary of your discovery job; deciding retry vs. fail policy
Metrics None built in — HypothesisLedger/CalibrationReport are structured data you can turn into metrics yourself Exporting ledger.m2_surface, calibration.tippett_p, the verdict-count distribution, etc. to your metrics system
Timeouts None — a forge() call runs to completion or raises Wrapping the call in your own timeout/cancellation if a job scheduler requires one
Retries None — the library is deterministic given the same input, so a naive retry is safe but pointless unless the input itself was the problem Retry logic belongs at the data-ingestion layer, where transient I/O failures actually occur
Caching None — every forge() call recomputes everything from the KPI Table you pass Caching intermediate ForgeResults (or at least event_frame) keyed by input hash, if you re-run discovery on largely-unchanged data
Secrets None — the library takes no credentials of any kind API keys for your data source live entirely in your ingestion layer, never near forgedge
Versioning forgedge.__version__; the library's own golden tests demonstrate that behavior can change between versions even at fixed config (§14, §18) Pin your forgedge version; re-run your own regression tests (§18) on upgrade, expecting some pinned values to legitimately need re-pinning
Rollback None — stateless, nothing to roll back inside the library Rolling back means reverting to a previously-persisted rule catalog in your database

21. Troubleshooting

Each entry: symptom → likely cause → how to confirm → fix → how to prevent it next time.

"Zero (or almost zero) Event Discovery candidates, just \"M1 Event Discovery — 0 candidate(s)\" in the log"

"Zero promoted contracts, or every contract is direction='undetermined'"

"I ran an examples/*.py script and got TypeError: GateParams.__init__() got an unexpected keyword argument 'min_act'"

"All my verdicts are PARTIAL-EDGE, never full EDGE"

"RuleRegistry output has empty classification/is_generic columns"

"A custom feature column never shows up combined with anything"

"summary_report flags scale_mixed on data I'm confident is fine"

"RuleDiscovery.__init__ raises ValueError about a mismatched candidate/contract"

"Re-running Alpha Discovery on new data to 'check if the edge still holds' gives worse or nonsensical results"

"I set GridSpec.buy_delay_bar (or expected the grid to tune it) and the operating point never changed"

"A Sharpe or Deflated Sharpe I computed by calling validate() directly looks wrong on daily/15-minute data"

"Two identical forge() runs on identical data promote a slightly different number of contracts, or pick a different rule as best"


22. Best Practices


23. Anti-patterns


24. FAQ

Does forgedge place orders or connect to an exchange? No. It has no execution capability whatsoever, by explicit design (§2, §3, §19). It produces rule specifications; an execution system you build separately implements them.

Why did my forge() run promote a lot of contracts but end up with very few (or zero) full EDGE verdicts? This is very likely the default rotation-null check doing exactly what it's meant to do (§14, §21) — check rejection_reasons for "search-level rotation null not cleared" before assuming something is wrong.

Can I use forgedge on 1-minute or tick data? Split by data type, because the two halves of this question have different answers. 1-minute bars are still aggregated OHLCV, calibrated the same way 15m/5m are (_TFClass buckets anything under 1 hour into one calibrated class, internally named "hft", with its own horizon grid and its own — tighter — dispersion tolerance); nothing in the code singles 1-minute out as unsupported. What's genuinely untested is that no worked example, calibration analysis, or the library's own low-frequency robustness study goes below hourly-to-daily bars, so this manual cannot verify behavior there from first-hand evidence. Tick data (no bar aggregation at all) is the harder case §3's "do not use for" section describes: very short in-sample windows introduce the same statistical-power problems §16 and §21 describe for daily data, likely more acutely, and nothing in the repository tests that regime at all.

Why does AlphaConfig's class default horizon_grid look wrong for my daily data? Because it's hourly-calibrated by design, and forge() (not AlphaConfig itself) substitutes a daily-calibrated grid for you automatically when timeframe is a day or slower (§7, §9, §14). Constructing AlphaDiscovery directly bypasses that substitution.

Is the output of forge() deterministic? Given the same input DataFrame and the same configuration, yes — there's no randomness in Event/Alpha/Rule Discovery's own logic. RotationCalibrator (not the default FastRotationNull, which is exact/exhaustive) uses a seeded RNG (RotationConfig.seed, default 20260624) for its sampled draws, so it too is reproducible given the same seed.

Does forgedge persist anything between runs? No — explicitly and by design (§14's anti-goals, §19). Every forge() call is a pure function of its input; persistence is entirely your application's responsibility.

What's the difference between an EventCandidate, an AlphaContract, and a RuleDiscoveryResponse? They're the outputs of Modules 1, 2, and 3 respectively (§4, §8) — a boolean condition with no economic meaning yet; the same condition plus a derived economic target and a statistical grade; and finally a backtested, walk-forward-validated verdict. A RuleSpec (§9, §12) is a fourth, lighter-weight object that bundles what you need to replay a tradeable rule later.

Why is GateParams's default min_tpm so low (0.5)? It's deliberately permissive so the Consistency Gate doesn't discard genuinely rare-but-real events before they even reach Alpha Discovery's much more powerful statistical checks (§10, §16). If you find Event Discovery producing too many low-quality candidates, raising min_tpm is the intended lever (§16's frequency-vs-selectivity trade-off) — but understand the trade-off before doing it.


25. Glossary


26. API Reference (Quick Lookup)

This is a compact index, not a replacement for §9–10's fuller treatment. Every entry names the module it lives in.

Symbol Module One-line purpose
forge() forgedge run the full pipeline end to end
forge_multi() forgedge forge() per ticker + one pooled cross-ticker registry
ForgeResult forgedge the return value of forge() — every intermediate artefact
forge_preset() / preset_info() / PRESETS forgedge pre-tuned (DiscoveryConfig, AlphaConfig, RuleDiscoveryConfig) triples
MarketContext / MarketContextConfig / EMAProxyConfig forgedge Module 0 — regime classification
EventDiscovery / DiscoveryConfig / EventCandidate / CustomEvent forgedge Module 1 — event mining
AlphaDiscovery / AlphaConfig / AlphaContract / PromotionThresholds forgedge Module 2 — target derivation and predictive-power measurement
RuleDiscovery / RuleDiscoveryConfig / RuleDiscoveryResponse / BacktestParams / SelectionCriteria forgedge Module 3 — realistic backtest and walk-forward verdict
RuleRegistry / RegistryConfig / RuleSubmission / RuleDocument forgedge Module 4 — dedup, cross-ticker, catalog
build_features() / candle_features() / lag_features() / pattern_features() forgedge raw candles → KPI Table
summary_report() / DataQualityReport / Finding forgedge opt-in data-quality diagnostics
TimeBudget forgedge shared purged/embargoed IS/OOS split
HypothesisLedger forgedge search-surface bookkeeping
FastRotationNull / RotationCalibrator / RotationConfig / CalibrationReport forgedge search-level multiple-testing calibration
TargetOptimizer / TargetConfig forgedge standalone target-first alternative workflow
RuleSpec / rule_performance_report() forgedge replay published rules on new candles, HTML report
text_report() / html_report() / rule_summary_report() forgedge (and forgedge.rule_discovery) human/HTML reports from a RuleDiscoveryResponse; rule_summary_report(..., save=True) writes to disk
run_backtest() forgedge.rule_discovery the underlying single-configuration backtest engine

For every dataclass's full field list and default, see §10 (the ones you're likely to tune) or the source directly — every default quoted anywhere in this manual was verified against the installed package at the time of writing.


This manual was written by reading the forgedge source code, its test suite, its own documentation and design-analysis notes, and by executing real code against the version of the library installed from this repository. Where a claim reflects the authors' own stated reasoning rather than this manual's reading of the code, it is quoted and attributed as such throughout. The companion Italian edition, docs/manuale-it.md, covers identical content.