Metadata-Version: 2.5
Name: langbot-sdk
Version: 0.1.0
Summary: Langbot SDK and CLI: data and simulation for every market on Kalshi and Polymarket. Market history, replays against recorded order books, a strategy runtime and a Kalshi adapter.
Project-URL: Homepage, https://langbot.ai
Project-URL: Documentation, https://langbot.ai/docs
Author-email: Langbot <hello@langbot.ai>
Maintainer-email: Langbot <hello@langbot.ai>
License-Expression: MIT
License-File: LICENSE
Keywords: backtesting,kalshi,mcp,order-book,polymarket,prediction-markets,trading-agents
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: cryptography>=42
Requires-Dist: fastapi>=0.110
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Requires-Dist: python-ulid>=2.2
Requires-Dist: rich>=13
Requires-Dist: typer>=0.12
Requires-Dist: uvicorn>=0.29
Requires-Dist: websockets>=13
Provides-Extra: dev
Requires-Dist: jsonschema>=4.21; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: x402
Requires-Dist: eth-account>=0.13; extra == 'x402'
Requires-Dist: x402[evm]>=2.26; extra == 'x402'
Description-Content-Type: text/markdown

# langbot-sdk

Python SDK and CLI for [Langbot](https://langbot.ai), the data and simulation layer for every
market on Kalshi and Polymarket. Look up any market and its price history, replay a strategy
against the order books Langbot recorded, with real fees and latency, then run the same file
live with your own Kalshi keys: paper first, live only when you say so.

```bash
pip install langbot-sdk      # Python 3.12+; the import name and the command are `langbot`
uv tool install langbot-sdk  # or: just the `langbot` command, isolated
```

The PyPI name `langbot` belongs to an unrelated project; install `langbot-sdk`.

**No token needed** for the first step:

```bash
langbot markets senate                           # search every Kalshi and Polymarket market
langbot market KXEARNINGSMENTIONGS-26OCT13-PRIV  # one market: price history, result, replayable books
langbot events                                   # live and upcoming events with markets
```

With a free token (`langbot login`):

```bash
langbot sim events                               # events with recorded books
langbot sim run -e ev_kxearningsmentiongs_26oct13 -s my_strategy.py   # replay, scorecard
langbot run my_strategy.py --mode demo           # the same file, paper, on the live feed
langbot calendar --days 7                        # upcoming events, speakers, rights
langbot context KXFEDMENTION-26OCT-TARIFF        # rules + book + base rate + event + mentions
langbot rates --speaker powell --phrase transitory [--series KXFEDMENTION] [--window-days 365]
langbot outcomes --speaker powell --since 2026-01-01   # settled markets, paginated (--cursor)
```

Every command takes `--json` for the raw response.

## Client

Runs as is, without a token (anonymous calls get the Free delay, +60 s):

```python
import asyncio

from langbot import Langbot  # alias of LangbotClient


async def main() -> None:
    # token: LANGBOT_API_TOKEN, else the one `langbot login` saved, else anonymous
    # api_url: LANGBOT_API_URL, else https://api.langbot.ai (stream: wss://api.langbot.ai)
    async with Langbot() as lb:
        hits = await lb.recent_events(types=["phrase_detected"], limit=5)
        for e in hits:
            print(e.event_id, e.phrase, e.t_spoken, e.verdict.counts if e.verdict else None)
        if hits:
            page = await lb.transcript(hits[-1].event_id)  # words, text, last_seq
            print(page.text[:200] or f"words withheld for {page.redacted_sources}")
        async for evt in lb.stream(types=["phrase_detected"]):
            print(evt.phrase, evt.t_spoken, evt.lag_vs_public_ms)


asyncio.run(main())
```

`transcript()` returns one page (`words`, `text`, `last_seq`, `held`, `delay_ms`,
`redacted_sources`); poll with `since=page.last_seq`. Restricted sources return no words.

Feed errors raise `LangbotAPIError` (`.status`, `.detail`, `.url`); `LangbotApiError` is the
same class under the TypeScript SDK's spelling. A stream refused on policy (close `1008`: your
plan's connection cap) raises `StreamRefused` instead of reconnecting. A local feed:
`Langbot("http://localhost:8000")`. Examples that run against the hosted feed:
[`examples/`](examples/) (`recent_hits.py`, `transcript_pages.py`, `stream_hits.py`).

### Same calls in the TypeScript SDK

The TypeScript SDK uses the Python SDK's names in camelCase (`recent_events` → `recentEvents`).
The old TypeScript names (`getEvent`, `getTranscript`, `listMarkets`, `listSubscriptions`,
`listTokens`) still work for one release, marked deprecated.

| | Python (`langbot`) | TypeScript (`@langbotai/sdk`) |
|---|---|---|
| client | `LangbotClient` / `Langbot` | `LangbotClient` / `Langbot` |
| live + upcoming events | `live_events()` | `liveEvents()` |
| one event | `event(id)` | `event(id)` |
| recent hits (one page) | `recent_events(event_id, phrases=, types=, since=, until=, cursor=, limit=)` | `recentEvents({eventId, phrases, types, since, until, cursor, limit})` |
| every page, newest first | `iter_recent_events(...)` | `iterRecentEvents({...})` |
| transcript page | `transcript(id, since=)` → `Transcript` | `transcript(id, {since})` → `Transcript` |
| every market (no token) | `public_markets(q, venue=, status=, category=, limit=)` | `publicMarkets({q, venue, status, category, limit})` |
| one market + history (no token) | `public_market(ticker)` | `publicMarket(ticker)` |
| markets with recorded events | `markets(q=, venue=, status=)` | `markets({q, venue, status})` |
| subscriptions / tokens | `subscriptions()`, `tokens()` | `subscriptions()`, `tokens()` |
| live stream | `stream(...)`, `stream_sse(...)` | `stream(...)`, `streamSSE(...)` |
| feed error | `LangbotAPIError` (`.status`, `.code`, `.detail`, `.url`) | `LangbotAPIError` (`.status`, `.code`, `.detail`, `.url`, `.body`) |
| refused stream (1008) | raises `StreamRefused` (`.code`) | `onStatus({state: "closed", code: 1008, willRetry: false})` after a `server_error` |
| token default | `LANGBOT_API_TOKEN`, else `~/.config/langbot/config.toml` | `LANGBOT_API_TOKEN` (the npm CLI also reads `config.toml`) |
| config file (both CLIs) | `~/.config/langbot/config.toml`, `LANGBOT_CONFIG` | the same file (falls back to the old `config.json`) |

Both read unknown keys and unknown enum values without failing, and both are tested against
the feed's own response shapes (`packages/schemas/fixtures/feed-responses.json`).

## History, calendar and context

```python
from langbot import LangbotClient

async with LangbotClient() as lb:
    # list[CalendarEntry]: .rights, .speaker_keys, .phrases, .restricted
    cal = await lb.calendar(days=7)
    # MarketContext: .market .book .base_rates .event .mentions .rules
    ctx = await lb.context("KXFEDMENTION-26OCT-TARIFF")
    # BaseRates: .yes_rate (P(YES)), .markets (sample), .mispricing (avg result - open)
    br = await lb.base_rates(speaker="powell", phrase="transitory", window_days=365)
    # OutcomesPage: .items (list[MarketOutcome]), .next_cursor
    page = await lb.outcomes(speaker="powell", limit=50)
    # list[Mention]: phrase facts only, never transcript text
    facts = await lb.mentions(phrase="blackwell", since=1_760_000_000_000)
    who = await lb.speakers(q="pow")  # list[SpeakerProfile]
```

Base rates and outcomes are the venue's public settlement record; mentions are Langbot's own
`phrase_detected` facts. For `restricted` / `unknown` sources only phrase facts are ever returned
(no transcript text) — `CalendarEntry.restricted` tells you ahead of time.

## Write a strategy

One protocol for `langbot run`, `langbot backtest` and `langbot sim run`: a class with a
synchronous `on_event(evt, ctx)` (the sim protocol below; `langbot.adapter` drives it live).

```python
class Mention:
    markets = ["KXEARNINGSMENTIONNVDA-*"]  # glob over tickers
    phrases = ["blackwell"]

    def on_event(self, evt, ctx):
        if evt["type"] == "phrase_detected" and evt.get("phrase") == "blackwell":
            for ticker in evt["market_refs"]:
                book = ctx.book(ticker)
                if book and book.yes_ask and book.yes_ask < 0.60:
                    ctx.buy(ticker, "yes", 10, limit=book.yes_ask, tif="ioc")
```

`ctx`: `now`, `tickers`, `book(ticker)`, `position(ticker)`, `orders(ticker)`,
`buy(ticker, side, qty, limit=None, tif=None)`, `sell(...)`, `cancel_all(ticker=None)`.
The older `class X(langbot.Strategy)` with async hooks still runs, with a deprecation warning.

Every real order passes through the **live guard** (`langbot.live`): hard limits from
`~/.config/langbot/live.toml` (per order, per market, total exposure, open orders, orders per
minute, daily loss, price through the book, series allow/deny; a missing file means the
smallest limits), a kill switch (`langbot live halt` / `resume`, plus automatic halts on daily
loss, reconciliation mismatch, repeated API errors, stale books and clock skew), reconciliation
against Kalshi every 15 s, idempotent client order ids, and an append-only audit log.

```bash
langbot live run my_strategy.py --dry-run            # Kalshi demo, nothing sent
langbot live run my_strategy.py                      # Kalshi demo
langbot live run my_strategy.py --env prod --yes-real-money
langbot live halt --cancel-orders                    # stop now, pull resting orders
```

Demo is the default. Prod needs `--yes-real-money` and a non-empty `series_allow`. See
`docs/risk.md` for every limit and halt.

## Langbot Sim: test a bot against a past event

`langbot.sim` replays a recorded event through your strategy against the Kalshi order books we
recorded while it happened (1 s snapshots), then scores it. Your code runs on your machine; the
feed serves the timeline (`GET /v1/sim/events/{id}/timeline`) and only ever runs its own
built-in strategies (`POST /v1/sim/runs`). Both use this same engine, so the same strategy on
the same event gives the same scorecard locally and on the server.

```python
# my_bot.py
class FirstHit:
    name = "first_hit"

    def __init__(self, qty=10, max_price=0.90):
        self.qty, self.max_price = qty, max_price

    def on_event(self, evt, ctx):  # every feed event, as the stream sends it
        if evt["type"] != "phrase_detected":
            return
        if (evt.get("verdict") or {}).get("counts") is False:  # §21: does not settle
            return
        for ticker in evt["market_refs"]:
            if not ctx.position(ticker) and not ctx.orders(ticker):
                ctx.buy(ticker, "yes", self.qty, limit=self.max_price, tif="ioc")

    # optional: def on_book(self, ticker, book, ctx), on_fill(self, fill, ctx),
    #           on_start(self, ctx), on_end(self, ctx)
```

```bash
langbot sim events                                   # what can be replayed, with coverage
langbot sim run --event ev_kxearningsmentiongs_26oct13 \
    --strategy my_bot.py:FirstHit --latency 250,2000,5000 --params '{"qty": 5}'
langbot sim run --event ev_… --strategy my_bot.py --timeline gs.ndjson   # cache, then offline
langbot sim strategies                               # the built-ins
langbot sim run --event ev_… --strategy fade_rule_mismatch --server      # a built-in, on the feed
langbot research bench --strategy my_bot.py --latency 250,2250           # every recorded event, one document
langbot research calibration --speaker powell --basis mid                # openings vs settlements, with intervals
```

Hooks are synchronous (no `await`); the clock is `ctx.now` (ms). `ctx.buy(ticker, side="yes"|"no",
qty, limit=None, tif=None)` / `ctx.sell(...)` return an `Order`; `ctx.position(ticker)` (`.yes`,
`.no`, `.net`), `ctx.book(ticker)` (`yes_bid`, `yes_ask`, `no_bid`, `no_ask`, `mid`, `bids`,
`asks`), `ctx.orders(ticker)`, `ctx.cancel(order_id)`, `ctx.cash`, `ctx.tickers`. Strategies never
see settlement results.

**Fill model** (`langbot/sim/engine.py` has the details):

- An order placed at `ctx.now` arrives at `now + latency` and matches the first snapshot at or
  after that, walking its depth levels; size that runs out leaves a partial fill. A market order
  (no `limit`) or `tif="ioc"` cancels the rest; a limit order rests.
- A resting order fills only when a later snapshot's best opposite price moves *through* its
  limit (not on touch), at the limit, up to the size shown through it.
- No market impact: the recording never reacts to you (size you take is gone only within that
  snapshot). `--queue-position` makes resting orders wait behind the size recorded at their price.
- Fees per order execution: `ceil(0.07 · Σ C·P·(1−P) · 100) / 100` taker; resting fills pay 0.0175.
- Positions settle at the market's result (1.00 / 0.00); a market without one is marked to its
  last mid and flagged.

The scorecard: P&L (gross, fees, net), per market, orders and fills, fill rate, max drawdown,
exposure, a latency table (by default your latency, +2 s, +5 s) and the caveats that apply.

In code:

```python
from langbot import LangbotClient
from langbot.sim import load_strategy, run_scorecard

async with LangbotClient() as lb:
    timeline = await lb.sim_timeline("ev_kxearningsmentiongs_26oct13")
card = run_scorecard(timeline, load_strategy("my_bot.py:FirstHit"), latencies=[250, 2250, 5250])
print(card["pnl"], card["latency_sensitivity"])
```

For a timeline too big to hold, stream it: `ScorecardRun(Timeline.header(event_id, markets,
booked=tickers_with_books), strategy, latencies=[...])`, then `feed(page)` for each page of
`TimelineItem`s in timeline order and `finish()`. Every latency runs in the same pass, memory
stays at a book per ticker plus your orders and fills, and the scorecard is identical to
`run_scorecard` on the whole timeline. The feed's server runs work this way.

**Event study.** How do prices move after the speaker says a word? `langbot sim study` pools
every trigger (detected phrases, and transcript words when you pass `--words`; multi-word phrases
match consecutive words) across events and reports, per (word, ticker), the mean / median / stdev
move of the YES mid in cents and the share up / down / flat at each horizon, plus a chronological
train/test split and caveats (small n, how many pairs you tested):

```bash
langbot sim study --event ev_a --event ev_b --words "tariff,rate cut" --horizons 5,30,300
langbot sim study --event ev_a --words tariff --tickers KX… --clock spoken --json
```

Triggers are timed by default when the data was emitted (`--clock emitted`, plus `--delay-ms`),
i.e. when you could have traded. `--clock spoken` uses when the words were said and is not
tradeable: the broadcast can run 20-30 s behind the market, so it credits words with moves that
already happened. In code: `event_study(timeline, words=[...], tickers=None)` or
`event_study_many([tl_a, tl_b], ...)` from `langbot.sim`.

Plans: every plan runs Sim on any recorded event. Free has 20 runs a month, Builder 100,
Standard and up unlimited. A run is one server run or one timeline download (its first page).
Past the allowance the feed answers `429` with `code == "quota_exceeded"`; the error's `.quota`
says what was used and when it resets (`resets_at`).

## Modules

| module | what |
|---|---|
| `langbot.schemas` | pydantic models mirroring `packages/schemas/event.schema.json` (`Event`, `Speaker`, `EventType`, `SourceKind`) plus `Book`, `Fill`, `Order`, `Market`, `LiveEvent`, `MarketOutcome`, `BaseRates`, `SpeakerProfile`, `Mention`, `CalendarEntry`, `MarketContext`, `new_event_id()` |
| `langbot.client` | `Langbot` / `LangbotClient` — every feed endpoint incl. `calendar()`, `context()`, `base_rates()`, `outcomes()`, `mentions()`, `speakers()`; `stream()` (WS, auto-reconnect), `stream_sse()` |
| `langbot.kalshi` | `KalshiClient` — Trade API v2 with RSA-PSS / Ed25519 signing, token-bucket rate limiter, `ws_subscribe()` with orderbook reconstruction |
| `langbot.strategy` | `Strategy`, `RiskRails`, `PaperBroker`, `KalshiBroker`, `Position` |
| `langbot.live` | `LiveGuard`, `GuardedBroker`, `Limits`/`load_limits`, `KillSwitch`, `AuditLog`, the `Venue` protocol, `KalshiVenue`, `PolymarketUSVenue` |
| `langbot.runtime` | `langbot run`: feed stream + Kalshi books + local daemon on `127.0.0.1:8787` |
| `langbot.backtest` | replay archived events + book snapshots (feed or JSONL) at original timestamps |
| `langbot.sim` | Langbot Sim: the deterministic engine (fill model, fees, settlement; `queue_position`, `taker_delay_ms`, `maker_fee_rate` settings), scorecard and built-in strategies the feed also runs (`quote_both_sides` is the market-making baseline); `event_study` for price moves after words; `langbot.sim.bench` aggregates a strategy across events (bootstrap interval, p-values with FDR, fee sensitivity, walk-forward) and `langbot.sim.stats` holds the shared tests |
| `langbot.notify` | Discord / Slack / Telegram notifiers (`notifier_from_env()`) |
| `langbot.cli` | the `langbot` command |

## Paying per call

Some endpoints are sold by the call (evidence packs, provenance reports, replay bundles,
history, facts — CONTRACTS §17). Without a plan that covers them the feed answers
`402 Payment Required` in the [x402](https://x402.org) v2 shape, and the client can pay it in
USDC with a wallet you hold:

```bash
pip install "langbot-sdk[x402]"        # adds the official x402 client + eth-account (pulls web3)
export LANGBOT_X402_WALLET=0x…         # hex private key of a funded wallet; yours, never Langbot's
```

```python
import os
from langbot import LangbotClient, PaymentRequired

async with LangbotClient(x402_wallet=os.environ["LANGBOT_X402_WALLET"], x402_max_usd=1.0) as lb:
    bundle = await lb.replay("ev_nvda_q3_2026")  # 402 → signed retry → 200, one call
    print(lb.last_payment)  # {price_usd, header, network, settlement: {transaction, payer, …}}
```

What happens on a 402 whose `PAYMENT-REQUIRED` header and JSON body carry the x402 v2
`PaymentRequired` object `{x402Version: 2, resource: {url, …}, accepts: [{scheme: "exact",
network: "eip155:8453", amount: "250000", asset: <USDC>, payTo, maxTimeoutSeconds, extra: {name, version}}]}`:

- the price is `accepts[0].amount` ÷ asset decimals (USDC: 6). At or under `x402_max_usd`
  (default **$1**) the client signs an EIP-3009 `transferWithAuthorization` for exactly that
  amount to `payTo` (EIP-712 domain from `extra.name` / `extra.version`) and retries the same
  request **once** with `PAYMENT-SIGNATURE` = base64 `{x402Version: 2, accepted: <accepts[0]
  verbatim>, payload: {signature, authorization}}`;
- over the cap, without a wallet, or for a scheme other than `exact`, it raises
  `PaymentRequired(price_usd, requirements)` (a `LangbotAPIError` with status 402) and signs
  nothing;
- a 402 that is not x402-shaped (a plan gate) is the usual `LangbotAPIError`; a failed
  settlement (402 + `PAYMENT-RESPONSE {success: false}`) is a `LangbotAPIError`
  "payment failed: <reason>" and is never retried;
- the feed's `PAYMENT-RESPONSE` settlement is decoded into `client.last_payment["settlement"]`.

CLI: `langbot --x402-wallet-env LANGBOT_X402_WALLET replay ev_…` reads the key from that env
var (never from an argument; `LANGBOT_X402_WALLET` is used when set even without the flag),
`--x402-max-usd` sets the cap, and a paid call prints `paid $0.5000 USDC on eip155:8453 …` on stderr.
The key is never logged, printed or stored; only signatures leave the process.

## Configuration

`~/.config/langbot/config.toml` (written by `langbot init`), overridden by env:

```
LANGBOT_API_URL, LANGBOT_API_TOKEN
KALSHI_KEY_ID, KALSHI_PRIVATE_KEY_PATH, KALSHI_ENV=demo|prod
LANGBOT_MAX_POSITION_USD, LANGBOT_MAX_DAILY_LOSS_USD, LANGBOT_MARKET_ALLOWLIST
DISCORD_WEBHOOK_URL, SLACK_WEBHOOK_URL, TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID
LANGBOT_I_UNDERSTAND_LIVE=1   # required for --mode live (`langbot live run --env prod` uses --yes-real-money)
LANGBOT_LIVE_CONFIG            # live limits file (default ~/.config/langbot/live.toml)
LANGBOT_LIVE_DIR               # HALT flag + audit logs (default ~/.config/langbot/live)
LANGBOT_X402_WALLET            # hex wallet key for paying per call (langbot-sdk[x402])
```

## Local daemon

`langbot run` serves `http://127.0.0.1:8787`: `GET /status`, `GET /strategies`,
`POST /strategies` `{path, mode}`, `POST /strategies/{id}/start|stop`, `GET /positions`,
`GET /fills`. The MCP server's `run_paper` tool talks to it.

## Kalshi notes

Prices are dollars (`0.42`), sent to Kalshi as fixed-point strings (`"0.4200"`); counts
are contracts (`"10.00"`). Orders are bids/asks on the **YES** book. Signing message is
`timestamp_ms + METHOD + path` where `path` includes `/trade-api/v2` and no query string.

## Install from source

From a checkout of this repository (Python 3.12+, [uv](https://docs.astral.sh/uv/)):

```bash
uv sync --all-packages                     # every workspace package into .venv
uv run langbot --help
uv run python packages/sdk-py/examples/recent_hits.py
# or into another project's environment:
pip install ./packages/sdk-py
```

## Develop

```bash
uv run --package langbot-sdk pytest packages/sdk-py -q
uv run ruff check packages/sdk-py
```

MIT. The SDK, CLI and runtime are public so you can read exactly what runs on your machine with your keys; the hosted feed is a separate, proprietary service at [langbot.ai](https://langbot.ai).
