diff --git a/brumby-soccer/src/bin/soc_prices.rs b/brumby-soccer/src/bin/soc_prices.rs index fb99527..db3af3a 100644 --- a/brumby-soccer/src/bin/soc_prices.rs +++ b/brumby-soccer/src/bin/soc_prices.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::env; use std::error::Error; use std::fs::File; @@ -14,6 +14,7 @@ use stanza::renderer::Renderer; use stanza::style::Styles; use stanza::table::{Cell, Content, Row, Table}; use tracing::{debug, info, warn}; +use brumby::derived_price::DerivedPrice; use brumby::hash_lookup::HashLookup; use brumby::market::{Market, OverroundMethod, PriceBounds}; @@ -30,6 +31,7 @@ use brumby_soccer::model::score_fitter::ScoreFitter; const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; const SINGLE_PRICE_BOUNDS: PriceBounds = 1.001..=301.0; +const MULTI_PRICE_BOUNDS: PriceBounds = 1.001..=1001.0; const INTERVALS: u8 = 18; const INCREMENTAL_OVERROUND: f64 = 0.01; const MAX_TOTAL_GOALS: u16 = 8; @@ -277,24 +279,16 @@ async fn main() -> Result<(), Box> { // let relatedness = // compute_relatedness_coefficient(&selections, model.offers(), derivation.probability); let scaling_exponent = compute_scaling_exponent(derivation.relatedness); - let scaled_price = derivation.quotation.price - / derivation - .quotation - .overround() - .powf(scaling_exponent - 1.0); + let scaled_price = scale_price(&derivation.quotation, scaling_exponent); info!("selections: {selections:?}, quotation: {:?}, overround: {:.3}, relatedness: {:.3}, redundancies: {:?}, scaling_exponent: {scaling_exponent:?}, scaled_price: {scaled_price:.3}, took: {elapsed:?}", derivation.quotation, derivation.quotation.overround(), derivation.relatedness, derivation.redundancies); let mut total_fringes = 0; let mut unattainable_fringes = 0; - for (offer, fringe_vec) in derivation.fringes { - info!("fringe offer: {offer:?}"); + for (offer, fringe_vec) in derivation.fringes.into_iter().collect::>() { + info!("\nfringe offer: {offer:?}"); for fringe in fringe_vec { let scaling_exponent = compute_scaling_exponent(fringe.relatedness); - let scaled_price = fringe.quotation.price - / fringe - .quotation - .overround() - .powf(scaling_exponent - 1.0); + let scaled_price = scale_price(&fringe.quotation, scaling_exponent); total_fringes += 1; if fringe.quotation.probability == 0.0 { unattainable_fringes += 1; @@ -307,28 +301,17 @@ async fn main() -> Result<(), Box> { Ok(()) } -// fn compute_unrelated_probability( -// selections: &[(OfferType, Outcome)], -// offers: &FxHashMap, -// ) -> f64 { -// selections -// .iter() -// .map(|(offer_type, outcome)| { -// let offer = offers.get(offer_type).unwrap(); -// let outcome_index = offer.outcomes.index_of(outcome).unwrap(); -// offer.market.probs[outcome_index] -// }) -// .product() -// } - -// fn compute_relatedness_coefficient( -// selections: &[(OfferType, Outcome)], -// offers: &FxHashMap, -// related_prob: f64, -// ) -> f64 { -// let unrelated_prob = compute_unrelated_probability(selections, offers); -// unrelated_prob / related_prob -// } +fn scale_price(quotation: &DerivedPrice, scaling_exponent: f64) -> f64 { + if quotation.price.is_finite() { + let scaled_price = quotation.price + / quotation + .overround() + .powf(scaling_exponent - 1.0); + f64::max(*MULTI_PRICE_BOUNDS.start(), f64::min(scaled_price, *MULTI_PRICE_BOUNDS.end())) + } else { + quotation.price + } +} fn compute_scaling_exponent(relatedness: f64) -> f64 { 0.5 * f64::log10(100.0 * relatedness) diff --git a/brumby-soccer/src/interval/query.rs b/brumby-soccer/src/interval/query.rs index b3b450f..7b3d8ef 100644 --- a/brumby-soccer/src/interval/query.rs +++ b/brumby-soccer/src/interval/query.rs @@ -5,16 +5,14 @@ use crate::interval::{Expansions, Prospect, Prospects}; mod anytime_assist; mod anytime_goalscorer; -mod asian_handicap; mod correct_score; mod first_goalscorer; -mod head_to_head; +mod win_draw; mod total_goals; #[derive(Debug)] pub enum QuerySpec { - None, - PassThrough(OfferType, Outcome), + Stateless, PlayerLookup(usize), NoFirstGoalscorer, NoAnytimeGoalscorer, @@ -24,10 +22,10 @@ pub enum QuerySpec { #[must_use] pub fn requirements(offer_type: &OfferType) -> Expansions { match offer_type { - OfferType::HeadToHead(period, _) => head_to_head::requirements(period), + OfferType::HeadToHead(period, _) => win_draw::requirements(period), OfferType::TotalGoals(period, _) => total_goals::requirements(period), OfferType::CorrectScore(period) => correct_score::requirements(period), - OfferType::AsianHandicap(period, _) => asian_handicap::requirements(period), + OfferType::AsianHandicap(period, _) => win_draw::requirements(period), OfferType::DrawNoBet(_) => unimplemented!(), OfferType::FirstGoalscorer => first_goalscorer::requirements(), OfferType::AnytimeGoalscorer => anytime_goalscorer::requirements(), @@ -43,10 +41,10 @@ pub fn prepare( player_lookup: &HashLookup, ) -> QuerySpec { match offer_type { - OfferType::HeadToHead(_, _) => head_to_head::prepare(offer_type, outcome), - OfferType::TotalGoals(_, _) => total_goals::prepare(offer_type, outcome), - OfferType::CorrectScore(_) => correct_score::prepare(offer_type, outcome), - OfferType::AsianHandicap(_, _) => asian_handicap::prepare(offer_type, outcome), + OfferType::HeadToHead(_, _) => win_draw::prepare(), + OfferType::TotalGoals(_, _) => total_goals::prepare(), + OfferType::CorrectScore(_) => correct_score::prepare(), + OfferType::AsianHandicap(_, _) => win_draw::prepare(), OfferType::DrawNoBet(_) => unimplemented!(), OfferType::FirstGoalscorer => first_goalscorer::prepare(outcome, player_lookup), OfferType::AnytimeGoalscorer => anytime_goalscorer::prepare(outcome, player_lookup), @@ -56,12 +54,12 @@ pub fn prepare( } #[must_use] -pub fn filter(offer_type: &OfferType, query: &QuerySpec, prospect: &Prospect) -> bool { +pub fn filter(offer_type: &OfferType, outcome: &Outcome, query: &QuerySpec, prospect: &Prospect) -> bool { match offer_type { - OfferType::HeadToHead(_, _) => head_to_head::filter(query, prospect), - OfferType::TotalGoals(_, _) => total_goals::filter(query, prospect), - OfferType::CorrectScore(_) => correct_score::filter(query, prospect), - OfferType::AsianHandicap(_, _) => asian_handicap::filter(query, prospect), + OfferType::HeadToHead(period, _) => win_draw::filter(period, outcome, prospect), + OfferType::TotalGoals(period, _) => total_goals::filter(period, outcome, prospect), + OfferType::CorrectScore(period) => correct_score::filter(period, outcome, prospect), + OfferType::AsianHandicap(period, _) => win_draw::filter(period, outcome, prospect), OfferType::DrawNoBet(_) => unimplemented!(), OfferType::AnytimeGoalscorer => anytime_goalscorer::filter(query, prospect), OfferType::FirstGoalscorer => first_goalscorer::filter(query, prospect), @@ -80,7 +78,7 @@ pub fn isolate( let query = prepare(offer_type, outcome, player_lookup); prospects .iter() - .filter(|(prospect, _)| filter(offer_type, &query, prospect)) + .filter(|(prospect, _)| filter(offer_type, outcome, &query, prospect)) .map(|(_, prob)| prob) .sum() } @@ -94,7 +92,7 @@ pub fn isolate_set( let queries = selections .iter() .map(|(offer_type, outcome)| { - (offer_type, prepare(offer_type, outcome, player_lookup)) + (offer_type, outcome, prepare(offer_type, outcome, player_lookup)) }) .collect::>(); prospects @@ -102,7 +100,7 @@ pub fn isolate_set( .filter(|(prospect, _)| { !queries .iter() - .any(|(offer_type, query)| !filter(offer_type, query, prospect)) + .any(|(offer_type, outcome, query)| !filter(offer_type, outcome, query, prospect)) }) .map(|(_, prospect_prob)| prospect_prob) .sum() diff --git a/brumby-soccer/src/interval/query/asian_handicap.rs b/brumby-soccer/src/interval/query/asian_handicap.rs deleted file mode 100644 index 08a763d..0000000 --- a/brumby-soccer/src/interval/query/asian_handicap.rs +++ /dev/null @@ -1,183 +0,0 @@ -use super::*; -use crate::domain::{Period, Side, WinHandicap}; - -#[inline] -#[must_use] -pub(crate) fn requirements(period: &Period) -> Expansions { - match period { - Period::FirstHalf => Expansions { - ht_score: true, - ft_score: false, - max_player_goals: 0, - player_split_goal_stats: false, - max_player_assists: 0, - first_goalscorer: false, - }, - Period::SecondHalf => Expansions { - ht_score: true, - ft_score: true, - max_player_goals: 0, - player_split_goal_stats: false, - max_player_assists: 0, - first_goalscorer: false, - }, - Period::FullTime => Expansions { - ht_score: false, - ft_score: true, - max_player_goals: 0, - player_split_goal_stats: false, - max_player_assists: 0, - first_goalscorer: false, - }, - } -} - -#[inline] -#[must_use] -pub(crate) fn prepare(offer_type: &OfferType, outcome: &Outcome) -> QuerySpec { - QuerySpec::PassThrough(offer_type.clone(), outcome.clone()) -} - -#[inline] -#[must_use] -pub(crate) fn filter(query: &QuerySpec, prospect: &Prospect) -> bool { - match query { - QuerySpec::PassThrough(OfferType::AsianHandicap(period, _), outcome) => { - let (home_goals, away_goals) = match period { - Period::FirstHalf => (prospect.ht_score.home, prospect.ht_score.away), - Period::SecondHalf => { - let h2_score = prospect.h2_score(); - (h2_score.home, h2_score.away) - } - Period::FullTime => (prospect.ft_score.home, prospect.ft_score.away), - }; - - match outcome { - Outcome::Win(Side::Home, win_handicap) => match win_handicap { - WinHandicap::AheadOver(by) => home_goals.saturating_sub(away_goals) > *by, - WinHandicap::BehindUnder(by) => { - home_goals > away_goals || away_goals - home_goals < *by - } - }, - Outcome::Win(Side::Away, win_handicap) => match win_handicap { - WinHandicap::AheadOver(by) => away_goals.saturating_sub(home_goals) > *by, - WinHandicap::BehindUnder(by) => { - away_goals > home_goals || home_goals - away_goals < *by - } - }, - _ => panic!("{outcome:?} unsupported"), - } - } - _ => panic!("{query:?} unsupported"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::Score; - use crate::interval::Exploration; - use assert_float_eq::*; - use brumby::linear::matrix::Matrix; - - fn create_test_4x4_exploration() -> Exploration { - let mut scoregrid = Matrix::allocate(4, 4); - scoregrid[0].copy_from_slice(&[0.04, 0.03, 0.02, 0.01]); - scoregrid[1].copy_from_slice(&[0.08, 0.06, 0.04, 0.02]); - scoregrid[2].copy_from_slice(&[0.12, 0.09, 0.06, 0.03]); - scoregrid[3].copy_from_slice(&[0.16, 0.12, 0.08, 0.04]); - - let mut prospects = Prospects::default(); - for home_goals in 0..scoregrid.rows() { - for away_goals in 0..scoregrid.cols() { - let prob = scoregrid[(home_goals, away_goals)]; - prospects.insert( - Prospect { - ht_score: Score::nil_all(), - ft_score: Score { - home: home_goals as u8, - away: away_goals as u8, - }, - stats: Default::default(), - first_scorer: None, - }, - prob, - ); - } - } - Exploration { - player_lookup: HashLookup::default(), - prospects, - pruned: 0.0, - } - } - - #[test] - pub fn win_gather() { - let exploration = create_test_4x4_exploration(); - assert_float_absolute_eq!( - 0.65, - isolate( - &OfferType::AsianHandicap(Period::FullTime, WinHandicap::AheadOver(0)), - &Outcome::Win(Side::Home, WinHandicap::AheadOver(0)), - &exploration.prospects, - &exploration.player_lookup - ) - ); - assert_float_absolute_eq!( - 0.15, - isolate( - &OfferType::AsianHandicap(Period::FullTime, WinHandicap::AheadOver(0)), - &Outcome::Win(Side::Away, WinHandicap::BehindUnder(0)), - &exploration.prospects, - &exploration.player_lookup - ) - ); - } - - #[test] - pub fn win_ahead_1_gather() { - let exploration = create_test_4x4_exploration(); - assert_float_absolute_eq!( - 0.4, - isolate( - &OfferType::AsianHandicap(Period::FullTime, WinHandicap::AheadOver(1)), - &Outcome::Win(Side::Home, WinHandicap::AheadOver(1)), - &exploration.prospects, - &exploration.player_lookup - ) - ); - assert_float_absolute_eq!( - 0.35, - isolate( - &OfferType::AsianHandicap(Period::FullTime, WinHandicap::AheadOver(1)), - &Outcome::Win(Side::Away, WinHandicap::BehindUnder(1)), - &exploration.prospects, - &exploration.player_lookup - ) - ); - } - - #[test] - pub fn win_behind_1_gather() { - let exploration = create_test_4x4_exploration(); - assert_float_absolute_eq!( - 0.85, - isolate( - &OfferType::AsianHandicap(Period::FullTime, WinHandicap::BehindUnder(1)), - &Outcome::Win(Side::Home, WinHandicap::BehindUnder(1)), - &exploration.prospects, - &exploration.player_lookup - ) - ); - assert_float_absolute_eq!( - 0.05, - isolate( - &OfferType::AsianHandicap(Period::FullTime, WinHandicap::BehindUnder(1)), - &Outcome::Win(Side::Away, WinHandicap::AheadOver(1)), - &exploration.prospects, - &exploration.player_lookup - ) - ); - } -} diff --git a/brumby-soccer/src/interval/query/correct_score.rs b/brumby-soccer/src/interval/query/correct_score.rs index f5e6872..77b7581 100644 --- a/brumby-soccer/src/interval/query/correct_score.rs +++ b/brumby-soccer/src/interval/query/correct_score.rs @@ -34,15 +34,15 @@ pub(crate) fn requirements(period: &Period) -> Expansions { #[inline] #[must_use] -pub(crate) fn prepare(offer_type: &OfferType, outcome: &Outcome) -> QuerySpec { - QuerySpec::PassThrough(offer_type.clone(), outcome.clone()) +pub(crate) fn prepare() -> QuerySpec { + QuerySpec::Stateless } #[inline] #[must_use] -pub(crate) fn filter(query: &QuerySpec, prospect: &Prospect) -> bool { - match query { - QuerySpec::PassThrough(OfferType::CorrectScore(period), Outcome::Score(score)) => { +pub(crate) fn filter(period: &Period, outcome: &Outcome, prospect: &Prospect) -> bool { + match outcome { + Outcome::Score(score) => { let (home_goals, away_goals) = match period { Period::FirstHalf => (prospect.ht_score.home, prospect.ht_score.away), Period::SecondHalf => { let h2_score = prospect.h2_score(); (h2_score.home, h2_score.away) }, @@ -50,6 +50,6 @@ pub(crate) fn filter(query: &QuerySpec, prospect: &Prospect) -> bool { }; score.home == home_goals && score.away == away_goals } - _ => panic!("{query:?} unsupported"), + _ => panic!("{outcome:?} unsupported"), } } \ No newline at end of file diff --git a/brumby-soccer/src/interval/query/total_goals.rs b/brumby-soccer/src/interval/query/total_goals.rs index ddd140c..6e21221 100644 --- a/brumby-soccer/src/interval/query/total_goals.rs +++ b/brumby-soccer/src/interval/query/total_goals.rs @@ -34,27 +34,22 @@ pub(crate) fn requirements(period: &Period) -> Expansions { #[inline] #[must_use] -pub(crate) fn prepare(offer_type: &OfferType, outcome: &Outcome) -> QuerySpec { - QuerySpec::PassThrough(offer_type.clone(), outcome.clone()) +pub(crate) fn prepare() -> QuerySpec { + QuerySpec::Stateless } #[inline] #[must_use] -pub(crate) fn filter(query: &QuerySpec, prospect: &Prospect) -> bool { - match query { - QuerySpec::PassThrough(OfferType::TotalGoals(period, _), outcome) => { - let (home_goals, away_goals) = match period { - Period::FirstHalf => (prospect.ht_score.home, prospect.ht_score.away), - Period::SecondHalf => { let h2_score = prospect.h2_score(); (h2_score.home, h2_score.away) }, - Period::FullTime => (prospect.ft_score.home, prospect.ft_score.away), - }; +pub(crate) fn filter(period: &Period, outcome: &Outcome, prospect: &Prospect) -> bool { + let (home_goals, away_goals) = match period { + Period::FirstHalf => (prospect.ht_score.home, prospect.ht_score.away), + Period::SecondHalf => { let h2_score = prospect.h2_score(); (h2_score.home, h2_score.away) }, + Period::FullTime => (prospect.ft_score.home, prospect.ft_score.away), + }; - match outcome { - Outcome::Over(limit) => home_goals + away_goals > *limit, - Outcome::Under(limit) => away_goals + home_goals < *limit, - _ => panic!("{outcome:?} unsupported"), - } - } - _ => panic!("{query:?} unsupported"), + match outcome { + Outcome::Over(limit) => home_goals + away_goals > *limit, + Outcome::Under(limit) => away_goals + home_goals < *limit, + _ => panic!("{outcome:?} unsupported"), } } \ No newline at end of file diff --git a/brumby-soccer/src/interval/query/head_to_head.rs b/brumby-soccer/src/interval/query/win_draw.rs similarity index 75% rename from brumby-soccer/src/interval/query/head_to_head.rs rename to brumby-soccer/src/interval/query/win_draw.rs index b5e5154..c6b1dfd 100644 --- a/brumby-soccer/src/interval/query/head_to_head.rs +++ b/brumby-soccer/src/interval/query/win_draw.rs @@ -1,3 +1,6 @@ +//! Handling of handicaps with win and draw outcomes, common to `HeadToHead` and `AsianHandicap` +//! offer types. + use super::*; use crate::domain::{DrawHandicap, Period, Side, WinHandicap}; @@ -34,49 +37,44 @@ pub(crate) fn requirements(period: &Period) -> Expansions { #[inline] #[must_use] -pub(crate) fn prepare(offer_type: &OfferType, outcome: &Outcome) -> QuerySpec { - QuerySpec::PassThrough(offer_type.clone(), outcome.clone()) +pub(crate) fn prepare() -> QuerySpec { + QuerySpec::Stateless } #[inline] #[must_use] -pub(crate) fn filter(query: &QuerySpec, prospect: &Prospect) -> bool { - match query { - QuerySpec::PassThrough(OfferType::HeadToHead(period, _), outcome) => { - let (home_goals, away_goals) = match period { - Period::FirstHalf => (prospect.ht_score.home, prospect.ht_score.away), - Period::SecondHalf => { - let h2_score = prospect.h2_score(); - (h2_score.home, h2_score.away) - } - Period::FullTime => (prospect.ft_score.home, prospect.ft_score.away), - }; +pub(crate) fn filter(period: &Period, outcome: &Outcome, prospect: &Prospect) -> bool { + let (home_goals, away_goals) = match period { + Period::FirstHalf => (prospect.ht_score.home, prospect.ht_score.away), + Period::SecondHalf => { + let h2_score = prospect.h2_score(); + (h2_score.home, h2_score.away) + } + Period::FullTime => (prospect.ft_score.home, prospect.ft_score.away), + }; - match outcome { - Outcome::Win(Side::Home, win_handicap) => match win_handicap { - WinHandicap::AheadOver(by) => home_goals.saturating_sub(away_goals) > *by, - WinHandicap::BehindUnder(by) => { - home_goals > away_goals || away_goals - home_goals < *by - } - }, - Outcome::Win(Side::Away, win_handicap) => match win_handicap { - WinHandicap::AheadOver(by) => away_goals.saturating_sub(home_goals) > *by, - WinHandicap::BehindUnder(by) => { - away_goals > home_goals || home_goals - away_goals < *by - } - }, - Outcome::Draw(draw_handicap) => match draw_handicap { - DrawHandicap::Ahead(by) => { - home_goals >= away_goals && home_goals - away_goals == *by - } - DrawHandicap::Behind(by) => { - away_goals >= home_goals && away_goals - home_goals == *by - } - }, - _ => panic!("{outcome:?} unsupported"), + match outcome { + Outcome::Win(Side::Home, win_handicap) => match win_handicap { + WinHandicap::AheadOver(by) => home_goals.saturating_sub(away_goals) > *by, + WinHandicap::BehindUnder(by) => { + home_goals > away_goals || away_goals - home_goals < *by } - } - _ => panic!("{query:?} unsupported"), + }, + Outcome::Win(Side::Away, win_handicap) => match win_handicap { + WinHandicap::AheadOver(by) => away_goals.saturating_sub(home_goals) > *by, + WinHandicap::BehindUnder(by) => { + away_goals > home_goals || home_goals - away_goals < *by + } + }, + Outcome::Draw(draw_handicap) => match draw_handicap { + DrawHandicap::Ahead(by) => { + home_goals >= away_goals && home_goals - away_goals == *by + } + DrawHandicap::Behind(by) => { + away_goals >= home_goals && away_goals - home_goals == *by + } + }, + _ => panic!("{outcome:?} unsupported"), } } diff --git a/brumby-soccer/src/model.rs b/brumby-soccer/src/model.rs index 3576da5..8937328 100644 --- a/brumby-soccer/src/model.rs +++ b/brumby-soccer/src/model.rs @@ -337,7 +337,7 @@ impl Model { market, } } else { - // let LOG = stub.offer_type == TotalGoals(Period::FirstHalf, Over(4)); //TODO + // let LOG = stub.offer_type == OfferType::HeadToHead(Period::FullTime, DrawHandicap::Ahead(0)); //TODO // if LOG { // trace!("reqs: {reqs:?}"); // } @@ -499,6 +499,7 @@ impl Model { let mut fringes_vec = Vec::with_capacity(offer.outcomes.len()); for (outcome_index, outcome) in offer.outcomes.items().iter().enumerate() { // let LOG = offer_type == &TotalGoals(Period::FirstHalf, Over(4)) && outcome == &Outcome::Over(4); + // let LOG = offer_type == &OfferType::HeadToHead(Period::FullTime, DrawHandicap::Ahead(0)) && outcome == &Outcome::Win(Side::Home, WinHandicap::AheadOver(0)); let single_prob = offer.market.probs[outcome_index]; if single_prob == 0.0 { @@ -589,7 +590,7 @@ impl Model { .collect::>(); let prob = query::isolate_set(&prefix, &exploration.prospects, &exploration.player_lookup); - // debug!("prefix: {prefix:?}, prob: {prob:.3}"); + // if LOG { trace!("fringe prefix: {prefix:?}, prob: {prob:.3}"); } let tail = &fringe_sorted_selections[end_index - 1]; if prob < fringe_lowest_prob { fringe_lowest_prob = prob;