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.
- package/package.json +1 -1
- package/src/backtest/discovery.ts +110 -8
- package/src/backtest/metrics.ts +235 -8
- package/src/backtest/renderer.ts +91 -3
- package/src/backtest/types.ts +92 -1
- package/src/commands/analyze.ts +140 -39
- package/src/commands/backtest.ts +90 -29
- package/src/commands/help.ts +9 -3
- package/src/commands/index.ts +8 -0
- package/src/commands/parse-args.ts +23 -1
- package/src/commands/review.ts +28 -17
- package/src/scan/octagon-client.ts +130 -2
- package/src/tools/v2/portfolio-review.ts +1 -1
|
@@ -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,
|
package/src/commands/review.ts
CHANGED
|
@@ -10,9 +10,12 @@ export interface PositionReview {
|
|
|
10
10
|
direction: 'yes' | 'no';
|
|
11
11
|
size: number;
|
|
12
12
|
entryPrice: number | null;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
66
|
-
modelProb:
|
|
67
|
-
edge:
|
|
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 {
|
|
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 (
|
|
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
|
|
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
|
-
:
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
421
|
-
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,
|