d58b7919

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 three parts

### 1. 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.
- 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.

### 2. 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.

### 3. Follow-ups from review (`5d4ef7a`..`76dfe8d`)

**The Swiss reports combine lots instead of rejecting them.** They have one line
per currency, so two lots of one symbol are one line whose amount is their sum.
`combineLots` in `Centjes.Switzerland.Report.Common` folds a `Commodity`-keyed
balance down by currency; `CommonSpec` has a per-currency property plus a fixed
case for exact cancellation, because `MultiAccount` drops a key whose balance
reaches zero and random amounts never cancel. This replaces the two
lot-rejection errors that had no fixture.

**`Module.AssertionEquals` gained `CommodityExpression`.** It held a currency
symbol and a `Maybe` cost expression side by side, so nothing tied the lot to
the symbol it was a lot of. It is now one value, mirroring `Commodity`.

**`PriceOrigin` is gone.** It tagged each price as declared / from a posting /
made up to hold a lot together, and exactly one of the three was ever read, in
one filter. The tag restated what `priceCommodity` already said — a price is the
synthetic one-to-one edge exactly when its from-side is a `CommodityLot`,
because a price declaration has no lot syntax and a lot posting carries no cost.
Nothing kept the two in agreement and the generator wrote them independently, so
a generated ledger could claim a plain currency was a lot edge and have the
register drop a real price. The filter now matches on the commodity.

**A zero-amount lot posting no longer declares a price.** The acquisition test
was `Account.Positive _`, which matches `Positive (Amount 0)`, so a posting of no
shares at all counted as a trade and its rate went into the price graph. With a
later timestamp it then beat the rate a real purchase had set:
`balance/balanced/lot-zero.cent` valued one share at 475.00 CHF instead of
95.00.

## Tried against a real ledger

Ran `nix flake check --override-input centjes …` in a real private ledger, both
against this branch and against a worktree at `origin/master`. Output is
byte-identical: one failing attribute, `vat-2026-Q3`, "Missing VAT posting" on a
transaction that is missing it in the ledger data. **Not a regression** — it
fails the same way without this branch. Everything else passes: both taxes
packets, the other three VAT quarters, `centjes-check`, and `centjes format`
over the whole ledger.

Separately, expressing 35 previously-commented-out lots (CSPX, DH2O, SWDA) as
real lot postings in that ledger left the CHF-converted balance **byte-identical
across all 117 lines**, which is the valuation claim holding on real data rather
than on a fixture.

## Known gaps

- 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.
- The Swiss reports do not run `checkEvaluatedLedgerAssertions`, so combining
  means a negative lot offset by a positive one passes `requireAssetPositive`
  there. `centjes check` still catches it. Not a regression: master was
  per-currency too.
- An assertion-only currency is still reported unused by `checkCurrencyUsage`,
  since `transactionCurrencySymbols` walks only postings. Pre-existing, but lots
  make it easier to hit.
- Out of scope by design: automatic gain computation, lot selection strategies
  (FIFO, average cost), a `--merge-lots` display flag, and date- or
  label-identified lots.

## Checks

`nix flake check` passes. All five suites pass.

Suite timing

Time to Start Worker time Duration Time to finish Idle
Config 0s 3s 3s 3s 0s
Eval 3s 8s 5s 8s 0s
Build - - - - -
Suite 0s 11s 8s 8s 0s