Skip to content

Modeling

Forecasters, calibration, diagnostics, and the champion: the published model, its manifest, and the registry that archives each promotion.

MarketForecaster

Bases: ABC

Abstract base class for all market forecasters.

fit abstractmethod

fit(X: DataFrame, y: Series) -> None

Fit the model on training data.

predict_proba abstractmethod

predict_proba(X: DataFrame) -> np.ndarray

Return probability estimates.

save abstractmethod

save(path: Path) -> None

Serialize model to disk.

load abstractmethod classmethod

load(path: Path) -> MarketForecaster

Deserialize model from disk.

feature_names

feature_names() -> list[str]

Return list of feature names used by this model.

LGBMForecaster

LGBMForecaster(
    objective: str = "binary", **lgbm_params: Any
)

Bases: MarketForecaster

LightGBM wrapper supporting binary and multiclass classification.

objective instance-attribute

objective = objective

lgbm_params instance-attribute

lgbm_params = lgbm_params

resolved_params

resolved_params() -> dict[str, Any]

The parameters LightGBM is actually given: the defaults, then the caller's overrides.

This is what a reproducibility record stores as the model config, so a later change of a default here does not change what an old record means. The seed is part of it only when the caller passed random_state (LightGBM otherwise uses its own fixed internal seeds: deterministic, but not a value pmlab can record).

fit

fit(X: DataFrame, y: Series) -> None

Fit LightGBM classifier.

predict_proba

predict_proba(X: DataFrame) -> np.ndarray

Return probability estimates, shape (n_samples, n_classes).

save

save(path: Path) -> None

Pickle-serialize the model to path.

load classmethod

load(path: Path) -> LGBMForecaster

Load a previously saved LGBMForecaster from path.

feature_names

feature_names() -> list[str]

SklearnForecaster

SklearnForecaster(
    estimator: str
    | ClassifierMixin = "logistic_regression",
    **kwargs: Any,
)

Bases: MarketForecaster

sklearn-based market forecaster supporting named presets and custom estimators.

Parameters:

Name Type Description Default
estimator str | ClassifierMixin

One of 'logistic_regression' (default), 'random_forest', 'gradient_boosting', or a pre-instantiated sklearn classifier.

'logistic_regression'
**kwargs Any

Extra kwargs forwarded to the preset constructor (ignored when a pre-instantiated estimator is passed).

{}

Examples::

# Named preset
f = SklearnForecaster(estimator="random_forest", n_estimators=50)

# Custom estimator
from sklearn.tree import DecisionTreeClassifier
f = SklearnForecaster(estimator=DecisionTreeClassifier(max_depth=4))

fit

fit(X: DataFrame, y: Series) -> None

Fit the sklearn estimator on training data.

predict_proba

predict_proba(X: DataFrame) -> np.ndarray

Return probability estimates, shape (n_samples, n_classes).

save

save(path: Path) -> None

Pickle-serialize the forecaster to path.

load classmethod

load(path: Path) -> SklearnForecaster

Load a previously saved SklearnForecaster from path.

feature_names

feature_names() -> list[str]

Return feature names from the last fit() call.

EnsembleForecaster

EnsembleForecaster(
    forecasters: Sequence[MarketForecaster],
    weights: Sequence[float] | None = None,
)

Bases: MarketForecaster

Weighted-average ensemble of member forecasters.

Parameters:

Name Type Description Default
forecasters Sequence[MarketForecaster]

One or more fitted-or-unfitted MarketForecaster members. fit trains every member on the same data; if you prefer to blend already-trained models, pass them in and call fit anyway (cheap) or set them up before use — predict_proba only needs them fitted.

required
weights Sequence[float] | None

Optional non-negative blend weights, one per member. Defaults to equal weight. Normalized internally, so [3, 1] == [0.75, 0.25].

None

Example::

ens = EnsembleForecaster(
    forecasters=[LGBMForecaster(), SklearnForecaster("random_forest")],
    weights=[2.0, 1.0],
)
ens.fit(X_train, y_train)
proba = ens.predict_proba(X_test)

forecasters instance-attribute

forecasters: list[MarketForecaster] = list(forecasters)

fit

fit(X: DataFrame, y: Series) -> None

Fit every member on the same training data.

predict_proba

predict_proba(X: DataFrame) -> np.ndarray

Weighted-average of member probability matrices, shape (n, n_classes).

save

save(path: Path) -> None

Pickle-serialize the ensemble (members included) to path.

load classmethod

load(path: Path) -> EnsembleForecaster

Load a previously saved EnsembleForecaster from path.

feature_names

feature_names() -> list[str]

Return feature names from the last fit() call.

ConformalForecaster

ConformalForecaster(
    base: MarketForecaster,
    alpha: float = 0.1,
    calib_fraction: float = 0.3,
    random_state: int = 0,
)

Bases: MarketForecaster

Wrap a MarketForecaster to emit calibrated prediction sets.

Parameters:

Name Type Description Default
base MarketForecaster

The forecaster to wrap.

required
alpha float

Miscoverage level in (0, 1). Target coverage is 1 - alpha (e.g. alpha=0.1 → 90% sets).

0.1
calib_fraction float

Fraction of fit data held out for calibration.

0.3
random_state int

Seed for the calibration split.

0

predict_proba passes through the base model's probabilities (so an instance is still a drop-in forecaster); predict_set returns the coverage-guaranteed sets.

base instance-attribute

base = base

alpha instance-attribute

alpha = alpha

calib_fraction instance-attribute

calib_fraction = calib_fraction

random_state instance-attribute

random_state = random_state

fit

fit(X: DataFrame, y: Series) -> None

Fit base on a proper-train split, calibrate qhat on the held-out split.

predict_proba

predict_proba(X: DataFrame) -> np.ndarray

Pass through the base model's probability matrix, shape (n, n_classes).

predict_set

predict_set(X: DataFrame) -> list[set[object]]

Return one prediction set per row: classes with 1 - p_k <= qhat.

Every set is guaranteed non-empty (the argmax class always qualifies), so a downstream consumer never has to special-case the empty set. Label types are preserved (int, str, etc.) — categorical markets (e.g. F1 driver names) get their real labels back, not coerced integers.

save

save(path: Path) -> None

Pickle-serialize the wrapper (base + qhat + classes) to path.

load classmethod

load(path: Path) -> ConformalForecaster

Load a previously saved ConformalForecaster from path.

feature_names

feature_names() -> list[str]

Return feature names from the last fit() call.

TunedForecaster

TunedForecaster(
    build_fn: Callable[[dict[str, Any]], MarketForecaster],
    param_space: Callable[[Trial], dict[str, Any]],
    n_trials: int = 20,
    metric: TuneMetric = "total_pnl",
    random_state: int = 0,
    stride: int = 10,
    min_train_rows: int = 20,
    flat_stake: float = 1.0,
    taker_bps: float | None = None,
    policy: TradePolicy | None = None,
    costs: CostModel | None = None,
)

Bases: MarketForecaster

Optuna hyperparameter search scored by walk-forward backtest.

Parameters:

Name Type Description Default
build_fn Callable[[dict[str, Any]], MarketForecaster]

params -> MarketForecaster. Given the concrete params Optuna picked for a trial, return an (unfitted) forecaster.

required
param_space Callable[[Trial], dict[str, Any]]

trial -> params dict. Uses the Optuna trial API (trial.suggest_int etc.) to define the search space.

required
n_trials int

Number of Optuna trials (> 0).

20
metric TuneMetric

What to maximize — "total_pnl" (default), "mean_pnl", or "hit_rate".

'total_pnl'
random_state int

Seed for Optuna's sampler (reproducible search).

0
stride int

Forwarded to rolling_origin_eval.

10
min_train_rows int

Forwarded to rolling_origin_eval.

20
flat_stake float

Forwarded to rolling_origin_eval.

1.0
taker_bps float | None

Legacy fee-only knob (default 30). Mutually exclusive with costs.

None
policy TradePolicy | None

The decision rule every trial is scored under, a TradePolicy. Omit it for the historical objective (YES on the top-probability bin, every market: the search that shipped before policies existed, unchanged). Pass TradePolicy.live(min_edge=...) to tune for what record-trades trades: the best after-fee edge per market, YES or NO, taken if it clears min_edge. Use the min_edge you will trade with.

None
costs CostModel | None

The CostModel a trial's fills are priced with (fee, slippage, depth). Under the live policy it decides which trades clear the threshold, so tune with the costs you will trade with. Omit it for the 30 bps fee, fill at the quote.

None

After tune(panel), the instance is a fitted forecaster (best params, refit on the full panel) and exposes best_params_, best_score_, and study_. tuning_config returns the objective it maximised, resolved, for the reproducibility record (BacktestConfig.resolve(tuning=...)).

A trial that makes no trade scores -inf; if every trial does (a threshold nothing clears), tune warns, because the search learned nothing and best_params_ is just the first trial.

build_fn instance-attribute

build_fn = build_fn

param_space instance-attribute

param_space = param_space

n_trials instance-attribute

n_trials = n_trials

metric instance-attribute

metric: TuneMetric = metric

random_state instance-attribute

random_state = random_state

stride instance-attribute

stride = stride

min_train_rows instance-attribute

min_train_rows = min_train_rows

flat_stake instance-attribute

flat_stake = flat_stake

policy instance-attribute

policy = policy

costs instance-attribute

costs: CostModel = (
    costs
    if costs is not None
    else CostModel()
    if taker_bps is None
    else CostModel(taker_bps=taker_bps)
)

taker_bps instance-attribute

taker_bps: float = self.costs.taker_bps

best_params_ instance-attribute

best_params_: dict[str, Any] | None = None

best_score_ instance-attribute

best_score_: float = float('-inf')

study_ instance-attribute

study_: Study | None = None

tune

tune(panel: DataFrame) -> TunedForecaster

Run the Optuna search over panel, then refit best on the full panel.

tuning_config

tuning_config() -> dict[str, Any]

The objective the search maximised, resolved and strict-JSON, for the record.

Pass it to BacktestConfig.resolve(tuning=...) so the reproducibility record of the backtest says how its parameters were chosen: which metric, under which trade policy and costs, walking forward how. Every default is spelled out. The sampler seed is not in here: a run's seeds live in one place, resolve_seeds(seed, optuna=tuner.random_state). best_score is None before tune and for a search that never traded.

fit

fit(X: DataFrame, y: Series) -> None

Fit the current best model directly (bypasses search).

Available after tune; use tune for the walk-forward search. This exists so a TunedForecaster still satisfies the MarketForecaster contract.

predict_proba

predict_proba(X: DataFrame) -> np.ndarray

Delegate to the tuned-and-fitted model.

save

save(path: Path) -> None

Pickle-serialize the fitted best model + search metadata.

load classmethod

load(path: Path) -> TunedForecaster

Load a tuned forecaster. The Optuna study itself is not restored.

feature_names

feature_names() -> list[str]

Return feature names from the last fit/tune call.

CalibratedForecaster

CalibratedForecaster(
    base: MarketForecaster,
    method: CalibrationMethod = "isotonic",
    calib_fraction: float = 0.3,
    random_state: int = 0,
)

Bases: MarketForecaster

Wrap a MarketForecaster and calibrate its probability output.

On fit, the data is split into a training part (fits the base model) and a held-out calibration part (fits the calibrator on the base model's out-of-fold scores). predict_proba then returns the calibrated matrix. Binary targets calibrate the positive class; multiclass targets use one-vs-rest calibration (MulticlassCalibrator), so F1 / political multi-outcome markets are supported too.

Parameters:

Name Type Description Default
base MarketForecaster

The forecaster to wrap.

required
method CalibrationMethod

"isotonic" (default) or "sigmoid" (Platt).

'isotonic'
calib_fraction float

Fraction of fit data held out to fit the calibrator.

0.3
random_state int

Seed for the calibration split.

0

base instance-attribute

base = base

method instance-attribute

method: CalibrationMethod = method

calib_fraction instance-attribute

calib_fraction = calib_fraction

random_state instance-attribute

random_state = random_state

fit

fit(X: DataFrame, y: Series) -> None

Fit base model on a train split, calibrator on a held-out split.

predict_proba

predict_proba(X: DataFrame) -> np.ndarray

Return calibrated (n, n_classes) probabilities.

save

save(path: Path) -> None

Pickle-serialize the wrapper (base + calibrator) to path.

load classmethod

load(path: Path) -> CalibratedForecaster

Load a previously saved CalibratedForecaster from path.

feature_names

feature_names() -> list[str]

Return feature names from the last fit() call.

IsotonicCalibrator

IsotonicCalibrator()

Wraps sklearn IsotonicRegression to calibrate predicted probabilities.

fit

fit(probs: ndarray, labels: ndarray) -> None

Fit isotonic regression on raw probabilities and true labels.

transform

transform(probs: ndarray) -> np.ndarray

Calibrate probabilities, clipping output to [0, 1].

save

save(path: Path) -> None

Pickle-serialize calibrator to path.

load classmethod

load(path: Path) -> IsotonicCalibrator

Load calibrator from path.

SigmoidCalibrator

SigmoidCalibrator()

Platt scaling — a 1-D logistic regression mapping raw scores to probabilities.

More sample-efficient than isotonic when calibration data is scarce, at the cost of assuming a sigmoidal distortion. Output is monotonic in the input.

fit

fit(probs: ndarray, labels: ndarray) -> None

Fit the logistic mapping from raw scores to true labels.

transform

transform(probs: ndarray) -> np.ndarray

Map raw scores onto calibrated probabilities in [0, 1].

save

save(path: Path) -> None

Pickle-serialize calibrator to path.

load classmethod

load(path: Path) -> SigmoidCalibrator

Load calibrator from path.

MulticlassCalibrator

MulticlassCalibrator(
    method: CalibrationMethod = "isotonic",
)

One-vs-rest probability calibration for multiclass score matrices.

Fits an independent 1-D calibrator (isotonic or sigmoid) per class on the one-vs-rest problem "is this row class k?", then renormalizes the calibrated columns to sum to 1. This is the standard reduction sklearn uses for multiclass calibration, and it keeps each class's mapping monotonic.

Input to fit/transform is a (n, n_classes) probability matrix whose columns are ordered by np.unique of the training labels.

method instance-attribute

method: CalibrationMethod = method

fit

fit(proba: ndarray, labels: ndarray) -> None

Fit one calibrator per class on the one-vs-rest target.

transform

transform(proba: ndarray) -> np.ndarray

Calibrate each column, then renormalize rows to sum to 1.

save

save(path: Path) -> None

Pickle-serialize the per-class calibrators to path.

load classmethod

load(path: Path) -> MulticlassCalibrator

Load a previously saved MulticlassCalibrator from path.

BrierDecomposition dataclass

BrierDecomposition(
    uncertainty: float,
    resolution: float,
    reliability: float,
    brier_score: float,
    skill_score: float,
    n_samples: int,
)

uncertainty instance-attribute

uncertainty: float

resolution instance-attribute

resolution: float

reliability instance-attribute

reliability: float

brier_score instance-attribute

brier_score: float

skill_score instance-attribute

skill_score: float

n_samples instance-attribute

n_samples: int

brier_decomposition

brier_decomposition(
    y_true: ArrayLike, y_prob: ArrayLike, n_bins: int = 10
) -> BrierDecomposition

Murphy (1973) Brier score decomposition.

reliability_data

reliability_data(
    y_true: ArrayLike, y_prob: ArrayLike, n_bins: int = 10
) -> tuple[
    NDArray[np.float64],
    NDArray[np.float64],
    NDArray[np.float64],
]

Reliability diagram data: (bin_centers, mean_predicted_prob, fraction_positive).

MulticlassBrier dataclass

MulticlassBrier(
    brier_score: float,
    skill_score: float,
    n_samples: int,
    n_classes: int,
)

Multiclass Brier score = mean over rows of sum_k (p_k - onehot_k)^2.

brier_score instance-attribute

brier_score: float

skill_score instance-attribute

skill_score: float

n_samples instance-attribute

n_samples: int

n_classes instance-attribute

n_classes: int

multiclass_brier

multiclass_brier(
    y_true: ArrayLike, y_prob: ArrayLike
) -> MulticlassBrier

Multiclass Brier score with a climatology skill score.

y_true is a length-n vector of integer class indices (0..k-1); y_prob is the (n, k) probability matrix. Skill is measured against the base-rate (climatology) forecast that predicts the training class frequencies for every row: skill = 1 - BS_model / BS_climatology.

reliability_data_multiclass

reliability_data_multiclass(
    y_true: ArrayLike, y_prob: ArrayLike, n_bins: int = 10
) -> list[
    tuple[
        NDArray[np.float64],
        NDArray[np.float64],
        NDArray[np.float64],
    ]
]

Per-class reliability data via one-vs-rest.

Returns one (bin_centers, mean_predicted_prob, fraction_positive) tuple per class, in the column order of the probability matrix.

ChampionManifest dataclass

ChampionManifest(
    model_name: str,
    model_path: Path,
    calibrator_path: Path | None,
    gate: HoldoutGateResult,
    published_at: str,
    plugin_family: str,
    costs: CostModel | None = None,
    expectation: BacktestExpectation | None = None,
    reproducibility: ReproducibilityRecord | None = None,
)

model_name instance-attribute

model_name: str

model_path instance-attribute

model_path: Path

calibrator_path instance-attribute

calibrator_path: Path | None

gate instance-attribute

gate: HoldoutGateResult

published_at instance-attribute

published_at: str

plugin_family instance-attribute

plugin_family: str

costs class-attribute instance-attribute

costs: CostModel | None = None

expectation class-attribute instance-attribute

expectation: BacktestExpectation | None = None

reproducibility class-attribute instance-attribute

reproducibility: ReproducibilityRecord | None = None

publish classmethod

publish(
    model: MarketForecaster,
    gate: HoldoutGateResult,
    output_dir: Path,
    plugin_family: str,
    model_name: str = "champion",
    calibrator: IsotonicCalibrator | None = None,
    costs: CostModel | None = None,
    expectation: BacktestExpectation | None = None,
    reproducibility: ReproducibilityRecord | None = None,
) -> ChampionManifest

Publish a champion model.

HARD GATE: raises ValueError if gate.decision != "GO".

Parameters:

Name Type Description Default
costs CostModel | None

The cost model the gate was computed under. Recording it lets a live run assert it is trading with the same friction the promotion assumed; without it, live and backtest PnL can diverge for reasons that have nothing to do with the market.

None
expectation BacktestExpectation | None

The backtest's per-trade PnL and Brier statistics (BacktestExpectation.from_trades). Without it, pmlab monitor cannot say whether live PnL or calibration has fallen short of what the promotion assumed.

None
reproducibility ReproducibilityRecord | None

The backtest's ReproducibilityRecord (capture_backtest), so the result the promotion rests on can be re-run with pmlab reproduce.

None

to_dict

to_dict() -> dict[str, Any]

The champion.json payload: every field of the manifest, JSON-ready.

The one place a manifest is serialized. publish, ModelRegistry.record and ModelRegistry.rollback all write this, so a field added to the manifest reaches every copy of it, instead of being dropped by whichever writer was not updated (#33). ChampionManifest.load is its inverse (the JSON key for expectation is backtest_expectation, and the gate is stored as publish_gate).

load classmethod

load(json_path: Path) -> ChampionManifest

Load a ChampionManifest from champion.json.

get_allowed_segments

get_allowed_segments() -> set[str]

Return segments where the gate result passes.

A champion promoted on an aggregate gate returns {"all"}: it was graded on the whole trade log and allows every segment (see allows_segment).

allows_segment

allows_segment(segment: str) -> bool

May this champion trade segment? "all" among the allowed means yes.

load_model

load_model() -> MarketForecaster

Load the champion model from disk.

ModelRegistry

ModelRegistry(registry_dir: Path | str)

Append-only, versioned store of promoted champions.

registry_dir instance-attribute

registry_dir = Path(registry_dir)

record

record(manifest: ChampionManifest) -> str

Archive a published champion as an immutable version.

Re-asserts the hard gate: refuses to record anything whose gate is not GO, independent of whatever the caller claims. Returns the version_id.

list

list() -> list[RegistryEntry]

All archived versions, newest first (by published_at, then id).

get

get(version_id: str) -> ChampionManifest

Load the archived manifest for a version_id.

diff

diff(version_a: str, version_b: str) -> dict[str, object]

Compare two versions field by field.

Every field maps to an (a_value, b_value) tuple (equal fields included), so a reviewer sees the full promotion-time evidence — gate decision, PnL, trades, allowed segments — side by side. Compare the two elements of each tuple to spot what actually changed.

rollback

rollback(
    version_id: str, output_dir: Path | str
) -> ChampionManifest

Restore an archived version as the live champion in output_dir.

Rewrites champion.json / champion.pkl (and calibrator.pkl if present) in output_dir to point at the archived artifacts, so the standard ChampionManifest.load(output_dir / "champion.json") flow serves the rolled-back model. Does not delete newer versions — history is preserved.

RegistryEntry dataclass

RegistryEntry(
    version_id: str,
    model_name: str,
    plugin_family: str,
    published_at: str,
    gate_decision: str,
    aggregate_pnl: float,
    aggregate_trades: int,
)

Lightweight metadata for one archived champion (what list() returns).

version_id instance-attribute

version_id: str

model_name instance-attribute

model_name: str

plugin_family instance-attribute

plugin_family: str

published_at instance-attribute

published_at: str

gate_decision instance-attribute

gate_decision: str

aggregate_pnl instance-attribute

aggregate_pnl: float

aggregate_trades instance-attribute

aggregate_trades: int