convert pdf to md, add OVERVIEW and docs
This commit is contained in:
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user