source: docs/P4-Prototype/wiki/UseCase0005Implementation.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: 20.5 KB
Line 
1= Use-case 0005 Implementation - Place market SELL order =
2
3'''Initiating actor:''' Trader
4
5'''Other actors:''' Market Simulator (indirect — supplies the current price).
6
7A logged-in Trader sells part or all of a holding at the current market price. The
8Trader never types a symbol: the system lists only the cryptos the Trader holds and can
9still sell (the quantity not already reserved by an open sell order), numbered, with
10how much is held and how much is free, and the Trader picks one by its number and
11enters the quantity. In one database transaction the system records the order,
12reserves the crypto being sold and settles it, credits the proceeds to the Trader's
13available cash while reducing the invested cash by the cost basis, writes a ledger
14entry and a market trade, and marks the order executed. Cost basis is preserved, so
15the realised P/L can be reconstructed from the ledger.
16
17Original use-case description (P3): [wiki:UseCase0005].
18Implementation: `server/trade.go`, function
19`PlaceOrder(s, "sell")`, which calls `ChooseHolding`, `pickNumber` and `LatestPrice`
20from `server/market.go` (the code is shown at the end of this page).
21
22All statements run on the `project` schema (the connection sets
23`search_path=project,public` in `server/db/db.go`). The SQL below is copied from the
24Go code; only the Go source indentation is removed, a `;` is added after each
25statement of the transaction, and `--` comments say what each `$n` placeholder is
26bound to.
27
28The run shown is user `alice` right after the buy of
29[wiki:UseCase0004Implementation UseCase0004]: 7578.60 USD available, holdings
300.01 BTC (bought at 67140) and 0.5 ETH (bought at 3500). She sells 0.2 ETH.
31
32== Reserve, then settle ==
33
34The crypto being sold is '''reserved''' (`holdings.reserved_quantity`) before it is
35removed from the position, and the sell check is against what is truly still free,
36`quantity - reserved_quantity`, not against the raw `quantity`, which would also count
37crypto already promised to another order. Because only market orders are implemented,
38an order settles in the same transaction it is placed in, so reserve and settle are two
39statements inside one commit; they stay logically distinct so that a future
40limit-order matcher, where an order would stay `open` until a ''later'' transaction fills
41it, needs a second transaction but no schema change.
42
43== Scenario ==
44
45 1. '''Trader''' chooses `[5] Place market SELL order` in the authenticated menu (types `5`).
46 2. '''System''' prints `-- Place market sell order --` and lists, numbered, only the
47 cryptos the Trader holds with some quantity still free to sell, with the quantity
48 held, the quantity free to sell and the last price (`ChooseHolding`;
49 `$1` = the logged-in user's id):
50
51{{{
52SELECT m.id, c.id, c.symbol, m.quote_currency,
53 h.quantity, h.quantity - h.reserved_quantity AS free,
54 COALESCE(lp.price, 0) AS price
55 FROM holdings h
56 JOIN crypto c ON c.id = h.crypto_id
57 JOIN markets m ON m.crypto_id = c.id AND m.is_active = true
58 LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
59 WHERE h.user_id = $1
60 AND h.quantity - h.reserved_quantity > 0
61 ORDER BY c.symbol
62}}}
63
64For alice it prints `1 BTC USD 0.0100 0.0100 67140.000000` and
65`2 ETH USD 0.5000 0.5000 3520.000000`, then asks `Holding #:`. Go keeps each row's
66market id and crypto id in memory; the Trader only types the list number. (If the
67query returns no row, the system prints `you hold no crypto that is free to sell`
68and the use-case ends.)
69
70[[Image(uc0005_1_2_holdings.png)]]
71
72 3. '''Trader''' picks the holding by its number in the list: `2` (ETH).
73 4. '''System''' takes the market id and crypto id of row 2 from the list and reads the
74 latest price of that market (`LatestPrice`; `$1` = the chosen market's id):
75
76{{{
77SELECT price FROM v_latest_prices WHERE market_id = $1
78}}}
79
80It prints `Latest price for ETH/USD = 3520.000000` and asks `Quantity:`.
81
82[[Image(uc0005_3_4_price.png)]]
83
84 5. '''Trader''' enters the quantity `0.2`.
85 6. '''System''' computes in Go notional = quantity × price = 0.2 × 3520 = 704.00 and
86 runs one database transaction; the statements are in exactly the order
87 `PlaceOrder` executes them for a sell. After statement (b) Go also computes the
88 cost basis = avg_price × quantity = 3500 × 0.2 = 700.00 from the locked holding row;
89 both values are passed to SQL as parameters.
90
91{{{
92BEGIN;
93
94-- (a) record the order as 'open' — no trade has happened yet.
95-- $1 = user id, $2 = market id, $3 = side (the Go variable side = 'sell'),
96-- $4 = quantity (0.2), $5 = price (3520); the returned id is kept in Go.
97INSERT INTO orders (user_id, market_id, side, type, status, quantity, price)
98 VALUES ($1, $2, $3, 'market', 'open', $4, $5)
99 RETURNING id;
100
101-- (b) lock the holding row and read what is held, what is already reserved and
102-- the average entry price. $1 = user id, $2 = crypto id.
103-- Go computes available = quantity - reserved_quantity (0.5 - 0 = 0.5);
104-- if there is no row or available < quantity -> alternate flow 5a.
105SELECT quantity, reserved_quantity, avg_price FROM holdings
106 WHERE user_id = $1 AND crypto_id = $2 FOR UPDATE;
107
108-- (c) reserve: committed to this order, not yet removed from the position.
109-- $1 = quantity (0.2), $2 = user id, $3 = crypto id.
110UPDATE holdings
111 SET reserved_quantity = reserved_quantity + $1,
112 updated_at = now()
113 WHERE user_id = $2 AND crypto_id = $3;
114
115-- (d) settle: a market order fills immediately, so release the reservation and
116-- remove the asset from the position in one step. Same parameters as (c).
117UPDATE holdings
118 SET quantity = quantity - $1,
119 reserved_quantity = reserved_quantity - $1,
120 updated_at = now()
121 WHERE user_id = $2 AND crypto_id = $3;
122
123-- (e) credit the proceeds; reduce invested cash by the cost basis.
124-- $1 = notional (704.00), $2 = cost basis (700.00), $3 = user id.
125UPDATE users
126 SET available_balance = available_balance + $1,
127 invested_balance = GREATEST(invested_balance - $2, 0),
128 updated_at = now()
129 WHERE id = $3;
130
131-- (f) ledger entry. $1 = user id, $2 = notional (704.00), $3 = order id from (a),
132-- $4 = description built in Go: 'Market sell 0.2000 ETH @ 3520.000000'.
133INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
134 VALUES ($1, 'sell', $2, 'USD', $3, $4);
135
136-- (g) record the resulting market trade.
137-- $1 = market id, $2 = price, $3 = quantity, $4 = side ('sell').
138INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source)
139 VALUES ($1, now(), $2, $3, $4, 'user');
140
141-- (h) settle the order itself — it has now actually been filled. $1 = order id.
142UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1;
143
144COMMIT;
145}}}
146
147 7. '''System''' confirms
148 `Order executed: sell 0.2000 ETH @ 3520.000000 (notional 704.0000 USD)` and shows
149 the authenticated menu again.
150
151The screenshot shows steps 5–7: the entered quantity, the confirmation and the menu.
152
153[[Image(uc0005_5_7_executed.png)]]
154
155After this run the database holds for alice: ETH `quantity` 0.3000 with
156`reserved_quantity` 0.0000; `available_balance` 8282.60 (= 7578.60 + 704.00) and
157`invested_balance` 1721.40 (= 2421.40 − 700.00); a `sell` row in `transactions` with
158amount 704.0000 and description `Market sell 0.2000 ETH @ 3520.000000`; and the order
159with status `executed`. The realised P/L of this sell is notional − cost basis =
160704.00 − 700.00 = +4.00 USD.
161
162=== Alternate flow 5a — insufficient holding ===
163
164Right after the sell above, alice chooses `[5]` again. The list from step 2 now shows
165`2 ETH USD 0.3000 0.3000 3520.000000`. She picks `2` (ETH) and enters quantity `5`.
166In the transaction, statement (a) inserts the `open` order and statement (b) returns
167quantity 0.3000 and reserved_quantity 0.0000, so available = 0.3 < 5. `PlaceOrder`
168prints
169
170{{{
171Insufficient holding: trying to sell 5.0000, available 0.3000 (of 0.3000 held, 0.0000 reserved)
172}}}
173
174and returns without running (c)–(h); the deferred `tx.Rollback()` undoes statement (a)
175as well, so no order, no reservation and no ledger entry is left behind. The
176authenticated menu is shown again. The same message is printed if the holding row no
177longer exists (for example because it was sold out from another session after the
178list was shown).
179
180[[Image(uc0005_5a_insufficient.png)]]
181
182== Reserve and settle, step by step ==
183
184The CLI reserves and settles inside one transaction, so `reserved_quantity` is never
185nonzero ''outside'' a transaction. The intermediate state is shown by running statements
186(c) and (d) by hand in one `psql` transaction (which sees its own uncommitted
187writes) against alice's ETH holding after the scenario above (0.3 ETH), for a sell of
1880.1, and rolling back at the end so nothing is changed. Literal values replace the
189`$n` parameters; `:alice` and `:eth` are psql variables for
190`(SELECT id FROM users WHERE username = 'alice')` and
191`(SELECT id FROM crypto WHERE symbol = 'ETH')`:
192
193{{{
194BEGIN;
195SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
196 FROM holdings WHERE user_id = :alice AND crypto_id = :eth;
197-- quantity | reserved_quantity | available
198-- ----------+-------------------+-----------
199-- 0.3000 | 0.0000 | 0.3000
200
201-- (c) reserve 0.1: the order is placed, no trade has happened yet
202UPDATE holdings SET reserved_quantity = reserved_quantity + 0.1, updated_at = now()
203 WHERE user_id = :alice AND crypto_id = :eth;
204SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
205 FROM holdings WHERE user_id = :alice AND crypto_id = :eth;
206-- quantity | reserved_quantity | available
207-- ----------+-------------------+-----------
208-- 0.3000 | 0.1000 | 0.2000
209
210-- (d) settle: the reservation is released and the asset removed
211UPDATE holdings SET quantity = quantity - 0.1, reserved_quantity = reserved_quantity - 0.1, updated_at = now()
212 WHERE user_id = :alice AND crypto_id = :eth;
213SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
214 FROM holdings WHERE user_id = :alice AND crypto_id = :eth;
215-- quantity | reserved_quantity | available
216-- ----------+-------------------+-----------
217-- 0.2000 | 0.0000 | 0.2000
218ROLLBACK;
219}}}
220
221The middle state is what every other connection would see for as long as an order
222stayed `open` once limit orders exist: 0.1 ETH still owned but no longer free to sell.
223
224== Two concurrent sells ==
225
226A Trader must not be able to sell the same units twice from two sessions at once. Both
227sessions may have listed the holding as free (step 2 runs outside the transaction), so
228the protection is statement (b): `SELECT ... FOR UPDATE` locks the holding row, and a
229second transaction that reaches (b) waits until the first one commits, then reads the
230already reduced `quantity` before deciding.
231
232This was checked with two `psql` sessions running statements (b)–(d) against alice's
2330.3 ETH. Session A locked the row, reserved and settled 0.2 ETH and committed after a
2343-second pause; session B asked for the lock one second after A had taken it:
235
236{{{
237A: SELECT ... FOR UPDATE -> quantity 0.3000, reserved_quantity 0.0000
238A: reserve 0.2, settle 0.2, pg_sleep(3)
239B: 11:43:54 SELECT ... FOR UPDATE -- blocks, A holds the row lock
240A: 11:43:56 COMMIT
241B: 11:43:56 lock granted -> quantity 0.1000, reserved_quantity 0.0000
242}}}
243
244Session B was blocked for the two seconds until A committed and then saw only
2450.1 ETH, so a second 0.2 ETH sell in B takes alternate flow 5a
246(`available 0.1000`) instead of selling units that no longer exist. (B was rolled
247back and alice's holding was restored to 0.3 ETH after the check.)
248
249== The constraint holds even if the application code did not ==
250
251`schema_creation.sql` declares
252`CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` on
253`holdings.reserved_quantity`, so an inconsistent reservation is impossible at the
254database level, independently of `trade.go` (run inside a transaction that was
255rolled back):
256
257{{{
258UPDATE holdings SET reserved_quantity = quantity + 1 WHERE user_id = :alice AND crypto_id = :eth;
259ERROR: new row for relation "holdings" violates check constraint "holdings_check"
260}}}
261
262== Source code ==
263
264`server/trade.go` — `PlaceOrder` (buy and sell):
265
266{{{
267// PlaceOrder - UC0004 (buy) / UC0005 (sell)
268// Market order that executes immediately against the latest price.
269// Runs inside a single database transaction so the orders, holdings,
270// users.balance and transactions tables always agree.
271//
272// The order still passes through 'open' before 'executed'. Placing it
273// reserves whatever it commits — on a sell, the crypto being sold, tracked in
274// holdings.reserved_quantity — before anything is actually moved, so a
275// second order against the same holding can never be granted the same units
276// twice. Because only market orders are implemented, reserve and settle
277// happen inside this one transaction rather than across two commits; a
278// future limit-order matcher would split them into a second transaction
279// later, without needing a schema change.
280func PlaceOrder(s *Session, side string) {
281 if side != "buy" && side != "sell" {
282 fmt.Println("Invalid side.")
283 return
284 }
285 fmt.Printf("\n-- Place market %s order --\n", side)
286
287 // buy: any market; sell: only what the user holds and can still sell
288 var m *Market
289 var err error
290 if side == "buy" {
291 m, err = ChooseMarket()
292 } else {
293 m, err = ChooseHolding(s)
294 }
295 if err != nil {
296 fmt.Println(err)
297 return
298 }
299 price, err := LatestPrice(m.ID)
300 if err != nil {
301 fmt.Println(err)
302 return
303 }
304 fmt.Printf("Latest price for %s/%s = %.6f\n", m.Symbol, m.Quote, price)
305
306 qtyStr := prompt("Quantity: ")
307 qty, err := strconv.ParseFloat(qtyStr, 64)
308 if err != nil || qty <= 0 {
309 fmt.Println("Invalid quantity.")
310 return
311 }
312 notional := qty * price
313
314 tx, err := db.DB.Begin()
315 if err != nil {
316 fmt.Println("Error:", err)
317 return
318 }
319 defer tx.Rollback()
320
321 // 1. record the order as 'open' — no trade has happened yet.
322 var orderID string
323 err = tx.QueryRow(
324 `INSERT INTO orders (user_id, market_id, side, type, status, quantity, price)
325 VALUES ($1, $2, $3, 'market', 'open', $4, $5)
326 RETURNING id`,
327 s.UserID, m.ID, side, qty, price,
328 ).Scan(&orderID)
329 if err != nil {
330 fmt.Println("Error creating order:", err)
331 return
332 }
333
334 if side == "buy" {
335 // check balance
336 var avail float64
337 if err := tx.QueryRow(
338 `SELECT available_balance FROM users WHERE id = $1 FOR UPDATE`,
339 s.UserID).Scan(&avail); err != nil {
340 fmt.Println("Error:", err)
341 return
342 }
343 if avail < notional {
344 fmt.Printf("Insufficient funds: need %.4f, have %.4f\n", notional, avail)
345 return
346 }
347
348 // debit balance
349 if _, err := tx.Exec(
350 `UPDATE users
351 SET available_balance = available_balance - $1,
352 invested_balance = invested_balance + $1,
353 updated_at = now()
354 WHERE id = $2`,
355 notional, s.UserID,
356 ); err != nil {
357 fmt.Println("Error:", err)
358 return
359 }
360
361 // a buy never reserves crypto, only ever adds it — upsert holding
362 // with running weighted average
363 if err := upsertHoldingOnBuy(tx, s.UserID, m.CryptoID, qty, price); err != nil {
364 fmt.Println("Error updating holding:", err)
365 return
366 }
367
368 // ledger entry
369 if _, err := tx.Exec(
370 `INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
371 VALUES ($1, 'buy', $2, 'USD', $3, $4)`,
372 s.UserID, -notional, orderID,
373 fmt.Sprintf("Market buy %.4f %s @ %.6f", qty, m.Symbol, price),
374 ); err != nil {
375 fmt.Println("Error:", err)
376 return
377 }
378 } else {
379 // sell: lock the holding and check what is actually free to sell —
380 // quantity minus whatever another open order has already reserved.
381 var held, reserved, avgPrice float64
382 err := tx.QueryRow(
383 `SELECT quantity, reserved_quantity, avg_price FROM holdings
384 WHERE user_id = $1 AND crypto_id = $2 FOR UPDATE`,
385 s.UserID, m.CryptoID,
386 ).Scan(&held, &reserved, &avgPrice)
387 if err != nil && err != sql.ErrNoRows {
388 fmt.Println("Error:", err)
389 return
390 }
391 available := held - reserved
392 if err == sql.ErrNoRows || available < qty {
393 fmt.Printf("Insufficient holding: trying to sell %.4f, available %.4f (of %.4f held, %.4f reserved)\n",
394 qty, available, held, reserved)
395 return
396 }
397
398 // reserve: committed to this order, not yet removed from the position.
399 if _, err := tx.Exec(
400 `UPDATE holdings
401 SET reserved_quantity = reserved_quantity + $1,
402 updated_at = now()
403 WHERE user_id = $2 AND crypto_id = $3`,
404 qty, s.UserID, m.CryptoID,
405 ); err != nil {
406 fmt.Println("Error:", err)
407 return
408 }
409
410 // settle: a market order fills immediately, so release the
411 // reservation and remove the asset from the position in one step.
412 if _, err := tx.Exec(
413 `UPDATE holdings
414 SET quantity = quantity - $1,
415 reserved_quantity = reserved_quantity - $1,
416 updated_at = now()
417 WHERE user_id = $2 AND crypto_id = $3`,
418 qty, s.UserID, m.CryptoID,
419 ); err != nil {
420 fmt.Println("Error:", err)
421 return
422 }
423
424 // credit balance; reduce invested by cost basis (avg_price * qty)
425 costBasis := avgPrice * qty
426 if _, err := tx.Exec(
427 `UPDATE users
428 SET available_balance = available_balance + $1,
429 invested_balance = GREATEST(invested_balance - $2, 0),
430 updated_at = now()
431 WHERE id = $3`,
432 notional, costBasis, s.UserID,
433 ); err != nil {
434 fmt.Println("Error:", err)
435 return
436 }
437
438 // ledger entry
439 if _, err := tx.Exec(
440 `INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
441 VALUES ($1, 'sell', $2, 'USD', $3, $4)`,
442 s.UserID, notional, orderID,
443 fmt.Sprintf("Market sell %.4f %s @ %.6f", qty, m.Symbol, price),
444 ); err != nil {
445 fmt.Println("Error:", err)
446 return
447 }
448 }
449
450 // record the resulting market trade so the book reflects this fill
451 if _, err := tx.Exec(
452 `INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source)
453 VALUES ($1, now(), $2, $3, $4, 'user')`,
454 m.ID, price, qty, side,
455 ); err != nil {
456 fmt.Println("Error:", err)
457 return
458 }
459
460 // settle the order itself: it has now actually been filled.
461 if _, err := tx.Exec(
462 `UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1`,
463 orderID,
464 ); err != nil {
465 fmt.Println("Error:", err)
466 return
467 }
468
469 if err := tx.Commit(); err != nil {
470 fmt.Println("Commit error:", err)
471 return
472 }
473 fmt.Printf("Order executed: %s %.4f %s @ %.6f (notional %.4f USD)\n",
474 side, qty, m.Symbol, price, notional)
475}
476}}}
477
478`server/market.go` — `pickNumber`, `ChooseHolding` and `LatestPrice`:
479
480{{{
481// pickNumber reads a 1-based choice from a list of n items.
482func pickNumber(label string, n int) (int, error) {
483 if n == 0 {
484 return 0, fmt.Errorf("Nothing to choose from.")
485 }
486 k, err := strconv.Atoi(prompt(label))
487 if err != nil || k < 1 || k > n {
488 return 0, fmt.Errorf("Invalid choice, enter a number from 1 to %d.", n)
489 }
490 return k - 1, nil
491}
492
493// ChooseHolding lists only the markets the user can sell in — cryptos they
494// hold with some quantity still free (not reserved by an open sell order) —
495// with how much is held and free, and lets them pick one by number.
496func ChooseHolding(s *Session) (*Market, error) {
497 rows, err := db.DB.Query(`
498 SELECT m.id, c.id, c.symbol, m.quote_currency,
499 h.quantity, h.quantity - h.reserved_quantity AS free,
500 COALESCE(lp.price, 0) AS price
501 FROM holdings h
502 JOIN crypto c ON c.id = h.crypto_id
503 JOIN markets m ON m.crypto_id = c.id AND m.is_active = true
504 LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
505 WHERE h.user_id = $1
506 AND h.quantity - h.reserved_quantity > 0
507 ORDER BY c.symbol`, s.UserID)
508 if err != nil {
509 return nil, err
510 }
511 defer rows.Close()
512
513 fmt.Println()
514 fmt.Printf(" %-4s %-8s %-5s %12s %12s %15s\n", "#", "Symbol", "Quote", "Held", "Free to sell", "Last price")
515 fmt.Println(" -------------------------------------------------------------------")
516 var list []Market
517 for rows.Next() {
518 var m Market
519 var held, free, price float64
520 if err := rows.Scan(&m.ID, &m.CryptoID, &m.Symbol, &m.Quote, &held, &free, &price); err != nil {
521 return nil, err
522 }
523 list = append(list, m)
524 fmt.Printf(" %-4d %-8s %-5s %12.4f %12.4f %15.6f\n", len(list), m.Symbol, m.Quote, held, free, price)
525 }
526 if len(list) == 0 {
527 return nil, fmt.Errorf("you hold no crypto that is free to sell")
528 }
529 k, err := pickNumber("Holding #: ", len(list))
530 if err != nil {
531 return nil, err
532 }
533 return &list[k], nil
534}
535
536// LatestPrice returns the last traded price on a market.
537func LatestPrice(marketID string) (float64, error) {
538 var price float64
539 err := db.DB.QueryRow(
540 `SELECT price FROM v_latest_prices WHERE market_id = $1`, marketID,
541 ).Scan(&price)
542 if err == sql.ErrNoRows {
543 return 0, fmt.Errorf("no trades yet for this market")
544 }
545 return price, err
546}
547}}}
Note: See TracBrowser for help on using the repository browser.