Pattern Events API
Read-only API over the deterministic pattern corpus (sequency.pattern_events)
backing the Pattern Intelligence UI and Track P’s lifecycle/scenario surfaces.
Base path /api/graph/v1/pattern-events (api/app/routes/pattern_events.py).
Auth as in the README, plus: the whole router is gated behind
the patterns capability (Capability.PATTERNS,
app/core/pattern_events_auth.py’s require_pattern_reader). A JWT holder
resolves through the ordinary DB-authoritative capability path. A service
principal that AuthMiddleware has already validated
(request.state.internal_caller) may also read the corpus, but only with an
X-Sequency-Agent-ID header naming the calling agent — the chart-sessions
precedent, not a blanket internal-secret bypass; a bare valid secret with no
agent id is refused. That bridge is read-only by construction: it never
populates request.state.user, so the one mutating route below
(PUT /scenario-policies) carries its own additional
Capability.ADMIN_PANEL dependency and stays operator-only — an agent
principal hitting it gets a 401 (require_capability sees no user), not a
grant. (data#1657, child of data#1536.)
Every event-row query reads FROM sequency.pattern_events FINAL (a
ReplacingMergeTree — FINAL is required to force dedup at read) with
server-side {name:Type} parameters only, and every one of those reads scopes the merge with
do_not_merge_across_partitions_select_final = 1 — safe on this table for a
structural reason, and unsafe on the two Track P tables. See
“FINAL merge scoping” before copying it anywhere.
Under FINAL a count() cannot use part metadata, so its cost tracks the
corpus rather than the result set — which is why /search’s former
per-request total was the binding constraint on how far pattern_events can
grow (AC-VOL stage two R-5/R-6). The rule enforced here, stated as what the
code checks: paging answers “is there more?” by over-fetching one row, never by
counting; an exact total is opt-in and capped; and an opt-in total is admitted
only when the request carries a filter that prunes the scan — symbols, or
a date_from whose value falls inside a bounded lookback. See
GET /search.
Endpoints (orientation)
GET /meta
/meta returns the exact per-grain and per-pattern corpus counts used for
interval enablement, plus registry provenance, corpus generation, and the time
the aggregate was generated. It does not aggregate pattern_events FINAL
on a user’s request. At the current corpus size, the former three cold
aggregates cost several seconds each and intermittently exceeded the API query
budget (data#2316).
Migration 108 seeds pattern_events_meta_snapshots with one exact aggregate.
After every clean non-dry-run pattern backfill or nightly incremental, the
authoritative writer recomputes and appends the next snapshot. API workers read
only the newest small row, so a cold process and a warm process see the same
shared result. A failed refresh is logged and leaves the prior snapshot intact;
its generated_at and corpus_generation remain visible. A malformed persisted
snapshot fails explicitly with 503; it is never turned into zero counts. The
legacy full-corpus loader remains only as bootstrap/development compatibility
when the snapshot table is genuinely empty, including a genuinely empty local
corpus.
corpus_registry_versions and corpus_registry_hashes disclose mixed
historical registry stamps. registry remains the newest corpus writer stamp;
the definitions endpoint is the authority for the currently served registry.
The GET /event/{event_id} and supersession reads still preserve FINAL and
exact identity semantics. Migration 109 materializes bloom indices for
event_id and dedup_parent, allowing ClickHouse to skip unrelated granules
without changing the result (data#1903). It does not create a second event
table or weaken deduplication.
GET /definitions
/definitions is the authoritative discovery surface for the currently
activated pattern vocabulary. It serves all 62 pattern_registry_v8
definitions from the generated registry artifact, including definitions with
zero materialized corpus rows. Each definition includes its canonical ID,
version, family, direction, grains, min_bars, and level capability.
The response carries two different pins on purpose:
registryidentifies the generated definition set currently served by the worker;corpusidentifies the registry stamp on the latest materialized corpus snapshot and includesmatches_registry.
/search for
events. /meta remains the row-derived count/enablement surface.
Point-in-time reads (as_of)
GET /search, GET /symbol/{symbol}, and GET /symbol/{symbol}/insights all
accept an optional as_of query param (data#1655): an ISO-8601 datetime
(bare date or full timestamp; naive input is treated as UTC — a bare
as_of=YYYY-MM-DD resolves to 00:00:00Z on that date, so it excludes every
event confirmed later that same day; pass an explicit time if you mean
“through the end of” a date). When present, every one of those reads adds
confirm_bar_time <= as_of to its WHERE clause — a parameterized
DateTime64(3) bind, never interpolated — on top of the existing FINAL +
suppressed = 0 semantics, which are unchanged.
What as_of actually filters, precisely: it restricts the result to
events whose confirm_bar_time is at or before T, evaluated against the
corpus as it stands today — not a bitemporal read of the corpus as it
stood at T. It mirrors the guard the product side already applies
internally (app/services/registry_patterns.py’s _ACTIVE_PATTERNS_SQL,
which binds confirm_bar_time <= {as_of:DateTime64(3)} the same way), and
that guard has the identical scope, not a stronger one.
The nightly re-emission caveat. The pattern-events pipeline re-emits
trailing days nightly (backfill corrections, dedup resolution). as_of
filters on confirm_bar_time — WHEN a pattern is claimed to have confirmed —
which is stable under this filter, but every OTHER column (quality,
grade, suppressed, dedup_parent, geometry) reads back in whatever
form the corpus holds today, regardless of as_of. A row confirmed
2026-06-10 but corrected by a 2026-06-20 re-emission is visible to
as_of=2026-06-15 in its POST-correction form — the read cannot reconstruct
what that row looked like on 2026-06-15 itself. as_of answers “which events
had confirmed by T”, not “what did the corpus say about those events as of
T”; conflating the two is the mistake this filter must not be used to make.
This is therefore not sufficient, alone, for a lookahead-safe backtest. A
backtest that needs the second guarantee — the exact field values a strategy
would have observed at T, not just which rows existed by T — must read the
sealed pattern-events tape via the research freeze (ADR-0007, “Backtests
consume the sealed pattern corpus via the research freeze; they never
recompute detections”; ratified 2026-08-08 —
docs/adr/0007-backtester-pattern-corpus-sealed-tape.md), which remains the
authoritative PIT mechanism for that use case. Closing the bitemporal gap for
a live HTTP read directly — an inserted_at axis alongside
confirm_bar_time — is tracked separately (data#1707). Research replay and
agent tooling that only need “which events had confirmed by T” (not their
exact historical field values) are exactly what as_of as shipped here
answers.
as_of layers on top of date_from/date_to, not in place of them: a
caller can combine all three, e.g. “events confirmed in June, as known at
end-of-June” (date_from=2026-06-01&date_to=2026-06-30&as_of=2026-06-30T23:59:59Z).
GET /search
Params:pattern_ids, families, direction, grade_floor, grains,
date_from, date_to, as_of, symbols, include_suppressed, sort
(detected | grade | quality | symbol), limit (1-500, default
100), offset (>= 0), and include_total (default false). An
unrecognized filter value is 422, never a silent empty page.
Response: {"events": [Event], "total", "total_capped", "total_cap", "has_more", "limit", "offset", "query_ms", "registry"}.
Paging: has_more, not total (data#1599)
This endpoint used to run an uncached, unlimited count() … FINAL on every
request, sequentially after the row query and under the same 8s budget.
count() cannot use part metadata under FINAL — it has to materialise the
dedup — so the cost scaled with the corpus, not with the result set, and
the default view (no symbols filter, i.e. no prefix of the sort key) made it a
full-table count. That one query is what placed an operational ceiling on how
far pattern_events may grow (AC-VOL stage two R-5/R-6).
The default response now carries has_more instead: a boolean computed by
over-fetching a single row (limit + 1), the same mechanism /symbol uses.
“Is there another page?” is what a pager actually needs, and it costs one row
rather than a corpus traversal. offset paging is unchanged.
total is opt-in, nullable, and bounded twice
total is null unless you pass include_total=true. Null means not
computed — never zero. A client that renders total ?? 0 will print
“0 results” over a full page of events; read has_more (and, if you asked for
one, total_capped) instead.
With include_total=true:
- the count runs over a subquery capped at
total_cap(50,000) rows, so the aggregate itself can never be unbounded; total_capped: truemeanstotalis a floor (“at least this many”), not an exact count;- the request must carry a filter that bounds the scan — either
symbols, or adate_fromwithin the last 400 days — or it is refused with422.
LIMIT inside the subquery bounds the rows aggregated, not the
rows scanned. It short-circuits only once at least total_cap rows match.
A selective predicate on a non-prefix column — pattern_ids=gapexh with no
symbol and no lower date bound — matches far fewer rows than the cap and
therefore still traverses the whole corpus under FINAL to prove it.
The two admissible bounds prune by different mechanisms, which is why only
one of them has a value limit:
date_from’s value is checked because presence bounds nothing:
?include_total=true&date_from=1970-01-01&pattern_ids=gapbrk prunes zero
partitions and, measured cold on prod, read 56,301,859 rows / 5.56 s — the
entire corpus, 70% of the 8s query budget. The same query with a recent
date_from read 376,813 rows in 0.21 s. 400 days spans at most 14 monthly
partitions (≈23% of the corpus) and still clears a trailing-year total.
An ancient date_from is allowed alongside symbols, since the symbol
prefix already bounds the read. date_to/as_of never qualify: they prune the
newest partitions, which on a corpus whose mass sits in the past prunes almost
nothing.
FINAL merge scoping
Every pattern_events read on this router carries
SETTINGS do_not_merge_across_partitions_select_final = 1. It is safe on
this table specifically, for a structural reason rather than a writer
convention: migration 081 partitions on toYYYYMM(confirm_bar_time) while
confirm_bar_time is itself in the ORDER BY, so two rows sharing a full sort
key necessarily share a partition and the cross-partition merge can never have
anything to do. The Track P tables (083_pattern_lifecycle_events,
084_trading_scenarios) partition on state_time, which is absent from
their ORDER BY — the same setting there would return duplicate transitions,
silently. Never widen it to those tables.
Latency metric
Both queries are timed into the Prometheus histogrampattern_events_query_duration_seconds, labelled query =
search_rows | search_count | symbol_rows. search_count is observed
only when a count actually runs, so the series stays a distribution of real
count latencies rather than one diluted by zeros. This is the series AC-VOL
R-6’s tripwire reads — p95 of the count ≤ 4,000 ms, half the route’s 8,000 ms
_QUERY_TIMEOUT_MS; both values are exact bucket edges.
Cursor pagination + expand[] on /search (#1845 P1-a/b/c)
sort=confirm_bar_time is the canonical recency sort (detected is a
deprecated alias with identical order — the tuple is
confirm_bar_time DESC, event_id DESC, direction DESC, total per migration
081’s collision rationale).
Cursor (canonical sort only; other sorts page with offset):
cursor and offset are mutually
exclusive; malformed or foreign cursors are rejected. Pages are
detection-only, not snapshot-consistent: the nightly re-emits trailing
days and the corp-action sweep restates history. Compare corpus_generation
across pages — unequal proves the corpus moved (restart the scan or move to an
ADR-0007 freeze); equal does NOT prove it didn’t (the watermark is cached,
<=300s stale). Snapshot-stable reads are the freezes’ job, never this surface.
expand[] (Stripe bracket form): anchors, levels, provenance,
evidence, relations. Unknown values are a 422. With expand[]=levels,
"levels": null means the pattern maps no levels (see /definitions for
which and why); key absence means “not expanded”, never “no data”.
direction accepts 1/-1 and bullish/bearish — anything the API
emits round-trips as a filter.
GET /symbol/
DEPRECATED (#1845 ruling §7.3): now sugar overParams:/search’s filter grammar with a frozen wire shape; responses carry"deprecated": true; removed once the app-consumer migration is verified (condition-gated per the 2026-08-16 amendment; tracked on #1889 — no calendar date). Unknown query params are a 422 pointing at/search(AC-4).next_cursortokens are now 3-part (millis:event_id:direction— total order per E3); legacy 2-part tokens remain accepted for in-flight scans.
symbol— path, required.grain— query, default"daily". Must be one of1min,5min,15min,30min,60min,daily(CANONICAL_GRAINS); an unknown grain is422.date_from,date_to— query, optional ISO date strings; filterconfirm_bar_timewhen present.date_fromhas a default (see windowing below) — omitting it does NOT mean “no lower bound.”as_of— query, optional ISO-8601 datetime. See “Point-in-time reads” above.limit— query,1-2000, default2000.offset— query,>= 0, default0.cursor— query, optional keyset token (data#1600). See “Truncation” below. Mutually exclusive with a non-zerooffset(422).include_suppressed— query, defaultfalse.
date_from is omitted, the scan
defaults to a trailing 90-day window anchored to the MIN of whichever of
as_of/date_to are present, else now — never the unbounded full-history
corpus, and never a fixed priority order between as_of and date_to. The
agent bench’s original t8-all-drawn-levels task proved the failure mode the
default itself exists to prevent: listing drawn levels from the full undated
corpus on a liquid symbol exhausted an 8k-token budget on roughly 300 prices
(332 on NVDA/daily when measured). That task has since been rescoped to the 3
most recently confirmed events (t8-recent-drawn-levels, data#1708); the
finding about the unbounded read stands. The min-of-bounds
rule exists to prevent a narrower failure: as_of and date_to both
constrain confirm_bar_time, so anchoring to either one unconditionally over
the other reproduces a guaranteed-empty window (date_from > date_to,
returned as a quiet 200) whenever that param names an earlier point than
the one anchored to — a bare date_to in the past breaks under an
unconditional “now” anchor, and as_of later than date_to (e.g. a research
replay combining a recent as_of with an old date_to) breaks the same way
under an unconditional as_of anchor. Taking the min of whatever bounds are
present is never later than the tightest explicit constraint, regardless of
which params accompany it. The resolved window is always echoed back in the
response (date_from/date_to) so a caller can see what was actually
scanned, whether it came from the default or an explicit param.
Within that window, results page most-recent-first: the underlying query
orders by confirm_bar_time DESC, event_id DESC before applying
limit/offset, then re-sorts the page by the exact reverse key
(confirm_bar_time, event_id ascending) for consumers (the chart
lane/overlay expect ascending). The event_id tiebreak on both sides is
load-bearing, not decorative: ClickHouse doesn’t guarantee a subquery’s row
order survives an outer re-sort, so a coarser outer sort (on
confirm_bar_time alone) would leave rows tied on that column in an
unspecified relative order — which matters because the query over-fetches
one surplus row to compute has_more (below) and trims it by position.
offset=0 is always the newest limit events in the window; increasing
offset walks backward in time.
has_more (boolean) tells a caller whether more matching events exist beyond
the current page within the resolved window — TRUE means the page was
truncated, FALSE means this page is everything the window has to offer (not
necessarily everything in the full corpus; a narrower date_from/date_to
can still exclude older or newer matching events entirely — check the
echoed-back window if that distinction matters). It is computed by
over-fetching one extra row (limit + 1) server-side rather than a separate
count() query. That’s a deliberate choice, not an oversight: an exact total
here would carry a corpus-scaled cost for a question (“is there more?”) that a
boolean already answers. /search reached the same conclusion when its own
unbounded count() was removed (#1599) — where an exact total survives at all,
it is opt-in, capped, and requires a filter that demonstrably prunes the scan.
Do not retrofit one onto this endpoint.
Truncation is disclosed, not silent (data#1600)
Thelimit cap tops out at 2000 and is not raised — raising it buys a
slower query and the identical silent failure one density doubling later. What
changed is that its effect is now visible. Three fields say so:
truncated— the window holds more than this page. Same value ashas_more, named for the question a chart asks (“am I showing a complete picture?”) rather than the one a pager asks. Both ship, so existinghas_moreconsumers need no change.truncated: falsemeans “everything in the effective window”, not “everything that exists” — whendate_fromwas defaulted, that window is the trailing 90 days, so paging backwards withnext_cursorreaches the default floor and reportsfalsewhile older events remain in the corpus. Read the echoed-backdate_fromif that distinction matters, and pass an explicitdate_fromto widen it.oldest_confirm_bar_time— theconfirm_bar_timeof the oldest row in this response, i.e. where the window was cut, so a caller can disclose a bounded span honestly instead of implying a complete one.nullonly on an empty page, where there is no cut to report.next_cursor— a keyset token for the next page backwards in time, ornullwhen there is nothing older. A missing token means “no next page”; there is no token that returns an empty page.
next_cursor back verbatim as cursor. The token
is <epoch_millis>:<event_id> — the exact two-column key the feed already
sorts on, in the column’s own millisecond resolution, and URL-safe with no
escaping. A malformed token is 422, never ignored: a silently-dropped cursor
would restart the caller at the newest page while it believed it was paging
backwards, looping forever over the same rows while appearing to progress.
cursor supersedes offset and the two cannot be combined (422). A cursor
resumes strictly older than the row it encodes, so pages cannot overlap or skip
when new events land at the head of the feed between requests — which offset
cannot promise, since an insert at the head shifts every later page by one.
GET /symbol//insights
The chart-annotation path: projects the same rows/symbol/{symbol} returns
(same query, same suppression semantics, same registry stamp) into CT-01
SemanticChartObjects (kind="insight") so charts-core renders confirmed
pattern structure through its insight projection instead of a bespoke
overlay. This is a view, never a detector — every anchor is a coordinate
the registry already wrote; nothing here fits a line, infers a level, or
derives a price (api/app/services/pattern_insight_projection.py). This is
the replacement for the deleted app-side analyze proxy: chart geometry now
comes from this corpus-backed endpoint, never from an LLM.
Params: same as /symbol/{symbol} above — grain, date_from/date_to
(with the same 90-day default window, data#1656), as_of (data#1655),
limit/offset/cursor (data#1600) — since this endpoint is a view over that
query. The one difference: no include_suppressed switch; suppressed rows
(dedup losers) are never projected, by design — a view must not re-surface a
precedence loser.
Response: {"symbol", "grain", "objects": [SemanticChartObject], "registry", "limit", "offset", "has_more", "truncated", "oldest_confirm_bar_time", "next_cursor", "date_from", "date_to"}. Everything after objects/registry
mirrors /symbol/{symbol}’s windowing/pagination envelope verbatim (data#1656,
data#1600) — see that section for has_more/truncated semantics, why the
signal is a boolean rather than an exact count, and how to page backwards. The
truncation disclosure matters more on this endpoint than on the raw feed, not
less: this is the surface that would otherwise present a bounded window to a
chart as a complete one.
Each object’s own scope sub-object (part of the CT-01 InsightScope
contract, not a request param) always carries symbol, interval (=grain),
from_time, to_time — required by charts-core’s semanticGraph validation,
computed per-event from the anchor window (or the confirm bar, for an event
with no drawable geometry).
confirm_time — a top-level field on every object (data#1648), an
ISO-8601 string mirroring the source event’s confirm_bar_time. It exists
because scope.to_time is not a recency signal despite looking like one:
to_time is the max anchor-pivot time (or the confirm bar, for a
geometry-less event), and that is not monotonic with confirm order whenever
confirm_lag_bars > 0 — a pattern’s anchors can sit well before its own
confirmation bar. Verified empirically on a real NVDA matchhl payload: an
object confirmed earlier had a later scope.to_time than one confirmed
after it, so picking “the most recent X” by max to_time silently returns
the wrong object (a real value, for the wrong event) — the exact failure an
LLM benchmark reproduced against this endpoint (Task 11,
docs/superpowers/specs/oq-pde-001-results/bench-insights.md). objects
stays in the same ascending-by-confirm_bar_time order /symbol/{symbol}
returns, so list position is also a valid recency ordering; confirm_time
means a caller no longer has to rely on either that ordering or the
misleading scope.to_time proxy.
confirm_time sits as a sibling of semantics, not a key inside it —
also load-bearing, for the mirror-image reason claim.levels is nested
inside claim below: charts-core’s annotationFromObject reads a fixed
allow-list of semantics keys and silently drops anything outside it, so a
new semantics.confirm_time would vanish at the renderer. A top-level object
field has no such allow-list — SemanticChartObject is generated with
extra="allow" (app/generated/chart_contracts.py), so an additional field
here round-trips through the wire contract untouched, and the app’s typed
consumer (which reads whole objects) sees it without a contract regeneration.
semantics.claim.levels — the structural levels (resistance/support/
invalidation) shipped this week (data#1585). Per-pattern lookup only, never
computed:
- Shape:
{resistance?: number, support?: number, invalidation?: number}. There is notarget— a target requires projecting a measured move, which this module deliberately does not do (data#1584). - Values are verbatim corpus field values — a named price already written
by the detector behind the pinned registry hash (e.g.
nbar→trigger_high/trigger_low,nr7id→entry_long/entry_short), looked up by name, never derived. Six patterns currently have a defensible horizontal band mapping (nbar,matchhl,spkldg,two_b,nr7id,cuphandle— the last is support-only). The other registered patterns may project detector-authored pivots, regions, or confirmation markers, or remain semantic-only;/definitionsreportslevels=nullplus the specific absence reason instead of treating missing level geometry as missing data. See the_LEVEL_BANDSand_NO_LEVELS_REASONStables inpattern_insight_projection.py. nr7idnever carriesinvalidation, even though it emits bothresistanceandsupport— unlikecuphandle’s empty band key, this is a data-driven suppression (_NO_INVALIDATIONinpattern_insight_projection.py), not an absent band.nr7id’sdirectionis candle-bias only (the compression bar’s own color), not a committed trade side — the pattern’s real directional resolution is the ORB-band breakout, and until that resolves bothentry_longandentry_shortare live triggers, neither is anyone’s stop. Drawing aninvalidationoff the candle’s color would assert a direction the detector explicitly disclaims (ratification pack epic #1654 item B2,sequencyhq/sequency-data#1638).two_b’spenetration_high/penetration_lowguarantee is one-sided, per direction — never rely on “outside the range.” A shorttwo_bguaranteespenetration_high > new_extreme_price; a long guaranteesnew_extreme_price > penetration_low(the literal definition of penetration). The other side of the interval is architecturally free —new_extreme_pricesits inside[penetration_low, penetration_high]in the common case, since a 2B failure is a marginal poke that fails, not a clean break of both sides. A consumer that filters or scores on “new_extreme_priceis outside the penetration range” will be wrong most of the time; only the one-sided, per-direction inequality above is a real invariant (ratification pack epic #1654 item D,sequencyhq/sequency-data#1640).- Nested inside
claim, not a sibling of it — load-bearing. charts-core’sannotationFromObjectmaps a fixed set ofsemanticskeys (insight_kind,insight_status,claim,evidence,uncertainty,invalidation,presentation); a siblingsemantics.levelswould be silently dropped at the renderer and look identical to a pattern having no band.claimis cloned wholesale, so nesting survives the contract’s own reader.
semantics.degraded = true) rather than fabricating anchors. This includes
the semantic-only donchian_break projection and historical contextual events
that predate their confirmation-marker coordinate. A registered pattern with
no corpus row produces no object at all; the API never synthesizes an event to
exercise presentation.
Charts-core’s persistence tests pin semantics.degraded through ChartSession
hydration, the semantic graph, agent projection, and accessibility copy. The
generic annotation-kind contract handles detector-authored trendlines, levels,
regions (including four-anchor pipe), and confirmation markers without a
per-pattern chart allow-list.