14.1 市场形态:拍卖行 / 摆摊 / 交易所

玩家之间的交换是 SLG 经济最容易被低估的一环。本节把拍卖行、摆摊、交易所三种市场形态拆开,讲清集中撮合、分散位置成本、挂单深度各自对经济的影响与实现代价;再对同一批买卖订单用先到先得、价格优先、价格时间优先三种规则跑出成交价与成交量,对比差异。

前面十三节我们一直在讲系统和系统之间的资源流动:产出端给多少、消耗端吃多少、赛季重置回收多少。但 SLG 里还有一条主线,是玩家和玩家之间的资源流动——一个玩家多余的粮草,换成另一个玩家手里的铁矿。这条线一旦打开,经济就不再是一个封闭的池子,而是有了一个内部的价格。这一章讲的就是这条线。

动手前先划清边界。站内的经济平衡长文 给了一套通用的经济平衡框架,但它举例用的是放置类:离线收益、线性挂机、指数成长,玩家之间几乎不发生交换。那套框架里没有「价格」这个概念,因为放置类不需要它。SLG 不一样:大地图上有大量同质化的资源,玩家又有差异化的需求,于是交换必然发生,价格必然出现。本章换用 SLG 大地图地块口径,专门处理这个「玩家之间」的问题。

站内的 SLG 技术架构专题 讲的是前后端分层与协议设计,明确不碰玩法数值。市场恰恰是协议与数值咬合最紧的地方——撮合规则既是服务器的一段代码,也是一个会改变经济分配结果的数值决策。这一节先把形态讲清楚,定价与流动性留到 14.2,套利与工作室留到 14.3。

玩家之间为什么要交换

在单机或纯 PVE 的 SLG 里,资源是「打出来的」,玩家和系统之间是单向关系。但 SLG 一旦进入赛季制大地图,玩家之间的资源禀赋就会分化:

  • 地缘差异:出生在粮区的人粮草溢出,出生在矿区的人铁矿溢出;
  • 阶段差异:早期玩家屯了一堆用不上的高级材料,后期玩家正缺这些材料;
  • 玩法差异:种田党产出的资源远多于自己消耗,战斗党消耗远大于产出。

只要有差异,就有交换的动力;只要有交换,就需要一个市场来撮合。而市场长成什么样,直接决定了这个经济体的价格透明度、流动性和被套利的难易程度。

三种形态:拍卖行 / 摆摊 / 交易所

市面上的 SLG 大致用三种市场形态,它们不是「好和坏」,而是三种不同的取舍。

拍卖行是集中撮合:所有挂单进同一个池子,系统按规则统一撮合,成交价对所有玩家可见。它的优点是价格透明——玩家一眼就能看到「现在粮草值多少」,系统也容易拿到一个统一的参考价来指导经济。代价是实现成本高:你需要一个真正的撮合引擎、一个订单簿、一套防刷与防自成交的风控。

摆摊是分散撮合:玩家在自己的地块或摊位上挂出商品,别人走过来才能看到、才能买。它的优点是实现极简——没有中央撮合,交易就是两个玩家之间的一次直接转账,服务器压力小。代价是它天然带位置成本:好位置(主城门口、交通要道)的摊位费天然更高,价格也不透明,同一个东西在不同摊位能卖出完全不同的价。

交易所介于两者之间,本质是挂单撮合:玩家挂出「买/卖」的限价单,进入一个共享的订单簿,系统按价格优先、时间优先撮合,允许部分成交。它比拍卖行更接近真实金融市场,有深度(同一价位可以堆积大量挂单),但也意味着你要处理部分成交、撤单、订单簿膨胀这些工程问题。

三种形态最核心的差别,其实可以浓缩成一个问题:当你手里有一批买单和一批卖单,你按什么规则决定谁和谁成交、以什么价格成交? 下面这份配置就是同一批订单,用来让三种规则跑在同一份输入上。

{
  "version": 1,
  "note": "同一批买卖订单,用三种撮合规则(先到先得/价格优先/价格时间优先)跑出成交价与成交量。字段均为整数,保证 Python 与 TypeScript 逐位一致。",
  "referencePrice": 104,
  "orders": [
    { "id": "B1", "side": "buy",  "price": 112, "qty": 50,  "seq": 6 },
    { "id": "B2", "side": "buy",  "price": 112, "qty": 40,  "seq": 1 },
    { "id": "B3", "side": "buy",  "price": 105, "qty": 80,  "seq": 3 },
    { "id": "B4", "side": "buy",  "price": 100, "qty": 120, "seq": 8 },
    { "id": "B5", "side": "buy",  "price": 110, "qty": 60,  "seq": 10 },
    { "id": "S1", "side": "sell", "price": 99,  "qty": 40,  "seq": 5 },
    { "id": "S2", "side": "sell", "price": 99,  "qty": 50,  "seq": 2 },
    { "id": "S3", "side": "sell", "price": 106, "qty": 70,  "seq": 4 },
    { "id": "S4", "side": "sell", "price": 111, "qty": 40,  "seq": 7 },
    { "id": "S5", "side": "sell", "price": 104, "qty": 90,  "seq": 9 }
  ]
}

注意这批订单里故意埋了两处同价竞争:买单 B1 和 B2 都挂 112,卖单 S1 和 S2 都挂 99。这两处会让「价格优先」和「价格时间优先」产生分歧——因为它们的同价拆解规则不同。

同一批订单,三种撮合规则

下面这段是 S 层仿真脚本,读 config/ 里的这批订单,用三种规则各撮合一遍,输出成交明细与汇总。规则定义如下:

  • 先到先得(fifo):完全按到达顺序 seq 处理,新单与对面最早的挂单撮合,价格不参与排序;
  • 价格优先(price):两侧先按价格排序(买高卖低),同价按配置数组顺序;
  • 价格时间优先(price_time):先按价格排序,同价再按到达顺序 seq。

三种规则下,成交价一律取「先到的一方」(seq 较小者)的挂价,也就是做市/挂单方的价格。

# file: sim/ch14_market_forms.py
# tier: S
# verify: python3 sim/ch14_market_forms.py --config config/ --out out/
"""市场形态:对同一批买卖订单,用三种撮合规则跑出成交价与成交量。

fifo=先到先得(完全按 seq);price=价格优先(同价按配置数组顺序);
price_time=价格时间优先(同价按 seq)。成交价一律取先到(seq 较小)一方挂价。
产出 out/ch14_market.json + out/ch14_market_trades.csv。
"""
from __future__ import annotations

import argparse, csv, json, os


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "ch14_market.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    return cfg["referencePrice"], cfg["orders"]


def tprice(b, s):
    return b["price"] if b["seq"] <= s["seq"] else s["price"]


def match_arrival(orders):
    """先到先得:按 seq 处理,新单与对面最早的挂单撮合。"""
    buys, sells, trades = [], [], []
    for o in sorted(orders, key=lambda x: x["seq"]):
        rest = o["qty"]
        if o["side"] == "buy":
            while rest > 0 and sells and o["price"] >= sells[0]["price"]:
                s = sells[0]; q = min(rest, s["qty"])
                trades.append({"buyId": o["id"], "sellId": s["id"], "price": tprice(o, s), "qty": q})
                rest -= q; s["qty"] -= q
                if s["qty"] == 0: sells.pop(0)
            if rest > 0: buys.append({**o, "qty": rest})
        else:
            while rest > 0 and buys and buys[0]["price"] >= o["price"]:
                b = buys[0]; q = min(rest, b["qty"])
                trades.append({"buyId": b["id"], "sellId": o["id"], "price": tprice(b, o), "qty": q})
                rest -= q; b["qty"] -= q
                if b["qty"] == 0: buys.pop(0)
            if rest > 0: sells.append({**o, "qty": rest})
    return trades


def match_book(orders, kb, ks):
    """挂单簿撮合:两侧按给定键排序,反复取最优买/卖对撮合到不过价。"""
    buys = sorted(({**o} for o in orders if o["side"] == "buy"), key=kb)
    sells = sorted(({**o} for o in orders if o["side"] == "sell"), key=ks)
    trades, i, j = [], 0, 0
    while i < len(buys) and j < len(sells):
        b, s = buys[i], sells[j]
        if b["price"] < s["price"]: break
        q = min(b["qty"], s["qty"])
        trades.append({"buyId": b["id"], "sellId": s["id"], "price": tprice(b, s), "qty": q})
        b["qty"] -= q; s["qty"] -= q
        if b["qty"] == 0: i += 1
        if s["qty"] == 0: j += 1
    return trades


def summarize(rule, trades):
    vol = sum(t["qty"] for t in trades)
    notional = sum(t["price"] * t["qty"] for t in trades)
    px = sorted({t["price"] for t in trades})
    return {"rule": rule, "tradeCount": len(trades), "volume": vol, "notional": notional,
            "vwap": notional // vol if vol else 0, "minPrice": px[0] if px else 0,
            "maxPrice": px[-1] if px else 0}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    ref, orders = load(a.config)
    idx = {o["id"]: k for k, o in enumerate(orders)}
    runs = {
        "fifo": match_arrival(orders),
        "price": match_book(orders, lambda o: (-o["price"], idx[o["id"]]), lambda o: (o["price"], idx[o["id"]])),
        "price_time": match_book(orders, lambda o: (-o["price"], o["seq"]), lambda o: (o["price"], o["seq"])),
    }
    report = {"version": 1, "referencePrice": ref,
              "rules": {k: summarize(k, v) for k, v in runs.items()}, "trades": runs}
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "ch14_market.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(a.out, "ch14_market_trades.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["rule", "buyId", "sellId", "price", "qty"])
        for rule, ts in runs.items():
            for t in ts: w.writerow([rule, t["buyId"], t["sellId"], t["price"], t["qty"]])
    for k in ("fifo", "price", "price_time"):
        r = report["rules"][k]
        print(f"{k:11s} trades={r['tradeCount']} volume={r['volume']} vwap={r['vwap']} range=[{r['minPrice']},{r['maxPrice']}]")


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它(同机相对量级,绝对数值不可移植):

fifo        trades=6 volume=190 vwap=106 range=[99,112]
price       trades=5 volume=180 vwap=104 range=[99,112]
price_time  trades=5 volume=180 vwap=104 range=[99,112]

三行结果的差别,正是三种形态的差别。先到先得成交了 190、均价 106,而价格优先和价格时间优先都只成交了 180、均价 104。先到先得多出来那 10 个单位,来自它把一批「价格并不最优」的订单也撮合了——因为它根本不看价格,只看谁先到。这就是摆摊式市场的典型后果:它保证「先来后到」的公平,却牺牲了价格效率。

而价格优先与价格时间优先的总量相同、均价相同,差别只在谁先成交:价格优先让数组里更靠前的 B1 先吃到卖单,价格时间优先让到达更早的 B2 先吃到。换句话说,在同一价格档位上,时间戳决定了谁被满足——这正是「价格时间优先」存在的意义。

P 层:TypeScript 侧再撮合一遍

撮合规则不该只活在 Python 里。下面这段是 P 层原型,用同一份 config/ch14_market.json 重算一遍——如果两边算出的成交量或均价不一致,说明「唯一真源」名不副实。

// file: proto/src/ch14_market.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch14_market_forms.py 同构:读同一份 config/ch14_market.json,三种撮合规则逐位一致。
// 全程整数运算,成交价取 seq 较小(先到)一方挂价。

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const HERE = dirname(fileURLToPath(import.meta.url));
const CONFIG_DIR = join(HERE, "..", "..", "config");

export interface Order { id: string; side: "buy" | "sell"; price: number; qty: number; seq: number }
export interface Trade { buyId: string; sellId: string; price: number; qty: number }
export interface MarketConfig { referencePrice: number; orders: Order[] }

export function loadMarketConfig(): MarketConfig {
  return JSON.parse(readFileSync(join(CONFIG_DIR, "ch14_market.json"), "utf-8")) as MarketConfig;
}

const tprice = (b: Order, s: Order): number => (b.seq <= s.seq ? b.price : s.price);

export function matchArrival(orders: Order[]): Trade[] {
  const buys: Order[] = [], sells: Order[] = [], trades: Trade[] = [];
  for (const o of [...orders].sort((x, y) => x.seq - y.seq)) {
    if (o.side === "buy") {
      let rest = o.qty;
      while (rest > 0 && sells.length && o.price >= sells[0].price) {
        const s = sells[0];
        const q = Math.min(rest, s.qty);
        trades.push({ buyId: o.id, sellId: s.id, price: tprice(o, s), qty: q });
        rest -= q; s.qty -= q;
        if (s.qty === 0) sells.shift();
      }
      if (rest > 0) buys.push({ ...o, qty: rest });
    } else {
      let rest = o.qty;
      while (rest > 0 && buys.length && buys[0].price >= o.price) {
        const b = buys[0];
        const q = Math.min(rest, b.qty);
        trades.push({ buyId: b.id, sellId: o.id, price: tprice(b, o), qty: q });
        rest -= q; b.qty -= q;
        if (b.qty === 0) buys.shift();
      }
      if (rest > 0) sells.push({ ...o, qty: rest });
    }
  }
  return trades;
}

export function matchBook(orders: Order[], kb: (o: Order) => number[], ks: (o: Order) => number[]): Trade[] {
  const cmp = (a: number[], b: number[]): number => {
    for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] - b[i];
    return 0;
  };
  const buys = orders.filter((o) => o.side === "buy").map((o) => ({ ...o })).sort((a, b) => cmp(kb(a), kb(b)));
  const sells = orders.filter((o) => o.side === "sell").map((o) => ({ ...o })).sort((a, b) => cmp(ks(a), ks(b)));
  const trades: Trade[] = [];
  let i = 0, j = 0;
  while (i < buys.length && j < sells.length) {
    const b = buys[i], s = sells[j];
    if (b.price < s.price) break;
    const q = Math.min(b.qty, s.qty);
    trades.push({ buyId: b.id, sellId: s.id, price: tprice(b, s), qty: q });
    b.qty -= q; s.qty -= q;
    if (b.qty === 0) i++;
    if (s.qty === 0) j++;
  }
  return trades;
}

export interface RuleSummary {
  rule: string; tradeCount: number; volume: number; notional: number; vwap: number; minPrice: number; maxPrice: number;
}

export function summarize(rule: string, trades: Trade[]): RuleSummary {
  let volume = 0, notional = 0;
  const prices = new Set<number>();
  for (const t of trades) { volume += t.qty; notional += t.price * t.qty; prices.add(t.price); }
  const sorted = [...prices].sort((a, b) => a - b);
  return { rule, tradeCount: trades.length, volume, notional, vwap: volume ? Math.floor(notional / volume) : 0,
    minPrice: sorted.length ? sorted[0] : 0, maxPrice: sorted.length ? sorted[sorted.length - 1] : 0 };
}

export function marketReport() {
  const cfg = loadMarketConfig();
  const arrIdx: Record<string, number> = {};
  cfg.orders.forEach((o, k) => { arrIdx[o.id] = k; });
  const fifo = matchArrival(cfg.orders);
  const price = matchBook(cfg.orders, (o) => [-o.price, arrIdx[o.id]], (o) => [o.price, arrIdx[o.id]]);
  const priceTime = matchBook(cfg.orders, (o) => [-o.price, o.seq], (o) => [o.price, o.seq]);
  return { referencePrice: cfg.referencePrice,
    rules: { fifo: summarize("fifo", fifo), price: summarize("price", price), price_time: summarize("price_time", priceTime) },
    trades: { fifo, price, price_time: priceTime } };
}

对应的断言写进 proto/test/ch14.test.ts,跑 node --test proto/test/ch14.test.ts,本节 5 条全过:fifo 成交量 190 / 均价 106,price 与 price_time 均为 180 / 104,且两者「成交量相同但配对顺序不同」。Python 与 TypeScript 对同一份配置算出逐位一致的结果,这就是「一份配置、两处消费」。

形态选择决定经济形态

把三种形态对经济的影响与实现代价并列,一张表就能看清取舍:

形态价格透明深度位置成本实现代价对经济的主要影响
拍卖行高(统一撮合价)中无高(撮合引擎+风控)价格收敛快,易形成全服参考价
摆摊低(一摊一价)无有(摊位费/地价)低(点对点转账)价格离散,价差大,套利空间天然存在
交易所高(订单簿公开)高无最高(部分成交/撤单/簿维护)深度最好,但订单簿膨胀需治理

一个常见的错误是「哪个先进上哪个」。真实决策应该反过来问:你希望这个经济体的价格是「收敛」还是「离散」? 想让玩家有稳定的参考价、想让系统能用一个统一价去指导产出与回收,就上拍卖行或交易所;想让价格保持离散、让地头蛇有位置优势、想少写代码,就上摆摊。形态不是技术选型,是经济设计。

本节只回答了「用哪种形态、按什么规则撮合」。但撮合完之后,价格会停在哪个水平、价差会不会收窄、流动性够不够,是下一节的问题。14.2 给定价与流动性,14.3 给套利与风控边界。

阅读导航:上一节:赛季重置与货币回收 · 下一节:定价机制与流动性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引