From aaf6a40f6c5a227f9ea55fbc993e8dd2af254784 Mon Sep 17 00:00:00 2001 From: Emil Koutanov Date: Mon, 18 Dec 2023 19:02:41 +1100 Subject: [PATCH] ScoreFitter work --- brumby-soccer/src/bin/soc_prices.rs | 1078 +--------------- brumby-soccer/src/bin/soc_prices2.rs | 1132 +++++++++++++++++ brumby-soccer/src/data.rs | 5 +- brumby-soccer/src/domain.rs | 26 + brumby-soccer/src/domain/error.rs | 90 +- .../src/domain/error/head_to_head.rs | 86 +- brumby-soccer/src/domain/error/total_goals.rs | 67 +- brumby-soccer/src/fit.rs | 66 +- brumby-soccer/src/interval.rs | 2 +- brumby-soccer/src/model.rs | 94 +- brumby-soccer/src/model/score_fitter.rs | 93 +- brumby/src/hash_lookup.rs | 2 +- justfile | 3 + 13 files changed, 1576 insertions(+), 1168 deletions(-) create mode 100644 brumby-soccer/src/bin/soc_prices2.rs diff --git a/brumby-soccer/src/bin/soc_prices.rs b/brumby-soccer/src/bin/soc_prices.rs index cc03ea1..168b178 100644 --- a/brumby-soccer/src/bin/soc_prices.rs +++ b/brumby-soccer/src/bin/soc_prices.rs @@ -5,22 +5,16 @@ use std::path::PathBuf; use anyhow::bail; use clap::Parser; -use stanza::renderer::console::Console; -use stanza::renderer::Renderer; +use rustc_hash::FxHashMap; use tracing::{debug, info}; use brumby::hash_lookup::HashLookup; -use brumby::market::{Market, Overround, OverroundMethod, PriceBounds}; +use brumby::market::{Market, OverroundMethod, PriceBounds}; use brumby::probs::SliceExt; use brumby_soccer::data::{download_by_id, ContestSummary, SoccerFeedId}; -use brumby_soccer::domain::{Offer, OfferType, OutcomeType, Over, Period, Score}; -use brumby_soccer::fit::{away_booksum, home_booksum, ErrorType, FittingErrors}; -use brumby_soccer::interval::query::isolate; -use brumby_soccer::interval::{ - explore, BivariateProbs, Expansions, Exploration, IntervalConfig, PlayerProbs, PruneThresholds, - TeamProbs, UnivariateProbs, -}; -use brumby_soccer::{fit, print}; +use brumby_soccer::domain::{Offer, OfferType, OutcomeType, Over, Period}; +use brumby_soccer::model::{Model, score_fitter}; +use brumby_soccer::model::score_fitter::ScoreFitter; const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; const SINGLE_PRICE_BOUNDS: PriceBounds = 1.01..=301.0; @@ -61,6 +55,8 @@ impl Args { } } +const INCREMENTAL_OVERROUND: f64 = 0.01; + #[tokio::main] async fn main() -> Result<(), Box> { if env::var("RUST_BACKTRACE").is_err() { @@ -76,742 +72,41 @@ async fn main() -> Result<(), Box> { debug!("args: {args:?}"); let contest = read_contest_data(&args).await?; info!("contest.name: {}", contest.name); - for offer_type in contest.offerings.keys() { - info!("offered {offer_type:?}"); - } - - let ft_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::FullTime)].clone(); - let ft_goals_ou_prices = - contest.offerings[&OfferType::TotalGoals(Period::FullTime, Over(2))].clone(); - let ft_correct_score_prices = - contest.offerings[&OfferType::CorrectScore(Period::FullTime)].clone(); - let h1_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::FirstHalf)].clone(); - let h1_goals_ou_prices = - contest.offerings[&OfferType::TotalGoals(Period::FirstHalf, Over(2))].clone(); - let h2_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::SecondHalf)].clone(); - let h2_goals_ou_prices = - contest.offerings[&OfferType::TotalGoals(Period::SecondHalf, Over(2))].clone(); - let first_gs = contest.offerings[&OfferType::FirstGoalscorer].clone(); - let anytime_gs = contest.offerings[&OfferType::AnytimeGoalscorer].clone(); - let anytime_assist = contest.offerings[&OfferType::AnytimeAssist].clone(); - - let ft_h2h = fit_offer(OfferType::HeadToHead(Period::FullTime), &ft_h2h_prices, 1.0); - // println!("ft_h2h: {ft_h2h:?}"); - let ft_goals_ou = fit_offer( - OfferType::TotalGoals(Period::FullTime, Over(2)), - &ft_goals_ou_prices, - 1.0, - ); - // println!("ft_goals_ou: {ft_goals_ou:?}"); - let ft_correct_score = fit_offer( - OfferType::CorrectScore(Period::FullTime), - &ft_correct_score_prices, - 1.0, - ); - let h1_h2h = fit_offer( - OfferType::HeadToHead(Period::FirstHalf), - &h1_h2h_prices, - 1.0, - ); - let h1_goals_ou = fit_offer( - OfferType::TotalGoals(Period::FirstHalf, Over(2)), - &h1_goals_ou_prices, - 1.0, - ); - let h2_h2h = fit_offer( - OfferType::HeadToHead(Period::SecondHalf), - &h2_h2h_prices, - 1.0, - ); - let h2_goals_ou = fit_offer( - OfferType::TotalGoals(Period::SecondHalf, Over(2)), - &h2_goals_ou_prices, - 1.0, - ); - - println!("*** fitting H1 ***"); - let h1_search_outcome = fit::fit_scoregrid_half(&[&h1_h2h, &h1_goals_ou]); - println!("*** fitting H2 ***"); - let h2_search_outcome = fit::fit_scoregrid_half(&[&h2_h2h, &h2_goals_ou]); - let ft_search_outcome = fit::fit_scoregrid_full(&ft_h2h, &ft_goals_ou); - - let mut adj_optimal_h1 = [0.0; 3]; - let mut adj_optimal_h2 = [0.0; 3]; - // only adjust the home and away scoring probs; common prob is locked to the full-time one - for (i, orig_h1) in h1_search_outcome.optimal_values.iter().take(2).enumerate() { - let orig_h2 = h2_search_outcome.optimal_values[i]; - let ft = ft_search_outcome.optimal_values[i]; - let avg_h1_h2 = (orig_h1 + orig_h2) / 2.0; - if avg_h1_h2 > 0.0 { - adj_optimal_h1[i] = orig_h1 / (avg_h1_h2 / ft); - adj_optimal_h2[i] = orig_h2 / (avg_h1_h2 / ft); - } else { - adj_optimal_h1[i] = ft; - adj_optimal_h2[i] = ft; - } - } - adj_optimal_h1[2] = ft_search_outcome.optimal_values[2]; - adj_optimal_h2[2] = ft_search_outcome.optimal_values[2]; - println!("adjusted optimal_h1={adj_optimal_h1:?}, optimal_h2={adj_optimal_h2:?}"); - // let adj_optimal_h1 = h1_search_outcome.optimal_values; - // let adj_optimal_h2 = h2_search_outcome.optimal_values; - - // let ft_gamma_sum = ft_search_outcome.optimal_values.sum(); - // h1_search_outcome.optimal_values.normalise(ft_gamma_sum * 1.0); - // h2_search_outcome.optimal_values.normalise(ft_gamma_sum * 1.0); - - let exploration = explore_scores( - BivariateProbs::from(adj_optimal_h1.as_slice()), - BivariateProbs::from(adj_optimal_h2.as_slice()), - ); - - // let mut ft_scoregrid = allocate_scoregrid(MAX_TOTAL_GOALS_FULL); - // interval_scoregrid( - // 0..INTERVALS as u8, - // ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, - // ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, - // &mut ft_scoregrid, - // ); - // interval_scoregrid( - // 0..INTERVALS as u8, - // MAX_TOTAL_GOALS_FULL, - // ScoringProbs::from(adj_optimal_h1.as_slice()), - // ScoringProbs::from(adj_optimal_h2.as_slice()), - // &mut ft_scoregrid, - // ); - // correct_score_scoregrid(&ft_correct_score, &mut ft_scoregrid); - - // let mut h1_scoregrid = allocate_scoregrid(MAX_TOTAL_GOALS_HALF); - // interval_scoregrid( - // 0..(INTERVALS / 2) as u8, - // MAX_TOTAL_GOALS_HALF, - // ScoringProbs::from(adj_optimal_h1.as_slice()), - // ScoringProbs { - // home_prob: 0.0, - // away_prob: 0.0, - // common_prob: 0.0, - // }, - // &mut h1_scoregrid, - // ); - - // let fitted_h1_h2h = - // frame_prices_from_scoregrid(&h1_scoregrid, &h1_h2h.outcomes.items(), &h1_h2h.market.overround); - // let fitted_h1_h2h = Offer { - // offer_type: OfferType::HeadToHead(Period::FirstHalf), - // outcomes: h1_h2h.outcomes.clone(), - // market: fitted_h1_h2h, - // }; - let fitted_h1_h2h = frame_prices_from_exploration( - &exploration, - &OfferType::HeadToHead(Period::FirstHalf), - h1_h2h.outcomes.items(), - 1.0, - &h1_h2h.market.overround, - ); - let h1_h2h_table = print::tabulate_offer(&fitted_h1_h2h); - println!( - "{:?}: [Σ={:.3}]\n{}", - fitted_h1_h2h.offer_type, - fitted_h1_h2h.market.probs.sum(), - Console::default().render(&h1_h2h_table) - ); - - // let fitted_h1_goals_ou = frame_prices_from_scoregrid( - // &h1_scoregrid, - // &ft_goals_ou.outcomes, - // &ft_goals_ou.market.overround, - // ); - // let fitted_h1_goals_ou = Offer { - // offer_type: OfferType::TotalGoals(Period::FirstHalf, Over(2)), - // outcomes: h1_goals_ou.outcomes.clone(), - // market: fitted_h1_goals_ou, - // }; - let fitted_h1_goals_ou = frame_prices_from_exploration( - &exploration, - &OfferType::TotalGoals(Period::FirstHalf, Over(2)), - h1_goals_ou.outcomes.items(), - 1.0, - &h1_goals_ou.market.overround, - ); - let h1_goals_ou_table = print::tabulate_offer(&fitted_h1_goals_ou); - println!( - "{:?}: [Σ={:.3}]\n{}", - fitted_h1_goals_ou.offer_type, - fitted_h1_goals_ou.market.probs.sum(), - Console::default().render(&h1_goals_ou_table) - ); - - // let mut h2_scoregrid = allocate_scoregrid(MAX_TOTAL_GOALS_HALF); - // interval_scoregrid( - // 0..(INTERVALS / 2) as u8, - // ModelParams { home_prob: h2_search_outcome.optimal_values[0], away_prob: h2_search_outcome.optimal_values[1], common_prob: h2_search_outcome.optimal_values[2] }, - // ModelParams { home_prob: 0.0, away_prob: 0.0, common_prob: 0.0 }, - // &mut h2_scoregrid, - // ); - // interval_scoregrid( - // (INTERVALS / 2) as u8..INTERVALS as u8, - // MAX_TOTAL_GOALS_HALF, - // ScoringProbs { - // home_prob: 0.0, - // away_prob: 0.0, - // common_prob: 0.0, - // }, - // ScoringProbs::from(adj_optimal_h2.as_slice()), - // &mut h2_scoregrid, - // ); - - // let fitted_h2_h2h = - // frame_prices_from_scoregrid(&h2_scoregrid, &h2_h2h.outcomes, &h2_h2h.market.overround); - // let fitted_h2_h2h = Offer { - // offer_type: OfferType::HeadToHead(Period::SecondHalf), - // outcomes: h2_h2h.outcomes.clone(), - // market: fitted_h2_h2h, - // }; - let fitted_h2_h2h = frame_prices_from_exploration( - &exploration, - &OfferType::HeadToHead(Period::SecondHalf), - h2_h2h.outcomes.items(), - 1.0, - &h2_h2h.market.overround, - ); - let h2_h2h_table = print::tabulate_offer(&fitted_h2_h2h); - println!( - "{:?}: [Σ={:.3}]\n{}", - fitted_h2_h2h.offer_type, - fitted_h2_h2h.market.probs.sum(), - Console::default().render(&h2_h2h_table) - ); - - // let fitted_h2_goals_ou = frame_prices_from_scoregrid( - // &h2_scoregrid, - // &h2_goals_ou.outcomes, - // &h2_goals_ou.market.overround, - // ); - // let fitted_h2_goals_ou = Offer { - // offer_type: OfferType::TotalGoals(Period::SecondHalf, Over(2)), - // outcomes: h2_goals_ou.outcomes.clone(), - // market: fitted_h2_goals_ou, - // }; - let fitted_h2_goals_ou = frame_prices_from_exploration( - &exploration, - &OfferType::TotalGoals(Period::SecondHalf, Over(2)), - h2_goals_ou.outcomes.items(), - 1.0, - &h2_goals_ou.market.overround, - ); - let h2_goals_ou_table = print::tabulate_offer(&fitted_h2_goals_ou); - println!( - "{:?}: [Σ={:.3}]\n{}", - fitted_h2_goals_ou.offer_type, - fitted_h2_goals_ou.market.probs.sum(), - Console::default().render(&h2_goals_ou_table) - ); - - // let fitted_ft_h2h = - // frame_prices_from_scoregrid(&ft_scoregrid, &ft_h2h.outcomes, &ft_h2h.market.overround); - // let fitted_ft_h2h = Offer { - // offer_type: OfferType::HeadToHead(Period::FullTime), - // outcomes: ft_h2h.outcomes.clone(), - // market: fitted_ft_h2h, - // }; - let fitted_ft_h2h = frame_prices_from_exploration( - &exploration, - &OfferType::HeadToHead(Period::FullTime), - ft_h2h.outcomes.items(), - 1.0, - &ft_h2h.market.overround, - ); - let ft_h2h_table = print::tabulate_offer(&fitted_ft_h2h); - println!( - "{:?}: [Σ={:.3}]\n{}", - fitted_ft_h2h.offer_type, - fitted_ft_h2h.market.probs.sum(), - Console::default().render(&ft_h2h_table) - ); - - // let fitted_ft_goals_ou = frame_prices_from_scoregrid( - // &ft_scoregrid, - // &ft_goals_ou.outcomes, - // &ft_goals_ou.market.overround, - // ); - // let fitted_ft_goals_ou = Offer { - // offer_type: OfferType::TotalGoals(Period::FullTime, Over(2)), - // outcomes: ft_goals_ou.outcomes.clone(), - // market: fitted_ft_goals_ou, - // }; - let fitted_ft_goals_ou = frame_prices_from_exploration( - &exploration, - &OfferType::TotalGoals(Period::FullTime, Over(2)), - ft_goals_ou.outcomes.items(), - 1.0, - &ft_goals_ou.market.overround, - ); - let ft_goals_ou_table = print::tabulate_offer(&fitted_ft_goals_ou); - println!( - "{:?}: [Σ={:.3}]\n{}", - fitted_ft_goals_ou.offer_type, - fitted_ft_goals_ou.market.probs.sum(), - Console::default().render(&ft_goals_ou_table) - ); - - // let fitted_ft_correct_score = frame_prices_from_scoregrid( - // &ft_scoregrid, - // &ft_correct_score.outcomes.items(), - // &ft_correct_score.market.overround, - // ); - // let fitted_ft_correct_score = Offer { - // offer_type: OfferType::CorrectScore(Period::FullTime), - // outcomes: ft_correct_score.outcomes.clone(), - // market: fitted_ft_correct_score, - // }; - let fitted_ft_correct_score = frame_prices_from_exploration( - &exploration, - &OfferType::CorrectScore(Period::FullTime), - ft_correct_score.outcomes.items(), - 1.0, - &ft_correct_score.market.overround, - ); - let ft_correct_score_table = print::tabulate_offer(&fitted_ft_correct_score); - println!( - "{:?}: [Σ={:.3}]\n{}", - fitted_ft_correct_score.offer_type, - fitted_ft_correct_score.market.probs.sum(), - Console::default().render(&ft_correct_score_table), - ); - - // let home_away_expectations = home_away_expectations(&ft_scoregrid); - // println!( - // "p(0, 0)={}, home + away expectations: ({} + {} = {})", - // ft_scoregrid[(0, 0)], - // home_away_expectations.0, - // home_away_expectations.1, - // home_away_expectations.0 + home_away_expectations.1 - // ); - - let first_gs = fit_offer( - OfferType::FirstGoalscorer, - &first_gs, - FIRST_GOALSCORER_BOOKSUM, - ); - let anytime_gs = fit_offer(OfferType::AnytimeGoalscorer, &anytime_gs, 1.0); - - // println!("scoregrid:\n{}sum: {}", scoregrid.verbose(), scoregrid.flatten().sum()); - let draw_prob = isolate( - &OfferType::CorrectScore(Period::FullTime), - &OutcomeType::Score(Score { home: 0, away: 0 }), - &exploration.prospects, - &exploration.player_lookup, - ); - // let home_ratio = (ft_search_outcome.optimal_values[0] - // + ft_search_outcome.optimal_values[2] / 2.0) - // / ft_search_outcome.optimal_values.sum() - // * (1.0 - draw_prob); - // let away_ratio = (ft_search_outcome.optimal_values[1] - // + ft_search_outcome.optimal_values[2] / 2.0) - // / ft_search_outcome.optimal_values.sum() - // * (1.0 - draw_prob); - // // println!("home_ratio={home_ratio} + away_ratio={away_ratio}"); - // let mut fitted_goalscorer_probs = BTreeMap::new(); - // let start = Instant::now(); - // for (index, outcome) in first_gs.outcomes.items().iter().enumerate() { - // match outcome { - // OutcomeType::Player(player) => { - // let side_ratio = match player { - // Named(side, _) => match side { - // Side::Home => home_ratio, - // Side::Away => away_ratio, - // }, - // Player::Other => unreachable!(), - // }; - // let init_estimate = first_gs.market.probs[index] / side_ratio; - // let player_search_outcome = fit::fit_first_goalscorer_one( - // // &ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, - // // &ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, - // &ScoringProbs::from(adj_optimal_h1.as_slice()), - // &ScoringProbs::from(adj_optimal_h2.as_slice()), - // player, - // init_estimate, - // first_gs.market.probs[index], - // ); - // // println!("for player {player:?}, {player_search_outcome:?}, sample prob. {}, init_estimate: {init_estimate}", first_gs.market.probs[index]); - // fitted_goalscorer_probs.insert(player.clone(), player_search_outcome.optimal_value); - // } - // OutcomeType::None => {} - // _ => unreachable!(), - // } - // } - // let elapsed = start.elapsed(); - // println!("player fitting took {elapsed:?}"); - let fitted_goalscorer_probs = fit::fit_first_goalscorer_all( - &BivariateProbs::from(adj_optimal_h1.as_slice()), - &BivariateProbs::from(adj_optimal_h2.as_slice()), - &first_gs, - draw_prob, - ); - - let mut fitted_first_goalscorer_probs = Vec::with_capacity(first_gs.outcomes.len()); - for (player, prob) in &fitted_goalscorer_probs { - let exploration = explore( - &IntervalConfig { - intervals: INTERVALS as u8, - team_probs: TeamProbs { - h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), - h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), - assists: UnivariateProbs { - home: 1.0, - away: 1.0, - }, - }, - player_probs: vec![( - player.clone(), - PlayerProbs { - goal: Some(*prob), - assist: None, - }, - )], - prune_thresholds: PruneThresholds { - max_total_goals: MAX_TOTAL_GOALS_FULL, - min_prob: GOALSCORER_MIN_PROB, - }, - expansions: Expansions { - ht_score: false, - ft_score: false, - player_goal_stats: false, - player_split_goal_stats: false, - max_player_assists: 0, - first_goalscorer: true, - }, - }, - 0..INTERVALS as u8, - ); - let isolated_prob = isolate( - &OfferType::FirstGoalscorer, - &OutcomeType::Player(player.clone()), - &exploration.prospects, - &exploration.player_lookup, - ); - fitted_first_goalscorer_probs.push(isolated_prob); - // println!("first scorer {player:?}, prob: {isolated_prob:.3}"); - } - - // fitted_first_goalscorer_probs.push(draw_prob); - // fitted_first_goalscorer_probs.normalise(FIRST_GOALSCORER_BOOKSUM); - fitted_first_goalscorer_probs - .push(FIRST_GOALSCORER_BOOKSUM - fitted_first_goalscorer_probs.sum()); - - let fitted_first_goalscorer = Offer { - offer_type: OfferType::FirstGoalscorer, - outcomes: first_gs.outcomes.clone(), - market: Market::frame( - &first_gs.market.overround, - fitted_first_goalscorer_probs, - &SINGLE_PRICE_BOUNDS, - ), - }; - - if args.player_goals { - println!( - "sample first goalscorer σ={:.3}", - first_gs.market.offered_booksum(), - ); - let table_first_goalscorer = print::tabulate_offer(&fitted_first_goalscorer); - println!( - "{:?}: [Σ={:.3}, σ={:.3}, n={}]\n{}", - fitted_first_goalscorer.offer_type, - fitted_first_goalscorer.market.probs.sum(), - fitted_first_goalscorer.market.offered_booksum(), - fitted_first_goalscorer.market.probs.len(), - Console::default().render(&table_first_goalscorer) - ); - } - - let mut fitted_anytime_goalscorer_outcomes = - HashLookup::with_capacity(fitted_goalscorer_probs.len()); - let mut fitted_anytime_goalscorer_probs = Vec::with_capacity(fitted_goalscorer_probs.len()); - for (player, prob) in &fitted_goalscorer_probs { - fitted_anytime_goalscorer_outcomes.push(OutcomeType::Player(player.clone())); - let exploration = explore( - &IntervalConfig { - intervals: INTERVALS as u8, - team_probs: TeamProbs { - h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), - h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), - assists: UnivariateProbs { - home: 1.0, - away: 1.0, - }, - }, - player_probs: vec![( - player.clone(), - PlayerProbs { - goal: Some(*prob), - assist: None, - }, - )], - prune_thresholds: PruneThresholds { - max_total_goals: MAX_TOTAL_GOALS_FULL, - min_prob: GOALSCORER_MIN_PROB, - }, - expansions: Expansions { - ht_score: false, - ft_score: false, - player_goal_stats: true, - player_split_goal_stats: false, - max_player_assists: 0, - first_goalscorer: false, - }, - }, - 0..INTERVALS as u8, - ); - let isolated_prob = isolate( - &OfferType::AnytimeGoalscorer, - &OutcomeType::Player(player.clone()), - &exploration.prospects, - &exploration.player_lookup, - ); - fitted_anytime_goalscorer_probs.push(isolated_prob); - } - fitted_anytime_goalscorer_outcomes.push(OutcomeType::None); - fitted_anytime_goalscorer_probs.push(draw_prob); - - let anytime_goalscorer_overround = Overround { - method: OVERROUND_METHOD, - value: anytime_gs.market.offered_booksum() / fitted_anytime_goalscorer_probs.sum(), - }; - let fitted_anytime_goalscorer = Offer { - offer_type: OfferType::AnytimeGoalscorer, - outcomes: fitted_anytime_goalscorer_outcomes, - market: Market::frame( - &anytime_goalscorer_overround, - fitted_anytime_goalscorer_probs, - &SINGLE_PRICE_BOUNDS, - ), - }; - if args.player_goals { - println!( - "sample anytime goalscorer σ={:.3}", - anytime_gs.market.offered_booksum(), - ); - let table_anytime_goalscorer = print::tabulate_offer(&fitted_anytime_goalscorer); - println!( - "{:?}: [Σ={:.3}, σ={:.3}, n={}]\n{}", - fitted_anytime_goalscorer.offer_type, - fitted_anytime_goalscorer.market.probs.sum(), - fitted_anytime_goalscorer.market.offered_booksum(), - fitted_anytime_goalscorer.market.probs.len(), - Console::default().render(&table_anytime_goalscorer) - ); - } - - let sample_anytime_assist_booksum = anytime_assist - .values() - .map(|price| 1.0 / price) - .sum::(); - - let per_outcome_overround = - (anytime_goalscorer_overround.value - 1.0) / anytime_gs.outcomes.len() as f64; - - let anytime_assist = fit_offer( - OfferType::AnytimeAssist, - &anytime_assist, - sample_anytime_assist_booksum / (1.0 + per_outcome_overround * anytime_assist.len() as f64), - ); - - let home_goalscorer_booksum = home_booksum(&fitted_anytime_goalscorer); - let away_goalscorer_booksum = away_booksum(&fitted_anytime_goalscorer); - // println!("partial goalscorer booksums: home: {home_goalscorer_booksum:.3}, away: {away_goalscorer_booksum:.3}"); - - let home_assister_booksum = home_booksum(&anytime_assist); - let away_assister_booksum = away_booksum(&anytime_assist); - // println!("partial assister booksums: home: {home_assister_booksum:.3}, away: {away_assister_booksum:.3}"); - let assist_probs = UnivariateProbs { - home: home_assister_booksum / home_goalscorer_booksum, - away: away_assister_booksum / away_goalscorer_booksum, - }; - println!("assist_probs: {assist_probs:?}"); - - let fitted_assist_probs = fit::fit_anytime_assist_all( - &BivariateProbs::from(adj_optimal_h1.as_slice()), - &BivariateProbs::from(adj_optimal_h2.as_slice()), - &assist_probs, - &anytime_assist, - draw_prob, - anytime_assist.market.fair_booksum(), - ); - - let mut fitted_anytime_assist_outcomes = HashLookup::with_capacity(fitted_assist_probs.len()); - let mut fitted_anytime_assist_probs = Vec::with_capacity(fitted_assist_probs.len()); - for (player, prob) in &fitted_assist_probs { - fitted_anytime_assist_outcomes.push(OutcomeType::Player(player.clone())); - let exploration = explore( - &IntervalConfig { - intervals: INTERVALS as u8, - team_probs: TeamProbs { - h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), - h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), - assists: assist_probs.clone(), - }, - player_probs: vec![( - player.clone(), - PlayerProbs { - goal: None, - assist: Some(*prob), - }, - )], - prune_thresholds: PruneThresholds { - max_total_goals: MAX_TOTAL_GOALS_FULL, - min_prob: GOALSCORER_MIN_PROB, - }, - expansions: Expansions { - ht_score: false, - ft_score: false, - player_goal_stats: false, - player_split_goal_stats: false, - max_player_assists: 1, - first_goalscorer: false, + let offers = contest + .offerings + .iter() + .map(|(offer_type, prices)| { + debug!("sourced {offer_type:?} with {} outcomes, σ={:.3}", prices.len(), implied_booksum(prices.values())); + let normal = match &offer_type { + OfferType::HeadToHead(_) + | OfferType::TotalGoals(_, _) + | OfferType::CorrectScore(_) + | OfferType::DrawNoBet => 1.0, + OfferType::AnytimeGoalscorer + | OfferType::FirstGoalscorer + | OfferType::PlayerShotsOnTarget(_) + | OfferType::AnytimeAssist => { + let implied_booksum = implied_booksum(prices.values()); + let expected_overround = prices.len() as f64 * INCREMENTAL_OVERROUND; + implied_booksum / expected_overround }, - }, - 0..INTERVALS as u8, - ); - let isolated_prob = isolate( - &OfferType::AnytimeAssist, - &OutcomeType::Player(player.clone()), - &exploration.prospects, - &exploration.player_lookup, - ); - fitted_anytime_assist_probs.push(isolated_prob); - } - - let anytime_assist_overround = Overround { - method: OVERROUND_METHOD, - value: anytime_assist.market.offered_booksum() / fitted_anytime_assist_probs.sum(), - }; - fitted_anytime_assist_outcomes.push(OutcomeType::None); - fitted_anytime_assist_probs.push(draw_prob); - - let fitted_anytime_assist = Offer { - offer_type: OfferType::AnytimeAssist, - outcomes: fitted_anytime_assist_outcomes, - market: Market::frame( - &anytime_assist_overround, - fitted_anytime_assist_probs, - &SINGLE_PRICE_BOUNDS, - ), - }; - - if args.player_assists { - println!( - "sample anytime assists σ={:.3}", - anytime_assist.market.offered_booksum(), - ); - let table_anytime_assist = print::tabulate_offer(&fitted_anytime_assist); - println!( - "{:?}: [Σ={:.3}, σ={:.3}, n={}]\n{}", - fitted_anytime_assist.offer_type, - fitted_anytime_assist.market.probs.sum(), - fitted_anytime_assist.market.offered_booksum(), - fitted_anytime_assist.market.probs.len(), - Console::default().render(&table_anytime_assist) - ); - } - - let market_errors = [ - (&h1_h2h, &fitted_h1_h2h), - (&h1_goals_ou, &fitted_h1_goals_ou), - (&h2_h2h, &fitted_h2_h2h), - (&h2_goals_ou, &fitted_h2_goals_ou), - (&ft_h2h, &fitted_ft_h2h), - (&ft_goals_ou, &fitted_ft_goals_ou), - (&ft_correct_score, &fitted_ft_correct_score), - (&first_gs, &fitted_first_goalscorer), - (&anytime_gs, &fitted_anytime_goalscorer), - (&anytime_assist, &fitted_anytime_assist), - ] - .iter() - .map(|(sample, fitted)| { - ( - &sample.offer_type, - FittingErrors { - rmse: fit::compute_error( - &sample.market.prices, - &fitted.market.prices, - &ErrorType::SquaredAbsolute, - ), - rmsre: fit::compute_error( - &sample.market.prices, - &fitted.market.prices, - &ErrorType::SquaredRelative, - ), - }, - ) - }) - .collect::>(); - let table_errors = print::tabulate_errors(&market_errors); - println!( - "Fitting errors:\n{}", - Console::default().render(&table_errors) - ); - - let fitted_markets = [ - fitted_h1_h2h, - fitted_h1_goals_ou, - fitted_h2_h2h, - fitted_h2_goals_ou, - fitted_ft_h2h, - fitted_ft_goals_ou, - fitted_ft_correct_score, - fitted_first_goalscorer, - fitted_anytime_goalscorer, - fitted_anytime_assist, - ]; - - let table_overrounds = print::tabulate_overrounds(&fitted_markets); - println!( - "Market overrounds:\n{}", - Console::default().render(&table_overrounds) - ); + }; + let offer = fit_offer(offer_type.clone(), prices, normal); + (offer_type.clone(), offer) + }) + .collect::>(); - // let fitted_markets_hash = fitted_markets.iter().map(|market| (market.offer_type.clone(), market)).collect::>(); - // let h2h_sel = (OfferType::HeadToHead(Period::FullTime), OutcomeType::Win(Side::Home)); - // let tg_sel = (OfferType::TotalGoalsOverUnder(Period::FullTime, Over(2)), OutcomeType::Over(2)); - // let anytime_player = Named(Side::Home, "Gianluca Lapadula".into()); - // let anytime_sel = (OfferType::AnytimeGoalscorer, OutcomeType::Player(anytime_player.clone())); - // let player_prob = fitted_goalscorer_probs[&anytime_player]; - // let exploration = explore( - // &IntervalConfig { - // intervals: INTERVALS as u8, - // h1_probs: ScoringProbs::from(adj_optimal_h1.as_slice()), - // h2_probs: ScoringProbs::from(adj_optimal_h2.as_slice()), - // players: vec![ - // (anytime_player.clone(), player_prob) - // ], - // prune_thresholds: PruneThresholds { - // max_total_goals: MAX_TOTAL_GOALS_FULL, - // min_prob: 0.0, - // }, - // expansions: Expansions::default() - // }, - // 0..INTERVALS as u8, - // ); - // let selections = [h2h_sel, tg_sel, anytime_sel]; - // let overround = selections.iter().map(|(offer_type, outcome_type)| { - // let fitted_market = fitted_markets_hash[offer_type]; - // let outcome_index = fitted_market.outcomes.iter().position(|in_vec| in_vec == outcome_type).unwrap(); - // let outcome_prob = fitted_market.market.probs[outcome_index]; - // let outcome_price = fitted_market.market.prices[outcome_index]; - // 1.0 / outcome_prob / outcome_price - // }).product::(); - // let multi_prob = isolate_batch(&selections, &exploration.prospects, &exploration.player_lookup); - // let multi_price = 1.0 / multi_prob / overround; - // info!("selections: {selections:?}, prob: {multi_prob:.3}, overround: {overround:.3}, price: {multi_price:.3}"); + let mut model = Model::new(); + let score_fitter = ScoreFitter::try_from(score_fitter::Config::default())?; + score_fitter.fit(&mut model, &offers)?; Ok(()) } -// fn implied_booksum(prices: &[f64]) -> f64 { -// prices.invert().sum() -// } +fn implied_booksum<'a>(prices: impl Iterator) -> f64 { + prices.map(|&price| 1.0 / price).sum() +} fn fit_offer(offer_type: OfferType, map: &HashMap, normal: f64) -> Offer { let mut entries = map.iter().collect::>(); @@ -829,309 +124,6 @@ fn fit_offer(offer_type: OfferType, map: &HashMap, normal: f64 } } -// fn fit_scoregrid_half(markets: &[&Offer]) -> HypergridSearchOutcome { -// let init_estimates = { -// let start = Instant::now(); -// let search_outcome = fit_bivariate_poisson_scoregrid(markets, MAX_TOTAL_GOALS_HALF); -// let elapsed = start.elapsed(); -// println!("biv-poisson: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", expectation_from_lambdas(&search_outcome.optimal_values)); -// search_outcome -// .optimal_values -// .iter() -// .map(|optimal_value| { -// 1.0 - poisson::univariate( -// 0, -// optimal_value / INTERVALS as f64 * 2.0, -// &factorial::Calculator, -// ) -// }) -// .collect::>() -// }; -// println!("initial estimates: {init_estimates:?}"); -// -// let start = Instant::now(); -// let search_outcome = fit_bivariate_binomial_scoregrid( -// markets, -// &init_estimates, -// (INTERVALS / 2) as u8, -// MAX_TOTAL_GOALS_HALF, -// ); -// let elapsed = start.elapsed(); -// println!("biv-binomial: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); -// search_outcome -// } - -// fn fit_bivariate_binomial_scoregrid( -// markets: &[&Offer], -// init_estimates: &[f64], -// intervals: u8, -// max_total_goals: u16, -// ) -> HypergridSearchOutcome { -// let mut scoregrid = allocate_scoregrid(max_total_goals); -// let bounds = init_estimates -// .iter() -// .map(|&estimate| (estimate * 0.67)..=(estimate * 1.5)) -// .collect::>(); -// hypergrid_search( -// &HypergridSearchConfig { -// max_steps: 10, -// acceptable_residual: 1e-6, -// bounds: bounds.into(), -// resolution: 10, -// }, -// |values| values.sum() <= 1.0, -// |values| { -// bivariate_binomial_scoregrid( -// intervals, -// values[0], -// values[1], -// values[2], -// &mut scoregrid, -// ); -// scoregrid_error(markets, &scoregrid) -// }, -// ) -// } -// -// fn fit_bivariate_poisson_scoregrid( -// markets: &[&Offer], -// max_total_goals: u16, -// ) -> HypergridSearchOutcome { -// let mut scoregrid = allocate_scoregrid(max_total_goals); -// hypergrid_search( -// &HypergridSearchConfig { -// max_steps: 10, -// acceptable_residual: 1e-6, -// bounds: vec![0.2..=3.0, 0.2..=3.0, 0.0..=0.5].into(), -// resolution: 10, -// }, -// |_| true, -// |values| { -// bivariate_poisson_scoregrid(values[0], values[1], values[2], &mut scoregrid); -// scoregrid_error(markets, &scoregrid) -// }, -// ) -// } -// -// fn scoregrid_error(offers: &[&Offer], scoregrid: &Matrix) -> f64 { -// let mut residual = 0.0; -// for offer in offers { -// for (index, outcome) in offer.outcomes.items().iter().enumerate() { -// let fitted_prob = outcome.gather(scoregrid); -// let sample_prob = offer.market.probs[index]; -// residual += ERROR_TYPE.calculate(sample_prob, fitted_prob); -// } -// } -// residual -// } - -// fn fit_first_goalscorer( -// h1_probs: &ScoringProbs, -// h2_probs: &ScoringProbs, -// player: &Player, -// init_estimate: f64, -// expected_prob: f64, -// ) -> UnivariateDescentOutcome { -// univariate_descent( -// &UnivariateDescentConfig { -// init_value: init_estimate, -// init_step: init_estimate * 0.1, -// min_step: init_estimate * 0.001, -// max_steps: 100, -// acceptable_residual: 1e-9, -// }, -// |value| { -// let exploration = explore( -// &IntervalConfig { -// intervals: INTERVALS as u8, -// h1_probs: h1_probs.clone(), -// h2_probs: h2_probs.clone(), -// players: vec![(player.clone(), value)], -// prune_thresholds: PruneThresholds { -// max_total_goals: MAX_TOTAL_GOALS_FULL, -// min_prob: GOALSCORER_MIN_PROB, -// }, -// expansions: Expansions { -// ht_score: false, -// ft_score: false, -// player_stats: false, -// player_split_stats: false, -// first_goalscorer: true, -// }, -// }, -// 0..INTERVALS as u8, -// ); -// let isolated_prob = isolate( -// &OfferType::FirstGoalscorer, -// &OutcomeType::Player(player.clone()), -// &exploration.prospects, -// &exploration.player_lookup, -// ); -// ERROR_TYPE.calculate(expected_prob, isolated_prob) -// }, -// ) -// } - -// fn expectation_from_lambdas(lambdas: &[f64]) -> f64 { -// assert_eq!(3, lambdas.len()); -// lambdas[0] + lambdas[1] + 2.0 * lambdas[2] -// } - -// /// Intervals. -// fn interval_scoregrid( -// explore_intervals: Range, -// max_total_goals: u16, -// h1_probs: ScoringProbs, -// h2_probs: ScoringProbs, -// scoregrid: &mut Matrix, -// ) { -// scoregrid.fill(0.0); -// scoregrid::from_interval( -// INTERVALS as u8, -// explore_intervals, -// max_total_goals, -// h1_probs, -// h2_probs, -// scoregrid, -// ); -// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); -// } - -fn explore_scores(h1_goals: BivariateProbs, h2_goals: BivariateProbs) -> Exploration { - explore( - &IntervalConfig { - intervals: INTERVALS as u8, - team_probs: TeamProbs { - h1_goals, - h2_goals, - assists: UnivariateProbs { - home: 1.0, - away: 1.0, - }, - }, - player_probs: vec![], - prune_thresholds: PruneThresholds { - max_total_goals: MAX_TOTAL_GOALS_FULL, - min_prob: 0.0, - }, - expansions: Expansions { - ht_score: true, - ft_score: true, - player_goal_stats: false, - player_split_goal_stats: false, - max_player_assists: 0, - first_goalscorer: false, - }, - }, - 0..INTERVALS as u8, - ) -} - -// /// Binomial. -// fn binomial_scoregrid( -// intervals: u8, -// interval_home_prob: f64, -// interval_away_prob: f64, -// scoregrid: &mut Matrix, -// ) { -// scoregrid.fill(0.0); -// scoregrid::from_binomial(intervals, interval_home_prob, interval_away_prob, scoregrid); -// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); -// } -// -// /// Bivariate binomial. -// fn bivariate_binomial_scoregrid( -// intervals: u8, -// interval_home_prob: f64, -// interval_away_prob: f64, -// interval_common_prob: f64, -// scoregrid: &mut Matrix, -// ) { -// scoregrid.fill(0.0); -// scoregrid::from_bivariate_binomial( -// intervals, -// interval_home_prob, -// interval_away_prob, -// interval_common_prob, -// scoregrid, -// ); -// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); -// } -// -// /// Independent Poisson. -// fn univariate_poisson_scoregrid(home_rate: f64, away_rate: f64, scoregrid: &mut Matrix) { -// scoregrid.fill(0.0); -// scoregrid::from_univariate_poisson(home_rate, away_rate, scoregrid); -// } -// -// /// Bivariate Poisson. -// fn bivariate_poisson_scoregrid( -// home_rate: f64, -// away_rate: f64, -// common: f64, -// scoregrid: &mut Matrix, -// ) { -// scoregrid.fill(0.0); -// scoregrid::from_bivariate_poisson(home_rate, away_rate, common, scoregrid); -// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); -// } -// -// fn correct_score_scoregrid(correct_score: &Offer, scoregrid: &mut Matrix) { -// scoregrid.fill(0.0); -// from_correct_score( -// correct_score.outcomes.items(), -// &correct_score.market.probs, -// scoregrid, -// ); -// } -// -// fn allocate_scoregrid(max_total_goals: u16) -> Matrix { -// let dim = usize::min(max_total_goals as usize, INTERVALS) + 1; -// Matrix::allocate(dim, dim) -// } -// -// fn frame_prices_from_scoregrid( -// scoregrid: &Matrix, -// outcomes: &[OutcomeType], -// overround: &Overround, -// ) -> Market { -// let mut probs = outcomes -// .iter() -// .map(|outcome_type| outcome_type.gather(scoregrid)) -// .map(|prob| f64::max(0.0001, prob)) -// .collect::>(); -// probs.normalise(1.0); -// Market::frame(overround, probs, &SINGLE_PRICE_BOUNDS) -// } - -fn frame_prices_from_exploration( - exploration: &Exploration, - offer_type: &OfferType, - outcomes: &[OutcomeType], - normal: f64, - overround: &Overround, -) -> Offer { - let mut probs = outcomes - .iter() - .map(|outcome_type| { - isolate( - offer_type, - outcome_type, - &exploration.prospects, - &exploration.player_lookup, - ) - }) - // .map(|prob| f64::max(1e-6, prob)) - .collect::>(); - probs.normalise(normal); - let market = Market::frame(overround, probs, &SINGLE_PRICE_BOUNDS); - Offer { - offer_type: offer_type.clone(), - outcomes: HashLookup::from(outcomes.to_vec()), - market, - } -} - async fn read_contest_data(args: &Args) -> anyhow::Result { let contest = { if let Some(_) = args.file.as_ref() { diff --git a/brumby-soccer/src/bin/soc_prices2.rs b/brumby-soccer/src/bin/soc_prices2.rs new file mode 100644 index 0000000..8dfb37b --- /dev/null +++ b/brumby-soccer/src/bin/soc_prices2.rs @@ -0,0 +1,1132 @@ +use std::collections::HashMap; +use std::env; +use std::error::Error; +use std::path::PathBuf; + +use anyhow::bail; +use clap::Parser; +use stanza::renderer::console::Console; +use stanza::renderer::Renderer; +use tracing::{debug, info}; + +use brumby::hash_lookup::HashLookup; +use brumby::market::{Market, Overround, OverroundMethod, PriceBounds}; +use brumby::probs::SliceExt; +use brumby_soccer::data::{download_by_id, ContestSummary, SoccerFeedId}; +use brumby_soccer::domain::{Offer, OfferType, OutcomeType, Over, Period, Score}; +use brumby_soccer::fit::{away_booksum, home_booksum, ErrorType, FittingErrors}; +use brumby_soccer::interval::query::isolate; +use brumby_soccer::interval::{ + explore, BivariateProbs, Expansions, Exploration, IntervalConfig, PlayerProbs, PruneThresholds, + TeamProbs, UnivariateProbs, +}; +use brumby_soccer::{fit, print}; + +const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; +const SINGLE_PRICE_BOUNDS: PriceBounds = 1.01..=301.0; +const FIRST_GOALSCORER_BOOKSUM: f64 = 1.5; +const INTERVALS: usize = 18; +// const MAX_TOTAL_GOALS_HALF: u16 = 4; +const MAX_TOTAL_GOALS_FULL: u16 = 8; +const GOALSCORER_MIN_PROB: f64 = 0.0; +// const ERROR_TYPE: ErrorType = ErrorType::SquaredRelative; + +#[derive(Debug, clap::Parser, Clone)] +struct Args { + /// file to source the contest data from + #[clap(short = 'f', long)] + file: Option, + + /// download contest data by ID + #[clap(short = 'd', long)] + // download: Option, + download: Option, + + /// print player goal markets + #[clap(long = "player-goals")] + player_goals: bool, + + /// print player assists markets + #[clap(long = "player-assists")] + player_assists: bool, +} +impl Args { + fn validate(&self) -> anyhow::Result<()> { + if self.file.is_none() && self.download.is_none() + || self.file.is_some() && self.download.is_some() + { + bail!("either the -f or the -d flag must be specified"); + } + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + if env::var("RUST_BACKTRACE").is_err() { + env::set_var("RUST_BACKTRACE", "full") + } + if env::var("RUST_LOG").is_err() { + env::set_var("RUST_LOG", "info") + } + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + args.validate()?; + debug!("args: {args:?}"); + let contest = read_contest_data(&args).await?; + info!("contest.name: {}", contest.name); + for offer_type in contest.offerings.keys() { + info!("offered {offer_type:?}"); + } + + let ft_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::FullTime)].clone(); + let ft_goals_prices = + contest.offerings[&OfferType::TotalGoals(Period::FullTime, Over(2))].clone(); + let ft_correct_score_prices = + contest.offerings[&OfferType::CorrectScore(Period::FullTime)].clone(); + let h1_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::FirstHalf)].clone(); + let h1_goals_prices = + contest.offerings[&OfferType::TotalGoals(Period::FirstHalf, Over(2))].clone(); + let h2_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::SecondHalf)].clone(); + let h2_goals_prices = + contest.offerings[&OfferType::TotalGoals(Period::SecondHalf, Over(2))].clone(); + let first_gs = contest.offerings[&OfferType::FirstGoalscorer].clone(); + let anytime_gs = contest.offerings[&OfferType::AnytimeGoalscorer].clone(); + let anytime_assist = contest.offerings[&OfferType::AnytimeAssist].clone(); + + let ft_h2h = fit_offer(OfferType::HeadToHead(Period::FullTime), &ft_h2h_prices, 1.0); + // println!("ft_h2h: {ft_h2h:?}"); + let ft_goals = fit_offer( + OfferType::TotalGoals(Period::FullTime, Over(2)), + &ft_goals_prices, + 1.0, + ); + // println!("ft_goals_ou: {ft_goals_ou:?}"); + let ft_correct_score = fit_offer( + OfferType::CorrectScore(Period::FullTime), + &ft_correct_score_prices, + 1.0, + ); + let h1_h2h = fit_offer( + OfferType::HeadToHead(Period::FirstHalf), + &h1_h2h_prices, + 1.0, + ); + let h1_goals = fit_offer( + OfferType::TotalGoals(Period::FirstHalf, Over(2)), + &h1_goals_prices, + 1.0, + ); + let h2_h2h = fit_offer( + OfferType::HeadToHead(Period::SecondHalf), + &h2_h2h_prices, + 1.0, + ); + let h2_goals = fit_offer( + OfferType::TotalGoals(Period::SecondHalf, Over(2)), + &h2_goals_prices, + 1.0, + ); + + let (ft_search_outcome, lambdas) = fit::fit_scoregrid_full(&ft_h2h, &ft_goals); + const H1_RATIO: f64 = 0.425; + println!("*** fitting H1 ***"); + let h1_home_goals_estimate = (lambdas[0] + lambdas[2]) * H1_RATIO; + let h1_away_goals_estimate = (lambdas[1] + lambdas[2]) * H1_RATIO; + let h1_search_outcome = fit::fit_scoregrid_half(h1_home_goals_estimate, h1_away_goals_estimate, &[&h1_h2h, &h1_goals]); + println!("*** fitting H2 ***"); + let h2_home_goals_estimate = (lambdas[0] + lambdas[2]) * (1.0 - H1_RATIO); + let h2_away_goals_estimate = (lambdas[1] + lambdas[2]) * (1.0 - H1_RATIO); + let h2_search_outcome = fit::fit_scoregrid_half(h2_home_goals_estimate, h2_away_goals_estimate, &[&h2_h2h, &h2_goals]); + + let mut adj_optimal_h1 = [0.0; 3]; + let mut adj_optimal_h2 = [0.0; 3]; + // only adjust the home and away scoring probs; common prob is locked to the full-time one + for (i, orig_h1) in h1_search_outcome.optimal_values.iter().take(2).enumerate() { + let orig_h2 = h2_search_outcome.optimal_values[i]; + let ft = ft_search_outcome.optimal_values[i]; + let avg_h1_h2 = (orig_h1 + orig_h2) / 2.0; + if avg_h1_h2 > 0.0 { + adj_optimal_h1[i] = orig_h1 / (avg_h1_h2 / ft); + adj_optimal_h2[i] = orig_h2 / (avg_h1_h2 / ft); + } else { + adj_optimal_h1[i] = ft; + adj_optimal_h2[i] = ft; + } + } + adj_optimal_h1[2] = ft_search_outcome.optimal_values[2]; + adj_optimal_h2[2] = ft_search_outcome.optimal_values[2]; + println!("adjusted optimal_h1={adj_optimal_h1:?}, optimal_h2={adj_optimal_h2:?}"); + // let adj_optimal_h1 = h1_search_outcome.optimal_values; + // let adj_optimal_h2 = h2_search_outcome.optimal_values; + + // let ft_gamma_sum = ft_search_outcome.optimal_values.sum(); + // h1_search_outcome.optimal_values.normalise(ft_gamma_sum * 1.0); + // h2_search_outcome.optimal_values.normalise(ft_gamma_sum * 1.0); + + let exploration = explore_scores( + BivariateProbs::from(adj_optimal_h1.as_slice()), + BivariateProbs::from(adj_optimal_h2.as_slice()), + ); + + // let mut ft_scoregrid = allocate_scoregrid(MAX_TOTAL_GOALS_FULL); + // interval_scoregrid( + // 0..INTERVALS as u8, + // ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, + // ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, + // &mut ft_scoregrid, + // ); + // interval_scoregrid( + // 0..INTERVALS as u8, + // MAX_TOTAL_GOALS_FULL, + // ScoringProbs::from(adj_optimal_h1.as_slice()), + // ScoringProbs::from(adj_optimal_h2.as_slice()), + // &mut ft_scoregrid, + // ); + // correct_score_scoregrid(&ft_correct_score, &mut ft_scoregrid); + + // let mut h1_scoregrid = allocate_scoregrid(MAX_TOTAL_GOALS_HALF); + // interval_scoregrid( + // 0..(INTERVALS / 2) as u8, + // MAX_TOTAL_GOALS_HALF, + // ScoringProbs::from(adj_optimal_h1.as_slice()), + // ScoringProbs { + // home_prob: 0.0, + // away_prob: 0.0, + // common_prob: 0.0, + // }, + // &mut h1_scoregrid, + // ); + + // let fitted_h1_h2h = + // frame_prices_from_scoregrid(&h1_scoregrid, &h1_h2h.outcomes.items(), &h1_h2h.market.overround); + // let fitted_h1_h2h = Offer { + // offer_type: OfferType::HeadToHead(Period::FirstHalf), + // outcomes: h1_h2h.outcomes.clone(), + // market: fitted_h1_h2h, + // }; + let fitted_h1_h2h = frame_prices_from_exploration( + &exploration, + &OfferType::HeadToHead(Period::FirstHalf), + h1_h2h.outcomes.items(), + 1.0, + &h1_h2h.market.overround, + ); + let h1_h2h_table = print::tabulate_offer(&fitted_h1_h2h); + println!( + "{:?}: [Σ={:.3}]\n{}", + fitted_h1_h2h.offer_type, + fitted_h1_h2h.market.probs.sum(), + Console::default().render(&h1_h2h_table) + ); + + // let fitted_h1_goals_ou = frame_prices_from_scoregrid( + // &h1_scoregrid, + // &ft_goals_ou.outcomes, + // &ft_goals_ou.market.overround, + // ); + // let fitted_h1_goals_ou = Offer { + // offer_type: OfferType::TotalGoals(Period::FirstHalf, Over(2)), + // outcomes: h1_goals_ou.outcomes.clone(), + // market: fitted_h1_goals_ou, + // }; + let fitted_h1_goals = frame_prices_from_exploration( + &exploration, + &OfferType::TotalGoals(Period::FirstHalf, Over(2)), + h1_goals.outcomes.items(), + 1.0, + &h1_goals.market.overround, + ); + let h1_goals_table = print::tabulate_offer(&fitted_h1_goals); + println!( + "{:?}: [Σ={:.3}]\n{}", + fitted_h1_goals.offer_type, + fitted_h1_goals.market.probs.sum(), + Console::default().render(&h1_goals_table) + ); + + // let mut h2_scoregrid = allocate_scoregrid(MAX_TOTAL_GOALS_HALF); + // interval_scoregrid( + // 0..(INTERVALS / 2) as u8, + // ModelParams { home_prob: h2_search_outcome.optimal_values[0], away_prob: h2_search_outcome.optimal_values[1], common_prob: h2_search_outcome.optimal_values[2] }, + // ModelParams { home_prob: 0.0, away_prob: 0.0, common_prob: 0.0 }, + // &mut h2_scoregrid, + // ); + // interval_scoregrid( + // (INTERVALS / 2) as u8..INTERVALS as u8, + // MAX_TOTAL_GOALS_HALF, + // ScoringProbs { + // home_prob: 0.0, + // away_prob: 0.0, + // common_prob: 0.0, + // }, + // ScoringProbs::from(adj_optimal_h2.as_slice()), + // &mut h2_scoregrid, + // ); + + // let fitted_h2_h2h = + // frame_prices_from_scoregrid(&h2_scoregrid, &h2_h2h.outcomes, &h2_h2h.market.overround); + // let fitted_h2_h2h = Offer { + // offer_type: OfferType::HeadToHead(Period::SecondHalf), + // outcomes: h2_h2h.outcomes.clone(), + // market: fitted_h2_h2h, + // }; + let fitted_h2_h2h = frame_prices_from_exploration( + &exploration, + &OfferType::HeadToHead(Period::SecondHalf), + h2_h2h.outcomes.items(), + 1.0, + &h2_h2h.market.overround, + ); + let h2_h2h_table = print::tabulate_offer(&fitted_h2_h2h); + println!( + "{:?}: [Σ={:.3}]\n{}", + fitted_h2_h2h.offer_type, + fitted_h2_h2h.market.probs.sum(), + Console::default().render(&h2_h2h_table) + ); + + // let fitted_h2_goals_ou = frame_prices_from_scoregrid( + // &h2_scoregrid, + // &h2_goals_ou.outcomes, + // &h2_goals_ou.market.overround, + // ); + // let fitted_h2_goals_ou = Offer { + // offer_type: OfferType::TotalGoals(Period::SecondHalf, Over(2)), + // outcomes: h2_goals_ou.outcomes.clone(), + // market: fitted_h2_goals_ou, + // }; + let fitted_h2_goals = frame_prices_from_exploration( + &exploration, + &OfferType::TotalGoals(Period::SecondHalf, Over(2)), + h2_goals.outcomes.items(), + 1.0, + &h2_goals.market.overround, + ); + let h2_goals_table = print::tabulate_offer(&fitted_h2_goals); + println!( + "{:?}: [Σ={:.3}]\n{}", + fitted_h2_goals.offer_type, + fitted_h2_goals.market.probs.sum(), + Console::default().render(&h2_goals_table) + ); + + // let fitted_ft_h2h = + // frame_prices_from_scoregrid(&ft_scoregrid, &ft_h2h.outcomes, &ft_h2h.market.overround); + // let fitted_ft_h2h = Offer { + // offer_type: OfferType::HeadToHead(Period::FullTime), + // outcomes: ft_h2h.outcomes.clone(), + // market: fitted_ft_h2h, + // }; + let fitted_ft_h2h = frame_prices_from_exploration( + &exploration, + &OfferType::HeadToHead(Period::FullTime), + ft_h2h.outcomes.items(), + 1.0, + &ft_h2h.market.overround, + ); + let ft_h2h_table = print::tabulate_offer(&fitted_ft_h2h); + println!( + "{:?}: [Σ={:.3}]\n{}", + fitted_ft_h2h.offer_type, + fitted_ft_h2h.market.probs.sum(), + Console::default().render(&ft_h2h_table) + ); + + let fitted_ft_goals_ou = frame_prices_from_exploration( + &exploration, + &OfferType::TotalGoals(Period::FullTime, Over(2)), + ft_goals.outcomes.items(), + 1.0, + &ft_goals.market.overround, + ); + let ft_goals_ou_table = print::tabulate_offer(&fitted_ft_goals_ou); + println!( + "{:?}: [Σ={:.3}]\n{}", + fitted_ft_goals_ou.offer_type, + fitted_ft_goals_ou.market.probs.sum(), + Console::default().render(&ft_goals_ou_table) + ); + + let fitted_ft_correct_score = frame_prices_from_exploration( + &exploration, + &OfferType::CorrectScore(Period::FullTime), + ft_correct_score.outcomes.items(), + 1.0, + &ft_correct_score.market.overround, + ); + let ft_correct_score_table = print::tabulate_offer(&fitted_ft_correct_score); + println!( + "{:?}: [Σ={:.3}]\n{}", + fitted_ft_correct_score.offer_type, + fitted_ft_correct_score.market.probs.sum(), + Console::default().render(&ft_correct_score_table), + ); + + // let home_away_expectations = home_away_expectations(&ft_scoregrid); + // println!( + // "p(0, 0)={}, home + away expectations: ({} + {} = {})", + // ft_scoregrid[(0, 0)], + // home_away_expectations.0, + // home_away_expectations.1, + // home_away_expectations.0 + home_away_expectations.1 + // ); + + let first_gs = fit_offer( + OfferType::FirstGoalscorer, + &first_gs, + FIRST_GOALSCORER_BOOKSUM, + ); + let anytime_gs = fit_offer(OfferType::AnytimeGoalscorer, &anytime_gs, 1.0); + + // println!("scoregrid:\n{}sum: {}", scoregrid.verbose(), scoregrid.flatten().sum()); + let draw_prob = isolate( + &OfferType::CorrectScore(Period::FullTime), + &OutcomeType::Score(Score { home: 0, away: 0 }), + &exploration.prospects, + &exploration.player_lookup, + ); + // let home_ratio = (ft_search_outcome.optimal_values[0] + // + ft_search_outcome.optimal_values[2] / 2.0) + // / ft_search_outcome.optimal_values.sum() + // * (1.0 - draw_prob); + // let away_ratio = (ft_search_outcome.optimal_values[1] + // + ft_search_outcome.optimal_values[2] / 2.0) + // / ft_search_outcome.optimal_values.sum() + // * (1.0 - draw_prob); + // // println!("home_ratio={home_ratio} + away_ratio={away_ratio}"); + // let mut fitted_goalscorer_probs = BTreeMap::new(); + // let start = Instant::now(); + // for (index, outcome) in first_gs.outcomes.items().iter().enumerate() { + // match outcome { + // OutcomeType::Player(player) => { + // let side_ratio = match player { + // Named(side, _) => match side { + // Side::Home => home_ratio, + // Side::Away => away_ratio, + // }, + // Player::Other => unreachable!(), + // }; + // let init_estimate = first_gs.market.probs[index] / side_ratio; + // let player_search_outcome = fit::fit_first_goalscorer_one( + // // &ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, + // // &ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, + // &ScoringProbs::from(adj_optimal_h1.as_slice()), + // &ScoringProbs::from(adj_optimal_h2.as_slice()), + // player, + // init_estimate, + // first_gs.market.probs[index], + // ); + // // println!("for player {player:?}, {player_search_outcome:?}, sample prob. {}, init_estimate: {init_estimate}", first_gs.market.probs[index]); + // fitted_goalscorer_probs.insert(player.clone(), player_search_outcome.optimal_value); + // } + // OutcomeType::None => {} + // _ => unreachable!(), + // } + // } + // let elapsed = start.elapsed(); + // println!("player fitting took {elapsed:?}"); + let fitted_goalscorer_probs = fit::fit_first_goalscorer_all( + &BivariateProbs::from(adj_optimal_h1.as_slice()), + &BivariateProbs::from(adj_optimal_h2.as_slice()), + &first_gs, + draw_prob, + ); + + let mut fitted_first_goalscorer_probs = Vec::with_capacity(first_gs.outcomes.len()); + for (player, prob) in &fitted_goalscorer_probs { + let exploration = explore( + &IntervalConfig { + intervals: INTERVALS as u8, + team_probs: TeamProbs { + h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), + h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), + assists: UnivariateProbs { + home: 1.0, + away: 1.0, + }, + }, + player_probs: vec![( + player.clone(), + PlayerProbs { + goal: Some(*prob), + assist: None, + }, + )], + prune_thresholds: PruneThresholds { + max_total_goals: MAX_TOTAL_GOALS_FULL, + min_prob: GOALSCORER_MIN_PROB, + }, + expansions: Expansions { + ht_score: false, + ft_score: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, + first_goalscorer: true, + }, + }, + 0..INTERVALS as u8, + ); + let isolated_prob = isolate( + &OfferType::FirstGoalscorer, + &OutcomeType::Player(player.clone()), + &exploration.prospects, + &exploration.player_lookup, + ); + fitted_first_goalscorer_probs.push(isolated_prob); + // println!("first scorer {player:?}, prob: {isolated_prob:.3}"); + } + + // fitted_first_goalscorer_probs.push(draw_prob); + // fitted_first_goalscorer_probs.normalise(FIRST_GOALSCORER_BOOKSUM); + fitted_first_goalscorer_probs + .push(FIRST_GOALSCORER_BOOKSUM - fitted_first_goalscorer_probs.sum()); + + let fitted_first_goalscorer = Offer { + offer_type: OfferType::FirstGoalscorer, + outcomes: first_gs.outcomes.clone(), + market: Market::frame( + &first_gs.market.overround, + fitted_first_goalscorer_probs, + &SINGLE_PRICE_BOUNDS, + ), + }; + + if args.player_goals { + println!( + "sample first goalscorer σ={:.3}", + first_gs.market.offered_booksum(), + ); + let table_first_goalscorer = print::tabulate_offer(&fitted_first_goalscorer); + println!( + "{:?}: [Σ={:.3}, σ={:.3}, n={}]\n{}", + fitted_first_goalscorer.offer_type, + fitted_first_goalscorer.market.probs.sum(), + fitted_first_goalscorer.market.offered_booksum(), + fitted_first_goalscorer.market.probs.len(), + Console::default().render(&table_first_goalscorer) + ); + } + + let mut fitted_anytime_goalscorer_outcomes = + HashLookup::with_capacity(fitted_goalscorer_probs.len()); + let mut fitted_anytime_goalscorer_probs = Vec::with_capacity(fitted_goalscorer_probs.len()); + for (player, prob) in &fitted_goalscorer_probs { + fitted_anytime_goalscorer_outcomes.push(OutcomeType::Player(player.clone())); + let exploration = explore( + &IntervalConfig { + intervals: INTERVALS as u8, + team_probs: TeamProbs { + h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), + h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), + assists: UnivariateProbs { + home: 1.0, + away: 1.0, + }, + }, + player_probs: vec![( + player.clone(), + PlayerProbs { + goal: Some(*prob), + assist: None, + }, + )], + prune_thresholds: PruneThresholds { + max_total_goals: MAX_TOTAL_GOALS_FULL, + min_prob: GOALSCORER_MIN_PROB, + }, + expansions: Expansions { + ht_score: false, + ft_score: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, + first_goalscorer: false, + }, + }, + 0..INTERVALS as u8, + ); + let isolated_prob = isolate( + &OfferType::AnytimeGoalscorer, + &OutcomeType::Player(player.clone()), + &exploration.prospects, + &exploration.player_lookup, + ); + fitted_anytime_goalscorer_probs.push(isolated_prob); + } + fitted_anytime_goalscorer_outcomes.push(OutcomeType::None); + fitted_anytime_goalscorer_probs.push(draw_prob); + + let anytime_goalscorer_overround = Overround { + method: OVERROUND_METHOD, + value: anytime_gs.market.offered_booksum() / fitted_anytime_goalscorer_probs.sum(), + }; + let fitted_anytime_goalscorer = Offer { + offer_type: OfferType::AnytimeGoalscorer, + outcomes: fitted_anytime_goalscorer_outcomes, + market: Market::frame( + &anytime_goalscorer_overround, + fitted_anytime_goalscorer_probs, + &SINGLE_PRICE_BOUNDS, + ), + }; + + if args.player_goals { + println!( + "sample anytime goalscorer σ={:.3}", + anytime_gs.market.offered_booksum(), + ); + let table_anytime_goalscorer = print::tabulate_offer(&fitted_anytime_goalscorer); + println!( + "{:?}: [Σ={:.3}, σ={:.3}, n={}]\n{}", + fitted_anytime_goalscorer.offer_type, + fitted_anytime_goalscorer.market.probs.sum(), + fitted_anytime_goalscorer.market.offered_booksum(), + fitted_anytime_goalscorer.market.probs.len(), + Console::default().render(&table_anytime_goalscorer) + ); + } + + let sample_anytime_assist_booksum = anytime_assist + .values() + .map(|price| 1.0 / price) + .sum::(); + + let per_outcome_overround = + (anytime_goalscorer_overround.value - 1.0) / anytime_gs.outcomes.len() as f64; + + let anytime_assist = fit_offer( + OfferType::AnytimeAssist, + &anytime_assist, + sample_anytime_assist_booksum / (1.0 + per_outcome_overround * anytime_assist.len() as f64), + ); + + let home_goalscorer_booksum = home_booksum(&fitted_anytime_goalscorer); + let away_goalscorer_booksum = away_booksum(&fitted_anytime_goalscorer); + // println!("partial goalscorer booksums: home: {home_goalscorer_booksum:.3}, away: {away_goalscorer_booksum:.3}"); + + let home_assister_booksum = home_booksum(&anytime_assist); + let away_assister_booksum = away_booksum(&anytime_assist); + // println!("partial assister booksums: home: {home_assister_booksum:.3}, away: {away_assister_booksum:.3}"); + let assist_probs = UnivariateProbs { + home: home_assister_booksum / home_goalscorer_booksum, + away: away_assister_booksum / away_goalscorer_booksum, + }; + println!("assist_probs: {assist_probs:?}"); + + let fitted_assist_probs = fit::fit_anytime_assist_all( + &BivariateProbs::from(adj_optimal_h1.as_slice()), + &BivariateProbs::from(adj_optimal_h2.as_slice()), + &assist_probs, + &anytime_assist, + draw_prob, + anytime_assist.market.fair_booksum(), + ); + + let mut fitted_anytime_assist_outcomes = HashLookup::with_capacity(fitted_assist_probs.len()); + let mut fitted_anytime_assist_probs = Vec::with_capacity(fitted_assist_probs.len()); + for (player, prob) in &fitted_assist_probs { + fitted_anytime_assist_outcomes.push(OutcomeType::Player(player.clone())); + let exploration = explore( + &IntervalConfig { + intervals: INTERVALS as u8, + team_probs: TeamProbs { + h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), + h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), + assists: assist_probs.clone(), + }, + player_probs: vec![( + player.clone(), + PlayerProbs { + goal: None, + assist: Some(*prob), + }, + )], + prune_thresholds: PruneThresholds { + max_total_goals: MAX_TOTAL_GOALS_FULL, + min_prob: GOALSCORER_MIN_PROB, + }, + expansions: Expansions { + ht_score: false, + ft_score: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 1, + first_goalscorer: false, + }, + }, + 0..INTERVALS as u8, + ); + let isolated_prob = isolate( + &OfferType::AnytimeAssist, + &OutcomeType::Player(player.clone()), + &exploration.prospects, + &exploration.player_lookup, + ); + fitted_anytime_assist_probs.push(isolated_prob); + } + + let anytime_assist_overround = Overround { + method: OVERROUND_METHOD, + value: anytime_assist.market.offered_booksum() / fitted_anytime_assist_probs.sum(), + }; + fitted_anytime_assist_outcomes.push(OutcomeType::None); + fitted_anytime_assist_probs.push(draw_prob); + + let fitted_anytime_assist = Offer { + offer_type: OfferType::AnytimeAssist, + outcomes: fitted_anytime_assist_outcomes, + market: Market::frame( + &anytime_assist_overround, + fitted_anytime_assist_probs, + &SINGLE_PRICE_BOUNDS, + ), + }; + + if args.player_assists { + println!( + "sample anytime assists σ={:.3}", + anytime_assist.market.offered_booksum(), + ); + let table_anytime_assist = print::tabulate_offer(&fitted_anytime_assist); + println!( + "{:?}: [Σ={:.3}, σ={:.3}, n={}]\n{}", + fitted_anytime_assist.offer_type, + fitted_anytime_assist.market.probs.sum(), + fitted_anytime_assist.market.offered_booksum(), + fitted_anytime_assist.market.probs.len(), + Console::default().render(&table_anytime_assist) + ); + } + + let market_errors = [ + (&h1_h2h, &fitted_h1_h2h), + (&h1_goals, &fitted_h1_goals), + (&h2_h2h, &fitted_h2_h2h), + (&h2_goals, &fitted_h2_goals), + (&ft_h2h, &fitted_ft_h2h), + (&ft_goals, &fitted_ft_goals_ou), + (&ft_correct_score, &fitted_ft_correct_score), + (&first_gs, &fitted_first_goalscorer), + (&anytime_gs, &fitted_anytime_goalscorer), + (&anytime_assist, &fitted_anytime_assist), + ] + .iter() + .map(|(sample, fitted)| { + ( + &sample.offer_type, + FittingErrors { + rmse: fit::compute_error( + &sample.market.prices, + &fitted.market.prices, + &ErrorType::SquaredAbsolute, + ), + rmsre: fit::compute_error( + &sample.market.prices, + &fitted.market.prices, + &ErrorType::SquaredRelative, + ), + }, + ) + }) + .collect::>(); + let table_errors = print::tabulate_errors(&market_errors); + println!( + "Fitting errors:\n{}", + Console::default().render(&table_errors) + ); + + let fitted_markets = [ + fitted_h1_h2h, + fitted_h1_goals, + fitted_h2_h2h, + fitted_h2_goals, + fitted_ft_h2h, + fitted_ft_goals_ou, + fitted_ft_correct_score, + fitted_first_goalscorer, + fitted_anytime_goalscorer, + fitted_anytime_assist, + ]; + + let table_overrounds = print::tabulate_overrounds(&fitted_markets); + println!( + "Market overrounds:\n{}", + Console::default().render(&table_overrounds) + ); + + // let fitted_markets_hash = fitted_markets.iter().map(|market| (market.offer_type.clone(), market)).collect::>(); + // let h2h_sel = (OfferType::HeadToHead(Period::FullTime), OutcomeType::Win(Side::Home)); + // let tg_sel = (OfferType::TotalGoalsOverUnder(Period::FullTime, Over(2)), OutcomeType::Over(2)); + // let anytime_player = Named(Side::Home, "Gianluca Lapadula".into()); + // let anytime_sel = (OfferType::AnytimeGoalscorer, OutcomeType::Player(anytime_player.clone())); + // let player_prob = fitted_goalscorer_probs[&anytime_player]; + // let exploration = explore( + // &IntervalConfig { + // intervals: INTERVALS as u8, + // h1_probs: ScoringProbs::from(adj_optimal_h1.as_slice()), + // h2_probs: ScoringProbs::from(adj_optimal_h2.as_slice()), + // players: vec![ + // (anytime_player.clone(), player_prob) + // ], + // prune_thresholds: PruneThresholds { + // max_total_goals: MAX_TOTAL_GOALS_FULL, + // min_prob: 0.0, + // }, + // expansions: Expansions::default() + // }, + // 0..INTERVALS as u8, + // ); + // let selections = [h2h_sel, tg_sel, anytime_sel]; + // let overround = selections.iter().map(|(offer_type, outcome_type)| { + // let fitted_market = fitted_markets_hash[offer_type]; + // let outcome_index = fitted_market.outcomes.iter().position(|in_vec| in_vec == outcome_type).unwrap(); + // let outcome_prob = fitted_market.market.probs[outcome_index]; + // let outcome_price = fitted_market.market.prices[outcome_index]; + // 1.0 / outcome_prob / outcome_price + // }).product::(); + // let multi_prob = isolate_batch(&selections, &exploration.prospects, &exploration.player_lookup); + // let multi_price = 1.0 / multi_prob / overround; + // info!("selections: {selections:?}, prob: {multi_prob:.3}, overround: {overround:.3}, price: {multi_price:.3}"); + + Ok(()) +} + +// fn implied_booksum(prices: &[f64]) -> f64 { +// prices.invert().sum() +// } + +fn fit_offer(offer_type: OfferType, map: &HashMap, normal: f64) -> Offer { + let mut entries = map.iter().collect::>(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + let outcomes = entries + .iter() + .map(|(outcome, _)| (*outcome).clone()) + .collect::>(); + let prices = entries.iter().map(|(_, &price)| price).collect(); + let market = Market::fit(&OVERROUND_METHOD, prices, normal); + Offer { + offer_type, + outcomes: HashLookup::from(outcomes), + market, + } +} + +// fn fit_scoregrid_half(markets: &[&Offer]) -> HypergridSearchOutcome { +// let init_estimates = { +// let start = Instant::now(); +// let search_outcome = fit_bivariate_poisson_scoregrid(markets, MAX_TOTAL_GOALS_HALF); +// let elapsed = start.elapsed(); +// println!("biv-poisson: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", expectation_from_lambdas(&search_outcome.optimal_values)); +// search_outcome +// .optimal_values +// .iter() +// .map(|optimal_value| { +// 1.0 - poisson::univariate( +// 0, +// optimal_value / INTERVALS as f64 * 2.0, +// &factorial::Calculator, +// ) +// }) +// .collect::>() +// }; +// println!("initial estimates: {init_estimates:?}"); +// +// let start = Instant::now(); +// let search_outcome = fit_bivariate_binomial_scoregrid( +// markets, +// &init_estimates, +// (INTERVALS / 2) as u8, +// MAX_TOTAL_GOALS_HALF, +// ); +// let elapsed = start.elapsed(); +// println!("biv-binomial: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); +// search_outcome +// } + +// fn fit_bivariate_binomial_scoregrid( +// markets: &[&Offer], +// init_estimates: &[f64], +// intervals: u8, +// max_total_goals: u16, +// ) -> HypergridSearchOutcome { +// let mut scoregrid = allocate_scoregrid(max_total_goals); +// let bounds = init_estimates +// .iter() +// .map(|&estimate| (estimate * 0.67)..=(estimate * 1.5)) +// .collect::>(); +// hypergrid_search( +// &HypergridSearchConfig { +// max_steps: 10, +// acceptable_residual: 1e-6, +// bounds: bounds.into(), +// resolution: 10, +// }, +// |values| values.sum() <= 1.0, +// |values| { +// bivariate_binomial_scoregrid( +// intervals, +// values[0], +// values[1], +// values[2], +// &mut scoregrid, +// ); +// scoregrid_error(markets, &scoregrid) +// }, +// ) +// } +// +// fn fit_bivariate_poisson_scoregrid( +// markets: &[&Offer], +// max_total_goals: u16, +// ) -> HypergridSearchOutcome { +// let mut scoregrid = allocate_scoregrid(max_total_goals); +// hypergrid_search( +// &HypergridSearchConfig { +// max_steps: 10, +// acceptable_residual: 1e-6, +// bounds: vec![0.2..=3.0, 0.2..=3.0, 0.0..=0.5].into(), +// resolution: 10, +// }, +// |_| true, +// |values| { +// bivariate_poisson_scoregrid(values[0], values[1], values[2], &mut scoregrid); +// scoregrid_error(markets, &scoregrid) +// }, +// ) +// } +// +// fn scoregrid_error(offers: &[&Offer], scoregrid: &Matrix) -> f64 { +// let mut residual = 0.0; +// for offer in offers { +// for (index, outcome) in offer.outcomes.items().iter().enumerate() { +// let fitted_prob = outcome.gather(scoregrid); +// let sample_prob = offer.market.probs[index]; +// residual += ERROR_TYPE.calculate(sample_prob, fitted_prob); +// } +// } +// residual +// } + +// fn fit_first_goalscorer( +// h1_probs: &ScoringProbs, +// h2_probs: &ScoringProbs, +// player: &Player, +// init_estimate: f64, +// expected_prob: f64, +// ) -> UnivariateDescentOutcome { +// univariate_descent( +// &UnivariateDescentConfig { +// init_value: init_estimate, +// init_step: init_estimate * 0.1, +// min_step: init_estimate * 0.001, +// max_steps: 100, +// acceptable_residual: 1e-9, +// }, +// |value| { +// let exploration = explore( +// &IntervalConfig { +// intervals: INTERVALS as u8, +// h1_probs: h1_probs.clone(), +// h2_probs: h2_probs.clone(), +// players: vec![(player.clone(), value)], +// prune_thresholds: PruneThresholds { +// max_total_goals: MAX_TOTAL_GOALS_FULL, +// min_prob: GOALSCORER_MIN_PROB, +// }, +// expansions: Expansions { +// ht_score: false, +// ft_score: false, +// player_stats: false, +// player_split_stats: false, +// first_goalscorer: true, +// }, +// }, +// 0..INTERVALS as u8, +// ); +// let isolated_prob = isolate( +// &OfferType::FirstGoalscorer, +// &OutcomeType::Player(player.clone()), +// &exploration.prospects, +// &exploration.player_lookup, +// ); +// ERROR_TYPE.calculate(expected_prob, isolated_prob) +// }, +// ) +// } + +// fn expectation_from_lambdas(lambdas: &[f64]) -> f64 { +// assert_eq!(3, lambdas.len()); +// lambdas[0] + lambdas[1] + 2.0 * lambdas[2] +// } + +// /// Intervals. +// fn interval_scoregrid( +// explore_intervals: Range, +// max_total_goals: u16, +// h1_probs: ScoringProbs, +// h2_probs: ScoringProbs, +// scoregrid: &mut Matrix, +// ) { +// scoregrid.fill(0.0); +// scoregrid::from_interval( +// INTERVALS as u8, +// explore_intervals, +// max_total_goals, +// h1_probs, +// h2_probs, +// scoregrid, +// ); +// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); +// } + +fn explore_scores(h1_goals: BivariateProbs, h2_goals: BivariateProbs) -> Exploration { + explore( + &IntervalConfig { + intervals: INTERVALS as u8, + team_probs: TeamProbs { + h1_goals, + h2_goals, + assists: UnivariateProbs { + home: 1.0, + away: 1.0, + }, + }, + player_probs: vec![], + prune_thresholds: PruneThresholds { + max_total_goals: MAX_TOTAL_GOALS_FULL, + min_prob: 0.0, + }, + expansions: Expansions { + ht_score: true, + ft_score: true, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, + first_goalscorer: false, + }, + }, + 0..INTERVALS as u8, + ) +} + +// /// Binomial. +// fn binomial_scoregrid( +// intervals: u8, +// interval_home_prob: f64, +// interval_away_prob: f64, +// scoregrid: &mut Matrix, +// ) { +// scoregrid.fill(0.0); +// scoregrid::from_binomial(intervals, interval_home_prob, interval_away_prob, scoregrid); +// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); +// } +// +// /// Bivariate binomial. +// fn bivariate_binomial_scoregrid( +// intervals: u8, +// interval_home_prob: f64, +// interval_away_prob: f64, +// interval_common_prob: f64, +// scoregrid: &mut Matrix, +// ) { +// scoregrid.fill(0.0); +// scoregrid::from_bivariate_binomial( +// intervals, +// interval_home_prob, +// interval_away_prob, +// interval_common_prob, +// scoregrid, +// ); +// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); +// } +// +// /// Independent Poisson. +// fn univariate_poisson_scoregrid(home_rate: f64, away_rate: f64, scoregrid: &mut Matrix) { +// scoregrid.fill(0.0); +// scoregrid::from_univariate_poisson(home_rate, away_rate, scoregrid); +// } +// +// /// Bivariate Poisson. +// fn bivariate_poisson_scoregrid( +// home_rate: f64, +// away_rate: f64, +// common: f64, +// scoregrid: &mut Matrix, +// ) { +// scoregrid.fill(0.0); +// scoregrid::from_bivariate_poisson(home_rate, away_rate, common, scoregrid); +// // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); +// } +// +// fn correct_score_scoregrid(correct_score: &Offer, scoregrid: &mut Matrix) { +// scoregrid.fill(0.0); +// from_correct_score( +// correct_score.outcomes.items(), +// &correct_score.market.probs, +// scoregrid, +// ); +// } +// +// fn allocate_scoregrid(max_total_goals: u16) -> Matrix { +// let dim = usize::min(max_total_goals as usize, INTERVALS) + 1; +// Matrix::allocate(dim, dim) +// } +// +// fn frame_prices_from_scoregrid( +// scoregrid: &Matrix, +// outcomes: &[OutcomeType], +// overround: &Overround, +// ) -> Market { +// let mut probs = outcomes +// .iter() +// .map(|outcome_type| outcome_type.gather(scoregrid)) +// .map(|prob| f64::max(0.0001, prob)) +// .collect::>(); +// probs.normalise(1.0); +// Market::frame(overround, probs, &SINGLE_PRICE_BOUNDS) +// } + +fn frame_prices_from_exploration( + exploration: &Exploration, + offer_type: &OfferType, + outcomes: &[OutcomeType], + normal: f64, + overround: &Overround, +) -> Offer { + let mut probs = outcomes + .iter() + .map(|outcome_type| { + isolate( + offer_type, + outcome_type, + &exploration.prospects, + &exploration.player_lookup, + ) + }) + // .map(|prob| f64::max(1e-6, prob)) + .collect::>(); + probs.normalise(normal); + let market = Market::frame(overround, probs, &SINGLE_PRICE_BOUNDS); + Offer { + offer_type: offer_type.clone(), + outcomes: HashLookup::from(outcomes.to_vec()), + market, + } +} + +async fn read_contest_data(args: &Args) -> anyhow::Result { + let contest = { + if let Some(_) = args.file.as_ref() { + //ContestModel::read_json_file(path)? + unimplemented!() + } else if let Some(id) = args.download.as_ref() { + download_by_id(id.clone()).await? + } else { + unreachable!() + } + }; + Ok(contest.into()) +} diff --git a/brumby-soccer/src/data.rs b/brumby-soccer/src/data.rs index 40e0d14..9054c98 100644 --- a/brumby-soccer/src/data.rs +++ b/brumby-soccer/src/data.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::fmt::{Display, Formatter}; use std::str::FromStr; use racing_scraper::sports::{get_sports_contest, Provider}; +use rustc_hash::FxHashMap; use thiserror::Error; use brumby::feed_id::FeedId; @@ -12,14 +13,14 @@ use brumby::feed_id::FeedId; pub struct ContestSummary { pub id: String, pub name: String, - pub offerings: HashMap>, + pub offerings: FxHashMap>, } impl From for ContestSummary { fn from(external: ContestModel) -> Self { let id = external.id; let name = external.name; - let mut offerings = HashMap::with_capacity(external.markets.len()); + let mut offerings = FxHashMap::with_capacity_and_hasher(external.markets.len(), Default::default()); for market in external.markets { match market { SoccerMarket::CorrectScore(markets) => { diff --git a/brumby-soccer/src/domain.rs b/brumby-soccer/src/domain.rs index a30d8b7..b07ba24 100644 --- a/brumby-soccer/src/domain.rs +++ b/brumby-soccer/src/domain.rs @@ -52,6 +52,32 @@ pub enum OfferType { PlayerShotsOnTarget(Over), AnytimeAssist } +impl OfferType { + pub fn category(&self) -> OfferCategory { + match self { + OfferType::HeadToHead(_) => OfferCategory::HeadToHead, + OfferType::TotalGoals(_, _) => OfferCategory::TotalGoals, + OfferType::CorrectScore(_) => OfferCategory::CorrectScore, + OfferType::DrawNoBet => OfferCategory::DrawNoBet, + OfferType::AnytimeGoalscorer => OfferCategory::AnytimeGoalscorer, + OfferType::FirstGoalscorer => OfferCategory::FirstGoalscorer, + OfferType::PlayerShotsOnTarget(_) => OfferCategory::PlayerShotsOnTarget, + OfferType::AnytimeAssist => OfferCategory::AnytimeAssist + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub enum OfferCategory { + HeadToHead, + TotalGoals, + CorrectScore, + DrawNoBet, + AnytimeGoalscorer, + FirstGoalscorer, + PlayerShotsOnTarget, + AnytimeAssist +} #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] pub enum Side { diff --git a/brumby-soccer/src/domain/error.rs b/brumby-soccer/src/domain/error.rs index a26ece9..b60dc14 100644 --- a/brumby-soccer/src/domain/error.rs +++ b/brumby-soccer/src/domain/error.rs @@ -1,25 +1,24 @@ -use crate::domain::{Offer, OfferType, OutcomeType}; +use crate::domain::{Offer, OfferType, OutcomeType, Player}; use brumby::hash_lookup::HashLookup; use brumby::probs::SliceExt; use std::fmt::{Display, Formatter}; use std::ops::RangeInclusive; +use rustc_hash::FxHashMap; use thiserror::Error; +use crate::interval::PlayerProbs; mod head_to_head; mod total_goals; #[derive(Debug, Error)] pub enum InvalidOffer { - #[error("misaligned offer: {0}")] + #[error("{0}")] MisalignedOffer(#[from] MisalignedOffer), #[error("{0}")] - MissingOutcome(#[from] MissingOutcome), + InvalidOutcome(#[from] InvalidOutcome), #[error("{0}")] - ExtraneousOutcome(#[from] ExtraneousOutcome), - - #[error("wrong booksum: {0}")] WrongBooksum(#[from] WrongBooksum), } @@ -30,16 +29,47 @@ impl Offer { &self.market.probs, &self.offer_type, )?; + self.offer_type.validate(&self.outcomes)?; match self.offer_type { - OfferType::TotalGoals(_, _) => total_goals::validate(self), - OfferType::HeadToHead(_) => head_to_head::validate(self), + OfferType::TotalGoals(_, _) => total_goals::validate_probs(&self.offer_type, &self.market.probs), + OfferType::HeadToHead(_) => head_to_head::validate_probs(&self.offer_type, &self.market.probs), + _ => Ok(()), + } + } +} + + +#[derive(Debug, Error)] +pub enum InvalidOutcome { + #[error("{0}")] + MissingOutcome(#[from] MissingOutcome), + + #[error("{0}")] + ExtraneousOutcome(#[from] ExtraneousOutcome), +} + +impl OfferType { + pub fn validate(&self, outcomes: &HashLookup) -> Result<(), InvalidOutcome> { + match self { + OfferType::TotalGoals(_, _) => total_goals::validate_outcomes(self, outcomes), + OfferType::HeadToHead(_) => head_to_head::validate_outcomes(self, outcomes), _ => Ok(()), } } + + pub fn create_outcomes(&self, player_probs: &FxHashMap) { + todo!() + } } #[derive(Debug, Error)] -#[error("expected {assertion}, got {actual} for {offer_type:?}")] +pub enum FitError { + #[error("missing offer {0:?}")] + MissingOffer(OfferType), +} + +#[derive(Debug, Error)] +#[error("expected booksum in {assertion}, got {actual} for {offer_type:?}")] pub struct WrongBooksum { pub assertion: BooksumAssertion, pub actual: f64, @@ -166,23 +196,23 @@ impl bool> OutcomesMatchAssertion { } } -#[derive(Debug, Error)] -pub enum IncompleteOutcomes { - #[error("{0}")] - MissingOutcome(#[from] MissingOutcome), - - #[error("{0}")] - ExtraneousOutcome(#[from] ExtraneousOutcome), -} - -impl From for InvalidOffer { - fn from(value: IncompleteOutcomes) -> Self { - match value { - IncompleteOutcomes::MissingOutcome(nested) => nested.into(), - IncompleteOutcomes::ExtraneousOutcome(nested) => nested.into(), - } - } -} +// #[derive(Debug, Error)] +// pub enum IncompleteOutcomes { +// #[error("{0}")] +// MissingOutcome(#[from] MissingOutcome), +// +// #[error("{0}")] +// ExtraneousOutcome(#[from] ExtraneousOutcome), +// } +// +// impl From for InvalidOutcome { +// fn from(value: IncompleteOutcomes) -> Self { +// match value { +// IncompleteOutcomes::MissingOutcome(nested) => nested.into(), +// IncompleteOutcomes::ExtraneousOutcome(nested) => nested.into(), +// } +// } +// } #[derive(Debug)] pub struct OutcomesCompleteAssertion<'a> { @@ -193,7 +223,7 @@ impl<'a> OutcomesCompleteAssertion<'a> { &self, outcomes: &HashLookup, offer_type: &OfferType, - ) -> Result<(), IncompleteOutcomes> { + ) -> Result<(), InvalidOutcome> { OutcomesIntactAssertion { outcomes: self.outcomes, } @@ -228,7 +258,7 @@ mod tests { market: Market::frame(&Overround::fair(), vec![0.4], &PRICE_BOUNDS), }; assert_eq!( - "misaligned offer: 2:1 outcomes:probabilities mapped for TotalGoals(FullTime, Over(2))", + "2:1 outcomes:probabilities mapped for TotalGoals(FullTime, Over(2))", offer.validate().unwrap_err().to_string() ); } @@ -258,7 +288,7 @@ mod tests { .check(&[1.0 - 0.011], &OfferType::FirstGoalscorer) .unwrap_err(); assert_eq!( - "expected 1.0..=2.0 ± 0.01, got 0.989 for FirstGoalscorer", + "expected booksum in 1.0..=2.0 ± 0.01, got 0.989 for FirstGoalscorer", err.to_string() ); } @@ -267,7 +297,7 @@ mod tests { .check(&[2.0 + 0.011], &OfferType::FirstGoalscorer) .unwrap_err(); assert_eq!( - "expected 1.0..=2.0 ± 0.01, got 2.011 for FirstGoalscorer", + "expected booksum in 1.0..=2.0 ± 0.01, got 2.011 for FirstGoalscorer", err.to_string() ); } diff --git a/brumby-soccer/src/domain/error/head_to_head.rs b/brumby-soccer/src/domain/error/head_to_head.rs index 264a434..5d0cc53 100644 --- a/brumby-soccer/src/domain/error/head_to_head.rs +++ b/brumby-soccer/src/domain/error/head_to_head.rs @@ -1,21 +1,42 @@ -use crate::domain::{error, Offer, OfferType, OutcomeType, Side}; -use crate::domain::error::InvalidOffer; +use brumby::hash_lookup::HashLookup; -pub fn validate(offer: &Offer) -> Result<(), InvalidOffer> { - match &offer.offer_type { +use crate::domain::{error, OfferType, OutcomeType, Side}; +use crate::domain::error::{InvalidOffer, InvalidOutcome}; + +pub fn validate_outcomes( + offer_type: &OfferType, + outcomes: &HashLookup, +) -> Result<(), InvalidOutcome> { + match offer_type { OfferType::HeadToHead(_) => { - error::BooksumAssertion::with_default_tolerance(1.0..=1.0) - .check(&offer.market.probs, &offer.offer_type)?; error::OutcomesCompleteAssertion { - outcomes: &[OutcomeType::Win(Side::Home), OutcomeType::Win(Side::Away), OutcomeType::Draw], + outcomes: &create_outcomes(), } - .check(&offer.outcomes, &offer.offer_type)?; + .check(outcomes, offer_type)?; Ok(()) } - _ => panic!("{:?} unsupported", offer.offer_type), + _ => unreachable!(), } } +pub fn validate_probs(offer_type: &OfferType, probs: &[f64]) -> Result<(), InvalidOffer> { + match offer_type { + OfferType::HeadToHead(_) => { + error::BooksumAssertion::with_default_tolerance(1.0..=1.0).check(probs, offer_type)?; + Ok(()) + } + _ => unreachable!(), + } +} + +pub fn create_outcomes() -> [OutcomeType; 3] { + [ + OutcomeType::Win(Side::Home), + OutcomeType::Win(Side::Away), + OutcomeType::Draw, + ] +} + #[cfg(test)] mod tests { use std::ops::RangeInclusive; @@ -23,7 +44,7 @@ mod tests { use brumby::hash_lookup::HashLookup; use brumby::market::{Market, Overround}; - use crate::domain::{Over, Period}; + use crate::domain::{Offer, Period}; use super::*; @@ -34,7 +55,11 @@ mod tests { fn valid() { let offer = Offer { offer_type: OFFER_TYPE, - outcomes: HashLookup::from(vec![OutcomeType::Win(Side::Home), OutcomeType::Win(Side::Away), OutcomeType::Draw]), + outcomes: HashLookup::from(vec![ + OutcomeType::Win(Side::Home), + OutcomeType::Win(Side::Away), + OutcomeType::Draw, + ]), market: Market::frame(&Overround::fair(), vec![0.4, 0.4, 0.2], &PRICE_BOUNDS), }; offer.validate().unwrap(); @@ -44,29 +69,54 @@ mod tests { fn wrong_booksum() { let offer = Offer { offer_type: OFFER_TYPE, - outcomes: HashLookup::from(vec![OutcomeType::Win(Side::Home), OutcomeType::Win(Side::Away), OutcomeType::Draw]), + outcomes: HashLookup::from(vec![ + OutcomeType::Win(Side::Home), + OutcomeType::Win(Side::Away), + OutcomeType::Draw, + ]), market: Market::frame(&Overround::fair(), vec![0.4, 0.4, 0.1], &PRICE_BOUNDS), }; - assert_eq!("wrong booksum: expected 1.0..=1.0 ± 0.000001, got 0.9 for HeadToHead(FullTime)", offer.validate().unwrap_err().to_string()); + assert_eq!( + "expected booksum in 1.0..=1.0 ± 0.000001, got 0.9 for HeadToHead(FullTime)", + offer.validate().unwrap_err().to_string() + ); } #[test] fn missing_outcome() { let offer = Offer { offer_type: OFFER_TYPE, - outcomes: HashLookup::from(vec![OutcomeType::Win(Side::Home), OutcomeType::Win(Side::Away)]), + outcomes: HashLookup::from(vec![ + OutcomeType::Win(Side::Home), + OutcomeType::Win(Side::Away), + ]), market: Market::frame(&Overround::fair(), vec![0.4, 0.6], &PRICE_BOUNDS), }; - assert_eq!("Draw missing from HeadToHead(FullTime)", offer.validate().unwrap_err().to_string()); + assert_eq!( + "Draw missing from HeadToHead(FullTime)", + offer.validate().unwrap_err().to_string() + ); } #[test] fn extraneous_outcome() { let offer = Offer { offer_type: OFFER_TYPE, - outcomes: HashLookup::from(vec![OutcomeType::Win(Side::Home), OutcomeType::Win(Side::Away), OutcomeType::Draw, OutcomeType::None]), - market: Market::frame(&Overround::fair(), vec![0.4, 0.5, 0.05, 0.05], &PRICE_BOUNDS), + outcomes: HashLookup::from(vec![ + OutcomeType::Win(Side::Home), + OutcomeType::Win(Side::Away), + OutcomeType::Draw, + OutcomeType::None, + ]), + market: Market::frame( + &Overround::fair(), + vec![0.4, 0.5, 0.05, 0.05], + &PRICE_BOUNDS, + ), }; - assert_eq!("None does not belong in HeadToHead(FullTime)", offer.validate().unwrap_err().to_string()); + assert_eq!( + "None does not belong in HeadToHead(FullTime)", + offer.validate().unwrap_err().to_string() + ); } } diff --git a/brumby-soccer/src/domain/error/total_goals.rs b/brumby-soccer/src/domain/error/total_goals.rs index cbac7c8..73b92c6 100644 --- a/brumby-soccer/src/domain/error/total_goals.rs +++ b/brumby-soccer/src/domain/error/total_goals.rs @@ -1,21 +1,47 @@ -use crate::domain::{error, Offer, OfferType, OutcomeType}; -use crate::domain::error::InvalidOffer; +use brumby::hash_lookup::HashLookup; -pub fn validate(offer: &Offer) -> Result<(), InvalidOffer> { - match &offer.offer_type { +use crate::domain::error::{InvalidOffer, InvalidOutcome}; +use crate::domain::{error, OfferType, OutcomeType, Over}; + +pub fn validate_outcomes( + offer_type: &OfferType, + outcomes: &HashLookup, +) -> Result<(), InvalidOutcome> { + match offer_type { OfferType::TotalGoals(_, over) => { - error::BooksumAssertion::with_default_tolerance(1.0..=1.0) - .check(&offer.market.probs, &offer.offer_type)?; error::OutcomesCompleteAssertion { - outcomes: &[OutcomeType::Over(over.0), OutcomeType::Under(over.0 + 1)], + outcomes: &_create_outcomes(over), } - .check(&offer.outcomes, &offer.offer_type)?; + .check(outcomes, offer_type)?; + Ok(()) + } + _ => unreachable!(), + } +} + +pub fn validate_probs(offer_type: &OfferType, probs: &[f64]) -> Result<(), InvalidOffer> { + match offer_type { + OfferType::TotalGoals(_, _) => { + error::BooksumAssertion::with_default_tolerance(1.0..=1.0).check(probs, offer_type)?; Ok(()) } - _ => panic!("{:?} unsupported", offer.offer_type), + _ => unreachable!(), } } +pub fn create_outcomes(offer_type: &OfferType) -> [OutcomeType; 2] { + match offer_type { + OfferType::TotalGoals(_, over) => _create_outcomes(over), + _ => unreachable!(), + } +} + +fn _create_outcomes(over: &Over) -> [OutcomeType; 2] { + [ + OutcomeType::Over(over.0), OutcomeType::Under(over.0 + 1), + ] +} + #[cfg(test)] mod tests { use std::ops::RangeInclusive; @@ -23,7 +49,7 @@ mod tests { use brumby::hash_lookup::HashLookup; use brumby::market::{Market, Overround}; - use crate::domain::{Over, Period}; + use crate::domain::{Offer, Over, Period}; use super::*; @@ -47,7 +73,10 @@ mod tests { outcomes: HashLookup::from(vec![OutcomeType::Over(2), OutcomeType::Under(3)]), market: Market::frame(&Overround::fair(), vec![0.4, 0.5], &PRICE_BOUNDS), }; - assert_eq!("wrong booksum: expected 1.0..=1.0 ± 0.000001, got 0.9 for TotalGoals(FullTime, Over(2))", offer.validate().unwrap_err().to_string()); + assert_eq!( + "expected booksum in 1.0..=1.0 ± 0.000001, got 0.9 for TotalGoals(FullTime, Over(2))", + offer.validate().unwrap_err().to_string() + ); } #[test] @@ -57,16 +86,26 @@ mod tests { outcomes: HashLookup::from(vec![OutcomeType::Over(2)]), market: Market::frame(&Overround::fair(), vec![1.0], &PRICE_BOUNDS), }; - assert_eq!("Under(3) missing from TotalGoals(FullTime, Over(2))", offer.validate().unwrap_err().to_string()); + assert_eq!( + "Under(3) missing from TotalGoals(FullTime, Over(2))", + offer.validate().unwrap_err().to_string() + ); } #[test] fn extraneous_outcome() { let offer = Offer { offer_type: OFFER_TYPE, - outcomes: HashLookup::from(vec![OutcomeType::Over(2), OutcomeType::Under(3), OutcomeType::None]), + outcomes: HashLookup::from(vec![ + OutcomeType::Over(2), + OutcomeType::Under(3), + OutcomeType::None, + ]), market: Market::frame(&Overround::fair(), vec![0.4, 0.5, 0.1], &PRICE_BOUNDS), }; - assert_eq!("None does not belong in TotalGoals(FullTime, Over(2))", offer.validate().unwrap_err().to_string()); + assert_eq!( + "None does not belong in TotalGoals(FullTime, Over(2))", + offer.validate().unwrap_err().to_string() + ); } } diff --git a/brumby-soccer/src/fit.rs b/brumby-soccer/src/fit.rs index 48a02b1..de221bf 100644 --- a/brumby-soccer/src/fit.rs +++ b/brumby-soccer/src/fit.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::time::Instant; +use tracing::debug; use brumby::capture::Capture; use brumby::linear::matrix::Matrix; @@ -101,12 +102,15 @@ pub fn away_booksum(offer: &Offer) -> f64 { // search_outcome // } -pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { +pub fn fit_scoregrid_half( + home_goals_estimate: f64, + away_goals_estimate: f64, + offers: &[&Offer]) -> HypergridSearchOutcome { let init_estimates = { let start = Instant::now(); - let search_outcome = fit_univariate_poisson_scoregrid(offers, MAX_TOTAL_GOALS_HALF); + let search_outcome = fit_univariate_poisson_scoregrid(home_goals_estimate, away_goals_estimate, offers, MAX_TOTAL_GOALS_HALF); let elapsed = start.elapsed(); - println!("biv-poisson: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", expectation_from_univariate_poisson(&search_outcome.optimal_values)); + debug!("fitted univariate Poisson: took {elapsed:?}, search outcome: {search_outcome:?}, expectation: {:.3}", expectation_from_univariate_poisson(&search_outcome.optimal_values)); search_outcome .optimal_values .iter() @@ -140,12 +144,12 @@ pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { // // pub fn fit_scoregrid_full(offers: &[&Offer]) -> HypergridSearchOutcome { // let init_estimates = { -// println!("*** F/T: fitting bivariate poisson scoregrid ***"); +// println!("*** f/t: fitting bivariate poisson scoregrid ***"); // let start = Instant::now(); // let search_outcome = fit_bivariate_poisson_scoregrid(offers, MAX_TOTAL_GOALS_FULL); // let elapsed = start.elapsed(); // println!( -// "F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", +// "f/t: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", // expectation_from_bivariate_poisson(&search_outcome.optimal_values) // ); // search_outcome @@ -160,7 +164,7 @@ pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { // }) // .collect::>() // }; -// println!("F/T: initial estimates: {init_estimates:?}"); +// println!("f/t: initial estimates: {init_estimates:?}"); // // // HypergridSearchOutcome { // // steps: 0, @@ -168,7 +172,7 @@ pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { // // optimal_residual: 0.0, // // } // -// println!("*** F/T: fitting bivariate binomial scoregrid ***"); +// println!("*** f/t: fitting bivariate binomial scoregrid ***"); // let start = Instant::now(); // let search_outcome = fit_bivariate_binomial_scoregrid( // offers, @@ -178,34 +182,35 @@ pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { // ); // // let search_outcome = fit_scoregrid(&[&correct_score]); // let elapsed = start.elapsed(); -// println!("F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); +// println!("f/t: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); // search_outcome // } -pub fn fit_scoregrid_full(h2h: &Offer, total_goals: &Offer) -> HypergridSearchOutcome { +pub fn fit_scoregrid_full(h2h: &Offer, total_goals: &Offer) -> (HypergridSearchOutcome, Vec) { let expected_total_goals_per_side = { - println!("*** F/T: fitting poisson total goals ***"); let start = Instant::now(); + let init_estimate = match &total_goals.offer_type { + OfferType::TotalGoals(_, over) => over.0 as f64 + 0.5, + _ => unreachable!() + }; let search_outcome = - fit_poisson_total_goals_scoregrid(2.5, total_goals, MAX_TOTAL_GOALS_FULL); + fit_poisson_total_goals_scoregrid(init_estimate, total_goals, MAX_TOTAL_GOALS_FULL); let elapsed = start.elapsed(); - println!("F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); + debug!("fitted f/t Poisson total goals ({init_estimate:.1}): took {elapsed:?}, {search_outcome:?}"); search_outcome.optimal_value }; let expected_home_goals = { - println!("*** F/T: fitting poisson h2h ***"); let start = Instant::now(); let search_outcome = fit_poisson_h2h_scoregrid(expected_total_goals_per_side, h2h, MAX_TOTAL_GOALS_FULL); let elapsed = start.elapsed(); - println!("F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); + debug!("fitted f/t Poisson goals per side: took {elapsed:?}, {search_outcome:?}"); search_outcome.optimal_value }; let offers = &[h2h, total_goals]; let expected_common_goals = { - println!("*** F/T: fitting poisson common ***"); let start = Instant::now(); let search_outcome = fit_poisson_common_scoregrid( expected_home_goals, @@ -214,7 +219,7 @@ pub fn fit_scoregrid_full(h2h: &Offer, total_goals: &Offer) -> HypergridSearchOu MAX_TOTAL_GOALS_FULL, ); let elapsed = start.elapsed(); - println!("F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); + debug!("fitted f/t Poisson common goals: took {elapsed:?}, {search_outcome:?}"); search_outcome.optimal_value }; @@ -226,25 +231,24 @@ pub fn fit_scoregrid_full(h2h: &Offer, total_goals: &Offer) -> HypergridSearchOu // }) // .collect::>(); - let init_estimates = { - println!("*** F/T: fitting bivariate poisson scoregrid ***"); + let (init_estimates, lambdas) = { let start = Instant::now(); let expected_away_goals = 2.0 * expected_total_goals_per_side - expected_home_goals; let search_outcome = fit_bivariate_poisson_scoregrid(offers, expected_home_goals, expected_away_goals, expected_common_goals, MAX_TOTAL_GOALS_FULL); let elapsed = start.elapsed(); - println!( - "F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", + debug!( + "fitted f/t bivariate Poisson: took {elapsed:?}, {search_outcome:?}, expectation: {:.3}", expectation_from_bivariate_poisson(&search_outcome.optimal_values) ); - search_outcome + (search_outcome .optimal_values .iter() .map(|optimal_value| { 1.0 - poisson::univariate(0, optimal_value / INTERVALS as f64, &factorial::Calculator) }) - .collect::>() + .collect::>(), search_outcome.optimal_values) }; - println!("F/T: initial estimates: {init_estimates:?}"); + println!("f/t: initial estimates: {init_estimates:?}"); // HypergridSearchOutcome { // steps: 0, @@ -252,7 +256,6 @@ pub fn fit_scoregrid_full(h2h: &Offer, total_goals: &Offer) -> HypergridSearchOu // optimal_residual: 0.0, // } - println!("*** F/T: fitting bivariate binomial scoregrid ***"); let start = Instant::now(); let search_outcome = fit_bivariate_binomial_scoregrid( offers, @@ -262,8 +265,8 @@ pub fn fit_scoregrid_full(h2h: &Offer, total_goals: &Offer) -> HypergridSearchOu ); // let search_outcome = fit_scoregrid(&[&correct_score]); let elapsed = start.elapsed(); - println!("F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); - search_outcome + debug!("fitted f/t bivariate binomial: took {elapsed:?}, {search_outcome:?}"); + (search_outcome, lambdas) } pub fn fit_first_goalscorer_all( @@ -539,8 +542,8 @@ fn fit_poisson_h2h_scoregrid( } fn fit_poisson_common_scoregrid( - home_goals: f64, - away_goals: f64, + home_goals_estimate: f64, + away_goals_estimate: f64, h2h: &Offer, max_total_goals: u16, ) -> UnivariateDescentOutcome { @@ -553,7 +556,7 @@ fn fit_poisson_common_scoregrid( max_steps: 100, acceptable_residual: 1e-6, }, |value| { - bivariate_poisson_scoregrid(home_goals - value, away_goals - value, value, &mut scoregrid); + bivariate_poisson_scoregrid(home_goals_estimate - value, away_goals_estimate - value, value, &mut scoregrid); scoregrid_error(&offers, &scoregrid) }) // let bounds = [0.01..=0.5]; @@ -578,11 +581,14 @@ fn fit_poisson_common_scoregrid( } fn fit_univariate_poisson_scoregrid( + home_goals_estimate: f64, + away_goals_estimate: f64, offers: &[&Offer], max_total_goals: u16, ) -> HypergridSearchOutcome { let mut scoregrid = allocate_scoregrid(max_total_goals); - let bounds = [0.2..=3.0, 0.2..=3.0]; + let bounds = [home_goals_estimate * 0.83..=home_goals_estimate * 1.2, away_goals_estimate * 0.83..=away_goals_estimate * 1.20]; + // let bounds = [0.2..=3.0, 0.2..=3.0]; hypergrid_search( &HypergridSearchConfig { max_steps: 10, diff --git a/brumby-soccer/src/interval.rs b/brumby-soccer/src/interval.rs index 66a058c..6bb6002 100644 --- a/brumby-soccer/src/interval.rs +++ b/brumby-soccer/src/interval.rs @@ -146,7 +146,7 @@ pub struct IntervalConfig { pub expansions: Expansions, } -#[derive(Debug)] +#[derive(Debug, Default)] pub struct PlayerProbs { pub goal: Option, pub assist: Option, diff --git a/brumby-soccer/src/model.rs b/brumby-soccer/src/model.rs index 4fdac89..7bc26f9 100644 --- a/brumby-soccer/src/model.rs +++ b/brumby-soccer/src/model.rs @@ -1,19 +1,53 @@ -use std::error::Error; use rustc_hash::FxHashMap; +use std::collections::hash_map::Entry; use thiserror::Error; +use brumby::hash_lookup::HashLookup; -use crate::domain::{Offer, OfferType, Player}; -use crate::interval::{PlayerProbs, BivariateProbs}; +use crate::domain::{Offer, OfferCategory, OfferType, OutcomeType, Player}; +use crate::domain::error::{InvalidOffer, InvalidOutcome}; +use crate::interval::{BivariateProbs, PlayerProbs}; pub mod score_fitter; #[derive(Debug, Error)] pub enum FitError { - #[error("missing offer {0:?}")] - MissingOffer(OfferType), + #[error("{0}")] + MissingOffer(#[from] MissingOffer), + // #[error("other: {0}")] + // Other(#[from] Box) +} + +#[derive(Debug, Error)] +pub enum MissingOffer { + #[error("missing type {0:?}")] + Type(OfferType), + + #[error("missing category {0:?}")] + Category(OfferCategory), +} + +#[derive(Debug, Error)] +pub enum DerivationError { + #[error("{0}")] + UnmetPrerequisite(#[from] UnmetPrerequisite), + + #[error("{0}")] + InvalidOutcome(#[from] InvalidOutcome) +} + +#[derive(Debug, Error)] +pub enum UnmetPrerequisite { + #[error("missing team goal probabilities")] + TeamGoalProbabilities, + + #[error("missing team assist probabilities")] + TeamAssistProbabilities, + + #[error("missing goal probability for {0:?}")] + PlayerGoalProbability(Player), - #[error("other: {0}")] - Other(#[from] Box) + #[error("missing assist probability for {0:?}")] + PlayerAssistProbability(Player), } #[derive(Debug)] @@ -22,12 +56,24 @@ pub struct GoalProbs { pub h2: BivariateProbs, } +#[derive(Debug)] +pub struct Derivation { + offer_type: OfferType, + outcomes: OutcomeSet +} + +#[derive(Debug)] +pub enum OutcomeSet { + All, + Specific( HashLookup) +} + #[derive(Debug)] pub struct Model { pub goal_probs: Option, pub assist_probs: Option, pub player_probs: FxHashMap, - pub offers: FxHashMap + pub offers: FxHashMap, } impl Model { pub fn new() -> Self { @@ -38,4 +84,34 @@ impl Model { offers: Default::default(), } } -} \ No newline at end of file + + pub fn derive(&mut self, derivations: &[Derivation]) -> Result<(), DerivationError> { + for derivation in derivations { + let outcomes = match &derivation.outcomes { + OutcomeSet::All => { + todo!() + } + OutcomeSet::Specific(outcomes) => { + derivation.offer_type.validate(outcomes)?; + outcomes + } + }; + } + todo!() + } + + fn get_or_create_player(&mut self, player: Player) -> &mut PlayerProbs { + match self.player_probs.entry(player) { + Entry::Occupied(entry) => { + entry.into_mut() + } + Entry::Vacant(entry) => { + entry.insert(PlayerProbs::default()) + } + } + // if !self.player_probs.contains_key(player) { + // self.player_probs.insert(player.clone(), PlayerProbs::default()); + // } + // self.player_probs.get_mut(player).unwrap() + } +} diff --git a/brumby-soccer/src/model/score_fitter.rs b/brumby-soccer/src/model/score_fitter.rs index c97d355..ecf3192 100644 --- a/brumby-soccer/src/model/score_fitter.rs +++ b/brumby-soccer/src/model/score_fitter.rs @@ -1,50 +1,103 @@ +use std::ops::RangeInclusive; +use anyhow::bail; +use crate::domain::{Offer, OfferCategory, OfferType, OutcomeType, Over, Period}; +use crate::model::{FitError, MissingOffer, Model}; use rustc_hash::FxHashMap; -use crate::domain::{Offer, OfferType, OutcomeType, Over, Period}; -use crate::model::{FitError, Model}; +use tracing::debug; +use crate::fit; -pub struct FitterConfig { +pub struct Config { + h1_goal_ratio: f64, // poisson_search: HypergridSearchConfig<'a>, // binomial_search: HypergridSearchConfig<'a>, } -impl FitterConfig { +impl Config { fn validate(&self) -> Result<(), anyhow::Error> { + const H1_GOAL_RATIO_RANGE: RangeInclusive = 0.0..=1.0; + if !H1_GOAL_RATIO_RANGE.contains(&self.h1_goal_ratio) { + bail!("H1 goal ratio ({}) outside of allowable range (H1_GOAL_RATIO_RANGE:?)", self.h1_goal_ratio); + } // self.poisson_search.validate()?; // self.binomial_search.validate()?; Ok(()) } } +impl Default for Config { + fn default() -> Self { + Self { + h1_goal_ratio: 0.425 + } + } +} + pub struct ScoreFitter { - config: FitterConfig + config: Config, } impl ScoreFitter { - pub fn fit(model: &mut Model, offers: &FxHashMap) -> Result<(), FitError> { - let ft_goals = most_balanced_total_goals(offers.values(), &Period::FullTime); - todo!() + pub fn fit(&self, model: &mut Model, offers: &FxHashMap) -> Result<(), FitError> { + let (ft_goals, _) = + most_balanced_goals(offers.values(), &Period::FullTime).ok_or_else(|| { + FitError::MissingOffer(MissingOffer::Category(OfferCategory::TotalGoals)) + })?; + + let ft_h2h = get_offer(offers, &OfferType::HeadToHead(Period::FullTime))?; + let (ft_search_outcome, lambdas) = fit::fit_scoregrid_full(&ft_h2h, &ft_goals); + + let (h1_goals, h1_goals_over) = + most_balanced_goals(offers.values(), &Period::FirstHalf).ok_or_else(|| { + FitError::MissingOffer(MissingOffer::Category(OfferCategory::TotalGoals)) + })?; + let h1_h2h = get_offer(offers, &OfferType::HeadToHead(Period::FirstHalf))?; + + let (h2_goals, h2_goals_over) = + most_balanced_goals(offers.values(), &Period::SecondHalf).ok_or_else(|| { + FitError::MissingOffer(MissingOffer::Category(OfferCategory::TotalGoals)) + })?; + let h2_h2h = get_offer(offers, &OfferType::HeadToHead(Period::SecondHalf))?; + + debug!("fitting 1st half ({:.1} goals line)", h1_goals_over.0 as f64 + 0.5); + let h1_home_goals_estimate = (lambdas[0] + lambdas[2]) * self.config.h1_goal_ratio; + let h1_away_goals_estimate = (lambdas[1] + lambdas[2]) * self.config.h1_goal_ratio; + let h1_search_outcome = fit::fit_scoregrid_half(h1_home_goals_estimate, h1_away_goals_estimate, &[&h1_h2h, &h1_goals]); + + debug!("fitting 2nd half ({:.1} goals line)", h2_goals_over.0 as f64 + 0.5); + let h2_home_goals_estimate = (lambdas[0] + lambdas[2]) * (1.0 - self.config.h1_goal_ratio); + let h2_away_goals_estimate = (lambdas[1] + lambdas[2]) * (1.0 - self.config.h1_goal_ratio); + let h2_search_outcome = fit::fit_scoregrid_half(h2_home_goals_estimate, h2_away_goals_estimate, &[&h2_h2h, &h2_goals]); + + Ok(()) } } -impl TryFrom for ScoreFitter { +fn get_offer<'a>(offers: &'a FxHashMap, offer_type: &OfferType) -> Result<&'a Offer, MissingOffer> { + offers.get(offer_type).ok_or_else(|| MissingOffer::Type(offer_type.clone())) +} + +impl TryFrom for ScoreFitter { type Error = anyhow::Error; - fn try_from(config: FitterConfig) -> Result { + fn try_from(config: Config) -> Result { config.validate()?; - Ok(Self { - config, - }) + Ok(Self { config }) } } -fn most_balanced_total_goals<'a>(offers: impl Iterator, period: &Period) -> Option<(&'a Offer, &'a Over)> { +fn most_balanced_goals<'a>( + offers: impl Iterator, + period: &Period, +) -> Option<(&'a Offer, &'a Over)> { let mut most_balanced = None; let mut most_balanced_diff = f64::MAX; for offer in offers { match &offer.offer_type { - OfferType::TotalGoals(p, over) => if p == period { - let diff = offer.market.prices[0] - offer.market.prices[1]; - if diff < most_balanced_diff { - most_balanced_diff = diff; - most_balanced = Some((offer, over)); + OfferType::TotalGoals(p, over) => { + if p == period { + let diff = f64::abs(offer.market.prices[0] - offer.market.prices[1]); + if diff < most_balanced_diff { + most_balanced_diff = diff; + most_balanced = Some((offer, over)); + } } } _ => {} @@ -52,4 +105,4 @@ fn most_balanced_total_goals<'a>(offers: impl Iterator, period } most_balanced -} \ No newline at end of file +} diff --git a/brumby/src/hash_lookup.rs b/brumby/src/hash_lookup.rs index 761ee1a..0fcb0c4 100644 --- a/brumby/src/hash_lookup.rs +++ b/brumby/src/hash_lookup.rs @@ -24,7 +24,7 @@ impl HashLookup { Self::insert_unique(&mut self.item_to_index, &item, self.index_to_item.len()); self.index_to_item.push(item); } - + pub fn item_at(&self, index: usize) -> Option<&T> { self.index_to_item.get(index) } diff --git a/justfile b/justfile index 26e8e84..c9403c6 100644 --- a/justfile +++ b/justfile @@ -5,6 +5,9 @@ _help: soc_prices *ARGS: cargo run --release --bin soc_prices -- {{ARGS}} +soc_prices2 *ARGS: + cargo run --release --bin soc_prices2 -- {{ARGS}} + # produce prices for a race (singles and multis) rac_prices *ARGS: cargo run --release --bin rac_prices -- {{ARGS}}