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.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/assets/kalshi-flow-light.png +0 -0
- package/assets/screenshot.png +0 -0
- package/env.example +43 -0
- package/kalshi-flow-light.png +0 -0
- package/package.json +66 -0
- package/src/agent/agent.ts +249 -0
- package/src/agent/channels.ts +53 -0
- package/src/agent/index.ts +29 -0
- package/src/agent/prompts.ts +171 -0
- package/src/agent/run-context.ts +23 -0
- package/src/agent/scratchpad.ts +465 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/tool-executor.ts +166 -0
- package/src/agent/types.ts +221 -0
- package/src/audit/index.ts +25 -0
- package/src/audit/reader.ts +43 -0
- package/src/audit/trail.ts +29 -0
- package/src/audit/types.ts +133 -0
- package/src/backtest/discovery.ts +170 -0
- package/src/backtest/fetcher.ts +247 -0
- package/src/backtest/metrics.ts +165 -0
- package/src/backtest/renderer.ts +196 -0
- package/src/backtest/types.ts +45 -0
- package/src/cli.ts +943 -0
- package/src/commands/alerts.ts +48 -0
- package/src/commands/analyze.ts +662 -0
- package/src/commands/backtest.ts +276 -0
- package/src/commands/clear-cache.ts +24 -0
- package/src/commands/config.ts +107 -0
- package/src/commands/dispatch.ts +473 -0
- package/src/commands/edge.ts +62 -0
- package/src/commands/formatters.ts +339 -0
- package/src/commands/help.ts +263 -0
- package/src/commands/helpers.ts +48 -0
- package/src/commands/index.ts +287 -0
- package/src/commands/json.ts +43 -0
- package/src/commands/parse-args.ts +229 -0
- package/src/commands/portfolio.ts +236 -0
- package/src/commands/review.ts +176 -0
- package/src/commands/scan-formatters.ts +98 -0
- package/src/commands/scan.ts +38 -0
- package/src/commands/search-edge.ts +139 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/themes.ts +117 -0
- package/src/commands/watch.ts +295 -0
- package/src/components/answer-box.ts +57 -0
- package/src/components/approval-prompt.ts +34 -0
- package/src/components/browse-list.ts +134 -0
- package/src/components/chat-log.ts +291 -0
- package/src/components/custom-editor.ts +18 -0
- package/src/components/debug-panel.ts +52 -0
- package/src/components/index.ts +17 -0
- package/src/components/intro.ts +92 -0
- package/src/components/select-list.ts +155 -0
- package/src/components/tool-event.ts +127 -0
- package/src/components/user-query.ts +18 -0
- package/src/components/working-indicator.ts +87 -0
- package/src/controllers/agent-runner.ts +283 -0
- package/src/controllers/browse.ts +1013 -0
- package/src/controllers/index.ts +7 -0
- package/src/controllers/input-history.ts +76 -0
- package/src/controllers/model-selection.ts +244 -0
- package/src/db/alerts.ts +77 -0
- package/src/db/edge.ts +105 -0
- package/src/db/event-index.ts +323 -0
- package/src/db/events.ts +41 -0
- package/src/db/index.ts +60 -0
- package/src/db/octagon-cache.ts +118 -0
- package/src/db/positions.ts +71 -0
- package/src/db/risk.ts +51 -0
- package/src/db/schema.ts +227 -0
- package/src/db/themes.ts +34 -0
- package/src/db/trades.ts +50 -0
- package/src/eval/brier.ts +90 -0
- package/src/eval/index.ts +4 -0
- package/src/eval/performance.ts +87 -0
- package/src/gateway/access-control.ts +253 -0
- package/src/gateway/agent-runner.ts +75 -0
- package/src/gateway/alerts/formatter.ts +90 -0
- package/src/gateway/alerts/index.ts +4 -0
- package/src/gateway/alerts/router.ts +32 -0
- package/src/gateway/alerts/terminal.ts +16 -0
- package/src/gateway/alerts/types.ts +13 -0
- package/src/gateway/channels/index.ts +9 -0
- package/src/gateway/channels/manager.ts +153 -0
- package/src/gateway/channels/types.ts +48 -0
- package/src/gateway/channels/whatsapp/README.md +234 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
- package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
- package/src/gateway/channels/whatsapp/error.ts +122 -0
- package/src/gateway/channels/whatsapp/inbound.ts +326 -0
- package/src/gateway/channels/whatsapp/index.ts +5 -0
- package/src/gateway/channels/whatsapp/lid.ts +56 -0
- package/src/gateway/channels/whatsapp/logger.ts +25 -0
- package/src/gateway/channels/whatsapp/login.ts +94 -0
- package/src/gateway/channels/whatsapp/outbound.ts +119 -0
- package/src/gateway/channels/whatsapp/plugin.ts +54 -0
- package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
- package/src/gateway/channels/whatsapp/runtime.ts +122 -0
- package/src/gateway/channels/whatsapp/session.ts +89 -0
- package/src/gateway/channels/whatsapp/types.ts +32 -0
- package/src/gateway/commands/handler.ts +64 -0
- package/src/gateway/commands/index.ts +7 -0
- package/src/gateway/commands/parser.ts +29 -0
- package/src/gateway/commands/wa-formatters.ts +92 -0
- package/src/gateway/config.ts +244 -0
- package/src/gateway/extension-points.ts +17 -0
- package/src/gateway/gateway.ts +301 -0
- package/src/gateway/group/history-buffer.ts +75 -0
- package/src/gateway/group/index.ts +8 -0
- package/src/gateway/group/member-tracker.ts +60 -0
- package/src/gateway/group/mention-detection.ts +42 -0
- package/src/gateway/heartbeat/index.ts +8 -0
- package/src/gateway/heartbeat/prompt.ts +73 -0
- package/src/gateway/heartbeat/runner.ts +200 -0
- package/src/gateway/heartbeat/suppression.ts +74 -0
- package/src/gateway/index.ts +138 -0
- package/src/gateway/routing/resolve-route.ts +119 -0
- package/src/gateway/sessions/store.ts +65 -0
- package/src/gateway/types.ts +11 -0
- package/src/gateway/utils.ts +82 -0
- package/src/index.tsx +30 -0
- package/src/model/llm.ts +247 -0
- package/src/providers.ts +94 -0
- package/src/risk/circuit-breaker.ts +113 -0
- package/src/risk/correlation.ts +40 -0
- package/src/risk/gate.ts +125 -0
- package/src/risk/index.ts +10 -0
- package/src/risk/kelly.ts +230 -0
- package/src/scan/alerter.ts +64 -0
- package/src/scan/edge-computer.ts +164 -0
- package/src/scan/invoker.ts +199 -0
- package/src/scan/loop.ts +184 -0
- package/src/scan/octagon-client.ts +627 -0
- package/src/scan/octagon-events-api.ts +105 -0
- package/src/scan/octagon-prefetch.ts +172 -0
- package/src/scan/theme-resolver.ts +179 -0
- package/src/scan/types.ts +62 -0
- package/src/scan/watchdog.ts +126 -0
- package/src/setup/wizard.ts +659 -0
- package/src/theme.ts +67 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +419 -0
- package/src/tools/index.ts +10 -0
- package/src/tools/kalshi/api.ts +251 -0
- package/src/tools/kalshi/dlq.ts +35 -0
- package/src/tools/kalshi/events.ts +84 -0
- package/src/tools/kalshi/exchange.ts +24 -0
- package/src/tools/kalshi/historical.ts +89 -0
- package/src/tools/kalshi/index.ts +11 -0
- package/src/tools/kalshi/kalshi-search.ts +437 -0
- package/src/tools/kalshi/kalshi-trade.ts +102 -0
- package/src/tools/kalshi/markets.ts +76 -0
- package/src/tools/kalshi/portfolio.ts +100 -0
- package/src/tools/kalshi/search-index.ts +198 -0
- package/src/tools/kalshi/series.ts +16 -0
- package/src/tools/kalshi/trading.ts +115 -0
- package/src/tools/kalshi/types.ts +199 -0
- package/src/tools/registry.ts +160 -0
- package/src/tools/search/index.ts +25 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/types.ts +53 -0
- package/src/tools/v2/edge-query.ts +135 -0
- package/src/tools/v2/octagon-report.ts +112 -0
- package/src/tools/v2/portfolio-query.ts +79 -0
- package/src/tools/v2/portfolio-review.ts +59 -0
- package/src/tools/v2/risk-status.ts +94 -0
- package/src/tools/v2/scan.ts +78 -0
- package/src/types/qrcode-terminal.d.ts +7 -0
- package/src/types/whiskeysockets-baileys.d.ts +41 -0
- package/src/types.ts +22 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/bot-config.ts +219 -0
- package/src/utils/cache.ts +195 -0
- package/src/utils/config.ts +113 -0
- package/src/utils/env.ts +111 -0
- package/src/utils/errors.ts +313 -0
- package/src/utils/history-context.ts +32 -0
- package/src/utils/in-memory-chat-history.ts +268 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/model.ts +70 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/paths.ts +12 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/telemetry.ts +103 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +18 -0
- package/src/utils/tokens.ts +36 -0
- 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
|
+
}
|