| 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, 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 | | - **Orders**(<u>**id**</u>, *user_id*, *market_id*, side, type, status, quantity, price, placed_at, executed_at) |
| 20 | | - `side ∈ {buy, sell}`, `type ∈ {market, limit}`, `status ∈ {open, executed, cancelled}`. |
| 21 | | - **Transactions**(<u>**id**</u>, *user_id*, type, amount, currency, *related_order*, created_at, description) |
| 22 | | - `type ∈ {deposit, buy, sell, fee}`. |
| 23 | | - **MarketTrades**(<u>**id**</u>, *market_id*, executed_at, price, quantity, side, source) |
| 24 | | - **MarketCandles**(<u>**id**</u>, *market_id*, timeframe, open, high, low, close, volume, candle_time) |
| 25 | | - `UNIQUE(market_id, timeframe, candle_time)`. |
| 26 | | - **Watchlists**(<u>**id**</u>, *user_id*, name, created_at) |
| 27 | | - **WatchlistItems**(<u>**id**</u>, *watchlist_id*, *crypto_id*, added_at) |
| 28 | | - Transformation of the M:N relationship `Contains`. Candidate keys: `{id}` |
| 29 | | and `{watchlist_id, crypto_id}`, the latter enforced with |
| 30 | | `UNIQUE(watchlist_id, crypto_id)`. |
| 31 | | |
| 32 | | ==== Transformation method used |
| 33 | | |
| 34 | | **Partial transformation.** Applied as follows: |
| 35 | | |
| 36 | | - Each of the 8 entity sets in [ERModel](../P1-ConceptualModel/ERModel.md) becomes one table, keeping |
| 37 | | its UUID (or serial) primary key. |
| 38 | | - Each **1:N relationship without attributes** is transformed by adding the |
| 39 | | parent's primary key as a foreign-key column on the child table — the "N" |
| 40 | | side. This is where every foreign key in the schema comes from, and it is why |
| 41 | | no foreign keys appear in the ER diagram itself: |
| 42 | | `QuotedOn` → `markets.crypto_id`, `PlacedOn` → `orders.market_id`, |
| 43 | | `Places` → `orders.user_id`, `Records` → `transactions.user_id`, |
| 44 | | `Settles` → `transactions.related_order`, `Fills` → `market_trades.market_id`, |
| 45 | | `Aggregates` → `market_candles.market_id`, `Owns` → `watchlists.user_id`. |
| 46 | | - Each **M:N relationship** becomes its own table holding the two foreign keys |
| 47 | | plus the relationship's own attributes: `Holds` → `holdings`, |
| 48 | | `Contains` → `watchlist_items`. The pair of foreign keys is the relationship's |
| 49 | | key and is enforced as a `UNIQUE` constraint in both tables. |
| 50 | | - **Total participation** in the ER model becomes `NOT NULL` on the |
| 51 | | corresponding foreign key; partial participation stays nullable. `Settles` is |
| 52 | | partial on both sides, which is exactly why `transactions.related_order` is |
| 53 | | the one nullable foreign key in the schema — a deposit has no originating |
| 54 | | order. |
| 55 | | |
| 56 | | ==== Normalisation |
| 57 | | |
| 58 | | All relations are in **3NF**: |
| 59 | | |
| 60 | | - Every attribute is atomic (no repeating groups, no composite fields). |
| 61 | | - No partial dependency exists because every primary key is a single UUID column. |
| 62 | | - 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. |
| 63 | | - `avg_price` in `Holdings` is a **derived value** cached for performance (it is |
| 64 | | the weighted-average entry price across all `buy` transactions for that |
| 65 | | `(user, crypto)` pair) — it is drawn as a derived attribute in the ER diagram. |
| 66 | | We accept the denormalisation: it is recomputed by the database inside the same |
| 67 | | transaction as each buy, in the same statement that changes the quantity |
| 68 | | (`INSERT … ON CONFLICT (user_id, crypto_id) DO UPDATE`), so the stored average |
| 69 | | and the stored quantity can never disagree. |
| 70 | | - `avg_price` is declared `NOT NULL DEFAULT 0`. This matters: it is used in the |
| 71 | | P/L arithmetic of `v_portfolio`, and in SQL any arithmetic involving `NULL` |
| 72 | | yields `NULL`, so a nullable average would have silently blanked the |
| 73 | | unrealised-P/L column for an existing position instead of failing loudly. |
| 74 | | |
| 75 | | === DDL script |
| 76 | | |
| 77 | | 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. |
| | 1 | = Relational Design = |
| | 2 | |
| | 3 | == Descriptive representation of the relational schema == |
| | 4 | |
| | 5 | Notation: '''bold''' = primary key, ''italic'' = foreign key. |
| | 6 | |
| | 7 | * '''Users'''(__'''id'''__, 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'''(__'''id'''__, symbol, name, created_at) |
| | 10 | * Candidate keys: `{id}`, `{symbol}`. `UNIQUE(symbol)`. |
| | 11 | * '''Markets'''(__'''id'''__, ''crypto_id'', quote_currency, is_active, created_at) |
| | 12 | * Candidate keys: `{id}`, `{crypto_id, quote_currency}`. `UNIQUE(crypto_id, quote_currency)`. |
| | 13 | * '''Holdings'''(__'''id'''__, ''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 AND reserved_quantity <= quantity)` — the amount already committed to the |
| | 20 | user's own open sell orders. `quantity - reserved_quantity` (the amount |
| | 21 | actually free to sell) is not a stored column; it is computed wherever |
| | 22 | needed, in `v_portfolio` as `available_quantity` and in the sell path of |
| | 23 | UseCase0005. See the `Holds` section of ERModel |
| | 24 | for why this mirrors `available_balance`/`invested_balance` on `Users`. |
| | 25 | * '''Orders'''(__'''id'''__, ''user_id'', ''market_id'', side, type, status, quantity, price, placed_at, executed_at) |
| | 26 | * `side ∈ {buy, sell}`, `type ∈ {market, limit}`, `status ∈ {open, executed, cancelled}`. |
| | 27 | * '''Transactions'''(__'''id'''__, ''user_id'', type, amount, currency, ''related_order'', created_at, description) |
| | 28 | * `type ∈ {deposit, buy, sell, fee}`. |
| | 29 | * '''!MarketTrades'''(__'''id'''__, ''market_id'', executed_at, price, quantity, side, source) |
| | 30 | * '''!MarketCandles'''(__'''id'''__, ''market_id'', timeframe, open, high, low, close, volume, candle_time) |
| | 31 | * `UNIQUE(market_id, timeframe, candle_time)`. |
| | 32 | * '''Watchlists'''(__'''id'''__, ''user_id'', name, created_at) |
| | 33 | * '''!WatchlistItems'''(__'''id'''__, ''watchlist_id'', ''crypto_id'', added_at) |
| | 34 | * Transformation of the M:N relationship `Contains`. Candidate keys: `{id}` |
| | 35 | and `{watchlist_id, crypto_id}`, the latter enforced with |
| | 36 | `UNIQUE(watchlist_id, crypto_id)`. |
| | 37 | |
| | 38 | === Transformation method used === |
| | 39 | |
| | 40 | '''Partial transformation.''' Applied as follows: |
| | 41 | |
| | 42 | * Each of the 8 entity sets in ERModel becomes one table, keeping |
| | 43 | its UUID (or serial) primary key. |
| | 44 | * Each '''1:N relationship without attributes''' is transformed by adding the |
| | 45 | parent's primary key as a foreign-key column on the child table — the "N" |
| | 46 | side. This is where every foreign key in the schema comes from, and it is why |
| | 47 | no foreign keys appear in the ER diagram itself: |
| | 48 | `QuotedOn` → `markets.crypto_id`, `PlacedOn` → `orders.market_id`, |
| | 49 | `Places` → `orders.user_id`, `Records` → `transactions.user_id`, |
| | 50 | `Settles` → `transactions.related_order`, `Fills` → `market_trades.market_id`, |
| | 51 | `Aggregates` → `market_candles.market_id`, `Owns` → `watchlists.user_id`. |
| | 52 | * Each '''M:N relationship''' becomes its own table holding the two foreign keys |
| | 53 | plus the relationship's own attributes: `Holds` → `holdings`, |
| | 54 | `Contains` → `watchlist_items`. The pair of foreign keys is the relationship's |
| | 55 | key and is enforced as a `UNIQUE` constraint in both tables. |
| | 56 | * '''Total participation''' in the ER model becomes `NOT NULL` on the |
| | 57 | corresponding foreign key; partial participation stays nullable. `Settles` is |
| | 58 | partial on both sides, which is exactly why `transactions.related_order` is |
| | 59 | the one nullable foreign key in the schema — a deposit has no originating |
| | 60 | order. |
| | 61 | |
| | 62 | === Normalisation === |
| | 63 | |
| | 64 | > '''Validated in P5.''' Normalization derives this |
| | 65 | > exact schema independently — starting only from a single de-normalized relation of every |
| | 66 | > model attribute and its functional dependencies, with no reference to the ER-to-relational |
| | 67 | > transformation below — and shows it decomposes to '''BCNF''', one normal form stronger than |
| | 68 | > the 3NF claimed here. The two designs agree relation for relation and key for key, so |
| | 69 | > nothing here changed as a result; see that page's |
| | 70 | > discussion section for what the one real |
| | 71 | > difference is (`avg_price`, a stored derived value, not a normalisation issue) and why this |
| | 72 | > design is still the one used from P5 onward. |
| | 73 | |
| | 74 | All relations are in '''3NF''': |
| | 75 | |
| | 76 | * Every attribute is atomic (no repeating groups, no composite fields). |
| | 77 | * No partial dependency exists because every primary key is a single UUID column. |
| | 78 | * 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. |
| | 79 | * `avg_price` in `Holdings` is a '''derived value''' cached for performance (it is |
| | 80 | the weighted-average entry price across all `buy` transactions for that |
| | 81 | `(user, crypto)` pair) — it is drawn as a derived attribute in the ER diagram. |
| | 82 | We accept the denormalisation: it is recomputed by the database inside the same |
| | 83 | transaction as each buy, in the same statement that changes the quantity |
| | 84 | (`INSERT … ON CONFLICT (user_id, crypto_id) DO UPDATE`), so the stored average |
| | 85 | and the stored quantity can never disagree. |
| | 86 | * `avg_price` is declared `NOT NULL DEFAULT 0`. This matters: it is used in the |
| | 87 | P/L arithmetic of `v_portfolio`, and in SQL any arithmetic involving `NULL` |
| | 88 | yields `NULL`, so a nullable average would have silently blanked the |
| | 89 | unrealised-P/L column for an existing position instead of failing loudly. |
| | 90 | * `holdings.reserved_quantity`, unlike `avg_price`, is '''not''' derived — it is |
| | 91 | written directly by the application (`trade.go`) as orders are placed and |
| | 92 | settled, the same way `quantity` itself is. `quantity - reserved_quantity` |
| | 93 | ("available") is the derived value here, and it is never stored, only |
| | 94 | computed where it is needed. |
| | 95 | |
| | 96 | === Reservation and the order lifecycle === |
| | 97 | |
| | 98 | `holdings.reserved_quantity` exists so that placing a sell order can be |
| | 99 | checked against what a user actually has ''free'' to sell |
| | 100 | (`quantity - reserved_quantity`), not against the raw `quantity`, which also |
| | 101 | counts crypto already promised to another order that has not settled yet. |
| | 102 | `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` makes an |
| | 103 | inconsistent reservation impossible at the database level, regardless of what |
| | 104 | application code does. The exact statement sequence — lock the row, check the |
| | 105 | available amount, reserve, then settle — is in |
| | 106 | UseCase0005; the same |
| | 107 | `SELECT … FOR UPDATE` locking that already protected `users.available_balance` |
| | 108 | on the buy path is what makes two concurrent sell orders against the same |
| | 109 | holding serialize correctly instead of racing. |
| | 110 | |
| | 111 | == DDL script == |
| | 112 | |
| | 113 | The script that creates the entire schema is `../server/db/schema_creation.sql` (shown in full below). 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. |
| 80 | | - 10 tables with check constraints, primary keys, foreign keys and unique constraints. |
| 81 | | - 5 performance indexes. |
| 82 | | - 2 views: `v_latest_prices` (latest trade price per market) and `v_portfolio` (per-user holdings valuation with unrealised P/L). |
| 83 | | |
| 84 | | === DML script (sample data) |
| 85 | | |
| 86 | | 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: |
| 87 | | - 5 crypto assets (BTC, ETH, ADA, SOL, DOGE) and 5 USD-quoted markets. |
| 88 | | - 3 sample users (`alice`, `bob`, `charlie`) with password `test123` (sha256 hex). |
| 89 | | - 18 recent market trades across all markets so `v_latest_prices` is populated. |
| 90 | | - 10 one-hour candles (BTC and ETH). |
| 91 | | - One fully-executed market-buy order for Alice, the matching holding, and two ledger entries (deposit + buy), with Alice's balances updated accordingly. |
| 92 | | - Two watchlists with five watchlist items. |
| 93 | | |
| 94 | | === Relational diagram |
| 95 | | |
| 96 | |  |
| 97 | | |
| 98 | | Generated in **Pgadmin** from the **live** `project` schema, in crow's-foot |
| | 116 | * 10 tables with check constraints, primary keys, foreign keys and unique constraints. |
| | 117 | * 5 performance indexes. |
| | 118 | * 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`). |
| | 119 | |
| | 120 | === schema_creation.sql === |
| | 121 | |
| | 122 | The two report functions at the end of the file (`report_top_traders` and `report_market_performance`) belong to Phase 6 (!AdvancedReports) and are left out here. |
| | 123 | |
| | 124 | {{{ |
| | 125 | -- schema_creation.sql |
| | 126 | -- EduBerza - crypto exchange simulation database |
| | 127 | -- Course: Databases 2025/2026 Winter, FINKI UKIM |
| | 128 | -- |
| | 129 | -- This script is idempotent. It drops the `project` schema and all contained |
| | 130 | -- objects, then recreates them from scratch. Safe to run on an empty database |
| | 131 | -- or on a database where the schema already exists. |
| | 132 | |
| | 133 | DROP SCHEMA IF EXISTS project CASCADE; |
| | 134 | CREATE SCHEMA project; |
| | 135 | |
| | 136 | CREATE EXTENSION IF NOT EXISTS pgcrypto; |
| | 137 | |
| | 138 | SET search_path TO project, public; |
| | 139 | |
| | 140 | -- ============================================================================ |
| | 141 | -- USERS |
| | 142 | -- Platform users. Each user has virtual (prop) balances used for simulation. |
| | 143 | -- ============================================================================ |
| | 144 | CREATE TABLE project.users ( |
| | 145 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 146 | username varchar(50) NOT NULL UNIQUE, |
| | 147 | email varchar(255) NOT NULL UNIQUE, |
| | 148 | full_name varchar(200), |
| | 149 | password_hash varchar(255) NOT NULL, |
| | 150 | available_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (available_balance >= 0), |
| | 151 | invested_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (invested_balance >= 0), |
| | 152 | created_at timestamptz NOT NULL DEFAULT now(), |
| | 153 | updated_at timestamptz |
| | 154 | ); |
| | 155 | |
| | 156 | -- ============================================================================ |
| | 157 | -- CRYPTO |
| | 158 | -- Catalog of crypto assets available on the platform. |
| | 159 | -- ============================================================================ |
| | 160 | CREATE TABLE project.crypto ( |
| | 161 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 162 | symbol varchar(20) NOT NULL UNIQUE, |
| | 163 | name varchar(255) NOT NULL, |
| | 164 | created_at timestamptz NOT NULL DEFAULT now() |
| | 165 | ); |
| | 166 | |
| | 167 | -- ============================================================================ |
| | 168 | -- MARKETS |
| | 169 | -- A market is a (crypto, quote_currency) pair, e.g. BTC/USD. |
| | 170 | -- ============================================================================ |
| | 171 | CREATE TABLE project.markets ( |
| | 172 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 173 | crypto_id uuid NOT NULL REFERENCES project.crypto(id), |
| | 174 | quote_currency char(3) NOT NULL DEFAULT 'USD', |
| | 175 | is_active boolean NOT NULL DEFAULT true, |
| | 176 | created_at timestamptz NOT NULL DEFAULT now(), |
| | 177 | CONSTRAINT uq_markets UNIQUE (crypto_id, quote_currency) |
| | 178 | ); |
| | 179 | |
| | 180 | -- ============================================================================ |
| | 181 | -- HOLDINGS |
| | 182 | -- Per-user crypto position with running weighted average entry price. |
| | 183 | -- ============================================================================ |
| | 184 | CREATE TABLE project.holdings ( |
| | 185 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 186 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE, |
| | 187 | crypto_id uuid NOT NULL REFERENCES project.crypto(id), |
| | 188 | quantity numeric(20,4) NOT NULL CHECK (quantity >= 0), |
| | 189 | -- Committed to the user's own open sell orders, not yet removed from the |
| | 190 | -- position. quantity - reserved_quantity is what is actually free to |
| | 191 | -- sell — the crypto-side equivalent of users.available_balance. |
| | 192 | reserved_quantity numeric(20,4) NOT NULL DEFAULT 0 |
| | 193 | CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity), |
| | 194 | -- Weighted-average entry price. NOT NULL so that the P/L arithmetic in |
| | 195 | -- v_portfolio can never silently produce NULL for an existing position. |
| | 196 | avg_price numeric(18,6) NOT NULL DEFAULT 0 CHECK (avg_price >= 0), |
| | 197 | created_at timestamptz NOT NULL DEFAULT now(), |
| | 198 | updated_at timestamptz, |
| | 199 | CONSTRAINT uq_holdings_user_crypto UNIQUE (user_id, crypto_id) |
| | 200 | ); |
| | 201 | |
| | 202 | -- ============================================================================ |
| | 203 | -- ORDERS |
| | 204 | -- Orders placed by users on a market. |
| | 205 | -- ============================================================================ |
| | 206 | CREATE TABLE project.orders ( |
| | 207 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 208 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE, |
| | 209 | market_id uuid NOT NULL REFERENCES project.markets(id), |
| | 210 | side varchar(4) NOT NULL CHECK (side IN ('buy', 'sell')), |
| | 211 | type varchar(20) NOT NULL CHECK (type IN ('market', 'limit')), |
| | 212 | status varchar(20) NOT NULL CHECK (status IN ('open', 'executed', 'cancelled')), |
| | 213 | quantity numeric(20,4) NOT NULL CHECK (quantity > 0), |
| | 214 | price numeric(18,6), |
| | 215 | placed_at timestamptz NOT NULL DEFAULT now(), |
| | 216 | executed_at timestamptz |
| | 217 | ); |
| | 218 | |
| | 219 | CREATE INDEX idx_orders_user ON project.orders(user_id); |
| | 220 | CREATE INDEX idx_orders_market ON project.orders(market_id); |
| | 221 | CREATE INDEX idx_orders_status ON project.orders(status); |
| | 222 | |
| | 223 | -- ============================================================================ |
| | 224 | -- TRANSACTIONS |
| | 225 | -- Financial ledger: deposits, buys, sells, fees. |
| | 226 | -- ============================================================================ |
| | 227 | CREATE TABLE project.transactions ( |
| | 228 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 229 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE, |
| | 230 | type varchar(50) NOT NULL CHECK (type IN ('deposit', 'buy', 'sell', 'fee')), |
| | 231 | amount numeric(18,4) NOT NULL, |
| | 232 | currency char(3) NOT NULL DEFAULT 'USD', |
| | 233 | related_order uuid REFERENCES project.orders(id), |
| | 234 | created_at timestamptz NOT NULL DEFAULT now(), |
| | 235 | description text |
| | 236 | ); |
| | 237 | |
| | 238 | CREATE INDEX idx_transactions_user ON project.transactions(user_id, created_at DESC); |
| | 239 | |
| | 240 | -- ============================================================================ |
| | 241 | -- MARKET TRADES |
| | 242 | -- Raw executed trades on a market. Source of truth for current price. |
| | 243 | -- ============================================================================ |
| | 244 | CREATE TABLE project.market_trades ( |
| | 245 | id bigserial PRIMARY KEY, |
| | 246 | market_id uuid NOT NULL REFERENCES project.markets(id), |
| | 247 | executed_at timestamptz NOT NULL, |
| | 248 | price numeric(18,6) NOT NULL CHECK (price > 0), |
| | 249 | quantity numeric(20,6) NOT NULL CHECK (quantity > 0), |
| | 250 | side varchar(4) CHECK (side IN ('buy', 'sell')), |
| | 251 | source varchar(50) NOT NULL DEFAULT 'simulation' |
| | 252 | ); |
| | 253 | |
| | 254 | CREATE INDEX idx_market_trades_market_time ON project.market_trades(market_id, executed_at DESC); |
| | 255 | |
| | 256 | -- ============================================================================ |
| | 257 | -- MARKET CANDLES |
| | 258 | -- OHLCV aggregates over standard timeframes. |
| | 259 | -- ============================================================================ |
| | 260 | CREATE TABLE project.market_candles ( |
| | 261 | id bigserial PRIMARY KEY, |
| | 262 | market_id uuid NOT NULL REFERENCES project.markets(id), |
| | 263 | timeframe varchar(5) NOT NULL CHECK (timeframe IN ('1m', '5m', '1h', '1d')), |
| | 264 | open numeric(18,6) NOT NULL, |
| | 265 | high numeric(18,6) NOT NULL, |
| | 266 | low numeric(18,6) NOT NULL, |
| | 267 | close numeric(18,6) NOT NULL, |
| | 268 | volume numeric(20,6) NOT NULL, |
| | 269 | candle_time timestamptz NOT NULL, |
| | 270 | CONSTRAINT uq_candle UNIQUE (market_id, timeframe, candle_time) |
| | 271 | ); |
| | 272 | |
| | 273 | CREATE INDEX idx_market_candles_market_tf_time ON project.market_candles(market_id, timeframe, candle_time DESC); |
| | 274 | |
| | 275 | -- ============================================================================ |
| | 276 | -- WATCHLISTS |
| | 277 | -- ============================================================================ |
| | 278 | CREATE TABLE project.watchlists ( |
| | 279 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 280 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE, |
| | 281 | name varchar(100) NOT NULL, |
| | 282 | created_at timestamptz NOT NULL DEFAULT now() |
| | 283 | ); |
| | 284 | |
| | 285 | CREATE TABLE project.watchlist_items ( |
| | 286 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
| | 287 | watchlist_id uuid NOT NULL REFERENCES project.watchlists(id) ON DELETE CASCADE, |
| | 288 | crypto_id uuid NOT NULL REFERENCES project.crypto(id), |
| | 289 | added_at timestamptz NOT NULL DEFAULT now(), |
| | 290 | CONSTRAINT uq_watchlist_crypto UNIQUE (watchlist_id, crypto_id) |
| | 291 | ); |
| | 292 | |
| | 293 | -- ============================================================================ |
| | 294 | -- VIEWS |
| | 295 | -- ============================================================================ |
| | 296 | |
| | 297 | -- Latest trade price per market (current price). |
| | 298 | CREATE OR REPLACE VIEW project.v_latest_prices AS |
| | 299 | SELECT DISTINCT ON (t.market_id) |
| | 300 | t.market_id, |
| | 301 | c.symbol, |
| | 302 | m.quote_currency, |
| | 303 | t.price, |
| | 304 | t.executed_at |
| | 305 | FROM project.market_trades t |
| | 306 | JOIN project.markets m ON m.id = t.market_id |
| | 307 | JOIN project.crypto c ON c.id = m.crypto_id |
| | 308 | ORDER BY t.market_id, t.executed_at DESC; |
| | 309 | |
| | 310 | -- Portfolio valuation per user (holdings x latest price). |
| | 311 | CREATE OR REPLACE VIEW project.v_portfolio AS |
| | 312 | SELECT h.user_id, |
| | 313 | c.symbol, |
| | 314 | h.quantity, |
| | 315 | h.reserved_quantity, |
| | 316 | (h.quantity - h.reserved_quantity) AS available_quantity, |
| | 317 | h.avg_price, |
| | 318 | lp.price AS current_price, |
| | 319 | (h.quantity * lp.price) AS market_value, |
| | 320 | (h.quantity * (lp.price - h.avg_price)) AS unrealized_pnl |
| | 321 | FROM project.holdings h |
| | 322 | JOIN project.crypto c ON c.id = h.crypto_id |
| | 323 | LEFT JOIN project.markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD' |
| | 324 | LEFT JOIN project.v_latest_prices lp ON lp.market_id = m.id; |
| | 325 | }}} |
| | 326 | |
| | 327 | == DML script (sample data) == |
| | 328 | |
| | 329 | The script that loads realistic sample data is `../server/db/data_load.sql` (shown in full below). It is idempotent: it truncates all tables with `CASCADE` then re-inserts. Loaded: |
| | 330 | * 5 crypto assets (BTC, ETH, ADA, SOL, DOGE) and 5 USD-quoted markets. |
| | 331 | * 3 sample users (`alice`, `bob`, `charlie`) with password `test123` (sha256 hex). |
| | 332 | * 18 recent market trades across all markets so `v_latest_prices` is populated. |
| | 333 | * 10 one-hour candles (BTC and ETH). |
| | 334 | * One fully-executed market-buy order for Alice, the matching holding, and two ledger entries (deposit + buy), with Alice's balances updated accordingly. |
| | 335 | * Two watchlists with five watchlist items. |
| | 336 | |
| | 337 | === data_load.sql === |
| | 338 | |
| | 339 | {{{ |
| | 340 | -- data_load.sql |
| | 341 | -- EduBerza - sample data |
| | 342 | -- Course: Databases 2025/2026 Winter, FINKI UKIM |
| | 343 | -- |
| | 344 | -- Idempotent. Truncates all tables in the `project` schema and reloads |
| | 345 | -- deterministic sample data. Run schema_creation.sql first if tables do |
| | 346 | -- not yet exist. |
| | 347 | -- |
| | 348 | -- All sample users have the password: test123 |
| | 349 | |
| | 350 | SET search_path TO project, public; |
| | 351 | |
| | 352 | TRUNCATE TABLE |
| | 353 | project.watchlist_items, |
| | 354 | project.watchlists, |
| | 355 | project.market_candles, |
| | 356 | project.market_trades, |
| | 357 | project.transactions, |
| | 358 | project.orders, |
| | 359 | project.holdings, |
| | 360 | project.markets, |
| | 361 | project.crypto, |
| | 362 | project.users |
| | 363 | RESTART IDENTITY CASCADE; |
| | 364 | |
| | 365 | -- ============================================================================ |
| | 366 | -- CRYPTO |
| | 367 | -- ============================================================================ |
| | 368 | INSERT INTO project.crypto (id, symbol, name) VALUES |
| | 369 | ('11111111-1111-1111-1111-111111111111', 'BTC', 'Bitcoin'), |
| | 370 | ('22222222-2222-2222-2222-222222222222', 'ETH', 'Ethereum'), |
| | 371 | ('33333333-3333-3333-3333-333333333333', 'ADA', 'Cardano'), |
| | 372 | ('44444444-4444-4444-4444-444444444444', 'SOL', 'Solana'), |
| | 373 | ('55555555-5555-5555-5555-555555555555', 'DOGE', 'Dogecoin'); |
| | 374 | |
| | 375 | -- ============================================================================ |
| | 376 | -- MARKETS (all quoted in USD) |
| | 377 | -- ============================================================================ |
| | 378 | INSERT INTO project.markets (id, crypto_id, quote_currency, is_active) VALUES |
| | 379 | ('a1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'USD', true), |
| | 380 | ('a2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'USD', true), |
| | 381 | ('a3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'USD', true), |
| | 382 | ('a4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', 'USD', true), |
| | 383 | ('a5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'USD', true); |
| | 384 | |
| | 385 | -- ============================================================================ |
| | 386 | -- USERS |
| | 387 | -- Password for all: test123 (stored as sha256 hex hash) |
| | 388 | -- ============================================================================ |
| | 389 | INSERT INTO project.users (id, username, email, full_name, password_hash, available_balance, invested_balance) VALUES |
| | 390 | ('b1111111-1111-1111-1111-111111111111', 'alice', 'alice@example.com', 'Alice Johnson', |
| | 391 | encode(digest('test123', 'sha256'), 'hex'), 10000.0000, 0), |
| | 392 | ('b2222222-2222-2222-2222-222222222222', 'bob', 'bob@example.com', 'Bob Smith', |
| | 393 | encode(digest('test123', 'sha256'), 'hex'), 5000.0000, 0), |
| | 394 | ('b3333333-3333-3333-3333-333333333333', 'charlie', 'charlie@example.com', 'Charlie Davis', |
| | 395 | encode(digest('test123', 'sha256'), 'hex'), 2500.0000, 0); |
| | 396 | |
| | 397 | -- ============================================================================ |
| | 398 | -- MARKET TRADES |
| | 399 | -- Recent simulated trades per market, used as price source. |
| | 400 | -- ============================================================================ |
| | 401 | INSERT INTO project.market_trades (market_id, executed_at, price, quantity, side, source) VALUES |
| | 402 | -- BTC/USD around $67,000 |
| | 403 | ('a1111111-1111-1111-1111-111111111111', now() - interval '10 min', 66850.250000, 0.120000, 'buy', 'simulation'), |
| | 404 | ('a1111111-1111-1111-1111-111111111111', now() - interval '8 min', 66910.500000, 0.075000, 'sell', 'simulation'), |
| | 405 | ('a1111111-1111-1111-1111-111111111111', now() - interval '5 min', 67020.750000, 0.200000, 'buy', 'simulation'), |
| | 406 | ('a1111111-1111-1111-1111-111111111111', now() - interval '2 min', 67105.100000, 0.050000, 'buy', 'simulation'), |
| | 407 | ('a1111111-1111-1111-1111-111111111111', now() - interval '30 second', 67140.000000, 0.030000, 'sell', 'simulation'), |
| | 408 | -- ETH/USD around $3,500 |
| | 409 | ('a2222222-2222-2222-2222-222222222222', now() - interval '10 min', 3490.500000, 1.500000, 'buy', 'simulation'), |
| | 410 | ('a2222222-2222-2222-2222-222222222222', now() - interval '6 min', 3502.750000, 0.800000, 'sell', 'simulation'), |
| | 411 | ('a2222222-2222-2222-2222-222222222222', now() - interval '2 min', 3515.250000, 2.100000, 'buy', 'simulation'), |
| | 412 | ('a2222222-2222-2222-2222-222222222222', now() - interval '30 second', 3520.000000, 0.650000, 'buy', 'simulation'), |
| | 413 | -- ADA/USD around $0.45 |
| | 414 | ('a3333333-3333-3333-3333-333333333333', now() - interval '10 min', 0.446500, 500.000000, 'buy', 'simulation'), |
| | 415 | ('a3333333-3333-3333-3333-333333333333', now() - interval '3 min', 0.452000, 1200.000000, 'buy', 'simulation'), |
| | 416 | ('a3333333-3333-3333-3333-333333333333', now() - interval '30 second', 0.453750, 800.000000, 'sell', 'simulation'), |
| | 417 | -- SOL/USD around $165 |
| | 418 | ('a4444444-4444-4444-4444-444444444444', now() - interval '10 min', 164.250000, 10.000000, 'buy', 'simulation'), |
| | 419 | ('a4444444-4444-4444-4444-444444444444', now() - interval '4 min', 165.500000, 5.500000, 'sell', 'simulation'), |
| | 420 | ('a4444444-4444-4444-4444-444444444444', now() - interval '30 second', 166.100000, 8.000000, 'buy', 'simulation'), |
| | 421 | -- DOGE/USD around $0.12 |
| | 422 | ('a5555555-5555-5555-5555-555555555555', now() - interval '10 min', 0.118500, 10000.000000, 'buy', 'simulation'), |
| | 423 | ('a5555555-5555-5555-5555-555555555555', now() - interval '3 min', 0.121250, 7500.000000, 'sell', 'simulation'), |
| | 424 | ('a5555555-5555-5555-5555-555555555555', now() - interval '30 second', 0.122000, 12000.000000, 'buy', 'simulation'); |
| | 425 | |
| | 426 | -- ============================================================================ |
| | 427 | -- MARKET CANDLES (1h aggregates, last 5 hours per market) |
| | 428 | -- ============================================================================ |
| | 429 | INSERT INTO project.market_candles (market_id, timeframe, open, high, low, close, volume, candle_time) VALUES |
| | 430 | ('a1111111-1111-1111-1111-111111111111', '1h', 66200, 66500, 66050, 66400, 12.50, date_trunc('hour', now() - interval '5 hour')), |
| | 431 | ('a1111111-1111-1111-1111-111111111111', '1h', 66400, 66800, 66380, 66700, 15.30, date_trunc('hour', now() - interval '4 hour')), |
| | 432 | ('a1111111-1111-1111-1111-111111111111', '1h', 66700, 66950, 66650, 66900, 11.80, date_trunc('hour', now() - interval '3 hour')), |
| | 433 | ('a1111111-1111-1111-1111-111111111111', '1h', 66900, 67100, 66800, 67050, 14.20, date_trunc('hour', now() - interval '2 hour')), |
| | 434 | ('a1111111-1111-1111-1111-111111111111', '1h', 67050, 67200, 66900, 67140, 10.75, date_trunc('hour', now() - interval '1 hour')), |
| | 435 | ('a2222222-2222-2222-2222-222222222222', '1h', 3460, 3490, 3450, 3485, 120.0, date_trunc('hour', now() - interval '5 hour')), |
| | 436 | ('a2222222-2222-2222-2222-222222222222', '1h', 3485, 3510, 3480, 3500, 135.0, date_trunc('hour', now() - interval '4 hour')), |
| | 437 | ('a2222222-2222-2222-2222-222222222222', '1h', 3500, 3520, 3495, 3515, 110.0, date_trunc('hour', now() - interval '3 hour')), |
| | 438 | ('a2222222-2222-2222-2222-222222222222', '1h', 3515, 3525, 3500, 3520, 125.5, date_trunc('hour', now() - interval '2 hour')), |
| | 439 | ('a2222222-2222-2222-2222-222222222222', '1h', 3520, 3530, 3510, 3520, 140.0, date_trunc('hour', now() - interval '1 hour')); |
| | 440 | |
| | 441 | -- ============================================================================ |
| | 442 | -- EXAMPLE ORDERS, HOLDINGS AND TRANSACTIONS for alice |
| | 443 | -- Shows a fully-filled market buy and its resulting holding & ledger entry. |
| | 444 | -- ============================================================================ |
| | 445 | INSERT INTO project.orders (id, user_id, market_id, side, type, status, quantity, price, placed_at, executed_at) VALUES |
| | 446 | ('c1111111-1111-1111-1111-111111111111', |
| | 447 | 'b1111111-1111-1111-1111-111111111111', |
| | 448 | 'a2222222-2222-2222-2222-222222222222', |
| | 449 | 'buy', 'market', 'executed', 0.5000, 3500.000000, |
| | 450 | now() - interval '1 hour', now() - interval '1 hour'); |
| | 451 | |
| | 452 | INSERT INTO project.holdings (user_id, crypto_id, quantity, avg_price, updated_at) VALUES |
| | 453 | ('b1111111-1111-1111-1111-111111111111', |
| | 454 | '22222222-2222-2222-2222-222222222222', |
| | 455 | 0.5000, 3500.000000, now() - interval '1 hour'); |
| | 456 | |
| | 457 | INSERT INTO project.transactions (user_id, type, amount, currency, related_order, description) VALUES |
| | 458 | ('b1111111-1111-1111-1111-111111111111', 'deposit', 10000.0000, 'USD', NULL, |
| | 459 | 'Initial virtual deposit'), |
| | 460 | ('b1111111-1111-1111-1111-111111111111', 'buy', -1750.0000, 'USD', |
| | 461 | 'c1111111-1111-1111-1111-111111111111', |
| | 462 | 'Market buy 0.5 ETH @ 3500.00'); |
| | 463 | |
| | 464 | -- After the buy, alice's invested_balance reflects the used funds. |
| | 465 | UPDATE project.users |
| | 466 | SET available_balance = 10000.0000 - 1750.0000, |
| | 467 | invested_balance = 1750.0000, |
| | 468 | updated_at = now() |
| | 469 | WHERE id = 'b1111111-1111-1111-1111-111111111111'; |
| | 470 | |
| | 471 | -- ============================================================================ |
| | 472 | -- WATCHLISTS |
| | 473 | -- ============================================================================ |
| | 474 | INSERT INTO project.watchlists (id, user_id, name) VALUES |
| | 475 | ('d1111111-1111-1111-1111-111111111111', 'b1111111-1111-1111-1111-111111111111', 'Favorites'), |
| | 476 | ('d2222222-2222-2222-2222-222222222222', 'b2222222-2222-2222-2222-222222222222', 'Bobs Picks'); |
| | 477 | |
| | 478 | INSERT INTO project.watchlist_items (watchlist_id, crypto_id) VALUES |
| | 479 | ('d1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111'), |
| | 480 | ('d1111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'), |
| | 481 | ('d1111111-1111-1111-1111-111111111111', '44444444-4444-4444-4444-444444444444'), |
| | 482 | ('d2222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111'), |
| | 483 | ('d2222222-2222-2222-2222-222222222222', '55555555-5555-5555-5555-555555555555'); |
| | 484 | }}} |
| | 485 | |
| | 486 | == Relational diagram == |
| | 487 | |
| | 488 | [[Image(relational_schema.jpg)]] |
| | 489 | |
| | 490 | Generated in '''Pgadmin''' from the '''live''' `project` schema, in crow's-foot |