kalshi-trading-bot-cli 2.1.6 → 2.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalshi-trading-bot-cli",
3
- "version": "2.1.6",
3
+ "version": "2.1.7",
4
4
  "description": "Kalshi Trading Bot CLI - AI-powered prediction market terminal.",
5
5
  "license": "MIT",
6
6
  "author": "Octagon AI, Inc.",
@@ -35,12 +35,25 @@ export interface AnalyzeData {
35
35
  * cache time but didn't get a newer underlying report from Octagon.
36
36
  */
37
37
  staleUpstream: boolean;
38
- modelProb: number;
39
- marketProb: number;
40
- edge: number;
41
- edgePp: string;
42
- confidence: string;
43
- mispricingSignal: string;
38
+ /**
39
+ * Octagon's model probability for this market. null when hasModel is
40
+ * false — we deliberately do NOT emit the 0.5 placeholder fallback to
41
+ * JSON consumers. Always check hasModel before reading this field.
42
+ */
43
+ modelProb: number | null;
44
+ /**
45
+ * Last traded market probability. null when hasMarketPrice is false.
46
+ * Always check hasMarketPrice before reading.
47
+ */
48
+ marketProb: number | null;
49
+ /** modelProb − marketProb. null when either input is unavailable. */
50
+ edge: number | null;
51
+ /** Pretty-printed edge ("+14pp"). null when edge is null. */
52
+ edgePp: string | null;
53
+ /** "very_high" | "high" | "moderate" | "low" — null when edge is null. */
54
+ confidence: string | null;
55
+ /** "underpriced" | "overpriced" | "fair_value" — null when edge is null. */
56
+ mispricingSignal: string | null;
44
57
  signal: string;
45
58
  drivers: PriceDriver[];
46
59
  catalysts: Catalyst[];
@@ -228,6 +241,16 @@ export async function handleAnalyze(
228
241
  const latestDbReport = getLatestReport(db, resolvedTicker);
229
242
  const reportAge = latestDbReport ? formatAge(latestDbReport.fetched_at) : null;
230
243
 
244
+ // Decide trading-side gating BEFORE running edge / Kelly / signal math.
245
+ // hasModel uses report.modelProb directly (snapshot.modelProb is just
246
+ // propagated unchanged from computeEdge — verified in edge-computer.ts:38).
247
+ // canComputeEdge is the contract: any trading decision (signal, Kelly,
248
+ // mispricing) must check it first. Otherwise we'd build a "BUY YES @ $X"
249
+ // recommendation from a 0.5 placeholder modelProb on uncovered events.
250
+ const hasModel = !report.cacheMiss && Number.isFinite(report.modelProb)
251
+ && !(report.modelProb === 0.5 && report.drivers.length === 0 && report.catalysts.length === 0);
252
+ const canComputeEdge = hasModel && hasMarketPrice;
253
+
231
254
  const snapshot = edgeComputer.computeEdge(resolvedTicker, report, marketProb);
232
255
 
233
256
  // Persist edge
@@ -264,7 +287,9 @@ export async function handleAnalyze(
264
287
  liquidityAdjusted: false,
265
288
  };
266
289
  let kelly: KellyResult;
267
- if (!hasMarketPrice) {
290
+ if (!canComputeEdge) {
291
+ // Either no model coverage or no last_price → any sizing computed from
292
+ // a placeholder modelProb / marketProb would be meaningless.
268
293
  kelly = emptyKelly;
269
294
  } else {
270
295
  try {
@@ -316,9 +341,16 @@ export async function handleAnalyze(
316
341
  const entryPrice = (snapshot.edge > 0 ? yesAsk : noAsk);
317
342
 
318
343
  let signal: string;
319
- if (!hasMarketPrice) {
320
- // No tradeable price no actionable signal. Render explicitly.
321
- signal = 'no signal (market has no last traded price)';
344
+ if (!canComputeEdge) {
345
+ // Any actionable signal needs both a real model probability and a real
346
+ // last_price. Spell out which one is missing so the user / bot knows
347
+ // why we're not making a recommendation.
348
+ const reason = !hasModel && !hasMarketPrice
349
+ ? 'no Octagon model coverage and no last traded price'
350
+ : !hasModel
351
+ ? 'no Octagon model coverage for this market'
352
+ : 'market has no last traded price';
353
+ signal = `no signal (${reason})`;
322
354
  } else if (existingPosition) {
323
355
  const holdDir = existingPosition.direction.toUpperCase();
324
356
  const edgeReversed =
@@ -365,11 +397,9 @@ export async function handleAnalyze(
365
397
  ? latestDbReport.analysis_last_updated.replace('T', ' ').slice(0, 16) + ' UTC'
366
398
  : null;
367
399
 
368
- // hasModel = Octagon returned a real probability for this market. A
369
- // cache-miss report keeps modelProb at the 0.5 placeholder; we must NOT
370
- // render that as if it were a real prediction.
371
- const hasModel = !report.cacheMiss && Number.isFinite(snapshot.modelProb)
372
- && !(snapshot.modelProb === 0.5 && report.drivers.length === 0 && report.catalysts.length === 0);
400
+ // hasModel + canComputeEdge were computed earlier (above Kelly/signal),
401
+ // so trading-side math never reads a placeholder edge. See top of
402
+ // handleAnalyze for the contract.
373
403
 
374
404
  // staleUpstream = user asked for --refresh but Octagon's upstream model run
375
405
  // timestamp didn't move. Cache fetch time bumped, but the underlying report
@@ -380,6 +410,11 @@ export async function handleAnalyze(
380
410
  && latestDbReport?.analysis_last_updated != null
381
411
  && preRefreshAnalysis === latestDbReport.analysis_last_updated;
382
412
 
413
+ // Null out trading-side fields when the underlying inputs are unavailable.
414
+ // JSON consumers previously saw modelProb: 0.5 / marketProb: 0.5 / edge: 0
415
+ // on degraded paths and treated them as real predictions. The hasModel and
416
+ // hasMarketPrice flags are the source of truth — fields here mirror them.
417
+ // (canComputeEdge was already evaluated at the top of the function.)
383
418
  return {
384
419
  ticker: resolvedTicker,
385
420
  eventTicker,
@@ -390,12 +425,12 @@ export async function handleAnalyze(
390
425
  staleUpstream,
391
426
  hasModel,
392
427
  hasMarketPrice,
393
- modelProb: snapshot.modelProb,
394
- marketProb,
395
- edge: snapshot.edge,
396
- edgePp,
397
- confidence: snapshot.confidence,
398
- mispricingSignal,
428
+ modelProb: hasModel ? snapshot.modelProb : null,
429
+ marketProb: hasMarketPrice ? marketProb : null,
430
+ edge: canComputeEdge ? snapshot.edge : null,
431
+ edgePp: canComputeEdge ? edgePp : null,
432
+ confidence: canComputeEdge ? snapshot.confidence : null,
433
+ mispricingSignal: canComputeEdge ? mispricingSignal : null,
399
434
  signal,
400
435
  drivers: snapshot.drivers,
401
436
  catalysts: snapshot.catalysts,
@@ -437,17 +472,17 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
437
472
  // hasMarketPrice=false → Kalshi market has no last_price → Market Prob shows "--"
438
473
  // Edge needs both. Either being false means edge/confidence/mispricing
439
474
  // render "--" — we never show a number derived from a placeholder.
440
- const modelStr = data.hasModel
475
+ const modelStr = data.hasModel && data.modelProb != null
441
476
  ? `${(data.modelProb * 100).toFixed(1)}%`
442
477
  : `-- (no Octagon model coverage for this market)`;
443
- const marketStr = data.hasMarketPrice
478
+ const marketStr = data.hasMarketPrice && data.marketProb != null
444
479
  ? `${(data.marketProb * 100).toFixed(1)}%`
445
480
  : `-- (no last traded price — market hasn't traded yet)`;
446
- const canComputeEdge = data.hasModel && data.hasMarketPrice;
481
+ const canComputeEdge = data.hasModel && data.hasMarketPrice && data.edge != null;
447
482
  lines.push(` Model Prob: ${modelStr}`);
448
483
  lines.push(` Market Prob: ${marketStr}`);
449
484
  if (canComputeEdge) {
450
- lines.push(` Edge: ${data.edgePp} (${(data.edge * 100).toFixed(1)}%)`);
485
+ lines.push(` Edge: ${data.edgePp} (${(data.edge! * 100).toFixed(1)}%)`);
451
486
  lines.push(` Confidence: ${data.confidence}`);
452
487
  lines.push(` Mispricing: ${data.mispricingSignal}`);
453
488
  } else {
@@ -624,8 +659,12 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
624
659
  // Close position: sell what we hold
625
660
  const sellSide = data.existingPosition.direction;
626
661
  const sellSize = data.existingPosition.size;
662
+ // marketProb is guaranteed when isSell is reachable (we got a SELL
663
+ // recommendation, which requires a price), but type system can't
664
+ // see that — fall back to 50 if data was tampered with.
665
+ const mp = data.marketProb ?? 0.5;
627
666
  const closePrice = data.closePriceCents ?? Math.round(
628
- (sellSide === 'yes' ? data.marketProb : 1 - data.marketProb) * 100
667
+ (sellSide === 'yes' ? mp : 1 - mp) * 100
629
668
  );
630
669
 
631
670
  console.log(` Signal: SELL ${sellSize} ${sellSide.toUpperCase()} @ ${closePrice}¢ (close position)`);
@@ -701,7 +740,7 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
701
740
  break;
702
741
  }
703
742
 
704
- const side = data.edge > 0 ? 'yes' : 'no';
743
+ const side = (data.edge ?? 0) > 0 ? 'yes' : 'no';
705
744
  const price = data.kelly.entryPriceCents;
706
745
  console.log(` Signal: BUY ${data.kelly.contracts} ${side.toUpperCase()} @ ${price}¢`);
707
746
  const confirm = await ask(' Execute? [y/n] ');
@@ -10,9 +10,12 @@ export interface PositionReview {
10
10
  direction: 'yes' | 'no';
11
11
  size: number;
12
12
  entryPrice: number | null;
13
- currentMarketProb: number;
14
- modelProb: number;
15
- edge: number;
13
+ /** null when analyze couldn't read a last_price for the market. */
14
+ currentMarketProb: number | null;
15
+ /** null when Octagon has no model coverage for the market. */
16
+ modelProb: number | null;
17
+ /** null when either currentMarketProb or modelProb is null. */
18
+ edge: number | null;
16
19
  signal: 'HOLD' | 'SELL';
17
20
  sellSide: 'yes' | 'no';
18
21
  closePriceCents: number;
@@ -62,9 +65,9 @@ export async function reviewPortfolio(): Promise<PositionReview[]> {
62
65
  direction,
63
66
  size,
64
67
  entryPrice: null,
65
- currentMarketProb: 0,
66
- modelProb: 0,
67
- edge: 0,
68
+ currentMarketProb: null,
69
+ modelProb: null,
70
+ edge: null,
68
71
  signal: 'HOLD' as const,
69
72
  sellSide: direction,
70
73
  closePriceCents: 0,
@@ -74,13 +77,20 @@ export async function reviewPortfolio(): Promise<PositionReview[]> {
74
77
  }
75
78
 
76
79
  const analysis: AnalyzeData = result.value;
77
- const { edge, marketProb, modelProb, kelly } = analysis;
80
+ const { marketProb, modelProb, kelly } = analysis;
78
81
 
79
- // Determine if edge has reversed against our position
82
+ // Determine if edge has reversed against our position.
83
+ // When edge is null (no model coverage or no last_price), we can't make
84
+ // a quantitative call — hold and surface the data gap as the reason.
80
85
  let signal: 'HOLD' | 'SELL' = 'HOLD';
81
86
  let reason = '';
87
+ const edge = analysis.edge;
82
88
 
83
- if (direction === 'yes' && edge < -SELL_THRESHOLD) {
89
+ if (edge == null) {
90
+ reason = !analysis.hasModel
91
+ ? 'No Octagon model coverage — cannot evaluate edge for this position'
92
+ : 'No last traded price — cannot evaluate edge for this position';
93
+ } else if (direction === 'yes' && edge < -SELL_THRESHOLD) {
84
94
  signal = 'SELL';
85
95
  reason = `Edge reversed: model now favors NO by ${Math.abs(edge * 100).toFixed(0)}pp`;
86
96
  } else if (direction === 'no' && edge > SELL_THRESHOLD) {
@@ -97,15 +107,13 @@ export async function reviewPortfolio(): Promise<PositionReview[]> {
97
107
  }
98
108
 
99
109
  // Use the bid-derived close price from handleAnalyze when available,
100
- // fall back to marketProb approximation only if missing
110
+ // fall back to marketProb approximation only if both are present
101
111
  const closePriceCents =
102
112
  analysis.closePriceCents && analysis.closePriceCents > 0
103
113
  ? analysis.closePriceCents
104
- : Math.round(
105
- direction === 'yes'
106
- ? marketProb * 100 - 1
107
- : (1 - marketProb) * 100 - 1
108
- );
114
+ : marketProb != null
115
+ ? Math.round(direction === 'yes' ? marketProb * 100 - 1 : (1 - marketProb) * 100 - 1)
116
+ : 0;
109
117
 
110
118
  return {
111
119
  ticker: pos.ticker,
@@ -140,10 +148,13 @@ export function formatReviewHuman(reviews: PositionReview[]): string {
140
148
  lines.push(` ${reviews.length} position${reviews.length === 1 ? '' : 's'} analyzed | ${sells.length} SELL signal${sells.length === 1 ? '' : 's'} | ${holds.length} HOLD`);
141
149
  lines.push('');
142
150
 
151
+ const edgePpStr = (edge: number | null): string =>
152
+ edge == null ? '--' : `${edge >= 0 ? '+' : ''}${(edge * 100).toFixed(0)}pp`;
153
+
143
154
  // Show SELL signals first
144
155
  for (const r of sells) {
145
156
  const dirLabel = r.direction.toUpperCase();
146
- const edgePp = `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`;
157
+ const edgePp = edgePpStr(r.edge);
147
158
  lines.push(` ⚠ ${r.ticker} ${dirLabel} ×${r.size}`);
148
159
  lines.push(` Edge: ${edgePp} | ${r.reason}`);
149
160
  lines.push(` → SELL ${dirLabel} @ ${r.closePriceCents}¢`);
@@ -157,7 +168,7 @@ export function formatReviewHuman(reviews: PositionReview[]): string {
157
168
  // Show HOLD positions
158
169
  for (const r of holds) {
159
170
  const dirLabel = r.direction.toUpperCase();
160
- const edgePp = `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`;
171
+ const edgePp = edgePpStr(r.edge);
161
172
  lines.push(` ✓ ${r.ticker} ${dirLabel} ×${r.size}`);
162
173
  lines.push(` Edge: ${edgePp} | ${r.reason}`);
163
174
  if (r.analyzeError) {
@@ -415,10 +415,28 @@ export class OctagonClient {
415
415
  }
416
416
 
417
417
  private extractFromMarkdown(raw: string, defaults: OctagonReport): OctagonReport {
418
+ // For multi-outcome reports (World Cup Silver Ball, FOMC ladders, IPO
419
+ // event trees), the per-outcome probabilities live in markdown tables
420
+ // like:
421
+ // | Player | Market % | Model % |
422
+ // |--------------|----------|---------|
423
+ // | Harry Kane | 35.0% | 36.0% |
424
+ // | Lionel Messi | 29.0% | 40.1% |
425
+ // The simple key:value regex below misses these and leaves the
426
+ // 0.5/0.5 placeholder. Try table extraction first, matching against the
427
+ // outcome suffix of the ticker (e.g. KXWCAWARD-26SBALL-HKANE → Kane).
428
+ let modelProb = this.extractProbFromMarkdownTable(raw, defaults.ticker, 'model');
429
+ let marketProb = this.extractProbFromMarkdownTable(raw, defaults.ticker, 'market');
430
+ if (modelProb === null) {
431
+ modelProb = this.extractProb(raw, /model\s*(?:prob(?:ability)?|estimate)\s*[:=]\s*([\d.]+%?)/i);
432
+ }
433
+ if (marketProb === null) {
434
+ marketProb = this.extractProb(raw, /market\s*(?:prob(?:ability)?|price)\s*[:=]\s*([\d.]+%?)/i);
435
+ }
418
436
  return {
419
437
  ...defaults,
420
- modelProb: this.extractProb(raw, /model\s*(?:prob(?:ability)?|estimate)\s*[:=]\s*([\d.]+%?)/i) ?? defaults.modelProb,
421
- marketProb: this.extractProb(raw, /market\s*(?:prob(?:ability)?|price)\s*[:=]\s*([\d.]+%?)/i) ?? defaults.marketProb,
438
+ modelProb: modelProb ?? defaults.modelProb,
439
+ marketProb: marketProb ?? defaults.marketProb,
422
440
  mispricingSignal: this.extractSignal(raw) ?? defaults.mispricingSignal,
423
441
  drivers: this.extractDrivers(raw),
424
442
  catalysts: this.extractCatalysts(raw),
@@ -428,6 +446,116 @@ export class OctagonClient {
428
446
  };
429
447
  }
430
448
 
449
+ /**
450
+ * Parse markdown tables in `raw` looking for a Model/Market % grid, then
451
+ * find the row matching `ticker`'s outcome suffix and return that row's
452
+ * `kind` (model | market) probability as a 0-1 fraction. Returns null when
453
+ * no table is found, no row matches, or the percentage can't be parsed.
454
+ *
455
+ * Matching strategy: the outcome suffix is whatever follows the last `-`
456
+ * in the ticker (e.g. KXWCAWARD-26SBALL-HKANE → "HKANE"). We compare it
457
+ * against each row's first cell (the name) by stripping non-letters and
458
+ * checking if either string contains the other (case-insensitive). This
459
+ * handles the common patterns: initial+lastname ("HKANE" ⊆ "harrykane"),
460
+ * country abbreviations ("MEX" ⊆ "mexico"), full names, etc.
461
+ *
462
+ * If multiple rows match we pick the strongest (longest overlap) to keep
463
+ * abbreviations like "MEX" from accidentally matching "MEXICO CITY".
464
+ */
465
+ private extractProbFromMarkdownTable(
466
+ raw: string,
467
+ ticker: string,
468
+ kind: 'model' | 'market',
469
+ ): number | null {
470
+ const suffix = ticker.split('-').pop()?.toUpperCase() ?? '';
471
+ if (!suffix || suffix.length < 2) return null;
472
+ const tickerKey = suffix.replace(/[^A-Z0-9]/g, '');
473
+
474
+ // Split into possible table blocks. Each block is a contiguous run of
475
+ // lines that start with `|`. A real table has >=3 lines (header, sep,
476
+ // body), at least 3 columns, and at least two columns whose values are
477
+ // percentages.
478
+ const lines = raw.split('\n');
479
+ const blocks: string[][] = [];
480
+ let cur: string[] = [];
481
+ for (const line of lines) {
482
+ if (line.trim().startsWith('|')) {
483
+ cur.push(line);
484
+ } else {
485
+ if (cur.length > 0) blocks.push(cur);
486
+ cur = [];
487
+ }
488
+ }
489
+ if (cur.length > 0) blocks.push(cur);
490
+
491
+ const parseCells = (line: string): string[] =>
492
+ line.split('|').slice(1, -1).map((c) => c.trim());
493
+ const isPercent = (s: string): boolean => /^-?\d+(\.\d+)?%?$/.test(s.replace(/[,$]/g, ''));
494
+ const parsePercent = (s: string): number | null => {
495
+ // Honor an explicit % marker in the input — "0.9%" must be 0.009, not
496
+ // 0.9, and "1%" must be 0.01. The legacy `n > 1 ? n/100 : n` heuristic
497
+ // only works when the value is unambiguously > 1 (e.g. "35%") and
498
+ // misreads sub-1 percentages. We only fall back to the heuristic when
499
+ // there's no % sign at all (raw fractions like "0.35").
500
+ const hasPercentSign = s.includes('%');
501
+ const cleaned = s.replace(/[%,$\s]/g, '');
502
+ const n = parseFloat(cleaned);
503
+ if (!Number.isFinite(n)) return null;
504
+ if (hasPercentSign) return n / 100;
505
+ return n > 1 ? n / 100 : n;
506
+ };
507
+
508
+ let bestMatch: { score: number; value: number } | null = null;
509
+ for (const block of blocks) {
510
+ if (block.length < 3) continue;
511
+ const header = parseCells(block[0]).map((c) => c.toLowerCase());
512
+ // Find target column. Accept "model", "model %", "model prob", etc.
513
+ const targetCol = header.findIndex((c) =>
514
+ kind === 'model'
515
+ ? /\bmodel\b/.test(c)
516
+ : /\b(market|mkt|kalshi)\b/.test(c) && !/cap/.test(c), // avoid "market cap"
517
+ );
518
+ if (targetCol < 0) continue;
519
+
520
+ // Body starts after the separator row (line index 2). Skip rows whose
521
+ // cells don't look like data (e.g. another separator).
522
+ for (let i = 2; i < block.length; i++) {
523
+ const cells = parseCells(block[i]);
524
+ if (cells.length <= targetCol) continue;
525
+ const cell = cells[targetCol];
526
+ if (!isPercent(cell)) continue;
527
+ const name = (cells[0] ?? '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
528
+ if (!name) continue;
529
+ // Match strategies (try in order, take strongest):
530
+ // 1. ticker fully inside row name ("MEX" in "MEXICO")
531
+ // 2. row name fully inside ticker ("MESSI" in "LIONELMESSI")
532
+ // 3. ticker suffix without leading initial(s) inside row name
533
+ // ("HKANE" → "KANE" in "HARRYKANE", "LMESSI" → "MESSI" in
534
+ // "LIONELMESSI"). Common Kalshi convention is one or two
535
+ // initials + lastname, so we strip up to 2 leading chars.
536
+ let score = 0;
537
+ if (name.includes(tickerKey)) score = tickerKey.length;
538
+ else if (tickerKey.includes(name)) score = name.length;
539
+ else {
540
+ for (let drop = 1; drop <= Math.min(2, tickerKey.length - 3); drop++) {
541
+ const trimmed = tickerKey.slice(drop);
542
+ if (trimmed.length >= 3 && name.includes(trimmed)) {
543
+ score = Math.max(score, trimmed.length);
544
+ break;
545
+ }
546
+ }
547
+ }
548
+ if (score === 0) continue;
549
+ const v = parsePercent(cell);
550
+ if (v === null) continue;
551
+ if (!bestMatch || score > bestMatch.score) {
552
+ bestMatch = { score, value: v };
553
+ }
554
+ }
555
+ }
556
+ return bestMatch?.value ?? null;
557
+ }
558
+
431
559
  private inferSignal(modelProb: number | null, marketProb: number | null): MispricingSignal | null {
432
560
  if (modelProb === null || marketProb === null) return null;
433
561
  const edge = modelProb - marketProb;
@@ -27,7 +27,7 @@ export const portfolioReviewTool = new DynamicStructuredTool({
27
27
  direction: r.direction,
28
28
  size: r.size,
29
29
  edge: r.edge,
30
- edgePp: `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`,
30
+ edgePp: r.edge == null ? null : `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`,
31
31
  signal: r.signal,
32
32
  reason: r.reason,
33
33
  closePriceCents: r.closePriceCents,