Changeset ef1c1c7 for server


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

Wiki docs, phase 6 and phase 7 added

Location:
server
Files:
3 added
9 edited

Legend:

Unmodified
Added
Removed
  • server/account.go

    ra531b45 ref1c1c7  
    1111// ShowBalance prints the logged-in user's balances.
    1212func 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
    1416        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`,
    1619                s.UserID,
    17         ).Scan(&avail, &invested)
     20        ).Scan(&avail, &reserved, &invested)
    1821        if err != nil {
    1922                fmt.Println("Error:", err)
    … …  
    2124        }
    2225        fmt.Printf("\n  Available: %.4f USD\n", avail)
     26        fmt.Printf("  Reserved : %.4f USD (open buy orders)\n", reserved)
    2327        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)
    2529}
    2630
  • server/cli.go

    ra531b45 ref1c1c7  
    7070        fmt.Println("[2] Deposit virtual funds")
    7171        fmt.Println("[3] Browse markets")
    72         fmt.Println("[4] Place market BUY order")
    73         fmt.Println("[5] Place market SELL order")
     72        fmt.Println("[4] Place BUY order")
     73        fmt.Println("[5] Place SELL order")
    7474        fmt.Println("[6] View portfolio")
    7575        fmt.Println("[7] View transaction history")
    … …  
    7878        fmt.Println("[10] Report: top traders")
    7979        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")
    8083        fmt.Println("[0] Exit")
    8184        switch prompt("> ") {
    … …  
    100103        case "11":
    101104                ShowMarketPerformance(s)
     105        case "12":
     106                ShowOrderBook()
     107        case "13":
     108                ShowMyOrders(s)
     109        case "14":
     110                CancelOrder(s)
    102111        case "9":
    103112                s.UserID = ""
  • server/db/data_load.sql

    ra531b45 ref1c1c7  
    88--
    99-- 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
     16BEGIN;
    1017
    1118SET search_path TO project, public;
    … …  
    104111-- Shows a fully-filled market buy and its resulting holding & ledger entry.
    105112-- ============================================================================
    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).
     114INSERT INTO project.orders (id, user_id, market_id, side, type, status, quantity, filled_quantity, price, placed_at, executed_at) VALUES
    107115    ('c1111111-1111-1111-1111-111111111111',
    108116     'b1111111-1111-1111-1111-111111111111',
    109117     'a2222222-2222-2222-2222-222222222222',
    110      'buy', 'market', 'executed', 0.5000, 3500.000000,
     118     'buy', 'market', 'executed', 0.5000, 0.5000, 3500.000000,
    111119     now() - interval '1 hour', now() - interval '1 hour');
    112120
    … …  
    118126INSERT INTO project.transactions (user_id, type, amount, currency, related_order, description) VALUES
    119127    ('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,
    120132        'Initial virtual deposit'),
    121133    ('b1111111-1111-1111-1111-111111111111', 'buy',      -1750.0000, 'USD',
    … …  
    143155    ('d2222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111'),
    144156    ('d2222222-2222-2222-2222-222222222222', '55555555-5555-5555-5555-555555555555');
     157
     158COMMIT;
  • server/db/db.go

    ra531b45 ref1c1c7  
    1111        "strings"
    1212
    13         _ "github.com/lib/pq"
     13        "github.com/lib/pq"
    1414)
    1515
    … …  
    1919// which directory the program is started from.
    2020//
    21 //go:embed schema_creation.sql data_load.sql
     21//go:embed schema_creation.sql advanced_db.sql data_load.sql
    2222var sqlScripts embed.FS
    2323
    … …  
    3636        )
    3737
    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                }
    4258        }
    4359        if err := DB.Ping(); err != nil {
    … …  
    7288}
    7389
    74 // InitSchema runs schema_creation.sql then data_load.sql.
     90// InitSchema runs schema_creation.sql, advanced_db.sql (P7) and data_load.sql.
    7591// Destructive: drops the `project` schema. Intended for the -init flag.
    7692func 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                }
    8098        }
    8199        if err := LoadData(); err != nil {
  • server/db/reports_demo_data.sql

    ra531b45 ref1c1c7  
    2828-- description/source) before re-inserting.
    2929
     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
     35BEGIN;
     36
    3037SET search_path TO project, public;
     38
     39UPDATE 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;
    3144
    3245DELETE FROM transactions  WHERE description = 'P6 demo data';
    … …  
    98111-- Executed orders: who participated in which market, across the same quarters.
    99112-- ============================================================================
    100 INSERT INTO orders (id, user_id, market_id, side, type, status, quantity, price, placed_at, executed_at) VALUES
     113INSERT INTO orders (id, user_id, market_id, side, type, status, quantity, filled_quantity, price, placed_at, executed_at) VALUES
    101114    ('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,
    103116     '2025-07-15 10:00', '2025-07-15 10:00'),
    104117    ('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,
    106119     '2025-10-15 10:00', '2025-10-15 10:00'),
    107120    ('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,
    109122     '2026-01-15 10:00', '2026-01-15 10:00'),
    110123    ('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,
    112125     '2026-04-15 10:00', '2026-04-15 10:00'),
    113126    ('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,
    115128     '2026-01-15 10:00', '2026-01-15 10:00');
     129
     130UPDATE 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
     136COMMIT;
  • server/db/schema_creation.sql

    ra531b45 ref1c1c7  
    2626    available_balance numeric(18,4)   NOT NULL DEFAULT 0 CHECK (available_balance >= 0),
    2727    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),
    2831    created_at        timestamptz     NOT NULL DEFAULT now(),
    2932    updated_at        timestamptz
    … …  
    8689    side        varchar(4)     NOT NULL CHECK (side   IN ('buy', 'sell')),
    8790    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')),
    8992    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),
    9097    price       numeric(18,6),
    9198    placed_at   timestamptz    NOT NULL DEFAULT now(),
    … …  
    125132    quantity    numeric(20,6)  NOT NULL CHECK (quantity > 0),
    126133    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)
    128139);
    129140
    130141CREATE INDEX idx_market_trades_market_time ON project.market_trades(market_id, executed_at DESC);
     142CREATE INDEX idx_market_trades_buy_order  ON project.market_trades(buy_order_id)  WHERE buy_order_id  IS NOT NULL;
     143CREATE INDEX idx_market_trades_sell_order ON project.market_trades(sell_order_id) WHERE sell_order_id IS NOT NULL;
    131144
    132145-- ============================================================================
  • server/market.go

    ra531b45 ref1c1c7  
    44        "database/sql"
    55        "fmt"
     6        "strconv"
    67
    78        "bp_project/server/db"
    … …  
    1617}
    1718
    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.
     21func ListMarkets() []Market {
    2022        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,
    2224                       COALESCE(lp.price, 0) AS price
    2325                  FROM markets m
    … …  
    2830        if err != nil {
    2931                fmt.Println("Error:", err)
    30                 return
     32                return nil
    3133        }
    3234        defer rows.Close()
    … …  
    3537        fmt.Printf("  %-4s  %-8s  %-5s  %15s\n", "#", "Symbol", "Quote", "Last price")
    3638        fmt.Println("  -----------------------------------------")
    37         i := 1
     39        var list []Market
    3840        for rows.Next() {
    39                 var id, sym, quote string
     41                var m Market
    4042                var price float64
    41                 if err := rows.Scan(&id, &sym, &quote, &price); err != nil {
     43                if err := rows.Scan(&m.ID, &m.CryptoID, &m.Symbol, &m.Quote, &price); err != nil {
    4244                        fmt.Println("scan error:", err)
    43                         return
     45                        return nil
    4446                }
    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)
    4749        }
     50        return list
    4851}
    4952
    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.
     54func 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.
    5167func 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))
    6970        if err != nil {
    7071                return nil, err
    7172        }
    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.
     79func 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
    73117}
    74118
  • 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}
  • server/watchlist.go

    ra531b45 ref1c1c7  
    8888}
    8989
     90// addToWatchlist lists the cryptos that are not on the watchlist yet,
     91// numbered, and adds the one the user picks.
    9092func 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)
    98101                return
    99102        }
     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))
    100126        if err != nil {
    101                 fmt.Println("Error:", err)
     127                fmt.Println(err)
    102128                return
    103129        }
    … …  
    106132                 VALUES ($1, $2)
    107133                 ON CONFLICT (watchlist_id, crypto_id) DO NOTHING`,
    108                 wlID, cryptoID,
     134                wlID, list[k].id,
    109135        )
    110136        if err != nil {
    … …  
    112138                return
    113139        }
    114         fmt.Println("Added.")
     140        fmt.Printf("Added %s.\n", list[k].symbol)
    115141}
    116142
     143// removeFromWatchlist lists the watchlist's cryptos, numbered, and removes
     144// the one the user picks.
    117145func removeFromWatchlist(wlID string) {
    118         sym := prompt("Crypto symbol to remove: ")
    119         res, err := db.DB.Exec(`
    120                 DELETE FROM watchlist_items
    121                  WHERE watchlist_id = $1
    122                    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)
    124152        if err != nil {
    125153                fmt.Println("Error:", err)
    126154                return
    127155        }
    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.")
    131170                return
    132171        }
    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)
    134191}
Note: See TracChangeset for help on using the changeset viewer.