上一节我们把市场形态和撮合规则讲清楚了:给定一批买卖单,按什么顺序、什么优先级成交。但撮合规则只回答了「谁和谁成交」,它没有回答另外两个更要命的问题:成交在什么价位?这个价位能不能一直成交下去? 前者是定价,后者是流动性。这一节处理这两个问题。
先说边界。站内的经济系统运营 checklist 给的是产业层面的运营动作清单——什么时候该做什么活动、该盯哪些指标。那是一张动作表,不是价格模型:它不会告诉你「粮草在这个赛季的中期应该值多少」,更不会告诉你「为什么它今天值 98 明天值 103」。本节给的是一个可跑的价格形成模型,把「价格」从一个经验数字变成一个能被仿真的过程。
站内的经济平衡长文 用的是放置类口径,那里几乎不存在玩家间价格。SLG 不同:只要有交换,价格就会自发形成,而你如果不主动设计价格的形成机制,它也会自己长出来一个——只是长成什么样你说了不算。
价格从哪里来:三种锚
在一个玩家市场里,价格的形成无非三种锚:
第一种是供需锚。 价格完全由买单和卖单的相对强度决定:想买的人多、想卖的人少,价格就往上走。这是最「市场」的一种,优点是你什么都不用做,缺点是它会失控——在 SLG 里,供需很容易被一次活动、一次版本更新、甚至一次部落战彻底打乱,价格可能一天翻倍。
第二种是锚定锚。 价格锚在一个玩家心里已有的参照点上,比如「上个月粮草一直卖 100」。锚定的好处是价格稳定、玩家有预期;坏处是当基本面已经变了(比如产出端被砍了一半),价格还会赖在旧锚上,形成虚假的稳定,一旦松动就是断崖。
第三种是系统指导价。 系统直接给一个参考价(比如「粮草官方价 100」),玩家的挂单围绕它浮动。这是最可控的一种,系统随时能用一个价去指导产出与回收;代价是它把市场半计划化了——玩家会围着指导价套利,指导价一旦偏离真实供需,反而会放大扭曲。
真实 SLG 几乎总是三锚混用:系统给一个指导价兜底,玩家在指导价附近挂单,短期靠供需波动,长期靠锚定收敛。本节要仿真的,正是这个「玩家围绕理论价值挂单、系统让价格自然收敛」的过程。
流动性怎么度量:三个数
「流动性好」是一个模糊的说法,它必须被拆成可量的三个数:
- 挂单深度:当前订单簿上所有未成交挂单的总量。深度越厚,一笔大单越不容易把价格打飞。
- 成交率:本轮挂出的单子里,有多大比例真的成交了。成交率太低,说明挂单离成交价太远,市场「看着热闹其实很薄」。
- 买卖价差:最优买价和最优卖价之间的距离。价差越窄,玩家买卖的摩擦成本越低,市场越「活」。
这三个数互相牵制:深度靠耐心挂单堆积,成交率靠价格贴近提高,而价差是两者的结果。一个健康的市场,应该是深度随时间变厚、价差随时间收窄、成交率维持在一个不温不火的水平。
多玩家订单簿仿真
下面这段是 S 层仿真脚本,读 config/ 里的市场参数,造一个多玩家限价订单簿,跑 N 轮交易。每轮每个玩家围绕理论价值挂一张限价单,订单进簿后用价格时间优先撮合,未成交部分留在簿里形成深度。脚本每轮记录最优买/卖价、价差、成交价、成交量、簿深与成交率,并画出价格收敛与价差变化图。
{
"version": 1,
"note": "多玩家限价订单簿仿真:每轮玩家围绕理论价值挂单,价格时间优先撮合,观察价格收敛与价差收窄。全部整数,PRNG 确定性。",
"seed": 20261012,
"rounds": 40,
"fundamentalStart": 100,
"fundamentalDriftPermille": 0,
"traders": 12,
"priceNoisePermille": 90,
"qtyMin": 10,
"qtyMax": 60,
"feePermille": 30,
"targetSpreadPermille": 40
}
# file: sim/ch14_pricing_liquidity.py
# tier: S
# verify: python3 sim/ch14_pricing_liquidity.py --config config/ --out out/
"""定价机制与流动性:多玩家限价订单簿仿真,跑 N 轮交易,观察价格收敛与价差收窄。
每轮玩家围绕理论价值挂限价单,订单进入常驻订单簿后用价格时间优先撮合,
未成交部分留在簿内形成深度。每轮记录最优买/卖价、价差、成交价、成交量、簿深、成交率。
产出 out/ch14_pricing.json + out/ch14_pricing.csv + out/ch14_pricing.svg(纯 Python SVG)。
"""
from __future__ import annotations
import argparse, csv, json, os, xml.etree.ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
def xorshift32(seed):
"""32 位 xorshift,纯整数,确定性。"""
s = seed & 0xFFFFFFFF or 0x9E3779B9
def nxt():
nonlocal s
s ^= (s << 13) & 0xFFFFFFFF; s ^= s >> 17; s ^= (s << 5) & 0xFFFFFFFF
s &= 0xFFFFFFFF
return s
return nxt
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch14_pricing.json"), encoding="utf-8") as f:
return json.load(f)
def run(cfg):
rand = xorshift32(cfg["seed"])
fund, drift = cfg["fundamentalStart"], cfg["fundamentalDriftPermille"]
noise, qmin, qmax, traders = cfg["priceNoisePermille"], cfg["qtyMin"], cfg["qtyMax"], cfg["traders"]
seq, book_buy, book_sell, rounds = 0, [], [], []
for r in range(1, cfg["rounds"] + 1):
fund = fund * (1000 + drift) // 1000
posted = 0
for _ in range(traders):
side = "buy" if rand() % 2 == 0 else "sell"
price = fund * (1000 + rand() % (2 * noise + 1) - noise) // 1000
qty = qmin + rand() % (qmax - qmin + 1)
seq += 1; posted += qty
(book_buy if side == "buy" else book_sell).append({"price": price, "qty": qty, "seq": seq})
book_buy.sort(key=lambda o: (-o["price"], o["seq"]))
book_sell.sort(key=lambda o: (o["price"], o["seq"]))
i = j = filled = notional = 0
while i < len(book_buy) and j < len(book_sell) and book_buy[i]["price"] >= book_sell[j]["price"]:
b, s = book_buy[i], book_sell[j]
q = min(b["qty"], s["qty"])
price = b["price"] if b["seq"] <= s["seq"] else s["price"] # 成交价取先到一方挂价
filled += q; notional += q * price
b["qty"] -= q; s["qty"] -= q
if b["qty"] == 0: i += 1
if s["qty"] == 0: j += 1
book_buy = [o for o in book_buy if o["qty"] > 0]
book_sell = [o for o in book_sell if o["qty"] > 0]
best_bid = max((o["price"] for o in book_buy), default=0)
best_ask = min((o["price"] for o in book_sell), default=0)
rounds.append({"round": r, "fundamental": fund, "bestBid": best_bid, "bestAsk": best_ask,
"spread": (best_ask - best_bid) if (best_bid and best_ask) else 0,
"clearingPrice": notional // filled if filled else 0, "volume": filled,
"depth": sum(o["qty"] for o in book_buy) + sum(o["qty"] for o in book_sell),
"fillRatePermille": filled * 1000 // posted if posted else 0})
head, tail = rounds[:5], rounds[-5:]
hs = sum(r["spread"] for r in head) // len(head)
ts = sum(r["spread"] for r in tail) // len(tail)
cl = [r["clearingPrice"] for r in rounds if r["clearingPrice"]]
mc = sum(cl) // len(cl)
return {"version": 1, "rounds": len(rounds), "fundamentalEnd": fund,
"head5AvgSpread": hs, "tail5AvgSpread": ts,
"spreadTightenPct": (hs - ts) * 100 // hs if hs else 0,
"meanClearingPrice": mc, "priceErrorPermille": abs(mc - fund) * 1000 // fund,
"finalClearingPrice": rounds[-1]["clearingPrice"],
"initialDepth": rounds[0]["depth"], "finalDepth": rounds[-1]["depth"],
"meanFillRatePermille": sum(r["fillRatePermille"] for r in rounds) // len(rounds),
"series": rounds}
def draw_svg(path, series):
W, H, pl, pt, tw, bot = 720, 440, 60, 34, 230, 120
svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
n, iw = len(series), W - 80
x = lambda i: pl + iw * i / max(n - 1, 1)
vals = [r[k] for r in series for k in ("bestBid", "bestAsk", "clearingPrice") if r[k]]
lo, hi = min(vals), max(vals); span = (hi - lo) or 1
yt = lambda v: pt + tw * (1.0 - (v - lo) / span)
ET.SubElement(svg, "text", {"x": str(pl), "y": "20", "font-size": "13", "fill": "#111"}).text = \
"价格收敛(上:买价/卖价/成交价)与买卖价差(下)"
for key, color, w in (("bestAsk", "#d1495b", "1.5"), ("bestBid", "#2a9d8f", "1.5"), ("clearingPrice", "#1f6feb", "2.2")):
pts = " ".join(f"{x(i):.1f},{yt(r[key]):.1f}" for i, r in enumerate(series) if r[key])
ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": color, "stroke-width": w})
by = pt + tw + 40
ms = max((r["spread"] for r in series), default=1) or 1
bw = iw / max(n, 1) * 0.7
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(by + bot), "x2": str(W - 20), "y2": str(by + bot), "stroke": "#999"})
for i, r in enumerate(series):
h = bot * r["spread"] / ms
ET.SubElement(svg, "rect", {"x": f"{x(i) - bw / 2:.1f}", "y": f"{by + bot - h:.1f}", "width": f"{bw:.1f}",
"height": f"{h:.1f}", "fill": "#f4a261"})
ET.SubElement(svg, "text", {"x": str(pl), "y": str(by - 8), "font-size": "12", "fill": "#111"}).text = f"买卖价差(峰值 {ms})"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
rep = run(load(a.config))
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "ch14_pricing.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
with open(os.path.join(a.out, "ch14_pricing.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["round", "fundamental", "bestBid", "bestAsk", "spread", "clearingPrice", "volume", "depth", "fillRatePermille"])
for r in rep["series"]:
w.writerow([r["round"], r["fundamental"], r["bestBid"], r["bestAsk"], r["spread"],
r["clearingPrice"], r["volume"], r["depth"], r["fillRatePermille"]])
draw_svg(os.path.join(a.out, "ch14_pricing.svg"), rep["series"])
print(json.dumps({k: rep[k] for k in ("rounds", "head5AvgSpread", "tail5AvgSpread", "spreadTightenPct",
"meanClearingPrice", "priceErrorPermille", "initialDepth",
"finalDepth", "meanFillRatePermille")}, ensure_ascii=False))
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,汇总输出如下(同机相对量级,绝对数值不可移植):
{"rounds": 40, "head5AvgSpread": 2, "tail5AvgSpread": 1, "spreadTightenPct": 50,
"meanClearingPrice": 98, "priceErrorPermille": 20, "initialDepth": 181,
"finalDepth": 6420, "meanFillRatePermille": 305}
out/ch14_pricing.csv 的前后几行是这样的:
round,fundamental,bestBid,bestAsk,spread,clearingPrice,volume,depth,fillRatePermille
1,100,95,99,4,93,112,181,276
2,100,95,100,5,101,119,355,288
...
39,100,100,101,1,98,121,6392,315
40,100,100,101,1,98,174,6420,462
脚本还会画出 out/ch14_pricing.svg——上半是买价/卖价/成交价三条线,下半是买卖价差柱状图,全部由纯 Python 的 xml.etree 生成,不依赖 matplotlib。
结果怎么读:价格收敛与价差收窄
三组数字讲了三件事。
第一,价格向理论价值收敛。 理论价值固定为 100,成交价的均值是 98,与理论价值的偏差只有 20 个千分点(约 2%)。第 1 轮成交价还在 93,到后面稳定在 98–103 之间。这说明玩家围绕理论价值挂单时,成交价会自发收敛到理论价值附近——系统不需要手动定价,只需要让玩家有理由围绕一个锚挂单。
第二,价差在收窄。 前 5 轮的平均价差是 2,后 5 轮降到 1,收窄了 50%。价差为什么会收窄?因为订单簿在变厚:簿深从第 1 轮的 181 涨到第 40 轮的 6420。深度越厚,最优买价和最优卖价就越容易被顶到贴近理论价值的位置,价差自然就窄。
第三,成交率维持在一个「不温不火」的水平。 平均成交率是 305 千分点(约 30%)。也就是说,每轮挂出的单子里大约三成成交、七成留在簿里变成深度。这个比例既不能太高也不能太低:太高(比如 80%)说明大家都在抢着成交,深度堆不起来,市场脆;太低(比如 5%)说明挂单离成交价太远,市场看着有单子其实撮合不动。30% 左右,是一个「深度能积累、成交也不断」的平衡点。
这三组数字合起来,就是一个健康市场的「体检报告」:价格贴着锚走、价差随时间收窄、深度随时间变厚、成交率维持中位。任何一项明显跑偏——价格脱离锚、价差不降反升、深度越跑越薄——都是经济要出问题的早期信号。
把流动性接进运营
流动性不是一次性调好的,它需要被持续监控。落地时有三个动作:
- 把三个数做成看板:挂单深度、成交率、买卖价差,按小时或按天出曲线,价差连续上升就告警;
- 用指导价兜底:当深度掉到某个阈值以下(市场要枯了),系统可以挂出几张「托底单」,用指导价把价差重新压回去;
- 用手续费调节:手续费本质是「每笔交易的摩擦」,调高手续费会同时降低成交率和套利空间——它是运营手里最直接的一个旋钮(这一点和 11.2 的税率弹性是同一套逻辑)。
下一节我们离开「价格怎么形成」,转向「价格差会招来什么」——哪里有价差,哪里就有套利;套利到什么程度是健康的,什么程度会招来工作室。14.3 讲套利、工作室与风控边界。
阅读导航:上一节:市场形态:拍卖行 / 摆摊 / 交易所 · 下一节:套利、工作室与风控边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。