| | 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 | |
| | 7 | A logged-in Trader buys a crypto asset at the current market price. The Trader never |
| | 8 | types a symbol or an identifier: the system lists the active markets with their last |
| | 9 | price, numbered, and the Trader picks one by its number and then enters only the |
| | 10 | quantity. The system checks that the Trader has enough available cash for |
| | 11 | quantity × price and then, in one database transaction, records the order, moves the |
| | 12 | cash from available to invested, adds the crypto to the Trader's holding (recomputing |
| | 13 | the weighted-average entry price), writes a ledger entry and a market trade, and marks |
| | 14 | the 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 |
| | 16 | back. |
| | 17 | |
| | 18 | Original use-case description (P3): [wiki:UseCase0004]. |
| | 19 | Implementation: `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 | |
| | 24 | All statements run on the `project` schema: the connection sets |
| | 25 | `search_path=project,public` (`server/db/db.go`), so `orders` means `project.orders`. |
| | 26 | The SQL below is copied from the Go code; only the Go source indentation is removed, |
| | 27 | a `;` is added after each statement of the transaction, and `--` comments say what |
| | 28 | each `$n` placeholder is bound to. |
| | 29 | |
| | 30 | The run shown is user `alice` on the seed data (available 8250.00 USD, holding |
| | 31 | 0.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 | {{{ |
| | 40 | SELECT 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 | |
| | 49 | The rows are printed in this order as `1 ADA`, `2 BTC`, `3 DOGE`, `4 ETH`, `5 SOL`; |
| | 50 | Go keeps each row's market id and crypto id in memory, so the Trader only ever sees |
| | 51 | and 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 | {{{ |
| | 61 | SELECT price FROM v_latest_prices WHERE market_id = $1 |
| | 62 | }}} |
| | 63 | |
| | 64 | It 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 | {{{ |
| | 74 | BEGIN; |
| | 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. |
| | 79 | INSERT 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. |
| | 85 | SELECT 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. |
| | 89 | UPDATE 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). |
| | 99 | INSERT 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'. |
| | 110 | INSERT 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'). |
| | 115 | INSERT 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. |
| | 119 | UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1; |
| | 120 | |
| | 121 | COMMIT; |
| | 122 | }}} |
| | 123 | |
| | 124 | A buy never reserves crypto (only a sell does, see |
| | 125 | [wiki:UseCase0005Implementation UseCase0005]), so `holdings.reserved_quantity` is not |
| | 126 | touched 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 | |
| | 132 | The 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 | |
| | 138 | Right 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), |
| | 141 | the 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 |
| | 143 | net worth 10010.0000 USD. The `Reserved` column is 0.0000 on both rows — a buy never |
| | 144 | reserves anything. |
| | 145 | |
| | 146 | [[Image(uc0004_verify_portfolio.png)]] |
| | 147 | |
| | 148 | === Alternate flow 6a — insufficient funds === |
| | 149 | |
| | 150 | User `charlie` (seed data: 2500.00 USD available, no crypto) chooses `[4]`, picks |
| | 151 | market `2` (BTC, 67140.000000) and enters quantity `1`. Go computes the notional |
| | 152 | 67140.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 |
| | 154 | is 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 |
| | 157 | entry 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 | |
| | 164 | If in step 3 the Trader enters something that is not a number from 1 to the number of |
| | 165 | listed markets, `pickNumber` prints `Invalid choice, enter a number from 1 to 5.`, no |
| | 166 | further SQL is run and the authenticated menu is shown again (the same check is shown |
| | 167 | in [wiki:UseCase0007Implementation UseCase0007], alternate flow 12a). Likewise, a |
| | 168 | quantity that is not a positive number in step 5 prints `Invalid quantity.` before any |
| | 169 | transaction 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. |
| | 189 | func 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. |
| | 394 | func 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. |
| | 415 | func 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. |
| | 448 | func 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. |
| | 461 | func 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. |
| | 471 | func 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 | }}} |