Skip to content

Commit 4d854b6

Browse files
Mlaz-coderootclaude
authored
fix(models): flatten MiddleSide to the wire; alias confidence; add quality_tier (#20)
Fixes #18. ## The two defects, reproduced against live payloads ``` MiddleOpportunity.model_validate(<live row>) -> 2 validation errors side1.odds Field required side2.odds Field required EVOpportunity.model_validate(<live row>) -> parsed, but: wire confidence=80 -> model.confidence_score=None wire quality_tier='A' -> model has no such attribute ``` `client.middles()` could not parse **any** successful response. `confidence_score` read `None` on every row. ## One correction to the issue #18 says the wire sends `sportsbook` rather than `book`. **It doesn't** — live sides send `book`, which the model already accepted. The only field that broke parsing was the nested `odds`. Changing `book` on that report would have introduced a bug rather than fixed one. Live side shape, all 12 fields present on 4/4 sampled sides: ``` book selection line odds_american odds_decimal odds_probability fair_probability stake_percent odds_age_seconds external_event_id market_id selection_id ``` ## Choices worth flagging **`confidence_score` is aliased, not renamed.** `AliasChoices("confidence", "confidence_score")` matches the file's existing idiom (`fair_probability`/`true_probability`, `sharp_book`/`devig_book`) and keeps the public attribute name, so nobody's code breaks. **`MiddleSide` is a hard shape change** — `side.odds.american` becomes `side.odds_american`. Normally breaking; here it cannot be, because the model never parsed a response, so no working consumer exists to break. ## Bonus defect found while checking the release path `pyproject.toml` = `0.4.1`, `__init__.py` = `0.4.0`. #13 bumped one and missed the other, and **0.4.1 is already on PyPI** — so the published package reports `sharpapi.__version__ == "0.4.0"`. PyPI is immutable, so that stays wrong for 0.4.1 forever; the test here only stops the next one. Synced to 0.4.1 (matching what is published), which is a correction, not a release bump. ## Tests — 9 new, all against captured live payloads `tests/test_wire_contract.py` + `tests/fixtures/{middles,ev}_live.json`. Hand-written dicts could never have caught this: they'd be written from the same wrong belief as the model. Two notes on what is deliberately **not** asserted, both discovered by writing the check and watching it misfire: - **"every declared field is populated by some payload"** — dropped. A 2-row fixture cannot tell "the SDK declares a field the wire never sends" from "this sample lacked a conditional field." It flagged `sharp_odds_american`, `is_suspended` and the `*_ref` objects, all legitimately conditional. - **`importlib.metadata.version()`** for the version check — replaced with a direct read of `pyproject.toml`. Dist metadata is only as fresh as the last `pip install`; the editable install on the dev box reports `0.2.0`, so that test would have passed in CI and failed locally for a reason unrelated to the bug. Run locally with `PYTHONPATH=src` — the editable install resolves to the canonical checkout, not a worktree. ## Release Not bumping the release version — this repo lands those as separate `chore(release):` commits, and PyPI needs a `gh release create` to publish. This fix should land before the docs audit's Wave 1, since the docs mirror these model shapes and the SDK is the source they follow. Type: fix --------- Co-authored-by: root <root@api-dev.hs.chocopancake.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3b92d04 commit 4d854b6

6 files changed

Lines changed: 191 additions & 5 deletions

File tree

.github/workflows/test.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,19 @@ jobs:
2222

2323
- name: Install dependencies
2424
run: |
25-
pip install -e ".[pandas]"
25+
pip install -e ".[pandas,test]"
2626
pip install ruff pyright
2727
2828
- name: Ruff check
2929
run: ruff check
3030

3131
- name: Pyright
3232
run: pyright
33+
34+
# This workflow is named "Tests" but never ran any until now — `tests/` has
35+
# existed since 0.3.x and CI only linted and type-checked it. #18 shipped two
36+
# model/wire mismatches that a single parse of a real payload would have
37+
# caught, so the fixture round-trip it asks for is only worth adding if it can
38+
# actually fail the build.
39+
- name: Pytest
40+
run: pytest -q

src/sharpapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
)
6262
from .streaming import EventStream
6363

64-
__version__ = "0.4.0"
64+
__version__ = "0.4.1"
6565

6666
__all__ = [
6767
# Clients

src/sharpapi/models.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,13 @@ class EVOpportunity(BaseModel):
274274
kelly_percent: float | None = Field(
275275
None, validation_alias=AliasChoices("kelly_percent", "kelly_fraction")
276276
)
277-
confidence_score: float | None = None
277+
# The wire field is `confidence` (int 0-100). Aliased rather than renamed so
278+
# the public attribute name is unchanged — before this, `confidence_score`
279+
# silently read None on every row (#18).
280+
confidence_score: float | None = Field(
281+
None, validation_alias=AliasChoices("confidence", "confidence_score")
282+
)
283+
quality_tier: str | None = None
278284
book_count: int | None = None
279285
market_width: float | None = None
280286
devig_method: str | None = None
@@ -383,14 +389,27 @@ class ArbitrageOpportunity(BaseModel):
383389

384390

385391
class MiddleSide(BaseModel):
386-
"""One side of a middle opportunity."""
392+
"""One side of a middle opportunity.
393+
394+
Odds are **flat** on the wire — ``odds_american`` / ``odds_decimal`` /
395+
``odds_probability`` — the same shape ``EVOpportunity`` uses, not a nested
396+
``OddsValue``. This previously declared a required ``odds: OddsValue``, which
397+
is absent from every real payload, so ``client.middles()`` raised
398+
``ValidationError`` on any response carrying a side (#18).
399+
"""
387400

388401
book: str
389402
selection: str
390403
line: float
391-
odds: OddsValue
404+
odds_american: int | float
405+
odds_decimal: float
406+
odds_probability: float | None = None
407+
fair_probability: float | None = None
392408
stake_percent: float | None = None
393409
odds_age_seconds: float | None = None
410+
external_event_id: str | None = None
411+
market_id: str | None = None
412+
selection_id: str | None = None
394413
deep_link: str | None = None
395414

396415

tests/fixtures/ev_live.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}

tests/fixtures/middles_live.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}

tests/test_wire_contract.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Model <-> wire contract, pinned against captured live payloads.
2+
3+
Issue #18: two model/wire mismatches shipped undetected because nothing here ever
4+
parsed a real response.
5+
6+
- ``MiddleSide`` declared a required ``odds: OddsValue``. The wire sends odds
7+
**flat**. Every ``client.middles()`` call raised ``ValidationError`` — loud, but
8+
only at runtime, in a user's process.
9+
- ``EVOpportunity.confidence_score`` had no alias for the wire's ``confidence``, so
10+
it silently read ``None`` on every row. Consumers filtering on it got nothing and
11+
no error.
12+
13+
The second kind is the reason these tests exist. A missing required field announces
14+
itself; a field that quietly never populates does not, and no amount of unit testing
15+
against hand-written dicts will catch it — the hand-written dict is written from the
16+
same wrong belief as the model.
17+
18+
Fixtures are real responses captured from the deployed API (``tests/fixtures``).
19+
Refresh them when the wire changes on purpose; a failure here means the wire moved
20+
and the models did not.
21+
22+
Deliberately *not* asserted: "every declared field is populated by some payload."
23+
That check was written and dropped — a small fixture cannot distinguish "the SDK
24+
declares a field the wire never sends" from "this sample happened not to include a
25+
conditional field." It flagged ``sharp_odds_american``, ``is_suspended`` and the
26+
``*_ref`` objects, all of which are legitimately conditional. Catching the
27+
``confidence_score`` class generically needs the full field space, not two rows;
28+
until then it is pinned explicitly above.
29+
"""
30+
31+
import json
32+
import re
33+
from pathlib import Path
34+
35+
import pytest
36+
37+
from sharpapi.models import EVOpportunity, MiddleOpportunity, MiddleSide
38+
39+
FIXTURES = Path(__file__).parent / "fixtures"
40+
41+
42+
def _rows(name: str) -> list[dict]:
43+
payload = json.loads((FIXTURES / name).read_text())
44+
rows = payload.get("data") or []
45+
assert rows, f"{name} has no rows — recapture it, an empty fixture asserts nothing"
46+
return rows
47+
48+
49+
def _sides(rows: list[dict]) -> list[dict]:
50+
return [s for r in rows for k in ("side1", "side2") if (s := r.get(k))]
51+
52+
53+
# --------------------------------------------------------------------------- #
54+
# Regression guards for the two shipped defects
55+
# --------------------------------------------------------------------------- #
56+
57+
58+
def test_middles_response_parses():
59+
"""The #18 headline: this raised ValidationError on every real payload."""
60+
for row in _rows("middles_live.json"):
61+
MiddleOpportunity.model_validate(row)
62+
63+
64+
def test_middle_side_reads_flat_odds():
65+
for side in _sides(_rows("middles_live.json")):
66+
parsed = MiddleSide.model_validate(side)
67+
assert parsed.odds_american == side["odds_american"]
68+
assert parsed.odds_decimal == side["odds_decimal"]
69+
70+
71+
def test_ev_confidence_score_populates_from_wire_confidence():
72+
"""Wire sends ``confidence``; the public attribute stayed ``confidence_score``."""
73+
for row in _rows("ev_live.json"):
74+
if (wire := row.get("confidence")) is None:
75+
continue
76+
assert EVOpportunity.model_validate(row).confidence_score == pytest.approx(wire)
77+
break
78+
else:
79+
pytest.skip("no row carried `confidence`")
80+
81+
82+
def test_ev_quality_tier_populates():
83+
for row in _rows("ev_live.json"):
84+
if (wire := row.get("quality_tier")) is None:
85+
continue
86+
assert EVOpportunity.model_validate(row).quality_tier == wire
87+
break
88+
else:
89+
pytest.skip("no row carried `quality_tier`")
90+
91+
92+
# --------------------------------------------------------------------------- #
93+
# The general guard — catches the next one of these, not just these two
94+
# --------------------------------------------------------------------------- #
95+
96+
97+
@pytest.mark.parametrize(
98+
("model", "fixture", "extract"),
99+
[
100+
(MiddleSide, "middles_live.json", _sides),
101+
(EVOpportunity, "ev_live.json", lambda rows: rows),
102+
],
103+
ids=["MiddleSide", "EVOpportunity"],
104+
)
105+
def test_no_required_field_is_absent_from_the_wire(model, fixture, extract):
106+
"""A required field the wire never sends makes the endpoint unparseable.
107+
108+
This is the shape of the ``MiddleSide.odds`` bug, stated generally.
109+
"""
110+
payloads = extract(_rows(fixture))
111+
required = {n for n, f in model.model_fields.items() if f.is_required()}
112+
for payload in payloads:
113+
missing = {
114+
r
115+
for r in required
116+
if r not in payload and not _satisfied_by_alias(model, r, payload)
117+
}
118+
assert not missing, (
119+
f"{model.__name__} requires {sorted(missing)}, absent from a live payload "
120+
f"— this endpoint cannot be parsed. Wire keys: {sorted(payload)}"
121+
)
122+
123+
124+
def _satisfied_by_alias(model, field_name: str, payload: dict) -> bool:
125+
alias = model.model_fields[field_name].validation_alias
126+
if alias is None:
127+
return False
128+
choices = getattr(alias, "choices", None)
129+
if choices is None:
130+
return isinstance(alias, str) and alias in payload
131+
return any(isinstance(c, str) and c in payload for c in choices)
132+
133+
134+
# --------------------------------------------------------------------------- #
135+
# Version declarations are also a wire contract — with the packaging metadata
136+
# --------------------------------------------------------------------------- #
137+
138+
139+
def test_dunder_version_matches_pyproject():
140+
"""``__version__`` and ``pyproject.toml`` are two declarations of one fact.
141+
142+
They drifted: #13 bumped ``pyproject.toml`` 0.4.0 -> 0.4.1 and left
143+
``__init__.py`` at 0.4.0, so the published 0.4.1 sdist reports
144+
``sharpapi.__version__ == "0.4.0"``. PyPI is immutable, so that wrong answer is
145+
permanent for 0.4.1 — this test only stops the next one.
146+
147+
Compares the two *sources*, not ``importlib.metadata``. Dist metadata is only as
148+
fresh as the last ``pip install``; a stale editable install reports whatever it
149+
was built at (0.2.0 on this dev box), which would make this test pass in CI and
150+
fail locally for a reason that has nothing to do with the bug.
151+
"""
152+
import sharpapi
153+
154+
pyproject = (Path(__file__).parents[1] / "pyproject.toml").read_text()
155+
declared = re.search(r'^version\s*=\s*"([^"]+)"', pyproject, re.MULTILINE)
156+
assert declared, "no version in pyproject.toml"
157+
assert sharpapi.__version__ == declared.group(1)

0 commit comments

Comments
 (0)