kalshi-trading-bot-cli 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/assets/kalshi-flow-light.png +0 -0
- package/assets/screenshot.png +0 -0
- package/env.example +43 -0
- package/kalshi-flow-light.png +0 -0
- package/package.json +66 -0
- package/src/agent/agent.ts +249 -0
- package/src/agent/channels.ts +53 -0
- package/src/agent/index.ts +29 -0
- package/src/agent/prompts.ts +171 -0
- package/src/agent/run-context.ts +23 -0
- package/src/agent/scratchpad.ts +465 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/tool-executor.ts +166 -0
- package/src/agent/types.ts +221 -0
- package/src/audit/index.ts +25 -0
- package/src/audit/reader.ts +43 -0
- package/src/audit/trail.ts +29 -0
- package/src/audit/types.ts +133 -0
- package/src/backtest/discovery.ts +170 -0
- package/src/backtest/fetcher.ts +247 -0
- package/src/backtest/metrics.ts +165 -0
- package/src/backtest/renderer.ts +196 -0
- package/src/backtest/types.ts +45 -0
- package/src/cli.ts +943 -0
- package/src/commands/alerts.ts +48 -0
- package/src/commands/analyze.ts +662 -0
- package/src/commands/backtest.ts +276 -0
- package/src/commands/clear-cache.ts +24 -0
- package/src/commands/config.ts +107 -0
- package/src/commands/dispatch.ts +473 -0
- package/src/commands/edge.ts +62 -0
- package/src/commands/formatters.ts +339 -0
- package/src/commands/help.ts +263 -0
- package/src/commands/helpers.ts +48 -0
- package/src/commands/index.ts +287 -0
- package/src/commands/json.ts +43 -0
- package/src/commands/parse-args.ts +229 -0
- package/src/commands/portfolio.ts +236 -0
- package/src/commands/review.ts +176 -0
- package/src/commands/scan-formatters.ts +98 -0
- package/src/commands/scan.ts +38 -0
- package/src/commands/search-edge.ts +139 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/themes.ts +117 -0
- package/src/commands/watch.ts +295 -0
- package/src/components/answer-box.ts +57 -0
- package/src/components/approval-prompt.ts +34 -0
- package/src/components/browse-list.ts +134 -0
- package/src/components/chat-log.ts +291 -0
- package/src/components/custom-editor.ts +18 -0
- package/src/components/debug-panel.ts +52 -0
- package/src/components/index.ts +17 -0
- package/src/components/intro.ts +92 -0
- package/src/components/select-list.ts +155 -0
- package/src/components/tool-event.ts +127 -0
- package/src/components/user-query.ts +18 -0
- package/src/components/working-indicator.ts +87 -0
- package/src/controllers/agent-runner.ts +283 -0
- package/src/controllers/browse.ts +1013 -0
- package/src/controllers/index.ts +7 -0
- package/src/controllers/input-history.ts +76 -0
- package/src/controllers/model-selection.ts +244 -0
- package/src/db/alerts.ts +77 -0
- package/src/db/edge.ts +105 -0
- package/src/db/event-index.ts +323 -0
- package/src/db/events.ts +41 -0
- package/src/db/index.ts +60 -0
- package/src/db/octagon-cache.ts +118 -0
- package/src/db/positions.ts +71 -0
- package/src/db/risk.ts +51 -0
- package/src/db/schema.ts +227 -0
- package/src/db/themes.ts +34 -0
- package/src/db/trades.ts +50 -0
- package/src/eval/brier.ts +90 -0
- package/src/eval/index.ts +4 -0
- package/src/eval/performance.ts +87 -0
- package/src/gateway/access-control.ts +253 -0
- package/src/gateway/agent-runner.ts +75 -0
- package/src/gateway/alerts/formatter.ts +90 -0
- package/src/gateway/alerts/index.ts +4 -0
- package/src/gateway/alerts/router.ts +32 -0
- package/src/gateway/alerts/terminal.ts +16 -0
- package/src/gateway/alerts/types.ts +13 -0
- package/src/gateway/channels/index.ts +9 -0
- package/src/gateway/channels/manager.ts +153 -0
- package/src/gateway/channels/types.ts +48 -0
- package/src/gateway/channels/whatsapp/README.md +234 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
- package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
- package/src/gateway/channels/whatsapp/error.ts +122 -0
- package/src/gateway/channels/whatsapp/inbound.ts +326 -0
- package/src/gateway/channels/whatsapp/index.ts +5 -0
- package/src/gateway/channels/whatsapp/lid.ts +56 -0
- package/src/gateway/channels/whatsapp/logger.ts +25 -0
- package/src/gateway/channels/whatsapp/login.ts +94 -0
- package/src/gateway/channels/whatsapp/outbound.ts +119 -0
- package/src/gateway/channels/whatsapp/plugin.ts +54 -0
- package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
- package/src/gateway/channels/whatsapp/runtime.ts +122 -0
- package/src/gateway/channels/whatsapp/session.ts +89 -0
- package/src/gateway/channels/whatsapp/types.ts +32 -0
- package/src/gateway/commands/handler.ts +64 -0
- package/src/gateway/commands/index.ts +7 -0
- package/src/gateway/commands/parser.ts +29 -0
- package/src/gateway/commands/wa-formatters.ts +92 -0
- package/src/gateway/config.ts +244 -0
- package/src/gateway/extension-points.ts +17 -0
- package/src/gateway/gateway.ts +301 -0
- package/src/gateway/group/history-buffer.ts +75 -0
- package/src/gateway/group/index.ts +8 -0
- package/src/gateway/group/member-tracker.ts +60 -0
- package/src/gateway/group/mention-detection.ts +42 -0
- package/src/gateway/heartbeat/index.ts +8 -0
- package/src/gateway/heartbeat/prompt.ts +73 -0
- package/src/gateway/heartbeat/runner.ts +200 -0
- package/src/gateway/heartbeat/suppression.ts +74 -0
- package/src/gateway/index.ts +138 -0
- package/src/gateway/routing/resolve-route.ts +119 -0
- package/src/gateway/sessions/store.ts +65 -0
- package/src/gateway/types.ts +11 -0
- package/src/gateway/utils.ts +82 -0
- package/src/index.tsx +30 -0
- package/src/model/llm.ts +247 -0
- package/src/providers.ts +94 -0
- package/src/risk/circuit-breaker.ts +113 -0
- package/src/risk/correlation.ts +40 -0
- package/src/risk/gate.ts +125 -0
- package/src/risk/index.ts +10 -0
- package/src/risk/kelly.ts +230 -0
- package/src/scan/alerter.ts +64 -0
- package/src/scan/edge-computer.ts +164 -0
- package/src/scan/invoker.ts +199 -0
- package/src/scan/loop.ts +184 -0
- package/src/scan/octagon-client.ts +627 -0
- package/src/scan/octagon-events-api.ts +105 -0
- package/src/scan/octagon-prefetch.ts +172 -0
- package/src/scan/theme-resolver.ts +179 -0
- package/src/scan/types.ts +62 -0
- package/src/scan/watchdog.ts +126 -0
- package/src/setup/wizard.ts +659 -0
- package/src/theme.ts +67 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +419 -0
- package/src/tools/index.ts +10 -0
- package/src/tools/kalshi/api.ts +251 -0
- package/src/tools/kalshi/dlq.ts +35 -0
- package/src/tools/kalshi/events.ts +84 -0
- package/src/tools/kalshi/exchange.ts +24 -0
- package/src/tools/kalshi/historical.ts +89 -0
- package/src/tools/kalshi/index.ts +11 -0
- package/src/tools/kalshi/kalshi-search.ts +437 -0
- package/src/tools/kalshi/kalshi-trade.ts +102 -0
- package/src/tools/kalshi/markets.ts +76 -0
- package/src/tools/kalshi/portfolio.ts +100 -0
- package/src/tools/kalshi/search-index.ts +198 -0
- package/src/tools/kalshi/series.ts +16 -0
- package/src/tools/kalshi/trading.ts +115 -0
- package/src/tools/kalshi/types.ts +199 -0
- package/src/tools/registry.ts +160 -0
- package/src/tools/search/index.ts +25 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/types.ts +53 -0
- package/src/tools/v2/edge-query.ts +135 -0
- package/src/tools/v2/octagon-report.ts +112 -0
- package/src/tools/v2/portfolio-query.ts +79 -0
- package/src/tools/v2/portfolio-review.ts +59 -0
- package/src/tools/v2/risk-status.ts +94 -0
- package/src/tools/v2/scan.ts +78 -0
- package/src/types/qrcode-terminal.d.ts +7 -0
- package/src/types/whiskeysockets-baileys.d.ts +41 -0
- package/src/types.ts +22 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/bot-config.ts +219 -0
- package/src/utils/cache.ts +195 -0
- package/src/utils/config.ts +113 -0
- package/src/utils/env.ts +111 -0
- package/src/utils/errors.ts +313 -0
- package/src/utils/history-context.ts +32 -0
- package/src/utils/in-memory-chat-history.ts +268 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/model.ts +70 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/paths.ts +12 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/telemetry.ts +103 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +18 -0
- package/src/utils/tokens.ts +36 -0
- package/src/utils/tool-description.ts +61 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import type { KalshiEvent, KalshiMarket } from '../tools/kalshi/types.js';
|
|
3
|
+
|
|
4
|
+
export interface IndexedEvent {
|
|
5
|
+
event_ticker: string;
|
|
6
|
+
series_ticker: string | null;
|
|
7
|
+
title: string;
|
|
8
|
+
category: string | null;
|
|
9
|
+
strike_date: string | null;
|
|
10
|
+
sub_title: string | null;
|
|
11
|
+
tags: string | null;
|
|
12
|
+
markets_json: string | null;
|
|
13
|
+
indexed_at: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Search the local event index using keyword matching.
|
|
18
|
+
* All keywords must match against title, event_ticker, series_ticker, or category.
|
|
19
|
+
* Returns up to `limit` results.
|
|
20
|
+
*/
|
|
21
|
+
export function searchEventIndex(db: Database, query: string, limit = 50): IndexedEvent[] {
|
|
22
|
+
const keywords = query
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.split(/\s+/)
|
|
25
|
+
.filter((k) => k.length > 0);
|
|
26
|
+
|
|
27
|
+
if (keywords.length === 0) return [];
|
|
28
|
+
|
|
29
|
+
// Build WHERE clause: each keyword must match somewhere in the searchable fields
|
|
30
|
+
const conditions = keywords.map((_, i) => `(search_text LIKE $kw${i})`);
|
|
31
|
+
const whereClause = conditions.join(' AND ');
|
|
32
|
+
|
|
33
|
+
const params: Record<string, string | number> = { $limit: limit, $now: new Date().toISOString() };
|
|
34
|
+
keywords.forEach((kw, i) => {
|
|
35
|
+
params[`$kw${i}`] = `%${kw}%`;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Use a CTE to compute search_text, filter expired markets, and rank by open-market volume descending
|
|
39
|
+
const fullSql = `
|
|
40
|
+
WITH indexed AS (
|
|
41
|
+
SELECT *,
|
|
42
|
+
lower(title) || ' ' || lower(coalesce(event_ticker,'')) || ' ' || lower(coalesce(series_ticker,'')) || ' ' || lower(coalesce(category,'')) || ' ' || lower(coalesce(sub_title,'')) || ' ' || lower(coalesce(tags,'')) AS search_text
|
|
43
|
+
FROM event_index
|
|
44
|
+
),
|
|
45
|
+
matched AS (
|
|
46
|
+
SELECT event_ticker, series_ticker, title, category, strike_date, sub_title, tags, markets_json, indexed_at
|
|
47
|
+
FROM indexed
|
|
48
|
+
WHERE ${whereClause}
|
|
49
|
+
)
|
|
50
|
+
SELECT *
|
|
51
|
+
FROM matched
|
|
52
|
+
ORDER BY (
|
|
53
|
+
SELECT coalesce(sum(
|
|
54
|
+
CASE WHEN json_extract(value, '$.status') IN ('open','active')
|
|
55
|
+
AND (json_extract(value, '$.close_time') IS NULL OR json_extract(value, '$.close_time') > $now)
|
|
56
|
+
THEN json_extract(value, '$.volume')
|
|
57
|
+
ELSE 0
|
|
58
|
+
END
|
|
59
|
+
), 0)
|
|
60
|
+
FROM json_each(markets_json)
|
|
61
|
+
) DESC
|
|
62
|
+
LIMIT $limit
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
return db.query(fullSql).all(params) as IndexedEvent[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clear and repopulate the event index in a single transaction.
|
|
70
|
+
*/
|
|
71
|
+
export function clearAndPopulateIndex(
|
|
72
|
+
db: Database,
|
|
73
|
+
events: Array<{
|
|
74
|
+
event_ticker: string;
|
|
75
|
+
series_ticker?: string;
|
|
76
|
+
title: string;
|
|
77
|
+
category?: string;
|
|
78
|
+
strike_date?: string;
|
|
79
|
+
sub_title?: string;
|
|
80
|
+
tags?: string[];
|
|
81
|
+
markets?: KalshiMarket[];
|
|
82
|
+
}>,
|
|
83
|
+
lastPriceMap?: Map<string, { last_price?: number; dollar_last_price?: string; volume_24h_fp?: string }>,
|
|
84
|
+
): void {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
|
|
87
|
+
const insert = db.prepare(`
|
|
88
|
+
INSERT INTO event_index (event_ticker, series_ticker, title, category, strike_date, sub_title, tags, markets_json, indexed_at)
|
|
89
|
+
VALUES ($event_ticker, $series_ticker, $title, $category, $strike_date, $sub_title, $tags, $markets_json, $indexed_at)
|
|
90
|
+
`);
|
|
91
|
+
|
|
92
|
+
db.transaction(() => {
|
|
93
|
+
db.exec('DELETE FROM event_index');
|
|
94
|
+
|
|
95
|
+
for (const event of events) {
|
|
96
|
+
const compactMarkets = event.markets?.map((m) => {
|
|
97
|
+
const ticker = m.ticker as string;
|
|
98
|
+
const priceData = lastPriceMap?.get(ticker);
|
|
99
|
+
return {
|
|
100
|
+
ticker,
|
|
101
|
+
title: m.title,
|
|
102
|
+
yes_sub_title: m.yes_sub_title,
|
|
103
|
+
yes_bid: m.yes_bid,
|
|
104
|
+
yes_ask: m.yes_ask,
|
|
105
|
+
yes_bid_dollars: m.yes_bid_dollars,
|
|
106
|
+
yes_ask_dollars: m.yes_ask_dollars,
|
|
107
|
+
no_bid: m.no_bid,
|
|
108
|
+
no_ask: m.no_ask,
|
|
109
|
+
no_bid_dollars: m.no_bid_dollars,
|
|
110
|
+
no_ask_dollars: m.no_ask_dollars,
|
|
111
|
+
last_price: priceData?.last_price ?? m.last_price,
|
|
112
|
+
last_price_dollars: priceData?.dollar_last_price ?? m.last_price_dollars,
|
|
113
|
+
dollar_last_price: priceData?.dollar_last_price ?? m.dollar_last_price,
|
|
114
|
+
volume: m.volume_fp ?? m.volume ?? 0,
|
|
115
|
+
volume_24h: parseFloat(priceData?.volume_24h_fp ?? String(m.volume_24h_fp ?? m.volume_24h ?? 0)),
|
|
116
|
+
close_time: m.close_time,
|
|
117
|
+
status: m.status,
|
|
118
|
+
result: m.result,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
insert.run({
|
|
123
|
+
$event_ticker: event.event_ticker,
|
|
124
|
+
$series_ticker: event.series_ticker ?? null,
|
|
125
|
+
$title: event.title,
|
|
126
|
+
$category: event.category ?? null,
|
|
127
|
+
$strike_date: event.strike_date ?? null,
|
|
128
|
+
$sub_title: event.sub_title ?? null,
|
|
129
|
+
$tags: event.tags?.length ? event.tags.join(',') : null,
|
|
130
|
+
$markets_json: compactMarkets ? JSON.stringify(compactMarkets) : null,
|
|
131
|
+
$indexed_at: now,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Enrich existing index rows with market price/volume data from the API.
|
|
139
|
+
* Groups market data by event_ticker and upserts markets_json for each event,
|
|
140
|
+
* creating it from scratch if it was NULL (e.g. after Phase 1 index build).
|
|
141
|
+
*/
|
|
142
|
+
export function enrichIndexPrices(
|
|
143
|
+
db: Database,
|
|
144
|
+
priceMap: Map<string, { last_price?: number; dollar_last_price?: string; volume_24h_fp?: string }>,
|
|
145
|
+
marketsByEvent?: Map<string, Array<Record<string, unknown>>>,
|
|
146
|
+
): void {
|
|
147
|
+
if (priceMap.size === 0 && (!marketsByEvent || marketsByEvent.size === 0)) return;
|
|
148
|
+
|
|
149
|
+
const update = db.prepare('UPDATE event_index SET markets_json = $markets_json WHERE event_ticker = $event_ticker');
|
|
150
|
+
|
|
151
|
+
db.transaction(() => {
|
|
152
|
+
if (marketsByEvent) {
|
|
153
|
+
// Build markets_json from full market data, enriched with prices
|
|
154
|
+
for (const [eventTicker, markets] of marketsByEvent) {
|
|
155
|
+
const compactMarkets = markets.map((m) => {
|
|
156
|
+
const ticker = m.ticker as string;
|
|
157
|
+
const priceData = priceMap.get(ticker);
|
|
158
|
+
return {
|
|
159
|
+
ticker,
|
|
160
|
+
title: m.title,
|
|
161
|
+
yes_sub_title: m.yes_sub_title,
|
|
162
|
+
yes_bid: m.yes_bid,
|
|
163
|
+
yes_ask: m.yes_ask,
|
|
164
|
+
yes_bid_dollars: m.yes_bid_dollars,
|
|
165
|
+
yes_ask_dollars: m.yes_ask_dollars,
|
|
166
|
+
no_bid: m.no_bid,
|
|
167
|
+
no_ask: m.no_ask,
|
|
168
|
+
no_bid_dollars: m.no_bid_dollars,
|
|
169
|
+
no_ask_dollars: m.no_ask_dollars,
|
|
170
|
+
last_price: priceData?.last_price ?? m.last_price,
|
|
171
|
+
dollar_last_price: priceData?.dollar_last_price ?? m.dollar_last_price,
|
|
172
|
+
last_price_dollars: priceData?.dollar_last_price ?? m.last_price_dollars,
|
|
173
|
+
volume: m.volume_fp ?? m.volume ?? 0,
|
|
174
|
+
volume_24h: parseFloat(priceData?.volume_24h_fp ?? String(m.volume_24h_fp ?? m.volume_24h ?? 0)),
|
|
175
|
+
close_time: m.close_time,
|
|
176
|
+
status: m.status,
|
|
177
|
+
result: m.result,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
update.run({ $markets_json: JSON.stringify(compactMarkets), $event_ticker: eventTicker });
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// Fallback: update existing markets_json rows with price data
|
|
184
|
+
const rows = db.query('SELECT event_ticker, markets_json FROM event_index WHERE markets_json IS NOT NULL').all() as Array<{
|
|
185
|
+
event_ticker: string;
|
|
186
|
+
markets_json: string;
|
|
187
|
+
}>;
|
|
188
|
+
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
let markets: Array<Record<string, unknown>>;
|
|
191
|
+
try {
|
|
192
|
+
markets = JSON.parse(row.markets_json);
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let changed = false;
|
|
198
|
+
for (const m of markets) {
|
|
199
|
+
const ticker = m.ticker as string;
|
|
200
|
+
const priceData = priceMap.get(ticker);
|
|
201
|
+
if (!priceData) continue;
|
|
202
|
+
if (priceData.last_price != null) m.last_price = priceData.last_price;
|
|
203
|
+
if (priceData.dollar_last_price != null) m.dollar_last_price = priceData.dollar_last_price;
|
|
204
|
+
if (priceData.volume_24h_fp != null) m.volume_24h = parseFloat(priceData.volume_24h_fp);
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (changed) {
|
|
209
|
+
update.run({ $markets_json: JSON.stringify(markets), $event_ticker: row.event_ticker });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get the timestamp of the last successful index refresh, or null if never refreshed.
|
|
218
|
+
*/
|
|
219
|
+
export function getLastRefresh(db: Database): number | null {
|
|
220
|
+
const row = db.query("SELECT value FROM event_index_meta WHERE key = 'last_refresh'").get() as
|
|
221
|
+
| { value: string }
|
|
222
|
+
| null;
|
|
223
|
+
return row ? parseInt(row.value, 10) : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Set the last refresh timestamp.
|
|
228
|
+
*/
|
|
229
|
+
export function setLastRefresh(db: Database, timestamp: number): void {
|
|
230
|
+
db.query("INSERT OR REPLACE INTO event_index_meta (key, value) VALUES ('last_refresh', $ts)").run({
|
|
231
|
+
$ts: String(timestamp),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Reconstruct KalshiEvent[] from the local index for given event tickers.
|
|
237
|
+
* Parses markets_json back into nested market objects.
|
|
238
|
+
*/
|
|
239
|
+
export function getEventsFromIndex(db: Database, eventTickers: string[]): KalshiEvent[] {
|
|
240
|
+
if (eventTickers.length === 0) return [];
|
|
241
|
+
|
|
242
|
+
const placeholders = eventTickers.map(() => '?').join(',');
|
|
243
|
+
const rows = db
|
|
244
|
+
.query(
|
|
245
|
+
`SELECT event_ticker, series_ticker, title, category, strike_date, sub_title, markets_json
|
|
246
|
+
FROM event_index
|
|
247
|
+
WHERE event_ticker IN (${placeholders})`,
|
|
248
|
+
)
|
|
249
|
+
.all(...eventTickers) as IndexedEvent[];
|
|
250
|
+
|
|
251
|
+
return rows
|
|
252
|
+
.map((r) => {
|
|
253
|
+
let markets: unknown[] = [];
|
|
254
|
+
try {
|
|
255
|
+
markets = r.markets_json ? JSON.parse(r.markets_json) : [];
|
|
256
|
+
} catch {
|
|
257
|
+
// Corrupted markets_json — skip markets for this event
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
event_ticker: r.event_ticker,
|
|
261
|
+
series_ticker: r.series_ticker ?? '',
|
|
262
|
+
title: r.title,
|
|
263
|
+
category: r.category ?? '',
|
|
264
|
+
sub_title: r.sub_title ?? '',
|
|
265
|
+
strike_date: r.strike_date ?? '',
|
|
266
|
+
mutually_exclusive: false,
|
|
267
|
+
markets,
|
|
268
|
+
} as KalshiEvent;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get top N events by total market volume from the index.
|
|
274
|
+
* Parses markets_json, sums volume per event, sorts descending.
|
|
275
|
+
*/
|
|
276
|
+
export function getTopEventsByVolume(db: Database, limit: number): KalshiEvent[] {
|
|
277
|
+
const rows = db
|
|
278
|
+
.query(
|
|
279
|
+
`SELECT event_ticker, series_ticker, title, category, strike_date, sub_title, markets_json
|
|
280
|
+
FROM event_index
|
|
281
|
+
WHERE markets_json IS NOT NULL`,
|
|
282
|
+
)
|
|
283
|
+
.all() as IndexedEvent[];
|
|
284
|
+
|
|
285
|
+
const events: Array<{ event: KalshiEvent; totalVolume: number }> = [];
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
let markets: any[] = [];
|
|
288
|
+
try {
|
|
289
|
+
markets = r.markets_json ? JSON.parse(r.markets_json) : [];
|
|
290
|
+
} catch {
|
|
291
|
+
// Corrupted markets_json — treat as no markets
|
|
292
|
+
}
|
|
293
|
+
const totalVolume = markets.reduce(
|
|
294
|
+
(sum: number, m: any) => sum + (parseFloat(m.volume) || parseFloat(m.volume_fp) || 0),
|
|
295
|
+
0,
|
|
296
|
+
);
|
|
297
|
+
events.push({
|
|
298
|
+
event: {
|
|
299
|
+
event_ticker: r.event_ticker,
|
|
300
|
+
series_ticker: r.series_ticker ?? '',
|
|
301
|
+
title: r.title,
|
|
302
|
+
category: r.category ?? '',
|
|
303
|
+
sub_title: r.sub_title ?? '',
|
|
304
|
+
strike_date: r.strike_date ?? '',
|
|
305
|
+
mutually_exclusive: false,
|
|
306
|
+
markets,
|
|
307
|
+
} as KalshiEvent,
|
|
308
|
+
totalVolume,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
events.sort((a, b) => b.totalVolume - a.totalVolume);
|
|
313
|
+
return events.slice(0, limit).map((e) => e.event);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get the age of the index in milliseconds, or Infinity if never refreshed.
|
|
318
|
+
*/
|
|
319
|
+
export function getIndexAge(db: Database): number {
|
|
320
|
+
const last = getLastRefresh(db);
|
|
321
|
+
if (last === null) return Infinity;
|
|
322
|
+
return Date.now() - last;
|
|
323
|
+
}
|
package/src/db/events.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export interface Event {
|
|
4
|
+
ticker: string;
|
|
5
|
+
category?: string | null;
|
|
6
|
+
expiry?: number | null;
|
|
7
|
+
vol_24h?: number | null;
|
|
8
|
+
theme_id?: string | null;
|
|
9
|
+
active?: number | null;
|
|
10
|
+
updated_at?: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function upsertEvent(db: Database, event: Event): void {
|
|
14
|
+
db.prepare(`
|
|
15
|
+
INSERT OR REPLACE INTO events (ticker, category, expiry, vol_24h, theme_id, active, updated_at)
|
|
16
|
+
VALUES ($ticker, $category, $expiry, $vol_24h, $theme_id, $active, $updated_at)
|
|
17
|
+
`).run({
|
|
18
|
+
$ticker: event.ticker,
|
|
19
|
+
$category: event.category ?? null,
|
|
20
|
+
$expiry: event.expiry ?? null,
|
|
21
|
+
$vol_24h: event.vol_24h ?? null,
|
|
22
|
+
$theme_id: event.theme_id ?? null,
|
|
23
|
+
$active: event.active ?? 1,
|
|
24
|
+
$updated_at: event.updated_at ?? null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getActiveEvents(db: Database): Event[] {
|
|
29
|
+
return db.query('SELECT * FROM events WHERE active = 1').all() as Event[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getEvent(db: Database, ticker: string): Event | null {
|
|
33
|
+
return db.query('SELECT * FROM events WHERE ticker = $ticker').get({ $ticker: ticker }) as Event | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function deactivateExpired(db: Database, cutoffTimestamp: number): number {
|
|
37
|
+
const result = db.prepare(
|
|
38
|
+
'UPDATE events SET active = 0 WHERE expiry < $cutoff AND active = 1'
|
|
39
|
+
).run({ $cutoff: cutoffTimestamp });
|
|
40
|
+
return result.changes;
|
|
41
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
import { migrate } from './schema.js';
|
|
5
|
+
import { appPath } from '../utils/paths.js';
|
|
6
|
+
import { prefetchOctagonEvents } from '../scan/octagon-prefetch.js';
|
|
7
|
+
|
|
8
|
+
let _db: Database | null = null;
|
|
9
|
+
|
|
10
|
+
const DEFAULT_DB_PATH = appPath('kalshi-bot.db');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get the database singleton. Lazy-initializes on first call.
|
|
14
|
+
* Pass a custom path for testing (e.g. ":memory:").
|
|
15
|
+
*/
|
|
16
|
+
export function getDb(path?: string): Database {
|
|
17
|
+
if (_db) return _db;
|
|
18
|
+
|
|
19
|
+
const dbPath = path ?? DEFAULT_DB_PATH;
|
|
20
|
+
|
|
21
|
+
if (dbPath !== ':memory:') {
|
|
22
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_db = new Database(dbPath);
|
|
26
|
+
_db.exec('PRAGMA journal_mode = WAL');
|
|
27
|
+
_db.exec('PRAGMA foreign_keys = ON');
|
|
28
|
+
migrate(_db);
|
|
29
|
+
|
|
30
|
+
// Fire-and-forget: prefetch Octagon events in background (only for the real runtime DB)
|
|
31
|
+
if (dbPath === DEFAULT_DB_PATH) {
|
|
32
|
+
const db = _db;
|
|
33
|
+
prefetchOctagonEvents(db).catch(() => {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return _db;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Close the database singleton and release the file descriptor.
|
|
41
|
+
* Safe to call even if no DB is open.
|
|
42
|
+
*/
|
|
43
|
+
export function closeDb(): void {
|
|
44
|
+
if (_db) {
|
|
45
|
+
_db.close();
|
|
46
|
+
_db = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a fresh database instance (not the singleton).
|
|
52
|
+
* Useful for tests with :memory: databases.
|
|
53
|
+
*/
|
|
54
|
+
export function createDb(path: string): Database {
|
|
55
|
+
const db = new Database(path);
|
|
56
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
57
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
58
|
+
migrate(db);
|
|
59
|
+
return db;
|
|
60
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export interface OctagonReport {
|
|
4
|
+
report_id: string;
|
|
5
|
+
ticker: string;
|
|
6
|
+
event_ticker: string;
|
|
7
|
+
model_prob: number;
|
|
8
|
+
market_prob?: number | null;
|
|
9
|
+
mispricing_signal?: string | null;
|
|
10
|
+
drivers_json?: string | null;
|
|
11
|
+
catalysts_json?: string | null;
|
|
12
|
+
sources_json?: string | null;
|
|
13
|
+
resolution_history_json?: string | null;
|
|
14
|
+
contract_snapshot_json?: string | null;
|
|
15
|
+
raw_response?: string | null;
|
|
16
|
+
model_accuracy?: number | null;
|
|
17
|
+
variant_used?: string | null;
|
|
18
|
+
fetched_at: number;
|
|
19
|
+
expires_at: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function insertReport(db: Database, report: OctagonReport): void {
|
|
23
|
+
db.prepare(`
|
|
24
|
+
INSERT INTO octagon_reports
|
|
25
|
+
(report_id, ticker, event_ticker, model_prob, market_prob, mispricing_signal,
|
|
26
|
+
drivers_json, catalysts_json, sources_json, resolution_history_json,
|
|
27
|
+
contract_snapshot_json, raw_response, model_accuracy, variant_used, fetched_at, expires_at)
|
|
28
|
+
VALUES
|
|
29
|
+
($report_id, $ticker, $event_ticker, $model_prob, $market_prob, $mispricing_signal,
|
|
30
|
+
$drivers_json, $catalysts_json, $sources_json, $resolution_history_json,
|
|
31
|
+
$contract_snapshot_json, $raw_response, $model_accuracy, $variant_used, $fetched_at, $expires_at)
|
|
32
|
+
ON CONFLICT(report_id) DO UPDATE SET
|
|
33
|
+
ticker = EXCLUDED.ticker,
|
|
34
|
+
event_ticker = EXCLUDED.event_ticker,
|
|
35
|
+
model_prob = EXCLUDED.model_prob,
|
|
36
|
+
market_prob = EXCLUDED.market_prob,
|
|
37
|
+
mispricing_signal = EXCLUDED.mispricing_signal,
|
|
38
|
+
drivers_json = EXCLUDED.drivers_json,
|
|
39
|
+
catalysts_json = EXCLUDED.catalysts_json,
|
|
40
|
+
sources_json = EXCLUDED.sources_json,
|
|
41
|
+
resolution_history_json = EXCLUDED.resolution_history_json,
|
|
42
|
+
contract_snapshot_json = EXCLUDED.contract_snapshot_json,
|
|
43
|
+
raw_response = EXCLUDED.raw_response,
|
|
44
|
+
model_accuracy = EXCLUDED.model_accuracy,
|
|
45
|
+
variant_used = EXCLUDED.variant_used,
|
|
46
|
+
fetched_at = EXCLUDED.fetched_at,
|
|
47
|
+
expires_at = EXCLUDED.expires_at
|
|
48
|
+
`).run({
|
|
49
|
+
$report_id: report.report_id,
|
|
50
|
+
$ticker: report.ticker,
|
|
51
|
+
$event_ticker: report.event_ticker,
|
|
52
|
+
$model_prob: report.model_prob,
|
|
53
|
+
$market_prob: report.market_prob ?? null,
|
|
54
|
+
$mispricing_signal: report.mispricing_signal ?? null,
|
|
55
|
+
$drivers_json: report.drivers_json ?? null,
|
|
56
|
+
$catalysts_json: report.catalysts_json ?? null,
|
|
57
|
+
$sources_json: report.sources_json ?? null,
|
|
58
|
+
$resolution_history_json: report.resolution_history_json ?? null,
|
|
59
|
+
$contract_snapshot_json: report.contract_snapshot_json ?? null,
|
|
60
|
+
$raw_response: report.raw_response ?? null,
|
|
61
|
+
$model_accuracy: report.model_accuracy ?? null,
|
|
62
|
+
$variant_used: report.variant_used ?? null,
|
|
63
|
+
$fetched_at: report.fetched_at,
|
|
64
|
+
$expires_at: report.expires_at,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getReport(db: Database, reportId: string): OctagonReport | null {
|
|
69
|
+
return db.query('SELECT * FROM octagon_reports WHERE report_id = $id').get({
|
|
70
|
+
$id: reportId,
|
|
71
|
+
}) as OctagonReport | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getLatestReport(db: Database, ticker: string): OctagonReport | null {
|
|
75
|
+
return db.query(
|
|
76
|
+
'SELECT * FROM octagon_reports WHERE ticker = $ticker ORDER BY fetched_at DESC LIMIT 1'
|
|
77
|
+
).get({ $ticker: ticker }) as OctagonReport | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function updateReportModelProb(db: Database, reportId: string, modelProb: number): void {
|
|
81
|
+
db.prepare(
|
|
82
|
+
`UPDATE octagon_reports SET model_prob = $model_prob WHERE report_id = $report_id`,
|
|
83
|
+
).run({ $report_id: reportId, $model_prob: modelProb });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns the cache TTL (in seconds) based on how far away the market close time is.
|
|
88
|
+
*
|
|
89
|
+
* | Time to close | TTL |
|
|
90
|
+
* |---------------|-------|
|
|
91
|
+
* | <24h | 1h |
|
|
92
|
+
* | 1–7d | 6h |
|
|
93
|
+
* | 7–30d | 24h |
|
|
94
|
+
* | 30d+ | 48h |
|
|
95
|
+
* | Already closed| 1h |
|
|
96
|
+
*/
|
|
97
|
+
export function getTtlForCloseTime(secondsUntilClose: number): number {
|
|
98
|
+
if (secondsUntilClose <= 0) return 3600; // already closed → 1h
|
|
99
|
+
if (secondsUntilClose < 86400) return 3600; // <24h → 1h
|
|
100
|
+
if (secondsUntilClose < 7 * 86400) return 21600; // 1–7d → 6h
|
|
101
|
+
if (secondsUntilClose < 30 * 86400) return 86400; // 7–30d → 24h
|
|
102
|
+
return 172800; // 30d+ → 48h
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns true if no report exists for this ticker, or the latest is older than the TTL.
|
|
107
|
+
* When closeTimeEpoch is provided, uses tiered TTL based on market close proximity.
|
|
108
|
+
* Otherwise falls back to 24h.
|
|
109
|
+
*/
|
|
110
|
+
export function isStale(db: Database, ticker: string, nowSeconds?: number, closeTimeEpoch?: number): boolean {
|
|
111
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
112
|
+
const report = getLatestReport(db, ticker);
|
|
113
|
+
if (!report) return true;
|
|
114
|
+
const ttl = closeTimeEpoch != null
|
|
115
|
+
? getTtlForCloseTime(closeTimeEpoch - now)
|
|
116
|
+
: 86400;
|
|
117
|
+
return report.fetched_at + ttl < now;
|
|
118
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import type { EdgeRow } from './edge.js';
|
|
3
|
+
|
|
4
|
+
export interface Position {
|
|
5
|
+
position_id: string;
|
|
6
|
+
ticker: string;
|
|
7
|
+
event_ticker: string;
|
|
8
|
+
direction: string;
|
|
9
|
+
size: number;
|
|
10
|
+
entry_price: number;
|
|
11
|
+
entry_edge?: number | null;
|
|
12
|
+
entry_kelly?: number | null;
|
|
13
|
+
current_pnl?: number | null;
|
|
14
|
+
status?: string | null;
|
|
15
|
+
opened_at?: number | null;
|
|
16
|
+
closed_at?: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PositionWithEdge extends Position {
|
|
20
|
+
latest_edge?: EdgeRow | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function openPosition(db: Database, position: Position): void {
|
|
24
|
+
db.prepare(`
|
|
25
|
+
INSERT INTO positions
|
|
26
|
+
(position_id, ticker, event_ticker, direction, size, entry_price,
|
|
27
|
+
entry_edge, entry_kelly, current_pnl, status, opened_at, closed_at)
|
|
28
|
+
VALUES
|
|
29
|
+
($position_id, $ticker, $event_ticker, $direction, $size, $entry_price,
|
|
30
|
+
$entry_edge, $entry_kelly, $current_pnl, $status, $opened_at, $closed_at)
|
|
31
|
+
`).run({
|
|
32
|
+
$position_id: position.position_id,
|
|
33
|
+
$ticker: position.ticker,
|
|
34
|
+
$event_ticker: position.event_ticker,
|
|
35
|
+
$direction: position.direction,
|
|
36
|
+
$size: position.size,
|
|
37
|
+
$entry_price: position.entry_price,
|
|
38
|
+
$entry_edge: position.entry_edge ?? null,
|
|
39
|
+
$entry_kelly: position.entry_kelly ?? null,
|
|
40
|
+
$current_pnl: position.current_pnl ?? 0,
|
|
41
|
+
$status: position.status ?? 'open',
|
|
42
|
+
$opened_at: position.opened_at ?? null,
|
|
43
|
+
$closed_at: position.closed_at ?? null,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function closePosition(db: Database, positionId: string, closedAt: number): void {
|
|
48
|
+
db.prepare(
|
|
49
|
+
"UPDATE positions SET status = 'closed', closed_at = $closed_at WHERE position_id = $id"
|
|
50
|
+
).run({ $closed_at: closedAt, $id: positionId });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getOpenPositions(db: Database): Position[] {
|
|
54
|
+
return db.query("SELECT * FROM positions WHERE status = 'open'").all() as Position[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get a position with its latest edge_history row joined by ticker.
|
|
59
|
+
*/
|
|
60
|
+
export function getPositionWithEdge(db: Database, positionId: string): PositionWithEdge | null {
|
|
61
|
+
const position = db.query('SELECT * FROM positions WHERE position_id = $id').get({
|
|
62
|
+
$id: positionId,
|
|
63
|
+
}) as Position | null;
|
|
64
|
+
if (!position) return null;
|
|
65
|
+
|
|
66
|
+
const latestEdge = db.query(
|
|
67
|
+
'SELECT * FROM edge_history WHERE ticker = $ticker ORDER BY timestamp DESC LIMIT 1'
|
|
68
|
+
).get({ $ticker: position.ticker }) as EdgeRow | null;
|
|
69
|
+
|
|
70
|
+
return { ...position, latest_edge: latestEdge ?? null };
|
|
71
|
+
}
|
package/src/db/risk.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export interface RiskSnapshot {
|
|
4
|
+
id?: number;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
cash_balance?: number | null;
|
|
7
|
+
portfolio_value?: number | null;
|
|
8
|
+
open_exposure?: number | null;
|
|
9
|
+
available_bankroll?: number | null;
|
|
10
|
+
daily_pnl?: number | null;
|
|
11
|
+
drawdown_current?: number | null;
|
|
12
|
+
drawdown_max?: number | null;
|
|
13
|
+
correlation_max?: number | null;
|
|
14
|
+
positions_count?: number | null;
|
|
15
|
+
circuit_breaker_on?: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function insertRiskSnapshot(db: Database, snapshot: RiskSnapshot): void {
|
|
19
|
+
db.prepare(`
|
|
20
|
+
INSERT INTO risk_snapshots
|
|
21
|
+
(timestamp, cash_balance, portfolio_value, open_exposure, available_bankroll,
|
|
22
|
+
daily_pnl, drawdown_current, drawdown_max, correlation_max, positions_count, circuit_breaker_on)
|
|
23
|
+
VALUES
|
|
24
|
+
($timestamp, $cash_balance, $portfolio_value, $open_exposure, $available_bankroll,
|
|
25
|
+
$daily_pnl, $drawdown_current, $drawdown_max, $correlation_max, $positions_count, $circuit_breaker_on)
|
|
26
|
+
`).run({
|
|
27
|
+
$timestamp: snapshot.timestamp,
|
|
28
|
+
$cash_balance: snapshot.cash_balance ?? null,
|
|
29
|
+
$portfolio_value: snapshot.portfolio_value ?? null,
|
|
30
|
+
$open_exposure: snapshot.open_exposure ?? null,
|
|
31
|
+
$available_bankroll: snapshot.available_bankroll ?? null,
|
|
32
|
+
$daily_pnl: snapshot.daily_pnl ?? null,
|
|
33
|
+
$drawdown_current: snapshot.drawdown_current ?? null,
|
|
34
|
+
$drawdown_max: snapshot.drawdown_max ?? null,
|
|
35
|
+
$correlation_max: snapshot.correlation_max ?? null,
|
|
36
|
+
$positions_count: snapshot.positions_count ?? null,
|
|
37
|
+
$circuit_breaker_on: snapshot.circuit_breaker_on ?? 0,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getLatestSnapshot(db: Database): RiskSnapshot | null {
|
|
42
|
+
return db.query(
|
|
43
|
+
'SELECT * FROM risk_snapshots ORDER BY timestamp DESC LIMIT 1'
|
|
44
|
+
).get() as RiskSnapshot | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getDrawdownHistory(db: Database, since: number): RiskSnapshot[] {
|
|
48
|
+
return db.query(
|
|
49
|
+
'SELECT * FROM risk_snapshots WHERE timestamp >= $since ORDER BY timestamp ASC'
|
|
50
|
+
).all({ $since: since }) as RiskSnapshot[];
|
|
51
|
+
}
|