Building a Prediction Market From Scratch (5): Positions — the Other Half of the Account
Balance tracked a user’s cash. But cash is only half of what a prediction-market account holds — the other half is shares: how much YES (or NO) you’re holding. That half is Position, and it’s what we build next.
It matters because you can’t even describe a seller without it. A buy order locks cash; a sell order locks shares. With only Balance, the engine could model buyers and nothing else — no sellers, no trades, no payouts. Position is the seller’s side, and the thing resolution pays out on.
Balance 管的是用户的现金。但现金只是预测市场账户的一半,另一半是份额:你手里攥着多少 YES(或 NO)。这一半就是 Position,也是我们接着要做的。
它要紧,是因为没有它你连"卖方"都描述不了。买单锁的是现金,卖单锁的是份额。只有 Balance,引擎就只能表示买方——没有卖方,没有成交,没有派彩。Position 是卖方那一边,也是到期派彩的依据。
It’s Balance’s twin
Position has the same shape as Balance, just counting shares instead of cents: available shares you can sell or lock into an ask, frozen shares already reserved by a resting ask. Same private fields, same conserving rule, same checked arithmetic.
#[derive(Debug, Clone)]
pub struct Position {
available: u64, // can be sold or locked into an ask
frozen: u64, // reserved by a resting ask
}lock / unlock / spend_frozen are line-for-line what Balance had. The one new verb is add — a buyer’s shares appear out of a fill:
pub fn add(&mut self, qty: u64) -> Result<(), PositionError> {
self.available = checked_math::checked_add(self.available, qty)?;
Ok(())
}Building it right after Balance is almost “now do the same thing for shares” — which is the point. The money-safety discipline (private fields, conserve the total, refuse to go negative, never bare arithmetic) gets applied a second time while it’s fresh. Same shape, second proof.
它是 Balance 的双胞胎
Position 和 Balance 一个形状,只是数的是份额不是分:available 是能卖、能锁进卖单的份额,frozen 是已经被挂着的卖单占住的份额。一样的私有字段,一样的守恒规则,一样的受检算术。
#[derive(Debug, Clone)]
pub struct Position {
available: u64, // 能卖,或锁进卖单
frozen: u64, // 被挂着的卖单占住
}lock / unlock / spend_frozen和 Balance 那几个一行不差。唯一新增的动词是 add——买方的份额是从一笔成交里冒出来的:
pub fn add(&mut self, qty: u64) -> Result<(), PositionError> {
self.available = checked_math::checked_add(self.available, qty)?;
Ok(())
}紧跟着 Balance 做它,几乎就是"对份额再来一遍"——这正是用意。那套钱的安全纪律(私有字段、守住总额、拒绝变负、不用裸算术)趁手感还热又焊了一遍。同样的形状,第二次证明。
How it all connects
Balance and Position are deliberately “dumb” — they don’t know who owns them or which market they’re in. The wiring lives one level up:
它们怎么串起来
Balance 和 Position 是故意"哑"的——它们不认识自己属于谁、在哪个市场。接线在上面一层:
The keys are the whole story. Cash is kept per user — one pool, since money is fungible across markets. Shares are kept per (user, market) — you hold a separate Position in each market you’ve traded. An Order carries both a user_id and a market_id, and that’s how the engine finds the right buckets:
- place a bid →
balance.lock(qty × price)— reserve cash; - place an ask →
positions[market_id].lock(qty)— reserve shares; - on a fill → buyer’s
balance.spend_frozen(...)+positions[market].add(qty); seller’spositions[market].spend_frozen(qty)+balance.credit(...).
The Order is the verb; Balance and Position are the nouns it moves; user_id and market_id are the keys that say whose and which. Keeping the value types context-free is what lets the engine organize accounts and books however it needs — and what made them trivial to unit-test in isolation.
键就是关键。现金按用户存——一个池子,因为钱在各市场之间是通用的。份额按 (user, market) 存——你在每个交易过的市场里各有一份 Position。一个 Order 同时带着 user_id 和 market_id,引擎就靠这两把钥匙找到对应的桶:
- 下买单 →
balance.lock(qty × price)—— 锁现金; - 下卖单 →
positions[market_id].lock(qty)—— 锁份额; - 成交时 → 买方
balance.spend_frozen(...)+positions[market].add(qty);卖方positions[market].spend_frozen(qty)+balance.credit(...)。
Order 是动词,Balance 和 Position 是它搬动的名词,user_id 和 market_id 是"谁的、哪个"的钥匙。把这些值类型做成不带上下文,引擎才能想怎么组织账户和簿就怎么组织——也正因如此,它们单测起来不要太省心。
You might expect a position to carry your average cost, your PnL, the time you bought. It carries none of that — on purpose. The engine only needs quantity: lock shares on an ask, transfer them on a fill, pay out on resolution. And payout in a prediction market depends only on how many shares you hold and which side won — never on what you paid. Cost basis, entry time, and PnL are reporting, and their real source is the trade log: every fill emits an event with price, quantity, and timestamp, and from that you can compute average cost, FIFO, realized/unrealized PnL — any way you like. Baking it into Position would pin one accounting method, duplicate the trade log, fatten the hot path, and (timestamps) drag a clock into an engine that must stay deterministic. Keep the position lean; derive the rest downstream.
你可能以为持仓会带上你的均价、盈亏、买入时间。它一概不带——故意的。引擎只需要数量:卖单时锁份额、成交时转移、到期时派彩。而预测市场的派彩只看你持有几份、哪一边赢了,从不看你当初花了多少。成本价、买入时间、盈亏都是报表,它们真正的源头是成交流水:每笔成交都发一个带价格、数量、时间戳的事件,从这条流水里你想算均价、FIFO、已实现/未实现盈亏,怎么算都行。把它塞进 Position 会绑死一种记账法、和成交流水重复、让热路径变重,还(时间戳)把时钟拽进一个必须保持确定性的引擎里。持仓保持精瘦,其余的在下游派生。
The vocabulary it rode in with
Position shipped alongside two smaller things the coming code needs. Typed IDs — OrderId, MarketId, UserId, EventId are each a u64 newtype, not a bare number, so the compiler refuses to let you pass a UserId where an OrderId belongs. And the label enums — OrderSide (Bid/Ask), OrderType (Limit/Market), MarketSide (Yes/No), plus the status and result labels — the fixed words the engine speaks in.
One deliberate omission: no serde, no rkyv yet. The crate still depends on nothing but thiserror. Serialization derives arrive in the increment that first writes a command to the log — added when we actually need them, not before.
$ cargo test -p zhulong-types
test result: ok. 26 passed; 0 failedTwenty-six green. Next we compose these into Account (one balance, a map of positions) and Order, and the shapes start to act like a market. Every line is in the open repo, free, no strings.
它一起带来的"词汇"
Position 是和两样更小的东西一起来的,后面的代码要用。类型化 ID——OrderId、MarketId、UserId、EventId 各是一个 u64 newtype,不是裸数字,于是编译器不让你把 UserId 传到该传 OrderId 的地方。还有标签枚举——OrderSide(Bid/Ask)、OrderType(Limit/Market)、MarketSide(Yes/No),加上状态和结果这些标签——引擎说话用的固定词。
一个刻意的"没做":还没有 serde,没有 rkyv。这个 crate 至今只依赖 thiserror。序列化的派生留到"第一次把命令写进日志"那一增量再加——真要用了才加,不提前。
$ cargo test -p zhulong-types
test result: ok. 26 passed; 0 failed26 个全绿。下一步把它们组合成 Account(一份余额、一张持仓表)和 Order,这些形状就开始像个市场了。每一行都在公开仓库里,免费,没有附加条件。