| | 1 | = Use-case 0006 Implementation - View portfolio and transaction history = |
| | 2 | |
| | 3 | '''Initiating actor:''' Trader |
| | 4 | |
| | 5 | '''Other actors:''' — |
| | 6 | |
| | 7 | A logged-in Trader inspects the current state of their account. The portfolio view |
| | 8 | lists every cryptocurrency the Trader holds with the quantity (also split into the |
| | 9 | part reserved by open sell orders and the part that is free to sell), the average |
| | 10 | buy price, the current market price, the market value and the unrealised |
| | 11 | profit/loss, followed by a totals row and a cash summary (cash available, portfolio |
| | 12 | value, net worth). The transaction history lists the Trader's last 20 ledger |
| | 13 | entries — deposits, buys and sells — newest first. Both are read-only: nothing in |
| | 14 | the database is changed. |
| | 15 | |
| | 16 | Original use-case description (P3): [wiki:UseCase0006]. |
| | 17 | Implementation: `server/portfolio.go`, function `ShowPortfolio`, and |
| | 18 | `server/account.go`, function `ShowTransactions` (the code is shown at the end of |
| | 19 | this page). |
| | 20 | |
| | 21 | Precondition: the Trader is logged in ([wiki:UseCase0002Implementation UseCase0002]). |
| | 22 | The run below is alice's, after she bought 0.01 BTC |
| | 23 | ([wiki:UseCase0004Implementation UseCase0004]) and sold 0.2 ETH |
| | 24 | ([wiki:UseCase0005Implementation UseCase0005]) on top of the seed data (0.5 ETH, |
| | 25 | 8250.00 USD cash). |
| | 26 | |
| | 27 | == Scenario == |
| | 28 | |
| | 29 | === Portfolio === |
| | 30 | |
| | 31 | 1. '''Trader''' chooses `[6] View portfolio` in the authenticated menu (types `6`). |
| | 32 | The menu is the one shown in [wiki:UseCase0002Implementation UseCase0002], step 7. |
| | 33 | 2. '''System''' queries the `v_portfolio` view (`$1` = user id): |
| | 34 | |
| | 35 | {{{ |
| | 36 | SELECT symbol, |
| | 37 | quantity, |
| | 38 | COALESCE(reserved_quantity, 0), |
| | 39 | COALESCE(available_quantity, quantity), |
| | 40 | COALESCE(avg_price, 0), |
| | 41 | COALESCE(current_price, 0), |
| | 42 | COALESCE(market_value, 0), |
| | 43 | COALESCE(unrealized_pnl, 0) |
| | 44 | FROM v_portfolio |
| | 45 | WHERE user_id = $1 AND quantity > 0 |
| | 46 | ORDER BY symbol |
| | 47 | }}} |
| | 48 | |
| | 49 | 3. '''System''' displays the rows and a `TOTAL` row (sums of the value and P/L columns, |
| | 50 | computed in Go), then reads the cash balance for the summary (`$1` = user id): |
| | 51 | |
| | 52 | {{{ |
| | 53 | SELECT available_balance, invested_balance FROM users WHERE id = $1 |
| | 54 | }}} |
| | 55 | |
| | 56 | and prints `Cash available` (= `available_balance`), `Portfolio value` |
| | 57 | (= the total market value) and `Net worth` (= their sum). |
| | 58 | |
| | 59 | The screenshot shows the result of steps 2–3. The table is wider than the |
| | 60 | terminal window, so each long line wraps and the header row has scrolled out of |
| | 61 | the top of the window; the complete output of this run is reproduced below it. |
| | 62 | |
| | 63 | [[Image(uc0006_portfolio.png)]] |
| | 64 | |
| | 65 | {{{ |
| | 66 | Symbol Quantity Reserved Available Avg buy Current Value Unrealised P/L |
| | 67 | ------------------------------------------------------------------------------------------------------------------ |
| | 68 | BTC 0.0100 0.0000 0.0100 67140.000000 67140.000000 671.4000 +0.0000 |
| | 69 | ETH 0.3000 0.0000 0.3000 3500.000000 3520.000000 1056.0000 +6.0000 |
| | 70 | ------------------------------------------------------------------------------------------------------------------ |
| | 71 | TOTAL 1727.4000 +6.0000 |
| | 72 | |
| | 73 | Cash available : 8282.6000 USD |
| | 74 | Portfolio value: 1727.4000 USD |
| | 75 | Net worth : 10010.0000 USD |
| | 76 | }}} |
| | 77 | |
| | 78 | Checking the figures: ETH is 0.5 − 0.2 = 0.3 at an average buy price of 3500 and |
| | 79 | a current price of 3520, so value 1056.00 and P/L 0.3 × 20 = +6.00; BTC was just |
| | 80 | bought at the current price 67140, so its P/L is 0. Cash is |
| | 81 | 8250.00 − 671.40 (buy) + 704.00 (sell) = 8282.60. `Reserved` is 0.0000 for both |
| | 82 | because the market orders executed immediately — a quantity is reserved only |
| | 83 | while a sell order is still open. |
| | 84 | |
| | 85 | === Transaction history === |
| | 86 | |
| | 87 | 1. '''Trader''' chooses `[7] View transaction history` in the authenticated menu |
| | 88 | (types `7`). |
| | 89 | 2. '''System''' queries the last 20 ledger entries of the Trader (`$1` = user id) and |
| | 90 | prints them, newest first (the time is shown as the first 19 characters of |
| | 91 | `created_at`): |
| | 92 | |
| | 93 | {{{ |
| | 94 | SELECT created_at, type, amount, currency, COALESCE(description, '') |
| | 95 | FROM transactions |
| | 96 | WHERE user_id = $1 |
| | 97 | ORDER BY created_at DESC |
| | 98 | LIMIT 20 |
| | 99 | }}} |
| | 100 | |
| | 101 | The screenshot shows steps 1–2: the choice `7` and the four ledger rows of this |
| | 102 | run — the sell of 0.2 ETH (+704.0000 USD), the buy of 0.01 BTC (−671.4000 USD), |
| | 103 | and the two seed rows, the initial deposit of 10000.0000 USD and the seed buy of |
| | 104 | 0.5 ETH (−1750.0000 USD). Buys are stored with a negative amount, deposits and |
| | 105 | sells with a positive one. The two seed rows were inserted by `data_load.sql` in |
| | 106 | one statement and have the same `created_at`, so their relative order is not |
| | 107 | determined by the `ORDER BY`. |
| | 108 | |
| | 109 | [[Image(uc0006_history.png)]] |
| | 110 | |
| | 111 | All statements run on the `project` schema (the connection sets |
| | 112 | `search_path=project,public`). |
| | 113 | |
| | 114 | === Reference — how v_portfolio is defined === |
| | 115 | |
| | 116 | From `server/db/schema_creation.sql`: |
| | 117 | |
| | 118 | {{{ |
| | 119 | CREATE OR REPLACE VIEW project.v_portfolio AS |
| | 120 | SELECT h.user_id, |
| | 121 | c.symbol, |
| | 122 | h.quantity, |
| | 123 | h.reserved_quantity, |
| | 124 | (h.quantity - h.reserved_quantity) AS available_quantity, |
| | 125 | h.avg_price, |
| | 126 | lp.price AS current_price, |
| | 127 | (h.quantity * lp.price) AS market_value, |
| | 128 | (h.quantity * (lp.price - h.avg_price)) AS unrealized_pnl |
| | 129 | FROM project.holdings h |
| | 130 | JOIN project.crypto c ON c.id = h.crypto_id |
| | 131 | LEFT JOIN project.markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD' |
| | 132 | LEFT JOIN project.v_latest_prices lp ON lp.market_id = m.id; |
| | 133 | }}} |
| | 134 | |
| | 135 | == How to reproduce == |
| | 136 | |
| | 137 | {{{ |
| | 138 | ./eduberza -init |
| | 139 | ./eduberza |
| | 140 | # [2] Login: alice / test123 |
| | 141 | # [4] buy 0.01 BTC, [5] sell 0.2 ETH (UseCase0004 / UseCase0005) |
| | 142 | # [6] View portfolio |
| | 143 | # [7] View transaction history |
| | 144 | }}} |
| | 145 | |
| | 146 | Both screenshots come from one real run (portfolio and history taken right after |
| | 147 | the buy and sell runs). |
| | 148 | |
| | 149 | == Source code == |
| | 150 | |
| | 151 | `server/portfolio.go` — `ShowPortfolio`: |
| | 152 | |
| | 153 | {{{ |
| | 154 | // ShowPortfolio - UC0006 |
| | 155 | // Uses the v_portfolio view to list holdings with current market value and P&L. |
| | 156 | func ShowPortfolio(s *Session) { |
| | 157 | rows, err := db.DB.Query( |
| | 158 | `SELECT symbol, |
| | 159 | quantity, |
| | 160 | COALESCE(reserved_quantity, 0), |
| | 161 | COALESCE(available_quantity, quantity), |
| | 162 | COALESCE(avg_price, 0), |
| | 163 | COALESCE(current_price, 0), |
| | 164 | COALESCE(market_value, 0), |
| | 165 | COALESCE(unrealized_pnl, 0) |
| | 166 | FROM v_portfolio |
| | 167 | WHERE user_id = $1 AND quantity > 0 |
| | 168 | ORDER BY symbol`, |
| | 169 | s.UserID, |
| | 170 | ) |
| | 171 | if err != nil { |
| | 172 | fmt.Println("Error:", err) |
| | 173 | return |
| | 174 | } |
| | 175 | defer rows.Close() |
| | 176 | |
| | 177 | header := fmt.Sprintf(" %-8s %12s %12s %12s %14s %14s %14s %14s", |
| | 178 | "Symbol", "Quantity", "Reserved", "Available", "Avg buy", "Current", "Value", "Unrealised P/L") |
| | 179 | fmt.Println() |
| | 180 | fmt.Println(header) |
| | 181 | fmt.Println(" " + strings.Repeat("-", len(header)-2)) |
| | 182 | |
| | 183 | var totalValue, totalPnL float64 |
| | 184 | empty := true |
| | 185 | for rows.Next() { |
| | 186 | var sym string |
| | 187 | var qty, reserved, avail, avg, cur, val, pnl float64 |
| | 188 | if err := rows.Scan(&sym, &qty, &reserved, &avail, &avg, &cur, &val, &pnl); err != nil { |
| | 189 | fmt.Println("scan error:", err) |
| | 190 | return |
| | 191 | } |
| | 192 | fmt.Printf(" %-8s %12.4f %12.4f %12.4f %14.6f %14.6f %14.4f %+14.4f\n", |
| | 193 | sym, qty, reserved, avail, avg, cur, val, pnl) |
| | 194 | totalValue += val |
| | 195 | totalPnL += pnl |
| | 196 | empty = false |
| | 197 | } |
| | 198 | if empty { |
| | 199 | fmt.Println(" (no holdings yet)") |
| | 200 | return |
| | 201 | } |
| | 202 | fmt.Println(" " + strings.Repeat("-", len(header)-2)) |
| | 203 | fmt.Printf(" %-8s %12s %12s %12s %14s %14s %14.4f %+14.4f\n", |
| | 204 | "TOTAL", "", "", "", "", "", totalValue, totalPnL) |
| | 205 | |
| | 206 | // cash summary |
| | 207 | var avail, invested float64 |
| | 208 | _ = db.DB.QueryRow( |
| | 209 | `SELECT available_balance, invested_balance FROM users WHERE id = $1`, |
| | 210 | s.UserID, |
| | 211 | ).Scan(&avail, &invested) |
| | 212 | fmt.Printf("\n Cash available : %.4f USD\n", avail) |
| | 213 | fmt.Printf(" Portfolio value: %.4f USD\n", totalValue) |
| | 214 | fmt.Printf(" Net worth : %.4f USD\n", avail+totalValue) |
| | 215 | } |
| | 216 | }}} |
| | 217 | |
| | 218 | `server/account.go` — `ShowTransactions`: |
| | 219 | |
| | 220 | {{{ |
| | 221 | // ShowTransactions lists the last 20 ledger entries for the user. |
| | 222 | func ShowTransactions(s *Session) { |
| | 223 | rows, err := db.DB.Query( |
| | 224 | `SELECT created_at, type, amount, currency, COALESCE(description, '') |
| | 225 | FROM transactions |
| | 226 | WHERE user_id = $1 |
| | 227 | ORDER BY created_at DESC |
| | 228 | LIMIT 20`, |
| | 229 | s.UserID, |
| | 230 | ) |
| | 231 | if err != nil { |
| | 232 | fmt.Println("Error:", err) |
| | 233 | return |
| | 234 | } |
| | 235 | defer rows.Close() |
| | 236 | |
| | 237 | fmt.Println() |
| | 238 | fmt.Printf(" %-20s %-8s %12s %-3s %s\n", "When", "Type", "Amount", "Cur", "Description") |
| | 239 | fmt.Println(" " + strings.Repeat("-", 70)) |
| | 240 | for rows.Next() { |
| | 241 | var when, typ, cur, desc string |
| | 242 | var amt float64 |
| | 243 | if err := rows.Scan(&when, &typ, &amt, &cur, &desc); err != nil { |
| | 244 | fmt.Println("scan error:", err) |
| | 245 | return |
| | 246 | } |
| | 247 | fmt.Printf(" %-20s %-8s %12.4f %-3s %s\n", when[:19], typ, amt, cur, desc) |
| | 248 | } |
| | 249 | } |
| | 250 | }}} |