# Polymarket Tennis

> Build observe-only Polymarket and Kalshi tennis market tooling on the polymarket-tennis Python package (MIT) plus the Live Tennis API free tier. Use when asked for a Polymarket tennis bot or market watcher, a Kalshi tennis trading bot, tennis prediction-market data, Gamma API tennis markets, matching a market to a live match, break-point or serving state next to market prices, or how a tennis retirement, walkover, or cancelled match settles (venue rule text, never hard-coded). Covers the real package API (GammaClient, LiveTennisClient, discover_tennis_markets, match_market, build_view, pmtennis CLI), the free-tier request budget (30/min, 100/day), the outcome and event_status fields, and the verbatim 2026 settlement matrix. No order execution, wallets, or strategy advice.

- Skill: `livetennisapi/polymarket-tennis` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add livetennisapi/polymarket-tennis`
- Raw SKILL.md: https://api.skillmd.com/api/skills/livetennisapi/polymarket-tennis/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Finance & Business
- License: MIT
- Author: livetennisapi (https://skillmd.com/u/livetennisapi)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/livetennisapi/polymarket-tennis

---


# Polymarket / Kalshi tennis trading data

> **Vendor-authored, observe-only.** This skill is maintained by the team behind the
> [Live Tennis API](https://livetennisapi.com). It teaches the `polymarket-tennis`
> package, which reads public market data and live scores. It contains no order
> execution, no wallet or private-key handling, no CLOB client, and no strategy
> advice. Execution is permanently out of scope. Nothing here is financial advice.

## Hard guardrails (apply to every file you write with this skill)

1. **Observe-only.** Never add order placement, wallet, private-key, or CLOB code to
   anything built on this package. If the user wants execution, it belongs in their
   own code behind a clearly separated seam, using the venue's own official
   interfaces, and this skill does not write it.
2. **Never hard-code a settlement rule.** Retirement and walkover payouts differ by
   venue (polymarket.com vs Polymarket US vs Kalshi) and by tour (ATP/WTA vs ITF).
   Read the market's own text — Gamma `description` (`market.raw["description"]`),
   Kalshi `rules_secondary` — and print it. The reference matrix in
   [references/settlement-rules.md](references/settlement-rules.md) is for the
   human, not for code branches.
3. **Respect the free tier: 30 requests/minute, 100 requests/day.** Every
   `LiveTennisClient` call costs one request; Gamma and Kalshi reads cost nothing.
   `pmtennis watch` is 1 request per poll at a 60 s default (minimum 30 s). A
   watcher that calls `live_matches()` + `fixtures()` per poll costs 2 per poll
   and must self-cap (the reference build caps at 96/day, `--interval 300`).
4. **Detect match endings with `outcome` and `event_status`, not `status`.**
   `status` is only the lifecycle (`upcoming|live|completed|cancelled`).
   `outcome` is `completed|retired|walkover|default|abandoned` and `null` until
   settled; `event_status` is the feed designator (`Retired`, `Walk Over`,
   `Cancelled`, `Postponed`, `Interrupted`); `withdrew` names who stopped.
5. **Never guess a market-to-match pairing.** `match_market` returns `None` on
   ambiguity; skip it. Use `override_match_id` / `--match-id` only when the user
   supplies the id explicitly.
6. **Tests stay offline.** Use trimmed fixtures and `httpx.MockTransport`; never
   put live calls in tests.

## Quick start

```bash
pip install polymarket-tennis          # Python 3.10+, depends only on httpx
pmtennis discover --matches-only --moneyline-only   # keyless, Gamma only
export LIVETENNIS_API_KEY=ltapi_...    # free, no card: https://livetennisapi.com/subscribe/free
pmtennis match atp-lehecka-fils-2026-08-17          # show the pairing decision + confidence
pmtennis watch atp-lehecka-fils-2026-08-17          # 1 request per poll, 60 s default
```

Env-var names differ between the two Live Tennis API tools: the Python package reads
`LIVETENNIS_API_KEY`; the `livetennisapi-mcp` server/plugin reads `LIVETENNISAPI_KEY`.
Same key value works in both.

Library form — every symbol below is exported from `polymarket_tennis.__init__`:

```python
from polymarket_tennis import (
    GammaClient, LiveTennisClient,
    discover_tennis_markets, match_market, build_view,
)

with GammaClient() as gamma, LiveTennisClient() as lta:
    markets = discover_tennis_markets(gamma, market_types={"moneyline"},
                                      matches_only=True)
    candidates = lta.live_matches() + lta.fixtures()   # 2 free-tier requests
    for market in markets:
        decision = match_market(market, candidates)
        if decision is None:
            continue  # ambiguous or no live counterpart — never guessed
        view = build_view(market, decision.match)
        print(view.render())
```

Sample `view.render()` output:

```text
Cincinnati Open: Jiri Lehecka vs Arthur Fils  [atp-lehecka-fils-2026-08-17]
  market: Jiri Lehecka 0.095 | Arthur Fils 0.905  (as of 12s ago)
  live:   Jiri Lehecka vs Arthur Fils  4-6 3-4 (15-40)  serving: Jiri Lehecka  [BREAK POINT]  (as of 8s ago)
```

## The package, in one screen

| Symbol | What it does | Network cost |
|---|---|---|
| `GammaClient()` | Polymarket Gamma API (keyless): `events()`, `event_by_slug()`, `market_by_id()`, `market_by_slug()`, `market()` | none against your key |
| `LiveTennisClient(api_key=None)` | Live Tennis API; key from `LIVETENNIS_API_KEY`: `matches()`, `live_matches()`, `match(id)`, `fixtures()`, `players(search)` | 1 request per call |
| `discover_tennis_markets(client, market_types=None, include_closed=False, matches_only=False, limit=100)` | normalized `TennisMarket` list from the `tennis` tag | Gamma only |
| `find_market(client, id_or_slug)` | one `TennisMarket` by Gamma id, market slug, or event slug | Gamma only |
| `match_market(market, candidates, override_match_id=None, threshold=0.70, ambiguity_margin=0.10)` | `MatchDecision` or `None` | pure |
| `build_view(market, match)` | `LiveMarketView` with both feeds' staleness | pure |
| `derive_break_point(score)`, `score_line(score)` | helpers used by the view | pure |
| `TennisMarket.price_by_outcome`, `.slug_date`, `.raw` | prices dict, slug date, the raw Gamma object (settlement text lives in `raw["description"]`) | — |

Full signatures, dataclass fields, and the live-match JSON shape are in
[references/api.md](references/api.md). Read it before writing code that touches
fields not shown above — in particular, `TennisMarket` has **no** `description`
attribute and `LiveMarketView` does **not** expose `outcome`; go through `.raw`
and `.match`.

## Workflow: build a watcher or bot data layer

1. **Discover** with `discover_tennis_markets(gamma, market_types={"moneyline"}, matches_only=True)`.
   Futures/outrights are dropped by `matches_only`; doubles markets are rejected by
   the matcher in v0.1.
2. **Fetch candidates once per poll**: `lta.live_matches()` (+ `lta.fixtures()` if
   you need pre-start matches). Count the requests; budget them against 100/day.
3. **Pair** each market with `match_market(market, candidates)`; skip `None`.
   Log `decision.confidence` and `decision.method` (`"explicit" | "names+date" | "names"`).
4. **Join** with `build_view(market, decision.match)`; read `view.prices`,
   `view.score_line`, `view.server` (1/2), `view.break_point`, `view.is_tiebreak`,
   `view.event_status`, and both `view.market_staleness()` / `view.live_staleness()`.
5. **Settlement branch**: read `view.match.get("outcome")` and
   `view.match.get("event_status")`; when `outcome in ("retired", "walkover",
   "default", "abandoned")` or `event_status == "Cancelled"`, print
   `view.market.raw.get("description")` verbatim (fall back to `rules_secondary`,
   then say the payload carries no rule text). Do not compute a payout.
6. **Paper only**: any "signal" the user asks for is logged to a local JSON book
   with a loud banner that no orders are sent. See the verified reference build.

```python
def settlement_text(view) -> str:
    raw = view.market.raw
    text = raw.get("description") or raw.get("rules_secondary")
    return text or "(market payload carries no settlement text; read it on the venue)"
```

## Kalshi

The package discovers **Polymarket** markets (Gamma). Kalshi publishes each market's
rule text in its public read endpoint, keyless; the same `outcome`/`event_status`
detection applies. Use the package's `LiveTennisClient` for the live side and read
Kalshi directly for the market side (see
[references/settlement-rules.md](references/settlement-rules.md) for the Kalshi
snippet and the ATP/WTA vs ITF series difference). Do not hard-code the ITF `$0.50`
rule; print `rules_secondary`.

## One-prompt build (verified output checked in)

When the user wants a watcher "vibe-coded" end to end, use the prompt in
[references/one-prompt-build.md](references/one-prompt-build.md). It is the exact
prompt from the package README; what it produced, unedited except for lint, is in
`examples/claude-code-watcher/` of the repository (7 offline tests, `ruff` clean,
`--once --fixtures` dry run without a key). That README also lists the honest
deviations — read them before re-running the prompt, because two of them are
traps: the prompt's "once a minute" costs 2 requests per poll (so the watcher must
self-cap), and the offline fixtures carry `event_status` but no `outcome` key.

## Retirement / walkover rule matrix (retrieved 2026-08-23)

The short version, for the human reading this — the code must still print the
market's own text:

| Scenario | Polymarket (polymarket.com) | Polymarket US | Kalshi (ATP/WTA) | Kalshi (ITF) |
|---|---|---|---|---|
| Walkover / withdrawal before the match starts | 50-50 | Last fair market price at announcement | Fair price per rules | $0.50 per contract |
| Match cancelled, not played | 50-50 | Last fair market price | Fair price per rules | $0.50 |
| Retirement after play starts (injury, default, DQ) | Advancing player wins | Awarded winner settles $1.00 | Winner resolves Yes ("after a ball has been played") | Winner Yes; withdrawing/forfeiting player No |
| Delayed / postponed | 50-50 if beyond 7 days with no winner | — (see venue FAQ) | Stays open, closes after rescheduled match (within two weeks) | Same as ATP/WTA |
| What counts as "started" | "the match begins" | First serve is struck | A ball has been played | A ball has been played |

Verbatim venue quotes, source URLs, retrieval dates, and the live-data mapping
(`outcome`/`event_status`/`withdrew` to each venue column) are in
[references/settlement-rules.md](references/settlement-rules.md). Rules are per
market and can change; the market's own text always wins over that file.

## Verification checklist before you hand code back

- `ruff check` clean; tests run with no network (`httpx.MockTransport` or fixture files).
- Every import resolves to a symbol listed in `references/api.md`.
- Request count per poll is computed and documented against 30/min, 100/day.
- No payout number appears in code; the venue text is printed instead.
- Match-end detection reads `outcome` / `event_status`, never `status == "completed"` alone.
- A banner states that no orders are ever sent.

## Further reading

- Package: https://github.com/livetennisapi/polymarket-tennis (PyPI `polymarket-tennis`)
- Pillar guide: https://blog.livetennisapi.com/blog/build-polymarket-tennis-trading-bot
- Verbatim rules: https://blog.livetennisapi.com/blog/polymarket-kalshi-tennis-retirement-walkover-rules
- Kalshi walkthrough: https://blog.livetennisapi.com/blog/kalshi-tennis-trading-bot
- Live Tennis API docs: https://docs.livetennisapi.com — free key: https://livetennisapi.com/subscribe/free
- Sibling skill (entry gate, simmer-sdk style): https://github.com/livetennisapi/simmer-tennis-live-gate
- MCP server for the same data: https://github.com/livetennisapi/livetennisapi-mcp

