| 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. |
| | 17 | * Entity set `Cryptos`. Candidate keys: `{id}`, `{symbol}`. `UNIQUE(symbol)`. |
| | 18 | * '''Markets'''(__'''id'''__, ''crypto_id'' [`QuotedOn`], quote_currency, is_active, created_at) |
| | 19 | * Entity set `Markets`. Candidate keys: `{id}`, `{crypto_id, quote_currency}` (the model's rule "a crypto is quoted at most once per currency"), enforced with `UNIQUE(crypto_id, quote_currency)`. |
| | 20 | * '''Holdings'''(__'''id'''__, ''user_id'' [`Holds`], ''crypto_id'' [`PositionIn`], quantity, reserved_quantity, avg_price, created_at, updated_at) |
| | 21 | * Entity set `Holdings`. Candidate keys: `{id}` and `{user_id, crypto_id}` (the model's rule "one holding per user and crypto"), enforced with `UNIQUE(user_id, crypto_id)`. |
| 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)`. |
| | 23 | * `reserved_quantity` is `NOT NULL DEFAULT 0 CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` — the amount already committed to the user's own open sell orders. `quantity - reserved_quantity` (the amount actually free to sell) is not a stored column; it is computed wherever needed, in `v_portfolio` as `available_quantity` and in the sell path of [wiki:UseCase0005]. See [wiki:ERModel] (section "Holdings") for why this mirrors `available_balance`/`invested_balance` on `Users`. |
| | 24 | * '''Orders'''(__'''id'''__, ''user_id'' [`Places`], ''market_id'' [`PlacedOn`], side, type, status, quantity, filled_quantity, price, placed_at, executed_at) |
| | 25 | * Entity set `Orders`. `side ∈ {buy, sell}`, `type ∈ {market, limit}`, `status ∈ {open, partially_filled, executed, cancelled}`, `0 ≤ filled_quantity ≤ quantity`. |
| | 26 | * '''Transactions'''(__'''id'''__, ''user_id'' [`Records`], type, amount, currency, ''related_order'' [`Settles`], created_at, description) |
| | 27 | * Entity set `Transactions`. `type ∈ {deposit, buy, sell, fee}`. `related_order` is nullable (see below). |
| | 28 | * '''!MarketTrades'''(__'''id'''__, ''market_id'' [`Fills`], executed_at, price, quantity, side, source, ''buy_order_id'' [`FillsBuy`], ''sell_order_id'' [`FillsSell`]) |
| | 29 | * Entity set `MarketTrades`. `buy_order_id` and `sell_order_id` are both nullable (see below). |
| | 30 | * '''!OrderEvents'''(__'''id'''__, ''order_id'' [`Logs`], event_type, quantity, price, status_after, created_at) |
| | 31 | * Entity set `OrderEvents`. `event_type ∈ {placed, partially_filled, filled, cancelled}`. |
| | 32 | * '''!MarketCandles'''(__'''id'''__, ''market_id'' [`Aggregates`], timeframe, open, high, low, close, volume, candle_time) |
| | 33 | * Entity set `MarketCandles`. Candidate keys: `{id}`, `{market_id, timeframe, candle_time}` (the model's rule "one candle per market, timeframe and bucket"), enforced with `UNIQUE(market_id, timeframe, candle_time)`. |
| | 34 | * '''Watchlists'''(__'''id'''__, ''user_id'' [`Owns`], name, created_at) |
| | 35 | * Entity set `Watchlists`. |
| | 36 | * '''!WatchlistItems'''(__'''id'''__, ''watchlist_id'' [`Contains`], ''crypto_id'' [`Lists`], added_at) |
| | 37 | * Entity set `WatchlistItems`. Candidate keys: `{id}` and `{watchlist_id, crypto_id}` (the model's rule "an asset at most once per list"), enforced with `UNIQUE(watchlist_id, crypto_id)`. |
| 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. |
| | 41 | '''Partial transformation.''' The model has 11 entity sets and 15 relationships. |
| | 42 | Every relationship is binary and 1:N with no attributes of its own (the two M:N |
| | 43 | relationships of earlier versions, `Holds` and `Contains`, were corrected into |
| | 44 | the entity sets `Holdings` and `WatchlistItems` in v05). The rules: |
| | 45 | |
| | 46 | * '''Each entity set becomes one relation''', with its own attributes and its own key `id` as primary key. 11 entity sets → 11 relations. |
| | 47 | * '''Each 1:N relationship becomes one foreign key''' on the relation of the "N" side, pointing to the primary key of the "1" side. No relationship gets its own table, because none is M:N and none has attributes. 15 relationships → 15 foreign keys: |
| | 48 | |
| | 49 | ||= ER relationship =||= 1 side → N side =||= Foreign key =||= Participation of the N side =||= `NULL`? =|| |
| | 50 | || `QuotedOn` || Cryptos → Markets || `markets.crypto_id` || total || `NOT NULL` || |
| | 51 | || `PlacedOn` || Markets → Orders || `orders.market_id` || total || `NOT NULL` || |
| | 52 | || `Places` || Users → Orders || `orders.user_id` || total || `NOT NULL` || |
| | 53 | || `Records` || Users → Transactions || `transactions.user_id` || total || `NOT NULL` || |
| | 54 | || `Settles` || Orders → Transactions || `transactions.related_order` || partial || nullable || |
| | 55 | || `Fills` || Markets → !MarketTrades || `market_trades.market_id` || total || `NOT NULL` || |
| | 56 | || `FillsBuy` || Orders → !MarketTrades || `market_trades.buy_order_id` || partial || nullable || |
| | 57 | || `FillsSell` || Orders → !MarketTrades || `market_trades.sell_order_id` || partial || nullable || |
| | 58 | || `Logs` || Orders → !OrderEvents || `order_events.order_id` || total || `NOT NULL` || |
| | 59 | || `Aggregates` || Markets → !MarketCandles || `market_candles.market_id` || total || `NOT NULL` || |
| | 60 | || `Owns` || Users → Watchlists || `watchlists.user_id` || total || `NOT NULL` || |
| | 61 | || `Holds` || Users → Holdings || `holdings.user_id` || total || `NOT NULL` || |
| | 62 | || `PositionIn` || Cryptos → Holdings || `holdings.crypto_id` || total || `NOT NULL` || |
| | 63 | || `Contains` || Watchlists → !WatchlistItems || `watchlist_items.watchlist_id` || total || `NOT NULL` || |
| | 64 | || `Lists` || Cryptos → !WatchlistItems || `watchlist_items.crypto_id` || total || `NOT NULL` || |
| | 65 | |
| | 66 | * '''Participation decides `NULL`.''' Total participation of the N side means every row must reference a parent, so the foreign key is `NOT NULL`. Partial participation leaves it nullable. There are exactly three partial ones: `Settles` (a deposit has no originating order), and `FillsBuy` / `FillsSell` (a trade against the simulated market has no user order on that side). Partial participation of the '''1''' side (for example, a user with no orders) needs no column at all. It simply means no row points at that parent. |
| | 67 | * '''Uniqueness rules of the model become `UNIQUE` constraints.''' The four rules the model states in words ("a crypto quoted once per currency", "one candle per market, timeframe and bucket", "one holding per user and crypto", "an asset once per list") involve a relationship, so Chen notation cannot draw them as keys. After transformation, the relationship is a foreign-key column, and each rule becomes an ordinary composite `UNIQUE` constraint, i.e. a second candidate key. |
| | 68 | |
| | 69 | Nothing in the schema comes from anywhere else. Every column is either an ER |
| | 70 | attribute or the foreign key of one listed relationship. |
| | 71 | |
| | 72 | === Normalisation === |
| | 73 | |
| | 74 | > '''Checked in P5.''' [wiki:Normalization] |
| | 75 | > starts from a single de-normalized relation containing only the attributes |
| | 76 | > of the ER model and the functional dependencies that follow from its rules. |
| | 77 | > It decomposes that relation step by step to BCNF and arrives at these same 11 |
| | 78 | > relations, with one deliberate difference: `transactions.user_id` (see the last |
| | 79 | > bullet below). The comparison is in the ''Discussion'' section at the end of that |
| | 80 | > page. |
| | 81 | |
| | 82 | All relations except `transactions` are in '''BCNF''', as P5 shows. `transactions` |
| | 83 | is in 2NF but not in 3NF, because of the deliberately kept `user_id` (last |
| | 84 | bullet): |
| | 85 | |
| | 86 | * Every attribute is atomic (no repeating groups, no composite fields). |
| | 87 | * No partial dependency exists: every candidate key is either the single column `id` or a composite key (`{user_id, crypto_id}`, …) on which no non-key attribute depends only partially. |
| | 88 | * No transitive dependency exists, except `transactions.user_id` (last bullet): every other non-key attribute depends directly on the row's own entity, never on another entity reached through a foreign key. For example, `holdings.quantity` depends on `holdings.id`, and nothing about the user or the crypto is copied into `holdings`. |
| | 89 | * `avg_price` in `Holdings` is a '''derived value''' cached for performance (it is the weighted-average entry price across all `buy` transactions for that `(user, crypto)` pair) — it is drawn as a derived attribute in the ER diagram. We accept the denormalisation: it is recomputed by the database inside the same transaction as each buy, in the same statement that changes the quantity (`INSERT … ON CONFLICT (user_id, crypto_id) DO UPDATE`), so the stored average and the stored quantity can never disagree. |
| | 90 | * `avg_price` is declared `NOT NULL DEFAULT 0`. This matters: it is used in the P/L arithmetic of `v_portfolio`, and in SQL any arithmetic involving `NULL` yields `NULL`, so a nullable average would have silently blanked the unrealised-P/L column for an existing position instead of failing loudly. |
| | 91 | * `holdings.reserved_quantity`, unlike `avg_price`, is '''not''' derived — it is written directly by the application (`trade.go`) as orders are placed and settled, the same way `quantity` itself is. `quantity - reserved_quantity` ("available") is the derived value here, and it is never stored, only computed where it is needed. |
| | 92 | * `transactions.user_id` is kept '''deliberately''', although for an entry that settles an order it repeats that order's user (`related_order → user_id`, a transitive dependency). A deposit has no order (`Settles` is partial), so `user_id` is the only way to record whose deposit it is. For entries with an order, the only code that sets `related_order` (the buy and sell inserts in `advanced_db.sql`) writes both from the same order row. No database constraint enforces this. |
| 454 | | [[Image(relational_schema_v3.png)]] |
| 455 | | |
| 456 | | Generated in '''Pgadmin''' from the '''live''' `project` schema, in crow's-foot |
| 457 | | notation — not drawn by hand, so it is evidence that the deployed database |
| 458 | | actually matches the design described above. Each box is a table with its |
| 459 | | columns and declared types; key icons mark primary keys and the arrowed lines |
| 460 | | are the 12 declared foreign keys. |
| | 539 | [[Image(relational_diagram_v4.png, 800px)]] |
| | 540 | |
| | 541 | Generated in '''DBeaver''' from the '''live''' `project` schema (after |
| | 542 | `./eduberza -init`), not drawn by hand, so it shows what the deployed database |
| | 543 | actually contains. Each box is a table with its columns; the key icon marks the |
| | 544 | primary key, and the lines are the 15 declared foreign keys. The two foreign keys from |
| | 545 | `market_trades` to `orders` (`buy_order_id`, `sell_order_id`) connect the same |
| | 546 | two boxes, so DBeaver draws them on top of each other as one line. |
| | 547 | |
| | 548 | The tables are arranged in the '''same positions''' as the entity sets in |
| | 549 | `ERModel_v05.png`, so the two can be compared directly: |
| | 550 | |
| | 551 | * every rectangle of the ER diagram is one table in the same place; |
| | 552 | * every diamond of the ER diagram is one foreign-key line between the same two boxes. The dot is on the referencing ("N") table, next to the foreign-key column; |
| | 553 | * a double (total) line in the ER diagram is a `NOT NULL` foreign key, drawn by DBeaver as a solid line. The three single lines on the N side (`Settles`, `FillsBuy`, `FillsSell`) are the three nullable foreign keys, which DBeaver draws dashed, with a hollow diamond on the `orders` side. The table under Transformation method used lists all 15. |
| | 554 | |
| | 555 | Earlier images (`relational_schema.jpg`, `relational_schema_v2.png`, |
| | 556 | `relational_schema_v3.png`) were exported from pgAdmin, with a different layout |
| | 557 | and from an older schema. They are kept only as history. |
| 464 | | '''With pgAdmin 4''', if DBeaver is unavailable — it reads the live schema the same |
| 465 | | way, so the result is equivalent in substance: |
| 466 | | |
| 467 | | 1. Connect to the project database. |
| 468 | | 2. Right-click the database → '''ERD For Database''' (or open a blank ERD and drag |
| 469 | | the `project` tables in). |
| 470 | | 3. Arrange the tables to mirror `ERModel_v03.png`. |
| 471 | | 4. '''Download image''' → PNG, then convert: |
| 472 | | `convert relational_schema.png relational_schema.jpg` |
| | 561 | 1. Initialise the database: `./eduberza -init` (runs `schema_creation.sql` and `advanced_db.sql`, so `order_events` is included). |
| | 562 | 2. In DBeaver, connect to the project database and expand ''Schemas → project → Tables''. |
| | 563 | 3. Select all 11 tables → right-click → '''View Diagram''' (or create a new ER diagram and drag the tables in). |
| | 564 | 4. Drag each table to the position of its entity set in `ERModel_v05.png`: |
| | 565 | |
| | 566 | {{{ |
| | 567 | column 1 column 2 column 3 column 4 |
| | 568 | row 1 watchlist_items crypto markets market_candles |
| | 569 | row 2 watchlists holdings . market_trades |
| | 570 | row 3 users . orders . |
| | 571 | row 4 . transactions order_events . |
| | 572 | }}} |
| | 573 | |
| | 574 | Leave the empty cells (`.`) empty. They are where the relationship |
| | 575 | diamonds are in the ER diagram, so the foreign-key lines will run through |
| | 576 | the same gaps. |
| | 577 | |
| | 578 | 5. Right-click the canvas → '''Export diagram''' → PNG, saved as `relational_diagram_v4.png` in this folder. |