bet_sizing
A classifier that is right 55% of the time can still lose money if it bets the same amount on a 51% call as on a 90% one. AFML Chapter 10 opens with the poker version of the point: the cards are the forecast, and the money is made or lost in how the hand is bet. This module is the step between a prediction and an order. It offers three routes, depending on what the model produces:
| You have | Use | AFML |
|---|---|---|
| a class probability (typically from a meta-label model) | bet_size_probability | §10.3–10.5 |
| a price forecast and the current market price | bet_size_dynamic | §10.6 |
| only the sides and lifespans of the bets | bet_size_budget, bet_size_reserve | §10.2 |
From a probability
Section titled “From a probability”Under the null hypothesis that the model knows nothing, the predicted class has probability . The size is how far the prediction is from that, as a test statistic pushed through the normal CDF (Snippet 10.1):
where is the probability of the predicted class, the number of classes and the
side. lies in : a fraction of the maximum position. get_signal is exactly this;
with pred omitted it returns the unsigned size.
Two further steps make it tradeable. Signals stay alive until their label’s end time, so at
any moment several are open; avg_active_signals replaces each with the mean of the signals
live at that instant (§10.4), which stops the position from flipping every time a new one
arrives. Then discrete_signal rounds to a step (§10.5), so that a change from 0.337 to
0.328 does not generate an order. bet_size_probability runs all three.
get_signal with two classes. Right: the sigmoid that bet_size_dynamic uses, with its fixed calibration point.from openquant import bet_sizing
# 1. Probability -> size. A two-class meta-label model: 0.5 is "no idea".probs = [0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 0.99]sizes = bet_sizing.get_signal(probs, 2)print("p ", " ".join(f"{p:5.2f}" for p in probs))print("size ", " ".join(f"{m:5.2f}" for m in sizes))
# 2. Bets overlap. Four signals, each alive for two hours; the side comes from the primary model.starts = [f"2024-01-02 {h:02d}:00:00" for h in (9, 10, 11, 14)]ends = [f"2024-01-02 {h:02d}:00:00" for h in (11, 12, 13, 16)]p, side = [0.70, 0.90, 0.60, 0.80], [1.0, 1.0, -1.0, -1.0]
alone = bet_sizing.bet_size_probability(starts, ends, p, side, 2, 0.0, False)averaged = bet_sizing.bet_size_probability(starts, ends, p, side, 2, 0.0, True)stepped = bet_sizing.bet_size_probability(starts, ends, p, side, 2, 0.1, True)print()print("each signal alone:", [round(v, 3) for _, v in alone])for (t, avg), (_, step) in zip(averaged, stepped): print(f"{t[11:16]} average of live bets {avg:+.3f} in steps of 0.1 {step:+.1f}")
# 3. A price forecast instead of a probability: how many of 100 contracts to hold, and the# worst price at which moving to that position is still justified.print()for market in (99.0, 95.0, 90.0): size, target, limit = bet_sizing.bet_size_dynamic([0.0], [100.0], [market], [100.0])[0] print(f"forecast 100, market {market:5.1f}: size {size:.3f}, hold {target:3.0f}, limit {limit:.2f}")p 0.50 0.55 0.60 0.70 0.80 0.90 0.99size 0.00 0.08 0.16 0.34 0.55 0.82 1.00
each signal alone: [0.337, 0.818, -0.162, -0.547]09:00 average of live bets +0.337 in steps of 0.1 +0.310:00 average of live bets +0.578 in steps of 0.1 +0.611:00 average of live bets +0.328 in steps of 0.1 +0.312:00 average of live bets -0.162 in steps of 0.1 -0.213:00 average of live bets +0.000 in steps of 0.1 +0.014:00 average of live bets -0.547 in steps of 0.1 -0.516:00 average of live bets +0.000 in steps of 0.1 +0.0
forecast 100, market 99.0: size 0.291, hold 29, limit 99.50forecast 100, market 95.0: size 0.836, hold 83, limit 98.22forecast 100, market 90.0: size 0.950, hold 95, limit 97.57The first table is the shape of the function: a 55% call earns an 8% position and a 70% call a third. It is cautious where most classifiers live, and a model whose probabilities are not calibrated will be sized wrongly by exactly the amount of its miscalibration — calibrate before sizing.
The second shows what averaging is for. At 11:00 the 0.70 long has expired, and a new short at −0.162 arrives while the 0.90 long is still open: the book moves to +0.328, not to −0.162. At 13:00 nothing is live and the position is flat. Note the rounding at 12:00: −0.162 becomes −0.2, because the step rounds to nearest, not toward zero.
From a price forecast
Section titled “From a price forecast”When the model forecasts a price rather than a probability, size should grow with the gap to the market price and saturate (§10.6). With :
is the target position out of a maximum , the current one, and the limit
price: the average, over each position passed through on the way from to ,
of the price at which holding units is exactly justified. Buying above means paying
more for the marginal units than the forecast supports. In the example, with the market at 95
and a forecast of 100, the target is 83 contracts and the order should not be filled above
98.22. Both positions are truncated to whole units, and is NaN when they are equal.
Where this departs from Snippet 10.4
Section titled “Where this departs from Snippet 10.4”Snippet 10.4 loops over j in range(abs(pos + sgn), abs(tPos + 1)) with unsigned j and
divides by tPos - pos. When the position is long and growing () that is
exactly the sum above, and the two agree. In every other direction the book’s loop does not
follow the move, so openquant uses the path with signs kept:
| Move (with ) | Positions averaged here | Snippet 10.4 as written |
|---|---|---|
| 0 → 4 (increasing a long) | 1, 2, 3, 4 | the same |
| 10 → 5 (reducing a long) | 9, 8, 7, 6, 5 | none: the range is empty, |
| 0 → −4 (going short) | −1, −2, −3, −4 | (the bound is abs(tPos + 1) = 3), unsigned, divided by −4 |
| 3 → −2 (crossing zero) | 2, 1, 0, −1, −2 | none: the range from 2 to 1 is empty |
Keeping the sign matters: a short’s limit price lies above , the mirror image of a long’s
below it, and at the term is itself. Until issue #163 openquant used the book’s
unsigned loop with abs(tPos) as the upper bound, so it returned 0 when reducing, the long’s
price for a short, and 19.96 for the 3 → −2 move on the power curve with and
(now 100). bet_size_dynamic computes its limit column the same way.
bet_size_dynamic fixes . It calibrates the curve so that a divergence of 10 price
units gives a size of 0.95 — get_w_sigmoid(10.0, 0.95) — and there is no argument to change
it. Ten units is a large gap for a stock at 20 and nothing for an index at 5,000, so for
anything other than a first look, calibrate your own: get_w(divergence, size, "sigmoid"),
then get_target_pos and limit_price with that . The "power" variants use
and require the divergence to be scaled into
; outside it they return BetSizingError::PriceDivergenceOutOfRange.
From concurrency alone
Section titled “From concurrency alone”§10.2 sizes without any model output, from how many bets are open. With and the number of concurrent long and short bets at , and :
bet_size_budget is the first: it reaches full size only when concurrency is at its
historical maximum, which keeps capacity in reserve for the moment signals pile up.
bet_size_reserve is the second, with the CDF of a mixture of two Gaussians fitted to
. bet_size_reserve_full fits that mixture by EM from random starting points, so its
output changes from run to run; fit once, keep the five parameters
[mu1, mu2, sigma1, sigma2, p1], and pass them to bet_size_reserve for reproducible sizes.
The maxima in the budget formula are taken over the sample you pass in, so computing it on a
full backtest and trading on it looks ahead.
From Rust
Section titled “From Rust”use openquant::bet_sizing::{get_signal, get_target_pos, get_w, limit_price, BetSizingError};
// Probability to size: no edge at 1/K, saturating towards 1.let sizes = get_signal(&[0.5, 0.7, 0.9], 2, None);assert!(sizes[0].abs() < 1e-12);assert!((sizes[1] - 0.3374).abs() < 1e-4 && (sizes[2] - 0.8176).abs() < 1e-4);
// Calibrate the sigmoid for this instrument: a gap of 2.0 should be a 0.9 bet.let w = get_w(2.0, 0.9, "sigmoid")?;let target = get_target_pos(w, 101.5, 100.0, 50.0, "sigmoid")?; // forecast, market, max positionlet limit = limit_price(target, 0.0, 101.5, w, 50.0, "sigmoid")?;assert_eq!(target, 42.0);assert!(limit > 100.0 && limit < 101.5);
// An unknown function name is an error, not a panic.assert!(matches!(get_w(2.0, 0.9, "logistic"), Err(BetSizingError::InvalidFunction { .. })));What to watch for
Section titled “What to watch for”- is the probability that the bet is right. With a meta-label model and a side from a primary model, as in the example, that is the model’s : above it sizes the primary model’s call, and below the size goes negative — a bet against the primary model, not a smaller bet with it. If you only ever want to follow or stand aside, floor the size at zero yourself.
- of exactly 0 or 1 divides by zero. The result is in and a size of exactly ; clip probabilities if your model emits hard 0s and 1s.
- A signal is live on . At its own end time it no longer counts, which is why 13:00 and 16:00 in the example are flat.
- A
step_sizeof zero or less disables discretisation rather than raising. bet_size_dynamicbroadcasts. Each of its four inputs must have length 1 or the common length; anything else isBetSizingError::ShapeMismatch.- Timestamps from Python are strings,
"%Y-%m-%d %H:%M:%S"with an optional fractional second. Output times keep the fraction, written asstr(datetime)writes it.
Related modules
Section titled “Related modules”labeling— meta-labels are what the probability model is trained on, and each label’s end time is the lifespan of its bet.sampling— the same concurrency, used to weight observations.risk-metricsandstrategy-risk— what the sized strategy then has to survive.ef3m— the moment-matching mixture fit AFML uses for the reserve method; this module fits by EM instead.
API reference
Section titled “API reference”Python
Section titled “Python”Signatures, parameters, return values and errors for every function: openquant.bet_sizing.
bet_sizing.get_signal— Convert predicted-class probabilities into bet sizes in `[-1, 1]`.bet_sizing.avg_active_signals— Average the signals that are live at each change point.bet_sizing.discrete_signal— Round bet sizes to multiples of `step_size` and clamp them to `[-1, 1]`.bet_sizing.bet_size_probability— Size bets from classifier probabilities.bet_sizing.bet_size_dynamic— Dynamic bet sizing from a price forecast.bet_sizing.bet_size_budget— Budgeting bet size `L_t / max(L) - S_t / max(S)` from concurrent bet counts.bet_sizing.bet_size_reserve— Reserve bet sizes under a given two-Gaussian mixture fit.bet_sizing.bet_size_reserve_full— Reserve bet sizing with a two-Gaussian mixture fitted to the net concurrency.
Rustdoc for this commit, with every public item of openquant::bet_sizing (after a crates.io release the same pages are on docs.rs):