diff --git a/API-REFERENCE.upstream.md b/API-REFERENCE.upstream.md index 34ea264..a56dcbb 100644 --- a/API-REFERENCE.upstream.md +++ b/API-REFERENCE.upstream.md @@ -106,79 +106,115 @@ Get cross-platform arbitrage opportunities between Polymarket and Kalshi. **Request:** ``` -GET /api/markets/arbitrage?minSpread=0.03&minConfidence=0.5&limit=20&category=crypto +GET /api/markets/arbitrage?mode=fast&minNetEdgeBps=50&maxDataAgeMs=5000&minConfidence=0.5&limit=20&category=crypto ``` **Query Parameters:** -- `minSpread` (optional): Minimum price spread (default: 0.03 = 3%) +- `mode` (optional): `fast | full` (default: `fast`) +- `minNetEdgeBps` (optional): Minimum net executable edge in bps (default derived from `minSpread`) +- `maxDataAgeMs` (optional): Freshness budget; stale data returns degraded empty payload +- `minSpread` (optional): Legacy fallback threshold (mapped to bps for backward compatibility) - `minConfidence` (optional): Minimum match confidence (default: 0.5 = 50%) - `limit` (optional): Max results (default: 20, max: 100) - `category` (optional): Filter by category (crypto, us_politics, economics, etc.) -**Response:** +**Response (`mode=full`):** ```json { "success": true, "data": { "opportunities": [ { - "polymarket": { - "id": "polymarket-0x123...", - "title": "Will Bitcoin reach $100k by March 2026?", - "yesPrice": 0.63, - "volume24h": 450000, - ... + "polymarket": { "...": "full market object" }, + "kalshi": { "...": "full market object" }, + "buyVenue": "polymarket", + "sellVenue": "kalshi", + "buyPrice": 0.62, + "sellPrice": 0.69, + "grossEdgeBps": 1129, + "estimatedFeesBps": 40, + "slippageBps": 10, + "latencyRiskBps": 5, + "netEdgeBps": 1074, + "matchConfidence": { + "score": 0.84, + "titleSimilarity": 0.78, + "keywordOverlap": 4, + "categoryAligned": true, + "expiryAligned": true }, - "kalshi": { - "id": "kalshi-KXBTC-...", - "title": "Bitcoin $100k by Mar 2026", - "yesPrice": 0.70, - "volume24h": 200000, - ... + "expiryDeltaMinutes": 45, + "liquidityScore": 0.92, + "sourceTimestamps": { + "polymarket": "2026-03-01T11:59:42.000Z", + "kalshi": "2026-03-01T11:59:41.000Z" }, - "spread": 0.07, // 7% spread - "profitPotential": 0.07, // Expected 7% profit + "asOfTs": "2026-03-01T12:00:00.000Z", + // Backward-compatible fields during migration window: + "spread": 0.07, + "profitPotential": 0.07, "direction": "buy_poly_sell_kalshi", - "confidence": 0.85, - "matchReason": "High title similarity (85%)" + "confidence": 0.84, + "matchReason": "High title similarity (78%)" } ], "count": 5, "timestamp": "2026-03-01T12:00:00.000Z", "filters": { + "mode": "full", "minSpread": 0.03, + "minNetEdgeBps": 300, + "maxDataAgeMs": 5000, "minConfidence": 0.5, "limit": 20, "category": "crypto" - }, - "metadata": { - "processing_time_ms": 89, - "markets_analyzed": 1234, - "polymarket_count": 734, - "kalshi_count": 500, - // Stage 0: Freshness tracking (added March 2026) - "data_age_seconds": 18, - "fetched_at": "2026-03-01T11:59:42.000Z", - "sources": { - "polymarket": { - "available": true, - "last_successful_fetch": "2026-03-01T11:59:42.000Z", - "market_count": 1200 - }, - "kalshi": { - "available": true, - "last_successful_fetch": "2026-03-01T11:59:42.000Z", - "market_count": 500 - } - } + } + }, + "metadata": { + "processing_time_ms": 89, + "data_age_seconds": 18, + "fetched_at": "2026-03-01T11:59:42.000Z", + "mode": "full", + "sources": { + "polymarket": { "available": true, "market_count": 1200 }, + "kalshi": { "available": true, "market_count": 500 } } } } ``` -**Trading Strategy:** -- `buy_poly_sell_kalshi`: Buy YES on Polymarket (cheaper), sell YES on Kalshi (expensive) -- `buy_kalshi_sell_poly`: Buy YES on Kalshi (cheaper), sell YES on Polymarket (expensive) +**Response (`mode=fast`):** +```json +{ + "success": true, + "data": { + "opportunities": [ + { + "buyVenue": "polymarket", + "sellVenue": "kalshi", + "buyPrice": 0.62, + "sellPrice": 0.69, + "netEdgeBps": 1074, + "estimatedFeesBps": 40, + "slippageBps": 10, + "latencyRiskBps": 5, + "matchConfidence": 0.84, + "expiryDeltaMinutes": 45, + "liquidityScore": 0.92, + "sourceTimestamps": { + "polymarket": "2026-03-01T11:59:42.000Z", + "kalshi": "2026-03-01T11:59:41.000Z" + }, + "asOfTs": "2026-03-01T12:00:00.000Z" + } + ], + "count": 1 + }, + "metadata": { + "mode": "fast" + } +} +``` --- diff --git a/api/health.ts b/api/health.ts index d581941..aa79e01 100644 --- a/api/health.ts +++ b/api/health.ts @@ -1,6 +1,7 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; import { fetchPolymarkets } from '../src/api/polymarket-client'; import { fetchKalshiMarkets } from '../src/api/kalshi-client'; +import { getArbitrageCacheMetadata, getMarketMetadata } from './lib/market-cache'; export default async function handler( req: VercelRequest, @@ -52,6 +53,9 @@ export default async function handler( ? 'down' : 'degraded'; + const freshness = getMarketMetadata(); + const arbCache = getArbitrageCacheMetadata(); + const healthData = { status: overallStatus, timestamp: new Date().toISOString(), @@ -89,6 +93,20 @@ export default async function handler( cache_ttl_seconds: 300, rate_limit: 'none (currently)', }, + caches: { + arbitrage: arbCache, + }, + feature_flags: { + ARB_V15_ENABLED: process.env.ARB_V15_ENABLED !== '0', + ARB_NET_EDGE_ENABLED: process.env.ARB_NET_EDGE_ENABLED !== '0', + ARB_STRICT_MATCH_ENABLED: process.env.ARB_STRICT_MATCH_ENABLED !== '0', + ARB_SHARED_CACHE_ENABLED: process.env.ARB_SHARED_CACHE_ENABLED === '1', + }, + freshness: { + data_age_seconds: freshness.data_age_seconds, + fetched_at: freshness.fetched_at, + sources: freshness.sources, + }, }; const response = { diff --git a/api/lib/market-cache.ts b/api/lib/market-cache.ts index 8b41d67..0475ac1 100644 --- a/api/lib/market-cache.ts +++ b/api/lib/market-cache.ts @@ -8,7 +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 { FreshnessMetadata, SourceStatus } from './types'; +import { FreshnessMetadata } from './types'; +import { kv, setKvWithTtl } from './vercel-kv'; // In-memory cache for markets // Default: 20 seconds (configurable via MARKET_CACHE_TTL_SECONDS env var) @@ -16,6 +17,16 @@ let cachedMarkets: Market[] = []; let cacheTimestamp = 0; const CACHE_TTL_MS = (parseInt(process.env.MARKET_CACHE_TTL_SECONDS || '20', 10)) * 1000; +// In-memory cache for arbitrage opportunities +// Default: 15 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; + +// Refresh Guards +let marketsRefreshPromise: Promise | null = null; +let arbRefreshPromise: Promise | null = null; + // Stage 0: Per-source tracking for freshness metadata let polyTimestamp = 0; let kalshiTimestamp = 0; @@ -24,34 +35,28 @@ let kalshiMarketCount = 0; 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) -let cachedArbitrage: ArbitrageOpportunity[] = []; -let arbCacheTimestamp = 0; -const ARB_CACHE_TTL_MS = (parseInt(process.env.ARBITRAGE_CACHE_TTL_SECONDS || '15', 10)) * 1000; +// Stage 0 Session 2: Per-source timeout (5 seconds) +const SOURCE_TIMEOUT_MS = 5000; const POLYMARKET_TARGET_COUNT = parsePositiveInt(process.env.MUSASHI_POLYMARKET_TARGET_COUNT, 1200); const POLYMARKET_MAX_PAGES = parsePositiveInt(process.env.MUSASHI_POLYMARKET_MAX_PAGES, 20); const KALSHI_TARGET_COUNT = parsePositiveInt(process.env.MUSASHI_KALSHI_TARGET_COUNT, 1000); const KALSHI_MAX_PAGES = parsePositiveInt(process.env.MUSASHI_KALSHI_MAX_PAGES, 20); +const ARB_SHARED_CACHE_ENABLED = process.env.ARB_SHARED_CACHE_ENABLED === '1'; +const SHARED_ARB_CACHE_KEY = 'arb:v15:opportunities'; +const SHARED_ARB_LOCK_KEY = 'arb:v15:refresh_lock'; +const SHARED_ARB_LOCK_TTL_SECONDS = 15; +const SHARED_ARB_CACHE_TTL_SECONDS = Math.max(Math.floor(ARB_CACHE_TTL_MS / 1000), 3); -// Stage 0 Session 2: Per-source timeout (5 seconds) -const SOURCE_TIMEOUT_MS = 5000; +function logCacheEvent(event: string, payload: Record = {}): void { + console.log(JSON.stringify({ event, ...payload, ts: new Date().toISOString() })); +} function parsePositiveInt(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(value ?? '', 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } -/** - * Stage 0 Session 2: Wrap a promise with a timeout - * If the promise doesn't resolve within timeoutMs, reject with timeout error - * - * @param promise - The promise to wrap - * @param timeoutMs - Timeout in milliseconds - * @param sourceName - Name of the source (for error message) - * @returns Promise that rejects if timeout is exceeded - */ function withTimeout( promise: Promise, timeoutMs: number, @@ -78,103 +83,101 @@ export async function getMarkets(): Promise { // Return cached if fresh if (cachedMarkets.length > 0 && (now - cacheTimestamp) < CACHE_TTL_MS) { - console.log(`[Market Cache] Using cached ${cachedMarkets.length} markets (TTL: ${CACHE_TTL_MS}ms, age: ${now - cacheTimestamp}ms)`); + logCacheEvent('markets_cache_hit', { cached_count: cachedMarkets.length, age_ms: now - cacheTimestamp }); return cachedMarkets; } - // Fetch fresh markets - console.log(`[Market Cache] Fetching fresh markets... (TTL: ${CACHE_TTL_MS}ms)`); - - try { - // Stage 0 Session 2: Wrap each source with 5-second timeout - const [polyResult, kalshiResult] = await Promise.allSettled([ - withTimeout( - fetchPolymarkets(POLYMARKET_TARGET_COUNT, POLYMARKET_MAX_PAGES), - SOURCE_TIMEOUT_MS, - 'Polymarket' - ), - withTimeout( - fetchKalshiMarkets(KALSHI_TARGET_COUNT, KALSHI_MAX_PAGES), - SOURCE_TIMEOUT_MS, - 'Kalshi' - ), - ]); - - // Stage 0: Track Polymarket fetch - if (polyResult.status === 'fulfilled') { - polyTimestamp = now; - polyMarketCount = polyResult.value.length; - polyError = null; - } else { - polyError = polyResult.reason?.message || 'Failed to fetch Polymarket markets'; - console.error('[Market Cache] Polymarket fetch failed:', polyError); - } + // If a refresh is already in progress, return the existing promise + if (marketsRefreshPromise) { + return marketsRefreshPromise; + } - // Stage 0: Track Kalshi fetch - if (kalshiResult.status === 'fulfilled') { - kalshiTimestamp = now; - kalshiMarketCount = kalshiResult.value.length; - kalshiError = null; - } else { - kalshiError = kalshiResult.reason?.message || 'Failed to fetch Kalshi markets'; - console.error('[Market Cache] Kalshi fetch failed:', kalshiError); - } + // Fetch fresh markets with refresh guard + marketsRefreshPromise = (async () => { + try { + console.log(`[Market Cache] Fetching fresh markets... (TTL: ${CACHE_TTL_MS}ms)`); + + const [polyResult, kalshiResult] = await Promise.allSettled([ + withTimeout( + fetchPolymarkets(POLYMARKET_TARGET_COUNT, POLYMARKET_MAX_PAGES), + SOURCE_TIMEOUT_MS, + 'Polymarket' + ), + withTimeout( + fetchKalshiMarkets(KALSHI_TARGET_COUNT, KALSHI_MAX_PAGES), + SOURCE_TIMEOUT_MS, + 'Kalshi' + ), + ]); - const polyMarkets = polyResult.status === 'fulfilled' ? polyResult.value : []; - const kalshiMarkets = kalshiResult.status === 'fulfilled' ? kalshiResult.value : []; + const currentFetchTime = Date.now(); - cachedMarkets = [...polyMarkets, ...kalshiMarkets]; - cacheTimestamp = now; + if (polyResult.status === 'fulfilled') { + polyTimestamp = currentFetchTime; + polyMarketCount = polyResult.value.length; + polyError = null; + } else { + polyError = polyResult.reason?.message || 'Failed to fetch Polymarket'; + } - console.log(`[Market Cache] Cached ${cachedMarkets.length} markets (${polyMarkets.length} Poly + ${kalshiMarkets.length} Kalshi)`); - return cachedMarkets; - } catch (error) { - console.error('[Market Cache] Failed to fetch markets:', error); - // Return stale cache if available - return cachedMarkets; - } + if (kalshiResult.status === 'fulfilled') { + kalshiTimestamp = currentFetchTime; + kalshiMarketCount = kalshiResult.value.length; + kalshiError = null; + } else { + kalshiError = kalshiResult.reason?.message || 'Failed to fetch Kalshi'; + } + + const polyMarkets = polyResult.status === 'fulfilled' ? polyResult.value : []; + const kalshiMarkets = kalshiResult.status === 'fulfilled' ? kalshiResult.value : []; + + cachedMarkets = [...polyMarkets, ...kalshiMarkets]; + cacheTimestamp = currentFetchTime; + logCacheEvent('markets_cache_refresh', { + total_count: cachedMarkets.length, + polymarket_count: polyMarkets.length, + kalshi_count: kalshiMarkets.length, + }); + + return cachedMarkets; + } finally { + marketsRefreshPromise = null; + } + })(); + + return marketsRefreshPromise; } /** * Stage 0: Get freshness metadata for current cached data * Tells bots/agents how old the data is and which sources are healthy - * - * @returns FreshnessMetadata with data age and source health status */ export function getMarketMetadata(): FreshnessMetadata { const now = Date.now(); - - // Find oldest fetch timestamp (or use cache timestamp if no individual source timestamps) const oldestTimestamp = Math.min( polyTimestamp || cacheTimestamp, kalshiTimestamp || cacheTimestamp ); - // Calculate data age in seconds const dataAgeMs = now - oldestTimestamp; const dataAgeSeconds = Math.floor(dataAgeMs / 1000); - // Build source status - const polymarketStatus: SourceStatus = { - available: polyError === null && polyMarketCount > 0, - last_successful_fetch: polyTimestamp > 0 ? new Date(polyTimestamp).toISOString() : null, - error: polyError || undefined, - market_count: polyMarketCount, - }; - - const kalshiStatus: SourceStatus = { - available: kalshiError === null && kalshiMarketCount > 0, - last_successful_fetch: kalshiTimestamp > 0 ? new Date(kalshiTimestamp).toISOString() : null, - error: kalshiError || undefined, - market_count: kalshiMarketCount, - }; - return { data_age_seconds: dataAgeSeconds, fetched_at: new Date(oldestTimestamp).toISOString(), sources: { - polymarket: polymarketStatus, - kalshi: kalshiStatus, + polymarket: { + available: polyError === null && polyMarketCount > 0, + last_successful_fetch: polyTimestamp > 0 ? new Date(polyTimestamp).toISOString() : null, + error: polyError || undefined, + market_count: polyMarketCount, + }, + kalshi: { + available: kalshiError === null && kalshiMarketCount > 0, + last_successful_fetch: kalshiTimestamp > 0 ? new Date(kalshiTimestamp).toISOString() : null, + error: kalshiError || undefined, + market_count: kalshiMarketCount, + }, }, }; } @@ -182,29 +185,86 @@ export function getMarketMetadata(): FreshnessMetadata { /** * Get cached arbitrage opportunities * - * Caches with low minSpread (0.01) and filters client-side. + * Caches with low minNetEdgeBps (10) and filters client-side. * This allows different callers to request different thresholds * without recomputing the expensive O(n×m) scan. * - * @param minSpread - Minimum spread threshold (default: 0.03) - * @returns Arbitrage opportunities filtered by minSpread + * @param minNetEdgeBps - Minimum net edge in basis points (default: 50) + * @returns Arbitrage opportunities filtered by minNetEdgeBps */ -export async function getArbitrage(minSpread: number = 0.03): Promise { - const markets = await getMarkets(); +export async function getArbitrage(minNetEdgeBps: number = 50): Promise { const now = Date.now(); - // Recompute if cache is stale - if (cachedArbitrage.length === 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); - arbCacheTimestamp = now; - console.log(`[Arbitrage Cache] Cached ${cachedArbitrage.length} opportunities (minSpread: 0.01, TTL: ${ARB_CACHE_TTL_MS}ms)`); + if (arbRefreshPromise) { + const opportunities = await arbRefreshPromise; + return opportunities.filter((arb) => (arb.netEdgeBps ?? 0) >= minNetEdgeBps); } - // Filter cached results by requested minSpread - const filtered = cachedArbitrage.filter(arb => arb.spread >= minSpread); - console.log(`[Arbitrage Cache] Returning ${filtered.length}/${cachedArbitrage.length} opportunities (minSpread: ${minSpread})`); + if (cachedArbitrage.length === 0 || (now - arbCacheTimestamp) >= ARB_CACHE_TTL_MS) { + arbRefreshPromise = (async () => { + try { + if (ARB_SHARED_CACHE_ENABLED) { + const shared = await kv.get(SHARED_ARB_CACHE_KEY); + if (shared && shared.length > 0) { + cachedArbitrage = shared; + arbCacheTimestamp = Date.now(); + logCacheEvent('arb_shared_cache_hit', { shared_count: shared.length }); + return cachedArbitrage; + } + } + + let acquiredLock = false; + if (ARB_SHARED_CACHE_ENABLED) { + try { + const lockResult = await (kv as any).set(SHARED_ARB_LOCK_KEY, `holder-${Date.now()}`, { + nx: true, + ex: SHARED_ARB_LOCK_TTL_SECONDS, + }); + acquiredLock = lockResult === 'OK' || lockResult === true; + } catch { + acquiredLock = false; + } + } + + if (ARB_SHARED_CACHE_ENABLED && !acquiredLock) { + await new Promise((resolve) => setTimeout(resolve, 80)); + const sharedAfterWait = await kv.get(SHARED_ARB_CACHE_KEY); + if (sharedAfterWait && sharedAfterWait.length > 0) { + cachedArbitrage = sharedAfterWait; + arbCacheTimestamp = Date.now(); + logCacheEvent('arb_shared_cache_wait_hit', { shared_count: sharedAfterWait.length }); + return cachedArbitrage; + } + } - return filtered; + const markets = await getMarkets(); + cachedArbitrage = detectArbitrage(markets, 10); + arbCacheTimestamp = Date.now(); + logCacheEvent('arb_cache_refresh', { computed_count: cachedArbitrage.length }); + if (ARB_SHARED_CACHE_ENABLED) { + await setKvWithTtl(SHARED_ARB_CACHE_KEY, SHARED_ARB_CACHE_TTL_SECONDS, cachedArbitrage); + await kv.del(SHARED_ARB_LOCK_KEY); + } + return cachedArbitrage; + } finally { + arbRefreshPromise = null; + } + })(); + await arbRefreshPromise; + } + + return cachedArbitrage.filter((arb) => (arb.netEdgeBps ?? 0) >= minNetEdgeBps); } + +export function getArbitrageCacheMetadata(): { + cached_count: number; + cache_age_ms: number | null; + refreshed_at: string | null; +} { + const cacheAge = arbCacheTimestamp > 0 ? Date.now() - arbCacheTimestamp : null; + return { + cached_count: cachedArbitrage.length, + cache_age_ms: cacheAge, + refreshed_at: arbCacheTimestamp > 0 ? new Date(arbCacheTimestamp).toISOString() : null, + }; +} \ No newline at end of file diff --git a/api/markets/arbitrage.ts b/api/markets/arbitrage.ts index 26a2f2a..04d66f7 100644 --- a/api/markets/arbitrage.ts +++ b/api/markets/arbitrage.ts @@ -1,5 +1,10 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; -import { getMarkets, getArbitrage, getMarketMetadata } from '../lib/market-cache'; +import { getArbitrage, getMarketMetadata } from '../lib/market-cache'; + +export const config = { + maxDuration: 30, +}; +const ARB_V15_ENABLED = process.env.ARB_V15_ENABLED !== '0'; export default async function handler( req: VercelRequest, @@ -29,94 +34,156 @@ export default async function handler( const startTime = Date.now(); try { - // Parse query parameters const { + mode = 'fast', + maxDataAgeMs, + minNetEdgeBps, minSpread = '0.03', minConfidence = '0.5', limit = '20', category, } = req.query; - const minSpreadNum = parseFloat(minSpread as string); - const minConfidenceNum = parseFloat(minConfidence as string); - const limitNum = parseInt(limit as string, 10); + const parsedMinSpread = parseFloat(minSpread as string); + const parsedMinConfidence = parseFloat(minConfidence as string); + const parsedLimit = parseInt(limit as string, 10); + const parsedMaxDataAgeMs = maxDataAgeMs !== undefined ? Number(maxDataAgeMs) : undefined; - // Validate parameters - if (isNaN(minSpreadNum) || minSpreadNum < 0 || minSpreadNum > 1) { - res.status(400).json({ - success: false, - error: 'Invalid minSpread. Must be between 0 and 1.', - }); + if (Number.isNaN(parsedMinSpread) || parsedMinSpread < 0 || parsedMinSpread > 1) { + res.status(400).json({ success: false, error: 'Invalid minSpread. Must be between 0 and 1.' }); return; } - - if (isNaN(minConfidenceNum) || minConfidenceNum < 0 || minConfidenceNum > 1) { - res.status(400).json({ - success: false, - error: 'Invalid minConfidence. Must be between 0 and 1.', - }); + if (Number.isNaN(parsedMinConfidence) || parsedMinConfidence < 0 || parsedMinConfidence > 1) { + res.status(400).json({ success: false, error: 'Invalid minConfidence. Must be between 0 and 1.' }); return; } - - if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { - res.status(400).json({ - success: false, - error: 'Invalid limit. Must be between 1 and 100.', - }); + if (Number.isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) { + res.status(400).json({ success: false, error: 'Invalid limit. Must be between 1 and 100.' }); + return; + } + if (!['fast', 'full'].includes(mode as string)) { + res.status(400).json({ success: false, error: 'Invalid mode. Must be fast or full.' }); + return; + } + if (parsedMaxDataAgeMs !== undefined && (!Number.isFinite(parsedMaxDataAgeMs) || parsedMaxDataAgeMs < 0)) { + res.status(400).json({ success: false, error: 'Invalid maxDataAgeMs. Must be greater than or equal to 0.' }); return; } - // Get markets - const markets = await getMarkets(); + let effectiveMinBps = Math.round(parsedMinSpread * 10000); + if (minNetEdgeBps !== undefined) { + effectiveMinBps = Number(minNetEdgeBps); + if (!Number.isFinite(effectiveMinBps) || effectiveMinBps < 0) { + res.status(400).json({ success: false, error: 'Invalid minNetEdgeBps. Must be greater than or equal to 0.' }); + return; + } + } - if (markets.length === 0) { - res.status(503).json({ - success: false, - error: 'No markets available. Service temporarily unavailable.', + const opportunities = await getArbitrage(effectiveMinBps); + const freshness = getMarketMetadata(); + + if (parsedMaxDataAgeMs !== undefined && freshness.data_age_seconds * 1000 > parsedMaxDataAgeMs) { + res.status(200).json({ + success: true, + data: { + opportunities: [], + count: 0, + timestamp: new Date().toISOString(), + filters: { + mode, + minSpread: parsedMinSpread, + minNetEdgeBps: effectiveMinBps, + minConfidence: parsedMinConfidence, + limit: parsedLimit, + category: category || null, + maxDataAgeMs: parsedMaxDataAgeMs, + }, + }, + metadata: { + processing_time_ms: Date.now() - startTime, + data_age_seconds: freshness.data_age_seconds, + fetched_at: freshness.fetched_at, + mode, + degraded: true, + stale_reason: 'data_age_exceeded', + sources: freshness.sources, + }, }); return; } - // Get cached arbitrage opportunities (filtered by minSpread) - let opportunities = await getArbitrage(minSpreadNum); - - // Apply additional filters client-side - // Note: opportunities are already sorted by spread descending from detectArbitrage() - opportunities = opportunities - .filter(arb => arb.confidence >= minConfidenceNum) - .filter(arb => !category || arb.polymarket.category === category || arb.kalshi.category === category) - .slice(0, limitNum); + let filtered = opportunities; + if (category || parsedMinConfidence > 0) { + filtered = filtered.filter(arb => { + const matchesCat = !category || + arb.polymarket.category === category || + arb.kalshi.category === category; + const matchesConf = arb.confidence >= parsedMinConfidence; + return matchesCat && matchesConf; + }); + } - // Stage 0: Get freshness metadata - const freshnessMetadata = getMarketMetadata(); + filtered = filtered.sort((a, b) => { + if (b.netEdgeBps !== a.netEdgeBps) return b.netEdgeBps - a.netEdgeBps; + if (b.confidence !== a.confidence) return b.confidence - a.confidence; + const aKey = `${a.polymarket.id}|${a.kalshi.id}`; + const bKey = `${b.polymarket.id}|${b.kalshi.id}`; + return aKey.localeCompare(bKey); + }); - // Build response - const response = { + const result = filtered.slice(0, parsedLimit); + + const payloadOpportunities = (ARB_V15_ENABLED && mode === 'fast' + ? result.map((o) => ({ + buyVenue: o.buyVenue, + sellVenue: o.sellVenue, + buyPrice: o.buyPrice, + sellPrice: o.sellPrice, + netEdgeBps: o.netEdgeBps, + estimatedFeesBps: o.estimatedFeesBps, + slippageBps: o.slippageBps, + latencyRiskBps: o.latencyRiskBps, + matchConfidence: o.matchConfidence.score, + expiryDeltaMinutes: o.expiryDeltaMinutes, + liquidityScore: o.liquidityScore, + sourceTimestamps: o.sourceTimestamps, + asOfTs: o.asOfTs, + })) + : result); + + console.log(JSON.stringify({ + event: 'arb_req', + duration_ms: Date.now() - startTime, + mode, + count: payloadOpportunities.length, + data_age_seconds: freshness.data_age_seconds, + })); + + res.status(200).json({ success: true, data: { - opportunities, - count: opportunities.length, + opportunities: payloadOpportunities, + count: payloadOpportunities.length, timestamp: new Date().toISOString(), filters: { - minSpread: minSpreadNum, - minConfidence: minConfidenceNum, - limit: limitNum, + mode, + v15_enabled: ARB_V15_ENABLED, + minSpread: parsedMinSpread, + minNetEdgeBps: effectiveMinBps, + minConfidence: parsedMinConfidence, + limit: parsedLimit, category: category || null, - }, - metadata: { - processing_time_ms: Date.now() - startTime, - markets_analyzed: markets.length, - polymarket_count: markets.filter(m => m.platform === 'polymarket').length, - kalshi_count: markets.filter(m => m.platform === 'kalshi').length, - // Stage 0: Freshness metadata - data_age_seconds: freshnessMetadata.data_age_seconds, - fetched_at: freshnessMetadata.fetched_at, - sources: freshnessMetadata.sources, + maxDataAgeMs: parsedMaxDataAgeMs ?? null, }, }, - }; - - res.status(200).json(response); + metadata: { + processing_time_ms: Date.now() - startTime, + data_age_seconds: freshness.data_age_seconds, + fetched_at: freshness.fetched_at, + mode, + sources: freshness.sources, + }, + }); } catch (error) { console.error('[Arbitrage API] Error:', error); res.status(500).json({ diff --git a/scripts/case-study/run-simulation.ts b/scripts/case-study/run-simulation.ts index be1961c..5017a89 100644 --- a/scripts/case-study/run-simulation.ts +++ b/scripts/case-study/run-simulation.ts @@ -67,15 +67,40 @@ function legacyDetect(markets: Market[]): ArbitrageOpportunity[] { const spread = Math.abs(poly.yesPrice - kalshi.yesPrice); if (spread < 0.03) continue; + const direction = poly.yesPrice < kalshi.yesPrice ? 'buy_poly_sell_kalshi' : 'buy_kalshi_sell_poly'; + const buyPrice = direction === 'buy_poly_sell_kalshi' ? poly.yesPrice : kalshi.yesPrice; + const sellPrice = direction === 'buy_poly_sell_kalshi' ? kalshi.yesPrice : poly.yesPrice; + const now = new Date().toISOString(); + opportunities.push({ polymarket: poly, kalshi, spread, rawPriceGap: spread, profitPotential: spread, - direction: poly.yesPrice < kalshi.yesPrice ? 'buy_poly_sell_kalshi' : 'buy_kalshi_sell_poly', + direction, + buyPrice, + sellPrice, + buyVenue: direction === 'buy_poly_sell_kalshi' ? 'polymarket' : 'kalshi', + sellVenue: direction === 'buy_poly_sell_kalshi' ? 'kalshi' : 'polymarket', + netEdgeBps: 0, + grossEdgeBps: 0, + estimatedFeesBps: 0, + slippageBps: 0, + latencyRiskBps: 0, confidence, matchReason: `Legacy title similarity (${(confidence * 100).toFixed(0)}%)`, + matchConfidence: { + score: confidence, + titleSimilarity: confidence, + keywordOverlap: 0, + categoryAligned: true, + expiryAligned: true, + }, + sourceTimestamps: { polymarket: poly.lastUpdated ?? null, kalshi: kalshi.lastUpdated ?? null }, + expiryDeltaMinutes: null, + asOfTs: now, + liquidityScore: 1, }); } } @@ -103,7 +128,8 @@ function coveredRealizedPnl(op: ArbitrageOpportunity): number { } function fixedPnl(op: ArbitrageOpportunity): number { - const cost = op.costPerBundle ?? 1; + const cost = op.costPerBundle ?? op.buyPrice; + if (!cost || cost <= 0) return 0; return op.profitPotential * (POSITION_USD / cost); } @@ -122,7 +148,7 @@ async function liveSnapshot(): Promise { fetchPolymarkets(200, 3), fetchKalshiMarkets(400, 3), ]); - const arbs = detectArbitrage([...poly, ...kalshi], 0.03); + const arbs = detectArbitrage([...poly, ...kalshi], Math.round(0.03 * 10000)); console.log('\nLIVE DATA SNAPSHOT'); console.log(` polymarket markets: ${poly.length}`); console.log(` kalshi markets: ${kalshi.length}`); @@ -182,7 +208,7 @@ async function main(): Promise { ]; const before = legacyDetect(fixtures); - const after = detectArbitrage(fixtures, 0.03, COST_BUFFER); + const after = detectArbitrage(fixtures, Math.round(0.03 * 10000)); console.log('MUSASHI CASE STUDY SIMULATION'); console.log(`position size: $${POSITION_USD}`); diff --git a/scripts/test-agent-api.ts b/scripts/test-agent-api.ts index ca621ce..723ba39 100644 --- a/scripts/test-agent-api.ts +++ b/scripts/test-agent-api.ts @@ -91,6 +91,9 @@ describe('analyze-text', () => { describe('markets', () => { test('arbitrage happy path', testOptions(), runAgentApiCase(testArbitrageHappyPath)); + test('arbitrage fast mode payload shape', testOptions(), runAgentApiCase(testArbitrageFastModePayload)); + test('arbitrage supports minNetEdgeBps', testOptions(), runAgentApiCase(testArbitrageMinNetEdgeBps)); + test('arbitrage maxDataAgeMs degrades stale data', testOptions(), runAgentApiCase(testArbitrageMaxDataAgeDegrade)); test('arbitrage rejects invalid minSpread', testOptions(), runAgentApiCase(testArbitrageInvalidMinSpread)); test('arbitrage rejects invalid minConfidence', testOptions(), runAgentApiCase(testArbitrageInvalidMinConfidence)); test('arbitrage rejects invalid limit', testOptions(), runAgentApiCase(testArbitrageInvalidLimit)); @@ -619,7 +622,7 @@ async function testAnalyzeTextFormUrlEncoded(): Promise { } async function testArbitrageHappyPath(): Promise { - const response = await request('/api/markets/arbitrage?minSpread=0.03&minConfidence=0.5&limit=5'); + const response = await request('/api/markets/arbitrage?mode=full&minSpread=0.03&minConfidence=0.5&limit=5'); if (response.status === 503) { return warn(extractError(response)); @@ -633,26 +636,68 @@ async function testArbitrageHappyPath(): Promise { return pass(`returned ${response.json.data.count} opportunities`); } +async function testArbitrageFastModePayload(): Promise { + const response = await request('/api/markets/arbitrage?mode=fast&minNetEdgeBps=50&limit=3'); + + if (response.status === 503) return warn(extractError(response)); + expect(response.status === 200, `expected 200, got ${response.status}`); + expect(response.json.success === true, 'arbitrage fast mode success must be true'); + validateArbitrageResponse(response); + expect(response.json.metadata?.mode === 'fast', 'fast mode metadata should echo fast'); + + for (const item of response.json.data.opportunities as any[]) { + expect(typeof item.netEdgeBps === 'number', 'fast mode opportunity must include netEdgeBps'); + expect(typeof item.buyVenue === 'string', 'fast mode opportunity must include buyVenue'); + expect(typeof item.sellVenue === 'string', 'fast mode opportunity must include sellVenue'); + } + + return pass(`returned ${response.json.data.count} fast opportunities`); +} + +async function testArbitrageMinNetEdgeBps(): Promise { + const response = await request('/api/markets/arbitrage?mode=full&minNetEdgeBps=120&limit=5'); + if (response.status === 503) return warn(extractError(response)); + + expect(response.status === 200, `expected 200, got ${response.status}`); + validateArbitrageResponse(response); + for (const item of response.json.data.opportunities as any[]) { + if (typeof item.netEdgeBps === 'number') { + expect(item.netEdgeBps >= 120, 'minNetEdgeBps filter returned lower netEdgeBps item'); + } + } + return pass(`returned ${response.json.data.count} opportunities with minNetEdgeBps=120`); +} + +async function testArbitrageMaxDataAgeDegrade(): Promise { + const response = await request('/api/markets/arbitrage?mode=full&maxDataAgeMs=0&limit=5'); + if (response.status === 503) return warn(extractError(response)); + expect(response.status === 200, `expected 200, got ${response.status}`); + expect(response.json.success === true, 'maxDataAgeMs response should still be success'); + expect(response.json.metadata?.degraded === true, 'maxDataAgeMs stale path should set degraded=true'); + expect(response.json.data?.count === 0, 'maxDataAgeMs stale path should return zero opportunities'); + return pass('maxDataAgeMs stale-degrade path returned expected response'); +} + async function testArbitrageInvalidMinSpread(): Promise { - const response = await request('/api/markets/arbitrage?minSpread=-1'); + const response = await request('/api/markets/arbitrage?mode=full&minSpread=-1'); expect(response.status === 400, `expected 400, got ${response.status}`); return pass(extractError(response)); } async function testArbitrageInvalidMinConfidence(): Promise { - const response = await request('/api/markets/arbitrage?minConfidence=1.5'); + const response = await request('/api/markets/arbitrage?mode=full&minConfidence=1.5'); expect(response.status === 400, `expected 400, got ${response.status}`); return pass(extractError(response)); } async function testArbitrageInvalidLimit(): Promise { - const response = await request('/api/markets/arbitrage?limit=0'); + const response = await request('/api/markets/arbitrage?mode=full&limit=0'); expect(response.status === 400, `expected 400, got ${response.status}`); return pass(extractError(response)); } async function testArbitrageDuplicateQueryParams(): Promise { - const response = await request('/api/markets/arbitrage?limit=2&limit=3&minSpread=0.01'); + const response = await request('/api/markets/arbitrage?mode=full&limit=2&limit=3&minSpread=0.01'); assertNoSensitiveLeak(response, 'arbitrage duplicate-query response'); if (response.status === 503) return warn(extractError(response)); @@ -662,7 +707,7 @@ async function testArbitrageDuplicateQueryParams(): Promise { } async function testArbitrageCategoryFilter(): Promise { - const response = await request('/api/markets/arbitrage?category=crypto&limit=3&minSpread=0.01'); + const response = await request('/api/markets/arbitrage?mode=full&category=crypto&limit=3&minSpread=0.01'); if (response.status === 503) return warn(extractError(response)); expect(response.status === 200, `expected 200, got ${response.status}`); @@ -1801,12 +1846,23 @@ function validateArbitrageResponse(response: HttpResult): void { expectIsoTimestamp(response.json.data.timestamp, 'arbitrage timestamp must be valid ISO'); for (const item of response.json.data.opportunities as any[]) { - validateMarket(item.polymarket); - validateMarket(item.kalshi); - expect(typeof item.spread === 'number', 'arbitrage spread must be number'); - expect(typeof item.profitPotential === 'number', 'arbitrage profitPotential must be number'); - expect(['buy_poly_sell_kalshi', 'buy_kalshi_sell_poly'].includes(item.direction), 'arbitrage direction invalid'); - expect(typeof item.confidence === 'number', 'arbitrage confidence must be number'); + // full mode + if (item.polymarket && item.kalshi) { + validateMarket(item.polymarket); + validateMarket(item.kalshi); + expect(typeof item.spread === 'number', 'arbitrage spread must be number'); + expect(typeof item.profitPotential === 'number', 'arbitrage profitPotential must be number'); + expect(['buy_poly_sell_kalshi', 'buy_kalshi_sell_poly'].includes(item.direction), 'arbitrage direction invalid'); + expect(typeof item.confidence === 'number', 'arbitrage confidence must be number'); + continue; + } + + // fast mode + expect(typeof item.buyVenue === 'string', 'fast arbitrage buyVenue must be string'); + expect(typeof item.sellVenue === 'string', 'fast arbitrage sellVenue must be string'); + expect(typeof item.buyPrice === 'number', 'fast arbitrage buyPrice must be number'); + expect(typeof item.sellPrice === 'number', 'fast arbitrage sellPrice must be number'); + expect(typeof item.netEdgeBps === 'number', 'fast arbitrage netEdgeBps must be number'); } } diff --git a/src/api/arbitrage-detector.ts b/src/api/arbitrage-detector.ts index 379f118..732afc7 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -1,365 +1,329 @@ -// Cross-platform arbitrage detector -// Matches equivalent Polymarket/Kalshi contracts and prices covered YES/NO bundles. +// Cross-platform arbitrage detector +// Matches markets across Polymarket and Kalshi to find price discrepancies import { Market, ArbitrageOpportunity } from '../types/market'; -const STOP_WORDS = new Set([ - 'will', 'the', 'a', 'an', 'in', 'on', 'at', 'by', 'for', 'to', 'of', - 'and', 'or', 'is', 'be', 'has', 'have', 'are', 'was', 'were', 'been', - 'do', 'does', 'did', 'before', 'after', 'end', 'yes', 'no', 'than', - 'major', 'us', 'use', 'its', 'their', 'any', 'all', 'into', 'out', - 'as', 'from', 'with', 'this', 'that', 'not', 'new', 'more', 'most', - 'least', 'how', 'what', 'when', 'where', 'who', 'get', 'got', 'put', - 'set', 'per', 'via', 'if', 'whether', 'each', 'such', 'also', -]); - -const OUTCOME_PHRASES = [ - 'win', 'wins', 'won', 'nominee', 'nomination', 'elected', 'election', - 'above', 'below', 'over', 'under', 'reach', 'reaches', 'hit', 'hits', - 'pass', 'passes', 'rate hike', 'rate cut', 'cut rates', 'raise rates', - 'shutdown', 'resign', 'indicted', 'approved', 'land', 'launch', -]; - -interface ContractSignature { - terms: Set; - years: Set; - dates: Set; - numbers: Set; - outcomes: Set; - scopes: Set; -} +declare const process: { + env: Record; +}; -interface MatchResult { - isSimilar: boolean; - confidence: number; - reason: string; -} +const FEE_POLY_BPS = Number(process.env.ARB_POLY_FEE_BPS || process.env.ARB_FEE_BPS || 20); +const FEE_KALSHI_BPS = Number(process.env.ARB_KALSHI_FEE_BPS || process.env.ARB_FEE_BPS || 20); +const SLIPPAGE_BPS = Number(process.env.ARB_SLIPPAGE_BPS || 10); +const LATENCY_BPS = Number(process.env.ARB_LATENCY_BPS || 5); +const MIN_VOLUME_FLOOR = Number(process.env.ARB_MIN_VOL || 500); +const ARB_V15_ENABLED = process.env.ARB_V15_ENABLED !== '0'; +const ARB_NET_EDGE_ENABLED = process.env.ARB_NET_EDGE_ENABLED !== '0'; +const ARB_STRICT_MATCH_ENABLED = process.env.ARB_STRICT_MATCH_ENABLED !== '0'; -interface BundleCandidate { - direction: ArbitrageOpportunity['direction']; - yesPlatform: 'polymarket' | 'kalshi'; - noPlatform: 'polymarket' | 'kalshi'; - yesPrice: number; - noPrice: number; - costPerBundle: number; - edge: number; +/** + * Helper to group markets by category for faster scanning (O(N) vs O(N*M)) + */ +function groupByCategory(markets: Market[]): Record { + return markets.reduce((acc, market) => { + const cat = market.category || 'other'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(market); + return acc; + }, {} as Record); } -const DEFAULT_FEES_AND_SLIPPAGE = Number.parseFloat( - process.env.MUSASHI_ARB_COST_BUFFER ?? '0.02', -); - +/** + * Normalize a title for fuzzy matching + * Removes punctuation, dates, common question words, normalizes spacing + */ function normalizeTitle(title: string): string { return title .toLowerCase() - .replace(/[$,]/g, '') - .replace(/[^a-z0-9.%\s-]/g, ' ') - .replace(/\s+/g, ' ') + .replace(/\?/g, '') // Remove question marks + .replace(/\b(will|before|after|by|in|on|at|the|a|an)\b/g, '') // Remove filler words + .replace(/\b(2024|2025|2026|2027|2028)\b/g, '') // Remove years + .replace(/[^a-z0-9\s]/g, ' ') // Remove all punctuation + .replace(/\s+/g, ' ') // Normalize whitespace .trim(); } -function tokens(title: string): string[] { - return normalizeTitle(title) - .split(' ') - .filter(word => word.length > 1 && !STOP_WORDS.has(word)); -} - -function extractTerms(title: string): Set { - return new Set(tokens(title).filter(word => word.length >= 3)); -} - -function extractYears(title: string, endDate?: string): Set { - const years = new Set(); - for (const match of normalizeTitle(title).matchAll(/\b20\d{2}\b/g)) { - years.add(match[0]); - } - if (endDate) { - const year = new Date(endDate).getUTCFullYear(); - if (Number.isFinite(year)) years.add(String(year)); - } - return years; -} - -function extractDates(title: string): Set { +/** + * Extract key entities from a market title + * Looks for: names, tickers, numbers, organizations + */ +function extractEntities(title: string): Set { const normalized = normalizeTitle(title); - const dates = new Set(); - const monthPattern = /\b(jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)\s+\d{1,2}\b/g; - for (const match of normalized.matchAll(monthPattern)) dates.add(match[0]); - for (const match of normalized.matchAll(/\b\d{1,2}\/\d{1,2}(?:\/\d{2,4})?\b/g)) dates.add(match[0]); - return dates; -} + const words = normalized.split(' '); + const entities = new Set(); -function extractNumbers(title: string): Set { - const numbers = new Set(); - for (const match of normalizeTitle(title).matchAll(/\b\d+(?:\.\d+)?\s?(?:k|m|b|%|percent|bps)?\b/g)) { - numbers.add(match[0].replace(/\s+/g, '')); - } - return numbers; -} + // Extract significant words (3+ chars, not in stop list) + const stopWords = new Set(['will', 'hit', 'reach', 'win', 'lose', 'pass', 'than', 'over', 'under']); -function extractOutcomes(title: string): Set { - const normalized = normalizeTitle(title); - const outcomes = new Set(); - for (const phrase of OUTCOME_PHRASES) { - if (normalized.includes(phrase)) outcomes.add(phrase); + for (const word of words) { + if (word.length >= 3 && !stopWords.has(word)) { + entities.add(word); + } } - return outcomes; -} - -function extractScopes(title: string): Set { - const normalized = normalizeTitle(title); - const scopes = new Set(); - if (/\bmatch\b|\bvs\b|\bversus\b/.test(normalized)) scopes.add('single_match'); - if (/\bseason\b|\bplayoffs?\b|\bchampionship\b|\btournament\b|\bseries\b|\bwinner\b/.test(normalized)) scopes.add('season_or_tournament'); - if (/\belection\b|\bnominee\b|\bnomination\b/.test(normalized)) scopes.add('election'); - if (/\bby\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)|before|after/.test(normalized)) scopes.add('deadline'); - - return scopes; + return entities; } -function signature(market: Market): ContractSignature { - return { - terms: extractTerms(market.title), - years: extractYears(market.title, market.endDate), - dates: extractDates(market.title), - numbers: extractNumbers(market.title), - outcomes: extractOutcomes(market.title), - scopes: extractScopes(market.title), - }; -} +/** + * Calculate similarity score between two titles + * Returns 0-1 based on shared entities + */ +function calculateTitleSimilarity(title1: string, title2: string): number { + const entities1 = extractEntities(title1); + const entities2 = extractEntities(title2); -function intersectionSize(a: Set, b: Set): number { - let shared = 0; - for (const value of a) { - if (b.has(value)) shared++; - } - return shared; -} + if (entities1.size === 0 || entities2.size === 0) return 0; -function hasConflict(a: Set, b: Set): boolean { - return a.size > 0 && b.size > 0 && intersectionSize(a, b) === 0; -} + // Count shared entities + let sharedCount = 0; + for (const entity of entities1) { + if (entities2.has(entity)) { + sharedCount++; + } + } -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; + // Jaccard similarity: intersection / union + const union = entities1.size + entities2.size - sharedCount; + return union > 0 ? sharedCount / union : 0; } +/** + * Calculate keyword overlap between two markets + * Returns the number of shared keywords + */ function calculateKeywordOverlap(market1: Market, market2: Market): number { - return intersectionSize(new Set(market1.keywords), new Set(market2.keywords)); -} + const keywords1 = new Set(market1.keywords); + const keywords2 = new Set(market2.keywords); -function areMarketsSimilar(poly: Market, kalshi: Market): MatchResult { - const strictCategoryMatch = - poly.category === kalshi.category && - poly.category !== 'other' && - kalshi.category !== 'other'; - const categoryUnknown = poly.category === 'other' || kalshi.category === 'other'; - - if (!strictCategoryMatch && !categoryUnknown) { - return { isSimilar: false, confidence: 0, reason: 'Different categories' }; - } - - const polySig = signature(poly); - const kalshiSig = signature(kalshi); - - if (hasConflict(polySig.years, kalshiSig.years)) { - return { isSimilar: false, confidence: 0, reason: 'Different contract years' }; + let overlap = 0; + for (const kw of keywords1) { + if (keywords2.has(kw)) { + overlap++; + } } - if (hasConflict(polySig.dates, kalshiSig.dates)) { - return { isSimilar: false, confidence: 0, reason: 'Different contract dates' }; - } + return overlap; +} - if (hasConflict(polySig.numbers, kalshiSig.numbers)) { - return { isSimilar: false, confidence: 0, reason: 'Different numeric thresholds' }; - } +/** + * Check if two markets refer to the same event + * Uses title similarity + keyword overlap + category matching + */ +function areMarketsSimilar(poly: Market, kalshi: Market): { + isSimilar: boolean; + confidence: number; + titleSim: number; + keywordOverlap: number; + reason: string; +} { + // Must be in the same category (or one is 'other') + const categoryMatch = poly.category === kalshi.category || + poly.category === 'other' || + kalshi.category === 'other'; - if (hasConflict(polySig.outcomes, kalshiSig.outcomes)) { - return { isSimilar: false, confidence: 0, reason: 'Different outcome wording' }; + if (!categoryMatch) { + return { + isSimilar: false, + confidence: 0, + titleSim: 0, + keywordOverlap: 0, + reason: 'Different categories', + }; } - if (hasConflict(polySig.scopes, kalshiSig.scopes)) { - return { isSimilar: false, confidence: 0, reason: 'Different contract scope' }; - } + // Calculate title similarity + const titleSim = calculateTitleSimilarity(poly.title, kalshi.title); - const titleSim = jaccard(polySig.terms, kalshiSig.terms); + // Calculate keyword overlap const keywordOverlap = calculateKeywordOverlap(poly, kalshi); - const sharedTerms = intersectionSize(polySig.terms, kalshiSig.terms); - let confidence = Math.max(titleSim, Math.min(keywordOverlap / 8, 0.85)); - 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); + // Matching criteria (needs at least one strong signal): + // 1. High title similarity OR + // 2. Strong keyword overlap (3+ shared keywords) + const titleThreshold = ARB_STRICT_MATCH_ENABLED ? 0.6 : 0.5; + const entityThreshold = ARB_STRICT_MATCH_ENABLED ? 0.35 : 0.3; - if (strictCategoryMatch && blockersMatched && titleSim >= 0.45) { - confidence = Math.max(confidence, 0.75); + if (titleSim > titleThreshold) { return { isSimilar: true, - confidence, - reason: `Strict category + contract fields + title similarity (${(titleSim * 100).toFixed(0)}%)`, + confidence: titleSim, + titleSim, + keywordOverlap, + reason: `High title similarity (${(titleSim * 100).toFixed(0)}%)`, }; } - if (strictCategoryMatch && blockersMatched && keywordOverlap >= 4 && sharedTerms >= 2) { - confidence = Math.max(confidence, 0.65); + if (keywordOverlap >= 3) { + const confidence = Math.min(keywordOverlap / 10, 0.85); // Cap at 0.85 return { isSimilar: true, confidence, - reason: `${keywordOverlap} shared keywords with matching contract fields`, + titleSim, + keywordOverlap, + reason: `${keywordOverlap} shared keywords`, }; } - if (categoryUnknown && blockersMatched && titleSim >= 0.85 && sharedTerms >= 4) { - confidence = Math.max(confidence, 0.7); + // Check for exact entity matches (strong signal even with low overall similarity) + const polyEntities = extractEntities(poly.title); + const kalshiEntities = extractEntities(kalshi.title); + const sharedEntities = Array.from(polyEntities).filter(e => kalshiEntities.has(e)); + + if (sharedEntities.length >= 2 && titleSim > entityThreshold) { return { isSimilar: true, - confidence, - reason: `Unknown category but strong title similarity (${(titleSim * 100).toFixed(0)}%)`, + confidence: 0.7, + titleSim, + keywordOverlap, + reason: `Shared entities: ${sharedEntities.slice(0, 3).join(', ')}`, }; } - return { isSimilar: false, confidence: 0, reason: 'Insufficient contract equivalence' }; + return { + isSimilar: false, + confidence: 0, + titleSim, + keywordOverlap, + reason: 'Insufficient similarity', + }; } -function buyYesPrice(market: Market): number { +function safePriceForBuy(market: Market): number { return market.yesAsk ?? market.yesPrice; } -function buyNoPrice(market: Market): number { - return market.noAsk ?? market.noPrice; -} - -function priceBundle(poly: Market, kalshi: Market, feesAndSlippage: number): BundleCandidate[] { - const polyYesKalshiNo = buyYesPrice(poly) + buyNoPrice(kalshi) + feesAndSlippage; - const kalshiYesPolyNo = buyYesPrice(kalshi) + buyNoPrice(poly) + feesAndSlippage; - - return [ - { - direction: 'buy_poly_sell_kalshi', - yesPlatform: 'polymarket', - noPlatform: 'kalshi', - yesPrice: buyYesPrice(poly), - noPrice: buyNoPrice(kalshi), - costPerBundle: polyYesKalshiNo, - edge: 1 - polyYesKalshiNo, - }, - { - direction: 'buy_kalshi_sell_poly', - yesPlatform: 'kalshi', - noPlatform: 'polymarket', - yesPrice: buyYesPrice(kalshi), - noPrice: buyNoPrice(poly), - costPerBundle: kalshiYesPolyNo, - edge: 1 - kalshiYesPolyNo, - }, - ]; -} - -function candidatesFor(poly: Market, kalshiByCategory: Map): Market[] { - if (poly.category === 'other') { - return kalshiByCategory.get('other') ?? []; - } - - return [ - ...(kalshiByCategory.get(poly.category) ?? []), - ...(kalshiByCategory.get('other') ?? []), - ]; +function safePriceForSell(market: Market): number { + return market.yesBid ?? market.yesPrice; } /** - * Detect covered arbitrage opportunities across Polymarket and Kalshi. - * - * Real cross-venue arbitrage buys complementary outcomes: - * YES on venue A + NO on venue B + fees/slippage < $1 payout. + * Detect arbitrage opportunities * - * The legacy absolute YES-vs-YES spread is exposed as rawPriceGap only; the - * spread field now represents net edge after modeled costs. + * @param markets - Combined array of markets from both platforms + * @param minNetEdgeBps - Minimum basis points profit (default, 50bps/0.5%) */ export function detectArbitrage( markets: Market[], - minSpread: number = 0.03, - feesAndSlippage: number = DEFAULT_FEES_AND_SLIPPAGE, + minNetEdgeBps: number = 10 ): ArbitrageOpportunity[] { const opportunities: ArbitrageOpportunity[] = []; - const polymarkets = markets.filter(m => m.platform === 'polymarket'); - const kalshiMarkets = markets.filter(m => m.platform === 'kalshi'); - const kalshiByCategory = new Map(); - - for (const market of kalshiMarkets) { - const bucket = kalshiByCategory.get(market.category) ?? []; - bucket.push(market); - kalshiByCategory.set(market.category, bucket); - } - - console.log(`[Arbitrage] Checking ${polymarkets.length} Polymarket markets against category-filtered Kalshi buckets`); - - for (const poly of polymarkets) { - for (const kalshi of candidatesFor(poly, kalshiByCategory)) { - const similarity = areMarketsSimilar(poly, kalshi); - if (!similarity.isSimilar) continue; - - const bestBundle = priceBundle(poly, kalshi, feesAndSlippage) - .sort((a, b) => b.edge - a.edge)[0]; - - if (bestBundle.edge < minSpread) continue; - - opportunities.push({ - polymarket: poly, - kalshi, - spread: +bestBundle.edge.toFixed(4), - rawPriceGap: +Math.abs(poly.yesPrice - kalshi.yesPrice).toFixed(4), - costPerBundle: +bestBundle.costPerBundle.toFixed(4), - feesAndSlippage, - profitPotential: +bestBundle.edge.toFixed(4), - direction: bestBundle.direction, - legs: { - yes: { platform: bestBundle.yesPlatform, price: bestBundle.yesPrice }, - no: { platform: bestBundle.noPlatform, price: bestBundle.noPrice }, - }, - confidence: similarity.confidence, - matchReason: similarity.reason, - }); + const polyByCat = groupByCategory(markets.filter(m => m.platform === 'polymarket')); + const kalshiByCat = groupByCategory(markets.filter(m => m.platform === 'kalshi')); + + for (const cat in polyByCat) { + if (!kalshiByCat[cat]) continue; + for (const poly of polyByCat[cat]) { + for (const kalshi of kalshiByCat[cat]) { + + // Expiry Delta + let expiryDeltaMinutes: number | null = null; + if (poly.endDate && kalshi.endDate) { + expiryDeltaMinutes = Math.abs(new Date(poly.endDate).getTime() - new Date(kalshi.endDate).getTime()) / 60000; + if (expiryDeltaMinutes > 1440) continue; + } + + const similarity = areMarketsSimilar(poly, kalshi); + if (!similarity.isSimilar) continue; + + // Executable Edge (Buy at Ask, Sell at Bid) + const polyBuy = ARB_V15_ENABLED ? (poly.yesAsk ?? poly.yesPrice) : poly.yesPrice; + const polySell = ARB_V15_ENABLED ? (poly.yesBid ?? poly.yesPrice) : poly.yesPrice; + const kalshiBuy = ARB_V15_ENABLED ? (kalshi.yesAsk ?? kalshi.yesPrice) : kalshi.yesPrice; + const kalshiSell = ARB_V15_ENABLED ? (kalshi.yesBid ?? kalshi.yesPrice) : kalshi.yesPrice; + + const edgePolyBuy = kalshiSell - polyBuy; + const edgeKalshiBuy = polySell - kalshiBuy; + + const isPolyCheaper = edgePolyBuy >= edgeKalshiBuy; + const buyPrice = isPolyCheaper ? safePriceForBuy(poly) : safePriceForBuy(kalshi); + const sellPrice = isPolyCheaper ? safePriceForSell(kalshi) : safePriceForSell(poly); + if (buyPrice <= 0 || sellPrice <= 0 || sellPrice <= buyPrice) continue; + + // Summed Venue Fees + const totalFees = ARB_NET_EDGE_ENABLED + ? (FEE_POLY_BPS + FEE_KALSHI_BPS + SLIPPAGE_BPS + LATENCY_BPS) + : 0; + const grossBps = ((sellPrice - buyPrice) / buyPrice) * 10000; + const netEdge = grossBps - totalFees; + + if (netEdge < minNetEdgeBps) continue; + + // Real Liquidity Score + const combinedVol = (poly.volume24h || 0) + (kalshi.volume24h || 0); + if (combinedVol < MIN_VOLUME_FLOOR) continue; + + opportunities.push({ + polymarket: poly, + kalshi, + buyPrice, + sellPrice, + buyVenue: isPolyCheaper ? 'polymarket' : 'kalshi', + sellVenue: isPolyCheaper ? 'kalshi' : 'polymarket', + netEdgeBps: Math.round(netEdge), + grossEdgeBps: Math.round(grossBps), + estimatedFeesBps: FEE_POLY_BPS + FEE_KALSHI_BPS, + slippageBps: SLIPPAGE_BPS, + latencyRiskBps: LATENCY_BPS, + confidence: similarity.confidence, + matchReason: similarity.reason, + spread: sellPrice - buyPrice, + profitPotential: sellPrice - buyPrice, + direction: isPolyCheaper ? 'buy_poly_sell_kalshi' : 'buy_kalshi_sell_poly', + matchConfidence: { + score: similarity.confidence, + titleSimilarity: similarity.titleSim, + keywordOverlap: similarity.keywordOverlap, + categoryAligned: true, + expiryAligned: (expiryDeltaMinutes || 0) < 60, + liquidityAligned: combinedVol >= MIN_VOLUME_FLOOR, + }, + sourceTimestamps: { + polymarket: poly.lastUpdated || null, + kalshi: kalshi.lastUpdated || null, + }, + expiryDeltaMinutes, + asOfTs: new Date().toISOString(), + liquidityScore: Math.min(combinedVol / 10000, 1), + }); + } } } - opportunities.sort((a, b) => b.profitPotential - a.profitPotential); - console.log(`[Arbitrage] Found ${opportunities.length} covered opportunities (min edge: ${minSpread})`); - - return opportunities; + // Deterministic Sort + Tie-break + return opportunities.sort((a, b) => { + if (b.netEdgeBps !== a.netEdgeBps) return b.netEdgeBps - a.netEdgeBps; + if (b.matchConfidence.score !== a.matchConfidence.score) return b.matchConfidence.score - a.matchConfidence.score; + return `${a.polymarket.id}${a.kalshi.id}`.localeCompare(`${b.polymarket.id}${b.kalshi.id}`); + }); } /** - * Get top arbitrage opportunities. + * Get top arbitrage opportunities + * Filters by minimum spread and confidence, returns top N */ export function getTopArbitrage( markets: Market[], options: { - minSpread?: number; - minConfidence?: number; + minNetEdgeBps?: number; limit?: number; category?: string; } = {} ): ArbitrageOpportunity[] { const { - minSpread = 0.03, - minConfidence = 0.5, + minNetEdgeBps = 50, limit = 20, category, } = options; - let opportunities = detectArbitrage(markets, minSpread); - - opportunities = opportunities.filter(op => op.confidence >= minConfidence); + let opportunities = detectArbitrage(markets, minNetEdgeBps); + // Filter by category if specified if (category) { opportunities = opportunities.filter( op => op.polymarket.category === category || op.kalshi.category === category ); } + // Return top N return opportunities.slice(0, limit); } diff --git a/src/sdk/musashi-agent.ts b/src/sdk/musashi-agent.ts index 9572814..d226833 100644 --- a/src/sdk/musashi-agent.ts +++ b/src/sdk/musashi-agent.ts @@ -75,6 +75,29 @@ export interface Sentiment { export interface ArbitrageOpportunity { polymarket: Market; kalshi: Market; + buyPrice?: number; + sellPrice?: number; + buyVenue?: Platform; + sellVenue?: Platform; + netEdgeBps?: number; + grossEdgeBps?: number; + estimatedFeesBps?: number; + slippageBps?: number; + latencyRiskBps?: number; + matchConfidence?: { + score: number; + titleSimilarity: number; + keywordOverlap: number; + categoryAligned: boolean; + expiryAligned: boolean; + }; + sourceTimestamps?: { + polymarket: string | null; + kalshi: string | null; + }; + expiryDeltaMinutes?: number | null; + liquidityScore?: number; + asOfTs?: string; spread: number; profitPotential: number; direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; @@ -112,6 +135,9 @@ export interface AnalyzeTextOptions { export interface GetArbitrageOptions { minSpread?: number; + minNetEdgeBps?: number; + mode?: 'fast' | 'full'; + maxDataAgeMs?: number; minConfidence?: number; limit?: number; category?: string; @@ -286,9 +312,12 @@ export class MusashiAgent { */ async getArbitrage(options?: GetArbitrageOptions): Promise { const params = new URLSearchParams(); - if (options?.minSpread) params.set('minSpread', options.minSpread.toString()); - if (options?.minConfidence) params.set('minConfidence', options.minConfidence.toString()); - if (options?.limit) params.set('limit', options.limit.toString()); + if (options?.mode) params.set('mode', options.mode); + if (options?.minSpread !== undefined) params.set('minSpread', options.minSpread.toString()); + if (options?.minNetEdgeBps !== undefined) params.set('minNetEdgeBps', options.minNetEdgeBps.toString()); + if (options?.maxDataAgeMs !== undefined) params.set('maxDataAgeMs', options.maxDataAgeMs.toString()); + if (options?.minConfidence !== undefined) params.set('minConfidence', options.minConfidence.toString()); + if (options?.limit !== undefined) params.set('limit', options.limit.toString()); if (options?.category) params.set('category', options.category); const response = await this.request(`/api/markets/arbitrage?${params.toString()}`); diff --git a/src/types/market.ts b/src/types/market.ts index 93edfba..b2ad7bf 100644 --- a/src/types/market.ts +++ b/src/types/market.ts @@ -7,18 +7,19 @@ export interface Market { description: string; keywords: string[]; yesPrice: number; // 0.0 to 1.0 (0.65 = 65%) - noPrice: number; // 0.0 to 1.0 (0.35 = 35%) - yesBid?: number; // best executable YES bid when available - yesAsk?: number; // best executable YES ask when available - noBid?: number; // best executable NO bid when available - noAsk?: number; // best executable NO ask when available + noPrice: number; // 0.0 to 1.0 (0.35 = 35%) volume24h: number; // 24h trading volume in dollars url: string; category: string; lastUpdated: string; // ISO timestamp - 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") + yesBid?: number; + yesAsk?: number; + noBid?: number; + noAsk?: number; + liquidity?: number; + 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") } export interface MarketMatch { @@ -30,16 +31,42 @@ export interface MarketMatch { export interface ArbitrageOpportunity { polymarket: Market; kalshi: Market; - spread: number; // Net covered-position edge after modeled costs - rawPriceGap?: number; // Difference between indicative YES prices - costPerBundle?: number; // Cost to buy YES on one venue and NO on the other - feesAndSlippage?: number; // Conservative cost buffer used in the calculation - profitPotential: number; // Expected profit per $1 payout bundle + buyPrice: number; + sellPrice: number; + buyVenue: 'polymarket' | 'kalshi'; + sellVenue: 'polymarket' | 'kalshi'; + netEdgeBps: number; + grossEdgeBps: number; + estimatedFeesBps: number; + slippageBps: number; + latencyRiskBps: number; + confidence: number; + matchReason: string; + spread: number; + profitPotential: number; direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; + matchConfidence: { + score: number; + titleSimilarity: number; + keywordOverlap: number; + categoryAligned: boolean; + expiryAligned: boolean; + liquidityAligned?: boolean; + }; + sourceTimestamps: { + polymarket: string | null; + kalshi: string | null; + }; + expiryDeltaMinutes: number | null; + asOfTs: string; + liquidityScore: number; + + /** Optional bundle-reporting fields (e.g. case-study script vs upstream-style payloads). */ + rawPriceGap?: number; + costPerBundle?: number; + feesAndSlippage?: number; legs?: { yes: { platform: 'polymarket' | 'kalshi'; price: number }; no: { platform: 'polymarket' | 'kalshi'; price: number }; }; - confidence: number; // 0-1, how confident we are this is the same event - matchReason: string; // Why we think these are the same market }