1use 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#[derive(Debug, Clone)]
21pub struct OddsAPIConfig {
22 pub api_key: String,
24 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
37pub struct OddsAPIClient {
39 client: Client,
40 config: OddsAPIConfig,
41 base_url: String,
42 rate_limiter: DefaultDirectRateLimiter,
43}
44
45impl OddsAPIClient {
46 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 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 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(¶ms)
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 pub async fn get_odds(
93 &self,
94 sport_key: &str,
95 regions: Vec<&str>, markets: Vec<&str>, odds_format: &str, ) -> 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", ®ions.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(¶ms)
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 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", ®ions.join(",")),
146 ("markets", &markets.join(",")),
147 ("date", &date.to_rfc3339()),
148 ];
149
150 let response = self
151 .client
152 .get(&url)
153 .query(¶ms)
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 pub async fn get_scores(
168 &self,
169 sport_key: &str,
170 days_from: u32, ) -> 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(¶ms)
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 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 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, 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>, }
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}