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.