diff --git a/api/cron/collect-tweets.ts b/api/cron/collect-tweets.ts index 20b5018..a06ab65 100644 --- a/api/cron/collect-tweets.ts +++ b/api/cron/collect-tweets.ts @@ -153,7 +153,7 @@ export default async function handler( // Generate signal const sentiment = analyzeSentiment(rawTweet.text); - const signal = generateSignal(rawTweet.text, matches, arbitrage); + const signal = generateSignal(rawTweet.text, matches, arbitrage, sentiment, rawTweet.id); // Build analyzed tweet const analyzedTweet: AnalyzedTweet = { diff --git a/api/lib/market-cache.ts b/api/lib/market-cache.ts index 8b41d67..d892d66 100644 --- a/api/lib/market-cache.ts +++ b/api/lib/market-cache.ts @@ -8,6 +8,8 @@ import { Market, ArbitrageOpportunity } from '../../src/types/market'; import { fetchPolymarkets } from '../../src/api/polymarket-client'; import { fetchKalshiMarkets } from '../../src/api/kalshi-client'; import { detectArbitrage } from '../../src/api/arbitrage-detector'; +import { detectIntraEventArbitrage } from '../../src/api/intra-event-arbitrage'; +import type { GroupArbitrageOpportunity } from '../../src/types/market'; import { FreshnessMetadata, SourceStatus } from './types'; // In-memory cache for markets @@ -25,10 +27,14 @@ let polyError: string | null = null; let kalshiError: string | null = null; // In-memory cache for arbitrage opportunities -// Default: 15 seconds (configurable via ARBITRAGE_CACHE_TTL_SECONDS env var) +// Default: 20 seconds (configurable via ARBITRAGE_CACHE_TTL_SECONDS env var) let cachedArbitrage: ArbitrageOpportunity[] = []; let arbCacheTimestamp = 0; -const ARB_CACHE_TTL_MS = (parseInt(process.env.ARBITRAGE_CACHE_TTL_SECONDS || '15', 10)) * 1000; +const ARB_CACHE_TTL_MS = (parseInt(process.env.ARBITRAGE_CACHE_TTL_SECONDS || '20', 10)) * 1000; + +// In-memory cache for intra-event (group) arbitrage opportunities +let cachedGroupArbitrage: GroupArbitrageOpportunity[] = []; +let groupArbCacheTimestamp = 0; const POLYMARKET_TARGET_COUNT = parsePositiveInt(process.env.MUSASHI_POLYMARKET_TARGET_COUNT, 1200); const POLYMARKET_MAX_PAGES = parsePositiveInt(process.env.MUSASHI_POLYMARKET_MAX_PAGES, 20); @@ -193,8 +199,8 @@ export async function getArbitrage(minSpread: number = 0.03): Promise= ARB_CACHE_TTL_MS) { + // Recompute if cache is stale or never populated + if (arbCacheTimestamp === 0 || (now - arbCacheTimestamp) >= ARB_CACHE_TTL_MS) { console.log('[Arbitrage Cache] Computing arbitrage opportunities...'); // Cache with low threshold (0.01) so we can filter client-side cachedArbitrage = detectArbitrage(markets, 0.01); @@ -208,3 +214,35 @@ export async function getArbitrage(minSpread: number = 0.03): Promise { + const markets = await getMarkets(); + const now = Date.now(); + + // Recompute alongside the cross-platform cache (same TTL). + // Use minEdge=0 so every profitable group is cached; callers filter client-side. + if (cachedGroupArbitrage.length === 0 || (now - groupArbCacheTimestamp) >= ARB_CACHE_TTL_MS) { + console.log('[GroupArbitrage Cache] Computing intra-event arbitrage opportunities...'); + cachedGroupArbitrage = detectIntraEventArbitrage(markets, 0); + groupArbCacheTimestamp = now; + console.log(`[GroupArbitrage Cache] Cached ${cachedGroupArbitrage.length} opportunities (TTL: ${ARB_CACHE_TTL_MS}ms)`); + } + + const filtered = cachedGroupArbitrage.filter(op => op.edge >= minEdge); + console.log(`[GroupArbitrage Cache] Returning ${filtered.length}/${cachedGroupArbitrage.length} opportunities (minEdge: ${minEdge})`); + + return filtered; +} diff --git a/api/markets/arbitrage.ts b/api/markets/arbitrage.ts index 26a2f2a..b6121ce 100644 --- a/api/markets/arbitrage.ts +++ b/api/markets/arbitrage.ts @@ -1,5 +1,5 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; -import { getMarkets, getArbitrage, getMarketMetadata } from '../lib/market-cache'; +import { getMarkets, getArbitrage, getGroupArbitrage, getMarketMetadata } from '../lib/market-cache'; export default async function handler( req: VercelRequest, @@ -77,16 +77,25 @@ export default async function handler( return; } - // Get cached arbitrage opportunities (filtered by minSpread) - let opportunities = await getArbitrage(minSpreadNum); + // Fetch both cross-platform and intra-event arbitrage in parallel + const [crossPlatformOpportunities, groupOpportunitiesRaw] = await Promise.all([ + getArbitrage(minSpreadNum), + getGroupArbitrage(minSpreadNum), + ]); - // Apply additional filters client-side - // Note: opportunities are already sorted by spread descending from detectArbitrage() - opportunities = opportunities + // Apply additional filters to cross-platform opportunities + let opportunities = crossPlatformOpportunities .filter(arb => arb.confidence >= minConfidenceNum) .filter(arb => !category || arb.polymarket.category === category || arb.kalshi.category === category) .slice(0, limitNum); + // Apply category filter to intra-event opportunities. + // All legs of a group share the same platform and category, so checking the + // first leg is sufficient (avoids iterating all legs on every filter call). + const groupOpportunities = groupOpportunitiesRaw + .filter(op => !category || op.legs[0]?.market.category === category) + .slice(0, limitNum); + // Stage 0: Get freshness metadata const freshnessMetadata = getMarketMetadata(); @@ -94,8 +103,12 @@ export default async function handler( const response = { success: true, data: { + // Cross-platform arbitrage (Polymarket vs Kalshi, covered YES/NO bundle) opportunities, count: opportunities.length, + // Intra-event arbitrage (same platform, range sums / match outcomes) + group_opportunities: groupOpportunities, + group_count: groupOpportunities.length, timestamp: new Date().toISOString(), filters: { minSpread: minSpreadNum, diff --git a/src/analysis/contract-type.ts b/src/analysis/contract-type.ts new file mode 100644 index 0000000..48d1b8d --- /dev/null +++ b/src/analysis/contract-type.ts @@ -0,0 +1,142 @@ +// Contract type detection +// Classifies markets by their prediction *structure* (not topic/domain). +// Two markets should only be compared for equivalence if they share the same type. + +import { Market } from '../types/market'; + +/** + * Prediction structure types. + * + * These represent the *shape* of a contract, not its subject matter. + * + * | Type | Example | + * |--------------------|------------------------------------------------| + * | RANGE_COUNT | Will Elon tweet 215–239 times in May? | + * | THRESHOLD_PRICE | Will BTC exceed $500k by year-end? | + * | EVENT_MATCH_OUTCOME| Will Team A beat Team B in Game 3? | + * | TIME_WINDOW_BINARY | Will X happen before March 31? | + * | BINARY_OUTCOME | Will the Fed cut rates in June? (generic Y/N) | + */ +export type ContractType = + | 'RANGE_COUNT' + | 'THRESHOLD_PRICE' + | 'EVENT_MATCH_OUTCOME' + | 'TIME_WINDOW_BINARY' + | 'BINARY_OUTCOME'; + +// Numeric range: "215-239", "10 to 20", "between 5 and 10" +// Dash class covers hyphen-minus U+002D, en-dash U+2013, and em-dash U+2014. +const RANGE_PATTERN = /\b\d+\s*[\u002D\u2013\u2014]\s*\d+\b|\b\d+\s+to\s+\d+\b|\bbetween\s+\d+\s+and\s+\d+\b/i; + +// Head-to-head / match outcome: "vs", "beat", "defeat", "match winner" +const MATCH_OUTCOME_PATTERN = + /\bvs\.?\b|\bversus\b|\bbeat\b|\bdefeats?\b|\bmatch winner\b|\bgame\s+\d+\b|\bseries winner\b/i; + +// Price threshold: a dollar amount AND a threshold verb +const PRICE_AMOUNT_PATTERN = /\$[\d,.]+[KMBkmb]?\b/; +const THRESHOLD_VERB_PATTERN = + /\b(exceed|surpass|hit|reach|break|cross|above|below|over|under)\b/i; + +// Deadline / time-window: "by DATE", "before DATE", "within N days" +const TIME_WINDOW_PATTERN = + /\b(by|before|prior\s+to|within)\b.{0,40}?\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|202\d|q[1-4])\b|\bwithin\s+\d+\s+(?:day|week|month)/i; + +/** + * Detect the prediction structure type of a market from its title and description. + * + * Detection is ordered from most-specific to least-specific so that a contract + * like "Will BTC exceed $500k before December?" is classified as + * THRESHOLD_PRICE rather than TIME_WINDOW_BINARY. + */ +export function detectContractType(market: Market): ContractType { + // Use only the title for classification so that cross-platform description + // differences (Polymarket includes deadline prose; Kalshi descriptions are + // empty) do not cause asymmetric contract-type assignments for equivalent + // markets. + const text = market.title; + + // 1. RANGE_COUNT – explicit numeric interval wins over everything else + if (RANGE_PATTERN.test(text)) { + return 'RANGE_COUNT'; + } + + // 2. THRESHOLD_PRICE – price amount + directional verb + if (PRICE_AMOUNT_PATTERN.test(text) && THRESHOLD_VERB_PATTERN.test(text)) { + return 'THRESHOLD_PRICE'; + } + + // 3. EVENT_MATCH_OUTCOME – head-to-head competition + if (MATCH_OUTCOME_PATTERN.test(text)) { + return 'EVENT_MATCH_OUTCOME'; + } + + // 4. TIME_WINDOW_BINARY – event conditioned on a deadline + if (TIME_WINDOW_PATTERN.test(text)) { + return 'TIME_WINDOW_BINARY'; + } + + // 5. Generic binary yes/no + return 'BINARY_OUTCOME'; +} + +/** + * Return a compatibility score in [0, 1] between two contract types. + * + * A score of 0 means the pair is structurally incompatible and should be + * hard-rejected. Any positive score is used as a penalty multiplier in the + * confidence formula so that type mismatches reduce confidence rather than + * eliminating candidates outright. + * + * The only pair that retains a hard-zero is RANGE_COUNT ↔ EVENT_MATCH_OUTCOME: + * a count-in-range contract cannot meaningfully map onto a head-to-head winner. + */ +export function contractTypeCompatibility(a: ContractType, b: ContractType): number { + if (a === b) return 1.0; + + // Normalise order so we only need one entry per unordered pair. + const [lo, hi] = a < b ? [a, b] : [b, a]; + + switch (`${lo}|${hi}`) { + // TIME_WINDOW_BINARY ↔ BINARY_OUTCOME: both are generic YES/NO, effectively + // the same structure. The detector in arbitrage-detector.ts also overrides + // this pair to 1.0 via the BINARY_COMPATIBLE_TYPES set, but we return 0.8 + // here for completeness and to support callers that use this function directly. + case 'BINARY_OUTCOME|TIME_WINDOW_BINARY': return 0.8; + + // A generic YES/NO contract and a head-to-head winner are often the same + // market expressed differently across platforms. + case 'BINARY_OUTCOME|EVENT_MATCH_OUTCOME': return 0.6; + + // A generic YES/NO can be a threshold contract with the threshold omitted + // from the title on one platform. + case 'BINARY_OUTCOME|THRESHOLD_PRICE': return 0.5; + + // A time-window binary and a threshold/price contract share a deadline + // structure and are semantically close. + case 'THRESHOLD_PRICE|TIME_WINDOW_BINARY': return 0.6; + + // A named match outcome paired with a time-window binary: plausible when + // one platform qualifies the outcome with a deadline. + case 'EVENT_MATCH_OUTCOME|TIME_WINDOW_BINARY': return 0.4; + + // A price-threshold contract paired with a match-outcome is a stretch but + // still partially comparable (e.g. score milestones in a game). + case 'EVENT_MATCH_OUTCOME|THRESHOLD_PRICE': return 0.2; + + // A numeric range and a binary outcome: one platform may bucket the same + // event into ranges while the other offers a single YES/NO. + case 'BINARY_OUTCOME|RANGE_COUNT': return 0.35; + + // Numeric range and price threshold share quantitative structure. + case 'RANGE_COUNT|THRESHOLD_PRICE': return 0.5; + + // Numeric range with a time-window qualifier: partial structural overlap. + case 'RANGE_COUNT|TIME_WINDOW_BINARY': return 0.35; + + // RANGE_COUNT ↔ EVENT_MATCH_OUTCOME: a count-in-range cannot map onto a + // head-to-head winner. Hard-reject (0). + case 'EVENT_MATCH_OUTCOME|RANGE_COUNT': return 0.0; + + default: return 0.0; + } +} diff --git a/src/analysis/event-matcher.ts b/src/analysis/event-matcher.ts new file mode 100644 index 0000000..4ce828d --- /dev/null +++ b/src/analysis/event-matcher.ts @@ -0,0 +1,125 @@ +import { Market, CanonicalEvent } from '../types/market'; +import { detectContractType } from './contract-type'; + +// Event canonicalization: group equivalent Polymarket and Kalshi markets +// into a single CanonicalEvent representing the same real-world bet. + +/** + * Normalize raw market title text into a comparable form by lowercasing, + * stripping punctuation, and collapsing whitespace. + */ +export function normalizeText(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Return a normalized signature string for a market, used as the basis + * for similarity comparisons. + */ +export function getMarketSignature(market: Market): string { + return normalizeText(market.title); +} + +/** + * Compute Jaccard similarity between two normalized strings by comparing + * their word-level token sets. Returns a value in [0, 1]. + */ +export function jaccardSimilarity(a: string, b: string): number { + const setA = new Set(a.split(' ').filter(Boolean)); + const setB = new Set(b.split(' ').filter(Boolean)); + + if (setA.size === 0 && setB.size === 0) return 1; + if (setA.size === 0 || setB.size === 0) return 0; + + let intersection = 0; + for (const token of setA) { + if (setB.has(token)) intersection++; + } + + const union = setA.size + setB.size - intersection; + return intersection / union; +} + +/** + * Match Polymarket and Kalshi markets into CanonicalEvents. + * + * Each Polymarket market is greedily paired with the best-scoring Kalshi + * market whose Jaccard similarity meets `threshold`. Unmatched markets + * from either platform are included as solo events. + * + * @param polymarket - Array of Polymarket Market objects + * @param kalshi - Array of Kalshi Market objects + * @param threshold - Minimum Jaccard similarity to treat two markets as + * the same underlying event (default: 0.55) + */ +export function matchEvents( + polymarket: Market[], + kalshi: Market[], + threshold = 0.55, +): CanonicalEvent[] { + const events: CanonicalEvent[] = []; + const usedKalshi = new Set(); + + // Precompute type and signature for each Kalshi market so we avoid + // recomputing them in the O(n×m) inner loop. + const kalshiMeta = kalshi.map(k => ({ + market: k, + type: detectContractType(k), + sig: getMarketSignature(k), + })); + + for (const poly of polymarket) { + const polySig = getMarketSignature(poly); + const polyType = detectContractType(poly); + + let bestMatch: { market: Market; score: number } | null = null; + + for (const km of kalshiMeta) { + if (usedKalshi.has(km.market.id)) continue; + + // Gate 1: Skip markets with incompatible prediction structures. + // There is no scenario in which a RANGE_COUNT contract on one platform + // represents the same real-world event as an EVENT_MATCH_OUTCOME contract + // on another platform. + if (km.type !== polyType) continue; + + const score = jaccardSimilarity(polySig, km.sig); + if (score >= threshold && (!bestMatch || score > bestMatch.score)) { + bestMatch = { market: km.market, score }; + } + } + + if (bestMatch) { + usedKalshi.add(bestMatch.market.id); + } + + events.push({ + id: `event-poly-${poly.id}`, + title: poly.title, + normalizedTitle: polySig, + category: poly.category !== 'other' ? poly.category : undefined, + markets: { + polymarket: poly, + ...(bestMatch ? { kalshi: bestMatch.market } : {}), + }, + }); + } + + // Include Kalshi markets that were not matched to any Polymarket market. + for (const k of kalshi) { + if (usedKalshi.has(k.id)) continue; + events.push({ + id: `event-kalshi-${k.id}`, + title: k.title, + normalizedTitle: getMarketSignature(k), + category: k.category !== 'other' ? k.category : undefined, + markets: { kalshi: k }, + }); + } + + return events; +} diff --git a/src/analysis/sentiment-analyzer.ts b/src/analysis/sentiment-analyzer.ts index 21d73b0..b4cdfc5 100644 --- a/src/analysis/sentiment-analyzer.ts +++ b/src/analysis/sentiment-analyzer.ts @@ -53,13 +53,14 @@ export function analyzeSentiment(tweetText: string): SentimentResult { for (let i = 0; i < words.length; i++) { const word = words[i].replace(/[^a-z]/g, ''); - const prevWord = i > 0 ? words[i - 1].replace(/[^a-z]/g, '') : ''; - // Check for negation - const isNegated = NEGATIONS.includes(prevWord); + // Check for negation within the preceding 3 words (not just 1) + const windowStart = Math.max(0, i - 3); + const precedingWords = words.slice(windowStart, i).map(w => w.replace(/[^a-z]/g, '')); + const isNegated = precedingWords.some(w => NEGATIONS.includes(w)); - // Check for strong modifier - const isStrong = STRONG_MODIFIERS.includes(prevWord); + // Check for strong modifier in the preceding 3 words + const isStrong = precedingWords.some(w => STRONG_MODIFIERS.includes(w)); const weight = isStrong ? 2 : 1; // Check bullish @@ -81,7 +82,7 @@ export function analyzeSentiment(tweetText: string): SentimentResult { } } - // Calculate total and determine sentiment + // Calculate net signal and total evidence const total = bullishScore + bearishScore; if (total === 0) { @@ -91,13 +92,18 @@ export function analyzeSentiment(tweetText: string): SentimentResult { const bullishRatio = bullishScore / total; const bearishRatio = bearishScore / total; - // Need strong signal to classify (>60%) + // confidenceScaling scales down confidence when we have few matching signals. + // A single bullish keyword should not be 100% confident. + // We require at least 3 strong keyword hits to reach full confidence. + const confidenceScaling = Math.min(1, total / 3); + + // Need strong directional bias to classify (>60%) if (bullishRatio > 0.6) { - return { sentiment: 'bullish', confidence: bullishRatio }; + return { sentiment: 'bullish', confidence: bullishRatio * confidenceScaling }; } if (bearishRatio > 0.6) { - return { sentiment: 'bearish', confidence: bearishRatio }; + return { sentiment: 'bearish', confidence: bearishRatio * confidenceScaling }; } // Mixed or weak signal diff --git a/src/analysis/signal-generator.ts b/src/analysis/signal-generator.ts index 45c1eaf..6f2e7e7 100644 --- a/src/analysis/signal-generator.ts +++ b/src/analysis/signal-generator.ts @@ -3,6 +3,7 @@ import { Market, MarketMatch, ArbitrageOpportunity } from '../types/market'; import { analyzeSentiment, SentimentResult } from './sentiment-analyzer'; +import type { MarketWalletFlow } from '../types/wallet'; export type SignalType = 'arbitrage' | 'news_event' | 'sentiment_shift' | 'user_interest'; export type UrgencyLevel = 'low' | 'medium' | 'high' | 'critical'; @@ -53,6 +54,10 @@ function isBreakingNews(text: string): boolean { * Calculate implied probability from sentiment * Bullish sentiment implies higher YES probability * Bearish sentiment implies lower YES probability (higher NO) + * + * The shift is intentionally conservative (max ±15 percentage points when confidence = 1.0). + * Social-media sentiment rarely justifies moving a probability by more than + * that, and overclaiming here leads to spurious high-confidence signals. */ function calculateImpliedProbability(sentiment: SentimentResult): number { if (sentiment.sentiment === 'neutral') { @@ -60,17 +65,17 @@ function calculateImpliedProbability(sentiment: SentimentResult): number { } if (sentiment.sentiment === 'bullish') { - // Bullish: high confidence = higher YES probability - return 0.5 + (sentiment.confidence * 0.4); // Range: 0.5 to 0.9 + // Bullish: high confidence = higher YES probability (max +15pp) + return 0.5 + (sentiment.confidence * 0.15); // Range: 0.5 to 0.65 } - // Bearish: high confidence = lower YES probability - return 0.5 - (sentiment.confidence * 0.4); // Range: 0.1 to 0.5 + // Bearish: high confidence = lower YES probability (max -15pp) + return 0.5 - (sentiment.confidence * 0.15); // Range: 0.1 to 0.5 **messing with percentages } /** - * Calculate trading edge for a market given sentiment - * Edge = how much the sentiment-implied probability differs from market price + * Calculate trading edge for a market given sentiment. + * Includes a (1 - price) factor to reduce overconfidence in high-priced markets. */ function calculateEdge(market: Market, sentiment: SentimentResult): number { const impliedProb = calculateImpliedProbability(sentiment); @@ -79,12 +84,112 @@ function calculateEdge(market: Market, sentiment: SentimentResult): number { // Raw difference between implied and actual price const priceDiff = Math.abs(impliedProb - currentPrice); - // Weight by sentiment confidence - const edge = sentiment.confidence * priceDiff; + // Weight by sentiment confidence and dampen in high-priced markets + const edge = sentiment.confidence * priceDiff * (1 - currentPrice); return edge; } +/** + * Convert a wallet-flow net direction into the buy/sell/neutral vocabulary + * used by the decision gate and confidence adjuster. + */ +function deriveSmartMoneyDirection( + flow: MarketWalletFlow, +): 'buy' | 'sell' | 'neutral' { + if (flow.netDirection === 'YES') return 'buy'; + if (flow.netDirection === 'NO') return 'sell'; + return 'neutral'; +} + +/** + * Decision gate – all conditions must pass before a trade is suggested. + * + * Priority order: + * 1. Strong arbitrage always passes. + * 2. Weak sentiment is rejected. + * 3. Insufficient edge is rejected. + * 4. Smart-money direction that contradicts sentiment is rejected. + */ +function passesDecisionGate( + sentiment: SentimentResult, + edge: number, + arbitrage?: ArbitrageOpportunity, + smartMoneyDirection?: 'buy' | 'sell' | 'neutral' +): boolean { + // 1. Arbitrage with meaningful spread always passes + if (arbitrage && arbitrage.spread > 0.03) return true; + + // 2. Weak sentiment → reject + if (sentiment.confidence < 0.6) return false; + + // 3. Edge too small → reject + if (edge < 0.05) return false; + + // 4. Smart money disagrees with sentiment → reject + if (smartMoneyDirection) { + if ( + (sentiment.sentiment === 'bullish' && smartMoneyDirection === 'sell') || + (sentiment.sentiment === 'bearish' && smartMoneyDirection === 'buy') + ) { + return false; + } + } + + return true; +} + +const WEAK_SENTIMENT_CONFIDENCE_THRESHOLD = 0.7; +const WEAK_SENTIMENT_CONFIDENCE_PENALTY = 0.7; + +/** + * Adjust confidence based on signal quality. + * Penalizes weak sentiment and boosts when arbitrage or smart-money agree. + */ +function adjustConfidence( + base: number, + sentiment: SentimentResult, + hasArbitrage: boolean, + smartMoneyAgreement: boolean +): number { + let conf = base; + + // Penalize weak sentiment + if (sentiment.confidence < WEAK_SENTIMENT_CONFIDENCE_THRESHOLD) conf *= WEAK_SENTIMENT_CONFIDENCE_PENALTY; + + // Boost if arbitrage is present + if (hasArbitrage) conf = Math.min(conf * 1.5, 0.95); + + // Boost if smart money agrees + if (smartMoneyAgreement) conf = Math.min(conf * 1.3, 0.9); + + return conf; +} + +/** + * Determine trade direction with a buffer to avoid noise trades. + * Returns HOLD when sentiment is neutral or the edge vs price is within the buffer. + */ +function getDirection( + sentiment: SentimentResult, + impliedProb: number, + price: number, + buffer = 0.03 +): Direction { + if (sentiment.sentiment === 'neutral') return 'HOLD'; + + if (sentiment.sentiment === 'bullish' && impliedProb > price + buffer) { + return 'YES'; + } + + if (sentiment.sentiment === 'bearish' && impliedProb < price - buffer) { + return 'NO'; + } + + return 'HOLD'; +} + + /** * Check if market expires soon (within 7 days) */ @@ -100,6 +205,9 @@ function expiresSoon(market: Market): boolean { /** * Compute urgency level based on edge, volume, and expiry + * + * Edge thresholds are calibrated to the conservative implied-probability shift + * (max ±15pp), so max possible sentiment edge ≈ 0.15. */ function computeUrgency( edge: number, @@ -113,12 +221,12 @@ function computeUrgency( return 'critical'; } - if (edge > 0.15 && market.volume24h > 500000 && expiresSoon(market)) { + if (edge > 0.08 && market.volume24h > 500000 && expiresSoon(market)) { return 'critical'; } // High: Good edge OR moderate arbitrage - if (edge > 0.10) { + if (edge > 0.06) { return 'high'; } @@ -127,7 +235,7 @@ function computeUrgency( } // Medium: Decent edge - if (edge > 0.05) { + if (edge > 0.03) { return 'medium'; } @@ -164,64 +272,76 @@ function computeSignalType( } /** - * Generate suggested trading action + * Generate suggested trading action. + * + * Uses the decision gate, direction buffer, and confidence adjustment helpers + * to produce a higher-quality signal than a simple edge threshold check. */ function generateSuggestedAction( market: Market, sentiment: SentimentResult, edge: number, - urgency: UrgencyLevel + urgency: UrgencyLevel, + arbitrage?: ArbitrageOpportunity, + topMatchConfidence?: number, + smartMoneyFlow?: MarketWalletFlow ): SuggestedAction { - // Don't suggest action if edge is too low - if (edge < 0.10) { + // Scale edge by the top match confidence so better matches produce stronger signals + const scaledEdge = topMatchConfidence !== undefined ? edge * topMatchConfidence : edge; + + // Derive smart-money direction from wallet-flow data when available + const smartMoneyDirection = smartMoneyFlow + ? deriveSmartMoneyDirection(smartMoneyFlow) + : undefined; + + // Run through multi-factor decision gate before committing to a trade + if (!passesDecisionGate(sentiment, scaledEdge, arbitrage, smartMoneyDirection)) { return { direction: 'HOLD', confidence: 0, edge: 0, - reasoning: 'Insufficient edge to justify a trade', + reasoning: 'Signal did not pass decision gate (weak sentiment, insufficient edge, or contradictory signals)', }; } const impliedProb = calculateImpliedProbability(sentiment); const currentPrice = market.yesPrice; - let direction: Direction; - let reasoning: string; + // Use buffered direction to avoid noise trades + const direction = getDirection(sentiment, impliedProb, currentPrice); - if (sentiment.sentiment === 'neutral') { - direction = 'HOLD'; + let reasoning: string; + if (direction === 'YES') { + reasoning = `Bullish sentiment (${(sentiment.confidence * 100).toFixed(0)}% confidence) suggests YES is underpriced at ${(currentPrice * 100).toFixed(0)}%`; + } else if (direction === 'NO') { + reasoning = `Bearish sentiment (${(sentiment.confidence * 100).toFixed(0)}% confidence) suggests YES is overpriced at ${(currentPrice * 100).toFixed(0)}%`; + } else if (sentiment.sentiment === 'neutral') { reasoning = 'Neutral sentiment, no clear directional bias'; - } else if (sentiment.sentiment === 'bullish') { - // Bullish sentiment - if (impliedProb > currentPrice) { - // YES is underpriced - direction = 'YES'; - reasoning = `Bullish sentiment (${(sentiment.confidence * 100).toFixed(0)}% confidence) suggests YES is underpriced at ${(currentPrice * 100).toFixed(0)}%`; - } else { - direction = 'HOLD'; - reasoning = 'Bullish sentiment but YES already priced high'; - } } else { - // Bearish sentiment - if (impliedProb < currentPrice) { - // YES is overpriced, buy NO - direction = 'NO'; - reasoning = `Bearish sentiment (${(sentiment.confidence * 100).toFixed(0)}% confidence) suggests YES is overpriced at ${(currentPrice * 100).toFixed(0)}%`; - } else { - direction = 'HOLD'; - reasoning = 'Bearish sentiment but YES already priced low'; - } + reasoning = 'Price already reflects sentiment direction (within noise buffer)'; } - // Confidence based on edge and urgency - let actionConfidence = edge; - if (urgency === 'critical') actionConfidence = Math.min(edge * 1.5, 0.95); - else if (urgency === 'high') actionConfidence = Math.min(edge * 1.2, 0.9); + // Determine whether smart money agrees with the sentiment direction + const smartMoneyAgreement = + smartMoneyDirection !== undefined && + smartMoneyDirection !== 'neutral' && + ((sentiment.sentiment === 'bullish' && smartMoneyDirection === 'buy') || + (sentiment.sentiment === 'bearish' && smartMoneyDirection === 'sell')); + + // Build adjusted confidence from multiple signals + const hasArbitrage = !!arbitrage; + const baseConfidence = urgency === 'critical' + ? Math.min(scaledEdge * 1.5, 0.95) + : urgency === 'high' + ? Math.min(scaledEdge * 1.2, 0.9) + : scaledEdge; + + const actionConfidence = adjustConfidence(baseConfidence, sentiment, hasArbitrage, smartMoneyAgreement); return { direction, confidence: actionConfidence, - edge, + edge: scaledEdge, reasoning, }; } @@ -243,19 +363,34 @@ function generateEventId(tweetText: string): string { } /** - * Generate a trading signal from matched markets and tweet text + * Generate a trading signal from matched markets and tweet text. + * + * @param tweetText - Raw tweet text used for sentiment analysis and event ID. + * @param matches - Pre-computed market matches. + * @param arbitrageOpportunity - Optional arbitrage opportunity for the top match. + * @param precomputedSentiment - Optional sentiment already computed upstream. + * Pass this when the caller has already called analyzeSentiment to avoid + * running the analysis twice. + * @param eventId - Optional explicit event ID (e.g. the tweet's own ID). + * Falls back to a hash of tweetText when not provided. + * @param smartMoneyFlow - Optional wallet-flow data for the top market. + * When provided, the smart-money direction is used by the decision gate and + * the confidence adjuster to validate and strengthen the signal. */ export function generateSignal( tweetText: string, matches: MarketMatch[], - arbitrageOpportunity?: ArbitrageOpportunity + arbitrageOpportunity?: ArbitrageOpportunity, + precomputedSentiment?: SentimentResult, + eventId?: string, + smartMoneyFlow?: MarketWalletFlow ): TradingSignal { const startTime = Date.now(); // If no matches, return minimal signal if (matches.length === 0) { return { - event_id: generateEventId(tweetText), + event_id: eventId ?? generateEventId(tweetText), signal_type: 'user_interest', urgency: 'low', matches: [], @@ -266,8 +401,8 @@ export function generateSignal( }; } - // Analyze tweet sentiment - const sentiment = analyzeSentiment(tweetText); + // Use pre-computed sentiment if provided to avoid redundant analysis + const sentiment = precomputedSentiment ?? analyzeSentiment(tweetText); // Use the top match (highest confidence) for signal computation const topMatch = matches[0]; @@ -287,11 +422,11 @@ export function generateSignal( // Determine signal type const signal_type = computeSignalType(tweetText, sentiment, edge, !!arbitrageOpportunity); - // Generate suggested action - const suggested_action = generateSuggestedAction(topMarket, sentiment, edge, urgency); + // Generate suggested action (passes arbitrage, top-match confidence, and smart-money flow) + const suggested_action = generateSuggestedAction(topMarket, sentiment, edge, urgency, arbitrageOpportunity, topMatch.confidence, smartMoneyFlow); return { - event_id: generateEventId(tweetText), + event_id: eventId ?? generateEventId(tweetText), signal_type, urgency, matches, diff --git a/src/api/arbitrage-detector.ts b/src/api/arbitrage-detector.ts index 379f118..5d62faa 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -2,6 +2,7 @@ // Matches equivalent Polymarket/Kalshi contracts and prices covered YES/NO bundles. import { Market, ArbitrageOpportunity } from '../types/market'; +import { detectContractType, contractTypeCompatibility } from '../analysis/contract-type'; const STOP_WORDS = new Set([ 'will', 'the', 'a', 'an', 'in', 'on', 'at', 'by', 'for', 'to', 'of', @@ -49,6 +50,11 @@ const DEFAULT_FEES_AND_SLIPPAGE = Number.parseFloat( process.env.MUSASHI_ARB_COST_BUFFER ?? '0.02', ); +// Contract types that are structurally interchangeable for arbitrage matching. +// Both settle to a single YES/NO outcome; the time-window qualifier is a detail +// that one platform may omit in its title while the other includes it. +const BINARY_COMPATIBLE_TYPES = new Set(['TIME_WINDOW_BINARY', 'BINARY_OUTCOME']); + function normalizeTitle(title: string): string { return title .toLowerCase() @@ -141,17 +147,40 @@ function hasConflict(a: Set, b: Set): boolean { return a.size > 0 && b.size > 0 && intersectionSize(a, b) === 0; } +// Weight applied to the containment score when used as a Jaccard fallback. +// Halved so that a fully-contained short title doesn't dominate the composite +// confidence score the same way a high Jaccard score would. +const CONTAINMENT_WEIGHT = 0.5; + function jaccard(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0; const shared = intersectionSize(a, b); const union = a.size + b.size - shared; - return union > 0 ? shared / union : 0; + const jaccardScore = union > 0 ? shared / union : 0; + // When one set is much smaller (e.g. a short Kalshi title after stop-word + // filtering), pure Jaccard is unfairly penalised by the large union. Use a + // weighted containment score (fraction of the smaller set that is covered) as + // a fallback so short but highly-overlapping titles still match. + const containment = shared / Math.min(a.size, b.size); + return Math.max(jaccardScore, containment * CONTAINMENT_WEIGHT); } function calculateKeywordOverlap(market1: Market, market2: Market): number { return intersectionSize(new Set(market1.keywords), new Set(market2.keywords)); } +// Minimum composite confidence required to accept a pair as a match. +// 0.4 is exploratory: more coverage with acceptable noise. +const MIN_MATCH_CONFIDENCE = 0.4; + +// Per-field conflict penalties subtracted from the base confidence score. +// Conflicts reduce confidence but no longer hard-reject the pair. +const PENALTY_YEAR = 0.35; +const PENALTY_DATE = 0.25; +const PENALTY_NUMBER = 0.30; +const PENALTY_OUTCOME = 0.20; +const PENALTY_SCOPE = 0.20; + function areMarketsSimilar(poly: Market, kalshi: Market): MatchResult { const strictCategoryMatch = poly.category === kalshi.category && @@ -163,38 +192,78 @@ function areMarketsSimilar(poly: Market, kalshi: Market): MatchResult { return { isSimilar: false, confidence: 0, reason: 'Different categories' }; } + // Gate 2: Contract structure type compatibility. + // Structurally incompatible types (e.g. RANGE_COUNT vs EVENT_MATCH_OUTCOME, + // score 0) are still hard-rejected. All other pairs receive a positive type + // score that acts as a weight in the composite confidence formula. + const polyType = detectContractType(poly); + const kalshiType = detectContractType(kalshi); + const typeScore = contractTypeCompatibility(polyType, kalshiType); + // Also treat types that are both in the binary-compatible set as fully + // compatible (TIME_WINDOW_BINARY ↔ BINARY_OUTCOME). + const effectiveTypeScore = + typeScore === 0 && BINARY_COMPATIBLE_TYPES.has(polyType) && BINARY_COMPATIBLE_TYPES.has(kalshiType) + ? 1.0 + : typeScore; + if (effectiveTypeScore === 0) { + return { + isSimilar: false, + confidence: 0, + reason: `Incompatible contract types (${polyType} vs ${kalshiType})`, + }; + } + const polySig = signature(poly); const kalshiSig = signature(kalshi); + // Accumulate penalties for field conflicts instead of hard-rejecting. + // Each mismatch reduces confidence; a single mismatch no longer kills the pair. + let penaltyTotal = 0; + const penaltyReasons: string[] = []; + if (hasConflict(polySig.years, kalshiSig.years)) { - return { isSimilar: false, confidence: 0, reason: 'Different contract years' }; + penaltyTotal += PENALTY_YEAR; + penaltyReasons.push('year mismatch'); } if (hasConflict(polySig.dates, kalshiSig.dates)) { - return { isSimilar: false, confidence: 0, reason: 'Different contract dates' }; + penaltyTotal += PENALTY_DATE; + penaltyReasons.push('date mismatch'); } if (hasConflict(polySig.numbers, kalshiSig.numbers)) { - return { isSimilar: false, confidence: 0, reason: 'Different numeric thresholds' }; + penaltyTotal += PENALTY_NUMBER; + penaltyReasons.push('numeric threshold mismatch'); } if (hasConflict(polySig.outcomes, kalshiSig.outcomes)) { - return { isSimilar: false, confidence: 0, reason: 'Different outcome wording' }; + penaltyTotal += PENALTY_OUTCOME; + penaltyReasons.push('outcome wording mismatch'); } if (hasConflict(polySig.scopes, kalshiSig.scopes)) { - return { isSimilar: false, confidence: 0, reason: 'Different contract scope' }; + penaltyTotal += PENALTY_SCOPE; + penaltyReasons.push('scope mismatch'); } const titleSim = jaccard(polySig.terms, kalshiSig.terms); const keywordOverlap = calculateKeywordOverlap(poly, kalshi); const sharedTerms = intersectionSize(polySig.terms, kalshiSig.terms); - let confidence = Math.max(titleSim, Math.min(keywordOverlap / 8, 0.85)); + const semanticScore = Math.max(titleSim, Math.min(keywordOverlap / 8, 0.85)); + + // Base confidence: weighted semantic + type score, minus accumulated penalties. + // Floored at 0 so penalties cannot make confidence negative. + // penaltyTotal is capped at 0.6 so that even when every field conflicts a + // pair with strong semantic + type alignment can still reach the threshold. + const cappedPenalty = Math.min(penaltyTotal, 0.6); + let confidence = Math.max(0, semanticScore * 0.6 + effectiveTypeScore * 0.4 - cappedPenalty); + const blockersMatched = (polySig.years.size === 0 || kalshiSig.years.size === 0 || intersectionSize(polySig.years, kalshiSig.years) > 0) && (polySig.numbers.size === 0 || kalshiSig.numbers.size === 0 || intersectionSize(polySig.numbers, kalshiSig.numbers) > 0); + // Strong-match fast paths: floor confidence at a high value when hard signals agree. if (strictCategoryMatch && blockersMatched && titleSim >= 0.45) { confidence = Math.max(confidence, 0.75); return { @@ -222,7 +291,14 @@ function areMarketsSimilar(poly: Market, kalshi: Market): MatchResult { }; } - return { isSimilar: false, confidence: 0, reason: 'Insufficient contract equivalence' }; + // Graded match: accept any pair whose composite score meets the minimum threshold. + if (confidence >= MIN_MATCH_CONFIDENCE) { + const parts = [`Graded match (score: ${confidence.toFixed(2)})`]; + if (penaltyReasons.length > 0) parts.push(`penalties: ${penaltyReasons.join(', ')}`); + return { isSimilar: true, confidence, reason: parts.join('; ') }; + } + + return { isSimilar: false, confidence, reason: 'Score below match threshold' }; } function buyYesPrice(market: Market): number { @@ -264,10 +340,10 @@ function candidatesFor(poly: Market, kalshiByCategory: Map): M return kalshiByCategory.get('other') ?? []; } - return [ - ...(kalshiByCategory.get(poly.category) ?? []), - ...(kalshiByCategory.get('other') ?? []), - ]; + const sameCategory = kalshiByCategory.get(poly.category) ?? []; + const fallback = (kalshiByCategory.get('other') ?? []).slice(0, 5); + + return [...sameCategory, ...fallback]; } /** @@ -281,7 +357,7 @@ function candidatesFor(poly: Market, kalshiByCategory: Map): M */ export function detectArbitrage( markets: Market[], - minSpread: number = 0.03, + minSpread: number = 0.03, //FOR DEBUG: FROM 0.03 TO 0.01 feesAndSlippage: number = DEFAULT_FEES_AND_SLIPPAGE, ): ArbitrageOpportunity[] { const opportunities: ArbitrageOpportunity[] = []; @@ -300,11 +376,25 @@ export function detectArbitrage( for (const poly of polymarkets) { for (const kalshi of candidatesFor(poly, kalshiByCategory)) { const similarity = areMarketsSimilar(poly, kalshi); - if (!similarity.isSimilar) continue; + //DEBUGGING + console.log("[Arbitrage] pair:", poly.title, kalshi.title); + console.log("[Arbitrage] similarity:", similarity.confidence, similarity.reason); + + if (!similarity.isSimilar) { + //DEBUG LOGGING + console.log("[Arbitrage] rejected pair:", poly.title, kalshi.title); + continue; + } const bestBundle = priceBundle(poly, kalshi, feesAndSlippage) .sort((a, b) => b.edge - a.edge)[0]; + //DEBUG + + console.log("[Arbitrage] candidate edge:", bestBundle.edge); + console.log("[Arbitrage] costPerBundle:", bestBundle.costPerBundle); + console.log("[Arbitrage] direction:", bestBundle.direction); + if (bestBundle.edge < minSpread) continue; opportunities.push({ diff --git a/src/api/intra-event-arbitrage.ts b/src/api/intra-event-arbitrage.ts new file mode 100644 index 0000000..e7d136f --- /dev/null +++ b/src/api/intra-event-arbitrage.ts @@ -0,0 +1,304 @@ +/* An intra-event arbitrage detector tries to detect arbitrage WITHIN a single event, i.e across multiple markets that +represent mutually-exclusive outcomes of the same real-world event. Unlike cross-platform arbitrage (Polymarket vs Kalshi), this +does not need similarity matching. Markets that belong to the same event are +identified by a shared structural key (Kalshi event_ticker) rather than +by comparing free-text titles. + +Supported strategies +────────────────────────────────────────────────────────────────────────────── +RANGE_SUM – Kalshi range-bucket markets (e.g. "80–84 seats", "85–89 seats") + must collectively cover every possible outcome, so their YES + prices must sum to exactly 1 (minus fees). + sum < 1 leads to BUY_ALL (guaranteed payout for less than $1) + sum > 1 leads to SELL_ALL (collect more than $1, pay out exactly $1) +MATCH_OUTCOME – Head-to-head markets where exactly one side wins. + p(A wins) + p(B wins) must equal 1. + +BINARY_COMPLEMENT – Two binary markets whose resolution conditions are + complementary (e.g. "X above 50%" / "X at or below 50%"). +*/ +import { Market, GroupArbitrageOpportunity, GroupArbitrageLeg } from '../types/market'; +import { detectContractType } from '../analysis/contract-type'; + +const DEFAULT_FEES_AND_SLIPPAGE = Number.parseFloat( + process.env.MUSASHI_ARB_COST_BUFFER ?? '0.02', +); + +// ─── Event key extraction ────────────────────────────────────────────────────── + +/** + * Return a canonical event key for a market. + * + * For Kalshi markets the `event_ticker` is a reliable, platform-assigned key + * that is shared by every outcome market within the same event. We use it + * directly when available. + * + * For Polymarket (or Kalshi markets missing an event_ticker) we fall back to a + * normalised title slug. This is less precise but still useful for grouping + * range/match pairs that share most title words. + */ +export function getEventKey(market: Market): string { + if (market.eventTicker) { + return `${market.platform}:${market.eventTicker.toLowerCase()}`; + } + + // Title-based fallback: strip range suffixes ("80–84", "85 to 89") so that + // all buckets of the same election map to the same key. + const base = market.title + .toLowerCase() + // Strip numeric range suffixes so all buckets of the same event share a key. + // The three characters are: hyphen-minus (U+002D), en-dash (U+2013), em-dash (U+2014). + .replace(/\b\d+\s*[\u002D\u2013\u2014]\s*\d+\b/g, '') // numeric range, e.g. 80-84 + .replace(/\b\d+\s+to\s+\d+\b/g, '') // "X to Y" + .replace(/\bbetween\s+\d+\s+and\s+\d+\b/g, '') // "between X and Y" + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .trim(); + + return `${market.platform}:${base}`; +} + +// GROUPING #####!!!!! + +/** + * Group a flat list of markets by their underlying event key. + * Only groups that contain 2 or more markets from the SAME platform are + * meaningful candidates for intra-event arbitrage. + */ +export function groupMarketsByEvent(markets: Market[]): Map { + const groups = new Map(); + + for (const market of markets) { + const key = getEventKey(market); + const bucket = groups.get(key) ?? []; + bucket.push(market); + groups.set(key, bucket); + } + + return groups; +} + +// Price helpers + +// Best executable price to BUY YES (ask). +function buyYesPrice(market: Market): number { + return market.yesAsk ?? market.yesPrice; +} + +/* Best executable price to SELL YES (bid). */ +function sellYesPrice(market: Market): number { + return market.yesBid ?? market.yesPrice; +} + +// Per-group arbitrage detection + +/** + * Given a group of markets that share an event key, attempt to find an + * intra-event arbitrage opportunity. + * + * @param eventKey - Shared event key for logging/identification. + * @param group - Markets belonging to the same event (same platform). + * @param minEdge - Minimum net edge required to report an opportunity. + * @param feesAndSlippage - Per-leg cost buffer (applied once per group). + */ +function detectArbitrageInGroup( + eventKey: string, + group: Market[], + minEdge: number, + feesAndSlippage: number, +): GroupArbitrageOpportunity | null { + if (group.length < 2) return null; + + // All markets in a group must be on the same platform. + const platform = group[0].platform; + if (!group.every(m => m.platform === platform)) return null; + + /* multi platforms? + const platforms = new Set(group.map(m => m.platform)); + const platformLabel = platforms.size === 1 + ? group[0].platform + : 'cross-platform'; + */ + + const types = group.map(m => detectContractType(m)); + + // ── RANGE_SUM ──────────────────────────────────────────────────────────────── + // All markets in the group are numeric range buckets (RANGE_COUNT). + if (types.every(t => t === 'RANGE_COUNT')) { + return buildRangeSumOpportunity(eventKey, group, platform, minEdge, feesAndSlippage); + } + const titles = group.map(m => m.title.toLowerCase()); + + const isLikelyComplement = + titles.some(t => t.includes('yes')) || + titles.some(t => t.includes('above')) || + titles.some(t => t.includes('below')); + + // ── MATCH_OUTCOME ───────────────────────────────────────────────────────────── + // Exactly two markets, both classified as head-to-head match outcomes. + if ( + group.length === 2 && types.every(t => t === 'BINARY_OUTCOME' || t === 'TIME_WINDOW_BINARY') && isLikelyComplement + ) + + // ── BINARY_COMPLEMENT ───────────────────────────────────────────────────────── + // Exactly two binary markets that together should exhaust all outcomes. + if (group.length === 2 && types.every(t => t === 'BINARY_OUTCOME' || t === 'TIME_WINDOW_BINARY')) { + return buildBinaryOpportunity(eventKey, group, platform, 'BINARY_COMPLEMENT', minEdge, feesAndSlippage); + } + + return null; +} + +/** + * Build a RANGE_SUM opportunity. + * + * If the sum of buy-YES prices is below 1 minus fees, buying all outcomes + * guarantees a $1 payout for less than $1 → BUY_ALL. + * + * If the sum of sell-YES prices exceeds 1 plus fees, selling all YES contracts + * collects more than $1 while paying out exactly $1 → SELL_ALL. + */ +function buildRangeSumOpportunity( + eventKey: string, + group: Market[], + platform: 'kalshi' | 'polymarket', + minEdge: number, + feesAndSlippage: number, +): GroupArbitrageOpportunity | null { + const buySum = group.reduce((acc, m) => acc + buyYesPrice(m), 0); + const sellSum = group.reduce((acc, m) => acc + sellYesPrice(m), 0); + + const buyEdge = 1 - buySum - feesAndSlippage; + const sellEdge = sellSum - 1 - feesAndSlippage; + + if (buyEdge >= minEdge) { + const legs: GroupArbitrageLeg[] = group.map(m => ({ + market: m, + action: 'BUY', + price: buyYesPrice(m), + })); + return makeOpportunity(eventKey, platform, 'RANGE_SUM', 'BUY_ALL', legs, buySum, buyEdge, feesAndSlippage, group.length); + } + + if (sellEdge >= minEdge) { + const legs: GroupArbitrageLeg[] = group.map(m => ({ + market: m, + action: 'SELL', + price: sellYesPrice(m), + })); + return makeOpportunity(eventKey, platform, 'RANGE_SUM', 'SELL_ALL', legs, sellSum, sellEdge, feesAndSlippage, group.length); + } + + return null; +} + +/** + * Build a MATCH_OUTCOME or BINARY_COMPLEMENT opportunity. + * + * For a two-outcome exhaustive market (A wins or B wins; above or below), + * the YES prices must sum to exactly 1. Any deviation (after fees) is + * arbitrageable. + */ +function buildBinaryOpportunity( + eventKey: string, + group: Market[], + platform: 'kalshi' | 'polymarket', + type: 'MATCH_OUTCOME' | 'BINARY_COMPLEMENT', + minEdge: number, + feesAndSlippage: number, +): GroupArbitrageOpportunity | null { + const [m1, m2] = group; + + const buySum = buyYesPrice(m1) + buyYesPrice(m2); + const sellSum = sellYesPrice(m1) + sellYesPrice(m2); + + const buyEdge = 1 - buySum - feesAndSlippage; + const sellEdge = sellSum - 1 - feesAndSlippage; + + if (buyEdge >= minEdge) { + const legs: GroupArbitrageLeg[] = group.map(m => ({ + market: m, + action: 'BUY', + price: buyYesPrice(m), + })); + return makeOpportunity(eventKey, platform, type, 'BUY_ALL', legs, buySum, buyEdge, feesAndSlippage, 2); + } + + if (sellEdge >= minEdge) { + const legs: GroupArbitrageLeg[] = group.map(m => ({ + market: m, + action: 'SELL', + price: sellYesPrice(m), + })); + return makeOpportunity(eventKey, platform, type, 'SELL_ALL', legs, sellSum, sellEdge, feesAndSlippage, 2); + } + + return null; +} + +function makeOpportunity( + eventKey: string, + platform: 'kalshi' | 'polymarket', + type: GroupArbitrageOpportunity['type'], + action: GroupArbitrageOpportunity['action'], + legs: GroupArbitrageLeg[], + priceSum: number, + edge: number, + feesAndSlippage: number, + marketCount: number, +): GroupArbitrageOpportunity { + return { + eventKey, + platform, + type, + action, + legs, + priceSum: +priceSum.toFixed(4), + edge: +edge.toFixed(4), + spread: +edge.toFixed(4), + feesAndSlippage, + profitPotential: +edge.toFixed(4), + marketCount, + }; +} + +// API +/** + * Detect intra-event arbitrage opportunities across all markets. + * + * Markets are grouped by their underlying event key. Within each group the + * sum of YES prices is compared to 1; groups whose prices deviate beyond + * `minEdge + feesAndSlippage` are returned as opportunities. + * + * This replaces the pairwise cross-platform similarity scan for intra-platform + * structural arbitrage and runs in O(n) time rather than O(n²). + * + * @param markets - All markets (any platform). + * @param minEdge - Minimum net edge to report (default 0.01). + * @param feesAndSlippage - Per-group cost buffer (default from env or 0.02). + */ +export function detectIntraEventArbitrage( + markets: Market[], + minEdge: number = 0.01, + feesAndSlippage: number = DEFAULT_FEES_AND_SLIPPAGE, +): GroupArbitrageOpportunity[] { + const groups = groupMarketsByEvent(markets); + const opportunities: GroupArbitrageOpportunity[] = []; + + for (const [eventKey, group] of groups) { + const opportunity = detectArbitrageInGroup(eventKey, group, minEdge, feesAndSlippage); + if (opportunity) { + opportunities.push(opportunity); + } + } + + opportunities.sort((a, b) => b.edge - a.edge); + + console.log( + `[IntraEvent] Checked ${groups.size} event groups → found ${opportunities.length} opportunity(ies) (minEdge: ${minEdge})` + ); + + return opportunities; +} \ No newline at end of file diff --git a/src/api/kalshi-client.ts b/src/api/kalshi-client.ts index f80fd46..b4167cd 100644 --- a/src/api/kalshi-client.ts +++ b/src/api/kalshi-client.ts @@ -200,6 +200,7 @@ function toMarket(km: KalshiMarket): Market { category: inferCategory(km.series_ticker || km.event_ticker || km.ticker), lastUpdated: new Date().toISOString(), endDate: km.close_time, + eventTicker: km.event_ticker || undefined, }; } diff --git a/src/types/market.ts b/src/types/market.ts index 93edfba..bb9c99d 100644 --- a/src/types/market.ts +++ b/src/types/market.ts @@ -19,6 +19,7 @@ export interface Market { numericId?: string; // Polymarket numeric ID for live price polling oneDayPriceChange?: number; // 24h price delta for YES (e.g. 0.05 = +5%) endDate?: string; // ISO date string (e.g. "2026-03-31") + eventTicker?: string; // Kalshi event_ticker: shared key across all outcome markets in the same event } export interface MarketMatch { @@ -27,6 +28,17 @@ export interface MarketMatch { matchedKeywords: string[]; } +export interface CanonicalEvent { + id: string; + title: string; + normalizedTitle: string; + category?: string; + markets: { + polymarket?: Market; + kalshi?: Market; + }; +} + export interface ArbitrageOpportunity { polymarket: Market; kalshi: Market; @@ -43,3 +55,36 @@ export interface ArbitrageOpportunity { confidence: number; // 0-1, how confident we are this is the same event matchReason: string; // Why we think these are the same market } + +/** + * A single position within an intra-event arbitrage group. + */ +export interface GroupArbitrageLeg { + market: Market; + /** BUY = buy YES; SELL = sell YES (collect premium). */ + action: 'BUY' | 'SELL'; + price: number; // Executable price used in the calculation +} + +/** + * Intra-event arbitrage: multiple markets that represent mutually-exclusive + * outcomes of the same event (e.g. Kalshi range buckets, or the + * two sides of a head-to-head match) but whose prices do not sum to 1. + * + * This is not the same as ArbitrageOpportunity (cross-platform, two venues). + */ +export interface GroupArbitrageOpportunity { + // Shared event key = the Kalshi event_ticker. + eventKey: string; + platform: 'kalshi' | 'polymarket'; + type: 'RANGE_SUM' | 'BINARY_COMPLEMENT' | 'MATCH_OUTCOME'; + action: 'BUY_ALL' | 'SELL_ALL'; + legs: GroupArbitrageLeg[]; + priceSum: number; + // Net profit edge + edge: number; + spread: number; + feesAndSlippage: number; + profitPotential: number; + marketCount: number; +}