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,102 @@
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 { placeOrder, amendOrder, cancelOrder, cancelOrders, placeBatchOrders } from './trading.js';
9
+
10
+ const TRADING_TOOLS: StructuredToolInterface[] = [
11
+ placeOrder,
12
+ amendOrder,
13
+ cancelOrder,
14
+ cancelOrders,
15
+ placeBatchOrders,
16
+ ];
17
+
18
+ const TRADING_TOOL_MAP = new Map(TRADING_TOOLS.map((t) => [t.name, t]));
19
+
20
+ export const KALSHI_TRADE_DESCRIPTION = `
21
+ Execute trading actions on Kalshi prediction markets. Routes natural language trade instructions to the correct API endpoints.
22
+
23
+ ## When to Use
24
+
25
+ - Placing buy or sell orders (limit or market)
26
+ - Amending resting orders (price or quantity)
27
+ - Canceling one or more resting orders
28
+ - Placing batch orders
29
+
30
+ ## IMPORTANT
31
+
32
+ - NEVER call this tool without explicit user confirmation of trade details
33
+ - Always confirm: ticker, side (yes/no), action (buy/sell), count, and price
34
+ - Prices are in cents: $0.56 = 56 cents
35
+ `.trim();
36
+
37
+ function buildTradingRouterPrompt(): string {
38
+ return `You are a Kalshi trading execution assistant.
39
+ Current date: ${getCurrentDate()}
40
+
41
+ Given a trading instruction, call the appropriate trading tool.
42
+
43
+ ## Key Facts
44
+ - Prices are in cents (1-99): $0.56 → yes_price: 56
45
+ - side: "yes" or "no" (the contract type)
46
+ - action: "buy" or "sell"
47
+ - type: "limit" (use yes_price) or "market" (no price needed)
48
+ - To cancel by order_id, use cancel_order
49
+ - For multiple cancels, use cancel_orders with order_ids array
50
+
51
+ Execute the trading action now.`;
52
+ }
53
+
54
+ const KalshiTradeInputSchema = z.object({
55
+ action: z.string().describe('Natural language description of the trade to execute'),
56
+ });
57
+
58
+ export function createKalshiTrade(model: string): DynamicStructuredTool {
59
+ return new DynamicStructuredTool({
60
+ name: 'kalshi_trade',
61
+ description: KALSHI_TRADE_DESCRIPTION,
62
+ schema: KalshiTradeInputSchema,
63
+ func: async (input, _runManager, config?: RunnableConfig) => {
64
+ const onProgress = config?.metadata?.onProgress as ((msg: string) => void) | undefined;
65
+
66
+ onProgress?.('Preparing trade...');
67
+ const { response } = await callLlm(input.action, {
68
+ model,
69
+ systemPrompt: buildTradingRouterPrompt(),
70
+ tools: TRADING_TOOLS,
71
+ });
72
+ const aiMessage = response as AIMessage;
73
+
74
+ const toolCalls = aiMessage.tool_calls as ToolCall[];
75
+ if (!toolCalls || toolCalls.length === 0) {
76
+ return formatToolResult({ error: 'Could not parse trade instruction into a valid order' });
77
+ }
78
+
79
+ const results = [];
80
+ for (const tc of toolCalls) {
81
+ onProgress?.(`Executing ${tc.name.replace(/_/g, ' ')}...`);
82
+ try {
83
+ const tool = TRADING_TOOL_MAP.get(tc.name);
84
+ if (!tool) throw new Error(`Tool '${tc.name}' not found`);
85
+ const rawResult = await tool.invoke(tc.args);
86
+ const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
87
+ const parsed = JSON.parse(result);
88
+ results.push({ tool: tc.name, args: tc.args, data: parsed.data, error: null });
89
+ } catch (error) {
90
+ results.push({
91
+ tool: tc.name,
92
+ args: tc.args,
93
+ data: null,
94
+ error: error instanceof Error ? error.message : String(error),
95
+ });
96
+ }
97
+ }
98
+
99
+ return formatToolResult({ results });
100
+ },
101
+ });
102
+ }
@@ -0,0 +1,76 @@
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 getMarkets = new DynamicStructuredTool({
7
+ name: 'get_markets',
8
+ description: 'Get a list of Kalshi markets, optionally filtered by event ticker, series ticker, status, or specific tickers.',
9
+ schema: z.object({
10
+ event_ticker: z.string().optional().describe('Filter by event ticker'),
11
+ series_ticker: z.string().optional().describe('Filter by series ticker'),
12
+ status: z.enum(['open', 'closed', 'settled']).optional().describe('Market status filter'),
13
+ tickers: z.array(z.string()).optional().describe('Specific market tickers to fetch'),
14
+ limit: z.number().optional().describe('Max markets to return (default 100)'),
15
+ }),
16
+ func: async (input) => {
17
+ const params: Record<string, string | number | undefined> = {};
18
+ if (input.event_ticker) params.event_ticker = input.event_ticker;
19
+ if (input.series_ticker) params.series_ticker = input.series_ticker;
20
+ if (input.status) params.status = input.status;
21
+ if (input.tickers?.length) params.tickers = input.tickers.join(',');
22
+ if (input.limit) params.limit = input.limit;
23
+
24
+ const data = await callKalshiApi('GET', '/markets', { params });
25
+ return formatToolResult(data);
26
+ },
27
+ });
28
+
29
+ export const getMarket = new DynamicStructuredTool({
30
+ name: 'get_market',
31
+ description: 'Get details for a specific Kalshi market by ticker.',
32
+ schema: z.object({
33
+ ticker: z.string().describe('Market ticker (e.g. KXBTC-26MAR-B50000)'),
34
+ }),
35
+ func: async (input) => {
36
+ const data = await callKalshiApi('GET', `/markets/${input.ticker}`);
37
+ return formatToolResult(data);
38
+ },
39
+ });
40
+
41
+ export const getMarketOrderbook = new DynamicStructuredTool({
42
+ name: 'get_market_orderbook',
43
+ description: 'Get the current orderbook for a Kalshi market.',
44
+ schema: z.object({
45
+ ticker: z.string().describe('Market ticker'),
46
+ depth: z.number().optional().describe('Number of price levels to return'),
47
+ }),
48
+ func: async (input) => {
49
+ const params: Record<string, number | undefined> = {};
50
+ if (input.depth) params.depth = input.depth;
51
+ const data = await callKalshiApi('GET', `/markets/${input.ticker}/orderbook`, { params });
52
+ return formatToolResult(data);
53
+ },
54
+ });
55
+
56
+ export const getMarketCandlesticks = new DynamicStructuredTool({
57
+ name: 'get_market_candlesticks',
58
+ description: 'Get candlestick price data for a Kalshi market.',
59
+ schema: z.object({
60
+ ticker: z.string().describe('Market ticker'),
61
+ start_ts: z.number().optional().describe('Start timestamp (Unix seconds)'),
62
+ end_ts: z.number().optional().describe('End timestamp (Unix seconds)'),
63
+ period_interval: z.number().optional().describe('Candlestick interval in minutes (default 60)'),
64
+ }),
65
+ func: async (input) => {
66
+ const now = Math.floor(Date.now() / 1000);
67
+ const params: Record<string, string | number | undefined> = {
68
+ market_tickers: input.ticker,
69
+ start_ts: input.start_ts ?? now - 7 * 24 * 3600,
70
+ end_ts: input.end_ts ?? now,
71
+ period_interval: input.period_interval,
72
+ };
73
+ const data = await callKalshiApi('GET', '/markets/candlesticks', { params });
74
+ return formatToolResult(data);
75
+ },
76
+ });
@@ -0,0 +1,100 @@
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 getBalance = new DynamicStructuredTool({
7
+ name: 'get_balance',
8
+ description: 'Get the current Kalshi account balance.',
9
+ schema: z.object({}),
10
+ func: async () => {
11
+ const data = await callKalshiApi('GET', '/portfolio/balance');
12
+ return formatToolResult(data);
13
+ },
14
+ });
15
+
16
+ export const getPositions = new DynamicStructuredTool({
17
+ name: 'get_positions',
18
+ description: 'Get current open positions in Kalshi markets.',
19
+ schema: z.object({
20
+ event_ticker: z.string().optional().describe('Filter by event ticker'),
21
+ ticker: z.string().optional().describe('Filter by market ticker'),
22
+ }),
23
+ func: async (input) => {
24
+ const params: Record<string, string | undefined> = {};
25
+ if (input.event_ticker) params.event_ticker = input.event_ticker;
26
+ if (input.ticker) params.ticker = input.ticker;
27
+ const data = await callKalshiApi('GET', '/portfolio/positions', { params });
28
+ return formatToolResult(data);
29
+ },
30
+ });
31
+
32
+ export const getFills = new DynamicStructuredTool({
33
+ name: 'get_fills',
34
+ description: 'Get trade fills (executed orders) from the portfolio.',
35
+ schema: z.object({
36
+ ticker: z.string().optional().describe('Filter by market ticker'),
37
+ order_id: z.string().optional().describe('Filter by order ID'),
38
+ min_ts: z.number().optional().describe('Min timestamp (Unix seconds)'),
39
+ max_ts: z.number().optional().describe('Max timestamp (Unix seconds)'),
40
+ limit: z.number().optional().describe('Max fills to return'),
41
+ }),
42
+ func: async (input) => {
43
+ const params: Record<string, string | number | undefined> = {};
44
+ if (input.ticker) params.ticker = input.ticker;
45
+ if (input.order_id) params.order_id = input.order_id;
46
+ if (input.min_ts) params.min_ts = input.min_ts;
47
+ if (input.max_ts) params.max_ts = input.max_ts;
48
+ if (input.limit) params.limit = input.limit;
49
+ const data = await callKalshiApi('GET', '/portfolio/fills', { params });
50
+ return formatToolResult(data);
51
+ },
52
+ });
53
+
54
+ export const getSettlements = new DynamicStructuredTool({
55
+ name: 'get_settlements',
56
+ description: 'Get settlement history for resolved markets.',
57
+ schema: z.object({
58
+ ticker: z.string().optional().describe('Filter by market ticker'),
59
+ limit: z.number().optional().describe('Max settlements to return'),
60
+ }),
61
+ func: async (input) => {
62
+ const params: Record<string, string | number | undefined> = {};
63
+ if (input.ticker) params.ticker = input.ticker;
64
+ if (input.limit) params.limit = input.limit;
65
+ const data = await callKalshiApi('GET', '/portfolio/settlements', { params });
66
+ return formatToolResult(data);
67
+ },
68
+ });
69
+
70
+ export const getOrders = new DynamicStructuredTool({
71
+ name: 'get_orders',
72
+ description: 'Get orders from the portfolio.',
73
+ schema: z.object({
74
+ ticker: z.string().optional().describe('Filter by market ticker'),
75
+ event_ticker: z.string().optional().describe('Filter by event ticker'),
76
+ status: z.enum(['resting', 'canceled', 'executed', 'all']).optional().describe('Order status filter'),
77
+ limit: z.number().optional().describe('Max orders to return'),
78
+ }),
79
+ func: async (input) => {
80
+ const params: Record<string, string | number | undefined> = {};
81
+ if (input.ticker) params.ticker = input.ticker;
82
+ if (input.event_ticker) params.event_ticker = input.event_ticker;
83
+ if (input.status) params.status = input.status;
84
+ if (input.limit) params.limit = input.limit;
85
+ const data = await callKalshiApi('GET', '/portfolio/orders', { params });
86
+ return formatToolResult(data);
87
+ },
88
+ });
89
+
90
+ export const getOrder = new DynamicStructuredTool({
91
+ name: 'get_order',
92
+ description: 'Get details for a specific order by order ID.',
93
+ schema: z.object({
94
+ order_id: z.string().describe('Order ID'),
95
+ }),
96
+ func: async (input) => {
97
+ const data = await callKalshiApi('GET', `/portfolio/orders/${input.order_id}`);
98
+ return formatToolResult(data);
99
+ },
100
+ });
@@ -0,0 +1,198 @@
1
+ import { getDb } from '../../db/index.js';
2
+ import { clearAndPopulateIndex, getIndexAge, setLastRefresh } from '../../db/event-index.js';
3
+ import { callKalshiApi, fetchAllPages } from './api.js';
4
+ import { logger } from '../../utils/logger.js';
5
+ import type { KalshiEvent, KalshiSeries } from './types.js';
6
+
7
+ /** Stale threshold: triggers background refresh */
8
+ const INDEX_STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
9
+
10
+ /** Hard TTL: data still usable but stale */
11
+ const INDEX_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
12
+
13
+ /** Singleton promise to prevent concurrent refreshes */
14
+ let _refreshPromise: Promise<void> | null = null;
15
+
16
+ /** Max concurrent series fetches during index refresh */
17
+ const SERIES_CONCURRENCY = 20;
18
+
19
+ // --- Progress observable ---
20
+
21
+ export type IndexProgressPhase = 'fetching_events' | 'fetching_series' | 'populating';
22
+
23
+ export interface IndexProgressInfo {
24
+ phase: IndexProgressPhase;
25
+ fetchedItems: number;
26
+ page: number;
27
+ maxPages: number;
28
+ detail?: string;
29
+ }
30
+
31
+ export type IndexProgressListener = (info: IndexProgressInfo) => void;
32
+
33
+ const _progressListeners = new Set<IndexProgressListener>();
34
+
35
+ /** Subscribe to index refresh progress. Returns an unsubscribe function. */
36
+ export function onIndexProgress(listener: IndexProgressListener): () => void {
37
+ _progressListeners.add(listener);
38
+ return () => { _progressListeners.delete(listener); };
39
+ }
40
+
41
+ function emitProgress(info: IndexProgressInfo): void {
42
+ for (const listener of _progressListeners) {
43
+ try { listener(info); } catch { /* ignore listener errors */ }
44
+ }
45
+ }
46
+
47
+ /** Get the current refresh promise so callers can await it if desired. */
48
+ export function getRefreshPromise(): Promise<void> | null {
49
+ return _refreshPromise;
50
+ }
51
+
52
+ /**
53
+ * Fetch series tags for a set of unique series tickers.
54
+ * Returns a map of series_ticker → tags array.
55
+ */
56
+ async function fetchSeriesTags(seriesTickers: string[], totalEvents: number): Promise<Map<string, string[]>> {
57
+ const tagsMap = new Map<string, string[]>();
58
+ // Process in batches to limit concurrency
59
+ for (let i = 0; i < seriesTickers.length; i += SERIES_CONCURRENCY) {
60
+ const batch = seriesTickers.slice(i, i + SERIES_CONCURRENCY);
61
+ const results = await Promise.allSettled(
62
+ batch.map(async (ticker) => {
63
+ const data = await callKalshiApi('GET', `/series/${ticker}`);
64
+ const series = (data.series ?? data) as KalshiSeries;
65
+ return { ticker, tags: series.tags ?? [] };
66
+ }),
67
+ );
68
+ for (const result of results) {
69
+ if (result.status === 'fulfilled' && result.value.tags.length > 0) {
70
+ tagsMap.set(result.value.ticker, result.value.tags);
71
+ }
72
+ }
73
+ emitProgress({
74
+ phase: 'fetching_series',
75
+ fetchedItems: Math.min(i + SERIES_CONCURRENCY, seriesTickers.length),
76
+ page: 0,
77
+ maxPages: 0,
78
+ detail: `Series tags: ${Math.min(i + SERIES_CONCURRENCY, seriesTickers.length)}/${seriesTickers.length} (${totalEvents} events)`,
79
+ });
80
+ }
81
+ return tagsMap;
82
+ }
83
+
84
+ /**
85
+ * Refresh the local event index by fetching all open events from Kalshi API.
86
+ */
87
+ async function refreshIndex(): Promise<void> {
88
+ const db = getDb();
89
+ logger.info('[search-index] Refreshing event index from Kalshi API...');
90
+ const start = Date.now();
91
+
92
+ try {
93
+ // Fetch events with nested markets — the API now includes volume_24h_fp
94
+ // and last_price_dollars on nested markets, so no separate enrichment needed
95
+ const events = await fetchAllPages<KalshiEvent>(
96
+ '/events',
97
+ { status: 'open', with_nested_markets: true },
98
+ 'events',
99
+ 20,
100
+ (info) => {
101
+ emitProgress({
102
+ phase: 'fetching_events',
103
+ fetchedItems: info.fetchedItems,
104
+ page: info.page,
105
+ maxPages: info.maxPages,
106
+ });
107
+ }
108
+ );
109
+
110
+ // Fetch series tags in parallel with index write
111
+ const uniqueSeries = [...new Set(events.map((e) => e.series_ticker).filter(Boolean))];
112
+ const seriesTagsPromise = fetchSeriesTags(uniqueSeries, events.length);
113
+
114
+ emitProgress({
115
+ phase: 'populating',
116
+ fetchedItems: events.length,
117
+ page: 0,
118
+ maxPages: 0,
119
+ detail: `Writing ${events.length} events to index...`,
120
+ });
121
+
122
+ // Write events with nested markets directly to the index
123
+ clearAndPopulateIndex(
124
+ db,
125
+ events.map((e) => ({
126
+ event_ticker: e.event_ticker,
127
+ series_ticker: e.series_ticker,
128
+ title: e.title,
129
+ category: e.category,
130
+ strike_date: e.strike_date,
131
+ sub_title: e.sub_title,
132
+ markets: e.markets,
133
+ })),
134
+ );
135
+
136
+ // Update tags on index rows
137
+ const seriesTags = await seriesTagsPromise;
138
+ if (seriesTags.size > 0) {
139
+ const updateTags = db.prepare('UPDATE event_index SET tags = $tags WHERE series_ticker = $series_ticker');
140
+ db.transaction(() => {
141
+ for (const [seriesTicker, tags] of seriesTags) {
142
+ updateTags.run({ $tags: tags.join(','), $series_ticker: seriesTicker });
143
+ }
144
+ })();
145
+ }
146
+
147
+ setLastRefresh(db, Date.now());
148
+
149
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
150
+ logger.info(`[search-index] Index refreshed: ${events.length} events in ${elapsed}s`);
151
+ } catch (error) {
152
+ logger.error('[search-index] Failed to refresh index:', error);
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Force an immediate index rebuild, bypassing the 2-hour stale check.
159
+ * If a refresh is already in progress, waits for it to complete first,
160
+ * then starts a new one.
161
+ */
162
+ export async function forceRefreshIndex(): Promise<void> {
163
+ if (_refreshPromise) {
164
+ await _refreshPromise;
165
+ }
166
+ _refreshPromise = refreshIndex().finally(() => {
167
+ _refreshPromise = null;
168
+ });
169
+ await _refreshPromise;
170
+ }
171
+
172
+ /**
173
+ * Ensure the local event index is fresh. If stale or empty, triggers a refresh.
174
+ * Always returns immediately (never blocks).
175
+ *
176
+ * - age < 2h: nothing to do
177
+ * - age >= 2h but < Infinity: fire-and-forget refresh, serve stale data
178
+ * - age === Infinity (first run): fire-and-forget refresh, return immediately
179
+ */
180
+ export async function ensureIndex(): Promise<void> {
181
+ const db = getDb();
182
+ const age = getIndexAge(db);
183
+
184
+ // Fresh index — nothing to do
185
+ if (age < INDEX_STALE_MS) return;
186
+
187
+ // Stale or first-run: trigger background refresh if not already running
188
+ if (!_refreshPromise) {
189
+ _refreshPromise = refreshIndex()
190
+ .catch((err) => {
191
+ logger.error('[search-index] Background refresh failed:', err);
192
+ })
193
+ .finally(() => {
194
+ _refreshPromise = null;
195
+ });
196
+ }
197
+ // Never block — return immediately regardless of first-run or stale
198
+ }
@@ -0,0 +1,16 @@
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 getSeries = new DynamicStructuredTool({
7
+ name: 'get_series',
8
+ description: 'Get details for a Kalshi series by series ticker.',
9
+ schema: z.object({
10
+ series_ticker: z.string().describe('Series ticker (e.g. KXBTC)'),
11
+ }),
12
+ func: async (input) => {
13
+ const data = await callKalshiApi('GET', `/series/${input.series_ticker}`);
14
+ return formatToolResult(data);
15
+ },
16
+ });
@@ -0,0 +1,115 @@
1
+ import { DynamicStructuredTool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { callKalshiApi, toDollarString } from './api.js';
4
+ import { formatToolResult } from '../types.js';
5
+
6
+ export const placeOrder = new DynamicStructuredTool({
7
+ name: 'place_order',
8
+ description: 'Place a new order on a Kalshi market.',
9
+ schema: z.object({
10
+ ticker: z.string().describe('Market ticker'),
11
+ action: z.enum(['buy', 'sell']).describe('Buy or sell'),
12
+ side: z.enum(['yes', 'no']).describe('Yes or No side'),
13
+ type: z.enum(['limit', 'market']).describe('Order type'),
14
+ count: z.number().int().positive().describe('Number of contracts'),
15
+ yes_price: z.number().int().min(1).max(99).optional().describe('Price in cents (1-99) for limit orders'),
16
+ expiration_ts: z.number().optional().describe('Order expiration Unix timestamp'),
17
+ client_order_id: z.string().optional().describe('Optional client-provided order ID'),
18
+ }),
19
+ func: async (input) => {
20
+ const body: Record<string, unknown> = {
21
+ ticker: input.ticker,
22
+ action: input.action,
23
+ side: input.side,
24
+ type: input.type,
25
+ count: input.count,
26
+ };
27
+ if (input.yes_price !== undefined) {
28
+ body.yes_price = input.yes_price;
29
+ body.dollar_price = toDollarString(input.yes_price);
30
+ }
31
+ if (input.expiration_ts !== undefined) body.expiration_ts = input.expiration_ts;
32
+ if (input.client_order_id) body.client_order_id = input.client_order_id;
33
+
34
+ const data = await callKalshiApi('POST', '/portfolio/orders', { body });
35
+ return formatToolResult(data);
36
+ },
37
+ });
38
+
39
+ export const amendOrder = new DynamicStructuredTool({
40
+ name: 'amend_order',
41
+ description: 'Amend an existing resting order.',
42
+ schema: z.object({
43
+ order_id: z.string().describe('Order ID to amend'),
44
+ count: z.number().int().positive().optional().describe('New contract count'),
45
+ yes_price: z.number().int().min(1).max(99).optional().describe('New price in cents'),
46
+ expiration_ts: z.number().optional().describe('New expiration timestamp'),
47
+ }),
48
+ func: async (input) => {
49
+ const body: Record<string, unknown> = {};
50
+ if (input.count !== undefined) body.count = input.count;
51
+ if (input.yes_price !== undefined) {
52
+ body.yes_price = input.yes_price;
53
+ body.dollar_price = toDollarString(input.yes_price);
54
+ }
55
+ if (input.expiration_ts !== undefined) body.expiration_ts = input.expiration_ts;
56
+
57
+ const data = await callKalshiApi('POST', `/portfolio/orders/${input.order_id}/amend`, { body });
58
+ return formatToolResult(data);
59
+ },
60
+ });
61
+
62
+ export const cancelOrder = new DynamicStructuredTool({
63
+ name: 'cancel_order',
64
+ description: 'Cancel an existing resting order.',
65
+ schema: z.object({
66
+ order_id: z.string().describe('Order ID to cancel'),
67
+ }),
68
+ func: async (input) => {
69
+ const data = await callKalshiApi('DELETE', `/portfolio/orders/${input.order_id}`);
70
+ return formatToolResult(data);
71
+ },
72
+ });
73
+
74
+ export const cancelOrders = new DynamicStructuredTool({
75
+ name: 'cancel_orders',
76
+ description: 'Cancel multiple resting orders in batch.',
77
+ schema: z.object({
78
+ order_ids: z.array(z.string()).describe('List of order IDs to cancel'),
79
+ }),
80
+ func: async (input) => {
81
+ const body = { order_ids: input.order_ids };
82
+ const data = await callKalshiApi('DELETE', '/portfolio/orders/batched', { body });
83
+ return formatToolResult(data);
84
+ },
85
+ });
86
+
87
+ export const placeBatchOrders = new DynamicStructuredTool({
88
+ name: 'place_batch_orders',
89
+ description: 'Place multiple orders in a single batch request.',
90
+ schema: z.object({
91
+ orders: z
92
+ .array(
93
+ z.object({
94
+ ticker: z.string(),
95
+ action: z.enum(['buy', 'sell']),
96
+ side: z.enum(['yes', 'no']),
97
+ type: z.enum(['limit', 'market']),
98
+ count: z.number().int().positive(),
99
+ yes_price: z.number().int().min(1).max(99).optional(),
100
+ })
101
+ )
102
+ .describe('List of orders to place'),
103
+ }),
104
+ func: async (input) => {
105
+ const orders = input.orders.map((o) => {
106
+ if (o.yes_price !== undefined) {
107
+ return { ...o, dollar_price: toDollarString(o.yes_price) };
108
+ }
109
+ return o;
110
+ });
111
+ const body = { orders };
112
+ const data = await callKalshiApi('POST', '/portfolio/orders/batched', { body });
113
+ return formatToolResult(data);
114
+ },
115
+ });