Changeset 9577c79
- Timestamp:
- 09/16/26 23:37:15 (13 days ago)
- Branches:
- main
- Children:
- 8b447ef
- Parents:
- df05838
- Files:
-
- 2 added
- 24 edited
-
docs/P1-ConceptualModel/ERModel.md (modified) (15 diffs)
-
docs/P1-ConceptualModel/ERModelAIUsage.md (modified) (4 diffs)
-
docs/P1-ConceptualModel/ERModel_v03.png (added)
-
docs/P1-ConceptualModel/ERModel_v03.xml (added)
-
docs/P1-ConceptualModel/P1.zip (modified) ( previous)
-
docs/P2-RelationalDesign/P2.zip (modified) ( previous)
-
docs/P2-RelationalDesign/RelationalDesign.md (modified) (5 diffs)
-
docs/P2-RelationalDesign/RelationalDesignAIUsage.md (modified) (1 diff)
-
docs/P3-UseCaseModel/P3.zip (modified) ( previous)
-
docs/P3-UseCaseModel/UseCase0004.md (modified) (4 diffs)
-
docs/P3-UseCaseModel/UseCase0005.md (modified) (3 diffs)
-
docs/P3-UseCaseModel/UseCase0006.md (modified) (3 diffs)
-
docs/P3-UseCaseModel/UseCaseModel.md (modified) (3 diffs)
-
docs/P3-UseCaseModel/UseCaseModelAIUsage.md (modified) (1 diff)
-
docs/P4-Prototype/BuildInstructions.md (modified) (4 diffs)
-
docs/P4-Prototype/P4.zip (modified) ( previous)
-
docs/P4-Prototype/PrototypeImplementation.md (modified) (4 diffs)
-
docs/P4-Prototype/PrototypeImplementationAIUsage.md (modified) (2 diffs)
-
docs/P4-Prototype/UseCase0004Implementation.md (modified) (5 diffs)
-
docs/P4-Prototype/UseCase0005Implementation.md (modified) (4 diffs)
-
docs/P4-Prototype/UseCase0006Implementation.md (modified) (3 diffs)
-
docs/README.md (modified) (5 diffs)
-
server/cli.go (modified) (2 diffs)
-
server/db/schema_creation.sql (modified) (3 diffs)
-
server/portfolio.go (modified) (5 diffs)
-
server/trade.go (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
docs/P1-ConceptualModel/ERModel.md
rdf05838 r9577c79 1 # Entity-Relationship Model v.0 21 # Entity-Relationship Model v.03 2 2 3 3 ## Diagram 4 4 5  6 5  7 6 8 7 Notation: Chen. Rectangles are entity sets, diamonds are relationships, ellipses … … 23 22 ## Data requirements 24 23 24 Each entity set is given as a short rationale for why it exists as its own set, 25 its keys, and its attributes as a table. Each relationship is given as its 26 cardinality and participation, a short rationale, and — where it carries data — 27 an attribute table. 28 25 29 ### Entity sets 26 30 … … 28 32 Registered participants of the platform. Every action in the simulation is 29 33 attributed 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). 34 simulation work: cash that is free to trade is tracked separately from cash 35 that is currently committed to open positions, so the platform can refuse a 36 purchase without having to recompute the whole portfolio first. 37 38 **Keys:** candidates `{id}`, `{username}`, `{email}`; primary key **`id`**. A 39 surrogate 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 41 every relationship in the diagram points at `Users`, so a mutable key would 42 propagate 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) | 50 55 51 56 #### Cryptos … … 55 60 in the *asset*, not in a particular pair. 56 61 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 63 reason as in `Users`. `symbol` is kept as a unique natural key because that is 64 what 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 | 65 72 66 73 #### Markets … … 71 78 trades, candles and orders all reference the pair, not the asset. 72 79 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 81 unique by definition, since a given asset can only be quoted once per 82 currency; primary key **`id`**, so that the many entity sets referencing a 83 market 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 | 83 91 84 92 #### Orders … … 88 96 makes the ledger auditable. 89 97 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. 98 Placing an order is what triggers a **reservation** of whatever it commits — 99 the crypto being sold (`Holds.reserved_quantity`, below) on a sell, cash 100 already handled the same way on a buy via `available_balance` / 101 `invested_balance`. `status` therefore has real meaning as a lifecycle, not 102 just a label: `open` means reserved but not yet settled, `executed` means 103 settled, `cancelled` would release the reservation without settling (not yet 104 exercised by any use case, since only market orders — which settle 105 immediately — are implemented). See 106 [UseCase0005](../P3-UseCaseModel/UseCase0005.md) for the reserve-then-settle 107 sequence. 108 109 **Keys:** candidate `{id}` only — there is no natural key, since the same user 110 can place two identical orders on the same market in the same second, and both 111 are 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 | 105 123 106 124 #### Transactions … … 110 128 the project needs. 111 129 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 | 121 140 122 141 #### MarketTrades … … 126 145 writes directly. 127 146 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 148 principle, but two trades can share a timestamp, so it is not a safe key; 149 primary key **`id`** (a plain auto-incrementing integer here rather than a 150 UUID, because this is the highest-volume entity set and it is only ever read 151 in 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`) | 141 161 142 162 #### MarketCandles … … 146 166 screen refresh does not scale. 147 167 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 169 has exactly one candle per timeframe per time bucket; primary key **`id`**, the 170 composite is enforced as a uniqueness rule because it is the real-world 171 constraint 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 | 158 180 159 181 #### Watchlists … … 162 184 several lists ("long term", "watching today") and each needs its own name. 163 185 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 187 were required to be unique per user, which the model does not impose, so it is 188 not 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 | 171 195 172 196 ### Relationships … … 175 199 Ties a market to the asset it trades. One asset can be quoted in many markets; 176 200 every market must have exactly one asset, hence total participation on the 177 `Markets` side. No attributes of its own.201 `Markets` side. No attributes. 178 202 179 203 #### PlacedOn — Markets (1) : Orders (N), total on Orders … … 182 206 183 207 #### Places — Users (1) : Orders (N), total on Orders 184 Records who placed an order. Every order belongs to exactly one user; a new user185 has no orders. No attributes.208 Records who placed an order. Every order belongs to exactly one user; a new 209 user has no orders. No attributes. 186 210 187 211 #### 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.212 Attributes each ledger entry to a user. Every entry belongs to exactly one 213 user. No attributes. 190 214 191 215 #### 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. 216 Links a ledger entry to the order that caused it. Partial on the 217 `Transactions` side because deposits have no originating order, and partial on 218 the `Orders` side because an order that never executes never produces a 219 ledger entry — which is why the corresponding column is nullable in P2. No 220 attributes. 196 221 197 222 #### Fills — Markets (1) : MarketTrades (N), total on MarketTrades … … 211 236 entirely described by *which user*, *which asset*, and how much. 212 237 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`: 239 two independently updated stored numbers, with the amount actually free to use 240 computed on demand rather than stored (`quantity − reserved_quantity` here, 241 `available_balance` alone on the cash side). Without it, nothing stopped a 242 user from placing a second sell order against crypto already promised to a 243 first one — `quantity` alone cannot tell "owned" apart from "owned, but 244 already 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 | 221 253 222 254 #### Contains — Watchlists (M) : Cryptos (N), partial on both sides, **with attribute** … … 225 257 asset need not be on any list. 226 258 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 | 230 262 231 263 ## Entity-Relationship Model History … … 243 275 3. `avg_price` was marked as a derived attribute rather than a plain one, to 244 276 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. 245 292 246 293 Reasoning for the AI-assisted part of this phase, and the full interaction log, 247 294 are 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 34 34 by reading it back through TerraER's own reader and comparing the figure count 35 35 (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. 36 diagram. `ERModel_v02.xml` is the student's own review pass over v01, done by 37 hand in the GUI. 38 39 `ERModel_v03.xml` / `ERModel_v03.png` (session 3, 2026-09-16) were produced the 40 same way, this time as a genuine load–modify–save round trip through TerraER's 41 own classes rather than a from-scratch build: `ERModel_v02.xml` was read with 42 the application's real `DrawFigureFactory` and `DOMStorableInputOutputFormat` 43 into a live `QuadTreeDrawing`, one `AtributoFigure` was cloned from the 44 existing `quantity` attribute of `Holds` (to inherit its exact styling) and 45 relabelled `reserved_quantity`, a matching `LabeledLineConnectionFigure` was 46 added 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 49 attributes, moved by the same offset — was translated proportionally toward 50 the diagram's centroid to close up excess canvas space, after which every 51 connection figure had `updateConnection()` called so its drawn path follows 52 the moved figures. The result was written with the real writer and rendered to 53 PNG with TerraER's own `ImageOutputFormat`, and re-verified by reading 54 `ERModel_v03.xml` back and confirming the figure count (146 = 144 + the new 55 attribute + its line) and that all five attributes of `Holds` resolve with the 56 expected connector classes. No figure was hand-edited in XML. 38 57 39 58 ### Model description … … 43 62 ## Summary of AI involvement 44 63 45 Work on this project happened in t wo 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 | 64 Work 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 | 53 72 54 73 In **session 1** I brought my own data model (`ep-diagram.md`, written in … … 65 84 for a review pass over the database and Go code, which turned up three further 66 85 bugs (see [PrototypeImplementationAIUsage](../P4-Prototype/PrototypeImplementationAIUsage.md)). 86 87 In **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 89 already promised to a first, unsettled one — and asked the AI to fix it 90 starting 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 92 it; see the model history on 93 [ERModel](ERModel.md#entity-relationship-model-history) (v03) and the log 94 below. 67 95 68 96 Below is the log, one entry per exchange. My prompts are quoted as **excerpts**: … … 176 204 > any further prompts. The complete transcript is in your Claude Code session 177 205 > 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 212 model had no way to represent crypto committed to an order that had not yet 213 settled — 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 261 than 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 263 fixed). 264 265 **Follow-up, same day:** I asked for `ERModel_v03.xml`/`.png` after all, 266 having noticed the PNG still showed v02 with no `reserved_quantity` on it, and 267 asked at the same time for the diagram to be a little more compact — it had a 268 lot 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 271 under [Diagram](#diagram) above, rather than hand-editing the XML or asking me 272 to 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 275 black background instead of white, unlike v01/v02. Cause: TerraER's 276 `ImageOutputFormat` defaults to an ARGB image and paints its background with 277 zero alpha (transparent), not opaque white; whatever displayed the PNG then 278 flattened that transparency onto black instead of white. Fixed by exporting 279 through the same `ImageOutputFormat.toImage(...)` call for figure geometry, 280 but compositing its result onto an explicitly white-filled opaque `RGB` image 281 before saving, rather than trusting the library's own (transparent) output. 282 Verified the fix by reading back the corner pixel of the written PNG as pure 283 white `(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 11 11 - **Markets**(<u>**id**</u>, *crypto_id*, quote_currency, is_active, created_at) 12 12 - 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) 14 14 - Transformation of the M:N relationship `Holds`. Candidate keys: `{id}` and 15 15 `{user_id, crypto_id}` — the latter is the relationship's own key and is … … 17 17 consistency with the other relations. 18 18 - `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`. 19 27 - **Orders**(<u>**id**</u>, *user_id*, *market_id*, side, type, status, quantity, price, placed_at, executed_at) 20 28 - `side ∈ {buy, sell}`, `type ∈ {market, limit}`, `status ∈ {open, executed, cancelled}`. … … 56 64 ### Normalisation 57 65 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 58 76 All relations are in **3NF**: 59 77 … … 72 90 yields `NULL`, so a nullable average would have silently blanked the 73 91 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 101 checked against what a user actually has *free* to sell 102 (`quantity - reserved_quantity`), not against the raw `quantity`, which also 103 counts crypto already promised to another order that has not settled yet. 104 `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` makes an 105 inconsistent reservation impossible at the database level, regardless of what 106 application code does. The exact statement sequence — lock the row, check the 107 available 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` 110 on the buy path is what makes two concurrent sell orders against the same 111 holding serialize correctly instead of racing. 74 112 75 113 ## DDL script … … 80 118 - 10 tables with check constraints, primary keys, foreign keys and unique constraints. 81 119 - 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`). 83 121 84 122 ## DML script (sample data) -
docs/P2-RelationalDesign/RelationalDesignAIUsage.md
rdf05838 r9577c79 69 69 > against the faculty database. No AI involvement is possible there — it needs a 70 70 > live connection to your assigned database. 71 72 ### Session 3 — 2026-09-16 73 74 Driven by the same design review logged in full in 75 [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16): 76 a sell order had nothing to check `holdings.quantity` against except itself, 77 so nothing stopped two sell orders from being granted the same units. 78 79 Changes 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 93 Re-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 95 reproduce the exact scenario that motivated the change — see 96 [UseCase0005Implementation](../P4-Prototype/UseCase0005Implementation.md) for 97 the 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 101 already applied to `avg_price NOT NULL` in session 2. -
docs/P3-UseCaseModel/UseCase0004.md
rdf05838 r9577c79 37 37 BEGIN; 38 38 39 -- (a) record intent — no trade has happened yet. 39 40 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) 41 42 VALUES 42 ($user_id, $market_id, 'buy', 'market', ' executed', $qty, $price, now())43 ($user_id, $market_id, 'buy', 'market', 'open', $qty, $price) 43 44 RETURNING id; -- captured as $order_id 44 45 46 -- (b) lock and check the user balance 45 47 SELECT available_balance FROM project.users WHERE id = $user_id FOR UPDATE; 46 48 -- abort if available_balance < notional 47 49 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. 48 53 UPDATE project.users 49 54 SET available_balance = available_balance - $notional, … … 52 57 WHERE id = $user_id; 53 58 54 -- Upsert holding with running weighted-average price:59 -- (d) upsert holding with running weighted-average price: 55 60 SELECT quantity, avg_price 56 61 FROM project.holdings … … 61 66 -- new_avg = (old_qty*old_avg + $qty*$price) / (old_qty + $qty) 62 67 68 -- (e) ledger entry 63 69 INSERT INTO project.transactions 64 70 (user_id, type, amount, currency, related_order, description) … … 66 72 ($user_id, 'buy', -$notional, 'USD', $order_id, 'Market buy ...'); 67 73 74 -- (f) record the resulting market trade 68 75 INSERT INTO project.market_trades 69 76 (market_id, executed_at, price, quantity, side, source) 70 77 VALUES 71 78 ($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; 72 84 73 85 COMMIT; -
docs/P3-UseCaseModel/UseCase0005.md
rdf05838 r9577c79 6 6 7 7 A 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 11 The crypto being sold is **reserved** (`holdings.reserved_quantity`) before it 12 is actually removed from the position, so the check a second sell order makes 13 is always against what is truly still free (`quantity - reserved_quantity`), 14 not against the raw `quantity`, which would also count crypto already 15 promised to this order. Because only market orders are implemented, an order 16 settles in the same database transaction it is placed in, so reserve and 17 settle below are two statements inside one commit rather than two separate 18 ones — the existing all-or-nothing guarantee (see 19 [PrototypeImplementation](../P4-Prototype/PrototypeImplementation.md)) is 20 kept. They stay logically distinct so that a future limit-order matcher — 21 where an order really would sit `open` for a while before a *later* 22 transaction settles it — needs only a second transaction where today there is 23 one, not a schema change. 8 24 9 25 ## Scenario … … 18 34 BEGIN; 19 35 36 -- (a) record intent — no trade has happened yet. 20 37 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) 22 39 VALUES 23 ($user_id, $market_id, 'sell', 'market', ' executed', $qty, $price, now())40 ($user_id, $market_id, 'sell', 'market', 'open', $qty, $price) 24 41 RETURNING id; -- $order_id 25 42 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 27 45 FROM project.holdings 28 46 WHERE user_id = $user_id AND crypto_id = $crypto_id 29 47 FOR UPDATE; 30 -- abort if row missing or quantity < $qty 48 -- available := quantity - reserved_quantity 49 -- abort if row missing or available < $qty 31 50 ``` 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 52 6. If the check passes, system reserves the crypto, then — since this is a market order — settles it immediately, all inside the same transaction: 33 53 34 54 ```sql 55 -- (c) reserve: committed to this order, not yet removed from the position. 35 56 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() 38 66 WHERE user_id = $user_id AND crypto_id = $crypto_id; 39 67 … … 54 82 ($market_id, now(), $price, $qty, 'sell', 'user'); 55 83 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 56 89 COMMIT; 57 90 ``` 91 58 92 7. System confirms: `Order executed: sell 0.5000 ETH @ 3520.000000 (notional 1760.0000 USD)`. 59 93 60 94 ### Alternate flow 5a — insufficient holding 61 95 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." 96 If the holding row is missing, or `quantity - reserved_quantity < $qty`, the 97 entire transaction rolls back — including the `open` order from step 5, which 98 was 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 103 Alice 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 111 If 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, 113 then sees the reduced `quantity` and correctly reports insufficient holding — 114 proven under real concurrency in 115 [UseCase0005Implementation](../P4-Prototype/UseCase0005Implementation.md). 63 116 64 117 ### Realised P/L (post-scenario) -
docs/P3-UseCaseModel/UseCase0006.md
rdf05838 r9577c79 17 17 SELECT symbol, 18 18 quantity, 19 COALESCE(reserved_quantity, 0), 20 COALESCE(available_quantity, quantity), 19 21 COALESCE(avg_price, 0), 20 22 COALESCE(current_price, 0), … … 26 28 ORDER BY symbol; 27 29 ``` 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. 28 34 3. System displays the rows and a computed summary: 29 35 … … 54 60 c.symbol, 55 61 h.quantity, 62 h.reserved_quantity, 63 (h.quantity - h.reserved_quantity) AS available_quantity, 56 64 h.avg_price, 57 65 lp.price AS current_price, -
docs/P3-UseCaseModel/UseCaseModel.md
rdf05838 r9577c79 38 38 * [https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0005.md UC0005] – 39 39 '''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. 41 41 * [https://github.com/StefanTrsunov/bp/blob/main/docs/P3-UseCaseModel/UseCase0006.md UC0006] – 42 42 '''View portfolio and transaction history''' – Trader inspects current holdings, unrealised … … 88 88 ||[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`.|| 89 89 ||[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.|| 91 91 ||[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.|| 92 92 ||[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.|| … … 104 104 kept in one place. Direct links: 105 105 [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]. 107 108 108 109 '''Service:''' Claude Code (Anthropic), https://claude.com/claude-code – Claude subscription, 109 model Claude Opus 4.7 (1M context) .110 model Claude Opus 4.7 (1M context) in sessions 1–2, Claude Sonnet 5 in session 3. 110 111 111 112 '''In short:''' the AI proposed the actor taxonomy and drafted the seven use cases with their SQL 112 113 in session 1. In session 2 the use-case model itself was '''not''' changed – the only work was 113 114 re-executing every scenario, including the failure paths, against a live PostgreSQL 16 database. 115 In session 3, UC0004 and UC0005 were revised to reserve the resource an order commits (crypto on 116 a sell) before settling it, closing a gap where nothing stopped a second sell order from being 117 granted crypto already promised to a first one; see 118 [UseCaseModelAIUsage](UseCaseModelAIUsage.md#session-3--2026-09-16). -
docs/P3-UseCaseModel/UseCaseModelAIUsage.md
rdf05838 r9577c79 55 55 duplicate registration, wrong password). The results are documented per use case 56 56 on the `UseCaseXXXXImplementation` pages. 57 58 ### Session 3 — 2026-09-16 59 60 Driven by the design review logged in full in 61 [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16): 62 placing a sell order checked `holdings.quantity` directly, with no way to 63 record that part of a position was already promised to another, unsettled 64 order. 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 87 Every changed scenario's SQL was re-run against the live database, including a 88 two-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 92 pages rather than a new use case (e.g. "cancel order") — `cancelled` remains 93 an unused status, same as before, since nothing in the prototype produces it 94 and inventing a cancel flow was not what the review asked for. -
docs/P4-Prototype/BuildInstructions.md
rdf05838 r9577c79 106 106 most recent trade (`v_latest_prices`), never from a stored column. 107 107 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 111 for the [top traders](../P6-AdvancedReports/AdvancedReports.md#top-traders-by-realized-performance) 112 or [market performance](../P6-AdvancedReports/AdvancedReports.md#market-performance-leaderboard) 113 reports (menu `[10]`/`[11]`) to show more than a single period. To see them do 114 something more interesting, load five quarters of synthetic history on top: 115 116 ```sh 117 psql "postgresql://$DBUSER:$DBPASSWORD@$DBHOST:$DBPORT/$DBNAME" \ 118 -f server/db/reports_demo_data.sql 119 ``` 120 121 It 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 123 running it never changes the balances the smoke test below checks. 124 108 125 ## Testing instructions 109 126 … … 114 131 funds**, **Browse markets**, **Place market BUY order**, **Place market SELL 115 132 order**, **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**. 117 135 118 136 You never have to remember an identifier. Markets are always printed as a … … 122 140 ### End-to-end smoke test 123 141 124 Verified on 2026-0 8-07against PostgreSQL 16 with freshly loaded sample data.142 Verified on 2026-09-16 against PostgreSQL 16 with freshly loaded sample data. 125 143 Expected values are exact. 126 144 127 145 1. `./eduberza -init` — prints `Database initialised.` 128 146 2. `./eduberza`, then `[2] Login` → `alice` / `test123` → `Login successful.` 129 3. `[6] View portfolio` → one row: `ETH 0.5000` at avg 3500.000000, current130 3520.000000, value 1760.0000, unrealised P/L `+10.0000`. Cash available131 8250.0000, net worth 10010.0000.147 3. `[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. 132 150 4. `[4] Place market BUY order` → `BTC` → `0.01` → 133 151 `Order executed: buy 0.0100 BTC @ 67140.000000 (notional 671.4000 USD)`. … … 150 168 to any table — no order row, no ledger entry, no holding. 151 169 - **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. 153 180 - **Duplicate registration:** register with username `alice`. Expect 154 181 `Username or email already taken.` -
docs/P4-Prototype/PrototypeImplementation.md
rdf05838 r9577c79 46 46 `INSERT … ON CONFLICT DO UPDATE` upsert possible, so the weighted-average entry price is 47 47 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. 49 57 * '''No identifiers are ever typed.''' Markets are listed with their prices before any choice is 50 58 made, and everything else is selected by symbol. … … 63 71 * There is no connection pooling configuration and no explicit isolation level; both are P8 64 72 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. 85 78 86 79 == AI usage == … … 96 89 kept in one place. Direct links: 97 90 [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]. 99 93 100 94 '''Service:''' Claude Code (Anthropic), https://claude.com/claude-code – Claude subscription, 101 model Claude Opus 4.7 (1M context) .95 model Claude Opus 4.7 (1M context) in sessions 1–2, Claude Sonnet 5 in session 3. 102 96 103 97 '''In short:''' session 1 rewrote the existing Chi/HTTP backend as the CLI prototype covering … … 106 100 infinite loop at end of input, and an error check in the wrong order that misreported database 107 101 failures as "Insufficient holding" – and replaced the read-modify-write holding update with a 108 single `INSERT … ON CONFLICT DO UPDATE`. 102 single `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 104 could be granted the same units; see 105 [PrototypeImplementationAIUsage](PrototypeImplementationAIUsage.md#session-3--2026-09-16). -
docs/P4-Prototype/PrototypeImplementationAIUsage.md
rdf05838 r9577c79 39 39 ## Summary of AI involvement 40 40 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 | 46 46 47 47 The prototype was built in session 1 and worked. What session 2 added was a … … 116 116 > presentation. You will be asked how the buy transaction works, and the answer 117 117 > has to be yours. 118 119 ### Session 3 — 2026-09-16 120 121 Prompted by a design review I did myself, logged in full in 122 [ERModelAIUsage](../P1-ConceptualModel/ERModelAIUsage.md#session-3--2026-09-16). 123 The report: a user who owns 2 BTC and places a sell order for 0.5 BTC has that 124 crypto immediately removed from `quantity`, but nothing in the model recorded 125 that a *pending* order had already committed part of a position before it 126 settled — `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 133 only because the whole operation — order, holding check, holding update, 134 balance update, ledger, trade — runs inside one transaction with a 135 `SELECT ... FOR UPDATE` lock. It was not safe against the actual scenario 136 described: nothing distinguished "owned" from "owned, but already promised to 137 this order," which matters the moment an order can legitimately sit `open` 138 across more than one transaction — exactly what a limit-order matcher would 139 need, and what `orders.status` already implied was coming. 140 141 **What changed** 142 143 1. `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`. 146 2. `v_portfolio` gained `reserved_quantity` and derived `available_quantity`. 147 3. `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. 154 4. 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. 158 5. `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. 161 6. 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 169 Verified against PostgreSQL 16 (`bp_database`, `localhost:5433`): 170 171 ``` 172 $ eduberza sell 0.5 BTC (Alice: 2.0000 BTC held, 0.0000 reserved) 173 Order 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) 177 Insufficient 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;): 185 before: quantity 2.0000, reserved_quantity 0.0000, available 2.0000 186 after reserve: quantity 2.0000, reserved_quantity 0.5000, available 1.5000 187 after settle: quantity 1.5000, reserved_quantity 0.0000, available 1.5000 188 189 # The CHECK constraint holds even without going through trade.go: 190 UPDATE holdings SET reserved_quantity = quantity + 1 WHERE ...; 191 ERROR: new row for relation "holdings" violates check constraint "holdings_check" 192 ``` 193 194 Full transcripts are on 195 [UseCase0005Implementation](UseCase0005Implementation.md). The rejected-order 196 rollback guarantee from session 2 was re-checked too: after both the 197 insufficient-funds and insufficient-holding failure paths, the affected user 198 still has zero new rows in `orders`. 199 200 **What I decided:** to keep reserve and settle inside a single transaction — 201 splitting them into two, so an order genuinely sits `open` and reserved 202 between two commits, is what a real limit-order matcher will eventually need, 203 but building that now would add a way for an order to get stuck without also 204 building 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 206 manually. -
docs/P4-Prototype/UseCase0004Implementation.md
rdf05838 r9577c79 27 27 BEGIN; 28 28 29 -- (a) record the order 29 -- (a) record the order as 'open' — no trade has happened yet 30 30 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) 32 32 VALUES 33 ($1, $2, 'buy', 'market', ' executed', $3, $4, now())33 ($1, $2, 'buy', 'market', 'open', $3, $4) 34 34 RETURNING id; 35 35 … … 37 37 SELECT available_balance FROM users WHERE id = $1 FOR UPDATE; 38 38 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. 40 43 UPDATE users 41 44 SET available_balance = available_balance - $notional, … … 47 50 -- in one statement. Every SET expression sees the pre-update row, so 48 51 -- holdings.quantity below is still the old quantity. 52 -- reserved_quantity is untouched by a buy and defaults to 0. 49 53 INSERT INTO holdings (user_id, crypto_id, quantity, avg_price, updated_at) 50 54 VALUES ($1, $c, $3, $4, now()) … … 68 72 ($2, now(), $4, $3, 'buy', 'user'); 69 73 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 70 77 COMMIT; 71 78 ``` … … 77 84 ## Verified run (from actual prototype execution) 78 85 79 With seed data loaded:86 Re-run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`) with freshly loaded seed data: 80 87 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 }. 82 89 - **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. 84 91 85 92 ## Failure path — insufficient funds 86 93 87 If `available_balance < notional`, the `defer tx.Rollback()` in `server/trade.go` reverts all six statementsand the user sees:94 If `available_balance < notional`, the `defer tx.Rollback()` in `server/trade.go` reverts every statement above and the user sees: 88 95 89 96 ``` -
docs/P4-Prototype/UseCase0005Implementation.md
rdf05838 r9577c79 2 2 3 3 **Initiating actor:** Trader. **Source file:** `server/trade.go`, function `PlaceOrder(s, "sell")`. 4 5 ## The bug this closes 6 7 Before this change, `holdings` had `quantity` and `avg_price` only. The sell 8 path checked `held < qty` straight against `quantity`, which cannot tell 9 "owned" apart from "owned, but already committed to another order that has 10 not settled." `holdings.reserved_quantity` fixes that: the crypto being sold 11 is reserved before it is removed from the position, and the check is against 12 `quantity - reserved_quantity`. 4 13 5 14 ## Scenario (implemented) … … 13 22 BEGIN; 14 23 24 -- (a) record the order as 'open' — no trade has happened yet 15 25 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) 17 27 VALUES 18 ($1, $2, 'sell', 'market', ' executed', $3, $4, now())28 ($1, $2, 'sell', 'market', 'open', $3, $4) 19 29 RETURNING id; 20 30 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 22 33 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 25 38 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() 27 48 WHERE user_id = $1 AND crypto_id = $c; 28 49 … … 43 64 ($2, now(), $price, $qty, 'sell', 'user'); 44 65 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 45 69 COMMIT; 46 70 ``` … … 52 76 ## Failure path — insufficient holding 53 77 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 ``` 78 If 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 ``` 81 Insufficient 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 86 Run 2026-09-16 against PostgreSQL 16 (`bp_database` on `localhost:5433`). 87 Alice's ETH/BTC holdings were seeded, then her BTC holding was set to exactly 88 the 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 ``` 115 Order 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 126 designed. 127 128 ## Verified run — reserve and settle as two distinct, observable steps 129 130 The 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 132 hand in one `psql` session (one transaction, so the session sees its own 133 uncommitted writes) to show the intermediate state that step (c) alone would 134 leave, before step (d) runs: 135 136 ```sql 137 BEGIN; 138 139 -- before: Alice owns 2 BTC, none reserved 140 SELECT 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 147 UPDATE holdings SET reserved_quantity = reserved_quantity + 0.5, updated_at = now() 148 WHERE user_id = '<alice>' AND crypto_id = '<btc>'; 149 150 SELECT 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 157 UPDATE 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 160 SELECT 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 166 COMMIT; 167 ``` 168 169 This is the row that would stay visible to every other connection for as long 170 as the order stayed `open` — i.e. for as long as it took a matcher to fill 171 it, once limit orders exist. 172 173 ## Verified run — two concurrent sells, which is the bug itself 174 175 The scenario the design review described: a user should not be able to place 176 two sell orders whose combined quantity exceeds what they actually hold. With 177 Alice's BTC holding at 1.5 BTC (0 reserved), two independent CLI processes 178 were started at the same instant, each selling `1.0 BTC` — together 2.0 BTC, 179 more 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 === 187 Insufficient holding: trying to sell 1.0000, available 0.5000 (of 0.5000 held, 0.0000 reserved) 188 === B === 189 Order 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 197 One 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 199 1.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 201 there 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 207 UPDATE holdings SET reserved_quantity = quantity + 1 WHERE user_id = '<alice>' AND crypto_id = '<btc>'; 208 209 ERROR: 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 214 database level, independent of `trade.go`. -
docs/P4-Prototype/UseCase0006Implementation.md
rdf05838 r9577c79 12 12 ```sql 13 13 SELECT symbol, quantity, 14 COALESCE(reserved_quantity, 0), 15 COALESCE(available_quantity, quantity), 14 16 COALESCE(avg_price, 0), 15 17 COALESCE(current_price, 0), … … 30 32 ### Verified run 31 33 32 With the seed data (`data_load.sql`), immediately after login, alice's portfolio prints: 34 Re-run 2026-09-16 against PostgreSQL 16 with the seed data (`data_load.sql`). 35 Immediately after login, alice's portfolio prints (now with the 36 `Reserved`/`Available` columns from `holdings.reserved_quantity`): 33 37 34 38 ``` 35 Symbol Quantity Avg buy Current Value Unrealised P/L36 ------------------------------------------------------------------------------------ 37 ETH 0.5000 3500.000000 3520.000000 1760.0000 +10.000038 ------------------------------------------------------------------------------------ 39 TOTAL 1760.0000 +10.000039 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 40 44 41 45 Cash available : 8250.0000 USD … … 43 47 Net worth : 10010.0000 USD 44 48 ``` 49 50 `Reserved` is 0.0000 here because nothing is mid-sell; see 51 [UseCase0005Implementation](UseCase0005Implementation.md) for a portfolio 52 snapshot taken with crypto actually reserved. 45 53 46 54 ### Transaction history -
docs/README.md
rdf05838 r9577c79 13 13 ## Team members 14 14 15 - *Your First Name Last Name — Index XXXXXX*15 - Stefan Trsunov 231285 16 16 17 17 ## Course … … 41 41 | `P3-UseCaseModel/` | P3 | `UseCaseModel`, `UseCase0001`–`UseCase0007`, `UseCaseModelAIUsage` | 42 42 | `P4-Prototype/` | P4 | `PrototypeImplementation`, `UseCase000XImplementation`, `BuildInstructions`, `PrototypeImplementationAIUsage` | 43 | `P5-Normalization/` | P5 | `Normalization`, `NormalizationAIUsage` | 44 | `P6-AdvancedReports/` | P6 | `AdvancedReports`, `AdvancedReportsAIUsage` | 43 45 44 46 `Instructions.md` is the condensed course rubric — reference material, not a … … 61 63 | P3 | [UseCaseModel](P3-UseCaseModel/UseCaseModel.md) | Finished, awaiting approval | 62 64 | 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 | 65 67 | P7 | *Advanced Database Development* | Not started | 66 68 | P8 | *Advanced Application Development* | Not started | … … 77 79 | File | Phase | What | 78 80 |------|-------|------| 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 | 81 85 | [`ERModel_v01.xml`](P1-ConceptualModel/ERModel_v01.xml) | P1 | TerraER source, first version (kept per P1 rules) | 82 86 | [`ERModel_v01.png`](P1-ConceptualModel/ERModel_v01.png) | P1 | Exported diagram image, first version | … … 84 88 | [`../server/db/data_load.sql`](../server/db/data_load.sql) | P2 | DML — truncates and reloads sample data | 85 89 | [`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`) | 86 91 87 92 ## Use cases (P3) -
server/cli.go
rdf05838 r9577c79 76 76 fmt.Println("[8] Manage watchlist") 77 77 fmt.Println("[9] Logout") 78 fmt.Println("[10] Report: top traders") 79 fmt.Println("[11] Report: market performance") 78 80 fmt.Println("[0] Exit") 79 81 switch prompt("> ") { … … 94 96 case "8": 95 97 ManageWatchlist(s) 98 case "10": 99 ShowTopTraders(s) 100 case "11": 101 ShowMarketPerformance(s) 96 102 case "9": 97 103 s.UserID = "" -
server/db/schema_creation.sql
rdf05838 r9577c79 59 59 -- ============================================================================ 60 60 CREATE 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), 65 70 -- Weighted-average entry price. NOT NULL so that the P/L arithmetic in 66 71 -- 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, 70 75 CONSTRAINT uq_holdings_user_crypto UNIQUE (user_id, crypto_id) 71 76 ); … … 184 189 c.symbol, 185 190 h.quantity, 191 h.reserved_quantity, 192 (h.quantity - h.reserved_quantity) AS available_quantity, 186 193 h.avg_price, 187 194 lp.price AS current_price, … … 192 199 LEFT JOIN project.markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD' 193 200 LEFT 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. 211 CREATE OR REPLACE FUNCTION project.report_top_traders(p_from timestamptz, p_to timestamptz) 212 RETURNS 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 ) 222 LANGUAGE 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. 255 CREATE OR REPLACE FUNCTION project.report_market_performance(p_from timestamptz, p_to timestamptz) 256 RETURNS 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 ) 266 LANGUAGE 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 3 3 import ( 4 4 "fmt" 5 "strings" 5 6 6 7 "bp_project/server/db" … … 13 14 `SELECT symbol, 14 15 quantity, 16 COALESCE(reserved_quantity, 0), 17 COALESCE(available_quantity, quantity), 15 18 COALESCE(avg_price, 0), 16 19 COALESCE(current_price, 0), … … 28 31 defer rows.Close() 29 32 33 header := fmt.Sprintf(" %-8s %12s %12s %12s %14s %14s %14s %14s", 34 "Symbol", "Quantity", "Reserved", "Available", "Avg buy", "Current", "Value", "Unrealised P/L") 30 35 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)) 34 38 35 39 var totalValue, totalPnL float64 … … 37 41 for rows.Next() { 38 42 var sym string 39 var qty, avg, cur, val, pnl float6440 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 { 41 45 fmt.Println("scan error:", err) 42 46 return 43 47 } 44 fmt.Printf(" %-8s %12.4f %1 4.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) 46 50 totalValue += val 47 51 totalPnL += pnl … … 52 56 return 53 57 } 54 fmt.Println(" ------------------------------------------------------------------------------------")55 fmt.Printf(" %-8s %12s %1 4s %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) 57 61 58 62 // cash summary -
server/trade.go
rdf05838 r9577c79 13 13 // Runs inside a single database transaction so the orders, holdings, 14 14 // 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. 15 24 func PlaceOrder(s *Session, side string) { 16 25 if side != "buy" && side != "sell" { … … 47 56 defer tx.Rollback() 48 57 49 // 1. create the order (status='executed' since we fill immediately)58 // 1. record the order as 'open' — no trade has happened yet. 50 59 var orderID string 51 60 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) 54 63 RETURNING id`, 55 64 s.UserID, m.ID, side, qty, price, … … 87 96 } 88 97 89 // upsert holding with running weighted average 98 // a buy never reserves crypto, only ever adds it — upsert holding 99 // with running weighted average 90 100 if err := upsertHoldingOnBuy(tx, s.UserID, m.CryptoID, qty, price); err != nil { 91 101 fmt.Println("Error updating holding:", err) … … 104 114 } 105 115 } 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 108 119 err := tx.QueryRow( 109 `SELECT quantity, avg_price FROM holdings120 `SELECT quantity, reserved_quantity, avg_price FROM holdings 110 121 WHERE user_id = $1 AND crypto_id = $2 FOR UPDATE`, 111 122 s.UserID, m.CryptoID, 112 ).Scan(&held, & avgPrice)123 ).Scan(&held, &reserved, &avgPrice) 113 124 if err != nil && err != sql.ErrNoRows { 114 125 fmt.Println("Error:", err) 115 126 return 116 127 } 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. 123 136 if _, err := tx.Exec( 124 137 `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() 127 154 WHERE user_id = $2 AND crypto_id = $3`, 128 155 qty, s.UserID, m.CryptoID, … … 163 190 VALUES ($1, now(), $2, $3, $4, 'user')`, 164 191 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, 165 201 ); err != nil { 166 202 fmt.Println("Error:", err)
Note:
See TracChangeset
for help on using the changeset viewer.
