Changeset ef1c1c7 for docs/P4-Prototype/UseCase0005Implementation.md
- Timestamp:
- 09/24/26 17:43:19 (6 days ago)
- Branches:
- main
- Children:
- 0cee8ec
- Parents:
- a531b45
- File:
-
- 1 edited
-
docs/P4-Prototype/UseCase0005Implementation.md (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
docs/P4-Prototype/UseCase0005Implementation.md
ra531b45 ref1c1c7 1 # Use-case 0005 Implementation — Sell 2 3 **Initiating actor:** Trader. **Source file:** `server/trade.go`, function `PlaceOrder(s, "sell")`. 4 5 ## The bug this closes 6 7 Before this change, `holdings` had `quantity` and `avg_price` only. The sell 8 path checked `held < qty` straight against `quantity`, which cannot tell 9 "owned" apart from "owned, but already committed to another order that has 10 not settled." `holdings.reserved_quantity` fixes that: the crypto being sold 11 is reserved before it is removed from the position, and the check is against 12 `quantity - reserved_quantity`. 13 14 ## Scenario (implemented) 15 16 1. **User** chooses `[5] Place market SELL order`. 17 2. **System** lists markets (same as UC0004 step 2). 18 3. **User** enters market symbol, e.g. `ETH`, then quantity `0.5`. 19 4. **System** opens a transaction and runs: 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 7 A logged-in Trader sells part or all of a holding at the current market price. The 8 Trader never types a symbol: the system lists only the cryptos the Trader holds and can 9 still sell (the quantity not already reserved by an open sell order), numbered, with 10 how much is held and how much is free, and the Trader picks one by its number and 11 enters the quantity. In one database transaction the system records the order, 12 reserves the crypto being sold and settles it, credits the proceeds to the Trader's 13 available cash while reducing the invested cash by the cost basis, writes a ledger 14 entry and a market trade, and marks the order executed. Cost basis is preserved, so 15 the realised P/L can be reconstructed from the ledger. 16 17 Original use-case description (P3): [UseCase0005](../P3-UseCaseModel/UseCase0005.md). 18 Implementation: [`server/trade.go`](../../server/trade.go), function 19 `PlaceOrder(s, "sell")`, which calls `ChooseHolding`, `pickNumber` and `LatestPrice` 20 from [`server/market.go`](../../server/market.go). 21 22 All 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 24 Go code; only the Go source indentation is removed, a `;` is added after each 25 statement of the transaction, and `--` comments say what each `$n` placeholder is 26 bound to. 27 28 The run shown is user `alice` right after the buy of 29 [UseCase0004](UseCase0004Implementation.md): 7578.60 USD available, holdings 30 0.01 BTC (bought at 67140) and 0.5 ETH (bought at 3500). She sells 0.2 ETH. 31 32 ## Reserve, then settle 33 34 The crypto being sold is **reserved** (`holdings.reserved_quantity`) before it is 35 removed 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 37 crypto already promised to another order. Because only market orders are implemented, 38 an order settles in the same transaction it is placed in, so reserve and settle are two 39 statements inside one commit; they stay logically distinct so that a future 40 limit-order matcher, where an order would stay `open` until a *later* transaction fills 41 it, 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 ```sql 52 SELECT 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 64 For 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 66 market id and crypto id in memory; the Trader only types the list number. (If the 67 query returns no row, the system prints `you hold no crypto that is free to sell` 68 and the use-case ends.) 69 70  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 ```sql 77 SELECT price FROM v_latest_prices WHERE market_id = $1 78 ``` 79 80 It prints `Latest price for ETH/USD = 3520.000000` and asks `Quantity:`. 81 82  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. 20 90 21 91 ```sql 22 92 BEGIN; 23 93 24 -- (a) record the order as 'open' — no trade has happened yet 25 INSERT INTO orders 26 (user_id, market_id, side, type, status, quantity, price) 27 VALUES 28 ($1, $2, 'sell', 'market', 'open', $3, $4) 29 RETURNING id; 30 31 -- (b) lock the holding and check what is actually free to sell 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. 97 INSERT 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. 32 105 SELECT quantity, reserved_quantity, avg_price FROM holdings 33 WHERE user_id = $1 AND crypto_id = $c FOR UPDATE; 34 -- available := quantity - reserved_quantity 35 -- abort if missing or available < $qty 36 37 -- (c) reserve: committed to this order, not yet removed from the position 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. 38 110 UPDATE holdings 39 SET reserved_quantity = reserved_quantity + $qty, updated_at = now() 40 WHERE user_id = $1 AND crypto_id = $c; 41 42 -- (d) settle: a market order fills immediately, so release the 43 -- reservation and remove the asset in the same step 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). 44 117 UPDATE holdings 45 SET quantity = quantity - $qty, 46 reserved_quantity = reserved_quantity - $qty, 47 updated_at = now() 48 WHERE user_id = $1 AND crypto_id = $c; 49 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. 50 125 UPDATE users 51 SET available_balance = available_balance + $notional,52 invested_balance = GREATEST(invested_balance - ($avg * $qty), 0),53 updated_at = now()54 WHERE id = $1;55 56 INSERT INTO transactions57 (user_id, type, amount, currency, related_order, description)58 VALUES59 ($1, 'sell', $notional, 'USD', $orderId, 'Market sell ...');60 61 INSERT INTO market_trades62 (market_id, executed_at, price, quantity, side, source)63 VALUES64 ($2, now(), $price, $qty, 'sell', 'user');65 66 -- ( e) settle the order itself — it has now actually been filled67 UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $ orderId;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'. 133 INSERT 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'). 138 INSERT 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. 142 UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1; 68 143 69 144 COMMIT; 70 145 ``` 71 146 72  73 74 5. **System** prints: `Order executed: sell 0.5000 ETH @ 3520.000000 (notional 1760.0000 USD)`. 75 76 ## Failure path — insufficient holding 77 78 If the holding does not exist, or `quantity - reserved_quantity < requested`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above — including the `open` order, which was never committed — and the user sees: 79 80 ``` 81 Insufficient holding: trying to sell X, available Y (of Z held, W reserved) 82 ``` 83 84 ## Verified run — the exact scenario from the design review 85 86 Run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`). 87 Alice's ETH/BTC holdings were seeded, then her BTC holding was set to exactly 88 the scenario that motivated this fix: 2 BTC owned, nothing reserved. 89 90 ``` 91 $ psql ... -c "SELECT symbol, quantity, reserved_quantity, avg_price 92 FROM holdings h JOIN crypto c ON c.id = h.crypto_id 93 WHERE user_id = '<alice>';" 94 95 symbol | quantity | reserved_quantity | avg_price 96 --------+----------+--------------------+------------- 97 BTC | 2.0000 | 0.0000 | 65000.000000 98 ETH | 0.5000 | 0.0000 | 3500.000000 99 ``` 100 101 **Step 1 — portfolio before the sell** (`[6] View portfolio`): 102 103 ``` 104 Symbol Quantity Reserved Available Avg buy Current Value Unrealised P/L 105 ------------------------------------------------------------------------------------------------------------------ 106 BTC 2.0000 0.0000 2.0000 65000.000000 67140.000000 134280.0000 +4280.0000 107 ETH 0.5000 0.0000 0.5000 3500.000000 3520.000000 1760.0000 +10.0000 108 ------------------------------------------------------------------------------------------------------------------ 109 TOTAL 136040.0000 +4290.0000 110 ``` 111 112 **Step 2 — `[5] Place market SELL order` → `BTC` → `0.5`:** 113 114 ``` 115 Order executed: sell 0.5000 BTC @ 67140.000000 (notional 33570.0000 USD) 116 ``` 117 118 **Step 3 — portfolio after the sell:** 119 120 ``` 121 BTC 1.5000 0.0000 1.5000 65000.000000 67140.000000 100710.0000 +3210.0000 122 ``` 123 124 `quantity` dropped from 2.0 to 1.5 and `reserved_quantity` is back to 0.0000 125 — reserve and settle both happened, inside the one commit, exactly as 126 designed. 127 128 ## Verified run — reserve and settle as two distinct, observable steps 129 130 The CLI settles a market order in the same transaction it reserves in, so 131 `reserved_quantity` is never visibly nonzero *outside* a transaction. Run by 132 hand in one `psql` session (one transaction, so the session sees its own 133 uncommitted writes) to show the intermediate state that step (c) alone would 134 leave, before step (d) runs: 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 151 The screenshot shows steps 5–7: the entered quantity, the confirmation and the menu. 152 153  154 155 After 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 158 amount 704.0000 and description `Market sell 0.2000 ETH @ 3520.000000`; and the order 159 with status `executed`. The realised P/L of this sell is notional − cost basis = 160 704.00 − 700.00 = +4.00 USD. 161 162 ### Alternate flow 5a — insufficient holding 163 164 Right 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`. 166 In the transaction, statement (a) inserts the `open` order and statement (b) returns 167 quantity 0.3000 and reserved_quantity 0.0000, so available = 0.3 < 5. `PlaceOrder` 168 prints 169 170 ``` 171 Insufficient holding: trying to sell 5.0000, available 0.3000 (of 0.3000 held, 0.0000 reserved) 172 ``` 173 174 and returns without running (c)–(h); the deferred `tx.Rollback()` undoes statement (a) 175 as well, so no order, no reservation and no ledger entry is left behind. The 176 authenticated menu is shown again. The same message is printed if the holding row no 177 longer exists (for example because it was sold out from another session after the 178 list was shown). 179 180  181 182 ## Reserve and settle, step by step 183 184 The CLI reserves and settles inside one transaction, so `reserved_quantity` is never 185 nonzero *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 187 writes) against alice's ETH holding after the scenario above (0.3 ETH), for a sell of 188 0.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')`: 135 192 136 193 ```sql 137 194 BEGIN; 138 139 -- before: Alice owns 2 BTC, none reserved140 195 SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available 141 FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';196 FROM holdings WHERE user_id = :alice AND crypto_id = :eth; 142 197 -- quantity | reserved_quantity | available 143 -- ----------+--------------------+----------- 144 -- 2.0000 | 0.0000 | 2.0000 145 146 -- step (c): order placed, 0.5 BTC reserved — no trade has happened yet 147 UPDATE holdings SET reserved_quantity = reserved_quantity + 0.5, updated_at = now() 148 WHERE user_id = '<alice>' AND crypto_id = '<btc>'; 149 198 -- ----------+-------------------+----------- 199 -- 0.3000 | 0.0000 | 0.3000 200 201 -- (c) reserve 0.1: the order is placed, no trade has happened yet 202 UPDATE holdings SET reserved_quantity = reserved_quantity + 0.1, updated_at = now() 203 WHERE user_id = :alice AND crypto_id = :eth; 150 204 SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available 151 FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';205 FROM holdings WHERE user_id = :alice AND crypto_id = :eth; 152 206 -- quantity | reserved_quantity | available 153 -- ----------+--------------------+----------- 154 -- 2.0000 | 0.5000 | 1.5000 155 156 -- step (d): market order settles immediately, reservation released 157 UPDATE holdings SET quantity = quantity - 0.5, reserved_quantity = reserved_quantity - 0.5, updated_at = now() 158 WHERE user_id = '<alice>' AND crypto_id = '<btc>'; 159 207 -- ----------+-------------------+----------- 208 -- 0.3000 | 0.1000 | 0.2000 209 210 -- (d) settle: the reservation is released and the asset removed 211 UPDATE holdings SET quantity = quantity - 0.1, reserved_quantity = reserved_quantity - 0.1, updated_at = now() 212 WHERE user_id = :alice AND crypto_id = :eth; 160 213 SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available 161 FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';214 FROM holdings WHERE user_id = :alice AND crypto_id = :eth; 162 215 -- quantity | reserved_quantity | available 163 -- ----------+--------------------+----------- 164 -- 1.5000 | 0.0000 | 1.5000 165 166 COMMIT; 167 ``` 168 169 This is the row that would stay visible to every other connection for as long 170 as the order stayed `open` — i.e. for as long as it took a matcher to fill 171 it, once limit orders exist. 172 173 ## Verified run — two concurrent sells, which is the bug itself 174 175 The scenario the design review described: a user should not be able to place 176 two sell orders whose combined quantity exceeds what they actually hold. With 177 Alice's BTC holding at 1.5 BTC (0 reserved), two independent CLI processes 178 were started at the same instant, each selling `1.0 BTC` — together 2.0 BTC, 179 more than she has: 180 181 ``` 182 $ ( eduberza-sell-1.0-BTC ) & # process A 183 $ ( eduberza-sell-1.0-BTC ) & # process B 184 $ wait 185 186 === A === 187 Insufficient holding: trying to sell 1.0000, available 0.5000 (of 0.5000 held, 0.0000 reserved) 188 === B === 189 Order executed: sell 1.0000 BTC @ 67140.000000 (notional 67140.0000 USD) 190 191 === final holding === 192 quantity | reserved_quantity 193 ----------+-------------------- 194 0.5000 | 0.0000 195 ``` 196 197 One order settled, one was correctly rejected, and the final `quantity` 198 (0.5) is consistent with exactly one 1.0 BTC sell having happened against the 199 1.5 BTC available — not both, and not neither. This is enforced by the 200 `SELECT ... FOR UPDATE` lock on the holdings row: whichever transaction gets 201 there second blocks until the first commits, then re-reads the now-current 202 `quantity`/`reserved_quantity` before deciding. 203 204 ## Verified — the constraint holds even if application code did not 205 206 ```sql 207 UPDATE holdings SET reserved_quantity = quantity + 1 WHERE user_id = '<alice>' AND crypto_id = '<btc>'; 208 216 -- ----------+-------------------+----------- 217 -- 0.2000 | 0.0000 | 0.2000 218 ROLLBACK; 219 ``` 220 221 The middle state is what every other connection would see for as long as an order 222 stayed `open` once limit orders exist: 0.1 ETH still owned but no longer free to sell. 223 224 ## Two concurrent sells 225 226 A Trader must not be able to sell the same units twice from two sessions at once. Both 227 sessions may have listed the holding as free (step 2 runs outside the transaction), so 228 the protection is statement (b): `SELECT ... FOR UPDATE` locks the holding row, and a 229 second transaction that reaches (b) waits until the first one commits, then reads the 230 already reduced `quantity` before deciding. 231 232 This was checked with two `psql` sessions running statements (b)–(d) against alice's 233 0.3 ETH. Session A locked the row, reserved and settled 0.2 ETH and committed after a 234 3-second pause; session B asked for the lock one second after A had taken it: 235 236 ``` 237 A: SELECT ... FOR UPDATE -> quantity 0.3000, reserved_quantity 0.0000 238 A: reserve 0.2, settle 0.2, pg_sleep(3) 239 B: 11:43:54 SELECT ... FOR UPDATE -- blocks, A holds the row lock 240 A: 11:43:56 COMMIT 241 B: 11:43:56 lock granted -> quantity 0.1000, reserved_quantity 0.0000 242 ``` 243 244 Session B was blocked for the two seconds until A committed and then saw only 245 0.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 247 back 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 254 database level, independently of `trade.go` (run inside a transaction that was 255 rolled back): 256 257 ``` 258 UPDATE holdings SET reserved_quantity = quantity + 1 WHERE user_id = :alice AND crypto_id = :eth; 209 259 ERROR: new row for relation "holdings" violates check constraint "holdings_check" 210 260 ``` 211 212 `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` in213 `schema_creation.sql` makes an inconsistent reservation impossible at the214 database level, independent of `trade.go`.
Note:
See TracChangeset
for help on using the changeset viewer.
