Changeset ef1c1c7 for server/trade.go
- Timestamp:
- 09/24/26 17:43:19 (5 days ago)
- Branches:
- main
- Children:
- 0cee8ec
- Parents:
- a531b45
- File:
-
- 1 edited
-
server/trade.go (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
server/trade.go
ra531b45 ref1c1c7 2 2 3 3 import ( 4 " database/sql"4 "errors" 5 5 "fmt" 6 6 "strconv" 7 "strings" 8 9 "github.com/lib/pq" 7 10 8 11 "bp_project/server/db" … … 10 13 11 14 // PlaceOrder - UC0004 (buy) / UC0005 (sell) 12 // Market order that executes immediately against the latest price. 13 // Runs inside a single database transaction so the orders, holdings, 14 // users.balance and transactions tables always agree. 15 // 16 // The order still passes through 'open' before 'executed'. Placing it 17 // reserves whatever it commits — on a sell, the crypto being sold, tracked in 18 // holdings.reserved_quantity — before anything is actually moved, so a 19 // second order against the same holding can never be granted the same units 20 // twice. Because only market orders are implemented, reserve and settle 21 // happen inside this one transaction rather than across two commits; a 22 // future limit-order matcher would split them into a second transaction 23 // later, without needing a schema change. 15 // Market or limit order. All the database work — checking free cash/crypto, 16 // reserving it, recording the order, matching it against the order book and 17 // filling the rest from the simulated market — is done by the P7 stored 18 // function project.place_order in one call, so it is one atomic statement 19 // and the P7 triggers keep orders, trades, holdings and balances consistent. 24 20 func PlaceOrder(s *Session, side string) { 25 21 if side != "buy" && side != "sell" { … … 27 23 return 28 24 } 29 fmt.Printf("\n-- Place market %s order --\n", side) 30 31 m, err := ChooseMarket() 25 fmt.Printf("\n-- Place %s order --\n", side) 26 27 // buy: any market; sell: only what the user holds and can still sell 28 var m *Market 29 var err error 30 if side == "buy" { 31 m, err = ChooseMarket() 32 } else { 33 m, err = ChooseHolding(s) 34 } 32 35 if err != nil { 33 36 fmt.Println(err) … … 40 43 } 41 44 fmt.Printf("Latest price for %s/%s = %.6f\n", m.Symbol, m.Quote, price) 42 43 qtyStr := prompt("Quantity: ") 44 qty, err := strconv.ParseFloat(qtyStr, 64) 45 printBookSide(m, side) 46 47 fmt.Println("[1] Market order (fills now at the best available price)") 48 fmt.Println("[2] Limit order (fills only at your price or better, otherwise waits in the order book)") 49 orderType := map[string]string{"1": "market", "2": "limit"}[prompt("> ")] 50 if orderType == "" { 51 fmt.Println("Unknown option.") 52 return 53 } 54 55 qty, err := strconv.ParseFloat(prompt("Quantity: "), 64) 45 56 if err != nil || qty <= 0 { 46 57 fmt.Println("Invalid quantity.") 47 58 return 48 59 } 49 notional := qty * price50 51 tx, err := db.DB.Begin()52 if err != nil{53 fmt.Println("Error:", err)54 return55 }56 defer tx.Rollback()57 58 // 1. record the order as 'open' — no trade has happened yet. 60 var limit optFloat 61 if orderType == "limit" { 62 p, err := strconv.ParseFloat(prompt("Limit price: "), 64) 63 if err != nil || p <= 0 { 64 fmt.Println("Invalid price.") 65 return 66 } 67 limit = optFloat{p, true} 68 } 69 59 70 var orderID string 60 err = tx.QueryRow( 61 `INSERT INTO orders (user_id, market_id, side, type, status, quantity, price) 62 VALUES ($1, $2, $3, 'market', 'open', $4, $5) 63 RETURNING id`, 64 s.UserID, m.ID, side, qty, price, 71 err = db.DB.QueryRow( 72 `SELECT place_order($1, $2, $3, $4, $5, $6)`, 73 s.UserID, m.ID, side, orderType, qty, limit.value(), 65 74 ).Scan(&orderID) 66 75 if err != nil { 67 fmt.Println("Error creating order:", err) 68 return 69 } 70 71 if side == "buy" { 72 // check balance 73 var avail float64 74 if err := tx.QueryRow( 75 `SELECT available_balance FROM users WHERE id = $1 FOR UPDATE`, 76 s.UserID).Scan(&avail); err != nil { 77 fmt.Println("Error:", err) 76 fmt.Println(dbMessage(err)) 77 return 78 } 79 80 var status string 81 var filled, remaining float64 82 var avg *float64 83 if err := db.DB.QueryRow( 84 `SELECT status, filled_quantity, remaining, avg_fill_price 85 FROM v_order_history WHERE order_id = $1`, orderID, 86 ).Scan(&status, &filled, &remaining, &avg); err != nil { 87 fmt.Println("Error:", err) 88 return 89 } 90 switch status { 91 case "executed": 92 fmt.Printf("Order executed: %s %.4f %s, average price %.6f\n", side, filled, m.Symbol, *avg) 93 case "partially_filled": 94 fmt.Printf("Order partially filled: %.4f %s at average %.6f, %.4f waiting in the order book\n", 95 filled, m.Symbol, *avg, remaining) 96 default: 97 fmt.Printf("Order placed in the order book: %s %.4f %s at %.6f\n", side, remaining, m.Symbol, limit.v) 98 } 99 } 100 101 // optFloat is an optional float parameter (NULL when not set). 102 type optFloat struct { 103 v float64 104 ok bool 105 } 106 107 func (n optFloat) value() any { 108 if !n.ok { 109 return nil 110 } 111 return n.v 112 } 113 114 // dbMessage shows a rule the database refused (P7 raises check_violation 115 // with a readable message) without the driver's prefix. 116 func dbMessage(err error) string { 117 var pqErr *pq.Error 118 if errors.As(err, &pqErr) && pqErr.Code.Class() == "23" { 119 return "Rejected: " + pqErr.Message 120 } 121 return "Error: " + err.Error() 122 } 123 124 // printBookSide shows the best resting orders on the side this order would 125 // trade against (asks for a buy, bids for a sell). 126 func printBookSide(m *Market, side string) { 127 other, order := "sell", "price ASC" 128 if side == "sell" { 129 other, order = "buy", "price DESC" 130 } 131 rows, err := db.DB.Query( 132 `SELECT price, quantity, orders FROM v_order_book 133 WHERE market_id = $1 AND side = $2 ORDER BY `+order+` LIMIT 5`, m.ID, other) 134 if err != nil { 135 fmt.Println("Error:", err) 136 return 137 } 138 defer rows.Close() 139 label := map[string]string{"sell": "asks", "buy": "bids"}[other] 140 first := true 141 for rows.Next() { 142 var price, qty float64 143 var n int 144 if err := rows.Scan(&price, &qty, &n); err != nil { 145 fmt.Println("scan error:", err) 78 146 return 79 147 } 80 if avail < notional { 81 fmt.Printf("Insufficient funds: need %.4f, have %.4f\n", notional, avail) 148 if first { 149 fmt.Printf("Order book %s (other users' limit orders):\n", label) 150 first = false 151 } 152 fmt.Printf(" %14.6f %12.4f (%d orders)\n", price, qty, n) 153 } 154 if first { 155 fmt.Printf("Order book has no %s - a market order fills from the simulated market.\n", label) 156 } 157 } 158 159 // ShowOrderBook - P7 view v_order_book for one market. 160 func ShowOrderBook() { 161 m, err := ChooseMarket() 162 if err != nil { 163 fmt.Println(err) 164 return 165 } 166 rows, err := db.DB.Query( 167 `SELECT side, price, quantity, orders FROM v_order_book 168 WHERE market_id = $1 169 ORDER BY side DESC, price DESC`, m.ID) 170 if err != nil { 171 fmt.Println("Error:", err) 172 return 173 } 174 defer rows.Close() 175 fmt.Printf("\n Order book %s/%s\n", m.Symbol, m.Quote) 176 fmt.Printf(" %-5s %14s %12s %6s\n", "Side", "Price", "Quantity", "Orders") 177 fmt.Println(" " + strings.Repeat("-", 44)) 178 empty := true 179 for rows.Next() { 180 var side string 181 var price, qty float64 182 var n int 183 if err := rows.Scan(&side, &price, &qty, &n); err != nil { 184 fmt.Println("scan error:", err) 82 185 return 83 186 } 84 85 // debit balance 86 if _, err := tx.Exec( 87 `UPDATE users 88 SET available_balance = available_balance - $1, 89 invested_balance = invested_balance + $1, 90 updated_at = now() 91 WHERE id = $2`, 92 notional, s.UserID, 93 ); err != nil { 94 fmt.Println("Error:", err) 95 return 96 } 97 98 // a buy never reserves crypto, only ever adds it — upsert holding 99 // with running weighted average 100 if err := upsertHoldingOnBuy(tx, s.UserID, m.CryptoID, qty, price); err != nil { 101 fmt.Println("Error updating holding:", err) 102 return 103 } 104 105 // ledger entry 106 if _, err := tx.Exec( 107 `INSERT INTO transactions (user_id, type, amount, currency, related_order, description) 108 VALUES ($1, 'buy', $2, 'USD', $3, $4)`, 109 s.UserID, -notional, orderID, 110 fmt.Sprintf("Market buy %.4f %s @ %.6f", qty, m.Symbol, price), 111 ); err != nil { 112 fmt.Println("Error:", err) 113 return 114 } 115 } else { 116 // sell: lock the holding and check what is actually free to sell — 117 // quantity minus whatever another open order has already reserved. 118 var held, reserved, avgPrice float64 119 err := tx.QueryRow( 120 `SELECT quantity, reserved_quantity, avg_price FROM holdings 121 WHERE user_id = $1 AND crypto_id = $2 FOR UPDATE`, 122 s.UserID, m.CryptoID, 123 ).Scan(&held, &reserved, &avgPrice) 124 if err != nil && err != sql.ErrNoRows { 125 fmt.Println("Error:", err) 126 return 127 } 128 available := held - reserved 129 if err == sql.ErrNoRows || available < qty { 130 fmt.Printf("Insufficient holding: trying to sell %.4f, available %.4f (of %.4f held, %.4f reserved)\n", 131 qty, available, held, reserved) 132 return 133 } 134 135 // reserve: committed to this order, not yet removed from the position. 136 if _, err := tx.Exec( 137 `UPDATE holdings 138 SET reserved_quantity = reserved_quantity + $1, 139 updated_at = now() 140 WHERE user_id = $2 AND crypto_id = $3`, 141 qty, s.UserID, m.CryptoID, 142 ); err != nil { 143 fmt.Println("Error:", err) 144 return 145 } 146 147 // settle: a market order fills immediately, so release the 148 // reservation and remove the asset from the position in one step. 149 if _, err := tx.Exec( 150 `UPDATE holdings 151 SET quantity = quantity - $1, 152 reserved_quantity = reserved_quantity - $1, 153 updated_at = now() 154 WHERE user_id = $2 AND crypto_id = $3`, 155 qty, s.UserID, m.CryptoID, 156 ); err != nil { 157 fmt.Println("Error:", err) 158 return 159 } 160 161 // credit balance; reduce invested by cost basis (avg_price * qty) 162 costBasis := avgPrice * qty 163 if _, err := tx.Exec( 164 `UPDATE users 165 SET available_balance = available_balance + $1, 166 invested_balance = GREATEST(invested_balance - $2, 0), 167 updated_at = now() 168 WHERE id = $3`, 169 notional, costBasis, s.UserID, 170 ); err != nil { 171 fmt.Println("Error:", err) 172 return 173 } 174 175 // ledger entry 176 if _, err := tx.Exec( 177 `INSERT INTO transactions (user_id, type, amount, currency, related_order, description) 178 VALUES ($1, 'sell', $2, 'USD', $3, $4)`, 179 s.UserID, notional, orderID, 180 fmt.Sprintf("Market sell %.4f %s @ %.6f", qty, m.Symbol, price), 181 ); err != nil { 182 fmt.Println("Error:", err) 183 return 184 } 185 } 186 187 // record the resulting market trade so the book reflects this fill 188 if _, err := tx.Exec( 189 `INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source) 190 VALUES ($1, now(), $2, $3, $4, 'user')`, 191 m.ID, price, qty, side, 192 ); err != nil { 193 fmt.Println("Error:", err) 194 return 195 } 196 197 // settle the order itself: it has now actually been filled. 198 if _, err := tx.Exec( 199 `UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1`, 200 orderID, 201 ); err != nil { 202 fmt.Println("Error:", err) 203 return 204 } 205 206 if err := tx.Commit(); err != nil { 207 fmt.Println("Commit error:", err) 208 return 209 } 210 fmt.Printf("Order executed: %s %.4f %s @ %.6f (notional %.4f USD)\n", 211 side, qty, m.Symbol, price, notional) 212 } 213 214 // upsertHoldingOnBuy creates or updates a holding using running weighted-average price. 215 // 216 // This is a single statement that relies on UNIQUE (user_id, crypto_id): the new 217 // weighted average is recomputed by the database in numeric arithmetic rather 218 // than in Go float64, and no separate SELECT ... FOR UPDATE round-trip is 219 // needed because ON CONFLICT DO UPDATE locks the conflicting row itself. 220 // Every SET expression sees the pre-update row, so `holdings.quantity` below is 221 // still the old quantity while the average is being computed. 222 func upsertHoldingOnBuy(tx *sql.Tx, userID, cryptoID string, qty, price float64) error { 223 _, err := tx.Exec( 224 `INSERT INTO holdings (user_id, crypto_id, quantity, avg_price, updated_at) 225 VALUES ($1, $2, $3, $4, now()) 226 ON CONFLICT (user_id, crypto_id) DO UPDATE 227 SET avg_price = (holdings.quantity * holdings.avg_price 228 + EXCLUDED.quantity * EXCLUDED.avg_price) 229 / (holdings.quantity + EXCLUDED.quantity), 230 quantity = holdings.quantity + EXCLUDED.quantity, 231 updated_at = now()`, 232 userID, cryptoID, qty, price, 233 ) 234 return err 235 } 187 fmt.Printf(" %-5s %14.6f %12.4f %6d\n", map[string]string{"sell": "ask", "buy": "bid"}[side], price, qty, n) 188 empty = false 189 } 190 if empty { 191 fmt.Println(" (no resting limit orders)") 192 } 193 } 194 195 // activeOrder is one row of the user's open orders list. 196 type activeOrder struct { 197 id, symbol, side, typ, status string 198 qty, filled, remaining, price float64 199 } 200 201 // listMyOrders prints the user's active orders numbered 1..n (P7 view 202 // v_active_orders) and returns them, so the user can pick one by number. 203 func listMyOrders(s *Session) []activeOrder { 204 rows, err := db.DB.Query( 205 `SELECT order_id, symbol, side, type, status, quantity, filled_quantity, remaining, price 206 FROM v_active_orders WHERE user_id = $1 ORDER BY placed_at`, s.UserID) 207 if err != nil { 208 fmt.Println("Error:", err) 209 return nil 210 } 211 defer rows.Close() 212 var list []activeOrder 213 for rows.Next() { 214 var o activeOrder 215 if err := rows.Scan(&o.id, &o.symbol, &o.side, &o.typ, &o.status, 216 &o.qty, &o.filled, &o.remaining, &o.price); err != nil { 217 fmt.Println("scan error:", err) 218 return nil 219 } 220 list = append(list, o) 221 } 222 fmt.Println() 223 if len(list) == 0 { 224 fmt.Println(" (no open orders)") 225 return nil 226 } 227 fmt.Printf(" %-3s %-6s %-4s %-6s %-16s %10s %10s %14s\n", 228 "#", "Symbol", "Side", "Type", "Status", "Filled", "Remaining", "Price") 229 fmt.Println(" " + strings.Repeat("-", 84)) 230 for i, o := range list { 231 fmt.Printf(" %-3d %-6s %-4s %-6s %-16s %10.4f %10.4f %14.6f\n", 232 i+1, o.symbol, o.side, o.typ, o.status, o.filled, o.remaining, o.price) 233 } 234 return list 235 } 236 237 // ShowMyOrders - P7 view v_active_orders. 238 func ShowMyOrders(s *Session) { 239 listMyOrders(s) 240 } 241 242 // CancelOrder - P7 stored function project.cancel_order, which releases 243 // the reserved cash or crypto of what is still unfilled. 244 func CancelOrder(s *Session) { 245 list := listMyOrders(s) 246 if len(list) == 0 { 247 return 248 } 249 n, err := strconv.Atoi(prompt("Order # to cancel (0 = back): ")) 250 if err != nil || n < 0 || n > len(list) { 251 fmt.Println("Invalid choice.") 252 return 253 } 254 if n == 0 { 255 return 256 } 257 if _, err := db.DB.Exec(`SELECT cancel_order($1, $2)`, list[n-1].id, s.UserID); err != nil { 258 fmt.Println(dbMessage(err)) 259 return 260 } 261 fmt.Println("Order cancelled; its reservation was released.") 262 }
Note:
See TracChangeset
for help on using the changeset viewer.
