kalshi-trading-bot-cli 2.1.0

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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/assets/kalshi-flow-light.png +0 -0
  4. package/assets/screenshot.png +0 -0
  5. package/env.example +43 -0
  6. package/kalshi-flow-light.png +0 -0
  7. package/package.json +66 -0
  8. package/src/agent/agent.ts +249 -0
  9. package/src/agent/channels.ts +53 -0
  10. package/src/agent/index.ts +29 -0
  11. package/src/agent/prompts.ts +171 -0
  12. package/src/agent/run-context.ts +23 -0
  13. package/src/agent/scratchpad.ts +465 -0
  14. package/src/agent/token-counter.ts +33 -0
  15. package/src/agent/tool-executor.ts +166 -0
  16. package/src/agent/types.ts +221 -0
  17. package/src/audit/index.ts +25 -0
  18. package/src/audit/reader.ts +43 -0
  19. package/src/audit/trail.ts +29 -0
  20. package/src/audit/types.ts +133 -0
  21. package/src/backtest/discovery.ts +170 -0
  22. package/src/backtest/fetcher.ts +247 -0
  23. package/src/backtest/metrics.ts +165 -0
  24. package/src/backtest/renderer.ts +196 -0
  25. package/src/backtest/types.ts +45 -0
  26. package/src/cli.ts +943 -0
  27. package/src/commands/alerts.ts +48 -0
  28. package/src/commands/analyze.ts +662 -0
  29. package/src/commands/backtest.ts +276 -0
  30. package/src/commands/clear-cache.ts +24 -0
  31. package/src/commands/config.ts +107 -0
  32. package/src/commands/dispatch.ts +473 -0
  33. package/src/commands/edge.ts +62 -0
  34. package/src/commands/formatters.ts +339 -0
  35. package/src/commands/help.ts +263 -0
  36. package/src/commands/helpers.ts +48 -0
  37. package/src/commands/index.ts +287 -0
  38. package/src/commands/json.ts +43 -0
  39. package/src/commands/parse-args.ts +229 -0
  40. package/src/commands/portfolio.ts +236 -0
  41. package/src/commands/review.ts +176 -0
  42. package/src/commands/scan-formatters.ts +98 -0
  43. package/src/commands/scan.ts +38 -0
  44. package/src/commands/search-edge.ts +139 -0
  45. package/src/commands/status.ts +70 -0
  46. package/src/commands/themes.ts +117 -0
  47. package/src/commands/watch.ts +295 -0
  48. package/src/components/answer-box.ts +57 -0
  49. package/src/components/approval-prompt.ts +34 -0
  50. package/src/components/browse-list.ts +134 -0
  51. package/src/components/chat-log.ts +291 -0
  52. package/src/components/custom-editor.ts +18 -0
  53. package/src/components/debug-panel.ts +52 -0
  54. package/src/components/index.ts +17 -0
  55. package/src/components/intro.ts +92 -0
  56. package/src/components/select-list.ts +155 -0
  57. package/src/components/tool-event.ts +127 -0
  58. package/src/components/user-query.ts +18 -0
  59. package/src/components/working-indicator.ts +87 -0
  60. package/src/controllers/agent-runner.ts +283 -0
  61. package/src/controllers/browse.ts +1013 -0
  62. package/src/controllers/index.ts +7 -0
  63. package/src/controllers/input-history.ts +76 -0
  64. package/src/controllers/model-selection.ts +244 -0
  65. package/src/db/alerts.ts +77 -0
  66. package/src/db/edge.ts +105 -0
  67. package/src/db/event-index.ts +323 -0
  68. package/src/db/events.ts +41 -0
  69. package/src/db/index.ts +60 -0
  70. package/src/db/octagon-cache.ts +118 -0
  71. package/src/db/positions.ts +71 -0
  72. package/src/db/risk.ts +51 -0
  73. package/src/db/schema.ts +227 -0
  74. package/src/db/themes.ts +34 -0
  75. package/src/db/trades.ts +50 -0
  76. package/src/eval/brier.ts +90 -0
  77. package/src/eval/index.ts +4 -0
  78. package/src/eval/performance.ts +87 -0
  79. package/src/gateway/access-control.ts +253 -0
  80. package/src/gateway/agent-runner.ts +75 -0
  81. package/src/gateway/alerts/formatter.ts +90 -0
  82. package/src/gateway/alerts/index.ts +4 -0
  83. package/src/gateway/alerts/router.ts +32 -0
  84. package/src/gateway/alerts/terminal.ts +16 -0
  85. package/src/gateway/alerts/types.ts +13 -0
  86. package/src/gateway/channels/index.ts +9 -0
  87. package/src/gateway/channels/manager.ts +153 -0
  88. package/src/gateway/channels/types.ts +48 -0
  89. package/src/gateway/channels/whatsapp/README.md +234 -0
  90. package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
  91. package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
  92. package/src/gateway/channels/whatsapp/error.ts +122 -0
  93. package/src/gateway/channels/whatsapp/inbound.ts +326 -0
  94. package/src/gateway/channels/whatsapp/index.ts +5 -0
  95. package/src/gateway/channels/whatsapp/lid.ts +56 -0
  96. package/src/gateway/channels/whatsapp/logger.ts +25 -0
  97. package/src/gateway/channels/whatsapp/login.ts +94 -0
  98. package/src/gateway/channels/whatsapp/outbound.ts +119 -0
  99. package/src/gateway/channels/whatsapp/plugin.ts +54 -0
  100. package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
  101. package/src/gateway/channels/whatsapp/runtime.ts +122 -0
  102. package/src/gateway/channels/whatsapp/session.ts +89 -0
  103. package/src/gateway/channels/whatsapp/types.ts +32 -0
  104. package/src/gateway/commands/handler.ts +64 -0
  105. package/src/gateway/commands/index.ts +7 -0
  106. package/src/gateway/commands/parser.ts +29 -0
  107. package/src/gateway/commands/wa-formatters.ts +92 -0
  108. package/src/gateway/config.ts +244 -0
  109. package/src/gateway/extension-points.ts +17 -0
  110. package/src/gateway/gateway.ts +301 -0
  111. package/src/gateway/group/history-buffer.ts +75 -0
  112. package/src/gateway/group/index.ts +8 -0
  113. package/src/gateway/group/member-tracker.ts +60 -0
  114. package/src/gateway/group/mention-detection.ts +42 -0
  115. package/src/gateway/heartbeat/index.ts +8 -0
  116. package/src/gateway/heartbeat/prompt.ts +73 -0
  117. package/src/gateway/heartbeat/runner.ts +200 -0
  118. package/src/gateway/heartbeat/suppression.ts +74 -0
  119. package/src/gateway/index.ts +138 -0
  120. package/src/gateway/routing/resolve-route.ts +119 -0
  121. package/src/gateway/sessions/store.ts +65 -0
  122. package/src/gateway/types.ts +11 -0
  123. package/src/gateway/utils.ts +82 -0
  124. package/src/index.tsx +30 -0
  125. package/src/model/llm.ts +247 -0
  126. package/src/providers.ts +94 -0
  127. package/src/risk/circuit-breaker.ts +113 -0
  128. package/src/risk/correlation.ts +40 -0
  129. package/src/risk/gate.ts +125 -0
  130. package/src/risk/index.ts +10 -0
  131. package/src/risk/kelly.ts +230 -0
  132. package/src/scan/alerter.ts +64 -0
  133. package/src/scan/edge-computer.ts +164 -0
  134. package/src/scan/invoker.ts +199 -0
  135. package/src/scan/loop.ts +184 -0
  136. package/src/scan/octagon-client.ts +627 -0
  137. package/src/scan/octagon-events-api.ts +105 -0
  138. package/src/scan/octagon-prefetch.ts +172 -0
  139. package/src/scan/theme-resolver.ts +179 -0
  140. package/src/scan/types.ts +62 -0
  141. package/src/scan/watchdog.ts +126 -0
  142. package/src/setup/wizard.ts +659 -0
  143. package/src/theme.ts +67 -0
  144. package/src/tools/fetch/cache.ts +95 -0
  145. package/src/tools/fetch/external-content.ts +200 -0
  146. package/src/tools/fetch/index.ts +1 -0
  147. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  148. package/src/tools/fetch/web-fetch.ts +419 -0
  149. package/src/tools/index.ts +10 -0
  150. package/src/tools/kalshi/api.ts +251 -0
  151. package/src/tools/kalshi/dlq.ts +35 -0
  152. package/src/tools/kalshi/events.ts +84 -0
  153. package/src/tools/kalshi/exchange.ts +24 -0
  154. package/src/tools/kalshi/historical.ts +89 -0
  155. package/src/tools/kalshi/index.ts +11 -0
  156. package/src/tools/kalshi/kalshi-search.ts +437 -0
  157. package/src/tools/kalshi/kalshi-trade.ts +102 -0
  158. package/src/tools/kalshi/markets.ts +76 -0
  159. package/src/tools/kalshi/portfolio.ts +100 -0
  160. package/src/tools/kalshi/search-index.ts +198 -0
  161. package/src/tools/kalshi/series.ts +16 -0
  162. package/src/tools/kalshi/trading.ts +115 -0
  163. package/src/tools/kalshi/types.ts +199 -0
  164. package/src/tools/registry.ts +160 -0
  165. package/src/tools/search/index.ts +25 -0
  166. package/src/tools/search/tavily.ts +35 -0
  167. package/src/tools/types.ts +53 -0
  168. package/src/tools/v2/edge-query.ts +135 -0
  169. package/src/tools/v2/octagon-report.ts +112 -0
  170. package/src/tools/v2/portfolio-query.ts +79 -0
  171. package/src/tools/v2/portfolio-review.ts +59 -0
  172. package/src/tools/v2/risk-status.ts +94 -0
  173. package/src/tools/v2/scan.ts +78 -0
  174. package/src/types/qrcode-terminal.d.ts +7 -0
  175. package/src/types/whiskeysockets-baileys.d.ts +41 -0
  176. package/src/types.ts +22 -0
  177. package/src/utils/ai-message.ts +26 -0
  178. package/src/utils/bot-config.ts +219 -0
  179. package/src/utils/cache.ts +195 -0
  180. package/src/utils/config.ts +113 -0
  181. package/src/utils/env.ts +111 -0
  182. package/src/utils/errors.ts +313 -0
  183. package/src/utils/history-context.ts +32 -0
  184. package/src/utils/in-memory-chat-history.ts +268 -0
  185. package/src/utils/index.ts +28 -0
  186. package/src/utils/input-key-handlers.ts +64 -0
  187. package/src/utils/logger.ts +67 -0
  188. package/src/utils/long-term-chat-history.ts +138 -0
  189. package/src/utils/markdown-table.ts +227 -0
  190. package/src/utils/model.ts +70 -0
  191. package/src/utils/ollama.ts +37 -0
  192. package/src/utils/paths.ts +12 -0
  193. package/src/utils/progress-channel.ts +84 -0
  194. package/src/utils/telemetry.ts +103 -0
  195. package/src/utils/text-navigation.ts +81 -0
  196. package/src/utils/thinking-verbs.ts +18 -0
  197. package/src/utils/tokens.ts +36 -0
  198. package/src/utils/tool-description.ts +61 -0
@@ -0,0 +1,662 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { formatBoxHeader } from './formatters.js';
3
+ import { insertEdge } from '../db/edge.js';
4
+ import { getLatestReport } from '../db/octagon-cache.js';
5
+ import { auditTrail } from '../audit/index.js';
6
+ import { EdgeComputer } from '../scan/edge-computer.js';
7
+ import { OctagonClient } from '../scan/octagon-client.js';
8
+ import { createOctagonInvoker } from '../scan/invoker.js';
9
+ import * as readline from 'node:readline';
10
+ import { callKalshiApi, KalshiApiError } from '../tools/kalshi/api.js';
11
+ import type { KalshiMarket, KalshiEvent, KalshiOrder, KalshiPosition } from '../tools/kalshi/types.js';
12
+ import { openPosition, closePosition, getOpenPositions } from '../db/positions.js';
13
+ import { logTrade } from '../db/trades.js';
14
+ import { formatRawReport, parseMarketProb, parsePriceField } from '../controllers/browse.js';
15
+ import type { PriceDriver, Catalyst, Source } from '../scan/types.js';
16
+ import { kellySize, getVolume24h } from '../risk/kelly.js';
17
+ import type { KellyResult } from '../risk/kelly.js';
18
+ import { riskGate } from '../risk/gate.js';
19
+ import { getBotSetting } from '../utils/bot-config.js';
20
+ import type { RiskGateResult } from '../risk/gate.js';
21
+ import { formatTable } from './scan-formatters.js';
22
+
23
+ export interface AnalyzeData {
24
+ ticker: string;
25
+ eventTicker: string;
26
+ title: string;
27
+ expirationTime: string | null;
28
+ modelLastUpdated: string | null;
29
+ modelProb: number;
30
+ marketProb: number;
31
+ edge: number;
32
+ edgePp: string;
33
+ confidence: string;
34
+ mispricingSignal: string;
35
+ signal: string;
36
+ drivers: PriceDriver[];
37
+ catalysts: Catalyst[];
38
+ sources: Source[];
39
+ kelly: KellyResult;
40
+ riskGate: RiskGateResult;
41
+ liquidityGrade: string;
42
+ fromCache: boolean;
43
+ reportAge: string | null;
44
+ reportId: string;
45
+ rawReport: string;
46
+ existingPosition?: { direction: 'yes' | 'no'; size: number } | null;
47
+ closePriceCents?: number | null;
48
+ }
49
+
50
+
51
+ function deriveLiquidityGrade(market: KalshiMarket): string {
52
+ const bid = parsePriceField(market.yes_bid_dollars, market.dollar_yes_bid, market.yes_bid);
53
+ const ask = parsePriceField(market.yes_ask_dollars, market.dollar_yes_ask, market.yes_ask);
54
+ const spreadCents = Number.isFinite(bid) && Number.isFinite(ask) ? Math.round((ask - bid) * 100) : 99;
55
+ const volume = getVolume24h(market);
56
+ if (spreadCents <= 2 && volume >= 5000) return 'Excellent';
57
+ if (spreadCents <= 4 && volume >= 1000) return 'Good';
58
+ return 'Poor';
59
+ }
60
+
61
+ function formatAge(epochSeconds: number): string {
62
+ const ageMs = Date.now() - epochSeconds * 1000;
63
+ const mins = Math.floor(ageMs / 60000);
64
+ if (mins < 60) return `${mins}m ago`;
65
+ const hours = Math.floor(mins / 60);
66
+ if (hours < 24) return `${hours}h ago`;
67
+ const days = Math.floor(hours / 24);
68
+ return `${days}d ago`;
69
+ }
70
+
71
+ function getVolume(m: KalshiMarket): number {
72
+ if (m.volume_fp != null) {
73
+ const v = parseFloat(m.volume_fp);
74
+ if (Number.isFinite(v)) return v;
75
+ }
76
+ return m.volume || 0;
77
+ }
78
+
79
+ /**
80
+ * Resolve a user-provided ticker to a market ticker.
81
+ * Accepts: market ticker, event ticker, or series ticker.
82
+ * Returns the resolved KalshiMarket (picking the most active open market for events/series).
83
+ */
84
+ export async function resolveMarket(input: string): Promise<KalshiMarket> {
85
+ // 1. Try as a market ticker first
86
+ try {
87
+ const res = await callKalshiApi('GET', `/markets/${input}`);
88
+ const m = (res.market ?? res) as KalshiMarket;
89
+ if (m.ticker) return m;
90
+ } catch (err) {
91
+ if (!(err instanceof KalshiApiError && err.statusCode === 404)) throw err;
92
+ }
93
+
94
+ // 2. Try as an event ticker
95
+ try {
96
+ const res = await callKalshiApi('GET', `/events/${input}`, {
97
+ params: { with_nested_markets: true },
98
+ });
99
+ const ev = (res.event ?? res) as KalshiEvent;
100
+ const markets = (ev.markets ?? []).filter(
101
+ (m: KalshiMarket) => m.status === 'open' || m.status === 'active',
102
+ );
103
+ if (markets.length > 0) {
104
+ markets.sort((a, b) => getVolume(b) - getVolume(a));
105
+ return markets[0];
106
+ }
107
+ } catch (err) {
108
+ if (!(err instanceof KalshiApiError && err.statusCode === 404)) throw err;
109
+ }
110
+
111
+ // 3. Try as a series ticker — fetch recent events, then get their markets
112
+ try {
113
+ const res = await callKalshiApi('GET', '/events', {
114
+ params: { series_ticker: input, status: 'open', limit: 5 },
115
+ });
116
+ const events = (res.events ?? []) as KalshiEvent[];
117
+ const allMarkets: KalshiMarket[] = [];
118
+ for (const ev of events) {
119
+ if (!ev.event_ticker) continue;
120
+ try {
121
+ const evRes = await callKalshiApi('GET', `/events/${ev.event_ticker}`, {
122
+ params: { with_nested_markets: true },
123
+ });
124
+ const fullEv = (evRes.event ?? evRes) as KalshiEvent;
125
+ for (const m of (fullEv.markets ?? []) as KalshiMarket[]) {
126
+ if (m.status === 'open' || m.status === 'active') {
127
+ allMarkets.push(m);
128
+ }
129
+ }
130
+ } catch {
131
+ // skip events that fail to fetch
132
+ }
133
+ }
134
+ if (allMarkets.length > 0) {
135
+ allMarkets.sort((a, b) => getVolume(b) - getVolume(a));
136
+ return allMarkets[0];
137
+ }
138
+ } catch (err) {
139
+ if (!(err instanceof KalshiApiError && err.statusCode === 404)) throw err;
140
+ }
141
+
142
+ throw new Error(`Could not find a market for "${input}". Try a full market ticker (e.g. KXBTC-26MAR14-T50049), event ticker (e.g. KXBTC-26MAR14), or series ticker (e.g. KXBTC).`);
143
+ }
144
+
145
+ export async function handleAnalyze(
146
+ ticker: string,
147
+ refresh = false,
148
+ providedPosition?: { direction: 'yes' | 'no'; size: number } | null,
149
+ ): Promise<AnalyzeData> {
150
+ const db = getDb();
151
+
152
+ // Resolve input to a market — accepts market, event, or series tickers
153
+ const market = await resolveMarket(ticker);
154
+ const resolvedTicker = market.ticker;
155
+ const eventTicker = market.event_ticker;
156
+ const marketProb = parseMarketProb(market);
157
+ if (marketProb === null) {
158
+ throw new Error(`No last traded price for ${resolvedTicker} — market may have no trades yet.`);
159
+ }
160
+
161
+ const invoker = createOctagonInvoker();
162
+ const octagonClient = new OctagonClient(invoker, db, auditTrail);
163
+ const edgeComputer = new EdgeComputer(db, auditTrail);
164
+
165
+ // Use cache by default; only refresh when explicitly requested
166
+ // Try prefetch first to avoid an individual Octagon API call
167
+ let variant: 'cache' | 'refresh' = refresh ? 'refresh' : 'cache';
168
+ let report = (!refresh ? octagonClient.tryFromPrefetch(resolvedTicker, eventTicker) : null)
169
+ ?? await octagonClient.fetchReport(resolvedTicker, eventTicker, variant);
170
+
171
+ // If cache returned no meaningful data, auto-fetch fresh
172
+ let usedFresh = refresh;
173
+ if (!refresh && report.cacheMiss) {
174
+ try {
175
+ report = await octagonClient.fetchReport(resolvedTicker, eventTicker, 'refresh');
176
+ usedFresh = true;
177
+ } catch (err) {
178
+ // Auto-refresh failed — continue with cache-miss report rather than crashing
179
+ // The user can explicitly --refresh to retry
180
+ const msg = err instanceof Error ? err.message : String(err);
181
+ console.error(` ⚠ Auto-refresh failed: ${msg}`);
182
+ console.error(` Showing cached data. Run \`analyze ${ticker} --refresh\` to retry.`);
183
+ }
184
+ }
185
+
186
+ const fromCache = !usedFresh;
187
+ const latestDbReport = getLatestReport(db, resolvedTicker);
188
+ const reportAge = latestDbReport ? formatAge(latestDbReport.fetched_at) : null;
189
+
190
+ const snapshot = edgeComputer.computeEdge(resolvedTicker, report, marketProb);
191
+
192
+ // Persist edge
193
+ insertEdge(db, {
194
+ ticker: snapshot.ticker,
195
+ event_ticker: snapshot.eventTicker,
196
+ timestamp: snapshot.timestamp,
197
+ model_prob: snapshot.modelProb,
198
+ market_prob: snapshot.marketProb,
199
+ edge: snapshot.edge,
200
+ octagon_report_id: snapshot.octagonReportId,
201
+ drivers_json: JSON.stringify(snapshot.drivers),
202
+ sources_json: JSON.stringify(snapshot.sources),
203
+ catalysts_json: JSON.stringify(snapshot.catalysts),
204
+ cache_hit: fromCache ? 1 : 0,
205
+ cache_miss: report.cacheMiss ? 1 : 0,
206
+ confidence: snapshot.confidence,
207
+ });
208
+
209
+ // Kelly sizing — wrapped in try/catch for demo mode (portfolio endpoints may 401)
210
+ let kelly: KellyResult;
211
+ try {
212
+ kelly = await kellySize({
213
+ edge: snapshot.edge,
214
+ marketProb,
215
+ market,
216
+ multiplier: getBotSetting('risk.kelly_multiplier') as number | undefined,
217
+ minEdgeThreshold: getBotSetting('risk.min_edge_threshold') as number | undefined,
218
+ });
219
+ } catch {
220
+ kelly = {
221
+ side: snapshot.edge >= 0 ? 'yes' : 'no',
222
+ fraction: 0,
223
+ adjustedFraction: 0,
224
+ contracts: 0,
225
+ dollarAmountCents: 0,
226
+ entryPriceCents: 0,
227
+ availableBankroll: 0,
228
+ openExposure: 0,
229
+ cashBalance: 0,
230
+ portfolioValue: 0,
231
+ liquidityAdjusted: false,
232
+ };
233
+ }
234
+
235
+ // Risk gate
236
+ const gate = riskGate({ ticker: resolvedTicker, eventTicker, kelly, market, db });
237
+
238
+ // Use caller-provided position or fetch from API when not provided
239
+ let existingPosition: { direction: 'yes' | 'no'; size: number } | null =
240
+ providedPosition !== undefined ? (providedPosition ?? null) : null;
241
+ if (providedPosition === undefined) {
242
+ try {
243
+ const posData = await callKalshiApi('GET', '/portfolio/positions', {
244
+ params: { ticker: resolvedTicker },
245
+ });
246
+ const positions = (posData.market_positions ?? posData.positions ?? []) as KalshiPosition[];
247
+ const match = positions.find((p) => p.ticker === resolvedTicker);
248
+ if (match) {
249
+ const rawPos = parseFloat(String(match.position ?? '0'));
250
+ if (rawPos !== 0) {
251
+ existingPosition = {
252
+ direction: rawPos > 0 ? 'yes' : 'no',
253
+ size: Math.abs(Math.round(rawPos)),
254
+ };
255
+ }
256
+ }
257
+ } catch {
258
+ // Position fetch failed (e.g. demo mode) — continue without
259
+ }
260
+ }
261
+
262
+ // Build signal — position-aware
263
+ const side = snapshot.edge > 0 ? 'YES' : 'NO';
264
+ const yesAsk = parsePriceField(market.yes_ask_dollars, market.dollar_yes_ask, market.yes_ask);
265
+ const noAsk = parsePriceField(market.no_ask_dollars, market.dollar_no_ask, market.no_ask);
266
+ const yesBid = parsePriceField(market.yes_bid_dollars, market.dollar_yes_bid, market.yes_bid);
267
+ const noBid = parsePriceField(market.no_bid_dollars, market.dollar_no_bid, market.no_bid);
268
+ const entryPrice = (snapshot.edge > 0 ? yesAsk : noAsk);
269
+
270
+ let signal: string;
271
+ if (existingPosition) {
272
+ const holdDir = existingPosition.direction.toUpperCase();
273
+ const edgeReversed =
274
+ (existingPosition.direction === 'yes' && snapshot.edge < -0.03) ||
275
+ (existingPosition.direction === 'no' && snapshot.edge > 0.03);
276
+ if (edgeReversed) {
277
+ const closePrice = existingPosition.direction === 'yes' ? yesBid : noBid;
278
+ signal = Number.isFinite(closePrice)
279
+ ? `SELL ${holdDir} @ $${closePrice.toFixed(2)} (close position)`
280
+ : `SELL ${holdDir} (close position)`;
281
+ } else {
282
+ signal = `HOLD (long ${holdDir} ×${existingPosition.size})`;
283
+ }
284
+ } else {
285
+ signal = Number.isFinite(entryPrice) ? `BUY ${side} @ $${entryPrice.toFixed(2)}` : `BUY ${side}`;
286
+ }
287
+ const edgePp = `${snapshot.edge >= 0 ? '+' : ''}${(snapshot.edge * 100).toFixed(0)}pp`;
288
+
289
+ const mispricingSignal = snapshot.edge > 0.02
290
+ ? 'underpriced'
291
+ : snapshot.edge < -0.02
292
+ ? 'overpriced'
293
+ : 'fair_value';
294
+
295
+ // Audit
296
+ auditTrail.log({
297
+ type: 'RECOMMENDATION',
298
+ ticker: resolvedTicker,
299
+ action: signal,
300
+ size: kelly.contracts,
301
+ kelly: kelly.adjustedFraction,
302
+ risk_gate: gate.passed ? 'PASSED' : 'FAILED',
303
+ });
304
+
305
+ // Model last-updated timestamp
306
+ const modelUpdatedAt = latestDbReport
307
+ ? new Date(latestDbReport.fetched_at * 1000).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
308
+ : null;
309
+
310
+ return {
311
+ ticker: resolvedTicker,
312
+ eventTicker,
313
+ title: market.title || market.subtitle || resolvedTicker,
314
+ expirationTime: market.expiration_time || market.expected_expiration_time || market.close_time || null,
315
+ modelLastUpdated: modelUpdatedAt,
316
+ modelProb: snapshot.modelProb,
317
+ marketProb,
318
+ edge: snapshot.edge,
319
+ edgePp,
320
+ confidence: snapshot.confidence,
321
+ mispricingSignal,
322
+ signal,
323
+ drivers: snapshot.drivers,
324
+ catalysts: snapshot.catalysts,
325
+ sources: snapshot.sources,
326
+ kelly,
327
+ riskGate: gate,
328
+ liquidityGrade: deriveLiquidityGrade(market),
329
+ fromCache,
330
+ reportAge,
331
+ reportId: report.reportId,
332
+ rawReport: report.rawResponse,
333
+ existingPosition,
334
+ closePriceCents: existingPosition
335
+ ? Math.round((existingPosition.direction === 'yes' ? yesBid : noBid) * 100) || null
336
+ : null,
337
+ };
338
+ }
339
+
340
+ export function formatAnalyzeHuman(data: AnalyzeData): string {
341
+ const lines: string[] = [];
342
+
343
+ lines.push(...formatBoxHeader('MARKET ANALYSIS'));
344
+ lines.push('');
345
+ lines.push(` Title: ${data.title}`);
346
+ lines.push(` Ticker: ${data.ticker}`);
347
+ lines.push(` Event: ${data.eventTicker}`);
348
+ if (data.expirationTime) {
349
+ const exp = new Date(data.expirationTime);
350
+ lines.push(` Expires: ${exp.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} ${exp.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })}`);
351
+ }
352
+ lines.push(` Signal: ${data.signal}`);
353
+ if (data.existingPosition) {
354
+ lines.push(` Position: ${data.existingPosition.direction.toUpperCase()} ×${data.existingPosition.size}`);
355
+ }
356
+ lines.push('');
357
+
358
+ // Edge & Probabilities
359
+ lines.push(` Model Prob: ${(data.modelProb * 100).toFixed(1)}%`);
360
+ lines.push(` Market Prob: ${(data.marketProb * 100).toFixed(1)}%`);
361
+ lines.push(` Edge: ${data.edgePp} (${(data.edge * 100).toFixed(1)}%)`);
362
+ lines.push(` Confidence: ${data.confidence}`);
363
+ lines.push(` Mispricing: ${data.mispricingSignal}`);
364
+ lines.push('');
365
+
366
+ // Price Drivers
367
+ if (data.drivers.length > 0) {
368
+ lines.push(' Price Drivers:');
369
+ for (const d of data.drivers) {
370
+ const src = d.sourceUrl ? ` (${d.sourceUrl})` : '';
371
+ lines.push(` • [${d.impact.toUpperCase()}/${d.category}] ${d.claim}${src}`);
372
+ }
373
+ lines.push('');
374
+ }
375
+
376
+ // Catalyst Calendar
377
+ if (data.catalysts.length > 0) {
378
+ lines.push(' Catalyst Calendar:');
379
+ const catRows = data.catalysts.map((c) => [
380
+ c.date || '-',
381
+ c.event,
382
+ c.impact.toUpperCase(),
383
+ c.potentialMove || '-',
384
+ ]);
385
+ lines.push(formatTable(
386
+ ['Date', 'Event', 'Impact', 'Potential Move'],
387
+ catRows,
388
+ ));
389
+ lines.push('');
390
+ }
391
+
392
+ // Kelly Sizing
393
+ lines.push(' Position Sizing (Half-Kelly):');
394
+ lines.push(` Side: ${data.kelly.side.toUpperCase()}`);
395
+ lines.push(` Cash Balance: $${(data.kelly.cashBalance / 100).toFixed(2)}`);
396
+ lines.push(` Open Exposure: $${(data.kelly.openExposure / 100).toFixed(2)}`);
397
+ lines.push(` Available: $${(data.kelly.availableBankroll / 100).toFixed(2)}`);
398
+ lines.push(` Contracts: ${data.kelly.contracts}`);
399
+ lines.push(` Dollar Amount: $${(data.kelly.dollarAmountCents / 100).toFixed(2)}`);
400
+ lines.push(` Entry Price: ${data.kelly.entryPriceCents}¢`);
401
+ lines.push(` Kelly f*: ${(data.kelly.fraction * 100).toFixed(1)}%`);
402
+ lines.push(` Adjusted f: ${(data.kelly.adjustedFraction * 100).toFixed(1)}%`);
403
+ if (data.kelly.liquidityAdjusted) {
404
+ lines.push(' ⚠ Liquidity-adjusted (wide spread or low volume)');
405
+ }
406
+ if (data.kelly.skippedReason) {
407
+ lines.push(` ⚠ ${data.kelly.skippedReason}`);
408
+ }
409
+ lines.push('');
410
+
411
+ // Risk Gate
412
+ const gateIcon = data.riskGate.passed ? '✓' : '✗';
413
+ lines.push(` Risk Gate: ${gateIcon} ${data.riskGate.passed ? 'PASSED' : 'FAILED'}`);
414
+ for (const check of data.riskGate.checks) {
415
+ const icon = check.passed ? '✓' : '✗';
416
+ lines.push(` ${icon} ${check.name}: ${check.reason}`);
417
+ }
418
+ lines.push('');
419
+ lines.push(` Liquidity: ${data.liquidityGrade}`);
420
+
421
+ // Sources
422
+ if (data.sources.length > 0) {
423
+ lines.push('');
424
+ lines.push(' Sources:');
425
+ for (const s of data.sources) {
426
+ const title = s.title ? `${s.title}: ` : '';
427
+ lines.push(` • ${title}${s.url}`);
428
+ }
429
+ }
430
+
431
+ // Cache status & model timestamp
432
+ lines.push('');
433
+ if (data.modelLastUpdated) {
434
+ lines.push(` Model Updated: ${data.modelLastUpdated}`);
435
+ }
436
+ if (data.fromCache && data.reportAge) {
437
+ lines.push(` Data: cached (${data.reportAge}). Run \`analyze ${data.ticker} --refresh\` for latest (costs 3 credits).`);
438
+ } else if (data.fromCache) {
439
+ lines.push(` Data: cached. Run \`analyze ${data.ticker} --refresh\` for latest (costs 3 credits).`);
440
+ } else {
441
+ lines.push(' Data: freshly generated.');
442
+ }
443
+
444
+ return lines.join('\n');
445
+ }
446
+
447
+ /**
448
+ * Interactive post-analyze menu. Presents options to view the full report,
449
+ * refresh the report, or place the suggested trade.
450
+ */
451
+ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
452
+ if (!process.stdin.isTTY) return;
453
+
454
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
455
+ const ask = (q: string) => new Promise<string>((resolve) => {
456
+ rl.question(q, (ans) => resolve(ans.trim()));
457
+ });
458
+
459
+ const menu = [
460
+ ' 1) View full report',
461
+ ' 2) Refresh report (costs credits)',
462
+ ' 3) Make suggested trade',
463
+ ' 4) Exit',
464
+ ].join('\n');
465
+
466
+ let running = true;
467
+ while (running) {
468
+ console.log(`\n${menu}`);
469
+ const choice = await ask('\n Choose [1-4]: ');
470
+
471
+ switch (choice) {
472
+ case '1': {
473
+ if (data.rawReport) {
474
+ console.log('\n' + formatRawReport(data.rawReport, data.ticker));
475
+ } else {
476
+ console.log(' No report available. Try option 2 to refresh.');
477
+ }
478
+ break;
479
+ }
480
+
481
+ case '2': {
482
+ console.log(' Fetching fresh report…');
483
+ try {
484
+ const freshData = await handleAnalyze(data.ticker, true);
485
+ data = freshData;
486
+ console.log(formatAnalyzeHuman(data));
487
+ } catch (err) {
488
+ console.error(` Refresh failed: ${err instanceof Error ? err.message : String(err)}`);
489
+ }
490
+ break;
491
+ }
492
+
493
+ case '3': {
494
+ // Determine if this is a SELL (close position) or BUY (open position)
495
+ const isSell = data.signal.startsWith('SELL');
496
+ const isHold = data.signal.startsWith('HOLD');
497
+
498
+ if (isHold) {
499
+ console.log(' Signal is HOLD — no trade suggested.');
500
+ break;
501
+ }
502
+
503
+ if (isSell && data.existingPosition) {
504
+ // Close position: sell what we hold
505
+ const sellSide = data.existingPosition.direction;
506
+ const sellSize = data.existingPosition.size;
507
+ const closePrice = data.closePriceCents ?? Math.round(
508
+ (sellSide === 'yes' ? data.marketProb : 1 - data.marketProb) * 100
509
+ );
510
+
511
+ console.log(` Signal: SELL ${sellSize} ${sellSide.toUpperCase()} @ ${closePrice}¢ (close position)`);
512
+ const confirm = await ask(' Execute? [y/n] ');
513
+ if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
514
+ console.log(' Trade cancelled.');
515
+ break;
516
+ }
517
+
518
+ try {
519
+ const orderPayload: Record<string, unknown> = {
520
+ ticker: data.ticker,
521
+ action: 'sell',
522
+ side: sellSide,
523
+ type: 'limit',
524
+ count: sellSize,
525
+ };
526
+ if (sellSide === 'yes') orderPayload.yes_price = closePrice;
527
+ else orderPayload.no_price = closePrice;
528
+
529
+ const orderRes = await callKalshiApi('POST', '/portfolio/orders', { body: orderPayload });
530
+ const order = (orderRes.order ?? orderRes) as KalshiOrder;
531
+
532
+ const db = getDb();
533
+ const now = Math.floor(Date.now() / 1000);
534
+
535
+ // Find matching open DB position for this ticker to close
536
+ const dbPositions = getOpenPositions(db);
537
+ const dbMatch = dbPositions.find(
538
+ (p) => p.ticker === data.ticker && p.direction === sellSide,
539
+ );
540
+
541
+ logTrade(db, {
542
+ trade_id: crypto.randomUUID(),
543
+ position_id: dbMatch?.position_id ?? '',
544
+ order_id: order.order_id,
545
+ ticker: data.ticker,
546
+ action: 'sell',
547
+ side: sellSide,
548
+ size: sellSize,
549
+ price: closePrice,
550
+ fill_status: order.status,
551
+ kalshi_response: JSON.stringify(order),
552
+ created_at: now,
553
+ });
554
+
555
+ auditTrail.log({
556
+ type: 'TRADE_EXECUTED',
557
+ ticker: data.ticker,
558
+ order_id: order.order_id,
559
+ fill_price: closePrice,
560
+ size: sellSize,
561
+ });
562
+
563
+ // If order filled immediately, close the DB position
564
+ if (dbMatch && order.status === 'filled') {
565
+ closePosition(db, dbMatch.position_id, now);
566
+ }
567
+
568
+ console.log(` Sell order placed: ${order.order_id} (${order.status})`);
569
+ } catch (err) {
570
+ console.error(` Trade failed: ${err instanceof Error ? err.message : String(err)}`);
571
+ }
572
+ break;
573
+ }
574
+
575
+ if (!data.riskGate.passed) {
576
+ console.log(' Risk gate FAILED — trade blocked.');
577
+ break;
578
+ }
579
+ if (data.kelly.contracts === 0) {
580
+ console.log(` Kelly sizing produced 0 contracts${data.kelly.skippedReason ? `: ${data.kelly.skippedReason}` : ''}.`);
581
+ break;
582
+ }
583
+
584
+ const side = data.edge > 0 ? 'yes' : 'no';
585
+ const price = data.kelly.entryPriceCents;
586
+ console.log(` Signal: BUY ${data.kelly.contracts} ${side.toUpperCase()} @ ${price}¢`);
587
+ const confirm = await ask(' Execute? [y/n] ');
588
+ if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
589
+ console.log(' Trade cancelled.');
590
+ break;
591
+ }
592
+
593
+ try {
594
+ const orderPayload: Record<string, unknown> = {
595
+ ticker: data.ticker,
596
+ action: 'buy',
597
+ side,
598
+ type: 'limit',
599
+ count: data.kelly.contracts,
600
+ };
601
+ if (side === 'yes') orderPayload.yes_price = price;
602
+ else orderPayload.no_price = price;
603
+
604
+ const orderRes = await callKalshiApi('POST', '/portfolio/orders', { body: orderPayload });
605
+ const order = (orderRes.order ?? orderRes) as KalshiOrder;
606
+
607
+ const db = getDb();
608
+ const positionId = crypto.randomUUID();
609
+ const now = Math.floor(Date.now() / 1000);
610
+
611
+ openPosition(db, {
612
+ position_id: positionId,
613
+ ticker: data.ticker,
614
+ event_ticker: data.eventTicker,
615
+ direction: side,
616
+ size: data.kelly.contracts,
617
+ entry_price: price,
618
+ entry_edge: data.edge,
619
+ entry_kelly: data.kelly.adjustedFraction,
620
+ current_pnl: 0,
621
+ status: 'open',
622
+ opened_at: now,
623
+ });
624
+
625
+ logTrade(db, {
626
+ trade_id: crypto.randomUUID(),
627
+ position_id: positionId,
628
+ order_id: order.order_id,
629
+ ticker: data.ticker,
630
+ action: 'buy',
631
+ side,
632
+ size: data.kelly.contracts,
633
+ price,
634
+ fill_status: order.status,
635
+ kalshi_response: JSON.stringify(order),
636
+ created_at: now,
637
+ });
638
+
639
+ auditTrail.log({
640
+ type: 'TRADE_EXECUTED',
641
+ ticker: data.ticker,
642
+ order_id: order.order_id,
643
+ fill_price: price,
644
+ size: data.kelly.contracts,
645
+ });
646
+
647
+ console.log(` Order placed: ${order.order_id} (${order.status})`);
648
+ } catch (err) {
649
+ console.error(` Trade failed: ${err instanceof Error ? err.message : String(err)}`);
650
+ }
651
+ break;
652
+ }
653
+
654
+ case '4':
655
+ default:
656
+ running = false;
657
+ break;
658
+ }
659
+ }
660
+
661
+ rl.close();
662
+ }