From b8691d9ad0dbfffbe577b04cad40dea50eb29830 Mon Sep 17 00:00:00 2001 From: Felarof Date: Sun, 22 Feb 2026 20:50:29 -0800 Subject: [PATCH 1/6] feat: add kalshi-feed TikTok-style prediction market extension Chrome extension that opens as a side panel with a TikTok-style vertical swipe feed of Kalshi prediction markets. Features: - Full-viewport vertical scroll-snap cards - YES/NO price bars with probability visualization - Category-based gradient backgrounds (politics, crypto, sports, etc.) - Category filter tabs (trending, all, by category) - "Buy" buttons that deep-link to kalshi.com - Fake data populated for 12 markets - Clear SCHEMA.md documenting the API contract for backend integration Built with WXT, React 19, and Tailwind CSS v4. Co-Authored-By: Claude Opus 4.6 --- apps/kalshi-feed/.gitignore | 4 + apps/kalshi-feed/SCHEMA.md | 95 +++++++ apps/kalshi-feed/biome.json | 36 +++ .../entrypoints/background/index.ts | 3 + .../kalshi-feed/entrypoints/sidepanel/App.tsx | 6 + .../sidepanel/components/CategoryTabs.tsx | 43 +++ .../entrypoints/sidepanel/components/Feed.tsx | 53 ++++ .../sidepanel/components/MarketCard.tsx | 153 +++++++++++ .../entrypoints/sidepanel/index.html | 12 + .../entrypoints/sidepanel/main.tsx | 14 + apps/kalshi-feed/lib/data/fake-markets.ts | 244 ++++++++++++++++++ apps/kalshi-feed/lib/types/market.ts | 45 ++++ apps/kalshi-feed/lib/utils/cn.ts | 6 + apps/kalshi-feed/lib/utils/format.ts | 40 +++ apps/kalshi-feed/lib/utils/gradients.ts | 30 +++ apps/kalshi-feed/package.json | 34 +++ apps/kalshi-feed/styles/global.css | 107 ++++++++ apps/kalshi-feed/tsconfig.json | 12 + apps/kalshi-feed/wxt.config.ts | 21 ++ bun.lock | 24 ++ 20 files changed, 982 insertions(+) create mode 100644 apps/kalshi-feed/.gitignore create mode 100644 apps/kalshi-feed/SCHEMA.md create mode 100644 apps/kalshi-feed/biome.json create mode 100644 apps/kalshi-feed/entrypoints/background/index.ts create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/App.tsx create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/index.html create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/main.tsx create mode 100644 apps/kalshi-feed/lib/data/fake-markets.ts create mode 100644 apps/kalshi-feed/lib/types/market.ts create mode 100644 apps/kalshi-feed/lib/utils/cn.ts create mode 100644 apps/kalshi-feed/lib/utils/format.ts create mode 100644 apps/kalshi-feed/lib/utils/gradients.ts create mode 100644 apps/kalshi-feed/package.json create mode 100644 apps/kalshi-feed/styles/global.css create mode 100644 apps/kalshi-feed/tsconfig.json create mode 100644 apps/kalshi-feed/wxt.config.ts diff --git a/apps/kalshi-feed/.gitignore b/apps/kalshi-feed/.gitignore new file mode 100644 index 00000000..93a92483 --- /dev/null +++ b/apps/kalshi-feed/.gitignore @@ -0,0 +1,4 @@ +dist/ +.output/ +.wxt/ +node_modules/ diff --git a/apps/kalshi-feed/SCHEMA.md b/apps/kalshi-feed/SCHEMA.md new file mode 100644 index 00000000..2a4a9163 --- /dev/null +++ b/apps/kalshi-feed/SCHEMA.md @@ -0,0 +1,95 @@ +# Kalshi Feed — Backend API Schema + +The frontend expects data from a Supabase backend. Below is the exact schema the backend should populate. + +## Supabase Table: `markets` + +| Column | Type | Description | +|--------|------|-------------| +| `id` | `uuid` | Primary key | +| `ticker` | `text` | Kalshi market ticker (e.g. `KXFEDRATE-26MAR-B4.5`) | +| `title` | `text` | Market question (e.g. "Will the Fed cut rates in March 2026?") | +| `subtitle` | `text` | Short context line | +| `category` | `text` | One of: `politics`, `sports`, `crypto`, `economics`, `entertainment`, `science`, `weather`, `tech` | +| `yes_price` | `integer` | Current YES price in cents (0-100) | +| `no_price` | `integer` | Current NO price in cents (0-100, should equal 100 - yes_price) | +| `volume` | `bigint` | Total volume in cents | +| `volume_24h` | `bigint` | 24-hour volume in cents | +| `open_interest` | `bigint` | Open interest in cents | +| `close_time` | `timestamptz` | When the market closes | +| `image_url` | `text` | Optional market image URL | +| `kalshi_url` | `text` | Direct link to market on kalshi.com | +| `status` | `text` | One of: `open`, `closed`, `settled` | +| `last_price` | `integer` | Last trade price in cents | +| `price_change_24h` | `integer` | Price change in last 24h (percentage points) | +| `traders_count` | `integer` | Number of unique traders | +| `created_at` | `timestamptz` | When the market was created on Kalshi | + +## API Endpoint: `GET /feed` + +### Query Parameters +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `category` | `string` | `trending` | Filter by category or `trending`/`all` | +| `cursor` | `string` | `null` | Pagination cursor | +| `limit` | `integer` | `20` | Number of markets per page | + +### Response Shape +```json +{ + "markets": [ + { + "id": "uuid", + "ticker": "KXFEDRATE-26MAR-B4.5", + "title": "Will the Fed cut rates in March 2026?", + "subtitle": "Federal Reserve interest rate decision", + "category": "economics", + "yes_price": 67, + "no_price": 33, + "volume": 2847293, + "volume_24h": 482100, + "open_interest": 1200000, + "close_time": "2026-03-19T18:00:00Z", + "image_url": null, + "kalshi_url": "https://kalshi.com/markets/kxfedrate", + "status": "open", + "last_price": 67, + "price_change_24h": 3, + "traders_count": 18432, + "created_at": "2026-01-15T00:00:00Z" + } + ], + "cursor": "next_page_token_or_null", + "has_more": true +} +``` + +## Feed Ranking Algorithm (for `trending`) + +``` +score = ( + volume_24h * 0.3 + + controversy_score * 0.25 + + recency_boost * 0.2 + + closing_soon_boost * 0.15 + + category_diversity * 0.1 +) + +controversy_score = 1 - abs(yes_price - 50) / 50 +``` + +Markets closest to 50/50 with high volume rank highest. + +## Kalshi Public API (data source for scraper) + +Base URL: `https://api.elections.kalshi.com/trade-api/v2` + +No auth required for market data: +- `GET /markets?status=open&limit=200` — all open markets +- `GET /events/{event_ticker}` — event details +- `GET /markets/{ticker}/orderbook` — live orderbook +- `GET /markets/{ticker}/trades` — trade history + +## TypeScript Types + +See `lib/types/market.ts` for the exact TypeScript interface the frontend uses. diff --git a/apps/kalshi-feed/biome.json b/apps/kalshi-feed/biome.json new file mode 100644 index 00000000..496933c9 --- /dev/null +++ b/apps/kalshi-feed/biome.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "root": false, + "extends": "//", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + } +} diff --git a/apps/kalshi-feed/entrypoints/background/index.ts b/apps/kalshi-feed/entrypoints/background/index.ts new file mode 100644 index 00000000..2951e52e --- /dev/null +++ b/apps/kalshi-feed/entrypoints/background/index.ts @@ -0,0 +1,3 @@ +export default defineBackground(() => { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) +}) diff --git a/apps/kalshi-feed/entrypoints/sidepanel/App.tsx b/apps/kalshi-feed/entrypoints/sidepanel/App.tsx new file mode 100644 index 00000000..fb9d3387 --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/App.tsx @@ -0,0 +1,6 @@ +import type { FC } from 'react' +import { Feed } from './components/Feed' + +export const App: FC = () => { + return +} diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx new file mode 100644 index 00000000..cc5a08b3 --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx @@ -0,0 +1,43 @@ +import type { FC } from 'react' +import type { FeedCategory } from '@/lib/types/market' +import { cn } from '@/lib/utils/cn' + +interface CategoryTabsProps { + activeCategory: FeedCategory + onCategoryChange: (category: FeedCategory) => void +} + +const CATEGORIES: { key: FeedCategory; label: string }[] = [ + { key: 'trending', label: '\u{1F525} Trending' }, + { key: 'all', label: 'All' }, + { key: 'politics', label: '\u{1F3DB} Politics' }, + { key: 'sports', label: '\u{26BD} Sports' }, + { key: 'crypto', label: '\u{1FA99} Crypto' }, + { key: 'economics', label: '\u{1F4C8} Economics' }, + { key: 'entertainment', label: '\u{1F3AC} Entertainment' }, +] + +export const CategoryTabs: FC = ({ + activeCategory, + onCategoryChange, +}) => { + return ( +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ ) +} diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx new file mode 100644 index 00000000..2ff18561 --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx @@ -0,0 +1,53 @@ +import { type FC, useRef, useState } from 'react' +import { FAKE_MARKETS } from '@/lib/data/fake-markets' +import type { FeedCategory } from '@/lib/types/market' +import { CategoryTabs } from './CategoryTabs' +import { MarketCard } from './MarketCard' + +export const Feed: FC = () => { + const [activeCategory, setActiveCategory] = useState('trending') + const feedRef = useRef(null) + + const filteredMarkets = + activeCategory === 'trending' || activeCategory === 'all' + ? FAKE_MARKETS + : FAKE_MARKETS.filter((m) => m.category === activeCategory) + + const handleCategoryChange = (category: FeedCategory) => { + setActiveCategory(category) + if (feedRef.current) { + feedRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + } + + return ( +
+
+
+
+

Kalshi Feed

+
+ +
+
+ +
+ {filteredMarkets.length > 0 ? ( + filteredMarkets.map((market) => ( + + )) + ) : ( +
+

No markets in this category

+
+ )} +
+
+ ) +} diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx new file mode 100644 index 00000000..9788d8fd --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx @@ -0,0 +1,153 @@ +import { + ArrowDown, + ArrowUp, + Clock, + ExternalLink, + TrendingUp, + Users, +} from 'lucide-react' +import type { FC } from 'react' +import type { Market } from '@/lib/types/market' +import { cn } from '@/lib/utils/cn' +import { + formatTimeRemaining, + formatTraders, + formatVolume, +} from '@/lib/utils/format' +import { getCategoryEmoji, getCategoryGradient } from '@/lib/utils/gradients' + +interface MarketCardProps { + market: Market +} + +export const MarketCard: FC = ({ market }) => { + const timeRemaining = formatTimeRemaining(market.close_time) + const isClosingSoon = + timeRemaining.includes('h ') || timeRemaining.includes('m ') + const gradient = getCategoryGradient(market.category) + const emoji = getCategoryEmoji(market.category) + + const handleBuyYes = () => { + window.open(market.kalshi_url, '_blank') + } + + const handleBuyNo = () => { + window.open(market.kalshi_url, '_blank') + } + + return ( +
+
+
+ +
+
+
+ + {emoji} {market.category} + + {isClosingSoon && ( + + + Closing soon + + )} +
+ + + {timeRemaining} + +
+ +
+
+

+ {market.title} +

+

{market.subtitle}

+
+ +
+
+ + Probability + +
+ {market.price_change_24h > 0 ? ( + + ) : market.price_change_24h < 0 ? ( + + ) : null} + 0 + ? 'text-yes-green' + : 'text-no-red', + )} + > + {market.price_change_24h > 0 ? '+' : ''} + {market.price_change_24h}% + +
+
+
+
+
+
+
+ + Yes {market.yes_price}¢ + + + No {market.no_price}¢ + +
+
+
+ +
+
+ + + {formatVolume(market.volume_24h)} 24h vol + + + + {formatTraders(market.traders_count)} traders + +
+ +
+ + +
+ +

+ Tap to place trade on Kalshi +

+
+
+
+ ) +} diff --git a/apps/kalshi-feed/entrypoints/sidepanel/index.html b/apps/kalshi-feed/entrypoints/sidepanel/index.html new file mode 100644 index 00000000..75703aea --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/index.html @@ -0,0 +1,12 @@ + + + + + + Kalshi Feed + + +
+ + + diff --git a/apps/kalshi-feed/entrypoints/sidepanel/main.tsx b/apps/kalshi-feed/entrypoints/sidepanel/main.tsx new file mode 100644 index 00000000..b0cde1b4 --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import '@/styles/global.css' +import { App } from './App' + +const root = document.getElementById('root') + +if (root) { + ReactDOM.createRoot(root).render( + + + , + ) +} diff --git a/apps/kalshi-feed/lib/data/fake-markets.ts b/apps/kalshi-feed/lib/data/fake-markets.ts new file mode 100644 index 00000000..754b3d06 --- /dev/null +++ b/apps/kalshi-feed/lib/data/fake-markets.ts @@ -0,0 +1,244 @@ +import type { Market } from '@/lib/types/market' + +export const FAKE_MARKETS: Market[] = [ + { + id: '1', + ticker: 'KXFEDRATE-26MAR-B4.5', + title: 'Will the Fed cut rates in March 2026?', + subtitle: 'Federal Reserve interest rate decision', + category: 'economics', + yes_price: 67, + no_price: 33, + volume: 2_847_293, + volume_24h: 482_100, + open_interest: 1_200_000, + close_time: '2026-03-19T18:00:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxfedrate', + status: 'open', + last_price: 67, + price_change_24h: 3, + traders_count: 18_432, + created_at: '2026-01-15T00:00:00Z', + }, + { + id: '2', + ticker: 'KXBTC-26FEB-B100K', + title: 'Will Bitcoin hit $100K by end of February?', + subtitle: 'BTC price prediction', + category: 'crypto', + yes_price: 42, + no_price: 58, + volume: 5_120_000, + volume_24h: 890_000, + open_interest: 3_200_000, + close_time: '2026-02-28T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxbtc', + status: 'open', + last_price: 42, + price_change_24h: -5, + traders_count: 32_100, + created_at: '2026-01-01T00:00:00Z', + }, + { + id: '3', + ticker: 'KXSB-26FEB-CHIEFS', + title: 'Will the Chiefs win Super Bowl LX?', + subtitle: 'NFL Championship 2026', + category: 'sports', + yes_price: 55, + no_price: 45, + volume: 8_450_000, + volume_24h: 1_200_000, + open_interest: 4_500_000, + close_time: '2026-02-08T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxsb', + status: 'open', + last_price: 55, + price_change_24h: 2, + traders_count: 45_000, + created_at: '2025-09-01T00:00:00Z', + }, + { + id: '4', + ticker: 'KXELECTION-28NOV', + title: 'Will the Democratic candidate win in 2028?', + subtitle: 'US Presidential Election 2028', + category: 'politics', + yes_price: 48, + no_price: 52, + volume: 12_300_000, + volume_24h: 2_100_000, + open_interest: 8_000_000, + close_time: '2028-11-03T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxelection', + status: 'open', + last_price: 48, + price_change_24h: -1, + traders_count: 89_000, + created_at: '2025-11-15T00:00:00Z', + }, + { + id: '5', + ticker: 'KXOSCARS-26MAR', + title: 'Will "The Brutalist" win Best Picture at the Oscars?', + subtitle: '98th Academy Awards', + category: 'entertainment', + yes_price: 38, + no_price: 62, + volume: 1_890_000, + volume_24h: 345_000, + open_interest: 900_000, + close_time: '2026-03-02T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxoscars', + status: 'open', + last_price: 38, + price_change_24h: 5, + traders_count: 12_400, + created_at: '2026-01-20T00:00:00Z', + }, + { + id: '6', + ticker: 'KXHIGHNY-26FEB22-B50', + title: 'NYC high temp above 50\u00B0F today?', + subtitle: 'New York City weather', + category: 'weather', + yes_price: 72, + no_price: 28, + volume: 420_000, + volume_24h: 89_000, + open_interest: 150_000, + close_time: '2026-02-22T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxhighny', + status: 'open', + last_price: 72, + price_change_24h: 8, + traders_count: 3_200, + created_at: '2026-02-22T00:00:00Z', + }, + { + id: '7', + ticker: 'KXETH-26MAR-B5K', + title: 'Will Ethereum hit $5,000 by end of March?', + subtitle: 'ETH price prediction', + category: 'crypto', + yes_price: 23, + no_price: 77, + volume: 3_200_000, + volume_24h: 560_000, + open_interest: 1_800_000, + close_time: '2026-03-31T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxeth', + status: 'open', + last_price: 23, + price_change_24h: -2, + traders_count: 15_600, + created_at: '2026-01-10T00:00:00Z', + }, + { + id: '8', + ticker: 'KXAI-26Q1-AGI', + title: 'Will OpenAI release GPT-5 in Q1 2026?', + subtitle: 'AI model release prediction', + category: 'tech', + yes_price: 31, + no_price: 69, + volume: 4_100_000, + volume_24h: 720_000, + open_interest: 2_200_000, + close_time: '2026-03-31T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxai', + status: 'open', + last_price: 31, + price_change_24h: -8, + traders_count: 28_000, + created_at: '2025-12-01T00:00:00Z', + }, + { + id: '9', + ticker: 'KXGDP-26Q1-B3', + title: 'Will US GDP growth exceed 3% in Q1 2026?', + subtitle: 'Economic growth forecast', + category: 'economics', + yes_price: 35, + no_price: 65, + volume: 1_950_000, + volume_24h: 280_000, + open_interest: 1_100_000, + close_time: '2026-04-30T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxgdp', + status: 'open', + last_price: 35, + price_change_24h: 1, + traders_count: 8_900, + created_at: '2026-01-05T00:00:00Z', + }, + { + id: '10', + ticker: 'KXMMA-26MAR-JONES', + title: 'Will Jon Jones retain UFC Heavyweight title?', + subtitle: 'UFC Championship bout', + category: 'sports', + yes_price: 62, + no_price: 38, + volume: 6_700_000, + volume_24h: 1_450_000, + open_interest: 3_800_000, + close_time: '2026-03-15T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxmma', + status: 'open', + last_price: 62, + price_change_24h: 4, + traders_count: 52_000, + created_at: '2026-01-20T00:00:00Z', + }, + { + id: '11', + ticker: 'KXSPACEX-26H1', + title: 'Will SpaceX complete a Starship orbital flight in H1 2026?', + subtitle: 'Space exploration milestone', + category: 'science', + yes_price: 78, + no_price: 22, + volume: 2_340_000, + volume_24h: 410_000, + open_interest: 1_400_000, + close_time: '2026-06-30T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxspacex', + status: 'open', + last_price: 78, + price_change_24h: 2, + traders_count: 19_500, + created_at: '2025-12-15T00:00:00Z', + }, + { + id: '12', + ticker: 'KXTIKTOK-26MAR', + title: 'Will TikTok be banned in the US by March 2026?', + subtitle: 'Social media regulation', + category: 'politics', + yes_price: 15, + no_price: 85, + volume: 9_800_000, + volume_24h: 1_800_000, + open_interest: 5_500_000, + close_time: '2026-03-31T23:59:00Z', + image_url: null, + kalshi_url: 'https://kalshi.com/markets/kxtiktok', + status: 'open', + last_price: 15, + price_change_24h: -3, + traders_count: 67_000, + created_at: '2025-10-01T00:00:00Z', + }, +] diff --git a/apps/kalshi-feed/lib/types/market.ts b/apps/kalshi-feed/lib/types/market.ts new file mode 100644 index 00000000..d6c98aee --- /dev/null +++ b/apps/kalshi-feed/lib/types/market.ts @@ -0,0 +1,45 @@ +export type MarketCategory = + | 'politics' + | 'sports' + | 'crypto' + | 'economics' + | 'entertainment' + | 'science' + | 'weather' + | 'tech' + +export interface Market { + id: string + ticker: string + title: string + subtitle: string + category: MarketCategory + yes_price: number + no_price: number + volume: number + volume_24h: number + open_interest: number + close_time: string + image_url: string | null + kalshi_url: string + status: 'open' | 'closed' | 'settled' + last_price: number + price_change_24h: number + traders_count: number + created_at: string +} + +export interface FeedResponse { + markets: Market[] + cursor: string | null + has_more: boolean +} + +export type FeedCategory = + | 'trending' + | 'politics' + | 'sports' + | 'crypto' + | 'economics' + | 'entertainment' + | 'all' diff --git a/apps/kalshi-feed/lib/utils/cn.ts b/apps/kalshi-feed/lib/utils/cn.ts new file mode 100644 index 00000000..d32b0fe6 --- /dev/null +++ b/apps/kalshi-feed/lib/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/kalshi-feed/lib/utils/format.ts b/apps/kalshi-feed/lib/utils/format.ts new file mode 100644 index 00000000..73ba5692 --- /dev/null +++ b/apps/kalshi-feed/lib/utils/format.ts @@ -0,0 +1,40 @@ +export function formatVolume(volume: number): string { + if (volume >= 1_000_000) { + return `$${(volume / 1_000_000).toFixed(1)}M` + } + if (volume >= 1_000) { + return `$${(volume / 1_000).toFixed(0)}K` + } + return `$${volume}` +} + +export function formatTimeRemaining(closeTime: string): string { + const now = new Date() + const close = new Date(closeTime) + const diffMs = close.getTime() - now.getTime() + + if (diffMs < 0) return 'Closed' + + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + + if (diffDays > 30) { + const months = Math.floor(diffDays / 30) + return `${months}mo left` + } + if (diffDays > 0) { + return `${diffDays}d left` + } + if (diffHours > 0) { + return `${diffHours}h left` + } + const diffMinutes = Math.floor(diffMs / (1000 * 60)) + return `${diffMinutes}m left` +} + +export function formatTraders(count: number): string { + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}K` + } + return `${count}` +} diff --git a/apps/kalshi-feed/lib/utils/gradients.ts b/apps/kalshi-feed/lib/utils/gradients.ts new file mode 100644 index 00000000..aea11198 --- /dev/null +++ b/apps/kalshi-feed/lib/utils/gradients.ts @@ -0,0 +1,30 @@ +import type { MarketCategory } from '@/lib/types/market' + +const CATEGORY_GRADIENTS: Record = { + politics: 'from-blue-900/80 via-red-900/60 to-purple-900/80', + sports: 'from-green-900/80 via-emerald-900/60 to-teal-900/80', + crypto: 'from-purple-900/80 via-violet-900/60 to-indigo-900/80', + economics: 'from-amber-900/80 via-yellow-900/60 to-orange-900/80', + entertainment: 'from-pink-900/80 via-rose-900/60 to-fuchsia-900/80', + science: 'from-cyan-900/80 via-sky-900/60 to-blue-900/80', + weather: 'from-sky-900/80 via-blue-900/60 to-indigo-900/80', + tech: 'from-slate-900/80 via-zinc-900/60 to-neutral-900/80', +} + +export function getCategoryGradient(category: MarketCategory): string { + return CATEGORY_GRADIENTS[category] ?? CATEGORY_GRADIENTS.economics +} + +export function getCategoryEmoji(category: MarketCategory): string { + const emojis: Record = { + politics: '\u{1F3DB}', + sports: '\u{26BD}', + crypto: '\u{1FA99}', + economics: '\u{1F4C8}', + entertainment: '\u{1F3AC}', + science: '\u{1F52C}', + weather: '\u{26C5}', + tech: '\u{1F4BB}', + } + return emojis[category] ?? '\u{1F4CA}' +} diff --git a/apps/kalshi-feed/package.json b/apps/kalshi-feed/package.json new file mode 100644 index 00000000..9636154f --- /dev/null +++ b/apps/kalshi-feed/package.json @@ -0,0 +1,34 @@ +{ + "name": "@browseros/kalshi-feed", + "description": "TikTok-style prediction market feed for Kalshi", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "wxt", + "build": "wxt build", + "build:dev": "wxt build --mode development", + "zip": "wxt zip", + "typecheck": "tsc --noEmit", + "lint": "bunx biome check", + "lint:fix": "bunx biome check --write --unsafe" + }, + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.17", + "@types/chrome": "^0.1.28", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@wxt-dev/module-react": "^1.1.3", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.2", + "wxt": "^0.20.6" + } +} diff --git a/apps/kalshi-feed/styles/global.css b/apps/kalshi-feed/styles/global.css new file mode 100644 index 00000000..94757193 --- /dev/null +++ b/apps/kalshi-feed/styles/global.css @@ -0,0 +1,107 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: #000000; + --foreground: #ffffff; + --card: #111111; + --card-foreground: #ffffff; + --muted: #1a1a1a; + --muted-foreground: #888888; + --border: #222222; + --yes-green: #00c853; + --yes-green-dark: #00a844; + --no-red: #ff1744; + --no-red-dark: #d50000; + --accent-blue: #2979ff; + --accent-purple: #7c4dff; + --accent-orange: #ff6d00; + --accent-cyan: #00e5ff; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-yes-green: var(--yes-green); + --color-yes-green-dark: var(--yes-green-dark); + --color-no-red: var(--no-red); + --color-no-red-dark: var(--no-red-dark); + --color-accent-blue: var(--accent-blue); + --color-accent-purple: var(--accent-purple); + --color-accent-orange: var(--accent-orange); + --color-accent-cyan: var(--accent-cyan); + + --animate-slide-up: slide-up 0.3s ease-out; + --animate-fade-in: fade-in 0.2s ease-out; + --animate-pulse-green: pulse-green 0.6s ease-out; + --animate-pulse-red: pulse-red 0.6s ease-out; +} + +@layer base { + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + body { + background: var(--background); + color: var(--foreground); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + width: 100%; + height: 100vh; + } + + html { + overflow: hidden; + } + + @keyframes slide-up { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes pulse-green { + 0% { + box-shadow: 0 0 0 0 rgba(0, 200, 83, 0.6); + } + 100% { + box-shadow: 0 0 0 20px rgba(0, 200, 83, 0); + } + } + + @keyframes pulse-red { + 0% { + box-shadow: 0 0 0 0 rgba(255, 23, 68, 0.6); + } + 100% { + box-shadow: 0 0 0 20px rgba(255, 23, 68, 0); + } + } +} diff --git a/apps/kalshi-feed/tsconfig.json b/apps/kalshi-feed/tsconfig.json new file mode 100644 index 00000000..f2cb3db6 --- /dev/null +++ b/apps/kalshi-feed/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "types": ["chrome"], + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/apps/kalshi-feed/wxt.config.ts b/apps/kalshi-feed/wxt.config.ts new file mode 100644 index 00000000..abc7586d --- /dev/null +++ b/apps/kalshi-feed/wxt.config.ts @@ -0,0 +1,21 @@ +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'wxt' + +export default defineConfig({ + outDir: 'dist', + modules: ['@wxt-dev/module-react'], + manifest: { + name: 'Kalshi Feed', + description: 'TikTok-style prediction market feed powered by Kalshi', + action: { + default_title: 'Kalshi Feed', + }, + permissions: ['sidePanel', 'storage'], + side_panel: { + default_path: 'sidepanel.html', + }, + }, + vite: () => ({ + plugins: [tailwindcss()], + }), +}) diff --git a/bun.lock b/bun.lock index d455ab7d..18a533a0 100644 --- a/bun.lock +++ b/bun.lock @@ -136,6 +136,28 @@ "ws": "^8.18.3", }, }, + "apps/kalshi-feed": { + "name": "@browseros/kalshi-feed", + "version": "0.0.1", + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.17", + "@types/chrome": "^0.1.28", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@wxt-dev/module-react": "^1.1.3", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.2", + "wxt": "^0.20.6", + }, + }, "apps/server": { "name": "@browseros/server", "version": "0.0.52", @@ -349,6 +371,8 @@ "@browseros/agent": ["@browseros/agent@workspace:apps/agent"], + "@browseros/kalshi-feed": ["@browseros/kalshi-feed@workspace:apps/kalshi-feed"], + "@browseros/server": ["@browseros/server@workspace:apps/server"], "@browseros/shared": ["@browseros/shared@workspace:packages/shared"], From fa519b987c0fd1c5426613e864c3081c26ee7054 Mon Sep 17 00:00:00 2001 From: Felarof Date: Sun, 22 Feb 2026 21:15:25 -0800 Subject: [PATCH 2/6] feat: add kalshi-scraper backend service with API + Browserbase scraping Hono REST API that ingests Kalshi prediction markets via their public API and Browserbase cloud browser scraping, stores in Supabase via Drizzle ORM, and serves a ranked TikTok-style feed for the kalshi-feed extension. Co-Authored-By: Claude Opus 4.6 --- .../sidepanel/components/BottomNav.tsx | 43 ++++ .../sidepanel/components/CategoryTabs.tsx | 43 ---- .../entrypoints/sidepanel/components/Feed.tsx | 54 ++--- .../sidepanel/components/MarketCard.tsx | 215 ++++++++---------- .../sidepanel/components/TopBar.tsx | 27 +++ apps/kalshi-feed/lib/data/fake-markets.ts | 96 +++++++- apps/kalshi-feed/lib/types/market.ts | 7 + apps/kalshi-feed/lib/utils/format.ts | 37 +-- apps/kalshi-feed/lib/utils/gradients.ts | 38 +--- apps/kalshi-scraper/drizzle.config.ts | 10 + apps/kalshi-scraper/package.json | 28 +++ apps/kalshi-scraper/scripts/test-api.ts | 77 +++++++ apps/kalshi-scraper/src/config.ts | 26 +++ apps/kalshi-scraper/src/db/index.ts | 10 + apps/kalshi-scraper/src/db/schema.ts | 41 ++++ apps/kalshi-scraper/src/index.ts | 40 ++++ apps/kalshi-scraper/src/routes/feed.ts | 71 ++++++ apps/kalshi-scraper/src/routes/scrape.ts | 192 ++++++++++++++++ .../src/services/browserbase-scraper.ts | 135 +++++++++++ .../src/services/category-mapper.ts | 70 ++++++ .../kalshi-scraper/src/services/kalshi-api.ts | 145 ++++++++++++ apps/kalshi-scraper/src/services/ranking.ts | 82 +++++++ apps/kalshi-scraper/tsconfig.json | 16 ++ 23 files changed, 1248 insertions(+), 255 deletions(-) create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx delete mode 100644 apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx create mode 100644 apps/kalshi-feed/entrypoints/sidepanel/components/TopBar.tsx create mode 100644 apps/kalshi-scraper/drizzle.config.ts create mode 100644 apps/kalshi-scraper/package.json create mode 100644 apps/kalshi-scraper/scripts/test-api.ts create mode 100644 apps/kalshi-scraper/src/config.ts create mode 100644 apps/kalshi-scraper/src/db/index.ts create mode 100644 apps/kalshi-scraper/src/db/schema.ts create mode 100644 apps/kalshi-scraper/src/index.ts create mode 100644 apps/kalshi-scraper/src/routes/feed.ts create mode 100644 apps/kalshi-scraper/src/routes/scrape.ts create mode 100644 apps/kalshi-scraper/src/services/browserbase-scraper.ts create mode 100644 apps/kalshi-scraper/src/services/category-mapper.ts create mode 100644 apps/kalshi-scraper/src/services/kalshi-api.ts create mode 100644 apps/kalshi-scraper/src/services/ranking.ts create mode 100644 apps/kalshi-scraper/tsconfig.json diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx new file mode 100644 index 00000000..0dd5da99 --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx @@ -0,0 +1,43 @@ +import { Home, Play, PlusSquare, Trophy, User } from 'lucide-react' +import type { FC } from 'react' + +const NAV_ITEMS = [ + { icon: Home, label: 'Home' }, + { icon: Play, label: 'Feed', active: true }, + { icon: PlusSquare, label: '', isCenter: true }, + { icon: Trophy, label: 'Ranking' }, + { icon: User, label: 'Profile' }, +] as const + +export const BottomNav: FC = () => { + return ( +
+ {NAV_ITEMS.map((item) => + item.isCenter ? ( + + ) : ( + + ), + )} +
+ ) +} diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx deleted file mode 100644 index cc5a08b3..00000000 --- a/apps/kalshi-feed/entrypoints/sidepanel/components/CategoryTabs.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { FC } from 'react' -import type { FeedCategory } from '@/lib/types/market' -import { cn } from '@/lib/utils/cn' - -interface CategoryTabsProps { - activeCategory: FeedCategory - onCategoryChange: (category: FeedCategory) => void -} - -const CATEGORIES: { key: FeedCategory; label: string }[] = [ - { key: 'trending', label: '\u{1F525} Trending' }, - { key: 'all', label: 'All' }, - { key: 'politics', label: '\u{1F3DB} Politics' }, - { key: 'sports', label: '\u{26BD} Sports' }, - { key: 'crypto', label: '\u{1FA99} Crypto' }, - { key: 'economics', label: '\u{1F4C8} Economics' }, - { key: 'entertainment', label: '\u{1F3AC} Entertainment' }, -] - -export const CategoryTabs: FC = ({ - activeCategory, - onCategoryChange, -}) => { - return ( -
- {CATEGORIES.map((cat) => ( - - ))} -
- ) -} diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx index 2ff18561..8f2888ea 100644 --- a/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx @@ -1,53 +1,45 @@ import { type FC, useRef, useState } from 'react' import { FAKE_MARKETS } from '@/lib/data/fake-markets' -import type { FeedCategory } from '@/lib/types/market' -import { CategoryTabs } from './CategoryTabs' +import { BottomNav } from './BottomNav' import { MarketCard } from './MarketCard' +import { TopBar } from './TopBar' export const Feed: FC = () => { - const [activeCategory, setActiveCategory] = useState('trending') + const [currentIndex, setCurrentIndex] = useState(0) const feedRef = useRef(null) - const filteredMarkets = - activeCategory === 'trending' || activeCategory === 'all' - ? FAKE_MARKETS - : FAKE_MARKETS.filter((m) => m.category === activeCategory) + const currentMarket = FAKE_MARKETS[currentIndex] ?? FAKE_MARKETS[0] - const handleCategoryChange = (category: FeedCategory) => { - setActiveCategory(category) - if (feedRef.current) { - feedRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + const handleScroll = () => { + if (!feedRef.current) return + const scrollTop = feedRef.current.scrollTop + const cardHeight = feedRef.current.clientHeight + const index = Math.round(scrollTop / cardHeight) + if (index !== currentIndex && index >= 0 && index < FAKE_MARKETS.length) { + setCurrentIndex(index) } } return (
-
-
-
-

Kalshi Feed

-
- -
+ {/* Top bar overlay */} +
+
+ {/* Scrollable feed */}
- {filteredMarkets.length > 0 ? ( - filteredMarkets.map((market) => ( - - )) - ) : ( -
-

No markets in this category

-
- )} + {FAKE_MARKETS.map((market) => ( + + ))}
+ + {/* Bottom nav */} +
) } diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx index 9788d8fd..d0820745 100644 --- a/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx @@ -1,151 +1,132 @@ import { - ArrowDown, - ArrowUp, + Bookmark, + CheckCircle, Clock, - ExternalLink, + Flame, + Heart, + MessageCircle, + Share2, TrendingUp, Users, + XCircle, } from 'lucide-react' import type { FC } from 'react' import type { Market } from '@/lib/types/market' -import { cn } from '@/lib/utils/cn' -import { - formatTimeRemaining, - formatTraders, - formatVolume, -} from '@/lib/utils/format' -import { getCategoryEmoji, getCategoryGradient } from '@/lib/utils/gradients' +import { formatCompact, formatDaysRemaining } from '@/lib/utils/format' interface MarketCardProps { market: Market } export const MarketCard: FC = ({ market }) => { - const timeRemaining = formatTimeRemaining(market.close_time) - const isClosingSoon = - timeRemaining.includes('h ') || timeRemaining.includes('m ') - const gradient = getCategoryGradient(market.category) - const emoji = getCategoryEmoji(market.category) - - const handleBuyYes = () => { - window.open(market.kalshi_url, '_blank') - } - - const handleBuyNo = () => { + const handleBet = () => { window.open(market.kalshi_url, '_blank') } return ( -
-
-
+
+ {market.image_url && ( + + )} +
-
-
-
- - {emoji} {market.category} + {/* Right side action bar */} +
+ {market.is_hot && ( +
+
+ +
+ + HOT - {isClosingSoon && ( - - - Closing soon - - )}
- - - {timeRemaining} + )} +
+ + + {formatCompact(market.likes_count)} + +
+
+ + + {formatCompact(market.comments_count)}
+
+ + + {formatCompact(market.shares_count)} + +
+
+ + Save +
+
-
-
-

- {market.title} -

-

{market.subtitle}

+ {/* Bottom content overlay */} +
+ {/* Trending badge */} + {market.is_trending && ( +
+ + + TRENDING +
+ )} -
-
- - Probability - -
- {market.price_change_24h > 0 ? ( - - ) : market.price_change_24h < 0 ? ( - - ) : null} - 0 - ? 'text-yes-green' - : 'text-no-red', - )} - > - {market.price_change_24h > 0 ? '+' : ''} - {market.price_change_24h}% - -
-
-
-
-
-
-
- - Yes {market.yes_price}¢ - - - No {market.no_price}¢ - -
-
-
+ {/* Title */} +

+ {market.title} +

-
-
- - - {formatVolume(market.volume_24h)} 24h vol + {/* Stats row */} +
+
+ + + {market.yes_price}% YES - - - {formatTraders(market.traders_count)} traders +
+
+ + + {formatDaysRemaining(market.close_time)}
- -
- - +
+ + + {formatCompact(market.traders_count)} +
+
-

- Tap to place trade on Kalshi -

+ {/* YES / NO buttons */} +
+ +
diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/TopBar.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/TopBar.tsx new file mode 100644 index 00000000..4e33ea0b --- /dev/null +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/TopBar.tsx @@ -0,0 +1,27 @@ +import { Bell, Eye, Search } from 'lucide-react' +import type { FC } from 'react' +import type { MarketCategory } from '@/lib/types/market' +import { getCategoryLabel } from '@/lib/utils/gradients' + +interface TopBarProps { + category: MarketCategory +} + +export const TopBar: FC = ({ category }) => { + return ( +
+ +
+ + + {getCategoryLabel(category)} + +
+ +
+ ) +} diff --git a/apps/kalshi-feed/lib/data/fake-markets.ts b/apps/kalshi-feed/lib/data/fake-markets.ts index 754b3d06..4bff787f 100644 --- a/apps/kalshi-feed/lib/data/fake-markets.ts +++ b/apps/kalshi-feed/lib/data/fake-markets.ts @@ -13,12 +13,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 482_100, open_interest: 1_200_000, close_time: '2026-03-19T18:00:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxfedrate', status: 'open', last_price: 67, price_change_24h: 3, traders_count: 18_432, + likes_count: 287_000, + comments_count: 142_000, + shares_count: 98_500, + is_hot: true, + is_trending: true, created_at: '2026-01-15T00:00:00Z', }, { @@ -33,12 +39,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 890_000, open_interest: 3_200_000, close_time: '2026-02-28T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1518546305927-5a555bb7020d?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxbtc', status: 'open', last_price: 42, price_change_24h: -5, traders_count: 32_100, + likes_count: 412_000, + comments_count: 89_000, + shares_count: 156_000, + is_hot: true, + is_trending: true, created_at: '2026-01-01T00:00:00Z', }, { @@ -53,12 +65,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 1_200_000, open_interest: 4_500_000, close_time: '2026-02-08T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1508098682722-e99c643e7f1b?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxsb', status: 'open', last_price: 55, price_change_24h: 2, traders_count: 45_000, + likes_count: 534_000, + comments_count: 201_000, + shares_count: 178_000, + is_hot: false, + is_trending: true, created_at: '2025-09-01T00:00:00Z', }, { @@ -73,12 +91,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 2_100_000, open_interest: 8_000_000, close_time: '2028-11-03T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1540910419892-4a36d2c3266c?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxelection', status: 'open', last_price: 48, price_change_24h: -1, traders_count: 89_000, + likes_count: 892_000, + comments_count: 445_000, + shares_count: 312_000, + is_hot: true, + is_trending: true, created_at: '2025-11-15T00:00:00Z', }, { @@ -93,12 +117,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 345_000, open_interest: 900_000, close_time: '2026-03-02T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxoscars', status: 'open', last_price: 38, price_change_24h: 5, traders_count: 12_400, + likes_count: 156_000, + comments_count: 67_000, + shares_count: 43_000, + is_hot: false, + is_trending: false, created_at: '2026-01-20T00:00:00Z', }, { @@ -113,12 +143,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 89_000, open_interest: 150_000, close_time: '2026-02-22T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1534430480872-3498386e7856?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxhighny', status: 'open', last_price: 72, price_change_24h: 8, traders_count: 3_200, + likes_count: 23_000, + comments_count: 8_400, + shares_count: 5_200, + is_hot: false, + is_trending: false, created_at: '2026-02-22T00:00:00Z', }, { @@ -133,12 +169,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 560_000, open_interest: 1_800_000, close_time: '2026-03-31T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1639762681485-074b7f938ba0?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxeth', status: 'open', last_price: 23, price_change_24h: -2, traders_count: 15_600, + likes_count: 198_000, + comments_count: 54_000, + shares_count: 72_000, + is_hot: false, + is_trending: false, created_at: '2026-01-10T00:00:00Z', }, { @@ -153,12 +195,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 720_000, open_interest: 2_200_000, close_time: '2026-03-31T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxai', status: 'open', last_price: 31, price_change_24h: -8, traders_count: 28_000, + likes_count: 345_000, + comments_count: 167_000, + shares_count: 123_000, + is_hot: true, + is_trending: true, created_at: '2025-12-01T00:00:00Z', }, { @@ -173,12 +221,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 280_000, open_interest: 1_100_000, close_time: '2026-04-30T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1590283603385-17ffb3a7f29f?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxgdp', status: 'open', last_price: 35, price_change_24h: 1, traders_count: 8_900, + likes_count: 67_000, + comments_count: 23_000, + shares_count: 15_000, + is_hot: false, + is_trending: false, created_at: '2026-01-05T00:00:00Z', }, { @@ -193,12 +247,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 1_450_000, open_interest: 3_800_000, close_time: '2026-03-15T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxmma', status: 'open', last_price: 62, price_change_24h: 4, traders_count: 52_000, + likes_count: 423_000, + comments_count: 189_000, + shares_count: 134_000, + is_hot: true, + is_trending: true, created_at: '2026-01-20T00:00:00Z', }, { @@ -213,12 +273,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 410_000, open_interest: 1_400_000, close_time: '2026-06-30T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1541185933-ef5d8ed016c2?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxspacex', status: 'open', last_price: 78, price_change_24h: 2, traders_count: 19_500, + likes_count: 267_000, + comments_count: 98_000, + shares_count: 87_000, + is_hot: false, + is_trending: true, created_at: '2025-12-15T00:00:00Z', }, { @@ -233,12 +299,18 @@ export const FAKE_MARKETS: Market[] = [ volume_24h: 1_800_000, open_interest: 5_500_000, close_time: '2026-03-31T23:59:00Z', - image_url: null, + image_url: + 'https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=800&h=1200&fit=crop', kalshi_url: 'https://kalshi.com/markets/kxtiktok', status: 'open', last_price: 15, price_change_24h: -3, traders_count: 67_000, + likes_count: 756_000, + comments_count: 334_000, + shares_count: 245_000, + is_hot: true, + is_trending: true, created_at: '2025-10-01T00:00:00Z', }, ] diff --git a/apps/kalshi-feed/lib/types/market.ts b/apps/kalshi-feed/lib/types/market.ts index d6c98aee..e672ba67 100644 --- a/apps/kalshi-feed/lib/types/market.ts +++ b/apps/kalshi-feed/lib/types/market.ts @@ -7,6 +7,7 @@ export type MarketCategory = | 'science' | 'weather' | 'tech' + | 'conspiracy' export interface Market { id: string @@ -26,6 +27,11 @@ export interface Market { last_price: number price_change_24h: number traders_count: number + likes_count: number + comments_count: number + shares_count: number + is_hot: boolean + is_trending: boolean created_at: string } @@ -42,4 +48,5 @@ export type FeedCategory = | 'crypto' | 'economics' | 'entertainment' + | 'conspiracy' | 'all' diff --git a/apps/kalshi-feed/lib/utils/format.ts b/apps/kalshi-feed/lib/utils/format.ts index 73ba5692..00915718 100644 --- a/apps/kalshi-feed/lib/utils/format.ts +++ b/apps/kalshi-feed/lib/utils/format.ts @@ -1,40 +1,25 @@ -export function formatVolume(volume: number): string { - if (volume >= 1_000_000) { - return `$${(volume / 1_000_000).toFixed(1)}M` +export function formatCompact(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M` } - if (volume >= 1_000) { - return `$${(volume / 1_000).toFixed(0)}K` + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1)}K` } - return `$${volume}` + return `${n}` } -export function formatTimeRemaining(closeTime: string): string { +export function formatDaysRemaining(closeTime: string): string { const now = new Date() const close = new Date(closeTime) const diffMs = close.getTime() - now.getTime() - if (diffMs < 0) return 'Closed' + if (diffMs < 0) return '0d' const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) const diffDays = Math.floor(diffHours / 24) - if (diffDays > 30) { - const months = Math.floor(diffDays / 30) - return `${months}mo left` - } - if (diffDays > 0) { - return `${diffDays}d left` - } - if (diffHours > 0) { - return `${diffHours}h left` - } + if (diffDays > 0) return `${diffDays}d` + if (diffHours > 0) return `${diffHours}h` const diffMinutes = Math.floor(diffMs / (1000 * 60)) - return `${diffMinutes}m left` -} - -export function formatTraders(count: number): string { - if (count >= 1_000) { - return `${(count / 1_000).toFixed(1)}K` - } - return `${count}` + return `${diffMinutes}m` } diff --git a/apps/kalshi-feed/lib/utils/gradients.ts b/apps/kalshi-feed/lib/utils/gradients.ts index aea11198..8c0c06c2 100644 --- a/apps/kalshi-feed/lib/utils/gradients.ts +++ b/apps/kalshi-feed/lib/utils/gradients.ts @@ -1,30 +1,16 @@ import type { MarketCategory } from '@/lib/types/market' -const CATEGORY_GRADIENTS: Record = { - politics: 'from-blue-900/80 via-red-900/60 to-purple-900/80', - sports: 'from-green-900/80 via-emerald-900/60 to-teal-900/80', - crypto: 'from-purple-900/80 via-violet-900/60 to-indigo-900/80', - economics: 'from-amber-900/80 via-yellow-900/60 to-orange-900/80', - entertainment: 'from-pink-900/80 via-rose-900/60 to-fuchsia-900/80', - science: 'from-cyan-900/80 via-sky-900/60 to-blue-900/80', - weather: 'from-sky-900/80 via-blue-900/60 to-indigo-900/80', - tech: 'from-slate-900/80 via-zinc-900/60 to-neutral-900/80', -} - -export function getCategoryGradient(category: MarketCategory): string { - return CATEGORY_GRADIENTS[category] ?? CATEGORY_GRADIENTS.economics -} - -export function getCategoryEmoji(category: MarketCategory): string { - const emojis: Record = { - politics: '\u{1F3DB}', - sports: '\u{26BD}', - crypto: '\u{1FA99}', - economics: '\u{1F4C8}', - entertainment: '\u{1F3AC}', - science: '\u{1F52C}', - weather: '\u{26C5}', - tech: '\u{1F4BB}', +export function getCategoryLabel(category: MarketCategory): string { + const labels: Record = { + politics: 'Politics', + sports: 'Sports', + crypto: 'Crypto', + economics: 'Economics', + entertainment: 'Entertainment', + science: 'Science', + weather: 'Weather', + tech: 'Tech', + conspiracy: 'Conspiracy', } - return emojis[category] ?? '\u{1F4CA}' + return labels[category] ?? category } diff --git a/apps/kalshi-scraper/drizzle.config.ts b/apps/kalshi-scraper/drizzle.config.ts new file mode 100644 index 00000000..c884c61b --- /dev/null +++ b/apps/kalshi-scraper/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? '', + }, +}) diff --git a/apps/kalshi-scraper/package.json b/apps/kalshi-scraper/package.json new file mode 100644 index 00000000..889972d5 --- /dev/null +++ b/apps/kalshi-scraper/package.json @@ -0,0 +1,28 @@ +{ + "name": "@browseros/kalshi-scraper", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.0.0", + "drizzle-orm": "^0.45.1", + "hono": "^4.6.0", + "playwright-core": "^1.52.0", + "postgres": "^3.4.7", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bun": "latest", + "drizzle-kit": "^0.31.8", + "typescript": "^5.9.3" + } +} diff --git a/apps/kalshi-scraper/scripts/test-api.ts b/apps/kalshi-scraper/scripts/test-api.ts new file mode 100644 index 00000000..a605e130 --- /dev/null +++ b/apps/kalshi-scraper/scripts/test-api.ts @@ -0,0 +1,77 @@ +import { mapCategory } from '../src/services/category-mapper' +import { + dollarsToCents, + fetchAllMarkets, + fetchEvents, +} from '../src/services/kalshi-api' +import { + computeFeedScore, + generateEngagementCounts, + isHotMarket, +} from '../src/services/ranking' + +async function main() { + console.log('Fetching markets from Kalshi API...') + const markets = await fetchAllMarkets() + console.log(`Fetched ${markets.length} open markets`) + + console.log('\nFetching events...') + const eventMap = await fetchEvents() + console.log(`Fetched ${eventMap.size} events`) + + // Show first 5 markets with transformed data + console.log('\n--- Top 5 markets by volume_24h ---\n') + const sorted = markets.sort((a, b) => b.volume_24h - a.volume_24h).slice(0, 5) + + for (const m of sorted) { + const event = eventMap.get(m.event_ticker) + const category = mapCategory(event?.category ?? '', [], m.title) + const yesPrice = dollarsToCents(m.yes_bid_dollars) + + const market = { + ticker: m.ticker, + title: m.title, + yesPrice, + noPrice: 100 - yesPrice, + volume24h: m.volume_24h, + closeTime: m.close_time ? new Date(m.close_time) : null, + createdAt: new Date(), + } + + const score = computeFeedScore({ + market: market as any, + isInTrendingList: false, + }) + const hot = isHotMarket(market as any) + const engagement = generateEngagementCounts(m.volume_24h) + + console.log(`${m.ticker}`) + console.log(` Title: ${m.title}`) + console.log(` Category: ${event?.category ?? 'unknown'} → ${category}`) + console.log(` YES: ${yesPrice}¢ Volume 24h: ${m.volume_24h}`) + console.log( + ` Price change: ${dollarsToCents(m.last_price_dollars) - dollarsToCents(m.previous_price_dollars)}`, + ) + console.log(` Feed score: ${score} Hot: ${hot}`) + console.log( + ` Engagement: likes=${engagement.likesCount} comments=${engagement.commentsCount}`, + ) + console.log() + } + + // Show category distribution + const categories = new Map() + for (const m of markets) { + const event = eventMap.get(m.event_ticker) + const cat = mapCategory(event?.category ?? '', [], m.title) + categories.set(cat, (categories.get(cat) ?? 0) + 1) + } + console.log('--- Category distribution ---') + for (const [cat, count] of [...categories.entries()].sort( + (a, b) => b[1] - a[1], + )) { + console.log(` ${cat}: ${count}`) + } +} + +main().catch(console.error) diff --git a/apps/kalshi-scraper/src/config.ts b/apps/kalshi-scraper/src/config.ts new file mode 100644 index 00000000..bc8db05f --- /dev/null +++ b/apps/kalshi-scraper/src/config.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +const envSchema = z.object({ + DATABASE_URL: z.string(), + BROWSERBASE_API_KEY: z.string(), + BROWSERBASE_PROJECT_ID: z.string(), + SCRAPE_SECRET: z.string().default('dev-secret'), + PORT: z.coerce.number().default(3001), +}) + +function parseConfig() { + const result = envSchema.safeParse(process.env) + + if (!result.success) { + console.error('Invalid environment variables:') + for (const issue of result.error.issues) { + console.error(` ${issue.path.join('.')}: ${issue.message}`) + } + process.exit(1) + } + + return result.data +} + +export const config = parseConfig() +export type Config = z.infer diff --git a/apps/kalshi-scraper/src/db/index.ts b/apps/kalshi-scraper/src/db/index.ts new file mode 100644 index 00000000..0ab6ded3 --- /dev/null +++ b/apps/kalshi-scraper/src/db/index.ts @@ -0,0 +1,10 @@ +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { config } from '../config' +import * as schema from './schema' + +const client = postgres(config.DATABASE_URL) + +export const db = drizzle(client, { schema }) + +export * from './schema' diff --git a/apps/kalshi-scraper/src/db/schema.ts b/apps/kalshi-scraper/src/db/schema.ts new file mode 100644 index 00000000..2c2f9a68 --- /dev/null +++ b/apps/kalshi-scraper/src/db/schema.ts @@ -0,0 +1,41 @@ +import { + bigint, + boolean, + integer, + pgTable, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core' + +export const markets = pgTable('markets', { + id: uuid('id').defaultRandom().primaryKey(), + ticker: text('ticker').notNull().unique(), + title: text('title').notNull(), + subtitle: text('subtitle').default(''), + category: text('category').notNull(), + yesPrice: integer('yes_price').notNull(), + noPrice: integer('no_price').notNull(), + volume: bigint('volume', { mode: 'number' }).default(0), + volume24h: bigint('volume_24h', { mode: 'number' }).default(0), + openInterest: bigint('open_interest', { mode: 'number' }).default(0), + closeTime: timestamp('close_time', { withTimezone: true }), + imageUrl: text('image_url'), + kalshiUrl: text('kalshi_url').notNull(), + status: text('status').notNull().default('open'), + lastPrice: integer('last_price').default(0), + priceChange24h: integer('price_change_24h').default(0), + tradersCount: integer('traders_count').default(0), + isHot: boolean('is_hot').default(false), + isTrending: boolean('is_trending').default(false), + feedScore: integer('feed_score').default(0), + likesCount: integer('likes_count').default(0), + commentsCount: integer('comments_count').default(0), + sharesCount: integer('shares_count').default(0), + eventTicker: text('event_ticker'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +}) + +export type Market = typeof markets.$inferSelect +export type NewMarket = typeof markets.$inferInsert diff --git a/apps/kalshi-scraper/src/index.ts b/apps/kalshi-scraper/src/index.ts new file mode 100644 index 00000000..7abb8765 --- /dev/null +++ b/apps/kalshi-scraper/src/index.ts @@ -0,0 +1,40 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import { config } from './config' +import { feedRoute } from './routes/feed' +import { scrapeRoute } from './routes/scrape' + +const app = new Hono() + .use(logger()) + .use('*', cors()) + .get('/', (c) => + c.json({ service: '@browseros/kalshi-scraper', version: '0.1.0' }), + ) + .get('/health', (c) => c.json({ status: 'ok' })) + .route('/feed', feedRoute) + .route('/scrape', scrapeRoute) + .notFound((c) => c.json({ error: 'Not found' }, 404)) + .onError((err, c) => { + console.error('Unhandled error:', err) + return c.json({ error: 'Internal server error' }, 500) + }) + +console.log(` +@browseros/kalshi-scraper v0.1.0 + + PORT: ${config.PORT} + DATABASE_URL: *** + BROWSERBASE: *** + +Endpoints: + GET / - Service info + GET /health - Health check + GET /feed - Market feed (category, cursor, limit) + POST /scrape - Trigger scrape cycle (requires x-scrape-secret) +`) + +export default { + port: config.PORT, + fetch: app.fetch, +} diff --git a/apps/kalshi-scraper/src/routes/feed.ts b/apps/kalshi-scraper/src/routes/feed.ts new file mode 100644 index 00000000..c1f60741 --- /dev/null +++ b/apps/kalshi-scraper/src/routes/feed.ts @@ -0,0 +1,71 @@ +import { and, desc, eq, gt } from 'drizzle-orm' +import { Hono } from 'hono' +import { db, markets } from '../db' + +export const feedRoute = new Hono().get('/', async (c) => { + const category = c.req.query('category') ?? 'trending' + const cursor = c.req.query('cursor') ?? null + const limit = Math.min(parseInt(c.req.query('limit') ?? '20', 10), 100) + + // Build where conditions + const conditions = [eq(markets.status, 'open')] + + // Category filter + if (category === 'trending') { + conditions.push(eq(markets.isTrending, true)) + } else if (category !== 'all') { + conditions.push(eq(markets.category, category)) + } + + // Cursor-based pagination (cursor = feed_score of last item) + if (cursor) { + conditions.push(gt(markets.feedScore, parseInt(cursor, 10))) + } + + const rows = await db + .select() + .from(markets) + .where(and(...conditions)) + .orderBy(desc(markets.feedScore)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = + hasMore && pageRows.length > 0 + ? String(pageRows[pageRows.length - 1].feedScore) + : null + + // Map DB rows to the FeedResponse shape the frontend expects + const feedMarkets = pageRows.map((row) => ({ + id: row.id, + ticker: row.ticker, + title: row.title, + subtitle: row.subtitle ?? '', + category: row.category, + yes_price: row.yesPrice, + no_price: row.noPrice, + volume: row.volume ?? 0, + volume_24h: row.volume24h ?? 0, + open_interest: row.openInterest ?? 0, + close_time: row.closeTime?.toISOString() ?? '', + image_url: row.imageUrl, + kalshi_url: row.kalshiUrl, + status: row.status, + last_price: row.lastPrice ?? 0, + price_change_24h: row.priceChange24h ?? 0, + traders_count: row.tradersCount ?? 0, + likes_count: row.likesCount ?? 0, + comments_count: row.commentsCount ?? 0, + shares_count: row.sharesCount ?? 0, + is_hot: row.isHot ?? false, + is_trending: row.isTrending ?? false, + created_at: row.createdAt?.toISOString() ?? '', + })) + + return c.json({ + markets: feedMarkets, + cursor: nextCursor, + has_more: hasMore, + }) +}) diff --git a/apps/kalshi-scraper/src/routes/scrape.ts b/apps/kalshi-scraper/src/routes/scrape.ts new file mode 100644 index 00000000..8d42f840 --- /dev/null +++ b/apps/kalshi-scraper/src/routes/scrape.ts @@ -0,0 +1,192 @@ +import { sql } from 'drizzle-orm' +import { Hono } from 'hono' +import { config } from '../config' +import { db, markets } from '../db' +import type { NewMarket } from '../db/schema' +import { + type ScrapedRanking, + scrapeKalshiRankings, +} from '../services/browserbase-scraper' +import { mapCategory } from '../services/category-mapper' +import { + dollarsToCents, + fetchAllMarkets, + fetchEvents, + type KalshiMarket, +} from '../services/kalshi-api' +import { + computeFeedScore, + generateEngagementCounts, + isHotMarket, + isTrendingMarket, +} from '../services/ranking' + +export const scrapeRoute = new Hono().post('/', async (c) => { + // Protect endpoint with secret + const secret = c.req.header('x-scrape-secret') ?? c.req.query('secret') + if (secret !== config.SCRAPE_SECRET) { + return c.json({ error: 'Unauthorized' }, 401) + } + + const startTime = Date.now() + const results = { + marketsIngested: 0, + eventsLoaded: 0, + scraped: false, + error: null as string | null, + } + + try { + // Run API pull and Browserbase scrape in parallel + const [kalshiMarkets, eventMap, scrapedRankings] = await Promise.all([ + fetchAllMarkets(), + fetchEvents(), + scrapeKalshiRankingsOrFallback(), + ]) + + results.eventsLoaded = eventMap.size + results.scraped = scrapedRankings.length > 0 + + // Build set of trending titles from scraped data + const trendingTitles = buildTrendingTitles(scrapedRankings) + + // Transform and upsert each market + const marketRows = kalshiMarkets.map((km) => + transformMarket(km, eventMap, trendingTitles), + ) + + // Batch upsert in chunks of 100 + for (let i = 0; i < marketRows.length; i += 100) { + const chunk = marketRows.slice(i, i + 100) + await upsertMarkets(chunk) + } + + results.marketsIngested = marketRows.length + } catch (err) { + results.error = err instanceof Error ? err.message : String(err) + console.error('Scrape failed:', results.error) + } + + const duration = Date.now() - startTime + return c.json({ ...results, durationMs: duration }) +}) + +// -- Helpers -- + +async function scrapeKalshiRankingsOrFallback(): Promise { + try { + return await scrapeKalshiRankings() + } catch (err) { + console.error('Browserbase scrape failed, continuing with API-only:', err) + return [] + } +} + +function buildTrendingTitles(rankings: ScrapedRanking[]): Set { + const titles = new Set() + for (const ranking of rankings) { + if (ranking.feedType === 'trending' || ranking.feedType === 'top_movers') { + for (const item of ranking.items) { + titles.add(item.title.toLowerCase()) + } + } + } + return titles +} + +function transformMarket( + km: KalshiMarket, + eventMap: Map, + trendingTitles: Set, +): NewMarket { + const event = eventMap.get(km.event_ticker) + const category = mapCategory(event?.category ?? '', [], km.title) + + // Convert prices from FixedPointDollars to cents + const yesPrice = dollarsToCents(km.yes_bid_dollars) + const noPrice = 100 - yesPrice + const lastPrice = dollarsToCents(km.last_price_dollars) + const previousPrice = dollarsToCents(km.previous_price_dollars) + const priceChange24h = lastPrice - previousPrice + + // Build the kalshi URL from the event ticker + const seriesTicker = event?.series_ticker ?? km.event_ticker + const kalshiUrl = `https://kalshi.com/markets/${seriesTicker.toLowerCase()}` + + // Check if this market appears in scraped trending list + const isInTrendingList = trendingTitles.has(km.title.toLowerCase()) + + const engagement = generateEngagementCounts(km.volume_24h) + + const market: NewMarket = { + ticker: km.ticker, + title: km.title, + subtitle: event?.sub_title ?? km.subtitle ?? '', + category, + yesPrice, + noPrice, + volume: km.volume, + volume24h: km.volume_24h, + openInterest: km.open_interest, + closeTime: km.close_time ? new Date(km.close_time) : null, + imageUrl: null, + kalshiUrl, + status: 'open', + lastPrice, + priceChange24h, + tradersCount: Math.round(km.volume_24h / 50), + isHot: false, + isTrending: false, + feedScore: 0, + likesCount: engagement.likesCount, + commentsCount: engagement.commentsCount, + sharesCount: engagement.sharesCount, + eventTicker: km.event_ticker, + } + + // Compute ranking fields + market.feedScore = computeFeedScore({ market, isInTrendingList }) + market.isHot = isHotMarket(market) + market.isTrending = isTrendingMarket( + market, + isInTrendingList, + market.feedScore ?? 0, + ) + + return market +} + +async function upsertMarkets(rows: NewMarket[]) { + if (rows.length === 0) return + + await db + .insert(markets) + .values(rows) + .onConflictDoUpdate({ + target: markets.ticker, + set: { + title: sql`excluded.title`, + subtitle: sql`excluded.subtitle`, + category: sql`excluded.category`, + yesPrice: sql`excluded.yes_price`, + noPrice: sql`excluded.no_price`, + volume: sql`excluded.volume`, + volume24h: sql`excluded.volume_24h`, + openInterest: sql`excluded.open_interest`, + closeTime: sql`excluded.close_time`, + kalshiUrl: sql`excluded.kalshi_url`, + status: sql`excluded.status`, + lastPrice: sql`excluded.last_price`, + priceChange24h: sql`excluded.price_change_24h`, + tradersCount: sql`excluded.traders_count`, + isHot: sql`excluded.is_hot`, + isTrending: sql`excluded.is_trending`, + feedScore: sql`excluded.feed_score`, + likesCount: sql`excluded.likes_count`, + commentsCount: sql`excluded.comments_count`, + sharesCount: sql`excluded.shares_count`, + eventTicker: sql`excluded.event_ticker`, + updatedAt: sql`now()`, + }, + }) +} diff --git a/apps/kalshi-scraper/src/services/browserbase-scraper.ts b/apps/kalshi-scraper/src/services/browserbase-scraper.ts new file mode 100644 index 00000000..f7d33884 --- /dev/null +++ b/apps/kalshi-scraper/src/services/browserbase-scraper.ts @@ -0,0 +1,135 @@ +import Browserbase from '@browserbasehq/sdk' +import { chromium } from 'playwright-core' +import { config } from '../config' + +export type FeedType = 'trending' | 'top_movers' | 'new' | 'highest_volume' + +export interface ScrapedItem { + title: string + subtitle: string + pricePercent: number + priceChange: number + rank: number +} + +export interface ScrapedRanking { + feedType: FeedType + items: ScrapedItem[] +} + +const SECTION_NAMES: Array<{ label: string; feedType: FeedType }> = [ + { label: 'Trending', feedType: 'trending' }, + { label: 'Top movers', feedType: 'top_movers' }, + { label: 'Top Movers', feedType: 'top_movers' }, + { label: 'New', feedType: 'new' }, + { label: 'Highest volume', feedType: 'highest_volume' }, + { label: 'Highest Volume', feedType: 'highest_volume' }, +] + +export async function scrapeKalshiRankings(): Promise { + const bb = new Browserbase({ apiKey: config.BROWSERBASE_API_KEY }) + + const session = await bb.sessions.create({ + projectId: config.BROWSERBASE_PROJECT_ID, + browserSettings: { blockAds: true }, + }) + + const browser = await chromium.connectOverCDP(session.connectUrl) + const context = browser.contexts()[0] + const page = context.pages()[0] + + try { + await page.goto('https://kalshi.com/markets', { + waitUntil: 'networkidle', + timeout: 30_000, + }) + await page.waitForTimeout(3000) + + // Extract each section separately to keep complexity low + const rankings: ScrapedRanking[] = [] + for (const { label, feedType } of SECTION_NAMES) { + const items = await extractSection(page, label) + if (items.length > 0) { + rankings.push({ feedType, items }) + } + } + + // Deduplicate by feedType (e.g. "Top movers" vs "Top Movers") + return deduplicateRankings(rankings) + } finally { + await browser.close() + } +} + +async function extractSection( + page: import('playwright-core').Page, + sectionLabel: string, +): Promise { + return page.evaluate((label) => { + const headings = document.querySelectorAll( + 'h2, h3, [class*="heading"], [class*="title"]', + ) + + // Find the heading matching this section + let section: Element | null = null + for (const h of headings) { + if (h.textContent?.trim() === label) { + section = h.closest('section') ?? h.parentElement + break + } + } + if (!section) return [] + + const items: Array<{ + title: string + subtitle: string + pricePercent: number + priceChange: number + rank: number + }> = [] + + const listItems = section.querySelectorAll( + 'a, [class*="item"], [class*="card"], [class*="row"]', + ) + + let rank = 1 + for (const item of listItems) { + if (rank > 10) break + + const titleEl = item.querySelector( + '[class*="title"], [class*="name"], h4, h5, span', + ) + const title = titleEl?.textContent?.trim() ?? '' + if (!title || title === label) continue + + const subtitleEl = item.querySelector( + '[class*="subtitle"], [class*="description"], [class*="sub"]', + ) + const subtitle = subtitleEl?.textContent?.trim() ?? '' + + const priceMatch = item.textContent?.match(/(\d{1,3})%/) + const pricePercent = priceMatch ? Number.parseInt(priceMatch[1], 10) : 0 + + const changeMatch = item.textContent?.match(/[▲▼△▽]?\s*(\d+)/) + const isNeg = + item.textContent?.includes('▼') || item.textContent?.includes('▽') + const priceChange = changeMatch + ? Number.parseInt(changeMatch[1], 10) * (isNeg ? -1 : 1) + : 0 + + items.push({ title, subtitle, pricePercent, priceChange, rank }) + rank++ + } + + return items + }, sectionLabel) +} + +function deduplicateRankings(rankings: ScrapedRanking[]): ScrapedRanking[] { + const seen = new Set() + return rankings.filter((r) => { + if (seen.has(r.feedType)) return false + seen.add(r.feedType) + return true + }) +} diff --git a/apps/kalshi-scraper/src/services/category-mapper.ts b/apps/kalshi-scraper/src/services/category-mapper.ts new file mode 100644 index 00000000..4d38fa4b --- /dev/null +++ b/apps/kalshi-scraper/src/services/category-mapper.ts @@ -0,0 +1,70 @@ +type FrontendCategory = + | 'politics' + | 'sports' + | 'crypto' + | 'economics' + | 'entertainment' + | 'science' + | 'weather' + | 'tech' + +const CATEGORY_MAP: Record = { + Politics: 'politics', + 'US Politics': 'politics', + World: 'politics', + 'World Politics': 'politics', + Sports: 'sports', + 'Climate and Weather': 'weather', + Climate: 'weather', + Weather: 'weather', + Economics: 'economics', + Economy: 'economics', + Financial: 'economics', + Financials: 'economics', + Finance: 'economics', + Tech: 'tech', + Technology: 'tech', + AI: 'tech', + Entertainment: 'entertainment', + Culture: 'entertainment', + Science: 'science', + Crypto: 'crypto', + Cryptocurrency: 'crypto', +} + +// Map tags that hint at crypto markets +const CRYPTO_TAGS = [ + 'bitcoin', + 'btc', + 'ethereum', + 'eth', + 'crypto', + 'defi', + 'solana', +] + +export function mapCategory( + kalshiCategory: string, + tags: string[] = [], + title = '', +): FrontendCategory { + // Check tags for crypto signals first + const lowerTags = tags.map((t) => t.toLowerCase()) + if (lowerTags.some((t) => CRYPTO_TAGS.includes(t))) return 'crypto' + + // Check title for crypto keywords + const lowerTitle = title.toLowerCase() + if (CRYPTO_TAGS.some((k) => lowerTitle.includes(k))) return 'crypto' + + // Direct category mapping + const mapped = CATEGORY_MAP[kalshiCategory] + if (mapped) return mapped + + // Case-insensitive fallback + const lowerCategory = kalshiCategory.toLowerCase() + for (const [key, value] of Object.entries(CATEGORY_MAP)) { + if (key.toLowerCase() === lowerCategory) return value + } + + return 'economics' +} diff --git a/apps/kalshi-scraper/src/services/kalshi-api.ts b/apps/kalshi-scraper/src/services/kalshi-api.ts new file mode 100644 index 00000000..261c2dd7 --- /dev/null +++ b/apps/kalshi-scraper/src/services/kalshi-api.ts @@ -0,0 +1,145 @@ +const KALSHI_API_BASE = 'https://api.elections.kalshi.com/trade-api/v2' +const MAX_RETRIES = 3 +const MAX_MARKET_PAGES = 50 +const MAX_EVENT_PAGES = 30 +const PAGE_DELAY_MS = 300 + +// -- Kalshi API response types -- + +export interface KalshiMarket { + ticker: string + event_ticker: string + market_type: string + title: string + subtitle: string + yes_sub_title: string + no_sub_title: string + yes_bid: number + yes_ask: number + yes_bid_dollars: string + yes_ask_dollars: string + last_price: number + last_price_dollars: string + previous_price: number + previous_price_dollars: string + volume: number + volume_24h: number + volume_24h_fp: string + open_interest: number + open_interest_fp: string + close_time: string + status: string + result: string +} + +export interface KalshiEvent { + event_ticker: string + series_ticker: string + title: string + sub_title: string + category: string +} + +interface MarketsResponse { + cursor: string + markets: KalshiMarket[] +} + +interface EventsResponse { + cursor: string + events: KalshiEvent[] +} + +// -- Helpers -- + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function fetchJson(url: string): Promise { + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const res = await fetch(url, { + headers: { Accept: 'application/json' }, + }) + + if (res.status === 429) { + const backoff = 2 ** (attempt + 1) * 1000 + console.warn(`Rate limited, retrying in ${backoff}ms...`) + await sleep(backoff) + continue + } + + if (!res.ok) { + throw new Error(`Kalshi API ${res.status}: ${url}`) + } + + return res.json() as Promise + } + + throw new Error(`Kalshi API: max retries exceeded for ${url}`) +} + +// Convert FixedPointDollars string to integer cents (0-100) +export function dollarsToCents(dollars: string): number { + const value = parseFloat(dollars || '0') + return Math.round(value * 100) +} + +// -- Public API -- + +export async function fetchAllMarkets(): Promise { + const allMarkets: KalshiMarket[] = [] + let cursor = '' + let pages = 0 + + // Fetch non-multivariate markets with pagination + while (pages < MAX_MARKET_PAGES) { + const params = new URLSearchParams({ + status: 'open', + limit: '1000', + mve_filter: 'exclude', + }) + if (cursor) params.set('cursor', cursor) + + const data = await fetchJson( + `${KALSHI_API_BASE}/markets?${params}`, + ) + allMarkets.push(...data.markets) + pages++ + + if (!data.cursor) break + cursor = data.cursor + await sleep(PAGE_DELAY_MS) + } + + // Keep only markets with trading activity + return allMarkets.filter((m) => m.volume_24h > 0 || m.open_interest > 0) +} + +export async function fetchEvents(): Promise> { + const eventMap = new Map() + let cursor = '' + let pages = 0 + + while (pages < MAX_EVENT_PAGES) { + const params = new URLSearchParams({ + status: 'open', + limit: '200', + }) + if (cursor) params.set('cursor', cursor) + + const data = await fetchJson( + `${KALSHI_API_BASE}/events?${params}`, + ) + for (const event of data.events) { + eventMap.set(event.event_ticker, event) + } + pages++ + + if (!data.cursor) break + cursor = data.cursor + await sleep(PAGE_DELAY_MS) + } + + return eventMap +} diff --git a/apps/kalshi-scraper/src/services/ranking.ts b/apps/kalshi-scraper/src/services/ranking.ts new file mode 100644 index 00000000..480856b5 --- /dev/null +++ b/apps/kalshi-scraper/src/services/ranking.ts @@ -0,0 +1,82 @@ +import type { NewMarket } from '../db/schema' + +interface RankingInput { + market: NewMarket + isInTrendingList: boolean +} + +// Compute a feed score (0-1000) for ranking markets in the TikTok feed +export function computeFeedScore(input: RankingInput): number { + const { market, isInTrendingList } = input + + // Volume component — normalize to 0-1 range (cap at 2M for 24h volume) + const volume24h = market.volume24h ?? 0 + const volumeScore = Math.min(volume24h / 2_000_000, 1) + + // Controversy — markets near 50/50 are most engaging + const yesPrice = market.yesPrice ?? 50 + const controversyScore = 1 - Math.abs(yesPrice - 50) / 50 + + // Recency — newer markets get a boost (decay over 30 days) + const createdMs = market.createdAt + ? new Date(market.createdAt).getTime() + : Date.now() - 30 * 24 * 60 * 60 * 1000 + const ageMs = Date.now() - createdMs + const ageDays = ageMs / (24 * 60 * 60 * 1000) + const recencyScore = Math.max(0, 1 - ageDays / 30) + + // Closing soon — urgency factor (peak when closing in next 24h) + const closeMs = market.closeTime + ? new Date(market.closeTime).getTime() + : Date.now() + 365 * 24 * 60 * 60 * 1000 + const timeToCloseHours = Math.max( + 0, + (closeMs - Date.now()) / (60 * 60 * 1000), + ) + const closingSoonScore = + timeToCloseHours < 24 + ? 1 - timeToCloseHours / 24 + : timeToCloseHours < 168 + ? 0.3 + : 0 + + // Kalshi trending boost + const trendingBoost = isInTrendingList ? 1 : 0 + + // Weighted sum + const score = + volumeScore * 0.3 + + controversyScore * 0.25 + + recencyScore * 0.2 + + closingSoonScore * 0.15 + + trendingBoost * 0.1 + + return Math.round(score * 1000) +} + +// Determine if a market should be flagged as "hot" +export function isHotMarket(market: NewMarket): boolean { + const volume24h = market.volume24h ?? 0 + const yesPrice = market.yesPrice ?? 50 + const controversyScore = 1 - Math.abs(yesPrice - 50) / 50 + return volume24h > 500_000 && controversyScore > 0.5 +} + +// Determine if a market should be flagged as "trending" +export function isTrendingMarket( + _market: NewMarket, + isInTrendingList: boolean, + feedScore: number, +): boolean { + return isInTrendingList || feedScore > 500 +} + +// Generate synthetic engagement counts based on volume +export function generateEngagementCounts(volume24h: number) { + const base = Math.max(100, volume24h / 10) + return { + likesCount: Math.round(base * (0.8 + Math.random() * 0.4)), + commentsCount: Math.round(base * (0.3 + Math.random() * 0.2)), + sharesCount: Math.round(base * (0.2 + Math.random() * 0.15)), + } +} diff --git a/apps/kalshi-scraper/tsconfig.json b/apps/kalshi-scraper/tsconfig.json new file mode 100644 index 00000000..754a1643 --- /dev/null +++ b/apps/kalshi-scraper/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "preserve", + "moduleResolution": "bundler", + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["@types/bun"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "drizzle"] +} From ed6dbc555919c2a7e7fc6d16ed27a81335262f77 Mon Sep 17 00:00:00 2001 From: Felarof Date: Sun, 22 Feb 2026 21:17:00 -0800 Subject: [PATCH 3/6] fix: address review comments for kalshi-scraper - Fix cursor pagination: use lt() instead of gt() with desc ordering - Add NaN fallback for limit query param (|| 20) - Replace Math.random() with deterministic hash for stable engagement counts - Remove unused _market param from isTrendingMarket - Use proper KalshiEvent type import instead of inline import() Co-Authored-By: Claude Opus 4.6 --- apps/kalshi-scraper/scripts/test-api.ts | 2 +- apps/kalshi-scraper/src/routes/feed.ts | 9 ++++++--- apps/kalshi-scraper/src/routes/scrape.ts | 11 ++++------- apps/kalshi-scraper/src/services/ranking.ts | 21 +++++++++++++++------ 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/kalshi-scraper/scripts/test-api.ts b/apps/kalshi-scraper/scripts/test-api.ts index a605e130..76039475 100644 --- a/apps/kalshi-scraper/scripts/test-api.ts +++ b/apps/kalshi-scraper/scripts/test-api.ts @@ -43,7 +43,7 @@ async function main() { isInTrendingList: false, }) const hot = isHotMarket(market as any) - const engagement = generateEngagementCounts(m.volume_24h) + const engagement = generateEngagementCounts(m.volume_24h, m.ticker) console.log(`${m.ticker}`) console.log(` Title: ${m.title}`) diff --git a/apps/kalshi-scraper/src/routes/feed.ts b/apps/kalshi-scraper/src/routes/feed.ts index c1f60741..ef828bb3 100644 --- a/apps/kalshi-scraper/src/routes/feed.ts +++ b/apps/kalshi-scraper/src/routes/feed.ts @@ -1,11 +1,14 @@ -import { and, desc, eq, gt } from 'drizzle-orm' +import { and, desc, eq, lt } from 'drizzle-orm' import { Hono } from 'hono' import { db, markets } from '../db' export const feedRoute = new Hono().get('/', async (c) => { const category = c.req.query('category') ?? 'trending' const cursor = c.req.query('cursor') ?? null - const limit = Math.min(parseInt(c.req.query('limit') ?? '20', 10), 100) + const limit = Math.min( + Number.parseInt(c.req.query('limit') ?? '20', 10) || 20, + 100, + ) // Build where conditions const conditions = [eq(markets.status, 'open')] @@ -19,7 +22,7 @@ export const feedRoute = new Hono().get('/', async (c) => { // Cursor-based pagination (cursor = feed_score of last item) if (cursor) { - conditions.push(gt(markets.feedScore, parseInt(cursor, 10))) + conditions.push(lt(markets.feedScore, Number.parseInt(cursor, 10))) } const rows = await db diff --git a/apps/kalshi-scraper/src/routes/scrape.ts b/apps/kalshi-scraper/src/routes/scrape.ts index 8d42f840..7ca3e5a8 100644 --- a/apps/kalshi-scraper/src/routes/scrape.ts +++ b/apps/kalshi-scraper/src/routes/scrape.ts @@ -12,6 +12,7 @@ import { dollarsToCents, fetchAllMarkets, fetchEvents, + type KalshiEvent, type KalshiMarket, } from '../services/kalshi-api' import { @@ -96,7 +97,7 @@ function buildTrendingTitles(rankings: ScrapedRanking[]): Set { function transformMarket( km: KalshiMarket, - eventMap: Map, + eventMap: Map, trendingTitles: Set, ): NewMarket { const event = eventMap.get(km.event_ticker) @@ -116,7 +117,7 @@ function transformMarket( // Check if this market appears in scraped trending list const isInTrendingList = trendingTitles.has(km.title.toLowerCase()) - const engagement = generateEngagementCounts(km.volume_24h) + const engagement = generateEngagementCounts(km.volume_24h, km.ticker) const market: NewMarket = { ticker: km.ticker, @@ -147,11 +148,7 @@ function transformMarket( // Compute ranking fields market.feedScore = computeFeedScore({ market, isInTrendingList }) market.isHot = isHotMarket(market) - market.isTrending = isTrendingMarket( - market, - isInTrendingList, - market.feedScore ?? 0, - ) + market.isTrending = isTrendingMarket(isInTrendingList, market.feedScore ?? 0) return market } diff --git a/apps/kalshi-scraper/src/services/ranking.ts b/apps/kalshi-scraper/src/services/ranking.ts index 480856b5..56628470 100644 --- a/apps/kalshi-scraper/src/services/ranking.ts +++ b/apps/kalshi-scraper/src/services/ranking.ts @@ -64,19 +64,28 @@ export function isHotMarket(market: NewMarket): boolean { // Determine if a market should be flagged as "trending" export function isTrendingMarket( - _market: NewMarket, isInTrendingList: boolean, feedScore: number, ): boolean { return isInTrendingList || feedScore > 500 } -// Generate synthetic engagement counts based on volume -export function generateEngagementCounts(volume24h: number) { +// Simple deterministic hash for a string, returns value between 0 and 1 +function deterministicHash(str: string): number { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = (hash * 31 + str.charCodeAt(i)) | 0 + } + return Math.abs(hash % 1000) / 1000 +} + +// Generate synthetic engagement counts based on volume and ticker +export function generateEngagementCounts(volume24h: number, ticker: string) { const base = Math.max(100, volume24h / 10) + const h = deterministicHash(ticker) return { - likesCount: Math.round(base * (0.8 + Math.random() * 0.4)), - commentsCount: Math.round(base * (0.3 + Math.random() * 0.2)), - sharesCount: Math.round(base * (0.2 + Math.random() * 0.15)), + likesCount: Math.round(base * (0.8 + h * 0.4)), + commentsCount: Math.round(base * (0.3 + h * 0.2)), + sharesCount: Math.round(base * (0.2 + h * 0.15)), } } From 8360054291e9d2e3887795fcbcdef687c8778381 Mon Sep 17 00:00:00 2001 From: Felarof Date: Sun, 22 Feb 2026 21:23:03 -0800 Subject: [PATCH 4/6] fix: resolve TypeScript error in BottomNav isCenter check Use 'isCenter' in item narrowing pattern consistent with 'active' check. Co-Authored-By: Claude Opus 4.6 --- apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx index 0dd5da99..3668f28c 100644 --- a/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx +++ b/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx @@ -13,7 +13,7 @@ export const BottomNav: FC = () => { return (
{NAV_ITEMS.map((item) => - item.isCenter ? ( + 'isCenter' in item && item.isCenter ? (