Coverage for packages / betting / value_bets.py: 0%

25 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-08 08:37 +1200

1"""Value bet finder — identifies betting opportunities where model 

2probabilities exceed market-implied probabilities. 

3 

4Uses Kelly criterion sizing and confidence filtering to surface 

5actionable betting recommendations. 

6""" 

7 

8from __future__ import annotations 

9 

10from decimal import Decimal 

11from typing import Any 

12 

13 

14class ValueBetFinder: 

15 """Identifies value bets by comparing model vs market probabilities. 

16 

17 A "value bet" exists when the model's estimated win/place probability 

18 exceeds the market-implied probability by a configurable threshold. 

19 """ 

20 

21 def __init__( 

22 self, 

23 min_edge: float = 0.05, 

24 min_confidence: float = 0.3, 

25 max_kelly_fraction: float = 0.25, 

26 ) -> None: 

27 """Initialize the value bet finder. 

28 

29 Args: 

30 min_edge: Minimum edge (model_prob - market_prob) to 

31 consider a value bet. Default 0.05 (5%). 

32 min_confidence: Minimum model confidence (probability) to 

33 consider. Filters out very low-probability bets. 

34 Default 0.3. 

35 max_kelly_fraction: Maximum fraction of bankroll to 

36 stake per bet (Kelly cap). Default 0.25 (25%). 

37 """ 

38 self.min_edge = min_edge 

39 self.min_confidence = min_confidence 

40 self.max_kelly_fraction = max_kelly_fraction 

41 

42 def find_value_bets( 

43 self, 

44 comparisons: list[dict[str, Any]], 

45 bankroll: Decimal = Decimal("1000"), 

46 ) -> list[dict[str, Any]]: 

47 """Find value bets from model-vs-market comparisons. 

48 

49 Args: 

50 comparisons: List of comparison dicts from 

51 OddsComparisonClient.compare_to_model(). 

52 bankroll: Current bankroll for Kelly stake sizing. 

53 

54 Returns: 

55 List of value bet dicts, sorted by edge descending, with keys: 

56 - runner_name: str 

57 - starter_id: int | None 

58 - model_prob: float 

59 - market_prob: float 

60 - edge: float 

61 - kelly_stake: float (fraction of bankroll) 

62 - recommended_stake: Decimal (in bankroll units) 

63 - confidence: str ("high", "medium", "low") 

64 """ 

65 raise NotImplementedError 

66 

67 def kelly_criterion( 

68 self, 

69 model_prob: float, 

70 market_prob: float, 

71 ) -> float: 

72 """Calculate Kelly stake fraction. 

73 

74 f* = (p * b - q) / b 

75 where p = model_prob, q = 1-p, b = (1/market_prob) - 1 

76 

77 Args: 

78 model_prob: Model's estimated win probability. 

79 market_prob: Market-implied win probability. 

80 

81 Returns: 

82 Kelly fraction (0 to max_kelly_fraction). 

83 """ 

84 if market_prob <= 0 or market_prob >= 1: 

85 return 0.0 

86 if model_prob <= market_prob: 

87 return 0.0 

88 

89 b = (Decimal("1") / Decimal(str(market_prob))) - Decimal("1") 

90 f_star = ( 

91 Decimal(str(model_prob)) * b - (Decimal("1") - Decimal(str(model_prob))) 

92 ) / b 

93 return min(float(f_star), self.max_kelly_fraction) 

94 

95 def filter_by_confidence( 

96 self, 

97 bets: list[dict[str, Any]], 

98 min_edge: float | None = None, 

99 min_prob: float | None = None, 

100 ) -> list[dict[str, Any]]: 

101 """Filter value bets by minimum edge and probability thresholds. 

102 

103 Args: 

104 bets: List of value bet dicts. 

105 min_edge: Override default min_edge. 

106 min_prob: Override default min_confidence. 

107 

108 Returns: 

109 Filtered list of value bet dicts. 

110 """ 

111 edge = min_edge if min_edge is not None else self.min_edge 

112 prob = min_prob if min_prob is not None else self.min_confidence 

113 return [ 

114 b 

115 for b in bets 

116 if b.get("edge", 0) >= edge and b.get("model_prob", 0) >= prob 

117 ] 

118 

119 def summarize( 

120 self, 

121 bets: list[dict[str, Any]], 

122 ) -> dict[str, Any]: 

123 """Generate a summary of value bet opportunities. 

124 

125 Args: 

126 bets: List of value bet dicts. 

127 

128 Returns: 

129 Summary dict with count, total stake, average edge, etc. 

130 """ 

131 if not bets: 

132 return { 

133 "total_bets": 0, 

134 "total_stake": Decimal("0"), 

135 "avg_edge": 0.0, 

136 "high_confidence": 0, 

137 "medium_confidence": 0, 

138 "low_confidence": 0, 

139 } 

140 

141 return { 

142 "total_bets": len(bets), 

143 "total_stake": sum(b.get("recommended_stake", Decimal("0")) for b in bets), 

144 "avg_edge": sum(b.get("edge", 0) for b in bets) / len(bets), 

145 "high_confidence": sum(1 for b in bets if b.get("confidence") == "high"), 

146 "medium_confidence": sum( 

147 1 for b in bets if b.get("confidence") == "medium" 

148 ), 

149 "low_confidence": sum(1 for b in bets if b.get("confidence") == "low"), 

150 }