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.
- package/README.md +181 -5
- package/data/themes_seo.json +159 -0
- package/env.example +5 -2
- package/package.json +1 -1
- package/src/backtest/renderer.ts +0 -2
- package/src/cli.ts +88 -0
- package/src/commands/basket.ts +646 -0
- package/src/commands/catalysts.ts +121 -0
- package/src/commands/clusters.ts +153 -0
- package/src/commands/correlate.ts +112 -0
- package/src/commands/dispatch.ts +238 -7
- package/src/commands/editorial-themes.ts +494 -0
- package/src/commands/events.ts +140 -0
- package/src/commands/help.ts +299 -14
- package/src/commands/index.ts +137 -6
- package/src/commands/parse-args.ts +204 -1
- package/src/commands/peers.ts +87 -0
- package/src/commands/search-remote.ts +90 -0
- package/src/commands/series.ts +386 -0
- package/src/commands/similar.ts +97 -0
- package/src/components/intro.ts +9 -0
- package/src/db/editorial-themes.ts +111 -0
- package/src/db/event-index.ts +109 -31
- package/src/db/schema.ts +18 -0
- package/src/gateway/commands/handler.ts +6 -2
- package/src/scan/octagon-events-api.ts +55 -0
- package/src/scan/octagon-kalshi-api.ts +564 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editorial themes — narrative buckets that the user curates (e.g. "AI Race
|
|
3
|
+
* Milestones", "Iran Escalation"), mapped to lists of Kalshi series, optionally
|
|
4
|
+
* annotated with monthly SEO search volume.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands (under the existing `themes` slot, when first positional is one
|
|
7
|
+
* of these keywords):
|
|
8
|
+
* themes list List all editorial themes
|
|
9
|
+
* themes show <name> Drill into one theme
|
|
10
|
+
* themes create <name> [--label desc] [--search-volume N] [--series KXA,KXB]
|
|
11
|
+
* themes delete <name>
|
|
12
|
+
* themes add-series <name> KX-A,KX-B
|
|
13
|
+
* themes remove-series <name> KX-A
|
|
14
|
+
* themes set-search-volume <name> N
|
|
15
|
+
* themes import [<path>] Default: data/themes_seo.json
|
|
16
|
+
* themes export <path>
|
|
17
|
+
* themes report [--min-volume N] [--min-search N] 25-theme dashboard
|
|
18
|
+
* themes audit Flag dead themes (high SEO / zero vol)
|
|
19
|
+
* themes overlap Cross-theme dedupe report
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
22
|
+
import { resolve as resolvePath } from 'path';
|
|
23
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
24
|
+
import type { CLIResponse } from './json.js';
|
|
25
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
26
|
+
import { getDb } from '../db/index.js';
|
|
27
|
+
import {
|
|
28
|
+
listEditorialThemes,
|
|
29
|
+
getEditorialTheme,
|
|
30
|
+
upsertEditorialTheme,
|
|
31
|
+
deleteEditorialTheme,
|
|
32
|
+
addSeriesToTheme,
|
|
33
|
+
removeSeriesFromTheme,
|
|
34
|
+
setSearchVolume,
|
|
35
|
+
findSeriesOverlaps,
|
|
36
|
+
type EditorialThemeRow,
|
|
37
|
+
type EditorialThemeWithSeries,
|
|
38
|
+
} from '../db/editorial-themes.js';
|
|
39
|
+
import { type SeriesRollup } from './series.js';
|
|
40
|
+
import { listKalshiSeries, type SeriesRollupRow } from '../scan/octagon-kalshi-api.js';
|
|
41
|
+
import { formatTable } from './scan-formatters.js';
|
|
42
|
+
|
|
43
|
+
function truncate(s: string, max: number): string {
|
|
44
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fmtVol(v: number | null | undefined): string {
|
|
48
|
+
if (v === null || v === undefined) return '-';
|
|
49
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
50
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
|
51
|
+
return v.toFixed(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmtSearchVol(v: number | null | undefined): string {
|
|
55
|
+
if (v === null || v === undefined) return '-';
|
|
56
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
57
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`;
|
|
58
|
+
return String(v);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Result types ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export type EditorialThemeResult =
|
|
64
|
+
| { kind: 'list'; data: EditorialThemeRow[] }
|
|
65
|
+
| { kind: 'show'; theme: EditorialThemeWithSeries }
|
|
66
|
+
| { kind: 'mutation'; action: string; theme?: string; affected?: number; message: string }
|
|
67
|
+
| { kind: 'overlap'; data: Array<{ series_ticker: string; themes: string[] }> }
|
|
68
|
+
| { kind: 'audit'; data: Array<{ name: string; search_volume: number | null; active_markets: number; total_volume_24h: number; status: string }> }
|
|
69
|
+
| { kind: 'report'; data: Array<{ name: string; search_volume: number | null; series_count: number; series: Array<SeriesRollup & { theme_match: boolean }>; active_markets: number; total_volume_24h: number }> };
|
|
70
|
+
|
|
71
|
+
// ─── Handler dispatch ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export async function handleEditorialThemes(args: ParsedArgs): Promise<CLIResponse<EditorialThemeResult>> {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
const sub = args.positionalArgs[0]?.toLowerCase();
|
|
76
|
+
const rest = args.positionalArgs.slice(1);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
switch (sub) {
|
|
80
|
+
case 'list':
|
|
81
|
+
case undefined:
|
|
82
|
+
return listHandler(db, sub === undefined);
|
|
83
|
+
case 'show':
|
|
84
|
+
return showHandler(db, rest[0]);
|
|
85
|
+
case 'create':
|
|
86
|
+
return createHandler(db, rest, args);
|
|
87
|
+
case 'delete':
|
|
88
|
+
return deleteHandler(db, rest[0]);
|
|
89
|
+
case 'add-series':
|
|
90
|
+
return addSeriesHandler(db, rest);
|
|
91
|
+
case 'remove-series':
|
|
92
|
+
return removeSeriesHandler(db, rest);
|
|
93
|
+
case 'set-search-volume':
|
|
94
|
+
return setSearchVolumeHandler(db, rest);
|
|
95
|
+
case 'import':
|
|
96
|
+
return importHandler(db, rest[0]);
|
|
97
|
+
case 'export':
|
|
98
|
+
return exportHandler(db, rest[0]);
|
|
99
|
+
case 'overlap':
|
|
100
|
+
return overlapHandler(db);
|
|
101
|
+
case 'audit':
|
|
102
|
+
return await auditHandler(db, args);
|
|
103
|
+
case 'report':
|
|
104
|
+
return await reportHandler(db, args);
|
|
105
|
+
default:
|
|
106
|
+
return wrapError('themes', 'UNKNOWN_SUB', `Unknown subcommand: ${sub}. Try: list, show, create, delete, add-series, remove-series, set-search-volume, import, export, report, audit, overlap`);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
110
|
+
return wrapError('themes', 'INTERNAL', message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Handlers ───────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function listHandler(db: ReturnType<typeof getDb>, fellThroughBare: boolean): CLIResponse<EditorialThemeResult> {
|
|
117
|
+
const themes = listEditorialThemes(db);
|
|
118
|
+
// If user typed bare `themes` and registry is empty, suggest the import command.
|
|
119
|
+
if (fellThroughBare && themes.length === 0) {
|
|
120
|
+
return wrapError('themes', 'EMPTY_REGISTRY', 'No editorial themes registered. Run `themes import` to seed from data/themes_seo.json, or `themes create <name>` to add one.');
|
|
121
|
+
}
|
|
122
|
+
return wrapSuccess('themes', { kind: 'list', data: themes });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function showHandler(db: ReturnType<typeof getDb>, name?: string): CLIResponse<EditorialThemeResult> {
|
|
126
|
+
if (!name) return wrapError('themes', 'MISSING_NAME', 'Usage: themes show <name>');
|
|
127
|
+
const theme = getEditorialTheme(db, name);
|
|
128
|
+
if (!theme) return wrapError('themes', 'NOT_FOUND', `No editorial theme named "${name}". Try \`themes list\`.`);
|
|
129
|
+
return wrapSuccess('themes', { kind: 'show', theme });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function createHandler(db: ReturnType<typeof getDb>, positional: string[], args: ParsedArgs): CLIResponse<EditorialThemeResult> {
|
|
133
|
+
const name = positional.join(' ').trim();
|
|
134
|
+
if (!name) return wrapError('themes', 'MISSING_NAME', 'Usage: themes create <name> [--label "desc"] [--search-volume N] [--tickers KX-A,KX-B]');
|
|
135
|
+
upsertEditorialTheme(db, {
|
|
136
|
+
name,
|
|
137
|
+
description: args.labelContains ?? null,
|
|
138
|
+
search_volume: args.minVolume ?? null,
|
|
139
|
+
});
|
|
140
|
+
let added = 0;
|
|
141
|
+
if (args.tickers) {
|
|
142
|
+
added = addSeriesToTheme(db, name, args.tickers.split(','));
|
|
143
|
+
}
|
|
144
|
+
return wrapSuccess('themes', {
|
|
145
|
+
kind: 'mutation',
|
|
146
|
+
action: 'create',
|
|
147
|
+
theme: name,
|
|
148
|
+
affected: added,
|
|
149
|
+
message: `Created theme "${name}"${added ? ` with ${added} series` : ''}.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function deleteHandler(db: ReturnType<typeof getDb>, name?: string): CLIResponse<EditorialThemeResult> {
|
|
154
|
+
if (!name) return wrapError('themes', 'MISSING_NAME', 'Usage: themes delete <name>');
|
|
155
|
+
const existed = deleteEditorialTheme(db, name);
|
|
156
|
+
if (!existed) return wrapError('themes', 'NOT_FOUND', `No editorial theme named "${name}".`);
|
|
157
|
+
return wrapSuccess('themes', {
|
|
158
|
+
kind: 'mutation', action: 'delete', theme: name, message: `Deleted theme "${name}".`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function addSeriesHandler(db: ReturnType<typeof getDb>, positional: string[]): CLIResponse<EditorialThemeResult> {
|
|
163
|
+
if (positional.length < 2) return wrapError('themes', 'MISSING_ARGS', 'Usage: themes add-series <theme_name> <SERIES,SERIES,...>');
|
|
164
|
+
const themeName = positional[0];
|
|
165
|
+
const list = positional.slice(1).join(',').split(',');
|
|
166
|
+
if (!getEditorialTheme(db, themeName)) {
|
|
167
|
+
return wrapError('themes', 'NOT_FOUND', `No editorial theme named "${themeName}". Create it first with \`themes create\`.`);
|
|
168
|
+
}
|
|
169
|
+
const added = addSeriesToTheme(db, themeName, list);
|
|
170
|
+
return wrapSuccess('themes', {
|
|
171
|
+
kind: 'mutation', action: 'add-series', theme: themeName, affected: added,
|
|
172
|
+
message: `Added ${added} series to "${themeName}".`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function removeSeriesHandler(db: ReturnType<typeof getDb>, positional: string[]): CLIResponse<EditorialThemeResult> {
|
|
177
|
+
if (positional.length < 2) return wrapError('themes', 'MISSING_ARGS', 'Usage: themes remove-series <theme_name> <SERIES,SERIES,...>');
|
|
178
|
+
const themeName = positional[0];
|
|
179
|
+
const list = positional.slice(1).join(',').split(',');
|
|
180
|
+
const removed = removeSeriesFromTheme(db, themeName, list);
|
|
181
|
+
return wrapSuccess('themes', {
|
|
182
|
+
kind: 'mutation', action: 'remove-series', theme: themeName, affected: removed,
|
|
183
|
+
message: `Removed ${removed} series from "${themeName}".`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function setSearchVolumeHandler(db: ReturnType<typeof getDb>, positional: string[]): CLIResponse<EditorialThemeResult> {
|
|
188
|
+
if (positional.length < 2) return wrapError('themes', 'MISSING_ARGS', 'Usage: themes set-search-volume <name> <number>');
|
|
189
|
+
const themeName = positional[0];
|
|
190
|
+
const volume = Number(positional[1]);
|
|
191
|
+
if (!Number.isFinite(volume) || volume < 0) {
|
|
192
|
+
return wrapError('themes', 'INVALID_VOLUME', `Invalid search volume: "${positional[1]}"`);
|
|
193
|
+
}
|
|
194
|
+
const ok = setSearchVolume(db, themeName, volume);
|
|
195
|
+
if (!ok) return wrapError('themes', 'NOT_FOUND', `No editorial theme named "${themeName}".`);
|
|
196
|
+
return wrapSuccess('themes', {
|
|
197
|
+
kind: 'mutation', action: 'set-search-volume', theme: themeName,
|
|
198
|
+
message: `Set search_volume=${volume} on "${themeName}".`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface ThemesImportFile {
|
|
203
|
+
themes: Array<{
|
|
204
|
+
name: string;
|
|
205
|
+
description?: string;
|
|
206
|
+
search_volume?: number;
|
|
207
|
+
series?: string[];
|
|
208
|
+
}>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function importHandler(db: ReturnType<typeof getDb>, path?: string): CLIResponse<EditorialThemeResult> {
|
|
212
|
+
const importPath = path ?? resolvePath(import.meta.dir, '..', '..', 'data', 'themes_seo.json');
|
|
213
|
+
let raw: string;
|
|
214
|
+
try {
|
|
215
|
+
raw = readFileSync(importPath, 'utf-8');
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return wrapError('themes', 'READ_ERROR', `Cannot read ${importPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
218
|
+
}
|
|
219
|
+
let parsed: ThemesImportFile;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(raw) as ThemesImportFile;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return wrapError('themes', 'PARSE_ERROR', `Invalid JSON in ${importPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
224
|
+
}
|
|
225
|
+
if (!parsed.themes || !Array.isArray(parsed.themes)) {
|
|
226
|
+
return wrapError('themes', 'BAD_SHAPE', `Expected { "themes": [...] } in ${importPath}`);
|
|
227
|
+
}
|
|
228
|
+
let createdOrUpdated = 0;
|
|
229
|
+
let seriesAdded = 0;
|
|
230
|
+
for (const t of parsed.themes) {
|
|
231
|
+
if (!t.name) continue;
|
|
232
|
+
upsertEditorialTheme(db, {
|
|
233
|
+
name: t.name,
|
|
234
|
+
description: t.description ?? null,
|
|
235
|
+
search_volume: t.search_volume ?? null,
|
|
236
|
+
});
|
|
237
|
+
createdOrUpdated += 1;
|
|
238
|
+
if (Array.isArray(t.series) && t.series.length > 0) {
|
|
239
|
+
seriesAdded += addSeriesToTheme(db, t.name, t.series);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return wrapSuccess('themes', {
|
|
243
|
+
kind: 'mutation', action: 'import',
|
|
244
|
+
message: `Imported ${createdOrUpdated} themes (${seriesAdded} new series mappings) from ${importPath}.`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function exportHandler(db: ReturnType<typeof getDb>, path?: string): CLIResponse<EditorialThemeResult> {
|
|
249
|
+
if (!path) return wrapError('themes', 'MISSING_PATH', 'Usage: themes export <path>');
|
|
250
|
+
const themes = listEditorialThemes(db);
|
|
251
|
+
const out: ThemesImportFile = {
|
|
252
|
+
themes: themes.map((t) => {
|
|
253
|
+
const detail = getEditorialTheme(db, t.name);
|
|
254
|
+
return {
|
|
255
|
+
name: t.name,
|
|
256
|
+
description: t.description ?? undefined,
|
|
257
|
+
search_volume: t.search_volume ?? undefined,
|
|
258
|
+
series: detail?.series ?? [],
|
|
259
|
+
};
|
|
260
|
+
}),
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
writeFileSync(path, JSON.stringify(out, null, 2));
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return wrapError('themes', 'WRITE_ERROR', `Cannot write ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
266
|
+
}
|
|
267
|
+
return wrapSuccess('themes', {
|
|
268
|
+
kind: 'mutation', action: 'export',
|
|
269
|
+
message: `Exported ${out.themes.length} themes to ${path}.`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function overlapHandler(db: ReturnType<typeof getDb>): CLIResponse<EditorialThemeResult> {
|
|
274
|
+
const data = findSeriesOverlaps(db);
|
|
275
|
+
return wrapSuccess('themes', { kind: 'overlap', data });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function reportHandler(db: ReturnType<typeof getDb>, args: ParsedArgs): Promise<CLIResponse<EditorialThemeResult>> {
|
|
279
|
+
const themes = listEditorialThemes(db);
|
|
280
|
+
if (themes.length === 0) {
|
|
281
|
+
return wrapError('themes', 'EMPTY_REGISTRY', 'No editorial themes registered. Run `themes import` first.');
|
|
282
|
+
}
|
|
283
|
+
// Single server-side rollup call instead of the old paginate-then-reduce.
|
|
284
|
+
// Pull a generous page (most universes < 200 series).
|
|
285
|
+
const allRollups: SeriesRollupRow[] = [];
|
|
286
|
+
let cursor: string | undefined;
|
|
287
|
+
for (let i = 0; i < 5; i++) {
|
|
288
|
+
const page = await listKalshiSeries({ limit: 200, cursor });
|
|
289
|
+
allRollups.push(...page.data);
|
|
290
|
+
if (!page.has_more || !page.next_cursor) break;
|
|
291
|
+
cursor = page.next_cursor;
|
|
292
|
+
}
|
|
293
|
+
const rollupByTicker = new Map(allRollups.map((r) => [r.series_ticker, {
|
|
294
|
+
series_ticker: r.series_ticker,
|
|
295
|
+
market_count: r.market_count,
|
|
296
|
+
active_count: r.active_count,
|
|
297
|
+
total_volume_24h: r.total_volume_24h,
|
|
298
|
+
total_open_interest: 0,
|
|
299
|
+
dominant_category: r.dominant_category,
|
|
300
|
+
sample_titles: r.series_title ? [r.series_title] : [],
|
|
301
|
+
earliest_close: null as string | null,
|
|
302
|
+
latest_close: null as string | null,
|
|
303
|
+
} as SeriesRollup]));
|
|
304
|
+
|
|
305
|
+
const minSearch = args.minReturn !== undefined ? Math.floor(args.minReturn * 1_000_000) : (args.windowDays ?? 0);
|
|
306
|
+
// ^ reuse `--min-return` parsed as fraction of millions, or `--window-days` as raw integer;
|
|
307
|
+
// safer to expose a clean flag in dispatch. For now use args.windowDays as int floor.
|
|
308
|
+
const minVolume = args.minVolume ?? 0;
|
|
309
|
+
|
|
310
|
+
const out: Array<{
|
|
311
|
+
name: string;
|
|
312
|
+
search_volume: number | null;
|
|
313
|
+
series_count: number;
|
|
314
|
+
series: Array<SeriesRollup & { theme_match: boolean }>;
|
|
315
|
+
active_markets: number;
|
|
316
|
+
total_volume_24h: number;
|
|
317
|
+
}> = [];
|
|
318
|
+
|
|
319
|
+
for (const t of themes) {
|
|
320
|
+
if (t.search_volume != null && t.search_volume < minSearch) continue;
|
|
321
|
+
const detail = getEditorialTheme(db, t.name);
|
|
322
|
+
const seriesList: Array<SeriesRollup & { theme_match: boolean }> = [];
|
|
323
|
+
let activeMarkets = 0;
|
|
324
|
+
let totalVolume = 0;
|
|
325
|
+
for (const seriesTicker of detail?.series ?? []) {
|
|
326
|
+
const r = rollupByTicker.get(seriesTicker.toUpperCase());
|
|
327
|
+
if (r) {
|
|
328
|
+
seriesList.push({ ...r, theme_match: true });
|
|
329
|
+
activeMarkets += r.active_count;
|
|
330
|
+
totalVolume += r.total_volume_24h;
|
|
331
|
+
} else {
|
|
332
|
+
// Theme references a series with no current active markets — record a stub
|
|
333
|
+
seriesList.push({
|
|
334
|
+
series_ticker: seriesTicker.toUpperCase(),
|
|
335
|
+
market_count: 0,
|
|
336
|
+
active_count: 0,
|
|
337
|
+
total_volume_24h: 0,
|
|
338
|
+
total_open_interest: 0,
|
|
339
|
+
dominant_category: null,
|
|
340
|
+
sample_titles: [],
|
|
341
|
+
earliest_close: null,
|
|
342
|
+
latest_close: null,
|
|
343
|
+
theme_match: false,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (totalVolume < minVolume) continue;
|
|
348
|
+
seriesList.sort((a, b) => b.total_volume_24h - a.total_volume_24h);
|
|
349
|
+
out.push({
|
|
350
|
+
name: t.name,
|
|
351
|
+
search_volume: t.search_volume,
|
|
352
|
+
series_count: seriesList.length,
|
|
353
|
+
series: seriesList,
|
|
354
|
+
active_markets: activeMarkets,
|
|
355
|
+
total_volume_24h: totalVolume,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
out.sort((a, b) => (b.search_volume ?? 0) - (a.search_volume ?? 0));
|
|
360
|
+
return wrapSuccess('themes', { kind: 'report', data: out });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function auditHandler(db: ReturnType<typeof getDb>, _args: ParsedArgs): Promise<CLIResponse<EditorialThemeResult>> {
|
|
364
|
+
const report = await reportHandler(db, _args);
|
|
365
|
+
if (!report.ok || report.data.kind !== 'report') return report;
|
|
366
|
+
// Tag each theme with a status:
|
|
367
|
+
// STALE — has SEO interest but 0 active markets across all series
|
|
368
|
+
// THIN — active_markets >0 but total_volume_24h <$1000
|
|
369
|
+
// NO_INVENTORY — no series mapped at all
|
|
370
|
+
// TRADEABLE — active_markets>0 and volume>$1000
|
|
371
|
+
// UNKNOWN_DEMAND — no search_volume recorded
|
|
372
|
+
const STALE_VOL_FLOOR = 1000;
|
|
373
|
+
const auditRows = report.data.data.map((r) => {
|
|
374
|
+
let status = 'TRADEABLE';
|
|
375
|
+
if (r.series_count === 0) status = 'NO_INVENTORY';
|
|
376
|
+
else if (r.active_markets === 0) status = 'STALE';
|
|
377
|
+
else if (r.total_volume_24h < STALE_VOL_FLOOR) status = 'THIN';
|
|
378
|
+
if (r.search_volume == null && status === 'TRADEABLE') status = 'TRADEABLE_NO_SEO';
|
|
379
|
+
return {
|
|
380
|
+
name: r.name,
|
|
381
|
+
search_volume: r.search_volume,
|
|
382
|
+
active_markets: r.active_markets,
|
|
383
|
+
total_volume_24h: r.total_volume_24h,
|
|
384
|
+
status,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
// Sort: STALE / NO_INVENTORY first (the warnings), then TRADEABLE by volume desc
|
|
388
|
+
const order: Record<string, number> = { STALE: 0, NO_INVENTORY: 0, THIN: 1, TRADEABLE_NO_SEO: 2, TRADEABLE: 3 };
|
|
389
|
+
auditRows.sort((a, b) => {
|
|
390
|
+
const oa = order[a.status] ?? 99;
|
|
391
|
+
const ob = order[b.status] ?? 99;
|
|
392
|
+
if (oa !== ob) return oa - ob;
|
|
393
|
+
return (b.search_volume ?? 0) - (a.search_volume ?? 0);
|
|
394
|
+
});
|
|
395
|
+
return wrapSuccess('themes', { kind: 'audit', data: auditRows });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── Formatters ─────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
export function formatEditorialThemesHuman(result: EditorialThemeResult): string {
|
|
401
|
+
switch (result.kind) {
|
|
402
|
+
case 'list': return formatList(result.data);
|
|
403
|
+
case 'show': return formatShow(result.theme);
|
|
404
|
+
case 'mutation': return result.message;
|
|
405
|
+
case 'overlap': return formatOverlap(result.data);
|
|
406
|
+
case 'audit': return formatAudit(result.data);
|
|
407
|
+
case 'report': return formatReport(result.data);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function formatList(themes: EditorialThemeRow[]): string {
|
|
412
|
+
const lines: string[] = [];
|
|
413
|
+
lines.push(`Editorial themes — ${themes.length}`);
|
|
414
|
+
lines.push('');
|
|
415
|
+
if (themes.length === 0) {
|
|
416
|
+
lines.push('No themes registered. Run `themes import` to seed from data/themes_seo.json.');
|
|
417
|
+
return lines.join('\n');
|
|
418
|
+
}
|
|
419
|
+
const rows: string[][] = themes.map((t) => [
|
|
420
|
+
t.name,
|
|
421
|
+
fmtSearchVol(t.search_volume),
|
|
422
|
+
truncate(t.description ?? '', 60),
|
|
423
|
+
]);
|
|
424
|
+
lines.push(formatTable(['Name', 'Monthly searches', 'Description'], rows));
|
|
425
|
+
lines.push('');
|
|
426
|
+
lines.push('Use `themes show <name>` to drill in, `themes report` for the full dashboard.');
|
|
427
|
+
return lines.join('\n');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function formatShow(theme: EditorialThemeWithSeries): string {
|
|
431
|
+
const lines: string[] = [];
|
|
432
|
+
lines.push(`Editorial theme: ${theme.name}`);
|
|
433
|
+
lines.push(` Description ${theme.description ?? '-'}`);
|
|
434
|
+
lines.push(` Search volume ${fmtSearchVol(theme.search_volume)}/month`);
|
|
435
|
+
lines.push(` Series ${theme.series.length} mapped`);
|
|
436
|
+
if (theme.series.length > 0) {
|
|
437
|
+
lines.push('');
|
|
438
|
+
lines.push(' ' + theme.series.join(', '));
|
|
439
|
+
}
|
|
440
|
+
return lines.join('\n');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function formatOverlap(rows: Array<{ series_ticker: string; themes: string[] }>): string {
|
|
444
|
+
const lines: string[] = [];
|
|
445
|
+
lines.push(`Cross-theme overlap audit — ${rows.length} series appear in 2+ themes`);
|
|
446
|
+
lines.push('');
|
|
447
|
+
if (rows.length === 0) {
|
|
448
|
+
lines.push('No overlaps. Every series is in exactly one theme.');
|
|
449
|
+
return lines.join('\n');
|
|
450
|
+
}
|
|
451
|
+
const tableRows: string[][] = rows.map((r) => [r.series_ticker, r.themes.join(' · ')]);
|
|
452
|
+
lines.push(formatTable(['Series', 'Themes'], tableRows));
|
|
453
|
+
lines.push('');
|
|
454
|
+
lines.push('Use `basket build --dedupe-series` to suppress duplicates when constructing cross-theme baskets.');
|
|
455
|
+
return lines.join('\n');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function formatAudit(rows: Array<{ name: string; search_volume: number | null; active_markets: number; total_volume_24h: number; status: string }>): string {
|
|
459
|
+
const lines: string[] = [];
|
|
460
|
+
lines.push(`Theme audit — ${rows.length} themes, dead/thin themes flagged first`);
|
|
461
|
+
lines.push('');
|
|
462
|
+
const tableRows: string[][] = rows.map((r) => [
|
|
463
|
+
r.name,
|
|
464
|
+
r.status,
|
|
465
|
+
fmtSearchVol(r.search_volume),
|
|
466
|
+
String(r.active_markets),
|
|
467
|
+
fmtVol(r.total_volume_24h),
|
|
468
|
+
]);
|
|
469
|
+
lines.push(formatTable(['Theme', 'Status', 'Searches', 'Active mkts', '24h Vol'], tableRows));
|
|
470
|
+
lines.push('');
|
|
471
|
+
lines.push('STALE = high SEO + zero active markets · THIN = <$1000/day · TRADEABLE = ready.');
|
|
472
|
+
return lines.join('\n');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function formatReport(rows: Array<{ name: string; search_volume: number | null; series_count: number; series: Array<SeriesRollup & { theme_match: boolean }>; active_markets: number; total_volume_24h: number }>): string {
|
|
476
|
+
const lines: string[] = [];
|
|
477
|
+
lines.push(`Editorial theme dashboard — ${rows.length} themes, sorted by search volume`);
|
|
478
|
+
lines.push('');
|
|
479
|
+
const tableRows: string[][] = rows.map((r) => [
|
|
480
|
+
r.name,
|
|
481
|
+
fmtSearchVol(r.search_volume),
|
|
482
|
+
String(r.series_count),
|
|
483
|
+
String(r.active_markets),
|
|
484
|
+
fmtVol(r.total_volume_24h),
|
|
485
|
+
r.series.slice(0, 2).map((s) => s.series_ticker).join(', '),
|
|
486
|
+
]);
|
|
487
|
+
lines.push(formatTable(
|
|
488
|
+
['Theme', 'Searches', 'Series', 'Active mkts', '24h Vol', 'Top series'],
|
|
489
|
+
tableRows,
|
|
490
|
+
));
|
|
491
|
+
lines.push('');
|
|
492
|
+
lines.push('Use `themes show <name>` for full series list, `themes audit` to flag dead themes.');
|
|
493
|
+
return lines.join('\n');
|
|
494
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
2
|
+
import type { CLIResponse } from './json.js';
|
|
3
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
4
|
+
import {
|
|
5
|
+
fetchOctagonEventsPage,
|
|
6
|
+
fetchOctagonEventByTicker,
|
|
7
|
+
type OctagonEventEntry,
|
|
8
|
+
} from '../scan/octagon-events-api.js';
|
|
9
|
+
import { formatTable } from './scan-formatters.js';
|
|
10
|
+
|
|
11
|
+
function truncate(s: string, max: number): string {
|
|
12
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fmtVol(v: number | null | undefined): string {
|
|
16
|
+
if (v === null || v === undefined) return '-';
|
|
17
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
18
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
|
19
|
+
return v.toFixed(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type EventsResult =
|
|
23
|
+
| { kind: 'list'; data: OctagonEventEntry[]; total_returned: number; filtered_from?: number }
|
|
24
|
+
| { kind: 'detail'; event: OctagonEventEntry };
|
|
25
|
+
|
|
26
|
+
export async function handleEvents(args: ParsedArgs): Promise<CLIResponse<EventsResult>> {
|
|
27
|
+
const positional = args.positionalArgs[0];
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// events <event_ticker> — drill into one
|
|
31
|
+
if (positional && positional.toUpperCase().startsWith('KX')) {
|
|
32
|
+
const ev = await fetchOctagonEventByTicker(positional.toUpperCase());
|
|
33
|
+
if (!ev) {
|
|
34
|
+
return wrapError('events', 'EVENT_NOT_FOUND', `No event found for ticker ${positional}`);
|
|
35
|
+
}
|
|
36
|
+
return wrapSuccess('events', { kind: 'detail', event: ev });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// events list — filter + sort
|
|
40
|
+
const wantLimit = args.limit ?? 50;
|
|
41
|
+
const all: OctagonEventEntry[] = [];
|
|
42
|
+
let cursor: string | null = null;
|
|
43
|
+
// Cap pages defensively (universe is ~hundreds; this is paranoid)
|
|
44
|
+
for (let i = 0; i < 25; i++) {
|
|
45
|
+
const page: { data: OctagonEventEntry[]; next_cursor: string | null; has_more: boolean } =
|
|
46
|
+
await fetchOctagonEventsPage({ cursor });
|
|
47
|
+
all.push(...page.data);
|
|
48
|
+
if (!page.has_more) break;
|
|
49
|
+
cursor = page.next_cursor;
|
|
50
|
+
if (!cursor) break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let filtered = all;
|
|
54
|
+
if (args.category) {
|
|
55
|
+
const cat = args.category.toLowerCase();
|
|
56
|
+
filtered = filtered.filter((e) => (e.series_category ?? '').toLowerCase().includes(cat));
|
|
57
|
+
}
|
|
58
|
+
if (args.minVolume !== undefined) {
|
|
59
|
+
const floor = args.minVolume;
|
|
60
|
+
filtered = filtered.filter((e) => (e.total_volume ?? 0) >= floor);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Default sort: descending by total_volume
|
|
64
|
+
filtered.sort((a, b) => (b.total_volume ?? 0) - (a.total_volume ?? 0));
|
|
65
|
+
|
|
66
|
+
return wrapSuccess('events', {
|
|
67
|
+
kind: 'list',
|
|
68
|
+
data: filtered.slice(0, wantLimit),
|
|
69
|
+
total_returned: Math.min(filtered.length, wantLimit),
|
|
70
|
+
filtered_from: all.length !== filtered.length ? all.length : undefined,
|
|
71
|
+
});
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
74
|
+
return wrapError('events', 'OCTAGON_ERROR', message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatEventsHuman(result: EventsResult): string {
|
|
79
|
+
if (result.kind === 'detail') return formatEventDetail(result.event);
|
|
80
|
+
return formatEventList(result.data, result.filtered_from);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatEventList(events: OctagonEventEntry[], filteredFrom?: number): string {
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
const fromLabel = filteredFrom != null ? ` (filtered from ${filteredFrom})` : '';
|
|
86
|
+
lines.push(`Octagon events — ${events.length} shown${fromLabel}, sorted by total_volume desc`);
|
|
87
|
+
lines.push('');
|
|
88
|
+
if (events.length === 0) {
|
|
89
|
+
lines.push('No events match.');
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
const rows: string[][] = events.map((e) => [
|
|
93
|
+
e.event_ticker,
|
|
94
|
+
truncate(e.name ?? '', 40),
|
|
95
|
+
e.series_category ?? '-',
|
|
96
|
+
`${e.model_probability.toFixed(1)}%`,
|
|
97
|
+
`${e.market_probability.toFixed(1)}%`,
|
|
98
|
+
`${e.edge_pp >= 0 ? '+' : ''}${e.edge_pp.toFixed(1)}pp`,
|
|
99
|
+
fmtVol(e.total_volume),
|
|
100
|
+
(e.close_time ?? '').slice(0, 10),
|
|
101
|
+
]);
|
|
102
|
+
lines.push(formatTable(
|
|
103
|
+
['Event', 'Name', 'Category', 'Model', 'Market', 'Edge', 'Volume', 'Closes'],
|
|
104
|
+
rows,
|
|
105
|
+
));
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatEventDetail(e: OctagonEventEntry): string {
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
lines.push(`Event ${e.event_ticker} — ${e.name}`);
|
|
112
|
+
lines.push(` Category ${e.series_category}`);
|
|
113
|
+
lines.push(` Model ${e.model_probability.toFixed(1)}%`);
|
|
114
|
+
lines.push(` Market ${e.market_probability.toFixed(1)}%`);
|
|
115
|
+
lines.push(` Edge ${e.edge_pp >= 0 ? '+' : ''}${e.edge_pp.toFixed(1)}pp (confidence ${e.confidence_score.toFixed(1)}/10)`);
|
|
116
|
+
lines.push(` Volume ${fmtVol(e.total_volume)} open interest ${fmtVol(e.total_open_interest)}`);
|
|
117
|
+
lines.push(` Closes ${e.close_time ?? '-'}`);
|
|
118
|
+
if (e.key_takeaway) {
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(` ${e.key_takeaway}`);
|
|
121
|
+
}
|
|
122
|
+
const outcomes = e.outcome_probabilities ?? [];
|
|
123
|
+
if (outcomes.length > 0) {
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push('Sub-markets (outcome probabilities):');
|
|
126
|
+
const rows: string[][] = outcomes.map((o) => [
|
|
127
|
+
o.market_ticker,
|
|
128
|
+
truncate(o.outcome_name ?? '-', 35),
|
|
129
|
+
`${o.model_probability.toFixed(1)}%`,
|
|
130
|
+
`${o.market_probability.toFixed(1)}%`,
|
|
131
|
+
`${(o.model_probability - o.market_probability) >= 0 ? '+' : ''}${(o.model_probability - o.market_probability).toFixed(1)}pp`,
|
|
132
|
+
fmtVol(o.volume_24h ?? o.volume),
|
|
133
|
+
]);
|
|
134
|
+
lines.push(formatTable(
|
|
135
|
+
['Market', 'Outcome', 'Model', 'Market', 'Edge', '24h Vol'],
|
|
136
|
+
rows,
|
|
137
|
+
));
|
|
138
|
+
}
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|