diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3fe147..acfebaa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | - pip install -e ".[pandas]" + pip install -e ".[pandas,test]" pip install ruff pyright - name: Ruff check @@ -30,3 +30,11 @@ jobs: - name: Pyright run: pyright + + # This workflow is named "Tests" but never ran any until now — `tests/` has + # existed since 0.3.x and CI only linted and type-checked it. #18 shipped two + # model/wire mismatches that a single parse of a real payload would have + # caught, so the fixture round-trip it asks for is only worth adding if it can + # actually fail the build. + - name: Pytest + run: pytest -q diff --git a/src/sharpapi/__init__.py b/src/sharpapi/__init__.py index 2e24c0a..006d439 100644 --- a/src/sharpapi/__init__.py +++ b/src/sharpapi/__init__.py @@ -61,7 +61,7 @@ ) from .streaming import EventStream -__version__ = "0.4.0" +__version__ = "0.4.1" __all__ = [ # Clients diff --git a/src/sharpapi/models.py b/src/sharpapi/models.py index 1bdb59a..86284bf 100644 --- a/src/sharpapi/models.py +++ b/src/sharpapi/models.py @@ -274,7 +274,13 @@ class EVOpportunity(BaseModel): kelly_percent: float | None = Field( None, validation_alias=AliasChoices("kelly_percent", "kelly_fraction") ) - confidence_score: float | None = None + # The wire field is `confidence` (int 0-100). Aliased rather than renamed so + # the public attribute name is unchanged — before this, `confidence_score` + # silently read None on every row (#18). + confidence_score: float | None = Field( + None, validation_alias=AliasChoices("confidence", "confidence_score") + ) + quality_tier: str | None = None book_count: int | None = None market_width: float | None = None devig_method: str | None = None @@ -383,14 +389,27 @@ class ArbitrageOpportunity(BaseModel): class MiddleSide(BaseModel): - """One side of a middle opportunity.""" + """One side of a middle opportunity. + + Odds are **flat** on the wire — ``odds_american`` / ``odds_decimal`` / + ``odds_probability`` — the same shape ``EVOpportunity`` uses, not a nested + ``OddsValue``. This previously declared a required ``odds: OddsValue``, which + is absent from every real payload, so ``client.middles()`` raised + ``ValidationError`` on any response carrying a side (#18). + """ book: str selection: str line: float - odds: OddsValue + odds_american: int | float + odds_decimal: float + odds_probability: float | None = None + fair_probability: float | None = None stake_percent: float | None = None odds_age_seconds: float | None = None + external_event_id: str | None = None + market_id: str | None = None + selection_id: str | None = None deep_link: str | None = None diff --git a/tests/fixtures/ev_live.json b/tests/fixtures/ev_live.json new file mode 100644 index 0000000..91c0e0a --- /dev/null +++ b/tests/fixtures/ev_live.json @@ -0,0 +1 @@ +{"data":[{"arb_available":false,"arb_profit":0,"away_team":"Miriam Bulgaru","book_count":4,"confidence":80,"cross_ref_count":1,"cross_ref_dispersion":null,"deep_link":"https://api.sharpapi.io/api/v1/deeplink/180571354935028","detected_at":"2026-07-27T16:24:23.869967937Z","devig_method":"POWER","display_selection":"Carole Monnet +1.5","est_correction_s":0,"ev_percentage":14.88,"event_id":"tennis_women_carole_monnet_miriam_bulgaru_2026-07-28","event_name":"Miriam Bulgaru @ Carole Monnet","external_event_id":"sr:match:73178164","fair_probability":0.7709,"game_id":"tennis_women_carole_monnet_miriam_bulgaru_2026-07-28","home_team":"Carole Monnet","id":"a32c215af585590e","is_alternate_line":false,"is_live":false,"is_player_prop":false,"kelly_percent":30.35,"league":"wta","line":1.5,"market_id":"tennis_women_carole_monnet_miriam_bulgaru_2026-07-28:set_handicap:1.5","market_type":"set_handicap","market_width":4.787585296059871,"no_vig_odds":-336,"odds_american":-204,"odds_decimal":1.49,"odds_probability":0.6711,"oldest_odds_age_seconds":13.4,"partner_fair_probability":0.2291,"player_id":null,"player_name":null,"possibly_stale":false,"quality_tier":"A","selection":"Home","selection_id":"1715","sharp_book":"pinnacle","single_sharp_period":false,"sport":"tennis","sportsbook":"rebet","sportsbooks":["rebet"],"start_time":"2026-07-28T08:00:00Z","stat_category":null,"team_side":"home","warnings":["SINGLE_SHARP_REF"]},{"arb_available":true,"arb_profit":6.33,"away_team":"Pedro Boscardin Dias","book_count":4,"confidence":80,"cross_ref_count":1,"cross_ref_dispersion":null,"deep_link":"https://api.sharpapi.io/api/v1/deeplink/49035591713778","detected_at":"2026-07-27T16:24:27.770800113Z","devig_method":"POWER","display_selection":"Under 19.5","est_correction_s":0,"ev_percentage":13.76,"event_id":"tennis_men_jurij_rodionov_pedro_boscardin_dias_2026-07-27","event_name":"Pedro Boscardin Dias @ Jurij Rodionov","external_event_id":"34444690","fair_probability":0.6636,"game_id":"tennis_men_jurij_rodionov_pedro_boscardin_dias_2026-07-27","home_team":"Jurij Rodionov","id":"e0ea053b6f0a7c2f","is_alternate_line":false,"is_live":true,"is_player_prop":false,"kelly_percent":19.26,"league":"atp_challenger","line":19.5,"market_id":"tennis_men_jurij_rodionov_pedro_boscardin_dias_2026-07-27:total_games:19.5","market_type":"total_games","market_width":4.754533392304294,"no_vig_odds":-204,"odds_american":-140,"odds_decimal":1.714,"odds_probability":0.5833,"oldest_odds_age_seconds":4.8,"partner_fair_probability":0.3294,"player_id":null,"player_name":null,"possibly_stale":false,"quality_tier":"B","selection":"Under","selection_id":"0QA355007760#2225950624_13L213456Q155225381Q20","sharp_book":"pinnacle","single_sharp_period":false,"sport":"tennis","sportsbook":"draftkings","sportsbooks":["draftkings"],"start_time":"2026-07-27T15:00:00Z","stat_category":null,"warnings":["SINGLE_SHARP_REF"]}],"pagination":{"limit":2,"offset":0,"count":2,"total":173,"has_more":true,"next_offset":2,"next_cursor":"eyJ2IjoxMy43NiwiaSI6IiJ9"},"updated_at":"2026-07-27T16:24:46.782860227Z"} diff --git a/tests/fixtures/middles_live.json b/tests/fixtures/middles_live.json new file mode 100644 index 0000000..95867dd --- /dev/null +++ b/tests/fixtures/middles_live.json @@ -0,0 +1 @@ +{"data":[{"alt_side1":[],"alt_side2":[{"book":"hardrock","external_event_id":"6094294020626972928","market_id":"point_spread:M::","odds":-350,"selection_id":"point_spread:M::BH:Mladá Boleslav -0.5"}],"away_team":"Mlada Boleslav","best_case_profit":95.14,"break_even_percent":2.49,"detected_at":"2026-07-27T16:24:46Z","event_id":"czech_republic_-_first_league_artisbrno_mladaboleslav_2026-07-27_b2","event_name":"Mlada Boleslav @ Sk Artis Brno","expected_value":27.6,"guaranteed_roi":null,"home_team":"Sk Artis Brno","id":"4e115d22b7e08557","is_guaranteed_profit":false,"is_live":false,"is_player_prop":false,"is_team_total":false,"key_number_probability":0.38,"key_numbers":[-1],"league":"czech_first_league","league_label":"Czech First League","market_label":"Point Spread","market_overround":1.0249,"market_type":"point_spread","middle_numbers":[-1],"middle_probability":0.3078,"middle_size":1,"odds_age_seconds":28.2,"player_name":null,"quality_score":27.95,"roi_percentage":27.6,"side1":{"book":"hardrock","external_event_id":"6094294020626972928","fair_probability":0.5605,"line":1.5,"market_id":"point_spread:M::","odds_age_seconds":28.2,"odds_american":-135,"odds_decimal":1.741,"odds_probability":0.574468085106383,"selection":"SK Artis Brno","selection_id":"point_spread:M::AH:SK Artis Brno +1.5","stake_percent":56.05},"side2":{"book":"pinnacle","external_event_id":"1632990677","fair_probability":0.4395,"line":-0.5,"market_id":"1632990677_point_spread_0_-0.5","odds_age_seconds":19,"odds_american":122,"odds_decimal":2.22,"odds_probability":0.45045045045045046,"selection":"Mlada Boleslav","selection_id":"away","stake_percent":43.95},"sport":"soccer","start_time":"2026-07-27T16:00Z","stat_category":null,"team_name":null,"warnings":[],"worst_case_loss":-2.43,"worst_case_pnl":-2.43},{"alt_side1":[],"alt_side2":[],"away_team":"Mlada Boleslav","best_case_profit":84.05,"break_even_percent":8.68,"detected_at":"2026-07-27T16:24:46Z","event_id":"czech_republic_-_first_league_artisbrno_mladaboleslav_2026-07-27_b2","event_name":"Mlada Boleslav @ Sk Artis Brno","expected_value":20.35,"guaranteed_roi":null,"home_team":"Sk Artis Brno","id":"7b2236b4044bb037","is_guaranteed_profit":false,"is_live":false,"is_player_prop":false,"is_team_total":false,"key_number_probability":0.38,"key_numbers":[-1],"league":"czech_first_league","league_label":"Czech First League","market_label":"Point Spread","market_overround":1.0867,"market_type":"point_spread","middle_numbers":[-1],"middle_probability":0.3078,"middle_size":1.3,"odds_age_seconds":28.2,"player_name":null,"quality_score":25.52,"roi_percentage":20.35,"side1":{"book":"hardrock","external_event_id":"6094294020626972928","fair_probability":0.5287,"line":1.5,"market_id":"point_spread:M::","odds_age_seconds":28.2,"odds_american":-135,"odds_decimal":1.741,"odds_probability":0.574468085106383,"selection":"SK Artis Brno","selection_id":"point_spread:M::AH:SK Artis Brno +1.5","stake_percent":52.87},"side2":{"book":"pinnacle","external_event_id":"1632990677","fair_probability":0.4713,"line":-0.25,"market_id":"1632990677_point_spread_0_-0.25","odds_age_seconds":19,"odds_american":-105,"odds_decimal":1.952,"odds_probability":0.5121951219512195,"selection":"Mlada Boleslav","selection_id":"away","stake_percent":47.13},"sport":"soccer","start_time":"2026-07-27T16:00Z","stat_category":null,"team_name":null,"warnings":[],"worst_case_loss":-7.98,"worst_case_pnl":-7.98}],"pagination":{"limit":2,"offset":0,"count":2,"total":641,"has_more":true,"next_offset":2},"updated_at":"2026-07-27T16:24:46.815818432Z"} diff --git a/tests/test_wire_contract.py b/tests/test_wire_contract.py new file mode 100644 index 0000000..8cfd9d8 --- /dev/null +++ b/tests/test_wire_contract.py @@ -0,0 +1,157 @@ +"""Model <-> wire contract, pinned against captured live payloads. + +Issue #18: two model/wire mismatches shipped undetected because nothing here ever +parsed a real response. + +- ``MiddleSide`` declared a required ``odds: OddsValue``. The wire sends odds + **flat**. Every ``client.middles()`` call raised ``ValidationError`` — loud, but + only at runtime, in a user's process. +- ``EVOpportunity.confidence_score`` had no alias for the wire's ``confidence``, so + it silently read ``None`` on every row. Consumers filtering on it got nothing and + no error. + +The second kind is the reason these tests exist. A missing required field announces +itself; a field that quietly never populates does not, and no amount of unit testing +against hand-written dicts will catch it — the hand-written dict is written from the +same wrong belief as the model. + +Fixtures are real responses captured from the deployed API (``tests/fixtures``). +Refresh them when the wire changes on purpose; a failure here means the wire moved +and the models did not. + +Deliberately *not* asserted: "every declared field is populated by some payload." +That check was written and dropped — a small fixture cannot distinguish "the SDK +declares a field the wire never sends" from "this sample happened not to include a +conditional field." It flagged ``sharp_odds_american``, ``is_suspended`` and the +``*_ref`` objects, all of which are legitimately conditional. Catching the +``confidence_score`` class generically needs the full field space, not two rows; +until then it is pinned explicitly above. +""" + +import json +import re +from pathlib import Path + +import pytest + +from sharpapi.models import EVOpportunity, MiddleOpportunity, MiddleSide + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _rows(name: str) -> list[dict]: + payload = json.loads((FIXTURES / name).read_text()) + rows = payload.get("data") or [] + assert rows, f"{name} has no rows — recapture it, an empty fixture asserts nothing" + return rows + + +def _sides(rows: list[dict]) -> list[dict]: + return [s for r in rows for k in ("side1", "side2") if (s := r.get(k))] + + +# --------------------------------------------------------------------------- # +# Regression guards for the two shipped defects +# --------------------------------------------------------------------------- # + + +def test_middles_response_parses(): + """The #18 headline: this raised ValidationError on every real payload.""" + for row in _rows("middles_live.json"): + MiddleOpportunity.model_validate(row) + + +def test_middle_side_reads_flat_odds(): + for side in _sides(_rows("middles_live.json")): + parsed = MiddleSide.model_validate(side) + assert parsed.odds_american == side["odds_american"] + assert parsed.odds_decimal == side["odds_decimal"] + + +def test_ev_confidence_score_populates_from_wire_confidence(): + """Wire sends ``confidence``; the public attribute stayed ``confidence_score``.""" + for row in _rows("ev_live.json"): + if (wire := row.get("confidence")) is None: + continue + assert EVOpportunity.model_validate(row).confidence_score == pytest.approx(wire) + break + else: + pytest.skip("no row carried `confidence`") + + +def test_ev_quality_tier_populates(): + for row in _rows("ev_live.json"): + if (wire := row.get("quality_tier")) is None: + continue + assert EVOpportunity.model_validate(row).quality_tier == wire + break + else: + pytest.skip("no row carried `quality_tier`") + + +# --------------------------------------------------------------------------- # +# The general guard — catches the next one of these, not just these two +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("model", "fixture", "extract"), + [ + (MiddleSide, "middles_live.json", _sides), + (EVOpportunity, "ev_live.json", lambda rows: rows), + ], + ids=["MiddleSide", "EVOpportunity"], +) +def test_no_required_field_is_absent_from_the_wire(model, fixture, extract): + """A required field the wire never sends makes the endpoint unparseable. + + This is the shape of the ``MiddleSide.odds`` bug, stated generally. + """ + payloads = extract(_rows(fixture)) + required = {n for n, f in model.model_fields.items() if f.is_required()} + for payload in payloads: + missing = { + r + for r in required + if r not in payload and not _satisfied_by_alias(model, r, payload) + } + assert not missing, ( + f"{model.__name__} requires {sorted(missing)}, absent from a live payload " + f"— this endpoint cannot be parsed. Wire keys: {sorted(payload)}" + ) + + +def _satisfied_by_alias(model, field_name: str, payload: dict) -> bool: + alias = model.model_fields[field_name].validation_alias + if alias is None: + return False + choices = getattr(alias, "choices", None) + if choices is None: + return isinstance(alias, str) and alias in payload + return any(isinstance(c, str) and c in payload for c in choices) + + +# --------------------------------------------------------------------------- # +# Version declarations are also a wire contract — with the packaging metadata +# --------------------------------------------------------------------------- # + + +def test_dunder_version_matches_pyproject(): + """``__version__`` and ``pyproject.toml`` are two declarations of one fact. + + They drifted: #13 bumped ``pyproject.toml`` 0.4.0 -> 0.4.1 and left + ``__init__.py`` at 0.4.0, so the published 0.4.1 sdist reports + ``sharpapi.__version__ == "0.4.0"``. PyPI is immutable, so that wrong answer is + permanent for 0.4.1 — this test only stops the next one. + + Compares the two *sources*, not ``importlib.metadata``. Dist metadata is only as + fresh as the last ``pip install``; a stale editable install reports whatever it + was built at (0.2.0 on this dev box), which would make this test pass in CI and + fail locally for a reason that has nothing to do with the bug. + """ + import sharpapi + + pyproject = (Path(__file__).parents[1] / "pyproject.toml").read_text() + declared = re.search(r'^version\s*=\s*"([^"]+)"', pyproject, re.MULTILINE) + assert declared, "no version in pyproject.toml" + assert sharpapi.__version__ == declared.group(1)