跳转至

做市游戏:10 枚骰子的和

题

面试官掷 10 枚骰子,盖住不看,点数和记为 \(S\)。他说:"给我报一个买价和卖价(bid / ask),每点 1 块钱,我可以在你的价格上买或卖。"你怎么报?他在你的卖价上买了一手之后,你怎么改价?

一句话

中价报 35,价差(spread)取在一个标准差附近——比如 32 @ 38;被成交之后不要原地不动:对手愿意买,说明真值更可能在你的卖价之上,把报价往成交方向挪。

关键技巧

一、公允价 = 期望。 期望线性,\(E[S] = 10 \times 3.5 = 35\)。不需要知道分布长什么样。

二、价差由不确定性决定。 单枚骰子方差 \(\frac{35}{12}\),独立相加:

\[ \mathrm{Var}(S) = 10 \times \frac{35}{12} = \frac{175}{6} \approx 29.17, \qquad \mathrm{sd} \approx 5.40 \]

对完全没信息的对手,每成交一手你稳赚半个价差——这笔钱是用来贴补有信息的对手从你身上赚走的部分。不确定性越大,对手可能掌握的信息越值钱,价差越要宽。

三、成交是信息:逆向选择(adverse selection)。 对手不是随机成交的。若他比你多知道一点,他只在对他有利时成交——你被买走的时刻,恰恰是真值高于卖价的时刻。你该关心的不是 \(E[S]\),而是

\[ E[S \mid \text{对手在 ask 买}] \]

报价合理的条件是这个条件期望不超过 ask。这就是 Glosten–Milgrom 模型的做市零利润条件:ask 取在 \(a = E[S \mid \text{在 } a \text{ 被买}]\) 的不动点上。

解

第一步:报初始价。 精确分布下,不同宽度的区间覆盖真值的概率:

报价 32 @ 38 30 @ 40 27 @ 43
半宽 3(约 0.6 sd) 5(约 0.9 sd) 8(约 1.5 sd)
\(P(\text{bid} \le S \le \text{ask})\) 47.8% 68.7% 88.4%

面试里报 32 @ 38 或 30 @ 40 都说得通——要能说出"这是大约一个标准差"。报 34 @ 36 太窄,稍有信息的对手就能吃你;报 20 @ 50 太宽,没人跟你做,等于没做市。

{
  "height": 300,
  "grid": {"left": 55, "right": 30, "top": 30, "bottom": 40},
  "xAxis": {"type": "category", "name": "S", "nameLocation": "middle", "nameGap": 26,
            "data": ["20","21","22","23","24","25","26","27","28","29","30","31","32","33","34","35","36","37","38","39","40","41","42","43","44","45","46","47","48","49","50"]},
  "yAxis": {"type": "value", "name": "P(S)"},
  "series": [{
    "type": "bar", "barWidth": "70%", "itemStyle": {"color": "#4e79a7"},
    "data": [0.0014,0.0024,0.004,0.0063,0.0095,0.0137,0.019,0.0254,0.0326,0.0405,0.0485,0.0561,0.0629,0.0682,0.0715,0.0727,0.0715,0.0682,0.0629,0.0561,0.0485,0.0405,0.0326,0.0254,0.019,0.0137,0.0095,0.0063,0.004,0.0024,0.0014],
    "markLine": {
      "symbol": "none",
      "lineStyle": {"color": "#e15759", "width": 2, "type": "dashed"},
      "label": {"position": "end"},
      "data": [{"xAxis": "32", "label": {"formatter": "bid 32"}}, {"xAxis": "38", "label": {"formatter": "ask 38"}}]
    }
  }]
}

第二步:被成交后更新。 先看最极端的情形——对手偷看了一枚骰子 \(d\),他眼里的期望是 \(d + 31.5\),只在这比你的 ask 高时才买:

  • ask = 37:只有 \(d = 6\) 时他才买。成交条件下 \(E[S] = 37.5\)——你每手亏 0.5。
  • ask = 36:\(d \in \{5, 6\}\) 时买,成交条件下 \(E[S] = 37\),每手亏 1。
  • ask = 38:他永远不买。

只要被成交,你就站在了错的一边。 更现实的设定是你不知道对手有没有信息。设对手有 \(\pi\) 的概率是知道 \(S\) 的内幕交易者($S > $ ask 才买),否则是噪声交易者(一半概率买)。在 ask = 37 被买之后:

\(\pi\) 0.1 0.3 0.5
\(E[S \mid \text{在 37 被买}]\) 35.40 36.31 37.37
零利润 ask 35.44 36.34 37.37

\(\pi = 0.5\) 时 37 已经不够——被买一次,新的公允价就跳到 37.4,下一轮报价应该整体上移到 34 @ 40 一带。面试里说清楚"被买就上移、被卖就下移,移多少取决于我认为对手有多大信息优势",比算出精确数重要。

延伸

  • 逐枚揭开的版本:面试官常在中途翻开一枚骰子。翻出 \(d\) 后,剩下 9 枚的期望 31.5、sd \(\sqrt{9 \times 35/12} \approx 5.12\),新中价 \(31.5 + d\),价差按新 sd 收窄。每揭开一枚,不确定性按 \(\sqrt{n}\) 缩小。
  • 库存风险:连续被买 10 手后你空了 10 手,盈亏的标准差是 \(10 \times 5.4 = 54\)。即使没有信息不对称,也该把报价往上偏——提高 bid 更愿意买回来、提高 ask 更不愿再卖。Avellaneda–Stoikov 模型把这写成保留价格 \(r = s - q\gamma\sigma^2\)(\(q\) 为持仓,空头时 \(q<0\),\(r\) 上移)。
  • 信息 vs 库存,两种移价别混:逆向选择移的是中价的估计(你对 \(S\) 的看法变了);库存管理移的是报价相对中价的偏斜(你对风险的承受力变了)。两者方向常常一致,原因完全不同。
  • 常见错误一:被成交后原价继续报。对手连着在同一个价位买,是在告诉你价格错了。
  • 常见错误二:追求价差最窄。面试官考的是风险意识——宽价差加上能讲清楚的更新规则,远比窄价差被反复吃单好。
  • 标的换成估算题:做市游戏的标的常是 Fermi 估算 类的问题("世界上有多少架飞机")。这时 sd 来自你拆解的误差,价差应在对数尺度上对称——报 \(x/3\) @ \(3x\) 而不是 \(x \pm c\)。
数值验证

精确卷积出 \(S\) 的分布,再算偷看一枚和 Glosten–Milgrom 两个情形:

from fractions import Fraction as F
import math

dist = {0: F(1)}
for _ in range(10):                        # 10 次卷积,精确有理数
    new = {}
    for s, p in dist.items():
        for d in range(1, 7):
            new[s + d] = new.get(s + d, 0) + p / 6
    dist = new
mu = sum(s * p for s, p in dist.items())
var = sum((s - mu) ** 2 * p for s, p in dist.items())
print(mu, var, math.sqrt(var))             # 35 175/6 5.400617248673217
P = lambda cond: float(sum(p for s, p in dist.items() if cond(s)))
for w in (3, 5, 8):
    print(35 - w, 35 + w, round(P(lambda s: 35 - w <= s <= 35 + w), 3))
# 32 38 0.478
# 30 40 0.687
# 27 43 0.884

for ask in (36, 37, 38):                   # 对手偷看了 1 枚骰子
    buys = [d for d in range(1, 7) if d + 31.5 > ask]
    print(ask, buys, sum(d + 31.5 for d in buys) / len(buys) if buys else None)
# 36 [5, 6] 37.0
# 37 [6] 37.5
# 38 [] None

def E_given_buy(ask, pi):
    """比例 pi 的对手知道 S(S > ask 才买),其余一半概率买。"""
    up = sum(p for s, p in dist.items() if s > ask)
    num = pi * sum(s * p for s, p in dist.items() if s > ask) + (1 - pi) * F(1, 2) * mu
    return float(num / (pi * up + (1 - pi) * F(1, 2)))

for pi in (F(1, 10), F(3, 10), F(1, 2)):
    a = min((abs(x / 100 - E_given_buy(x / 100, pi)), x / 100) for x in range(3500, 4500))[1]
    print(float(pi), round(E_given_buy(37, pi), 2), a)
# 0.1 35.4 35.44
# 0.3 36.31 36.34
# 0.5 37.37 37.37

print(round(math.sqrt(9 * 35 / 12), 2))   # 5.12