kalshi-trading-bot-cli 2.1.3 → 2.1.5

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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Batch analyze — fan out across multiple tickers in a single Octagon call.
3
+ *
4
+ * The full per-ticker `analyze` does deep work (resolve market, run risk gate,
5
+ * Kelly-size, etc.) and is slow because it makes a fresh Octagon call per
6
+ * ticker. When the user just wants a model-edge readout across N tickers,
7
+ * `POST /kalshi/markets/edge` returns them all in one shot — orders of
8
+ * magnitude faster.
9
+ *
10
+ * This is a complement to `analyze <ticker>`, not a replacement. Use it when
11
+ * you need:
12
+ * - quick edge ladder across 5-100 tickers
13
+ * - JSON output for downstream scripting
14
+ * - refresh without paying per-ticker Octagon round-trips
15
+ */
16
+ import { wrapSuccess, wrapError } from './json.js';
17
+ import type { CLIResponse } from './json.js';
18
+ import { getMarketsEdge, type PerTickerEdgeRow } from '../scan/octagon-kalshi-api.js';
19
+ import { formatTable } from './scan-formatters.js';
20
+
21
+ function truncate(s: string, max: number): string {
22
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
23
+ }
24
+
25
+ function fmtVol(v: number | null | undefined): string {
26
+ if (v === null || v === undefined) return '-';
27
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
28
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
29
+ return v.toFixed(0);
30
+ }
31
+
32
+ export interface AnalyzeBatchResult {
33
+ run_id: string;
34
+ captured_at: string;
35
+ data: PerTickerEdgeRow[];
36
+ scored: number;
37
+ unscored: number;
38
+ }
39
+
40
+ export async function handleAnalyzeBatch(tickers: string[]): Promise<CLIResponse<AnalyzeBatchResult>> {
41
+ const cleaned = Array.from(new Set(
42
+ tickers.map((t) => t.trim().toUpperCase()).filter(Boolean),
43
+ ));
44
+ if (cleaned.length === 0) {
45
+ return wrapError('analyze', 'NO_TICKERS', 'analyze (batch): supply 1+ tickers');
46
+ }
47
+ if (cleaned.length > 100) {
48
+ return wrapError('analyze', 'TOO_MANY_TICKERS', 'analyze (batch): at most 100 tickers per call');
49
+ }
50
+ try {
51
+ const resp = await getMarketsEdge({ tickers: cleaned });
52
+ const scored = resp.data.filter((r) => r.status === 'scored').length;
53
+ return wrapSuccess('analyze', {
54
+ run_id: resp.run_id,
55
+ captured_at: resp.captured_at,
56
+ data: resp.data,
57
+ scored,
58
+ unscored: resp.data.length - scored,
59
+ });
60
+ } catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ return wrapError('analyze', 'OCTAGON_ERROR', message);
63
+ }
64
+ }
65
+
66
+ export function formatAnalyzeBatchHuman(data: AnalyzeBatchResult): string {
67
+ const lines: string[] = [];
68
+ lines.push(`Batch analyze — ${data.scored}/${data.data.length} scored in run ${data.run_id.slice(0, 8)} (${data.captured_at.slice(0, 16).replace('T', ' ')})`);
69
+ lines.push('');
70
+ if (data.data.length === 0) {
71
+ lines.push('No tickers returned.');
72
+ return lines.join('\n');
73
+ }
74
+ const rows: string[][] = data.data.map((r) => {
75
+ const status = r.status === 'scored' ? '✓' : '—';
76
+ const modelStr = r.model_probability == null ? '-' : `${(r.model_probability * 100).toFixed(1)}%`;
77
+ const marketStr = r.market_probability == null ? '-' : `${(r.market_probability * 100).toFixed(1)}%`;
78
+ const edgeStr = r.edge_pp == null ? '-' : `${r.edge_pp > 0 ? '+' : ''}${r.edge_pp.toFixed(1)}pp`;
79
+ const erStr = r.expected_return == null ? '-' : `${(r.expected_return * 100).toFixed(1)}%`;
80
+ return [
81
+ r.input_ticker,
82
+ status,
83
+ truncate(r.title ?? '', 32),
84
+ r.series_category ?? '-',
85
+ modelStr,
86
+ marketStr,
87
+ edgeStr,
88
+ erStr,
89
+ fmtVol(r.total_volume),
90
+ ];
91
+ });
92
+ lines.push(formatTable(
93
+ ['Ticker', '?', 'Title', 'Category', 'Model', 'Market', 'Edge', 'Exp Ret', 'Volume'],
94
+ rows,
95
+ ));
96
+ if (data.unscored > 0) {
97
+ lines.push('');
98
+ lines.push(`${data.unscored} ticker(s) not scored in this Octagon run — try \`kalshi analyze <ticker> --refresh\` for a one-off deep call.`);
99
+ }
100
+ return lines.join('\n');
101
+ }
@@ -25,7 +25,16 @@ export interface AnalyzeData {
25
25
  eventTicker: string;
26
26
  title: string;
27
27
  expirationTime: string | null;
28
- modelLastUpdated: string | null;
28
+ /** Local timestamp when we last pulled the report from Octagon. */
29
+ refreshedAt: string | null;
30
+ /** Upstream Octagon model-run timestamp (Octagon's `analysis_last_updated`). */
31
+ modelRunAt: string | null;
32
+ /**
33
+ * True when --refresh just ran but the upstream `analysis_last_updated`
34
+ * is unchanged from before the refresh. Tells the user we bumped the
35
+ * cache time but didn't get a newer underlying report from Octagon.
36
+ */
37
+ staleUpstream: boolean;
29
38
  modelProb: number;
30
39
  marketProb: number;
31
40
  edge: number;
@@ -40,6 +49,12 @@ export interface AnalyzeData {
40
49
  riskGate: RiskGateResult;
41
50
  liquidityGrade: string;
42
51
  fromCache: boolean;
52
+ /**
53
+ * True when Octagon has no model scoring for this market in the cached report.
54
+ * When true, model probability + edge fields should be rendered as "--",
55
+ * not as the 0.5 placeholder.
56
+ */
57
+ hasModel: boolean;
43
58
  reportAge: string | null;
44
59
  reportId: string;
45
60
  rawReport: string;
@@ -162,6 +177,15 @@ export async function handleAnalyze(
162
177
  const octagonClient = new OctagonClient(invoker, db, auditTrail);
163
178
  const edgeComputer = new EdgeComputer(db, auditTrail);
164
179
 
180
+ // Capture the upstream Octagon `analysis_last_updated` BEFORE the refresh
181
+ // so we can detect when --refresh re-fetches the same stale upstream
182
+ // report (cache fetch time bumped, but Octagon's underlying model run
183
+ // didn't move). This catches "stale upstream" cases where the user thinks
184
+ // they got fresh analysis but actually got the same body Octagon last
185
+ // generated weeks ago.
186
+ const preRefreshReport = refresh ? getLatestReport(db, resolvedTicker) : null;
187
+ const preRefreshAnalysis = preRefreshReport?.analysis_last_updated ?? null;
188
+
165
189
  // Use cache by default; only refresh when explicitly requested
166
190
  // Try prefetch first to avoid an individual Octagon API call
167
191
  let variant: 'cache' | 'refresh' = refresh ? 'refresh' : 'cache';
@@ -302,17 +326,42 @@ export async function handleAnalyze(
302
326
  risk_gate: gate.passed ? 'PASSED' : 'FAILED',
303
327
  });
304
328
 
305
- // Model last-updated timestamp
306
- const modelUpdatedAt = latestDbReport
329
+ // Two distinct timestamps:
330
+ // refreshedAt = our local fetched_at (when WE pulled this from Octagon).
331
+ // This is the "Refreshed" date — what bumps when --refresh runs.
332
+ // modelRunAt = Octagon's analysis_last_updated (when their model last
333
+ // scored this event). Independent of our cache.
334
+ const refreshedAt = latestDbReport
307
335
  ? new Date(latestDbReport.fetched_at * 1000).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
308
336
  : null;
337
+ const modelRunAt = latestDbReport?.analysis_last_updated
338
+ ? latestDbReport.analysis_last_updated.replace('T', ' ').slice(0, 16) + ' UTC'
339
+ : null;
340
+
341
+ // hasModel = Octagon returned a real probability for this market. A
342
+ // cache-miss report keeps modelProb at the 0.5 placeholder; we must NOT
343
+ // render that as if it were a real prediction.
344
+ const hasModel = !report.cacheMiss && Number.isFinite(snapshot.modelProb)
345
+ && !(snapshot.modelProb === 0.5 && report.drivers.length === 0 && report.catalysts.length === 0);
346
+
347
+ // staleUpstream = user asked for --refresh but Octagon's upstream model run
348
+ // timestamp didn't move. Cache fetch time bumped, but the underlying report
349
+ // body is the same one Octagon previously generated. The user wanted fresh
350
+ // analysis; they got an unchanged stale one.
351
+ const staleUpstream = refresh
352
+ && preRefreshAnalysis != null
353
+ && latestDbReport?.analysis_last_updated != null
354
+ && preRefreshAnalysis === latestDbReport.analysis_last_updated;
309
355
 
310
356
  return {
311
357
  ticker: resolvedTicker,
312
358
  eventTicker,
313
359
  title: market.title || market.subtitle || resolvedTicker,
314
360
  expirationTime: market.expiration_time || market.expected_expiration_time || market.close_time || null,
315
- modelLastUpdated: modelUpdatedAt,
361
+ refreshedAt,
362
+ modelRunAt,
363
+ staleUpstream,
364
+ hasModel,
316
365
  modelProb: snapshot.modelProb,
317
366
  marketProb,
318
367
  edge: snapshot.edge,
@@ -355,12 +404,23 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
355
404
  }
356
405
  lines.push('');
357
406
 
358
- // Edge & Probabilities
359
- lines.push(` Model Prob: ${(data.modelProb * 100).toFixed(1)}%`);
360
- lines.push(` Market Prob: ${(data.marketProb * 100).toFixed(1)}%`);
361
- lines.push(` Edge: ${data.edgePp} (${(data.edge * 100).toFixed(1)}%)`);
362
- lines.push(` Confidence: ${data.confidence}`);
363
- lines.push(` Mispricing: ${data.mispricingSignal}`);
407
+ // Edge & Probabilities.
408
+ // When Octagon has no model scoring for this market (hasModel=false), render
409
+ // the model/edge fields as "--" instead of the 0.5 placeholder — otherwise
410
+ // downstream consumers (bots, dashboards) think we have a 50/50 prediction.
411
+ if (data.hasModel) {
412
+ lines.push(` Model Prob: ${(data.modelProb * 100).toFixed(1)}%`);
413
+ lines.push(` Market Prob: ${(data.marketProb * 100).toFixed(1)}%`);
414
+ lines.push(` Edge: ${data.edgePp} (${(data.edge * 100).toFixed(1)}%)`);
415
+ lines.push(` Confidence: ${data.confidence}`);
416
+ lines.push(` Mispricing: ${data.mispricingSignal}`);
417
+ } else {
418
+ lines.push(` Model Prob: -- (no Octagon model coverage for this market)`);
419
+ lines.push(` Market Prob: ${(data.marketProb * 100).toFixed(1)}%`);
420
+ lines.push(` Edge: --`);
421
+ lines.push(` Confidence: --`);
422
+ lines.push(` Mispricing: --`);
423
+ }
364
424
  lines.push('');
365
425
 
366
426
  // Price Drivers
@@ -428,15 +488,37 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
428
488
  }
429
489
  }
430
490
 
431
- // Cache status & model timestamp
491
+ // Two distinct timestamps labeled with the wording users actually use:
492
+ //
493
+ // Cache refreshed at = when the bot last fetched/re-read the Octagon
494
+ // payload. This is what bumps on --refresh.
495
+ // Report body updated at = the upstream Octagon `analysis_last_updated`
496
+ // (matches the "Updated: …" date embedded in the
497
+ // report body text). Doesn't change unless
498
+ // Octagon re-runs their analysis upstream.
499
+ //
500
+ // If you're a bot/agent reading this output: use **Report body updated at**
501
+ // to decide whether the underlying analysis is fresh. The Cache refreshed
502
+ // at time only tells you when we last re-pulled the same body — it can be
503
+ // recent while the report itself is weeks old.
432
504
  lines.push('');
433
- if (data.modelLastUpdated) {
434
- lines.push(` Model Updated: ${data.modelLastUpdated}`);
505
+ if (data.refreshedAt) {
506
+ const ageSuffix = data.reportAge ? ` (${data.reportAge})` : '';
507
+ lines.push(` Cache refreshed at: ${data.refreshedAt}${ageSuffix}`);
508
+ lines.push(` ↳ when the bot last fetched the Octagon payload; bumps on --refresh`);
509
+ }
510
+ if (data.modelRunAt) {
511
+ lines.push(` Report body updated at: ${data.modelRunAt}`);
512
+ lines.push(` ↳ when Octagon last ran the model upstream (the "Updated:" date inside the report)`);
513
+ }
514
+ if (data.staleUpstream) {
515
+ lines.push('');
516
+ lines.push(` ⚠ --refresh pulled the same Octagon report body. The cache fetch time bumped,`);
517
+ lines.push(` but Octagon's upstream analysis hasn't been re-run since ${data.modelRunAt ?? 'an earlier date'}.`);
518
+ lines.push(` Treat this as a stale upstream report — no newer analysis is available.`);
435
519
  }
436
- if (data.fromCache && data.reportAge) {
437
- lines.push(` Data: cached (${data.reportAge}). Run \`analyze ${data.ticker} --refresh\` for latest (costs 3 credits).`);
438
- } else if (data.fromCache) {
439
- lines.push(` Data: cached. Run \`analyze ${data.ticker} --refresh\` for latest (costs 3 credits).`);
520
+ if (data.fromCache) {
521
+ lines.push(` Data: cached. Run \`analyze ${data.ticker} --refresh\` for the latest report (costs 3 credits).`);
440
522
  } else {
441
523
  lines.push(' Data: freshly generated.');
442
524
  }