Changeset ef1c1c7


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

Files:
68 added
6 deleted
35 edited

Legend:

Unmodified
Added
Removed
  • .env.example

    ra531b45 ref1c1c7  
    1111DBNAME=bp_database
    1212DBHOST=localhost
     13
     14# Optional: connect through SSH, like DBeaver's "SSH" tab. Leave SSH_HOST
     15# empty for the local docker setup. When SSH_HOST is set, DBHOST/DBPORT are
     16# as seen from the SSH server (copy them from DBeaver's "Main" tab).
     17# SSH_HOST=
     18# SSH_PORT=22
     19# SSH_USER=
     20# SSH_PASSWORD=
     21# SSH_KEY=/home/you/.ssh/id_ed25519
     22# SSH_KEY_PASSPHRASE=
  • .gitignore

    ra531b45 ref1c1c7  
    22/eduberza
    33/bots/bots
     4/server/server
     5/server/eduberza
    46
    57# Local configuration and secrets — never commit; use .env.example as template
  • README.md

    ra531b45 ref1c1c7  
    1414./eduberza                   
    1515```
     16
     17Things to do:
     18Because we added one more table in Phase 7 I updated phase 1 and phase 2 `Order_events`
     191. phase 2 update picture
     202. In normalization P5
     21Modify and find a different way to do `Lossless join`
     221. for phase 6 if wanted all points do Algebra
  • bots/main.go

    ra531b45 ref1c1c7  
    8484                        log.Printf("  %-8s  %.6f  qty=%.4f  side=%s", m.symbol, m.price, qty, side)
    8585                }
     86                // P7 background job: the prices just moved, so fill any resting
     87                // limit order the new market price has reached.
     88                var filled int
     89                if err := db.QueryRow(`SELECT fill_marketable_orders()`).Scan(&filled); err != nil {
     90                        log.Printf("fill_marketable_orders: %v", err)
     91                } else if filled > 0 {
     92                        log.Printf("  filled %d resting limit order(s) at the new market price", filled)
     93                }
    8694                time.Sleep(*interval)
    8795        }
  • docs/P1-ConceptualModel/ERModel.md

    ra531b45 ref1c1c7  
    1 # Entity-Relationship Model v.03
     1# Entity-Relationship Model v.04
    22
    33## Diagram
    44
    5 ![ERModel_v03](ERModel_v03.png)
     5![ERModel_v04](ERModel_v04.png)
    66
    77Notation: Chen. Rectangles are entity sets, diamonds are relationships, ellipses
    … …  
    5151| `available_balance` | numeric(18,4) | required, default 0, ≥ 0 |
    5252| `invested_balance` | numeric(18,4) | required, default 0, ≥ 0 |
     53| `reserved_balance` | numeric(18,4) | required, default 0, ≥ 0 — cash set aside for the user's open buy orders (added in v04, after P7) |
    5354| `created_at` | timestamptz | required, defaults to now |
    5455| `updated_at` | timestamptz | optional (null until first change) |
    … …  
    9697makes the ledger auditable.
    9798
    98 Placing an order is what triggers a **reservation** of whatever it commits —
    99 the crypto being sold (`Holds.reserved_quantity`, below) on a sell, cash
    100 already handled the same way on a buy via `available_balance` /
    101 `invested_balance`. `status` therefore has real meaning as a lifecycle, not
    102 just a label: `open` means reserved but not yet settled, `executed` means
    103 settled, `cancelled` would release the reservation without settling (not yet
    104 exercised by any use case, since only market orders — which settle
    105 immediately — are implemented). See
     99Placing an order is what triggers a **reservation** of whatever it commits:
     100the crypto being sold (`Holds.reserved_quantity`, below) on a sell, and the
     101cash (`Users.reserved_balance`) on a buy. Since v04 (after P7) an order can
     102wait in the order book and be filled in parts, so `status` is a real
     103lifecycle driven by `filled_quantity`: `open` (nothing filled yet),
     104`partially_filled`, `executed` (completely filled), or `cancelled`, which
     105releases what is still reserved. See
    106106[UseCase0005](../P3-UseCaseModel/UseCase0005.md) for the reserve-then-settle
    107 sequence.
     107sequence and
     108[AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md)
     109for the rules that keep it consistent.
    108110
    109111**Keys:** candidate `{id}` only — there is no natural key, since the same user
    … …  
    115117| `id` | UUID | PK, required |
    116118| `side` | text | required, `buy` or `sell` |
    117 | `type` | text | required, `market` or `limit` — the prototype executes only `market`; `limit` exists so the model does not have to change when limit orders are implemented |
    118 | `status` | text | required, `open`, `executed` or `cancelled` |
     119| `type` | text | required, `market` or `limit` (both executed since P7) |
     120| `status` | text | required, `open`, `partially_filled`, `executed` or `cancelled` |
    119121| `quantity` | numeric(20,4) | required, > 0 |
    120 | `price` | numeric(18,6) | optional — null until the order settles, then the fill price |
     122| `filled_quantity` | numeric(20,4) | required, default 0, between 0 and `quantity` — how much has been traded; remaining = `quantity − filled_quantity` (added in v04, after P7) |
     123| `price` | numeric(18,6) | the limit price; for a market order, the market price when it was placed |
    121124| `placed_at` | timestamptz | required, defaults to now |
    122125| `executed_at` | timestamptz | optional, set when the order settles |
    … …  
    160163| `source` | text(50) | required, default `simulation` — distinguishes a simulated trade from a user's own fill (`user`) |
    161164
     165Since v04 (after P7) a trade also records which orders it filled, through the
     166relationships `FillsBuy` and `FillsSell` below.
     167
     168#### OrderEvents
     169*Added in v04, after P7.* The audit trail of an order: one event for its
     170placement, one for every (partial) fill, and one for a cancellation. The
     171`Orders` row only holds the current state; this entity keeps the history of
     172how the order got there. Events are recorded automatically by the database.
     173
     174**Keys:** candidate `{id}` only; primary key **`id`** (auto-incrementing
     175integer, events are only read in order).
     176
     177| Attribute | Type | Constraints |
     178|---|---|---|
     179| `id` | integer | PK, required, auto-generated |
     180| `event_type` | text | required, `placed`, `partially_filled`, `filled` or `cancelled` |
     181| `quantity` | numeric(20,4) | required — the ordered quantity for `placed`, the filled amount for a fill, the unfilled rest for `cancelled` |
     182| `price` | numeric(18,6) | optional — the order price, or the trade price for a fill |
     183| `status_after` | text | required, the order's status after the event |
     184| `created_at` | timestamptz | required, defaults to now |
     185
    162186#### MarketCandles
    163187OHLCV aggregates per market and timeframe — the data a price chart is drawn
    … …  
    222246#### Fills — Markets (1) : MarketTrades (N), total on MarketTrades
    223247Every executed trade happened on exactly one market. No attributes.
     248
     249#### FillsBuy — Orders (1) : MarketTrades (N), partial on both sides
     250*Added in v04, after P7.* The buy order a trade filled. An order can be
     251filled by many trades (partial fills); a trade fills at most one buy order,
     252and none when the simulated market was the buyer. No attributes.
     253
     254#### FillsSell — Orders (1) : MarketTrades (N), partial on both sides
     255*Added in v04, after P7.* The sell order a trade filled, symmetric to
     256`FillsBuy`. A trade between two users' orders participates in both. No
     257attributes.
     258
     259#### Logs — Orders (1) : OrderEvents (N), total on OrderEvents
     260*Added in v04, after P7.* Every event belongs to exactly one order. No
     261attributes.
    224262
    225263#### Aggregates — Markets (1) : MarketCandles (N), total on MarketCandles
    … …  
    290328  [UseCase0005](../P3-UseCaseModel/UseCase0005.md) for how the new attribute
    291329  is enforced.
     330- **v04 — after P7.** Phase 7 (order, balance and trade consistency) needed
     331  data the model did not have, so the model was extended to stay in line with
     332  the database:
     333  - `Users.reserved_balance`: cash reserved by open buy orders;
     334  - `Orders.filled_quantity` and the status value `partially_filled`: orders
     335    can now be filled in parts;
     336  - the relationships `FillsBuy` and `FillsSell` between `Orders` and
     337    `MarketTrades`: which orders a trade filled;
     338  - the entity set `OrderEvents` with the relationship `Logs`: the
     339    automatically recorded history of every order.
     340
     341  Nothing existing was removed or changed. See
     342  [AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md).
     343  The diagram files are `ERModel_v04.xml` / `ERModel_v04.png`; earlier versions
     344  are kept.
    292345
    293346Reasoning for the AI-assisted part of this phase, and the full interaction log,
    294347are on [ERModelAIUsage](ERModelAIUsage.md).
    295348
    296 > **Student action required.** Open `ERModel_v03.xml` in TerraER, read the
    297 > whole diagram — not just the new `reserved_quantity` ellipse — and change
    298 > anything you disagree with, including the compaction. The phase rules
    299 > require the model to be yours; this is a generated revision to review and
    300 > take over, not an answer to submit unread.
  • docs/P1-ConceptualModel/ERModelAIUsage.md

    ra531b45 ref1c1c7  
    283283white `(255,255,255)`, matching `ERModel_v02.png`.
    284284
    285 > **Student action required.** Open `ERModel_v03.xml` in TerraER and read it
    286 > end to end before submission — see the note at the end of
    287 > [ERModel](ERModel.md). Everything else in this session's diff is already
    288 > applied to the docs and to `server/`.
     285
     286### Session 4 — 2026-09-24 (Claude Opus 5.5): v04 after P7
     287
     288**Prompts (student, verbatim):**
     289> But this order_events is added after Phase 7 right? can we add that too?
     290
     291> can you make the ERmodel again with TerraER file to update it? and tell that after P7 we added this
     292
     293> Make it with a white background like earier versions
     294
     295**Response (AI, summarised):**
     296
     297- Explained that the P7 changes must also appear in P1 and P2, since both must describe the
     298  current data structure.
     299- Built `ERModel_v04.xml` in TerraER's own file format by taking `ERModel_v03.xml` unchanged
     300  and appending the new elements with the same XML structure TerraER uses:
     301  - the attribute `reserved_balance` on `Users`;
     302  - the attribute `filled_quantity` on `Orders`;
     303  - the relationships `FillsBuy` and `FillsSell` (Orders 1 : N MarketTrades, partial);
     304  - the entity set `OrderEvents` (key `id`, `event_type`, `quantity`, `price`,
     305    `status_after`, `created_at`) with `Logs` (Orders 1 : N OrderEvents, total on
     306    OrderEvents).
     307- Rendered `ERModel_v04.png` with TerraER 3.14's own drawing classes (loading the `.xml`
     308  exactly as TerraER does and using its image export), on a white background and trimmed like
     309  the earlier versions.
     310- Updated [ERModel](ERModel.md) (title v.04, new attribute rows, the `OrderEvents` section, the
     311  three relationships, and a v04 history entry stating these were added after P7).
     312
     313**What I decided:** to add the P7 structure to the ER model. The new elements are placed
     314automatically, so the layout can be tidied by hand in TerraER.
  • docs/P2-RelationalDesign/RelationalDesign.md

    ra531b45 ref1c1c7  
    1481482. Right-click the database → **ERD For Database** (or open a blank ERD and drag
    149149   the `project` tables in).
    150 3. Arrange the tables to mirror `ERModel_v01.png`.
     1503. Arrange the tables to mirror `ERModel_v03.png`.
    1511514. **Download image** → PNG, then convert:
    152152   `convert relational_schema.png relational_schema.jpg`
  • docs/P3-UseCaseModel/UseCase0004.md

    ra531b45 ref1c1c7  
    1010
    11111. Trader chooses "Place market BUY order".
    12 2. System lists the available markets with their latest price:
     122. System lists the active markets, numbered, with their latest price:
    1313
    1414   ```sql
    15    SELECT m.id, c.symbol, m.quote_currency, COALESCE(lp.price, 0)
     15   SELECT m.id, c.id, c.symbol, m.quote_currency,
     16          COALESCE(lp.price, 0) AS price
    1617     FROM project.markets m
    1718     JOIN project.crypto  c  ON c.id = m.crypto_id
    … …  
    2021    ORDER BY c.symbol;
    2122   ```
    22 3. Trader enters a market symbol, e.g. `ETH`.
    23 4. System resolves the market and looks up the latest price:
     233. Trader picks the market by its number in the listed markets, e.g. `2` (BTC).
     244. System takes the chosen row's market id and crypto id from the list (no lookup by
     25   symbol) and looks up the latest price:
    2426
    2527   ```sql
    26    SELECT m.id, c.id AS crypto_id, c.symbol, m.quote_currency
    27      FROM project.markets m
    28      JOIN project.crypto c ON c.id = m.crypto_id
    29     WHERE upper(c.symbol) = upper($1) AND m.is_active = true;
    30 
    31    SELECT price FROM project.v_latest_prices WHERE market_id = $2;
     28   SELECT price FROM project.v_latest_prices WHERE market_id = $1;
    3229   ```
    33305. Trader enters a quantity.
    … …  
    9188If `available_balance < notional`, the entire transaction rolls back and system shows "Insufficient funds: need X, have Y."
    9289
    93 ### Alternate flow 4a — market not found
     90### Alternate flow 3a — number not in the list
    9491
    95 If the entered symbol does not match any active market, system shows "market X not found" and returns to the authenticated menu without opening a transaction.
     92If the entered number is not one of the listed market numbers, system shows "Invalid choice, enter a number from 1 to N." and returns to the authenticated menu without opening a transaction.
  • docs/P3-UseCaseModel/UseCase0005.md

    ra531b45 ref1c1c7  
    2626
    27271. Trader chooses "Place market SELL order".
    28 2. System lists markets (same SQL as UC0004 step 2).
    29 3. Trader enters market symbol and quantity.
    30 4. System resolves the market and looks up the latest price (same SQL as UC0004 step 4).
     282. System lists, numbered, the Trader's holdings that still have a quantity free to
     29   sell (not reserved by an open sell order), with the quantity held, the free
     30   quantity and the latest price:
     31
     32   ```sql
     33   SELECT m.id, c.id, c.symbol, m.quote_currency,
     34          h.quantity, h.quantity - h.reserved_quantity AS free,
     35          COALESCE(lp.price, 0) AS price
     36     FROM project.holdings h
     37     JOIN project.crypto  c ON c.id = h.crypto_id
     38     JOIN project.markets m ON m.crypto_id = c.id AND m.is_active = true
     39     LEFT JOIN project.v_latest_prices lp ON lp.market_id = m.id
     40    WHERE h.user_id = $1
     41      AND h.quantity - h.reserved_quantity > 0
     42    ORDER BY c.symbol;
     43   ```
     443. Trader picks the holding by its number in the listed holdings, e.g. `2` (ETH), and
     45   enters the quantity.
     464. System takes the chosen row's market id and crypto id from the list (no lookup by
     47   symbol) and looks up the latest price:
     48
     49   ```sql
     50   SELECT price FROM project.v_latest_prices WHERE market_id = $1;
     51   ```
    31525. System opens a transaction:
    3253
  • docs/P3-UseCaseModel/UseCase0007.md

    ra531b45 ref1c1c7  
    4040### Add a crypto
    4141
     42The Trader picks the crypto by its number from the listed cryptos that are not on the watchlist yet:
     43
    4244```sql
    43 -- 1. resolve the symbol to a crypto_id
    44 SELECT id FROM project.crypto WHERE upper(symbol) = upper($1);
     45-- 1. list, numbered, the cryptos not yet on the watchlist
     46SELECT c.id, c.symbol, c.name
     47  FROM project.crypto c
     48 WHERE NOT EXISTS (SELECT 1 FROM project.watchlist_items wi
     49                    WHERE wi.watchlist_id = $1 AND wi.crypto_id = c.id)
     50 ORDER BY c.symbol;
    4551
    46 -- 2. insert the item; do nothing if it's already there
     52-- 2. insert the chosen crypto ($2 = its id from the list); do nothing if it's already there
    4753INSERT INTO project.watchlist_items (watchlist_id, crypto_id)
    48 VALUES ($watchlist_id, $crypto_id)
     54VALUES ($1, $2)
    4955ON CONFLICT (watchlist_id, crypto_id) DO NOTHING;
    5056```
    … …  
    5258### Remove a crypto
    5359
     60The Trader picks the crypto by its number from the listed cryptos on the watchlist:
     61
    5462```sql
     63-- 1. list, numbered, the cryptos on the watchlist
     64SELECT c.id, c.symbol, c.name
     65  FROM project.watchlist_items wi
     66  JOIN project.crypto c ON c.id = wi.crypto_id
     67 WHERE wi.watchlist_id = $1
     68 ORDER BY c.symbol;
     69
     70-- 2. delete the chosen crypto ($2 = its id from the list)
    5571DELETE FROM project.watchlist_items
    56  WHERE watchlist_id = $1
    57    AND crypto_id = (
    58        SELECT id FROM project.crypto
    59         WHERE upper(symbol) = upper($2)
    60    );
     72 WHERE watchlist_id = $1 AND crypto_id = $2;
    6173```
    6274
    63 If the delete affects zero rows, system shows "Not in watchlist."
     75If the entered number is not one of the listed numbers, system shows "Invalid choice, enter a number from 1 to N." and nothing is changed.
  • docs/P4-Prototype/BuildInstructions.md

    ra531b45 ref1c1c7  
    11# Build Instructions
    22
    3 How to compile, configure, run and test the EduBerza prototype.
    4 Linked from [PrototypeImplementation](PrototypeImplementation.md).
     3This page explains how to compile, configure, run and test the EduBerza prototype.
     4It is linked from [PrototypeImplementation](PrototypeImplementation.md).
    55
    66## Development environment description
    … …  
    88| Tool        | Version tested        | Needed for                                                    |
    99|-------------|-----------------------|---------------------------------------------------------------|
    10 | Go          | 1.26 (1.25+ works)    | Building `server/` (the CLI) and `bots/` (the market bot).     |
    11 | PostgreSQL  | 16                    | The database. Docker image or the faculty server.              |
    12 | Docker      | any recent            | Optional — brings up a local PostgreSQL in one command.        |
    13 | `psql`      | any                   | Optional — running the SQL scripts by hand.                    |
    14 | Java        | 21 (8+ works)         | Optional — only to open/edit the ER diagram in TerraER.         |
    15 | DBeaver     | any recent            | Optional — only to export `relational_schema.jpg`.              |
    16 
    17 Nothing else has to be installed. The only third-party Go dependency
    18 (`github.com/lib/pq`, the PostgreSQL driver) is fetched automatically by
    19 `go build` from `go.mod`/`go.sum`.
     10| Go          | 1.26.0 (`go.mod` asks for 1.25 or newer) | Building `server/` (the CLI) and `bots/` (the market bot). |
     11| PostgreSQL  | 16.3 (Docker container) | The database. Either the local Docker container or the faculty server. |
     12| Docker + Docker Compose | any recent | Optional. Starts a local PostgreSQL with one command. |
     13| `psql`      | 16                    | Optional. Only for running the SQL scripts by hand.            |
     14| Java        | 21 (8+ works)         | Optional. Only to open or edit the ER diagram in TerraER.      |
     15| DBeaver     | any recent            | Optional. Only to export `relational_schema.jpg`.              |
     16
     17About the PostgreSQL version: `docker-compose.yml` uses the image `postgres` without a version
     18tag. Docker therefore starts whatever version of the official image it has pulled. On the
     19machine where the prototype was tested, that was PostgreSQL 16.3. The only extension the schema
     20needs is `pgcrypto` (`CREATE EXTENSION IF NOT EXISTS pgcrypto`). It ships with PostgreSQL and is
     21included in the official image.
     22
     23You do not need to install anything else. The only third-party Go library is the PostgreSQL
     24driver `github.com/lib/pq`. `go build` downloads it automatically, at the version pinned in
     25`go.mod` and `go.sum`.
    2026
    2127## Build instructions
    2228
    23 All commands run from the repository root.
     29Run all commands from the repository root.
    2430
    2531### 1. Configure the database connection
    … …  
    2935```
    3036
    31 The defaults in `.env.example` match the bundled Docker setup. To use the
    32 faculty database instead, either edit `.env` or pass the values as real
    33 environment variables — those take precedence over the file:
     37The defaults in `.env.example` (`localhost:5433`, user `bp_project`, database `bp_database`)
     38match the bundled Docker setup. To use the faculty database instead, edit `.env`, or pass the
     39values as real environment variables. Real environment variables take precedence over the file:
    3440
    3541```sh
    … …  
    3743```
    3844
    39 `.env` is deliberately not committed (see `.gitignore`) because it holds a
    40 password.
     45`.env` is not committed on purpose (see `.gitignore`), because it holds a password.
    4146
    4247### 2. Start PostgreSQL
    … …  
    4651```
    4752
    48 Skip this step if you are pointing at the faculty database.
     53Skip this step if you use the faculty database.
    4954
    5055### 3. Build
    … …  
    6065```
    6166
    62 This runs `server/db/schema_creation.sql` and then `server/db/data_load.sql`.
    63 Both are **compiled into the binary** (`go:embed`), so `-init` works regardless
    64 of which directory you launch it from. It is destructive and idempotent — it
    65 drops and recreates the whole `project` schema, so it is also the reset button
    66 if a demo goes wrong. To reload only the data, keeping the schema:
    67 
    68 ```sh
    69 ./eduberza -load-data
    70 ```
    71 
    72 The equivalent with `psql`, if you prefer to watch the statements run:
     67This runs `server/db/schema_creation.sql` and then `server/db/data_load.sql`. It logs
     68`Running schema_creation.sql ...`, `Running data_load.sql ...` and `Database initialised.`,
     69then prints:
     70
     71```
     72Schema initialised. Re-run without -init to start the CLI.
     73```
     74
     75Both scripts are **compiled into the binary** (`go:embed`), so `-init` works from any
     76directory. It is destructive and can be run again any number of times: it drops and recreates
     77the whole `project` schema, so it also resets everything if a demo goes wrong. To reload only
     78the data and keep the schema:
     79
     80```sh
     81./eduberza -load-data          # prints "Sample data reloaded."
     82```
     83
     84If you prefer to watch the statements run, the same can be done with `psql`:
    7385
    7486```sh
    … …  
    8597```
    8698
    87 Seed accounts — all with the password `test123`:
    88 
    89 | Username  | Starting state                                        |
    90 |-----------|-------------------------------------------------------|
    91 | `alice`   | 8250.00 USD cash, holds 0.5 ETH — best demo account   |
    92 | `bob`     | 5000.00 USD cash, no positions                        |
    93 | `charlie` | 2500.00 USD cash, no positions                        |
    94 
    95 ### 6. Optional — run the market simulation bot
    96 
    97 In a second terminal:
    98 
    99 ```sh
    100 go run ./bots
    101 ```
    102 
    103 The bot walks the price of every active market, inserts a row into
    104 `market_trades` on each tick and upserts the current 1-minute candle. Prices in
    105 the CLI change while it runs, because the current price is always read from the
    106 most recent trade (`v_latest_prices`), never from a stored column.
    107 
    108 ### 7. Optional — richer data for the P6 reports
    109 
    110 `data_load.sql` only seeds a few minutes of trade history, which is not enough
    111 for the [top traders](../P6-AdvancedReports/AdvancedReports.md#top-traders-by-realized-performance)
    112 or [market performance](../P6-AdvancedReports/AdvancedReports.md#market-performance-leaderboard)
    113 reports (menu `[10]`/`[11]`) to show more than a single period. To see them do
    114 something more interesting, load five quarters of synthetic history on top:
     99### 6. Optional: run the market simulation bot
     100
     101In a second terminal, also from the repository root (the bot reads `.env` from the current
     102directory):
     103
     104```sh
     105go run ./bots                  # add -interval 1s for faster ticks; the default is 3s
     106```
     107
     108On every tick the bot moves the price of every active market by a small random step, inserts a
     109row into `market_trades` and updates the current 1-minute candle. Prices in the CLI change
     110while it runs, because the current price is always read from the most recent trade
     111(`v_latest_prices`) and never from a stored column. Leave the bot off if you want the exact
     112numbers in the tests below.
     113
     114### 7. Optional: richer data for the P6 reports
     115
     116`data_load.sql` seeds only a few minutes of trade history. That is not enough for the
     117[top traders](../P6-AdvancedReports/AdvancedReports.md) and
     118[market performance](../P6-AdvancedReports/AdvancedReports.md) reports (menu `[10]` and `[11]`)
     119to show more than one period. To see more interesting results, load five quarters of synthetic
     120history on top:
    115121
    116122```sh
    … …  
    119125```
    120126
    121 It is deliberately not part of `-init`/`-load-data` — see the header of
    122 [`reports_demo_data.sql`](../../server/db/reports_demo_data.sql) for why — so
    123 running it never changes the balances the smoke test below checks.
     127This script is not part of `-init` or `-load-data` on purpose. The header of
     128[`reports_demo_data.sql`](../../server/db/reports_demo_data.sql) explains why. Running it never
     129changes the balances that the tests below check.
    124130
    125131## Testing instructions
    126132
     133### How to launch and log in
     134
     135Start the prototype with `./eduberza` after steps 1–4. The sample data creates three test
     136users. All of them have the password **`test123`**:
     137
     138| Username  | Starting state after `-init` |
     139|-----------|------------------------------|
     140| `alice`   | 8250.00 USD available (1750.00 invested), holds 0.5 ETH bought at 3500.00. Watchlist "Favorites": BTC, ETH, SOL. Best demo account. |
     141| `bob`     | 5000.00 USD available, no crypto. Watchlist "Bobs Picks": BTC, DOGE. |
     142| `charlie` | 2500.00 USD available, no crypto, no watchlist yet. |
     143
     144The five sample markets are ADA, BTC, DOGE, ETH and SOL, all quoted in USD. Their starting
     145last prices are 0.45375, 67140, 0.122, 3520 and 166.1.
     146
    127147### Mini-guide to the application
    128148
    129 The CLI has two menus. Before logging in: **Register**, **Login**,
    130 **Browse markets**. After logging in: **View balance**, **Deposit virtual
    131 funds**, **Browse markets**, **Place market BUY order**, **Place market SELL
    132 order**, **View portfolio**, **View transaction history**, **Manage watchlist**,
    133 **Logout**, and two [P6](../P6-AdvancedReports/AdvancedReports.md) reports:
    134 **Report: top traders** and **Report: market performance**.
    135 
    136 You never have to remember an identifier. Markets are always printed as a
    137 numbered list with their current price before you are asked which one you want,
    138 and assets are referred to by symbol (`BTC`, `ETH`, …), never by database id.
     149You always answer with the number of a menu option. When you have to choose a market, a
     150holding or a crypto, the prototype prints a numbered list and you type the number from that
     151list. You never type an id or a symbol. A number that is not in the list is refused with
     152`Invalid choice, enter a number from 1 to N.`
     153
     154**Menu before login**
     155
     156| Option | What it does and how to use it |
     157|--------|--------------------------------|
     158| `[1] Register` | Enter a username, an e-mail (must contain `@`), your full name and a password (at least 6 characters). You get `Account created. You can now log in.`, or `Invalid email.`, `Password must be at least 6 characters.` or `Username or email already taken.` A new account starts with 0 USD. |
     159| `[2] Login` | Enter your username and password. You get `Login successful.` and the second menu. A wrong password and an unknown username both give `Invalid credentials.` |
     160| `[3] Browse markets` | Prints the numbered list of markets with their last price. |
     161| `[0] Exit` | Ends the program. |
     162
     163**Menu after login** (headed `--- Logged in as <username> ---`)
     164
     165| Option | What it does and how to use it |
     166|--------|--------------------------------|
     167| `[1] View balance` | Shows the available, invested and total USD. |
     168| `[2] Deposit virtual funds` | Enter an amount in USD. It must be a positive number, otherwise you get `Invalid amount.` You get `Deposited 500.0000 USD.` |
     169| `[3] Browse markets` | Same list as before login. |
     170| `[4] Place market BUY order` | Lists all markets, numbered, with their last price. Type the number at `Market #:`. The prototype shows the latest price. Type the quantity. You get `Order executed: buy …` or `Insufficient funds: need …, have …`. |
     171| `[5] Place market SELL order` | Lists only the cryptos you hold, numbered, with columns `Held` and `Free to sell`. Type the number at `Holding #:`, then the quantity. You get `Order executed: sell …` or `Insufficient holding: …`. If you hold nothing, you get `you hold no crypto that is free to sell`. |
     172| `[6] View portfolio` | One row per crypto you hold: quantity, reserved, available, average buy price, current price, value and unrealised P/L. Then your cash, portfolio value and net worth. |
     173| `[7] View transaction history` | Your last 20 ledger entries (deposits, buys, sells), newest first. |
     174| `[8] Manage watchlist` | Opens a submenu: `[1] List items` shows your watchlist with last prices. `[2] Add crypto` lists, numbered, the cryptos not on it yet; type a number. `[3] Remove crypto` lists, numbered, the cryptos on it; type a number. `[0] Back` returns. A user without a watchlist gets one named "Favorites" the first time. |
     175| `[9] Logout` | Back to the first menu. |
     176| `[10] Report: top traders` | P6 report. Enter a start date (inclusive) and an end date (exclusive) as `YYYY-MM-DD`. |
     177| `[11] Report: market performance` | P6 report, with the same two dates. |
     178| `[0] Exit` | Ends the program. |
    139179
    140180### End-to-end smoke test
    141181
    142 Verified on 2026-09-16 against PostgreSQL 16 with freshly loaded sample data.
    143 Expected values are exact.
    144 
    145 1. `./eduberza -init` — prints `Database initialised.`
    146 2. `./eduberza`, then `[2] Login` → `alice` / `test123` → `Login successful.`
    147 3. `[6] View portfolio` → one row: `ETH 0.5000` reserved 0.0000, available
    148    0.5000, at avg 3500.000000, current 3520.000000, value 1760.0000,
    149    unrealised P/L `+10.0000`. Cash available 8250.0000, net worth 10010.0000.
    150 4. `[4] Place market BUY order` → `BTC` → `0.01` →
     182These values were checked on 2026-09-24 against freshly loaded sample data (PostgreSQL 16.3),
     183with the bot not running. The expected values are exact.
     184
     1851. `./eduberza -init` prints `Schema initialised. Re-run without -init to start the CLI.`
     1862. `./eduberza`, then `2` (Login), then `alice` / `test123` gives `Login successful.`
     1873. `6` (View portfolio) shows one row: `ETH`, quantity 0.5000, reserved 0.0000, available
     188   0.5000, average buy 3500.000000, current 3520.000000, value 1760.0000, unrealised P/L
     189   `+10.0000`. Cash available is 8250.0000 and net worth is 10010.0000.
     1904. `4` (BUY). The market list shows `1 ADA`, `2 BTC`, `3 DOGE`, `4 ETH`, `5 SOL`. Type `2` at
     191   `Market #:`, then `0.01` at `Quantity:`. The result is
    151192   `Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)`.
    152 5. `[6] View portfolio` → now BTC *and* ETH, total value 2431.4000, cash
    153    7578.6000 (= 8250.00 − 671.40), net worth still 10010.0000.
    154 6. `[5] Place market SELL order` → `ETH` → `0.5` →
     1935. `6` (View portfolio) now shows BTC *and* ETH, with total value 2431.4000, cash 7578.6000
     194   (= 8250.00 − 671.40), and net worth still 10010.0000.
     1956. `5` (SELL). The holdings list shows `1 BTC` (held 0.0100) and `2 ETH` (held 0.5000). Type
     196   `2` at `Holding #:`, then `0.5`. The result is
    155197   `Order executed: sell 0.5000 ETH @ 3520.000000 (notional 1760.0000 USD)`.
    156 7. `[7] View transaction history` → deposit, buy, buy, sell, newest first.
    157 8. `[8] Manage watchlist` → `[1] List items` → alice's `Favorites` contains
    158    BTC, ETH, SOL with live prices.
    159 9. `[9] Logout`, then `[0] Exit`.
     1987. `7` (View transaction history) lists, newest first: the `sell` (+1760.0000), the `buy` of
     199   BTC (−671.4000), then the two rows from the sample data, which have the same timestamp:
     200   `deposit` 10000.0000 "Initial virtual deposit" and `buy` −1750.0000 "Market buy 0.5 ETH @
     201   3500.00".
     2028. `8` (Manage watchlist), then `1` (List items), shows alice's watchlist with BTC, ETH and SOL
     203   and their last prices. Then `2` (Add crypto) lists `1 ADA` and `2 DOGE`; type `1` and you get
     204   `Added ADA.` Then `0` (Back).
     2059. `9` (Logout), then `0` (Exit).
    160206
    161207### Testing the failure paths
    162208
    163 These matter more than the happy path, because they are what proves the
    164 transactions actually roll back:
    165 
    166 - **Insufficient funds:** log in as `charlie` (2500 USD) and try to buy `1` BTC.
    167   Expect `Insufficient funds: need 67140.0000, have 2500.0000` and *no* change
    168   to any table — no order row, no ledger entry, no holding.
    169 - **Insufficient holding:** as `bob` (no positions), try to sell `1` ETH.
    170   Expect `Insufficient holding: trying to sell 1.0000, available 0.0000 (of
    171   0.0000 held, 0.0000 reserved)`.
    172 - **Two sell orders racing for the same crypto:** give `alice` a 2 BTC holding
    173   and start two `eduberza` processes at once, each selling `1.5` BTC (together
    174   3 BTC, more than she has). Expect exactly one `Order executed`, and the
    175   other `Insufficient holding` reading the post-commit quantity — see
    176   [UseCase0005Implementation](UseCase0005Implementation.md) for the exact
    177   transcript. This is the concurrency guarantee that
    178   `holdings.reserved_quantity` and the `SELECT ... FOR UPDATE` lock together
    179   provide.
     209These matter more than the happy path, because they prove that the transactions really roll
     210back and that invalid choices are refused:
     211
     212- **Insufficient funds:** log in as `charlie` (2500 USD). Choose `4`, market `2` (BTC),
     213  quantity `1`. Expect `Insufficient funds: need 67140.0000, have 2500.0000` and *no* change to
     214  any table: no order row, no ledger entry, no holding.
     215- **Insufficient holding:** on fresh data (`./eduberza -load-data`), log in as `alice`. Choose
     216  `5`; the list shows only `1 ETH` (held 0.5000, free 0.5000). Choose `1`, quantity `5`. Expect
     217  `Insufficient holding: trying to sell 5.0000, available 0.5000 (of 0.5000 held, 0.0000 reserved)`.
     218- **Nothing to sell:** as `bob` (no crypto), choose `5`. The holdings list is empty, and you
     219  get `you hold no crypto that is free to sell` without being asked for a number.
     220- **Invalid choice from a list:** in any list (for example `8`, then `3` Remove crypto), type a
     221  number larger than the list. Expect `Invalid choice, enter a number from 1 to N.`
     222- **Invalid deposit:** choose `2` and enter `-50`. Expect `Invalid amount.`
    180223- **Duplicate registration:** register with username `alice`. Expect
    181224  `Username or email already taken.`
     225- **Invalid e-mail:** register with an e-mail without `@`. Expect `Invalid email.`
    182226- **Wrong password:** log in as `alice` with any wrong password. Expect
    183   `Invalid credentials.` — and note the same message for an unknown username, so
    184   the prototype does not leak which accounts exist.
     227  `Invalid credentials.` An unknown username gives the same message, so the prototype does not
     228  reveal which accounts exist.
     229
     230The concurrency guarantee of the sell path (two processes selling the same crypto at the same
     231moment) cannot be reproduced by typing into two terminals, because each order commits within
     232milliseconds. It is described in [UseCase0005Implementation](UseCase0005Implementation.md).
    185233
    186234### For the public presentation
    187235
    188 Demo with `alice` (already has a position, so the portfolio screen is not
    189 empty), and register a brand-new account live to show UC0001. Run the bot in a
    190 background terminal so the prices visibly move between two portfolio refreshes.
     236Demo with `alice`. She already has a position, so the portfolio screen is not empty. Register a
     237brand-new account live to show UC0001. Run the bot in a background terminal so the prices
     238visibly move between two portfolio refreshes.
    191239
    192240## Editing the ER diagram
    193241
    194 TerraER is a third-party tool and is deliberately **not** committed to this
    195 repository. Download the teacher's build from
    196 <https://bazi.finki.ukim.mk/resources/Software/> and run it:
    197 
    198 ```sh
    199 java -jar TerraER3.11.jar     # then File → Open → docs/ERModel_v01.xml
    200 ```
    201 
    202 Save new versions as `ERModel_v02.xml`, `ERModel_v03.xml`, … and export a
    203 matching PNG for each. TerraER does not add the extension itself — type
    204 `.xml` explicitly or the file will not reopen.
     242TerraER is a third-party tool and is **not** committed to this repository on purpose. Download
     243the teacher's build from <https://bazi.finki.ukim.mk/resources/Software/> and run it:
     244
     245```sh
     246java -jar TerraER3.11.jar     # then File → Open → docs/P1-ConceptualModel/ERModel_v03.xml
     247```
     248
     249The current version is `ERModel_v03.xml`. Save new versions as `ERModel_v04.xml` and so on,
     250and export a matching PNG for each. TerraER does not add the extension itself: type `.xml`
     251yourself, or the file will not reopen.
    205252
    206253## Up-to-date source code
    207254
    208 The repository is pushed to the FINKI DEVELOP git server; see the Repositories
    209 section in EPRMS for the clone URL and credentials.
     255The repository is pushed to the FINKI DEVELOP git server. The clone URL and credentials are in
     256the Repositories section in EPRMS.
    210257
    211258### About the source code
    212259
    213 - All source needed to run the prototype is in this repository: the CLI
    214   (`server/`), the market bot (`bots/`), the DDL script and the sample-data
    215   script (`server/db/`).
    216 - Third-party Go libraries are **not** vendored — `go build` downloads
    217   `github.com/lib/pq` using the pinned versions in `go.mod` and `go.sum`.
    218 - Third-party executables are **not** committed. `.gitignore` excludes `*.jar`;
    219   TerraER is downloaded from the URL above.
    220 - No third-party images, styles or frameworks are used, and there are no
    221   images in the prototype at all — the interface is text.
     260- All the source needed to run the prototype is in this repository: the CLI (`server/`), the
     261  market bot (`bots/`), the DDL script and the sample-data script (`server/db/`).
     262- Third-party Go libraries are **not** vendored. `go build` downloads `github.com/lib/pq` at
     263  the versions pinned in `go.mod` and `go.sum`.
     264- Third-party executables are **not** committed. `.gitignore` excludes `*.jar`, and TerraER is
     265  downloaded from the URL above.
     266- No third-party images, styles or frameworks are used. The prototype has no images at all;
     267  the interface is text.
  • docs/P4-Prototype/PrototypeImplementation.md

    ra531b45 ref1c1c7  
    1 = Prototype Implementation =
     1# Prototype Implementation
    22
    3 The prototype is a Go command-line application in
    4 [https://github.com/StefanTrsunov/bp/tree/main/server server/] that works against the `project`
    5 schema in PostgreSQL. It implements all seven use cases from
    6 [https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCaseModel.md UseCaseModel]
    7 – the rubric requires at least three – with every database access shown as real, executed SQL.
    8 An auxiliary program in [https://github.com/StefanTrsunov/bp/tree/main/bots bots/] simulates a
    9 live market so prices move while the prototype is running.
     3EduBerza's P4 prototype is a Go command-line program (in [`server/`](../../server/)). It works
     4against the `project` schema in PostgreSQL. It implements all seven use cases from
     5[UseCaseModel](../P3-UseCaseModel/UseCaseModel.md); the course asks for at least three. Every
     6database access is real SQL that was executed and tested. A second program, the market bot in
     7[`bots/`](../../bots/), simulates a live market so prices move while the prototype runs.
    108
    11 Build, configure, run and test instructions:
    12 [https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/BuildInstructions.md BuildInstructions].
     9## Implemented use-cases
    1310
    14 All pages listed below, together with the screenshots of each run, are kept in the project's
    15 GitHub repository, [https://github.com/StefanTrsunov/bp StefanTrsunov/bp], under
    16 `docs/P4-Prototype/`.
     11- [UseCase0001Implementation](UseCase0001Implementation.md) — Register new account
     12- [UseCase0002Implementation](UseCase0002Implementation.md) — Log in
     13- [UseCase0003Implementation](UseCase0003Implementation.md) — Deposit virtual funds
     14- [UseCase0004Implementation](UseCase0004Implementation.md) — Place market BUY order
     15- [UseCase0005Implementation](UseCase0005Implementation.md) — Place market SELL order
     16- [UseCase0006Implementation](UseCase0006Implementation.md) — View portfolio and transaction history
     17- [UseCase0007Implementation](UseCase0007Implementation.md) — Manage watchlist
    1718
    18 == Implemented use-cases ==
     19Each page follows its P3 use case step by step. It adds the exact SQL the Go code runs in that
     20step and a screenshot of the step from a real run against the database. Screenshots are in
     21[`screenshots/`](screenshots/).
    1922
    20 ||=Page=||=Use-case=||=Source=||
    21 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0001Implementation.md UseCase0001Implementation]||Register a new account||[https://github.com/StefanTrsunov/bp/blob/main/server/auth.go server/auth.go]||
    22 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0002Implementation.md UseCase0002Implementation]||Log in||[https://github.com/StefanTrsunov/bp/blob/main/server/auth.go server/auth.go]||
    23 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0003Implementation.md UseCase0003Implementation]||Deposit virtual funds||[https://github.com/StefanTrsunov/bp/blob/main/server/account.go server/account.go]||
    24 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0004Implementation.md UseCase0004Implementation]||Place market BUY order||[https://github.com/StefanTrsunov/bp/blob/main/server/trade.go server/trade.go]||
    25 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0005Implementation.md UseCase0005Implementation]||Place market SELL order||[https://github.com/StefanTrsunov/bp/blob/main/server/trade.go server/trade.go]||
    26 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0006Implementation.md UseCase0006Implementation]||View portfolio and history||[https://github.com/StefanTrsunov/bp/blob/main/server/portfolio.go server/portfolio.go]||
    27 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0007Implementation.md UseCase0007Implementation]||Manage watchlist||[https://github.com/StefanTrsunov/bp/blob/main/server/watchlist.go server/watchlist.go]||
     23- How to build, configure, run and test the prototype: [BuildInstructions](BuildInstructions.md)
     24- AI usage for this phase: [PrototypeImplementationAIUsage](PrototypeImplementationAIUsage.md)
    2825
    29 Each page mirrors its P3 use-case page and adds the actual SQL emitted by the Go code plus a
    30 screenshot of the corresponding run against the live database. The screenshots are committed
    31 alongside the pages, in
    32 [https://github.com/StefanTrsunov/bp/tree/main/docs/P4-Prototype/screenshots docs/P4-Prototype/screenshots/].
     26## Technology and architecture
    3327
    34 == What the prototype demonstrates about the database design ==
     28- **Language:** Go (module `bp_project`, `go 1.25` in [`go.mod`](../../go.mod)). The only
     29  third-party library is the PostgreSQL driver `github.com/lib/pq`.
     30- **Database:** PostgreSQL. Every table, view and function is in the `project` schema. The DDL
     31  is [`schema_creation.sql`](../../server/db/schema_creation.sql) and the sample data is
     32  [`data_load.sql`](../../server/db/data_load.sql). Both scripts are compiled into the binary
     33  and run by `./eduberza -init`.
     34- **Interface:** plain text menus on standard input and output. There are no web server,
     35  frameworks, images or styles.
     36- **Structure:** one source file per area of the application.
    3537
    36  * '''The current price is never stored as a column.''' It is always the price of the most recent
    37    row in `market_trades`, read through the `v_latest_prices` view. Both the user's own fills and
    38    the bot's simulated trades feed the same table, so there is exactly one definition of "the
    39    price".
    40  * '''Money movements are transactional.''' Buying touches five tables – `orders`, `users`,
    41    `holdings`, `transactions`, `market_trades` – inside one transaction. A failed balance check
    42    rolls the whole thing back: after a rejected purchase there is no order row, no ledger entry
    43    and no holding. This is verified in the failure-path tests in
    44    [https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/BuildInstructions.md BuildInstructions].
    45  * '''Constraints do real work.''' `UNIQUE (user_id, crypto_id)` on `holdings` is what makes the
    46    `INSERT … ON CONFLICT DO UPDATE` upsert possible, so the weighted-average entry price is
    47    recomputed by the database in one statement instead of by a read-modify-write in application
    48    code. `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` is the same idea
    49    applied to the sell path: an inconsistent reservation is impossible at the database level, not
    50    just something `trade.go` is careful about.
    51  * '''Selling reserves before it removes.''' A sell order locks the holding row, reserves the
    52    quantity being sold, then settles by removing it — see
    53    [UseCase0005Implementation](UseCase0005Implementation.md). Two sell orders placed at the same
    54    instant for more than the available quantity are serialised correctly by `SELECT ... FOR
    55    UPDATE`, not just by luck of everything happening in one CLI process; this is demonstrated
    56    there with two concurrent processes.
    57  * '''No identifiers are ever typed.''' Markets are listed with their prices before any choice is
    58    made, and everything else is selected by symbol.
     38| File | Responsibility | Use cases |
     39|------|----------------|-----------|
     40| [`server/main.go`](../../server/main.go) | Flags `-init` / `-load-data`, then starts the menu loop | — |
     41| [`server/cli.go`](../../server/cli.go) | The two menus (before and after login), input reading | all |
     42| [`server/db/db.go`](../../server/db/db.go) | Connection from `.env` / environment variables, embedded SQL scripts | — |
     43| [`server/auth.go`](../../server/auth.go) | Register, log in (SHA-256 password hash) | UC0001, UC0002 |
     44| [`server/account.go`](../../server/account.go) | Balance, deposit, transaction history | UC0003, UC0006 |
     45| [`server/market.go`](../../server/market.go) | Market list, choosing a market or a holding by number, latest price | UC0004, UC0005 |
     46| [`server/trade.go`](../../server/trade.go) | Market buy and sell orders, each in one transaction | UC0004, UC0005 |
     47| [`server/portfolio.go`](../../server/portfolio.go) | Portfolio with current value and unrealised P/L | UC0006 |
     48| [`server/watchlist.go`](../../server/watchlist.go) | List, add and remove watchlist items | UC0007 |
     49| [`bots/main.go`](../../bots/main.go) | Market bot: random-walk price ticks into `market_trades`, 1-minute candles | — |
    5950
    60 == Known limitations ==
     51## No identifiers to remember
    6152
    62 Deliberately out of scope for a first prototype, and the natural content of the later phases:
     53The user never has to type or remember an id, a code or a symbol:
    6354
    64  * Only `market` orders execute. `limit` is accepted by the schema (`orders.type`) but the
    65    matching logic is not implemented.
    66  * Passwords are SHA-256 without a salt. Adequate to demonstrate that the password itself is
    67    never stored; not adequate for real use. A proper password hash belongs in P9 (security).
    68  * Money is handled as `float64` in Go while the database columns are `numeric`. All arithmetic
    69    that must be exact – the weighted average – is done in SQL for that reason, but the Go side
    70    would need a decimal type for real use.
    71  * There is no connection pooling configuration and no explicit isolation level; both are P8
    72    topics.
    73  * Reservation only ever lives inside one transaction, because only market orders (which settle
    74    immediately) exist. A real limit-order matcher would leave `holdings.reserved_quantity` set
    75    and `orders.status = 'open'` between two separate commits, and would need a way to cancel an
    76    order to release the reservation — neither is implemented, since nothing in the prototype
    77    produces an order that stays open.
     55- Every menu is numbered, and the user answers with the number of an option.
     56- **Buying:** all active markets are listed with their latest price, numbered `1…n`. The user
     57  enters the market's number at `Market #:` (`ChooseMarket` in `market.go`).
     58- **Selling:** only the cryptos the user actually holds are listed, each with the quantity held
     59  and the quantity still free to sell. The user enters the holding's number at `Holding #:`
     60  (`ChooseHolding`). A user who holds nothing free to sell gets
     61  `you hold no crypto that is free to sell` and is never asked to choose.
     62- **Watchlist:** *Add* lists the cryptos that are not on the watchlist yet. *Remove* lists the
     63  ones that are on it. Both are numbered, and the user enters the number.
     64- A number outside the list is refused with `Invalid choice, enter a number from 1 to N.` and
     65  nothing is changed.
    7866
    79 == AI usage ==
     67The only things the user types are their own data: username, e-mail, full name, password, the
     68amount to deposit, the quantity to buy or sell, and the date range of the two P6 reports.
    8069
    81 AI was used in this phase and is logged in full, per the course rule for P1 onward.
     70## What the prototype demonstrates about the database design
    8271
    83  * '''Phase log:'''
    84    [https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/PrototypeImplementationAIUsage.md PrototypeImplementationAIUsage.md]
    85    – service used, the bugs found and fixed, the test evidence, and what I decided myself.
    86  * '''Full conversation transcript:'''
    87    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md ERModelAIUsage.md]
    88    – the same conversation produced the P1–P4 artefacts, so the complete prompt/response log is
    89    kept in one place. Direct links:
    90    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-1--2026-04-21 Session 1 – 2026-04-21],
    91    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-2--2026-08-06--2026-08-07 Session 2 – 2026-08-06/07],
    92    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16 Session 3 – 2026-09-16].
     72- **The current price is never stored as a column.** It is always the price of the most recent
     73  row in `market_trades`, read through the `v_latest_prices` view. The user's own fills and the
     74  bot's simulated trades go into the same table, so there is only one definition of "the
     75  price".
     76- **Money movements are transactional.** A buy touches five tables (`orders`, `users`,
     77  `holdings`, `transactions`, `market_trades`) inside one transaction. If the balance check
     78  fails, the whole transaction is rolled back: after a rejected purchase there is no order row,
     79  no ledger entry and no holding. The failure-path tests in
     80  [BuildInstructions](BuildInstructions.md) check this.
     81- **Constraints do real work.** `UNIQUE (user_id, crypto_id)` on `holdings` is what makes the
     82  `INSERT … ON CONFLICT DO UPDATE` upsert possible, so the database recomputes the
     83  weighted-average entry price in one statement, instead of the application reading, changing
     84  and writing the row. `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` does
     85  the same for the sell path: the database itself makes an inconsistent reservation
     86  impossible, and it does not rely only on `trade.go` being careful.
     87- **Selling reserves before it removes.** A sell order locks the holding row with
     88  `SELECT … FOR UPDATE`, reserves the quantity being sold, then settles by removing it (see
     89  [UseCase0005Implementation](UseCase0005Implementation.md)). Two sell orders for more than the
     90  free quantity, placed at the same moment from two separate processes, are serialised by the
     91  row lock. Exactly one of them succeeds. This was tested with two concurrent processes in
     92  session 3 (see [PrototypeImplementationAIUsage](PrototypeImplementationAIUsage.md)).
    9393
    94 '''Service:''' Claude Code (Anthropic), https://claude.com/claude-code – Claude subscription,
    95 model Claude Opus 4.7 (1M context) in sessions 1–2, Claude Sonnet 5 in session 3.
     94## Known limitations
    9695
    97 '''In short:''' session 1 rewrote the existing Chi/HTTP backend as the CLI prototype covering
    98 UC0001–UC0007 and added the market bot. Session 2 was a review pass I asked for, which found and
    99 fixed three bugs – a path-resolution bug that made the documented build instructions fail, an
    100 infinite loop at end of input, and an error check in the wrong order that misreported database
    101 failures as "Insufficient holding" – and replaced the read-modify-write holding update with a
    102 single `INSERT … ON CONFLICT DO UPDATE`. Session 3 added `holdings.reserved_quantity` and changed
    103 `trade.go`'s sell path to reserve crypto before removing it, closing a gap where two sell orders
    104 could be granted the same units; see
    105 [PrototypeImplementationAIUsage](PrototypeImplementationAIUsage.md#session-3--2026-09-16).
     96These were left out on purpose for a first prototype. They belong to the later phases:
     97
     98- Only `market` orders execute. The schema accepts `limit` (`orders.type`), but there is no
     99  matching logic for it.
     100- Passwords are hashed with SHA-256 and no salt. That shows the password itself is never
     101  stored, but it is not good enough for real use. A proper password hash belongs in P9
     102  (security).
     103- Money is `float64` in Go, while the database columns are `numeric`. For that reason, all
     104  arithmetic that must be exact (the weighted average) is done in SQL. Real use would need a
     105  decimal type on the Go side too.
     106- The prototype sets no connection pool and no explicit isolation level. Both are P8 topics.
     107- A reservation only exists inside one transaction, because the prototype only has market
     108  orders, and they settle immediately. A real limit-order matcher would leave
     109  `holdings.reserved_quantity` set and `orders.status = 'open'` between two separate commits.
     110  It would also need a way to cancel an order and release the reservation. Neither is
     111  implemented, because nothing in the prototype creates an order that stays open.
     112
     113## History of changes
     114
     115The code started as my own Go backend: HTTP handlers, a draft schema, and a `db.go` that
     116recreated the tables on every start. It changed as follows. The AI's share of each change is
     117logged in [PrototypeImplementationAIUsage](PrototypeImplementationAIUsage.md).
     118
     119| Date | Change | Origin |
     120|------|--------|--------|
     121| 2026-04-21 | My HTTP backend rewritten as the CLI prototype covering UC0001–UC0007. The schema errors in my draft were corrected. The market bot was added. | My code and decisions (CLI instead of web, drop the frontend, keep a simulator); rewrite by AI (session 1) |
     122| 2026-08-06/07 | Three bugs fixed: path resolution of `.env` and the SQL scripts, an endless loop at end of input, and an error check in the wrong order on the sell path. The holding update became one `INSERT … ON CONFLICT DO UPDATE`. | I asked for a code review; fixes by AI (session 2) |
     123| 2026-09-16 | `holdings.reserved_quantity` added. The sell path now reserves, then settles. Orders go from `open` to `executed`. | The edge case was mine; implementation by AI (session 3) |
     124| 2026-09-24 | Every choice is picked from a numbered list: markets by number, a sell lists only the user's holdings, the watchlist lists the cryptos. All screenshots were retaken, one per step. | I asked for a check against the P4 rules; implementation by AI (session 4) |
     125
     126**Service:** Claude Code (Anthropic), Claude subscription. Session 1 used Claude Opus 4.7 (1M
     127context), session 2 Claude Opus 5 (1M context), session 3 Claude Sonnet 5, and session 4
     128Claude Opus 5.5 (1M context).
  • docs/P4-Prototype/PrototypeImplementationAIUsage.md

    ra531b45 ref1c1c7  
    33## Name of AI service/solution that was used
    44
    5 **Claude Code** (Anthropic)
     5**Claude Code** (Anthropic), an AI coding assistant that runs in the terminal and reads and
     6edits the project files.
    67
    78- **URL:** https://claude.com/claude-code
    8 - **Type of service/subscription:** Claude subscription, model Claude Opus 4.7 (1M context).
     9- **Type of service/subscription:** Claude subscription (Claude Code CLI). A different model was
     10  used in each session:
     11
     12| Session | Date | Model |
     13|---------|------|-------|
     14| 1 | 2026-04-21 | Claude Opus 4.7 (1M context) |
     15| 2 | 2026-08-06 / 2026-08-07 | Claude Opus 5 (1M context) |
     16| 3 | 2026-09-16 | Claude Sonnet 5 |
     17| 4 | 2026-09-24 | Claude Opus 5.5 (1M context) |
     18
     19The same sessions also worked on other phases. This page covers only what concerns the P4
     20prototype and its documentation.
    921
    1022## Final result
    … …  
    1224### Results in details / description
    1325
    14 During the 2026-04-21 session the AI:
    15 
    16 - Replaced the broken HTTP + frontend scaffolding (which the student had already decided to drop) with a single-binary CLI prototype in Go, split across `main.go`, `cli.go`, `auth.go`, `account.go`, `market.go`, `trade.go`, `portfolio.go`, `watchlist.go`.
    17 - Consolidated the broken `db.go` — which used to drop every table on every startup — into a single `Connect()` plus explicit `-init` and `-load-data` flags.
    18 - Moved `go.mod` from `server/` to the project root so the same module graph covers both `server/` and `bots/`.
    19 - Fixed `go.mod`: removed the unused MySQL driver, promoted `github.com/lib/pq` to a direct dependency.
    20 - Implemented the trade flows as real database transactions with row-level `FOR UPDATE` locks, cost-basis bookkeeping, and a ledger row per operation.
    21 - Wrote a 140-line market-simulation bot that inserts trades and upserts 1-minute candles every tick.
    22 - Exercised the full prototype end-to-end against a running PostgreSQL on port 5433 and verified a sample buy and portfolio view produced the expected numbers.
     26**My own starting code (before any AI was used).** Before session 1 I had written:
     27
     28- a Go backend with HTTP handlers (Chi router);
     29- a draft database schema (`server/db/db.sql`, `server/db/schema.sql`) and a diagram description
     30  (`docs/dbdiagram.md`), based on my data model in `ep-diagram.md`;
     31- a `main.go` / `db.go` that connected to PostgreSQL and dropped and recreated every table on
     32  each start;
     33- a half-finished frontend, which I deleted myself at the start of session 1 because the
     34  prototype does not need it.
     35
     36This code is older than the git repository. The first commit (2026-08-07) was made after
     37sessions 1 and 2, so the history in git starts from the AI-improved version. My original files
     38are not in the repository. What they contained, and which errors the AI found in them, is
     39recorded in the session 1 log below and in [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md).
     40
     41**Session 1 (2026-04-21).** Starting from that code, the AI:
     42
     43- replaced my HTTP backend and the frontend scaffolding with a single-binary CLI prototype in Go,
     44  split across `main.go`, `cli.go`, `auth.go`, `account.go`, `market.go`, `trade.go`,
     45  `portfolio.go` and `watchlist.go`. This followed my decision to make a CLI and not a web app;
     46- corrected my schema. `crypto_id` had been declared as a foreign key to two tables at once in
     47  `holdings`, `orders` and `transactions`, and `market_candles` referenced a `markets` table that
     48  did not exist. The corrected schema became `schema_creation.sql`, with the sample data in
     49  `data_load.sql`;
     50- replaced my `db.go`, which dropped every table on every start, with one `Connect()` plus
     51  explicit `-init` and `-load-data` flags;
     52- moved `go.mod` to the project root, removed an unused MySQL driver, and made
     53  `github.com/lib/pq` a direct dependency;
     54- wrote the trade flows as database transactions with `FOR UPDATE` row locks, cost-basis
     55  bookkeeping and one ledger row per operation;
     56- wrote the market-simulation bot (`bots/main.go`), after I decided to keep a market simulator
     57  in the project.
     58
     59**Session 2 (2026-08-06/07).** I asked for a code review. The AI found and fixed three bugs
     60(`.env`/SQL path resolution, an endless loop at end of input, and a wrong error check on the
     61sell path). It also replaced the read-modify-write holding update with one
     62`INSERT … ON CONFLICT DO UPDATE`, removed the committed `TerraER3.11.jar` and a TradingView
     63screenshot we had no licence for, and wrote the first version of the P4 pages.
     64
     65**Session 3 (2026-09-16).** From an edge case I described, the AI added
     66`holdings.reserved_quantity`, made the sell path reserve and then settle, and gave
     67`orders.status` a real `open` → `executed` lifecycle.
     68
     69**Session 4 (2026-09-24).** I asked whether P4 fulfils the course rules. The AI found that the
     70prototype still asked the user to type a market symbol and crypto symbols, which breaks the rule
     71that the user must never have to remember identifiers or codes. It changed every choice to a
     72numbered list (`market.go`, `trade.go`, `watchlist.go`), retook every screenshot, one per
     73scenario step, and rewrote the P4 pages.
    2374
    2475### Test evidence
    2576
     77This is the current prototype (after session 4) on fresh sample data, logged in as `alice`. It
     78is a real run, and the same run is on the screenshots of
     79[UseCase0004Implementation](UseCase0004Implementation.md):
     80
    2681```
     82-- Place market buy order --
     83
     84  #     Symbol    Quote       Last price
     85  -----------------------------------------
     86  1     ADA       USD           0.453750
     87  2     BTC       USD       67140.000000
     88  3     DOGE      USD           0.122000
     89  4     ETH       USD        3520.000000
     90  5     SOL       USD         166.100000
     91Market #: 2
     92Latest price for BTC/USD = 67140.000000
     93Quantity: 0.01
    2794Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)
    2895
    29   Symbol        Quantity         Avg buy         Current           Value  Unrealised P/L
    30   BTC             0.0100    67140.000000    67140.000000        671.4000         +0.0000
    31   ETH             0.5000     3500.000000     3520.000000       1760.0000        +10.0000
    32   TOTAL                                                        2431.4000        +10.0000
     96  Symbol        Quantity      Reserved     Available         Avg buy         Current           Value  Unrealised P/L
     97  ------------------------------------------------------------------------------------------------------------------
     98  BTC             0.0100        0.0000        0.0100    67140.000000    67140.000000        671.4000         +0.0000
     99  ETH             0.5000        0.0000        0.5000     3500.000000     3520.000000       1760.0000        +10.0000
     100  ------------------------------------------------------------------------------------------------------------------
     101  TOTAL                                                                                    2431.4000        +10.0000
    33102
    34103  Cash available : 7578.6000 USD
    … …  
    39108## Summary of AI involvement
    40109
    41 | | Session 1 — 2026-04-21 | Session 2 — 2026-08-06/07 | Session 3 — 2026-09-16 |
    42 |---|---|---|---|
    43 | **What I brought** | My existing Go backend (Chi HTTP handlers) and a half-finished frontend | The CLI prototype as it stood after session 1 | A design review: the sell path had no way to reserve crypto committed to an order |
    44 | **What the AI did** | Rewrote the backend as a CLI covering UC0001–UC0007, wrote the market bot | Reviewed the code, found and fixed three bugs, improved the holding upsert | Added `holdings.reserved_quantity`, changed the sell path to reserve-then-settle, gave `Orders.status` a real lifecycle, tested it live including under real concurrency |
    45 | **What I decided** | To delete the frontend, to build a CLI rather than a web app, to keep the market simulator | To ask for a code review pass rather than documentation alone | To keep reserve+settle in one transaction rather than split it across two, since there is no cancel-order use case to recover a stuck reservation |
    46 
    47 The prototype was built in session 1 and worked. What session 2 added was a
    48 review pass I asked for specifically because I have to defend this code in
    49 person: it turned up a path-resolution bug that made my own documented build
    50 instructions fail, an infinite loop at end of input, and an error check in the
    51 wrong order that misreported database failures as "Insufficient holding".
     110| | Session 1 — 2026-04-21 | Session 2 — 2026-08-06/07 | Session 3 — 2026-09-16 | Session 4 — 2026-09-24 |
     111|---|---|---|---|---|
     112| **What I brought** | My Go backend (Chi HTTP handlers), draft schema, a half-finished frontend | The CLI prototype as it stood after session 1 | A design review: the sell path had no way to reserve crypto committed to an order | The official P4 instructions and the question whether the prototype and pages meet them |
     113| **What the AI did** | Rewrote the backend as a CLI covering UC0001–UC0007, corrected the schema, wrote the market bot | Reviewed the code, found and fixed three bugs, improved the holding upsert | Added `holdings.reserved_quantity`, reserve-then-settle sell path, `orders.status` lifecycle, tested it including real concurrency | Audited P4, made every choice a numbered list, retook all screenshots, rewrote the P4 pages |
     114| **What I decided** | To delete the frontend, to build a CLI and not a web app, to keep the market simulator | To ask for a code review and not only documentation | To keep reserve and settle in one transaction, because there is no cancel-order use case to free a stuck reservation | To fix the problems found and to have the screenshots taken from real runs |
    52115
    53116## Entire AI usage log
    54117
    55 See [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md) for the full prompt/response transcript of the 2026-04-21 session — that single conversation produced all of the P1–P4 artefacts. The defining student prompt for P4 was:
    56 
     118**About this log.** Sessions 3 and 4 are still in my local Claude Code history, so their
     119prompts are quoted **verbatim** below, typos included. The local history of sessions 1 and 2
     120(April and August 2026) is no longer available. For them, the prompts are the excerpts I wrote
     121down at the time, the same ones as on [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md),
     122where they were logged because the same conversation produced P1–P4. The AI's responses are
     123summarised, because several ran to many pages of code and file edits.
     124
     125---
     126
     127### Session 1 — 2026-04-21 (Claude Opus 4.7, 1M context)
     128
     129**Prompt (student, excerpt):**
     130> This is my project for my exam Database, go to the docs folder and README.md there you will find all of the documentation, afterwards take a look at the whole project. Delete everything you want add everything you want just make it work and make it fill in or to follow all of the needed instructions. Ask me anything, also tell me what do I need to do
     131
     132**Response (AI, summarised):**
     133- Read `docs/opis.md`, `docs/ep-diagram.md`, `docs/dbdiagram.md`, `server/db/db.sql`,
     134  `server/db/schema.sql` and all Go sources.
     135- Found errors in my schema: `holdings.crypto_id` declared as a foreign key to both
     136  `users(id)` and `crypto(id)` (and the same mistake in `orders` and `transactions`), and
     137  `market_candles` referencing a non-existent `markets` table. Also found that `main.go` dropped
     138  and recreated all tables on every start, wiping the data.
     139- Asked me to decide on team composition, prototype shape (CLI or HTTP), which columns to keep,
     140  which use cases to implement, and the documentation language.
     141
     142**Prompt (student, excerpt):**
     143> Read it now, just filled it in, also deleted the frontend we don' need that
     144
     145**Response (AI, summarised):**
     146- Re-read `docs/README.md`, which now contained the course rubric, and explained the AI rules
     147  per phase and the deliverables of each phase.
     148
     149**Prompt (student, excerpt):**
    57150> do everything that you need to do, and tell me after what do I need to do
    58151
    59 which translated into the rewrite of the backend and the writing of this documentation.
    60 
    61 > **Student action required:** append any further prompts here where you asked the AI to debug, extend, or refactor the prototype.
    62 
    63 
    64 ### Session 2 — 2026-08-06 / 2026-08-07
    65 
    66 Prompts are logged in full in [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-2--2026-08-06--2026-08-07).
    67 The student asked the AI to complete the phase documentation and to *"fix some
    68 database things or golang things if you think we can do it better"*. What the AI
    69 changed in the prototype, and why:
     152**Response (AI, summarised):**
     153- Rewrote the backend as the CLI prototype (UC0001–UC0007), with the corrected
     154  `schema_creation.sql`, `data_load.sql`, the new `db.go` with `-init` / `-load-data`, the
     155  transactional trade flows and the market bot. The details are under *Results in details*
     156  above.
     157- Ran the prototype end to end against PostgreSQL on port 5433 and checked that a sample buy
     158  and the portfolio view gave the expected numbers.
     159
     160---
     161
     162### Session 2 — 2026-08-06 / 2026-08-07 (Claude Opus 5, 1M context)
     163
     164**Prompt (student, excerpt):**
     165> […] do all of the other Phases till m0.
     166> opis.md It's p0 so I will take care of that. Delete anything that we don't need,
     167> make all of the phases and terra diagram if you can, and delete anything
     168> that we don't need and make a documentation about how to start it.
     169
     170**Prompt (student, excerpt, follow-up):**
     171> Also fix some database things or golang things if you think we can do it better,
     172
     173**Response (AI, summarised): what changed in the prototype, and why**
    70174
    71175**Bugs found and fixed**
    72176
    73 1. `server/db/db.go` resolved `../.env` and `db/schema_creation.sql` as paths
    74    relative to the working directory, so they only worked when the program was
    75    started from inside `server/`. Following the documented instructions — build
    76    from the repository root and run `./eduberza -init` — failed with
    77    `password authentication failed for user "postgres"`, because the `.env` file
    78    was never found and the defaults were used. The two SQL scripts are now
    79    compiled into the binary with `go:embed`, and `.env` is located by searching
    80    the working directory and every parent. Real environment variables now take
    81    precedence over the file, which is what allows the prototype to be pointed at
    82    the faculty database without editing anything.
    83 2. `prompt()` in `server/cli.go` ignored the error from `ReadString`. At end of
    84    input — Ctrl-D, or a scripted run — it returned an empty string forever and
    85    the menu loop spun printing "Unknown option." without end. It now exits
    86    cleanly.
    87 3. On the sell path, `trade.go` checked `err == sql.ErrNoRows || held < qty`
    88    before checking for other errors, so any scan failure was reported to the user
    89    as "Insufficient holding" regardless of the real cause. The error check now
    90    comes first.
     1771. `server/db/db.go` resolved `../.env` and `db/schema_creation.sql` relative to the working
     178   directory, so they only worked when the program was started from inside `server/`.
     179   Following the documented instructions (build from the repository root and run
     180   `./eduberza -init`) failed with `password authentication failed for user "postgres"`,
     181   because `.env` was never found and the defaults were used. The two SQL scripts are now
     182   compiled into the binary with `go:embed`, and `.env` is searched for in the working directory
     183   and every parent. Real environment variables now take precedence over the file.
     1842. `prompt()` in `server/cli.go` ignored the error from `ReadString`. At end of input (Ctrl-D,
     185   or a scripted run) it returned an empty string forever, and the menu loop kept printing
     186   "Unknown option." without end. It now exits cleanly.
     1873. On the sell path, `trade.go` checked `err == sql.ErrNoRows || held < qty` before checking
     188   for other errors, so any scan failure was reported as "Insufficient holding". The error
     189   check now comes first.
    91190
    92191**Improvements**
    93192
    94 4. The holding upsert was a read-modify-write in Go (`SELECT ... FOR UPDATE`,
    95    compute the new weighted average in `float64`, then `INSERT` or `UPDATE`). It
    96    is now a single `INSERT … ON CONFLICT (user_id, crypto_id) DO UPDATE`, so the
    97    average is recomputed by PostgreSQL in `numeric` arithmetic and the statement
    98    relies on the unique constraint that the relational design already declared.
    99 5. `TerraER3.11.jar` was removed from the repository and `.gitignore` now
    100    excludes `*.jar`, because P4 requires third-party executables to be
    101    downloaded rather than committed. `.env` is excluded too and `.env.example`
    102    was added in its place.
    103 6. `image.png`, a TradingView screenshot, was deleted — the project has no
    104    licence to publish it and P4 requires explicit usage rights.
    105 
    106 **Verification**
    107 
    108 All seven use cases were executed against a live PostgreSQL 16 database. The
    109 screenshots on the `UseCaseXXXXImplementation` pages are captures of those runs.
    110 The four failure paths were tested, and the rollback behaviour was checked
    111 directly in SQL: after a rejected purchase, the affected user has zero rows in
    112 `orders`, `transactions` and `holdings`.
    113 
    114 > **Student action required:** read the changed files (`server/db/db.go`,
    115 > `server/cli.go`, `server/trade.go`, `server/db/schema_creation.sql`) before the
    116 > presentation. You will be asked how the buy transaction works, and the answer
    117 > has to be yours.
    118 
    119 ### Session 3 — 2026-09-16
    120 
    121 Prompted by a design review I did myself, logged in full in
    122 [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16).
    123 The report: a user who owns 2 BTC and places a sell order for 0.5 BTC has that
    124 crypto immediately removed from `quantity`, but nothing in the model recorded
    125 that a *pending* order had already committed part of a position before it
    126 settled — `holdings` had `quantity` and `avg_price` only, no equivalent of the
    127 `available_balance`/`invested_balance` split already used for cash.
    128 
    129 **Bug fixed**
    130 
    131 `server/trade.go`'s sell path checked `held < qty` directly against
    132 `holdings.quantity`. This happened to be safe against concurrent double-sells
    133 only because the whole operation — order, holding check, holding update,
    134 balance update, ledger, trade — runs inside one transaction with a
    135 `SELECT ... FOR UPDATE` lock. It was not safe against the actual scenario
    136 described: nothing distinguished "owned" from "owned, but already promised to
    137 this order," which matters the moment an order can legitimately sit `open`
    138 across more than one transaction — exactly what a limit-order matcher would
    139 need, and what `orders.status` already implied was coming.
    140 
    141 **What changed**
    142 
    143 1. `holdings.reserved_quantity numeric(20,4) NOT NULL DEFAULT 0 CHECK
    144    (reserved_quantity >= 0 AND reserved_quantity <= quantity)` added to
    145    `schema_creation.sql`.
    146 2. `v_portfolio` gained `reserved_quantity` and derived `available_quantity`.
    147 3. `trade.go`'s sell path now: locks the holding, computes
    148    `available := quantity - reserved_quantity`, rejects if `available < qty`
    149    (previously: rejects if `quantity < qty`), reserves
    150    (`reserved_quantity += qty`), then settles (`quantity -= qty;
    151    reserved_quantity -= qty`) — two statements instead of one, kept inside the
    152    same transaction rather than split into two commits, which would risk an
    153    order stuck `open` with reserved crypto and no cancel command to free it.
    154 4. Both `PlaceOrder` branches now insert the order as `status='open'` and
    155    `UPDATE ... SET status='executed', executed_at=now()` at the end, instead
    156    of inserting `'executed'` directly — `Orders.status` is now a real
    157    lifecycle rather than a label written once.
    158 5. `portfolio.go` gained `Reserved`/`Available` columns, reading
    159    `v_portfolio.reserved_quantity`/`available_quantity`, so the new field is
    160    something a Trader can actually see.
    161 6. The error message on the sell path changed from
    162    `"Insufficient holding: trying to sell X, hold Y"` to
    163    `"Insufficient holding: trying to sell X, available Y (of Z held, W
    164    reserved)"`, since "how much you hold" is no longer the only number that
    165    matters.
    166 
    167 **Test evidence**
    168 
    169 Verified against PostgreSQL 16 (`bp_database`, `localhost:5433`):
     1934. The holding upsert was a read-modify-write in Go. It is now a single
     194   `INSERT … ON CONFLICT (user_id, crypto_id) DO UPDATE`, so PostgreSQL recomputes the average
     195   in `numeric` arithmetic, relying on the unique constraint of the relational design.
     1965. `TerraER3.11.jar` was removed from the repository and `.gitignore` now excludes `*.jar`,
     197   because P4 requires third-party executables to be downloaded, not committed. `.env` is
     198   excluded too, and `.env.example` was added in its place.
     1996. `image.png`, a TradingView screenshot, was deleted, because the project has no licence to
     200   publish it.
     201
     202**Verification.** All seven use cases were run against PostgreSQL 16, and the four failure
     203paths were tested. After a rejected purchase, the affected user had zero rows in `orders`,
     204`transactions` and `holdings`.
     205
     206---
     207
     208### Session 3 — 2026-09-16 (Claude Sonnet 5)
     209
     210**Prompt (student, verbatim):**
     211> Soo we have a problem here In this scenario we have an edge case where our functionallity doesn't work:
     212> Suppose the user owns:
     213>
     214> 2 BTC
     215>
     216> and wants to sell:
     217>
     218> 0.5 BTC at market price
     219>
     220> A sensible procedure is:
     221>
     222> 1. User creates an Order
     223>
     224> Orders gets something like:
     225>
     226> id    user    market    side    type    quantity    status
     227> O1    Alice    BTC/USD    sell    market    0.5    open
     228>
     229> At this point, no trade has necessarily happened yet.
     230>
     231> 2. Reserve the crypto
     232>
     233> This is where your current model has a gap.
     234>
     235> You currently have:
     236>
     237> Holds
     238> quantity
     239> avg_price
     240>
     241> but you don't have a concept of reserved/committed crypto.
     242>
     243> For cash, you already explicitly distinguish:
     244>
     245> available_balance
     246> invested_balance
     247>
     248> For crypto, you probably want the equivalent:
     249>
     250> quantity
     251> reserved_quantity
     252> avg_price
     253>
     254> So Alice might go from:
     255>
     256> quantity = 2.0 BTC
     257> reserved_quantity = 0
     258>
     259> to:
     260>
     261> quantity = 2.0 BTC
     262> reserved_quantity = 0.5 BTC
     263>
     264> with:
     265>
     266> available BTC = quantity - reserved_quantity = 1.5 BTC
     267>
     268> The important point is that you generally don't actually remove the 0.5 BTC from her position yet. You reserve it so she cannot submit another sell order for the same BTC.
     269>
     270> Can you please start from Phase 1 and modify everything to make it work like it should, modify the AIUsage.md in every Phase, and can you make the ConceptualMOdel a little bit more put together not that much spread out.
     271
     272The other prompts of that day were about the P1 diagram (logged on
     273[ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md)) and about P5 and P6. None of them
     274changed the P4 prototype.
     275
     276**Response (AI, summarised)**
     277
     278The gap: `trade.go`'s sell path checked `held < qty` directly against `holdings.quantity`.
     279Nothing distinguished "owned" from "owned, but already promised to an order". This was safe only
     280because the whole sell runs in one transaction with a `SELECT … FOR UPDATE` lock. It would stop
     281being safe the moment an order could stay `open` across transactions.
     282
     283What changed:
     284
     2851. `holdings.reserved_quantity numeric(20,4) NOT NULL DEFAULT 0 CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)`
     286   was added to `schema_creation.sql`.
     2872. `v_portfolio` gained `reserved_quantity` and the derived `available_quantity`.
     2883. The sell path in `trade.go` now locks the holding, computes
     289   `available := quantity - reserved_quantity`, rejects the order if `available < qty`,
     290   reserves (`reserved_quantity += qty`), then settles (`quantity -= qty; reserved_quantity -= qty`).
     291   All of this stays inside the same transaction.
     2924. Both branches of `PlaceOrder` insert the order as `status='open'` and set
     293   `status='executed', executed_at=now()` at the end.
     2945. `portfolio.go` shows new `Reserved` and `Available` columns.
     2956. The sell error message became
     296   `"Insufficient holding: trying to sell X, available Y (of Z held, W reserved)"`.
     297
     298**Test evidence** (PostgreSQL 16, `bp_database`, `localhost:5433`):
    170299
    171300```
    … …  
    192321```
    193322
    194 Full transcripts are on
    195 [UseCase0005Implementation](UseCase0005Implementation.md). The rejected-order
    196 rollback guarantee from session 2 was re-checked too: after both the
    197 insufficient-funds and insufficient-holding failure paths, the affected user
    198 still has zero new rows in `orders`.
    199 
    200 **What I decided:** to keep reserve and settle inside a single transaction —
    201 splitting them into two, so an order genuinely sits `open` and reserved
    202 between two commits, is what a real limit-order matcher will eventually need,
    203 but building that now would add a way for an order to get stuck without also
    204 building a way to cancel it, which is out of scope for this review. `go build
    205 ./...` was run after every change; all seven use cases were re-exercised
    206 manually.
     323(At that time the sell path still asked for a symbol, so a user with no holding could reach the
     324check. Since session 4, such a user is never offered anything to sell.)
     325
     326**What I decided:** to keep reserve and settle inside a single transaction. Splitting them into
     327two commits, so that an order really sits `open` and reserved in between, is what a real
     328limit-order matcher will need. Building it now would add a way for an order to get stuck without
     329a way to cancel it.
     330
     331---
     332
     333### Session 4 — 2026-09-24 (Claude Opus 5.5, 1M context)
     334
     335**Prompt (student, verbatim):**
     336> Does the P4 documentation, fullfill this:
     337>
     338> *[pasted: the complete official text "Instructions on Phase P4: First Application Prototype"
     339> from the course site — the prototype requirements, the rule that the user must never have to
     340> remember identifiers or codes, the PrototypeImplementation / UseCaseXXXXImplementation /
     341> BuildInstructions templates, the source-code rules and the AI Use rules with the
     342> PrototypeImplementationAIUsage template]*
     343>
     344> is this correct?
     345> Or do we need to change anything?
     346
     347**Response (AI, summarised):** an audit of the prototype and of the P4 pages against each
     348requirement. The main findings:
     349- The prototype asked the user to type a market symbol (`Market symbol (e.g. BTC):`) and, in the
     350  watchlist, a crypto symbol. That breaks the rule that every choice must be picked from a list.
     351- The use-case implementation pages had one screenshot per use case, not one per step, and did
     352  not show the SQL at every step.
     353- `PrototypeImplementation.md` was written in Trac markup inside a Markdown file. It did not
     354  follow the template, and it said identifiers were never typed, which was not accurate.
     355- `BuildInstructions` had wrong details: the `-init` message, the order of the history, an
     356  outdated ER diagram path, an unpinned PostgreSQL version described as 16, and a concurrency
     357  test with no steps to prepare it.
     358- This page listed only one model, still had "Student action required" notes, and had no
     359  line-by-line log.
     360
     361**Prompt (student, verbatim):**
     362> YEs you can start fixing it, and can you make the screenshots yourself?
     363
     364**Response (AI, summarised):**
     365- **Code** (`market.go`, `trade.go`, `watchlist.go`):
     366  - `ListMarkets` now returns the numbered list it prints. `ChooseMarket` asks for `Market #:`
     367    and takes the market by its position in that list.
     368  - New `ChooseHolding` for the sell path. It lists only the user's holdings that still have a
     369    free quantity, with `Held` and `Free to sell`, and asks for `Holding #:`.
     370  - Watchlist *Add* lists, numbered, the cryptos not yet on the watchlist. *Remove* lists the ones
     371    on it.
     372  - A shared `pickNumber` refuses anything outside `1…n` with
     373    `Invalid choice, enter a number from 1 to N.`
     374  - The SQL that looked a crypto up by symbol (`WHERE upper(c.symbol) = upper($1)`) is gone. The
     375    chosen row's id is used directly.
     376- **Screenshots:** all of them were retaken from real CLI sessions, driven in a pseudo-terminal
     377  against freshly initialised sample data. There is one screenshot per scenario step, including
     378  the alternate flows (invalid e-mail, duplicate user, wrong password, invalid amount,
     379  insufficient funds, insufficient holding, invalid list choice). This gave 30 screenshots,
     380  which replace the old 8.
     381- **Documentation:**
     382  - Rewrote the seven UseCase000XImplementation pages, with the SQL of each step quoted literally
     383    from the code.
     384  - Rewrote [PrototypeImplementation](PrototypeImplementation.md) (real Markdown, template
     385    order, accurate description of the choices), [BuildInstructions](BuildInstructions.md) (the
     386    corrections above, a mini-guide of every menu item, and a smoke test re-run on 2026-09-24)
     387    and this page.
     388  - Made small matching edits to the P3 use cases UC0004, UC0005 and UC0007, so that the "system
     389    lists …, user picks …" steps match the prototype.
     390  - Wrote the wiki versions of the P4 pages for the faculty site.
     391
     392**What I decided:** to accept the numbered-list change, because it is what the P4 rule asks for.
     393I also decided to have the screenshots made from real runs instead of editing the old ones.
  • docs/P4-Prototype/UseCase0001Implementation.md

    ra531b45 ref1c1c7  
    1 # Use-case 0001 Implementation — Register
     1# Use-case 0001 Implementation - Register new account
    22
    3 **Initiating actor:** Visitor. **Source file:** `server/auth.go`, function `Register`.
     3**Initiating actor:** Visitor
    44
    5 ## Scenario (implemented)
     5**Other actors:** —
    66
    7 1. **User** chooses option `[1] Register` from the anonymous menu.
     7A new person creates an account on EduBerza so that they can later log in as a
     8Trader ([UseCase0002](UseCase0002Implementation.md)). The Visitor enters a
     9username, an e-mail address, a full name and a password. The system validates the
     10input (required fields, an `@` in the e-mail, a password of at least 6 characters),
     11refuses a username or e-mail that is already registered, and stores only a SHA-256
     12hash of the password, never the password itself. A new account starts with a cash
     13balance of 0 USD; money is added later with a deposit
     14([UseCase0003](UseCase0003Implementation.md)).
    815
     16Original use-case description (P3): [UseCase0001](../P3-UseCaseModel/UseCase0001.md).
     17Implementation: [`server/auth.go`](../../server/auth.go), function `Register`
     18(the password hash is computed by `hashPassword` in the same file).
    919
    10 2. **System** prompts for username, email, full name and password (`server/auth.go:20-23`).
     20## Scenario
    1121
    12 3. **User** enters the values.
     221. **Visitor** chooses `[1] Register` in the anonymous menu (types `1`).
     232. **System** prints `-- Register --` and asks, one prompt after another, for
     24   `Username:`, `Email:`, `Full name:` and `Password (min 6 chars):`.
    1325
    14 4. **System** validates (empty checks, `@` in email, ≥ 6 chars in password) and then asks the database:
     26   The screenshot shows steps 1–2: option `1` is chosen and the first prompt
     27   (`Username:`) is waiting for input.
     28
     29   ![UC0001 steps 1-2: Visitor chooses Register, system asks for the data](screenshots/uc0001_1_register.png)
     30
     313. **Visitor** enters the values: `marko`, `marko@example.com`, `Marko Markovski`,
     32   `secret1`.
     334. **System** validates the input in Go, without accessing the database:
     34   - username, e-mail and password must be non-empty, otherwise it prints
     35     `Username, email and password are required.` and the scenario ends;
     36   - the e-mail must contain `@` (see alternate flow 3a);
     37   - the password must be at least 6 characters long, otherwise it prints
     38     `Password must be at least 6 characters.` and the scenario ends.
     395. **System** checks whether the username or the e-mail already exists
     40   (`$1` = username, `$2` = e-mail):
    1541
    1642   ```sql
    17    SELECT EXISTS(
    18        SELECT 1 FROM users WHERE username = $1 OR email = $2
    19    );
     43   SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 OR email = $2)
    2044   ```
    2145
    22 
    23 5. **System** inserts the new row (password hashed in Go, not in SQL, to keep hashing identical between register and login):
     46   If the result is `true`, alternate flow 5a applies.
     476. **System** creates the account (`$1` = username, `$2` = e-mail, `$3` = full name,
     48   `$4` = password hash). The hash is computed in Go by `hashPassword` as the
     49   hex-encoded SHA-256 of the password — the same value that P3's
     50   `encode(digest($4, 'sha256'), 'hex')` would produce in SQL. Hashing on the Go side
     51   keeps it identical to the check done at login (SQL as in the code, only the Go
     52   source indentation removed):
    2453
    2554   ```sql
    2655   INSERT INTO users (username, email, full_name, password_hash, available_balance)
    27    VALUES ($1, $2, $3, $4, 0);
     56   VALUES ($1, $2, $3, $4, 0)
    2857   ```
    2958
    30 6. **System** confirms: `Account created. You can now log in.`
     597. **System** prints `Account created. You can now log in.` and returns to the
     60   anonymous menu; the Visitor can continue with
     61   [UseCase0002](UseCase0002Implementation.md).
    3162
    32    ![Registering a new account](screenshots/uc0001_register.png)
     63   The screenshot shows steps 3–7 of the successful attempt (bottom half): the
     64   entered values, the confirmation and the anonymous menu again. The top half is the
     65   earlier rejected attempt from alternate flow 3a.
     66
     67   ![UC0001 steps 3-7: valid data, account created](screenshots/uc0001_3_7_created.png)
     68
     69All statements are run on the `project` schema: the connection sets
     70`search_path=project,public` (`server/db/db.go`), so `users` means `project.users`.
     71
     72### Alternate flow 3a — invalid e-mail
     73
     74In step 3 the Visitor entered `marko.example.com` (no `@`). The check in step 4
     75fails, the system prints `Invalid email.` and no SQL statement is executed. In the
     76prototype the system then shows the anonymous menu again and the Visitor chooses
     77`[1] Register` once more, which returns the scenario to step 2.
     78
     79![UC0001 alternate flow 3a: invalid e-mail](screenshots/uc0001_3a_invalid_email.png)
     80
     81### Alternate flow 5a — duplicate username or e-mail
     82
     83After `marko` has been created, the Visitor tries to register again with username
     84`marko`, e-mail `other@example.com`, full name `Marko Two`, password `secret2`. The
     85query from step 5 (`$1` = `marko`, `$2` = `other@example.com`) returns `true`
     86because the username is taken, so the system prints
     87`Username or email already taken.`, does not run the `INSERT`, and the scenario
     88ends in the anonymous menu.
     89
     90![UC0001 alternate flow 5a: username already taken](screenshots/uc0001_5a_duplicate.png)
    3391
    3492## How to reproduce
    … …  
    3795./eduberza -init      # optional: reset to a known state
    3896./eduberza
    39 # choose [1] Register, then enter username / email / full name / password
     97# [1] Register: marko / marko.example.com / Marko Markovski / secret1  -> Invalid email.
     98# [1] Register: marko / marko@example.com / Marko Markovski / secret1  -> Account created.
     99# [1] Register: marko / other@example.com / Marko Two / secret2        -> Username or email already taken.
    40100```
    41101
    42 The screenshot above is from an actual run: registering `marko` /
    43 `marko@example.com`, followed by the confirmation line.
     102All three screenshots come from one real run of exactly these inputs.
  • docs/P4-Prototype/UseCase0002Implementation.md

    ra531b45 ref1c1c7  
    1 # Use-case 0002 Implementation — Log in
     1# Use-case 0002 Implementation - Log in
    22
    3 **Initiating actor:** Visitor. **Source file:** `server/auth.go`, function `Login` + `authenticate`.
     3**Initiating actor:** Visitor
    44
    5 ## Scenario (implemented)
     5**Other actors:** —
    66
    7 1. **User** chooses option `[2] Login`.
     7A registered user authenticates with a username and password so that the system
     8treats all following actions as actions of that Trader. The system looks the user up
     9by username and compares the stored password hash with the SHA-256 hash of the
     10entered password. An unknown username and a wrong password give the same answer,
     11`Invalid credentials.`, so the system does not reveal which usernames exist. After a
     12successful login the user's id and username are kept in the in-process session and
     13the authenticated (Trader) menu is shown, from which all other Trader use-cases
     14start.
    815
     16Original use-case description (P3): [UseCase0002](../P3-UseCaseModel/UseCase0002.md).
     17Implementation: [`server/auth.go`](../../server/auth.go), functions `Login` and
     18`authenticate` (password hash by `hashPassword`).
    919
    10 2. **System** prompts for username and password.
     20## Scenario
    1121
    12 3. **User** enters `alice` / `test123`.
     221. **Visitor** chooses `[2] Login` in the anonymous menu (types `2`).
     232. **System** prints `-- Login --` and asks for `Username:` and then `Password:`.
    1324
    14 4. **System** looks up the user and compares hashes:
     25   The screenshot shows steps 1–2: option `2` is chosen and the `Username:` prompt
     26   is waiting for input.
     27
     28   ![UC0002 steps 1-2: Visitor chooses Login, system asks for credentials](screenshots/uc0002_1_login.png)
     29
     303. **Visitor** enters the username and the password. (If either is empty, the
     31   system prints `Username and password are required.` without accessing the
     32   database.)
     334. **System** looks up the user (`$1` = entered username):
    1534
    1635   ```sql
    17    SELECT id, password_hash FROM users WHERE username = $1;
     36   SELECT id, password_hash FROM users WHERE username = $1
    1837   ```
    1938
    20    The Go side (`authenticate` in `server/auth.go`) compares the returned `password_hash` against `sha256hex(entered_password)`.
     395. If no row is returned (`sql.ErrNoRows` in Go), the **System** responds
     40   `Invalid credentials.` and the scenario ends.
     416. If a row is returned, the **System** compares the returned `password_hash` with
     42   `hashPassword(entered password)` (hex-encoded SHA-256, computed in Go). On a
     43   mismatch it responds `Invalid credentials.` and the scenario ends.
    2144
    22 5. **System** on success stores `{UserID, Username}` in the in-process `Session` and shows the authenticated menu.
     45   The screenshot shows this failure path with an existing user and a wrong
     46   password: `alice` / `wrongpass`. The query from step 4 finds alice's row, the
     47   hash comparison of step 6 fails, and the system prints `Invalid credentials.` and
     48   returns to the anonymous menu. (An unknown username — step 5 — prints exactly the
     49   same message.)
    2350
    24    ![A rejected login followed by a successful one](screenshots/uc0002_login.png)
     51   ![UC0002 steps 5-6: wrong password, Invalid credentials](screenshots/uc0002_5_6_invalid.png)
     52
     537. On a match, the **System** stores the returned `id` and the username in the
     54   session (`s.UserID`, `s.Username`), prints `Login successful.` and displays the
     55   authenticated menu headed `--- Logged in as alice ---`.
     56
     57   The screenshot shows steps 3–7 of the second, successful attempt with the seed
     58   credentials `alice` / `test123` (the first, rejected attempt is still visible at
     59   the top of the window).
     60
     61   ![UC0002 steps 3-7: correct credentials, authenticated menu](screenshots/uc0002_7_success.png)
     62
     63The query runs on the `project` schema (the connection sets
     64`search_path=project,public`), so `users` means `project.users`.
     65
     66### Alternate flow 4a (P3) — lookup combined with the live balance
     67
     68P3 describes an optional variant that checks the password in SQL and returns the
     69balances in the same query. The P4 prototype does **not** use it: login always uses
     70the query from step 4 with the hash comparison in Go, and the balances are read
     71separately when the Trader asks for them (`[1] View balance`, see
     72[UseCase0003](UseCase0003Implementation.md)).
    2573
    2674## Seed credentials
    2775
    28 | Username | Password | Balance |
    29 |----------|----------|---------|
    30 | `alice`    | `test123` | 10000.00 USD |
    31 | `bob`      | `test123` |  5000.00 USD |
    32 | `charlie`  | `test123` |  2500.00 USD |
     76State after `./eduberza -init` (`server/db/data_load.sql`):
     77
     78| Username  | Password  | Available balance | Invested balance | Holdings |
     79|-----------|-----------|------------------:|-----------------:|----------|
     80| `alice`   | `test123` | 8250.00 USD       | 1750.00 USD      | 0.5 ETH  |
     81| `bob`     | `test123` | 5000.00 USD       | 0.00 USD         | —        |
     82| `charlie` | `test123` | 2500.00 USD       | 0.00 USD         | —        |
     83
     84## How to reproduce
     85
     86```sh
     87./eduberza -init
     88./eduberza
     89# [2] Login: alice / wrongpass  -> Invalid credentials.
     90# [2] Login: alice / test123    -> Login successful.  (authenticated menu)
     91```
     92
     93The screenshots come from one real run of exactly these inputs.
  • docs/P4-Prototype/UseCase0003Implementation.md

    ra531b45 ref1c1c7  
    1 # Use-case 0003 Implementation — Deposit
     1# Use-case 0003 Implementation - Deposit virtual funds
    22
    3 **Initiating actor:** Trader. **Source file:** `server/account.go`, function `Deposit`.
     3**Initiating actor:** Trader
    44
    5 ## Scenario (implemented)
     5**Other actors:** —
    66
    7 1. **User** chooses `[2] Deposit virtual funds`.
    8 2. **System** prompts for an amount in USD.
    9 3. **User** enters `2500`.
    10 4. **System** opens a database transaction and runs:
     7A logged-in Trader tops up their virtual cash balance in USD. This is a
     8simulation-only operation: no real money changes hands, the amount is simply added to
     9the Trader's `available_balance`. Every deposit is also recorded in the ledger
     10(`transactions`) so that it appears in the transaction history
     11([UseCase0006](UseCase0006Implementation.md)). The operation writes to two tables —
     12the user row and the ledger — inside a single database transaction, so either both
     13changes are stored or neither is. Non-numeric, zero or negative amounts are rejected
     14before the database is touched.
     15
     16Original use-case description (P3): [UseCase0003](../P3-UseCaseModel/UseCase0003.md).
     17Implementation: [`server/account.go`](../../server/account.go), function `Deposit`;
     18the verification uses `ShowBalance` from the same file.
     19
     20Precondition: the Trader is logged in ([UseCase0002](UseCase0002Implementation.md));
     21in the run below as `alice`, who starts from the seed state (available 8250.00 USD,
     22invested 1750.00 USD).
     23
     24## Scenario
     25
     261. **Trader** chooses `[2] Deposit virtual funds` in the authenticated menu
     27   (types `2`).
     282. **System** prints `-- Deposit virtual funds --` and asks `Amount (USD):`.
     29
     30   The screenshot shows steps 1–2: the login as alice, the authenticated menu, the
     31   choice `2` and the amount prompt waiting for input.
     32
     33   ![UC0003 steps 1-2: Trader chooses Deposit, system asks for the amount](screenshots/uc0003_1_2_deposit.png)
     34
     353. **Trader** enters an amount: `500`.
     364. **System** validates the input in Go, without accessing the database: the text
     37   must parse as a number (`strconv.ParseFloat`) and be greater than 0 (see
     38   alternate flow 4a).
     395. **System** opens one database transaction (`db.DB.Begin()`), increments the
     40   balance, writes the ledger row and commits. Both statements run in this single
     41   transaction; if either fails, the deferred `tx.Rollback()` undoes everything.
     42   `BEGIN` and `COMMIT` are issued by Go's `Begin()` / `Commit()`; the two
     43   statements are sent exactly as in the code:
    1144
    1245   ```sql
    1346   BEGIN;
    14      UPDATE users
    15         SET available_balance = available_balance + $1,
    16             updated_at        = now()
    17       WHERE id = $2;
    18      INSERT INTO transactions (user_id, type, amount, currency, description)
    19      VALUES ($2, 'deposit', $1, 'USD', 'Virtual deposit');
     47
     48   UPDATE users
     49       SET available_balance = available_balance + $1,
     50           updated_at        = now()
     51     WHERE id = $2;
     52
     53   INSERT INTO transactions (user_id, type, amount, currency, description)
     54   VALUES ($1, 'deposit', $2, 'USD', 'Virtual deposit');
     55
    2056   COMMIT;
    2157   ```
    2258
    23    ![Depositing 2500 USD, then checking the balance](screenshots/uc0003_deposit.png)
     59   Parameters: placeholders are numbered per statement. In the `UPDATE`, `$1` is the
     60   amount (`500`) and `$2` the user id (`amt, s.UserID`); in the `INSERT` it is the
     61   other way round, `$1` is the user id and `$2` the amount (`s.UserID, amt`),
     62   matching the column order.
     636. **System** confirms `Deposited 500.0000 USD.` and returns to the authenticated
     64   menu.
    2465
    25 5. **System** confirms: `Deposited 2500.0000 USD.`
     66   The screenshot shows steps 3–6: the entered amount `500`, the confirmation and the
     67   authenticated menu again.
     68
     69   ![UC0003 steps 3-6: amount deposited](screenshots/uc0003_3_6_deposited.png)
     70
     71The statements run on the `project` schema (the connection sets
     72`search_path=project,public`), so `users` and `transactions` mean `project.users`
     73and `project.transactions`.
     74
     75### Alternate flow 4a — invalid amount
     76
     77If the amount is not a number, or is zero or negative, the system prints
     78`Invalid amount.`; no transaction is started and nothing is written. In the run the
     79Trader first entered `-50`. In the prototype the system then shows the
     80authenticated menu again and the Trader chooses `[2] Deposit virtual funds` once
     81more, which returns the scenario to step 2.
     82
     83![UC0003 alternate flow 4a: invalid amount](screenshots/uc0003_4a_invalid.png)
    2684
    2785## Verification
    2886
    29 Right after the deposit, `[1] View balance` runs:
     87Right after the deposit the Trader chooses `[1] View balance` (function
     88`ShowBalance`), which runs (`$1` = user id):
    3089
    3190```sql
    32 SELECT available_balance, invested_balance FROM users WHERE id = $1;
     91SELECT available_balance, invested_balance FROM users WHERE id = $1
    3392```
    3493
    35 In the screenshot above, alice starts from the seed state (available 8250.00,
    36 invested 1750.00) and ends at available **10750.00** — increased by exactly the
    37 2500.00 deposited, with `invested_balance` untouched.
     94It prints `Available: 8750.0000 USD`, `Invested : 1750.0000 USD`,
     95`Total    : 10500.0000 USD`. The available balance grew from the seed value 8250.00
     96by exactly the 500.00 deposited (the rejected `-50` changed nothing), and
     97`invested_balance` is untouched.
     98
     99![UC0003 verification: balance after the deposit](screenshots/uc0003_verify_balance.png)
     100
     101## How to reproduce
     102
     103```sh
     104./eduberza -init
     105./eduberza
     106# [2] Login: alice / test123
     107# [2] Deposit virtual funds: -50   -> Invalid amount.
     108# [2] Deposit virtual funds: 500   -> Deposited 500.0000 USD.
     109# [1] View balance                 -> Available: 8750.0000 USD
     110```
     111
     112All four screenshots come from one real run of exactly these inputs.
  • docs/P4-Prototype/UseCase0004Implementation.md

    ra531b45 ref1c1c7  
    1 # Use-case 0004 Implementation — Buy
     1# Use-case 0004 Implementation - Place market BUY order
    22
    3 **Initiating actor:** Trader. **Source file:** `server/trade.go`, function `PlaceOrder(s, "buy")`.
     3**Initiating actor:** Trader
    44
    5 ## Scenario (implemented)
     5**Other actors:** Market Simulator (indirect — supplies the current price via `market_trades`).
    66
    7 1. **User** chooses `[4] Place market BUY order`.
    8 2. **System** lists markets with their latest price (same SQL as UC0006 — via the `v_latest_prices` view).
     7A logged-in Trader buys a crypto asset at the current market price. The Trader never
     8types a symbol or an identifier: the system lists the active markets with their last
     9price, numbered, and the Trader picks one by its number and then enters only the
     10quantity. The system checks that the Trader has enough available cash for
     11quantity × price and then, in one database transaction, records the order, moves the
     12cash from available to invested, adds the crypto to the Trader's holding (recomputing
     13the weighted-average entry price), writes a ledger entry and a market trade, and marks
     14the order executed. The operation touches five tables (`orders`, `users`, `holdings`,
     15`transactions`, `market_trades`) and either all of it succeeds or all of it is rolled
     16back.
    917
     18Original use-case description (P3): [UseCase0004](../P3-UseCaseModel/UseCase0004.md).
     19Implementation: [`server/trade.go`](../../server/trade.go), function
     20`PlaceOrder(s, "buy")` (with `upsertHoldingOnBuy` in the same file), which calls
     21`ChooseMarket`, `ListMarkets`, `pickNumber` and `LatestPrice` from
     22[`server/market.go`](../../server/market.go).
    1023
    11 3. **User** enters `BTC`.
    12 4. **System** resolves the market and fetches the latest price:
     24All statements run on the `project` schema: the connection sets
     25`search_path=project,public` (`server/db/db.go`), so `orders` means `project.orders`.
     26The SQL below is copied from the Go code; only the Go source indentation is removed,
     27a `;` is added after each statement of the transaction, and `--` comments say what
     28each `$n` placeholder is bound to.
     29
     30The run shown is user `alice` on the seed data (available 8250.00 USD, holding
     310.5 ETH bought at 3500), buying 0.01 BTC.
     32
     33## Scenario
     34
     351. **Trader** chooses `[4] Place market BUY order` in the authenticated menu (types `4`).
     362. **System** prints `-- Place market buy order --` and lists all active markets,
     37   numbered, with their last price (`ListMarkets`, called by `ChooseMarket`):
    1338
    1439   ```sql
    15    SELECT m.id, c.id, c.symbol, m.quote_currency
     40   SELECT m.id, c.id, c.symbol, m.quote_currency,
     41          COALESCE(lp.price, 0) AS price
    1642     FROM markets m
    17      JOIN crypto c ON c.id = m.crypto_id
    18     WHERE upper(c.symbol) = upper($1) AND m.is_active;
    19 
    20    SELECT price FROM v_latest_prices WHERE market_id = $2;
     43     JOIN crypto  c ON c.id = m.crypto_id
     44     LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
     45    WHERE m.is_active = true
     46    ORDER BY c.symbol
    2147   ```
    2248
    23 5. **User** enters quantity `0.01`.
    24 6. **System** executes a single database transaction — *all or nothing*:
     49   The rows are printed in this order as `1 ADA`, `2 BTC`, `3 DOGE`, `4 ETH`, `5 SOL`;
     50   Go keeps each row's market id and crypto id in memory, so the Trader only ever sees
     51   and types the list number. The system then asks `Market #:`.
     52
     53   ![UC0004 steps 1-2: Trader chooses BUY, system lists the markets](screenshots/uc0004_1_2_markets.png)
     54
     553. **Trader** picks the market by its number in the list: `2` (BTC/USD).
     564. **System** takes the market id and crypto id of row 2 from the list (no further
     57   lookup by symbol) and reads the latest price of that market (`LatestPrice`;
     58   `$1` = the chosen market's id):
     59
     60   ```sql
     61   SELECT price FROM v_latest_prices WHERE market_id = $1
     62   ```
     63
     64   It prints `Latest price for BTC/USD = 67140.000000` and asks `Quantity:`.
     65
     66   ![UC0004 steps 3-4: Trader picks market #2 (BTC), system shows the price](screenshots/uc0004_3_4_price.png)
     67
     685. **Trader** enters the quantity `0.01`.
     696. **System** computes in Go notional = quantity × price = 0.01 × 67140 = 671.40 and
     70   passes it to SQL as a parameter. It then runs one database transaction; the
     71   statements below are in exactly the order `PlaceOrder` executes them for a buy:
    2572
    2673   ```sql
    2774   BEGIN;
    2875
    29    -- (a) record the order as 'open' — no trade has happened yet
    30    INSERT INTO orders
    31        (user_id, market_id, side, type, status, quantity, price)
    32    VALUES
    33        ($1, $2, 'buy', 'market', 'open', $3, $4)
    34    RETURNING id;
     76   -- (a) record the order as 'open' — no trade has happened yet.
     77   --     $1 = user id, $2 = market id, $3 = side (the Go variable side = 'buy'),
     78   --     $4 = quantity (0.01), $5 = price (67140); the returned id is kept in Go.
     79   INSERT INTO orders (user_id, market_id, side, type, status, quantity, price)
     80    VALUES ($1, $2, $3, 'market', 'open', $4, $5)
     81    RETURNING id;
    3582
    36    -- (b) lock and check the user balance
     83   -- (b) lock the user's row and read the available cash. $1 = user id.
     84   --     Go compares it with the notional; if it is smaller -> alternate flow 6a.
    3785   SELECT available_balance FROM users WHERE id = $1 FOR UPDATE;
    3886
    39    -- (c) move cash from available to invested. A buy never reserves crypto
    40    --     the way a sell does (see UC0005) — it only ever adds to the
    41    --     position, so there is nothing to commit on the holdings side
    42    --     before settling.
     87   -- (c) move the notional from available to invested cash.
     88   --     $1 = notional (671.40), $2 = user id.
    4389   UPDATE users
    44       SET available_balance = available_balance - $notional,
    45           invested_balance  = invested_balance  + $notional,
    46           updated_at        = now()
    47     WHERE id = $1;
     90       SET available_balance = available_balance - $1,
     91           invested_balance  = invested_balance  + $1,
     92           updated_at        = now()
     93     WHERE id = $2;
    4894
    49    -- (d) upsert the holding, recomputing the weighted-average entry price
    50    --     in one statement. Every SET expression sees the pre-update row, so
    51    --     holdings.quantity below is still the old quantity.
    52    --     reserved_quantity is untouched by a buy and defaults to 0.
     95   -- (d) add the crypto to the holding (upsertHoldingOnBuy), recomputing the
     96   --     weighted-average entry price in the database. Every SET expression sees
     97   --     the pre-update row, so holdings.quantity is still the old quantity.
     98   --     $1 = user id, $2 = crypto id, $3 = quantity (0.01), $4 = price (67140).
    5399   INSERT INTO holdings (user_id, crypto_id, quantity, avg_price, updated_at)
    54    VALUES ($1, $c, $3, $4, now())
    55    ON CONFLICT (user_id, crypto_id) DO UPDATE
    56       SET avg_price  = (holdings.quantity * holdings.avg_price
    57                          + EXCLUDED.quantity * EXCLUDED.avg_price)
    58                        / (holdings.quantity + EXCLUDED.quantity),
    59           quantity   = holdings.quantity + EXCLUDED.quantity,
    60           updated_at = now();
     100    VALUES ($1, $2, $3, $4, now())
     101    ON CONFLICT (user_id, crypto_id) DO UPDATE
     102       SET avg_price  = (holdings.quantity * holdings.avg_price
     103                          + EXCLUDED.quantity * EXCLUDED.avg_price)
     104                        / (holdings.quantity + EXCLUDED.quantity),
     105           quantity   = holdings.quantity + EXCLUDED.quantity,
     106           updated_at = now();
    61107
    62    -- (e) ledger entry
    63    INSERT INTO transactions
    64        (user_id, type, amount, currency, related_order, description)
    65    VALUES
    66        ($1, 'buy', -$notional, 'USD', $orderId, 'Market buy ...');
     108   -- (e) ledger entry. $1 = user id, $2 = -notional (-671.40), $3 = order id from (a),
     109   --     $4 = description built in Go: 'Market buy 0.0100 BTC @ 67140.000000'.
     110   INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
     111    VALUES ($1, 'buy', $2, 'USD', $3, $4);
    67112
    68    -- (f) record the resulting market trade
    69    INSERT INTO market_trades
    70        (market_id, executed_at, price, quantity, side, source)
    71    VALUES
    72        ($2, now(), $4, $3, 'buy', 'user');
     113   -- (f) record the resulting market trade.
     114   --     $1 = market id, $2 = price, $3 = quantity, $4 = side ('buy').
     115   INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source)
     116    VALUES ($1, now(), $2, $3, $4, 'user');
    73117
    74    -- (g) settle the order itself — it has now actually been filled
    75    UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $orderId;
     118   -- (g) settle the order itself — it has now actually been filled. $1 = order id.
     119   UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1;
    76120
    77121   COMMIT;
    78122   ```
    79123
    80 7. **System** prints: `Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)`.
     124   A buy never reserves crypto (only a sell does, see
     125   [UseCase0005](UseCase0005Implementation.md)), so `holdings.reserved_quantity` is not
     126   touched and stays 0.
    81127
    82    ![Market list, then a filled BUY order](screenshots/uc0004_buy.png)
     1287. **System** confirms
     129   `Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)` and shows
     130   the authenticated menu again.
    83131
    84 ## Verified run (from actual prototype execution)
     132   The screenshot shows steps 5–7: the entered quantity, the confirmation and the menu.
    85133
    86 Re-run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`) with freshly loaded seed data:
     134   ![UC0004 steps 5-7: quantity entered, order executed](screenshots/uc0004_5_7_executed.png)
    87135
    88 - **Before:** alice.available_balance = 8250.00, portfolio = { ETH: 0.5000, reserved 0.0000 }.
    89 - **Command:** `buy 0.01 BTC`.
    90 - **After:** alice.available_balance = 7578.60 (= 8250 − 671.40), portfolio = { BTC: 0.0100 @ 67140 (reserved 0.0000), ETH: 0.5000 @ 3500 (reserved 0.0000) }, net worth = 10010.00 USD (the +10 is the ETH unrealised P/L from the price moving from 3500 → 3520). A buy never sets `reserved_quantity`, so it reads 0 on every row here.
     136### Verification — portfolio after the buy
    91137
    92 ## Failure path — insufficient funds
     138Right after the buy the Trader chooses `[6] View portfolio`
     139([UseCase0006](UseCase0006Implementation.md)). It shows the new holding
     140`BTC 0.0100` with average buy price and current price 67140.000000 (value 671.4000),
     141the unchanged `ETH 0.5000` (average 3500, current 3520, unrealised P/L +10.0000),
     142`Cash available : 7578.6000 USD` (= 8250.00 − 671.40), portfolio value 2431.4000 and
     143net worth 10010.0000 USD. The `Reserved` column is 0.0000 on both rows — a buy never
     144reserves anything.
    93145
    94 If `available_balance < notional`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above and the user sees:
     146![UC0004 verification: portfolio after the buy](screenshots/uc0004_verify_portfolio.png)
    95147
    96 ```
    97 Insufficient funds: need X, have Y
    98 ```
     148### Alternate flow 6a — insufficient funds
     149
     150User `charlie` (seed data: 2500.00 USD available, no crypto) chooses `[4]`, picks
     151market `2` (BTC, 67140.000000) and enters quantity `1`. Go computes the notional
     15267140.00. In the transaction, statement (a) inserts the `open` order and statement (b)
     153`SELECT available_balance FROM users WHERE id = $1 FOR UPDATE` returns 2500.00, which
     154is less than the notional. `PlaceOrder` prints
     155`Insufficient funds: need 67140.0000, have 2500.0000` and returns without running
     156(c)–(g); the deferred `tx.Rollback()` undoes statement (a), so no order, no ledger
     157entry and no balance change is left behind (after this run charlie has no row in
     158`orders` and still 2500.00 USD available). The authenticated menu is shown again.
     159
     160![UC0004 alternate flow: insufficient funds](screenshots/uc0004_6a_insufficient.png)
     161
     162### Alternate flow 3a — number not in the list
     163
     164If in step 3 the Trader enters something that is not a number from 1 to the number of
     165listed markets, `pickNumber` prints `Invalid choice, enter a number from 1 to 5.`, no
     166further SQL is run and the authenticated menu is shown again (the same check is shown
     167in [UseCase0007](UseCase0007Implementation.md), alternate flow 12a). Likewise, a
     168quantity that is not a positive number in step 5 prints `Invalid quantity.` before any
     169transaction is opened.
  • docs/P4-Prototype/UseCase0005Implementation.md

    ra531b45 ref1c1c7  
    1 # Use-case 0005 Implementation — Sell
    2 
    3 **Initiating actor:** Trader. **Source file:** `server/trade.go`, function `PlaceOrder(s, "sell")`.
    4 
    5 ## The bug this closes
    6 
    7 Before this change, `holdings` had `quantity` and `avg_price` only. The sell
    8 path checked `held < qty` straight against `quantity`, which cannot tell
    9 "owned" apart from "owned, but already committed to another order that has
    10 not settled." `holdings.reserved_quantity` fixes that: the crypto being sold
    11 is reserved before it is removed from the position, and the check is against
    12 `quantity - reserved_quantity`.
    13 
    14 ## Scenario (implemented)
    15 
    16 1. **User** chooses `[5] Place market SELL order`.
    17 2. **System** lists markets (same as UC0004 step 2).
    18 3. **User** enters market symbol, e.g. `ETH`, then quantity `0.5`.
    19 4. **System** opens a transaction and runs:
     1# Use-case 0005 Implementation - Place market SELL order
     2
     3**Initiating actor:** Trader
     4
     5**Other actors:** Market Simulator (indirect — supplies the current price).
     6
     7A logged-in Trader sells part or all of a holding at the current market price. The
     8Trader never types a symbol: the system lists only the cryptos the Trader holds and can
     9still sell (the quantity not already reserved by an open sell order), numbered, with
     10how much is held and how much is free, and the Trader picks one by its number and
     11enters the quantity. In one database transaction the system records the order,
     12reserves the crypto being sold and settles it, credits the proceeds to the Trader's
     13available cash while reducing the invested cash by the cost basis, writes a ledger
     14entry and a market trade, and marks the order executed. Cost basis is preserved, so
     15the realised P/L can be reconstructed from the ledger.
     16
     17Original use-case description (P3): [UseCase0005](../P3-UseCaseModel/UseCase0005.md).
     18Implementation: [`server/trade.go`](../../server/trade.go), function
     19`PlaceOrder(s, "sell")`, which calls `ChooseHolding`, `pickNumber` and `LatestPrice`
     20from [`server/market.go`](../../server/market.go).
     21
     22All statements run on the `project` schema (the connection sets
     23`search_path=project,public` in `server/db/db.go`). The SQL below is copied from the
     24Go code; only the Go source indentation is removed, a `;` is added after each
     25statement of the transaction, and `--` comments say what each `$n` placeholder is
     26bound to.
     27
     28The run shown is user `alice` right after the buy of
     29[UseCase0004](UseCase0004Implementation.md): 7578.60 USD available, holdings
     300.01 BTC (bought at 67140) and 0.5 ETH (bought at 3500). She sells 0.2 ETH.
     31
     32## Reserve, then settle
     33
     34The crypto being sold is **reserved** (`holdings.reserved_quantity`) before it is
     35removed from the position, and the sell check is against what is truly still free,
     36`quantity - reserved_quantity`, not against the raw `quantity`, which would also count
     37crypto already promised to another order. Because only market orders are implemented,
     38an order settles in the same transaction it is placed in, so reserve and settle are two
     39statements inside one commit; they stay logically distinct so that a future
     40limit-order matcher, where an order would stay `open` until a *later* transaction fills
     41it, needs a second transaction but no schema change.
     42
     43## Scenario
     44
     451. **Trader** chooses `[5] Place market SELL order` in the authenticated menu (types `5`).
     462. **System** prints `-- Place market sell order --` and lists, numbered, only the
     47   cryptos the Trader holds with some quantity still free to sell, with the quantity
     48   held, the quantity free to sell and the last price (`ChooseHolding`;
     49   `$1` = the logged-in user's id):
     50
     51   ```sql
     52   SELECT m.id, c.id, c.symbol, m.quote_currency,
     53          h.quantity, h.quantity - h.reserved_quantity AS free,
     54          COALESCE(lp.price, 0) AS price
     55     FROM holdings h
     56     JOIN crypto  c ON c.id = h.crypto_id
     57     JOIN markets m ON m.crypto_id = c.id AND m.is_active = true
     58     LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
     59    WHERE h.user_id = $1
     60      AND h.quantity - h.reserved_quantity > 0
     61    ORDER BY c.symbol
     62   ```
     63
     64   For alice it prints `1 BTC USD 0.0100 0.0100 67140.000000` and
     65   `2 ETH USD 0.5000 0.5000 3520.000000`, then asks `Holding #:`. Go keeps each row's
     66   market id and crypto id in memory; the Trader only types the list number. (If the
     67   query returns no row, the system prints `you hold no crypto that is free to sell`
     68   and the use-case ends.)
     69
     70   ![UC0005 steps 1-2: Trader chooses SELL, system lists what they hold](screenshots/uc0005_1_2_holdings.png)
     71
     723. **Trader** picks the holding by its number in the list: `2` (ETH).
     734. **System** takes the market id and crypto id of row 2 from the list and reads the
     74   latest price of that market (`LatestPrice`; `$1` = the chosen market's id):
     75
     76   ```sql
     77   SELECT price FROM v_latest_prices WHERE market_id = $1
     78   ```
     79
     80   It prints `Latest price for ETH/USD = 3520.000000` and asks `Quantity:`.
     81
     82   ![UC0005 steps 3-4: Trader picks holding #2 (ETH), system shows the price](screenshots/uc0005_3_4_price.png)
     83
     845. **Trader** enters the quantity `0.2`.
     856. **System** computes in Go notional = quantity × price = 0.2 × 3520 = 704.00 and
     86   runs one database transaction; the statements are in exactly the order
     87   `PlaceOrder` executes them for a sell. After statement (b) Go also computes the
     88   cost basis = avg_price × quantity = 3500 × 0.2 = 700.00 from the locked holding row;
     89   both values are passed to SQL as parameters.
    2090
    2191   ```sql
    2292   BEGIN;
    2393
    24    -- (a) record the order as 'open' — no trade has happened yet
    25    INSERT INTO orders
    26        (user_id, market_id, side, type, status, quantity, price)
    27    VALUES
    28        ($1, $2, 'sell', 'market', 'open', $3, $4)
    29    RETURNING id;
    30 
    31    -- (b) lock the holding and check what is actually free to sell
     94   -- (a) record the order as 'open' — no trade has happened yet.
     95   --     $1 = user id, $2 = market id, $3 = side (the Go variable side = 'sell'),
     96   --     $4 = quantity (0.2), $5 = price (3520); the returned id is kept in Go.
     97   INSERT INTO orders (user_id, market_id, side, type, status, quantity, price)
     98    VALUES ($1, $2, $3, 'market', 'open', $4, $5)
     99    RETURNING id;
     100
     101   -- (b) lock the holding row and read what is held, what is already reserved and
     102   --     the average entry price. $1 = user id, $2 = crypto id.
     103   --     Go computes available = quantity - reserved_quantity (0.5 - 0 = 0.5);
     104   --     if there is no row or available < quantity -> alternate flow 5a.
    32105   SELECT quantity, reserved_quantity, avg_price FROM holdings
    33     WHERE user_id = $1 AND crypto_id = $c FOR UPDATE;
    34    -- available := quantity - reserved_quantity
    35    -- abort if missing or available < $qty
    36 
    37    -- (c) reserve: committed to this order, not yet removed from the position
     106     WHERE user_id = $1 AND crypto_id = $2 FOR UPDATE;
     107
     108   -- (c) reserve: committed to this order, not yet removed from the position.
     109   --     $1 = quantity (0.2), $2 = user id, $3 = crypto id.
    38110   UPDATE holdings
    39       SET reserved_quantity = reserved_quantity + $qty, updated_at = now()
    40     WHERE user_id = $1 AND crypto_id = $c;
    41 
    42    -- (d) settle: a market order fills immediately, so release the
    43    --     reservation and remove the asset in the same step
     111       SET reserved_quantity = reserved_quantity + $1,
     112           updated_at        = now()
     113     WHERE user_id = $2 AND crypto_id = $3;
     114
     115   -- (d) settle: a market order fills immediately, so release the reservation and
     116   --     remove the asset from the position in one step. Same parameters as (c).
    44117   UPDATE holdings
    45       SET quantity = quantity - $qty,
    46           reserved_quantity = reserved_quantity - $qty,
    47           updated_at = now()
    48     WHERE user_id = $1 AND crypto_id = $c;
    49 
     118       SET quantity          = quantity - $1,
     119           reserved_quantity = reserved_quantity - $1,
     120           updated_at        = now()
     121     WHERE user_id = $2 AND crypto_id = $3;
     122
     123   -- (e) credit the proceeds; reduce invested cash by the cost basis.
     124   --     $1 = notional (704.00), $2 = cost basis (700.00), $3 = user id.
    50125   UPDATE users
    51       SET available_balance = available_balance + $notional,
    52           invested_balance  = GREATEST(invested_balance - ($avg * $qty), 0),
    53           updated_at        = now()
    54     WHERE id = $1;
    55 
    56    INSERT INTO transactions
    57        (user_id, type, amount, currency, related_order, description)
    58    VALUES
    59        ($1, 'sell', $notional, 'USD', $orderId, 'Market sell ...');
    60 
    61    INSERT INTO market_trades
    62        (market_id, executed_at, price, quantity, side, source)
    63    VALUES
    64        ($2, now(), $price, $qty, 'sell', 'user');
    65 
    66    -- (e) settle the order itself — it has now actually been filled
    67    UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $orderId;
     126       SET available_balance = available_balance + $1,
     127           invested_balance  = GREATEST(invested_balance - $2, 0),
     128           updated_at        = now()
     129     WHERE id = $3;
     130
     131   -- (f) ledger entry. $1 = user id, $2 = notional (704.00), $3 = order id from (a),
     132   --     $4 = description built in Go: 'Market sell 0.2000 ETH @ 3520.000000'.
     133   INSERT INTO transactions (user_id, type, amount, currency, related_order, description)
     134    VALUES ($1, 'sell', $2, 'USD', $3, $4);
     135
     136   -- (g) record the resulting market trade.
     137   --     $1 = market id, $2 = price, $3 = quantity, $4 = side ('sell').
     138   INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source)
     139    VALUES ($1, now(), $2, $3, $4, 'user');
     140
     141   -- (h) settle the order itself — it has now actually been filled. $1 = order id.
     142   UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1;
    68143
    69144   COMMIT;
    70145   ```
    71146
    72    ![Selling 0.5 ETH at the current market price](screenshots/uc0005_sell.png)
    73 
    74 5. **System** prints: `Order executed: sell 0.5000 ETH @ 3520.000000 (notional 1760.0000 USD)`.
    75 
    76 ## Failure path — insufficient holding
    77 
    78 If the holding does not exist, or `quantity - reserved_quantity < requested`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above — including the `open` order, which was never committed — and the user sees:
    79 
    80 ```
    81 Insufficient holding: trying to sell X, available Y (of Z held, W reserved)
    82 ```
    83 
    84 ## Verified run — the exact scenario from the design review
    85 
    86 Run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`).
    87 Alice's ETH/BTC holdings were seeded, then her BTC holding was set to exactly
    88 the scenario that motivated this fix: 2 BTC owned, nothing reserved.
    89 
    90 ```
    91 $ psql ... -c "SELECT symbol, quantity, reserved_quantity, avg_price
    92                FROM holdings h JOIN crypto c ON c.id = h.crypto_id
    93                WHERE user_id = '<alice>';"
    94 
    95  symbol | quantity | reserved_quantity |  avg_price
    96 --------+----------+--------------------+-------------
    97  BTC    |   2.0000 |             0.0000 | 65000.000000
    98  ETH    |   0.5000 |             0.0000 |  3500.000000
    99 ```
    100 
    101 **Step 1 — portfolio before the sell** (`[6] View portfolio`):
    102 
    103 ```
    104   Symbol        Quantity      Reserved     Available         Avg buy         Current           Value  Unrealised P/L
    105   ------------------------------------------------------------------------------------------------------------------
    106   BTC             2.0000        0.0000        2.0000    65000.000000    67140.000000     134280.0000      +4280.0000
    107   ETH             0.5000        0.0000        0.5000     3500.000000     3520.000000       1760.0000        +10.0000
    108   ------------------------------------------------------------------------------------------------------------------
    109   TOTAL                                                                                  136040.0000      +4290.0000
    110 ```
    111 
    112 **Step 2 — `[5] Place market SELL order` → `BTC` → `0.5`:**
    113 
    114 ```
    115 Order executed: sell 0.5000 BTC @ 67140.000000 (notional 33570.0000 USD)
    116 ```
    117 
    118 **Step 3 — portfolio after the sell:**
    119 
    120 ```
    121   BTC             1.5000        0.0000        1.5000    65000.000000    67140.000000     100710.0000      +3210.0000
    122 ```
    123 
    124 `quantity` dropped from 2.0 to 1.5 and `reserved_quantity` is back to 0.0000
    125 — reserve and settle both happened, inside the one commit, exactly as
    126 designed.
    127 
    128 ## Verified run — reserve and settle as two distinct, observable steps
    129 
    130 The CLI settles a market order in the same transaction it reserves in, so
    131 `reserved_quantity` is never visibly nonzero *outside* a transaction. Run by
    132 hand in one `psql` session (one transaction, so the session sees its own
    133 uncommitted writes) to show the intermediate state that step (c) alone would
    134 leave, before step (d) runs:
     1477. **System** confirms
     148   `Order executed: sell 0.2000 ETH @ 3520.000000 (notional 704.0000 USD)` and shows
     149   the authenticated menu again.
     150
     151   The screenshot shows steps 5–7: the entered quantity, the confirmation and the menu.
     152
     153   ![UC0005 steps 5-7: quantity entered, order executed](screenshots/uc0005_5_7_executed.png)
     154
     155After this run the database holds for alice: ETH `quantity` 0.3000 with
     156`reserved_quantity` 0.0000; `available_balance` 8282.60 (= 7578.60 + 704.00) and
     157`invested_balance` 1721.40 (= 2421.40 − 700.00); a `sell` row in `transactions` with
     158amount 704.0000 and description `Market sell 0.2000 ETH @ 3520.000000`; and the order
     159with status `executed`. The realised P/L of this sell is notional − cost basis =
     160704.00 − 700.00 = +4.00 USD.
     161
     162### Alternate flow 5a — insufficient holding
     163
     164Right after the sell above, alice chooses `[5]` again. The list from step 2 now shows
     165`2 ETH USD 0.3000 0.3000 3520.000000`. She picks `2` (ETH) and enters quantity `5`.
     166In the transaction, statement (a) inserts the `open` order and statement (b) returns
     167quantity 0.3000 and reserved_quantity 0.0000, so available = 0.3 < 5. `PlaceOrder`
     168prints
     169
     170```
     171Insufficient holding: trying to sell 5.0000, available 0.3000 (of 0.3000 held, 0.0000 reserved)
     172```
     173
     174and returns without running (c)–(h); the deferred `tx.Rollback()` undoes statement (a)
     175as well, so no order, no reservation and no ledger entry is left behind. The
     176authenticated menu is shown again. The same message is printed if the holding row no
     177longer exists (for example because it was sold out from another session after the
     178list was shown).
     179
     180![UC0005 alternate flow 5a: selling more than is held](screenshots/uc0005_5a_insufficient.png)
     181
     182## Reserve and settle, step by step
     183
     184The CLI reserves and settles inside one transaction, so `reserved_quantity` is never
     185nonzero *outside* a transaction. The intermediate state is shown by running statements
     186(c) and (d) by hand in one `psql` transaction (which sees its own uncommitted
     187writes) against alice's ETH holding after the scenario above (0.3 ETH), for a sell of
     1880.1, and rolling back at the end so nothing is changed. Literal values replace the
     189`$n` parameters; `:alice` and `:eth` are psql variables for
     190`(SELECT id FROM users WHERE username = 'alice')` and
     191`(SELECT id FROM crypto WHERE symbol = 'ETH')`:
    135192
    136193```sql
    137194BEGIN;
    138 
    139 -- before: Alice owns 2 BTC, none reserved
    140195SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
    141   FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     196  FROM holdings WHERE user_id = :alice AND crypto_id = :eth;
    142197--  quantity | reserved_quantity | available
    143 -- ----------+--------------------+-----------
    144 --    2.0000 |             0.0000 |    2.0000
    145 
    146 -- step (c): order placed, 0.5 BTC reserved — no trade has happened yet
    147 UPDATE holdings SET reserved_quantity = reserved_quantity + 0.5, updated_at = now()
    148  WHERE user_id = '<alice>' AND crypto_id = '<btc>';
    149 
     198-- ----------+-------------------+-----------
     199--    0.3000 |            0.0000 |    0.3000
     200
     201-- (c) reserve 0.1: the order is placed, no trade has happened yet
     202UPDATE holdings SET reserved_quantity = reserved_quantity + 0.1, updated_at = now()
     203 WHERE user_id = :alice AND crypto_id = :eth;
    150204SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
    151   FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     205  FROM holdings WHERE user_id = :alice AND crypto_id = :eth;
    152206--  quantity | reserved_quantity | available
    153 -- ----------+--------------------+-----------
    154 --    2.0000 |             0.5000 |    1.5000
    155 
    156 -- step (d): market order settles immediately, reservation released
    157 UPDATE holdings SET quantity = quantity - 0.5, reserved_quantity = reserved_quantity - 0.5, updated_at = now()
    158  WHERE user_id = '<alice>' AND crypto_id = '<btc>';
    159 
     207-- ----------+-------------------+-----------
     208--    0.3000 |            0.1000 |    0.2000
     209
     210-- (d) settle: the reservation is released and the asset removed
     211UPDATE holdings SET quantity = quantity - 0.1, reserved_quantity = reserved_quantity - 0.1, updated_at = now()
     212 WHERE user_id = :alice AND crypto_id = :eth;
    160213SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
    161   FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     214  FROM holdings WHERE user_id = :alice AND crypto_id = :eth;
    162215--  quantity | reserved_quantity | available
    163 -- ----------+--------------------+-----------
    164 --    1.5000 |             0.0000 |    1.5000
    165 
    166 COMMIT;
    167 ```
    168 
    169 This is the row that would stay visible to every other connection for as long
    170 as the order stayed `open` — i.e. for as long as it took a matcher to fill
    171 it, once limit orders exist.
    172 
    173 ## Verified run — two concurrent sells, which is the bug itself
    174 
    175 The scenario the design review described: a user should not be able to place
    176 two sell orders whose combined quantity exceeds what they actually hold. With
    177 Alice's BTC holding at 1.5 BTC (0 reserved), two independent CLI processes
    178 were started at the same instant, each selling `1.0 BTC` — together 2.0 BTC,
    179 more than she has:
    180 
    181 ```
    182 $ ( eduberza-sell-1.0-BTC ) &   # process A
    183 $ ( eduberza-sell-1.0-BTC ) &   # process B
    184 $ wait
    185 
    186 === A ===
    187 Insufficient holding: trying to sell 1.0000, available 0.5000 (of 0.5000 held, 0.0000 reserved)
    188 === B ===
    189 Order executed: sell 1.0000 BTC @ 67140.000000 (notional 67140.0000 USD)
    190 
    191 === final holding ===
    192  quantity | reserved_quantity
    193 ----------+--------------------
    194    0.5000 |             0.0000
    195 ```
    196 
    197 One order settled, one was correctly rejected, and the final `quantity`
    198 (0.5) is consistent with exactly one 1.0 BTC sell having happened against the
    199 1.5 BTC available — not both, and not neither. This is enforced by the
    200 `SELECT ... FOR UPDATE` lock on the holdings row: whichever transaction gets
    201 there second blocks until the first commits, then re-reads the now-current
    202 `quantity`/`reserved_quantity` before deciding.
    203 
    204 ## Verified — the constraint holds even if application code did not
    205 
    206 ```sql
    207 UPDATE holdings SET reserved_quantity = quantity + 1 WHERE user_id = '<alice>' AND crypto_id = '<btc>';
    208 
     216-- ----------+-------------------+-----------
     217--    0.2000 |            0.0000 |    0.2000
     218ROLLBACK;
     219```
     220
     221The middle state is what every other connection would see for as long as an order
     222stayed `open` once limit orders exist: 0.1 ETH still owned but no longer free to sell.
     223
     224## Two concurrent sells
     225
     226A Trader must not be able to sell the same units twice from two sessions at once. Both
     227sessions may have listed the holding as free (step 2 runs outside the transaction), so
     228the protection is statement (b): `SELECT ... FOR UPDATE` locks the holding row, and a
     229second transaction that reaches (b) waits until the first one commits, then reads the
     230already reduced `quantity` before deciding.
     231
     232This was checked with two `psql` sessions running statements (b)–(d) against alice's
     2330.3 ETH. Session A locked the row, reserved and settled 0.2 ETH and committed after a
     2343-second pause; session B asked for the lock one second after A had taken it:
     235
     236```
     237A: SELECT ... FOR UPDATE  ->  quantity 0.3000, reserved_quantity 0.0000
     238A: reserve 0.2, settle 0.2, pg_sleep(3)
     239B: 11:43:54  SELECT ... FOR UPDATE   -- blocks, A holds the row lock
     240A: 11:43:56  COMMIT
     241B: 11:43:56  lock granted  ->  quantity 0.1000, reserved_quantity 0.0000
     242```
     243
     244Session B was blocked for the two seconds until A committed and then saw only
     2450.1 ETH, so a second 0.2 ETH sell in B takes alternate flow 5a
     246(`available 0.1000`) instead of selling units that no longer exist. (B was rolled
     247back and alice's holding was restored to 0.3 ETH after the check.)
     248
     249## The constraint holds even if the application code did not
     250
     251`schema_creation.sql` declares
     252`CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` on
     253`holdings.reserved_quantity`, so an inconsistent reservation is impossible at the
     254database level, independently of `trade.go` (run inside a transaction that was
     255rolled back):
     256
     257```
     258UPDATE holdings SET reserved_quantity = quantity + 1 WHERE user_id = :alice AND crypto_id = :eth;
    209259ERROR:  new row for relation "holdings" violates check constraint "holdings_check"
    210260```
    211 
    212 `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` in
    213 `schema_creation.sql` makes an inconsistent reservation impossible at the
    214 database level, independent of `trade.go`.
  • docs/P4-Prototype/UseCase0006Implementation.md

    ra531b45 ref1c1c7  
    1 # Use-case 0006 Implementation — Portfolio & transactions
     1# Use-case 0006 Implementation - View portfolio and transaction history
    22
    3 **Initiating actor:** Trader. **Source files:** `server/portfolio.go` (`ShowPortfolio`), `server/account.go` (`ShowTransactions`, `ShowBalance`).
     3**Initiating actor:** Trader
    44
    5 ## Scenario (implemented)
     5**Other actors:** —
     6
     7A logged-in Trader inspects the current state of their account. The portfolio view
     8lists every cryptocurrency the Trader holds with the quantity (also split into the
     9part reserved by open sell orders and the part that is free to sell), the average
     10buy price, the current market price, the market value and the unrealised
     11profit/loss, followed by a totals row and a cash summary (cash available, portfolio
     12value, net worth). The transaction history lists the Trader's last 20 ledger
     13entries — deposits, buys and sells — newest first. Both are read-only: nothing in
     14the database is changed.
     15
     16Original use-case description (P3): [UseCase0006](../P3-UseCaseModel/UseCase0006.md).
     17Implementation: [`server/portfolio.go`](../../server/portfolio.go), function
     18`ShowPortfolio`, and [`server/account.go`](../../server/account.go), function
     19`ShowTransactions`.
     20
     21Precondition: the Trader is logged in ([UseCase0002](UseCase0002Implementation.md)).
     22The run below is alice's, after she bought 0.01 BTC
     23([UseCase0004](UseCase0004Implementation.md)) and sold 0.2 ETH
     24([UseCase0005](UseCase0005Implementation.md)) on top of the seed data (0.5 ETH,
     258250.00 USD cash).
     26
     27## Scenario
    628
    729### Portfolio
    830
    9 1. **User** chooses `[6] View portfolio`.
    10 2. **System** runs:
     311. **Trader** chooses `[6] View portfolio` in the authenticated menu (types `6`).
     32   The menu is the one shown in [UseCase0002](UseCase0002Implementation.md), step 7.
     332. **System** queries the `v_portfolio` view (`$1` = user id):
    1134
    1235   ```sql
    13    SELECT symbol, quantity,
    14           COALESCE(reserved_quantity,  0),
     36   SELECT symbol,
     37          quantity,
     38          COALESCE(reserved_quantity, 0),
    1539          COALESCE(available_quantity, quantity),
    16           COALESCE(avg_price,      0),
    17           COALESCE(current_price,  0),
    18           COALESCE(market_value,   0),
     40          COALESCE(avg_price, 0),
     41          COALESCE(current_price, 0),
     42          COALESCE(market_value, 0),
    1943          COALESCE(unrealized_pnl, 0)
    2044     FROM v_portfolio
    2145    WHERE user_id = $1 AND quantity > 0
    22     ORDER BY symbol;
     46    ORDER BY symbol
    2347   ```
    24 3. **System** renders a table with a totals row, then prints the cash summary:
     48
     493. **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):
    2551
    2652   ```sql
    27    SELECT available_balance, invested_balance FROM users WHERE id = $1;
     53   SELECT available_balance, invested_balance FROM users WHERE id = $1
    2854   ```
    2955
    30    ![screenshot: portfolio view](screenshots/uc0006_portfolio.png)
     56   and prints `Cash available` (= `available_balance`), `Portfolio value`
     57   (= the total market value) and `Net worth` (= their sum).
    3158
    32 ### Verified run
     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.
    3362
    34 Re-run 2026-09-16 against PostgreSQL 16 with the seed data (`data_load.sql`).
    35 Immediately after login, alice's portfolio prints (now with the
    36 `Reserved`/`Available` columns from `holdings.reserved_quantity`):
     63   ![UC0006 portfolio: holdings, P/L and cash summary](screenshots/uc0006_portfolio.png)
    3764
    38 ```
    39   Symbol        Quantity      Reserved     Available         Avg buy         Current           Value  Unrealised P/L
    40   ------------------------------------------------------------------------------------------------------------------
    41   ETH             0.5000        0.0000        0.5000     3500.000000     3520.000000       1760.0000        +10.0000
    42   ------------------------------------------------------------------------------------------------------------------
    43   TOTAL                                                                                    1760.0000        +10.0000
     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
    4472
    45   Cash available : 8250.0000 USD
    46   Portfolio value: 1760.0000 USD
    47   Net worth      : 10010.0000 USD
    48 ```
     73     Cash available : 8282.6000 USD
     74     Portfolio value: 1727.4000 USD
     75     Net worth      : 10010.0000 USD
     76   ```
    4977
    50 `Reserved` is 0.0000 here because nothing is mid-sell; see
    51 [UseCase0005Implementation](UseCase0005Implementation.md) for a portfolio
    52 snapshot taken with crypto actually reserved.
     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.
    5384
    5485### Transaction history
    5586
    56 1. **User** chooses `[7] View transaction history`.
    57 2. **System** runs:
     871. **Trader** chooses `[7] View transaction history` in the authenticated menu
     88   (types `7`).
     892. **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`):
    5892
    5993   ```sql
    … …  
    6296    WHERE user_id = $1
    6397    ORDER BY created_at DESC
    64     LIMIT 20;
     98    LIMIT 20
    6599   ```
    66100
    67    ![screenshot: transaction history](screenshots/uc0006_history.png)
     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   ![UC0006 transaction history: last ledger entries](screenshots/uc0006_history.png)
     110
     111All statements run on the `project` schema (the connection sets
     112`search_path=project,public`).
     113
     114### Reference — how `v_portfolio` is defined
     115
     116From `server/db/schema_creation.sql`:
     117
     118```sql
     119CREATE OR REPLACE VIEW project.v_portfolio AS
     120SELECT 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
     129FROM   project.holdings h
     130JOIN   project.crypto   c ON c.id = h.crypto_id
     131LEFT   JOIN project.markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD'
     132LEFT   JOIN project.v_latest_prices lp ON lp.market_id = m.id;
     133```
     134
     135## How to reproduce
     136
     137```sh
     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
     146Both screenshots come from one real run (portfolio and history taken right after
     147the buy and sell runs).
  • docs/P4-Prototype/UseCase0007Implementation.md

    ra531b45 ref1c1c7  
    1 # Use-case 0007 Implementation — Watchlist
     1# Use-case 0007 Implementation - Manage watchlist
    22
    3 **Initiating actor:** Trader. **Source file:** `server/watchlist.go`.
     3**Initiating actor:** Trader
    44
    5 ## Scenario (implemented)
     5**Other actors:** —
    66
    7 1. **User** chooses `[8] Manage watchlist`.
    8 2. **System** ensures a default watchlist exists:
     7A logged-in Trader keeps a list of crypto assets they want to monitor, with the last
     8price of each. The first time the watchlist is opened the system creates a default
     9watchlist named "Favorites" for the Trader. From a sub-menu the Trader can list the
     10watchlist, add a crypto or remove one. The Trader never types a symbol: for adding, the
     11system lists, numbered, only the cryptos that are not on the watchlist yet, and for
     12removing, only the cryptos that are on it; the Trader picks one by its number. Adding
     13a crypto that is already on the list is a no-op (idempotent), and a number that is not
     14in the list is refused without touching the database.
     15
     16Original use-case description (P3): [UseCase0007](../P3-UseCaseModel/UseCase0007.md).
     17Implementation: [`server/watchlist.go`](../../server/watchlist.go), functions
     18`ManageWatchlist`, `ensureDefaultWatchlist`, `listWatchlist`, `addToWatchlist` and
     19`removeFromWatchlist`, with `pickNumber` from
     20[`server/market.go`](../../server/market.go).
     21
     22All statements run on the `project` schema (the connection sets
     23`search_path=project,public` in `server/db/db.go`). The SQL below is copied from the
     24Go code; only the Go source indentation is removed.
     25
     26The run shown is user `alice` on the seed data, whose watchlist contains BTC, ETH and
     27SOL. She lists it, adds ADA, tries to remove a number that is not in the list, removes
     28SOL and lists the result.
     29
     30## Scenario
     31
     321. **Trader** chooses `[8] Manage watchlist` in the authenticated menu (types `8`).
     332. **System** makes sure the Trader has a watchlist and takes the id of the oldest one
     34   (`ensureDefaultWatchlist`; `$1` = the logged-in user's id):
    935
    1036   ```sql
    11    SELECT id FROM watchlists WHERE user_id = $1 ORDER BY created_at LIMIT 1;
    12    -- else
    13    INSERT INTO watchlists (user_id, name) VALUES ($1, 'Favorites') RETURNING id;
     37   SELECT id FROM watchlists WHERE user_id = $1 ORDER BY created_at LIMIT 1
    1438   ```
    1539
    16 3. **System** offers the submenu: List, Add, Remove, Back.
     40   Only if this returns no row, it creates the default watchlist and uses its id:
    1741
    18 ### List
     42   ```sql
     43   INSERT INTO watchlists (user_id, name) VALUES ($1, 'Favorites') RETURNING id
     44   ```
    1945
    20 ```sql
    21 SELECT c.symbol, c.name, COALESCE(lp.price, 0)
    22   FROM watchlist_items wi
    23   JOIN crypto c ON c.id = wi.crypto_id
    24   LEFT JOIN markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD'
    25   LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
    26  WHERE wi.watchlist_id = $1
    27  ORDER BY c.symbol;
    28 ```
     46   (alice already has the seed watchlist "Favorites", so only the `SELECT` runs.) The
     47   watchlist id is kept in Go and used as `$1` in all statements below.
     483. **System** shows the sub-menu `-- Watchlist --` with `[1] List items`,
     49   `[2] Add crypto`, `[3] Remove crypto` and `[0] Back`.
    2950
    30 ![Adding DOGE to the watchlist, then listing it](screenshots/uc0007_watchlist.png)
     51   ![UC0007 steps 1-3: Trader opens the watchlist, system shows the sub-menu](screenshots/uc0007_1_3_menu.png)
    3152
    32 ### Add
     53### List items
    3354
    34 ```sql
    35 SELECT id FROM crypto WHERE upper(symbol) = upper($1);
     554. **Trader** chooses `[1] List items`.
     565. **System** lists the cryptos on the watchlist with their last price against USD
     57   (`listWatchlist`; `$1` = watchlist id):
    3658
    37 INSERT INTO watchlist_items (watchlist_id, crypto_id)
    38 VALUES ($watchlist_id, $crypto_id)
    39 ON CONFLICT (watchlist_id, crypto_id) DO NOTHING;
    40 ```
     59   ```sql
     60   SELECT c.symbol, c.name, COALESCE(lp.price, 0)
     61     FROM watchlist_items wi
     62     JOIN crypto  c  ON c.id = wi.crypto_id
     63     LEFT JOIN markets       m  ON m.crypto_id = c.id AND m.quote_currency = 'USD'
     64     LEFT JOIN v_latest_prices lp ON lp.market_id = m.id
     65    WHERE wi.watchlist_id = $1
     66    ORDER BY c.symbol
     67   ```
    4168
    42 Re-adding the same symbol is a no-op thanks to the unique constraint + `ON CONFLICT`.
     69   For alice it prints `BTC Bitcoin 67140.000000`, `ETH Ethereum 3520.000000` and
     70   `SOL Solana 166.100000` (an empty watchlist prints `(watchlist is empty)`), then
     71   shows the sub-menu again.
    4372
    44 ### Remove
     73   ![UC0007 List items](screenshots/uc0007_list.png)
    4574
    46 ```sql
    47 DELETE FROM watchlist_items
    48  WHERE watchlist_id = $1
    49    AND crypto_id = (SELECT id FROM crypto WHERE upper(symbol) = upper($2));
    50 ```
     75### Add a crypto
     76
     776. **Trader** chooses `[2] Add crypto`.
     787. **System** lists, numbered, the cryptos that are not on the watchlist yet
     79   (`addToWatchlist`; `$1` = watchlist id):
     80
     81   ```sql
     82   SELECT c.id, c.symbol, c.name
     83     FROM crypto c
     84    WHERE NOT EXISTS (SELECT 1 FROM watchlist_items wi
     85                       WHERE wi.watchlist_id = $1 AND wi.crypto_id = c.id)
     86    ORDER BY c.symbol
     87   ```
     88
     89   For alice it prints `1 ADA Cardano` and `2 DOGE Dogecoin` and asks
     90   `Crypto # to add:`. Go keeps each row's crypto id in memory. (If every crypto is
     91   already on the watchlist, it prints `Every crypto is already on your watchlist.`
     92   instead.)
     93
     94   ![UC0007 Add: system lists the cryptos not yet on the watchlist](screenshots/uc0007_add_1_list.png)
     95
     968. **Trader** picks the crypto by its number in the list: `1` (ADA).
     979. **System** adds the crypto of row 1 to the watchlist (`$1` = watchlist id,
     98   `$2` = the chosen crypto's id); thanks to the unique constraint and
     99   `ON CONFLICT ... DO NOTHING`, adding a crypto that is already there changes
     100   nothing:
     101
     102   ```sql
     103   INSERT INTO watchlist_items (watchlist_id, crypto_id)
     104    VALUES ($1, $2)
     105    ON CONFLICT (watchlist_id, crypto_id) DO NOTHING
     106   ```
     107
     108   It prints `Added ADA.` and shows the sub-menu again.
     109
     110   ![UC0007 Add: Trader picks #1 (ADA), system adds it](screenshots/uc0007_add_2_added.png)
     111
     112### Remove a crypto
     113
     11410. **Trader** chooses `[3] Remove crypto`.
     11511. **System** lists, numbered, the cryptos that are on the watchlist
     116    (`removeFromWatchlist`; `$1` = watchlist id):
     117
     118    ```sql
     119    SELECT c.id, c.symbol, c.name
     120      FROM watchlist_items wi
     121      JOIN crypto c ON c.id = wi.crypto_id
     122     WHERE wi.watchlist_id = $1
     123     ORDER BY c.symbol
     124    ```
     125
     126    For alice it now prints `1 ADA Cardano`, `2 BTC Bitcoin`, `3 ETH Ethereum` and
     127    `4 SOL Solana` and asks `Crypto # to remove:`. Go keeps each row's crypto id in
     128    memory. (If the watchlist is empty, it prints `Your watchlist is empty.` instead.)
     129
     130    ![UC0007 Remove: system lists the watchlist's cryptos](screenshots/uc0007_remove_1_list.png)
     131
     13212. **Trader** picks the crypto by its number in the list: `4` (SOL).
     13313. **System** removes the crypto of row 4 from the watchlist (`$1` = watchlist id,
     134    `$2` = the chosen crypto's id):
     135
     136    ```sql
     137    DELETE FROM watchlist_items WHERE watchlist_id = $1 AND crypto_id = $2
     138    ```
     139
     140    It prints `Removed SOL.` and shows the sub-menu again.
     141
     142    ![UC0007 Remove: Trader picks #4 (SOL), system removes it](screenshots/uc0007_remove_2_removed.png)
     143
     144#### Alternate flow 12a — number not in the list
     145
     146Before removing SOL, alice first chose `[3] Remove crypto` and, at step 12, entered `5`
     147while only numbers 1–4 were listed. `pickNumber` prints
     148`Invalid choice, enter a number from 1 to 4.`, the `DELETE` is not run and the sub-menu
     149is shown again; she then chose `[3]` once more, which returned the scenario to step 11.
     150The same check applies to the number entered at step 8.
     151
     152![UC0007 Remove: a number that is not in the list is refused](screenshots/uc0007_remove_invalid.png)
     153
     154### Verification — list after the changes
     155
     156Choosing `[1] List items` again runs the query from step 5, which now returns
     157`ADA Cardano 0.453750`, `BTC Bitcoin 67140.000000` and `ETH Ethereum 3520.000000`:
     158ADA was added and SOL removed. `[0] Back` returns to the authenticated menu.
     159
     160![UC0007 List items after the changes](screenshots/uc0007_list_after.png)
  • docs/P5-Normalization/Normalization.md

    ra531b45 ref1c1c7  
    257257again: nothing was lost.
    258258
    259 **Lossless join.** For every pair (referencing relation, referenced relation) connected by a
    260 foreign key — `R_MARKETS.M_CRYPTO_ID → R_CRYPTO.C_ID`, `R_HOLDINGS.H_USER_ID → R_USERS.U_ID` /
    261 `R_HOLDINGS.H_CRYPTO_ID → R_CRYPTO.C_ID`, `R_ORDERS.O_USER_ID → R_USERS.U_ID` /
    262 `R_ORDERS.O_MARKET_ID → R_MARKETS.M_ID`, `R_TRANSACTIONS.T_USER_ID → R_USERS.U_ID` /
    263 `R_TRANSACTIONS.T_RELATED_ORDER → R_ORDERS.O_ID`, `R_MARKET_TRADES.MT_MARKET_ID →
    264 R_MARKETS.M_ID`, `R_MARKET_CANDLES.MC_MARKET_ID → R_MARKETS.M_ID`,
    265 `R_WATCHLISTS.W_USER_ID → R_USERS.U_ID`, `R_WATCHLIST_ITEMS.WI_WATCHLIST_ID →
    266 R_WATCHLISTS.W_ID` / `R_WATCHLIST_ITEMS.WI_CRYPTO_ID → R_CRYPTO.C_ID` — the join attribute on
    267 the "one" side is that relation's own primary key (`U_ID`, `C_ID`, `M_ID`, `O_ID`, `W_ID`).
    268 A join on a foreign key equated to the primary key it references is the textbook sufficient
    269 condition for a lossless decomposition (`Ri ∩ Rj` is a key of `Rj`), so re-joining all ten
    270 relations on their foreign-key/primary-key pairs reconstructs `R_EDUBERZA` exactly, with no
    271 spurious rows and none missing.
     259**Lossless join — chase test.**
     260
     261> *Note: the chase algorithm is not part of the course material. I was curious about a
     262> stricter way to test lossless join than the usual "the common attributes are a key of one
     263> side" argument, so I applied it here.*
     264
     265The chase decides whether a decomposition `R = R1 ∪ … ∪ Rn` is lossless under a set of
     266functional dependencies. Build a tableau with one column per attribute of `R` and one row per
     267relation `Ri`. In row `i`, put a distinguished symbol `a` in every column of `Ri` and a unique
     268symbol `b_i` in every other column. Then repeat, until nothing changes: for each FD `X → Y`,
     269whenever two rows agree on all of `X`, make them agree on `Y`. If they disagree, an `a` wins,
     270otherwise one `b` replaces the other. **The decomposition is lossless exactly when some row
     271ends up with `a` in every column.**
     272
     273All attributes of one cluster (`U_*`, `C_*`, `M_*`, …) always appear together, and FD1–FD17
     274never mix clusters. So each cluster is one column group below: `a` means every column of the
     275group holds a distinguished symbol, and `b` means none of them does. The foreign-key
     276attributes (`H_USER_ID`, `O_MARKET_ID`, …) belong to their own cluster (`H_*`, `O_*`, …), not
     277to the cluster they reference.
     278
     279**Step 1 — the ten relations from the table above.**
     280
     281```
     282              U*  C*  M*  H*  O*  T*  MT* MC* W*  WI*
     283R_USERS        a   b   b   b   b   b   b   b   b   b
     284R_CRYPTO       b   a   b   b   b   b   b   b   b   b
     285R_MARKETS      b   b   a   b   b   b   b   b   b   b
     286R_HOLDINGS     b   b   b   a   b   b   b   b   b   b
     287R_ORDERS       b   b   b   b   a   b   b   b   b   b
     288R_TRANSACTIONS b   b   b   b   b   a   b   b   b   b
     289R_MARKET_TR.   b   b   b   b   b   b   a   b   b   b
     290R_MARKET_CA.   b   b   b   b   b   b   b   a   b   b
     291R_WATCHLISTS   b   b   b   b   b   b   b   b   a   b
     292R_WATCHLIST_I. b   b   b   b   b   b   b   b   b   a
     293```
     294
     295Every FD has its left side inside one cluster, for example `U_ID → U_*` or
     296`H_USER_ID, H_CRYPTO_ID → H_ID`. For such an FD to fire, two rows would have to agree on that
     297left side. But only one row has `a`s in that cluster, and the `b`s of different rows are
     298all different, so no two rows ever agree on any left side. **The chase changes nothing, and
     299no row becomes all `a`.** Under FD1–FD17 alone, the ten relations are *not* guaranteed to
     300join back to `R_EDUBERZA`. This is not an accident of this model. It is exactly why
     301Bernstein's synthesis algorithm has a final step: *if no synthesised relation contains a
     302candidate key of `R`, add one that does.* None of the ten contains the ten-attribute key.
     303
     304**Step 2 — add the key relation** `R_KEY(U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID,
     305W_ID, WI_ID)`. Its row has `a` only in the ten ID columns, written `a·` for "`a` in the ID,
     306`b` in the rest of the group":
     307
     308```
     309              U*  C*  M*  H*  O*  T*  MT* MC* W*  WI*
     310R_KEY          a·  a·  a·  a·  a·  a·  a·  a·  a·  a·
     311(the ten rows of step 1 unchanged)
     312```
     313
     314Now FD1 `U_ID → U_*` fires: row `R_KEY` and row `R_USERS` both have `a` in `U_ID`, so they
     315must agree on the rest of `U_*`, and `R_USERS` has `a` there. `R_KEY` becomes `a` in the whole
     316`U*` group. The same happens with FD4 (`C*`), FD6 (`M*`), FD8 (`H*`), FD10 (`O*`), FD11 (`T*`),
     317FD12 (`MT*`), FD13 (`MC*`), FD15 (`W*`) and FD16 (`WI*`):
     318
     319```
     320              U*  C*  M*  H*  O*  T*  MT* MC* W*  WI*
     321R_KEY          a   a   a   a   a   a   a   a   a   a     <- all distinguished
     322```
     323
     324**Row `R_KEY` is all `a`, so the decomposition into the ten relations plus `R_KEY` is
     325lossless.**
     326
     327**Why `R_KEY` is not kept in the final schema.** An instance of `R_KEY` would only record
     328which ID of one cluster appears together with which ID of every other cluster. As shown under
     329*Candidate keys and primary key*, the ten clusters are independent record types, and
     330`R_EDUBERZA` pairs every row of one with every row of the others. So `R_KEY` would be just the
     331cross product of the ten ID sets and would carry no information. The same independence means
     332the join dependency `⋈[R_USERS, …, R_WATCHLIST_ITEMS]` holds on `R_EDUBERZA` by construction.
     333Under that dependency the ten relations alone already reconstruct it: their natural join, with
     334no common attributes, is exactly that cross product. The chase makes this reasoning explicit.
     335FDs by themselves cannot prove the join lossless; you need either the key relation or the
     336independence of the clusters. That was hidden in the earlier "foreign key equals primary key"
     337argument, which described the equi-joins the application runs, not the natural join the
     338lossless-join property is about.
    272339
    273340## 3NF decomposition
  • docs/README.md

    ra531b45 ref1c1c7  
    4343| `P5-Normalization/`     | P5 | `Normalization`, `NormalizationAIUsage` |
    4444| `P6-AdvancedReports/`   | P6 | `AdvancedReports`, `AdvancedReportsAIUsage` |
     45| `P7-AdvancedDatabaseDevelopment/` | P7 | `AdvancedDatabaseDevelopment`, `AdvancedDatabaseDevelopmentAIUsage` |
    4546
    4647`Instructions.md` is the condensed course rubric — reference material, not a
    … …  
    6566| P5 | [Normalization](P5-Normalization/Normalization.md) | Finished, awaiting approval |
    6667| P6 | [AdvancedReports](P6-AdvancedReports/AdvancedReports.md) | Finished, awaiting approval |
    67 | P7 | *Advanced Database Development* | Not started |
     68| P7 | [AdvancedDatabaseDevelopment](P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md) | Finished, awaiting approval |
    6869| P8 | *Advanced Application Development* | Not started |
    6970| P9 | *Other topics (Performance, Security)* | Not started |
    … …  
    8990| [`relational_schema.jpg`](P2-RelationalDesign/relational_schema.jpg) | P2 | Crow's-foot diagram exported from DBeaver |
    9091| [`../server/db/reports_demo_data.sql`](../server/db/reports_demo_data.sql) | P6 | Optional multi-quarter demo data for the two reports (not part of `-init`) |
     92| [`../server/db/advanced_db.sql`](../server/db/advanced_db.sql) | P7 | Triggers, functions, views and the background job (part of `-init`) |
     93| [`../server/db/advanced_db_tests.sql`](../server/db/advanced_db_tests.sql) | P7 | Tests for every P7 rule, rolled back at the end |
    9194
    9295## Use cases (P3)
    … …  
    109112- [UseCaseModelAIUsage](P3-UseCaseModel/UseCaseModelAIUsage.md) (P3)
    110113- [PrototypeImplementationAIUsage](P4-Prototype/PrototypeImplementationAIUsage.md) (P4)
     114- [NormalizationAIUsage](P5-Normalization/NormalizationAIUsage.md) (P5)
     115- [AdvancedReportsAIUsage](P6-AdvancedReports/AdvancedReportsAIUsage.md) (P6)
     116- [AdvancedDatabaseDevelopmentAIUsage](P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopmentAIUsage.md) (P7)
    111117
    112118## Build & run
  • go.mod

    ra531b45 ref1c1c7  
    33go 1.25
    44
    5 require github.com/lib/pq v1.10.9
     5require (
     6        github.com/lib/pq v1.10.9
     7        golang.org/x/crypto v0.45.0
     8)
     9
     10require golang.org/x/sys v0.38.0 // indirect
  • go.sum

    ra531b45 ref1c1c7  
    11github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
    22github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
     3golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
     4golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
     5golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
     6golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
     7golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
     8golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
  • 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.