kalshi-trading-bot-cli 2.1.4 → 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.
- package/README.md +33 -0
- package/package.json +1 -1
- package/src/cli.ts +1 -0
- package/src/commands/analyze-batch.ts +101 -0
- package/src/commands/analyze.ts +99 -17
- package/src/commands/basket.ts +15 -8
- package/src/commands/dispatch.ts +68 -0
- package/src/commands/help.ts +46 -7
- package/src/commands/parse-args.ts +10 -1
- package/src/db/octagon-cache.ts +2 -1
- package/src/db/schema.ts +5 -0
- package/src/scan/octagon-kalshi-api.ts +6 -6
- package/src/scan/octagon-prefetch.ts +48 -27
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ That's it — no clone, no install. The setup wizard runs automatically on first
|
|
|
28
28
|
|
|
29
29
|
Prefer a global install? `bun add -g kalshi-trading-bot-cli` then run `kalshi`.
|
|
30
30
|
|
|
31
|
+
> **Scripting and agent use** — for parallel invocations, `--json` consumers, or anything that pipes our output: **install globally and use the `kalshi` binary**, not `bunx`. See [Scripting & Parallel Use](#scripting--parallel-use) below for the gory details.
|
|
32
|
+
|
|
31
33
|
Or work from a clone:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
@@ -363,6 +365,37 @@ UNRESOLVED (105 markets)
|
|
|
363
365
|
|
|
364
366
|
Set `KALSHI_USE_DEMO=true` in your `.env` to use Kalshi's demo environment. All trades are simulated — no real money at risk.
|
|
365
367
|
|
|
368
|
+
## Scripting & Parallel Use
|
|
369
|
+
|
|
370
|
+
The `bunx kalshi-trading-bot-cli@latest …` form is great for one-off interactive use, but it has two gotchas when you script against it:
|
|
371
|
+
|
|
372
|
+
**1. Install chatter leaks into `--json` output.** Bun prints lines like `Resolving dependencies` and `Saved lockfile` to stdout *before* our CLI runs, which corrupts JSON pipelines.
|
|
373
|
+
|
|
374
|
+
**2. Parallel invocations race on the install cache.** Running multiple `bunx kalshi-trading-bot-cli@latest …` calls in parallel can fail with `Failed to link …: EEXIST` and `could not determine executable`. See [oven-sh/bun#12917](https://github.com/oven-sh/bun/issues/12917) for current upstream status — `bunx`'s ephemeral install path isn't covered by the `bun install` global-store fix, so the workarounds below are still the recommended path.
|
|
375
|
+
|
|
376
|
+
**Recommended pattern for scripts and agents:**
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
# Install once globally — this is parallel-safe and emits no install chatter on subsequent runs
|
|
380
|
+
bun add -g kalshi-trading-bot-cli
|
|
381
|
+
|
|
382
|
+
# Then use the `kalshi` binary directly — fan out as much as you want
|
|
383
|
+
parallel -j 30 'kalshi analyze {} --json > {}.json' ::: KXBTC-26APR-B95000 KXETH-… …
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**If you must use `bunx`:**
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
# --silent suppresses Bun's install chatter (keeps your CLI's stdout clean)
|
|
390
|
+
bunx --silent kalshi-trading-bot-cli@latest analyze KXBTC-26APR-B95000 --json
|
|
391
|
+
|
|
392
|
+
# For parallel bunx, pre-warm the cache serially first to dodge the link race
|
|
393
|
+
bunx --silent kalshi-trading-bot-cli@latest --version # one-shot, populates cache
|
|
394
|
+
parallel -j 30 'bunx --silent kalshi-trading-bot-cli@latest analyze {} --json' ::: KXBTC-… …
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Keep `@latest` if you want auto-update on every invocation; drop it after the first run if you've pinned a version and want speed.
|
|
398
|
+
|
|
366
399
|
## Agent Usage
|
|
367
400
|
|
|
368
401
|
Every command supports `--json` for structured output, making the bot easy to orchestrate from scripts or AI agents.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -371,6 +371,7 @@ export async function runCli(options?: { forceSetup?: boolean }) {
|
|
|
371
371
|
{ value: 'cancel', label: 'cancel', description: 'Cancel an order' },
|
|
372
372
|
{ value: 'backtest', label: 'backtest', description: 'Model accuracy & edge scanner' },
|
|
373
373
|
{ value: 'help', label: 'help', description: 'Show help' },
|
|
374
|
+
{ value: 'scripting', label: 'scripting', description: 'Tips for agents, pipelines, parallel use' },
|
|
374
375
|
{ value: 'setup', label: 'setup', description: 'Re-run setup wizard' },
|
|
375
376
|
];
|
|
376
377
|
if (!typed) return topics;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch analyze — fan out across multiple tickers in a single Octagon call.
|
|
3
|
+
*
|
|
4
|
+
* The full per-ticker `analyze` does deep work (resolve market, run risk gate,
|
|
5
|
+
* Kelly-size, etc.) and is slow because it makes a fresh Octagon call per
|
|
6
|
+
* ticker. When the user just wants a model-edge readout across N tickers,
|
|
7
|
+
* `POST /kalshi/markets/edge` returns them all in one shot — orders of
|
|
8
|
+
* magnitude faster.
|
|
9
|
+
*
|
|
10
|
+
* This is a complement to `analyze <ticker>`, not a replacement. Use it when
|
|
11
|
+
* you need:
|
|
12
|
+
* - quick edge ladder across 5-100 tickers
|
|
13
|
+
* - JSON output for downstream scripting
|
|
14
|
+
* - refresh without paying per-ticker Octagon round-trips
|
|
15
|
+
*/
|
|
16
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
17
|
+
import type { CLIResponse } from './json.js';
|
|
18
|
+
import { getMarketsEdge, type PerTickerEdgeRow } from '../scan/octagon-kalshi-api.js';
|
|
19
|
+
import { formatTable } from './scan-formatters.js';
|
|
20
|
+
|
|
21
|
+
function truncate(s: string, max: number): string {
|
|
22
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fmtVol(v: number | null | undefined): string {
|
|
26
|
+
if (v === null || v === undefined) return '-';
|
|
27
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
28
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
|
29
|
+
return v.toFixed(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AnalyzeBatchResult {
|
|
33
|
+
run_id: string;
|
|
34
|
+
captured_at: string;
|
|
35
|
+
data: PerTickerEdgeRow[];
|
|
36
|
+
scored: number;
|
|
37
|
+
unscored: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function handleAnalyzeBatch(tickers: string[]): Promise<CLIResponse<AnalyzeBatchResult>> {
|
|
41
|
+
const cleaned = Array.from(new Set(
|
|
42
|
+
tickers.map((t) => t.trim().toUpperCase()).filter(Boolean),
|
|
43
|
+
));
|
|
44
|
+
if (cleaned.length === 0) {
|
|
45
|
+
return wrapError('analyze', 'NO_TICKERS', 'analyze (batch): supply 1+ tickers');
|
|
46
|
+
}
|
|
47
|
+
if (cleaned.length > 100) {
|
|
48
|
+
return wrapError('analyze', 'TOO_MANY_TICKERS', 'analyze (batch): at most 100 tickers per call');
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const resp = await getMarketsEdge({ tickers: cleaned });
|
|
52
|
+
const scored = resp.data.filter((r) => r.status === 'scored').length;
|
|
53
|
+
return wrapSuccess('analyze', {
|
|
54
|
+
run_id: resp.run_id,
|
|
55
|
+
captured_at: resp.captured_at,
|
|
56
|
+
data: resp.data,
|
|
57
|
+
scored,
|
|
58
|
+
unscored: resp.data.length - scored,
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
return wrapError('analyze', 'OCTAGON_ERROR', message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatAnalyzeBatchHuman(data: AnalyzeBatchResult): string {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
lines.push(`Batch analyze — ${data.scored}/${data.data.length} scored in run ${data.run_id.slice(0, 8)} (${data.captured_at.slice(0, 16).replace('T', ' ')})`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
if (data.data.length === 0) {
|
|
71
|
+
lines.push('No tickers returned.');
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
const rows: string[][] = data.data.map((r) => {
|
|
75
|
+
const status = r.status === 'scored' ? '✓' : '—';
|
|
76
|
+
const modelStr = r.model_probability == null ? '-' : `${(r.model_probability * 100).toFixed(1)}%`;
|
|
77
|
+
const marketStr = r.market_probability == null ? '-' : `${(r.market_probability * 100).toFixed(1)}%`;
|
|
78
|
+
const edgeStr = r.edge_pp == null ? '-' : `${r.edge_pp > 0 ? '+' : ''}${r.edge_pp.toFixed(1)}pp`;
|
|
79
|
+
const erStr = r.expected_return == null ? '-' : `${(r.expected_return * 100).toFixed(1)}%`;
|
|
80
|
+
return [
|
|
81
|
+
r.input_ticker,
|
|
82
|
+
status,
|
|
83
|
+
truncate(r.title ?? '', 32),
|
|
84
|
+
r.series_category ?? '-',
|
|
85
|
+
modelStr,
|
|
86
|
+
marketStr,
|
|
87
|
+
edgeStr,
|
|
88
|
+
erStr,
|
|
89
|
+
fmtVol(r.total_volume),
|
|
90
|
+
];
|
|
91
|
+
});
|
|
92
|
+
lines.push(formatTable(
|
|
93
|
+
['Ticker', '?', 'Title', 'Category', 'Model', 'Market', 'Edge', 'Exp Ret', 'Volume'],
|
|
94
|
+
rows,
|
|
95
|
+
));
|
|
96
|
+
if (data.unscored > 0) {
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push(`${data.unscored} ticker(s) not scored in this Octagon run — try \`kalshi analyze <ticker> --refresh\` for a one-off deep call.`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
package/src/commands/analyze.ts
CHANGED
|
@@ -25,7 +25,16 @@ export interface AnalyzeData {
|
|
|
25
25
|
eventTicker: string;
|
|
26
26
|
title: string;
|
|
27
27
|
expirationTime: string | null;
|
|
28
|
-
|
|
28
|
+
/** Local timestamp when we last pulled the report from Octagon. */
|
|
29
|
+
refreshedAt: string | null;
|
|
30
|
+
/** Upstream Octagon model-run timestamp (Octagon's `analysis_last_updated`). */
|
|
31
|
+
modelRunAt: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* True when --refresh just ran but the upstream `analysis_last_updated`
|
|
34
|
+
* is unchanged from before the refresh. Tells the user we bumped the
|
|
35
|
+
* cache time but didn't get a newer underlying report from Octagon.
|
|
36
|
+
*/
|
|
37
|
+
staleUpstream: boolean;
|
|
29
38
|
modelProb: number;
|
|
30
39
|
marketProb: number;
|
|
31
40
|
edge: number;
|
|
@@ -40,6 +49,12 @@ export interface AnalyzeData {
|
|
|
40
49
|
riskGate: RiskGateResult;
|
|
41
50
|
liquidityGrade: string;
|
|
42
51
|
fromCache: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* True when Octagon has no model scoring for this market in the cached report.
|
|
54
|
+
* When true, model probability + edge fields should be rendered as "--",
|
|
55
|
+
* not as the 0.5 placeholder.
|
|
56
|
+
*/
|
|
57
|
+
hasModel: boolean;
|
|
43
58
|
reportAge: string | null;
|
|
44
59
|
reportId: string;
|
|
45
60
|
rawReport: string;
|
|
@@ -162,6 +177,15 @@ export async function handleAnalyze(
|
|
|
162
177
|
const octagonClient = new OctagonClient(invoker, db, auditTrail);
|
|
163
178
|
const edgeComputer = new EdgeComputer(db, auditTrail);
|
|
164
179
|
|
|
180
|
+
// Capture the upstream Octagon `analysis_last_updated` BEFORE the refresh
|
|
181
|
+
// so we can detect when --refresh re-fetches the same stale upstream
|
|
182
|
+
// report (cache fetch time bumped, but Octagon's underlying model run
|
|
183
|
+
// didn't move). This catches "stale upstream" cases where the user thinks
|
|
184
|
+
// they got fresh analysis but actually got the same body Octagon last
|
|
185
|
+
// generated weeks ago.
|
|
186
|
+
const preRefreshReport = refresh ? getLatestReport(db, resolvedTicker) : null;
|
|
187
|
+
const preRefreshAnalysis = preRefreshReport?.analysis_last_updated ?? null;
|
|
188
|
+
|
|
165
189
|
// Use cache by default; only refresh when explicitly requested
|
|
166
190
|
// Try prefetch first to avoid an individual Octagon API call
|
|
167
191
|
let variant: 'cache' | 'refresh' = refresh ? 'refresh' : 'cache';
|
|
@@ -302,17 +326,42 @@ export async function handleAnalyze(
|
|
|
302
326
|
risk_gate: gate.passed ? 'PASSED' : 'FAILED',
|
|
303
327
|
});
|
|
304
328
|
|
|
305
|
-
//
|
|
306
|
-
|
|
329
|
+
// Two distinct timestamps:
|
|
330
|
+
// refreshedAt = our local fetched_at (when WE pulled this from Octagon).
|
|
331
|
+
// This is the "Refreshed" date — what bumps when --refresh runs.
|
|
332
|
+
// modelRunAt = Octagon's analysis_last_updated (when their model last
|
|
333
|
+
// scored this event). Independent of our cache.
|
|
334
|
+
const refreshedAt = latestDbReport
|
|
307
335
|
? new Date(latestDbReport.fetched_at * 1000).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
|
308
336
|
: null;
|
|
337
|
+
const modelRunAt = latestDbReport?.analysis_last_updated
|
|
338
|
+
? latestDbReport.analysis_last_updated.replace('T', ' ').slice(0, 16) + ' UTC'
|
|
339
|
+
: null;
|
|
340
|
+
|
|
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);
|
|
346
|
+
|
|
347
|
+
// staleUpstream = user asked for --refresh but Octagon's upstream model run
|
|
348
|
+
// timestamp didn't move. Cache fetch time bumped, but the underlying report
|
|
349
|
+
// body is the same one Octagon previously generated. The user wanted fresh
|
|
350
|
+
// analysis; they got an unchanged stale one.
|
|
351
|
+
const staleUpstream = refresh
|
|
352
|
+
&& preRefreshAnalysis != null
|
|
353
|
+
&& latestDbReport?.analysis_last_updated != null
|
|
354
|
+
&& preRefreshAnalysis === latestDbReport.analysis_last_updated;
|
|
309
355
|
|
|
310
356
|
return {
|
|
311
357
|
ticker: resolvedTicker,
|
|
312
358
|
eventTicker,
|
|
313
359
|
title: market.title || market.subtitle || resolvedTicker,
|
|
314
360
|
expirationTime: market.expiration_time || market.expected_expiration_time || market.close_time || null,
|
|
315
|
-
|
|
361
|
+
refreshedAt,
|
|
362
|
+
modelRunAt,
|
|
363
|
+
staleUpstream,
|
|
364
|
+
hasModel,
|
|
316
365
|
modelProb: snapshot.modelProb,
|
|
317
366
|
marketProb,
|
|
318
367
|
edge: snapshot.edge,
|
|
@@ -355,12 +404,23 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
|
|
|
355
404
|
}
|
|
356
405
|
lines.push('');
|
|
357
406
|
|
|
358
|
-
// Edge & Probabilities
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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)}%)`);
|
|
415
|
+
lines.push(` Confidence: ${data.confidence}`);
|
|
416
|
+
lines.push(` Mispricing: ${data.mispricingSignal}`);
|
|
417
|
+
} else {
|
|
418
|
+
lines.push(` Model Prob: -- (no Octagon model coverage for this market)`);
|
|
419
|
+
lines.push(` Market Prob: ${(data.marketProb * 100).toFixed(1)}%`);
|
|
420
|
+
lines.push(` Edge: --`);
|
|
421
|
+
lines.push(` Confidence: --`);
|
|
422
|
+
lines.push(` Mispricing: --`);
|
|
423
|
+
}
|
|
364
424
|
lines.push('');
|
|
365
425
|
|
|
366
426
|
// Price Drivers
|
|
@@ -428,15 +488,37 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
|
|
|
428
488
|
}
|
|
429
489
|
}
|
|
430
490
|
|
|
431
|
-
//
|
|
491
|
+
// Two distinct timestamps — labeled with the wording users actually use:
|
|
492
|
+
//
|
|
493
|
+
// Cache refreshed at = when the bot last fetched/re-read the Octagon
|
|
494
|
+
// payload. This is what bumps on --refresh.
|
|
495
|
+
// Report body updated at = the upstream Octagon `analysis_last_updated`
|
|
496
|
+
// (matches the "Updated: …" date embedded in the
|
|
497
|
+
// report body text). Doesn't change unless
|
|
498
|
+
// Octagon re-runs their analysis upstream.
|
|
499
|
+
//
|
|
500
|
+
// If you're a bot/agent reading this output: use **Report body updated at**
|
|
501
|
+
// to decide whether the underlying analysis is fresh. The Cache refreshed
|
|
502
|
+
// at time only tells you when we last re-pulled the same body — it can be
|
|
503
|
+
// recent while the report itself is weeks old.
|
|
432
504
|
lines.push('');
|
|
433
|
-
if (data.
|
|
434
|
-
|
|
505
|
+
if (data.refreshedAt) {
|
|
506
|
+
const ageSuffix = data.reportAge ? ` (${data.reportAge})` : '';
|
|
507
|
+
lines.push(` Cache refreshed at: ${data.refreshedAt}${ageSuffix}`);
|
|
508
|
+
lines.push(` ↳ when the bot last fetched the Octagon payload; bumps on --refresh`);
|
|
509
|
+
}
|
|
510
|
+
if (data.modelRunAt) {
|
|
511
|
+
lines.push(` Report body updated at: ${data.modelRunAt}`);
|
|
512
|
+
lines.push(` ↳ when Octagon last ran the model upstream (the "Updated:" date inside the report)`);
|
|
513
|
+
}
|
|
514
|
+
if (data.staleUpstream) {
|
|
515
|
+
lines.push('');
|
|
516
|
+
lines.push(` ⚠ --refresh pulled the same Octagon report body. The cache fetch time bumped,`);
|
|
517
|
+
lines.push(` but Octagon's upstream analysis hasn't been re-run since ${data.modelRunAt ?? 'an earlier date'}.`);
|
|
518
|
+
lines.push(` Treat this as a stale upstream report — no newer analysis is available.`);
|
|
435
519
|
}
|
|
436
|
-
if (data.fromCache
|
|
437
|
-
lines.push(` Data: cached
|
|
438
|
-
} else if (data.fromCache) {
|
|
439
|
-
lines.push(` Data: cached. Run \`analyze ${data.ticker} --refresh\` for latest (costs 3 credits).`);
|
|
520
|
+
if (data.fromCache) {
|
|
521
|
+
lines.push(` Data: cached. Run \`analyze ${data.ticker} --refresh\` for the latest report (costs 3 credits).`);
|
|
440
522
|
} else {
|
|
441
523
|
lines.push(' Data: freshly generated.');
|
|
442
524
|
}
|
package/src/commands/basket.ts
CHANGED
|
@@ -32,6 +32,15 @@ function fmtPct(v: number): string {
|
|
|
32
32
|
return `${(v * 100).toFixed(1)}%`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Render a possibly-null numeric value to a fixed-decimal string with an
|
|
37
|
+
* optional prefix/suffix. Returns "--" for null/undefined or non-finite
|
|
38
|
+
* (NaN/Infinity) values so we never render "NaNpp" or "Infinity%".
|
|
39
|
+
*/
|
|
40
|
+
function num(v: number | null | undefined, decimals: number, prefix = '', suffix = ''): string {
|
|
41
|
+
return v == null || !Number.isFinite(v) ? '--' : `${prefix}${v.toFixed(decimals)}${suffix}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
function parseProbabilities(raw: string | undefined): Record<string, number> | undefined {
|
|
36
45
|
if (!raw) return undefined;
|
|
37
46
|
const map: Record<string, number> = {};
|
|
@@ -208,8 +217,6 @@ export function formatBasketBuildHuman(data: BasketBuildResponse): string {
|
|
|
208
217
|
if (data.legs.length === 0) {
|
|
209
218
|
lines.push('No legs selected.');
|
|
210
219
|
} else {
|
|
211
|
-
const num = (v: number | null | undefined, decimals: number, prefix = '', suffix = '') =>
|
|
212
|
-
v == null ? '-' : `${prefix}${v.toFixed(decimals)}${suffix}`;
|
|
213
220
|
const rows: string[][] = data.legs.map((l) => [
|
|
214
221
|
l.market_ticker,
|
|
215
222
|
truncate(l.title, 35),
|
|
@@ -430,12 +437,12 @@ export function formatBasketSizeHuman(data: BasketSizeResponse): string {
|
|
|
430
437
|
const rows: string[][] = data.legs.map((l) => [
|
|
431
438
|
l.market_ticker,
|
|
432
439
|
l.side.toUpperCase(),
|
|
433
|
-
l.price
|
|
434
|
-
|
|
435
|
-
`${l.edge_pp
|
|
436
|
-
l.kelly_fraction
|
|
437
|
-
l.weight
|
|
438
|
-
|
|
440
|
+
num(l.price, 2),
|
|
441
|
+
num(l.model_probability != null ? l.model_probability * 100 : null, 1, '', '%'),
|
|
442
|
+
!Number.isFinite(l.edge_pp as number) ? '--' : `${(l.edge_pp as number) > 0 ? '+' : ''}${(l.edge_pp as number).toFixed(1)}pp`,
|
|
443
|
+
num(l.kelly_fraction, 3),
|
|
444
|
+
num(l.weight, 3),
|
|
445
|
+
num(l.notional_usd, 2, '$'),
|
|
439
446
|
]);
|
|
440
447
|
lines.push(formatTable(['Ticker', 'Side', 'Price', 'Model%', 'Edge', 'Kelly', 'Weight', 'Notional'], rows));
|
|
441
448
|
return lines.join('\n');
|
package/src/commands/dispatch.ts
CHANGED
|
@@ -91,9 +91,56 @@ function modeFlagsFor(canonical: Subcommand, args: ParsedArgs): Record<string, s
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
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
|
+
|
|
94
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
|
+
|
|
95
141
|
const { subcommand, json } = args;
|
|
96
142
|
const resolved = resolveAlias(subcommand, args.positionalArgs);
|
|
143
|
+
await maybeEmitBunxHint(args);
|
|
97
144
|
trackEvent('cli_command', {
|
|
98
145
|
command: resolved.canonical,
|
|
99
146
|
subview: resolved.subview ?? '',
|
|
@@ -307,6 +354,27 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
|
|
|
307
354
|
|
|
308
355
|
// ─── analyze ───────────────────────────────────────────────────────
|
|
309
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
|
+
}
|
|
310
378
|
const ticker = args.positionalArgs[0];
|
|
311
379
|
if (!ticker) {
|
|
312
380
|
const errResp = wrapError('analyze', 'MISSING_TICKER', 'Usage: analyze <ticker> [--refresh] [--report]');
|
package/src/commands/help.ts
CHANGED
|
@@ -24,9 +24,10 @@ Search flags (server-side path):
|
|
|
24
24
|
--category <name> Filter by category
|
|
25
25
|
--series <ticker> Filter to a series
|
|
26
26
|
--min-volume <n> Floor on 24h volume
|
|
27
|
-
--close-before <iso> Only markets closing before
|
|
27
|
+
--close-before <iso> Only markets closing before this timestamp
|
|
28
|
+
--days-to-close <n> Shortcut: only markets closing in the next N days
|
|
28
29
|
--limit <n> Page size (default 30)
|
|
29
|
-
--sort-by <key> volume_24h | close_time | last_price (
|
|
30
|
+
--sort-by <key> volume_24h | close_time | last_price (server-side sort)
|
|
30
31
|
--aggregate-by series Roll up results by series (calls series rollup)
|
|
31
32
|
--active-only Drop non-active markets (defensive; the live universe is active by default)
|
|
32
33
|
|
|
@@ -51,12 +52,22 @@ Flags:
|
|
|
51
52
|
|
|
52
53
|
analyze: `**${p}analyze** — Deep market analysis
|
|
53
54
|
|
|
54
|
-
${p}analyze <ticker>
|
|
55
|
-
${p}analyze <ticker> ${ctx === 'cli' ? '--' : ''}refresh
|
|
56
|
-
|
|
55
|
+
${p}analyze <ticker> Full analysis: edge, drivers, catalysts, Kelly sizing
|
|
56
|
+
${p}analyze <ticker> ${ctx === 'cli' ? '--' : ''}refresh Force fresh Octagon report
|
|
57
|
+
|
|
58
|
+
Batch mode (one Octagon round-trip instead of N):
|
|
59
|
+
${p}analyze KX-A KX-B KX-C Edge readout across 2-100 tickers
|
|
60
|
+
${p}analyze --tickers KX-A,KX-B,KX-C Same, comma-separated
|
|
61
|
+
${p}analyze KX-A KX-B KX-C --json For pipelines / scripting
|
|
62
|
+
|
|
63
|
+
The batch mode hits POST /kalshi/markets/edge in one call and returns
|
|
64
|
+
model_probability, market_probability, edge_pp, expected_return per ticker.
|
|
65
|
+
Use single-ticker mode when you need the full deep-analysis pipeline
|
|
66
|
+
(drivers, catalysts, Kelly sizing, risk gate).${ctx === 'cli' ? `
|
|
67
|
+
|
|
57
68
|
Legacy aliases (still work):
|
|
58
|
-
${p}edge [--ticker X]
|
|
59
|
-
${p}edge --since <date>
|
|
69
|
+
${p}edge [--ticker X] Edge history / snapshots (default: last 24h)
|
|
70
|
+
${p}edge --since <date> Edges since date (e.g. 2026-03-01)` : ''}`,
|
|
60
71
|
|
|
61
72
|
watch: `**${p}watch** — Live monitoring
|
|
62
73
|
|
|
@@ -138,6 +149,34 @@ ${p}init Launch the TUI with the setup wizard open
|
|
|
138
149
|
${p}help Show all commands
|
|
139
150
|
${p}help <command> Show detailed help for a command`,
|
|
140
151
|
|
|
152
|
+
scripting: `**Scripting & Parallel Use** — for agents, pipelines, and parallel invocations
|
|
153
|
+
|
|
154
|
+
The \`bunx kalshi-trading-bot-cli@latest …\` form is convenient for one-off use
|
|
155
|
+
but has two gotchas under scripting:
|
|
156
|
+
|
|
157
|
+
1. Bun's install chatter ("Resolving dependencies", "Saved lockfile") leaks
|
|
158
|
+
into stdout before our CLI runs, corrupting JSON pipelines.
|
|
159
|
+
2. Parallel \`bunx\` invocations race on the install cache and fail with
|
|
160
|
+
"Failed to link …: EEXIST" / "could not determine executable".
|
|
161
|
+
See oven-sh/bun#12917 for upstream status.
|
|
162
|
+
|
|
163
|
+
**Recommended for scripts and agents:**
|
|
164
|
+
|
|
165
|
+
bun add -g kalshi-trading-bot-cli # install once; emits no chatter on subsequent runs
|
|
166
|
+
parallel -j 30 'kalshi analyze {} --json' ::: TICKER1 TICKER2 …
|
|
167
|
+
|
|
168
|
+
**If you must use bunx:**
|
|
169
|
+
|
|
170
|
+
bunx --silent kalshi-trading-bot-cli@latest analyze KX-A --json
|
|
171
|
+
^^^^^^^^ suppresses install chatter; keeps our stdout clean
|
|
172
|
+
|
|
173
|
+
For parallel bunx, pre-warm the cache serially before fanning out:
|
|
174
|
+
|
|
175
|
+
bunx --silent kalshi-trading-bot-cli@latest --version # one-shot, warms cache
|
|
176
|
+
parallel -j 30 'bunx --silent kalshi-trading-bot-cli@latest analyze {} --json' ::: …
|
|
177
|
+
|
|
178
|
+
See README → Scripting & Parallel Use for the full picture.`,
|
|
179
|
+
|
|
141
180
|
similar: `**${p}similar** — Semantic market search (Octagon-powered)
|
|
142
181
|
|
|
143
182
|
${p}similar <ticker> Markets near this ticker by embedding distance
|
|
@@ -71,6 +71,7 @@ export interface ParsedArgs {
|
|
|
71
71
|
sides?: string; // comma-separated yes/no per ticker for correlate
|
|
72
72
|
cells: boolean; // include_cell_detail for correlate
|
|
73
73
|
autoProbs: boolean; // basket size: auto-fetch leg probabilities via markets/edge
|
|
74
|
+
daysToClose?: number; // ergonomic shortcut: close_before = now + N days
|
|
74
75
|
parseErrors: string[];
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -129,6 +130,7 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
|
|
|
129
130
|
let sides: string | undefined;
|
|
130
131
|
let cells = false;
|
|
131
132
|
let autoProbs = false;
|
|
133
|
+
let daysToClose: number | undefined;
|
|
132
134
|
|
|
133
135
|
for (let i = 0; i < argv.length; i++) {
|
|
134
136
|
const arg = argv[i];
|
|
@@ -397,6 +399,13 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
|
|
|
397
399
|
cells = true;
|
|
398
400
|
} else if (arg === '--auto-probs') {
|
|
399
401
|
autoProbs = true;
|
|
402
|
+
} else if (arg === '--days-to-close' || arg === '--max-dte') {
|
|
403
|
+
const raw = argv[++i];
|
|
404
|
+
if (raw != null) {
|
|
405
|
+
const numeric = Number(raw);
|
|
406
|
+
if (Number.isFinite(numeric) && Number.isInteger(numeric) && numeric > 0) { daysToClose = numeric; }
|
|
407
|
+
else { parseErrors.push(`Invalid ${arg} value: "${raw}" (expected a positive integer)`); }
|
|
408
|
+
} else { parseErrors.push(`${arg} requires a value`); }
|
|
400
409
|
} else if (arg.startsWith('--')) {
|
|
401
410
|
parseErrors.push(`Unknown flag: ${arg}`);
|
|
402
411
|
} else {
|
|
@@ -426,7 +435,7 @@ export function parseArgs(argv: string[] = process.argv.slice(2)): ParsedArgs {
|
|
|
426
435
|
topK, behavioral, ranked, labelContains, closeBefore, windowDays, correlationInterval, timeframe,
|
|
427
436
|
weights, bankroll, kellyMultiplier, n, maxPerCluster, maxCorrelation, minReturn, seriesTicker,
|
|
428
437
|
sortBy, probabilities, tickers, query, showCluster, aggregateBy, activeOnly,
|
|
429
|
-
seriesPrefix, sides, cells, autoProbs,
|
|
438
|
+
seriesPrefix, sides, cells, autoProbs, daysToClose,
|
|
430
439
|
parseErrors,
|
|
431
440
|
};
|
|
432
441
|
}
|
package/src/db/octagon-cache.ts
CHANGED
|
@@ -15,8 +15,9 @@ export interface OctagonReport {
|
|
|
15
15
|
raw_response?: string | null;
|
|
16
16
|
model_accuracy?: number | null;
|
|
17
17
|
variant_used?: string | null;
|
|
18
|
-
fetched_at: number;
|
|
18
|
+
fetched_at: number; // Unix seconds — when WE pulled this report ("Refreshed")
|
|
19
19
|
expires_at: number;
|
|
20
|
+
analysis_last_updated?: string | null; // Octagon's upstream model-run timestamp ("Model run")
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function insertReport(db: Database, report: OctagonReport): void {
|
package/src/db/schema.ts
CHANGED
|
@@ -237,6 +237,11 @@ export function migrate(db: Database): void {
|
|
|
237
237
|
if (!reportCols.some((c) => c.name === 'close_time')) {
|
|
238
238
|
db.exec(`ALTER TABLE octagon_reports ADD COLUMN close_time TEXT`);
|
|
239
239
|
}
|
|
240
|
+
// Upstream model-run timestamp (Octagon `analysis_last_updated`). Distinct
|
|
241
|
+
// from `fetched_at` (when we pulled the report into our local cache).
|
|
242
|
+
if (!reportCols.some((c) => c.name === 'analysis_last_updated')) {
|
|
243
|
+
db.exec(`ALTER TABLE octagon_reports ADD COLUMN analysis_last_updated TEXT`);
|
|
244
|
+
}
|
|
240
245
|
|
|
241
246
|
const historyCols = db.query(`PRAGMA table_info(octagon_history)`).all() as Array<{ name: string }>;
|
|
242
247
|
if (!historyCols.some((c) => c.name === 'outcome_probabilities_json')) {
|
|
@@ -189,12 +189,12 @@ export interface BasketBacktestResponse extends BasketCandlesResponse {
|
|
|
189
189
|
export interface BasketSizeLeg {
|
|
190
190
|
market_ticker: string;
|
|
191
191
|
side: 'yes' | 'no';
|
|
192
|
-
model_probability: number;
|
|
193
|
-
price: number;
|
|
194
|
-
edge_pp: number;
|
|
195
|
-
kelly_fraction: number;
|
|
196
|
-
weight: number;
|
|
197
|
-
notional_usd: number;
|
|
192
|
+
model_probability: number | null;
|
|
193
|
+
price: number | null;
|
|
194
|
+
edge_pp: number | null;
|
|
195
|
+
kelly_fraction: number | null;
|
|
196
|
+
weight: number | null;
|
|
197
|
+
notional_usd: number | null;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
export interface BasketSizeResponse {
|
|
@@ -49,7 +49,7 @@ function classifyConfidence(absEdge: number): string {
|
|
|
49
49
|
* Convert an Octagon event entry to local DB records and persist.
|
|
50
50
|
* Returns true if a new record was inserted, false if skipped.
|
|
51
51
|
*/
|
|
52
|
-
function persistEvent(db: Database, event: OctagonEventEntry): boolean {
|
|
52
|
+
export function persistEvent(db: Database, event: OctagonEventEntry): boolean {
|
|
53
53
|
const capturedDate = new Date(event.captured_at);
|
|
54
54
|
const closeDate = new Date(event.close_time);
|
|
55
55
|
if (isNaN(capturedDate.getTime()) || isNaN(closeDate.getTime())) return false;
|
|
@@ -102,7 +102,8 @@ function persistEvent(db: Database, event: OctagonEventEntry): boolean {
|
|
|
102
102
|
|
|
103
103
|
db.prepare(
|
|
104
104
|
`UPDATE octagon_reports SET has_history = $hh, mutually_exclusive = $me, series_category = $sc,
|
|
105
|
-
confidence_score = $cs, outcome_probabilities_json = $opj, close_time = $ct
|
|
105
|
+
confidence_score = $cs, outcome_probabilities_json = $opj, close_time = $ct,
|
|
106
|
+
analysis_last_updated = $alu
|
|
106
107
|
WHERE report_id = $rid`,
|
|
107
108
|
).run({
|
|
108
109
|
$rid: reportId,
|
|
@@ -112,34 +113,54 @@ function persistEvent(db: Database, event: OctagonEventEntry): boolean {
|
|
|
112
113
|
$cs: event.confidence_score ?? null,
|
|
113
114
|
$opj: event.outcome_probabilities ? JSON.stringify(event.outcome_probabilities) : null,
|
|
114
115
|
$ct: event.close_time ?? null,
|
|
116
|
+
$alu: event.analysis_last_updated ?? null,
|
|
115
117
|
});
|
|
116
118
|
})();
|
|
117
119
|
|
|
118
|
-
// Also persist to edge_history
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
120
|
+
// Also persist to edge_history. Two cases:
|
|
121
|
+
// - Multi-outcome events (e.g. KXFEDCHAIRNOM-29) ship outcome_probabilities
|
|
122
|
+
// with per-market model/market probs. Write one edge_history row per
|
|
123
|
+
// outcome with the correct ticker — otherwise downstream consumers see
|
|
124
|
+
// the event-level placeholder (typically ~0.5) on every contract.
|
|
125
|
+
// - Single-outcome / no-outcome events: fall back to event-level probs on
|
|
126
|
+
// a single row keyed by the event ticker, as before.
|
|
127
|
+
const outcomes = Array.isArray(event.outcome_probabilities) ? event.outcome_probabilities : [];
|
|
128
|
+
const perOutcomeRows = outcomes.length > 0
|
|
129
|
+
? outcomes
|
|
130
|
+
.filter((o) => o && o.market_ticker && typeof o.model_probability === 'number' && typeof o.market_probability === 'number')
|
|
131
|
+
.map((o) => ({
|
|
132
|
+
ticker: o.market_ticker,
|
|
133
|
+
model_prob: o.model_probability / 100,
|
|
134
|
+
market_prob: o.market_probability / 100,
|
|
135
|
+
}))
|
|
136
|
+
: [{ ticker: event.event_ticker, model_prob: modelProb, market_prob: marketProb }];
|
|
137
|
+
|
|
138
|
+
for (const row of perOutcomeRows) {
|
|
139
|
+
const rowEdge = row.model_prob - row.market_prob;
|
|
140
|
+
try {
|
|
141
|
+
insertEdge(db, {
|
|
142
|
+
ticker: row.ticker,
|
|
143
|
+
event_ticker: event.event_ticker,
|
|
144
|
+
timestamp: capturedAt,
|
|
145
|
+
model_prob: row.model_prob,
|
|
146
|
+
market_prob: row.market_prob,
|
|
147
|
+
edge: rowEdge,
|
|
148
|
+
octagon_report_id: reportId,
|
|
149
|
+
drivers_json: null,
|
|
150
|
+
sources_json: null,
|
|
151
|
+
catalysts_json: null,
|
|
152
|
+
cache_hit: 1,
|
|
153
|
+
cache_miss: 0,
|
|
154
|
+
confidence: classifyConfidence(Math.abs(rowEdge)),
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// Only swallow UNIQUE constraint violations (duplicate ticker+timestamp)
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
if (/UNIQUE constraint failed/i.test(msg)) {
|
|
160
|
+
// Expected — edge already exists for this ticker+timestamp
|
|
161
|
+
} else {
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
166
|
|