The event-processing subsystem lets you write standing conditions over events, prices, fundamentals and cohort membership; attach consequences to them; compose those into a strategy that manages a paper book; and replay that strategy over history under a set of refusals designed to stop it flattering you. This page is the deep guide; the User Guide covers the same layers at a walking pace.
execute verb, no order verb, no broker connection and no promote_paper_book anywhere in this system. Every consequence lands in a paper book — a separate store whose write path cannot address one of your real portfolios and refuses one by name. The owner books a trade by booking a trade.filing.break.* (5), filing.roster.* (4), model.proforma.* (11) and book.note.added (1) — named in full in §2.3.get_events tool handler. The SECURITY_STREAM_MAP family — earnings, dividends, splits, SEC filings, transcripts, insider buys and sells, financials, and the rating and ownership changes — has no schedule behind it. A rule on estimate.rating.changed fires only if something calls get_events for that symbol. §2.2b.Everything else on the live list is written by a producer riding a fetch or a scheduled process. The distinction is carried on every table on this page: live on demand dormant.
Everything below is verified against the shipped code. Where the specification (EVENT-PROCESSING-SPEC.md) and the code disagree, this guide follows the code and says so.
get_events only
2.3 DORMANT — nothing writes these
2.4 What is deliberately absent
2.5 Non-event predicates
Seven bands, read top to bottom: the event plane and its producers, the condition layer, the rule layer and the tick, the action layer, the paper book, the strategy layer, and the backtester with its deflation. Refusal paths are drawn in their own visual language — they are not error handling, they are the design.
The diagram carries its own three-state legend and it says the same thing in slightly different words: its CONDITIONAL is this page's live plus on demand together — a writer exists, but it only runs if a key, a process or a gesture does. Where the diagram says CONDITIONAL, the live table's door that must open column names the specific key, process or gesture; the twelve with no schedule at all are broken out separately in §2.2b. DORMANT means the same in both.
Five objects, in this order. Each step refuses at the desk rather than at 3 a.m. inside a tick.
paper book → rule(s) → strategy → daily loop (forward) → backtest (historical)
create_paper_book {
"name": "Modernized G&D (paper)",
"strategy": "Modernized G&D",
"initial_cash": 250000,
"date": "2024-01-02",
"cost_basis_method": "fifo"
}
| argument | notes |
|---|---|
name | how a rule's book addresses it and how the report titles it. Refused if a real portfolio already carries that name. |
strategy | one book per strategy. A second book for a strategy that already has one is refused, naming the first. Defaults to the book name. |
initial_cash | booked as a deposit through the one lot engine (default 0) |
date | the opening deposit date. Set it before the earliest bar a rule may fire on — a ledger whose first buy precedes its own deposit produces a negative equity base, and the report says so. |
cost_basis_method | fifo (default) · lifo · hifo · average · specific |
You can skip this step: create_strategy will create a book for you if you give it book_name and initial_cash.
A rule is a named, versioned condition with a subject scope, optionally carrying actions.
create_rule {
"name": "GD entry — 50-day cross in a cheap cohort",
"text": "WHEN in(cohort \"Modernized G & D\") AND crossover(close, sma(close, 50)) AND pctl(evToEbitda, within=cohort) < 40",
"scope": { "cohort": "Modernized G & D" },
"note": "entry leg"
}
create_rule {
"name": "GD exit — 15% off the 60-day high",
"text": "WHEN close < highest(close, 60) * 0.85",
"scope": { "cohort": "Modernized G & D" }
}
| argument | notes |
|---|---|
name | unique; it is how the firing, the event and the process log refer to the rule |
text or condition | the sugar, or the canonical JSON. The canonical JSON is what is stored; the sugar is regenerated from it on every write. |
scope | REQUIRED. {"symbols":["AAPL","MSFT"]} · {"cohort":"<id or name>"} · {"universe":true}. Resolved live at every tick, never cached. A scope resolving past RULE_TICK_MAX_SUBJECTS (200) is refused whole, not truncated. |
actions | what a firing causes — see §1.3. May also be written in text after =>. |
book | the paper book a propose_* action writes into. A propose action with no book is refused at save. |
enabled | default true. A disabled rule is skipped by the tick and says it was skipped. |
playbook, note | carried verbatim onto every notification |
Test it before you rely on it — this writes nothing: no event, no accrual row, no state, no notification, and the fire count does not move.
test_rule { "rule": "GD entry — 50-day cross in a cheap cohort", "symbol": "MTB", "bars": 500 }
The reply carries the firings it would record, the ones the debounce would suppress, the open sequence partials, the live absent timers, the per-leg point-in-time verdict, and — separately — the historical firings that predate the rule, reported as the record the tape already carried rather than as notifications.
rule_id | condition fingerprint | rule_version | SUBJECT. An edit that moves the fingerprint (the condition) bumps rule_version and resets the per-subject state, archiving what it drops as book.rule.reset. An edit that does not — name, note, scope, playbook, enabled, actions — inherits the state. Renaming a rule must not re-arm it; retagging a proposal must not re-arm it on every name it watches.Nine verbs, from one registry. list_action_verbs prints it live.
| verb | category | what it does |
|---|---|---|
propose_buy | book | appends a PROPOSED buy, sized by the declared size |
propose_sell | book | appends a PROPOSED sell; qty = "all" | {pct} | {shares} |
propose_size | book | appends the DELTA trade moving the position to target_pct of total equity |
flag | record | a label + one of five disclosure severities (note, caveat, warning, refusal, provenance) |
annotate | record | writes a research note through notes.add — the one note store, unchanged |
notify | record | delivers to the agent inbox as alert_fired |
run_playbook | record | resolves through playbooks.applicable(), unchanged |
emit | record | writes a book.* event back into the log which a later tick may match |
set_state | record | writes the per-subject named state a {"state":…} leaf reads |
update_rule {
"rule": "GD entry — 50-day cross in a cheap cohort",
"book": "Modernized G&D (paper)",
"actions": [
{ "action": "propose_buy", "size": { "kind": "fixed_pct", "pct": 4 }, "tag": "gd-entry" },
{ "action": "flag", "label": "GD entry signalled", "severity": "note" }
]
}
Or in the sugar, which create_rule also accepts:
WHEN rsi(14) < 30 THEN crossover(close, ema(close,20)) WITHIN 10d => PROPOSE BUY size=fixed_pct(2) tag="oversold-reversion"
Bounds: ACTIONS_MAX_PER_FIRING = 8 (refused at save, with the count); CASCADE_MAX_DEPTH = 3 for emit chains; PAPER_BOOK_MAX_POSITIONS = 60 (a store bound, not a strategy policy). An emit may only write on the book plane, and a rule that emits a kind it also matches is refused by static analysis at save time — that is a rule that fires itself forever.
create_strategy {
"name": "Modernized G&D",
"universe": { "kind": "cohort", "ref": "Modernized G & D" },
"rules": [
{ "rule": "GD entry — 50-day cross in a cheap cohort", "role": "entry", "priority": 0 },
{ "rule": "GD exit — 15% off the 60-day high", "role": "exit" }
],
"policy": {
"rank_entries_by": "fcfYield",
"sizing": { "kind": "fixed_pct", "pct": 4 },
"max_positions": 20,
"max_weight_pct": 8,
"max_sector_pct": 30,
"cash_floor_pct": 5,
"min_position_pct": 0.5,
"cash_yield": { "source": "tbill", "series": "DTB3" },
"rebalance": { "cadence": "quarterly", "drift_tolerance_pct": 5 },
"costs": { "cost_bps": 10, "slippage_bps": 0, "adv_participation_max_pct": 10 }
},
"book": "Modernized G&D (paper)"
}
Universe kinds — explicit (a symbol list), cohort, screen, universe. The universe kind parses and saves but resolveUniverse refuses it at run time with its reasons, so in practice you have three.
Roles — entry · exit · adjust · veto. The role belongs to the strategy, so the same rule can be one strategy's entry and another's veto.
rank_entries_by is required. On any day where more candidates signal than fit, something decides which get the cash and the slots. If the strategy does not say, the answer is hash iteration order. Legal forms:
"fcfYield", "-pe", "fcfYield * 2 - debtToEquity""signal_time" — earliest signal first, the honest first-come rule"random(<seed>)" — a declared, reproducible drawPolicy defaults (from policyDefaults()): max_positions 20 · max_weight_pct from sizing.MAX_SINGLE_PCT · max_sector_pct null (unconstrained) · min_position_pct 0.5 · cash_floor_pct 5 · cash_yield {source:"tbill", series:"DTB3"} · rebalance {cadence:"never", drift_tolerance_pct:5} · costs {cost_bps:10, slippage_bps:0, adv_participation_max_pct:10} · ordering "exits_then_entries" · sizing null · targets null · rank_entries_by null (refuses).
Rebalance cadences: never · monthly · quarterly · annual · on_drift.
Sizing — seven arms, each stating its budget basis, i.e. a share of what:
| arm | budget basis | needs a strategy? |
|---|---|---|
fixed_cash | a stated dollar amount | no |
fixed_pct | a share of TOTAL EQUITY, fully marked at the fill bar | no |
equal_weight | total equity ÷ the declared slot count | no |
grade_ceiling | a share of equity capped by sizing.js's own ceilings | no |
risk_parity | the book's target portfolio volatility, inverse-vol weights scaled against pf-risk.js's own covariance | yes |
atr_risk | a fixed fraction of equity put at risk — shares = (risk_pct% × equity) / (atr_mult × ATR) | yes |
from_targets | the strategy's stored policy.targets, through analytics.rebalancePlan | yes |
The last three refuse by name on a paper book that belongs to no strategy: a budget shared across positions has no meaning without something that owns the positions. list_sizing_methods prints the union and the constraint set live.
Constraints compose in this order, and when one binds the payload names it plus every other that would also have bound:
max_positions → max_weight_pct → max_sector_pct → cash_floor_pct
→ cash_available → min_position_pct → adv_participation_max_pct
max_weight_pct and max_sector_pct are ceilings — they shrink the order and name themselves. The rest refuse. A candidate whose sector is unknown while a sector cap is set is refused, not treated as unconstrained. min_position_pct is a refusal, not a rounding.
strategy_version and writes a dated methodology break (book.strategy.policy_changed) — and does not reset the book. A rule's partial match is a claim about a condition and means nothing under new semantics; a book is a record of proposals actually made at prices that actually printed. An equity curve that spans a break is more than one measurement, and every payload covering such a window says so. A cosmetic edit (name, note, enabled) inherits.The daemon runs event-engine (the rule tick) once per weekday and strategy-loop after it. Both are declared processes with pause/resume through control_process. The manual doors:
run_rule_tick {}
run_strategy_loop { "strategy": "Modernized G&D", "days": 1 }
The tick runs on empty days by design: an absent … within N fires at the expiry of its window, on a day when nothing happened, so a data-triggered engine would be silent on the one day it was needed. A tick that ran and fired nothing records a success saying so, in those words — which is a different answer from silence, and silence reads red past EVENT_ENGINE_TICK_GRACE_HOURS (6).
The loop is idempotent on the live path: a day already processed is not re-walked, because re-walking it would double every fill, every dividend and every interest accrual.
Read what happened:
get_strategy_run { "strategy": "Modernized G&D" } // the most recent day, in the loop's own order
get_rule_history { "rule": "GD entry — 50-day cross in a cheap cohort" }
get_rule_evaluations { "rule": "GD entry — 50-day cross in a cheap cohort", "fired": false }
get_paper_book { "book": "Modernized G&D (paper)" }
get_event_feed { "plane": ["book"], "limit": 50 }
get_rule_evaluations is the forward-accrual record: one dated row per (rule, subject) evaluated, including the non-firings, because the non-firings are the data — a record of only the fires cannot compute a hit rate, a base rate or a false-positive count. Its coverage counts days, not rows: a rule ticking over 40 names for 3 days has 120 rows and three days of record.
1 corporate actions and dividends dated t (a split applied after the signal prices it in pre-split units)
2 cash accrues, before any trade (interest on the opening balance is earned whatever the day does)
3 the knowability cut + the mark (bars ≤ t, events observed_at ≤ close(t), and nothing else)
4 veto rules (removes a subject from ENTRY, and is RECORDED)
5 exit rules, on held names (BEFORE entries — an exit frees cash AND a slot)
6 entry candidates collected, then RANKED
7 size + constrain, in rank order (each miss records skipped_capacity with its binding constraint)
8 fills at the bar CLOSE (cost_bps per side booked as a `fee` transaction)
9 rebalance, if t is a rebalance date (through analytics.rebalancePlan — read, not re-derived)
10 mark and RECONCILE (analytics.reconcile THROWS above one cent; no flag skips it)
Also handled at step 3b: a name whose first bar postdates t is excluded from that day's cross-section and the exclusion is counted. Held names are never excluded — a position cannot be held in a name that had not listed.
server/event-registry.js builds 101 kinds across 37 families and 7 planes, every family derived from a vocabulary its own producer publishes. A kind is always plane.family.name, three segments. A trailing * in an event leaf matches one segment: filing.break.*, model.*.*.
Written by a producer riding a fetch that was already happening, or by a scheduled process. The door that must open is named on every row.
get_events onlyThe SECURITY_STREAM_MAP family. No schedule stands behind it. A rule on one of these fires only if something calls get_events for that symbol.
Registered, compilable, saveable — and structurally incapable of firing. Do not write a rule against one.
68 + 12 + 21 = 101, the derived total. The three counts are the whole registry; nothing is in two of them and nothing is in none.
EVENT-PROCESSING-SPEC.md §18 records the registry as "75 kinds across 27 families". That was true at 5.8.0. The registry has since absorbed the data-change kinds (5.11.0), the action and strategy kinds (5.12.0/5.13.0), the economic-release kinds (5.21.0) and the user-calendar kinds (5.29.0). 101 / 37 is the derived number today — require('./server/event-registry').build() reports it, and no list needs editing to keep it true.The plane answers how do we know this, which is the question the backtester refuses on. It is deliberately not a browsing category.
| plane | what | default reconstructability |
|---|---|---|
tape | daily OHLCV bars and everything derived from them | yes |
filing | as-filed statements, corporate actions, dividends, splits, insider forms, roster changes | gated — only where a filing date exists |
estimate | analyst estimates, ratings, targets, ownership aggregates | no — served as-revised, no vintage |
model | our own output: momw fair value, gap, band percentile, residual, E11 | recorded_days — and only within one judgment generation |
membership | cohort, index, universe and portfolio membership | forward_only |
external | FRED and macro series, provider news | no — we read the current vintage |
book | the owner's ledger, research notes, rule firings, the paper book | yes |
A rule written against any of these can fire, provided the door that produces them is being opened. Not one producer adds a polling loop; every one rides a fetch that was already happening.
| kinds | producer | the door that must open | matchable payload fields |
|---|---|---|---|
external.news.item | event-producers.recordNewsItems | get_news, or the ingest cycle (which rides the repricing scan) | title (verbatim, opaque — match with contains), publisher, published_at, url, symbols, symbol_count, text_chars/has_text, story_key, publisher_count, publishers, also_titled |
filing.ingest.* — 14 classes: periodic_report, current_report, securities_offering, insider_form4, ownership_stake, proxy_statement, filing, earnings_reported, earnings_scheduled, dividend_declaration, split_announced, transcript_available, news_item, unknown_source | ingest.runCycle → recordIngestItems | the repricing scan's cycle, or run_ingest | source_id, intake_source, taxonomy_kind, basis, headline, url, tags |
filing.statement.filed, .restated, .bulk_restated | data-change.observeStatements | get_financial_statements, edgar.companyConcept | statement, period, period_end, field, old_value, new_value, change, change_pct, filing_date, observed_before, source_plane |
external.series.observation, .revised | data-change.observeSeries | fred.series — the one door every FRED read passes through (needs a FRED key) | series_id, period, value, prior_period, prior_value, change, change_pct, units |
estimate.field.moved | data-change.observeFields | get_company_profile, get_analyst_data | field, panel, old_value, new_value, change_pct, categorical, observed_before |
model.* — 16 kinds via SCAN_MAP (model.coverage.gained/.lost/.refused_regime/.evidence_floor_crossed, model.frame.rotated/.value_step/.divergence_gate_changed, model.band.percentile_crossed, model.gap.moved/.rich_unrationalized/.cheap_tail, model.risk.shared_concentration, model.initiative.ramp_diverged, model.defect.vintage_thin/.basis_suspect/.vehicle_unmodeled) plus filing.shares.discontinuity | scan.js → recordScanRun | the repricing-scan process | detail, proposal |
model.divergence.* — 5 kinds | divergence.record | any divergence-ledger write | classification, signed_pct, basis |
membership.cohort.entered, .exited | membership-ledger.observe | cohorts.writeCache, i.e. the cohort-refresh process | cohort_id, cohort_name, cohort_kind |
membership.universe.snapshot_taken, _refused | screener.js, pit.js | the universe-archive and pit-snapshot processes | month, exchanges, count, reason |
external.release.printed, .rescheduled, .missed | econ-calendar.js | the econ-calendar-pull process (needs a FRED key) | event_id, release_id, release_name, reference_period, consensus_at_print, actual_as_printed, surprise, surprise_pct, series_ids |
book.calendar.event_asserted, _amended, _retracted | user-event.js | add_calendar_event / update_calendar_event / delete_calendar_event | chain_id, record_id, date, title, association_kind, association_ref, time_source, admission |
book.rule.fired | alerts.js and rule-engine.js | any firing | rule_id, rule_name, rule_version, fingerprint, trigger, sugar, why, inputs, playbook — or, from the legacy path, rule_type, value, detail |
book.rule.reset, book.rule.refused | rules.js, rule-engine.js | a fingerprint-moving edit; a cap being hit | rule_id, versions, fingerprints, cap, count, reason |
book.proposal.buy, .sell, .size, book.strategy.skipped_capacity, book.action.refused, .flagged, .annotated, book.state.set | rule-actions.js (and strategy-loop.js for buy/sell/skipped) | a firing with actions; a strategy loop day | book_id, tx_id, symbol, shares, price, size_kind, size_basis, binding, reason, label, severity, name, value |
book.position.opened, .closed | rule-actions.js emit only | a rule that emits them | rule_id, rule_name, emitted_by, cascade_depth, why |
book.strategy.policy_changed | strategy.js | a forming strategy edit | strategy_id, versions, fingerprints, changed, book_id, reason |
book.strategy.reconcile_failed | strategy-loop.js | a reconciliation residue above one cent | strategy_id, book_id, reason |
observed_at — an event becomes true on the first bar at or after it became knowable to us, not on the date it happened. An event whose observed_at is null is never knowable and is excluded rather than dated by its occurrence.get_eventsThese twelve are the SECURITY_STREAM_MAP family, produced by event-producers.recordSecurityStream. Their one door is the get_events tool handler, and no schedule stands behind it.
A rule on estimate.rating.changed is not dead — it is conditional on something calling get_events for that symbol. If nothing does, the rule ticks every weekday and never fires, and the reason is not in the rule. If you rely on one of these, make the call part of the routine that the rule depends on.
filing.earnings.reportedfiling.dividend.paidfiling.split.effectivefiling.sec.filedfiling.transcript.publishedfiling.insider.boughtfiling.insider.soldfiling.financials.releasedestimate.rating.upgradedestimate.rating.downgradedestimate.rating.changedestimate.ownership.changed
Matchable payload fields: label, detail, stream_source, sentiment + sentiment_basis (only where derived from a structured numeric), url.
Twenty-one of the 101 kinds resolve in the registry and no code path records them. A rule matching one of these compiles, saves, ticks every weekday and is structurally incapable of firing. Do not write a rule against them.
| kinds | count | declared producer | status |
|---|---|---|---|
filing.break.accounting_change, .acquisition, .divestiture, .leadership, .restructuring | 5 | server/model-events.js | the module does not require server/event-log.js at all |
filing.roster.executive_joined, .executive_left, .pay_changed, .title_changed | 4 | server/executives.js | same |
model.proforma.* — guidance, filing_update, tariff, input_cost, sector_demand, inflation_regime, discount_rate_shift, si_gate_probability, si_gate_resolved, si_capital_revision, si_scale_revision | 11 | server/proforma-events.js | same |
book.note.added | 1 | server/events.js | excluded by design — STREAM_EXCLUDED skips it, because a research note is our own record with its own producer at write time |
The twenty-one, in full:
filing.break.accounting_changefiling.break.acquisitionfiling.break.divestiturefiling.break.leadershipfiling.break.restructuringfiling.roster.executive_joinedfiling.roster.executive_leftfiling.roster.pay_changedfiling.roster.title_changedmodel.proforma.guidancemodel.proforma.filing_updatemodel.proforma.tariffmodel.proforma.input_costmodel.proforma.sector_demandmodel.proforma.inflation_regimemodel.proforma.discount_rate_shiftmodel.proforma.si_gate_probabilitymodel.proforma.si_gate_resolvedmodel.proforma.si_capital_revisionmodel.proforma.si_scale_revisionbook.note.added
count(event(filing.break.*), 250d) == 0 as a veto. It compiles and it is always true today, because nothing has ever written a filing.break.* event. That is a correct veto (it never blocks) and a misleading signal.Check before you commit. get_event_feed { "vocabulary_only": true } returns the registry and the observed volume without reading any events, so you can see what your installation has actually recorded. check_predicate_reach { "predicates": ["filing.break.acquisition"] } gives the point-in-time verdict per predicate.
There is no external.release.scheduled and no book.calendar.event_occurred. A schedule has not happened; the log pages backwards, so thousands of future-dated records would make "what happened recently" answer with things that have not. This is why the legacy earnings_within_days alert cannot be migrated: it needs filing.earnings.scheduled, which does not exist.
The nearest workable substitute is filing.ingest.earnings_scheduled, which does exist and is written by the ingest cycle — with the caveat that it is an intake classification from structured fields, not a publisher's commitment, and its coverage is whatever your ingest universe covers.
An event leaf is one of six leaf kinds. The others do not need the log at all:
field — the 520-field screen vocabulary, evaluated through screener.applyOp (the one comparator)series — an expr-series expression over bars only (open high low close volume hlc3 ohlc4 time)indicator — the one indicator registry, with the registry's own parameter namesmember — cohort:<id or name>, against the dated membership ledgerstate — the per-subject named state a set_state action wrotemember is deliberately restricted to cohorts. Index and universe membership are not offered: index membership resolves live from the provider's current constituents and nothing archives it, so a leaf naming it would be a claim this system cannot keep.
The canonical form is JSON. A text sugar compiles to it and is never the stored form — a stored string is a parser dependency forever. compile_condition round-trips both directions and prints the fingerprint, the leaves with their planes, the durations with their clocks, and the operators used.
| suffix | means |
|---|---|
20d | 20 trading days (bars) — a holiday is not a bar and does not consume the window |
20c | 20 calendar days — a holiday consumes one, so the same nominal window covers fewer bars |
1mo, 1q, 1y | calendar months, quarters, years |
There is no default and there will not be one: "within a month of earnings" and "within twenty bars" are different rules. The refusal names both suffixes.
| trigger | fires on |
|---|---|
edge | the false→true transition. The default, because changing it would silently change every migrated alert. |
level | every bar the condition holds — requires an explicit debounce, refused at save. A level condition with no debounce is a firehose. |
exit | the falling edge |
debounce is {"once_per": "<duration>"}.
Caps. CONDITION_MAX_NODES 64 · CONDITION_WINDOW_MAX_BARS 2520 (about ten years of tape) · CONDITION_DEDUPE_HORIZON_DAYS 370.
WHEN close > 250
{ "trigger": "edge", "when": { "series": "close > 250" } }
WHEN rsi(14) < 30 ONCE_PER 30d
{
"trigger": "edge",
"debounce": { "once_per": "30d" },
"when": { "indicator": "rsi", "params": { "period": 14 }, "op": "lt", "value": 30 }
}
WHEN close >= highest(high, 252) ONCE_PER 30d
{
"trigger": "edge",
"debounce": { "once_per": "30d" },
"when": { "series": "close >= highest(high, 252)" }
}
Note the fidelity difference migrate_alerts discloses: the legacy alert compared the live quote's price to the provider's yearHigh (an intraday high over the trailing 52 calendar weeks); the rule compares the close to the highest high of the last 252 bars. They agree in the ordinary case and differ at the window edges.
WHEN rsi(14) < 30 THEN crossover(close, ema(close, 20)) WITHIN 10d
{
"trigger": "edge",
"when": {
"then": [
{ "indicator": "rsi", "params": { "period": 14 }, "op": "lt", "value": 30 },
{ "series": "crossover(close, ema(close, 20))" }
],
"within": "10d"
}
}
A then must state its window. An unbounded sequence is a partial match that never expires, and a partial that never expires is a memory leak wearing a semantic. A one-step then is the honest spelling of "X occurred within the trailing window".
WHEN event(filing.earnings.reported) AND absent(close <= ref(close, 1), 5d)
{
"trigger": "edge",
"when": {
"absent": { "series": "close <= ref(close, 1)" },
"after": { "event": "filing.earnings.reported" },
"within": "5d"
}
}
An earnings gap that does not fill. It fires on the fifth bar after the earnings event, if no bar in between closed back at or below the prior close — that is, on a day when nothing happened. This is why the tick is calendar-triggered and why a missed tick is a missed firing that leaves no other trace.
An absent requires an anchor (after). Standing alone it would assert "this never happened", which is a claim about all of history rather than a rule. In the sugar the compiler lifts the conjunction: the other conjuncts become the anchor. Two anchorless absent terms in one conjunction is refused.
filing.earnings.reported is one of the twelve §2.2b kinds — the anchor only exists on symbols for which something has called get_events.Cohort membership × tape × cross-sectional rank × a window aggregation used as a veto:
WHEN entered(cohort "Modernized G & D")
THEN close > sma(close, 200) WITHIN 20d
AND sustained(pctl(mom_10, within=cohort) > 90, 5d)
AND netDebtToEbitda < 3
AND count(event(filing.break.*), 250d) == 0
ONCE_PER 60d
Canonical:
{
"trigger": "edge",
"debounce": { "once_per": "60d" },
"when": {
"all": [
{
"then": [
{ "event": "membership.cohort.entered", "match": { "cohort": "Modernized G & D" } },
{ "series": "close > sma(close, 200)" }
],
"within": "20d"
},
{
"sustained": { "field": "mom_10", "op": "pctl_gt", "value": 90, "within": "cohort" },
"for": "5d"
},
{ "field": "netDebtToEbitda", "op": "lt", "value": 3 },
{ "count": { "event": "filing.break.*" }, "over": "250d", "op": "eq", "value": 0 }
]
}
}
Read the reach report this compiles to before you trust it. Four of those legs sit on four different planes: membership (forward_only), tape (yes), model/enrichment for the percentile, and filing (gated) — and the filing.break.* count is over a dormant family, so it is always zero. evaluate_condition returns the per-leg verdict.
doc := "WHEN" cond clause*
clause := "ONCE_PER" dur | "TRIGGER" (edge|level|exit) | "SCOPE" cohort "<name>"
cond := andExpr ("OR" andExpr)*
andExpr := seqExpr ("AND" seqExpr)*
seqExpr := unary ("THEN" unary)* ("WITHIN" dur)?
unary := "NOT" unary | primary
primary := "(" cond ")" | leaf
leaf := entered(cohort "…") | exited(cohort "…") | in(cohort "…")
| event(kind[, key=value]…) | state("name") is "value"
| sustained(cond, dur[, at_least N])
| count(cond, win) cmp num
| agg(max|min|sum|avg, <series expr>, win) cmp num
| absent(cond, dur)
| pctl(field, within=cohort) cmp num
| <expression> — classified against the registries
win := dur | "tumbling" dur | "session" "gap" dur
Windows: sliding (the default), tumbling size, session gap. Aggregations: max, min, sum, avg.
=> is refused inside a condition, by name. An action belongs to the rule, not the condition, because the condition's fingerprint is the per-subject state key — folding an action in would make retagging a proposal reset every open partial and re-arm the rule on every name it watches. create_rule accepts the full grammar including => and stores the two halves separately.
Before any evaluation is believed, the evaluator consults server/reach.js per leg. Every number in that report is a live read of the stores, so a verdict changes the day the record is long enough without anyone editing a constant.
| verdict | families today |
|---|---|
yes | tape.bars, model.detections |
gated | filing.statements |
recorded_days | model.surface |
forward_only | estimate.street, model.enrichment, membership.cohort, membership.universe |
no | external.macro, external.container |
A historical evaluation (as_of before today, or point_in_time: true) with any leg on forward_only, no or unresolved refuses by name, naming the leg, its plane and what would fix it.
The evaluator also refuses on its own account, which is a stronger statement: a field leaf reads the current screener row, so at any historical as_of it refuses regardless of the verdict. A gated verdict says the record could support it; the evaluator says whether this reader can. The two are reported separately rather than collapsed.
backtest_strategy {
"strategy": "Modernized G&D",
"from": "2019-01-02",
"to": "2024-12-31",
"initial_cash": 100000,
"warmup_bars": 300
}
from and to are required. A defaulted window is a window nobody chose, and the window is half of what a backtest result means.
A backtest is not a simulator. It drives server/strategy-loop.js run() — the same function the live book runs — with replayClock(from, to) instead of a live clock, writing real portfolio.buildRec records into a paper book of its own, gated by analytics.reconcile on every replayed day, and measured by the existing engine. It computes no return, no Sharpe, no drawdown and no attribution of its own.
Two loop implementations would disagree, and the moment they did every reported number would be a claim about code the live book never ran. A test asserts the live path and the replay path call the same function.
It never touches the strategy's live forward book. That record is the out-of-sample evidence hindsight cannot manufacture, and a replay appending historical proposals into it would destroy it.
The preflight collects every refusal rather than short-circuiting on the first, because "it did not fit" and "it did not fit for four separate reasons" are different findings.
| refusal | why |
|---|---|
Survivorship — a cohort-, screen- or universe-scoped historical run | Running a cohort over today's membership is hindsight twice over: the membership is post-hoc (chosen knowing the outcome) and the members are survivors (the names that left are not in it). Both biases point the same way and both flatter. The refusal names the missing dated membership ledger and the date from which this installation now records one. An explicit symbol list runs, with the caller's own selection bias disclosed Tier 1 as the caller's own. |
Point-in-time — any leg whose reach verdict is gated, recorded_days, forward_only or unresolved | Named per leg, with the rule and the plane. Fundamentals are gated because the provider serves them as revised and restatements are unrecoverable; panel fields and model outputs have no dated archive at all. The engine never substitutes today's value. |
The rank — rank_entries_by over a screen field | The ranking row comes from the screener's current values. Ranking a 2019 candidate set by 2026 fundamentals is not a subtle bias, it is the answer. Permitted: signal_time, random(<seed>), expressions over close. |
| Judgment generations — a window spanning a model re-fit | Refused by default when a model-plane predicate is in play. acknowledge_generations: true looks anyway and stamps the generations spanned on every result the run produces, not only the summary. |
| Window | missing or non-forward from/to |
| Missing rules | the strategy binds rules that no longer exist — named, and the run is refused rather than run over the remainder |
The tape does not reach from | a run that quietly walked a narrower window and reported it as the one you asked for would be a real number over a window nobody chose. It refuses with every symbol's first bar so you can tell a short fetch from a late listing. |
| Fewer than two trading days | not a short backtest; no backtest |
An exclusion leaving fewer than DROP_RERUN_MIN_SURVIVING_NAMES (2) names | below two names there is no cross-section for a result to survive into |
A refused run registers no trial. Nothing ran, so nothing was tried; counting it would inflate the denominator of every later deflation with searches that never happened.
Every run registers its trial in server/search-ledger.js before it computes the result. A run that registered only on success would count the winners and forget the losers, which is precisely the bias the deflation exists to undo. The trial count rides every payload Tier 1: a result rendered without its trial count is a defect.
The trial key is (universe, from, to, cadence). The trial record has no prune verb, no cap and no retention window — a Deflated Sharpe computed against a pruned trial count is a lie. Runs are prunable (prune_backtest_runs, dry-run by default, two named depths); trials are not, and the prune path structurally cannot reach them.
Read the record with get_strategy_trials.
At or above DSR_MIN_TRIALS (5) the engine reports the DSR (Bailey & López de Prado, 2014):
DSR = Z( ((SR − SR₀) · √(T − 1)) / √(1 − γ₃·SR + ((γ₄ − 1)/4)·SR²) )
all quantities per-observation (daily). It corrects for the three things that actually inflate a strategy backtest:
SR₀ — the expected maximum Sharpe under the null, i.e. what the best of N tries looks like when none of them has any edge at all;γ₄ here is raw kurtosis, not excess);How to read the number. DSR is the probability that the true Sharpe exceeds zero, given the search that produced this one. It is not a p-value on the return, it is not a forecast, and it does not become a recommendation at any value.
Below DSR_MIN_TRIALS the engine reports the Šidák deflation of the nominal per-trade p-value — 1 − (1 − p)^k — and says which statistic it used. It does not fabricate the trial variance V: a variance from fewer than five points is noise wearing a formula.
Computed inside the engine over the clock's own days, so no caller argument can request the flattering half. It reports the holdout trade count because a strategy with four holdout trades has not been tested (MIN_HOLDOUT_TRADES = 10). Three verdicts:
rerun_backtest_dropping_top_names { "run_id": "<the parent run>", "n": 2 }
The deflation asks how many things did you try? This asks how much of it was one lucky name? It replays the same strategy over the same window with the top contributor(s) removed from the universe — the exclusion applied where the universe resolves, never inside the walk.
Contributors are ranked by contribution to return through analytics.contribution, by absolute magnitude with the sign on every row, because a large loser concentrates a result exactly as much as a large winner. The closed-lot ranking is printed beside it, and the two can legitimately disagree — a position still open on the last day is invisible to closed-lot P&L.
Ranking by contribution to risk is accepted and refused by name, for two reasons, the second being the interesting one: the re-run already reports the risk answer, because the change in Sharpe and in drawdown when a name is removed is that name's realised risk contribution — measured rather than decomposed.
Drops are cumulative: drop 1 removes the top name; drop 2 removes the top two together. That is a decay curve, which is the reading that means something. DROP_TOP_NAMES_DEFAULT = 1, DROP_TOP_NAMES_MAX = 3; an N above the cap is refused with its count, never silently truncated.
A drop re-run is not a trial. It registers as a derived record linked to the parent and does not increment the trial count the DSR is computed against — nobody runs one hoping it wins, and counting it would mean that the more carefully a result was checked, the worse its deflated statistic read. It cannot launder a search either: a run is admitted as derived only when it is structurally a subset re-run of a registered parent (same strategy, same window, something actually excluded, and a strictly smaller surviving universe). Anything else registers as a full trial and says it was downgraded. Read them with get_derived_runs.
The reading vocabulary, printed descriptively and never as advice:
| verdict | when |
|---|---|
survived | the drop retains at least DROP_RERUN_COLLAPSE_RETAINED_PCT (50%) of the headline return |
collapsed | below it — every other name together contributes less than the one dropped |
reversed | the sign flips |
unreadable | the headline return is zero, so there is no share of it to retain |
This is the Wave-5 backtest this house actually ran, over AAPL, MSFT, JNJ, KO, XOM, 2019–2024, reconcile residue 0 on all 1,510 days:
| run | total return | reading |
|---|---|---|
| headline | +53.36% (Sharpe 0.54, max DD −26.55%) | a respectable six-year result |
| without AAPL | +18.74% | 35.1% of the headline retained → collapsed |
| without AAPL and MSFT | −7.70% | sign flipped → reversed |
Two further readings from that run, both of which are the machinery working:
get_backtests {} // the tree — derived runs nested under their parent
get_backtest_detail { "run_id": "…" } // the panel payload: honesty block, curve, blotter, drops
get_backtest_run { "run_id": "…", "report": true } // the full portfolio report over the run's book
The detail runs markers → honesty block → counterfactual → numbers, in that order, and a run with no trial block reports renderable: false — the refusal prints instead of the performance table, the curve and the blotter, behind the same gate. A derived run is never listed as a peer; it nests under the run it checks.
A momentum-with-a-value-gate strategy over five explicit names, forward-live and then backtested. Every call below is real and in the shipped tool surface.
create_paper_book {
"name": "Trend/Value demo (paper)",
"strategy": "Trend/Value demo",
"initial_cash": 100000,
"date": "2019-01-02",
"cost_basis_method": "fifo"
}
Note the condition is pure tape, which is what makes it backtestable: every leg's reach verdict is yes.
create_rule {
"name": "TV entry — reclaim the 200-day after an oversold print",
"text": "WHEN rsi(14) < 35 THEN crossover(close, sma(close, 200)) WITHIN 20d ONCE_PER 90d",
"scope": { "symbols": ["AAPL", "MSFT", "JNJ", "KO", "XOM"] },
"book": "Trend/Value demo (paper)",
"actions": [
{ "action": "propose_buy", "size": { "kind": "fixed_pct", "pct": 15 }, "tag": "tv-entry" }
]
}
create_rule {
"name": "TV exit — 15% off the 60-day high",
"text": "WHEN close < highest(close, 60) * 0.85 ONCE_PER 20d",
"scope": { "symbols": ["AAPL", "MSFT", "JNJ", "KO", "XOM"] },
"book": "Trend/Value demo (paper)"
}
Nothing is written.
test_rule { "rule": "TV entry — reclaim the 200-day after an oversold print", "symbol": "MSFT", "bars": 600 }
Read reach.legs (both should be tape.bars / yes), partials (a sequence mid-flight is not a rule that failed), and historical_firings (the record the tape already carried — these are not notifications).
rank_entries_by is signal_time, deliberately: it is one of the three forms the backtester permits, so this strategy is backtestable as written.
create_strategy {
"name": "Trend/Value demo",
"universe": { "kind": "explicit", "symbols": ["AAPL", "MSFT", "JNJ", "KO", "XOM"] },
"rules": [
{ "rule": "TV entry — reclaim the 200-day after an oversold print", "role": "entry" },
{ "rule": "TV exit — 15% off the 60-day high", "role": "exit" }
],
"policy": {
"rank_entries_by": "signal_time",
"sizing": { "kind": "fixed_pct", "pct": 15 },
"max_positions": 5,
"max_weight_pct": 25,
"cash_floor_pct": 2,
"cash_yield": { "source": "tbill", "series": "DTB3" },
"rebalance": { "cadence": "never" },
"costs": { "cost_bps": 10, "adv_participation_max_pct": 10 }
},
"book": "Trend/Value demo (paper)"
}
run_strategy_loop { "strategy": "Trend/Value demo", "days": 1 }
get_strategy_run { "strategy": "Trend/Value demo" }
Check three things on the day record: interest (and its source — tbill or zero, and which it used), skipped (each entry that did not fit, with its binding constraint named), and reconciliation (which must be clean, because it throws if it is not).
backtest_strategy {
"strategy": "Trend/Value demo",
"from": "2019-01-02",
"to": "2024-12-31",
"initial_cash": 100000,
"warmup_bars": 300,
"drop_top_names": 2
}
The warmup_bars matters: the entry rule needs a 200-bar moving average, so without a warm-up the first replayed days would evaluate against a null-padded indicator. The fetch names the date it wants, and the run refuses if the tape does not reach it.
get_backtest_detail { "run_id": "<from step 7>" }
preflight.refusals — every refusal that did not fire is as informative as the ones that did.holdout.verdict — trades out-of-sample, and how the two halves compare.verdict per drop, retained_pct, and both rankings.trials — the count, statistic (dsr or sidak), and does_not_fix.performance.twr_net_pct.The forward book accrues out-of-sample evidence a backtest cannot manufacture, one loop day at a time, and it costs nothing but the tick. That record — plus get_rule_evaluations, which writes the value every leg actually had on every day including the non-firings — is strictly better evidence than the backtest, and it is the reason the two books are kept separate.
Stated plainly, because a reader should not have to discover an absence by having something fail.
No broker, no order routing, no "send to broker" affordance, no execute verb, no promote_paper_book. Law 4 is structural in four places: a different file, a write path that cannot address a real book, only the pure functions of portfolio.js reached (asserted by reading both module graphs), and paper: true stamped at the write rather than asserted at the read.
filing.break.* (5), filing.roster.* (4), model.proforma.* (11) and book.note.added (1). See §2.3. A rule against any of them ticks daily and cannot fire.
There is no external.release.scheduled and no book.calendar.event_occurred, deliberately. Consequently earnings_within_days cannot be migrated from the legacy alert layer and is left running on the old path, by name. rating_change was refused for the same class of reason at 5.10.0 and now migrates, because get_events started writing estimate.rating.changed at 5.11.0 — that check is a live read of the log, not a list.
condition-eval.js resolves a field leaf against the current screener row and refuses at any historical as_of. There is no dated panel archive and no as-filed statement reader wired into the evaluator, so no historical backtest can use a fundamental predicate. The spec's §8 claim that "families 1–6 support real multi-decade backtesting today" is aspirational on family 6 (as-filed statement lines) — the code's own §24.4 note records the correction, and the practical reach is families 1–5 plus 20.
Of §7.3's three cross-sectional hazards, only the third is handled: a name whose first bar postdates t is excluded from that day's cross-section and counted. The comparison set as it stood (hazard a) and each member's inputs as they stood (hazard b) are refusals, not features. A pctl(..., within=cohort) leaf refuses to evaluate at any historical date: a rank against today's membership would be hindsight twice over.
Owner-deferred (spec §21.6): there is no standing batch that evaluates every time-series criterion across the universe. Rules are evaluated per (rule, subject) on the daily tick, bounded by RULE_TICK_MAX_SUBJECTS (200).
The bar granularity is daily, honestly stated. There is no short-lot concept in the lot engine — propose_sell on an unheld name is refused by name rather than opening one. No margin, no options. The book is single-currency. There is no automatic optimizer over rule space; if one is ever built it must register every enumerated trial, report DSR against the full enumeration count, and refuse to report a single "best" without its holdout and its drop re-run. Those are gates, not guidelines.
Rules reference this installation's cohorts, custom indicators and models; a shared rule would silently rebind.
server/tuning.jsCASCADE_MAX_DEPTH (used by rule-actions.js, fallback 3) and DEFAULT_CASH_FLOOR_PCT (used by strategy.js, fallback 5) are read through a helper that catches tuning.get()'s "unknown tuning constant" throw and returns the hard-coded fallback. Both therefore work, both report the documented value, and neither can be overridden by the state directory's tuning override file, which is the only override path this system offers. If you want to change either, today you must change the source.
The strategy object, the constraint set, the ranking law, the cash yield and the daily loop are built (Wave 4). The backtester, the holdout, the deflation, the trial record and the drop re-run are built (Waves 5–6). What the spec lists and the code does not have: the continuous-query batch (§6.6 above), ALFRED vintages, an as-filed archive readable by the evaluator, and a dated enrichment-panel archive the ranker could use.
| layer | verbs |
|---|---|
| conditions | compile_condition · evaluate_condition · check_predicate_reach |
| events | get_event_feed · get_event_log · backfill_event_log · get_membership_ledger |
| rules | create_rule · update_rule · list_rules · delete_rule · test_rule · run_rule_tick · get_rule_evaluations · get_rule_history · migrate_alerts |
| actions | list_action_verbs |
| paper books | create_paper_book · list_paper_books · get_paper_book · delete_paper_book |
| strategies | create_strategy · update_strategy · list_strategies · get_strategy · delete_strategy · run_strategy_loop · get_strategy_run · list_sizing_methods |
| backtests | backtest_strategy · rerun_backtest_dropping_top_names · list_backtest_runs · get_backtest_run · get_backtests · get_backtest_detail · get_derived_runs · get_strategy_trials · prune_backtest_runs |
| processes | get_processes · control_process { id: "event-engine" | "strategy-loop" } |
Terminal surfaces, in the rail's own grouping — Administration →
One note on the JSON forms above: parameter names on an indicator leaf are the registry's own — this compiler never invents one, and an unknown parameter is refused naming what the indicator does take. rsi takes period, not length.