Coverage for src/pyrf_api/schemas.py: 98%
62 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-06 21:54 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-06 21:54 +0000
1"""Pydantic request/response schemas for the API."""
3from typing import Literal
5from pydantic import BaseModel, Field, field_validator
8class ImpliedProbRequest(BaseModel):
9 """Request body for implied-prob and implied-odds endpoints."""
11 market: Literal["TotalGoals", "AsianHandicap"] = Field(
12 ..., description="Market type: TotalGoals (over/under) or AsianHandicap"
13 )
14 line: float = Field(..., description="Betting line, must be multiple of 0.25")
15 model: Literal["BivariatePoisson", "DixonColes", "RingfinityModelZero"] = Field(
16 ..., description="Probability model"
17 )
18 lambda_home: float = Field(..., ge=0, description="Home team expected goals")
19 lambda_away: float = Field(..., ge=0, description="Away team expected goals")
20 rho: float | None = Field(default=None, description="Dixon-Coles dependence (DixonColes only)")
21 n: int | None = Field(default=None, ge=1, le=50, description="GoalGrid size (default from lib)")
23 @field_validator("line")
24 @classmethod
25 def line_multiple_of_quarter(cls, v: float) -> float:
26 """Validate that line is a finite multiple of 0.25."""
27 if not (abs(v) < float("inf")):
28 raise ValueError("Line must be finite")
29 remainder = abs(v * 4 - round(v * 4))
30 if remainder > 1e-9: # allow tiny float error
31 raise ValueError("Line must be a multiple of 0.25")
32 return v
35class ImpliedProbResponse(BaseModel):
36 """Response for implied-prob endpoint."""
38 probabilities: dict[str, float] = Field(
39 ..., description="Model-implied probabilities keyed by side (Over/Under or Home/Away)"
40 )
43class QuoteLegSchema(BaseModel):
44 """One leg of a quote."""
46 side: str = Field(..., description="Bet side (Over, Under, Home, Away)")
47 odds: float = Field(..., description="Decimal odds")
50class ImpliedOddsResponse(BaseModel):
51 """Response for implied-odds endpoint."""
53 market: str = Field(..., description="Market name")
54 line: float = Field(..., description="Line value")
55 legs: list[QuoteLegSchema] = Field(..., description="Quote legs (sides and odds)")
58def _validate_line(v: float) -> float:
59 """Validate that a line value is a finite multiple of 0.25."""
60 if not (abs(v) < float("inf")):
61 raise ValueError("Line must be finite")
62 remainder = abs(v * 4 - round(v * 4))
63 if remainder > 1e-9:
64 raise ValueError("Line must be a multiple of 0.25")
65 return v
68class TotalGoalsQuoteSchema(BaseModel):
69 """Total Goals quote with Over/Under decimal odds."""
71 line: float = Field(..., description="TG line, must be multiple of 0.25")
72 over_odds: float = Field(..., gt=1, description="Decimal odds for Over")
73 under_odds: float = Field(..., gt=1, description="Decimal odds for Under")
75 @field_validator("line")
76 @classmethod
77 def line_multiple_of_quarter(cls, v: float) -> float:
78 """Validate line is a finite multiple of 0.25."""
79 return _validate_line(v)
82class AsianHandicapQuoteSchema(BaseModel):
83 """Asian Handicap quote with Home/Away decimal odds."""
85 line: float = Field(..., description="AH line, must be multiple of 0.25")
86 home_odds: float = Field(..., gt=1, description="Decimal odds for Home")
87 away_odds: float = Field(..., gt=1, description="Decimal odds for Away")
89 @field_validator("line")
90 @classmethod
91 def line_multiple_of_quarter(cls, v: float) -> float:
92 """Validate line is a finite multiple of 0.25."""
93 return _validate_line(v)
96class CalibrateRequest(BaseModel):
97 """Request body for the calibrate endpoint."""
99 tg_quote: TotalGoalsQuoteSchema = Field(..., description="Total Goals market quote")
100 ah_quote: AsianHandicapQuoteSchema = Field(..., description="Asian Handicap market quote")
101 model: Literal["DixonColes", "RingfinityModelZero"] = Field(
102 default="DixonColes", description="Model class to calibrate"
103 )
104 rho: float | None = Field(default=None, description="Dixon-Coles dependence (ignored for RingfinityModelZero)")
105 xtol: float = Field(default=1e-3, gt=0, description="Solver tolerance for root-finding")
108class CalibrateResponse(BaseModel):
109 """Response for the calibrate endpoint."""
111 model: str = Field(..., description="Calibrated model type")
112 lambda_home: float = Field(..., description="Calibrated home expected goals")
113 lambda_away: float = Field(..., description="Calibrated away expected goals")
114 rho: float = Field(..., description="Dixon-Coles dependence parameter")