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,1013 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { getLatestEdge, insertEdge } from '../db/edge.js';
3
+ import { getLatestReport, updateReportModelProb } from '../db/octagon-cache.js';
4
+ import { auditTrail } from '../audit/index.js';
5
+ import { OctagonClient } from '../scan/octagon-client.js';
6
+ import { EdgeComputer } from '../scan/edge-computer.js';
7
+ import { createOctagonInvoker } from '../scan/invoker.js';
8
+ import { callKalshiApi } from '../tools/kalshi/api.js';
9
+ import { callOctagon } from '../scan/invoker.js';
10
+ import { ensureIndex, onIndexProgress, getRefreshPromise } from '../tools/kalshi/search-index.js';
11
+ import { getEventsFromIndex, getTopEventsByVolume, getIndexAge } from '../db/event-index.js';
12
+ import { resolveMarket } from '../commands/analyze.js';
13
+ import type { KalshiEvent, KalshiMarket } from '../tools/kalshi/types.js';
14
+ import { trackEvent } from '../utils/telemetry.js';
15
+
16
+ /** Maps lowercase theme IDs to exact Kalshi category labels (inlined to avoid heavy theme-resolver import) */
17
+ const CATEGORY_MAP: Record<string, string> = {
18
+ climate: 'Climate and Weather',
19
+ companies: 'Companies',
20
+ crypto: 'Crypto',
21
+ economics: 'Economics',
22
+ elections: 'Elections',
23
+ entertainment: 'Entertainment',
24
+ financials: 'Financials',
25
+ health: 'Health',
26
+ mentions: 'Mentions',
27
+ politics: 'Politics',
28
+ science: 'Science and Technology',
29
+ social: 'Social',
30
+ sports: 'Sports',
31
+ transportation: 'Transportation',
32
+ world: 'World',
33
+ };
34
+
35
+ /** Minimal market shape needed by parseMarketProb and isMarketActive */
36
+ export interface MarketRow {
37
+ last_price_dollars?: string | null;
38
+ dollar_last_price?: string | null;
39
+ last_price?: number | null;
40
+ yes_bid_dollars?: string | null;
41
+ dollar_yes_bid?: string | null;
42
+ yes_ask_dollars?: string | null;
43
+ dollar_yes_ask?: string | null;
44
+ yes_bid?: number | null;
45
+ yes_ask?: number | null;
46
+ response_price_units?: string | null;
47
+ status?: string | null;
48
+ result?: string | null;
49
+ volume_24h?: number | string | null;
50
+ }
51
+
52
+ /** Parse a dollar or cent price field into a decimal probability (0-1).
53
+ * Checks both new (yes_bid_dollars) and legacy (dollar_yes_bid) API field names. */
54
+ export function parsePriceField(newDollar: string | undefined | null, legacyDollar: string | undefined | null, centVal: number | undefined | null): number {
55
+ if (newDollar != null) {
56
+ const d = parseFloat(String(newDollar).trim());
57
+ if (Number.isFinite(d)) return d;
58
+ }
59
+ if (legacyDollar != null) {
60
+ const d = parseFloat(String(legacyDollar).trim());
61
+ if (Number.isFinite(d)) return d;
62
+ }
63
+ if (centVal != null && Number.isFinite(centVal)) return centVal / 100;
64
+ return NaN;
65
+ }
66
+
67
+ /** Parse a market probability from last traded price.
68
+ * Returns null if no last_price is available — callers should display "—" or skip the market.
69
+ * Does NOT fall back to bid/ask mid, which misrepresents where the market is actually trading. */
70
+ export function parseMarketProb(m: MarketRow): number | null {
71
+ // Check all three API field name variants: last_price_dollars (new), dollar_last_price (legacy), last_price (cents)
72
+ const dollarStr = m.last_price_dollars ?? m.dollar_last_price;
73
+ if (dollarStr != null) {
74
+ const d = parseFloat(String(dollarStr));
75
+ if (Number.isFinite(d) && d > 0) return d;
76
+ }
77
+ if (m.last_price != null && m.last_price > 0) return m.last_price / 100;
78
+ return null;
79
+ }
80
+
81
+ /** Check if a market is actively tradeable: open/active, not resolved, and has at least one trade */
82
+ export function isMarketActive(m: MarketRow): boolean {
83
+ // Must be in a tradeable state
84
+ if (m.status !== 'open' && m.status !== 'active') return false;
85
+ // Must not be resolved
86
+ if (m.result && m.result !== '') return false;
87
+ // Must have recent trading activity (volume_24h > 0)
88
+ // Markets with zero 24h volume have stale last_price from old trades
89
+ const vol24h = typeof m.volume_24h === 'string'
90
+ ? parseFloat(m.volume_24h)
91
+ : (m.volume_24h ?? 0);
92
+ if (m.volume_24h != null && vol24h <= 0) return false;
93
+ // Must have at least one actual trade (last_price > 0)
94
+ // If last_price is absent (old index row), fall through and allow it
95
+ const lastPrice = m.last_price ?? 0;
96
+ const dollarStr = m.last_price_dollars ?? m.dollar_last_price;
97
+ const parsedDollar = dollarStr != null ? parseFloat(String(dollarStr)) : NaN;
98
+ const lastPriceDollar = Number.isFinite(parsedDollar) ? parsedDollar : 0;
99
+ if (lastPrice === 0 && lastPriceDollar === 0) {
100
+ // Transition fallback: if all last_price fields are missing entirely (not zero),
101
+ // allow the market through so old index rows still appear
102
+ if (m.last_price == null && dollarStr == null) return true;
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ export interface BrowseMarketRow {
109
+ ticker: string;
110
+ title: string;
111
+ marketProb: number | null;
112
+ modelProb: number | null;
113
+ edge: number | null;
114
+ confidence: string | null;
115
+ }
116
+
117
+ export interface BrowseEventRow {
118
+ eventTicker: string;
119
+ title: string;
120
+ category: string;
121
+ markets: BrowseMarketRow[];
122
+ pending?: boolean;
123
+ }
124
+
125
+ export type BrowseAppState = 'idle' | 'loading' | 'event_list' | 'action_menu' | 'view_report';
126
+
127
+ export interface BrowseState {
128
+ appState: BrowseAppState;
129
+ theme: string;
130
+ events: BrowseEventRow[];
131
+ selectedMarket: BrowseMarketRow | null;
132
+ selectedEventTicker: string | null;
133
+ pendingRecommendTicker: string | null;
134
+ pendingTradeTicker: string | null;
135
+ lastError: string | null;
136
+ progressMessage: string | null;
137
+ reportText: string | null;
138
+ }
139
+
140
+ type ChangeListener = () => void;
141
+
142
+ /**
143
+ * Format a raw Octagon report string for display.
144
+ * Exported for testability — used internally by BrowseController.formatRawReport.
145
+ */
146
+ export function formatRawReport(raw: string, ticker: string): string {
147
+ const header = `── Octagon Report: ${ticker} ──`;
148
+ const cleanMarkdown = (md: string) =>
149
+ md
150
+ .replace(/(?<=[\s:(])\/markets\//g, 'https://octagonai.co/markets/')
151
+ .replace(/###?\s*Why This Matters\s*\(GEO\)\s*\n(?:[\s\S]*?)(?=\n##(?!#)|\n$|$)/g, '');
152
+
153
+ try {
154
+ const parsed = JSON.parse(raw);
155
+
156
+ // New cache format: full markdown report in latest_report
157
+ if (parsed.latest_report?.markdown_report) {
158
+ return `${header}\n\n${cleanMarkdown(parsed.latest_report.markdown_report)}`;
159
+ }
160
+
161
+ // Legacy structured JSON fallback (versions[0])
162
+ const source = parsed.versions?.[0] ?? parsed;
163
+ const lines: string[] = [header, ''];
164
+ if (source.model_probability != null) lines.push(`Model Probability: ${source.model_probability}`);
165
+ if (source.market_probability != null) lines.push(`Market Probability: ${source.market_probability}`);
166
+ if (source.mispricing_signal) lines.push(`Signal: ${source.mispricing_signal}`);
167
+ if (source.key_takeaway) {
168
+ lines.push('');
169
+ lines.push(`Key Takeaway: ${source.key_takeaway}`);
170
+ }
171
+ if (source.resolution_history) {
172
+ lines.push('');
173
+ lines.push('Resolution History:');
174
+ lines.push(String(source.resolution_history));
175
+ }
176
+ if (source.drivers && Array.isArray(source.drivers)) {
177
+ lines.push('');
178
+ lines.push('Drivers:');
179
+ for (const d of source.drivers) {
180
+ lines.push(` • [${d.impact ?? '?'}] ${d.claim ?? d.description ?? JSON.stringify(d)}`);
181
+ }
182
+ }
183
+ if (source.catalysts && Array.isArray(source.catalysts)) {
184
+ lines.push('');
185
+ lines.push('Catalysts:');
186
+ for (const c of source.catalysts) {
187
+ lines.push(` • ${c.date ?? '?'} — ${c.event ?? c.description ?? JSON.stringify(c)}`);
188
+ }
189
+ }
190
+ if (source.sources && Array.isArray(source.sources)) {
191
+ lines.push('');
192
+ lines.push('Sources:');
193
+ for (const s of source.sources) {
194
+ const title = s.title ? `${s.title}: ` : '';
195
+ lines.push(` • ${title}${s.url ?? JSON.stringify(s)}`);
196
+ }
197
+ }
198
+ if (source.outcome_probabilities_json) {
199
+ lines.push('');
200
+ lines.push('Outcome Probabilities:');
201
+ const outcomes = typeof source.outcome_probabilities_json === 'string'
202
+ ? JSON.parse(source.outcome_probabilities_json)
203
+ : source.outcome_probabilities_json;
204
+ if (Array.isArray(outcomes)) {
205
+ for (const o of outcomes) {
206
+ lines.push(` • ${o.market_ticker}: ${o.model_probability}`);
207
+ }
208
+ }
209
+ }
210
+ if (lines.length <= 3) {
211
+ return `${header}\n\n${JSON.stringify(source, null, 2)}`;
212
+ }
213
+ return lines.join('\n');
214
+ } catch {
215
+ // Not JSON — raw markdown from refresh endpoint
216
+ return `${header}\n\n${cleanMarkdown(raw)}`;
217
+ }
218
+ }
219
+
220
+ export class BrowseController {
221
+ private appStateValue: BrowseAppState = 'idle';
222
+ private themeValue = '';
223
+ private eventsValue: BrowseEventRow[] = [];
224
+ private selectedMarketValue: BrowseMarketRow | null = null;
225
+ private selectedEventTickerValue: string | null = null;
226
+ private pendingRecommendTickerValue: string | null = null;
227
+ private pendingTradeTickerValue: string | null = null;
228
+ private lastErrorValue: string | null = null;
229
+ private progressMessageValue: string | null = null;
230
+ private reportTextValue: string | null = null;
231
+ private readonly pendingReports = new Set<string>(); // event tickers with in-flight reports
232
+ private refreshAllInFlight = false;
233
+ private directReportMode = false; // true when entered via /report <ticker> (not browse)
234
+ private loadToken = 0; // monotonic counter to invalidate stale async responses
235
+ private readonly onError: (message: string) => void;
236
+ private readonly onChange: ChangeListener;
237
+
238
+ constructor(onError: (message: string) => void, onChange: ChangeListener) {
239
+ this.onError = onError;
240
+ this.onChange = onChange;
241
+ }
242
+
243
+ get state(): BrowseState {
244
+ return {
245
+ appState: this.appStateValue,
246
+ theme: this.themeValue,
247
+ events: this.eventsValue,
248
+ selectedMarket: this.selectedMarketValue,
249
+ selectedEventTicker: this.selectedEventTickerValue,
250
+ pendingRecommendTicker: this.pendingRecommendTickerValue,
251
+ pendingTradeTicker: this.pendingTradeTickerValue,
252
+ lastError: this.lastErrorValue,
253
+ progressMessage: this.progressMessageValue,
254
+ reportText: this.reportTextValue,
255
+ };
256
+ }
257
+
258
+ isInBrowseFlow(): boolean {
259
+ return this.appStateValue !== 'idle';
260
+ }
261
+
262
+ consumePendingRecommendTicker(): string | null {
263
+ const ticker = this.pendingRecommendTickerValue;
264
+ this.pendingRecommendTickerValue = null;
265
+ return ticker;
266
+ }
267
+
268
+ consumePendingTradeTicker(): string | null {
269
+ const ticker = this.pendingTradeTickerValue;
270
+ this.pendingTradeTickerValue = null;
271
+ return ticker;
272
+ }
273
+
274
+ /** Whether the current session was started via /report (direct) vs /browse */
275
+ get isDirectReport(): boolean {
276
+ return this.directReportMode;
277
+ }
278
+
279
+ startBrowse(theme: string): void {
280
+ trackEvent('browse_action', { action: 'start', theme });
281
+ this.loadToken++;
282
+ this.directReportMode = false;
283
+ this.themeValue = theme;
284
+ this.eventsValue = [];
285
+ this.selectedMarketValue = null;
286
+ this.selectedEventTickerValue = null;
287
+ this.pendingRecommendTickerValue = null;
288
+ this.pendingTradeTickerValue = null;
289
+ this.lastErrorValue = null;
290
+ this.progressMessageValue = null;
291
+ this.refreshAllInFlight = false;
292
+ this.pendingReports.clear();
293
+ this.appStateValue = 'loading';
294
+ this.emitChange();
295
+ void this.loadEvents(theme, this.loadToken);
296
+ }
297
+
298
+ /**
299
+ * Enter the report action menu directly for a given ticker.
300
+ * Resolves market/event/series tickers and jumps to the action menu.
301
+ */
302
+ startReport(ticker: string): void {
303
+ this.loadToken++;
304
+ this.directReportMode = true;
305
+ this.eventsValue = [];
306
+ this.selectedMarketValue = null;
307
+ this.selectedEventTickerValue = null;
308
+ this.pendingRecommendTickerValue = null;
309
+ this.pendingTradeTickerValue = null;
310
+ this.lastErrorValue = null;
311
+ this.progressMessageValue = null;
312
+ this.reportTextValue = null;
313
+ this.refreshAllInFlight = false;
314
+ this.pendingReports.clear();
315
+ this.themeValue = ticker;
316
+ this.appStateValue = 'loading';
317
+ this.emitChange();
318
+ void this.resolveAndShowReport(ticker, this.loadToken);
319
+ }
320
+
321
+ private async resolveAndShowReport(ticker: string, token: number): Promise<void> {
322
+ try {
323
+ const market = await resolveMarket(ticker.toUpperCase());
324
+ if (token !== this.loadToken) return;
325
+
326
+ const db = getDb();
327
+ const marketRow = this.toMarketRow(market, db);
328
+ const eventTicker = market.event_ticker;
329
+
330
+ // Store as a single-event list so runReport/handleAction work
331
+ this.eventsValue = [{
332
+ eventTicker,
333
+ title: market.title ?? market.subtitle ?? eventTicker,
334
+ category: market.category ?? '',
335
+ markets: [marketRow],
336
+ }];
337
+ this.selectedMarketValue = marketRow;
338
+ this.selectedEventTickerValue = eventTicker;
339
+ this.appStateValue = 'action_menu';
340
+ this.emitChange();
341
+ } catch (err) {
342
+ if (token !== this.loadToken) return;
343
+ this.onError(`Report failed: ${err instanceof Error ? err.message : String(err)}`);
344
+ this.resetToIdle();
345
+ }
346
+ }
347
+
348
+ selectMarket(eventTicker: string, marketTicker: string): void {
349
+ trackEvent('browse_action', { action: 'select_market' });
350
+ for (const ev of this.eventsValue) {
351
+ if (ev.eventTicker === eventTicker) {
352
+ const market = ev.markets.find((m) => m.ticker === marketTicker);
353
+ if (market) {
354
+ this.selectedMarketValue = market;
355
+ this.selectedEventTickerValue = eventTicker;
356
+ this.appStateValue = 'action_menu';
357
+ this.emitChange();
358
+ return;
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ handleAction(action: string): void {
365
+ trackEvent('browse_action', { action });
366
+ this.lastErrorValue = null;
367
+ if (action === 'report' || action === 'refresh') {
368
+ const forceRefresh = action === 'refresh';
369
+ if (this.selectedMarketValue && this.selectedEventTickerValue) {
370
+ const ticker = this.selectedMarketValue.ticker;
371
+ const evTicker = this.selectedEventTickerValue;
372
+ // Skip if already pending or bulk refresh is running
373
+ if (this.pendingReports.has(evTicker) || this.refreshAllInFlight) {
374
+ if (this.directReportMode) return; // stay on menu in direct mode
375
+ this.selectedMarketValue = null;
376
+ this.selectedEventTickerValue = null;
377
+ this.appStateValue = 'event_list';
378
+ this.emitChange();
379
+ return;
380
+ }
381
+ // Mark event as pending
382
+ this.pendingReports.add(evTicker);
383
+ for (const ev of this.eventsValue) {
384
+ if (ev.eventTicker === evTicker) ev.pending = true;
385
+ }
386
+
387
+ // Show loading message and fetch the report — display it when done
388
+ this.progressMessageValue = forceRefresh
389
+ ? `Generating full research report for ${ticker}... this may take several minutes.`
390
+ : `Fetching cached report for ${ticker}...`;
391
+ this.appStateValue = 'loading';
392
+ this.emitChange();
393
+ void this.runDirectReport(ticker, evTicker, forceRefresh, this.loadToken);
394
+ }
395
+ } else if (action === 'refresh_all') {
396
+ if (this.refreshAllInFlight) return;
397
+ this.selectedMarketValue = null;
398
+ this.selectedEventTickerValue = null;
399
+ this.appStateValue = 'event_list';
400
+ this.emitChange();
401
+ void this.refreshAllReports(this.loadToken);
402
+ } else if (action === 'view_report') {
403
+ if (this.selectedMarketValue) {
404
+ const db = getDb();
405
+ const report = getLatestReport(db, this.selectedMarketValue.ticker);
406
+ if (report?.raw_response) {
407
+ this.reportTextValue = this.formatRawReport(report.raw_response, this.selectedMarketValue.ticker);
408
+ this.appStateValue = 'view_report';
409
+ this.emitChange();
410
+ return;
411
+ }
412
+ // No local report — fetch from Octagon cache instead
413
+ this.handleAction('report');
414
+ return;
415
+ }
416
+ this.selectedMarketValue = null;
417
+ this.selectedEventTickerValue = null;
418
+ this.appStateValue = 'event_list';
419
+ this.emitChange();
420
+ } else if (action === 'trade') {
421
+ if (this.selectedMarketValue) {
422
+ this.pendingTradeTickerValue = this.selectedMarketValue.ticker;
423
+ }
424
+ this.resetToIdle();
425
+ } else if (action === 'no_report') {
426
+ // No-op: no cached report available, stay on action menu
427
+ return;
428
+ } else if (action === 'back') {
429
+ if (this.appStateValue === 'view_report') {
430
+ this.reportTextValue = null;
431
+ this.appStateValue = 'action_menu';
432
+ this.emitChange();
433
+ return;
434
+ }
435
+ if (this.directReportMode) {
436
+ this.resetToIdle();
437
+ return;
438
+ }
439
+ this.selectedMarketValue = null;
440
+ this.selectedEventTickerValue = null;
441
+ this.appStateValue = 'event_list';
442
+ this.emitChange();
443
+ }
444
+ }
445
+
446
+ cancelBrowse(): void {
447
+ // Step back from view_report to action_menu instead of full exit
448
+ if (this.appStateValue === 'view_report') {
449
+ this.reportTextValue = null;
450
+ this.appStateValue = 'action_menu';
451
+ this.emitChange();
452
+ return;
453
+ }
454
+ this.loadToken++; // invalidate in-flight loads and reports
455
+ this.refreshAllInFlight = false;
456
+ this.pendingReports.clear();
457
+ this.resetToIdle();
458
+ }
459
+
460
+ private async loadEvents(theme: string, token?: number): Promise<void> {
461
+ try {
462
+ const db = getDb();
463
+ let kalshiEvents: KalshiEvent[];
464
+
465
+ const indexAge = getIndexAge(db);
466
+ const indexEmpty = indexAge === Infinity;
467
+
468
+ // Kick off ensureIndex (always non-blocking now)
469
+ void ensureIndex().catch((err) => {
470
+ console.warn(`[browse] Background index refresh failed: ${err instanceof Error ? err.message : String(err)}`);
471
+ });
472
+
473
+ if (theme === 'top50') {
474
+ // Try local index first (instant if populated)
475
+ kalshiEvents = indexEmpty ? [] : getTopEventsByVolume(db, 30);
476
+
477
+ // Fallback to API if index is empty (first run)
478
+ if (kalshiEvents.length === 0) {
479
+ this.progressMessageValue = 'Fetching top markets...';
480
+ this.emitChange();
481
+ const data = await callKalshiApi('GET', '/events', {
482
+ params: { status: 'open', with_nested_markets: true, limit: 100 },
483
+ });
484
+ kalshiEvents = (data.events ?? []) as KalshiEvent[];
485
+ kalshiEvents.sort((a, b) => {
486
+ const volA = (a.markets ?? []).reduce((sum: number, m: any) => sum + (parseFloat(m.volume_fp) || 0), 0);
487
+ const volB = (b.markets ?? []).reduce((sum: number, m: any) => sum + (parseFloat(m.volume_fp) || 0), 0);
488
+ return volB - volA;
489
+ });
490
+ kalshiEvents = kalshiEvents.slice(0, 30);
491
+ }
492
+ } else if (indexEmpty) {
493
+ // Non-top50 theme but index is empty — must wait for index
494
+ this.progressMessageValue = 'Building event index for the first time...';
495
+ this.emitChange();
496
+
497
+ // Subscribe to progress updates while waiting
498
+ const unsub = onIndexProgress((info) => {
499
+ if (token !== undefined && token !== this.loadToken) { unsub(); return; }
500
+ if (info.phase === 'fetching_events') {
501
+ this.progressMessageValue = `Indexing markets... ${info.fetchedItems} fetched (page ${info.page}/${info.maxPages})`;
502
+ } else if (info.detail) {
503
+ this.progressMessageValue = info.detail;
504
+ }
505
+ this.emitChange();
506
+ });
507
+
508
+ try {
509
+ const refreshPromise = getRefreshPromise();
510
+ if (refreshPromise) await refreshPromise;
511
+ } finally {
512
+ unsub();
513
+ }
514
+
515
+ if (token !== undefined && token !== this.loadToken) return;
516
+
517
+ // Now query the index
518
+ if (CATEGORY_MAP[theme]) {
519
+ const categoryLabel = CATEGORY_MAP[theme];
520
+ kalshiEvents = await this.searchIndex(db, '', categoryLabel);
521
+ } else {
522
+ const searchTerm = theme.includes(':') ? theme.split(':').slice(1).join(':') : theme;
523
+ const categoryLabel = theme.includes(':') ? CATEGORY_MAP[theme.split(':')[0]] : null;
524
+ kalshiEvents = await this.searchIndex(db, searchTerm, categoryLabel);
525
+ }
526
+ } else if (CATEGORY_MAP[theme]) {
527
+ // Pure category (e.g. "elections") — read from local index
528
+ const categoryLabel = CATEGORY_MAP[theme];
529
+ kalshiEvents = await this.searchIndex(db, '', categoryLabel);
530
+ } else {
531
+ // Subcategory (e.g. "politics:iran") or free-text search (e.g. "iran")
532
+ const searchTerm = theme.includes(':') ? theme.split(':').slice(1).join(':') : theme;
533
+ const categoryLabel = theme.includes(':') ? CATEGORY_MAP[theme.split(':')[0]] : null;
534
+ kalshiEvents = await this.searchIndex(db, searchTerm, categoryLabel);
535
+ }
536
+
537
+ // Sort all events by total market volume (most active first)
538
+ kalshiEvents.sort((a, b) => {
539
+ const volA = (a.markets ?? []).reduce((sum: number, m: any) => sum + (parseFloat(m.volume) || parseFloat(m.volume_fp) || 0), 0);
540
+ const volB = (b.markets ?? []).reduce((sum: number, m: any) => sum + (parseFloat(m.volume) || parseFloat(m.volume_fp) || 0), 0);
541
+ return volB - volA;
542
+ });
543
+
544
+ // Discard stale response if a newer browse was started
545
+ if (token !== undefined && token !== this.loadToken) return;
546
+
547
+ this.progressMessageValue = null;
548
+ this.eventsValue = this.kalshiEventsToRows(kalshiEvents, db);
549
+ this.appStateValue = 'event_list';
550
+ this.emitChange();
551
+
552
+ // Background: hydrate model probabilities from Octagon cache for each event
553
+ void this.hydrateOutcomeProbs(this.loadToken);
554
+ } catch (err) {
555
+ if (token !== undefined && token !== this.loadToken) return;
556
+ this.progressMessageValue = null;
557
+ this.onError(`Browse failed: ${err instanceof Error ? err.message : String(err)}`);
558
+ this.resetToIdle();
559
+ }
560
+ }
561
+
562
+ /** Convert Kalshi events (with nested markets) to BrowseEventRows */
563
+ private kalshiEventsToRows(events: KalshiEvent[], db: ReturnType<typeof getDb>): BrowseEventRow[] {
564
+ const rows: BrowseEventRow[] = [];
565
+ for (const ev of events) {
566
+ const markets = (ev.markets ?? []).filter((m) => isMarketActive(m));
567
+ if (markets.length === 0) continue;
568
+ rows.push({
569
+ eventTicker: ev.event_ticker,
570
+ title: ev.title ?? ev.event_ticker,
571
+ category: ev.category ?? '',
572
+ markets: markets.map((m) => this.toMarketRow(m, db)),
573
+ });
574
+ }
575
+ return rows;
576
+ }
577
+
578
+ private toMarketRow(m: KalshiMarket, db: ReturnType<typeof getDb>): BrowseMarketRow {
579
+ const marketProb = parseMarketProb(m);
580
+ let modelProb: number | null = null;
581
+ let edge: number | null = null;
582
+ let confidence: string | null = null;
583
+ try {
584
+ const latestEdge = getLatestEdge(db, m.ticker);
585
+ // Skip edges from cache misses — valid 0.5 probabilities are shown
586
+ if (latestEdge && !latestEdge.cache_miss) {
587
+ modelProb = latestEdge.model_prob;
588
+ edge = latestEdge.edge;
589
+ confidence = latestEdge.confidence ?? null;
590
+ }
591
+ } catch {
592
+ // Edge lookup failed — show without model data
593
+ }
594
+ return {
595
+ ticker: m.ticker,
596
+ title: m.title ?? m.subtitle ?? m.ticker,
597
+ marketProb,
598
+ modelProb,
599
+ edge,
600
+ confidence,
601
+ };
602
+ }
603
+
604
+ private async runReport(ticker: string, eventTicker: string, forceRefresh = false, sessionToken?: number): Promise<void> {
605
+ try {
606
+ const db = getDb();
607
+ const octagonClient = new OctagonClient(createOctagonInvoker(), db, auditTrail);
608
+ const edgeComputer = new EdgeComputer(db, auditTrail);
609
+
610
+ // Fetch current market data
611
+ const marketRes = await callKalshiApi('GET', `/markets/${ticker}`);
612
+ // Bail if session changed
613
+ if (sessionToken !== undefined && sessionToken !== this.loadToken) return;
614
+
615
+ const market = (marketRes.market ?? marketRes) as KalshiMarket;
616
+ const marketProb = parseMarketProb(market);
617
+ if (marketProb === null) {
618
+ this.lastErrorValue = `No last traded price for ${ticker} — market may be untradeable.`;
619
+ this.pendingReports.delete(eventTicker);
620
+ for (const ev of this.eventsValue) {
621
+ if (ev.eventTicker === eventTicker) ev.pending = false;
622
+ }
623
+ this.emitChange();
624
+ return;
625
+ }
626
+
627
+ // Fetch octagon report: cache only unless explicitly refreshing
628
+ const variant = forceRefresh ? 'refresh' : 'cache';
629
+ const report = await octagonClient.fetchReport(ticker, eventTicker, variant);
630
+ if (sessionToken !== undefined && sessionToken !== this.loadToken) return;
631
+
632
+ // If cache miss and not a forced refresh, bail — no data to show, no credits spent
633
+ if (!forceRefresh && report.cacheMiss) {
634
+ this.lastErrorValue = `No cached report for ${ticker}. Use "Refresh" to generate one (costs credits).`;
635
+ this.pendingReports.delete(eventTicker);
636
+ for (const ev of this.eventsValue) {
637
+ if (ev.eventTicker === eventTicker) ev.pending = false;
638
+ }
639
+ this.emitChange();
640
+ return;
641
+ }
642
+
643
+ // Octagon analyzes the entire event — extract all outcome probabilities
644
+ const allOutcomeProbs = await this.extractAllOutcomeProbs(ticker);
645
+ if (sessionToken !== undefined && sessionToken !== this.loadToken) return;
646
+
647
+ // Fix the selected market's report and persist the corrected model_prob
648
+ const selectedProb = allOutcomeProbs.get(ticker.toUpperCase());
649
+ if (selectedProb !== null && selectedProb !== undefined) {
650
+ report.modelProb = selectedProb;
651
+ // UX override: once we have a valid model probability extracted from
652
+ // the Octagon response, treat this record as "not a cache miss" for
653
+ // browse display purposes — even if the underlying API call was a
654
+ // cache miss. This differs from how /analyze uses cacheMiss (to decide
655
+ // whether to auto-refresh); here we're masking the API/database cache
656
+ // state so the browse list shows edge data without a stale indicator.
657
+ report.cacheMiss = false;
658
+ if (report.reportId) {
659
+ updateReportModelProb(db, report.reportId, selectedProb);
660
+ }
661
+ }
662
+
663
+ const snapshot = edgeComputer.computeEdge(ticker, report, marketProb);
664
+
665
+ // Always update the selected market's in-memory row directly
666
+ for (const ev of this.eventsValue) {
667
+ if (ev.eventTicker !== eventTicker) continue;
668
+ const mkt = ev.markets.find(m => m.ticker === ticker);
669
+ if (mkt) {
670
+ mkt.modelProb = snapshot.modelProb;
671
+ mkt.edge = snapshot.edge;
672
+ mkt.confidence = snapshot.confidence;
673
+ }
674
+ }
675
+
676
+ // Persist the selected market's edge
677
+ insertEdge(db, {
678
+ ticker: snapshot.ticker,
679
+ event_ticker: snapshot.eventTicker,
680
+ timestamp: snapshot.timestamp,
681
+ model_prob: snapshot.modelProb,
682
+ market_prob: snapshot.marketProb,
683
+ edge: snapshot.edge,
684
+ octagon_report_id: snapshot.octagonReportId,
685
+ drivers_json: JSON.stringify(snapshot.drivers),
686
+ sources_json: JSON.stringify(snapshot.sources),
687
+ catalysts_json: JSON.stringify(snapshot.catalysts),
688
+ cache_hit: snapshot.cacheHit ? 1 : 0,
689
+ cache_miss: report.cacheMiss ? 1 : 0,
690
+ confidence: snapshot.confidence,
691
+ });
692
+
693
+ // Update ALL sibling markets in the event with their outcome probabilities
694
+ for (const ev of this.eventsValue) {
695
+ if (ev.eventTicker !== eventTicker) continue;
696
+ for (const mkt of ev.markets) {
697
+ const outcomeProb = allOutcomeProbs.get(mkt.ticker.toUpperCase());
698
+ if (outcomeProb !== null && outcomeProb !== undefined) {
699
+ mkt.modelProb = outcomeProb;
700
+ if (mkt.marketProb !== null) {
701
+ mkt.edge = outcomeProb - mkt.marketProb;
702
+ mkt.confidence = edgeComputer.classifyConfidence(Math.abs(mkt.edge));
703
+ }
704
+
705
+ // Persist sibling edges (skip the selected one — already persisted above)
706
+ if (mkt.ticker !== ticker && mkt.marketProb !== null) {
707
+ try {
708
+ insertEdge(db, {
709
+ ticker: mkt.ticker,
710
+ event_ticker: eventTicker,
711
+ timestamp: snapshot.timestamp,
712
+ model_prob: outcomeProb,
713
+ market_prob: mkt.marketProb,
714
+ edge: mkt.edge ?? 0,
715
+ octagon_report_id: snapshot.octagonReportId,
716
+ drivers_json: null,
717
+ sources_json: null,
718
+ catalysts_json: null,
719
+ cache_hit: 0,
720
+ cache_miss: report.cacheMiss ? 1 : 0,
721
+ confidence: mkt.confidence,
722
+ });
723
+ } catch {
724
+ // DB insert failed for sibling — update in-memory only
725
+ }
726
+ }
727
+ }
728
+ }
729
+ }
730
+
731
+ // Clear pending flag — skip if session changed
732
+ if (sessionToken !== undefined && sessionToken !== this.loadToken) return;
733
+ this.pendingReports.delete(eventTicker);
734
+ for (const ev of this.eventsValue) {
735
+ if (ev.eventTicker === eventTicker) ev.pending = false;
736
+ }
737
+ this.emitChange();
738
+ } catch (err) {
739
+ if (sessionToken !== undefined && sessionToken !== this.loadToken) return;
740
+ this.lastErrorValue = `Report failed (${ticker}): ${err instanceof Error ? err.message : String(err)}`;
741
+ this.pendingReports.delete(eventTicker);
742
+ for (const ev of this.eventsValue) {
743
+ if (ev.eventTicker === eventTicker) ev.pending = false;
744
+ }
745
+ this.emitChange();
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Run a report in direct mode (/report <ticker>).
751
+ * After completion, show the full report view instead of returning to event list.
752
+ */
753
+ private async runDirectReport(ticker: string, eventTicker: string, forceRefresh: boolean, sessionToken: number): Promise<void> {
754
+ // Run the normal report flow first
755
+ await this.runReport(ticker, eventTicker, forceRefresh, sessionToken);
756
+ if (sessionToken !== this.loadToken) return;
757
+
758
+ // After report completes, show the report view if we have a raw_response
759
+ const db = getDb();
760
+ const report = getLatestReport(db, ticker);
761
+ if (report?.raw_response) {
762
+ this.reportTextValue = this.formatRawReport(report.raw_response, ticker);
763
+ this.progressMessageValue = null;
764
+ this.appStateValue = 'view_report';
765
+ this.emitChange();
766
+ } else {
767
+ // No raw report — go back to action menu
768
+ this.progressMessageValue = null;
769
+ // Restore selection for the action menu
770
+ for (const ev of this.eventsValue) {
771
+ const mkt = ev.markets.find(m => m.ticker === ticker);
772
+ if (mkt) {
773
+ this.selectedMarketValue = mkt;
774
+ this.selectedEventTickerValue = ev.eventTicker;
775
+ break;
776
+ }
777
+ }
778
+ this.appStateValue = 'action_menu';
779
+ this.emitChange();
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Search the local event index for matching events.
785
+ * Reads markets_json directly from the index — no API calls needed.
786
+ */
787
+ private async searchIndex(
788
+ db: ReturnType<typeof getDb>,
789
+ searchTerm: string,
790
+ categoryLabel: string | null,
791
+ ): Promise<KalshiEvent[]> {
792
+ try {
793
+ await ensureIndex();
794
+ let rows: any[] = [];
795
+ if (categoryLabel && !searchTerm) {
796
+ rows = db.query(
797
+ `SELECT event_ticker FROM event_index WHERE category = ? LIMIT 30`,
798
+ ).all(categoryLabel);
799
+ } else if (categoryLabel) {
800
+ const term = `%${searchTerm.toLowerCase()}%`;
801
+ rows = db.query(
802
+ `SELECT event_ticker FROM event_index
803
+ WHERE category = ? AND (LOWER(title) LIKE ? OR LOWER(event_ticker) LIKE ? OR LOWER(COALESCE(sub_title,'')) LIKE ? OR LOWER(COALESCE(series_ticker,'')) LIKE ? OR LOWER(COALESCE(tags,'')) LIKE ?)
804
+ LIMIT 30`,
805
+ ).all(categoryLabel, term, term, term, term, term);
806
+ } else {
807
+ const normalizedTerm = searchTerm.trim().toUpperCase();
808
+ const isTicker = /^[A-Z0-9]+$/.test(normalizedTerm);
809
+ if (isTicker) {
810
+ rows = db.query(
811
+ `SELECT event_ticker FROM event_index WHERE series_ticker = ? LIMIT 30`,
812
+ ).all(normalizedTerm);
813
+ }
814
+ if (!rows || rows.length === 0) {
815
+ const term = `%${searchTerm.toLowerCase()}%`;
816
+ rows = db.query(
817
+ `SELECT event_ticker FROM event_index
818
+ WHERE LOWER(title) LIKE ? OR LOWER(event_ticker) LIKE ? OR LOWER(COALESCE(sub_title,'')) LIKE ? OR LOWER(COALESCE(series_ticker,'')) LIKE ? OR LOWER(COALESCE(tags,'')) LIKE ?
819
+ LIMIT 30`,
820
+ ).all(term, term, term, term, term);
821
+ }
822
+ }
823
+ if (rows.length === 0) return [];
824
+
825
+ // Read events with nested markets directly from the index — no API calls
826
+ const tickers = rows.map((r: any) => r.event_ticker as string);
827
+ return getEventsFromIndex(db, tickers);
828
+ } catch {
829
+ return [];
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Background hydration: fetch cached Octagon outcome probabilities for each event
835
+ * and populate Model%/Edge/Conf columns without costing credits.
836
+ */
837
+ private async hydrateOutcomeProbs(token: number): Promise<void> {
838
+ // Deduplicate: pick one market ticker per event to query Octagon
839
+ const seen = new Set<string>();
840
+ const queries: Array<{ eventTicker: string; sampleTicker: string }> = [];
841
+ for (const ev of this.eventsValue) {
842
+ if (seen.has(ev.eventTicker)) continue;
843
+ seen.add(ev.eventTicker);
844
+ if (ev.markets.length > 0) {
845
+ queries.push({ eventTicker: ev.eventTicker, sampleTicker: ev.markets[0].ticker });
846
+ }
847
+ }
848
+
849
+ const edgeComputer = new EdgeComputer(getDb(), auditTrail);
850
+ for (const { eventTicker, sampleTicker } of queries) {
851
+ if (token !== this.loadToken) return; // session changed
852
+ try {
853
+ const probs = await this.extractAllOutcomeProbs(sampleTicker);
854
+ if (token !== this.loadToken) return;
855
+ if (probs.size === 0) continue;
856
+
857
+ // Update in-memory rows for this event
858
+ for (const ev of this.eventsValue) {
859
+ if (ev.eventTicker !== eventTicker) continue;
860
+ let updated = false;
861
+ for (const mkt of ev.markets) {
862
+ const prob = probs.get(mkt.ticker.toUpperCase());
863
+ if (prob !== undefined && mkt.modelProb === null) {
864
+ mkt.modelProb = prob;
865
+ if (mkt.marketProb !== null) {
866
+ mkt.edge = prob - mkt.marketProb;
867
+ mkt.confidence = edgeComputer.classifyConfidence(Math.abs(mkt.edge));
868
+ }
869
+ updated = true;
870
+ }
871
+ }
872
+ if (updated) this.emitChange();
873
+ }
874
+ } catch {
875
+ // Skip this event on error
876
+ }
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Extract all outcome probabilities from the Octagon cache response.
882
+ * Returns a map of MARKET_TICKER (uppercase) → model probability (0-1).
883
+ */
884
+ private async extractAllOutcomeProbs(ticker: string): Promise<Map<string, number>> {
885
+ const probs = new Map<string, number>();
886
+
887
+ // Try prefetch DB first to avoid an Octagon API call
888
+ try {
889
+ const db = getDb();
890
+ // Look up by event_ticker prefix (ticker may be a market ticker like KXBTC-26-B95000)
891
+ // Try exact match first, then find by event prefix
892
+ const row = db.query(
893
+ `SELECT outcome_probabilities_json FROM octagon_reports
894
+ WHERE variant_used = 'events-api' AND outcome_probabilities_json IS NOT NULL
895
+ AND (close_time IS NULL OR close_time > $now)
896
+ AND (event_ticker = $t OR event_ticker IN (
897
+ SELECT event_ticker FROM octagon_reports WHERE ticker = $t AND variant_used != 'events-api' LIMIT 1
898
+ ))
899
+ ORDER BY fetched_at DESC LIMIT 1`,
900
+ ).get({ $t: ticker, $now: new Date().toISOString() }) as { outcome_probabilities_json: string } | null;
901
+
902
+ if (row?.outcome_probabilities_json) {
903
+ const outcomes = JSON.parse(row.outcome_probabilities_json) as Array<{
904
+ market_ticker: string; model_probability: number;
905
+ }>;
906
+ for (const o of outcomes) {
907
+ if (typeof o.model_probability === 'number' && o.market_ticker) {
908
+ const prob = o.model_probability / 100;
909
+ if (prob >= 0 && prob <= 1) {
910
+ probs.set(o.market_ticker.toUpperCase(), prob);
911
+ }
912
+ }
913
+ }
914
+ if (probs.size > 0) return probs;
915
+ }
916
+ } catch { /* prefetch lookup failed — fall back to API */ }
917
+
918
+ // Fall back to individual Octagon cache API call
919
+ try {
920
+ const rawCache = await callOctagon(ticker, 'cache');
921
+ const parsed = JSON.parse(rawCache);
922
+ const version = parsed.versions?.[0];
923
+ if (!version?.outcome_probabilities_json) return probs;
924
+
925
+ const outcomes: Array<{ market_ticker: string; model_probability: number }> =
926
+ typeof version.outcome_probabilities_json === 'string'
927
+ ? JSON.parse(version.outcome_probabilities_json)
928
+ : version.outcome_probabilities_json;
929
+
930
+ for (const o of outcomes) {
931
+ if (typeof o.model_probability === 'number' && o.market_ticker) {
932
+ const prob = o.model_probability / 100;
933
+ if (prob >= 0 && prob <= 1) {
934
+ probs.set(o.market_ticker.toUpperCase(), prob);
935
+ }
936
+ }
937
+ }
938
+ } catch {
939
+ // Cache extraction failed
940
+ }
941
+ return probs;
942
+ }
943
+
944
+ private async refreshAllReports(sessionToken?: number): Promise<void> {
945
+ this.refreshAllInFlight = true;
946
+ let total = 0;
947
+ let succeeded = 0;
948
+ let failed = 0;
949
+ try {
950
+ // Mark ALL events as pending upfront so UI shows them all immediately
951
+ const eventsToRefresh: Array<{ ev: BrowseEventRow; ticker: string; evTicker: string }> = [];
952
+ for (const ev of this.eventsValue) {
953
+ if (ev.markets.length === 0) continue;
954
+ const evTicker = ev.eventTicker;
955
+ if (this.pendingReports.has(evTicker)) continue;
956
+ eventsToRefresh.push({ ev, ticker: ev.markets[0].ticker, evTicker });
957
+ this.pendingReports.add(evTicker);
958
+ ev.pending = true;
959
+ }
960
+ total = eventsToRefresh.length;
961
+ this.emitChange();
962
+
963
+ // Run octagon reports for all events sequentially
964
+ for (const { ev, ticker, evTicker } of eventsToRefresh) {
965
+ // Bail if session changed
966
+ if (sessionToken !== undefined && sessionToken !== this.loadToken) return;
967
+ this.progressMessageValue = `Refreshing reports: ${succeeded + failed}/${total} done...`;
968
+ this.emitChange();
969
+ const errorBefore = this.lastErrorValue;
970
+ await this.runReport(ticker, evTicker, true, sessionToken);
971
+ if (this.lastErrorValue && this.lastErrorValue !== errorBefore) {
972
+ failed++;
973
+ } else {
974
+ succeeded++;
975
+ }
976
+ }
977
+ } finally {
978
+ if (sessionToken === undefined || sessionToken === this.loadToken) {
979
+ this.refreshAllInFlight = false;
980
+ this.progressMessageValue = null;
981
+ if (total > 0) {
982
+ if (failed > 0) {
983
+ this.progressMessageValue = `Refreshed ${succeeded}/${total} reports (${failed} failed)`;
984
+ } else {
985
+ this.progressMessageValue = `Refreshed all ${total} reports successfully`;
986
+ }
987
+ this.emitChange();
988
+ }
989
+ }
990
+ }
991
+ }
992
+
993
+ private formatRawReport(raw: string, ticker: string): string {
994
+ return formatRawReport(raw, ticker);
995
+ }
996
+
997
+ private resetToIdle(): void {
998
+ this.appStateValue = 'idle';
999
+ this.themeValue = '';
1000
+ this.directReportMode = false;
1001
+ this.eventsValue = [];
1002
+ this.selectedMarketValue = null;
1003
+ this.selectedEventTickerValue = null;
1004
+ this.lastErrorValue = null;
1005
+ this.progressMessageValue = null;
1006
+ this.reportTextValue = null;
1007
+ this.emitChange();
1008
+ }
1009
+
1010
+ private emitChange(): void {
1011
+ this.onChange();
1012
+ }
1013
+ }