Open Trading Surface

Event processing — a working guide

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.

What it is not. There is no 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.

Read this before you write a rule

  1. 21 of the 101 registered event kinds are DORMANT. They resolve in the registry, they compile, they save — and no code path writes them. A rule matching one ticks every weekday and is structurally incapable of firing. They are filing.break.* (5), filing.roster.* (4), model.proforma.* (11) and book.note.added (1) — named in full in §2.3.
  2. Twelve kinds are written only by the 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.

The picture
The architecture, in one diagram
1 · Building one
How to create a strategy, end to end 1.1 The paper book 1.2 The rules 1.3 Actions 1.4 The strategy 1.5 Running it forward 1.6 The daily loop's order
2 · What can trigger a rule
2.1 The seven planes 2.2 LIVE — a producer writes these 2.2b ON DEMAND — get_events only 2.3 DORMANT — nothing writes these 2.4 What is deliberately absent 2.5 Non-event predicates
3 · Conditions
3.1 The rules you will hit first 3.2–3.7 Worked conditions 3.8 The grammar 3.9 The point-in-time gate
4 · The backtester
4.1 What it actually does 4.2 What it refuses 4.3 Trial registration 4.4 The Deflated Sharpe Ratio 4.5 The holdout 4.6 The drop-top-names re-run 4.7 The measured example 4.8 Reading the result
5–6 · Worked & absent
A worked end-to-end example What does not exist Appendix — the tool surface

The architecture, in one diagram

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.

Architecture of the Open Trading Surface event-processing subsystem, in seven bands: the event plane with its producers and the seven planes; the condition layer with leaf kinds and the point-in-time gate; the rule layer with the versioned condition, the subject scope and the daily tick; the action layer with its nine verbs; the paper book; the strategy layer with sizing, constraints and ranking; and the backtester with its preflight refusals, trial registration, deflation, holdout and drop-top-names re-run. Refusal points are marked distinctly throughout.
Scroll the frame to read it at full size — 1680 × 3416. Open the diagram full size (SVG) ↗

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.

1 · How to create a strategy, end to end

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)

1.1 Create the paper book

create_paper_book {
  "name": "Modernized G&D (paper)",
  "strategy": "Modernized G&D",
  "initial_cash": 250000,
  "date": "2024-01-02",
  "cost_basis_method": "fifo"
}
argumentnotes
namehow a rule's book addresses it and how the report titles it. Refused if a real portfolio already carries that name.
strategyone book per strategy. A second book for a strategy that already has one is refused, naming the first. Defaults to the book name.
initial_cashbooked as a deposit through the one lot engine (default 0)
datethe 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_methodfifo (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.

1.2 Create the rules

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" }
}
argumentnotes
nameunique; it is how the firing, the event and the process log refer to the rule
text or conditionthe sugar, or the canonical JSON. The canonical JSON is what is stored; the sugar is regenerated from it on every write.
scopeREQUIRED. {"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.
actionswhat a firing causes — see §1.3. May also be written in text after =>.
bookthe paper book a propose_* action writes into. A propose action with no book is refused at save.
enableddefault true. A disabled rule is skipped by the tick and says it was skipped.
playbook, notecarried 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.

The version law, because it decides whether your rule fires tomorrow. The per-subject state key is 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, actionsinherits the state. Renaming a rule must not re-arm it; retagging a proposal must not re-arm it on every name it watches.

1.3 Attach actions (optional)

Nine verbs, from one registry. list_action_verbs prints it live.

verbcategorywhat it does
propose_buybookappends a PROPOSED buy, sized by the declared size
propose_sellbookappends a PROPOSED sell; qty = "all" | {pct} | {shares}
propose_sizebookappends the DELTA trade moving the position to target_pct of total equity
flagrecorda label + one of five disclosure severities (note, caveat, warning, refusal, provenance)
annotaterecordwrites a research note through notes.add — the one note store, unchanged
notifyrecorddelivers to the agent inbox as alert_fired
run_playbookrecordresolves through playbooks.applicable(), unchanged
emitrecordwrites a book.* event back into the log which a later tick may match
set_staterecordwrites 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.

1.4 Create the strategy

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 kindsexplicit (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.

Rolesentry · 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:

Policy 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:

armbudget basisneeds a strategy?
fixed_casha stated dollar amountno
fixed_pcta share of TOTAL EQUITY, fully marked at the fill barno
equal_weighttotal equity ÷ the declared slot countno
grade_ceilinga share of equity capped by sizing.js's own ceilingsno
risk_paritythe book's target portfolio volatility, inverse-vol weights scaled against pf-risk.js's own covarianceyes
atr_riska fixed fraction of equity put at riskshares = (risk_pct% × equity) / (atr_mult × ATR)yes
from_targetsthe strategy's stored policy.targets, through analytics.rebalancePlanyes

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.

The strategy edit law, and it differs from a rule's. A forming edit (universe, rules, policy) moves the formation fingerprint, bumps 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.

1.5 Run it forward

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.6 The daily loop's order, which is the contract

 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.

2 · What events can trigger a rule

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.*.*.

68

live a producer writes these

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.

12

on demand get_events only

The 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.

21

dormant nothing writes these

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.

Divergence from the spec. 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 todayrequire('./server/event-registry').build() reports it, and no list needs editing to keep it true.

2.1 The seven planes

The plane answers how do we know this, which is the question the backtester refuses on. It is deliberately not a browsing category.

planewhatdefault reconstructability
tapedaily OHLCV bars and everything derived from themyes
filingas-filed statements, corporate actions, dividends, splits, insider forms, roster changesgated — only where a filing date exists
estimateanalyst estimates, ratings, targets, ownership aggregatesno — served as-revised, no vintage
modelour own output: momw fair value, gap, band percentile, residual, E11recorded_days — and only within one judgment generation
membershipcohort, index, universe and portfolio membershipforward_only
externalFRED and macro series, provider newsno — we read the current vintage
bookthe owner's ledger, research notes, rule firings, the paper bookyes

2.2 live — a producer writes these

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.

kindsproducerthe door that must openmatchable payload fields
external.news.itemevent-producers.recordNewsItemsget_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_sourceingest.runCyclerecordIngestItemsthe repricing scan's cycle, or run_ingestsource_id, intake_source, taxonomy_kind, basis, headline, url, tags
filing.statement.filed, .restated, .bulk_restateddata-change.observeStatementsget_financial_statements, edgar.companyConceptstatement, period, period_end, field, old_value, new_value, change, change_pct, filing_date, observed_before, source_plane
external.series.observation, .reviseddata-change.observeSeriesfred.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.moveddata-change.observeFieldsget_company_profile, get_analyst_datafield, 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.discontinuityscan.jsrecordScanRunthe repricing-scan processdetail, proposal
model.divergence.* — 5 kindsdivergence.recordany divergence-ledger writeclassification, signed_pct, basis
membership.cohort.entered, .exitedmembership-ledger.observecohorts.writeCache, i.e. the cohort-refresh processcohort_id, cohort_name, cohort_kind
membership.universe.snapshot_taken, _refusedscreener.js, pit.jsthe universe-archive and pit-snapshot processesmonth, exchanges, count, reason
external.release.printed, .rescheduled, .missedecon-calendar.jsthe 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, _retracteduser-event.jsadd_calendar_event / update_calendar_event / delete_calendar_eventchain_id, record_id, date, title, association_kind, association_ref, time_source, admission
book.rule.firedalerts.js and rule-engine.jsany firingrule_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.refusedrules.js, rule-engine.jsa fingerprint-moving edit; a cap being hitrule_id, versions, fingerprints, cap, count, reason
book.proposal.buy, .sell, .size, book.strategy.skipped_capacity, book.action.refused, .flagged, .annotated, book.state.setrule-actions.js (and strategy-loop.js for buy/sell/skipped)a firing with actions; a strategy loop daybook_id, tx_id, symbol, shares, price, size_kind, size_basis, binding, reason, label, severity, name, value
book.position.opened, .closedrule-actions.js emit onlya rule that emits themrule_id, rule_name, emitted_by, cascade_depth, why
book.strategy.policy_changedstrategy.jsa forming strategy editstrategy_id, versions, fingerprints, changed, book_id, reason
book.strategy.reconcile_failedstrategy-loop.jsa reconciliation residue above one centstrategy_id, book_id, reason
Read the two-timestamp rule before you write an event leaf. Matching uses 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.

2.2b on demand — twelve kinds written only by get_events

These 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.

2.3 dormant — registered, and nothing writes them

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.

kindscountdeclared producerstatus
filing.break.accounting_change, .acquisition, .divestiture, .leadership, .restructuring5server/model-events.jsthe module does not require server/event-log.js at all
filing.roster.executive_joined, .executive_left, .pay_changed, .title_changed4server/executives.jssame
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_revision11server/proforma-events.jssame
book.note.added1server/events.jsexcluded by designSTREAM_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

§4.8 worked example 5 in the spec uses 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.

2.4 What is deliberately absent

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.

2.5 Non-event predicates

An event leaf is one of six leaf kinds. The others do not need the log at all:

member 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.

3 · How to write conditions

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.

3.1 The rules you will hit first

Durations carry their clock, and a bare number is a parse error.

suffixmeans
20d20 trading days (bars) — a holiday is not a bar and does not consume the window
20c20 calendar days — a holiday consumes one, so the same nominal window covers fewer bars
1mo, 1q, 1ycalendar 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.

Triggers.

triggerfires on
edgethe false→true transition. The default, because changing it would silently change every migrated alert.
levelevery bar the condition holds — requires an explicit debounce, refused at save. A level condition with no debounce is a firehose.
exitthe 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.

3.2 Trivial

WHEN close > 250
{ "trigger": "edge", "when": { "series": "close > 250" } }

3.3 An indicator, debounced

WHEN rsi(14) < 30 ONCE_PER 30d
{
  "trigger": "edge",
  "debounce": { "once_per": "30d" },
  "when": { "indicator": "rsi", "params": { "period": 14 }, "op": "lt", "value": 30 }
}

3.4 The 52-week high, migrated from the legacy alert

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.

3.5 A sequence

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

3.6 Negation with timeout — the operator that makes this a scheduler

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.

Note the anchor in this example is an on demand kind. filing.earnings.reported is one of the twelve §2.2b kinds — the anchor only exists on symbols for which something has called get_events.

3.7 A real multi-leg temporal condition

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.

3.8 The rest of the grammar

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.

3.9 The point-in-time gate

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.

verdictfamilies today
yestape.bars, model.detections
gatedfiling.statements
recorded_daysmodel.surface
forward_onlyestimate.street, model.enrichment, membership.cohort, membership.universe
noexternal.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.

4 · How a backtest executes, and how to read its result

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.

4.1 What it actually does

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.

4.2 What it refuses, and why — the preflight

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.

refusalwhy
Survivorship — a cohort-, screen- or universe-scoped historical runRunning 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 unresolvedNamed 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 rankrank_entries_by over a screen fieldThe 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-fitRefused 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.
Windowmissing or non-forward from/to
Missing rulesthe strategy binds rules that no longer exist — named, and the run is refused rather than run over the remainder
The tape does not reach froma 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 daysnot a short backtest; no backtest
An exclusion leaving fewer than DROP_RERUN_MIN_SURVIVING_NAMES (2) namesbelow 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.

Note the reading that follows from the survivorship refusal: in practice a historical backtest runs over an explicit symbol list. That is not a workaround, it is the honest posture — you chose those names today, and the payload says so.

4.3 Trial registration, and why the count matters

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.

4.4 The Deflated Sharpe Ratio — what it means

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:

  1. selection among trials, through 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;
  2. non-normality — strategy returns are skewed and fat-tailed, and the classical Sharpe standard error assumes neither (γ₄ here is raw kurtosis, not excess);
  3. sample length — a 2.0 Sharpe over 60 days and over 15 years are not the same claim.

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.

What deflation does not fix, printed on every payload: it corrects for how many strategies you tried, not for how few independent things you observed. This house has the recorded negative result — the 2026-07-19 premium study survived Šidák at deflated p 0.009 with 5/5 sign-consistent slices and a cluster t of −8.2, and was still an artifact. Only the holdout and the name-drop killed it.

4.5 The holdout

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:

4.6 The drop-top-names re-run — how to read it

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:

verdictwhen
survivedthe drop retains at least DROP_RERUN_COLLAPSE_RETAINED_PCT (50%) of the headline return
collapsedbelow it — every other name together contributes less than the one dropped
reversedthe sign flips
unreadablethe headline return is zero, so there is no share of it to retain

4.7 The measured example — read this one carefully

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:

runtotal returnreading
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
Three of the five names lost money over six years while the headline showed a 53% gain. The deflation would not have told you that. The holdout might not have. The name-drop did, in one call.

Two further readings from that run, both of which are the machinery working:

4.8 Reading the result

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.

Order of reading, in this house's terms: the refusals first, then the holdout and the name-drop, then the deflation, and only then the return.

5 · A worked end-to-end example

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.

Step 1 — the book.

create_paper_book {
  "name": "Trend/Value demo (paper)",
  "strategy": "Trend/Value demo",
  "initial_cash": 100000,
  "date": "2019-01-02",
  "cost_basis_method": "fifo"
}

Step 2 — the entry rule.

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

Step 3 — the exit rule.

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

Step 4 — dry-run both against one name.

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

Step 5 — the strategy.

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

Step 6 — run it forward once, and read the day.

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 sourcetbill 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).

Step 7 — backtest it.

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.

Step 8 — read it in the right order.

get_backtest_detail { "run_id": "<from step 7>" }
  1. preflight.refusals — every refusal that did not fire is as informative as the ones that did.
  2. holdout.verdict — trades out-of-sample, and how the two halves compare.
  3. the drop table — verdict per drop, retained_pct, and both rankings.
  4. trials — the count, statistic (dsr or sidak), and does_not_fix.
  5. only then performance.twr_net_pct.

Step 9 — if it survives, keep the forward record.

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.

6 · What does not exist

Stated plainly, because a reader should not have to discover an absence by having something fail.

6.1 No execution, anywhere

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.

6.2 Twenty-one registered event kinds nothing writes

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.

6.3 No scheduled-event kind

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.

6.4 No point-in-time field reader

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.

6.5 No cross-sectional point-in-time, except one part

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.

6.6 No continuous-query batch

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

6.7 No intraday, no shorting, no leverage, no multi-currency, no optimizer

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.

6.8 No rule marketplace or sharing

Rules reference this installation's cohorts, custom indicators and models; a shared rule would silently rebind.

6.9 Two tuning constants that are declared in prose and not in server/tuning.js

CASCADE_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.

6.10 Unbuilt in the spec's own terms

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.

Appendix — the tool surface, by layer

layerverbs
conditionscompile_condition · evaluate_condition · check_predicate_reach
eventsget_event_feed · get_event_log · backfill_event_log · get_membership_ledger
rulescreate_rule · update_rule · list_rules · delete_rule · test_rule · run_rule_tick · get_rule_evaluations · get_rule_history · migrate_alerts
actionslist_action_verbs
paper bookscreate_paper_book · list_paper_books · get_paper_book · delete_paper_book
strategiescreate_strategy · update_strategy · list_strategies · get_strategy · delete_strategy · run_strategy_loop · get_strategy_run · list_sizing_methods
backtestsbacktest_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
processesget_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.

Descriptive, not advice. A backtest is a description of a past, not a prediction. Nothing in this subsystem says a rule will work. Open Trading Surface is not investment advice — every output is a decision aid to be independently verified, and you bear full responsibility for all investment decisions.