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,35 @@
1
+ import { appendFileSync, mkdirSync } from 'fs';
2
+ import { dirname } from 'path';
3
+ import { appPath } from '../../utils/paths.js';
4
+
5
+ const DEFAULT_PATH = appPath('dlq.jsonl');
6
+
7
+ export interface DlqEntry {
8
+ ts: string;
9
+ method: string;
10
+ path: string;
11
+ body?: Record<string, unknown>;
12
+ error: string;
13
+ attempts: number;
14
+ }
15
+
16
+ export class DlqWriter {
17
+ private filePath: string;
18
+ private dirCreated = false;
19
+
20
+ constructor(filePath?: string) {
21
+ this.filePath = filePath ?? DEFAULT_PATH;
22
+ }
23
+
24
+ append(entry: Omit<DlqEntry, 'ts'>): void {
25
+ if (!this.dirCreated) {
26
+ mkdirSync(dirname(this.filePath), { recursive: true });
27
+ this.dirCreated = true;
28
+ }
29
+
30
+ const record: DlqEntry = { ts: new Date().toISOString(), ...entry };
31
+ appendFileSync(this.filePath, JSON.stringify(record) + '\n');
32
+ }
33
+ }
34
+
35
+ export const dlqWriter = new DlqWriter();
@@ -0,0 +1,84 @@
1
+ import { DynamicStructuredTool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { callKalshiApi } from './api.js';
4
+ import { formatToolResult } from '../types.js';
5
+ import { getDb } from '../../db/index.js';
6
+ import { searchEventIndex } from '../../db/event-index.js';
7
+ import { ensureIndex } from './search-index.js';
8
+
9
+ export const getEvents = new DynamicStructuredTool({
10
+ name: 'get_events',
11
+ description: 'Get Kalshi events, optionally filtered by status, series ticker, or title keyword search.',
12
+ schema: z.object({
13
+ status: z.enum(['open', 'closed', 'settled']).optional().describe('Event status filter'),
14
+ series_ticker: z.string().optional().describe('Filter by series ticker'),
15
+ title: z.string().optional().describe('Filter events by title keyword (case-insensitive substring match). Use short keywords like "tesla", "fed decision", "bitcoin"'),
16
+ with_nested_markets: z.boolean().optional().describe('Include nested market data'),
17
+ limit: z.number().optional().describe('Max events to return'),
18
+ }),
19
+ func: async (input) => {
20
+ // Try local index first for title-based searches
21
+ if (input.title) {
22
+ try {
23
+ await ensureIndex();
24
+ const results = searchEventIndex(getDb(), input.title, input.limit ?? 50);
25
+ if (results.length > 0) {
26
+ // Reconstruct events in the same shape the API returns
27
+ const events = results.map((r) => ({
28
+ event_ticker: r.event_ticker,
29
+ series_ticker: r.series_ticker,
30
+ title: r.title,
31
+ category: r.category,
32
+ strike_date: r.strike_date,
33
+ sub_title: r.sub_title,
34
+ markets: r.markets_json ? JSON.parse(r.markets_json) : undefined,
35
+ }));
36
+ return formatToolResult({ events, cursor: null, _source: 'local_index' });
37
+ }
38
+ } catch {
39
+ // Fall through to API on any index error
40
+ }
41
+ }
42
+
43
+ const params: Record<string, string | number | boolean | undefined> = {};
44
+ if (input.status) params.status = input.status;
45
+ if (input.series_ticker) params.series_ticker = input.series_ticker;
46
+ if (input.with_nested_markets !== undefined) params.with_nested_markets = input.with_nested_markets;
47
+ if (input.limit) params.limit = input.limit ?? 200;
48
+
49
+ const data = await callKalshiApi('GET', '/events', { params });
50
+
51
+ // Client-side title filtering (Kalshi API doesn't support server-side title search)
52
+ if (input.title && data && typeof data === 'object') {
53
+ const d = data as Record<string, unknown>;
54
+ const events = d.events as Array<Record<string, unknown>> | undefined;
55
+ if (Array.isArray(events)) {
56
+ const keywords = input.title.toLowerCase().split(/\s+/);
57
+ d.events = events.filter((e) => {
58
+ const title = String(e.title ?? '').toLowerCase();
59
+ const ticker = String(e.event_ticker ?? '').toLowerCase();
60
+ const category = String(e.category ?? '').toLowerCase();
61
+ const text = `${title} ${ticker} ${category}`;
62
+ return keywords.every((kw) => text.includes(kw));
63
+ });
64
+ }
65
+ }
66
+
67
+ return formatToolResult(data);
68
+ },
69
+ });
70
+
71
+ export const getEvent = new DynamicStructuredTool({
72
+ name: 'get_event',
73
+ description: 'Get details for a specific Kalshi event by event ticker.',
74
+ schema: z.object({
75
+ event_ticker: z.string().describe('Event ticker (e.g. KXBTC-26MAR)'),
76
+ with_nested_markets: z.boolean().optional().describe('Include nested market data'),
77
+ }),
78
+ func: async (input) => {
79
+ const params: Record<string, boolean | undefined> = {};
80
+ if (input.with_nested_markets !== undefined) params.with_nested_markets = input.with_nested_markets;
81
+ const data = await callKalshiApi('GET', `/events/${input.event_ticker}`, { params });
82
+ return formatToolResult(data);
83
+ },
84
+ });
@@ -0,0 +1,24 @@
1
+ import { DynamicStructuredTool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { callKalshiApi } from './api.js';
4
+ import { formatToolResult } from '../types.js';
5
+
6
+ export const getExchangeStatus = new DynamicStructuredTool({
7
+ name: 'get_exchange_status',
8
+ description: 'Get the current status of the Kalshi exchange (active/paused).',
9
+ schema: z.object({}),
10
+ func: async () => {
11
+ const data = await callKalshiApi('GET', '/exchange/status');
12
+ return formatToolResult(data);
13
+ },
14
+ });
15
+
16
+ export const getExchangeSchedule = new DynamicStructuredTool({
17
+ name: 'get_exchange_schedule',
18
+ description: 'Get the Kalshi exchange trading schedule including maintenance windows.',
19
+ schema: z.object({}),
20
+ func: async () => {
21
+ const data = await callKalshiApi('GET', '/exchange/schedule');
22
+ return formatToolResult(data);
23
+ },
24
+ });
@@ -0,0 +1,89 @@
1
+ import { DynamicStructuredTool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { callKalshiApi } from './api.js';
4
+ import { formatToolResult } from '../types.js';
5
+
6
+ export const getHistoricalMarkets = new DynamicStructuredTool({
7
+ name: 'get_historical_markets',
8
+ description: 'Get historical Kalshi markets data.',
9
+ schema: z.object({
10
+ series_ticker: z.string().optional().describe('Filter by series ticker'),
11
+ event_ticker: z.string().optional().describe('Filter by event ticker'),
12
+ status: z.enum(['open', 'closed', 'settled']).optional().describe('Market status filter'),
13
+ limit: z.number().optional().describe('Max markets to return'),
14
+ }),
15
+ func: async (input) => {
16
+ const params: Record<string, string | number | undefined> = {};
17
+ if (input.series_ticker) params.series_ticker = input.series_ticker;
18
+ if (input.event_ticker) params.event_ticker = input.event_ticker;
19
+ if (input.status) params.status = input.status;
20
+ if (input.limit) params.limit = input.limit;
21
+ const data = await callKalshiApi('GET', '/historical/markets', { params });
22
+ return formatToolResult(data);
23
+ },
24
+ });
25
+
26
+ export const getHistoricalMarket = new DynamicStructuredTool({
27
+ name: 'get_historical_market',
28
+ description: 'Get historical data for a specific Kalshi market.',
29
+ schema: z.object({
30
+ ticker: z.string().describe('Market ticker'),
31
+ }),
32
+ func: async (input) => {
33
+ const data = await callKalshiApi('GET', `/historical/markets/${input.ticker}`);
34
+ return formatToolResult(data);
35
+ },
36
+ });
37
+
38
+ export const getHistoricalCandlesticks = new DynamicStructuredTool({
39
+ name: 'get_historical_candlesticks',
40
+ description: 'Get historical candlestick data for a Kalshi market.',
41
+ schema: z.object({
42
+ ticker: z.string().describe('Market ticker'),
43
+ start_ts: z.number().optional().describe('Start timestamp (Unix seconds)'),
44
+ end_ts: z.number().optional().describe('End timestamp (Unix seconds)'),
45
+ period_interval: z.number().optional().describe('Interval in minutes'),
46
+ }),
47
+ func: async (input) => {
48
+ const now = Math.floor(Date.now() / 1000);
49
+ const params: Record<string, number | undefined> = {
50
+ start_ts: input.start_ts ?? now - 30 * 24 * 3600,
51
+ end_ts: input.end_ts ?? now,
52
+ period_interval: input.period_interval,
53
+ };
54
+ const data = await callKalshiApi('GET', `/historical/markets/${input.ticker}/candlesticks`, { params });
55
+ return formatToolResult(data);
56
+ },
57
+ });
58
+
59
+ export const getHistoricalFills = new DynamicStructuredTool({
60
+ name: 'get_historical_fills',
61
+ description: 'Get historical fill data.',
62
+ schema: z.object({
63
+ ticker: z.string().optional().describe('Filter by market ticker'),
64
+ limit: z.number().optional().describe('Max fills to return'),
65
+ }),
66
+ func: async (input) => {
67
+ const params: Record<string, string | number | undefined> = {};
68
+ if (input.ticker) params.ticker = input.ticker;
69
+ if (input.limit) params.limit = input.limit;
70
+ const data = await callKalshiApi('GET', '/historical/fills', { params });
71
+ return formatToolResult(data);
72
+ },
73
+ });
74
+
75
+ export const getHistoricalOrders = new DynamicStructuredTool({
76
+ name: 'get_historical_orders',
77
+ description: 'Get historical order data.',
78
+ schema: z.object({
79
+ ticker: z.string().optional().describe('Filter by market ticker'),
80
+ limit: z.number().optional().describe('Max orders to return'),
81
+ }),
82
+ func: async (input) => {
83
+ const params: Record<string, string | number | undefined> = {};
84
+ if (input.ticker) params.ticker = input.ticker;
85
+ if (input.limit) params.limit = input.limit;
86
+ const data = await callKalshiApi('GET', '/historical/orders', { params });
87
+ return formatToolResult(data);
88
+ },
89
+ });
@@ -0,0 +1,11 @@
1
+ export * from './types.js';
2
+ export * from './api.js';
3
+ export * from './markets.js';
4
+ export * from './events.js';
5
+ export * from './series.js';
6
+ export * from './portfolio.js';
7
+ export * from './historical.js';
8
+ export * from './exchange.js';
9
+ export * from './trading.js';
10
+ export * from './kalshi-search.js';
11
+ export * from './kalshi-trade.js';
@@ -0,0 +1,437 @@
1
+ import { DynamicStructuredTool, StructuredToolInterface } from '@langchain/core/tools';
2
+ import type { RunnableConfig } from '@langchain/core/runnables';
3
+ import { AIMessage, ToolCall } from '@langchain/core/messages';
4
+ import { z } from 'zod';
5
+ import { callLlm } from '../../model/llm.js';
6
+ import { formatToolResult } from '../types.js';
7
+ import { getCurrentDate } from '../../agent/prompts.js';
8
+ import { octagonReportTool } from '../v2/octagon-report.js';
9
+ import { callKalshiApi } from './api.js';
10
+ import { logger } from '../../utils/logger.js';
11
+
12
+ // All read-only Kalshi tools available for routing
13
+ import { getMarkets, getMarket, getMarketOrderbook, getMarketCandlesticks } from './markets.js';
14
+ import { getEvents, getEvent } from './events.js';
15
+ import { getSeries } from './series.js';
16
+ import { getBalance, getPositions, getFills, getSettlements, getOrders, getOrder } from './portfolio.js';
17
+ import { getHistoricalMarkets, getHistoricalMarket, getHistoricalCandlesticks, getHistoricalFills, getHistoricalOrders } from './historical.js';
18
+ import { getExchangeStatus, getExchangeSchedule } from './exchange.js';
19
+
20
+ export const KALSHI_SEARCH_DESCRIPTION = `
21
+ Intelligent meta-tool for Kalshi prediction market research. Takes a natural language query and automatically routes to appropriate Kalshi data sources.
22
+
23
+ ## When to Use
24
+
25
+ - Finding markets by topic, category, or keyword
26
+ - Getting market prices (yes/no bid/ask), volume, and close dates
27
+ - Fetching event details and related markets
28
+ - Checking portfolio balance, positions, fills, and orders
29
+ - Getting orderbook depth for a specific market
30
+ - Viewing historical market data and candlestick price charts
31
+ - Checking exchange status and trading schedule
32
+
33
+ ## When NOT to Use
34
+
35
+ - Placing, amending, or canceling orders (use kalshi_trade instead)
36
+ - General web research unrelated to Kalshi data (use web_search instead)
37
+
38
+ ## Usage Notes
39
+
40
+ - Call ONCE with the complete natural language query
41
+ - Prices are in cents: 56 = $0.56 = 56% implied probability
42
+ - Tickers follow patterns: KXBTC-26MAR-B50000, PRES-2024-DJT
43
+ - YES price + NO price = ~100 cents (the complement)
44
+ `.trim();
45
+
46
+ /** Format snake_case tool name to Title Case for progress messages */
47
+ function formatSubToolName(name: string): string {
48
+ return name
49
+ .split('_')
50
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
51
+ .join(' ');
52
+ }
53
+
54
+ const KALSHI_READ_TOOLS: StructuredToolInterface[] = [
55
+ getMarkets,
56
+ getMarket,
57
+ getMarketOrderbook,
58
+ getMarketCandlesticks,
59
+ getEvents,
60
+ getEvent,
61
+ getSeries,
62
+ getBalance,
63
+ getPositions,
64
+ getFills,
65
+ getSettlements,
66
+ getOrders,
67
+ getOrder,
68
+ getHistoricalMarkets,
69
+ getHistoricalMarket,
70
+ getHistoricalCandlesticks,
71
+ getHistoricalFills,
72
+ getHistoricalOrders,
73
+ getExchangeStatus,
74
+ getExchangeSchedule,
75
+ ];
76
+
77
+ const KALSHI_TOOL_MAP = new Map(KALSHI_READ_TOOLS.map((t) => [t.name, t]));
78
+
79
+ function buildRouterPrompt(): string {
80
+ return `You are a Kalshi prediction market data routing assistant.
81
+ Current date: ${getCurrentDate()}
82
+
83
+ You MUST call at least one tool. Never respond with text alone — always call a tool to fetch live data.
84
+
85
+ ## CRITICAL: Use Title Search
86
+ The Kalshi API has no keyword search endpoint, but get_events supports a "title" parameter that filters events by title keywords (case-insensitive substring match). ALWAYS use this to find events by topic:
87
+ - "Fed decision in June" → get_events(title="fed decision", status="open", with_nested_markets=true)
88
+ - "Tesla deliveries" → get_events(title="tesla", status="open", with_nested_markets=true)
89
+ - "Bitcoin price" → get_events(title="bitcoin", status="open", with_nested_markets=true)
90
+ - "inflation" → get_events(title="inflation", status="open", with_nested_markets=true)
91
+
92
+ Use short, broad keywords for the title filter. Prefer single words or two-word phrases — do NOT pass full sentences.
93
+
94
+ Only fall back to get_events(status="open", limit=200) without a title filter if the topic is extremely vague.
95
+
96
+ ## Multi-Step Strategy
97
+
98
+ You may be called multiple times with accumulated results. Follow this pattern:
99
+
100
+ 1. **Search by title**: Extract keywords from the query and call get_events(title="keyword", status="open", with_nested_markets=true)
101
+ 2. **Browse**: If title search returns nothing, broaden the keyword or use get_events(status="open", limit=200)
102
+ 3. **Drill down**: Once you find relevant event tickers, call get_event(event_ticker="...", with_nested_markets=true) for contract-level prices
103
+ 4. **Complete**: When you have sufficient data (especially prices/probabilities), respond with text only — do NOT call more tools
104
+
105
+ Always prefer get_event(..., with_nested_markets=true) when you need prices for a known event.
106
+
107
+ ## Tool Selection
108
+
109
+ - **Topic search** → get_events(title="keyword", status="open", with_nested_markets=true)
110
+ - **Broad browse** → get_events(status="open", limit=200) — only if title search fails
111
+ - **Known event ticker** → get_event(event_ticker="KXBTC-26MAR", with_nested_markets=true)
112
+ - **Known market ticker** → get_market(ticker="KXBTC-26MAR-B80000")
113
+ - **Orderbook depth** → get_market_orderbook(ticker=...)
114
+ - **Price history** → get_market_candlesticks or get_historical_candlesticks
115
+ - **Portfolio balance** → get_balance
116
+ - **Open positions** → get_positions
117
+ - **Recent fills** → get_fills
118
+ - **Resting orders** → get_orders(status="resting")
119
+ - **Exchange open?** → get_exchange_status
120
+
121
+ ## Ticker Formats
122
+ - Series: KXBTC, KXPRES, KXETH
123
+ - Event: KXBTC-26MAR, KXPRES-28
124
+ - Market: KXBTC-26MAR-B80000, KXPRES-28-DJT
125
+
126
+ ## Price Interpretation
127
+ - Prices are in cents (1–99): 56 = $0.56 = 56% implied probability
128
+ - YES + NO prices ≈ 100 cents
129
+
130
+ Call the appropriate tool(s) now.`;
131
+ }
132
+
133
+ export interface SubToolResult {
134
+ tool: string;
135
+ args: Record<string, unknown>;
136
+ data: unknown;
137
+ error: string | null;
138
+ }
139
+
140
+ async function executeToolCalls(toolCalls: ToolCall[]): Promise<SubToolResult[]> {
141
+ return Promise.all(
142
+ toolCalls.map(async (tc) => {
143
+ try {
144
+ const tool = KALSHI_TOOL_MAP.get(tc.name);
145
+ if (!tool) throw new Error(`Tool '${tc.name}' not found`);
146
+ const rawResult = await tool.invoke(tc.args);
147
+ const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
148
+ const parsed = JSON.parse(result);
149
+ return { tool: tc.name, args: tc.args as Record<string, unknown>, data: parsed.data, error: null };
150
+ } catch (error) {
151
+ return {
152
+ tool: tc.name,
153
+ args: tc.args as Record<string, unknown>,
154
+ data: null,
155
+ error: error instanceof Error ? error.message : String(error),
156
+ };
157
+ }
158
+ })
159
+ );
160
+ }
161
+
162
+ function buildFollowUpPrompt(originalQuery: string, allResults: SubToolResult[]): string {
163
+ const resultsText = allResults
164
+ .map((r) => {
165
+ const header = `[${r.tool}(${JSON.stringify(r.args)})]`;
166
+ if (r.error) return `${header} ERROR: ${r.error}`;
167
+ return `${header}\n${JSON.stringify(r.data, null, 2)}`;
168
+ })
169
+ .join('\n\n');
170
+
171
+ return `Original query: ${originalQuery}
172
+
173
+ Data retrieved so far:
174
+ ${resultsText}
175
+
176
+ If you have sufficient data (especially prices/probabilities) to answer the query, respond with text only — do NOT call more tools.
177
+ Otherwise, call the next tool(s) needed to drill down (e.g. get_event with with_nested_markets=true for contract-level prices).`;
178
+ }
179
+
180
+ interface ExtractedEvent {
181
+ event_ticker: string;
182
+ series_ticker?: string;
183
+ title?: string;
184
+ /** Full Kalshi event URL for Octagon */
185
+ url?: string;
186
+ /** Source priority: get_event (drill-down) > get_events (list) */
187
+ priority: number;
188
+ }
189
+
190
+ /**
191
+ * Convert a title to a URL slug.
192
+ */
193
+ function titleToSlug(title: string): string {
194
+ return title
195
+ .toLowerCase()
196
+ .replace(/[^a-z0-9\s-]/g, '')
197
+ .trim()
198
+ .replace(/\s+/g, '-');
199
+ }
200
+
201
+ /** Cache series title lookups to avoid repeated API calls */
202
+ const seriesTitleCache = new Map<string, string>();
203
+
204
+ /**
205
+ * Build the correct Kalshi event URL by fetching the series title for the slug.
206
+ * URL format: https://kalshi.com/markets/{series_ticker}/{series_slug}/{event_ticker}
207
+ * The slug comes from the series title (e.g. "Tesla deliveries" → "tesla-deliveries"),
208
+ * NOT the event title.
209
+ */
210
+ export async function buildKalshiEventUrl(seriesTicker: string, eventTicker: string): Promise<string | undefined> {
211
+ try {
212
+ let seriesTitle = seriesTitleCache.get(seriesTicker);
213
+ if (!seriesTitle) {
214
+ const data = await callKalshiApi('GET', `/series/${seriesTicker}`);
215
+ const series = (data.series ?? data) as Record<string, unknown>;
216
+ seriesTitle = series.title as string | undefined;
217
+ if (seriesTitle) {
218
+ seriesTitleCache.set(seriesTicker, seriesTitle);
219
+ }
220
+ }
221
+ if (seriesTitle) {
222
+ const slug = titleToSlug(seriesTitle);
223
+ return `https://kalshi.com/markets/${seriesTicker.toLowerCase()}/${slug}/${eventTicker.toLowerCase()}`;
224
+ }
225
+ } catch {
226
+ // Fall back — can't build URL without series title
227
+ }
228
+ return undefined;
229
+ }
230
+
231
+ /**
232
+ * Extract unique events from sub-tool results for octagon_report.
233
+ * Returns events sorted by relevance: drill-down results first.
234
+ * Octagon needs event-level URLs: https://kalshi.com/markets/{series}/{title-slug}/{event_ticker}
235
+ * Exported for testing.
236
+ */
237
+ export function extractEventsFromResults(results: SubToolResult[]): ExtractedEvent[] {
238
+ const events: ExtractedEvent[] = [];
239
+ const seen = new Set<string>();
240
+
241
+ function addEvent(eventTicker: string, seriesTicker?: string, title?: string, priority = 0) {
242
+ if (seen.has(eventTicker)) {
243
+ // Upgrade priority if this source is higher priority
244
+ const existing = events.find(e => e.event_ticker === eventTicker);
245
+ if (existing && priority > existing.priority) {
246
+ existing.priority = priority;
247
+ if (seriesTicker && !existing.series_ticker) existing.series_ticker = seriesTicker;
248
+ if (title && !existing.title) existing.title = title;
249
+ }
250
+ return;
251
+ }
252
+ seen.add(eventTicker);
253
+ // URL is resolved async later via buildKalshiEventUrl — not set here
254
+ events.push({ event_ticker: eventTicker, series_ticker: seriesTicker, title, url: undefined, priority });
255
+ }
256
+
257
+ for (const r of results) {
258
+ if (r.error || !r.data) continue;
259
+ const data = r.data as Record<string, unknown>;
260
+
261
+ // From get_events (list): lower priority
262
+ const eventsList = (data.events ?? []) as Array<Record<string, unknown>>;
263
+ for (const event of eventsList) {
264
+ const eventTicker = event.event_ticker as string | undefined;
265
+ if (eventTicker) {
266
+ addEvent(eventTicker, event.series_ticker as string | undefined, event.title as string | undefined, 0);
267
+ }
268
+ }
269
+
270
+ // From get_event (drill-down): highest priority — the LLM chose this event
271
+ const singleEvent = data.event as Record<string, unknown> | undefined;
272
+ if (singleEvent?.event_ticker) {
273
+ addEvent(
274
+ singleEvent.event_ticker as string,
275
+ singleEvent.series_ticker as string | undefined,
276
+ singleEvent.title as string | undefined,
277
+ 2
278
+ );
279
+ }
280
+
281
+ // From get_market: extract event_ticker
282
+ if (data.market && typeof data.market === 'object') {
283
+ const market = data.market as Record<string, unknown>;
284
+ if (market.event_ticker && typeof market.event_ticker === 'string') {
285
+ addEvent(market.event_ticker, market.series_ticker as string | undefined, undefined, 1);
286
+ }
287
+ }
288
+ }
289
+
290
+ // Sort by priority (drill-down first)
291
+ events.sort((a, b) => b.priority - a.priority);
292
+
293
+ return events;
294
+ }
295
+
296
+ /** Extract market ticker strings (for backward compatibility with tests) */
297
+ export function extractTickersFromResults(results: SubToolResult[]): string[] {
298
+ const tickers: string[] = [];
299
+ const seen = new Set<string>();
300
+ for (const r of results) {
301
+ if (r.error || !r.data) continue;
302
+ const data = r.data as Record<string, unknown>;
303
+ const eventsList = (data.events ?? []) as Array<Record<string, unknown>>;
304
+ for (const event of eventsList) {
305
+ for (const market of (event.markets ?? []) as Array<Record<string, unknown>>) {
306
+ if (market.ticker && typeof market.ticker === 'string' && !seen.has(market.ticker)) {
307
+ seen.add(market.ticker);
308
+ tickers.push(market.ticker);
309
+ }
310
+ }
311
+ }
312
+ const singleEvent = data.event as Record<string, unknown> | undefined;
313
+ if (singleEvent?.markets) {
314
+ for (const market of singleEvent.markets as Array<Record<string, unknown>>) {
315
+ if (market.ticker && typeof market.ticker === 'string' && !seen.has(market.ticker)) {
316
+ seen.add(market.ticker);
317
+ tickers.push(market.ticker);
318
+ }
319
+ }
320
+ }
321
+ if (data.market && typeof data.market === 'object') {
322
+ const market = data.market as Record<string, unknown>;
323
+ if (market.ticker && typeof market.ticker === 'string' && !seen.has(market.ticker)) {
324
+ seen.add(market.ticker);
325
+ tickers.push(market.ticker);
326
+ }
327
+ }
328
+ }
329
+ return tickers;
330
+ }
331
+
332
+ /** @deprecated Use extractEventsFromResults instead */
333
+ export function extractMarketsFromResults(results: SubToolResult[]) {
334
+ return extractEventsFromResults(results);
335
+ }
336
+
337
+ const MAX_ITERATIONS = 3;
338
+
339
+ const KalshiSearchInputSchema = z.object({
340
+ query: z.string().describe('Natural language query about Kalshi markets or portfolio'),
341
+ });
342
+
343
+ export function createKalshiSearch(model: string): DynamicStructuredTool {
344
+ return new DynamicStructuredTool({
345
+ name: 'kalshi_search',
346
+ description: KALSHI_SEARCH_DESCRIPTION,
347
+ schema: KalshiSearchInputSchema,
348
+ func: async (input, _runManager, config?: RunnableConfig) => {
349
+ const onProgress = config?.metadata?.onProgress as ((msg: string) => void) | undefined;
350
+ const allResults: SubToolResult[] = [];
351
+ const systemPrompt = buildRouterPrompt();
352
+
353
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
354
+ const isFirst = iteration === 0;
355
+
356
+ onProgress?.(
357
+ isFirst
358
+ ? 'Searching Kalshi...'
359
+ : iteration === 1
360
+ ? 'Drilling down into results...'
361
+ : 'Analyzing results...'
362
+ );
363
+
364
+ const prompt = isFirst ? input.query : buildFollowUpPrompt(input.query, allResults);
365
+
366
+ const { response } = await callLlm(prompt, {
367
+ model,
368
+ systemPrompt,
369
+ tools: KALSHI_READ_TOOLS,
370
+ toolChoice: isFirst ? 'required' : 'auto',
371
+ });
372
+ const aiMessage = response as AIMessage;
373
+
374
+ const toolCalls = aiMessage.tool_calls as ToolCall[];
375
+ if (!toolCalls || toolCalls.length === 0) {
376
+ // No tool calls — LLM decided it has enough data (or first iteration failed)
377
+ if (isFirst) {
378
+ return formatToolResult({ error: 'No tools selected for query' });
379
+ }
380
+ break;
381
+ }
382
+
383
+ const toolNames = [...new Set(toolCalls.map((tc) => formatSubToolName(tc.name)))];
384
+ onProgress?.(`Fetching ${toolNames.join(', ')}...`);
385
+
386
+ const results = await executeToolCalls(toolCalls);
387
+ allResults.push(...results);
388
+ }
389
+
390
+ // Build combined data from all iterations
391
+ const combinedData: Record<string, unknown> = {};
392
+ for (const result of allResults.filter((r) => r.error === null)) {
393
+ const ticker = result.args.ticker as string | undefined;
394
+ const eventTicker = result.args.event_ticker as string | undefined;
395
+ const key = ticker
396
+ ? `${result.tool}_${ticker}`
397
+ : eventTicker
398
+ ? `${result.tool}_${eventTicker}`
399
+ : result.tool;
400
+ combinedData[key] = result.data;
401
+ }
402
+
403
+ const failed = allResults.filter((r) => r.error !== null);
404
+ if (failed.length > 0) {
405
+ combinedData._errors = failed.map((r) => ({ tool: r.tool, error: r.error }));
406
+ }
407
+
408
+ // Auto-call octagon_report for the most relevant event
409
+ const extractedEvents = extractEventsFromResults(allResults);
410
+ logger.info(`[kalshi-search] Extracted ${extractedEvents.length} events from ${allResults.length} results`);
411
+ if (extractedEvents.length > 0) {
412
+ const target = extractedEvents[0];
413
+ onProgress?.('Fetching Octagon report...');
414
+ try {
415
+ // Resolve the correct Kalshi URL via series API lookup
416
+ let octagonInput = target.event_ticker;
417
+ if (target.series_ticker) {
418
+ const url = await buildKalshiEventUrl(target.series_ticker, target.event_ticker);
419
+ if (url) octagonInput = url;
420
+ }
421
+ logger.info(`[kalshi-search] Auto-calling octagon_report for ${octagonInput}`);
422
+ const octagonResult = await octagonReportTool.invoke({ ticker: octagonInput });
423
+ const parsed = typeof octagonResult === 'string' ? JSON.parse(octagonResult) : octagonResult;
424
+ combinedData.octagon_report = parsed.data ?? parsed;
425
+ logger.info(`[kalshi-search] octagon_report succeeded`);
426
+ } catch (error) {
427
+ logger.warn(`[kalshi-search] octagon_report failed:`, error);
428
+ combinedData._octagon_error = error instanceof Error ? error.message : String(error);
429
+ }
430
+ } else {
431
+ logger.warn(`[kalshi-search] No events found in results, skipping octagon_report`);
432
+ }
433
+
434
+ return formatToolResult(combinedData);
435
+ },
436
+ });
437
+ }