From 91c9258ad061737e7ce9a56ea5a929acc145702f Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Wed, 8 Apr 2026 00:18:27 +0700 Subject: [PATCH 1/4] fix(kalshi): stabilize event description template and outcome labels --- core/src/exchanges/kalshi/normalizer.ts | 62 +++++++++++++++------ core/src/exchanges/kalshi/utils.ts | 15 ++++-- core/test/unit/normalizers/kalshi.test.ts | 66 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 core/test/unit/normalizers/kalshi.test.ts diff --git a/core/src/exchanges/kalshi/normalizer.ts b/core/src/exchanges/kalshi/normalizer.ts index c128d2c1..ab29b662 100644 --- a/core/src/exchanges/kalshi/normalizer.ts +++ b/core/src/exchanges/kalshi/normalizer.ts @@ -35,10 +35,7 @@ export class KalshiNormalizer implements IExchangeNormalizer(); + for (const market of markets) { + const rawRule = typeof market?.rules_primary === 'string' ? market.rules_primary : ''; + if (!rawRule) continue; + + const candidate = this.deriveOutcomeLabel(market); + const templated = this.templateRule(rawRule, candidate); + templates.set(templated, (templates.get(templated) ?? 0) + 1); } - const suffixCandidates = texts.map((t) => t.slice(prefix.length)); - let suffix = suffixCandidates[0]; - for (const t of suffixCandidates) { - while (!t.endsWith(suffix)) suffix = suffix.slice(1); - if (!suffix) break; + if (templates.size > 0) { + let bestTemplate: string | null = null; + let bestCount = 0; + for (const [template, count] of templates.entries()) { + if (count > bestCount) { + bestTemplate = template; + bestCount = count; + } + } + if (bestTemplate) return bestTemplate; } - if (prefix.length + suffix.length < 20) return texts[0]; + return texts[0]; + } - const variables = texts.map((t) => t.slice(prefix.length, suffix.length ? t.length - suffix.length : undefined)); - if (new Set(variables).size === 1) return texts[0]; + private deriveOutcomeLabel(market: KalshiRawMarket): string | null { + const yesSubtitle = this.cleanLabel(market.yes_sub_title); + if (yesSubtitle) return yesSubtitle; + + const subtitle = this.cleanLabel(market.subtitle); + if (subtitle) return subtitle; + + return null; + } + + private cleanLabel(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + // Some Kalshi markets use structural subtitles like ":: Democratic". + if (trimmed.startsWith('::')) return null; + return trimmed; + } - return prefix + '{x}' + suffix; + private templateRule(rule: string, candidateName: string | null): string { + if (!candidateName) return rule; + const escaped = candidateName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const matcher = new RegExp(`\\b${escaped}\\b`, 'g'); + const replaced = rule.replace(matcher, '{x}'); + return replaced === rule ? rule : replaced; } } diff --git a/core/src/exchanges/kalshi/utils.ts b/core/src/exchanges/kalshi/utils.ts index 4c389583..653fd8f9 100644 --- a/core/src/exchanges/kalshi/utils.ts +++ b/core/src/exchanges/kalshi/utils.ts @@ -18,11 +18,16 @@ export function mapMarketToUnified( price = fromKalshiCents(market.yes_ask); } - // Extract candidate name - let candidateName: string | null = null; - if (market.subtitle || market.yes_sub_title) { - candidateName = market.subtitle || market.yes_sub_title; - } + // Extract candidate name. Prefer explicit outcome subtitle and ignore + // structural labels such as ":: Democratic". + const cleanLabel = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.startsWith("::")) return null; + return trimmed; + }; + const candidateName: string | null = + cleanLabel(market.yes_sub_title) ?? cleanLabel(market.subtitle); // Calculate 24h change let priceChange = 0; diff --git a/core/test/unit/normalizers/kalshi.test.ts b/core/test/unit/normalizers/kalshi.test.ts new file mode 100644 index 00000000..e4d2fc5a --- /dev/null +++ b/core/test/unit/normalizers/kalshi.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from '@jest/globals'; +import { KalshiNormalizer } from '../../../src/exchanges/kalshi/normalizer'; +import { KalshiRawEvent, KalshiRawMarket } from '../../../src/exchanges/kalshi/fetcher'; + +const normalizer = new KalshiNormalizer(); + +function makeMarket(overrides: Partial): KalshiRawMarket { + return { + ticker: 'KALSHI-MKT', + expiration_time: '2029-01-20T15:00:00Z', + ...overrides, + }; +} + +describe('KalshiNormalizer outcome labels', () => { + test('prefers yes_sub_title over structural subtitle values', () => { + const event: KalshiRawEvent = { + event_ticker: 'KXGOVCA-26', + title: 'California Governor winner? (Person)', + markets: [ + makeMarket({ + ticker: 'KXGOVCA-26-TATK', + subtitle: ':: Democratic', + yes_sub_title: 'Toni Atkins', + rules_primary: 'If Toni Atkins is elected, then the market resolves to Yes.', + }), + ], + }; + + const market = normalizer.normalizeMarketsFromEvent(event)[0]; + expect(market.outcomes[0].label).toBe('Toni Atkins'); + expect(market.outcomes[1].label).toBe('Not Toni Atkins'); + }); +}); + +describe('KalshiNormalizer event description', () => { + test('uses dominant template and avoids malformed suffix truncation', () => { + const event: KalshiRawEvent = { + event_ticker: 'KXCABOUT-26MAR', + title: "Who will leave Trump's Cabinet next?", + markets: [ + makeMarket({ + ticker: 'KXCABOUT-26MAR-MRUB', + yes_sub_title: 'Marco Rubio', + rules_primary: 'If Marco Rubio is the first member of the Cabinet of Donald Trump to leave or announce they will leave (such as by quitting, being fired, or being impeached) after Mar 10, 2026, then the market resolves to Yes.', + }), + makeMarket({ + ticker: 'KXCABOUT-26MAR-SBES', + yes_sub_title: 'Scott Bessent', + rules_primary: 'If Scott Bessent is the first member of the Cabinet of Donald Trump to leave or announce they will leave (such as by quitting, being fired, or being impeached) after Mar 10, 2026, then the market resolves to Yes.', + }), + makeMarket({ + ticker: 'KXCABOUT-26MAR-MMUL', + yes_sub_title: 'Markwayne Mullin', + rules_primary: 'If Markwayne Mullin is the first member of the Cabinet of Donald Trump to leave or announce they will leave (such as by quitting, being fired, or being impeached) after Mar 30, 2026, then the market resolves to Yes.', + }), + ], + }; + + const unifiedEvent = normalizer.normalizeEvent(event)!; + expect(unifiedEvent.description).toBe( + 'If {x} is the first member of the Cabinet of Donald Trump to leave or announce they will leave (such as by quitting, being fired, or being impeached) after Mar 10, 2026, then the market resolves to Yes.', + ); + expect(unifiedEvent.description).not.toContain('{x}0, 2026'); + }); +}); From 5067c2eb00bb1ca666f07c380988841c66092f95 Mon Sep 17 00:00:00 2001 From: "Samuel EF. Tinnerholm" Date: Thu, 9 Apr 2026 18:16:54 +0300 Subject: [PATCH 2/4] fix(kalshi): only vote on templates containing {x} placeholder Prevents raw rules (where candidate name could not be substituted) from winning the event description vote and leaking a specific name. --- core/src/exchanges/kalshi/normalizer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/src/exchanges/kalshi/normalizer.ts b/core/src/exchanges/kalshi/normalizer.ts index ab29b662..97f10ad1 100644 --- a/core/src/exchanges/kalshi/normalizer.ts +++ b/core/src/exchanges/kalshi/normalizer.ts @@ -294,10 +294,14 @@ export class KalshiNormalizer implements IExchangeNormalizer 0) { let bestTemplate: string | null = null; let bestCount = 0; for (const [template, count] of templates.entries()) { + if (!template.includes('{x}')) continue; if (count > bestCount) { bestTemplate = template; bestCount = count; From d6836de5da6205dbd2edff1d03999a033f940a75 Mon Sep 17 00:00:00 2001 From: "Samuel EF. Tinnerholm" Date: Thu, 9 Apr 2026 18:17:37 +0300 Subject: [PATCH 3/4] fix(kalshi): use unicode word boundaries when templating candidate names ASCII \b fails on names with accents, apostrophes, or non-Latin characters. Unicode property escapes keep templating correct. --- core/src/exchanges/kalshi/normalizer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/src/exchanges/kalshi/normalizer.ts b/core/src/exchanges/kalshi/normalizer.ts index 97f10ad1..6a2af8eb 100644 --- a/core/src/exchanges/kalshi/normalizer.ts +++ b/core/src/exchanges/kalshi/normalizer.ts @@ -335,7 +335,10 @@ export class KalshiNormalizer implements IExchangeNormalizer Date: Thu, 9 Apr 2026 18:18:42 +0300 Subject: [PATCH 4/4] test(kalshi): cover no-majority and unicode candidate name cases --- core/test/unit/normalizers/kalshi.test.ts | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/core/test/unit/normalizers/kalshi.test.ts b/core/test/unit/normalizers/kalshi.test.ts index e4d2fc5a..898d41a3 100644 --- a/core/test/unit/normalizers/kalshi.test.ts +++ b/core/test/unit/normalizers/kalshi.test.ts @@ -63,4 +63,58 @@ describe('KalshiNormalizer event description', () => { ); expect(unifiedEvent.description).not.toContain('{x}0, 2026'); }); + + test('never leaks a candidate name when every market has a distinct template', () => { + const event: KalshiRawEvent = { + event_ticker: 'KXDISTINCT', + title: 'Distinct dates per market', + markets: [ + makeMarket({ + ticker: 'KXDISTINCT-A', + yes_sub_title: 'Alice', + rules_primary: 'If Alice wins by Jan 1, 2026, then the market resolves to Yes.', + }), + makeMarket({ + ticker: 'KXDISTINCT-B', + yes_sub_title: 'Bob', + rules_primary: 'If Bob wins by Feb 1, 2026, then the market resolves to Yes.', + }), + makeMarket({ + ticker: 'KXDISTINCT-C', + yes_sub_title: 'Carol', + rules_primary: 'If Carol wins by Mar 1, 2026, then the market resolves to Yes.', + }), + ], + }; + + const unifiedEvent = normalizer.normalizeEvent(event)!; + expect(unifiedEvent.description).toContain('{x}'); + expect(unifiedEvent.description).not.toContain('Alice'); + expect(unifiedEvent.description).not.toContain('Bob'); + expect(unifiedEvent.description).not.toContain('Carol'); + }); + + test('templates non-ASCII candidate names', () => { + const event: KalshiRawEvent = { + event_ticker: 'KXUNICODE', + title: 'Unicode candidate names', + markets: [ + makeMarket({ + ticker: 'KXUNICODE-J', + yes_sub_title: 'Jose Munoz', + rules_primary: 'If Jose Munoz is elected, then the market resolves to Yes.', + }), + makeMarket({ + ticker: 'KXUNICODE-M', + yes_sub_title: 'Muller', + rules_primary: 'If Muller is elected, then the market resolves to Yes.', + }), + ], + }; + + const unifiedEvent = normalizer.normalizeEvent(event)!; + expect(unifiedEvent.description).toContain('{x}'); + expect(unifiedEvent.description).not.toContain('Jose'); + expect(unifiedEvent.description).not.toContain('Muller'); + }); });