| 1 | # Relational Design
|
|---|
| 2 |
|
|---|
| 3 | ## Descriptive representation of the relational schema
|
|---|
| 4 |
|
|---|
| 5 | Notation: **bold** = primary key, *italic* = foreign key.
|
|---|
| 6 |
|
|---|
| 7 | - **Users**(<u>**id**</u>, username, email, full_name, password_hash, available_balance, invested_balance, created_at, updated_at)
|
|---|
| 8 | - Candidate keys: `{id}`, `{username}`, `{email}`. `UNIQUE(username)`, `UNIQUE(email)`.
|
|---|
| 9 | - **Crypto**(<u>**id**</u>, symbol, name, created_at)
|
|---|
| 10 | - Candidate keys: `{id}`, `{symbol}`. `UNIQUE(symbol)`.
|
|---|
| 11 | - **Markets**(<u>**id**</u>, *crypto_id*, quote_currency, is_active, created_at)
|
|---|
| 12 | - Candidate keys: `{id}`, `{crypto_id, quote_currency}`. `UNIQUE(crypto_id, quote_currency)`.
|
|---|
| 13 | - **Holdings**(<u>**id**</u>, *user_id*, *crypto_id*, quantity, reserved_quantity, avg_price, created_at, updated_at)
|
|---|
| 14 | - Transformation of the M:N relationship `Holds`. Candidate keys: `{id}` and
|
|---|
| 15 | `{user_id, crypto_id}` — the latter is the relationship's own key and is
|
|---|
| 16 | enforced with `UNIQUE(user_id, crypto_id)`. `id` was chosen as PK for
|
|---|
| 17 | consistency with the other relations.
|
|---|
| 18 | - `avg_price` is `NOT NULL DEFAULT 0 CHECK (avg_price >= 0)`.
|
|---|
| 19 | - `reserved_quantity` is `NOT NULL DEFAULT 0 CHECK (reserved_quantity >= 0
|
|---|
| 20 | AND reserved_quantity <= quantity)` — the amount already committed to the
|
|---|
| 21 | user's own open sell orders. `quantity - reserved_quantity` (the amount
|
|---|
| 22 | actually free to sell) is not a stored column; it is computed wherever
|
|---|
| 23 | needed, in `v_portfolio` as `available_quantity` and in the sell path of
|
|---|
| 24 | [UseCase0005](../P3-UseCaseModel/UseCase0005.md). See
|
|---|
| 25 | [ERModel](../P1-ConceptualModel/ERModel.md#holds--users-m--cryptos-n-partial-on-both-sides-with-attributes)
|
|---|
| 26 | for why this mirrors `available_balance`/`invested_balance` on `Users`.
|
|---|
| 27 | - **Orders**(<u>**id**</u>, *user_id*, *market_id*, side, type, status, quantity, price, placed_at, executed_at)
|
|---|
| 28 | - `side ∈ {buy, sell}`, `type ∈ {market, limit}`, `status ∈ {open, executed, cancelled}`.
|
|---|
| 29 | - **Transactions**(<u>**id**</u>, *user_id*, type, amount, currency, *related_order*, created_at, description)
|
|---|
| 30 | - `type ∈ {deposit, buy, sell, fee}`.
|
|---|
| 31 | - **MarketTrades**(<u>**id**</u>, *market_id*, executed_at, price, quantity, side, source)
|
|---|
| 32 | - **MarketCandles**(<u>**id**</u>, *market_id*, timeframe, open, high, low, close, volume, candle_time)
|
|---|
| 33 | - `UNIQUE(market_id, timeframe, candle_time)`.
|
|---|
| 34 | - **Watchlists**(<u>**id**</u>, *user_id*, name, created_at)
|
|---|
| 35 | - **WatchlistItems**(<u>**id**</u>, *watchlist_id*, *crypto_id*, added_at)
|
|---|
| 36 | - Transformation of the M:N relationship `Contains`. Candidate keys: `{id}`
|
|---|
| 37 | and `{watchlist_id, crypto_id}`, the latter enforced with
|
|---|
| 38 | `UNIQUE(watchlist_id, crypto_id)`.
|
|---|
| 39 |
|
|---|
| 40 | ### Transformation method used
|
|---|
| 41 |
|
|---|
| 42 | **Partial transformation.** Applied as follows:
|
|---|
| 43 |
|
|---|
| 44 | - Each of the 8 entity sets in [ERModel](../P1-ConceptualModel/ERModel.md) becomes one table, keeping
|
|---|
| 45 | its UUID (or serial) primary key.
|
|---|
| 46 | - Each **1:N relationship without attributes** is transformed by adding the
|
|---|
| 47 | parent's primary key as a foreign-key column on the child table — the "N"
|
|---|
| 48 | side. This is where every foreign key in the schema comes from, and it is why
|
|---|
| 49 | no foreign keys appear in the ER diagram itself:
|
|---|
| 50 | `QuotedOn` → `markets.crypto_id`, `PlacedOn` → `orders.market_id`,
|
|---|
| 51 | `Places` → `orders.user_id`, `Records` → `transactions.user_id`,
|
|---|
| 52 | `Settles` → `transactions.related_order`, `Fills` → `market_trades.market_id`,
|
|---|
| 53 | `Aggregates` → `market_candles.market_id`, `Owns` → `watchlists.user_id`.
|
|---|
| 54 | - Each **M:N relationship** becomes its own table holding the two foreign keys
|
|---|
| 55 | plus the relationship's own attributes: `Holds` → `holdings`,
|
|---|
| 56 | `Contains` → `watchlist_items`. The pair of foreign keys is the relationship's
|
|---|
| 57 | key and is enforced as a `UNIQUE` constraint in both tables.
|
|---|
| 58 | - **Total participation** in the ER model becomes `NOT NULL` on the
|
|---|
| 59 | corresponding foreign key; partial participation stays nullable. `Settles` is
|
|---|
| 60 | partial on both sides, which is exactly why `transactions.related_order` is
|
|---|
| 61 | the one nullable foreign key in the schema — a deposit has no originating
|
|---|
| 62 | order.
|
|---|
| 63 |
|
|---|
| 64 | ### Normalisation
|
|---|
| 65 |
|
|---|
| 66 | > **Validated in P5.** [Normalization](../P5-Normalization/Normalization.md) derives this
|
|---|
| 67 | > exact schema independently — starting only from a single de-normalized relation of every
|
|---|
| 68 | > model attribute and its functional dependencies, with no reference to the ER-to-relational
|
|---|
| 69 | > transformation below — and shows it decomposes to **BCNF**, one normal form stronger than
|
|---|
| 70 | > the 3NF claimed here. The two designs agree relation for relation and key for key, so
|
|---|
| 71 | > nothing here changed as a result; see that page's
|
|---|
| 72 | > [discussion](../P5-Normalization/Normalization.md#discussion) for what the one real
|
|---|
| 73 | > difference is (`avg_price`, a stored derived value, not a normalisation issue) and why this
|
|---|
| 74 | > design is still the one used from P5 onward.
|
|---|
| 75 |
|
|---|
| 76 | All relations are in **3NF**:
|
|---|
| 77 |
|
|---|
| 78 | - Every attribute is atomic (no repeating groups, no composite fields).
|
|---|
| 79 | - No partial dependency exists because every primary key is a single UUID column.
|
|---|
| 80 | - No transitive dependency exists: every non-key attribute depends directly on the row identifier. For example, `holdings.quantity` depends on `holdings.id`, not on `user_id` via some intermediate.
|
|---|
| 81 | - `avg_price` in `Holdings` is a **derived value** cached for performance (it is
|
|---|
| 82 | the weighted-average entry price across all `buy` transactions for that
|
|---|
| 83 | `(user, crypto)` pair) — it is drawn as a derived attribute in the ER diagram.
|
|---|
| 84 | We accept the denormalisation: it is recomputed by the database inside the same
|
|---|
| 85 | transaction as each buy, in the same statement that changes the quantity
|
|---|
| 86 | (`INSERT … ON CONFLICT (user_id, crypto_id) DO UPDATE`), so the stored average
|
|---|
| 87 | and the stored quantity can never disagree.
|
|---|
| 88 | - `avg_price` is declared `NOT NULL DEFAULT 0`. This matters: it is used in the
|
|---|
| 89 | P/L arithmetic of `v_portfolio`, and in SQL any arithmetic involving `NULL`
|
|---|
| 90 | yields `NULL`, so a nullable average would have silently blanked the
|
|---|
| 91 | unrealised-P/L column for an existing position instead of failing loudly.
|
|---|
| 92 | - `holdings.reserved_quantity`, unlike `avg_price`, is **not** derived — it is
|
|---|
| 93 | written directly by the application (`trade.go`) as orders are placed and
|
|---|
| 94 | settled, the same way `quantity` itself is. `quantity - reserved_quantity`
|
|---|
| 95 | ("available") is the derived value here, and it is never stored, only
|
|---|
| 96 | computed where it is needed.
|
|---|
| 97 |
|
|---|
| 98 | ### Reservation and the order lifecycle
|
|---|
| 99 |
|
|---|
| 100 | `holdings.reserved_quantity` exists so that placing a sell order can be
|
|---|
| 101 | checked against what a user actually has *free* to sell
|
|---|
| 102 | (`quantity - reserved_quantity`), not against the raw `quantity`, which also
|
|---|
| 103 | counts crypto already promised to another order that has not settled yet.
|
|---|
| 104 | `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` makes an
|
|---|
| 105 | inconsistent reservation impossible at the database level, regardless of what
|
|---|
| 106 | application code does. The exact statement sequence — lock the row, check the
|
|---|
| 107 | available amount, reserve, then settle — is in
|
|---|
| 108 | [UseCase0005](../P3-UseCaseModel/UseCase0005.md); the same
|
|---|
| 109 | `SELECT … FOR UPDATE` locking that already protected `users.available_balance`
|
|---|
| 110 | on the buy path is what makes two concurrent sell orders against the same
|
|---|
| 111 | holding serialize correctly instead of racing.
|
|---|
| 112 |
|
|---|
| 113 | ## DDL script
|
|---|
| 114 |
|
|---|
| 115 | The script that creates the entire schema is [`../server/db/schema_creation.sql`](../../server/db/schema_creation.sql). It is idempotent: it drops and recreates the `project` schema every run, so it works on an empty database and on a database that already has the schema.
|
|---|
| 116 |
|
|---|
| 117 | The script creates:
|
|---|
| 118 | - 10 tables with check constraints, primary keys, foreign keys and unique constraints.
|
|---|
| 119 | - 5 performance indexes.
|
|---|
| 120 | - 2 views: `v_latest_prices` (latest trade price per market) and `v_portfolio` (per-user holdings valuation with unrealised P/L, plus `reserved_quantity` and the derived `available_quantity`).
|
|---|
| 121 |
|
|---|
| 122 | ## DML script (sample data)
|
|---|
| 123 |
|
|---|
| 124 | The script that loads realistic sample data is [`../server/db/data_load.sql`](../../server/db/data_load.sql). It is idempotent: it truncates all tables with `CASCADE` then re-inserts. Loaded:
|
|---|
| 125 | - 5 crypto assets (BTC, ETH, ADA, SOL, DOGE) and 5 USD-quoted markets.
|
|---|
| 126 | - 3 sample users (`alice`, `bob`, `charlie`) with password `test123` (sha256 hex).
|
|---|
| 127 | - 18 recent market trades across all markets so `v_latest_prices` is populated.
|
|---|
| 128 | - 10 one-hour candles (BTC and ETH).
|
|---|
| 129 | - One fully-executed market-buy order for Alice, the matching holding, and two ledger entries (deposit + buy), with Alice's balances updated accordingly.
|
|---|
| 130 | - Two watchlists with five watchlist items.
|
|---|
| 131 |
|
|---|
| 132 | ## Relational diagram
|
|---|
| 133 |
|
|---|
| 134 | 
|
|---|
| 135 |
|
|---|
| 136 | Generated in **Pgadmin** from the **live** `project` schema, in crow's-foot
|
|---|
| 137 | notation — not drawn by hand, so it is evidence that the deployed database
|
|---|
| 138 | actually matches the design described above. Each box is a table with its
|
|---|
| 139 | columns and declared types; key icons mark primary keys and the arrowed lines
|
|---|
| 140 | are the 12 declared foreign keys.
|
|---|
| 141 |
|
|---|
| 142 | ### How to regenerate it
|
|---|
| 143 |
|
|---|
| 144 | **With pgAdmin 4**, if DBeaver is unavailable — it reads the live schema the same
|
|---|
| 145 | way, so the result is equivalent in substance:
|
|---|
| 146 |
|
|---|
| 147 | 1. Connect to the project database.
|
|---|
| 148 | 2. Right-click the database → **ERD For Database** (or open a blank ERD and drag
|
|---|
| 149 | the `project` tables in).
|
|---|
| 150 | 3. Arrange the tables to mirror `ERModel_v03.png`.
|
|---|
| 151 | 4. **Download image** → PNG, then convert:
|
|---|
| 152 | `convert relational_schema.png relational_schema.jpg`
|
|---|
| 153 |
|
|---|