Skip to content

Lot tracking forces breaking the accounting equation / mis-valuing assets. #2731

Description

@awpr

Version: hledger 1.99-g89eede297-20260418, linux-x86_64
OS: Arch Linux 7.1.9-arch1-2
Docs: https://hledger.org/dev/hledger.html#first-lots-example
Example: to follow

Abstract

Lot tracking as currently implemented appears to force the accounting equation to be broken (if any gains or losses are realized). I recognize some hledger docs have suggested accounting-equation-breakage is only a nuisance to be ignored, and one should only look at hledger bs and not hledger bse, but to me it means something is incorrect.

I'll attempt to make the case that lot postings should participate in balancing at basis cost, not at transacted cost, and that this will fix the accounting equation; and moreover that balancing at transacted cost is the root of a lot of difficulties that have been showing up in the lot tracking implementation.

Argument

Running example

Let's use an augmented version of the first example in the lot tracking doc as a working example:

commodity AAPL  ; lots:

2026-01-01 opening
    assets:cash  $500
    equity:opening/closing balances

2026-02-01 buy
    assets:cash     -$500
    assets:stocks      10 AAPL @ $50

P 2026-03-01 AAPL  $70

2026-03-01 sell some
    assets:stocks      -5 AAPL @ $70
    assets:cash      $350
    ; generated:  revenue:gain  -$100
    ; generated:  equity:unrealised-gain  $100

2026-03-01 retain
    revenue:gain  $100
    equity:retained revenue

2026-04-01 sell
    assets:stocks      -5 AAPL @ $70
    assets:cash      $350
    ; generated:  revenue:gain  -$100
    ; generated:  equity:unrealised-gain  $100

2026-04-01 retain
    revenue:gain  $100
    equity:retained revenue

Demo output of hledger -f test.journal bse -p monthly:

Monthly Balance Sheet With Equity 2026-01-31..2026-04-30

                                 || 2026-01-31      2026-02-28     2026-03-31  2026-04-30 
=================================++=======================================================
 Assets                          ||                                                       
---------------------------------++-------------------------------------------------------
 assets:cash                     ||       $500               0           $350        $700 
 assets:stocks                   ||          0         10 AAPL         5 AAPL           0 
---------------------------------++-------------------------------------------------------
                                 ||       $500         10 AAPL   $350, 5 AAPL        $700 
=================================++=======================================================
 Liabilities                     ||                                                       
---------------------------------++-------------------------------------------------------
---------------------------------++-------------------------------------------------------
                                 ||          0               0              0           0 
=================================++=======================================================
 Equity                          ||                                                       
---------------------------------++-------------------------------------------------------
 equity:opening/closing balances ||       $500            $500           $500        $500 
 equity:retained revenue         ||          0               0           $100        $200 
 equity:unrealised-gain          ||          0               0          $-100       $-200 
---------------------------------++-------------------------------------------------------
                                 ||       $500            $500           $500        $500 
=================================++=======================================================
 Net:                            ||          0  $-500, 10 AAPL  $-150, 5 AAPL        $200 

Observe that the net should be zero in 2026-03-31 and 2026-04-30 because all revenues/expenses are swept into equity (hledger inc is truly 0 for all reporting periods). For 2026-03-31, "zero" means -$250, 5 AAPL{$50} (i.e. zero after valuing assets at cost), but instead it's $100 greater. At 2026-04-30, it should say literally 0, and instead it says $200.

A different sort of trouble shows up with hledger -f test.journal -B -p monthly:

Monthly Balance Sheet With Equity 2026-01-31..2026-04-30, converted to cost

                                 || 2026-01-31  2026-02-28  2026-03-31  2026-04-30 
=================================++================================================
 Assets                          ||                                                
---------------------------------++------------------------------------------------
 assets:cash                     ||       $500           0        $350        $700 
 assets:stocks                   ||          0        $500        $150       $-200 
---------------------------------++------------------------------------------------
                                 ||       $500        $500        $500        $500 
=================================++================================================
 Liabilities                     ||                                                
---------------------------------++------------------------------------------------
---------------------------------++------------------------------------------------
                                 ||          0           0           0           0 
=================================++================================================
 Equity                          ||                                                
---------------------------------++------------------------------------------------
 equity:opening/closing balances ||       $500        $500        $500        $500 
 equity:retained revenue         ||          0           0        $100        $200 
 equity:unrealised-gain          ||          0           0       $-100       $-200 
---------------------------------++------------------------------------------------
                                 ||       $500        $500        $500        $500 
=================================++================================================
 Net:                            ||          0           0           0           0 

Now the assets and equity are balanced, but only because they're both wrong: in the last period, assets says we have net $500, but it should be $700; there's a phantom -$200 in the stocks account that's valuing nonexistent assets at negative cost. Something similar is happening at 2026-03-31, but it's less blatant because the real cost $250 plus the error -$100 is still positive.

Balancing at Basis

Here's my take on what went wrong here, first in the "no -B" case.

Using the discipline that commodities are considered equal to their transacted cost, the 2026-02-01 acquire transaction is balanced; the balance sheet is "balanced" afterwards, too: +10 AAPL @ $50 ~= $500. Nothing wrong yet.

The 2026-03-01 transaction is currently considered balanced by valuing the AAPL commodity at its transacted cost at sale, 5*$70=$350. When summing the two transactions, we get dAssets = -$150 + 5 AAPL@$50 ~= $100, dRevenues = -$100, dEquity = $100. The accounting equation here says dAssets + dRevenues + dEquity = 0, but instead it's $100. According to these books, if valuing assets at their transacted cost, $100 appeared out of thin air. This is a bit unclear after selling just half because we're trying to equate shares and dollars, but after selling the rest, it's crystal clear: assets are $700 and equity is only $500.

The lot tracking system attempts to account for the extra dEquity by using the revenues:gain posting to record that the gain came in as revenue. But in order to make the first sale transaction "balanced", it also had to create an unrealised-gain posting. These two postings together effectively do nothing: revenues is just equity under a special name for "recent" negative equity changes, so the unrealised-gain posting offsets the gain posting, and there's no net change to equity+revenues to balance the change to assets. Observe that in the last reporting period, the $200 gain is correctly reported, but there's a spurious -$200 unrealised-gain that's making the net equity incorrect.

Last time I tried using the lot tracking preview a few months ago, it was generating gain postings and giving them special treatment to be ignored in transaction balancing. This was actually better in terms of the accounting equation, but janky conceptually and in implementation, so I understand why it was changed. It was better in that disposals would have the correct net effect on equity+revenues, but at the cost of being forced to accept "unbalanced" transactions (both mentally and in implementation).

So, let's look closer at where the first $100 came from according to this ledger. If we consider commodities to be equal to their transacted price, then the $100 appeared in between the acquire and dispose, when the AAPL shares started being valued at $70. In some sense that's true, as the market price evidently changed between the two transactions; but it's not really compatible with the idea that accounts only change through transactions. Plus, even if we accept that the AAPL holdings increased through extra-transactional means, that increase should still have been balanced by equity+revenue changes, and in this ledger, it wasn't. The source of error here is that the USD value of AAPL as understood by transaction balancing changed when it was sold above basis cost, but the corresponding USD value in equity didn't.

So, what happens if we look at the second transaction as-written (without any gain postings) and try to balance it when valuing AAPL at basis cost instead? Then we have -$250 from the AAPL posting and +$350 from the assets posting. The difference is $100, exactly the gain. In order to be balanced under that methodology, the transaction needs a -$100 revenue posting:

2026-02-01 sell some
    assets:stocks:{2026-01-15, $50}               -5 AAPL @ $70 {$50}
    assets:cash                                 $350
    revenues:gain                              $-100

How to read the above transaction in this "balance at basis cost" model: we sold 5 AAPL, which means converting it back to its corresponding 5 * $50 = $250 of basis; we also received $100 of gain as part of this transaction; and the total proceeds of $350 landed in assets:cash. This is now balanced both locally (inside the transaction) and globally (in the accounting equation): dEquity is now $0 and the sum is 0 as it should be. The source of error in the previous ledger is gone, because the USD value of each share of AAPL in transaction balancing never changes from its cost basis.

With the above balancing tweak, lot tracking still has the opportunity to infer a lot, and it seems to me it requires slightly less special-purpose code to do so. I think the following maximally-stripped-down version of the transaction has enough information to infer everything:

2026-02-01 sell some
    assets:stocks  -5 AAPL @ $70
    assets:cash

First, lot tracking can match the sale to a lot and find that the basis is $50. Then, gain inference can deduce a gain posting of -5 * ($70 - $50) = -$100. Finally, transaction balancing can deduce an amount of $350 for assets:cash from the sum of the other two postings (note the AAPL posting acts as -$250). (Granted I don't know if the phases are in the right order here).

Conveniently, this form of the transaction also works tolerably when balanced without lots under the old "balance at transacted cost" model (it'd still get the right $350 posting to assets:cash, but would bring back the accounting equation problem).

Balancing at Basis in Acquires

To stress-test the idea of valuing commodities at cost basis and inferring postings for the difference, let's see what happens when applying it to acquires, in situations where the basis of newly acquired lots would differ from their transacted cost. First up, option exercise:

2026-01-01 exercise options
  assets:options  -5 AAPL:$40 @ $0 {$10}   ; balances as -$50; gain/loss is $50 (loss)
  assets:stocks    5 AAPL @ $40 {$50}        ; balances as $250; gain/loss is -$50 (gain)
  assets:cash     -$200

This seems to check out; here the total gain posting should be $0, because disposing of the options without receiving USD is a "loss" (net shares * (cost basis - transacted cost) < 0), but receiving a stepped-up basis for the shares is a "gain" (net shares * (cost basis - transacted cost) > 0).

Next up, wash sales:

2026-01-01 wash sale part 1
  assets:stocks  -5 AAPL @ $50 {$100}
  assets:cash    $250
  revenue:gain   $250  ; loss which will be disallowed later

2026-02-01 wash sale part 2
  assets:stocks  5 AAPL @ $60 {$110}  ; disallowed loss becomes basis
  assets:cash   -$300
  revenue:gain  -$250  ; inferred "gain" posting offsets the loss

This also seems to check out: as long as the wash sale acquire posting gets the stepped-up basis, the gain posting derived from the difference between cost basis and transacted cost is exactly offsetting the loss that was disallowed.

Valuing at Cost

The bse -B output for the running example was wrong too (it showed negative value in the stocks account); here's my best guess at what went wrong there.

It looks almost like hledger is reinterpreting transactions in -B mode by replacing commodity postings with their transacted price. If that's the case, it explains what was observed: when we acquired 10 AAPL, that became +$500 into assets:stocks. When we sold 5 @ $70, that became -$350 from assets:stocks; and the same applies to the second sale. Net, that's -$200 to assets:stocks, which is the observed outcome.

Intuitively I'd have expected -B to reinterpret balances after summing transactions in terms of the underlying commodities (although I see why this would be unachievable back when hledger didn't track cost basis). Under that behavior, we'd find that after the first sale we have 5 AAPL at $50 cost, valued at $250, and after the second sale we have 0 AAPL, valued at $0.

If hledger were to treat asset postings as balancing at their cost basis instead of transacted cost, then this would correspond naturally to -B treating asset postings as having the cost basis as their amount, and this would give exactly the correct result: $250 value after the first sale, $0 after the second.

Conversion Postings

Personally I prefer conversion postings over priced commodities, because they make the equity side actually reflect assets rather than costs. Luckily this seems to be reasonably compatible with the idea of permanently valuing commodities at their cost basis: instead of balancing commodities as equal to their cost basis, infer (or write) equity:conversion postings whose amount is derived from their cost basis.

Under this model, instead of transmuting assets back to their USD cost basis at disposal, we just (partially, proportionally) undo the conversion postings from their acquisition. The role of lot tracking in this context would be to guarantee the correct USD amounts are assigned to equity:conversion and to revenues:gain.

In some sense the USD balance of equity:conversion is the aggregate cost basis, in that it's exactly the amount that you're justified in "moving back to assets" on sale without treating it as revenue; without lot tracking, one could claim to take the full transacted price out of equity:conversion, which would sort-of amount to trying to "use up" other shares' cost basis without selling them, and report less ($0) than the true gain. Lot tracking would be responsible for guaranteeing you take the correct amount of basis out of equity:conversion for a given disposal.

The way I'd most prefer to see this turn out is that each lot gets its own (lot-named) conversion account, which at any given moment holds negative the remaining commodities in the lot and positive the remaining basis. No matter where these conversion accounts reside (assets, revenues, equity), the accounting equation is exactly upheld; only the reporting differs. If they're in assets, then net assets are reported as currency at cost basis (because the conversion postings undo the sale postings). If they're in revenues/expenses, then net assets include the commodities, equity remains in currency, and the difference is in the income statement. If they're in equity, then both equity and assets report the commodities.

Making them an additional top-level account hierarchy might also be a good option: then reporting on conversion and sub-accounts tells you about cost basis per lot, the behavior of -B could be achieved by netting them with assets, and commodity-aware reporting could be achieved by netting them with equity.

Without Lot Tracking

Most of what's written above applies even without automated lot tracking: we can have the same problematic journal with the same hledger bse (-B)? results even without lot tracking (except without the net-zero pair of revenue:gain and equity:unrealized-gain postings). The same change of balancing priced commodity postings as their cost basis fixes the problem in the same way without lot tracking; it's just a lot more inconvenient and error-prone (because nothing infers or checks the basis price for you, and nothing infers or checks the relationship between gain amount and price/cost difference for you).

In fact, my pre-lot-tracking CSV rules for a brokerage account effectively use the methodology of balancing priced commodities at their cost basis, just in the form of "conversion postings" instead of priced commodity postings: a sale has conversion postings based on the cost basis, asset postings based on the transacted price, and a gain posting for the difference. The main downsides are that it's less legible (I don't get to see the transacted price per share in the ledger) and less rigorously checked (no lot tracking).

Compatibility

Changing priced commodity postings to balance at cost seems to risk breaking some existing journals. A few issues to call out:

  • Any priced commodity posting without a declared basis might become uninterpretable when lots are not matched to the posting. Alternatively, it could be assumed to have basis = transacted cost, in which case (in the absence of lots) it could break the accounting equation. That's no worse than before!
  • Any priced commodity posting without a declared basis might behave differently in balancing when lots are enabled/disabled. That's somewhat working as intended: if lots tracking automatically generates gain postings, then reports silently become more correct for journals that use transacted prices for commodity sales. The journals whose reports would change were already broken to begin with (had wrong equity+revenue, and had wrong value under -B).
  • Any priced commodity posting with a declared basis would change its behavior in balancing. Do these even exist in the wild for hledger <=1.52? Not sure why anyone would write them when they didn't have any meaning.

Summary

It seems to me that priced commodity postings are broken, and have been for long before lot tracking existed; and that the reason they're broken is that the commodity postings should balance as equal to their cost basis, not their transacted cost. My opinion is that hledger 2 should do this, and I anticipate this would result in more-correct reporting across the board. Optionally inferring conversion postings derived from cost basis would be nice, too.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    investingRelated to investments, lots, capital gains, etc.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions