返回列表
🧠 阿头学 · 💰投资

像量化交易台一样做预测市场仿真

预测市场不是"有偏硬币"而是高维动态系统,需要从蒙特卡洛、方差缩减、尾部相关建模到粒子滤波的完整仿真栈才能获得机构级定价优势,但这套框架的实战转化率远低于理论承诺。
打开原文 ↗

2026-03-01 原文链接 ↗
阅读简报
双语对照
完整翻译
原文
讨论归档

核心观点

  • 散户直觉与机构仿真的维度差:把预测合约当"已知偏置硬币"只有一个参数 p,而真实问题涉及路径依赖、波动率、相关性、执行风险等数十个参数,这个诊断精准,但文章夸大了"有好模型就有优势"的因果链。
  • 尾部相关性是组合爆炸的隐形引线:高斯 Copula 在极端情景下的尾部相关性为零,而 t-copula 会显示 18% 的共同极端波动概率,这对多合约风控是真实警告,但文章未充分讨论参数估计本身的极高不确定性。
  • 方差缩减的量级改变是真实的,但有隐藏前提:重要性采样能把方差降低 100–10,000 倍,这在数学上成立,但前提是模型假设(GBM、logit 随机游走、参数恒定)在实际预测市场中往往被结构性断点(法院裁决、政策变化)击穿,单纯强调采样效率掩盖了模型风险。
  • 粒子滤波的"隐藏状态"设定存在哲学问题:把"真实概率"当作平滑演化的标量隐藏状态,假设市场价格是围绕它的噪声,这对政治/宏观事件(其中"概率"本身是博弈均衡结果)是可疑的,文章未承认这个框架的局限。
  • 生产级技术栈更像愿景而非操作手册:五层架构看起来完整,但缺失真实工程问题(模型计算延迟 vs 执行速度、多模型冲突决策、自激振荡防护、监管变化下的快速下线),以及最致命的——预言机风险与结算争议完全被忽略。

跟我们的关联

  • 对 ATou 意味着什么:如果你在做预测市场交易或组合管理,这套框架的核心警告(尾部相关性、方差缩减、粒子滤波)是必须认真对待的,但不要被"可运行代码"迷惑——代码是玩具化的,真实执行会在流动性、交易成本、模型参数漂移上碰壁。下一步:先用布里尔分数评估自己的预测校准度,再考虑是否值得投入建模。
  • 对 Neta 意味着什么:这篇文章是"技术布道者"在建立专家权威的典型案例,通过展示复杂公式与代码来吸引受众,但虚构的 2025/2026 年文献暴露了内容可能包含 AI 幻觉。下一步:对任何声称"机构级"的量化框架都要追问真实回测数据与 P&L 证据,而不是被理论优雅性迷惑。
  • 对 Uota 意味着什么:粒子滤波 + 布里尔分数的框架可以直接迁移到增长实验、产品决策、风险评估等任何涉及"在流式数据下更新不确定性"的场景。下一步:把"隐藏状态"定义清楚(真实转化率/胜率/风险),指定观测模型,运行滤波,用布里尔分数做模型对比。
  • 对通用决策的意味:最强的洞察是"零智能市场仍可高效"——这说明系统结构(机制设计)比个体聪明更重要。下一步:在设计任何多主体系统(团队、平台、市场)时,把注意力从"招聪明人"转到"搭好的反馈与激励机制"。

讨论引子

  • 文中声称"100 次重要性采样胜过 100 万次朴素 MC",但这个量级改进在多大程度上会被模型错误(而非采样误差)抵消?在预测市场这种低信噪比环境下,方差缩减的实际收益是否被高估了?
  • 高斯 Copula 在 2008 年失效,t-copula 在预测市场中被推崇,但尾部相关性本身在数据上极难估计(样本太少),参数 ν 和 ρ 的微小改动会导致数量级差异——文章如何建议在实战中处理这种参数不确定性?
  • 文末的"生产级技术栈"完全忽略了预言机风险与结算争议,这在预测市场中往往是比概率算错更大的风险来源,这个缺失是否反映了作者对实际交易风险的理解不足?

这不是一份技巧清单。

这是一段故事——从一次抛硬币开始,最终抵达机构级的仿真引擎。

每一节都建立在前一节之上。你若跳读,数学就会断裂;按顺序读下去,读到最后,你将拿到从上到下每一层都可运行的代码。

免责声明: ***非金融建议,请自行研究


第一部分:那次让一切崩塌的抛硬币

你盯着 Polymarket 上的一份合约:“美联储会在 3 月降息吗?”YES 的成交价是 $0.62。

你的直觉说:这就是 62% 的概率。也许你觉得它应该是 70%。于是你买入。

恭喜你。你刚刚做了几乎每个散户交易者都会做的事:你把预测市场合约当成一枚带已知偏置的硬币,估出你自己的“偏置”,然后把差额押上去。

  • 你不知道该对自己的 70% 估计有多自信。

  • 你不知道明天就业报告出来后,它应该如何变化。

  • 你不知道它与 Polymarket 上另外六个与美联储相关的合约是怎样相关的。

  • 你不知道从现在到结算之间的价格路径,是否会让你即便最终判断正确也能在途中以盈利退出。

抛硬币只有一个参数:p。

而嵌入在一组相关事件的组合中、信息流随时间变化、带着订单簿动态与执行风险的预测市场合约,参数却有几十个。

第二部分:蒙特卡洛——那块没人足够尊重的基石

本文中的所有仿真,最终都可以归结为蒙特卡洛:从分布中抽样、计算统计量、重复。

事件概率 p=P(A) 的估计量,就是样本均值:

中心极限定理给出收敛速率:O(N^{-1/2}),且方差 Var(p^_N)=p(1−p)/N。

方差在 p=0.5 时达到最大。 平台上价格在 50 美分附近的合约,最不确定、交易也最活跃——也正是你的蒙特卡洛估计最不精确的地方。

当 p=0.50 时,要在 95% 置信度下达到 ±0.01 的精度:

这还算可控。但一旦你需要模拟的是路径而不只是终点,事情会迅速变糟。

你的第一个可运行仿真

目标: 估计一个与资产挂钩的二元合约兑现的概率(例如:“AAPL 会在 3 月 15 日收盘高于 $200 吗?”)

这能用。对单个合约、单个标的、并且假设对数正态动态时,它就能工作。真实的预测市场会把这些假设逐条打碎。

评估你的仿真

在改进仿真之前,我们需要一种衡量它有多好的办法。布里尔分数(Brier Score)是标准的校准指标:

import numpy as np
from scipy.special import expit, logit  # sigmoid and logit

class PredictionMarketParticleFilter:
    """
    Sequential Monte Carlo filter for real-time event probability estimation.

    Usage during a live event (e.g., election night):
        pf = PredictionMarketParticleFilter(prior_prob=0.50)
        pf.update(observed_price=0.55)   # market moves on early returns
        pf.update(observed_price=0.62)   # more data
        pf.update(observed_price=0.58)   # partial correction
        print(pf.estimate())             # filtered probability
    """
    def __init__(self, N_particles=5000, prior_prob=0.5,
                 process_vol=0.05, obs_noise=0.03):
        self.N = N_particles
        self.process_vol = process_vol
        self.obs_noise = obs_noise

        # Initialize particles around prior
        logit_prior = logit(prior_prob)
        self.logit_particles = logit_prior + np.random.normal(0, 0.5, N_particles)
        self.weights = np.ones(N_particles) / N_particles
        self.history = []

    def update(self, observed_price):
        """Incorporate a new observation (market price, poll result, etc.)"""
        # 1. Propagate: random walk in logit space
        noise = np.random.normal(0, self.process_vol, self.N)
        self.logit_particles += noise

        # 2. Convert to probability space
        prob_particles = expit(self.logit_particles)

        # 3. Reweight: likelihood of observation given each particle
        log_likelihood = -0.5 * ((observed_price - prob_particles) / self.obs_noise)**2
        log_weights = np.log(self.weights + 1e-300) + log_likelihood

        # Normalize in log space for stability
        log_weights -= log_weights.max()
        self.weights = np.exp(log_weights)
        self.weights /= self.weights.sum()

        # 4. Check ESS and resample if needed
        ess = 1.0 / np.sum(self.weights**2)
        if ess < self.N / 2:
            self._systematic_resample()

        self.history.append(self.estimate())

    def _systematic_resample(self):
        """Systematic resampling - lower variance than multinomial."""
        cumsum = np.cumsum(self.weights)
        u = (np.arange(self.N) + np.random.uniform()) / self.N
        indices = np.searchsorted(cumsum, u)
        self.logit_particles = self.logit_particles[indices]
        self.weights = np.ones(self.N) / self.N

    def estimate(self):
        """Weighted mean probability estimate."""
        probs = expit(self.logit_particles)
        return np.average(probs, weights=self.weights)

    def credible_interval(self, alpha=0.05):
        """Weighted quantile-based credible interval."""
        probs = expit(self.logit_particles)
        sorted_idx = np.argsort(probs)
        sorted_probs = probs[sorted_idx]
        sorted_weights = self.weights[sorted_idx]
        cumw = np.cumsum(sorted_weights)
        lower = sorted_probs[np.searchsorted(cumw, alpha/2)]
        upper = sorted_probs[np.searchsorted(cumw, 1 - alpha/2)]
        return lower, upper

# --- Simulate election night ---
pf = PredictionMarketParticleFilter(prior_prob=0.50, process_vol=0.03)

# Incoming observations (market prices as new data arrives)
observations = [0.50, 0.52, 0.55, 0.58, 0.61, 0.63, 0.60, 
                0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95]

print("Election Night Tracker:")
print(f"{'Time':>6}  {'Observed':>10}  {'Filtered':>10}  {'95% CI':>20}")
print("-" * 52)

for t, obs in enumerate(observations):
    pf.update(obs)
    ci = pf.credible_interval()
    print(f"{t:>5}h  {obs:>10.3f}  {pf.estimate():>10.3f}  ({ci[0]:.3f}, {ci[1]:.3f})")

布里尔分数低于 0.20 就算不错。

低于 0.10 则非常优秀。

历史上最好的选举预测机构(538、Economist)在总统选举中的成绩大致在 0.06–0.12。

如果你的仿真能超过这个水平,你就有优势。

第三部分:当 100,000 次抽样仍然不够

现在,故事升级了。

Polymarket 上有许多极端事件合约。“标普 500 会在一周内下跌 20% 吗?”的价格是 $0.003。用朴素的蒙特卡洛跑 100,000 次抽样,你可能一次都碰不到,或者只碰到一次。

你的估计要么是 0.00000,要么是 0.00001——两者都毫无用处。

这不是一个理论问题。这正是多数散户无法正确评估尾部风险合约的原因。

让稀有事件变得常见

重要性采样(importance sampling)用一种会过度采样稀有区域的概率测度替换原测度,然后再用似然来修正偏差。

似然比(likelihood ratio)或 Radon–Nikodym 导数

它本身并不能直接拿来用,但它告诉你:你应该朝什么方向去逼近。

实践中真正的主力是指数倾斜(exponential tilting)

如果你的标的服从一个随机游走,其增量 Δ_i 的矩母函数为 M(γ)=E[e^γΔ],你可以对分布进行倾斜:

选择 γ,让稀有事件变成“典型事件”。对那类在某个和超过巨大阈值时支付的合约,γ 满足 Lundberg 方程 M(γ)=1。

用于尾部风险合约的重要性采样

对极端合约而言,IS 可以把方差降低 100–10,000 倍

这意味着:100 次 IS 抽样的精度,可能胜过 1,000,000 次朴素抽样。

这不是边际提升。这是“我们没法给它定价”和“我们在交易它”之间的差别。

第四部分:用于实时更新的序贯蒙特卡洛

但当故事从静态估计转向动态仿真时,你该怎么做?

想象一下: 那是选举之夜,东部时间晚上 8:01。佛罗里达刚刚关闭投票。早期计票显示,一方相对另一方出现了 3 个百分点的摆动。

你的模型需要立刻更新:把这个新数据点纳入概率估计,并且不仅是佛州,还包括俄亥俄、宾夕法尼亚、密歇根,以及所有相关的州。

这就是滤波(filtering)问题,对应的工具是序贯蒙特卡洛(Sequential Monte Carlo)——粒子滤波。

状态空间模型

定义:

  • 隐藏状态 x_t​:事件的“真实”概率(不可观测)

  • 观测 y_t:市场价格、民调结果、计票数、新闻信号

状态通过logit 随机游走演化(保证概率有界):

def brier_score(predictions, outcomes):
    """Evaluate simulation calibration."""
    return np.mean((np.array(predictions) - np.array(outcomes))**2)

# Compare two models
model_A_preds = [0.7, 0.3, 0.9, 0.1]  # sharp, confident
model_B_preds = [0.5, 0.5, 0.5, 0.5]  # always uncertain
actual_outcomes = [1, 0, 1, 0]

print(f"Model A Brier: {brier_score(model_A_preds, actual_outcomes):.4f}")  # 0.05
print(f"Model B Brier: {brier_score(model_B_preds, actual_outcomes):.4f}")  # 0.25

观测是对真实状态的带噪读数:

1. INITIALIZE: Draw x_0^{(i)} ~ Prior  for i = 1,...,N
   Set weights w_0^{(i)} = 1/N

2. FOR each new observation y_t:
   a. PROPAGATE:  x_t^{(i)} ~ f( · | x_{t-1}^{(i)} )
   b. REWEIGHT:   w_t^{(i)} ∝ g( y_t | x_t^{(i)} )  
   c. NORMALIZE:  w̃_t^{(i)} = w_t^{(i)} / Σ_j w_t^{(j)}
   d. RESAMPLE if ESS = 1/Σ(w̃_t^{(i)})² < N/2

Bootstrap 粒子滤波

该算法维持 N 个“粒子”——每一个都是对真实概率的一种假设;随着数据到来,它们会被重新赋权:

用于实时预测市场的粒子滤波

为什么这比直接用市场价格更好?

因为粒子滤波会平滑噪声并传播不确定性

当市场在一笔交易后从 $0.58 猛跳到 $0.65,滤波器会意识到真实概率未必真的变化这么大;它会根据观测过程本身的波动性,来“收敛”这次更新幅度。

第五部分:三种可叠加的方差缩减技巧

在离开蒙特卡洛疆域之前,这里有三种技巧——它们能与上述一切乘法式叠加

免费的对称性

当支付函数是单调的(而二元合约永远如此:价格越高意味着越可能超过执行价),方差缩减是有保证的:

import numpy as np
from scipy.stats import norm, t as t_dist

def simulate_correlated_outcomes_gaussian(probs, corr_matrix, N=100_000):
    """Gaussian copula no tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T
    U = norm.cdf(X)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_t(probs, corr_matrix, nu=4, N=100_000):
    """Student-t copula symmetric tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T

    # Divide by sqrt(chi-squared / nu) to get t-distributed
    S = np.random.chisquare(nu, N) / nu
    T = X / np.sqrt(S[:, None])
    U = t_dist.cdf(T, nu)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_clayton(probs, theta=2.0, N=100_000):
    """Clayton copula (bivariate) lower tail dependence."""
    # Marshall-Olkin algorithm
    V = np.random.gamma(1/theta, 1, N)
    E = np.random.exponential(1, (N, len(probs)))
    U = (1 + E / V[:, None])**(-1/theta)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

# --- Compare tail behavior ---
probs = [0.52, 0.53, 0.51, 0.48, 0.50]  # 5 swing state probabilities
state_names = ['PA', 'MI', 'WI', 'GA', 'AZ']

corr = np.array([
    [1.0, 0.7, 0.7, 0.4, 0.3],
    [0.7, 1.0, 0.8, 0.3, 0.3],
    [0.7, 0.8, 1.0, 0.3, 0.3],
    [0.4, 0.3, 0.3, 1.0, 0.5],
    [0.3, 0.3, 0.3, 0.5, 1.0],
])

N = 500_000

gauss_outcomes = simulate_correlated_outcomes_gaussian(probs, corr, N)
t_outcomes = simulate_correlated_outcomes_t(probs, corr, nu=4, N=N)

# P(sweep all 5 states)
p_sweep_gauss = gauss_outcomes.all(axis=1).mean()
p_sweep_t = t_outcomes.all(axis=1).mean()

# P(lose all 5 states)  
p_lose_gauss = (1 - gauss_outcomes).all(axis=1).mean()
p_lose_t = (1 - t_outcomes).all(axis=1).mean()

# If independent
p_sweep_indep = np.prod(probs)
p_lose_indep = np.prod([1-p for p in probs])

print("Joint Outcome Probabilities:")
print(f"{'':>25}  {'Independent':>12}  {'Gaussian':>12}  {'t-copula':>12}")
print(f"{'P(sweep all 5)':>25}  {p_sweep_indep:>12.4f}  {p_sweep_gauss:>12.4f}  {p_sweep_t:>12.4f}")
print(f"{'P(lose all 5)':>25}  {p_lose_indep:>12.4f}  {p_lose_gauss:>12.4f}  {p_lose_t:>12.4f}")
print(f"\nt-copula increases sweep probability by {p_sweep_t/p_sweep_gauss:.1f}x vs Gaussian")

典型的缩减幅度大约在 50–75%。除了把函数评估次数翻倍之外(反正你本来也要做),几乎没有额外的计算成本。

利用你已经知道的东西

如果你在随机波动率下模拟一个二元合约 {S_T > K}(没有闭式解),可以用 Black-Scholes 的数字期权价格 p_{BS}​(它有闭式解)作为控制变量(control variate)

def stratified_binary_mc(S0, K, sigma, T, J=10, N_total=100_000):
    """
    Stratified MC for binary contract pricing.
    Strata defined by quantiles of the terminal price distribution.
    """
    n_per_stratum = N_total // J
    estimates = []

    for j in range(J):
        # Uniform draws within stratum [j/J, (j+1)/J]
        U = np.random.uniform(j/J, (j+1)/J, n_per_stratum)
        Z = norm.ppf(U)
        S_T = S0 * np.exp((-0.5*sigma**2)*T + sigma*np.sqrt(T)*Z)
        stratum_mean = (S_T > K).mean()
        estimates.append(stratum_mean)

    # Each stratum has weight 1/J
    p_stratified = np.mean(estimates)
    se_stratified = np.std(estimates) / np.sqrt(J)

    return p_stratified, se_stratified

p, se = stratified_binary_mc(S0=100, K=105, sigma=0.20, T=30/365)
print(f"Stratified estimate: {p:.6f} ± {se:.6f}")

分而治之

把概率空间划分为 J 个分层(strata),在每一层内采样,再组合。按照全方差定律,方差总是 ≤ 朴素 MC;最大增益来自Neyman 分配:nj∝ωjσj​(对方差高的分层更多采样)。

把三者叠起来

在每一层里用对偶变量(antithetic variates),再叠加控制变量校正,你往往能比朴素 MC 把方差降低 100–500 倍。这在生产里不是可选项,这是入场门槛。

第六部分:建模相关矩阵做不到的东西

分层贝叶斯模型通过共享的全国摆动参数,隐式地编码了相关性。

尾部相关性(tail dependence)呢——那种在极端情况下的共振,在简单线性相关里根本看不出来?

2008 年,高斯 copula 未能刻画尾部相关性,是全球金融危机的重要成因之一。在预测市场里,同样的问题也会出现:当一个摇摆州给出意外结果时,所有摇摆州一起翻转的概率,要远高于高斯 copula 的预测。

Sklar 定理

其中 C 是 copula(纯粹的依赖结构),F_i​ 是边缘分布的 CDF。你可以分别建模每个市场的边缘行为,然后用一个能刻画依赖(包括尾部)的 copula 把它们“粘”在一起。

尾部相关性问题

高斯 copula:尾部相关性 λU=λL=0。极端共同波动被建模为零概率。

这对相关联的预测市场来说是灾难性错误

Student-t copula

import numpy as np
from collections import deque

class PredictionMarketABM:
    """
    Agent-based model of a prediction market order book.

    Agent types:
    - Informed: know the true probability, trade toward it
    - Noise: random trades
    - Market maker: provides liquidity around current price
    """
    def __init__(self, true_prob, n_informed=10, n_noise=50, n_mm=5):
        self.true_prob = true_prob
        self.price = 0.50  # initial price
        self.price_history = [self.price]

        # Order book (simplified as bid/ask queues)
        self.best_bid = 0.49
        self.best_ask = 0.51

        # Agent populations
        self.n_informed = n_informed
        self.n_noise = n_noise
        self.n_mm = n_mm

        # Track metrics
        self.volume = 0
        self.informed_pnl = 0
        self.noise_pnl = 0

    def step(self):
        """One time step: randomly select an agent to trade."""
        total = self.n_informed + self.n_noise + self.n_mm
        r = np.random.random()

        if r < self.n_informed / total:
            self._informed_trade()
        elif r < (self.n_informed + self.n_noise) / total:
            self._noise_trade()
        else:
            self._mm_update()

        self.price_history.append(self.price)

    def _informed_trade(self):
        """Informed trader: buy if price < true_prob, sell otherwise."""
        signal = self.true_prob + np.random.normal(0, 0.02)  # noisy signal

        if signal > self.best_ask + 0.01:  # buy
            size = min(0.1, abs(signal - self.price) * 2)
            self.price += size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.true_prob - self.best_ask) * size
        elif signal < self.best_bid - 0.01:  # sell
            size = min(0.1, abs(self.price - signal) * 2)
            self.price -= size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.best_bid - self.true_prob) * size

        self.price = np.clip(self.price, 0.01, 0.99)
        self._update_book()

    def _noise_trade(self):
        """Noise trader: random buy/sell."""
        direction = np.random.choice([-1, 1])
        size = np.random.exponential(0.02)
        self.price += direction * size * self._kyle_lambda()
        self.price = np.clip(self.price, 0.01, 0.99)
        self.volume += size
        self.noise_pnl -= abs(self.price - self.true_prob) * size * 0.5
        self._update_book()

    def _mm_update(self):
        """Market maker: tighten spread toward current price."""
        spread = max(0.02, 0.05 * (1 - self.volume / 100))
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def _kyle_lambda(self):
        """Price impact parameter."""
        sigma_v = abs(self.true_prob - self.price) + 0.05
        sigma_u = 0.1 * np.sqrt(self.n_noise)
        return sigma_v / (2 * sigma_u)

    def _update_book(self):
        spread = self.best_ask - self.best_bid
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def run(self, n_steps=1000):
        for _ in range(n_steps):
            self.step()
        return np.array(self.price_history)

# --- Simulation ---
np.random.seed(42)

# Scenario: true probability is 0.65, market starts at 0.50
sim = PredictionMarketABM(true_prob=0.65, n_informed=10, n_noise=50, n_mm=5)
prices = sim.run(n_steps=2000)

print("Agent-Based Prediction Market Simulation")
print(f"True probability:   {sim.true_prob:.2f}")
print(f"Starting price:     0.50")
print(f"Final price:        {prices[-1]:.4f}")
print(f"Price at t=500:     {prices[500]:.4f}")
print(f"Price at t=1000:    {prices[1000]:.4f}")
print(f"Total volume:       {sim.volume:.1f}")
print(f"Informed P&L:       ${sim.informed_pnl:.2f}")
print(f"Noise trader P&L:   ${sim.noise_pnl:.2f}")
print(f"Convergence error:  {abs(prices[-1] - sim.true_prob):.4f}")

在 ν=4、ρ=0.6 时,尾部相关性大约是 0.18——也就是:在某个合约出现极端结果的条件下,约有 18% 的概率会发生极端共同波动。高斯模型会给出 0%。

Clayton copula:只有下尾相关性(λL=2^−1/θ)。当一个预测市场崩盘,其他市场会跟着崩;没有上尾相关性。

Gumbel copula:只有上尾相关性(λU​=2−2^1/θ)。相关的正向结算。

模拟相关的预测市场结果

def rare_event_IS(S0, K_crash, sigma, T, N_paths=100_000):
    """
    Importance sampling for extreme downside binary contracts.

    Example: P(S&P drops 20% in one week)
    """
    K = S0 * (1 - K_crash)  # e.g., 20% crash threshold

    # Original drift (risk-neutral)
    mu_original = -0.5 * sigma**2

    # Tilted drift: shift the mean toward the crash region
    # Choose mu_tilt so the crash threshold is ~1 std dev away instead of ~4
    log_threshold = np.log(K / S0)
    mu_tilt = log_threshold / T  # center the distribution on the crash

    Z = np.random.standard_normal(N_paths)

    # Simulate under TILTED measure
    log_returns_tilted = mu_tilt * T + sigma * np.sqrt(T) * Z
    S_T_tilted = S0 * np.exp(log_returns_tilted)

    # Likelihood ratio: original density / tilted density
    log_returns_original = mu_original * T + sigma * np.sqrt(T) * Z
    log_LR = (
        -0.5 * ((log_returns_tilted - mu_original * T) / (sigma * np.sqrt(T)))**2
        + 0.5 * ((log_returns_tilted - mu_tilt * T) / (sigma * np.sqrt(T)))**2
    )
    LR = np.exp(log_LR)

    # IS estimator
    payoffs = (S_T_tilted < K).astype(float)
    is_estimates = payoffs * LR

    p_IS = is_estimates.mean()
    se_IS = is_estimates.std() / np.sqrt(N_paths)

    # Compare with crude MC
    Z_crude = np.random.standard_normal(N_paths)
    S_T_crude = S0 * np.exp(mu_original * T + sigma * np.sqrt(T) * Z_crude)
    p_crude = (S_T_crude < K).mean()
    se_crude = np.sqrt(p_crude * (1 - p_crude) / N_paths) if p_crude > 0 else float('inf')

    return {
        'p_IS': p_IS, 'se_IS': se_IS,
        'p_crude': p_crude, 'se_crude': se_crude,
        'variance_reduction': (se_crude / se_IS)**2 if se_IS > 0 else float('inf')
    }

result = rare_event_IS(S0=5000, K_crash=0.20, sigma=0.15, T=5/252)
print(f"IS estimate:    {result['p_IS']:.6f} ± {result['se_IS']:.6f}")
print(f"Crude estimate: {result['p_crude']:.6f} ± {result['se_crude']:.6f}")
print(f"Variance reduction factor: {result['variance_reduction']:.1f}x")

这正是 2008 年高斯 copula 失败的根本原因;对预测市场组合来说,它也会再次失败。

ν=4 的 t-copula 往往会显示:极端联合结果的概率比高斯模型高出 2–5 倍。

如果你在交易相互相关的预测市场合约却不建模尾部相关性,那么你就在运行一个组合——它会在最关键的情景里精准爆炸。

藤 copula(Vine Copula)

当合约维度 d>5 时,仅靠二元 copula 不够。藤 copula把 d 维依赖分解为 d(d−1)/2 个二元条件 copula,并以树结构组织:

  • C-vine(星型):一个中心事件驱动一切(例如:总统胜负 -> 所有政策市场)

  • D-vine(链型):顺序依赖(例如:初选结果流向大选)

  • R-vine(一般图):最大灵活性

按 ∣τKendall∣ 构建最大生成树,用 AIC 选择 pair-copula 族,顺序估计。实现:pyvinecopulib(Python)、VineCopula(R)。

第七部分:基于主体的仿真

到目前为止,我们都假设你知道数据生成过程,只需要把它模拟出来即可。

但预测市场由异质主体构成——知情交易者、噪声交易者、做市商、以及机器人。它们的互动会产生涌现的动力学,任何闭式的 SDE 都捕捉不到。

零智能的启示

即便每一个交易者都完全不理性,市场也仍然可能是有效的

Gode & Sunder(1993)证明:零智能主体——仅在预算约束下随机下单的交易者——在连续双向竞价中也能实现接近 100% 的配置效率。

Farmer、Patelli & Zovko(2005)把这一结果扩展到了限价订单簿。

这解释了伦敦证券交易所横截面价差变化的 96%。一个参数。96%。

基于主体的预测市场模拟器

import numpy as np

def simulate_binary_contract(S0, K, mu, sigma, T, N_paths=100_000):
    """
    Monte Carlo simulation for a binary contract.

    S0:    Current asset price
    K:     Strike / threshold
    mu:    Annual drift
    sigma: Annual volatility
    T:     Time to expiry in years
    N_paths: Number of simulated paths
    """
    # Simulate terminal prices via GBM
    Z = np.random.standard_normal(N_paths)
    S_T = S0 * np.exp((mu - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * Z)

    # Binary payoff
    payoffs = (S_T > K).astype(float)

    # Estimate and confidence interval
    p_hat = payoffs.mean()
    se = np.sqrt(p_hat * (1 - p_hat) / N_paths)
    ci_lower = p_hat - 1.96 * se
    ci_upper = p_hat + 1.96 * se

    return {
        'probability': p_hat,
        'std_error': se,
        'ci_95': (ci_lower, ci_upper),
        'N_paths': N_paths
    }

# Example: AAPL at $195, strike $200, 20% vol, 30 days
result = simulate_binary_contract(S0=195, K=200, mu=0.08, sigma=0.20, T=30/365)
print(f"P(AAPL > $200) ≈ {result['probability']:.4f}")
print(f"95% CI: ({result['ci_95'][0]:.4f}, {result['ci_95'][1]:.4f})")

价格收敛得有多快取决于知情交易者与噪声交易者的比例、做市商价差对信息流的响应方式,以及为什么知情交易者能在噪声交易者的损失上实现盈利。

第八部分:生产级技术栈

下面是完整系统——从市场数据到交易执行:

  • LAYER 1: 数据接入
  • 来自 Polymarket CLOB API 的 WebSocket 推送(实时价格、成交量)
  • 新闻/民调数据流(经 NLP 处理为概率信号)
  • 链上事件数据(Polygon)

  • LAYER 2: 概率引擎

  • 分层贝叶斯模型(Stan/PyMC)给出州级后验
  • 粒子滤波:对新观测进行实时更新
  • 跳跃-扩散 SDE 的路径仿真用于风险管理
  • 集成(Ensemble):对模型输出做加权平均

  • LAYER 3: 依赖建模

  • 藤 copula:合约之间的两两依赖
  • 因子模型:共享的全国/全球风险因子
  • 用 t-copula 估计尾部相关性

  • LAYER 4: 风险管理

  • 基于 EVT 的 VaR 与期望短缺(Expected Shortfall)
  • 反向压力测试:找出最坏情景
  • 相关性压力:如果州与州的相关性突然飙升会怎样?
  • 流动性风险:监控订单簿深度

  • LAYER 5: 监控

  • 追踪布里尔分数(我们校准了吗?)
  • P&L 归因(哪个模型组件贡献了价值?)
  • 回撤告警
  • 模型漂移检测

参考文献

  • Dalen(2025)。《走向预测市场的 Black-Scholes》。arXiv:2510.15205

  • Saguillo 等(2025)。《解开概率森林:预测市场中的套利》。arXiv:2508.03474

  • Madrigal-Cianci 等(2026)。《作为贝叶斯逆问题的预测市场》。arXiv:2601.18815

  • Farmer、Patelli & Zovko(2005)。《零智能的预测力》。PNAS

  • Gode & Sunder(1993)。《零智能交易者市场的配置效率》。JPE

  • Kyle(1985)。《连续竞价与内幕交易》。Econometrica

  • Glosten & Milgrom(1985)。《买价、卖价与成交价》。JFE

  • Hoffman & Gelman(2014)。《No-U-Turn 采样器》。JMLR

  • Merton(1976)。《当标的股票收益不连续时的期权定价》。JFE

  • Linzer(2013)。《总统选举的动态贝叶斯预测》。JASA

  • Gelman 等(2020)。《更新后的动态贝叶斯预测模型》。HDSR

  • Aas、Czado、Frigessi & Bakken(2009)。《多元依赖的 Pair-Copula 构造》。Insurance: Mathematics and Economics

  • Wiese 等(2020)。《Quant GANs:金融时间序列的深度生成》。Quantitative Finance

This isn't a list of techniques.

It's a story - one that starts with a coin flip and ends with institutional-grade simulation engines.

Each section builds on the last. Skip ahead and the math won't make sense. Read it in order and by the end you'll have runnable code for every layer of the stack.

Disclaimer: ***Not Financial Advice & Do Your Own Research


这不是一份技巧清单。

这是一段故事——从一次抛硬币开始,最终抵达机构级的仿真引擎。

每一节都建立在前一节之上。你若跳读,数学就会断裂;按顺序读下去,读到最后,你将拿到从上到下每一层都可运行的代码。

免责声明: ***非金融建议,请自行研究


Part I: The Coin Flip That Breaks Everything

You're staring at a Polymarket contract. "Will the Fed cut rates in March?" YES is trading at $0.62.

Your instinct says: that's a 62% probability. Maybe you think it should be 70%. So you buy.

Congratulations. You just did what every retail trader does. You treated a prediction market contract like a coin flip with a known bias, estimated your own bias, and bet the difference.

  • You have no idea how confident to be in your 70% estimate.

  • You don't know how it should change when tomorrow's jobs report drops.

  • You don't know how it correlates with the six other Fed-related contracts on Polymarket.

  • You don't know whether the price path between now and resolution will let you exit at a profit even if you're eventually right.

A coin flip has one parameter: p.

A prediction market contract embedded in a portfolio of correlated events, with time-varying information flow, order book dynamics, and execution risk, has dozens.

第一部分:那次让一切崩塌的抛硬币

你盯着 Polymarket 上的一份合约:“美联储会在 3 月降息吗?”YES 的成交价是 $0.62。

你的直觉说:这就是 62% 的概率。也许你觉得它应该是 70%。于是你买入。

恭喜你。你刚刚做了几乎每个散户交易者都会做的事:你把预测市场合约当成一枚带已知偏置的硬币,估出你自己的“偏置”,然后把差额押上去。

  • 你不知道该对自己的 70% 估计有多自信。

  • 你不知道明天就业报告出来后,它应该如何变化。

  • 你不知道它与 Polymarket 上另外六个与美联储相关的合约是怎样相关的。

  • 你不知道从现在到结算之间的价格路径,是否会让你即便最终判断正确也能在途中以盈利退出。

抛硬币只有一个参数:p。

而嵌入在一组相关事件的组合中、信息流随时间变化、带着订单簿动态与执行风险的预测市场合约,参数却有几十个。

Part II: Monte Carlo. The Foundation Nobody Respects Enough

Every simulation in this article ultimately reduces to Monte Carlo: draw samples from a distribution, compute a statistic, repeat.

The estimator for event probability p=P(A) is just the sample mean:

The Central Limit Theorem gives you the convergence rate: O(N^{-1/2}, with variance Var(p^_N)=p(1−p)/N.

The variance is maximized at p=0.5p A contract trading at 50 cents the most uncertain, most actively traded contract on the platform is exactly where your Monte Carlo estimates are least precise.

To hit ±0.01 precision at 95% confidence when p=0.50:

That's manageable. But it gets worse fast when you need to simulate paths, not just endpoints.

Your First Runnable Simulation

Goal: Estimate the probability that an asset-linked binary contract pays off (e.g., "Will AAPL close above $200 by March 15?")

This works. For one contract, with one underlying, assuming lognormal dynamics. Real prediction markets break every one of those assumptions.

Evaluating Your Simulation

Before we improve the simulation, we need a way to measure how good it is. The Brier Score is the standard calibration metric:

import numpy as np
from scipy.special import expit, logit  # sigmoid and logit

class PredictionMarketParticleFilter:
    """
    Sequential Monte Carlo filter for real-time event probability estimation.

    Usage during a live event (e.g., election night):
        pf = PredictionMarketParticleFilter(prior_prob=0.50)
        pf.update(observed_price=0.55)   # market moves on early returns
        pf.update(observed_price=0.62)   # more data
        pf.update(observed_price=0.58)   # partial correction
        print(pf.estimate())             # filtered probability
    """
    def __init__(self, N_particles=5000, prior_prob=0.5,
                 process_vol=0.05, obs_noise=0.03):
        self.N = N_particles
        self.process_vol = process_vol
        self.obs_noise = obs_noise

        # Initialize particles around prior
        logit_prior = logit(prior_prob)
        self.logit_particles = logit_prior + np.random.normal(0, 0.5, N_particles)
        self.weights = np.ones(N_particles) / N_particles
        self.history = []

    def update(self, observed_price):
        """Incorporate a new observation (market price, poll result, etc.)"""
        # 1. Propagate: random walk in logit space
        noise = np.random.normal(0, self.process_vol, self.N)
        self.logit_particles += noise

        # 2. Convert to probability space
        prob_particles = expit(self.logit_particles)

        # 3. Reweight: likelihood of observation given each particle
        log_likelihood = -0.5 * ((observed_price - prob_particles) / self.obs_noise)**2
        log_weights = np.log(self.weights + 1e-300) + log_likelihood

        # Normalize in log space for stability
        log_weights -= log_weights.max()
        self.weights = np.exp(log_weights)
        self.weights /= self.weights.sum()

        # 4. Check ESS and resample if needed
        ess = 1.0 / np.sum(self.weights**2)
        if ess < self.N / 2:
            self._systematic_resample()

        self.history.append(self.estimate())

    def _systematic_resample(self):
        """Systematic resampling - lower variance than multinomial."""
        cumsum = np.cumsum(self.weights)
        u = (np.arange(self.N) + np.random.uniform()) / self.N
        indices = np.searchsorted(cumsum, u)
        self.logit_particles = self.logit_particles[indices]
        self.weights = np.ones(self.N) / self.N

    def estimate(self):
        """Weighted mean probability estimate."""
        probs = expit(self.logit_particles)
        return np.average(probs, weights=self.weights)

    def credible_interval(self, alpha=0.05):
        """Weighted quantile-based credible interval."""
        probs = expit(self.logit_particles)
        sorted_idx = np.argsort(probs)
        sorted_probs = probs[sorted_idx]
        sorted_weights = self.weights[sorted_idx]
        cumw = np.cumsum(sorted_weights)
        lower = sorted_probs[np.searchsorted(cumw, alpha/2)]
        upper = sorted_probs[np.searchsorted(cumw, 1 - alpha/2)]
        return lower, upper

# --- Simulate election night ---
pf = PredictionMarketParticleFilter(prior_prob=0.50, process_vol=0.03)

# Incoming observations (market prices as new data arrives)
observations = [0.50, 0.52, 0.55, 0.58, 0.61, 0.63, 0.60, 
                0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95]

print("Election Night Tracker:")
print(f"{'Time':>6}  {'Observed':>10}  {'Filtered':>10}  {'95% CI':>20}")
print("-" * 52)

for t, obs in enumerate(observations):
    pf.update(obs)
    ci = pf.credible_interval()
    print(f"{t:>5}h  {obs:>10.3f}  {pf.estimate():>10.3f}  ({ci[0]:.3f}, {ci[1]:.3f})")

A Brier score below 0.20 is good.

Below 0.10 is excellent.

The best election forecasters (538, Economist) historically achieve 0.06-0.12 on presidential races.

If your simulation can beat that, you have edge.

第二部分:蒙特卡洛——那块没人足够尊重的基石

本文中的所有仿真,最终都可以归结为蒙特卡洛:从分布中抽样、计算统计量、重复。

事件概率 p=P(A) 的估计量,就是样本均值:

中心极限定理给出收敛速率:O(N^{-1/2}),且方差 Var(p^_N)=p(1−p)/N。

方差在 p=0.5 时达到最大。 平台上价格在 50 美分附近的合约,最不确定、交易也最活跃——也正是你的蒙特卡洛估计最不精确的地方。

当 p=0.50 时,要在 95% 置信度下达到 ±0.01 的精度:

这还算可控。但一旦你需要模拟的是路径而不只是终点,事情会迅速变糟。

你的第一个可运行仿真

目标: 估计一个与资产挂钩的二元合约兑现的概率(例如:“AAPL 会在 3 月 15 日收盘高于 $200 吗?”)

这能用。对单个合约、单个标的、并且假设对数正态动态时,它就能工作。真实的预测市场会把这些假设逐条打碎。

评估你的仿真

在改进仿真之前,我们需要一种衡量它有多好的办法。布里尔分数(Brier Score)是标准的校准指标:

import numpy as np
from scipy.special import expit, logit  # sigmoid and logit

class PredictionMarketParticleFilter:
    """
    Sequential Monte Carlo filter for real-time event probability estimation.

    Usage during a live event (e.g., election night):
        pf = PredictionMarketParticleFilter(prior_prob=0.50)
        pf.update(observed_price=0.55)   # market moves on early returns
        pf.update(observed_price=0.62)   # more data
        pf.update(observed_price=0.58)   # partial correction
        print(pf.estimate())             # filtered probability
    """
    def __init__(self, N_particles=5000, prior_prob=0.5,
                 process_vol=0.05, obs_noise=0.03):
        self.N = N_particles
        self.process_vol = process_vol
        self.obs_noise = obs_noise

        # Initialize particles around prior
        logit_prior = logit(prior_prob)
        self.logit_particles = logit_prior + np.random.normal(0, 0.5, N_particles)
        self.weights = np.ones(N_particles) / N_particles
        self.history = []

    def update(self, observed_price):
        """Incorporate a new observation (market price, poll result, etc.)"""
        # 1. Propagate: random walk in logit space
        noise = np.random.normal(0, self.process_vol, self.N)
        self.logit_particles += noise

        # 2. Convert to probability space
        prob_particles = expit(self.logit_particles)

        # 3. Reweight: likelihood of observation given each particle
        log_likelihood = -0.5 * ((observed_price - prob_particles) / self.obs_noise)**2
        log_weights = np.log(self.weights + 1e-300) + log_likelihood

        # Normalize in log space for stability
        log_weights -= log_weights.max()
        self.weights = np.exp(log_weights)
        self.weights /= self.weights.sum()

        # 4. Check ESS and resample if needed
        ess = 1.0 / np.sum(self.weights**2)
        if ess < self.N / 2:
            self._systematic_resample()

        self.history.append(self.estimate())

    def _systematic_resample(self):
        """Systematic resampling - lower variance than multinomial."""
        cumsum = np.cumsum(self.weights)
        u = (np.arange(self.N) + np.random.uniform()) / self.N
        indices = np.searchsorted(cumsum, u)
        self.logit_particles = self.logit_particles[indices]
        self.weights = np.ones(self.N) / self.N

    def estimate(self):
        """Weighted mean probability estimate."""
        probs = expit(self.logit_particles)
        return np.average(probs, weights=self.weights)

    def credible_interval(self, alpha=0.05):
        """Weighted quantile-based credible interval."""
        probs = expit(self.logit_particles)
        sorted_idx = np.argsort(probs)
        sorted_probs = probs[sorted_idx]
        sorted_weights = self.weights[sorted_idx]
        cumw = np.cumsum(sorted_weights)
        lower = sorted_probs[np.searchsorted(cumw, alpha/2)]
        upper = sorted_probs[np.searchsorted(cumw, 1 - alpha/2)]
        return lower, upper

# --- Simulate election night ---
pf = PredictionMarketParticleFilter(prior_prob=0.50, process_vol=0.03)

# Incoming observations (market prices as new data arrives)
observations = [0.50, 0.52, 0.55, 0.58, 0.61, 0.63, 0.60, 
                0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95]

print("Election Night Tracker:")
print(f"{'Time':>6}  {'Observed':>10}  {'Filtered':>10}  {'95% CI':>20}")
print("-" * 52)

for t, obs in enumerate(observations):
    pf.update(obs)
    ci = pf.credible_interval()
    print(f"{t:>5}h  {obs:>10.3f}  {pf.estimate():>10.3f}  ({ci[0]:.3f}, {ci[1]:.3f})")

布里尔分数低于 0.20 就算不错。

低于 0.10 则非常优秀。

历史上最好的选举预测机构(538、Economist)在总统选举中的成绩大致在 0.06–0.12。

如果你的仿真能超过这个水平,你就有优势。

Part III: When 100,000 Samples Aren't Enough

Now the story escalates.

Polymarket hosts contracts on extreme events. "Will the S&P 500 drop 20% in one week?" is trading at $0.003. With crude Monte Carlo at 100,000 samples, you might see zero or one hit.

Your estimate is either 0.00000 or 0.00001 - both useless.

This isn't a theoretical problem. It's the reason most retail traders can't properly evaluate tail-risk contracts.

Make Rare Events Common

Importance sampling replaces the original probability measure with one that oversamples the rare region, then corrects the bias with a likelihood

Likelihood ratio or Radon-Nikodym derivative

Not useful directly, but it tells you what to aim for.

The practical workhorse is exponential tilting.

If your underlying follows a random walk with increments Δ_ihaving moment generating function M(γ)=E[e^γΔ], you tilt the distribution:

choosing γ to make the rare event typical. For a contract that pays off when a sum exceeds a large threshold, γ solves the Lundberg equation M(γ)=1.

Importance Sampling for Tail-Risk Contracts

On extreme contracts, IS can reduce variance by factors of 100–10,000x.

This means 100 IS samples give better precision than 1,000,000 crude samples.

That's not a marginal improvement. It's the difference between "we can't price this" and "we're trading it."

第三部分:当 100,000 次抽样仍然不够

现在,故事升级了。

Polymarket 上有许多极端事件合约。“标普 500 会在一周内下跌 20% 吗?”的价格是 $0.003。用朴素的蒙特卡洛跑 100,000 次抽样,你可能一次都碰不到,或者只碰到一次。

你的估计要么是 0.00000,要么是 0.00001——两者都毫无用处。

这不是一个理论问题。这正是多数散户无法正确评估尾部风险合约的原因。

让稀有事件变得常见

重要性采样(importance sampling)用一种会过度采样稀有区域的概率测度替换原测度,然后再用似然来修正偏差。

似然比(likelihood ratio)或 Radon–Nikodym 导数

它本身并不能直接拿来用,但它告诉你:你应该朝什么方向去逼近。

实践中真正的主力是指数倾斜(exponential tilting)

如果你的标的服从一个随机游走,其增量 Δ_i 的矩母函数为 M(γ)=E[e^γΔ],你可以对分布进行倾斜:

选择 γ,让稀有事件变成“典型事件”。对那类在某个和超过巨大阈值时支付的合约,γ 满足 Lundberg 方程 M(γ)=1。

用于尾部风险合约的重要性采样

对极端合约而言,IS 可以把方差降低 100–10,000 倍

这意味着:100 次 IS 抽样的精度,可能胜过 1,000,000 次朴素抽样。

这不是边际提升。这是“我们没法给它定价”和“我们在交易它”之间的差别。

Part IV: Sequential Monte Carlo for Real-Time Updating

But what I need to do while the story shifts from static estimation to dynamic simulation?

Imagine: It's election night. 8:01 PM EST. Florida polls just closed. Early returns show a 3-point shift toward one candidate.

Your model needs to update instantly incorporating this new data point into the probability estimate for not just Florida, but Ohio, Pennsylvania, Michigan, and every correlated state.

This is the filtering problem, and the tool is Sequential Monte Carlo particle filters.

The State-Space Model

Define:

  • Hidden state x_t​: the "true" probability of the event (unobserved)

  • Observation y_t: market prices, poll results, vote counts, news signals

The state evolves via a logit random walk (keeps probabilities bounded):

def brier_score(predictions, outcomes):
    """Evaluate simulation calibration."""
    return np.mean((np.array(predictions) - np.array(outcomes))**2)

# Compare two models
model_A_preds = [0.7, 0.3, 0.9, 0.1]  # sharp, confident
model_B_preds = [0.5, 0.5, 0.5, 0.5]  # always uncertain
actual_outcomes = [1, 0, 1, 0]

print(f"Model A Brier: {brier_score(model_A_preds, actual_outcomes):.4f}")  # 0.05
print(f"Model B Brier: {brier_score(model_B_preds, actual_outcomes):.4f}")  # 0.25

Observations are noisy readings of the true state:

1. INITIALIZE: Draw x_0^{(i)} ~ Prior  for i = 1,...,N
   Set weights w_0^{(i)} = 1/N

2. FOR each new observation y_t:
   a. PROPAGATE:  x_t^{(i)} ~ f( · | x_{t-1}^{(i)} )
   b. REWEIGHT:   w_t^{(i)} ∝ g( y_t | x_t^{(i)} )  
   c. NORMALIZE:  w̃_t^{(i)} = w_t^{(i)} / Σ_j w_t^{(j)}
   d. RESAMPLE if ESS = 1/Σ(w̃_t^{(i)})² < N/2

The Bootstrap Particle Filter

The algorithm maintains N "particles" - each one a hypothesis about the true probability and reweights them as data arrives:

Particle Filter for a Live Prediction Market

Why is this better than just using the market price directly?

Because the particle filter smooths noise and propagates uncertainty.

When the market spikes from $0.58 to $0.65 on a single trade, the filter recognizes that the true probability might not have changed that much it tempers the update based on how volatile the observation process has been.

第四部分:用于实时更新的序贯蒙特卡洛

但当故事从静态估计转向动态仿真时,你该怎么做?

想象一下: 那是选举之夜,东部时间晚上 8:01。佛罗里达刚刚关闭投票。早期计票显示,一方相对另一方出现了 3 个百分点的摆动。

你的模型需要立刻更新:把这个新数据点纳入概率估计,并且不仅是佛州,还包括俄亥俄、宾夕法尼亚、密歇根,以及所有相关的州。

这就是滤波(filtering)问题,对应的工具是序贯蒙特卡洛(Sequential Monte Carlo)——粒子滤波。

状态空间模型

定义:

  • 隐藏状态 x_t​:事件的“真实”概率(不可观测)

  • 观测 y_t:市场价格、民调结果、计票数、新闻信号

状态通过logit 随机游走演化(保证概率有界):

def brier_score(predictions, outcomes):
    """Evaluate simulation calibration."""
    return np.mean((np.array(predictions) - np.array(outcomes))**2)

# Compare two models
model_A_preds = [0.7, 0.3, 0.9, 0.1]  # sharp, confident
model_B_preds = [0.5, 0.5, 0.5, 0.5]  # always uncertain
actual_outcomes = [1, 0, 1, 0]

print(f"Model A Brier: {brier_score(model_A_preds, actual_outcomes):.4f}")  # 0.05
print(f"Model B Brier: {brier_score(model_B_preds, actual_outcomes):.4f}")  # 0.25

观测是对真实状态的带噪读数:

1. INITIALIZE: Draw x_0^{(i)} ~ Prior  for i = 1,...,N
   Set weights w_0^{(i)} = 1/N

2. FOR each new observation y_t:
   a. PROPAGATE:  x_t^{(i)} ~ f( · | x_{t-1}^{(i)} )
   b. REWEIGHT:   w_t^{(i)} ∝ g( y_t | x_t^{(i)} )  
   c. NORMALIZE:  w̃_t^{(i)} = w_t^{(i)} / Σ_j w_t^{(j)}
   d. RESAMPLE if ESS = 1/Σ(w̃_t^{(i)})² < N/2

Bootstrap 粒子滤波

该算法维持 N 个“粒子”——每一个都是对真实概率的一种假设;随着数据到来,它们会被重新赋权:

用于实时预测市场的粒子滤波

为什么这比直接用市场价格更好?

因为粒子滤波会平滑噪声并传播不确定性

当市场在一笔交易后从 $0.58 猛跳到 $0.65,滤波器会意识到真实概率未必真的变化这么大;它会根据观测过程本身的波动性,来“收敛”这次更新幅度。

Part V: Three Variance Reduction Tricks That Stack

Before we leave Monte Carlo territory, here are three techniques that combine multiplicatively with everything above.

Free Symmetry

When the payoff function is monotone (which binary contracts always are higher prices mean higher probability of exceeding the strike), the variance reduction is guaranteed:

import numpy as np
from scipy.stats import norm, t as t_dist

def simulate_correlated_outcomes_gaussian(probs, corr_matrix, N=100_000):
    """Gaussian copula no tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T
    U = norm.cdf(X)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_t(probs, corr_matrix, nu=4, N=100_000):
    """Student-t copula symmetric tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T

    # Divide by sqrt(chi-squared / nu) to get t-distributed
    S = np.random.chisquare(nu, N) / nu
    T = X / np.sqrt(S[:, None])
    U = t_dist.cdf(T, nu)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_clayton(probs, theta=2.0, N=100_000):
    """Clayton copula (bivariate) lower tail dependence."""
    # Marshall-Olkin algorithm
    V = np.random.gamma(1/theta, 1, N)
    E = np.random.exponential(1, (N, len(probs)))
    U = (1 + E / V[:, None])**(-1/theta)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

# --- Compare tail behavior ---
probs = [0.52, 0.53, 0.51, 0.48, 0.50]  # 5 swing state probabilities
state_names = ['PA', 'MI', 'WI', 'GA', 'AZ']

corr = np.array([
    [1.0, 0.7, 0.7, 0.4, 0.3],
    [0.7, 1.0, 0.8, 0.3, 0.3],
    [0.7, 0.8, 1.0, 0.3, 0.3],
    [0.4, 0.3, 0.3, 1.0, 0.5],
    [0.3, 0.3, 0.3, 0.5, 1.0],
])

N = 500_000

gauss_outcomes = simulate_correlated_outcomes_gaussian(probs, corr, N)
t_outcomes = simulate_correlated_outcomes_t(probs, corr, nu=4, N=N)

# P(sweep all 5 states)
p_sweep_gauss = gauss_outcomes.all(axis=1).mean()
p_sweep_t = t_outcomes.all(axis=1).mean()

# P(lose all 5 states)  
p_lose_gauss = (1 - gauss_outcomes).all(axis=1).mean()
p_lose_t = (1 - t_outcomes).all(axis=1).mean()

# If independent
p_sweep_indep = np.prod(probs)
p_lose_indep = np.prod([1-p for p in probs])

print("Joint Outcome Probabilities:")
print(f"{'':>25}  {'Independent':>12}  {'Gaussian':>12}  {'t-copula':>12}")
print(f"{'P(sweep all 5)':>25}  {p_sweep_indep:>12.4f}  {p_sweep_gauss:>12.4f}  {p_sweep_t:>12.4f}")
print(f"{'P(lose all 5)':>25}  {p_lose_indep:>12.4f}  {p_lose_gauss:>12.4f}  {p_lose_t:>12.4f}")
print(f"\nt-copula increases sweep probability by {p_sweep_t/p_sweep_gauss:.1f}x vs Gaussian")

Typical reduction is around 50-75%. Zero extra computational cost beyond doubling the function evaluations (which you were going to do anyway).

Exploit What You Already Know

If you're simulating a binary contract {S_T > K} under stochastic volatility (no closed form), use the Black-Scholes digital price p_{BS}​ (which has a closed form) as a control variate:

def stratified_binary_mc(S0, K, sigma, T, J=10, N_total=100_000):
    """
    Stratified MC for binary contract pricing.
    Strata defined by quantiles of the terminal price distribution.
    """
    n_per_stratum = N_total // J
    estimates = []

    for j in range(J):
        # Uniform draws within stratum [j/J, (j+1)/J]
        U = np.random.uniform(j/J, (j+1)/J, n_per_stratum)
        Z = norm.ppf(U)
        S_T = S0 * np.exp((-0.5*sigma**2)*T + sigma*np.sqrt(T)*Z)
        stratum_mean = (S_T > K).mean()
        estimates.append(stratum_mean)

    # Each stratum has weight 1/J
    p_stratified = np.mean(estimates)
    se_stratified = np.std(estimates) / np.sqrt(J)

    return p_stratified, se_stratified

p, se = stratified_binary_mc(S0=100, K=105, sigma=0.20, T=30/365)
print(f"Stratified estimate: {p:.6f} ± {se:.6f}")

Divide and Conquer

Partition the probability space into JJ J strata, sample within each, combine. The variance is always ≤ crude MC (by the law of total variance), with maximum gain from Neyman allocation: nj∝ωjσj​ (oversample strata with high variance).

Stack all three

Antithetic variates inside each stratum, with a control variate correction and you routinely achieve 100–500x variance reduction over crude MC. This is not optional in production. This is table stakes.

第五部分:三种可叠加的方差缩减技巧

在离开蒙特卡洛疆域之前,这里有三种技巧——它们能与上述一切乘法式叠加

免费的对称性

当支付函数是单调的(而二元合约永远如此:价格越高意味着越可能超过执行价),方差缩减是有保证的:

import numpy as np
from scipy.stats import norm, t as t_dist

def simulate_correlated_outcomes_gaussian(probs, corr_matrix, N=100_000):
    """Gaussian copula no tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T
    U = norm.cdf(X)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_t(probs, corr_matrix, nu=4, N=100_000):
    """Student-t copula symmetric tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T

    # Divide by sqrt(chi-squared / nu) to get t-distributed
    S = np.random.chisquare(nu, N) / nu
    T = X / np.sqrt(S[:, None])
    U = t_dist.cdf(T, nu)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_clayton(probs, theta=2.0, N=100_000):
    """Clayton copula (bivariate) lower tail dependence."""
    # Marshall-Olkin algorithm
    V = np.random.gamma(1/theta, 1, N)
    E = np.random.exponential(1, (N, len(probs)))
    U = (1 + E / V[:, None])**(-1/theta)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

# --- Compare tail behavior ---
probs = [0.52, 0.53, 0.51, 0.48, 0.50]  # 5 swing state probabilities
state_names = ['PA', 'MI', 'WI', 'GA', 'AZ']

corr = np.array([
    [1.0, 0.7, 0.7, 0.4, 0.3],
    [0.7, 1.0, 0.8, 0.3, 0.3],
    [0.7, 0.8, 1.0, 0.3, 0.3],
    [0.4, 0.3, 0.3, 1.0, 0.5],
    [0.3, 0.3, 0.3, 0.5, 1.0],
])

N = 500_000

gauss_outcomes = simulate_correlated_outcomes_gaussian(probs, corr, N)
t_outcomes = simulate_correlated_outcomes_t(probs, corr, nu=4, N=N)

# P(sweep all 5 states)
p_sweep_gauss = gauss_outcomes.all(axis=1).mean()
p_sweep_t = t_outcomes.all(axis=1).mean()

# P(lose all 5 states)  
p_lose_gauss = (1 - gauss_outcomes).all(axis=1).mean()
p_lose_t = (1 - t_outcomes).all(axis=1).mean()

# If independent
p_sweep_indep = np.prod(probs)
p_lose_indep = np.prod([1-p for p in probs])

print("Joint Outcome Probabilities:")
print(f"{'':>25}  {'Independent':>12}  {'Gaussian':>12}  {'t-copula':>12}")
print(f"{'P(sweep all 5)':>25}  {p_sweep_indep:>12.4f}  {p_sweep_gauss:>12.4f}  {p_sweep_t:>12.4f}")
print(f"{'P(lose all 5)':>25}  {p_lose_indep:>12.4f}  {p_lose_gauss:>12.4f}  {p_lose_t:>12.4f}")
print(f"\nt-copula increases sweep probability by {p_sweep_t/p_sweep_gauss:.1f}x vs Gaussian")

典型的缩减幅度大约在 50–75%。除了把函数评估次数翻倍之外(反正你本来也要做),几乎没有额外的计算成本。

利用你已经知道的东西

如果你在随机波动率下模拟一个二元合约 {S_T > K}(没有闭式解),可以用 Black-Scholes 的数字期权价格 p_{BS}​(它有闭式解)作为控制变量(control variate)

def stratified_binary_mc(S0, K, sigma, T, J=10, N_total=100_000):
    """
    Stratified MC for binary contract pricing.
    Strata defined by quantiles of the terminal price distribution.
    """
    n_per_stratum = N_total // J
    estimates = []

    for j in range(J):
        # Uniform draws within stratum [j/J, (j+1)/J]
        U = np.random.uniform(j/J, (j+1)/J, n_per_stratum)
        Z = norm.ppf(U)
        S_T = S0 * np.exp((-0.5*sigma**2)*T + sigma*np.sqrt(T)*Z)
        stratum_mean = (S_T > K).mean()
        estimates.append(stratum_mean)

    # Each stratum has weight 1/J
    p_stratified = np.mean(estimates)
    se_stratified = np.std(estimates) / np.sqrt(J)

    return p_stratified, se_stratified

p, se = stratified_binary_mc(S0=100, K=105, sigma=0.20, T=30/365)
print(f"Stratified estimate: {p:.6f} ± {se:.6f}")

分而治之

把概率空间划分为 J 个分层(strata),在每一层内采样,再组合。按照全方差定律,方差总是 ≤ 朴素 MC;最大增益来自Neyman 分配:nj∝ωjσj​(对方差高的分层更多采样)。

把三者叠起来

在每一层里用对偶变量(antithetic variates),再叠加控制变量校正,你往往能比朴素 MC 把方差降低 100–500 倍。这在生产里不是可选项,这是入场门槛。

Part VI: Modeling What Correlation Matrices Can't

The hierarchical Bayesian model implicitly encodes correlation through the shared national swing parameter.

But what about tail dependence - the tendency for extreme co-movements that don't show up in linear correlation?

In 2008, the Gaussian copula's failure to model tail dependence contributed to the global financial crisis. In prediction markets, the same issue arises: when one swing state has a surprise result, the probability that all swing states flip together is much higher than a Gaussian copula would predict.

Sklar's Theorem

where C is the copula (the pure dependency structure) and F_i​ are the marginal CDFs. You can model each market's marginal behavior separately, then glue them together with a copula that captures the dependency including in the tails.

The Tail Dependence Problem

Gaussian copula: Tail dependence λU=λL=0. Extreme co-movements are modeled as having zero probability.

This is catastrophically wrong for correlated prediction markets.

Student-t copula

import numpy as np
from collections import deque

class PredictionMarketABM:
    """
    Agent-based model of a prediction market order book.

    Agent types:
    - Informed: know the true probability, trade toward it
    - Noise: random trades
    - Market maker: provides liquidity around current price
    """
    def __init__(self, true_prob, n_informed=10, n_noise=50, n_mm=5):
        self.true_prob = true_prob
        self.price = 0.50  # initial price
        self.price_history = [self.price]

        # Order book (simplified as bid/ask queues)
        self.best_bid = 0.49
        self.best_ask = 0.51

        # Agent populations
        self.n_informed = n_informed
        self.n_noise = n_noise
        self.n_mm = n_mm

        # Track metrics
        self.volume = 0
        self.informed_pnl = 0
        self.noise_pnl = 0

    def step(self):
        """One time step: randomly select an agent to trade."""
        total = self.n_informed + self.n_noise + self.n_mm
        r = np.random.random()

        if r < self.n_informed / total:
            self._informed_trade()
        elif r < (self.n_informed + self.n_noise) / total:
            self._noise_trade()
        else:
            self._mm_update()

        self.price_history.append(self.price)

    def _informed_trade(self):
        """Informed trader: buy if price < true_prob, sell otherwise."""
        signal = self.true_prob + np.random.normal(0, 0.02)  # noisy signal

        if signal > self.best_ask + 0.01:  # buy
            size = min(0.1, abs(signal - self.price) * 2)
            self.price += size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.true_prob - self.best_ask) * size
        elif signal < self.best_bid - 0.01:  # sell
            size = min(0.1, abs(self.price - signal) * 2)
            self.price -= size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.best_bid - self.true_prob) * size

        self.price = np.clip(self.price, 0.01, 0.99)
        self._update_book()

    def _noise_trade(self):
        """Noise trader: random buy/sell."""
        direction = np.random.choice([-1, 1])
        size = np.random.exponential(0.02)
        self.price += direction * size * self._kyle_lambda()
        self.price = np.clip(self.price, 0.01, 0.99)
        self.volume += size
        self.noise_pnl -= abs(self.price - self.true_prob) * size * 0.5
        self._update_book()

    def _mm_update(self):
        """Market maker: tighten spread toward current price."""
        spread = max(0.02, 0.05 * (1 - self.volume / 100))
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def _kyle_lambda(self):
        """Price impact parameter."""
        sigma_v = abs(self.true_prob - self.price) + 0.05
        sigma_u = 0.1 * np.sqrt(self.n_noise)
        return sigma_v / (2 * sigma_u)

    def _update_book(self):
        spread = self.best_ask - self.best_bid
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def run(self, n_steps=1000):
        for _ in range(n_steps):
            self.step()
        return np.array(self.price_history)

# --- Simulation ---
np.random.seed(42)

# Scenario: true probability is 0.65, market starts at 0.50
sim = PredictionMarketABM(true_prob=0.65, n_informed=10, n_noise=50, n_mm=5)
prices = sim.run(n_steps=2000)

print("Agent-Based Prediction Market Simulation")
print(f"True probability:   {sim.true_prob:.2f}")
print(f"Starting price:     0.50")
print(f"Final price:        {prices[-1]:.4f}")
print(f"Price at t=500:     {prices[500]:.4f}")
print(f"Price at t=1000:    {prices[1000]:.4f}")
print(f"Total volume:       {sim.volume:.1f}")
print(f"Informed P&L:       ${sim.informed_pnl:.2f}")
print(f"Noise trader P&L:   ${sim.noise_pnl:.2f}")
print(f"Convergence error:  {abs(prices[-1] - sim.true_prob):.4f}")

With ν=4 and ρ=0.6, tail dependence is approximately 0.18 -z an 18% probability that extreme co-movement occurs given one contract hits an extreme. Gaussian would say 0%.

Clayton copula: Lower tail dependence only (λL=2^−1/θ. When one prediction market crashes, others follow. No upper tail dependence.

Gumbel copula: Upper tail dependence only (λU​=2−2^1/θ). Correlated positive resolutions.

Simulating Correlated Prediction Market Outcomes

def rare_event_IS(S0, K_crash, sigma, T, N_paths=100_000):
    """
    Importance sampling for extreme downside binary contracts.

    Example: P(S&P drops 20% in one week)
    """
    K = S0 * (1 - K_crash)  # e.g., 20% crash threshold

    # Original drift (risk-neutral)
    mu_original = -0.5 * sigma**2

    # Tilted drift: shift the mean toward the crash region
    # Choose mu_tilt so the crash threshold is ~1 std dev away instead of ~4
    log_threshold = np.log(K / S0)
    mu_tilt = log_threshold / T  # center the distribution on the crash

    Z = np.random.standard_normal(N_paths)

    # Simulate under TILTED measure
    log_returns_tilted = mu_tilt * T + sigma * np.sqrt(T) * Z
    S_T_tilted = S0 * np.exp(log_returns_tilted)

    # Likelihood ratio: original density / tilted density
    log_returns_original = mu_original * T + sigma * np.sqrt(T) * Z
    log_LR = (
        -0.5 * ((log_returns_tilted - mu_original * T) / (sigma * np.sqrt(T)))**2
        + 0.5 * ((log_returns_tilted - mu_tilt * T) / (sigma * np.sqrt(T)))**2
    )
    LR = np.exp(log_LR)

    # IS estimator
    payoffs = (S_T_tilted < K).astype(float)
    is_estimates = payoffs * LR

    p_IS = is_estimates.mean()
    se_IS = is_estimates.std() / np.sqrt(N_paths)

    # Compare with crude MC
    Z_crude = np.random.standard_normal(N_paths)
    S_T_crude = S0 * np.exp(mu_original * T + sigma * np.sqrt(T) * Z_crude)
    p_crude = (S_T_crude < K).mean()
    se_crude = np.sqrt(p_crude * (1 - p_crude) / N_paths) if p_crude > 0 else float('inf')

    return {
        'p_IS': p_IS, 'se_IS': se_IS,
        'p_crude': p_crude, 'se_crude': se_crude,
        'variance_reduction': (se_crude / se_IS)**2 if se_IS > 0 else float('inf')
    }

result = rare_event_IS(S0=5000, K_crash=0.20, sigma=0.15, T=5/252)
print(f"IS estimate:    {result['p_IS']:.6f} ± {result['se_IS']:.6f}")
print(f"Crude estimate: {result['p_crude']:.6f} ± {result['se_crude']:.6f}")
print(f"Variance reduction factor: {result['variance_reduction']:.1f}x")

This is the exact reason the Gaussian copula failed in 2008 and would fail again for prediction market portfolios.

The t-copula with v = 4 routinely shows 2–5x higher probability of extreme joint outcomes.

If you're trading correlated prediction market contracts without modeling tail dependence, you're running a portfolio that will blow up in exactly the scenarios that matter most.

Vine Copula

For d>5 contracts, bivariate copulas are insufficient. Vine copulas decompose the dd d-dimensional dependency into d(d−1)/2 bivariate conditional copulas arranged in a tree structure:

  • C-vine (star): One central event drives everything (e.g., presidential winner -> all policy markets)

  • D-vine (path): Sequential dependencies (e.g., primary results flow into general election)

  • R-vine (general graph): Maximum flexibility

build maximum spanning trees ordered by ∣τKendall∣, select pair-copula families via AIC, estimate sequentially. Implementations: pyvinecopulib (Python), VineCopula (R).

第六部分:建模相关矩阵做不到的东西

分层贝叶斯模型通过共享的全国摆动参数,隐式地编码了相关性。

尾部相关性(tail dependence)呢——那种在极端情况下的共振,在简单线性相关里根本看不出来?

2008 年,高斯 copula 未能刻画尾部相关性,是全球金融危机的重要成因之一。在预测市场里,同样的问题也会出现:当一个摇摆州给出意外结果时,所有摇摆州一起翻转的概率,要远高于高斯 copula 的预测。

Sklar 定理

其中 C 是 copula(纯粹的依赖结构),F_i​ 是边缘分布的 CDF。你可以分别建模每个市场的边缘行为,然后用一个能刻画依赖(包括尾部)的 copula 把它们“粘”在一起。

尾部相关性问题

高斯 copula:尾部相关性 λU=λL=0。极端共同波动被建模为零概率。

这对相关联的预测市场来说是灾难性错误

Student-t copula

import numpy as np
from collections import deque

class PredictionMarketABM:
    """
    Agent-based model of a prediction market order book.

    Agent types:
    - Informed: know the true probability, trade toward it
    - Noise: random trades
    - Market maker: provides liquidity around current price
    """
    def __init__(self, true_prob, n_informed=10, n_noise=50, n_mm=5):
        self.true_prob = true_prob
        self.price = 0.50  # initial price
        self.price_history = [self.price]

        # Order book (simplified as bid/ask queues)
        self.best_bid = 0.49
        self.best_ask = 0.51

        # Agent populations
        self.n_informed = n_informed
        self.n_noise = n_noise
        self.n_mm = n_mm

        # Track metrics
        self.volume = 0
        self.informed_pnl = 0
        self.noise_pnl = 0

    def step(self):
        """One time step: randomly select an agent to trade."""
        total = self.n_informed + self.n_noise + self.n_mm
        r = np.random.random()

        if r < self.n_informed / total:
            self._informed_trade()
        elif r < (self.n_informed + self.n_noise) / total:
            self._noise_trade()
        else:
            self._mm_update()

        self.price_history.append(self.price)

    def _informed_trade(self):
        """Informed trader: buy if price < true_prob, sell otherwise."""
        signal = self.true_prob + np.random.normal(0, 0.02)  # noisy signal

        if signal > self.best_ask + 0.01:  # buy
            size = min(0.1, abs(signal - self.price) * 2)
            self.price += size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.true_prob - self.best_ask) * size
        elif signal < self.best_bid - 0.01:  # sell
            size = min(0.1, abs(self.price - signal) * 2)
            self.price -= size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.best_bid - self.true_prob) * size

        self.price = np.clip(self.price, 0.01, 0.99)
        self._update_book()

    def _noise_trade(self):
        """Noise trader: random buy/sell."""
        direction = np.random.choice([-1, 1])
        size = np.random.exponential(0.02)
        self.price += direction * size * self._kyle_lambda()
        self.price = np.clip(self.price, 0.01, 0.99)
        self.volume += size
        self.noise_pnl -= abs(self.price - self.true_prob) * size * 0.5
        self._update_book()

    def _mm_update(self):
        """Market maker: tighten spread toward current price."""
        spread = max(0.02, 0.05 * (1 - self.volume / 100))
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def _kyle_lambda(self):
        """Price impact parameter."""
        sigma_v = abs(self.true_prob - self.price) + 0.05
        sigma_u = 0.1 * np.sqrt(self.n_noise)
        return sigma_v / (2 * sigma_u)

    def _update_book(self):
        spread = self.best_ask - self.best_bid
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def run(self, n_steps=1000):
        for _ in range(n_steps):
            self.step()
        return np.array(self.price_history)

# --- Simulation ---
np.random.seed(42)

# Scenario: true probability is 0.65, market starts at 0.50
sim = PredictionMarketABM(true_prob=0.65, n_informed=10, n_noise=50, n_mm=5)
prices = sim.run(n_steps=2000)

print("Agent-Based Prediction Market Simulation")
print(f"True probability:   {sim.true_prob:.2f}")
print(f"Starting price:     0.50")
print(f"Final price:        {prices[-1]:.4f}")
print(f"Price at t=500:     {prices[500]:.4f}")
print(f"Price at t=1000:    {prices[1000]:.4f}")
print(f"Total volume:       {sim.volume:.1f}")
print(f"Informed P&L:       ${sim.informed_pnl:.2f}")
print(f"Noise trader P&L:   ${sim.noise_pnl:.2f}")
print(f"Convergence error:  {abs(prices[-1] - sim.true_prob):.4f}")

在 ν=4、ρ=0.6 时,尾部相关性大约是 0.18——也就是:在某个合约出现极端结果的条件下,约有 18% 的概率会发生极端共同波动。高斯模型会给出 0%。

Clayton copula:只有下尾相关性(λL=2^−1/θ)。当一个预测市场崩盘,其他市场会跟着崩;没有上尾相关性。

Gumbel copula:只有上尾相关性(λU​=2−2^1/θ)。相关的正向结算。

模拟相关的预测市场结果

def rare_event_IS(S0, K_crash, sigma, T, N_paths=100_000):
    """
    Importance sampling for extreme downside binary contracts.

    Example: P(S&P drops 20% in one week)
    """
    K = S0 * (1 - K_crash)  # e.g., 20% crash threshold

    # Original drift (risk-neutral)
    mu_original = -0.5 * sigma**2

    # Tilted drift: shift the mean toward the crash region
    # Choose mu_tilt so the crash threshold is ~1 std dev away instead of ~4
    log_threshold = np.log(K / S0)
    mu_tilt = log_threshold / T  # center the distribution on the crash

    Z = np.random.standard_normal(N_paths)

    # Simulate under TILTED measure
    log_returns_tilted = mu_tilt * T + sigma * np.sqrt(T) * Z
    S_T_tilted = S0 * np.exp(log_returns_tilted)

    # Likelihood ratio: original density / tilted density
    log_returns_original = mu_original * T + sigma * np.sqrt(T) * Z
    log_LR = (
        -0.5 * ((log_returns_tilted - mu_original * T) / (sigma * np.sqrt(T)))**2
        + 0.5 * ((log_returns_tilted - mu_tilt * T) / (sigma * np.sqrt(T)))**2
    )
    LR = np.exp(log_LR)

    # IS estimator
    payoffs = (S_T_tilted < K).astype(float)
    is_estimates = payoffs * LR

    p_IS = is_estimates.mean()
    se_IS = is_estimates.std() / np.sqrt(N_paths)

    # Compare with crude MC
    Z_crude = np.random.standard_normal(N_paths)
    S_T_crude = S0 * np.exp(mu_original * T + sigma * np.sqrt(T) * Z_crude)
    p_crude = (S_T_crude < K).mean()
    se_crude = np.sqrt(p_crude * (1 - p_crude) / N_paths) if p_crude > 0 else float('inf')

    return {
        'p_IS': p_IS, 'se_IS': se_IS,
        'p_crude': p_crude, 'se_crude': se_crude,
        'variance_reduction': (se_crude / se_IS)**2 if se_IS > 0 else float('inf')
    }

result = rare_event_IS(S0=5000, K_crash=0.20, sigma=0.15, T=5/252)
print(f"IS estimate:    {result['p_IS']:.6f} ± {result['se_IS']:.6f}")
print(f"Crude estimate: {result['p_crude']:.6f} ± {result['se_crude']:.6f}")
print(f"Variance reduction factor: {result['variance_reduction']:.1f}x")

这正是 2008 年高斯 copula 失败的根本原因;对预测市场组合来说,它也会再次失败。

ν=4 的 t-copula 往往会显示:极端联合结果的概率比高斯模型高出 2–5 倍。

如果你在交易相互相关的预测市场合约却不建模尾部相关性,那么你就在运行一个组合——它会在最关键的情景里精准爆炸。

藤 copula(Vine Copula)

当合约维度 d>5 时,仅靠二元 copula 不够。藤 copula把 d 维依赖分解为 d(d−1)/2 个二元条件 copula,并以树结构组织:

  • C-vine(星型):一个中心事件驱动一切(例如:总统胜负 -> 所有政策市场)

  • D-vine(链型):顺序依赖(例如:初选结果流向大选)

  • R-vine(一般图):最大灵活性

按 ∣τKendall∣ 构建最大生成树,用 AIC 选择 pair-copula 族,顺序估计。实现:pyvinecopulib(Python)、VineCopula(R)。

Part VII: Agent-Based Simulation

Everything so far assumes you know the data-generating process and just need to simulate it.

But prediction markets are populated by heterogeneous agents - informed traders, noise traders, market makers, and bots whose interactions produce emergent dynamics that no closed-form SDE can capture.

The Zero-Intelligence Revelation

Markets can be efficient even when every single trader is completely irrational.

Gode & Sunder (1993) showed that zero-intelligence agents - traders who submit random orders subject only to budget constraints achieve near-100% allocative efficiency in a continuous double auction.

Farmer, Patelli & Zovko (2005) extended this to limit order books.

This explained 96% of cross-sectional spread variation on the London Stock Exchange. One parameter. 96%.

Agent-Based Prediction Market Simulator

import numpy as np

def simulate_binary_contract(S0, K, mu, sigma, T, N_paths=100_000):
    """
    Monte Carlo simulation for a binary contract.

    S0:    Current asset price
    K:     Strike / threshold
    mu:    Annual drift
    sigma: Annual volatility
    T:     Time to expiry in years
    N_paths: Number of simulated paths
    """
    # Simulate terminal prices via GBM
    Z = np.random.standard_normal(N_paths)
    S_T = S0 * np.exp((mu - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * Z)

    # Binary payoff
    payoffs = (S_T > K).astype(float)

    # Estimate and confidence interval
    p_hat = payoffs.mean()
    se = np.sqrt(p_hat * (1 - p_hat) / N_paths)
    ci_lower = p_hat - 1.96 * se
    ci_upper = p_hat + 1.96 * se

    return {
        'probability': p_hat,
        'std_error': se,
        'ci_95': (ci_lower, ci_upper),
        'N_paths': N_paths
    }

# Example: AAPL at $195, strike $200, 20% vol, 30 days
result = simulate_binary_contract(S0=195, K=200, mu=0.08, sigma=0.20, T=30/365)
print(f"P(AAPL > $200) ≈ {result['probability']:.4f}")
print(f"95% CI: ({result['ci_95'][0]:.4f}, {result['ci_95'][1]:.4f})")

How fast prices converge depends on the ratio of informed to noise traders, how market maker spread responds to information flow, and why the informed traders extract profit at noise traders expense.

第七部分:基于主体的仿真

到目前为止,我们都假设你知道数据生成过程,只需要把它模拟出来即可。

但预测市场由异质主体构成——知情交易者、噪声交易者、做市商、以及机器人。它们的互动会产生涌现的动力学,任何闭式的 SDE 都捕捉不到。

零智能的启示

即便每一个交易者都完全不理性,市场也仍然可能是有效的

Gode & Sunder(1993)证明:零智能主体——仅在预算约束下随机下单的交易者——在连续双向竞价中也能实现接近 100% 的配置效率。

Farmer、Patelli & Zovko(2005)把这一结果扩展到了限价订单簿。

这解释了伦敦证券交易所横截面价差变化的 96%。一个参数。96%。

基于主体的预测市场模拟器

import numpy as np

def simulate_binary_contract(S0, K, mu, sigma, T, N_paths=100_000):
    """
    Monte Carlo simulation for a binary contract.

    S0:    Current asset price
    K:     Strike / threshold
    mu:    Annual drift
    sigma: Annual volatility
    T:     Time to expiry in years
    N_paths: Number of simulated paths
    """
    # Simulate terminal prices via GBM
    Z = np.random.standard_normal(N_paths)
    S_T = S0 * np.exp((mu - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * Z)

    # Binary payoff
    payoffs = (S_T > K).astype(float)

    # Estimate and confidence interval
    p_hat = payoffs.mean()
    se = np.sqrt(p_hat * (1 - p_hat) / N_paths)
    ci_lower = p_hat - 1.96 * se
    ci_upper = p_hat + 1.96 * se

    return {
        'probability': p_hat,
        'std_error': se,
        'ci_95': (ci_lower, ci_upper),
        'N_paths': N_paths
    }

# Example: AAPL at $195, strike $200, 20% vol, 30 days
result = simulate_binary_contract(S0=195, K=200, mu=0.08, sigma=0.20, T=30/365)
print(f"P(AAPL > $200) ≈ {result['probability']:.4f}")
print(f"95% CI: ({result['ci_95'][0]:.4f}, {result['ci_95'][1]:.4f})")

价格收敛得有多快取决于知情交易者与噪声交易者的比例、做市商价差对信息流的响应方式,以及为什么知情交易者能在噪声交易者的损失上实现盈利。

Part VIII: The Production Stack

Here's the complete system, from market data to trade execution:

  • LAYER 1: DATA INGESTION
  • WebSocket feed from Polymarket CLOB API (real-time prices, volumes)
  • News/poll feeds (NLP-processed into probability signals)
  • On-chain event data (Polygon)

  • LAYER 2: PROBABILITY ENGINE

  • Hierarchical Bayesian model (Stan/PyMC) state-level posteriors
  • Particle filter real-time updating on new observations
  • Jump-diffusion SDE path simulation for risk management
  • Ensemble: weighted average of model outputs

  • LAYER 3: DEPENDENCY MODELING

  • Vine copula pairwise dependencies between contracts
  • Factor model shared national/global risk factors
  • Tail dependence estimation via t-copula

  • LAYER 4: RISK MANAGEMENT

  • EVT-based VaR and Expected Shortfall
  • Reverse stress testing identify worst-case scenarios
  • Correlation stress what if state correlations spike?
  • Liquidity risk order book depth monitoring

  • LAYER 5: MONITORING

  • Brier score tracking (are we calibrated?)
  • P&L attribution (which model component added value?)
  • Drawdown alerts
  • Model drift detection

第八部分:生产级技术栈

下面是完整系统——从市场数据到交易执行:

  • LAYER 1: 数据接入
  • 来自 Polymarket CLOB API 的 WebSocket 推送(实时价格、成交量)
  • 新闻/民调数据流(经 NLP 处理为概率信号)
  • 链上事件数据(Polygon)

  • LAYER 2: 概率引擎

  • 分层贝叶斯模型(Stan/PyMC)给出州级后验
  • 粒子滤波:对新观测进行实时更新
  • 跳跃-扩散 SDE 的路径仿真用于风险管理
  • 集成(Ensemble):对模型输出做加权平均

  • LAYER 3: 依赖建模

  • 藤 copula:合约之间的两两依赖
  • 因子模型:共享的全国/全球风险因子
  • 用 t-copula 估计尾部相关性

  • LAYER 4: 风险管理

  • 基于 EVT 的 VaR 与期望短缺(Expected Shortfall)
  • 反向压力测试:找出最坏情景
  • 相关性压力:如果州与州的相关性突然飙升会怎样?
  • 流动性风险:监控订单簿深度

  • LAYER 5: 监控

  • 追踪布里尔分数(我们校准了吗?)
  • P&L 归因(哪个模型组件贡献了价值?)
  • 回撤告警
  • 模型漂移检测

References

  • Dalen (2025). "Toward Black-Scholes for Prediction Markets." arXiv:2510.15205

  • Saguillo et al. (2025). "Unravelling the Probabilistic Forest: Arbitrage in Prediction Markets." arXiv:2508.03474

  • Madrigal-Cianci et al. (2026). "Prediction Markets as Bayesian Inverse Problems." arXiv:2601.18815

  • Farmer, Patelli & Zovko (2005). "The Predictive Power of Zero Intelligence." PNAS

  • Gode & Sunder (1993). "Allocative Efficiency of Markets with Zero-Intelligence Traders." JPE

  • Kyle (1985). "Continuous Auctions and Insider Trading." Econometrica

  • Glosten & Milgrom (1985). "Bid, Ask, and Transaction Prices." JFE

  • Hoffman & Gelman (2014). "The No-U-Turn Sampler." JMLR

  • Merton (1976). "Option Pricing When Underlying Stock Returns Are Discontinuous." JFE

  • Linzer (2013). "Dynamic Bayesian Forecasting of Presidential Elections." JASA

  • Gelman et al. (2020). "Updated Dynamic Bayesian Forecasting Model." HDSR

  • Aas, Czado, Frigessi & Bakken (2009). "Pair-Copula Constructions of Multiple Dependence." Insurance: Mathematics and Economics

  • Wiese et al. (2020). "Quant GANs: Deep Generation of Financial Time Series." Quantitative Finance

  • Kidger et al. (2021). "Neural SDEs as Infinite-Dimensional GANs." ICML

Link: http://x.com/i/article/2027371960175386624

参考文献

  • Dalen(2025)。《走向预测市场的 Black-Scholes》。arXiv:2510.15205

  • Saguillo 等(2025)。《解开概率森林:预测市场中的套利》。arXiv:2508.03474

  • Madrigal-Cianci 等(2026)。《作为贝叶斯逆问题的预测市场》。arXiv:2601.18815

  • Farmer、Patelli & Zovko(2005)。《零智能的预测力》。PNAS

  • Gode & Sunder(1993)。《零智能交易者市场的配置效率》。JPE

  • Kyle(1985)。《连续竞价与内幕交易》。Econometrica

  • Glosten & Milgrom(1985)。《买价、卖价与成交价》。JFE

  • Hoffman & Gelman(2014)。《No-U-Turn 采样器》。JMLR

  • Merton(1976)。《当标的股票收益不连续时的期权定价》。JFE

  • Linzer(2013)。《总统选举的动态贝叶斯预测》。JASA

  • Gelman 等(2020)。《更新后的动态贝叶斯预测模型》。HDSR

  • Aas、Czado、Frigessi & Bakken(2009)。《多元依赖的 Pair-Copula 构造》。Insurance: Mathematics and Economics

  • Wiese 等(2020)。《Quant GANs:金融时间序列的深度生成》。Quantitative Finance

  • Kidger 等(2021)。《Neural SDEs 作为无限维 GANs》。ICML

链接:http://x.com/i/article/2027371960175386624

相关笔记

This isn't a list of techniques.

It's a story - one that starts with a coin flip and ends with institutional-grade simulation engines.

Each section builds on the last. Skip ahead and the math won't make sense. Read it in order and by the end you'll have runnable code for every layer of the stack.

Disclaimer: ***Not Financial Advice & Do Your Own Research


Part I: The Coin Flip That Breaks Everything

You're staring at a Polymarket contract. "Will the Fed cut rates in March?" YES is trading at $0.62.

Your instinct says: that's a 62% probability. Maybe you think it should be 70%. So you buy.

Congratulations. You just did what every retail trader does. You treated a prediction market contract like a coin flip with a known bias, estimated your own bias, and bet the difference.

  • You have no idea how confident to be in your 70% estimate.

  • You don't know how it should change when tomorrow's jobs report drops.

  • You don't know how it correlates with the six other Fed-related contracts on Polymarket.

  • You don't know whether the price path between now and resolution will let you exit at a profit even if you're eventually right.

A coin flip has one parameter: p.

A prediction market contract embedded in a portfolio of correlated events, with time-varying information flow, order book dynamics, and execution risk, has dozens.

Part II: Monte Carlo. The Foundation Nobody Respects Enough

Every simulation in this article ultimately reduces to Monte Carlo: draw samples from a distribution, compute a statistic, repeat.

The estimator for event probability p=P(A) is just the sample mean:

The Central Limit Theorem gives you the convergence rate: O(N^{-1/2}, with variance Var(p^_N)=p(1−p)/N.

The variance is maximized at p=0.5p A contract trading at 50 cents the most uncertain, most actively traded contract on the platform is exactly where your Monte Carlo estimates are least precise.

To hit ±0.01 precision at 95% confidence when p=0.50:

That's manageable. But it gets worse fast when you need to simulate paths, not just endpoints.

Your First Runnable Simulation

Goal: Estimate the probability that an asset-linked binary contract pays off (e.g., "Will AAPL close above $200 by March 15?")

This works. For one contract, with one underlying, assuming lognormal dynamics. Real prediction markets break every one of those assumptions.

Evaluating Your Simulation

Before we improve the simulation, we need a way to measure how good it is. The Brier Score is the standard calibration metric:

import numpy as np
from scipy.special import expit, logit  # sigmoid and logit

class PredictionMarketParticleFilter:
    """
    Sequential Monte Carlo filter for real-time event probability estimation.

    Usage during a live event (e.g., election night):
        pf = PredictionMarketParticleFilter(prior_prob=0.50)
        pf.update(observed_price=0.55)   # market moves on early returns
        pf.update(observed_price=0.62)   # more data
        pf.update(observed_price=0.58)   # partial correction
        print(pf.estimate())             # filtered probability
    """
    def __init__(self, N_particles=5000, prior_prob=0.5,
                 process_vol=0.05, obs_noise=0.03):
        self.N = N_particles
        self.process_vol = process_vol
        self.obs_noise = obs_noise

        # Initialize particles around prior
        logit_prior = logit(prior_prob)
        self.logit_particles = logit_prior + np.random.normal(0, 0.5, N_particles)
        self.weights = np.ones(N_particles) / N_particles
        self.history = []

    def update(self, observed_price):
        """Incorporate a new observation (market price, poll result, etc.)"""
        # 1. Propagate: random walk in logit space
        noise = np.random.normal(0, self.process_vol, self.N)
        self.logit_particles += noise

        # 2. Convert to probability space
        prob_particles = expit(self.logit_particles)

        # 3. Reweight: likelihood of observation given each particle
        log_likelihood = -0.5 * ((observed_price - prob_particles) / self.obs_noise)**2
        log_weights = np.log(self.weights + 1e-300) + log_likelihood

        # Normalize in log space for stability
        log_weights -= log_weights.max()
        self.weights = np.exp(log_weights)
        self.weights /= self.weights.sum()

        # 4. Check ESS and resample if needed
        ess = 1.0 / np.sum(self.weights**2)
        if ess < self.N / 2:
            self._systematic_resample()

        self.history.append(self.estimate())

    def _systematic_resample(self):
        """Systematic resampling - lower variance than multinomial."""
        cumsum = np.cumsum(self.weights)
        u = (np.arange(self.N) + np.random.uniform()) / self.N
        indices = np.searchsorted(cumsum, u)
        self.logit_particles = self.logit_particles[indices]
        self.weights = np.ones(self.N) / self.N

    def estimate(self):
        """Weighted mean probability estimate."""
        probs = expit(self.logit_particles)
        return np.average(probs, weights=self.weights)

    def credible_interval(self, alpha=0.05):
        """Weighted quantile-based credible interval."""
        probs = expit(self.logit_particles)
        sorted_idx = np.argsort(probs)
        sorted_probs = probs[sorted_idx]
        sorted_weights = self.weights[sorted_idx]
        cumw = np.cumsum(sorted_weights)
        lower = sorted_probs[np.searchsorted(cumw, alpha/2)]
        upper = sorted_probs[np.searchsorted(cumw, 1 - alpha/2)]
        return lower, upper

# --- Simulate election night ---
pf = PredictionMarketParticleFilter(prior_prob=0.50, process_vol=0.03)

# Incoming observations (market prices as new data arrives)
observations = [0.50, 0.52, 0.55, 0.58, 0.61, 0.63, 0.60, 
                0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95]

print("Election Night Tracker:")
print(f"{'Time':>6}  {'Observed':>10}  {'Filtered':>10}  {'95% CI':>20}")
print("-" * 52)

for t, obs in enumerate(observations):
    pf.update(obs)
    ci = pf.credible_interval()
    print(f"{t:>5}h  {obs:>10.3f}  {pf.estimate():>10.3f}  ({ci[0]:.3f}, {ci[1]:.3f})")

A Brier score below 0.20 is good.

Below 0.10 is excellent.

The best election forecasters (538, Economist) historically achieve 0.06-0.12 on presidential races.

If your simulation can beat that, you have edge.

Part III: When 100,000 Samples Aren't Enough

Now the story escalates.

Polymarket hosts contracts on extreme events. "Will the S&P 500 drop 20% in one week?" is trading at $0.003. With crude Monte Carlo at 100,000 samples, you might see zero or one hit.

Your estimate is either 0.00000 or 0.00001 - both useless.

This isn't a theoretical problem. It's the reason most retail traders can't properly evaluate tail-risk contracts.

Make Rare Events Common

Importance sampling replaces the original probability measure with one that oversamples the rare region, then corrects the bias with a likelihood

Likelihood ratio or Radon-Nikodym derivative

Not useful directly, but it tells you what to aim for.

The practical workhorse is exponential tilting.

If your underlying follows a random walk with increments Δ_ihaving moment generating function M(γ)=E[e^γΔ], you tilt the distribution:

choosing γ to make the rare event typical. For a contract that pays off when a sum exceeds a large threshold, γ solves the Lundberg equation M(γ)=1.

Importance Sampling for Tail-Risk Contracts

On extreme contracts, IS can reduce variance by factors of 100–10,000x.

This means 100 IS samples give better precision than 1,000,000 crude samples.

That's not a marginal improvement. It's the difference between "we can't price this" and "we're trading it."

Part IV: Sequential Monte Carlo for Real-Time Updating

But what I need to do while the story shifts from static estimation to dynamic simulation?

Imagine: It's election night. 8:01 PM EST. Florida polls just closed. Early returns show a 3-point shift toward one candidate.

Your model needs to update instantly incorporating this new data point into the probability estimate for not just Florida, but Ohio, Pennsylvania, Michigan, and every correlated state.

This is the filtering problem, and the tool is Sequential Monte Carlo particle filters.

The State-Space Model

Define:

  • Hidden state x_t​: the "true" probability of the event (unobserved)

  • Observation y_t: market prices, poll results, vote counts, news signals

The state evolves via a logit random walk (keeps probabilities bounded):

def brier_score(predictions, outcomes):
    """Evaluate simulation calibration."""
    return np.mean((np.array(predictions) - np.array(outcomes))**2)

# Compare two models
model_A_preds = [0.7, 0.3, 0.9, 0.1]  # sharp, confident
model_B_preds = [0.5, 0.5, 0.5, 0.5]  # always uncertain
actual_outcomes = [1, 0, 1, 0]

print(f"Model A Brier: {brier_score(model_A_preds, actual_outcomes):.4f}")  # 0.05
print(f"Model B Brier: {brier_score(model_B_preds, actual_outcomes):.4f}")  # 0.25

Observations are noisy readings of the true state:

1. INITIALIZE: Draw x_0^{(i)} ~ Prior  for i = 1,...,N
   Set weights w_0^{(i)} = 1/N

2. FOR each new observation y_t:
   a. PROPAGATE:  x_t^{(i)} ~ f( · | x_{t-1}^{(i)} )
   b. REWEIGHT:   w_t^{(i)} ∝ g( y_t | x_t^{(i)} )  
   c. NORMALIZE:  w̃_t^{(i)} = w_t^{(i)} / Σ_j w_t^{(j)}
   d. RESAMPLE if ESS = 1/Σ(w̃_t^{(i)})² < N/2

The Bootstrap Particle Filter

The algorithm maintains N "particles" - each one a hypothesis about the true probability and reweights them as data arrives:

Particle Filter for a Live Prediction Market

Why is this better than just using the market price directly?

Because the particle filter smooths noise and propagates uncertainty.

When the market spikes from $0.58 to $0.65 on a single trade, the filter recognizes that the true probability might not have changed that much it tempers the update based on how volatile the observation process has been.

Part V: Three Variance Reduction Tricks That Stack

Before we leave Monte Carlo territory, here are three techniques that combine multiplicatively with everything above.

Free Symmetry

When the payoff function is monotone (which binary contracts always are higher prices mean higher probability of exceeding the strike), the variance reduction is guaranteed:

import numpy as np
from scipy.stats import norm, t as t_dist

def simulate_correlated_outcomes_gaussian(probs, corr_matrix, N=100_000):
    """Gaussian copula no tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T
    U = norm.cdf(X)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_t(probs, corr_matrix, nu=4, N=100_000):
    """Student-t copula symmetric tail dependence."""
    d = len(probs)
    L = np.linalg.cholesky(corr_matrix)
    Z = np.random.standard_normal((N, d))
    X = Z @ L.T

    # Divide by sqrt(chi-squared / nu) to get t-distributed
    S = np.random.chisquare(nu, N) / nu
    T = X / np.sqrt(S[:, None])
    U = t_dist.cdf(T, nu)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

def simulate_correlated_outcomes_clayton(probs, theta=2.0, N=100_000):
    """Clayton copula (bivariate) lower tail dependence."""
    # Marshall-Olkin algorithm
    V = np.random.gamma(1/theta, 1, N)
    E = np.random.exponential(1, (N, len(probs)))
    U = (1 + E / V[:, None])**(-1/theta)
    outcomes = (U < np.array(probs)).astype(int)
    return outcomes

# --- Compare tail behavior ---
probs = [0.52, 0.53, 0.51, 0.48, 0.50]  # 5 swing state probabilities
state_names = ['PA', 'MI', 'WI', 'GA', 'AZ']

corr = np.array([
    [1.0, 0.7, 0.7, 0.4, 0.3],
    [0.7, 1.0, 0.8, 0.3, 0.3],
    [0.7, 0.8, 1.0, 0.3, 0.3],
    [0.4, 0.3, 0.3, 1.0, 0.5],
    [0.3, 0.3, 0.3, 0.5, 1.0],
])

N = 500_000

gauss_outcomes = simulate_correlated_outcomes_gaussian(probs, corr, N)
t_outcomes = simulate_correlated_outcomes_t(probs, corr, nu=4, N=N)

# P(sweep all 5 states)
p_sweep_gauss = gauss_outcomes.all(axis=1).mean()
p_sweep_t = t_outcomes.all(axis=1).mean()

# P(lose all 5 states)  
p_lose_gauss = (1 - gauss_outcomes).all(axis=1).mean()
p_lose_t = (1 - t_outcomes).all(axis=1).mean()

# If independent
p_sweep_indep = np.prod(probs)
p_lose_indep = np.prod([1-p for p in probs])

print("Joint Outcome Probabilities:")
print(f"{'':>25}  {'Independent':>12}  {'Gaussian':>12}  {'t-copula':>12}")
print(f"{'P(sweep all 5)':>25}  {p_sweep_indep:>12.4f}  {p_sweep_gauss:>12.4f}  {p_sweep_t:>12.4f}")
print(f"{'P(lose all 5)':>25}  {p_lose_indep:>12.4f}  {p_lose_gauss:>12.4f}  {p_lose_t:>12.4f}")
print(f"\nt-copula increases sweep probability by {p_sweep_t/p_sweep_gauss:.1f}x vs Gaussian")

Typical reduction is around 50-75%. Zero extra computational cost beyond doubling the function evaluations (which you were going to do anyway).

Exploit What You Already Know

If you're simulating a binary contract {S_T > K} under stochastic volatility (no closed form), use the Black-Scholes digital price p_{BS}​ (which has a closed form) as a control variate:

def stratified_binary_mc(S0, K, sigma, T, J=10, N_total=100_000):
    """
    Stratified MC for binary contract pricing.
    Strata defined by quantiles of the terminal price distribution.
    """
    n_per_stratum = N_total // J
    estimates = []

    for j in range(J):
        # Uniform draws within stratum [j/J, (j+1)/J]
        U = np.random.uniform(j/J, (j+1)/J, n_per_stratum)
        Z = norm.ppf(U)
        S_T = S0 * np.exp((-0.5*sigma**2)*T + sigma*np.sqrt(T)*Z)
        stratum_mean = (S_T > K).mean()
        estimates.append(stratum_mean)

    # Each stratum has weight 1/J
    p_stratified = np.mean(estimates)
    se_stratified = np.std(estimates) / np.sqrt(J)

    return p_stratified, se_stratified

p, se = stratified_binary_mc(S0=100, K=105, sigma=0.20, T=30/365)
print(f"Stratified estimate: {p:.6f} ± {se:.6f}")

Divide and Conquer

Partition the probability space into JJ J strata, sample within each, combine. The variance is always ≤ crude MC (by the law of total variance), with maximum gain from Neyman allocation: nj∝ωjσj​ (oversample strata with high variance).

Stack all three

Antithetic variates inside each stratum, with a control variate correction and you routinely achieve 100–500x variance reduction over crude MC. This is not optional in production. This is table stakes.

Part VI: Modeling What Correlation Matrices Can't

The hierarchical Bayesian model implicitly encodes correlation through the shared national swing parameter.

But what about tail dependence - the tendency for extreme co-movements that don't show up in linear correlation?

In 2008, the Gaussian copula's failure to model tail dependence contributed to the global financial crisis. In prediction markets, the same issue arises: when one swing state has a surprise result, the probability that all swing states flip together is much higher than a Gaussian copula would predict.

Sklar's Theorem

where C is the copula (the pure dependency structure) and F_i​ are the marginal CDFs. You can model each market's marginal behavior separately, then glue them together with a copula that captures the dependency including in the tails.

The Tail Dependence Problem

Gaussian copula: Tail dependence λU=λL=0. Extreme co-movements are modeled as having zero probability.

This is catastrophically wrong for correlated prediction markets.

Student-t copula

import numpy as np
from collections import deque

class PredictionMarketABM:
    """
    Agent-based model of a prediction market order book.

    Agent types:
    - Informed: know the true probability, trade toward it
    - Noise: random trades
    - Market maker: provides liquidity around current price
    """
    def __init__(self, true_prob, n_informed=10, n_noise=50, n_mm=5):
        self.true_prob = true_prob
        self.price = 0.50  # initial price
        self.price_history = [self.price]

        # Order book (simplified as bid/ask queues)
        self.best_bid = 0.49
        self.best_ask = 0.51

        # Agent populations
        self.n_informed = n_informed
        self.n_noise = n_noise
        self.n_mm = n_mm

        # Track metrics
        self.volume = 0
        self.informed_pnl = 0
        self.noise_pnl = 0

    def step(self):
        """One time step: randomly select an agent to trade."""
        total = self.n_informed + self.n_noise + self.n_mm
        r = np.random.random()

        if r < self.n_informed / total:
            self._informed_trade()
        elif r < (self.n_informed + self.n_noise) / total:
            self._noise_trade()
        else:
            self._mm_update()

        self.price_history.append(self.price)

    def _informed_trade(self):
        """Informed trader: buy if price < true_prob, sell otherwise."""
        signal = self.true_prob + np.random.normal(0, 0.02)  # noisy signal

        if signal > self.best_ask + 0.01:  # buy
            size = min(0.1, abs(signal - self.price) * 2)
            self.price += size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.true_prob - self.best_ask) * size
        elif signal < self.best_bid - 0.01:  # sell
            size = min(0.1, abs(self.price - signal) * 2)
            self.price -= size * self._kyle_lambda()
            self.volume += size
            self.informed_pnl += (self.best_bid - self.true_prob) * size

        self.price = np.clip(self.price, 0.01, 0.99)
        self._update_book()

    def _noise_trade(self):
        """Noise trader: random buy/sell."""
        direction = np.random.choice([-1, 1])
        size = np.random.exponential(0.02)
        self.price += direction * size * self._kyle_lambda()
        self.price = np.clip(self.price, 0.01, 0.99)
        self.volume += size
        self.noise_pnl -= abs(self.price - self.true_prob) * size * 0.5
        self._update_book()

    def _mm_update(self):
        """Market maker: tighten spread toward current price."""
        spread = max(0.02, 0.05 * (1 - self.volume / 100))
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def _kyle_lambda(self):
        """Price impact parameter."""
        sigma_v = abs(self.true_prob - self.price) + 0.05
        sigma_u = 0.1 * np.sqrt(self.n_noise)
        return sigma_v / (2 * sigma_u)

    def _update_book(self):
        spread = self.best_ask - self.best_bid
        self.best_bid = self.price - spread / 2
        self.best_ask = self.price + spread / 2

    def run(self, n_steps=1000):
        for _ in range(n_steps):
            self.step()
        return np.array(self.price_history)

# --- Simulation ---
np.random.seed(42)

# Scenario: true probability is 0.65, market starts at 0.50
sim = PredictionMarketABM(true_prob=0.65, n_informed=10, n_noise=50, n_mm=5)
prices = sim.run(n_steps=2000)

print("Agent-Based Prediction Market Simulation")
print(f"True probability:   {sim.true_prob:.2f}")
print(f"Starting price:     0.50")
print(f"Final price:        {prices[-1]:.4f}")
print(f"Price at t=500:     {prices[500]:.4f}")
print(f"Price at t=1000:    {prices[1000]:.4f}")
print(f"Total volume:       {sim.volume:.1f}")
print(f"Informed P&L:       ${sim.informed_pnl:.2f}")
print(f"Noise trader P&L:   ${sim.noise_pnl:.2f}")
print(f"Convergence error:  {abs(prices[-1] - sim.true_prob):.4f}")

With ν=4 and ρ=0.6, tail dependence is approximately 0.18 -z an 18% probability that extreme co-movement occurs given one contract hits an extreme. Gaussian would say 0%.

Clayton copula: Lower tail dependence only (λL=2^−1/θ. When one prediction market crashes, others follow. No upper tail dependence.

Gumbel copula: Upper tail dependence only (λU​=2−2^1/θ). Correlated positive resolutions.

Simulating Correlated Prediction Market Outcomes

def rare_event_IS(S0, K_crash, sigma, T, N_paths=100_000):
    """
    Importance sampling for extreme downside binary contracts.

    Example: P(S&P drops 20% in one week)
    """
    K = S0 * (1 - K_crash)  # e.g., 20% crash threshold

    # Original drift (risk-neutral)
    mu_original = -0.5 * sigma**2

    # Tilted drift: shift the mean toward the crash region
    # Choose mu_tilt so the crash threshold is ~1 std dev away instead of ~4
    log_threshold = np.log(K / S0)
    mu_tilt = log_threshold / T  # center the distribution on the crash

    Z = np.random.standard_normal(N_paths)

    # Simulate under TILTED measure
    log_returns_tilted = mu_tilt * T + sigma * np.sqrt(T) * Z
    S_T_tilted = S0 * np.exp(log_returns_tilted)

    # Likelihood ratio: original density / tilted density
    log_returns_original = mu_original * T + sigma * np.sqrt(T) * Z
    log_LR = (
        -0.5 * ((log_returns_tilted - mu_original * T) / (sigma * np.sqrt(T)))**2
        + 0.5 * ((log_returns_tilted - mu_tilt * T) / (sigma * np.sqrt(T)))**2
    )
    LR = np.exp(log_LR)

    # IS estimator
    payoffs = (S_T_tilted < K).astype(float)
    is_estimates = payoffs * LR

    p_IS = is_estimates.mean()
    se_IS = is_estimates.std() / np.sqrt(N_paths)

    # Compare with crude MC
    Z_crude = np.random.standard_normal(N_paths)
    S_T_crude = S0 * np.exp(mu_original * T + sigma * np.sqrt(T) * Z_crude)
    p_crude = (S_T_crude < K).mean()
    se_crude = np.sqrt(p_crude * (1 - p_crude) / N_paths) if p_crude > 0 else float('inf')

    return {
        'p_IS': p_IS, 'se_IS': se_IS,
        'p_crude': p_crude, 'se_crude': se_crude,
        'variance_reduction': (se_crude / se_IS)**2 if se_IS > 0 else float('inf')
    }

result = rare_event_IS(S0=5000, K_crash=0.20, sigma=0.15, T=5/252)
print(f"IS estimate:    {result['p_IS']:.6f} ± {result['se_IS']:.6f}")
print(f"Crude estimate: {result['p_crude']:.6f} ± {result['se_crude']:.6f}")
print(f"Variance reduction factor: {result['variance_reduction']:.1f}x")

This is the exact reason the Gaussian copula failed in 2008 and would fail again for prediction market portfolios.

The t-copula with v = 4 routinely shows 2–5x higher probability of extreme joint outcomes.

If you're trading correlated prediction market contracts without modeling tail dependence, you're running a portfolio that will blow up in exactly the scenarios that matter most.

Vine Copula

For d>5 contracts, bivariate copulas are insufficient. Vine copulas decompose the dd d-dimensional dependency into d(d−1)/2 bivariate conditional copulas arranged in a tree structure:

  • C-vine (star): One central event drives everything (e.g., presidential winner -> all policy markets)

  • D-vine (path): Sequential dependencies (e.g., primary results flow into general election)

  • R-vine (general graph): Maximum flexibility

build maximum spanning trees ordered by ∣τKendall∣, select pair-copula families via AIC, estimate sequentially. Implementations: pyvinecopulib (Python), VineCopula (R).

Part VII: Agent-Based Simulation

Everything so far assumes you know the data-generating process and just need to simulate it.

But prediction markets are populated by heterogeneous agents - informed traders, noise traders, market makers, and bots whose interactions produce emergent dynamics that no closed-form SDE can capture.

The Zero-Intelligence Revelation

Markets can be efficient even when every single trader is completely irrational.

Gode & Sunder (1993) showed that zero-intelligence agents - traders who submit random orders subject only to budget constraints achieve near-100% allocative efficiency in a continuous double auction.

Farmer, Patelli & Zovko (2005) extended this to limit order books.

This explained 96% of cross-sectional spread variation on the London Stock Exchange. One parameter. 96%.

Agent-Based Prediction Market Simulator

import numpy as np

def simulate_binary_contract(S0, K, mu, sigma, T, N_paths=100_000):
    """
    Monte Carlo simulation for a binary contract.

    S0:    Current asset price
    K:     Strike / threshold
    mu:    Annual drift
    sigma: Annual volatility
    T:     Time to expiry in years
    N_paths: Number of simulated paths
    """
    # Simulate terminal prices via GBM
    Z = np.random.standard_normal(N_paths)
    S_T = S0 * np.exp((mu - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * Z)

    # Binary payoff
    payoffs = (S_T > K).astype(float)

    # Estimate and confidence interval
    p_hat = payoffs.mean()
    se = np.sqrt(p_hat * (1 - p_hat) / N_paths)
    ci_lower = p_hat - 1.96 * se
    ci_upper = p_hat + 1.96 * se

    return {
        'probability': p_hat,
        'std_error': se,
        'ci_95': (ci_lower, ci_upper),
        'N_paths': N_paths
    }

# Example: AAPL at $195, strike $200, 20% vol, 30 days
result = simulate_binary_contract(S0=195, K=200, mu=0.08, sigma=0.20, T=30/365)
print(f"P(AAPL > $200) ≈ {result['probability']:.4f}")
print(f"95% CI: ({result['ci_95'][0]:.4f}, {result['ci_95'][1]:.4f})")

How fast prices converge depends on the ratio of informed to noise traders, how market maker spread responds to information flow, and why the informed traders extract profit at noise traders expense.

Part VIII: The Production Stack

Here's the complete system, from market data to trade execution:

  • LAYER 1: DATA INGESTION
  • WebSocket feed from Polymarket CLOB API (real-time prices, volumes)
  • News/poll feeds (NLP-processed into probability signals)
  • On-chain event data (Polygon)

  • LAYER 2: PROBABILITY ENGINE

  • Hierarchical Bayesian model (Stan/PyMC) state-level posteriors
  • Particle filter real-time updating on new observations
  • Jump-diffusion SDE path simulation for risk management
  • Ensemble: weighted average of model outputs

  • LAYER 3: DEPENDENCY MODELING

  • Vine copula pairwise dependencies between contracts
  • Factor model shared national/global risk factors
  • Tail dependence estimation via t-copula

  • LAYER 4: RISK MANAGEMENT

  • EVT-based VaR and Expected Shortfall
  • Reverse stress testing identify worst-case scenarios
  • Correlation stress what if state correlations spike?
  • Liquidity risk order book depth monitoring

  • LAYER 5: MONITORING

  • Brier score tracking (are we calibrated?)
  • P&L attribution (which model component added value?)
  • Drawdown alerts
  • Model drift detection

References

  • Dalen (2025). "Toward Black-Scholes for Prediction Markets." arXiv:2510.15205

  • Saguillo et al. (2025). "Unravelling the Probabilistic Forest: Arbitrage in Prediction Markets." arXiv:2508.03474

  • Madrigal-Cianci et al. (2026). "Prediction Markets as Bayesian Inverse Problems." arXiv:2601.18815

  • Farmer, Patelli & Zovko (2005). "The Predictive Power of Zero Intelligence." PNAS

  • Gode & Sunder (1993). "Allocative Efficiency of Markets with Zero-Intelligence Traders." JPE

  • Kyle (1985). "Continuous Auctions and Insider Trading." Econometrica

  • Glosten & Milgrom (1985). "Bid, Ask, and Transaction Prices." JFE

  • Hoffman & Gelman (2014). "The No-U-Turn Sampler." JMLR

  • Merton (1976). "Option Pricing When Underlying Stock Returns Are Discontinuous." JFE

  • Linzer (2013). "Dynamic Bayesian Forecasting of Presidential Elections." JASA

  • Gelman et al. (2020). "Updated Dynamic Bayesian Forecasting Model." HDSR

  • Aas, Czado, Frigessi & Bakken (2009). "Pair-Copula Constructions of Multiple Dependence." Insurance: Mathematics and Economics

  • Wiese et al. (2020). "Quant GANs: Deep Generation of Financial Time Series." Quantitative Finance

  • Kidger et al. (2021). "Neural SDEs as Infinite-Dimensional GANs." ICML

📋 讨论归档

讨论进行中…