Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ jobs:

- name: Install dependencies
run: |
pip install -e ".[pandas]"
pip install -e ".[pandas,test]"
pip install ruff pyright

- name: Ruff check
run: ruff check

- 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
2 changes: 1 addition & 1 deletion src/sharpapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
)
from .streaming import EventStream

__version__ = "0.4.0"
__version__ = "0.4.1"

__all__ = [
# Clients
Expand Down
25 changes: 22 additions & 3 deletions src/sharpapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/ev_live.json
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions tests/fixtures/middles_live.json
Original file line number Diff line number Diff line change
@@ -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"}
157 changes: 157 additions & 0 deletions tests/test_wire_contract.py
Original file line number Diff line number Diff line change
@@ -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)