convert pdf to md, add OVERVIEW and docs

This commit is contained in:
2026-08-17 22:30:18 +00:00
parent 95d5787868
commit 4df62d2609
44 changed files with 3307 additions and 1 deletions

View File

@@ -0,0 +1,96 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/order.rb
---
# Order
A student's intent to buy or sell shares. **Never executes immediately** — it sits
`pending` until a cron job sweeps it up.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**Orders are deferred by design.** Creating one only writes a row; the money and shares
move later, when `OrderExecutionJob` runs — every 15 minutes
(`config/recurring.yml:2-6`). The student therefore trades at whatever
[stock](stock.md)`#price_cents` says **at execution time**, not the price on screen when
they clicked. This is the single most surprising fact about the trading model and the
reason `ExecuteOrder` re-checks funds and shares before committing
(`app/services/execute_order.rb:16-33`).
Because pending orders are just rows, [portfolio](../money/portfolio.md) has to subtract
them from the balance itself (`app/models/portfolio.rb:93-100`) — otherwise a student
could spend the same dollar repeatedly inside the 15-minute window.
**The $1.00 fee is per batch, not per order.** `Order#transaction_fee` returns 0 if the
user already has *any other* pending order (`:146-148`), matching
`TransactionFeeProcessor`, which charges each user once per sweep
(`app/services/transaction_fee_processor.rb:23-31`). So a student placing five orders in
one window pays $1.00 total.
**Validation is heavily conditional** (`:16-26`) — funds are checked on create, and
differently on update; share availability is re-checked only when the share count changes
(`:195-201`), specifically so the `pending → completed` status write does not trip a
spurious error. Read those `on:` and `if:` clauses before adding a validation here.
## Shape
- Table `orders`, `db/schema.rb:132-146`
- `status` — **integer** enum, `pending: 0 / completed: 1 / canceled: 2`, default pending
(`:11`, `db/schema.rb:138`)
- `action` — **string** enum, `"buy" / "sell"`, `null: false` (`:12`, `db/schema.rb:133`).
The two enums use different storage; this is not a mistake to "fix" casually.
- `shares` — `decimal` with no precision (`db/schema.rb:137`). **Fractional shares are
allowed**; only `> 0` is enforced (`:14`).
- `belongs_to :user` (not portfolio); `portfolio_stock` and `portfolio_transaction` are
optional and stay `nil` until execution (`:6-9`)
- Scopes: `buy`, `sell`, `pending`, `completed`, `canceled`, `for_student`, `for_teacher`
(`:32-42`)
- Eight sorting scopes + `SORTING_METHODS` + `apply_sorting` (`:44-99`)
- `cancel!` (`:101-103`), `purchase_cost = price_cents * shares` (`:105-107`)
The model `include ApplicationHelper` (`:4`) purely to call `format_money` inside
validation messages — a view helper reaching into a model.
## Connected to
- **owns:** nothing until executed; then references
[portfolio-stock](portfolio-stock.md) and
[portfolio-transaction](../money/portfolio-transaction.md)
- **owned-by:** [user](../identity/user.md), [stock](stock.md)
- **joins:** —
- **looks-like-but-is-not:** an order is **not** a transaction. The ledger row is created
by `ExecuteOrder` and the order merely points at it. Also `Order#portfolio` is a
delegation through user (`:30`), not an association — you cannot `joins(:portfolio)`.
## If you change this
- **Hits:** [portfolio](../money/portfolio.md)`#cash_balance` — pending buys and the
pending fee are part of the balance formula;
[place-and-execute-order](../../processes/place-and-execute-order.md) and
`ExecuteOrder`; `OrdersController` and `OrderPolicy`; the `order_form` Stimulus
controller; `Order.for_teacher`, which scopes through the **legacy**
`users.classroom_id` (`:40-42`) — teachers will not see orders from students enrolled
only via [classroom-enrollment](../org/classroom-enrollment.md).
- **Does not hit:** [portfolio-snapshot](portfolio-snapshot.md). Snapshots value settled
holdings and cash at month end; a pending order contributes only through the balance
formula and never creates or amends a snapshot.
## Surfaces
| Surface | Role |
|---|---|
| `OrdersController` (`index`, `new`, `create`, `edit`, `update`, `cancel`) | student writes |
| `OrderExecutionJob` → `ExecuteOrder` | reads pending, writes completed/canceled |
| `TransactionFeeProcessor` | reads |
| teacher order list (`for_teacher`) | reads |
## See
- Source: `app/models/order.rb`, `db/schema.rb:132-146`
- As-built: `docs/orders-and-transactions.md`

View File

@@ -0,0 +1,79 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/portfolio_position.rb
---
# PortfolioPosition
A plain Ruby object (**no table**) that aggregates many
[portfolio-stock](portfolio-stock.md) lots into one row per stock — what a student sees as
"my holdings".
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Holdings cannot be read directly because lots are append-only and sells are negative
(see [portfolio-stock](portfolio-stock.md)). `PortfolioPosition.for_portfolio` does the
collapsing in **one SQL query** rather than in Ruby (`app/models/portfolio_position.rb:24-40`):
it groups by `stocks.id`, filters `HAVING SUM(portfolio_stocks.shares) > 0` (`:29`) so
fully-sold stocks disappear, and computes gain/loss in the `SELECT` (`:30-38`).
The query starts from `Stock`, not from `Portfolio` — so each result is a **`Stock`
instance decorated with extra columns** (`total_shares`, `aggregated_change_amount`,
`aggregated_total_return`), which `build_position` then wraps (`:42-52`). That is why
the `stock:` passed in is already carrying aggregate data.
**`total_return_amount` is not a return.** The SQL behind it is
`(stocks.price_cents / 100.0) * SUM(shares)` (`:36`) — that is the position's *current
market value*, with no cost subtracted. The genuine gain/loss is `change_amount`, which
does subtract the basis (`:34-35`). Do not present `total_return_amount` as profit.
## Shape
- PORO; no table, no Active Record (`:3`)
- `attr_reader :stock, :shares, :portfolio, :change_amount, :total_return_amount` (`:4`)
- `initialize(stock:, shares:, portfolio: nil, financial_data: {})` (`:8-14`)
- `current_value` — dollars (`:16-18`); `current_value_cents` — cents (`:20-22`)
- `self.for_portfolio(portfolio)` returns an **Array**, not a relation (`:24-40`)
- `build_position` is `private_class_method` (`:54`)
- Delegates `current_price`, `price_cents`, `ticker` to the stock with a `stock_` prefix
(`:6`)
`current_value_cents` multiplies `shares * stock_price_cents` where `shares` is a decimal
from SQL — it returns a `BigDecimal`, not an `Integer`, unlike every other `_cents`
reader in the app.
## Connected to
- **owns:** —
- **owned-by:** [portfolio](../money/portfolio.md), which exposes it as `#positions`
(`app/models/portfolio.rb:28-30`)
- **joins:** reads [portfolio-stock](portfolio-stock.md) and [stock](stock.md)
- **looks-like-but-is-not:** not an Active Record model — no `where`, no `find`, and
`for_portfolio` cannot be chained. Not [portfolio-stock](portfolio-stock.md) either:
one position spans many lots.
## If you change this
- **Hits:** the portfolio holdings table in `app/views/portfolios/`;
`PortfoliosController#show`; anything reading `Portfolio#positions`.
- **Does not hit:** [portfolio](../money/portfolio.md)`#holdings_value_cents`. That is a
**separate** SQL sum (`app/models/portfolio.rb:48-52`) which does **not** apply the
`HAVING SUM(shares) > 0` filter. The two can disagree — a stock with a net-zero or
negative lot sum is excluded from positions but still counted in holdings value.
Changing one does not change the other.
## Surfaces
| Surface | Role |
|---|---|
| `PortfoliosController#show` → holdings table | read |
| `Portfolio#positions` | read |
## See
- Source: `app/models/portfolio_position.rb`

View File

@@ -0,0 +1,79 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/portfolio_snapshot.rb
---
# PortfolioSnapshot
One portfolio's total worth on one date. **The only persisted history in the app** —
everything else about a portfolio is recomputed on every read.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
[portfolio](../money/portfolio.md) stores nothing, so "what was this student worth in
March?" is unanswerable from the ledger alone — reconstructing it would need historical
stock prices, which the app also does not keep ([stock](stock.md) holds only today and
yesterday). Snapshots exist to close that gap, and they are **write-once history**:
delete a row and that month is gone permanently.
Written only by `MonthlyPortfolioSnapshotJob` on the **last day of each month at 23:00**
(`config/recurring.yml:15-19`, cron `0 23 L * *`). Worth is taken from
`Portfolio#calculate_total_value_cents` — cash plus holdings at that moment
(`app/jobs/monthly_portfolio_snapshot_job.rb:24-29`).
**Re-running is safe.** The unique index on `[portfolio_id, date]`
(`db/schema.rb:154`), the model validation (`app/models/portfolio_snapshot.rb:8`), and
the job's own `exists?` guard (`app/jobs/monthly_portfolio_snapshot_job.rb:22`) all say
the same thing three times. The job also swallows `RecordInvalid` per portfolio and logs
it (`app/jobs/monthly_portfolio_snapshot_job.rb:30-31`), so one bad portfolio cannot abort the run.
Every portfolio is snapshotted, including empty ones — there is no skip for zero worth.
## Shape
- Table `portfolio_snapshots`, `db/schema.rb:148-156`
- `date` — a `date`, `null: false` (`db/schema.rb:150`)
- `worth_cents` — integer, `null: false`, validated `>= 0` (`db/schema.rb:153`,
`app/models/portfolio_snapshot.rb:7`)
- `belongs_to :portfolio` (`:4`)
- `current_worth` returns **dollars** (`:10-12`)
- Batched at 1,000 portfolios per pass (`app/jobs/monthly_portfolio_snapshot_job.rb:6,12`)
`worth_cents` cannot be negative, but a portfolio with an overdrawn cash balance would
compute one — that snapshot fails validation, gets logged, and is skipped.
## Connected to
- **owns:** —
- **owned-by:** [portfolio](../money/portfolio.md)
- **joins:** —
- **looks-like-but-is-not:** not a transaction and not an audit log. A snapshot records a
*total*, never a movement, and nothing reconciles it against
[portfolio-transaction](../money/portfolio-transaction.md).
## If you change this
- **Hits:** `Portfolio#chart_data`, which takes the **last 12** snapshots ordered by date
(`app/models/portfolio.rb:54-63`) — so the student chart shows at most a year;
the `portfolio_chart` Stimulus controller and the Chart.js view;
[snapshot-portfolio-worth](../../processes/snapshot-portfolio-worth.md).
- **Does not hit:** any balance or holding. Snapshots are pure output — nothing in the
app reads a snapshot back to compute current worth, so a wrong or missing snapshot
distorts the chart and nothing else.
## Surfaces
| Surface | Role |
|---|---|
| `MonthlyPortfolioSnapshotJob` | writes (the only writer) |
| `Portfolio#chart_data` → portfolio chart | reads |
## See
- Source: `app/models/portfolio_snapshot.rb`, `db/schema.rb:148-156`
- Schedule: `config/recurring.yml:15-19`, `docs/scheduling.md`

View File

@@ -0,0 +1,81 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/portfolio_stock.rb
---
# PortfolioStock
One **lot** — a single executed buy or sell. Not "the shares a student owns": holdings are
the *sum* of these rows.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**The table is append-only and sells are stored as negative shares.** `ExecuteOrder`
creates a new row per execution, negating the quantity for a sell
(`app/services/execute_order.rb:53-58`). Nothing ever updates or deletes a lot. So a
student who bought 10 and sold 4 has two rows, `+10` and `-4`, and owns 6 — which is why
`Portfolio#shares_owned` is a `SUM` (`app/models/portfolio.rb:24-26`) and
[portfolio-position](portfolio-position.md) filters `HAVING SUM(shares) > 0`
(`app/models/portfolio_position.rb:29`). Treating one row as a holding will be wrong for
anyone who has ever sold.
The model itself carries a one-line warning to this effect (`app/models/portfolio_stock.rb:3`).
**`purchase_price` is in dollars, not cents.** It is written as `stock.current_price`
(`app/services/execute_order.rb:57`), which already divides by 100
(`app/models/stock.rb:19-21`), into a `decimal(15,2)` column (`db/schema.rb:161`) —
while [stock](stock.md)`#price_cents` beside it is an integer in cents. Any query joining
the two must convert, and `PortfolioPosition`'s SQL does exactly that:
`(stocks.price_cents / 100.0) * SUM(shares) - SUM(purchase_price * shares)`
(`app/models/portfolio_position.rb:34-36`).
On a sell, the lot records the **sale** price in `purchase_price` — the column name lies
for negative rows.
## Shape
- Table `portfolio_stocks`, `db/schema.rb:158-168`
- `shares` — `decimal(15,2)`, may be negative (`db/schema.rb:162`)
- `purchase_price` — `decimal(15,2)`, **dollars** (`db/schema.rb:161`)
- `belongs_to :portfolio`, `belongs_to :stock` (`:5-6`) — no validations, no callbacks
- Composite index on `[portfolio_id, stock_id]` (`db/schema.rb:165`)
- No `order_id`. The link runs the other way:
[order](order.md)`#portfolio_stock_id` points here (`db/schema.rb:135`)
## Connected to
- **owns:** —
- **owned-by:** [portfolio](../money/portfolio.md), [stock](stock.md)
- **joins:** portfolio ↔ stock, once per execution
- **looks-like-but-is-not:** not a position. [portfolio-position](portfolio-position.md)
is the aggregate; this is one lot. Also not a ledger entry — cash lives in
[portfolio-transaction](../money/portfolio-transaction.md).
## If you change this
- **Hits:** [portfolio](../money/portfolio.md)`#shares_owned` and
`#holdings_value_cents` (`app/models/portfolio.rb:48-52`);
[portfolio-position](portfolio-position.md), whose entire query is over this table;
[order](order.md) sell validation, which calls `shares_owned`
(`app/models/order.rb:124-132`);
[portfolio-snapshot](portfolio-snapshot.md) values, computed from holdings.
- **Does not hit:** a student's cash. Shares and cash are written together by
`ExecuteOrder` but stored apart — adding or removing a lot changes holdings and total
worth, and leaves `cash_balance` untouched.
## Surfaces
| Surface | Role |
|---|---|
| `ExecuteOrder` | writes (the only writer) |
| `PortfolioPosition`, `Portfolio` | read |
| `MonthlyPortfolioSnapshotJob` | reads |
## See
- Source: `app/models/portfolio_stock.rb`, `db/schema.rb:158-168`

View File

@@ -0,0 +1,95 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/stock.rb
---
# Stock
A real, tradeable ticker with a cached price. The catalogue students buy from — curated
by admins, priced nightly by Alpha Vantage.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**Prices are cached columns, not live lookups.** `price_cents` and
`yesterday_price_cents` are plain nullable integers (`db/schema.rb:352,358`) refreshed by
[refresh-market-data](../../processes/refresh-market-data.md). Every valuation in the app
reads these columns, so the whole portfolio is priced as of the last successful job run.
No request ever calls the API.
**`price_cents` is nullable, and nothing defaults it.** A stock created by an admin
without a price has `price_cents = nil` until the nightly job runs.
`Stock#current_price` copes (`nil.to_f / 100 == 0.0`, `:19-21`), but
`Order#purchase_cost` does `stock.price_cents * shares`
(`app/models/order.rb:105-107`) and raises `NoMethodError` on `nil`. Creating a stock and
trading it the same day is the way to hit this.
**Archived means unbuyable, not untradeable.** `prevent_archived_stock_purchase` is
guarded by `if: -> { buy? }` (`app/models/order.rb:25,189-193`), so students can still
**sell** an archived holding — deliberate, since archiving must not trap anyone's money.
Deletion is blocked outright: both associations are `dependent: :restrict_with_error`
(`:4-5`). Archive is the only retirement path.
**Two writers disagree about the analyst columns.** Admins may set all twenty-odd fields
(`app/controllers/admin/stocks_controller.rb:80-104`), but the weekly
`StockAttributeUpdate` overwrites only six — `company_name`, `description`,
`stock_exchange`, `industry`, `company_website`, `profit_margin`
(`app/services/stock_attribute_update.rb:62-72`). Hand-edit one of those six and the
Saturday job will silently revert it. The rest (`debt`, `cash_flow`, `debt_to_equity`,
`sales_growth`, `employees`, `management`, `competitor_names`, the three `industry_avg_*`)
are admin-only and never auto-updated.
## Shape
- Table `stocks`, `db/schema.rb:335-360`; `ticker` uniquely indexed (`db/schema.rb:359`)
- `validates :ticker, presence: true` (`:7`) — the column itself is nullable
- `company_website` must be a valid http/https URL, blank allowed (`:8-14`)
- `archived` boolean, default false, `null: false` (`db/schema.rb:336`)
- `last_trading_day` date — the freshness gate the price job compares against
- Scopes `active` / `archived` (`:16-17`)
- Readers in **dollars**: `current_price` (`:19`), `yesterday_price` (`:23`),
`percentage_change` (`:29`), `percentage_change_formatted` (`:35`)
- `yesterday_price` falls back to `current_price` when null, so day-one change is 0%
(`:23-27,29-33`)
## Connected to
- **owns:** —
- **owned-by:** —
- **joins:** [portfolio](../money/portfolio.md), through
[portfolio-stock](portfolio-stock.md); [order](order.md)
- **looks-like-but-is-not:** `price_cents` is the *cached* price, not a market price at
order time. An order placed at 9am executes at whatever `price_cents` says when the job
runs — see [place-and-execute-order](../../processes/place-and-execute-order.md).
## If you change this
- **Hits:** [order](order.md) — `purchase_cost`, all funds validations, and four sorting
scopes join this table (`app/models/order.rb:54-73`);
[portfolio](../money/portfolio.md)`#holdings_value_cents`, which multiplies
`price_cents` in SQL (`app/models/portfolio.rb:48-52`);
[portfolio-position](portfolio-position.md), whose gain/loss maths is raw SQL over
`stocks.price_cents` (`app/models/portfolio_position.rb:30-38`);
`ApplicationController#set_navbar_stocks`, which loads active stocks on **every**
request (`app/controllers/application_controller.rb:22-24`).
- **Does not hit:** [portfolio-transaction](../money/portfolio-transaction.md). Ledger
rows store the cents paid at execution time and never re-read the stock — a price
change never rewrites history, it only re-values current holdings.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::StocksController` | admin CRUD (all columns) |
| `StocksController` (`index`, `show`) | student/teacher read |
| `StockPricesUpdateJob` | writes prices nightly |
| `StockAttributeUpdateJob` | writes six attributes weekly |
| every layout, via `@navbar_stocks` | reads |
## See
- Source: `app/models/stock.rb`, `db/schema.rb:335-360`