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/BottomNav.tsx b/apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx
new file mode 100644
index 00000000..3668f28c
--- /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) =>
+ 'isCenter' in item && item.isCenter ? (
+
+ ) : (
+
+ ),
+ )}
+
+ )
+}
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..8f2888ea
--- /dev/null
+++ b/apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx
@@ -0,0 +1,45 @@
+import { type FC, useRef, useState } from 'react'
+import { FAKE_MARKETS } from '@/lib/data/fake-markets'
+import { BottomNav } from './BottomNav'
+import { MarketCard } from './MarketCard'
+import { TopBar } from './TopBar'
+
+export const Feed: FC = () => {
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const feedRef = useRef(null)
+
+ const currentMarket = FAKE_MARKETS[currentIndex] ?? FAKE_MARKETS[0]
+
+ 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 (
+
+ {/* Top bar overlay */}
+
+
+
+
+ {/* Scrollable feed */}
+
+ {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
new file mode 100644
index 00000000..d0820745
--- /dev/null
+++ b/apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx
@@ -0,0 +1,134 @@
+import {
+ Bookmark,
+ CheckCircle,
+ Clock,
+ Flame,
+ Heart,
+ MessageCircle,
+ Share2,
+ TrendingUp,
+ Users,
+ XCircle,
+} from 'lucide-react'
+import type { FC } from 'react'
+import type { Market } from '@/lib/types/market'
+import { formatCompact, formatDaysRemaining } from '@/lib/utils/format'
+
+interface MarketCardProps {
+ market: Market
+}
+
+export const MarketCard: FC = ({ market }) => {
+ const handleBet = () => {
+ window.open(market.kalshi_url, '_blank')
+ }
+
+ return (
+
+ {market.image_url && (
+

+ )}
+
+
+ {/* Right side action bar */}
+
+ {market.is_hot && (
+
+ )}
+
+
+
+ {formatCompact(market.likes_count)}
+
+
+
+
+
+ {formatCompact(market.comments_count)}
+
+
+
+
+
+ {formatCompact(market.shares_count)}
+
+
+
+
+ Save
+
+
+
+ {/* Bottom content overlay */}
+
+ {/* Trending badge */}
+ {market.is_trending && (
+
+
+
+ TRENDING
+
+
+ )}
+
+ {/* Title */}
+
+ {market.title}
+
+
+ {/* Stats row */}
+
+
+
+
+ {market.yes_price}% YES
+
+
+
+
+
+ {formatDaysRemaining(market.close_time)}
+
+
+
+
+
+ {formatCompact(market.traders_count)}
+
+
+
+
+ {/* 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/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..4bff787f
--- /dev/null
+++ b/apps/kalshi-feed/lib/data/fake-markets.ts
@@ -0,0 +1,316 @@
+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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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',
+ },
+ {
+ 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:
+ '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
new file mode 100644
index 00000000..b15ee5ac
--- /dev/null
+++ b/apps/kalshi-feed/lib/types/market.ts
@@ -0,0 +1,50 @@
+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
+ likes_count: number
+ comments_count: number
+ shares_count: number
+ is_hot: boolean
+ is_trending: boolean
+ 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..00915718
--- /dev/null
+++ b/apps/kalshi-feed/lib/utils/format.ts
@@ -0,0 +1,25 @@
+export function formatCompact(n: number): string {
+ if (n >= 1_000_000) {
+ return `${(n / 1_000_000).toFixed(1)}M`
+ }
+ if (n >= 1_000) {
+ return `${(n / 1_000).toFixed(1)}K`
+ }
+ return `${n}`
+}
+
+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 '0d'
+
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
+ const diffDays = Math.floor(diffHours / 24)
+
+ if (diffDays > 0) return `${diffDays}d`
+ if (diffHours > 0) return `${diffHours}h`
+ const diffMinutes = Math.floor(diffMs / (1000 * 60))
+ return `${diffMinutes}m`
+}
diff --git a/apps/kalshi-feed/lib/utils/gradients.ts b/apps/kalshi-feed/lib/utils/gradients.ts
new file mode 100644
index 00000000..7c16dab5
--- /dev/null
+++ b/apps/kalshi-feed/lib/utils/gradients.ts
@@ -0,0 +1,15 @@
+import type { MarketCategory } from '@/lib/types/market'
+
+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',
+ }
+ return labels[category] ?? category
+}
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/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..76039475
--- /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, m.ticker)
+
+ 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..ef828bb3
--- /dev/null
+++ b/apps/kalshi-scraper/src/routes/feed.ts
@@ -0,0 +1,74 @@
+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(
+ Number.parseInt(c.req.query('limit') ?? '20', 10) || 20,
+ 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(lt(markets.feedScore, Number.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..7ca3e5a8
--- /dev/null
+++ b/apps/kalshi-scraper/src/routes/scrape.ts
@@ -0,0 +1,189 @@
+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 KalshiEvent,
+ 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, km.ticker)
+
+ 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(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