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,105 @@
1
+ /**
2
+ * A single event entry from the Octagon Prediction Markets Events API.
3
+ * Probabilities are percentages (0-100).
4
+ */
5
+ export interface OctagonEventEntry {
6
+ history_id: number;
7
+ run_id: string;
8
+ captured_at: string;
9
+ event_ticker: string;
10
+ name: string;
11
+ slug: string;
12
+ image_url?: string;
13
+ series_category: string;
14
+ available_on_brokers: boolean;
15
+ mutually_exclusive: boolean;
16
+ analysis_last_updated: string;
17
+ confidence_score: number;
18
+ model_probability: number;
19
+ market_probability: number;
20
+ edge_pp: number;
21
+ expected_return: number;
22
+ r_score: number;
23
+ total_volume: number;
24
+ total_open_interest: number;
25
+ close_time: string;
26
+ key_takeaway: string;
27
+ has_history?: boolean;
28
+ outcome_probabilities?: Array<{
29
+ market_ticker: string;
30
+ outcome_name?: string;
31
+ model_probability: number;
32
+ market_probability: number;
33
+ volume?: number | null;
34
+ volume_24h?: number | null;
35
+ }> | null;
36
+ current_state_summary_richtext?: string;
37
+ short_answer_richtext?: string;
38
+ executive_summary_richtext?: string;
39
+ }
40
+
41
+ interface EventsPage {
42
+ data: OctagonEventEntry[];
43
+ next_cursor: string | null;
44
+ has_more: boolean;
45
+ }
46
+
47
+ const EVENTS_API_BASE = 'https://api.octagonai.co/v1';
48
+ const PAGE_LIMIT = 200;
49
+ const TIMEOUT_MS = 60_000;
50
+
51
+ /**
52
+ * Fetch all events from the Octagon Prediction Markets Events API,
53
+ * paginating through all pages.
54
+ * @param opts.hasHistory - When true, only return events with multiple historical snapshots.
55
+ * Note: The events list endpoint now returns `has_history` per event, so this filter
56
+ * is only needed if you want to reduce response size.
57
+ */
58
+ export async function fetchAllOctagonEvents(opts?: { hasHistory?: boolean }): Promise<OctagonEventEntry[]> {
59
+ const apiKey = process.env.OCTAGON_API_KEY;
60
+ if (!apiKey) throw new Error('OCTAGON_API_KEY not set');
61
+
62
+ const all: OctagonEventEntry[] = [];
63
+ let cursor: string | null = null;
64
+
65
+ do {
66
+ const params = new URLSearchParams({ limit: String(PAGE_LIMIT) });
67
+ if (opts?.hasHistory) params.set('has_history', 'true');
68
+ if (cursor) params.set('cursor', cursor);
69
+
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
72
+
73
+ let resp: Response;
74
+ try {
75
+ resp = await fetch(`${EVENTS_API_BASE}/prediction-markets/events?${params}`, {
76
+ headers: { Authorization: `Bearer ${apiKey}` },
77
+ signal: controller.signal,
78
+ });
79
+ } finally {
80
+ clearTimeout(timer);
81
+ }
82
+
83
+ if (!resp.ok) {
84
+ const body = await resp.text().catch(() => '');
85
+ throw new Error(`Octagon events API ${resp.status}: ${body.slice(0, 200)}`);
86
+ }
87
+
88
+ const page = (await resp.json()) as unknown;
89
+ if (!page || typeof page !== 'object') {
90
+ throw new Error('Octagon events API returned invalid response shape');
91
+ }
92
+ const p = page as Record<string, unknown>;
93
+ if (!Array.isArray(p.data)) {
94
+ throw new Error('Octagon events API response missing data array');
95
+ }
96
+ const hasMore = typeof p.has_more === 'boolean' ? p.has_more : false;
97
+ if (hasMore && !p.next_cursor) {
98
+ throw new Error('Octagon events API has_more=true but next_cursor is missing');
99
+ }
100
+ all.push(...(p.data as OctagonEventEntry[]));
101
+ cursor = hasMore ? (p.next_cursor as string) : null;
102
+ } while (cursor);
103
+
104
+ return all;
105
+ }
@@ -0,0 +1,172 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import { fetchAllOctagonEvents, type OctagonEventEntry } from './octagon-events-api.js';
3
+ import { insertReport, getLatestReport, getTtlForCloseTime } from '../db/octagon-cache.js';
4
+ import { insertEdge } from '../db/edge.js';
5
+
6
+ const PREFETCH_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
7
+ const META_KEY = 'octagon_prefetch_at';
8
+
9
+ /**
10
+ * Check if a prefetch is needed (more than 1h since last one).
11
+ */
12
+ function shouldPrefetch(db: Database): boolean {
13
+ const row = db.query("SELECT value FROM event_index_meta WHERE key = $key").get({
14
+ $key: META_KEY,
15
+ }) as { value: string } | null;
16
+ if (!row) return true;
17
+ const lastPrefetch = parseInt(row.value, 10);
18
+ if (!Number.isFinite(lastPrefetch)) return true;
19
+ return Date.now() - lastPrefetch > PREFETCH_COOLDOWN_MS;
20
+ }
21
+
22
+ function markPrefetchDone(db: Database): void {
23
+ db.query("INSERT OR REPLACE INTO event_index_meta (key, value) VALUES ($key, $value)").run({
24
+ $key: META_KEY,
25
+ $value: String(Date.now()),
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Infer a mispricing signal from the edge.
31
+ */
32
+ function inferSignal(modelProb: number, marketProb: number): string {
33
+ const edge = modelProb - marketProb;
34
+ if (Math.abs(edge) < 0.03) return 'fair_value';
35
+ return edge > 0 ? 'underpriced' : 'overpriced';
36
+ }
37
+
38
+ /**
39
+ * Classify confidence from absolute edge.
40
+ */
41
+ function classifyConfidence(absEdge: number): string {
42
+ if (absEdge >= 0.10) return 'very_high';
43
+ if (absEdge >= 0.05) return 'high';
44
+ if (absEdge >= 0.02) return 'moderate';
45
+ return 'low';
46
+ }
47
+
48
+ /**
49
+ * Convert an Octagon event entry to local DB records and persist.
50
+ * Returns true if a new record was inserted, false if skipped.
51
+ */
52
+ function persistEvent(db: Database, event: OctagonEventEntry): boolean {
53
+ const capturedDate = new Date(event.captured_at);
54
+ const closeDate = new Date(event.close_time);
55
+ if (isNaN(capturedDate.getTime()) || isNaN(closeDate.getTime())) return false;
56
+ const capturedAt = Math.floor(capturedDate.getTime() / 1000);
57
+ const closeTime = Math.floor(closeDate.getTime() / 1000);
58
+
59
+ // Probabilities from the events API are percentages (0-100)
60
+ const modelProb = event.model_probability / 100;
61
+ const marketProb = event.market_probability / 100;
62
+
63
+ // Skip events with no model analysis — unless they have per-market outcome data
64
+ const hasOutcomes = Array.isArray(event.outcome_probabilities) && event.outcome_probabilities.length > 0;
65
+ if ((event.model_probability === 0 || event.model_probability == null) && !hasOutcomes) return false;
66
+
67
+ // Always update close_time on existing events-api reports (backfill)
68
+ db.prepare(
69
+ "UPDATE octagon_reports SET close_time = $ct WHERE event_ticker = $et AND variant_used = 'events-api' AND close_time IS NULL",
70
+ ).run({ $et: event.event_ticker, $ct: event.close_time ?? null });
71
+
72
+ // Skip if we already have a fresher report for this event
73
+ const existing = getLatestReport(db, event.event_ticker);
74
+ if (existing && existing.fetched_at >= capturedAt) return false;
75
+
76
+ const ttl = getTtlForCloseTime(Math.max(0, closeTime - capturedAt));
77
+ const reportId = `events-api-${event.event_ticker}-${capturedAt}`;
78
+
79
+ // Insert report and set metadata in a single transaction
80
+ db.transaction(() => {
81
+ insertReport(db, {
82
+ report_id: reportId,
83
+ ticker: event.event_ticker,
84
+ event_ticker: event.event_ticker,
85
+ model_prob: modelProb,
86
+ market_prob: marketProb,
87
+ mispricing_signal: inferSignal(modelProb, marketProb),
88
+ drivers_json: JSON.stringify([{
89
+ claim: event.key_takeaway || event.name,
90
+ category: (event.series_category || 'other').toLowerCase(),
91
+ impact: 'medium',
92
+ }]),
93
+ catalysts_json: null,
94
+ sources_json: null,
95
+ resolution_history_json: null,
96
+ contract_snapshot_json: null,
97
+ raw_response: null,
98
+ variant_used: 'events-api',
99
+ fetched_at: capturedAt,
100
+ expires_at: capturedAt + ttl,
101
+ });
102
+
103
+ db.prepare(
104
+ `UPDATE octagon_reports SET has_history = $hh, mutually_exclusive = $me, series_category = $sc,
105
+ confidence_score = $cs, outcome_probabilities_json = $opj, close_time = $ct
106
+ WHERE report_id = $rid`,
107
+ ).run({
108
+ $rid: reportId,
109
+ $hh: event.has_history ? 1 : 0,
110
+ $me: event.mutually_exclusive ? 1 : 0,
111
+ $sc: event.series_category ?? null,
112
+ $cs: event.confidence_score ?? null,
113
+ $opj: event.outcome_probabilities ? JSON.stringify(event.outcome_probabilities) : null,
114
+ $ct: event.close_time ?? null,
115
+ });
116
+ })();
117
+
118
+ // Also persist to edge_history
119
+ const edge = modelProb - marketProb;
120
+ try {
121
+ insertEdge(db, {
122
+ ticker: event.event_ticker,
123
+ event_ticker: event.event_ticker,
124
+ timestamp: capturedAt,
125
+ model_prob: modelProb,
126
+ market_prob: marketProb,
127
+ edge,
128
+ octagon_report_id: reportId,
129
+ drivers_json: null,
130
+ sources_json: null,
131
+ catalysts_json: null,
132
+ cache_hit: 1,
133
+ cache_miss: 0,
134
+ confidence: classifyConfidence(Math.abs(edge)),
135
+ });
136
+ } catch (err) {
137
+ // Only swallow UNIQUE constraint violations (duplicate ticker+timestamp)
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ if (/UNIQUE constraint failed/i.test(msg)) {
140
+ // Expected — edge already exists for this ticker+timestamp
141
+ } else {
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Fetch all Octagon events via the REST API and persist them locally.
151
+ * Runs only if the last prefetch was more than 1h ago.
152
+ */
153
+ export async function prefetchOctagonEvents(db: Database): Promise<{ inserted: number; skipped: number }> {
154
+ if (!shouldPrefetch(db)) {
155
+ return { inserted: 0, skipped: 0 };
156
+ }
157
+
158
+ const events = await fetchAllOctagonEvents();
159
+ let inserted = 0;
160
+ let skipped = 0;
161
+
162
+ for (const event of events) {
163
+ if (persistEvent(db, event)) {
164
+ inserted++;
165
+ } else {
166
+ skipped++;
167
+ }
168
+ }
169
+
170
+ markPrefetchDone(db);
171
+ return { inserted, skipped };
172
+ }
@@ -0,0 +1,179 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { AuditTrail } from '../audit/trail.js';
3
+ import { callKalshiApi, fetchAllPages } from '../tools/kalshi/api.js';
4
+ import type { KalshiEvent, KalshiMarket, KalshiSeries } from '../tools/kalshi/types.js';
5
+ import { ensureIndex, getRefreshPromise } from '../tools/kalshi/search-index.js';
6
+ import { upsertEvent, deactivateExpired } from '../db/events.js';
7
+ import { getThemeTickers } from '../db/themes.js';
8
+
9
+ /** Maps lowercase theme IDs → exact Kalshi category labels */
10
+ export const CATEGORY_MAP: Record<string, string> = {
11
+ 'climate': 'Climate and Weather',
12
+ 'companies': 'Companies',
13
+ 'crypto': 'Crypto',
14
+ 'economics': 'Economics',
15
+ 'elections': 'Elections',
16
+ 'entertainment': 'Entertainment',
17
+ 'financials': 'Financials',
18
+ 'health': 'Health',
19
+ 'mentions': 'Mentions',
20
+ 'politics': 'Politics',
21
+ 'science': 'Science and Technology',
22
+ 'social': 'Social',
23
+ 'sports': 'Sports',
24
+ 'transportation': 'Transportation',
25
+ 'world': 'World',
26
+ };
27
+
28
+ /**
29
+ * Fetch all series from Kalshi and build a map of category → sorted subcategory tags.
30
+ * Each series has a `tags` field; we collect unique tags per category.
31
+ */
32
+ export async function fetchSubcategories(): Promise<Record<string, string[]>> {
33
+ const allSeries = await fetchAllPages<KalshiSeries>('/series', {}, 'series', 50);
34
+ const catTags: Record<string, Set<string>> = {};
35
+
36
+ for (const s of allSeries) {
37
+ const cat = s.category;
38
+ if (!cat) continue;
39
+ if (!catTags[cat]) catTags[cat] = new Set();
40
+ for (const tag of s.tags ?? []) {
41
+ catTags[cat].add(tag);
42
+ }
43
+ }
44
+
45
+ const result: Record<string, string[]> = {};
46
+ for (const [cat, tags] of Object.entries(catTags)) {
47
+ result[cat] = [...tags].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
48
+ }
49
+ return result;
50
+ }
51
+
52
+ export class ThemeResolver {
53
+ private db: Database;
54
+ private audit: AuditTrail;
55
+
56
+ constructor(db: Database, audit: AuditTrail) {
57
+ this.db = db;
58
+ this.audit = audit;
59
+ }
60
+
61
+ async resolve(themeName: string): Promise<string[]> {
62
+ const now = Math.floor(Date.now() / 1000);
63
+ let eventTickers: string[];
64
+
65
+ if (themeName === 'top50') {
66
+ eventTickers = await this.resolveTop50();
67
+ } else if (themeName.includes(':')) {
68
+ // Subcategory filter: "crypto:btc", "sports:football"
69
+ eventTickers = await this.resolveSubcategory(themeName);
70
+ } else if (CATEGORY_MAP[themeName]) {
71
+ eventTickers = await this.resolveCategory(themeName);
72
+ } else {
73
+ eventTickers = getThemeTickers(this.db, themeName);
74
+ }
75
+
76
+ // Upsert resolved events
77
+ for (const ticker of eventTickers) {
78
+ upsertEvent(this.db, { ticker, active: 1, updated_at: now });
79
+ }
80
+
81
+ // Deactivate expired events
82
+ deactivateExpired(this.db, now);
83
+
84
+ // Audit log
85
+ this.audit.log({
86
+ type: 'SCAN_START',
87
+ theme: themeName,
88
+ events_count: eventTickers.length,
89
+ });
90
+
91
+ return eventTickers;
92
+ }
93
+
94
+ private async resolveTop50(): Promise<string[]> {
95
+ const markets = await fetchAllPages<KalshiMarket>(
96
+ '/markets',
97
+ { status: 'open', limit: 200 },
98
+ 'markets',
99
+ 3
100
+ );
101
+
102
+ // Sort by volume_24h descending
103
+ markets.sort((a, b) => (b.volume_24h ?? 0) - (a.volume_24h ?? 0));
104
+
105
+ // Take top 50 unique event tickers
106
+ const seen = new Set<string>();
107
+ const result: string[] = [];
108
+ for (const m of markets) {
109
+ if (!seen.has(m.event_ticker)) {
110
+ seen.add(m.event_ticker);
111
+ result.push(m.event_ticker);
112
+ if (result.length >= 50) break;
113
+ }
114
+ }
115
+ return result;
116
+ }
117
+
118
+ private async resolveCategory(themeName: string): Promise<string[]> {
119
+ const categoryLabel = CATEGORY_MAP[themeName];
120
+ // Kalshi /events API does not support server-side category filtering,
121
+ // so query the local SQLite index instead of fetching all open events
122
+ await ensureIndex();
123
+ // If ensureIndex kicked off a background refresh (first run / empty index),
124
+ // await it so we don't query an unpopulated event_index table
125
+ const pending = getRefreshPromise();
126
+ if (pending) await pending;
127
+ const rows = this.db.query(
128
+ `SELECT event_ticker FROM event_index WHERE category = ?`,
129
+ ).all(categoryLabel) as { event_ticker: string }[];
130
+ return rows.map((r) => r.event_ticker);
131
+ }
132
+
133
+ private async resolveSubcategory(themeName: string): Promise<string[]> {
134
+ const [catKey, ...subParts] = themeName.split(':');
135
+ const subTag = subParts.join(':').toLowerCase();
136
+ const categoryLabel = CATEGORY_MAP[catKey];
137
+ if (!categoryLabel) return [];
138
+
139
+ // Find series in this category with matching tag
140
+ const allSeries = await fetchAllPages<KalshiSeries>('/series', { category: categoryLabel }, 'series', 50);
141
+ const matchingSeries = new Set<string>();
142
+ for (const s of allSeries) {
143
+ if (s.category !== categoryLabel) continue;
144
+ const hasTag = (s.tags ?? []).some((t) => {
145
+ const tagLower = t.toLowerCase();
146
+ const tagKebab = tagLower.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
147
+ return tagLower === subTag || tagKebab === subTag;
148
+ });
149
+ if (hasTag) matchingSeries.add(s.ticker);
150
+ }
151
+
152
+ if (matchingSeries.size === 0) return [];
153
+
154
+ // Fetch open events for matching series in parallel (server-side filtered)
155
+ const results = await Promise.all(
156
+ [...matchingSeries].map((seriesTicker) =>
157
+ fetchAllPages<KalshiEvent>(
158
+ '/events',
159
+ { status: 'open', series_ticker: seriesTicker },
160
+ 'events',
161
+ 50
162
+ )
163
+ )
164
+ );
165
+
166
+ const seen = new Set<string>();
167
+ const eventTickers: string[] = [];
168
+ for (const events of results) {
169
+ for (const e of events) {
170
+ if (!seen.has(e.event_ticker)) {
171
+ seen.add(e.event_ticker);
172
+ eventTickers.push(e.event_ticker);
173
+ }
174
+ }
175
+ }
176
+
177
+ return eventTickers;
178
+ }
179
+ }
@@ -0,0 +1,62 @@
1
+ export type OctagonVariant = 'default' | 'cache' | 'refresh';
2
+
3
+ export type MispricingSignal = 'overpriced' | 'underpriced' | 'fair_value';
4
+
5
+ export type OctagonInvoker = (ticker: string, variant: OctagonVariant) => Promise<string>;
6
+
7
+ export type DriverCategory = 'political' | 'economic' | 'sentiment' | 'technical';
8
+ export type DriverImpact = 'high' | 'medium' | 'low';
9
+
10
+ export interface PriceDriver {
11
+ claim: string;
12
+ category: DriverCategory;
13
+ impact: DriverImpact;
14
+ sourceUrl?: string;
15
+ }
16
+
17
+ export interface Catalyst {
18
+ date: string; // ISO date
19
+ event: string;
20
+ impact: DriverImpact;
21
+ potentialMove: string;
22
+ }
23
+
24
+ export interface Source {
25
+ url: string;
26
+ title?: string;
27
+ }
28
+
29
+ export interface OctagonReport {
30
+ ticker: string;
31
+ eventTicker: string;
32
+ modelProb: number;
33
+ marketProb: number;
34
+ mispricingSignal: MispricingSignal;
35
+ drivers: PriceDriver[];
36
+ catalysts: Catalyst[];
37
+ sources: Source[];
38
+ resolutionHistory: string;
39
+ contractSnapshot: string;
40
+ variantUsed: OctagonVariant;
41
+ fetchedAt: number; // epoch seconds
42
+ rawResponse: string; // full raw Octagon API response
43
+ cacheMiss: boolean; // true when cache returned no meaningful data
44
+ reportId: string; // persisted DB report_id (UUID)
45
+ }
46
+
47
+ export type ConfidenceLevel = 'low' | 'moderate' | 'high' | 'very_high';
48
+
49
+ export interface EdgeSnapshot {
50
+ ticker: string; // market ticker e.g. 'KXBTC-26MAR-B80000'
51
+ eventTicker: string; // event ticker e.g. 'KXBTC-26MAR'
52
+ modelProb: number; // Octagon probability [0, 1]
53
+ marketProb: number; // Kalshi mid price as probability [0, 1]
54
+ edge: number; // modelProb - marketProb (signed)
55
+ confidence: ConfidenceLevel;
56
+ drivers: PriceDriver[];
57
+ catalysts: Catalyst[];
58
+ sources: Source[];
59
+ octagonReportId: string;
60
+ cacheHit: boolean;
61
+ timestamp: number; // epoch seconds
62
+ }
@@ -0,0 +1,126 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { AuditTrail } from '../audit/trail.js';
3
+ import type { Position } from '../db/positions.js';
4
+ import type { Catalyst } from './types.js';
5
+ import { getOpenPositions } from '../db/positions.js';
6
+ import { getLatestEdge } from '../db/edge.js';
7
+ import { getEvent } from '../db/events.js';
8
+
9
+ export type WatchdogAlertType =
10
+ | 'CONVERGENCE'
11
+ | 'ADVERSE_MOVE'
12
+ | 'EXPIRY_APPROACHING'
13
+ | 'CATALYST_APPROACHING';
14
+
15
+ export interface WatchdogAlert {
16
+ ticker: string;
17
+ alertType: WatchdogAlertType;
18
+ edge: number;
19
+ entryEdge: number;
20
+ message: string;
21
+ position: Position;
22
+ }
23
+
24
+ const CONVERGENCE_THRESHOLD = 0.02;
25
+ const EXPIRY_WINDOW = 24 * 3600;
26
+ const CATALYST_WINDOW = 24 * 3600;
27
+
28
+ export class PositionWatchdog {
29
+ private audit: AuditTrail;
30
+
31
+ constructor(audit: AuditTrail) {
32
+ this.audit = audit;
33
+ }
34
+
35
+ check(db: Database, now?: number): WatchdogAlert[] {
36
+ const currentTime = now ?? Math.floor(Date.now() / 1000);
37
+ const positions = getOpenPositions(db);
38
+ const alerts: WatchdogAlert[] = [];
39
+
40
+ for (const position of positions) {
41
+ const edgeRow = getLatestEdge(db, position.ticker);
42
+ if (!edgeRow) continue;
43
+
44
+ const currentEdge = edgeRow.edge;
45
+ const entryEdge = position.entry_edge ?? 0;
46
+ const statuses: string[] = [];
47
+
48
+ // CONVERGENCE: edge has shrunk to near zero
49
+ if (Math.abs(currentEdge) < CONVERGENCE_THRESHOLD) {
50
+ statuses.push('CONVERGENCE');
51
+ alerts.push({
52
+ ticker: position.ticker,
53
+ alertType: 'CONVERGENCE',
54
+ edge: currentEdge,
55
+ entryEdge,
56
+ message: `Edge converged to ${currentEdge.toFixed(4)} (threshold ${CONVERGENCE_THRESHOLD})`,
57
+ position,
58
+ });
59
+ }
60
+
61
+ // ADVERSE_MOVE: edge flipped sign from entry
62
+ if (entryEdge * currentEdge < 0) {
63
+ statuses.push('ADVERSE_MOVE');
64
+ alerts.push({
65
+ ticker: position.ticker,
66
+ alertType: 'ADVERSE_MOVE',
67
+ edge: currentEdge,
68
+ entryEdge,
69
+ message: `Edge flipped sign: entry=${entryEdge.toFixed(4)}, current=${currentEdge.toFixed(4)}`,
70
+ position,
71
+ });
72
+ }
73
+
74
+ // EXPIRY_APPROACHING: event expires within 24h
75
+ const event = getEvent(db, position.event_ticker);
76
+ if (event?.expiry && event.expiry - currentTime < EXPIRY_WINDOW) {
77
+ statuses.push('EXPIRY_APPROACHING');
78
+ const hoursLeft = Math.max(0, (event.expiry - currentTime) / 3600);
79
+ alerts.push({
80
+ ticker: position.ticker,
81
+ alertType: 'EXPIRY_APPROACHING',
82
+ edge: currentEdge,
83
+ entryEdge,
84
+ message: `Event ${position.event_ticker} expires in ${hoursLeft.toFixed(1)}h`,
85
+ position,
86
+ });
87
+ }
88
+
89
+ // CATALYST_APPROACHING: high-impact catalyst within 24h
90
+ if (edgeRow.catalysts_json) {
91
+ try {
92
+ const catalysts: Catalyst[] = JSON.parse(edgeRow.catalysts_json);
93
+ for (const catalyst of catalysts) {
94
+ if (catalyst.impact === 'high') {
95
+ const catalystTime = new Date(catalyst.date).getTime() / 1000;
96
+ if (catalystTime - currentTime > 0 && catalystTime - currentTime < CATALYST_WINDOW) {
97
+ statuses.push('CATALYST_APPROACHING');
98
+ alerts.push({
99
+ ticker: position.ticker,
100
+ alertType: 'CATALYST_APPROACHING',
101
+ edge: currentEdge,
102
+ entryEdge,
103
+ message: `High-impact catalyst "${catalyst.event}" approaching: ${catalyst.date}`,
104
+ position,
105
+ });
106
+ }
107
+ }
108
+ }
109
+ } catch {
110
+ // Ignore malformed catalysts JSON
111
+ }
112
+ }
113
+
114
+ const status = statuses.length > 0 ? statuses.join(',') : 'healthy';
115
+ this.audit.log({
116
+ type: 'WATCHDOG_CHECK',
117
+ ticker: position.ticker,
118
+ entry_edge: entryEdge,
119
+ current_edge: currentEdge,
120
+ status,
121
+ });
122
+ }
123
+
124
+ return alerts;
125
+ }
126
+ }