kalshi-trading-bot-cli 2.1.2 → 2.1.4

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.
@@ -17,8 +17,18 @@ export interface IndexedEvent {
17
17
  * Search the local event index using keyword matching.
18
18
  * All keywords must match against title, event_ticker, series_ticker, or category.
19
19
  * Returns up to `limit` results.
20
+ *
21
+ * By default, only events with at least one active (open/active status, not past
22
+ * close_time) market are returned, and expired markets are stripped from each
23
+ * event's `markets_json`. Pass `{ includeExpired: true }` to disable both filters.
20
24
  */
21
- export function searchEventIndex(db: Database, query: string, limit = 50): IndexedEvent[] {
25
+ export function searchEventIndex(
26
+ db: Database,
27
+ query: string,
28
+ limit = 50,
29
+ options: { includeExpired?: boolean } = {},
30
+ ): IndexedEvent[] {
31
+ const { includeExpired = false } = options;
22
32
  const keywords = query
23
33
  .toLowerCase()
24
34
  .split(/\s+/)
@@ -30,11 +40,21 @@ export function searchEventIndex(db: Database, query: string, limit = 50): Index
30
40
  const conditions = keywords.map((_, i) => `(search_text LIKE $kw${i})`);
31
41
  const whereClause = conditions.join(' AND ');
32
42
 
33
- const params: Record<string, string | number> = { $limit: limit, $now: new Date().toISOString() };
43
+ const now = new Date().toISOString();
44
+ const params: Record<string, string | number> = { $limit: limit, $now: now };
34
45
  keywords.forEach((kw, i) => {
35
46
  params[`$kw${i}`] = `%${kw}%`;
36
47
  });
37
48
 
49
+ // Require at least one active market unless caller opts in to expired events.
50
+ const activeMarketsClause = includeExpired
51
+ ? ''
52
+ : `AND EXISTS (
53
+ SELECT 1 FROM json_each(markets_json)
54
+ WHERE json_extract(value, '$.status') IN ('open','active')
55
+ AND (json_extract(value, '$.close_time') IS NULL OR json_extract(value, '$.close_time') > $now)
56
+ )`;
57
+
38
58
  // Use a CTE to compute search_text, filter expired markets, and rank by open-market volume descending
39
59
  const fullSql = `
40
60
  WITH indexed AS (
@@ -46,6 +66,7 @@ export function searchEventIndex(db: Database, query: string, limit = 50): Index
46
66
  SELECT event_ticker, series_ticker, title, category, strike_date, sub_title, tags, markets_json, indexed_at
47
67
  FROM indexed
48
68
  WHERE ${whereClause}
69
+ ${activeMarketsClause}
49
70
  )
50
71
  SELECT *
51
72
  FROM matched
@@ -62,7 +83,62 @@ export function searchEventIndex(db: Database, query: string, limit = 50): Index
62
83
  LIMIT $limit
63
84
  `;
64
85
 
65
- return db.query(fullSql).all(params) as IndexedEvent[];
86
+ const rows = db.query(fullSql).all(params) as IndexedEvent[];
87
+ if (includeExpired) return rows;
88
+
89
+ // Strip expired markets from each event's markets_json so callers never see them.
90
+ return rows.map((r) => ({
91
+ ...r,
92
+ markets_json: filterActiveMarketsJson(r.markets_json, now),
93
+ }));
94
+ }
95
+
96
+ /**
97
+ * Predicate shared by every call site that filters expired markets.
98
+ * "Active" means status in ('open','active') AND (no close_time or close_time > now).
99
+ */
100
+ function isActiveMarketRecord(record: Record<string, unknown>, nowIso: string): boolean {
101
+ const status = record.status;
102
+ if (status !== 'open' && status !== 'active') return false;
103
+ const closeTime = record.close_time;
104
+ if (closeTime != null && typeof closeTime === 'string' && closeTime <= nowIso) return false;
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * Parse markets_json from the index into an array of object records.
110
+ * Returns [] on parse failure or non-array payloads, and drops any non-object entries.
111
+ */
112
+ function parseMarketsJsonSafe(markets_json: string | null): Array<Record<string, unknown>> {
113
+ if (!markets_json) return [];
114
+ let parsed: unknown;
115
+ try {
116
+ parsed = JSON.parse(markets_json);
117
+ } catch {
118
+ return [];
119
+ }
120
+ if (!Array.isArray(parsed)) return [];
121
+ return parsed.filter((m): m is Record<string, unknown> => typeof m === 'object' && m !== null);
122
+ }
123
+
124
+ /**
125
+ * Parse markets_json, drop markets that aren't currently active, and re-serialize.
126
+ * Returns the original string on parse failure (so callers don't lose data they could re-parse).
127
+ */
128
+ function filterActiveMarketsJson(markets_json: string | null, nowIso: string): string | null {
129
+ if (!markets_json) return markets_json;
130
+ let parsed: unknown;
131
+ try {
132
+ parsed = JSON.parse(markets_json);
133
+ } catch {
134
+ return markets_json;
135
+ }
136
+ if (!Array.isArray(parsed)) return markets_json;
137
+ const active = parsed.filter(
138
+ (m): m is Record<string, unknown> =>
139
+ typeof m === 'object' && m !== null && isActiveMarketRecord(m as Record<string, unknown>, nowIso),
140
+ );
141
+ return JSON.stringify(active);
66
142
  }
67
143
 
68
144
  /**
@@ -235,10 +311,20 @@ export function setLastRefresh(db: Database, timestamp: number): void {
235
311
  /**
236
312
  * Reconstruct KalshiEvent[] from the local index for given event tickers.
237
313
  * Parses markets_json back into nested market objects.
314
+ *
315
+ * By default, expired markets (status not open/active, or past close_time) are
316
+ * stripped from each event. Pass `{ includeExpired: true }` to keep them.
238
317
  */
239
- export function getEventsFromIndex(db: Database, eventTickers: string[]): KalshiEvent[] {
318
+ export function getEventsFromIndex(
319
+ db: Database,
320
+ eventTickers: string[],
321
+ options: { includeExpired?: boolean } = {},
322
+ ): KalshiEvent[] {
240
323
  if (eventTickers.length === 0) return [];
241
324
 
325
+ const { includeExpired = false } = options;
326
+ const nowIso = new Date().toISOString();
327
+
242
328
  const placeholders = eventTickers.map(() => '?').join(',');
243
329
  const rows = db
244
330
  .query(
@@ -248,25 +334,22 @@ export function getEventsFromIndex(db: Database, eventTickers: string[]): Kalshi
248
334
  )
249
335
  .all(...eventTickers) as IndexedEvent[];
250
336
 
251
- return rows
252
- .map((r) => {
253
- let markets: unknown[] = [];
254
- try {
255
- markets = r.markets_json ? JSON.parse(r.markets_json) : [];
256
- } catch {
257
- // Corrupted markets_json — skip markets for this event
258
- }
259
- return {
260
- event_ticker: r.event_ticker,
261
- series_ticker: r.series_ticker ?? '',
262
- title: r.title,
263
- category: r.category ?? '',
264
- sub_title: r.sub_title ?? '',
265
- strike_date: r.strike_date ?? '',
266
- mutually_exclusive: false,
267
- markets,
268
- } as KalshiEvent;
269
- });
337
+ return rows.map((r) => {
338
+ let markets = parseMarketsJsonSafe(r.markets_json);
339
+ if (!includeExpired) {
340
+ markets = markets.filter((m) => isActiveMarketRecord(m, nowIso));
341
+ }
342
+ return {
343
+ event_ticker: r.event_ticker,
344
+ series_ticker: r.series_ticker ?? '',
345
+ title: r.title,
346
+ category: r.category ?? '',
347
+ sub_title: r.sub_title ?? '',
348
+ strike_date: r.strike_date ?? '',
349
+ mutually_exclusive: false,
350
+ markets: markets as unknown as KalshiMarket[],
351
+ } as KalshiEvent;
352
+ });
270
353
  }
271
354
 
272
355
  /**
@@ -284,14 +367,9 @@ export function getTopEventsByVolume(db: Database, limit: number): KalshiEvent[]
284
367
 
285
368
  const events: Array<{ event: KalshiEvent; totalVolume: number }> = [];
286
369
  for (const r of rows) {
287
- let markets: any[] = [];
288
- try {
289
- markets = r.markets_json ? JSON.parse(r.markets_json) : [];
290
- } catch {
291
- // Corrupted markets_json — treat as no markets
292
- }
370
+ const markets = parseMarketsJsonSafe(r.markets_json);
293
371
  const totalVolume = markets.reduce(
294
- (sum: number, m: any) => sum + (parseFloat(m.volume) || parseFloat(m.volume_fp) || 0),
372
+ (sum, m) => sum + (parseFloat(String(m.volume ?? '')) || parseFloat(String(m.volume_fp ?? '')) || 0),
295
373
  0,
296
374
  );
297
375
  events.push({
@@ -303,7 +381,7 @@ export function getTopEventsByVolume(db: Database, limit: number): KalshiEvent[]
303
381
  sub_title: r.sub_title ?? '',
304
382
  strike_date: r.strike_date ?? '',
305
383
  mutually_exclusive: false,
306
- markets,
384
+ markets: markets as unknown as KalshiMarket[],
307
385
  } as KalshiEvent,
308
386
  totalVolume,
309
387
  });
package/src/db/schema.ts CHANGED
@@ -181,6 +181,24 @@ export function migrate(db: Database): void {
181
181
 
182
182
  CREATE INDEX IF NOT EXISTS idx_history_event
183
183
  ON octagon_history(event_ticker, captured_at);
184
+
185
+ CREATE TABLE IF NOT EXISTS editorial_themes (
186
+ name TEXT PRIMARY KEY,
187
+ description TEXT,
188
+ search_volume INTEGER,
189
+ created_at INTEGER NOT NULL,
190
+ updated_at INTEGER NOT NULL
191
+ );
192
+
193
+ CREATE TABLE IF NOT EXISTS editorial_theme_series (
194
+ theme_name TEXT NOT NULL,
195
+ series_ticker TEXT NOT NULL,
196
+ PRIMARY KEY (theme_name, series_ticker),
197
+ FOREIGN KEY (theme_name) REFERENCES editorial_themes(name) ON DELETE CASCADE
198
+ );
199
+
200
+ CREATE INDEX IF NOT EXISTS idx_ets_series
201
+ ON editorial_theme_series(series_ticker);
184
202
  `);
185
203
 
186
204
  // Schema migrations for columns added after initial release
@@ -23,8 +23,12 @@ function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
23
23
  performance: false,
24
24
  resolved: false,
25
25
  unresolved: false,
26
-
27
-
26
+ behavioral: false,
27
+ ranked: false,
28
+ showCluster: false,
29
+ activeOnly: false,
30
+ cells: false,
31
+ autoProbs: false,
28
32
  parseErrors: [],
29
33
  ...overrides,
30
34
  };
package/src/model/llm.ts CHANGED
@@ -9,11 +9,11 @@ import { BaseChatModel } from '@langchain/core/language_models/chat_models';
9
9
  import { StructuredToolInterface } from '@langchain/core/tools';
10
10
  import { Runnable } from '@langchain/core/runnables';
11
11
  import { z } from 'zod';
12
- import { DEFAULT_SYSTEM_PROMPT } from '@/agent/prompts';
13
- import type { TokenUsage } from '@/agent/types';
14
- import { logger } from '@/utils';
15
- import { classifyError, isNonRetryableError } from '@/utils/errors';
16
- import { resolveProvider, getProviderById } from '@/providers';
12
+ import { DEFAULT_SYSTEM_PROMPT } from '../agent/prompts.js';
13
+ import type { TokenUsage } from '../agent/types.js';
14
+ import { logger } from '../utils/index.js';
15
+ import { classifyError, isNonRetryableError } from '../utils/errors.js';
16
+ import { resolveProvider, getProviderById } from '../providers.js';
17
17
 
18
18
  export const DEFAULT_PROVIDER = 'openai';
19
19
  export const DEFAULT_MODEL = process.env.DEFAULT_MODEL ?? 'gpt-5.4';
@@ -48,6 +48,61 @@ const EVENTS_API_BASE = 'https://api.octagonai.co/v1';
48
48
  const PAGE_LIMIT = 200;
49
49
  const TIMEOUT_MS = 60_000;
50
50
 
51
+ /**
52
+ * Fetch a single page of events with optional filters. Useful for CLI commands
53
+ * that don't need the full universe — e.g. `events list --limit 50`.
54
+ */
55
+ export async function fetchOctagonEventsPage(opts?: {
56
+ limit?: number;
57
+ cursor?: string | null;
58
+ hasHistory?: boolean;
59
+ }): Promise<{ data: OctagonEventEntry[]; next_cursor: string | null; has_more: boolean }> {
60
+ const apiKey = process.env.OCTAGON_API_KEY;
61
+ if (!apiKey) throw new Error('OCTAGON_API_KEY not set');
62
+
63
+ const params = new URLSearchParams({ limit: String(opts?.limit ?? PAGE_LIMIT) });
64
+ if (opts?.hasHistory) params.set('has_history', 'true');
65
+ if (opts?.cursor) params.set('cursor', opts.cursor);
66
+
67
+ const controller = new AbortController();
68
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
69
+ let resp: Response;
70
+ try {
71
+ resp = await fetch(`${EVENTS_API_BASE}/prediction-markets/events?${params}`, {
72
+ headers: { Authorization: `Bearer ${apiKey}` },
73
+ signal: controller.signal,
74
+ });
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ if (!resp.ok) {
79
+ const body = await resp.text().catch(() => '');
80
+ throw new Error(`Octagon events API ${resp.status}: ${body.slice(0, 200)}`);
81
+ }
82
+ const page = (await resp.json()) as { data?: OctagonEventEntry[]; next_cursor?: string | null; has_more?: boolean };
83
+ return {
84
+ data: Array.isArray(page.data) ? page.data : [],
85
+ next_cursor: page.next_cursor ?? null,
86
+ has_more: !!page.has_more,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Look up a single event by ticker. Scans pages until found (universe is small).
92
+ * Returns null if not found.
93
+ */
94
+ export async function fetchOctagonEventByTicker(eventTicker: string): Promise<OctagonEventEntry | null> {
95
+ let cursor: string | null = null;
96
+ do {
97
+ const page: { data: OctagonEventEntry[]; next_cursor: string | null; has_more: boolean } =
98
+ await fetchOctagonEventsPage({ cursor });
99
+ const hit = page.data.find((e) => e.event_ticker === eventTicker);
100
+ if (hit) return hit;
101
+ cursor = page.has_more ? page.next_cursor : null;
102
+ } while (cursor);
103
+ return null;
104
+ }
105
+
51
106
  /**
52
107
  * Fetch all events from the Octagon Prediction Markets Events API,
53
108
  * paginating through all pages.