source: docs/P1-ConceptualModel/ERModel.md

main
Last change on this file was 1549dae, checked in by Stefan <trsunovstefan@…>, 55 minutes ago

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

  • Property mode set to 100644
File size: 22.7 KB
Line 
1# Entity-Relationship Model v.05
2
3## Diagram
4
5![ERModel_v05](ERModel_v05.png)
6
7Notation: Chen. Rectangles are entity sets, diamonds are relationships, ellipses
8are attributes, underlined ellipses are primary keys, the dashed ellipse is a
9derived attribute. A double line between an entity set and a relationship marks
10**total participation** (every instance of that entity set must participate); a
11single line marks partial participation.
12
13Three 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](../P2-RelationalDesign/RelationalDesign.md).
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".
31
32## Data requirements
33
34Each entity set is given as a short rationale for why it exists as its own set,
35its keys, and its attributes as a table. Each relationship is given as its
36cardinality and participation, a short rationale, and — where it carries data —
37an attribute table.
38
39### Entity sets
40
41#### Users
42Registered participants of the platform. Every action in the simulation is
43attributed to a user, and the two balance attributes are what makes the
44simulation work: cash that is free to trade is tracked separately from cash
45that is currently committed to open positions, so the platform can refuse a
46purchase without having to recompute the whole portfolio first.
47
48**Keys:** candidates `{id}`, `{username}`, `{email}`; primary key **`id`**. A
49surrogate UUID was chosen because it is opaque and stable — `username` and
50`email` are both things a user may legitimately want to change later, and
51every relationship in the diagram points at `Users`, so a mutable key would
52propagate changes across the whole database.
53
54| Attribute | Type | Constraints |
55|---|---|---|
56| `id` | UUID | PK, required |
57| `username` | text(50) | required, unique |
58| `email` | text(255) | required, unique, contains `@` (checked by the application at registration, not by a database constraint) |
59| `full_name` | text(200) | optional |
60| `password_hash` | text(255) | required — never the password itself; the prototype stores a SHA-256 hex digest |
61| `available_balance` | numeric(18,4) | required, default 0, ≥ 0 |
62| `invested_balance` | numeric(18,4) | required, default 0, ≥ 0 |
63| `reserved_balance` | numeric(18,4) | required, default 0, ≥ 0 — cash set aside for the user's open buy orders (added in v04, after P7) |
64| `created_at` | timestamptz | required, defaults to now |
65| `updated_at` | timestamptz | optional (null until first change) |
66
67#### Cryptos
68The catalog of crypto assets the platform knows about. Kept separate from
69`Markets` because an asset exists independently of the pairs it is traded in —
70the same asset can be quoted against several currencies, and a user's holding is
71in the *asset*, not in a particular pair.
72
73**Keys:** candidates `{id}`, `{symbol}`; primary key **`id`**, for the same
74reason as in `Users`. `symbol` is kept as a unique natural key because that is
75what users type and see.
76
77| Attribute | Type | Constraints |
78|---|---|---|
79| `id` | UUID | PK, required |
80| `symbol` | text(20) | required, unique (e.g. `BTC`) |
81| `name` | text(255) | required (e.g. `Bitcoin`) |
82| `created_at` | timestamptz | required, defaults to now |
83
84#### Markets
85A tradeable pair: one crypto asset quoted in one currency, e.g. BTC/USD. This is
86where prices live, and it is the thing an order is placed *on*. Modeled as its
87own entity set rather than an attribute of `Cryptos` because a market has its own
88lifecycle — it can be deactivated without deleting the asset — and because
89trades, candles and orders all reference the pair, not the asset.
90
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)`.
97
98| Attribute | Type | Constraints |
99|---|---|---|
100| `id` | UUID | PK, required |
101| `quote_currency` | text(3) | required, default `USD` |
102| `is_active` | boolean | required, default true — inactive markets are hidden from the trading menus but keep their history |
103| `created_at` | timestamptz | required, defaults to now |
104
105#### Orders
106A user's instruction to buy or sell on a market. Needed as a separate entity set
107because an order is a record of *intent* that outlives its execution: it keeps
108the requested quantity and price even after it has been filled, which is what
109makes the ledger auditable.
110
111Placing an order is what triggers a **reservation** of whatever it commits:
112the crypto being sold (`Holdings.reserved_quantity`, below) on a sell, and the
113cash (`Users.reserved_balance`) on a buy. Since v04 (after P7) an order can
114wait in the order book and be filled in parts, so `status` is a real
115lifecycle driven by `filled_quantity`: `open` (nothing filled yet),
116`partially_filled`, `executed` (completely filled), or `cancelled`, which
117releases what is still reserved. See
118[UseCase0005](../P3-UseCaseModel/UseCase0005.md) for the reserve-then-settle
119sequence and
120[AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md)
121for the rules that keep it consistent.
122
123**Keys:** candidate `{id}` only — there is no natural key, since the same user
124can place two identical orders on the same market in the same second, and both
125are legitimately distinct; primary key **`id`**.
126
127| Attribute | Type | Constraints |
128|---|---|---|
129| `id` | UUID | PK, required |
130| `side` | text | required, `buy` or `sell` |
131| `type` | text | required, `market` or `limit` (both executed since P7) |
132| `status` | text | required, `open`, `partially_filled`, `executed` or `cancelled` |
133| `quantity` | numeric(20,4) | required, > 0 |
134| `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) |
135| `price` | numeric(18,6) | optional — the limit price; for a market order, the market price when it was placed |
136| `placed_at` | timestamptz | required, defaults to now |
137| `executed_at` | timestamptz | optional, set when the order settles |
138
139#### Transactions
140The financial ledger: every movement of virtual cash, in one place. This exists
141so that a balance is never just a number someone edited — it is the sum of an
142auditable list of entries, which is also what the "explain every step" goal of
143the project needs.
144
145**Keys:** candidate `{id}` only; primary key **`id`**.
146
147| Attribute | Type | Constraints |
148|---|---|---|
149| `id` | UUID | PK, required |
150| `type` | text | required, `deposit`, `buy`, `sell` or `fee` |
151| `amount` | numeric(18,4) | required, signed — negative for money leaving the cash balance, positive for money arriving |
152| `currency` | text(3) | required, default `USD` |
153| `created_at` | timestamptz | required, defaults to now |
154| `description` | text | optional, free-form |
155
156#### MarketTrades
157Individual executed trades on a market, from the user's own fills and from the
158market simulator. This is the single source of truth for the current price: the
159price of a market is the price of its most recent trade, never a column someone
160writes directly.
161
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;
164primary key **`id`** (a plain auto-incrementing integer here rather than a
165UUID, because this is the highest-volume entity set and it is only ever read
166in timestamp order, never referenced by anything else).
167
168| Attribute | Type | Constraints |
169|---|---|---|
170| `id` | big integer | PK, required, auto-generated (`bigserial` in P2) |
171| `executed_at` | timestamptz | required |
172| `price` | numeric(18,6) | required, > 0 |
173| `quantity` | numeric(20,6) | required, > 0 |
174| `side` | text | optional, `buy` or `sell` |
175| `source` | text(50) | required, default `simulation` — distinguishes a simulated trade from a user's own fill (`user`) |
176
177Since v04 (after P7) a trade also records which orders it filled, through the
178relationships `FillsBuy` and `FillsSell` below.
179
180#### OrderEvents
181*Added in v04, after P7.* The audit trail of an order: one event for its
182placement, one for every (partial) fill, and one for a cancellation. The
183`Orders` row only holds the current state; this entity keeps the history of
184how the order got there. Events are recorded automatically by the database.
185
186**Keys:** candidate `{id}` only; primary key **`id`** (auto-incrementing
187integer, events are only read in order).
188
189| Attribute | Type | Constraints |
190|---|---|---|
191| `id` | big integer | PK, required, auto-generated (`bigserial` in P2) |
192| `event_type` | text | required, `placed`, `partially_filled`, `filled` or `cancelled` |
193| `quantity` | numeric(20,4) | required — the ordered quantity for `placed`, the filled amount for a fill, the unfilled rest for `cancelled` |
194| `price` | numeric(18,6) | optional — the order price, or the trade price for a fill |
195| `status_after` | text | required, the order's status after the event |
196| `created_at` | timestamptz | required, set automatically when the event is recorded (`clock_timestamp()`, so events inside one transaction keep their real order) |
197
198#### MarketCandles
199OHLCV aggregates per market and timeframe — the data a price chart is drawn
200from. Stored rather than computed on the fly because the point of the project is
201a chart-driven interface, and re-aggregating the whole trade history for every
202screen refresh does not scale.
203
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) |
213| `timeframe` | text | required, `1m`, `5m`, `1h` or `1d` |
214| `open`, `high`, `low`, `close` | numeric(18,6) | all required |
215| `volume` | numeric(20,6) | required |
216| `candle_time` | timestamptz | required — the start of the bucket |
217
218#### Watchlists
219A named list of assets a user wants to monitor. A separate entity set rather than
220a flag on the relationship between users and assets, because a user may want
221several lists ("long term", "watching today") and each needs its own name.
222
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.
226
227| Attribute | Type | Constraints |
228|---|---|---|
229| `id` | UUID | PK, required |
230| `name` | text(100) | required |
231| `created_at` | timestamptz | required, defaults to now |
232
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)`.
247
248`reserved_quantity` mirrors `available_balance`/`invested_balance` on `Users`:
249two independently updated stored numbers, with the amount actually free to use
250computed on demand rather than stored (`quantity − reserved_quantity` here,
251`available_balance` alone on the cash side). Without it, nothing stopped a
252user from placing a second sell order against crypto already promised to a
253first one — `quantity` alone cannot tell "owned" apart from "owned, but
254already committed elsewhere." See [history](#entity-relationship-model-history), v03.
255
256| Attribute | Type | Constraints |
257|---|---|---|
258| `id` | UUID | PK, required |
259| `quantity` | numeric(20,4) | required, ≥ 0 — total amount owned |
260| `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 |
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 |
262| `created_at` | timestamptz | required, defaults to now |
263| `updated_at` | timestamptz | optional |
264
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 |
278| `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.
351
352## Entity-Relationship Model History
353
354- **v01** — First complete version. Built from the entity notes in
355 [`ep-diagram.md`](ep-diagram.md) (the initial hand-written model), with three
356 changes made to that initial model while drawing it:
357 1. `Markets` was promoted from an implied attribute of the asset to its own
358 entity set, so that prices, orders, trades and candles can all reference a
359 pair rather than an asset.
360 2. `holdings` and `watchlist_items` were re-expressed as the M:N relationships
361 `Holds` and `Contains` with their own attributes, instead of entity sets
362 with foreign keys — the initial notes listed them as tables, which is a
363 relational concept that does not belong in a Chen ERD.
364 3. `avg_price` was marked as a derived attribute rather than a plain one, to
365 make the denormalisation explicit rather than hidden.
366- **v02** — Student review pass over the AI-generated v01 in the TerraER GUI.
367- **v03** — Added `reserved_quantity` to `Holds`, and reworded `Orders.status`
368 to state its reserve → settle → (cancel) lifecycle explicitly, instead of
369 leaving `open`/`cancelled` as unused enum values. Triggered by a design
370 review that pointed out the model had no way to stop a user from placing a
371 second sell order against crypto already promised to a first, unsettled one
372 — `quantity` alone cannot distinguish "owned" from "owned, but already
373 committed." Also redrawn more compactly: every entity and relationship (with
374 its own attributes moved along with it) was pulled proportionally toward the
375 diagram's centroid, shrinking the canvas by roughly 45% with the same
376 topology and no new overlaps. See [ERModelAIUsage](ERModelAIUsage.md) for
377 the reasoning and how the diagram file itself was produced, and
378 [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md) and
379 [UseCase0005](../P3-UseCaseModel/UseCase0005.md) for how the new attribute
380 is enforced.
381- **v04 — after P7.** Phase 7 (order, balance and trade consistency) needed
382 data the model did not have, so the model was extended to stay in line with
383 the database:
384 - `Users.reserved_balance`: cash reserved by open buy orders;
385 - `Orders.filled_quantity` and the status value `partially_filled`: orders
386 can now be filled in parts;
387 - the relationships `FillsBuy` and `FillsSell` between `Orders` and
388 `MarketTrades`: which orders a trade filled;
389 - the entity set `OrderEvents` with the relationship `Logs`: the
390 automatically recorded history of every order.
391
392 Nothing existing was removed or changed. See
393 [AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md).
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
426 are kept.
427
428Reasoning for the AI-assisted part of this phase, and the full interaction log,
429are on [ERModelAIUsage](ERModelAIUsage.md).
430
Note: See TracBrowser for help on using the repository browser.