Changeset 1549dae


Ignore:
Timestamp:
09/29/26 20:55:13 (5 hours ago)
Author:
Stefan <trsunovstefan@…>
Branches:
main
Parents:
0cee8ec
Message:

Correct P1/P2 consistency (Holdings, WatchlistItems), redo P5 normalization

Location:
docs
Files:
3 added
16 edited

Legend:

Unmodified
Added
Removed
  • docs/P1-ConceptualModel/ERModel.md

    r0cee8ec r1549dae  
    1 # Entity-Relationship Model v.04
     1# Entity-Relationship Model v.05
    22
    33## Diagram
    44
    5 ![ERModel_v04](ERModel_v04.png)
     5![ERModel_v05](ERModel_v05.png)
    66
    77Notation: Chen. Rectangles are entity sets, diamonds are relationships, ellipses
    … …  
    1111single line marks partial participation.
    1212
    13 Two deliberate modeling decisions worth stating up front:
     13Three deliberate modeling decisions worth stating up front:
    1414
    1515- **No foreign keys appear in the diagram.** Connections between entity sets are
    1616  expressed as relationships, per the notation. Foreign-key columns appear only
    1717  in the relational model in [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md).
    18 - **`Holds` and `Contains` are relationships, not entity sets.** Both are M:N and
    19   both carry their own attributes, which is exactly what a Chen relationship is
    20   for. They become tables (`holdings`, `watchlist_items`) only in P2.
     18- **A position and a watchlist entry are entity sets, not M:N relationships.**
     19  `Holdings` (a user's position in an asset) and `WatchlistItems` (an asset on a
     20  watchlist) each have their own identifier `id`, and each is connected by two
     21  1:N relationships: `Holds` and `PositionIn` for a holding, `Contains` and
     22  `Lists` for a watchlist item. Until v04 they were drawn as the M:N
     23  relationships `Holds` and `Contains`, but the database has always given
     24  `holdings` and `watchlist_items` their own `id` primary key. That is how an
     25  entity set is implemented, not an M:N relationship, whose key would be the
     26  pair of participating keys. v05 corrects the model to match; see
     27  [history](#entity-relationship-model-history).
     28- **Key and uniqueness rules are stated with entity and relationship names,
     29  never with foreign-key columns.** For example: "a crypto is quoted at most
     30  once per currency", not "`{crypto_id, quote_currency}` is unique".
    2131
    2232## Data requirements
    … …  
    4656| `id` | UUID | PK, required |
    4757| `username` | text(50) | required, unique |
    48 | `email` | text(255) | required, unique, contains `@` |
     58| `email` | text(255) | required, unique, contains `@` (checked by the application at registration, not by a database constraint) |
    4959| `full_name` | text(200) | optional |
    5060| `password_hash` | text(255) | required — never the password itself; the prototype stores a SHA-256 hex digest |
    … …  
    7989trades, candles and orders all reference the pair, not the asset.
    8090
    81 **Keys:** candidates `{id}`, `{crypto_id, quote_currency}` — that pair is
    82 unique by definition, since a given asset can only be quoted once per
    83 currency; primary key **`id`**, so that the many entity sets referencing a
    84 market carry one narrow column instead of a composite key.
     91**Keys:** candidate `{id}`; primary key **`id`**, so that the many entity sets
     92related to a market need one narrow identifier instead of a composite one.
     93**Uniqueness rule:** a crypto is quoted at most once per currency, so the crypto
     94a market is `QuotedOn` together with its `quote_currency` identifies the market
     95as well. Chen notation cannot draw this, because half of it comes through a
     96relationship. P2 enforces it as `UNIQUE(crypto_id, quote_currency)`.
    8597
    8698| Attribute | Type | Constraints |
    … …  
    98110
    99111Placing an order is what triggers a **reservation** of whatever it commits:
    100 the crypto being sold (`Holds.reserved_quantity`, below) on a sell, and the
     112the crypto being sold (`Holdings.reserved_quantity`, below) on a sell, and the
    101113cash (`Users.reserved_balance`) on a buy. Since v04 (after P7) an order can
    102114wait in the order book and be filled in parts, so `status` is a real
    … …  
    121133| `quantity` | numeric(20,4) | required, > 0 |
    122134| `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 |
     135| `price` | numeric(18,6) | optional — the limit price; for a market order, the market price when it was placed |
    124136| `placed_at` | timestamptz | required, defaults to now |
    125137| `executed_at` | timestamptz | optional, set when the order settles |
    … …  
    148160writes directly.
    149161
    150 **Keys:** candidate `{id}` — `{market_id, executed_at}` looks unique in
    151 principle, but two trades can share a timestamp, so it is not a safe key;
     162**Keys:** candidate `{id}` — "market plus `executed_at`" looks unique in
     163principle, but two trades on a market can share a timestamp, so it is not a safe key;
    152164primary key **`id`** (a plain auto-incrementing integer here rather than a
    153165UUID, because this is the highest-volume entity set and it is only ever read
    … …  
    156168| Attribute | Type | Constraints |
    157169|---|---|---|
    158 | `id` | integer | PK, required, auto-generated |
     170| `id` | big integer | PK, required, auto-generated (`bigserial` in P2) |
    159171| `executed_at` | timestamptz | required |
    160172| `price` | numeric(18,6) | required, > 0 |
    … …  
    177189| Attribute | Type | Constraints |
    178190|---|---|---|
    179 | `id` | integer | PK, required, auto-generated |
     191| `id` | big integer | PK, required, auto-generated (`bigserial` in P2) |
    180192| `event_type` | text | required, `placed`, `partially_filled`, `filled` or `cancelled` |
    181193| `quantity` | numeric(20,4) | required — the ordered quantity for `placed`, the filled amount for a fill, the unfilled rest for `cancelled` |
    182194| `price` | numeric(18,6) | optional — the order price, or the trade price for a fill |
    183195| `status_after` | text | required, the order's status after the event |
    184 | `created_at` | timestamptz | required, defaults to now |
     196| `created_at` | timestamptz | required, set automatically when the event is recorded (`clock_timestamp()`, so events inside one transaction keep their real order) |
    185197
    186198#### MarketCandles
    … …  
    190202screen refresh does not scale.
    191203
    192 **Keys:** candidates `{id}`, `{market_id, timeframe, candle_time}` — a market
    193 has exactly one candle per timeframe per time bucket; primary key **`id`**, the
    194 composite is enforced as a uniqueness rule because it is the real-world
    195 constraint and it is what prevents duplicate candles.
    196 
    197 | Attribute | Type | Constraints |
    198 |---|---|---|
    199 | `id` | integer | PK, required, auto-generated |
     204**Keys:** candidate `{id}`; primary key **`id`**. **Uniqueness rule:** a market
     205has exactly one candle per timeframe per time bucket, so the market a candle
     206`Aggregates` together with `timeframe` and `candle_time` also identifies it.
     207This is the real-world constraint that prevents duplicate candles. P2 enforces
     208it as `UNIQUE(market_id, timeframe, candle_time)`.
     209
     210| Attribute | Type | Constraints |
     211|---|---|---|
     212| `id` | big integer | PK, required, auto-generated (`bigserial` in P2) |
    200213| `timeframe` | text | required, `1m`, `5m`, `1h` or `1d` |
    201214| `open`, `high`, `low`, `close` | numeric(18,6) | all required |
    … …  
    208221several lists ("long term", "watching today") and each needs its own name.
    209222
    210 **Keys:** candidate `{id}` — `{user_id, name}` would also work if list names
    211 were required to be unique per user, which the model does not impose, so it is
    212 not listed as a candidate key; primary key **`id`**.
     223**Keys:** candidate `{id}`; primary key **`id`**. "Owner plus `name`" would
     224also identify a list if names had to be unique per user, but the model does
     225not require that, so there is no uniqueness rule here.
    213226
    214227| Attribute | Type | Constraints |
    … …  
    218231| `created_at` | timestamptz | required, defaults to now |
    219232
    220 ### Relationships
    221 
    222 #### QuotedOn — Cryptos (1) : Markets (N), total on Markets
    223 Ties a market to the asset it trades. One asset can be quoted in many markets;
    224 every market must have exactly one asset, hence total participation on the
    225 `Markets` side. No attributes.
    226 
    227 #### PlacedOn — Markets (1) : Orders (N), total on Orders
    228 Records which market an order was placed on. Every order must name a market;
    229 a market may have no orders yet. No attributes.
    230 
    231 #### Places — Users (1) : Orders (N), total on Orders
    232 Records who placed an order. Every order belongs to exactly one user; a new
    233 user has no orders. No attributes.
    234 
    235 #### Records — Users (1) : Transactions (N), total on Transactions
    236 Attributes each ledger entry to a user. Every entry belongs to exactly one
    237 user. No attributes.
    238 
    239 #### Settles — Orders (1) : Transactions (N), partial on both sides
    240 Links a ledger entry to the order that caused it. Partial on the
    241 `Transactions` side because deposits have no originating order, and partial on
    242 the `Orders` side because an order that never executes never produces a
    243 ledger entry — which is why the corresponding column is nullable in P2. No
    244 attributes.
    245 
    246 #### Fills — Markets (1) : MarketTrades (N), total on MarketTrades
    247 Every 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
    251 filled by many trades (partial fills); a trade fills at most one buy order,
    252 and 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
    257 attributes.
    258 
    259 #### Logs — Orders (1) : OrderEvents (N), total on OrderEvents
    260 *Added in v04, after P7.* Every event belongs to exactly one order. No
    261 attributes.
    262 
    263 #### Aggregates — Markets (1) : MarketCandles (N), total on MarketCandles
    264 Every candle summarises trades of exactly one market. No attributes.
    265 
    266 #### Owns — Users (1) : Watchlists (N), total on Watchlists
    267 Every watchlist belongs to exactly one user. No attributes.
    268 
    269 #### Holds — Users (M) : Cryptos (N), partial on both sides, **with attributes**
    270 A user's position in an asset. M:N because one user holds many assets and one
    271 asset is held by many users, and partial on both sides because a user may hold
    272 nothing and an asset may be held by nobody. Modeled as a relationship rather
    273 than an entity set because a position has no identity of its own — it is
    274 entirely described by *which user*, *which asset*, and how much.
     233#### Holdings
     234A user's position in one crypto asset: how much of it the user owns, how much
     235of that is already promised to open sell orders, and at what average price it
     236was accumulated. *An entity set since v05* (until v04 it was the M:N
     237relationship `Holds`). A holding has its own identifier and its own
     238lifecycle: it is created on the first buy, updated on every later fill, and
     239the prototype reads and locks it as a unit (`SELECT … FOR UPDATE` on the sell
     240path). It is linked to its owner through `Holds` and to its asset through
     241`PositionIn`.
     242
     243**Keys:** candidate `{id}`; primary key **`id`**. **Uniqueness rule:** a user
     244has at most one holding per crypto, so the user who `Holds` it together with
     245the crypto it is a `PositionIn` also identifies a holding. P2 enforces this as
     246`UNIQUE(user_id, crypto_id)`.
    275247
    276248`reserved_quantity` mirrors `available_balance`/`invested_balance` on `Users`:
    … …  
    284256| Attribute | Type | Constraints |
    285257|---|---|---|
     258| `id` | UUID | PK, required |
    286259| `quantity` | numeric(20,4) | required, ≥ 0 — total amount owned |
    287260| `reserved_quantity` | numeric(20,4) | required, default 0, `0 ≤ reserved_quantity ≤ quantity` — committed to the user's own open sell orders, not yet removed from the position |
    288 | `avg_price` | numeric(18,6) | required, ≥ 0, **derived** (dashed ellipse) — the weighted average of the prices at which the position was accumulated; derivable from the buy history, stored anyway so unrealised P/L can be shown without replaying the whole ledger |
     261| `avg_price` | numeric(18,6) | required, default 0, ≥ 0, **derived** (dashed ellipse) — the weighted average of the prices at which the position was accumulated; derivable from the buy history, stored anyway so unrealised P/L can be shown without replaying the whole ledger |
    289262| `created_at` | timestamptz | required, defaults to now |
    290263| `updated_at` | timestamptz | optional |
    291264
    292 #### Contains — Watchlists (M) : Cryptos (N), partial on both sides, **with attribute**
    293 Which assets are on which watchlist. M:N: a list holds many assets, an asset
    294 appears on many lists. Partial on both sides — an empty list is valid and an
    295 asset need not be on any list.
    296 
    297 | Attribute | Type | Constraints |
    298 |---|---|---|
     265#### WatchlistItems
     266One asset placed on one watchlist. *An entity set since v05* (until v04 it was
     267the M:N relationship `Contains`). It has its own identifier, and it is linked
     268to its list through `Contains` and to its asset through `Lists`.
     269
     270**Keys:** candidate `{id}`; primary key **`id`**. **Uniqueness rule:** an asset
     271appears at most once on a given list, so the watchlist that `Contains` an item
     272together with the crypto it `Lists` also identifies the item. P2 enforces this
     273as `UNIQUE(watchlist_id, crypto_id)`.
     274
     275| Attribute | Type | Constraints |
     276|---|---|---|
     277| `id` | UUID | PK, required |
    299278| `added_at` | timestamptz | required, defaults to now — recorded so a list can be shown in the order the user built it |
     279
     280### Relationships
     281
     282#### QuotedOn — Cryptos (1) : Markets (N), total on Markets
     283Ties a market to the asset it trades. One asset can be quoted in many markets;
     284every market must have exactly one asset, hence total participation on the
     285`Markets` side. No attributes.
     286
     287#### PlacedOn — Markets (1) : Orders (N), total on Orders
     288Records which market an order was placed on. Every order must name a market;
     289a market may have no orders yet. No attributes.
     290
     291#### Places — Users (1) : Orders (N), total on Orders
     292Records who placed an order. Every order belongs to exactly one user; a new
     293user has no orders. No attributes.
     294
     295#### Records — Users (1) : Transactions (N), total on Transactions
     296Attributes each ledger entry to a user. Every entry belongs to exactly one
     297user. No attributes.
     298
     299#### Settles — Orders (1) : Transactions (N), partial on both sides
     300Links a ledger entry to the order that caused it. Partial on the
     301`Transactions` side because deposits have no originating order, and partial on
     302the `Orders` side because an order that never executes never produces a
     303ledger entry — which is why the corresponding column is nullable in P2. No
     304attributes.
     305
     306#### Fills — Markets (1) : MarketTrades (N), total on MarketTrades
     307Every executed trade happened on exactly one market. No attributes.
     308
     309#### FillsBuy — Orders (1) : MarketTrades (N), partial on both sides
     310*Added in v04, after P7.* The buy order a trade filled. An order can be
     311filled by many trades (partial fills); a trade fills at most one buy order,
     312and none when the simulated market was the buyer. The role of `Orders` in this
     313relationship is *the buy order* of the trade. No attributes.
     314
     315#### FillsSell — Orders (1) : MarketTrades (N), partial on both sides
     316*Added in v04, after P7.* The sell order a trade filled, symmetric to
     317`FillsBuy`; the role of `Orders` here is *the sell order* of the trade. A
     318trade between two users' orders participates in both. No attributes.
     319
     320#### Logs — Orders (1) : OrderEvents (N), total on OrderEvents
     321*Added in v04, after P7.* Every event belongs to exactly one order. No
     322attributes.
     323
     324#### Aggregates — Markets (1) : MarketCandles (N), total on MarketCandles
     325Every candle summarises trades of exactly one market. No attributes.
     326
     327#### Owns — Users (1) : Watchlists (N), total on Watchlists
     328Every watchlist belongs to exactly one user. No attributes.
     329
     330#### Holds — Users (1) : Holdings (N), total on Holdings
     331*1:N since v05.* Every holding belongs to exactly one user. A user may hold
     332nothing yet, so participation is partial on the `Users` side. No attributes.
     333
     334#### PositionIn — Cryptos (1) : Holdings (N), total on Holdings
     335*Added in v05.* Every holding is a position in exactly one crypto asset. An
     336asset may be held by nobody. No attributes.
     337
     338Together, `Holds` and `PositionIn` still say what the old M:N `Holds` said:
     339a user can hold many assets and an asset can be held by many users. The
     340difference is that the position is now a thing with its own identity, not
     341just a pair. The rule "at most one holding per user and crypto" is stated
     342under [Holdings](#holdings).
     343
     344#### Contains — Watchlists (1) : WatchlistItems (N), total on WatchlistItems
     345*1:N since v05.* Every watchlist item is on exactly one list. An empty list is
     346valid, so participation is partial on the `Watchlists` side. No attributes.
     347
     348#### Lists — Cryptos (1) : WatchlistItems (N), total on WatchlistItems
     349*Added in v05.* Every watchlist item names exactly one crypto asset. An asset
     350need not be on any list. No attributes.
    300351
    301352## Entity-Relationship Model History
    … …  
    341392  Nothing existing was removed or changed. See
    342393  [AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md).
    343   The diagram files are `ERModel_v04.xml` / `ERModel_v04.png`; earlier versions
     394  The diagram files are `ERModel_v04.xml` / `ERModel_v04.png`.
     395- **v05 — correction after review.** The review of P2 found that two parts of
     396  the model were implemented differently in the database:
     397  - `Contains` was an M:N relationship in the model, but `watchlist_items`
     398    has its own `id` primary key;
     399  - `Holds` was an M:N relationship in the model, but `holdings` has its own
     400    `id` primary key.
     401
     402  An M:N relationship has no identifier of its own; its table's key is the pair
     403  of participating keys. A table with its own `id` is the implementation of an
     404  entity set. Every phase after P2 (the prototype, the reports and the P7
     405  logic) already uses the database as it is. So the **model** was corrected to
     406  match P2, not the other way round:
     407  - `Holds` (M:N, with attributes) became the entity set `Holdings` (its former
     408    attributes plus `id`) with two 1:N relationships, `Holds` (Users → Holdings)
     409    and `PositionIn` (Cryptos → Holdings), both total on the `Holdings` side;
     410  - `Contains` (M:N, with `added_at`) became the entity set `WatchlistItems`
     411    (`id`, `added_at`) with `Contains` (Watchlists → WatchlistItems) and
     412    `Lists` (Cryptos → WatchlistItems), both total on the `WatchlistItems`
     413    side;
     414  - the former keys of the two relationships are kept as uniqueness rules
     415    ("one holding per user and crypto", "an asset at most once per list");
     416  - the key descriptions of `Markets`, `MarketTrades`, `MarketCandles` and
     417    `Watchlists` no longer name foreign-key columns (`crypto_id`,
     418    `market_id`, `user_id`), which do not exist in an ER model;
     419  - the diagram was redrawn on a grid with no overlapping attributes. In v04,
     420    `Watchlists.id` was hidden behind `added_at`, and several attributes of
     421    `Orders`, `Transactions`, `MarketTrades` and `MarketCandles` overlapped.
     422    The grid also makes it easier to compare the diagram with the P2
     423    relational diagram.
     424
     425  The diagram files are `ERModel_v05.xml` / `ERModel_v05.png`; earlier versions
    344426  are kept.
    345427
  • docs/P1-ConceptualModel/ERModelAIUsage.md

    r0cee8ec r1549dae  
    1313### Diagram
    1414
    15 `ERModel_v01.xml` / `ERModel_v01.png`.
     15Current version: `ERModel_v05.xml` / `ERModel_v05.png` (see session 5 below). First
     16version: `ERModel_v01.xml` / `ERModel_v01.png`.
    1617
    1718**Declaration of how the diagram was produced.** The initial model is the
    … …  
    313314**What I decided:** to add the P7 structure to the ER model. The new elements are placed
    314315automatically, so the layout can be tidied by hand in TerraER.
     316
     317
     318### Session 5 — 2026-09-29 (Claude Opus 5.5): v05, correction after review
     319
     320**Prompt (student, excerpt):** I pasted the reviewer's comments that `Holds` and `Contains`
     321are implemented differently in the database than in the model, with the suggestion to
     322"correct only the Phase 1 model so that it matches Phase 2", and asked:
     323
     324> Can you take a look at this stuff? and fix them, but don't push anything just make the
     325> changes requested and try to explain it to me to see if you understand the problem with
     326> the solution
     327
     328**Response (AI, summarised):**
     329
     330- Explained that an M:N relationship has no identifier of its own, while `holdings` and
     331  `watchlist_items` each have an `id` primary key, so the database treats them as entity
     332  sets.
     333- Changed the model to v05. `Holdings` (`id`, `quantity`, `reserved_quantity`, derived
     334  `avg_price`, `created_at`, `updated_at`) and `WatchlistItems` (`id`, `added_at`) became
     335  entity sets, with the 1:N relationships `Holds`, `PositionIn`, `Contains` and `Lists`,
     336  each total on the new entity's side. The old relationship keys are now stated as
     337  uniqueness rules. The key descriptions no longer name foreign-key columns.
     338- Generated `ERModel_v05.xml` / `ERModel_v05.png` from scratch with TerraER 3.11's own figure
     339  classes and writer (adapted from the v01 generator), on a grid, with no overlapping
     340  attributes. It was verified by reading the file back with TerraER's reader (184 figures)
     341  and by inspecting the rendered PNG.
     342- Updated [ERModel](ERModel.md) (v05 sections and history entry).
     343
     344**What I decided:** to follow the reviewer's advice and change the model rather than the
     345database, since every later phase already uses the database as it is.
  • docs/P1-ConceptualModel/wiki/ERModel.md

    r0cee8ec r1549dae  
    1 = Entity-Relationship Model v.04 =
     1= Entity-Relationship Model v.05 =
    22
    33== Diagram ==
    44
    5 [[Image(ERModel_v04.png, 800px)]]
     5[[Image(ERModel_v05.png, 800px)]]
    66
    77Notation: Chen. Rectangles are entity sets, diamonds are relationships, ellipses
    … …  
    1111single line marks partial participation.
    1212
    13 Two deliberate modeling decisions worth stating up front:
     13Three deliberate modeling decisions worth stating up front:
    1414
    1515 * '''No foreign keys appear in the diagram.''' Connections between entity sets are expressed as relationships, per the notation. Foreign-key columns appear only in the relational model in [wiki:RelationalDesign].
    16  * '''`Holds` and `Contains` are relationships, not entity sets.''' Both are M:N and both carry their own attributes, which is exactly what a Chen relationship is for. They become tables (`holdings`, `watchlist_items`) only in P2.
     16 * '''A position and a watchlist entry are entity sets, not M:N relationships.''' `Holdings` (a user's position in an asset) and `WatchlistItems` (an asset on a watchlist) each have their own identifier `id`, and each is connected by two 1:N relationships: `Holds` and `PositionIn` for a holding, `Contains` and `Lists` for a watchlist item. Until v04 they were drawn as the M:N relationships `Holds` and `Contains`, but the database has always given `holdings` and `watchlist_items` their own `id` primary key. That is how an entity set is implemented, not an M:N relationship, whose key would be the pair of participating keys. v05 corrects the model to match; see history.
     17 * '''Key and uniqueness rules are stated with entity and relationship names, never with foreign-key columns.''' For example: "a crypto is quoted at most once per currency", not "`{crypto_id, quote_currency}` is unique".
    1718
    1819== Data requirements ==
    … …  
    4142|| `id` || UUID || PK, required ||
    4243|| `username` || text(50) || required, unique ||
    43 || `email` || text(255) || required, unique, contains `@` ||
     44|| `email` || text(255) || required, unique, contains `@` (checked by the application at registration, not by a database constraint) ||
    4445|| `full_name` || text(200) || optional ||
    4546|| `password_hash` || text(255) || required — never the password itself; the prototype stores a SHA-256 hex digest ||
    … …  
    7374trades, candles and orders all reference the pair, not the asset.
    7475
    75 '''Keys:''' candidates `{id}`, `{crypto_id, quote_currency}` — that pair is
    76 unique by definition, since a given asset can only be quoted once per
    77 currency; primary key '''`id`''', so that the many entity sets referencing a
    78 market carry one narrow column instead of a composite key.
     76'''Keys:''' candidate `{id}`; primary key '''`id`''', so that the many entity sets
     77related to a market need one narrow identifier instead of a composite one.
     78'''Uniqueness rule:''' a crypto is quoted at most once per currency, so the crypto
     79a market is `QuotedOn` together with its `quote_currency` identifies the market
     80as well. Chen notation cannot draw this, because half of it comes through a
     81relationship. P2 enforces it as `UNIQUE(crypto_id, quote_currency)`.
    7982
    8083||= Attribute =||= Type =||= Constraints =||
    … …  
    9194
    9295Placing an order is what triggers a '''reservation''' of whatever it commits:
    93 the crypto being sold (`Holds.reserved_quantity`, below) on a sell, and the
     96the crypto being sold (`Holdings.reserved_quantity`, below) on a sell, and the
    9497cash (`Users.reserved_balance`) on a buy. Since v04 (after P7) an order can
    9598wait in the order book and be filled in parts, so `status` is a real
    … …  
    113116|| `quantity` || numeric(20,4) || required, > 0 ||
    114117|| `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) ||
    115 || `price` || numeric(18,6) || the limit price; for a market order, the market price when it was placed ||
     118|| `price` || numeric(18,6) || optional — the limit price; for a market order, the market price when it was placed ||
    116119|| `placed_at` || timestamptz || required, defaults to now ||
    117120|| `executed_at` || timestamptz || optional, set when the order settles ||
    … …  
    139142writes directly.
    140143
    141 '''Keys:''' candidate `{id}` — `{market_id, executed_at}` looks unique in
    142 principle, but two trades can share a timestamp, so it is not a safe key;
     144'''Keys:''' candidate `{id}` — "market plus `executed_at`" looks unique in
     145principle, but two trades on a market can share a timestamp, so it is not a safe key;
    143146primary key '''`id`''' (a plain auto-incrementing integer here rather than a
    144147UUID, because this is the highest-volume entity set and it is only ever read
    … …  
    146149
    147150||= Attribute =||= Type =||= Constraints =||
    148 || `id` || integer || PK, required, auto-generated ||
     151|| `id` || big integer || PK, required, auto-generated (`bigserial` in P2) ||
    149152|| `executed_at` || timestamptz || required ||
    150153|| `price` || numeric(18,6) || required, > 0 ||
    … …  
    166169
    167170||= Attribute =||= Type =||= Constraints =||
    168 || `id` || integer || PK, required, auto-generated ||
     171|| `id` || big integer || PK, required, auto-generated (`bigserial` in P2) ||
    169172|| `event_type` || text || required, `placed`, `partially_filled`, `filled` or `cancelled` ||
    170173|| `quantity` || numeric(20,4) || required — the ordered quantity for `placed`, the filled amount for a fill, the unfilled rest for `cancelled` ||
    171174|| `price` || numeric(18,6) || optional — the order price, or the trade price for a fill ||
    172175|| `status_after` || text || required, the order's status after the event ||
    173 || `created_at` || timestamptz || required, defaults to now ||
     176|| `created_at` || timestamptz || required, set automatically when the event is recorded (`clock_timestamp()`, so events inside one transaction keep their real order) ||
    174177
    175178==== !MarketCandles ====
    … …  
    179182screen refresh does not scale.
    180183
    181 '''Keys:''' candidates `{id}`, `{market_id, timeframe, candle_time}` — a market
    182 has exactly one candle per timeframe per time bucket; primary key '''`id`''', the
    183 composite is enforced as a uniqueness rule because it is the real-world
    184 constraint and it is what prevents duplicate candles.
    185 
    186 ||= Attribute =||= Type =||= Constraints =||
    187 || `id` || integer || PK, required, auto-generated ||
     184'''Keys:''' candidate `{id}`; primary key '''`id`'''. '''Uniqueness rule:''' a market
     185has exactly one candle per timeframe per time bucket, so the market a candle
     186`Aggregates` together with `timeframe` and `candle_time` also identifies it.
     187This is the real-world constraint that prevents duplicate candles. P2 enforces
     188it as `UNIQUE(market_id, timeframe, candle_time)`.
     189
     190||= Attribute =||= Type =||= Constraints =||
     191|| `id` || big integer || PK, required, auto-generated (`bigserial` in P2) ||
    188192|| `timeframe` || text || required, `1m`, `5m`, `1h` or `1d` ||
    189193|| `open`, `high`, `low`, `close` || numeric(18,6) || all required ||
    … …  
    196200several lists ("long term", "watching today") and each needs its own name.
    197201
    198 '''Keys:''' candidate `{id}` — `{user_id, name}` would also work if list names
    199 were required to be unique per user, which the model does not impose, so it is
    200 not listed as a candidate key; primary key '''`id`'''.
     202'''Keys:''' candidate `{id}`; primary key '''`id`'''. "Owner plus `name`" would
     203also identify a list if names had to be unique per user, but the model does
     204not require that, so there is no uniqueness rule here.
    201205
    202206||= Attribute =||= Type =||= Constraints =||
    … …  
    205209|| `created_at` || timestamptz || required, defaults to now ||
    206210
    207 === Relationships ===
    208 
    209 ==== !QuotedOn — Cryptos (1) : Markets (N), total on Markets ====
    210 Ties a market to the asset it trades. One asset can be quoted in many markets;
    211 every market must have exactly one asset, hence total participation on the
    212 `Markets` side. No attributes.
    213 
    214 ==== !PlacedOn — Markets (1) : Orders (N), total on Orders ====
    215 Records which market an order was placed on. Every order must name a market;
    216 a market may have no orders yet. No attributes.
    217 
    218 ==== Places — Users (1) : Orders (N), total on Orders ====
    219 Records who placed an order. Every order belongs to exactly one user; a new
    220 user has no orders. No attributes.
    221 
    222 ==== Records — Users (1) : Transactions (N), total on Transactions ====
    223 Attributes each ledger entry to a user. Every entry belongs to exactly one
    224 user. No attributes.
    225 
    226 ==== Settles — Orders (1) : Transactions (N), partial on both sides ====
    227 Links a ledger entry to the order that caused it. Partial on the
    228 `Transactions` side because deposits have no originating order, and partial on
    229 the `Orders` side because an order that never executes never produces a
    230 ledger entry — which is why the corresponding column is nullable in P2. No
    231 attributes.
    232 
    233 ==== Fills — Markets (1) : !MarketTrades (N), total on !MarketTrades ====
    234 Every executed trade happened on exactly one market. No attributes.
    235 
    236 ==== !FillsBuy — Orders (1) : !MarketTrades (N), partial on both sides ====
    237 ''Added in v04, after P7.'' The buy order a trade filled. An order can be
    238 filled by many trades (partial fills); a trade fills at most one buy order,
    239 and none when the simulated market was the buyer. No attributes.
    240 
    241 ==== !FillsSell — Orders (1) : !MarketTrades (N), partial on both sides ====
    242 ''Added in v04, after P7.'' The sell order a trade filled, symmetric to
    243 `FillsBuy`. A trade between two users' orders participates in both. No
    244 attributes.
    245 
    246 ==== Logs — Orders (1) : !OrderEvents (N), total on !OrderEvents ====
    247 ''Added in v04, after P7.'' Every event belongs to exactly one order. No
    248 attributes.
    249 
    250 ==== Aggregates — Markets (1) : !MarketCandles (N), total on !MarketCandles ====
    251 Every candle summarises trades of exactly one market. No attributes.
    252 
    253 ==== Owns — Users (1) : Watchlists (N), total on Watchlists ====
    254 Every watchlist belongs to exactly one user. No attributes.
    255 
    256 ==== Holds — Users (M) : Cryptos (N), partial on both sides, '''with attributes''' ====
    257 A user's position in an asset. M:N because one user holds many assets and one
    258 asset is held by many users, and partial on both sides because a user may hold
    259 nothing and an asset may be held by nobody. Modeled as a relationship rather
    260 than an entity set because a position has no identity of its own — it is
    261 entirely described by ''which user'', ''which asset'', and how much.
     211==== Holdings ====
     212A user's position in one crypto asset: how much of it the user owns, how much
     213of that is already promised to open sell orders, and at what average price it
     214was accumulated. ''An entity set since v05'' (until v04 it was the M:N
     215relationship `Holds`). A holding has its own identifier and its own
     216lifecycle: it is created on the first buy, updated on every later fill, and
     217the prototype reads and locks it as a unit (`SELECT … FOR UPDATE` on the sell
     218path). It is linked to its owner through `Holds` and to its asset through
     219`PositionIn`.
     220
     221'''Keys:''' candidate `{id}`; primary key '''`id`'''. '''Uniqueness rule:''' a user
     222has at most one holding per crypto, so the user who `Holds` it together with
     223the crypto it is a `PositionIn` also identifies a holding. P2 enforces this as
     224`UNIQUE(user_id, crypto_id)`.
    262225
    263226`reserved_quantity` mirrors `available_balance`/`invested_balance` on `Users`:
    … …  
    270233
    271234||= Attribute =||= Type =||= Constraints =||
     235|| `id` || UUID || PK, required ||
    272236|| `quantity` || numeric(20,4) || required, ≥ 0 — total amount owned ||
    273237|| `reserved_quantity` || numeric(20,4) || required, default 0, `0 ≤ reserved_quantity ≤ quantity` — committed to the user's own open sell orders, not yet removed from the position ||
    274 || `avg_price` || numeric(18,6) || required, ≥ 0, '''derived''' (dashed ellipse) — the weighted average of the prices at which the position was accumulated; derivable from the buy history, stored anyway so unrealised P/L can be shown without replaying the whole ledger ||
     238|| `avg_price` || numeric(18,6) || required, default 0, ≥ 0, '''derived''' (dashed ellipse) — the weighted average of the prices at which the position was accumulated; derivable from the buy history, stored anyway so unrealised P/L can be shown without replaying the whole ledger ||
    275239|| `created_at` || timestamptz || required, defaults to now ||
    276240|| `updated_at` || timestamptz || optional ||
    277241
    278 ==== Contains — Watchlists (M) : Cryptos (N), partial on both sides, '''with attribute''' ====
    279 Which assets are on which watchlist. M:N: a list holds many assets, an asset
    280 appears on many lists. Partial on both sides — an empty list is valid and an
    281 asset need not be on any list.
    282 
    283 ||= Attribute =||= Type =||= Constraints =||
     242==== !WatchlistItems ====
     243One asset placed on one watchlist. ''An entity set since v05'' (until v04 it was
     244the M:N relationship `Contains`). It has its own identifier, and it is linked
     245to its list through `Contains` and to its asset through `Lists`.
     246
     247'''Keys:''' candidate `{id}`; primary key '''`id`'''. '''Uniqueness rule:''' an asset
     248appears at most once on a given list, so the watchlist that `Contains` an item
     249together with the crypto it `Lists` also identifies the item. P2 enforces this
     250as `UNIQUE(watchlist_id, crypto_id)`.
     251
     252||= Attribute =||= Type =||= Constraints =||
     253|| `id` || UUID || PK, required ||
    284254|| `added_at` || timestamptz || required, defaults to now — recorded so a list can be shown in the order the user built it ||
     255
     256=== Relationships ===
     257
     258==== !QuotedOn — Cryptos (1) : Markets (N), total on Markets ====
     259Ties a market to the asset it trades. One asset can be quoted in many markets;
     260every market must have exactly one asset, hence total participation on the
     261`Markets` side. No attributes.
     262
     263==== !PlacedOn — Markets (1) : Orders (N), total on Orders ====
     264Records which market an order was placed on. Every order must name a market;
     265a market may have no orders yet. No attributes.
     266
     267==== Places — Users (1) : Orders (N), total on Orders ====
     268Records who placed an order. Every order belongs to exactly one user; a new
     269user has no orders. No attributes.
     270
     271==== Records — Users (1) : Transactions (N), total on Transactions ====
     272Attributes each ledger entry to a user. Every entry belongs to exactly one
     273user. No attributes.
     274
     275==== Settles — Orders (1) : Transactions (N), partial on both sides ====
     276Links a ledger entry to the order that caused it. Partial on the
     277`Transactions` side because deposits have no originating order, and partial on
     278the `Orders` side because an order that never executes never produces a
     279ledger entry — which is why the corresponding column is nullable in P2. No
     280attributes.
     281
     282==== Fills — Markets (1) : !MarketTrades (N), total on !MarketTrades ====
     283Every executed trade happened on exactly one market. No attributes.
     284
     285==== !FillsBuy — Orders (1) : !MarketTrades (N), partial on both sides ====
     286''Added in v04, after P7.'' The buy order a trade filled. An order can be
     287filled by many trades (partial fills); a trade fills at most one buy order,
     288and none when the simulated market was the buyer. The role of `Orders` in this
     289relationship is ''the buy order'' of the trade. No attributes.
     290
     291==== !FillsSell — Orders (1) : !MarketTrades (N), partial on both sides ====
     292''Added in v04, after P7.'' The sell order a trade filled, symmetric to
     293`FillsBuy`; the role of `Orders` here is ''the sell order'' of the trade. A
     294trade between two users' orders participates in both. No attributes.
     295
     296==== Logs — Orders (1) : !OrderEvents (N), total on !OrderEvents ====
     297''Added in v04, after P7.'' Every event belongs to exactly one order. No
     298attributes.
     299
     300==== Aggregates — Markets (1) : !MarketCandles (N), total on !MarketCandles ====
     301Every candle summarises trades of exactly one market. No attributes.
     302
     303==== Owns — Users (1) : Watchlists (N), total on Watchlists ====
     304Every watchlist belongs to exactly one user. No attributes.
     305
     306==== Holds — Users (1) : Holdings (N), total on Holdings ====
     307''1:N since v05.'' Every holding belongs to exactly one user. A user may hold
     308nothing yet, so participation is partial on the `Users` side. No attributes.
     309
     310==== !PositionIn — Cryptos (1) : Holdings (N), total on Holdings ====
     311''Added in v05.'' Every holding is a position in exactly one crypto asset. An
     312asset may be held by nobody. No attributes.
     313
     314Together, `Holds` and `PositionIn` still say what the old M:N `Holds` said:
     315a user can hold many assets and an asset can be held by many users. The
     316difference is that the position is now a thing with its own identity, not
     317just a pair. The rule "at most one holding per user and crypto" is stated
     318under Holdings.
     319
     320==== Contains — Watchlists (1) : !WatchlistItems (N), total on !WatchlistItems ====
     321''1:N since v05.'' Every watchlist item is on exactly one list. An empty list is
     322valid, so participation is partial on the `Watchlists` side. No attributes.
     323
     324==== Lists — Cryptos (1) : !WatchlistItems (N), total on !WatchlistItems ====
     325''Added in v05.'' Every watchlist item names exactly one crypto asset. An asset
     326need not be on any list. No attributes.
    285327
    286328== Entity-Relationship Model History ==
    … …  
    300342  Nothing existing was removed or changed. See
    301343  [wiki:AdvancedDatabaseDevelopment].
    302   The diagram files are `ERModel_v04.xml` / `ERModel_v04.png`; earlier versions
     344  The diagram files are `ERModel_v04.xml` / `ERModel_v04.png`.
     345 * '''v05 — correction after review.''' The review of P2 found that two parts of the model were implemented differently in the database:
     346   * `Contains` was an M:N relationship in the model, but `watchlist_items` has its own `id` primary key;
     347   * `Holds` was an M:N relationship in the model, but `holdings` has its own `id` primary key.
     348
     349  An M:N relationship has no identifier of its own; its table's key is the pair
     350  of participating keys. A table with its own `id` is the implementation of an
     351  entity set. Every phase after P2 (the prototype, the reports and the P7
     352  logic) already uses the database as it is. So the '''model''' was corrected to
     353  match P2, not the other way round:
     354   * `Holds` (M:N, with attributes) became the entity set `Holdings` (its former attributes plus `id`) with two 1:N relationships, `Holds` (Users → Holdings) and `PositionIn` (Cryptos → Holdings), both total on the `Holdings` side;
     355   * `Contains` (M:N, with `added_at`) became the entity set `WatchlistItems` (`id`, `added_at`) with `Contains` (Watchlists → !WatchlistItems) and `Lists` (Cryptos → !WatchlistItems), both total on the `WatchlistItems` side;
     356   * the former keys of the two relationships are kept as uniqueness rules ("one holding per user and crypto", "an asset at most once per list");
     357   * the key descriptions of `Markets`, `MarketTrades`, `MarketCandles` and `Watchlists` no longer name foreign-key columns (`crypto_id`, `market_id`, `user_id`), which do not exist in an ER model;
     358   * the diagram was redrawn on a grid with no overlapping attributes. In v04, `Watchlists.id` was hidden behind `added_at`, and several attributes of `Orders`, `Transactions`, `MarketTrades` and `MarketCandles` overlapped. The grid also makes it easier to compare the diagram with the P2 relational diagram.
     359
     360  The diagram files are `ERModel_v05.xml` / `ERModel_v05.png`; earlier versions
    303361  are kept.
    304362
  • docs/P1-ConceptualModel/wiki/ERModelAIUsage.md

    r0cee8ec r1549dae  
    1313=== Diagram ===
    1414
    15 `ERModel_v01.xml` / `ERModel_v01.png`.
     15Current version: `ERModel_v05.xml` / `ERModel_v05.png` (see session 5 below). First
     16version: `ERModel_v01.xml` / `ERModel_v01.png`.
    1617
    1718'''Declaration of how the diagram was produced.''' The initial model is the
    … …  
    305306'''What I decided:''' to add the P7 structure to the ER model. The new elements are placed
    306307automatically, so the layout can be tidied by hand in TerraER.
     308
     309=== Session 5 — 2026-09-29 (Claude Opus 5.5): v05, correction after review ===
     310
     311'''Prompt (student, excerpt):''' I pasted the reviewer's comments that `Holds` and `Contains`
     312are implemented differently in the database than in the model, with the suggestion to
     313"correct only the Phase 1 model so that it matches Phase 2", and asked:
     314
     315> Can you take a look at this stuff? and fix them, but don't push anything just make the
     316> changes requested and try to explain it to me to see if you understand the problem with
     317> the solution
     318
     319'''Response (AI, summarised):'''
     320
     321 * Explained that an M:N relationship has no identifier of its own, while `holdings` and `watchlist_items` each have an `id` primary key, so the database treats them as entity sets.
     322 * Changed the model to v05. `Holdings` (`id`, `quantity`, `reserved_quantity`, derived `avg_price`, `created_at`, `updated_at`) and `WatchlistItems` (`id`, `added_at`) became entity sets, with the 1:N relationships `Holds`, `PositionIn`, `Contains` and `Lists`, each total on the new entity's side. The old relationship keys are now stated as uniqueness rules. The key descriptions no longer name foreign-key columns.
     323 * Generated `ERModel_v05.xml` / `ERModel_v05.png` from scratch with TerraER 3.11's own figure classes and writer (adapted from the v01 generator), on a grid, with no overlapping attributes. It was verified by reading the file back with TerraER's reader (184 figures) and by inspecting the rendered PNG.
     324 * Updated [wiki:ERModel] (v05 sections and history entry).
     325
     326'''What I decided:''' to follow the reviewer's advice and change the model rather than the
     327database, since every later phase already uses the database as it is.
  • docs/P2-RelationalDesign/RelationalDesign.md

    r0cee8ec r1549dae  
    11# Relational Design
    22
     3This page transforms [ERModel](../P1-ConceptualModel/ERModel.md) **v05** into
     4relations. Every relation below corresponds to exactly one entity set of the
     5model, and every foreign key corresponds to exactly one relationship, so the
     6two diagrams can be compared box for box and line for line (see
     7[Relational diagram](#relational-diagram)).
     8
    39## Descriptive representation of the relational schema
    410
    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)`.
     11Notation: **bold** = primary key, *italic* = foreign key. After each foreign key
     12comes the ER relationship it implements.
     13
     14- **Users**(<u>**id**</u>, username, email, full_name, password_hash, available_balance, invested_balance, reserved_balance, created_at, updated_at)
     15  - Entity set `Users`. Candidate keys: `{id}`, `{username}`, `{email}`. `UNIQUE(username)`, `UNIQUE(email)`.
    916- **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.
     17  - Entity set `Cryptos`. Candidate keys: `{id}`, `{symbol}`. `UNIQUE(symbol)`.
     18- **Markets**(<u>**id**</u>, *crypto_id* [`QuotedOn`], quote_currency, is_active, created_at)
     19  - Entity set `Markets`. Candidate keys: `{id}`, `{crypto_id, quote_currency}`
     20    (the model's rule "a crypto is quoted at most once per currency"),
     21    enforced with `UNIQUE(crypto_id, quote_currency)`.
     22- **Holdings**(<u>**id**</u>, *user_id* [`Holds`], *crypto_id* [`PositionIn`], quantity, reserved_quantity, avg_price, created_at, updated_at)
     23  - Entity set `Holdings`. Candidate keys: `{id}` and `{user_id, crypto_id}`
     24    (the model's rule "one holding per user and crypto"), enforced with
     25    `UNIQUE(user_id, crypto_id)`.
    1826  - `avg_price` is `NOT NULL DEFAULT 0 CHECK (avg_price >= 0)`.
    1927  - `reserved_quantity` is `NOT NULL DEFAULT 0 CHECK (reserved_quantity >= 0
    … …  
    2331    needed, in `v_portfolio` as `available_quantity` and in the sell path of
    2432    [UseCase0005](../P3-UseCaseModel/UseCase0005.md). See
    25     [ERModel](../P1-ConceptualModel/ERModel.md#holds--users-m--cryptos-n-partial-on-both-sides-with-attributes)
     33    [ERModel](../P1-ConceptualModel/ERModel.md#holdings)
    2634    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)`.
     35- **Orders**(<u>**id**</u>, *user_id* [`Places`], *market_id* [`PlacedOn`], side, type, status, quantity, filled_quantity, price, placed_at, executed_at)
     36  - Entity set `Orders`. `side ∈ {buy, sell}`, `type ∈ {market, limit}`,
     37    `status ∈ {open, partially_filled, executed, cancelled}`,
     38    `0 ≤ filled_quantity ≤ quantity`.
     39- **Transactions**(<u>**id**</u>, *user_id* [`Records`], type, amount, currency, *related_order* [`Settles`], created_at, description)
     40  - Entity set `Transactions`. `type ∈ {deposit, buy, sell, fee}`.
     41    `related_order` is nullable (see below).
     42- **MarketTrades**(<u>**id**</u>, *market_id* [`Fills`], executed_at, price, quantity, side, source, *buy_order_id* [`FillsBuy`], *sell_order_id* [`FillsSell`])
     43  - Entity set `MarketTrades`. `buy_order_id` and `sell_order_id` are both
     44    nullable (see below).
     45- **OrderEvents**(<u>**id**</u>, *order_id* [`Logs`], event_type, quantity, price, status_after, created_at)
     46  - Entity set `OrderEvents`. `event_type ∈ {placed, partially_filled, filled, cancelled}`.
     47- **MarketCandles**(<u>**id**</u>, *market_id* [`Aggregates`], timeframe, open, high, low, close, volume, candle_time)
     48  - Entity set `MarketCandles`. Candidate keys: `{id}`, `{market_id, timeframe,
     49    candle_time}` (the model's rule "one candle per market, timeframe and
     50    bucket"), enforced with `UNIQUE(market_id, timeframe, candle_time)`.
     51- **Watchlists**(<u>**id**</u>, *user_id* [`Owns`], name, created_at)
     52  - Entity set `Watchlists`.
     53- **WatchlistItems**(<u>**id**</u>, *watchlist_id* [`Contains`], *crypto_id* [`Lists`], added_at)
     54  - Entity set `WatchlistItems`. Candidate keys: `{id}` and `{watchlist_id,
     55    crypto_id}` (the model's rule "an asset at most once per list"), enforced
     56    with `UNIQUE(watchlist_id, crypto_id)`.
    3957
    4058### Transformation method used
    4159
    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.
     60**Partial transformation.** The model has 11 entity sets and 15 relationships.
     61Every relationship is binary and 1:N with no attributes of its own (the two M:N
     62relationships of earlier versions, `Holds` and `Contains`, were corrected into
     63the entity sets `Holdings` and `WatchlistItems` in v05). The rules:
     64
     65- **Each entity set becomes one relation**, with its own attributes and its
     66  own key `id` as primary key. 11 entity sets → 11 relations.
     67- **Each 1:N relationship becomes one foreign key** on the relation of the "N"
     68  side, pointing to the primary key of the "1" side. No relationship gets its
     69  own table, because none is M:N and none has attributes. 15 relationships →
     70  15 foreign keys:
     71
     72  | ER relationship | 1 side → N side | Foreign key | Participation of the N side | `NULL`? |
     73  |---|---|---|---|---|
     74  | `QuotedOn`   | Cryptos → Markets             | `markets.crypto_id`            | total   | `NOT NULL` |
     75  | `PlacedOn`   | Markets → Orders              | `orders.market_id`             | total   | `NOT NULL` |
     76  | `Places`     | Users → Orders                | `orders.user_id`               | total   | `NOT NULL` |
     77  | `Records`    | Users → Transactions          | `transactions.user_id`         | total   | `NOT NULL` |
     78  | `Settles`    | Orders → Transactions         | `transactions.related_order`   | partial | nullable |
     79  | `Fills`      | Markets → MarketTrades        | `market_trades.market_id`      | total   | `NOT NULL` |
     80  | `FillsBuy`   | Orders → MarketTrades         | `market_trades.buy_order_id`   | partial | nullable |
     81  | `FillsSell`  | Orders → MarketTrades         | `market_trades.sell_order_id`  | partial | nullable |
     82  | `Logs`       | Orders → OrderEvents          | `order_events.order_id`        | total   | `NOT NULL` |
     83  | `Aggregates` | Markets → MarketCandles       | `market_candles.market_id`     | total   | `NOT NULL` |
     84  | `Owns`       | Users → Watchlists            | `watchlists.user_id`           | total   | `NOT NULL` |
     85  | `Holds`      | Users → Holdings              | `holdings.user_id`             | total   | `NOT NULL` |
     86  | `PositionIn` | Cryptos → Holdings            | `holdings.crypto_id`           | total   | `NOT NULL` |
     87  | `Contains`   | Watchlists → WatchlistItems   | `watchlist_items.watchlist_id` | total   | `NOT NULL` |
     88  | `Lists`      | Cryptos → WatchlistItems      | `watchlist_items.crypto_id`    | total   | `NOT NULL` |
     89
     90- **Participation decides `NULL`.** Total participation of the N side means
     91  every row must reference a parent, so the foreign key is `NOT NULL`. Partial
     92  participation leaves it nullable. There are exactly three partial ones:
     93  `Settles` (a deposit has no originating order), and `FillsBuy` / `FillsSell`
     94  (a trade against the simulated market has no user order on that side).
     95  Partial participation of the **1** side (for example, a user with no orders)
     96  needs no column at all. It simply means no row points at that parent.
     97- **Uniqueness rules of the model become `UNIQUE` constraints.** The four
     98  rules the model states in words ("a crypto quoted once per currency", "one
     99  candle per market, timeframe and bucket", "one holding per user and crypto",
     100  "an asset once per list") involve a relationship, so Chen notation cannot
     101  draw them as keys. After transformation, the relationship is a foreign-key
     102  column, and each rule becomes an ordinary composite `UNIQUE` constraint, i.e. a
     103  second candidate key.
     104
     105Nothing in the schema comes from anywhere else. Every column is either an ER
     106attribute or the foreign key of one listed relationship.
    63107
    64108### Normalisation
    65109
    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**:
     110> **Checked in P5.** [Normalization](../P5-Normalization/Normalization.md)
     111> starts from a single de-normalized relation containing only the attributes
     112> of the ER model and the functional dependencies that follow from its rules.
     113> It decomposes that relation step by step to BCNF and arrives at these same 11
     114> relations, with one deliberate difference: `transactions.user_id` (see the last
     115> bullet below). The comparison is in the *Discussion* section at the end of that
     116> page.
     117
     118All relations except `transactions` are in **BCNF**, as P5 shows. `transactions`
     119is in 2NF but not in 3NF, because of the deliberately kept `user_id` (last
     120bullet):
    77121
    78122- 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.
     123- No partial dependency exists: every candidate key is either the single
     124  column `id` or a composite key (`{user_id, crypto_id}`, …) on which no
     125  non-key attribute depends only partially.
     126- No transitive dependency exists, except `transactions.user_id` (last bullet):
     127  every other non-key attribute depends directly on the row's own entity, never
     128  on another entity reached through a foreign key.
     129  For example, `holdings.quantity` depends on `holdings.id`, and nothing about
     130  the user or the crypto is copied into `holdings`.
    81131- `avg_price` in `Holdings` is a **derived value** cached for performance (it is
    82132  the weighted-average entry price across all `buy` transactions for that
    … …  
    95145  ("available") is the derived value here, and it is never stored, only
    96146  computed where it is needed.
     147- `transactions.user_id` is kept **deliberately**, although for an entry that
     148  settles an order it repeats that order's user (`related_order → user_id`, a
     149  transitive dependency). A deposit has no order (`Settles` is partial), so
     150  `user_id` is the only way to record whose deposit it is. For entries with an
     151  order, the only code that sets `related_order` (the buy and sell inserts in
     152  `advanced_db.sql`) writes both from the same order row. No database
     153  constraint enforces this.
    97154
    98155### Reservation and the order lifecycle
    … …  
    109166`SELECT … FOR UPDATE` locking that already protected `users.available_balance`
    110167on the buy path is what makes two concurrent sell orders against the same
    111 holding serialize correctly instead of racing.
     168holding serialize correctly instead of racing. The cash side of a buy order
     169(`users.reserved_balance`), `orders.filled_quantity` and `order_events` were
     170added in P7; see
     171[AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md).
    112172
    113173## DDL script
    114174
    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.
     175The script that creates the 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.
    116176
    117177The script creates:
    118 - 10 tables with check constraints, primary keys, foreign keys and unique constraints.
    119 - 5 performance indexes.
     178- 10 of the 11 tables, with check constraints, primary keys, foreign keys and unique constraints.
     179- 8 performance indexes.
    120180- 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`).
     181
     182The 11th table, `order_events`, is created by
     183[`../server/db/advanced_db.sql`](../../server/db/advanced_db.sql) together with
     184the P7 triggers that fill it. `./eduberza -init` runs both scripts in that
     185order, so a freshly initialised database always has all 11 tables and all 15
     186foreign keys.
    121187
    122188## DML script (sample data)
    … …  
    132198## Relational diagram
    133199
    134 ![relational_schema](relational_schema.jpg)
    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.
     200![relational_diagram_v4](relational_diagram_v4.png)
     201
     202Generated in **DBeaver** from the **live** `project` schema (after
     203`./eduberza -init`), not drawn by hand, so it shows what the deployed database
     204actually contains. Each box is a table with its columns; the key icon marks the
     205primary key, and the lines are the 15 declared foreign keys. The two foreign keys from
     206`market_trades` to `orders` (`buy_order_id`, `sell_order_id`) connect the same
     207two boxes, so DBeaver draws them on top of each other as one line.
     208
     209The tables are arranged in the **same positions** as the entity sets in
     210`ERModel_v05.png`, so the two can be compared directly:
     211
     212- every rectangle of the ER diagram is one table in the same place;
     213- every diamond of the ER diagram is one foreign-key line between the same two
     214  boxes. The dot is on the referencing ("N") table, next to the foreign-key
     215  column;
     216- a double (total) line in the ER diagram is a `NOT NULL` foreign key, drawn by
     217  DBeaver as a solid line. The three single lines on the N side (`Settles`,
     218  `FillsBuy`, `FillsSell`) are the three nullable foreign keys, which DBeaver
     219  draws dashed, with a hollow diamond on the `orders` side. The table under
     220  [Transformation method used](#transformation-method-used) lists all 15.
     221
     222Earlier images (`relational_schema.jpg`, `relational_schema_v2.png`,
     223`relational_schema_v3.png`) were exported from pgAdmin, with a different layout
     224and from an older schema. They are kept only as history.
    141225
    142226### How to regenerate it
    143227
    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 
     2281. Initialise the database: `./eduberza -init` (runs `schema_creation.sql` and
     229   `advanced_db.sql`, so `order_events` is included).
     2302. In DBeaver, connect to the project database and expand
     231   *Schemas → project → Tables*.
     2323. Select all 11 tables → right-click → **View Diagram** (or create a new ER
     233   diagram and drag the tables in).
     2344. Drag each table to the position of its entity set in `ERModel_v05.png`:
     235
     236   ```
     237            column 1          column 2        column 3        column 4
     238   row 1    watchlist_items   crypto          markets         market_candles
     239   row 2    watchlists        holdings        .               market_trades
     240   row 3    users             .               orders          .
     241   row 4    .                 transactions    order_events    .
     242   ```
     243
     244   Leave the empty cells (`.`) empty. They are where the relationship
     245   diamonds are in the ER diagram, so the foreign-key lines will run through
     246   the same gaps.
     247
     2485. Right-click the canvas → **Export diagram** → PNG, saved as
     249   `relational_diagram_v4.png` in this folder.
  • docs/P2-RelationalDesign/RelationalDesignAIUsage.md

    r0cee8ec r1549dae  
    1212### Diagram
    1313
    14 The student produces `relational_schema.jpg` in DBeaver from the live `project` schema; see [RelationalDesign](RelationalDesign.md) for instructions.
     14`relational_diagram_v4.png` was exported by the student in DBeaver from the live `project` schema, with the tables in the same positions as the entity sets of `ERModel_v05.png`; see [RelationalDesign](RelationalDesign.md#how-to-regenerate-it) for instructions.
    1515
    1616### Results in details / description
    … …  
    100100`trade.go` alone to keep the reservation consistent — the same reasoning
    101101already applied to `avg_price NOT NULL` in session 2.
     102
     103### Session 4 — 2026-09-29 (Claude Opus 5.5): correction after review
     104
     105**Prompt (student, excerpt):** I pasted the reviewer's comments on P2:
     106
     107> the `contains` relationship from the model is implemented differently in the database
     108> the `holds` relationship from the model is implemented differently in the database
     109> there may be other inconsistencies that are difficult to spot; the Phase 2 diagram should
     110> have been created using DBeaver with a layout visually similar to the ERD
     111
     112> I suggest correcting only the Phase 1 model so that it matches Phase 2 [...] For Phase 2,
     113> simply update the image according to the requirements.
     114
     115**Response (AI, summarised):**
     116
     117- Explained the mismatch. In the model, `Holds` and `Contains` were M:N relationships, whose
     118  table key would be the pair `{user_id, crypto_id}` / `{watchlist_id, crypto_id}`. But
     119  `holdings` and `watchlist_items` have their own `id` primary key, which is how an entity
     120  set is implemented. Following the reviewer's advice, P1 was corrected (v05, entity sets
     121  `Holdings` and `WatchlistItems`), and the database was not changed.
     122- Found the other inconsistencies between this page and the live schema. The page was
     123  missing `users.reserved_balance`, `orders.filled_quantity`, the status
     124  `partially_filled`, `market_trades.buy_order_id` / `sell_order_id` and the whole
     125  `order_events` table. The "10 tables", "5 indexes" and "the one nullable foreign key"
     126  counts were also out of date (really 11 tables, 8 indexes in `schema_creation.sql`, and 3
     127  nullable foreign keys).
     128- Rewrote [RelationalDesign](RelationalDesign.md). Each relation is labelled with its entity
     129  set and each foreign key with its relationship, and the transformation is a table of all
     130  15 relationships → 15 foreign keys, with `NOT NULL` following participation.
     131- Wrote export instructions with a table grid that mirrors `ERModel_v05.png`.
     132
     133**What I decided:** to correct P1 instead of the database, as the reviewer suggested. I
     134exported `relational_diagram_v4.png` from DBeaver myself, with the tables arranged like the
     135ER diagram.
  • docs/P2-RelationalDesign/wiki/RelationalDesign.md

    r0cee8ec r1549dae  
    11= Relational Design =
    22
     3This page transforms [wiki:ERModel] '''v05''' into
     4relations. Every relation below corresponds to exactly one entity set of the
     5model, and every foreign key corresponds to exactly one relationship, so the
     6two diagrams can be compared box for box and line for line (see
     7Relational diagram).
     8
    39== Descriptive representation of the relational schema ==
    410
    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)`.
     11Notation: '''bold''' = primary key, ''italic'' = foreign key. After each foreign key
     12comes the ER relationship it implements.
     13
     14 * '''Users'''(__'''id'''__, username, email, full_name, password_hash, available_balance, invested_balance, reserved_balance, created_at, updated_at)
     15   * Entity set `Users`. Candidate keys: `{id}`, `{username}`, `{email}`. `UNIQUE(username)`, `UNIQUE(email)`.
    916 * '''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.
     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)`.
    1822   * `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      [wiki:UseCase0005]. See the `Holds` section of [wiki: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)`.
    3738
    3839=== Transformation method used ===
    3940
    40 '''Partial transformation.''' Applied as follows:
    41 
    42  * Each of the 8 entity sets in [wiki: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.
     42Every relationship is binary and 1:N with no attributes of its own (the two M:N
     43relationships of earlier versions, `Holds` and `Contains`, were corrected into
     44the 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
     69Nothing in the schema comes from anywhere else. Every column is either an ER
     70attribute or the foreign key of one listed relationship.
    6171
    6272=== Normalisation ===
    6373
    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''':
     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
     82All relations except `transactions` are in '''BCNF''', as P5 shows. `transactions`
     83is in 2NF but not in 3NF, because of the deliberately kept `user_id` (last
     84bullet):
    7585
    7686 * 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.
     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.
    9593
    9694=== Reservation and the order lifecycle ===
    … …  
    107105`SELECT … FOR UPDATE` locking that already protected `users.available_balance`
    108106on the buy path is what makes two concurrent sell orders against the same
    109 holding serialize correctly instead of racing.
     107holding serialize correctly instead of racing. The cash side of a buy order
     108(`users.reserved_balance`), `orders.filled_quantity` and `order_events` were
     109added in P7; see
     110[wiki:AdvancedDatabaseDevelopment].
    110111
    111112== DDL script ==
    112113
    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.
     114The script that creates the schema is `../server/db/schema_creation.sql` (shown 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.
    114115
    115116The script creates:
    116  * 10 tables with check constraints, primary keys, foreign keys and unique constraints.
    117  * 5 performance indexes.
     117 * 10 of the 11 tables, with check constraints, primary keys, foreign keys and unique constraints.
     118 * 8 performance indexes.
    118119 * 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`).
     120
     121The 11th table, `order_events`, is created by
     122`../server/db/advanced_db.sql` together with
     123the P7 triggers that fill it. `./eduberza -init` runs both scripts in that
     124order, so a freshly initialised database always has all 11 tables and all 15
     125foreign keys.
    119126
    120127=== schema_creation.sql ===
    … …  
    150157    available_balance numeric(18,4)   NOT NULL DEFAULT 0 CHECK (available_balance >= 0),
    151158    invested_balance  numeric(18,4)   NOT NULL DEFAULT 0 CHECK (invested_balance  >= 0),
     159    -- P7: cash committed to the user's active buy orders, moved out of
     160    -- available_balance when the order is placed and consumed as it fills.
     161    reserved_balance  numeric(18,4)   NOT NULL DEFAULT 0 CHECK (reserved_balance  >= 0),
    152162    created_at        timestamptz     NOT NULL DEFAULT now(),
    153163    updated_at        timestamptz
    … …  
    210220    side        varchar(4)     NOT NULL CHECK (side   IN ('buy', 'sell')),
    211221    type        varchar(20)    NOT NULL CHECK (type   IN ('market', 'limit')),
    212     status      varchar(20)    NOT NULL CHECK (status IN ('open', 'executed', 'cancelled')),
     222    status      varchar(20)    NOT NULL CHECK (status IN ('open', 'partially_filled', 'executed', 'cancelled')),
    213223    quantity    numeric(20,4)  NOT NULL CHECK (quantity > 0),
     224    -- P7: how much of the order has been traded so far; remaining is
     225    -- quantity - filled_quantity. Maintained from market_trades.
     226    filled_quantity numeric(20,4) NOT NULL DEFAULT 0
     227                               CHECK (filled_quantity >= 0 AND filled_quantity <= quantity),
    214228    price       numeric(18,6),
    215229    placed_at   timestamptz    NOT NULL DEFAULT now(),
    … …  
    249263    quantity    numeric(20,6)  NOT NULL CHECK (quantity > 0),
    250264    side        varchar(4)     CHECK (side IN ('buy', 'sell')),
    251     source      varchar(50)    NOT NULL DEFAULT 'simulation'
     265    source      varchar(50)    NOT NULL DEFAULT 'simulation',
     266    -- P7: the orders this trade filled. NULL on a side means the counterparty
     267    -- was the simulated market (bot ticks have both NULL).
     268    buy_order_id  uuid         REFERENCES project.orders(id),
     269    sell_order_id uuid         REFERENCES project.orders(id)
    252270);
    253271
    254272CREATE INDEX idx_market_trades_market_time ON project.market_trades(market_id, executed_at DESC);
     273CREATE INDEX idx_market_trades_buy_order  ON project.market_trades(buy_order_id)  WHERE buy_order_id  IS NOT NULL;
     274CREATE INDEX idx_market_trades_sell_order ON project.market_trades(sell_order_id) WHERE sell_order_id IS NOT NULL;
    255275
    256276-- ============================================================================
    … …  
    325345}}}
    326346
     347=== order_events (from advanced_db.sql) ===
     348
     349{{{
     350CREATE TABLE project.order_events (
     351    id           bigserial      PRIMARY KEY,
     352    order_id     uuid           NOT NULL REFERENCES project.orders(id) ON DELETE CASCADE,
     353    event_type   varchar(20)    NOT NULL
     354                 CHECK (event_type IN ('placed', 'partially_filled', 'filled', 'cancelled')),
     355    quantity     numeric(20,4)  NOT NULL,
     356    price        numeric(18,6),
     357    status_after varchar(20)    NOT NULL,
     358    created_at   timestamptz    NOT NULL DEFAULT clock_timestamp()
     359);
     360
     361CREATE INDEX idx_order_events_order ON project.order_events(order_id, id);
     362}}}
     363
    327364== DML script (sample data) ==
    328365
    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:
     366The script that loads realistic sample data is `../server/db/data_load.sql` (shown below). It is idempotent: it truncates all tables with `CASCADE` then re-inserts. Loaded:
    330367 * 5 crypto assets (BTC, ETH, ADA, SOL, DOGE) and 5 USD-quoted markets.
    331368 * 3 sample users (`alice`, `bob`, `charlie`) with password `test123` (sha256 hex).
    … …  
    347384--
    348385-- All sample users have the password: test123
     386--
     387-- One transaction: the P7 checks in advanced_db.sql compare balances with
     388-- the ledger at COMMIT, and the users are inserted with their balances
     389-- before the deposit rows that back them. In an auto-commit client
     390-- (DBeaver) every statement would otherwise be checked on its own.
     391
     392BEGIN;
    349393
    350394SET search_path TO project, public;
    … …  
    443487-- Shows a fully-filled market buy and its resulting holding & ledger entry.
    444488-- ============================================================================
    445 INSERT INTO project.orders (id, user_id, market_id, side, type, status, quantity, price, placed_at, executed_at) VALUES
     489-- Imported as already completely filled (filled_quantity = quantity).
     490INSERT INTO project.orders (id, user_id, market_id, side, type, status, quantity, filled_quantity, price, placed_at, executed_at) VALUES
    446491    ('c1111111-1111-1111-1111-111111111111',
    447492     'b1111111-1111-1111-1111-111111111111',
    448493     'a2222222-2222-2222-2222-222222222222',
    449      'buy', 'market', 'executed', 0.5000, 3500.000000,
     494     'buy', 'market', 'executed', 0.5000, 0.5000, 3500.000000,
    450495     now() - interval '1 hour', now() - interval '1 hour');
    451496
    … …  
    457502INSERT INTO project.transactions (user_id, type, amount, currency, related_order, description) VALUES
    458503    ('b1111111-1111-1111-1111-111111111111', 'deposit',  10000.0000, 'USD', NULL,
     504        'Initial virtual deposit'),
     505    ('b2222222-2222-2222-2222-222222222222', 'deposit',   5000.0000, 'USD', NULL,
     506        'Initial virtual deposit'),
     507    ('b3333333-3333-3333-3333-333333333333', 'deposit',   2500.0000, 'USD', NULL,
    459508        'Initial virtual deposit'),
    460509    ('b1111111-1111-1111-1111-111111111111', 'buy',      -1750.0000, 'USD',
    … …  
    482531    ('d2222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111'),
    483532    ('d2222222-2222-2222-2222-222222222222', '55555555-5555-5555-5555-555555555555');
     533
     534COMMIT;
    484535}}}
    485536
    486537== Relational diagram ==
    487538
    488 [[Image(relational_schema.jpg)]]
    489 
    490 Generated in '''Pgadmin''' from the '''live''' `project` schema, in crow's-foot
    491 notation — not drawn by hand, so it is evidence that the deployed database
    492 actually matches the design described above. Each box is a table with its
    493 columns and declared types; key icons mark primary keys and the arrowed lines
    494 are the 12 declared foreign keys.
     539[[Image(relational_diagram_v4.png, 800px)]]
     540
     541Generated in '''DBeaver''' from the '''live''' `project` schema (after
     542`./eduberza -init`), not drawn by hand, so it shows what the deployed database
     543actually contains. Each box is a table with its columns; the key icon marks the
     544primary 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
     546two boxes, so DBeaver draws them on top of each other as one line.
     547
     548The 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
     555Earlier images (`relational_schema.jpg`, `relational_schema_v2.png`,
     556`relational_schema_v3.png`) were exported from pgAdmin, with a different layout
     557and from an older schema. They are kept only as history.
    495558
    496559=== How to regenerate it ===
    497560
    498 '''With pgAdmin 4''', if DBeaver is unavailable — it reads the live schema the same
    499 way, so the result is equivalent in substance:
    500 
    501  1. Connect to the project database.
    502  2. Right-click the database → '''ERD For Database''' (or open a blank ERD and drag
    503     the `project` tables in).
    504  3. Arrange the tables to mirror `ERModel_v03.png`.
    505  4. '''Download image''' → PNG, then convert:
    506     `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.
  • docs/P2-RelationalDesign/wiki/RelationalDesignAIUsage.md

    r0cee8ec r1549dae  
    1212=== Diagram ===
    1313
    14 The student produces `relational_schema.jpg` in DBeaver from the live `project` schema; see [wiki:RelationalDesign] for instructions.
     14`relational_diagram_v4.png` was exported by the student in DBeaver from the live `project` schema, with the tables in the same positions as the entity sets of `ERModel_v05.png`; see [wiki:RelationalDesign] (section "How to regenerate it") for instructions.
    1515
    1616=== Results in details / description ===
    … …  
    9696`trade.go` alone to keep the reservation consistent — the same reasoning
    9797already applied to `avg_price NOT NULL` in session 2.
     98
     99=== Session 4 — 2026-09-29 (Claude Opus 5.5): correction after review ===
     100
     101'''Prompt (student, excerpt):''' I pasted the reviewer's comments on P2:
     102
     103> the `contains` relationship from the model is implemented differently in the database
     104> the `holds` relationship from the model is implemented differently in the database
     105> there may be other inconsistencies that are difficult to spot; the Phase 2 diagram should
     106> have been created using DBeaver with a layout visually similar to the ERD
     107
     108> I suggest correcting only the Phase 1 model so that it matches Phase 2 [...] For Phase 2,
     109> simply update the image according to the requirements.
     110
     111'''Response (AI, summarised):'''
     112
     113 * Explained the mismatch. In the model, `Holds` and `Contains` were M:N relationships, whose table key would be the pair `{user_id, crypto_id}` / `{watchlist_id, crypto_id}`. But `holdings` and `watchlist_items` have their own `id` primary key, which is how an entity set is implemented. Following the reviewer's advice, P1 was corrected (v05, entity sets `Holdings` and `WatchlistItems`), and the database was not changed.
     114 * Found the other inconsistencies between this page and the live schema. The page was missing `users.reserved_balance`, `orders.filled_quantity`, the status `partially_filled`, `market_trades.buy_order_id` / `sell_order_id` and the whole `order_events` table. The "10 tables", "5 indexes" and "the one nullable foreign key" counts were also out of date (really 11 tables, 8 indexes in `schema_creation.sql`, and 3 nullable foreign keys).
     115 * Rewrote [wiki:RelationalDesign]. Each relation is labelled with its entity set and each foreign key with its relationship, and the transformation is a table of all 15 relationships → 15 foreign keys, with `NOT NULL` following participation.
     116 * Wrote export instructions with a table grid that mirrors `ERModel_v05.png`.
     117
     118'''What I decided:''' to correct P1 instead of the database, as the reviewer suggested. I
     119exported `relational_diagram_v4.png` from DBeaver myself, with the tables arranged like the
     120ER diagram.
  • docs/P4-Prototype/BuildInstructions.md

    r0cee8ec r1549dae  
    1313| `psql`      | 16                    | Optional. Only for running the SQL scripts by hand.            |
    1414| 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`.              |
     15| DBeaver     | any recent            | Optional. Only to export `relational_diagram_v4.png`.             |
    1616
    1717About the PostgreSQL version: `docker-compose.yml` uses the image `postgres` without a version
    … …  
    244244
    245245```sh
    246 java -jar TerraER3.11.jar     # then File → Open → docs/P1-ConceptualModel/ERModel_v03.xml
    247 ```
    248 
    249 The current version is `ERModel_v03.xml`. Save new versions as `ERModel_v04.xml` and so on,
     246java -jar TerraER3.11.jar     # then File → Open → docs/P1-ConceptualModel/ERModel_v05.xml
     247```
     248
     249The current version is `ERModel_v05.xml`. Save new versions as `ERModel_v06.xml` and so on,
    250250and export a matching PNG for each. TerraER does not add the extension itself: type `.xml`
    251251yourself, or the file will not reopen.
  • docs/P4-Prototype/wiki/BuildInstructions.md

    r0cee8ec r1549dae  
    1212|| `psql` || 16 || Optional. Only for running the SQL scripts by hand. ||
    1313|| Java || 21 (8+ works) || Optional. Only to open or edit the ER diagram in TerraER. ||
    14 || DBeaver || any recent || Optional. Only to export `relational_schema.jpg`. ||
     14|| DBeaver || any recent || Optional. Only to export `relational_diagram_v4.png`. ||
    1515
    1616About the PostgreSQL version: `docker-compose.yml` uses the image `postgres` without a version
    … …  
    270270
    271271{{{
    272 java -jar TerraER3.11.jar     # then File → Open → docs/P1-ConceptualModel/ERModel_v03.xml
    273 }}}
    274 
    275 The current version is `ERModel_v03.xml`. Save new versions as `ERModel_v04.xml` and so on,
     272java -jar TerraER3.11.jar     # then File → Open → docs/P1-ConceptualModel/ERModel_v05.xml
     273}}}
     274
     275The current version is `ERModel_v05.xml`. Save new versions as `ERModel_v06.xml` and so on,
    276276and export a matching PNG for each. TerraER does not add the extension itself: type `.xml`
    277277yourself, or the file will not reopen.
  • docs/P5-Normalization/Normalization.md

    r0cee8ec r1549dae  
    11# Normalization
    22
    3 This phase deliberately ignores the design from [ERModel](../P1-ConceptualModel/ERModel.md)
    4 (P1) and [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md) (P2) as a starting
    5 point. Instead it starts over from a single flat relation containing every attribute of the
    6 model, derives the functional dependencies that hold on it, and decomposes it formally,
    7 step by step. The
    8 [final section](#final-result-and-discussion) compares what falls out of that process with
    9 the P2 design.
     3This phase does not use the relations of
     4[RelationalDesign](../P2-RelationalDesign/RelationalDesign.md) (P2) as a starting point.
     5It starts from the attributes of [ERModel](../P1-ConceptualModel/ERModel.md) **v05** (P1),
     6put into one de-normalized relation. It states the functional dependencies that the
     7model's rules impose on those attributes, **computes** the keys of that relation from the
     8dependencies, and then decomposes it step by step through 2NF, 3NF and BCNF. Every step is
     9checked for a lossless join and for dependency preservation. The
     10[final section](#final-result-and-discussion) compares the result with P2.
    1011
    1112## De-normalized database form
    1213
    13 ### Building one relation out of the whole model
    14 
    15 The ER model has ten entity/relationship sets carrying attributes (see
    16 [ERModel](../P1-ConceptualModel/ERModel.md)): `Users`, `Cryptos`, `Markets`, `Orders`,
    17 `Transactions`, `MarketTrades`, `MarketCandles`, `Watchlists`, and the two attributed
    18 relationships `Holds` and `Contains`. Eight more relationships (`QuotedOn`, `PlacedOn`,
    19 `Places`, `Records`, `Settles`, `Fills`, `Aggregates`, `Owns`) carry no attributes of their
    20 own — in Chen notation they need none, because the diagram expresses the link itself as a
    21 relationship, not a column. A single flat relation has no such device: the only way to keep
    22 one entity's rows pointed at another's is a plain attribute holding the referenced key,
    23 which is exactly what P2's ER-to-relational transformation already introduces for each of
    24 those eight relationships (`markets.crypto_id`, `orders.market_id`, `orders.user_id`,
    25 `transactions.user_id`, `transactions.related_order`, `market_trades.market_id`,
    26 `market_candles.market_id`, `watchlists.user_id`). Those linking attributes are included
    27 below for that reason — not because they were copied from P2's design, but because a "single
    28 table with everything in it" cannot represent the model at all without them.
    29 
    30 Every attribute name is prefixed by a two-or-three-letter code for the entity/relationship it
    31 came from, because several names repeat across the model (`id`, `created_at`, `quantity`,
    32 `type`, `name`, `price`, `side` all appear more than once) and the de-normalized relation may
    33 not contain duplicate names.
    34 
    35 | Prefix | Origin (P1 entity / relationship) | Attributes |
     14### Which attributes go into the relation
     15
     16The relation contains **the attributes of the ER model and nothing else**. In v05 all
     17attributes belong to the 11 entity sets. None of the 15 relationships has attributes of its
     18own.
     19
     20A relationship adds **no column**. Foreign-key columns such as `crypto_id` or `watchlist_id`
     21belong to the relational model of P2, not to the ER model, so they do not appear here. What a
     22relationship contributes is a **functional dependency** between attributes that are already
     23in the relation. For example, `Contains` (Watchlists 1 : N WatchlistItems) says that every
     24watchlist item is on exactly one watchlist, which is the dependency `WI_ID → W_ID` in the
     25next section. It is not a column `WI_WATCHLIST_ID`.
     26
     27Attribute names are prefixed with the entity set they come from, because several names repeat
     28across the model (`id`, `created_at`, `quantity`, `type`, `name`, `price`, `side`), and one
     29relation cannot contain the same name twice.
     30
     31| Prefix | Entity set (P1) | Attributes |
    3632|---|---|---|
    37 | `U_`  | Users        | `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` |
    38 | `C_`  | Cryptos      | `C_ID, C_SYMBOL, C_NAME, C_CREATED_AT` |
    39 | `M_`  | Markets (+ `QuotedOn`) | `M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` |
    40 | `H_`  | `Holds` (+ surrogate key) | `H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` |
    41 | `O_`  | Orders (+ `PlacedOn`, `Places`) | `O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` |
    42 | `T_`  | Transactions (+ `Records`, `Settles`) | `T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` |
    43 | `MT_` | MarketTrades (+ `Fills`) | `MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` |
    44 | `MC_` | MarketCandles (+ `Aggregates`) | `MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` |
    45 | `W_`  | Watchlists (+ `Owns`) | `W_ID, W_USER_ID, W_NAME, W_CREATED_AT` |
    46 | `WI_` | `Contains` (+ surrogate key) | `WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` |
    47 
    48 `H_ID` and `WI_ID` exist for the same reason they exist in P2: `Holds` and `Contains` are M:N
    49 relationships with their own attributes, and giving each its own surrogate key (rather than
    50 relying solely on the `{user,crypto}` / `{watchlist,crypto}` pair) is the same design choice
    51 already justified in [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md#descriptive-representation-of-the-relational-schema).
    52 
    53 This gives **one relation, `R_EDUBERZA`, of 68 attributes:**
     33| `U_`  | Users          | `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_RESERVED_BALANCE, U_CREATED_AT, U_UPDATED_AT` |
     34| `C_`  | Cryptos        | `C_ID, C_SYMBOL, C_NAME, C_CREATED_AT` |
     35| `M_`  | Markets        | `M_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` |
     36| `H_`  | Holdings       | `H_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` |
     37| `O_`  | Orders         | `O_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_FILLED_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` |
     38| `T_`  | Transactions   | `T_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION` |
     39| `MT_` | MarketTrades   | `MT_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` |
     40| `OE_` | OrderEvents    | `OE_ID, OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE, OE_STATUS_AFTER, OE_CREATED_AT` |
     41| `MC_` | MarketCandles  | `MC_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` |
     42| `W_`  | Watchlists     | `W_ID, W_NAME, W_CREATED_AT` |
     43| `WI_` | WatchlistItems | `WI_ID, WI_ADDED_AT` |
     44
     45That is 64 attributes. **One case needs two more.** `FillsBuy` and `FillsSell` are two
     46different relationships between the same two entity sets, Orders and MarketTrades. A trade
     47can fill one buy order *and* one sell order, which are two different orders. One relation
     48has only one `O_ID` column, and one column cannot hold two different orders in the same
     49tuple. So the order's identifier appears once per **role**, named after the relationship
     50that gives the role:
     51
     52| Attribute | Meaning |
     53|---|---|
     54| `O_ID_FILLSBUY`  | the `id` of Orders, in its role in `FillsBuy` (the buy order a trade filled) |
     55| `O_ID_FILLSSELL` | the `id` of Orders, in its role in `FillsSell` (the sell order a trade filled) |
     56
     57These are not foreign keys copied from P2. They are the ER attribute `Orders.id` itself, once
     58for each of the two relationships. [ERModel](../P1-ConceptualModel/ERModel.md) names these two
     59roles of `Orders` explicitly: *the buy order* of a trade in `FillsBuy`, and *the sell order* in
     60`FillsSell`. This is the only place where the model has two
     61relationships between the same pair of entity sets. Every other relationship is expressed with
     62the attributes above, without renaming.
     63
     64This gives **one relation, `R_EDUBERZA`, of 66 attributes:**
    5465
    5566```
    5667R_EDUBERZA(
    5768  U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE,
    58   U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT,
     69  U_INVESTED_BALANCE, U_RESERVED_BALANCE, U_CREATED_AT, U_UPDATED_AT,
    5970  C_ID, C_SYMBOL, C_NAME, C_CREATED_AT,
    60   M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT,
    61   H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE,
    62   H_CREATED_AT, H_UPDATED_AT,
    63   O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE,
     71  M_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT,
     72  H_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT,
     73  O_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_FILLED_QUANTITY, O_PRICE,
    6474  O_PLACED_AT, O_EXECUTED_AT,
    65   T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT,
    66   T_DESCRIPTION,
    67   MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE,
    68   MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME,
    69   MC_CANDLE_TIME,
    70   W_ID, W_USER_ID, W_NAME, W_CREATED_AT,
    71   WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT
     75  T_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION,
     76  MT_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE,
     77  O_ID_FILLSBUY, O_ID_FILLSSELL,
     78  OE_ID, OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE, OE_STATUS_AFTER, OE_CREATED_AT,
     79  MC_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME,
     80  W_ID, W_NAME, W_CREATED_AT,
     81  WI_ID, WI_ADDED_AT
    7282)
    7383```
    7484
     85To keep the tables below readable, **`X_*`** means the non-identifier attributes of prefix
     86`X_`. For example, `U_*` = `U_USERNAME … U_UPDATED_AT` (9 attributes), and `O_*` =
     87`O_SIDE … O_EXECUTED_AT` (8 attributes). `U_ID`, `O_ID`, … are always written out.
     88
    7589Every attribute is single-valued and atomic (a balance, a timestamp, a symbol, an amount —
    7690nothing here is a list or a nested record), so `R_EDUBERZA` satisfies 1NF as soon as it is
    77 written down. Whether it satisfies anything beyond that is exactly what the rest of this page
     91written down.
     92
     93## Functional dependencies
     94
     95At this point `R_EDUBERZA` is just a set of attributes. It has **no keys yet**. `U_ID`,
     96`O_ID`, … are ordinary attributes of this relation, and which attribute sets are keys of
     97`R_EDUBERZA` is computed in the [next section](#candidate-keys-and-primary-key), from the
     98dependencies below. Each dependency is justified by a rule of the domain, as described in
     99the data requirements of [ERModel](../P1-ConceptualModel/ERModel.md). The rules are of four
     100kinds:
     101
     102- **(I) Identification.** Every value of an identifier (`U_ID`, `C_ID`, …) is given to
     103  exactly one real object: one user, one crypto, one order. That object has exactly one
     104  username, one balance, one price, and so on. So the identifier's value fixes those values.
     105- **(R) 1:N relationship.** In a 1:N relationship, each object on the N side is linked to
     106  exactly one object on the 1 side. So the N side's identifier fixes the 1 side's
     107  identifier. Example: an order is placed by exactly one user (`Places`), so `O_ID → U_ID`.
     108  The opposite direction does not hold: a user places many orders, so `U_ID ↛ O_ID`.
     109- **(U) Uniqueness rule.** A rule of the form "at most one X per Y and Z" gives
     110  `Y, Z → X`.
     111- **(N) Unique natural attribute.** No two users share a username or an email, and no two
     112  cryptos share a symbol.
     113
     114**Only rules of the ER model are used.** The dependencies below come from the rules stated in
     115[ERModel](../P1-ConceptualModel/ERModel.md) v05 and nothing else. The analysis uses the
     116classical definitions (Armstrong's axioms), with no special treatment of `NULL`. Partial
     117relationships (`Settles`, `FillsBuy`, `FillsSell`) are discussed where they matter:
     118under [Canonical cover](#canonical-cover) and in the [discussion](#discussion).
     119
     120| # | Functional dependency | Rule | Why it holds |
     121|---|---|---|---|
     122| FD1  | `U_ID → U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_RESERVED_BALANCE, U_CREATED_AT, U_UPDATED_AT` | I | one user, one value of each |
     123| FD2  | `U_USERNAME → U_ID` | N | usernames are unique |
     124| FD3  | `U_EMAIL → U_ID` | N | emails are unique |
     125| FD4  | `C_ID → C_SYMBOL, C_NAME, C_CREATED_AT` | I | one crypto, one value of each |
     126| FD5  | `C_SYMBOL → C_ID` | N | symbols are unique |
     127| FD6  | `M_ID → M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT, C_ID` | I, R | …and a market is `QuotedOn` exactly one crypto |
     128| FD7  | `C_ID, M_QUOTE_CURRENCY → M_ID` | U | a crypto is quoted at most once per currency |
     129| FD8  | `H_ID → H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT, U_ID, C_ID` | I, R | …and a holding belongs to one user (`Holds`) and is a position in one crypto (`PositionIn`) |
     130| FD9  | `U_ID, C_ID → H_ID` | U | at most one holding per user and crypto |
     131| FD10 | `O_ID → O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_FILLED_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT, U_ID, M_ID` | I, R | …and an order is placed by one user (`Places`) on one market (`PlacedOn`) |
     132| FD11 | `T_ID → T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION, U_ID, O_ID` | I, R | …and a ledger entry belongs to one user (`Records`) and to at most one order (`Settles`) |
     133| FD12 | `MT_ID → MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE, M_ID, O_ID_FILLSBUY, O_ID_FILLSSELL` | I, R | …and a trade happened on one market (`Fills`) and filled at most one buy order (`FillsBuy`) and at most one sell order (`FillsSell`) |
     134| FD13 | `OE_ID → OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE, OE_STATUS_AFTER, OE_CREATED_AT, O_ID` | I, R | …and an event belongs to one order (`Logs`) |
     135| FD14 | `MC_ID → MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME, M_ID` | I, R | …and a candle summarises one market (`Aggregates`) |
     136| FD15 | `M_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` | U | one candle per market, timeframe and bucket |
     137| FD16 | `W_ID → W_NAME, W_CREATED_AT, U_ID` | I, R | …and a watchlist is owned by one user (`Owns`) |
     138| FD17 | `WI_ID → WI_ADDED_AT, W_ID, C_ID` | I, R | …and an item is on one watchlist (`Contains`) and names one crypto (`Lists`) |
     139| FD18 | `W_ID, C_ID → WI_ID` | U | an asset appears at most once per watchlist |
     140
     141**Dependencies that do *not* hold** are as important, because they are why some attributes
     142must be combined in the key later:
     143
     144- The reverse of every (R) dependency, e.g. `U_ID ↛ O_ID`, `M_ID ↛ MT_ID`, `W_ID ↛ WI_ID`.
     145  These are 1:N, not 1:1.
     146- `M_ID, MT_EXECUTED_AT ↛ MT_ID`. Two trades on a market can share a timestamp.
     147- `U_ID, W_NAME ↛ W_ID`. The model does not require list names to be unique per user.
     148- `O_ID_FILLSBUY` and `O_ID_FILLSSELL` determine no other attribute of `R_EDUBERZA` **by any
     149  rule of the ER model**. The order data (`O_SIDE`, `O_PRICE`, …) describes the order in the
     150  `O_ID` column, not the order in a role column. (The database also has a rule that a trade
     151  and the orders it fills are on the same market. That rule is a trigger in P7 relating
     152  several entity sets, not a rule of the ER model, so it is not used here.)
     153
     154### Canonical cover
     155
     156A canonical (minimal) cover is obtained in three steps.
     157
     158**Step 1 — single attribute on the right.** Each FD above is read as one dependency per
     159right-side attribute, e.g. FD6 is `M_ID → M_QUOTE_CURRENCY`, `M_ID → M_IS_ACTIVE`,
     160`M_ID → M_CREATED_AT`, `M_ID → C_ID`.
     161
     162**Step 2 — no extraneous attribute on the left.** Only FD7, FD9, FD15 and FD18 have more than one
     163attribute on the left. For each one, dropping any attribute makes the rule false:
     164
     165| FD | Drop | Counter-example (the smaller left side does not determine the right side) |
     166|---|---|---|
     167| FD7  | `M_QUOTE_CURRENCY` | BTC is quoted in USD *and* in EUR: one `C_ID`, two markets |
     168|      | `C_ID` | USD is the quote currency of many markets |
     169| FD9  | `C_ID` | one user holds several cryptos |
     170|      | `U_ID` | one crypto is held by several users |
     171| FD15 | `M_ID` | every market has a `1h` candle starting at 10:00 |
     172|      | `MC_TIMEFRAME` | a market has a `1m` and a `1h` candle both starting at 10:00 |
     173|      | `MC_CANDLE_TIME` | a market has many `1h` candles |
     174| FD18 | `C_ID` | a watchlist has several items |
     175|      | `W_ID` | a crypto is on several watchlists |
     176
     177**Step 3 — no redundant dependency.** A dependency is redundant if it follows from the others. For
     178almost every dependency, its right-side attribute appears on the right of no other
     179dependency with a different left side (e.g. nothing but `U_ID` determines
     180`U_AVAILABLE_BALANCE`), so it cannot be derived. The candidates worth checking are the
     181identifiers that are reached from several places:
     182
     183- **`T_ID → U_ID` is redundant.** It follows by transitivity from `T_ID → O_ID` (FD11) and
     184  `O_ID → U_ID` (FD10): a ledger entry's user is the user of the order it settles. It is
     185  therefore **removed** from FD11. The derivation is valid only for an entry that has an
     186  order. Every tuple of `R_EDUBERZA` does have one (see the
     187  [discussion](#discussion)), so in the de-normalized relation the removal is correct. The
     188  consequence for deposits, which have no order, is taken up in the discussion.
     189- `H_ID → U_ID`, `H_ID → C_ID`, `WI_ID → W_ID`, `WI_ID → C_ID`, `M_ID → C_ID`, `MT_ID → M_ID`,
     190  `MC_ID → M_ID`, `OE_ID → O_ID`, `W_ID → U_ID` and `O_ID → U_ID`, `O_ID → M_ID`: for
     191  each, no other dependency with a different left side has that attribute on its right
     192  side and a left side reachable from this one, so none can be derived.
     193- The four (U) and three (N) dependencies go "backwards" from a non-identifier to an
     194  identifier. Nothing else produces an identifier from those attributes, so they are not
     195  derivable either.
     196
     197Grouping the single-attribute dependencies back by left side gives FD1–FD18 as listed,
     198except that FD11 loses `U_ID`:
     199
     200| # | Functional dependency (canonical cover) |
     201|---|---|
     202| FD11 | `T_ID → T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION, O_ID` |
     203
     204**FD1–FD18, with this FD11, is the canonical cover.** From here on, "FD11" means this reduced
     205form.
     206
     207## Candidate keys and primary key
     208
     209A candidate key is a minimal set of attributes whose closure under FD1–FD18 is all 66
     210attributes.
     211
     212**Attributes that must be in every key.** `T_ID`, `OE_ID` and `MT_ID` appear on the right side
     213of no dependency. Nothing determines them, so every key must contain them.
     214
     215**Closure of `{T_ID, OE_ID, MT_ID}`:**
     216
     217| Step | Added | Using |
     218|---|---|---|
     219| start | `T_ID, OE_ID, MT_ID` | — |
     220| 1 | `T_*`, `O_ID` | FD11 |
     221| 2 | `OE_*` | FD13 |
     222| 3 | `MT_*`, `M_ID`, `O_ID_FILLSBUY`, `O_ID_FILLSSELL` | FD12 |
     223| 4 | `O_*`, `U_ID` | FD10 |
     224| 5 | `U_*` | FD1 |
     225| 6 | `M_*`, `C_ID` | FD6 |
     226| 7 | `C_*` | FD4 |
     227| 8 | `H_ID` | FD9 (`U_ID` and `C_ID` are both present) |
     228| 9 | `H_*` | FD8 |
     229
     230That is 53 attributes. Still missing are all 8 `MC_` attributes, the 3 `W_` attributes and
     231the 2 `WI_` attributes:
     232
     233- **`MC_`:** only `MC_ID` determines them (FD14), and `MC_ID` is reached only by FD15, which
     234  needs `M_ID` (already present), `MC_TIMEFRAME` and `MC_CANDLE_TIME`. So the key must add
     235  either `MC_ID` or both `MC_TIMEFRAME` and `MC_CANDLE_TIME`. Neither of those two alone is
     236  enough.
     237- **`W_` and `WI_`:** `WI_ID` gives `W_ID` (FD17), and `W_ID` gives `WI_ID` together with
     238  `C_ID`, which is already present (FD18). So adding either `WI_ID` or `W_ID` gives all five.
     239
     240**Candidate keys** (each one's closure is all 66 attributes, and removing any member breaks
     241that, by the argument above):
     242
     243| Key | Attributes |
     244|---|---|
     245| **K1** | `T_ID, OE_ID, MT_ID, MC_ID, WI_ID` |
     246| K2 | `T_ID, OE_ID, MT_ID, MC_ID, W_ID` |
     247| K3 | `T_ID, OE_ID, MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME, WI_ID` |
     248| K4 | `T_ID, OE_ID, MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME, W_ID` |
     249
     250**Primary key: K1.** It consists only of identifiers, and it is the key that remains at the
     251end of the decomposition below.
     252
     253**Prime attributes** (in at least one candidate key): `T_ID, OE_ID, MT_ID, MC_ID,
     254MC_TIMEFRAME, MC_CANDLE_TIME, W_ID, WI_ID`. The other 58 attributes are **non-prime**. The
     255difference matters: 2NF and 3NF only restrict dependencies of non-prime attributes, and BCNF
     256restricts all of them.
     257
     258In words, a tuple of `R_EDUBERZA` puts together one ledger entry, one order event, one
     259trade, one candle and one watchlist item. Everything else in the tuple (the user, the order,
     260the market, the crypto, the holding, the watchlist) follows from those five.
     261
     262**Normal form of `R_EDUBERZA`:** 1NF only. It is not in 2NF, because, for example, `T_AMOUNT`
     263depends on `T_ID` alone, a proper part of K1.
     264
     265## 1NF decomposition
     266
     267No decomposition is needed. Every attribute of `R_EDUBERZA` is atomic and single-valued, and
     268the relation has no repeating groups (see
     269[De-normalized database form](#de-normalized-database-form)).
     270
     271## 2NF decomposition
     272
     273### How every step is described and checked
     274
     275Each step of 2NF, 3NF and BCNF below lists, in this order: the relation analyzed, its
     276dependencies, its candidate keys and primary key, and its normal form; the dependency that
     277violates the next normal form and is used for the split; the two resulting relations, each
     278with its dependencies, keys and normal form; and the dependency-preservation and lossless-join
    78279checks.
    79280
    80 ## Functional dependencies
    81 
    82 ### Canonical cover
    83 
    84 Read directly off the model: each entity's/relationship's own key determines its own
    85 attributes, nothing more. This is already minimal — no functional dependency below has an
    86 extraneous attribute on its left side, and no dependent attribute is repeated on the right
    87 side of more than one dependency, which is what "canonical cover" requires.
    88 
    89 | # | Functional dependency | Source |
     281Every step splits one relation `R` into two: the **extracted** relation `Ri` and the
     282**residual** relation `R'` (what is left of `R`). The same two checks are made each time:
     283
     284- **Lossless join.** The split of `R` into `Ri` and `R'` is lossless if the common attributes
     285  determine one of the two sides: `(Ri ∩ R') → Ri` or `(Ri ∩ R') → R'`. Every step below
     286  extracts `Ri = X ∪ (what X determines)` for some determinant `X` that stays in `R'`. So
     287  `X ⊆ Ri ∩ R'` and `X → Ri`, and the first condition holds.
     288- **Dependency preservation.** Every dependency of the canonical cover must end up with all
     289  its attributes inside one relation. So an attribute is removed from the residual only when
     290  no dependency still waiting in the residual needs it. Otherwise it is extracted **and**
     291  kept.
     292
     293**Relation analyzed first:** `R_EDUBERZA` (66 attributes), dependencies FD1–FD18, candidate
     294keys K1–K4, primary key K1. **Normal form:** 1NF.
     295
     296**Dependencies that violate 2NF.** 2NF forbids a non-prime attribute from depending on a proper
     297part of a candidate key. There are six such partial dependencies:
     298
     299| Part of a key | Non-prime attributes that depend on it | Through |
    90300|---|---|---|
    91 | FD1 | `U_ID → U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` | Users |
    92 | FD2 | `U_USERNAME → U_ID` | Users (`UNIQUE(username)`) |
    93 | FD3 | `U_EMAIL → U_ID` | Users (`UNIQUE(email)`) |
    94 | FD4 | `C_ID → C_SYMBOL, C_NAME, C_CREATED_AT` | Cryptos |
    95 | FD5 | `C_SYMBOL → C_ID` | Cryptos (`UNIQUE(symbol)`) |
    96 | FD6 | `M_ID → M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` | Markets |
    97 | FD7 | `M_CRYPTO_ID, M_QUOTE_CURRENCY → M_ID` | Markets (`UNIQUE(crypto_id, quote_currency)`) |
    98 | FD8 | `H_ID → H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` | Holds |
    99 | FD9 | `H_USER_ID, H_CRYPTO_ID → H_ID` | Holds (`UNIQUE(user_id, crypto_id)`) |
    100 | FD10 | `O_ID → O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` | Orders |
    101 | FD11 | `T_ID → T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` | Transactions |
    102 | FD12 | `MT_ID → MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` | MarketTrades |
    103 | FD13 | `MC_ID → MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` | MarketCandles |
    104 | FD14 | `MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` | MarketCandles (`UNIQUE(market_id, timeframe, candle_time)`) |
    105 | FD15 | `W_ID → W_USER_ID, W_NAME, W_CREATED_AT` | Watchlists |
    106 | FD16 | `WI_ID → WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` | Contains |
    107 | FD17 | `WI_WATCHLIST_ID, WI_CRYPTO_ID → WI_ID` | Contains (`UNIQUE(watchlist_id, crypto_id)`) |
    108 
    109 **Minimality, checked by example (Markets):** could FD7 drop an attribute from its left side?
    110 `M_CRYPTO_ID` alone does not determine `M_ID` — many markets can reference the same crypto in
    111 different quote currencies (that is the entire point of the market entity), so two rows can
    112 share `M_CRYPTO_ID` and disagree on `M_ID`. `M_QUOTE_CURRENCY` alone fails the same way in the
    113 other direction. Neither attribute is extraneous, so the left side of FD7 cannot shrink. The
    114 same check applies to FD9, FD14 and FD17, whose composite left sides come directly from the
    115 `UNIQUE` constraints already justified per-relation in
    116 [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md); none of those constraints
    117 holds on a proper subset of its columns either.
    118 
    119 **No redundant dependency:** each of FD1–FD17 has a right side that is not implied by any
    120 other dependency in the set — for instance, nothing outside FD1 mentions `U_AVAILABLE_BALANCE`,
    121 so FD1 cannot be derived from the rest and cannot be dropped. This set is the canonical cover.
    122 
    123 ### Dependencies carried by foreign keys
    124 
    125 Six attributes above are foreign keys: `M_CRYPTO_ID`, `H_USER_ID`, `H_CRYPTO_ID`,
    126 `O_USER_ID`, `O_MARKET_ID`, `T_USER_ID`, `T_RELATED_ORDER`, `MT_MARKET_ID`, `MC_MARKET_ID`,
    127 `W_USER_ID`, `WI_WATCHLIST_ID`, `WI_CRYPTO_ID` — each one draws its values from the same
    128 domain as some other attribute's key. Because of that, every dependency that holds on the
    129 referenced key also holds, by substitution, on the referencing attribute:
    130 
    131 | Foreign key | References | Therefore also determines |
    132 |---|---|---|
    133 | `M_CRYPTO_ID` | `C_ID` | `C_SYMBOL, C_NAME, C_CREATED_AT` |
    134 | `H_USER_ID` | `U_ID` | all of `U_*` |
    135 | `H_CRYPTO_ID` | `C_ID` | all of `C_*` |
    136 | `O_USER_ID` | `U_ID` | all of `U_*` |
    137 | `O_MARKET_ID` | `M_ID` | all of `M_*`, and transitively all of `C_*` |
    138 | `T_USER_ID` | `U_ID` | all of `U_*` |
    139 | `T_RELATED_ORDER` | `O_ID` | all of `O_*`, and transitively `U_*`, `M_*`, `C_*` (when not null) |
    140 | `MT_MARKET_ID` | `M_ID` | all of `M_*`, transitively `C_*` |
    141 | `MC_MARKET_ID` | `M_ID` | all of `M_*`, transitively `C_*` |
    142 | `W_USER_ID` | `U_ID` | all of `U_*` |
    143 | `WI_WATCHLIST_ID` | `W_ID` | all of `W_*`, transitively `U_*` |
    144 | `WI_CRYPTO_ID` | `C_ID` | all of `C_*` |
    145 
    146 None of these is added to the canonical cover — each is *derivable* from FD1–FD17 by
    147 transitivity plus the foreign-key identity, which is exactly why a canonical cover excludes
    148 them. They matter anyway: they are precisely the transitive dependencies the 3NF check below
    149 has to rule out.
    150 
    151 ## Candidate keys and primary key
    152 
    153 `Orders`, `Transactions`, `MarketTrades`, `MarketCandles`, `Holds`, `Watchlists` and
    154 `Contains` are, with respect to each other, independent record types: nothing about an
    155 order's id says anything about which market-candle row, or which unrelated transaction, or
    156 which watchlist item is in the same tuple of `R_EDUBERZA` — a user can exist with zero of any
    157 of them, and having one order says nothing about how many holdings, trades or candles exist
    158 alongside it. (The one FK that crosses between two of these — `T_RELATED_ORDER` — is
    159 nullable, so it cannot be relied on to always connect a transaction row back to an order.)
    160 That means no proper subset of attributes can functionally determine all 68 attributes of
    161 `R_EDUBERZA`: the only way to pin down a `H_*` value, an `O_*` value, a `T_*` value, an
    162 `MT_*` value, an `MC_*` value, a `W_*` value *and* a `WI_*` value at once is to state one
    163 identifying attribute from each cluster explicitly.
    164 
    165 **Chosen primary key** (closure shown below):
    166 
    167 ```
    168 { U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID }
    169 ```
    170 
    171 **Closure check**, applying FD1–FD17 in turn to this set:
    172 
    173 | Step | Attributes added | Dependency used |
    174 |---|---|---|
    175 | start | `U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID` | — |
    176 | 1 | `U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` | FD1 (`U_ID → …`) |
    177 | 2 | `C_SYMBOL, C_NAME, C_CREATED_AT` | FD4 |
    178 | 3 | `M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` | FD6 |
    179 | 4 | `H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` | FD8 |
    180 | 5 | `O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` | FD10 |
    181 | 6 | `T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` | FD11 |
    182 | 7 | `MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` | FD12 |
    183 | 8 | `MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` | FD13 |
    184 | 9 | `W_USER_ID, W_NAME, W_CREATED_AT` | FD15 |
    185 | 10 | `WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` | FD16 |
    186 
    187 The closure now contains all 68 attributes, so the set is a superkey; removing any one of its
    188 ten attributes drops an entire cluster that nothing else in the set can reach (e.g. drop
    189 `T_ID` and no remaining attribute determines any `T_*` value), so it is minimal — a candidate
    190 key.
    191 
    192 **It is not the only one.** Any attribute that is itself a determinant of a whole cluster can
    193 stand in for that cluster's id — `U_USERNAME` or `U_EMAIL` for `U_ID` (FD2/FD3), `C_SYMBOL`
    194 for `C_ID` (FD5), `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` for `M_ID` (FD7), `{H_USER_ID,
    195 H_CRYPTO_ID}` for `H_ID` (FD9), `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` for `MC_ID`
    196 (FD14), `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` for `WI_ID` (FD17) — giving 3 × 2 × 2 × 2 × 1 × 1 ×
    197 1 × 2 × 1 × 2 = 96 candidate keys in total. The all-surrogate-id combination above is chosen
    198 as **primary key** for the same reason `id` was chosen over `username`/`email`/`symbol`/etc.
    199 per entity in [ERModel](../P1-ConceptualModel/ERModel.md): it is opaque, and none of its parts
    200 are things a user would ever legitimately change.
    201 
    202 **Normal form of `R_EDUBERZA` before decomposition:** 1NF only, and barely that — see 2NF
    203 below. It cannot be in 2NF, 3NF or BCNF, since each of those requires 2NF as a precondition.
    204 
    205 ## 1NF decomposition
    206 
    207 No decomposition happens at this step. 1NF requires atomic, single-valued attributes and no
    208 repeating groups; `R_EDUBERZA` was built that way from the start (every column above is a
    209 single scalar), so the relation already satisfies 1NF as written in
    210 [De-normalized database form](#de-normalized-database-form). The real work starts at 2NF.
    211 
    212 ## 2NF decomposition
    213 
    214 **Relation analyzed:** `R_EDUBERZA`, all 68 attributes, primary key
    215 `{U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID}` (10 attributes), FD1–FD17
    216 in force.
    217 
    218 **Current normal form:** 1NF only (previous section).
    219 
    220 **Violations:** 2NF forbids a non-prime attribute from depending on *part* of a candidate
    221 key. Every single functional dependency in the canonical cover (FD1–FD17) has a left side
    222 that is a **proper subset** of the ten-attribute primary key — `U_ID` alone, `C_ID` alone, …,
    223 down to the two-attribute `{WI_WATCHLIST_ID, WI_CRYPTO_ID}`. There is no non-prime attribute
    224 in `R_EDUBERZA` that depends on the whole ten-attribute key and nothing smaller. In other
    225 words, *every* non-prime attribute violates 2NF at once — the violation is not a handful of
    226 stray columns to peel off, it is the entire relation, because gluing ten independent record
    227 types together under one artificial composite key was never going to satisfy 2NF to begin
    228 with.
    229 
    230 **Decomposition.** This uses 3NF/BCNF **synthesis** (Bernstein's algorithm) rather than the
    231 binary decomposition algorithm: since the canonical cover is already in hand (as the phase
    232 instructions recommend building first), synthesis creates one relation per left-hand side in
    233 the cover directly, instead of hunting for one offending dependency at a time and splitting
    234 in two repeatedly. Grouping FD1–FD17 by determinant produces ten relations:
    235 
    236 | New relation | Attributes | Key(s) | Source FDs |
     301| `T_ID` (K1–K4)  | `T_*`, `O_ID`, and through them `O_*`, `U_ID`, `U_*`, `M_ID`, `M_*`, `C_ID`, `C_*`, `H_ID`, `H_*` | FD11, then FD10, FD1, FD6, FD4, FD9, FD8 |
     302| `OE_ID` (K1–K4) | `OE_*`, `O_ID` | FD13 |
     303| `MT_ID` (K1–K4) | `MT_*`, `M_ID`, `O_ID_FILLSBUY`, `O_ID_FILLSSELL` | FD12 |
     304| `MC_ID` (K1, K2) | `MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME`, `M_ID` | FD14 |
     305| `W_ID` (K2, K4) | `W_*`, `U_ID` | FD16 |
     306| `WI_ID` (K1, K3) | `WI_ADDED_AT`, `C_ID` | FD17 |
     307
     308The table lists the part of a key that each group depends on most directly. It is not the
     309only one: under K3/K4, for example, `MC_OPEN … MC_VOLUME` also depend on
     310`{MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME}`, and under K1/K3 `W_*` depend on `WI_ID` through
     311`W_ID`. These lead to the same relations, so they need no extra steps. `MC_TIMEFRAME`,
     312`MC_CANDLE_TIME` and `W_ID` also depend on parts of keys, but they are prime, so 2NF does not
     313restrict them. They are handled under BCNF.
     314
     315Each step below removes one row of this table, splitting the current relation into two. The
     316**order** is chosen so that no dependency is lost. `T_ID` goes first, because its group is the
     317largest and carries FD1–FD11 with it. Each later step handles a group whose determinant is
     318still in the residual relation.
     319
     320### Step 2NF-1 — partial dependency on `T_ID`
     321
     322- **Relation analyzed:** `R_EDUBERZA` (66 attributes).
     323- **Dependencies:** FD1–FD18. **Candidate keys:** K1–K4. **Primary key:** K1.
     324  **Normal form:** 1NF.
     325- **2NF violations:** all six rows of the table above. **Split first on `T_ID`**, the
     326  largest group (see the order explained above).
     327- **Decomposition dependency:** `T_ID → T_*, O_ID` (FD11), together with everything it
     328  determines transitively (FD10, FD1, FD6, FD4, FD9, FD8). `T_ID` is a proper part of K1, and
     329  `T_AMOUNT`, for example, is non-prime, so this violates 2NF.
     330- **New relation `R_A`** = `{ T_ID, T_*, O_ID, O_*, U_ID, U_*, M_ID, M_*, C_ID, C_*, H_ID, H_* }`
     331  (39 attributes). Dependencies: FD1–FD11. Candidate key and primary key: `T_ID`. Normal form: 2NF (it has a
     332  one-attribute key), but not 3NF (see 3NF).
     333- **Residual relation `S1`** = `R_EDUBERZA − { T_*, O_*, U_*, M_*, C_*, H_ID, H_* }` =
     334  `{ T_ID, O_ID, U_ID, M_ID, C_ID, OE_ID, OE_*, MT_ID, MT_*, O_ID_FILLSBUY, O_ID_FILLSSELL,
     335  MC_ID, MC_*, W_ID, W_*, WI_ID, WI_ADDED_AT }` (32 attributes). `O_ID`, `U_ID`, `M_ID` and
     336  `C_ID` stay, because FD13, FD16, FD12/FD14/FD15 and FD17/FD18 still need them. Dependencies: FD12–FD18, plus the projected dependencies between the identifiers kept here:
     337  `T_ID → O_ID, U_ID, M_ID, C_ID`, `O_ID → U_ID, M_ID, C_ID`, `M_ID → C_ID`,
     338  `OE_ID → U_ID, M_ID, C_ID`, `MT_ID → C_ID`, `MC_ID → C_ID`, `WI_ID → U_ID`.
     339  Candidate keys: K1–K4 (all
     340  their attributes are still here). Normal form: 1NF.
     341- **Dependency preservation:** FD1–FD11 lie entirely in `R_A`, and FD12–FD18 entirely in `S1`. ✓
     342- **Lossless join:** `R_A ∩ S1 = { T_ID, O_ID, U_ID, M_ID, C_ID }` contains `T_ID`, and
     343  `T_ID → R_A`, so `(R_A ∩ S1) → R_A`. ✓
     344
     345### Step 2NF-2 — partial dependency on `OE_ID`
     346
     347- **Relation analyzed:** `S1` (32 attributes). Dependencies: as listed for `S1` in the
     348  previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     349- **Remaining 2NF violations:** the partial dependencies on `OE_ID`, `MT_ID`, `MC_ID`, `W_ID`
     350  and `WI_ID` (table above), and the partial dependencies of the kept identifiers
     351  `O_ID`, `U_ID`, `M_ID`, `C_ID` on `T_ID`. The kept identifiers cannot leave yet, because
     352  other groups still need them. Each one leaves with the last group that needs it (`O_ID` in
     353  2NF-2, `M_ID` in 2NF-4, `U_ID` in 2NF-5, `C_ID` in 2NF-6). **Split first on `OE_ID`**,
     354  because after it no group needs `O_ID` any more.
     355- **Decomposition dependency:** `OE_ID → OE_*, O_ID` (FD13). `OE_ID` is a proper part of K1
     356  and `OE_*` are non-prime.
     357- **New relation `R_B`** = `{ OE_ID, OE_*, O_ID }` (7 attributes). Dependencies: FD13.
     358  Candidate key: `OE_ID`. Normal form: BCNF.
     359- **Residual relation `S2`** = `S1 − { OE_*, O_ID }` (26 attributes). No dependency still
     360  needed in the residual uses `O_ID`. Dependencies: FD12, FD14–FD18, plus the projected `T_ID → U_ID, M_ID, C_ID`,
     361  `OE_ID → U_ID, M_ID, C_ID`, `M_ID → C_ID`, `MT_ID → C_ID`, `MC_ID → C_ID`, `WI_ID → U_ID`.
     362  Candidate keys: K1–K4. Normal form: 1NF.
     363- **Dependency preservation:** FD13 is in `R_B`, and the others are in `S2`.
     364  `T_ID → O_ID` is already kept in `R_A`. ✓
     365- **Lossless join:** `R_B ∩ S2 = { OE_ID }`, and `OE_ID → R_B` (FD13). ✓
     366
     367### Step 2NF-3 — partial dependency on `MT_ID`
     368
     369- **Relation analyzed:** `S2` (26 attributes). Dependencies: as listed for `S2` in the
     370  previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     371- **Remaining 2NF violations:** the groups of `MT_ID`, `MC_ID`, `W_ID`, `WI_ID`, and the kept
     372  identifiers `U_ID`, `M_ID`, `C_ID`. **Split first on `MT_ID`**, the next group. `M_ID` must
     373  still stay for `MC_ID`.
     374- **Decomposition dependency:** `MT_ID → MT_*, M_ID, O_ID_FILLSBUY, O_ID_FILLSSELL` (FD12).
     375- **New relation `R_C`** = `{ MT_ID, MT_*, M_ID, O_ID_FILLSBUY, O_ID_FILLSSELL }`
     376  (9 attributes). Dependencies: FD12. Candidate key: `MT_ID`. Normal form: BCNF.
     377- **Residual relation `S3`** = `S2 − { MT_*, O_ID_FILLSBUY, O_ID_FILLSSELL }` (19 attributes).
     378  `M_ID` stays, because FD14/FD15 need it. Dependencies: FD14–FD18, plus the projected `T_ID → U_ID, M_ID, C_ID`,
     379  `OE_ID → U_ID, M_ID, C_ID`, `MT_ID → M_ID, C_ID`, `M_ID → C_ID`, `MC_ID → C_ID`,
     380  `WI_ID → U_ID`.
     381  Candidate keys: K1–K4. Normal form: 1NF.
     382- **Dependency preservation:** FD12 is in `R_C`, and FD14–FD18 are in `S3`. ✓
     383- **Lossless join:** `R_C ∩ S3 = { MT_ID, M_ID }` contains `MT_ID`, and `MT_ID → R_C`
     384  (FD12). ✓
     385
     386### Step 2NF-4 — partial dependency on `MC_ID`
     387
     388- **Relation analyzed:** `S3` (19 attributes). Dependencies: as listed for `S3` in the
     389  previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     390- **Remaining 2NF violations:** the groups of `MC_ID`, `W_ID`, `WI_ID`, and the kept
     391  identifiers `U_ID`, `M_ID`, `C_ID`. **Split first on `MC_ID`**, the last group that needs
     392  `M_ID`, so `M_ID` can leave with it.
     393- **Decomposition dependency:** `MC_ID → MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, M_ID`
     394  (FD14). `MC_ID` is a proper part of K1. The prime `MC_TIMEFRAME` and `MC_CANDLE_TIME` also go
     395  into the new relation, so that FD15, which needs them with `M_ID` and `MC_ID`, is preserved.
     396- **New relation `R_D`** = `{ MC_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE,
     397  MC_VOLUME, MC_CANDLE_TIME, M_ID }` (9 attributes). Dependencies: FD14, FD15. Candidate keys:
     398  `MC_ID` and `{M_ID, MC_TIMEFRAME, MC_CANDLE_TIME}`. Normal form: BCNF.
     399- **Residual relation `S4`** = `S3 − { MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, M_ID }`
     400  (13 attributes). `MC_TIMEFRAME` and `MC_CANDLE_TIME` are prime and stay. Dependencies: FD16–FD18, plus the projected `T_ID → U_ID, C_ID`, `OE_ID → U_ID, C_ID`,
     401  `MT_ID → C_ID`, `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME, C_ID`, `WI_ID → U_ID`.
     402  Candidate keys: K1–K4. Normal form: 1NF.
     403- **Dependency preservation:** FD14 and FD15 are in `R_D`, and FD16–FD18 are in `S4`. ✓
     404- **Lossless join:** `R_D ∩ S4 = { MC_ID, MC_TIMEFRAME, MC_CANDLE_TIME }` contains `MC_ID`,
     405  and `MC_ID → R_D` (FD14). ✓
     406
     407### Step 2NF-5 — partial dependency on `W_ID`
     408
     409- **Relation analyzed:** `S4` (13 attributes). Dependencies: as listed for `S4` in the
     410  previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     411- **Remaining 2NF violations:** the groups of `W_ID` and `WI_ID`, and the kept identifiers
     412  `U_ID`, `C_ID`. **Split first on `W_ID`**, the last group that needs `U_ID`.
     413- **Decomposition dependency:** `W_ID → W_NAME, W_CREATED_AT, U_ID` (FD16). `W_ID` is a proper
     414  part of K2.
     415- **New relation `R_E`** = `{ W_ID, W_NAME, W_CREATED_AT, U_ID }` (4 attributes). Dependencies:
     416  FD16. Candidate key: `W_ID`. Normal form: BCNF.
     417- **Residual relation `S5`** = `S4 − { W_NAME, W_CREATED_AT, U_ID }` (10 attributes).
     418  Dependencies: FD17, FD18, plus the projected `T_ID → C_ID`, `OE_ID → C_ID`, `MT_ID → C_ID`,
     419  `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME, C_ID`.
     420  Candidate keys: K1–K4. Normal form: 1NF.
     421- **Dependency preservation:** FD16 is in `R_E`, and FD17 and FD18 are in `S5`. ✓
     422- **Lossless join:** `R_E ∩ S5 = { W_ID }`, and `W_ID → R_E` (FD16). ✓
     423
     424### Step 2NF-6 — partial dependency on `WI_ID`
     425
     426- **Relation analyzed:** `S5` (10 attributes). Dependencies: as listed for `S5` in the
     427  previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     428- **Remaining 2NF violations:** the group of `WI_ID`, and the kept identifier `C_ID`.
     429  **Split on `WI_ID`**, the last group that needs `C_ID`.
     430- **Decomposition dependency:** `WI_ID → WI_ADDED_AT, W_ID, C_ID` (FD17). `WI_ID` is a proper
     431  part of K1, and `WI_ADDED_AT` and `C_ID` are non-prime.
     432- **New relation `R_F`** = `{ WI_ID, WI_ADDED_AT, W_ID, C_ID }` (4 attributes). Dependencies:
     433  FD17, FD18. Candidate keys: `WI_ID` and `{W_ID, C_ID}`. Normal form: BCNF.
     434- **Residual relation `S6`** = `S5 − { WI_ADDED_AT, C_ID }` =
     435  `{ T_ID, OE_ID, MT_ID, MC_ID, MC_TIMEFRAME, MC_CANDLE_TIME, W_ID, WI_ID }` (8 attributes).
     436  `W_ID` is prime and stays. Dependencies: no dependency of the cover lies entirely inside
     437  `S6`. The projected ones are `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME` and `WI_ID → W_ID`, plus
     438  derived ones such as `MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` and `MT_ID, W_ID → WI_ID`.
     439  Candidate keys: K1–K4. Normal form: 3NF, because every attribute is prime (and so 2NF).
     440- **Dependency preservation:** FD17 and FD18 are in `R_F`. ✓
     441- **Lossless join:** `R_F ∩ S6 = { WI_ID, W_ID }` contains `WI_ID`, and `WI_ID → R_F`
     442  (FD17). ✓
     443
     444**Result of 2NF:** `R_A`, `R_B`, `R_C`, `R_D`, `R_E`, `R_F`, `S6`. All seven are in 2NF (`R_A`
     445only 2NF, `S6` 3NF, the rest BCNF). All 18 dependencies are preserved: FD1–FD11 in `R_A`,
     446FD13 in `R_B`, FD12 in `R_C`, FD14–FD15 in `R_D`, FD16 in `R_E`, FD17–FD18 in `R_F`.
     447
     448## 3NF decomposition
     449
     450Only `R_A` is not in 3NF. `R_B`–`R_F` are already in BCNF, and `S6` is in 3NF (all its
     451attributes are prime).
     452
     453**Dependencies that violate 3NF in `R_A`.** 3NF forbids a non-prime attribute from depending on
     454a key only **transitively**, through a determinant that is not a superkey. The only key of
     455`R_A` is `T_ID`, but inside `R_A`:
     456
     457- `U_ID → U_*` (FD1), `U_USERNAME → U_ID` (FD2), `U_EMAIL → U_ID` (FD3)
     458- `C_ID → C_*` (FD4), `C_SYMBOL → C_ID` (FD5)
     459- `U_ID, C_ID → H_ID` (FD9), `H_ID → H_*, U_ID, C_ID` (FD8)
     460- `M_ID → M_*, C_ID` (FD6), `C_ID, M_QUOTE_CURRENCY → M_ID` (FD7)
     461- `O_ID → O_*, U_ID, M_ID` (FD10)
     462
     463None of these determinants is a superkey of `R_A`. For example, `T_ID → O_ID → O_PRICE` is a
     464transitive dependency of the non-prime `O_PRICE` on the key.
     465
     466**Order of the steps.** An attribute can leave the residual only after every dependency that
     467needs it has been extracted. FD9 needs `U_ID` and `C_ID` together, and extracting `Markets`
     468takes `C_ID` out of the residual, so `Holdings` must come before `Markets`. Extracting
     469`Orders` takes `M_ID` and `U_ID` out, so `Orders` comes last. The dependencies are therefore
     470taken from the "leaves" of the chain `T_ID → O_ID → {U_ID, M_ID → C_ID}` inward.
     471
     472### Step 3NF-1 — transitive dependency through `U_ID`
     473
     474- **Relation analyzed:** `R_A` (39 attributes), dependencies FD1–FD11, candidate key and
     475  primary candidate key and primary key `T_ID`, normal form 2NF.
     476- **3NF violations:** all five groups listed above. **Split first on `U_ID`**. It is a leaf
     477  of the chain: its dependents determine nothing outside its own group.
     478- **Decomposition dependency:** `U_ID → U_*` (FD1). `U_ID` is not a superkey of `R_A`.
     479- **New relation `R_USERS`** = `{ U_ID, U_* }` (10 attributes). Dependencies: FD1, FD2, FD3.
     480  Candidate keys: `U_ID`, `U_USERNAME`, `U_EMAIL`. Primary key: `U_ID`. Normal form: BCNF.
     481- **Residual relation `R_A1`** = `R_A − U_*` (30 attributes). Dependencies: FD4–FD11, which
     482  also imply `T_ID → H_ID` and `O_ID → H_ID` (through `U_ID, C_ID`).
     483  Candidate key and primary key: `T_ID`. Normal form: 2NF.
     484- **Dependency preservation:** FD1–FD3 are in `R_USERS`, and FD4–FD11 are in `R_A1`. ✓
     485- **Lossless join:** `R_USERS ∩ R_A1 = { U_ID }`, and `U_ID → R_USERS` (FD1). ✓
     486
     487### Step 3NF-2 — transitive dependency through `C_ID`
     488
     489- **Relation analyzed:** `R_A1` (30 attributes), dependencies FD4–FD11, candidate key and primary key `T_ID`, normal form
     490  2NF.
     491- **3NF violations:** `C_ID → C_*`, `U_ID, C_ID → H_ID → H_*`, `M_ID → M_*, C_ID`,
     492  `O_ID → O_*, U_ID, M_ID`. **Split first on `C_ID`**, the next leaf.
     493- **Decomposition dependency:** `C_ID → C_*` (FD4).
     494- **New relation `R_CRYPTO`** = `{ C_ID, C_* }` (4 attributes). Dependencies: FD4, FD5.
     495  Candidate keys: `C_ID`, `C_SYMBOL`. Primary key: `C_ID`. Normal form: BCNF.
     496- **Residual relation `R_A2`** = `R_A1 − C_*` (27 attributes). Dependencies: FD6–FD11. Candidate
     497  key and primary key: `T_ID`. Normal form: 2NF.
     498- **Dependency preservation:** FD4 and FD5 are in `R_CRYPTO`, and FD6–FD11 are in `R_A2`. ✓
     499- **Lossless join:** `R_CRYPTO ∩ R_A2 = { C_ID }`, and `C_ID → R_CRYPTO` (FD4). ✓
     500
     501### Step 3NF-3 — transitive dependency through `{U_ID, C_ID}`
     502
     503- **Relation analyzed:** `R_A2` (27 attributes), dependencies FD6–FD11, candidate key and primary key `T_ID`, normal form
     504  2NF.
     505- **3NF violations:** `U_ID, C_ID → H_ID → H_*`, `M_ID → M_*, C_ID`, `O_ID → O_*, U_ID, M_ID`.
     506  **Split first on `{U_ID, C_ID}`**, because it must come before `Markets` takes `C_ID` away.
     507- **Decomposition dependency:** `U_ID, C_ID → H_ID` (FD9), together with `H_ID → H_*`
     508  (FD8).
     509- **New relation `R_HOLDINGS`** = `{ H_ID, H_*, U_ID, C_ID }` (8 attributes). Dependencies:
     510  FD8, FD9. Candidate keys: `H_ID`, `{U_ID, C_ID}`. Primary key: `H_ID`. Normal form: BCNF.
     511- **Residual relation `R_A3`** = `R_A2 − { H_ID, H_* }` (21 attributes). Dependencies: FD6,
     512  FD7, FD10, FD11. Candidate key and primary key: `T_ID`. Normal form: 2NF.
     513- **Dependency preservation:** FD8 and FD9 are in `R_HOLDINGS`, and the others are in `R_A3`. ✓
     514- **Lossless join:** `R_HOLDINGS ∩ R_A3 = { U_ID, C_ID }`, and `U_ID, C_ID → H_ID → H_*`, so
     515  `{U_ID, C_ID} → R_HOLDINGS`. ✓
     516
     517### Step 3NF-4 — transitive dependency through `M_ID`
     518
     519- **Relation analyzed:** `R_A3` (21 attributes), dependencies FD6, FD7, FD10, FD11, key
     520  `T_ID`, normal form 2NF.
     521- **3NF violations:** `M_ID → M_*, C_ID` and `O_ID → O_*, U_ID, M_ID`. **Split first on
     522  `M_ID`**, because `Orders` still needs `M_ID`.
     523- **Decomposition dependency:** `M_ID → M_*, C_ID` (FD6).
     524- **New relation `R_MARKETS`** = `{ M_ID, M_*, C_ID }` (5 attributes). Dependencies: FD6, FD7.
     525  Candidate keys: `M_ID`, `{C_ID, M_QUOTE_CURRENCY}`. Primary key: `M_ID`. Normal form: BCNF.
     526- **Residual relation `R_A4`** = `R_A3 − { M_*, C_ID }` (17 attributes). No dependency left
     527  needs `C_ID`. Dependencies: FD10, FD11. Candidate key and primary key: `T_ID`. Normal form: 2NF.
     528- **Dependency preservation:** FD6 and FD7 are in `R_MARKETS`, and FD10 and FD11 are in
     529  `R_A4`. ✓
     530- **Lossless join:** `R_MARKETS ∩ R_A4 = { M_ID }`, and `M_ID → R_MARKETS` (FD6). ✓
     531
     532### Step 3NF-5 — transitive dependency through `O_ID`
     533
     534- **Relation analyzed:** `R_A4` = `{ T_ID, T_*, O_ID, O_*, U_ID, M_ID }` (17 attributes),
     535  dependencies FD10, FD11, candidate key and primary key `T_ID`, normal form 2NF.
     536- **3NF violations:** only `O_ID → O_*, U_ID, M_ID`. **Split on `O_ID`**.
     537- **Decomposition dependency:** `O_ID → O_*, U_ID, M_ID` (FD10).
     538- **New relation `R_ORDERS`** = `{ O_ID, O_*, U_ID, M_ID }` (11 attributes). Dependencies:
     539  FD10. Candidate key: `O_ID`. Normal form: BCNF.
     540- **Residual relation `R_TRANSACTIONS`** = `R_A4 − { O_*, U_ID, M_ID }` = `{ T_ID, T_*, O_ID }`
     541  (7 attributes). Dependencies: FD11. Candidate key: `T_ID`. Normal form: BCNF. Keeping `U_ID`
     542  here would have left the transitive dependency `T_ID → O_ID → U_ID` inside the relation.
     543  `T_ID → U_ID` was removed from the cover as redundant, so nothing is lost.
     544- **Dependency preservation:** FD10 is in `R_ORDERS`, and FD11 is in `R_TRANSACTIONS`. ✓
     545- **Lossless join:** `R_ORDERS ∩ R_TRANSACTIONS = { O_ID }`, and `O_ID → R_ORDERS`
     546  (FD10). ✓
     547
     548**Result of 3NF:** `R_USERS`, `R_CRYPTO`, `R_HOLDINGS`, `R_MARKETS`, `R_ORDERS`,
     549`R_TRANSACTIONS` (from `R_A`), and `R_B`, `R_C`, `R_D`, `R_E`, `R_F`, `S6` unchanged. 12
     550relations, all in 3NF, and all except `S6` in BCNF. All 18 dependencies are preserved.
     551
     552## BCNF if possible
     553
     554BCNF requires **every** determinant of a non-trivial dependency to be a superkey, even when
     555the dependent attribute is prime.
     556
     557| Relation | Dependencies in force | Determinants | All superkeys? |
    237558|---|---|---|---|
    238 | `R_USERS` | `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` | `U_ID`, `U_USERNAME`, `U_EMAIL` | FD1, FD2, FD3 |
    239 | `R_CRYPTO` | `C_ID, C_SYMBOL, C_NAME, C_CREATED_AT` | `C_ID`, `C_SYMBOL` | FD4, FD5 |
    240 | `R_MARKETS` | `M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` | `M_ID`, `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` | FD6, FD7 |
    241 | `R_HOLDINGS` | `H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` | `H_ID`, `{H_USER_ID, H_CRYPTO_ID}` | FD8, FD9 |
    242 | `R_ORDERS` | `O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` | `O_ID` | FD10 |
    243 | `R_TRANSACTIONS` | `T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` | `T_ID` | FD11 |
    244 | `R_MARKET_TRADES` | `MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` | `MT_ID` | FD12 |
    245 | `R_MARKET_CANDLES` | `MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` | `MC_ID`, `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` | FD13, FD14 |
    246 | `R_WATCHLISTS` | `W_ID, W_USER_ID, W_NAME, W_CREATED_AT` | `W_ID` | FD15 |
    247 | `R_WATCHLIST_ITEMS` | `WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` | `WI_ID`, `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` | FD16, FD17 |
    248 
    249 Every one of these ten relations now has **all** of its non-prime attributes depending on its
    250 **whole** key (in every case there is only one non-composite or one designated key doing the
    251 determining, so 2NF holds trivially in each).
    252 
    253 **Dependency preservation.** FD1–FD17 is the canonical cover of `R_EDUBERZA`. Each FD's
    254 determinant and every one of its dependent attributes land inside exactly one of the ten new
    255 relations (see the "Source FDs" column above — no FD is split across two relations). The
    256 union of the FDs that hold on `R_USERS, …, R_WATCHLIST_ITEMS` is therefore exactly FD1–FD17
    257 again: nothing was lost.
    258 
    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 
    265 The chase decides whether a decomposition `R = R1 ∪ … ∪ Rn` is lossless under a set of
    266 functional dependencies. Build a tableau with one column per attribute of `R` and one row per
    267 relation `Ri`. In row `i`, put a distinguished symbol `a` in every column of `Ri` and a unique
    268 symbol `b_i` in every other column. Then repeat, until nothing changes: for each FD `X → Y`,
    269 whenever two rows agree on all of `X`, make them agree on `Y`. If they disagree, an `a` wins,
    270 otherwise one `b` replaces the other. **The decomposition is lossless exactly when some row
    271 ends up with `a` in every column.**
    272 
    273 All attributes of one cluster (`U_*`, `C_*`, `M_*`, …) always appear together, and FD1–FD17
    274 never mix clusters. So each cluster is one column group below: `a` means every column of the
    275 group holds a distinguished symbol, and `b` means none of them does. The foreign-key
    276 attributes (`H_USER_ID`, `O_MARKET_ID`, …) belong to their own cluster (`H_*`, `O_*`, …), not
    277 to 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*
    283 R_USERS        a   b   b   b   b   b   b   b   b   b
    284 R_CRYPTO       b   a   b   b   b   b   b   b   b   b
    285 R_MARKETS      b   b   a   b   b   b   b   b   b   b
    286 R_HOLDINGS     b   b   b   a   b   b   b   b   b   b
    287 R_ORDERS       b   b   b   b   a   b   b   b   b   b
    288 R_TRANSACTIONS b   b   b   b   b   a   b   b   b   b
    289 R_MARKET_TR.   b   b   b   b   b   b   a   b   b   b
    290 R_MARKET_CA.   b   b   b   b   b   b   b   a   b   b
    291 R_WATCHLISTS   b   b   b   b   b   b   b   b   a   b
    292 R_WATCHLIST_I. b   b   b   b   b   b   b   b   b   a
    293 ```
    294 
    295 Every 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
    297 left side. But only one row has `a`s in that cluster, and the `b`s of different rows are
    298 all different, so no two rows ever agree on any left side. **The chase changes nothing, and
    299 no row becomes all `a`.** Under FD1–FD17 alone, the ten relations are *not* guaranteed to
    300 join back to `R_EDUBERZA`. This is not an accident of this model. It is exactly why
    301 Bernstein's synthesis algorithm has a final step: *if no synthesised relation contains a
    302 candidate 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,
    305 W_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*
    310 R_KEY          a·  a·  a·  a·  a·  a·  a·  a·  a·  a·
    311 (the ten rows of step 1 unchanged)
    312 ```
    313 
    314 Now FD1 `U_ID → U_*` fires: row `R_KEY` and row `R_USERS` both have `a` in `U_ID`, so they
    315 must 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*`),
    317 FD12 (`MT*`), FD13 (`MC*`), FD15 (`W*`) and FD16 (`WI*`):
    318 
    319 ```
    320               U*  C*  M*  H*  O*  T*  MT* MC* W*  WI*
    321 R_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
    325 lossless.**
    326 
    327 **Why `R_KEY` is not kept in the final schema.** An instance of `R_KEY` would only record
    328 which 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
    331 cross product of the ten ID sets and would carry no information. The same independence means
    332 the join dependency `⋈[R_USERS, …, R_WATCHLIST_ITEMS]` holds on `R_EDUBERZA` by construction.
    333 Under that dependency the ten relations alone already reconstruct it: their natural join, with
    334 no common attributes, is exactly that cross product. The chase makes this reasoning explicit.
    335 FDs by themselves cannot prove the join lossless; you need either the key relation or the
    336 independence of the clusters. That was hidden in the earlier "foreign key equals primary key"
    337 argument, which described the equi-joins the application runs, not the natural join the
    338 lossless-join property is about.
    339 
    340 ## 3NF decomposition
    341 
    342 **Relations analyzed:** each of the ten relations produced above, individually.
    343 
    344 For each relation, 3NF asks whether any non-prime attribute is *transitively* dependent on a
    345 key — i.e. determined by another non-prime attribute rather than directly by the key. This is
    346 exactly where the foreign-key-carried dependencies from
    347 [Dependencies carried by foreign keys](#dependencies-carried-by-foreign-keys) have to be
    348 checked, because that table is precisely the list of "dependency that would cause a problem at
    349 the next higher normal form" the phase template asks for.
    350 
    351 **Worked example — `R_MARKETS`.** Its key `M_ID` determines `M_CRYPTO_ID`, and
    352 `M_CRYPTO_ID → C_SYMBOL, C_NAME, C_CREATED_AT` also holds (`M_CRYPTO_ID` draws its values from
    353 `C_ID`'s domain). If `C_SYMBOL`, `C_NAME` and `C_CREATED_AT` were still columns of
    354 `R_MARKETS`, this would be exactly the transitive dependency `M_ID → M_CRYPTO_ID → C_SYMBOL`
    355 that violates 3NF. They are not: the 2NF step above already put them in `R_CRYPTO`, keyed
    356 directly by `C_ID` (FD4), because FD4 — not the derived `M_CRYPTO_ID → C_SYMBOL` — is what the
    357 canonical cover actually contains. `R_MARKETS` itself has no attribute that determines another
    358 non-prime attribute of `R_MARKETS`; the transitive dependency is real, but it points *out* of
    359 the relation, not within it.
    360 
    361 The same reasoning applies to every other foreign key in the list: `H_USER_ID`/`H_CRYPTO_ID`,
    362 `O_USER_ID`/`O_MARKET_ID`, `T_USER_ID`/`T_RELATED_ORDER`, `MT_MARKET_ID`, `MC_MARKET_ID`,
    363 `W_USER_ID`, `WI_WATCHLIST_ID`/`WI_CRYPTO_ID` are all foreign keys sitting *alongside* a
    364 non-key attribute set that depends only on their own relation's key, never on the foreign key
    365 itself. None of `R_USERS`, `R_CRYPTO`, `R_HOLDINGS`, `R_ORDERS`, `R_TRANSACTIONS`,
    366 `R_MARKET_TRADES`, `R_MARKET_CANDLES`, `R_WATCHLISTS`, `R_WATCHLIST_ITEMS` has a non-prime
    367 attribute that another non-prime attribute of the *same* relation determines.
    368 
    369 **Conclusion:** synthesising directly from the canonical cover in the 2NF step already
    370 avoided every transitive dependency — there is nothing left to decompose for 3NF. All ten
    371 relations from the previous section satisfy 3NF unchanged.
    372 
    373 ## BCNF if possible
    374 
    375 **Relations analyzed:** the same ten relations, checked against the stricter BCNF rule: every
    376 determinant of every functional dependency that holds on the relation must be a candidate key
    377 of that relation (3NF allows an exception when the dependent side is prime; BCNF does not).
    378 
    379 | Relation | Functional dependencies in force | Determinant | Is it a candidate key? |
    380 |---|---|---|---|
    381 | `R_USERS` | FD1, FD2, FD3 | `U_ID`, `U_USERNAME`, `U_EMAIL` | Yes — all three are candidate keys |
    382 | `R_CRYPTO` | FD4, FD5 | `C_ID`, `C_SYMBOL` | Yes — both candidate keys |
    383 | `R_MARKETS` | FD6, FD7 | `M_ID`, `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` | Yes — both candidate keys |
    384 | `R_HOLDINGS` | FD8, FD9 | `H_ID`, `{H_USER_ID, H_CRYPTO_ID}` | Yes — both candidate keys |
    385 | `R_ORDERS` | FD10 | `O_ID` | Yes — the only candidate key |
    386 | `R_TRANSACTIONS` | FD11 | `T_ID` | Yes — the only candidate key |
    387 | `R_MARKET_TRADES` | FD12 | `MT_ID` | Yes — the only candidate key |
    388 | `R_MARKET_CANDLES` | FD13, FD14 | `MC_ID`, `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` | Yes — both candidate keys |
    389 | `R_WATCHLISTS` | FD15 | `W_ID` | Yes — the only candidate key |
    390 | `R_WATCHLIST_ITEMS` | FD16, FD17 | `WI_ID`, `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` | Yes — both candidate keys |
    391 
    392 Every determinant in every relation is one of that relation's own candidate keys. **All ten
    393 relations are already in BCNF** — the highest of the four normal forms this phase asks for,
    394 reached in the same step that fixed 2NF. This is not a coincidence: it happens because the
    395 canonical cover already grouped each relation's own key directly against its own attributes
    396 with no attribute appearing on the right side of two different relations' dependencies, which
    397 is exactly what synthesis from a canonical cover guarantees when, as here, none of the
    398 per-cluster functional dependencies overlap.
    399 
    400 No further decomposition is possible or necessary; splitting any of the ten relations further
    401 would only separate attributes that already depend on the *whole* key of a BCNF relation,
    402 which cannot fix anything and only costs a join.
     559| `R_USERS` | FD1, FD2, FD3 | `U_ID`, `U_USERNAME`, `U_EMAIL` | yes |
     560| `R_CRYPTO` | FD4, FD5 | `C_ID`, `C_SYMBOL` | yes |
     561| `R_MARKETS` | FD6, FD7 | `M_ID`, `{C_ID, M_QUOTE_CURRENCY}` | yes |
     562| `R_HOLDINGS` | FD8, FD9 | `H_ID`, `{U_ID, C_ID}` | yes |
     563| `R_ORDERS` | FD10 | `O_ID` | yes |
     564| `R_TRANSACTIONS` | FD11 | `T_ID` | yes |
     565| `R_B` | FD13 | `OE_ID` | yes |
     566| `R_C` | FD12 | `MT_ID` | yes |
     567| `R_D` | FD14, FD15 | `MC_ID`, `{M_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` | yes |
     568| `R_E` | FD16 | `W_ID` | yes |
     569| `R_F` | FD17, FD18 | `WI_ID`, `{W_ID, C_ID}` | yes |
     570| `S6` | `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME`; `WI_ID → W_ID`; derived ones such as `MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` and `MT_ID, W_ID → WI_ID` | `MC_ID`, `WI_ID`, `{MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME}`, `{MT_ID, W_ID}`, … | **no** |
     571
     572**Dependencies that violate BCNF — only in `S6`.** `MC_ID` determines `MC_TIMEFRAME` and
     573`MC_CANDLE_TIME`, and `WI_ID` determines `W_ID`, but neither `MC_ID` nor `WI_ID` is a superkey
     574of `S6`. 3NF allowed this because the dependent attributes are prime. BCNF does not. The derived
     575dependencies all involve `W_ID` or `MC_TIMEFRAME`/`MC_CANDLE_TIME`, so they disappear once the
     576two steps below remove those attributes.
     577
     578### Step BCNF-1 — `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME`
     579
     580- **Relation analyzed:** `S6` (8 attributes), dependencies as in the table above, candidate
     581  keys K1–K4, primary key K1, normal form 3NF.
     582- **BCNF violations:** `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME` and `WI_ID → W_ID`, and the
     583  derived ones that depend on them. **Split first on `MC_ID`**. The order does not matter
     584  here, because the two violations share no attribute.
     585- **Decomposition dependency:** `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME`. `MC_ID` is not a
     586  superkey of `S6`.
     587- **New relation** `{ MC_ID, MC_TIMEFRAME, MC_CANDLE_TIME }`. Dependencies:
     588  `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME`. Key: `MC_ID`. Normal form: BCNF. It is a
     589  projection of `R_D`, which already contains these attributes with the same key, so it adds no
     590  information and is merged into `R_D`.
     591- **Residual relation `S7`** = `{ T_ID, OE_ID, MT_ID, MC_ID, W_ID, WI_ID }` (6 attributes).
     592  Dependencies: `WI_ID → W_ID`, and derived ones such as `MT_ID, W_ID → WI_ID`. Candidate
     593  keys: `{T_ID, OE_ID, MT_ID, MC_ID, WI_ID}` (K1) and `{T_ID, OE_ID, MT_ID, MC_ID, W_ID}` (K2).
     594  Normal form: 3NF.
     595- **Dependency preservation:** no dependency of the cover is affected. FD14 and FD15 are in
     596  `R_D`. ✓
     597- **Lossless join:** the intersection is `{ MC_ID }`, and `MC_ID → { MC_ID, MC_TIMEFRAME,
     598  MC_CANDLE_TIME }`. ✓
     599
     600### Step BCNF-2 — `WI_ID → W_ID`
     601
     602- **Relation analyzed:** `S7` (6 attributes), dependencies `WI_ID → W_ID` and derived ones,
     603  candidate keys K1, K2, primary key K1, normal form 3NF.
     604- **BCNF violations:** only `WI_ID → W_ID` (and the derived `MT_ID, W_ID → WI_ID`). **Split on
     605  `WI_ID`**.
     606- **Decomposition dependency:** `WI_ID → W_ID`. `WI_ID` is not a superkey of `S7`.
     607- **New relation** `{ WI_ID, W_ID }`. Dependencies: `WI_ID → W_ID`. Key: `WI_ID`. Normal
     608  form: BCNF. For the same reason as in BCNF-1, it is merged into `R_F`.
     609- **Residual relation `R_KEY`** = `{ T_ID, OE_ID, MT_ID, MC_ID, WI_ID }` (5 attributes). No
     610  non-trivial dependency holds among these attributes. Candidate key: all five (= K1).
     611  Normal form: BCNF.
     612- **Dependency preservation:** no dependency of the cover is affected. FD17 and FD18 are in
     613  `R_F`. The derived dependencies of `S6`/`S7` follow from FD12, FD15, FD17 and FD18, which
     614  are all preserved. ✓
     615- **Lossless join:** the intersection is `{ WI_ID }`, and `WI_ID → { WI_ID, W_ID }`. ✓
     616
     617**Result: every relation is in BCNF.** The decomposition into these 12 relations is lossless
     618(each of the 13 binary steps passed the test) and preserves all 18 dependencies of the
     619canonical cover.
    403620
    404621## Final result and discussion
    405622
    406623### Normalized relational model
     624
     625Each relation is followed by its keys (primary key first). An attribute that is the
     626identifier of another relation is marked `→` with that relation.
    407627
    408628```
    409629R_USERS          (U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH,
    410                    U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT)
     630                  U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_RESERVED_BALANCE,
     631                  U_CREATED_AT, U_UPDATED_AT)
     632                  keys: U_ID; U_USERNAME; U_EMAIL
    411633R_CRYPTO         (C_ID, C_SYMBOL, C_NAME, C_CREATED_AT)
    412 R_MARKETS        (M_ID, M_CRYPTO_ID → R_CRYPTO, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT)
    413 R_HOLDINGS       (H_ID, H_USER_ID → R_USERS, H_CRYPTO_ID → R_CRYPTO, H_QUANTITY,
    414                    H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT)
    415 R_ORDERS         (O_ID, O_USER_ID → R_USERS, O_MARKET_ID → R_MARKETS, O_SIDE, O_TYPE,
    416                    O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT)
    417 R_TRANSACTIONS   (T_ID, T_USER_ID → R_USERS, T_TYPE, T_AMOUNT, T_CURRENCY,
    418                    T_RELATED_ORDER → R_ORDERS, T_CREATED_AT, T_DESCRIPTION)
    419 R_MARKET_TRADES  (MT_ID, MT_MARKET_ID → R_MARKETS, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY,
    420                    MT_SIDE, MT_SOURCE)
    421 R_MARKET_CANDLES (MC_ID, MC_MARKET_ID → R_MARKETS, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW,
    422                    MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME)
    423 R_WATCHLISTS     (W_ID, W_USER_ID → R_USERS, W_NAME, W_CREATED_AT)
    424 R_WATCHLIST_ITEMS(WI_ID, WI_WATCHLIST_ID → R_WATCHLISTS, WI_CRYPTO_ID → R_CRYPTO, WI_ADDED_AT)
     634                  keys: C_ID; C_SYMBOL
     635R_MARKETS        (M_ID, C_ID → R_CRYPTO, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT)
     636                  keys: M_ID; {C_ID, M_QUOTE_CURRENCY}
     637R_HOLDINGS       (H_ID, U_ID → R_USERS, C_ID → R_CRYPTO, H_QUANTITY,
     638                  H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT)
     639                  keys: H_ID; {U_ID, C_ID}
     640R_ORDERS         (O_ID, U_ID → R_USERS, M_ID → R_MARKETS, O_SIDE, O_TYPE, O_STATUS,
     641                  O_QUANTITY, O_FILLED_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT)
     642                  key: O_ID
     643R_TRANSACTIONS   (T_ID, O_ID → R_ORDERS, T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT,
     644                  T_DESCRIPTION)
     645                  key: T_ID
     646R_MARKET_TRADES  (MT_ID, M_ID → R_MARKETS, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY,
     647                  MT_SIDE, MT_SOURCE, O_ID_FILLSBUY → R_ORDERS (nullable),
     648                  O_ID_FILLSSELL → R_ORDERS (nullable))              [= R_C]
     649                  key: MT_ID
     650R_ORDER_EVENTS   (OE_ID, O_ID → R_ORDERS, OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE,
     651                  OE_STATUS_AFTER, OE_CREATED_AT)                     [= R_B]
     652                  key: OE_ID
     653R_MARKET_CANDLES (MC_ID, M_ID → R_MARKETS, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW,
     654                  MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME)                [= R_D]
     655                  keys: MC_ID; {M_ID, MC_TIMEFRAME, MC_CANDLE_TIME}
     656R_WATCHLISTS     (W_ID, U_ID → R_USERS, W_NAME, W_CREATED_AT)         [= R_E]
     657                  key: W_ID
     658R_WATCHLIST_ITEMS(WI_ID, W_ID → R_WATCHLISTS, C_ID → R_CRYPTO, WI_ADDED_AT)  [= R_F]
     659                  keys: WI_ID; {W_ID, C_ID}
     660R_KEY            (T_ID, OE_ID, MT_ID, MC_ID, WI_ID)                   [= R_KEY]
     661                  key: all five
    425662```
    426663
    427 Ten relations, every one in BCNF, connected by the eleven foreign keys spelled out above.
    428 
    429664### Discussion
    430665
    431 **This is the P2 design.** Strip the `U_`/`C_`/`M_`/… prefixes back to plain column names and
    432 `R_USERS, R_CRYPTO, R_MARKETS, R_HOLDINGS, R_ORDERS, R_TRANSACTIONS, R_MARKET_TRADES,
    433 R_MARKET_CANDLES, R_WATCHLISTS, R_WATCHLIST_ITEMS` are, attribute for attribute and key for
    434 key, `users, crypto, markets, holdings, orders, transactions, market_trades, market_candles,
    435 watchlists, watchlist_items` from
    436 [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md). Every foreign key matches,
    437 every candidate key matches (including the less obvious composite ones — `{user_id,
    438 crypto_id}` on `holdings`, `{crypto_id, quote_currency}` on `markets`, `{market_id, timeframe,
    439 candle_time}` on `market_candles`), and the normal form matches (P2 already claimed 3NF; this
    440 phase shows the stronger result that the design is actually in BCNF).
    441 
    442 That is not a coincidence of two people happening to agree — it is what should happen when a
    443 design is derived correctly twice by two different methods from the same underlying model:
    444 P2 got here by applying the standard ER-to-relational transformation rules (each entity
    445 becomes a table on its own key, each attributed M:N relationship becomes a table on the
    446 combined key, each attributeless 1:N relationship becomes a foreign key on the "many" side).
    447 This phase got here by ignoring that transformation entirely, writing down only the
    448 attributes and the functional dependencies they obey, and mechanically applying 2NF/3NF/BCNF
    449 synthesis. Landing on the same ten relations either means the P2 transformation rules are
    450 sound for this particular model (which they are, for exactly the reason [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md#normalisation)
    451 already argued: single-column UUID primary keys everywhere rule out partial dependencies by
    452 construction, and no non-key attribute references another non-key attribute anywhere in the
    453 model, which rules out transitive dependencies too), or it is a coincidence spanning ten
    454 independently-checked relations and dozens of functional dependencies — the first explanation
    455 is the only credible one.
    456 
    457 **The one substantive difference** is `holdings.avg_price`, which P2 documents as a
    458 *derived* attribute — the running weighted-average buy price, recomputable from the `buy` rows
    459 in `transactions` — kept as a stored column anyway for read performance
    460 ([RelationalDesign](../P2-RelationalDesign/RelationalDesign.md#normalisation) calls this out
    461 explicitly as an accepted denormalisation). Nothing in this phase's functional-dependency
    462 analysis can see that `H_AVG_PRICE` is derivable from `T_*` rows rather than stored
    463 independently — FD8 (`H_ID → H_AVG_PRICE`) is a perfectly ordinary functional dependency
    464 either way, because *derivability from a different relation's rows* is a property of the data
    465 and the application logic that maintains it (see
    466 [UseCase0004](../P3-UseCaseModel/UseCase0004.md)'s `ON CONFLICT … DO UPDATE`), not something
    467 that shows up as a violation of any single-relation normal form. Formal normalization and "no
    468 column is a cached computation of other columns" are related but different concerns; this
    469 phase only checked the first one.
    470 
    471 **Which design is used going forward:** P2's, unchanged. Since the two designs coincide
    472 exactly, "restructuring the database objects" means confirming there is nothing to change
    473 rather than writing new DDL. [`server/db/schema_creation.sql`](../../server/db/schema_creation.sql)
    474 already matches `R_USERS`…`R_WATCHLIST_ITEMS` column-for-column (including
    475 `holdings.reserved_quantity`, added between P2 and this phase — see
    476 [RelationalDesignAIUsage](../P2-RelationalDesign/RelationalDesignAIUsage.md#session-3--2026-09-16)
    477 — which is `H_RESERVED_QUANTITY` above, correctly grouped under `R_HOLDINGS`'s key alongside
    478 `H_QUANTITY` and not treated as needing a relation of its own). P4's prototype
    479 (`server/trade.go`, `server/portfolio.go`) keeps working against the same schema without
    480 change. [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md) has been updated with a
    481 short note pointing here as the formal validation of its normal-form claim.
     666**The eleven data relations are the P2 design, with one difference** (`transactions.user_id`,
     667explained below). Each relation is one entity set of the ER model:
     668
     669| P5 relation | P2 table | How the relationships appear |
     670|---|---|---|
     671| `R_USERS` | `users` | — |
     672| `R_CRYPTO` | `crypto` | — |
     673| `R_MARKETS` | `markets` | `C_ID` = `crypto_id` (`QuotedOn`) |
     674| `R_HOLDINGS` | `holdings` | `U_ID` = `user_id` (`Holds`), `C_ID` = `crypto_id` (`PositionIn`) |
     675| `R_ORDERS` | `orders` | `U_ID` = `user_id` (`Places`), `M_ID` = `market_id` (`PlacedOn`) |
     676| `R_TRANSACTIONS` | `transactions` | `O_ID` = `related_order` (`Settles`); P2 also stores `user_id` (`Records`), see below |
     677| `R_MARKET_TRADES` | `market_trades` | `M_ID` = `market_id` (`Fills`), `O_ID_FILLSBUY` = `buy_order_id`, `O_ID_FILLSSELL` = `sell_order_id` |
     678| `R_ORDER_EVENTS` | `order_events` | `O_ID` = `order_id` (`Logs`) |
     679| `R_MARKET_CANDLES` | `market_candles` | `M_ID` = `market_id` (`Aggregates`) |
     680| `R_WATCHLISTS` | `watchlists` | `U_ID` = `user_id` (`Owns`) |
     681| `R_WATCHLIST_ITEMS` | `watchlist_items` | `W_ID` = `watchlist_id` (`Contains`), `C_ID` = `crypto_id` (`Lists`) |
     682
     683The two methods produce the foreign keys differently. In P2 they come from a transformation
     684rule: a 1:N relationship becomes a column on the N side. Here, each one appears because a
     685dependency of kind (R), for example `O_ID → U_ID`, keeps the other entity's identifier in the
     686same relation as the entity that depends on it. The candidate keys also match, including the
     687composite ones (`{C_ID, M_QUOTE_CURRENCY}`, `{U_ID, C_ID}`, `{M_ID, MC_TIMEFRAME,
     688MC_CANDLE_TIME}`, `{W_ID, C_ID}`). They are exactly the `UNIQUE` constraints in
     689[`schema_creation.sql`](../../server/db/schema_creation.sql).
     690
     691**The one difference: `transactions.user_id`.** The decomposition drops `U_ID` from
     692`R_TRANSACTIONS`, because `T_ID → U_ID` follows from `T_ID → O_ID` and `O_ID → U_ID`. That is
     693correct for every ledger entry that settles an order. It does not work for a **deposit**.
     694`Settles` is partial, so a deposit has no order, and without `user_id` a deposit would have no
     695owner at all. The de-normalized relation cannot show this case. Every one of its tuples
     696contains an order (every key contains `OE_ID`, and every order event has an order), so a
     697ledger entry without an order cannot appear in it. P2 therefore keeps `user_id` (the
     698relationship `Records`) as a deliberate exception. As a result, the implemented
     699`transactions` table is in **2NF but not in 3NF** (`related_order → user_id` is a transitive
     700dependency), and this is by design. For entries with an order,
     701`transactions.user_id` repeats the order's user. The only code that sets `related_order` (the buy
     702and sell inserts in `advanced_db.sql`) writes the user and the id of the same order row. No
     703database constraint enforces this.
     704
     705**Two order columns in `market_trades`.** `FillsBuy` and `FillsSell` needed two role
     706attributes already in the de-normalized relation, and both end up in `R_MARKET_TRADES`.
     707They correspond to `buy_order_id` and `sell_order_id`.
     708
     709**`R_KEY` belongs to the formal result, but it is not implemented as a table.** It is the
     710relation that contains a key of `R_EDUBERZA`, and the lossless-join result above holds for all
     71112 relations *including* it. It records no fact of the domain. It only says which ledger
     712entry, order event, trade, candle and watchlist item were put into the same tuple, and that
     713combination exists only because we started from one single relation. Not implementing it is
     714an implementation decision. The eleven implemented tables are not claimed to reconstruct
     715`R_EDUBERZA` on their own. They keep every attribute and every dependency of the canonical
     716cover, and that is what the application needs.
     717
     718**`holdings.avg_price`** is shown as a *derived* attribute in the ER model: it can be
     719recomputed from the buy history. It is still stored, and that is a deliberate
     720denormalisation (see [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md#normalisation)).
     721Normalisation cannot detect this. `H_ID → H_AVG_PRICE` is an ordinary functional dependency,
     722because "derivable from rows of another entity" is a property of the application logic
     723that maintains the value (see [UseCase0004](../P3-UseCaseModel/UseCase0004.md),
     724`ON CONFLICT … DO UPDATE`), not a dependency between attributes of one tuple.
     725
     726**Which design is used going forward:** P2's, unchanged. The eleven data relations coincide
     727with the eleven tables of [`schema_creation.sql`](../../server/db/schema_creation.sql) and
     728[`advanced_db.sql`](../../server/db/advanced_db.sql) column for column, except for the
     729deliberately kept `transactions.user_id` explained above. So there are no database objects
     730to restructure, and the prototype and the reports of P6/P7 keep working against the same
     731schema.
  • docs/P5-Normalization/wiki/Normalization.md

    r0cee8ec r1549dae  
    11= Normalization =
    22
    3 This phase deliberately ignores the design from [wiki:ERModel]
    4 (P1) and [wiki:RelationalDesign] (P2) as a starting
    5 point. Instead it starts over from a single flat relation containing every attribute of the
    6 model, derives the functional dependencies that hold on it, and decomposes it formally,
    7 step by step. The
    8 final section compares what falls out of that process with
    9 the P2 design.
     3This phase does not use the relations of
     4[wiki:RelationalDesign] (P2) as a starting point.
     5It starts from the attributes of [wiki:ERModel] '''v05''' (P1),
     6put into one de-normalized relation. It states the functional dependencies that the
     7model's rules impose on those attributes, '''computes''' the keys of that relation from the
     8dependencies, and then decomposes it step by step through 2NF, 3NF and BCNF. Every step is
     9checked for a lossless join and for dependency preservation. The
     10final section compares the result with P2.
    1011
    1112== De-normalized database form ==
    1213
    13 === Building one relation out of the whole model ===
    14 
    15 The ER model has ten entity/relationship sets carrying attributes (see
    16 [wiki:ERModel]): `Users`, `Cryptos`, `Markets`, `Orders`,
    17 `Transactions`, `MarketTrades`, `MarketCandles`, `Watchlists`, and the two attributed
    18 relationships `Holds` and `Contains`. Eight more relationships (`QuotedOn`, `PlacedOn`,
    19 `Places`, `Records`, `Settles`, `Fills`, `Aggregates`, `Owns`) carry no attributes of their
    20 own — in Chen notation they need none, because the diagram expresses the link itself as a
    21 relationship, not a column. A single flat relation has no such device: the only way to keep
    22 one entity's rows pointed at another's is a plain attribute holding the referenced key,
    23 which is exactly what P2's ER-to-relational transformation already introduces for each of
    24 those eight relationships (`markets.crypto_id`, `orders.market_id`, `orders.user_id`,
    25 `transactions.user_id`, `transactions.related_order`, `market_trades.market_id`,
    26 `market_candles.market_id`, `watchlists.user_id`). Those linking attributes are included
    27 below for that reason — not because they were copied from P2's design, but because a "single
    28 table with everything in it" cannot represent the model at all without them.
    29 
    30 Every attribute name is prefixed by a two-or-three-letter code for the entity/relationship it
    31 came from, because several names repeat across the model (`id`, `created_at`, `quantity`,
    32 `type`, `name`, `price`, `side` all appear more than once) and the de-normalized relation may
    33 not contain duplicate names.
    34 
    35 ||= Prefix =||= Origin (P1 entity / relationship) =||= Attributes =||
    36 || `U_` || Users || `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` ||
     14=== Which attributes go into the relation ===
     15
     16The relation contains '''the attributes of the ER model and nothing else'''. In v05 all
     17attributes belong to the 11 entity sets. None of the 15 relationships has attributes of its
     18own.
     19
     20A relationship adds '''no column'''. Foreign-key columns such as `crypto_id` or `watchlist_id`
     21belong to the relational model of P2, not to the ER model, so they do not appear here. What a
     22relationship contributes is a '''functional dependency''' between attributes that are already
     23in the relation. For example, `Contains` (Watchlists 1 : N !WatchlistItems) says that every
     24watchlist item is on exactly one watchlist, which is the dependency `WI_ID → W_ID` in the
     25next section. It is not a column `WI_WATCHLIST_ID`.
     26
     27Attribute names are prefixed with the entity set they come from, because several names repeat
     28across the model (`id`, `created_at`, `quantity`, `type`, `name`, `price`, `side`), and one
     29relation cannot contain the same name twice.
     30
     31||= Prefix =||= Entity set (P1) =||= Attributes =||
     32|| `U_` || Users || `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_RESERVED_BALANCE, U_CREATED_AT, U_UPDATED_AT` ||
    3733|| `C_` || Cryptos || `C_ID, C_SYMBOL, C_NAME, C_CREATED_AT` ||
    38 || `M_` || Markets (+ `QuotedOn`) || `M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` ||
    39 || `H_` || `Holds` (+ surrogate key) || `H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` ||
    40 || `O_` || Orders (+ `PlacedOn`, `Places`) || `O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` ||
    41 || `T_` || Transactions (+ `Records`, `Settles`) || `T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` ||
    42 || `MT_` || !MarketTrades (+ `Fills`) || `MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` ||
    43 || `MC_` || !MarketCandles (+ `Aggregates`) || `MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` ||
    44 || `W_` || Watchlists (+ `Owns`) || `W_ID, W_USER_ID, W_NAME, W_CREATED_AT` ||
    45 || `WI_` || `Contains` (+ surrogate key) || `WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` ||
    46 
    47 `H_ID` and `WI_ID` exist for the same reason they exist in P2: `Holds` and `Contains` are M:N
    48 relationships with their own attributes, and giving each its own surrogate key (rather than
    49 relying solely on the `{user,crypto}` / `{watchlist,crypto}` pair) is the same design choice
    50 already justified in [wiki:RelationalDesign] (section "Descriptive representation of the relational schema").
    51 
    52 This gives '''one relation, `R_EDUBERZA`, of 68 attributes:'''
     34|| `M_` || Markets || `M_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` ||
     35|| `H_` || Holdings || `H_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` ||
     36|| `O_` || Orders || `O_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_FILLED_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` ||
     37|| `T_` || Transactions || `T_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION` ||
     38|| `MT_` || !MarketTrades || `MT_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` ||
     39|| `OE_` || !OrderEvents || `OE_ID, OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE, OE_STATUS_AFTER, OE_CREATED_AT` ||
     40|| `MC_` || !MarketCandles || `MC_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` ||
     41|| `W_` || Watchlists || `W_ID, W_NAME, W_CREATED_AT` ||
     42|| `WI_` || !WatchlistItems || `WI_ID, WI_ADDED_AT` ||
     43
     44That is 64 attributes. '''One case needs two more.''' `FillsBuy` and `FillsSell` are two
     45different relationships between the same two entity sets, Orders and !MarketTrades. A trade
     46can fill one buy order ''and'' one sell order, which are two different orders. One relation
     47has only one `O_ID` column, and one column cannot hold two different orders in the same
     48tuple. So the order's identifier appears once per '''role''', named after the relationship
     49that gives the role:
     50
     51||= Attribute =||= Meaning =||
     52|| `O_ID_FILLSBUY` || the `id` of Orders, in its role in `FillsBuy` (the buy order a trade filled) ||
     53|| `O_ID_FILLSSELL` || the `id` of Orders, in its role in `FillsSell` (the sell order a trade filled) ||
     54
     55These are not foreign keys copied from P2. They are the ER attribute `Orders.id` itself, once
     56for each of the two relationships. [wiki:ERModel] names these two
     57roles of `Orders` explicitly: ''the buy order'' of a trade in `FillsBuy`, and ''the sell order'' in
     58`FillsSell`. This is the only place where the model has two
     59relationships between the same pair of entity sets. Every other relationship is expressed with
     60the attributes above, without renaming.
     61
     62This gives '''one relation, `R_EDUBERZA`, of 66 attributes:'''
    5363
    5464{{{
    5565R_EDUBERZA(
    5666  U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE,
    57   U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT,
     67  U_INVESTED_BALANCE, U_RESERVED_BALANCE, U_CREATED_AT, U_UPDATED_AT,
    5868  C_ID, C_SYMBOL, C_NAME, C_CREATED_AT,
    59   M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT,
    60   H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE,
    61   H_CREATED_AT, H_UPDATED_AT,
    62   O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE,
     69  M_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT,
     70  H_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT,
     71  O_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_FILLED_QUANTITY, O_PRICE,
    6372  O_PLACED_AT, O_EXECUTED_AT,
    64   T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT,
    65   T_DESCRIPTION,
    66   MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE,
    67   MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME,
    68   MC_CANDLE_TIME,
    69   W_ID, W_USER_ID, W_NAME, W_CREATED_AT,
    70   WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT
     73  T_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION,
     74  MT_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE,
     75  O_ID_FILLSBUY, O_ID_FILLSSELL,
     76  OE_ID, OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE, OE_STATUS_AFTER, OE_CREATED_AT,
     77  MC_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME,
     78  W_ID, W_NAME, W_CREATED_AT,
     79  WI_ID, WI_ADDED_AT
    7180)
    7281}}}
    7382
     83To keep the tables below readable, '''`X_*`''' means the non-identifier attributes of prefix
     84`X_`. For example, `U_*` = `U_USERNAME … U_UPDATED_AT` (9 attributes), and `O_*` =
     85`O_SIDE … O_EXECUTED_AT` (8 attributes). `U_ID`, `O_ID`, … are always written out.
     86
    7487Every attribute is single-valued and atomic (a balance, a timestamp, a symbol, an amount —
    7588nothing here is a list or a nested record), so `R_EDUBERZA` satisfies 1NF as soon as it is
    76 written down. Whether it satisfies anything beyond that is exactly what the rest of this page
     89written down.
     90
     91== Functional dependencies ==
     92
     93At this point `R_EDUBERZA` is just a set of attributes. It has '''no keys yet'''. `U_ID`,
     94`O_ID`, … are ordinary attributes of this relation, and which attribute sets are keys of
     95`R_EDUBERZA` is computed in the next section, from the
     96dependencies below. Each dependency is justified by a rule of the domain, as described in
     97the data requirements of [wiki:ERModel]. The rules are of four
     98kinds:
     99
     100 * '''(I) Identification.''' Every value of an identifier (`U_ID`, `C_ID`, …) is given to exactly one real object: one user, one crypto, one order. That object has exactly one username, one balance, one price, and so on. So the identifier's value fixes those values.
     101 * '''(R) 1:N relationship.''' In a 1:N relationship, each object on the N side is linked to exactly one object on the 1 side. So the N side's identifier fixes the 1 side's identifier. Example: an order is placed by exactly one user (`Places`), so `O_ID → U_ID`. The opposite direction does not hold: a user places many orders, so `U_ID ↛ O_ID`.
     102 * '''(U) Uniqueness rule.''' A rule of the form "at most one X per Y and Z" gives `Y, Z → X`.
     103 * '''(N) Unique natural attribute.''' No two users share a username or an email, and no two cryptos share a symbol.
     104
     105'''Only rules of the ER model are used.''' The dependencies below come from the rules stated in
     106[wiki:ERModel] v05 and nothing else. The analysis uses the
     107classical definitions (Armstrong's axioms), with no special treatment of `NULL`. Partial
     108relationships (`Settles`, `FillsBuy`, `FillsSell`) are discussed where they matter:
     109under Canonical cover and in the discussion.
     110
     111||= # =||= Functional dependency =||= Rule =||= Why it holds =||
     112|| FD1 || `U_ID → U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_RESERVED_BALANCE, U_CREATED_AT, U_UPDATED_AT` || I || one user, one value of each ||
     113|| FD2 || `U_USERNAME → U_ID` || N || usernames are unique ||
     114|| FD3 || `U_EMAIL → U_ID` || N || emails are unique ||
     115|| FD4 || `C_ID → C_SYMBOL, C_NAME, C_CREATED_AT` || I || one crypto, one value of each ||
     116|| FD5 || `C_SYMBOL → C_ID` || N || symbols are unique ||
     117|| FD6 || `M_ID → M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT, C_ID` || I, R || …and a market is `QuotedOn` exactly one crypto ||
     118|| FD7 || `C_ID, M_QUOTE_CURRENCY → M_ID` || U || a crypto is quoted at most once per currency ||
     119|| FD8 || `H_ID → H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT, U_ID, C_ID` || I, R || …and a holding belongs to one user (`Holds`) and is a position in one crypto (`PositionIn`) ||
     120|| FD9 || `U_ID, C_ID → H_ID` || U || at most one holding per user and crypto ||
     121|| FD10 || `O_ID → O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_FILLED_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT, U_ID, M_ID` || I, R || …and an order is placed by one user (`Places`) on one market (`PlacedOn`) ||
     122|| FD11 || `T_ID → T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION, U_ID, O_ID` || I, R || …and a ledger entry belongs to one user (`Records`) and to at most one order (`Settles`) ||
     123|| FD12 || `MT_ID → MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE, M_ID, O_ID_FILLSBUY, O_ID_FILLSSELL` || I, R || …and a trade happened on one market (`Fills`) and filled at most one buy order (`FillsBuy`) and at most one sell order (`FillsSell`) ||
     124|| FD13 || `OE_ID → OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE, OE_STATUS_AFTER, OE_CREATED_AT, O_ID` || I, R || …and an event belongs to one order (`Logs`) ||
     125|| FD14 || `MC_ID → MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME, M_ID` || I, R || …and a candle summarises one market (`Aggregates`) ||
     126|| FD15 || `M_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` || U || one candle per market, timeframe and bucket ||
     127|| FD16 || `W_ID → W_NAME, W_CREATED_AT, U_ID` || I, R || …and a watchlist is owned by one user (`Owns`) ||
     128|| FD17 || `WI_ID → WI_ADDED_AT, W_ID, C_ID` || I, R || …and an item is on one watchlist (`Contains`) and names one crypto (`Lists`) ||
     129|| FD18 || `W_ID, C_ID → WI_ID` || U || an asset appears at most once per watchlist ||
     130
     131'''Dependencies that do ''not'' hold''' are as important, because they are why some attributes
     132must be combined in the key later:
     133
     134 * The reverse of every (R) dependency, e.g. `U_ID ↛ O_ID`, `M_ID ↛ MT_ID`, `W_ID ↛ WI_ID`. These are 1:N, not 1:1.
     135 * `M_ID, MT_EXECUTED_AT ↛ MT_ID`. Two trades on a market can share a timestamp.
     136 * `U_ID, W_NAME ↛ W_ID`. The model does not require list names to be unique per user.
     137 * `O_ID_FILLSBUY` and `O_ID_FILLSSELL` determine no other attribute of `R_EDUBERZA` '''by any rule of the ER model'''. The order data (`O_SIDE`, `O_PRICE`, …) describes the order in the `O_ID` column, not the order in a role column. (The database also has a rule that a trade and the orders it fills are on the same market. That rule is a trigger in P7 relating several entity sets, not a rule of the ER model, so it is not used here.)
     138
     139=== Canonical cover ===
     140
     141A canonical (minimal) cover is obtained in three steps.
     142
     143'''Step 1 — single attribute on the right.''' Each FD above is read as one dependency per
     144right-side attribute, e.g. FD6 is `M_ID → M_QUOTE_CURRENCY`, `M_ID → M_IS_ACTIVE`,
     145`M_ID → M_CREATED_AT`, `M_ID → C_ID`.
     146
     147'''Step 2 — no extraneous attribute on the left.''' Only FD7, FD9, FD15 and FD18 have more than one
     148attribute on the left. For each one, dropping any attribute makes the rule false:
     149
     150||= FD =||= Drop =||= Counter-example (the smaller left side does not determine the right side) =||
     151|| FD7 || `M_QUOTE_CURRENCY` || BTC is quoted in USD ''and'' in EUR: one `C_ID`, two markets ||
     152||  || `C_ID` || USD is the quote currency of many markets ||
     153|| FD9 || `C_ID` || one user holds several cryptos ||
     154||  || `U_ID` || one crypto is held by several users ||
     155|| FD15 || `M_ID` || every market has a `1h` candle starting at 10:00 ||
     156||  || `MC_TIMEFRAME` || a market has a `1m` and a `1h` candle both starting at 10:00 ||
     157||  || `MC_CANDLE_TIME` || a market has many `1h` candles ||
     158|| FD18 || `C_ID` || a watchlist has several items ||
     159||  || `W_ID` || a crypto is on several watchlists ||
     160
     161'''Step 3 — no redundant dependency.''' A dependency is redundant if it follows from the others. For
     162almost every dependency, its right-side attribute appears on the right of no other
     163dependency with a different left side (e.g. nothing but `U_ID` determines
     164`U_AVAILABLE_BALANCE`), so it cannot be derived. The candidates worth checking are the
     165identifiers that are reached from several places:
     166
     167 * '''`T_ID → U_ID` is redundant.''' It follows by transitivity from `T_ID → O_ID` (FD11) and `O_ID → U_ID` (FD10): a ledger entry's user is the user of the order it settles. It is therefore '''removed''' from FD11. The derivation is valid only for an entry that has an order. Every tuple of `R_EDUBERZA` does have one (see the discussion), so in the de-normalized relation the removal is correct. The consequence for deposits, which have no order, is taken up in the discussion.
     168 * `H_ID → U_ID`, `H_ID → C_ID`, `WI_ID → W_ID`, `WI_ID → C_ID`, `M_ID → C_ID`, `MT_ID → M_ID`, `MC_ID → M_ID`, `OE_ID → O_ID`, `W_ID → U_ID` and `O_ID → U_ID`, `O_ID → M_ID`: for each, no other dependency with a different left side has that attribute on its right side and a left side reachable from this one, so none can be derived.
     169 * The four (U) and three (N) dependencies go "backwards" from a non-identifier to an identifier. Nothing else produces an identifier from those attributes, so they are not derivable either.
     170
     171Grouping the single-attribute dependencies back by left side gives FD1–FD18 as listed,
     172except that FD11 loses `U_ID`:
     173
     174||= # =||= Functional dependency (canonical cover) =||
     175|| FD11 || `T_ID → T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT, T_DESCRIPTION, O_ID` ||
     176
     177'''FD1–FD18, with this FD11, is the canonical cover.''' From here on, "FD11" means this reduced
     178form.
     179
     180== Candidate keys and primary key ==
     181
     182A candidate key is a minimal set of attributes whose closure under FD1–FD18 is all 66
     183attributes.
     184
     185'''Attributes that must be in every key.''' `T_ID`, `OE_ID` and `MT_ID` appear on the right side
     186of no dependency. Nothing determines them, so every key must contain them.
     187
     188'''Closure of `{T_ID, OE_ID, MT_ID}`:'''
     189
     190||= Step =||= Added =||= Using =||
     191|| start || `T_ID, OE_ID, MT_ID` || — ||
     192|| 1 || `T_*`, `O_ID` || FD11 ||
     193|| 2 || `OE_*` || FD13 ||
     194|| 3 || `MT_*`, `M_ID`, `O_ID_FILLSBUY`, `O_ID_FILLSSELL` || FD12 ||
     195|| 4 || `O_*`, `U_ID` || FD10 ||
     196|| 5 || `U_*` || FD1 ||
     197|| 6 || `M_*`, `C_ID` || FD6 ||
     198|| 7 || `C_*` || FD4 ||
     199|| 8 || `H_ID` || FD9 (`U_ID` and `C_ID` are both present) ||
     200|| 9 || `H_*` || FD8 ||
     201
     202That is 53 attributes. Still missing are all 8 `MC_` attributes, the 3 `W_` attributes and
     203the 2 `WI_` attributes:
     204
     205 * '''`MC_`:''' only `MC_ID` determines them (FD14), and `MC_ID` is reached only by FD15, which needs `M_ID` (already present), `MC_TIMEFRAME` and `MC_CANDLE_TIME`. So the key must add either `MC_ID` or both `MC_TIMEFRAME` and `MC_CANDLE_TIME`. Neither of those two alone is enough.
     206 * '''`W_` and `WI_`:''' `WI_ID` gives `W_ID` (FD17), and `W_ID` gives `WI_ID` together with `C_ID`, which is already present (FD18). So adding either `WI_ID` or `W_ID` gives all five.
     207
     208'''Candidate keys''' (each one's closure is all 66 attributes, and removing any member breaks
     209that, by the argument above):
     210
     211||= Key =||= Attributes =||
     212|| '''K1''' || `T_ID, OE_ID, MT_ID, MC_ID, WI_ID` ||
     213|| K2 || `T_ID, OE_ID, MT_ID, MC_ID, W_ID` ||
     214|| K3 || `T_ID, OE_ID, MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME, WI_ID` ||
     215|| K4 || `T_ID, OE_ID, MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME, W_ID` ||
     216
     217'''Primary key: K1.''' It consists only of identifiers, and it is the key that remains at the
     218end of the decomposition below.
     219
     220'''Prime attributes''' (in at least one candidate key): `T_ID, OE_ID, MT_ID, MC_ID,
     221MC_TIMEFRAME, MC_CANDLE_TIME, W_ID, WI_ID`. The other 58 attributes are '''non-prime'''. The
     222difference matters: 2NF and 3NF only restrict dependencies of non-prime attributes, and BCNF
     223restricts all of them.
     224
     225In words, a tuple of `R_EDUBERZA` puts together one ledger entry, one order event, one
     226trade, one candle and one watchlist item. Everything else in the tuple (the user, the order,
     227the market, the crypto, the holding, the watchlist) follows from those five.
     228
     229'''Normal form of `R_EDUBERZA`:''' 1NF only. It is not in 2NF, because, for example, `T_AMOUNT`
     230depends on `T_ID` alone, a proper part of K1.
     231
     232== 1NF decomposition ==
     233
     234No decomposition is needed. Every attribute of `R_EDUBERZA` is atomic and single-valued, and
     235the relation has no repeating groups (see
     236De-normalized database form).
     237
     238== 2NF decomposition ==
     239
     240=== How every step is described and checked ===
     241
     242Each step of 2NF, 3NF and BCNF below lists, in this order: the relation analyzed, its
     243dependencies, its candidate keys and primary key, and its normal form; the dependency that
     244violates the next normal form and is used for the split; the two resulting relations, each
     245with its dependencies, keys and normal form; and the dependency-preservation and lossless-join
    77246checks.
    78247
    79 == Functional dependencies ==
    80 
    81 === Canonical cover ===
    82 
    83 Read directly off the model: each entity's/relationship's own key determines its own
    84 attributes, nothing more. This is already minimal — no functional dependency below has an
    85 extraneous attribute on its left side, and no dependent attribute is repeated on the right
    86 side of more than one dependency, which is what "canonical cover" requires.
    87 
    88 ||= # =||= Functional dependency =||= Source =||
    89 || FD1 || `U_ID → U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` || Users ||
    90 || FD2 || `U_USERNAME → U_ID` || Users (`UNIQUE(username)`) ||
    91 || FD3 || `U_EMAIL → U_ID` || Users (`UNIQUE(email)`) ||
    92 || FD4 || `C_ID → C_SYMBOL, C_NAME, C_CREATED_AT` || Cryptos ||
    93 || FD5 || `C_SYMBOL → C_ID` || Cryptos (`UNIQUE(symbol)`) ||
    94 || FD6 || `M_ID → M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` || Markets ||
    95 || FD7 || `M_CRYPTO_ID, M_QUOTE_CURRENCY → M_ID` || Markets (`UNIQUE(crypto_id, quote_currency)`) ||
    96 || FD8 || `H_ID → H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` || Holds ||
    97 || FD9 || `H_USER_ID, H_CRYPTO_ID → H_ID` || Holds (`UNIQUE(user_id, crypto_id)`) ||
    98 || FD10 || `O_ID → O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` || Orders ||
    99 || FD11 || `T_ID → T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` || Transactions ||
    100 || FD12 || `MT_ID → MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` || !MarketTrades ||
    101 || FD13 || `MC_ID → MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` || !MarketCandles ||
    102 || FD14 || `MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` || !MarketCandles (`UNIQUE(market_id, timeframe, candle_time)`) ||
    103 || FD15 || `W_ID → W_USER_ID, W_NAME, W_CREATED_AT` || Watchlists ||
    104 || FD16 || `WI_ID → WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` || Contains ||
    105 || FD17 || `WI_WATCHLIST_ID, WI_CRYPTO_ID → WI_ID` || Contains (`UNIQUE(watchlist_id, crypto_id)`) ||
    106 
    107 '''Minimality, checked by example (Markets):''' could FD7 drop an attribute from its left side?
    108 `M_CRYPTO_ID` alone does not determine `M_ID` — many markets can reference the same crypto in
    109 different quote currencies (that is the entire point of the market entity), so two rows can
    110 share `M_CRYPTO_ID` and disagree on `M_ID`. `M_QUOTE_CURRENCY` alone fails the same way in the
    111 other direction. Neither attribute is extraneous, so the left side of FD7 cannot shrink. The
    112 same check applies to FD9, FD14 and FD17, whose composite left sides come directly from the
    113 `UNIQUE` constraints already justified per-relation in
    114 [wiki:RelationalDesign]; none of those constraints
    115 holds on a proper subset of its columns either.
    116 
    117 '''No redundant dependency:''' each of FD1–FD17 has a right side that is not implied by any
    118 other dependency in the set — for instance, nothing outside FD1 mentions `U_AVAILABLE_BALANCE`,
    119 so FD1 cannot be derived from the rest and cannot be dropped. This set is the canonical cover.
    120 
    121 === Dependencies carried by foreign keys ===
    122 
    123 Six attributes above are foreign keys: `M_CRYPTO_ID`, `H_USER_ID`, `H_CRYPTO_ID`,
    124 `O_USER_ID`, `O_MARKET_ID`, `T_USER_ID`, `T_RELATED_ORDER`, `MT_MARKET_ID`, `MC_MARKET_ID`,
    125 `W_USER_ID`, `WI_WATCHLIST_ID`, `WI_CRYPTO_ID` — each one draws its values from the same
    126 domain as some other attribute's key. Because of that, every dependency that holds on the
    127 referenced key also holds, by substitution, on the referencing attribute:
    128 
    129 ||= Foreign key =||= References =||= Therefore also determines =||
    130 || `M_CRYPTO_ID` || `C_ID` || `C_SYMBOL, C_NAME, C_CREATED_AT` ||
    131 || `H_USER_ID` || `U_ID` || all of `U_*` ||
    132 || `H_CRYPTO_ID` || `C_ID` || all of `C_*` ||
    133 || `O_USER_ID` || `U_ID` || all of `U_*` ||
    134 || `O_MARKET_ID` || `M_ID` || all of `M_*`, and transitively all of `C_*` ||
    135 || `T_USER_ID` || `U_ID` || all of `U_*` ||
    136 || `T_RELATED_ORDER` || `O_ID` || all of `O_*`, and transitively `U_*`, `M_*`, `C_*` (when not null) ||
    137 || `MT_MARKET_ID` || `M_ID` || all of `M_*`, transitively `C_*` ||
    138 || `MC_MARKET_ID` || `M_ID` || all of `M_*`, transitively `C_*` ||
    139 || `W_USER_ID` || `U_ID` || all of `U_*` ||
    140 || `WI_WATCHLIST_ID` || `W_ID` || all of `W_*`, transitively `U_*` ||
    141 || `WI_CRYPTO_ID` || `C_ID` || all of `C_*` ||
    142 
    143 None of these is added to the canonical cover — each is ''derivable'' from FD1–FD17 by
    144 transitivity plus the foreign-key identity, which is exactly why a canonical cover excludes
    145 them. They matter anyway: they are precisely the transitive dependencies the 3NF check below
    146 has to rule out.
    147 
    148 == Candidate keys and primary key ==
    149 
    150 `Orders`, `Transactions`, `MarketTrades`, `MarketCandles`, `Holds`, `Watchlists` and
    151 `Contains` are, with respect to each other, independent record types: nothing about an
    152 order's id says anything about which market-candle row, or which unrelated transaction, or
    153 which watchlist item is in the same tuple of `R_EDUBERZA` — a user can exist with zero of any
    154 of them, and having one order says nothing about how many holdings, trades or candles exist
    155 alongside it. (The one FK that crosses between two of these — `T_RELATED_ORDER` — is
    156 nullable, so it cannot be relied on to always connect a transaction row back to an order.)
    157 That means no proper subset of attributes can functionally determine all 68 attributes of
    158 `R_EDUBERZA`: the only way to pin down a `H_*` value, an `O_*` value, a `T_*` value, an
    159 `MT_*` value, an `MC_*` value, a `W_*` value ''and'' a `WI_*` value at once is to state one
    160 identifying attribute from each cluster explicitly.
    161 
    162 '''Chosen primary key''' (closure shown below):
    163 
    164 {{{
    165 { U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID }
    166 }}}
    167 
    168 '''Closure check''', applying FD1–FD17 in turn to this set:
    169 
    170 ||= Step =||= Attributes added =||= Dependency used =||
    171 || start || `U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID` || — ||
    172 || 1 || `U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` || FD1 (`U_ID → …`) ||
    173 || 2 || `C_SYMBOL, C_NAME, C_CREATED_AT` || FD4 ||
    174 || 3 || `M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` || FD6 ||
    175 || 4 || `H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` || FD8 ||
    176 || 5 || `O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` || FD10 ||
    177 || 6 || `T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` || FD11 ||
    178 || 7 || `MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` || FD12 ||
    179 || 8 || `MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` || FD13 ||
    180 || 9 || `W_USER_ID, W_NAME, W_CREATED_AT` || FD15 ||
    181 || 10 || `WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` || FD16 ||
    182 
    183 The closure now contains all 68 attributes, so the set is a superkey; removing any one of its
    184 ten attributes drops an entire cluster that nothing else in the set can reach (e.g. drop
    185 `T_ID` and no remaining attribute determines any `T_*` value), so it is minimal — a candidate
    186 key.
    187 
    188 '''It is not the only one.''' Any attribute that is itself a determinant of a whole cluster can
    189 stand in for that cluster's id — `U_USERNAME` or `U_EMAIL` for `U_ID` (FD2/FD3), `C_SYMBOL`
    190 for `C_ID` (FD5), `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` for `M_ID` (FD7), `{H_USER_ID, H_CRYPTO_ID}` for `H_ID` (FD9), `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` for `MC_ID`
    191 (FD14), `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` for `WI_ID` (FD17) — giving 3 × 2 × 2 × 2 × 1 × 1 ×
    192 1 × 2 × 1 × 2 = 96 candidate keys in total. The all-surrogate-id combination above is chosen
    193 as '''primary key''' for the same reason `id` was chosen over `username`/`email`/`symbol`/etc.
    194 per entity in [wiki:ERModel]: it is opaque, and none of its parts
    195 are things a user would ever legitimately change.
    196 
    197 '''Normal form of `R_EDUBERZA` before decomposition:''' 1NF only, and barely that — see 2NF
    198 below. It cannot be in 2NF, 3NF or BCNF, since each of those requires 2NF as a precondition.
    199 
    200 == 1NF decomposition ==
    201 
    202 No decomposition happens at this step. 1NF requires atomic, single-valued attributes and no
    203 repeating groups; `R_EDUBERZA` was built that way from the start (every column above is a
    204 single scalar), so the relation already satisfies 1NF as written in
    205 De-normalized database form. The real work starts at 2NF.
    206 
    207 == 2NF decomposition ==
    208 
    209 '''Relation analyzed:''' `R_EDUBERZA`, all 68 attributes, primary key
    210 `{U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID}` (10 attributes), FD1–FD17
    211 in force.
    212 
    213 '''Current normal form:''' 1NF only (previous section).
    214 
    215 '''Violations:''' 2NF forbids a non-prime attribute from depending on ''part'' of a candidate
    216 key. Every single functional dependency in the canonical cover (FD1–FD17) has a left side
    217 that is a '''proper subset''' of the ten-attribute primary key — `U_ID` alone, `C_ID` alone, …,
    218 down to the two-attribute `{WI_WATCHLIST_ID, WI_CRYPTO_ID}`. There is no non-prime attribute
    219 in `R_EDUBERZA` that depends on the whole ten-attribute key and nothing smaller. In other
    220 words, ''every'' non-prime attribute violates 2NF at once — the violation is not a handful of
    221 stray columns to peel off, it is the entire relation, because gluing ten independent record
    222 types together under one artificial composite key was never going to satisfy 2NF to begin
    223 with.
    224 
    225 '''Decomposition.''' This uses 3NF/BCNF '''synthesis''' (Bernstein's algorithm) rather than the
    226 binary decomposition algorithm: since the canonical cover is already in hand (as the phase
    227 instructions recommend building first), synthesis creates one relation per left-hand side in
    228 the cover directly, instead of hunting for one offending dependency at a time and splitting
    229 in two repeatedly. Grouping FD1–FD17 by determinant produces ten relations:
    230 
    231 ||= New relation =||= Attributes =||= Key(s) =||= Source FDs =||
    232 || `R_USERS` || `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` || `U_ID`, `U_USERNAME`, `U_EMAIL` || FD1, FD2, FD3 ||
    233 || `R_CRYPTO` || `C_ID, C_SYMBOL, C_NAME, C_CREATED_AT` || `C_ID`, `C_SYMBOL` || FD4, FD5 ||
    234 || `R_MARKETS` || `M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` || `M_ID`, `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` || FD6, FD7 ||
    235 || `R_HOLDINGS` || `H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` || `H_ID`, `{H_USER_ID, H_CRYPTO_ID}` || FD8, FD9 ||
    236 || `R_ORDERS` || `O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` || `O_ID` || FD10 ||
    237 || `R_TRANSACTIONS` || `T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` || `T_ID` || FD11 ||
    238 || `R_MARKET_TRADES` || `MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` || `MT_ID` || FD12 ||
    239 || `R_MARKET_CANDLES` || `MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` || `MC_ID`, `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` || FD13, FD14 ||
    240 || `R_WATCHLISTS` || `W_ID, W_USER_ID, W_NAME, W_CREATED_AT` || `W_ID` || FD15 ||
    241 || `R_WATCHLIST_ITEMS` || `WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` || `WI_ID`, `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` || FD16, FD17 ||
    242 
    243 Every one of these ten relations now has '''all''' of its non-prime attributes depending on its
    244 '''whole''' key (in every case there is only one non-composite or one designated key doing the
    245 determining, so 2NF holds trivially in each).
    246 
    247 '''Dependency preservation.''' FD1–FD17 is the canonical cover of `R_EDUBERZA`. Each FD's
    248 determinant and every one of its dependent attributes land inside exactly one of the ten new
    249 relations (see the "Source FDs" column above — no FD is split across two relations). The
    250 union of the FDs that hold on `R_USERS, …, R_WATCHLIST_ITEMS` is therefore exactly FD1–FD17
    251 again: nothing was lost.
    252 
    253 '''Lossless join — chase test.'''
    254 
    255 > ''Note: the chase algorithm is not part of the course material. I was curious about a stricter way to test lossless join than the usual "the common attributes are a key of one side" argument, so I applied it here.''
    256 
    257 The chase decides whether a decomposition `R = R1 ∪ … ∪ Rn` is lossless under a set of
    258 functional dependencies. Build a tableau with one column per attribute of `R` and one row per
    259 relation `Ri`. In row `i`, put a distinguished symbol `a` in every column of `Ri` and a unique
    260 symbol `b_i` in every other column. Then repeat, until nothing changes: for each FD `X → Y`,
    261 whenever two rows agree on all of `X`, make them agree on `Y`. If they disagree, an `a` wins,
    262 otherwise one `b` replaces the other. '''The decomposition is lossless exactly when some row ends up with `a` in every column.'''
    263 
    264 All attributes of one cluster (`U_*`, `C_*`, `M_*`, …) always appear together, and FD1–FD17 never mix clusters. So each cluster is one column group below: `a` means every column of the group holds a distinguished symbol, and `b` means none of them does. The foreign-key attributes (`H_USER_ID`, `O_MARKET_ID`, …) belong to their own cluster (`H_*`, `O_*`, …), not to the cluster they reference.
    265 
    266 '''Step 1 — the ten relations from the table above.'''
    267 
    268 {{{
    269               U*  C*  M*  H*  O*  T*  MT* MC* W*  WI*
    270 R_USERS        a   b   b   b   b   b   b   b   b   b
    271 R_CRYPTO       b   a   b   b   b   b   b   b   b   b
    272 R_MARKETS      b   b   a   b   b   b   b   b   b   b
    273 R_HOLDINGS     b   b   b   a   b   b   b   b   b   b
    274 R_ORDERS       b   b   b   b   a   b   b   b   b   b
    275 R_TRANSACTIONS b   b   b   b   b   a   b   b   b   b
    276 R_MARKET_TR.   b   b   b   b   b   b   a   b   b   b
    277 R_MARKET_CA.   b   b   b   b   b   b   b   a   b   b
    278 R_WATCHLISTS   b   b   b   b   b   b   b   b   a   b
    279 R_WATCHLIST_I. b   b   b   b   b   b   b   b   b   a
    280 }}}
    281 
    282 Every FD has its left side inside one cluster, for example `U_ID → U_*` or `H_USER_ID, H_CRYPTO_ID → H_ID`. For such an FD to fire, two rows would have to agree on that left side. But only one row has `a`s in that cluster, and the `b`s of different rows are all different, so no two rows ever agree on any left side. ''*The chase changes nothing, and no row becomes all `a`.'''' Under FD1–FD17 alone, the ten relations are ''not'' guaranteed to join back to `R_EDUBERZA`. This is not an accident of this model. It is exactly why Bernstein's synthesis algorithm has a final step: *if no synthesised relation contains a candidate key of `R`, add one that does.'' None of the ten contains the ten-attribute key.
    283 
    284 '''Step 2 — add the key relation''' `R_KEY(U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID,
    285 W_ID, WI_ID)`. Its row has `a` only in the ten ID columns, written `a·` for "`a` in the ID,
    286 `b` in the rest of the group":
    287 
    288 {{{
    289               U*  C*  M*  H*  O*  T*  MT* MC* W*  WI*
    290 R_KEY          a·  a·  a·  a·  a·  a·  a·  a·  a·  a·
    291 (the ten rows of step 1 unchanged)
    292 }}}
    293 
    294 Now FD1 `U_ID → U_*` fires: row `R_KEY` and row `R_USERS` both have `a` in `U_ID`, so they must agree on the rest of `U_*`, and `R_USERS` has `a` there. `R_KEY` becomes `a` in the whole
    295 `U*` group. The same happens with FD4 (`C*`), FD6 (`M*`), FD8 (`H*`), FD10 (`O*`), FD11 (`T*`),
    296 FD12 (`MT*`), FD13 (`MC*`), FD15 (`W*`) and FD16 (`WI*`):
    297 
    298 {{{
    299               U*  C*  M*  H*  O*  T*  MT* MC* W*  WI*
    300 R_KEY          a   a   a   a   a   a   a   a   a   a     <- all distinguished
    301 }}}
    302 
    303 '''Row `R_KEY` is all `a`, so the decomposition into the ten relations plus `R_KEY` is lossless.'''
    304 
    305 '''Why `R_KEY` is not kept in the final schema.''' An instance of `R_KEY` would only record
    306 which ID of one cluster appears together with which ID of every other cluster. As shown under
    307 ''Candidate keys and primary key'', the ten clusters are independent record types, and
    308 `R_EDUBERZA` pairs every row of one with every row of the others. So `R_KEY` would be just the
    309 cross product of the ten ID sets and would carry no information. The same independence means
    310 the join dependency `⋈[R_USERS, …, R_WATCHLIST_ITEMS]` holds on `R_EDUBERZA` by construction.
    311 Under that dependency the ten relations alone already reconstruct it: their natural join, with
    312 no common attributes, is exactly that cross product. The chase makes this reasoning explicit.
    313 FDs by themselves cannot prove the join lossless; you need either the key relation or the
    314 independence of the clusters. That was hidden in the earlier "foreign key equals primary key"
    315 argument, which described the equi-joins the application runs, not the natural join the
    316 lossless-join property is about.
     248Every step splits one relation `R` into two: the '''extracted''' relation `Ri` and the
     249'''residual''' relation `R'` (what is left of `R`). The same two checks are made each time:
     250
     251 * '''Lossless join.''' The split of `R` into `Ri` and `R'` is lossless if the common attributes determine one of the two sides: `(Ri ∩ R') → Ri` or `(Ri ∩ R') → R'`. Every step below extracts `Ri = X ∪ (what X determines)` for some determinant `X` that stays in `R'`. So `X ⊆ Ri ∩ R'` and `X → Ri`, and the first condition holds.
     252 * '''Dependency preservation.''' Every dependency of the canonical cover must end up with all its attributes inside one relation. So an attribute is removed from the residual only when no dependency still waiting in the residual needs it. Otherwise it is extracted '''and''' kept.
     253
     254'''Relation analyzed first:''' `R_EDUBERZA` (66 attributes), dependencies FD1–FD18, candidate
     255keys K1–K4, primary key K1. '''Normal form:''' 1NF.
     256
     257'''Dependencies that violate 2NF.''' 2NF forbids a non-prime attribute from depending on a proper
     258part of a candidate key. There are six such partial dependencies:
     259
     260||= Part of a key =||= Non-prime attributes that depend on it =||= Through =||
     261|| `T_ID` (K1–K4) || `T_*`, `O_ID`, and through them `O_*`, `U_ID`, `U_*`, `M_ID`, `M_*`, `C_ID`, `C_*`, `H_ID`, `H_*` || FD11, then FD10, FD1, FD6, FD4, FD9, FD8 ||
     262|| `OE_ID` (K1–K4) || `OE_*`, `O_ID` || FD13 ||
     263|| `MT_ID` (K1–K4) || `MT_*`, `M_ID`, `O_ID_FILLSBUY`, `O_ID_FILLSSELL` || FD12 ||
     264|| `MC_ID` (K1, K2) || `MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME`, `M_ID` || FD14 ||
     265|| `W_ID` (K2, K4) || `W_*`, `U_ID` || FD16 ||
     266|| `WI_ID` (K1, K3) || `WI_ADDED_AT`, `C_ID` || FD17 ||
     267
     268The table lists the part of a key that each group depends on most directly. It is not the
     269only one: under K3/K4, for example, `MC_OPEN … MC_VOLUME` also depend on
     270`{MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME}`, and under K1/K3 `W_*` depend on `WI_ID` through
     271`W_ID`. These lead to the same relations, so they need no extra steps. `MC_TIMEFRAME`,
     272`MC_CANDLE_TIME` and `W_ID` also depend on parts of keys, but they are prime, so 2NF does not
     273restrict them. They are handled under BCNF.
     274
     275Each step below removes one row of this table, splitting the current relation into two. The
     276'''order''' is chosen so that no dependency is lost. `T_ID` goes first, because its group is the
     277largest and carries FD1–FD11 with it. Each later step handles a group whose determinant is
     278still in the residual relation.
     279
     280=== Step 2NF-1 — partial dependency on `T_ID` ===
     281
     282 * '''Relation analyzed:''' `R_EDUBERZA` (66 attributes).
     283 * '''Dependencies:''' FD1–FD18. '''Candidate keys:''' K1–K4. '''Primary key:''' K1. '''Normal form:''' 1NF.
     284 * '''2NF violations:''' all six rows of the table above. '''Split first on `T_ID`''', the largest group (see the order explained above).
     285 * '''Decomposition dependency:''' `T_ID → T_*, O_ID` (FD11), together with everything it determines transitively (FD10, FD1, FD6, FD4, FD9, FD8). `T_ID` is a proper part of K1, and `T_AMOUNT`, for example, is non-prime, so this violates 2NF.
     286 * '''New relation `R_A`''' = `{ T_ID, T_*, O_ID, O_*, U_ID, U_*, M_ID, M_*, C_ID, C_*, H_ID, H_* }` (39 attributes). Dependencies: FD1–FD11. Candidate key and primary key: `T_ID`. Normal form: 2NF (it has a one-attribute key), but not 3NF (see 3NF).
     287 * '''Residual relation `S1`''' = `R_EDUBERZA − { T_*, O_*, U_*, M_*, C_*, H_ID, H_* }` = `{ T_ID, O_ID, U_ID, M_ID, C_ID, OE_ID, OE_*, MT_ID, MT_*, O_ID_FILLSBUY, O_ID_FILLSSELL, MC_ID, MC_*, W_ID, W_*, WI_ID, WI_ADDED_AT }` (32 attributes). `O_ID`, `U_ID`, `M_ID` and `C_ID` stay, because FD13, FD16, FD12/FD14/FD15 and FD17/FD18 still need them. Dependencies: FD12–FD18, plus the projected dependencies between the identifiers kept here: `T_ID → O_ID, U_ID, M_ID, C_ID`, `O_ID → U_ID, M_ID, C_ID`, `M_ID → C_ID`, `OE_ID → U_ID, M_ID, C_ID`, `MT_ID → C_ID`, `MC_ID → C_ID`, `WI_ID → U_ID`. Candidate keys: K1–K4 (all their attributes are still here). Normal form: 1NF.
     288 * '''Dependency preservation:''' FD1–FD11 lie entirely in `R_A`, and FD12–FD18 entirely in `S1`. ✓
     289 * '''Lossless join:''' `R_A ∩ S1 = { T_ID, O_ID, U_ID, M_ID, C_ID }` contains `T_ID`, and `T_ID → R_A`, so `(R_A ∩ S1) → R_A`. ✓
     290
     291=== Step 2NF-2 — partial dependency on `OE_ID` ===
     292
     293 * '''Relation analyzed:''' `S1` (32 attributes). Dependencies: as listed for `S1` in the previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     294 * '''Remaining 2NF violations:''' the partial dependencies on `OE_ID`, `MT_ID`, `MC_ID`, `W_ID` and `WI_ID` (table above), and the partial dependencies of the kept identifiers `O_ID`, `U_ID`, `M_ID`, `C_ID` on `T_ID`. The kept identifiers cannot leave yet, because other groups still need them. Each one leaves with the last group that needs it (`O_ID` in 2NF-2, `M_ID` in 2NF-4, `U_ID` in 2NF-5, `C_ID` in 2NF-6). '''Split first on `OE_ID`''', because after it no group needs `O_ID` any more.
     295 * '''Decomposition dependency:''' `OE_ID → OE_*, O_ID` (FD13). `OE_ID` is a proper part of K1 and `OE_*` are non-prime.
     296 * '''New relation `R_B`''' = `{ OE_ID, OE_*, O_ID }` (7 attributes). Dependencies: FD13. Candidate key: `OE_ID`. Normal form: BCNF.
     297 * '''Residual relation `S2`''' = `S1 − { OE_*, O_ID }` (26 attributes). No dependency still needed in the residual uses `O_ID`. Dependencies: FD12, FD14–FD18, plus the projected `T_ID → U_ID, M_ID, C_ID`, `OE_ID → U_ID, M_ID, C_ID`, `M_ID → C_ID`, `MT_ID → C_ID`, `MC_ID → C_ID`, `WI_ID → U_ID`. Candidate keys: K1–K4. Normal form: 1NF.
     298 * '''Dependency preservation:''' FD13 is in `R_B`, and the others are in `S2`. `T_ID → O_ID` is already kept in `R_A`. ✓
     299 * '''Lossless join:''' `R_B ∩ S2 = { OE_ID }`, and `OE_ID → R_B` (FD13). ✓
     300
     301=== Step 2NF-3 — partial dependency on `MT_ID` ===
     302
     303 * '''Relation analyzed:''' `S2` (26 attributes). Dependencies: as listed for `S2` in the previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     304 * '''Remaining 2NF violations:''' the groups of `MT_ID`, `MC_ID`, `W_ID`, `WI_ID`, and the kept identifiers `U_ID`, `M_ID`, `C_ID`. '''Split first on `MT_ID`''', the next group. `M_ID` must still stay for `MC_ID`.
     305 * '''Decomposition dependency:''' `MT_ID → MT_*, M_ID, O_ID_FILLSBUY, O_ID_FILLSSELL` (FD12).
     306 * '''New relation `R_C`''' = `{ MT_ID, MT_*, M_ID, O_ID_FILLSBUY, O_ID_FILLSSELL }` (9 attributes). Dependencies: FD12. Candidate key: `MT_ID`. Normal form: BCNF.
     307 * '''Residual relation `S3`''' = `S2 − { MT_*, O_ID_FILLSBUY, O_ID_FILLSSELL }` (19 attributes). `M_ID` stays, because FD14/FD15 need it. Dependencies: FD14–FD18, plus the projected `T_ID → U_ID, M_ID, C_ID`, `OE_ID → U_ID, M_ID, C_ID`, `MT_ID → M_ID, C_ID`, `M_ID → C_ID`, `MC_ID → C_ID`, `WI_ID → U_ID`. Candidate keys: K1–K4. Normal form: 1NF.
     308 * '''Dependency preservation:''' FD12 is in `R_C`, and FD14–FD18 are in `S3`. ✓
     309 * '''Lossless join:''' `R_C ∩ S3 = { MT_ID, M_ID }` contains `MT_ID`, and `MT_ID → R_C` (FD12). ✓
     310
     311=== Step 2NF-4 — partial dependency on `MC_ID` ===
     312
     313 * '''Relation analyzed:''' `S3` (19 attributes). Dependencies: as listed for `S3` in the previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     314 * '''Remaining 2NF violations:''' the groups of `MC_ID`, `W_ID`, `WI_ID`, and the kept identifiers `U_ID`, `M_ID`, `C_ID`. '''Split first on `MC_ID`''', the last group that needs `M_ID`, so `M_ID` can leave with it.
     315 * '''Decomposition dependency:''' `MC_ID → MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, M_ID` (FD14). `MC_ID` is a proper part of K1. The prime `MC_TIMEFRAME` and `MC_CANDLE_TIME` also go into the new relation, so that FD15, which needs them with `M_ID` and `MC_ID`, is preserved.
     316 * '''New relation `R_D`''' = `{ MC_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME, M_ID }` (9 attributes). Dependencies: FD14, FD15. Candidate keys: `MC_ID` and `{M_ID, MC_TIMEFRAME, MC_CANDLE_TIME}`. Normal form: BCNF.
     317 * '''Residual relation `S4`''' = `S3 − { MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, M_ID }` (13 attributes). `MC_TIMEFRAME` and `MC_CANDLE_TIME` are prime and stay. Dependencies: FD16–FD18, plus the projected `T_ID → U_ID, C_ID`, `OE_ID → U_ID, C_ID`, `MT_ID → C_ID`, `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME, C_ID`, `WI_ID → U_ID`. Candidate keys: K1–K4. Normal form: 1NF.
     318 * '''Dependency preservation:''' FD14 and FD15 are in `R_D`, and FD16–FD18 are in `S4`. ✓
     319 * '''Lossless join:''' `R_D ∩ S4 = { MC_ID, MC_TIMEFRAME, MC_CANDLE_TIME }` contains `MC_ID`, and `MC_ID → R_D` (FD14). ✓
     320
     321=== Step 2NF-5 — partial dependency on `W_ID` ===
     322
     323 * '''Relation analyzed:''' `S4` (13 attributes). Dependencies: as listed for `S4` in the previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     324 * '''Remaining 2NF violations:''' the groups of `W_ID` and `WI_ID`, and the kept identifiers `U_ID`, `C_ID`. '''Split first on `W_ID`''', the last group that needs `U_ID`.
     325 * '''Decomposition dependency:''' `W_ID → W_NAME, W_CREATED_AT, U_ID` (FD16). `W_ID` is a proper part of K2.
     326 * '''New relation `R_E`''' = `{ W_ID, W_NAME, W_CREATED_AT, U_ID }` (4 attributes). Dependencies: FD16. Candidate key: `W_ID`. Normal form: BCNF.
     327 * '''Residual relation `S5`''' = `S4 − { W_NAME, W_CREATED_AT, U_ID }` (10 attributes). Dependencies: FD17, FD18, plus the projected `T_ID → C_ID`, `OE_ID → C_ID`, `MT_ID → C_ID`, `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME, C_ID`. Candidate keys: K1–K4. Normal form: 1NF.
     328 * '''Dependency preservation:''' FD16 is in `R_E`, and FD17 and FD18 are in `S5`. ✓
     329 * '''Lossless join:''' `R_E ∩ S5 = { W_ID }`, and `W_ID → R_E` (FD16). ✓
     330
     331=== Step 2NF-6 — partial dependency on `WI_ID` ===
     332
     333 * '''Relation analyzed:''' `S5` (10 attributes). Dependencies: as listed for `S5` in the previous step. Candidate keys: K1–K4. Primary key: K1. Normal form: 1NF.
     334 * '''Remaining 2NF violations:''' the group of `WI_ID`, and the kept identifier `C_ID`. '''Split on `WI_ID`''', the last group that needs `C_ID`.
     335 * '''Decomposition dependency:''' `WI_ID → WI_ADDED_AT, W_ID, C_ID` (FD17). `WI_ID` is a proper part of K1, and `WI_ADDED_AT` and `C_ID` are non-prime.
     336 * '''New relation `R_F`''' = `{ WI_ID, WI_ADDED_AT, W_ID, C_ID }` (4 attributes). Dependencies: FD17, FD18. Candidate keys: `WI_ID` and `{W_ID, C_ID}`. Normal form: BCNF.
     337 * '''Residual relation `S6`''' = `S5 − { WI_ADDED_AT, C_ID }` = `{ T_ID, OE_ID, MT_ID, MC_ID, MC_TIMEFRAME, MC_CANDLE_TIME, W_ID, WI_ID }` (8 attributes). `W_ID` is prime and stays. Dependencies: no dependency of the cover lies entirely inside `S6`. The projected ones are `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME` and `WI_ID → W_ID`, plus derived ones such as `MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` and `MT_ID, W_ID → WI_ID`. Candidate keys: K1–K4. Normal form: 3NF, because every attribute is prime (and so 2NF).
     338 * '''Dependency preservation:''' FD17 and FD18 are in `R_F`. ✓
     339 * '''Lossless join:''' `R_F ∩ S6 = { WI_ID, W_ID }` contains `WI_ID`, and `WI_ID → R_F` (FD17). ✓
     340
     341'''Result of 2NF:''' `R_A`, `R_B`, `R_C`, `R_D`, `R_E`, `R_F`, `S6`. All seven are in 2NF (`R_A`
     342only 2NF, `S6` 3NF, the rest BCNF). All 18 dependencies are preserved: FD1–FD11 in `R_A`,
     343FD13 in `R_B`, FD12 in `R_C`, FD14–FD15 in `R_D`, FD16 in `R_E`, FD17–FD18 in `R_F`.
    317344
    318345== 3NF decomposition ==
    319346
    320 '''Relations analyzed:''' each of the ten relations produced above, individually.
    321 
    322 For each relation, 3NF asks whether any non-prime attribute is ''transitively'' dependent on a
    323 key — i.e. determined by another non-prime attribute rather than directly by the key. This is
    324 exactly where the foreign-key-carried dependencies from
    325 Dependencies carried by foreign keys have to be
    326 checked, because that table is precisely the list of "dependency that would cause a problem at
    327 the next higher normal form" the phase template asks for.
    328 
    329 '''Worked example — `R_MARKETS`.''' Its key `M_ID` determines `M_CRYPTO_ID`, and
    330 `M_CRYPTO_ID → C_SYMBOL, C_NAME, C_CREATED_AT` also holds (`M_CRYPTO_ID` draws its values from
    331 `C_ID`'s domain). If `C_SYMBOL`, `C_NAME` and `C_CREATED_AT` were still columns of
    332 `R_MARKETS`, this would be exactly the transitive dependency `M_ID → M_CRYPTO_ID → C_SYMBOL`
    333 that violates 3NF. They are not: the 2NF step above already put them in `R_CRYPTO`, keyed
    334 directly by `C_ID` (FD4), because FD4 — not the derived `M_CRYPTO_ID → C_SYMBOL` — is what the
    335 canonical cover actually contains. `R_MARKETS` itself has no attribute that determines another
    336 non-prime attribute of `R_MARKETS`; the transitive dependency is real, but it points ''out'' of
    337 the relation, not within it.
    338 
    339 The same reasoning applies to every other foreign key in the list: `H_USER_ID`/`H_CRYPTO_ID`,
    340 `O_USER_ID`/`O_MARKET_ID`, `T_USER_ID`/`T_RELATED_ORDER`, `MT_MARKET_ID`, `MC_MARKET_ID`,
    341 `W_USER_ID`, `WI_WATCHLIST_ID`/`WI_CRYPTO_ID` are all foreign keys sitting ''alongside'' a
    342 non-key attribute set that depends only on their own relation's key, never on the foreign key
    343 itself. None of `R_USERS`, `R_CRYPTO`, `R_HOLDINGS`, `R_ORDERS`, `R_TRANSACTIONS`,
    344 `R_MARKET_TRADES`, `R_MARKET_CANDLES`, `R_WATCHLISTS`, `R_WATCHLIST_ITEMS` has a non-prime
    345 attribute that another non-prime attribute of the ''same'' relation determines.
    346 
    347 '''Conclusion:''' synthesising directly from the canonical cover in the 2NF step already
    348 avoided every transitive dependency — there is nothing left to decompose for 3NF. All ten
    349 relations from the previous section satisfy 3NF unchanged.
     347Only `R_A` is not in 3NF. `R_B`–`R_F` are already in BCNF, and `S6` is in 3NF (all its
     348attributes are prime).
     349
     350'''Dependencies that violate 3NF in `R_A`.''' 3NF forbids a non-prime attribute from depending on
     351a key only '''transitively''', through a determinant that is not a superkey. The only key of
     352`R_A` is `T_ID`, but inside `R_A`:
     353
     354 * `U_ID → U_*` (FD1), `U_USERNAME → U_ID` (FD2), `U_EMAIL → U_ID` (FD3)
     355 * `C_ID → C_*` (FD4), `C_SYMBOL → C_ID` (FD5)
     356 * `U_ID, C_ID → H_ID` (FD9), `H_ID → H_*, U_ID, C_ID` (FD8)
     357 * `M_ID → M_*, C_ID` (FD6), `C_ID, M_QUOTE_CURRENCY → M_ID` (FD7)
     358 * `O_ID → O_*, U_ID, M_ID` (FD10)
     359
     360None of these determinants is a superkey of `R_A`. For example, `T_ID → O_ID → O_PRICE` is a
     361transitive dependency of the non-prime `O_PRICE` on the key.
     362
     363'''Order of the steps.''' An attribute can leave the residual only after every dependency that
     364needs it has been extracted. FD9 needs `U_ID` and `C_ID` together, and extracting `Markets`
     365takes `C_ID` out of the residual, so `Holdings` must come before `Markets`. Extracting
     366`Orders` takes `M_ID` and `U_ID` out, so `Orders` comes last. The dependencies are therefore
     367taken from the "leaves" of the chain `T_ID → O_ID → {U_ID, M_ID → C_ID}` inward.
     368
     369=== Step 3NF-1 — transitive dependency through `U_ID` ===
     370
     371 * '''Relation analyzed:''' `R_A` (39 attributes), dependencies FD1–FD11, candidate key and primary candidate key and primary key `T_ID`, normal form 2NF.
     372 * '''3NF violations:''' all five groups listed above. '''Split first on `U_ID`'''. It is a leaf of the chain: its dependents determine nothing outside its own group.
     373 * '''Decomposition dependency:''' `U_ID → U_*` (FD1). `U_ID` is not a superkey of `R_A`.
     374 * '''New relation `R_USERS`''' = `{ U_ID, U_* }` (10 attributes). Dependencies: FD1, FD2, FD3. Candidate keys: `U_ID`, `U_USERNAME`, `U_EMAIL`. Primary key: `U_ID`. Normal form: BCNF.
     375 * '''Residual relation `R_A1`''' = `R_A − U_*` (30 attributes). Dependencies: FD4–FD11, which also imply `T_ID → H_ID` and `O_ID → H_ID` (through `U_ID, C_ID`). Candidate key and primary key: `T_ID`. Normal form: 2NF.
     376 * '''Dependency preservation:''' FD1–FD3 are in `R_USERS`, and FD4–FD11 are in `R_A1`. ✓
     377 * '''Lossless join:''' `R_USERS ∩ R_A1 = { U_ID }`, and `U_ID → R_USERS` (FD1). ✓
     378
     379=== Step 3NF-2 — transitive dependency through `C_ID` ===
     380
     381 * '''Relation analyzed:''' `R_A1` (30 attributes), dependencies FD4–FD11, candidate key and primary key `T_ID`, normal form 2NF.
     382 * '''3NF violations:''' `C_ID → C_*`, `U_ID, C_ID → H_ID → H_*`, `M_ID → M_*, C_ID`, `O_ID → O_*, U_ID, M_ID`. '''Split first on `C_ID`''', the next leaf.
     383 * '''Decomposition dependency:''' `C_ID → C_*` (FD4).
     384 * '''New relation `R_CRYPTO`''' = `{ C_ID, C_* }` (4 attributes). Dependencies: FD4, FD5. Candidate keys: `C_ID`, `C_SYMBOL`. Primary key: `C_ID`. Normal form: BCNF.
     385 * '''Residual relation `R_A2`''' = `R_A1 − C_*` (27 attributes). Dependencies: FD6–FD11. Candidate key and primary key: `T_ID`. Normal form: 2NF.
     386 * '''Dependency preservation:''' FD4 and FD5 are in `R_CRYPTO`, and FD6–FD11 are in `R_A2`. ✓
     387 * '''Lossless join:''' `R_CRYPTO ∩ R_A2 = { C_ID }`, and `C_ID → R_CRYPTO` (FD4). ✓
     388
     389=== Step 3NF-3 — transitive dependency through `{U_ID, C_ID}` ===
     390
     391 * '''Relation analyzed:''' `R_A2` (27 attributes), dependencies FD6–FD11, candidate key and primary key `T_ID`, normal form 2NF.
     392 * '''3NF violations:''' `U_ID, C_ID → H_ID → H_*`, `M_ID → M_*, C_ID`, `O_ID → O_*, U_ID, M_ID`. '''Split first on `{U_ID, C_ID}`''', because it must come before `Markets` takes `C_ID` away.
     393 * '''Decomposition dependency:''' `U_ID, C_ID → H_ID` (FD9), together with `H_ID → H_*` (FD8).
     394 * '''New relation `R_HOLDINGS`''' = `{ H_ID, H_*, U_ID, C_ID }` (8 attributes). Dependencies: FD8, FD9. Candidate keys: `H_ID`, `{U_ID, C_ID}`. Primary key: `H_ID`. Normal form: BCNF.
     395 * '''Residual relation `R_A3`''' = `R_A2 − { H_ID, H_* }` (21 attributes). Dependencies: FD6, FD7, FD10, FD11. Candidate key and primary key: `T_ID`. Normal form: 2NF.
     396 * '''Dependency preservation:''' FD8 and FD9 are in `R_HOLDINGS`, and the others are in `R_A3`. ✓
     397 * '''Lossless join:''' `R_HOLDINGS ∩ R_A3 = { U_ID, C_ID }`, and `U_ID, C_ID → H_ID → H_*`, so `{U_ID, C_ID} → R_HOLDINGS`. ✓
     398
     399=== Step 3NF-4 — transitive dependency through `M_ID` ===
     400
     401 * '''Relation analyzed:''' `R_A3` (21 attributes), dependencies FD6, FD7, FD10, FD11, key `T_ID`, normal form 2NF.
     402 * '''3NF violations:''' `M_ID → M_*, C_ID` and `O_ID → O_*, U_ID, M_ID`. '''Split first on `M_ID`''', because `Orders` still needs `M_ID`.
     403 * '''Decomposition dependency:''' `M_ID → M_*, C_ID` (FD6).
     404 * '''New relation `R_MARKETS`''' = `{ M_ID, M_*, C_ID }` (5 attributes). Dependencies: FD6, FD7. Candidate keys: `M_ID`, `{C_ID, M_QUOTE_CURRENCY}`. Primary key: `M_ID`. Normal form: BCNF.
     405 * '''Residual relation `R_A4`''' = `R_A3 − { M_*, C_ID }` (17 attributes). No dependency left needs `C_ID`. Dependencies: FD10, FD11. Candidate key and primary key: `T_ID`. Normal form: 2NF.
     406 * '''Dependency preservation:''' FD6 and FD7 are in `R_MARKETS`, and FD10 and FD11 are in `R_A4`. ✓
     407 * '''Lossless join:''' `R_MARKETS ∩ R_A4 = { M_ID }`, and `M_ID → R_MARKETS` (FD6). ✓
     408
     409=== Step 3NF-5 — transitive dependency through `O_ID` ===
     410
     411 * '''Relation analyzed:''' `R_A4` = `{ T_ID, T_*, O_ID, O_*, U_ID, M_ID }` (17 attributes), dependencies FD10, FD11, candidate key and primary key `T_ID`, normal form 2NF.
     412 * '''3NF violations:''' only `O_ID → O_*, U_ID, M_ID`. '''Split on `O_ID`'''.
     413 * '''Decomposition dependency:''' `O_ID → O_*, U_ID, M_ID` (FD10).
     414 * '''New relation `R_ORDERS`''' = `{ O_ID, O_*, U_ID, M_ID }` (11 attributes). Dependencies: FD10. Candidate key: `O_ID`. Normal form: BCNF.
     415 * '''Residual relation `R_TRANSACTIONS`''' = `R_A4 − { O_*, U_ID, M_ID }` = `{ T_ID, T_*, O_ID }` (7 attributes). Dependencies: FD11. Candidate key: `T_ID`. Normal form: BCNF. Keeping `U_ID` here would have left the transitive dependency `T_ID → O_ID → U_ID` inside the relation. `T_ID → U_ID` was removed from the cover as redundant, so nothing is lost.
     416 * '''Dependency preservation:''' FD10 is in `R_ORDERS`, and FD11 is in `R_TRANSACTIONS`. ✓
     417 * '''Lossless join:''' `R_ORDERS ∩ R_TRANSACTIONS = { O_ID }`, and `O_ID → R_ORDERS` (FD10). ✓
     418
     419'''Result of 3NF:''' `R_USERS`, `R_CRYPTO`, `R_HOLDINGS`, `R_MARKETS`, `R_ORDERS`,
     420`R_TRANSACTIONS` (from `R_A`), and `R_B`, `R_C`, `R_D`, `R_E`, `R_F`, `S6` unchanged. 12
     421relations, all in 3NF, and all except `S6` in BCNF. All 18 dependencies are preserved.
    350422
    351423== BCNF if possible ==
    352424
    353 '''Relations analyzed:''' the same ten relations, checked against the stricter BCNF rule: every
    354 determinant of every functional dependency that holds on the relation must be a candidate key
    355 of that relation (3NF allows an exception when the dependent side is prime; BCNF does not).
    356 
    357 ||= Relation =||= Functional dependencies in force =||= Determinant =||= Is it a candidate key? =||
    358 || `R_USERS` || FD1, FD2, FD3 || `U_ID`, `U_USERNAME`, `U_EMAIL` || Yes — all three are candidate keys ||
    359 || `R_CRYPTO` || FD4, FD5 || `C_ID`, `C_SYMBOL` || Yes — both candidate keys ||
    360 || `R_MARKETS` || FD6, FD7 || `M_ID`, `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` || Yes — both candidate keys ||
    361 || `R_HOLDINGS` || FD8, FD9 || `H_ID`, `{H_USER_ID, H_CRYPTO_ID}` || Yes — both candidate keys ||
    362 || `R_ORDERS` || FD10 || `O_ID` || Yes — the only candidate key ||
    363 || `R_TRANSACTIONS` || FD11 || `T_ID` || Yes — the only candidate key ||
    364 || `R_MARKET_TRADES` || FD12 || `MT_ID` || Yes — the only candidate key ||
    365 || `R_MARKET_CANDLES` || FD13, FD14 || `MC_ID`, `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` || Yes — both candidate keys ||
    366 || `R_WATCHLISTS` || FD15 || `W_ID` || Yes — the only candidate key ||
    367 || `R_WATCHLIST_ITEMS` || FD16, FD17 || `WI_ID`, `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` || Yes — both candidate keys ||
    368 
    369 Every determinant in every relation is one of that relation's own candidate keys. '''All ten relations are already in BCNF''' — the highest of the four normal forms this phase asks for,
    370 reached in the same step that fixed 2NF. This is not a coincidence: it happens because the
    371 canonical cover already grouped each relation's own key directly against its own attributes
    372 with no attribute appearing on the right side of two different relations' dependencies, which
    373 is exactly what synthesis from a canonical cover guarantees when, as here, none of the
    374 per-cluster functional dependencies overlap.
    375 
    376 No further decomposition is possible or necessary; splitting any of the ten relations further
    377 would only separate attributes that already depend on the ''whole'' key of a BCNF relation,
    378 which cannot fix anything and only costs a join.
     425BCNF requires '''every''' determinant of a non-trivial dependency to be a superkey, even when
     426the dependent attribute is prime.
     427
     428||= Relation =||= Dependencies in force =||= Determinants =||= All superkeys? =||
     429|| `R_USERS` || FD1, FD2, FD3 || `U_ID`, `U_USERNAME`, `U_EMAIL` || yes ||
     430|| `R_CRYPTO` || FD4, FD5 || `C_ID`, `C_SYMBOL` || yes ||
     431|| `R_MARKETS` || FD6, FD7 || `M_ID`, `{C_ID, M_QUOTE_CURRENCY}` || yes ||
     432|| `R_HOLDINGS` || FD8, FD9 || `H_ID`, `{U_ID, C_ID}` || yes ||
     433|| `R_ORDERS` || FD10 || `O_ID` || yes ||
     434|| `R_TRANSACTIONS` || FD11 || `T_ID` || yes ||
     435|| `R_B` || FD13 || `OE_ID` || yes ||
     436|| `R_C` || FD12 || `MT_ID` || yes ||
     437|| `R_D` || FD14, FD15 || `MC_ID`, `{M_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` || yes ||
     438|| `R_E` || FD16 || `W_ID` || yes ||
     439|| `R_F` || FD17, FD18 || `WI_ID`, `{W_ID, C_ID}` || yes ||
     440|| `S6` || `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME`; `WI_ID → W_ID`; derived ones such as `MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` and `MT_ID, W_ID → WI_ID` || `MC_ID`, `WI_ID`, `{MT_ID, MC_TIMEFRAME, MC_CANDLE_TIME}`, `{MT_ID, W_ID}`, … || '''no''' ||
     441
     442'''Dependencies that violate BCNF — only in `S6`.''' `MC_ID` determines `MC_TIMEFRAME` and
     443`MC_CANDLE_TIME`, and `WI_ID` determines `W_ID`, but neither `MC_ID` nor `WI_ID` is a superkey
     444of `S6`. 3NF allowed this because the dependent attributes are prime. BCNF does not. The derived
     445dependencies all involve `W_ID` or `MC_TIMEFRAME`/`MC_CANDLE_TIME`, so they disappear once the
     446two steps below remove those attributes.
     447
     448=== Step BCNF-1 — `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME` ===
     449
     450 * '''Relation analyzed:''' `S6` (8 attributes), dependencies as in the table above, candidate keys K1–K4, primary key K1, normal form 3NF.
     451 * '''BCNF violations:''' `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME` and `WI_ID → W_ID`, and the derived ones that depend on them. '''Split first on `MC_ID`'''. The order does not matter here, because the two violations share no attribute.
     452 * '''Decomposition dependency:''' `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME`. `MC_ID` is not a superkey of `S6`.
     453 * '''New relation''' `{ MC_ID, MC_TIMEFRAME, MC_CANDLE_TIME }`. Dependencies: `MC_ID → MC_TIMEFRAME, MC_CANDLE_TIME`. Key: `MC_ID`. Normal form: BCNF. It is a projection of `R_D`, which already contains these attributes with the same key, so it adds no information and is merged into `R_D`.
     454 * '''Residual relation `S7`''' = `{ T_ID, OE_ID, MT_ID, MC_ID, W_ID, WI_ID }` (6 attributes). Dependencies: `WI_ID → W_ID`, and derived ones such as `MT_ID, W_ID → WI_ID`. Candidate keys: `{T_ID, OE_ID, MT_ID, MC_ID, WI_ID}` (K1) and `{T_ID, OE_ID, MT_ID, MC_ID, W_ID}` (K2). Normal form: 3NF.
     455 * '''Dependency preservation:''' no dependency of the cover is affected. FD14 and FD15 are in `R_D`. ✓
     456 * '''Lossless join:''' the intersection is `{ MC_ID }`, and `MC_ID → { MC_ID, MC_TIMEFRAME, MC_CANDLE_TIME }`. ✓
     457
     458=== Step BCNF-2 — `WI_ID → W_ID` ===
     459
     460 * '''Relation analyzed:''' `S7` (6 attributes), dependencies `WI_ID → W_ID` and derived ones, candidate keys K1, K2, primary key K1, normal form 3NF.
     461 * '''BCNF violations:''' only `WI_ID → W_ID` (and the derived `MT_ID, W_ID → WI_ID`). '''Split on `WI_ID`'''.
     462 * '''Decomposition dependency:''' `WI_ID → W_ID`. `WI_ID` is not a superkey of `S7`.
     463 * '''New relation''' `{ WI_ID, W_ID }`. Dependencies: `WI_ID → W_ID`. Key: `WI_ID`. Normal form: BCNF. For the same reason as in BCNF-1, it is merged into `R_F`.
     464 * '''Residual relation `R_KEY`''' = `{ T_ID, OE_ID, MT_ID, MC_ID, WI_ID }` (5 attributes). No non-trivial dependency holds among these attributes. Candidate key: all five (= K1). Normal form: BCNF.
     465 * '''Dependency preservation:''' no dependency of the cover is affected. FD17 and FD18 are in `R_F`. The derived dependencies of `S6`/`S7` follow from FD12, FD15, FD17 and FD18, which are all preserved. ✓
     466 * '''Lossless join:''' the intersection is `{ WI_ID }`, and `WI_ID → { WI_ID, W_ID }`. ✓
     467
     468'''Result: every relation is in BCNF.''' The decomposition into these 12 relations is lossless
     469(each of the 13 binary steps passed the test) and preserves all 18 dependencies of the
     470canonical cover.
    379471
    380472== Final result and discussion ==
    381473
    382474=== Normalized relational model ===
     475
     476Each relation is followed by its keys (primary key first). An attribute that is the
     477identifier of another relation is marked `→` with that relation.
    383478
    384479{{{
    385480R_USERS          (U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH,
    386                    U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT)
     481                  U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_RESERVED_BALANCE,
     482                  U_CREATED_AT, U_UPDATED_AT)
     483                  keys: U_ID; U_USERNAME; U_EMAIL
    387484R_CRYPTO         (C_ID, C_SYMBOL, C_NAME, C_CREATED_AT)
    388 R_MARKETS        (M_ID, M_CRYPTO_ID → R_CRYPTO, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT)
    389 R_HOLDINGS       (H_ID, H_USER_ID → R_USERS, H_CRYPTO_ID → R_CRYPTO, H_QUANTITY,
    390                    H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT)
    391 R_ORDERS         (O_ID, O_USER_ID → R_USERS, O_MARKET_ID → R_MARKETS, O_SIDE, O_TYPE,
    392                    O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT)
    393 R_TRANSACTIONS   (T_ID, T_USER_ID → R_USERS, T_TYPE, T_AMOUNT, T_CURRENCY,
    394                    T_RELATED_ORDER → R_ORDERS, T_CREATED_AT, T_DESCRIPTION)
    395 R_MARKET_TRADES  (MT_ID, MT_MARKET_ID → R_MARKETS, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY,
    396                    MT_SIDE, MT_SOURCE)
    397 R_MARKET_CANDLES (MC_ID, MC_MARKET_ID → R_MARKETS, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW,
    398                    MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME)
    399 R_WATCHLISTS     (W_ID, W_USER_ID → R_USERS, W_NAME, W_CREATED_AT)
    400 R_WATCHLIST_ITEMS(WI_ID, WI_WATCHLIST_ID → R_WATCHLISTS, WI_CRYPTO_ID → R_CRYPTO, WI_ADDED_AT)
     485                  keys: C_ID; C_SYMBOL
     486R_MARKETS        (M_ID, C_ID → R_CRYPTO, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT)
     487                  keys: M_ID; {C_ID, M_QUOTE_CURRENCY}
     488R_HOLDINGS       (H_ID, U_ID → R_USERS, C_ID → R_CRYPTO, H_QUANTITY,
     489                  H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT)
     490                  keys: H_ID; {U_ID, C_ID}
     491R_ORDERS         (O_ID, U_ID → R_USERS, M_ID → R_MARKETS, O_SIDE, O_TYPE, O_STATUS,
     492                  O_QUANTITY, O_FILLED_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT)
     493                  key: O_ID
     494R_TRANSACTIONS   (T_ID, O_ID → R_ORDERS, T_TYPE, T_AMOUNT, T_CURRENCY, T_CREATED_AT,
     495                  T_DESCRIPTION)
     496                  key: T_ID
     497R_MARKET_TRADES  (MT_ID, M_ID → R_MARKETS, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY,
     498                  MT_SIDE, MT_SOURCE, O_ID_FILLSBUY → R_ORDERS (nullable),
     499                  O_ID_FILLSSELL → R_ORDERS (nullable))              [= R_C]
     500                  key: MT_ID
     501R_ORDER_EVENTS   (OE_ID, O_ID → R_ORDERS, OE_EVENT_TYPE, OE_QUANTITY, OE_PRICE,
     502                  OE_STATUS_AFTER, OE_CREATED_AT)                     [= R_B]
     503                  key: OE_ID
     504R_MARKET_CANDLES (MC_ID, M_ID → R_MARKETS, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW,
     505                  MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME)                [= R_D]
     506                  keys: MC_ID; {M_ID, MC_TIMEFRAME, MC_CANDLE_TIME}
     507R_WATCHLISTS     (W_ID, U_ID → R_USERS, W_NAME, W_CREATED_AT)         [= R_E]
     508                  key: W_ID
     509R_WATCHLIST_ITEMS(WI_ID, W_ID → R_WATCHLISTS, C_ID → R_CRYPTO, WI_ADDED_AT)  [= R_F]
     510                  keys: WI_ID; {W_ID, C_ID}
     511R_KEY            (T_ID, OE_ID, MT_ID, MC_ID, WI_ID)                   [= R_KEY]
     512                  key: all five
    401513}}}
    402514
    403 Ten relations, every one in BCNF, connected by the eleven foreign keys spelled out above.
    404 
    405515=== Discussion ===
    406516
    407 '''This is the P2 design.''' Strip the `U_`/`C_`/`M_`/… prefixes back to plain column names and
    408 `R_USERS, R_CRYPTO, R_MARKETS, R_HOLDINGS, R_ORDERS, R_TRANSACTIONS, R_MARKET_TRADES, R_MARKET_CANDLES, R_WATCHLISTS, R_WATCHLIST_ITEMS` are, attribute for attribute and key for
    409 key, `users, crypto, markets, holdings, orders, transactions, market_trades, market_candles, watchlists, watchlist_items` from
    410 [wiki:RelationalDesign]. Every foreign key matches,
    411 every candidate key matches (including the less obvious composite ones — `{user_id, crypto_id}` on `holdings`, `{crypto_id, quote_currency}` on `markets`, `{market_id, timeframe, candle_time}` on `market_candles`), and the normal form matches (P2 already claimed 3NF; this
    412 phase shows the stronger result that the design is actually in BCNF).
    413 
    414 That is not a coincidence of two people happening to agree — it is what should happen when a
    415 design is derived correctly twice by two different methods from the same underlying model:
    416 P2 got here by applying the standard ER-to-relational transformation rules (each entity
    417 becomes a table on its own key, each attributed M:N relationship becomes a table on the
    418 combined key, each attributeless 1:N relationship becomes a foreign key on the "many" side).
    419 This phase got here by ignoring that transformation entirely, writing down only the
    420 attributes and the functional dependencies they obey, and mechanically applying 2NF/3NF/BCNF
    421 synthesis. Landing on the same ten relations either means the P2 transformation rules are
    422 sound for this particular model (which they are, for exactly the reason [wiki:RelationalDesign] (Normalisation section)
    423 already argued: single-column UUID primary keys everywhere rule out partial dependencies by
    424 construction, and no non-key attribute references another non-key attribute anywhere in the
    425 model, which rules out transitive dependencies too), or it is a coincidence spanning ten
    426 independently-checked relations and dozens of functional dependencies — the first explanation
    427 is the only credible one.
    428 
    429 '''The one substantive difference''' is `holdings.avg_price`, which P2 documents as a
    430 ''derived'' attribute — the running weighted-average buy price, recomputable from the `buy` rows
    431 in `transactions` — kept as a stored column anyway for read performance
    432 ([wiki:RelationalDesign] (Normalisation section) calls this out
    433 explicitly as an accepted denormalisation). Nothing in this phase's functional-dependency
    434 analysis can see that `H_AVG_PRICE` is derivable from `T_*` rows rather than stored
    435 independently — FD8 (`H_ID → H_AVG_PRICE`) is a perfectly ordinary functional dependency
    436 either way, because ''derivability from a different relation's rows'' is a property of the data
    437 and the application logic that maintains it (see
    438 [wiki:UseCase0004]'s `ON CONFLICT … DO UPDATE`), not something
    439 that shows up as a violation of any single-relation normal form. Formal normalization and "no
    440 column is a cached computation of other columns" are related but different concerns; this
    441 phase only checked the first one.
    442 
    443 '''Which design is used going forward:''' P2's, unchanged. Since the two designs coincide
    444 exactly, "restructuring the database objects" means confirming there is nothing to change
    445 rather than writing new DDL. `server/db/schema_creation.sql`
    446 already matches `R_USERS`…`R_WATCHLIST_ITEMS` column-for-column (including
    447 `holdings.reserved_quantity`, added between P2 and this phase — see
    448 [wiki:RelationalDesignAIUsage] (section "Session 3 — 2026-09-16")
    449 — which is `H_RESERVED_QUANTITY` above, correctly grouped under `R_HOLDINGS`'s key alongside
    450 `H_QUANTITY` and not treated as needing a relation of its own). P4's prototype
    451 (`server/trade.go`, `server/portfolio.go`) keeps working against the same schema without
    452 change. [wiki:RelationalDesign] has been updated with a
    453 short note pointing here as the formal validation of its normal-form claim.
    454 
    455 The table definitions in `server/db/schema_creation.sql`:
     517'''The eleven data relations are the P2 design, with one difference''' (`transactions.user_id`,
     518explained below). Each relation is one entity set of the ER model:
     519
     520||= P5 relation =||= P2 table =||= How the relationships appear =||
     521|| `R_USERS` || `users` || — ||
     522|| `R_CRYPTO` || `crypto` || — ||
     523|| `R_MARKETS` || `markets` || `C_ID` = `crypto_id` (`QuotedOn`) ||
     524|| `R_HOLDINGS` || `holdings` || `U_ID` = `user_id` (`Holds`), `C_ID` = `crypto_id` (`PositionIn`) ||
     525|| `R_ORDERS` || `orders` || `U_ID` = `user_id` (`Places`), `M_ID` = `market_id` (`PlacedOn`) ||
     526|| `R_TRANSACTIONS` || `transactions` || `O_ID` = `related_order` (`Settles`); P2 also stores `user_id` (`Records`), see below ||
     527|| `R_MARKET_TRADES` || `market_trades` || `M_ID` = `market_id` (`Fills`), `O_ID_FILLSBUY` = `buy_order_id`, `O_ID_FILLSSELL` = `sell_order_id` ||
     528|| `R_ORDER_EVENTS` || `order_events` || `O_ID` = `order_id` (`Logs`) ||
     529|| `R_MARKET_CANDLES` || `market_candles` || `M_ID` = `market_id` (`Aggregates`) ||
     530|| `R_WATCHLISTS` || `watchlists` || `U_ID` = `user_id` (`Owns`) ||
     531|| `R_WATCHLIST_ITEMS` || `watchlist_items` || `W_ID` = `watchlist_id` (`Contains`), `C_ID` = `crypto_id` (`Lists`) ||
     532
     533The two methods produce the foreign keys differently. In P2 they come from a transformation
     534rule: a 1:N relationship becomes a column on the N side. Here, each one appears because a
     535dependency of kind (R), for example `O_ID → U_ID`, keeps the other entity's identifier in the
     536same relation as the entity that depends on it. The candidate keys also match, including the
     537composite ones (`{C_ID, M_QUOTE_CURRENCY}`, `{U_ID, C_ID}`, `{M_ID, MC_TIMEFRAME,
     538MC_CANDLE_TIME}`, `{W_ID, C_ID}`). They are exactly the `UNIQUE` constraints in
     539`schema_creation.sql`.
     540
     541'''The one difference: `transactions.user_id`.''' The decomposition drops `U_ID` from
     542`R_TRANSACTIONS`, because `T_ID → U_ID` follows from `T_ID → O_ID` and `O_ID → U_ID`. That is
     543correct for every ledger entry that settles an order. It does not work for a '''deposit'''.
     544`Settles` is partial, so a deposit has no order, and without `user_id` a deposit would have no
     545owner at all. The de-normalized relation cannot show this case. Every one of its tuples
     546contains an order (every key contains `OE_ID`, and every order event has an order), so a
     547ledger entry without an order cannot appear in it. P2 therefore keeps `user_id` (the
     548relationship `Records`) as a deliberate exception. As a result, the implemented
     549`transactions` table is in '''2NF but not in 3NF''' (`related_order → user_id` is a transitive
     550dependency), and this is by design. For entries with an order,
     551`transactions.user_id` repeats the order's user. The only code that sets `related_order` (the buy
     552and sell inserts in `advanced_db.sql`) writes the user and the id of the same order row. No
     553database constraint enforces this.
     554
     555'''Two order columns in `market_trades`.''' `FillsBuy` and `FillsSell` needed two role
     556attributes already in the de-normalized relation, and both end up in `R_MARKET_TRADES`.
     557They correspond to `buy_order_id` and `sell_order_id`.
     558
     559'''`R_KEY` belongs to the formal result, but it is not implemented as a table.''' It is the
     560relation that contains a key of `R_EDUBERZA`, and the lossless-join result above holds for all
     56112 relations ''including'' it. It records no fact of the domain. It only says which ledger
     562entry, order event, trade, candle and watchlist item were put into the same tuple, and that
     563combination exists only because we started from one single relation. Not implementing it is
     564an implementation decision. The eleven implemented tables are not claimed to reconstruct
     565`R_EDUBERZA` on their own. They keep every attribute and every dependency of the canonical
     566cover, and that is what the application needs.
     567
     568'''`holdings.avg_price`''' is shown as a ''derived'' attribute in the ER model: it can be
     569recomputed from the buy history. It is still stored, and that is a deliberate
     570denormalisation (see [wiki:RelationalDesign] (section "Normalisation")).
     571Normalisation cannot detect this. `H_ID → H_AVG_PRICE` is an ordinary functional dependency,
     572because "derivable from rows of another entity" is a property of the application logic
     573that maintains the value (see [wiki:UseCase0004],
     574`ON CONFLICT … DO UPDATE`), not a dependency between attributes of one tuple.
     575
     576'''Which design is used going forward:''' P2's, unchanged. The eleven data relations coincide
     577with the eleven tables of `schema_creation.sql` and
     578`advanced_db.sql` column for column, except for the
     579deliberately kept `transactions.user_id` explained above. So there are no database objects
     580to restructure, and the prototype and the reports of P6/P7 keep working against the same
     581schema.
     582
     583The table definitions in `server/db/schema_creation.sql` and `server/db/advanced_db.sql`:
    456584
    457585{{{
    … …  
    464592    available_balance numeric(18,4)   NOT NULL DEFAULT 0 CHECK (available_balance >= 0),
    465593    invested_balance  numeric(18,4)   NOT NULL DEFAULT 0 CHECK (invested_balance  >= 0),
     594    -- P7: cash committed to the user's active buy orders, moved out of
     595    -- available_balance when the order is placed and consumed as it fills.
     596    reserved_balance  numeric(18,4)   NOT NULL DEFAULT 0 CHECK (reserved_balance  >= 0),
    466597    created_at        timestamptz     NOT NULL DEFAULT now(),
    467598    updated_at        timestamptz
    468599);
    469600
    470 -- ============================================================================
    471 -- CRYPTO
    472 -- Catalog of crypto assets available on the platform.
    473 -- ============================================================================
    474601CREATE TABLE project.crypto (
    475602    id         uuid         PRIMARY KEY DEFAULT gen_random_uuid(),
    … …  
    479606);
    480607
    481 -- ============================================================================
    482 -- MARKETS
    483 -- A market is a (crypto, quote_currency) pair, e.g. BTC/USD.
    484 -- ============================================================================
    485608CREATE TABLE project.markets (
    486609    id             uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    … …  
    492615);
    493616
    494 -- ============================================================================
    495 -- HOLDINGS
    496 -- Per-user crypto position with running weighted average entry price.
    497 -- ============================================================================
    498617CREATE TABLE project.holdings (
    499618    id                uuid           PRIMARY KEY DEFAULT gen_random_uuid(),
    … …  
    514633);
    515634
    516 -- ============================================================================
    517 -- ORDERS
    518 -- Orders placed by users on a market.
    519 -- ============================================================================
    520635CREATE TABLE project.orders (
    521636    id          uuid           PRIMARY KEY DEFAULT gen_random_uuid(),
    … …  
    524639    side        varchar(4)     NOT NULL CHECK (side   IN ('buy', 'sell')),
    525640    type        varchar(20)    NOT NULL CHECK (type   IN ('market', 'limit')),
    526     status      varchar(20)    NOT NULL CHECK (status IN ('open', 'executed', 'cancelled')),
     641    status      varchar(20)    NOT NULL CHECK (status IN ('open', 'partially_filled', 'executed', 'cancelled')),
    527642    quantity    numeric(20,4)  NOT NULL CHECK (quantity > 0),
     643    -- P7: how much of the order has been traded so far; remaining is
     644    -- quantity - filled_quantity. Maintained from market_trades.
     645    filled_quantity numeric(20,4) NOT NULL DEFAULT 0
     646                               CHECK (filled_quantity >= 0 AND filled_quantity <= quantity),
    528647    price       numeric(18,6),
    529648    placed_at   timestamptz    NOT NULL DEFAULT now(),
    … …  
    531650);
    532651
    533 CREATE INDEX idx_orders_user      ON project.orders(user_id);
    534 CREATE INDEX idx_orders_market    ON project.orders(market_id);
    535 CREATE INDEX idx_orders_status    ON project.orders(status);
    536 
    537 -- ============================================================================
    538 -- TRANSACTIONS
    539 -- Financial ledger: deposits, buys, sells, fees.
    540 -- ============================================================================
    541652CREATE TABLE project.transactions (
    542653    id            uuid           PRIMARY KEY DEFAULT gen_random_uuid(),
    … …  
    550661);
    551662
    552 CREATE INDEX idx_transactions_user ON project.transactions(user_id, created_at DESC);
    553 
    554 -- ============================================================================
    555 -- MARKET TRADES
    556 -- Raw executed trades on a market. Source of truth for current price.
    557 -- ============================================================================
    558663CREATE TABLE project.market_trades (
    559664    id          bigserial      PRIMARY KEY,
    … …  
    563668    quantity    numeric(20,6)  NOT NULL CHECK (quantity > 0),
    564669    side        varchar(4)     CHECK (side IN ('buy', 'sell')),
    565     source      varchar(50)    NOT NULL DEFAULT 'simulation'
    566 );
    567 
    568 CREATE INDEX idx_market_trades_market_time ON project.market_trades(market_id, executed_at DESC);
    569 
    570 -- ============================================================================
    571 -- MARKET CANDLES
    572 -- OHLCV aggregates over standard timeframes.
    573 -- ============================================================================
     670    source      varchar(50)    NOT NULL DEFAULT 'simulation',
     671    -- P7: the orders this trade filled. NULL on a side means the counterparty
     672    -- was the simulated market (bot ticks have both NULL).
     673    buy_order_id  uuid         REFERENCES project.orders(id),
     674    sell_order_id uuid         REFERENCES project.orders(id)
     675);
     676
    574677CREATE TABLE project.market_candles (
    575678    id          bigserial      PRIMARY KEY,
    … …  
    585688);
    586689
    587 CREATE INDEX idx_market_candles_market_tf_time ON project.market_candles(market_id, timeframe, candle_time DESC);
    588 
    589 -- ============================================================================
    590 -- WATCHLISTS
    591 -- ============================================================================
    592690CREATE TABLE project.watchlists (
    593691    id         uuid         PRIMARY KEY DEFAULT gen_random_uuid(),
    … …  
    604702    CONSTRAINT uq_watchlist_crypto UNIQUE (watchlist_id, crypto_id)
    605703);
     704
     705CREATE TABLE project.order_events (
     706    id           bigserial      PRIMARY KEY,
     707    order_id     uuid           NOT NULL REFERENCES project.orders(id) ON DELETE CASCADE,
     708    event_type   varchar(20)    NOT NULL
     709                 CHECK (event_type IN ('placed', 'partially_filled', 'filled', 'cancelled')),
     710    quantity     numeric(20,4)  NOT NULL,
     711    price        numeric(18,6),
     712    status_after varchar(20)    NOT NULL,
     713    created_at   timestamptz    NOT NULL DEFAULT clock_timestamp()
     714);
    606715}}}
  • docs/README.md

    r0cee8ec r1549dae  
    3030`ERModel`, `RelationalDesign`, `UseCaseModel`, `UseCase0001`, …,
    3131`PrototypeImplementation`, `BuildInstructions`, and the four `*AIUsage` pages.
    32 Attachments (`ERModel_v01.xml`, `ERModel_v01.png`, `schema_creation.sql`,
    33 `data_load.sql`, `relational_schema.jpg`, the screenshots) attach to the page
     32Attachments (`ERModel_v05.xml`, `ERModel_v05.png`, `schema_creation.sql`,
     33`data_load.sql`, `relational_diagram_v4.png`, the screenshots) attach to the page
    3434that documents them.
    3535
    … …  
    8080| File | Phase | What |
    8181|------|-------|------|
    82 | [`ERModel_v03.xml`](P1-ConceptualModel/ERModel_v03.xml) | P1 | TerraER source, current version |
    83 | [`ERModel_v03.png`](P1-ConceptualModel/ERModel_v03.png) | P1 | Exported diagram image, current version |
     82| [`ERModel_v05.xml`](P1-ConceptualModel/ERModel_v05.xml) | P1 | TerraER source, current version |
     83| [`ERModel_v05.png`](P1-ConceptualModel/ERModel_v05.png) | P1 | Exported diagram image, current version |
     84| [`ERModel_v04.xml`](P1-ConceptualModel/ERModel_v04.xml), [`ERModel_v03.xml`](P1-ConceptualModel/ERModel_v03.xml) | P1 | TerraER source, previous versions (kept per P1 rules) |
     85| [`ERModel_v04.png`](P1-ConceptualModel/ERModel_v04.png), [`ERModel_v03.png`](P1-ConceptualModel/ERModel_v03.png) | P1 | Exported diagram images, previous versions |
    8486| [`ERModel_v02.xml`](P1-ConceptualModel/ERModel_v02.xml) | P1 | TerraER source, previous version (kept per P1 rules) |
    8587| [`ERModel_v02.png`](P1-ConceptualModel/ERModel_v02.png) | P1 | Exported diagram image, previous version |
    … …  
    8890| [`../server/db/schema_creation.sql`](../server/db/schema_creation.sql) | P2 | DDL — drops and recreates the `project` schema |
    8991| [`../server/db/data_load.sql`](../server/db/data_load.sql) | P2 | DML — truncates and reloads sample data |
    90 | [`relational_schema.jpg`](P2-RelationalDesign/relational_schema.jpg) | P2 | Crow's-foot diagram exported from DBeaver |
     92| [`relational_diagram_v4.png`](P2-RelationalDesign/relational_diagram_v4.png) | P2 | Relational diagram exported from DBeaver, laid out like `ERModel_v05.png` |
    9193| [`../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`) |
    9294| [`../server/db/advanced_db.sql`](../server/db/advanced_db.sql) | P7 | Triggers, functions, views and the background job (part of `-init`) |
Note: See TracChangeset for help on using the changeset viewer.