kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
885 lines (830 loc) • 165 kB
text/typescript
/**
* # frame/pane-catalog — the ONE pane registry: single source for render / View / prompt (kestrel-4gl.13)
*
* Every available (non-kernel) pane is catalogued HERE, exactly once, with its metadata + its pure
* builder. This registry is what three consumers all read, so they can never drift:
* 1. the **renderer** ({@link ./render.ts}) MATERIALIZES panes from it;
* 2. the **View language** ({@link ../lang/ast.ts ViewStatement}) SELECTS panes from it (by id);
* 3. a **prompt** will later ADVERTISE the menu from it (`title` + one-line `description`).
*
* The **cockpit kernel is NOT in this catalog** — it is built in code and LEADS every frame,
* non-configurably, OUTSIDE any View selection (its reserved ids `kernel`/`cockpit` are refused,
* {@link ../frame/types.ts assertPaneIdAllowed}). This catalog is the *configurable body* only.
*
* ## Single-source guarantee (the whole point)
* A pane that is NOT in {@link PANE_CATALOG} CANNOT render (there is no other builder path), and a
* View naming an id absent from the catalog FAILS CLOSED ({@link resolveView}) — never a silent
* drop. Adding a new pane is one entry here; it is then selectable, renderable, and advertisable
* with no change anywhere else.
*
* ## Attribution class (charter) + token cost
* Each entry declares an {@link PaneAttribution} (`OBS` observed | `CALC` derived | `MODEL` a model
* output — a MODEL pane carries a receipt in its rendered text, e.g. the chain's `fair` fairNote)
* and a documented `tokenCostEstimate` (a rough chars/4 figure for the prompt menu + the View
* budget planner; the REAL cost is measured at materialization under the active tokenizer).
*
* ## Purity (ADR-0009)
* Every builder is a PURE function of its {@link PaneBuildContext} — no wall clock, no RNG; the same
* context renders a byte-identical block. Invents no value (`—` for the unknowns, {@link ./format.ts}).
*/
import { KernelHonestyError, assertPaneIdAllowed, makeField } from "./types.ts";
import type { SeatId } from "./seat.ts";
import { founderViewFor } from "./founder-registry.ts";
// The canonical render tokenizer contract (kestrel-2fab): the budget guard measures a materialized
// pane's real cost under it. Homed in the render barrel — importing the pure type here does NOT pull
// in the renderer (`./render.ts` imports THIS module), so no cycle is created.
import type { Tokenizer } from "../render/index.ts";
import { paneRefusal } from "./refusals.ts";
import type {
ArmedPlanTerms,
ArmedPlanTermsEntry,
ChainRow,
InstrumentSpec,
Kernel,
LevelSet,
MarketPane,
Position,
PriorSessionTape,
PriorVantage,
RestingOrder,
FillRecord,
Budget,
TapeRow,
VehicleHealth,
} from "./types.ts";
import { SPOT_STALE_AFTER_MS } from "./options-analytics.ts";
import type { OptionsAnalytics, OptionsExpiry } from "./options-analytics.ts";
import { DASH, isSpotLeg, money, num, pad, px, signedInt, usd } from "./format.ts";
import { candleLine, tapeBounds } from "./tape-geometry.ts";
import { resolveScheme, rollingAnchorOf, schemeHasBoundary, type SessionScheme } from "./session-scheme.ts";
import { PANE_BOUNDARY_REQUIREMENT, schemeConditionedDefect } from "./paradigm-ledger.ts";
// ─────────────────────────────────────────────────────────────────────────────
// The build context a pane builder reads (a pure projection of the frame input)
// ─────────────────────────────────────────────────────────────────────────────
/** Everything a body-pane builder may read — a pure projection of the OPEN/WAKE frame input. A
* builder is a total function of THIS; it never reaches outside it (no wall clock, no RNG). */
export interface PaneBuildContext {
readonly instruments: readonly InstrumentSpec[];
readonly market: MarketPane;
readonly kernel: Kernel;
readonly frameKind: "OPEN" | "WAKE";
/** The prior author vantage's SERVED values (kestrel-wa0j.48) — a pure projection of the WAKE
* frame input's {@link ../frame/types.ts PriorVantage}. Present only on a WAKE frame the driver
* threaded a prior vantage into; the `delta` pane fails closed to absent-with-reason without it.
* Absent on OPEN and on any frame with no prior vantage (additive: those frames are byte-identical). */
readonly priorVantage?: PriorVantage;
/** Minutes since the author's last vantage (the WAKE frame's `minutesSinceLast`) — the `(<N>m ago)`
* cue on the `delta` header. Absent on OPEN / when unknown ⇒ the header omits the age (never a
* fabricated gap). PURE: a value carried on the frame input, not a wall-clock read. */
readonly minutesSinceLast?: number | null;
/** The prior sessions' tapes (kestrel-wa0j.20) — a pure projection of the frame input's
* {@link ../frame/types.ts PriorSessionTape}[]. Present only on a frame the driver threaded a multiday
* tape into; `tape d-N` serves ordinal N's rows as its OWN single-session block, else it renders the
* Train 1B absence line (byte-identical). Absent on a single-day frame (additive, byte-identical). */
readonly priorSessions?: readonly PriorSessionTape[];
/** The ARMED plans' enforced terms (kestrel-wa0j.29) — a pure projection of the frame input's
* {@link ../frame/types.ts ArmedPlanTerms}. Present only on a frame the driver threaded armed-plan
* terms into (a wake with a plan in an enforced state); the `armed-plan` pane fails closed to exactly
* one absent-with-reason line without it. Absent ⇒ byte-identical (the pane is catalog-only). */
readonly armedPlan?: ArmedPlanTerms;
}
// ─────────────────────────────────────────────────────────────────────────────
// The pane builders — the EXISTING v1 panes, moved here verbatim (byte-identical output)
// ─────────────────────────────────────────────────────────────────────────────
// ── instruments ─────────────────────────────────────────────────────────────
function renderInstruments(instruments: readonly InstrumentSpec[]): string {
if (instruments.length === 0) return "instruments:\n (none)";
const lines = instruments.map((i) => {
const role = i.role !== undefined ? ` ${i.role}` : "";
const mult = `mult ${px(i.multiplier)}`;
const tick = `tick ${px(i.tick)}`;
return ` ${i.symbol} ${i.assetClass}${role} ${mult} ${tick}`;
});
return ["instruments:", ...lines].join("\n");
}
// ── levels ───────────────────────────────────────────────────────────────────
/**
* The `spot` cell: the price, plus a `[STALE <age>]` tag once the feed that printed it has gone
* quiet longer than the backstop (kestrel-rs4). A dead feed and a genuinely frozen market render
* the SAME price — an untagged one is read as a clean current value, so the author acts on a level
* the tape stopped supporting. The tag is the only thing that tells the two apart, and it is worth
* its tokens ONLY when it says something: a fresh spot renders bare (byte-identical to a pre-rs4
* frame), as does a spot the caller supplied no age for (an absent age is not a freshness claim).
* `spot` itself is never suppressed or invented — the value prints, the tag keeps it honest (the
* chain pane's stale-spot flag idiom).
*/
function spotCell(lv: LevelSet): string {
const age = lv.spotStaleSeconds;
if (age === undefined || age === null || age * 1000 <= SPOT_STALE_AFTER_MS) return `spot ${px(lv.spot)}`;
return `spot ${px(lv.spot)} [STALE ${Math.round(age / 60)}m]`;
}
function renderLevels(instrument: string, lv: LevelSet): string {
const parts = [
spotCell(lv),
`prior_close ${px(lv.priorClose)}`,
`hod ${px(lv.hod)}`,
`lod ${px(lv.lod)}`,
`vwap ${px(lv.vwap)}`,
`or ${px(lv.orLow)}–${px(lv.orHigh)}`,
];
return `levels · ${instrument}\n ${parts.join(" · ")}`;
}
// ── levels level-set inflection (kestrel-wa0j.24 / ADR-0041 §1) ─────────────────
//
// The `levels` pane gains a LEVEL-SET slot: a variadic run of LevelName idents naming WHICH level
// cells render (`levels vwap hod` renders only vwap + hod). No-arg `levels` is unchanged (all six
// cells, byte-identical). Each ident is a ground ADDRESS over the closed LEVEL_ROSTER — never an
// expression (ADR-0041 §1). DENOTATION: an ident names one cell of the frozen {@link LevelSet}; the
// cell renders its value (or `—` when the LevelSet carries none — absent-not-hidden, unchanged). The
// selected cells render in AUTHORED order (the arg selects WHICH cells; it never invents a value),
// so `levels vwap hod` and `levels hod vwap` are DISTINCT byte-stable forms (biunique spelling→bytes,
// ADR-0041 §1 — no collision). An ident OUTSIDE the roster fails closed NAMING the roster (a named
// level-SET like `registry` is a LATENT future inflection, not an individual level — ADR-0041 §3).
/** The closed roster of individual-level idents the `levels` level-set slot addresses — exactly the
* cells `renderLevels` prints, in its canonical order. (A named level-SET, e.g. `registry`, is a
* distinct LATENT inflection — the band-keyed level-set train — not a member of THIS roster.) */
const LEVEL_ROSTER = ["spot", "prior_close", "hod", "lod", "vwap", "or"] as const;
type LevelRosterName = (typeof LEVEL_ROSTER)[number];
/** Render ONE named level cell, byte-identical to that cell's rendering inside {@link renderLevels}. */
function levelCell(name: LevelRosterName, lv: LevelSet): string {
switch (name) {
case "spot":
return spotCell(lv);
case "prior_close":
return `prior_close ${px(lv.priorClose)}`;
case "hod":
return `hod ${px(lv.hod)}`;
case "lod":
return `lod ${px(lv.lod)}`;
case "vwap":
return `vwap ${px(lv.vwap)}`;
case "or":
return `or ${px(lv.orLow)}–${px(lv.orHigh)}`;
}
}
/**
* The `levels` pane's ARGED builder (kestrel-wa0j.24 / ADR-0041 §1): a variadic LevelName level-set.
* Reached ONLY with args already refined to sort LevelName (bare idents) by {@link bindArgs}; this
* builder applies the `renderable`-rung VALUE check — each ident must NAME a cell in the closed
* {@link LEVEL_ROSTER}. An unknown ident fails closed NAMING the roster + citing the LATENT
* named-level-set cell (ADR-0041 §3 four-state paradigm). Selected cells render in authored order.
*/
function renderLevelsWithArgs(ctx: PaneBuildContext, args: readonly PaneSelectionArg[]): string {
const lv = ctx.market.levels;
const cells: string[] = [];
for (const a of args) {
if (a.kind !== "arg-ident") {
// Unreachable after bindArgs (sort LevelName ⇐ ident) — fail closed rather than trust it.
throw paneRefusal({ kind: "unreachable-bound-arg", pane: "levels", expected: "bound LevelName idents", args: [a] });
}
if (!(LEVEL_ROSTER as readonly string[]).includes(a.name)) {
throw paneRefusal({ kind: "unknown-value-naming-roster", pane: "levels", slot: "level-set", value: a.name, roster: LEVEL_ROSTER });
}
cells.push(levelCell(a.name as LevelRosterName, lv));
}
return `levels · ${ctx.market.instrument}\n ${cells.join(" · ")}`;
}
// ── tape (the incumbent rotated candlestick, rendering-variants.md #2) ─────────
/**
* Render the rotated-candlestick tape. Price → column position; time flows down the page
* (append-only). The axis bounds are the data's own extremes (real input numbers — the renderer
* derives layout, never a value). Each row: leading indent to the low column, `─` wick to the
* high column, `█` body over open→close. A flat window collapses to a single block column. The
* candle geometry is the shared {@link ../frame/tape-geometry.ts candleLine} (one geometry, drawn
* identically by the ARM candlestick adapter); this function owns only the header + row prefix.
*/
function renderTape(rows: readonly TapeRow[], bucketMin: number, sessionTag?: string): string {
// kestrel-wa0j.20: an OPTIONAL header session tag names a PRIOR session (`d-N` [+ label]) when this
// block is a `tape d-N` render. Absent (the d-0 / no-arg path) ⇒ the header is BYTE-IDENTICAL to
// before — a prior-session block differs from a d-0 block of the same rows ONLY by this ` · <tag>`.
const tag = sessionTag !== undefined ? ` · ${sessionTag}` : "";
if (rows.length === 0) {
return [`tape ${bucketMin}m${tag} · (no prints this window)`].join("\n");
}
const { lo, hi } = tapeBounds(rows);
const anchorClock = rows[0]!.clock;
const header = `tape ${bucketMin}m${tag} · axis ${px(lo)}→${px(hi)} · anchor @ ${anchorClock} ET`;
const body = rows.map((r) => `${r.clock} ${candleLine(r, lo, hi)}`);
return [header, ...body].join("\n");
}
/**
* CALC re-bucketing (kestrel-wa0j.19 §1): aggregate consecutive runs of `factor` served rows into
* ONE wider bucket. Deterministic AGGREGATION of real inputs — every number on an output row is a
* function of the input rows' own numbers (open = the run's FIRST open, high = max high, low = min
* low, close = the run's LAST close, clock = the CLOSING row's clock — the bucket closed there,
* volume = the sum WHEN every grouped row carries one; a partially-volumed group omits it rather
* than present a partial sum as the bucket's volume). A TRAILING PARTIAL run aggregates the rows
* that exist — it is real data, and nothing on it is invented. Pure; input order preserved.
* Exported so the aggregation semantics are directly testable (and reusable by the session lane).
*/
export function rebucketTape(rows: readonly TapeRow[], factor: number): TapeRow[] {
const out: TapeRow[] = [];
for (let i = 0; i < rows.length; i += factor) {
const run = rows.slice(i, i + factor);
const first = run[0]!;
const last = run[run.length - 1]!;
let high = -Infinity;
let low = Infinity;
let volume = 0;
let allVolumed = true;
for (const r of run) {
if (r.high > high) high = r.high;
if (r.low < low) low = r.low;
if (r.volume === null || r.volume === undefined || !Number.isFinite(r.volume)) allVolumed = false;
else volume += r.volume;
}
out.push({
clock: last.clock,
open: first.open,
high,
low,
close: last.close,
...(allVolumed ? { volume } : {}),
});
}
return out;
}
/**
* TRUE iff a `tape <window>` arg is SERVABLE by a context whose rows arrive bucketed at `bucketMin`
* minutes: the window has a fixed minute width AND is a positive INTEGER MULTIPLE of the served
* bucket (equal ⇒ factor 1). The ONE shared validity predicate (kestrel-wa0j.19 §1): the session
* lane checks a scheduled View's window arg against it at SCHEDULE time (so a bad window is refused
* before it ever reaches an unguarded delivery render), and the arged tape builder enforces the
* SAME predicate at materialization — the two can never drift. Pure unit arithmetic, no context.
*/
export function windowServableBy(
bucketMin: number,
window: { readonly value: number; readonly unit: string },
): boolean {
const w = windowMinutes(window);
if (w === null || !Number.isFinite(bucketMin) || bucketMin <= 0) return false;
const factor = w / bucketMin;
return Number.isInteger(factor) && factor >= 1;
}
/**
* The tape pane's ARGED builder (kestrel-wa0j.3 / T3a + kestrel-wa0j.19 §1): `tape 5m` — a single
* WINDOW arg names the bucket width the author wants. The context serves rows PRE-BUCKETED at
* `ctx.market.tapeBucketMin`; a requested window that is an INTEGER MULTIPLE of that width is
* served by CALC re-bucketing ({@link rebucketTape} — deterministic aggregation of the real rows,
* so `tape 5m` materializes over the product driver's 1m buckets), an EQUAL window renders the
* rows as-is (byte-identical to the no-arg tape), and a SUB-RESOLUTION or NON-MULTIPLE window
* FAILS CLOSED naming pane + arg + the served width (the served rows cannot honestly express it —
* never mismatched data under a requested label).
*
* MIGRATED onto the ParamSlot substrate (kestrel-wa0j.24 / ADR-0041 §1): the ARITY + SORT refusals
* (an ident arg, extra args) are the uniform {@link bindArgs} `mat`-rung surface — this builder is
* reached with the args already refined (an optional Window + an optional SessionOrdinal), so it keeps
* only the `renderable`-rung reads (which need the Frame). Accepted forms render BYTE-IDENTICALLY to
* before the migration; the servability message is unchanged.
*
* SESSION addressing (SessionOrdinal, Train 1B / ADR-0041 §1): `tape d-0` names the CURRENT session
* (the frozen frame's own tape) and collapses to the window path — byte-identical to the un-sessioned
* render. `tape d-N` (N ≥ 1) names a PRIOR session, a TOTAL `renderable` judgment (ADR-0041 §3: absence
* is a value): scheme-conditioned absence when the instrument's {@link ../frame/session-scheme.ts
* SessionScheme} declares no session-delimiting `close` boundary (a perp's rolling UTC day — reuse the
* wa0j.44 conditioning path, never an invented session), else a frame-conditioned absence naming the
* multiday DATA train (Train 1B lands the ADDRESS; kestrel-wa0j.20 lands the DATA). d-0 is date-blind
* (relative), exactly like the servability label.
*/
function renderTapeWithArgs(ctx: PaneBuildContext, args: readonly PaneSelectionArg[]): string {
const windowArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-window" }> => a.kind === "arg-window");
const sessionArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-ordinal" }> => a.kind === "arg-ordinal");
// A PRIOR session (d-N, N ≥ 1): a total renderable absence (never an invented session). d-0 falls
// through to the current-session window path below.
if (sessionArg !== undefined && sessionArg.ordinal >= 1) {
return renderTapePriorSession(ctx, sessionArg.ordinal, windowArg);
}
const available = ctx.market.tapeBucketMin;
// Session-only `tape d-0`: the current session at the served bucket — byte-identical to no-arg tape.
if (windowArg === undefined) return renderTape(ctx.market.tape, available);
const requested = windowMinutes(windowArg.window);
if (requested === null || !windowServableBy(available, windowArg.window)) {
throw paneRefusal({ kind: "window-not-servable", pane: "tape", arg: windowArg, servedBucketMin: available });
}
const factor = requested / available;
// factor 1 (requested === served): the rows as-is — byte-identical to the no-arg tape.
if (factor === 1) return renderTape(ctx.market.tape, requested);
// factor > 1: CALC re-bucketing — aggregate runs of `factor` served rows into the requested width.
return renderTape(rebucketTape(ctx.market.tape, factor), requested);
}
/** The SessionOrdinal `d-N` (N ≥ 1) prior-session render (Train 1B ADDRESS + kestrel-wa0j.20 DATA,
* ADR-0041 §1/§3) — a TOTAL `renderable` judgment. Three outcomes, single-sourced (the census + this
* render read the same tables, honoring the authority arrow — materialization consults the
* scheme/boundary DATA, never the ledger STATE):
* • SCHEME-conditioned ABSENCE (reuse the wa0j.44 path): the instrument's SessionScheme declares no
* session-delimiting `close` boundary (a perp's rolling UTC day) ⇒ there is no prior-session address
* under this scheme — render the sanctioned periphrasis (anchor to the declared rolling extreme).
* • SERVED (kestrel-wa0j.20): the scheme HAS the boundary AND the frame CARRIES ordinal N's tape ⇒
* render that session's OWN complete single-session block (the EXISTING renderer over its rows; its
* own axis from its own extremes; header naming `d-N` + the label) — sessions are NEVER folded.
* • FRAME-conditioned ABSENCE: the scheme has the boundary but the frame carries no session at `d-N`
* (the v1 single-day tape) — the Train 1B absence line, deliberately NOT ratchet-pinned so this
* absence→data flip needs no re-pin. */
function renderTapePriorSession(
ctx: PaneBuildContext,
ordinal: number,
windowArg: Extract<PaneSelectionArg, { kind: "arg-window" }> | undefined,
): string {
const scheme = schemeOfMarket(ctx);
const label = windowArg !== undefined ? ` ${windowArg.window.value}${windowArg.window.unit}` : "";
const addr = `d-${ordinal}`;
const head = `tape${label} · ${ctx.market.instrument} · ${addr}`;
const defect = schemeConditionedDefect("tape", scheme);
if (defect !== undefined) {
const anchor = rollingAnchorOf(scheme) ?? "rolling extreme";
return `${head} · (${defect.reason} (${scheme}) — no prior-session address under this SessionScheme; ${defect.periphrasis}: ${anchor})`;
}
// kestrel-wa0j.20 — the frame may CARRY ordinal N's tape (the DATA that fills the Train 1B address).
// Serve it as its OWN block; NEVER fold with the current session (that is wa0j.20b, behind PR #194).
const carried = ctx.priorSessions?.find((s) => s.ordinal === ordinal);
if (carried !== undefined && carried.rows.length > 0) {
return renderPriorSessionBlock(ctx, carried, ordinal, windowArg);
}
return `${head} · (frozen frame carries no session at ${addr} — the current session only; multiday tape data threads with kestrel-wa0j.20)`;
}
/** Render one carried prior session as its OWN complete single-session tape block (kestrel-wa0j.20).
* Reuses the EXISTING single-session {@link renderTape} + the SAME window servability/re-bucketing path
* the d-0 render walks — so a `tape d-N` block is byte-format IDENTICAL to a `tape d-0` render of the
* same rows except the header names the ordinal (+ the session label when present). Its axis is derived
* from ITS OWN extremes (renderTape reads the rows it is handed); no cross-session anchor, no fold. */
function renderPriorSessionBlock(
ctx: PaneBuildContext,
session: PriorSessionTape,
ordinal: number,
windowArg: Extract<PaneSelectionArg, { kind: "arg-window" }> | undefined,
): string {
const available = ctx.market.tapeBucketMin;
const tag = session.sessionLabel !== undefined ? `d-${ordinal} (${session.sessionLabel})` : `d-${ordinal}`;
// No window arg: the prior session at the served bucket (mirrors the `tape d-0` no-window path).
if (windowArg === undefined) return renderTape(session.rows, available, tag);
const requested = windowMinutes(windowArg.window);
if (requested === null || !windowServableBy(available, windowArg.window)) {
throw paneRefusal({ kind: "window-not-servable", pane: "tape", arg: windowArg, servedBucketMin: available });
}
const factor = requested / available;
// factor 1 (requested === served): the rows as-is; factor > 1: CALC re-bucketing (same as d-0).
if (factor === 1) return renderTape(session.rows, requested, tag);
return renderTape(rebucketTape(session.rows, factor), requested, tag);
}
// ── chain (near-money) — bid/ask/fair + receipt + dark flags ───────────────────
function darkFlag(row: ChainRow): string {
const bidDark = row.bid === null;
const askDark = row.ask === null;
if (bidDark && askDark) return "dark";
if (bidDark) return "bid dark";
if (askDark) return "ask dark";
return DASH;
}
function renderChain(underlier: string, chain: readonly ChainRow[], dte?: number | null): string {
// The RELATIVE days-to-expiry tag (kestrel-wa0j.19 §4) — restores the `0dte` the header lost in
// the one-renderer swap (the pre-swap day renderer stamped `(<dte>dte)`; the machine channel kept
// it as `AuthorFrame.chain.dte`). Relative, never a date (date-blind). `null`/absent ⇒ omitted —
// the header is byte-identical to today (the TapeRow.volume idiom).
const dteTag = dte !== undefined && dte !== null && Number.isFinite(dte) ? ` · ${dte}dte` : "";
if (chain.length === 0) {
return `chain (near-money) · ${underlier}${dteTag}\n (no legs)`;
}
const head = ` ${pad("strike", 8)}${pad("R", 3)}${pad("bid", 8)}${pad("ask", 8)}${pad("fair", 10)}flags`;
const lines = chain.map((r) => {
const fair = r.fairNote !== undefined && r.fair !== null && r.fair !== undefined
? `${px(r.fair)} ${r.fairNote}`
: px(r.fair);
// The fair cell carries a variable-length receipt annotation; pad to at least its own
// length + a 2-space gutter so the flags column never jams against it.
const fairCell = pad(fair, Math.max(10, fair.length + 2));
return ` ${pad(px(r.strike), 8)}${pad(r.right, 3)}${pad(px(r.bid), 8)}${pad(px(r.ask), 8)}${fairCell}${darkFlag(r)}`;
});
return [`chain (near-money) · ${underlier}${dteTag}`, head, ...lines].join("\n");
}
// ── chain Count inflection (kestrel-wa0j.24 / ADR-0041 §1) ──────────────────────
//
// The `chain` pane gains a COUNT slot: `chain 12` addresses the 12 NEAREST-THE-MONEY strikes of the
// frozen near-money chain. DENOTATION: a Count is a ground cardinal naming N nearest strikes — an
// ADDRESS over the frozen chain, never a computation (ADR-0041 §1). The frame already emits legs
// nearest-money FIRST (see `renderChain` in src/session/day.ts — "nearest-the-money first is the
// tape's own order"), so selection preserves that order (no re-sort, no invented ordering; robust
// even when spot is unknown). No-arg `chain` is unchanged (renders every carried leg).
//
// SHORTFALL is FAIL-CLOSED per absent-not-hidden (ADR-0041 §3), NOT a hard refusal and NEVER silently
// fewer: a Count the frozen chain cannot fully serve renders EVERY available strike (real data —
// invent nothing) PLUS an explicit absent-with-reason line for the missing strikes. `chain N` where
// N ≥ the available strike count therefore renders the whole chain + the shortfall line; `chain N`
// where N equals the available count is byte-identical to the no-arg chain (the selection anchor).
/** Select the rows of the N nearest-money DISTINCT strikes, preserving the frame's nearest-first
* order (no re-sort), and report how many distinct strikes the chain carries. Pure. */
function selectNearMoneyStrikes(chain: readonly ChainRow[], n: number): { readonly rows: ChainRow[]; readonly availableStrikes: number } {
const seen = new Set<number>();
const ordered: number[] = [];
for (const r of chain) {
if (!seen.has(r.strike)) {
seen.add(r.strike);
ordered.push(r.strike);
}
}
const keep = new Set(ordered.slice(0, n));
return { rows: chain.filter((r) => keep.has(r.strike)), availableStrikes: ordered.length };
}
/**
* The `chain` pane's ARGED builder (kestrel-wa0j.24 + Train 1B / ADR-0041 §1): an optional Count
* (the N nearest-money strikes) + an optional ExpiryOrdinal (which expiry). Reached with the args
* already refined by {@link bindArgs}; this builder applies the `renderable`-rung reads.
*
* EXPIRY addressing (ExpiryOrdinal, Train 1B): `chain e-0` names the NEAREST expiry — the frozen
* near-money chain — and collapses to the Count path (byte-identical to the un-expiry render).
* `chain e-N` (N ≥ 1) names a FURTHER-OUT expiry: the frozen frame carries ONE expiry of chain, so
* this is a TOTAL renderable absence (ADR-0041 §3: absence is a value) naming the multi-expiry DATA
* train (kestrel-wa0j.20) — never a fabricated expiry. On the nearest expiry a Count SHORTFALL stays
* absent-not-hidden (available strikes + a named shortfall line, never silently fewer).
*/
function renderChainWithArgs(ctx: PaneBuildContext, args: readonly PaneSelectionArg[]): string {
const countArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-count" }> => a.kind === "arg-count");
const expiryArg = args.find((a): a is Extract<PaneSelectionArg, { kind: "arg-expiry" }> => a.kind === "arg-expiry");
// A FURTHER-OUT expiry (e-N, N ≥ 1): a total renderable absence (never a fabricated expiry). e-0
// falls through to the nearest-expiry Count path below.
if (expiryArg !== undefined && expiryArg.expiry >= 1) {
return renderChainFurtherExpiry(ctx, expiryArg.expiry, countArg);
}
// e-0 (or no expiry arg) + no Count: the nearest expiry's whole chain — byte-identical to no-arg chain.
if (countArg === undefined) {
return renderChain(ctx.market.instrument, ctx.market.chain, ctx.market.chainDte);
}
const n = countArg.count;
const { rows, availableStrikes } = selectNearMoneyStrikes(ctx.market.chain, n);
const base = renderChain(ctx.market.instrument, rows, ctx.market.chainDte);
if (availableStrikes >= n) return base; // fully served — the N nearest strikes (byte-identical to no-arg when N = available)
const missing = n - availableStrikes;
// Absent-not-hidden (ADR-0041 §3): render the available strikes, then NAME the shortfall — never a
// silent fewer-than-requested, never a fabricated leg to pad the count.
const shortfall =
` (requested ${n} near-money strikes; ${availableStrikes} available — ${missing} absent with reason: ` +
`the frozen near-money chain carries only ${availableStrikes} strike${availableStrikes === 1 ? "" : "s"})`;
return `${base}\n${shortfall}`;
}
/** The ExpiryOrdinal `e-N` (N ≥ 1) further-out-expiry absence render (Train 1B / ADR-0041 §1/§3) — a
* TOTAL `renderable` judgment: the address is valid (it bound at `mat`) but the frozen frame carries
* only the nearest expiry, so the pane renders the absence AS A VALUE (naming the multi-expiry data
* train, kestrel-wa0j.20), never a fabricated expiry. A carried Count is echoed (the requested count is
* moot when the expiry is absent — say so, never silently drop it). */
function renderChainFurtherExpiry(
ctx: PaneBuildContext,
expiry: number,
countArg: Extract<PaneSelectionArg, { kind: "arg-count" }> | undefined,
): string {
const countLabel = countArg !== undefined ? ` ${countArg.count}` : "";
const addr = `e-${expiry}`;
return (
`chain (near-money)${countLabel} · ${ctx.market.instrument} · ${addr} · ` +
`(frozen chain carries no expiry at ${addr} — the nearest expiry only; multi-expiry chain data threads with kestrel-wa0j.20)`
);
}
// ── macro — absent-with-reason in v1 (EVALUATION.md briefing spec) ─────────────
const MACRO_PANE = "macro: unavailable (v1 harness)";
// ── acting-detail (RUNTIME §5–6) — the plan-lifecycle view ─────────────────────
function planTag(plan: string | undefined): string {
return plan === undefined ? "" : ` (${plan})`;
}
function renderPositions(positions: readonly Position[]): string {
if (positions.length === 0) return "positions:\n (none)";
// ADR-0017 (kestrel-orx): an equity/spot leg has NO strike/right — render `<instrument> shares` and
// DROP the option-only `fair` column (a spot leg's fair is not a chain leg); an option leg keeps
// today's `<strike><right> … fair …` form BYTE-IDENTICAL.
const lines = positions.map((p: Position) =>
isSpotLeg(p)
? ` ${signedInt(p.qty)} ${p.instrument} shares basis ${px(p.basis)}${unrealTag(p)}${planTag(p.plan)}`
: ` ${signedInt(p.qty)} ${p.instrument} ${px(p.strike)}${p.right} basis ${px(p.basis)} fair ${px(p.fair)}${unrealTag(p)}${planTag(p.plan)}`,
);
return ["positions:", ...lines].join("\n");
}
/** The running unrealized-P&L tag (kestrel-c11) — the position's `unrealUsd` in DOLLARS, so the agent
* READS its P&L instead of computing (and mis-scaling) it. Absent ⇒ no tag (purely additive); `null`
* ⇒ `unreal —` (mark UNKNOWN, fail-closed — never a fabricated 0). */
function unrealTag(p: Position): string {
return p.unrealUsd === undefined ? "" : ` unreal ${usd(p.unrealUsd)}`;
}
function renderResting(resting: readonly RestingOrder[]): string {
if (resting.length === 0) return "resting:\n (none)";
const lines = resting.map((o: RestingOrder) => {
const note = o.note !== undefined ? ` [${o.note}]` : "";
// ADR-0017 (kestrel-orx): equity/spot ⇒ `<instrument> shares`; option ⇒ `<strike><right>` (unchanged).
const leg = isSpotLeg(o) ? "shares" : `${px(o.strike)}${o.right}`;
return ` ${o.side.toUpperCase()} ${o.qty} ${o.instrument} ${leg} @ ${px(o.px)}${note}${planTag(o.plan)}`;
});
return ["resting:", ...lines].join("\n");
}
function renderFills(fills: readonly FillRecord[]): string {
if (fills.length === 0) return "fills since last:\n (none)";
const lines = fills.map((f: FillRecord) => {
const at = f.clock !== undefined ? ` @${f.clock}` : "";
// ADR-0017 (kestrel-orx): equity/spot ⇒ `<instrument> shares`; option ⇒ `<strike><right>` (unchanged).
const leg = isSpotLeg(f) ? "shares" : `${px(f.strike)}${f.right}`;
return ` ${f.side.toUpperCase()} ${f.qty} ${f.instrument} ${leg} @ ${px(f.px)}${at}${planTag(f.plan)}`;
});
return ["fills since last:", ...lines].join("\n");
}
function renderBudget(budget: Budget | null | undefined): string {
// kestrel-wa0j.47: labelled `premium budget` (dollars of premium spend), never bare `budget` —
// the kernel's `-- BUDGET / REMAINING-R --` section quotes the RISK envelope in R units, and one
// word carrying two units across two blocks is exactly the confusable-surface ADR-0041 §1 forbids.
if (budget === null || budget === undefined) return `premium budget: ${DASH}`;
const total = budget.total !== undefined ? ` (total ${px(budget.total)}` : "";
const maxR = budget.maxConcurrentR !== undefined ? `, maxR ${px(budget.maxConcurrentR)})` : total !== "" ? ")" : "";
return `premium budget: used ${money(budget.used)} / remaining ${money(budget.remaining)}${total}${maxR}`;
}
function renderPlans(kernel: Kernel): string {
if (kernel.plans.length === 0) return "plans:\n (none)";
const lines = kernel.plans.map((p) => {
const outcome = p.outcome !== undefined ? `(${p.outcome})` : "";
// kestrel-50w: an authored plan stuck on an unsatisfiable regime gate carries the engine's arm-block
// reason — render it as `(blocked: <reason>)` so a bare `authored` (a live plan awaiting its WHEN) is
// DISTINGUISHABLE from one that can never arm (the phantom-position trap).
const blocked = p.blockedReason !== undefined ? ` (blocked: ${p.blockedReason})` : "";
const note = p.note !== undefined ? ` — ${p.note}` : "";
return ` ${p.name}: ${p.state}${outcome === "" ? "" : " " + outcome}${blocked}${note}`;
});
return ["plans:", ...lines].join("\n");
}
/** The trailing plan-lifecycle detail block ("KERNEL (acting)"): fills-since-last + plan states +
* the detailed inventory view. The safety/control half of the same Kernel LEADS every frame as the
* cockpit block ({@link ./render.ts renderKernel}); this is its plan-lifecycle complement. */
function renderActingDetail(kernel: Kernel): string {
return [
"KERNEL (acting)",
renderPositions(kernel.positions),
renderResting(kernel.resting),
renderFills(kernel.fillsSinceLast),
renderBudget(kernel.budget),
renderPlans(kernel),
].join("\n");
}
// ── failed-breaks (kestrel-4gl.13.1) — the fake-out trap made legible ──────────
//
// A CALC tally of pokes past HOD/LOD/OR-high/OR-low that did NOT HOLD this session — the ORB
// fake-out poke IS a failed break, so this pane literally shows the trap before the agent chases it.
// PURE over `market.tape` (OHLC bars) + `market.levels` (the break levels). No receipt, no new data.
//
// Definitions (precise, so the count is deterministic):
// • LEVEL — a break level with a direction: UP levels (hod, orHigh) are broken by trading ABOVE;
// DOWN levels (lod, orLow) by trading BELOW. VWAP is a MEAN, not a break level → not counted here.
// • POKE — a maximal run of ≥1 CONSECUTIVE bars that PIERCE the level (up: `bar.high > L`;
// down: `bar.low < L`). A bar that only touches (`high === L`) does not pierce (strict).
// • HELD — the poke's run reaches the LAST bar of the window AND that last bar CLOSES past the
// level (up: `close > L`; down: `close < L`) — i.e. price is still through the level at the last
// observation (the break sustained).
// • FAILED — a poke that did not hold: either a later bar brought price back inside (the run ends
// before the window does), or the run reached the end but the last bar CLOSED back inside (an
// intrabar poke rejected at the close — the textbook fake-out).
// • EXCURSION — the max distance a poke reached strictly past the level (its trap depth).
// • RUN — the poke's length in bars (the "time-above/below", in whole tape buckets — the frozen
// projection carries no sub-bar time, so we report bars, never invented seconds).
/** A break level and the side a break of it points. UP = broken above (hod/orHigh); DOWN = below. */
interface BreakLevel {
readonly label: string;
readonly value: number | null | undefined;
readonly dir: "up" | "down";
}
/** One poke's outcome over the window: how far past it reached, how many bars it lasted, held?. */
interface Poke {
readonly excursion: number;
readonly bars: number;
readonly held: boolean;
}
/** Every poke past `level` in `dir`, in tape order. Pure: a total function of the OHLC rows. */
function pokesForLevel(rows: readonly TapeRow[], level: number, dir: "up" | "down"): readonly Poke[] {
const pierces = (r: TapeRow): boolean => (dir === "up" ? r.high > level : r.low < level);
const pastBy = (p: number): number => (dir === "up" ? p - level : level - p);
const pokes: Poke[] = [];
const n = rows.length;
let i = 0;
while (i < n) {
if (!pierces(rows[i]!)) {
i += 1;
continue;
}
let j = i;
let excursion = 0;
while (j < n && pierces(rows[j]!)) {
const r = rows[j]!;
const ex = pastBy(dir === "up" ? r.high : r.low);
if (ex > excursion) excursion = ex;
j += 1;
}
const reachesEnd = j === n;
const lastClose = rows[j - 1]!.close;
const held = reachesEnd && (dir === "up" ? lastClose > level : lastClose < level);
pokes.push({ excursion, bars: j - i, held });
i = j;
}
return pokes;
}
/** One `failed-breaks` line for a level. Absent level ⇒ explicit unknown (invents no value). */
function failedBreakLine(rows: readonly TapeRow[], lv: BreakLevel): { readonly line: string; readonly failed: number } {
if (lv.value === null || lv.value === undefined || !Number.isFinite(lv.value)) {
return { line: ` ${lv.label} ${DASH}: level unknown`, failed: 0 };
}
const pokes = pokesForLevel(rows, lv.value, lv.dir);
const probed = pokes.length;
const held = pokes.filter((p) => p.held).length;
const failedPokes = pokes.filter((p) => !p.held);
const failed = failedPokes.length;
const head = ` ${lv.label} ${px(lv.value)}: probed ${probed}×`;
if (probed === 0) return { line: head, failed: 0 };
const detail = `${head} · held ${held}× · failed ${failed}×`;
if (failed === 0) return { line: `${detail} · clean break (held)`, failed: 0 };
const maxExc = failedPokes.reduce((m, p) => (p.excursion > m ? p.excursion : m), 0);
const longest = failedPokes.reduce((m, p) => (p.bars > m ? p.bars : m), 0);
return {
line: `${detail} · max excursion ${num(maxExc, 2)} · longest ${longest} bar${longest === 1 ? "" : "s"}`,
failed,
};
}
/** The failed-break tally: per-level poke/held/failed counts + trap depth, and a session total. */
function renderFailedBreaks(market: MarketPane): string {
const lv = market.levels;
const levels: readonly BreakLevel[] = [
{ label: "hod", value: lv.hod, dir: "up" },
{ label: "lod", value: lv.lod, dir: "down" },
{ label: "orHigh", value: lv.orHigh, dir: "up" },
{ label: "orLow", value: lv.orLow, dir: "down" },
];
if (market.tape.length === 0) {
return `failed-breaks · ${market.instrument} · (no prints this window)`;
}
const built = levels.map((l) => failedBreakLine(market.tape, l));
const total = built.reduce((s, b) => s + b.failed, 0);
const header = `failed-breaks · ${market.instrument} · ${total} failed this session`;
return [header, ...built.map((b) => b.line)].join("\n");
}
// ── level-interaction (kestrel-4gl.13.2) — the poke as exhaustion, not breakout ─
//
// A CALC read of HOW price behaved at each key level (HOD/LOD/VWAP/OR edges): touches, and the
// REJECTION CHARACTER of the most-recent interaction — wick (exhaustion, closed back inside) vs body
// (continuation, closed through). Complements `failed-breaks` (counts) with SHAPE. PURE over the same
// OHLC tape + levels; no receipt, no new data.
//
// Definitions:
// • TOUCH — a bar whose range straddles the level (`low ≤ L ≤ high`): price traded AT the level.
// • CHARACTER (break levels) — from the LAST touching bar (the current texture): "broke" if it
// CLOSED through the level, "rejected" if it closed back inside. The reach past the level splits
// into BODY-past (`|body edge − L|`, the part the candle body held) and WICK-past (the rest, the
// part it gave back). A big wick + tiny body that closed inside is a textbook exhaustion print;
// a body that closed through is a real break.
// • VWAP (a MEAN, two-sided) — reports touches + which side the last touch CLOSED (reclaim/loss)
// and the reach on each side of the mean; the reject/break verb does not apply to a mean.
/** The wick-vs-body split of the last touching bar past a break level. Pure geometry. */
function pastSplit(r: TapeRow, level: number, dir: "up" | "down"): { readonly wick: number; readonly body: number } {
const bodyHi = Math.max(r.open, r.close);
const bodyLo = Math.min(r.open, r.close);
if (dir === "up") {
const body = Math.max(0, bodyHi - level);
const wick = r.high - Math.max(bodyHi, level);
return { wick, body };
}
const body = Math.max(0, level - bodyLo);
const wick = Math.min(bodyLo, level) - r.low;
return { wick, body };
}
/** One `level-interaction` line for a break level (hod/lod/orHigh/orLow). */
function breakInteractionLine(rows: readonly TapeRow[], lv: BreakLevel): string {
if (lv.value === null || lv.value === undefined || !Number.isFinite(lv.value)) {
return ` ${lv.label} ${DASH}: level unknown`;
}
const level = lv.value;
const touches = rows.filter((r) => r.low <= level && level <= r.high);
if (touches.length === 0) return ` ${lv.label} ${px(level)}: no touch`;
const last = touches[touches.length - 1]!;
const broke = lv.dir === "up" ? last.close > level : last.close < level;
const verb = broke ? "broke (body through)" : "rejected (wick, closed inside)";
const { wick, body } = pastSplit(last, level, lv.dir);
const wickLabel = lv.dir === "up" ? "upper-wick" : "lower-wick";
return ` ${lv.label} ${px(level)}: ${touches.length} touch${touches.length === 1 ? "" : "es"} · ${verb} · ${wickLabel} ${num(wick, 2)} / body ${num(body, 2)}`;
}
/** One `level-interaction` line for VWAP (a mean — reclaim/loss + reach each side, no break verb). */
function vwapInteractionLine(rows: readonly TapeRow[], vwap: number | null | undefined): string {
if (vwap === null || vwap === undefined || !Number.isFinite(vwap)) {
return ` vwap ${DASH}: level unknown`;
}
const touches = rows.filter((r) => r.low <= vwap && vwap <= r.high);
if (touches.length === 0) return ` vwap ${px(vwap)}: no touch`;
const last = touches[touches.length - 1]!;
const side = last.close > vwap ? "closed above (reclaim)" : last.close < vwap ? "closed below (loss)" : "closed at vwap";
const up = last.high - vwap;
const down = vwap - last.low;
return ` vwap ${px(vwap)}: ${touches.length} touch${touches.length === 1 ? "" : "es"} · ${side} · reach up ${num(up, 2)} / down ${num(down, 2)}`;
}
/** The level-interaction detail: rejection character (wick vs body) at each key level. */
function renderLevelInteraction(market: MarketPane): string {
const lv = market.levels;
if (market.tape.length === 0) {
return `level-interaction · ${market.instrument} · (no prints this window)`;
}
const breakLevels: readonly BreakLevel[] = [
{ label: "hod", value: lv.hod, dir: "up" },
{ label: "lod", value: lv.lod, dir: "down" },
{ label: "orHigh", value: lv.orHigh, dir: "up" },
{ label: "orLow", value: lv.orLow, dir: "down" },
];
const lines = [
breakInteractionLine(market.tape, breakLevels[0]!), // hod
breakInteractionLine(market.tape, breakLevels[1]!), // lod
vwapInteractionLine(market.tape, lv.vwap),
breakInteractionLine(market.tape, breakLevels[2]!), // orHigh
breakInteractionLine(market.tape, breakLevels[3]!), // orLow
];
return [`level-interaction · ${market.instrument}`, ...lines].join("\n");
}
// ── range-velocity (kestrel-4gl.13.3) — the current move's RANGE + VELOCITY, the quality of momentum ─
//
// A CALC read of the realized RANGE (the tape's own excursion) and the VELOCITY of the current move
// (signed close-to-close), and whether that velocity is IMPULSIVE (expanding into the last bar, at/near
// its own p99 — a fundable break) or GRINDING (collapsing, far below p99 — a poke on decelerating
// velocity is unfunded, the textbook EQ fakeout). PURE over `market.tape` ALONE (+ the instrument for
// the header): reads NO level / chain / kernel / model input — CALC, no new data sourced. Aligned with
// the runtime Window doctrine (CONTEXT.md): range = excursion, velocity = signed, move = magnitude,
// p99 = the window's own trailing baseline (the per-bar |Δclose| distribution's nearest-rank max).
//
// Definitions (precise, so the read is deterministic):
// • RANGE — the tape's realized excursion `max(high) − min(low)` over the window (its OWN extremes,
// NOT `levels.hod/lod` — "Data: the tape. Nothing new." per spec §3, so the "no new data" proof
// is maximal — range never reaches for a level Field).
// • VELOCITY — signed close-to-close: per-bar `Δ_i = close_i − close_{i-1}`; the window's NET velocity
// is `close_last − close_first`, and MOVE is its magnitude `|net|`.
// • p99 — the nearest-rank 99th percentile of the per-bar `|Δ|` distribution (for small n this is the
// max — matching the runtime's `p99 === max{1..10}`). The current bar's velocity is judged vs it.
// • IMPULSIVE — the current bar `|Δ_last|` is at/near p99 (≥ half) AND accelerating
// (`|Δ_last| > |Δ_prev|`): the break is being driven — fundable.
// • GRINDING — the current bar is far below p99 (< half) AND decelerating (`|Δ_last| < |Δ_prev|`):
// velocity is collapsing — a poke here is unfunded (the EQ fakeout — a poke on decelerating velocity).
// • FAIL-CLOSED — no prints ⇒ `(no prints this window)`; a single bar (no close-to-close delta) ⇒
// velocity UNKNOWN (never a fabricated impulsive/grinding read); < 2 deltas ⇒ the accel/decel read
// is UNKNOWN/insufficient, never invented.
/** The nearest-rank 99th percentile of a non-empty sample (ascending sort; rank = ceil(0.99·n), clamped
* to ≥1). For small n this is the max — matching the runtime Window doctrine's `p99 === max{1..10}`
* (CONTEXT.md; tests/series.test.ts). Pure — a total function of the sample. */
function p99NearestRank(sample: readonly number[]): number {
const sorted = [...sample].sort((a, b) => a - b);
const rank = Math.max(1, Math.ceil(0.99 * sorted.length));
return sorted[rank - 1]!;
}
/**
* `range-velocity` — realized RANGE + VELOCITY of the current move (CALC, no receipt, no new data).
* Is the break IMPULSIVE (accelerating at/near its p99 — fundable) or GRINDING (decelerating far below
* p99 — a poke here is unfunded, the EQ fakeout)? PURE over `market.tape` (+ the instrument header);
* reads no level/chain/kernel/model Field. FAIL-CLOSED: no prints ⇒ absent-not-hidden; a single bar ⇒
* velocity UNKNOWN; < 3 bars ⇒ the accel/decel verdict is UNKNOWN — never a fabricated velocity.
* DATE-BLIND: renders bar counts + prices + trend words only (no clock/date token).
*/
function renderRangeVelocity(market: MarketPane): string {
const rows = market.tape;
const head = `range-velocity · ${market.instrument}`;
if (rows.length === 0) {
return `${head} · (no prints this window)`;
}
// RANGE — the tape's OWN realized excursion (max high − min low), never levels.hod/lod (no new data).
let hi = -Infinity;
let lo = Infinity;
for (const r of rows) {
if (r.high > hi) hi = r.high;
if (r.low < lo) lo = r.low;
}
const range = hi - lo;
// A single bar carries no close-to-close velocity — fail closed to UNKNOWN (invents no velocity).
if (rows.length < 2) {
return [
head,
` range ${num(range, 2)} · velocity ${DASH} · 1 bar (insufficient for a close-to-close velocity)`,
" read: UNKNOWN — need ≥2 bars for a velocity read (insufficient)",
].join("\n");
}
// VELOCITY — signed close-to-close deltas; the window's net + its magnitude (move); the p99 baseline.
const deltas: number[] = [];
for (let i = 1; i < rows.length; i += 1) deltas.push(rows[i]!.close - rows[i - 1]!.close);
const absDeltas = deltas.map((d) => Math.abs(d));
const net = rows[rows.length - 1]!.close - rows[0]!.close;
const move = Math.abs(net);
const p99 = p99NearestRank(absDeltas);
const lastAbs = absDeltas[absDeltas.length - 1]!;
const barVel = deltas[deltas.length - 1]!; // the signed current-bar velocity
const bars = rows.length;
const ratioPct = p99 > 0 ? Math.round((lastAbs / p99) * 100) : null;
const ratioStr = ratioPct === null ? DASH : `${ratioPct}%`;
const l2 = ` range ${num(range, 2)} · velocity ${money(net)} · move ${num(move, 2)} over ${bars} bars`;
const l3base = ` bar velocity now ${money(barVel)} · p99 ${num(p99, 2)} (${ratioStr} of p99)`;
// The accel/decel read needs ≥2 deltas (≥3 bars); fewer ⇒ UNKNOWN, never a fabricated verdict.
const prevAbs = absDeltas.length >= 2 ? absDeltas[absDeltas.length - 2]! : null;
if (prevAbs === null || ratioPct === null) {
return [
head,
l2,
`${l3base} · trend UNKNOWN`,
" read: UNKNOWN — need ≥3 bars to judge accelerating vs decelerating (insufficient)",
].join("\n");
}
const trend = lastAbs > prevAbs ? "ACCELERATING" : lastAbs < prevAbs ? "DECELERATING" : "STEADY";
const l3 = `${l3base} · ${trend}`;
let read: string;
if (trend === "ACCELERATING" && ratioPct >= 50) {
read = " read: IMPULSIVE — break on accelerating velocity at/near its p99, fundable";
} else if (trend === "DECELERATING" && ratioPct < 50) {
read = " read: GRINDING — poke on decelerating velocity far below p99, unfunded (EQ fakeout)";
} else {
read = ` read: MIXED — velocity ${ratioStr} of p99, ${trend.toLowerCase()}; inconclusive (neit