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

  1. awpr commented on Sep 14, 2026

    @awpr
    Author

    I recognize this is a really long issue description, and it ultimately covers several interrelated but separable behavior changes. It's currently one big issue because I think the overall picture is clearer when all the parts are in one place. I would hope to discuss here first, and then I'm happy to create sub-issues or separate issues for any parts that are agreed upon.

  2. simonmichael commented on Sep 14, 2026

    @simonmichael
    Member

    Thanks! I will study this.

    some hledger docs have suggested accounting-equation-breakage is only a nuisance to be ignored

    I hope they don't say it that strongly! I do think it could be a wasteful distraction for new users who just need basic accounting. But of course a person who wants to see it working and check it, should be able to. Related: https://hledger.org/balancing-the-accounting-equation.html

  3. simonmichael commented on Sep 14, 2026

    @simonmichael
    Member

    PS yes this is really huge. Any chance you could condense it ? TLDR ?

  4. simonmichael commented on Sep 14, 2026

    @simonmichael
    Member

    PPS I know it's a complex topic so I appreciate the in depth discussion and will read it. I guess the TLDR might be: "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."

  5. awpr commented on Sep 14, 2026

    @awpr
    Author

    I hope they don't say it that strongly! I do think it could be a wasteful distraction for new users who just need basic accounting. But of course a person who wants to see it working and check it, should be able to. Related: https://hledger.org/balancing-the-accounting-equation.html

    It looks like the part I was remembering is https://hledger.org/1.52/hledger.html#equity-conversion-postings. I can't quite tell whether it's referring to the benign imbalance of 1 AAPL {$50} != $50 (that one I agree is benign) or the real imbalance caused by pairs of differently-priced commodity postings. I assumed the former, since the example only shows a single transaction which would have only the benign form of the imbalance.

  6. awpr commented on Sep 14, 2026

    @awpr
    Author

    PS yes this is really huge. Any chance you could condense it ? TLDR ?
    PPS I know it's a complex topic so I appreciate the in depth discussion and will read it. I guess the TLDR might be: "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."

    Yes, that's essentially the TLDR; there's another condensed form under the summary header which makes largely the same claim but without mentioning lots.

    What I realized gradually while investigating/writing this is that it's not really about lots; it's about making sure sequences of priced commodity postings don't conjure/annihilate assets out of thin air, and lots just happen to a) encourage priced commodity postings and b) provide automation/checking to make commodity postings' basis more visible.

  7. simonmichael commented on Sep 14, 2026

    @simonmichael
    Member
  8. awpr commented on Sep 14, 2026

    @awpr
    Author

    Sounds related: https://hledger.org/1.52/hledger.html?#equity-conversion-postings

    Definitely related; roughly that says (combined with https://hledger.org/investments.html#recording-capital-gain) that priced commodity postings are the problem and conversion postings are the fix. My take is that there's a more nuanced answer on both sides:

    First, priced commodity postings don't have to be a problem: if they're balanced at cost basis, then they force transactions to account gain coming from somewhere else, and no assets appear out of thin air, only change form between currency and equal-cost assets. Two things to note from that link (which I hadn't re-read in the last few months until now): one, the example ledger transaction is exactly the form I cited in the original issue description above and is balanced iff the priced commodity posting is balanced at cost; and two, the following paragraph recommends fiddling with the price annotation to make hledger balance the priced commodity posting at cost.

    Second, equity conversion postings on their own don't entirely solve the problem, they just change it from "money appeared" into "value accrued to equity bypassing revenue": if the conversion postings use the transacted price, then the value of gain comes out of equity:conversion leaving it with negative currency balance. If the conversion postings use the cost basis instead, then equity:conversion stays neutral (always contains positive and negative assets of equal cost) and any leftover must be attributed to gain/loss. (This also matches what the Tracking Investments link recommends in the context of equity postings).

    It looks to me like both priced commodity postings and equity conversion postings can be considered valid (up to personal preference whether assets are equal to their currency cost), and in order to account gain correctly, both priced commodity postings and equity conversion postings need to use the cost basis rather than transacted price (for their currency aspect, i.e. for balancing priced commodity postings or for the currency side of the conversion postings).

  9. simonmichael commented on Sep 22, 2026

    @simonmichael
    Member

    Hi @awpr - I must admit I still haven't absorbed every word above in full detail, but I think I've understood the main points:

    You're right that the accounting equation is unbalanced by disposals balancing at transacted cost.
    I believe it's because we are not tracking the unrealised gain caused by market price changes; I just haven't implemented that
    (related notes: https://github.com/hledgerorg/hledger/blob/main/doc/PLAN-ugain.md).
    https://hledger.org/dev/hledger.html#gain-postings should mention the accounting equation imbalance, at least.

    What if transaction balancing used the basis cost as you propose ? This would be exactly how we do it in hledger 1.

    Apparently these are two standard accounting conventions, both legitimate:
    transacted-cost balancing (with revaluations) is "mark-to-market accounting", and basis-cost balancing is "historical cost accounting".

    Basis-cost balancing doesn't track the unrealised gain changes explicitly (though they can still be reported from market prices).
    But it seems simpler and more intuitive.
    I thought it would complicate transaction balancing, requiring full knowledge of prior lot history, but we already have that, so the implementation changes seem small.
    For reparseability we just need to make sure the basis cost is made explicit.

    So I think you're right, and this should be changed. I'm looking into it.

  10. simonmichael commented on Sep 22, 2026

    @simonmichael
    Member

    Well the changes weren't so small. But I think it's a net simplification, in addition to being more correct accounting. Actually it's a return to the lots/balancing design of February, now better understood and fleshed out.

    A number of other improvements came with it. In particular, -B/--cost now shows basis cost when known, instead of transacted cost. This helps preserve bse's zero total with -B, eg. -B is a very old flag, but the change only affects lots users. The old behaviour (showing transacted cost always) is now available as --value=transacted.

    Please test: PR #2744

  11. simonmichael commented on Sep 23, 2026

    @simonmichael
    Member

    This was merged into main. I believe it resolves the issue(s), if not we can reopen.

  12. simonmichael commented on Sep 23, 2026

    @simonmichael
    Member

    And, Thank you Andrew!

  13. awpr commented on Sep 28, 2026

    @awpr
    Author

    Thanks for the quick turnaround! I tried this out on the journals I had been using with a vibe-coded version and quickly ran into issues with acquires with fees; but this was my own journals' issue, as they weren't consistent with my own proposed balancing rules, and it was just being ignored because it wasn't checked on acquires. I had something like the following trying to record a notional price alongside a different cost basis, and it doesn't balance in the first at-transacted-cost pass:

    2026-01-01 Buy
      assets:cash  -$151.00
      assets:stocks  1 AAPL {$151.00} @ $150.00
    

    I spent some time thinking about it and it seems like being able to capitalize fees as basis without adjusting the transacted price is a lot more involved1, so for now I'll need to change my CSV rules to omit or adjust the price and re-test; haven't gotten back to doing that yet. Will update when I get the chance, but at first glance the merged version looks sensible to me.

    Footnotes

    1. Blindly applying "balance at transacted cost without gain, then add gain according to basis" here leads to a capital loss of $1, which is sort of half correct, as it's not a realized loss but instead something that should offset a future gain. This looks like a case where I want the asset to sit on the books valued at $150 but with a cost basis of $151, which doesn't fit into the "basis = notional value on the books" model. ↩

  14. simonmichael commented on Sep 28, 2026

    @simonmichael
    Member

    Thanks for testing. I had to look up again how this works. Disposal transactions balance using cost basis now; but all other transactions still balance using transacted cost.

    Also https://hledger.org/dev/hledger.html#cost-basis-vs-transacted-cost recommends keeping cost basis and transacted cost the same in acquisitions, and recording the difference another way (and we have an optional check for this). I'm not sure I understood your use case or the footnote here; also I haven't figured out a good workflow for capitalising acquisition fees (adjusting cost basis to include them). So I'm interested to hear what you think of this.

  15. simonmichael commented on Sep 28, 2026

    @simonmichael
    Member

    Actually, I'm having trouble now finding any valid case where basis and transacted cost should be recorded differently in an acquisition, and I'm starting to think we should not allow it.

    Here are three of the cases mentioned in that doc, and the simple way of recording them that doesn't involve writing B != T:

    commodity AAPL  ; lots:
    
    2026-01-01 gift of 10 AAPL from Mum, carryover basis $50/share
        equity:gifts received     $-500
        assets:stocks              10 AAPL @ $50
    
    2026-02-01 inherited 10 AAPL, basis stepped up to market
        equity:inheritance       $-1500
        assets:stocks              10 AAPL @ $150
    
    2026-03-01 10 RSUs vested at $150, taxed as income
        revenues:compensation    $-1500
        assets:stocks              10 AAPL @ $150
    
  16. simonmichael commented on Sep 29, 2026

    @simonmichael
    Member

    I am convinced, and went ahead with this change. We now require @ and {} to be the same in an acquire, and the rewritten https://hledger.org/dev/hledger.html#acquire explains why.

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