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

Virtual trading accounts in plaintext accounting

Cost basis tracking is a universal pain point in personal finance bookkeeping, especially for Canadian ACB users. Lot-based tracking works for FIFO and LIFO, however it is almost impossible 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.

What trading accounts are

A trading account, or I would call it a virtual trading account in the scope of plaintext accounting tools, is a special account, the balance of which is extended with the values of priced positions. For instance, consider this plaintext 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 unit values. During the disposition event, a 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.

To read more on how trading accounts work, refer to this amazing article.

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

Virtual trading account implementation

Virtual trading accounts are purely a derived state from transaction replay. Switching the realized PnL calculation rule is just 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 unit: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,
  cost_per_unit: Decimal,
  cost_unit: String,        // e.g. "USD"
}

// 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 a trading account 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 at a glance.

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 trading accounts must show error, as this will produce garbage or would be ignored by the engine.

First, we need some way to identify which account is a trading account. 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:

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 {
        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 unit
            // e.g. "AAPL/USD"
            let pair = posting.commodity + "/" + posting.price.unit
            let ctx = account_contexts
                .entry(account.name)
                .or_insert(TradingContexts {})
                .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
}

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. The realized PnL computation could look like this:

fn calculate_realized(
  rule: RealizedRule,
  positions: Vec<Position>,
  posting: Posting,
  balance: Pool,
) -> Option<Decimal> {
  let multiplier = posting.price.number
  let price_unit = posting.price.unit

  if posting.quantity > 0 { return None }

  if rule == FIFO || rule == 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 is to keep track only
      // remaining open positions when reporting and for subsequent
      // calculations.
      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)
  }

  if rule == ACB {
    let base_balance = balance[posting.commodity]
    let quote_balance = balance[posting.price.unit]

    let average = (posting.quantity * (quote_balance / base_balance)).abs()
    let realized = posting.quantity * multiplier - average
    return Some(realized)
  }

  unreachable()
}

You may note that FIFO and LIFO position tracking is invisible to the ledger and is part of the engine. That contrasts with the 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/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:

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 {
          // ...
        }
    }

    account_contexts
}

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

ACB to FIFO or LIFO transfers is unsupported, since ACB does not track open positions by design. This requires deemed disposition instead, which is actually the legally correct treatment in most jurisdictions anyway.

Trading transfer may look like this:

fn apply_trading_transfer(
  account_contexts: Map<String, TradingContexts>,
  accounts: Map<String, Account>,
  transaction: Transaction
) -> Error {
  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 "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 = empty_trading_context()
  }

  let transfer_qty = dst_posting.quantity
  let balance = get_trading_balance(src_context)
  let total_qty = balance[src_posting.commodity]

  let src_rule = src_account.rule
  let transferred = if src_rule === ACB {
    apply_acb_transfer(src_context, transfer_qty, total_qty)
  } else {
    apply_fifo_lifo_transfer(
      src_context,
      dst_context,
      transfer_qty,
      total_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.
  transferred
}

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

fn apply_fifo_lifo_transfer(
  src_context: TradingContext,
  dst_context: TradingContext,
  transfer_qty: Decimal,
  total_qty: Decimal,
  rule: RealizedRule,
) -> Pool {
  let proportion = transfer_qty / total_qty
  let result = {}
  for (unit, number) in get_trading_balance(src_context) {
    transfer_pool = { unit: number * proportion }
    merge_pools(result, transfer_pool)
  }

  let migrated_positions = []
  let remaining = transfer_qty
  // Consume postings 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

    migrated_positions.push(Position {
        date: position.date,
        quantity: consumed,
        cost_per_unit: position.cost_per_unit,
        cost_unit: position.cost_unit
    })
  }

  let merged = [...dst_context.positions, ...migrated_positions]
  merged.sort_by_key(|a| a.date)
  dst_context.positions = merged

  result
}

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 that redenominates the lot queue directly. Quantity multiplied by ratio, cost per unit divided by ratio, total cost basis preserved.

Reverse splits with fractional cashout that generally produce taxable PnL can be handled as a separate transaction alongside the split event.

This is left as future work — currently the workaround is to manually close and reopen the position at the split-adjusted price and quantity, with the caveat that acquisition dates are reset.

In conclusion

This method of calculation covers ACB calculation for Canadian users. ACB does not map to 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. Stock splits are the one exception, requiring a dedicated split event rather than a standard transaction.

Virtual trading accounts are compatible with existing plaintext 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 can see that this engine can be implemented as a plugin for Beancount. ACB support does not require a lot tracking at all, making it the simplest possible starting point for a plugin. Canadian users in particular have been underserved by existing tools, and ACB support alone would make a significant difference.

I implemented trading accounts feature in the software that I built, which also offers client-side encryption, multi-currency and market data, while being compatible with plaintext accounting format. The one compatibility gap is trading account state — virtual accounts are derived by the engine and have no plaintext representation. A beancount plugin implementing this algorithm would close that gap, giving plaintext users a full migration path.