Ruin Is a Result, Not an Exception

Ask a bankroll simulator for 500 bets and you might get 47. The other 453 did not happen because there was no money left to place them with.

That is not an error. It is the single most interesting thing the simulation has to say, and where you put the boundary between “raise” and “return” decides whether you can analyse it or only catch it.

Two layers, two behaviours

keeks draws the line between the bankroll and the simulator, and they behave differently on purpose.

The bankroll raises. It is the object that enforces the safeguards, and a request that would breach one is a programming error from its point of view:

bank = BankRoll(initial_funds=100.0, percent_bettable=1.0, max_draw_down=0.5)
bank.bet(80.0)
# RuinError: You lost too much money buddy, slow down.

The simulator catches and stops:

except RuinError:
    # Settlement exceeded a bankroll safeguard; stop gracefully
    break

So a losing proposition bet aggressively, with no try anywhere in the calling code:

returned normally: final funds 0.02
trials requested 500, transactions recorded 47
the path: [100.0, 140.0, 84.0, 50.4, 70.56, 42.34, 25.4, 15.24] ... [0.04, 0.03, 0.02]
peak 140.00, trough 0.02

The call returns. The bankroll holds the whole trajectory, including the run up to 140 before the collapse. Nothing was lost, and nothing needed to be wrapped.

Why the boundary sits there

The bankroll cannot know whether ruin is a bug or the subject. It is a component enforcing an invariant, and enforcing it means refusing, loudly, so that whoever asked can decide what refusal means.

The simulator does know. Running a bankroll to destruction is precisely what a bankroll simulator is for, so it treats the refusal as the terminal state of a trajectory rather than a failure of the program.

Put the exception one layer too high and the caller of evaluate_strategy has to wrap every simulation in a try, and once they do, the natural thing is to catch and move on, and the run that mattered most is the one they threw away. Put it one layer too low, and the bankroll silently allows a bet that breaches its own limit, at which point the limit is decoration.

What that buys downstream

Because ruin returns instead of raising, the interesting quantities are just there to be read:

  • Did it end early? Transactions recorded against trials requested. 47 of 500.
  • When? The index in the history.
  • How far did it get first? Peak 140 from a start of 100. This one made 40% before losing everything, which is the shape almost every ruin has and the reason people keep betting.
  • What was the path? The whole list.

The nine-strategy benchmark reports early_stop_rate as a first-class column next to growth and drawdown, and it can only do that because the simulators hand back a terminated run rather than an exception. In the estimate-error scenarios that column reaches 100% for full Kelly, which is the single most useful number in the whole benchmark and would have been invisible if ruin were something you catch.

Absorbing states in general

The pattern is not about betting. Any simulation with a state you cannot leave has the same shape:

  • A queue that overflows
  • A patient who dies in a survival model
  • An epidemic that burns out
  • A resource that depletes

In each case the run has ended, the rest of the requested horizon is meaningless, and the time and manner of the ending is usually what the simulation was run to learn. If your return type cannot express “stopped at step 47 with this trajectory”, then your API is throwing away the answer and handing back the question.

The rule I would write down: anything that can end your run early is part of your return type. Not an exception, not a log line, not a flag you have to remember to check. A field, populated, next to the results.

Three things to get right

Terminate, don’t truncate. A run that ended at step 47 should say so. Padding to 500 with the final value, or silently returning a shorter list, both lose the distinction between “ended” and “finished”.

Keep the path. The trajectory before the end is where the interesting analysis is. Peak-before-ruin, time-to-ruin and drawdown-at-ruin are all computable from it and none of them from a single final number.

Let the enforcing layer raise. The component that owns an invariant should refuse to break it. Deciding what a refusal means belongs to the layer that knows what the program is for.

Running it

from keeks.bankroll import BankRoll
from keeks.binary_strategies import FixedFractionStrategy
from keeks.simulators.repeated_binary import RepeatedBinarySimulator

bank = BankRoll(initial_funds=100.0, percent_bettable=1.0, max_draw_down=0.5)
strategy = FixedFractionStrategy(fraction=0.4, payoff=1.0, loss=1.0,
                                 transaction_cost=0.0, min_probability=0.0)
sim = RepeatedBinarySimulator(payoff=1.0, loss=1.0, transaction_costs=0.0,
                              probability=0.30, trials=500, seed=1)
sim.evaluate_strategy(strategy, bank)          # no try/except needed

print(len(bank.history) - 1, "of 500 bets placed")
print("peak", max(bank.history), "final", bank.total_funds)

pip install keeks==0.6.0.