Building a Prediction Market From Scratch (4): The Account Model — a Balance That Can't Lose Money
The repo’s empty. Where do you start?
You’d probably guess the order book, or the matching engine. Neither. I start with the account — a little type called Balance that just holds one person’s money. It looks humble, but it earns the first slot, and here’s why.
好,仓库还是空的,从哪儿写起?
你大概会猜订单簿,或者撮合引擎。都不是。我先写账户——一个就管一个人那点钱的小类型,叫 Balance。看着不起眼,可让它打头阵,是有道理的。
Why the account comes first
Three reasons, and they stack up.
First, it’s the foundation. Matching, settlement, every order you’ll ever place — all of it is just moving money around. If the engine is hazy about “how much does this person have, and how much is already tied up in open orders,” every layer on top inherits that haze. And money bugs are the ones that cost real money.
Second, it depends on nothing. You build from the ground up, and if you keep asking “what does this need first?” — the order book needs orders, orders need their types, all of it eventually needs an account — you end up right here, at the bottom. Start here and everything above stands on tested ground.
Third, it’s small enough to nail completely. Two numbers and a handful of methods. You can write a test for every way it might go wrong and know it’s solid before you lean on it.
为什么账户排第一
三个理由,一层叠一层。
一是它是地基。撮合也好、结算也好、你下的每一单也好,说穿了都是在挪钱。引擎要是对"这个人有多少钱、其中多少已经压在挂单里"含含糊糊,上面盖的每一层都跟着糊。而钱上的 bug,是真要赔钱的。
二是它谁都不依赖。系统从地基往上盖,你一路追问"这个得先有啥"——订单簿要订单,订单要那些类型,到头来全都要账户——追到底,就是它。从这儿起步,上面每一层都踩在测过的地面上。
三是它小到能彻底搞定。两个数,加几个方法。它可能出错的每一种情形,你都能写个测试盯住,靠它之前就确知它稳。
What a balance really is
A person’s money isn’t one number, it’s two. There’s what they can spend right now — call it available. And there’s what’s tied up in their resting buy orders — call it frozen.
Say you bid for 100 shares at 60¢. The market has to set that 6,000¢ aside the instant you place the order — otherwise the order sits there waiting and you could turn around and spend the same money again. Cancel the order, and it comes straight back.
So the type really only does four things. A deposit adds to available. Placing an order moves money from available into frozen. Cancelling moves it back. And when an order actually fills, the frozen money leaves the account for good. Through all of that, one number must never move on its own: the total, available plus frozen. Locking and unlocking just slide money between the two buckets — the sum doesn’t budge. Guarding that sum is the whole job.
余额到底是什么
一个人的钱不是一个数,是两个。一个是此刻能花的——叫它可用;一个是压在挂单里的——叫它冻结。
比方你挂单 60 分买 100 份。你一下单,市场就得立刻把这 6000 分扣出来留着——不然单子还在那儿等着,你回头又能把这笔钱再花一遍。单子一撤,钱原样回来。
所以这个类型其实就干四件事。充值,可用加一笔。挂单,把钱从可用挪进冻结。撤单,再挪回来。等单子真成交了,冻结的那笔就彻底离开账户。这一通折腾里,有一个数绝不能自己动:总额,也就是可用加冻结。锁定、解锁,只是把钱在两个桶之间挪来挪去,这个和一分不动。看住这个和,就是它的全部活儿。
Making the bug impossible, not just unlikely
Here’s the part that actually keeps the money safe. Those two numbers are private — nothing outside the type can reach in and change them. The only way in is through a few methods, and every one of them is written to keep the total honest and to refuse to drop below zero.
So “oops, we just created money out of nowhere” isn’t a bug we’re carefully trying to dodge. It’s a sentence the type won’t let you write. Everything above — matching, settlement, all of it — can only move money through these methods, so it can’t conjure or lose a single cent.
The type itself is almost nothing:
#[derive(Debug, Clone)]
pub struct Balance {
avail: u64, // spendable
order_frozen: u64, // reserved by resting orders
}Two unsigned integers. Unsigned is a deliberate little safety net: a balance can’t go negative, and u64 makes “negative” impossible to even write down — the compiler won’t hear of it. Money’s counted in whole cents too, never floating-point, so there’s no rounding dust drifting around.
让 bug 不可能,而不只是不太可能
真正护住钱的是下面这步。那两个数是私有的——类型外头谁都伸不进手去改。进去的唯一通道是几个方法,而每个都写成:总额得守住,绝不许掉到零以下。
于是"哎呀,我们凭空造了笔钱出来"就不是一个要小心绕开的 bug,而是这个类型压根不让你写出来的一句话。上面的一切——撮合、结算,全部——只能通过这几个方法动钱,所以它连一分钱都变不出来,也丢不掉。
类型本身几乎没东西:
#[derive(Debug, Clone)]
pub struct Balance {
avail: u64, // 可用
order_frozen: u64, // 被挂单占住
}两个无符号整数。无符号是个故意留的小保险:余额不可能为负,而 u64 让"负"这个念头连写都写不出来——编译器根本不听。钱也按整数分算,从不用浮点,省得有四舍五入的渣到处飘。
Locking, and why the math is “checked”
lock fires every time someone places a buy order — it slides money from available to frozen, and backs out cleanly if there isn’t enough.
pub fn lock(&mut self, amount: u64) -> Result<(), BalanceError> {
self.avail = checked_math::checked_sub(self.avail, amount)?;
self.order_frozen = checked_math::checked_add(self.order_frozen, amount)?;
Ok(())
}Notice there’s no plain - or + in there. It uses checked subtraction and addition — math that hands back an error instead of quietly wrapping around. Try to lock 200 when you’ve only got 100, and checked_sub doesn’t silently spit out some enormous number the way unsigned subtraction underflows; it returns an error, the method stops, and the balance is left exactly as it was. We take this seriously enough that the project’s lint settings flat-out ban bare + and - on these values — raw arithmetic isn’t allowed anywhere near the money. The helper behind it is four lines:
pub fn checked_sub(current: u64, amount: u64) -> Result<u64, MathError> {
current.checked_sub(amount).ok_or(MathError::Insufficient {
needed: amount,
available: current,
})
}unlock is the same thing in reverse. credit and debit handle deposits and withdrawals. spend_frozen takes the money out when an order fills. Three checked lines each — nothing clever.
锁定,以及为什么算术要"受检"
lock 每次有人挂买单都会触发——把钱从可用滑进冻结,钱不够就干干净净退出来。
pub fn lock(&mut self, amount: u64) -> Result<(), BalanceError> {
self.avail = checked_math::checked_sub(self.avail, amount)?;
self.order_frozen = checked_math::checked_add(self.order_frozen, amount)?;
Ok(())
}注意里头没有普通的 - 和 +,用的是受检的加减——算不动的时候返回一个错误,而不是闷头绕回去。你只有 100,却想锁 200,checked_sub 不会像无符号减法下溢那样悄悄吐出一个天文数字,而是返回错误,方法停下,余额一动没动。这事我们较真到什么程度?项目的 lint 直接禁掉对这些值用裸的 + 和 -——裸算术别想靠近钱半步。背后那个小函数就四行:
pub fn checked_sub(current: u64, amount: u64) -> Result<u64, MathError> {
current.checked_sub(amount).ok_or(MathError::Insufficient {
needed: amount,
available: current,
})
}unlock 就是反着来。credit 和 debit 管充值和提现。spend_frozen 在成交时把钱扣走。各自三行受检代码,没什么花活。
The tests are the spec
Test-first means the tests came before the type — the type exists to make them pass. Read them and they’re basically a list of promises the money keeps. Two of them:
#[test]
fn lock_and_unlock_conserves_total() {
let mut b = Balance::new(100);
b.lock(60).expect("lock");
assert_eq!((b.avail(), b.frozen(), b.total()), (40, 60, 100));
b.unlock(60).expect("unlock");
assert_eq!((b.avail(), b.frozen(), b.total()), (100, 0, 100));
}
#[test]
fn lock_insufficient_leaves_balance_unchanged() {
let mut b = Balance::new(100);
assert!(b.lock(200).is_err());
assert_eq!(b.avail(), 100); // untouched
}The first says: lock some money, unlock it, and the total lands exactly where it started. The second says: ask to lock more than you have, and it’s refused — and, crucially, nothing changes, no half-done state left behind. Run them:
$ cargo test -p zhulong-types
test result: ok. 14 passed; 0 failedFourteen green, before a single thing is built on top.
And quietly, this one little type has set the house rules for everything that follows: money is unsigned integer cents, anything with rules hides its insides behind checked methods, and the test gets written first. Next up: the IDs, the enums, then Position — same idea as Balance, but for shares instead of cash. One brick at a time, every line out in the open.
测试就是规格
测试先行,意思是测试先于类型存在——类型是为了让它们通过才写的。读下来,它们基本就是一串"钱会守住的承诺"。挑两条:
#[test]
fn lock_and_unlock_conserves_total() {
let mut b = Balance::new(100);
b.lock(60).expect("lock");
assert_eq!((b.avail(), b.frozen(), b.total()), (40, 60, 100));
b.unlock(60).expect("unlock");
assert_eq!((b.avail(), b.frozen(), b.total()), (100, 0, 100));
}
#[test]
fn lock_insufficient_leaves_balance_unchanged() {
let mut b = Balance::new(100);
assert!(b.lock(200).is_err());
assert_eq!(b.avail(), 100); // 原封不动
}第一条说:锁一笔、再解开,总额分毫不差地回到原点。第二条说:你想锁的比手里有的还多,直接被拒——而且要紧的是,什么都没变,不留半拉子状态。跑一下:
$ cargo test -p zhulong-types
test result: ok. 14 passed; 0 failed14 个全绿,在任何东西压上来之前。
而且不声不响地,这个小类型已经把后面所有事的规矩立好了:钱按无符号整数分算,但凡有规矩的类型都把内部藏在受检方法背后,测试永远先写。下一章:ID、枚举,然后是 Position——和 Balance 一个路子,只不过管的是份额,不是现金。一块砖一块砖来,每一行都摊在明面上。