Polymarket / Kalshi tennis trading data
Vendor-authored, observe-only. This skill is maintained by the team behind the
Live Tennis API. 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)
- 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.
- 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 is for the
human, not for code branches.
- 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).
- 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.
- 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.
- Tests stay offline. Use trimmed fixtures and
httpx.MockTransport; never
put live calls in tests.
Quick start
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__:
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:
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. 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
- 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.
- Fetch candidates once per poll:
lta.live_matches() (+ lta.fixtures() if
you need pre-start matches). Count the requests; budget them against 100/day.
- Pair each market with
match_market(market, candidates); skip None.
Log decision.confidence and decision.method ("explicit" | "names+date" | "names").
- 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().
- 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.
- 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.
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 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. 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. 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
1---2name: polymarket-tennis3description: 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.4license: MIT5---67# Polymarket / Kalshi tennis trading data89> **Vendor-authored, observe-only.** This skill is maintained by the team behind the10> [Live Tennis API](https://livetennisapi.com). It teaches the `polymarket-tennis`11> package, which reads public market data and live scores. It contains no order12> execution, no wallet or private-key handling, no CLOB client, and no strategy13> advice. Execution is permanently out of scope. Nothing here is financial advice.1415## Hard guardrails (apply to every file you write with this skill)16171. **Observe-only.** Never add order placement, wallet, private-key, or CLOB code to18 anything built on this package. If the user wants execution, it belongs in their19 own code behind a clearly separated seam, using the venue's own official20 interfaces, and this skill does not write it.212. **Never hard-code a settlement rule.** Retirement and walkover payouts differ by22 venue (polymarket.com vs Polymarket US vs Kalshi) and by tour (ATP/WTA vs ITF).23 Read the market's own text — Gamma `description` (`market.raw["description"]`),24 Kalshi `rules_secondary` — and print it. The reference matrix in25 [references/settlement-rules.md](references/settlement-rules.md) is for the26 human, not for code branches.273. **Respect the free tier: 30 requests/minute, 100 requests/day.** Every28 `LiveTennisClient` call costs one request; Gamma and Kalshi reads cost nothing.29 `pmtennis watch` is 1 request per poll at a 60 s default (minimum 30 s). A30 watcher that calls `live_matches()` + `fixtures()` per poll costs 2 per poll31 and must self-cap (the reference build caps at 96/day, `--interval 300`).324. **Detect match endings with `outcome` and `event_status`, not `status`.**33 `status` is only the lifecycle (`upcoming|live|completed|cancelled`).34 `outcome` is `completed|retired|walkover|default|abandoned` and `null` until35 settled; `event_status` is the feed designator (`Retired`, `Walk Over`,36 `Cancelled`, `Postponed`, `Interrupted`); `withdrew` names who stopped.375. **Never guess a market-to-match pairing.** `match_market` returns `None` on38 ambiguity; skip it. Use `override_match_id` / `--match-id` only when the user39 supplies the id explicitly.406. **Tests stay offline.** Use trimmed fixtures and `httpx.MockTransport`; never41 put live calls in tests.4243## Quick start4445```bash46pip install polymarket-tennis # Python 3.10+, depends only on httpx47pmtennis discover --matches-only --moneyline-only # keyless, Gamma only48export LIVETENNIS_API_KEY=ltapi_... # free, no card: https://livetennisapi.com/subscribe/free49pmtennis match atp-lehecka-fils-2026-08-17 # show the pairing decision + confidence50pmtennis watch atp-lehecka-fils-2026-08-17 # 1 request per poll, 60 s default51```5253Env-var names differ between the two Live Tennis API tools: the Python package reads54`LIVETENNIS_API_KEY`; the `livetennisapi-mcp` server/plugin reads `LIVETENNISAPI_KEY`.55Same key value works in both.5657Library form — every symbol below is exported from `polymarket_tennis.__init__`:5859```python60from polymarket_tennis import (61 GammaClient, LiveTennisClient,62 discover_tennis_markets, match_market, build_view,63)6465with GammaClient() as gamma, LiveTennisClient() as lta:66 markets = discover_tennis_markets(gamma, market_types={"moneyline"},67 matches_only=True)68 candidates = lta.live_matches() + lta.fixtures() # 2 free-tier requests69 for market in markets:70 decision = match_market(market, candidates)71 if decision is None:72 continue # ambiguous or no live counterpart — never guessed73 view = build_view(market, decision.match)74 print(view.render())75```7677Sample `view.render()` output:7879```text80Cincinnati Open: Jiri Lehecka vs Arthur Fils [atp-lehecka-fils-2026-08-17]81 market: Jiri Lehecka 0.095 | Arthur Fils 0.905 (as of 12s ago)82 live: Jiri Lehecka vs Arthur Fils 4-6 3-4 (15-40) serving: Jiri Lehecka [BREAK POINT] (as of 8s ago)83```8485## The package, in one screen8687| Symbol | What it does | Network cost |88|---|---|---|89| `GammaClient()` | Polymarket Gamma API (keyless): `events()`, `event_by_slug()`, `market_by_id()`, `market_by_slug()`, `market()` | none against your key |90| `LiveTennisClient(api_key=None)` | Live Tennis API; key from `LIVETENNIS_API_KEY`: `matches()`, `live_matches()`, `match(id)`, `fixtures()`, `players(search)` | 1 request per call |91| `discover_tennis_markets(client, market_types=None, include_closed=False, matches_only=False, limit=100)` | normalized `TennisMarket` list from the `tennis` tag | Gamma only |92| `find_market(client, id_or_slug)` | one `TennisMarket` by Gamma id, market slug, or event slug | Gamma only |93| `match_market(market, candidates, override_match_id=None, threshold=0.70, ambiguity_margin=0.10)` | `MatchDecision` or `None` | pure |94| `build_view(market, match)` | `LiveMarketView` with both feeds' staleness | pure |95| `derive_break_point(score)`, `score_line(score)` | helpers used by the view | pure |96| `TennisMarket.price_by_outcome`, `.slug_date`, `.raw` | prices dict, slug date, the raw Gamma object (settlement text lives in `raw["description"]`) | — |9798Full signatures, dataclass fields, and the live-match JSON shape are in99[references/api.md](references/api.md). Read it before writing code that touches100fields not shown above — in particular, `TennisMarket` has **no** `description`101attribute and `LiveMarketView` does **not** expose `outcome`; go through `.raw`102and `.match`.103104## Workflow: build a watcher or bot data layer1051061. **Discover** with `discover_tennis_markets(gamma, market_types={"moneyline"}, matches_only=True)`.107 Futures/outrights are dropped by `matches_only`; doubles markets are rejected by108 the matcher in v0.1.1092. **Fetch candidates once per poll**: `lta.live_matches()` (+ `lta.fixtures()` if110 you need pre-start matches). Count the requests; budget them against 100/day.1113. **Pair** each market with `match_market(market, candidates)`; skip `None`.112 Log `decision.confidence` and `decision.method` (`"explicit" | "names+date" | "names"`).1134. **Join** with `build_view(market, decision.match)`; read `view.prices`,114 `view.score_line`, `view.server` (1/2), `view.break_point`, `view.is_tiebreak`,115 `view.event_status`, and both `view.market_staleness()` / `view.live_staleness()`.1165. **Settlement branch**: read `view.match.get("outcome")` and117 `view.match.get("event_status")`; when `outcome in ("retired", "walkover",118 "default", "abandoned")` or `event_status == "Cancelled"`, print119 `view.market.raw.get("description")` verbatim (fall back to `rules_secondary`,120 then say the payload carries no rule text). Do not compute a payout.1216. **Paper only**: any "signal" the user asks for is logged to a local JSON book122 with a loud banner that no orders are sent. See the verified reference build.123124```python125def settlement_text(view) -> str:126 raw = view.market.raw127 text = raw.get("description") or raw.get("rules_secondary")128 return text or "(market payload carries no settlement text; read it on the venue)"129```130131## Kalshi132133The package discovers **Polymarket** markets (Gamma). Kalshi publishes each market's134rule text in its public read endpoint, keyless; the same `outcome`/`event_status`135detection applies. Use the package's `LiveTennisClient` for the live side and read136Kalshi directly for the market side (see137[references/settlement-rules.md](references/settlement-rules.md) for the Kalshi138snippet and the ATP/WTA vs ITF series difference). Do not hard-code the ITF `$0.50`139rule; print `rules_secondary`.140141## One-prompt build (verified output checked in)142143When the user wants a watcher "vibe-coded" end to end, use the prompt in144[references/one-prompt-build.md](references/one-prompt-build.md). It is the exact145prompt from the package README; what it produced, unedited except for lint, is in146`examples/claude-code-watcher/` of the repository (7 offline tests, `ruff` clean,147`--once --fixtures` dry run without a key). That README also lists the honest148deviations — read them before re-running the prompt, because two of them are149traps: the prompt's "once a minute" costs 2 requests per poll (so the watcher must150self-cap), and the offline fixtures carry `event_status` but no `outcome` key.151152## Retirement / walkover rule matrix (retrieved 2026-08-23)153154The short version, for the human reading this — the code must still print the155market's own text:156157| Scenario | Polymarket (polymarket.com) | Polymarket US | Kalshi (ATP/WTA) | Kalshi (ITF) |158|---|---|---|---|---|159| Walkover / withdrawal before the match starts | 50-50 | Last fair market price at announcement | Fair price per rules | $0.50 per contract |160| Match cancelled, not played | 50-50 | Last fair market price | Fair price per rules | $0.50 |161| 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 |162| 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 |163| What counts as "started" | "the match begins" | First serve is struck | A ball has been played | A ball has been played |164165Verbatim venue quotes, source URLs, retrieval dates, and the live-data mapping166(`outcome`/`event_status`/`withdrew` to each venue column) are in167[references/settlement-rules.md](references/settlement-rules.md). Rules are per168market and can change; the market's own text always wins over that file.169170## Verification checklist before you hand code back171172- `ruff check` clean; tests run with no network (`httpx.MockTransport` or fixture files).173- Every import resolves to a symbol listed in `references/api.md`.174- Request count per poll is computed and documented against 30/min, 100/day.175- No payout number appears in code; the venue text is printed instead.176- Match-end detection reads `outcome` / `event_status`, never `status == "completed"` alone.177- A banner states that no orders are ever sent.178179## Further reading180181- Package: https://github.com/livetennisapi/polymarket-tennis (PyPI `polymarket-tennis`)182- Pillar guide: https://blog.livetennisapi.com/blog/build-polymarket-tennis-trading-bot183- Verbatim rules: https://blog.livetennisapi.com/blog/polymarket-kalshi-tennis-retirement-walkover-rules184- Kalshi walkthrough: https://blog.livetennisapi.com/blog/kalshi-tennis-trading-bot185- Live Tennis API docs: https://docs.livetennisapi.com — free key: https://livetennisapi.com/subscribe/free186- Sibling skill (entry gate, simmer-sdk style): https://github.com/livetennisapi/simmer-tennis-live-gate187- MCP server for the same data: https://github.com/livetennisapi/livetennisapi-mcp