The Proppr Player Bot analyzes individual player statistics and sends alerts when bookmaker odds on player props (goals, assists, shots, cards) diverge significantly from statistical projections based on recent performance data.
Player Bot tracks player-level performance metrics across 40+ markets, comparing bookmaker odds against projected probabilities calculated from historical player stats, positional data, and contextual factors like opposition strength and home/away splits.
# From player_bot.py:1500-1550def filter_by_lineup_status(alerts, user_settings): """ Filter alerts based on lineup confirmation status. Options: - confirmed_only: Only XI confirmed players - confirmed_or_bench: XI or bench players - any: All players regardless of status """ lineup_filter = user_settings.get('lineup_filter', 'confirmed_only') if lineup_filter == 'any': return alerts # No filtering filtered = [] for alert in alerts: lineup_status = alert.get('lineup_status', 'unknown') if lineup_filter == 'confirmed_only': if lineup_status == 'confirmed_xi': filtered.append(alert) elif lineup_filter == 'confirmed_or_bench': if lineup_status in ['confirmed_xi', 'confirmed_bench']: filtered.append(alert) return filtered
# From player_bot.py:255-315def get_scoped_field_name(base_field_name, scope, is_player_stat=False): """ Build scoped field names for player stat lookups. Player stats are nested in avg_stats_{scope} containers. Examples: - avg_stats + last10 -> avg_stats_last10 - projected_goals + season -> projected_goals_season """ if base_field_name == "avg_stats": return f"avg_stats_{scope}" return f"{base_field_name}_{scope}"
# From player_bot.py:3000-3100def calculate_player_market_probability(player_stats, market_name, line, scope='last10'): """ Calculate probability of player exceeding market line. Uses Poisson distribution for count markets (goals, shots, tackles) and historical frequency for binary markets (cards, goals) """ stat_key = MARKET_TO_STAT_MAP.get(market_name) if not stat_key: return None # Get scoped stats container avg_stats = player_stats.get(f'avg_stats_{scope}', {}) player_avg = avg_stats.get(stat_key) if player_avg is None: return None # For count stats, use Poisson distribution if market_name in COUNT_MARKETS: from scipy.stats import poisson # P(X > line) = 1 - P(X <= line) prob_under = poisson.cdf(line, player_avg) prob_over = 1 - prob_under return prob_over * 100 # For binary stats, use frequency if market_name in BINARY_MARKETS: # Convert average to probability (0-1 scale) return min(player_avg * 100, 100) return None
# From player_bot.py:4000-4100class PlayerAlertBatcher: """ Batch player alerts by fixture to reduce message spam. Groups alerts for same fixture and sends as single message. """ def __init__(self, batch_window_seconds=45): self.pending_alerts = defaultdict(list) # fixture_id -> [alerts] self.batch_timers = {} # fixture_id -> Timer self.batch_window = batch_window_seconds def queue_alert(self, alert_data): """Add alert to batch queue""" fixture_id = alert_data['fixture_id'] self.pending_alerts[fixture_id].append(alert_data) # Start timer if first alert for fixture if fixture_id not in self.batch_timers: timer = threading.Timer( self.batch_window, lambda: self._send_batched_alerts(fixture_id) ) timer.start() self.batch_timers[fixture_id] = timer def _send_batched_alerts(self, fixture_id): """Send all queued alerts for fixture as single message""" alerts = self.pending_alerts.pop(fixture_id, []) if not alerts: return # Group alerts by player by_player = defaultdict(list) for alert in alerts: by_player[alert['player_name']].append(alert) # Format combined message message = self._format_fixture_alert_group( fixture_id, by_player ) # Send to all subscribed users self._broadcast_to_users(message, fixture_id)
# From player_bot.py:185-200def run_singleton_job(job_name: str, fn, *args, **kwargs): """ Ensure only one instance of a job runs at a time. Prevents overlapping executions that could cause race conditions. """ lock = _get_job_run_lock(job_name) if not lock.acquire(blocking=False): logging.warning(f"Skipping '{job_name}' - previous run still active") return None try: return fn(*args, **kwargs) finally: lock.release()