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,627 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { AuditTrail } from '../audit/trail.js';
3
+ import {
4
+ insertReport,
5
+ getLatestReport,
6
+ getTtlForCloseTime,
7
+ isStale,
8
+ type OctagonReport as DbOctagonReport,
9
+ } from '../db/octagon-cache.js';
10
+ import { getLatestEdge } from '../db/edge.js';
11
+ import type {
12
+ OctagonInvoker,
13
+ OctagonVariant,
14
+ OctagonReport,
15
+ MispricingSignal,
16
+ PriceDriver,
17
+ Catalyst,
18
+ Source,
19
+ DriverCategory,
20
+ DriverImpact,
21
+ } from './types.js';
22
+ import { getBotSetting } from '../utils/bot-config.js';
23
+
24
+ const CREDITS_PER_FRESH_CALL = 3;
25
+ const DEFAULT_DAILY_CREDIT_CEILING = 150;
26
+
27
+ export class OctagonClient {
28
+ private invoke: OctagonInvoker;
29
+ private db: Database;
30
+ private audit: AuditTrail;
31
+ private dailyCreditCeiling: number;
32
+ private creditsUsed = 0;
33
+
34
+ constructor(
35
+ invoke: OctagonInvoker,
36
+ db: Database,
37
+ audit: AuditTrail,
38
+ config?: { dailyCreditCeiling: number }
39
+ ) {
40
+ this.invoke = invoke;
41
+ this.db = db;
42
+ this.audit = audit;
43
+ this.dailyCreditCeiling = config?.dailyCreditCeiling
44
+ ?? (getBotSetting('octagon.daily_credit_ceiling') as number | undefined)
45
+ ?? DEFAULT_DAILY_CREDIT_CEILING;
46
+ }
47
+
48
+ /**
49
+ * Try to build an OctagonReport from the prefetched events API data in SQLite.
50
+ * Returns null if no fresh prefetch data is available for this event.
51
+ * This avoids an individual Octagon cache API call when the prefetch is fresh.
52
+ */
53
+ tryFromPrefetch(ticker: string, eventTicker: string, closeTimeIso?: string): OctagonReport | null {
54
+ const row = this.db.query(
55
+ `SELECT model_prob, market_prob, mispricing_signal, drivers_json, fetched_at, expires_at,
56
+ outcome_probabilities_json, report_id, confidence_score
57
+ FROM octagon_reports WHERE event_ticker = $et AND variant_used = 'events-api'
58
+ AND (close_time IS NULL OR close_time > $now)
59
+ ORDER BY fetched_at DESC LIMIT 1`,
60
+ ).get({ $et: eventTicker, $now: new Date().toISOString() }) as {
61
+ model_prob: number; market_prob: number | null; mispricing_signal: string | null;
62
+ drivers_json: string | null; fetched_at: number; expires_at: number;
63
+ outcome_probabilities_json: string | null; report_id: string;
64
+ confidence_score: number | null;
65
+ } | null;
66
+
67
+ if (!row) return null;
68
+
69
+ // Check if the prefetch is still fresh
70
+ const now = Math.floor(Date.now() / 1000);
71
+ if (row.expires_at < now) return null;
72
+
73
+ // Extract per-market probability if available
74
+ let modelProb = row.model_prob;
75
+ let marketProb = row.market_prob ?? 0.5;
76
+ if (row.outcome_probabilities_json) {
77
+ try {
78
+ const outcomes = JSON.parse(row.outcome_probabilities_json) as Array<{
79
+ market_ticker: string; model_probability: number; market_probability: number;
80
+ }>;
81
+ const match = outcomes.find(
82
+ o => o.market_ticker.toUpperCase() === ticker.toUpperCase(),
83
+ );
84
+ if (match) {
85
+ modelProb = match.model_probability / 100;
86
+ marketProb = match.market_probability / 100;
87
+ }
88
+ } catch { /* malformed JSON — use event-level */ }
89
+ }
90
+
91
+ // Parse drivers from prefetched data
92
+ let drivers: PriceDriver[] = [];
93
+ if (row.drivers_json) {
94
+ try { drivers = JSON.parse(row.drivers_json); } catch { /* skip */ }
95
+ }
96
+
97
+ const edge = modelProb - marketProb;
98
+ let signal: MispricingSignal = 'fair_value';
99
+ if (Math.abs(edge) >= 0.03) signal = edge > 0 ? 'underpriced' : 'overpriced';
100
+
101
+ return {
102
+ ticker,
103
+ eventTicker,
104
+ modelProb,
105
+ marketProb,
106
+ mispricingSignal: (row.mispricing_signal as MispricingSignal) ?? signal,
107
+ drivers,
108
+ catalysts: [],
109
+ sources: [],
110
+ resolutionHistory: '',
111
+ contractSnapshot: '',
112
+ variantUsed: 'cache',
113
+ fetchedAt: row.fetched_at,
114
+ rawResponse: '',
115
+ cacheMiss: false,
116
+ reportId: row.report_id,
117
+ };
118
+ }
119
+
120
+ async fetchReport(
121
+ ticker: string,
122
+ eventTicker: string,
123
+ variant: OctagonVariant,
124
+ options?: { creditsPreReserved?: boolean; closeTimeIso?: string }
125
+ ): Promise<OctagonReport> {
126
+ let effectiveVariant = variant;
127
+
128
+ // Default always uses cache — explicit 'refresh' required for fresh data
129
+ if (variant === 'default') {
130
+ effectiveVariant = 'cache';
131
+ }
132
+
133
+ // Auto-downgrade refresh to cache if budget exhausted
134
+ // Skip when credits were pre-reserved via reserveRefresh()
135
+ if (
136
+ variant === 'refresh' &&
137
+ !options?.creditsPreReserved &&
138
+ this.creditsUsed + CREDITS_PER_FRESH_CALL > this.dailyCreditCeiling
139
+ ) {
140
+ effectiveVariant = 'cache';
141
+ }
142
+
143
+ const raw = await this.invoke(ticker, effectiveVariant);
144
+ const report = this.parseReport(raw, ticker, eventTicker, effectiveVariant);
145
+
146
+ // Persist to DB and record the report_id on the report object
147
+ const closeEpoch = options?.closeTimeIso
148
+ ? Math.floor(new Date(options.closeTimeIso).getTime() / 1000)
149
+ : undefined;
150
+ const dbRow = this.toDbRow(report, closeEpoch);
151
+ insertReport(this.db, dbRow);
152
+ report.reportId = dbRow.report_id;
153
+
154
+ // Track credits — only 'refresh' costs credits ('default' is remapped to 'cache')
155
+ // Skip increment when credits were pre-reserved via reserveRefresh()
156
+ const isFresh = effectiveVariant === 'refresh';
157
+ const credits = isFresh ? CREDITS_PER_FRESH_CALL : 0;
158
+ if (isFresh && !options?.creditsPreReserved) {
159
+ this.creditsUsed += CREDITS_PER_FRESH_CALL;
160
+ }
161
+
162
+ // Audit
163
+ this.audit.log({
164
+ type: 'OCTAGON_CALL',
165
+ ticker,
166
+ variant: effectiveVariant,
167
+ cache_hit: effectiveVariant === 'cache',
168
+ credits_used: credits,
169
+ });
170
+
171
+ return report;
172
+ }
173
+
174
+ parseReport(
175
+ raw: string,
176
+ ticker: string,
177
+ eventTicker: string,
178
+ variant: OctagonVariant
179
+ ): OctagonReport {
180
+ const now = Math.floor(Date.now() / 1000);
181
+
182
+ const defaults: OctagonReport = {
183
+ ticker,
184
+ eventTicker,
185
+ modelProb: 0.5,
186
+ marketProb: 0.5,
187
+ mispricingSignal: 'fair_value',
188
+ drivers: [],
189
+ catalysts: [],
190
+ sources: [],
191
+ resolutionHistory: '',
192
+ contractSnapshot: '',
193
+ variantUsed: variant,
194
+ fetchedAt: now,
195
+ rawResponse: raw,
196
+ cacheMiss: false,
197
+ reportId: '', // set after DB persist in fetchReport
198
+ };
199
+
200
+ // Phase 1: Try JSON parse
201
+ let report: OctagonReport;
202
+ let hasExplicitModelProb = false;
203
+ try {
204
+ const parsed = JSON.parse(raw);
205
+ if (typeof parsed === 'object' && parsed !== null) {
206
+ // Check for explicit cache-miss indicators from the API
207
+ if (parsed.cache_miss === true || parsed.cacheMiss === true) {
208
+ report = { ...defaults, cacheMiss: true };
209
+ return report;
210
+ }
211
+ // Check if versions array is empty (cache variant returns { versions: [] } on miss)
212
+ const versions = parsed.versions as unknown[] | undefined;
213
+ if (Array.isArray(versions) && versions.length === 0) {
214
+ report = { ...defaults, cacheMiss: true };
215
+ return report;
216
+ }
217
+ // Check if model probability was actually provided (event-level)
218
+ const source = (versions?.[0] ?? parsed) as Record<string, unknown>;
219
+ hasExplicitModelProb = (source.modelProb ?? source.model_prob ?? source.model_probability) != null;
220
+ report = this.mapJsonToReport(parsed, defaults);
221
+ // Per-market probability from outcome_probabilities_json also counts as explicit
222
+ if (report.modelProb !== defaults.modelProb) {
223
+ hasExplicitModelProb = true;
224
+ }
225
+ } else {
226
+ report = this.extractFromMarkdown(raw, defaults);
227
+ }
228
+ } catch {
229
+ // Not JSON — fall through to regex extraction
230
+ report = this.extractFromMarkdown(raw, defaults);
231
+ }
232
+
233
+ // Detect cache miss: no explicit model probability was provided AND no meaningful content
234
+ if (!hasExplicitModelProb && report.modelProb === defaults.modelProb && report.drivers.length === 0 && report.catalysts.length === 0) {
235
+ report.cacheMiss = true;
236
+ }
237
+
238
+ return report;
239
+ }
240
+
241
+ shouldRefresh(
242
+ ticker: string,
243
+ currentMarketProb: number,
244
+ forceManual?: boolean,
245
+ closeTimeIso?: string
246
+ ): { refresh: boolean; reason: string } {
247
+ // (d) Manual request
248
+ if (forceManual) {
249
+ return { refresh: true, reason: 'manual refresh requested' };
250
+ }
251
+
252
+ // (e) Time-based staleness with tiered TTL
253
+ if (closeTimeIso) {
254
+ const closeEpoch = Math.floor(new Date(closeTimeIso).getTime() / 1000);
255
+ const now = Math.floor(Date.now() / 1000);
256
+ const secondsUntilClose = closeEpoch - now;
257
+ const ttl = getTtlForCloseTime(secondsUntilClose);
258
+ if (isStale(this.db, ticker, undefined, closeEpoch)) {
259
+ const tierLabel = ttl <= 3600 ? '1h' : ttl <= 21600 ? '6h' : ttl <= 86400 ? '24h' : '48h';
260
+ return {
261
+ refresh: true,
262
+ reason: `stale per ${tierLabel} TTL tier (close ${closeTimeIso})`,
263
+ };
264
+ }
265
+ }
266
+
267
+ const latestEdge = getLatestEdge(this.db, ticker);
268
+
269
+ if (latestEdge) {
270
+ // (a) Price moved beyond threshold
271
+ const priceMoveThreshold = getBotSetting('octagon.price_move_threshold') as number;
272
+ const priceDelta = Math.abs(currentMarketProb - latestEdge.market_prob);
273
+ if (priceDelta > priceMoveThreshold) {
274
+ return {
275
+ refresh: true,
276
+ reason: `price moved ${(priceDelta * 100).toFixed(1)}% (>${priceMoveThreshold * 100}% threshold)`,
277
+ };
278
+ }
279
+
280
+ // (b) Edge flipped sign
281
+ const oldEdge = latestEdge.edge;
282
+ const impliedEdge = latestEdge.model_prob - currentMarketProb;
283
+ if (oldEdge !== 0 && impliedEdge !== 0 && Math.sign(oldEdge) !== Math.sign(impliedEdge)) {
284
+ return { refresh: true, reason: 'edge flipped sign' };
285
+ }
286
+ }
287
+
288
+ // (c) High-impact catalyst occurred
289
+ const latestReport = getLatestReport(this.db, ticker);
290
+ if (latestReport?.catalysts_json) {
291
+ try {
292
+ const parsed = JSON.parse(latestReport.catalysts_json);
293
+ const catalysts: Catalyst[] = Array.isArray(parsed) ? parsed : [];
294
+ const today = new Date().toISOString().slice(0, 10);
295
+ const hasTriggered = catalysts.some(
296
+ (c) => c.impact === 'high' && c.date <= today
297
+ );
298
+ if (hasTriggered) {
299
+ return { refresh: true, reason: 'high-impact catalyst date reached' };
300
+ }
301
+ } catch {
302
+ // Malformed catalysts — ignore
303
+ }
304
+ }
305
+
306
+ return { refresh: false, reason: 'no refresh triggers met' };
307
+ }
308
+
309
+ toDbRow(report: OctagonReport, closeTimeEpoch?: number): DbOctagonReport {
310
+ const ttl = closeTimeEpoch != null
311
+ ? getTtlForCloseTime(Math.max(0, closeTimeEpoch - report.fetchedAt))
312
+ : 86400;
313
+ return {
314
+ report_id: crypto.randomUUID(),
315
+ ticker: report.ticker,
316
+ event_ticker: report.eventTicker,
317
+ model_prob: report.modelProb,
318
+ market_prob: report.marketProb,
319
+ mispricing_signal: report.mispricingSignal,
320
+ drivers_json: JSON.stringify(report.drivers),
321
+ catalysts_json: JSON.stringify(report.catalysts),
322
+ sources_json: JSON.stringify(report.sources),
323
+ resolution_history_json: report.resolutionHistory || null,
324
+ contract_snapshot_json: report.contractSnapshot || null,
325
+ raw_response: report.rawResponse || null,
326
+ variant_used: report.variantUsed,
327
+ fetched_at: report.fetchedAt,
328
+ expires_at: report.fetchedAt + ttl,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Synchronously reserve credits for a refresh call. Returns the effective
334
+ * variant: 'refresh' if budget allows, 'cache' if budget would be exceeded.
335
+ * Must be called before the async invoke to prevent concurrent calls from
336
+ * overshooting the daily credit ceiling.
337
+ */
338
+ reserveRefresh(requestedVariant: OctagonVariant): OctagonVariant {
339
+ if (requestedVariant !== 'refresh') return requestedVariant === 'default' ? 'cache' : requestedVariant;
340
+ if (this.creditsUsed + CREDITS_PER_FRESH_CALL > this.dailyCreditCeiling) {
341
+ return 'cache';
342
+ }
343
+ this.creditsUsed += CREDITS_PER_FRESH_CALL;
344
+ return 'refresh';
345
+ }
346
+
347
+ getCreditsUsed(): number {
348
+ return this.creditsUsed;
349
+ }
350
+
351
+ resetCredits(): void {
352
+ this.creditsUsed = 0;
353
+ }
354
+
355
+ // --- Private helpers ---
356
+
357
+ private mapJsonToReport(parsed: Record<string, unknown>, defaults: OctagonReport): OctagonReport {
358
+ // Handle nested cache response: { versions: [{ model_probability, market_probability, ... }] }
359
+ const versions = parsed.versions as Array<Record<string, unknown>> | undefined;
360
+ const source = versions?.[0] ?? parsed;
361
+
362
+ // For multi-outcome events, look up this specific market's probability
363
+ // from outcome_probabilities_json before falling back to event-level values.
364
+ // The event-level model_probability is typically the first outcome's value,
365
+ // not the one for the market we're analyzing.
366
+ let modelProb: number | null = null;
367
+ let marketProb: number | null = null;
368
+ const outcomeJson = (source as Record<string, unknown>).outcome_probabilities_json;
369
+ if (outcomeJson != null) {
370
+ try {
371
+ const outcomes = typeof outcomeJson === 'string'
372
+ ? JSON.parse(outcomeJson) : outcomeJson;
373
+ if (Array.isArray(outcomes)) {
374
+ const match = outcomes.find(
375
+ (o: { market_ticker?: string }) => String(o.market_ticker).toUpperCase() === defaults.ticker.toUpperCase()
376
+ );
377
+ if (match) {
378
+ modelProb = this.toProbFromJson(match.model_probability);
379
+ marketProb = this.toProbFromJson(match.market_probability);
380
+ }
381
+ }
382
+ } catch { /* malformed outcome JSON — fall through */ }
383
+ }
384
+
385
+ // Fall back to event-level values (correct for single-outcome markets).
386
+ // Uses toProbFromJson which always divides by 100, unlike toProb which uses a
387
+ // > 1 heuristic that fails for sub-1% values (e.g. 0.9% stays as 0.9 → 90%).
388
+ modelProb = modelProb ?? this.toProbFromJson(source.modelProb ?? source.model_prob ?? source.model_probability) ?? defaults.modelProb;
389
+ marketProb = marketProb ?? this.toProbFromJson(source.marketProb ?? source.market_prob ?? source.market_probability) ?? defaults.marketProb;
390
+
391
+ return {
392
+ ...defaults,
393
+ modelProb,
394
+ marketProb,
395
+ mispricingSignal: this.toSignal(source.mispricingSignal ?? source.mispricing_signal) ?? this.inferSignal(
396
+ modelProb,
397
+ marketProb
398
+ ) ?? defaults.mispricingSignal,
399
+ drivers: (() => {
400
+ const latestReport = parsed.latest_report as Record<string, unknown> | undefined;
401
+ const markdownReport = typeof latestReport?.markdown_report === 'string'
402
+ ? latestReport.markdown_report
403
+ : null;
404
+ const shortAnswer = markdownReport ? this.extractShortAnswer(markdownReport) : null;
405
+ return this.parseDrivers(source.drivers)
406
+ ?? (shortAnswer ? [{ claim: shortAnswer, category: 'economic' as const, impact: 'high' as const }] : null)
407
+ ?? this.driversFromTakeaway(source.key_takeaway)
408
+ ?? defaults.drivers;
409
+ })(),
410
+ catalysts: this.parseCatalysts(source.catalysts) ?? defaults.catalysts,
411
+ sources: this.parseSources(source.sources) ?? defaults.sources,
412
+ resolutionHistory: String(source.resolutionHistory ?? source.resolution_history ?? defaults.resolutionHistory),
413
+ contractSnapshot: String(source.contractSnapshot ?? source.contract_snapshot ?? source.outcome_probabilities_json ?? defaults.contractSnapshot),
414
+ };
415
+ }
416
+
417
+ private extractFromMarkdown(raw: string, defaults: OctagonReport): OctagonReport {
418
+ return {
419
+ ...defaults,
420
+ modelProb: this.extractProb(raw, /model\s*(?:prob(?:ability)?|estimate)\s*[:=]\s*([\d.]+%?)/i) ?? defaults.modelProb,
421
+ marketProb: this.extractProb(raw, /market\s*(?:prob(?:ability)?|price)\s*[:=]\s*([\d.]+%?)/i) ?? defaults.marketProb,
422
+ mispricingSignal: this.extractSignal(raw) ?? defaults.mispricingSignal,
423
+ drivers: this.extractDrivers(raw),
424
+ catalysts: this.extractCatalysts(raw),
425
+ sources: this.extractSources(raw),
426
+ resolutionHistory: this.extractSection(raw, /##?\s*resolution\s*history/i) ?? defaults.resolutionHistory,
427
+ contractSnapshot: this.extractSection(raw, /##?\s*contract\s*snapshot/i) ?? defaults.contractSnapshot,
428
+ };
429
+ }
430
+
431
+ private inferSignal(modelProb: number | null, marketProb: number | null): MispricingSignal | null {
432
+ if (modelProb === null || marketProb === null) return null;
433
+ const edge = modelProb - marketProb;
434
+ if (Math.abs(edge) < 0.03) return 'fair_value';
435
+ return edge > 0 ? 'underpriced' : 'overpriced';
436
+ }
437
+
438
+ private driversFromTakeaway(takeaway: unknown): PriceDriver[] | null {
439
+ if (typeof takeaway !== 'string' || !takeaway.trim()) return null;
440
+ return [{ claim: takeaway, category: 'economic', impact: 'medium' }];
441
+ }
442
+
443
+ private toProb(val: unknown): number | null {
444
+ if (val === undefined || val === null) return null;
445
+ if (typeof val === 'number') return val > 1 ? val / 100 : val;
446
+ if (typeof val === 'string') {
447
+ const cleaned = val.replace('%', '').trim();
448
+ const num = parseFloat(cleaned);
449
+ if (isNaN(num)) return null;
450
+ return num > 1 ? num / 100 : num;
451
+ }
452
+ return null;
453
+ }
454
+
455
+ /** Parse probability from Octagon JSON API responses where values are always percentages (0-100). */
456
+ private toProbFromJson(val: unknown): number | null {
457
+ if (val === undefined || val === null) return null;
458
+ if (typeof val === 'number') return val / 100;
459
+ if (typeof val === 'string') {
460
+ const num = parseFloat(val.replace('%', '').trim());
461
+ if (isNaN(num)) return null;
462
+ return num / 100;
463
+ }
464
+ return null;
465
+ }
466
+
467
+ private toSignal(val: unknown): MispricingSignal | null {
468
+ if (typeof val !== 'string') return null;
469
+ const normalized = val.toLowerCase().replace(/[\s-]/g, '_');
470
+ if (normalized === 'overpriced') return 'overpriced';
471
+ if (normalized === 'underpriced') return 'underpriced';
472
+ if (normalized === 'fair_value' || normalized === 'fair') return 'fair_value';
473
+ return null;
474
+ }
475
+
476
+ private parseDrivers(val: unknown): PriceDriver[] | null {
477
+ if (!Array.isArray(val)) return null;
478
+ return val
479
+ .filter((d): d is Record<string, unknown> => typeof d === 'object' && d !== null)
480
+ .map((d) => ({
481
+ claim: String(d.claim ?? ''),
482
+ category: this.toCategory(d.category) ?? 'economic',
483
+ impact: this.toImpact(d.impact) ?? 'medium',
484
+ sourceUrl: d.sourceUrl != null ? String(d.sourceUrl) : undefined,
485
+ }));
486
+ }
487
+
488
+ private parseCatalysts(val: unknown): Catalyst[] | null {
489
+ if (!Array.isArray(val)) return null;
490
+ return val
491
+ .filter((c): c is Record<string, unknown> => typeof c === 'object' && c !== null)
492
+ .map((c) => ({
493
+ date: String(c.date ?? ''),
494
+ event: String(c.event ?? ''),
495
+ impact: this.toImpact(c.impact) ?? 'medium',
496
+ potentialMove: String(c.potentialMove ?? c.potential_move ?? ''),
497
+ }));
498
+ }
499
+
500
+ private parseSources(val: unknown): Source[] | null {
501
+ if (!Array.isArray(val)) return null;
502
+ return val
503
+ .filter((s): s is Record<string, unknown> => typeof s === 'object' && s !== null)
504
+ .map((s) => ({
505
+ url: String(s.url ?? ''),
506
+ title: s.title != null ? String(s.title) : undefined,
507
+ }));
508
+ }
509
+
510
+ private toCategory(val: unknown): DriverCategory | null {
511
+ if (typeof val !== 'string') return null;
512
+ const v = val.toLowerCase();
513
+ if (v === 'political' || v === 'economic' || v === 'sentiment' || v === 'technical') return v;
514
+ return null;
515
+ }
516
+
517
+ private toImpact(val: unknown): DriverImpact | null {
518
+ if (typeof val !== 'string') return null;
519
+ const v = val.toLowerCase();
520
+ if (v === 'high' || v === 'medium' || v === 'low') return v;
521
+ return null;
522
+ }
523
+
524
+ private extractProb(raw: string, pattern: RegExp): number | null {
525
+ const match = raw.match(pattern);
526
+ if (!match) return null;
527
+ return this.toProb(match[1]);
528
+ }
529
+
530
+ private extractSignal(raw: string): MispricingSignal | null {
531
+ const match = raw.match(/(?:mispricing|signal|assessment)\s*[:=]\s*(\w[\w\s]*)/i);
532
+ if (!match) return null;
533
+ return this.toSignal(match[1].trim());
534
+ }
535
+
536
+ private extractDrivers(raw: string): PriceDriver[] {
537
+ const drivers: PriceDriver[] = [];
538
+ // Match bullet points under a "drivers" section
539
+ const section = this.extractSection(raw, /##?\s*(?:price\s*)?drivers/i);
540
+ if (!section) return drivers;
541
+
542
+ const bulletPattern = /[-*]\s*\*?\*?(.+?)(?:\n|$)/g;
543
+ let m: RegExpExecArray | null;
544
+ while ((m = bulletPattern.exec(section)) !== null) {
545
+ drivers.push({
546
+ claim: m[1].replace(/\*\*/g, '').trim(),
547
+ category: 'economic',
548
+ impact: 'medium',
549
+ });
550
+ }
551
+ return drivers;
552
+ }
553
+
554
+ private extractCatalysts(raw: string): Catalyst[] {
555
+ const catalysts: Catalyst[] = [];
556
+ const section = this.extractSection(raw, /##?\s*catalysts/i);
557
+ if (!section) return catalysts;
558
+
559
+ const bulletPattern = /[-*]\s*\*?\*?(.+?)(?:\n|$)/g;
560
+ let m: RegExpExecArray | null;
561
+ while ((m = bulletPattern.exec(section)) !== null) {
562
+ const text = m[1].replace(/\*\*/g, '').trim();
563
+ const dateMatch = text.match(/(\d{4}-\d{2}-\d{2})/);
564
+ catalysts.push({
565
+ date: dateMatch?.[1] ?? '',
566
+ event: text,
567
+ impact: 'medium',
568
+ potentialMove: '',
569
+ });
570
+ }
571
+ return catalysts;
572
+ }
573
+
574
+ private extractSources(raw: string): Source[] {
575
+ const sources: Source[] = [];
576
+ // Extract markdown links anywhere in the document
577
+ const linkPattern = /\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
578
+ let m: RegExpExecArray | null;
579
+ while ((m = linkPattern.exec(raw)) !== null) {
580
+ sources.push({ url: m[2], title: m[1] || undefined });
581
+ }
582
+ // Also extract bare URLs
583
+ const urlPattern = /(?<!\()(https?:\/\/[^\s)]+)/g;
584
+ const existingUrls = new Set(sources.map((s) => s.url));
585
+ while ((m = urlPattern.exec(raw)) !== null) {
586
+ if (!existingUrls.has(m[1])) {
587
+ sources.push({ url: m[1] });
588
+ existingUrls.add(m[1]);
589
+ }
590
+ }
591
+ return sources;
592
+ }
593
+
594
+ private extractShortAnswer(markdown: string): string | null {
595
+ const section = this.extractSection(markdown, /##?\s*Short\s+Answer/i);
596
+ if (!section) return null;
597
+ const firstPara = section.split(/\n{2,}|\n(?=##)/)[0]?.trim();
598
+ if (!firstPara) return null;
599
+ return firstPara
600
+ .replace(/\*\*(.+?)\*\*/g, '$1')
601
+ .replace(/\*(.+?)\*/g, '$1')
602
+ .replace(/\[\^[^\]]*\]/g, '')
603
+ .replace(/^Key\s+takeaway[.:]\s*/i, '')
604
+ .replace(/\s{2,}/g, ' ')
605
+ .trim();
606
+ }
607
+
608
+ private extractSection(raw: string, headerPattern: RegExp): string | null {
609
+ const lines = raw.split('\n');
610
+ let capturing = false;
611
+ const sectionLines: string[] = [];
612
+
613
+ for (const line of lines) {
614
+ if (headerPattern.test(line)) {
615
+ capturing = true;
616
+ continue;
617
+ }
618
+ if (capturing) {
619
+ // Stop at next header
620
+ if (/^##?\s/.test(line) && sectionLines.length > 0) break;
621
+ sectionLines.push(line);
622
+ }
623
+ }
624
+
625
+ return sectionLines.length > 0 ? sectionLines.join('\n').trim() : null;
626
+ }
627
+ }