| 1 | # Use-case 0004 Implementation — Buy
|
|---|
| 2 |
|
|---|
| 3 | **Initiating actor:** Trader. **Source file:** `server/trade.go`, function `PlaceOrder(s, "buy")`.
|
|---|
| 4 |
|
|---|
| 5 | ## Scenario (implemented)
|
|---|
| 6 |
|
|---|
| 7 | 1. **User** chooses `[4] Place market BUY order`.
|
|---|
| 8 | 2. **System** lists markets with their latest price (same SQL as UC0006 — via the `v_latest_prices` view).
|
|---|
| 9 |
|
|---|
| 10 |
|
|---|
| 11 | 3. **User** enters `BTC`.
|
|---|
| 12 | 4. **System** resolves the market and fetches the latest price:
|
|---|
| 13 |
|
|---|
| 14 | ```sql
|
|---|
| 15 | SELECT m.id, c.id, c.symbol, m.quote_currency
|
|---|
| 16 | FROM markets m
|
|---|
| 17 | JOIN crypto c ON c.id = m.crypto_id
|
|---|
| 18 | WHERE upper(c.symbol) = upper($1) AND m.is_active;
|
|---|
| 19 |
|
|---|
| 20 | SELECT price FROM v_latest_prices WHERE market_id = $2;
|
|---|
| 21 | ```
|
|---|
| 22 |
|
|---|
| 23 | 5. **User** enters quantity `0.01`.
|
|---|
| 24 | 6. **System** executes a single database transaction — *all or nothing*:
|
|---|
| 25 |
|
|---|
| 26 | ```sql
|
|---|
| 27 | BEGIN;
|
|---|
| 28 |
|
|---|
| 29 | -- (a) record the order as 'open' — no trade has happened yet
|
|---|
| 30 | INSERT INTO orders
|
|---|
| 31 | (user_id, market_id, side, type, status, quantity, price)
|
|---|
| 32 | VALUES
|
|---|
| 33 | ($1, $2, 'buy', 'market', 'open', $3, $4)
|
|---|
| 34 | RETURNING id;
|
|---|
| 35 |
|
|---|
| 36 | -- (b) lock and check the user balance
|
|---|
| 37 | SELECT available_balance FROM users WHERE id = $1 FOR UPDATE;
|
|---|
| 38 |
|
|---|
| 39 | -- (c) move cash from available to invested. A buy never reserves crypto
|
|---|
| 40 | -- the way a sell does (see UC0005) — it only ever adds to the
|
|---|
| 41 | -- position, so there is nothing to commit on the holdings side
|
|---|
| 42 | -- before settling.
|
|---|
| 43 | UPDATE users
|
|---|
| 44 | SET available_balance = available_balance - $notional,
|
|---|
| 45 | invested_balance = invested_balance + $notional,
|
|---|
| 46 | updated_at = now()
|
|---|
| 47 | WHERE id = $1;
|
|---|
| 48 |
|
|---|
| 49 | -- (d) upsert the holding, recomputing the weighted-average entry price
|
|---|
| 50 | -- in one statement. Every SET expression sees the pre-update row, so
|
|---|
| 51 | -- holdings.quantity below is still the old quantity.
|
|---|
| 52 | -- reserved_quantity is untouched by a buy and defaults to 0.
|
|---|
| 53 | INSERT INTO holdings (user_id, crypto_id, quantity, avg_price, updated_at)
|
|---|
| 54 | VALUES ($1, $c, $3, $4, now())
|
|---|
| 55 | ON CONFLICT (user_id, crypto_id) DO UPDATE
|
|---|
| 56 | SET avg_price = (holdings.quantity * holdings.avg_price
|
|---|
| 57 | + EXCLUDED.quantity * EXCLUDED.avg_price)
|
|---|
| 58 | / (holdings.quantity + EXCLUDED.quantity),
|
|---|
| 59 | quantity = holdings.quantity + EXCLUDED.quantity,
|
|---|
| 60 | updated_at = now();
|
|---|
| 61 |
|
|---|
| 62 | -- (e) ledger entry
|
|---|
| 63 | INSERT INTO transactions
|
|---|
| 64 | (user_id, type, amount, currency, related_order, description)
|
|---|
| 65 | VALUES
|
|---|
| 66 | ($1, 'buy', -$notional, 'USD', $orderId, 'Market buy ...');
|
|---|
| 67 |
|
|---|
| 68 | -- (f) record the resulting market trade
|
|---|
| 69 | INSERT INTO market_trades
|
|---|
| 70 | (market_id, executed_at, price, quantity, side, source)
|
|---|
| 71 | VALUES
|
|---|
| 72 | ($2, now(), $4, $3, 'buy', 'user');
|
|---|
| 73 |
|
|---|
| 74 | -- (g) settle the order itself — it has now actually been filled
|
|---|
| 75 | UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $orderId;
|
|---|
| 76 |
|
|---|
| 77 | COMMIT;
|
|---|
| 78 | ```
|
|---|
| 79 |
|
|---|
| 80 | 7. **System** prints: `Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)`.
|
|---|
| 81 |
|
|---|
| 82 | 
|
|---|
| 83 |
|
|---|
| 84 | ## Verified run (from actual prototype execution)
|
|---|
| 85 |
|
|---|
| 86 | Re-run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`) with freshly loaded seed data:
|
|---|
| 87 |
|
|---|
| 88 | - **Before:** alice.available_balance = 8250.00, portfolio = { ETH: 0.5000, reserved 0.0000 }.
|
|---|
| 89 | - **Command:** `buy 0.01 BTC`.
|
|---|
| 90 | - **After:** alice.available_balance = 7578.60 (= 8250 − 671.40), portfolio = { BTC: 0.0100 @ 67140 (reserved 0.0000), ETH: 0.5000 @ 3500 (reserved 0.0000) }, net worth = 10010.00 USD (the +10 is the ETH unrealised P/L from the price moving from 3500 → 3520). A buy never sets `reserved_quantity`, so it reads 0 on every row here.
|
|---|
| 91 |
|
|---|
| 92 | ## Failure path — insufficient funds
|
|---|
| 93 |
|
|---|
| 94 | If `available_balance < notional`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above and the user sees:
|
|---|
| 95 |
|
|---|
| 96 | ```
|
|---|
| 97 | Insufficient funds: need X, have Y
|
|---|
| 98 | ```
|
|---|