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.
- package/README.md +205 -9
- 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/commands/status.ts +2 -2
- 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/model/llm.ts +5 -5
- package/src/scan/octagon-events-api.ts +55 -0
- package/src/scan/octagon-kalshi-api.ts +564 -0
- package/src/utils/env.ts +1 -1
- package/src/utils/model.ts +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `catalysts upcoming [--days N]` — list active Kalshi markets sorted by
|
|
3
|
+
* close_time within the next N days. Group by week so the user can scan an
|
|
4
|
+
* event-calendar view.
|
|
5
|
+
*
|
|
6
|
+
* Pure composition over /kalshi/markets — no new endpoint needed.
|
|
7
|
+
*/
|
|
8
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
9
|
+
import type { CLIResponse } from './json.js';
|
|
10
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
11
|
+
import { fetchUniverse } from './series.js';
|
|
12
|
+
import { formatTable } from './scan-formatters.js';
|
|
13
|
+
|
|
14
|
+
function truncate(s: string, max: number): string {
|
|
15
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmtVol(v: number | null | undefined): string {
|
|
19
|
+
if (v === null || v === undefined) return '-';
|
|
20
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
21
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
|
22
|
+
return v.toFixed(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isoWeek(d: Date): string {
|
|
26
|
+
// Return YYYY-MM-DD of the Monday of the ISO week.
|
|
27
|
+
const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
28
|
+
const day = tmp.getUTCDay() || 7;
|
|
29
|
+
if (day !== 1) tmp.setUTCDate(tmp.getUTCDate() - (day - 1));
|
|
30
|
+
return tmp.toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CatalystRow {
|
|
34
|
+
market_ticker: string;
|
|
35
|
+
series_prefix: string;
|
|
36
|
+
title: string;
|
|
37
|
+
category: string | null;
|
|
38
|
+
close_time: string;
|
|
39
|
+
volume_24h: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CatalystsResult {
|
|
43
|
+
days: number;
|
|
44
|
+
weeks: Array<{ week_starting: string; markets: CatalystRow[] }>;
|
|
45
|
+
total: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleCatalysts(args: ParsedArgs): Promise<CLIResponse<CatalystsResult>> {
|
|
49
|
+
const sub = args.positionalArgs[0]?.toLowerCase() ?? 'upcoming';
|
|
50
|
+
if (sub !== 'upcoming') {
|
|
51
|
+
return wrapError('catalysts', 'UNKNOWN_SUB', `Unknown subcommand "${sub}". Try: catalysts upcoming [--days N]`);
|
|
52
|
+
}
|
|
53
|
+
const days = args.days ?? 30;
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const cutoff = new Date(now.getTime() + days * 86_400_000);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const universe = await fetchUniverse({
|
|
59
|
+
close_before: cutoff.toISOString(),
|
|
60
|
+
min_volume_24h: args.minVolume,
|
|
61
|
+
category: args.category,
|
|
62
|
+
});
|
|
63
|
+
// Filter to markets that close in the future window
|
|
64
|
+
const candidates = universe
|
|
65
|
+
.filter((m) => m.close_time && new Date(m.close_time) > now)
|
|
66
|
+
.map<CatalystRow>((m) => ({
|
|
67
|
+
market_ticker: m.market_ticker,
|
|
68
|
+
series_prefix: m.market_ticker.split('-')[0],
|
|
69
|
+
title: m.title,
|
|
70
|
+
category: m.category ?? null,
|
|
71
|
+
close_time: m.close_time as string,
|
|
72
|
+
volume_24h: m.volume_24h ?? null,
|
|
73
|
+
}))
|
|
74
|
+
.sort((a, b) => a.close_time.localeCompare(b.close_time));
|
|
75
|
+
|
|
76
|
+
// Group by ISO week of close_time
|
|
77
|
+
const buckets = new Map<string, CatalystRow[]>();
|
|
78
|
+
for (const c of candidates) {
|
|
79
|
+
const week = isoWeek(new Date(c.close_time));
|
|
80
|
+
const arr = buckets.get(week) ?? [];
|
|
81
|
+
arr.push(c);
|
|
82
|
+
buckets.set(week, arr);
|
|
83
|
+
}
|
|
84
|
+
const limit = args.limit ?? 8;
|
|
85
|
+
const weeks = Array.from(buckets.entries())
|
|
86
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
87
|
+
.map(([week_starting, markets]) => ({
|
|
88
|
+
week_starting,
|
|
89
|
+
markets: markets
|
|
90
|
+
.sort((a, b) => (b.volume_24h ?? 0) - (a.volume_24h ?? 0))
|
|
91
|
+
.slice(0, limit),
|
|
92
|
+
}));
|
|
93
|
+
return wrapSuccess('catalysts', { days, weeks, total: candidates.length });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
return wrapError('catalysts', 'OCTAGON_ERROR', message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatCatalystsHuman(result: CatalystsResult): string {
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
lines.push(`Upcoming catalysts — next ${result.days} days, ${result.total} markets across ${result.weeks.length} weeks`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
if (result.weeks.length === 0) {
|
|
105
|
+
lines.push('No markets closing in that window.');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
for (const w of result.weeks) {
|
|
109
|
+
lines.push(`Week of ${w.week_starting} — ${w.markets.length} markets`);
|
|
110
|
+
const rows: string[][] = w.markets.map((m) => [
|
|
111
|
+
m.market_ticker,
|
|
112
|
+
truncate(m.title, 45),
|
|
113
|
+
m.close_time.slice(0, 16).replace('T', ' '),
|
|
114
|
+
fmtVol(m.volume_24h),
|
|
115
|
+
m.category ?? '-',
|
|
116
|
+
]);
|
|
117
|
+
lines.push(formatTable(['Market', 'Title', 'Closes (UTC)', '24h Vol', 'Category'], rows));
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
listClusters,
|
|
6
|
+
listBehavioralClusters,
|
|
7
|
+
getClusterMarkets,
|
|
8
|
+
getBehavioralClusterMarkets,
|
|
9
|
+
getClustersRankedByReturn,
|
|
10
|
+
type ClusterRow,
|
|
11
|
+
type PagedResult,
|
|
12
|
+
type SimilarMarketRow,
|
|
13
|
+
type RankedClustersResponse,
|
|
14
|
+
} from '../scan/octagon-kalshi-api.js';
|
|
15
|
+
import { formatTable } from './scan-formatters.js';
|
|
16
|
+
|
|
17
|
+
function truncate(s: string, max: number): string {
|
|
18
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fmtPct(v: number): string {
|
|
22
|
+
return `${(v * 100).toFixed(1)}%`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ClustersResult =
|
|
26
|
+
| { kind: 'list'; behavioral: boolean; data: ClusterRow[] }
|
|
27
|
+
| { kind: 'members'; behavioral: boolean; cluster_id: number; markets: PagedResult<SimilarMarketRow> }
|
|
28
|
+
| { kind: 'ranked'; data: RankedClustersResponse };
|
|
29
|
+
|
|
30
|
+
export async function handleClusters(args: ParsedArgs): Promise<CLIResponse<ClustersResult>> {
|
|
31
|
+
try {
|
|
32
|
+
if (args.ranked) {
|
|
33
|
+
const data = await getClustersRankedByReturn({
|
|
34
|
+
timeframe: args.timeframe,
|
|
35
|
+
min_return: args.minReturn,
|
|
36
|
+
top_n_per_cluster: args.topK,
|
|
37
|
+
kind: args.behavioral ? 'behavioral' : 'thematic',
|
|
38
|
+
max_clusters: args.limit,
|
|
39
|
+
});
|
|
40
|
+
return wrapSuccess('clusters', { kind: 'ranked', data });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const positional = args.positionalArgs[0];
|
|
44
|
+
if (positional !== undefined && /^\d+$/.test(positional)) {
|
|
45
|
+
const clusterId = Number(positional);
|
|
46
|
+
const markets = args.behavioral
|
|
47
|
+
? await getBehavioralClusterMarkets(clusterId, { limit: args.limit })
|
|
48
|
+
: await getClusterMarkets(clusterId, { limit: args.limit });
|
|
49
|
+
return wrapSuccess('clusters', { kind: 'members', behavioral: args.behavioral, cluster_id: clusterId, markets });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const params = {
|
|
53
|
+
limit: args.limit,
|
|
54
|
+
sample_titles: 3,
|
|
55
|
+
label_contains: args.labelContains,
|
|
56
|
+
};
|
|
57
|
+
const data = args.behavioral
|
|
58
|
+
? await listBehavioralClusters(params)
|
|
59
|
+
: await listClusters(params);
|
|
60
|
+
return wrapSuccess('clusters', { kind: 'list', behavioral: args.behavioral, data: data.data });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
return wrapError('clusters', 'OCTAGON_ERROR', message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatClustersHuman(result: ClustersResult): string {
|
|
68
|
+
if (result.kind === 'list') return formatClusterList(result.data, result.behavioral);
|
|
69
|
+
if (result.kind === 'members') return formatClusterMembers(result.cluster_id, result.markets, result.behavioral);
|
|
70
|
+
return formatRankedClusters(result.data);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatClusterList(clusters: ClusterRow[], behavioral: boolean): string {
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
const kindLabel = behavioral ? 'Behavioral' : 'Thematic';
|
|
76
|
+
lines.push(`${kindLabel} clusters — ${clusters.length} result(s)`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
|
|
79
|
+
if (clusters.length === 0) {
|
|
80
|
+
lines.push('No clusters found.');
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const headers = behavioral
|
|
85
|
+
? ['ID', 'Label', 'Size', 'Mean Ret', 'Vol', 'Sample']
|
|
86
|
+
: ['ID', 'Label', 'Size', 'Sample'];
|
|
87
|
+
|
|
88
|
+
const rows: string[][] = clusters.map((c) => {
|
|
89
|
+
const sample = truncate(c.sample_titles[0] ?? '-', 50);
|
|
90
|
+
if (behavioral) {
|
|
91
|
+
return [
|
|
92
|
+
String(c.cluster_id),
|
|
93
|
+
truncate(c.label, 30),
|
|
94
|
+
String(c.size),
|
|
95
|
+
c.mean_daily_return != null ? fmtPct(c.mean_daily_return) : '-',
|
|
96
|
+
c.daily_volatility != null ? fmtPct(c.daily_volatility) : '-',
|
|
97
|
+
sample,
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
return [String(c.cluster_id), truncate(c.label, 30), String(c.size), sample];
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
lines.push(formatTable(headers, rows));
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push(`Use "clusters <id>${behavioral ? ' --behavioral' : ''}" to list members.`);
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatClusterMembers(clusterId: number, page: PagedResult<SimilarMarketRow>, behavioral: boolean): string {
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
const kindLabel = behavioral ? 'behavioral' : 'thematic';
|
|
112
|
+
lines.push(`Markets in ${kindLabel} cluster ${clusterId} — ${page.data.length} shown${page.has_more ? ' (more available)' : ''}`);
|
|
113
|
+
lines.push('');
|
|
114
|
+
|
|
115
|
+
if (page.data.length === 0) {
|
|
116
|
+
lines.push('No markets in this cluster.');
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rows: string[][] = page.data.map((m) => [
|
|
121
|
+
m.market_ticker,
|
|
122
|
+
truncate(m.title, 45),
|
|
123
|
+
m.distance != null ? m.distance.toFixed(3) : '-',
|
|
124
|
+
m.category ?? '-',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
lines.push(formatTable(['Ticker', 'Title', 'Distance', 'Category'], rows));
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatRankedClusters(data: RankedClustersResponse): string {
|
|
132
|
+
const lines: string[] = [];
|
|
133
|
+
lines.push(`Clusters ranked by ${data.timeframe} return (${data.kind}, top ${data.top_n_per_cluster} per cluster, min return ${(data.min_return * 100).toFixed(1)}%)`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
|
|
136
|
+
if (data.data.length === 0) {
|
|
137
|
+
lines.push('No clusters meet the threshold.');
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rows: string[][] = data.data.map((c) => [
|
|
142
|
+
String(c.cluster_id),
|
|
143
|
+
truncate(c.label, 30),
|
|
144
|
+
String(c.size),
|
|
145
|
+
fmtPct(c.summary.total_return),
|
|
146
|
+
c.summary.sharpe != null ? c.summary.sharpe.toFixed(2) : '-',
|
|
147
|
+
fmtPct(c.summary.max_drawdown),
|
|
148
|
+
fmtPct(c.summary.win_rate),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
lines.push(formatTable(['ID', 'Label', 'Size', 'Total Ret', 'Sharpe', 'Max DD', 'Win%'], rows));
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
2
|
+
import type { CLIResponse } from './json.js';
|
|
3
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
4
|
+
import { getCorrelations, type CorrelationResponseWithSides } from '../scan/octagon-kalshi-api.js';
|
|
5
|
+
import { formatTable } from './scan-formatters.js';
|
|
6
|
+
|
|
7
|
+
function shortTicker(t: string, max = 18): string {
|
|
8
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function collectTickers(args: ParsedArgs): string[] {
|
|
12
|
+
const set = new Set<string>();
|
|
13
|
+
for (const p of args.positionalArgs) {
|
|
14
|
+
const upper = p.toUpperCase();
|
|
15
|
+
if (upper) set.add(upper);
|
|
16
|
+
}
|
|
17
|
+
if (args.tickers) {
|
|
18
|
+
for (const t of args.tickers.split(',')) {
|
|
19
|
+
const upper = t.trim().toUpperCase();
|
|
20
|
+
if (upper) set.add(upper);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return Array.from(set);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleCorrelate(args: ParsedArgs): Promise<CLIResponse<CorrelationResponseWithSides>> {
|
|
27
|
+
const tickers = collectTickers(args);
|
|
28
|
+
if (tickers.length < 2) {
|
|
29
|
+
return wrapError(
|
|
30
|
+
'correlate',
|
|
31
|
+
'TOO_FEW_TICKERS',
|
|
32
|
+
'Usage: correlate <ticker1> <ticker2> [...] [--window-days N] [--sides yes,no,yes] [--cells]',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (tickers.length > 100) {
|
|
36
|
+
return wrapError('correlate', 'TOO_MANY_TICKERS', 'At most 100 tickers allowed.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --sides yes,no,yes — must match ticker count (server enforces this too)
|
|
40
|
+
let sides: ('yes' | 'no')[] | undefined;
|
|
41
|
+
if (args.sides) {
|
|
42
|
+
sides = args.sides.split(',').map((s) => (s.trim().toLowerCase() === 'no' ? 'no' : 'yes'));
|
|
43
|
+
if (sides.length !== tickers.length) {
|
|
44
|
+
return wrapError('correlate', 'SIDES_MISMATCH',
|
|
45
|
+
`--sides has ${sides.length} entries but ${tickers.length} tickers were supplied.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const data = await getCorrelations({
|
|
51
|
+
market_tickers: tickers,
|
|
52
|
+
sides,
|
|
53
|
+
include_cell_detail: args.cells || undefined,
|
|
54
|
+
window_days: args.windowDays,
|
|
55
|
+
interval: args.correlationInterval,
|
|
56
|
+
});
|
|
57
|
+
return wrapSuccess('correlate', data);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
return wrapError('correlate', 'OCTAGON_ERROR', message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatCorrelationHuman(data: CorrelationResponseWithSides): string {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
const sidesLabel = data.sides ? ` (sides: ${data.sides.join(',')})` : '';
|
|
67
|
+
lines.push(`Correlation matrix — ${data.tickers.length} markets${sidesLabel}, ${data.window_days}d window, interval ${data.interval}`);
|
|
68
|
+
if (data.missing.length > 0) {
|
|
69
|
+
lines.push(` Dropped (no candle data): ${data.missing.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push('');
|
|
72
|
+
|
|
73
|
+
if (data.tickers.length === 0) {
|
|
74
|
+
lines.push('No matrix available — all tickers were missing candle data.');
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Matrix table — header row of short tickers, body of correlation values.
|
|
79
|
+
const headerRow: string[][] = [['', ...data.tickers.map((t) => shortTicker(t, 14))]];
|
|
80
|
+
const bodyRows: string[][] = data.matrix.map((row, i) => [
|
|
81
|
+
shortTicker(data.tickers[i] ?? '?', 14),
|
|
82
|
+
...row.map((v) => (v == null ? '-' : v.toFixed(2))),
|
|
83
|
+
]);
|
|
84
|
+
lines.push(formatTable(headerRow[0], bodyRows));
|
|
85
|
+
|
|
86
|
+
if (data.ranked_pairs.length > 0) {
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push('Most-uncorrelated pairs (ascending):');
|
|
89
|
+
const topN = data.ranked_pairs.slice(0, 10);
|
|
90
|
+
const rows: string[][] = topN.map((p) => [
|
|
91
|
+
shortTicker(p.ticker_a, 22),
|
|
92
|
+
shortTicker(p.ticker_b, 22),
|
|
93
|
+
p.correlation.toFixed(3),
|
|
94
|
+
]);
|
|
95
|
+
lines.push(formatTable(['Ticker A', 'Ticker B', 'Corr'], rows));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (data.cells_detail && data.cells_detail.length > 0) {
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push('Cell detail:');
|
|
101
|
+
const rows: string[][] = data.cells_detail.map((c) => [
|
|
102
|
+
shortTicker(c.ticker_a, 20),
|
|
103
|
+
shortTicker(c.ticker_b, 20),
|
|
104
|
+
c.correlation == null ? '-' : c.correlation.toFixed(3),
|
|
105
|
+
String(c.overlap_count),
|
|
106
|
+
c.reason,
|
|
107
|
+
]);
|
|
108
|
+
lines.push(formatTable(['Ticker A', 'Ticker B', 'Corr', 'Overlap', 'Reason'], rows));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
package/src/commands/dispatch.ts
CHANGED
|
@@ -26,6 +26,17 @@ import { scanEdges, formatEdgeScanHuman } from './search-edge.js';
|
|
|
26
26
|
import type { KalshiBalanceResponse } from './formatters.js';
|
|
27
27
|
import { ExitCode, exitCodeFromError } from '../utils/errors.js';
|
|
28
28
|
import { trackEvent } from '../utils/telemetry.js';
|
|
29
|
+
import { handleSimilar, formatSimilarHuman } from './similar.js';
|
|
30
|
+
import { handleClusters, formatClustersHuman } from './clusters.js';
|
|
31
|
+
import { handlePeers, formatPeersHuman } from './peers.js';
|
|
32
|
+
import { handleCorrelate, formatCorrelationHuman } from './correlate.js';
|
|
33
|
+
import { handleBasket, formatBasketHuman } from './basket.js';
|
|
34
|
+
import { searchKalshiMarkets, getMarketsWithEdge } from '../scan/octagon-kalshi-api.js';
|
|
35
|
+
import { formatMarketSearchHuman, formatMarketsWithEdgeHuman } from './search-remote.js';
|
|
36
|
+
import { handleEvents, formatEventsHuman } from './events.js';
|
|
37
|
+
import { handleSeries, formatSeriesHuman } from './series.js';
|
|
38
|
+
import { handleEditorialThemes, formatEditorialThemesHuman } from './editorial-themes.js';
|
|
39
|
+
import { handleCatalysts, formatCatalystsHuman } from './catalysts.js';
|
|
29
40
|
|
|
30
41
|
// ─── Alias resolution ────────────────────────────────────────────────────────
|
|
31
42
|
// Maps legacy CLI subcommands to canonical commands with mode/subview context
|
|
@@ -45,19 +56,49 @@ function resolveAlias(subcommand: Subcommand, positionalArgs: string[]): Resolve
|
|
|
45
56
|
case 'status':
|
|
46
57
|
return { canonical: 'portfolio', subview: 'status' };
|
|
47
58
|
|
|
48
|
-
// themes
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
// `themes` is now the editorial-themes registry (curated narrative buckets).
|
|
60
|
+
// Legacy "kalshi search themes" (Kalshi category labels) is still reachable
|
|
61
|
+
// via `search themes`.
|
|
62
|
+
|
|
63
|
+
// basket sub-routing (build/backtest/size/candles) — exposed for telemetry granularity
|
|
64
|
+
case 'basket': {
|
|
65
|
+
const sub = positionalArgs[0]?.toLowerCase();
|
|
66
|
+
if (sub === 'build' || sub === 'backtest' || sub === 'size' || sub === 'candles') {
|
|
67
|
+
return { canonical: 'basket', subview: sub };
|
|
68
|
+
}
|
|
69
|
+
return { canonical: 'basket' };
|
|
70
|
+
}
|
|
51
71
|
|
|
52
72
|
default:
|
|
53
73
|
return { canonical: subcommand };
|
|
54
74
|
}
|
|
55
75
|
}
|
|
56
76
|
|
|
77
|
+
function modeFlagsFor(canonical: Subcommand, args: ParsedArgs): Record<string, string | boolean> {
|
|
78
|
+
switch (canonical) {
|
|
79
|
+
case 'clusters':
|
|
80
|
+
return { behavioral: args.behavioral, ranked: args.ranked };
|
|
81
|
+
case 'peers':
|
|
82
|
+
return { behavioral: args.behavioral, show_cluster: args.showCluster };
|
|
83
|
+
case 'similar':
|
|
84
|
+
return { anchor: args.ticker ? 'ticker' : args.query ? 'query' : 'positional' };
|
|
85
|
+
case 'basket':
|
|
86
|
+
return { kelly_sizing: args.bankroll !== undefined };
|
|
87
|
+
case 'search':
|
|
88
|
+
return { remote: !!process.env.OCTAGON_API_KEY };
|
|
89
|
+
default:
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
57
94
|
export async function dispatch(args: ParsedArgs): Promise<void> {
|
|
58
95
|
const { subcommand, json } = args;
|
|
59
96
|
const resolved = resolveAlias(subcommand, args.positionalArgs);
|
|
60
|
-
trackEvent('cli_command', {
|
|
97
|
+
trackEvent('cli_command', {
|
|
98
|
+
command: resolved.canonical,
|
|
99
|
+
subview: resolved.subview ?? '',
|
|
100
|
+
...modeFlagsFor(resolved.canonical, args),
|
|
101
|
+
});
|
|
61
102
|
|
|
62
103
|
try {
|
|
63
104
|
// ─── reject invalid flags early (for all commands) ───────────────
|
|
@@ -87,8 +128,26 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
|
|
|
87
128
|
return;
|
|
88
129
|
}
|
|
89
130
|
if (sub === 'edge') {
|
|
90
|
-
const db = (await import('../db/index.js')).getDb();
|
|
91
131
|
const minEdgePp = (args.minEdge ?? 0.05) * 100;
|
|
132
|
+
if (process.env.OCTAGON_API_KEY) {
|
|
133
|
+
// edge_pp_min is asymmetric (only filters lower bound). Skip when
|
|
134
|
+
// user passes --min-edge 0 so they see the full distribution.
|
|
135
|
+
const data = await getMarketsWithEdge({
|
|
136
|
+
category: args.category,
|
|
137
|
+
...(minEdgePp > 0 ? { edge_pp_min: minEdgePp } : {}),
|
|
138
|
+
sort_by: (args.sortBy as 'edge_pp' | 'expected_return' | 'total_volume' | 'model_probability' | undefined) ?? 'edge_pp',
|
|
139
|
+
limit: args.limit ?? 20,
|
|
140
|
+
});
|
|
141
|
+
if (json) {
|
|
142
|
+
console.log(JSON.stringify(wrapSuccess('search', data)));
|
|
143
|
+
} else {
|
|
144
|
+
console.log(formatMarketsWithEdgeHuman(data, minEdgePp));
|
|
145
|
+
}
|
|
146
|
+
process.exit(ExitCode.SUCCESS);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Local fallback: scan cached Octagon reports in SQLite
|
|
150
|
+
const db = (await import('../db/index.js')).getDb();
|
|
92
151
|
const result = scanEdges(db, { minEdgePp, limit: args.limit, category: args.category });
|
|
93
152
|
if (json) {
|
|
94
153
|
console.log(JSON.stringify(wrapSuccess('search', result)));
|
|
@@ -109,14 +168,60 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
|
|
|
109
168
|
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
110
169
|
return;
|
|
111
170
|
}
|
|
112
|
-
|
|
171
|
+
const query = args.positionalArgs.join(' ');
|
|
172
|
+
|
|
173
|
+
// Octagon-powered server-side search: broader universe, full-text + structured filters.
|
|
174
|
+
if (process.env.OCTAGON_API_KEY) {
|
|
175
|
+
// --aggregate-by series → route to series rollup
|
|
176
|
+
if (args.aggregateBy === 'series') {
|
|
177
|
+
const { handleSeries, formatSeriesHuman } = await import('./series.js');
|
|
178
|
+
const seriesArgs = { ...args, positionalArgs: query ? ['search', query] : ['list'] };
|
|
179
|
+
const resp = await handleSeries(seriesArgs);
|
|
180
|
+
if (json) {
|
|
181
|
+
console.log(JSON.stringify(resp));
|
|
182
|
+
} else if (resp.ok) {
|
|
183
|
+
console.log(formatSeriesHuman(resp.data));
|
|
184
|
+
} else {
|
|
185
|
+
console.error(resp.error?.message ?? 'series rollup failed');
|
|
186
|
+
}
|
|
187
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// sort_by is now server-side (true top-N across the whole universe);
|
|
191
|
+
// series_prefix lets us tree-browse (KXBTC matches all Bitcoin series).
|
|
192
|
+
const serverSortBy = (args.sortBy === 'volume_24h' || args.sortBy === 'close_time' || args.sortBy === 'last_price')
|
|
193
|
+
? args.sortBy
|
|
194
|
+
: undefined;
|
|
195
|
+
const page = await searchKalshiMarkets({
|
|
196
|
+
q: query,
|
|
197
|
+
category: args.category,
|
|
198
|
+
series_ticker: args.seriesTicker,
|
|
199
|
+
series_prefix: args.seriesPrefix,
|
|
200
|
+
min_volume_24h: args.minVolume,
|
|
201
|
+
close_before: args.closeBefore,
|
|
202
|
+
sort_by: serverSortBy,
|
|
203
|
+
limit: args.limit ?? 30,
|
|
204
|
+
});
|
|
205
|
+
// --active-only is defensive — the live universe is active by default.
|
|
206
|
+
const rows = args.activeOnly
|
|
207
|
+
? page.data.filter((m) => m.status === 'active' || m.status === 'open')
|
|
208
|
+
: page.data;
|
|
209
|
+
const filteredPage = { ...page, data: rows };
|
|
210
|
+
if (json) {
|
|
211
|
+
console.log(JSON.stringify(wrapSuccess('search', filteredPage)));
|
|
212
|
+
} else {
|
|
213
|
+
console.log(formatMarketSearchHuman(query, filteredPage));
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Local fallback: query the pre-built event index.
|
|
113
219
|
if (args.refresh) {
|
|
114
220
|
await forceRefreshIndex();
|
|
115
221
|
} else {
|
|
116
222
|
await ensureIndex();
|
|
117
223
|
}
|
|
118
224
|
const db = (await import('../db/index.js')).getDb();
|
|
119
|
-
const query = args.positionalArgs.join(' ');
|
|
120
225
|
const results = searchEventIndex(db, query, 30);
|
|
121
226
|
if (json) {
|
|
122
227
|
console.log(JSON.stringify(wrapSuccess('search', { events: results })));
|
|
@@ -228,6 +333,132 @@ export async function dispatch(args: ParsedArgs): Promise<void> {
|
|
|
228
333
|
return;
|
|
229
334
|
}
|
|
230
335
|
|
|
336
|
+
// ─── similar (Octagon semantic search) ─────────────────────────────
|
|
337
|
+
if (resolved.canonical === 'similar') {
|
|
338
|
+
const resp = await handleSimilar(args);
|
|
339
|
+
if (json) {
|
|
340
|
+
console.log(JSON.stringify(resp));
|
|
341
|
+
} else if (resp.ok) {
|
|
342
|
+
console.log(formatSimilarHuman(resp.data));
|
|
343
|
+
} else {
|
|
344
|
+
console.error(resp.error?.message ?? 'similar failed');
|
|
345
|
+
}
|
|
346
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── clusters (Octagon thematic & behavioral) ──────────────────────
|
|
351
|
+
if (resolved.canonical === 'clusters') {
|
|
352
|
+
const resp = await handleClusters(args);
|
|
353
|
+
if (json) {
|
|
354
|
+
console.log(JSON.stringify(resp));
|
|
355
|
+
} else if (resp.ok) {
|
|
356
|
+
console.log(formatClustersHuman(resp.data));
|
|
357
|
+
} else {
|
|
358
|
+
console.error(resp.error?.message ?? 'clusters failed');
|
|
359
|
+
}
|
|
360
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── peers (Octagon cluster peers) ─────────────────────────────────
|
|
365
|
+
if (resolved.canonical === 'peers') {
|
|
366
|
+
const resp = await handlePeers(args);
|
|
367
|
+
if (json) {
|
|
368
|
+
console.log(JSON.stringify(resp));
|
|
369
|
+
} else if (resp.ok) {
|
|
370
|
+
console.log(formatPeersHuman(resp.data));
|
|
371
|
+
} else {
|
|
372
|
+
console.error(resp.error?.message ?? 'peers failed');
|
|
373
|
+
}
|
|
374
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─── correlate (Octagon correlation matrix) ────────────────────────
|
|
379
|
+
if (resolved.canonical === 'correlate') {
|
|
380
|
+
const resp = await handleCorrelate(args);
|
|
381
|
+
if (json) {
|
|
382
|
+
console.log(JSON.stringify(resp));
|
|
383
|
+
} else if (resp.ok) {
|
|
384
|
+
console.log(formatCorrelationHuman(resp.data));
|
|
385
|
+
} else {
|
|
386
|
+
console.error(resp.error?.message ?? 'correlate failed');
|
|
387
|
+
}
|
|
388
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─── catalysts (upcoming market closes grouped by week) ────────────
|
|
393
|
+
if (resolved.canonical === 'catalysts') {
|
|
394
|
+
const resp = await handleCatalysts(args);
|
|
395
|
+
if (json) {
|
|
396
|
+
console.log(JSON.stringify(resp));
|
|
397
|
+
} else if (resp.ok) {
|
|
398
|
+
console.log(formatCatalystsHuman(resp.data));
|
|
399
|
+
} else {
|
|
400
|
+
console.error(resp.error?.message ?? 'catalysts failed');
|
|
401
|
+
}
|
|
402
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── themes (editorial narrative registry) ─────────────────────────
|
|
407
|
+
if (resolved.canonical === 'themes') {
|
|
408
|
+
const resp = await handleEditorialThemes(args);
|
|
409
|
+
if (json) {
|
|
410
|
+
console.log(JSON.stringify(resp));
|
|
411
|
+
} else if (resp.ok) {
|
|
412
|
+
console.log(formatEditorialThemesHuman(resp.data));
|
|
413
|
+
} else {
|
|
414
|
+
console.error(resp.error?.message ?? 'themes failed');
|
|
415
|
+
}
|
|
416
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ─── series (Kalshi series rollup) ─────────────────────────────────
|
|
421
|
+
if (resolved.canonical === 'series') {
|
|
422
|
+
const resp = await handleSeries(args);
|
|
423
|
+
if (json) {
|
|
424
|
+
console.log(JSON.stringify(resp));
|
|
425
|
+
} else if (resp.ok) {
|
|
426
|
+
console.log(formatSeriesHuman(resp.data));
|
|
427
|
+
} else {
|
|
428
|
+
console.error(resp.error?.message ?? 'series failed');
|
|
429
|
+
}
|
|
430
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── events (Octagon events list / detail) ─────────────────────────
|
|
435
|
+
if (resolved.canonical === 'events') {
|
|
436
|
+
const resp = await handleEvents(args);
|
|
437
|
+
if (json) {
|
|
438
|
+
console.log(JSON.stringify(resp));
|
|
439
|
+
} else if (resp.ok) {
|
|
440
|
+
console.log(formatEventsHuman(resp.data));
|
|
441
|
+
} else {
|
|
442
|
+
console.error(resp.error?.message ?? 'events failed');
|
|
443
|
+
}
|
|
444
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─── basket (build, backtest, size, candles) ───────────────────────
|
|
449
|
+
if (resolved.canonical === 'basket') {
|
|
450
|
+
const resp = await handleBasket(args);
|
|
451
|
+
if (json) {
|
|
452
|
+
console.log(JSON.stringify(resp));
|
|
453
|
+
} else if (resp.ok) {
|
|
454
|
+
console.log(formatBasketHuman(resp.data));
|
|
455
|
+
} else {
|
|
456
|
+
console.error(resp.error?.message ?? 'basket failed');
|
|
457
|
+
}
|
|
458
|
+
process.exit(resp.ok ? ExitCode.SUCCESS : ExitCode.USER_ERROR);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
231
462
|
// ─── watch ─────────────────────────────────────────────────────────
|
|
232
463
|
if (resolved.canonical === 'watch') {
|
|
233
464
|
// Force index rebuild before watching if --refresh is set
|