Plugin Authoring Guide¶
What is a plugin?¶
A MarketPlugin is a Python class that teaches pmlab how to work with one Polymarket
market family: how to find markets, generate features, fetch truth, and build training data.
The core framework (backtest, paper trading, settlement) is 100% plugin-agnostic. You write the domain logic; pmlab handles everything else.
Minimal example¶
from pmlab import MarketPlugin, MarketSpec, OutcomeBin
from typing import Any
class CryptoPricePlugin(MarketPlugin):
family = "crypto_price" # unique identifier
def discover_markets(self, **kwargs: Any) -> list[MarketSpec]:
# Fetch open "Will BTC be above $X on date Y?" markets
...
def fetch_features(self, spec: MarketSpec, horizon: str, **kwargs: Any) -> dict[str, float]:
# Return numeric features: {"btc_price": 65000.0, "rsi_14": 52.3, ...}
...
def fetch_truth(self, spec: MarketSpec, **kwargs: Any) -> float | str | None:
# Return BTC closing price on target date, or None if not yet resolved
...
def build_training_row(self, spec: MarketSpec, horizon: str, **kwargs: Any) -> dict | None:
# A binary market: one bin ("Yes"), so one row. `price` is the Yes price you
# observed at decision time: look it up, and return None if you cannot. Never
# substitute a default such as 0.5; the backtest would trade against it.
features = self.fetch_features(spec, horizon)
truth = self.fetch_truth(spec)
price = self.yes_price_at(spec, kwargs["decision_time"]) # your own lookup
if truth is None or price is None:
return None
winning_label = spec.resolve_winning_bin(float(truth))
return {"market_id": spec.market_id, "decision_horizon": horizon,
"decision_date": kwargs["decision_time"][:10],
"outcome_label": "Yes", "winning_label": winning_label,
"market_price": price, **features}
A market with more than one bin (temperature ranges, a race winner) is not one row:
see One row per bin before you write build_training_row for it.
The four methods¶
discover_markets(**kwargs) -> list[MarketSpec]¶
Fetch currently open markets for your family from Polymarket.
Use GammaClient from pmlab.markets.gamma_client to query the API.
Return a list of MarketSpec objects.
fetch_features(spec, horizon, **kwargs) -> dict[str, float]¶
Generate numeric features for a single (market, decision horizon) pair. All feature values must be float. Feature names must be consistent across calls.
Horizons: "market_open", "previous_evening", "morning_of".
fetch_truth(spec, **kwargs) -> float | str | None¶
- Range/numeric markets: return a float (e.g.
31.2for temperature °C). - Categorical markets: return the winning label string (e.g.
"Verstappen"). - Unresolved: return
None.
build_training_row(spec, horizon, **kwargs) -> dict | None¶
Assemble one labeled training row: one (market, bin, decision). It is the only row-building method you have to write. Required output keys:
{
"market_id": str,
"decision_horizon": str,
"decision_date": str, # YYYY-MM-DD (UTC date of the decision), what the backtest orders and splits on
"outcome_label": str, # the bin THIS row describes
"winning_label": str, # the bin that won
"market_price": float, # the YES price of outcome_label, observed before the decision
# + all feature columns (keys starting with "feature_" required to train on them)
}
None if data is unavailable.
build_training_rows(spec, horizon, **kwargs) -> list[dict]¶
Every row of one (market, decision): the plural form, and the one a panel is built
from. The default wraps build_training_row ([row], or [] for None), so a plugin
written for the singular form needs no change. Override it for a market with several bins.
from pmlab import build_panel
panel = build_panel(plugin.build_training_rows(s, "morning_of", decision_time=t) for s in specs)
build_panel takes the per-market lists as they come (and still takes single rows and
None).
One row per bin¶
A market with several outcome bins is several rows: one per bin, the same market_id
and decision_date, each with that bin's outcome_label and that bin's market_price.
The backtest decides per market which bin to buy and whether to buy YES or NO
(--policy live, the default, is what record-trades does). It can only do that if it
sees the bins it could have chosen. A panel with a single row per market, labelled with
the bin that won, shows it only winners: every YES it buys wins and every NO it buys
loses, whatever the model says, and the PnL it reports belongs to the panel, not the
model. That was weather_tmax before 0.9.2
(#28): on a synthetic world with
fair prices, where the expected PnL of any policy is exactly zero, it reported a 100% hit
rate under --policy top-bin-yes and, with the CLI's LightGBM model, a 0% hit rate under
--policy live, each 18 to 20 standard deviations from zero. (The sign under live follows
the model: it trained on winners only, LightGBM then reports a probability of about zero for
every label, and the policy buys NO on the winner.)
Rules for the rows of one market:
- Every bin that has a usable price gets a row, winner and losers alike.
outcome_label == winning_labelfor exactly one of them. market_priceis the price of that row's bin, never the market's maximum or favourite, and never the same number on every row.- The price is point-in-time. It was observed strictly before the decision
(the rule
FeatureSnapshotStore.as_ofapplies to features). Say when and where it came from, withprice_ts(ISO-8601),decision_tsandprice_source;build_panelthen refuses a panel in which a price is at or after its decision (LeakageError, seecheck_price_timing). A panel with neither column is not checked, so a plugin that omits them is trusted, not verified. A panel with only one ofprice_tsanddecision_tsis refused (LeakageError, whateverraise_on_leaksays): a price time with no decision to compare it to, or a decision with no price time, is not an untimed panel, it is a timing schema that cannot be checked. Emit both or neither.
An untimed panel is built, with one warning. build_panel logs a single WARNING (logger
pmlab.data.panel_builder, once per call, through logging, never to stdout) when no row
carries price_ts/decision_ts: market_price drives every trade the backtest takes and
nothing says it was known by the decision, so lookahead in the prices would go undetected.
Untimed today: every plugin written before the check, the sports_f1 plugin (its price is
metadata["market_price"] or 0.5, and its row has no outcome_label or decision_date
either), and build_panel_from_snapshots, whose market_price is a snapshot feature that
as_of took before the decision but whose panel carries no price_ts/decision_ts. Filter
the logger to silence it; stamping the rows is the fix. pmlab monitor never builds a
panel (it reads its --reference directly), so the warning cannot reach its quiet-on-green
output; a test holds that.
And the decision was taken before the market ended. A price observed before a
decision is still not a decision-time price when the decision came after the market had
resolved: the outcome was known, and so was the price that reflects it. Give each row
market_close_ts (ISO-8601, when the market closed or its outcome became known) and
check_price_timing refuses a row whose decision_ts is at or after it. A row with
no readable market_close_ts is counted (close_unknown in the report) but not
refused; a plugin that cannot show the market was open should not emit the row.
4. A bin with no usable price gets no row. Never fill one in (0.5 included): the
backtest would trade against a quote nobody made. Count what you drop, and say why.
5. The model sees only feature_* columns. Features that are the same for every bin of a
market (a forecast) cannot tell the bins apart; if the model should, add bin-aware
features (the bin's bounds, the forecast's distance from them).
How the counts differ from a one-row panel: a panel has K rows per market, but a
backtest makes one trade per (market, decision date), so num_trades, hit rate, the gate's
per-segment n and the watchdog's counts are all per trade and need no change. Two
settings are in rows, not markets: --min-train-rows (20 rows is 4 markets at K=5, so
scale it by K) and any row-wise split you do yourself, where the bins of one market belong
on the same side.
weather_tmax¶
WeatherTmaxPlugin.build_training_rows(spec, horizon, decision_time=...) is the reference
(decision_time is required; there is no way to check a price against a decision that
has no time). decision_ts is that instant in UTC and decision_date is the UTC date:
2026-05-10T23:30:00-05:00 is 2026-05-11T04:30:00Z, so its decision_date is
2026-05-11, not the 2026-05-10 it is where you are. It reads each bin's price from spec.metadata["outcome_schema"], where
discover_markets put the Gamma outcomePrices and stamped when it saw them
(price_observed_at, price_source = "discovery").
The only price this plugin has is the discovery-time one. pmlab.markets reads the
CLOB's current midpoint (fetch_token_prices) and keeps no history, and the snapshot
store is keyed per market and has no per-bin price. So a training row's price is the
Gamma price at the moment discover_markets ran, and that must be before the decision. To
train on a better price (a CLOB midpoint at the horizon, say), put it in the spec's
outcome_schema with its own observed_at and source before calling.
from collections import Counter
from pmlab import build_panel, WeatherTmaxPlugin
plugin = WeatherTmaxPlugin(gamma_client=..., forecast_client=..., truth_client=...)
specs = plugin.discover_markets() # prices are stamped "observed now"
exclusions: Counter[str] = Counter()
rows = [
row
for spec in specs
for row in plugin.build_training_rows(
spec, "morning_of", decision_time="2026-06-05T08:00:00Z", exclusions=exclusions
)
]
panel = build_panel(rows)
print(exclusions) # e.g. Counter({'price_missing': 3, 'price_after_cutoff': 12, ...})
A bin is left out, and counted under its reason, when its price is price_missing,
price_invalid (not a number in [0, 1]), price_degenerate (exactly 0 or 1, which is what
a settled market shows), price_untimed (no readable observation time),
price_after_cutoff or, if you ask for it, price_stale (below). A market is left out
entirely when the decision came at or after it
ended (decision_after_close, below) or its end cannot be read (market_close_unknown),
when it is market_unresolved, its truth is in no bin (market_truth_unbinned), or no bin is
usable (market_no_usable_price). Without an exclusions mapping the counts are logged at WARNING
per market, never lost. A market with a bin left out still has rows for the others, so the backtest
chooses among the bins that were priced.
discover_markets stamps its own time on the prices, so a market discovered after the
decision has prices that are not decision-time prices, and every bin of it is excluded as
price_after_cutoff. Capture the specs at the decision, store them, and build the rows when
the market resolves.
A discovery price is reused for every later decision, so a price seen a week before a
decision is a week-old quote of a market that has moved. By default that is accepted (the
only price the plugin has; the row's price_ts says how old it is). To bound it, pass
max_price_age (a timedelta, or a positive number of seconds) to build_training_rows: a
bin whose price was observed longer before the decision than that is left out and counted as
price_stale; a price exactly that old is kept. None (the default) keeps the old behaviour.
It is a plugin argument only: no pmlab command builds a weather panel, so there is no CLI
option. The deprecated build_training_row takes it too, but only with a decision_time
(there is nothing to measure the age from otherwise, and it raises rather than ignore it).
rows = plugin.build_training_rows(
spec, "morning_of", decision_time="2026-06-05T08:00:00Z",
max_price_age=timedelta(hours=6), exclusions=exclusions,
)
A market ends at its close_time (Gamma's endDate) 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_time at or after that gives no rows for the market, counted once as
decision_after_close, even when every price predates the decision: a market discovered
after it resolved carries only post-resolution prices, and any later decision is "after" them.
A decision on the target day itself, before the close, is fine (the morning_of horizon). The
target day's end bounds a close that comes later because the day's temperature is known when
the day ends, while a market can stay open for days to settle. A target_date that is free
text ("June 5", which is what discovery parses from the question) carries no year and is not
read, so the close_time alone decides. A market with neither readable is excluded as
market_close_unknown: it cannot be shown to have been open. Each row records the end it was
checked against in market_close_ts.
The deprecated build_training_row is the single winning-bin row, kept so existing callers
do not break; it emits a DeprecationWarning, and a panel made of it is biased the way
described above. Called without a decision_time (as it always could be) its row has no
decision_date, so build_panel rejects it (PanelSchemaError) until you add one, and
then builds an untimed panel with a warning.
Optional: is_truth_final(spec, **kwargs) -> bool¶
Return True when the truth from fetch_truth() is final (not preliminary).
Default: True whenever fetch_truth() returns non-None.
Override when your data source has a finalization lag (e.g. weather stations).
Registering your plugin¶
from pmlab import PluginRegistry
from my_package import CryptoPricePlugin
registry = PluginRegistry()
registry.register(CryptoPricePlugin())
plugin = registry.get("crypto_price")
Dependency injection¶
Don't hardcode API clients in __init__. Accept them as parameters:
class CryptoPricePlugin(MarketPlugin):
def __init__(self, gamma_client=None, price_client=None):
self._gamma = gamma_client or GammaClient()
self._prices = price_client
This makes your plugin testable without network calls:
from unittest.mock import MagicMock
mock_gamma = MagicMock()
mock_gamma.fetch_markets.return_value = [...]
plugin = CryptoPricePlugin(gamma_client=mock_gamma)
Testing your plugin¶
from unittest.mock import MagicMock
from pmlab import MarketSpec
def test_fetch_features_returns_float_dict():
plugin = CryptoPricePlugin()
spec = MarketSpec(market_id="btc_001", ...)
features = plugin.fetch_features(spec, "previous_evening")
assert isinstance(features, dict)
assert all(isinstance(v, float) for v in features.values())
def test_truth_none_when_unresolved():
plugin = CryptoPricePlugin(price_client=None)
spec = MarketSpec(...)
assert plugin.fetch_truth(spec) is None
Bundled plugins¶
| Family | Class | State |
|---|---|---|
weather_tmax |
WeatherTmaxPlugin (public: from pmlab import WeatherTmaxPlugin) |
The reference implementation: "Highest temperature in [city] on [date]?" markets. Features are the forecast temperature, the ensemble spread, the lead time and a city baseline; the truth is the official observation. One training row per bin, at a price observed before the decision. Runs offline on the bundled sample. |
sports_f1 |
pmlab.plugins.sports_f1.SportsF1Plugin (not part of the public API) |
A skeleton for categorical outcomes (a driver or team name is the winning label). Its row has an untimed price (metadata["market_price"], else 0.5) and no outcome_label or decision_date, so it cannot pass build_panel as it stands. Use it as a starting point, not as a finished plugin. |
Reference implementation¶
See src/pmlab/plugins/weather_tmax/plugin.py — the most complete plugin example.
It demonstrates dependency injection, spec building from raw API dicts, truth
resolution for numeric (temperature) markets, and one training row per bin at a
point-in-time price.