kalshi-trading-bot-cli 2.1.6 → 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 +66 -27
- 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[];
|
|
@@ -228,6 +241,16 @@ export async function handleAnalyze(
|
|
|
228
241
|
const latestDbReport = getLatestReport(db, resolvedTicker);
|
|
229
242
|
const reportAge = latestDbReport ? formatAge(latestDbReport.fetched_at) : null;
|
|
230
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
|
+
|
|
231
254
|
const snapshot = edgeComputer.computeEdge(resolvedTicker, report, marketProb);
|
|
232
255
|
|
|
233
256
|
// Persist edge
|
|
@@ -264,7 +287,9 @@ export async function handleAnalyze(
|
|
|
264
287
|
liquidityAdjusted: false,
|
|
265
288
|
};
|
|
266
289
|
let kelly: KellyResult;
|
|
267
|
-
if (!
|
|
290
|
+
if (!canComputeEdge) {
|
|
291
|
+
// Either no model coverage or no last_price → any sizing computed from
|
|
292
|
+
// a placeholder modelProb / marketProb would be meaningless.
|
|
268
293
|
kelly = emptyKelly;
|
|
269
294
|
} else {
|
|
270
295
|
try {
|
|
@@ -316,9 +341,16 @@ export async function handleAnalyze(
|
|
|
316
341
|
const entryPrice = (snapshot.edge > 0 ? yesAsk : noAsk);
|
|
317
342
|
|
|
318
343
|
let signal: string;
|
|
319
|
-
if (!
|
|
320
|
-
//
|
|
321
|
-
|
|
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})`;
|
|
322
354
|
} else if (existingPosition) {
|
|
323
355
|
const holdDir = existingPosition.direction.toUpperCase();
|
|
324
356
|
const edgeReversed =
|
|
@@ -365,11 +397,9 @@ export async function handleAnalyze(
|
|
|
365
397
|
? latestDbReport.analysis_last_updated.replace('T', ' ').slice(0, 16) + ' UTC'
|
|
366
398
|
: null;
|
|
367
399
|
|
|
368
|
-
// hasModel
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
const hasModel = !report.cacheMiss && Number.isFinite(snapshot.modelProb)
|
|
372
|
-
&& !(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.
|
|
373
403
|
|
|
374
404
|
// staleUpstream = user asked for --refresh but Octagon's upstream model run
|
|
375
405
|
// timestamp didn't move. Cache fetch time bumped, but the underlying report
|
|
@@ -380,6 +410,11 @@ export async function handleAnalyze(
|
|
|
380
410
|
&& latestDbReport?.analysis_last_updated != null
|
|
381
411
|
&& preRefreshAnalysis === latestDbReport.analysis_last_updated;
|
|
382
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.)
|
|
383
418
|
return {
|
|
384
419
|
ticker: resolvedTicker,
|
|
385
420
|
eventTicker,
|
|
@@ -390,12 +425,12 @@ export async function handleAnalyze(
|
|
|
390
425
|
staleUpstream,
|
|
391
426
|
hasModel,
|
|
392
427
|
hasMarketPrice,
|
|
393
|
-
modelProb: snapshot.modelProb,
|
|
394
|
-
marketProb,
|
|
395
|
-
edge: snapshot.edge,
|
|
396
|
-
edgePp,
|
|
397
|
-
confidence: snapshot.confidence,
|
|
398
|
-
mispricingSignal,
|
|
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,
|
|
399
434
|
signal,
|
|
400
435
|
drivers: snapshot.drivers,
|
|
401
436
|
catalysts: snapshot.catalysts,
|
|
@@ -437,17 +472,17 @@ export function formatAnalyzeHuman(data: AnalyzeData): string {
|
|
|
437
472
|
// hasMarketPrice=false → Kalshi market has no last_price → Market Prob shows "--"
|
|
438
473
|
// Edge needs both. Either being false means edge/confidence/mispricing
|
|
439
474
|
// render "--" — we never show a number derived from a placeholder.
|
|
440
|
-
const modelStr = data.hasModel
|
|
475
|
+
const modelStr = data.hasModel && data.modelProb != null
|
|
441
476
|
? `${(data.modelProb * 100).toFixed(1)}%`
|
|
442
477
|
: `-- (no Octagon model coverage for this market)`;
|
|
443
|
-
const marketStr = data.hasMarketPrice
|
|
478
|
+
const marketStr = data.hasMarketPrice && data.marketProb != null
|
|
444
479
|
? `${(data.marketProb * 100).toFixed(1)}%`
|
|
445
480
|
: `-- (no last traded price — market hasn't traded yet)`;
|
|
446
|
-
const canComputeEdge = data.hasModel && data.hasMarketPrice;
|
|
481
|
+
const canComputeEdge = data.hasModel && data.hasMarketPrice && data.edge != null;
|
|
447
482
|
lines.push(` Model Prob: ${modelStr}`);
|
|
448
483
|
lines.push(` Market Prob: ${marketStr}`);
|
|
449
484
|
if (canComputeEdge) {
|
|
450
|
-
lines.push(` Edge: ${data.edgePp} (${(data.edge * 100).toFixed(1)}%)`);
|
|
485
|
+
lines.push(` Edge: ${data.edgePp} (${(data.edge! * 100).toFixed(1)}%)`);
|
|
451
486
|
lines.push(` Confidence: ${data.confidence}`);
|
|
452
487
|
lines.push(` Mispricing: ${data.mispricingSignal}`);
|
|
453
488
|
} else {
|
|
@@ -624,8 +659,12 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
|
|
|
624
659
|
// Close position: sell what we hold
|
|
625
660
|
const sellSide = data.existingPosition.direction;
|
|
626
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;
|
|
627
666
|
const closePrice = data.closePriceCents ?? Math.round(
|
|
628
|
-
(sellSide === 'yes' ?
|
|
667
|
+
(sellSide === 'yes' ? mp : 1 - mp) * 100
|
|
629
668
|
);
|
|
630
669
|
|
|
631
670
|
console.log(` Signal: SELL ${sellSize} ${sellSide.toUpperCase()} @ ${closePrice}¢ (close position)`);
|
|
@@ -701,7 +740,7 @@ export async function promptAnalyzeActions(data: AnalyzeData): Promise<void> {
|
|
|
701
740
|
break;
|
|
702
741
|
}
|
|
703
742
|
|
|
704
|
-
const side = data.edge > 0 ? 'yes' : 'no';
|
|
743
|
+
const side = (data.edge ?? 0) > 0 ? 'yes' : 'no';
|
|
705
744
|
const price = data.kelly.entryPriceCents;
|
|
706
745
|
console.log(` Signal: BUY ${data.kelly.contracts} ${side.toUpperCase()} @ ${price}¢`);
|
|
707
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,
|