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.
126 lines (118 loc) • 7.45 kB
text/typescript
/**
* # protocol/support — the four-cell SIGNED partition + named READ VIEWS (kestrel-dpy, ADR-0014)
*
* PROMOTED ONTO THE PROTOCOL SURFACE (kestrel-h314, D5). This module used to live at `src/support/index.ts`
* (a runtime leaf); it is PURE (imports nothing) and it owns the fill-support vocabulary the certified
* `fill_claim` evidence on the protocol {@link ./index.ts Blotter} is partitioned by, so it now sits on the
* zero-dependency protocol surface and is re-exported from `kestrel.markets/protocol`. `src/support/index.ts`
* remains as a thin re-export of THIS module (one source, ADR-0014 — the fill seam, the Blotter projector,
* the OSS grader, and any self-hosted outsider all read the SAME `bankableEv` / `bankableEvOf` policy). The
* platform then deletes its `apps/api/src/grade/support.ts` redeclaration and imports these from the package
* (closing "seam candidate 6" duplication).
*
* The single owner of the run's expected-$ **honesty question**. ADR-0014: *sign policy is a property
* of the QUESTION asked of a run, never of the run itself.* So the support partition is **written once**
* — as four SIGNED cells, `{calibrated, extrapolated} × {gain, loss}` — and every policy (`raw`,
* `bankable`, and any future one) is a pure READ VIEW over it, never a write-time collapse. One module
* owns the vocabulary ({@link FillSupport}), the partition ({@link partition}), and the views; the fill
* engine, the Blotter projector, and the grader all consume this ONE module so a second, forkable policy
* can never drift out of sync.
*
* The forcing case (ADR-0014): grading one tape under a floor model (`strict-cross`) and an offline
* ceiling (`maker-fair`, fills `@fair`) is a deliberate bracket, and the ceiling's cells are
* `extrapolated` by construction. Two legitimate consumers ask different questions:
*
* - **raw** — *"what does this run claim under its own fill model?"* The experiment / comparison view
* (fill-model brackets + config grids): the full, support-LABELED total — extrapolated gains KEPT,
* because censoring the ceiling's upside makes the floor↔ceiling bracket unreadable. Label, never
* censor.
* - **bankable** — *"what may be banked, ranked, sold?"* Calibrated cells in FULL (both signs), plus
* extrapolated **losses**; extrapolated **gains are REFUSED** (contribute 0). The asymmetry is
* deliberate and conservative: a strategy can neither hill-climb into an uncalibrated corner nor
* park its losses there. Leaderboards, seasons, and any attested receipt read this view only.
*
* The bug this closes: the Blotter's `fill_claim` used to partition expected-$ **unsigned**, so a
* consumer that "subtracts the extrapolated slice" would erase extrapolated LOSSES together with the
* gains — flattering exactly the loss-parking strategy the doctrine exists to punish, and disagreeing
* with `bankableEv`. A SIGNED partition on the record lets a subtracting consumer remove only the
* refused `gain`, so extrapolated losses can never be zeroed. Pure and deterministic (RUNTIME §0).
*/
/** The support flag's two states (the single source; the fill seam and Blotter re-export from here).
* `"calibrated"` = the cell is covered by realized live data (or is a structural price-priority fact);
* `"extrapolated"` = priced off the offline ceiling with no live anchor (a grader refuses to bank its
* gains). A missing/unstated flag is treated as `"extrapolated"` downstream (the conservative,
* fail-closed default). */
export type FillSupport = "calibrated" | "extrapolated";
/** The grader/RunRecord's banking predicate: a cell's expected-$ **gain** is bankable **only** when its
* support is `"calibrated"`. An `"extrapolated"` cell — or an unstated one — is refused (conservative,
* fail-closed default), so a strategy cannot hill-climb into an uncalibrated corner of the fill model
* (e.g. the far-OTM-offer fantasy or a dark internalization lift). */
export function isCalibratedSupport(support: FillSupport | undefined): boolean {
return support === "calibrated";
}
/**
* The four-cell SIGNED partition of a run's expected-$ (ADR-0014, written ONCE). Each cell is a signed
* sum on the same currency grid as {@link rawEv}/{@link bankableEvOf}: `gain ≥ 0` (Σ of the non-negative
* expected-$ in that support class) and `loss ≤ 0` (Σ of the negative). The named views below are pure
* folds over these four numbers; the partition itself never bakes a sign policy in.
*/
export interface SupportPartition {
readonly calibrated: { readonly gain: number; readonly loss: number };
readonly extrapolated: { readonly gain: number; readonly loss: number };
}
/**
* Fold per-cell expected-$ into the four SIGNED cells (PURE). Fail-closed: an unstated support flag is
* bucketed as `extrapolated` (never silently banked); each cell's value is split by sign so a gain and a
* loss in the same support class land in different cells and can never cancel at write time. The empty
* fold is the all-zero partition.
*/
export function partition(
cells: readonly { readonly support: FillSupport | undefined; readonly ev: number }[],
): SupportPartition {
let calGain = 0;
let calLoss = 0;
let extGain = 0;
let extLoss = 0;
for (const cell of cells) {
if (isCalibratedSupport(cell.support)) {
if (cell.ev >= 0) calGain += cell.ev;
else calLoss += cell.ev;
} else {
// Unstated support fails closed to extrapolated (ADR-0014) — never banked as calibrated.
if (cell.ev >= 0) extGain += cell.ev;
else extLoss += cell.ev;
}
}
return {
calibrated: { gain: calGain, loss: calLoss },
extrapolated: { gain: extGain, loss: extLoss },
};
}
/**
* The **raw** view (ADR-0014): the full, support-LABELED total — all four cells summed, so extrapolated
* gains are KEPT. This is the experiment / comparison number the fill-model floor↔ceiling brackets and
* the config grids read; the Grade's obligation here is to label the support, never to censor it.
*/
export function rawEv(p: SupportPartition): number {
return p.calibrated.gain + p.calibrated.loss + p.extrapolated.gain + p.extrapolated.loss;
}
/**
* The **bankable** view (ADR-0014): calibrated cells in FULL (both signs) + extrapolated LOSSES, and it
* REFUSES extrapolated GAINS (they contribute 0). The one number leaderboards, seasons, and attested
* receipts read. Exactly the landed per-cell {@link bankableEv} policy, aggregated:
* `bankableEvOf(partition(cells)) === Σ bankableEv(cell.ev, cell.support)` (one policy, never forked).
*/
export function bankableEvOf(p: SupportPartition): number {
return p.calibrated.gain + p.calibrated.loss + p.extrapolated.loss;
}
/**
* The per-cell banking policy, RE-EXPRESSED here as the single source (grade re-exports this; it does not
* fork a second copy). A `"calibrated"` cell banks its expected-$ as-is (both signs). An `"extrapolated"`
* / unstated cell REFUSES a paper GAIN (→ 0) but does NOT forgive a paper LOSS — zeroing a negative EV
* would flatter a strategy that routes losing fills into uncalibrated corners (the fail-OPEN inverse of
* the hill-climb this guard prevents). So an extrapolated cell banks only its non-positive part.
*/
export function bankableEv(ev: number, support: FillSupport | undefined): number {
if (isCalibratedSupport(support)) return ev;
return Math.min(ev, 0);
}