kalshi-trading-bot-cli 2.1.5 → 2.1.7
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/package.json +1 -1
- package/src/commands/analyze.ts +149 -72
- package/src/commands/review.ts +28 -17
- package/src/scan/octagon-client.ts +130 -2
- package/src/tools/v2/portfolio-review.ts +1 -1
package/package.json
CHANGED
package/src/commands/analyze.ts
CHANGED
|
@@ -35,12 +35,25 @@ export interface AnalyzeData {
|
|
|
35
35
|
* cache time but didn't get a newer underlying report from Octagon.
|
|
36
36
|
*/
|
|
37
37
|
staleUpstream: boolean;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Octagon's model probability for this market. null when hasModel is
|
|
40
|
+
* false — we deliberately do NOT emit the 0.5 placeholder fallback to
|
|
41
|
+
* JSON consumers. Always check hasModel before reading this field.
|
|
42
|
+
*/
|
|
43
|
+
modelProb: number | null;
|
|
44
|
+
/**
|
|
45
|
+
* Last traded market probability. null when hasMarketPrice is false.
|
|
46
|
+
* Always check hasMarketPrice before reading.
|
|
47
|
+
*/
|
|
48
|
+
marketProb: number | null;
|
|
49
|
+
/** modelProb − marketProb. null when either input is unavailable. */
|
|
50
|
+
edge: number | null;
|
|
51
|
+
/** Pretty-printed edge ("+14pp"). null when edge is null. */
|
|
52
|
+
edgePp: string | null;
|
|
53
|
+
/** "very_high" | "high" | "moderate" | "low" — null when edge is null. */
|
|
54
|
+
confidence: string | null;
|
|
55
|
+
/** "underpriced" | "overpriced" | "fair_value" — null when edge is null. */
|
|
56
|
+
mispricingSignal: string | null;
|
|
44
57
|
signal: string;
|
|
45
58
|
drivers: PriceDriver[];
|
|
46
59
|
catalysts: Catalyst[];
|
|
@@ -55,6 +68,17 @@ export interface AnalyzeData {
|
|
|
55
68
|
* not as the 0.5 placeholder.
|
|
56
69
|
*/
|
|
57
70
|
hasModel: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* True when the Kalshi market has a `last_price` (it has actually traded).
|
|
73
|
+
* When false, market_prob/edge/Kelly cannot be computed; the formatter
|
|
74
|
+
* renders "--" for those fields and notes that the report was generated
|
|
75
|
+
* without a tradeable price reference.
|
|
76
|
+
*
|
|
77
|
+
* The Octagon report itself still loads — only the trading-side math is
|
|
78
|
+
* skipped. This is the common case for newly-listed event-level markets
|
|
79
|
+
* (e.g. World Cup quarterfinal contracts before the bracket is set).
|
|
80
|
+
*/
|
|
81
|
+
hasMarketPrice: boolean;
|
|
58
82
|
reportAge: string | null;
|
|
59
83
|
reportId: string;
|
|
60
84
|
rawReport: string;
|
|
@@ -168,10 +192,16 @@ export async function handleAnalyze(
|
|
|
168
192
|
const market = await resolveMarket(ticker);
|
|
169
193
|
const resolvedTicker = market.ticker;
|
|
170
194
|
const eventTicker = market.event_ticker;
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
195
|
+
const rawMarketProb = parseMarketProb(market);
|
|
196
|
+
const hasMarketPrice = rawMarketProb !== null;
|
|
197
|
+
|
|
198
|
+
// Many event-level Kalshi tickers exist before any contract has traded
|
|
199
|
+
// (World Cup brackets, FOMC date ladders, IPO timing — there's no
|
|
200
|
+
// last_price until someone takes a side). The Octagon report path doesn't
|
|
201
|
+
// need market_prob — only the edge / Kelly / risk-gate math does. So we
|
|
202
|
+
// keep going with a neutral fallback and mark hasMarketPrice = false so
|
|
203
|
+
// the formatter renders "--" for the trading-side fields.
|
|
204
|
+
const marketProb = hasMarketPrice ? rawMarketProb! : 0.5;
|
|
175
205
|
|
|
176
206
|
const invoker = createOctagonInvoker();
|
|
177
207
|
const octagonClient = new OctagonClient(invoker, db, auditTrail);
|
|
@@ -211,6 +241,16 @@ export async function handleAnalyze(
|
|
|
211
241
|
const latestDbReport = getLatestReport(db, resolvedTicker);
|
|
212
242
|
const reportAge = latestDbReport ? formatAge(latestDbReport.fetched_at) : null;
|
|
213
243
|
|
|
244
|
+
// Decide trading-side gating BEFORE running edge / Kelly / signal math.
|
|
245
|
+
// hasModel uses report.modelProb directly (snapshot.modelProb is just
|
|
246
|
+
// propagated unchanged from computeEdge — verified in edge-computer.ts:38).
|
|
247
|
+
// canComputeEdge is the contract: any trading decision (signal, Kelly,
|
|
248
|
+
// mispricing) must check it first. Otherwise we'd build a "BUY YES @ $X"
|
|
249
|
+
// recommendation from a 0.5 placeholder modelProb on uncovered events.
|
|
250
|
+
const hasModel = !report.cacheMiss && Number.isFinite(report.modelProb)
|
|
251
|
+
&& !(report.modelProb === 0.5 && report.drivers.length === 0 && report.catalysts.length === 0);
|
|
252
|
+
const canComputeEdge = hasModel && hasMarketPrice;
|
|
253
|
+
|
|
214
254
|
const snapshot = edgeComputer.computeEdge(resolvedTicker, report, marketProb);
|
|
215
255
|
|
|
216
256
|
// Persist edge
|
|
@@ -230,30 +270,39 @@ export async function handleAnalyze(
|
|
|
230
270
|
confidence: snapshot.confidence,
|
|
231
271
|
});
|
|
232
272
|
|
|
233
|
-
// Kelly sizing — wrapped in try/catch for demo mode (portfolio endpoints may 401)
|
|
273
|
+
// Kelly sizing — wrapped in try/catch for demo mode (portfolio endpoints may 401).
|
|
274
|
+
// Skip Kelly entirely when there's no last_price: any sizing computed from
|
|
275
|
+
// a 50% market_prob fallback would be meaningless.
|
|
276
|
+
const emptyKelly: KellyResult = {
|
|
277
|
+
side: snapshot.edge >= 0 ? 'yes' : 'no',
|
|
278
|
+
fraction: 0,
|
|
279
|
+
adjustedFraction: 0,
|
|
280
|
+
contracts: 0,
|
|
281
|
+
dollarAmountCents: 0,
|
|
282
|
+
entryPriceCents: 0,
|
|
283
|
+
availableBankroll: 0,
|
|
284
|
+
openExposure: 0,
|
|
285
|
+
cashBalance: 0,
|
|
286
|
+
portfolioValue: 0,
|
|
287
|
+
liquidityAdjusted: false,
|
|
288
|
+
};
|
|
234
289
|
let kelly: KellyResult;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
availableBankroll: 0,
|
|
252
|
-
openExposure: 0,
|
|
253
|
-
cashBalance: 0,
|
|
254
|
-
portfolioValue: 0,
|
|
255
|
-
liquidityAdjusted: false,
|
|
256
|
-
};
|
|
290
|
+
if (!canComputeEdge) {
|
|
291
|
+
// Either no model coverage or no last_price → any sizing computed from
|
|
292
|
+
// a placeholder modelProb / marketProb would be meaningless.
|
|
293
|
+
kelly = emptyKelly;
|
|
294
|
+
} else {
|
|
295
|
+
try {
|
|
296
|
+
kelly = await kellySize({
|
|
297
|
+
edge: snapshot.edge,
|
|
298
|
+
marketProb,
|
|
299
|
+
market,
|
|
300
|
+
multiplier: getBotSetting('risk.kelly_multiplier') as number | undefined,
|
|
301
|
+
minEdgeThreshold: getBotSetting('risk.min_edge_threshold') as number | undefined,
|
|
302
|
+
});
|
|
303
|
+
} catch {
|
|
304
|
+
kelly = { ...emptyKelly };
|
|
305
|
+
}
|
|
257
306
|
}
|
|
258
307
|
|
|
259
308
|
// Risk gate
|
|
@@ -292,7 +341,17 @@ export async function handleAnalyze(
|
|
|
292
341
|
const entryPrice = (snapshot.edge > 0 ? yesAsk : noAsk);
|
|
293
342
|
|
|
294
343
|
let signal: string;
|
|
295
|
-
if (
|
|
344
|
+
if (!canComputeEdge) {
|
|
345
|
+
// Any actionable signal needs both a real model probability and a real
|
|
346
|
+
// last_price. Spell out which one is missing so the user / bot knows
|
|
347
|
+
// why we're not making a recommendation.
|
|
348
|
+
const reason = !hasModel && !hasMarketPrice
|
|
349
|
+
? 'no Octagon model coverage and no last traded price'
|
|
350
|
+
: !hasModel
|
|
351
|
+
? 'no Octagon model coverage for this market'
|
|
352
|
+
: 'market has no last traded price';
|
|
353
|
+
signal = `no signal (${reason})`;
|
|
354
|
+
} else if (existingPosition) {
|
|
296
355
|
const holdDir = existingPosition.direction.toUpperCase();
|
|
297
356
|
const edgeReversed =
|
|
298
357
|
(existingPosition.direction === 'yes' && snapshot.edge < -0.03) ||
|
|
@@ -338,11 +397,9 @@ export async function handleAnalyze(
|
|
|
338
397
|
? latestDbReport.analysis_last_updated.replace('T', ' ').slice(0, 16) + ' UTC'
|
|
339
398
|
: null;
|
|
340
399
|
|
|
341
|
-
// hasModel
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
const hasModel = !report.cacheMiss && Number.isFinite(snapshot.modelProb)
|
|
345
|
-
&& !(snapshot.modelProb === 0.5 && report.drivers.length === 0 && report.catalysts.length === 0);
|
|
400
|
+
// hasModel + canComputeEdge were computed earlier (above Kelly/signal),
|
|
401
|
+
// so trading-side math never reads a placeholder edge. See top of
|
|
402
|
+
// handleAnalyze for the contract.
|
|
346
403
|
|
|
347
404
|
// staleUpstream = user asked for --refresh but Octagon's upstream model run
|
|
348
405
|
// timestamp didn't move. Cache fetch time bumped, but the underlying report
|
|
@@ -353,6 +410,11 @@ export async function handleAnalyze(
|
|
|
353
410
|
&& latestDbReport?.analysis_last_updated != null
|
|
354
411
|
&& preRefreshAnalysis === latestDbReport.analysis_last_updated;
|
|
355
412
|
|
|
413
|
+
// Null out trading-side fields when the underlying inputs are unavailable.
|
|
414
|
+
// JSON consumers previously saw modelProb: 0.5 / marketProb: 0.5 / edge: 0
|
|
415
|
+
// on degraded paths and treated them as real predictions. The hasModel and
|
|
416
|
+
// hasMarketPrice flags are the source of truth — fields here mirror them.
|
|
417
|
+
// (canComputeEdge was already evaluated at the top of the function.)
|
|
356
418
|
return {
|
|
357
419
|
ticker: resolvedTicker,
|
|
358
420
|
eventTicker,
|
|
@@ -362,12 +424,13 @@ export async function handleAnalyze(
|
|
|
362
424
|
modelRunAt,
|
|
363
425
|
staleUpstream,
|
|
364
426
|
hasModel,
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
427
|
+
hasMarketPrice,
|
|
428
|
+
modelProb: hasModel ? snapshot.modelProb : null,
|
|
429
|
+
marketProb: hasMarketPrice ? marketProb : null,
|
|
430
|
+
edge: canComputeEdge ? snapshot.edge : null,
|
|
431
|
+
edgePp: canComputeEdge ? edgePp : null,
|
|
432
|
+
confidence: canComputeEdge ? snapshot.confidence : null,
|
|
433
|
+
mispricingSignal: canComputeEdge ? mispricingSignal : null,
|
|
371
434
|
signal,
|
|
372
435
|
drivers: snapshot.drivers,
|
|
373
436
|
catalysts: snapshot.catalysts,
|
|
@@ -404,19 +467,25 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
|
|
|
404
467
|
}
|
|
405
468
|
lines.push('');
|
|
406
469
|
|
|
407
|
-
// Edge & Probabilities.
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
470
|
+
// Edge & Probabilities. Two independent reasons a field may be unavailable:
|
|
471
|
+
// hasModel=false → Octagon has no model scoring → Model Prob shows "--"
|
|
472
|
+
// hasMarketPrice=false → Kalshi market has no last_price → Market Prob shows "--"
|
|
473
|
+
// Edge needs both. Either being false means edge/confidence/mispricing
|
|
474
|
+
// render "--" — we never show a number derived from a placeholder.
|
|
475
|
+
const modelStr = data.hasModel && data.modelProb != null
|
|
476
|
+
? `${(data.modelProb * 100).toFixed(1)}%`
|
|
477
|
+
: `-- (no Octagon model coverage for this market)`;
|
|
478
|
+
const marketStr = data.hasMarketPrice && data.marketProb != null
|
|
479
|
+
? `${(data.marketProb * 100).toFixed(1)}%`
|
|
480
|
+
: `-- (no last traded price — market hasn't traded yet)`;
|
|
481
|
+
const canComputeEdge = data.hasModel && data.hasMarketPrice && data.edge != null;
|
|
482
|
+
lines.push(` Model Prob: ${modelStr}`);
|
|
483
|
+
lines.push(` Market Prob: ${marketStr}`);
|
|
484
|
+
if (canComputeEdge) {
|
|
485
|
+
lines.push(` Edge: ${data.edgePp} (${(data.edge! * 100).toFixed(1)}%)`);
|
|
415
486
|
lines.push(` Confidence: ${data.confidence}`);
|
|
416
487
|
lines.push(` Mispricing: ${data.mispricingSignal}`);
|
|
417
488
|
} else {
|
|
418
|
-
lines.push(` Model Prob: -- (no Octagon model coverage for this market)`);
|
|
419
|
-
lines.push(` Market Prob: ${(data.marketProb * 100).toFixed(1)}%`);
|
|
420
489
|
lines.push(` Edge: --`);
|
|
421
490
|
lines.push(` Confidence: --`);
|
|
422
491
|
lines.push(` Mispricing: --`);
|
|
@@ -449,22 +518,26 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
|
|
|
449
518
|
lines.push('');
|
|
450
519
|
}
|
|
451
520
|
|
|
452
|
-
//
|
|
521
|
+
// Position Sizing — only meaningful when there's a tradeable price.
|
|
453
522
|
lines.push(' Position Sizing (Half-Kelly):');
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
lines.push(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
523
|
+
if (!data.hasMarketPrice) {
|
|
524
|
+
lines.push(' ⚠ Skipped — market has no last traded price; no sizing reference available.');
|
|
525
|
+
} else {
|
|
526
|
+
lines.push(` Side: ${data.kelly.side.toUpperCase()}`);
|
|
527
|
+
lines.push(` Cash Balance: $${(data.kelly.cashBalance / 100).toFixed(2)}`);
|
|
528
|
+
lines.push(` Open Exposure: $${(data.kelly.openExposure / 100).toFixed(2)}`);
|
|
529
|
+
lines.push(` Available: $${(data.kelly.availableBankroll / 100).toFixed(2)}`);
|
|
530
|
+
lines.push(` Contracts: ${data.kelly.contracts}`);
|
|
531
|
+
lines.push(` Dollar Amount: $${(data.kelly.dollarAmountCents / 100).toFixed(2)}`);
|
|
532
|
+
lines.push(` Entry Price: ${data.kelly.entryPriceCents}¢`);
|
|
533
|
+
lines.push(` Kelly f*: ${(data.kelly.fraction * 100).toFixed(1)}%`);
|
|
534
|
+
lines.push(` Adjusted f: ${(data.kelly.adjustedFraction * 100).toFixed(1)}%`);
|
|
535
|
+
if (data.kelly.liquidityAdjusted) {
|
|
536
|
+
lines.push(' ⚠ Liquidity-adjusted (wide spread or low volume)');
|
|
537
|
+
}
|
|
538
|
+
if (data.kelly.skippedReason) {
|
|
539
|
+
lines.push(` ⚠ ${data.kelly.skippedReason}`);
|
|
540
|
+
}
|
|
468
541
|
}
|
|
469
542
|
lines.push('');
|
|
470
543
|
|
|
@@ -586,8 +659,12 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
|
|
|
586
659
|
// Close position: sell what we hold
|
|
587
660
|
const sellSide = data.existingPosition.direction;
|
|
588
661
|
const sellSize = data.existingPosition.size;
|
|
662
|
+
// marketProb is guaranteed when isSell is reachable (we got a SELL
|
|
663
|
+
// recommendation, which requires a price), but type system can't
|
|
664
|
+
// see that — fall back to 50 if data was tampered with.
|
|
665
|
+
const mp = data.marketProb ?? 0.5;
|
|
589
666
|
const closePrice = data.closePriceCents ?? Math.round(
|
|
590
|
-
(sellSide === 'yes' ?
|
|
667
|
+
(sellSide === 'yes' ? mp : 1 - mp) * 100
|
|
591
668
|
);
|
|
592
669
|
|
|
593
670
|
console.log(` Signal: SELL ${sellSize} ${sellSide.toUpperCase()} @ ${closePrice}¢ (close position)`);
|
|
@@ -663,7 +740,7 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
|
|
|
663
740
|
break;
|
|
664
741
|
}
|
|
665
742
|
|
|
666
|
-
const side = data.edge > 0 ? 'yes' : 'no';
|
|
743
|
+
const side = (data.edge ?? 0) > 0 ? 'yes' : 'no';
|
|
667
744
|
const price = data.kelly.entryPriceCents;
|
|
668
745
|
console.log(` Signal: BUY ${data.kelly.contracts} ${side.toUpperCase()} @ ${price}¢`);
|
|
669
746
|
const confirm = await ask(' Execute? [y/n] ');
|
package/src/commands/review.ts
CHANGED
|
@@ -10,9 +10,12 @@ export interface PositionReview {
|
|
|
10
10
|
direction: 'yes' | 'no';
|
|
11
11
|
size: number;
|
|
12
12
|
entryPrice: number | null;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/** null when analyze couldn't read a last_price for the market. */
|
|
14
|
+
currentMarketProb: number | null;
|
|
15
|
+
/** null when Octagon has no model coverage for the market. */
|
|
16
|
+
modelProb: number | null;
|
|
17
|
+
/** null when either currentMarketProb or modelProb is null. */
|
|
18
|
+
edge: number | null;
|
|
16
19
|
signal: 'HOLD' | 'SELL';
|
|
17
20
|
sellSide: 'yes' | 'no';
|
|
18
21
|
closePriceCents: number;
|
|
@@ -62,9 +65,9 @@ export async function reviewPortfolio(): Promise<PositionReview[]> {
|
|
|
62
65
|
direction,
|
|
63
66
|
size,
|
|
64
67
|
entryPrice: null,
|
|
65
|
-
currentMarketProb:
|
|
66
|
-
modelProb:
|
|
67
|
-
edge:
|
|
68
|
+
currentMarketProb: null,
|
|
69
|
+
modelProb: null,
|
|
70
|
+
edge: null,
|
|
68
71
|
signal: 'HOLD' as const,
|
|
69
72
|
sellSide: direction,
|
|
70
73
|
closePriceCents: 0,
|
|
@@ -74,13 +77,20 @@ export async function reviewPortfolio(): Promise<PositionReview[]> {
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
const analysis: AnalyzeData = result.value;
|
|
77
|
-
const {
|
|
80
|
+
const { marketProb, modelProb, kelly } = analysis;
|
|
78
81
|
|
|
79
|
-
// Determine if edge has reversed against our position
|
|
82
|
+
// Determine if edge has reversed against our position.
|
|
83
|
+
// When edge is null (no model coverage or no last_price), we can't make
|
|
84
|
+
// a quantitative call — hold and surface the data gap as the reason.
|
|
80
85
|
let signal: 'HOLD' | 'SELL' = 'HOLD';
|
|
81
86
|
let reason = '';
|
|
87
|
+
const edge = analysis.edge;
|
|
82
88
|
|
|
83
|
-
if (
|
|
89
|
+
if (edge == null) {
|
|
90
|
+
reason = !analysis.hasModel
|
|
91
|
+
? 'No Octagon model coverage — cannot evaluate edge for this position'
|
|
92
|
+
: 'No last traded price — cannot evaluate edge for this position';
|
|
93
|
+
} else if (direction === 'yes' && edge < -SELL_THRESHOLD) {
|
|
84
94
|
signal = 'SELL';
|
|
85
95
|
reason = `Edge reversed: model now favors NO by ${Math.abs(edge * 100).toFixed(0)}pp`;
|
|
86
96
|
} else if (direction === 'no' && edge > SELL_THRESHOLD) {
|
|
@@ -97,15 +107,13 @@ export async function reviewPortfolio(): Promise<PositionReview[]> {
|
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
// Use the bid-derived close price from handleAnalyze when available,
|
|
100
|
-
// fall back to marketProb approximation only if
|
|
110
|
+
// fall back to marketProb approximation only if both are present
|
|
101
111
|
const closePriceCents =
|
|
102
112
|
analysis.closePriceCents && analysis.closePriceCents > 0
|
|
103
113
|
? analysis.closePriceCents
|
|
104
|
-
:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
: (1 - marketProb) * 100 - 1
|
|
108
|
-
);
|
|
114
|
+
: marketProb != null
|
|
115
|
+
? Math.round(direction === 'yes' ? marketProb * 100 - 1 : (1 - marketProb) * 100 - 1)
|
|
116
|
+
: 0;
|
|
109
117
|
|
|
110
118
|
return {
|
|
111
119
|
ticker: pos.ticker,
|
|
@@ -140,10 +148,13 @@ export function formatReviewHuman(reviews: PositionReview[]): string {
|
|
|
140
148
|
lines.push(` ${reviews.length} position${reviews.length === 1 ? '' : 's'} analyzed | ${sells.length} SELL signal${sells.length === 1 ? '' : 's'} | ${holds.length} HOLD`);
|
|
141
149
|
lines.push('');
|
|
142
150
|
|
|
151
|
+
const edgePpStr = (edge: number | null): string =>
|
|
152
|
+
edge == null ? '--' : `${edge >= 0 ? '+' : ''}${(edge * 100).toFixed(0)}pp`;
|
|
153
|
+
|
|
143
154
|
// Show SELL signals first
|
|
144
155
|
for (const r of sells) {
|
|
145
156
|
const dirLabel = r.direction.toUpperCase();
|
|
146
|
-
const edgePp =
|
|
157
|
+
const edgePp = edgePpStr(r.edge);
|
|
147
158
|
lines.push(` ⚠ ${r.ticker} ${dirLabel} ×${r.size}`);
|
|
148
159
|
lines.push(` Edge: ${edgePp} | ${r.reason}`);
|
|
149
160
|
lines.push(` → SELL ${dirLabel} @ ${r.closePriceCents}¢`);
|
|
@@ -157,7 +168,7 @@ export function formatReviewHuman(reviews: PositionReview[]): string {
|
|
|
157
168
|
// Show HOLD positions
|
|
158
169
|
for (const r of holds) {
|
|
159
170
|
const dirLabel = r.direction.toUpperCase();
|
|
160
|
-
const edgePp =
|
|
171
|
+
const edgePp = edgePpStr(r.edge);
|
|
161
172
|
lines.push(` ✓ ${r.ticker} ${dirLabel} ×${r.size}`);
|
|
162
173
|
lines.push(` Edge: ${edgePp} | ${r.reason}`);
|
|
163
174
|
if (r.analyzeError) {
|
|
@@ -415,10 +415,28 @@ export class OctagonClient {
|
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
private extractFromMarkdown(raw: string, defaults: OctagonReport): OctagonReport {
|
|
418
|
+
// For multi-outcome reports (World Cup Silver Ball, FOMC ladders, IPO
|
|
419
|
+
// event trees), the per-outcome probabilities live in markdown tables
|
|
420
|
+
// like:
|
|
421
|
+
// | Player | Market % | Model % |
|
|
422
|
+
// |--------------|----------|---------|
|
|
423
|
+
// | Harry Kane | 35.0% | 36.0% |
|
|
424
|
+
// | Lionel Messi | 29.0% | 40.1% |
|
|
425
|
+
// The simple key:value regex below misses these and leaves the
|
|
426
|
+
// 0.5/0.5 placeholder. Try table extraction first, matching against the
|
|
427
|
+
// outcome suffix of the ticker (e.g. KXWCAWARD-26SBALL-HKANE → Kane).
|
|
428
|
+
let modelProb = this.extractProbFromMarkdownTable(raw, defaults.ticker, 'model');
|
|
429
|
+
let marketProb = this.extractProbFromMarkdownTable(raw, defaults.ticker, 'market');
|
|
430
|
+
if (modelProb === null) {
|
|
431
|
+
modelProb = this.extractProb(raw, /model\s*(?:prob(?:ability)?|estimate)\s*[:=]\s*([\d.]+%?)/i);
|
|
432
|
+
}
|
|
433
|
+
if (marketProb === null) {
|
|
434
|
+
marketProb = this.extractProb(raw, /market\s*(?:prob(?:ability)?|price)\s*[:=]\s*([\d.]+%?)/i);
|
|
435
|
+
}
|
|
418
436
|
return {
|
|
419
437
|
...defaults,
|
|
420
|
-
modelProb:
|
|
421
|
-
marketProb:
|
|
438
|
+
modelProb: modelProb ?? defaults.modelProb,
|
|
439
|
+
marketProb: marketProb ?? defaults.marketProb,
|
|
422
440
|
mispricingSignal: this.extractSignal(raw) ?? defaults.mispricingSignal,
|
|
423
441
|
drivers: this.extractDrivers(raw),
|
|
424
442
|
catalysts: this.extractCatalysts(raw),
|
|
@@ -428,6 +446,116 @@ export class OctagonClient {
|
|
|
428
446
|
};
|
|
429
447
|
}
|
|
430
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Parse markdown tables in `raw` looking for a Model/Market % grid, then
|
|
451
|
+
* find the row matching `ticker`'s outcome suffix and return that row's
|
|
452
|
+
* `kind` (model | market) probability as a 0-1 fraction. Returns null when
|
|
453
|
+
* no table is found, no row matches, or the percentage can't be parsed.
|
|
454
|
+
*
|
|
455
|
+
* Matching strategy: the outcome suffix is whatever follows the last `-`
|
|
456
|
+
* in the ticker (e.g. KXWCAWARD-26SBALL-HKANE → "HKANE"). We compare it
|
|
457
|
+
* against each row's first cell (the name) by stripping non-letters and
|
|
458
|
+
* checking if either string contains the other (case-insensitive). This
|
|
459
|
+
* handles the common patterns: initial+lastname ("HKANE" ⊆ "harrykane"),
|
|
460
|
+
* country abbreviations ("MEX" ⊆ "mexico"), full names, etc.
|
|
461
|
+
*
|
|
462
|
+
* If multiple rows match we pick the strongest (longest overlap) to keep
|
|
463
|
+
* abbreviations like "MEX" from accidentally matching "MEXICO CITY".
|
|
464
|
+
*/
|
|
465
|
+
private extractProbFromMarkdownTable(
|
|
466
|
+
raw: string,
|
|
467
|
+
ticker: string,
|
|
468
|
+
kind: 'model' | 'market',
|
|
469
|
+
): number | null {
|
|
470
|
+
const suffix = ticker.split('-').pop()?.toUpperCase() ?? '';
|
|
471
|
+
if (!suffix || suffix.length < 2) return null;
|
|
472
|
+
const tickerKey = suffix.replace(/[^A-Z0-9]/g, '');
|
|
473
|
+
|
|
474
|
+
// Split into possible table blocks. Each block is a contiguous run of
|
|
475
|
+
// lines that start with `|`. A real table has >=3 lines (header, sep,
|
|
476
|
+
// body), at least 3 columns, and at least two columns whose values are
|
|
477
|
+
// percentages.
|
|
478
|
+
const lines = raw.split('\n');
|
|
479
|
+
const blocks: string[][] = [];
|
|
480
|
+
let cur: string[] = [];
|
|
481
|
+
for (const line of lines) {
|
|
482
|
+
if (line.trim().startsWith('|')) {
|
|
483
|
+
cur.push(line);
|
|
484
|
+
} else {
|
|
485
|
+
if (cur.length > 0) blocks.push(cur);
|
|
486
|
+
cur = [];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (cur.length > 0) blocks.push(cur);
|
|
490
|
+
|
|
491
|
+
const parseCells = (line: string): string[] =>
|
|
492
|
+
line.split('|').slice(1, -1).map((c) => c.trim());
|
|
493
|
+
const isPercent = (s: string): boolean => /^-?\d+(\.\d+)?%?$/.test(s.replace(/[,$]/g, ''));
|
|
494
|
+
const parsePercent = (s: string): number | null => {
|
|
495
|
+
// Honor an explicit % marker in the input — "0.9%" must be 0.009, not
|
|
496
|
+
// 0.9, and "1%" must be 0.01. The legacy `n > 1 ? n/100 : n` heuristic
|
|
497
|
+
// only works when the value is unambiguously > 1 (e.g. "35%") and
|
|
498
|
+
// misreads sub-1 percentages. We only fall back to the heuristic when
|
|
499
|
+
// there's no % sign at all (raw fractions like "0.35").
|
|
500
|
+
const hasPercentSign = s.includes('%');
|
|
501
|
+
const cleaned = s.replace(/[%,$\s]/g, '');
|
|
502
|
+
const n = parseFloat(cleaned);
|
|
503
|
+
if (!Number.isFinite(n)) return null;
|
|
504
|
+
if (hasPercentSign) return n / 100;
|
|
505
|
+
return n > 1 ? n / 100 : n;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
let bestMatch: { score: number; value: number } | null = null;
|
|
509
|
+
for (const block of blocks) {
|
|
510
|
+
if (block.length < 3) continue;
|
|
511
|
+
const header = parseCells(block[0]).map((c) => c.toLowerCase());
|
|
512
|
+
// Find target column. Accept "model", "model %", "model prob", etc.
|
|
513
|
+
const targetCol = header.findIndex((c) =>
|
|
514
|
+
kind === 'model'
|
|
515
|
+
? /\bmodel\b/.test(c)
|
|
516
|
+
: /\b(market|mkt|kalshi)\b/.test(c) && !/cap/.test(c), // avoid "market cap"
|
|
517
|
+
);
|
|
518
|
+
if (targetCol < 0) continue;
|
|
519
|
+
|
|
520
|
+
// Body starts after the separator row (line index 2). Skip rows whose
|
|
521
|
+
// cells don't look like data (e.g. another separator).
|
|
522
|
+
for (let i = 2; i < block.length; i++) {
|
|
523
|
+
const cells = parseCells(block[i]);
|
|
524
|
+
if (cells.length <= targetCol) continue;
|
|
525
|
+
const cell = cells[targetCol];
|
|
526
|
+
if (!isPercent(cell)) continue;
|
|
527
|
+
const name = (cells[0] ?? '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
|
528
|
+
if (!name) continue;
|
|
529
|
+
// Match strategies (try in order, take strongest):
|
|
530
|
+
// 1. ticker fully inside row name ("MEX" in "MEXICO")
|
|
531
|
+
// 2. row name fully inside ticker ("MESSI" in "LIONELMESSI")
|
|
532
|
+
// 3. ticker suffix without leading initial(s) inside row name
|
|
533
|
+
// ("HKANE" → "KANE" in "HARRYKANE", "LMESSI" → "MESSI" in
|
|
534
|
+
// "LIONELMESSI"). Common Kalshi convention is one or two
|
|
535
|
+
// initials + lastname, so we strip up to 2 leading chars.
|
|
536
|
+
let score = 0;
|
|
537
|
+
if (name.includes(tickerKey)) score = tickerKey.length;
|
|
538
|
+
else if (tickerKey.includes(name)) score = name.length;
|
|
539
|
+
else {
|
|
540
|
+
for (let drop = 1; drop <= Math.min(2, tickerKey.length - 3); drop++) {
|
|
541
|
+
const trimmed = tickerKey.slice(drop);
|
|
542
|
+
if (trimmed.length >= 3 && name.includes(trimmed)) {
|
|
543
|
+
score = Math.max(score, trimmed.length);
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (score === 0) continue;
|
|
549
|
+
const v = parsePercent(cell);
|
|
550
|
+
if (v === null) continue;
|
|
551
|
+
if (!bestMatch || score > bestMatch.score) {
|
|
552
|
+
bestMatch = { score, value: v };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return bestMatch?.value ?? null;
|
|
557
|
+
}
|
|
558
|
+
|
|
431
559
|
private inferSignal(modelProb: number | null, marketProb: number | null): MispricingSignal | null {
|
|
432
560
|
if (modelProb === null || marketProb === null) return null;
|
|
433
561
|
const edge = modelProb - marketProb;
|
|
@@ -27,7 +27,7 @@ export const portfolioReviewTool = new DynamicStructuredTool({
|
|
|
27
27
|
direction: r.direction,
|
|
28
28
|
size: r.size,
|
|
29
29
|
edge: r.edge,
|
|
30
|
-
edgePp: `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`,
|
|
30
|
+
edgePp: r.edge == null ? null : `${r.edge >= 0 ? '+' : ''}${(r.edge * 100).toFixed(0)}pp`,
|
|
31
31
|
signal: r.signal,
|
|
32
32
|
reason: r.reason,
|
|
33
33
|
closePriceCents: r.closePriceCents,
|