# Project export: BetBasket

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: Thematic index builder for Kalshi
- Devpost: https://devpost.com/software/betbasket
- GitHub: https://github.com/mirabor/treehacks
- Video: https://www.youtube.com/embed/pUaH1kX9GXA?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Justin Ji (4 commits), Mira Yu (1 commits)

## Devpost submission (written by the team)

### Inspiration

Prediction markets are powerful tools for forecasting, but they can be overwhelming. While traditional finance offers ETFs (Exchange-Traded Funds) to let investors bet on sectors or trends without picking individual stocks, prediction markets like Kalshi require users to find and trade specific, granular contracts (e.g., "Fed rates in March" vs "Fed rates in April"). So, our project explores ETFs for prediction markets. Our inspiration came from the desire to bridge the gap between high-level intuition ("I think AI is going to boom this year") and the specific, complex instruments available on exchanges. We wanted to build a tool that lets anyone trade on a theme in a single click.

### What it does

Kalshi ETF Baskets allows users to: Select a Theme: Choose from curated baskets like "Tech Growth", "Climate Change", or "Inflation". Generate with AI: Describe a trend in natural language (e.g., "Crypto will crash in late 2025"), and our LLM agent dynamically constructs a portfolio of relevant Kalshi markets. One-Click Execution: Instantly execute a batched order to buy "Yes" or "No" positions across multiple markets, weighted by confidence or strategy.

### How we built it

Stack Backend: Python & FastAPI Frontend: Streamlit Model: We used GPT-4 to interpret natural language trends and map them to live Kalshi market tickers Key Algorithms To allocate capital across different markets in a basket, we implement a weighted allocation strategy. The total budget B is distributed among n selected markets based on a relevance score R_i and a confidence factor C_i. Allocation for market i is computed as: This ensures that capital is distributed proportionally to the strength of each market’s signal, so the ETF emphasizes the contracts most aligned with the chosen theme.

### Challenges we ran into

Market Ephemerality: Unlike stocks, prediction markets expire. A "Climate" basket today must have different contracts than one next month. Our dynamic sourcing engin constantly updates the available components of a basket. Latency & Atomicity: Executing a basket trade implies placing multiple orders simultaneously. We had to handle partial fills and ensure that either the whole basket executes or we handle failures gracefully (using batch orders where possible).

### Accomplishments we're proud of

End-to-End Trading: We successfully placed real (demo) trades on Kalshi from a single button click in our custom UI Smart Curation: The "Generate from Trend" feature lets you type a sentence and see a list of financial contracts pop up is a glimpse into the future of trading interfaces.

## README (from the GitHub repository)

# BetBasket

**Trade prediction-market baskets like ETFs — one click, multiple markets.**

Built at [TreeHacks](https://www.treehacks.com/) 2026.

---

## The Idea

Prediction markets (like [Kalshi](https://kalshi.com)) let you bet on real-world outcomes — elections, Fed policy, AI milestones, sports. But placing individual bets across many markets is tedious. What if you could:

- **Pick a theme** — e.g., "AI stagnation in 2026"
- **Build a basket** — 5–10 related markets with one click
- **Trade the whole basket** — set a budget, preview cost, execute

Think of it as an **ETF for prediction markets**: diversify across a thesis instead of one-off contracts.

---

## What We Built

### Three Ways to Build a Basket

| Source | How it works |
|--------|--------------|
| **Pre-defined theme** | Curated trend baskets: AI Stagnation, Trump Economic Agenda, Climate Goals, Cannabis Policy |
| **Generate from trend** | Describe a belief in plain English → GPT-4o-mini picks markets and directions |
| **Search events** | Browse top events by volume, search by keyword, use any event as a basket |

### UX Features

- **For / Against toggle** — Flip the whole basket from betting on the trend to betting against it
- **Yes / No per leg** — Simple labels instead of BUY_YES/BUY_NO/SELL_YES/SELL_NO
- **Preview before execute** — See estimated cost, contracts, and orderbook before placing orders
- **Weighted legs** — Adjust allocation per market (default: equal weight)

### Tech Highlights

- **SQLite events DB** — Searchable index of 50k+ Kalshi events by volume
- **Keyword expansion** — "AI" → OpenAI, xAI, ChatGPT, Anthropic for smarter search
- **Structured output** — LLM returns JSON with market tickers, directions, weights (validated against candidate set)

---

## Tech Stack

| Layer | Tech |
|-------|------|
| **Backend** | FastAPI, Pydantic |
| **Frontend** | Streamlit |
| **Database** | SQLite (events index) |
| **LLM** | OpenAI GPT-4o-mini (structured output) |
| **API** | Kalshi demo (RSA-signed requests) |

---

## Technical Challenges & How We Solved Them

### 1. **Matching natural language to markets**

**Problem:** User says "AI progress will stall" — how do we find relevant markets among 50k+ events?

**Solution:** Keyword extraction + expansion. Short terms like "ai" expand to ["OpenAI", "xAI", "ChatGPT", "Anthropic"] so we search the events DB for each. We batch-fetch full market data from Kalshi and pass ~80 candidates to the LLM with tickers, titles, and rules.

### 2. **LLM hallucinating tickers**

**Problem:** The model might invent tickers that don't exist or are closed.

**Solution:** Strict schema + validation. We use `response_format` with a JSON schema so the model returns only `market_ticker`, `direction`, `weight`. We filter each leg: if the ticker isn't in our candidate set, we drop it. No hallucinated contracts reach the basket.

### 3. **Unified direction UX (For/Against vs Yes/No)**

**Problem:** BUY_YES, BUY_NO, SELL_YES, SELL_NO are confusing. Users think in terms of "I bet on this" or "I bet against this."

**Solution:** Two-level abstraction. A global **For / Against** toggle flips all legs (BUY_YES ↔ BUY_NO, SELL_YES ↔ SELL_NO). Per-leg we display **Yes / No** — betting the outcome happens or doesn't. Internally we still send Kalshi’s 4-direction enum.

### 4. **Events DB vs live API**

**Problem:** Kalshi’s API returns events with nested markets, but searching by keyword isn’t supported. We need volume-ordered, searchable events.

**Solution:** One-time init script fetches all open events, parses market tickers, stores in SQLite with `title`, `volume`, `markets_json`. Search uses SQL `LIKE` on title/series/category. Generate-from-trend and Search share the same event pool.

### 5. **Batch orders and pricing**

**Problem:** Each leg needs a price (ask for buy, bid for sell). Orders are GTC resting orders.

**Solution:** `basket_service` fetches markets in batches, applies overrides (direction, weight, enabled), computes per-leg budget and contract counts, builds Kalshi batch order payload. Preview shows est. cost before execute.

---

## How to Run

### Prerequisites

- Python 3.9+
- [Kalshi demo](https://demo.kalshi.com/) account (no real money)
- Optional: [OpenAI API key](https://platform.openai.com/) for "Generate from trend"

### 1. Clone & install

```bash
git clone <your-repo-url>
cd treehacks
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r backend/requirements.txt
pip install -r frontend/requirements.txt
```

### 2. Configure

```bash
cp backend/.env.example backend/.env
```

Edit `backend/.env`:

| Variable | Required for | Where to get |
|----------|--------------|--------------|
| `KALSHI_API_KEY_ID` | All | Kalshi demo → Account & security → API Keys |
| `KALSHI_PRIVATE_KEY_PATH` | All | Same page — download PEM, save as `backend/kalshi_private.key` |
| `OPENAI_API_KEY` | Generate from trend | [platform.openai.com](https://platform.openai.com/api-keys) |

### 3. Initialize events DB (required for search & generate)

```bash
cd backend
python scripts/init_events_db.py
```

Creates `events.db` with open events indexed by volume. Run once after clone; re-run if markets change.

### 4. Start the app

**Terminal 1 — API**

```bash
cd backend
uvicorn app.main:app --reload
```

**Terminal 2 — UI**

```bash
cd frontend
streamlit run streamlit_app.py
```

Open **http://localhost:8501**.

---

## Project Structure

```
treehacks/
├── backend/
│   ├── app/
│   │   ├── main.py           # FastAPI routes
│   │   ├── basket_service.py # Preview & execute
│   │   ├── llm_basket_service.py  # Generate from trend (LLM)
│   │   ├── events_db.py      # SQLite search
│   │   ├── kalshi_client.py  # RSA auth, markets, orders
│   │   └── models.py
│   ├── scripts/
│   │   ├── init_events_db.py # Populate events DB
│   │   └── build_themes_from_events.py
│   ├── themes.json           # Pre-defined trend baskets
│   └── requirements.txt
├── frontend/
│   ├── streamlit_app.py
│   └── requirements.txt
└── README.md
```

---

## License

MIT.

---

*Uses [Kalshi demo](https://demo.kalshi.com/) — no real money. Trade at your own risk.*


## Detected evidence (automated analysis)

Indexed codebase: 17 recognized source files, 80 KB.
- FastAPI (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- Streamlit (technology) — detected in the code

## Codebase structure (from repository index)

### Files (24 of 24)

```
.DS_Store
.gitignore
backend/.env.example
backend/app/__init__.py
backend/app/basket_service.py
backend/app/config.py
backend/app/events_db.py
backend/app/kalshi_client.py
backend/app/llm_basket_service.py
backend/app/main.py
backend/app/models.py
backend/app/test_order.py
backend/events_list_summary.json
backend/requirements.txt
backend/scripts/build_themes_from_events.py
backend/scripts/fetch_events.py
backend/scripts/init_events_db.py
backend/scripts/update_themes.py
backend/themes.json
frontend/requirements.txt
frontend/streamlit_app.py
frontend/test_buy.py
README.md
SETUP.md
```

### Dependencies

- backend/requirements.txt: cryptography@>=42.0.0, fastapi@>=0.109.0, httpx@>=0.26.0, openai@>=1.40.0, pydantic@>=2.5.0, python-dotenv@>=1.0.0, streamlit@>=1.29.0, uvicorn[standard]@>=0.27.0
- frontend/requirements.txt: httpx@>=0.26.0, streamlit@>=1.29.0

### Recent commits (newest first)

- Fix last minute bugs
- Push
- Kalshi Demo
- Juji's first go
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### SETUP.md

```markdown
# Setup (fresh repo) — Mac

Copy-paste these. One venv for the whole project.

### 1. Clone and venv

```bash
cd treehacks
python3 -m venv treehacks2026
source treehacks2026/bin/activate
```

### 2. Install deps

```bash
pip install -r backend/requirements.txt
pip install -r frontend/requirements.txt
```

### 3. Env file

```bash
cp backend/.env.example backend/.env
```

Edit `backend/.env`: set `KALSHI_API_KEY_ID` (from Kalshi → Account & security → API Keys). If you use the key file instead of PEM, set `KALSHI_PRIVATE_KEY_PATH=./kalshi_private.key` and put the `.key` file in `backend/`. For **Generate from trend**, set `OPENAI_API_KEY` (from platform.openai.com).

### 4. Run

**Terminal 1 – API**

```bash
cd backend
source ../treehacks2026/bin/activate
uvicorn app.main:app --reload
```

**Terminal 2 – UI**

```bash
cd frontend
source ../treehacks2026/bin/activate
streamlit run streamlit_app.py
```

Then open **http://localhost:8501**.

### 5. Test Buy (optional)

Minimal UI to place a single contract (hardcoded or LLM-found market):

```bash
cd frontend
source ../treehacks2026/bin/activate
streamlit run test_buy.py
```

Opens on another port (e.g. 8502). Use **Buy 1 YES contract** to verify the Kalshi demo dashboard updates. Requires backend running.

---

### 6. Refresh themes (optional)

Pre-defined themes may use markets that have closed. To refresh `backend/themes.json` with current open markets:

```bash
cd backend
source ../treehacks2026/bin/activate
python scripts/update_themes.py
```

Restart the backend (uvicorn) to load the updated themes.

### 7. Initialize events database (required for search)

To search events by keyword and build baskets:

```bash
cd backend
source ../treehacks2026/bin/activate
python scripts/init_events_db.py
```

Creates `events.db` with events indexed by volume. The UI shows top 20 traded events by default; type a keyword to filter (e.g. Fed, NBA, Democratic).

### 8. Fetch events to file (optional)

To export the full events list for reference:

```bash
cd backend
python scripts/fetch_events.py
```

Creates:
- `events_list_summary.json` — slim reference — committed to repo
- `events_list.json` — full export — gitignored

```

### frontend/requirements.txt

```
streamlit>=1.29.0
httpx>=0.26.0

```

### backend/requirements.txt

```
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
httpx>=0.26.0
pydantic>=2.5.0
cryptography>=42.0.0
streamlit>=1.29.0
python-dotenv>=1.0.0
openai>=1.40.0

```

### backend/app/main.py

```python
"""FastAPI app: themes and basket preview/execute."""
from __future__ import annotations

import json
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException

from app.basket_service import execute, preview
from app.events_db import get_event, search_events
from app.config import OPENAI_API_KEY
from app.kalshi_client import KalshiClient
from app.llm_basket_service import generate_basket
from app.test_order import HARDCODED_TICKER, HARDCODED_TITLE, place_order, search_market_by_query
from app.models import (
    BasketTheme,
    ExecuteRequest,
    ExecuteResponse,
    BatchOrderResultLeg,
    GenerateRequest,
    PreviewRequest,
)

app = FastAPI(title="BetBasket", version="0.1.0")

THEMES_PATH = Path(__file__).resolve().parent.parent / "themes.json"
_themes: Optional[list[BasketTheme]] = None
_kalshi: Optional[KalshiClient] = None


def get_themes() -> list[BasketTheme]:
    global _themes
    if _themes is None:
        with open(THEMES_PATH) as f:
            raw = json.load(f)
        _themes = [BasketTheme(**t) for t in raw]
    return _themes


def get_kalshi() -> KalshiClient:
    global _kalshi
    if _kalshi is None:
        _kalshi = KalshiClient()
    return _kalshi


@app.get("/themes")
def list_themes():
    """List all basket themes."""
    return {"themes": [t.model_dump() for t in get_themes()]}


@app.get("/themes/{theme_id}")
def get_theme(theme_id: str):
    """Get one theme by id."""
    for t in get_themes():
        if t.theme_id == theme_id:
            return t.model_dump()
    raise HTTPException(status_code=404, detail="Theme not found")


def _resolve_theme(body: PreviewRequest | ExecuteRequest):
    if body.theme is not None:
        return body.theme
    if not (body.theme_id or "").strip():
        raise HTTPException(status_code=400, detail="Provide theme_id or theme")
    themes = get_themes()
    theme = next((t for t in themes if t.theme_id == body.theme_id), None)
    if not theme:
        raise HTTPException(status_code=404, detail="Theme not found")
    return theme


@app.post("/basket/generate")
def basket_generate(body: GenerateRequest):
    """Generate a basket from a natural-language trend (LLM picks markets + directions + weights)."""
    try:
        theme = generate_basket(body.query, get_kalshi(), OPENAI_API_KEY)
        return theme.model_dump()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


@app.post("/basket/preview")
def basket_preview(body: PreviewRequest):
    """Preview basket order: estimated cost and contract counts per leg."""
    theme = _resolve_theme(body)
    result = preview(theme, body.total_budget_dollars, body.overrides, get_kalshi())
    return result.model_dump()


@app.post("/basket/execute")
def basket_execute(body: ExecuteRequest):
    """Execute basket order (batched Kalshi orders)."""
    theme = _resolve_theme(body)
    success, message, results = execute(theme, body.total_budget_dollars, body.overrides, get_kalshi())
    legs = [
        BatchOrderResultLeg(
            market_ticker=r.get("market_ticker", "?"),
            client_order_id=r.get("client_order_id"),
            order_id=r.get("order_id"),
            status=r.get("status"),
            error=r.get("error"),
        )
        for r in results
    ]
    return ExecuteResponse(success=success, message=message, legs=legs)


@app.get("/health")
def health():
    return {"status": "ok"}


@app.get("/markets/open")
def list_open_markets(limit: int = 200):
    """List open markets from Kalshi demo (public, no auth)."""
    markets = get_kalshi().get_open_markets(limit=limit)
    return {"markets": markets, "count": len(markets)}


@app.get("/markets")
def get_markets(tickers: str = ""):
    """Fetch specific markets by ticker (comma-separated). Returns list of market objects."""
    if not tickers.strip():
        return {"markets": []}
    ticker_list = [t.strip() for t in tickers.split(",") if t.strip()]
    if not ticker_list:
        return {"markets": []}
    by_ticker = get_kalshi().get_markets(ticker_list)
    return {"markets": [by_ticker[t] for t in ticker_list if t in by_ticker]}


@app.get("/events/search")
def search_events_api(q: Optional[str] = None, limit: int = 20):
    """Search events by keyword. Returns top `limit` by volume. Requires init: python scripts/init_events_db.py"""
    try:
        events = search_events(q=q, limit=limit)
        return {"events": events, "count": len(events)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Search failed: {e}. Run: python scripts/init_events_db.py")


@app.get("/events/open")
def list_open_events(limit: Optional[int] = None, with_nested_markets: bool = True):
    """List open events from Kalshi demo (public, no auth). Omit limit for full list."""
    events = get_kalshi().get_open_events(limit=limit, with_nested_markets=with_nested_markets)
    return {"events": events, "count": len(events)}


@app.get("/events/by/{event_ticker}")
def get_event_api(event_ticker: str):
    """Get one event by ticker for basket building."""
    ev = get_event(event_ticker)
    if not ev:
        raise HTTPException(status_code=404, detail="Event not found")
    return ev


@app.get("/themes/from-event/{event_ticker}")
def theme_from_event(event_ticker: str):
    """Build a basket theme from an event (for preview/execute)."""
    ev = get_event(event_ticker)
    if not ev:
        raise HTTPException(status_code=404, detail="Event not found")
    markets = ev.get("markets", [])
    if not markets:
        raise HTTPException(status_code=400, detail="Event has no markets")
    n = len(markets)
    legs = [
        {
            "market_ticker": m.get("market_ticker", ""),
            "event_ticker": m.get("event_ticker", ev.get("event_ticker", "")),
            "title": m.get("title", m.get("market_ticker", "Market")),
            "direction": "BUY_YES",
            "weight": 1.0 / n,
            "enab
[truncated — 1686 more characters]
```

### frontend/test_buy.py

```python
"""Minimal test UI: Buy 1 contract on hardcoded or LLM-found market. Run backend first."""
import os
import httpx

import streamlit as st

BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")


def api_get(path: str):
    with httpx.Client() as client:
        r = client.get(f"{BACKEND_URL}{path}", timeout=10.0)
        r.raise_for_status()
        return r.json()


def api_post(path: str, json_body: dict, timeout: float = 30.0):
    with httpx.Client() as client:
        r = client.post(f"{BACKEND_URL}{path}", json=json_body, timeout=timeout)
        r.raise_for_status()
        return r.json()


def main():
    st.set_page_config(page_title="Test Buy", layout="centered")
    st.title("Test: Single Contract Buy")

    try:
        api_get("/health")
    except Exception:
        st.error(f"Backend not reachable at {BACKEND_URL}. Start it: cd backend && uvicorn app.main:app --reload")
        st.stop()

    # --- Hardcoded market ---
    st.subheader("1. Hardcoded market")
    try:
        h = api_get("/test/hardcoded-market")
        ticker = h.get("ticker", "?")
        title = h.get("title", ticker)
    except Exception as e:
        st.error(str(e))
        ticker = None
        title = None

    if ticker:
        st.write(f"**{title}**")
        st.caption(f"Ticker: `{ticker}`")
        if st.button("Buy 1 YES contract (hardcoded)", type="primary", key="buy_hardcoded"):
            try:
                result = api_post("/test/place-order", {"ticker": ticker, "side": "yes"})
                if result.get("success"):
                    st.success(f"Order placed: {result.get('order_id')} | Status: {result.get('status')}")
                    st.info("Check your Kalshi demo dashboard — Positions or Resting.")
                else:
                    st.error(result.get("error", "Order failed"))
            except httpx.HTTPStatusError as e:
                st.error(e.response.text)
            except Exception as e:
                st.error(str(e))

    st.divider()

    # --- LLM search ---
    st.subheader("2. AI search: find market by query")
    query = st.text_input("Query", value="AI progress by OpenAI", key="query")
    if st.button("Search", key="search"):
        if not (query or "").strip():
            st.warning("Enter a query.")
        else:
            with st.spinner("Searching..."):
                try:
                    m = api_post("/test/search-market", {"query": query.strip()}, timeout=45.0)
                    st.session_state.ai_ticker = m.get("ticker")
                    st.session_state.ai_title = m.get("title", m.get("ticker"))
                except httpx.HTTPStatusError as e:
                    st.error(e.response.text)
                except Exception as e:
                    st.error(str(e))

    if "ai_ticker" in st.session_state:
        st.write(f"**{st.session_state.ai_title}**")
        st.caption(f"Ticker: `{st.session_state.ai_ticker}`")
        if st.button("Buy 1 YES contract (AI match)", type="primary", key="buy_ai"):
            try:
                result = api_post(
                    "/test/place-order",
                    {"ticker": st.session_state.ai_ticker, "side": "yes"},
                )
                if result.get("success"):
                    st.success(f"Order placed: {result.get('order_id')} | Status: {result.get('status')}")
                    st.info("Check your Kalshi demo dashboard — Positions or Resting.")
                else:
                    st.error(result.get("error", "Order failed"))
            except httpx.HTTPStatusError as e:
                st.error(e.response.text)
            except Exception as e:
                st.error(str(e))


if __name__ == "__main__":
    main()

```

### frontend/streamlit_app.py

```python
"""BetBasket — Trade themed baskets of prediction markets. Run backend first."""
from __future__ import annotations

import os
from datetime import datetime

import httpx
import streamlit as st

BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")

def _flip_direction(d: str) -> str:
    """Flip direction: For ↔ Against the outcome."""
    flip = {"BUY_YES": "BUY_NO", "BUY_NO": "BUY_YES", "SELL_YES": "SELL_NO", "SELL_NO": "SELL_YES"}
    return flip.get(d, "BUY_YES")


def _direction_to_yes_no(d: str) -> str:
    """Map Kalshi direction to Yes/No label (betting on outcome or against)."""
    return "Yes" if d in ("BUY_YES", "SELL_NO") else "No"


def api_get(path: str, params: dict | None = None):
    with httpx.Client() as client:
        r = client.get(f"{BACKEND_URL}{path}", params=params or {}, timeout=30.0)
        r.raise_for_status()
        return r.json()


def _fetch_market(ticker: str) -> dict | None:
    """Fetch a single market by ticker; returns None on error."""
    try:
        data = api_get("/markets", params={"tickers": ticker})
        markets = data.get("markets", [])
        return markets[0] if markets else None
    except Exception:
        return None


def api_post(path: str, json_body: dict, timeout: float = 15.0):
    with httpx.Client() as client:
        r = client.post(f"{BACKEND_URL}{path}", json=json_body, timeout=timeout)
        r.raise_for_status()
        return r.json()


def _format_close_time(close_time: str | None) -> str:
    if not close_time:
        return "—"
    try:
        dt = datetime.fromisoformat(close_time.replace("Z", "+00:00"))
        return dt.strftime("%b %d, %Y %H:%M UTC")
    except Exception:
        return str(close_time)[:19]


def _format_price(val: str | int | float | None) -> str:
    """Format a price value for display; return '—' if invalid."""
    if val is None or val == "":
        return "—"
    try:
        f = float(str(val).strip())
        return f"${f:.2f}"
    except (ValueError, TypeError):
        return "—"


def _format_volume(vol: int) -> str:
    if vol >= 1_000_000:
        return f"${vol/1e6:.1f}M"
    if vol >= 1_000:
        return f"${vol/1e3:.1f}K"
    return str(vol)


def _render_market_details(m: dict, *, show_orderbook: bool = True) -> None:
    """Render full Kalshi-style market details (same info as demo UI)."""
    ticker = m.get("ticker") or m.get("market_ticker", "")
    event_ticker = m.get("event_ticker", "")

    st.markdown("**Market info**")
    cols = st.columns([2, 2, 2])
    with cols[0]:
        st.caption(f"Ticker: `{ticker}`")
    with cols[1]:
        if event_ticker:
            st.caption(f"Event: `{event_ticker}`")
    with cols[2]:
        status = m.get("status", "")
        if status:
            st.caption(f"Status: **{status}**")

    open_time = m.get("open_time")
    close_time = m.get("close_time")
    exp_time = m.get("latest_expiration_time") or m.get("expiration_time")
    if open_time or close_time or exp_time:
        st.caption(
            f"Opens: {_format_close_time(open_time)} | "
            f"Closes: {_format_close_time(close_time)} | "
            f"Expires: {_format_close_time(exp_time)}"
        )

    if show_orderbook:
        st.markdown("**Orderbook**")
        ya_f = _format_price(m.get("yes_ask_dollars"))
        yb_f = _format_price(m.get("yes_bid_dollars"))
        na_f = _format_price(m.get("no_ask_dollars"))
        nb_f = _format_price(m.get("no_bid_dollars"))
        st.markdown(
            "| Side | Bid (sell at) | Ask (buy at) |\n|------|---------------|--------------|\n"
            f"| YES  | {yb_f} | {ya_f} |\n| NO   | {nb_f} | {na_f} |"
        )

    vol = m.get("volume") or m.get("volume_fp")
    vol_24h = m.get("volume_24h") or m.get("volume_24h_fp")
    liq = m.get("liquidity_dollars") or m.get("liquidity")
    last = m.get("last_price_dollars") or m.get("last_price")
    oi = m.get("open_interest") or m.get("open_interest_fp")
    if vol is not None or vol_24h is not None or liq is not None or last is not None or oi is not None:
        st.markdown("**Market stats**")
        stat_parts = []
        if vol is not None:
            stat_parts.append(f"Volume: {vol}")
        if vol_24h is not None:
            stat_parts.append(f"24h vol: {vol_24h}")
        if liq is not None:
            try:
                lf = float(str(liq).strip())
                stat_parts.append(f"Liquidity: ${lf:.2f}")
            except (ValueError, TypeError):
                stat_parts.append(f"Liquidity: {liq}")
        if last is not None:
            stat_parts.append(f"Last: {_format_price(last)}")
        if oi is not None:
            stat_parts.append(f"Open interest: {oi}")
        st.caption(" | ".join(stat_parts))

    yes_sub = m.get("yes_sub_title") or ""
    no_sub = m.get("no_sub_title") or ""
    if yes_sub or no_sub:
        st.markdown("**Contract meanings**")
        st.caption(f"YES = {yes_sub or '—'} | NO = {no_sub or '—'}")

    subtitle = m.get("subtitle", "")
    if subtitle:
        st.caption(f"*{subtitle}*")

    rules = m.get("rules_primary", "")
    rules2 = m.get("rules_secondary", "")
    if rules:
        st.markdown("**Settlement rules**")
        st.markdown(rules)
    if rules2:
        st.caption(rules2[:500] + ("…" if len(rules2 or "") > 500 else ""))

    result = m.get("result", "")
    if result:
        st.info(f"**Result:** {result.upper()}")

    st.markdown(f"[View on Kalshi demo →](https://demo.kalshi.com/markets/{ticker})")


st.set_page_config(page_title="BetBasket", layout="wide", initial_sidebar_state="expanded")

st.markdown("""
<style>
    .stApp { max-width: 1200px; margin: 0 auto; }
    h1 { color: #1a1a2e; font-weight: 700; }
</style>
""", unsafe_allow_html=True)

st.title("BetBasket")
st.markdown("Search events, build a basket, and trade in one click.")

try:
    api_get("/health")
except Exception:
    st.error(f"Backend not reachable at {BACKEND_URL}. Start: `uvicorn app.main:app --r
[truncated — 11208 more characters]
```

### backend/app/config.py

```python
"""Load config from environment."""
import os
from pathlib import Path

from dotenv import load_dotenv

load_dotenv()

# Kalshi API (use demo for testing: https://demo-api.kalshi.co)
KALSHI_BASE_URL: str = os.getenv("KALSHI_BASE_URL", "https://demo-api.kalshi.co")
KALSHI_API_KEY_ID: str = os.getenv("KALSHI_API_KEY_ID", "")
KALSHI_PRIVATE_KEY_PATH: str = os.getenv(
    "KALSHI_PRIVATE_KEY_PATH",
    str(Path(__file__).resolve().parent.parent / "kalshi_private.key"),
)
# Optional: inline PEM instead of file (e.g. in CI)
KALSHI_PRIVATE_KEY_PEM: str = os.getenv("KALSHI_PRIVATE_KEY_PEM", "")

# Backend URL for Streamlit (default same host)
BACKEND_URL: str = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")

# OpenAI (for LLM basket generation)
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")

```

### backend/scripts/init_events_db.py

```python
#!/usr/bin/env python3
"""Initialize events SQLite database from Kalshi API. Run from backend/."""
from __future__ import annotations

import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from app.events_db import init_schema, upsert_event, get_conn
from app.kalshi_client import KalshiClient


def _parse_volume(m: dict) -> int:
    v = m.get("volume")
    if v is not None:
        try:
            return int(v)
        except (ValueError, TypeError):
            pass
    vfp = m.get("volume_fp")
    if vfp is not None:
        try:
            return int(float(str(vfp).strip()))
        except (ValueError, TypeError):
            pass
    return 0


def main():
    client = KalshiClient()
    print("Fetching open events (with nested markets)...")
    events = client.get_open_events(with_nested_markets=True)

    conn = get_conn()
    init_schema(conn)

    for e in events:
        event_ticker = e.get("event_ticker", "")
        title = e.get("title", "")
        series_ticker = e.get("series_ticker", "") or ""
        category = e.get("category", "") or ""
        markets_raw = e.get("markets", [])
        market_count = len(markets_raw)
        volume = sum(_parse_volume(m) for m in markets_raw)
        markets_json = json.dumps([
            {
                "market_ticker": m.get("ticker", ""),
                "event_ticker": m.get("event_ticker", event_ticker),
                "title": (m.get("yes_sub_title") or m.get("title") or m.get("ticker", ""))[:200],
            }
            for m in markets_raw if m.get("ticker")
        ])
        upsert_event(conn, event_ticker, title, series_ticker, category, market_count, volume, markets_json)

    conn.commit()
    conn.close()

    print(f"Inserted {len(events)} events into events.db")
    print("Run the app and use search to browse.")


if __name__ == "__main__":
    main()

```

### backend/app/models.py

```python
"""Pydantic models for basket themes, preview, and API."""
from __future__ import annotations

from typing import Literal, Optional

from pydantic import BaseModel, Field

Direction = Literal["BUY_YES", "BUY_NO", "SELL_YES", "SELL_NO"]

DIRECTION_OPTIONS: list[Direction] = ["BUY_YES", "BUY_NO", "SELL_YES", "SELL_NO"]


class BasketLeg(BaseModel):
    market_ticker: str
    event_ticker: str
    title: str
    direction: Direction = "BUY_YES"
    weight: float = Field(ge=0.0, le=1.0)
    enabled: bool = True


class BasketTheme(BaseModel):
    theme_id: str
    name: str
    description: str
    legs: list[BasketLeg]


class LegOverride(BaseModel):
    enabled: Optional[bool] = None
    direction: Optional[Direction] = None
    weight: Optional[float] = None


class GenerateRequest(BaseModel):
    query: str = Field(min_length=1)


class PreviewRequest(BaseModel):
    theme_id: str = ""  # ignored when theme is set
    total_budget_dollars: float = Field(gt=0)
    overrides: dict[str, LegOverride] = Field(default_factory=dict)
    theme: Optional[BasketTheme] = None  # when set, use this instead of theme_id lookup


class ExecuteRequest(BaseModel):
    theme_id: str = ""
    total_budget_dollars: float = Field(gt=0)
    overrides: dict[str, LegOverride] = Field(default_factory=dict)
    theme: Optional[BasketTheme] = None


class BasketOrderPreviewLeg(BaseModel):
    market_ticker: str
    title: str
    direction: Direction
    price_dollars: float
    contracts: int
    est_cost_dollars: float
    warnings: list[str] = Field(default_factory=list)
    # Orderbook & settlement info
    yes_bid_dollars: Optional[float] = None
    yes_ask_dollars: Optional[float] = None
    no_bid_dollars: Optional[float] = None
    no_ask_dollars: Optional[float] = None
    close_time: Optional[str] = None
    rules_primary: Optional[str] = None


class BasketOrderPreview(BaseModel):
    total_budget_dollars: float
    legs: list[BasketOrderPreviewLeg]
    est_total_cost_dollars: float
    warnings: list[str] = Field(default_factory=list)


class BatchOrderResultLeg(BaseModel):
    market_ticker: str
    client_order_id: Optional[str] = None
    order_id: Optional[str] = None
    status: Optional[str] = None
    error: Optional[str] = None


class ExecuteResponse(BaseModel):
    success: bool
    message: str
    legs: list[BatchOrderResultLeg] = Field(default_factory=list)

```

### backend/scripts/fetch_events.py

```python
#!/usr/bin/env python3
"""Fetch all open events from Kalshi demo and save to events_list.json. Run from backend/."""
from __future__ import annotations

import json
import sys
from datetime import datetime, timezone
from pathlib import Path

# Add parent to path for app imports
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from app.kalshi_client import KalshiClient


def main():
    client = KalshiClient()
    print("Fetching open events (with nested markets)...")
    events = client.get_open_events(with_nested_markets=True)

    total_markets = sum(len(e.get("markets", [])) for e in events)
    fetched_at = datetime.now(timezone.utc).isoformat()

    # Full export (large; add to .gitignore if needed)
    out_full = {
        "fetched_at": fetched_at,
        "count": len(events),
        "total_markets": total_markets,
        "events": events,
    }
    out_full_path = Path(__file__).resolve().parent.parent / "events_list.json"
    with open(out_full_path, "w") as f:
        json.dump(out_full, f, indent=2)
    print(f"Saved full list to {out_full_path} ({len(events)} events, {total_markets} markets)")

    # Slim summary for repo reference (event_ticker, title, series, market_count, tickers)
    summary = {
        "fetched_at": fetched_at,
        "count": len(events),
        "total_markets": total_markets,
        "events": [
            {
                "event_ticker": e.get("event_ticker"),
                "title": (e.get("title") or "")[:120],
                "series_ticker": e.get("series_ticker"),
                "market_count": len(e.get("markets", [])),
                "market_tickers": [m.get("ticker") for m in e.get("markets", []) if m.get("ticker")],
            }
            for e in events
        ],
    }
    summary_path = Path(__file__).resolve().parent.parent / "events_list_summary.json"
    with open(summary_path, "w") as f:
        json.dump(summary, f, indent=2)
    print(f"Saved summary to {summary_path} (for repo reference)")

    # Print summary
    for i, e in enumerate(events[:20]):
        title = (e.get("title") or e.get("event_ticker", ""))[:60]
        mcount = len(e.get("markets", []))
        series = e.get("series_ticker", "")
        print(f"  {i+1}. {title}... ({mcount} markets) [{series}]")
    if len(events) > 20:
        print(f"  ... and {len(events) - 20} more")


if __name__ == "__main__":
    main()

```

[7 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]