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,236 @@
|
|
|
1
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
2
|
+
import { formatBoxHeader } from './formatters.js';
|
|
3
|
+
import type { CLIResponse } from './json.js';
|
|
4
|
+
import { wrapSuccess } from './json.js';
|
|
5
|
+
import { getDb } from '../db/index.js';
|
|
6
|
+
import { getOpenPositions, getPositionWithEdge } from '../db/positions.js';
|
|
7
|
+
import type { PositionWithEdge } from '../db/positions.js';
|
|
8
|
+
import { fetchLiveBankroll } from '../risk/kelly.js';
|
|
9
|
+
import { getLatestSnapshot } from '../db/risk.js';
|
|
10
|
+
import type { RiskSnapshot } from '../db/risk.js';
|
|
11
|
+
import { formatTable } from './scan-formatters.js';
|
|
12
|
+
import { computePerformance } from '../eval/performance.js';
|
|
13
|
+
import type { PerformanceStats } from '../eval/performance.js';
|
|
14
|
+
|
|
15
|
+
export type { PerformanceStats };
|
|
16
|
+
|
|
17
|
+
export interface PositionView {
|
|
18
|
+
ticker: string;
|
|
19
|
+
direction: string;
|
|
20
|
+
size: number;
|
|
21
|
+
entryPrice: number;
|
|
22
|
+
entryEdge: number | null;
|
|
23
|
+
currentEdge: number | null;
|
|
24
|
+
unrealizedPnl: number | null;
|
|
25
|
+
watchdogStatus: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PortfolioData {
|
|
29
|
+
positions: PositionView[];
|
|
30
|
+
accountSummary: {
|
|
31
|
+
cashBalance: number;
|
|
32
|
+
portfolioValue: number;
|
|
33
|
+
openExposure: number;
|
|
34
|
+
available: number;
|
|
35
|
+
positionsCount: number;
|
|
36
|
+
} | null;
|
|
37
|
+
riskSnapshot: RiskSnapshot | null;
|
|
38
|
+
performance?: PerformanceStats;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function deriveWatchdogStatus(pos: PositionWithEdge): string {
|
|
42
|
+
if (!pos.latest_edge) return 'unknown';
|
|
43
|
+
const entryEdge = pos.entry_edge ?? 0;
|
|
44
|
+
const currentEdge = pos.latest_edge.edge;
|
|
45
|
+
|
|
46
|
+
// Edge converging toward zero or flipped
|
|
47
|
+
if (Math.sign(entryEdge) !== Math.sign(currentEdge) && entryEdge !== 0) return 'adverse_move';
|
|
48
|
+
if (Math.abs(currentEdge) < Math.abs(entryEdge) * 0.5) return 'converging';
|
|
49
|
+
return 'stable';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function handlePortfolio(args: ParsedArgs): Promise<CLIResponse<PortfolioData>> {
|
|
53
|
+
const db = getDb();
|
|
54
|
+
const warnings: string[] = [];
|
|
55
|
+
|
|
56
|
+
// Get open positions with edge data
|
|
57
|
+
let positionViews: PositionView[] = [];
|
|
58
|
+
let positionsCount = 0;
|
|
59
|
+
try {
|
|
60
|
+
const openPositions = getOpenPositions(db);
|
|
61
|
+
const views = openPositions.map((pos) => {
|
|
62
|
+
const withEdge = getPositionWithEdge(db, pos.position_id);
|
|
63
|
+
return {
|
|
64
|
+
ticker: pos.ticker,
|
|
65
|
+
direction: pos.direction,
|
|
66
|
+
size: pos.size,
|
|
67
|
+
entryPrice: pos.entry_price,
|
|
68
|
+
entryEdge: pos.entry_edge ?? null,
|
|
69
|
+
currentEdge: withEdge?.latest_edge?.edge ?? null,
|
|
70
|
+
unrealizedPnl: pos.current_pnl ?? null,
|
|
71
|
+
watchdogStatus: withEdge ? deriveWatchdogStatus(withEdge) : 'unknown',
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
positionViews = views;
|
|
75
|
+
positionsCount = openPositions.length;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
positionsCount = 0;
|
|
78
|
+
warnings.push(`Positions unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Live bankroll — degrade gracefully on API failure (e.g. 503)
|
|
82
|
+
let bankroll: Awaited<ReturnType<typeof fetchLiveBankroll>> | null = null;
|
|
83
|
+
try {
|
|
84
|
+
bankroll = await fetchLiveBankroll();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
warnings.push(`Balance unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Risk snapshot
|
|
90
|
+
let riskSnapshot: RiskSnapshot | null = null;
|
|
91
|
+
try {
|
|
92
|
+
riskSnapshot = getLatestSnapshot(db);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
warnings.push(`Risk snapshot unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const data: PortfolioData = {
|
|
98
|
+
positions: positionViews,
|
|
99
|
+
accountSummary: bankroll
|
|
100
|
+
? {
|
|
101
|
+
cashBalance: bankroll.cashBalance,
|
|
102
|
+
portfolioValue: bankroll.portfolioValue,
|
|
103
|
+
openExposure: bankroll.openExposure,
|
|
104
|
+
available: bankroll.availableBankroll,
|
|
105
|
+
positionsCount,
|
|
106
|
+
}
|
|
107
|
+
: null,
|
|
108
|
+
riskSnapshot,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Performance stats if requested
|
|
112
|
+
if (args.performance) {
|
|
113
|
+
try {
|
|
114
|
+
data.performance = computePerformance(db);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
warnings.push(`Performance unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const meta: CLIResponse<PortfolioData>['meta'] = {
|
|
121
|
+
...(bankroll
|
|
122
|
+
? {
|
|
123
|
+
bankroll: {
|
|
124
|
+
cash_balance: bankroll.cashBalance,
|
|
125
|
+
portfolio_value: bankroll.portfolioValue,
|
|
126
|
+
open_exposure: bankroll.openExposure,
|
|
127
|
+
available: bankroll.availableBankroll,
|
|
128
|
+
positions_count: positionsCount,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
: {}),
|
|
132
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return wrapSuccess('portfolio', data, meta);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function formatPortfolioHuman(data: PortfolioData): string {
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
|
|
141
|
+
lines.push(...formatBoxHeader('PORTFOLIO'));
|
|
142
|
+
lines.push('');
|
|
143
|
+
|
|
144
|
+
// Positions table
|
|
145
|
+
if (data.positions.length === 0) {
|
|
146
|
+
lines.push(' No open positions.');
|
|
147
|
+
} else {
|
|
148
|
+
const rows = data.positions.map((p) => [
|
|
149
|
+
p.ticker,
|
|
150
|
+
p.direction.toUpperCase(),
|
|
151
|
+
String(p.size),
|
|
152
|
+
`$${(p.entryPrice / 100).toFixed(2)}`,
|
|
153
|
+
p.currentEdge !== null ? `${(p.currentEdge * 100).toFixed(1)}%` : '-',
|
|
154
|
+
p.unrealizedPnl !== null ? `$${(p.unrealizedPnl / 100).toFixed(2)}` : '-',
|
|
155
|
+
p.watchdogStatus,
|
|
156
|
+
]);
|
|
157
|
+
lines.push(formatTable(
|
|
158
|
+
['Ticker', 'Dir', 'Size', 'Entry', 'Current Edge', 'P&L', 'Status'],
|
|
159
|
+
rows
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
lines.push('');
|
|
163
|
+
|
|
164
|
+
// Account summary
|
|
165
|
+
lines.push(' Account Summary:');
|
|
166
|
+
if (data.accountSummary) {
|
|
167
|
+
lines.push(` Cash Balance: $${(data.accountSummary.cashBalance / 100).toFixed(2)}`);
|
|
168
|
+
lines.push(` Portfolio Value: $${(data.accountSummary.portfolioValue / 100).toFixed(2)}`);
|
|
169
|
+
lines.push(` Open Exposure: $${(data.accountSummary.openExposure / 100).toFixed(2)}`);
|
|
170
|
+
lines.push(` Available: $${(data.accountSummary.available / 100).toFixed(2)}`);
|
|
171
|
+
lines.push(` Positions: ${data.accountSummary.positionsCount}`);
|
|
172
|
+
} else {
|
|
173
|
+
lines.push(' (unavailable — Kalshi API returned an error)');
|
|
174
|
+
}
|
|
175
|
+
lines.push('');
|
|
176
|
+
|
|
177
|
+
// Risk snapshot
|
|
178
|
+
if (data.riskSnapshot) {
|
|
179
|
+
const snap = data.riskSnapshot;
|
|
180
|
+
lines.push(' Risk Snapshot:');
|
|
181
|
+
if (snap.drawdown_current != null) {
|
|
182
|
+
lines.push(` Current Drawdown: ${(snap.drawdown_current * 100).toFixed(1)}%`);
|
|
183
|
+
}
|
|
184
|
+
if (snap.drawdown_max != null) {
|
|
185
|
+
lines.push(` Max Drawdown: ${(snap.drawdown_max * 100).toFixed(1)}%`);
|
|
186
|
+
}
|
|
187
|
+
if (snap.daily_pnl != null) {
|
|
188
|
+
lines.push(` Daily P&L: $${(snap.daily_pnl / 100).toFixed(2)}`);
|
|
189
|
+
}
|
|
190
|
+
if (snap.circuit_breaker_on) {
|
|
191
|
+
lines.push(' Circuit Breaker: ACTIVE');
|
|
192
|
+
}
|
|
193
|
+
lines.push('');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Performance stats
|
|
197
|
+
if (data.performance) {
|
|
198
|
+
const perf = data.performance;
|
|
199
|
+
lines.push(' Performance:');
|
|
200
|
+
if (perf.winRate !== null) {
|
|
201
|
+
lines.push(` Win Rate: ${(perf.winRate * 100).toFixed(1)}%`);
|
|
202
|
+
}
|
|
203
|
+
lines.push(` Total P&L: $${(perf.totalPnl / 100).toFixed(2)}`);
|
|
204
|
+
if (perf.sharpeRatio !== null) {
|
|
205
|
+
lines.push(` Sharpe Ratio: ${perf.sharpeRatio.toFixed(2)}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const brierEntries = Object.entries(perf.brierByCategory);
|
|
209
|
+
if (brierEntries.length > 0) {
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push(' Brier Scores by Category:');
|
|
212
|
+
const brierRows = brierEntries.map(([cat, score]) => [cat, score.toFixed(3)]);
|
|
213
|
+
lines.push(formatTable(['Category', 'Brier Score'], brierRows));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const pnlEntries = Object.entries(perf.pnlByCategory);
|
|
217
|
+
if (pnlEntries.length > 0) {
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push(' P&L by Category:');
|
|
220
|
+
const pnlRows = pnlEntries.map(([cat, pnl]) => [cat, `$${(pnl / 100).toFixed(2)}`]);
|
|
221
|
+
lines.push(formatTable(['Category', 'P&L'], pnlRows));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (perf.underperformingCategories && perf.underperformingCategories.length > 0) {
|
|
225
|
+
lines.push('');
|
|
226
|
+
lines.push(' ⚠ Underperforming Categories (Brier > 0.30):');
|
|
227
|
+
for (const cat of perf.underperformingCategories) {
|
|
228
|
+
const score = perf.brierByCategory[cat];
|
|
229
|
+
lines.push(` - ${cat}: ${score !== undefined ? score.toFixed(3) : 'N/A'}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
lines.push('');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { callKalshiApi } from '../tools/kalshi/api.js';
|
|
2
|
+
import type { KalshiPosition } from '../tools/kalshi/types.js';
|
|
3
|
+
import { handleAnalyze } from './analyze.js';
|
|
4
|
+
import type { AnalyzeData } from './analyze.js';
|
|
5
|
+
import { parsePriceField } from '../controllers/browse.js';
|
|
6
|
+
import { formatBoxHeader } from './formatters.js';
|
|
7
|
+
|
|
8
|
+
export interface PositionReview {
|
|
9
|
+
ticker: string;
|
|
10
|
+
direction: 'yes' | 'no';
|
|
11
|
+
size: number;
|
|
12
|
+
entryPrice: number | null;
|
|
13
|
+
currentMarketProb: number;
|
|
14
|
+
modelProb: number;
|
|
15
|
+
edge: number;
|
|
16
|
+
signal: 'HOLD' | 'SELL';
|
|
17
|
+
sellSide: 'yes' | 'no';
|
|
18
|
+
closePriceCents: number;
|
|
19
|
+
reason: string;
|
|
20
|
+
analyzeError?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SELL_THRESHOLD = 0.03; // minimum edge reversal to trigger SELL signal
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fetch all live Kalshi positions with non-zero holdings,
|
|
27
|
+
* run edge analysis on each, and return HOLD/SELL recommendations.
|
|
28
|
+
*/
|
|
29
|
+
export async function reviewPortfolio(): Promise<PositionReview[]> {
|
|
30
|
+
const data = await callKalshiApi('GET', '/portfolio/positions');
|
|
31
|
+
const allPositions = (data.market_positions ?? data.positions ?? []) as KalshiPosition[];
|
|
32
|
+
|
|
33
|
+
const nonZero = allPositions.filter((p) => {
|
|
34
|
+
const pos = parseFloat(String(p.position ?? '0'));
|
|
35
|
+
return pos !== 0;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (nonZero.length === 0) return [];
|
|
39
|
+
|
|
40
|
+
// Run analysis concurrently (cached — no Octagon credits consumed)
|
|
41
|
+
// Pass preloaded position to avoid N+1 portfolio fetches inside handleAnalyze
|
|
42
|
+
const results = await Promise.allSettled(
|
|
43
|
+
nonZero.map((p) => {
|
|
44
|
+
const rawPos = parseFloat(String(p.position ?? '0'));
|
|
45
|
+
const pos = rawPos !== 0
|
|
46
|
+
? { direction: (rawPos > 0 ? 'yes' : 'no') as 'yes' | 'no', size: Math.abs(Math.round(rawPos)) }
|
|
47
|
+
: null;
|
|
48
|
+
return handleAnalyze(p.ticker, false, pos);
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return results.map((result, i) => {
|
|
53
|
+
const pos = nonZero[i];
|
|
54
|
+
const rawPos = parseFloat(String(pos.position ?? '0'));
|
|
55
|
+
const direction: 'yes' | 'no' = rawPos > 0 ? 'yes' : 'no';
|
|
56
|
+
const size = Math.abs(Math.round(rawPos));
|
|
57
|
+
|
|
58
|
+
if (result.status === 'rejected') {
|
|
59
|
+
const err = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
60
|
+
return {
|
|
61
|
+
ticker: pos.ticker,
|
|
62
|
+
direction,
|
|
63
|
+
size,
|
|
64
|
+
entryPrice: null,
|
|
65
|
+
currentMarketProb: 0,
|
|
66
|
+
modelProb: 0,
|
|
67
|
+
edge: 0,
|
|
68
|
+
signal: 'HOLD' as const,
|
|
69
|
+
sellSide: direction,
|
|
70
|
+
closePriceCents: 0,
|
|
71
|
+
reason: 'Analysis failed — manual review required',
|
|
72
|
+
analyzeError: err,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const analysis: AnalyzeData = result.value;
|
|
77
|
+
const { edge, marketProb, modelProb, kelly } = analysis;
|
|
78
|
+
|
|
79
|
+
// Determine if edge has reversed against our position
|
|
80
|
+
let signal: 'HOLD' | 'SELL' = 'HOLD';
|
|
81
|
+
let reason = '';
|
|
82
|
+
|
|
83
|
+
if (direction === 'yes' && edge < -SELL_THRESHOLD) {
|
|
84
|
+
signal = 'SELL';
|
|
85
|
+
reason = `Edge reversed: model now favors NO by ${Math.abs(edge * 100).toFixed(0)}pp`;
|
|
86
|
+
} else if (direction === 'no' && edge > SELL_THRESHOLD) {
|
|
87
|
+
signal = 'SELL';
|
|
88
|
+
reason = `Edge reversed: model now favors YES by ${(edge * 100).toFixed(0)}pp`;
|
|
89
|
+
} else if (direction === 'yes' && edge >= 0) {
|
|
90
|
+
reason = `Still favorable: +${(edge * 100).toFixed(0)}pp edge`;
|
|
91
|
+
} else if (direction === 'no' && edge <= 0) {
|
|
92
|
+
reason = `Still favorable: ${(edge * 100).toFixed(0)}pp edge`;
|
|
93
|
+
} else {
|
|
94
|
+
// Edge has decayed but not reversed past threshold
|
|
95
|
+
const decay = direction === 'yes' ? edge : -edge;
|
|
96
|
+
reason = `Edge decayed (${(decay * 100).toFixed(0)}pp) but below sell threshold`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Use the bid-derived close price from handleAnalyze when available,
|
|
100
|
+
// fall back to marketProb approximation only if missing
|
|
101
|
+
const closePriceCents =
|
|
102
|
+
analysis.closePriceCents && analysis.closePriceCents > 0
|
|
103
|
+
? analysis.closePriceCents
|
|
104
|
+
: Math.round(
|
|
105
|
+
direction === 'yes'
|
|
106
|
+
? marketProb * 100 - 1
|
|
107
|
+
: (1 - marketProb) * 100 - 1
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ticker: pos.ticker,
|
|
112
|
+
direction,
|
|
113
|
+
size,
|
|
114
|
+
entryPrice: kelly.entryPriceCents > 0 ? kelly.entryPriceCents : null,
|
|
115
|
+
currentMarketProb: marketProb,
|
|
116
|
+
modelProb,
|
|
117
|
+
edge,
|
|
118
|
+
signal,
|
|
119
|
+
sellSide: direction,
|
|
120
|
+
closePriceCents: Math.max(1, closePriceCents),
|
|
121
|
+
reason,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatReviewHuman(reviews: PositionReview[]): string {
|
|
127
|
+
const lines: string[] = [];
|
|
128
|
+
|
|
129
|
+
lines.push(...formatBoxHeader('PORTFOLIO REVIEW'));
|
|
130
|
+
lines.push('');
|
|
131
|
+
|
|
132
|
+
if (reviews.length === 0) {
|
|
133
|
+
lines.push(' No open positions found.');
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sells = reviews.filter((r) => r.signal === 'SELL');
|
|
138
|
+
const holds = reviews.filter((r) => r.signal === 'HOLD');
|
|
139
|
+
|
|
140
|
+
lines.push(` ${reviews.length} position${reviews.length === 1 ? '' : 's'} analyzed | ${sells.length} SELL signal${sells.length === 1 ? '' : 's'} | ${holds.length} HOLD`);
|
|
141
|
+
lines.push('');
|
|
142
|
+
|
|
143
|
+
// Show SELL signals first
|
|
144
|
+
for (const r of sells) {
|
|
145
|
+
const dirLabel = r.direction.toUpperCase();
|
|
146
|
+
const edgePp = `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`;
|
|
147
|
+
lines.push(` ⚠ ${r.ticker} ${dirLabel} ×${r.size}`);
|
|
148
|
+
lines.push(` Edge: ${edgePp} | ${r.reason}`);
|
|
149
|
+
lines.push(` → SELL ${dirLabel} @ ${r.closePriceCents}¢`);
|
|
150
|
+
lines.push(` Command: /sell ${r.ticker} ${r.size} ${r.closePriceCents} ${r.direction}`);
|
|
151
|
+
if (r.analyzeError) {
|
|
152
|
+
lines.push(` ⚠ Analysis error: ${r.analyzeError}`);
|
|
153
|
+
}
|
|
154
|
+
lines.push('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Show HOLD positions
|
|
158
|
+
for (const r of holds) {
|
|
159
|
+
const dirLabel = r.direction.toUpperCase();
|
|
160
|
+
const edgePp = `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`;
|
|
161
|
+
lines.push(` ✓ ${r.ticker} ${dirLabel} ×${r.size}`);
|
|
162
|
+
lines.push(` Edge: ${edgePp} | ${r.reason}`);
|
|
163
|
+
if (r.analyzeError) {
|
|
164
|
+
lines.push(` ⚠ Analysis error: ${r.analyzeError}`);
|
|
165
|
+
}
|
|
166
|
+
lines.push('');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (sells.length > 0) {
|
|
170
|
+
lines.push(` Run the commands above to close flagged positions, or use /analyze <ticker> for details.`);
|
|
171
|
+
} else {
|
|
172
|
+
lines.push(' All positions are within acceptable edge range. No closes recommended.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ScanResult } from '../scan/loop.js';
|
|
2
|
+
import type { EdgeSnapshot } from '../scan/types.js';
|
|
3
|
+
import type { EdgeRow } from '../db/edge.js';
|
|
4
|
+
|
|
5
|
+
function truncate(s: string, max: number): string {
|
|
6
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatTable(headers: string[], rows: string[][]): string {
|
|
10
|
+
const colWidths = headers.map((h, i) =>
|
|
11
|
+
Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const pad = (s: string, w: number) => s.padEnd(w);
|
|
15
|
+
const sep = '─';
|
|
16
|
+
|
|
17
|
+
const topBorder = '┌' + colWidths.map((w) => sep.repeat(w + 2)).join('┬') + '┐';
|
|
18
|
+
const headerRow = '│' + headers.map((h, i) => ` ${pad(h, colWidths[i])} `).join('│') + '│';
|
|
19
|
+
const midBorder = '├' + colWidths.map((w) => sep.repeat(w + 2)).join('┼') + '┤';
|
|
20
|
+
const bottomBorder = '└' + colWidths.map((w) => sep.repeat(w + 2)).join('┴') + '┘';
|
|
21
|
+
|
|
22
|
+
const dataRows = rows.map(
|
|
23
|
+
(row) => '│' + colWidths.map((w, i) => ` ${pad(row[i] ?? '', w)} `).join('│') + '│'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return [topBorder, headerRow, midBorder, ...dataRows, bottomBorder].join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fmtEdge(edge: number): string {
|
|
30
|
+
const pct = (edge * 100).toFixed(1);
|
|
31
|
+
return edge >= 0 ? `+${pct}%` : `${pct}%`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fmtProb(prob: number): string {
|
|
35
|
+
return `${(prob * 100).toFixed(1)}%`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fmtTimestamp(epoch: number): string {
|
|
39
|
+
const d = new Date(epoch * 1000);
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const diffMs = now - d.getTime();
|
|
42
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
43
|
+
|
|
44
|
+
if (diffMin < 1) return 'just now';
|
|
45
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
46
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
47
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
48
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatScanTable(result: ScanResult): string {
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
|
|
54
|
+
if (result.edgeSnapshots.length === 0) {
|
|
55
|
+
lines.push('No edges found in this scan.');
|
|
56
|
+
} else {
|
|
57
|
+
const rows = result.edgeSnapshots.map((s) => [
|
|
58
|
+
s.ticker,
|
|
59
|
+
fmtProb(s.modelProb),
|
|
60
|
+
fmtProb(s.marketProb),
|
|
61
|
+
fmtEdge(s.edge),
|
|
62
|
+
s.confidence,
|
|
63
|
+
truncate(s.drivers[0]?.claim ?? '-', 40),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
lines.push(formatTable(
|
|
67
|
+
['Ticker', 'Model%', 'Market%', 'Edge', 'Confidence', 'Top Driver'],
|
|
68
|
+
rows
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const actionable = result.edgeSnapshots.filter(
|
|
73
|
+
(s) => s.confidence === 'high' || s.confidence === 'very_high'
|
|
74
|
+
).length;
|
|
75
|
+
const secs = (result.duration / 1000).toFixed(1);
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push(`Scanned ${result.eventsScanned} events, found ${actionable} actionable edges in ${secs}s`);
|
|
78
|
+
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatEdgeTable(rows: EdgeRow[]): string {
|
|
83
|
+
if (rows.length === 0) return 'No edges found.';
|
|
84
|
+
|
|
85
|
+
const tableRows = rows.map((r) => [
|
|
86
|
+
r.ticker,
|
|
87
|
+
fmtProb(r.model_prob),
|
|
88
|
+
fmtProb(r.market_prob),
|
|
89
|
+
fmtEdge(r.edge),
|
|
90
|
+
r.confidence ?? '-',
|
|
91
|
+
fmtTimestamp(r.timestamp),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
return formatTable(
|
|
95
|
+
['Ticker', 'Model%', 'Market%', 'Edge', 'Confidence', 'Timestamp'],
|
|
96
|
+
tableRows
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
2
|
+
import type { CLIResponse } from './json.js';
|
|
3
|
+
import type { ScanResult } from '../scan/loop.js';
|
|
4
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
5
|
+
import { getDb } from '../db/index.js';
|
|
6
|
+
import { auditTrail } from '../audit/index.js';
|
|
7
|
+
import { ScanLoop } from '../scan/loop.js';
|
|
8
|
+
import { createOctagonInvoker } from '../scan/invoker.js';
|
|
9
|
+
import { formatScanTable } from './scan-formatters.js';
|
|
10
|
+
|
|
11
|
+
export async function handleScan(args: ParsedArgs): Promise<CLIResponse<ScanResult>> {
|
|
12
|
+
const db = getDb();
|
|
13
|
+
const invoker = createOctagonInvoker();
|
|
14
|
+
const loop = new ScanLoop(db, auditTrail, invoker);
|
|
15
|
+
|
|
16
|
+
const result = await loop.runOnce({
|
|
17
|
+
theme: args.theme ?? 'top50',
|
|
18
|
+
dryRun: args.dryRun,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const actionable = result.edgeSnapshots.filter(
|
|
22
|
+
(s) => s.confidence === 'high' || s.confidence === 'very_high'
|
|
23
|
+
).length;
|
|
24
|
+
|
|
25
|
+
const meta: CLIResponse<ScanResult>['meta'] = {
|
|
26
|
+
scan_id: result.scanId,
|
|
27
|
+
theme: args.theme ?? 'top50',
|
|
28
|
+
events_scanned: result.eventsScanned,
|
|
29
|
+
actionable,
|
|
30
|
+
octagon_credits_used: result.octagonCreditsUsed,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return wrapSuccess('scan', result, meta);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatScanHuman(result: ScanResult): string {
|
|
37
|
+
return formatScanTable(result);
|
|
38
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export interface EdgeMarket {
|
|
4
|
+
market_ticker: string;
|
|
5
|
+
event_ticker: string;
|
|
6
|
+
model_prob: number; // 0-100
|
|
7
|
+
market_prob: number; // 0-100
|
|
8
|
+
edge_pp: number; // model - market
|
|
9
|
+
direction: 'YES' | 'NO';
|
|
10
|
+
series_category: string;
|
|
11
|
+
confidence_score: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EdgeScanResult {
|
|
15
|
+
markets: EdgeMarket[];
|
|
16
|
+
total_scanned: number;
|
|
17
|
+
events_scanned: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scan all cached Octagon events for markets with edge above a threshold.
|
|
22
|
+
* Reads directly from SQLite — zero API calls, instant response.
|
|
23
|
+
*/
|
|
24
|
+
export function scanEdges(
|
|
25
|
+
db: Database,
|
|
26
|
+
opts?: { minEdgePp?: number; limit?: number; category?: string },
|
|
27
|
+
): EdgeScanResult {
|
|
28
|
+
const minEdgePp = opts?.minEdgePp ?? 5;
|
|
29
|
+
const limit = opts?.limit ?? 20;
|
|
30
|
+
const category = opts?.category;
|
|
31
|
+
|
|
32
|
+
const nowIso = new Date().toISOString();
|
|
33
|
+
let inner = `SELECT event_ticker, MAX(fetched_at) as max_fetched
|
|
34
|
+
FROM octagon_reports WHERE variant_used = 'events-api' AND outcome_probabilities_json IS NOT NULL
|
|
35
|
+
AND (close_time IS NULL OR close_time > $now)`;
|
|
36
|
+
const params: Record<string, string> = { $now: nowIso };
|
|
37
|
+
if (category) {
|
|
38
|
+
inner += ' AND LOWER(series_category) LIKE $cat';
|
|
39
|
+
params.$cat = `%${category.toLowerCase()}%`;
|
|
40
|
+
}
|
|
41
|
+
inner += ' GROUP BY event_ticker';
|
|
42
|
+
|
|
43
|
+
const query = `SELECT r.event_ticker, r.series_category, r.confidence_score, r.outcome_probabilities_json
|
|
44
|
+
FROM octagon_reports r
|
|
45
|
+
INNER JOIN (${inner}) latest ON r.event_ticker = latest.event_ticker AND r.fetched_at = latest.max_fetched
|
|
46
|
+
WHERE r.variant_used = 'events-api' AND r.outcome_probabilities_json IS NOT NULL`;
|
|
47
|
+
|
|
48
|
+
const rows = db.query(query).all(params) as Array<{
|
|
49
|
+
event_ticker: string;
|
|
50
|
+
series_category: string | null;
|
|
51
|
+
confidence_score: number | null;
|
|
52
|
+
outcome_probabilities_json: string;
|
|
53
|
+
}>;
|
|
54
|
+
|
|
55
|
+
const allMarkets: EdgeMarket[] = [];
|
|
56
|
+
let totalScanned = 0;
|
|
57
|
+
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
let outcomes: Array<{ market_ticker: string; model_probability: number; market_probability: number }>;
|
|
60
|
+
try {
|
|
61
|
+
outcomes = JSON.parse(row.outcome_probabilities_json);
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(outcomes)) continue;
|
|
66
|
+
|
|
67
|
+
for (const o of outcomes) {
|
|
68
|
+
if (typeof o.model_probability !== 'number' || typeof o.market_probability !== 'number') continue;
|
|
69
|
+
if (!o.market_ticker) continue;
|
|
70
|
+
// Skip illiquid markets with no trading activity
|
|
71
|
+
if (o.market_probability <= 0) continue;
|
|
72
|
+
totalScanned++;
|
|
73
|
+
const edgePp = Math.round((o.model_probability - o.market_probability) * 10) / 10;
|
|
74
|
+
if (Math.abs(edgePp) < minEdgePp) continue;
|
|
75
|
+
|
|
76
|
+
allMarkets.push({
|
|
77
|
+
market_ticker: o.market_ticker,
|
|
78
|
+
event_ticker: row.event_ticker,
|
|
79
|
+
model_prob: o.model_probability,
|
|
80
|
+
market_prob: o.market_probability,
|
|
81
|
+
edge_pp: edgePp,
|
|
82
|
+
direction: edgePp > 0 ? 'YES' : 'NO',
|
|
83
|
+
series_category: row.series_category ?? '',
|
|
84
|
+
confidence_score: row.confidence_score ?? 0,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sort by |edge| descending
|
|
90
|
+
allMarkets.sort((a, b) => Math.abs(b.edge_pp) - Math.abs(a.edge_pp));
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
markets: allMarkets.slice(0, limit),
|
|
94
|
+
total_scanned: totalScanned,
|
|
95
|
+
events_scanned: rows.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatEdgeScanHuman(result: EdgeScanResult, minEdgePp: number): string {
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
lines.push(`Octagon Edge Scanner — ${result.events_scanned} events, ${result.total_scanned} markets scanned`);
|
|
102
|
+
lines.push('════════════════════════════════════════════════════════');
|
|
103
|
+
lines.push('');
|
|
104
|
+
|
|
105
|
+
if (result.markets.length === 0) {
|
|
106
|
+
lines.push(` No markets with |edge| ≥ ${minEdgePp}pp found.`);
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const header = ' ' + [
|
|
111
|
+
'#'.padStart(3),
|
|
112
|
+
'Ticker'.padEnd(35),
|
|
113
|
+
'Model'.padStart(6),
|
|
114
|
+
'Market'.padStart(7),
|
|
115
|
+
'Edge'.padStart(7),
|
|
116
|
+
'Dir'.padStart(5),
|
|
117
|
+
'Category'.padEnd(15),
|
|
118
|
+
].join(' ');
|
|
119
|
+
lines.push(header);
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < result.markets.length; i++) {
|
|
122
|
+
const m = result.markets[i];
|
|
123
|
+
const row = ' ' + [
|
|
124
|
+
String(i + 1).padStart(3),
|
|
125
|
+
m.market_ticker.padEnd(35),
|
|
126
|
+
`${m.model_prob.toFixed(0)}%`.padStart(6),
|
|
127
|
+
`${m.market_prob.toFixed(0)}%`.padStart(7),
|
|
128
|
+
`${m.edge_pp >= 0 ? '+' : ''}${m.edge_pp.toFixed(0)}pp`.padStart(7),
|
|
129
|
+
m.direction.padStart(5),
|
|
130
|
+
m.series_category.padEnd(15),
|
|
131
|
+
].join(' ');
|
|
132
|
+
lines.push(row);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
lines.push('');
|
|
136
|
+
lines.push(`${result.markets.length} markets with |edge| ≥ ${minEdgePp}pp`);
|
|
137
|
+
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|