kalshi-trading-bot-cli 2.1.0

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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/assets/kalshi-flow-light.png +0 -0
  4. package/assets/screenshot.png +0 -0
  5. package/env.example +43 -0
  6. package/kalshi-flow-light.png +0 -0
  7. package/package.json +66 -0
  8. package/src/agent/agent.ts +249 -0
  9. package/src/agent/channels.ts +53 -0
  10. package/src/agent/index.ts +29 -0
  11. package/src/agent/prompts.ts +171 -0
  12. package/src/agent/run-context.ts +23 -0
  13. package/src/agent/scratchpad.ts +465 -0
  14. package/src/agent/token-counter.ts +33 -0
  15. package/src/agent/tool-executor.ts +166 -0
  16. package/src/agent/types.ts +221 -0
  17. package/src/audit/index.ts +25 -0
  18. package/src/audit/reader.ts +43 -0
  19. package/src/audit/trail.ts +29 -0
  20. package/src/audit/types.ts +133 -0
  21. package/src/backtest/discovery.ts +170 -0
  22. package/src/backtest/fetcher.ts +247 -0
  23. package/src/backtest/metrics.ts +165 -0
  24. package/src/backtest/renderer.ts +196 -0
  25. package/src/backtest/types.ts +45 -0
  26. package/src/cli.ts +943 -0
  27. package/src/commands/alerts.ts +48 -0
  28. package/src/commands/analyze.ts +662 -0
  29. package/src/commands/backtest.ts +276 -0
  30. package/src/commands/clear-cache.ts +24 -0
  31. package/src/commands/config.ts +107 -0
  32. package/src/commands/dispatch.ts +473 -0
  33. package/src/commands/edge.ts +62 -0
  34. package/src/commands/formatters.ts +339 -0
  35. package/src/commands/help.ts +263 -0
  36. package/src/commands/helpers.ts +48 -0
  37. package/src/commands/index.ts +287 -0
  38. package/src/commands/json.ts +43 -0
  39. package/src/commands/parse-args.ts +229 -0
  40. package/src/commands/portfolio.ts +236 -0
  41. package/src/commands/review.ts +176 -0
  42. package/src/commands/scan-formatters.ts +98 -0
  43. package/src/commands/scan.ts +38 -0
  44. package/src/commands/search-edge.ts +139 -0
  45. package/src/commands/status.ts +70 -0
  46. package/src/commands/themes.ts +117 -0
  47. package/src/commands/watch.ts +295 -0
  48. package/src/components/answer-box.ts +57 -0
  49. package/src/components/approval-prompt.ts +34 -0
  50. package/src/components/browse-list.ts +134 -0
  51. package/src/components/chat-log.ts +291 -0
  52. package/src/components/custom-editor.ts +18 -0
  53. package/src/components/debug-panel.ts +52 -0
  54. package/src/components/index.ts +17 -0
  55. package/src/components/intro.ts +92 -0
  56. package/src/components/select-list.ts +155 -0
  57. package/src/components/tool-event.ts +127 -0
  58. package/src/components/user-query.ts +18 -0
  59. package/src/components/working-indicator.ts +87 -0
  60. package/src/controllers/agent-runner.ts +283 -0
  61. package/src/controllers/browse.ts +1013 -0
  62. package/src/controllers/index.ts +7 -0
  63. package/src/controllers/input-history.ts +76 -0
  64. package/src/controllers/model-selection.ts +244 -0
  65. package/src/db/alerts.ts +77 -0
  66. package/src/db/edge.ts +105 -0
  67. package/src/db/event-index.ts +323 -0
  68. package/src/db/events.ts +41 -0
  69. package/src/db/index.ts +60 -0
  70. package/src/db/octagon-cache.ts +118 -0
  71. package/src/db/positions.ts +71 -0
  72. package/src/db/risk.ts +51 -0
  73. package/src/db/schema.ts +227 -0
  74. package/src/db/themes.ts +34 -0
  75. package/src/db/trades.ts +50 -0
  76. package/src/eval/brier.ts +90 -0
  77. package/src/eval/index.ts +4 -0
  78. package/src/eval/performance.ts +87 -0
  79. package/src/gateway/access-control.ts +253 -0
  80. package/src/gateway/agent-runner.ts +75 -0
  81. package/src/gateway/alerts/formatter.ts +90 -0
  82. package/src/gateway/alerts/index.ts +4 -0
  83. package/src/gateway/alerts/router.ts +32 -0
  84. package/src/gateway/alerts/terminal.ts +16 -0
  85. package/src/gateway/alerts/types.ts +13 -0
  86. package/src/gateway/channels/index.ts +9 -0
  87. package/src/gateway/channels/manager.ts +153 -0
  88. package/src/gateway/channels/types.ts +48 -0
  89. package/src/gateway/channels/whatsapp/README.md +234 -0
  90. package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
  91. package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
  92. package/src/gateway/channels/whatsapp/error.ts +122 -0
  93. package/src/gateway/channels/whatsapp/inbound.ts +326 -0
  94. package/src/gateway/channels/whatsapp/index.ts +5 -0
  95. package/src/gateway/channels/whatsapp/lid.ts +56 -0
  96. package/src/gateway/channels/whatsapp/logger.ts +25 -0
  97. package/src/gateway/channels/whatsapp/login.ts +94 -0
  98. package/src/gateway/channels/whatsapp/outbound.ts +119 -0
  99. package/src/gateway/channels/whatsapp/plugin.ts +54 -0
  100. package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
  101. package/src/gateway/channels/whatsapp/runtime.ts +122 -0
  102. package/src/gateway/channels/whatsapp/session.ts +89 -0
  103. package/src/gateway/channels/whatsapp/types.ts +32 -0
  104. package/src/gateway/commands/handler.ts +64 -0
  105. package/src/gateway/commands/index.ts +7 -0
  106. package/src/gateway/commands/parser.ts +29 -0
  107. package/src/gateway/commands/wa-formatters.ts +92 -0
  108. package/src/gateway/config.ts +244 -0
  109. package/src/gateway/extension-points.ts +17 -0
  110. package/src/gateway/gateway.ts +301 -0
  111. package/src/gateway/group/history-buffer.ts +75 -0
  112. package/src/gateway/group/index.ts +8 -0
  113. package/src/gateway/group/member-tracker.ts +60 -0
  114. package/src/gateway/group/mention-detection.ts +42 -0
  115. package/src/gateway/heartbeat/index.ts +8 -0
  116. package/src/gateway/heartbeat/prompt.ts +73 -0
  117. package/src/gateway/heartbeat/runner.ts +200 -0
  118. package/src/gateway/heartbeat/suppression.ts +74 -0
  119. package/src/gateway/index.ts +138 -0
  120. package/src/gateway/routing/resolve-route.ts +119 -0
  121. package/src/gateway/sessions/store.ts +65 -0
  122. package/src/gateway/types.ts +11 -0
  123. package/src/gateway/utils.ts +82 -0
  124. package/src/index.tsx +30 -0
  125. package/src/model/llm.ts +247 -0
  126. package/src/providers.ts +94 -0
  127. package/src/risk/circuit-breaker.ts +113 -0
  128. package/src/risk/correlation.ts +40 -0
  129. package/src/risk/gate.ts +125 -0
  130. package/src/risk/index.ts +10 -0
  131. package/src/risk/kelly.ts +230 -0
  132. package/src/scan/alerter.ts +64 -0
  133. package/src/scan/edge-computer.ts +164 -0
  134. package/src/scan/invoker.ts +199 -0
  135. package/src/scan/loop.ts +184 -0
  136. package/src/scan/octagon-client.ts +627 -0
  137. package/src/scan/octagon-events-api.ts +105 -0
  138. package/src/scan/octagon-prefetch.ts +172 -0
  139. package/src/scan/theme-resolver.ts +179 -0
  140. package/src/scan/types.ts +62 -0
  141. package/src/scan/watchdog.ts +126 -0
  142. package/src/setup/wizard.ts +659 -0
  143. package/src/theme.ts +67 -0
  144. package/src/tools/fetch/cache.ts +95 -0
  145. package/src/tools/fetch/external-content.ts +200 -0
  146. package/src/tools/fetch/index.ts +1 -0
  147. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  148. package/src/tools/fetch/web-fetch.ts +419 -0
  149. package/src/tools/index.ts +10 -0
  150. package/src/tools/kalshi/api.ts +251 -0
  151. package/src/tools/kalshi/dlq.ts +35 -0
  152. package/src/tools/kalshi/events.ts +84 -0
  153. package/src/tools/kalshi/exchange.ts +24 -0
  154. package/src/tools/kalshi/historical.ts +89 -0
  155. package/src/tools/kalshi/index.ts +11 -0
  156. package/src/tools/kalshi/kalshi-search.ts +437 -0
  157. package/src/tools/kalshi/kalshi-trade.ts +102 -0
  158. package/src/tools/kalshi/markets.ts +76 -0
  159. package/src/tools/kalshi/portfolio.ts +100 -0
  160. package/src/tools/kalshi/search-index.ts +198 -0
  161. package/src/tools/kalshi/series.ts +16 -0
  162. package/src/tools/kalshi/trading.ts +115 -0
  163. package/src/tools/kalshi/types.ts +199 -0
  164. package/src/tools/registry.ts +160 -0
  165. package/src/tools/search/index.ts +25 -0
  166. package/src/tools/search/tavily.ts +35 -0
  167. package/src/tools/types.ts +53 -0
  168. package/src/tools/v2/edge-query.ts +135 -0
  169. package/src/tools/v2/octagon-report.ts +112 -0
  170. package/src/tools/v2/portfolio-query.ts +79 -0
  171. package/src/tools/v2/portfolio-review.ts +59 -0
  172. package/src/tools/v2/risk-status.ts +94 -0
  173. package/src/tools/v2/scan.ts +78 -0
  174. package/src/types/qrcode-terminal.d.ts +7 -0
  175. package/src/types/whiskeysockets-baileys.d.ts +41 -0
  176. package/src/types.ts +22 -0
  177. package/src/utils/ai-message.ts +26 -0
  178. package/src/utils/bot-config.ts +219 -0
  179. package/src/utils/cache.ts +195 -0
  180. package/src/utils/config.ts +113 -0
  181. package/src/utils/env.ts +111 -0
  182. package/src/utils/errors.ts +313 -0
  183. package/src/utils/history-context.ts +32 -0
  184. package/src/utils/in-memory-chat-history.ts +268 -0
  185. package/src/utils/index.ts +28 -0
  186. package/src/utils/input-key-handlers.ts +64 -0
  187. package/src/utils/logger.ts +67 -0
  188. package/src/utils/long-term-chat-history.ts +138 -0
  189. package/src/utils/markdown-table.ts +227 -0
  190. package/src/utils/model.ts +70 -0
  191. package/src/utils/ollama.ts +37 -0
  192. package/src/utils/paths.ts +12 -0
  193. package/src/utils/progress-channel.ts +84 -0
  194. package/src/utils/telemetry.ts +103 -0
  195. package/src/utils/text-navigation.ts +81 -0
  196. package/src/utils/thinking-verbs.ts +18 -0
  197. package/src/utils/tokens.ts +36 -0
  198. package/src/utils/tool-description.ts +61 -0
@@ -0,0 +1,70 @@
1
+ import { callKalshiApi } from '../tools/kalshi/api.js';
2
+ import { PROVIDERS } from '@/providers';
3
+ import { getDefaultModelForProvider } from '@/utils/model';
4
+
5
+ /**
6
+ * Verify setup: check API keys, exchange connectivity, and optional services.
7
+ * Designed to be the first command a new user runs after `cp env.example .env`.
8
+ */
9
+ export async function handleStatus(): Promise<string> {
10
+ const lines: string[] = [];
11
+ let allGood = true;
12
+
13
+ lines.push('Checking setup...');
14
+ lines.push('');
15
+
16
+ // 1. Kalshi API key
17
+ const hasKalshiKey = !!process.env.KALSHI_API_KEY;
18
+ const hasKalshiPem = !!(process.env.KALSHI_PRIVATE_KEY_FILE || process.env.KALSHI_PRIVATE_KEY);
19
+ lines.push(hasKalshiKey ? '✓ KALSHI_API_KEY set' : '✗ KALSHI_API_KEY missing');
20
+ lines.push(hasKalshiPem ? '✓ Kalshi private key configured' : '✗ Kalshi private key missing (set KALSHI_PRIVATE_KEY_FILE or KALSHI_PRIVATE_KEY)');
21
+ if (!hasKalshiKey || !hasKalshiPem) allGood = false;
22
+
23
+ // 2. Exchange connectivity
24
+ if (hasKalshiKey && hasKalshiPem) {
25
+ try {
26
+ const data = await callKalshiApi('GET', '/exchange/status');
27
+ const active = (data as any).exchange_active;
28
+ const trading = (data as any).trading_active;
29
+ lines.push(active ? '✓ Exchange reachable' : '✗ Exchange not active');
30
+ lines.push(trading ? '✓ Trading enabled' : '⚠ Trading paused');
31
+ if (!active) allGood = false;
32
+ } catch (e: any) {
33
+ lines.push(`✗ Cannot reach Kalshi API: ${e.message}`);
34
+ allGood = false;
35
+ }
36
+ }
37
+
38
+ // 3. LLM provider — detect which provider is configured and show its default model
39
+ const configuredProvider = PROVIDERS.find(
40
+ (p) => p.apiKeyEnvVar && process.env[p.apiKeyEnvVar],
41
+ );
42
+ const defaultModel =
43
+ process.env.DEFAULT_MODEL ??
44
+ (configuredProvider ? getDefaultModelForProvider(configuredProvider.id) : undefined);
45
+ const llmKey = !!configuredProvider;
46
+ lines.push(
47
+ llmKey
48
+ ? `✓ LLM provider configured (${configuredProvider!.displayName}${defaultModel ? `, default model: ${defaultModel}` : ''})`
49
+ : '✗ No LLM API key set (need at least one: OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.)',
50
+ );
51
+ if (!llmKey) allGood = false;
52
+
53
+ // 4. Octagon
54
+ const hasOctagon = !!process.env.OCTAGON_API_KEY;
55
+ lines.push(hasOctagon ? '✓ OCTAGON_API_KEY set' : '⚠ OCTAGON_API_KEY missing — /scan and deep research will not work');
56
+
57
+ // 5. Optional: Tavily
58
+ const hasTavily = !!process.env.TAVILY_API_KEY;
59
+ lines.push(hasTavily ? '✓ TAVILY_API_KEY set (web search enabled)' : ' TAVILY_API_KEY not set (web search disabled — optional)');
60
+
61
+ // 6. Demo mode
62
+ if (process.env.KALSHI_USE_DEMO === 'true') {
63
+ lines.push('⚠ KALSHI_USE_DEMO=true — using demo environment (no real money)');
64
+ }
65
+
66
+ lines.push('');
67
+ lines.push(allGood ? '✓ All good — ready to trade.' : '✗ Fix the issues above before continuing.');
68
+
69
+ return lines.join('\n');
70
+ }
@@ -0,0 +1,117 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { getActiveThemes } from '../db/themes.js';
3
+ import { CATEGORY_MAP, fetchSubcategories } from '../scan/theme-resolver.js';
4
+ import { formatTable } from './scan-formatters.js';
5
+ import { wrapSuccess } from './json.js';
6
+ import type { CLIResponse } from './json.js';
7
+ import type { ParsedArgs } from './parse-args.js';
8
+
9
+ export interface ThemeInfo {
10
+ id: string;
11
+ name: string;
12
+ type: 'built-in' | 'category' | 'custom';
13
+ subcategories?: string[];
14
+ tickerCount?: number;
15
+ }
16
+
17
+ export interface ThemesResult {
18
+ themes: ThemeInfo[];
19
+ }
20
+
21
+ export async function handleThemes(args: ParsedArgs): Promise<CLIResponse<ThemesResult>> {
22
+ const themes: ThemeInfo[] = [];
23
+
24
+ // Special built-in theme
25
+ themes.push({ id: 'top50', name: 'Top 50 markets by 24h volume', type: 'built-in' });
26
+
27
+ // Fetch subcategories from Kalshi API
28
+ let subcatMap: Record<string, string[]> = {};
29
+ try {
30
+ subcatMap = await fetchSubcategories();
31
+ } catch {
32
+ // API unavailable — show categories without subcategories
33
+ }
34
+
35
+ // All Kalshi categories with their subcategories
36
+ for (const [id, label] of Object.entries(CATEGORY_MAP)) {
37
+ const subs = subcatMap[label] ?? [];
38
+ themes.push({
39
+ id,
40
+ name: label,
41
+ type: 'category',
42
+ ...(subs.length > 0 ? { subcategories: subs } : {}),
43
+ });
44
+ }
45
+
46
+ // Custom themes from DB
47
+ try {
48
+ const db = getDb();
49
+ const custom = getActiveThemes(db);
50
+ for (const t of custom) {
51
+ const tickers = t.tickers ? JSON.parse(t.tickers) as string[] : [];
52
+ themes.push({
53
+ id: t.theme_id,
54
+ name: t.name,
55
+ type: 'custom',
56
+ tickerCount: tickers.length,
57
+ });
58
+ }
59
+ } catch {
60
+ // DB not initialized yet — just show built-in themes
61
+ }
62
+
63
+ return wrapSuccess('themes', { themes });
64
+ }
65
+
66
+ /** Wrap a comma-separated list into lines of at most `maxWidth` characters */
67
+ function wrapSubs(subs: string[], maxWidth: number): string[] {
68
+ if (subs.length === 0) return [''];
69
+ const wrapped: string[] = [];
70
+ let line = '';
71
+ for (const s of subs) {
72
+ const addition = line ? `, ${s}` : s;
73
+ if (line && (line + addition).length > maxWidth) {
74
+ wrapped.push(line);
75
+ line = s;
76
+ } else {
77
+ line += addition;
78
+ }
79
+ }
80
+ if (line) wrapped.push(line);
81
+ return wrapped;
82
+ }
83
+
84
+ export function formatThemesHuman(data: ThemesResult): string {
85
+ const lines: string[] = [];
86
+ const SUB_WIDTH = 55;
87
+
88
+ const rows: string[][] = [];
89
+
90
+ // Built-in special themes
91
+ for (const t of data.themes.filter((t) => t.type === 'built-in')) {
92
+ rows.push([t.id, t.name]);
93
+ }
94
+
95
+ // Categories — main row, then one subcategory per line
96
+ for (const t of data.themes.filter((t) => t.type === 'category')) {
97
+ rows.push([t.id, t.name]);
98
+ for (const s of t.subcategories ?? []) {
99
+ rows.push(['', ` ${t.id}:${s.toLowerCase()}`]);
100
+ }
101
+ }
102
+
103
+ // Custom themes
104
+ for (const t of data.themes.filter((t) => t.type === 'custom')) {
105
+ const extra = t.tickerCount !== undefined ? `${t.name} (${t.tickerCount} tickers)` : t.name;
106
+ rows.push([t.id, extra]);
107
+ }
108
+
109
+ lines.push(formatTable(['Theme', 'Description / Subcategories'], rows));
110
+
111
+ lines.push('');
112
+ lines.push('Usage: search crypto');
113
+ lines.push(' search crypto:btc');
114
+ lines.push(' analyze <TICKER>');
115
+
116
+ return lines.join('\n');
117
+ }
@@ -0,0 +1,295 @@
1
+ import type { ParsedArgs } from './parse-args.js';
2
+ import { wrapSuccess, wrapError } from './json.js';
3
+ import { getDb } from '../db/index.js';
4
+ import { auditTrail } from '../audit/index.js';
5
+ import { ScanLoop } from '../scan/loop.js';
6
+ import { createOctagonInvoker } from '../scan/invoker.js';
7
+ import { formatScanTable } from './scan-formatters.js';
8
+ import { callKalshiApi } from '../tools/kalshi/api.js';
9
+ import { getBotSetting } from '../utils/bot-config.js';
10
+ import type { ScanResult } from '../scan/loop.js';
11
+
12
+ export async function handleWatch(args: ParsedArgs): Promise<void> {
13
+ const db = getDb();
14
+ const invoker = createOctagonInvoker();
15
+ const loop = new ScanLoop(db, auditTrail, invoker);
16
+
17
+ const rawMinInterval = Number(getBotSetting('watch.min_interval_minutes'));
18
+ const minIntervalMinutes = Number.isFinite(rawMinInterval) && rawMinInterval > 0 ? rawMinInterval : 15;
19
+ const intervalMinutes = args.live
20
+ ? minIntervalMinutes
21
+ : Math.max(minIntervalMinutes, args.interval ?? 60);
22
+ const intervalMs = intervalMinutes * 60_000;
23
+ const theme = args.theme ?? 'top50';
24
+
25
+ let totalCycles = 0;
26
+ let totalEdges = 0;
27
+ const startTime = Date.now();
28
+ let stopped = false;
29
+ let timer: ReturnType<typeof setInterval>;
30
+
31
+ const shutdown = () => {
32
+ if (stopped) return;
33
+ stopped = true;
34
+ clearInterval(timer);
35
+
36
+ const durationSec = ((Date.now() - startTime) / 1000).toFixed(0);
37
+ if (args.json) {
38
+ console.log(JSON.stringify({
39
+ event: 'watch_stopped',
40
+ totalCycles,
41
+ totalEdges,
42
+ durationSeconds: Number(durationSec),
43
+ }));
44
+ } else {
45
+ console.log('');
46
+ console.log(`Watch stopped. ${totalCycles} cycles, ${totalEdges} edges found in ${durationSec}s`);
47
+ }
48
+ process.exit(0);
49
+ };
50
+
51
+ process.once('SIGINT', shutdown);
52
+ process.once('SIGTERM', shutdown);
53
+
54
+ if (!args.json) {
55
+ console.log(`Watching theme "${theme}" every ${intervalMinutes}m (Ctrl+C to stop)\n`);
56
+ }
57
+
58
+ const runCycle = async (): Promise<void> => {
59
+ try {
60
+ const result = await loop.runOnce({ theme, dryRun: args.dryRun });
61
+ totalCycles++;
62
+ totalEdges += result.edgeSnapshots.length;
63
+
64
+ if (args.json) {
65
+ const actionable = result.edgeSnapshots.filter(
66
+ (s) => s.confidence === 'high' || s.confidence === 'very_high'
67
+ ).length;
68
+ console.log(JSON.stringify(wrapSuccess('watch', result, {
69
+ scan_id: result.scanId,
70
+ theme,
71
+ events_scanned: result.eventsScanned,
72
+ actionable,
73
+ octagon_credits_used: result.octagonCreditsUsed,
74
+ })));
75
+ } else {
76
+ console.clear();
77
+ console.log(`Watch cycle #${totalCycles} — theme "${theme}" — every ${intervalMinutes}m\n`);
78
+ console.log(formatScanTable(result));
79
+ }
80
+ } catch (err) {
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ if (args.json) {
83
+ console.log(JSON.stringify(wrapError('watch', 'SCAN_ERROR', message)));
84
+ } else {
85
+ console.error(`[watch] Scan error: ${message}`);
86
+ }
87
+ }
88
+ };
89
+
90
+ // Run first cycle immediately
91
+ await runCycle();
92
+
93
+ // Continue running on interval until stopped
94
+ timer = setInterval(() => {
95
+ if (stopped) return;
96
+ runCycle().catch((err) => {
97
+ console.error(`[watch] Scan cycle failed: ${err instanceof Error ? err.message : String(err)}`);
98
+ });
99
+ }, intervalMs);
100
+
101
+ // Keep process alive — the SIGINT handler will exit
102
+ await new Promise<void>(() => {});
103
+ }
104
+
105
+ // ─── Per-ticker watch mode ──────────────────────────────────────────────────
106
+
107
+ interface TickerSnapshot {
108
+ ticker: string;
109
+ lastPrice: string;
110
+ yesAsk: string;
111
+ yesBid: string;
112
+ noAsk: string;
113
+ noBid: string;
114
+ spread: string;
115
+ volume: string;
116
+ openInterest: string;
117
+ orderbook: { price: string; quantity: number }[];
118
+ timestamp: string;
119
+ }
120
+
121
+ function parseDollarField(val: string | number | undefined | null, isCentField = false): number {
122
+ if (val === undefined || val === null) return 0;
123
+ const n = typeof val === 'number' ? val : parseFloat(val as string);
124
+ if (isNaN(n)) return 0;
125
+ return isCentField ? n / 100 : n;
126
+ }
127
+
128
+ /** Format a value already in dollars (0.00–1.00) */
129
+ function fmtDollars(val: number): string {
130
+ return `$${val.toFixed(2)}`;
131
+ }
132
+
133
+ /** Format a cent integer (1–99) as dollars */
134
+ function fmtCents(val: number): string {
135
+ return `$${(val / 100).toFixed(2)}`;
136
+ }
137
+
138
+ function fmtNum(n: number | string | undefined | null): string {
139
+ if (n === undefined || n === null) return '-';
140
+ const val = typeof n === 'number' ? n : parseFloat(n as string);
141
+ if (isNaN(val)) return '-';
142
+ return val.toLocaleString();
143
+ }
144
+
145
+ async function fetchTickerSnapshot(ticker: string): Promise<TickerSnapshot> {
146
+ // Fetch market data
147
+ const market = await callKalshiApi('GET', `/markets/${ticker}`) as any;
148
+ const m = market.market ?? market;
149
+
150
+ const hasDollarYesAsk = m.yes_ask_dollars != null || m.dollar_yes_ask != null;
151
+ const hasDollarYesBid = m.yes_bid_dollars != null || m.dollar_yes_bid != null;
152
+ const hasDollarNoAsk = m.no_ask_dollars != null || m.dollar_no_ask != null;
153
+ const hasDollarNoBid = m.no_bid_dollars != null || m.dollar_no_bid != null;
154
+ const yesAsk = parseDollarField(m.yes_ask_dollars ?? m.dollar_yes_ask ?? m.yes_ask, !hasDollarYesAsk);
155
+ const yesBid = parseDollarField(m.yes_bid_dollars ?? m.dollar_yes_bid ?? m.yes_bid, !hasDollarYesBid);
156
+ const noAsk = parseDollarField(m.no_ask_dollars ?? m.dollar_no_ask ?? m.no_ask, !hasDollarNoAsk);
157
+ const noBid = parseDollarField(m.no_bid_dollars ?? m.dollar_no_bid ?? m.no_bid, !hasDollarNoBid);
158
+ const spread = yesAsk - yesBid;
159
+
160
+ // Fetch orderbook
161
+ let orderbook: { price: string; quantity: number }[] = [];
162
+ try {
163
+ const ob = await callKalshiApi('GET', `/markets/${ticker}/orderbook`) as any;
164
+ const book = ob.orderbook ?? ob;
165
+ const rawEntries = Array.isArray(book.yes) ? book.yes : [];
166
+ orderbook = rawEntries
167
+ .filter((entry: unknown): entry is [number, number] =>
168
+ Array.isArray(entry) && entry.length === 2 &&
169
+ typeof entry[0] === 'number' && typeof entry[1] === 'number'
170
+ )
171
+ .slice(0, 5)
172
+ .map(([price, qty]: [number, number]) => ({
173
+ price: fmtCents(price),
174
+ quantity: qty,
175
+ }));
176
+ } catch {
177
+ // Orderbook not available for all markets
178
+ }
179
+
180
+ // Resolve last price — dollar string fields are already in dollars; last_price is cents
181
+ const dollarLastStr = m.last_price_dollars ?? m.dollar_last_price;
182
+ const parsedDollarLast = dollarLastStr != null ? parseFloat(dollarLastStr) : NaN;
183
+ const lastPriceDollars = Number.isFinite(parsedDollarLast)
184
+ ? parsedDollarLast
185
+ : (m.last_price != null ? m.last_price / 100 : NaN);
186
+
187
+ return {
188
+ ticker,
189
+ lastPrice: Number.isFinite(lastPriceDollars) ? fmtDollars(lastPriceDollars) : '-',
190
+ yesAsk: fmtDollars(yesAsk),
191
+ yesBid: fmtDollars(yesBid),
192
+ noAsk: fmtDollars(noAsk),
193
+ noBid: fmtDollars(noBid),
194
+ spread: `$${spread.toFixed(4)}`,
195
+ volume: fmtNum(m.volume_fp ?? m.volume),
196
+ openInterest: fmtNum(m.open_interest_fp ?? m.open_interest),
197
+ orderbook,
198
+ timestamp: new Date().toISOString(),
199
+ };
200
+ }
201
+
202
+ function formatTickerDashboard(snap: TickerSnapshot, tick: number): string {
203
+ const lines: string[] = [];
204
+ lines.push(` ${snap.ticker} (tick #${tick}) ${new Date(snap.timestamp).toLocaleTimeString()}`);
205
+ lines.push('');
206
+ lines.push(` Last Price: ${snap.lastPrice}`);
207
+ lines.push(` YES Bid / Ask: ${snap.yesBid} / ${snap.yesAsk} Spread: ${snap.spread}`);
208
+ lines.push(` NO Bid / Ask: ${snap.noBid} / ${snap.noAsk}`);
209
+ lines.push(` Volume: ${snap.volume} Open Interest: ${snap.openInterest}`);
210
+
211
+ if (snap.orderbook.length > 0) {
212
+ lines.push('');
213
+ lines.push(' Orderbook (YES, top 5):');
214
+ for (const level of snap.orderbook) {
215
+ lines.push(` ${level.price} ×${level.quantity}`);
216
+ }
217
+ }
218
+
219
+ return lines.join('\n');
220
+ }
221
+
222
+ export async function handleWatchTicker(ticker: string, args: ParsedArgs): Promise<void> {
223
+ let totalTicks = 0;
224
+ const startTime = Date.now();
225
+ let stopped = false;
226
+ let timer: ReturnType<typeof setInterval>;
227
+
228
+ const shutdown = () => {
229
+ if (stopped) return;
230
+ stopped = true;
231
+ clearInterval(timer);
232
+
233
+ const durationSec = ((Date.now() - startTime) / 1000).toFixed(0);
234
+ if (args.json) {
235
+ console.log(JSON.stringify({
236
+ event: 'watch_stopped',
237
+ ticker,
238
+ totalTicks,
239
+ durationSeconds: Number(durationSec),
240
+ }));
241
+ } else {
242
+ console.log('');
243
+ console.log(`Watch stopped. ${totalTicks} ticks in ${durationSec}s`);
244
+ }
245
+ process.exit(0);
246
+ };
247
+
248
+ process.once('SIGINT', shutdown);
249
+ process.once('SIGTERM', shutdown);
250
+
251
+ const rawTickerInterval = Number(getBotSetting('watch.ticker_interval_seconds'));
252
+ const tickerIntervalMs = (Number.isFinite(rawTickerInterval) && rawTickerInterval > 0 ? rawTickerInterval : 5) * 1000;
253
+ const intervalMs = args.interval ? args.interval * 1000 : tickerIntervalMs;
254
+ const intervalLabel = intervalMs >= 60_000 ? `${(intervalMs / 60_000).toFixed(0)}m` : `${(intervalMs / 1000).toFixed(0)}s`;
255
+
256
+ if (!args.json) {
257
+ console.log(`Watching ${ticker} every ${intervalLabel} (Ctrl+C to stop)\n`);
258
+ }
259
+
260
+ const runTick = async (): Promise<void> => {
261
+ try {
262
+ const snap = await fetchTickerSnapshot(ticker);
263
+ totalTicks++;
264
+
265
+ if (args.json) {
266
+ console.log(JSON.stringify(wrapSuccess('watch:ticker', snap)));
267
+ } else {
268
+ console.clear();
269
+ console.log(formatTickerDashboard(snap, totalTicks));
270
+ }
271
+ } catch (err) {
272
+ const message = err instanceof Error ? err.message : String(err);
273
+ if (args.json) {
274
+ console.log(JSON.stringify(wrapError('watch:ticker', 'FETCH_ERROR', message)));
275
+ } else {
276
+ console.error(`[watch] Error: ${message}`);
277
+ }
278
+ }
279
+ };
280
+
281
+ // First tick immediately
282
+ await runTick();
283
+
284
+ // Continue on interval
285
+ timer = setInterval(() => {
286
+ if (stopped) return;
287
+ runTick().catch((err) => {
288
+ const message = err instanceof Error ? err.message : String(err);
289
+ console.error(`[watch-ticker] Tick failed: ${message}`);
290
+ });
291
+ }, intervalMs);
292
+
293
+ // Keep process alive
294
+ await new Promise<void>(() => {});
295
+ }
@@ -0,0 +1,57 @@
1
+ import { Container, Markdown, Spacer, type TUI } from '@mariozechner/pi-tui';
2
+ import { formatResponse } from '../utils/markdown-table.js';
3
+ import { markdownTheme, theme } from '../theme.js';
4
+
5
+ const SPINNER_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
6
+ const SPINNER_INTERVAL_MS = 80;
7
+
8
+ export class AnswerBoxComponent extends Container {
9
+ private readonly body: Markdown;
10
+ private value = '';
11
+ private spinnerInterval: ReturnType<typeof setInterval> | null = null;
12
+ private spinnerFrame = 0;
13
+ private tui: TUI | null = null;
14
+
15
+ constructor(initialText = '') {
16
+ super();
17
+ this.addChild(new Spacer(1));
18
+ this.body = new Markdown('', 0, 0, markdownTheme, { color: (line) => line });
19
+ this.addChild(this.body);
20
+ this.setText(initialText);
21
+ }
22
+
23
+ setText(text: string) {
24
+ this.value = text;
25
+ this.render_();
26
+ }
27
+
28
+ /** Start an animated spinner prefix. Call stopSpinner() when done. */
29
+ startSpinner(tui: TUI) {
30
+ this.tui = tui;
31
+ this.spinnerFrame = 0;
32
+ if (this.spinnerInterval) return;
33
+ this.spinnerInterval = setInterval(() => {
34
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
35
+ this.render_();
36
+ this.tui?.requestRender();
37
+ }, SPINNER_INTERVAL_MS);
38
+ }
39
+
40
+ /** Stop the spinner and revert to static ⏺ prefix. */
41
+ stopSpinner() {
42
+ if (this.spinnerInterval) {
43
+ clearInterval(this.spinnerInterval);
44
+ this.spinnerInterval = null;
45
+ }
46
+ this.render_();
47
+ }
48
+
49
+ private render_() {
50
+ const rendered = formatResponse(this.value);
51
+ const normalized = rendered.replace(/^\n+/, '');
52
+ const prefix = this.spinnerInterval
53
+ ? theme.primary(SPINNER_FRAMES[this.spinnerFrame])
54
+ : theme.primary('⏺');
55
+ this.body.setText(`${prefix}\n${normalized}`);
56
+ }
57
+ }
@@ -0,0 +1,34 @@
1
+ import { Container, Text } from '@mariozechner/pi-tui';
2
+ import type { ApprovalDecision } from '../agent/types.js';
3
+ import { createApprovalSelector } from './select-list.js';
4
+ import { theme } from '../theme.js';
5
+
6
+ function formatToolLabel(tool: string): string {
7
+ return tool
8
+ .split('_')
9
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
10
+ .join(' ');
11
+ }
12
+
13
+ export class ApprovalPromptComponent extends Container {
14
+ readonly selector: any;
15
+ onSelect?: (decision: ApprovalDecision) => void;
16
+
17
+ constructor(tool: string, args: Record<string, unknown>) {
18
+ super();
19
+ this.selector = createApprovalSelector((decision) => this.onSelect?.(decision));
20
+ const width = Math.max(20, process.stdout.columns ?? 80);
21
+ const border = theme.warning('─'.repeat(width));
22
+ const path = (args.path as string) || '<unknown>';
23
+
24
+ this.addChild(new Text(border, 0, 0));
25
+ this.addChild(new Text(theme.warning(theme.bold('Permission required')), 0, 0));
26
+ this.addChild(new Text(`${formatToolLabel(tool)} ${path}`, 0, 0));
27
+ this.addChild(new Text(theme.muted('Do you want to allow this?'), 0, 0));
28
+ this.addChild(new Text('', 0, 0));
29
+ this.addChild(this.selector);
30
+ this.addChild(new Text('', 0, 0));
31
+ this.addChild(new Text(theme.muted('Enter to confirm · esc to deny'), 0, 0));
32
+ this.addChild(new Text(border, 0, 0));
33
+ }
34
+ }