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
}