antonta's space
Following patterns wherever they emerge.
main posts

Virtual trading accounts in plain text accounting

Cost basis tracking is a pain point in personal finance bookkeeping, esepcially when taking ACB into account. Manual lot-based tracking works for FIFO and LIFO, however it is pretty hard to calculate ACB using that method. Luckily, there is a solution for ACB realized profit and loss calculation, where FIFO and LIFO also map nicely. The solution is called trading accounts.

I believe trading accounts can be implemented as a Beancount plugin, although I don't have full context on that, and it may require first-class support from the plain text accounting tool.

What trading accounts are

A trading account, or I would call it a virtual trading account in the scope of plain text accounting tools because it is an in-memory only state, is a special account, the balance of which is extended with the values of priced postings. For instance, consider this plain text entry:

2026-06-01 * "Buy AAPL"
  Assets:Broker:AAPL   10 AAPL @ 120 USD
  Assets:Broker:Cash   -1200 USD

The trading account in this case would be populated with the following values:

Trading:Broker:AAPL   10 AAPL
Trading:Broker:USD    -1200 USD

In short, price is "ignored", and the accounts balance is extended with commodity values. The balance of the trading account essentially tracks the total cost basis. During the disposition event, the realized PnL is calculated according to the selected rule. For FIFO and LIFO that means tracking every lot, in this case priced postings, and calculating from that. ACB, however, comes naturally. The cost basis would be 1200 / 10, and the difference with the market price would result in realized PnL.

To preserve correctness of the calculations, realized PnL on every sell is subtracted from the trading account balance. This ensures the USD balance always reflects the cost basis of the remaining open position, not total cash in and out.

A more complex example could look like this:

2026-06-01 * "Buy 10 AAPL"
  Assets:Broker:AAPL   10 AAPL @ 120 USD
  Assets:Broker:Cash   -1200 USD

2026-06-02 * "Sell 5 AAPL"
  Assets:Broker:Cash   650 USD
  Assets:Broker:AAPL   -5 AAPL @ 130 USD

2026-06-03 * "Buy 5 AAPL"
  Assets:Broker:AAPL   5 AAPL @ 150 USD
  Assets:Broker:Cash   -750 USD

2026-06-04 * "Sell 8 AAPL"
  Assets:Broker:Cash   1440 USD
  Assets:Broker:AAPL   -8 AAPL @ 180 USD

For FIFO, we have the following state of trading accounts:

; 2026-06-01
Trading:Broker:AAPL   10 AAPL
Trading:Broker:USD    -1200 USD

; 2026-06-02
Income:PnL:USD        50 USD    ; 650 - 5 * 120
Trading:Broker:AAPL   5 AAPL    ; 10 - 5
Trading:Broker:USD    -600 USD  ; -1200 + 650 - 50

; 2026-06-03
Trading:Broker:AAPL   10 AAPL   ; 5 + 5
Trading:Broker:USD    -1350 USD ; -600 - 750

; 2026-06-04
Income:PnL:USD        390 USD   ; 1440 - (5 * 120) - (3 * 150)
Trading:Broker:AAPL   2 AAPL    ; 10 - 8
Trading:Broker:USD    -300 USD  ; -1350 + 1440 - 390

LIFO follows the same pattern as FIFO, except the positions are consumed from the most recent first. Realized PnL will be 50 USD and 330 USD for two sell events. ACB calculation is more interesting. It can be derived from the state of trading account balances, without the necessity to track the whole history of positions:

; 2026-06-01
Trading:Broker:AAPL   10 AAPL
Trading:Broker:USD    -1200 USD

; 2026-06-02
Income:PnL:USD        50 USD    ; 650 - 5 * (1200 / 10)
Trading:Broker:AAPL   5 AAPL    ; 10 - 5
Trading:Broker:USD    -600 USD  ; -1200 + 650 - 50

; 2026-06-03
Trading:Broker:AAPL   10 AAPL   ; 5 + 5
Trading:Broker:USD    -1350 USD ; -600 - 750

; 2026-06-04
Income:PnL:USD        360 USD   ; 1440 - 8 * (1350 / 10)
Trading:Broker:AAPL   2 AAPL    ; 10 - 8
Trading:Broker:USD    -270 USD  ; -1350 + 1440 - 360

Here, the numbers in parenthesis are the previous total balances of the trading account. The rest is the state of the transaction that is involved in the stock disposition. ACB just works because the state of trading accounts naturally tracks average price.

Funny enough, the unrealized PnL would also be the state of trading accounts, just converted at the market rate. For instance, 2 AAPL and -270 USD at 190 USD market rate per AAPL stock would result in 380 - 270 = 110 USD of unrealized PnL.

I did not come up with this idea. Refer to this amazing article to read more about how trading accounts work and the reasoning behind them.

With the fundamentals established, let's look at the implementation.

Implementing virtual trading accounts

Virtual trading accounts are purely a derived state from transaction replay. Switching the realized PnL calculation rule equals re-running the replay with different parameters. All we need is to track that state in some form. I chose the following:

// A bag of commodity:decimal pairs,
// e.g. { "AAPL": 10, "USD": -1200 }
// Used throughout to represent multi-commodity balances
type Pool = Map<String, Decimal>

// A single open position, tracked for FIFO and LIFO
struct Position {
  date: Date,
  quantity: Decimal,
  commodity: String,
  cost_per_unit: Decimal,
  cost_currency: String,
}

// A single entry in the trading account history,
// corresponding to one transaction
struct TradingEntry {
    transaction: Transaction, // reference to the original transaction
    entry: Pool,              // delta for this transaction, e.g. { "AAPL": -5, "USD": 650 }
    balance: Pool,            // running balance after this transaction
    realized: Pool,           // realized PnL for this transaction, e.g. { "USD": 50 }
    realized_cumulative: Pool // cumulative realized PnL up to this transaction
}

// Running state for a single commodity/currency pair,
// e.g. AAPL/USD on Assets:Broker
struct TradingContext {
    balance: Pool,             // balance from previous period, e.g. { "AAPL": 10, "USD": -1200 }
    realized: Pool,            // cumulative realized PnL from previous period
    positions: Vec<Position>,  // empty for ACB, populated for FIFO/LIFO
    entries: Vec<TradingEntry> // full history, one entry per transaction
}

// All trading contexts, keyed by commodity/currency pair
type TradingContexts = Map<String, TradingContext>

Every transaction that touches an account marked for trading produces a TradingEntry, giving a full audit trail of position changes, realized PnL per transaction, and running balances. This maps directly to a reporting table where the user can inspect their trading history.

The engine identifies which postings belong to trading accounts - the @ price annotation is the only primitive needed for both acquisition and dispositions. Cost basis on these accounts should be rejected with an error, since the engine only understands @ price and would otherwise silently ignore or misinterpret them.

First, we need some way to identify which account is a mapped to trading accounts. We could annotate the open directive with something like type and rule:

2026-01-01 open Assets:Broker:AAPL
  type: "TRADING"
  rule: "ACB"

The core transaction replay mechanic will look at the trading account annotation and price annotation, and populate the contexts accordingly. Every context has to be attached to the appropriate account. See appendix.

Both apply_acquisition and apply_disposition create a TradingEntry and push it to the vector of entries on the TradingContext. For FIFO and LIFO rules, the position is also added during apply_acquisition. Disposition depends on the account rule, and requires calculating realized PnL. See appendix.

You may note that FIFO and LIFO position tracking is invisible to the ledger and is part of the engine. That contrasts with the manual cost basis inventory tracking, where the user has to select positions to sell explicitly.

Unrealized PnL calculation is trivial and all it needs is the conversion of the balance of the trading account at market rate:

fn get_unrealized_pnl(balance: Pool, prices: Prices) -> Pool {
  pool_at_price(balance, prices, today())
}

The engine handles standard buy and sell workflows cleanly. Two real-world scenarios require special attention: broker transfers and stock splits.

Edge cases

Broker transfers

A naive transfer posting would look like a sell on the source and a buy on the destination, generating spurious PnL on both sides and resetting the date on open positions. We can do better. I think one way to approach that problem is to have a special treatment for transactions without @ price annotations, signaling a transfer. The engine will move the balance and open positions from one account to another and should be injected into transaction replay. See appendix.

ACB transfers are proportional balance moves, while FIFO and LIFO migrate the lot queue consuming from front and back respectively, preserving original acquisition dates.

ACB to FIFO or LIFO transfers are unsupported, since ACB does not track open positions by design. This likely requires selling on one side and buying on another instead, which also happens to match how deemed disposition is treated for tax purposes in some jurisdictions.

See appendix for a sample trading transfer implementation.

Note, that there is a hard limitation imposed on the user in the ledger. The transfer must be a two-legged transaction, otherwise it will be hard to infer the numbers moved. The engine has to detect that in is_trading_transfer call, and ignore the transfer attempt otherwise. Any attempt to transfer without correct specification should signal a warning to the user. Moreover, the realized method mismatch should also be flagged as a warning.

Stock splits

A split changes the quantity of the commodity without changing economic value. It's not a transaction in accounting sense, and it does not involve account counter-party, i.e. the transaction is from and to the same account. Any posting representation could generate spurious PnL, unless added in the order of the open positions. Requiring the user to add the transaction with proper ordering is a little cumbersome, but doable. The downside is that acquisition dates will be reset that way.

The clean solution would be to isolate stock split events into a separate entity, called corporate actions. The split action redenominates the lot queue directly. Quantity multiplied by ratio, cost per currency divided by ratio, total cost basis preserved. Trading account state is multiplied by the ratio for the given commodity where the split has occurred. The cost basis is preserved.

Reverse splits with fractional cashout that generally produce taxable PnL can be handled as a separate transaction alongside the split event. The transaction has to be linked to a split event, which will signal the engine to generate realized PnL by matching the lots based on whether they are evenly divisible, disposing the remainder. If there is no cash-in-lieu transaction associated with the reverse split action, the default would be to produce fractional shares, which some traded stocks support.

This is left as a future work.

In conclusion

This method of calculation covers ACB calculation, which does not map to manual inventory tracking well. As a bonus, FIFO and LIFO also come naturally with trading accounts. The engine can work with all three methods transparently, requiring minimum effort from the user. The ergonomics, in other words, are really good - no need to select the lots by hand. As a bonus, there is no more potential for a mistake of selling the wrong lot, which in practice can mess up the history going forward.

Virtual trading accounts are compatible with existing plain text accounting data model, meaning that user-facing ledger does not require any changes beyond @ price annotation, just the structure of journal entries must be maintained.

Virtual trading accounts being purely derived state means you can switch methods retroactively, audit the engine's work by inspecting the virtual account history, and the source ledger remains immutable and method-agnostic.

I suspect that this engine can be implemented as a plugin for Beancount. ACB support does not require lot tracking at all, making it the simplest possible starting point for a plugin. I've read a few times that Canadian users in particular were asking for ACB support, and this extension could cover the gap.

I implemented trading accounts as an experimental feature in my app, which is somewhat compatible with plain text accounting format. While having just finished stock splits, I'm going to move my own data to trading accounts instead of manual inventory tracking. While developing the feature and testing it on my own data, I found entry mistake in my books - I sold the wrong position while manually selecting it. This is exactly the kind of manual-selection error the engine eliminates by construction.

Appendix

Transaction replay

fn replay(
  transactions: Vec<Transaction>,
  accounts: Map<String, Account>
) -> Map<String, TradingContexts> {
    // Keyed by account name, e.g. "Assets:Broker"
    let account_contexts: Map<String, TradingContexts> = {}

    for txn in transactions {
        if is_trading_transfer(accounts, txn) {
          apply_trading_transfer(account_contexts, accounts, txn)
          continue
        }

        for posting in txn.postings {
            let account = accounts[posting.account]
            if account.type != TRADING { continue }
            if posting.price == None { continue }

            // Derive context key from commodity and price currency
            // e.g. "AAPL/USD"
            let pair = posting.commodity + "/" + posting.price.currency
            let ctx = account_contexts
                .entry(account.name)
                .or_insert({})
                .entry(pair)
                .or_insert(TradingContext::empty())

            if posting.quantity > 0 {
                apply_acquisition(ctx, posting, txn)
            } else {
                apply_disposition(ctx, posting, txn, account.rule)
            }
        }
    }

    account_contexts
}

Realized PnL calculation

fn calculate_realized(
  rule: RealizedRule,
  positions: Vec<Position>,
  posting: Posting,
  balance: Pool,
) -> Option<Decimal> {
  if posting.quantity > 0 { return None }

  let multiplier = posting.price.number
  match rule {
    FIFO || LIFO => {
        let realized = ZERO
        let remainder = posting.quantity.negated()
        let positions = if rule == FIFO {
          positions
        } else {
          positions.reverse()
        }

        for position in positions {
          if remainder == ZERO { break }
          if position.quantity == ZERO { continue }

          // Position quantity is mutated in place, that's just an example.
          let position_qty = position.quantity.abs()
          if position_qty >= remainder {
            realized = realized
              + remainder * multiplier
              - remainder * position.cost_per_unit
            position.quantity = position_qty.minus(remainder);
            remainder = ZERO;
          } else {
            remainder = remainder.minus(position_qty);
            realized = realized
              + position_qty * multiplier
              - position_qty * position.cost_per_unit
            position.quantity = ZERO;
          }
        }
        return Some(realized)
    },
    ACB => {
        let base_balance = balance[posting.commodity]
        let quote_balance = balance[posting.price.currency]
        let qty_sold = posting.quantity.abs()

        let avg_cost_per_unit = (quote_balance / base_balance).abs()
        let realized = qty_sold * multiplier - qty_sold * avg_cost_per_unit
        return Some(realized)
    },
  }
}

Trading transfer

fn apply_trading_transfer(
  account_contexts: Map<String, TradingContexts>,
  accounts: Map<String, Account>,
  transaction: Transaction
) -> Result<Pool, String> {
  let src_posting = transaction.postings.find(|posting| posting.quantity < 0)
  let dst_posting = transaction.postings.find(|posting| posting.quantity > 0)

  let src_account = accounts[src_posting.account]
  let (pair, src_context) = find_trading_ctx_by_source(
    account_contexts[src_account.name],
    src_posting.commodity
  )
  if !src_context { return Err("Transfer with ambiguous balances") }

  let dst_account = accounts[dst_posting.account]
  let dst_context = account_contexts[dst_account.name][pair]
  if !dst_context {
    dst_context = TradingContext::empty()
  }

  let transfer_qty = dst_posting.quantity
  let src_rule = src_account.rule
  let transferred = if src_rule == ACB {
    let balance = get_trading_balance(src_context)
    let total_qty = balance[src_posting.commodity]
    apply_acb_transfer(src_context, transfer_qty, total_qty)
  } else {
    apply_fifo_lifo_transfer(
      src_context,
      dst_context,
      transfer_qty,
      src_rule
    )
  }

  account_contexts[dst_account.name][pair] = dst_context
  // Transferred pool is used for mutation of the balances in
  // src_context and dst_context.
  Ok(transferred)
}

fn apply_acb_transfer(
  src_context: TradingContext,
  transfer_qty: Decimal,
  total_qty: Decimal
) -> Pool {
  let proportion = transfer_qty / total_qty
  let result = {}
  for (commodity, number) in get_trading_balance(src_context) {
    transfer_pool = { [commdity]: number * proportion }
    merge_pools(result, transfer_pool)
  }
  result
}

fn apply_fifo_lifo_transfer(
  src_context: TradingContext,
  dst_context: TradingContext,
  transfer_qty: Decimal,
  rule: RealizedRule,
) -> Pool {
  let transferred_positions = []
  let remaining = transfer_qty
  // Consume positions according to realized rule.
  let positions = if rule == FIFO {
    src_context.positions
  } else {
    src_context.positions.reverse()
  }
  for position in positions {
    if remaining <= ZERO { break }

    let position_qty = position.quantity.abs()
    let consumed = min(position_qty, remaining)
    remaining = remaining - consumed

    transferred_positions.push(Position {
        date: position.date,
        commodity: position.commodity,
        quantity: consumed,
        cost_per_unit: position.cost_per_unit,
        cost_currency: position.cost_currency
    })
  }

  let merged = [...dst_context.positions, ...transferred_positions]
  // Sorting by date is a little naive, ideally the order of transactions
  // and postings should define the total order of merged positions.
  merged.sort_by_key(|a| a.date)
  dst_context.positions = merged

  let result = {}
  for position in transferred_positions {
    result[position.commodity] = result[position.commodity] + position.quantity;
    result[position.cost_currency] =
        result[position.cost_currency] - position.quantity *
          position.cost_per_unit;
  }

  result
}