Diagram
Data requirements
Entity sets
Users
Registered participants of the platform. Every action in the simulation is attributed to a user, and the two balance attributes are what makes the simulation work: cash that is free to trade is tracked separately from cash that is currently committed to open positions, so the platform can refuse a purchase without having to recompute the whole portfolio first.
- Candidate keys:
{id},{username},{email}. Primary key:id. A surrogate UUID was chosen because it is opaque and stable —usernameandemailare both things a user may legitimately want to change later, and every relationship in the diagram points atUsers, so a mutable key would propagate changes across the whole database. - Attributes:
id— UUID, required, primary key.username— text, max 50, required, unique.email— text, max 255, required, unique, must contain@.full_name— text, max 200, optional.password_hash— text, max 255, required. Never the password itself; the prototype stores a SHA-256 hex digest.available_balance— numeric(18,4), required, default 0, must be ≥ 0.invested_balance— numeric(18,4), required, default 0, must be ≥ 0.created_at— timestamp with time zone, required, defaults to now.updated_at— timestamp with time zone, optional (null until first change).
Cryptos
The catalog of crypto assets the platform knows about. Kept separate from
Markets because an asset exists independently of the pairs it is traded in —
the same asset can be quoted against several currencies, and a user's holding is
in the *asset*, not in a particular pair.
- Candidate keys:
{id},{symbol}. Primary key:id, for the same reason as inUsers;symbolis kept as a unique natural key because that is what users type and see. - Attributes:
id— UUID, required, primary key.symbol— text, max 20, required, unique (e.g.BTC).name— text, max 255, required (e.g.Bitcoin).created_at— timestamptz, required, defaults to now.
Markets
A tradeable pair: one crypto asset quoted in one currency, e.g. BTC/USD. This is
where prices live, and it is the thing an order is placed *on*. Modeled as its
own entity set rather than an attribute of Cryptos because a market has its own
lifecycle — it can be deactivated without deleting the asset — and because
trades, candles and orders all reference the pair, not the asset.
- Candidate keys:
{id},{crypto_id, quote_currency}— that pair is unique by definition, since a given asset can only be quoted once per currency. Primary key:id, so that the many entity sets referencing a market carry one narrow column instead of a composite key. - Attributes:
id— UUID, required, primary key.quote_currency— text, exactly 3 characters, required, defaultUSD.is_active— boolean, required, default true. Inactive markets are hidden from the trading menus but keep their history.created_at— timestamptz, required, defaults to now.
Orders
A user's instruction to buy or sell on a market. Needed as a separate entity set because an order is a record of *intent* that outlives its execution: it keeps the requested quantity and price even after it has been filled, which is what makes the ledger auditable.
- Candidate keys:
{id}only. There is no natural key — the same user can place two identical orders on the same market in the same second, and both are legitimately distinct. Primary key:id. - Attributes:
id— UUID, required, primary key.side— text, required, restricted tobuyorsell.type— text, required, restricted tomarketorlimit. The prototype executes onlymarketorders;limitexists so the model does not have to change when limit orders are implemented.status— text, required, restricted toopen,executed,cancelled.quantity— numeric(20,4), required, must be > 0.price— numeric(18,6), optional — null for a market order until it fills, then the fill price.placed_at— timestamptz, required, defaults to now.executed_at— timestamptz, optional, set when the order fills.
Transactions
The financial ledger: every movement of virtual cash, in one place. This exists so that a balance is never just a number someone edited — it is the sum of an auditable list of entries, which is also what the "explain every step" goal of the project needs.
- Candidate keys:
{id}only. Primary key:id. - Attributes:
id— UUID, required, primary key.type— text, required, restricted todeposit,buy,sell,fee.amount— numeric(18,4), required. Signed: negative for money leaving the cash balance, positive for money arriving.currency— text, exactly 3 characters, required, defaultUSD.created_at— timestamptz, required, defaults to now.description— text, optional, free-form human-readable explanation.
MarketTrades
Individual executed trades on a market, from the user's own fills and from the market simulator. This is the single source of truth for the current price: the price of a market is the price of its most recent trade, never a column someone writes directly.
- Candidate keys:
{id}. In principle{market_id, executed_at}looks unique, but two trades can share a timestamp, so it is not a safe key. Primary key:id(a plain auto-incrementing integer here rather than a UUID, because this is the highest-volume entity set and it is only ever read in timestamp order, never referenced by anything else). - Attributes:
id— integer, required, primary key, auto-generated.executed_at— timestamptz, required.price— numeric(18,6), required, must be > 0.quantity— numeric(20,6), required, must be > 0.side— text, optional,buyorsell.source— text, max 50, required, defaultsimulation. Distinguishes a simulated trade from a user's own fill (user).
MarketCandles
OHLCV aggregates per market and timeframe — the data a price chart is drawn from. Stored rather than computed on the fly because the point of the project is a chart-driven interface, and re-aggregating the whole trade history for every screen refresh does not scale.
- Candidate keys:
{id}, and{market_id, timeframe, candle_time}— a market has exactly one candle per timeframe per time bucket. Primary key:id; the composite is enforced as a uniqueness rule because it is the real-world constraint and it is what prevents duplicate candles. - Attributes:
id— integer, required, primary key, auto-generated.timeframe— text, required, restricted to1m,5m,1h,1d.open,high,low,close— numeric(18,6), all required.volume— numeric(20,6), required.candle_time— timestamptz, required — the start of the bucket.
Watchlists
A named list of assets a user wants to monitor. A separate entity set rather than a flag on the relationship between users and assets, because a user may want several lists ("long term", "watching today") and each needs its own name.
- Candidate keys:
{id}.{user_id, name}would also work if list names are required to be unique per user; the model does not impose that, so it is not listed as a candidate key. Primary key:id. - Attributes:
id— UUID, required, primary key.name— text, max 100, required.created_at— timestamptz, required, defaults to now.
Relationships
QuotedOn — Cryptos (1) : Markets (N), total on Markets
Ties a market to the asset it trades. One asset can be quoted in many markets;
every market must have exactly one asset, hence total participation on the
Markets side. No attributes of its own.
PlacedOn — Markets (1) : Orders (N), total on Orders
Records which market an order was placed on. Every order must name a market; a market may have no orders yet. No attributes.
Places — Users (1) : Orders (N), total on Orders
Records who placed an order. Every order belongs to exactly one user; a new user has no orders. No attributes.
Records — Users (1) : Transactions (N), total on Transactions
Attributes each ledger entry to a user. Every entry belongs to exactly one user. No attributes.
Settles — Orders (1) : Transactions (N), partial on both sides
Links a ledger entry to the order that caused it. Partial on the Transactions
side because deposits have no originating order, and partial on the Orders side
because an order that never executes never produces a ledger entry. This is why
the corresponding column is nullable in P2. No attributes.
Fills — Markets (1) : MarketTrades (N), total on MarketTrades
Every executed trade happened on exactly one market. No attributes.
Aggregates — Markets (1) : MarketCandles (N), total on MarketCandles
Every candle summarises trades of exactly one market. No attributes.
Owns — Users (1) : Watchlists (N), total on Watchlists
Every watchlist belongs to exactly one user. No attributes.
Holds — Users (M) : Cryptos (N), partial on both sides, with attributes
A user's position in an asset. M:N because one user holds many assets and one asset is held by many users, and partial on both sides because a user may hold nothing and an asset may be held by nobody. Modeled as a relationship rather than an entity set because a position has no identity of its own — it is entirely described by *which user*, *which asset*, and how much.
- Attributes:
quantity— numeric(20,4), required, must be ≥ 0.avg_price— numeric(18,6), required, ≥ 0, derived (dashed ellipse): the weighted average of the prices at which the position was accumulated. It is derivable from the buy history, and is stored anyway so that unrealised P/L can be shown without replaying the whole ledger.created_at— timestamptz, required, defaults to now.updated_at— timestamptz, optional.
Contains — Watchlists (M) : Cryptos (N), partial on both sides, with attribute
Which assets are on which watchlist. M:N: a list holds many assets, an asset appears on many lists. Partial on both sides — an empty list is valid and an asset need not be on any list.
- Attributes:
added_at— timestamptz, required, defaults to now. Recorded so a list can be shown in the order the user built it.
Entity-Relationship Model History
- v01 — First complete version. Built from the entity notes in
[
ep-diagram.md](ep-diagram.md) (the initial hand-written model), with three changes made to that initial model while drawing it:Marketswas 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.holdingsandwatchlist_itemswere re-expressed as the M:N relationshipsHoldsandContainswith 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.avg_pricewas marked as a derived attribute rather than a plain one, to make the denormalisation explicit rather than hidden.
Reasoning for the AI-assisted part of this phase, and the full interaction log, are on https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md.

