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.
@@ -26,6 +26,17 @@ import { scanEdges, formatEdgeScanHuman } from './search-edge.js';
26
26
  import type { KalshiBalanceResponse } from './formatters.js';
27
27
  import { ExitCode, exitCodeFromError } from '../utils/errors.js';
28
28
  import { trackEvent } from '../utils/telemetry.js';
29
+ import { handleSimilar, formatSimilarHuman } from './similar.js';
30
+ import { handleClusters, formatClustersHuman } from './clusters.js';
31
+ import { handlePeers, formatPeersHuman } from './peers.js';
32
+ import { handleCorrelate, formatCorrelationHuman } from './correlate.js';
33
+ import { handleBasket, formatBasketHuman } from './basket.js';
34
+ import { searchKalshiMarkets, getMarketsWithEdge } from '../scan/octagon-kalshi-api.js';
35
+ import { formatMarketSearchHuman, formatMarketsWithEdgeHuman } from './search-remote.js';
36
+ import { handleEvents, formatEventsHuman } from './events.js';
37
+ import { handleSeries, formatSeriesHuman } from './series.js';
38
+ import { handleEditorialThemes, formatEditorialThemesHuman } from './editorial-themes.js';
39
+ import { handleCatalysts, formatCatalystsHuman } from './catalysts.js';
29
40
 
30
41
  // ─── Alias resolution ────────────────────────────────────────────────────────
31
42
  // Maps legacy CLI subcommands to canonical commands with mode/subview context
@@ -45,19 +56,96 @@ function resolveAlias(subcommand: Subcommand, positionalArgs: string[]): Resolve
45
56
  case 'status':
46
57
  return { canonical: 'portfolio', subview: 'status' };
47
58
 
48
- // themes search themes
49
- case 'themes':
50
- return { canonical: 'search', subview: 'themes' };
59
+ // `themes` is now the editorial-themes registry (curated narrative buckets).
60
+ // Legacy "kalshi search themes" (Kalshi category labels) is still reachable
61
+ // via `search themes`.
62
+
63
+ // basket sub-routing (build/backtest/size/candles) — exposed for telemetry granularity
64
+ case 'basket': {
65
+ const sub = positionalArgs[0]?.toLowerCase();
66
+ if (sub === 'build' || sub === 'backtest' || sub === 'size' || sub === 'candles') {
67
+ return { canonical: 'basket', subview: sub };
68
+ }
69
+ return { canonical: 'basket' };
70
+ }
51
71
 
52
72
  default:
53
73
  return { canonical: subcommand };
54
74
  }
55
75
  }
56
76
 
77
+ function modeFlagsFor(canonical: Subcommand, args: ParsedArgs): Record<string, string | boolean> {
78
+ switch (canonical) {
79
+ case 'clusters':
80
+ return { behavioral: args.behavioral, ranked: args.ranked };
81
+ case 'peers':
82
+ return { behavioral: args.behavioral, show_cluster: args.showCluster };
83
+ case 'similar':
84
+ return { anchor: args.ticker ? 'ticker' : args.query ? 'query' : 'positional' };
85
+ case 'basket':
86
+ return { kelly_sizing: args.bankroll !== undefined };
87
+ case 'search':
88
+ return { remote: !!process.env.OCTAGON_API_KEY };
89
+ default:
90
+ return {};
91
+ }
92
+ }
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
+
57
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
+
58
141
  const { subcommand, json } = args;
59
142
  const resolved = resolveAlias(subcommand, args.positionalArgs);
60
- trackEvent('cli_command', { command: resolved.canonical, subview: resolved.subview ?? '' });
143
+ await maybeEmitBunxHint(args);
144
+ trackEvent('cli_command', {
145
+ command: resolved.canonical,
146
+ subview: resolved.subview ?? '',
147
+ ...modeFlagsFor(resolved.canonical, args),
148
+ });
61
149
 
62
150
  try {
63
151
  // ─── reject invalid flags early (for all commands) ───────────────
@@ -87,8 +175,26 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
87
175
  return;
88
176
  }
89
177
  if (sub === 'edge') {
90
- const db = (await import('../db/index.js')).getDb();
91
178
  const minEdgePp = (args.minEdge ?? 0.05) * 100;
179
+ if (process.env.OCTAGON_API_KEY) {
180
+ // edge_pp_min is asymmetric (only filters lower bound). Skip when
181
+ // user passes --min-edge 0 so they see the full distribution.
182
+ const data = await getMarketsWithEdge({
183
+ category: args.category,
184
+ ...(minEdgePp > 0 ? { edge_pp_min: minEdgePp } : {}),
185
+ sort_by: (args.sortBy as 'edge_pp' | 'expected_return' | 'total_volume' | 'model_probability' | undefined) ?? 'edge_pp',
186
+ limit: args.limit ?? 20,
187
+ });
188
+ if (json) {
189
+ console.log(JSON.stringify(wrapSuccess('search', data)));
190
+ } else {
191
+ console.log(formatMarketsWithEdgeHuman(data, minEdgePp));
192
+ }
193
+ process.exit(ExitCode.SUCCESS);
194
+ return;
195
+ }
196
+ // Local fallback: scan cached Octagon reports in SQLite
197
+ const db = (await import('../db/index.js')).getDb();
92
198
  const result = scanEdges(db, { minEdgePp, limit: args.limit, category: args.category });
93
199
  if (json) {
94
200
  console.log(JSON.stringify(wrapSuccess('search', result)));
@@ -109,14 +215,60 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
109
215
  process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
110
216
  return;
111
217
  }
112
- // General search: query the local event index
218
+ const query = args.positionalArgs.join(' ');
219
+
220
+ // Octagon-powered server-side search: broader universe, full-text + structured filters.
221
+ if (process.env.OCTAGON_API_KEY) {
222
+ // --aggregate-by series → route to series rollup
223
+ if (args.aggregateBy === 'series') {
224
+ const { handleSeries, formatSeriesHuman } = await import('./series.js');
225
+ const seriesArgs = { ...args, positionalArgs: query ? ['search', query] : ['list'] };
226
+ const resp = await handleSeries(seriesArgs);
227
+ if (json) {
228
+ console.log(JSON.stringify(resp));
229
+ } else if (resp.ok) {
230
+ console.log(formatSeriesHuman(resp.data));
231
+ } else {
232
+ console.error(resp.error?.message ?? 'series rollup failed');
233
+ }
234
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
235
+ return;
236
+ }
237
+ // sort_by is now server-side (true top-N across the whole universe);
238
+ // series_prefix lets us tree-browse (KXBTC matches all Bitcoin series).
239
+ const serverSortBy = (args.sortBy === 'volume_24h' || args.sortBy === 'close_time' || args.sortBy === 'last_price')
240
+ ? args.sortBy
241
+ : undefined;
242
+ const page = await searchKalshiMarkets({
243
+ q: query,
244
+ category: args.category,
245
+ series_ticker: args.seriesTicker,
246
+ series_prefix: args.seriesPrefix,
247
+ min_volume_24h: args.minVolume,
248
+ close_before: args.closeBefore,
249
+ sort_by: serverSortBy,
250
+ limit: args.limit ?? 30,
251
+ });
252
+ // --active-only is defensive — the live universe is active by default.
253
+ const rows = args.activeOnly
254
+ ? page.data.filter((m) => m.status === 'active' || m.status === 'open')
255
+ : page.data;
256
+ const filteredPage = { ...page, data: rows };
257
+ if (json) {
258
+ console.log(JSON.stringify(wrapSuccess('search', filteredPage)));
259
+ } else {
260
+ console.log(formatMarketSearchHuman(query, filteredPage));
261
+ }
262
+ return;
263
+ }
264
+
265
+ // Local fallback: query the pre-built event index.
113
266
  if (args.refresh) {
114
267
  await forceRefreshIndex();
115
268
  } else {
116
269
  await ensureIndex();
117
270
  }
118
271
  const db = (await import('../db/index.js')).getDb();
119
- const query = args.positionalArgs.join(' ');
120
272
  const results = searchEventIndex(db, query, 30);
121
273
  if (json) {
122
274
  console.log(JSON.stringify(wrapSuccess('search', { events: results })));
@@ -202,6 +354,27 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
202
354
 
203
355
  // ─── analyze ───────────────────────────────────────────────────────
204
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
+ }
205
378
  const ticker = args.positionalArgs[0];
206
379
  if (!ticker) {
207
380
  const errResp = wrapError('analyze', 'MISSING_TICKER', 'Usage: analyze <ticker> [--refresh] [--report]');
@@ -228,6 +401,132 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
228
401
  return;
229
402
  }
230
403
 
404
+ // ─── similar (Octagon semantic search) ─────────────────────────────
405
+ if (resolved.canonical === 'similar') {
406
+ const resp = await handleSimilar(args);
407
+ if (json) {
408
+ console.log(JSON.stringify(resp));
409
+ } else if (resp.ok) {
410
+ console.log(formatSimilarHuman(resp.data));
411
+ } else {
412
+ console.error(resp.error?.message ?? 'similar failed');
413
+ }
414
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
415
+ return;
416
+ }
417
+
418
+ // ─── clusters (Octagon thematic & behavioral) ──────────────────────
419
+ if (resolved.canonical === 'clusters') {
420
+ const resp = await handleClusters(args);
421
+ if (json) {
422
+ console.log(JSON.stringify(resp));
423
+ } else if (resp.ok) {
424
+ console.log(formatClustersHuman(resp.data));
425
+ } else {
426
+ console.error(resp.error?.message ?? 'clusters failed');
427
+ }
428
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
429
+ return;
430
+ }
431
+
432
+ // ─── peers (Octagon cluster peers) ─────────────────────────────────
433
+ if (resolved.canonical === 'peers') {
434
+ const resp = await handlePeers(args);
435
+ if (json) {
436
+ console.log(JSON.stringify(resp));
437
+ } else if (resp.ok) {
438
+ console.log(formatPeersHuman(resp.data));
439
+ } else {
440
+ console.error(resp.error?.message ?? 'peers failed');
441
+ }
442
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
443
+ return;
444
+ }
445
+
446
+ // ─── correlate (Octagon correlation matrix) ────────────────────────
447
+ if (resolved.canonical === 'correlate') {
448
+ const resp = await handleCorrelate(args);
449
+ if (json) {
450
+ console.log(JSON.stringify(resp));
451
+ } else if (resp.ok) {
452
+ console.log(formatCorrelationHuman(resp.data));
453
+ } else {
454
+ console.error(resp.error?.message ?? 'correlate failed');
455
+ }
456
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
457
+ return;
458
+ }
459
+
460
+ // ─── catalysts (upcoming market closes grouped by week) ────────────
461
+ if (resolved.canonical === 'catalysts') {
462
+ const resp = await handleCatalysts(args);
463
+ if (json) {
464
+ console.log(JSON.stringify(resp));
465
+ } else if (resp.ok) {
466
+ console.log(formatCatalystsHuman(resp.data));
467
+ } else {
468
+ console.error(resp.error?.message ?? 'catalysts failed');
469
+ }
470
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
471
+ return;
472
+ }
473
+
474
+ // ─── themes (editorial narrative registry) ─────────────────────────
475
+ if (resolved.canonical === 'themes') {
476
+ const resp = await handleEditorialThemes(args);
477
+ if (json) {
478
+ console.log(JSON.stringify(resp));
479
+ } else if (resp.ok) {
480
+ console.log(formatEditorialThemesHuman(resp.data));
481
+ } else {
482
+ console.error(resp.error?.message ?? 'themes failed');
483
+ }
484
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
485
+ return;
486
+ }
487
+
488
+ // ─── series (Kalshi series rollup) ─────────────────────────────────
489
+ if (resolved.canonical === 'series') {
490
+ const resp = await handleSeries(args);
491
+ if (json) {
492
+ console.log(JSON.stringify(resp));
493
+ } else if (resp.ok) {
494
+ console.log(formatSeriesHuman(resp.data));
495
+ } else {
496
+ console.error(resp.error?.message ?? 'series failed');
497
+ }
498
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
499
+ return;
500
+ }
501
+
502
+ // ─── events (Octagon events list / detail) ─────────────────────────
503
+ if (resolved.canonical === 'events') {
504
+ const resp = await handleEvents(args);
505
+ if (json) {
506
+ console.log(JSON.stringify(resp));
507
+ } else if (resp.ok) {
508
+ console.log(formatEventsHuman(resp.data));
509
+ } else {
510
+ console.error(resp.error?.message ?? 'events failed');
511
+ }
512
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
513
+ return;
514
+ }
515
+
516
+ // ─── basket (build, backtest, size, candles) ───────────────────────
517
+ if (resolved.canonical === 'basket') {
518
+ const resp = await handleBasket(args);
519
+ if (json) {
520
+ console.log(JSON.stringify(resp));
521
+ } else if (resp.ok) {
522
+ console.log(formatBasketHuman(resp.data));
523
+ } else {
524
+ console.error(resp.error?.message ?? 'basket failed');
525
+ }
526
+ process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
527
+ return;
528
+ }
529
+
231
530
  // ─── watch ─────────────────────────────────────────────────────────
232
531
  if (resolved.canonical === 'watch') {
233
532
  // Force index rebuild before watching if --refresh is set