diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..f7372c537 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,21 @@ +name: Tests + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: pytest tests/ -v diff --git a/games/0_0_cluster/game_config.py b/games/0_0_cluster/game_config.py index 54a5eec13..3cf820aa0 100644 --- a/games/0_0_cluster/game_config.py +++ b/games/0_0_cluster/game_config.py @@ -23,7 +23,7 @@ def __init__(self): self.working_name = "Sample Cluster Game" self.wincap = 5000.0 self.win_type = "cluster" - self.rtp = 0.9700 + self.rtp = 0.9670 self.construct_paths() # Game Dimensions diff --git a/games/0_0_cluster/game_optimization.py b/games/0_0_cluster/game_optimization.py index a8ca71de6..469372736 100644 --- a/games/0_0_cluster/game_optimization.py +++ b/games/0_0_cluster/game_optimization.py @@ -21,11 +21,11 @@ def __init__(self, game_config): "base": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["base"], search_conditions=wincaps["base"] + rtp=0.001, av_win=wincaps["base"], search_conditions=wincaps["base"] ).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), "freegame": ConstructConditions( - rtp=0.37, hr=200, search_conditions={"symbol": "scatter"} + rtp=0.376, hr=200, search_conditions={"symbol": "scatter"} ).return_dict(), "basegame": ConstructConditions(hr=3.5, rtp=0.59).return_dict(), }, @@ -67,9 +67,9 @@ def __init__(self, game_config): "bonus": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + rtp=0.001, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] ).return_dict(), - "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + "freegame": ConstructConditions(rtp=0.966, hr="x").return_dict(), }, "scaling": ConstructScaling( [ diff --git a/games/0_0_expwilds/game_config.py b/games/0_0_expwilds/game_config.py index 89497cc5e..14ad8a063 100644 --- a/games/0_0_expwilds/game_config.py +++ b/games/0_0_expwilds/game_config.py @@ -16,7 +16,7 @@ def __init__(self): self.working_name = "Expanding Wilds" self.wincap = 5000 self.win_type = "lines" - self.rtp = 0.97 + self.rtp = 0.967 self.construct_paths() # Game Dimensions diff --git a/games/0_0_expwilds/game_optimization.py b/games/0_0_expwilds/game_optimization.py index 1e9946938..67d0a71d4 100644 --- a/games/0_0_expwilds/game_optimization.py +++ b/games/0_0_expwilds/game_optimization.py @@ -21,11 +21,11 @@ def __init__(self, game_config): "base": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["base"], search_conditions=wincaps["base"] + rtp=0.001, av_win=wincaps["base"], search_conditions=wincaps["base"] ).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), "freegame": ConstructConditions( - rtp=0.37, hr=200, search_conditions={"symbol": "scatter"} + rtp=0.376, hr=200, search_conditions={"symbol": "scatter"} ).return_dict(), "basegame": ConstructConditions(hr=3.5, rtp=0.59).return_dict(), }, @@ -77,9 +77,9 @@ def __init__(self, game_config): "bonus": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + rtp=0.001, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] ).return_dict(), - "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + "freegame": ConstructConditions(rtp=0.966, hr="x").return_dict(), }, "scaling": ConstructScaling( [ @@ -118,10 +118,10 @@ def __init__(self, game_config): "superspin": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["superspin"], search_conditions=wincaps["superspin"] + rtp=0.001, av_win=wincaps["superspin"], search_conditions=wincaps["superspin"] ).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), - "basegame": ConstructConditions(hr=1.9, rtp=0.96).return_dict(), + "basegame": ConstructConditions(hr=1.9, rtp=0.966).return_dict(), }, "scaling": ConstructScaling( [ diff --git a/games/0_0_lines/game_config.py b/games/0_0_lines/game_config.py index c43d32bd9..49bad55b5 100644 --- a/games/0_0_lines/game_config.py +++ b/games/0_0_lines/game_config.py @@ -22,7 +22,7 @@ def __init__(self): self.working_name = "Sample Lines Game" self.wincap = 5000.0 self.win_type = "lines" - self.rtp = 0.9700 + self.rtp = 0.9670 self.construct_paths() # Game Dimensions diff --git a/games/0_0_lines/game_optimization.py b/games/0_0_lines/game_optimization.py index dc6e78dd4..0f756f8ac 100644 --- a/games/0_0_lines/game_optimization.py +++ b/games/0_0_lines/game_optimization.py @@ -22,13 +22,13 @@ def __init__(self, game_config): "base": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["base"], search_conditions=wincaps["base"] + rtp=0.001, av_win=wincaps["base"], search_conditions=wincaps["base"] ).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), "freegame": ConstructConditions( - rtp=0.37, hr=200, search_conditions={"symbol": "scatter"} + rtp=0.367, hr=200, search_conditions={"symbol": "scatter"} ).return_dict(), - "basegame": ConstructConditions(hr=3.5, rtp=0.59).return_dict(), + "basegame": ConstructConditions(hr=3.5, rtp=0.599).return_dict(), }, "scaling": ConstructScaling( [ @@ -68,9 +68,9 @@ def __init__(self, game_config): "bonus": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + rtp=0.001, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] ).return_dict(), - "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + "freegame": ConstructConditions(rtp=0.966, hr="x").return_dict(), }, "scaling": ConstructScaling( [ diff --git a/games/0_0_lines_feature_match/game_config.py b/games/0_0_lines_feature_match/game_config.py index 0a3140ea6..877cba228 100644 --- a/games/0_0_lines_feature_match/game_config.py +++ b/games/0_0_lines_feature_match/game_config.py @@ -22,7 +22,7 @@ def __init__(self): self.working_name = "Sample Lines Game" self.wincap = 5000.0 self.win_type = "lines" - self.rtp = 0.9700 + self.rtp = 0.967 self.construct_paths() # Game Dimensions diff --git a/games/0_0_lines_feature_match/game_optimization.py b/games/0_0_lines_feature_match/game_optimization.py index c7f5e4521..b5e04a667 100644 --- a/games/0_0_lines_feature_match/game_optimization.py +++ b/games/0_0_lines_feature_match/game_optimization.py @@ -21,11 +21,11 @@ def __init__(self, game_config): "base": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["base"], search_conditions=wincaps["base"] + rtp=0.001, av_win=wincaps["base"], search_conditions=wincaps["base"] ).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), "freegame": ConstructConditions( - rtp=0.37, hr=200, search_conditions={"symbol": "scatter"} + rtp=0.376, hr=200, search_conditions={"symbol": "scatter"} ).return_dict(), "basegame": ConstructConditions(hr=3.5, rtp=0.59).return_dict(), }, @@ -50,9 +50,9 @@ def __init__(self, game_config): "bonus": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + rtp=0.001, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] ).return_dict(), - "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + "freegame": ConstructConditions(rtp=0.966, hr="x").return_dict(), }, "scaling": ConstructScaling([]).return_dict(), "parameters": ConstructParameters( diff --git a/games/0_0_scatter/game_config.py b/games/0_0_scatter/game_config.py index 8f078a9d8..0d20763a0 100644 --- a/games/0_0_scatter/game_config.py +++ b/games/0_0_scatter/game_config.py @@ -22,7 +22,7 @@ def __init__(self): self.working_name = "Sample scatter pay (pay anywhere)" self.wincap = 5000.0 self.win_type = "scatter" - self.rtp = 0.9700 + self.rtp = 0.967 self.construct_paths() # Game Dimensions diff --git a/games/0_0_scatter/game_optimization.py b/games/0_0_scatter/game_optimization.py index 070b86f05..96d90518e 100644 --- a/games/0_0_scatter/game_optimization.py +++ b/games/0_0_scatter/game_optimization.py @@ -17,10 +17,10 @@ def __init__(self, game_config): self.game_config.opt_params = { "base": { "conditions": { - "wincap": ConstructConditions(rtp=0.01, av_win=5000, search_conditions=5000).return_dict(), + "wincap": ConstructConditions(rtp=0.001, av_win=5000, search_conditions=5000).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), "freegame": ConstructConditions( - rtp=0.37, hr=200, search_conditions={"symbol": "scatter"} + rtp=0.376, hr=200, search_conditions={"symbol": "scatter"} ).return_dict(), "basegame": ConstructConditions(hr=3.5, rtp=0.59).return_dict(), }, @@ -72,8 +72,8 @@ def __init__(self, game_config): }, "bonus": { "conditions": { - "wincap": ConstructConditions(rtp=0.01, av_win=5000, search_conditions=5000).return_dict(), - "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + "wincap": ConstructConditions(rtp=0.001, av_win=5000, search_conditions=5000).return_dict(), + "freegame": ConstructConditions(rtp=0.966, hr="x").return_dict(), }, "scaling": ConstructScaling( [ diff --git a/games/0_0_ways/game_config.py b/games/0_0_ways/game_config.py index 3de6945df..b0137c1c6 100644 --- a/games/0_0_ways/game_config.py +++ b/games/0_0_ways/game_config.py @@ -21,7 +21,7 @@ def __init__(self): self.working_name = "sample ways game" self.wincap = 5000 self.win_type = "ways" - self.rtp = 0.97 + self.rtp = 0.967 self.construct_paths() # Game Dimensions diff --git a/games/0_0_ways/game_optimization.py b/games/0_0_ways/game_optimization.py index 9c90f8ca8..87feeef1d 100644 --- a/games/0_0_ways/game_optimization.py +++ b/games/0_0_ways/game_optimization.py @@ -23,11 +23,11 @@ def __init__(self, game_config): "base": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["base"], search_conditions=wincaps["base"] + rtp=0.001, av_win=wincaps["base"], search_conditions=wincaps["base"] ).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), "freegame": ConstructConditions( - rtp=0.37, hr=200, search_conditions={"symbol": "scatter"} + rtp=0.376, hr=200, search_conditions={"symbol": "scatter"} ).return_dict(), "basegame": ConstructConditions(hr=3.5, rtp=0.59).return_dict(), }, @@ -79,9 +79,9 @@ def __init__(self, game_config): "bonus": { "conditions": { "wincap": ConstructConditions( - rtp=0.01, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] + rtp=0.001, av_win=wincaps["bonus"], search_conditions=wincaps["bonus"] ).return_dict(), - "freegame": ConstructConditions(rtp=0.96, hr="x").return_dict(), + "freegame": ConstructConditions(rtp=0.966, hr="x").return_dict(), }, "scaling": ConstructScaling( [ diff --git a/games/template/game_optimization.py b/games/template/game_optimization.py index 32cef48fb..e3eb2e873 100644 --- a/games/template/game_optimization.py +++ b/games/template/game_optimization.py @@ -18,10 +18,10 @@ def __init__(self, game_config: object): self.game_config.opt_params = { "base": { "conditions": { - "wincap": ConstructConditions(rtp=0.01, av_win=5000, search_conditions=5000).return_dict(), + "wincap": ConstructConditions(rtp=0.001, av_win=5000, search_conditions=5000).return_dict(), "0": ConstructConditions(rtp=0, av_win=0, search_conditions=0).return_dict(), "freegame": ConstructConditions( - rtp=0.37, hr=200, search_conditions={"symbol": "scatter"} + rtp=0.379, hr=200, search_conditions={"symbol": "scatter"} ).return_dict(), "basegame": ConstructConditions(hr=3.5, rtp=0.59).return_dict(), }, diff --git a/utils/analysis/distribution_functions.py b/utils/analysis/distribution_functions.py index 351f92104..128672abd 100644 --- a/utils/analysis/distribution_functions.py +++ b/utils/analysis/distribution_functions.py @@ -90,6 +90,57 @@ def get_distribution_median(dist: dict, total_weight=None) -> float: return 0 +def conditional_value_at_risk(cutoff: float, dist: dict, total_weight) -> float: + """Calculate upper-tail CVaR from payout distribution.""" + if total_weight is None: + total_weight = sum(dist.values()) + if total_weight == 0: + return 0.0 + + ordered = sorted(dist.items(), key=lambda x: x[0]) + cumulative_prob = 0.0 + tail_start = ordered[0][0] if ordered else 0.0 + + for payout, weight in ordered: + cumulative_prob += weight / total_weight + if cumulative_prob >= cutoff: + tail_start = payout + break + + tail_prob = 0.0 + tail_value = 0.0 + + for payout, weight in ordered: + if payout >= tail_start: + prob = weight / total_weight + tail_prob += prob + tail_value += prob * payout + + if tail_prob == 0.0: + return 0.0 + + return tail_value / tail_prob + + +def get_etl_cvar_p5k_10k_vales(dist: dict, bet_cost: float, total_weight=None) -> list[float]: + """Get Math Validation Values""" + if total_weight is None: + total_weight = sum(list(dist.values())) + + p5k, p10k, cvar, etl40, etl10k = 0, 0, 0, 0, 0 + for win, weight in dist.items(): + if win >= 10000: + p10k += weight / total_weight + etl10k += win * (weight / total_weight) + if win >= 5000: + p5k += weight / total_weight + if win >= 40 * bet_cost: + etl40 += win * (weight / total_weight) + cvar = conditional_value_at_risk(0.999, dist, total_weight) + + return p5k, p10k, etl10k, etl40, cvar + + def get_maxwin_hitrate(dist: dict, total_weight=None) -> float: """Return frequency of max-win.""" if total_weight is not None: diff --git a/utils/rgs_verification.py b/utils/rgs_verification.py index 462c8dd71..b30690133 100644 --- a/utils/rgs_verification.py +++ b/utils/rgs_verification.py @@ -25,6 +25,7 @@ get_distribution_median, min_dist_difference, calculate_rtp, + get_etl_cvar_p5k_10k_vales, ) from src.write_data.write_data import get_sha_256 @@ -53,6 +54,11 @@ def __init__( num_non_zero_payouts=None, skew=None, excess_kurtosis=None, + prob5k=None, + prob10k=None, + etl40b=None, + etl10k=None, + cvar=None, ): self.win_distribution = win_distribution self.num_events = num_events @@ -73,6 +79,11 @@ def __init__( self.prob_less_bet = prob_less_bet self.skew = skew self.excess_kurtosis = excess_kurtosis + self.prob5k = prob5k + self.prob10k = prob10k + self.etl40b = etl40b + self.etl10k = etl10k + self.cvar = cvar def to_dict(self): map_object = {} @@ -163,12 +174,33 @@ def get_num_non_zero_payouts(book_int_payouts) -> None: return len([p for p in book_int_payouts if p > 0]) +def verify_mode_volatility(name: str, MathStats: object) -> dict: + """Check math betlevel volatility limits. Returns dict of violated attributes.""" + mode_limits = {"prob5k": 1e-2, "prob10k": 0.5e-2, "etl40b": 0.9, "etl10k": 0.8, "cvar": 800, "rtp": 0.967} + violated_attributes = {} + + for key, limit in mode_limits.items(): + val = getattr(MathStats, key, None) + if val is not None and val > limit: + violated_attributes[key] = val + + if violated_attributes: + warnings.warn(f"\nMode [{name}] fails 3-star volatility limits:\n") + for key, val in violated_attributes.items(): + print(f"\tVIOLATED: {key} VALUE:{round(val, 4)} --- LIMIT: {mode_limits[key]}") + print("\n\n") + return violated_attributes + + def get_lut_statistics( - win_distribution, bet_cost, unique_payouts, weight_range, min_win, max_win, num_events + name, win_distribution, bet_cost, unique_payouts, weight_range, min_win, max_win, num_events ) -> object: """Run RGS statistic tests for upload verification.""" var, std, skew, kurtosis = get_distribution_moments(win_distribution, bet_cost) + p5k, p10k, etl10k, etl40, cvarp01 = get_etl_cvar_p5k_10k_vales( + win_distribution, bet_cost, sum(list(win_distribution.values())) + ) MathStats = WinStatistics( win_distribution=win_distribution, num_events=num_events, @@ -188,13 +220,21 @@ def get_lut_statistics( num_non_zero_payouts=get_num_non_zero_payouts(unique_payouts), skew=skew, excess_kurtosis=kurtosis, + prob5k=p5k, + prob10k=p10k, + etl40b=etl40, + etl10k=etl10k, + cvar=cvarp01, ) median = get_distribution_median(win_distribution, weight_range) + if median > 0: m2m = MathStats.average_win / median MathStats.m2m = m2m else: MathStats.m2m = 0 + + verify_mode_volatility(name, MathStats) return MathStats @@ -246,7 +286,7 @@ def execute_all_tests(config, excluded_modes=[]): compare_payout_values(book_payouts, lut_payouts) StatsObject = get_lut_statistics( - win_dist, cost, lut_payouts, weights_range, min_win, max_win, num_events + name, win_dist, cost, lut_payouts, weights_range, min_win, max_win, num_events ) mode_rtps.append(StatsObject.rtp) setattr(StatsObject, "name", name)