Stern × Polymarket 历史回测 + 非参数学习
2026-02-19
核心思路
不等实盘,用历史数据模拟几百场比赛的交易经验,一晚上跑完一整个赛季的"学习"。
┌─────────────────────────────────────────────────────┐
│ 数据采集层 │
│ NBA play-by-play (nba_api) │
│ + Polymarket 历史价格 (CLOB timeseries API) │
│ + NBA.com 赔率快照 (odds API) │
└──────────────────────┬──────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 回放引擎 │
│ 逐场比赛,每 30 秒 tick: │
│ ├── 当前比分/节数/时钟 │
│ ├── Stern 模型 → 实时概率 │
│ ├── Polymarket 当时价格 │
│ └── edge = stern_prob - poly_price │
└──────────────────────┬──────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 模拟交易器 │
│ if edge > threshold: │
│ 记录虚拟买入 │
│ 比赛结束 → 结算盈亏 │
└──────────────────────┬──────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 批量复盘 → 经验提炼 │
│ 500 场比赛 → 500 份复盘报告 │
│ Agent 读取全部报告 → 统计分析 → 提炼规律 │
│ → 写入策略文件(非参数学习的核心输出) │
└─────────────────────────────────────────────────────┘
一、数据源
1.1 NBA Play-by-Play(免费)
# nba_api — 官方 NBA.com 数据包装
pip install nba_api
from nba_api.stats.endpoints import playbyplayv2
from nba_api.stats.endpoints import leaguegamefinder
# 获取本赛季所有比赛
games = leaguegamefinder.LeagueGameFinder(
season_nullable='2025-26',
league_id_nullable='00'
).get_data_frames()[0]
# 获取单场 play-by-play
pbp = playbyplayv2.PlayByPlayV2(game_id='0022500XXX')
df = pbp.get_data_frames()[0]
# 每条记录 = 一个事件(得分、犯规、暂停等)
# 包含:PERIOD, PCTIMESTRING, SCOREHOME, SCOREAWAY, EVENTMSGTYPE
数据量:2025-26 赛季截至今天约 900 场常规赛,每场约 400-600 个事件。
1.2 Polymarket 历史价格
# CLOB API — 免费,无需认证
# 文档:https://docs.polymarket.com/developers/CLOB/timeseries
import requests
# 获取某个 token 的价格历史
url = "https://clob.polymarket.com/prices-history"
params = {
"market": token_id, # 从 gamma-api 获取
"interval": "1m", # 1分钟粒度
"fidelity": 60 # 每60秒一个数据点
}
resp = requests.get(url, params=params)
# 返回: [{t: timestamp, p: price}, ...]
关键问题:Polymarket 历史数据保留多久?文档说支持回溯,但具体深度需要实测。
备选方案:如果 Polymarket 历史数据不够深,可以用 NBA.com 自带的赔率数据(你的 server.js 已经在拉 odds_todaysGames.json),用博彩公司的 implied probability 代替 Polymarket 价格做回测。逻辑一样,只是市场效率更高、edge 更小。
1.3 补充数据
| 数据 | 来源 | 用途 |
|---|---|---|
| 伤病报告 | ESPN API | 回测时加入伤病上下文 |
| 赛程背靠背 | nba_api schedule | 疲劳因子 |
| 球员上场时间 | boxscore | 核心球员在场/休息的影响 |
二、Stern 模型实现
2.1 原始论文公式
Stern (1994) "The Brownian Motion Model for the Progress of Sports Scores":
P(home_win | score_diff, time_remaining) = Φ(μ / σ)
其中:
μ = current_lead + expected_drift × time_remaining
σ = volatility × √time_remaining
Φ = 标准正态分布 CDF
NBA 参数(经验值):
expected_drift≈ 0(NBA 没有显著主场得分率优势)volatility≈ 1.2 分/分钟(即每分钟比分差的标准差)- 主场优势 ≈ +2.5 分的起始 spread
2.2 增强版(加入赛前信息)
import math
from scipy.stats import norm
def stern_win_prob(home_score, away_score, minutes_remaining,
home_spread=-3.5, volatility=1.2):
"""
计算主队实时胜率
Args:
home_score: 主队当前得分
away_score: 客队当前得分
minutes_remaining: 剩余时间(分钟)
home_spread: 赛前盘口(负数 = 主队让分)
volatility: 每分钟分差波动率
Returns:
主队胜率 (0-1)
"""
if minutes_remaining <= 0:
return 1.0 if home_score > away_score else 0.0
# 当前领先优势
lead = home_score - away_score
# 预期漂移:基于赛前 spread 的剩余时间预期
# 如果 spread = -3.5(主队让3.5分),说明主队预期净赢 3.5 分/48 分钟
total_minutes = 48.0
minutes_played = total_minutes - minutes_remaining
# 剩余时间内的预期分差变化
expected_remaining = home_spread * (minutes_remaining / total_minutes)
# 总预期 = 当前领先 + 剩余预期
mu = lead + expected_remaining
# 标准差 = 波动率 × √剩余时间
sigma = volatility * math.sqrt(minutes_remaining)
if sigma == 0:
return 1.0 if mu > 0 else 0.0
return norm.cdf(mu / sigma)
2.3 为什么 Stern 模型能套利?
| 时间节点 | Stern 的优势 | Polymarket 的弱点 |
|---|---|---|
| 开场前 | 无优势(赛前信息都公开) | 定价效率高 |
| Q1-Q2 | 快速消化比分信息 | 散户反应慢,情绪波动大 |
| Q3 中段 | **最大优势**:8-15 分差时概率估算稳定 | 玩家倾向于"相信逆转" |
| Q4 垃圾时间 | 概率接近 0/1 | 价格追赶慢 2-4 分钟 |
| 加时赛 | 重置为 50/50 附近 | 过度反应最后时刻事件 |
三、回测引擎
3.1 架构
# backtest_engine.py — 核心回测框架
class GameReplay:
"""单场比赛回放"""
def __init__(self, game_id, pbp_data, poly_prices, odds_data):
self.game_id = game_id
self.pbp = pbp_data # play-by-play 事件流
self.poly = poly_prices # Polymarket 价格时间序列
self.odds = odds_data # 赛前赔率/spread
self.ticks = [] # 每 30 秒快照
def build_ticks(self):
"""将 play-by-play 转换为 30 秒间隔的快照"""
# 每个 tick 包含:
# - timestamp
# - home_score, away_score
# - period, clock
# - minutes_remaining
# - stern_prob(计算得出)
# - poly_price(从历史价格插值)
# - edge = stern_prob - poly_price
def simulate_trades(self, strategy):
"""模拟交易决策"""
trades = []
for tick in self.ticks:
signal = strategy.evaluate(tick)
if signal:
trades.append(signal)
return trades
class BacktestEngine:
"""批量回测引擎"""
def __init__(self, season='2025-26'):
self.season = season
self.games = []
self.results = []
def load_season(self):
"""加载整个赛季的数据"""
# 1. 获取所有已完成比赛列表
# 2. 逐场下载 play-by-play
# 3. 匹配 Polymarket 历史价格
# 4. 构建 GameReplay 对象
def run(self, strategy):
"""跑完所有比赛"""
for game in self.games:
game.build_ticks()
trades = game.simulate_trades(strategy)
result = self.settle(trades, game)
self.results.append(result)
def generate_reports(self):
"""批量生成复盘报告"""
for result in self.results:
report = self.format_report(result)
# 保存为 reports/2026-02-15-HOU-CHA.md
3.2 策略类
class BaseStrategy:
"""基础策略:纯 edge 阈值"""
def __init__(self, edge_threshold=0.05, min_minutes=6, max_minutes=42):
self.edge_threshold = edge_threshold
self.min_minutes = min_minutes # 不在最后6分钟内交易
self.max_minutes = max_minutes # 不在开场6分钟内交易
def evaluate(self, tick):
if tick.minutes_remaining < self.min_minutes:
return None # 太接近结束,流动性差
if tick.minutes_remaining > self.max_minutes:
return None # 太早,信号弱
edge = tick.stern_prob - tick.poly_price
if abs(edge) > self.edge_threshold:
return {
'side': 'home' if edge > 0 else 'away',
'stern_prob': tick.stern_prob,
'poly_price': tick.poly_price,
'edge': edge,
'minutes_remaining': tick.minutes_remaining,
'score': f"{tick.home_score}-{tick.away_score}"
}
return None
四、非参数学习:从 500 场复盘中提炼规律
4.1 复盘报告格式(每场比赛一份)
# 复盘:HOU @ CHA | 2026-02-15 | 火箭 112-98 黄蜂
## 比赛概况
- 赛前 spread: HOU -5.5
- 最终分差: +14 (主队赢)
- Polymarket 赛前价格: HOU 0.68
## 交易记录
| 时间点 | 比分 | Stern | Poly | Edge | 方向 | 结果 |
|--------|------|-------|------|------|------|------|
| Q3 4:30 | 78-69 | 0.81 | 0.73 | +8% | 买 HOU | ✅ +0.27 |
| Q3 1:20 | 85-72 | 0.89 | 0.80 | +9% | 买 HOU | ✅ +0.20 |
## 交易盈亏
- 总投入: 2 units
- 总回报: 2.47 units
- 净利润: +0.47 units (+23.5%)
## 模型观察
- Stern 在 Q3 中段(lead=9)给出 0.81,最终胜率 1.00
- Polymarket 从 0.73 追到 0.95 花了约 5 分钟
- 最大 edge 出现在 Q3 4:30(+8%)
## 特殊情况
- CHA 核心 LaMelo Ball 第三节犯规麻烦,上场时间减少
- Stern 不考虑球员个体因素,但 Polymarket 也反应慢
## 标签
- #Q3_mid_peak_edge
- #blowout_slow_poly_adjustment
- #injury_factor_missed
4.2 批量统计分析(Agent 做的事)
跑完 500 场后,Agent 读取所有复盘,做统计分析,输出发现:
# memory/backtest-findings.md
# 回测发现(基于 2025-26 赛季 500 场模拟)
## 发现 1: Edge 分布按节次
| 节次 | 平均 edge | 出现频率 | 胜率 |
|------|-----------|----------|------|
| Q1 | 2.1% | 12% | 54% |
| Q2 | 3.4% | 18% | 57% |
| Q3 | 5.8% | 35% | 64% |
| Q4 (前8分钟) | 4.2% | 25% | 68% |
| Q4 (后4分钟) | 6.5% | 10% | 78% |
**结论**:Q3 是最佳交易窗口 — edge 大且频率高。
Q4 后段虽然胜率最高,但 Polymarket 流动性差,实际难以成交。
## 发现 2: 分差与 Stern 准确度
| 分差区间 | Stern 校准误差 | 说明 |
|----------|---------------|------|
| 0-5 分 | ±3.2% | 准确 |
| 6-10 分 | ±2.1% | 最准 ⭐ |
| 11-15 分 | ±4.5% | 略低估逆转概率 |
| 16-20 分 | ±6.8% | 高估领先方(垃圾时间松懈) |
| 20+ 分 | ±8.3% | 很不准(替补上场、节奏变化大) |
**结论**:最佳交易区间是分差 6-10 分。
## 发现 3: 球队特异性
| 球队 | Stern 偏差 | 原因假设 |
|------|-----------|----------|
| 骑士 | Stern 低估 2.3% | 防守强队,领先后能守住 |
| 步行者 | Stern 高估 3.1% | 进攻节奏快,分差波动大 |
| 湖人 | Stern 高估 1.8% | 第四节执行力差 |
| 雷霆 | Stern 低估 1.5% | SGA 第四节 clutch |
**结论**:需要按球队做校准修正。
## 发现 4: Polymarket 价格反应速度
| 事件类型 | 价格调整时间 | 套利窗口 |
|----------|-------------|----------|
| 连续得分潮 (10-0 run) | 2-4 分钟 | 大 |
| 核心球员下场/受伤 | 1-2 分钟 | 中 |
| 暂停后恢复 | 即时 | 无 |
| 节间休息 | N/A(停止交易) | 无 |
| 垃圾时间(20+分差) | 3-6 分钟 | 大但低流动性 |
**结论**:连续得分潮后是最佳入场时机。
## 发现 5: 最优策略参数
- Edge 阈值: 5% (低于5%手续费吃掉利润)
- 最佳时间窗: Q3 开始到 Q4 前8分钟
- 最佳分差: 6-15 分
- 避开: 开场前6分钟、最后4分钟、20+分差
- 单场最大交易: 3 次(避免过度暴露)
- 预期年化: ~18-25%(扣除手续费后)
4.3 策略文件更新(非参数学习的产物)
Agent 把统计发现转化为可执行的策略规则:
# memory/trading-strategy-v2.md
# 基于 500 场回测的交易策略 v2.0
## 入场条件(全部满足)
1. Edge ≥ 5%
2. 时间窗口:Q2 后半段 ~ Q4 前8分钟
3. 分差:6-15 分
4. Polymarket 该市场交易量 > $10,000
## 球队校准表
| 球队 | Stern 修正 | 方向 |
|------|-----------|------|
| CLE, OKC, BOS | +2% | Stern 低估,可以更激进 |
| IND, PHX, ATL | -2.5% | Stern 高估,需要更大 edge |
| LAL, MIL | -1.5% | 第四节执行力不稳定 |
## 仓位管理
- 单笔 ≤ 总资金 2%
- 单场 ≤ 3 笔
- 日最大亏损 ≤ 5%
## 来源
- 基于 2025-26 赛季前 500 场回测
- 样本量:有效交易信号 847 次
- 总胜率:63.2%
- 平均 edge:6.1%
- Kelly 最优仓位:~8%(实际用 1/4 Kelly = 2%)
五、学习加速:三个层次
Level 1:纯统计回测(快,但浅)
500 场 play-by-play → Stern 计算 → 统计分析 → 规律
耗时:几小时(数据下载 + 计算)
产出:基础参数优化(阈值、时间窗口、分差区间)
Level 2:情境回测(中等速度,更深)
加入上下文:伤病、背靠背、主客场、球队风格
每场比赛不只是数字,还有"故事"
产出:球队特异性规则、情境修正因子
Level 3:Agent 自主回测(最慢,但最聪明)
让 Agent(LLM)逐场"观看"回放
Agent 不只看数字,还能:
- 识别"这场像上周那场湖人比赛"
- 发现统计方法捕捉不到的模式
- 用自然语言记录微妙的观察
产出:高质量的经验报告 + 直觉性规律
推荐路径:先跑 Level 1(几小时搞定),用结果优化基础参数。然后用 Level 2 补充上下文。Level 3 可以挑重要比赛让 Agent 深度分析。
六、与传统 ML 的对比
| 维度 | 传统 ML 回测 | 非参数学习回测 |
|---|---|---|
| 输出 | 模型权重/参数 | .md 策略文件 |
| 可读性 | 需要 SHAP/LIME 解释 | 人直接读 |
| 修正 | 重新训练 | 编辑文件 |
| 增量学习 | 需要再训练 | 追加新发现 |
| 迁移 | 换模型要重做 | 文件可跨 Agent 使用 |
| Jay 参与 | 只能调超参 | 可以直接编辑策略 |
最大优势:Jay 你跑完回测后,看到策略文件写着"湖人第四节 Stern 高估 1.8%",你作为球迷可以说"不对,这赛季湖人换了教练,第四节执行力提升了"——直接改文件。这在传统 ML 里是做不到的。
七、实施计划
| 步骤 | 工作 | 耗时 |
|---|---|---|
| 1 | 数据采集脚本(nba_api + Polymarket CLOB) | 1天 |
| 2 | Stern 模型 Python 实现 | 半天 |
| 3 | 回测引擎框架 | 1天 |
| 4 | 跑完本赛季已完成比赛 | 几小时(自动) |
| 5 | 统计分析 + 生成发现 | 半天 |
| 6 | Agent 提炼策略文件 | 半天 |
| 7 | 接入实盘监控 | 1天 |
| **总计** | **~4天** |
八、预期产出
跑完回测后,你会得到:
projects/polymarket-tracker/
├── data/
│ ├── games/ ← 每场比赛的 play-by-play + 价格数据
│ ├── backtest_results.json ← 全部交易模拟结果
│ └── season_stats.json ← 赛季级统计
├── reports/
│ ├── 2026-02-15-HOU-CHA.md ← 500+ 份逐场复盘
│ └── backtest-summary.md ← 总结报告
├── memory/
│ ├── backtest-findings.md ← 统计发现
│ ├── trading-strategy-v2.md ← 优化后的策略
│ ├── team-profiles/ ← 每队校准参数
│ └── market-patterns.md ← 市场行为规律
└── scripts/
├── stern_model.py ← Stern 概率计算
├── backtest_engine.py ← 回测引擎
├── data_collector.py ← 数据采集
└── report_generator.py ← 复盘报告生成
这套东西的本质:你把一整个赛季的"交易经验"压缩成几个 .md 文件。下次实盘时,Agent 读这些文件就相当于"有 500 场比赛的经验"——而这些经验全是人可读、可修正的。