From 44ff42ee7fdf90f4f5c4ece7073895b7c2cba1e2 Mon Sep 17 00:00:00 2001 From: Adriel Date: Sat, 18 Apr 2026 19:18:52 -0700 Subject: [PATCH 1/9] Added basic point metrics & execution fields --- src/types/market.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/types/market.ts b/src/types/market.ts index 3b39c5b..72ef12f 100644 --- a/src/types/market.ts +++ b/src/types/market.ts @@ -26,9 +26,21 @@ export interface MarketMatch { export interface ArbitrageOpportunity { polymarket: Market; kalshi: Market; + buyPrice: number; + sellPrice: number; + buyVenue: 'polymarket' | 'kalshi'; + sellVenue: 'polymarket' | 'kalshi'; + netEdgeBps: number; + grossEdgeBps: number; + estimatedFeesBps: number; + slippageBps: number; + latencyRiskBps: number; + confidence: number; // 0-1, how confident we are this is the same event + matchReason: string; // Why we think these are the same market + liquidityScore: number; + expiryDeltaMinutes: number; + asOfTs: string; spread: number; // Absolute price difference (e.g., 0.05 = 5%) profitPotential: number; // Expected profit per $1 invested direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; - confidence: number; // 0-1, how confident we are this is the same event - matchReason: string; // Why we think these are the same market } From 4c95f0a04a424eec5146b4e5239673de512d0c1c Mon Sep 17 00:00:00 2001 From: Adriel Date: Sat, 18 Apr 2026 20:02:30 -0700 Subject: [PATCH 2/9] Added new helpers --- src/api/arbitrage-detector.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/api/arbitrage-detector.ts b/src/api/arbitrage-detector.ts index 0f2317f..7bc9b1a 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -3,6 +3,38 @@ import { Market, ArbitrageOpportunity } from '../types/market'; +const FEES_BPS = Number(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); + +/** + * V1.5 Net Edge Calculator + * Converts raw prices into tradable Basis Points (bps) + */ +function calculateNedEdge(buyPrice: number, sellPrice: number) { + const grossEdge = sellPrice - buyPrice; + const grossBps = (grossEdge / buyPrice) * 10000; + + const totalCosts = FEES_BPS + SLIPPAGE_BPS + LATENCY_BPS; + const netEdgeBps = grossBps - totalCosts; + + return { + grossBps: Math.round(grossBps), + netEdgeBps: Math.round(netEdgeBps) + }; +} + +/** + & 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); +} + /** * Normalize a title for fuzzy matching * Removes punctuation, dates, common question words, normalizes spacing From c22d01c2203a0e900c95d3e0677651671d680223 Mon Sep 17 00:00:00 2001 From: Adriel Date: Sat, 18 Apr 2026 20:41:25 -0700 Subject: [PATCH 3/9] Fixed Comment Issue --- src/api/arbitrage-detector.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/arbitrage-detector.ts b/src/api/arbitrage-detector.ts index 7bc9b1a..bb82ac2 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -25,7 +25,8 @@ function calculateNedEdge(buyPrice: number, sellPrice: number) { } /** - & Helper to group markets by category for faster scanning (O(N) vs O(N*M)) + * & 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'; From 2c2ef70bf999bb2b7cce185d36c8e5927915bd6a Mon Sep 17 00:00:00 2001 From: Adriel Date: Sun, 19 Apr 2026 00:30:33 -0700 Subject: [PATCH 4/9] Changed Arbitrage-detector to include execution logic --- src/api/arbitrage-detector.ts | 122 ++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 58 deletions(-) diff --git a/src/api/arbitrage-detector.ts b/src/api/arbitrage-detector.ts index bb82ac2..dac19bd 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -183,60 +183,71 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { */ export function detectArbitrage( markets: Market[], - minSpread: number = 0.03 + minNetEdgeBps: number = 50 ): ArbitrageOpportunity[] { - const opportunities: ArbitrageOpportunity[] = []; - - // Separate markets by platform - const polymarkets = markets.filter(m => m.platform === 'polymarket'); - const kalshiMarkets = markets.filter(m => m.platform === 'kalshi'); - - console.log(`[Arbitrage] Checking ${polymarkets.length} Polymarket × ${kalshiMarkets.length} Kalshi markets`); - - // Compare each Polymarket market with each Kalshi market - for (const poly of polymarkets) { - for (const kalshi of kalshiMarkets) { - const similarity = areMarketsSimilar(poly, kalshi); - - if (!similarity.isSimilar) continue; - - // Calculate spread - const spread = Math.abs(poly.yesPrice - kalshi.yesPrice); - - if (spread < minSpread) continue; - - // Determine direction and profit potential - let direction: ArbitrageOpportunity['direction']; - let profitPotential: number; - - if (poly.yesPrice < kalshi.yesPrice) { - // Buy on Polymarket (cheaper), sell on Kalshi (more expensive) - direction = 'buy_poly_sell_kalshi'; - profitPotential = spread; // Simplified: actual profit after fees would be lower - } else { - // Buy on Kalshi (cheaper), sell on Polymarket (more expensive) - direction = 'buy_kalshi_sell_poly'; - profitPotential = spread; + const opportunities: ArbitrageOpportunity[] = []; + + // Separate markets by platform + const polyByCat = groupByCategory(markets.filter(m => m.platform === 'polymarket')); + const kalshiByCat = groupByCategory(markets.filter(m => m.platform === 'kalshi')); + + // Compare each Polymarket market with each Kalshi market + for (const cat in polyByCat) { + if (!kalshiByCat[cat]) continue; + + for (const poly of polyByCat[cat]) { + for (const kalshi of kalshiByCat[cat]) { + // Date Check + if (poly.endDate && kalshi.endDate) { + const delta = Math.abs(new Data(poly.endDate).getTime() - new Date(kalshi.endDate).getTime()); + if (delta > 86400000) continue; + } + + const similarity = areMarketsSimilar(poly, kalshi); + + if (!similarity.isSimilar) continue; + + // Calculate spread + const spread = Math.abs(poly.yesPrice - kalshi.yesPrice); + + if (spread < minSpread) continue; + + // Math + const isPolyCheaper = poly.yesPrice < kalshi.yesPrice; + const buyPrice = isPolyCheaper ? poly.yesPrice : kalshi.yesPrice; + const sellPrice = isPolyCheaper ? kalshi.yesPrice : poly.yesPrice; + + const { grossBps, netEdgeBps } = calculateNetEdge(buyPrice, sellPrice); + + if (netEdgeBps < minNetEdgeBps) continue; + + // V1.5 Objects + opportunities.push({ + polymarket: poly, + kalshi: kalshi, + buyPrice, + sellPrice, + buyVenue: isPolyCheaper ? 'polymarket' : 'kalshi', + sellVenue: isPolyCheaper ? 'kalshi' : 'polymarket', + netEdgeBps, + grossEdgeBps: grossBps, + estimatedFeesBps: FEES_BPS, + slippageBps: SLIPPAGE_BPS, + latencyRiskBps: LATENCY_BPS, + confidence: similarity.confidence, + matchReason: similarity.reason, + liquidityScore: 0.5, + expiryDeltaMinutes: poly.endDate ? Math.floor((new Date(poly.endDate).getTime() - Date.now()) / 60000) :0, + asOfTs: new Date().toISOString(), + + spread: sellPrice - buyPrice, + profitPotential: (sellPrice - buyPrice), + direction: isPolyCheaper ? 'buy_poly_sell_kalshi' : 'buy_kalshi_sell_poly' + }); } - - opportunities.push({ - polymarket: poly, - kalshi: kalshi, - spread, - profitPotential, - direction, - confidence: similarity.confidence, - matchReason: similarity.reason, - }); } } - - // Sort by spread (highest first) - opportunities.sort((a, b) => b.spread - a.spread); - - console.log(`[Arbitrage] Found ${opportunities.length} opportunities (min spread: ${minSpread})`); - - return opportunities; + return opportunities.sort((a, b) => b.netEdgeBps - a.netEdgeBps); } /** @@ -246,23 +257,18 @@ export function detectArbitrage( 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); - - // Filter by confidence - opportunities = opportunities.filter(op => op.confidence >= minConfidence); + let opportunities = detectArbitrage(markets, minNetEdgeBps); // Filter by category if specified if (category) { From e325d0371011b33f8bea0ca085a4619d06b17b5b Mon Sep 17 00:00:00 2001 From: Adriel Date: Sun, 19 Apr 2026 02:23:52 -0700 Subject: [PATCH 5/9] Fixed Typo Errors --- src/api/arbitrage-detector.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/api/arbitrage-detector.ts b/src/api/arbitrage-detector.ts index dac19bd..7c86980 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -138,10 +138,10 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { const keywordOverlap = calculateKeywordOverlap(poly, kalshi); // Matching criteria (needs at least one strong signal): - // 1. High title similarity (>0.5) OR + // 1. High title similarity (>0.6) OR // 2. Strong keyword overlap (3+ shared keywords) - if (titleSim > 0.5) { + if (titleSim > 0.6) { return { isSimilar: true, confidence: titleSim, @@ -150,7 +150,7 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { } if (keywordOverlap >= 3) { - const confidence = Math.min(keywordOverlap / 10, 0.9); // Cap at 0.9 + const confidence = Math.min(keywordOverlap / 10, 0.85); // Cap at 0.85 return { isSimilar: true, confidence, @@ -163,7 +163,7 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { const kalshiEntities = extractEntities(kalshi.title); const sharedEntities = Array.from(polyEntities).filter(e => kalshiEntities.has(e)); - if (sharedEntities.length >= 2 && titleSim > 0.3) { + if (sharedEntities.length >= 2 && titleSim > 0.35) { return { isSimilar: true, confidence: 0.7, @@ -175,11 +175,10 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { } /** - * Detect arbitrage opportunities across Polymarket and Kalshi + * Detect arbitrage opportunities * * @param markets - Combined array of markets from both platforms - * @param minSpread - Minimum spread to be considered an opportunity (default: 0.03 = 3%) - * @returns Array of arbitrage opportunities sorted by spread (highest first) + * @param minNetEdgeBps - Minimum basis points profit (default, 50bps/0.5%) */ export function detectArbitrage( markets: Market[], @@ -199,7 +198,7 @@ export function detectArbitrage( for (const kalshi of kalshiByCat[cat]) { // Date Check if (poly.endDate && kalshi.endDate) { - const delta = Math.abs(new Data(poly.endDate).getTime() - new Date(kalshi.endDate).getTime()); + const delta = Math.abs(new Date(poly.endDate).getTime() - new Date(kalshi.endDate).getTime()); if (delta > 86400000) continue; } @@ -207,17 +206,13 @@ export function detectArbitrage( if (!similarity.isSimilar) continue; - // Calculate spread - const spread = Math.abs(poly.yesPrice - kalshi.yesPrice); - - if (spread < minSpread) continue; // Math const isPolyCheaper = poly.yesPrice < kalshi.yesPrice; const buyPrice = isPolyCheaper ? poly.yesPrice : kalshi.yesPrice; const sellPrice = isPolyCheaper ? kalshi.yesPrice : poly.yesPrice; - const { grossBps, netEdgeBps } = calculateNetEdge(buyPrice, sellPrice); + const { grossBps, netEdgeBps } = calculateNedEdge(buyPrice, sellPrice); if (netEdgeBps < minNetEdgeBps) continue; @@ -239,8 +234,9 @@ export function detectArbitrage( liquidityScore: 0.5, expiryDeltaMinutes: poly.endDate ? Math.floor((new Date(poly.endDate).getTime() - Date.now()) / 60000) :0, asOfTs: new Date().toISOString(), - + // Calculate spread spread: sellPrice - buyPrice, + profitPotential: (sellPrice - buyPrice), direction: isPolyCheaper ? 'buy_poly_sell_kalshi' : 'buy_kalshi_sell_poly' }); From 11341855e085ae5d82ed2631439a0c514f3f9f54 Mon Sep 17 00:00:00 2001 From: Adriel Date: Sun, 19 Apr 2026 02:56:28 -0700 Subject: [PATCH 6/9] Added Net Edge Logic to market cache --- api/lib/market-cache.ts | 217 ++++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 110 deletions(-) diff --git a/api/lib/market-cache.ts b/api/lib/market-cache.ts index 8b41d67..a433bb6 100644 --- a/api/lib/market-cache.ts +++ b/api/lib/market-cache.ts @@ -16,6 +16,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 +34,15 @@ 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; - -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); - // Stage 0 Session 2: Per-source timeout (5 seconds) const SOURCE_TIMEOUT_MS = 5000; -function parsePositiveInt(value: string | undefined, fallback: number): number { - const parsed = Number.parseInt(value ?? '', 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} +// Constants for fetch limits +const POLYMARKET_TARGET_COUNT = 100; +const POLYMARKET_MAX_PAGES = 3; +const KALSHI_TARGET_COUNT = 100; +const KALSHI_MAX_PAGES = 3; -/** - * 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 +69,95 @@ 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)`); 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 currentFetchTime = Date.now(); + + if (polyResult.status === 'fulfilled') { + polyTimestamp = currentFetchTime; + polyMarketCount = polyResult.value.length; + polyError = null; + } else { + polyError = polyResult.reason?.message || 'Failed to fetch Polymarket'; + } + + 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; + + return cachedMarkets; + } finally { + marketsRefreshPromise = null; } + })(); - const polyMarkets = polyResult.status === 'fulfilled' ? polyResult.value : []; - const kalshiMarkets = kalshiResult.status === 'fulfilled' ? kalshiResult.value : []; - - cachedMarkets = [...polyMarkets, ...kalshiMarkets]; - cacheTimestamp = now; - - 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; - } + 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 +165,43 @@ 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 a refresh is already in progress, return the existing promise + if (arbRefreshPromise) { + const opportunities = await arbRefreshPromise; + return opportunities.filter(arb => (arb.netEdgeBps ?? 0) >= minNetEdgeBps); + } + + // Recompute if cache is empty or 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)`); + arbRefreshPromise = (async () => { + try { + const markets = await getMarkets(); + console.log('[Arbitrage Cache] Computing arbitrage opportunities...'); + // Cache with low threshold (10 bps) so we can filter client-side + cachedArbitrage = detectArbitrage(markets, 10); + arbCacheTimestamp = Date.now(); + return cachedArbitrage; + } finally { + arbRefreshPromise = null; + } + })(); + + await arbRefreshPromise; } - // 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})`); + // Filter cached results by requested minNetEdgeBps (V1.5 logic) + const filtered = cachedArbitrage.filter(arb => (arb.netEdgeBps ?? 0) >= minNetEdgeBps); + console.log(`[Arbitrage Cache] Returning ${filtered.length}/${cachedArbitrage.length} opportunities (minNetEdgeBps: ${minNetEdgeBps})`); return filtered; -} +} \ No newline at end of file From af5f3a2d3eebaba895d4bf66b6227a022c50806b Mon Sep 17 00:00:00 2001 From: Adriel Date: Sun, 19 Apr 2026 03:20:46 -0700 Subject: [PATCH 7/9] Upgrade arbitrage handler to net edge logic --- api/markets/arbitrage.ts | 109 ++++++++++++++------------------------- 1 file changed, 40 insertions(+), 69 deletions(-) diff --git a/api/markets/arbitrage.ts b/api/markets/arbitrage.ts index 26a2f2a..e5c9513 100644 --- a/api/markets/arbitrage.ts +++ b/api/markets/arbitrage.ts @@ -1,5 +1,9 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; -import { getMarkets, getArbitrage, getMarketMetadata } from '../lib/market-cache'; + +//To ensure function doesnt time out +export const config = { + maxDuration: 30, +}; export default async function handler( req: VercelRequest, @@ -31,92 +35,59 @@ export default async function handler( try { // Parse query parameters const { + mode = 'fast', minSpread = '0.03', + minNetEdgeBps, 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); - - // Validate parameters - if (isNaN(minSpreadNum) || minSpreadNum < 0 || minSpreadNum > 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.', - }); - return; + let effectiveMinBps = 50; + if (minNetEdgeBps) { + effectiveMinBps = Number(minNetEdgeBps); + } else { + effectiveMinBps = Math.round(parseFloat(minSpread as string) * 10000); } - if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { - res.status(400).json({ - success: false, - error: 'Invalid limit. Must be between 1 and 100.', - }); - return; - } + const minConfidenceNum = parseFloat(minConfidence as string); + const limitNum = Math.min(parseInt(limit as string, 10), 100); - // Get markets - const markets = await getMarkets(); + let opportunities = await getArbitrage(effectiveMinBps); - if (markets.length === 0) { - res.status(503).json({ - success: false, - error: 'No markets available. Service temporarily unavailable.', + if (category || minConfidenceNum > 0) { + opportunities = opportunities.filter(arb => { + const matchesCat = !category || + arb.polymarket.category === category || + arb.kalshi.category === category; + const matchesConf = arb.confidence >= minConfidenceNum; + return matchesCat && matchesConf; }); - 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); - - // Stage 0: Get freshness metadata - const freshnessMetadata = getMarketMetadata(); + const result = opportunities.slice(0, limitNum); + const freshness = getMarketMetadata(); - // Build response - const response = { + res.status(200).json({ success: true, - data: { - opportunities, - count: opportunities.length, - timestamp: new Date().toISOString(), - filters: { - minSpread: minSpreadNum, - minConfidence: minConfidenceNum, - limit: limitNum, - 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, + metadata: { + processing_time_ms: Date.now() - startTime, + data_age_seconds: freshness.data_age_seconds, + fetched_at: freshness.fetched_at, + mode, + thresholds: { + applied_net_edge_bps: effectiveMinBps, + min_confidence: minConfidenceNum }, + markets_analyzed: freshness.sources.polymarket.market_count + freshness.sources.kalshi.market_count, + sources: freshness.sources }, - }; + data: { + count: result.length, + opportunities: result + } + }); - res.status(200).json(response); } catch (error) { console.error('[Arbitrage API] Error:', error); res.status(500).json({ From 06debe1ea0c80783d146d1cb5d07b8ca7ad092cd Mon Sep 17 00:00:00 2001 From: Adriel Date: Sun, 19 Apr 2026 12:58:14 -0700 Subject: [PATCH 8/9] Handles data ege enforcement and payload stripping --- api/lib/market-cache.ts | 40 +++++++------ api/markets/arbitrage.ts | 55 ++++++++---------- src/api/arbitrage-detector.ts | 103 ++++++++++++++++++++-------------- src/types/market.ts | 47 ++++++++-------- 4 files changed, 130 insertions(+), 115 deletions(-) diff --git a/api/lib/market-cache.ts b/api/lib/market-cache.ts index a433bb6..4bd1fc5 100644 --- a/api/lib/market-cache.ts +++ b/api/lib/market-cache.ts @@ -9,6 +9,7 @@ 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 { kv } from '@vercel/kv'; // In-memory cache for markets // Default: 20 seconds (configurable via MARKET_CACHE_TTL_SECONDS env var) @@ -43,6 +44,9 @@ const POLYMARKET_MAX_PAGES = 3; const KALSHI_TARGET_COUNT = 100; const KALSHI_MAX_PAGES = 3; +const LOCK_KEY = 'arb_recompute_lock'; +const LOCK_TTL = 20000; + function withTimeout( promise: Promise, timeoutMs: number, @@ -174,34 +178,28 @@ export function getMarketMetadata(): FreshnessMetadata { */ export async function getArbitrage(minNetEdgeBps: number = 50): Promise { const now = Date.now(); - - // If a refresh is already in progress, return the existing promise - if (arbRefreshPromise) { - const opportunities = await arbRefreshPromise; - return opportunities.filter(arb => (arb.netEdgeBps ?? 0) >= minNetEdgeBps); - } - - // Recompute if cache is empty or stale + + // Distributed Lock Pattern if (cachedArbitrage.length === 0 || (now - arbCacheTimestamp) >= ARB_CACHE_TTL_MS) { - arbRefreshPromise = (async () => { + const instanceId = Math.random().toString(36); + const locked = await kv.set(LOCK_KEY, instanceId, { nx: true, ex: 30 }); + + if (locked) { try { const markets = await getMarkets(); - console.log('[Arbitrage Cache] Computing arbitrage opportunities...'); - // Cache with low threshold (10 bps) so we can filter client-side cachedArbitrage = detectArbitrage(markets, 10); arbCacheTimestamp = Date.now(); - return cachedArbitrage; + // Sync to Redis for other lambdas + await kv.set('global_arb_cache', cachedArbitrage, { ex: 60 }); } finally { - arbRefreshPromise = null; + await kv.del(LOCK_KEY); } - })(); - - await arbRefreshPromise; + } else { + // If locked by another instance try to read their global cache + const remote = await kv.get('global_arb_cache'); + if (remote) return remote.filter(a => a.netEdgeBps >= minNetEdgeBps); + } } - // Filter cached results by requested minNetEdgeBps (V1.5 logic) - const filtered = cachedArbitrage.filter(arb => (arb.netEdgeBps ?? 0) >= minNetEdgeBps); - console.log(`[Arbitrage Cache] Returning ${filtered.length}/${cachedArbitrage.length} opportunities (minNetEdgeBps: ${minNetEdgeBps})`); - - return filtered; + return cachedArbitrage.filter(arb => arb.netEdgeBps >= minNetEdgeBps); } \ No newline at end of file diff --git a/api/markets/arbitrage.ts b/api/markets/arbitrage.ts index e5c9513..b08c906 100644 --- a/api/markets/arbitrage.ts +++ b/api/markets/arbitrage.ts @@ -36,14 +36,13 @@ export default async function handler( // Parse query parameters const { mode = 'fast', - minSpread = '0.03', + maxDataAgeMs, minNetEdgeBps, - minConfidence = '0.5', + minSpread, limit = '20', - category, } = req.query; - let effectiveMinBps = 50; + let effectiveMinBps = minNetEdgeBps ? Number(minNetEdgeBps) : Math.round(parseFloat(minSpread as string) * 10000); if (minNetEdgeBps) { effectiveMinBps = Number(minNetEdgeBps); } else { @@ -53,7 +52,7 @@ export default async function handler( const minConfidenceNum = parseFloat(minConfidence as string); const limitNum = Math.min(parseInt(limit as string, 10), 100); - let opportunities = await getArbitrage(effectiveMinBps); + const opportunities = await getArbitrage(effectiveMinBps); if (category || minConfidenceNum > 0) { opportunities = opportunities.filter(arb => { @@ -67,32 +66,26 @@ export default async function handler( const result = opportunities.slice(0, limitNum); const freshness = getMarketMetadata(); +// Item 1: Max Data Age Enforcement + if (maxDataAgeMs && freshness.data_age_seconds * 1000 > Number(maxDataAgeMs)) { + return res.status(200).json({ success: true, data: { opportunities: [], count: 0 }, metadata: { degraded: true }}); + } - res.status(200).json({ - success: true, - metadata: { - processing_time_ms: Date.now() - startTime, - data_age_seconds: freshness.data_age_seconds, - fetched_at: freshness.fetched_at, - mode, - thresholds: { - applied_net_edge_bps: effectiveMinBps, - min_confidence: minConfidenceNum - }, - markets_analyzed: freshness.sources.polymarket.market_count + freshness.sources.kalshi.market_count, - sources: freshness.sources - }, - data: { - count: result.length, - opportunities: result - } - }); - - } catch (error) { - console.error('[Arbitrage API] Error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Internal server error', - }); + // Item 2: Mode Payload Stripping + let finalOpps = opportunities.slice(0, Number(limit)); + if (mode === 'fast') { + finalOpps = finalOpps.map(o => ({ + pair: `${o.polymarket.id}:${o.kalshi.id}`, + netEdgeBps: o.netEdgeBps, + buy: o.buyVenue, + sell: o.sellVenue, + confidence: o.matchConfidence.score + })); } + + // Item 16: Log JSON for Observability + console.log(JSON.stringify({ event: 'arb_req', duration: Date.now() - startTime, count: finalOpps.length })); + + return res.status(200).json({ success: true, metadata: { ...freshness, mode }, data: finalOpps }); +} } diff --git a/src/api/arbitrage-detector.ts b/src/api/arbitrage-detector.ts index 7c86980..ab6d338 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -174,6 +174,10 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { return { isSimilar: false, confidence: 0, reason: 'Insufficient similarity' }; } +const FEE_POLY_BPS = Number(process.env.ARB_POLY_FEE_BPS || 20); +const FEE_KALSHI_BPS = Number(process.env.ARB_KALSHI_FEE_BPS || 15); +const SLIPPAGE_BPS = Number(process.env.ARB_SLIPPAGE_BPS || 10); + /** * Detect arbitrage opportunities * @@ -181,42 +185,52 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { * @param minNetEdgeBps - Minimum basis points profit (default, 50bps/0.5%) */ export function detectArbitrage( - markets: Market[], - minNetEdgeBps: number = 50 + markets: Market[], + minNetEdgeBps: number = 10 ): ArbitrageOpportunity[] { - const opportunities: ArbitrageOpportunity[] = []; - - // Separate markets by platform - const polyByCat = groupByCategory(markets.filter(m => m.platform === 'polymarket')); - const kalshiByCat = groupByCategory(markets.filter(m => m.platform === 'kalshi')); - - // Compare each Polymarket market with each Kalshi market - for (const cat in polyByCat) { - if (!kalshiByCat[cat]) continue; - - for (const poly of polyByCat[cat]) { - for (const kalshi of kalshiByCat[cat]) { - // Date Check + const opportunities: ArbitrageOpportunity[] = []; + 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) { - const delta = Math.abs(new Date(poly.endDate).getTime() - new Date(kalshi.endDate).getTime()); - if (delta > 86400000) continue; + expiryDeltaMinutes = Math.abs(new Date(poly.endDate).getTime() - new Date(kalshi.endDate).getTime()) / 60000; + if (expiryDeltaMinutes > 1440) continue; } - const similarity = areMarketsSimilar(poly, kalshi); + const sim = areMarketsSimilar(poly, kalshi); + if (!sim.isSimilar) continue; - if (!similarity.isSimilar) continue; + // Executable Edge (Buy at Ask, Sell at Bid) + const polyBuy = poly.yesAsk ?? poly.yesPrice; + const polySell = poly.yesBid ?? poly.yesPrice; + const kalshiBuy = kalshi.yesAsk ?? kalshi.yesPrice; + const kalshiSell = kalshi.yesBid ?? kalshi.yesPrice; + const edgePolyBuy = kalshiSell - polyBuy; + const edgeKalshiBuy = polySell - kalshiBuy; - // Math - const isPolyCheaper = poly.yesPrice < kalshi.yesPrice; - const buyPrice = isPolyCheaper ? poly.yesPrice : kalshi.yesPrice; - const sellPrice = isPolyCheaper ? kalshi.yesPrice : poly.yesPrice; + const isPolyCheaper = edgePolyBuy > edgeKalshiBuy; + const buyPrice = isPolyCheaper ? polyBuy : kalshiBuy; + const sellPrice = isPolyCheaper ? kalshiSell : polySell; + + // Summed Venue Fees + const totalFees = FEE_POLY_BPS + FEE_KALSHI_BPS + SLIPPAGE_BPS; + const grossBps = ((sellPrice - buyPrice) / buyPrice) * 10000; + const netEdge = grossBps - totalFees; - const { grossBps, netEdgeBps } = calculateNedEdge(buyPrice, sellPrice); + if (netEdge < minNetEdgeBps) continue; - if (netEdgeBps < minNetEdgeBps) continue; + // Real Liquidity Score + const combinedVol = (poly.volume24h || 0) + (kalshi.volume24h || 0); + if (combinedVol < Number(process.env.ARB_MIN_VOL || 500)) continue; - // V1.5 Objects opportunities.push({ polymarket: poly, kalshi: kalshi, @@ -224,26 +238,33 @@ export function detectArbitrage( sellPrice, buyVenue: isPolyCheaper ? 'polymarket' : 'kalshi', sellVenue: isPolyCheaper ? 'kalshi' : 'polymarket', - netEdgeBps, - grossEdgeBps: grossBps, - estimatedFeesBps: FEES_BPS, - slippageBps: SLIPPAGE_BPS, - latencyRiskBps: LATENCY_BPS, - confidence: similarity.confidence, - matchReason: similarity.reason, - liquidityScore: 0.5, - expiryDeltaMinutes: poly.endDate ? Math.floor((new Date(poly.endDate).getTime() - Date.now()) / 60000) :0, + netEdgeBps: Math.round(netEdge), + grossEdgeBps: Math.round(grossBps), + matchConfidence: { + score: sim.confidence, + titleSimilarity: sim.titleSim, + keywordOverlap: sim.keywordOverlap, + categoryAligned: true, + expiryAligned: (expiryDeltaMinutes || 0) < 60 + }, + sourceTimestamps: { + polymarket: poly.lastUpdated || null, + kalshi: kalshi.lastUpdated || null + }, + expiryDeltaMinutes, asOfTs: new Date().toISOString(), - // Calculate spread - spread: sellPrice - buyPrice, - - profitPotential: (sellPrice - buyPrice), - direction: isPolyCheaper ? 'buy_poly_sell_kalshi' : 'buy_kalshi_sell_poly' + liquidityScore: Math.min(combinedVol / 10000, 1) }); } } } - return opportunities.sort((a, b) => b.netEdgeBps - a.netEdgeBps); + + // 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}`); + }); } /** diff --git a/src/types/market.ts b/src/types/market.ts index 72ef12f..58026e6 100644 --- a/src/types/market.ts +++ b/src/types/market.ts @@ -4,17 +4,15 @@ export interface Market { id: string; platform: 'kalshi' | 'polymarket'; title: string; - description: string; - keywords: string[]; + category: string; yesPrice: number; // 0.0 to 1.0 (0.65 = 65%) - noPrice: number; // 0.0 to 1.0 (0.35 = 35%) + yesBid?: number; + yesAsk?: number; 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") + liquidity?: number; + endDate?: string; // ISO date string (e.g. "2026-03-31") + keywords: string[]; + lastUpdated?: string; // ISO timestamp } export interface MarketMatch { @@ -24,23 +22,28 @@ export interface MarketMatch { } export interface ArbitrageOpportunity { - polymarket: Market; + +polymarket: Market; kalshi: Market; buyPrice: number; sellPrice: number; - buyVenue: 'polymarket' | 'kalshi'; - sellVenue: 'polymarket' | 'kalshi'; + buyVenue: string; + sellVenue: string; netEdgeBps: number; grossEdgeBps: number; - estimatedFeesBps: number; - slippageBps: number; - latencyRiskBps: number; - confidence: number; // 0-1, how confident we are this is the same event - matchReason: string; // Why we think these are the same market - liquidityScore: number; - expiryDeltaMinutes: number; + matchConfidence: { + score: number; + titleSimilarity: number; + keywordOverlap: number; + categoryAligned: boolean; + expiryAligned: boolean; + }; + sourceTimestamps: { + polymarket: string | null; + kalshi: string | null; + }; + expiryDeltaMinutes: number | null; asOfTs: string; - spread: number; // Absolute price difference (e.g., 0.05 = 5%) - profitPotential: number; // Expected profit per $1 invested - direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; + liquidityScore: number; + } From acfa52077197b4db6fa88770c20e9cb5abfc889a Mon Sep 17 00:00:00 2001 From: Adriel Date: Sun, 19 Apr 2026 13:28:22 -0700 Subject: [PATCH 9/9] Implemented v1.5 executable edge contract, added mode/maxDataAge controls, and harden caches/health/tests/docs --- API-REFERENCE.upstream.md | 122 +++++++++++++++--------- api/health.ts | 18 ++++ api/lib/market-cache.ts | 113 +++++++++++++++++----- api/markets/arbitrage.ts | 171 +++++++++++++++++++++++++++------- scripts/test-agent-api.ts | 80 +++++++++++++--- src/api/arbitrage-detector.ts | 126 +++++++++++++++---------- src/sdk/musashi-agent.ts | 35 ++++++- src/types/market.ts | 31 ++++-- 8 files changed, 523 insertions(+), 173 deletions(-) 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 4bd1fc5..0475ac1 100644 --- a/api/lib/market-cache.ts +++ b/api/lib/market-cache.ts @@ -8,8 +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 { kv } from '@vercel/kv'; +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) @@ -38,14 +38,24 @@ let kalshiError: string | null = null; // Stage 0 Session 2: Per-source timeout (5 seconds) const SOURCE_TIMEOUT_MS = 5000; -// Constants for fetch limits -const POLYMARKET_TARGET_COUNT = 100; -const POLYMARKET_MAX_PAGES = 3; -const KALSHI_TARGET_COUNT = 100; -const KALSHI_MAX_PAGES = 3; +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); -const LOCK_KEY = 'arb_recompute_lock'; -const LOCK_TTL = 20000; +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; +} function withTimeout( promise: Promise, @@ -73,6 +83,7 @@ export async function getMarkets(): Promise { // Return cached if fresh if (cachedMarkets.length > 0 && (now - cacheTimestamp) < CACHE_TTL_MS) { + logCacheEvent('markets_cache_hit', { cached_count: cachedMarkets.length, age_ms: now - cacheTimestamp }); return cachedMarkets; } @@ -122,6 +133,11 @@ export async function getMarkets(): Promise { cachedMarkets = [...polyMarkets, ...kalshiMarkets]; cacheTimestamp = currentFetchTime; + logCacheEvent('markets_cache_refresh', { + total_count: cachedMarkets.length, + polymarket_count: polyMarkets.length, + kalshi_count: kalshiMarkets.length, + }); return cachedMarkets; } finally { @@ -178,28 +194,77 @@ export function getMarketMetadata(): FreshnessMetadata { */ export async function getArbitrage(minNetEdgeBps: number = 50): Promise { const now = Date.now(); - - // Distributed Lock Pattern - if (cachedArbitrage.length === 0 || (now - arbCacheTimestamp) >= ARB_CACHE_TTL_MS) { - const instanceId = Math.random().toString(36); - const locked = await kv.set(LOCK_KEY, instanceId, { nx: true, ex: 30 }); - if (locked) { + if (arbRefreshPromise) { + const opportunities = await arbRefreshPromise; + return opportunities.filter((arb) => (arb.netEdgeBps ?? 0) >= minNetEdgeBps); + } + + 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; + } + } + const markets = await getMarkets(); cachedArbitrage = detectArbitrage(markets, 10); arbCacheTimestamp = Date.now(); - // Sync to Redis for other lambdas - await kv.set('global_arb_cache', cachedArbitrage, { ex: 60 }); + 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 { - await kv.del(LOCK_KEY); + arbRefreshPromise = null; } - } else { - // If locked by another instance try to read their global cache - const remote = await kv.get('global_arb_cache'); - if (remote) return remote.filter(a => a.netEdgeBps >= minNetEdgeBps); - } + })(); + await arbRefreshPromise; } - return cachedArbitrage.filter(arb => arb.netEdgeBps >= minNetEdgeBps); + 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 b08c906..04d66f7 100644 --- a/api/markets/arbitrage.ts +++ b/api/markets/arbitrage.ts @@ -1,9 +1,10 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getArbitrage, getMarketMetadata } from '../lib/market-cache'; -//To ensure function doesnt time out export const config = { maxDuration: 30, }; +const ARB_V15_ENABLED = process.env.ARB_V15_ENABLED !== '0'; export default async function handler( req: VercelRequest, @@ -33,59 +34,161 @@ export default async function handler( const startTime = Date.now(); try { - // Parse query parameters const { mode = 'fast', maxDataAgeMs, minNetEdgeBps, - minSpread, + minSpread = '0.03', + minConfidence = '0.5', limit = '20', + category, } = req.query; - let effectiveMinBps = minNetEdgeBps ? Number(minNetEdgeBps) : Math.round(parseFloat(minSpread as string) * 10000); - if (minNetEdgeBps) { - effectiveMinBps = Number(minNetEdgeBps); - } else { - effectiveMinBps = Math.round(parseFloat(minSpread as string) * 10000); + 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; + + 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 (Number.isNaN(parsedMinConfidence) || parsedMinConfidence < 0 || parsedMinConfidence > 1) { + res.status(400).json({ success: false, error: 'Invalid minConfidence. Must be between 0 and 1.' }); + return; + } + 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; } - const minConfidenceNum = parseFloat(minConfidence as string); - const limitNum = Math.min(parseInt(limit as string, 10), 100); + 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; + } + } 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; + } - if (category || minConfidenceNum > 0) { - opportunities = opportunities.filter(arb => { + 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 >= minConfidenceNum; + const matchesConf = arb.confidence >= parsedMinConfidence; return matchesCat && matchesConf; }); } - const result = opportunities.slice(0, limitNum); - const freshness = getMarketMetadata(); -// Item 1: Max Data Age Enforcement - if (maxDataAgeMs && freshness.data_age_seconds * 1000 > Number(maxDataAgeMs)) { - return res.status(200).json({ success: true, data: { opportunities: [], count: 0 }, metadata: { degraded: true }}); - } + 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); + }); - // Item 2: Mode Payload Stripping - let finalOpps = opportunities.slice(0, Number(limit)); - if (mode === 'fast') { - finalOpps = finalOpps.map(o => ({ - pair: `${o.polymarket.id}:${o.kalshi.id}`, - netEdgeBps: o.netEdgeBps, - buy: o.buyVenue, - sell: o.sellVenue, - confidence: o.matchConfidence.score - })); - } + const result = filtered.slice(0, parsedLimit); - // Item 16: Log JSON for Observability - console.log(JSON.stringify({ event: 'arb_req', duration: Date.now() - startTime, count: finalOpps.length })); + 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); - return res.status(200).json({ success: true, metadata: { ...freshness, mode }, data: finalOpps }); -} + 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: payloadOpportunities, + count: payloadOpportunities.length, + timestamp: new Date().toISOString(), + filters: { + mode, + v15_enabled: ARB_V15_ENABLED, + minSpread: parsedMinSpread, + minNetEdgeBps: effectiveMinBps, + minConfidence: parsedMinConfidence, + limit: parsedLimit, + category: category || null, + maxDataAgeMs: parsedMaxDataAgeMs ?? null, + }, + }, + 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({ + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }); + } } 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 ab6d338..498d660 100644 --- a/src/api/arbitrage-detector.ts +++ b/src/api/arbitrage-detector.ts @@ -3,26 +3,18 @@ import { Market, ArbitrageOpportunity } from '../types/market'; -const FEES_BPS = Number(process.env.ARB_FEE_BPS || 20); +declare const process: { + env: Record; +}; + +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); - -/** - * V1.5 Net Edge Calculator - * Converts raw prices into tradable Basis Points (bps) - */ -function calculateNedEdge(buyPrice: number, sellPrice: number) { - const grossEdge = sellPrice - buyPrice; - const grossBps = (grossEdge / buyPrice) * 10000; - - const totalCosts = FEES_BPS + SLIPPAGE_BPS + LATENCY_BPS; - const netEdgeBps = grossBps - totalCosts; - - return { - grossBps: Math.round(grossBps), - netEdgeBps: Math.round(netEdgeBps) - }; -} +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'; /** * & Helper to group markets by category for faster scanning (O(N) vs O(N*M)) @@ -120,6 +112,8 @@ function calculateKeywordOverlap(market1: Market, market2: Market): number { 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') @@ -128,7 +122,13 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { kalshi.category === 'other'; if (!categoryMatch) { - return { isSimilar: false, confidence: 0, reason: 'Different categories' }; + return { + isSimilar: false, + confidence: 0, + titleSim: 0, + keywordOverlap: 0, + reason: 'Different categories', + }; } // Calculate title similarity @@ -138,14 +138,18 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { const keywordOverlap = calculateKeywordOverlap(poly, kalshi); // Matching criteria (needs at least one strong signal): - // 1. High title similarity (>0.6) OR + // 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 (titleSim > 0.6) { + if (titleSim > titleThreshold) { return { isSimilar: true, confidence: titleSim, - reason: `High title similarity (${(titleSim * 100).toFixed(0)}%)` + titleSim, + keywordOverlap, + reason: `High title similarity (${(titleSim * 100).toFixed(0)}%)`, }; } @@ -154,7 +158,9 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { return { isSimilar: true, confidence, - reason: `${keywordOverlap} shared keywords` + titleSim, + keywordOverlap, + reason: `${keywordOverlap} shared keywords`, }; } @@ -163,20 +169,32 @@ function areMarketsSimilar(poly: Market, kalshi: Market): { const kalshiEntities = extractEntities(kalshi.title); const sharedEntities = Array.from(polyEntities).filter(e => kalshiEntities.has(e)); - if (sharedEntities.length >= 2 && titleSim > 0.35) { + if (sharedEntities.length >= 2 && titleSim > entityThreshold) { return { isSimilar: true, confidence: 0.7, - reason: `Shared entities: ${sharedEntities.slice(0, 3).join(', ')}` + titleSim, + keywordOverlap, + reason: `Shared entities: ${sharedEntities.slice(0, 3).join(', ')}`, }; } - return { isSimilar: false, confidence: 0, reason: 'Insufficient similarity' }; + return { + isSimilar: false, + confidence: 0, + titleSim, + keywordOverlap, + reason: 'Insufficient similarity', + }; } -const FEE_POLY_BPS = Number(process.env.ARB_POLY_FEE_BPS || 20); -const FEE_KALSHI_BPS = Number(process.env.ARB_KALSHI_FEE_BPS || 15); -const SLIPPAGE_BPS = Number(process.env.ARB_SLIPPAGE_BPS || 10); +function safePriceForBuy(market: Market): number { + return market.yesAsk ?? market.yesPrice; +} + +function safePriceForSell(market: Market): number { + return market.yesBid ?? market.yesPrice; +} /** * Detect arbitrage opportunities @@ -185,7 +203,7 @@ const SLIPPAGE_BPS = Number(process.env.ARB_SLIPPAGE_BPS || 10); * @param minNetEdgeBps - Minimum basis points profit (default, 50bps/0.5%) */ export function detectArbitrage( - markets: Market[], + markets: Market[], minNetEdgeBps: number = 10 ): ArbitrageOpportunity[] { const opportunities: ArbitrageOpportunity[] = []; @@ -204,24 +222,27 @@ export function detectArbitrage( if (expiryDeltaMinutes > 1440) continue; } - const sim = areMarketsSimilar(poly, kalshi); - if (!sim.isSimilar) continue; + const similarity = areMarketsSimilar(poly, kalshi); + if (!similarity.isSimilar) continue; // Executable Edge (Buy at Ask, Sell at Bid) - const polyBuy = poly.yesAsk ?? poly.yesPrice; - const polySell = poly.yesBid ?? poly.yesPrice; - const kalshiBuy = kalshi.yesAsk ?? kalshi.yesPrice; - const kalshiSell = kalshi.yesBid ?? kalshi.yesPrice; + 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 ? polyBuy : kalshiBuy; - const sellPrice = isPolyCheaper ? kalshiSell : polySell; + 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 = FEE_POLY_BPS + FEE_KALSHI_BPS + SLIPPAGE_BPS; + 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; @@ -229,31 +250,40 @@ export function detectArbitrage( // Real Liquidity Score const combinedVol = (poly.volume24h || 0) + (kalshi.volume24h || 0); - if (combinedVol < Number(process.env.ARB_MIN_VOL || 500)) continue; + if (combinedVol < MIN_VOLUME_FLOOR) continue; opportunities.push({ polymarket: poly, - kalshi: kalshi, + 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: sim.confidence, - titleSimilarity: sim.titleSim, - keywordOverlap: sim.keywordOverlap, + score: similarity.confidence, + titleSimilarity: similarity.titleSim, + keywordOverlap: similarity.keywordOverlap, categoryAligned: true, - expiryAligned: (expiryDeltaMinutes || 0) < 60 + expiryAligned: (expiryDeltaMinutes || 0) < 60, + liquidityAligned: combinedVol >= MIN_VOLUME_FLOOR, }, sourceTimestamps: { polymarket: poly.lastUpdated || null, - kalshi: kalshi.lastUpdated || null + kalshi: kalshi.lastUpdated || null, }, expiryDeltaMinutes, asOfTs: new Date().toISOString(), - liquidityScore: Math.min(combinedVol / 10000, 1) + liquidityScore: Math.min(combinedVol / 10000, 1), }); } } 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 58026e6..f761c6e 100644 --- a/src/types/market.ts +++ b/src/types/market.ts @@ -4,15 +4,20 @@ export interface Market { id: string; platform: 'kalshi' | 'polymarket'; title: string; - category: string; + description: string; + keywords: string[]; yesPrice: number; // 0.0 to 1.0 (0.65 = 65%) - yesBid?: number; - yesAsk?: number; + 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 + yesBid?: number; + yesAsk?: 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") - keywords: string[]; - lastUpdated?: string; // ISO timestamp } export interface MarketMatch { @@ -22,21 +27,29 @@ export interface MarketMatch { } export interface ArbitrageOpportunity { - -polymarket: Market; + polymarket: Market; kalshi: Market; buyPrice: number; sellPrice: number; - buyVenue: string; - sellVenue: string; + buyVenue: 'polymarket' | 'kalshi'; + sellVenue: 'polymarket' | 'kalshi'; netEdgeBps: number; grossEdgeBps: number; + estimatedFeesBps: number; + slippageBps: number; + latencyRiskBps: number; + confidence: number; // Backward-compatible alias used by existing callers + matchReason: string; // Backward-compatible reasoning string + spread: number; // Backward-compatible spread proxy + profitPotential: number; // Backward-compatible expected profit proxy + direction: 'buy_poly_sell_kalshi' | 'buy_kalshi_sell_poly'; // Backward-compatible direction matchConfidence: { score: number; titleSimilarity: number; keywordOverlap: number; categoryAligned: boolean; expiryAligned: boolean; + liquidityAligned?: boolean; }; sourceTimestamps: { polymarket: string | null;