| 1 | = Normalization =
|
|---|
| 2 |
|
|---|
| 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.
|
|---|
| 10 |
|
|---|
| 11 | == De-normalized database form ==
|
|---|
| 12 |
|
|---|
| 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` ||
|
|---|
| 37 | || `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:'''
|
|---|
| 53 |
|
|---|
| 54 | {{{
|
|---|
| 55 | R_EDUBERZA(
|
|---|
| 56 | 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,
|
|---|
| 58 | 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,
|
|---|
| 63 | 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
|
|---|
| 71 | )
|
|---|
| 72 | }}}
|
|---|
| 73 |
|
|---|
| 74 | Every attribute is single-valued and atomic (a balance, a timestamp, a symbol, an amount —
|
|---|
| 75 | nothing 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
|
|---|
| 77 | checks.
|
|---|
| 78 |
|
|---|
| 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.
|
|---|
| 317 |
|
|---|
| 318 | == 3NF decomposition ==
|
|---|
| 319 |
|
|---|
| 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.
|
|---|
| 350 |
|
|---|
| 351 | == BCNF if possible ==
|
|---|
| 352 |
|
|---|
| 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.
|
|---|
| 379 |
|
|---|
| 380 | == Final result and discussion ==
|
|---|
| 381 |
|
|---|
| 382 | === Normalized relational model ===
|
|---|
| 383 |
|
|---|
| 384 | {{{
|
|---|
| 385 | R_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)
|
|---|
| 387 | R_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)
|
|---|
| 401 | }}}
|
|---|
| 402 |
|
|---|
| 403 | Ten relations, every one in BCNF, connected by the eleven foreign keys spelled out above.
|
|---|
| 404 |
|
|---|
| 405 | === Discussion ===
|
|---|
| 406 |
|
|---|
| 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`:
|
|---|
| 456 |
|
|---|
| 457 | {{{
|
|---|
| 458 | CREATE TABLE project.users (
|
|---|
| 459 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 460 | username varchar(50) NOT NULL UNIQUE,
|
|---|
| 461 | email varchar(255) NOT NULL UNIQUE,
|
|---|
| 462 | full_name varchar(200),
|
|---|
| 463 | password_hash varchar(255) NOT NULL,
|
|---|
| 464 | available_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (available_balance >= 0),
|
|---|
| 465 | invested_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (invested_balance >= 0),
|
|---|
| 466 | created_at timestamptz NOT NULL DEFAULT now(),
|
|---|
| 467 | updated_at timestamptz
|
|---|
| 468 | );
|
|---|
| 469 |
|
|---|
| 470 | -- ============================================================================
|
|---|
| 471 | -- CRYPTO
|
|---|
| 472 | -- Catalog of crypto assets available on the platform.
|
|---|
| 473 | -- ============================================================================
|
|---|
| 474 | CREATE TABLE project.crypto (
|
|---|
| 475 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 476 | symbol varchar(20) NOT NULL UNIQUE,
|
|---|
| 477 | name varchar(255) NOT NULL,
|
|---|
| 478 | created_at timestamptz NOT NULL DEFAULT now()
|
|---|
| 479 | );
|
|---|
| 480 |
|
|---|
| 481 | -- ============================================================================
|
|---|
| 482 | -- MARKETS
|
|---|
| 483 | -- A market is a (crypto, quote_currency) pair, e.g. BTC/USD.
|
|---|
| 484 | -- ============================================================================
|
|---|
| 485 | CREATE TABLE project.markets (
|
|---|
| 486 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 487 | crypto_id uuid NOT NULL REFERENCES project.crypto(id),
|
|---|
| 488 | quote_currency char(3) NOT NULL DEFAULT 'USD',
|
|---|
| 489 | is_active boolean NOT NULL DEFAULT true,
|
|---|
| 490 | created_at timestamptz NOT NULL DEFAULT now(),
|
|---|
| 491 | CONSTRAINT uq_markets UNIQUE (crypto_id, quote_currency)
|
|---|
| 492 | );
|
|---|
| 493 |
|
|---|
| 494 | -- ============================================================================
|
|---|
| 495 | -- HOLDINGS
|
|---|
| 496 | -- Per-user crypto position with running weighted average entry price.
|
|---|
| 497 | -- ============================================================================
|
|---|
| 498 | CREATE TABLE project.holdings (
|
|---|
| 499 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 500 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
|
|---|
| 501 | crypto_id uuid NOT NULL REFERENCES project.crypto(id),
|
|---|
| 502 | quantity numeric(20,4) NOT NULL CHECK (quantity >= 0),
|
|---|
| 503 | -- Committed to the user's own open sell orders, not yet removed from the
|
|---|
| 504 | -- position. quantity - reserved_quantity is what is actually free to
|
|---|
| 505 | -- sell — the crypto-side equivalent of users.available_balance.
|
|---|
| 506 | reserved_quantity numeric(20,4) NOT NULL DEFAULT 0
|
|---|
| 507 | CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity),
|
|---|
| 508 | -- Weighted-average entry price. NOT NULL so that the P/L arithmetic in
|
|---|
| 509 | -- v_portfolio can never silently produce NULL for an existing position.
|
|---|
| 510 | avg_price numeric(18,6) NOT NULL DEFAULT 0 CHECK (avg_price >= 0),
|
|---|
| 511 | created_at timestamptz NOT NULL DEFAULT now(),
|
|---|
| 512 | updated_at timestamptz,
|
|---|
| 513 | CONSTRAINT uq_holdings_user_crypto UNIQUE (user_id, crypto_id)
|
|---|
| 514 | );
|
|---|
| 515 |
|
|---|
| 516 | -- ============================================================================
|
|---|
| 517 | -- ORDERS
|
|---|
| 518 | -- Orders placed by users on a market.
|
|---|
| 519 | -- ============================================================================
|
|---|
| 520 | CREATE TABLE project.orders (
|
|---|
| 521 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 522 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
|
|---|
| 523 | market_id uuid NOT NULL REFERENCES project.markets(id),
|
|---|
| 524 | side varchar(4) NOT NULL CHECK (side IN ('buy', 'sell')),
|
|---|
| 525 | type varchar(20) NOT NULL CHECK (type IN ('market', 'limit')),
|
|---|
| 526 | status varchar(20) NOT NULL CHECK (status IN ('open', 'executed', 'cancelled')),
|
|---|
| 527 | quantity numeric(20,4) NOT NULL CHECK (quantity > 0),
|
|---|
| 528 | price numeric(18,6),
|
|---|
| 529 | placed_at timestamptz NOT NULL DEFAULT now(),
|
|---|
| 530 | executed_at timestamptz
|
|---|
| 531 | );
|
|---|
| 532 |
|
|---|
| 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 | -- ============================================================================
|
|---|
| 541 | CREATE TABLE project.transactions (
|
|---|
| 542 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 543 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
|
|---|
| 544 | type varchar(50) NOT NULL CHECK (type IN ('deposit', 'buy', 'sell', 'fee')),
|
|---|
| 545 | amount numeric(18,4) NOT NULL,
|
|---|
| 546 | currency char(3) NOT NULL DEFAULT 'USD',
|
|---|
| 547 | related_order uuid REFERENCES project.orders(id),
|
|---|
| 548 | created_at timestamptz NOT NULL DEFAULT now(),
|
|---|
| 549 | description text
|
|---|
| 550 | );
|
|---|
| 551 |
|
|---|
| 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 | -- ============================================================================
|
|---|
| 558 | CREATE TABLE project.market_trades (
|
|---|
| 559 | id bigserial PRIMARY KEY,
|
|---|
| 560 | market_id uuid NOT NULL REFERENCES project.markets(id),
|
|---|
| 561 | executed_at timestamptz NOT NULL,
|
|---|
| 562 | price numeric(18,6) NOT NULL CHECK (price > 0),
|
|---|
| 563 | quantity numeric(20,6) NOT NULL CHECK (quantity > 0),
|
|---|
| 564 | 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 | -- ============================================================================
|
|---|
| 574 | CREATE TABLE project.market_candles (
|
|---|
| 575 | id bigserial PRIMARY KEY,
|
|---|
| 576 | market_id uuid NOT NULL REFERENCES project.markets(id),
|
|---|
| 577 | timeframe varchar(5) NOT NULL CHECK (timeframe IN ('1m', '5m', '1h', '1d')),
|
|---|
| 578 | open numeric(18,6) NOT NULL,
|
|---|
| 579 | high numeric(18,6) NOT NULL,
|
|---|
| 580 | low numeric(18,6) NOT NULL,
|
|---|
| 581 | close numeric(18,6) NOT NULL,
|
|---|
| 582 | volume numeric(20,6) NOT NULL,
|
|---|
| 583 | candle_time timestamptz NOT NULL,
|
|---|
| 584 | CONSTRAINT uq_candle UNIQUE (market_id, timeframe, candle_time)
|
|---|
| 585 | );
|
|---|
| 586 |
|
|---|
| 587 | CREATE INDEX idx_market_candles_market_tf_time ON project.market_candles(market_id, timeframe, candle_time DESC);
|
|---|
| 588 |
|
|---|
| 589 | -- ============================================================================
|
|---|
| 590 | -- WATCHLISTS
|
|---|
| 591 | -- ============================================================================
|
|---|
| 592 | CREATE TABLE project.watchlists (
|
|---|
| 593 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 594 | user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
|
|---|
| 595 | name varchar(100) NOT NULL,
|
|---|
| 596 | created_at timestamptz NOT NULL DEFAULT now()
|
|---|
| 597 | );
|
|---|
| 598 |
|
|---|
| 599 | CREATE TABLE project.watchlist_items (
|
|---|
| 600 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|---|
| 601 | watchlist_id uuid NOT NULL REFERENCES project.watchlists(id) ON DELETE CASCADE,
|
|---|
| 602 | crypto_id uuid NOT NULL REFERENCES project.crypto(id),
|
|---|
| 603 | added_at timestamptz NOT NULL DEFAULT now(),
|
|---|
| 604 | CONSTRAINT uq_watchlist_crypto UNIQUE (watchlist_id, crypto_id)
|
|---|
| 605 | );
|
|---|
| 606 | }}}
|
|---|