| 13 | | Two deliberate modeling decisions worth stating up front: |
| 14 | | |
| 15 | | * '''No foreign keys appear in the diagram.''' Connections between entity sets are |
| 16 | | expressed as relationships, per the notation. Foreign-key columns appear only |
| 17 | | in the relational model in !RelationalDesign. |
| 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. |
| | 13 | Three deliberate modeling decisions worth stating up front: |
| | 14 | |
| | 15 | * '''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 | * '''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". |
| 78 | | '''Keys:''' candidates `{id}`, `{crypto_id, quote_currency}` — that pair is |
| 79 | | unique by definition, since a given asset can only be quoted once per |
| 80 | | currency; primary key '''`id`''', so that the many entity sets referencing a |
| 81 | | market carry one narrow column instead of a composite key. |
| | 76 | '''Keys:''' candidate `{id}`; primary key '''`id`''', so that the many entity sets |
| | 77 | related 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 |
| | 79 | a market is `QuotedOn` together with its `quote_currency` identifies the market |
| | 80 | as well. Chen notation cannot draw this, because half of it comes through a |
| | 81 | relationship. P2 enforces it as `UNIQUE(crypto_id, quote_currency)`. |
| 95 | | Placing an order is what triggers a '''reservation''' of whatever it commits — |
| 96 | | the crypto being sold (`Holds.reserved_quantity`, below) on a sell, cash |
| 97 | | already handled the same way on a buy via `available_balance` / |
| 98 | | `invested_balance`. `status` therefore has real meaning as a lifecycle, not |
| 99 | | just a label: `open` means reserved but not yet settled, `executed` means |
| 100 | | settled, `cancelled` would release the reservation without settling (not yet |
| 101 | | exercised by any use case, since only market orders — which settle |
| 102 | | immediately — are implemented). See |
| 103 | | UseCase0005 for the reserve-then-settle |
| 104 | | sequence. |
| | 95 | Placing an order is what triggers a '''reservation''' of whatever it commits: |
| | 96 | the crypto being sold (`Holdings.reserved_quantity`, below) on a sell, and the |
| | 97 | cash (`Users.reserved_balance`) on a buy. Since v04 (after P7) an order can |
| | 98 | wait in the order book and be filled in parts, so `status` is a real |
| | 99 | lifecycle driven by `filled_quantity`: `open` (nothing filled yet), |
| | 100 | `partially_filled`, `executed` (completely filled), or `cancelled`, which |
| | 101 | releases what is still reserved. See |
| | 102 | [wiki:UseCase0005] for the reserve-then-settle |
| | 103 | sequence and |
| | 104 | [wiki:AdvancedDatabaseDevelopment] |
| | 105 | for the rules that keep it consistent. |
| | 158 | Since v04 (after P7) a trade also records which orders it filled, through the |
| | 159 | relationships `FillsBuy` and `FillsSell` below. |
| | 160 | |
| | 161 | ==== !OrderEvents ==== |
| | 162 | ''Added in v04, after P7.'' The audit trail of an order: one event for its |
| | 163 | placement, one for every (partial) fill, and one for a cancellation. The |
| | 164 | `Orders` row only holds the current state; this entity keeps the history of |
| | 165 | how the order got there. Events are recorded automatically by the database. |
| | 166 | |
| | 167 | '''Keys:''' candidate `{id}` only; primary key '''`id`''' (auto-incrementing |
| | 168 | integer, events are only read in order). |
| | 169 | |
| | 170 | ||= Attribute =||= Type =||= Constraints =|| |
| | 171 | || `id` || big integer || PK, required, auto-generated (`bigserial` in P2) || |
| | 172 | || `event_type` || text || required, `placed`, `partially_filled`, `filled` or `cancelled` || |
| | 173 | || `quantity` || numeric(20,4) || required — the ordered quantity for `placed`, the filled amount for a fill, the unfilled rest for `cancelled` || |
| | 174 | || `price` || numeric(18,6) || optional — the order price, or the trade price for a fill || |
| | 175 | || `status_after` || text || required, the order's status after the event || |
| | 176 | || `created_at` || timestamptz || required, set automatically when the event is recorded (`clock_timestamp()`, so events inside one transaction keep their real order) || |
| | 177 | |
| 162 | | '''Keys:''' candidates `{id}`, `{market_id, timeframe, candle_time}` — a market |
| 163 | | has exactly one candle per timeframe per time bucket; primary key '''`id`''', the |
| 164 | | composite is enforced as a uniqueness rule because it is the real-world |
| 165 | | constraint and it is what prevents duplicate candles. |
| 166 | | |
| 167 | | ||= Attribute =||= Type =||= Constraints =|| |
| 168 | | || `id` || integer || PK, required, auto-generated || |
| | 184 | '''Keys:''' candidate `{id}`; primary key '''`id`'''. '''Uniqueness rule:''' a market |
| | 185 | has exactly one candle per timeframe per time bucket, so the market a candle |
| | 186 | `Aggregates` together with `timeframe` and `candle_time` also identifies it. |
| | 187 | This is the real-world constraint that prevents duplicate candles. P2 enforces |
| | 188 | it as `UNIQUE(market_id, timeframe, candle_time)`. |
| | 189 | |
| | 190 | ||= Attribute =||= Type =||= Constraints =|| |
| | 191 | || `id` || big integer || PK, required, auto-generated (`bigserial` in P2) || |
| | 210 | |
| | 211 | ==== Holdings ==== |
| | 212 | A user's position in one crypto asset: how much of it the user owns, how much |
| | 213 | of that is already promised to open sell orders, and at what average price it |
| | 214 | was accumulated. ''An entity set since v05'' (until v04 it was the M:N |
| | 215 | relationship `Holds`). A holding has its own identifier and its own |
| | 216 | lifecycle: it is created on the first buy, updated on every later fill, and |
| | 217 | the prototype reads and locks it as a unit (`SELECT … FOR UPDATE` on the sell |
| | 218 | path). 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 |
| | 222 | has at most one holding per crypto, so the user who `Holds` it together with |
| | 223 | the crypto it is a `PositionIn` also identifies a holding. P2 enforces this as |
| | 224 | `UNIQUE(user_id, crypto_id)`. |
| | 225 | |
| | 226 | `reserved_quantity` mirrors `available_balance`/`invested_balance` on `Users`: |
| | 227 | two independently updated stored numbers, with the amount actually free to use |
| | 228 | computed on demand rather than stored (`quantity − reserved_quantity` here, |
| | 229 | `available_balance` alone on the cash side). Without it, nothing stopped a |
| | 230 | user from placing a second sell order against crypto already promised to a |
| | 231 | first one — `quantity` alone cannot tell "owned" apart from "owned, but |
| | 232 | already committed elsewhere." See history, v03. |
| | 233 | |
| | 234 | ||= Attribute =||= Type =||= Constraints =|| |
| | 235 | || `id` || UUID || PK, required || |
| | 236 | || `quantity` || numeric(20,4) || required, ≥ 0 — total amount owned || |
| | 237 | || `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 || |
| | 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 || |
| | 239 | || `created_at` || timestamptz || required, defaults to now || |
| | 240 | || `updated_at` || timestamptz || optional || |
| | 241 | |
| | 242 | ==== !WatchlistItems ==== |
| | 243 | One asset placed on one watchlist. ''An entity set since v05'' (until v04 it was |
| | 244 | the M:N relationship `Contains`). It has its own identifier, and it is linked |
| | 245 | to its list through `Contains` and to its asset through `Lists`. |
| | 246 | |
| | 247 | '''Keys:''' candidate `{id}`; primary key '''`id`'''. '''Uniqueness rule:''' an asset |
| | 248 | appears at most once on a given list, so the watchlist that `Contains` an item |
| | 249 | together with the crypto it `Lists` also identifies the item. P2 enforces this |
| | 250 | as `UNIQUE(watchlist_id, crypto_id)`. |
| | 251 | |
| | 252 | ||= Attribute =||= Type =||= Constraints =|| |
| | 253 | || `id` || UUID || PK, required || |
| | 254 | || `added_at` || timestamptz || required, defaults to now — recorded so a list can be shown in the order the user built it || |
| | 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 |
| | 287 | filled by many trades (partial fills); a trade fills at most one buy order, |
| | 288 | and none when the simulated market was the buyer. The role of `Orders` in this |
| | 289 | relationship 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 |
| | 294 | trade 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 |
| | 298 | attributes. |
| | 299 | |
| 223 | | ==== Holds — Users (M) : Cryptos (N), partial on both sides, '''with attributes''' ==== |
| 224 | | A user's position in an asset. M:N because one user holds many assets and one |
| 225 | | asset is held by many users, and partial on both sides because a user may hold |
| 226 | | nothing and an asset may be held by nobody. Modeled as a relationship rather |
| 227 | | than an entity set because a position has no identity of its own — it is |
| 228 | | entirely described by ''which user'', ''which asset'', and how much. |
| 229 | | |
| 230 | | `reserved_quantity` mirrors `available_balance`/`invested_balance` on `Users`: |
| 231 | | two independently updated stored numbers, with the amount actually free to use |
| 232 | | computed on demand rather than stored (`quantity − reserved_quantity` here, |
| 233 | | `available_balance` alone on the cash side). Without it, nothing stopped a |
| 234 | | user from placing a second sell order against crypto already promised to a |
| 235 | | first one — `quantity` alone cannot tell "owned" apart from "owned, but |
| 236 | | already committed elsewhere." See the history section below, v03. |
| 237 | | |
| 238 | | ||= Attribute =||= Type =||= Constraints =|| |
| 239 | | || `quantity` || numeric(20,4) || required, ≥ 0 — total amount owned || |
| 240 | | || `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 || |
| 241 | | || `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 || |
| 242 | | || `created_at` || timestamptz || required, defaults to now || |
| 243 | | || `updated_at` || timestamptz || optional || |
| 244 | | |
| 245 | | ==== Contains — Watchlists (M) : Cryptos (N), partial on both sides, '''with attribute''' ==== |
| 246 | | Which assets are on which watchlist. M:N: a list holds many assets, an asset |
| 247 | | appears on many lists. Partial on both sides — an empty list is valid and an |
| 248 | | asset need not be on any list. |
| 249 | | |
| 250 | | ||= Attribute =||= Type =||= Constraints =|| |
| 251 | | || `added_at` || timestamptz || required, defaults to now — recorded so a list can be shown in the order the user built it || |
| | 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 |
| | 308 | nothing 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 |
| | 312 | asset may be held by nobody. No attributes. |
| | 313 | |
| | 314 | Together, `Holds` and `PositionIn` still say what the old M:N `Holds` said: |
| | 315 | a user can hold many assets and an asset can be held by many users. The |
| | 316 | difference is that the position is now a thing with its own identity, not |
| | 317 | just a pair. The rule "at most one holding per user and crypto" is stated |
| | 318 | under 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 |
| | 322 | valid, 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 |
| | 326 | need not be on any list. No attributes. |
| 255 | | * '''v01''' — First complete version. Built from the entity notes in |
| 256 | | `ep-diagram.md` (the initial hand-written model), with three |
| 257 | | changes made to that initial model while drawing it: |
| 258 | | 1. `Markets` was promoted from an implied attribute of the asset to its own |
| 259 | | entity set, so that prices, orders, trades and candles can all reference a |
| 260 | | pair rather than an asset. |
| 261 | | 2. `holdings` and `watchlist_items` were re-expressed as the M:N relationships |
| 262 | | `Holds` and `Contains` with their own attributes, instead of entity sets |
| 263 | | with foreign keys — the initial notes listed them as tables, which is a |
| 264 | | relational concept that does not belong in a Chen ERD. |
| 265 | | 3. `avg_price` was marked as a derived attribute rather than a plain one, to |
| 266 | | make the denormalisation explicit rather than hidden. |
| | 330 | * '''v01''' — First complete version. Built from the entity notes in `ep-diagram.md` (the initial hand-written model), with three changes made to that initial model while drawing it: |
| | 331 | 1. `Markets` was promoted from an implied attribute of the asset to its own entity set, so that prices, orders, trades and candles can all reference a pair rather than an asset. |
| | 332 | 2. `holdings` and `watchlist_items` were re-expressed as the M:N relationships `Holds` and `Contains` with their own attributes, instead of entity sets with foreign keys — the initial notes listed them as tables, which is a relational concept that does not belong in a Chen ERD. |
| | 333 | 3. `avg_price` was marked as a derived attribute rather than a plain one, to make the denormalisation explicit rather than hidden. |
| 268 | | * '''v03''' — Added `reserved_quantity` to `Holds`, and reworded `Orders.status` |
| 269 | | to state its reserve → settle → (cancel) lifecycle explicitly, instead of |
| 270 | | leaving `open`/`cancelled` as unused enum values. Triggered by a design |
| 271 | | review that pointed out the model had no way to stop a user from placing a |
| 272 | | second sell order against crypto already promised to a first, unsettled one |
| 273 | | — `quantity` alone cannot distinguish "owned" from "owned, but already |
| 274 | | committed." Also redrawn more compactly: every entity and relationship (with |
| 275 | | its own attributes moved along with it) was pulled proportionally toward the |
| 276 | | diagram's centroid, shrinking the canvas by roughly 45% with the same |
| 277 | | topology and no new overlaps. See ERModelAIUsage for |
| 278 | | the reasoning and how the diagram file itself was produced, and |
| 279 | | !RelationalDesign and |
| 280 | | UseCase0005 for how the new attribute |
| 281 | | is enforced. |
| | 335 | * '''v03''' — Added `reserved_quantity` to `Holds`, and reworded `Orders.status` to state its reserve → settle → (cancel) lifecycle explicitly, instead of leaving `open`/`cancelled` as unused enum values. Triggered by a design review that pointed out the model had no way to stop a user from placing a second sell order against crypto already promised to a first, unsettled one — `quantity` alone cannot distinguish "owned" from "owned, but already committed." Also redrawn more compactly: every entity and relationship (with its own attributes moved along with it) was pulled proportionally toward the diagram's centroid, shrinking the canvas by roughly 45% with the same topology and no new overlaps. See [wiki:ERModelAIUsage] for the reasoning and how the diagram file itself was produced, and [wiki:RelationalDesign] and [wiki:UseCase0005] for how the new attribute is enforced. |
| | 336 | * '''v04 — after P7.''' Phase 7 (order, balance and trade consistency) needed data the model did not have, so the model was extended to stay in line with the database: |
| | 337 | * `Users.reserved_balance`: cash reserved by open buy orders; |
| | 338 | * `Orders.filled_quantity` and the status value `partially_filled`: orders can now be filled in parts; |
| | 339 | * the relationships `FillsBuy` and `FillsSell` between `Orders` and `MarketTrades`: which orders a trade filled; |
| | 340 | * the entity set `OrderEvents` with the relationship `Logs`: the automatically recorded history of every order. |
| | 341 | |
| | 342 | Nothing existing was removed or changed. See |
| | 343 | [wiki:AdvancedDatabaseDevelopment]. |
| | 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 |
| | 361 | are kept. |