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.
248 lines (230 loc) • 8.87 kB
text/typescript
/**
* # react/HonestChart — human candlesticks over the Frame `json` Rendering (ADR-0052 §3)
*
* A standard human candlestick chart whose ONLY input is the typed Frame `json` Rendering
* ({@link ../frame/render-json.ts FrameJson}) — there is NO generic-OHLC entry point, by doctrine: a
* chart that cannot prove it invented nothing defeats the honest-chart claim. Three honesty rules,
* all enforced here on the reading side:
*
* - **UNKNOWN renders as a GAP.** A tape bucket with any UNKNOWN of {open,high,low,close} draws NO
* candle — a dashed time-axis tick marks that the bucket exists but its price is unknown. Never an
* interpolated candle, never a zero, never a neighbour carried forward.
* - **MODEL Fields are BADGED with their receipt.** The kernel's predictor/regime claims are `MODEL`
* Fields; each renders a badge whose `<title>` exposes the receipt (source + model version +
* confidence) on inspection. Provenance is never hidden.
* - **Fail-closed on a degraded MODEL Field.** A claim whose `MODEL` Field arrives WITHOUT its
* receipt is REFUSED ({@link assertModelReceipt}) — the chart throws rather than draw an
* unattributable model value.
*
* Rendered as pure SVG (no chart library, no runtime dep beyond the optional `react` peer). PURE +
* deterministic: the same {@link FrameJson} yields byte-identical markup (no wall clock, no RNG) —
* `React hosts Renderings; it never produces them`.
*/
import { createElement, type ReactElement } from "react";
import type { FrameJson, MarketJson, TapeRowJson } from "../frame/render-json.ts";
import { assertModelReceipt, isUnknown, valueOr } from "./frame-fields.ts";
// ── Fixed, deterministic layout geometry ─────────────────────────────────────
const M = { top: 40, right: 16, bottom: 28, left: 48 } as const;
const UP = "#16a34a";
const DOWN = "#dc2626";
/** 2-decimal fixed string — stable SVG coordinates (no float noise across platforms/runs). */
function r2(n: number): string {
return n.toFixed(2);
}
export interface HonestChartProps {
/** The typed Frame `json` Rendering — the ONLY data source (no generic-OHLC props, ADR-0052 §3). */
readonly frame: FrameJson;
/** Presentation only (not data): the SVG canvas size. Defaults 640×320. */
readonly width?: number;
readonly height?: number;
/** Presentation only: a class on the root `<svg>` for host styling. */
readonly className?: string;
}
/** The finite price extent over a tape (UNKNOWN legs skipped, never zero-filled). */
function priceExtent(tape: readonly TapeRowJson[]): { min: number; max: number } | null {
let min = Infinity;
let max = -Infinity;
for (const row of tape) {
for (const leg of [row.open, row.high, row.low, row.close]) {
const v = valueOr(leg);
if (v === null) continue;
if (v < min) min = v;
if (v > max) max = v;
}
}
return min === Infinity ? null : { min, max };
}
/** Draw the candle body + wick, or a GAP tick when any OHLC leg is UNKNOWN. */
function candleEl(
row: TapeRowJson,
i: number,
cx: number,
bodyW: number,
y: (p: number) => number,
baselineY: number,
): ReactElement {
const o = valueOr(row.open);
const h = valueOr(row.high);
const l = valueOr(row.low);
const c = valueOr(row.close);
// Fail-closed honesty: an incomplete bucket is a GAP, never an interpolated/zeroed candle.
if (o === null || h === null || l === null || c === null) {
return createElement("line", {
key: i,
className: "kestrel-candle kestrel-candle--gap",
x1: r2(cx),
x2: r2(cx),
y1: r2(baselineY),
y2: r2(baselineY - 6),
stroke: "currentColor",
strokeOpacity: 0.4,
strokeDasharray: "2 2",
"data-clock": row.clock,
"data-gap": "true",
});
}
const up = c >= o;
const color = up ? UP : DOWN;
const bodyTop = y(Math.max(o, c));
const bodyH = Math.max(Math.abs(y(o) - y(c)), 1);
return createElement(
"g",
{
key: i,
className: `kestrel-candle kestrel-candle--${up ? "up" : "down"}`,
"data-clock": row.clock,
},
createElement("line", {
key: "wick",
x1: r2(cx),
x2: r2(cx),
y1: r2(y(h)),
y2: r2(y(l)),
stroke: color,
strokeWidth: 1,
}),
createElement("rect", {
key: "body",
x: r2(cx - bodyW / 2),
y: r2(bodyTop),
width: r2(bodyW),
height: r2(bodyH),
fill: color,
}),
);
}
/** The MODEL-claim badge strip — each badge's `<title>` exposes its receipt on inspection. Refuses a
* claim whose MODEL Field lacks its receipt (fail-closed). */
function claimBadges(kernel: FrameJson["kernel"], width: number): ReactElement[] {
const badges: ReactElement[] = [];
const badgeW = 132;
kernel.claims.forEach((claim, i) => {
const receipt = assertModelReceipt(claim.field, `claim[${i}] ${claim.claimType}`);
const x = M.left + i * (badgeW + 6);
if (x + badgeW > width) return; // off-canvas badges are dropped from the strip, never overlapped
const conf = receipt.confidence.toFixed(2);
const receiptText = `${claim.claimType} = ${String(claim.field.value)} (MODEL ${receipt.source} ${receipt.modelVer} · conf ${conf})`;
badges.push(
createElement(
"g",
{
key: i,
className: "kestrel-model-badge",
transform: `translate(${r2(x)}, 8)`,
"data-claim-type": claim.claimType,
"data-source": receipt.source,
"data-model-ver": receipt.modelVer,
"data-confidence": conf,
},
createElement("title", { key: "t" }, receiptText),
createElement("rect", {
key: "r",
x: 0,
y: 0,
width: badgeW,
height: 18,
rx: 3,
fill: "none",
stroke: "currentColor",
strokeOpacity: 0.5,
}),
createElement("circle", { key: "c", cx: 9, cy: 9, r: 3, fill: UP, fillOpacity: 0.8 }),
createElement(
"text",
{ key: "x", x: 18, y: 13, fontSize: 10, fill: "currentColor" },
`${claim.claimType} · MODEL`,
),
),
);
});
return badges;
}
/**
* Render the honest candlestick chart for a Frame `json` Rendering. UNKNOWN buckets become gaps,
* MODEL claims are badged with their receipt, a degraded MODEL Field is refused (fail-closed). Pure
* SVG, deterministic markup.
*/
export function HonestChart(props: HonestChartProps): ReactElement {
const { frame } = props;
const width = props.width ?? 640;
const height = props.height ?? 320;
const market: MarketJson = frame.market;
const tape = market.tape;
const plotW = width - M.left - M.right;
const plotH = height - M.top - M.bottom;
const baselineY = height - M.bottom;
const extent = priceExtent(tape);
// Price → y. When the extent is a single price (or none), collapse to the plot's vertical midpoint
// rather than divide by zero — the candles still draw, just flat.
const span = extent && extent.max > extent.min ? extent.max - extent.min : 0;
const y = (p: number): number =>
span === 0 || extent === null ? M.top + plotH / 2 : M.top + ((extent.max - p) / span) * plotH;
const n = tape.length;
const slotW = n > 0 ? plotW / n : plotW;
const bodyW = Math.min(slotW * 0.6, 14);
const candles = tape.map((row, i) => candleEl(row, i, M.left + slotW * (i + 0.5), bodyW, y, baselineY));
const badges = claimBadges(frame.kernel, width);
const children: ReactElement[] = [
// Plot baseline (time axis) — a frame for the candles, not a price.
createElement("line", {
key: "axis",
className: "kestrel-chart-axis",
x1: r2(M.left),
x2: r2(width - M.right),
y1: r2(baselineY),
y2: r2(baselineY),
stroke: "currentColor",
strokeOpacity: 0.3,
}),
];
// Price extent labels (honest: the real max/min drawn, or nothing when the tape is all-UNKNOWN).
if (extent !== null) {
children.push(
createElement(
"text",
{ key: "pmax", x: 4, y: r2(M.top + 4), fontSize: 10, fill: "currentColor", fillOpacity: 0.7 },
r2(extent.max),
),
createElement(
"text",
{ key: "pmin", x: 4, y: r2(baselineY), fontSize: 10, fill: "currentColor", fillOpacity: 0.7 },
r2(extent.min),
),
);
}
children.push(...badges, ...candles);
return createElement(
"svg",
{
className: props.className ?? "kestrel-honest-chart",
xmlns: "http://www.w3.org/2000/svg",
viewBox: `0 0 ${width} ${height}`,
width,
height,
role: "img",
"data-instrument": market.instrument,
"aria-label": `Candlestick chart for ${market.instrument}`,
},
children,
);
}