kalshi-trading-bot-cli 2.1.3 → 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.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Series-level rollup over Octagon's /kalshi/markets endpoint.
3
+ *
4
+ * "Series" is the Kalshi grouping above markets — KXBTCD is a series, while
5
+ * KXBTCD-26DEC31-T100000 is a market inside it. Octagon doesn't expose a
6
+ * dedicated series endpoint, so we paginate /kalshi/markets and reduce by
7
+ * series_ticker client-side. The open universe is ~few thousand markets so
8
+ * this is cheap (2-3 paginated calls).
9
+ */
10
+ import { wrapSuccess, wrapError } from './json.js';
11
+ import type { CLIResponse } from './json.js';
12
+ import type { ParsedArgs } from './parse-args.js';
13
+ import {
14
+ searchKalshiMarkets,
15
+ getBasketCandles,
16
+ listKalshiSeries,
17
+ getSeriesEvents,
18
+ type KalshiMarketRow,
19
+ type BasketCandlesResponse,
20
+ type SeriesRollupRow,
21
+ type SeriesEventRow,
22
+ } from '../scan/octagon-kalshi-api.js';
23
+ import { formatTable } from './scan-formatters.js';
24
+
25
+ const UNIVERSE_PAGE_LIMIT = 200;
26
+ const MAX_PAGES = 25; // safety cap; universe is small
27
+
28
+ function truncate(s: string, max: number): string {
29
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
30
+ }
31
+
32
+ function fmtVol(v: number | null | undefined): string {
33
+ if (v === null || v === undefined) return '-';
34
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
35
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
36
+ return v.toFixed(0);
37
+ }
38
+
39
+ function fmtMoney(v: number | null | undefined): string {
40
+ if (v === null || v === undefined) return '-';
41
+ return `$${v.toFixed(2)}`;
42
+ }
43
+
44
+ // ─── Rollup logic ───────────────────────────────────────────────────────────
45
+
46
+ export interface SeriesRollup {
47
+ series_ticker: string;
48
+ market_count: number;
49
+ active_count: number;
50
+ total_volume_24h: number;
51
+ total_open_interest: number;
52
+ dominant_category: string | null;
53
+ sample_titles: string[];
54
+ earliest_close: string | null;
55
+ latest_close: string | null;
56
+ }
57
+
58
+ function rollupBySeries(markets: KalshiMarketRow[]): SeriesRollup[] {
59
+ const map = new Map<string, {
60
+ market_count: number;
61
+ active_count: number;
62
+ total_volume_24h: number;
63
+ total_open_interest: number;
64
+ category_counts: Map<string, number>;
65
+ sample_titles: string[];
66
+ earliest_close: string | null;
67
+ latest_close: string | null;
68
+ }>();
69
+
70
+ for (const m of markets) {
71
+ // Use series_ticker if present, else derive from market_ticker prefix
72
+ // (Kalshi convention: SERIES-EVENT-OUTCOME)
73
+ const series = m.series_ticker ?? m.market_ticker.split('-')[0];
74
+ if (!series) continue;
75
+
76
+ let entry = map.get(series);
77
+ if (!entry) {
78
+ entry = {
79
+ market_count: 0,
80
+ active_count: 0,
81
+ total_volume_24h: 0,
82
+ total_open_interest: 0,
83
+ category_counts: new Map(),
84
+ sample_titles: [],
85
+ earliest_close: null,
86
+ latest_close: null,
87
+ };
88
+ map.set(series, entry);
89
+ }
90
+
91
+ entry.market_count += 1;
92
+ if (m.status === 'active' || m.status === 'open') entry.active_count += 1;
93
+ entry.total_volume_24h += m.volume_24h ?? 0;
94
+ entry.total_open_interest += m.open_interest ?? 0;
95
+
96
+ const cat = m.category ?? '';
97
+ if (cat) entry.category_counts.set(cat, (entry.category_counts.get(cat) ?? 0) + 1);
98
+
99
+ if (entry.sample_titles.length < 3 && m.title) entry.sample_titles.push(m.title);
100
+
101
+ if (m.close_time) {
102
+ if (!entry.earliest_close || m.close_time < entry.earliest_close) entry.earliest_close = m.close_time;
103
+ if (!entry.latest_close || m.close_time > entry.latest_close) entry.latest_close = m.close_time;
104
+ }
105
+ }
106
+
107
+ return Array.from(map.entries()).map(([series_ticker, agg]) => {
108
+ let dominant_category: string | null = null;
109
+ let dominantCount = 0;
110
+ for (const [cat, count] of agg.category_counts.entries()) {
111
+ if (count > dominantCount) {
112
+ dominant_category = cat;
113
+ dominantCount = count;
114
+ }
115
+ }
116
+ return {
117
+ series_ticker,
118
+ market_count: agg.market_count,
119
+ active_count: agg.active_count,
120
+ total_volume_24h: agg.total_volume_24h,
121
+ total_open_interest: agg.total_open_interest,
122
+ dominant_category,
123
+ sample_titles: agg.sample_titles,
124
+ earliest_close: agg.earliest_close,
125
+ latest_close: agg.latest_close,
126
+ };
127
+ });
128
+ }
129
+
130
+ async function fetchUniverse(opts: {
131
+ q?: string;
132
+ category?: string;
133
+ series_ticker?: string;
134
+ min_volume_24h?: number;
135
+ close_before?: string;
136
+ maxMarkets?: number;
137
+ }): Promise<KalshiMarketRow[]> {
138
+ const all: KalshiMarketRow[] = [];
139
+ let cursor: string | undefined;
140
+ const cap = opts.maxMarkets ?? 5000;
141
+ for (let i = 0; i < MAX_PAGES; i++) {
142
+ const page = await searchKalshiMarkets({
143
+ q: opts.q,
144
+ category: opts.category,
145
+ series_ticker: opts.series_ticker,
146
+ min_volume_24h: opts.min_volume_24h,
147
+ close_before: opts.close_before,
148
+ limit: UNIVERSE_PAGE_LIMIT,
149
+ cursor,
150
+ });
151
+ all.push(...page.data);
152
+ if (all.length >= cap || !page.has_more || !page.next_cursor) break;
153
+ cursor = page.next_cursor;
154
+ }
155
+ return all;
156
+ }
157
+
158
+ // ─── Handlers ───────────────────────────────────────────────────────────────
159
+
160
+ export type SeriesResult =
161
+ | { kind: 'list'; data: SeriesRollup[]; total_markets: number }
162
+ | { kind: 'server-list'; data: SeriesRollupRow[]; has_more: boolean }
163
+ | { kind: 'detail'; series_ticker: string; rollup: SeriesRollup; markets: KalshiMarketRow[] }
164
+ | { kind: 'candles'; series_ticker: string; tickers_used: string[]; data: BasketCandlesResponse }
165
+ | { kind: 'events'; series_ticker: string; data: SeriesEventRow[]; has_more: boolean };
166
+
167
+ export async function handleSeries(args: ParsedArgs): Promise<CLIResponse<SeriesResult>> {
168
+ try {
169
+ const positional = args.positionalArgs[0]?.toLowerCase();
170
+
171
+ // series candles <ticker> [--timeframe]
172
+ if (positional === 'candles') {
173
+ const seriesTicker = (args.positionalArgs[1] ?? args.seriesTicker)?.toUpperCase();
174
+ if (!seriesTicker) {
175
+ return wrapError('series', 'MISSING_SERIES', 'Usage: series candles <series_ticker> [--timeframe 1y]');
176
+ }
177
+ // Server-side prefix match — replaces the old paginate-then-filter dance.
178
+ const topN = args.topK ?? 20;
179
+ const page = await searchKalshiMarkets({
180
+ series_prefix: seriesTicker,
181
+ sort_by: 'volume_24h',
182
+ limit: topN,
183
+ });
184
+ if (page.data.length === 0) {
185
+ return wrapError('series', 'EMPTY_SERIES', `No markets found for series ${seriesTicker}`);
186
+ }
187
+ const tickers = page.data.map((m) => m.market_ticker);
188
+ const data = await getBasketCandles({ market_tickers: tickers, timeframe: args.timeframe });
189
+ return wrapSuccess('series', { kind: 'candles', series_ticker: seriesTicker, tickers_used: tickers, data });
190
+ }
191
+
192
+ // series events <ticker> — list events in a series (new endpoint)
193
+ if (positional === 'events') {
194
+ const seriesTicker = (args.positionalArgs[1] ?? args.seriesTicker)?.toUpperCase();
195
+ if (!seriesTicker) {
196
+ return wrapError('series', 'MISSING_SERIES', 'Usage: series events <series_ticker> [--limit N] [-q "filter"]');
197
+ }
198
+ const resp = await getSeriesEvents(seriesTicker, { limit: args.limit, q: args.query });
199
+ return wrapSuccess('series', {
200
+ kind: 'events', series_ticker: seriesTicker, data: resp.data, has_more: !!resp.has_more,
201
+ });
202
+ }
203
+
204
+ // series search <query> [--min-volume N] — keyword search rolled up by series
205
+ if (positional === 'search') {
206
+ const q = args.positionalArgs.slice(1).join(' ');
207
+ if (!q) {
208
+ return wrapError('series', 'MISSING_QUERY', 'Usage: series search <query>');
209
+ }
210
+ const markets = await fetchUniverse({ q, min_volume_24h: args.minVolume });
211
+ const rollups = rollupBySeries(markets).sort((a, b) => b.total_volume_24h - a.total_volume_24h);
212
+ const limited = rollups.slice(0, args.limit ?? 30);
213
+ return wrapSuccess('series', { kind: 'list', data: limited, total_markets: markets.length });
214
+ }
215
+
216
+ // series <SERIES_TICKER> — drill into one series (server-side prefix match)
217
+ if (args.positionalArgs[0] && args.positionalArgs[0].toUpperCase().startsWith('KX')) {
218
+ const seriesTicker = args.positionalArgs[0].toUpperCase();
219
+ const limit = args.limit ?? 30;
220
+ const page = await searchKalshiMarkets({
221
+ series_prefix: seriesTicker,
222
+ sort_by: 'volume_24h',
223
+ limit,
224
+ });
225
+ if (page.data.length === 0) {
226
+ return wrapError('series', 'EMPTY_SERIES', `No markets found for series ${seriesTicker}`);
227
+ }
228
+ const rollup = rollupBySeries(page.data)[0];
229
+ return wrapSuccess('series', {
230
+ kind: 'detail', series_ticker: seriesTicker, rollup, markets: page.data,
231
+ });
232
+ }
233
+
234
+ // series list [--category C] [--min-volume N] [--limit N] [--series-prefix KX]
235
+ // Uses the new server-side /kalshi/series rollup endpoint — 1 call vs paginated reduce.
236
+ const serverPage = await listKalshiSeries({
237
+ series_prefix: args.seriesTicker,
238
+ category: args.category,
239
+ min_volume_24h: args.minVolume,
240
+ sort_by: 'total_volume_24h',
241
+ limit: args.limit ?? 50,
242
+ });
243
+ return wrapSuccess('series', {
244
+ kind: 'server-list', data: serverPage.data, has_more: !!serverPage.has_more,
245
+ });
246
+ } catch (err) {
247
+ const message = err instanceof Error ? err.message : String(err);
248
+ return wrapError('series', 'OCTAGON_ERROR', message);
249
+ }
250
+ }
251
+
252
+ // ─── Formatters ─────────────────────────────────────────────────────────────
253
+
254
+ export function formatSeriesHuman(result: SeriesResult): string {
255
+ if (result.kind === 'list') return formatSeriesList(result.data, result.total_markets);
256
+ if (result.kind === 'server-list') return formatServerSeriesList(result.data, result.has_more);
257
+ if (result.kind === 'detail') return formatSeriesDetail(result.series_ticker, result.rollup, result.markets);
258
+ if (result.kind === 'events') return formatSeriesEvents(result.series_ticker, result.data, result.has_more);
259
+ return formatSeriesCandles(result.series_ticker, result.tickers_used, result.data);
260
+ }
261
+
262
+ function formatServerSeriesList(rows: SeriesRollupRow[], hasMore: boolean): string {
263
+ const lines: string[] = [];
264
+ const more = hasMore ? ' (more available)' : '';
265
+ lines.push(`Series rollup — ${rows.length} series${more}, sorted by 24h volume (server-side)`);
266
+ lines.push('');
267
+ if (rows.length === 0) {
268
+ lines.push('No series match.');
269
+ return lines.join('\n');
270
+ }
271
+ const tableRows: string[][] = rows.map((r) => [
272
+ r.series_ticker,
273
+ truncate(r.series_title ?? r.dominant_category ?? '-', 30),
274
+ String(r.active_count),
275
+ String(r.market_count),
276
+ fmtVol(r.total_volume_24h),
277
+ r.dominant_category ?? '-',
278
+ (r.last_seen_at ?? '').slice(0, 10),
279
+ ]);
280
+ lines.push(formatTable(
281
+ ['Series', 'Title / Category', 'Active', 'Total', '24h Vol', 'Dom Cat', 'Last seen'],
282
+ tableRows,
283
+ ));
284
+ lines.push('');
285
+ lines.push('Use "series <SERIES>" to drill in, "series events <SERIES>" to see events, "series candles <SERIES>" for NAV.');
286
+ return lines.join('\n');
287
+ }
288
+
289
+ function formatSeriesEvents(seriesTicker: string, events: SeriesEventRow[], hasMore: boolean): string {
290
+ const lines: string[] = [];
291
+ const more = hasMore ? ' (more available)' : '';
292
+ lines.push(`Events in series ${seriesTicker} — ${events.length} shown${more}`);
293
+ lines.push('');
294
+ if (events.length === 0) {
295
+ lines.push('No events in this series.');
296
+ return lines.join('\n');
297
+ }
298
+ const rows: string[][] = events.map((e) => [
299
+ e.event_ticker,
300
+ truncate(e.title ?? '', 45),
301
+ e.category ?? '-',
302
+ (e.close_time ?? '').slice(0, 10) || '-',
303
+ e.has_report ? '✓' : '-',
304
+ ]);
305
+ lines.push(formatTable(['Event', 'Title', 'Category', 'Closes', 'Report'], rows));
306
+ return lines.join('\n');
307
+ }
308
+
309
+ function formatSeriesList(rollups: SeriesRollup[], totalMarkets: number): string {
310
+ const lines: string[] = [];
311
+ lines.push(`Series rollup — ${rollups.length} series across ${totalMarkets} markets, sorted by 24h volume`);
312
+ lines.push('');
313
+ if (rollups.length === 0) {
314
+ lines.push('No series match.');
315
+ return lines.join('\n');
316
+ }
317
+ const rows: string[][] = rollups.map((r) => [
318
+ r.series_ticker,
319
+ truncate(r.sample_titles[0] ?? '-', 40),
320
+ String(r.active_count),
321
+ String(r.market_count),
322
+ fmtVol(r.total_volume_24h),
323
+ fmtVol(r.total_open_interest),
324
+ r.dominant_category ?? '-',
325
+ (r.earliest_close ?? '').slice(0, 10),
326
+ ]);
327
+ lines.push(formatTable(
328
+ ['Series', 'Sample title', 'Active', 'Total', '24h Vol', 'OI', 'Category', 'Earliest close'],
329
+ rows,
330
+ ));
331
+ lines.push('');
332
+ lines.push('Use "series <SERIES>" to drill in, "series candles <SERIES>" for theme NAV.');
333
+ return lines.join('\n');
334
+ }
335
+
336
+ function formatSeriesDetail(seriesTicker: string, rollup: SeriesRollup, markets: KalshiMarketRow[]): string {
337
+ const lines: string[] = [];
338
+ lines.push(`Series ${seriesTicker} — ${rollup.active_count}/${rollup.market_count} active, $${rollup.total_volume_24h.toFixed(0)} 24h vol`);
339
+ lines.push(` Category ${rollup.dominant_category ?? '-'}`);
340
+ lines.push(` Close range ${(rollup.earliest_close ?? '-').slice(0, 10)} → ${(rollup.latest_close ?? '-').slice(0, 10)}`);
341
+ lines.push('');
342
+ if (markets.length === 0) {
343
+ lines.push('No sub-markets.');
344
+ return lines.join('\n');
345
+ }
346
+ const rows: string[][] = markets.map((m) => [
347
+ m.market_ticker,
348
+ truncate(m.title, 38),
349
+ m.status,
350
+ fmtMoney(m.last_price ?? m.yes_ask),
351
+ fmtVol(m.volume_24h),
352
+ (m.close_time ?? '').slice(0, 10),
353
+ ]);
354
+ lines.push(formatTable(['Market', 'Title', 'Status', 'Last', '24h Vol', 'Closes'], rows));
355
+ return lines.join('\n');
356
+ }
357
+
358
+ function formatSeriesCandles(seriesTicker: string, tickers: string[], data: BasketCandlesResponse): string {
359
+ const lines: string[] = [];
360
+ lines.push(`Series ${seriesTicker} NAV — ${data.timeframe} window, ${data.candles.length} bins, basket of top ${tickers.length} sub-markets`);
361
+ if (data.missing.length > 0) {
362
+ lines.push(` Excluded (no candle history): ${data.missing.length}`);
363
+ }
364
+ lines.push('');
365
+ if (data.candles.length === 0) {
366
+ lines.push('No candles available.');
367
+ return lines.join('\n');
368
+ }
369
+ const recent = data.candles.slice(-10);
370
+ const rows: string[][] = recent.map((c) => [
371
+ new Date(c.time * 1000).toISOString().slice(0, 16).replace('T', ' '),
372
+ c.open.toFixed(3),
373
+ c.high.toFixed(3),
374
+ c.low.toFixed(3),
375
+ c.close.toFixed(3),
376
+ ]);
377
+ lines.push(formatTable(['Time (UTC)', 'Open', 'High', 'Low', 'Close'], rows));
378
+ if (data.candles.length > recent.length) {
379
+ lines.push('');
380
+ lines.push(`(showing last ${recent.length} of ${data.candles.length} bins — use --json for all)`);
381
+ }
382
+ return lines.join('\n');
383
+ }
384
+
385
+ // Exposed so theme commands can reuse the rollup + universe fetcher
386
+ export { fetchUniverse, rollupBySeries };
@@ -0,0 +1,97 @@
1
+ import { wrapSuccess, wrapError } from './json.js';
2
+ import type { CLIResponse } from './json.js';
3
+ import type { ParsedArgs } from './parse-args.js';
4
+ import { findSimilarMarkets, type SimilarResponse, type SimilarMarketRow } from '../scan/octagon-kalshi-api.js';
5
+ import { formatTable } from './scan-formatters.js';
6
+
7
+ function truncate(s: string, max: number): string {
8
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
9
+ }
10
+
11
+ function fmtMoney(v: number | null | undefined): string {
12
+ if (v === null || v === undefined) return '-';
13
+ return `$${v.toFixed(2)}`;
14
+ }
15
+
16
+ function fmtVol(v: number | null | undefined): string {
17
+ if (v === null || v === undefined) return '-';
18
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
19
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
20
+ return v.toFixed(0);
21
+ }
22
+
23
+ function looksLikeTicker(s: string): boolean {
24
+ // Kalshi tickers are uppercase with hyphens, digits, no spaces. Anything with
25
+ // a space or lowercase letter is treated as a free-text query.
26
+ return /^[A-Z0-9._-]+$/i.test(s) && /[A-Z]/i.test(s) && s.includes('-');
27
+ }
28
+
29
+ export async function handleSimilar(args: ParsedArgs): Promise<CLIResponse<SimilarResponse>> {
30
+ const positional = args.positionalArgs.join(' ').trim();
31
+ let anchorTicker = args.ticker;
32
+ let q = args.query;
33
+
34
+ if (!anchorTicker && !q && positional) {
35
+ // Single-token uppercase-ish string with a hyphen → treat as ticker, else as query.
36
+ if (looksLikeTicker(positional)) {
37
+ anchorTicker = positional.toUpperCase();
38
+ } else {
39
+ q = positional;
40
+ }
41
+ }
42
+
43
+ if (!anchorTicker && !q) {
44
+ return wrapError('similar', 'MISSING_ANCHOR', 'Usage: similar <ticker> | similar -q "query text" [--top-k N] [--category C] [--min-volume N] [--close-before ISO]');
45
+ }
46
+ if (anchorTicker && q) {
47
+ return wrapError('similar', 'AMBIGUOUS_ANCHOR', 'Pass either a ticker or -q "query", not both.');
48
+ }
49
+
50
+ try {
51
+ const data = await findSimilarMarkets({
52
+ anchor_ticker: anchorTicker,
53
+ q,
54
+ top_k: args.topK,
55
+ category: args.category,
56
+ min_volume_24h: args.minVolume,
57
+ close_before: args.closeBefore,
58
+ });
59
+ return wrapSuccess('similar', data);
60
+ } catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ return wrapError('similar', 'OCTAGON_ERROR', message);
63
+ }
64
+ }
65
+
66
+ export function formatSimilarHuman(data: SimilarResponse): string {
67
+ const lines: string[] = [];
68
+ const anchor = data.anchor_ticker
69
+ ? `ticker ${data.anchor_ticker}`
70
+ : data.anchor_query
71
+ ? `query "${data.anchor_query}"`
72
+ : 'unknown anchor';
73
+ lines.push(`Markets similar to ${anchor} — ${data.data.length} result(s)`);
74
+ lines.push('');
75
+
76
+ if (data.data.length === 0) {
77
+ lines.push('No similar markets found.');
78
+ return lines.join('\n');
79
+ }
80
+
81
+ const rows: string[][] = data.data.map((m: SimilarMarketRow) => [
82
+ m.market_ticker,
83
+ truncate(m.title, 40),
84
+ m.distance.toFixed(3),
85
+ fmtMoney(m.last_price ?? m.yes_ask),
86
+ fmtVol(m.volume_24h),
87
+ m.category ?? '-',
88
+ ]);
89
+
90
+ lines.push(formatTable(
91
+ ['Ticker', 'Title', 'Distance', 'Last', '24h Vol', 'Category'],
92
+ rows,
93
+ ));
94
+ lines.push('');
95
+ lines.push('Lower distance = closer cosine similarity.');
96
+ return lines.join('\n');
97
+ }
@@ -68,6 +68,15 @@ export class IntroComponent extends Container {
68
68
  this.addChild(new Spacer(1));
69
69
  const cmd = (label: string) => theme.muted(label.padEnd(11));
70
70
  this.addChild(new Text(cmd('/search') + 'Search events by theme, ticker, or free-text; /search edge for edge scan', 0, 0));
71
+ this.addChild(new Text(cmd('/similar') + '<ticker|"text"> Semantic neighbors (Octagon embeddings)', 0, 0));
72
+ this.addChild(new Text(cmd('/clusters') + '[--ranked|--behavioral] Browse thematic & behavioral clusters', 0, 0));
73
+ this.addChild(new Text(cmd('/peers') + '<ticker> Markets in the same cluster', 0, 0));
74
+ this.addChild(new Text(cmd('/events') + '[ticker] Octagon events + outcome ladder', 0, 0));
75
+ this.addChild(new Text(cmd('/series') + '[ticker] Series rollup; /series candles <SERIES> for NAV', 0, 0));
76
+ this.addChild(new Text(cmd('/themes') + 'list|show|report|audit|overlap Editorial narrative registry', 0, 0));
77
+ this.addChild(new Text(cmd('/catalysts') + 'upcoming --days N Markets closing soon, grouped by week', 0, 0));
78
+ this.addChild(new Text(cmd('/correlate') + '<t1> <t2> [...] Pairwise correlation matrix', 0, 0));
79
+ this.addChild(new Text(cmd('/basket') + 'build|backtest|size|candles|validate Diversified basket tools', 0, 0));
71
80
  this.addChild(new Text(cmd('/portfolio') + 'Overview, positions, orders, balance, status', 0, 0));
72
81
  this.addChild(new Text(cmd('/analyze') + '<ticker> Full analysis: edge, research, Kelly sizing', 0, 0));
73
82
  this.addChild(new Text(cmd('/watch') + '<ticker> Live price/orderbook feed', 0, 0));
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Editorial themes registry — user-curated narrative buckets (e.g. "AI Race
3
+ * Milestones", "Iran Escalation") that map to lists of Kalshi series. Distinct
4
+ * from the existing `themes` table (which holds Kalshi-API watch lists) and
5
+ * from Octagon's nightly k-means clusters (which are embedding-derived).
6
+ */
7
+ import type { Database } from 'bun:sqlite';
8
+
9
+ export interface EditorialThemeRow {
10
+ name: string;
11
+ description: string | null;
12
+ search_volume: number | null;
13
+ created_at: number;
14
+ updated_at: number;
15
+ }
16
+
17
+ export interface EditorialThemeWithSeries extends EditorialThemeRow {
18
+ series: string[];
19
+ }
20
+
21
+ export function listEditorialThemes(db: Database): EditorialThemeRow[] {
22
+ return db.query(
23
+ 'SELECT name, description, search_volume, created_at, updated_at FROM editorial_themes ORDER BY name',
24
+ ).all() as EditorialThemeRow[];
25
+ }
26
+
27
+ export function getEditorialTheme(db: Database, name: string): EditorialThemeWithSeries | null {
28
+ const row = db.query(
29
+ 'SELECT name, description, search_volume, created_at, updated_at FROM editorial_themes WHERE name = $name',
30
+ ).get({ $name: name }) as EditorialThemeRow | undefined;
31
+ if (!row) return null;
32
+ const series = db.query(
33
+ 'SELECT series_ticker FROM editorial_theme_series WHERE theme_name = $name ORDER BY series_ticker',
34
+ ).all({ $name: name }) as Array<{ series_ticker: string }>;
35
+ return { ...row, series: series.map((s) => s.series_ticker) };
36
+ }
37
+
38
+ export function upsertEditorialTheme(
39
+ db: Database,
40
+ args: { name: string; description?: string | null; search_volume?: number | null },
41
+ ): void {
42
+ const now = Date.now();
43
+ db.query(
44
+ `INSERT INTO editorial_themes (name, description, search_volume, created_at, updated_at)
45
+ VALUES ($name, $description, $search_volume, $now, $now)
46
+ ON CONFLICT(name) DO UPDATE SET
47
+ description = COALESCE($description, description),
48
+ search_volume = COALESCE($search_volume, search_volume),
49
+ updated_at = $now`,
50
+ ).run({
51
+ $name: args.name,
52
+ $description: args.description ?? null,
53
+ $search_volume: args.search_volume ?? null,
54
+ $now: now,
55
+ });
56
+ }
57
+
58
+ export function deleteEditorialTheme(db: Database, name: string): boolean {
59
+ const before = (db.query('SELECT COUNT(*) as n FROM editorial_themes WHERE name = $name').get({ $name: name }) as { n: number }).n;
60
+ db.query('DELETE FROM editorial_themes WHERE name = $name').run({ $name: name });
61
+ return before > 0;
62
+ }
63
+
64
+ export function addSeriesToTheme(db: Database, themeName: string, seriesTickers: string[]): number {
65
+ const stmt = db.query(
66
+ `INSERT OR IGNORE INTO editorial_theme_series (theme_name, series_ticker) VALUES ($name, $series)`,
67
+ );
68
+ let added = 0;
69
+ for (const s of seriesTickers) {
70
+ const upper = s.trim().toUpperCase();
71
+ if (!upper) continue;
72
+ const changes = stmt.run({ $name: themeName, $series: upper });
73
+ if (changes.changes > 0) added += 1;
74
+ }
75
+ return added;
76
+ }
77
+
78
+ export function removeSeriesFromTheme(db: Database, themeName: string, seriesTickers: string[]): number {
79
+ const stmt = db.query(
80
+ `DELETE FROM editorial_theme_series WHERE theme_name = $name AND series_ticker = $series`,
81
+ );
82
+ let removed = 0;
83
+ for (const s of seriesTickers) {
84
+ const upper = s.trim().toUpperCase();
85
+ const changes = stmt.run({ $name: themeName, $series: upper });
86
+ removed += changes.changes;
87
+ }
88
+ return removed;
89
+ }
90
+
91
+ export function setSearchVolume(db: Database, themeName: string, volume: number): boolean {
92
+ const result = db.query(
93
+ `UPDATE editorial_themes SET search_volume = $vol, updated_at = $now WHERE name = $name`,
94
+ ).run({ $name: themeName, $vol: volume, $now: Date.now() });
95
+ return result.changes > 0;
96
+ }
97
+
98
+ /**
99
+ * Returns series tickers that appear in more than one theme. Useful for
100
+ * cross-theme dedupe audits.
101
+ */
102
+ export function findSeriesOverlaps(db: Database): Array<{ series_ticker: string; themes: string[] }> {
103
+ const rows = db.query(
104
+ `SELECT series_ticker, GROUP_CONCAT(theme_name, '|') as themes_csv
105
+ FROM editorial_theme_series
106
+ GROUP BY series_ticker
107
+ HAVING COUNT(*) > 1
108
+ ORDER BY series_ticker`,
109
+ ).all() as Array<{ series_ticker: string; themes_csv: string }>;
110
+ return rows.map((r) => ({ series_ticker: r.series_ticker, themes: r.themes_csv.split('|') }));
111
+ }