kalshi-trading-bot-cli 2.1.6 → 2.1.8

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.
@@ -40,6 +40,10 @@ export interface ParsedArgs {
40
40
  category?: string;
41
41
  limit?: number;
42
42
  exportPath?: string;
43
+ /** Backtest universe source — 'api' (default) or 'local'. */
44
+ backtestUniverse?: 'api' | 'local';
45
+ /** Backtest fee model — 'none' (default), 'taker', or 'maker'. */
46
+ backtestFees?: 'none' | 'taker' | 'maker';
43
47
  minVolume?: number;
44
48
  minPrice?: number;
45
49
  maxPrice?: number;
@@ -98,6 +102,8 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
98
102
  let category: string | undefined;
99
103
  let limit: number | undefined;
100
104
  let exportPath: string | undefined;
105
+ let backtestUniverse: 'api' | 'local' | undefined;
106
+ let backtestFees: 'none' | 'taker' | 'maker' | undefined;
101
107
  let maxAge: number | undefined;
102
108
  let minVolume: number | undefined;
103
109
  let minPrice: number | undefined;
@@ -237,6 +243,22 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
237
243
  } else if (arg === '--export') {
238
244
  const val = argv[++i];
239
245
  if (val != null) { exportPath = val; } else { parseErrors.push('--export requires a value'); }
246
+ } else if (arg === '--universe') {
247
+ if (i + 1 >= argv.length) {
248
+ parseErrors.push('--universe requires a value (expected "api" or "local")');
249
+ } else {
250
+ const val = argv[++i];
251
+ if (val === 'api' || val === 'local') { backtestUniverse = val; }
252
+ else { parseErrors.push(`Invalid --universe value: "${val}" (expected "api" or "local")`); }
253
+ }
254
+ } else if (arg === '--fees') {
255
+ if (i + 1 >= argv.length) {
256
+ parseErrors.push('--fees requires a value (expected "none", "taker", or "maker")');
257
+ } else {
258
+ const val = argv[++i];
259
+ if (val === 'none' || val === 'taker' || val === 'maker') { backtestFees = val; }
260
+ else { parseErrors.push(`Invalid --fees value: "${val}" (expected "none", "taker", or "maker")`); }
261
+ }
240
262
  } else if (arg === '--max-age') {
241
263
  const raw = argv[++i];
242
264
  if (raw != null) {
@@ -431,7 +453,7 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
431
453
  return {
432
454
  subcommand, positionalArgs, json, theme, ticker, interval, since, minConfidence, minEdge, side,
433
455
  live, refresh, report, dryRun, verbose, performance, resolved, unresolved, days, maxAge, category,
434
- limit, exportPath, minVolume, minPrice, maxPrice,
456
+ limit, exportPath, backtestUniverse, backtestFees, minVolume, minPrice, maxPrice,
435
457
  topK, behavioral, ranked, labelContains, closeBefore, windowDays, correlationInterval, timeframe,
436
458
  weights, bankroll, kellyMultiplier, n, maxPerCluster, maxCorrelation, minReturn, seriesTicker,
437
459
  sortBy, probabilities, tickers, query, showCluster, aggregateBy, activeOnly,
@@ -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,