kalshi-trading-bot-cli 2.1.4 → 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.
package/README.md CHANGED
@@ -28,6 +28,8 @@ That's it — no clone, no install. The setup wizard runs automatically on first
28
28
 
29
29
  Prefer a global install? `bun add -g kalshi-trading-bot-cli` then run `kalshi`.
30
30
 
31
+ > **Scripting and agent use** — for parallel invocations, `--json` consumers, or anything that pipes our output: **install globally and use the `kalshi` binary**, not `bunx`. See [Scripting & Parallel Use](#scripting--parallel-use) below for the gory details.
32
+
31
33
  Or work from a clone:
32
34
 
33
35
  ```bash
@@ -363,6 +365,37 @@ UNRESOLVED (105 markets)
363
365
 
364
366
  Set `KALSHI_USE_DEMO=true` in your `.env` to use Kalshi's demo environment. All trades are simulated — no real money at risk.
365
367
 
368
+ ## Scripting & Parallel Use
369
+
370
+ The `bunx kalshi-trading-bot-cli@latest …` form is great for one-off interactive use, but it has two gotchas when you script against it:
371
+
372
+ **1. Install chatter leaks into `--json` output.** Bun prints lines like `Resolving dependencies` and `Saved lockfile` to stdout *before* our CLI runs, which corrupts JSON pipelines.
373
+
374
+ **2. Parallel invocations race on the install cache.** Running multiple `bunx kalshi-trading-bot-cli@latest …` calls in parallel can fail with `Failed to link …: EEXIST` and `could not determine executable`. See [oven-sh/bun#12917](https://github.com/oven-sh/bun/issues/12917) for current upstream status — `bunx`'s ephemeral install path isn't covered by the `bun install` global-store fix, so the workarounds below are still the recommended path.
375
+
376
+ **Recommended pattern for scripts and agents:**
377
+
378
+ ```bash
379
+ # Install once globally — this is parallel-safe and emits no install chatter on subsequent runs
380
+ bun add -g kalshi-trading-bot-cli
381
+
382
+ # Then use the `kalshi` binary directly — fan out as much as you want
383
+ parallel -j 30 'kalshi analyze {} --json > {}.json' ::: KXBTC-26APR-B95000 KXETH-… …
384
+ ```
385
+
386
+ **If you must use `bunx`:**
387
+
388
+ ```bash
389
+ # --silent suppresses Bun's install chatter (keeps your CLI's stdout clean)
390
+ bunx --silent kalshi-trading-bot-cli@latest analyze KXBTC-26APR-B95000 --json
391
+
392
+ # For parallel bunx, pre-warm the cache serially first to dodge the link race
393
+ bunx --silent kalshi-trading-bot-cli@latest --version # one-shot, populates cache
394
+ parallel -j 30 'bunx --silent kalshi-trading-bot-cli@latest analyze {} --json' ::: KXBTC-… …
395
+ ```
396
+
397
+ Keep `@latest` if you want auto-update on every invocation; drop it after the first run if you've pinned a version and want speed.
398
+
366
399
  ## Agent Usage
367
400
 
368
401
  Every command supports `--json` for structured output, making the bot easy to orchestrate from scripts or AI agents.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalshi-trading-bot-cli",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "Kalshi Trading Bot CLI - AI-powered prediction market terminal.",
5
5
  "license": "MIT",
6
6
  "author": "Octagon AI, Inc.",
package/src/cli.ts CHANGED
@@ -371,6 +371,7 @@ export async function runCli(options?: { forceSetup?: boolean }) {
371
371
  { value: 'cancel', label: 'cancel', description: 'Cancel an order' },
372
372
  { value: 'backtest', label: 'backtest', description: 'Model accuracy & edge scanner' },
373
373
  { value: 'help', label: 'help', description: 'Show help' },
374
+ { value: 'scripting', label: 'scripting', description: 'Tips for agents, pipelines, parallel use' },
374
375
  { value: 'setup', label: 'setup', description: 'Re-run setup wizard' },
375
376
  ];
376
377
  if (!typed) return topics;
@@ -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
  }
@@ -32,6 +32,15 @@ function fmtPct(v: number): string {
32
32
  return `${(v * 100).toFixed(1)}%`;
33
33
  }
34
34
 
35
+ /**
36
+ * Render a possibly-null numeric value to a fixed-decimal string with an
37
+ * optional prefix/suffix. Returns "--" for null/undefined or non-finite
38
+ * (NaN/Infinity) values so we never render "NaNpp" or "Infinity%".
39
+ */
40
+ function num(v: number | null | undefined, decimals: number, prefix = '', suffix = ''): string {
41
+ return v == null || !Number.isFinite(v) ? '--' : `${prefix}${v.toFixed(decimals)}${suffix}`;
42
+ }
43
+
35
44
  function parseProbabilities(raw: string | undefined): Record<string, number> | undefined {
36
45
  if (!raw) return undefined;
37
46
  const map: Record<string, number> = {};
@@ -208,8 +217,6 @@ export function formatBasketBuildHuman(data: BasketBuildResponse): string {
208
217
  if (data.legs.length === 0) {
209
218
  lines.push('No legs selected.');
210
219
  } else {
211
- const num = (v: number | null | undefined, decimals: number, prefix = '', suffix = '') =>
212
- v == null ? '-' : `${prefix}${v.toFixed(decimals)}${suffix}`;
213
220
  const rows: string[][] = data.legs.map((l) => [
214
221
  l.market_ticker,
215
222
  truncate(l.title, 35),
@@ -430,12 +437,12 @@ export function formatBasketSizeHuman(data: BasketSizeResponse): string {
430
437
  const rows: string[][] = data.legs.map((l) => [
431
438
  l.market_ticker,
432
439
  l.side.toUpperCase(),
433
- l.price.toFixed(2),
434
- `${(l.model_probability * 100).toFixed(1)}%`,
435
- `${l.edge_pp >= 0 ? '+' : ''}${l.edge_pp.toFixed(1)}pp`,
436
- l.kelly_fraction.toFixed(3),
437
- l.weight.toFixed(3),
438
- `$${l.notional_usd.toFixed(2)}`,
440
+ num(l.price, 2),
441
+ num(l.model_probability != null ? l.model_probability * 100 : null, 1, '', '%'),
442
+ !Number.isFinite(l.edge_pp as number) ? '--' : `${(l.edge_pp as number) > 0 ? '+' : ''}${(l.edge_pp as number).toFixed(1)}pp`,
443
+ num(l.kelly_fraction, 3),
444
+ num(l.weight, 3),
445
+ num(l.notional_usd, 2, '$'),
439
446
  ]);
440
447
  lines.push(formatTable(['Ticker', 'Side', 'Price', 'Model%', 'Edge', 'Kelly', 'Weight', 'Notional'], rows));
441
448
  return lines.join('\n');
@@ -91,9 +91,56 @@ function modeFlagsFor(canonical: Subcommand, args: ParsedArgs): Record<string, s
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * One-time stderr hint when a user pipes `--json` through `bunx` without
96
+ * `--silent`. Bunx prints install chatter to stdout *before* our process even
97
+ * starts, which corrupts JSON pipelines — `--silent` fixes it entirely, but
98
+ * users rarely discover that flag on their own. We can't strip the chatter
99
+ * (it's not in our stdout), but we can nudge them once.
100
+ *
101
+ * Heuristic: --json + non-TTY stdout + BUN_INSTALL_CACHE_DIR set (bunx sets
102
+ * this; `bun add -g` installs don't). Silenced after first emit by touching
103
+ * a sentinel file under ~/.kalshi-bot/.
104
+ */
105
+ async function maybeEmitBunxHint(args: ParsedArgs): Promise<void> {
106
+ if (!args.json) return;
107
+ if (process.stdout.isTTY) return;
108
+ if (!process.env.BUN_INSTALL_CACHE_DIR) return;
109
+ try {
110
+ // Dynamic ESM imports to avoid pulling these into the module graph at init.
111
+ const { appPath } = await import('../utils/paths.js');
112
+ const { existsSync, writeFileSync, mkdirSync } = await import('fs');
113
+ const sentinel = appPath('.bunx-hint-shown');
114
+ if (existsSync(sentinel)) return;
115
+ process.stderr.write(
116
+ '[kalshi] Tip: for clean JSON output and parallel-safe scripting, install once with\n' +
117
+ '[kalshi] bun add -g kalshi-trading-bot-cli\n' +
118
+ '[kalshi] then call `kalshi …` directly. Or use `bunx --silent` to suppress install\n' +
119
+ '[kalshi] chatter from this invocation. See README → Scripting & Parallel Use.\n',
120
+ );
121
+ const dir = appPath('.');
122
+ mkdirSync(dir, { recursive: true });
123
+ writeFileSync(sentinel, String(Date.now()));
124
+ } catch {
125
+ // Best-effort hint — never fail the actual command because of it.
126
+ }
127
+ }
128
+
129
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
130
+
94
131
  export async function dispatch(args: ParsedArgs): Promise<void> {
132
+ // --days-to-close N is ergonomic sugar over --close-before <iso>. Resolve
133
+ // it once here so every downstream command (search, events, series,
134
+ // catalysts, basket --theme, similar) gets the same filter without each
135
+ // handler reimplementing the arithmetic.
136
+ if (args.daysToClose !== undefined && !args.closeBefore) {
137
+ const target = new Date(Date.now() + args.daysToClose * MILLISECONDS_PER_DAY);
138
+ args.closeBefore = target.toISOString();
139
+ }
140
+
95
141
  const { subcommand, json } = args;
96
142
  const resolved = resolveAlias(subcommand, args.positionalArgs);
143
+ await maybeEmitBunxHint(args);
97
144
  trackEvent('cli_command', {
98
145
  command: resolved.canonical,
99
146
  subview: resolved.subview ?? '',
@@ -307,6 +354,27 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
307
354
 
308
355
  // ─── analyze ───────────────────────────────────────────────────────
309
356
  if (resolved.canonical === 'analyze') {
357
+ // Batch mode: 2+ positional tickers OR --tickers csv. Routes through
358
+ // POST /kalshi/markets/edge in a single call (vs. N serial Octagon
359
+ // round-trips). Use --refresh on a single ticker for the full deep
360
+ // analysis pipeline.
361
+ const csvTickers = args.tickers
362
+ ? args.tickers.split(',').map((s) => s.trim()).filter(Boolean)
363
+ : [];
364
+ const tickerList = [...args.positionalArgs, ...csvTickers];
365
+ if (tickerList.length > 1) {
366
+ const { handleAnalyzeBatch, formatAnalyzeBatchHuman } = await import('./analyze-batch.js');
367
+ const resp = await handleAnalyzeBatch(tickerList);
368
+ if (json) {
369
+ console.log(JSON.stringify(resp));
370
+ } else if (resp.ok) {
371
+ console.log(formatAnalyzeBatchHuman(resp.data));
372
+ } else {
373
+ console.error(resp.error?.message ?? 'analyze (batch) failed');
374
+ }
375
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
376
+ return;
377
+ }
310
378
  const ticker = args.positionalArgs[0];
311
379
  if (!ticker) {
312
380
  const errResp = wrapError('analyze', 'MISSING_TICKER', 'Usage: analyze <ticker> [--refresh] [--report]');
@@ -24,9 +24,10 @@ Search flags (server-side path):
24
24
  --category <name> Filter by category
25
25
  --series <ticker> Filter to a series
26
26
  --min-volume <n> Floor on 24h volume
27
- --close-before <iso> Only markets closing before
27
+ --close-before <iso> Only markets closing before this timestamp
28
+ --days-to-close <n> Shortcut: only markets closing in the next N days
28
29
  --limit <n> Page size (default 30)
29
- --sort-by <key> volume_24h | close_time | last_price (client-side sort)
30
+ --sort-by <key> volume_24h | close_time | last_price (server-side sort)
30
31
  --aggregate-by series Roll up results by series (calls series rollup)
31
32
  --active-only Drop non-active markets (defensive; the live universe is active by default)
32
33
 
@@ -51,12 +52,22 @@ Flags:
51
52
 
52
53
  analyze: `**${p}analyze** — Deep market analysis
53
54
 
54
- ${p}analyze <ticker> Full analysis: edge, drivers, catalysts, Kelly sizing
55
- ${p}analyze <ticker> ${ctx === 'cli' ? '--' : ''}refresh Force fresh Octagon report
56
- ${ctx === 'cli' ? `
55
+ ${p}analyze <ticker> Full analysis: edge, drivers, catalysts, Kelly sizing
56
+ ${p}analyze <ticker> ${ctx === 'cli' ? '--' : ''}refresh Force fresh Octagon report
57
+
58
+ Batch mode (one Octagon round-trip instead of N):
59
+ ${p}analyze KX-A KX-B KX-C Edge readout across 2-100 tickers
60
+ ${p}analyze --tickers KX-A,KX-B,KX-C Same, comma-separated
61
+ ${p}analyze KX-A KX-B KX-C --json For pipelines / scripting
62
+
63
+ The batch mode hits POST /kalshi/markets/edge in one call and returns
64
+ model_probability, market_probability, edge_pp, expected_return per ticker.
65
+ Use single-ticker mode when you need the full deep-analysis pipeline
66
+ (drivers, catalysts, Kelly sizing, risk gate).${ctx === 'cli' ? `
67
+
57
68
  Legacy aliases (still work):
58
- ${p}edge [--ticker X] Edge history / snapshots (default: last 24h)
59
- ${p}edge --since <date> Edges since date (e.g. 2026-03-01)` : ''}`,
69
+ ${p}edge [--ticker X] Edge history / snapshots (default: last 24h)
70
+ ${p}edge --since <date> Edges since date (e.g. 2026-03-01)` : ''}`,
60
71
 
61
72
  watch: `**${p}watch** — Live monitoring
62
73
 
@@ -138,6 +149,34 @@ ${p}init Launch the TUI with the setup wizard open
138
149
  ${p}help Show all commands
139
150
  ${p}help <command> Show detailed help for a command`,
140
151
 
152
+ scripting: `**Scripting & Parallel Use** — for agents, pipelines, and parallel invocations
153
+
154
+ The \`bunx kalshi-trading-bot-cli@latest …\` form is convenient for one-off use
155
+ but has two gotchas under scripting:
156
+
157
+ 1. Bun's install chatter ("Resolving dependencies", "Saved lockfile") leaks
158
+ into stdout before our CLI runs, corrupting JSON pipelines.
159
+ 2. Parallel \`bunx\` invocations race on the install cache and fail with
160
+ "Failed to link …: EEXIST" / "could not determine executable".
161
+ See oven-sh/bun#12917 for upstream status.
162
+
163
+ **Recommended for scripts and agents:**
164
+
165
+ bun add -g kalshi-trading-bot-cli # install once; emits no chatter on subsequent runs
166
+ parallel -j 30 'kalshi analyze {} --json' ::: TICKER1 TICKER2 …
167
+
168
+ **If you must use bunx:**
169
+
170
+ bunx --silent kalshi-trading-bot-cli@latest analyze KX-A --json
171
+ ^^^^^^^^ suppresses install chatter; keeps our stdout clean
172
+
173
+ For parallel bunx, pre-warm the cache serially before fanning out:
174
+
175
+ bunx --silent kalshi-trading-bot-cli@latest --version # one-shot, warms cache
176
+ parallel -j 30 'bunx --silent kalshi-trading-bot-cli@latest analyze {} --json' ::: …
177
+
178
+ See README → Scripting & Parallel Use for the full picture.`,
179
+
141
180
  similar: `**${p}similar** — Semantic market search (Octagon-powered)
142
181
 
143
182
  ${p}similar <ticker> Markets near this ticker by embedding distance
@@ -71,6 +71,7 @@ export interface ParsedArgs {
71
71
  sides?: string; // comma-separated yes/no per ticker for correlate
72
72
  cells: boolean; // include_cell_detail for correlate
73
73
  autoProbs: boolean; // basket size: auto-fetch leg probabilities via markets/edge
74
+ daysToClose?: number; // ergonomic shortcut: close_before = now + N days
74
75
  parseErrors: string[];
75
76
  }
76
77
 
@@ -129,6 +130,7 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
129
130
  let sides: string | undefined;
130
131
  let cells = false;
131
132
  let autoProbs = false;
133
+ let daysToClose: number | undefined;
132
134
 
133
135
  for (let i = 0; i < argv.length; i++) {
134
136
  const arg = argv[i];
@@ -397,6 +399,13 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
397
399
  cells = true;
398
400
  } else if (arg === '--auto-probs') {
399
401
  autoProbs = true;
402
+ } else if (arg === '--days-to-close' || arg === '--max-dte') {
403
+ const raw = argv[++i];
404
+ if (raw != null) {
405
+ const numeric = Number(raw);
406
+ if (Number.isFinite(numeric) && Number.isInteger(numeric) && numeric > 0) { daysToClose = numeric; }
407
+ else { parseErrors.push(`Invalid ${arg} value: "${raw}" (expected a positive integer)`); }
408
+ } else { parseErrors.push(`${arg} requires a value`); }
400
409
  } else if (arg.startsWith('--')) {
401
410
  parseErrors.push(`Unknown flag: ${arg}`);
402
411
  } else {
@@ -426,7 +435,7 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
426
435
  topK, behavioral, ranked, labelContains, closeBefore, windowDays, correlationInterval, timeframe,
427
436
  weights, bankroll, kellyMultiplier, n, maxPerCluster, maxCorrelation, minReturn, seriesTicker,
428
437
  sortBy, probabilities, tickers, query, showCluster, aggregateBy, activeOnly,
429
- seriesPrefix, sides, cells, autoProbs,
438
+ seriesPrefix, sides, cells, autoProbs, daysToClose,
430
439
  parseErrors,
431
440
  };
432
441
  }
@@ -15,8 +15,9 @@ export interface OctagonReport {
15
15
  raw_response?: string | null;
16
16
  model_accuracy?: number | null;
17
17
  variant_used?: string | null;
18
- fetched_at: number;
18
+ fetched_at: number; // Unix seconds — when WE pulled this report ("Refreshed")
19
19
  expires_at: number;
20
+ analysis_last_updated?: string | null; // Octagon's upstream model-run timestamp ("Model run")
20
21
  }
21
22
 
22
23
  export function insertReport(db: Database, report: OctagonReport): void {
package/src/db/schema.ts CHANGED
@@ -237,6 +237,11 @@ export function migrate(db: Database): void {
237
237
  if (!reportCols.some((c) => c.name === 'close_time')) {
238
238
  db.exec(`ALTER TABLE octagon_reports ADD COLUMN close_time TEXT`);
239
239
  }
240
+ // Upstream model-run timestamp (Octagon `analysis_last_updated`). Distinct
241
+ // from `fetched_at` (when we pulled the report into our local cache).
242
+ if (!reportCols.some((c) => c.name === 'analysis_last_updated')) {
243
+ db.exec(`ALTER TABLE octagon_reports ADD COLUMN analysis_last_updated TEXT`);
244
+ }
240
245
 
241
246
  const historyCols = db.query(`PRAGMA table_info(octagon_history)`).all() as Array<{ name: string }>;
242
247
  if (!historyCols.some((c) => c.name === 'outcome_probabilities_json')) {
@@ -189,12 +189,12 @@ export interface BasketBacktestResponse extends BasketCandlesResponse {
189
189
  export interface BasketSizeLeg {
190
190
  market_ticker: string;
191
191
  side: 'yes' | 'no';
192
- model_probability: number;
193
- price: number;
194
- edge_pp: number;
195
- kelly_fraction: number;
196
- weight: number;
197
- notional_usd: number;
192
+ model_probability: number | null;
193
+ price: number | null;
194
+ edge_pp: number | null;
195
+ kelly_fraction: number | null;
196
+ weight: number | null;
197
+ notional_usd: number | null;
198
198
  }
199
199
 
200
200
  export interface BasketSizeResponse {
@@ -49,7 +49,7 @@ function classifyConfidence(absEdge: number): string {
49
49
  * Convert an Octagon event entry to local DB records and persist.
50
50
  * Returns true if a new record was inserted, false if skipped.
51
51
  */
52
- function persistEvent(db: Database, event: OctagonEventEntry): boolean {
52
+ export function persistEvent(db: Database, event: OctagonEventEntry): boolean {
53
53
  const capturedDate = new Date(event.captured_at);
54
54
  const closeDate = new Date(event.close_time);
55
55
  if (isNaN(capturedDate.getTime()) || isNaN(closeDate.getTime())) return false;
@@ -102,7 +102,8 @@ function persistEvent(db: Database, event: OctagonEventEntry): boolean {
102
102
 
103
103
  db.prepare(
104
104
  `UPDATE octagon_reports SET has_history = $hh, mutually_exclusive = $me, series_category = $sc,
105
- confidence_score = $cs, outcome_probabilities_json = $opj, close_time = $ct
105
+ confidence_score = $cs, outcome_probabilities_json = $opj, close_time = $ct,
106
+ analysis_last_updated = $alu
106
107
  WHERE report_id = $rid`,
107
108
  ).run({
108
109
  $rid: reportId,
@@ -112,34 +113,54 @@ function persistEvent(db: Database, event: OctagonEventEntry): boolean {
112
113
  $cs: event.confidence_score ?? null,
113
114
  $opj: event.outcome_probabilities ? JSON.stringify(event.outcome_probabilities) : null,
114
115
  $ct: event.close_time ?? null,
116
+ $alu: event.analysis_last_updated ?? null,
115
117
  });
116
118
  })();
117
119
 
118
- // Also persist to edge_history
119
- const edge = modelProb - marketProb;
120
- try {
121
- insertEdge(db, {
122
- ticker: event.event_ticker,
123
- event_ticker: event.event_ticker,
124
- timestamp: capturedAt,
125
- model_prob: modelProb,
126
- market_prob: marketProb,
127
- edge,
128
- octagon_report_id: reportId,
129
- drivers_json: null,
130
- sources_json: null,
131
- catalysts_json: null,
132
- cache_hit: 1,
133
- cache_miss: 0,
134
- confidence: classifyConfidence(Math.abs(edge)),
135
- });
136
- } catch (err) {
137
- // Only swallow UNIQUE constraint violations (duplicate ticker+timestamp)
138
- const msg = err instanceof Error ? err.message : String(err);
139
- if (/UNIQUE constraint failed/i.test(msg)) {
140
- // Expected — edge already exists for this ticker+timestamp
141
- } else {
142
- throw err;
120
+ // Also persist to edge_history. Two cases:
121
+ // - Multi-outcome events (e.g. KXFEDCHAIRNOM-29) ship outcome_probabilities
122
+ // with per-market model/market probs. Write one edge_history row per
123
+ // outcome with the correct ticker — otherwise downstream consumers see
124
+ // the event-level placeholder (typically ~0.5) on every contract.
125
+ // - Single-outcome / no-outcome events: fall back to event-level probs on
126
+ // a single row keyed by the event ticker, as before.
127
+ const outcomes = Array.isArray(event.outcome_probabilities) ? event.outcome_probabilities : [];
128
+ const perOutcomeRows = outcomes.length > 0
129
+ ? outcomes
130
+ .filter((o) => o && o.market_ticker && typeof o.model_probability === 'number' && typeof o.market_probability === 'number')
131
+ .map((o) => ({
132
+ ticker: o.market_ticker,
133
+ model_prob: o.model_probability / 100,
134
+ market_prob: o.market_probability / 100,
135
+ }))
136
+ : [{ ticker: event.event_ticker, model_prob: modelProb, market_prob: marketProb }];
137
+
138
+ for (const row of perOutcomeRows) {
139
+ const rowEdge = row.model_prob - row.market_prob;
140
+ try {
141
+ insertEdge(db, {
142
+ ticker: row.ticker,
143
+ event_ticker: event.event_ticker,
144
+ timestamp: capturedAt,
145
+ model_prob: row.model_prob,
146
+ market_prob: row.market_prob,
147
+ edge: rowEdge,
148
+ octagon_report_id: reportId,
149
+ drivers_json: null,
150
+ sources_json: null,
151
+ catalysts_json: null,
152
+ cache_hit: 1,
153
+ cache_miss: 0,
154
+ confidence: classifyConfidence(Math.abs(rowEdge)),
155
+ });
156
+ } catch (err) {
157
+ // Only swallow UNIQUE constraint violations (duplicate ticker+timestamp)
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ if (/UNIQUE constraint failed/i.test(msg)) {
160
+ // Expected — edge already exists for this ticker+timestamp
161
+ } else {
162
+ throw err;
163
+ }
143
164
  }
144
165
  }
145
166