SPEC 007 Position Cost Basis Engine - rafaelfqueiroz/allmywallet GitHub Wiki
SPEC-007 — Position & Cost Basis Engine
| Status | Draft |
| PRD source | §6.3, §6.4, §6.8, CR-2–CR-4, DM-4 |
| Depends on | SPEC-006 (ledger) |
| Required by | SPEC-009, SPEC-010, all report specs |
| Milestone | M2 |
Description
The engine that turns the transaction ledger into positions: quantity held, average cost (preço médio), and realized gain — correctly across splits, grupamentos, bonificações, subscriptions and partial sales.
This is where the product's credibility is won or lost. The user's broker shows a preço médio and Receita Federal expects a specific cost basis; if this engine's numbers disagree with either, trust collapses regardless of how good the rest of the product is. The conventions below are chosen to match Brazilian practice, not because they are the only defensible ones.
Nothing here is configurable (SPEC-002, DL-002-03): a runtime toggle between average cost and FIFO would make two users' reports incomparable and one user's own history unreproducible.
Business rules
Average cost
| ID | Rule |
|---|---|
| BR-007-01 | Cost basis is moving weighted average, the Brazilian convention and what Receita Federal expects. |
| BR-007-02 | Buy: new average = (existing qty × existing average + purchase qty × purchase price + fees) ÷ total qty. Fees increase cost basis. |
| BR-007-03 | Sell: quantity decreases; average cost is unchanged. Sales never alter cost basis. |
| BR-007-04 | Split / grupamento: quantity adjusts by the ratio; total cost unchanged; average cost adjusts inversely. |
| BR-007-04a | Where the ratio comes from. A B3 Desdobro row states the shares added and a Grupamento row the resulting quantity, never a ratio. The ratio is derived from the position immediately before the event in replay order and the quantity B3 states, and is applied only when it agrees with B3's published factor for the issuer and date (SPEC-008 BR-008-29). A derivation that disagrees, or has no factor to check against, is not applied: the row stays unclassified with both figures shown. (Amended 2026-09-15, #113 — see DL-007-08.) |
| BR-007-04b | Fraction left by a split, grupamento, conversion or liquidation: the fractional quantity B3 removes (Fração em Ativos) and sells at auction (Leilão de Fração) is a sale at the auction value — quantity decreases, average cost unchanged (BR-007-03), realized gain per BR-007-09. (Amended 2026-09-15, #113 — see DL-007-09.) A fraction B3 removes between a same-date grupamento and desdobramento is sold at the later event's scale — quantity × its ratio, unit price ÷ it — since replay applies both ratio events before the day's sales (BR-007-15); proceeds, cost removed and realized gain are those of the real sequence. (Amended 2026-09-21, #139 — see SPEC-005 DL-005-18.) |
| BR-007-05 | Bonificação: quantity increases; total cost increases by the value B3 attributes to the bonus shares, or zero where none is attributed; average recomputed. |
| BR-007-05a | Fraction left by a bonificação: the fractional quantity B3 removes leaves the position with total cost unchanged (average recomputed on the remaining quantity) and no realized gain; the auction cash is a provento of type leilão de frações (SPEC-014 BR-014-01). (Amended 2026-09-15, #113 — see DL-007-09.) |
| BR-007-05b | Asset conversion: every linked conversion_out leg removes its quantity and proportional average-cost basis without realised gain; the group's conversion_in legs add their stated quantities and exact allocated cost basis. The sum of incoming cost equals the sum removed, cash value is zero, and a one-to-many conversion without explicit allocation weights is not applied. Cash stated alongside a change of instrument makes it a disposal and an acquisition (BR-007-03, BR-007-06), never a conversion. (Added 2026-09-18, #121 — see DL-007-10; a cash component was added and withdrawn on 2026-09-21, #143 — see DL-007-11.) |
| BR-007-05c | Amortization — capital returned: quantity is unchanged and total cost decreases by the principal returned; average recomputed. For a listed asset (a stock's restituição de capital, a FII's amortização) the principal is the whole amount received. For a Tesouro Educa+ or Renda+ title (NTN-B1), whose monthly payments are principal plus yield, it is the remaining total cost ÷ the payments remaining including this one (Educa+ 60, Renda+ 240, monthly from 15 January of the year in the title's name); the rest of the payment is yield. Total cost never goes below zero: an amount above the remaining cost is realized gain (BR-007-09). An amortization applies at the end of its payment date, after that date's purchases and sales: the amount reduces the cost of what is held once it is received, as it does when a sale falls on an earlier day. Payments are counted by month, so one moved to the next business day stays in its month; the last payment returns the whole remaining cost. An amortization on any other asset (another Tesouro title, CDB, LCI, LCA), or on an NTN-B1 title outside its payment schedule, defines no principal and is refused into Needs attention rather than guessed. The last NTN-B1 payment does not close the position: quantity leaves only through a transaction the extract states (redemption or maturity). (Added 2026-09-27, #166 — see DL-007-13.) |
| BR-007-06 | Subscription: treated as a buy at the subscription price. Where B3 states no price, the price is an estimate (SPEC-005 BR-005-20d) and the transaction is marked. A position whose open lot includes an estimated cost is marked as estimated wherever its cost or preço médio is shown; the marker travels with a carried transfer or conversion and resets when the position closes (BR-007-07). A user edit of the price clears it. (Amended 2026-09-21, #144 — see DL-007-12.) |
| BR-007-07 | Position fully closed then reopened: average cost resets — a new lot begins. Residual cost from the closed lot must not carry over. |
| BR-007-08 | Positions are tracked per (user, asset, institution), and aggregated across institutions for portfolio-level views. Which institution that is comes from SPEC-005 BR-005-14's identity rule: two spellings of one broker are one institution and therefore one position. (Amended 2026-09-20, #136.) |
Realized gain
| ID | Rule |
|---|---|
| BR-007-09 | realized gain = (sale price − average cost at sale date) × quantity sold − fees. |
| BR-007-10 | Average-cost basis is used, not FIFO — consistent with BR-007-01 and with Brazilian tax rules. |
| BR-007-11 | Realized gain is computed at the moment of sale using the average cost then in force, and is not retroactively restated by later purchases. |
Precision & rebuildability
| ID | Rule |
|---|---|
| BR-007-12 | Exact decimals throughout: 2 places for currency, 8 for quantities. Never floating point (CR-2). |
| BR-007-13 | Rounding applies only at display time; intermediate calculations retain full precision (CR-3). |
| BR-007-14 | Positions at any historical date are derivable from the ledger alone. A rebuild command exists and is part of the test suite (DM-4). |
| BR-007-15 | Corporate events apply in correct chronological order relative to trades; a split processed out of order silently corrupts every subsequent average. |
| BR-007-16 | Only active transactions participate; unclassified and superseded rows are excluded (SPEC-006, BR-006-03). |
Acceptance Criteria
- Average cost after a sequence of buys matches a hand-computed weighted average to the stored precision.
- A sale leaves average cost unchanged and reduces quantity correctly.
- Buy fees increase the average cost; sale fees reduce realized gain.
- A stock's capital restitution or a FII's amortization lowers total cost by the amount received, leaves quantity unchanged, and books any amount above the remaining cost as realized gain (BR-007-05c).
- An Educa+ or Renda+ payment lowers total cost by remaining cost ÷ payments remaining; after the last payment total cost is exactly zero (BR-007-05c).
- An amortization with no defined principal, or outside an NTN-B1 schedule, is refused, never applied at a guessed amount (BR-007-05c).
- A 1:2 split doubles quantity, halves average cost, and leaves total cost unchanged.
- A grupamento (reverse split) does the inverse.
- A bonificação with attributed value increases quantity and total cost, and recomputes average correctly.
- A bonificação with zero attributed value increases quantity and reduces average cost proportionally.
- A one-to-one asset conversion moves quantity and the source's exact total cost to the target with no cash or realised gain; a weighted one-to-many conversion conserves the same group total exactly, and one without weights is not applied.
- A split or grupamento ratio is derived from the position before the event and B3's stated quantity, applied only when it matches B3's factor; a mismatch or a missing factor leaves the row
unclassifiedwith both figures shown. - A fraction removed after a split or grupamento is a sale at the auction value: average unchanged, realized gain recorded.
- A fraction removed after a bonificação leaves total cost unchanged, records no realized gain, and its auction cash is a leilão de frações provento.
- A subscription is treated as a buy at the subscription price.
- Selling to zero then rebuying resets average cost — no residual carries over.
- Realized gain on a partial sale uses the average cost at sale date and is not restated by a later purchase.
- A corporate event backdated between existing trades reorders correctly and every subsequent average is right.
- Positions are correct per institution and when aggregated across institutions.
-
unclassifiedtransactions are excluded; classifying one changes the position. - Full rebuild from the ledger produces byte-identical positions to the incrementally-maintained state — the primary regression guard.
- Average cost for a real portfolio matches the broker's statement for at least three assets with corporate-event history.
- No intermediate calculation rounds before display.
- A position including an estimated subscription cost reads as estimated wherever its cost or preço médio is shown, through transfers and conversions, until it closes; editing the price clears it. (Added 2026-09-21, #144.)
Out of Scope
- Market valuation and unrealized gain (SPEC-009) — this spec produces cost, that one produces value.
- Fixed income accrual (SPEC-009).
- Time-weighted or money-weighted returns (SPEC-012).
- Tax computation, IR reporting, DARF, or the exempt-sale threshold — realized gain is computed, not taxed.
- Day-trade separation for tax purposes.
- FIFO or specific-lot identification.
- Currency conversion.
- Corporate event data sourcing — events arrive via SPEC-005 or manual entry. The published split factor used to confirm a derived ratio (BR-007-04a) is served by SPEC-008.
Decision Logs
| # | Decision | Alternatives considered | Rationale |
|---|---|---|---|
| DL-007-01 | Moving weighted average cost. | FIFO; LIFO; specific-lot identification | It is the Brazilian convention, what Receita Federal expects for cost basis, and what the user's broker statement shows. Any other choice guarantees the app disagrees with both the broker and the tax return — the fastest way to lose a user's trust. |
| DL-007-02 | Realized gain on average-cost basis, explicitly not FIFO. | FIFO, common in other jurisdictions | Consistency with DL-007-01 and with Brazilian tax rules. FIFO would produce figures contradicting what the user must actually report. |
| DL-007-03 | Sales do not change average cost. | Recompute average on every event | Matches Brazilian convention and broker behaviour. Recomputing on sale would make the figure drift away from the statement the user compares against. |
| DL-007-04 | Average cost resets when a position closes fully. | Preserve historical average across a re-entry | A reopened position is economically a new investment. Carrying forward a stale average from a closed lot produces a preço médio matching nothing — not the broker, not the tax return, not intuition. |
| DL-007-05 | Not configurable (reinforces SPEC-002 DL-002-03). | Let users choose their convention | Two users' reports would become incomparable and a single user's history unreproducible — flip the setting and last year's realized gain changes. A change here is a versioned product decision with a migration. |
| DL-007-06 | Rebuild-equals-incremental asserted in the test suite. | Trust the incremental path; rebuild only for support | The incremental path is where subtle ordering bugs live, and they are invisible until someone reconciles. Asserting the two agree turns a class of silent corruption into a failing test. |
| DL-007-07 | Rounding at display only. | Round at each step to keep stored values tidy | Rounding intermediates accumulates error across hundreds of transactions until the average visibly diverges from the broker's. The stored value must carry full precision. |
| DL-007-08 | A split ratio is derived and confirmed against B3's published factor. (2026-09-15, #113.) | Derive only; B3's factor only; manual ratio only | B3's Movimentação states a quantity, not a ratio. Derivation alone is wrong whenever earlier history is incomplete (a real account's reverse split followed shares still unclassified) and ambiguous for same-day split-and-grupamento pairs. B3's factor alone is keyed by issuer, not ticker, and has no class. Requiring both to agree applies an event only when two independent sources say the same thing, and a disagreement surfaces instead of corrupting every later average (BR-007-15). The classify form's ratio stays the manual fallback. |
| DL-007-09 | A fraction sold at auction is treated by origin. (2026-09-15, #113.) | Always a sale; always income | Brazilian IRPF guidance declares a bonificação fraction's auction proceeds as exempt income without changing preço médio, while a split or grupamento fraction is an alienação that counts towards the monthly exemption and recomputes the average. One rule would disagree with what the user must report for one of the two. |
| DL-007-10 | An asset conversion is a grouped ledger event with exact cost conservation and no realised gain. (2026-09-18, #121.) | Reuse custody transfers; treat the source as a sale and target as a buy; ignore Atualização |
A conversion changes the instrument, not the investment's economic cost. Reusing transfers cannot express one-to-many conversion or bind all legs atomically, while sale/buy invents cash and taxable gain. B3's Atualização is an absolute balance statement with no old code, so it is evidence only; an explicit conversion definition is what makes the cross-asset relationship reviewable instead of guessed. |
| DL-007-11 | A conversion carries no cash; cash stated alongside a change of instrument makes it a disposal. (2026-09-21, #143. This replaces the same day's "cash on a conversion is a return of capital", which was built and then removed without any ledger using it.) | Keep a cash-bearing conversion leg treated as return of capital, unused; treat such cash as income | The only real case, BPFF11/HGFF11 → RVBI11, turned out to be a taxable liquidation (SPEC-005 DL-005-21). A genuine return of capital arrives as its own Restituição de Capital row (amortization), not inside a conversion. A capability with no real case would keep a spec rule describing the wrong treatment, and a looser database invariant (migration 0026, contracted by 0027) that nothing uses. |
| DL-007-12 | An estimated cost is carried and shown, never hidden. (2026-09-21, #144.) | Mark only the transaction; mark nothing | Preço médio is an average, so one estimated lot makes the whole figure an estimate until the position closes; a marker on the transaction alone would let the position look exact. |
| DL-007-13 | An amortization returns capital and reduces cost basis by the principal it returns. (2026-09-27, #166; acknowledged by the owner after research.) | Keep cost basis unchanged (v1); reduce it by the whole cash received for every asset | Receita Federal practice subtracts a capital restitution or FII amortization from the acquisition cost and treats an amount above it as capital gain (MeuIR, Velotax); leaving cost unchanged keeps a preço médio above the declared one. An NTN-B1 payment is principal Preço₀ × 1/n plus yield under Tesouro Nacional's rule (portaria), so subtracting the whole payment would count yield as returned capital; the investor's own cost is amortized on the same 1/n schedule. |