kalshi-trading-bot-cli 2.1.5 → 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.5",
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[];
@@ -55,6 +68,17 @@ export interface AnalyzeData {
55
68
  * not as the 0.5 placeholder.
56
69
  */
57
70
  hasModel: boolean;
71
+ /**
72
+ * True when the Kalshi market has a `last_price` (it has actually traded).
73
+ * When false, market_prob/edge/Kelly cannot be computed; the formatter
74
+ * renders "--" for those fields and notes that the report was generated
75
+ * without a tradeable price reference.
76
+ *
77
+ * The Octagon report itself still loads — only the trading-side math is
78
+ * skipped. This is the common case for newly-listed event-level markets
79
+ * (e.g. World Cup quarterfinal contracts before the bracket is set).
80
+ */
81
+ hasMarketPrice: boolean;
58
82
  reportAge: string | null;
59
83
  reportId: string;
60
84
  rawReport: string;
@@ -168,10 +192,16 @@ export async function handleAnalyze(
168
192
  const market = await resolveMarket(ticker);
169
193
  const resolvedTicker = market.ticker;
170
194
  const eventTicker = market.event_ticker;
171
- const marketProb = parseMarketProb(market);
172
- if (marketProb === null) {
173
- throw new Error(`No last traded price for ${resolvedTicker} — market may have no trades yet.`);
174
- }
195
+ const rawMarketProb = parseMarketProb(market);
196
+ const hasMarketPrice = rawMarketProb !== null;
197
+
198
+ // Many event-level Kalshi tickers exist before any contract has traded
199
+ // (World Cup brackets, FOMC date ladders, IPO timing — there's no
200
+ // last_price until someone takes a side). The Octagon report path doesn't
201
+ // need market_prob — only the edge / Kelly / risk-gate math does. So we
202
+ // keep going with a neutral fallback and mark hasMarketPrice = false so
203
+ // the formatter renders "--" for the trading-side fields.
204
+ const marketProb = hasMarketPrice ? rawMarketProb! : 0.5;
175
205
 
176
206
  const invoker = createOctagonInvoker();
177
207
  const octagonClient = new OctagonClient(invoker, db, auditTrail);
@@ -211,6 +241,16 @@ export async function handleAnalyze(
211
241
  const latestDbReport = getLatestReport(db, resolvedTicker);
212
242
  const reportAge = latestDbReport ? formatAge(latestDbReport.fetched_at) : null;
213
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
+
214
254
  const snapshot = edgeComputer.computeEdge(resolvedTicker, report, marketProb);
215
255
 
216
256
  // Persist edge
@@ -230,30 +270,39 @@ export async function handleAnalyze(
230
270
  confidence: snapshot.confidence,
231
271
  });
232
272
 
233
- // Kelly sizing — wrapped in try/catch for demo mode (portfolio endpoints may 401)
273
+ // Kelly sizing — wrapped in try/catch for demo mode (portfolio endpoints may 401).
274
+ // Skip Kelly entirely when there's no last_price: any sizing computed from
275
+ // a 50% market_prob fallback would be meaningless.
276
+ const emptyKelly: KellyResult = {
277
+ side: snapshot.edge >= 0 ? 'yes' : 'no',
278
+ fraction: 0,
279
+ adjustedFraction: 0,
280
+ contracts: 0,
281
+ dollarAmountCents: 0,
282
+ entryPriceCents: 0,
283
+ availableBankroll: 0,
284
+ openExposure: 0,
285
+ cashBalance: 0,
286
+ portfolioValue: 0,
287
+ liquidityAdjusted: false,
288
+ };
234
289
  let kelly: KellyResult;
235
- try {
236
- kelly = await kellySize({
237
- edge: snapshot.edge,
238
- marketProb,
239
- market,
240
- multiplier: getBotSetting('risk.kelly_multiplier') as number | undefined,
241
- minEdgeThreshold: getBotSetting('risk.min_edge_threshold') as number | undefined,
242
- });
243
- } catch {
244
- kelly = {
245
- side: snapshot.edge >= 0 ? 'yes' : 'no',
246
- fraction: 0,
247
- adjustedFraction: 0,
248
- contracts: 0,
249
- dollarAmountCents: 0,
250
- entryPriceCents: 0,
251
- availableBankroll: 0,
252
- openExposure: 0,
253
- cashBalance: 0,
254
- portfolioValue: 0,
255
- liquidityAdjusted: false,
256
- };
290
+ if (!canComputeEdge) {
291
+ // Either no model coverage or no last_price → any sizing computed from
292
+ // a placeholder modelProb / marketProb would be meaningless.
293
+ kelly = emptyKelly;
294
+ } else {
295
+ try {
296
+ kelly = await kellySize({
297
+ edge: snapshot.edge,
298
+ marketProb,
299
+ market,
300
+ multiplier: getBotSetting('risk.kelly_multiplier') as number | undefined,
301
+ minEdgeThreshold: getBotSetting('risk.min_edge_threshold') as number | undefined,
302
+ });
303
+ } catch {
304
+ kelly = { ...emptyKelly };
305
+ }
257
306
  }
258
307
 
259
308
  // Risk gate
@@ -292,7 +341,17 @@ export async function handleAnalyze(
292
341
  const entryPrice = (snapshot.edge > 0 ? yesAsk : noAsk);
293
342
 
294
343
  let signal: string;
295
- if (existingPosition) {
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})`;
354
+ } else if (existingPosition) {
296
355
  const holdDir = existingPosition.direction.toUpperCase();
297
356
  const edgeReversed =
298
357
  (existingPosition.direction === 'yes' && snapshot.edge < -0.03) ||
@@ -338,11 +397,9 @@ export async function handleAnalyze(
338
397
  ? latestDbReport.analysis_last_updated.replace('T', ' ').slice(0, 16) + ' UTC'
339
398
  : null;
340
399
 
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);
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.
346
403
 
347
404
  // staleUpstream = user asked for --refresh but Octagon's upstream model run
348
405
  // timestamp didn't move. Cache fetch time bumped, but the underlying report
@@ -353,6 +410,11 @@ export async function handleAnalyze(
353
410
  && latestDbReport?.analysis_last_updated != null
354
411
  && preRefreshAnalysis === latestDbReport.analysis_last_updated;
355
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.)
356
418
  return {
357
419
  ticker: resolvedTicker,
358
420
  eventTicker,
@@ -362,12 +424,13 @@ export async function handleAnalyze(
362
424
  modelRunAt,
363
425
  staleUpstream,
364
426
  hasModel,
365
- modelProb: snapshot.modelProb,
366
- marketProb,
367
- edge: snapshot.edge,
368
- edgePp,
369
- confidence: snapshot.confidence,
370
- mispricingSignal,
427
+ hasMarketPrice,
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,
371
434
  signal,
372
435
  drivers: snapshot.drivers,
373
436
  catalysts: snapshot.catalysts,
@@ -404,19 +467,25 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
404
467
  }
405
468
  lines.push('');
406
469
 
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)}%)`);
470
+ // Edge & Probabilities. Two independent reasons a field may be unavailable:
471
+ // hasModel=false → Octagon has no model scoring Model Prob shows "--"
472
+ // hasMarketPrice=false Kalshi market has no last_price Market Prob shows "--"
473
+ // Edge needs both. Either being false means edge/confidence/mispricing
474
+ // render "--" — we never show a number derived from a placeholder.
475
+ const modelStr = data.hasModel && data.modelProb != null
476
+ ? `${(data.modelProb * 100).toFixed(1)}%`
477
+ : `-- (no Octagon model coverage for this market)`;
478
+ const marketStr = data.hasMarketPrice && data.marketProb != null
479
+ ? `${(data.marketProb * 100).toFixed(1)}%`
480
+ : `-- (no last traded price — market hasn't traded yet)`;
481
+ const canComputeEdge = data.hasModel && data.hasMarketPrice && data.edge != null;
482
+ lines.push(` Model Prob: ${modelStr}`);
483
+ lines.push(` Market Prob: ${marketStr}`);
484
+ if (canComputeEdge) {
485
+ lines.push(` Edge: ${data.edgePp} (${(data.edge! * 100).toFixed(1)}%)`);
415
486
  lines.push(` Confidence: ${data.confidence}`);
416
487
  lines.push(` Mispricing: ${data.mispricingSignal}`);
417
488
  } else {
418
- lines.push(` Model Prob: -- (no Octagon model coverage for this market)`);
419
- lines.push(` Market Prob: ${(data.marketProb * 100).toFixed(1)}%`);
420
489
  lines.push(` Edge: --`);
421
490
  lines.push(` Confidence: --`);
422
491
  lines.push(` Mispricing: --`);
@@ -449,22 +518,26 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
449
518
  lines.push('');
450
519
  }
451
520
 
452
- // Kelly Sizing
521
+ // Position Sizing — only meaningful when there's a tradeable price.
453
522
  lines.push(' Position Sizing (Half-Kelly):');
454
- lines.push(` Side: ${data.kelly.side.toUpperCase()}`);
455
- lines.push(` Cash Balance: $${(data.kelly.cashBalance / 100).toFixed(2)}`);
456
- lines.push(` Open Exposure: $${(data.kelly.openExposure / 100).toFixed(2)}`);
457
- lines.push(` Available: $${(data.kelly.availableBankroll / 100).toFixed(2)}`);
458
- lines.push(` Contracts: ${data.kelly.contracts}`);
459
- lines.push(` Dollar Amount: $${(data.kelly.dollarAmountCents / 100).toFixed(2)}`);
460
- lines.push(` Entry Price: ${data.kelly.entryPriceCents}¢`);
461
- lines.push(` Kelly f*: ${(data.kelly.fraction * 100).toFixed(1)}%`);
462
- lines.push(` Adjusted f: ${(data.kelly.adjustedFraction * 100).toFixed(1)}%`);
463
- if (data.kelly.liquidityAdjusted) {
464
- lines.push(' Liquidity-adjusted (wide spread or low volume)');
465
- }
466
- if (data.kelly.skippedReason) {
467
- lines.push(`${data.kelly.skippedReason}`);
523
+ if (!data.hasMarketPrice) {
524
+ lines.push(' Skipped market has no last traded price; no sizing reference available.');
525
+ } else {
526
+ lines.push(` Side: ${data.kelly.side.toUpperCase()}`);
527
+ lines.push(` Cash Balance: $${(data.kelly.cashBalance / 100).toFixed(2)}`);
528
+ lines.push(` Open Exposure: $${(data.kelly.openExposure / 100).toFixed(2)}`);
529
+ lines.push(` Available: $${(data.kelly.availableBankroll / 100).toFixed(2)}`);
530
+ lines.push(` Contracts: ${data.kelly.contracts}`);
531
+ lines.push(` Dollar Amount: $${(data.kelly.dollarAmountCents / 100).toFixed(2)}`);
532
+ lines.push(` Entry Price: ${data.kelly.entryPriceCents}¢`);
533
+ lines.push(` Kelly f*: ${(data.kelly.fraction * 100).toFixed(1)}%`);
534
+ lines.push(` Adjusted f: ${(data.kelly.adjustedFraction * 100).toFixed(1)}%`);
535
+ if (data.kelly.liquidityAdjusted) {
536
+ lines.push('Liquidity-adjusted (wide spread or low volume)');
537
+ }
538
+ if (data.kelly.skippedReason) {
539
+ lines.push(` ⚠ ${data.kelly.skippedReason}`);
540
+ }
468
541
  }
469
542
  lines.push('');
470
543
 
@@ -586,8 +659,12 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
586
659
  // Close position: sell what we hold
587
660
  const sellSide = data.existingPosition.direction;
588
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;
589
666
  const closePrice = data.closePriceCents ?? Math.round(
590
- (sellSide === 'yes' ? data.marketProb : 1 - data.marketProb) * 100
667
+ (sellSide === 'yes' ? mp : 1 - mp) * 100
591
668
  );
592
669
 
593
670
  console.log(` Signal: SELL ${sellSize} ${sellSide.toUpperCase()} @ ${closePrice}¢ (close position)`);
@@ -663,7 +740,7 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
663
740
  break;
664
741
  }
665
742
 
666
- const side = data.edge > 0 ? 'yes' : 'no';
743
+ const side = (data.edge ?? 0) > 0 ? 'yes' : 'no';
667
744
  const price = data.kelly.entryPriceCents;
668
745
  console.log(` Signal: BUY ${data.kelly.contracts} ${side.toUpperCase()} @ ${price}¢`);
669
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,