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,253 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomInt } from 'node:crypto';
4
+ import { isSelfChatMode, normalizeE164 } from './utils.js';
5
+ import { appPath } from '../utils/paths.js';
6
+
7
+ const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000;
8
+
9
+ type PairingRequest = {
10
+ phone: string;
11
+ code: string;
12
+ createdAt: number;
13
+ };
14
+
15
+ type PairingStore = Record<string, PairingRequest>;
16
+
17
+ function pairingPath(): string {
18
+ return (
19
+ process.env.APP_PAIRING_PATH ??
20
+ appPath('pairing', 'whatsapp.json')
21
+ );
22
+ }
23
+
24
+ function loadPairingStore(): PairingStore {
25
+ const path = pairingPath();
26
+ if (!existsSync(path)) {
27
+ return {};
28
+ }
29
+ try {
30
+ return JSON.parse(readFileSync(path, 'utf8')) as PairingStore;
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function savePairingStore(store: PairingStore): void {
37
+ const path = pairingPath();
38
+ const dir = dirname(path);
39
+ if (!existsSync(dir)) {
40
+ mkdirSync(dir, { recursive: true });
41
+ }
42
+ writeFileSync(path, JSON.stringify(store, null, 2), 'utf8');
43
+ }
44
+
45
+ export function createPairingCode(): string {
46
+ return String(randomInt(100000, 999999));
47
+ }
48
+
49
+ export function recordPairingRequest(phone: string): PairingRequest {
50
+ const normalized = normalizeE164(phone);
51
+ const store = loadPairingStore();
52
+ const existing = store[normalized];
53
+ if (existing) {
54
+ return existing;
55
+ }
56
+ const request: PairingRequest = {
57
+ phone: normalized,
58
+ code: createPairingCode(),
59
+ createdAt: Date.now(),
60
+ };
61
+ store[normalized] = request;
62
+ savePairingStore(store);
63
+ return request;
64
+ }
65
+
66
+ export function isAllowedPhone(params: {
67
+ from: string;
68
+ allowFrom: string[];
69
+ }): { allowed: boolean; normalizedFrom: string } {
70
+ const normalizedFrom = normalizeE164(params.from);
71
+ const allowFrom = params.allowFrom.map(normalizeE164).filter(Boolean);
72
+ if (allowFrom.includes('+*') || params.allowFrom.includes('*')) {
73
+ return { allowed: true, normalizedFrom };
74
+ }
75
+ return { allowed: allowFrom.includes(normalizedFrom), normalizedFrom };
76
+ }
77
+
78
+ export function buildPairingReply(code: string, senderId: string): string {
79
+ return [
80
+ 'Access request received.',
81
+ `Sender ID: ${senderId}`,
82
+ `Approval code: ${code}`,
83
+ 'Ask the operator to approve this code in gateway config.',
84
+ ].join('\n');
85
+ }
86
+
87
+ export type InboundAccessControlResult = {
88
+ allowed: boolean;
89
+ shouldMarkRead: boolean;
90
+ isSelfChat: boolean;
91
+ resolvedAccountId: string;
92
+ denyReason?: string;
93
+ };
94
+
95
+ export async function checkInboundAccessControl(params: {
96
+ accountId: string;
97
+ from: string;
98
+ selfE164: string | null;
99
+ senderE164: string | null;
100
+ group: boolean;
101
+ pushName?: string;
102
+ isFromMe: boolean;
103
+ dmPolicy: 'pairing' | 'allowlist' | 'open' | 'disabled';
104
+ groupPolicy: 'open' | 'allowlist' | 'disabled';
105
+ allowFrom: string[];
106
+ groupAllowFrom: string[];
107
+ messageTimestampMs?: number;
108
+ connectedAtMs?: number;
109
+ pairingGraceMs?: number;
110
+ reply: (text: string) => Promise<void>;
111
+ }): Promise<InboundAccessControlResult> {
112
+ const normalizedSelfE164 = params.selfE164 ? normalizeE164(params.selfE164) : null;
113
+ const normalizedFrom = normalizeE164(params.from);
114
+ const normalizedSenderE164 = params.senderE164 ? normalizeE164(params.senderE164) : null;
115
+ const isSamePhone = normalizedSelfE164 != null && normalizedFrom === normalizedSelfE164;
116
+ const isSelfChat = isSelfChatMode(params.selfE164, params.allowFrom);
117
+ const pairingGraceMs =
118
+ typeof params.pairingGraceMs === 'number' && params.pairingGraceMs > 0
119
+ ? params.pairingGraceMs
120
+ : PAIRING_REPLY_HISTORY_GRACE_MS;
121
+ const suppressPairingReply =
122
+ typeof params.connectedAtMs === 'number' &&
123
+ typeof params.messageTimestampMs === 'number' &&
124
+ params.messageTimestampMs < params.connectedAtMs - pairingGraceMs;
125
+
126
+ const dmHasWildcard = params.allowFrom.includes('*');
127
+ const normalizedAllowFrom = params.allowFrom.filter((entry) => entry !== '*').map(normalizeE164);
128
+ const groupHasWildcard = params.groupAllowFrom.includes('*');
129
+ const normalizedGroupAllowFrom = params.groupAllowFrom
130
+ .filter((entry) => entry !== '*')
131
+ .map(normalizeE164);
132
+
133
+ // Strict self-chat mode: only allow direct messages to/from the user's own number.
134
+ // This provides fail-closed behavior even if policies are accidentally broadened.
135
+ if (isSelfChat) {
136
+ if (params.group) {
137
+ return {
138
+ allowed: false,
139
+ shouldMarkRead: false,
140
+ isSelfChat,
141
+ resolvedAccountId: params.accountId,
142
+ denyReason: 'group_blocked_self_chat_mode',
143
+ };
144
+ }
145
+ const senderIsSelf =
146
+ normalizedSelfE164 != null &&
147
+ (normalizedFrom === normalizedSelfE164 || normalizedSenderE164 === normalizedSelfE164);
148
+ if (!senderIsSelf) {
149
+ return {
150
+ allowed: false,
151
+ shouldMarkRead: false,
152
+ isSelfChat,
153
+ resolvedAccountId: params.accountId,
154
+ denyReason: 'sender_not_self_in_self_chat_mode',
155
+ };
156
+ }
157
+ return {
158
+ allowed: true,
159
+ shouldMarkRead: true,
160
+ isSelfChat,
161
+ resolvedAccountId: params.accountId,
162
+ };
163
+ }
164
+
165
+ // Block group messages unless explicitly allowed via 'open' or 'allowlist' policy.
166
+ // Fail-safe: if groupPolicy is missing/invalid, groups are blocked.
167
+ if (params.group) {
168
+ if (params.groupPolicy !== 'open' && params.groupPolicy !== 'allowlist') {
169
+ return {
170
+ allowed: false,
171
+ shouldMarkRead: false,
172
+ isSelfChat,
173
+ resolvedAccountId: params.accountId,
174
+ denyReason: 'group_policy_not_permissive',
175
+ };
176
+ }
177
+ }
178
+
179
+ if (params.group && params.groupPolicy === 'allowlist') {
180
+ if (normalizedGroupAllowFrom.length === 0 && !groupHasWildcard) {
181
+ return {
182
+ allowed: false,
183
+ shouldMarkRead: false,
184
+ isSelfChat,
185
+ resolvedAccountId: params.accountId,
186
+ denyReason: 'group_allowlist_empty',
187
+ };
188
+ }
189
+ const senderAllowed =
190
+ groupHasWildcard ||
191
+ (normalizedSenderE164 != null && normalizedGroupAllowFrom.includes(normalizedSenderE164));
192
+ if (!senderAllowed) {
193
+ return {
194
+ allowed: false,
195
+ shouldMarkRead: false,
196
+ isSelfChat,
197
+ resolvedAccountId: params.accountId,
198
+ denyReason: 'group_sender_not_allowlisted',
199
+ };
200
+ }
201
+ }
202
+
203
+ if (!params.group) {
204
+ if (params.dmPolicy === 'disabled') {
205
+ return {
206
+ allowed: false,
207
+ shouldMarkRead: false,
208
+ isSelfChat,
209
+ resolvedAccountId: params.accountId,
210
+ denyReason: 'dm_policy_disabled',
211
+ };
212
+ }
213
+
214
+ // Skip outbound DMs to other people (messages I sent to others)
215
+ if (params.isFromMe && !isSamePhone) {
216
+ return {
217
+ allowed: false,
218
+ shouldMarkRead: false,
219
+ isSelfChat,
220
+ resolvedAccountId: params.accountId,
221
+ denyReason: 'outbound_dm_to_non_self',
222
+ };
223
+ }
224
+
225
+ // For DMs from others, check allowlist (unless policy is 'open')
226
+ if (params.dmPolicy !== 'open' && !isSamePhone) {
227
+ const allowed =
228
+ dmHasWildcard ||
229
+ (normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(normalizedFrom));
230
+ if (!allowed) {
231
+ if (params.dmPolicy === 'pairing' && !suppressPairingReply) {
232
+ const pairing = recordPairingRequest(normalizedFrom);
233
+ await params.reply(buildPairingReply(pairing.code, normalizedFrom));
234
+ }
235
+ return {
236
+ allowed: false,
237
+ shouldMarkRead: false,
238
+ isSelfChat,
239
+ resolvedAccountId: params.accountId,
240
+ denyReason: 'dm_sender_not_allowlisted',
241
+ };
242
+ }
243
+ }
244
+ }
245
+
246
+ return {
247
+ allowed: true,
248
+ shouldMarkRead: true,
249
+ isSelfChat,
250
+ resolvedAccountId: params.accountId,
251
+ };
252
+ }
253
+
@@ -0,0 +1,75 @@
1
+ import { Agent } from '../agent/agent.js';
2
+ import { InMemoryChatHistory } from '../utils/in-memory-chat-history.js';
3
+ import { HEARTBEAT_OK_TOKEN } from './heartbeat/suppression.js';
4
+ import type { AgentEvent } from '../agent/types.js';
5
+ import type { GroupContext } from '../agent/prompts.js';
6
+
7
+ type SessionState = {
8
+ history: InMemoryChatHistory;
9
+ tail: Promise<void>;
10
+ };
11
+
12
+ const sessions = new Map<string, SessionState>();
13
+
14
+ function getSession(sessionKey: string, model: string): SessionState {
15
+ const existing = sessions.get(sessionKey);
16
+ if (existing) {
17
+ return existing;
18
+ }
19
+ const created: SessionState = {
20
+ history: new InMemoryChatHistory(model),
21
+ tail: Promise.resolve(),
22
+ };
23
+ sessions.set(sessionKey, created);
24
+ return created;
25
+ }
26
+
27
+ export type AgentRunRequest = {
28
+ sessionKey: string;
29
+ query: string;
30
+ model: string;
31
+ modelProvider: string;
32
+ maxIterations?: number;
33
+ signal?: AbortSignal;
34
+ onEvent?: (event: AgentEvent) => void | Promise<void>;
35
+ isHeartbeat?: boolean;
36
+ channel?: string;
37
+ groupContext?: GroupContext;
38
+ };
39
+
40
+ export async function runAgentForMessage(req: AgentRunRequest): Promise<string> {
41
+ const session = getSession(req.sessionKey, req.model);
42
+ let finalAnswer = '';
43
+
44
+ const run = async () => {
45
+ session.history.saveUserQuery(req.query);
46
+ const agent = await Agent.create({
47
+ model: req.model,
48
+ modelProvider: req.modelProvider,
49
+ maxIterations: req.maxIterations ?? 10,
50
+ signal: req.signal,
51
+ channel: req.channel,
52
+ groupContext: req.groupContext,
53
+ });
54
+ for await (const event of agent.run(req.query, session.history)) {
55
+ await req.onEvent?.(event);
56
+ if (event.type === 'done') {
57
+ finalAnswer = event.answer;
58
+ }
59
+ }
60
+ if (finalAnswer) {
61
+ await session.history.saveAnswer(finalAnswer);
62
+ }
63
+
64
+ // Prune HEARTBEAT_OK turns to avoid context pollution
65
+ if (req.isHeartbeat && finalAnswer.trim().toUpperCase().includes(HEARTBEAT_OK_TOKEN)) {
66
+ session.history.pruneLastTurn();
67
+ }
68
+ };
69
+
70
+ // Serialize per-session turns while allowing cross-session concurrency.
71
+ session.tail = session.tail.then(run, run);
72
+ await session.tail;
73
+ return finalAnswer;
74
+ }
75
+
@@ -0,0 +1,90 @@
1
+ import type { AlertPayload } from '../../scan/alerter.js';
2
+
3
+ function magnitudeBar(edge: number): string {
4
+ const absEdge = Math.abs(edge);
5
+ const pct = Math.min(absEdge * 100, 10);
6
+ const filled = Math.round(pct);
7
+ const empty = 10 - filled;
8
+ const sign = edge >= 0 ? '+' : '-';
9
+ return `[${`=`.repeat(filled)}${`-`.repeat(empty)}] ${sign}${(absEdge * 100).toFixed(1)}%`;
10
+ }
11
+
12
+ function formatEdgeAlert(alert: AlertPayload): string {
13
+ const lines: string[] = [];
14
+ lines.push(`🔔 *EDGE DETECTED*`);
15
+ lines.push('');
16
+ lines.push(`*${alert.ticker}*`);
17
+ lines.push(magnitudeBar(alert.edge));
18
+ lines.push('');
19
+ lines.push(alert.message);
20
+ lines.push('');
21
+ lines.push(`Reply *YES* to get a recommendation`);
22
+ return lines.join('\n');
23
+ }
24
+
25
+ function formatConvergenceAlert(alert: AlertPayload): string {
26
+ const lines: string[] = [];
27
+ lines.push(`⚠️ *CONVERGENCE*`);
28
+ lines.push('');
29
+ lines.push(`*${alert.ticker}*`);
30
+ lines.push(alert.message);
31
+ lines.push('');
32
+ lines.push(`Edge narrowed to ${(Math.abs(alert.edge) * 100).toFixed(1)}%`);
33
+ return lines.join('\n');
34
+ }
35
+
36
+ function formatAdverseMoveAlert(alert: AlertPayload): string {
37
+ const lines: string[] = [];
38
+ lines.push(`🚨 *ADVERSE MOVE*`);
39
+ lines.push('');
40
+ lines.push(`*${alert.ticker}*`);
41
+ lines.push(alert.message);
42
+ return lines.join('\n');
43
+ }
44
+
45
+ function formatExpiryAlert(alert: AlertPayload): string {
46
+ const lines: string[] = [];
47
+ lines.push(`⏰ *EXPIRY APPROACHING*`);
48
+ lines.push('');
49
+ lines.push(`*${alert.ticker}*`);
50
+ lines.push(alert.message);
51
+ return lines.join('\n');
52
+ }
53
+
54
+ function formatCatalystAlert(alert: AlertPayload): string {
55
+ const lines: string[] = [];
56
+ lines.push(`📅 *CATALYST APPROACHING*`);
57
+ lines.push('');
58
+ lines.push(`*${alert.ticker}*`);
59
+ lines.push(alert.message);
60
+ return lines.join('\n');
61
+ }
62
+
63
+ function formatCircuitBreakerAlert(alert: AlertPayload): string {
64
+ const lines: string[] = [];
65
+ lines.push(`🛑 *CIRCUIT BREAKER*`);
66
+ lines.push('');
67
+ lines.push(alert.message);
68
+ lines.push('');
69
+ lines.push(`All new positions paused until risk clears.`);
70
+ return lines.join('\n');
71
+ }
72
+
73
+ export function formatAlertForWhatsApp(alert: AlertPayload): string {
74
+ switch (alert.alertType) {
75
+ case 'EDGE_DETECTED':
76
+ return formatEdgeAlert(alert);
77
+ case 'CONVERGENCE':
78
+ return formatConvergenceAlert(alert);
79
+ case 'ADVERSE_MOVE':
80
+ return formatAdverseMoveAlert(alert);
81
+ case 'EXPIRY_APPROACHING':
82
+ return formatExpiryAlert(alert);
83
+ case 'CATALYST_APPROACHING':
84
+ return formatCatalystAlert(alert);
85
+ case 'CIRCUIT_BREAKER':
86
+ return formatCircuitBreakerAlert(alert);
87
+ default:
88
+ return `*ALERT* ${alert.ticker}: ${alert.message}`;
89
+ }
90
+ }
@@ -0,0 +1,4 @@
1
+ export { AlertRouter } from './router.js';
2
+ export { formatAlertForWhatsApp } from './formatter.js';
3
+ export { formatAlertForTerminal } from './terminal.js';
4
+ export type { AlertChannelHandler, AlertChannelDispatch, PendingApproval, AlertPayload, AlertType } from './types.js';
@@ -0,0 +1,32 @@
1
+ import type { AlertPayload } from '../../scan/alerter.js';
2
+ import type { AlertChannelHandler, PendingApproval } from './types.js';
3
+
4
+ export class AlertRouter {
5
+ private handlers = new Map<string, AlertChannelHandler>();
6
+ private pending = new Map<string, PendingApproval>();
7
+
8
+ registerChannel(name: string, handler: AlertChannelHandler): void {
9
+ this.handlers.set(name, handler);
10
+ }
11
+
12
+ async route(alert: AlertPayload, target?: string): Promise<void> {
13
+ for (const channel of alert.channels) {
14
+ const handler = this.handlers.get(channel);
15
+ if (handler) {
16
+ await handler(alert, target ?? '');
17
+ }
18
+ }
19
+ }
20
+
21
+ setPending(sessionKey: string, approval: PendingApproval): void {
22
+ this.pending.set(sessionKey, approval);
23
+ }
24
+
25
+ getPending(sessionKey: string): PendingApproval | undefined {
26
+ return this.pending.get(sessionKey);
27
+ }
28
+
29
+ clearPending(sessionKey: string): void {
30
+ this.pending.delete(sessionKey);
31
+ }
32
+ }
@@ -0,0 +1,16 @@
1
+ import type { AlertPayload } from '../../scan/alerter.js';
2
+
3
+ function magnitudeBar(edge: number): string {
4
+ const absEdge = Math.abs(edge);
5
+ const pct = Math.min(absEdge * 100, 10);
6
+ const filled = Math.round(pct);
7
+ const empty = 10 - filled;
8
+ const sign = edge >= 0 ? '+' : '-';
9
+ return `[${`=`.repeat(filled)}${`-`.repeat(empty)}] ${sign}${(absEdge * 100).toFixed(1)}%`;
10
+ }
11
+
12
+ export function formatAlertForTerminal(alert: AlertPayload): string {
13
+ const ts = new Date().toISOString().slice(11, 19);
14
+ const bar = magnitudeBar(alert.edge);
15
+ return `[${ts}] [${alert.alertType}] ${alert.ticker} ${bar} — ${alert.message}`;
16
+ }
@@ -0,0 +1,13 @@
1
+ export type { AlertType, AlertPayload } from '../../scan/alerter.js';
2
+
3
+ export type AlertChannelHandler = (alert: import('../../scan/alerter.js').AlertPayload, target: string) => Promise<void>;
4
+
5
+ export type AlertChannelDispatch = (channel: string, alert: import('../../scan/alerter.js').AlertPayload) => Promise<void>;
6
+
7
+ export interface PendingApproval {
8
+ ticker: string;
9
+ alertId: string;
10
+ edge: number;
11
+ createdAt: number;
12
+ sessionKey: string;
13
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Channel extension seam:
3
+ * - Add a new channel plugin that implements ChannelPlugin<TConfig, TAccount>.
4
+ * - Register it in gateway bootstrap alongside WhatsApp.
5
+ * - Reuse the same manager lifecycle (start/stop/status) without changing core gateway flow.
6
+ */
7
+ export * from './types.js';
8
+ export * from './manager.js';
9
+
@@ -0,0 +1,153 @@
1
+ import type { ChannelPlugin, ChannelRuntimeSnapshot } from './types.js';
2
+
3
+ type RuntimeStore = {
4
+ aborts: Map<string, AbortController>;
5
+ tasks: Map<string, Promise<unknown>>;
6
+ runtimes: Map<string, ChannelRuntimeSnapshot>;
7
+ };
8
+
9
+ export type ChannelManager<TConfig, TAccount> = {
10
+ startAll: () => Promise<void>;
11
+ startAccount: (accountId: string) => Promise<void>;
12
+ stopAccount: (accountId: string) => Promise<void>;
13
+ stopAll: () => Promise<void>;
14
+ getSnapshot: () => Record<string, ChannelRuntimeSnapshot>;
15
+ };
16
+
17
+ function createRuntimeStore(): RuntimeStore {
18
+ return {
19
+ aborts: new Map(),
20
+ tasks: new Map(),
21
+ runtimes: new Map(),
22
+ };
23
+ }
24
+
25
+ export function createChannelManager<TConfig, TAccount>(params: {
26
+ plugin: ChannelPlugin<TConfig, TAccount>;
27
+ loadConfig: () => TConfig;
28
+ }): ChannelManager<TConfig, TAccount> {
29
+ const { plugin, loadConfig } = params;
30
+ const store = createRuntimeStore();
31
+
32
+ const resolveRuntime = (accountId: string): ChannelRuntimeSnapshot =>
33
+ store.runtimes.get(accountId) ?? {
34
+ accountId,
35
+ running: false,
36
+ ...(plugin.status?.defaultRuntime ?? {}),
37
+ };
38
+
39
+ const setRuntime = (
40
+ accountId: string,
41
+ next: Partial<ChannelRuntimeSnapshot>,
42
+ ): ChannelRuntimeSnapshot => {
43
+ const current = resolveRuntime(accountId);
44
+ const merged = { ...current, ...next, accountId };
45
+ store.runtimes.set(accountId, merged);
46
+ return merged;
47
+ };
48
+
49
+ const startAccount = async (accountId: string): Promise<void> => {
50
+ if (store.tasks.has(accountId)) {
51
+ return;
52
+ }
53
+
54
+ const cfg = loadConfig();
55
+ const account = plugin.config.resolveAccount(cfg, accountId);
56
+ if (plugin.config.isEnabled?.(account, cfg) === false) {
57
+ setRuntime(accountId, { running: false, lastError: 'disabled' });
58
+ return;
59
+ }
60
+ if ((await plugin.config.isConfigured?.(account, cfg)) === false) {
61
+ setRuntime(accountId, { running: false, lastError: 'not configured' });
62
+ return;
63
+ }
64
+
65
+ const abort = new AbortController();
66
+ store.aborts.set(accountId, abort);
67
+ setRuntime(accountId, {
68
+ running: true,
69
+ lastError: null,
70
+ lastStartAt: Date.now(),
71
+ });
72
+
73
+ const task = Promise.resolve(
74
+ plugin.gateway.startAccount({
75
+ accountId,
76
+ account,
77
+ abortSignal: abort.signal,
78
+ getStatus: () => resolveRuntime(accountId),
79
+ setStatus: (patch) => setRuntime(accountId, patch),
80
+ }),
81
+ )
82
+ .catch((err) => {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ setRuntime(accountId, { running: false, lastError: message });
85
+ })
86
+ .finally(() => {
87
+ store.aborts.delete(accountId);
88
+ store.tasks.delete(accountId);
89
+ setRuntime(accountId, { running: false, lastStopAt: Date.now() });
90
+ });
91
+ store.tasks.set(accountId, task);
92
+ };
93
+
94
+ const stopAccount = async (accountId: string): Promise<void> => {
95
+ const abort = store.aborts.get(accountId);
96
+ const task = store.tasks.get(accountId);
97
+ abort?.abort();
98
+ if (plugin.gateway.stopAccount) {
99
+ const cfg = loadConfig();
100
+ const account = plugin.config.resolveAccount(cfg, accountId);
101
+ await plugin.gateway.stopAccount({
102
+ accountId,
103
+ account,
104
+ abortSignal: abort?.signal ?? new AbortController().signal,
105
+ getStatus: () => resolveRuntime(accountId),
106
+ setStatus: (patch) => setRuntime(accountId, patch),
107
+ });
108
+ }
109
+ if (task) {
110
+ await task.catch(() => undefined);
111
+ }
112
+ };
113
+
114
+ const startAll = async (): Promise<void> => {
115
+ const ids = plugin.config.listAccountIds(loadConfig());
116
+ for (const id of ids) {
117
+ await startAccount(id);
118
+ }
119
+ };
120
+
121
+ const stopAll = async (): Promise<void> => {
122
+ const ids = new Set([
123
+ ...plugin.config.listAccountIds(loadConfig()),
124
+ ...store.tasks.keys(),
125
+ ...store.aborts.keys(),
126
+ ]);
127
+ for (const id of ids) {
128
+ await stopAccount(id);
129
+ }
130
+ };
131
+
132
+ const getSnapshot = (): Record<string, ChannelRuntimeSnapshot> => {
133
+ const ids = new Set([
134
+ ...plugin.config.listAccountIds(loadConfig()),
135
+ ...store.runtimes.keys(),
136
+ ...store.tasks.keys(),
137
+ ]);
138
+ const snapshot: Record<string, ChannelRuntimeSnapshot> = {};
139
+ for (const id of ids) {
140
+ snapshot[id] = resolveRuntime(id);
141
+ }
142
+ return snapshot;
143
+ };
144
+
145
+ return {
146
+ startAll,
147
+ startAccount,
148
+ stopAccount,
149
+ stopAll,
150
+ getSnapshot,
151
+ };
152
+ }
153
+
@@ -0,0 +1,48 @@
1
+ export type ChannelId = 'whatsapp';
2
+
3
+ export type ChannelRuntimeSnapshot = {
4
+ accountId: string;
5
+ running: boolean;
6
+ connected?: boolean;
7
+ lastError?: string | null;
8
+ lastStartAt?: number;
9
+ lastStopAt?: number;
10
+ };
11
+
12
+ export type ChannelStartContext<TAccount> = {
13
+ accountId: string;
14
+ account: TAccount;
15
+ abortSignal: AbortSignal;
16
+ getStatus: () => ChannelRuntimeSnapshot;
17
+ setStatus: (next: Partial<ChannelRuntimeSnapshot>) => ChannelRuntimeSnapshot;
18
+ };
19
+
20
+ export type ChannelStopContext<TAccount> = {
21
+ accountId: string;
22
+ account: TAccount;
23
+ abortSignal: AbortSignal;
24
+ getStatus: () => ChannelRuntimeSnapshot;
25
+ setStatus: (next: Partial<ChannelRuntimeSnapshot>) => ChannelRuntimeSnapshot;
26
+ };
27
+
28
+ export type ChannelConfigAdapter<TConfig, TAccount> = {
29
+ listAccountIds: (cfg: TConfig) => string[];
30
+ resolveAccount: (cfg: TConfig, accountId: string) => TAccount;
31
+ isEnabled?: (account: TAccount, cfg: TConfig) => boolean;
32
+ isConfigured?: (account: TAccount, cfg: TConfig) => Promise<boolean> | boolean;
33
+ };
34
+
35
+ export type ChannelGatewayAdapter<TAccount> = {
36
+ startAccount: (ctx: ChannelStartContext<TAccount>) => Promise<void>;
37
+ stopAccount?: (ctx: ChannelStopContext<TAccount>) => Promise<void>;
38
+ };
39
+
40
+ export type ChannelPlugin<TConfig, TAccount> = {
41
+ id: ChannelId;
42
+ config: ChannelConfigAdapter<TConfig, TAccount>;
43
+ gateway: ChannelGatewayAdapter<TAccount>;
44
+ status?: {
45
+ defaultRuntime?: ChannelRuntimeSnapshot;
46
+ };
47
+ };
48
+