Skip to content

Commit 151980b

Browse files
committed
Fix Kalshi broad future event titles
1 parent 87241fd commit 151980b

4 files changed

Lines changed: 387 additions & 21 deletions

File tree

changelog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [2.44.6] - 2026-05-25
6+
7+
### Fixed
8+
9+
- **Kalshi**: Use enriched series titles to normalize contaminated broad-future event titles. Multi-market futures such as Champions League Winner and conference championship winner events no longer inherit current-matchup labels like `PSG vs Arsenal` or `Cleveland vs New York` as their PMXT event title, while true match events keep their matchup titles.
10+
- **Kalshi**: Add regression coverage for contaminated futures, already-sane futures, and true matchup events.
11+
512
## [2.44.5] - 2026-05-25
613

714
### Fixed

core/src/exchanges/kalshi/fetcher.ts

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,23 @@ export interface KalshiRawMarket {
4242
export interface KalshiRawEvent {
4343
event_ticker: string;
4444
title: string;
45+
sub_title?: string;
4546
image_url?: string;
4647
category?: string;
4748
tags?: string[];
4849
series_ticker?: string;
50+
series_title?: string;
51+
mutually_exclusive?: boolean;
4952
markets?: KalshiRawMarket[];
5053

5154
[key: string]: unknown;
5255
}
5356

57+
interface KalshiSeriesInfo {
58+
title?: string;
59+
tags?: string[];
60+
}
61+
5462
export interface KalshiRawEventPage {
5563
events: KalshiRawEvent[];
5664
cursor?: string | null;
@@ -149,7 +157,7 @@ export class KalshiFetcher implements IExchangeFetcher<KalshiRawEvent, KalshiRaw
149157

150158
// Instance-level cache (moved from module-level)
151159
private cachedEvents: KalshiRawEvent[] | null = null;
152-
private cachedSeriesMap: Map<string, string[]> | null = null;
160+
private cachedSeriesMap: Map<string, KalshiSeriesInfo> | null = null;
153161
private lastCacheTime: number = 0;
154162

155163
constructor(ctx: FetcherContext) {
@@ -205,16 +213,16 @@ export class KalshiFetcher implements IExchangeFetcher<KalshiRawEvent, KalshiRaw
205213
this.fetchAllWithStatus('closed'),
206214
this.fetchAllWithStatus('settled'),
207215
]);
208-
return [...openEvents, ...closedEvents, ...settledEvents];
216+
return this.enrichEventsWithSeriesList([...openEvents, ...closedEvents, ...settledEvents]);
209217
} else if (status === 'closed' || status === 'inactive') {
210218
const [closedEvents, settledEvents] = await Promise.all([
211219
this.fetchAllWithStatus('closed'),
212220
this.fetchAllWithStatus('settled'),
213221
]);
214-
return [...closedEvents, ...settledEvents];
222+
return this.enrichEventsWithSeriesList([...closedEvents, ...settledEvents]);
215223
}
216224

217-
return this.fetchAllWithStatus('open');
225+
return this.enrichEventsWithSeriesList(await this.fetchAllWithStatus('open'));
218226
} catch (error: any) {
219227
throw kalshiErrorMapper.mapError(error);
220228
}
@@ -232,7 +240,9 @@ export class KalshiFetcher implements IExchangeFetcher<KalshiRawEvent, KalshiRaw
232240
if (status === 'settled') apiStatus = 'settled';
233241

234242
const limit = Math.max(1, Math.floor(params.limit || BATCH_SIZE));
235-
return this.fetchPageWithStatus(apiStatus, limit, params.cursor);
243+
const page = await this.fetchPageWithStatus(apiStatus, limit, params.cursor);
244+
await this.enrichEventsWithSeriesList(page.events);
245+
return page;
236246
} catch (error: any) {
237247
throw kalshiErrorMapper.mapError(error);
238248
}
@@ -366,14 +376,17 @@ export class KalshiFetcher implements IExchangeFetcher<KalshiRawEvent, KalshiRaw
366376
return data.orders || [];
367377
}
368378

369-
async fetchRawSeriesMap(): Promise<Map<string, string[]>> {
379+
async fetchRawSeriesMap(): Promise<Map<string, KalshiSeriesInfo>> {
370380
try {
371381
const data = await this.ctx.callApi('GetSeriesList');
372382
const seriesList = data.series || [];
373-
const map = new Map<string, string[]>();
383+
const map = new Map<string, KalshiSeriesInfo>();
374384
for (const series of seriesList) {
375-
if (series.tags && series.tags.length > 0) {
376-
map.set(series.ticker, series.tags);
385+
if (series.ticker) {
386+
map.set(series.ticker, {
387+
title: typeof series.title === 'string' ? series.title : undefined,
388+
tags: Array.isArray(series.tags) ? series.tags : undefined,
389+
});
377390
}
378391
}
379392
return map;
@@ -394,19 +407,23 @@ export class KalshiFetcher implements IExchangeFetcher<KalshiRawEvent, KalshiRaw
394407
const event = data.event;
395408
if (!event) return [];
396409

397-
// Enrich with series tags
410+
// Enrich with series metadata. The series title helps the normalizer
411+
// avoid polluted event titles on multi-market futures.
398412
if (event.series_ticker) {
399413
try {
400414
const seriesData = await this.ctx.callApi('GetSeries', {
401415
series_ticker: event.series_ticker,
402416
});
403417
const series = seriesData.series;
418+
if (typeof series?.title === 'string' && series.title.trim()) {
419+
event.series_title = series.title.trim();
420+
}
404421
if (series?.tags?.length > 0 && (!event.tags || event.tags.length === 0)) {
405422
event.tags = series.tags;
406423
}
407424
} catch (err: unknown) {
408-
// Non-critical — tags are enrichment only.
409-
logger.warn('kalshi: series tag fetch failed', {
425+
// Non-critical — series metadata is enrichment only.
426+
logger.warn('kalshi: series metadata fetch failed', {
410427
series_ticker: event.series_ticker,
411428
error: err instanceof Error ? err.message : String(err),
412429
});
@@ -438,14 +455,7 @@ export class KalshiFetcher implements IExchangeFetcher<KalshiRawEvent, KalshiRaw
438455
this.fetchRawSeriesMap(),
439456
]);
440457

441-
// Enrich events with series tags
442-
for (const event of allEvents) {
443-
if (event.series_ticker && fetchedSeriesMap.has(event.series_ticker)) {
444-
if (!event.tags || event.tags.length === 0) {
445-
event.tags = fetchedSeriesMap.get(event.series_ticker);
446-
}
447-
}
448-
}
458+
this.enrichEventsWithSeriesMap(allEvents, fetchedSeriesMap);
449459

450460
if (fetchLimit >= 1000 && useCache) {
451461
this.cachedEvents = allEvents;
@@ -456,6 +466,40 @@ export class KalshiFetcher implements IExchangeFetcher<KalshiRawEvent, KalshiRaw
456466
return allEvents;
457467
}
458468

469+
private async enrichEventsWithSeriesList(events: KalshiRawEvent[]): Promise<KalshiRawEvent[]> {
470+
if (events.length === 0) return events;
471+
472+
try {
473+
const seriesMap = await this.fetchRawSeriesMap();
474+
this.enrichEventsWithSeriesMap(events, seriesMap);
475+
} catch (err: unknown) {
476+
// Non-critical — callers can still normalize the venue-native title.
477+
logger.warn('kalshi: series list enrichment failed', {
478+
error: err instanceof Error ? err.message : String(err),
479+
});
480+
}
481+
482+
return events;
483+
}
484+
485+
private enrichEventsWithSeriesMap(
486+
events: KalshiRawEvent[],
487+
seriesMap: Map<string, KalshiSeriesInfo>,
488+
): void {
489+
for (const event of events) {
490+
if (!event.series_ticker) continue;
491+
const seriesInfo = seriesMap.get(event.series_ticker);
492+
if (!seriesInfo) continue;
493+
494+
if (seriesInfo.title) {
495+
event.series_title = seriesInfo.title;
496+
}
497+
if (seriesInfo.tags?.length && (!event.tags || event.tags.length === 0)) {
498+
event.tags = seriesInfo.tags;
499+
}
500+
}
501+
}
502+
459503
private async fetchActiveEvents(targetMarketCount?: number, status: string = 'open'): Promise<KalshiRawEvent[]> {
460504
let allEvents: KalshiRawEvent[] = [];
461505
let totalMarketCount = 0;

core/src/exchanges/kalshi/normalizer.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export class KalshiNormalizer implements IExchangeNormalizer<KalshiRawEvent, Kal
105105

106106
return {
107107
id: raw.event_ticker,
108-
title: raw.title,
108+
title: this.deriveEventTitle(raw),
109109
description: this.deriveEventDescription(raw.markets || []),
110110
slug: raw.event_ticker,
111111
markets,
@@ -325,6 +325,149 @@ export class KalshiNormalizer implements IExchangeNormalizer<KalshiRawEvent, Kal
325325
return texts[0];
326326
}
327327

328+
private deriveEventTitle(event: KalshiRawEvent): string {
329+
const rawTitle = this.cleanLabel(event.title) || event.event_ticker;
330+
const seriesTitle = this.cleanLabel(event.series_title);
331+
const markets = event.markets || [];
332+
333+
if (!seriesTitle || !this.shouldUseSeriesTitle(event, markets)) {
334+
return rawTitle;
335+
}
336+
337+
return this.composeSeriesTitle(seriesTitle, this.deriveCommonEventTitle(event, markets));
338+
}
339+
340+
private shouldUseSeriesTitle(event: KalshiRawEvent, markets: KalshiRawMarket[]): boolean {
341+
if (event.mutually_exclusive !== true) return false;
342+
if (markets.length < 4) return false;
343+
344+
const rawTitle = this.cleanLabel(event.title);
345+
if (!rawTitle) return false;
346+
347+
const titleLooksScoped = /(?:\bvs\.?\b|\bversus\b|:)/i.test(rawTitle);
348+
if (!titleLooksScoped) return false;
349+
350+
const candidateLabels = markets
351+
.map((market) => this.deriveOutcomeLabel(market))
352+
.filter((label): label is string => label != null && label.length >= 3);
353+
354+
if (candidateLabels.length < 4) return false;
355+
356+
const normalizedTitle = this.normalizeTitleText(rawTitle);
357+
const containedLabels = new Set<string>();
358+
for (const label of candidateLabels) {
359+
const normalizedLabel = this.normalizeTitleText(label);
360+
if (normalizedLabel && normalizedTitle.includes(normalizedLabel)) {
361+
containedLabels.add(normalizedLabel);
362+
}
363+
}
364+
365+
return containedLabels.size >= 2;
366+
}
367+
368+
private deriveCommonEventTitle(event: KalshiRawEvent, markets: KalshiRawMarket[]): string | null {
369+
const eventTitlePrefix = this.extractEventTitlePrefix(event.title);
370+
if (eventTitlePrefix) {
371+
if (this.hasWinVerb(eventTitlePrefix)) return 'Winner';
372+
if (this.hasResolutionTerm(eventTitlePrefix)) return eventTitlePrefix;
373+
}
374+
375+
const candidates = new Map<string, number>();
376+
377+
for (const market of markets) {
378+
const marketTitle = this.cleanLabel(market.title);
379+
if (!marketTitle) continue;
380+
381+
const outcomeLabel = this.deriveOutcomeLabel(market);
382+
const candidate = this.extractEventTitleFromMarketTitle(marketTitle, outcomeLabel);
383+
if (!candidate) continue;
384+
385+
candidates.set(candidate, (candidates.get(candidate) ?? 0) + 1);
386+
}
387+
388+
let best: string | null = null;
389+
let bestCount = 0;
390+
for (const [candidate, count] of candidates.entries()) {
391+
if (count > bestCount) {
392+
best = candidate;
393+
bestCount = count;
394+
}
395+
}
396+
397+
return best;
398+
}
399+
400+
private extractEventTitleFromMarketTitle(title: string, outcomeLabel: string | null): string | null {
401+
const escapedOutcome = outcomeLabel ? this.escapeRegExp(outcomeLabel) : '[^?]+?';
402+
const winPattern = new RegExp(`^Will (?:the )?${escapedOutcome} win (?:the )?(.+?)\\??$`, 'i');
403+
const winMatch = title.match(winPattern);
404+
if (winMatch?.[1]) {
405+
return this.ensureWinnerTitle(winMatch[1].trim());
406+
}
407+
408+
const plainWinnerMatch = title.match(/^(.+? Winner)\??$/i);
409+
if (plainWinnerMatch?.[1]) return plainWinnerMatch[1].trim();
410+
411+
const championMatch = title.match(/^(.+? Champion(?:ship)?)\??$/i);
412+
if (championMatch?.[1]) return championMatch[1].trim();
413+
414+
return null;
415+
}
416+
417+
private composeSeriesTitle(seriesTitle: string, commonTitle: string | null): string {
418+
if (!commonTitle) return seriesTitle;
419+
420+
let title = seriesTitle;
421+
const year = commonTitle.match(/^\s*(20\d{2})\b/)?.[1];
422+
if (year && !new RegExp(`\\b${year}\\b`).test(title)) {
423+
title = `${year} ${title}`;
424+
}
425+
426+
if (this.hasResolutionTerm(title)) {
427+
return title;
428+
}
429+
430+
const resolutionTerm = this.extractResolutionTerm(commonTitle);
431+
if (resolutionTerm) {
432+
return `${title} ${resolutionTerm}`;
433+
}
434+
435+
return title;
436+
}
437+
438+
private ensureWinnerTitle(title: string): string {
439+
if (this.hasResolutionTerm(title)) return title;
440+
return `${title} Winner`;
441+
}
442+
443+
private hasResolutionTerm(title: string): boolean {
444+
return /\b(winner|champion|championship|nominee|nomination|election|finals?|cup|award)\b/i.test(title);
445+
}
446+
447+
private extractEventTitlePrefix(title: string): string | null {
448+
const match = title.match(/^(.+?)(?:\s*:\s*|\s+[-\u2013\u2014]\s+)(.+)$/u);
449+
const prefix = match?.[1]?.trim();
450+
if (prefix && this.normalizeTitleText(prefix) === 'series winner') return null;
451+
return prefix || null;
452+
}
453+
454+
private extractResolutionTerm(title: string): string | null {
455+
const match = title.match(/\b(Winner|Champion|Championship|Nominee|Nomination|Election|Finals?|Cup|Award)\b\s*$/i);
456+
return match?.[1] || null;
457+
}
458+
459+
private hasWinVerb(title: string): boolean {
460+
return /\bwin(?:s|ning)?\b/i.test(title);
461+
}
462+
463+
private normalizeTitleText(value: string): string {
464+
return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
465+
}
466+
467+
private escapeRegExp(value: string): string {
468+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
469+
}
470+
328471
private deriveOutcomeLabel(market: KalshiRawMarket): string | null {
329472
const yesSubtitle = this.cleanLabel(market.yes_sub_title);
330473
if (yesSubtitle) return yesSubtitle;

0 commit comments

Comments
 (0)