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.
- pmlab/__init__.py +63 -0
- pmlab/backtest/__init__.py +0 -0
- pmlab/backtest/holdout_gate.py +127 -0
- pmlab/backtest/metrics.py +53 -0
- pmlab/backtest/rolling_origin.py +142 -0
- pmlab/cli/__init__.py +0 -0
- pmlab/cli/main.py +83 -0
- pmlab/core/__init__.py +0 -0
- pmlab/core/edge.py +27 -0
- pmlab/core/fees.py +21 -0
- pmlab/core/market_spec.py +73 -0
- pmlab/core/pnl.py +48 -0
- pmlab/core/sizing.py +72 -0
- pmlab/data/__init__.py +0 -0
- pmlab/execution/__init__.py +0 -0
- pmlab/execution/edge_signal.py +23 -0
- pmlab/execution/live_broker.py +187 -0
- pmlab/execution/paper_broker.py +141 -0
- pmlab/execution/settlement.py +131 -0
- pmlab/features/__init__.py +12 -0
- pmlab/features/transforms.py +95 -0
- pmlab/markets/__init__.py +0 -0
- pmlab/markets/async_clob_client.py +43 -0
- pmlab/markets/async_gamma_client.py +33 -0
- pmlab/markets/cache.py +70 -0
- pmlab/markets/clob_client.py +71 -0
- pmlab/markets/gamma_client.py +106 -0
- pmlab/modeling/__init__.py +0 -0
- pmlab/modeling/base.py +38 -0
- pmlab/modeling/calibration.py +45 -0
- pmlab/modeling/champion.py +113 -0
- pmlab/modeling/diagnostics.py +67 -0
- pmlab/modeling/lgbm_baseline.py +69 -0
- pmlab/plugins/__init__.py +0 -0
- pmlab/plugins/base.py +105 -0
- pmlab/plugins/registry.py +38 -0
- pmlab/plugins/sports_f1/__init__.py +0 -0
- pmlab/plugins/sports_f1/plugin.py +103 -0
- pmlab/plugins/weather_tmax/__init__.py +0 -0
- pmlab/plugins/weather_tmax/_spec_builder.py +78 -0
- pmlab/plugins/weather_tmax/plugin.py +112 -0
- pmlab/py.typed +0 -0
- pmlab/reports/__init__.py +4 -0
- pmlab/reports/html_report.py +165 -0
- pmlab/workspace/__init__.py +0 -0
- pmlab/workspace/context.py +36 -0
- pmlab-0.1.0.dist-info/METADATA +490 -0
- pmlab-0.1.0.dist-info/RECORD +51 -0
- pmlab-0.1.0.dist-info/WHEEL +4 -0
- pmlab-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|