feat(sim): cash_out_threshold — rathole stack excess to bankroll

After each hand, stack above the threshold moves to the bankroll.
Bounds effective depth while keeping natural short-stack dynamics
(losses, rebuy cycles). Needed for realistic deep-stack-safe
benchmarks: the oracle hybrid's EV collapses with stack depth
(+6.9 at 75BB reset vs -33.8 natural), so unbounded natural-stack
snowballs distort candidate evaluation.
This commit is contained in:
Jan-Peter von Hunnius 2026-09-08 16:23:08 +02:00
parent a589acbbc7
commit b9963bfbb1
2 changed files with 41 additions and 1 deletions

View file

@ -354,6 +354,12 @@ pub struct GameConfig {
#[serde(default)] #[serde(default)]
pub bankroll_refill_threshold: ChipCount, pub bankroll_refill_threshold: ChipCount,
/// Cash-out (rathole) threshold: after each hand, stack excess above
/// this amount is moved to the player's bankroll. 0 = disabled.
/// Keeps natural stack dynamics (losses, rebuys) while bounding depth.
#[serde(default)]
pub cash_out_threshold: ChipCount,
/// If true, player seat assignments are randomized before each hand /// If true, player seat assignments are randomized before each hand
/// (cash game) or before tournament start. /// (cash game) or before tournament start.
#[serde(default)] #[serde(default)]
@ -430,6 +436,7 @@ impl Default for GameConfig {
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: default_rebuy_threshold(), rebuy_threshold_percentage: default_rebuy_threshold(),
@ -1069,6 +1076,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1158,6 +1166,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1197,6 +1206,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1252,6 +1262,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1309,6 +1320,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1360,6 +1372,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1422,6 +1435,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1475,6 +1489,7 @@ min_raise = 50
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
@ -1594,6 +1609,7 @@ min_raise = 20
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
cash_out_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,

View file

@ -128,6 +128,28 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
} }
} }
} }
// Cash-out (rathole): move stack excess above the threshold
// into the bankroll after each hand. Bounds effective depth
// while keeping natural short-stack dynamics intact.
if config.cash_out_threshold > 0 {
for slot in table.seats_mut().iter_mut() {
if let Some(seat) = slot {
if seat.stack > config.cash_out_threshold {
let excess =
seat.stack - config.cash_out_threshold;
log::debug!(
"Cash-out: {} ratholes {} (stack {} -> {})",
seat.player.name(),
excess,
seat.stack,
config.cash_out_threshold
);
seat.bankroll += excess as i64;
seat.stack = config.cash_out_threshold;
}
}
}
}
} }
Err(GameError::NotEnoughPlayers(count)) => { Err(GameError::NotEnoughPlayers(count)) => {
// Not enough active players to continue. This should only // Not enough active players to continue. This should only
@ -175,7 +197,9 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
.map(|seat| (seat.player_id, seat.bankroll)) .map(|seat| (seat.player_id, seat.bankroll))
}) })
.collect(); .collect();
let bankroll_mode = config.reset_bankroll_each_hand || config.bankroll_refill_threshold > 0; let bankroll_mode = config.reset_bankroll_each_hand
|| config.bankroll_refill_threshold > 0
|| config.cash_out_threshold > 0;
stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode, config.auto_rebuy); stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode, config.auto_rebuy);
Ok((stats, table)) Ok((stats, table))