Changeset ef1c1c7 for server/trade.go


Ignore:
Timestamp:
09/24/26 17:43:19 (5 days ago)
Author:
Stefan <trsunovstefan@…>
Branches:
main
Children:
0cee8ec
Parents:
a531b45
Message:

Wiki docs, phase 6 and phase 7 added

File:
1 edited

Legend:

Unmodified
Added
Removed
  • server/trade.go

    ra531b45 ref1c1c7  
    22
    33import (
    4         "database/sql"
     4        "errors"
    55        "fmt"
    66        "strconv"
     7        "strings"
     8
     9        "github.com/lib/pq"
    710
    811        "bp_project/server/db"
    … …  
    1013
    1114// 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.
    2420func PlaceOrder(s *Session, side string) {
    2521        if side != "buy" && side != "sell" {
    … …  
    2723                return
    2824        }
    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        }
    3235        if err != nil {
    3336                fmt.Println(err)
    … …  
    4043        }
    4144        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)
    4556        if err != nil || qty <= 0 {
    4657                fmt.Println("Invalid quantity.")
    4758                return
    4859        }
    49         notional := qty * price
    50 
    51         tx, err := db.DB.Begin()
    52         if err != nil {
    53                 fmt.Println("Error:", err)
    54                 return
    55         }
    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
    5970        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(),
    6574        ).Scan(&orderID)
    6675        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).
     102type optFloat struct {
     103        v  float64
     104        ok bool
     105}
     106
     107func (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.
     116func 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).
     126func 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)
    78146                        return
    79147                }
    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.
     160func 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)
    82185                        return
    83186                }
    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.
     196type 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.
     203func 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.
     238func 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.
     244func 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.