- Timestamp:
- 09/24/26 17:43:19 (6 days ago)
- Branches:
- main
- Children:
- 0cee8ec
- Parents:
- a531b45
- Location:
- server
- Files:
-
- 3 added
- 9 edited
-
account.go (modified) (2 diffs)
-
cli.go (modified) (3 diffs)
-
db/advanced_db.sql (added)
-
db/advanced_db_tests.sql (added)
-
db/data_load.sql (modified) (4 diffs)
-
db/db.go (modified) (4 diffs)
-
db/reports_demo_data.sql (modified) (2 diffs)
-
db/schema_creation.sql (modified) (3 diffs)
-
db/ssh.go (added)
-
market.go (modified) (4 diffs)
-
trade.go (modified) (4 diffs)
-
watchlist.go (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
server/account.go
ra531b45 ref1c1c7 11 11 // ShowBalance prints the logged-in user's balances. 12 12 func ShowBalance(s *Session) { 13 var avail, invested float64 13 // P7 view v_trader_balances: cash is split into what is free and what 14 // is reserved by the user's open buy orders. 15 var avail, reserved, invested float64 14 16 err := db.DB.QueryRow( 15 `SELECT available_balance, invested_balance FROM users WHERE id = $1`, 17 `SELECT available_balance, reserved_balance, invested_balance 18 FROM v_trader_balances WHERE user_id = $1`, 16 19 s.UserID, 17 ).Scan(&avail, & invested)20 ).Scan(&avail, &reserved, &invested) 18 21 if err != nil { 19 22 fmt.Println("Error:", err) … … 21 24 } 22 25 fmt.Printf("\n Available: %.4f USD\n", avail) 26 fmt.Printf(" Reserved : %.4f USD (open buy orders)\n", reserved) 23 27 fmt.Printf(" Invested : %.4f USD\n", invested) 24 fmt.Printf(" Total : %.4f USD\n", avail+ invested)28 fmt.Printf(" Total : %.4f USD\n", avail+reserved+invested) 25 29 } 26 30 -
server/cli.go
ra531b45 ref1c1c7 70 70 fmt.Println("[2] Deposit virtual funds") 71 71 fmt.Println("[3] Browse markets") 72 fmt.Println("[4] Place marketBUY order")73 fmt.Println("[5] Place marketSELL order")72 fmt.Println("[4] Place BUY order") 73 fmt.Println("[5] Place SELL order") 74 74 fmt.Println("[6] View portfolio") 75 75 fmt.Println("[7] View transaction history") … … 78 78 fmt.Println("[10] Report: top traders") 79 79 fmt.Println("[11] Report: market performance") 80 fmt.Println("[12] Order book") 81 fmt.Println("[13] My open orders") 82 fmt.Println("[14] Cancel an order") 80 83 fmt.Println("[0] Exit") 81 84 switch prompt("> ") { … … 100 103 case "11": 101 104 ShowMarketPerformance(s) 105 case "12": 106 ShowOrderBook() 107 case "13": 108 ShowMyOrders(s) 109 case "14": 110 CancelOrder(s) 102 111 case "9": 103 112 s.UserID = "" -
server/db/data_load.sql
ra531b45 ref1c1c7 8 8 -- 9 9 -- All sample users have the password: test123 10 -- 11 -- One transaction: the P7 checks in advanced_db.sql compare balances with 12 -- the ledger at COMMIT, and the users are inserted with their balances 13 -- before the deposit rows that back them. In an auto-commit client 14 -- (DBeaver) every statement would otherwise be checked on its own. 15 16 BEGIN; 10 17 11 18 SET search_path TO project, public; … … 104 111 -- Shows a fully-filled market buy and its resulting holding & ledger entry. 105 112 -- ============================================================================ 106 INSERT INTO project.orders (id, user_id, market_id, side, type, status, quantity, price, placed_at, executed_at) VALUES 113 -- Imported as already completely filled (filled_quantity = quantity). 114 INSERT INTO project.orders (id, user_id, market_id, side, type, status, quantity, filled_quantity, price, placed_at, executed_at) VALUES 107 115 ('c1111111-1111-1111-1111-111111111111', 108 116 'b1111111-1111-1111-1111-111111111111', 109 117 'a2222222-2222-2222-2222-222222222222', 110 'buy', 'market', 'executed', 0.5000, 3500.000000,118 'buy', 'market', 'executed', 0.5000, 0.5000, 3500.000000, 111 119 now() - interval '1 hour', now() - interval '1 hour'); 112 120 … … 118 126 INSERT INTO project.transactions (user_id, type, amount, currency, related_order, description) VALUES 119 127 ('b1111111-1111-1111-1111-111111111111', 'deposit', 10000.0000, 'USD', NULL, 128 'Initial virtual deposit'), 129 ('b2222222-2222-2222-2222-222222222222', 'deposit', 5000.0000, 'USD', NULL, 130 'Initial virtual deposit'), 131 ('b3333333-3333-3333-3333-333333333333', 'deposit', 2500.0000, 'USD', NULL, 120 132 'Initial virtual deposit'), 121 133 ('b1111111-1111-1111-1111-111111111111', 'buy', -1750.0000, 'USD', … … 143 155 ('d2222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111'), 144 156 ('d2222222-2222-2222-2222-222222222222', '55555555-5555-5555-5555-555555555555'); 157 158 COMMIT; -
server/db/db.go
ra531b45 ref1c1c7 11 11 "strings" 12 12 13 _"github.com/lib/pq"13 "github.com/lib/pq" 14 14 ) 15 15 … … 19 19 // which directory the program is started from. 20 20 // 21 //go:embed schema_creation.sql data_load.sql21 //go:embed schema_creation.sql advanced_db.sql data_load.sql 22 22 var sqlScripts embed.FS 23 23 … … 36 36 ) 37 37 38 var err error 39 DB, err = sql.Open("postgres", dsn) 40 if err != nil { 41 return fmt.Errorf("sql.Open: %w", err) 38 // Optional SSH tunnel, the same thing DBeaver's "SSH" tab does. When 39 // SSH_HOST is set, DBHOST/DBPORT are resolved from the SSH server's side 40 // (for the faculty server that is usually localhost:5432). 41 if sshHost := os.Getenv("SSH_HOST"); sshHost != "" { 42 dialer, err := newSSHDialer(sshHost) 43 if err != nil { 44 return err 45 } 46 connector, err := pq.NewConnector(dsn) 47 if err != nil { 48 return fmt.Errorf("pq.NewConnector: %w", err) 49 } 50 connector.Dialer(dialer) 51 DB = sql.OpenDB(connector) 52 } else { 53 var err error 54 DB, err = sql.Open("postgres", dsn) 55 if err != nil { 56 return fmt.Errorf("sql.Open: %w", err) 57 } 42 58 } 43 59 if err := DB.Ping(); err != nil { … … 72 88 } 73 89 74 // InitSchema runs schema_creation.sql thendata_load.sql.90 // InitSchema runs schema_creation.sql, advanced_db.sql (P7) and data_load.sql. 75 91 // Destructive: drops the `project` schema. Intended for the -init flag. 76 92 func InitSchema() error { 77 log.Println("Running schema_creation.sql ...") 78 if err := runScript("schema_creation.sql"); err != nil { 79 return err 93 for _, name := range []string{"schema_creation.sql", "advanced_db.sql"} { 94 log.Printf("Running %s ...", name) 95 if err := runScript(name); err != nil { 96 return err 97 } 80 98 } 81 99 if err := LoadData(); err != nil { -
server/db/reports_demo_data.sql
ra531b45 ref1c1c7 28 28 -- description/source) before re-inserting. 29 29 30 -- P7: users' cash (available + reserved) must equal their ledger at 31 -- COMMIT, so the balance moves by exactly what this script removes and 32 -- re-adds to the ledger, all in one transaction. The historical orders are 33 -- imported as completely filled. 34 35 BEGIN; 36 30 37 SET search_path TO project, public; 38 39 UPDATE users u 40 SET available_balance = u.available_balance - d.total 41 FROM (SELECT user_id, SUM(amount) AS total FROM transactions 42 WHERE description = 'P6 demo data' GROUP BY user_id) d 43 WHERE u.id = d.user_id; 31 44 32 45 DELETE FROM transactions WHERE description = 'P6 demo data'; … … 98 111 -- Executed orders: who participated in which market, across the same quarters. 99 112 -- ============================================================================ 100 INSERT INTO orders (id, user_id, market_id, side, type, status, quantity, price, placed_at, executed_at) VALUES113 INSERT INTO orders (id, user_id, market_id, side, type, status, quantity, filled_quantity, price, placed_at, executed_at) VALUES 101 114 ('e1111111-1111-1111-1111-111111111111', 'b1111111-1111-1111-1111-111111111111', 102 'a1111111-1111-1111-1111-111111111111', 'buy', 'market', 'executed', 0.5000, 40000.000000,115 'a1111111-1111-1111-1111-111111111111', 'buy', 'market', 'executed', 0.5000, 0.5000, 40000.000000, 103 116 '2025-07-15 10:00', '2025-07-15 10:00'), 104 117 ('e2222222-2222-2222-2222-222222222222', 'b1111111-1111-1111-1111-111111111111', 105 'a2222222-2222-2222-2222-222222222222', 'sell', 'market', 'executed', 3.0000, 4000.000000,118 'a2222222-2222-2222-2222-222222222222', 'sell', 'market', 'executed', 3.0000, 3.0000, 4000.000000, 106 119 '2025-10-15 10:00', '2025-10-15 10:00'), 107 120 ('e3333333-3333-3333-3333-333333333333', 'b2222222-2222-2222-2222-222222222222', 108 'a1111111-1111-1111-1111-111111111111', 'buy', 'market', 'executed', 1.2000, 55000.000000,121 'a1111111-1111-1111-1111-111111111111', 'buy', 'market', 'executed', 1.2000, 1.2000, 55000.000000, 109 122 '2026-01-15 10:00', '2026-01-15 10:00'), 110 123 ('e4444444-4444-4444-4444-444444444444', 'b2222222-2222-2222-2222-222222222222', 111 'a1111111-1111-1111-1111-111111111111', 'buy', 'market', 'executed', 1.0000, 60000.000000,124 'a1111111-1111-1111-1111-111111111111', 'buy', 'market', 'executed', 1.0000, 1.0000, 60000.000000, 112 125 '2026-04-15 10:00', '2026-04-15 10:00'), 113 126 ('e5555555-5555-5555-5555-555555555555', 'b3333333-3333-3333-3333-333333333333', 114 'a2222222-2222-2222-2222-222222222222', 'sell', 'market', 'executed', 2.0000, 3600.000000,127 'a2222222-2222-2222-2222-222222222222', 'sell', 'market', 'executed', 2.0000, 2.0000, 3600.000000, 115 128 '2026-01-15 10:00', '2026-01-15 10:00'); 129 130 UPDATE users u 131 SET available_balance = u.available_balance + d.total 132 FROM (SELECT user_id, SUM(amount) AS total FROM transactions 133 WHERE description = 'P6 demo data' GROUP BY user_id) d 134 WHERE u.id = d.user_id; 135 136 COMMIT; -
server/db/schema_creation.sql
ra531b45 ref1c1c7 26 26 available_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (available_balance >= 0), 27 27 invested_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (invested_balance >= 0), 28 -- P7: cash committed to the user's active buy orders, moved out of 29 -- available_balance when the order is placed and consumed as it fills. 30 reserved_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (reserved_balance >= 0), 28 31 created_at timestamptz NOT NULL DEFAULT now(), 29 32 updated_at timestamptz … … 86 89 side varchar(4) NOT NULL CHECK (side IN ('buy', 'sell')), 87 90 type varchar(20) NOT NULL CHECK (type IN ('market', 'limit')), 88 status varchar(20) NOT NULL CHECK (status IN ('open', ' executed', 'cancelled')),91 status varchar(20) NOT NULL CHECK (status IN ('open', 'partially_filled', 'executed', 'cancelled')), 89 92 quantity numeric(20,4) NOT NULL CHECK (quantity > 0), 93 -- P7: how much of the order has been traded so far; remaining is 94 -- quantity - filled_quantity. Maintained from market_trades. 95 filled_quantity numeric(20,4) NOT NULL DEFAULT 0 96 CHECK (filled_quantity >= 0 AND filled_quantity <= quantity), 90 97 price numeric(18,6), 91 98 placed_at timestamptz NOT NULL DEFAULT now(), … … 125 132 quantity numeric(20,6) NOT NULL CHECK (quantity > 0), 126 133 side varchar(4) CHECK (side IN ('buy', 'sell')), 127 source varchar(50) NOT NULL DEFAULT 'simulation' 134 source varchar(50) NOT NULL DEFAULT 'simulation', 135 -- P7: the orders this trade filled. NULL on a side means the counterparty 136 -- was the simulated market (bot ticks have both NULL). 137 buy_order_id uuid REFERENCES project.orders(id), 138 sell_order_id uuid REFERENCES project.orders(id) 128 139 ); 129 140 130 141 CREATE INDEX idx_market_trades_market_time ON project.market_trades(market_id, executed_at DESC); 142 CREATE INDEX idx_market_trades_buy_order ON project.market_trades(buy_order_id) WHERE buy_order_id IS NOT NULL; 143 CREATE INDEX idx_market_trades_sell_order ON project.market_trades(sell_order_id) WHERE sell_order_id IS NOT NULL; 131 144 132 145 -- ============================================================================ -
server/market.go
ra531b45 ref1c1c7 4 4 "database/sql" 5 5 "fmt" 6 "strconv" 6 7 7 8 "bp_project/server/db" … … 16 17 } 17 18 18 // ListMarkets prints all active markets with their latest price. 19 func ListMarkets() { 19 // ListMarkets prints all active markets, numbered, with their latest price, 20 // and returns them in the printed order so a caller can pick one by number. 21 func ListMarkets() []Market { 20 22 rows, err := db.DB.Query(` 21 SELECT m.id, c. symbol, m.quote_currency,23 SELECT m.id, c.id, c.symbol, m.quote_currency, 22 24 COALESCE(lp.price, 0) AS price 23 25 FROM markets m … … 28 30 if err != nil { 29 31 fmt.Println("Error:", err) 30 return 32 return nil 31 33 } 32 34 defer rows.Close() … … 35 37 fmt.Printf(" %-4s %-8s %-5s %15s\n", "#", "Symbol", "Quote", "Last price") 36 38 fmt.Println(" -----------------------------------------") 37 i := 139 var list []Market 38 40 for rows.Next() { 39 var id, sym, quote string41 var m Market 40 42 var price float64 41 if err := rows.Scan(& id, &sym, "e, &price); err != nil {43 if err := rows.Scan(&m.ID, &m.CryptoID, &m.Symbol, &m.Quote, &price); err != nil { 42 44 fmt.Println("scan error:", err) 43 return 45 return nil 44 46 } 45 fmt.Printf(" %-4d %-8s %-5s %15.6f\n", i, sym, quote, price)46 i++47 list = append(list, m) 48 fmt.Printf(" %-4d %-8s %-5s %15.6f\n", len(list), m.Symbol, m.Quote, price) 47 49 } 50 return list 48 51 } 49 52 50 // ChooseMarket asks the user to pick a market by symbol and returns it. 53 // pickNumber reads a 1-based choice from a list of n items. 54 func pickNumber(label string, n int) (int, error) { 55 if n == 0 { 56 return 0, fmt.Errorf("Nothing to choose from.") 57 } 58 k, err := strconv.Atoi(prompt(label)) 59 if err != nil || k < 1 || k > n { 60 return 0, fmt.Errorf("Invalid choice, enter a number from 1 to %d.", n) 61 } 62 return k - 1, nil 63 } 64 65 // ChooseMarket lists the active markets and lets the user pick one by its 66 // number in the list. 51 67 func ChooseMarket() (*Market, error) { 52 ListMarkets() 53 sym := prompt("Market symbol (e.g. BTC): ") 54 if sym == "" { 55 return nil, fmt.Errorf("no symbol entered") 56 } 57 var m Market 58 err := db.DB.QueryRow(` 59 SELECT m.id, c.id, c.symbol, m.quote_currency 60 FROM markets m 61 JOIN crypto c ON c.id = m.crypto_id 62 WHERE upper(c.symbol) = upper($1) 63 AND m.is_active = true 64 LIMIT 1`, sym, 65 ).Scan(&m.ID, &m.CryptoID, &m.Symbol, &m.Quote) 66 if err == sql.ErrNoRows { 67 return nil, fmt.Errorf("market %s not found", sym) 68 } 68 list := ListMarkets() 69 k, err := pickNumber("Market #: ", len(list)) 69 70 if err != nil { 70 71 return nil, err 71 72 } 72 return &m, nil 73 return &list[k], nil 74 } 75 76 // ChooseHolding lists only the markets the user can sell in — cryptos they 77 // hold with some quantity still free (not reserved by an open sell order) — 78 // with how much is held and free, and lets them pick one by number. 79 func ChooseHolding(s *Session) (*Market, error) { 80 rows, err := db.DB.Query(` 81 SELECT m.id, c.id, c.symbol, m.quote_currency, 82 h.quantity, h.quantity - h.reserved_quantity AS free, 83 COALESCE(lp.price, 0) AS price 84 FROM holdings h 85 JOIN crypto c ON c.id = h.crypto_id 86 JOIN markets m ON m.crypto_id = c.id AND m.is_active = true 87 LEFT JOIN v_latest_prices lp ON lp.market_id = m.id 88 WHERE h.user_id = $1 89 AND h.quantity - h.reserved_quantity > 0 90 ORDER BY c.symbol`, s.UserID) 91 if err != nil { 92 return nil, err 93 } 94 defer rows.Close() 95 96 fmt.Println() 97 fmt.Printf(" %-4s %-8s %-5s %12s %12s %15s\n", "#", "Symbol", "Quote", "Held", "Free to sell", "Last price") 98 fmt.Println(" -------------------------------------------------------------------") 99 var list []Market 100 for rows.Next() { 101 var m Market 102 var held, free, price float64 103 if err := rows.Scan(&m.ID, &m.CryptoID, &m.Symbol, &m.Quote, &held, &free, &price); err != nil { 104 return nil, err 105 } 106 list = append(list, m) 107 fmt.Printf(" %-4d %-8s %-5s %12.4f %12.4f %15.6f\n", len(list), m.Symbol, m.Quote, held, free, price) 108 } 109 if len(list) == 0 { 110 return nil, fmt.Errorf("you hold no crypto that is free to sell") 111 } 112 k, err := pickNumber("Holding #: ", len(list)) 113 if err != nil { 114 return nil, err 115 } 116 return &list[k], nil 73 117 } 74 118 -
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 } -
server/watchlist.go
ra531b45 ref1c1c7 88 88 } 89 89 90 // addToWatchlist lists the cryptos that are not on the watchlist yet, 91 // numbered, and adds the one the user picks. 90 92 func addToWatchlist(wlID string) { 91 sym := prompt("Crypto symbol to add: ") 92 var cryptoID string 93 err := db.DB.QueryRow( 94 `SELECT id FROM crypto WHERE upper(symbol) = upper($1)`, sym, 95 ).Scan(&cryptoID) 96 if err == sql.ErrNoRows { 97 fmt.Println("Unknown crypto symbol.") 93 rows, err := db.DB.Query(` 94 SELECT c.id, c.symbol, c.name 95 FROM crypto c 96 WHERE NOT EXISTS (SELECT 1 FROM watchlist_items wi 97 WHERE wi.watchlist_id = $1 AND wi.crypto_id = c.id) 98 ORDER BY c.symbol`, wlID) 99 if err != nil { 100 fmt.Println("Error:", err) 98 101 return 99 102 } 103 type option struct{ id, symbol, name string } 104 var list []option 105 for rows.Next() { 106 var o option 107 if err := rows.Scan(&o.id, &o.symbol, &o.name); err != nil { 108 rows.Close() 109 fmt.Println("scan error:", err) 110 return 111 } 112 list = append(list, o) 113 } 114 rows.Close() 115 if len(list) == 0 { 116 fmt.Println("Every crypto is already on your watchlist.") 117 return 118 } 119 fmt.Println() 120 fmt.Printf(" %-4s %-8s %s\n", "#", "Symbol", "Name") 121 fmt.Println(" ------------------------------") 122 for i, o := range list { 123 fmt.Printf(" %-4d %-8s %s\n", i+1, o.symbol, o.name) 124 } 125 k, err := pickNumber("Crypto # to add: ", len(list)) 100 126 if err != nil { 101 fmt.Println( "Error:",err)127 fmt.Println(err) 102 128 return 103 129 } … … 106 132 VALUES ($1, $2) 107 133 ON CONFLICT (watchlist_id, crypto_id) DO NOTHING`, 108 wlID, cryptoID,134 wlID, list[k].id, 109 135 ) 110 136 if err != nil { … … 112 138 return 113 139 } 114 fmt.Print ln("Added.")140 fmt.Printf("Added %s.\n", list[k].symbol) 115 141 } 116 142 143 // removeFromWatchlist lists the watchlist's cryptos, numbered, and removes 144 // the one the user picks. 117 145 func removeFromWatchlist(wlID string) { 118 sym := prompt("Crypto symbol to remove: ")119 res, err := db.DB.Exec(`120 DELETE FROM watchlist_items121 WHERE watchlist_id = $1122 AND crypto_id = (SELECT id FROM crypto WHERE upper(symbol) = upper($2))`,123 wlID, sym)146 rows, err := db.DB.Query(` 147 SELECT c.id, c.symbol, c.name 148 FROM watchlist_items wi 149 JOIN crypto c ON c.id = wi.crypto_id 150 WHERE wi.watchlist_id = $1 151 ORDER BY c.symbol`, wlID) 124 152 if err != nil { 125 153 fmt.Println("Error:", err) 126 154 return 127 155 } 128 n, _ := res.RowsAffected() 129 if n == 0 { 130 fmt.Println("Not in watchlist.") 156 type option struct{ id, symbol, name string } 157 var list []option 158 for rows.Next() { 159 var o option 160 if err := rows.Scan(&o.id, &o.symbol, &o.name); err != nil { 161 rows.Close() 162 fmt.Println("scan error:", err) 163 return 164 } 165 list = append(list, o) 166 } 167 rows.Close() 168 if len(list) == 0 { 169 fmt.Println("Your watchlist is empty.") 131 170 return 132 171 } 133 fmt.Println("Removed.") 172 fmt.Println() 173 fmt.Printf(" %-4s %-8s %s\n", "#", "Symbol", "Name") 174 fmt.Println(" ------------------------------") 175 for i, o := range list { 176 fmt.Printf(" %-4d %-8s %s\n", i+1, o.symbol, o.name) 177 } 178 k, err := pickNumber("Crypto # to remove: ", len(list)) 179 if err != nil { 180 fmt.Println(err) 181 return 182 } 183 if _, err := db.DB.Exec( 184 `DELETE FROM watchlist_items WHERE watchlist_id = $1 AND crypto_id = $2`, 185 wlID, list[k].id, 186 ); err != nil { 187 fmt.Println("Error:", err) 188 return 189 } 190 fmt.Printf("Removed %s.\n", list[k].symbol) 134 191 }
Note:
See TracChangeset
for help on using the changeset viewer.
