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.
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_labelat 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.
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
|
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 inmarket_price— this bin's YES priceprice_sourceandprice_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
|
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 |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
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,
)