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,199 @@
|
|
|
1
|
+
import type { OctagonInvoker, OctagonVariant } from './types.js';
|
|
2
|
+
import { callKalshiApi, KalshiApiError } from '../tools/kalshi/api.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Slugify a title for Kalshi website URL paths.
|
|
7
|
+
*/
|
|
8
|
+
function slugify(text: string): string {
|
|
9
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Cache series slug lookups to avoid redundant API calls */
|
|
13
|
+
const seriesSlugCache = new Map<string, string>(); // series_ticker → slug
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a Kalshi market URL that Octagon can resolve.
|
|
17
|
+
* Kalshi website URLs use the format: /markets/{series_ticker}/{series_title_slug}/{event_ticker}
|
|
18
|
+
* Octagon needs this exact format — it cannot follow client-side redirects.
|
|
19
|
+
*/
|
|
20
|
+
async function buildKalshiMarketUrl(ticker: string): Promise<string> {
|
|
21
|
+
let market: unknown;
|
|
22
|
+
try {
|
|
23
|
+
market = await callKalshiApi('GET', `/markets/${ticker}`);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err instanceof KalshiApiError && err.statusCode === 404) {
|
|
26
|
+
throw new Error(`Market ticker '${ticker}' not found on Kalshi. Use kalshi_search to find valid tickers.`);
|
|
27
|
+
}
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
const data = ((market as any).market ?? market) as Record<string, unknown>;
|
|
31
|
+
const eventTicker = data.event_ticker as string | undefined;
|
|
32
|
+
if (!eventTicker) throw new Error(`No event_ticker found for market ${ticker}`);
|
|
33
|
+
|
|
34
|
+
// Get series info (series_ticker + title for slug)
|
|
35
|
+
const eventRes = await callKalshiApi('GET', `/events/${eventTicker}`);
|
|
36
|
+
const ev = ((eventRes as any).event ?? eventRes) as Record<string, unknown>;
|
|
37
|
+
const seriesTicker = ev.series_ticker as string | undefined;
|
|
38
|
+
if (!seriesTicker) throw new Error(`No series_ticker found for event ${eventTicker}`);
|
|
39
|
+
|
|
40
|
+
// Check slug cache
|
|
41
|
+
let slug = seriesSlugCache.get(seriesTicker);
|
|
42
|
+
if (!slug) {
|
|
43
|
+
const seriesRes = await callKalshiApi('GET', `/series/${seriesTicker}`);
|
|
44
|
+
const ser = ((seriesRes as any).series ?? seriesRes) as Record<string, unknown>;
|
|
45
|
+
const seriesTitle = ser.title as string | undefined;
|
|
46
|
+
if (!seriesTitle) throw new Error(`No title found for series ${seriesTicker}`);
|
|
47
|
+
slug = slugify(seriesTitle);
|
|
48
|
+
seriesSlugCache.set(seriesTicker, slug);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return `https://kalshi.com/markets/${seriesTicker.toLowerCase()}/${slug}/${eventTicker.toLowerCase()}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract text content from an OpenAI-compatible responses API result.
|
|
56
|
+
*/
|
|
57
|
+
function extractTextFromResponse(data: unknown): string {
|
|
58
|
+
if (!data || typeof data !== 'object') return String(data);
|
|
59
|
+
|
|
60
|
+
const obj = data as Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
// OpenAI responses format: { output: [{ type: "message", content: [{ type: "output_text", text: "..." }] }] }
|
|
63
|
+
if (Array.isArray(obj.output)) {
|
|
64
|
+
for (const item of obj.output) {
|
|
65
|
+
if (item && typeof item === 'object') {
|
|
66
|
+
const entry = item as Record<string, unknown>;
|
|
67
|
+
if (Array.isArray(entry.content)) {
|
|
68
|
+
for (const block of entry.content) {
|
|
69
|
+
if (block && typeof block === 'object') {
|
|
70
|
+
const b = block as Record<string, unknown>;
|
|
71
|
+
if (b.type === 'output_text' && typeof b.text === 'string') {
|
|
72
|
+
return b.text;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Direct text field
|
|
78
|
+
if (typeof entry.text === 'string') return entry.text;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Chat completions format: { choices: [{ message: { content: "..." } }] }
|
|
84
|
+
if (Array.isArray(obj.choices)) {
|
|
85
|
+
const first = obj.choices[0] as Record<string, unknown> | undefined;
|
|
86
|
+
if (first?.message && typeof first.message === 'object') {
|
|
87
|
+
const msg = first.message as Record<string, unknown>;
|
|
88
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Direct output_text field
|
|
93
|
+
if (typeof obj.output_text === 'string') return obj.output_text;
|
|
94
|
+
|
|
95
|
+
// Fallback
|
|
96
|
+
return JSON.stringify(data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Call the Octagon API with a Kalshi market URL or ticker.
|
|
101
|
+
* Octagon only accepts full Kalshi URLs (e.g. https://kalshi.com/markets/series/event/ticker).
|
|
102
|
+
* If a ticker is passed, it will be resolved to a URL via the Kalshi API.
|
|
103
|
+
*/
|
|
104
|
+
export async function callOctagon(input: string, variant: OctagonVariant): Promise<string> {
|
|
105
|
+
const apiKey = process.env.OCTAGON_API_KEY;
|
|
106
|
+
const baseUrl = process.env.OCTAGON_BASE_URL ?? 'https://api-gateway.octagonagents.com/v1';
|
|
107
|
+
|
|
108
|
+
if (!apiKey) throw new Error('OCTAGON_API_KEY not set. Get one at https://app.octagonai.co');
|
|
109
|
+
|
|
110
|
+
const model = variant === 'default'
|
|
111
|
+
? 'octagon-prediction-markets-agent'
|
|
112
|
+
: `octagon-prediction-markets-agent:${variant}`;
|
|
113
|
+
|
|
114
|
+
// Octagon requires a full Kalshi URL — resolve tickers to URLs
|
|
115
|
+
const marketUrl = input.startsWith('https://kalshi.com/')
|
|
116
|
+
? input
|
|
117
|
+
: await buildKalshiMarketUrl(input);
|
|
118
|
+
|
|
119
|
+
// Refresh reports can take several minutes to generate; cache is fast
|
|
120
|
+
const timeoutMs = variant === 'cache' ? 60_000 : 600_000;
|
|
121
|
+
const reqBody = JSON.stringify({ model, input: marketUrl });
|
|
122
|
+
const MAX_RETRIES = 3;
|
|
123
|
+
const RETRY_DELAYS = [15_000, 30_000, 60_000]; // 15s, 30s, 60s
|
|
124
|
+
|
|
125
|
+
let lastError: Error | null = null;
|
|
126
|
+
|
|
127
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
128
|
+
if (attempt > 0) {
|
|
129
|
+
const delay = RETRY_DELAYS[attempt - 1];
|
|
130
|
+
logger.info(`[octagon] Returned ${lastError?.message?.match(/\d{3}/)?.[0] ?? '5xx'}, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES + 1})`);
|
|
131
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
136
|
+
|
|
137
|
+
let resp: Response;
|
|
138
|
+
try {
|
|
139
|
+
resp = await fetch(`${baseUrl}/responses`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
},
|
|
145
|
+
body: reqBody,
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
151
|
+
const secs = Math.round(timeoutMs / 1000);
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Octagon API timed out after ${secs}s. The ${variant} report is taking longer than expected. ` +
|
|
154
|
+
`Try again later or use cached data (omit --refresh).`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
} finally {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (resp.ok) {
|
|
163
|
+
const data = await resp.json();
|
|
164
|
+
return extractTextFromResponse(data);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Retry on 502/503/504 gateway errors
|
|
168
|
+
if ([502, 503, 504].includes(resp.status) && attempt < MAX_RETRIES) {
|
|
169
|
+
const body = await resp.text().catch(() => '');
|
|
170
|
+
const isHtml = body.trimStart().startsWith('<');
|
|
171
|
+
const detail = isHtml ? '' : body.slice(0, 200);
|
|
172
|
+
lastError = new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail}` : ''}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Non-retryable error or retries exhausted
|
|
177
|
+
const body = await resp.text().catch(() => '');
|
|
178
|
+
const isHtml = body.trimStart().startsWith('<');
|
|
179
|
+
const detail = isHtml ? '' : body.slice(0, 200);
|
|
180
|
+
const maskedKey = apiKey!.length > 4 ? '...' + apiKey!.slice(-4) : '****';
|
|
181
|
+
const curl = `curl -X POST '${baseUrl}/responses' \\\n -H 'Authorization: Bearer ${maskedKey}' \\\n -H 'Content-Type: application/json' \\\n -d '${reqBody}'`;
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Octagon API error: ${resp.status} ${resp.statusText}${detail ? ` — ${detail}` : ''}\n\nReproduce with:\n${curl}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Should not reach here, but satisfy TypeScript
|
|
188
|
+
throw lastError ?? new Error('Octagon API request failed');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Factory for the OctagonInvoker used by ScanLoop.
|
|
193
|
+
* Calls the Octagon Prediction Markets Agent API (OpenAI-compatible).
|
|
194
|
+
*/
|
|
195
|
+
export function createOctagonInvoker(): OctagonInvoker {
|
|
196
|
+
return async (ticker: string, variant: OctagonVariant): Promise<string> => {
|
|
197
|
+
return callOctagon(ticker, variant);
|
|
198
|
+
};
|
|
199
|
+
}
|
package/src/scan/loop.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { ThemeResolver } from './theme-resolver.js';
|
|
4
|
+
import { EdgeComputer } from './edge-computer.js';
|
|
5
|
+
import { OctagonClient } from './octagon-client.js';
|
|
6
|
+
import { PositionWatchdog } from './watchdog.js';
|
|
7
|
+
import { Alerter, type AlertPayload, type AlertChannelDispatch } from './alerter.js';
|
|
8
|
+
import { CircuitBreaker } from '../risk/circuit-breaker.js';
|
|
9
|
+
import type { OctagonInvoker, EdgeSnapshot } from './types.js';
|
|
10
|
+
import type { RiskSnapshot } from '../db/risk.js';
|
|
11
|
+
import type { AuditTrail } from '../audit/trail.js';
|
|
12
|
+
import { getBotSetting } from '../utils/bot-config.js';
|
|
13
|
+
|
|
14
|
+
export interface ScanOpts {
|
|
15
|
+
theme: string;
|
|
16
|
+
forceRefresh?: boolean;
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ScanResult {
|
|
21
|
+
scanId: string;
|
|
22
|
+
eventsScanned: number;
|
|
23
|
+
edgeSnapshots: EdgeSnapshot[];
|
|
24
|
+
alerts: AlertPayload[];
|
|
25
|
+
riskSnapshot: RiskSnapshot;
|
|
26
|
+
octagonCreditsUsed: number;
|
|
27
|
+
duration: number; // ms
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_INTERVAL_MINUTES = 60;
|
|
31
|
+
|
|
32
|
+
export class ScanLoop {
|
|
33
|
+
private db: Database;
|
|
34
|
+
private audit: AuditTrail;
|
|
35
|
+
private themeResolver: ThemeResolver;
|
|
36
|
+
private edgeComputer: EdgeComputer;
|
|
37
|
+
private octagonClient: OctagonClient;
|
|
38
|
+
private watchdog: PositionWatchdog;
|
|
39
|
+
private alerter: Alerter;
|
|
40
|
+
private circuitBreaker: CircuitBreaker;
|
|
41
|
+
private intervalTimer: ReturnType<typeof setInterval> | null = null;
|
|
42
|
+
private running = false;
|
|
43
|
+
private defaultChannels: string[];
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
db: Database,
|
|
47
|
+
audit: AuditTrail,
|
|
48
|
+
octagonInvoker: OctagonInvoker,
|
|
49
|
+
opts?: { alerter?: Alerter; defaultChannels?: string[] },
|
|
50
|
+
) {
|
|
51
|
+
this.db = db;
|
|
52
|
+
this.audit = audit;
|
|
53
|
+
this.themeResolver = new ThemeResolver(db, audit);
|
|
54
|
+
this.edgeComputer = new EdgeComputer(db, audit);
|
|
55
|
+
this.octagonClient = new OctagonClient(octagonInvoker, db, audit);
|
|
56
|
+
this.watchdog = new PositionWatchdog(audit);
|
|
57
|
+
this.alerter = opts?.alerter ?? new Alerter(db, audit);
|
|
58
|
+
this.circuitBreaker = new CircuitBreaker();
|
|
59
|
+
this.defaultChannels = opts?.defaultChannels ?? ['terminal'];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async runOnce(opts: ScanOpts): Promise<ScanResult> {
|
|
63
|
+
const scanId = randomUUID();
|
|
64
|
+
const start = Date.now();
|
|
65
|
+
const alerts: AlertPayload[] = [];
|
|
66
|
+
|
|
67
|
+
// Step 1: Resolve theme → event tickers
|
|
68
|
+
const tickers = await this.themeResolver.resolve(opts.theme);
|
|
69
|
+
|
|
70
|
+
// Steps 2-4: Pull markets, fetch Octagon, compute edge
|
|
71
|
+
const edgeSnapshots = await this.edgeComputer.computeAll(tickers, this.octagonClient);
|
|
72
|
+
|
|
73
|
+
// Step 5: Check open positions
|
|
74
|
+
const watchdogAlerts = this.watchdog.check(this.db);
|
|
75
|
+
|
|
76
|
+
// Step 6: Take risk snapshot
|
|
77
|
+
const riskSnapshot = await this.circuitBreaker.snapshot(this.db);
|
|
78
|
+
|
|
79
|
+
// Step 7: Collect and emit alerts
|
|
80
|
+
// Edge alerts for high/very_high confidence
|
|
81
|
+
for (const snap of edgeSnapshots) {
|
|
82
|
+
if (snap.confidence === 'high' || snap.confidence === 'very_high') {
|
|
83
|
+
alerts.push({
|
|
84
|
+
ticker: snap.ticker,
|
|
85
|
+
alertType: 'EDGE_DETECTED',
|
|
86
|
+
edge: snap.edge,
|
|
87
|
+
message: `Edge ${(snap.edge * 100).toFixed(1)}% (${snap.confidence}) on ${snap.ticker}`,
|
|
88
|
+
channels: this.defaultChannels,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Watchdog alerts
|
|
94
|
+
for (const wa of watchdogAlerts) {
|
|
95
|
+
alerts.push({
|
|
96
|
+
ticker: wa.ticker,
|
|
97
|
+
alertType: wa.alertType,
|
|
98
|
+
edge: wa.edge,
|
|
99
|
+
message: wa.message,
|
|
100
|
+
channels: this.defaultChannels,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Circuit breaker alert
|
|
105
|
+
const cbStatus = this.circuitBreaker.check(this.db);
|
|
106
|
+
if (cbStatus.active) {
|
|
107
|
+
alerts.push({
|
|
108
|
+
ticker: '*',
|
|
109
|
+
alertType: 'CIRCUIT_BREAKER',
|
|
110
|
+
edge: 0,
|
|
111
|
+
message: `Circuit breaker active: ${cbStatus.reason}`,
|
|
112
|
+
channels: this.defaultChannels,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Emit alerts (skip persistence when dryRun)
|
|
117
|
+
if (!opts.dryRun) {
|
|
118
|
+
for (const alert of alerts) {
|
|
119
|
+
await this.alerter.emit(alert);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Step 8: Log SCAN_COMPLETE
|
|
124
|
+
const duration = Date.now() - start;
|
|
125
|
+
this.audit.log({
|
|
126
|
+
type: 'SCAN_COMPLETE',
|
|
127
|
+
scan_id: scanId,
|
|
128
|
+
theme: opts.theme,
|
|
129
|
+
events_scanned: tickers.length,
|
|
130
|
+
edges_found: edgeSnapshots.length,
|
|
131
|
+
duration_ms: duration,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
scanId,
|
|
136
|
+
eventsScanned: tickers.length,
|
|
137
|
+
edgeSnapshots,
|
|
138
|
+
alerts,
|
|
139
|
+
riskSnapshot,
|
|
140
|
+
octagonCreditsUsed: this.octagonClient.getCreditsUsed(),
|
|
141
|
+
duration,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
start(opts: ScanOpts & { intervalMinutes?: number }): void {
|
|
146
|
+
if (this.running) return;
|
|
147
|
+
this.running = true;
|
|
148
|
+
|
|
149
|
+
const rawMinInterval = Number(getBotSetting('watch.min_interval_minutes'));
|
|
150
|
+
const minIntervalMinutes = Number.isFinite(rawMinInterval) && rawMinInterval > 0
|
|
151
|
+
? rawMinInterval
|
|
152
|
+
: DEFAULT_INTERVAL_MINUTES;
|
|
153
|
+
const minutes = Math.max(
|
|
154
|
+
minIntervalMinutes,
|
|
155
|
+
opts.intervalMinutes ?? DEFAULT_INTERVAL_MINUTES
|
|
156
|
+
);
|
|
157
|
+
const ms = minutes * 60_000;
|
|
158
|
+
|
|
159
|
+
// Run first cycle immediately
|
|
160
|
+
this.runOnce(opts).catch((err) => {
|
|
161
|
+
console.error('[ScanLoop] Error in scan cycle:', err);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
this.intervalTimer = setInterval(() => {
|
|
165
|
+
if (!this.running) return;
|
|
166
|
+
this.runOnce(opts).catch((err) => {
|
|
167
|
+
console.error('[ScanLoop] Error in scan cycle:', err);
|
|
168
|
+
});
|
|
169
|
+
}, ms);
|
|
170
|
+
|
|
171
|
+
// Graceful shutdown handlers — use once() to prevent accumulation
|
|
172
|
+
const shutdown = () => this.stop();
|
|
173
|
+
process.once('SIGINT', shutdown);
|
|
174
|
+
process.once('SIGTERM', shutdown);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stop(): void {
|
|
178
|
+
this.running = false;
|
|
179
|
+
if (this.intervalTimer) {
|
|
180
|
+
clearInterval(this.intervalTimer);
|
|
181
|
+
this.intervalTimer = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|