Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/kalshi-feed/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist/
.output/
.wxt/
node_modules/
95 changes: 95 additions & 0 deletions apps/kalshi-feed/SCHEMA.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions apps/kalshi-feed/biome.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
3 changes: 3 additions & 0 deletions apps/kalshi-feed/entrypoints/background/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default defineBackground(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
})
6 changes: 6 additions & 0 deletions apps/kalshi-feed/entrypoints/sidepanel/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { FC } from 'react'
import { Feed } from './components/Feed'

export const App: FC = () => {
return <Feed />
}
43 changes: 43 additions & 0 deletions apps/kalshi-feed/entrypoints/sidepanel/components/BottomNav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-16 items-center justify-around border-white/10 border-t bg-black/95 px-2 backdrop-blur-md">
{NAV_ITEMS.map((item) =>
'isCenter' in item && item.isCenter ? (
<button
key="center"
type="button"
className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500"
>
<PlusSquare className="h-6 w-6 text-white" />
</button>
) : (
<button
key={item.label}
type="button"
className="flex flex-col items-center gap-0.5"
>
<item.icon
className={`h-6 w-6 ${'active' in item && item.active ? 'text-white' : 'text-white/50'}`}
/>
<span
className={`text-[10px] ${'active' in item && item.active ? 'font-medium text-white' : 'text-white/50'}`}
>
{item.label}
</span>
</button>
),
)}
</div>
)
}
45 changes: 45 additions & 0 deletions apps/kalshi-feed/entrypoints/sidepanel/components/Feed.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="relative flex h-screen w-full flex-col bg-black">
{/* Top bar overlay */}
<div className="pointer-events-auto absolute top-0 right-0 left-0 z-30">
<TopBar category={currentMarket.category} />
</div>

{/* Scrollable feed */}
<div
ref={feedRef}
onScroll={handleScroll}
className="flex-1 snap-y snap-mandatory overflow-y-scroll [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{FAKE_MARKETS.map((market) => (
<MarketCard key={market.id} market={market} />
))}
</div>

{/* Bottom nav */}
<BottomNav />
</div>
)
}
134 changes: 134 additions & 0 deletions apps/kalshi-feed/entrypoints/sidepanel/components/MarketCard.tsx
Original file line number Diff line number Diff line change
@@ -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<MarketCardProps> = ({ market }) => {
const handleBet = () => {
window.open(market.kalshi_url, '_blank')
}

return (
<div className="relative flex h-full w-full shrink-0 snap-start flex-col overflow-hidden bg-black">
{market.image_url && (
<img
src={market.image_url}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent" />

{/* Right side action bar */}
<div className="absolute right-4 bottom-36 z-20 flex flex-col items-center gap-3">
{market.is_hot && (
<div className="flex flex-col items-center gap-0.5">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-orange-500/20">
<Flame className="h-5 w-5 text-orange-400" />
</div>
<span className="font-semibold text-[9px] text-orange-400">
HOT
</span>
</div>
)}
<div className="flex flex-col items-center gap-0.5">
<Heart className="h-6 w-6 text-white" />
<span className="font-medium text-[10px] text-white">
{formatCompact(market.likes_count)}
</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<MessageCircle className="h-6 w-6 text-white" />
<span className="font-medium text-[10px] text-white">
{formatCompact(market.comments_count)}
</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<Share2 className="h-6 w-6 text-white" />
<span className="font-medium text-[10px] text-white">
{formatCompact(market.shares_count)}
</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<Bookmark className="h-6 w-6 text-white" />
<span className="font-medium text-[10px] text-white">Save</span>
</div>
</div>

{/* Bottom content overlay */}
<div className="absolute right-0 bottom-3 left-0 z-10 px-4 pb-3">
{/* Trending badge */}
{market.is_trending && (
<div className="mb-2 inline-flex items-center gap-1 rounded bg-emerald-500/90 px-2.5 py-1">
<TrendingUp className="h-3 w-3 text-white" />
<span className="font-bold text-[11px] text-white tracking-wide">
TRENDING
</span>
</div>
)}

{/* Title */}
<h2 className="mb-3 pr-16 font-bold text-white text-xl leading-tight drop-shadow-lg">
{market.title}
</h2>

{/* Stats row */}
<div className="mb-4 flex items-center gap-3">
<div className="flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 backdrop-blur-sm">
<TrendingUp className="h-3 w-3 text-red-400" />
<span className="font-semibold text-white text-xs">
{market.yes_price}% YES
</span>
</div>
<div className="flex items-center gap-1 text-white/60">
<Clock className="h-3 w-3" />
<span className="text-xs">
{formatDaysRemaining(market.close_time)}
</span>
</div>
<div className="flex items-center gap-1 text-white/60">
<Users className="h-3 w-3" />
<span className="text-xs">
{formatCompact(market.traders_count)}
</span>
</div>
</div>

{/* YES / NO buttons */}
<div className="flex gap-2.5">
<button
type="button"
onClick={handleBet}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-600 py-3.5 font-bold text-[15px] text-white transition-all active:scale-[0.97] active:brightness-90"
>
<CheckCircle className="h-5 w-5" />
YES &middot; {market.yes_price}%
</button>
<button
type="button"
onClick={handleBet}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-rose-900/80 py-3.5 font-bold text-[15px] text-white transition-all active:scale-[0.97] active:brightness-90"
>
<XCircle className="h-5 w-5" />
NO &middot; {market.no_price}%
</button>
</div>
</div>
</div>
)
}
Loading
Loading