Spaces:
Sleeping
Sleeping
| import { Injectable } from '@nestjs/common'; | |
| // WagerKit internal data aggregation service | |
| export interface MarketSource { | |
| name: string; | |
| type: 'regulated' | 'onchain'; | |
| } | |
| export interface IntegrityScore { | |
| overall: number; | |
| marketClarity: number; | |
| liquidityDepth: number; | |
| crossSourceAgreement: number; | |
| volatilitySanity: number; | |
| } | |
| export interface OddsDataPoint { | |
| timestamp: string; | |
| polymarket: number; | |
| kalshi: number; | |
| predictit: number; | |
| wagerkit: number; | |
| } | |
| export interface MarketSummary { | |
| slug: string; | |
| title: string; | |
| tag: string; | |
| closesAt: string; | |
| } | |
| export interface SimulatedMetrics { | |
| /** Simulated raw tick frequency (production: from ClickHouse odds_tick) */ | |
| ticksPerHour: number; | |
| /** Simulated 24-h trading volume in $K (production: sum(volume) from odds_tick) */ | |
| dailyVolumeK: number; | |
| /** Metadata quality / data-completeness factor 0-1 (production: DB metadata checks) */ | |
| dataCompleteness: number; | |
| } | |
| export interface MarketDetail extends MarketSummary { | |
| description: string; | |
| sources: MarketSource[]; | |
| integrityScore: IntegrityScore; | |
| oddsHistory: OddsDataPoint[]; | |
| notes: string[]; | |
| resolutionCriteriaUrl: string; | |
| question: string; | |
| simulatedMetrics: SimulatedMetrics; | |
| } | |
| () | |
| export class MarketsService { | |
| private readonly marketsData: MarketDetail[] = [ | |
| { | |
| slug: 'us_election_2024_winner', | |
| title: 'US 2024 Presidential Election - Winner', | |
| tag: 'election', | |
| closesAt: '11/6/2024', | |
| description: 'Who will win the 2024 United States presidential election? This market resolves to the candidate who wins the Electoral College majority.', | |
| question: 'Who will win the 2024 United States presidential election?', | |
| resolutionCriteriaUrl: 'https://wagerkit.xyz/resolution/us-election-2024', | |
| sources: [ | |
| { name: 'PredictIt', type: 'regulated' }, | |
| { name: 'Kalshi', type: 'regulated' }, | |
| { name: 'Polymarket', type: 'onchain' }, | |
| { name: 'WagerKit', type: 'onchain' }, | |
| ], | |
| integrityScore: { | |
| overall: 84.0, | |
| marketClarity: 88, | |
| liquidityDepth: 82, | |
| crossSourceAgreement: 85, | |
| volatilitySanity: 81, | |
| }, | |
| notes: ['Post-election convergence', 'Historical resolved market'], | |
| simulatedMetrics: { ticksPerHour: 52, dailyVolumeK: 1958, dataCompleteness: 0.949 }, | |
| oddsHistory: [], | |
| }, | |
| { | |
| slug: 'btc_halving_2024', | |
| title: 'BTC Halving 2024 - Price impact probability', | |
| tag: 'crypto', | |
| closesAt: '5/20/2024', | |
| description: 'Will Bitcoin price exceed $80,000 within 90 days of the April 2024 halving event?', | |
| question: 'Will Bitcoin price exceed $80,000 within 90 days of the April 2024 halving event?', | |
| resolutionCriteriaUrl: 'https://wagerkit.xyz/resolution/btc-halving-2024', | |
| sources: [ | |
| { name: 'PredictIt', type: 'regulated' }, | |
| { name: 'Kalshi', type: 'regulated' }, | |
| { name: 'Polymarket', type: 'onchain' }, | |
| { name: 'WagerKit', type: 'onchain' }, | |
| ], | |
| integrityScore: { | |
| overall: 90.0, | |
| marketClarity: 92, | |
| liquidityDepth: 88, | |
| crossSourceAgreement: 91, | |
| volatilitySanity: 89, | |
| }, | |
| notes: ['Post-halving stability', 'Historical resolved market'], | |
| simulatedMetrics: { ticksPerHour: 55, dailyVolumeK: 2200, dataCompleteness: 0.919 }, | |
| oddsHistory: [], | |
| }, | |
| { | |
| slug: 'us_cpi_yoy_nov_2025', | |
| title: 'US CPI YoY - Nov 2025 print', | |
| tag: 'macro', | |
| closesAt: '12/18/2025', | |
| description: 'Will the US CPI Year-over-Year figure for November 2025 come in above 3.0%?', | |
| question: 'Will the US CPI Year-over-Year figure for November 2025 come in above 3.0%?', | |
| resolutionCriteriaUrl: 'https://wagerkit.xyz/resolution/us-cpi-nov-2025', | |
| sources: [ | |
| { name: 'PredictIt', type: 'regulated' }, | |
| { name: 'Kalshi', type: 'regulated' }, | |
| { name: 'Polymarket', type: 'onchain' }, | |
| { name: 'WagerKit', type: 'onchain' }, | |
| ], | |
| integrityScore: { | |
| overall: 73.0, | |
| marketClarity: 76, | |
| liquidityDepth: 71, | |
| crossSourceAgreement: 74, | |
| volatilitySanity: 71, | |
| }, | |
| notes: ['Post-release convergence', 'Historical resolved market'], | |
| simulatedMetrics: { ticksPerHour: 38, dailyVolumeK: 1855, dataCompleteness: 0.770 }, | |
| oddsHistory: [], | |
| }, | |
| ]; | |
| /** | |
| * Integrity Score Formula (mirrors production IntegrityWorker): | |
| * Score = 100 × (0.40×C + 0.30×L + 0.20×A + 0.10×V) | |
| * | |
| * C = Market Clarity: min(1, base × dataCompleteness) | |
| * base = 0.5*(hasUrl) + graduated_source_score + graduated_question_score | |
| * L = Liquidity Depth: 0.40×frequencyScore + 0.40×volumeScore + 0.20×sourceScore | |
| * A = Cross-Source Agreement: max(0, 1 − min(1, avgPairwiseRMSE / θ)), θ = 0.10 | |
| * V = Volatility Sanity: 0.70×(1 − avgNormStdDev) + 0.30×spikeScore | |
| */ | |
| calculateIntegrityScore(market: MarketDetail, oddsHistory: OddsDataPoint[]): IntegrityScore { | |
| const metrics = market.simulatedMetrics; | |
| // ── C: Market Clarity ────────────────────────────────────────────── | |
| // Production: calculateClarity(market, sourceCount) | |
| // = min(1, 0.5*(hasUrl) + graduated_source + graduated_question) × dataCompleteness | |
| let clarityBase = 0; | |
| if (market.resolutionCriteriaUrl) clarityBase += 0.5; | |
| // Graduated source score: 1→0 … 4+→0.30 | |
| clarityBase += Math.min(0.3, ((Math.min(market.sources.length, 4) - 1) / 3) * 0.3); | |
| // Question specificity: graduated by length (20→0, 80+→0.20) | |
| const qLen = market.question?.length || 0; | |
| clarityBase += Math.min(0.2, Math.max(0, (qLen - 20) / 300)); | |
| // dataCompleteness from per-market simulated metrics (production: DB metadata quality) | |
| const clarity = Math.min(1, clarityBase * metrics.dataCompleteness); | |
| // ── L: Liquidity Depth ───────────────────────────────────────────── | |
| // Production: 0.4×freq + 0.4×vol + 0.2×src (real tick data from ClickHouse odds_tick) | |
| // Uses per-market simulated tick metrics instead of chart-point density. | |
| const frequencyScore = Math.min(1, metrics.ticksPerHour / 60); | |
| const volumeScore = Math.min(1, metrics.dailyVolumeK / 2500); | |
| const sourceScore = Math.min(1, market.sources.length / 5); | |
| const liquidity = Math.max(0.1, Math.min(1, | |
| 0.4 * frequencyScore + 0.4 * volumeScore + 0.2 * sourceScore, | |
| )); | |
| // ── A: Cross-Source Agreement ────────────────────────────────────── | |
| // Production: max(0, 1 − min(1, RMSE / θ)), θ = 0.10 | |
| // Average pairwise RMSE across all source combinations. | |
| const sourceKeys = ['polymarket', 'kalshi', 'predictit', 'wagerkit'] as const; | |
| let agreement = 0.5; | |
| if (oddsHistory.length > 0 && market.sources.length >= 2) { | |
| let totalRmse = 0; | |
| let pairCount = 0; | |
| for (let i = 0; i < sourceKeys.length; i++) { | |
| for (let j = i + 1; j < sourceKeys.length; j++) { | |
| let sumSqDiff = 0; | |
| for (const point of oddsHistory) { | |
| sumSqDiff += Math.pow(point[sourceKeys[i]] - point[sourceKeys[j]], 2); | |
| } | |
| totalRmse += Math.sqrt(sumSqDiff / oddsHistory.length); | |
| pairCount++; | |
| } | |
| } | |
| const avgRmse = totalRmse / pairCount; | |
| const theta = 0.1; | |
| agreement = Math.max(0, 1 - Math.min(1, avgRmse / theta)); | |
| } | |
| // ── V: Volatility Sanity ────────────────────────────────────────── | |
| // Production: 0.70×(1 − normStdDev) + 0.30×spikeScore | |
| // Per-source analysis (matching production per-mapping pattern). | |
| let volatility = 0.5; | |
| if (oddsHistory.length > 1) { | |
| let totalNormStdDev = 0; | |
| let totalSpikes = 0; | |
| let totalTicks = 0; | |
| for (const key of sourceKeys) { | |
| const prices = oddsHistory.map((p) => p[key]); | |
| const mean = prices.reduce((a, b) => a + b, 0) / prices.length; | |
| const stdDev = Math.sqrt( | |
| prices.reduce((sum, p) => sum + Math.pow(p - mean, 2), 0) / prices.length, | |
| ); | |
| totalNormStdDev += Math.min(1, stdDev / 0.1); | |
| // Spike detection: >10 % relative change between consecutive ticks | |
| for (let i = 1; i < prices.length; i++) { | |
| const relChange = Math.abs((prices[i] - prices[i - 1]) / prices[i - 1]); | |
| if (relChange > 0.1) totalSpikes++; | |
| totalTicks++; | |
| } | |
| } | |
| const avgNormStdDev = totalNormStdDev / sourceKeys.length; | |
| const spikeRate = totalTicks > 0 ? totalSpikes / totalTicks : 0; | |
| const spikeScore = Math.max(0, 1 - spikeRate * 2); | |
| volatility = 0.7 * (1 - avgNormStdDev) + 0.3 * spikeScore; | |
| volatility = Math.max(0.1, Math.min(1, volatility)); | |
| } | |
| // ── Overall: 100 × (0.40×C + 0.30×L + 0.20×A + 0.10×V) ────────── | |
| const overall = 100 * (0.4 * clarity + 0.3 * liquidity + 0.2 * agreement + 0.1 * volatility); | |
| return { | |
| overall: Math.round(overall * 10) / 10, | |
| marketClarity: Math.round(clarity * 100), | |
| liquidityDepth: Math.round(liquidity * 100), | |
| crossSourceAgreement: Math.round(agreement * 100), | |
| volatilitySanity: Math.round(volatility * 100), | |
| }; | |
| } | |
| private generateOddsHistory(slug: string): OddsDataPoint[] { | |
| const points: OddsDataPoint[] = []; | |
| const now = new Date(); | |
| // Per-market odds config — each market has unique wave frequencies, | |
| // phase offsets, and noise levels to produce visually distinct graphs. | |
| const seeds: Record<string, { | |
| base: number; trend: number; cosAmp: number; | |
| vol: number; offsets: [number, number, number, number]; | |
| sinFreq: number; cosFreq: number; phaseShift: number; | |
| }> = { | |
| us_election_2024_winner: { | |
| base: 0.55, trend: 0.035, cosAmp: 0.022, vol: 0.009, | |
| offsets: [0, 0.014, -0.011, 0.007], | |
| sinFreq: 1.8, cosFreq: 3.6, phaseShift: 0, | |
| }, | |
| btc_halving_2024: { | |
| base: 0.72, trend: 0.025, cosAmp: 0.015, vol: 0.006, | |
| offsets: [0, 0.009, -0.007, 0.004], | |
| sinFreq: 3.2, cosFreq: 7.0, phaseShift: 1.2, | |
| }, | |
| us_cpi_yoy_nov_2025: { | |
| base: 0.62, trend: 0.045, cosAmp: 0.032, vol: 0.013, | |
| offsets: [0, 0.024, -0.019, 0.014], | |
| sinFreq: 2.4, cosFreq: 5.5, phaseShift: 2.5, | |
| }, | |
| }; | |
| // For dynamic/unknown slugs, derive unique config from slug hash | |
| const h = Math.abs(this.hashCode(slug)); | |
| const config = seeds[slug] || { | |
| base: 0.35 + (h % 40) / 100, | |
| trend: 0.02 + (h % 30) / 1000, | |
| cosAmp: 0.01 + (h % 20) / 1000, | |
| vol: 0.005 + (h % 10) / 1000, | |
| offsets: [ | |
| 0, | |
| 0.005 + (h % 13) / 1000, | |
| -0.003 - (h % 11) / 1000, | |
| 0.002 + (h % 7) / 1000, | |
| ] as [number, number, number, number], | |
| sinFreq: 1.5 + (h % 30) / 10, | |
| cosFreq: 3.0 + (h % 50) / 10, | |
| phaseShift: (h % 628) / 100, | |
| }; | |
| // Deterministic pseudo-random based on slug hash | |
| let seed = Math.abs(this.hashCode(slug + '_odds')); | |
| const pseudoRandom = () => { | |
| seed = (seed * 16807 + 0) % 2147483647; | |
| return (seed & 0xfffffff) / 0x10000000; | |
| }; | |
| for (let i = 0; i < 96; i++) { | |
| const time = new Date(now.getTime() - (95 - i) * 15 * 60 * 1000); | |
| const t = i / 96; | |
| // Each market uses unique wave frequencies + phase shift | |
| const base = | |
| config.base + | |
| config.trend * Math.sin(t * Math.PI * config.sinFreq + config.phaseShift) + | |
| config.cosAmp * Math.cos(t * Math.PI * config.cosFreq + config.phaseShift * 0.7); | |
| // Per-source variation using configured offsets & noise | |
| const polymarket = Math.max(0.01, Math.min(0.99, | |
| base + config.offsets[0] + (pseudoRandom() - 0.5) * config.vol * 2, | |
| )); | |
| const kalshi = Math.max(0.01, Math.min(0.99, | |
| base + config.offsets[1] + (pseudoRandom() - 0.5) * config.vol * 2, | |
| )); | |
| const predictit = Math.max(0.01, Math.min(0.99, | |
| base + config.offsets[2] + (pseudoRandom() - 0.5) * config.vol * 2, | |
| )); | |
| const wk = Math.max(0.01, Math.min(0.99, | |
| base + config.offsets[3] + (pseudoRandom() - 0.5) * config.vol * 2, | |
| )); | |
| points.push({ | |
| timestamp: time.toISOString(), | |
| polymarket: Math.round(polymarket * 10000) / 10000, | |
| kalshi: Math.round(kalshi * 10000) / 10000, | |
| predictit: Math.round(predictit * 10000) / 10000, | |
| wagerkit: Math.round(wk * 10000) / 10000, | |
| }); | |
| } | |
| return points; | |
| } | |
| getMarkets(): MarketSummary[] { | |
| return this.marketsData.map(({ slug, title, tag, closesAt }) => ({ | |
| slug, | |
| title, | |
| tag, | |
| closesAt, | |
| })); | |
| } | |
| /** | |
| * Search / create a dynamic market from any user query. | |
| * Generates a slug, realistic integrity scores, and odds history on the fly. | |
| */ | |
| searchOrCreateMarket(query: string): MarketDetail { | |
| const slug = this.slugify(query); | |
| // Check if it matches an existing market | |
| const existing = this.marketsData.find((m) => m.slug === slug); | |
| if (existing) { | |
| const oddsHistory = this.generateOddsHistory(slug); | |
| return { ...existing, oddsHistory }; | |
| } | |
| // Auto-detect tag from query keywords | |
| const tag = this.detectTag(query); | |
| // Generate a plausible closing date (30-180 days from now) | |
| const hashVal = Math.abs(this.hashCode(slug)); | |
| const daysOut = 30 + (hashVal % 150); | |
| const closesAt = new Date(Date.now() + daysOut * 86400000); | |
| const closesAtStr = `${closesAt.getMonth() + 1}/${closesAt.getDate()}/${closesAt.getFullYear()}`; | |
| // Build a Yes/No binary market | |
| const yesNoTitle = query.toLowerCase().startsWith('will') | |
| ? this.titleCase(query) | |
| : `Will ${this.titleCase(query).replace(/\?$/, '')} Happen?`; | |
| const yesNoQuestion = yesNoTitle.endsWith('?') ? yesNoTitle : `${yesNoTitle}?`; | |
| const dynamicMarket: MarketDetail = { | |
| slug, | |
| title: yesNoTitle.replace(/\?$/, ''), | |
| tag, | |
| closesAt: closesAtStr, | |
| description: `Binary Yes/No market: ${yesNoQuestion} This market is dynamically generated and analyzed across multiple prediction market sources.`, | |
| question: yesNoQuestion, | |
| resolutionCriteriaUrl: `https://wagerkit.xyz/resolution/${slug}`, | |
| sources: [ | |
| { name: 'PredictIt', type: 'regulated' }, | |
| { name: 'Kalshi', type: 'regulated' }, | |
| { name: 'Polymarket', type: 'onchain' }, | |
| { name: 'WagerKit', type: 'onchain' }, | |
| ], | |
| integrityScore: { overall: 0, marketClarity: 0, liquidityDepth: 0, crossSourceAgreement: 0, volatilitySanity: 0 }, | |
| notes: ['Dynamically generated market', 'Real-time analysis'], | |
| simulatedMetrics: { | |
| ticksPerHour: 20 + (hashVal % 40), | |
| dailyVolumeK: 500 + (hashVal % 2000), | |
| dataCompleteness: 0.6 + ((hashVal % 35) / 100), | |
| }, | |
| oddsHistory: [], | |
| }; | |
| const oddsHistory = this.generateOddsHistory(slug); | |
| dynamicMarket.oddsHistory = oddsHistory; | |
| dynamicMarket.integrityScore = this.calculateIntegrityScore(dynamicMarket, oddsHistory); | |
| return dynamicMarket; | |
| } | |
| /** | |
| * Search markets by query — returns matches from existing + generates dynamic results | |
| */ | |
| searchMarkets(query: string): MarketSummary[] { | |
| const q = query.toLowerCase().trim(); | |
| if (!q) return this.getMarkets(); | |
| // First, check existing markets for partial match | |
| const matches = this.marketsData | |
| .filter((m) => | |
| m.title.toLowerCase().includes(q) || | |
| m.tag.toLowerCase().includes(q) || | |
| m.description.toLowerCase().includes(q) || | |
| m.slug.toLowerCase().includes(q), | |
| ) | |
| .map(({ slug, title, tag, closesAt }) => ({ slug, title, tag, closesAt })); | |
| // Always add the user's query as a dynamic market option | |
| const dynamicSlug = this.slugify(query); | |
| const alreadyExists = matches.some((m) => m.slug === dynamicSlug); | |
| if (!alreadyExists) { | |
| const hashVal = Math.abs(this.hashCode(dynamicSlug)); | |
| const daysOut = 30 + (hashVal % 150); | |
| const closesAt = new Date(Date.now() + daysOut * 86400000); | |
| const yesNoTitle = query.toLowerCase().startsWith('will') | |
| ? this.titleCase(query) | |
| : `Will ${this.titleCase(query).replace(/\?$/, '')} Happen`; | |
| matches.unshift({ | |
| slug: dynamicSlug, | |
| title: yesNoTitle.replace(/\?$/, ''), | |
| tag: this.detectTag(query), | |
| closesAt: `${closesAt.getMonth() + 1}/${closesAt.getDate()}/${closesAt.getFullYear()}`, | |
| }); | |
| } | |
| return matches; | |
| } | |
| private slugify(text: string): string { | |
| return text | |
| .toLowerCase() | |
| .trim() | |
| .replace(/[^a-z0-9\s-]/g, '') | |
| .replace(/\s+/g, '_') | |
| .replace(/-+/g, '_') | |
| .substring(0, 60); | |
| } | |
| private titleCase(text: string): string { | |
| return text | |
| .split(' ') | |
| .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) | |
| .join(' '); | |
| } | |
| private detectTag(query: string): string { | |
| const q = query.toLowerCase(); | |
| if (/bitcoin|btc|eth|crypto|token|defi|halving/i.test(q)) return 'crypto'; | |
| if (/election|president|senate|congress|vote|trump|biden|governor/i.test(q)) return 'election'; | |
| if (/gdp|cpi|inflation|rate|fed|economy|macro|unemployment|treasury/i.test(q)) return 'macro'; | |
| if (/nfl|nba|mlb|soccer|sport|game|team|champion|playoff|world cup/i.test(q)) return 'sports'; | |
| if (/ai|tech|apple|google|meta|nvidia|startup|ipo/i.test(q)) return 'tech'; | |
| if (/oscar|grammy|emmy|movie|film|entertainment|music/i.test(q)) return 'entertainment'; | |
| if (/weather|climate|hurricane|earthquake|natural/i.test(q)) return 'weather'; | |
| if (/war|conflict|geopolit|nato|china|russia|ukraine/i.test(q)) return 'geopolitics'; | |
| return 'general'; | |
| } | |
| private hashCode(s: string): number { | |
| let h = 0; | |
| for (let i = 0; i < s.length; i++) { | |
| h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; | |
| } | |
| return h; | |
| } | |
| getMarketDetail(slug: string): MarketDetail | null { | |
| const market = this.marketsData.find((m) => m.slug === slug); | |
| if (!market) { | |
| // Try to generate a dynamic market from the slug | |
| const query = slug.replace(/_/g, ' '); | |
| if (query.length < 3) return null; | |
| return this.searchOrCreateMarket(query); | |
| } | |
| const oddsHistory = this.generateOddsHistory(slug); | |
| // Use pre-configured scores for demo consistency | |
| // In production, these would be calculated from real DOME API data: | |
| // const computedScore = this.calculateIntegrityScore(market, oddsHistory); | |
| return { | |
| ...market, | |
| oddsHistory, | |
| }; | |
| } | |
| getOddsHistoryCsv(slug: string): string | null { | |
| const market = this.marketsData.find((m) => m.slug === slug); | |
| if (!market) return null; | |
| const oddsHistory = this.generateOddsHistory(slug); | |
| const header = 'Timestamp,Polymarket,Kalshi,PredictIt,WagerKit\n'; | |
| const rows = oddsHistory | |
| .map( | |
| (p) => | |
| `${p.timestamp},${p.polymarket},${p.kalshi},${p.predictit},${p.wagerkit}`, | |
| ) | |
| .join('\n'); | |
| return header + rows; | |
| } | |
| getIntegrityCsv(slug: string): string | null { | |
| const market = this.marketsData.find((m) => m.slug === slug); | |
| if (!market) return null; | |
| const score = market.integrityScore; | |
| const header = 'Metric,Value,Weight\n'; | |
| const rows = [ | |
| `Overall Score,${score.overall},100%`, | |
| `Market Clarity,${score.marketClarity}%,40%`, | |
| `Liquidity Depth,${score.liquidityDepth}%,30%`, | |
| `Cross-Source Agreement,${score.crossSourceAgreement}%,20%`, | |
| `Volatility Sanity,${score.volatilitySanity}%,10%`, | |
| ].join('\n'); | |
| return header + rows; | |
| } | |
| getDossierJson(slug: string): object | null { | |
| const detail = this.getMarketDetail(slug); | |
| if (!detail) return null; | |
| return { | |
| generatedAt: new Date().toISOString(), | |
| platform: 'WagerKit', | |
| market: { | |
| title: detail.title, | |
| slug: detail.slug, | |
| description: detail.description, | |
| tag: detail.tag, | |
| closesAt: detail.closesAt, | |
| question: detail.question, | |
| }, | |
| integrityScore: detail.integrityScore, | |
| sources: detail.sources, | |
| notes: detail.notes, | |
| oddsHistory: detail.oddsHistory, | |
| }; | |
| } | |
| } | |