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,247 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ /** Thrown when the history API requires a paid subscription. */
4
+ export class SubscriptionRequiredError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = 'SubscriptionRequiredError';
8
+ }
9
+ }
10
+
11
+ /** Narrow snapshot type representing what we actually store and use from history. */
12
+ export interface OutcomeProbability {
13
+ market_ticker: string;
14
+ outcome_name?: string;
15
+ model_probability: number; // percentage 0-100
16
+ market_probability: number; // percentage 0-100
17
+ /** Per-contract cumulative volume at snapshot time (nullable for older snapshots). */
18
+ volume?: number | null;
19
+ /** Per-contract trailing 24h volume at snapshot time (nullable for older snapshots). */
20
+ volume_24h?: number | null;
21
+ }
22
+
23
+ export interface HistorySnapshot {
24
+ history_id: number;
25
+ event_ticker: string;
26
+ captured_at: string;
27
+ name: string | null;
28
+ series_category: string | null;
29
+ confidence_score: number | null;
30
+ model_probability: number; // percentage 0-100
31
+ market_probability: number; // percentage 0-100
32
+ edge_pp: number | null;
33
+ close_time: string | null;
34
+ outcome_probabilities?: OutcomeProbability[] | null;
35
+ outcome_probabilities_json?: string | null; // raw JSON from DB cache
36
+ }
37
+
38
+ interface HistoryPage {
39
+ event_ticker: string;
40
+ data: HistorySnapshot[];
41
+ next_cursor: string | null;
42
+ has_more: boolean;
43
+ }
44
+
45
+ const EVENTS_API_BASE = 'https://api.octagonai.co/v1';
46
+ const PAGE_LIMIT = 200;
47
+ const TIMEOUT_MS = 60_000;
48
+
49
+ /**
50
+ * Fetch all history snapshots for an event from the Octagon API.
51
+ * Supports optional time window filtering via captured_from/captured_to.
52
+ */
53
+ export async function fetchEventHistory(
54
+ eventTicker: string,
55
+ opts?: { capturedFrom?: string; capturedTo?: string; days?: number },
56
+ ): Promise<HistorySnapshot[]> {
57
+ const apiKey = process.env.OCTAGON_API_KEY;
58
+ if (!apiKey) throw new Error('OCTAGON_API_KEY not set');
59
+
60
+ const all: HistorySnapshot[] = [];
61
+ let cursor: string | null = null;
62
+
63
+ do {
64
+ const params = new URLSearchParams({ limit: String(PAGE_LIMIT) });
65
+ params.set('exclude_empty_model', 'true');
66
+ if (cursor) params.set('cursor', cursor);
67
+ if (opts?.capturedFrom) params.set('captured_from', opts.capturedFrom);
68
+ if (opts?.capturedTo) params.set('captured_to', opts.capturedTo);
69
+ if (opts?.days) params.set('days', String(opts.days));
70
+
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
73
+
74
+ let resp: Response;
75
+ try {
76
+ resp = await fetch(
77
+ `${EVENTS_API_BASE}/prediction-markets/events/${encodeURIComponent(eventTicker)}/history?${params}`,
78
+ { headers: { Authorization: `Bearer ${apiKey}` }, signal: controller.signal },
79
+ );
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+
84
+ if (!resp.ok) {
85
+ const body = await resp.text().catch(() => '');
86
+ if (resp.status === 403 || resp.status === 402) {
87
+ throw new SubscriptionRequiredError(
88
+ 'The Octagon history API requires a paid subscription. ' +
89
+ 'The unresolved edge scanner (--unresolved) uses the free events API. ' +
90
+ 'Upgrade at https://app.octagonai.co to unlock resolved market backtesting.',
91
+ );
92
+ }
93
+ throw new Error(`Octagon history API ${resp.status} for ${eventTicker}: ${body.slice(0, 200)}`);
94
+ }
95
+
96
+ const raw = (await resp.json()) as unknown;
97
+ if (!raw || typeof raw !== 'object') {
98
+ throw new Error(`Octagon history API returned invalid response for ${eventTicker}`);
99
+ }
100
+ const page = raw as Record<string, unknown>;
101
+ if (!Array.isArray(page.data)) {
102
+ throw new Error(`Octagon history API response missing data array for ${eventTicker}`);
103
+ }
104
+ const hasMore = typeof page.has_more === 'boolean' ? page.has_more : false;
105
+ if (hasMore && !page.next_cursor) {
106
+ throw new Error(`Octagon history API has_more=true but next_cursor missing for ${eventTicker}`);
107
+ }
108
+ all.push(...(page.data as HistorySnapshot[]));
109
+ cursor = hasMore ? (page.next_cursor as string) : null;
110
+ } while (cursor);
111
+
112
+ return all;
113
+ }
114
+
115
+ /**
116
+ * Fetch event history and cache it in the local octagon_history table.
117
+ * Only uses the cache for full-history requests (no time window).
118
+ * When capturedFrom/capturedTo are provided, always fetches fresh from the API.
119
+ *
120
+ * If `maxAgeDays` is supplied, the cache is considered stale when the newest
121
+ * cached snapshot is older than that window, and we refetch from the API so
122
+ * new snapshots show up. `INSERT OR IGNORE` keeps old rows intact.
123
+ */
124
+ export async function fetchAndCacheHistory(
125
+ db: Database,
126
+ eventTicker: string,
127
+ opts?: { capturedFrom?: string; capturedTo?: string; days?: number; maxAgeDays?: number },
128
+ ): Promise<HistorySnapshot[]> {
129
+ const hasWindow = !!(opts?.capturedFrom || opts?.capturedTo);
130
+
131
+ // Only use cache for full-history requests (no time window filter)
132
+ if (!hasWindow) {
133
+ const cached = db.query(
134
+ 'SELECT COUNT(*) as cnt, MAX(captured_at) as newest FROM octagon_history WHERE event_ticker = $et',
135
+ ).get({ $et: eventTicker }) as { cnt: number; newest: string | null };
136
+
137
+ let cacheFresh = cached.cnt > 0;
138
+ if (cacheFresh && opts?.maxAgeDays && cached.newest) {
139
+ const newestEpoch = new Date(cached.newest).getTime();
140
+ const cutoffEpoch = Date.now() - opts.maxAgeDays * 24 * 60 * 60 * 1000;
141
+ if (Number.isFinite(newestEpoch) && newestEpoch < cutoffEpoch) {
142
+ cacheFresh = false; // newest snapshot is older than the lookback window
143
+ }
144
+ }
145
+
146
+ if (cacheFresh) {
147
+ const rows = db.query(
148
+ `SELECT history_id, event_ticker, captured_at, name, series_category,
149
+ confidence_score, model_probability, market_probability, edge_pp, close_time,
150
+ outcome_probabilities_json
151
+ FROM octagon_history WHERE event_ticker = $et ORDER BY captured_at ASC`,
152
+ ).all({ $et: eventTicker }) as HistorySnapshot[];
153
+ // Parse outcome_probabilities from cached JSON
154
+ for (const r of rows) {
155
+ if (r.outcome_probabilities_json) {
156
+ try { r.outcome_probabilities = JSON.parse(r.outcome_probabilities_json); } catch { /* skip */ }
157
+ }
158
+ }
159
+ return rows;
160
+ }
161
+ }
162
+
163
+ // Fetch from API
164
+ const snapshots = await fetchEventHistory(eventTicker, opts);
165
+
166
+ // Cache in DB (only for full-history requests to avoid partial cache)
167
+ if (!hasWindow) {
168
+ const insert = db.prepare(`
169
+ INSERT OR IGNORE INTO octagon_history
170
+ (history_id, event_ticker, captured_at, model_probability, market_probability,
171
+ edge_pp, confidence_score, series_category, close_time, name, outcome_probabilities_json)
172
+ VALUES ($history_id, $event_ticker, $captured_at, $model_probability, $market_probability,
173
+ $edge_pp, $confidence_score, $series_category, $close_time, $name, $opj)
174
+ `);
175
+
176
+ db.transaction(() => {
177
+ for (const s of snapshots) {
178
+ insert.run({
179
+ $history_id: s.history_id,
180
+ $event_ticker: s.event_ticker,
181
+ $captured_at: s.captured_at,
182
+ $model_probability: s.model_probability,
183
+ $market_probability: s.market_probability,
184
+ $edge_pp: s.edge_pp,
185
+ $confidence_score: s.confidence_score,
186
+ $series_category: s.series_category ?? null,
187
+ $close_time: s.close_time ?? null,
188
+ $name: s.name ?? null,
189
+ $opj: s.outcome_probabilities ? JSON.stringify(s.outcome_probabilities) : null,
190
+ });
191
+ }
192
+ })();
193
+
194
+ // Re-read merged cache so callers see old snapshots that may have been
195
+ // stored on a previous fetch but omitted from this API response.
196
+ const merged = db.query(
197
+ `SELECT history_id, event_ticker, captured_at, name, series_category,
198
+ confidence_score, model_probability, market_probability, edge_pp, close_time,
199
+ outcome_probabilities_json
200
+ FROM octagon_history WHERE event_ticker = $et ORDER BY captured_at ASC`,
201
+ ).all({ $et: eventTicker }) as HistorySnapshot[];
202
+ for (const r of merged) {
203
+ if (r.outcome_probabilities_json) {
204
+ try { r.outcome_probabilities = JSON.parse(r.outcome_probabilities_json); } catch { /* skip */ }
205
+ }
206
+ }
207
+ return merged;
208
+ }
209
+
210
+ return snapshots;
211
+ }
212
+
213
+ /**
214
+ * Select the snapshot closest to a target date (N days ago).
215
+ * Returns the last snapshot captured on or before the target date.
216
+ * If `minDate` is provided, snapshots older than that are rejected — this
217
+ * prevents a 15-day lookback from silently using a 30-day-old prediction
218
+ * when the event has no fresh snapshot within the window.
219
+ *
220
+ * Additionally requires each candidate snapshot to carry a finite
221
+ * `market_probability` and a non-empty `outcome_probabilities` array
222
+ * (mirrors the Supabase-methodology guard
223
+ * `market_probability IS NOT NULL AND LENGTH(outcome_probabilities_json) > 2`).
224
+ *
225
+ * Probabilities in the returned snapshot are percentages (0-100).
226
+ */
227
+ export function selectSnapshotByDate(
228
+ snapshots: HistorySnapshot[],
229
+ targetDate: Date,
230
+ minDate?: Date,
231
+ ): HistorySnapshot | null {
232
+ const targetEpoch = targetDate.getTime();
233
+ const minEpoch = minDate ? minDate.getTime() : -Infinity;
234
+
235
+ let best: HistorySnapshot | null = null;
236
+ let bestEpoch = -Infinity;
237
+ for (const s of snapshots) {
238
+ if (!Number.isFinite(s.market_probability)) continue;
239
+ if (!Array.isArray(s.outcome_probabilities) || s.outcome_probabilities.length === 0) continue;
240
+ const capturedEpoch = new Date(s.captured_at).getTime();
241
+ if (capturedEpoch <= targetEpoch && capturedEpoch >= minEpoch && capturedEpoch > bestEpoch) {
242
+ best = s;
243
+ bestEpoch = capturedEpoch;
244
+ }
245
+ }
246
+ return best;
247
+ }
@@ -0,0 +1,165 @@
1
+ import type { ScoredSignal, BacktestResult } from './types.js';
2
+
3
+ /**
4
+ * Skill score: how much better Octagon is vs the market as a forecaster.
5
+ * Positive = model beats market. Negative = market is better.
6
+ */
7
+ export function computeSkillScore(brierOctagon: number, brierMarket: number): number {
8
+ if (brierMarket === 0) return 0;
9
+ return 1 - (brierOctagon / brierMarket);
10
+ }
11
+
12
+ /**
13
+ * Bootstrap confidence interval for a statistic.
14
+ * Resamples `data` with replacement `iterations` times, computes `statFn` on each sample.
15
+ * Returns [lower, upper] at the given confidence level (default 95%).
16
+ */
17
+ export function bootstrapCI(
18
+ data: number[],
19
+ statFn: (sample: number[]) => number,
20
+ iterations = 10_000,
21
+ alpha = 0.05,
22
+ ): [number, number] {
23
+ if (data.length === 0) return [0, 0];
24
+ if (!Number.isFinite(iterations) || !Number.isInteger(iterations) || iterations <= 0) {
25
+ throw new Error(`bootstrapCI: iterations must be a finite integer > 0, got ${iterations}`);
26
+ }
27
+ if (!Number.isFinite(alpha) || alpha <= 0 || alpha >= 1) {
28
+ throw new Error(`bootstrapCI: alpha must be a finite number in (0, 1), got ${alpha}`);
29
+ }
30
+
31
+ const stats: number[] = [];
32
+ for (let i = 0; i < iterations; i++) {
33
+ const sample: number[] = [];
34
+ for (let j = 0; j < data.length; j++) {
35
+ sample.push(data[Math.floor(Math.random() * data.length)]);
36
+ }
37
+ stats.push(statFn(sample));
38
+ }
39
+ stats.sort((a, b) => a - b);
40
+
41
+ if (stats.length === 0) return [0, 0];
42
+ const lo = Math.min(Math.max(0, Math.floor((alpha / 2) * stats.length)), stats.length - 1);
43
+ const hi = Math.min(Math.max(0, Math.floor((1 - alpha / 2) * stats.length)), stats.length - 1);
44
+ return [stats[lo], stats[hi]];
45
+ }
46
+
47
+ /**
48
+ * Compute Brier score: ((forecast/100) - (outcome/100))²
49
+ * Both forecast and outcome are on 0-100 scale.
50
+ */
51
+ function brier(forecast: number, outcome: number): number {
52
+ return ((forecast / 100) - (outcome / 100)) ** 2;
53
+ }
54
+
55
+ /**
56
+ * Compute all backtest metrics from a unified list of scored signals.
57
+ */
58
+ export function computeMetrics(signals: ScoredSignal[], minEdgePp = 0.5): Omit<BacktestResult, 'subscription_notice'> {
59
+ const n = signals.length;
60
+ if (n === 0) {
61
+ return {
62
+ verdict: { summary: 'No markets with Octagon coverage found.', significant: false, profitable: false },
63
+ days: 0,
64
+ events_scored: 0,
65
+ markets_resolved: 0,
66
+ markets_unresolved: 0,
67
+ brier_octagon: 0,
68
+ brier_market: 0,
69
+ skill_score: 0,
70
+ skill_ci: [0, 0],
71
+ edge_signals: 0,
72
+ edge_hit_rate: 0,
73
+ hit_rate_ci: [0, 0],
74
+ flat_bet_pnl: 0,
75
+ flat_bet_roi: 0,
76
+ total_capital: 0,
77
+ signals: [],
78
+ };
79
+ }
80
+
81
+ // Brier scores — model vs market, both compared to outcome (market_now)
82
+ const brierOctagonScores = signals.map(s => brier(s.model_prob, s.market_now));
83
+ const brierMarketScores = signals.map(s => brier(s.market_then, s.market_now));
84
+ const brierOctagon = brierOctagonScores.reduce((a, b) => a + b, 0) / n;
85
+ const brierMarket = brierMarketScores.reduce((a, b) => a + b, 0) / n;
86
+
87
+ // Skill score with bootstrap CI — resample both
88
+ const skillScore = computeSkillScore(brierOctagon, brierMarket);
89
+ const indices = signals.map((_, i) => i);
90
+ const skillCI = bootstrapCI(indices, (sample) => {
91
+ let sumOctagon = 0;
92
+ let sumMarket = 0;
93
+ for (const idx of sample) {
94
+ sumOctagon += brierOctagonScores[idx];
95
+ sumMarket += brierMarketScores[idx];
96
+ }
97
+ const avgOctagon = sumOctagon / sample.length;
98
+ const avgMarket = sumMarket / sample.length;
99
+ return avgMarket === 0 ? 0 : 1 - (avgOctagon / avgMarket);
100
+ });
101
+
102
+ // Edge signals: where |edge| >= minEdgePp AND edge is non-zero
103
+ const edgeSignals = signals.filter(s => s.edge_pp !== 0 && Math.abs(s.edge_pp) >= minEdgePp);
104
+ const edgeCount = edgeSignals.length;
105
+
106
+ // Hit rate: did the market move in the direction the model predicted?
107
+ const hits = edgeSignals.filter(s => {
108
+ // Model said YES (edge > 0): hit if market_now > market_then
109
+ // Model said NO (edge < 0): hit if market_now < market_then
110
+ if (s.edge_pp > 0) return s.market_now > s.market_then;
111
+ return s.market_now < s.market_then;
112
+ });
113
+ const hitRate = edgeCount > 0 ? hits.length / edgeCount : 0;
114
+
115
+ // Bootstrap hit rate CI
116
+ const hitRateData = edgeSignals.map(s => {
117
+ if (s.edge_pp > 0) return s.market_now > s.market_then ? 1 : 0;
118
+ return s.market_now < s.market_then ? 1 : 0;
119
+ });
120
+ const hitRateCI = bootstrapCI(hitRateData, (sample) => {
121
+ return sample.reduce((a, b) => a + b, 0) / sample.length;
122
+ });
123
+
124
+ // P&L and capital-weighted ROI (matches Supabase methodology):
125
+ // ROI = sum(pnl) / sum(capital) across edge signals.
126
+ const pnl = edgeSignals.reduce((sum, s) => sum + s.pnl, 0);
127
+ const totalCapital = edgeSignals.reduce((sum, s) => sum + s.capital, 0);
128
+ const roi = totalCapital > 0 ? pnl / totalCapital : 0;
129
+
130
+ // Counts
131
+ const uniqueEvents = new Set(signals.map(s => s.event_ticker));
132
+ const resolved = signals.filter(s => s.resolved).length;
133
+ const unresolved = signals.filter(s => !s.resolved).length;
134
+
135
+ // Verdict
136
+ const significant = skillCI[0] > 0;
137
+ const profitable = pnl > 0;
138
+ let summary: string;
139
+ if (skillScore > 0.05 && significant && profitable) {
140
+ summary = `Model has edge (Skill +${(skillScore * 100).toFixed(1)}% [CI: +${(skillCI[0] * 100).toFixed(1)}%, +${(skillCI[1] * 100).toFixed(1)}%]; ROI +${(roi * 100).toFixed(1)}%)`;
141
+ } else if (skillScore > 0 && !significant) {
142
+ summary = `Inconclusive — need more data (Skill +${(skillScore * 100).toFixed(1)}%, CI includes zero)`;
143
+ } else {
144
+ summary = `No edge detected (Skill ${(skillScore * 100).toFixed(1)}%)`;
145
+ }
146
+
147
+ return {
148
+ verdict: { summary, significant, profitable },
149
+ days: 0, // filled by caller
150
+ events_scored: uniqueEvents.size,
151
+ markets_resolved: resolved,
152
+ markets_unresolved: unresolved,
153
+ brier_octagon: brierOctagon,
154
+ brier_market: brierMarket,
155
+ skill_score: skillScore,
156
+ skill_ci: skillCI,
157
+ edge_signals: edgeCount,
158
+ edge_hit_rate: hitRate,
159
+ hit_rate_ci: hitRateCI,
160
+ flat_bet_pnl: pnl,
161
+ flat_bet_roi: roi,
162
+ total_capital: totalCapital,
163
+ signals,
164
+ };
165
+ }
@@ -0,0 +1,196 @@
1
+ import type { BacktestResult, ScoredSignal } from './types.js';
2
+ import { writeFileSync } from 'fs';
3
+
4
+ export interface FormatOpts {
5
+ minEdge?: number; // 0-1 scale, default 0.005 (0.5pp)
6
+ }
7
+
8
+ /**
9
+ * Format complete backtest result for terminal display.
10
+ */
11
+ export function formatBacktestHuman(result: BacktestResult, opts?: FormatOpts): string {
12
+ const minEdgePp = ((opts?.minEdge ?? 0.005) * 100).toFixed(1);
13
+ const now = new Date();
14
+ const from = new Date(now.getTime() - result.days * 24 * 60 * 60 * 1000);
15
+ const fromStr = from.toISOString().slice(5, 10).replace('-', '/');
16
+ const toStr = now.toISOString().slice(5, 10).replace('-', '/');
17
+
18
+ const lines: string[] = [];
19
+ lines.push(`Octagon Backtest — ${result.days}-day lookback (${fromStr} – ${toStr})`);
20
+ lines.push('══════════════════════════════════════════════════════════');
21
+ lines.push('');
22
+
23
+ if (result.subscription_notice) {
24
+ lines.push(` ${result.subscription_notice}`);
25
+ lines.push('');
26
+ // Still show unresolved signals if any
27
+ const unresolvedSignals = result.signals.filter(s => !s.resolved);
28
+ if (unresolvedSignals.length > 0) {
29
+ lines.push(formatUnresolvedTable(unresolvedSignals, minEdgePp));
30
+ }
31
+ return lines.join('\n');
32
+ }
33
+
34
+ if (result.signals.length === 0) {
35
+ lines.push('No data available. Try a longer lookback (--days 60) or broader filter.');
36
+ return lines.join('\n');
37
+ }
38
+
39
+ // Unified scorecard
40
+ lines.push(`VERDICT: ${result.verdict.summary}`);
41
+ lines.push('');
42
+ lines.push(` Events ${result.events_scored}`);
43
+ lines.push(` Markets ${result.markets_resolved + result.markets_unresolved} (${result.markets_resolved} resolved, ${result.markets_unresolved} unresolved)`);
44
+ lines.push('');
45
+ // Brier scores and Skill Score are hidden for now (keep values in result for JSON/CSV consumers).
46
+ // lines.push(` Brier (Octagon) ${result.brier_octagon.toFixed(3)}`);
47
+ // lines.push(` Brier (Market) ${result.brier_market.toFixed(3)}`);
48
+ // lines.push(` Skill Score ${result.skill_score >= 0 ? '+' : ''}${(result.skill_score * 100).toFixed(1)}% [95% CI: ${(result.skill_ci[0] * 100).toFixed(1)}% to ${(result.skill_ci[1] * 100).toFixed(1)}%]`);
49
+ // lines.push('');
50
+ lines.push(` Edge signals ${result.edge_signals} (min edge: ${minEdgePp}pp)`);
51
+ if (result.edge_signals > 0) {
52
+ lines.push(` Hit rate ${(result.edge_hit_rate * 100).toFixed(1)}% [95% CI: ${(result.hit_rate_ci[0] * 100).toFixed(1)}% to ${(result.hit_rate_ci[1] * 100).toFixed(1)}%]`);
53
+ lines.push(` Flat-bet P&L ${result.flat_bet_pnl >= 0 ? '+' : ''}$${result.flat_bet_pnl.toFixed(2)} (ROI: ${result.flat_bet_roi >= 0 ? '+' : ''}${(result.flat_bet_roi * 100).toFixed(1)}%)`);
54
+ lines.push(` Capital deployed $${result.total_capital.toFixed(2)} (capital-weighted ROI)`);
55
+ }
56
+
57
+ // Resolved detail table
58
+ const resolved = result.signals.filter(s => s.resolved);
59
+ if (resolved.length > 0) {
60
+ lines.push('');
61
+ lines.push(formatResolvedTable(resolved));
62
+ }
63
+
64
+ // Unresolved detail table
65
+ const unresolved = result.signals.filter(s => !s.resolved);
66
+ if (unresolved.length > 0) {
67
+ lines.push('');
68
+ lines.push(formatUnresolvedTable(unresolved, minEdgePp));
69
+ }
70
+
71
+ return lines.join('\n');
72
+ }
73
+
74
+ function formatResolvedTable(signals: ScoredSignal[]): string {
75
+ const lines: string[] = [];
76
+ lines.push(`RESOLVED (${signals.length} markets — scored against Kalshi settlement)`);
77
+ lines.push('─────────────────────────────────────────────────────────');
78
+
79
+ const header = ' ' + [
80
+ 'Ticker'.padEnd(30),
81
+ 'Model'.padStart(6),
82
+ 'Mkt Then'.padStart(9),
83
+ 'Outcome'.padStart(10),
84
+ 'Edge'.padStart(7),
85
+ 'Bkt'.padStart(7),
86
+ 'P&L'.padStart(8),
87
+ 'ROI'.padStart(8),
88
+ ].join(' ');
89
+ lines.push(header);
90
+
91
+ // Sort by |P&L| descending
92
+ const sorted = [...signals].sort((a, b) => Math.abs(b.pnl) - Math.abs(a.pnl));
93
+ for (const s of sorted.slice(0, 20)) {
94
+ const outcome = s.market_now === 100 ? 'YES 100%' : 'NO 0%';
95
+ const roi = s.capital > 0 ? (s.pnl / s.capital) * 100 : 0;
96
+ const roiStr = s.capital > 0
97
+ ? `${roi >= 0 ? '+' : ''}${roi.toFixed(1)}%`
98
+ : '—';
99
+ const row = ' ' + [
100
+ s.market_ticker.padEnd(30),
101
+ `${s.model_prob.toFixed(0)}%`.padStart(6),
102
+ `${s.market_then.toFixed(0)}%`.padStart(9),
103
+ outcome.padStart(10),
104
+ `${s.edge_pp >= 0 ? '+' : ''}${s.edge_pp.toFixed(0)}pp`.padStart(7),
105
+ s.edge_bucket.padStart(7),
106
+ `${s.pnl >= 0 ? '+' : ''}$${s.pnl.toFixed(2)}`.padStart(8),
107
+ roiStr.padStart(8),
108
+ ].join(' ');
109
+ lines.push(row);
110
+ }
111
+ if (sorted.length > 20) {
112
+ lines.push(` ... and ${sorted.length - 20} more`);
113
+ }
114
+
115
+ return lines.join('\n');
116
+ }
117
+
118
+ function formatUnresolvedTable(signals: ScoredSignal[], minEdgePp: string): string {
119
+ const lines: string[] = [];
120
+ lines.push(`UNRESOLVED (${signals.length} markets — mark-to-market vs Kalshi trading price)`);
121
+ lines.push('────────────────────────────────────────────────────────────────');
122
+
123
+ const header = ' ' + [
124
+ 'Ticker'.padEnd(30),
125
+ 'Model'.padStart(6),
126
+ 'Mkt Then'.padStart(9),
127
+ 'Now'.padStart(6),
128
+ 'Edge'.padStart(7),
129
+ 'Bkt'.padStart(7),
130
+ 'M2M'.padStart(8),
131
+ 'ROI'.padStart(8),
132
+ ].join(' ');
133
+ lines.push(header);
134
+
135
+ // Sort by |edge| descending
136
+ const sorted = [...signals].sort((a, b) => Math.abs(b.edge_pp) - Math.abs(a.edge_pp));
137
+ for (const s of sorted.slice(0, 20)) {
138
+ const roi = s.capital > 0 ? (s.pnl / s.capital) * 100 : 0;
139
+ const roiStr = s.capital > 0
140
+ ? `${roi >= 0 ? '+' : ''}${roi.toFixed(1)}%`
141
+ : '—';
142
+ const row = ' ' + [
143
+ s.market_ticker.padEnd(30),
144
+ `${s.model_prob.toFixed(0)}%`.padStart(6),
145
+ `${s.market_then.toFixed(0)}%`.padStart(9),
146
+ `${s.market_now.toFixed(0)}%`.padStart(6),
147
+ `${s.edge_pp >= 0 ? '+' : ''}${s.edge_pp.toFixed(0)}pp`.padStart(7),
148
+ s.edge_bucket.padStart(7),
149
+ `${s.pnl >= 0 ? '+' : ''}$${s.pnl.toFixed(2)}`.padStart(8),
150
+ roiStr.padStart(8),
151
+ ].join(' ');
152
+ lines.push(row);
153
+ }
154
+ if (sorted.length > 20) {
155
+ lines.push(` ... and ${sorted.length - 20} more`);
156
+ }
157
+
158
+ return lines.join('\n');
159
+ }
160
+
161
+ /** Escape a CSV cell: wrap in quotes if it contains comma, quote, or newline. */
162
+ function csvEscape(val: string | number): string {
163
+ const s = String(val);
164
+ if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
165
+ return '"' + s.replace(/"/g, '""') + '"';
166
+ }
167
+ return s;
168
+ }
169
+
170
+ /**
171
+ * Export per-market detail to CSV.
172
+ */
173
+ export function exportCSV(result: BacktestResult, path: string): void {
174
+ const rows: string[] = [];
175
+ rows.push('type,ticker,event_ticker,series_category,edge_bucket,model_prob,market_then,market_now,edge_pp,pnl,capital,resolved,close_time');
176
+
177
+ for (const s of result.signals) {
178
+ rows.push([
179
+ s.resolved ? 'resolved' : 'unresolved',
180
+ csvEscape(s.market_ticker),
181
+ csvEscape(s.event_ticker),
182
+ csvEscape(s.series_category),
183
+ csvEscape(s.edge_bucket),
184
+ s.model_prob.toFixed(1),
185
+ s.market_then.toFixed(1),
186
+ s.market_now.toFixed(1),
187
+ s.edge_pp.toFixed(1),
188
+ s.pnl.toFixed(4),
189
+ s.capital.toFixed(4),
190
+ s.resolved ? '1' : '0',
191
+ csvEscape(s.close_time),
192
+ ].join(','));
193
+ }
194
+
195
+ writeFileSync(path, rows.join('\n') + '\n');
196
+ }
@@ -0,0 +1,45 @@
1
+ export interface BacktestOpts {
2
+ days: number; // lookback period in days (default 30)
3
+ resolvedOnly: boolean;
4
+ unresolvedOnly: boolean;
5
+ category?: string;
6
+ minEdge: number; // fractional (0-1 scale), converted to pp by caller (e.g., 0.005 → 0.5pp)
7
+ exportPath?: string;
8
+ }
9
+
10
+ /** A single scored market signal — unified type for both resolved and unresolved. */
11
+ export interface ScoredSignal {
12
+ event_ticker: string;
13
+ market_ticker: string;
14
+ series_category: string;
15
+ model_prob: number; // 0-100 (Octagon model % from N days ago)
16
+ market_then: number; // 0-100 (Kalshi trading price N days ago, from Octagon snapshot)
17
+ market_now: number; // 0-100 (settlement for resolved, current price for unresolved)
18
+ resolved: boolean;
19
+ edge_pp: number; // model_prob - market_then
20
+ pnl: number; // computed P&L for this signal ($ per $1 face value)
21
+ capital: number; // $ capital deployed per $1 face value: kp/100 for YES edges, (100-kp)/100 for NO edges
22
+ edge_bucket: string; // absolute-edge bucket label e.g. "0-5%", "5-10%", ..., "90%+"
23
+ confidence_score: number;
24
+ close_time: string;
25
+ }
26
+
27
+ export interface BacktestResult {
28
+ verdict: { summary: string; significant: boolean; profitable: boolean };
29
+ days: number;
30
+ events_scored: number;
31
+ markets_resolved: number;
32
+ markets_unresolved: number;
33
+ brier_octagon: number;
34
+ brier_market: number;
35
+ skill_score: number;
36
+ skill_ci: [number, number];
37
+ edge_signals: number;
38
+ edge_hit_rate: number;
39
+ hit_rate_ci: [number, number];
40
+ flat_bet_pnl: number;
41
+ flat_bet_roi: number; // capital-weighted: sum(pnl) / sum(capital) across edge signals
42
+ total_capital: number; // sum of capital across edge signals (ROI denominator)
43
+ signals: ScoredSignal[];
44
+ subscription_notice?: string;
45
+ }