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.