Using BQL for investment portfolio analysis: ROI, cost basis, and tax optimization

Advanced Investment Tracking with BQL

After 5 years of tracking a diverse investment portfolio in Beancount, I’ve developed a set of BQL queries and workflows that make portfolio analysis, tax optimization, and performance tracking straightforward. Let me share the queries and techniques I use.

Portfolio Structure in Beancount

First, here’s how I structure investment accounts:

; Investment accounts
2020-01-01 open Assets:Investments:Brokerage:Fidelity:AAPL
2020-01-01 open Assets:Investments:Brokerage:Fidelity:GOOGL
2020-01-01 open Assets:Investments:Brokerage:Fidelity:VTSAX
2020-01-01 open Assets:Investments:Retirement:IRA:VTSAX
2020-01-01 open Assets:Investments:Retirement:IRA:BND

; Income from investments
2020-01-01 open Income:Investments:Dividends
2020-01-01 open Income:Investments:Interest
2020-01-01 open Income:Investments:CapitalGains:ShortTerm
2020-01-01 open Income:Investments:CapitalGains:LongTerm

; Commodities
2020-01-01 commodity AAPL
  name: "Apple Inc."
  asset-class: "Stock"

2020-01-01 commodity VTSAX
  name: "Vanguard Total Stock Market Index"
  asset-class: "Stock"
  asset-subclass: "Index Fund"

Recording Investment Transactions

Buying Stock

; Purchase with explicit lot tracking
2024-01-15 * "Buy AAPL"
  Assets:Investments:Brokerage:Fidelity:AAPL   10 AAPL {150.00 USD}
  Assets:Investments:Brokerage:Fidelity:Cash  -1500.00 USD

; Another purchase at different price (different lot)
2024-03-20 * "Buy more AAPL"
  Assets:Investments:Brokerage:Fidelity:AAPL   15 AAPL {165.00 USD}
  Assets:Investments:Brokerage:Fidelity:Cash  -2475.00 USD

Selling Stock with Gains Calculation

; Sell specific lot (FIFO, LIFO, or specific identification)
2024-06-10 * "Sell AAPL (tax-loss harvest)"
  Assets:Investments:Brokerage:Fidelity:AAPL  -10 AAPL {150.00 USD} @ 180.00 USD
  Assets:Investments:Brokerage:Fidelity:Cash   1800.00 USD
  Income:Investments:CapitalGains:LongTerm     -300.00 USD

; Note: 
; - {150.00 USD} = cost basis (what I paid)
; - @ 180.00 USD = sale price
; - Gain = (180 - 150) * 10 = $300
; - Long-term because held > 365 days

Recording Dividends

2024-03-15 * "VTSAX dividend"
  Assets:Investments:Brokerage:Fidelity:Cash   45.67 USD
  Income:Investments:Dividends                -45.67 USD

; With dividend reinvestment
2024-06-15 * "AAPL dividend (reinvested)"
  Assets:Investments:Brokerage:Fidelity:AAPL   0.5 AAPL {180.00 USD}
  Income:Investments:Dividends                -90.00 USD

Essential BQL Queries for Investments

Query 1: Current Holdings Report

SELECT 
  account,
  sum(units(position)) as shares,
  sum(cost(position)) as cost_basis,
  sum(convert(position, 'USD')) as market_value,
  sum(convert(position, 'USD')) - sum(cost(position)) as unrealized_gain
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'
GROUP BY account
ORDER BY market_value DESC

Output:

account                                  shares      cost_basis  market_value  unrealized_gain
Assets:Investments:Brokerage:AAPL        25 AAPL    3975.00 USD  4500.00 USD   525.00 USD
Assets:Investments:Brokerage:GOOGL       10 GOOGL   1400.00 USD  1650.00 USD   250.00 USD
Assets:Investments:Brokerage:VTSAX       100 VTSAX  9500.00 USD  11200.00 USD  1700.00 USD

Query 2: Realized Gains by Year

SELECT 
  YEAR(date) as year,
  account,
  sum(position) as total_gains
WHERE account ~ 'Income:Investments:CapitalGains'
GROUP BY year, account
ORDER BY year DESC

This shows your taxable gains by year and type (short-term vs long-term).

Query 3: Investment Performance (ROI)

-- Total invested (cost basis)
SELECT sum(cost(position)) as total_invested
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'

-- Current value
SELECT sum(convert(position, 'USD')) as current_value
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'

-- Then calculate: ROI = (current_value - total_invested) / total_invested

For automated ROI calculation, use a Python script:

# investment_roi.py

from beancount import loader
from beancount.query import query_execute
from decimal import Decimal

entries, errors, options = loader.load_file('main.beancount')

# Get cost basis
cost_query = """
SELECT sum(cost(position)) as total
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'
"""

rtypes, rows = query_execute.execute_query(entries, options, cost_query)
total_invested = rows[0].total.get_only_position().units.number

# Get current value
value_query = """
SELECT sum(convert(position, 'USD')) as total
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'
"""

rtypes, rows = query_execute.execute_query(entries, options, value_query)
current_value = rows[0].total.get_only_position().units.number

# Calculate
roi = ((current_value - total_invested) / total_invested) * 100

print(f"Total Invested:   ${total_invested:,.2f}")
print(f"Current Value:    ${current_value:,.2f}")
print(f"Unrealized Gain:  ${current_value - total_invested:,.2f}")
print(f"ROI:              {roi:.2f}%")

Query 4: Dividend Income Analysis

SELECT 
  YEAR(date) as year,
  MONTH(date) as month,
  sum(position) as dividend_income
WHERE account ~ 'Income:Investments:Dividends'
GROUP BY year, month
ORDER BY year DESC, month DESC
LIMIT 12

Shows monthly dividend income trend.

Query 5: Cost Basis by Lot (Tax Planning)

SELECT 
  account,
  currency,
  units(position) as shares,
  cost(position) as cost_basis,
  convert(position, 'USD') as market_value,
  cost(position) / units(position) as cost_per_share,
  convert(position, 'USD') / units(position) as current_price,
  (convert(position, 'USD') - cost(position)) as gain_loss
WHERE account ~ 'Assets:Investments:Brokerage'
AND currency != 'USD'

This is critical for tax-loss harvesting! Shows each lot’s unrealized gain/loss.

Tax Optimization Strategies

Strategy 1: Tax-Loss Harvesting

Goal: Sell losing positions to offset capital gains.

BQL Query to find losses:

SELECT 
  account,
  currency,
  units(position) as shares,
  cost(position) as cost_basis,
  convert(position, 'USD') as market_value,
  (convert(position, 'USD') - cost(position)) as unrealized_loss
WHERE account ~ 'Assets:Investments:Brokerage'
AND currency != 'USD'
AND (convert(position, 'USD') - cost(position)) < 0
ORDER BY unrealized_loss ASC

This shows all positions with unrealized losses, sorted by largest loss first.

Workflow:

  1. Run query in November/December
  2. Identify losses to harvest
  3. Sell losing positions
  4. Wait 31 days (avoid wash sale rule)
  5. Rebuy or buy similar (but not identical) security

Strategy 2: Lot Selection for Optimal Gains

When selling, choose which lot minimizes taxes:

; Example: I have 3 lots of AAPL
; Lot 1: 10 shares @ $150 (held 2 years) = Long-term
; Lot 2: 15 shares @ $165 (held 6 months) = Short-term
; Lot 3: 20 shares @ $180 (held 1 month) = Short-term

; Current price: $170

; Selling Lot 1: Gain = $200 (long-term, taxed at 15%)
; Selling Lot 2: Loss = -$75 (short-term, offsets ordinary income)
; Selling Lot 3: Loss = -$200 (short-term, offsets ordinary income)

; Best choice for tax: Sell Lot 3 (harvest loss)

2024-12-15 * "Sell AAPL - tax loss harvest"
  Assets:Investments:Brokerage:AAPL  -20 AAPL {180.00 USD} @ 170.00 USD
  Assets:Investments:Brokerage:Cash   3400.00 USD
  Income:Investments:CapitalGains:ShortTerm  200.00 USD  ; Loss = positive income

Strategy 3: Wash Sale Tracking

Wash sale rule: If you sell at a loss and rebuy within 30 days, you can’t claim the loss.

Use metadata to track:

2024-11-01 * "Sell VTSAX - tax loss"
  wash-sale-start: "2024-11-01"
  wash-sale-end: "2024-12-01"
  Assets:Investments:Brokerage:VTSAX  -100 VTSAX {100.00 USD} @ 95.00 USD
  Assets:Investments:Brokerage:Cash    9500.00 USD
  Income:Investments:CapitalGains:ShortTerm  500.00 USD

; DON'T buy VTSAX again before 2024-12-02!
; Can buy similar fund (VTI) instead

Query to check wash sales:

SELECT date, payee, metadata('wash-sale-end')
WHERE metadata('wash-sale-end') IS NOT NULL
AND metadata('wash-sale-end') >= TODAY()

Advanced Portfolio Analysis

Asset Allocation Report

# asset_allocation.py

from beancount import loader
from beancount.query import query_execute
from collections import defaultdict

entries, errors, options = loader.load_file('main.beancount')

# Get all holdings with asset class
query = """
SELECT 
  account,
  currency,
  convert(position, 'USD') as value
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'
"""

rtypes, rows = query_execute.execute_query(entries, options, query)

# Look up asset class from commodity directives
asset_classes = defaultdict(Decimal)
total_value = Decimal(0)

for row in rows:
    value = row.value.get_only_position().units.number
    total_value += value
    
    # Get asset class from commodity metadata
    # (simplified - in practice, parse commodity directives)
    if 'Stock' in str(row.currency):
        asset_classes['Stocks'] += value
    elif 'Bond' in str(row.currency):
        asset_classes['Bonds'] += value

# Report
print(f"{'Asset Class':<20} {'Value':>15} {'Allocation':>12}")
print("-" * 50)
for asset_class, value in asset_classes.items():
    pct = (value / total_value) * 100
    print(f"{asset_class:<20} ${value:>14,.2f} {pct:>11.1f}%")

Time-Weighted Return (TWR)

More accurate than simple ROI when adding/withdrawing funds:

# twr.py - Calculate time-weighted return

from beancount import loader
from datetime import date, timedelta
from decimal import Decimal

# Simplified TWR calculation
# For each period between cash flows:
#   period_return = (ending_value - beginning_value - net_cash_flow) / beginning_value
# TWR = product of (1 + period_return) - 1

# This requires tracking:
# 1. Portfolio value at each cash flow date
# 2. Cash flows (deposits/withdrawals)

# Example implementation left as exercise - requires price history

Sector Diversification

Add sector metadata to commodities:

2020-01-01 commodity AAPL
  name: "Apple Inc."
  asset-class: "Stock"
  sector: "Technology"
  
2020-01-01 commodity XOM
  name: "Exxon Mobil"
  asset-class: "Stock"
  sector: "Energy"

Then query by sector:

SELECT 
  metadata('sector') as sector,
  sum(convert(position, 'USD')) as value
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'
GROUP BY sector

Generating Tax Forms

Schedule D (Capital Gains)

# schedule_d.py

from beancount import loader
from beancount.query import query_execute

entries, errors, options = loader.load_file('main.beancount')

# Get all sales in tax year
query = """
SELECT 
  date,
  payee,
  account,
  units(position),
  cost(position),
  position
WHERE account ~ 'Income:Investments:CapitalGains'
AND year = 2024
"""

rtypes, rows = query_execute.execute_query(entries, options, query)

print("Schedule D - Capital Gains and Losses (2024)")
print("=" * 80)
print(f"{'Date':<12} {'Security':<10} {'Shares':>8} {'Cost':>12} {'Proceeds':>12} {'Gain/Loss':>12}")
print("-" * 80)

for row in rows:
    # Parse and format for Schedule D
    # ... implementation details ...
    pass

Integration with gains_minimizer Plugin

The beancount.plugins.gains_minimizer can automatically select optimal lots:

plugin "beancount.plugins.gains_minimizer"

; When selling, leave lot empty - plugin picks best
2024-12-15 * "Sell AAPL - plugin picks lot"
  Assets:Investments:Brokerage:AAPL  -10 AAPL {} @ 180.00 USD
  Assets:Investments:Brokerage:Cash   1800.00 USD
  Income:Investments:CapitalGains

; Plugin automatically:
; 1. Selects lot with highest cost basis (minimize gain)
; 2. Prefers long-term over short-term if equal
; 3. Fills in the {} with chosen lot

Best Practices

  1. Record price updates daily: Automated with yfinance
  2. Use explicit lot selection: Don’t rely on FIFO/LIFO defaults
  3. Track wash sales: Use metadata to avoid violations
  4. Separate accounts by tax treatment: Taxable vs IRA vs Roth
  5. Regular rebalancing: Query asset allocation monthly
  6. Document tax decisions: Use transaction metadata

Resources

What investment tracking queries do you find useful? Any edge cases I haven’t covered?

Excellent investment tracking guide! I’ve been using Beancount for complex multi-currency portfolio tracking for years. Let me add some international investment scenarios and edge cases.

International Investments

Multi-Currency Holdings

When investing globally, you need to track both the investment performance AND currency fluctuations:

; Buy European stock in EUR
2024-01-15 * "Buy SAP (German stock)"
  Assets:Investments:Brokerage:SAP   50 SAP {100.00 EUR}
  Assets:Investments:Brokerage:Cash  -5000.00 EUR

; Record exchange rate at purchase
2024-01-15 price EUR  1.10 USD

; Later: EUR appreciates
2024-06-01 price EUR  1.15 USD
2024-06-01 price SAP  110.00 EUR

BQL query for multi-currency analysis:

SELECT 
  account,
  currency,
  units(position) as shares,
  cost(position) as cost_in_currency,
  convert(cost(position), 'USD') as cost_in_usd,
  convert(position, 'USD') as market_value_usd,
  convert(position, 'USD') - convert(cost(position), 'USD') as total_gain_usd
WHERE account ~ 'Assets:Investments'
AND currency NOT IN ('USD', 'EUR')

This shows total gain including BOTH:

  • Investment performance (SAP: 100 → 110 EUR = +10%)
  • Currency gain (EUR: 1.10 → 1.15 USD = +4.5%)

Foreign Tax Withholding

Many countries withhold tax on dividends:

; Canadian stock dividend with 15% withholding
2024-03-15 * "TD Bank dividend (CAD) with withholding"
  Assets:Investments:Brokerage:Cash     85.00 CAD  ; Net received
  Expenses:Taxes:Foreign:Canada         15.00 CAD  ; Withheld
  Income:Investments:Dividends        -100.00 CAD  ; Gross dividend

; Track for foreign tax credit
2024-03-15 price CAD  0.74 USD

Query for foreign tax credit calculation:

SELECT 
  YEAR(date) as year,
  account,
  sum(convert(position, 'USD')) as foreign_tax_paid_usd
WHERE account ~ 'Expenses:Taxes:Foreign'
GROUP BY year, account

Use this for IRS Form 1116 (Foreign Tax Credit).

Advanced Investment Scenarios

Options Trading

Tracking options requires careful lot management:

; Buy call option
2024-01-15 * "Buy AAPL call option"
  Assets:Investments:Options:AAPL-Call-150-2024-03  1 AAPL-C-150-0315 {500.00 USD}
  Assets:Investments:Brokerage:Cash                -500.00 USD

; Option expires worthless
2024-03-15 * "AAPL call expired worthless"
  Assets:Investments:Options:AAPL-Call-150-2024-03  -1 AAPL-C-150-0315 {500.00 USD} @ 0.00 USD
  Income:Investments:CapitalGains:ShortTerm          500.00 USD  ; Loss

; Or: Exercise option
2024-03-15 * "Exercise AAPL call"
  Assets:Investments:Brokerage:AAPL             100 AAPL {150.00 USD}  ; Strike price
  Assets:Investments:Options:AAPL-Call-150      -1 AAPL-C-150 {500.00 USD}
  Assets:Investments:Brokerage:Cash            -15500.00 USD  ; 100 shares * $150 + $500 premium

Stock Splits

Handle stock splits by adjusting lots:

; Original purchase
2024-01-15 * "Buy NVDA"
  Assets:Investments:Brokerage:NVDA   10 NVDA {500.00 USD}
  Assets:Investments:Brokerage:Cash  -5000.00 USD

; 10-for-1 split on June 10, 2024
2024-06-10 * "NVDA 10:1 stock split"
  Assets:Investments:Brokerage:NVDA   90 NVDA {50.00 USD}  ; Add 90 shares at split-adjusted price
  Assets:Investments:Brokerage:NVDA  -10 NVDA {500.00 USD} ; Remove old lot
  Assets:Investments:Brokerage:NVDA   10 NVDA {50.00 USD}  ; Re-add at split price
  Equity:Adjustments  ; Balancing account

Better approach: Use metadata and don’t create transaction:

; Just note the split in metadata
2024-06-10 note Assets:Investments:Brokerage:NVDA "10:1 stock split - adjust cost basis manually"

; Continue tracking with new price
2024-06-10 price NVDA  50.00 USD

ESPP (Employee Stock Purchase Plan)

ESPPs have complex tax treatment:

; Purchase through ESPP (15% discount)
2024-06-15 * "ESPP purchase"
  purchase-date: "2024-06-15"
  fmv-at-purchase: "100.00"
  discount: "15.00"
  Assets:Investments:ESPP:TechCorp   10 TECH {85.00 USD}  ; Purchase price
  Income:Salary:TechCorp             -850.00 USD          ; Deducted from paycheck

; Qualifying disposition (held > 2 years from grant, > 1 year from purchase)
2026-07-01 * "Sell ESPP shares - qualifying"
  Assets:Investments:ESPP:TechCorp           -10 TECH {85.00 USD} @ 120.00 USD
  Assets:Investments:Brokerage:Cash           1200.00 USD
  Income:Investments:CapitalGains:LongTerm    -200.00 USD  ; Gain
  Income:Salary:TechCorp                      -150.00 USD  ; Discount = ordinary income

; The $150 discount is taxed as ordinary income
; The $200 gain is long-term capital gain

Performance Metrics Beyond ROI

Sharpe Ratio (Risk-Adjusted Returns)

# sharpe_ratio.py

from beancount import loader
from beancount.query import query_execute
import numpy as np
from datetime import date, timedelta

entries, errors, options = loader.load_file('main.beancount')

# Get monthly portfolio values for last 12 months
monthly_values = []

for i in range(12):
    month_date = date.today() - timedelta(days=30*i)
    
    # Query portfolio value at this date
    query = f"""
    SELECT sum(convert(position, 'USD', {month_date})) as value
    WHERE account ~ 'Assets:Investments'
    AND date <= {month_date}
    """
    
    rtypes, rows = query_execute.execute_query(entries, options, query)
    value = rows[0].value.get_only_position().units.number
    monthly_values.append(float(value))

# Calculate monthly returns
returns = []
for i in range(len(monthly_values) - 1):
    ret = (monthly_values[i] - monthly_values[i+1]) / monthly_values[i+1]
    returns.append(ret)

# Sharpe ratio = (avg return - risk-free rate) / std dev of returns
avg_return = np.mean(returns)
std_return = np.std(returns)
risk_free_rate = 0.04 / 12  # Assume 4% annual risk-free rate

sharpe = (avg_return - risk_free_rate) / std_return

print(f"Sharpe Ratio: {sharpe:.2f}")
print(f"Avg Monthly Return: {avg_return*100:.2f}%")
print(f"Monthly Volatility: {std_return*100:.2f}%")

Maximum Drawdown

# max_drawdown.py

# Track peak and trough to find maximum loss from peak
peak = max(monthly_values)
trough = min(monthly_values[monthly_values.index(peak):])

max_drawdown = (trough - peak) / peak

print(f"Maximum Drawdown: {max_drawdown*100:.2f}%")

Reconciliation with Brokerage Statements

Critical workflow: Monthly reconciliation

; Add balance assertions for each security
2024-10-31 balance Assets:Investments:Brokerage:AAPL  25 AAPL
2024-10-31 balance Assets:Investments:Brokerage:Cash  5432.10 USD

; If cost basis doesn't match broker:
; Query Beancount's cost basis
SELECT sum(cost(position))
WHERE account = 'Assets:Investments:Brokerage:AAPL'

; Compare with brokerage statement
; Investigate discrepancies (usually missing transactions)

Tax-Advantaged Accounts (IRA, 401k, Roth)

Different accounts have different tax treatment:

; Traditional IRA - contributions reduce taxable income
2024-03-15 * "IRA contribution"
  Assets:Investments:Retirement:IRA:Cash   6500.00 USD
  Assets:Bank:Checking                    -6500.00 USD
  Income:Tax-Deductions:IRA                 ; Track for tax deduction

; Roth IRA - contributions are after-tax
2024-03-15 * "Roth IRA contribution"
  Assets:Investments:Retirement:Roth:Cash  6500.00 USD
  Assets:Bank:Checking                    -6500.00 USD

; 401k - track employer match separately
2024-01-31 * "401k contribution + match"
  Assets:Investments:Retirement:401k:Cash   2000.00 USD  ; Your contribution
  Assets:Investments:Retirement:401k:Cash   1000.00 USD  ; Employer match
  Income:Salary:TechCorp                   -2000.00 USD  ; Pre-tax from salary
  Income:Salary:TechCorp:Match             -1000.00 USD  ; Employer contribution

Query for retirement account balances:

SELECT 
  account,
  sum(convert(position, 'USD')) as value
WHERE account ~ 'Assets:Investments:Retirement'
GROUP BY 1
ORDER BY 2 DESC

Advanced BQL: Finding Specific Lots

When you have many lots, find specific ones:

-- Find all lots purchased in 2023 that are now profitable
SELECT 
  account,
  currency,
  date,
  units(position) as shares,
  cost(position) / units(position) as purchase_price,
  convert(position, 'USD') / units(position) as current_price,
  (convert(position, 'USD') - cost(position)) as gain
WHERE account ~ 'Assets:Investments:Brokerage'
AND currency != 'USD'
AND YEAR(date) = 2023
AND (convert(position, 'USD') - cost(position)) > 0
ORDER BY gain DESC

Fava-Investor Plugin

For visual portfolio analytics:

pip install fava-investor

Add to main.beancount:

2020-01-01 custom "fava-extension" "fava_investor" "{
  'metadata-key': 'asset-class'
}"

Provides:

  • Asset allocation pie charts
  • Portfolio performance over time
  • Sector/geography breakdown
  • Rebalancing suggestions

Edge Cases and Gotchas

1. Fractional shares from DRIP

2024-06-15 * "Dividend reinvestment - fractional"
  Assets:Investments:Brokerage:VTSAX   0.123456 VTSAX {123.45 USD}
  Income:Investments:Dividends        -15.24 USD

2. Return of capital (not taxable)

2024-03-15 * "REI return of capital"
  Assets:Investments:Brokerage:REIT  -5.00 USD  ; Reduces cost basis
  Assets:Investments:Brokerage:Cash   5.00 USD

; Adjusts cost basis, not taxable income

3. Spin-offs

; When company XYZ spins off division into new company ABC
2024-05-01 * "XYZ spinoff - receive ABC shares"
  Assets:Investments:Brokerage:ABC   10 ABC {20.00 USD}  ; New shares
  Assets:Investments:Brokerage:XYZ   -200.00 USD         ; Reduce XYZ cost basis

Great guide! Investment tracking is where Beancount really shines compared to other tools.

This thread is a goldmine! Let me add documentation references and some Fava-specific investment analysis features that complement BQL queries.

Essential Investment Documentation

Official Beancount Trading Docs

Trading with Beancount: Trading with Beancount - Beancount Documentation

This covers:

  • Stock purchases and sales
  • Booking methods (FIFO, LIFO, specific identification)
  • Augmenting postings (how @ and @@ work)
  • Commission handling
  • Stock splits and dividends

Key concept from docs: The {} vs @ distinction

; {} = cost (immutable, historical)
Assets:Investments:AAPL   10 AAPL {150.00 USD}

; @ = price (current, for valuation)
Assets:Investments:AAPL  -10 AAPL {150.00 USD} @ 180.00 USD

; @@ = total price (alternative syntax)
Assets:Investments:AAPL  -10 AAPL {150.00 USD} @@ 1800.00 USD

Lot Date Tracking

For wash sale avoidance, track purchase dates in cost:

; Include date in cost specification
2024-01-15 * "Buy AAPL"
  Assets:Investments:AAPL   10 AAPL {150.00 USD, 2024-01-15}
  Assets:Cash              -1500.00 USD

; Later: Query by lot date
SELECT 
  account,
  currency,
  cost(position),
  date
WHERE account ~ 'Assets:Investments'
AND currency = 'AAPL'

Fava Investment Features

Holdings Report

Fava has a built-in Holdings page that shows:

  • Current market value
  • Cost basis
  • Unrealized gains/losses
  • Allocation percentages

To enhance: Add metadata to commodities

2020-01-01 commodity AAPL
  name: "Apple Inc."
  asset-class: "Stock"
  asset-allocation-equity-us-large: "100"
  export: "NASDAQ:AAPL"  ; For price lookup

Fava Charts for Investments

Create custom charts in Fava:

Chart 1: Portfolio value over time

Navigate to: Charts > Balance Sheet
Select: Assets:Investments:*
View: At Cost vs At Market Value

Chart 2: Investment income

Charts > Income Statement
Select: Income:Investments:*
Group by: Month

Advanced BQL Queries

Query: Annualized Return by Holding

# annualized_returns.py

from beancount import loader
from beancount.query import query_execute
from datetime import date
from decimal import Decimal
import math

entries, errors, options = loader.load_file('main.beancount')

# For each holding, calculate annualized return
holdings_query = """
SELECT DISTINCT currency
WHERE account ~ 'Assets:Investments'
AND currency != 'USD'
"""

rtypes, rows = query_execute.execute_query(entries, options, holdings_query)

print(f"{'Security':<10} {'Return':>10} {'Years Held':>12} {'Annualized':>12}")
print("-" * 50)

for row in rows:
    symbol = row.currency
    
    # Get first purchase date and cost
    first_query = f"""
    SELECT date, cost(position)
    WHERE account ~ 'Assets:Investments'
    AND currency = '{symbol}'
    ORDER BY date ASC
    LIMIT 1
    """
    
    rtypes2, rows2 = query_execute.execute_query(entries, options, first_query)
    if not rows2:
        continue
        
    first_date = rows2[0].date
    initial_cost = float(rows2[0].cost_position.get_only_position().units.number)
    
    # Get current value
    current_query = f"""
    SELECT sum(convert(position, 'USD'))
    WHERE account ~ 'Assets:Investments'
    AND currency = '{symbol}'
    """
    
    rtypes3, rows3 = query_execute.execute_query(entries, options, current_query)
    current_value = float(rows3[0].sum_position.get_only_position().units.number)
    
    # Calculate annualized return
    years_held = (date.today() - first_date).days / 365.25
    total_return = (current_value - initial_cost) / initial_cost
    annualized = (1 + total_return) ** (1 / years_held) - 1
    
    print(f"{symbol:<10} {total_return*100:>9.1f}% {years_held:>11.1f}y {annualized*100:>11.1f}%")

Query: Correlation Between Holdings

# correlation.py - Find correlated investments

# This requires historical price data
# Export prices to CSV, then use pandas/numpy

import pandas as pd
import numpy as np

# Export price history for each holding
query = """
SELECT date, currency, convert(1 {currency}, 'USD') as price
WHERE account ~ 'Assets:Investments'
"""

# Calculate correlation matrix
# (Implementation left as exercise - requires price history)

# Use to identify diversification opportunities

Tax Reporting Queries

Form 8949 (Sales of Capital Assets)

# form_8949.py - Generate Form 8949 data

from beancount import loader
from beancount.query import query_execute

entries, errors, options = loader.load_file('main.beancount')

# Get all sales in tax year
query = """
SELECT 
  date,
  account,
  units(position),
  cost(position),
  position
WHERE (account ~ 'Income:Investments:CapitalGains:ShortTerm'
   OR account ~ 'Income:Investments:CapitalGains:LongTerm')
AND year = 2024
ORDER BY date
"""

rtypes, rows = query_execute.execute_query(entries, options, query)

# Format for Form 8949
print("Form 8949 - Sales and Other Dispositions of Capital Assets")
print("Tax Year 2024")
print()
print("Part I - Short-Term Transactions")
print(f"{'Description':<30} {'Acquired':<12} {'Sold':<12} {'Proceeds':>12} {'Cost':>12} {'Gain/Loss':>12}")
print("-" * 100)

for row in rows:
    if 'ShortTerm' in str(row.account):
        # Parse and format
        # ... implementation ...
        pass

Dividend Income Summary (Form 1099-DIV)

SELECT 
  YEAR(date) as year,
  sum(position) as total_dividends
WHERE account = 'Income:Investments:Dividends'
AND year = 2024

Brokerage Import Automation

OFX Import for Investments

# fidelity_importer.py

from beancount.ingest import importer
from beancount.core import data, amount
from ofxparse import OfxParser

class FidelityImporter(importer.ImporterProtocol):
    def identify(self, file):
        return 'fidelity' in file.name.lower() and file.name.endswith('.ofx')
    
    def extract(self, file):
        with open(file.name) as f:
            ofx = OfxParser.parse(f)
        
        entries = []
        
        for transaction in ofx.account.statement.transactions:
            if transaction.type == 'debit':
                # Stock purchase
                meta = data.new_metadata(file.name, 0)
                
                entry = data.Transaction(
                    meta=meta,
                    date=transaction.date.date(),
                    flag='*',
                    payee=None,
                    narration=transaction.memo,
                    tags=set(),
                    links=set(),
                    postings=[
                        data.Posting(
                            account='Assets:Investments:Fidelity:TODO',
                            units=amount.Amount(transaction.amount, 'USD'),
                            cost=None,
                            price=None,
                            flag=None,
                            meta={}
                        )
                    ]
                )
                entries.append(entry)
        
        return entries

CSV Import for Robinhood, etc.

Many brokers provide CSV exports. Use beancount.ingest.csv:

# robinhood_csv.py

from beancount_import.source import csv

csv.CsvSource(
    account='Assets:Investments:Robinhood',
    csv_filename='robinhood_*.csv',
    field_mappings={
        'date': 'Date',
        'narration': 'Description',
        'amount': 'Amount',
    },
    # Custom processing for buy/sell
    categorizer=lambda row: categorize_investment_transaction(row)
)

Investment Best Practices

1. Separate Accounts by Tax Treatment

Assets:Investments:Taxable:Brokerage:Fidelity
Assets:Investments:TaxDeferred:IRA:Vanguard
Assets:Investments:TaxFree:Roth:Fidelity

This makes tax reporting much easier.

2. Use Metadata for Tax Optimization

2024-01-15 * "Buy AAPL"
  holding-period-start: "2024-01-15"
  tax-lot-id: "AAPL-2024-01-15-1"
  Assets:Investments:AAPL   10 AAPL {150.00 USD}
  Assets:Cash              -1500.00 USD

Query for lots approaching long-term status:

SELECT 
  date,
  metadata('holding-period-start'),
  account,
  units(position)
WHERE account ~ 'Assets:Investments'
AND date + 365 DAYS <= TODAY() + 30 DAYS  -- Within 30 days of long-term

3. Price Update Automation

Use bean-price (official Beancount tool):

# Fetch prices for all holdings
bean-price main.beancount >> prices/prices.beancount

Or custom script with yfinance:

# update_prices.py (cron daily)

import yfinance as yf
from datetime import date

symbols = ['AAPL', 'GOOGL', 'VTSAX', 'BND']

with open('prices/prices.beancount', 'a') as f:
    for symbol in symbols:
        try:
            ticker = yf.Ticker(symbol)
            price = ticker.history(period='1d')['Close'].iloc[-1]
            f.write(f"{date.today()} price {symbol} {price:.2f} USD
")
        except:
            print(f"Failed to fetch {symbol}")

Community Resources

GitHub Examples:

Fava Plugins:

  • fava-investor: Portfolio analytics and rebalancing
  • fava-portfolio-summary: Custom investment dashboard

Mailing List Threads:

  • “Investment tracking best practices” (Google Group, 2023)
  • “Tax-loss harvesting automation” (Google Group, 2024)

Final Tips

  1. Reconcile monthly: Balance assertions for each holding
  2. Track cost basis meticulously: The IRS requires it
  3. Use metadata liberally: Future you will thank you
  4. Automate price updates: Daily cron job
  5. Test tax queries early: Don’t wait until April
  6. Keep brokerage PDFs: Link them in transaction metadata

This thread should be in the Beancount wiki! Comprehensive investment tracking guide.