From 86e3bb96a38d4299c058d0dc6292237fd0daa99e Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:10:59 +0300 Subject: [PATCH] Add sports (live tennis) domain adapter, specialist, and example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `sports` domain sibling to finance/weather/politics, following the existing domain plugin pattern: - autopredict/ingestion/sports: normalize live tennis match state into the shared IngestionBatch shape, with a deterministic feature builder and a local break-point derivation (receiver at AD, or 40 vs server 0/15/30; never in a tiebreak). - autopredict/domains/sports: SportsDomainAdapter + TennisSpecialistStrategy, defaulting to the production-safe market-implied no-edge model so a packaged example never fabricates alpha. - Wire tennis_specialist into the router (domain/category "sports") and the default strategy registry. - Tests mirroring the weather ingestion/adapter/strategy tests. - docs/STRATEGIES.md + docs/DATASETS.md entries and an observe-only examples/custom_strategy/tennis_agent.py walkthrough. The match-state features are supplied by the Live Tennis API (https://livetennisapi.com), a live-tennis DATA provider — this is a data input to a strategy that trades tennis event markets on the existing venue clients, not a market or execution venue. Vendor-authored; judge accordingly. Co-Authored-By: Claude Fable 5 --- autopredict/domains/__init__.py | 16 +++ autopredict/domains/router.py | 6 +- autopredict/domains/sports/__init__.py | 21 +++ autopredict/domains/sports/adapter.py | 46 +++++++ autopredict/domains/sports/model.py | 55 ++++++++ autopredict/domains/sports/strategy.py | 79 +++++++++++ autopredict/ingestion/sports/__init__.py | 17 +++ autopredict/ingestion/sports/features.py | 36 +++++ autopredict/ingestion/sports/match_state.py | 118 ++++++++++++++++ autopredict/prediction_market/builtin.py | 7 + docs/DATASETS.md | 35 +++++ docs/STRATEGIES.md | 8 +- examples/custom_strategy/README.md | 20 +++ examples/custom_strategy/run_tennis.py | 19 +++ examples/custom_strategy/tennis_agent.py | 143 ++++++++++++++++++++ tests/domain_rows.py | 42 ++++++ tests/test_domain_sports.py | 118 ++++++++++++++++ tests/test_ingestion_sports.py | 87 ++++++++++++ 18 files changed, 871 insertions(+), 2 deletions(-) create mode 100644 autopredict/domains/sports/__init__.py create mode 100644 autopredict/domains/sports/adapter.py create mode 100644 autopredict/domains/sports/model.py create mode 100644 autopredict/domains/sports/strategy.py create mode 100644 autopredict/ingestion/sports/__init__.py create mode 100644 autopredict/ingestion/sports/features.py create mode 100644 autopredict/ingestion/sports/match_state.py create mode 100644 examples/custom_strategy/run_tennis.py create mode 100644 examples/custom_strategy/tennis_agent.py create mode 100644 tests/test_domain_sports.py create mode 100644 tests/test_ingestion_sports.py diff --git a/autopredict/domains/__init__.py b/autopredict/domains/__init__.py index 41aabc8..6525c97 100644 --- a/autopredict/domains/__init__.py +++ b/autopredict/domains/__init__.py @@ -50,6 +50,15 @@ ) from autopredict.domains.registry import DomainRegistry, domain_registry from autopredict.domains.router import RoutedSpecialistStrategy +from autopredict.domains.sports import ( + SportsDomainAdapter, + TennisSpecialistStrategy, + build_default_sports_model, + sports_calibration_examples, + sports_dataset, + sports_evaluation_examples, + sports_training_examples, +) from autopredict.domains.weather import ( WeatherDomainAdapter, WeatherSpecialistStrategy, @@ -82,11 +91,14 @@ "QuestionConditionedExample", "QuestionConditionedLinearModel", "SpecialistOrderPolicy", + "SportsDomainAdapter", + "TennisSpecialistStrategy", "WeatherDomainAdapter", "WeatherSpecialistStrategy", "build_default_finance_model", "build_default_generic_model", "build_default_politics_model", + "build_default_sports_model", "build_default_weather_model", "build_domain_report_card", "domain_registry", @@ -103,6 +115,10 @@ "politics_dataset", "politics_evaluation_examples", "politics_training_examples", + "sports_calibration_examples", + "sports_dataset", + "sports_evaluation_examples", + "sports_training_examples", "weather_calibration_examples", "weather_dataset", "weather_evaluation_examples", diff --git a/autopredict/domains/router.py b/autopredict/domains/router.py index d4a5ecb..751e110 100644 --- a/autopredict/domains/router.py +++ b/autopredict/domains/router.py @@ -6,12 +6,13 @@ from autopredict.domains.finance import FinanceSpecialistStrategy from autopredict.domains.generic import GenericSpecialistStrategy from autopredict.domains.politics import PoliticsSpecialistStrategy +from autopredict.domains.sports import TennisSpecialistStrategy from autopredict.domains.weather import WeatherSpecialistStrategy from autopredict.prediction_market.types import MarketSignal, MarketSnapshot, StrategyContext class RoutedSpecialistStrategy: - """Route markets to finance, politics, or generic specialist strategies.""" + """Route markets to finance, politics, weather, sports, or generic strategies.""" name = "routed_specialist" @@ -20,6 +21,7 @@ def __init__(self, policy: SpecialistOrderPolicy | None = None) -> None: self.finance = FinanceSpecialistStrategy(policy=self.policy) self.politics = PoliticsSpecialistStrategy(policy=self.policy) self.weather = WeatherSpecialistStrategy(policy=self.policy) + self.sports = TennisSpecialistStrategy(policy=self.policy) self.generic = GenericSpecialistStrategy(policy=self.policy) def generate_signal( @@ -48,4 +50,6 @@ def _select_strategy(self, snapshot: MarketSnapshot): return self.politics if domain == "weather": return self.weather + if domain == "sports" or category == "sports": + return self.sports return self.generic diff --git a/autopredict/domains/sports/__init__.py b/autopredict/domains/sports/__init__.py new file mode 100644 index 0000000..b1fa059 --- /dev/null +++ b/autopredict/domains/sports/__init__.py @@ -0,0 +1,21 @@ +"""Sports (live tennis) domain adapters.""" + +from autopredict.domains.sports.adapter import SportsDomainAdapter +from autopredict.domains.sports.model import ( + build_default_sports_model, + sports_calibration_examples, + sports_dataset, + sports_evaluation_examples, + sports_training_examples, +) +from autopredict.domains.sports.strategy import TennisSpecialistStrategy + +__all__ = [ + "SportsDomainAdapter", + "TennisSpecialistStrategy", + "build_default_sports_model", + "sports_calibration_examples", + "sports_dataset", + "sports_evaluation_examples", + "sports_training_examples", +] diff --git a/autopredict/domains/sports/adapter.py b/autopredict/domains/sports/adapter.py new file mode 100644 index 0000000..39024f0 --- /dev/null +++ b/autopredict/domains/sports/adapter.py @@ -0,0 +1,46 @@ +"""Sports (live tennis) domain adapter for caller-provided match state. + +Vendor note: this domain is contributed by the Live Tennis API team +(https://livetennisapi.com). It publishes live tennis match state (score, +server, break point, retirement/walkover/completed status) as an AutoPredict +domain so a strategy trading tennis EVENT MARKETS on the existing venue clients +can subscribe to it. It is a data input, not a market or execution venue; judge +accordingly. +""" + +from __future__ import annotations + +from autopredict.domains.base import DomainFeatureBundle +from autopredict.ingestion.base import IngestionBatch +from autopredict.ingestion.sports.features import build_sports_features + + +class SportsDomainAdapter: + """Build a normalized tennis bundle from explicit match-state batches.""" + + name = "sports" + + def __init__(self, *, match_state_batch: IngestionBatch) -> None: + self.match_state_batch = match_state_batch + + @classmethod + def from_batches(cls, *, match_state_batch: IngestionBatch) -> "SportsDomainAdapter": + """Return an adapter over an observed tennis match-state batch.""" + + return cls(match_state_batch=match_state_batch) + + def build_bundle(self) -> DomainFeatureBundle: + match_state_batch = self.match_state_batch + features = build_sports_features(match_state_batch) + dominant_record = match_state_batch.evidence[-1] + return DomainFeatureBundle( + domain="sports", + features=features, + metadata={ + "domain": "sports", + "market_family": str(dominant_record.metadata.get("market_family", "tennis")), + "regime": str(dominant_record.metadata.get("regime", "in_play")), + "feature_version": "sports.phase1", + }, + evidence_ids=match_state_batch.record_ids, + ) diff --git a/autopredict/domains/sports/model.py b/autopredict/domains/sports/model.py new file mode 100644 index 0000000..447ed89 --- /dev/null +++ b/autopredict/domains/sports/model.py @@ -0,0 +1,55 @@ +"""Default sports (tennis) model. + +AutoPredict does not bundle offline tennis examples as product data. The default +model is a neutral market-implied fallback until a verified model is configured +explicitly, so a packaged example never masquerades as production alpha. The +Live Tennis API supplies live match STATE; it does not ship a proven fair-value +model, which is exactly why the neutral no-edge default is the honest one here. +""" + +from __future__ import annotations + +from functools import lru_cache + +from autopredict.domains.modeling import ( + MarketImpliedNoEdgeModel, + QuestionConditionedDataset, + QuestionConditionedExample, +) + + +@lru_cache(maxsize=1) +def sports_dataset() -> QuestionConditionedDataset: + """Return the configured sports dataset metadata.""" + + return QuestionConditionedDataset( + name="no_verified_sports_dataset", + version="none", + domain="sports", + examples_by_split={}, + ) + + +def sports_training_examples() -> tuple[QuestionConditionedExample, ...]: + """Return offline training examples for sports.""" + + return sports_dataset().split_examples("train") + + +def sports_calibration_examples() -> tuple[QuestionConditionedExample, ...]: + """Return held-out calibration examples for sports.""" + + return sports_dataset().split_examples("calibration") + + +def sports_evaluation_examples() -> tuple[QuestionConditionedExample, ...]: + """Return held-out evaluation examples for sports.""" + + return sports_dataset().split_examples("evaluation") + + +@lru_cache(maxsize=1) +def build_default_sports_model() -> MarketImpliedNoEdgeModel: + """Return the production-safe neutral sports model.""" + + return MarketImpliedNoEdgeModel("sports_market_implied_no_edge", "sports") diff --git a/autopredict/domains/sports/strategy.py b/autopredict/domains/sports/strategy.py new file mode 100644 index 0000000..06785bb --- /dev/null +++ b/autopredict/domains/sports/strategy.py @@ -0,0 +1,79 @@ +"""Sports specialist strategy backed by a question-conditioned model.""" + +from __future__ import annotations + +from autopredict.domains.base import ( + SpecialistOrderPolicy, + build_single_edge_order, + snapshot_label, +) +from autopredict.domains.modeling import QuestionConditionedLinearModel +from autopredict.domains.sports.model import build_default_sports_model +from autopredict.prediction_market.types import MarketSignal, MarketSnapshot, StrategyContext + + +class TennisSpecialistStrategy: + """Model-backed tennis strategy driven by question and match-state features.""" + + name = "tennis_specialist" + + def __init__( + self, + policy: SpecialistOrderPolicy | None = None, + model: QuestionConditionedLinearModel | None = None, + ) -> None: + self.policy = policy or SpecialistOrderPolicy( + min_abs_edge=0.02, + max_bankroll_fraction=0.05, + aggressive_edge=0.06, + urgency_regimes=("retirement", "in_play"), + ) + self.model = model or build_default_sports_model() + + def generate_signal( + self, + snapshot: MarketSnapshot, + context: StrategyContext, + ) -> MarketSignal | None: + del context + if snapshot_label(snapshot, "domain", "") != "sports": + return None + + family = snapshot_label(snapshot, "market_family", "tennis") + regime = snapshot_label(snapshot, "regime", "in_play") + prediction = self.model.predict( + snapshot.market.question, + { + **snapshot.features, + "market_prob": snapshot.market.market_prob, + "spread_bps": snapshot.market.spread_bps, + "total_liquidity": snapshot.market.total_liquidity, + }, + snapshot.labels, + ) + return MarketSignal( + fair_prob=prediction.probability, + confidence=prediction.confidence, + rationale=prediction.rationale, + tags=("domain", "sports", "model", family, regime), + metadata={ + **prediction.metadata, + "domain": "sports", + "market_family": family, + "regime": regime, + }, + ) + + def build_orders( + self, + snapshot: MarketSnapshot, + signal: MarketSignal, + context: StrategyContext, + ) -> list: + return build_single_edge_order( + snapshot, + signal, + context, + strategy_name=self.name, + policy=self.policy, + ) diff --git a/autopredict/ingestion/sports/__init__.py b/autopredict/ingestion/sports/__init__.py new file mode 100644 index 0000000..cb20291 --- /dev/null +++ b/autopredict/ingestion/sports/__init__.py @@ -0,0 +1,17 @@ +"""Sports (live tennis) ingestion helpers.""" + +from autopredict.ingestion.sports.features import build_sports_features +from autopredict.ingestion.sports.match_state import ( + MATCH_STATE_SOURCE, + build_match_state_row, + derive_break_point, + normalize_match_states, +) + +__all__ = [ + "MATCH_STATE_SOURCE", + "build_match_state_row", + "build_sports_features", + "derive_break_point", + "normalize_match_states", +] diff --git a/autopredict/ingestion/sports/features.py b/autopredict/ingestion/sports/features.py new file mode 100644 index 0000000..ba9c0df --- /dev/null +++ b/autopredict/ingestion/sports/features.py @@ -0,0 +1,36 @@ +"""Feature builders for live tennis match-state evidence.""" + +from __future__ import annotations + +from typing import Any + +from autopredict.ingestion.base import IngestionBatch + + +def build_sports_features(match_state_batch: IngestionBatch) -> dict[str, Any]: + """Build a small deterministic tennis match-state feature payload.""" + + records = match_state_batch.evidence + win_probabilities = [ + float(record.payload["win_probability_p1"]) + for record in records + if record.payload.get("win_probability_p1") is not None + ] + num_break_points = sum(1 for record in records if bool(record.payload.get("break_point"))) + num_tiebreaks = sum(1 for record in records if bool(record.payload.get("is_tiebreak"))) + num_live = sum(1 for record in records if record.payload.get("status") == "live") + num_completed = sum(1 for record in records if record.payload.get("status") == "completed") + num_retired = sum( + 1 for record in records if record.payload.get("event_status") in ("Retired", "Walk Over") + ) + return { + "num_matches": len(records), + "num_live": num_live, + "num_completed": num_completed, + "num_retired": num_retired, + "num_break_points": num_break_points, + "has_break_point": num_break_points > 0, + "num_tiebreaks": num_tiebreaks, + "max_win_probability_p1": max(win_probabilities) if win_probabilities else 0.0, + "has_win_probability": bool(win_probabilities), + } diff --git a/autopredict/ingestion/sports/match_state.py b/autopredict/ingestion/sports/match_state.py new file mode 100644 index 0000000..ef56124 --- /dev/null +++ b/autopredict/ingestion/sports/match_state.py @@ -0,0 +1,118 @@ +"""Live tennis match-state normalization. + +The rows normalized here describe live tennis matches. They can come from any +authorized provider; :func:`build_match_state_row` maps one match object from +the Live Tennis API (https://livetennisapi.com, vendor-authored — this adapter +is contributed by the Live Tennis API team, judge accordingly) into the shared +row shape, deriving break-point state locally per the documented rule. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from autopredict.ingestion.base import EvidenceRecord, IngestionBatch, SourceConfig + +MATCH_STATE_SOURCE = SourceConfig(name="sports.match_state", version="v1") + +_STATUS_REGIME = { + "live": "in_play", + "upcoming": "scheduled", + "completed": "settled", + "cancelled": "settled", +} + + +def derive_break_point( + points: Sequence[Any] | None, + server: int | None, + is_tiebreak: bool, +) -> bool: + """Return whether the receiver currently holds break point. + + Break point is true when the receiver is at ``AD`` (advantage) or at ``40`` + while the server is at ``0``/``15``/``30``. It is never true in a tiebreak, + and it is false whenever the server or the point strings are unknown. This + mirrors the derivation documented for the Live Tennis API score object. + """ + + if is_tiebreak or server not in (1, 2) or not points: + return False + server_index = server - 1 + receiver_index = 1 - server_index + if len(points) <= max(server_index, receiver_index): + return False + server_point = points[server_index] + receiver_point = points[receiver_index] + if server_point is None or receiver_point is None: + return False + if receiver_point == "AD": + return True + return receiver_point == "40" and server_point in ("0", "15", "30") + + +def build_match_state_row(match: Mapping[str, Any]) -> dict[str, Any]: + """Map one Live Tennis API match object into a normalized match-state row. + + ``match`` is a single item from ``GET /matches?status=live`` (FREE tier) or + the embedded ``score`` on ``GET /matches/{matchId}``. Only the fields used + for feature building are copied. ``win_probability_p1`` is an ULTRA-tier + field on the score object; it stays ``None`` on lower tiers rather than + being fabricated. + """ + + score = match.get("score") or {} + server = score.get("server") + points = score.get("points") + is_tiebreak = bool(score.get("is_tiebreak", False)) + status = str(match.get("status", "live")) + event_status = match.get("event_status") + payload = { + "match_id": match.get("id"), + "tournament": match.get("tournament"), + "tour": match.get("tour"), + "surface": match.get("surface"), + "status": status, + "event_status": event_status, + "server": server, + "points": list(points) if isinstance(points, (list, tuple)) else None, + "is_tiebreak": is_tiebreak, + "break_point": derive_break_point(points, server, is_tiebreak), + "win_probability_p1": score.get("win_probability_p1"), + } + regime = _STATUS_REGIME.get(status, "in_play") + if event_status in ("Retired", "Walk Over"): + regime = "retirement" + return { + "record_id": str(match.get("id")), + "observed_at": match.get("observed_at"), + "payload": payload, + "metadata": {"market_family": "tennis", "regime": regime}, + } + + +def normalize_match_states( + rows: Sequence[dict[str, Any]], + *, + source_config: SourceConfig = MATCH_STATE_SOURCE, +) -> IngestionBatch: + """Normalize match-state rows into the shared ingestion batch shape.""" + + evidence = tuple( + EvidenceRecord( + source=MATCH_STATE_SOURCE.name, + record_id=str(row["record_id"]), + observed_at=row["observed_at"], + payload=dict(row["payload"]), + metadata={ + "record_type": "match_state", + **dict(row.get("metadata", {})), + }, + ) + for row in rows + ) + return IngestionBatch( + source_config=source_config, + evidence=evidence, + metadata={"domain": "sports"}, + ) diff --git a/autopredict/prediction_market/builtin.py b/autopredict/prediction_market/builtin.py index 977805b..ef86853 100644 --- a/autopredict/prediction_market/builtin.py +++ b/autopredict/prediction_market/builtin.py @@ -11,6 +11,7 @@ def create_default_registry() -> StrategyRegistry: from autopredict.domains.finance import FinanceSpecialistStrategy from autopredict.domains.politics import PoliticsSpecialistStrategy from autopredict.domains.router import RoutedSpecialistStrategy + from autopredict.domains.sports import TennisSpecialistStrategy from autopredict.domains.weather import WeatherSpecialistStrategy registry = StrategyRegistry() @@ -32,6 +33,12 @@ def create_default_registry() -> StrategyRegistry: description="Simple polling- and event-aware politics specialist heuristic.", tags=("domain", "politics", "phase2"), ) + registry.register( + "tennis_specialist", + factory=TennisSpecialistStrategy, + description="Live tennis match-state specialist for tennis event markets.", + tags=("domain", "sports", "phase2"), + ) registry.register( "routed_specialist", factory=RoutedSpecialistStrategy, diff --git a/docs/DATASETS.md b/docs/DATASETS.md index 9e78d29..5d8ec38 100644 --- a/docs/DATASETS.md +++ b/docs/DATASETS.md @@ -64,3 +64,38 @@ add user models without changing this dataset or report envelope. The repository fixture under `tests/fixtures/` exists only for deterministic tests; it is not bundled historical evidence or a runtime fallback. + +## Sports (live tennis) data source + +The `sports` domain (`autopredict.ingestion.sports`, `autopredict.domains.sports`) +is fed by the **Live Tennis API** (https://livetennisapi.com). This section and +that code are contributed by the Live Tennis API team, so they are +vendor-authored — judge accordingly. The feed publishes live tennis match STATE +for strategies trading tennis **event markets**; it is a data input, not a market +or execution venue, and it ships no proven fair-value model (hence the neutral +market-implied no-edge default in `build_default_sports_model`). + +Base URL `https://api.livetennisapi.com/api/public/v1`; auth via an `X-API-Key` +header. A free key (https://livetennisapi.com/subscribe/free) is rate-limited to +30 requests/minute and 100 requests/day, which suits develop-and-test or +low-cadence checks, not continuous fast polling. + +Endpoints used by `build_match_state_row`: + +- `GET /matches?status=live` — FREE. The current live picture: score, current + server, and per-match `status` / `event_status` (including retirement and + walkover). Break point is derived locally from the score, not fetched. +- `GET /matches/{matchId}/score` — FREE snapshot; `win_probability_p1` and + `danger` on this object require the ULTRA tier and stay `null` on lower tiers + (the adapter leaves them `None` rather than fabricating a value). +- `GET /matches/{matchId}/events` (point-by-point) requires the PRO tier. + +Break-point rule (implemented in `derive_break_point`, matching the API's +documented derivation): the receiver holds break point when at `AD`, or at `40` +while the server is at `0`/`15`/`30`; it is never true in a tiebreak and is false +whenever the server or point strings are unknown. + +Live match state is a real-time signal, not a resolved dataset. To use it as +performance evidence you must capture it into the canonical +`autopredict.dataset.v1` contract above (with explicit provenance) — the domain +adapter alone does not make it a benchmark. diff --git a/docs/STRATEGIES.md b/docs/STRATEGIES.md index 3b4439b..00b09bd 100644 --- a/docs/STRATEGIES.md +++ b/docs/STRATEGIES.md @@ -6,7 +6,7 @@ Strategies in AutoPredict turn market snapshots into decisions. The active produ - `agent.py`: legacy configurable agent used by the original backtest loop - `autopredict.prediction_market`: scaffold-native strategy protocol and decision objects -- `autopredict.domains`: finance, weather, politics, and generic specialist wrappers +- `autopredict.domains`: finance, weather, politics, sports (live tennis), and generic specialist wrappers - `autopredict.self_improvement.mutation`: deterministic genome mutation for offline search ## Default Behavior @@ -32,6 +32,12 @@ python -m autopredict.cli learn improve \ --frontier-path state/meta_harness/frontier.json ``` +## Sports (live tennis) specialist + +`tennis_specialist` (`autopredict.domains.sports`) is a domain specialist for tennis **event markets** on the existing venue clients (e.g. the Polymarket market client). It consumes live tennis match state — score, current server, break point, and retirement/walkover/completed status — as `DomainFeatureBundle` features and, like the other specialists, holds on the neutral market-implied no-edge default until you wire a verified model. It is a data input to a strategy, not a market or execution venue. + +The match-state features come from the Live Tennis API (`autopredict.ingestion.sports`). This domain is contributed by the Live Tennis API team (https://livetennisapi.com), so it is vendor-authored — judge accordingly. See [DATASETS.md](DATASETS.md#sports-live-tennis-data-source) for the endpoints, tiers, and the break-point rule, and `examples/custom_strategy/tennis_agent.py` for a runnable, observe-only walkthrough. + ## Promotion Rules Do not promote from PnL alone. Require forecast quality, calibration, execution quality, and held-out robustness to move together. diff --git a/examples/custom_strategy/README.md b/examples/custom_strategy/README.md index f9ea908..9b9e8fd 100644 --- a/examples/custom_strategy/README.md +++ b/examples/custom_strategy/README.md @@ -2,6 +2,26 @@ This example shows how to create a custom trading strategy by extending the AutoPredict agent. +## Tennis Domain Specialist (observe-only) + +`tennis_agent.py` shows the other extension style: a **domain specialist** rather +than a custom `ExecutionStrategy`. It builds a `sports` `DomainFeatureBundle` from +live tennis match state (score, current server, break point, retirement status) +and runs `TennisSpecialistStrategy` on a tennis event market. + +Vendor note: the `sports` domain and the Live Tennis API feed it consumes are +contributed by the Live Tennis API team (https://livetennisapi.com), so they are +vendor-authored — judge accordingly. Live Tennis API is a live-tennis DATA +provider, not a market or execution venue. The default sports model is the +production-safe market-implied no-edge model, so this walkthrough is observe-only: +it prints a neutral forecast and a `HOLD` decision and places no orders. See +[docs/DATASETS.md](../../docs/DATASETS.md#sports-live-tennis-data-source) for the +endpoints, tiers, and break-point rule. + +```bash +python examples/custom_strategy/run_tennis.py +``` + ## Conservative Limit-Only Strategy This strategy NEVER uses market orders, always using limit orders to capture the spread. diff --git a/examples/custom_strategy/run_tennis.py b/examples/custom_strategy/run_tennis.py new file mode 100644 index 0000000..17bb0f0 --- /dev/null +++ b/examples/custom_strategy/run_tennis.py @@ -0,0 +1,19 @@ +"""Run the observe-only tennis event-market walkthrough. + +See ``tennis_agent.py`` for the full vendor note. Live Tennis API +(https://livetennisapi.com) is a live-tennis DATA provider, not a venue; this +script only prints a neutral no-edge forecast and a HOLD decision. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Add repository root to path for imports. +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from examples.custom_strategy.tennis_agent import run_walkthrough + +if __name__ == "__main__": + run_walkthrough() diff --git a/examples/custom_strategy/tennis_agent.py b/examples/custom_strategy/tennis_agent.py new file mode 100644 index 0000000..d96a0f5 --- /dev/null +++ b/examples/custom_strategy/tennis_agent.py @@ -0,0 +1,143 @@ +"""Observe-only tennis event-market walkthrough for the sports domain. + +Vendor note: the ``sports`` domain and the Live Tennis API feed it consumes are +contributed by the Live Tennis API team (https://livetennisapi.com). Live Tennis +API is a live-tennis DATA provider, not a market or execution venue: this example +publishes live match STATE (score, server, break point, retirement status) as a +domain input to a strategy that trades tennis EVENT MARKETS on AutoPredict's +existing venue clients. Judge accordingly. + +The default sports model is the production-safe market-implied no-edge model, so +this walkthrough is observe-only: it prints the neutral forecast and the resulting +HOLD decision. It never places a live order and never invents a fair value. + +Run it with ``python examples/custom_strategy/run_tennis.py``. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta +from pathlib import Path + +# Add repository root to path for imports. +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from autopredict.core.types import MarketCategory, MarketState +from autopredict.domains.sports import SportsDomainAdapter, TennisSpecialistStrategy +from autopredict.evaluation import PredictionMarketBacktester, ResolvedMarketSnapshot +from autopredict.ingestion.sports import build_match_state_row, normalize_match_states +from autopredict.prediction_market import ( + PredictionMarketAgent, + VenueConfig, + VenueName, +) + + +def sample_live_matches() -> tuple[dict, ...]: + """Return a couple of Live Tennis API-shaped live matches. + + In a real run these come from ``GET /matches?status=live`` (FREE tier). The + ``win_probability_p1`` field is populated only on the ULTRA tier; it is left + out here to show that break point and score alone already drive the feature + bundle without it. + """ + + observed_at = datetime(2026, 8, 18, 13, 0, 0) + return ( + { + "id": 101, + "tournament": "Example Open", + "tour": "atp", + "surface": "hard", + "status": "live", + "event_status": None, + "observed_at": observed_at, + # server 1 at 30, receiver (p2) at 40 -> receiver holds break point. + "score": {"server": 1, "points": ["30", "40"], "is_tiebreak": False}, + }, + { + "id": 102, + "tournament": "Example Open", + "tour": "wta", + "surface": "hard", + "status": "live", + "event_status": "Retired", + "observed_at": observed_at + timedelta(minutes=4), + "score": {"server": None, "points": [None, None], "is_tiebreak": False}, + }, + ) + + +def build_sports_bundle(): + """Turn sample live matches into a normalized sports domain bundle.""" + + rows = [build_match_state_row(match) for match in sample_live_matches()] + batch = normalize_match_states(rows) + return SportsDomainAdapter.from_batches(match_state_batch=batch).build_bundle() + + +def run_walkthrough() -> None: + """Print the observe-only neutral forecast and HOLD decision for a tennis market.""" + + bundle = build_sports_bundle() + + print("=" * 72) + print("SPORTS DOMAIN BUNDLE (from live tennis match state)") + print("=" * 72) + print(f" domain: {bundle.domain}") + print(f" market_family: {bundle.metadata['market_family']}") + print(f" regime: {bundle.metadata['regime']}") + print(f" feature_version: {bundle.metadata['feature_version']}") + print(" features:") + for key, value in sorted(bundle.features.items()): + print(f" {key}: {value}") + + # A tennis event market on the existing venue client. The market probability + # is the venue's, not ours; the specialist stays neutral against it. + market = MarketState( + market_id="polymarket-tennis-example", + question="Will player 1 win the Example Open match?", + market_prob=0.58, + expiry=datetime.now() + timedelta(hours=2), + category=MarketCategory.SPORTS, + best_bid=0.56, + best_ask=0.60, + bid_liquidity=200.0, + ask_liquidity=200.0, + ) + venue = VenueConfig(name=VenueName.POLYMARKET, fee_bps=10.0) + + backtester = PredictionMarketBacktester() + result = backtester.run( + PredictionMarketAgent(strategy=TennisSpecialistStrategy()), + ( + ResolvedMarketSnapshot( + market=market, + venue=venue, + outcome=1, + metadata={"source": "tennis_example"}, + domain_bundle=bundle, + ), + ), + ) + + decision = result.decisions[0] + forecast = result.forecasts[0] + + print("\n" + "=" * 72) + print("OBSERVE-ONLY DECISION") + print("=" * 72) + print(f" market_prob: {market.market_prob}") + print(f" forecast_prob: {forecast.probability}") + print(f" forecast_source: {forecast.metadata['forecast_source']}") + print(f" decision: {decision.status.value}") + print(f" trades placed: {len(result.trades)}") + print( + "\nThe default sports model returns the venue's own probability as a neutral" + "\nno-edge forecast, so the specialist HOLDS. Wire a verified model to trade." + ) + + +if __name__ == "__main__": + run_walkthrough() diff --git a/tests/domain_rows.py b/tests/domain_rows.py index 7922a30..36ddc6c 100644 --- a/tests/domain_rows.py +++ b/tests/domain_rows.py @@ -151,3 +151,45 @@ def politics_event_rows() -> tuple[dict[str, Any], ...]: "metadata": {"market_family": "elections", "regime": "debate_week"}, }, ) + + +def sports_match_state_rows() -> tuple[dict[str, Any], ...]: + base = datetime(2026, 8, 18, 13, 0, 0) + return ( + { + "record_id": "match-101", + "observed_at": base, + "payload": { + "match_id": 101, + "tournament": "Test Open", + "tour": "atp", + "surface": "hard", + "status": "live", + "event_status": None, + "server": 1, + "points": ["30", "40"], + "is_tiebreak": False, + "break_point": True, + "win_probability_p1": 0.72, + }, + "metadata": {"market_family": "tennis", "regime": "in_play"}, + }, + { + "record_id": "match-102", + "observed_at": base + timedelta(minutes=5), + "payload": { + "match_id": 102, + "tournament": "Test Open", + "tour": "wta", + "surface": "hard", + "status": "live", + "event_status": "Retired", + "server": None, + "points": [None, None], + "is_tiebreak": False, + "break_point": False, + "win_probability_p1": None, + }, + "metadata": {"market_family": "tennis", "regime": "retirement"}, + }, + ) diff --git a/tests/test_domain_sports.py b/tests/test_domain_sports.py new file mode 100644 index 0000000..5015964 --- /dev/null +++ b/tests/test_domain_sports.py @@ -0,0 +1,118 @@ +"""Tests for the sports (live tennis) domain adapter and specialist strategy.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from autopredict.core.types import MarketCategory, MarketState +from autopredict.domains import ( + DomainFeatureBundle, + RoutedSpecialistStrategy, + SportsDomainAdapter, + TennisSpecialistStrategy, +) +from autopredict.evaluation import PredictionMarketBacktester, ResolvedMarketSnapshot +from autopredict.ingestion.sports import normalize_match_states +from autopredict.prediction_market import ( + DecisionStatus, + PredictionMarketAgent, + VenueConfig, + VenueName, + create_default_registry, +) +from tests.domain_rows import sports_match_state_rows + + +def _sports_bundle() -> DomainFeatureBundle: + return SportsDomainAdapter.from_batches( + match_state_batch=normalize_match_states(sports_match_state_rows()), + ).build_bundle() + + +def _market(market_id: str, market_prob: float) -> MarketState: + return MarketState( + market_id=market_id, + question=f"Will {market_id} resolve YES?", + market_prob=market_prob, + expiry=datetime.now() + timedelta(days=1), + category=MarketCategory.SPORTS, + best_bid=max(market_prob - 0.02, 0.01), + best_ask=min(market_prob + 0.02, 0.99), + bid_liquidity=200.0, + ask_liquidity=200.0, + ) + + +def test_sports_adapter_emits_required_labels_and_features() -> None: + """The sports adapter should emit a bundle with required metadata labels.""" + + bundle = _sports_bundle() + + assert isinstance(bundle, DomainFeatureBundle) + assert bundle.domain == "sports" + assert bundle.metadata["domain"] == "sports" + assert bundle.metadata["market_family"] == "tennis" + assert bundle.metadata["regime"] + assert bundle.metadata["feature_version"] == "sports.phase1" + features, metadata = bundle.as_snapshot_inputs() + assert features["num_matches"] == 2 + assert metadata["market_family"] == "tennis" + + +def test_default_registry_includes_tennis_specialist() -> None: + """The default scaffold registry should expose the tennis specialist.""" + + registry = create_default_registry() + + assert "tennis_specialist" in registry.names() + + +def test_tennis_specialist_holds_with_neutral_market_implied_default_model() -> None: + """The default production model should not trade without verified edge data.""" + + venue = VenueConfig(name=VenueName.POLYMARKET, fee_bps=10.0) + backtester = PredictionMarketBacktester() + + result = backtester.run( + PredictionMarketAgent(strategy=TennisSpecialistStrategy()), + ( + ResolvedMarketSnapshot( + market=_market("tennis-market", 0.42), + venue=venue, + outcome=1, + metadata={"source": "unit_test"}, + domain_bundle=_sports_bundle(), + ), + ), + ) + + assert result.decisions[0].status == DecisionStatus.HOLD + assert result.decisions[0].metadata["domain"] == "sports" + assert result.forecasts[0].metadata["forecast_source"] == "market_implied_no_edge" + assert result.forecasts[0].probability == 0.42 + assert result.trades == () + + +def test_routed_strategy_dispatches_sports_markets_to_tennis_specialist() -> None: + """Sports-labeled markets should use the tennis specialist rather than generic.""" + + venue = VenueConfig(name=VenueName.POLYMARKET, fee_bps=10.0) + backtester = PredictionMarketBacktester() + + result = backtester.run( + PredictionMarketAgent(strategy=RoutedSpecialistStrategy()), + ( + ResolvedMarketSnapshot( + market=_market("tennis-routed", 0.55), + venue=venue, + outcome=1, + metadata={"source": "unit_test"}, + domain_bundle=_sports_bundle(), + ), + ), + ) + + assert result.decisions[0].status == DecisionStatus.HOLD + assert result.forecasts[0].metadata["domain"] == "sports" + assert result.forecasts[0].metadata["forecast_source"] == "market_implied_no_edge" + assert result.trades == () diff --git a/tests/test_ingestion_sports.py b/tests/test_ingestion_sports.py new file mode 100644 index 0000000..fc9bfe7 --- /dev/null +++ b/tests/test_ingestion_sports.py @@ -0,0 +1,87 @@ +"""Tests for sports (live tennis) ingestion normalization.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from autopredict.ingestion.sports import ( + build_match_state_row, + build_sports_features, + derive_break_point, + normalize_match_states, +) +from tests.domain_rows import sports_match_state_rows + + +def test_sports_ingestion_normalizes_rows() -> None: + """Observed match-state rows should normalize into a shared sports batch.""" + + batch = normalize_match_states(sports_match_state_rows()) + + assert batch.source.domain == "sports" + assert batch.source.name == "sports.match_state" + assert len(batch.records) == 2 + assert batch.records[0].payload["win_probability_p1"] == pytest.approx(0.72) + assert batch.records[-1].payload["event_status"] == "Retired" + + +def test_sports_feature_builder_emits_stable_values() -> None: + """Feature extraction should be deterministic for provided match state.""" + + features = build_sports_features(normalize_match_states(sports_match_state_rows())) + + assert features["num_matches"] == 2 + assert features["num_live"] == 2 + assert features["num_retired"] == 1 + assert features["num_break_points"] == 1 + assert features["has_break_point"] is True + assert features["num_tiebreaks"] == 0 + assert features["max_win_probability_p1"] == pytest.approx(0.72) + assert features["has_win_probability"] is True + + +def test_break_point_rule_matches_documented_derivation() -> None: + """Break point holds at AD, or receiver 40 vs server 0/15/30; never in a tiebreak.""" + + # server=1, receiver (p2) at 40, server at 30 -> break point. + assert derive_break_point(["30", "40"], server=1, is_tiebreak=False) is True + # server=2, receiver (p1) at AD -> break point. + assert derive_break_point(["AD", "40"], server=2, is_tiebreak=False) is True + # 40-40 (deuce) is not a break point. + assert derive_break_point(["40", "40"], server=1, is_tiebreak=False) is False + # Never in a tiebreak. + assert derive_break_point(["6", "5"], server=1, is_tiebreak=True) is False + # Unknown server / points -> false, not fabricated. + assert derive_break_point(["40", "AD"], server=None, is_tiebreak=False) is False + assert derive_break_point([None, None], server=1, is_tiebreak=False) is False + + +def test_build_match_state_row_maps_live_tennis_api_match() -> None: + """A raw Live Tennis API match maps into a normalizable row with derived break point.""" + + match = { + "id": 555, + "tournament": "Sample Cup", + "tour": "atp", + "surface": "clay", + "status": "live", + "event_status": None, + "observed_at": datetime(2026, 8, 18, 14, 0, 0), + "score": { + "server": 2, + "points": ["15", "AD"], + "is_tiebreak": False, + "win_probability_p1": 0.4, + }, + } + + row = build_match_state_row(match) + batch = normalize_match_states((row,)) + + assert row["record_id"] == "555" + # server=2 => receiver is p1 at "15"; p2 (server) at "AD" => not a break point against server. + assert row["payload"]["break_point"] is False + assert row["metadata"]["market_family"] == "tennis" + assert batch.records[0].payload["match_id"] == 555