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,125 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { KalshiMarket } from '../tools/kalshi/types.js';
3
+ import type { KellyResult } from './kelly.js';
4
+ import { getSpreadCents, getVolume24h } from './kelly.js';
5
+ import { isCorrelated } from './correlation.js';
6
+ import { getOpenPositions } from '../db/positions.js';
7
+ import { getLatestSnapshot } from '../db/risk.js';
8
+ import { getBotSetting } from '../utils/bot-config.js';
9
+
10
+ export interface RiskConfig {
11
+ maxSpreadCents?: number; // default 5
12
+ minVolume24h?: number; // default 500
13
+ maxPerCategory?: number; // default 3
14
+ maxTotalPositions?: number; // default 10
15
+ maxDrawdownPct?: number; // default 0.20
16
+ maxPositionPct?: number; // default 0.10
17
+ }
18
+
19
+ export interface RiskGateParams {
20
+ ticker: string;
21
+ eventTicker: string;
22
+ kelly: KellyResult;
23
+ market: KalshiMarket;
24
+ db: Database;
25
+ config?: RiskConfig;
26
+ }
27
+
28
+ export interface RiskCheck {
29
+ name: string;
30
+ passed: boolean;
31
+ reason: string;
32
+ }
33
+
34
+ export interface RiskGateResult {
35
+ passed: boolean;
36
+ checks: RiskCheck[];
37
+ }
38
+
39
+ /**
40
+ * 5-check pre-execution risk gate. All checks must pass.
41
+ */
42
+ export function riskGate(params: RiskGateParams): RiskGateResult {
43
+ const { ticker, eventTicker, kelly, market, db, config } = params;
44
+
45
+ const maxSpreadCents = config?.maxSpreadCents ?? (getBotSetting('risk.max_spread_cents') as number);
46
+ const minVolume24h = config?.minVolume24h ?? (getBotSetting('risk.min_volume_24h') as number);
47
+ const maxPerCategory = config?.maxPerCategory ?? (getBotSetting('risk.max_per_category') as number);
48
+ const maxTotalPositions = config?.maxTotalPositions ?? (getBotSetting('risk.max_positions') as number);
49
+ const maxDrawdownPct = config?.maxDrawdownPct ?? (getBotSetting('risk.max_drawdown') as number);
50
+ const maxPositionPct = config?.maxPositionPct ?? (getBotSetting('risk.max_position_pct') as number);
51
+
52
+ const checks: RiskCheck[] = [];
53
+
54
+ // 1. Kelly check — contracts > 0 and dollar amount within position limit
55
+ // Re-check against maxPositionPct independently (kelly.ts uses its own default which may differ)
56
+ const kellyMaxDollar = Math.floor(kelly.availableBankroll * maxPositionPct);
57
+ const kellyWithinLimit = kelly.dollarAmountCents <= kellyMaxDollar;
58
+ const kellyPassed = kelly.contracts > 0 && kellyWithinLimit;
59
+ checks.push({
60
+ name: 'kelly',
61
+ passed: kellyPassed,
62
+ reason: kelly.contracts === 0
63
+ ? (kelly.skippedReason
64
+ ? `Kelly produced 0 contracts: ${kelly.skippedReason}`
65
+ : `Kelly produced 0 contracts for ${ticker}`)
66
+ : !kellyWithinLimit
67
+ ? `Dollar amount $${(kelly.dollarAmountCents / 100).toFixed(2)} exceeds ${maxPositionPct * 100}% of bankroll $${(kelly.availableBankroll / 100).toFixed(2)}`
68
+ : `${kelly.contracts} ${kelly.side.toUpperCase()} contracts, $${(kelly.dollarAmountCents / 100).toFixed(2)} within limits`,
69
+ });
70
+
71
+ // 2. Liquidity check — spread and volume (using dollar-aware spread)
72
+ const spreadCents = getSpreadCents(market);
73
+ const spreadOk = spreadCents < maxSpreadCents;
74
+ const vol24h = getVolume24h(market);
75
+ const volumeOk = vol24h >= minVolume24h;
76
+ const liquidityPassed = spreadOk && volumeOk;
77
+ checks.push({
78
+ name: 'liquidity',
79
+ passed: liquidityPassed,
80
+ reason: !spreadOk
81
+ ? `Spread ${spreadCents}¢ >= max ${maxSpreadCents}¢`
82
+ : !volumeOk
83
+ ? `24h volume ${vol24h} < min ${minVolume24h}`
84
+ : `Spread ${spreadCents}¢, volume ${vol24h} OK`,
85
+ });
86
+
87
+ // 3. Correlation check — category concentration
88
+ const correlated = isCorrelated(eventTicker, db, maxPerCategory);
89
+ checks.push({
90
+ name: 'correlation',
91
+ passed: !correlated,
92
+ reason: correlated
93
+ ? `Category for ${eventTicker} already has ${maxPerCategory}+ open positions`
94
+ : `Category concentration within limit`,
95
+ });
96
+
97
+ // 4. Concentration check — total open positions
98
+ const openPositions = getOpenPositions(db);
99
+ const concentrationPassed = openPositions.length < maxTotalPositions;
100
+ checks.push({
101
+ name: 'concentration',
102
+ passed: concentrationPassed,
103
+ reason: concentrationPassed
104
+ ? `${openPositions.length} open positions < max ${maxTotalPositions}`
105
+ : `${openPositions.length} open positions >= max ${maxTotalPositions}`,
106
+ });
107
+
108
+ // 5. Drawdown check — current drawdown vs limit
109
+ const snapshot = getLatestSnapshot(db);
110
+ const drawdownPassed = snapshot?.drawdown_current == null || snapshot.drawdown_current < maxDrawdownPct;
111
+ checks.push({
112
+ name: 'drawdown',
113
+ passed: drawdownPassed,
114
+ reason: snapshot?.drawdown_current == null
115
+ ? 'No snapshot yet — first trade allowed'
116
+ : snapshot.drawdown_current < maxDrawdownPct
117
+ ? `Drawdown ${(snapshot.drawdown_current * 100).toFixed(1)}% < max ${maxDrawdownPct * 100}%`
118
+ : `Drawdown ${(snapshot.drawdown_current * 100).toFixed(1)}% >= max ${maxDrawdownPct * 100}%`,
119
+ });
120
+
121
+ return {
122
+ passed: checks.every((c) => c.passed),
123
+ checks,
124
+ };
125
+ }
@@ -0,0 +1,10 @@
1
+ export { kellySize, fetchLiveBankroll, getSpreadCents, getVolume24h } from './kelly.js';
2
+ export type { KellySizeParams, KellyResult, LiveBankroll } from './kelly.js';
3
+
4
+ export { riskGate } from './gate.js';
5
+ export type { RiskGateParams, RiskGateResult, RiskCheck, RiskConfig } from './gate.js';
6
+
7
+ export { getCorrelationByCategory, isCorrelated } from './correlation.js';
8
+
9
+ export { CircuitBreaker } from './circuit-breaker.js';
10
+ export type { CircuitBreakerConfig, CircuitBreakerStatus } from './circuit-breaker.js';
@@ -0,0 +1,230 @@
1
+ import { callKalshiApi, supportsFractional } from "../tools/kalshi/api.js";
2
+ import type {
3
+ KalshiBalance,
4
+ KalshiMarket,
5
+ KalshiPosition,
6
+ } from "../tools/kalshi/types.js";
7
+ import { getBotSetting } from "../utils/bot-config.js";
8
+
9
+ export interface KellySizeParams {
10
+ edge: number; // octagon_prob - market_prob (signed)
11
+ marketProb: number; // current Kalshi market probability
12
+ multiplier?: number; // Kelly fraction, default 0.5 (half-Kelly)
13
+ maxPositionPct?: number; // max % of bankroll per position, default 0.10
14
+ minEdgeThreshold?: number; // min absolute edge to size, default 0.05 (5%)
15
+ market?: KalshiMarket; // for liquidity adjustment (spread, volume)
16
+ }
17
+
18
+ export interface KellyResult {
19
+ side: 'yes' | 'no'; // which side to buy
20
+ fraction: number; // raw Kelly fraction (before multiplier)
21
+ adjustedFraction: number; // after multiplier + liquidity adj
22
+ contracts: number; // rounded to supported increment
23
+ dollarAmountCents: number; // contracts * entry price in cents
24
+ entryPriceCents: number; // actual entry price used (ask, not midpoint)
25
+ availableBankroll: number; // cash - open exposure (cents)
26
+ openExposure: number; // sum of market_exposure from positions (cents)
27
+ cashBalance: number; // cents
28
+ portfolioValue: number; // cents
29
+ liquidityAdjusted: boolean;
30
+ skippedReason?: string; // if contracts=0, explains why
31
+ }
32
+
33
+ export interface LiveBankroll {
34
+ cashBalance: number; // cents
35
+ portfolioValue: number; // cents
36
+ openExposure: number; // cents
37
+ availableBankroll: number; // cents
38
+ }
39
+
40
+ /**
41
+ * Fetch live bankroll from Kalshi API.
42
+ * Returns balances and open exposure in cents.
43
+ */
44
+ export async function fetchLiveBankroll(): Promise<LiveBankroll> {
45
+ const balanceRes = await callKalshiApi("GET", "/portfolio/balance");
46
+ const balance = balanceRes as unknown as KalshiBalance;
47
+
48
+ const positionsRes = await callKalshiApi("GET", "/portfolio/positions");
49
+ const positions = (positionsRes.market_positions ??
50
+ positionsRes.positions ??
51
+ []) as KalshiPosition[];
52
+
53
+ const cashBalance = balance.balance;
54
+ const portfolioValue = balance.portfolio_value;
55
+ const openExposure = positions.reduce((sum, p) => {
56
+ if (p.market_exposure_dollars != null) {
57
+ const parsed = parseFloat(String(p.market_exposure_dollars).trim());
58
+ if (Number.isFinite(parsed)) {
59
+ return sum + Math.round(parsed * 100);
60
+ }
61
+ }
62
+ return sum + (p.market_exposure ?? 0);
63
+ }, 0);
64
+ const availableBankroll = Math.max(0, cashBalance - openExposure);
65
+
66
+ return { cashBalance, portfolioValue, openExposure, availableBankroll };
67
+ }
68
+
69
+ /** Parse a dollar-string or integer-cent price field to a decimal (0-1). */
70
+ function parsePriceField(dollarStr: string | undefined, legacyDollarStr: string | undefined, centVal: number | undefined): number {
71
+ const d = dollarStr != null ? parseFloat(dollarStr) : legacyDollarStr != null ? parseFloat(legacyDollarStr) : NaN;
72
+ if (Number.isFinite(d)) return d;
73
+ if (centVal != null && Number.isFinite(centVal)) return centVal / 100;
74
+ return NaN;
75
+ }
76
+
77
+ /** Get 24h volume from a market, handling both volume_24h_fp (string) and legacy volume_24h (number). */
78
+ export function getVolume24h(market: KalshiMarket): number {
79
+ if (market.volume_24h_fp != null) {
80
+ const v = parseFloat(market.volume_24h_fp);
81
+ if (Number.isFinite(v)) return v;
82
+ }
83
+ if (market.volume_24h != null && Number.isFinite(market.volume_24h)) return market.volume_24h;
84
+ return 0;
85
+ }
86
+
87
+ /** Get the bid/ask spread in cents from a market, handling dollar-string fields. */
88
+ export function getSpreadCents(market: KalshiMarket): number {
89
+ const bid = parsePriceField(market.yes_bid_dollars, market.dollar_yes_bid, market.yes_bid);
90
+ const ask = parsePriceField(market.yes_ask_dollars, market.dollar_yes_ask, market.yes_ask);
91
+ if (Number.isFinite(bid) && Number.isFinite(ask)) return Math.round((ask - bid) * 100);
92
+ return 99; // unknown spread → treat as very wide
93
+ }
94
+
95
+ /**
96
+ * Compute Kelly-optimal position size using live Kalshi portfolio data.
97
+ * All amounts in cents (Kalshi's native unit).
98
+ *
99
+ * For YES bets (edge > 0): f* = edge / (1 - marketProb)
100
+ * For NO bets (edge < 0): f* = |edge| / marketProb
101
+ */
102
+ export async function kellySize(params: KellySizeParams): Promise<KellyResult> {
103
+ const { edge, marketProb, market } = params;
104
+ const multiplier = params.multiplier ?? (getBotSetting('risk.kelly_multiplier') as number);
105
+ const maxPositionPct = params.maxPositionPct ?? (getBotSetting('risk.max_position_pct') as number);
106
+ const minEdgeThreshold = params.minEdgeThreshold ?? (getBotSetting('risk.min_edge_threshold') as number);
107
+
108
+ const bankroll = await fetchLiveBankroll();
109
+ const { cashBalance, portfolioValue, openExposure, availableBankroll } =
110
+ bankroll;
111
+
112
+ const side: 'yes' | 'no' = edge >= 0 ? 'yes' : 'no';
113
+
114
+ // Compute executable probability from the ask price we'd actually trade at.
115
+ // YES buy → yes_ask; NO buy → no_ask expressed as YES-equivalent (1 - no_ask)
116
+ let executableProb: number | null = null;
117
+ if (market) {
118
+ if (side === 'yes') {
119
+ const ask = parsePriceField(market.yes_ask_dollars, market.dollar_yes_ask, market.yes_ask);
120
+ if (Number.isFinite(ask) && ask > 0) executableProb = ask;
121
+ } else {
122
+ const noAsk = parsePriceField(market.no_ask_dollars, market.dollar_no_ask, market.no_ask);
123
+ if (Number.isFinite(noAsk) && noAsk > 0) executableProb = 1 - noAsk;
124
+ }
125
+ }
126
+ // Fall back to midpoint if no executable quote is available
127
+ const pricingProb = executableProb ?? marketProb;
128
+
129
+ // Recompute edge relative to executable quote to avoid overstating edge;
130
+ // when no executable quote is available, use the original edge directly
131
+ // to avoid floating-point roundtrip error from (marketProb + edge) - marketProb.
132
+ const executableEdge = executableProb != null
133
+ ? (marketProb + edge) - executableProb
134
+ : edge;
135
+ const absEdge = Math.abs(executableEdge);
136
+
137
+ // Entry price from executable quote — computed early so it's available even when sizing is skipped
138
+ let entryPriceCents: number;
139
+ if (side === 'yes') {
140
+ entryPriceCents = executableProb != null ? Math.round(executableProb * 100) : Math.round(marketProb * 100);
141
+ } else {
142
+ entryPriceCents = executableProb != null ? Math.round((1 - executableProb) * 100) : Math.round((1 - marketProb) * 100);
143
+ }
144
+
145
+ const makeResult = (overrides: Partial<KellyResult> = {}): KellyResult => ({
146
+ side,
147
+ fraction: 0,
148
+ adjustedFraction: 0,
149
+ contracts: 0,
150
+ dollarAmountCents: 0,
151
+ entryPriceCents,
152
+ availableBankroll,
153
+ openExposure,
154
+ cashBalance,
155
+ portfolioValue,
156
+ liquidityAdjusted: false,
157
+ ...overrides,
158
+ });
159
+
160
+ // Minimum edge threshold — don't size if edge is within model error
161
+ if (absEdge < minEdgeThreshold) {
162
+ return makeResult({ skippedReason: `Edge ${(absEdge * 100).toFixed(1)}% below ${(minEdgeThreshold * 100).toFixed(0)}% threshold` });
163
+ }
164
+
165
+ // Guard against extreme probabilities that would cause division by zero
166
+ if (pricingProb <= 0 || pricingProb >= 1) {
167
+ return makeResult({ skippedReason: 'Extreme probability — cannot size' });
168
+ }
169
+
170
+ // Kelly formula for binary outcome using executable quote
171
+ // YES: f* = executableEdge / (1 - pricingProb) — cost is pricingProb, payoff is (1 - pricingProb)
172
+ // NO: f* = |executableEdge| / pricingProb — cost is (1 - pricingProb), payoff is pricingProb
173
+ const fraction = side === 'yes'
174
+ ? executableEdge / (1 - pricingProb)
175
+ : absEdge / pricingProb;
176
+
177
+ let adjustedFraction = fraction * multiplier;
178
+ let liquidityAdjusted = false;
179
+
180
+ // Liquidity adjustment: wide spread or low volume → apply haircut
181
+ if (market) {
182
+ const spreadCents = getSpreadCents(market);
183
+ const liqSpreadThreshold = getBotSetting('risk.liquidity_spread_threshold') as number;
184
+ const liqVolumeThreshold = getBotSetting('risk.liquidity_volume_threshold') as number;
185
+ const liqHaircut = getBotSetting('risk.liquidity_haircut') as number;
186
+ if (spreadCents > liqSpreadThreshold || getVolume24h(market) < liqVolumeThreshold) {
187
+ adjustedFraction *= liqHaircut;
188
+ liquidityAdjusted = true;
189
+ }
190
+ }
191
+
192
+ // Dollar amount before position cap
193
+ let dollarAmountCents = Math.floor(adjustedFraction * availableBankroll);
194
+
195
+ // Cap at maxPositionPct of available bankroll
196
+ const maxDollar = Math.floor(maxPositionPct * availableBankroll);
197
+ dollarAmountCents = Math.min(dollarAmountCents, maxDollar);
198
+
199
+ let contracts = 0;
200
+ if (entryPriceCents > 0 && dollarAmountCents > 0) {
201
+ if (market && supportsFractional(market) && market.tick_size > 0) {
202
+ const rawContracts = dollarAmountCents / entryPriceCents;
203
+ contracts =
204
+ Math.floor(rawContracts / market.tick_size) * market.tick_size;
205
+ } else {
206
+ contracts = Math.floor(dollarAmountCents / entryPriceCents);
207
+ }
208
+ }
209
+
210
+ const skippedReason = contracts === 0
211
+ ? (availableBankroll === 0
212
+ ? 'No available bankroll'
213
+ : entryPriceCents === 0
214
+ ? 'Entry price rounds to zero'
215
+ : 'Position too small for bankroll size')
216
+ : undefined;
217
+
218
+ // Recalculate dollar amount based on actual contracts
219
+ dollarAmountCents = contracts * entryPriceCents;
220
+
221
+ return makeResult({
222
+ fraction,
223
+ adjustedFraction,
224
+ contracts,
225
+ dollarAmountCents,
226
+ entryPriceCents,
227
+ liquidityAdjusted,
228
+ skippedReason,
229
+ });
230
+ }
@@ -0,0 +1,64 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { AuditTrail } from '../audit/trail.js';
3
+ import { createAlert, markAlertSent } from '../db/alerts.js';
4
+
5
+ export type AlertType =
6
+ | 'EDGE_DETECTED'
7
+ | 'CONVERGENCE'
8
+ | 'ADVERSE_MOVE'
9
+ | 'EXPIRY_APPROACHING'
10
+ | 'CATALYST_APPROACHING'
11
+ | 'CIRCUIT_BREAKER';
12
+
13
+ export interface AlertPayload {
14
+ ticker: string;
15
+ alertType: AlertType;
16
+ edge: number;
17
+ message: string;
18
+ channels: string[];
19
+ }
20
+
21
+ export type AlertChannelDispatch = (channel: string, alert: AlertPayload) => Promise<void>;
22
+
23
+ export class Alerter {
24
+ private db: Database;
25
+ private audit: AuditTrail;
26
+ private dispatch?: AlertChannelDispatch;
27
+
28
+ constructor(db: Database, audit: AuditTrail, dispatch?: AlertChannelDispatch) {
29
+ this.db = db;
30
+ this.audit = audit;
31
+ this.dispatch = dispatch;
32
+ }
33
+
34
+ async emit(alert: AlertPayload): Promise<void> {
35
+ const alertId = crypto.randomUUID();
36
+
37
+ createAlert(this.db, {
38
+ alert_id: alertId,
39
+ ticker: alert.ticker,
40
+ alert_type: alert.alertType,
41
+ edge: alert.edge,
42
+ message: alert.message,
43
+ channels: JSON.stringify(alert.channels),
44
+ status: 'pending',
45
+ created_at: Math.floor(Date.now() / 1000),
46
+ });
47
+
48
+ for (const channel of alert.channels) {
49
+ if (this.dispatch) {
50
+ await this.dispatch(channel, alert);
51
+ } else if (channel === 'terminal') {
52
+ console.log(`[ALERT] [${alert.alertType}] ${alert.ticker}: ${alert.message} (edge=${alert.edge.toFixed(4)})`);
53
+ }
54
+ }
55
+
56
+ markAlertSent(this.db, alertId);
57
+
58
+ this.audit.log({
59
+ type: 'ALERT_SENT',
60
+ alert_id: alertId,
61
+ channels: alert.channels,
62
+ });
63
+ }
64
+ }
@@ -0,0 +1,164 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { AuditTrail } from '../audit/trail.js';
3
+ import { callKalshiApi } from '../tools/kalshi/api.js';
4
+ import type { KalshiMarket } from '../tools/kalshi/types.js';
5
+ import { insertEdge } from '../db/edge.js';
6
+ import { OctagonClient } from './octagon-client.js';
7
+ import type { OctagonReport, OctagonVariant, ConfidenceLevel, EdgeSnapshot } from './types.js';
8
+ import { isMarketActive, parseMarketProb } from '../controllers/browse.js';
9
+
10
+ const OCTAGON_CONCURRENCY = (() => {
11
+ const parsed = parseInt(process.env.OCTAGON_CONCURRENCY ?? '5', 10);
12
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : 5;
13
+ })();
14
+
15
+ export class EdgeComputer {
16
+ private db: Database;
17
+ private audit: AuditTrail;
18
+
19
+ constructor(db: Database, audit: AuditTrail) {
20
+ this.db = db;
21
+ this.audit = audit;
22
+ }
23
+
24
+ classifyConfidence(absEdge: number): ConfidenceLevel {
25
+ if (absEdge >= 0.10) return 'very_high';
26
+ if (absEdge >= 0.05) return 'high';
27
+ if (absEdge >= 0.02) return 'moderate';
28
+ return 'low';
29
+ }
30
+
31
+ computeEdge(ticker: string, octagonReport: OctagonReport, marketProb: number): EdgeSnapshot {
32
+ const edge = octagonReport.modelProb - marketProb;
33
+ const confidence = this.classifyConfidence(Math.abs(edge));
34
+
35
+ return {
36
+ ticker,
37
+ eventTicker: octagonReport.eventTicker,
38
+ modelProb: octagonReport.modelProb,
39
+ marketProb,
40
+ edge,
41
+ confidence,
42
+ drivers: octagonReport.drivers,
43
+ catalysts: octagonReport.catalysts,
44
+ sources: octagonReport.sources,
45
+ octagonReportId: `${octagonReport.ticker}-${octagonReport.fetchedAt}`,
46
+ cacheHit: octagonReport.variantUsed === 'cache',
47
+ timestamp: Math.floor(Date.now() / 1000),
48
+ };
49
+ }
50
+
51
+ async computeAll(tickers: string[], octagonClient: OctagonClient): Promise<EdgeSnapshot[]> {
52
+ const snapshots: EdgeSnapshot[] = [];
53
+
54
+ // Phase A: Collect all market tasks (Kalshi API calls are fast, keep sequential for rate limits)
55
+ interface MarketTask {
56
+ market: KalshiMarket;
57
+ eventTicker: string;
58
+ marketProb: number;
59
+ variant: OctagonVariant;
60
+ }
61
+ const tasks: MarketTask[] = [];
62
+
63
+ for (const eventTicker of tickers) {
64
+ try {
65
+ const response = await callKalshiApi('GET', `/events/${eventTicker}`, {
66
+ params: { with_nested_markets: true },
67
+ });
68
+
69
+ const event = response.event as { markets?: KalshiMarket[] } | undefined;
70
+ const markets = (event?.markets ?? response.markets ?? []) as KalshiMarket[];
71
+
72
+ for (const market of markets) {
73
+ if (!isMarketActive(market)) continue;
74
+ const marketProb = parseMarketProb(market);
75
+ if (marketProb === null) continue; // no last traded price — skip
76
+ const { refresh } = octagonClient.shouldRefresh(market.ticker, marketProb, false, market.close_time);
77
+ tasks.push({ market, eventTicker, marketProb, variant: refresh ? 'refresh' : 'cache' });
78
+ }
79
+ } catch (err) {
80
+ this.audit.log({
81
+ type: 'OCTAGON_ERROR',
82
+ ticker: eventTicker,
83
+ event_ticker: eventTicker,
84
+ error: String(err instanceof Error ? err.message : err),
85
+ });
86
+ continue;
87
+ }
88
+ }
89
+
90
+ // Phase B: Process Octagon calls in parallel batches
91
+ // Reserve credits synchronously before fanning out to prevent concurrent
92
+ // refresh calls from overshooting the daily credit ceiling.
93
+ for (let i = 0; i < tasks.length; i += OCTAGON_CONCURRENCY) {
94
+ const batch = tasks.slice(i, i + OCTAGON_CONCURRENCY);
95
+
96
+ // Reserve credits synchronously per-task before async fan-out
97
+ const reservedBatch = batch.map((task) => ({
98
+ ...task,
99
+ reservedVariant: octagonClient.reserveRefresh(task.variant),
100
+ }));
101
+
102
+ const results = await Promise.allSettled(
103
+ reservedBatch.map(async (task) => {
104
+ // Try prefetch first to avoid individual cache API calls
105
+ const prefetched = task.reservedVariant === 'cache'
106
+ ? octagonClient.tryFromPrefetch(task.market.ticker, task.eventTicker, task.market.close_time)
107
+ : null;
108
+ const report = prefetched ?? await octagonClient.fetchReport(
109
+ task.market.ticker, task.eventTicker, task.reservedVariant,
110
+ { creditsPreReserved: true, closeTimeIso: task.market.close_time },
111
+ );
112
+ return { task, report };
113
+ }),
114
+ );
115
+
116
+ for (let i = 0; i < results.length; i++) {
117
+ const result = results[i];
118
+ if (result.status === 'rejected') {
119
+ const failedTask = reservedBatch[i];
120
+ this.audit.log({
121
+ type: 'OCTAGON_ERROR',
122
+ ticker: failedTask.market.ticker,
123
+ event_ticker: failedTask.eventTicker,
124
+ error: String(result.reason),
125
+ });
126
+ continue;
127
+ }
128
+ const { task, report } = result.value;
129
+ const snapshot = this.computeEdge(task.market.ticker, report, task.marketProb);
130
+ snapshots.push(snapshot);
131
+
132
+ insertEdge(this.db, {
133
+ ticker: snapshot.ticker,
134
+ event_ticker: snapshot.eventTicker,
135
+ timestamp: snapshot.timestamp,
136
+ model_prob: snapshot.modelProb,
137
+ market_prob: snapshot.marketProb,
138
+ edge: snapshot.edge,
139
+ octagon_report_id: snapshot.octagonReportId,
140
+ drivers_json: JSON.stringify(snapshot.drivers),
141
+ sources_json: JSON.stringify(snapshot.sources),
142
+ catalysts_json: JSON.stringify(snapshot.catalysts),
143
+ cache_hit: snapshot.cacheHit ? 1 : 0,
144
+ cache_miss: report.cacheMiss ? 1 : 0,
145
+ confidence: snapshot.confidence,
146
+ });
147
+
148
+ if (Math.abs(snapshot.edge) >= 0.02) {
149
+ this.audit.log({
150
+ type: 'EDGE_DETECTED',
151
+ ticker: snapshot.ticker,
152
+ model_prob: snapshot.modelProb,
153
+ market_prob: snapshot.marketProb,
154
+ edge: snapshot.edge,
155
+ confidence: snapshot.confidence,
156
+ drivers: snapshot.drivers.map((d) => d.claim),
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ return snapshots;
163
+ }
164
+ }