Skip to content

Plugins and workspaces

What a market family implements, how plugins are registered and discovered, and the workspace that keeps live and training data apart.

MarketPlugin

Bases: ABC

Abstract base class for all pmlab market family plugins.

family instance-attribute

family: str

discover_markets abstractmethod

discover_markets(**kwargs: Any) -> list[MarketSpec]

Fetch currently open markets for this family from Polymarket.

Returns:

Type Description
list[MarketSpec]

List of MarketSpec objects for tradeable markets.

fetch_features abstractmethod

fetch_features(
    spec: MarketSpec, horizon: str, **kwargs: Any
) -> dict[str, float]

Return a flat feature dict for a single (market, decision horizon) pair.

Features must be numeric and named consistently across calls — the modeling layer will assemble a DataFrame from many such dicts.

Parameters:

Name Type Description Default
spec MarketSpec

The market to generate features for.

required
horizon str

Decision point identifier, e.g. "market_open", "previous_evening".

required

Returns:

Type Description
dict[str, float]

Dict mapping feature name → float value.

fetch_truth abstractmethod

fetch_truth(
    spec: MarketSpec, **kwargs: Any
) -> float | str | None

Return the realized outcome value for a resolved market.

For range/numeric markets: return a float (e.g. 31.2 for temperature). For categorical markets: return the winning label string (e.g. "Verstappen"). For unresolved markets: return None.

Parameters:

Name Type Description Default
spec MarketSpec

The market whose outcome to fetch.

required

Returns:

Type Description
float | str | None

Realized outcome or None if not yet resolved.

build_training_row abstractmethod

build_training_row(
    spec: MarketSpec, horizon: str, **kwargs: Any
) -> dict[str, Any] | None

Assemble a single labeled training row for model training.

Combines fetch_features() output with target labels and market prices. Return None if required data is unavailable for this (market, horizon).

The returned dict should include at minimum
  • All feature columns (numeric)
  • "winning_label": str (resolved outcome label)
  • "market_price": float (the YES price of outcome_label at decision time)
  • "market_id": str
  • "decision_horizon": str
  • "outcome_label": str (the bin this row represents)

A market with several outcome bins is several rows: see build_training_rows, which is what a panel should be built from. This method describes one (market, bin, decision) and is the only one a plugin has to write.

Parameters:

Name Type Description Default
spec MarketSpec

The market.

required
horizon str

Decision point identifier.

required

Returns:

Type Description
dict[str, Any] | None

Dict for one training row, or None if data unavailable.

build_training_rows

build_training_rows(
    spec: MarketSpec, horizon: str, **kwargs: Any
) -> list[dict[str, Any]]

Assemble every labeled training row of one (market, decision): one per outcome bin.

The backtest chooses between a market's bins and between buying and selling each, so it needs to see all of them, each at the price that bin had when the decision was taken, labelled with whether that bin won (outcome_label against winning_label). A panel holding only the bin that won makes every YES a winner and every NO a loser, and its backtest reports an edge that is not there.

The default wraps build_training_row, so a plugin written for the singular form keeps working: one row, or none when that returns None. Override this for a market with several bins. Then every row must carry:

- ``market_id``, ``decision_date`` (YYYY-MM-DD, the UTC date of the decision),
  ``decision_horizon``
- ``outcome_label`` (the bin this row is) and ``winning_label``
- ``market_price`` that bin's YES price, observed strictly before the decision
- feature columns (``feature_*``)

and should say where the price came from and when it was observed (price_source, price_ts) against the decision (decision_ts), which check_price_timing then enforces. A bin with no usable price gets no row; never fill one in.

Parameters:

Name Type Description Default
spec MarketSpec

The market.

required
horizon str

Decision point identifier.

required

Returns:

Type Description
list[dict[str, Any]]

The rows, possibly empty.

is_truth_final

is_truth_final(spec: MarketSpec, **kwargs: Any) -> bool

Return True if the truth for spec is final and safe to settle.

Default: True whenever fetch_truth() returns non-None. Override if your data source has a lag before finalization (e.g. weather stations may have preliminary vs confirmed readings).

PluginRegistry

PluginRegistry()

Registry of installed MarketPlugin instances, keyed by family name.

register

register(plugin: MarketPlugin) -> None

Register a plugin. Raises if family already registered.

get

get(family: str) -> MarketPlugin

Return plugin for family. Raises KeyError if not found.

list_families

list_families() -> list[str]

Return sorted list of registered family names.

unregister

unregister(family: str) -> None

Remove plugin for family. No-op if not registered.

WeatherTmaxPlugin

WeatherTmaxPlugin(
    gamma_client: Any | None = None,
    forecast_client: Any | None = None,
    truth_client: Any | None = None,
    clock: Callable[[], datetime] | None = None,
)

Bases: MarketPlugin

Plugin for Polymarket 'Highest temperature in [city] on [date]?' markets.

Market family: "weather_tmax"

Features include: - ECMWF forecast temperature and ensemble spread - Lead time (days before market close) - City climatological baseline (historical mean/std) - Intraday observations (where available)

Truth source: official temperature observations (Wunderground / NOAA / CWA) after market close.

Parameters:

Name Type Description Default
gamma_client Any | None

Polymarket Gamma API client (injected for testing).

None
forecast_client Any | None

Weather forecast client (ECMWF / OpenMeteo).

None
truth_client Any | None

Official temperature observation client.

None
clock Callable[[], datetime] | None

Returns "now"; stamps when discovery observed the prices (naive is taken as UTC). Defaults to the real UTC clock; inject it in tests.

None

family class-attribute instance-attribute

family = 'weather_tmax'

discover_markets

discover_markets(**kwargs: Any) -> list[MarketSpec]

Fetch open temperature markets from the Polymarket Gamma API.

Paginates through the Gamma API until all markets are retrieved, then filters by: 1. Question matches highest temperature in ... on ...? (regex). 2. Description references an authoritative source (soft check). 3. Outcomes contain temperature bins with °C or °F (soft check).

Every spec records when its prices were seen (metadata["price_observed_at"], one clock reading per call) and that they came from discovery (metadata["price_source"]): a training row may only use a price observed before its decision, and cannot tell that without a time.

fetch_features

fetch_features(
    spec: MarketSpec, horizon: str, **kwargs: Any
) -> dict[str, float]

Return forecast features for (city, target_date, horizon).

When forecast_client is None (e.g. during testing), returns a stub dict with the canonical feature keys expected by the model, all set to zero. In production the forecast client populates real values.

fetch_truth

fetch_truth(
    spec: MarketSpec, **kwargs: Any
) -> float | None

Return the official maximum temperature observation in Celsius, or None.

build_training_rows

build_training_rows(
    spec: MarketSpec, horizon: str, **kwargs: Any
) -> list[dict[str, Any]]

Assemble the labeled training rows of one market: one per temperature bin.

Each row describes one bin at the moment of the decision:

  • market_id, decision_horizon, decision_date (UTC, YYYY-MM-DD)
  • outcome_label — this bin; winning_label — the bin the truth fell in
  • market_price — this bin's YES price
  • price_source and price_ts — where that price came from and when it was observed; decision_ts — the decision it informs
  • the forecast features, identical on every row of the market

A bin's price comes from spec.metadata["outcome_schema"] ({"label", "price"}, optionally "observed_at" and "source" per bin), with price_observed_at / price_source on the metadata as the default for bins that do not say. Discovery fills these in from the Gamma payload, so the only price this plugin has is the one discovery saw: not a CLOB mid at the horizon. To train on better prices, put them in the spec before calling this.

A bin gets no row, and is counted in exclusions under the reason, when its price is missing (price_missing), not a probability (price_invalid), exactly 0 or 1 (price_degenerate), has no readable time (price_untimed), or was observed at or after the decision (price_after_cutoff). It is never replaced by a default. A market that is not resolved, whose truth falls in no bin, or that has no usable bin gives no rows (market_unresolved, market_truth_unbinned, market_no_usable_price).

A price observed before the decision is still no decision-time price if the decision came after the market ended: the weather was known, and so was the winner (a market discovered after it resolved has only such prices). The market ends at its close_time or at the end of its target_date when that is an ISO date, whichever is first (a date-only close_time ends with its day). A decision at or after that gives no rows, counted once per market as decision_after_close; a market with neither readable gives none as market_close_unknown. A decision on the target day itself, before the close, is fine (morning_of). Every row records the end it was checked against in market_close_ts, which build_panel checks again (see pmlab.data.check_price_timing).

Parameters:

Name Type Description Default
spec MarketSpec

The market.

required
horizon str

Decision point identifier.

required

Other Parameters:

Name Type Description
decision_time str | datetime

Required. When the decision is taken (ISO-8601 or a datetime; naive is UTC). No price observed at or after it is used. The row's decision_ts is this instant in UTC and its decision_date is the UTC date, not the date where you are: a decision at 2026-05-10T23:30:00-05:00 is 2026-05-11T04:30:00Z, so its decision_date is 2026-05-11. The backtest orders and splits on decision_date, so decide in UTC days consistently.

exclusions dict | None

Optional mapping the exclusion counts are added to, so a caller can total them over many markets. Without one, a market's exclusions are logged at WARNING instead of lost.

max_price_age timedelta | float | None

Optional (a timedelta, or a positive number of seconds). A bin whose price was observed longer before the decision than this is excluded and counted as price_stale; a price exactly this old is kept. Default None: a price of any age is used, as before. The discovery price is reused for every later decision, so a price seen a week before is a week-old quote; this is the knob that says how old is too old.

Raises:

Type Description
ValueError

decision_time is missing or not a timestamp, or max_price_age is not a positive, finite duration.

build_training_row

build_training_row(
    spec: MarketSpec, horizon: str, **kwargs: Any
) -> dict[str, Any] | None

The one row of the bin that won.

Deprecated since 0.9.2 and will be removed in 2.0.0: use build_training_rows (one row per bin) instead. It emits only the winning bin, which biases any backtest built from it.

A panel of one row per market, labelled with the winner, shows the backtest only winners (issue #28): every YES it takes wins, so its PnL is a property of the panel and not of the model. The row is now at least self-consistent: the winning bin's own price, not the market's highest. decision_time is optional here, as it always was, and without it nothing checks the price against a decision.

Without a decision_time the row has no decision_date (nor decision_ts or price_ts), so build_panel rejects it (PanelSchemaError: a required column is missing) until the caller adds one, and then builds an untimed panel with a warning. Pass decision_time to get the timed row, with the same exclusions as build_training_rows, or better, use that method.

discover_plugins

discover_plugins() -> PluginRegistry

Convenience wrapper: create a new registry and populate from entry_points.

Returns:

Type Description
PluginRegistry

A PluginRegistry with all installed pmlab plugins.

load_plugins_from_entry_points

load_plugins_from_entry_points(
    registry: PluginRegistry | None = None,
) -> PluginRegistry

Load all plugins registered under the 'pmlab.plugins' entry_point group.

Parameters:

Name Type Description Default
registry PluginRegistry | None

Optional existing registry to add plugins to. If None, a new PluginRegistry is created.

None

Returns:

Type Description
PluginRegistry

Registry populated with all successfully loaded plugins.

WorkspaceContext dataclass

WorkspaceContext(
    name: str,
    data_root: Path,
    artifacts_root: Path,
    parquet_root: Path,
    duckdb_path: Path,
)

name instance-attribute

name: str

data_root instance-attribute

data_root: Path

artifacts_root instance-attribute

artifacts_root: Path

parquet_root instance-attribute

parquet_root: Path

duckdb_path instance-attribute

duckdb_path: Path

from_name classmethod

from_name(name: str, repo_root: Path) -> WorkspaceContext

from_env classmethod

from_env() -> WorkspaceContext