source: docs/P4-Prototype/wiki/UseCase0004Implementation.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: 17.2 KB
Line 
1= Use-case 0004 Implementation - Place market BUY order =
2
3'''Initiating actor:''' Trader
4
5'''Other actors:''' Market Simulator (indirect — supplies the current price via `market_trades`).
6
7A logged-in Trader buys a crypto asset at the current market price. The Trader never
8types a symbol or an identifier: the system lists the active markets with their last
9price, numbered, and the Trader picks one by its number and then enters only the
10quantity. The system checks that the Trader has enough available cash for
11quantity × price and then, in one database transaction, records the order, moves the
12cash from available to invested, adds the crypto to the Trader's holding (recomputing
13the weighted-average entry price), writes a ledger entry and a market trade, and marks
14the order executed. The operation touches five tables (`orders`, `users`, `holdings`,
15`transactions`, `market_trades`) and either all of it succeeds or all of it is rolled
16back.
17
18Original use-case description (P3): [wiki:UseCase0004].
19Implementation: `server/trade.go`, function
20`PlaceOrder(s, "buy")` (with `upsertHoldingOnBuy` in the same file), which calls
21`ChooseMarket`, `ListMarkets`, `pickNumber` and `LatestPrice` from
22`server/market.go` (the code is shown at the end of this page).
23
24All statements run on the `project` schema: the connection sets
25`search_path=project,public` (`server/db/db.go`), so `orders` means `project.orders`.
26The SQL below is copied from the Go code; only the Go source indentation is removed,
27a `;` is added after each statement of the transaction, and `--` comments say what
28each `$n` placeholder is bound to.
29
30The run shown is user `alice` on the seed data (available 8250.00 USD, holding
310.5 ETH bought at 3500), buying 0.01 BTC.
32
33== Scenario ==
34
35 1. '''Trader''' chooses `[4] Place market BUY order` in the authenticated menu (types `4`).
36 2. '''System''' prints `-- Place market buy order --` and lists all active markets,
37 numbered, with their last price (`ListMarkets`, called by `ChooseMarket`):
38
39{{{
40SELECT m.id, c.id, c.symbol, m.quote_currency,
41 COALESCE(lp.price, 0) AS price
42 FROM markets m
43 JOIN crypto c ON c.id = m.crypto_id
44 LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
45 WHERE m.is_active = true
46 ORDER BY c.symbol
47}}}
48
49The rows are printed in this order as `1 ADA`, `2 BTC`, `3 DOGE`, `4 ETH`, `5 SOL`;
50Go keeps each row's market id and crypto id in memory, so the Trader only ever sees
51and types the list number. The system then asks `Market #:`.
52
53[[Image(uc0004_1_2_markets.png)]]
54
55 3. '''Trader''' picks the market by its number in the list: `2` (BTC/USD).
56 4. '''System''' takes the market id and crypto id of row 2 from the list (no further
57 lookup by symbol) and reads the latest price of that market (`LatestPrice`;
58 `$1` = the chosen market's id):
59
60{{{
61SELECT price FROM v_latest_prices WHERE market_id = $1
62}}}
63
64It prints `Latest price for BTC/USD = 67140.000000` and asks `Quantity:`.
65
66[[Image(uc0004_3_4_price.png)]]
67
68 5. '''Trader''' enters the quantity `0.01`.
69 6. '''System''' computes in Go notional = quantity × price = 0.01 × 67140 = 671.40 and
70 passes it to SQL as a parameter. It then runs one database transaction; the
71 statements below are in exactly the order `PlaceOrder` executes them for a buy:
72
73{{{
74BEGIN;
75
76-- (a) record the order as 'open' — no trade has happened yet.
77-- $1 = user id, $2 = market id, $3 = side (the Go variable side = 'buy'),
78-- $4 = quantity (0.01), $5 = price (67140); the returned id is kept in Go.
79INSERT INTO orders (user_id, market_id, side, type, status, quantity, price)
80 VALUES ($1, $2, $3, 'market', 'open', $4, $5)
81 RETURNING id;
82
83-- (b) lock the user's row and read the available cash. $1 = user id.
84-- Go compares it with the notional; if it is smaller -> alternate flow 6a.
85SELECT available_balance FROM users WHERE id = $1 FOR UPDATE;
86
87-- (c) move the notional from available to invested cash.
88-- $1 = notional (671.40), $2 = user id.
89UPDATE users
90 SET available_balance = available_balance - $1,
91 invested_balance = invested_balance + $1,
92 updated_at = now()
93 WHERE id = $2;
94
95-- (d) add the crypto to the holding (upsertHoldingOnBuy), recomputing the
96-- weighted-average entry price in the database. Every SET expression sees
97-- the pre-update row, so holdings.quantity is still the old quantity.
98-- $1 = user id, $2 = crypto id, $3 = quantity (0.01), $4 = price (67140).
99INSERT INTO holdings (user_id, crypto_id, quantity, avg_price, updated_at)
100 VALUES ($1, $2, $3, $4, now())
101 ON CONFLICT (user_id, crypto_id) DO UPDATE
102 SET avg_price = (holdings.quantity * holdings.avg_price
103 + EXCLUDED.quantity * EXCLUDED.avg_price)
104 / (holdings.quantity + EXCLUDED.quantity),
105 quantity = holdings.quantity + EXCLUDED.quantity,
106 updated_at = now();
107
108-- (e) ledger entry. $1 = user id, $2 = -notional (-671.40), $3 = order id from (a),
109-- $4 = description built in Go: 'Market buy 0.0100 BTC @ 67140.000000'.
110INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
111 VALUES ($1, 'buy', $2, 'USD', $3, $4);
112
113-- (f) record the resulting market trade.
114-- $1 = market id, $2 = price, $3 = quantity, $4 = side ('buy').
115INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source)
116 VALUES ($1, now(), $2, $3, $4, 'user');
117
118-- (g) settle the order itself — it has now actually been filled. $1 = order id.
119UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1;
120
121COMMIT;
122}}}
123
124A buy never reserves crypto (only a sell does, see
125[wiki:UseCase0005Implementation UseCase0005]), so `holdings.reserved_quantity` is not
126touched and stays 0.
127
128 7. '''System''' confirms
129 `Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)` and shows
130 the authenticated menu again.
131
132The screenshot shows steps 5–7: the entered quantity, the confirmation and the menu.
133
134[[Image(uc0004_5_7_executed.png)]]
135
136=== Verification — portfolio after the buy ===
137
138Right after the buy the Trader chooses `[6] View portfolio`
139([wiki:UseCase0006Implementation UseCase0006]). It shows the new holding
140`BTC 0.0100` with average buy price and current price 67140.000000 (value 671.4000),
141the unchanged `ETH 0.5000` (average 3500, current 3520, unrealised P/L +10.0000),
142`Cash available : 7578.6000 USD` (= 8250.00 − 671.40), portfolio value 2431.4000 and
143net worth 10010.0000 USD. The `Reserved` column is 0.0000 on both rows — a buy never
144reserves anything.
145
146[[Image(uc0004_verify_portfolio.png)]]
147
148=== Alternate flow 6a — insufficient funds ===
149
150User `charlie` (seed data: 2500.00 USD available, no crypto) chooses `[4]`, picks
151market `2` (BTC, 67140.000000) and enters quantity `1`. Go computes the notional
15267140.00. In the transaction, statement (a) inserts the `open` order and statement (b)
153`SELECT available_balance FROM users WHERE id = $1 FOR UPDATE` returns 2500.00, which
154is less than the notional. `PlaceOrder` prints
155`Insufficient funds: need 67140.0000, have 2500.0000` and returns without running
156(c)–(g); the deferred `tx.Rollback()` undoes statement (a), so no order, no ledger
157entry and no balance change is left behind (after this run charlie has no row in
158`orders` and still 2500.00 USD available). The authenticated menu is shown again.
159
160[[Image(uc0004_6a_insufficient.png)]]
161
162=== Alternate flow 3a — number not in the list ===
163
164If in step 3 the Trader enters something that is not a number from 1 to the number of
165listed markets, `pickNumber` prints `Invalid choice, enter a number from 1 to 5.`, no
166further SQL is run and the authenticated menu is shown again (the same check is shown
167in [wiki:UseCase0007Implementation UseCase0007], alternate flow 12a). Likewise, a
168quantity that is not a positive number in step 5 prints `Invalid quantity.` before any
169transaction is opened.
170
171== Source code ==
172
173`server/trade.go` — `PlaceOrder` (buy and sell) and `upsertHoldingOnBuy`:
174
175{{{
176// PlaceOrder - UC0004 (buy) / UC0005 (sell)
177// Market order that executes immediately against the latest price.
178// Runs inside a single database transaction so the orders, holdings,
179// users.balance and transactions tables always agree.
180//
181// The order still passes through 'open' before 'executed'. Placing it
182// reserves whatever it commits — on a sell, the crypto being sold, tracked in
183// holdings.reserved_quantity — before anything is actually moved, so a
184// second order against the same holding can never be granted the same units
185// twice. Because only market orders are implemented, reserve and settle
186// happen inside this one transaction rather than across two commits; a
187// future limit-order matcher would split them into a second transaction
188// later, without needing a schema change.
189func PlaceOrder(s *Session, side string) {
190 if side != "buy" && side != "sell" {
191 fmt.Println("Invalid side.")
192 return
193 }
194 fmt.Printf("\n-- Place market %s order --\n", side)
195
196 // buy: any market; sell: only what the user holds and can still sell
197 var m *Market
198 var err error
199 if side == "buy" {
200 m, err = ChooseMarket()
201 } else {
202 m, err = ChooseHolding(s)
203 }
204 if err != nil {
205 fmt.Println(err)
206 return
207 }
208 price, err := LatestPrice(m.ID)
209 if err != nil {
210 fmt.Println(err)
211 return
212 }
213 fmt.Printf("Latest price for %s/%s = %.6f\n", m.Symbol, m.Quote, price)
214
215 qtyStr := prompt("Quantity: ")
216 qty, err := strconv.ParseFloat(qtyStr, 64)
217 if err != nil || qty <= 0 {
218 fmt.Println("Invalid quantity.")
219 return
220 }
221 notional := qty * price
222
223 tx, err := db.DB.Begin()
224 if err != nil {
225 fmt.Println("Error:", err)
226 return
227 }
228 defer tx.Rollback()
229
230 // 1. record the order as 'open' — no trade has happened yet.
231 var orderID string
232 err = tx.QueryRow(
233 `INSERT INTO orders (user_id, market_id, side, type, status, quantity, price)
234 VALUES ($1, $2, $3, 'market', 'open', $4, $5)
235 RETURNING id`,
236 s.UserID, m.ID, side, qty, price,
237 ).Scan(&orderID)
238 if err != nil {
239 fmt.Println("Error creating order:", err)
240 return
241 }
242
243 if side == "buy" {
244 // check balance
245 var avail float64
246 if err := tx.QueryRow(
247 `SELECT available_balance FROM users WHERE id = $1 FOR UPDATE`,
248 s.UserID).Scan(&avail); err != nil {
249 fmt.Println("Error:", err)
250 return
251 }
252 if avail < notional {
253 fmt.Printf("Insufficient funds: need %.4f, have %.4f\n", notional, avail)
254 return
255 }
256
257 // debit balance
258 if _, err := tx.Exec(
259 `UPDATE users
260 SET available_balance = available_balance - $1,
261 invested_balance = invested_balance + $1,
262 updated_at = now()
263 WHERE id = $2`,
264 notional, s.UserID,
265 ); err != nil {
266 fmt.Println("Error:", err)
267 return
268 }
269
270 // a buy never reserves crypto, only ever adds it — upsert holding
271 // with running weighted average
272 if err := upsertHoldingOnBuy(tx, s.UserID, m.CryptoID, qty, price); err != nil {
273 fmt.Println("Error updating holding:", err)
274 return
275 }
276
277 // ledger entry
278 if _, err := tx.Exec(
279 `INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
280 VALUES ($1, 'buy', $2, 'USD', $3, $4)`,
281 s.UserID, -notional, orderID,
282 fmt.Sprintf("Market buy %.4f %s @ %.6f", qty, m.Symbol, price),
283 ); err != nil {
284 fmt.Println("Error:", err)
285 return
286 }
287 } else {
288 // sell: lock the holding and check what is actually free to sell —
289 // quantity minus whatever another open order has already reserved.
290 var held, reserved, avgPrice float64
291 err := tx.QueryRow(
292 `SELECT quantity, reserved_quantity, avg_price FROM holdings
293 WHERE user_id = $1 AND crypto_id = $2 FOR UPDATE`,
294 s.UserID, m.CryptoID,
295 ).Scan(&held, &reserved, &avgPrice)
296 if err != nil && err != sql.ErrNoRows {
297 fmt.Println("Error:", err)
298 return
299 }
300 available := held - reserved
301 if err == sql.ErrNoRows || available < qty {
302 fmt.Printf("Insufficient holding: trying to sell %.4f, available %.4f (of %.4f held, %.4f reserved)\n",
303 qty, available, held, reserved)
304 return
305 }
306
307 // reserve: committed to this order, not yet removed from the position.
308 if _, err := tx.Exec(
309 `UPDATE holdings
310 SET reserved_quantity = reserved_quantity + $1,
311 updated_at = now()
312 WHERE user_id = $2 AND crypto_id = $3`,
313 qty, s.UserID, m.CryptoID,
314 ); err != nil {
315 fmt.Println("Error:", err)
316 return
317 }
318
319 // settle: a market order fills immediately, so release the
320 // reservation and remove the asset from the position in one step.
321 if _, err := tx.Exec(
322 `UPDATE holdings
323 SET quantity = quantity - $1,
324 reserved_quantity = reserved_quantity - $1,
325 updated_at = now()
326 WHERE user_id = $2 AND crypto_id = $3`,
327 qty, s.UserID, m.CryptoID,
328 ); err != nil {
329 fmt.Println("Error:", err)
330 return
331 }
332
333 // credit balance; reduce invested by cost basis (avg_price * qty)
334 costBasis := avgPrice * qty
335 if _, err := tx.Exec(
336 `UPDATE users
337 SET available_balance = available_balance + $1,
338 invested_balance = GREATEST(invested_balance - $2, 0),
339 updated_at = now()
340 WHERE id = $3`,
341 notional, costBasis, s.UserID,
342 ); err != nil {
343 fmt.Println("Error:", err)
344 return
345 }
346
347 // ledger entry
348 if _, err := tx.Exec(
349 `INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
350 VALUES ($1, 'sell', $2, 'USD', $3, $4)`,
351 s.UserID, notional, orderID,
352 fmt.Sprintf("Market sell %.4f %s @ %.6f", qty, m.Symbol, price),
353 ); err != nil {
354 fmt.Println("Error:", err)
355 return
356 }
357 }
358
359 // record the resulting market trade so the book reflects this fill
360 if _, err := tx.Exec(
361 `INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source)
362 VALUES ($1, now(), $2, $3, $4, 'user')`,
363 m.ID, price, qty, side,
364 ); err != nil {
365 fmt.Println("Error:", err)
366 return
367 }
368
369 // settle the order itself: it has now actually been filled.
370 if _, err := tx.Exec(
371 `UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1`,
372 orderID,
373 ); err != nil {
374 fmt.Println("Error:", err)
375 return
376 }
377
378 if err := tx.Commit(); err != nil {
379 fmt.Println("Commit error:", err)
380 return
381 }
382 fmt.Printf("Order executed: %s %.4f %s @ %.6f (notional %.4f USD)\n",
383 side, qty, m.Symbol, price, notional)
384}
385
386// upsertHoldingOnBuy creates or updates a holding using running weighted-average price.
387//
388// This is a single statement that relies on UNIQUE (user_id, crypto_id): the new
389// weighted average is recomputed by the database in numeric arithmetic rather
390// than in Go float64, and no separate SELECT ... FOR UPDATE round-trip is
391// needed because ON CONFLICT DO UPDATE locks the conflicting row itself.
392// Every SET expression sees the pre-update row, so `holdings.quantity` below is
393// still the old quantity while the average is being computed.
394func upsertHoldingOnBuy(tx *sql.Tx, userID, cryptoID string, qty, price float64) error {
395 _, err := tx.Exec(
396 `INSERT INTO holdings (user_id, crypto_id, quantity, avg_price, updated_at)
397 VALUES ($1, $2, $3, $4, now())
398 ON CONFLICT (user_id, crypto_id) DO UPDATE
399 SET avg_price = (holdings.quantity * holdings.avg_price
400 + EXCLUDED.quantity * EXCLUDED.avg_price)
401 / (holdings.quantity + EXCLUDED.quantity),
402 quantity = holdings.quantity + EXCLUDED.quantity,
403 updated_at = now()`,
404 userID, cryptoID, qty, price,
405 )
406 return err
407}
408}}}
409
410`server/market.go` — `ListMarkets`, `pickNumber`, `ChooseMarket` and `LatestPrice`:
411
412{{{
413// ListMarkets prints all active markets, numbered, with their latest price,
414// and returns them in the printed order so a caller can pick one by number.
415func ListMarkets() []Market {
416 rows, err := db.DB.Query(`
417 SELECT m.id, c.id, c.symbol, m.quote_currency,
418 COALESCE(lp.price, 0) AS price
419 FROM markets m
420 JOIN crypto c ON c.id = m.crypto_id
421 LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
422 WHERE m.is_active = true
423 ORDER BY c.symbol`)
424 if err != nil {
425 fmt.Println("Error:", err)
426 return nil
427 }
428 defer rows.Close()
429
430 fmt.Println()
431 fmt.Printf(" %-4s %-8s %-5s %15s\n", "#", "Symbol", "Quote", "Last price")
432 fmt.Println(" -----------------------------------------")
433 var list []Market
434 for rows.Next() {
435 var m Market
436 var price float64
437 if err := rows.Scan(&m.ID, &m.CryptoID, &m.Symbol, &m.Quote, &price); err != nil {
438 fmt.Println("scan error:", err)
439 return nil
440 }
441 list = append(list, m)
442 fmt.Printf(" %-4d %-8s %-5s %15.6f\n", len(list), m.Symbol, m.Quote, price)
443 }
444 return list
445}
446
447// pickNumber reads a 1-based choice from a list of n items.
448func pickNumber(label string, n int) (int, error) {
449 if n == 0 {
450 return 0, fmt.Errorf("Nothing to choose from.")
451 }
452 k, err := strconv.Atoi(prompt(label))
453 if err != nil || k < 1 || k > n {
454 return 0, fmt.Errorf("Invalid choice, enter a number from 1 to %d.", n)
455 }
456 return k - 1, nil
457}
458
459// ChooseMarket lists the active markets and lets the user pick one by its
460// number in the list.
461func ChooseMarket() (*Market, error) {
462 list := ListMarkets()
463 k, err := pickNumber("Market #: ", len(list))
464 if err != nil {
465 return nil, err
466 }
467 return &list[k], nil
468}
469
470// LatestPrice returns the last traded price on a market.
471func LatestPrice(marketID string) (float64, error) {
472 var price float64
473 err := db.DB.QueryRow(
474 `SELECT price FROM v_latest_prices WHERE market_id = $1`, marketID,
475 ).Scan(&price)
476 if err == sql.ErrNoRows {
477 return 0, fmt.Errorf("no trades yet for this market")
478 }
479 return price, err
480}
481}}}
Note: See TracBrowser for help on using the repository browser.