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