pmlab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. pmlab/__init__.py +63 -0
  2. pmlab/backtest/__init__.py +0 -0
  3. pmlab/backtest/holdout_gate.py +127 -0
  4. pmlab/backtest/metrics.py +53 -0
  5. pmlab/backtest/rolling_origin.py +142 -0
  6. pmlab/cli/__init__.py +0 -0
  7. pmlab/cli/main.py +83 -0
  8. pmlab/core/__init__.py +0 -0
  9. pmlab/core/edge.py +27 -0
  10. pmlab/core/fees.py +21 -0
  11. pmlab/core/market_spec.py +73 -0
  12. pmlab/core/pnl.py +48 -0
  13. pmlab/core/sizing.py +72 -0
  14. pmlab/data/__init__.py +0 -0
  15. pmlab/execution/__init__.py +0 -0
  16. pmlab/execution/edge_signal.py +23 -0
  17. pmlab/execution/live_broker.py +187 -0
  18. pmlab/execution/paper_broker.py +141 -0
  19. pmlab/execution/settlement.py +131 -0
  20. pmlab/features/__init__.py +12 -0
  21. pmlab/features/transforms.py +95 -0
  22. pmlab/markets/__init__.py +0 -0
  23. pmlab/markets/async_clob_client.py +43 -0
  24. pmlab/markets/async_gamma_client.py +33 -0
  25. pmlab/markets/cache.py +70 -0
  26. pmlab/markets/clob_client.py +71 -0
  27. pmlab/markets/gamma_client.py +106 -0
  28. pmlab/modeling/__init__.py +0 -0
  29. pmlab/modeling/base.py +38 -0
  30. pmlab/modeling/calibration.py +45 -0
  31. pmlab/modeling/champion.py +113 -0
  32. pmlab/modeling/diagnostics.py +67 -0
  33. pmlab/modeling/lgbm_baseline.py +69 -0
  34. pmlab/plugins/__init__.py +0 -0
  35. pmlab/plugins/base.py +105 -0
  36. pmlab/plugins/registry.py +38 -0
  37. pmlab/plugins/sports_f1/__init__.py +0 -0
  38. pmlab/plugins/sports_f1/plugin.py +103 -0
  39. pmlab/plugins/weather_tmax/__init__.py +0 -0
  40. pmlab/plugins/weather_tmax/_spec_builder.py +78 -0
  41. pmlab/plugins/weather_tmax/plugin.py +112 -0
  42. pmlab/py.typed +0 -0
  43. pmlab/reports/__init__.py +4 -0
  44. pmlab/reports/html_report.py +165 -0
  45. pmlab/workspace/__init__.py +0 -0
  46. pmlab/workspace/context.py +36 -0
  47. pmlab-0.1.0.dist-info/METADATA +490 -0
  48. pmlab-0.1.0.dist-info/RECORD +51 -0
  49. pmlab-0.1.0.dist-info/WHEEL +4 -0
  50. pmlab-0.1.0.dist-info/entry_points.txt +2 -0
  51. pmlab-0.1.0.dist-info/licenses/LICENSE +21 -0
pmlab/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """pmlab — Generic ML framework for Polymarket prediction markets."""
2
+ from __future__ import annotations
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ from pmlab.backtest.holdout_gate import HoldoutGateResult, SegmentGateResult
7
+ from pmlab.backtest.metrics import BacktestMetrics, compute_metrics
8
+ from pmlab.core.edge import compute_edge
9
+ from pmlab.core.fees import estimate_fee
10
+ from pmlab.core.market_spec import MarketSpec, OutcomeBin
11
+ from pmlab.core.pnl import Position, settle_position
12
+ from pmlab.core.sizing import flat_stake_size, kelly_fraction, kelly_stake_size
13
+ from pmlab.execution.edge_signal import EdgeSignal
14
+ from pmlab.execution.live_broker import LiveBroker, LiveBrokerError, OrderReceipt
15
+ from pmlab.execution.paper_broker import PaperBroker
16
+ from pmlab.execution.settlement import SettlementEngine
17
+ from pmlab.features.transforms import (
18
+ add_lags,
19
+ add_rolling_stats,
20
+ clip_outliers,
21
+ encode_cyclical,
22
+ encode_onehot,
23
+ )
24
+ from pmlab.markets.async_clob_client import AsyncClobClient
25
+ from pmlab.markets.async_gamma_client import AsyncGammaClient
26
+ from pmlab.markets.cache import DiskCache
27
+ from pmlab.markets.clob_client import ClobClient
28
+ from pmlab.markets.gamma_client import GammaClient
29
+ from pmlab.modeling.base import MarketForecaster
30
+ from pmlab.modeling.champion import ChampionManifest
31
+ from pmlab.modeling.diagnostics import BrierDecomposition, brier_decomposition, reliability_data
32
+ from pmlab.modeling.lgbm_baseline import LGBMForecaster
33
+ from pmlab.plugins.base import MarketPlugin
34
+ from pmlab.plugins.registry import PluginRegistry
35
+ from pmlab.reports.html_report import generate_report
36
+ from pmlab.workspace.context import WorkspaceContext
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ # Core
41
+ "MarketSpec", "OutcomeBin",
42
+ "Position", "settle_position",
43
+ "compute_edge", "estimate_fee",
44
+ "flat_stake_size", "kelly_fraction", "kelly_stake_size",
45
+ # Execution
46
+ "EdgeSignal", "PaperBroker", "SettlementEngine",
47
+ "LiveBroker", "LiveBrokerError", "OrderReceipt",
48
+ # Features
49
+ "add_lags", "add_rolling_stats", "encode_cyclical", "encode_onehot", "clip_outliers",
50
+ # Markets
51
+ "GammaClient", "ClobClient", "AsyncGammaClient", "AsyncClobClient", "DiskCache",
52
+ # Modeling
53
+ "MarketForecaster", "ChampionManifest", "LGBMForecaster",
54
+ "BrierDecomposition", "brier_decomposition", "reliability_data",
55
+ # Backtest
56
+ "HoldoutGateResult", "SegmentGateResult",
57
+ "BacktestMetrics", "compute_metrics",
58
+ # Reports
59
+ "generate_report",
60
+ # Plugins / Workspace
61
+ "MarketPlugin", "PluginRegistry",
62
+ "WorkspaceContext",
63
+ ]
File without changes
@@ -0,0 +1,127 @@
1
+ """Holdout gate for go/no-go publish decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import pandas as pd
8
+
9
+
10
+ @dataclass
11
+ class SegmentGateResult:
12
+ segment: str
13
+ num_trades: int
14
+ total_pnl: float
15
+ passes: bool
16
+ reason: str # "ok" | "negative_pnl" | "insufficient_trades" | "missing"
17
+
18
+
19
+ @dataclass
20
+ class HoldoutGateResult:
21
+ decision: str # "GO" | "NO_GO"
22
+ segment_results: list[SegmentGateResult]
23
+ aggregate_pnl: float
24
+ aggregate_trades: int
25
+
26
+ @classmethod
27
+ def evaluate(
28
+ cls,
29
+ trades: pd.DataFrame, # columns: realized_pnl, outcome, segment
30
+ required_segments: list[str],
31
+ min_trades_per_segment: int = 40,
32
+ min_pnl_per_segment: float = 0.0,
33
+ ) -> HoldoutGateResult:
34
+ """Evaluate holdout gate across required segments.
35
+
36
+ Returns GO only if every required segment passes both
37
+ the PnL and trade-count thresholds.
38
+ """
39
+ segment_results: list[SegmentGateResult] = []
40
+
41
+ for seg in required_segments:
42
+ seg_trades = trades[trades["segment"] == seg] if not trades.empty else pd.DataFrame()
43
+
44
+ if seg_trades.empty and seg not in (trades["segment"].unique() if not trades.empty else []):
45
+ # Segment entirely missing from data
46
+ segment_results.append(
47
+ SegmentGateResult(
48
+ segment=seg,
49
+ num_trades=0,
50
+ total_pnl=0.0,
51
+ passes=False,
52
+ reason="missing",
53
+ )
54
+ )
55
+ continue
56
+
57
+ num = len(seg_trades)
58
+ total_pnl = float(seg_trades["realized_pnl"].sum())
59
+
60
+ if num < min_trades_per_segment:
61
+ reason = "insufficient_trades"
62
+ passes = False
63
+ elif total_pnl < min_pnl_per_segment:
64
+ reason = "negative_pnl"
65
+ passes = False
66
+ else:
67
+ reason = "ok"
68
+ passes = True
69
+
70
+ segment_results.append(
71
+ SegmentGateResult(
72
+ segment=seg,
73
+ num_trades=num,
74
+ total_pnl=total_pnl,
75
+ passes=passes,
76
+ reason=reason,
77
+ )
78
+ )
79
+
80
+ all_pass = all(r.passes for r in segment_results)
81
+ decision = "GO" if all_pass else "NO_GO"
82
+
83
+ agg_pnl = float(trades["realized_pnl"].sum()) if not trades.empty else 0.0
84
+ agg_trades = len(trades)
85
+
86
+ return cls(
87
+ decision=decision,
88
+ segment_results=segment_results,
89
+ aggregate_pnl=agg_pnl,
90
+ aggregate_trades=agg_trades,
91
+ )
92
+
93
+ def to_dict(self) -> dict:
94
+ return {
95
+ "decision": self.decision,
96
+ "aggregate_pnl": self.aggregate_pnl,
97
+ "aggregate_trades": self.aggregate_trades,
98
+ "segment_results": [
99
+ {
100
+ "segment": r.segment,
101
+ "num_trades": r.num_trades,
102
+ "total_pnl": r.total_pnl,
103
+ "passes": r.passes,
104
+ "reason": r.reason,
105
+ }
106
+ for r in self.segment_results
107
+ ],
108
+ }
109
+
110
+ @classmethod
111
+ def from_dict(cls, d: dict) -> HoldoutGateResult:
112
+ segment_results = [
113
+ SegmentGateResult(
114
+ segment=r["segment"],
115
+ num_trades=r["num_trades"],
116
+ total_pnl=r["total_pnl"],
117
+ passes=r["passes"],
118
+ reason=r["reason"],
119
+ )
120
+ for r in d["segment_results"]
121
+ ]
122
+ return cls(
123
+ decision=d["decision"],
124
+ segment_results=segment_results,
125
+ aggregate_pnl=d["aggregate_pnl"],
126
+ aggregate_trades=d["aggregate_trades"],
127
+ )
@@ -0,0 +1,53 @@
1
+ """Backtest performance metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import pandas as pd
8
+
9
+
10
+ @dataclass
11
+ class BacktestMetrics:
12
+ num_trades: int
13
+ total_pnl: float
14
+ hit_rate: float
15
+ avg_pnl_per_trade: float
16
+ avg_edge: float
17
+
18
+
19
+ def compute_metrics(trades: pd.DataFrame) -> BacktestMetrics:
20
+ """Compute backtest metrics from a trades DataFrame.
21
+
22
+ Args:
23
+ trades: DataFrame with columns:
24
+ - realized_pnl (float)
25
+ - outcome ("won" | "lost")
26
+ - edge (float)
27
+
28
+ Returns:
29
+ BacktestMetrics with aggregated stats. All zeros if trades is empty.
30
+ """
31
+ if trades.empty:
32
+ return BacktestMetrics(
33
+ num_trades=0,
34
+ total_pnl=0.0,
35
+ hit_rate=0.0,
36
+ avg_pnl_per_trade=0.0,
37
+ avg_edge=0.0,
38
+ )
39
+
40
+ n = len(trades)
41
+ total_pnl = float(trades["realized_pnl"].sum())
42
+ won = (trades["outcome"] == "won").sum()
43
+ hit_rate = float(won / n)
44
+ avg_pnl = total_pnl / n
45
+ avg_edge = float(trades["edge"].mean())
46
+
47
+ return BacktestMetrics(
48
+ num_trades=n,
49
+ total_pnl=total_pnl,
50
+ hit_rate=hit_rate,
51
+ avg_pnl_per_trade=avg_pnl,
52
+ avg_edge=avg_edge,
53
+ )
@@ -0,0 +1,142 @@
1
+ """Walk-forward (rolling origin) evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import pandas as pd
8
+
9
+ from pmlab.core.pnl import Position, settle_position
10
+
11
+
12
+ @dataclass
13
+ class RollingOriginResult:
14
+ trades: pd.DataFrame # columns: market_id, eval_date, outcome_label, predicted_prob, market_price, realized_pnl, edge
15
+ steps: list[dict] = field(default_factory=list)
16
+
17
+
18
+ def rolling_origin_eval(
19
+ panel: pd.DataFrame,
20
+ model, # has fit(X, y) and predict_proba(X) -> ndarray
21
+ min_train_rows: int = 20,
22
+ stride: int = 10,
23
+ flat_stake: float = 1.0,
24
+ taker_bps: float = 30.0,
25
+ ) -> RollingOriginResult:
26
+ """Walk-forward evaluation on a panel dataset.
27
+
28
+ panel columns required:
29
+ market_id, decision_date (str YYYY-MM-DD),
30
+ outcome_label, winning_label, market_price,
31
+ + feature_* columns
32
+
33
+ For each eval step:
34
+ - Train on rows with decision_date < eval_date
35
+ - Predict on rows at eval_date
36
+ - Select best bin per (market_id, decision_date) by max predicted_prob
37
+ - Compute PnL using settle_position
38
+
39
+ Returns:
40
+ RollingOriginResult with all trade records and step metadata.
41
+ """
42
+ panel = panel.copy()
43
+ panel["decision_date"] = panel["decision_date"].astype(str)
44
+
45
+ feature_cols = [c for c in panel.columns if c.startswith("feature_")]
46
+
47
+ sorted_dates = sorted(panel["decision_date"].unique())
48
+ n_dates = len(sorted_dates)
49
+
50
+ all_trades: list[dict] = []
51
+ steps: list[dict] = []
52
+
53
+ # Walk-forward: iterate in stride steps starting after min_train_rows worth of dates
54
+ for i in range(0, n_dates, stride):
55
+ eval_date = sorted_dates[i]
56
+
57
+ train_mask = panel["decision_date"] < eval_date
58
+ train_df = panel[train_mask]
59
+
60
+ if len(train_df) < min_train_rows:
61
+ continue
62
+
63
+ eval_df = panel[panel["decision_date"] == eval_date]
64
+ if eval_df.empty:
65
+ continue
66
+
67
+ X_train = train_df[feature_cols]
68
+ y_train = (train_df["outcome_label"] == train_df["winning_label"]).astype(int)
69
+
70
+ X_eval = eval_df[feature_cols]
71
+
72
+ model.fit(X_train, y_train)
73
+ proba = model.predict_proba(X_eval)
74
+
75
+ # Handle both binary (shape N,2) and single-column output
76
+ if proba.ndim == 2 and proba.shape[1] >= 2:
77
+ prob_positive = proba[:, 1]
78
+ elif proba.ndim == 2:
79
+ prob_positive = proba[:, 0]
80
+ else:
81
+ prob_positive = proba
82
+
83
+ eval_df = eval_df.copy()
84
+ eval_df["_predicted_prob"] = prob_positive
85
+
86
+ # Select best bin per (market_id, decision_date) by max prob
87
+ best_idx = eval_df.groupby(["market_id", "decision_date"])["_predicted_prob"].idxmax()
88
+ best_rows = eval_df.loc[best_idx]
89
+
90
+ fee_rate = taker_bps / 10_000.0
91
+
92
+ for _, row in best_rows.iterrows():
93
+ price = float(row["market_price"])
94
+ prob = float(row["_predicted_prob"])
95
+ edge = prob - price
96
+ fee_paid = flat_stake * fee_rate
97
+
98
+ pos = Position(
99
+ outcome_label=str(row["outcome_label"]),
100
+ price=price,
101
+ size=flat_stake / price if price > 0 else 0.0,
102
+ side="buy",
103
+ )
104
+ pnl = settle_position(pos, str(row["winning_label"]), fee_paid=fee_paid)
105
+
106
+ all_trades.append(
107
+ {
108
+ "market_id": row["market_id"],
109
+ "eval_date": eval_date,
110
+ "outcome_label": row["outcome_label"],
111
+ "predicted_prob": prob,
112
+ "market_price": price,
113
+ "realized_pnl": pnl,
114
+ "edge": edge,
115
+ }
116
+ )
117
+
118
+ steps.append(
119
+ {
120
+ "eval_date": eval_date,
121
+ "train_rows": len(train_df),
122
+ "eval_rows": len(eval_df),
123
+ "trades": len(best_rows),
124
+ }
125
+ )
126
+
127
+ if all_trades:
128
+ trades_df = pd.DataFrame(all_trades)
129
+ else:
130
+ trades_df = pd.DataFrame(
131
+ columns=[
132
+ "market_id",
133
+ "eval_date",
134
+ "outcome_label",
135
+ "predicted_prob",
136
+ "market_price",
137
+ "realized_pnl",
138
+ "edge",
139
+ ]
140
+ )
141
+
142
+ return RollingOriginResult(trades=trades_df, steps=steps)
pmlab/cli/__init__.py ADDED
File without changes
pmlab/cli/main.py ADDED
@@ -0,0 +1,83 @@
1
+ """CLI entry point — thin typer shell (filled in Phase 8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(
8
+ name="pmlab",
9
+ help="Generic ML framework for Polymarket prediction markets.",
10
+ no_args_is_help=True,
11
+ )
12
+
13
+
14
+ @app.command("version")
15
+ def version_cmd() -> None:
16
+ """Print the installed version."""
17
+ from pmlab import __version__
18
+
19
+ typer.echo(f"pmlab {__version__}")
20
+
21
+
22
+ @app.command("status")
23
+ def status_cmd() -> None:
24
+ """Show current champion, open trades count, and cumulative PnL."""
25
+ typer.echo("pmlab status: no champion published yet.")
26
+
27
+
28
+ @app.command("scan-markets")
29
+ def scan_markets_cmd(
30
+ plugin: str = typer.Option(..., "--plugin", "-p", help="Plugin family name (e.g. weather_tmax)"),
31
+ workspace: str = typer.Option("ops_daily", "--workspace", "-w"),
32
+ ) -> None:
33
+ """Discover open markets for a plugin family."""
34
+ typer.echo(f"[scan-markets] plugin={plugin} workspace={workspace}")
35
+ typer.echo("(Configure plugin clients via environment variables)")
36
+
37
+
38
+ @app.command("record-trades")
39
+ def record_trades_cmd(
40
+ plugin: str = typer.Option(..., "--plugin", "-p"),
41
+ workspace: str = typer.Option("ops_daily", "--workspace", "-w"),
42
+ min_edge: float = typer.Option(0.20, "--min-edge"),
43
+ ) -> None:
44
+ """Record paper trades from latest scan-edge signals."""
45
+ typer.echo(f"[record-trades] plugin={plugin} workspace={workspace} min_edge={min_edge}")
46
+
47
+
48
+ @app.command("settle-trades")
49
+ def settle_trades_cmd(
50
+ plugin: str = typer.Option(..., "--plugin", "-p"),
51
+ workspace: str = typer.Option("ops_daily", "--workspace", "-w"),
52
+ ) -> None:
53
+ """Settle open paper trades against resolved market truth."""
54
+ typer.echo(f"[settle-trades] plugin={plugin} workspace={workspace}")
55
+
56
+
57
+ @app.command("backtest")
58
+ def backtest_cmd(
59
+ plugin: str = typer.Option(..., "--plugin", "-p"),
60
+ model: str = typer.Option("lgbm_baseline", "--model", "-m"),
61
+ stride: int = typer.Option(30, "--stride", help="Walk-forward stride (min 10, never 1 in production)"),
62
+ workspace: str = typer.Option("historical_real", "--workspace", "-w"),
63
+ ) -> None:
64
+ """Run walk-forward backtest for a plugin+model combination."""
65
+ if stride < 10:
66
+ typer.echo(f"[error] stride={stride} is too low — minimum 10. Default stride=1 takes hours.", err=True)
67
+ raise typer.Exit(1)
68
+ typer.echo(f"[backtest] plugin={plugin} model={model} stride={stride} workspace={workspace}")
69
+
70
+
71
+ @app.command("promote-champion")
72
+ def promote_champion_cmd(
73
+ model_path: str = typer.Argument(..., help="Path to trained model .pkl"),
74
+ gate_path: str = typer.Option(..., "--gate-path", help="Path to holdout gate JSON"),
75
+ plugin: str = typer.Option(..., "--plugin", "-p"),
76
+ output_dir: str = typer.Option("artifacts/public_models", "--output-dir"),
77
+ ) -> None:
78
+ """Promote a model to champion if gate decision is GO."""
79
+ typer.echo(f"[promote-champion] model={model_path} gate={gate_path} plugin={plugin}")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ app()
pmlab/core/__init__.py ADDED
File without changes
pmlab/core/edge.py ADDED
@@ -0,0 +1,27 @@
1
+ """Edge calculation — domain-agnostic.
2
+
3
+ Edge is the after-cost expected value of a trade: how much better
4
+ our fair probability is than the market price, minus costs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def compute_edge(
11
+ fair_probability: float,
12
+ executable_price: float,
13
+ fee_estimate: float = 0.0,
14
+ slippage_estimate: float = 0.0,
15
+ ) -> float:
16
+ """Compute after-cost probability edge for a binary outcome bet.
17
+
18
+ Args:
19
+ fair_probability: Our model's estimated probability for this outcome (0–1).
20
+ executable_price: Market price we actually pay per share (0–1).
21
+ fee_estimate: Expected taker fee on this trade.
22
+ slippage_estimate: Expected price impact / slippage.
23
+
24
+ Returns:
25
+ Edge value. Positive = expected profit. Negative = expected loss.
26
+ """
27
+ return fair_probability - executable_price - fee_estimate - slippage_estimate
pmlab/core/fees.py ADDED
@@ -0,0 +1,21 @@
1
+ """Fee estimation for Polymarket CLOB trades."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Polymarket CLOB taker fee: 30 basis points on the flat stake.
6
+ DEFAULT_TAKER_BPS: float = 30.0
7
+
8
+
9
+ def estimate_fee(flat_stake: float, taker_bps: float = DEFAULT_TAKER_BPS) -> float:
10
+ """Estimate taker fee for a trade sized at *flat_stake* USDC.
11
+
12
+ Polymarket charges taker_bps / 10_000 on the notional stake (not per share).
13
+
14
+ Args:
15
+ flat_stake: Notional USDC being risked (e.g. 1.0 USDC).
16
+ taker_bps: Taker fee in basis points (default 30 = 0.30%).
17
+
18
+ Returns:
19
+ Fee amount in USDC.
20
+ """
21
+ return flat_stake * taker_bps / 10_000.0
@@ -0,0 +1,73 @@
1
+ """Generic market domain models — domain-agnostic core primitives.
2
+
3
+ These models describe any Polymarket market family without embedding
4
+ domain-specific logic (no weather, no sports, no crypto here).
5
+ Domain plugins extend these via MarketPlugin in pmlab.plugins.base.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class OutcomeBin(BaseModel):
14
+ """A single outcome bin for a prediction market.
15
+
16
+ Supports:
17
+ - Unbounded bins (binary YES/NO)
18
+ - Half-open numeric ranges (temperature, price)
19
+ - Categorical labels (driver name, country)
20
+
21
+ For categorical outcomes, leave lower/upper as None and match by label externally.
22
+ """
23
+
24
+ label: str
25
+ lower: float | None = None
26
+ upper: float | None = None
27
+ lower_inclusive: bool = True
28
+ upper_inclusive: bool = True
29
+
30
+ def contains(self, value: float) -> bool:
31
+ """Return True if *value* falls inside this bin's numeric range."""
32
+ if self.lower is not None:
33
+ if self.lower_inclusive and value < self.lower:
34
+ return False
35
+ if not self.lower_inclusive and value <= self.lower:
36
+ return False
37
+ if self.upper is not None:
38
+ if self.upper_inclusive and value > self.upper:
39
+ return False
40
+ if not self.upper_inclusive and value >= self.upper:
41
+ return False
42
+ return True
43
+
44
+
45
+ class MarketSpec(BaseModel):
46
+ """Domain-agnostic descriptor for a single Polymarket market.
47
+
48
+ Carries the minimum information needed by the core framework —
49
+ market identity, outcome structure, and timing.
50
+ Domain-specific fields (e.g. city, weather station) live in ``metadata``.
51
+ """
52
+
53
+ market_id: str
54
+ slug: str
55
+ question: str
56
+ outcome_bins: list[OutcomeBin]
57
+ close_time: str
58
+ market_family: str # "binary" | "range" | "categorical" | "numeric"
59
+
60
+ # Optional enrichment
61
+ tags: list[str] = Field(default_factory=list)
62
+ metadata: dict = Field(default_factory=dict) # domain-specific extras
63
+
64
+ def resolve_winning_bin(self, realized_value: float) -> str | None:
65
+ """Return the label of the first bin whose range contains *realized_value*.
66
+
67
+ Returns None if no bin matches (gap in coverage or uncovered value).
68
+ For categorical markets, resolve externally by label — do not call this.
69
+ """
70
+ for bin_ in self.outcome_bins:
71
+ if bin_.contains(realized_value):
72
+ return bin_.label
73
+ return None
pmlab/core/pnl.py ADDED
@@ -0,0 +1,48 @@
1
+ """PnL accounting for binary outcome token positions.
2
+
3
+ Matches the settlement logic used in polymarket-tmax-lab (pmtmax.backtest.pnl).
4
+ Works for any Polymarket market family — weather, sports, crypto.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class Position:
14
+ """An open binary outcome token position.
15
+
16
+ Attributes:
17
+ outcome_label: The outcome bin label this position is on (e.g. "YES", "30°C", "Verstappen").
18
+ price: Entry price per share (0.0–1.0).
19
+ size: Number of shares held (flat_stake / entry_price).
20
+ side: "buy" (long YES) or "sell" (short YES / long NO).
21
+ """
22
+
23
+ outcome_label: str
24
+ price: float
25
+ size: float
26
+ side: str # "buy" | "sell"
27
+
28
+
29
+ def settle_position(position: Position, winning_label: str, fee_paid: float = 0.0) -> float:
30
+ """Compute realized PnL when market resolves.
31
+
32
+ Formula (mirrors Polymarket binary settlement):
33
+ payout = 1.0 if position.outcome_label == winning_label else 0.0
34
+ buy: pnl = (payout - price) * size - fee_paid
35
+ sell: pnl = (price - payout) * size - fee_paid
36
+
37
+ Args:
38
+ position: The open position to settle.
39
+ winning_label: The resolved outcome label.
40
+ fee_paid: Total fee already paid at entry (not charged again here).
41
+
42
+ Returns:
43
+ Signed PnL in USDC (positive = profit, negative = loss).
44
+ """
45
+ payout = 1.0 if position.outcome_label == winning_label else 0.0
46
+ if position.side == "buy":
47
+ return (payout - position.price) * position.size - fee_paid
48
+ return (position.price - payout) * position.size - fee_paid