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.

Suite timing

Time to Start Worker time Duration Time to finish Idle
Config 0s 1s 1s 1s 0s
Eval 1s 13s 13s 14s 0s
Build 13s 1m31s 48s 1m02s 0s
Suite 0s 1m46s 1m02s 1m02s 0s

Timeline

0s10s20s30s40s50s1m