Skip to content

Backtest and gate

Rolling-origin evaluation without lookahead, its metrics, the holdout gate that decides GO or NO_GO, and the expectation a promotion records.

rolling_origin_eval

rolling_origin_eval(
    panel: DataFrame,
    model: _ModelProtocol,
    min_train_rows: int = 20,
    stride: int = 10,
    flat_stake: float = 1.0,
    taker_bps: float | None = None,
    costs: CostModel | None = None,
    policy: TradePolicy | None = None,
) -> RollingOriginResult

Walk-forward evaluation on a panel dataset.

panel columns required

market_id, decision_date (str YYYY-MM-DD), outcome_label, winning_label, market_price, + feature_* columns

For each eval step
  • Train on rows with decision_date < eval_date
  • Predict on rows at eval_date
  • Decide the trade of each (market_id, decision_date) under policy
  • Compute PnL using settle_position

market_price is the label's YES price at the row's decision_date (the plugin contract, see MarketPlugin.build_training_row). A decision uses only its own row's price and forecast, so nothing later than the decision date reaches it.

Parameters:

Name Type Description Default
panel DataFrame

Training/eval panel (see column contract above).

required
model _ModelProtocol

Anything with sklearn-style fit / predict_proba.

required
min_train_rows int

Skip an eval date until this many training rows exist.

20
stride int

Evaluate every Nth distinct decision_date.

10
flat_stake float

Notional USDC risked per trade.

1.0
taker_bps float | None

Legacy fee-only knob. Mutually exclusive with costs.

None
costs CostModel | None

Full CostModel (fee + slippage + depth). Omit both arguments for the historical default (30bps, fill at quote).

None
policy TradePolicy | None

The decision rule. Omit it for the historical backtest: YES on the bin with the highest predicted probability, every market, no threshold (the numbers published before policies existed reproduce). Pass TradePolicy.live(min_edge=...) to trade what record-trades trades: per market the (bin, side) with the best after-fee edge, YES or NO, taken if that edge is at least min_edge. Both price the trade the way the broker and settlement do: a NO trade is a buy of the NO token at 1 - market_price.

None

Returns:

Type Description
RollingOriginResult

RollingOriginResult with all trade records and step metadata.

Raises:

Type Description
ValueError

If both taker_bps and costs are supplied, which would give the fee two conflicting sources of truth.

RollingOriginResult dataclass

RollingOriginResult(
    trades: DataFrame, steps: list[dict[str, Any]] = list()
)

trades instance-attribute

trades: DataFrame

steps class-attribute instance-attribute

steps: list[dict[str, Any]] = field(default_factory=list)

compute_metrics

compute_metrics(trades: DataFrame) -> BacktestMetrics

Compute backtest metrics from a trades DataFrame.

Parameters:

Name Type Description Default
trades DataFrame

DataFrame with column realized_pnl (float). Optional columns: - outcome ("won" | "lost") — if absent, derived from the sign of realized_pnl so the output of rolling_origin_eval composes here directly. - edge (float) — averaged when present, otherwise reported as 0.

required

Returns:

Type Description
BacktestMetrics

BacktestMetrics with aggregated stats. All zeros if trades is empty.

BacktestMetrics dataclass

BacktestMetrics(
    num_trades: int,
    total_pnl: float,
    hit_rate: float,
    avg_pnl_per_trade: float,
    avg_edge: float,
)

num_trades instance-attribute

num_trades: int

total_pnl instance-attribute

total_pnl: float

hit_rate instance-attribute

hit_rate: float

avg_pnl_per_trade instance-attribute

avg_pnl_per_trade: float

avg_edge instance-attribute

avg_edge: float

HoldoutGateResult dataclass

HoldoutGateResult(
    decision: str,
    segment_results: list[SegmentGateResult],
    aggregate_pnl: float,
    aggregate_trades: int,
)

decision instance-attribute

decision: str

segment_results instance-attribute

segment_results: list[SegmentGateResult]

aggregate_pnl instance-attribute

aggregate_pnl: float

aggregate_trades instance-attribute

aggregate_trades: int

evaluate classmethod

evaluate(
    trades: DataFrame,
    required_segments: list[str] | None = None,
    min_trades_per_segment: int = 40,
    min_pnl_per_segment: float = 0.0,
) -> HoldoutGateResult

Evaluate the holdout gate.

Parameters:

Name Type Description Default
trades DataFrame

Backtest trade log (as produced by rolling_origin_eval).

required
required_segments list[str] | None

Segments that must each pass. When None or an empty list, the gate is evaluated on a single aggregate "all" segment — so the output of rolling_origin_eval can be graded directly without splitting into segments first.

None
min_trades_per_segment int

Minimum trades a segment needs to pass.

40
min_pnl_per_segment float

Minimum total PnL a segment needs to pass.

0.0

to_dict

to_dict() -> dict[str, Any]

from_dict classmethod

from_dict(d: dict[str, Any]) -> HoldoutGateResult

SegmentGateResult dataclass

SegmentGateResult(
    segment: str,
    num_trades: int,
    total_pnl: float,
    passes: bool,
    reason: str,
)

segment instance-attribute

segment: str

num_trades instance-attribute

num_trades: int

total_pnl instance-attribute

total_pnl: float

passes instance-attribute

passes: bool

reason instance-attribute

reason: str

BacktestExpectation dataclass

BacktestExpectation(
    n_trades: int,
    flat_stake: float,
    mean_pnl_per_stake: float,
    std_pnl_per_stake: float,
    brier_score: float | None = None,
    brier_std: float | None = None,
    by_direction: Mapping[str, GroupExpectation]
    | None = None,
    policy: TradePolicy | None = None,
    by_segment: Mapping[str, SegmentExpectation | None]
    | None = None,
)

Mean, spread and size of the backtest trades a champion was promoted on.

brier_score / brier_std are the mean and standard deviation of the per-trade squared forecast error. They are None when the backtest trades did not carry both predicted_prob and label_occurred.

by_direction holds the same numbers for the YES trades and for the NO trades on their own: a NO trade is a buy of a different token, picked where the model is bearish, and its PnL and forecast error are not draws from the YES distribution. None means the backtest did not record a split (a champion published before 0.9.1, or a frame without a direction column): the pooled numbers then describe the historical backtest, which only bought YES. A side the backtest traded fewer than MIN_GROUP_TRADES times is absent from the mapping, unless it traded nothing else (then it is the pooled expectation); an empty mapping means neither side traded enough to be promised.

policy is the decision rule the backtest ran (TradePolicy), recorded so live trading can use the same threshold. None when not recorded.

by_segment holds the same numbers for each segment on its own, each with its own split by side. A segment the backtest traded fewer than MIN_GROUP_TRADES times is present with None: recorded as absent, so "this segment could not be promised anything" stays distinguishable from "nobody looked". None for the whole mapping means no per-segment record was made (a champion published before it existed).

An expectation is only worth recording if a live run can be measured against it, so the constructor refuses one that cannot: every number finite, a spread that is not negative, a stake above zero, a Brier score in [0, 1] and at least two trades (one trade has no spread, none divides by zero). NaN compares false with everything, so it would not fail a test, it would silently switch the check off. The mean may be zero or negative; that is a finding the watchdog reports, not a malformed record.

n_trades instance-attribute

n_trades: int

flat_stake instance-attribute

flat_stake: float

mean_pnl_per_stake instance-attribute

mean_pnl_per_stake: float

std_pnl_per_stake instance-attribute

std_pnl_per_stake: float

brier_score class-attribute instance-attribute

brier_score: float | None = None

brier_std class-attribute instance-attribute

brier_std: float | None = None

by_direction class-attribute instance-attribute

by_direction: Mapping[str, GroupExpectation] | None = None

policy class-attribute instance-attribute

policy: TradePolicy | None = None

by_segment class-attribute instance-attribute

by_segment: (
    Mapping[str, SegmentExpectation | None] | None
) = None

from_trades classmethod

from_trades(
    trades: DataFrame,
    *,
    flat_stake: float = 1.0,
    policy: TradePolicy | None = None,
    min_group_trades: int = MIN_GROUP_TRADES,
) -> BacktestExpectation

Summarise a rolling_origin_eval(...).trades frame.

Parameters:

Name Type Description Default
trades DataFrame

Needs realized_pnl. If it also has predicted_prob and label_occurred, the Brier expectation is filled in too. If it has a direction column ("yes" / "no"), each side with at least min_group_trades trades gets an expectation of its own.

required
flat_stake float

The notional each backtest trade risked — the flat_stake the backtest was run with.

1.0
policy TradePolicy | None

The trade policy the backtest ran, to record with the numbers.

None
min_group_trades int

Fewest trades a side needs to be recorded (at least 2).

MIN_GROUP_TRADES

Raises:

Type Description
ValueError

On fewer than two trades (no spread to measure), a non-finite PnL, a non-positive stake, forecasts outside [0, 1] or a direction other than yes/no.

to_dict

to_dict() -> dict[str, Any]

from_dict classmethod

from_dict(data: Mapping[str, Any]) -> BacktestExpectation

Restore a record; raises ValueError (KeyError if a key is absent) if unusable.

A record written before 0.9.1 has neither by_direction nor policy; both load as None. One that has them but cannot be used raises: it must never read as "not recorded" and quietly weaken the watchdog.

GroupExpectation dataclass

GroupExpectation(
    n_trades: int,
    mean_pnl_per_stake: float,
    std_pnl_per_stake: float,
    brier_score: float | None = None,
    brier_std: float | None = None,
)

Mean, spread and size of one group of backtest trades: a side, or a segment.

The same numbers BacktestExpectation keeps for the whole backtest, per unit stake. A group is held to the same rules as the whole: every number finite, a spread that is not negative, at least two trades, a Brier score in [0, 1].

n_trades instance-attribute

n_trades: int

mean_pnl_per_stake instance-attribute

mean_pnl_per_stake: float

std_pnl_per_stake instance-attribute

std_pnl_per_stake: float

brier_score class-attribute instance-attribute

brier_score: float | None = None

brier_std class-attribute instance-attribute

brier_std: float | None = None

to_dict

to_dict() -> dict[str, Any]

from_dict classmethod

from_dict(data: Mapping[str, Any]) -> GroupExpectation

Restore a record; raises ValueError (KeyError if a key is absent) if unusable.

SegmentExpectation dataclass

SegmentExpectation(
    n_trades: int,
    mean_pnl_per_stake: float,
    std_pnl_per_stake: float,
    brier_score: float | None = None,
    brier_std: float | None = None,
    by_direction: Mapping[str, GroupExpectation]
    | None = None,
)

Bases: GroupExpectation

One segment's numbers, with that segment's own split by side.

A segment trades YES and NO in its own proportion, so a live segment is held to its own per-side means, weighted by the live mix, exactly as the whole book is.

by_direction class-attribute instance-attribute

by_direction: Mapping[str, GroupExpectation] | None = None

to_dict

to_dict() -> dict[str, Any]

from_dict classmethod

from_dict(data: Mapping[str, Any]) -> SegmentExpectation

Restore a record; raises ValueError (KeyError if a key is absent) if unusable.

purged_kfold

purged_kfold(
    samples: NDArray[int_] | ndarray,
    n_splits: int = 5,
    embargo: int = 0,
) -> Iterator[tuple[NDArray[np.int_], NDArray[np.int_]]]

Yield (train_idx, test_idx) for purged k-fold CV.

The samples are split into n_splits contiguous test blocks (preserving time order). For each, the training set is every other sample minus an embargo band immediately before and after the test block.

Parameters:

Name Type Description Default
samples NDArray[int_] | ndarray

1-D array whose length defines the sample count (values unused; pass np.arange(n) or the time-ordered index).

required
n_splits int

Number of folds (>= 2).

5
embargo int

Number of samples to purge on each side of the test block.

0

Yields:

Type Description
tuple[NDArray[int_], NDArray[int_]]

(train_idx, test_idx) as positional-index arrays into samples.

embargoed_split

embargoed_split(
    samples: NDArray[int_] | ndarray,
    n_splits: int = 5,
    embargo: int = 0,
) -> Iterator[tuple[NDArray[np.int_], NDArray[np.int_]]]

Yield (train_idx, test_idx) for walk-forward CV with an embargo gap.

Expanding-origin: fold f's training set is everything up to the start of its test block minus an embargo gap, and the test block is the next contiguous slice. Train is always strictly earlier than test.

Parameters:

Name Type Description Default
samples NDArray[int_] | ndarray

1-D array whose length defines the sample count.

required
n_splits int

Number of forward test blocks (>= 2).

5
embargo int

Gap (in samples) between the end of train and the start of test.

0

Yields:

Type Description
NDArray[int_]

(train_idx, test_idx) positional-index arrays. Early folds may yield an

NDArray[int_]

empty train_idx when the embargo consumes the available history.