source: docs/P7-AdvancedDatabaseDevelopment/wiki/AdvancedDatabaseDevelopment.md

main
Last change on this file was ef1c1c7, checked in by Stefan <trsunovstefan@…>, 6 days ago

Wiki docs, phase 6 and phase 7 added

  • Property mode set to 100644
File size: 73.5 KB
Line 
1 = Advanced Database Development =
2
3 !EduBerza's users trade virtual money and virtual crypto with each other and with a simulated
4 market. Up to P6 the prototype only knew market orders that filled immediately against the
5 latest price, so an order was either untouched or completely done. This phase adds what makes an
6 exchange consistent once orders can '''wait in an order book, fill in parts, and trade with each other''', and puts every rule that keeps orders, trades and balances in agreement into the
7 database itself — so it holds no matter who writes the data (the CLI, the market bot, a script,
8 or someone typing SQL in DBeaver).
9
10 Only rules that span several rows or several tables are listed. `NOT NULL`, `UNIQUE`, `CHECK`,
11 primary and foreign keys are P2 ([wiki:RelationalDesign])
12 and are not presented as P7 features.
13
14 All of it is in `server/db/advanced_db.sql`, run by `-init`
15 between `schema_creation.sql` and `data_load.sql`. Every rule is exercised by
16 `server/db/advanced_db_tests.sql` (see
17 Tests).
18
19 == Overview ==
20
21 ||= # =||= Requirement =||= Triggers =||= Procedures / functions =||= Views =||= Tables affected =||
22 || 1 || Order lifecycle and filled/remaining consistency || `orders_lifecycle` || || || `orders` ||
23 || 2 || Trade consistency || `market_trades_validate`, `market_trades_fill`, `market_trades_immutable` || `execute_trade` || || `market_trades`, `orders`, `users`, `holdings`, `transactions` ||
24 || 3 || Balance and reservation consistency || `reserved_cash_matches_orders`, `reserved_crypto_matches_orders`, `cash_matches_ledger` (deferred) || `order_reservation` || || `users`, `holdings`, `orders`, `transactions` ||
25 || 4 || Placing and cancelling orders || || `place_order`, `match_order`, `cancel_order` || || all of the above ||
26 || 5 || Automatic recording of order events || `orders_events` || || || `order_events` (new) ||
27 || 6 || Views for derived trading data || || || `v_order_book`, `v_active_orders`, `v_order_history`, `v_trader_balances` || read-only ||
28 || 7 || Background job: filling resting limit orders || || `fill_marketable_orders` || || `orders`, `market_trades`, … ||
29
30 === Schema additions ===
31
32 The existing design had no place for three things the requirements need, so four columns were
33 added — nothing else in the design changed:
34
35 ||= Column =||= Why it is needed =||
36 || `orders.filled_quantity` (+ status value `partially_filled`) || "filled / remaining quantity" cannot be kept consistent without storing how much was filled; remaining = `quantity − filled_quantity` ||
37 || `users.reserved_balance` || cash committed to open buy orders must be set aside somewhere; `holdings.reserved_quantity` already did this for crypto on sell orders ||
38 || `market_trades.buy_order_id`, `market_trades.sell_order_id` || "a trade can only happen between compatible orders" needs the trade to say which orders it filled. `NULL` on a side means the simulated market was the counterparty; the bot's price ticks have both `NULL` ||
39
40 One new table, `order_events`, holds the automatically recorded events (requirement 5).
41
42 == Order lifecycle and filled/remaining consistency ==
43
44 === Data requirements description ===
45
46 '''Business rule.'''
47
48 * An order's status follows from how much of it has been filled: nothing filled → `open`, partly filled → `partially_filled`, completely filled → `executed`. The only status that is set explicitly is `cancelled`, and only on an order that is still active.
49 * `executed` and `cancelled` are final — such an order can never be processed again (no further fills, no cancellation, no changes).
50 * `filled_quantity` only grows, never exceeds `quantity`, and only changes when a trade fills the order.
51 * What was ordered — user, market, side, type, quantity, price, time of placement — never changes after placement.
52 * `executed_at` is set exactly when the order becomes `executed`.
53 * New orders are only accepted on active markets, need a positive price, and start unfilled (the one exception is importing an order that was completely executed in the past, used by the sample data).
54
55 '''Why it is non-trivial.''' The rule compares the ''old'' and the ''new'' version of a row (valid
56 transitions, "final" states, "only grows", immutable columns) and ties two columns together
57 (status ↔ filled quantity). A `CHECK` constraint only sees one version of one row. It also
58 depends on ''who'' changes `filled_quantity` — a trade may, a manual `UPDATE` may not.
59
60 '''PostgreSQL feature.''' `BEFORE INSERT OR UPDATE` row trigger on `orders`. The trade trigger
61 (requirement 2) sets a transaction-local setting (`set_config('eduberza.trade_fill', 'on', true)`)
62 while it fills an order; the lifecycle trigger only accepts a change of `filled_quantity` when
63 that setting is on.
64
65 '''Tables affected.''' `orders` (reads `markets` for the active check).
66
67 === Implementation ===
68
69 ==== Triggers ====
70
71 {{{
72 CREATE OR REPLACE FUNCTION project.trg_orders_lifecycle()
73 RETURNS trigger LANGUAGE plpgsql AS $$
74 DECLARE
75 v_derived varchar(20);
76 BEGIN
77 IF TG_OP = 'UPDATE' THEN
78 IF OLD.status IN ('executed', 'cancelled') THEN
79 RAISE EXCEPTION 'order % is already % and cannot be processed again', OLD.id, OLD.status
80 USING ERRCODE = 'check_violation';
81 END IF;
82 IF (NEW.user_id, NEW.market_id, NEW.side, NEW.type, NEW.quantity, NEW.price, NEW.placed_at)
83 IS DISTINCT FROM
84 (OLD.user_id, OLD.market_id, OLD.side, OLD.type, OLD.quantity, OLD.price, OLD.placed_at) THEN
85 RAISE EXCEPTION 'user, market, side, type, quantity, price and placed_at of an order cannot change'
86 USING ERRCODE = 'check_violation';
87 END IF;
88 IF NEW.filled_quantity <> OLD.filled_quantity THEN
89 IF current_setting('eduberza.trade_fill', true) IS DISTINCT FROM 'on' THEN
90 RAISE EXCEPTION 'filled quantity of order % can only change through a trade', OLD.id
91 USING ERRCODE = 'check_violation';
92 END IF;
93 IF NEW.filled_quantity < OLD.filled_quantity THEN
94 RAISE EXCEPTION 'filled quantity of order % cannot decrease', OLD.id
95 USING ERRCODE = 'check_violation';
96 END IF;
97 END IF;
98 IF NEW.status = 'cancelled' AND OLD.status <> 'cancelled' THEN
99 IF NEW.filled_quantity <> OLD.filled_quantity THEN
100 RAISE EXCEPTION 'an order cannot be filled and cancelled in the same step'
101 USING ERRCODE = 'check_violation';
102 END IF;
103 NEW.executed_at := NULL;
104 RETURN NEW;
105 END IF;
106 ELSE
107 IF NOT EXISTS (SELECT 1 FROM project.markets WHERE id = NEW.market_id AND is_active) THEN
108 RAISE EXCEPTION 'market % is not active; no new orders accepted', NEW.market_id
109 USING ERRCODE = 'check_violation';
110 END IF;
111 IF NEW.price IS NULL OR NEW.price <= 0 THEN
112 RAISE EXCEPTION 'an order needs a positive price (limit price, or the market price for a market order)'
113 USING ERRCODE = 'check_violation';
114 END IF;
115 IF NEW.status = 'cancelled' THEN
116 RAISE EXCEPTION 'an order cannot be created already cancelled'
117 USING ERRCODE = 'check_violation';
118 END IF;
119 -- A new order starts unfilled. The only exception is importing an
120 -- order that was completely executed in the past (sample data).
121 IF NEW.filled_quantity NOT IN (0, NEW.quantity) THEN
122 RAISE EXCEPTION 'a new order is either unfilled or (imported history) completely filled'
123 USING ERRCODE = 'check_violation';
124 END IF;
125 END IF;
126
127 v_derived := CASE
128 WHEN NEW.filled_quantity = 0 THEN 'open'
129 WHEN NEW.filled_quantity < NEW.quantity THEN 'partially_filled'
130 ELSE 'executed'
131 END;
132 IF NEW.status IS DISTINCT FROM v_derived
133 AND (TG_OP = 'INSERT' OR NEW.status IS DISTINCT FROM OLD.status) THEN
134 RAISE EXCEPTION 'order status % does not match filled quantity % of %; status is derived automatically',
135 NEW.status, NEW.filled_quantity, NEW.quantity
136 USING ERRCODE = 'check_violation';
137 END IF;
138 NEW.status := v_derived;
139
140 IF v_derived = 'executed' THEN
141 NEW.executed_at := COALESCE(NEW.executed_at, now());
142 ELSE
143 NEW.executed_at := NULL;
144 END IF;
145 RETURN NEW;
146 END $$;
147
148 CREATE TRIGGER orders_lifecycle
149 BEFORE INSERT OR UPDATE ON project.orders
150 FOR EACH ROW EXECUTE FUNCTION project.trg_orders_lifecycle();
151 }}}
152
153 Because the status is derived, the application never sets `open`, `partially_filled` or
154 `executed` itself — it records trades, and the status follows.
155
156 == Trade consistency ==
157
158 === Data requirements description ===
159
160 '''Business rule.''' A trade that fills orders must be possible for those orders:
161
162 * the buy side is a buy order and the sell side is a sell order;
163 * both are on the trade's market and still active — a cancelled or executed order can never trade again;
164 * the trade quantity does not exceed the remaining quantity of either order;
165 * the price respects both limits: at most the buy order's price, at least the sell order's price;
166 * the two orders belong to different users (no self-trade).
167
168 Recording the trade must fill both orders by exactly the traded quantity (and so move their
169 status), and move the money and crypto for both users, all together. A trade that filled orders
170 is history and can't be changed or deleted afterwards — the fills and the money moved would no
171 longer match it.
172
173 '''Why it is non-trivial.''' One trade row has to be checked against two other rows of another
174 table (the orders), including their current remaining quantity, and a single insert must cause
175 consistent changes in five tables (`market_trades`, both `orders`, both users' `users` and
176 `holdings` rows, two `transactions` rows).
177
178 '''PostgreSQL features.'''
179
180 * `BEFORE INSERT` trigger `market_trades_validate` — the compatibility rules. It locks both orders (`FOR UPDATE`), so two concurrent trades can't both take the same remaining quantity.
181 * `AFTER INSERT` trigger `market_trades_fill` — raises `filled_quantity` on both orders (automatic status change through requirement 1).
182 * `BEFORE UPDATE OR DELETE` trigger `market_trades_immutable`.
183 * Stored function `execute_trade` — the settlement of money and crypto for one trade.
184
185 Because the rules sit on `market_trades` itself, even a trade inserted by hand is validated and
186 fills the orders.
187
188 '''Tables affected.''' `market_trades`, `orders`, `users`, `holdings`, `transactions`.
189
190 === Implementation ===
191
192 ==== Triggers ====
193
194 {{{
195 CREATE OR REPLACE FUNCTION project.trg_market_trades_validate()
196 RETURNS trigger LANGUAGE plpgsql AS $$
197 DECLARE
198 o project.orders%ROWTYPE;
199 v_uid uuid;
200 v_id uuid;
201 v_role varchar(4);
202 BEGIN
203 IF NEW.buy_order_id IS NULL AND NEW.sell_order_id IS NULL THEN
204 RETURN NEW;
205 END IF;
206 IF NEW.buy_order_id IS NOT DISTINCT FROM NEW.sell_order_id THEN
207 RAISE EXCEPTION 'an order cannot trade with itself'
208 USING ERRCODE = 'check_violation';
209 END IF;
210
211 FOREACH v_role IN ARRAY ARRAY['buy', 'sell'] LOOP
212 v_id := CASE v_role WHEN 'buy' THEN NEW.buy_order_id ELSE NEW.sell_order_id END;
213 CONTINUE WHEN v_id IS NULL;
214
215 SELECT * INTO o FROM project.orders WHERE id = v_id FOR UPDATE;
216 IF o.side <> v_role THEN
217 RAISE EXCEPTION 'order % is a % order and cannot be the % side of a trade', v_id, o.side, v_role
218 USING ERRCODE = 'check_violation';
219 END IF;
220 IF o.market_id <> NEW.market_id THEN
221 RAISE EXCEPTION 'order % is on a different market than the trade', v_id
222 USING ERRCODE = 'check_violation';
223 END IF;
224 IF o.status NOT IN ('open', 'partially_filled') THEN
225 RAISE EXCEPTION 'order % is % and cannot trade', v_id, o.status
226 USING ERRCODE = 'check_violation';
227 END IF;
228 IF NEW.quantity > o.quantity - o.filled_quantity THEN
229 RAISE EXCEPTION 'trade quantity % exceeds the remaining quantity % of order %',
230 NEW.quantity, o.quantity - o.filled_quantity, v_id
231 USING ERRCODE = 'check_violation';
232 END IF;
233 IF v_uid IS NOT NULL AND v_uid = o.user_id THEN
234 RAISE EXCEPTION 'a user cannot trade with their own order'
235 USING ERRCODE = 'check_violation';
236 END IF;
237 IF v_role = 'buy' AND NEW.price > o.price OR v_role = 'sell' AND NEW.price < o.price THEN
238 RAISE EXCEPTION 'trade price % is outside the limit % of % order %', NEW.price, o.price, v_role, v_id
239 USING ERRCODE = 'check_violation';
240 END IF;
241 v_uid := o.user_id;
242 END LOOP;
243 RETURN NEW;
244 END $$;
245
246 CREATE TRIGGER market_trades_validate
247 BEFORE INSERT ON project.market_trades
248 FOR EACH ROW EXECUTE FUNCTION project.trg_market_trades_validate();
249 }}}
250
251 {{{
252 CREATE OR REPLACE FUNCTION project.trg_market_trades_fill()
253 RETURNS trigger LANGUAGE plpgsql AS $$
254 BEGIN
255 IF NEW.buy_order_id IS NULL AND NEW.sell_order_id IS NULL THEN
256 RETURN NULL;
257 END IF;
258 PERFORM set_config('eduberza.trade_fill', 'on', true);
259 PERFORM set_config('eduberza.trade_price', NEW.price::text, true);
260 UPDATE project.orders
261 SET filled_quantity = filled_quantity + NEW.quantity,
262 executed_at = CASE WHEN filled_quantity + NEW.quantity = quantity
263 THEN NEW.executed_at END
264 WHERE id IN (NEW.buy_order_id, NEW.sell_order_id);
265 PERFORM set_config('eduberza.trade_fill', 'off', true);
266 RETURN NULL;
267 END $$;
268
269 CREATE TRIGGER market_trades_fill
270 AFTER INSERT ON project.market_trades
271 FOR EACH ROW EXECUTE FUNCTION project.trg_market_trades_fill();
272 }}}
273
274 {{{
275 CREATE OR REPLACE FUNCTION project.trg_market_trades_immutable()
276 RETURNS trigger LANGUAGE plpgsql AS $$
277 BEGIN
278 IF OLD.buy_order_id IS NULL AND OLD.sell_order_id IS NULL THEN
279 -- a simulated tick filled no order; allow it unless it is being
280 -- turned into one that did
281 IF TG_OP = 'DELETE' THEN
282 RETURN OLD;
283 ELSIF NEW.buy_order_id IS NULL AND NEW.sell_order_id IS NULL THEN
284 RETURN NEW;
285 END IF;
286 END IF;
287 RAISE EXCEPTION 'a trade that filled orders cannot be changed or deleted'
288 USING ERRCODE = 'check_violation';
289 END $$;
290
291 CREATE TRIGGER market_trades_immutable
292 BEFORE UPDATE OR DELETE ON project.market_trades
293 FOR EACH ROW EXECUTE FUNCTION project.trg_market_trades_immutable();
294 }}}
295
296 ==== Stored procedures/functions ====
297
298 `execute_trade(buy_order, sell_order, quantity, price)` — one trade. Either order may be `NULL`
299 when the simulated market is the counterparty. The `INSERT` into `market_trades` validates the
300 trade and fills the orders (triggers above); the function then settles both users:
301
302 * '''Buyer:''' the reservation for the filled part is released. The buyer pays the actual cost (trade price × quantity); if the trade price is below the order's limit, the difference goes back to `available_balance`. The crypto is added to the holding at a running weighted average price, and a `buy` ledger row is written.
303 * '''Seller:''' the reserved crypto is delivered out of the holding, the proceeds are credited, the cost basis is removed from `invested_balance`, and a `sell` ledger row is written.
304
305 Both orders are locked in a fixed order (by id), so two trades on the same pair of orders can't
306 deadlock.
307
308 {{{
309 CREATE OR REPLACE FUNCTION project.execute_trade(
310 p_buy_order uuid, p_sell_order uuid, p_quantity numeric, p_price numeric,
311 p_aggressor varchar DEFAULT NULL)
312 RETURNS bigint LANGUAGE plpgsql AS $$
313 DECLARE
314 b project.orders%ROWTYPE;
315 s project.orders%ROWTYPE;
316 v_market project.markets%ROWTYPE;
317 v_trade_id bigint;
318 v_release numeric;
319 v_cost numeric;
320 v_proceeds numeric;
321 v_avg numeric;
322 BEGIN
323 IF p_buy_order IS NULL AND p_sell_order IS NULL THEN
324 RAISE EXCEPTION 'a trade needs at least one order' USING ERRCODE = 'check_violation';
325 END IF;
326
327 -- lock both orders in a fixed order (by id) so two concurrent trades on
328 -- the same pair of orders cannot deadlock
329 PERFORM 1 FROM project.orders WHERE id IN (p_buy_order, p_sell_order) ORDER BY id FOR UPDATE;
330 SELECT * INTO b FROM project.orders WHERE id = p_buy_order;
331 SELECT * INTO s FROM project.orders WHERE id = p_sell_order;
332 SELECT * INTO v_market FROM project.markets WHERE id = COALESCE(b.market_id, s.market_id);
333
334 INSERT INTO project.market_trades
335 (market_id, executed_at, price, quantity, side, source, buy_order_id, sell_order_id)
336 VALUES (v_market.id, now(), p_price, p_quantity,
337 COALESCE(p_aggressor, CASE WHEN p_sell_order IS NULL THEN 'buy' ELSE 'sell' END),
338 CASE WHEN p_buy_order IS NOT NULL AND p_sell_order IS NOT NULL THEN 'match' ELSE 'market' END,
339 p_buy_order, p_sell_order)
340 RETURNING id INTO v_trade_id;
341
342 IF p_buy_order IS NOT NULL THEN
343 v_release := project.order_reservation(b.quantity - b.filled_quantity, b.price)
344 - project.order_reservation(b.quantity - b.filled_quantity - p_quantity, b.price);
345 v_cost := LEAST(round(p_quantity * p_price, 4), v_release);
346
347 UPDATE project.users
348 SET reserved_balance = reserved_balance - v_release,
349 available_balance = available_balance + (v_release - v_cost),
350 invested_balance = invested_balance + v_cost,
351 updated_at = now()
352 WHERE id = b.user_id;
353
354 INSERT INTO project.holdings AS h (user_id, crypto_id, quantity, avg_price, updated_at)
355 VALUES (b.user_id, v_market.crypto_id, p_quantity, p_price, now())
356 ON CONFLICT (user_id, crypto_id) DO UPDATE
357 SET avg_price = (h.quantity * h.avg_price + EXCLUDED.quantity * EXCLUDED.avg_price)
358 / (h.quantity + EXCLUDED.quantity),
359 quantity = h.quantity + EXCLUDED.quantity,
360 updated_at = now();
361
362 INSERT INTO project.transactions (user_id, type, amount, currency, related_order, description)
363 VALUES (b.user_id, 'buy', -v_cost, v_market.quote_currency, b.id,
364 format('Buy %s @ %s (trade %s)', p_quantity, p_price, v_trade_id));
365 END IF;
366
367 IF p_sell_order IS NOT NULL THEN
368 SELECT avg_price INTO v_avg FROM project.holdings
369 WHERE user_id = s.user_id AND crypto_id = v_market.crypto_id FOR UPDATE;
370
371 UPDATE project.holdings
372 SET quantity = quantity - p_quantity,
373 reserved_quantity = reserved_quantity - p_quantity,
374 updated_at = now()
375 WHERE user_id = s.user_id AND crypto_id = v_market.crypto_id;
376
377 v_proceeds := round(p_quantity * p_price, 4);
378 UPDATE project.users
379 SET available_balance = available_balance + v_proceeds,
380 invested_balance = GREATEST(invested_balance - round(p_quantity * v_avg, 4), 0),
381 updated_at = now()
382 WHERE id = s.user_id;
383
384 INSERT INTO project.transactions (user_id, type, amount, currency, related_order, description)
385 VALUES (s.user_id, 'sell', v_proceeds, v_market.quote_currency, s.id,
386 format('Sell %s @ %s (trade %s)', p_quantity, p_price, v_trade_id));
387 END IF;
388
389 RETURN v_trade_id;
390 END $$;
391 }}}
392
393 == Balance and reservation consistency ==
394
395 === Data requirements description ===
396
397 '''Business rule.'''
398
399 1. '''Reserved cash matches active buy orders.''' A user's `reserved_balance` always equals what their active buy orders still reserve: remaining quantity × order price, summed.
400 2. '''Reserved crypto matches active sell orders.''' A user's `holdings.reserved_quantity` for a crypto always equals the remaining quantity of their active sell orders for it.
401 3. '''Cash matches the ledger.''' `available_balance + reserved_balance` always equals the sum of the user's ledger (`transactions`). Reserving only moves cash between the two columns; money actually arrives or leaves only with a ledger row (deposit, buy fill, sell fill).
402
403 Together these make it impossible for an order operation to leave a balance in an inconsistent
404 state: money or crypto reserved for nothing, an order that is not backed by a reservation (and
405 so could spend the same money twice), or cash that appeared or vanished without a ledger entry.
406
407 '''Why it is non-trivial.''' Each rule is an equality between a column and an aggregate over
408 ''other'' rows of ''other'' tables. Worse, every legitimate operation breaks it for a moment:
409 placing a buy moves cash to `reserved_balance` in one statement and inserts the order in the
410 next; a trade releases the reservation, pays, and writes the ledger in several statements. Only
411 the state at the end of the transaction has to be consistent.
412
413 '''PostgreSQL feature.''' `CONSTRAINT TRIGGER … DEFERRABLE INITIALLY DEFERRED` — row triggers
414 whose check runs at `COMMIT`, on the final state. A transaction that leaves any of the three
415 equalities broken fails at `COMMIT` and is rolled back as a whole. Each rule has a trigger on
416 every table whose change can break it. `order_reservation(remaining, price)` is the one place
417 that defines how a reservation is rounded, so placing, filling, cancelling and checking always
418 agree to the last decimal.
419
420 '''Tables affected.''' `users`, `holdings`, `orders`, `transactions`.
421
422 === Implementation ===
423
424 ==== Stored procedures/functions ====
425
426 {{{
427 CREATE OR REPLACE FUNCTION project.order_reservation(p_remaining numeric, p_price numeric)
428 RETURNS numeric LANGUAGE sql IMMUTABLE AS $$
429 SELECT round(p_remaining * p_price, 4)
430 $$;
431 }}}
432
433 ==== Triggers ====
434
435 {{{
436 CREATE OR REPLACE FUNCTION project.trg_reserved_cash_matches_orders()
437 RETURNS trigger LANGUAGE plpgsql AS $$
438 DECLARE
439 v_user uuid := CASE WHEN TG_TABLE_NAME = 'users' THEN NEW.id END;
440 v_reserved numeric;
441 v_needed numeric;
442 BEGIN
443 IF TG_TABLE_NAME = 'orders' THEN
444 v_user := NEW.user_id;
445 END IF;
446 SELECT reserved_balance INTO v_reserved FROM project.users WHERE id = v_user;
447 IF NOT FOUND THEN
448 RETURN NULL;
449 END IF;
450 SELECT COALESCE(SUM(project.order_reservation(quantity - filled_quantity, price)), 0)
451 INTO v_needed
452 FROM project.orders
453 WHERE user_id = v_user AND side = 'buy' AND status IN ('open', 'partially_filled');
454 IF v_reserved <> v_needed THEN
455 RAISE EXCEPTION 'reserved balance % does not match the % needed by active buy orders (user %)',
456 v_reserved, v_needed, v_user
457 USING ERRCODE = 'check_violation', CONSTRAINT = 'reserved_cash_matches_orders';
458 END IF;
459 RETURN NULL;
460 END $$;
461 }}}
462
463 {{{
464 CREATE OR REPLACE FUNCTION project.trg_reserved_crypto_matches_orders()
465 RETURNS trigger LANGUAGE plpgsql AS $$
466 DECLARE
467 v_user uuid;
468 v_crypto uuid;
469 v_reserved numeric;
470 v_needed numeric;
471 BEGIN
472 IF TG_TABLE_NAME = 'holdings' THEN
473 v_user := NEW.user_id;
474 v_crypto := NEW.crypto_id;
475 ELSE
476 IF NEW.side <> 'sell' THEN
477 RETURN NULL;
478 END IF;
479 v_user := NEW.user_id;
480 SELECT crypto_id INTO v_crypto FROM project.markets WHERE id = NEW.market_id;
481 END IF;
482 SELECT COALESCE(SUM(reserved_quantity), 0) INTO v_reserved
483 FROM project.holdings WHERE user_id = v_user AND crypto_id = v_crypto;
484 SELECT COALESCE(SUM(o.quantity - o.filled_quantity), 0) INTO v_needed
485 FROM project.orders o
486 JOIN project.markets m ON m.id = o.market_id
487 WHERE o.user_id = v_user AND m.crypto_id = v_crypto
488 AND o.side = 'sell' AND o.status IN ('open', 'partially_filled');
489 IF v_reserved <> v_needed THEN
490 RAISE EXCEPTION 'reserved quantity % does not match the % needed by active sell orders (user %, crypto %)',
491 v_reserved, v_needed, v_user, v_crypto
492 USING ERRCODE = 'check_violation', CONSTRAINT = 'reserved_crypto_matches_orders';
493 END IF;
494 RETURN NULL;
495 END $$;
496 }}}
497
498 {{{
499 CREATE OR REPLACE FUNCTION project.trg_cash_matches_ledger()
500 RETURNS trigger LANGUAGE plpgsql AS $$
501 DECLARE
502 v_user uuid;
503 v_cash numeric;
504 v_ledger numeric;
505 BEGIN
506 IF TG_TABLE_NAME = 'users' THEN
507 v_user := NEW.id;
508 ELSIF TG_OP = 'DELETE' THEN
509 v_user := OLD.user_id;
510 ELSE
511 v_user := NEW.user_id;
512 END IF;
513 SELECT available_balance + reserved_balance INTO v_cash FROM project.users WHERE id = v_user;
514 IF NOT FOUND THEN
515 RETURN NULL;
516 END IF;
517 SELECT COALESCE(SUM(amount), 0) INTO v_ledger FROM project.transactions WHERE user_id = v_user;
518 IF v_cash <> v_ledger THEN
519 RAISE EXCEPTION 'cash % (available + reserved) does not match the ledger total % (user %)',
520 v_cash, v_ledger, v_user
521 USING ERRCODE = 'check_violation', CONSTRAINT = 'cash_matches_ledger';
522 END IF;
523 RETURN NULL;
524 END $$;
525 }}}
526
527 {{{
528 CREATE INDEX idx_orders_active ON project.orders (user_id, side)
529 WHERE status IN ('open', 'partially_filled');
530
531 CREATE CONSTRAINT TRIGGER reserved_cash_matches_orders
532 AFTER INSERT OR UPDATE OF reserved_balance ON project.users
533 DEFERRABLE INITIALLY DEFERRED
534 FOR EACH ROW EXECUTE FUNCTION project.trg_reserved_cash_matches_orders();
535
536 CREATE CONSTRAINT TRIGGER reserved_cash_matches_orders
537 AFTER INSERT OR UPDATE OF status, filled_quantity ON project.orders
538 DEFERRABLE INITIALLY DEFERRED
539 FOR EACH ROW EXECUTE FUNCTION project.trg_reserved_cash_matches_orders();
540
541 CREATE CONSTRAINT TRIGGER reserved_crypto_matches_orders
542 AFTER INSERT OR UPDATE OF reserved_quantity ON project.holdings
543 DEFERRABLE INITIALLY DEFERRED
544 FOR EACH ROW EXECUTE FUNCTION project.trg_reserved_crypto_matches_orders();
545
546 CREATE CONSTRAINT TRIGGER reserved_crypto_matches_orders
547 AFTER INSERT OR UPDATE OF status, filled_quantity ON project.orders
548 DEFERRABLE INITIALLY DEFERRED
549 FOR EACH ROW EXECUTE FUNCTION project.trg_reserved_crypto_matches_orders();
550
551 CREATE CONSTRAINT TRIGGER cash_matches_ledger
552 AFTER INSERT OR UPDATE OF available_balance, reserved_balance ON project.users
553 DEFERRABLE INITIALLY DEFERRED
554 FOR EACH ROW EXECUTE FUNCTION project.trg_cash_matches_ledger();
555
556 CREATE CONSTRAINT TRIGGER cash_matches_ledger
557 AFTER INSERT OR UPDATE OR DELETE ON project.transactions
558 DEFERRABLE INITIALLY DEFERRED
559 FOR EACH ROW EXECUTE FUNCTION project.trg_cash_matches_ledger();
560 }}}
561
562 The partial index `idx_orders_active` covers exactly the rows the reservation checks sum, and
563 stays small because active orders are few compared with the whole order history.
564
565 == Placing and cancelling orders ==
566
567 === Data requirements description ===
568
569 '''Business rule.''' Placing an order must, as one unit:
570
571 1. check the market, side, type, quantity and price;
572 2. reserve what the order commits — cash (quantity × price) for a buy, crypto for a sell — and refuse the order if not enough is free;
573 3. record the order;
574 4. match it against the order book: other users' active limit orders on the same market whose price is acceptable, best price first and oldest first (price–time priority), each trade at the resting order's price;
575 5. fill whatever is still unfilled from the simulated market if it is marketable at the current market price.
576
577 A '''market order''' is priced at the current market price, so it always fills completely in step
578 4 or 5 and never waits. A '''limit order''' that isn't marketable stays in the order book.
579 Cancelling an order must release exactly what it still reserves, and only for an active order of
580 the caller.
581
582 '''Why it is non-trivial.''' It is a multi-step operation over five tables whose steps depend on
583 each other (how much is left after each match, what to release), and it has to be correct under
584 concurrency: two orders of the same user must not both see the same free cash.
585
586 '''PostgreSQL feature.''' Stored functions (PL/pgSQL). `place_order` locks the user's row
587 (`SELECT … FOR UPDATE`) before checking free cash or crypto, so concurrent orders of the same
588 user are serialised. Every step's consistency is still checked by the triggers of requirements
589 1–3.
590
591 '''Tables affected.''' `orders`, `users`, `holdings`, `market_trades`, `transactions`.
592
593 === Implementation ===
594
595 ==== Stored procedures/functions ====
596
597 {{{
598 CREATE OR REPLACE FUNCTION project.latest_price(p_market_id uuid)
599 RETURNS numeric LANGUAGE sql STABLE AS $$
600 SELECT price FROM project.market_trades
601 WHERE market_id = p_market_id
602 ORDER BY executed_at DESC, id DESC
603 LIMIT 1
604 $$;
605 }}}
606
607 {{{
608 CREATE OR REPLACE FUNCTION project.match_order(p_order_id uuid)
609 RETURNS int LANGUAGE plpgsql AS $$
610 DECLARE
611 o project.orders%ROWTYPE;
612 r record;
613 v_rem numeric;
614 v_qty numeric;
615 v_count int := 0;
616 BEGIN
617 SELECT * INTO o FROM project.orders WHERE id = p_order_id FOR UPDATE;
618 FOR r IN
619 SELECT id, price, quantity - filled_quantity AS remaining
620 FROM project.orders
621 WHERE market_id = o.market_id
622 AND side <> o.side
623 AND type = 'limit'
624 AND status IN ('open', 'partially_filled')
625 AND user_id <> o.user_id
626 AND (o.side = 'buy' AND price <= o.price
627 OR o.side = 'sell' AND price >= o.price)
628 ORDER BY CASE WHEN o.side = 'buy' THEN price END ASC,
629 CASE WHEN o.side = 'sell' THEN price END DESC,
630 placed_at, id
631 FOR UPDATE
632 LOOP
633 SELECT quantity - filled_quantity INTO v_rem FROM project.orders WHERE id = p_order_id;
634 EXIT WHEN v_rem = 0;
635 v_qty := LEAST(v_rem, r.remaining);
636 IF o.side = 'buy' THEN
637 PERFORM project.execute_trade(o.id, r.id, v_qty, r.price, 'buy');
638 ELSE
639 PERFORM project.execute_trade(r.id, o.id, v_qty, r.price, 'sell');
640 END IF;
641 v_count := v_count + 1;
642 END LOOP;
643 RETURN v_count;
644 END $$;
645 }}}
646
647 {{{
648 CREATE OR REPLACE FUNCTION project.place_order(
649 p_user_id uuid, p_market_id uuid, p_side varchar, p_type varchar,
650 p_quantity numeric, p_limit_price numeric DEFAULT NULL)
651 RETURNS uuid LANGUAGE plpgsql AS $$
652 DECLARE
653 v_market numeric := project.latest_price(p_market_id);
654 v_price numeric;
655 v_available numeric;
656 v_free numeric;
657 v_crypto uuid;
658 v_order uuid;
659 v_rem numeric;
660 BEGIN
661 IF p_side NOT IN ('buy', 'sell') OR p_type NOT IN ('market', 'limit') THEN
662 RAISE EXCEPTION 'invalid side % or type %', p_side, p_type USING ERRCODE = 'check_violation';
663 END IF;
664 IF p_quantity IS NULL OR p_quantity <= 0 THEN
665 RAISE EXCEPTION 'quantity must be positive' USING ERRCODE = 'check_violation';
666 END IF;
667 IF p_type = 'limit' THEN
668 IF p_limit_price IS NULL OR p_limit_price <= 0 THEN
669 RAISE EXCEPTION 'a limit order needs a positive limit price' USING ERRCODE = 'check_violation';
670 END IF;
671 v_price := p_limit_price;
672 ELSE
673 IF v_market IS NULL THEN
674 RAISE EXCEPTION 'market has no price yet' USING ERRCODE = 'check_violation';
675 END IF;
676 v_price := v_market;
677 END IF;
678
679 SELECT available_balance INTO v_available FROM project.users WHERE id = p_user_id FOR UPDATE;
680 IF NOT FOUND THEN
681 RAISE EXCEPTION 'user % does not exist', p_user_id USING ERRCODE = 'no_data_found';
682 END IF;
683
684 IF p_side = 'buy' THEN
685 IF v_available < project.order_reservation(p_quantity, v_price) THEN
686 RAISE EXCEPTION 'insufficient funds: the order needs %, available %',
687 project.order_reservation(p_quantity, v_price), v_available
688 USING ERRCODE = 'check_violation';
689 END IF;
690 UPDATE project.users
691 SET available_balance = available_balance - project.order_reservation(p_quantity, v_price),
692 reserved_balance = reserved_balance + project.order_reservation(p_quantity, v_price),
693 updated_at = now()
694 WHERE id = p_user_id;
695 ELSE
696 SELECT crypto_id INTO v_crypto FROM project.markets WHERE id = p_market_id;
697 SELECT quantity - reserved_quantity INTO v_free FROM project.holdings
698 WHERE user_id = p_user_id AND crypto_id = v_crypto FOR UPDATE;
699 IF COALESCE(v_free, 0) < p_quantity THEN
700 RAISE EXCEPTION 'insufficient holding: trying to sell %, free to sell %', p_quantity, COALESCE(v_free, 0)
701 USING ERRCODE = 'check_violation';
702 END IF;
703 UPDATE project.holdings
704 SET reserved_quantity = reserved_quantity + p_quantity, updated_at = now()
705 WHERE user_id = p_user_id AND crypto_id = v_crypto;
706 END IF;
707
708 INSERT INTO project.orders (user_id, market_id, side, type, status, quantity, price)
709 VALUES (p_user_id, p_market_id, p_side, p_type, 'open', p_quantity, v_price)
710 RETURNING id INTO v_order;
711
712 PERFORM project.match_order(v_order);
713
714 SELECT quantity - filled_quantity INTO v_rem FROM project.orders WHERE id = v_order;
715 IF v_rem > 0 AND v_market IS NOT NULL
716 AND (p_side = 'buy' AND v_market <= v_price OR p_side = 'sell' AND v_market >= v_price) THEN
717 IF p_side = 'buy' THEN
718 PERFORM project.execute_trade(v_order, NULL, v_rem, v_market, 'buy');
719 ELSE
720 PERFORM project.execute_trade(NULL, v_order, v_rem, v_market, 'sell');
721 END IF;
722 END IF;
723 RETURN v_order;
724 END $$;
725 }}}
726
727 {{{
728 CREATE OR REPLACE FUNCTION project.cancel_order(p_order_id uuid, p_user_id uuid DEFAULT NULL)
729 RETURNS void LANGUAGE plpgsql AS $$
730 DECLARE
731 o project.orders%ROWTYPE;
732 v_rem numeric;
733 BEGIN
734 SELECT * INTO o FROM project.orders WHERE id = p_order_id FOR UPDATE;
735 IF NOT FOUND THEN
736 RAISE EXCEPTION 'order % does not exist', p_order_id USING ERRCODE = 'no_data_found';
737 END IF;
738 IF p_user_id IS NOT NULL AND o.user_id <> p_user_id THEN
739 RAISE EXCEPTION 'order % does not belong to this user', p_order_id
740 USING ERRCODE = 'insufficient_privilege';
741 END IF;
742 IF o.status NOT IN ('open', 'partially_filled') THEN
743 RAISE EXCEPTION 'order % is % and cannot be cancelled', p_order_id, o.status
744 USING ERRCODE = 'check_violation';
745 END IF;
746
747 v_rem := o.quantity - o.filled_quantity;
748 IF o.side = 'buy' THEN
749 UPDATE project.users
750 SET reserved_balance = reserved_balance - project.order_reservation(v_rem, o.price),
751 available_balance = available_balance + project.order_reservation(v_rem, o.price),
752 updated_at = now()
753 WHERE id = o.user_id;
754 ELSE
755 UPDATE project.holdings h
756 SET reserved_quantity = h.reserved_quantity - v_rem, updated_at = now()
757 FROM project.markets m
758 WHERE m.id = o.market_id AND h.user_id = o.user_id AND h.crypto_id = m.crypto_id;
759 END IF;
760
761 UPDATE project.orders SET status = 'cancelled' WHERE id = p_order_id;
762 END $$;
763 }}}
764
765 In the prototype, placing an order is now a single call:
766
767 {{{
768 err = db.DB.QueryRow(
769 `SELECT place_order($1, $2, $3, $4, $5, $6)`,
770 s.UserID, m.ID, side, orderType, qty, limit.value(),
771 ).Scan(&orderID)
772 }}}
773
774 Cancelling is `SELECT cancel_order($1, $2)` with the order the user picked from a numbered list
775 of their open orders (`server/trade.go`).
776
777 == Automatic recording of order events ==
778
779 === Data requirements description ===
780
781 '''Business rule.''' Every important thing that happens to an order is recorded with its time:
782 placement, each fill (with the quantity filled and the trade price), and cancellation. This is
783 the order's audit trail — the history of ''how'' it reached its current state, which the order row
784 alone (only the current state) cannot show.
785
786 '''Why it is non-trivial.''' Events come from several places — `place_order`, trades made by
787 `match_order`, trades made by the background job, `cancel_order`, and any direct SQL. Recording
788 them in each of those places would miss some; recording them where the change actually happens
789 cannot.
790
791 '''PostgreSQL feature.''' `AFTER INSERT OR UPDATE` row trigger on `orders`, writing into the new
792 table `order_events`. The fill price is handed over by the trade trigger through a
793 transaction-local setting.
794
795 '''Tables affected.''' `order_events` (new), written from changes to `orders`.
796
797 === Implementation ===
798
799 {{{
800 CREATE TABLE project.order_events (
801 id bigserial PRIMARY KEY,
802 order_id uuid NOT NULL REFERENCES project.orders(id) ON DELETE CASCADE,
803 event_type varchar(20) NOT NULL
804 CHECK (event_type IN ('placed', 'partially_filled', 'filled', 'cancelled')),
805 quantity numeric(20,4) NOT NULL,
806 price numeric(18,6),
807 status_after varchar(20) NOT NULL,
808 created_at timestamptz NOT NULL DEFAULT clock_timestamp()
809 );
810 }}}
811
812 ==== Triggers ====
813
814 {{{
815 CREATE OR REPLACE FUNCTION project.trg_orders_events()
816 RETURNS trigger LANGUAGE plpgsql AS $$
817 BEGIN
818 IF TG_OP = 'INSERT' THEN
819 INSERT INTO project.order_events (order_id, event_type, quantity, price, status_after)
820 VALUES (NEW.id, 'placed', NEW.quantity, NEW.price, NEW.status);
821 ELSIF NEW.filled_quantity > OLD.filled_quantity THEN
822 INSERT INTO project.order_events (order_id, event_type, quantity, price, status_after)
823 VALUES (NEW.id,
824 CASE WHEN NEW.status = 'executed' THEN 'filled' ELSE 'partially_filled' END,
825 NEW.filled_quantity - OLD.filled_quantity,
826 current_setting('eduberza.trade_price', true)::numeric,
827 NEW.status);
828 ELSIF NEW.status = 'cancelled' AND OLD.status <> 'cancelled' THEN
829 INSERT INTO project.order_events (order_id, event_type, quantity, price, status_after)
830 VALUES (NEW.id, 'cancelled', NEW.quantity - NEW.filled_quantity, NEW.price, NEW.status);
831 END IF;
832 RETURN NULL;
833 END $$;
834
835 CREATE TRIGGER orders_events
836 AFTER INSERT OR UPDATE ON project.orders
837 FOR EACH ROW EXECUTE FUNCTION project.trg_orders_events();
838 }}}
839
840 == Views for derived trading data ==
841
842 === Data requirements description ===
843
844 The application needs several things that are ''derived'' from orders, trades and balances. They
845 are defined once as views, instead of repeating the calculations in the application code:
846
847 ||= View =||= Derived data =||= Used by =||
848 || `v_active_orders` || active orders with remaining quantity and what each one holds in reserve || CLI `[13] My open orders`, `[14] Cancel an order` ||
849 || `v_order_book` || current order book: resting limit orders aggregated per market, side and price level || CLI `[12] Order book`, and when placing an order ||
850 || `v_order_history` || every order with its fill progress, number of trades and average fill price (from its trades) || CLI result of placing an order ||
851 || `v_trader_balances` || cash split into available and reserved, the ledger total it must equal, holdings at market value, net worth || CLI `[1] View balance` ||
852
853 None of these repeats a P6 report: P6 aggregates performance over a period, while these show
854 the current state of the order book and accounts.
855
856 === Implementation ===
857
858 ==== Views ====
859
860 {{{
861 CREATE VIEW project.v_active_orders AS
862 SELECT o.id AS order_id,
863 o.user_id,
864 u.username,
865 o.market_id,
866 c.symbol,
867 m.quote_currency,
868 o.side,
869 o.type,
870 o.status,
871 o.quantity,
872 o.filled_quantity,
873 o.quantity - o.filled_quantity AS remaining,
874 o.price,
875 CASE WHEN o.side = 'buy'
876 THEN project.order_reservation(o.quantity - o.filled_quantity, o.price) ELSE 0 END AS reserved_cash,
877 CASE WHEN o.side = 'sell' THEN o.quantity - o.filled_quantity ELSE 0 END AS reserved_crypto,
878 o.placed_at
879 FROM project.orders o
880 JOIN project.users u ON u.id = o.user_id
881 JOIN project.markets m ON m.id = o.market_id
882 JOIN project.crypto c ON c.id = m.crypto_id
883 WHERE o.status IN ('open', 'partially_filled');
884 }}}
885
886 {{{
887 CREATE VIEW project.v_order_book AS
888 SELECT market_id,
889 symbol,
890 quote_currency,
891 side,
892 price,
893 SUM(remaining) AS quantity,
894 COUNT(*) AS orders
895 FROM project.v_active_orders
896 WHERE type = 'limit'
897 GROUP BY market_id, symbol, quote_currency, side, price;
898 }}}
899
900 {{{
901 CREATE VIEW project.v_order_history AS
902 SELECT o.id AS order_id,
903 o.user_id,
904 u.username,
905 c.symbol,
906 o.side,
907 o.type,
908 o.status,
909 o.quantity,
910 o.filled_quantity,
911 o.quantity - o.filled_quantity AS remaining,
912 o.price,
913 f.trades,
914 f.avg_fill_price,
915 o.placed_at,
916 o.executed_at
917 FROM project.orders o
918 JOIN project.users u ON u.id = o.user_id
919 JOIN project.markets m ON m.id = o.market_id
920 JOIN project.crypto c ON c.id = m.crypto_id
921 LEFT JOIN LATERAL (
922 SELECT COUNT(*) AS trades,
923 round(SUM(t.quantity * t.price) / NULLIF(SUM(t.quantity), 0), 6) AS avg_fill_price
924 FROM (SELECT quantity, price FROM project.market_trades WHERE buy_order_id = o.id
925 UNION ALL
926 SELECT quantity, price FROM project.market_trades WHERE sell_order_id = o.id) t
927 ) f ON true;
928 }}}
929
930 {{{
931 CREATE VIEW project.v_trader_balances AS
932 SELECT u.id AS user_id,
933 u.username,
934 u.available_balance,
935 u.reserved_balance,
936 u.available_balance + u.reserved_balance AS total_cash,
937 COALESCE(l.ledger_total, 0) AS ledger_total,
938 u.invested_balance,
939 COALESCE(p.holdings_value, 0) AS holdings_value,
940 u.available_balance + u.reserved_balance + COALESCE(p.holdings_value, 0) AS net_worth
941 FROM project.users u
942 LEFT JOIN (SELECT user_id, SUM(amount) AS ledger_total
943 FROM project.transactions GROUP BY user_id) l ON l.user_id = u.id
944 LEFT JOIN (SELECT user_id, SUM(market_value) AS holdings_value
945 FROM project.v_portfolio GROUP BY user_id) p ON p.user_id = u.id;
946 }}}
947
948 == Background job: filling resting limit orders ==
949
950 === Data requirements description ===
951
952 '''Business rule.''' In !EduBerza the market price is moved by the simulator (the market bot), not
953 by users' orders. A limit order that rests in the book — a buy at or above, or a sell at or
954 below, the current market price — must then be filled by the simulated market, at the market
955 price, just as it would have been had the price already been there when the order was placed.
956
957 '''Why it is relevant, and why a background job.''' Without it, a limit order could only ever
958 fill against another user's order, and with few users most limit orders would wait forever while
959 the market price has long passed them. That would make limit orders useless in the simulation.
960 Nothing happens at the moment the price crosses an order that a trigger could react to: the
961 price moves through the bot's inserts into `market_trades`, which deliberately stay cheap single
962 inserts. Scanning and filling every crossed order on every tick inside that insert would make
963 each tick expensive. So the work runs as a periodic job after each round of price ticks.
964
965 '''PostgreSQL feature.''' Stored function `fill_marketable_orders()`. It is scheduled by the
966 application, because PostgreSQL has no built-in scheduler and the faculty server provides no
967 `pg_cron` (checked: only `plpgsql` and `pgcrypto` are available, and the project role is not a
968 superuser).
969
970 * An advisory lock (`pg_try_advisory_xact_lock`) keeps two runs from filling the same orders twice.
971 * `FOR UPDATE … SKIP LOCKED` leaves alone an order a user is cancelling at that moment; the next run picks it up.
972 * Every fill goes through `execute_trade`, so all the rules above apply to it.
973
974 '''Tables affected.''' `orders`, `market_trades`, `users`, `holdings`, `transactions`,
975 `order_events`.
976
977 === Implementation ===
978
979 ==== Stored procedures/functions ====
980
981 {{{
982 CREATE OR REPLACE FUNCTION project.fill_marketable_orders()
983 RETURNS int LANGUAGE plpgsql AS $$
984 DECLARE
985 r record;
986 v_count int := 0;
987 BEGIN
988 IF NOT pg_try_advisory_xact_lock(hashtext('project.fill_marketable_orders')) THEN
989 RETURN 0;
990 END IF;
991 FOR r IN
992 SELECT o.id, o.side, o.quantity - o.filled_quantity AS remaining, lp.price AS market_price
993 FROM project.orders o
994 JOIN project.markets m ON m.id = o.market_id AND m.is_active
995 CROSS JOIN LATERAL (SELECT project.latest_price(o.market_id) AS price) lp
996 WHERE o.type = 'limit'
997 AND o.status IN ('open', 'partially_filled')
998 AND (o.side = 'buy' AND o.price >= lp.price
999 OR o.side = 'sell' AND o.price <= lp.price)
1000 ORDER BY o.placed_at, o.id
1001 FOR UPDATE OF o SKIP LOCKED
1002 LOOP
1003 IF r.side = 'buy' THEN
1004 PERFORM project.execute_trade(r.id, NULL, r.remaining, r.market_price, 'sell');
1005 ELSE
1006 PERFORM project.execute_trade(NULL, r.id, r.remaining, r.market_price, 'buy');
1007 END IF;
1008 v_count := v_count + 1;
1009 END LOOP;
1010 RETURN v_count;
1011 END $$;
1012 }}}
1013
1014 ==== Scheduling ====
1015
1016 In `bots/main.go`, after every round of price ticks:
1017
1018 {{{
1019 // P7 background job: the prices just moved, so fill any resting
1020 // limit order the new market price has reached.
1021 var filled int
1022 if err := db.QueryRow(`SELECT fill_marketable_orders()`).Scan(&filled); err != nil {
1023 log.Printf("fill_marketable_orders: %v", err)
1024 } else if filled > 0 {
1025 log.Printf(" filled %d resting limit order(s) at the new market price", filled)
1026 }
1027 }}}
1028
1029 It can also be run by hand from any SQL client: `SELECT project.fill_marketable_orders();`.
1030
1031 A run with the bot, after charlie placed a limit buy of 0.1 ETH at 3599 while the market was at
1032 3600: the bot's random walk took the price below 3599 and the job filled the order at the market
1033 price.
1034
1035 {{{
1036 2026/09/24 13:19:02 filled 1 resting limit order(s) at the new market price
1037 }}}
1038
1039 {{{
1040 username | side | type | status | quantity | filled_quantity | price | avg_fill_price
1041 ----------+------+-------+-----------+----------+-----------------+-------------+----------------
1042 charlie | buy | limit | cancelled | 0.1000 | 0.0000 | 3400.000000 |
1043 charlie | buy | limit | executed | 0.1000 | 0.1000 | 3599.000000 | 3593.979167
1044 }}}
1045
1046 == Tests proving the rules ==
1047
1048 `server/db/advanced_db_tests.sql` plays a short trading
1049 story on the sample data and, along the way, tries to break every rule. It starts from alice with
1050 8250 USD and 0.5 ETH, bob with 5000 USD, charlie with 2500 USD, and ETH/USD last traded at 3520:
1051
1052 1. alice places a limit sell of 0.3 ETH at 3600, and bob a limit buy of 0.1 at 3500. Both rest in the book.
1053 2. bob places a limit buy of 0.2 at 3650. It crosses alice's ask, so they trade 0.2 at 3600: alice's order becomes partially filled, and bob gets back the 10 he had reserved above the trade price.
1054 3. charlie places a market buy of 0.15. He takes alice's remaining 0.1 from the book, and the other 0.05 comes from the simulated market.
1055 4. Invalid trades, state changes and balance changes are attempted directly in SQL.
1056 5. bob cancels his bid.
1057 6. The simulator moves the price to 3450, and the background job fills bob's new limit buy at 3500.
1058
1059 The deferred checks are forced with `SET CONSTRAINTS ALL IMMEDIATE`, so a violation shows up
1060 inside the test instead of at the final `COMMIT`. Everything is rolled back at the end. Run on
1061 PostgreSQL 17 after `-init`:
1062
1063 {{{
1064 PASS place: limit sell above the market rests in the book, crypto reserved: alice ETH reserved = 0.3000
1065 PASS place: limit buy below the market rests in the book, cash reserved: bob available 4650.0000 reserved 350.0000
1066 PASS event: placement recorded automatically:
1067 PASS view: order book shows both price levels: buy 0.1000 @ 3500.000000, sell 0.3000 @ 3600.000000
1068 PASS consistency holds after placing:
1069 PASS place: buy without enough free cash: insufficient funds: the order needs 60000.0000, available 2500.0000
1070 PASS place: sell more than is free (0.2 of 0.5 is already reserved): insufficient holding: trying to sell 0.3, free to sell 0.2000
1071 PASS match: trade between the two orders at the resting price:
1072 PASS status: seller partially filled, buyer executed (automatic): alice_ask partially_filled 0.2000/0.3000
1073 PASS money: buyer paid 720, got back the 10 reserved above the trade price: bob available 3930.0000 reserved 350.0000
1074 PASS crypto: 0.2 ETH moved from alice (0.1 still reserved) to bob:
1075 PASS ledger: one buy and one sell row, linked to the orders:
1076 PASS event: fills recorded automatically:
1077 PASS consistency holds after the trade:
1078 PASS market order: filled completely in two trades: executed, 2 trades, avg 3600.000000
1079 PASS market order: alice's ask is now executed, nothing left reserved:
1080 PASS consistency holds after the market order:
1081 PASS trade: price above the buyer's limit: trade price 3700.000000 is outside the limit 3500.000000 of buy order …
1082 PASS trade: more than the order has remaining: trade quantity 0.500000 exceeds the remaining quantity 0.1000 of order …
1083 PASS trade: a sell order used as the buy side: order … is a sell order and cannot be the buy side of a trade
1084 PASS trade: order of another market: order … is on a different market than the trade
1085 PASS trade: executed order cannot trade again: order … is executed and cannot trade
1086 PASS trade: a user with their own order: a user cannot trade with their own order
1087 PASS trade: a trade that filled orders cannot be deleted: a trade that filled orders cannot be changed or deleted
1088 PASS state: status cannot be set to executed by hand: order status executed does not match filled quantity 0.0000 of 0.1000; status is derived automatically
1089 PASS state: filled quantity cannot be changed by hand: filled quantity of order … can only change through a trade
1090 PASS state: executed order cannot be processed again: order … is already executed and cannot be processed again
1091 PASS state: ordered quantity cannot change: user, market, side, type, quantity, price and placed_at of an order cannot change
1092 PASS state: cancelling by hand without releasing the reservation: reserved balance 350.0000 does not match the 0 needed by active buy orders (user …)
1093 PASS cancel: someone else's order: order … does not belong to this user
1094 PASS cancel: 350 back from reserved to available, event recorded: bob available 4280.0000 reserved 0.0000
1095 PASS cancel: a cancelled order cannot be cancelled again: order … is cancelled and cannot be cancelled
1096 PASS trade: cancelled order cannot trade: order … is cancelled and cannot trade
1097 PASS balance: reserving cash with no order behind it: reserved balance 100.0000 does not match the 0 needed by active buy orders (user …)
1098 PASS balance: reserving crypto with no order behind it: reserved quantity 0.0100 does not match the 0 needed by active sell orders (user …, crypto …)
1099 PASS balance: cash changed without a ledger row: cash 2060.0000 (available + reserved) does not match the ledger total 1960.0000 (user …)
1100 PASS job: fills exactly the orders the new price reached: bob_bid3 executed @ 3450.000000, charlie_ask (3700) still open
1101 PASS job: buyer paid 345, got the 5 above the fill price back:
1102 PASS job: nothing more to do on a second run:
1103 PASS consistency holds after the job:
1104 PASS views: every trader's cash equals their ledger:
1105
1106 passed | failed
1107 --------+--------
1108 41 | 0
1109 }}}
1110
1111 The same rules seen from the prototype (bob, after alice put 0.3 ETH up for sale at 3600):
1112
1113 {{{
1114 -- Place buy order --
1115 Latest price for ETH/USD = 3520.000000
1116 Order book asks (other users' limit orders):
1117 3600.000000 0.3000 (1 orders)
1118 [1] Market order (fills now at the best available price)
1119 [2] Limit order (fills only at your price or better, otherwise waits in the order book)
1120 > 2
1121 Quantity: 0.2
1122 Limit price: 3650
1123 Order executed: buy 0.2000 ETH, average price 3600.000000
1124 }}}
1125
1126 === Test script ===
1127
1128 The complete test script, `advanced_db_tests.sql`:
1129
1130 {{{
1131 -- advanced_db_tests.sql
1132 -- EduBerza - tests for the P7 rules in advanced_db.sql
1133 --
1134 -- Run right after data_load.sql (seed state: alice 8250 USD + 0.5 ETH,
1135 -- bob 5000 USD, charlie 2500 USD, ETH/USD last traded at 3520).
1136 -- It plays a short trading story and, along the way, tries to break every
1137 -- rule. Each check prints PASS/FAIL as a NOTICE; everything is rolled back
1138 -- at the end, so the data is left exactly as it was.
1139 --
1140 -- The reservation and balance checks are deferred to COMMIT; the tests force
1141 -- them with SET CONSTRAINTS ALL IMMEDIATE so a violation shows up inside the
1142 -- test instead of at the final COMMIT.
1143
1144 BEGIN;
1145 SET search_path TO project, public;
1146
1147 CREATE TEMP TABLE test_results (name text, passed boolean) ON COMMIT DROP;
1148 CREATE TEMP TABLE ids (name text PRIMARY KEY, id uuid) ON COMMIT DROP;
1149
1150 -- expect_error: run p_sql (and the deferred checks); it must fail with an
1151 -- error containing p_fragment.
1152 CREATE PROCEDURE pg_temp.expect_error(p_name text, p_sql text, p_fragment text)
1153 LANGUAGE plpgsql AS $$
1154 BEGIN
1155 BEGIN
1156 EXECUTE p_sql;
1157 SET CONSTRAINTS ALL IMMEDIATE;
1158 RAISE NOTICE 'FAIL %: no error raised', p_name;
1159 INSERT INTO test_results VALUES (p_name, false);
1160 EXCEPTION WHEN OTHERS THEN
1161 IF SQLERRM ILIKE '%' || p_fragment || '%' THEN
1162 RAISE NOTICE 'PASS %: %', p_name, SQLERRM;
1163 INSERT INTO test_results VALUES (p_name, true);
1164 ELSE
1165 RAISE NOTICE 'FAIL %: unexpected error: %', p_name, SQLERRM;
1166 INSERT INTO test_results VALUES (p_name, false);
1167 END IF;
1168 END;
1169 SET CONSTRAINTS ALL DEFERRED;
1170 END $$;
1171
1172 CREATE FUNCTION pg_temp.expect_true(p_name text, p_ok boolean, p_detail text DEFAULT '')
1173 RETURNS void LANGUAGE plpgsql AS $$
1174 BEGIN
1175 RAISE NOTICE '% %: %', CASE WHEN coalesce(p_ok, false) THEN 'PASS' ELSE 'FAIL' END, p_name, p_detail;
1176 INSERT INTO test_results VALUES (p_name, coalesce(p_ok, false));
1177 END $$;
1178
1179 -- consistent: all deferred checks pass right now
1180 CREATE FUNCTION pg_temp.consistent() RETURNS boolean LANGUAGE plpgsql AS $$
1181 BEGIN
1182 SET CONSTRAINTS ALL IMMEDIATE;
1183 SET CONSTRAINTS ALL DEFERRED;
1184 RETURN true;
1185 EXCEPTION WHEN OTHERS THEN
1186 RAISE NOTICE ' consistency check failed: %', SQLERRM;
1187 RETURN false;
1188 END $$;
1189
1190 CREATE FUNCTION pg_temp.uid(p_name text) RETURNS uuid LANGUAGE sql AS $$
1191 SELECT id FROM users WHERE username = p_name
1192 $$;
1193 CREATE FUNCTION pg_temp.oid(p_name text) RETURNS uuid LANGUAGE sql AS $$
1194 SELECT id FROM ids WHERE name = p_name
1195 $$;
1196
1197
1198 -- ===========================================================================
1199 -- A. Placing orders reserves what they commit
1200 -- ===========================================================================
1201 INSERT INTO ids VALUES ('alice_ask',
1202 place_order(pg_temp.uid('alice'), 'a2222222-2222-2222-2222-222222222222', 'sell', 'limit', 0.3, 3600));
1203 INSERT INTO ids VALUES ('bob_bid',
1204 place_order(pg_temp.uid('bob'), 'a2222222-2222-2222-2222-222222222222', 'buy', 'limit', 0.1, 3500));
1205
1206 SELECT pg_temp.expect_true('place: limit sell above the market rests in the book, crypto reserved',
1207 (SELECT status FROM orders WHERE id = pg_temp.oid('alice_ask')) = 'open'
1208 AND (SELECT reserved_quantity FROM holdings WHERE user_id = pg_temp.uid('alice')) = 0.3,
1209 'alice ETH reserved = ' || (SELECT reserved_quantity FROM holdings WHERE user_id = pg_temp.uid('alice')));
1210 SELECT pg_temp.expect_true('place: limit buy below the market rests in the book, cash reserved',
1211 (SELECT (available_balance, reserved_balance) FROM users WHERE username = 'bob') = (4650.0000, 350.0000),
1212 (SELECT format('bob available %s reserved %s', available_balance, reserved_balance) FROM users WHERE username = 'bob'));
1213 SELECT pg_temp.expect_true('event: placement recorded automatically',
1214 (SELECT count(*) FROM order_events WHERE order_id IN (pg_temp.oid('alice_ask'), pg_temp.oid('bob_bid'))
1215 AND event_type = 'placed') = 2);
1216 SELECT pg_temp.expect_true('view: order book shows both price levels',
1217 (SELECT string_agg(side || ' ' || quantity || ' @ ' || price, ', ' ORDER BY side)
1218 FROM v_order_book WHERE symbol = 'ETH') = 'buy 0.1000 @ 3500.000000, sell 0.3000 @ 3600.000000',
1219 (SELECT string_agg(side || ' ' || quantity || ' @ ' || price, ', ' ORDER BY side) FROM v_order_book WHERE symbol = 'ETH'));
1220 SELECT pg_temp.expect_true('consistency holds after placing', pg_temp.consistent());
1221
1222 CALL pg_temp.expect_error('place: buy without enough free cash',
1223 $q$SELECT place_order(pg_temp.uid('charlie'), 'a1111111-1111-1111-1111-111111111111', 'buy', 'limit', 1, 60000)$q$,
1224 'insufficient funds');
1225 CALL pg_temp.expect_error('place: sell more than is free (0.2 of 0.5 is already reserved)',
1226 $q$SELECT place_order(pg_temp.uid('alice'), 'a2222222-2222-2222-2222-222222222222', 'sell', 'limit', 0.3, 3600)$q$,
1227 'insufficient holding');
1228
1229 -- ===========================================================================
1230 -- B. A trade between two compatible orders, partial fill
1231 -- ===========================================================================
1232 -- bob bids 0.2 at 3650: crosses alice's ask at 3600 -> trade 0.2 @ 3600
1233 INSERT INTO ids VALUES ('bob_bid2',
1234 place_order(pg_temp.uid('bob'), 'a2222222-2222-2222-2222-222222222222', 'buy', 'limit', 0.2, 3650));
1235
1236 SELECT pg_temp.expect_true('match: trade between the two orders at the resting price',
1237 EXISTS (SELECT 1 FROM market_trades
1238 WHERE buy_order_id = pg_temp.oid('bob_bid2') AND sell_order_id = pg_temp.oid('alice_ask')
1239 AND quantity = 0.2 AND price = 3600 AND source = 'match'));
1240 SELECT pg_temp.expect_true('status: seller partially filled, buyer executed (automatic)',
1241 (SELECT status || ' ' || filled_quantity FROM orders WHERE id = pg_temp.oid('alice_ask')) = 'partially_filled 0.2000'
1242 AND (SELECT status FROM orders WHERE id = pg_temp.oid('bob_bid2')) = 'executed',
1243 (SELECT format('alice_ask %s %s/%s', status, filled_quantity, quantity) FROM orders WHERE id = pg_temp.oid('alice_ask')));
1244 SELECT pg_temp.expect_true('money: buyer paid 720, got back the 10 reserved above the trade price',
1245 (SELECT (available_balance, reserved_balance) FROM users WHERE username = 'bob') = (3930.0000, 350.0000),
1246 (SELECT format('bob available %s reserved %s', available_balance, reserved_balance) FROM users WHERE username = 'bob'));
1247 SELECT pg_temp.expect_true('crypto: 0.2 ETH moved from alice (0.1 still reserved) to bob',
1248 (SELECT (quantity, reserved_quantity) FROM holdings WHERE user_id = pg_temp.uid('alice')) = (0.3000, 0.1000)
1249 AND (SELECT (quantity, avg_price) FROM holdings WHERE user_id = pg_temp.uid('bob')) = (0.2000, 3600.000000));
1250 SELECT pg_temp.expect_true('ledger: one buy and one sell row, linked to the orders',
1251 (SELECT count(*) FROM transactions WHERE related_order IN (pg_temp.oid('bob_bid2'), pg_temp.oid('alice_ask'))) = 2);
1252 SELECT pg_temp.expect_true('event: fills recorded automatically',
1253 (SELECT string_agg(event_type, ',' ORDER BY id) FROM order_events WHERE order_id = pg_temp.oid('alice_ask'))
1254 = 'placed,partially_filled');
1255 SELECT pg_temp.expect_true('consistency holds after the trade', pg_temp.consistent());
1256
1257 -- ===========================================================================
1258 -- C. Market order: book first, then the simulated market
1259 -- ===========================================================================
1260 -- market price is now 3600 (last trade); charlie buys 0.15 at market:
1261 -- 0.1 from alice's remaining ask @ 3600, the other 0.05 from the market @ 3600
1262 INSERT INTO ids VALUES ('charlie_mkt',
1263 place_order(pg_temp.uid('charlie'), 'a2222222-2222-2222-2222-222222222222', 'buy', 'market', 0.15));
1264
1265 SELECT pg_temp.expect_true('market order: filled completely in two trades',
1266 (SELECT (status, trades, avg_fill_price) FROM v_order_history WHERE order_id = pg_temp.oid('charlie_mkt'))
1267 = ('executed'::varchar, 2::bigint, 3600.000000::numeric),
1268 (SELECT format('%s, %s trades, avg %s', status, trades, avg_fill_price) FROM v_order_history WHERE order_id = pg_temp.oid('charlie_mkt')));
1269 SELECT pg_temp.expect_true('market order: alice''s ask is now executed, nothing left reserved',
1270 (SELECT status FROM orders WHERE id = pg_temp.oid('alice_ask')) = 'executed'
1271 AND (SELECT reserved_quantity FROM holdings WHERE user_id = pg_temp.uid('alice')) = 0
1272 AND (SELECT reserved_balance FROM users WHERE username = 'charlie') = 0);
1273 SELECT pg_temp.expect_true('consistency holds after the market order', pg_temp.consistent());
1274
1275 -- ===========================================================================
1276 -- D. Trades are only possible between valid, compatible orders
1277 -- ===========================================================================
1278 INSERT INTO ids VALUES ('charlie_ask',
1279 place_order(pg_temp.uid('charlie'), 'a2222222-2222-2222-2222-222222222222', 'sell', 'limit', 0.1, 3700));
1280 INSERT INTO ids VALUES ('bob_ask',
1281 place_order(pg_temp.uid('bob'), 'a2222222-2222-2222-2222-222222222222', 'sell', 'limit', 0.1, 3800));
1282
1283 CALL pg_temp.expect_error('trade: price above the buyer''s limit',
1284 $q$INSERT INTO market_trades (market_id, executed_at, price, quantity, buy_order_id, sell_order_id)
1285 VALUES ('a2222222-2222-2222-2222-222222222222', now(), 3700, 0.1, pg_temp.oid('bob_bid'), pg_temp.oid('charlie_ask'))$q$,
1286 'outside the limit');
1287 CALL pg_temp.expect_error('trade: more than the order has remaining',
1288 $q$INSERT INTO market_trades (market_id, executed_at, price, quantity, buy_order_id)
1289 VALUES ('a2222222-2222-2222-2222-222222222222', now(), 3500, 0.5, pg_temp.oid('bob_bid'))$q$,
1290 'exceeds the remaining quantity');
1291 CALL pg_temp.expect_error('trade: a sell order used as the buy side',
1292 $q$INSERT INTO market_trades (market_id, executed_at, price, quantity, buy_order_id)
1293 VALUES ('a2222222-2222-2222-2222-222222222222', now(), 3700, 0.1, pg_temp.oid('charlie_ask'))$q$,
1294 'cannot be the buy side');
1295 CALL pg_temp.expect_error('trade: order of another market',
1296 $q$INSERT INTO market_trades (market_id, executed_at, price, quantity, buy_order_id)
1297 VALUES ('a1111111-1111-1111-1111-111111111111', now(), 3500, 0.1, pg_temp.oid('bob_bid'))$q$,
1298 'different market');
1299 CALL pg_temp.expect_error('trade: executed order cannot trade again',
1300 $q$INSERT INTO market_trades (market_id, executed_at, price, quantity, sell_order_id)
1301 VALUES ('a2222222-2222-2222-2222-222222222222', now(), 3600, 0.1, pg_temp.oid('alice_ask'))$q$,
1302 'is executed and cannot trade');
1303 CALL pg_temp.expect_error('trade: a user with their own order',
1304 $q$INSERT INTO market_trades (market_id, executed_at, price, quantity, buy_order_id, sell_order_id)
1305 VALUES ('a2222222-2222-2222-2222-222222222222', now(), 3500, 0.1, pg_temp.oid('bob_bid'), pg_temp.oid('bob_ask'))$q$,
1306 'own order');
1307 CALL pg_temp.expect_error('trade: a trade that filled orders cannot be deleted',
1308 $q$DELETE FROM market_trades WHERE buy_order_id = pg_temp.oid('bob_bid2')$q$,
1309 'cannot be changed or deleted');
1310
1311 -- ===========================================================================
1312 -- E. Order state changes
1313 -- ===========================================================================
1314 CALL pg_temp.expect_error('state: status cannot be set to executed by hand',
1315 $q$UPDATE orders SET status = 'executed' WHERE id = pg_temp.oid('bob_bid')$q$,
1316 'status is derived automatically');
1317 CALL pg_temp.expect_error('state: filled quantity cannot be changed by hand',
1318 $q$UPDATE orders SET filled_quantity = 0.05 WHERE id = pg_temp.oid('bob_bid')$q$,
1319 'can only change through a trade');
1320 CALL pg_temp.expect_error('state: executed order cannot be processed again',
1321 $q$UPDATE orders SET status = 'cancelled' WHERE id = pg_temp.oid('alice_ask')$q$,
1322 'already executed');
1323 CALL pg_temp.expect_error('state: ordered quantity cannot change',
1324 $q$UPDATE orders SET quantity = 1 WHERE id = pg_temp.oid('bob_bid')$q$,
1325 'cannot change');
1326 CALL pg_temp.expect_error('state: cancelling by hand without releasing the reservation',
1327 $q$UPDATE orders SET status = 'cancelled' WHERE id = pg_temp.oid('bob_bid')$q$,
1328 'reserved balance');
1329
1330 -- ===========================================================================
1331 -- F. Cancelling releases the reservation, exactly once
1332 -- ===========================================================================
1333 CALL pg_temp.expect_error('cancel: someone else''s order',
1334 $q$SELECT cancel_order(pg_temp.oid('bob_bid'), pg_temp.uid('charlie'))$q$,
1335 'does not belong');
1336 SELECT cancel_order(pg_temp.oid('bob_bid'), pg_temp.uid('bob'));
1337 SELECT pg_temp.expect_true('cancel: 350 back from reserved to available, event recorded',
1338 (SELECT (available_balance, reserved_balance) FROM users WHERE username = 'bob') = (4280.0000, 0.0000)
1339 AND EXISTS (SELECT 1 FROM order_events WHERE order_id = pg_temp.oid('bob_bid') AND event_type = 'cancelled'),
1340 (SELECT format('bob available %s reserved %s', available_balance, reserved_balance) FROM users WHERE username = 'bob'));
1341 CALL pg_temp.expect_error('cancel: a cancelled order cannot be cancelled again',
1342 $q$SELECT cancel_order(pg_temp.oid('bob_bid'), pg_temp.uid('bob'))$q$,
1343 'cannot be cancelled');
1344 CALL pg_temp.expect_error('trade: cancelled order cannot trade',
1345 $q$INSERT INTO market_trades (market_id, executed_at, price, quantity, buy_order_id)
1346 VALUES ('a2222222-2222-2222-2222-222222222222', now(), 3500, 0.1, pg_temp.oid('bob_bid'))$q$,
1347 'is cancelled and cannot trade');
1348
1349 -- ===========================================================================
1350 -- G. Balances cannot be put in an inconsistent state
1351 -- ===========================================================================
1352 CALL pg_temp.expect_error('balance: reserving cash with no order behind it',
1353 $q$UPDATE users SET available_balance = available_balance - 100, reserved_balance = reserved_balance + 100
1354 WHERE username = 'charlie'$q$,
1355 'reserved balance');
1356 CALL pg_temp.expect_error('balance: reserving crypto with no order behind it',
1357 $q$UPDATE holdings SET reserved_quantity = reserved_quantity + 0.01 WHERE user_id = pg_temp.uid('alice')$q$,
1358 'reserved quantity');
1359 CALL pg_temp.expect_error('balance: cash changed without a ledger row',
1360 $q$UPDATE users SET available_balance = available_balance + 100 WHERE username = 'charlie'$q$,
1361 'does not match the ledger');
1362
1363 -- ===========================================================================
1364 -- H. Background job: resting limit orders filled when the market reaches them
1365 -- ===========================================================================
1366 INSERT INTO ids VALUES ('bob_bid3',
1367 place_order(pg_temp.uid('bob'), 'a2222222-2222-2222-2222-222222222222', 'buy', 'limit', 0.1, 3500));
1368 -- the simulator moves the price down to 3450 (a bot tick, no orders)
1369 INSERT INTO market_trades (market_id, executed_at, price, quantity, side)
1370 VALUES ('a2222222-2222-2222-2222-222222222222', now(), 3450, 0.01, 'sell');
1371
1372 CREATE TEMP TABLE job_run ON COMMIT DROP AS SELECT fill_marketable_orders() AS filled;
1373
1374 SELECT pg_temp.expect_true('job: fills exactly the orders the new price reached',
1375 (SELECT filled FROM job_run) = 1
1376 AND (SELECT (status, avg_fill_price) FROM v_order_history WHERE order_id = pg_temp.oid('bob_bid3'))
1377 = ('executed'::varchar, 3450.000000::numeric)
1378 AND (SELECT status FROM orders WHERE id = pg_temp.oid('charlie_ask')) = 'open',
1379 (SELECT format('bob_bid3 %s @ %s, charlie_ask (3700) still %s', h.status, h.avg_fill_price, o.status)
1380 FROM v_order_history h, orders o WHERE h.order_id = pg_temp.oid('bob_bid3') AND o.id = pg_temp.oid('charlie_ask')));
1381 SELECT pg_temp.expect_true('job: buyer paid 345, got the 5 above the fill price back',
1382 (SELECT reserved_balance FROM users WHERE username = 'bob') = 0
1383 AND (SELECT count(*) FROM transactions WHERE related_order = pg_temp.oid('bob_bid3') AND amount = -345) = 1);
1384 SELECT pg_temp.expect_true('job: nothing more to do on a second run', fill_marketable_orders() = 0);
1385 SELECT pg_temp.expect_true('consistency holds after the job', pg_temp.consistent());
1386
1387 -- ===========================================================================
1388 -- Final state of every trader
1389 -- ===========================================================================
1390 SELECT pg_temp.expect_true('views: every trader''s cash equals their ledger',
1391 NOT EXISTS (SELECT 1 FROM v_trader_balances WHERE total_cash <> ledger_total));
1392
1393 SELECT username, available_balance, reserved_balance, ledger_total, holdings_value
1394 FROM v_trader_balances ORDER BY username;
1395
1396 SELECT count(*) FILTER (WHERE passed) AS passed,
1397 count(*) FILTER (WHERE NOT passed) AS failed
1398 FROM test_results;
1399
1400 ROLLBACK;
1401 }}}
1402
1403 == Changes to earlier phases ==
1404
1405 * '''Schema''' ([wiki:RelationalDesign], [wiki:ERModel]):
1406 * `users.reserved_balance`;
1407 * `orders.filled_quantity` and the status value `partially_filled`;
1408 * `market_trades.buy_order_id` / `sell_order_id` (optional references to `orders` — a new relationship "trade fills order");
1409 * the new table `order_events`.
1410 * '''Sample data''' (`data_load.sql`):
1411 * deposit rows for bob and charlie, whose balances previously had no ledger entries behind them;
1412 * alice's seeded order is imported as completely filled;
1413 * the script runs as one transaction.
1414
1415 The balances documented in [wiki:BuildInstructions] are
1416 unchanged.
1417 * '''P6 demo data''' (`reports_demo_data.sql`): it adjusts the users' balances by exactly what it adds to the ledger, and imports its orders as completely filled. Both [wiki:AdvancedReports] outputs are unchanged.
1418 * '''Prototype:'''
1419 * placing an order is one call to `place_order`, and market or limit can be chosen;
1420 * new menu items `[12] Order book`, `[13] My open orders`, `[14] Cancel an order`;
1421 * the balance screen shows reserved cash;
1422 * the bot runs the background job.
1423
1424 == AI usage ==
1425
1426 AI was used in this phase and is logged in full, per the course rule for P1 onward.
1427
1428 * '''Phase log:''' [wiki:AdvancedDatabaseDevelopmentAIUsage]
1429
1430 '''In short:''' the requirements — order state consistency, filled/remaining quantities, reserved
1431 money and assets, trades only between compatible orders, the kinds of triggers, procedures and
1432 views, and a background job only if relevant — were mine. I asked the AI to turn them into
1433 concrete rules for the existing !EduBerza database, implement them without redesigning it, test
1434 them and document them.
Note: See TracBrowser for help on using the repository browser.