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,65 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { appPath } from '../../utils/paths.js';
4
+
5
+ export type SessionEntry = {
6
+ sessionKey: string;
7
+ createdAt: number;
8
+ updatedAt: number;
9
+ lastChannel?: string;
10
+ lastTo?: string;
11
+ lastAccountId?: string;
12
+ lastAgentId?: string;
13
+ };
14
+
15
+ export type SessionStore = Record<string, SessionEntry>;
16
+
17
+ export function resolveSessionStorePath(agentId: string): string {
18
+ const base = process.env.APP_SESSIONS_DIR ?? appPath('sessions');
19
+ return join(base, agentId, 'sessions.json');
20
+ }
21
+
22
+ export function loadSessionStore(path: string): SessionStore {
23
+ if (!existsSync(path)) {
24
+ return {};
25
+ }
26
+ try {
27
+ return JSON.parse(readFileSync(path, 'utf8')) as SessionStore;
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ export function saveSessionStore(path: string, store: SessionStore): void {
34
+ const dir = dirname(path);
35
+ if (!existsSync(dir)) {
36
+ mkdirSync(dir, { recursive: true });
37
+ }
38
+ writeFileSync(path, JSON.stringify(store, null, 2), 'utf8');
39
+ }
40
+
41
+ export function upsertSessionMeta(params: {
42
+ storePath: string;
43
+ sessionKey: string;
44
+ channel: string;
45
+ to: string;
46
+ accountId: string;
47
+ agentId: string;
48
+ }): SessionEntry {
49
+ const store = loadSessionStore(params.storePath);
50
+ const existing = store[params.sessionKey];
51
+ const now = Date.now();
52
+ const next: SessionEntry = {
53
+ sessionKey: params.sessionKey,
54
+ createdAt: existing?.createdAt ?? now,
55
+ updatedAt: now,
56
+ lastChannel: params.channel,
57
+ lastTo: params.to,
58
+ lastAccountId: params.accountId,
59
+ lastAgentId: params.agentId,
60
+ };
61
+ store[params.sessionKey] = next;
62
+ saveSessionStore(params.storePath, store);
63
+ return next;
64
+ }
65
+
@@ -0,0 +1,11 @@
1
+ export type InboundContext = {
2
+ channel: 'whatsapp';
3
+ accountId: string;
4
+ from: string;
5
+ to: string;
6
+ chatType: 'direct' | 'group';
7
+ body: string;
8
+ senderName?: string;
9
+ messageId?: string;
10
+ };
11
+
@@ -0,0 +1,82 @@
1
+ export function normalizeE164(number: string): string {
2
+ const withoutPrefix = number.replace(/^whatsapp:/, '').trim();
3
+ // Strip everything except digits; we deliberately ignore any number of leading '+'.
4
+ const digitsOnly = withoutPrefix.replace(/[^\d]/g, '');
5
+ if (!digitsOnly) {
6
+ return '+';
7
+ }
8
+ return `+${digitsOnly}`;
9
+ }
10
+
11
+ export function isSelfChatMode(
12
+ selfE164: string | null | undefined,
13
+ allowFrom?: Array<string | number> | null,
14
+ ): boolean {
15
+ if (!selfE164) {
16
+ return false;
17
+ }
18
+ if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
19
+ return false;
20
+ }
21
+ const normalizedSelf = normalizeE164(selfE164);
22
+ return allowFrom.some((value) => {
23
+ if (value === '*') {
24
+ return false;
25
+ }
26
+ try {
27
+ return normalizeE164(String(value)) === normalizedSelf;
28
+ } catch {
29
+ return false;
30
+ }
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Convert a phone number or JID to a WhatsApp JID suitable for sending messages.
36
+ *
37
+ * - Strips 'whatsapp:' prefix if present
38
+ * - For JIDs with @s.whatsapp.net, strips device suffix (e.g., :0)
39
+ * - For group JIDs (@g.us), returns as-is
40
+ * - Otherwise, normalizes as E.164 and converts to @s.whatsapp.net format
41
+ */
42
+ /**
43
+ * Clean up markdown for WhatsApp compatibility.
44
+ * - Converts `**text**` (markdown bold) to `*text*` (WhatsApp bold)
45
+ * - Merges adjacent bold sections to prevent literal asterisks showing
46
+ */
47
+ export function cleanMarkdownForWhatsApp(text: string): string {
48
+ let result = text;
49
+ // Convert markdown bold (**text**) to WhatsApp bold (*text*)
50
+ result = result.replace(/\*\*([^*]+)\*\*/g, '*$1*');
51
+ // Merge adjacent bold sections: `*foo* *bar*` -> `*foo bar*`
52
+ result = result.replace(/\*([^*]+)\*\s+\*([^*]+)\*/g, '*$1 $2*');
53
+ return result;
54
+ }
55
+
56
+ export function toWhatsappJid(input: string): string {
57
+ const clean = input.replace(/^whatsapp:/, '').trim();
58
+
59
+ // Handle group JIDs - return as-is
60
+ if (clean.endsWith('@g.us')) {
61
+ return clean;
62
+ }
63
+
64
+ // Handle user JIDs with @s.whatsapp.net - strip device suffix if present
65
+ if (clean.includes('@s.whatsapp.net')) {
66
+ // Extract phone number, stripping device suffix like ":0"
67
+ const atIndex = clean.indexOf('@');
68
+ const localPart = clean.slice(0, atIndex);
69
+ // Strip device suffix (e.g., "15551234567:0" -> "15551234567")
70
+ const phone = localPart.includes(':') ? localPart.split(':')[0] : localPart;
71
+ return `${phone}@s.whatsapp.net`;
72
+ }
73
+
74
+ // Handle other JIDs (like @lid) - return as-is
75
+ if (clean.includes('@')) {
76
+ return clean;
77
+ }
78
+
79
+ // Phone number - normalize and convert
80
+ const digits = normalizeE164(clean).replace(/\D/g, '');
81
+ return `${digits}@s.whatsapp.net`;
82
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bun
2
+ // Side-effect import: env.ts performs the dotenv load against the canonical
3
+ // ENV_PATH (~/.kalshi-bot/.env or CWD .env). Must run before any other module
4
+ // reads process.env.
5
+ import './utils/env.js';
6
+ import { runCli } from './cli.js';
7
+ import { parseArgs } from './commands/parse-args.js';
8
+ import { dispatch } from './commands/dispatch.js';
9
+ import { initTelemetry, trackEvent, shutdownTelemetry } from './utils/telemetry.js';
10
+ import packageJson from '../package.json';
11
+
12
+ const parsed = parseArgs();
13
+
14
+ await initTelemetry();
15
+ trackEvent('app_start', {
16
+ mode: parsed.subcommand === 'chat' || parsed.subcommand === 'init' ? 'tui' : 'cli',
17
+ command: parsed.subcommand,
18
+ version: packageJson.version,
19
+ });
20
+
21
+ if (parsed.subcommand === 'chat') {
22
+ await runCli();
23
+ await shutdownTelemetry();
24
+ } else if (parsed.subcommand === 'init') {
25
+ await runCli({ forceSetup: true });
26
+ await shutdownTelemetry();
27
+ } else {
28
+ await dispatch(parsed);
29
+ await shutdownTelemetry();
30
+ }
@@ -0,0 +1,247 @@
1
+ import { AIMessage } from '@langchain/core/messages';
2
+ import { ChatOpenAI } from '@langchain/openai';
3
+ import { ChatAnthropic } from '@langchain/anthropic';
4
+ import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
5
+ import { ChatOllama } from '@langchain/ollama';
6
+ import { ChatPromptTemplate } from '@langchain/core/prompts';
7
+ import { SystemMessage, HumanMessage } from '@langchain/core/messages';
8
+ import { BaseChatModel } from '@langchain/core/language_models/chat_models';
9
+ import { StructuredToolInterface } from '@langchain/core/tools';
10
+ import { Runnable } from '@langchain/core/runnables';
11
+ import { z } from 'zod';
12
+ import { DEFAULT_SYSTEM_PROMPT } from '@/agent/prompts';
13
+ import type { TokenUsage } from '@/agent/types';
14
+ import { logger } from '@/utils';
15
+ import { classifyError, isNonRetryableError } from '@/utils/errors';
16
+ import { resolveProvider, getProviderById } from '@/providers';
17
+
18
+ export const DEFAULT_PROVIDER = 'openai';
19
+ export const DEFAULT_MODEL = process.env.DEFAULT_MODEL ?? 'gpt-5.4';
20
+
21
+ /**
22
+ * Gets the fast model variant for the given provider.
23
+ * Falls back to the provided model if no fast variant is configured (e.g., Ollama).
24
+ */
25
+ export function getFastModel(modelProvider: string, fallbackModel: string): string {
26
+ return getProviderById(modelProvider)?.fastModel ?? fallbackModel;
27
+ }
28
+
29
+ // Generic retry helper with exponential backoff
30
+ async function withRetry<T>(fn: () => Promise<T>, provider: string, maxAttempts = 3): Promise<T> {
31
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
32
+ try {
33
+ return await fn();
34
+ } catch (e) {
35
+ const message = e instanceof Error ? e.message : String(e);
36
+ const errorType = classifyError(message);
37
+ logger.error(`[${provider} API] ${errorType} error (attempt ${attempt + 1}/${maxAttempts}): ${message}`);
38
+
39
+ if (isNonRetryableError(message)) {
40
+ throw new Error(`[${provider} API] ${message}`);
41
+ }
42
+
43
+ if (attempt === maxAttempts - 1) {
44
+ throw new Error(`[${provider} API] ${message}`);
45
+ }
46
+ await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
47
+ }
48
+ }
49
+ throw new Error('Unreachable');
50
+ }
51
+
52
+ // Model provider configuration
53
+ interface ModelOpts {
54
+ streaming: boolean;
55
+ }
56
+
57
+ type ModelFactory = (name: string, opts: ModelOpts) => BaseChatModel;
58
+
59
+ function getApiKey(envVar: string): string {
60
+ const apiKey = process.env[envVar];
61
+ if (!apiKey) {
62
+ throw new Error(`[LLM] ${envVar} not found in environment variables`);
63
+ }
64
+ return apiKey;
65
+ }
66
+
67
+ // Factories keyed by provider id — prefix routing is handled by resolveProvider()
68
+ const MODEL_FACTORIES: Record<string, ModelFactory> = {
69
+ anthropic: (name, opts) =>
70
+ new ChatAnthropic({
71
+ model: name,
72
+ ...opts,
73
+ apiKey: getApiKey('ANTHROPIC_API_KEY'),
74
+ }),
75
+ google: (name, opts) =>
76
+ new ChatGoogleGenerativeAI({
77
+ model: name,
78
+ ...opts,
79
+ apiKey: getApiKey('GOOGLE_API_KEY'),
80
+ }),
81
+ xai: (name, opts) =>
82
+ new ChatOpenAI({
83
+ model: name,
84
+ ...opts,
85
+ apiKey: getApiKey('XAI_API_KEY'),
86
+ configuration: {
87
+ baseURL: 'https://api.x.ai/v1',
88
+ },
89
+ }),
90
+ openrouter: (name, opts) =>
91
+ new ChatOpenAI({
92
+ model: name.replace(/^openrouter:/, ''),
93
+ ...opts,
94
+ apiKey: getApiKey('OPENROUTER_API_KEY'),
95
+ configuration: {
96
+ baseURL: 'https://openrouter.ai/api/v1',
97
+ },
98
+ }),
99
+ moonshot: (name, opts) =>
100
+ new ChatOpenAI({
101
+ model: name,
102
+ ...opts,
103
+ apiKey: getApiKey('MOONSHOT_API_KEY'),
104
+ configuration: {
105
+ baseURL: 'https://api.moonshot.cn/v1',
106
+ },
107
+ }),
108
+ deepseek: (name, opts) =>
109
+ new ChatOpenAI({
110
+ model: name,
111
+ ...opts,
112
+ apiKey: getApiKey('DEEPSEEK_API_KEY'),
113
+ configuration: {
114
+ baseURL: 'https://api.deepseek.com',
115
+ },
116
+ }),
117
+ ollama: (name, opts) =>
118
+ new ChatOllama({
119
+ model: name.replace(/^ollama:/, ''),
120
+ ...opts,
121
+ ...(process.env.OLLAMA_BASE_URL ? { baseUrl: process.env.OLLAMA_BASE_URL } : {}),
122
+ }),
123
+ };
124
+
125
+ const DEFAULT_FACTORY: ModelFactory = (name, opts) =>
126
+ new ChatOpenAI({
127
+ model: name,
128
+ ...opts,
129
+ apiKey: getApiKey('OPENAI_API_KEY'),
130
+ });
131
+
132
+ export function getChatModel(
133
+ modelName: string = DEFAULT_MODEL,
134
+ streaming: boolean = false
135
+ ): BaseChatModel {
136
+ const opts: ModelOpts = { streaming };
137
+ const provider = resolveProvider(modelName);
138
+ const factory = MODEL_FACTORIES[provider.id] ?? DEFAULT_FACTORY;
139
+ return factory(modelName, opts);
140
+ }
141
+
142
+ interface CallLlmOptions {
143
+ model?: string;
144
+ systemPrompt?: string;
145
+ outputSchema?: z.ZodType<unknown>;
146
+ tools?: StructuredToolInterface[];
147
+ toolChoice?: 'required' | 'auto' | 'none';
148
+ signal?: AbortSignal;
149
+ }
150
+
151
+ export interface LlmResult {
152
+ response: AIMessage | string;
153
+ usage?: TokenUsage;
154
+ }
155
+
156
+ function extractUsage(result: unknown): TokenUsage | undefined {
157
+ if (!result || typeof result !== 'object') return undefined;
158
+ const msg = result as Record<string, unknown>;
159
+
160
+ const usageMetadata = msg.usage_metadata;
161
+ if (usageMetadata && typeof usageMetadata === 'object') {
162
+ const u = usageMetadata as Record<string, unknown>;
163
+ const input = typeof u.input_tokens === 'number' ? u.input_tokens : 0;
164
+ const output = typeof u.output_tokens === 'number' ? u.output_tokens : 0;
165
+ const total = typeof u.total_tokens === 'number' ? u.total_tokens : input + output;
166
+ return { inputTokens: input, outputTokens: output, totalTokens: total };
167
+ }
168
+
169
+ const responseMetadata = msg.response_metadata;
170
+ if (responseMetadata && typeof responseMetadata === 'object') {
171
+ const rm = responseMetadata as Record<string, unknown>;
172
+ if (rm.usage && typeof rm.usage === 'object') {
173
+ const u = rm.usage as Record<string, unknown>;
174
+ const input = typeof u.prompt_tokens === 'number' ? u.prompt_tokens : 0;
175
+ const output = typeof u.completion_tokens === 'number' ? u.completion_tokens : 0;
176
+ const total = typeof u.total_tokens === 'number' ? u.total_tokens : input + output;
177
+ return { inputTokens: input, outputTokens: output, totalTokens: total };
178
+ }
179
+ }
180
+
181
+ return undefined;
182
+ }
183
+
184
+ /**
185
+ * Build messages with Anthropic cache_control on the system prompt.
186
+ * Marks the system prompt as ephemeral so Anthropic caches the prefix,
187
+ * reducing input token costs by ~90% on subsequent calls.
188
+ */
189
+ function buildAnthropicMessages(systemPrompt: string, userPrompt: string) {
190
+ return [
191
+ new SystemMessage({
192
+ content: [
193
+ {
194
+ type: 'text' as const,
195
+ text: systemPrompt,
196
+ cache_control: { type: 'ephemeral' },
197
+ },
198
+ ],
199
+ }),
200
+ new HumanMessage(userPrompt),
201
+ ];
202
+ }
203
+
204
+ export async function callLlm(prompt: string, options: CallLlmOptions = {}): Promise<LlmResult> {
205
+ const { model = DEFAULT_MODEL, systemPrompt, outputSchema, tools, toolChoice, signal } = options;
206
+ const finalSystemPrompt = systemPrompt || DEFAULT_SYSTEM_PROMPT;
207
+
208
+ const llm = getChatModel(model, false);
209
+
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
211
+ let runnable: Runnable<any, any> = llm;
212
+
213
+ if (outputSchema) {
214
+ runnable = llm.withStructuredOutput(outputSchema, { strict: false });
215
+ } else if (tools && tools.length > 0 && llm.bindTools) {
216
+ const provider = resolveProvider(model);
217
+ // Anthropic uses 'any' instead of 'required' for forced tool calling
218
+ const resolvedChoice = toolChoice === 'required' && provider.id === 'anthropic' ? 'any' : toolChoice;
219
+ runnable = resolvedChoice ? llm.bindTools(tools, { tool_choice: resolvedChoice }) : llm.bindTools(tools);
220
+ }
221
+
222
+ const invokeOpts = signal ? { signal } : undefined;
223
+ const provider = resolveProvider(model);
224
+ let result;
225
+
226
+ if (provider.id === 'anthropic') {
227
+ // Anthropic: use explicit messages with cache_control for prompt caching (~90% savings)
228
+ const messages = buildAnthropicMessages(finalSystemPrompt, prompt);
229
+ result = await withRetry(() => runnable.invoke(messages, invokeOpts), provider.displayName);
230
+ } else {
231
+ // Other providers: use ChatPromptTemplate (OpenAI/Gemini have automatic caching)
232
+ const promptTemplate = ChatPromptTemplate.fromMessages([
233
+ ['system', finalSystemPrompt],
234
+ ['user', '{prompt}'],
235
+ ]);
236
+ const chain = promptTemplate.pipe(runnable);
237
+ result = await withRetry(() => chain.invoke({ prompt }, invokeOpts), provider.displayName);
238
+ }
239
+ const usage = extractUsage(result);
240
+
241
+ // If no outputSchema and no tools, extract content from AIMessage
242
+ // When tools are provided, return the full AIMessage to preserve tool_calls
243
+ if (!outputSchema && !tools && result && typeof result === 'object' && 'content' in result) {
244
+ return { response: (result as { content: string }).content, usage };
245
+ }
246
+ return { response: result as AIMessage, usage };
247
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Canonical provider registry — single source of truth for all provider metadata.
3
+ * When adding a new provider, add a single entry here; all other modules derive from this.
4
+ */
5
+
6
+ export interface ProviderDef {
7
+ /** Slug used in config/settings (e.g., 'anthropic') */
8
+ id: string;
9
+ /** Human-readable name (e.g., 'Anthropic') */
10
+ displayName: string;
11
+ /** Model name prefix used for routing (e.g., 'claude-'). Empty string for default (OpenAI). */
12
+ modelPrefix: string;
13
+ /** Environment variable name for API key. Omit for local providers (e.g., Ollama). */
14
+ apiKeyEnvVar?: string;
15
+ /** Fast model variant for lightweight tasks like summarization. */
16
+ fastModel?: string;
17
+ }
18
+
19
+ export const PROVIDERS: ProviderDef[] = [
20
+ {
21
+ id: 'openai',
22
+ displayName: 'OpenAI',
23
+ modelPrefix: '',
24
+ apiKeyEnvVar: 'OPENAI_API_KEY',
25
+ fastModel: 'gpt-4.1',
26
+ },
27
+ {
28
+ id: 'anthropic',
29
+ displayName: 'Anthropic',
30
+ modelPrefix: 'claude-',
31
+ apiKeyEnvVar: 'ANTHROPIC_API_KEY',
32
+ fastModel: 'claude-haiku-4-5',
33
+ },
34
+ {
35
+ id: 'google',
36
+ displayName: 'Google',
37
+ modelPrefix: 'gemini-',
38
+ apiKeyEnvVar: 'GOOGLE_API_KEY',
39
+ fastModel: 'gemini-3-flash-preview',
40
+ },
41
+ {
42
+ id: 'xai',
43
+ displayName: 'xAI',
44
+ modelPrefix: 'grok-',
45
+ apiKeyEnvVar: 'XAI_API_KEY',
46
+ fastModel: 'grok-4-1-fast-reasoning',
47
+ },
48
+ {
49
+ id: 'moonshot',
50
+ displayName: 'Moonshot',
51
+ modelPrefix: 'kimi-',
52
+ apiKeyEnvVar: 'MOONSHOT_API_KEY',
53
+ fastModel: 'kimi-k2-5',
54
+ },
55
+ {
56
+ id: 'deepseek',
57
+ displayName: 'DeepSeek',
58
+ modelPrefix: 'deepseek-',
59
+ apiKeyEnvVar: 'DEEPSEEK_API_KEY',
60
+ fastModel: 'deepseek-chat',
61
+ },
62
+ {
63
+ id: 'openrouter',
64
+ displayName: 'OpenRouter',
65
+ modelPrefix: 'openrouter:',
66
+ apiKeyEnvVar: 'OPENROUTER_API_KEY',
67
+ fastModel: 'openrouter:openai/gpt-4o-mini',
68
+ },
69
+ {
70
+ id: 'ollama',
71
+ displayName: 'Ollama',
72
+ modelPrefix: 'ollama:',
73
+ },
74
+ ];
75
+
76
+ const defaultProvider = PROVIDERS.find((p) => p.id === 'openai')!;
77
+
78
+ /**
79
+ * Resolve the provider for a given model name based on its prefix.
80
+ * Falls back to OpenAI when no prefix matches.
81
+ */
82
+ export function resolveProvider(modelName: string): ProviderDef {
83
+ return (
84
+ PROVIDERS.find((p) => p.modelPrefix && modelName.startsWith(p.modelPrefix)) ??
85
+ defaultProvider
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Look up a provider by its slug (e.g., 'anthropic', 'google').
91
+ */
92
+ export function getProviderById(id: string): ProviderDef | undefined {
93
+ return PROVIDERS.find((p) => p.id === id);
94
+ }
@@ -0,0 +1,113 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import { fetchLiveBankroll } from './kelly.js';
3
+ import { insertRiskSnapshot, getLatestSnapshot, getDrawdownHistory } from '../db/risk.js';
4
+ import type { RiskSnapshot } from '../db/risk.js';
5
+
6
+ export interface CircuitBreakerConfig {
7
+ dailyLossLimit?: number; // cents, default 5000 ($50)
8
+ maxDrawdown?: number; // fraction, default 0.20
9
+ }
10
+
11
+ export interface CircuitBreakerStatus {
12
+ active: boolean;
13
+ reason?: string;
14
+ }
15
+
16
+ export class CircuitBreaker {
17
+ private config: Required<CircuitBreakerConfig>;
18
+
19
+ constructor(config?: CircuitBreakerConfig) {
20
+ this.config = {
21
+ dailyLossLimit: config?.dailyLossLimit ?? 5000,
22
+ maxDrawdown: config?.maxDrawdown ?? 0.20,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Check if circuit breaker should be active.
28
+ * Reads latest risk snapshot — does not call external APIs.
29
+ */
30
+ check(db: Database): CircuitBreakerStatus {
31
+ const snapshot = getLatestSnapshot(db);
32
+ if (!snapshot) return { active: false };
33
+
34
+ // Check daily P&L loss limit
35
+ if (snapshot.daily_pnl != null && snapshot.daily_pnl < -this.config.dailyLossLimit) {
36
+ return {
37
+ active: true,
38
+ reason: `Daily P&L ${snapshot.daily_pnl} cents exceeds loss limit of -${this.config.dailyLossLimit} cents`,
39
+ };
40
+ }
41
+
42
+ // Check drawdown
43
+ if (snapshot.drawdown_current != null && snapshot.drawdown_current >= this.config.maxDrawdown) {
44
+ return {
45
+ active: true,
46
+ reason: `Drawdown ${(snapshot.drawdown_current * 100).toFixed(1)}% >= max ${this.config.maxDrawdown * 100}%`,
47
+ };
48
+ }
49
+
50
+ return { active: false };
51
+ }
52
+
53
+ /**
54
+ * Take a fresh snapshot: fetch live bankroll, compute drawdown vs
55
+ * portfolio high-water mark from risk_snapshots history, insert new snapshot.
56
+ */
57
+ async snapshot(db: Database): Promise<RiskSnapshot> {
58
+ const bankroll = await fetchLiveBankroll();
59
+
60
+ // Compute high-water mark from history
61
+ const dayAgo = Math.floor(Date.now() / 1000) - 86400;
62
+ const history = getDrawdownHistory(db, dayAgo);
63
+
64
+ let highWaterMark = bankroll.portfolioValue;
65
+ let dailyPnl = 0;
66
+
67
+ if (history.length > 0) {
68
+ // High-water mark is max portfolio_value across all snapshots
69
+ for (const h of history) {
70
+ if (h.portfolio_value != null && h.portfolio_value > highWaterMark) {
71
+ highWaterMark = h.portfolio_value;
72
+ }
73
+ }
74
+
75
+ // Daily P&L = current portfolio value - earliest snapshot's portfolio value in the window
76
+ const earliest = history[0];
77
+ if (earliest.portfolio_value != null) {
78
+ dailyPnl = bankroll.portfolioValue - earliest.portfolio_value;
79
+ }
80
+ }
81
+
82
+ // Drawdown = (high_water - current) / high_water
83
+ const drawdownCurrent = highWaterMark > 0
84
+ ? (highWaterMark - bankroll.portfolioValue) / highWaterMark
85
+ : 0;
86
+
87
+ // Max drawdown is the worst we've seen
88
+ const latestSnapshot = getLatestSnapshot(db);
89
+ const drawdownMax = Math.max(
90
+ drawdownCurrent,
91
+ latestSnapshot?.drawdown_max ?? 0
92
+ );
93
+
94
+ const now = Math.floor(Date.now() / 1000);
95
+ const cbStatus = this.check(db);
96
+
97
+ const snapshot: RiskSnapshot = {
98
+ timestamp: now,
99
+ cash_balance: bankroll.cashBalance,
100
+ portfolio_value: bankroll.portfolioValue,
101
+ open_exposure: bankroll.openExposure,
102
+ available_bankroll: bankroll.availableBankroll,
103
+ daily_pnl: dailyPnl,
104
+ drawdown_current: drawdownCurrent,
105
+ drawdown_max: drawdownMax,
106
+ positions_count: null, // caller can set if needed
107
+ circuit_breaker_on: cbStatus.active ? 1 : 0,
108
+ };
109
+
110
+ insertRiskSnapshot(db, snapshot);
111
+ return snapshot;
112
+ }
113
+ }
@@ -0,0 +1,40 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ /**
4
+ * Count open positions per event category by joining positions (status='open')
5
+ * with events on event_ticker.
6
+ */
7
+ export function getCorrelationByCategory(db: Database): Map<string, number> {
8
+ const rows = db.query(`
9
+ SELECT e.category, COUNT(*) as cnt
10
+ FROM positions p
11
+ JOIN events e ON p.event_ticker = e.ticker
12
+ WHERE p.status = 'open' AND e.category IS NOT NULL
13
+ GROUP BY e.category
14
+ `).all() as Array<{ category: string; cnt: number }>;
15
+
16
+ const map = new Map<string, number>();
17
+ for (const row of rows) {
18
+ map.set(row.category, row.cnt);
19
+ }
20
+ return map;
21
+ }
22
+
23
+ /**
24
+ * Check if adding a position to the given event's category would exceed the limit.
25
+ */
26
+ export function isCorrelated(
27
+ eventTicker: string,
28
+ db: Database,
29
+ maxPerCategory = 3
30
+ ): boolean {
31
+ const event = db.query('SELECT category FROM events WHERE ticker = $ticker').get({
32
+ $ticker: eventTicker,
33
+ }) as { category: string | null } | null;
34
+
35
+ if (!event?.category) return false;
36
+
37
+ const counts = getCorrelationByCategory(db);
38
+ const current = counts.get(event.category) ?? 0;
39
+ return current >= maxPerCategory;
40
+ }