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.

462 lines (426 loc) 25.2 kB
/** * # cli/render/sim — the SIM story + menu as PURE TEXT (kestrel-585 / kestrel-vcn). * * The hosted `/simulate` path is one-shot today (the whole Operation arrives completed — * no interactive increments yet; that streaming face is kestrel-markets-dog). So this * renderer tells the COMPLETED Operation's story rather than faking a stream — but it is * structured as a list of independent lines built from a {@link SimStory} domain object, * so a streaming transport can later emit the SAME lines incrementally without changing a * character of the copy. * * Pure text on stdout in every non-json mode (the default human path is intentionally the * same plain text — the sim verb is a light, node-runnable funnel, not a TTY cockpit). * Diagnostics belong on stderr (the command writes those); this module only builds the * stdout payload lines. */ import type { OutputCtx } from "../context.ts"; import type { GradedMetrics, HostedEvidenceBundle, ReceiptsSummary } from "../backend/hosted.ts"; import type { EpisodeReport } from "../heavy.ts"; /** The domain object a sim run renders from — everything the story needs, transport-agnostic. */ export interface SimStory { /** The canonical catalog slug (printed in the header even when an alias was typed). */ readonly canonicalSlug: string; /** What the caller typed (may be a marketing alias). */ readonly requestedSlug: string; /** True when an alias resolved to the canonical slug. */ readonly viaAlias: boolean; readonly title: string; readonly instrument: string; readonly period: { readonly start: string; readonly end: string }; readonly frameCount: number; /** How the strategy was supplied: the stand-down demo label, or the --plans path. */ readonly strategyLabel: string; readonly operation: { readonly id: string; readonly status: string }; readonly receipts: ReceiptsSummary; /** Verified private R2 references for replay, evaluation, and watcher training. */ readonly evidence: HostedEvidenceBundle; /** * The graded metrics from the signed proof, or `null` when the proof is still SETTLING — a * transient R2 write-lag 404 on `GET /proof`. The Operation completed and was metered (the * user's paid-for result), but the shareable grade is not yet readable, so the grade + proof * lines annotate "settling — retry shortly" rather than discard the run (kestrel-fzum). */ readonly metrics: GradedMetrics | null; /** The shareable web proof URL — always the LAST line. */ readonly proofUrl: string; } /** Format a dollar figure compactly (budget/pnl), trimming noise floats deterministically. */ function money(n: number): string { const r = Math.round(n * 1e6) / 1e6; return `$${r}`; } /** * Build the ordered story lines. ONE line per beat so a future streaming face can emit * them as they arrive (header first, proof last). No color, no width-wrapping — the funnel * output is byte-identical across a TTY and a pipe. */ export function simStoryLines(story: SimStory): string[] { const lines: string[] = []; lines.push(`kestrel sim · ${story.canonicalSlug}`); if (story.viaAlias) lines.push(`alias ${story.requestedSlug}${story.canonicalSlug}`); lines.push( `scenario ${story.title}${story.instrument} · ${story.period.start}..${story.period.end} · ${story.frameCount} frames`, ); lines.push(`briefing ${story.instrument} percept context available (${story.frameCount} frames)`); lines.push(`strategy ${story.strategyLabel}`); lines.push(`operation ${story.operation.id} · ${story.operation.status}`); const budget = story.receipts.budgetRemaining === null ? "—" : money(story.receipts.budgetRemaining); lines.push(`receipts ${story.receipts.meteredCount} metered · budget remaining ${budget}`); lines.push( `evidence manifest=${story.evidence.manifestRef.artifact_id} bus=${story.evidence.busRef.artifact_id} grade=${story.evidence.gradeRef.artifact_id} calls=${story.evidence.callRefs.length}`, ); if (story.metrics === null) { // The Operation completed and was metered, but the signed grade proof is not yet readable // (transient R2 write lag). Annotate the pending lines "settling — retry shortly" rather than // fabricate a zero-trade grade or discard the completed run (kestrel-fzum). lines.push(`grade settling — retry shortly`); lines.push(`proof ${story.proofUrl} — settling — retry shortly`); } else { lines.push( `grade order_count=${story.metrics.orderCount} fill_count=${story.metrics.fillCount} realized_pnl=${story.metrics.realizedPnl}`, ); lines.push(`proof ${story.proofUrl}`); } return lines; } /** Write the sim story to stdout as pure text (human + text modes share the plain form). */ export function renderSimStory(story: SimStory, _ctx: OutputCtx): void { process.stdout.write(simStoryLines(story).join("\n") + "\n"); } /** * The one-line "why did nothing trade?" explanation for a 0-order run (kestrel-kglw). A run that * placed no orders is the single highest-value teaching moment in the funnel — and it used to be * SILENT on the default face (the grader's reason lived only under `--json`). This surfaces the * grader-stamped reason VERBATIM (gate unsatisfied / trigger never crossed / budget absent / series * unavailable, …) as one legible line beside the `grade order_count=0` line. Pure + transport- * agnostic; the caller decides WHEN to emit it (only a 0-order run carrying a reason). */ export function neverFiredLine(reason: string): string { return `why no orders fired — ${reason}`; } /** * The one-line never-fired reason for ONE plan in the LOCAL `run`/`day` `--format human` report * (kestrel-kglw / kestrel-y6vo) — the twin of the hosted {@link neverFiredLine}, but per-plan and * sourced from the plan's own lifecycle trace rather than the grader's run-level diagnostic. * * A plan that placed ZERO orders and never fired is the highest-value teaching moment of a 0-order run, * and the plan-lifecycle ARC alone could not tell WHY: the arc renders a reason only when it rides a * TERMINAL (outcome-bearing) step (`done(expired:…)`), so the armed-expiry classes surface there * (kestrel-y6vo engine work) but a regime-DOA plan — whose reason rides a NON-terminal `authored` reject * (engine/plans.ts `#emitReject`, the kestrel-ocyf/hk9u arm-block notice) — printed a bare * `authored → authored` with the reason dropped. This surfaces the LAST reason-bearing lifecycle step of * a never-fired plan UNIFORMLY, whether that reason sits on a terminal or an intermediate step, so every * never-fired class (plan-budget clamp, unwritten-series trigger, trigger-never-crossed, regime-gate DOA, * manages-nothing) self-explains on the default face. * * Returns `undefined` for a plan that traded (reached `fired`/`managing`, or placed any order — a resting * unfilled order still FIRED) or that carries no reason, so a healthy run stays quiet. Pure: reads only * report fields. A leading `ttl — ` from the armed-expiry classifier is trimmed so the line reads as one * clean cause, not a doubled `ttl` token. */ export function planNeverFiredReason( plan: EpisodeReport["plans"][number], orders: EpisodeReport["orders"], ): string | undefined { const fired = plan.lifecycle.some((s) => s.state === "fired" || s.state === "managing"); if (fired) return undefined; const placed = orders.some((o) => o.plan_instance === plan.plan_instance); if (placed) return undefined; for (let i = plan.lifecycle.length - 1; i >= 0; i--) { const reason = plan.lifecycle[i]!.reason; if (reason !== undefined) return reason.startsWith("ttl — ") ? reason.slice("ttl — ".length) : reason; } return undefined; } /** * The per-plan never-fired reason lines for the TEXT-mode `run`/`day` settle DIGEST (kestrel-1zix) — the * token-cheap agent/piped default face. That digest collapses a session to ONE aggregate line * (`plans_fired=0 … orders_placed=0`); before this it dropped every per-plan reason the report carries, * so the DEFAULT frame a user pipes was byte-for-byte silent about WHY a plan never fired — the exact * "learn it from an empty blotter" failure the human/json faces already close, one surface downstream. * * This surfaces the SAME projection the human report uses ({@link planNeverFiredReason}) — regime-DOA, * unwritten-series, trigger-never-crossed, plan-budget, ttl-expired, adoption-bound-nothing — one line * per zero-order plan that carries a reason, so the text digest self-explains for EVERY reason-bearing * class instead of an aggregate that looks the same for a healthy skip and a dead-on-arrival plan. * * Returns `[]` for a session where every plan traded (or none carries a reason), so a healthy run's * digest stays the SINGLE aggregate line it always was — the token-cheap default is preserved; the extra * lines appear ONLY when there is something to explain. Pure: reads only report fields. The plan NAME is * echoed (the digest has no per-plan section like the human face) so each reason is attributable. */ export function neverFiredDigestLines(report: EpisodeReport): string[] { const lines: string[] = []; for (const p of report.plans) { const why = planNeverFiredReason(p, report.orders); if (why !== undefined) lines.push(`why ${p.plan} placed no orders — ${why}`); } return lines; } /* ─────────────────────── the bundled-demo one-frame lesson ─────────────────── */ /** * The one-frame lesson a bare `prove` prints when it ran the BUNDLED example plan (no `--plans`). * It teaches in a single frame: WHAT the example is (a labeled, replaceable starter — never the * caller's strategy) and HOW to swap in your own. Pure + transport-agnostic; the `grade` line of * the story above is what the example actually did, so this block never restates the metrics * (and never dresses a naive buy-and-hold up as alpha). */ export interface DemoLesson { /** The bundled example's label (self-describing as a replaceable example). */ readonly planLabel: string; /** The example's canonical multi-line source, echoed verbatim so the reader sees what ran. */ readonly planSource: string; /** The zero-credential re-entry that authors your OWN plan (`prove --plans <file>`). */ readonly swapInCli: string; } /** Build the structured demo-lesson payload for `--json` (the printed block's twin). */ export function demoLessonObject(lesson: DemoLesson): { bundled_example: true; plan_label: string; plan_source: string; swap_in: string; } { return { bundled_example: true, plan_label: lesson.planLabel, plan_source: lesson.planSource, swap_in: lesson.swapInCli, }; } /** Build the ordered lesson lines: the labeled example, its source, and the swap-in re-entry. */ export function demoLessonLines(lesson: DemoLesson): string[] { const lines: string[] = []; lines.push(""); lines.push(`demo plan ${lesson.planLabel}`); lines.push(" this run used a BUNDLED EXAMPLE plan, not your strategy — it is what makes the"); lines.push(" grade above a real, filled trade instead of an empty ($0, no-orders) session:"); for (const l of lesson.planSource.split("\n")) lines.push(` ${l}`); lines.push(" the `grade` line above is exactly what this example did (a naive one-share"); lines.push(" buy-and-hold — an honest result, never dressed up as alpha). author your own:"); lines.push(` ${lesson.swapInCli}`); return lines; } /** Write the bundled-demo one-frame lesson to stdout as pure text. */ export function renderDemoLesson(lesson: DemoLesson): string { return demoLessonLines(lesson).join("\n"); } /* ─────────────────────── the plan-less stand-down lesson (v6et) ─────────────────── */ /** * The one-frame lesson `sim <slug>` prints when it ran the STAND-DOWN baseline (no `--plans`). * * Bug v6et: `sim <slug>` with no `--plans` certifies an honest but EMPTY session (order_count=0, * $0) — and said NOTHING about why. A cold user browsing the menu (or the crypto door routing * straight to `sim <btc/eth-slug>`) reads the all-zeros grade + a minted "proof of nothing" as * "broken / nothing happened", and the referral loop dies at the artifact. This is the honest * counterpart to `prove`'s bundled-example lesson: `sim`'s default is a stand-down BY DESIGN * (the raw funnel arms nothing), so this frame says so plainly and points at the next step — * author a plan and pass `--plans`, or run `prove` for a bundled example that trades. * * NEVER fabricates a fill: it teaches that the empty grade is correct for a plan-less run and * how to make the next run trade. Pure + transport-agnostic; the caller decides WHEN to emit it * (only a `sim` run that fell back to the stand-down default — never when `--plans` was authored). */ export interface StandDownLesson { /** The scenario slug the caller ran — echoed into the swap-in so the next step is copy-pasteable. */ readonly scenarioSlug: string; /** A minimal, replaceable example plan the reader can drop into a file — echoed verbatim. */ readonly examplePlanSource: string; /** The `--plans` re-entry that authors your OWN plan ON THIS scenario (built with {@link scenarioSlug}). */ readonly swapInCli: string; /** The zero-credential alternative front door that trades out of the box (`prove`). */ readonly proveCli: string; } /** Build the structured stand-down payload for `--json` (the printed block's twin). */ export function standDownLessonObject(lesson: StandDownLesson): { stand_down_demo: true; reason: string; example_plan_source: string; swap_in: string; prove: string; } { return { stand_down_demo: true, reason: "no --plans supplied — this run used the labeled stand-down baseline, which arms no orders", example_plan_source: lesson.examplePlanSource, swap_in: lesson.swapInCli, prove: lesson.proveCli, }; } /** Build the ordered stand-down lesson lines: the honest by-design framing + the two next steps. */ export function standDownLessonLines(lesson: StandDownLesson): string[] { const lines: string[] = []; lines.push(""); lines.push("stand-down no --plans supplied — this run used the labeled stand-down baseline BY DESIGN"); lines.push(" nothing is broken: with no strategy authored there is nothing to arm, so the grade"); lines.push(" above is an honest $0 / no-orders result — a plan-less stand-down, not a failure."); lines.push(" author a strategy and pass it with --plans to place real orders. a minimal example:"); for (const l of lesson.examplePlanSource.split("\n")) lines.push(` ${l}`); lines.push(" save that to a file, then run it on this scenario:"); lines.push(` ${lesson.swapInCli}`); lines.push(" or run a bundled example that trades out of the box, no plan to write:"); lines.push(` ${lesson.proveCli}`); return lines; } /** Write the plan-less stand-down lesson to stdout as pure text. */ export function renderStandDownLesson(lesson: StandDownLesson): string { return standDownLessonLines(lesson).join("\n"); } /* ─────────────────────── the free-path paid-boundary nudge (kestrel-yk9n) ─────────────────────── */ /** * The one-frame nudge a FREE `sim <slug>` run prints so the paid boundary is legible on the happy path * (kestrel-yk9n, mirror of kestrel-markets-f6ly). * * Bug f6ly: a persona following the advertised free CLI path completes a run, reads its grade + proof * URL, and is only ever offered MORE free stuff — the 402 value boundary (the signed, settleable Offer * that certifies a run against real spend) is reachable only by knowing the undocumented-to-humans * `POST /api/offers/quote`. So a Robinhood human never learns they could "pay to certify MY book", and * an allocator never feels what paid `grade.certified` buys — the value boundary never converts. * * This surfaces, on the FREE path only (no `--budget` declared), one honest frame: this run cost * nothing, and HOW to cross into the paid boundary — pass `--budget <amount>`, which caps a metered run * and returns the price as a signed Offer (data, never auto-charged). It states the mechanism plainly * and points at the copy-pasteable next step; it makes NO claim about the free run's grade and invents * no price (the Offer is signed server-side — the CLI only tells you how to reach it). Pure + * transport-agnostic; the caller decides WHEN to emit it (only a free `sim` run — never once `--budget` * opted into the boundary, and never on the gated/settlement outcome, which already prints the Offer). */ export interface PaidBoundaryNudge { /** The canonical scenario slug — echoed into the `--budget` re-entry so the next step is copy-pasteable. */ readonly scenarioSlug: string; } /** Build the structured paid-boundary nudge for `--json` (the printed block's twin). */ export function paidBoundaryNudgeObject(nudge: PaidBoundaryNudge): { free_run: true; reason: string; reach_paid_boundary: string; } { return { free_run: true, reason: "this run was free — nothing was owed; the paid boundary (a signed, settleable Offer) is opt-in via --budget", reach_paid_boundary: `npx kestrel.markets sim ${nudge.scenarioSlug} --budget 0`, }; } /** Build the ordered nudge lines: this run was free + how to cross into the paid boundary. */ export function paidBoundaryNudgeLines(nudge: PaidBoundaryNudge): string[] { const lines: string[] = []; lines.push(""); lines.push("paid path this run was FREE — metered against the anonymous trial, nothing owed, no card"); lines.push(" the paid boundary certifies a run against real spend: pass --budget <amount> and a metered"); lines.push(" run pauses at the Spend boundary and returns the price as a signed, settleable Offer (data,"); lines.push(" never auto-charged) — the same 402 an agent reaches. see it on this scenario:"); lines.push(` npx kestrel.markets sim ${nudge.scenarioSlug} --budget 0`); return lines; } /** Write the free-path paid-boundary nudge to stdout as pure text. */ export function renderPaidBoundaryNudge(nudge: PaidBoundaryNudge): string { return paidBoundaryNudgeLines(nudge).join("\n"); } /* ─────────────────────────────── the menu ──────────────────────────────── */ /** One row of the bare-`sim` menu — a scenario slug + its title/instrument/artifact. */ export interface MenuScenario { readonly slug: string; readonly title: string; readonly instrument: string; readonly artifactId: string; readonly free: boolean; } /** The menu payload: the derived scenarios + the marketing aliases. */ export interface SimMenu { readonly scenarios: readonly MenuScenario[]; readonly aliases: Readonly<Record<string, string>>; } /** * Render the bare-`sim` menu as pure text: the scenario slugs (with artifact_id), the * marketing aliases, and one usage line. Node-light — this is the free front door. */ export function renderSimMenu(menu: SimMenu, _ctx: OutputCtx): void { const lines: string[] = []; // The headline is derived from the rows, never asserted over them: only an all-free // catalog earns the anonymous-trial promise. A paid row is self-labelled below. // (An empty catalog earns no promise either — `every` is vacuously true over nothing.) const allFree = menu.scenarios.length > 0 && menu.scenarios.every((s) => s.free); lines.push(`kestrel sim — run a curated market scenario${allFree ? ", free (anonymous trial)" : ""}`); lines.push("usage: kestrel sim <scenario-slug> [--plans <file>]"); lines.push(""); lines.push("scenarios:"); const wSlug = Math.max(8, ...menu.scenarios.map((s) => s.slug.length)); const wTitle = Math.max(5, ...menu.scenarios.map((s) => s.title.length)); for (const s of menu.scenarios) { const price = s.free ? "free" : "paid"; lines.push(` ${s.slug.padEnd(wSlug)} ${s.title.padEnd(wTitle)} ${s.instrument.padEnd(5)} ${price} ${s.artifactId}`); } const aliasKeys = Object.keys(menu.aliases); if (aliasKeys.length > 0) { lines.push(""); lines.push("aliases:"); for (const k of aliasKeys) lines.push(` ${k}${menu.aliases[k]}`); } lines.push(""); const first = menu.scenarios[0]?.slug ?? "meme-stock-short-squeeze"; lines.push(`run one: kestrel sim ${first}`); process.stdout.write(lines.join("\n") + "\n"); } /* ─────────────────────── the graded-session report (human) ─────────────────────── */ /** * Render a graded {@link EpisodeReport} as a MULTI-LINE text report for `run`/`day` `--format human` * (kestrel-0hx). Text mode stays the one-line settle digest (the token-cheap agent default), and * `--json` stays the canonical machine object; this is the third face the site-is-spec principle asks * for — the FULL story (grade channels, plan lifecycle, orders/fills) as pure text, legible to a human * AND to an agent, without paying the JSON parse cost. * * Pure + deterministic: it reads only report fields (no wall clock, no color), so the same report * renders byte-identically every time — and every SECTION is sourced from a distinct report key, so a * dropped section changes the bytes (the teeth of `tests/cli/grade-report.test.ts`). ONE line per beat, * mirroring {@link simStoryLines}, so the sections read top-to-bottom as the session's arc. */ export function sessionReportLines(report: EpisodeReport): string[] { const s = report.session; const t = report.totals; const lines: string[] = []; lines.push(`kestrel session · ${s.date} · ${s.instruments.join(",")} · ${s.mode} · ${s.fill_model}`); // Grade channels — the three-channel P&L contract (RUNTIME §6): the headline + the floor/expected // (and sampled, when the channel ran) bounds beside it, and the taint that rides whichever channel // leads. NEVER just the headline number: an honest grade shows the bounds it was selected from. const h = t.headline; const chans = [`floor=${money(t.realized_floor_usd)}`, `expected=${money(t.expected_usd)}`]; if (t.sampled_usd !== undefined) chans.push(`sampled=${money(t.sampled_usd)}`); lines.push(`grade headline=${h.channel} ${money(h.usd)} · ${chans.join(" ")}`); if (h.tainted) lines.push(` tainted ${h.reasons.join("; ")}`); // The settle mark's provenance + fail-closed staleness verdict (kestrel-xwf) + premium spent. const m = t.settle_mark; const staleTag = m.stale ? " STALE" : ""; const uncertainTag = m.mark_uncertain ? " mark_uncertain" : ""; lines.push(`settle mark=${money(m.px)} source=${m.source}${staleTag}${uncertainTag} · premium_spent=${money(t.premium_spent)}`); // Plan lifecycle — one line per Plan INSTANCE (kestrel-22j.15), its full state arc → terminal state. lines.push(`plans ${report.plans.length}`); for (const p of report.plans) { const arc = p.lifecycle .map((step) => (step.outcome !== undefined ? `${step.state}(${step.outcome}${step.reason !== undefined ? `:${step.reason}` : ""})` : step.state)) .join(" → "); lines.push(` ${p.plan} ${arc === "" ? "—" : arc}${p.final_state}`); // The never-fired reason for THIS plan (kestrel-kglw / kestrel-y6vo): a 0-order plan is the // highest-value teaching moment, and the arc drops a reason that rides a non-terminal step (a // regime-DOA plan's `authored` reject). Surfaced HERE, per plan, so every never-fired class self- // explains on the default face — driven by the real `run` command (this render), so deleting this // call is what a wiring guard catches. const why = planNeverFiredReason(p, report.orders); if (why !== undefined) lines.push(` why ${why}`); } // Orders + fills — the position each order took and how it resolved (floor-filled / cancelled / // resting-unfilled), joined with its role, price-resolution annotation, and realized floor P&L. lines.push(`orders ${report.orders.length}`); for (const o of report.orders) { const leg = o.strike !== undefined && o.right !== undefined ? `${o.strike}${o.right}` : "SPOT"; const outcome = o.filled ? `filled@${o.fillPx} floorPnl=${money(o.floorPnl)}` : o.cancelled === true ? "cancelled" : `resting pFill=${o.pFill}`; lines.push(` ${o.side} ${o.qty} ${o.instrument} ${leg} @ ${o.px} ${o.role} ${outcome} (${o.annotation})`); } return lines; } /** Write the graded-session report to stdout as pure human text (the `--format human` face). */ export function renderEpisodeReport(report: EpisodeReport, _ctx: OutputCtx): void { process.stdout.write(sessionReportLines(report).join("\n") + "\n"); }