Skip to main content

Overview

The Proppr Horse Racing Bot monitors UK horse racing odds and sends value betting alerts for WIN and PLACE markets when bookmaker odds significantly exceed fair value estimates.

Purpose

Horse Bot specializes in UK horse racing, analyzing odds movements, market liquidity, and form data to identify horses trading at value prices. It uses Kelly Criterion for stake sizing and batches alerts by race to prevent overlapping positions.

Markets Covered

WIN Market

  • Horse to win the race outright
  • Sent night-before (17:00-22:00 window)
  • Limited to 8 horses per day

PLACE Market

  • Horse to finish in the places (1st, 2nd, 3rd, or 4th depending on field size)
  • Sent day-of (anytime on race day)
  • No daily limits
Place terms vary by field size:
  • 5-7 runners: 1st or 2nd (1/4 odds)
  • 8-15 runners: 1st, 2nd, or 3rd (1/5 odds)
  • 16+ runners: 1st, 2nd, 3rd, or 4th (1/4 odds)

Alert Criteria

Alerts are triggered when:
  1. Value Detected: Bookmaker odds exceed estimated fair value by threshold
  2. Within Alert Window:
    • WIN: 17:00-22:00 GMT/BST for next-day races
    • PLACE: Anytime on race day
  3. Race Tomorrow/Today:
    • WIN: Race must be tomorrow
    • PLACE: Race must be today or tomorrow
  4. Daily Limits:
    • WIN: Max 8 horses per day (global limit)
    • PLACE: No limits
  5. Per-User Limits: Max 12 alerts per user per day (configurable)
  6. Beta Access: User must be in beta chat (if configured)

Stake Calculation

# From horse_alert_formatter.py:100-150
def calculate_kelly_stake(fair_odds, bookmaker_odds, bankroll, kelly_fraction=0.25):
    """
    Calculate stake using fractional Kelly Criterion.
    
    Kelly Formula:
    - p = probability of winning (1 / fair_odds)
    - b = net odds (bookmaker_odds - 1)
    - q = probability of losing (1 - p)
    - Kelly% = (b*p - q) / b
    
    Example:
    - Fair odds: 5.0 (20% chance)
    - Bookmaker odds: 6.5
    - Kelly% = ((5.5 * 0.20) - 0.80) / 5.5 = 5.45%
    - Fractional Kelly (25%): 5.45% * 0.25 = 1.36% of bankroll
    """
    if not fair_odds or not bookmaker_odds or fair_odds <= 1.0 or bookmaker_odds <= 1.0:
        return 0
    
    p = 1 / fair_odds  # Win probability
    b = bookmaker_odds - 1  # Net odds
    q = 1 - p  # Lose probability
    
    kelly_pct = (b * p - q) / b
    
    # Apply fractional Kelly (default 25% for safety)
    fractional_kelly = kelly_pct * kelly_fraction
    
    # Cap at 5% of bankroll (risk management)
    fractional_kelly = min(fractional_kelly, 0.05)
    
    # Minimum stake: 0.5% of bankroll
    if fractional_kelly < 0.005:
        return 0
    
    stake = bankroll * fractional_kelly
    return round(stake, 2)

User Commands

Initialize bot and show welcome message
Horse Bot has fewer commands than other bots as it focuses on automated alert delivery with minimal user configuration.

Configuration Options

User Settings

# Horse bot user configuration
user_settings = {
    "alerts_enabled": True,
    "bankroll": 1000.0,           # Bankroll for stake sizing
    "kelly_fraction": 0.25,       # Fractional Kelly (25%)
    "min_value_percent": 10.0,    # Minimum value % to alert
    "max_odds": 15.0,             # Maximum odds to consider
    "min_odds": 3.0,              # Minimum odds to consider
    "win_market_enabled": True,   # Receive WIN alerts
    "place_market_enabled": True  # Receive PLACE alerts
}

Global Limits

# From config/constants.py:30-40
ALERT_WINDOW_START = dtime(17, 0)    # 5:00 PM
ALERT_WINDOW_END = dtime(22, 0)      # 10:00 PM
ALERT_TIMEZONE = "Europe/London"     # GMT/BST

MAX_WIN_HORSES_PER_DAY = 8           # Global WIN limit
MAX_ALERTS_PER_USER_PER_DAY = 12     # Per-user limit
TARGET_ALERTS_PER_USER_PER_DAY = 8   # Target (soft limit)

ALERT_COOLDOWN = 300                  # 5 minutes between duplicate alerts

Real Code Examples

Alert Batching System

# From horse_bot.py:305-481
class HorseRacingAlertSystem:
    """
    Batches alerts by race to adjust stakes for multiple horses.
    """
    
    def __init__(self, database, bot):
        self.db = database
        self.bot = bot
        self.pending_alerts = {}  # race_key -> [arb_data]
        self.batch_timers = {}    # race_key -> Timer
        self.batch_window_seconds = 1  # 1 second batching
    
    def _get_race_key(self, arb_data: dict) -> str:
        """Generate unique key: location_YYYY-MM-DD_HH:MM"""
        location = arb_data.get("location", "unknown")
        race_time = arb_data.get("race_time", "")
        
        try:
            race_dt = datetime.fromisoformat(race_time.replace("Z", "+00:00"))
            return f"{location}_{race_dt.strftime('%Y-%m-%d_%H:%M')}"
        except:
            return f"{location}_{race_time}"
    
    def send_alert_for_new_arb(self, arb_data: dict):
        """Queue alert for batching"""
        race_key = self._get_race_key(arb_data)
        
        # Add to pending queue
        if race_key not in self.pending_alerts:
            self.pending_alerts[race_key] = []
        self.pending_alerts[race_key].append(arb_data)
        
        # Start batch timer if first alert
        if race_key not in self.batch_timers:
            timer = threading.Timer(
                self.batch_window_seconds,
                lambda: self._process_batched_race(race_key)
            )
            timer.start()
            self.batch_timers[race_key] = timer
    
    def _process_batched_race(self, race_key: str):
        """Process all queued alerts for a race"""
        arb_list = self.pending_alerts.pop(race_key, [])
        if not arb_list:
            return
        
        logger.info(f"🏁 Processing batched race: {race_key} ({len(arb_list)} horses)")
        
        # Get all active users
        users = self.db.get_all_active_users()
        
        for user_id in users:
            user_settings = self.db.get_user_settings(user_id)
            
            # Filter by user preferences
            filtered = [
                arb for arb in arb_list
                if HorseRacingFilter.should_send_alert(arb, user_settings)
            ]
            
            if not filtered:
                continue
            
            # Adjust stakes for multiple horses in same race
            adjusted = self._adjust_stakes_for_multiple_horses(
                filtered, user_settings
            )
            
            # Send each alert
            for arb in adjusted:
                self.send_alert_to_user(user_id, arb)

Multi-Horse Stake Adjustment

# From horse_bot.py:161-196
def _adjust_stakes_for_multiple_horses(self, arb_list: list, user_settings: dict) -> list:
    """
    Adjust stakes when multiple horses are in the same race.
    Only one horse can win, so reduce stakes proportionally.
    
    Strategy:
    - If 2+ horses: divide each stake by number of horses
    - Keeps total exposure reasonable
    - Ensures profit if one wins
    
    Example:
    - 3 horses in race, each with 2pt Kelly stake
    - Adjusted: each gets 2pt / 3 = 0.67pt stake
    - Total risk: 2pt instead of 6pt
    """
    if len(arb_list) <= 1:
        for arb in arb_list:
            arb['adjusted_stake_multiplier'] = 1.0
        return arb_list
    
    num_horses = len(arb_list)
    adjustment_factor = 1.0 / num_horses
    
    logger.info(f"🔄 Adjusting stakes for {num_horses} horses in same race")
    
    for arb in arb_list:
        arb['adjusted_stake_multiplier'] = adjustment_factor
        logger.info(
            f"  → {arb.get('horse_name', 'Unknown')}: "
            f"stake multiplier = {adjustment_factor:.2f}x"
        )
    
    return arb_list

Alert Filtering

# From filters/filters.py:50-150
class HorseRacingFilter:
    """
    Filter horse racing alerts by user preferences.
    """
    
    @staticmethod
    def should_send_alert(arb_data: dict, user_settings: dict) -> bool:
        """
        Check if alert meets user's criteria.
        
        Filters:
        - Odds range (min/max)
        - Value percentage threshold
        - Market type (WIN vs PLACE)
        - Stake amount (minimum)
        """
        # Check market type enabled
        market_type = arb_data.get('market_type', 'WIN')
        if market_type == 'WIN' and not user_settings.get('win_market_enabled', True):
            return False
        if market_type == 'PLACE' and not user_settings.get('place_market_enabled', True):
            return False
        
        # Check odds range
        odds = arb_data.get('bookmaker_odds')
        min_odds = user_settings.get('min_odds', 3.0)
        max_odds = user_settings.get('max_odds', 15.0)
        
        if odds < min_odds or odds > max_odds:
            return False
        
        # Check value percentage
        value_pct = arb_data.get('value_percent', 0)
        min_value = user_settings.get('min_value_percent', 10.0)
        
        if value_pct < min_value:
            return False
        
        # Check minimum stake (if stake calculated)
        if 'recommended_stake' in arb_data:
            min_stake = user_settings.get('min_stake', 5.0)
            if arb_data['recommended_stake'] < min_stake:
                return False
        
        return True

Alert Formatting

# From horse_alert_formatter.py:50-120
class HorseAlertFormatter:
    """
    Format horse racing alerts for Telegram.
    """
    
    @staticmethod
    def format_alert(arb_data: dict, user_settings: dict):
        """
        Format horse racing alert with stake recommendation.
        
        Returns:
        - alert_text: Formatted message
        - reply_markup: Inline keyboard
        - stake_data: Stake calculation details
        """
        horse_name = arb_data.get('horse_name', 'Unknown')
        location = arb_data.get('location', 'Unknown')
        race_time = arb_data.get('race_time', '')
        market_type = arb_data.get('market_type', 'WIN')
        bookmaker_odds = arb_data.get('bookmaker_odds')
        fair_odds = arb_data.get('fair_odds')
        value_pct = arb_data.get('value_percent', 0)
        
        # Format race time
        try:
            race_dt = datetime.fromisoformat(race_time.replace('Z', '+00:00'))
            time_str = race_dt.strftime('%H:%M')
            date_str = race_dt.strftime('%a %d %b')
        except:
            time_str = 'Unknown'
            date_str = 'Unknown'
        
        # Calculate stake
        bankroll = user_settings.get('bankroll', 1000.0)
        kelly_fraction = user_settings.get('kelly_fraction', 0.25)
        
        stake = calculate_kelly_stake(
            fair_odds, bookmaker_odds, bankroll, kelly_fraction
        )
        
        # Apply adjustment if multiple horses
        adjustment = arb_data.get('adjusted_stake_multiplier', 1.0)
        adjusted_stake = stake * adjustment
        
        # Build message
        message = f"🐎 **{market_type} VALUE BET**\n\n"
        message += f"🏇 **{horse_name}**\n"
        message += f"📍 {location} - {time_str}\n"
        message += f"📅 {date_str}\n\n"
        
        message += f"📊 **Odds:** {bookmaker_odds:.2f}\n"
        message += f"⚖️ **Fair Value:** {fair_odds:.2f}\n"
        message += f"📈 **Value:** {value_pct:.1f}%\n\n"
        
        message += f"💰 **Recommended Stake:**\n"
        
        if adjustment < 1.0:
            message += f"   Base: £{stake:.2f}\n"
            message += f"   Adjusted (×{adjustment:.2f}): £{adjusted_stake:.2f}\n"
            message += f"   _(Multiple horses in race)_\n"
        else:
            message += f"   £{adjusted_stake:.2f}\n"
        
        potential_profit = adjusted_stake * (bookmaker_odds - 1)
        message += f"   Potential Profit: £{potential_profit:.2f}\n"
        
        # Add bookmaker link button
        keyboard = [
            [InlineKeyboardButton(
                f"🔗 Bet on {arb_data.get('bookmaker', 'Bookmaker')}",
                url=arb_data.get('bookmaker_link', '#')
            )]
        ]
        
        stake_data = {
            'base_stake': stake,
            'adjusted_stake': adjusted_stake,
            'adjustment_factor': adjustment,
            'bankroll': bankroll,
            'kelly_fraction': kelly_fraction
        }
        
        return message, InlineKeyboardMarkup(keyboard), stake_data

Database Collections

  • horse_arbs: Current horse racing value opportunities
  • sent_horse_alerts: Alert deduplication with horse/race tracking
  • horse_user_settings: User configurations
  • horse_results: Race results for performance tracking

Technical Architecture

PropprHorseBot/
├── core/
│   ├── bot/
│   │   └── horse_bot.py           # Main bot logic
│   ├── filters/
│   │   ├── filters.py             # Value filtering
│   │   └── race_filters.py        # Race-level filters
│   ├── horse_alert_formatter.py  # Alert formatting
│   ├── kelly_stake_calculator.py # Kelly Criterion
│   └── pipeline/
│       ├── eod_pipeline.py        # End-of-day reporting
│       └── eod_summary.py         # Daily summaries
├── services/
│   ├── database/
│   │   └── connection.py          # MongoDB wrapper
│   └── telegram/
│       └── commands/
│           └── bot_commands.py      # Command handlers
└── config/
    └── constants.py                # Configuration

Beta Access

# From horse_bot.py:198-216
def _is_user_in_beta_chat(self, user_id: int) -> bool:
    """
    Check if user is member of beta chat.
    Required for access during beta period.
    """
    if BETA_CHAT_ID is None:
        return True  # No restriction if no beta chat configured
    
    try:
        member = self.bot.get_chat_member(BETA_CHAT_ID, user_id)
        is_member = member.status in ['member', 'administrator', 'creator']
        
        if not is_member:
            logger.info(f"User {user_id} not in beta chat (status: {member.status})")
        
        return is_member
    except TelegramError as e:
        logger.warning(f"Error checking beta chat for user {user_id}: {e}")
        return False  # Fail closed

Source Code

View the complete Horse Bot implementation

EV Bot

Sharp odds-based value detection

Arb Bot

Arbitrage betting opportunities

Build docs developers (and LLMs) love