Changeset 9577c79


Ignore:
Timestamp:
09/16/26 23:37:15 (13 days ago)
Author:
Stefan <trsunovstefan@…>
Branches:
main
Children:
8b447ef
Parents:
df05838
Message:

add reserved_quantity and modify the phases, add v_03.png and v_03.xml for P1

Files:
2 added
24 edited

Legend:

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

    rdf05838 r9577c79  
    1 # Entity-Relationship Model v.02
     1# Entity-Relationship Model v.03
    22
    33## Diagram
    44
    5 ![ERModel_v02](ERModel_v02.png)
    6 
     5![ERModel_v03](ERModel_v03.png)
    76
    87Notation: Chen. Rectangles are entity sets, diamonds are relationships, ellipses
    … …  
    2322## Data requirements
    2423
     24Each entity set is given as a short rationale for why it exists as its own set,
     25its keys, and its attributes as a table. Each relationship is given as its
     26cardinality and participation, a short rationale, and — where it carries data —
     27an attribute table.
     28
    2529### Entity sets
    2630
    … …  
    2832Registered participants of the platform. Every action in the simulation is
    2933attributed to a user, and the two balance attributes are what makes the
    30 simulation work: cash that is free to trade is tracked separately from cash that
    31 is currently committed to open positions, so the platform can refuse a purchase
    32 without having to recompute the whole portfolio first.
    33 
    34 - **Candidate keys:** `{id}`, `{username}`, `{email}`. Primary key: **`id`**.
    35   A surrogate UUID was chosen because it is opaque and stable — `username` and
    36   `email` are both things a user may legitimately want to change later, and
    37   every relationship in the diagram points at `Users`, so a mutable key would
    38   propagate changes across the whole database.
    39 - **Attributes:**
    40   - `id` — UUID, required, primary key.
    41   - `username` — text, max 50, required, unique.
    42   - `email` — text, max 255, required, unique, must contain `@`.
    43   - `full_name` — text, max 200, optional.
    44   - `password_hash` — text, max 255, required. Never the password itself; the
    45     prototype stores a SHA-256 hex digest.
    46   - `available_balance` — numeric(18,4), required, default 0, must be ≥ 0.
    47   - `invested_balance` — numeric(18,4), required, default 0, must be ≥ 0.
    48   - `created_at` — timestamp with time zone, required, defaults to now.
    49   - `updated_at` — timestamp with time zone, optional (null until first change).
     34simulation work: cash that is free to trade is tracked separately from cash
     35that is currently committed to open positions, so the platform can refuse a
     36purchase without having to recompute the whole portfolio first.
     37
     38**Keys:** candidates `{id}`, `{username}`, `{email}`; primary key **`id`**. A
     39surrogate UUID was chosen because it is opaque and stable — `username` and
     40`email` are both things a user may legitimately want to change later, and
     41every relationship in the diagram points at `Users`, so a mutable key would
     42propagate changes across the whole database.
     43
     44| Attribute | Type | Constraints |
     45|---|---|---|
     46| `id` | UUID | PK, required |
     47| `username` | text(50) | required, unique |
     48| `email` | text(255) | required, unique, contains `@` |
     49| `full_name` | text(200) | optional |
     50| `password_hash` | text(255) | required — never the password itself; the prototype stores a SHA-256 hex digest |
     51| `available_balance` | numeric(18,4) | required, default 0, ≥ 0 |
     52| `invested_balance` | numeric(18,4) | required, default 0, ≥ 0 |
     53| `created_at` | timestamptz | required, defaults to now |
     54| `updated_at` | timestamptz | optional (null until first change) |
    5055
    5156#### Cryptos
    … …  
    5560in the *asset*, not in a particular pair.
    5661
    57 - **Candidate keys:** `{id}`, `{symbol}`. Primary key: **`id`**, for the same
    58   reason as in `Users`; `symbol` is kept as a unique natural key because that is
    59   what users type and see.
    60 - **Attributes:**
    61   - `id` — UUID, required, primary key.
    62   - `symbol` — text, max 20, required, unique (e.g. `BTC`).
    63   - `name` — text, max 255, required (e.g. `Bitcoin`).
    64   - `created_at` — timestamptz, required, defaults to now.
     62**Keys:** candidates `{id}`, `{symbol}`; primary key **`id`**, for the same
     63reason as in `Users`. `symbol` is kept as a unique natural key because that is
     64what users type and see.
     65
     66| Attribute | Type | Constraints |
     67|---|---|---|
     68| `id` | UUID | PK, required |
     69| `symbol` | text(20) | required, unique (e.g. `BTC`) |
     70| `name` | text(255) | required (e.g. `Bitcoin`) |
     71| `created_at` | timestamptz | required, defaults to now |
    6572
    6673#### Markets
    … …  
    7178trades, candles and orders all reference the pair, not the asset.
    7279
    73 - **Candidate keys:** `{id}`, `{crypto_id, quote_currency}` — that pair is
    74   unique by definition, since a given asset can only be quoted once per
    75   currency. Primary key: **`id`**, so that the many entity sets referencing a
    76   market carry one narrow column instead of a composite key.
    77 - **Attributes:**
    78   - `id` — UUID, required, primary key.
    79   - `quote_currency` — text, exactly 3 characters, required, default `USD`.
    80   - `is_active` — boolean, required, default true. Inactive markets are hidden
    81     from the trading menus but keep their history.
    82   - `created_at` — timestamptz, required, defaults to now.
     80**Keys:** candidates `{id}`, `{crypto_id, quote_currency}` — that pair is
     81unique by definition, since a given asset can only be quoted once per
     82currency; primary key **`id`**, so that the many entity sets referencing a
     83market carry one narrow column instead of a composite key.
     84
     85| Attribute | Type | Constraints |
     86|---|---|---|
     87| `id` | UUID | PK, required |
     88| `quote_currency` | text(3) | required, default `USD` |
     89| `is_active` | boolean | required, default true — inactive markets are hidden from the trading menus but keep their history |
     90| `created_at` | timestamptz | required, defaults to now |
    8391
    8492#### Orders
    … …  
    8896makes the ledger auditable.
    8997
    90 - **Candidate keys:** `{id}` only. There is no natural key — the same user can
    91   place two identical orders on the same market in the same second, and both are
    92   legitimately distinct. Primary key: **`id`**.
    93 - **Attributes:**
    94   - `id` — UUID, required, primary key.
    95   - `side` — text, required, restricted to `buy` or `sell`.
    96   - `type` — text, required, restricted to `market` or `limit`. The prototype
    97     executes only `market` orders; `limit` exists so the model does not have to
    98     change when limit orders are implemented.
    99   - `status` — text, required, restricted to `open`, `executed`, `cancelled`.
    100   - `quantity` — numeric(20,4), required, must be > 0.
    101   - `price` — numeric(18,6), optional — null for a market order until it fills,
    102     then the fill price.
    103   - `placed_at` — timestamptz, required, defaults to now.
    104   - `executed_at` — timestamptz, optional, set when the order fills.
     98Placing an order is what triggers a **reservation** of whatever it commits —
     99the crypto being sold (`Holds.reserved_quantity`, below) on a sell, cash
     100already handled the same way on a buy via `available_balance` /
     101`invested_balance`. `status` therefore has real meaning as a lifecycle, not
     102just a label: `open` means reserved but not yet settled, `executed` means
     103settled, `cancelled` would release the reservation without settling (not yet
     104exercised by any use case, since only market orders — which settle
     105immediately — are implemented). See
     106[UseCase0005](../P3-UseCaseModel/UseCase0005.md) for the reserve-then-settle
     107sequence.
     108
     109**Keys:** candidate `{id}` only — there is no natural key, since the same user
     110can place two identical orders on the same market in the same second, and both
     111are legitimately distinct; primary key **`id`**.
     112
     113| Attribute | Type | Constraints |
     114|---|---|---|
     115| `id` | UUID | PK, required |
     116| `side` | text | required, `buy` or `sell` |
     117| `type` | text | required, `market` or `limit` — the prototype executes only `market`; `limit` exists so the model does not have to change when limit orders are implemented |
     118| `status` | text | required, `open`, `executed` or `cancelled` |
     119| `quantity` | numeric(20,4) | required, > 0 |
     120| `price` | numeric(18,6) | optional — null until the order settles, then the fill price |
     121| `placed_at` | timestamptz | required, defaults to now |
     122| `executed_at` | timestamptz | optional, set when the order settles |
    105123
    106124#### Transactions
    … …  
    110128the project needs.
    111129
    112 - **Candidate keys:** `{id}` only. Primary key: **`id`**.
    113 - **Attributes:**
    114   - `id` — UUID, required, primary key.
    115   - `type` — text, required, restricted to `deposit`, `buy`, `sell`, `fee`.
    116   - `amount` — numeric(18,4), required. Signed: negative for money leaving the
    117     cash balance, positive for money arriving.
    118   - `currency` — text, exactly 3 characters, required, default `USD`.
    119   - `created_at` — timestamptz, required, defaults to now.
    120   - `description` — text, optional, free-form human-readable explanation.
     130**Keys:** candidate `{id}` only; primary key **`id`**.
     131
     132| Attribute | Type | Constraints |
     133|---|---|---|
     134| `id` | UUID | PK, required |
     135| `type` | text | required, `deposit`, `buy`, `sell` or `fee` |
     136| `amount` | numeric(18,4) | required, signed — negative for money leaving the cash balance, positive for money arriving |
     137| `currency` | text(3) | required, default `USD` |
     138| `created_at` | timestamptz | required, defaults to now |
     139| `description` | text | optional, free-form |
    121140
    122141#### MarketTrades
    … …  
    126145writes directly.
    127146
    128 - **Candidate keys:** `{id}`. In principle `{market_id, executed_at}` looks
    129   unique, but two trades can share a timestamp, so it is not a safe key.
    130   Primary key: **`id`** (a plain auto-incrementing integer here rather than a
    131   UUID, because this is the highest-volume entity set and it is only ever read
    132   in timestamp order, never referenced by anything else).
    133 - **Attributes:**
    134   - `id` — integer, required, primary key, auto-generated.
    135   - `executed_at` — timestamptz, required.
    136   - `price` — numeric(18,6), required, must be > 0.
    137   - `quantity` — numeric(20,6), required, must be > 0.
    138   - `side` — text, optional, `buy` or `sell`.
    139   - `source` — text, max 50, required, default `simulation`. Distinguishes a
    140     simulated trade from a user's own fill (`user`).
     147**Keys:** candidate `{id}` — `{market_id, executed_at}` looks unique in
     148principle, but two trades can share a timestamp, so it is not a safe key;
     149primary key **`id`** (a plain auto-incrementing integer here rather than a
     150UUID, because this is the highest-volume entity set and it is only ever read
     151in timestamp order, never referenced by anything else).
     152
     153| Attribute | Type | Constraints |
     154|---|---|---|
     155| `id` | integer | PK, required, auto-generated |
     156| `executed_at` | timestamptz | required |
     157| `price` | numeric(18,6) | required, > 0 |
     158| `quantity` | numeric(20,6) | required, > 0 |
     159| `side` | text | optional, `buy` or `sell` |
     160| `source` | text(50) | required, default `simulation` — distinguishes a simulated trade from a user's own fill (`user`) |
    141161
    142162#### MarketCandles
    … …  
    146166screen refresh does not scale.
    147167
    148 - **Candidate keys:** `{id}`, and `{market_id, timeframe, candle_time}` — a
    149   market has exactly one candle per timeframe per time bucket. Primary key:
    150   **`id`**; the composite is enforced as a uniqueness rule because it is the
    151   real-world constraint and it is what prevents duplicate candles.
    152 - **Attributes:**
    153   - `id` — integer, required, primary key, auto-generated.
    154   - `timeframe` — text, required, restricted to `1m`, `5m`, `1h`, `1d`.
    155   - `open`, `high`, `low`, `close` — numeric(18,6), all required.
    156   - `volume` — numeric(20,6), required.
    157   - `candle_time` — timestamptz, required — the start of the bucket.
     168**Keys:** candidates `{id}`, `{market_id, timeframe, candle_time}` — a market
     169has exactly one candle per timeframe per time bucket; primary key **`id`**, the
     170composite is enforced as a uniqueness rule because it is the real-world
     171constraint and it is what prevents duplicate candles.
     172
     173| Attribute | Type | Constraints |
     174|---|---|---|
     175| `id` | integer | PK, required, auto-generated |
     176| `timeframe` | text | required, `1m`, `5m`, `1h` or `1d` |
     177| `open`, `high`, `low`, `close` | numeric(18,6) | all required |
     178| `volume` | numeric(20,6) | required |
     179| `candle_time` | timestamptz | required — the start of the bucket |
    158180
    159181#### Watchlists
    … …  
    162184several lists ("long term", "watching today") and each needs its own name.
    163185
    164 - **Candidate keys:** `{id}`. `{user_id, name}` would also work if list names
    165   are required to be unique per user; the model does not impose that, so it is
    166   not listed as a candidate key. Primary key: **`id`**.
    167 - **Attributes:**
    168   - `id` — UUID, required, primary key.
    169   - `name` — text, max 100, required.
    170   - `created_at` — timestamptz, required, defaults to now.
     186**Keys:** candidate `{id}` — `{user_id, name}` would also work if list names
     187were required to be unique per user, which the model does not impose, so it is
     188not listed as a candidate key; primary key **`id`**.
     189
     190| Attribute | Type | Constraints |
     191|---|---|---|
     192| `id` | UUID | PK, required |
     193| `name` | text(100) | required |
     194| `created_at` | timestamptz | required, defaults to now |
    171195
    172196### Relationships
    … …  
    175199Ties a market to the asset it trades. One asset can be quoted in many markets;
    176200every market must have exactly one asset, hence total participation on the
    177 `Markets` side. No attributes of its own.
     201`Markets` side. No attributes.
    178202
    179203#### PlacedOn — Markets (1) : Orders (N), total on Orders
    … …  
    182206
    183207#### Places — Users (1) : Orders (N), total on Orders
    184 Records who placed an order. Every order belongs to exactly one user; a new user
    185 has no orders. No attributes.
     208Records who placed an order. Every order belongs to exactly one user; a new
     209user has no orders. No attributes.
    186210
    187211#### Records — Users (1) : Transactions (N), total on Transactions
    188 Attributes each ledger entry to a user. Every entry belongs to exactly one user.
    189 No attributes.
     212Attributes each ledger entry to a user. Every entry belongs to exactly one
     213user. No attributes.
    190214
    191215#### Settles — Orders (1) : Transactions (N), partial on both sides
    192 Links a ledger entry to the order that caused it. Partial on the `Transactions`
    193 side because deposits have no originating order, and partial on the `Orders` side
    194 because an order that never executes never produces a ledger entry. This is why
    195 the corresponding column is nullable in P2. No attributes.
     216Links a ledger entry to the order that caused it. Partial on the
     217`Transactions` side because deposits have no originating order, and partial on
     218the `Orders` side because an order that never executes never produces a
     219ledger entry — which is why the corresponding column is nullable in P2. No
     220attributes.
    196221
    197222#### Fills — Markets (1) : MarketTrades (N), total on MarketTrades
    … …  
    211236entirely described by *which user*, *which asset*, and how much.
    212237
    213 - **Attributes:**
    214   - `quantity` — numeric(20,4), required, must be ≥ 0.
    215   - `avg_price` — numeric(18,6), required, ≥ 0, **derived** (dashed ellipse):
    216     the weighted average of the prices at which the position was accumulated.
    217     It is derivable from the buy history, and is stored anyway so that
    218     unrealised P/L can be shown without replaying the whole ledger.
    219   - `created_at` — timestamptz, required, defaults to now.
    220   - `updated_at` — timestamptz, optional.
     238`reserved_quantity` mirrors `available_balance`/`invested_balance` on `Users`:
     239two independently updated stored numbers, with the amount actually free to use
     240computed on demand rather than stored (`quantity − reserved_quantity` here,
     241`available_balance` alone on the cash side). Without it, nothing stopped a
     242user from placing a second sell order against crypto already promised to a
     243first one — `quantity` alone cannot tell "owned" apart from "owned, but
     244already committed elsewhere." See [history](#entity-relationship-model-history), v03.
     245
     246| Attribute | Type | Constraints |
     247|---|---|---|
     248| `quantity` | numeric(20,4) | required, ≥ 0 — total amount owned |
     249| `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 |
     250| `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 |
     251| `created_at` | timestamptz | required, defaults to now |
     252| `updated_at` | timestamptz | optional |
    221253
    222254#### Contains — Watchlists (M) : Cryptos (N), partial on both sides, **with attribute**
    … …  
    225257asset need not be on any list.
    226258
    227 - **Attributes:**
    228   - `added_at` — timestamptz, required, defaults to now. Recorded so a list can
    229     be shown in the order the user built it.
     259| Attribute | Type | Constraints |
     260|---|---|---|
     261| `added_at` | timestamptz | required, defaults to now — recorded so a list can be shown in the order the user built it |
    230262
    231263## Entity-Relationship Model History
    … …  
    243275  3. `avg_price` was marked as a derived attribute rather than a plain one, to
    244276     make the denormalisation explicit rather than hidden.
     277- **v02** — Student review pass over the AI-generated v01 in the TerraER GUI.
     278- **v03** — Added `reserved_quantity` to `Holds`, and reworded `Orders.status`
     279  to state its reserve → settle → (cancel) lifecycle explicitly, instead of
     280  leaving `open`/`cancelled` as unused enum values. Triggered by a design
     281  review that pointed out the model had no way to stop a user from placing a
     282  second sell order against crypto already promised to a first, unsettled one
     283  — `quantity` alone cannot distinguish "owned" from "owned, but already
     284  committed." Also redrawn more compactly: every entity and relationship (with
     285  its own attributes moved along with it) was pulled proportionally toward the
     286  diagram's centroid, shrinking the canvas by roughly 45% with the same
     287  topology and no new overlaps. See [ERModelAIUsage](ERModelAIUsage.md) for
     288  the reasoning and how the diagram file itself was produced, and
     289  [RelationalDesign](../P2-RelationalDesign/RelationalDesign.md) and
     290  [UseCase0005](../P3-UseCaseModel/UseCase0005.md) for how the new attribute
     291  is enforced.
    245292
    246293Reasoning for the AI-assisted part of this phase, and the full interaction log,
    247294are on [ERModelAIUsage](ERModelAIUsage.md).
     295
     296> **Student action required.** Open `ERModel_v03.xml` in TerraER, read the
     297> whole diagram — not just the new `reserved_quantity` ellipse — and change
     298> anything you disagree with, including the compaction. The phase rules
     299> require the model to be yours; this is a generated revision to review and
     300> take over, not an answer to submit unread.
  • docs/P1-ConceptualModel/ERModelAIUsage.md

    rdf05838 r9577c79  
    3434by reading it back through TerraER's own reader and comparing the figure count
    3535(144), and it opens and can be edited in TerraER 3.11 like any hand-drawn
    36 diagram. Subsequent versions (`ERModel_v02.xml` onward) are edited by the student
    37 in the GUI.
     36diagram. `ERModel_v02.xml` is the student's own review pass over v01, done by
     37hand in the GUI.
     38
     39`ERModel_v03.xml` / `ERModel_v03.png` (session 3, 2026-09-16) were produced the
     40same way, this time as a genuine load–modify–save round trip through TerraER's
     41own classes rather than a from-scratch build: `ERModel_v02.xml` was read with
     42the application's real `DrawFigureFactory` and `DOMStorableInputOutputFormat`
     43into a live `QuadTreeDrawing`, one `AtributoFigure` was cloned from the
     44existing `quantity` attribute of `Holds` (to inherit its exact styling) and
     45relabelled `reserved_quantity`, a matching `LabeledLineConnectionFigure` was
     46added between it and the `Holds` diamond (`ChopDiamondConnector` /
     47`ChopEllipseConnector`, the same connector pair every other attribute of
     48`Holds` uses), and every entity and relationship — together with its own
     49attributes, moved by the same offset — was translated proportionally toward
     50the diagram's centroid to close up excess canvas space, after which every
     51connection figure had `updateConnection()` called so its drawn path follows
     52the moved figures. The result was written with the real writer and rendered to
     53PNG with TerraER's own `ImageOutputFormat`, and re-verified by reading
     54`ERModel_v03.xml` back and confirming the figure count (146 = 144 + the new
     55attribute + its line) and that all five attributes of `Holds` resolve with the
     56expected connector classes. No figure was hand-edited in XML.
    3857
    3958### Model description
    … …  
    4362## Summary of AI involvement
    4463
    45 Work on this project happened in two working sessions, several months apart.
    46 
    47 | | Session 1 | Session 2 |
    48 |---|---|---|
    49 | **When** | 2026-04-21 | 2026-08-06 / 2026-08-07 |
    50 | **Model** | Claude Opus 4.7 (1M context) | Claude Opus 5 (1M context) |
    51 | **Phases advanced** | P1, P2, P3 and the first working prototype | The ER diagram file, P4 documentation, bug fixes |
    52 | **My starting material** | `ep-diagram.md`, `opis.md`, my existing Go backend and draft SQL | Everything from session 1, plus the phase rubric |
     64Work on this project happened in three working sessions.
     65
     66| | Session 1 | Session 2 | Session 3 |
     67|---|---|---|---|
     68| **When** | 2026-04-21 | 2026-08-06 / 2026-08-07 | 2026-09-16 |
     69| **Model** | Claude Opus 4.7 (1M context) | Claude Opus 5 (1M context) | Claude Sonnet 5 |
     70| **Phases advanced** | P1, P2, P3 and the first working prototype | The ER diagram file, P4 documentation, bug fixes | `Holds.reserved_quantity` added across P1–P4 |
     71| **My starting material** | `ep-diagram.md`, `opis.md`, my existing Go backend and draft SQL | Everything from session 1, plus the phase rubric | Everything from sessions 1–2, plus a design review of the sell-order flow |
    5372
    5473In **session 1** I brought my own data model (`ep-diagram.md`, written in
    … …  
    6584for a review pass over the database and Go code, which turned up three further
    6685bugs (see [PrototypeImplementationAIUsage](../P4-Prototype/PrototypeImplementationAIUsage.md)).
     86
     87In **session 3** I described a concrete edge case I'd spotted in the sell flow
     88— nothing stopped a user from placing a second sell order against crypto
     89already promised to a first, unsettled one — and asked the AI to fix it
     90starting from P1 and carry the fix through every phase. This added
     91`reserved_quantity` to `Holds` and changed the sell path in `trade.go` to use
     92it; see the model history on
     93[ERModel](ERModel.md#entity-relationship-model-history) (v03) and the log
     94below.
    6795
    6896Below is the log, one entry per exchange. My prompts are quoted as **excerpts**:
    … …  
    176204>    any further prompts. The complete transcript is in your Claude Code session
    177205>    history.
     206
     207---
     208
     209### Session 3 — 2026-09-16
     210
     211**Intent:** describe a concrete gap I'd found in the sell-order design — the
     212model had no way to represent crypto committed to an order that had not yet
     213settled — and have the AI trace the fix through every phase rather than patch
     214`trade.go` alone.
     215
     216**Prompt (student, excerpt):**
     217> Suppose the user owns 2 BTC and wants to sell 0.5 BTC at market price. […] You
     218> currently have Holds: quantity, avg_price but you don't have a concept of
     219> reserved/committed crypto. For cash, you already explicitly distinguish
     220> available_balance, invested_balance. For crypto, you probably want the
     221> equivalent: quantity, reserved_quantity, avg_price. […] Can you please start
     222> from Phase 1 and modify everything to make it work like it should, modify
     223> the AIUsage.md in every Phase, and can you make the ConceptualModel a little
     224> bit more put together not that much spread out.
     225
     226**Response (AI, summarised):**
     227- Read every phase document and the live `server/trade.go`, `portfolio.go` and
     228  `schema_creation.sql` to confirm the actual gap: the sell path checked
     229  `holdings.quantity` directly, with nothing recording that part of a position
     230  was already promised to another order.
     231- Added `reserved_quantity` to `Holds` (P1), `holdings` (P2), the sell scenario
     232  SQL (P3), and `trade.go`/`portfolio.go`/`schema_creation.sql` (P4) — one
     233  attribute threaded consistently through all four phases rather than a
     234  patch confined to the Go code.
     235- Changed the sell path to lock the holding row, compute
     236  `available = quantity − reserved_quantity`, reserve (`reserved_quantity +=
     237  qty`) and then settle (`quantity -= qty; reserved_quantity -= qty`) inside
     238  the same transaction — keeping the existing all-or-nothing guarantee rather
     239  than splitting into two commits, which would leave an unresolvable `open`
     240  order if the process died in between and there is no cancel use case to
     241  recover it.
     242- Made both buy and sell orders genuinely pass through `status='open'` before
     243  `status='executed'`, instead of being inserted as `'executed'` directly, so
     244  the lifecycle the ER model already claimed for `Orders` is real.
     245- Added a `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)`
     246  constraint and exposed `reserved_quantity`/`available_quantity` on
     247  `v_portfolio` and in the CLI's portfolio table, so the new field is
     248  observable rather than a column nobody can see.
     249- Restructured this phase's [ERModel](ERModel.md) into short rationale + a
     250  compact attribute table per entity/relationship, per the "more put together,
     251  not that much spread out" request — no content was removed, only
     252  reformatted.
     253- Verified the fix against the live database (`bp_database` on `localhost:5433`):
     254  ran two concurrent `sell 1.0 BTC` attempts against a holding with only 1.5
     255  BTC available — exactly one succeeded, the other correctly reported
     256  insufficient holding — and ran the reserve/settle sequence by hand in `psql`
     257  to show `reserved_quantity` at 0.5 mid-transaction. Both are recorded in
     258  [UseCase0005Implementation](../P4-Prototype/UseCase0005Implementation.md).
     259
     260**What I decided:** to keep reserve and settle inside one transaction rather
     261than two (see the AI's reasoning above — I agreed with it, since a stuck
     262`open` order with no cancel command would be a worse bug than the one being
     263fixed).
     264
     265**Follow-up, same day:** I asked for `ERModel_v03.xml`/`.png` after all,
     266having noticed the PNG still showed v02 with no `reserved_quantity` on it, and
     267asked at the same time for the diagram to be a little more compact — it had a
     268lot of empty canvas in the middle. The AI drove TerraER's own classes directly
     269(load → clone the `quantity` attribute → relabel it → add its connecting line
     270→ pull every cluster toward the centroid → save → render), described in full
     271under [Diagram](#diagram) above, rather than hand-editing the XML or asking me
     272to do it in the GUI. I reviewed the rendered PNG before accepting it.
     273
     274**Second follow-up, same day:** the first `ERModel_v03.png` rendered with a
     275black background instead of white, unlike v01/v02. Cause: TerraER's
     276`ImageOutputFormat` defaults to an ARGB image and paints its background with
     277zero alpha (transparent), not opaque white; whatever displayed the PNG then
     278flattened that transparency onto black instead of white. Fixed by exporting
     279through the same `ImageOutputFormat.toImage(...)` call for figure geometry,
     280but compositing its result onto an explicitly white-filled opaque `RGB` image
     281before saving, rather than trusting the library's own (transparent) output.
     282Verified the fix by reading back the corner pixel of the written PNG as pure
     283white `(255,255,255)`, matching `ERModel_v02.png`.
     284
     285> **Student action required.** Open `ERModel_v03.xml` in TerraER and read it
     286> end to end before submission — see the note at the end of
     287> [ERModel](ERModel.md). Everything else in this session's diff is already
     288> applied to the docs and to `server/`.
  • docs/P2-RelationalDesign/RelationalDesign.md

    rdf05838 r9577c79  
    1111- **Markets**(<u>**id**</u>, *crypto_id*, quote_currency, is_active, created_at)
    1212  - Candidate keys: `{id}`, `{crypto_id, quote_currency}`. `UNIQUE(crypto_id, quote_currency)`.
    13 - **Holdings**(<u>**id**</u>, *user_id*, *crypto_id*, quantity, avg_price, created_at, updated_at)
     13- **Holdings**(<u>**id**</u>, *user_id*, *crypto_id*, quantity, reserved_quantity, avg_price, created_at, updated_at)
    1414  - Transformation of the M:N relationship `Holds`. Candidate keys: `{id}` and
    1515    `{user_id, crypto_id}` — the latter is the relationship's own key and is
    … …  
    1717    consistency with the other relations.
    1818  - `avg_price` is `NOT NULL DEFAULT 0 CHECK (avg_price >= 0)`.
     19  - `reserved_quantity` is `NOT NULL DEFAULT 0 CHECK (reserved_quantity >= 0
     20    AND reserved_quantity <= quantity)` — the amount already committed to the
     21    user's own open sell orders. `quantity - reserved_quantity` (the amount
     22    actually free to sell) is not a stored column; it is computed wherever
     23    needed, in `v_portfolio` as `available_quantity` and in the sell path of
     24    [UseCase0005](../P3-UseCaseModel/UseCase0005.md). See
     25    [ERModel](../P1-ConceptualModel/ERModel.md#holds--users-m--cryptos-n-partial-on-both-sides-with-attributes)
     26    for why this mirrors `available_balance`/`invested_balance` on `Users`.
    1927- **Orders**(<u>**id**</u>, *user_id*, *market_id*, side, type, status, quantity, price, placed_at, executed_at)
    2028  - `side ∈ {buy, sell}`, `type ∈ {market, limit}`, `status ∈ {open, executed, cancelled}`.
    … …  
    5664### Normalisation
    5765
     66> **Validated in P5.** [Normalization](../P5-Normalization/Normalization.md) derives this
     67> exact schema independently — starting only from a single de-normalized relation of every
     68> model attribute and its functional dependencies, with no reference to the ER-to-relational
     69> transformation below — and shows it decomposes to **BCNF**, one normal form stronger than
     70> the 3NF claimed here. The two designs agree relation for relation and key for key, so
     71> nothing here changed as a result; see that page's
     72> [discussion](../P5-Normalization/Normalization.md#discussion) for what the one real
     73> difference is (`avg_price`, a stored derived value, not a normalisation issue) and why this
     74> design is still the one used from P5 onward.
     75
    5876All relations are in **3NF**:
    5977
    … …  
    7290  yields `NULL`, so a nullable average would have silently blanked the
    7391  unrealised-P/L column for an existing position instead of failing loudly.
     92- `holdings.reserved_quantity`, unlike `avg_price`, is **not** derived — it is
     93  written directly by the application (`trade.go`) as orders are placed and
     94  settled, the same way `quantity` itself is. `quantity - reserved_quantity`
     95  ("available") is the derived value here, and it is never stored, only
     96  computed where it is needed.
     97
     98### Reservation and the order lifecycle
     99
     100`holdings.reserved_quantity` exists so that placing a sell order can be
     101checked against what a user actually has *free* to sell
     102(`quantity - reserved_quantity`), not against the raw `quantity`, which also
     103counts crypto already promised to another order that has not settled yet.
     104`CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` makes an
     105inconsistent reservation impossible at the database level, regardless of what
     106application code does. The exact statement sequence — lock the row, check the
     107available amount, reserve, then settle — is in
     108[UseCase0005](../P3-UseCaseModel/UseCase0005.md); the same
     109`SELECT … FOR UPDATE` locking that already protected `users.available_balance`
     110on the buy path is what makes two concurrent sell orders against the same
     111holding serialize correctly instead of racing.
    74112
    75113## DDL script
    … …  
    80118- 10 tables with check constraints, primary keys, foreign keys and unique constraints.
    81119- 5 performance indexes.
    82 - 2 views: `v_latest_prices` (latest trade price per market) and `v_portfolio` (per-user holdings valuation with unrealised P/L).
     120- 2 views: `v_latest_prices` (latest trade price per market) and `v_portfolio` (per-user holdings valuation with unrealised P/L, plus `reserved_quantity` and the derived `available_quantity`).
    83121
    84122## DML script (sample data)
  • docs/P2-RelationalDesign/RelationalDesignAIUsage.md

    rdf05838 r9577c79  
    6969> against the faculty database. No AI involvement is possible there — it needs a
    7070> live connection to your assigned database.
     71
     72### Session 3 — 2026-09-16
     73
     74Driven by the same design review logged in full in
     75[ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16):
     76a sell order had nothing to check `holdings.quantity` against except itself,
     77so nothing stopped two sell orders from being granted the same units.
     78
     79Changes to the P2 artefacts:
     80
     81- `holdings` gained `reserved_quantity numeric(20,4) NOT NULL DEFAULT 0
     82  CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` in
     83  `schema_creation.sql`.
     84- `v_portfolio` gained `reserved_quantity` and the derived
     85  `available_quantity = quantity - reserved_quantity`.
     86- [RelationalDesign](RelationalDesign.md) gained a "Reservation and the order
     87  lifecycle" section explaining why the check is enforced at the database
     88  level rather than trusted to application code, and why it does not conflict
     89  with the existing `SELECT … FOR UPDATE` locking on the sell path.
     90- `data_load.sql` needed no change — `reserved_quantity` defaults to 0, which
     91  is correct for every seeded holding.
     92
     93Re-run end to end against the live database on `localhost:5433`
     94(`-init` then `-load-data`), and against a manually seeded 2 BTC holding to
     95reproduce the exact scenario that motivated the change — see
     96[UseCase0005Implementation](../P4-Prototype/UseCase0005Implementation.md) for
     97the transcript.
     98
     99**What I decided:** to add the `CHECK` constraint rather than rely on
     100`trade.go` alone to keep the reservation consistent — the same reasoning
     101already applied to `avg_price NOT NULL` in session 2.
  • docs/P3-UseCaseModel/UseCase0004.md

    rdf05838 r9577c79  
    3737   BEGIN;
    3838
     39   -- (a) record intent — no trade has happened yet.
    3940   INSERT INTO project.orders
    40        (user_id, market_id, side, type, status, quantity, price, executed_at)
     41       (user_id, market_id, side, type, status, quantity, price)
    4142   VALUES
    42        ($user_id, $market_id, 'buy', 'market', 'executed', $qty, $price, now())
     43       ($user_id, $market_id, 'buy', 'market', 'open', $qty, $price)
    4344   RETURNING id;  -- captured as $order_id
    4445
     46   -- (b) lock and check the user balance
    4547   SELECT available_balance FROM project.users WHERE id = $user_id FOR UPDATE;
    4648   -- abort if available_balance < notional
    4749
     50   -- (c) move cash from available to invested. A buy never reserves crypto
     51   --     the way a sell does — it only ever adds to the position, so there
     52   --     is nothing on the holdings side to commit before settling.
    4853   UPDATE project.users
    4954      SET available_balance = available_balance - $notional,
    … …  
    5257    WHERE id = $user_id;
    5358
    54    -- Upsert holding with running weighted-average price:
     59   -- (d) upsert holding with running weighted-average price:
    5560   SELECT quantity, avg_price
    5661     FROM project.holdings
    … …  
    6166   -- new_avg = (old_qty*old_avg + $qty*$price) / (old_qty + $qty)
    6267
     68   -- (e) ledger entry
    6369   INSERT INTO project.transactions
    6470       (user_id, type, amount, currency, related_order, description)
    … …  
    6672       ($user_id, 'buy', -$notional, 'USD', $order_id, 'Market buy ...');
    6773
     74   -- (f) record the resulting market trade
    6875   INSERT INTO project.market_trades
    6976       (market_id, executed_at, price, quantity, side, source)
    7077   VALUES
    7178       ($market_id, now(), $price, $qty, 'buy', 'user');
     79
     80   -- (g) settle the order itself — it has now actually been filled.
     81   UPDATE project.orders
     82      SET status = 'executed', executed_at = now()
     83    WHERE id = $order_id;
    7284
    7385   COMMIT;
  • docs/P3-UseCaseModel/UseCase0005.md

    rdf05838 r9577c79  
    66
    77A Trader sells part or all of a holding at the current market price. Cost basis is preserved so realised P/L can be reconstructed from the ledger.
     8
     9## Reserve, then settle
     10
     11The crypto being sold is **reserved** (`holdings.reserved_quantity`) before it
     12is actually removed from the position, so the check a second sell order makes
     13is always against what is truly still free (`quantity - reserved_quantity`),
     14not against the raw `quantity`, which would also count crypto already
     15promised to this order. Because only market orders are implemented, an order
     16settles in the same database transaction it is placed in, so reserve and
     17settle below are two statements inside one commit rather than two separate
     18ones — the existing all-or-nothing guarantee (see
     19[PrototypeImplementation](../P4-Prototype/PrototypeImplementation.md)) is
     20kept. They stay logically distinct so that a future limit-order matcher —
     21where an order really would sit `open` for a while before a *later*
     22transaction settles it — needs only a second transaction where today there is
     23one, not a schema change.
    824
    925## Scenario
    … …  
    1834   BEGIN;
    1935
     36   -- (a) record intent — no trade has happened yet.
    2037   INSERT INTO project.orders
    21        (user_id, market_id, side, type, status, quantity, price, executed_at)
     38       (user_id, market_id, side, type, status, quantity, price)
    2239   VALUES
    23        ($user_id, $market_id, 'sell', 'market', 'executed', $qty, $price, now())
     40       ($user_id, $market_id, 'sell', 'market', 'open', $qty, $price)
    2441   RETURNING id;   -- $order_id
    2542
    26    SELECT quantity, avg_price
     43   -- (b) lock the holding and check what is actually free to sell.
     44   SELECT quantity, reserved_quantity, avg_price
    2745     FROM project.holdings
    2846    WHERE user_id = $user_id AND crypto_id = $crypto_id
    2947    FOR UPDATE;
    30    -- abort if row missing or quantity < $qty
     48   -- available := quantity - reserved_quantity
     49   -- abort if row missing or available < $qty
    3150   ```
    32 6. If the holding check passes, system reduces the holding, credits cash and debits invested, and appends a ledger and a market trade:
     51
     526. If the check passes, system reserves the crypto, then — since this is a market order — settles it immediately, all inside the same transaction:
    3353
    3454   ```sql
     55   -- (c) reserve: committed to this order, not yet removed from the position.
    3556   UPDATE project.holdings
    36       SET quantity   = quantity - $qty,
    37           updated_at = now()
     57      SET reserved_quantity = reserved_quantity + $qty,
     58          updated_at        = now()
     59    WHERE user_id = $user_id AND crypto_id = $crypto_id;
     60
     61   -- (d) settle: release the reservation and remove the asset in one step.
     62   UPDATE project.holdings
     63      SET quantity          = quantity - $qty,
     64          reserved_quantity = reserved_quantity - $qty,
     65          updated_at        = now()
    3866    WHERE user_id = $user_id AND crypto_id = $crypto_id;
    3967
    … …  
    5482       ($market_id, now(), $price, $qty, 'sell', 'user');
    5583
     84   -- (e) settle the order itself — it has now actually been filled.
     85   UPDATE project.orders
     86      SET status = 'executed', executed_at = now()
     87    WHERE id = $order_id;
     88
    5689   COMMIT;
    5790   ```
     91
    58927. System confirms: `Order executed: sell 0.5000 ETH @ 3520.000000 (notional 1760.0000 USD)`.
    5993
    6094### Alternate flow 5a — insufficient holding
    6195
    62 If the `SELECT ... FOR UPDATE` returns no row, or the held quantity is smaller than the sell quantity, the entire transaction rolls back and system shows "Insufficient holding: trying to sell X, hold Y."
     96If the holding row is missing, or `quantity - reserved_quantity < $qty`, the
     97entire transaction rolls back — including the `open` order from step 5, which
     98was never committed — and system shows:
     99`"Insufficient holding: trying to sell X, available Y (of Z held, W reserved)."`
     100
     101### Worked example — the case this fixes
     102
     103Alice holds 2 BTC, `reserved_quantity = 0`, and places `sell 0.5 BTC`:
     104
     105| | quantity | reserved_quantity | available |
     106|---|---|---|---|
     107| before | 2.0000 | 0.0000 | 2.0000 |
     108| after step (c) — reserved | 2.0000 | 0.5000 | 1.5000 |
     109| after step (d) — settled | 1.5000 | 0.0000 | 1.5000 |
     110
     111If a second sell for more than 1.5 BTC is placed concurrently, its own
     112`SELECT … FOR UPDATE` in step 5b blocks until the first transaction commits,
     113then sees the reduced `quantity` and correctly reports insufficient holding —
     114proven under real concurrency in
     115[UseCase0005Implementation](../P4-Prototype/UseCase0005Implementation.md).
    63116
    64117### Realised P/L (post-scenario)
  • docs/P3-UseCaseModel/UseCase0006.md

    rdf05838 r9577c79  
    1717   SELECT symbol,
    1818          quantity,
     19          COALESCE(reserved_quantity,  0),
     20          COALESCE(available_quantity, quantity),
    1921          COALESCE(avg_price,      0),
    2022          COALESCE(current_price,  0),
    … …  
    2628    ORDER BY symbol;
    2729   ```
     30
     31   `reserved_quantity` is the amount committed to the Trader's own open sell
     32   orders (see [UseCase0005](UseCase0005.md)); `available_quantity` is what is
     33   actually free to sell right now.
    28343. System displays the rows and a computed summary:
    2935
    … …  
    5460       c.symbol,
    5561       h.quantity,
     62       h.reserved_quantity,
     63       (h.quantity - h.reserved_quantity)      AS available_quantity,
    5664       h.avg_price,
    5765       lp.price                                AS current_price,
  • docs/P3-UseCaseModel/UseCaseModel.md

    rdf05838 r9577c79  
    3838 * [https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0005.md UC0005] –
    3939   '''Place market SELL order''' – Trader sells part or all of a holding at the current market
    40    price, which credits cash and preserves the cost basis.
     40   price, which reserves the crypto being sold, credits cash and preserves the cost basis.
    4141 * [https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0006.md UC0006] –
    4242   '''View portfolio and transaction history''' – Trader inspects current holdings, unrealised
    … …  
    8888||[https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0003.md UC0003 – Deposit]||High||Shows a multi-row transaction: `UPDATE users` plus `INSERT INTO transactions`.||
    8989||[https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0004.md UC0004 – Buy]||Very high||Core of the exchange: `INSERT orders`, `UPDATE users`, upsert `holdings`, ledger entry, market trade.||
    90 ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0005.md UC0005 – Sell]||Very high||Dual of Buy; demonstrates row-level `FOR UPDATE` locking and cost-basis bookkeeping.||
     90||[https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0005.md UC0005 – Sell]||Very high||Dual of Buy; demonstrates row-level `FOR UPDATE` locking, reservation of committed crypto (`holdings.reserved_quantity`) and cost-basis bookkeeping.||
    9191||[https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0006.md UC0006 – Portfolio]||High||Demonstrates joins over `holdings`, `markets` and `crypto`, and the `v_portfolio` view.||
    9292||[https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0007.md UC0007 – Watchlist]||Medium||Demonstrates N–M relation handling and `ON CONFLICT` upsert semantics.||
    … …  
    104104   kept in one place. Direct links:
    105105   [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-1--2026-04-21 Session 1 – 2026-04-21],
    106    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-2--2026-08-06--2026-08-07 Session 2 – 2026-08-06/07].
     106   [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-2--2026-08-06--2026-08-07 Session 2 – 2026-08-06/07],
     107   [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16 Session 3 – 2026-09-16].
    107108
    108109'''Service:''' Claude Code (Anthropic), https://claude.com/claude-code – Claude subscription,
    109 model Claude Opus 4.7 (1M context).
     110model Claude Opus 4.7 (1M context) in sessions 1–2, Claude Sonnet 5 in session 3.
    110111
    111112'''In short:''' the AI proposed the actor taxonomy and drafted the seven use cases with their SQL
    112113in session 1. In session 2 the use-case model itself was '''not''' changed – the only work was
    113114re-executing every scenario, including the failure paths, against a live PostgreSQL 16 database.
     115In session 3, UC0004 and UC0005 were revised to reserve the resource an order commits (crypto on
     116a sell) before settling it, closing a gap where nothing stopped a second sell order from being
     117granted crypto already promised to a first one; see
     118[UseCaseModelAIUsage](UseCaseModelAIUsage.md#session-3--2026-09-16).
  • docs/P3-UseCaseModel/UseCaseModelAIUsage.md

    rdf05838 r9577c79  
    5555duplicate registration, wrong password). The results are documented per use case
    5656on the `UseCaseXXXXImplementation` pages.
     57
     58### Session 3 — 2026-09-16
     59
     60Driven by the design review logged in full in
     61[ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16):
     62placing a sell order checked `holdings.quantity` directly, with no way to
     63record that part of a position was already promised to another, unsettled
     64order.
     65
     66**What changed:**
     67
     68- [UseCase0005](UseCase0005.md) — the scenario now reserves the crypto
     69  (`holdings.reserved_quantity`) before removing it from the position, checks
     70  `quantity - reserved_quantity` rather than raw `quantity`, and adds a
     71  worked example and a note on why the reserve and settle steps stay inside
     72  one transaction rather than two (only market orders are implemented, and
     73  splitting into two commits would risk an order stuck `open` with no cancel
     74  use case to recover it).
     75- [UseCase0004](UseCase0004.md) — no change to the balance logic, but the
     76  order insert now goes through `status='open'` before a final
     77  `UPDATE ... SET status='executed'`, matching the sell side, so `Orders`
     78  genuinely has the lifecycle [ERModel](../P1-ConceptualModel/ERModel.md)
     79  describes for it rather than a status column that is only ever written
     80  once.
     81- [UseCase0006](UseCase0006.md) — the `v_portfolio` reference and its query
     82  gained `reserved_quantity`/`available_quantity`, since the portfolio screen
     83  is where a Trader would actually see the new field.
     84- The use-case importance table and UC0005's one-line description in
     85  [UseCaseModel](UseCaseModel.md) were reworded to mention the reservation.
     86
     87Every changed scenario's SQL was re-run against the live database, including a
     88two-concurrent-sells test that reproduces the exact bug being fixed: see
     89[UseCase0005Implementation](../P4-Prototype/UseCase0005Implementation.md).
     90
     91**What I decided:** to keep this a revision of the existing UC0004/UC0005
     92pages rather than a new use case (e.g. "cancel order") — `cancelled` remains
     93an unused status, same as before, since nothing in the prototype produces it
     94and inventing a cancel flow was not what the review asked for.
  • docs/P4-Prototype/BuildInstructions.md

    rdf05838 r9577c79  
    106106most recent trade (`v_latest_prices`), never from a stored column.
    107107
     108### 7. Optional — richer data for the P6 reports
     109
     110`data_load.sql` only seeds a few minutes of trade history, which is not enough
     111for the [top traders](../P6-AdvancedReports/AdvancedReports.md#top-traders-by-realized-performance)
     112or [market performance](../P6-AdvancedReports/AdvancedReports.md#market-performance-leaderboard)
     113reports (menu `[10]`/`[11]`) to show more than a single period. To see them do
     114something more interesting, load five quarters of synthetic history on top:
     115
     116```sh
     117psql "postgresql://$DBUSER:$DBPASSWORD@$DBHOST:$DBPORT/$DBNAME" \
     118  -f server/db/reports_demo_data.sql
     119```
     120
     121It is deliberately not part of `-init`/`-load-data` — see the header of
     122[`reports_demo_data.sql`](../../server/db/reports_demo_data.sql) for why — so
     123running it never changes the balances the smoke test below checks.
     124
    108125## Testing instructions
    109126
    … …  
    114131funds**, **Browse markets**, **Place market BUY order**, **Place market SELL
    115132order**, **View portfolio**, **View transaction history**, **Manage watchlist**,
    116 **Logout**.
     133**Logout**, and two [P6](../P6-AdvancedReports/AdvancedReports.md) reports:
     134**Report: top traders** and **Report: market performance**.
    117135
    118136You never have to remember an identifier. Markets are always printed as a
    … …  
    122140### End-to-end smoke test
    123141
    124 Verified on 2026-08-07 against PostgreSQL 16 with freshly loaded sample data.
     142Verified on 2026-09-16 against PostgreSQL 16 with freshly loaded sample data.
    125143Expected values are exact.
    126144
    1271451. `./eduberza -init` — prints `Database initialised.`
    1281462. `./eduberza`, then `[2] Login` → `alice` / `test123` → `Login successful.`
    129 3. `[6] View portfolio` → one row: `ETH 0.5000` at avg 3500.000000, current
    130    3520.000000, value 1760.0000, unrealised P/L `+10.0000`. Cash available
    131    8250.0000, net worth 10010.0000.
     1473. `[6] View portfolio` → one row: `ETH 0.5000` reserved 0.0000, available
     148   0.5000, at avg 3500.000000, current 3520.000000, value 1760.0000,
     149   unrealised P/L `+10.0000`. Cash available 8250.0000, net worth 10010.0000.
    1321504. `[4] Place market BUY order` → `BTC` → `0.01` →
    133151   `Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)`.
    … …  
    150168  to any table — no order row, no ledger entry, no holding.
    151169- **Insufficient holding:** as `bob` (no positions), try to sell `1` ETH.
    152   Expect `Insufficient holding: trying to sell 1.0000, hold 0.0000`.
     170  Expect `Insufficient holding: trying to sell 1.0000, available 0.0000 (of
     171  0.0000 held, 0.0000 reserved)`.
     172- **Two sell orders racing for the same crypto:** give `alice` a 2 BTC holding
     173  and start two `eduberza` processes at once, each selling `1.5` BTC (together
     174  3 BTC, more than she has). Expect exactly one `Order executed`, and the
     175  other `Insufficient holding` reading the post-commit quantity — see
     176  [UseCase0005Implementation](UseCase0005Implementation.md) for the exact
     177  transcript. This is the concurrency guarantee that
     178  `holdings.reserved_quantity` and the `SELECT ... FOR UPDATE` lock together
     179  provide.
    153180- **Duplicate registration:** register with username `alice`. Expect
    154181  `Username or email already taken.`
  • docs/P4-Prototype/PrototypeImplementation.md

    rdf05838 r9577c79  
    4646   `INSERT … ON CONFLICT DO UPDATE` upsert possible, so the weighted-average entry price is
    4747   recomputed by the database in one statement instead of by a read-modify-write in application
    48    code.
     48   code. `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` is the same idea
     49   applied to the sell path: an inconsistent reservation is impossible at the database level, not
     50   just something `trade.go` is careful about.
     51 * '''Selling reserves before it removes.''' A sell order locks the holding row, reserves the
     52   quantity being sold, then settles by removing it — see
     53   [UseCase0005Implementation](UseCase0005Implementation.md). Two sell orders placed at the same
     54   instant for more than the available quantity are serialised correctly by `SELECT ... FOR
     55   UPDATE`, not just by luck of everything happening in one CLI process; this is demonstrated
     56   there with two concurrent processes.
    4957 * '''No identifiers are ever typed.''' Markets are listed with their prices before any choice is
    5058   made, and everything else is selected by symbol.
    … …  
    6371 * There is no connection pooling configuration and no explicit isolation level; both are P8
    6472   topics.
    65 == AI usage ==
    66 
    67 AI was used in this phase and is logged in full, per the course rule for P1 onward.
    68 
    69  * '''Phase log:'''
    70    [https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCaseModelAIUsage.md UseCaseModelAIUsage.md]
    71    – service used, what the AI produced, and what I decided myself.
    72  * '''Full conversation transcript:'''
    73    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md ERModelAIUsage.md]
    74    – the same conversation produced the P1–P4 artefacts, so the complete prompt/response log is
    75    kept in one place. Direct links:
    76    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-1--2026-04-21 Session 1 – 2026-04-21],
    77    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-2--2026-08-06--2026-08-07 Session 2 – 2026-08-06/07].
    78 
    79 '''Service:''' Claude Code (Anthropic), https://claude.com/claude-code – Claude subscription,
    80 model Claude Opus 4.7 (1M context).
    81 
    82 '''In short:''' the AI proposed the actor taxonomy and drafted the seven use cases with their SQL
    83 in session 1. In session 2 the use-case model itself was '''not''' changed – the only work was
    84 re-executing every scenario, including the failure paths, against a live PostgreSQL 16 database.
     73 * Reservation only ever lives inside one transaction, because only market orders (which settle
     74   immediately) exist. A real limit-order matcher would leave `holdings.reserved_quantity` set
     75   and `orders.status = 'open'` between two separate commits, and would need a way to cancel an
     76   order to release the reservation — neither is implemented, since nothing in the prototype
     77   produces an order that stays open.
    8578
    8679== AI usage ==
    … …  
    9689   kept in one place. Direct links:
    9790   [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-1--2026-04-21 Session 1 – 2026-04-21],
    98    [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-2--2026-08-06--2026-08-07 Session 2 – 2026-08-06/07].
     91   [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-2--2026-08-06--2026-08-07 Session 2 – 2026-08-06/07],
     92   [https://github.com/StefanTrsunov/bp/blob/main/docs/P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16 Session 3 – 2026-09-16].
    9993
    10094'''Service:''' Claude Code (Anthropic), https://claude.com/claude-code – Claude subscription,
    101 model Claude Opus 4.7 (1M context).
     95model Claude Opus 4.7 (1M context) in sessions 1–2, Claude Sonnet 5 in session 3.
    10296
    10397'''In short:''' session 1 rewrote the existing Chi/HTTP backend as the CLI prototype covering
    … …  
    106100infinite loop at end of input, and an error check in the wrong order that misreported database
    107101failures as "Insufficient holding" – and replaced the read-modify-write holding update with a
    108 single `INSERT … ON CONFLICT DO UPDATE`.
     102single `INSERT … ON CONFLICT DO UPDATE`. Session 3 added `holdings.reserved_quantity` and changed
     103`trade.go`'s sell path to reserve crypto before removing it, closing a gap where two sell orders
     104could be granted the same units; see
     105[PrototypeImplementationAIUsage](PrototypeImplementationAIUsage.md#session-3--2026-09-16).
  • docs/P4-Prototype/PrototypeImplementationAIUsage.md

    rdf05838 r9577c79  
    3939## Summary of AI involvement
    4040
    41 | | Session 1 — 2026-04-21 | Session 2 — 2026-08-06/07 |
    42 |---|---|---|
    43 | **What I brought** | My existing Go backend (Chi HTTP handlers) and a half-finished frontend | The CLI prototype as it stood after session 1 |
    44 | **What the AI did** | Rewrote the backend as a CLI covering UC0001–UC0007, wrote the market bot | Reviewed the code, found and fixed three bugs, improved the holding upsert |
    45 | **What I decided** | To delete the frontend, to build a CLI rather than a web app, to keep the market simulator | To ask for a code review pass rather than documentation alone |
     41| | Session 1 — 2026-04-21 | Session 2 — 2026-08-06/07 | Session 3 — 2026-09-16 |
     42|---|---|---|---|
     43| **What I brought** | My existing Go backend (Chi HTTP handlers) and a half-finished frontend | The CLI prototype as it stood after session 1 | A design review: the sell path had no way to reserve crypto committed to an order |
     44| **What the AI did** | Rewrote the backend as a CLI covering UC0001–UC0007, wrote the market bot | Reviewed the code, found and fixed three bugs, improved the holding upsert | Added `holdings.reserved_quantity`, changed the sell path to reserve-then-settle, gave `Orders.status` a real lifecycle, tested it live including under real concurrency |
     45| **What I decided** | To delete the frontend, to build a CLI rather than a web app, to keep the market simulator | To ask for a code review pass rather than documentation alone | To keep reserve+settle in one transaction rather than split it across two, since there is no cancel-order use case to recover a stuck reservation |
    4646
    4747The prototype was built in session 1 and worked. What session 2 added was a
    … …  
    116116> presentation. You will be asked how the buy transaction works, and the answer
    117117> has to be yours.
     118
     119### Session 3 — 2026-09-16
     120
     121Prompted by a design review I did myself, logged in full in
     122[ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16).
     123The report: a user who owns 2 BTC and places a sell order for 0.5 BTC has that
     124crypto immediately removed from `quantity`, but nothing in the model recorded
     125that a *pending* order had already committed part of a position before it
     126settled — `holdings` had `quantity` and `avg_price` only, no equivalent of the
     127`available_balance`/`invested_balance` split already used for cash.
     128
     129**Bug fixed**
     130
     131`server/trade.go`'s sell path checked `held < qty` directly against
     132`holdings.quantity`. This happened to be safe against concurrent double-sells
     133only because the whole operation — order, holding check, holding update,
     134balance update, ledger, trade — runs inside one transaction with a
     135`SELECT ... FOR UPDATE` lock. It was not safe against the actual scenario
     136described: nothing distinguished "owned" from "owned, but already promised to
     137this order," which matters the moment an order can legitimately sit `open`
     138across more than one transaction — exactly what a limit-order matcher would
     139need, and what `orders.status` already implied was coming.
     140
     141**What changed**
     142
     1431. `holdings.reserved_quantity numeric(20,4) NOT NULL DEFAULT 0 CHECK
     144   (reserved_quantity >= 0 AND reserved_quantity <= quantity)` added to
     145   `schema_creation.sql`.
     1462. `v_portfolio` gained `reserved_quantity` and derived `available_quantity`.
     1473. `trade.go`'s sell path now: locks the holding, computes
     148   `available := quantity - reserved_quantity`, rejects if `available < qty`
     149   (previously: rejects if `quantity < qty`), reserves
     150   (`reserved_quantity += qty`), then settles (`quantity -= qty;
     151   reserved_quantity -= qty`) — two statements instead of one, kept inside the
     152   same transaction rather than split into two commits, which would risk an
     153   order stuck `open` with reserved crypto and no cancel command to free it.
     1544. Both `PlaceOrder` branches now insert the order as `status='open'` and
     155   `UPDATE ... SET status='executed', executed_at=now()` at the end, instead
     156   of inserting `'executed'` directly — `Orders.status` is now a real
     157   lifecycle rather than a label written once.
     1585. `portfolio.go` gained `Reserved`/`Available` columns, reading
     159   `v_portfolio.reserved_quantity`/`available_quantity`, so the new field is
     160   something a Trader can actually see.
     1616. The error message on the sell path changed from
     162   `"Insufficient holding: trying to sell X, hold Y"` to
     163   `"Insufficient holding: trying to sell X, available Y (of Z held, W
     164   reserved)"`, since "how much you hold" is no longer the only number that
     165   matters.
     166
     167**Test evidence**
     168
     169Verified against PostgreSQL 16 (`bp_database`, `localhost:5433`):
     170
     171```
     172$ eduberza sell 0.5 BTC  (Alice: 2.0000 BTC held, 0.0000 reserved)
     173Order executed: sell 0.5000 BTC @ 67140.000000 (notional 33570.0000 USD)
     174# holdings.quantity: 2.0000 -> 1.5000, reserved_quantity: 0.0000 (unchanged net of reserve+release)
     175
     176$ eduberza sell 1 ETH   (Bob: no holdings row at all)
     177Insufficient holding: trying to sell 1.0000, available 0.0000 (of 0.0000 held, 0.0000 reserved)
     178
     179# Two concurrent processes, Alice at 1.5 BTC / 0 reserved, each selling 1.0 BTC:
     180=== process A === Insufficient holding: trying to sell 1.0000, available 0.5000 (of 0.5000 held, 0.0000 reserved)
     181=== process B === Order executed: sell 1.0000 BTC @ 67140.000000 (notional 67140.0000 USD)
     182# final holding: quantity 0.5000, reserved_quantity 0.0000 — exactly one sell went through
     183
     184# Reserve visible mid-transaction, in one psql session (BEGIN; ...; COMMIT;):
     185before:            quantity 2.0000, reserved_quantity 0.0000, available 2.0000
     186after reserve:     quantity 2.0000, reserved_quantity 0.5000, available 1.5000
     187after settle:      quantity 1.5000, reserved_quantity 0.0000, available 1.5000
     188
     189# The CHECK constraint holds even without going through trade.go:
     190UPDATE holdings SET reserved_quantity = quantity + 1 WHERE ...;
     191ERROR:  new row for relation "holdings" violates check constraint "holdings_check"
     192```
     193
     194Full transcripts are on
     195[UseCase0005Implementation](UseCase0005Implementation.md). The rejected-order
     196rollback guarantee from session 2 was re-checked too: after both the
     197insufficient-funds and insufficient-holding failure paths, the affected user
     198still has zero new rows in `orders`.
     199
     200**What I decided:** to keep reserve and settle inside a single transaction —
     201splitting them into two, so an order genuinely sits `open` and reserved
     202between two commits, is what a real limit-order matcher will eventually need,
     203but building that now would add a way for an order to get stuck without also
     204building a way to cancel it, which is out of scope for this review. `go build
     205./...` was run after every change; all seven use cases were re-exercised
     206manually.
  • docs/P4-Prototype/UseCase0004Implementation.md

    rdf05838 r9577c79  
    2727   BEGIN;
    2828
    29    -- (a) record the order
     29   -- (a) record the order as 'open' — no trade has happened yet
    3030   INSERT INTO orders
    31        (user_id, market_id, side, type, status, quantity, price, executed_at)
     31       (user_id, market_id, side, type, status, quantity, price)
    3232   VALUES
    33        ($1, $2, 'buy', 'market', 'executed', $3, $4, now())
     33       ($1, $2, 'buy', 'market', 'open', $3, $4)
    3434   RETURNING id;
    3535
    … …  
    3737   SELECT available_balance FROM users WHERE id = $1 FOR UPDATE;
    3838
    39    -- (c) move cash from available to invested
     39   -- (c) move cash from available to invested. A buy never reserves crypto
     40   --     the way a sell does (see UC0005) — it only ever adds to the
     41   --     position, so there is nothing to commit on the holdings side
     42   --     before settling.
    4043   UPDATE users
    4144      SET available_balance = available_balance - $notional,
    … …  
    4750   --     in one statement. Every SET expression sees the pre-update row, so
    4851   --     holdings.quantity below is still the old quantity.
     52   --     reserved_quantity is untouched by a buy and defaults to 0.
    4953   INSERT INTO holdings (user_id, crypto_id, quantity, avg_price, updated_at)
    5054   VALUES ($1, $c, $3, $4, now())
    … …  
    6872       ($2, now(), $4, $3, 'buy', 'user');
    6973
     74   -- (g) settle the order itself — it has now actually been filled
     75   UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $orderId;
     76
    7077   COMMIT;
    7178   ```
    … …  
    7784## Verified run (from actual prototype execution)
    7885
    79 With seed data loaded:
     86Re-run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`) with freshly loaded seed data:
    8087
    81 - **Before:** alice.available_balance = 8250.00, portfolio = { ETH: 0.5 }.
     88- **Before:** alice.available_balance = 8250.00, portfolio = { ETH: 0.5000, reserved 0.0000 }.
    8289- **Command:** `buy 0.01 BTC`.
    83 - **After:** alice.available_balance = 7578.60 (= 8250 − 671.40), portfolio = { BTC: 0.01 @ 67140, ETH: 0.5 @ 3500 }, net worth = 10010.00 USD (the +10 is the ETH unrealised P/L from the price moving from 3500 → 3520).
     90- **After:** alice.available_balance = 7578.60 (= 8250 − 671.40), portfolio = { BTC: 0.0100 @ 67140 (reserved 0.0000), ETH: 0.5000 @ 3500 (reserved 0.0000) }, net worth = 10010.00 USD (the +10 is the ETH unrealised P/L from the price moving from 3500 → 3520). A buy never sets `reserved_quantity`, so it reads 0 on every row here.
    8491
    8592## Failure path — insufficient funds
    8693
    87 If `available_balance < notional`, the `defer tx.Rollback()` in `server/trade.go` reverts all six statements and the user sees:
     94If `available_balance < notional`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above and the user sees:
    8895
    8996```
  • docs/P4-Prototype/UseCase0005Implementation.md

    rdf05838 r9577c79  
    22
    33**Initiating actor:** Trader. **Source file:** `server/trade.go`, function `PlaceOrder(s, "sell")`.
     4
     5## The bug this closes
     6
     7Before this change, `holdings` had `quantity` and `avg_price` only. The sell
     8path checked `held < qty` straight against `quantity`, which cannot tell
     9"owned" apart from "owned, but already committed to another order that has
     10not settled." `holdings.reserved_quantity` fixes that: the crypto being sold
     11is reserved before it is removed from the position, and the check is against
     12`quantity - reserved_quantity`.
    413
    514## Scenario (implemented)
    … …  
    1322   BEGIN;
    1423
     24   -- (a) record the order as 'open' — no trade has happened yet
    1525   INSERT INTO orders
    16        (user_id, market_id, side, type, status, quantity, price, executed_at)
     26       (user_id, market_id, side, type, status, quantity, price)
    1727   VALUES
    18        ($1, $2, 'sell', 'market', 'executed', $3, $4, now())
     28       ($1, $2, 'sell', 'market', 'open', $3, $4)
    1929   RETURNING id;
    2030
    21    SELECT quantity, avg_price FROM holdings
     31   -- (b) lock the holding and check what is actually free to sell
     32   SELECT quantity, reserved_quantity, avg_price FROM holdings
    2233    WHERE user_id = $1 AND crypto_id = $c FOR UPDATE;
    23    -- abort if missing or insufficient
    24 
     34   -- available := quantity - reserved_quantity
     35   -- abort if missing or available < $qty
     36
     37   -- (c) reserve: committed to this order, not yet removed from the position
    2538   UPDATE holdings
    26       SET quantity = quantity - $qty, updated_at = now()
     39      SET reserved_quantity = reserved_quantity + $qty, updated_at = now()
     40    WHERE user_id = $1 AND crypto_id = $c;
     41
     42   -- (d) settle: a market order fills immediately, so release the
     43   --     reservation and remove the asset in the same step
     44   UPDATE holdings
     45      SET quantity = quantity - $qty,
     46          reserved_quantity = reserved_quantity - $qty,
     47          updated_at = now()
    2748    WHERE user_id = $1 AND crypto_id = $c;
    2849
    … …  
    4364       ($2, now(), $price, $qty, 'sell', 'user');
    4465
     66   -- (e) settle the order itself — it has now actually been filled
     67   UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $orderId;
     68
    4569   COMMIT;
    4670   ```
    … …  
    5276## Failure path — insufficient holding
    5377
    54 If the holding does not exist or `quantity < requested`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above and the user sees:
    55 
    56 ```
    57 Insufficient holding: trying to sell X, hold Y
    58 ```
     78If the holding does not exist, or `quantity - reserved_quantity < requested`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above — including the `open` order, which was never committed — and the user sees:
     79
     80```
     81Insufficient holding: trying to sell X, available Y (of Z held, W reserved)
     82```
     83
     84## Verified run — the exact scenario from the design review
     85
     86Run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`).
     87Alice's ETH/BTC holdings were seeded, then her BTC holding was set to exactly
     88the scenario that motivated this fix: 2 BTC owned, nothing reserved.
     89
     90```
     91$ psql ... -c "SELECT symbol, quantity, reserved_quantity, avg_price
     92               FROM holdings h JOIN crypto c ON c.id = h.crypto_id
     93               WHERE user_id = '<alice>';"
     94
     95 symbol | quantity | reserved_quantity |  avg_price
     96--------+----------+--------------------+-------------
     97 BTC    |   2.0000 |             0.0000 | 65000.000000
     98 ETH    |   0.5000 |             0.0000 |  3500.000000
     99```
     100
     101**Step 1 — portfolio before the sell** (`[6] View portfolio`):
     102
     103```
     104  Symbol        Quantity      Reserved     Available         Avg buy         Current           Value  Unrealised P/L
     105  ------------------------------------------------------------------------------------------------------------------
     106  BTC             2.0000        0.0000        2.0000    65000.000000    67140.000000     134280.0000      +4280.0000
     107  ETH             0.5000        0.0000        0.5000     3500.000000     3520.000000       1760.0000        +10.0000
     108  ------------------------------------------------------------------------------------------------------------------
     109  TOTAL                                                                                  136040.0000      +4290.0000
     110```
     111
     112**Step 2 — `[5] Place market SELL order` → `BTC` → `0.5`:**
     113
     114```
     115Order executed: sell 0.5000 BTC @ 67140.000000 (notional 33570.0000 USD)
     116```
     117
     118**Step 3 — portfolio after the sell:**
     119
     120```
     121  BTC             1.5000        0.0000        1.5000    65000.000000    67140.000000     100710.0000      +3210.0000
     122```
     123
     124`quantity` dropped from 2.0 to 1.5 and `reserved_quantity` is back to 0.0000
     125— reserve and settle both happened, inside the one commit, exactly as
     126designed.
     127
     128## Verified run — reserve and settle as two distinct, observable steps
     129
     130The CLI settles a market order in the same transaction it reserves in, so
     131`reserved_quantity` is never visibly nonzero *outside* a transaction. Run by
     132hand in one `psql` session (one transaction, so the session sees its own
     133uncommitted writes) to show the intermediate state that step (c) alone would
     134leave, before step (d) runs:
     135
     136```sql
     137BEGIN;
     138
     139-- before: Alice owns 2 BTC, none reserved
     140SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
     141  FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     142--  quantity | reserved_quantity | available
     143-- ----------+--------------------+-----------
     144--    2.0000 |             0.0000 |    2.0000
     145
     146-- step (c): order placed, 0.5 BTC reserved — no trade has happened yet
     147UPDATE holdings SET reserved_quantity = reserved_quantity + 0.5, updated_at = now()
     148 WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     149
     150SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
     151  FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     152--  quantity | reserved_quantity | available
     153-- ----------+--------------------+-----------
     154--    2.0000 |             0.5000 |    1.5000
     155
     156-- step (d): market order settles immediately, reservation released
     157UPDATE holdings SET quantity = quantity - 0.5, reserved_quantity = reserved_quantity - 0.5, updated_at = now()
     158 WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     159
     160SELECT quantity, reserved_quantity, quantity - reserved_quantity AS available
     161  FROM holdings WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     162--  quantity | reserved_quantity | available
     163-- ----------+--------------------+-----------
     164--    1.5000 |             0.0000 |    1.5000
     165
     166COMMIT;
     167```
     168
     169This is the row that would stay visible to every other connection for as long
     170as the order stayed `open` — i.e. for as long as it took a matcher to fill
     171it, once limit orders exist.
     172
     173## Verified run — two concurrent sells, which is the bug itself
     174
     175The scenario the design review described: a user should not be able to place
     176two sell orders whose combined quantity exceeds what they actually hold. With
     177Alice's BTC holding at 1.5 BTC (0 reserved), two independent CLI processes
     178were started at the same instant, each selling `1.0 BTC` — together 2.0 BTC,
     179more than she has:
     180
     181```
     182$ ( eduberza-sell-1.0-BTC ) &   # process A
     183$ ( eduberza-sell-1.0-BTC ) &   # process B
     184$ wait
     185
     186=== A ===
     187Insufficient holding: trying to sell 1.0000, available 0.5000 (of 0.5000 held, 0.0000 reserved)
     188=== B ===
     189Order executed: sell 1.0000 BTC @ 67140.000000 (notional 67140.0000 USD)
     190
     191=== final holding ===
     192 quantity | reserved_quantity
     193----------+--------------------
     194   0.5000 |             0.0000
     195```
     196
     197One order settled, one was correctly rejected, and the final `quantity`
     198(0.5) is consistent with exactly one 1.0 BTC sell having happened against the
     1991.5 BTC available — not both, and not neither. This is enforced by the
     200`SELECT ... FOR UPDATE` lock on the holdings row: whichever transaction gets
     201there second blocks until the first commits, then re-reads the now-current
     202`quantity`/`reserved_quantity` before deciding.
     203
     204## Verified — the constraint holds even if application code did not
     205
     206```sql
     207UPDATE holdings SET reserved_quantity = quantity + 1 WHERE user_id = '<alice>' AND crypto_id = '<btc>';
     208
     209ERROR:  new row for relation "holdings" violates check constraint "holdings_check"
     210```
     211
     212`CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` in
     213`schema_creation.sql` makes an inconsistent reservation impossible at the
     214database level, independent of `trade.go`.
  • docs/P4-Prototype/UseCase0006Implementation.md

    rdf05838 r9577c79  
    1212   ```sql
    1313   SELECT symbol, quantity,
     14          COALESCE(reserved_quantity,  0),
     15          COALESCE(available_quantity, quantity),
    1416          COALESCE(avg_price,      0),
    1517          COALESCE(current_price,  0),
    … …  
    3032### Verified run
    3133
    32 With the seed data (`data_load.sql`), immediately after login, alice's portfolio prints:
     34Re-run 2026-09-16 against PostgreSQL 16 with the seed data (`data_load.sql`).
     35Immediately after login, alice's portfolio prints (now with the
     36`Reserved`/`Available` columns from `holdings.reserved_quantity`):
    3337
    3438```
    35   Symbol        Quantity         Avg buy         Current           Value  Unrealised P/L
    36   ------------------------------------------------------------------------------------
    37   ETH             0.5000     3500.000000     3520.000000       1760.0000        +10.0000
    38   ------------------------------------------------------------------------------------
    39   TOTAL                                                        1760.0000        +10.0000
     39  Symbol        Quantity      Reserved     Available         Avg buy         Current           Value  Unrealised P/L
     40  ------------------------------------------------------------------------------------------------------------------
     41  ETH             0.5000        0.0000        0.5000     3500.000000     3520.000000       1760.0000        +10.0000
     42  ------------------------------------------------------------------------------------------------------------------
     43  TOTAL                                                                                    1760.0000        +10.0000
    4044
    4145  Cash available : 8250.0000 USD
    … …  
    4347  Net worth      : 10010.0000 USD
    4448```
     49
     50`Reserved` is 0.0000 here because nothing is mid-sell; see
     51[UseCase0005Implementation](UseCase0005Implementation.md) for a portfolio
     52snapshot taken with crypto actually reserved.
    4553
    4654### Transaction history
  • docs/README.md

    rdf05838 r9577c79  
    1313## Team members
    1414
    15 - *Your First Name Last Name — Index XXXXXX*
     15- Stefan Trsunov 231285
    1616
    1717## Course
    … …  
    4141| `P3-UseCaseModel/`      | P3 | `UseCaseModel`, `UseCase0001`–`UseCase0007`, `UseCaseModelAIUsage` |
    4242| `P4-Prototype/`         | P4 | `PrototypeImplementation`, `UseCase000XImplementation`, `BuildInstructions`, `PrototypeImplementationAIUsage` |
     43| `P5-Normalization/`     | P5 | `Normalization`, `NormalizationAIUsage` |
     44| `P6-AdvancedReports/`   | P6 | `AdvancedReports`, `AdvancedReportsAIUsage` |
    4345
    4446`Instructions.md` is the condensed course rubric — reference material, not a
    … …  
    6163| P3 | [UseCaseModel](P3-UseCaseModel/UseCaseModel.md) | Finished, awaiting approval |
    6264| P4 | [PrototypeImplementation](P4-Prototype/PrototypeImplementation.md) | Finished, awaiting approval |
    63 | P5 | *Normalization* | Not started |
    64 | P6 | *Complex DB Reports* | Not started |
     65| P5 | [Normalization](P5-Normalization/Normalization.md) | Finished, awaiting approval |
     66| P6 | [AdvancedReports](P6-AdvancedReports/AdvancedReports.md) | Finished, awaiting approval |
    6567| P7 | *Advanced Database Development* | Not started |
    6668| P8 | *Advanced Application Development* | Not started |
    … …  
    7779| File | Phase | What |
    7880|------|-------|------|
    79 | [`ERModel_v02.xml`](P1-ConceptualModel/ERModel_v02.xml) | P1 | TerraER source, current version |
    80 | [`ERModel_v02.png`](P1-ConceptualModel/ERModel_v02.png) | P1 | Exported diagram image, current version |
     81| [`ERModel_v03.xml`](P1-ConceptualModel/ERModel_v03.xml) | P1 | TerraER source, current version |
     82| [`ERModel_v03.png`](P1-ConceptualModel/ERModel_v03.png) | P1 | Exported diagram image, current version |
     83| [`ERModel_v02.xml`](P1-ConceptualModel/ERModel_v02.xml) | P1 | TerraER source, previous version (kept per P1 rules) |
     84| [`ERModel_v02.png`](P1-ConceptualModel/ERModel_v02.png) | P1 | Exported diagram image, previous version |
    8185| [`ERModel_v01.xml`](P1-ConceptualModel/ERModel_v01.xml) | P1 | TerraER source, first version (kept per P1 rules) |
    8286| [`ERModel_v01.png`](P1-ConceptualModel/ERModel_v01.png) | P1 | Exported diagram image, first version |
    … …  
    8488| [`../server/db/data_load.sql`](../server/db/data_load.sql) | P2 | DML — truncates and reloads sample data |
    8589| [`relational_schema.jpg`](P2-RelationalDesign/relational_schema.jpg) | P2 | Crow's-foot diagram exported from DBeaver |
     90| [`../server/db/reports_demo_data.sql`](../server/db/reports_demo_data.sql) | P6 | Optional multi-quarter demo data for the two reports (not part of `-init`) |
    8691
    8792## Use cases (P3)
  • server/cli.go

    rdf05838 r9577c79  
    7676        fmt.Println("[8] Manage watchlist")
    7777        fmt.Println("[9] Logout")
     78        fmt.Println("[10] Report: top traders")
     79        fmt.Println("[11] Report: market performance")
    7880        fmt.Println("[0] Exit")
    7981        switch prompt("> ") {
    … …  
    9496        case "8":
    9597                ManageWatchlist(s)
     98        case "10":
     99                ShowTopTraders(s)
     100        case "11":
     101                ShowMarketPerformance(s)
    96102        case "9":
    97103                s.UserID = ""
  • server/db/schema_creation.sql

    rdf05838 r9577c79  
    5959-- ============================================================================
    6060CREATE TABLE project.holdings (
    61     id         uuid           PRIMARY KEY DEFAULT gen_random_uuid(),
    62     user_id    uuid           NOT NULL REFERENCES project.users(id)  ON DELETE CASCADE,
    63     crypto_id  uuid           NOT NULL REFERENCES project.crypto(id),
    64     quantity   numeric(20,4)  NOT NULL CHECK (quantity >= 0),
     61    id                uuid           PRIMARY KEY DEFAULT gen_random_uuid(),
     62    user_id           uuid           NOT NULL REFERENCES project.users(id)  ON DELETE CASCADE,
     63    crypto_id         uuid           NOT NULL REFERENCES project.crypto(id),
     64    quantity          numeric(20,4)  NOT NULL CHECK (quantity >= 0),
     65    -- Committed to the user's own open sell orders, not yet removed from the
     66    -- position. quantity - reserved_quantity is what is actually free to
     67    -- sell — the crypto-side equivalent of users.available_balance.
     68    reserved_quantity numeric(20,4)  NOT NULL DEFAULT 0
     69                                      CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity),
    6570    -- Weighted-average entry price. NOT NULL so that the P/L arithmetic in
    6671    -- v_portfolio can never silently produce NULL for an existing position.
    67     avg_price  numeric(18,6)  NOT NULL DEFAULT 0 CHECK (avg_price >= 0),
    68     created_at timestamptz    NOT NULL DEFAULT now(),
    69     updated_at timestamptz,
     72    avg_price         numeric(18,6)  NOT NULL DEFAULT 0 CHECK (avg_price >= 0),
     73    created_at        timestamptz    NOT NULL DEFAULT now(),
     74    updated_at        timestamptz,
    7075    CONSTRAINT uq_holdings_user_crypto UNIQUE (user_id, crypto_id)
    7176);
    … …  
    184189       c.symbol,
    185190       h.quantity,
     191       h.reserved_quantity,
     192       (h.quantity - h.reserved_quantity) AS available_quantity,
    186193       h.avg_price,
    187194       lp.price                           AS current_price,
    … …  
    192199LEFT   JOIN project.markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD'
    193200LEFT   JOIN project.v_latest_prices lp ON lp.market_id = m.id;
     201
     202-- ============================================================================
     203-- REPORTS (P6 — Complex DB Reports)
     204-- Both are single SELECT statements (with CTEs), wrapped as SQL functions so
     205-- they can be called as parameterised reports from the prototype instead of
     206-- being copy-pasted SQL text. See docs/P6-AdvancedReports/AdvancedReports.md.
     207-- ============================================================================
     208
     209-- report_top_traders: realized trading performance per user over [p_from, p_to),
     210-- bucketed into quarters to measure how consistently each user was profitable.
     211CREATE OR REPLACE FUNCTION project.report_top_traders(p_from timestamptz, p_to timestamptz)
     212RETURNS TABLE (
     213    username            varchar,
     214    realized_pl         numeric,
     215    total_invested      numeric,
     216    roi_pct             numeric,
     217    profitable_periods  bigint,
     218    losing_periods      bigint,
     219    total_periods       bigint,
     220    consistency_pct     numeric
     221)
     222LANGUAGE sql STABLE AS $$
     223    WITH period_pl AS (
     224        SELECT
     225            t.user_id,
     226            date_trunc('quarter', t.created_at)          AS period,
     227            SUM(t.amount)                                AS period_pl,
     228            SUM(t.amount) FILTER (WHERE t.type = 'buy')  AS period_buy
     229        FROM project.transactions t
     230        WHERE t.type IN ('buy', 'sell', 'fee')
     231          AND t.created_at >= p_from
     232          AND t.created_at <  p_to
     233        GROUP BY t.user_id, date_trunc('quarter', t.created_at)
     234    )
     235    SELECT
     236        u.username,
     237        SUM(pp.period_pl)                                                       AS realized_pl,
     238        ABS(SUM(pp.period_buy))                                                 AS total_invested,
     239        ROUND(SUM(pp.period_pl) / NULLIF(ABS(SUM(pp.period_buy)), 0) * 100, 2)  AS roi_pct,
     240        COUNT(*) FILTER (WHERE pp.period_pl > 0)                                AS profitable_periods,
     241        COUNT(*) FILTER (WHERE pp.period_pl < 0)                                AS losing_periods,
     242        COUNT(*)                                                                AS total_periods,
     243        ROUND(COUNT(*) FILTER (WHERE pp.period_pl > 0)::numeric
     244              / NULLIF(COUNT(*), 0) * 100, 2)                                   AS consistency_pct
     245    FROM period_pl pp
     246    JOIN project.users u ON u.id = pp.user_id
     247    GROUP BY u.id, u.username
     248    ORDER BY realized_pl DESC;
     249$$;
     250
     251-- report_market_performance: trading activity and price behaviour per market
     252-- over [p_from, p_to). Volume/trade-count/price stats come from market_trades
     253-- (the complete tape — user fills and simulated fills alike); participating
     254-- users can only come from orders, since market_trades has no user_id column.
     255CREATE OR REPLACE FUNCTION project.report_market_performance(p_from timestamptz, p_to timestamptz)
     256RETURNS TABLE (
     257    symbol               varchar,
     258    quote_currency       char(3),
     259    total_volume         numeric,
     260    trade_count          bigint,
     261    avg_price            numeric,
     262    market_return_pct    numeric,
     263    price_volatility     numeric,
     264    participating_users  bigint
     265)
     266LANGUAGE sql STABLE AS $$
     267    WITH trades AS (
     268        SELECT
     269            market_id, price, quantity, executed_at,
     270            FIRST_VALUE(price) OVER w AS first_price,
     271            LAST_VALUE(price)  OVER (PARTITION BY market_id ORDER BY executed_at
     272                                      ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_price
     273        FROM project.market_trades
     274        WHERE executed_at >= p_from AND executed_at < p_to
     275        WINDOW w AS (PARTITION BY market_id ORDER BY executed_at)
     276    ),
     277    market_stats AS (
     278        SELECT
     279            market_id,
     280            SUM(quantity)    AS total_volume,
     281            COUNT(*)         AS trade_count,
     282            AVG(price)       AS avg_price,
     283            STDDEV(price)    AS price_volatility,
     284            MAX(first_price) AS first_price,
     285            MAX(last_price)  AS last_price
     286        FROM trades
     287        GROUP BY market_id
     288    ),
     289    participation AS (
     290        SELECT market_id, COUNT(DISTINCT user_id) AS participating_users
     291        FROM project.orders
     292        WHERE status = 'executed' AND executed_at >= p_from AND executed_at < p_to
     293        GROUP BY market_id
     294    )
     295    SELECT
     296        c.symbol,
     297        m.quote_currency,
     298        ms.total_volume,
     299        ms.trade_count,
     300        ROUND(ms.avg_price, 6)                                                            AS avg_price,
     301        ROUND((ms.last_price - ms.first_price) / NULLIF(ms.first_price, 0) * 100, 2)      AS market_return_pct,
     302        ROUND(COALESCE(ms.price_volatility, 0), 6)                                        AS price_volatility,
     303        COALESCE(p.participating_users, 0)                                                AS participating_users
     304    FROM market_stats ms
     305    JOIN project.markets m ON m.id = ms.market_id
     306    JOIN project.crypto  c ON c.id = m.crypto_id
     307    LEFT JOIN participation p ON p.market_id = ms.market_id
     308    ORDER BY ms.total_volume DESC;
     309$$;
  • server/portfolio.go

    rdf05838 r9577c79  
    33import (
    44        "fmt"
     5        "strings"
    56
    67        "bp_project/server/db"
    … …  
    1314                `SELECT symbol,
    1415                        quantity,
     16                        COALESCE(reserved_quantity, 0),
     17                        COALESCE(available_quantity, quantity),
    1518                        COALESCE(avg_price, 0),
    1619                        COALESCE(current_price, 0),
    … …  
    2831        defer rows.Close()
    2932
     33        header := fmt.Sprintf("  %-8s  %12s  %12s  %12s  %14s  %14s  %14s  %14s",
     34                "Symbol", "Quantity", "Reserved", "Available", "Avg buy", "Current", "Value", "Unrealised P/L")
    3035        fmt.Println()
    31         fmt.Printf("  %-8s  %12s  %14s  %14s  %14s  %14s\n",
    32                 "Symbol", "Quantity", "Avg buy", "Current", "Value", "Unrealised P/L")
    33         fmt.Println("  ------------------------------------------------------------------------------------")
     36        fmt.Println(header)
     37        fmt.Println("  " + strings.Repeat("-", len(header)-2))
    3438
    3539        var totalValue, totalPnL float64
    … …  
    3741        for rows.Next() {
    3842                var sym string
    39                 var qty, avg, cur, val, pnl float64
    40                 if err := rows.Scan(&sym, &qty, &avg, &cur, &val, &pnl); err != nil {
     43                var qty, reserved, avail, avg, cur, val, pnl float64
     44                if err := rows.Scan(&sym, &qty, &reserved, &avail, &avg, &cur, &val, &pnl); err != nil {
    4145                        fmt.Println("scan error:", err)
    4246                        return
    4347                }
    44                 fmt.Printf("  %-8s  %12.4f  %14.6f  %14.6f  %14.4f  %+14.4f\n",
    45                         sym, qty, avg, cur, val, pnl)
     48                fmt.Printf("  %-8s  %12.4f  %12.4f  %12.4f  %14.6f  %14.6f  %14.4f  %+14.4f\n",
     49                        sym, qty, reserved, avail, avg, cur, val, pnl)
    4650                totalValue += val
    4751                totalPnL += pnl
    … …  
    5256                return
    5357        }
    54         fmt.Println("  ------------------------------------------------------------------------------------")
    55         fmt.Printf("  %-8s  %12s  %14s  %14s  %14.4f  %+14.4f\n",
    56                 "TOTAL", "", "", "", totalValue, totalPnL)
     58        fmt.Println("  " + strings.Repeat("-", len(header)-2))
     59        fmt.Printf("  %-8s  %12s  %12s  %12s  %14s  %14s  %14.4f  %+14.4f\n",
     60                "TOTAL", "", "", "", "", "", totalValue, totalPnL)
    5761
    5862        // cash summary
  • server/trade.go

    rdf05838 r9577c79  
    1313// Runs inside a single database transaction so the orders, holdings,
    1414// users.balance and transactions tables always agree.
     15//
     16// The order still passes through 'open' before 'executed'. Placing it
     17// reserves whatever it commits — on a sell, the crypto being sold, tracked in
     18// holdings.reserved_quantity — before anything is actually moved, so a
     19// second order against the same holding can never be granted the same units
     20// twice. Because only market orders are implemented, reserve and settle
     21// happen inside this one transaction rather than across two commits; a
     22// future limit-order matcher would split them into a second transaction
     23// later, without needing a schema change.
    1524func PlaceOrder(s *Session, side string) {
    1625        if side != "buy" && side != "sell" {
    … …  
    4756        defer tx.Rollback()
    4857
    49         // 1. create the order (status='executed' since we fill immediately)
     58        // 1. record the order as 'open' — no trade has happened yet.
    5059        var orderID string
    5160        err = tx.QueryRow(
    52                 `INSERT INTO orders (user_id, market_id, side, type, status, quantity, price, executed_at)
    53                  VALUES ($1, $2, $3, 'market', 'executed', $4, $5, now())
     61                `INSERT INTO orders (user_id, market_id, side, type, status, quantity, price)
     62                 VALUES ($1, $2, $3, 'market', 'open', $4, $5)
    5463                 RETURNING id`,
    5564                s.UserID, m.ID, side, qty, price,
    … …  
    8796                }
    8897
    89                 // upsert holding with running weighted average
     98                // a buy never reserves crypto, only ever adds it — upsert holding
     99                // with running weighted average
    90100                if err := upsertHoldingOnBuy(tx, s.UserID, m.CryptoID, qty, price); err != nil {
    91101                        fmt.Println("Error updating holding:", err)
    … …  
    104114                }
    105115        } else {
    106                 // sell: check holding
    107                 var held, avgPrice float64
     116                // sell: lock the holding and check what is actually free to sell —
     117                // quantity minus whatever another open order has already reserved.
     118                var held, reserved, avgPrice float64
    108119                err := tx.QueryRow(
    109                         `SELECT quantity, avg_price FROM holdings
     120                        `SELECT quantity, reserved_quantity, avg_price FROM holdings
    110121                          WHERE user_id = $1 AND crypto_id = $2 FOR UPDATE`,
    111122                        s.UserID, m.CryptoID,
    112                 ).Scan(&held, &avgPrice)
     123                ).Scan(&held, &reserved, &avgPrice)
    113124                if err != nil && err != sql.ErrNoRows {
    114125                        fmt.Println("Error:", err)
    115126                        return
    116127                }
    117                 if err == sql.ErrNoRows || held < qty {
    118                         fmt.Printf("Insufficient holding: trying to sell %.4f, hold %.4f\n", qty, held)
    119                         return
    120                 }
    121 
    122                 // reduce holding
     128                available := held - reserved
     129                if err == sql.ErrNoRows || available < qty {
     130                        fmt.Printf("Insufficient holding: trying to sell %.4f, available %.4f (of %.4f held, %.4f reserved)\n",
     131                                qty, available, held, reserved)
     132                        return
     133                }
     134
     135                // reserve: committed to this order, not yet removed from the position.
    123136                if _, err := tx.Exec(
    124137                        `UPDATE holdings
    125                             SET quantity   = quantity - $1,
    126                                 updated_at = now()
     138                            SET reserved_quantity = reserved_quantity + $1,
     139                                updated_at        = now()
     140                          WHERE user_id = $2 AND crypto_id = $3`,
     141                        qty, s.UserID, m.CryptoID,
     142                ); err != nil {
     143                        fmt.Println("Error:", err)
     144                        return
     145                }
     146
     147                // settle: a market order fills immediately, so release the
     148                // reservation and remove the asset from the position in one step.
     149                if _, err := tx.Exec(
     150                        `UPDATE holdings
     151                            SET quantity          = quantity - $1,
     152                                reserved_quantity = reserved_quantity - $1,
     153                                updated_at        = now()
    127154                          WHERE user_id = $2 AND crypto_id = $3`,
    128155                        qty, s.UserID, m.CryptoID,
    … …  
    163190                 VALUES ($1, now(), $2, $3, $4, 'user')`,
    164191                m.ID, price, qty, side,
     192        ); err != nil {
     193                fmt.Println("Error:", err)
     194                return
     195        }
     196
     197        // settle the order itself: it has now actually been filled.
     198        if _, err := tx.Exec(
     199                `UPDATE orders SET status = 'executed', executed_at = now() WHERE id = $1`,
     200                orderID,
    165201        ); err != nil {
    166202                fmt.Println("Error:", err)
Note: See TracChangeset for help on using the changeset viewer.