UNPKG

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
/** * # 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, ); }