16.3 价格索引与经济风控
前两节盯着的是一笔交易:物品怎么冻、金币怎么冻、出价怎么回滚。这一节把镜头拉远,盯整服的成交流水。
流水本身没有意义。一万条「某玩家以 1200 金币买了某材料」堆在一起,既回答不了「这个材料现在大概值多少钱」,也发现不了「有人在用两个号互相倒货」。要回答这些,得把流水压成指数:一个物品在一个时间窗口里的代表价,以及围绕这个代表价的偏离程度。有了指数,风控才有参照系——偏离指数三倍以上的成交,几乎一定是刷钱、洗钱或价格操纵。
这件事的难点有两层。第一层是选什么当代表价:均价、中位价、成交量加权均价各有取舍,选错一个就会让单笔异常把整条曲线带歪。第二层是窗口怎么滑:只增不减的统计量迟早会失真,比如「同一对买卖双方成交了 100 次」如果永远不清零,一个正常的长期交易伙伴也会被误判成对敲。本节用 internal/trade 的 PriceIndex 从零实现这两层。全部代码是 L1,go test 可以直接跑。
为什么中位价比均价更稳
先做取舍。假设某材料最近 9 笔成交都是 1000 金币,第 10 笔有人用 999999 金币刷了一单——这笔钱可能来自两个小号之间的转账,也可能只是想给排行榜注水。
用均价,这 10 笔的平均值是 (9×1000 + 999999)/10 ≈ 100900,整条价格曲线瞬间被抬高两个数量级,所有后续正常成交都会「偏离」这个假基准。用中位价,10 个数的中位数仍是 1000,那笔异常被完全吸收。这就是为什么本节的代表价以中位数为准。
但中位数也有盲点:它对「量」不敏感。一笔 9 个的成交和一笔 1 个的成交在中位数里权重相同。所以再补一个 VWAP(成交量加权均价):大额成交对它的影响更大,专门用来回答「这笔大单本身是否异常」。中位数做日常参考,VWAP 做单笔大额的风控基准,两者各司其职。
滑动窗口:中位价、VWAP 与对敲计数
实现上,每个物品维护最近 window 笔成交,新记录进来时把最旧的一笔挤出去。关键细节是:被挤出去的那笔,必须同时从「同一对买卖双方」的计数里扣掉。否则对敲计数只增不减,一个长期合作的交易伙伴也会被误报成对敲团伙。下面这段是 L1,go test ./internal/trade/ 可以直接跑:
// file: internal/trade/index.go
// tier: L1
// verify: go test ./internal/trade/
package trade
import (
"sort"
"sync"
)
// TradeRecord 是一笔已成交记录:拍卖成交与玩家面对面交易都会落成它。
type TradeRecord struct {
ItemID ItemID
Price int64
Count int64
Buyer PlayerID
Seller PlayerID
At int64
}
// PriceIndex 按物品维护最近 window 笔成交,产出中位价与成交量加权均价(VWAP)。
// 用中位数而非均价做主参考,是因为单笔异常高价对中位数几乎没有影响。
type PriceIndex struct {
mu sync.Mutex
window int
pairThreshold int
byItem map[ItemID][]TradeRecord
pairs map[pairKey]int // 窗口内同一对买卖双方的成交次数
}
type pairKey struct {
Buyer, Seller PlayerID
}
func NewPriceIndex(window int) *PriceIndex {
if window <= 0 {
window = 200
}
return &PriceIndex{
window: window,
pairThreshold: 5,
byItem: make(map[ItemID][]TradeRecord),
pairs: make(map[pairKey]int),
}
}
// Add 追加一笔成交,并滑出窗口最旧的一笔;被滑出的那笔同时从对敲计数里扣掉,
// 否则「高频对敲」会因计数只增不减而永远误报。
func (p *PriceIndex) Add(r TradeRecord) {
p.mu.Lock()
defer p.mu.Unlock()
rs := append(p.byItem[r.ItemID], r)
if len(rs) > p.window {
drop := rs[0]
rs = rs[1:]
if k := (pairKey{drop.Buyer, drop.Seller}); p.pairs[k] > 0 {
p.pairs[k]--
}
}
p.byItem[r.ItemID] = rs
p.pairs[pairKey{r.Buyer, r.Seller}]++
}
// Median 返回窗口内的中位价;窗口为空时 ok=false,调用方须自行决定兜底价。
func (p *PriceIndex) Median(item ItemID) (int64, bool) {
p.mu.Lock()
defer p.mu.Unlock()
rs := p.byItem[item]
if len(rs) == 0 {
return 0, false
}
prices := make([]int64, len(rs))
for i, r := range rs {
prices[i] = r.Price
}
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
return prices[len(prices)/2], true
}
// VWAP 是成交量加权均价:大额成交对它的影响更大,适合做「大单是否异常」的基准。
func (p *PriceIndex) VWAP(item ItemID) (int64, bool) {
p.mu.Lock()
defer p.mu.Unlock()
return p.vwapLocked(item)
}
func (p *PriceIndex) vwapLocked(item ItemID) (int64, bool) {
rs := p.byItem[item]
var sumPV, sumV int64
for _, r := range rs {
sumPV += r.Price * r.Count
sumV += r.Count
}
if sumV == 0 {
return 0, false
}
return sumPV / sumV, true
}
type RiskLevel uint8
const (
RiskNone RiskLevel = iota
RiskWatch
RiskBlock
)
// 偏离阈值的单位是百分比:超过 watchPct 进观察,超过 blockPct 直接拦截。
const (
watchPct = 150
blockPct = 300
)
// Assess 对一笔拟成交记录打分。它是经济风控的第一道闸:自买自卖与严重偏离
// 直接拦;同一对手方高频对敲与轻度偏离先记观察,交人工复核。
func (p *PriceIndex) Assess(r TradeRecord) (RiskLevel, string) {
if r.Buyer == r.Seller {
return RiskBlock, "自买自卖:买卖双方为同一角色"
}
p.mu.Lock()
defer p.mu.Unlock()
if p.pairs[pairKey{r.Buyer, r.Seller}] >= p.pairThreshold {
return RiskWatch, "同一对手方高频对敲"
}
ref, ok := p.vwapLocked(r.ItemID)
if !ok {
return RiskNone, ""
}
dev := r.Price - ref
if dev < 0 {
dev = -dev
}
switch {
case dev*100 > ref*blockPct:
return RiskBlock, "价格严重偏离指数(>300%)"
case dev*100 > ref*watchPct:
return RiskWatch, "价格偏离指数(>150%)"
}
return RiskNone, ""
}
Add 里那句 p.pairs[k]-- 是整段代码里最容易被漏掉的一行。漏掉它,测试里「刷 5 笔同对手 → 记观察」会永远成立,哪怕这 5 笔早就滑出了窗口——风控从此只会误报、不会收敛。Median 与 VWAP 都在同一把锁下读,保证并发写入时不会读到半更新的窗口。
风控打分:自买自卖、对敲与偏离
Assess 是三级打分,从最确定的规则开始:
- 自买自卖(
Buyer == Seller)直接RiskBlock。同一角色左手倒右手,除了刷成就与洗钱没有别的解释,不需要看指数。 - 高频对敲(同一对手方在窗口内成交 ≥
pairThreshold次)记RiskWatch。单次看都是正常成交,累积起来才是信号,所以交给人工复核而不是自动拦。 - 价格偏离:以 VWAP 为基准,偏离超过 150% 记观察,超过 300% 直接拦。用 VWAP 而非中位数,是因为偏离通常发生在「大单」上,而 VWAP 对大单更敏感。
下面是 internal/trade/index_test.go 里的两个用例,同属一个已跑通的 L1 包:
// snippet: internal/trade/index_test.go(摘录)
func TestMedianResistsSingleOutlier(t *testing.T) {
p := NewPriceIndex(100)
for i := 0; i < 9; i++ {
p.Add(TradeRecord{ItemID: 3001, Price: 1000, Count: 1, Buyer: PlayerID(i + 1), Seller: 900})
}
p.Add(TradeRecord{ItemID: 3001, Price: 999_999, Count: 1, Buyer: 77, Seller: 900})
med, ok := p.Median(3001)
if !ok || med != 1000 {
t.Fatalf("中位价 = %d ok=%v,期望 1000(不受 999999 影响)", med, ok)
}
}
func TestAssessBlocksSelfTradeAndExtremeDeviation(t *testing.T) {
p := NewPriceIndex(100)
for i := 0; i < 20; i++ {
p.Add(TradeRecord{ItemID: 3001, Price: 1000, Count: 1, Buyer: PlayerID(i + 1), Seller: 900})
}
if lvl, reason := p.Assess(TradeRecord{ItemID: 3001, Price: 1000, Buyer: 5, Seller: 5}); lvl != RiskBlock {
t.Fatalf("自买自卖 = %d (%s),期望 RiskBlock", lvl, reason)
}
if lvl, _ := p.Assess(TradeRecord{ItemID: 3001, Price: 3000, Count: 1, Buyer: 55, Seller: 900}); lvl != RiskWatch {
t.Fatalf("偏离 200%% = %d,期望 RiskWatch", lvl)
}
if lvl, _ := p.Assess(TradeRecord{ItemID: 3001, Price: 9999, Count: 1, Buyer: 55, Seller: 900}); lvl != RiskBlock {
t.Fatalf("偏离 900%% = %d,期望 RiskBlock", lvl)
}
}
第一个用例证明单笔 999999 的异常成交不会污染中位价;第二个用例把三级打分从 RiskNone(正常 1000)到 RiskWatch(3000,偏离 200%)再到 RiskBlock(9999,偏离约 900%)全走一遍。两个阈值不是拍脑袋:150% 允许正常的行情波动与稀有度溢价,300% 之上基本只剩刷钱一种解释。
从单笔到全服:金币水槽与人工复核
把视角再抬高一格。Assess 只处理单笔,整服经济还要看两个总量:
- 金币水槽:16.2 的 5% 成交税、修理费、传送费都在回收金币,而打怪、任务在投放金币。两者的净差就是通胀率。指数本身不解决通胀,但它是通胀的报警器——当所有材料的中位价在一周内齐涨,通常是金币投放超了。
- 人工复核队列:
RiskWatch不是自动拒绝,而是入队。真正的洗钱往往用大量「看着正常」的小额成交把黑钱洗白,单笔都过不了RiskBlock的线,但累积的对敲计数会把它们标出来。风控的产出不是「抓了谁」,而是「给运营一份按风险分排序的复核清单」。
这套东西的边界要说清楚:PriceIndex 是内存实现,重启即失。生产上它会接一个持久化的成交流水,把窗口预热回来;但判定逻辑与这里逐字相同。本节只做单服内的价格索引,跨服行情与全服经济大屏不在本书范围。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑:
$ go test ./internal/trade/
ok aegis/internal/trade 0.587s (15 个 test 全绿)
$ go test -race ./internal/trade/
ok aegis/internal/trade (无 data race)
价格索引的 5 个用例覆盖了「中位价抗单笔异常」「VWAP 按量加权」「窗口滑动后对敲计数衰减」「自买自卖与三级偏离打分」「并发读写无竞争」。以上是同机相对量级,绝对数值不可移植。到这里第 16 章收尾:从一笔托管,到并发竞价,再到全服价格与风控,交易域的三块地基都跑通了。下一章转向匹配与房间,看另一类「把玩家凑到一起」的玩法系统。
阅读导航:上一节:16.2 竞价并发与异常回滚 · 下一节:17.1 匹配算法与公平性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。