44497ab7

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.