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