Lot prices
An asset held at a lot price becomes its own commodity.
`2 SWDA lot @ 500 EUR` is an amount of `2` in the commodity *"SWDA acquired at
500 EUR per unit"*, a distinct key in every balance map from plain `SWDA` and
from `SWDA lot @ 480 EUR`.
```centjes
2025-01-27
| Buy two shares
* assets:broker +2 SWDA lot @ 500 EUR
* assets:bank -1000 EUR
2025-04-01
| Sell one at a gain
* assets:broker -1 SWDA lot @ 500 EUR
* assets:bank +600 EUR
* income:capital-gains -100 EUR
+ assert assets:broker = +1 SWDA lot @ 500 EUR
```
A lot balances at its lot rate, exactly like `@` does. It converts one-to-one
into the commodity it is a lot of, so it is worth whatever the underlying is
worth. Disposal is a negative posting in the same lot: it balances at the
*acquisition* rate while the cash side is at the *sale* rate, leaving a residual
for a capital-gains posting to absorb.
## The two behaviours worth checking first
- `balance/balanced/lot-convert.txt` reports **1140.00 CHF** (market: 2 × 600 ×
0.95), not 950.00 (cost basis). If this were 950 the valuation would be
walking the wrong edge.
- `register/valid/lot.txt` shows one `Price: SWDA` row per day, never
`Price: SWDA, SWDA`. The second would mean the one-to-one lot edges are
leaking into revaluations.
## Review in two parts
The first eight commits add lots. The last three are a follow-up refactor that
splits `Currency`, and they are where most of the diff is.
### Lots (`53d1e02`..`43c7fd8`)
Tests come first: `0a26f17` adds `LotSpec` before the parser knows the token, so
it is red on `parse error at token 'TokenVar "lot"'` and green once `1b46bce`
lands. Semantics are pinned at value level there, not only by goldens.
Worth a look:
- `Lot` holds no source locations other than declaration locations. Two mentions
of the same lot must compare equal or balances split silently.
- `PriceOrigin` makes "declared / from a posting / made up to hold a lot
together" typed, which is what lets the register skip the one-to-one edges.
- Capital *losses* go to `expenses:`, not `income:` — a loss is positive there
and would trip the income account-type assertion. Documented in
`syntax.markdown`.
- A currency can no longer be called `lot`. Accepted cost of the keyword.
### Splitting `Currency` (`47d8c3c`..`c3eaa43`)
`Currency` was doing two jobs: a declared currency, and whatever a balance is
denominated in. The lot rode on it as a `Maybe` field, which is why
`declaredCurrency` had to exist.
```haskell
data Commodity ann = CommodityCurrency !(Currency ann) | CommodityLot !(Lot ann)
```
One rule decides all ~240 sites: **conversion targets are `Currency`, balance
keys are `Commodity`.** What now follows from the types rather than a rule:
| | before | after |
|---|---|---|
| convert *into* a lot | possible, nothing caught it | `Cost.costCurrency :: Currency` |
| a lot of a lot | ruled out by storing the basis as a loose symbol + factor | `Lot` holds `Currency` |
| a posting with both a lot and a cost | a validity `declare` | `PostingPrice` has nowhere to put it |
| account currency assertion | `currencyWithoutLot`, a record update that quietly no-ops | `commodityCurrency`, a total projection |
Gone: `declaredCurrency` (12 uses), `currencyWithoutLot` (8), `currencyLot`
(15).
`postingConversion` is now top-level and reachable from a test; it was a
let-binding inside the balancing loop.
**`Ord` stays derived.** If display order ever needs to differ that should be an
explicit sort, not an instance. `lot-ordering.cent` pins what derived `Ord`
produces (plain `ZZZ` before an `AAA` lot) — every other fixture happens to pair
a currency that sorts before the lot's underlying, where both orderings agree,
so nothing caught the difference before.
The Switzerland reports keep their per-currency fields and reject a lot at the
gather step instead. That closes the "assert lot-free" item that was left open
when lots landed. The three direct `MemoisedPriceGraph.lookup` call sites there
are now type-checked rather than found by hand.
## Two things I got wrong along the way
- The design said the balancing engine would not change. It had to: with the
rate living only on the commodity, `postingCost` is `Nothing` for a lot
posting and the balancer had nothing to convert with. Fixed in `b3bdbb3`.
- The hand-computed formatter goldens for lot fixtures were one space short in
the account-name column. The formatter's own alignment is what the passing
non-lot fixtures already showed, so the goldens moved instead.
## Known gaps
- `VATErrorLotInReport` / `TaxesErrorLotInReport` have no fixture. They are only
reachable by posting a lot to a revenue or expense account, and I did not add
a Switzerland scenario for it.
- A negative lot balance reports as `EVAL_ACCOUNT_TYPE_ASSERTION` ("balance must
be positive") pointing at a lot, which reads oddly. A dedicated diagnostic
would be a good follow-up. `balance/error/EVAL_ACCOUNT_TYPE_ASSERTION-lot.cent`
is the case.
- Out of scope by design: automatic gain computation, lot selection strategies
(FIFO, average cost), a `--merge-lots` display flag, date- or
label-identified lots. Each is additive on this.
## Checks
`nix flake check` passes. Test suite: 762 passed, 0 failed, 1 pending.