@leroylabs/cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/format.mjs CHANGED
@@ -1,14 +1,24 @@
1
+ import { ansiForeground, LEROY_COLORS } from "./colors.mjs";
2
+
1
3
  const ANSI = {
2
4
  reset: "\u001b[0m",
3
5
  bold: "\u001b[1m",
4
6
  dim: "\u001b[2m",
5
- green: "\u001b[32m",
6
- orange: "\u001b[38;5;208m",
7
+ white: "\u001b[37m",
8
+ green: ansiForeground(LEROY_COLORS.green),
9
+ gray: "\u001b[90m",
10
+ neutral: ansiForeground(LEROY_COLORS.neutral),
11
+ inactive: ansiForeground(LEROY_COLORS.inactive),
12
+ red: ansiForeground(LEROY_COLORS.red),
7
13
  yellow: "\u001b[33m",
8
14
  };
9
15
 
10
16
  function color(value, tone, enabled) {
11
- return enabled ? `${ANSI[tone]}${value}${ANSI.reset}` : value;
17
+ return style(value, [tone], enabled);
18
+ }
19
+
20
+ function style(value, tones, enabled) {
21
+ return enabled ? `${tones.map((tone) => ANSI[tone]).join("")}${value}${ANSI.reset}` : value;
12
22
  }
13
23
 
14
24
  function record(value) {
@@ -23,7 +33,7 @@ function pct(value, digits = 2, enabled = true) {
23
33
  const number = finite(value);
24
34
  if (number === null) return "—";
25
35
  const text = `${number > 0 ? "+" : number < 0 ? "−" : ""}${Math.abs(number).toFixed(digits)}%`;
26
- return number >= 0 ? color(text, "green", enabled) : color(text, "orange", enabled);
36
+ return number >= 0 ? color(text, "green", enabled) : color(text, "red", enabled);
27
37
  }
28
38
 
29
39
  function dateLabel(value) {
@@ -62,17 +72,20 @@ function bestMemoryMatch(response) {
62
72
  function strategyCounts(response, requestedSide) {
63
73
  const counts = { buy: 0, neutral: 0, sell: 0, warming: 0 };
64
74
  const states = Array.isArray(response.strategy_states) ? response.strategy_states : [];
75
+ const observedStates = [];
65
76
  let observed = false;
66
77
  for (const entry of states) {
67
78
  const value = record(entry);
68
79
  const state = value.state;
69
80
  if (state === "warming" || state === "warming-up" || value.monitor_state === "warming-up") {
70
81
  counts.warming += 1;
82
+ observedStates.push("warming");
71
83
  observed = true;
72
84
  continue;
73
85
  }
74
86
  if (state in counts) {
75
87
  counts[state] += 1;
88
+ observedStates.push(state);
76
89
  observed = true;
77
90
  }
78
91
  }
@@ -80,36 +93,43 @@ function strategyCounts(response, requestedSide) {
80
93
  const activeCount = finite(record(response.active_confluence).active_signal_count);
81
94
  if (activeCount !== null && (requestedSide === "buy" || requestedSide === "sell")) {
82
95
  counts[requestedSide] = activeCount;
83
- return { counts, observed: true };
96
+ return { counts, observed: true, states: Array.from({ length: activeCount }, () => requestedSide) };
84
97
  }
85
98
  }
86
- return { counts, observed };
99
+ return { counts, observed, states: observedStates };
87
100
  }
88
101
 
89
102
  function memoryMatchCounts(response) {
90
- const entries = Array.isArray(record(response.market_memory).matches) ? record(response.market_memory).matches : [];
91
- const side = entries.find((entry) => record(entry).side === response.request?.side) ?? entries[0];
92
- const matches = Array.isArray(record(side).matches) ? record(side).matches : [];
93
- const countRows = (kinds) => matches
94
- .filter((match) => kinds.includes(record(match).match_kind))
95
- .reduce((total, match) => total + (finite(record(match).row_count) ?? 0), 0);
103
+ const selected = bestMemoryMatch(response);
104
+ const tier = matchTier(selected);
105
+ const rowCount = finite(record(selected).row_count) ?? 0;
96
106
  return {
97
- exact: countRows(["context"]),
98
- comparable: countRows(["cross_ticker_context", "strategy", "forward_overlay_context"]),
99
- baseline: countRows(["side"]),
107
+ exact: tier.score === 5 ? rowCount : 0,
108
+ comparable: tier.score > 0 && tier.score < 5 ? rowCount : 0,
109
+ tier,
100
110
  };
101
111
  }
102
112
 
103
- function historicalEvaluations(response) {
104
- const memory = record(response.market_memory);
105
- const coverage = record(memory.coverage);
106
- return finite(memory.strategy_evaluations)
107
- ?? finite(coverage.strategy_evaluations)
108
- ?? finite(coverage.historical_evaluations)
109
- ?? finite(memory.scans_completed)
110
- ?? finite(memory.scan_count)
111
- ?? finite(coverage.indexed_rows)
112
- ?? null;
113
+ function matchTier(match) {
114
+ const value = record(match);
115
+ const kind = value.match_kind;
116
+ const scope = typeof value.scope === "string" ? value.scope : null;
117
+ if (scope === "same_ticker_exact" || (kind === "context" && !scope)) {
118
+ return { score: 5, basis: "Exact ticker setup" };
119
+ }
120
+ if (scope === "cross_ticker_exact_state" || kind === "cross_ticker_context") {
121
+ return { score: 4, basis: "Same setup on other tickers" };
122
+ }
123
+ if (scope === "exact_41_sensor_state" || kind === "strategy") {
124
+ return { score: 3, basis: "Same 41-sensor state" };
125
+ }
126
+ if (scope?.startsWith("forward_overlay_") || kind === "forward_overlay_context") {
127
+ return { score: 2, basis: "Same side with strategy and market context" };
128
+ }
129
+ if (scope === "all_market_memory_observations" || kind === "side") {
130
+ return { score: 1, basis: "Same side baseline" };
131
+ }
132
+ return { score: 0, basis: null };
113
133
  }
114
134
 
115
135
  function memoryStatus(response) {
@@ -120,20 +140,9 @@ function memoryStatus(response) {
120
140
  };
121
141
  }
122
142
 
123
- function evidenceScope(response) {
124
- const match = record(bestMemoryMatch(response));
125
- const scope = match.scope;
126
- if (scope === "same_ticker_exact") return "exact ticker setup";
127
- if (scope === "cross_ticker_exact_state") return "cross-ticker exact state";
128
- if (scope === "exact_41_sensor_state") return "41-sensor state";
129
- if (scope?.startsWith("forward_overlay_")) return "forward overlay context";
130
- if (scope === "all_market_memory_observations") return "directional baseline";
131
- return null;
132
- }
133
-
134
143
  function unavailableReason(response) {
135
144
  const { status, reason } = memoryStatus(response);
136
- if (reason) return `${status ?? "Market Memory"} · ${reason}`;
145
+ if (reason) return `${humanizeStatus(status)} · ${reason}`;
137
146
  if (status === "artifact_unavailable") return "reader unavailable";
138
147
  if (status === "insufficient_context") return "not available in current evidence";
139
148
  return "not available in current evidence";
@@ -157,17 +166,18 @@ function lookupMilliseconds(response) {
157
166
 
158
167
  function humanizeStatus(value) {
159
168
  const labels = {
160
- historical_match: "Historical match",
161
- insufficient_context: "Limited match",
162
- artifact_unavailable: "Reader unavailable",
169
+ historical_match: "Historical Match",
170
+ insufficient_context: "Limited Match",
171
+ artifact_unavailable: "Reader Unavailable",
163
172
  };
164
173
  if (typeof value !== "string" || !value) return "Unavailable";
165
174
  return labels[value] ?? value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
166
175
  }
167
176
 
168
- function memorySummary(status, scope, exactMatches) {
169
- if (status === "historical_match" && (scope === "exact ticker setup" || exactMatches > 0)) return "Exact matches found.";
170
- return [humanizeStatus(status), scope].filter(Boolean).join(" · ");
177
+ function memorySummary(status, tier) {
178
+ if (status === "historical_match" && tier.score === 5) return "Match Found";
179
+ if (tier.score > 0) return "Limited Match";
180
+ return humanizeStatus(status);
171
181
  }
172
182
 
173
183
  function sideLabel(value) {
@@ -175,17 +185,38 @@ function sideLabel(value) {
175
185
  return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
176
186
  }
177
187
 
178
- function sectionRule(title, enabled, width = 58) {
188
+ const BOX_WIDTH = 65;
189
+
190
+ function sideTone(value) {
191
+ if (value === "buy") return "green";
192
+ if (value === "sell") return "red";
193
+ if (value === "both") return "yellow";
194
+ return "dim";
195
+ }
196
+
197
+ function sectionRule(title, enabled, width = BOX_WIDTH) {
179
198
  const dashCount = Math.max(4, width - title.length - 5);
180
199
  return color(`┌─ ${title} ${"─".repeat(dashCount)}┐`, "dim", enabled);
181
200
  }
182
201
 
183
- function sectionBottom(enabled, width = 58) {
202
+ function tickerRule(side, symbol, last, enabled) {
203
+ const sideText = sideLabel(side).toUpperCase();
204
+ const title = `${sideText} · ${symbol} @ ${last}`;
205
+ const dashCount = Math.max(4, BOX_WIDTH - title.length - 5);
206
+ return [
207
+ color("┌─ ", "dim", enabled),
208
+ style(sideText, ["bold", sideTone(side)], enabled),
209
+ style(` · ${symbol} @ ${last}`, ["bold", "white"], enabled),
210
+ color(` ${"─".repeat(dashCount)}┐`, "dim", enabled),
211
+ ].join("");
212
+ }
213
+
214
+ function sectionBottom(enabled, width = BOX_WIDTH) {
184
215
  return color(`└${"─".repeat(width - 2)}┘`, "dim", enabled);
185
216
  }
186
217
 
187
- function metricLine(label, value, tone, enabled) {
188
- return ` ${label.padEnd(26)}${color(value, tone, enabled)}`;
218
+ function metricLine(label, value, tone, enabled, labelWidth = 26) {
219
+ return ` ${label.padEnd(labelWidth)}${color(value, tone, enabled)}`;
189
220
  }
190
221
 
191
222
  function normalizedRate(value) {
@@ -201,7 +232,7 @@ function rateLabel(value) {
201
232
 
202
233
  function rateTone(value) {
203
234
  const rate = normalizedRate(value);
204
- return rate === null ? "dim" : rate >= 0.5 ? "green" : "orange";
235
+ return rate === null ? "dim" : rate >= 0.5 ? "green" : "red";
205
236
  }
206
237
 
207
238
  function bps(value) {
@@ -212,7 +243,7 @@ function bps(value) {
212
243
 
213
244
  function outcomeTone(value) {
214
245
  const number = finite(value);
215
- return number === null || number === 0 ? "dim" : number > 0 ? "green" : "orange";
246
+ return number === null || number === 0 ? "dim" : number > 0 ? "green" : "red";
216
247
  }
217
248
 
218
249
  function coverageWindow(response, match) {
@@ -275,28 +306,292 @@ function ratioLabel(value) {
275
306
  return number === null ? null : `${number.toFixed(2)}x`;
276
307
  }
277
308
 
309
+ function horizonLabel(horizon) {
310
+ return `${horizon} Minutes`;
311
+ }
312
+
278
313
  function setupLine(label, value, tone, enabled) {
279
- const innerWidth = 54;
314
+ const innerWidth = BOX_WIDTH - 4;
280
315
  const labelWidth = 21;
281
316
  const valueText = String(value ?? "—").slice(0, innerWidth - labelWidth);
282
317
  return ` ${label.padEnd(labelWidth)}${color(valueText, tone, enabled)}`;
283
318
  }
284
319
 
285
- function outcomeRow(label, values, enabled, align = "right") {
320
+ function outcomeRow(label, values, enabled) {
286
321
  const labelWidth = 19;
287
- const cellWidth = 13;
322
+ const cellWidth = 14;
288
323
  const cells = values.map(({ value, tone }) => {
289
324
  const text = String(value);
290
- const contentWidth = cellWidth - 1;
291
- const padding = Math.max(0, contentWidth - text.length);
292
- const left = align === "center" ? Math.floor(padding / 2) : align === "left" ? 0 : padding;
293
- const right = padding - left + 1;
294
- return `│${" ".repeat(left)}${color(text, tone, enabled)}${" ".repeat(right)}`;
325
+ const padding = Math.max(0, cellWidth - text.length);
326
+ return `${" ".repeat(padding)}${color(text, tone, enabled)}`;
295
327
  }).join("");
296
- return ` ${label.padEnd(labelWidth)}${cells}│`;
328
+ return ` ${label.padEnd(labelWidth)}${cells} `;
329
+ }
330
+
331
+ function compactMetricLine(label, value, tone, enabled) {
332
+ return ` ${label}: ${color(String(value ?? "—"), tone, enabled)}`;
333
+ }
334
+
335
+ function compactOutcomeLine(row, enabled) {
336
+ const returnValue = row.mean === null ? "—" : pct(row.mean, 2, false);
337
+ const winValue = rateLabel(row.winRate);
338
+ const liftValue = bps(row.liftBps).replace(/ bps$/, "");
339
+ return ` ${horizonLabel(row.horizon)} ${color(`${returnValue} return · ${winValue} win · ${liftValue} bps`, outcomeTone(row.mean), enabled)}`;
340
+ }
341
+
342
+ const STRATEGY_MIX_SLOTS = 20;
343
+
344
+ function strategyMixWidths(counts, total, slots = STRATEGY_MIX_SLOTS) {
345
+ if (total <= 0) return [0, 0, 0, 0];
346
+ const segments = [
347
+ counts.buy,
348
+ counts.neutral,
349
+ counts.sell,
350
+ counts.warming,
351
+ ];
352
+ const widths = segments.map((count) => count > 0 ? Math.max(1, Math.round((count / total) * slots)) : 0);
353
+ let occupied = widths.reduce((sum, value) => sum + value, 0);
354
+ while (occupied > slots) {
355
+ const index = widths.reduce((best, value, position) => {
356
+ if (value <= 1) return best;
357
+ return best === -1 || value > widths[best] || (value === widths[best] && segments[position] > segments[best]) ? position : best;
358
+ }, -1);
359
+ if (index < 0) break;
360
+ widths[index] -= 1;
361
+ occupied -= 1;
362
+ }
363
+ while (occupied < slots) {
364
+ const index = segments.reduce((best, count, position) => count > segments[best] ? position : best, 0);
365
+ widths[index] += 1;
366
+ occupied += 1;
367
+ }
368
+ return widths;
369
+ }
370
+
371
+ function strategyMixLines(counts, total, enabled) {
372
+ if (total <= 0) return [color("—", "dim", enabled)];
373
+ const widths = strategyMixWidths(counts, total);
374
+ const slots = [
375
+ { count: counts.buy, label: `Buy ${countLabel(counts.buy)}`, tone: "green" },
376
+ { count: counts.neutral, label: `Neutral ${countLabel(counts.neutral)}`, tone: "neutral" },
377
+ { count: counts.sell, label: `Sell ${countLabel(counts.sell)}`, tone: "red" },
378
+ { count: counts.warming, label: `Warming ${countLabel(counts.warming)}`, tone: "yellow" },
379
+ ];
380
+ const cells = slots.flatMap((segment, index) => Array.from({ length: widths[index] }, () => color("■", segment.tone, enabled)));
381
+ const labelWidth = slots.length === 4 && counts.warming > 0 ? 52 : 39;
382
+ const columnWidth = labelWidth / (counts.warming > 0 ? 4 : 3);
383
+ const visibleSlots = counts.warming > 0 ? slots : slots.slice(0, 3);
384
+ const buyLabel = visibleSlots[0].label.padEnd(columnWidth);
385
+ const neutralLabel = visibleSlots[1].label.padStart(Math.floor((columnWidth + visibleSlots[1].label.length) / 2)).padEnd(columnWidth);
386
+ const sellLabel = visibleSlots[2].label.padStart(columnWidth);
387
+ const labels = [buyLabel, neutralLabel, sellLabel];
388
+ if (counts.warming > 0) labels.push(visibleSlots[3].label.padStart(columnWidth));
389
+ return [cells.join(" "), labels.map((label, index) => color(label, visibleSlots[index].tone, enabled)).join("")];
390
+ }
391
+
392
+ const GRID_LEFT_WIDTH = 32;
393
+ const GRID_TOTAL_WIDTH = 58;
394
+
395
+ function gridColumns(left, right, colorEnabled) {
396
+ const leftText = left?.text ?? "";
397
+ const rightText = right?.text ?? "";
398
+ const leftCell = leftText
399
+ ? color(leftText.padEnd(GRID_LEFT_WIDTH), left.tone ?? "white", colorEnabled)
400
+ : " ".repeat(GRID_LEFT_WIDTH);
401
+ const rightCell = rightText
402
+ ? right.tone
403
+ ? color(rightText, right.tone, colorEnabled)
404
+ : rightText
405
+ : "";
406
+ return `${leftCell}${rightCell}`;
407
+ }
408
+
409
+ function gridFrameParts(value) {
410
+ if (typeof value !== "string") return { date: "—", time: "" };
411
+ const date = new Date(value);
412
+ if (Number.isNaN(date.valueOf())) return { date: value, time: "" };
413
+ return {
414
+ date: new Intl.DateTimeFormat("en-US", {
415
+ month: "short",
416
+ day: "numeric",
417
+ year: "numeric",
418
+ timeZone: "America/New_York",
419
+ }).format(date),
420
+ time: `${new Intl.DateTimeFormat("en-US", {
421
+ hour: "numeric",
422
+ minute: "2-digit",
423
+ timeZone: "America/New_York",
424
+ }).format(date)} ET`,
425
+ };
426
+ }
427
+
428
+ function gridMatchColumns(match, colorEnabled) {
429
+ const tier = matchTier(match);
430
+ const label = tier.score === 5 ? "Exact" : tier.score > 0 ? "Limited" : "No match";
431
+ const rowCount = countLabel(record(match).row_count);
432
+ const countLabelText = tier.score === 5 ? "exact observations" : "comparable observations";
433
+ const cells = Array.from({ length: 5 }, (_, index) => color(
434
+ index < tier.score ? "■" : "□",
435
+ index < tier.score ? "green" : "neutral",
436
+ colorEnabled,
437
+ )).join("");
438
+ return {
439
+ label: `${cells} ${label}`,
440
+ comparable: `${rowCount} ${countLabelText}`,
441
+ };
442
+ }
443
+
444
+ function gridHeading(label, value, enabled) {
445
+ const gap = Math.max(1, GRID_TOTAL_WIDTH - label.length - value.length);
446
+ return color(`${label}${" ".repeat(gap)}${value}`, "white", enabled);
447
+ }
448
+
449
+ function gridOutcomeLines(outcomeRows, colorEnabled) {
450
+ const cellWidth = 12;
451
+ const row = (label, values, tones) => [
452
+ ` ${label.padEnd(16)}`,
453
+ ...values.map((value, index) => color(String(value).padStart(cellWidth), tones[index], colorEnabled)),
454
+ ].join(" ");
455
+ return [
456
+ color("OUTCOME LADDER", "white", colorEnabled),
457
+ row("Timeframe", outcomeRows.map((outcome) => horizonLabel(outcome.horizon)), ["white", "white", "white"]),
458
+ row("Avg. Return", outcomeRows.map((outcome) => outcome.mean === null ? "—" : pct(outcome.mean, 2, false)), outcomeRows.map((outcome) => outcomeTone(outcome.mean))),
459
+ row("Win Rate", outcomeRows.map((outcome) => rateLabel(outcome.winRate)), outcomeRows.map((outcome) => rateTone(outcome.winRate))),
460
+ row("Lift (BPS)", outcomeRows.map((outcome) => outcome.liftBps === null ? "—" : bps(outcome.liftBps).replace(/ bps$/, "")), outcomeRows.map((outcome) => outcomeTone(outcome.liftBps))),
461
+ ];
462
+ }
463
+
464
+ export function buildEvaluationViewModel(response) {
465
+ const request = record(response.request);
466
+ const frame = record(response.current_market_frame);
467
+ const symbol = typeof request.symbol === "string" ? request.symbol : typeof frame.symbol === "string" ? frame.symbol : "—";
468
+ const requestedSide = typeof request.side === "string" ? request.side : null;
469
+ const strategy = strategyCounts(response, requestedSide);
470
+ const strategyTotal = Object.values(strategy.counts).reduce((total, value) => total + value, 0);
471
+ const match = record(bestMemoryMatch(response));
472
+ const tier = matchTier(match);
473
+ const frameParts = gridFrameParts(frame.source_time);
474
+ const outcomes = [15, 30, 60].map((horizon) => {
475
+ const outcome = record(outcomeFor(response, horizon));
476
+ return {
477
+ horizon,
478
+ mean: finite(outcome.mean_return_pct ?? outcome.average_return_pct),
479
+ winRate: finite(outcome.win_rate),
480
+ liftBps: finite(outcome.lift_vs_baseline_bps),
481
+ };
482
+ });
483
+ const frameContext = record(frame.market_context);
484
+ const benchmark = record(frame.benchmark);
485
+ const benchmarkContext = record(benchmark.market_context);
486
+ const setupContext = activeContext(response, requestedSide);
487
+ const marketSources = [frameContext, setupContext, frame];
488
+ const benchmarkSources = [benchmarkContext, benchmark];
489
+ const marketSetup = {
490
+ sessionReturnPct: finite(firstValue(marketSources, ["session_return_pct", "sessionReturnPct"])),
491
+ volumeRatio: finite(firstValue(marketSources, ["volume_ratio", "volumeRatio"])),
492
+ vwapDistancePct: finite(firstValue(marketSources, ["vwap_distance_pct", "vwapDistancePct"])),
493
+ atrPct: finite(firstValue(marketSources, ["atr_pct", "atrPct"])),
494
+ };
495
+ const marketOverview = {
496
+ symbol: typeof benchmark.symbol === "string" ? benchmark.symbol : "SPY",
497
+ sessionReturnPct: finite(firstValue(benchmarkSources, ["session_return_pct", "sessionReturnPct"])),
498
+ volumeRatio: finite(firstValue(benchmarkSources, ["volume_ratio", "volumeRatio"])),
499
+ vwapDistancePct: finite(firstValue(benchmarkSources, ["vwap_distance_pct", "vwapDistancePct"])),
500
+ atrPct: finite(firstValue(benchmarkSources, ["atr_pct", "atrPct"])),
501
+ };
502
+ const memory = memoryStatus(response);
503
+ return {
504
+ side: requestedSide,
505
+ symbol,
506
+ last: frame.quote?.last == null ? "—" : `$${Number(frame.quote.last).toFixed(2)}`,
507
+ frameDate: frameParts.date,
508
+ frameTime: frameParts.time,
509
+ frameSourceTime: typeof frame.source_time === "string" ? frame.source_time : null,
510
+ marketSessionStatus: frame.market_session_status === "open" || frame.market_session_status === "closed"
511
+ ? frame.market_session_status
512
+ : null,
513
+ status: memory.status,
514
+ statusReason: memory.reason,
515
+ match: {
516
+ score: tier.score,
517
+ label: tier.score === 5 ? "Exact" : tier.score > 0 ? "Limited" : "No match",
518
+ rowCount: countLabel(record(match).row_count),
519
+ basis: tier.basis,
520
+ },
521
+ strategy: {
522
+ counts: strategy.counts,
523
+ total: strategyTotal,
524
+ widths: strategyMixWidths(strategy.counts, strategyTotal),
525
+ states: strategy.states,
526
+ },
527
+ outcomes,
528
+ marketSetup,
529
+ marketOverview,
530
+ archiveWindow: coverageWindow(response, match),
531
+ };
532
+ }
533
+
534
+ function formatGridEvaluation(response, { colorEnabled = true } = {}) {
535
+ const request = record(response.request);
536
+ const frame = record(response.current_market_frame);
537
+ const symbol = typeof request.symbol === "string" ? request.symbol : typeof frame.symbol === "string" ? frame.symbol : "—";
538
+ const requestedSide = typeof request.side === "string" ? request.side : null;
539
+ const counts = strategyCounts(response, requestedSide).counts;
540
+ const strategyTotal = Object.values(counts).reduce((total, value) => total + value, 0);
541
+ const match = record(bestMemoryMatch(response));
542
+ const last = frame.quote?.last == null ? "—" : `$${Number(frame.quote.last).toFixed(2)}`;
543
+ const outcomes = [15, 30, 60].map((horizon) => {
544
+ const outcome = record(outcomeFor(response, horizon));
545
+ return {
546
+ horizon,
547
+ mean: finite(outcome.mean_return_pct ?? outcome.average_return_pct),
548
+ winRate: finite(outcome.win_rate),
549
+ liftBps: finite(outcome.lift_vs_baseline_bps),
550
+ };
551
+ });
552
+ const frameParts = gridFrameParts(frame.source_time);
553
+ const sideText = sideLabel(requestedSide).toUpperCase();
554
+ const matchColumns = gridMatchColumns(match, colorEnabled);
555
+ const topLines = [
556
+ gridColumns(
557
+ { text: `${sideText} EVIDENCE`, tone: sideTone(requestedSide) },
558
+ { text: "MATCH STRENGTH", tone: "white" },
559
+ colorEnabled,
560
+ ),
561
+ gridColumns(
562
+ { text: `${symbol} @ ${last}`, tone: "bold" },
563
+ { text: matchColumns.label, tone: "white" },
564
+ colorEnabled,
565
+ ),
566
+ gridColumns(
567
+ { text: `${frameParts.date} ${frameParts.time}`.trim(), tone: "gray" },
568
+ { text: matchColumns.comparable, tone: "gray" },
569
+ colorEnabled,
570
+ ),
571
+ ];
572
+ const strategyLines = [
573
+ gridHeading("STRATEGY MIX", `${countLabel(strategyTotal)} EVALUATED`, colorEnabled),
574
+ ...strategyMixLines(counts, strategyTotal, colorEnabled),
575
+ ];
576
+ return [
577
+ ...topLines,
578
+ "",
579
+ ...strategyLines,
580
+ "",
581
+ ...gridOutcomeLines(outcomes, colorEnabled),
582
+ color(` Archive Window ${coverageWindow(response, match)}`, "gray", colorEnabled),
583
+ "",
584
+ color("Leroy returns evidence; it does not make or submit trades.", "dim", colorEnabled),
585
+ ].join("\n");
297
586
  }
298
587
 
299
- export function formatEvaluation(response, { colorEnabled = true } = {}) {
588
+ export function formatEvaluation(response, { colorEnabled = true, setup = false, verbose = false, compact = false, layout = "ledger" } = {}) {
589
+ if (layout === "grid") {
590
+ const { status } = memoryStatus(response);
591
+ return status === "artifact_unavailable"
592
+ ? color("Market Memory is temporarily unavailable. Try again shortly.", "dim", colorEnabled)
593
+ : formatGridEvaluation(response, { colorEnabled });
594
+ }
300
595
  const request = record(response.request);
301
596
  const frame = record(response.current_market_frame);
302
597
  const symbol = typeof request.symbol === "string" ? request.symbol : typeof frame.symbol === "string" ? frame.symbol : "—";
@@ -305,13 +600,14 @@ export function formatEvaluation(response, { colorEnabled = true } = {}) {
305
600
  const strategyTotal = Object.values(counts.counts).reduce((total, value) => total + value, 0);
306
601
  const match = record(bestMemoryMatch(response));
307
602
  const matchCounts = memoryMatchCounts(response);
308
- const evaluations = historicalEvaluations(response);
309
603
  const { status } = memoryStatus(response);
310
- const scope = evidenceScope(response);
604
+ if (status === "artifact_unavailable") {
605
+ return color("Market Memory is temporarily unavailable. Try again shortly.", "dim", colorEnabled);
606
+ }
311
607
  const resultHorizons = [15, 30, 60];
312
608
  const last = frame.quote?.last == null ? "—" : `$${Number(frame.quote.last).toFixed(2)}`;
313
609
  const timestamp = frameLabel(frame.source_time);
314
- const statusTone = status === "historical_match" ? "green" : status === "artifact_unavailable" ? "orange" : "dim";
610
+ const statusTone = status === "historical_match" ? "green" : status === "artifact_unavailable" ? "red" : "dim";
315
611
  const outcomeRows = resultHorizons.map((resultHorizon) => {
316
612
  const outcome = record(outcomeFor(response, resultHorizon));
317
613
  const available = finite(outcome.sample_size) !== null && finite(outcome.sample_size) > 0;
@@ -326,6 +622,7 @@ export function formatEvaluation(response, { colorEnabled = true } = {}) {
326
622
  const marketContext = record(frame.market_context);
327
623
  const setupContext = activeContext(response, requestedSide);
328
624
  const marketSources = [marketContext, setupContext, frame];
625
+ const lookupMs = lookupMilliseconds(response);
329
626
  const marketRows = [
330
627
  {
331
628
  label: "Benchmark",
@@ -365,41 +662,76 @@ export function formatEvaluation(response, { colorEnabled = true } = {}) {
365
662
  },
366
663
  ].filter((row) => row.raw !== null && row.display !== null);
367
664
  const tickerSetup = [
368
- sectionRule(`${symbol} Setup`, colorEnabled),
369
- setupLine("Price", last, "bold", colorEnabled),
665
+ tickerRule(requestedSide, symbol, last, colorEnabled),
370
666
  setupLine("Frame", timestamp, "dim", colorEnabled),
371
- setupLine("Side", requestedSide ? sideLabel(requestedSide) : "—", "bold", colorEnabled),
372
- setupLine("Buy Signals", countLabel(counts.counts.buy), counts.counts.buy ? "green" : "dim", colorEnabled),
373
- setupLine("Neutral Signals", countLabel(counts.counts.neutral), "dim", colorEnabled),
374
- setupLine("Sell Signals", countLabel(counts.counts.sell), counts.counts.sell ? "orange" : "dim", colorEnabled),
375
- ...(counts.counts.warming ? [setupLine("Warming Signals", countLabel(counts.counts.warming), "yellow", colorEnabled)] : []),
376
- setupLine("Strategies Evaluated", countLabel(strategyTotal), "bold", colorEnabled),
667
+ setupLine("Lookup Time", lookupMs === null ? "—" : `${Math.round(lookupMs)}ms`, "dim", colorEnabled),
668
+ sectionBottom(colorEnabled),
669
+ ];
670
+ const strategyScorecard = [
671
+ sectionRule(`Strategy Mix · ${countLabel(strategyTotal)} Evaluated`, colorEnabled),
672
+ ...strategyMixLines(counts.counts, strategyTotal, colorEnabled),
377
673
  sectionBottom(colorEnabled),
378
674
  ];
379
675
  const outcomeGrid = [
380
- sectionRule("Outcome Field", colorEnabled, 64),
381
- outcomeRow("Metric", outcomeRows.map((row) => ({ value: `${row.horizon}m`, tone: "dim" })), colorEnabled, "center"),
676
+ sectionRule("Outcome", colorEnabled),
677
+ outcomeRow("Timeframe", outcomeRows.map((row) => ({ value: horizonLabel(row.horizon), tone: "white" })), colorEnabled),
382
678
  outcomeRow("Avg. Return", outcomeRows.map((row) => ({ value: row.mean === null ? "—" : pct(row.mean, 2, false), tone: outcomeTone(row.mean) })), colorEnabled),
383
679
  outcomeRow("Win Rate", outcomeRows.map((row) => ({ value: rateLabel(row.winRate), tone: rateTone(row.winRate) })), colorEnabled),
384
- outcomeRow("Lift vs. Baseline", outcomeRows.map((row) => ({ value: bps(row.liftBps), tone: outcomeTone(row.liftBps) })), colorEnabled),
385
- sectionBottom(colorEnabled, 64),
680
+ outcomeRow("Lift (bps)", outcomeRows.map((row) => ({ value: bps(row.liftBps).replace(/ bps$/, ""), tone: outcomeTone(row.liftBps) })), colorEnabled),
681
+ sectionBottom(colorEnabled),
386
682
  ];
387
683
  const unavailableLines = outcomeRows
388
684
  .filter((row) => !row.available)
389
- .map((row) => ` ${row.horizon}m unavailable · ${unavailableReason(response)}`);
390
- const lookupMs = lookupMilliseconds(response);
391
- const evaluationLabel = `${countLabel(evaluations)}${lookupMs === null ? "" : ` · ${Math.round(lookupMs)}ms`}`;
685
+ .map((row) => ` ${horizonLabel(row.horizon)} unavailable · ${unavailableReason(response)}`);
686
+
687
+ if (compact) {
688
+ const sideText = sideLabel(requestedSide).toUpperCase();
689
+ const compactLines = [
690
+ [
691
+ color(sideText, ["bold", sideTone(requestedSide)], colorEnabled),
692
+ style(` · ${symbol} @ ${last}`, ["bold", "white"], colorEnabled),
693
+ ].join(""),
694
+ compactMetricLine("Frame", timestamp, "dim", colorEnabled),
695
+ compactMetricLine("Lookup Time", lookupMs === null ? "—" : `${Math.round(lookupMs)}ms`, "dim", colorEnabled),
696
+ ];
697
+
698
+ if (verbose) {
699
+ compactLines.push("", `Strategy Mix · ${countLabel(strategyTotal)} Evaluated`, ...strategyMixLines(counts.counts, strategyTotal, colorEnabled));
700
+ }
701
+ if (setup && marketRows.length) {
702
+ compactLines.push("", "Market Setup", ...marketRows.map((row) => compactMetricLine(row.label, row.display, row.tone, colorEnabled)));
703
+ }
704
+ compactLines.push(
705
+ "",
706
+ "Market Memory",
707
+ compactMetricLine("Status", memorySummary(status, matchCounts.tier), statusTone, colorEnabled),
708
+ compactMetricLine("Match Strength", `${matchCounts.tier.score}/5`, matchCounts.tier.score === 5 ? "green" : matchCounts.tier.score > 0 ? "yellow" : "dim", colorEnabled),
709
+ ...(matchCounts.tier.score === 5 ? [compactMetricLine("Exact Matches", countLabel(matchCounts.exact), "bold", colorEnabled)] : []),
710
+ ...(matchCounts.tier.score > 0 && matchCounts.tier.score < 5 ? [compactMetricLine("Comparable Matches", countLabel(matchCounts.comparable), "bold", colorEnabled)] : []),
711
+ ...(verbose && matchCounts.tier.basis ? [compactMetricLine("Match Basis", matchCounts.tier.basis, "dim", colorEnabled)] : []),
712
+ compactMetricLine("Window", coverageWindow(response, match), "dim", colorEnabled),
713
+ "",
714
+ "Outcome",
715
+ ...outcomeRows.map((row) => compactOutcomeLine(row, colorEnabled)),
716
+ ...unavailableLines,
717
+ "",
718
+ color("Leroy returns evidence; it does not make or submit trades.", "dim", colorEnabled),
719
+ );
720
+ return compactLines.join("\n");
721
+ }
722
+
392
723
  const lines = [
393
724
  ...tickerSetup,
394
- ...(marketRows.length ? ["", sectionRule("Market Setup", colorEnabled), ...marketRows.map((row) => setupLine(row.label, row.display, row.tone, colorEnabled)), sectionBottom(colorEnabled)] : []),
725
+ ...(verbose ? ["", ...strategyScorecard] : []),
726
+ ...(setup && marketRows.length ? ["", sectionRule("Market Setup", colorEnabled), ...marketRows.map((row) => setupLine(row.label, row.display, row.tone, colorEnabled)), sectionBottom(colorEnabled)] : []),
395
727
  "",
396
728
  sectionRule("Market Memory", colorEnabled),
397
- metricLine("Status", memorySummary(status, scope, matchCounts.exact), statusTone, colorEnabled),
398
- metricLine("Exact Matches", countLabel(matchCounts.exact), "bold", colorEnabled),
399
- metricLine("Comparable Matches", countLabel(matchCounts.comparable), "bold", colorEnabled),
400
- metricLine("Baseline Observations", countLabel(matchCounts.baseline), "bold", colorEnabled),
401
- metricLine("Historical Evaluations", evaluationLabel, "bold", colorEnabled),
402
- metricLine("Window", coverageWindow(response, match), "dim", colorEnabled),
729
+ metricLine("Status", memorySummary(status, matchCounts.tier), statusTone, colorEnabled, 24),
730
+ metricLine("Match Strength", `${matchCounts.tier.score}/5`, matchCounts.tier.score === 5 ? "green" : matchCounts.tier.score > 0 ? "yellow" : "dim", colorEnabled, 24),
731
+ ...(matchCounts.tier.score === 5 ? [metricLine("Exact Matches", countLabel(matchCounts.exact), "bold", colorEnabled, 24)] : []),
732
+ ...(matchCounts.tier.score > 0 && matchCounts.tier.score < 5 ? [metricLine("Comparable Matches", countLabel(matchCounts.comparable), "bold", colorEnabled, 24)] : []),
733
+ ...(verbose && matchCounts.tier.basis ? [metricLine("Match Basis", matchCounts.tier.basis, "dim", colorEnabled, 24)] : []),
734
+ metricLine("Window", coverageWindow(response, match), "dim", colorEnabled, 24),
403
735
  sectionBottom(colorEnabled),
404
736
  "",
405
737
  ...outcomeGrid,
@@ -415,11 +747,18 @@ export function formatHelp() {
415
747
  "",
416
748
  "Usage:",
417
749
  " leroy connect [--api-key lr_live_...]",
418
- " leroy ask buy|sell|both SYMBOL [--horizon 5|15|30|60]",
750
+ " leroy evaluate SYMBOL [--side buy|sell|both] [--setup] [--verbose] [--demo] [--horizon 5|15|30|60]",
751
+ " leroy watch SYMBOL [--side buy|sell|both] [--interval SECONDS] [--demo] [--horizon 5|15|30|60]",
752
+ " Buy is the default side; use --side sell or --side both to override it.",
419
753
  "",
420
754
  "Examples:",
421
- " leroy ask buy ABNB",
422
- " leroy ask sell AAPL --horizon 30",
423
- " leroy ask buy ABNB --json",
755
+ " leroy evaluate ABNB",
756
+ " leroy evaluate ABNB --setup --verbose",
757
+ " leroy evaluate AAPL --side sell --horizon 30",
758
+ " leroy evaluate ABNB --json",
759
+ " leroy evaluate ABNB --demo",
760
+ " leroy watch ABNB",
761
+ "",
762
+ "Read the full CLI reference: https://getleroy.com/cli",
424
763
  ].join("\n");
425
764
  }