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
text/typescript
/**
* # 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");
}