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
- Introduction
- Why FORGE Exists
- When to Use It — and When Not To
- Core Concepts
- Installation
- Your First Working Example
- Quick Start: A Full Pipeline Run
- Anatomy of the Workflow
- Main API and Components
- Configuration
- Error Handling
- Progressive Use Cases
- Working with the Data in This Repository
- Design Choices
- Opt-in Behaviors
- Trade-offs
- Performance and Scalability
- Testing
- Integrating forgedge into a Real Application
- A Production-Ready Architecture
- Troubleshooting
- Best Practices
- Anti-patterns
- FAQ
- Glossary
- 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:
- Event Discovery (Module 1) never computes a forward return. The boolean expressions it mines are chosen purely from how an indicator's own value has behaved historically — never from what happened afterward. This is stated in the source as an architectural fact, not a tuning choice: "Event Discovery lavora completamente cieco rispetto al target economico... Questo non è un dettaglio implementativo — è un vincolo architetturale che elimina una categoria intera di look-ahead bias" ("this is not an implementation detail — it is an architectural constraint that eliminates an entire category of look-ahead bias,"
src/forgedge/docs/README.md). - Thresholds are fixed once discovered and never re-tuned against results. Wanting a "better" threshold means running Event Discovery again on a different in-sample window — never mutating a discovered candidate.
- The economic target (how long to hold, which direction, what take-profit) is derived from the data by Alpha Discovery, never assumed by the caller. This keeps a user's economic hunch (e.g., "surely a 2% move in 10 bars") from silently biasing which events look good.
- Rule Discovery is the only economic judge. A boolean expression can look statistically excellent in Module 2 and still be rejected in Module 3 because it isn't profitable once realistic order mechanics (limit fills, fees, delays) are simulated.
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...
- You have (or can build) a table of historical OHLCV bars plus technical indicators for one or more tickers, and you want to systematically search for boolean conditions that historically preceded a statistically significant price move — rather than hand-picking indicator thresholds from intuition.
- You want that search to carry honest statistical guardrails against overfitting by construction: out-of-sample confirmation, walk-forward validation, a search-level multiple-testing correction (the rotation null, §14–15), and a Deflated Sharpe Ratio check — not bolted on after the fact.
- You want auditable output: every candidate carries its own rejection reasons, every contract records why it was or wasn't promoted, and the pipeline's own intermediate artefacts (
ForgeResult.candidates,.contracts,.event_frame) remain inspectable after the run. - Your dependency budget is tight.
forgedgedepends on onlynumpyandpandas— noscipy, nostatsmodels, no ML framework. All statistical primitives (Spearman correlation, t-tests, the incomplete beta function, Benjamini-Hochberg FDR control, OU-process half-life regression) are reimplemented in pure numpy.
Do NOT use forgedge for...
- Order execution or position management. It has no exchange connectivity, no order placement, no portfolio state. This is stated explicitly and repeatedly in the repo's own documentation: "FORGE non esegue ordini, non gestisce posizioni, non si connette a exchange... È un sistema di ricerca, non di esecuzione" — "FORGE does not execute orders, manage positions, or connect to exchanges... it is a research system, not an execution system" (
src/forgedge/docs/README.md). If you need that,forgedgeproduces the specification (aValidatedRule, an entry/exit parameter set) that a separate execution system implements. - Machine-learning-based signal generation. The design docs (
docs/analysis/forge2_functional_analysis.md) explicitly list this as a rejected direction, with a stated reason: (translated) "No ML/feature learning in discovery. The differentiating value is that every rule is a readable, auditable boolean expression; a trained model would break the contract far more than any bug would." If you're looking for a library that fits a classifier or a neural net to price data, this is not it, by design. - A single indicator/threshold you already know you want to test.
forgedge's value is in the systematic search plus statistical guardrails; if you already have one specific hypothesis (e.g., "test whether RSI < 30 predicts a bounce"), the library'sCustomEventmechanism (§9, §12 Use Case 5) lets you inject it directly, but you'd be using a small fraction of what the library does. - Sub-second/tick-level microstructure research. This is about the data type — individual trade/quote events (order-book microstructure, bid-ask spread dynamics) — not about how short a bar interval you use. Sub-hourly OHLCV bars (15m, 5m, ...) are not this:
forge_preset()calibrates a dedicated horizon-grid class for them (internally named"hft"in_TFClass, alongside"daily"and"intraday", §7/§9), and every timeframe-scaled field in the resolver treats them as a first-class case, not something merely tolerated. What actually is untested is data below the level of an aggregated bar: every worked example, every statistical calibration, and the library's own low-frequency robustness analysis (docs/analysis/lowfreq_robustness.md) are built around bar data from 1 hour up to 1 day, and none of it says anything about tick streams. Nothing in the code technically forbids tick-level use, but see §16 (Trade-offs) and §21 (Troubleshooting) for the statistical-power problems that appear when the in-sample window doesn't span enough calendar months — a risk that tracks how much history you have, not how fast the bar interval is; a 15m table covering two years has the same monthly depth as an hourly one covering two years. - Anything requiring a persistent, cross-session catalog of discovered rules out of the box. Module 4 (Rule Registry) is explicitly stateless and rebuilt from scratch every session — persistence is the host application's responsibility (§19–20), not the library's.
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:
- a
closecolumn (float, required by every module), - a datetime source — either a column (default name
open_dt) or aDatetimeIndex, - any number of feature columns: RSI, EMA, Bollinger Bands, volatility, spreads, candle geometry — anything you want
forgedgeto consider.
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
EventDiscovery(kpi)classified both columns asCONTINUOUS, 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 whyclose— despite not being what you'd casually call "scale-free" — still produced theclose crosses_below 95.45candidate that topped this run's list.- It generated transformed versions of each column: rolling percentile rank, rolling z-score, and simple deltas, over several window lengths.
- 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
closehere), or something shaped likeclose_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). - Every candidate was passed through the Consistency Gate: does it fire often enough (
min_tpm), consistently enough across months (not bursty —dispersion_marginin the default"episode"counting mode,max_dispersionin"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 becomeEventCandidateobjects. - 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:
train_ratio=1.0— the entire table was used for discovery, no OOS split was reserved (there's no walk-forward validation happening here; see §10 for how to enable it).gate_params=GateParams()— the default Consistency Gate thresholds:min_tpm=0.5(at least 0.5 qualifying "episodes" per month),event_counting="episode"(§15),dispersion_margin=1.3(activations shouldn't cluster more than 1.3x what a Poisson process at the observed rate would — the field that actually governs dispersion in this default mode;max_dispersion=1.5is present but unread here, it only matters underevent_counting="bar", #205).max_and_components=1(issue #254 Phase 8 — was2) — Event Discovery composed no pairs here;forge()'s own default path composes downstream instead, in Alpha Discovery's grade-guided composition (§8, Module 2 walkthrough, step 8), not inside Event Discovery. Raise this to2/3only when running the legacy single-pass path (forge(two_pass_composition=False)) or when usingEventDiscoverystandalone, as in this example, and wanting its own structural AND-composition (§8, Module 1 walkthrough, step 5).
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
- 5356 event candidates survived Event Discovery's Consistency Gate — remember, none of these have been checked against a forward return yet.
- 392 of them were promoted by Alpha Discovery to
AlphaContract"HYPOTHESIS" status — meaning each has a determined direction (long or short) and a derived holding period / take-profit. - 392 rule responses — every promoted contract was run through Rule Discovery's realistic backtest and walk-forward validation (this fixture has
run_rule_discovery=Trueby default). - 40 are tradeable (
result.edges()— verdictEDGEorPARTIAL-EDGE). On this specific dataset with default settings, digging one level deeper shows all 40 arePARTIAL-EDGE, not fullEDGE, and 8 more contracts were explicitly demoted toINSUFFICIENT-DATArather than silently reported as something stronger than the OOS evidence could support:
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:
- Resolved the whole configuration bundle through the central parameter resolver and checked it for internal coherence via
config_report()— logged asINFO, and stored onForgeResult.coherence— before running anything. Withstrict=True(the default) aFAIL-level finding raisesValueErrorimmediately instead of letting the run produce a wall of uninterpretable rejections (§9, §15). - Ran Module 0 (Market Context) since you didn't pass
run_market_context=Falseand the table had noregimecolumn already. - Substituted a daily-calibrated
horizon_gridfor Alpha Discovery on every code path, not only the "no explicitAlphaConfig" one (issue #196 closed that gap) —AlphaConfig.horizon_griditself now defaults toUNSETand is resolved from the session's declaredtimeframe, so passing anAlphaConfigto change something unrelated no longer silently keeps the hourly-calibrated grid. - 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. - Ran the fast search-level rotation null (
fast_null=Trueby default) and annotatedrotation_p/rotation_thresholdon every promoted contract — this is exactly what produced thePARTIAL-EDGEcap you just saw. - Built a shared, purged
TimeBudgetfor Event/Alpha Discovery (§15) even though you passed notime_budget=argument. - Recorded a
HypothesisLedgeronresult.ledger, tallying how large the session's search surface actually was. - 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. - 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). - Measured episode count and trade concurrency for every backtest (
resp.in_sample_summary.n_episodes/.mean_concurrent_positions/.max_concurrent_positions, issue #168) —run_backtestopens 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). - Skipped Module 4 (Rule Registry) — not because you disabled it, but because
RuleRegistry.from_forge_resultsneeds multiple tickers to say anything about cross-ticker generalisation; with a single-tickerforge()call it still runs and produces a registry, but every rule is classifiedISOLATED(§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:
- Column classification (
TypeClassifier) — every non-timestamp column is labeledCONTINUOUS,BINARY(exactly 2 distinct values), orCATEGORICAL(non-numeric, or numeric with ≤max_categorical_classesdistinct 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. - 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"inbuild_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 defaultkpi_builderconfig. -candle_features()'s six geometry columns against each other, and againstclose_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 theratio_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:
- Horizon and direction derivation. For every horizon
hin the (possibly enriched) grid, it computesmean_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"ifmean_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 belowmin_direction_t(default0.5), or — under the defaultrequire_significant_direction=True— if neither statistical test in step 9 below finds the edge distinguishable from noise (Falserestores the older, purelyargmax-driven behavior). - Take-profit derivation.
sell_pct = max(quantile(MFE, mfe_quantile=0.5), mfe_floor=0.005), where MFE is the maximum favorable excursion withinh*bars of every active bar. This is a derived number, not a config input. - 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. - 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. - Regime sensitivity — per-regime IC and win rate, with a
dependency_typeclassification (agnostic/conditional/specific/broken/unknown). - 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, whereic_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 ath*, i.e. the edge-to-noise ratio), andbreadthis the regime breadth term, dropped (with the remaining weights renormalised) when no regime information is available. Two adjustments follow: astatistically_weaktarget —h*selected outside the BH-significant horizon set — multiplies the composite bystatistically_weak_penalty(default0.6), and a passing OOS confirmation addsoos_bonus(default0.05). The result is clamped to[0,1]and mapped to a letter grade A (≥0.75) through D (<0.25). - Contract compilation. All candidates with a determined direction become
AlphaContractobjects withstatus="HYPOTHESIS"; every other metric above only ever appends a string todiagnostics— it never blocks promotion, andrejection_reasonsstays 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). - Grade-guided composition (issue #254,
forge(two_pass_composition=True), the default since Phase 8) — steps 1-7 above are actually run twice underforge()'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, withGradePairingConfig.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_keysimilarity. 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 freshevent_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_contractskeep pass 1's pre-composition artefacts available for audit, andForgeResult.composition_timingreports each stage's wall-clock cost. Passtwo_pass_composition=Falseto 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. - 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 touchinglift/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Δ_halone 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 machineryh_sigalready uses (p_auc,PromotionThresholds.auc_max_p, default0.10). A candidate can now clear promotion via either test —AlphaContract.promotion_routerecords which:"z_score"(BH-FDR only),"auc"(stage (a) only — the case step 1's test alone would have left"undetermined"),"both", orNone(neither, ordirection=="undetermined"). For the"auc"-only route,h*is notargmax|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, thresholdPromotionThresholds.rho_momentum_threshold, default0.5) of the per-bar rate along the horizon grid labelsAlphaContract.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 ontoAlphaContractalongsidenature/promotion_route) answers "ish*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 discountnature— 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:
- On a signal bar, a limit order is placed at
anchor × (1 − buy_drop_pct)(long) oranchor × (1 + buy_drop_pct)(short). - If price touches that limit within
buy_delay_barbars, it fills; otherwise the order is cancelled (this is where fill rate comes from). - 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_hafter 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".
- M3's trade floor (
#173).min_train_monthsis now derived fromcriteria.min_tpmwith a 95% Poisson margin: 11 months at the balanced preset's daily rate, not a fixed 6. The naivefloor / rategives 12.5 and comes up short about 44% of the time — the same bug in milder form. Where the history cannot supply the derived window,oos_span_too_shortsays so and carries the rate to set. - M3's verdict. Below the floor with a span that could never have supplied
it, the verdict is now
INSUFFICIENT-DATA, notNON-EDGE. The rule is not being judged; the window is. - M1's folds (F1).
GateParams.min_episodesis an absolute count and was applied verbatim to walk-forward folds, making the implicit rate requirement inversely proportional to the fold's length — 5.1x stricter than in-sample on the configuration this manual recommends for production, at which 0.7% of fold evaluations passed. It is now in-sample only. A fold is testable when its expected episode count reaches 3, where an empty fold has probabilitye^-3 = 5%under "the candidate kept its rate"; below that the fold is measuring its own brevity and is markedINDETERMINATE, excluded from the denominator rather than counted as a failure. When no fold is testable,ValidationResult.passedisNone— inconclusive, not failed.
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:
- Stage 1 evaluates the rule at a market entry (next-open fill, ≈100%). This
verdict is authoritative. Stage 2 can never turn a
NON-EDGEinto an edge. - Stage 2 sweeps
buy_drop_pcton the survivors, replays the winner out-of-sample on Stage 1's own test windows, and publishes it only if it clears all three adoption conditions.
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:
n_effective=19.158againsttotal_trades=72. Trades on this rule overlap heavily (mean_concurrent_positions=3.758, up to 12 at once) —StatisticalValidation's t-tests and Deflated Sharpe are computed against the ~19 effective, concurrency-corrected observations this ledger actually supplies, not the 72 nominal ones.n_effectivenever touches the economic numbers (profit_factor,expectancyabove are still the true, nominal ones) — only the inferential ones (t-test standard error/df,deflated_sharpe'sn_obs).entry_optimization.adopted=Falsewithfailed_condition='sharpe'. Stage 2's limit sweep foundbuy_drop_pct=0.008with a higher per-trade Sharpe (it fires on fewer, cleaner fills) but a loweropportunity_sharpe(4.365 vs. the market point's 4.498) once the cost of trading less is priced in — see §9's Module 3 entry-mode discussion for whyopportunity_sharpe, notStatisticalValidation.sharpe_ratio, is the metric that decides this.n_splits_in_sample=3of 4. This isWalkForwardSplit.tests_in_sampleaggregated: three of this rule's four walk-forward test windows fall, by timestamp, before the session's own IS/OOS boundary (the span Alpha Discovery derivedderived_targeton) — meaning three of the four "OOS" folds are OOS only with respect to Rule Discovery's own parameter selection, not with respect to the target itself, and so contribute weaker evidence than a fold that is OOS on both axes. It is a quality annotation onWalkForwardResult, not a gate —consistency=1.0and the verdict are computed exactly the same either way. On this fixture's default 4-month test windows against a session split near the end of 2024, this 3-of-4 split is typical, not a bug in this particular rule.
The exact formulas behind two numbers you'll see quoted throughout this manual, since neither is the "textbook" version its name suggests:
- Deflated Sharpe (
StatisticalValidation.deflated_sharpe) is a multiplicative haircut, not the Bailey/López de Prado probabilistic DSR some readers will know:dsr = sharpe_annualised × sqrt(1 − γ·ln(n_trials) / ln(n_obs)), whereγ ≈ 0.5772156649is the Euler–Mascheroni constant andn_obsis the effective, concurrency-corrected trade count (n_effective, not the nominal one). It is a no-op — the raw Sharpe is returned unchanged — whenevern_trials ≤ 1(OOS validation, §9's walk-forward, always passesn_trials=1since OOS data played no part in selection) orn_obs ≤ e ≈ 2.718; it returnsnanoutright ("selection bias too severe to trust") if the radicand under the square root goes negative. cohens_d(feedingAlphaConfig.thresholds.min_cohens_dand theevent_stats.cohens_dshown above) pools variance assqrt((var_a + var_b) / 2)— the unweighted average of the two samples' own variances, not the classic(n-1)-weighted pooled-variance formula. The two agree only when the two samples are the same size; on the routinely unequal active/base sample sizes Alpha Discovery compares, this is a real, if usually small, numeric difference from the pooled-variance Cohen's d a statistics textbook would compute.
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:
- On the source ticker: find the percentile the original threshold occupies —
q = mean(source_series <= threshold). - 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.
candles— the only positional/required argument besidestimestamp_col. Must containtimestamp_coland whatever price/volume columns yourconfigreferences; anything else on the frame (an exchange's own bookkeeping columns, a symbol column, …) is carried through untouched.config— adict, a YAML file path (str/Path), orNonefor the packaged default (DEFAULT_CONFIG, table below) — see Configuring which indicators get built below for the schema.timestamp_col— required, keyword-only, no default. The column incandlesthat holds each bar's timestamp. Omitting it raises aTypeErrorfrom Python itself (it's a required keyword argument); passing a name that isn't a column raisesKeyErrornaming the columns that are available.output_timestamp_col="open_dt"— the name of the datetime columnbuild_featuresderives and writes."open_dt"is what every other module in the library looks for by default; change it only if you have a specific reason and remember to passtimestamp_col="your_name"everywhere downstream that asks for one.timestamp_unit="ms"— how to interprettimestamp_colif it's numeric (epoch milliseconds by default, matching most exchange APIs; pass"s"for Unix seconds,"ns"for nanoseconds). Ignored iftimestamp_colis already a datetime dtype or a parseable string column —build_featuresdetects which case you're in automatically (pandas.api.types.is_datetime64_any_dtype/is_numeric_dtype, then falls back to generic string parsing).add_color=True— adds acolorcolumn (+1ifclose > open,-1ifclose < open,0on a doji), computed from rawopen/closewith no rounding. Requires both columns present; silently skipped (with a warning) otherwise.sort_output=True— the returned frame is sorted chronologically byoutput_timestamp_col, regardless of what ordercandlesarrived in.build_featuresalso sorts before computing indicators, independently of this flag, so indicator math is always computed in time order even if you passsort_output=Falseto skip only the final re-sort.
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:
- ATR's default two periods (14, 28) are deliberate, not arbitrary, and the reason is specific to how Event Discovery pairs features: unlike a bounded oscillator (RSI, a
pctrank), raw ATR is not reliably scale-free on its own, so a second period exists specifically so{base}_{indicator}_{period}-family pairing (the convention above) can auto-derive a scale-free ratio between the two (e.g.ratio_close_atr14_atr28) — everymultiple_atrcall also emits the matching*_natr_*(normalised ATR,ATR/price) columns unconditionally as a second, already-scale-free reading of the same thing. MACD has no equivalent rescue: its column name carries two parameters (fast_slow), so the same-family pairing convention doesn't recognise multiple MACD triples as a ratio-able family the way it does multiple ATR/EMA/SMA periods — passing several(fast, slow, signal)triples doesn't buy you the same automatic pairing. multiple_macd'speriodsparameter is a flat list of(fast, slow, signal)triples, not independent window lengths like every other indicator here —len(periods) % 3 != 0raisesValueError. Passing[12, 26]expecting "two MACD windows" the way you would for RSI is the wrong mental model and will raise.- The computed MACD line is normalised by price (
(ema_fast − ema_slow) / close), not the textbook raw-price-unit difference — deliberately, to keep it in the same scale-free family as the rest of the KPI Table's indicators. - ATR/RSI both use Wilder's smoothing (
ewm(alpha=1/period, adjust=False), not a simple moving average of the true range/gains-losses) — RSI's100 - 100/(1+rs)usesrs = gain/(loss + 1e-10), so it asymptotically approaches but never exactly reaches 100 on a zero-loss window.
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
- Passing
timestamp_colpositionally, or forgetting it. It's keyword-only and required —build_features(candles, timestamp_col="open_time"), neverbuild_features(candles, "open_time"). Leaving it out entirely raises a plain PythonTypeError(a missing required keyword argument), not aforgedge-specific error. - Calling
candle_features()/pattern_features()on the rawcandles, not onbuild_features()'s output. Both needopen/high/low/close— which your raw candles already have — so this technically runs either way, but you loseopen_dtand every indicator column, and everything downstream that expectsopen_dt(includingforge()itself, unless you passtimestamp_col=explicitly) breaks. Chain them in order:build_features→candle_features→lag_features. - Passing the original KPI Table to
lag_features()instead of the frame you actually want lagged columns on.lag_features()lags whatever is on thedfyou hand it — call it last, aftercandle_features(), if you also want lagged copies of the geometry columns, not just the indicator columns frombuild_features(). - A custom or renamed column silently never getting paired with anything in Event Discovery. Not an error — see the naming-convention note above. This is far and away the most common "why isn't my feature being used" report; check the column name against
{base}_{indicator}_{period}before assuming something is broken. - Expecting
atr/macdcolumns after callingbuild_features()with no config. They ship disabled in the packaged default (DEFAULT_CONFIGbelow) — pass an explicit config that enables them, as shown above, if you want either.
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. AFAILnow raisesValueErrorinstead 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. Passstrict=Falseto downgrade everything toUserWarningand 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 valuesmin_train_months=6 × criteria.min_tpm=0.80 = 4.8against a floor of 10 — the audit's F2, and the reason daily-data users see mass early-elimination. It is fixed by derivingmin_train_monthsfrom the rate (issue #177); until then, daily preset runs needstrict=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_thresholdused to default to2.0independently of M3, whilepartial_min_profit_factoradmits rules at1.5— so aPARTIAL-EDGErule 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 nowPASS ⟺ 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.barreports 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:
- Input is always
Iterable[ForgeResult]— one or manyforge()/forge_multi()outputs, never re-executed. Pool a whole session's results into a list and hand the same list to every playground function you want to ask of it. - Output is always long-format — one row per elementary observation (one transition, one rejection reason, one component), never pre-aggregated. Every function's docstring shows the
groupby/aggregation you're expected to chain afterward — that composition is deliberately left to the caller rather than baked into the function, so the same long table answers questions the function's author didn't anticipate.
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:
- Resolve once. The caller's (or default)
event_discovery_config/alpha_config/rule_discovery_configare resolved a single time viaforgedge.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 internalEventDiscovery/AlphaDiscovery/RuleDiscoverycall the search makes downstream reuses these same resolved configs, never a fresh, partially-UNSETone — a hand-builtEventDiscovery/AlphaDiscoveryconstructed standalone otherwise falls back to an hourly calibration for any bar-counting field (horizon_gridand similar) left unset, regardless of the realtimeframe— the same trap this manual's own Module 1/2 walkthroughs warn about for direct standalone use, earlier in this section.StepWiseDiscoverycloses it by construction, the same wayforge()itself does internally. - Split an outer hold-out. Independent of, and in addition to, whatever internal train/test split the passed-in configs already use —
holdout_dfis never read by any discovery call, only by hold-out re-confirmation. Sized from the resolvedalpha_config.horizon_grid/embargo_barsby 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). - Iteration 1 — one
forge()call onsearch_dfalone, composition forced off (that is this class's own job): every single-condition candidate/contract/verdict the KPI table supports. - 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). - Partition & compose, per seed, up to
config.depth: partitionsearch_dfto 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 FULLsearch_dfand 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:
UserWarning— stale hourlyhorizon_gridon daily-or-slower data. Fired byforge()when you pass an explicitAlphaConfigthat still carries the untouched hourly-calibrated default grid on atimeframeof a day or longer.UserWarning— observed-candle index mismatch. Fired byAlphaDiscovery,RuleDiscovery, and Rule Registry ingestion when the frame you hand them has a different index than the event's cached training activation series — meaning the event gets re-evaluated via.apply()instead of reusing the cache. A second, more serious variant of this same warning fires when the re-evaluated activation count collapses to under 10% of the training count — this is a strong signal you're about to getdirection="undetermined"because rolling-transform baselines (pctrank, z-score) have shifted (§21).DeprecationWarning— the legacyTargetConfig.min_liftfield (superseded bymin_lift_atoms/min_lift_result) and a legacyTypeClassifierconstructor argument (scale_free_drift_threshold).
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:
- A
CustomEventthat fails the Consistency Gate is kept, not dropped — onlylogger.warning'd. binary_target(..., target_mode="proj")reverts to"abs"when there isn't enough history for the trend-SMA warmup, logged atWARNING.RuleDiscoveryConfig(selection_mode="walk_forward")silently falls back to full-sample selection when the data span is too short for even one walk-forward split — recorded as a note in the response, not raised.
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:
- the horizon grid genuinely doesn't cover the timescale at which the event has an effect (mitigated, but not eliminated, by
AlphaConfig.horizon_enrichment, on by default — §15); - you fed Alpha Discovery a frame whose observed index differs from the event's training activation series (§11's second
UserWarning), so the re-evaluated activation count collapsed and there's no statistical power left to determine a sign at all — a scenario covered concretely in §21.
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):
- "No ML/feature learning in discovery." Reason given: "The differentiating value is that every rule is a readable, auditable boolean expression; a trained model would break the contract far more than any bug would."
- "No persistent registry/database." Reason given: "In-memory + export (flat table, HTML) is the right level of ambition; persistence is the host's problem." — this is why §19–20 of this manual treat persistence as an application responsibility, not a library feature.
- "No probabilistic verdicts instead of the triad." Reason given: "The triad (+ INSUFFICIENT-DATA) IS the contract; confidence goes alongside the verdict, not in its place."
- "No external statistical dependencies." Reason given: "The pure-numpy primitives are an auditability asset, not a debt."
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):
FastRotationNullon real ADA daily data: ~1 second, computing the exact rotation null over every circular offset via FFT — reported by the design document, and consistent with the near-instantresult.calibration.summary()output this manual captured in §7.- The (heavier)
RotationCalibrator, sampled: ~4 seconds per draw on the same data, perdocs/analysis/search_rotation_calibration.md— meaningK=100is on the order of several minutes, not seconds. This is the direct cost of the statistical-rigor-vs-speed trade-off in §16. - This manual's own verified
forge()run on the 882-bar ADA fixture, single-threaded, produced 5356 candidates → 392 promoted → 392 rule-discovery responses (§6). The library's design docs separately flag Module 3 as the compute-heavy stage at scale: (translated,docs/analysis/forge2_functional_analysis.md) "M3 sequential (255 contracts × ~0.4 s on small data)" — i.e. Rule Discovery's per-contract walk-forward backtest is the module whose cost scales most directly with how many contracts Alpha Discovery promoted. - The same document reports the test suite itself takes ~8.5 minutes, "dominated by repeated full pipelines" — a fact more relevant to contributing to the library (§18) than to using it, but indicative of how much compute a full
forge()call represents.
Complexity observations from the code (this manual's own reading, not a stated benchmark):
- Event Discovery's Step 1–3 (feature generation × temporal transforms × threshold catalog) is combinatorial in the number of native feature columns: each continuous column can produce several transform variants, each transform variant is tested against roughly a dozen thresholds, and arity-2 feature combinations are attempted both between same-family columns and via the several dedicated pairings described in §8. A KPI Table with many similarly-named indicator columns (e.g. ten different RSI periods) will generate a correspondingly larger candidate pool before the Consistency Gate prunes it — this is precisely why
max_categorical_classes,max_and_components,indicator_lag_cross_lags, and the gate's own rate/dispersion thresholds exist as the practical levers to control candidate-pool size (§10). - A real, measured cost figure for one of §8's pairings (from the commit that introduced
indicator_lag_cross_lags, not an invented benchmark): on a 36-column EMA/SMA KPI Table (3 OHLC bases × 6 periods × 2 indicator families), enabling the indicator-vs-lagged-OHLC-base pairing (on by default) measured +24%EventDiscovery.run()time and +21% candidate count versus the same table with that pairing disabled (73.8 s / 23179 candidates vs. 59.6 s / 19205 candidates on that fixture). The commit message is explicit that this exceeded the original back-of-envelope estimate because "family" turned out to mean more than one representative column once period multiplicity was accounted for — worth knowing if you're tuningindicator_lag_cross_lagsormax_and_componentson a KPI Table with many indicator periods. - Rule Discovery's grid screening is, per contract, a small grid search (
buy_drop_pct × sell_pct × target_h × buy_delay_bar) run once in-sample and then re-run once per walk-forward split — so its cost scales as(grid size) × (n_splits + 1) × (backtest cost per configuration), independently for every promoted contract. The authors' own audit explicitly names this as "embarrassingly parallel" across contracts (§14) — but the library does not parallelize it itself; that would be an application-level responsibility (§19). pandas-level cost: several internal feature-generation steps insert columns one at a time into a growing DataFrame rather than concatenating all at once, which this manual's own verified test run surfaced directly as apandas.errors.PerformanceWarning("DataFrame is highly fragmented... Consider joining all columns at once using pd.concat(axis=1)") during the golden test suite. This is an internal implementation detail, not something you can configure around, but it's worth knowing the warning is expected and not a sign your own code did something wrong.
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
- One test file per source module, named
test_<module>.py—test_event_discovery.py↔event_discovery/,test_rule_discovery.py↔rule_discovery/, and so on, plus two cross-cutting files:test_forge.py(orchestrator wiring) andtest_golden.py(end-to-end regression pinning). - No mocking anywhere, except two uses of pytest's
monkeypatchintest_alpha_discovery.py, both used as a "must-not-be-called" tripwire rather than a stub:
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
- Config wiring, mirroring
test_forge.py: does your application correctly assembleDiscoveryConfig/AlphaConfig/RuleDiscoveryConfigfrom whatever configuration source you use (env vars, a settings file, a preset), and does it fail the way you expect on invalid combinations? - Edge cases with synthetic data you fully control, mirroring the rest of the suite: an event that never fires, a table with a single regime throughout, an empty
promotedlist reaching Rule Discovery, a rotation-null verdict that caps everything toPARTIAL-EDGE. These are cheap to construct withnumpy's seeded RNG and don't require real market data. - Regression-pin your own downstream logic the way
test_golden.pypins the library's: if your application derives a decision (e.g. "only act on grade-A EDGE rules") fromForgeResult, write a test that runs a fixed input throughforge()once and asserts on the derived decision, not just onforgedge's raw output — that's the layer most likely to silently drift as you tune your own configuration. - Do not mock
forge()itself in tests that are meant to validate your integration logic — the library has no I/O to fake, and the whole value of the pipeline is in its actual statistical behavior; mocking it away tests nothing real. Use a small, fast synthetic KPI Table (seeconftest.py's pattern) instead of a real multi-year dataset to keep your own test suite fast.
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
- A pure function of its inputs:
forge(kpi_table, ...) -> ForgeResult. No hidden state, no required setup/teardown, no background threads. - Every intermediate artefact of a run, inspectable after the fact (
ForgeResult.candidates,.contracts,.event_frame,.calibration,.ledger). - Deterministic replay of a discovered rule against new data (
EventCandidate.apply(),RuleDiscoveryon fresh candles,rule_performance_report()) — this is the mechanism §12's Use Case 6 and this section's "monitoring" component both build on. - JSON-serializable exports at each stage (
AlphaContract.to_contract_dict(),RuleDiscoveryResponse.to_dict()) and a full pickle round-trip forEventCandidate(.persist(path)).
What your application must build
- Persistence.
forgedge(specifically Module 4) is explicitly stateless across sessions (§14's anti-goals). If you need a durable catalog of discovered rules across many discovery runs over time, that catalog lives in your database, populated fromRuleRegistry.flat_table()/.export()output or from your own serialization ofForgeResultfields. - Scheduling. Nothing in
forgedgedecides when to re-run discovery, or when to re-check a published rule against fresh data. That's an external scheduler (cron, an Airflow/Prefect DAG, a queue worker) calling into your application code, which callsforgedge. - Secrets and data acquisition.
forgedgenever talks to an exchange or a data vendor — getting a KPI Table into memory (API keys, rate limits, retries) is entirely your application's job, upstream of the KPI Builder step. - Execution. The single most important boundary. A
ValidatedRule'sBacktestParams(buy_drop_pct,sell_pct,target_h,buy_delay_bar,fee) is a specification for how an order should behave under the pipeline's own backtest simulation — not a live order. Turning that specification into an actual order against a real exchange, with its own latency, slippage, and partial-fill behavior, is execution-system work that sits entirely outside this library, by explicit design (§2, §3). - Monitoring and alerting.
rule_performance_report()(§9, §12) produces a static HTML snapshot — it does not push a notification, does not poll on a schedule, and does not know about your alerting system. Wiring "signal is now active" (a real flag the report computes:EventCandidate.apply(latest_bars).iloc[-1]) into a Slack/email/webhook notification is your application layer.
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
- Ingestion (application) → raw candles.
- Feature engineering (
forgedge.kpi_builder— library) → KPI Table. - Data-quality gate (
forgedge.summary_report— library function, but the decision to block onhas_criticalis application logic; the library never blocks on its own). - 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 aforge()call blocks its calling thread for as long as Rule Discovery's per-contract walk-forward backtests take. - Persistence (application) — the
ForgeResult's promoted contracts, rule responses, and the ledger/calibration report are your durable record.AlphaContract.to_contract_dict()andRuleDiscoveryResponse.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. - 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 andRuleDiscovery(...).run()(notAlphaDiscovery— §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. - 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"
- Cause: the Consistency Gate's thresholds and this specific asset's actual event statistics disagree — a preset or hand-built
DiscoveryConfigcan be perfectly internally coherent (config_report()finds nothing wrong, since it never looks at data) and still reject every candidate a given KPI Table produces, e.g. a high-min_tpmpreset on a low-liquidity or low-volatility asset. - Confirm: read
result.event_discovery.event_distribution_report(ored.event_distribution_reportif built by hand) — a plain-text summary of the tpm/dispersion distribution actually observed across every raw candidate the gate evaluated, against the configured thresholds. Below a 15% gate-survival rate it also names concrete parameter values at the observed median.forge()'s M1 stage log line already carries this text whenever Event Discovery ran normally (§8, §9). - Fix: loosen
min_tpm/dispersion_margin(ormax_dispersionunderevent_counting="bar") toward the values the report suggests, or accept that this asset genuinely doesn't produce events at the configured rate.
"Zero promoted contracts, or every contract is direction='undetermined'"
- Cause A — genuinely no predictive events in this data/config combination. Not everything a KPI Table produces will have predictive power; this can be a correct, honest result.
- Cause B — the observed frame's index doesn't match the event's training activation series. Confirm: did
AlphaDiscovery/RuleDiscoveryemit theUserWarningabout "candles whose index differs from the event's stored activation series," possibly followed by the stronger variant about the activation count collapsing to under 10% of the training count (§11)? If so, this is the cause. Fix: if you intended to extend the training window with new bars, passpd.concat([train_df, new_bars_df]), not justnew_bars_dfalone — rolling-transform baselines (pctrank, z-score) need the preceding history to mean the same thing they meant during discovery. Prevent: treat that specificUserWarningas an error in CI for any code path that's supposed to be extending, not replacing, the discovery window. - Cause C — horizon grid genuinely doesn't cover the event's timescale. Confirm via
c.derived_target.score_by_h/.advantage_by_h— is the score curve still rising at the grid's edge?AlphaConfig.horizon_enrichment(on by default) mitigates this but doesn't eliminate it for genuinely long-timescale events. Fix: widenhorizon_gridexplicitly.
"I ran an examples/*.py script and got TypeError: GateParams.__init__() got an unexpected keyword argument 'min_act'"
- Cause: confirmed, real, reproducible (§11) — several example scripts in this repository predate a
GateParamsAPI change and still construct it with the old field names (min_act,min_months,max_conc). Fix: translate to the current fields —GateParams(min_tpm=2.0, max_dispersion=2.5, event_counting="bar")reproduces the old script's intent most closely (event_counting="bar"restores the pre-refactor counting semantics the oldmin_act/min_months/max_concfields implied). Confirm which scripts are affected:alpha_discovery_usage.py,extended_usage.py,kpi_table_1d.py,search_rotation_calibration.py,lowfreq_null_diagnostic.py,lowfreq_endpoint_diagnostic.py.kpi_builder_usage.pyis unaffected (verified, §13).
"All my verdicts are PARTIAL-EDGE, never full EDGE"
- Cause: almost certainly the default rotation-null gate (
rotation_p > SelectionCriteria.max_rotation_p, default 0.05), not a bug. Confirm: checkrejection_reasonsfor"search-level rotation null not cleared". This is expected, common, and — per the library's own audit — the intended behavior on datasets where the search surface is large relative to the sample size (§14, and the real ADA/AMZN examples in §7/§13, where it happened on both). - Fix, if you've confirmed you actually want to see through the gate for inspection purposes:
forge(..., fast_null=False)or raiseSelectionCriteria.max_rotation_p— but read §15's cost/risk entry for both before doing this for anything other than debugging.
"RuleRegistry output has empty classification/is_generic columns"
- Cause: a single-ticker session. Cross-ticker classification is mathematically undefined with nothing to cross-test against — every document becomes
ISOLATEDfor lack of test tickers, not because the rule failed a test. Confirm:reg.documents[i].cross_ticker_total == 0. - Fix: use
forge_multi()across at least two tickers if you want genuineGENERIC/PARTIAL/SPECIFICclassification.
"A custom feature column never shows up combined with anything"
- Cause: the column name doesn't match Event Discovery's
{base}_{indicator}_{period}family-recognition convention (§9). It still works as a standalone feature — it's just never paired into a ratio/spread. Confirm: rename it to match the convention and re-run; if candidates involving it as a pair now appear, this was the cause.
"summary_report flags scale_mixed on data I'm confident is fine"
- Cause:
summary_reportflags a magnitude gap of ≥ 2 orders of 10 between bars as a likely data-feed bug (e.g. some bars in cents, others in dollars). A genuine multi-order-of-magnitude price history (e.g. an asset that went from $0.01 to $50) can trigger this legitimately. - Confirm: inspect
[f for f in rep.findings if f.code == "scale_mixed"].message— it reports the actual min/median/max, so you can judge whether the spread is a real historical price range or a unit-conversion bug. - Fix: if it's a real price history, this finding is a false positive you can safely note and move past —
summary_reportnever blocks anything on its own (§11); there's nothing to "fix" in your data.
"RuleDiscovery.__init__ raises ValueError about a mismatched candidate/contract"
- Cause: you passed an
EventCandidatewhoseevent_iddoesn't matchcontract.event_candidate_id— a common mistake when building theby_id = {c.event_id: c for c in candidates}lookup dict incorrectly, or reusing a candidate from a differentforge()run. - Fix: always derive the candidate from the same
ForgeResult/EventDiscovery.run()output as the contract:by_id[contract.event_candidate_id], as shown throughout §9/§12.
"Re-running Alpha Discovery on new data to 'check if the edge still holds' gives worse or nonsensical results"
- Cause: this is a methodology error, not a bug —
AlphaDiscoveryre-derives direction and horizon from whatever data you give it. On history spanning incompatible market regimes, the same boolean condition can have preceded opposite-signed returns in different regimes, and the re-derivation can flip direction or collapse toundetermined, even though the original discovered edge is intact. - Fix: the correct tool for "does a published rule still hold" is
RuleDiscovery, notAlphaDiscovery— it replays the fixed, previously-derived target rather than re-deriving it. See §9's Module 3 API, §12's Use Case 6, and §19'srevalidate()sketch.
"I set GridSpec.buy_delay_bar (or expected the grid to tune it) and the operating point never changed"
- Cause:
build_grid()auto-fansbuy_drop_pct,sell_pct, andtarget_haround the seedBacktestParamsinto a small symmetric grid, but notbuy_delay_bar— it always stays pinned to the single value onbase_params, in every grid cell, unless you explicitly setGridSpec.buy_delay_barto a sequence of your own. This is easy to miss because the other three axes do auto-fan. - Confirm:
grid_dataframe(results)["buy_delay_bar"]has exactly one distinct value even thoughbuy_drop_pct/sell_pct/target_hshow several. - Fix: pass
RuleDiscoveryConfig(grid=GridSpec(buy_delay_bar=(1, 3, 6)))(or whatever values matter for your data) if you specifically want the fill-delay window searched too.
"A Sharpe or Deflated Sharpe I computed by calling validate() directly looks wrong on daily/15-minute data"
- Cause:
rule_discovery.validation.validate(trades, base_rate, n_trials, bars_per_year=24*365, ...)defaultsbars_per_yearto 8760 — an hourly-bar assumption — and does not infer it from your data.walk_forward()(and therefore everyRuleDiscoveryResponseyou get through the normalforge()/RuleDiscovery(...).run()path) always overrides it with the actual median bar spacing measured from your candles, so this only bites a caller who callsvalidate()standalone. - Confirm: an annualised
sharpe_ratio/deflated_sharpeon daily data that looks implausibly large or small relative to the per-trade numbers. - Fix: pass
bars_per_year=explicitly (365.25for daily,35040for 15-minute, …) whenever you callvalidate()outside ofwalk_forward().
"Two identical forge() runs on identical data promote a slightly different number of contracts, or pick a different rule as best"
- Cause: confirmed, verified empirically (850/851/855 promoted across repeated runs on the same full-history dataset).
feature_generator.pyiteratesset()intersections of column names (e.g.set(roll_min) & set(roll_max)) whose iteration order depends on Python's per-process string hash randomization (PYTHONHASHSEEDunset by default) — that order propagates downstream to which candidate survives a pool-truncating cap (e.g. the AND-composer's_MAX_PAIRS/_MAX_TRIPLES, or the grade-guided composer's pairing order under the two-pass default, §9/§12).forge()itself is not bit-for-bit deterministic across process restarts; it is deterministic within a single already-running process (the hash seed is fixed for the process's lifetime). - Confirm: re-run the exact same script twice, as two separate process invocations (not two calls inside one script) — a differing
len(result.promoted)or a different "best" rule between the two runs confirms this, rather than a real change in inputs/config. - Fix: pin
PYTHONHASHSEED=0(or any fixed value) in the environment before starting the process —examples/wf_period_reduction_test.pydoes this automatically by re-executing itself (os.environ["PYTHONHASHSEED"] = "0"; os.execv(sys.executable, ...)) if the variable isn't already set, since Python only reads it at interpreter startup — setting it viaos.environmid-process has no effect on the current process. Apply the same re-exec pattern (or set the environment variable before launching Python) in any script or service where reproducing an exactforge()result across runs matters, e.g. a CI regression test or an audit trail. - Scope: this affects exact reproducibility of a specific run, not correctness — every individual verdict is still computed honestly; it's the population of what gets a chance to be measured (which candidate survives a cap) that can shift by a handful of contracts between unpinned runs.
22. Best Practices
- Run
summary_report()before every discovery session, and decide explicitly what to do withhas_critical/has_warnings. It costs almost nothing and the library will never do this for you (§11). - Pass
ed.df(orForgeResult.event_frame), not the original KPI Table, to anything downstream of Event Discovery when building the pipeline by hand. This is the single most common source of aKeyErroron a derived feature column (§9). - Treat a
PARTIAL-EDGE-only result as informative, not as a failure to fix. Given the library's own documented history of a previously-too-permissive pipeline (§14), a run that produces mostlyPARTIAL-EDGEand few or zeroEDGEis very often the pipeline being honest about a genuinely difficult dataset — checkrejection_reasonsbefore assuming something is misconfigured. - Use
forge_preset()rather than hand-assembling three config objects, unless you have a specific reason not to. The presets exist specifically to keep M1/M2/M3 settings mutually consistent (§10) — a common way to get a subtly-wrong result is tuning one module's config without correspondingly adjusting another's. - Pin your
forgedgeversion and treat apytest-style golden test on your own downstream logic as a first-class part of your test suite (§18). The library's own behavior has legitimately changed between versions (§14's whole narrative is a series of such changes); code depending onforgedgeshould notice when that happens, on your own schedule, not silently. - Use
RuleDiscovery, neverAlphaDiscovery, to check whether a previously discovered edge still holds on new data. This is the single most consequential correctness rule in this manual (§21). - Log
result.ledger.describe()andresult.calibration.summary()(or.tippett_p) alongside every discovery run you persist. They're cheap, already computed, and are exactly the numbers you'll want later if you ever need to explain why a given verdict was or wasn't reached.
23. Anti-patterns
- Re-deriving Alpha Discovery's target on fresh data to "monitor" a published rule. Covered in depth in §21 — the correct tool is
RuleDiscovery. Doing this wrong doesn't crash; it silently produces a worse-looking (orundetermined) result that reads as "the edge decayed" when the actual cause is methodological. - Turning off the rotation null (
fast_null=False) to "get more EDGE verdicts." This does not make your rules better — it removes the check that was specifically added, with measured justification (§14), because the pipeline's earlier default behavior promoted noise nearly as often as signal on low-frequency data. If you find yourself doing this to make a result look better, that is exactly the situation the gate exists to catch. - Copying
GateParams(min_act=..., min_months=..., max_conc=..., min_tpm=...)from this repository's ownexamples/*.pyscripts without checking they're current. They aren't (§11, §21) — this specific anti-pattern is not hypothetical, it's reproducible today against this exact codebase. - Treating a non-
EDGEverdict as "no signal" without readingrejection_reasons. APARTIAL-EDGEor evenNON-EDGEresponse often carries specific, actionable diagnostic strings (a rotation-null p-value, an active-months ratio, a fill-rate number) that tell you exactly what's marginal — discarding the response object and only checkingverdictthrows that information away. - Building
AlphaDiscoverydirectly (bypassingforge()) on daily-or-slower data without settinghorizon_gridexplicitly.forge()'s automatic daily-grid substitution (§7) only happens insideforge()itself — constructingAlphaDiscoveryby hand does not get it, and you will silently scan holding periods of up to 48 days by default (§9, §21). - Assuming a promoted
AlphaContract(status="HYPOTHESIS") is itself a trading signal. It's the output of Module 2, not Module 3 — it hasn't been backtested with realistic order mechanics or validated out of sample by Rule Discovery yet. TreatAlphaContract.status == "HYPOTHESIS"as "worth backtesting," not "worth trading." - Mocking
forge()in your own application's tests. As covered in §18 — there's no I/O to fake, and the entire value of the call is in its real statistical output; a mock tells you nothing about whether your integration code is correct.
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
- AND composition — combining two (or three) single-column events with a boolean AND to form a more specific compound event, itself re-checked against the Consistency Gate.
- AlphaContract — Module 2's output object: an event candidate plus a derived economic target (direction, holding period, take-profit) and a statistical grade.
- base rate — the unconditional win rate of the derived binary target, measured across all bars, not just active ones — the baseline
liftis measured against. - Consistency Gate — Module 1's filter on raw event candidates: minimum activation rate, maximum dispersion (burstiness), and (in
"episode"mode) a minimum episode count. - DerivedTarget — the
(holding_period_h, sell_pct, direction)triple Alpha Discovery computes for one event candidate, from the data, never assumed. - dispersion (Index of Dispersion) — Variance/Mean of an event's monthly activation counts; 1.0 for a Poisson (memoryless) process, higher for bursty/clustered activations.
- EDGE / PARTIAL-EDGE / NON-EDGE / INSUFFICIENT-DATA — Rule Discovery's four possible verdicts (§8, §9, §15);
is_edgeis true for the first two. - embargo — an optional additional buffer of bars at the start of an OOS window, beyond what purging removes, to further quarantine serial correlation. Opt-in, default 0 (§15).
- EventCandidate — Module 1's output object: an immutable boolean expression plus its activation statistics and gate result.
- fill rate — the fraction of signals where the simulated limit order actually filled within
buy_delay_barbars. - HypothesisLedger — a
ForgeResult.ledgerobject recording how large a session's search surface was (candidates × horizons × grid cells) — bookkeeping, not a correction. - IC (Information Coefficient) — the Spearman correlation between a feature's raw value and the forward return at the derived horizon, computed in-sample.
- KPI Table — the input
pandas.DataFrame:close+ a datetime source + any number of feature columns. - lift — win rate on active bars minus the base rate.
- MAE / MFE — Maximum Adverse / Favorable Excursion — the worst/best unrealized return a trade experienced between fill and exit.
- regime — Module 0's 5-level ordered classification of market condition (
STRONG_BEAR…STRONG_BULL). - rotation null — a search-level statistical calibration that circularly rotates the
closecolumn (decoupling event timing from outcome) to build an empirical null distribution for the pipeline's own best statistic, correcting for the multiple-testing exposure of the whole search (§14, §15). - RuleDiscoveryResponse — Module 3's output object: the verdict plus every supporting statistic (IS summary, walk-forward result, statistical validation, execution envelope).
- RuleSpec — a lightweight bundle (name, candidate, params, verdict) for replaying a tradeable rule against new data, independent of the Rule Registry.
- TimeBudget — the shared, purged (and optionally embargoed) IS/OOS bar-index split used by Event and Alpha Discovery.
- walk-forward — validating a fixed operating point on a sequence of rolling out-of-sample test windows, each preceded by its own train window, concatenated into one OOS track record.
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.