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 参数(经验值)

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天
2Stern 模型 Python 实现半天
3回测引擎框架1天
4跑完本赛季已完成比赛几小时(自动)
5统计分析 + 生成发现半天
6Agent 提炼策略文件半天
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 场比赛的经验"——而这些经验全是人可读、可修正的。