nt_execution/
odds_api.rs

1// The Odds API integration for sports betting
2//
3// Features:
4// - Real-time odds from 40+ bookmakers
5// - Pre-match and live odds
6// - Multiple sports (football, basketball, baseball, etc.)
7// - Historical odds data
8
9use chrono::{DateTime, Utc};
10use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
11use reqwest::Client;
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::num::NonZeroU32;
16use std::time::Duration;
17use tracing::{debug, error};
18
19/// The Odds API configuration
20#[derive(Debug, Clone)]
21pub struct OddsAPIConfig {
22    /// API key from the-odds-api.com
23    pub api_key: String,
24    /// Request timeout
25    pub timeout: Duration,
26}
27
28impl Default for OddsAPIConfig {
29    fn default() -> Self {
30        Self {
31            api_key: String::new(),
32            timeout: Duration::from_secs(30),
33        }
34    }
35}
36
37/// The Odds API client for sports betting
38pub struct OddsAPIClient {
39    client: Client,
40    config: OddsAPIConfig,
41    base_url: String,
42    rate_limiter: DefaultDirectRateLimiter,
43}
44
45impl OddsAPIClient {
46    /// Create a new Odds API client
47    pub fn new(config: OddsAPIConfig) -> Self {
48        let client = Client::builder()
49            .timeout(config.timeout)
50            .build()
51            .expect("Failed to create HTTP client");
52
53        // Free tier: 500 requests per month
54        let quota = Quota::per_hour(NonZeroU32::new(20).unwrap());
55        let rate_limiter = RateLimiter::direct(quota);
56
57        Self {
58            client,
59            config,
60            base_url: "https://api.the-odds-api.com/v4".to_string(),
61            rate_limiter,
62        }
63    }
64
65    /// Get available sports
66    pub async fn get_sports(&self) -> Result<Vec<Sport>, OddsAPIError> {
67        self.rate_limiter.until_ready().await;
68
69        let url = format!("{}/sports", self.base_url);
70        let params = [("apiKey", &self.config.api_key)];
71
72        debug!("Odds API: fetching sports");
73
74        let response = self
75            .client
76            .get(&url)
77            .query(&params)
78            .send()
79            .await?;
80
81        if response.status().is_success() {
82            let sports = response.json().await?;
83            Ok(sports)
84        } else {
85            let error_text = response.text().await.unwrap_or_default();
86            error!("Odds API error: {}", error_text);
87            Err(OddsAPIError::ApiError(error_text))
88        }
89    }
90
91    /// Get odds for a specific sport
92    pub async fn get_odds(
93        &self,
94        sport_key: &str,
95        regions: Vec<&str>, // us, uk, eu, au
96        markets: Vec<&str>, // h2h (moneyline), spreads, totals
97        odds_format: &str,  // decimal, american
98    ) -> Result<Vec<Event>, OddsAPIError> {
99        self.rate_limiter.until_ready().await;
100
101        let url = format!("{}/sports/{}/odds", self.base_url, sport_key);
102        let params = [
103            ("apiKey", self.config.api_key.as_str()),
104            ("regions", &regions.join(",")),
105            ("markets", &markets.join(",")),
106            ("oddsFormat", odds_format),
107        ];
108
109        debug!("Odds API: fetching odds for {}", sport_key);
110
111        let response = self
112            .client
113            .get(&url)
114            .query(&params)
115            .send()
116            .await?;
117
118        if response.status().is_success() {
119            let events = response.json().await?;
120            Ok(events)
121        } else {
122            let error_text = response.text().await.unwrap_or_default();
123            Err(OddsAPIError::ApiError(error_text))
124        }
125    }
126
127    /// Get historical odds
128    pub async fn get_historical_odds(
129        &self,
130        sport_key: &str,
131        event_id: &str,
132        regions: Vec<&str>,
133        markets: Vec<&str>,
134        date: DateTime<Utc>,
135    ) -> Result<Event, OddsAPIError> {
136        self.rate_limiter.until_ready().await;
137
138        let url = format!(
139            "{}/sports/{}/events/{}/odds",
140            self.base_url, sport_key, event_id
141        );
142
143        let params = [
144            ("apiKey", self.config.api_key.as_str()),
145            ("regions", &regions.join(",")),
146            ("markets", &markets.join(",")),
147            ("date", &date.to_rfc3339()),
148        ];
149
150        let response = self
151            .client
152            .get(&url)
153            .query(&params)
154            .send()
155            .await?;
156
157        if response.status().is_success() {
158            let event = response.json().await?;
159            Ok(event)
160        } else {
161            let error_text = response.text().await.unwrap_or_default();
162            Err(OddsAPIError::ApiError(error_text))
163        }
164    }
165
166    /// Get event scores (for completed games)
167    pub async fn get_scores(
168        &self,
169        sport_key: &str,
170        days_from: u32, // Number of days in the past
171    ) -> Result<Vec<EventScore>, OddsAPIError> {
172        self.rate_limiter.until_ready().await;
173
174        let url = format!("{}/sports/{}/scores", self.base_url, sport_key);
175        let params = [
176            ("apiKey", self.config.api_key.as_str()),
177            ("daysFrom", &days_from.to_string()),
178        ];
179
180        let response = self
181            .client
182            .get(&url)
183            .query(&params)
184            .send()
185            .await?;
186
187        if response.status().is_success() {
188            let scores = response.json().await?;
189            Ok(scores)
190        } else {
191            let error_text = response.text().await.unwrap_or_default();
192            Err(OddsAPIError::ApiError(error_text))
193        }
194    }
195
196    /// Find arbitrage opportunities
197    pub async fn find_arbitrage(
198        &self,
199        sport_key: &str,
200        regions: Vec<&str>,
201    ) -> Result<Vec<ArbitrageOpportunity>, OddsAPIError> {
202        let events = self.get_odds(sport_key, regions, vec!["h2h"], "decimal").await?;
203
204        let mut opportunities = Vec::new();
205
206        for event in events {
207            for bookmaker in &event.bookmakers {
208                for market in &bookmaker.markets {
209                    if market.key == "h2h" && market.outcomes.len() == 2 {
210                        let outcome1 = &market.outcomes[0];
211                        let outcome2 = &market.outcomes[1];
212
213                        // Calculate arbitrage
214                        let implied1 = Decimal::ONE / outcome1.price;
215                        let implied2 = Decimal::ONE / outcome2.price;
216                        let total_implied = implied1 + implied2;
217
218                        if total_implied < Decimal::ONE {
219                            let profit_margin = (Decimal::ONE - total_implied) * Decimal::from(100);
220
221                            opportunities.push(ArbitrageOpportunity {
222                                event_id: event.id.clone(),
223                                home_team: event.home_team.clone(),
224                                away_team: event.away_team.clone(),
225                                bookmaker: bookmaker.title.clone(),
226                                outcome1: outcome1.name.clone(),
227                                odds1: outcome1.price,
228                                outcome2: outcome2.name.clone(),
229                                odds2: outcome2.price,
230                                profit_margin,
231                            });
232                        }
233                    }
234                }
235            }
236        }
237
238        Ok(opportunities)
239    }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Sport {
244    pub key: String,
245    pub group: String,
246    pub title: String,
247    pub description: String,
248    pub active: bool,
249    pub has_outrights: bool,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct Event {
254    pub id: String,
255    pub sport_key: String,
256    pub sport_title: String,
257    pub commence_time: String,
258    pub home_team: String,
259    pub away_team: String,
260    pub bookmakers: Vec<Bookmaker>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Bookmaker {
265    pub key: String,
266    pub title: String,
267    pub last_update: String,
268    pub markets: Vec<Market>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct Market {
273    pub key: String, // h2h, spreads, totals
274    pub last_update: String,
275    pub outcomes: Vec<Outcome>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct Outcome {
280    pub name: String,
281    pub price: Decimal,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub point: Option<Decimal>, // For spreads/totals
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct EventScore {
288    pub id: String,
289    pub sport_key: String,
290    pub commence_time: String,
291    pub completed: bool,
292    pub home_team: String,
293    pub away_team: String,
294    pub scores: Option<Vec<TeamScore>>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct TeamScore {
299    pub name: String,
300    pub score: String,
301}
302
303#[derive(Debug, Clone, Serialize)]
304pub struct ArbitrageOpportunity {
305    pub event_id: String,
306    pub home_team: String,
307    pub away_team: String,
308    pub bookmaker: String,
309    pub outcome1: String,
310    pub odds1: Decimal,
311    pub outcome2: String,
312    pub odds2: Decimal,
313    pub profit_margin: Decimal,
314}
315
316#[derive(Debug, thiserror::Error)]
317pub enum OddsAPIError {
318    #[error("API error: {0}")]
319    ApiError(String),
320
321    #[error("Network error: {0}")]
322    Network(#[from] reqwest::Error),
323
324    #[error("Parse error: {0}")]
325    Parse(#[from] serde_json::Error),
326
327    #[error("Rate limit exceeded")]
328    RateLimit,
329
330    #[error(transparent)]
331    Other(#[from] anyhow::Error),
332}