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

1"""Pydantic request/response schemas for the API.""" 

2 

3from typing import Literal 

4 

5from pydantic import BaseModel, Field, field_validator 

6 

7 

8class ImpliedProbRequest(BaseModel): 

9 """Request body for implied-prob and implied-odds endpoints.""" 

10 

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)") 

22 

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 

33 

34 

35class ImpliedProbResponse(BaseModel): 

36 """Response for implied-prob endpoint.""" 

37 

38 probabilities: dict[str, float] = Field( 

39 ..., description="Model-implied probabilities keyed by side (Over/Under or Home/Away)" 

40 ) 

41 

42 

43class QuoteLegSchema(BaseModel): 

44 """One leg of a quote.""" 

45 

46 side: str = Field(..., description="Bet side (Over, Under, Home, Away)") 

47 odds: float = Field(..., description="Decimal odds") 

48 

49 

50class ImpliedOddsResponse(BaseModel): 

51 """Response for implied-odds endpoint.""" 

52 

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)") 

56 

57 

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 

66 

67 

68class TotalGoalsQuoteSchema(BaseModel): 

69 """Total Goals quote with Over/Under decimal odds.""" 

70 

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") 

74 

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) 

80 

81 

82class AsianHandicapQuoteSchema(BaseModel): 

83 """Asian Handicap quote with Home/Away decimal odds.""" 

84 

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") 

88 

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) 

94 

95 

96class CalibrateRequest(BaseModel): 

97 """Request body for the calibrate endpoint.""" 

98 

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") 

106 

107 

108class CalibrateResponse(BaseModel): 

109 """Response for the calibrate endpoint.""" 

110 

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")