DEV Community

desgh white
desgh white

Posted on

Combinatorics in Production: Settling a 15-Line Full-Cover Bet Correctly

Every so often a domain hands you a problem that looks like a one-liner and turns out to have a dozen edge cases. Settling a "Lucky 15" — a betting slip covering four selections across fifteen combinations — is one of them. It is a nice study in why naive combinatorics plus real-world rules is where bugs live.

The shape of the problem

Four selections produce every non-empty combination of size 1..4:

from itertools import combinations

def lines(selections):
    for size in range(1, len(selections) + 1):
        yield from combinations(selections, size)

assert len(list(lines(["a", "b", "c", "d"]))) == 15   # 4 + 6 + 4 + 1
Enter fullscreen mode Exit fullscreen mode

Four singles, six doubles, four trebles, one fourfold. The return for a line is the product of the decimal odds of its members, times the unit stake, and only if every member won:

from math import prod

def line_return(line, results, stake):
    if not all(results[s].won for s in line):
        return 0.0
    return stake * prod(results[s].odds for s in line)
Enter fullscreen mode Exit fullscreen mode

Total return is the sum over all fifteen lines. That is the entire happy path, and it is where most implementations stop.

Where it actually gets hard

Stake semantics. The unit stake applies per line, so a "£1 Lucky 15" costs £15. Getting this backwards is the single most common bug in hobby implementations, and it is off by a factor of fifteen — an error big enough that no one notices it is a rounding problem, because it isn't one.

Bonus rules are not arithmetic, they are policy. Bookmakers attach concessions: a one-winner bonus paying a lone winner at double the odds, an all-winners bonus adding 10–20% to the return. They vary per operator in ways that resist a single formula — some apply the percentage to the whole return, others only to winning lines. Model them as a strategy object per operator, not as an if in the settlement loop, or you will be editing the core function every time a rule changes.

Non-runners collapse the bet. A withdrawn selection does not void the slip; it reduces it to the next size down — a Lucky 15 with one non-runner settles as a Lucky 11 over the remaining three. That means the line set is computed after filtering, not before, and your "15" is a derived value rather than a constant.

Rule 4 deductions apply a percentage reduction to winnings at prices taken before a withdrawal. It touches odds, not stake, and it must be applied before the bonus, not after. Order of operations is load-bearing.

Each-way doubles the line count and settles the place portion at a fraction of the odds, fixed when the bet was struck rather than at settlement time.

Testing it

This is a domain where property-based tests earn their keep. Useful invariants: the return is monotonic in each selection's odds; adding a losing selection never increases the return; a slip where every selection wins returns at least the sum of its parts; and settling with the unit stake scaled by k scales the return by exactly k. Then pin the awkward cases — one winner, all non-runners, a Rule 4 with a bonus — as explicit regression tests, because those are the ones that break when someone "simplifies" the bonus logic.

Reference implementation

If you want to sanity-check your numbers against a working implementation before writing the tests, a visit the website settles the same fifteen lines with the operator concessions applied, which makes it a convenient oracle for the cases you have not thought of yet.

Takeaway

Generate the line set with combinations, derive the count instead of hard-coding fifteen, keep operator rules in a strategy object, and get the order right: filter non-runners, apply Rule 4, sum the lines, then apply the bonus. The combinatorics are five lines of code; the domain rules are the actual program.

Top comments (0)