Changes between Initial Version and Version 1 of UseCase0004Implementation


Ignore:
Timestamp:
09/24/26 14:02:48 (4 days ago)
Author:
231285
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • UseCase0004Implementation

    v1 v1  
     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}}}