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,646 @@
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
+ buildBasket,
6
+ backtestBasket,
7
+ getBasketSize,
8
+ getBasketCandles,
9
+ searchKalshiMarkets,
10
+ validateBasket,
11
+ getMarketsEdge,
12
+ type BasketBuildResponse,
13
+ type BasketBacktestResponse,
14
+ type BasketSizeResponse,
15
+ type BasketCandlesResponse,
16
+ type BasketBuildBody,
17
+ type BasketSizeBody,
18
+ type BasketCandlesBody,
19
+ type BasketValidateResponse,
20
+ type ValidateBasketBody,
21
+ type ValidateBasketLeg,
22
+ } from '../scan/octagon-kalshi-api.js';
23
+ import { formatTable } from './scan-formatters.js';
24
+ import { getDb } from '../db/index.js';
25
+ import { getEditorialTheme } from '../db/editorial-themes.js';
26
+
27
+ function truncate(s: string, max: number): string {
28
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
29
+ }
30
+
31
+ function fmtPct(v: number): string {
32
+ return `${(v * 100).toFixed(1)}%`;
33
+ }
34
+
35
+ function parseProbabilities(raw: string | undefined): Record<string, number> | undefined {
36
+ if (!raw) return undefined;
37
+ const map: Record<string, number> = {};
38
+ for (const pair of raw.split(',')) {
39
+ const [tickerRaw, probRaw] = pair.split(':');
40
+ const ticker = tickerRaw?.trim().toUpperCase();
41
+ if (!ticker || !probRaw) continue;
42
+ const p = Number(probRaw);
43
+ if (!Number.isFinite(p) || p < 0 || p > 1) continue;
44
+ map[ticker] = p;
45
+ }
46
+ return Object.keys(map).length > 0 ? map : undefined;
47
+ }
48
+
49
+ function parseLegs(raw: string | undefined, sideDefault: 'yes' | 'no'): { market_ticker: string; side: 'yes' | 'no'; model_probability: number }[] {
50
+ if (!raw) return [];
51
+ const legs: { market_ticker: string; side: 'yes' | 'no'; model_probability: number }[] = [];
52
+ for (const pair of raw.split(',')) {
53
+ const [tickerRaw, probRaw] = pair.split(':');
54
+ const ticker = tickerRaw?.trim().toUpperCase();
55
+ if (!ticker || !probRaw) continue;
56
+ const p = Number(probRaw);
57
+ if (!Number.isFinite(p) || p < 0 || p > 1) continue;
58
+ legs.push({ market_ticker: ticker, side: sideDefault, model_probability: p });
59
+ }
60
+ return legs;
61
+ }
62
+
63
+ function collectTickers(args: ParsedArgs): string[] {
64
+ const set = new Set<string>();
65
+ if (args.tickers) {
66
+ for (const t of args.tickers.split(',')) {
67
+ const upper = t.trim().toUpperCase();
68
+ if (upper) set.add(upper);
69
+ }
70
+ }
71
+ // Skip positionalArgs[0] which is the basket subcommand (build/backtest/size/candles).
72
+ for (let i = 1; i < args.positionalArgs.length; i++) {
73
+ const upper = args.positionalArgs[i].toUpperCase();
74
+ if (upper) set.add(upper);
75
+ }
76
+ return Array.from(set);
77
+ }
78
+
79
+ /**
80
+ * Resolve an editorial theme name to a flat list of market tickers: for each
81
+ * series in the theme, pull the live universe, filter by market_ticker prefix
82
+ * (Octagon's series_ticker field is currently null), and pick the top market
83
+ * by 24h volume. Limit total candidates with --top-k.
84
+ */
85
+ async function tickersFromTheme(themeName: string, topPerSeries: number, maxTotal: number): Promise<string[]> {
86
+ const db = getDb();
87
+ const theme = getEditorialTheme(db, themeName);
88
+ if (!theme) throw new Error(`No editorial theme named "${themeName}". Try \`themes list\` or \`themes import\`.`);
89
+ if (theme.series.length === 0) throw new Error(`Theme "${themeName}" has no mapped series.`);
90
+ // Pull the universe once and bucket by series prefix.
91
+ const universe = await searchKalshiMarkets({ limit: 200 });
92
+ const all = [...universe.data];
93
+ let cursor = universe.next_cursor;
94
+ let pages = 1;
95
+ while (cursor && universe.has_more && pages < 25) {
96
+ const page = await searchKalshiMarkets({ limit: 200, cursor });
97
+ all.push(...page.data);
98
+ if (!page.has_more || !page.next_cursor) break;
99
+ cursor = page.next_cursor;
100
+ pages += 1;
101
+ }
102
+ const out: string[] = [];
103
+ for (const seriesTicker of theme.series) {
104
+ const prefix = seriesTicker.toUpperCase() + '-';
105
+ const sub = all
106
+ .filter((m) => m.market_ticker.toUpperCase().startsWith(prefix))
107
+ .sort((a, b) => (b.volume_24h ?? 0) - (a.volume_24h ?? 0))
108
+ .slice(0, topPerSeries)
109
+ .map((m) => m.market_ticker);
110
+ out.push(...sub);
111
+ if (out.length >= maxTotal) break;
112
+ }
113
+ return out.slice(0, maxTotal);
114
+ }
115
+
116
+ // ─── build ──────────────────────────────────────────────────────────────────
117
+
118
+ export async function handleBasketBuild(args: ParsedArgs): Promise<CLIResponse<BasketBuildResponse>> {
119
+ let probs = parseProbabilities(args.probabilities);
120
+ const wantsKelly = args.bankroll !== undefined || args.kellyMultiplier !== undefined || probs !== undefined;
121
+
122
+ if (wantsKelly && args.bankroll === undefined) {
123
+ return wrapError('basket', 'MISSING_BANKROLL', 'Kelly sizing requires --bankroll (e.g., --bankroll 1000).');
124
+ }
125
+
126
+ const labelContainsAny = args.labelContains
127
+ ? args.labelContains.split(',').map((s) => s.trim()).filter(Boolean)
128
+ : undefined;
129
+
130
+ // --theme <name> or --tickers KX-A,KX-B: pass explicit candidate pool via the
131
+ // new universe.market_tickers field. Server-side resolver respects this when
132
+ // wired up; falls back to the default search universe otherwise.
133
+ let marketTickers: string[] | undefined;
134
+ if (args.theme) {
135
+ try {
136
+ marketTickers = await tickersFromTheme(args.theme, args.topK ?? 1, 200);
137
+ } catch (err) {
138
+ return wrapError('basket', 'THEME_RESOLVE', err instanceof Error ? err.message : String(err));
139
+ }
140
+ if (marketTickers.length === 0) {
141
+ return wrapError('basket', 'THEME_EMPTY', `Theme "${args.theme}" resolved to 0 markets.`);
142
+ }
143
+ } else if (args.tickers) {
144
+ marketTickers = args.tickers.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
145
+ }
146
+
147
+ // --auto-probs: fetch model probabilities for the candidate pool so Kelly
148
+ // sizing isn't reliant on manual --probs entry.
149
+ if (args.autoProbs && marketTickers && marketTickers.length > 0 && !probs) {
150
+ try {
151
+ const edgeResp = await getMarketsEdge({ tickers: marketTickers });
152
+ const scored = edgeResp.data.filter((r) => r.status === 'scored' && r.model_probability != null);
153
+ if (scored.length > 0) {
154
+ probs = {};
155
+ for (const row of scored) {
156
+ const key = row.market_ticker ?? row.input_ticker;
157
+ probs[key.toUpperCase()] = row.model_probability!;
158
+ }
159
+ }
160
+ } catch {
161
+ // Best-effort — fall through to manual probs / equal sizing.
162
+ }
163
+ }
164
+
165
+ const body: BasketBuildBody = {
166
+ universe: {
167
+ q: args.query,
168
+ anchor_ticker: args.ticker,
169
+ market_tickers: marketTickers,
170
+ category: args.category,
171
+ series_ticker: args.seriesTicker,
172
+ min_volume_24h: args.minVolume,
173
+ close_before: args.closeBefore,
174
+ label_contains_any: labelContainsAny,
175
+ },
176
+ n: args.n ?? 5,
177
+ max_per_cluster: args.maxPerCluster,
178
+ max_pairwise_correlation: args.maxCorrelation,
179
+ candidate_pool_size: args.limit ?? (marketTickers ? Math.max(marketTickers.length, 50) : undefined),
180
+ correlation_window_days: args.windowDays,
181
+ sizing: (wantsKelly || probs)
182
+ ? {
183
+ strategy: 'kelly',
184
+ bankroll_usd: args.bankroll,
185
+ kelly_multiplier: args.kellyMultiplier ?? 0.25,
186
+ leg_probabilities: probs,
187
+ }
188
+ : { strategy: 'equal' },
189
+ };
190
+
191
+ try {
192
+ const data = await buildBasket(body);
193
+ return wrapSuccess('basket', data);
194
+ } catch (err) {
195
+ const message = err instanceof Error ? err.message : String(err);
196
+ return wrapError('basket', 'OCTAGON_ERROR', message);
197
+ }
198
+ }
199
+
200
+ export function formatBasketBuildHuman(data: BasketBuildResponse): string {
201
+ const lines: string[] = [];
202
+ const corrStr = data.realized_max_pairwise_correlation == null
203
+ ? 'n/a (no overlapping history)'
204
+ : data.realized_max_pairwise_correlation.toFixed(2);
205
+ lines.push(`Basket — ${data.legs.length} legs, ${data.universe_size} candidates considered, realized max pairwise correlation ${corrStr}`);
206
+ lines.push('');
207
+
208
+ if (data.legs.length === 0) {
209
+ lines.push('No legs selected.');
210
+ } else {
211
+ const num = (v: number | null | undefined, decimals: number, prefix = '', suffix = '') =>
212
+ v == null ? '-' : `${prefix}${v.toFixed(decimals)}${suffix}`;
213
+ const rows: string[][] = data.legs.map((l) => [
214
+ l.market_ticker,
215
+ truncate(l.title, 35),
216
+ l.side.toUpperCase(),
217
+ num(l.price, 2),
218
+ num(l.model_probability != null ? l.model_probability * 100 : null, 1, '', '%'),
219
+ num(l.kelly_fraction, 3),
220
+ num(l.weight, 3),
221
+ num(l.notional_usd, 2, '$'),
222
+ l.cluster_label ? truncate(l.cluster_label, 18) : '-',
223
+ ]);
224
+ lines.push(formatTable(
225
+ ['Ticker', 'Title', 'Side', 'Price', 'Model%', 'Kelly', 'Weight', 'Notional', 'Cluster'],
226
+ rows,
227
+ ));
228
+ }
229
+
230
+ if (data.dropped.length > 0) {
231
+ lines.push('');
232
+ lines.push(`Dropped ${data.dropped.length} candidate(s) during selection (top 5):`);
233
+ for (const d of data.dropped.slice(0, 5)) {
234
+ lines.push(` ${d.market_ticker} — ${d.reason}`);
235
+ }
236
+ }
237
+ return lines.join('\n');
238
+ }
239
+
240
+ // ─── backtest ───────────────────────────────────────────────────────────────
241
+
242
+ export async function handleBasketBacktest(args: ParsedArgs): Promise<CLIResponse<BasketBacktestResponse>> {
243
+ let tickers = collectTickers(args);
244
+ // --theme <name>: resolve registry to top market per series, equal-weight basket.
245
+ if (args.theme && tickers.length === 0) {
246
+ try {
247
+ tickers = await tickersFromTheme(args.theme, args.topK ?? 1, 50);
248
+ } catch (err) {
249
+ return wrapError('basket', 'THEME_RESOLVE', err instanceof Error ? err.message : String(err));
250
+ }
251
+ if (tickers.length === 0) {
252
+ return wrapError('basket', 'THEME_EMPTY', `Theme "${args.theme}" resolved to 0 tradeable markets.`);
253
+ }
254
+ }
255
+ if (tickers.length < 1) {
256
+ return wrapError('basket', 'MISSING_TICKERS', 'Usage: basket backtest --tickers KX-A,KX-B [--weights 0.6,0.4] [--timeframe 1y] OR basket backtest --theme "Iran Escalation"');
257
+ }
258
+ if (args.weights && args.weights.length !== tickers.length) {
259
+ return wrapError('basket', 'WEIGHTS_MISMATCH', `Got ${tickers.length} tickers but ${args.weights.length} weights.`);
260
+ }
261
+ const body: BasketCandlesBody = {
262
+ market_tickers: tickers,
263
+ weights: args.weights,
264
+ timeframe: args.timeframe,
265
+ };
266
+ try {
267
+ const data = await backtestBasket(body);
268
+ return wrapSuccess('basket', data);
269
+ } catch (err) {
270
+ const message = err instanceof Error ? err.message : String(err);
271
+ return wrapError('basket', 'OCTAGON_ERROR', message);
272
+ }
273
+ }
274
+
275
+ export function formatBasketBacktestHuman(data: BasketBacktestResponse): string {
276
+ const lines: string[] = [];
277
+ lines.push(`Basket backtest — ${data.timeframe} window, ${data.candles.length} bins (interval ${data.interval_source})`);
278
+ if (data.missing.length > 0) {
279
+ lines.push(` Excluded (no candle data): ${data.missing.join(', ')}`);
280
+ }
281
+ lines.push('');
282
+ const s = data.summary;
283
+ const rows: string[][] = [
284
+ ['Total return', fmtPct(s.total_return)],
285
+ ['Annualized return', fmtPct(s.annualized_return)],
286
+ ['Sharpe', s.sharpe != null ? s.sharpe.toFixed(2) : '-'],
287
+ ['Max drawdown', fmtPct(s.max_drawdown)],
288
+ ['Win rate', fmtPct(s.win_rate)],
289
+ ['First NAV', s.first_nav.toFixed(3)],
290
+ ['Final NAV', s.final_nav.toFixed(3)],
291
+ ['Observations', String(s.observation_count)],
292
+ ];
293
+ lines.push(formatTable(['Metric', 'Value'], rows));
294
+ return lines.join('\n');
295
+ }
296
+
297
+ // ─── candles ────────────────────────────────────────────────────────────────
298
+
299
+ export async function handleBasketCandles(args: ParsedArgs): Promise<CLIResponse<BasketCandlesResponse>> {
300
+ let tickers = collectTickers(args);
301
+ if (args.theme && tickers.length === 0) {
302
+ try {
303
+ tickers = await tickersFromTheme(args.theme, args.topK ?? 1, 50);
304
+ } catch (err) {
305
+ return wrapError('basket', 'THEME_RESOLVE', err instanceof Error ? err.message : String(err));
306
+ }
307
+ if (tickers.length === 0) {
308
+ return wrapError('basket', 'THEME_EMPTY', `Theme "${args.theme}" resolved to 0 tradeable markets.`);
309
+ }
310
+ }
311
+ if (tickers.length < 1) {
312
+ return wrapError('basket', 'MISSING_TICKERS', 'Usage: basket candles --tickers KX-A,KX-B [--weights 0.6,0.4] OR basket candles --theme "Iran Escalation"');
313
+ }
314
+ if (args.weights && args.weights.length !== tickers.length) {
315
+ return wrapError('basket', 'WEIGHTS_MISMATCH', `Got ${tickers.length} tickers but ${args.weights.length} weights.`);
316
+ }
317
+ const body: BasketCandlesBody = {
318
+ market_tickers: tickers,
319
+ weights: args.weights,
320
+ timeframe: args.timeframe,
321
+ };
322
+ try {
323
+ const data = await getBasketCandles(body);
324
+ return wrapSuccess('basket', data);
325
+ } catch (err) {
326
+ const message = err instanceof Error ? err.message : String(err);
327
+ return wrapError('basket', 'OCTAGON_ERROR', message);
328
+ }
329
+ }
330
+
331
+ export function formatBasketCandlesHuman(data: BasketCandlesResponse): string {
332
+ const lines: string[] = [];
333
+ lines.push(`Basket NAV — ${data.timeframe} window, ${data.candles.length} bins (interval ${data.interval_source})`);
334
+ if (data.missing.length > 0) {
335
+ lines.push(` Excluded (no candle data): ${data.missing.join(', ')}`);
336
+ }
337
+ lines.push('');
338
+ if (data.candles.length === 0) {
339
+ lines.push('No candles in window.');
340
+ return lines.join('\n');
341
+ }
342
+ const shown = data.candles.slice(-10);
343
+ const rows: string[][] = shown.map((c) => [
344
+ new Date(c.time * 1000).toISOString().slice(0, 16).replace('T', ' '),
345
+ c.open.toFixed(3),
346
+ c.high.toFixed(3),
347
+ c.low.toFixed(3),
348
+ c.close.toFixed(3),
349
+ ]);
350
+ lines.push(formatTable(['Time (UTC)', 'Open', 'High', 'Low', 'Close'], rows));
351
+ if (data.candles.length > shown.length) {
352
+ lines.push('');
353
+ lines.push(`(showing last ${shown.length} of ${data.candles.length} bins — use --json for all)`);
354
+ }
355
+ return lines.join('\n');
356
+ }
357
+
358
+ // ─── size ───────────────────────────────────────────────────────────────────
359
+
360
+ export async function handleBasketSize(args: ParsedArgs): Promise<CLIResponse<BasketSizeResponse>> {
361
+ if (args.bankroll === undefined || args.bankroll <= 0) {
362
+ return wrapError('basket', 'MISSING_BANKROLL', 'Usage: basket size --bankroll 1000 --kelly 0.25 --probs KX-A:0.62,KX-B:0.55 [--side yes|no] OR --auto-probs --theme "AI Race Milestones" OR --auto-probs --tickers KX-A,KX-B');
363
+ }
364
+ const sideDefault = args.side ?? 'yes';
365
+
366
+ // Source the leg list. Priority:
367
+ // 1) --probs (explicit): manual probabilities
368
+ // 2) --theme: resolve to top market per series, then fetch probs via markets/edge
369
+ // 3) --tickers + --auto-probs: explicit tickers, fetched probs
370
+ let legs: { market_ticker: string; side: 'yes' | 'no'; model_probability: number }[] = [];
371
+
372
+ if (args.probabilities && /[:]/.test(args.probabilities) && !args.autoProbs) {
373
+ legs = parseLegs(args.probabilities, sideDefault);
374
+ } else if (args.theme || (args.autoProbs && args.tickers)) {
375
+ let tickers: string[];
376
+ if (args.theme) {
377
+ try {
378
+ tickers = await tickersFromTheme(args.theme, args.topK ?? 1, 30);
379
+ } catch (err) {
380
+ return wrapError('basket', 'THEME_RESOLVE', err instanceof Error ? err.message : String(err));
381
+ }
382
+ } else {
383
+ tickers = (args.tickers ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
384
+ }
385
+ if (tickers.length === 0) {
386
+ return wrapError('basket', 'NO_TICKERS', 'No tickers to size.');
387
+ }
388
+ let edgeRows;
389
+ try {
390
+ const edgeResp = await getMarketsEdge({ tickers });
391
+ edgeRows = edgeResp.data;
392
+ } catch (err) {
393
+ return wrapError('basket', 'OCTAGON_EDGE', err instanceof Error ? err.message : String(err));
394
+ }
395
+ const scored = edgeRows.filter((r) => r.status === 'scored' && r.model_probability != null);
396
+ if (scored.length === 0) {
397
+ const unscored = edgeRows.map((r) => r.input_ticker).join(', ');
398
+ return wrapError('basket', 'NO_SCORED_LEGS',
399
+ `Octagon has no model coverage for any of these tickers in the current run. Unscored: ${unscored}. Try --probs to supply your own priors, or pick events Octagon scored (see kalshi search edge).`);
400
+ }
401
+ legs = scored.map((r) => ({
402
+ market_ticker: r.market_ticker ?? r.input_ticker,
403
+ side: sideDefault,
404
+ model_probability: r.model_probability!,
405
+ }));
406
+ }
407
+
408
+ if (legs.length === 0) {
409
+ return wrapError('basket', 'MISSING_PROBS', 'Pass --probs TICKER:prob,TICKER:prob OR --auto-probs --theme "..." OR --auto-probs --tickers KX-A,KX-B.');
410
+ }
411
+
412
+ const body: BasketSizeBody = {
413
+ bankroll_usd: args.bankroll,
414
+ kelly_multiplier: args.kellyMultiplier ?? 0.25,
415
+ legs,
416
+ };
417
+ try {
418
+ const data = await getBasketSize(body);
419
+ return wrapSuccess('basket', data);
420
+ } catch (err) {
421
+ const message = err instanceof Error ? err.message : String(err);
422
+ return wrapError('basket', 'OCTAGON_ERROR', message);
423
+ }
424
+ }
425
+
426
+ export function formatBasketSizeHuman(data: BasketSizeResponse): string {
427
+ const lines: string[] = [];
428
+ lines.push(`Kelly sizing — $${data.bankroll_usd.toFixed(2)} bankroll, ${(data.kelly_multiplier * 100).toFixed(0)}% Kelly cap, total notional $${data.total_notional.toFixed(2)}`);
429
+ lines.push('');
430
+ const rows: string[][] = data.legs.map((l) => [
431
+ l.market_ticker,
432
+ l.side.toUpperCase(),
433
+ l.price.toFixed(2),
434
+ `${(l.model_probability * 100).toFixed(1)}%`,
435
+ `${l.edge_pp >= 0 ? '+' : ''}${l.edge_pp.toFixed(1)}pp`,
436
+ l.kelly_fraction.toFixed(3),
437
+ l.weight.toFixed(3),
438
+ `$${l.notional_usd.toFixed(2)}`,
439
+ ]);
440
+ lines.push(formatTable(['Ticker', 'Side', 'Price', 'Model%', 'Edge', 'Kelly', 'Weight', 'Notional'], rows));
441
+ return lines.join('\n');
442
+ }
443
+
444
+ // ─── validate ───────────────────────────────────────────────────────────────
445
+
446
+ /**
447
+ * Parse --legs "KX-A:yes:170,KX-B:no:160" into ValidateBasketLeg[].
448
+ * Each leg is ticker[:side[:stake]] — side defaults to yes, stake defaults to
449
+ * an equal split of bankroll (or 100 if no bankroll).
450
+ */
451
+ function parseValidateLegs(args: ParsedArgs): { legs: ValidateBasketLeg[]; error?: string } {
452
+ // Two input modes: --legs "csv" with side+stake, OR --tickers + --probs/--side.
453
+ if (args.tickers) {
454
+ const tickers = args.tickers.split(',').map((t) => t.trim().toUpperCase()).filter(Boolean);
455
+ if (tickers.length === 0) return { legs: [], error: 'No tickers supplied.' };
456
+ const sideDefault = args.side ?? 'yes';
457
+ const totalStake = args.bankroll ?? 1000;
458
+ const perLeg = totalStake / tickers.length;
459
+ return {
460
+ legs: tickers.map((t) => ({ market_ticker: t, side: sideDefault, stake_usd: perLeg })),
461
+ };
462
+ }
463
+ // --legs csv: ticker:side[:stake]
464
+ if (args.probabilities && /[:]/.test(args.probabilities)) {
465
+ const legs: ValidateBasketLeg[] = [];
466
+ for (const piece of args.probabilities.split(',')) {
467
+ const parts = piece.trim().split(':');
468
+ if (parts.length < 1 || !parts[0]) continue;
469
+ const ticker = parts[0].toUpperCase();
470
+ const sideRaw = (parts[1] ?? 'yes').toLowerCase();
471
+ const side: 'yes' | 'no' = sideRaw === 'no' ? 'no' : 'yes';
472
+ const stake = parts[2] ? Number(parts[2]) : NaN;
473
+ legs.push({
474
+ market_ticker: ticker,
475
+ side,
476
+ stake_usd: Number.isFinite(stake) ? stake : (args.bankroll ?? 1000) / Math.max(1, args.probabilities!.split(',').length),
477
+ });
478
+ }
479
+ return { legs };
480
+ }
481
+ return { legs: [], error: 'Provide --tickers KX-A,KX-B OR --probs KX-A:yes:170,KX-B:no:160 to specify legs.' };
482
+ }
483
+
484
+ export async function handleBasketValidate(args: ParsedArgs): Promise<CLIResponse<BasketValidateResponse>> {
485
+ // --theme support: resolve to legs
486
+ let legs: ValidateBasketLeg[] = [];
487
+ if (args.theme) {
488
+ try {
489
+ const tickers = await tickersFromTheme(args.theme, args.topK ?? 1, 30);
490
+ if (tickers.length === 0) {
491
+ return wrapError('basket', 'THEME_EMPTY', `Theme "${args.theme}" resolved to 0 tickers.`);
492
+ }
493
+ const totalStake = args.bankroll ?? 1000;
494
+ const perLeg = totalStake / tickers.length;
495
+ const sideDefault = args.side ?? 'yes';
496
+ legs = tickers.map((t) => ({ market_ticker: t, side: sideDefault, stake_usd: perLeg }));
497
+ } catch (err) {
498
+ return wrapError('basket', 'THEME_RESOLVE', err instanceof Error ? err.message : String(err));
499
+ }
500
+ } else {
501
+ const parsed = parseValidateLegs(args);
502
+ if (parsed.error) return wrapError('basket', 'MISSING_LEGS', parsed.error);
503
+ legs = parsed.legs;
504
+ }
505
+ if (legs.length === 0) {
506
+ return wrapError('basket', 'MISSING_LEGS', 'No legs to validate.');
507
+ }
508
+ const body: ValidateBasketBody = {
509
+ legs,
510
+ bankroll_usd: args.bankroll,
511
+ correlation_window_days: args.windowDays ?? 30,
512
+ correlation_interval: args.correlationInterval,
513
+ max_pairwise_correlation: args.maxCorrelation,
514
+ calendar_clash_window_days: 7,
515
+ };
516
+ try {
517
+ const data = await validateBasket(body);
518
+ return wrapSuccess('basket', data);
519
+ } catch (err) {
520
+ const message = err instanceof Error ? err.message : String(err);
521
+ return wrapError('basket', 'OCTAGON_ERROR', message);
522
+ }
523
+ }
524
+
525
+ export function formatBasketValidateHuman(data: BasketValidateResponse): string {
526
+ const lines: string[] = [];
527
+ const bankroll = data.bankroll_usd != null ? `$${data.bankroll_usd.toFixed(0)}` : 'n/a';
528
+ lines.push(`Basket validation — total stake $${data.total_stake_usd.toFixed(0)}, bankroll ${bankroll}, max leg ${(data.max_leg_pct * 100).toFixed(1)}%`);
529
+ if (data.max_pairwise_correlation != null) {
530
+ lines.push(` Max pairwise correlation: ${data.max_pairwise_correlation.toFixed(2)}`);
531
+ } else {
532
+ lines.push(' Max pairwise correlation: n/a (no overlapping history)');
533
+ }
534
+ lines.push('');
535
+
536
+ // Cluster breakdown — flag any cluster with ≥2 legs
537
+ const thematic = Object.entries(data.cluster_breakdown_thematic);
538
+ if (thematic.length > 0) {
539
+ lines.push('Thematic cluster breakdown:');
540
+ for (const [clusterId, tickers] of thematic) {
541
+ const warn = tickers.length >= 2 ? ' ⚠' : '';
542
+ lines.push(` Cluster ${clusterId}${warn} ${tickers.join(', ')}`);
543
+ }
544
+ lines.push('');
545
+ }
546
+
547
+ if (data.unassigned_market_tickers.length > 0) {
548
+ lines.push(`Unassigned (no cluster): ${data.unassigned_market_tickers.join(', ')}`);
549
+ lines.push('');
550
+ }
551
+
552
+ // Pairwise correlations
553
+ if (data.pairwise_correlations.length > 0) {
554
+ lines.push('Pairwise correlations (top 10 by |corr|):');
555
+ const top = data.pairwise_correlations
556
+ .slice()
557
+ .sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation))
558
+ .slice(0, 10);
559
+ const rows: string[][] = top.map((p) => [
560
+ p.ticker_a.length > 22 ? p.ticker_a.slice(0, 21) + '…' : p.ticker_a,
561
+ p.ticker_b.length > 22 ? p.ticker_b.slice(0, 21) + '…' : p.ticker_b,
562
+ p.correlation.toFixed(3),
563
+ ]);
564
+ lines.push(formatTable(['Ticker A', 'Ticker B', 'Corr'], rows));
565
+ lines.push('');
566
+ }
567
+
568
+ if (data.calendar_clashes.length > 0) {
569
+ lines.push(`Calendar clashes (${data.calendar_clashes.length} weeks):`);
570
+ for (const c of data.calendar_clashes) {
571
+ lines.push(` ${c.window_start.slice(0, 10)} → ${c.window_end.slice(0, 10)}: ${c.market_tickers.join(', ')}`);
572
+ }
573
+ lines.push('');
574
+ }
575
+
576
+ if (data.duplicate_underliers.length > 0) {
577
+ lines.push(`Duplicate underliers (same event):`);
578
+ for (const d of data.duplicate_underliers) {
579
+ lines.push(` ${d.event_ticker}: ${d.market_tickers.join(', ')}`);
580
+ }
581
+ lines.push('');
582
+ }
583
+
584
+ if (data.warnings.length > 0) {
585
+ lines.push('⚠ Warnings:');
586
+ for (const w of data.warnings) lines.push(` • ${w}`);
587
+ } else {
588
+ lines.push('✓ No warnings.');
589
+ }
590
+ return lines.join('\n');
591
+ }
592
+
593
+ // Re-export for use by handleBasket below
594
+ export { getMarketsEdge };
595
+
596
+ // ─── Dispatcher ─────────────────────────────────────────────────────────────
597
+
598
+ export type BasketResult =
599
+ | { sub: 'build'; data: BasketBuildResponse }
600
+ | { sub: 'backtest'; data: BasketBacktestResponse }
601
+ | { sub: 'size'; data: BasketSizeResponse }
602
+ | { sub: 'candles'; data: BasketCandlesResponse }
603
+ | { sub: 'validate'; data: BasketValidateResponse };
604
+
605
+ export async function handleBasket(args: ParsedArgs): Promise<CLIResponse<BasketResult>> {
606
+ const sub = args.positionalArgs[0]?.toLowerCase();
607
+ if (sub === 'build') {
608
+ const resp = await handleBasketBuild(args);
609
+ return liftBasket(resp, 'build');
610
+ }
611
+ if (sub === 'backtest') {
612
+ const resp = await handleBasketBacktest(args);
613
+ return liftBasket(resp, 'backtest');
614
+ }
615
+ if (sub === 'size') {
616
+ const resp = await handleBasketSize(args);
617
+ return liftBasket(resp, 'size');
618
+ }
619
+ if (sub === 'candles') {
620
+ const resp = await handleBasketCandles(args);
621
+ return liftBasket(resp, 'candles');
622
+ }
623
+ if (sub === 'validate') {
624
+ const resp = await handleBasketValidate(args);
625
+ return liftBasket(resp, 'validate');
626
+ }
627
+ return wrapError('basket', 'MISSING_SUBCOMMAND', 'Usage: basket <build|backtest|size|candles|validate> [...]');
628
+ }
629
+
630
+ function liftBasket<T>(resp: CLIResponse<T>, sub: BasketResult['sub']): CLIResponse<BasketResult> {
631
+ if (!resp.ok) return resp as unknown as CLIResponse<BasketResult>;
632
+ return {
633
+ ok: true,
634
+ command: 'basket',
635
+ timestamp: resp.timestamp,
636
+ data: { sub, data: resp.data } as BasketResult,
637
+ };
638
+ }
639
+
640
+ export function formatBasketHuman(result: BasketResult): string {
641
+ if (result.sub === 'build') return formatBasketBuildHuman(result.data);
642
+ if (result.sub === 'backtest') return formatBasketBacktestHuman(result.data);
643
+ if (result.sub === 'size') return formatBasketSizeHuman(result.data);
644
+ if (result.sub === 'validate') return formatBasketValidateHuman(result.data);
645
+ return formatBasketCandlesHuman(result.data);
646
+ }