I tried to create a plugin myself but ended up just creating a script that
created the transfers. It works, but you have to make sure you add them in
order or you'll have issues. It's helpful if you have a lot of lots and
transfer cryptocurrencies between accounts or exchanges...
```
#!/usr/bin/env python3
"""
Beancount Crypto Transfer Transaction Generator v2
This script uses the native Beancount Python API instead of subprocess calls
to query your Beancount file and generate transfer transactions
automatically
using FIFO lot selection.
Usage:
python transfer_generator_v2.py ledger.beancount
"Assets:CryptoExchange" "Assets:PrivateWallet" 20 BSV "Transfer to cold
storage"
"""
import sys
import os
from decimal import Decimal
from datetime import datetime
import argparse
from beancount import loader
from beanquery.query import run_query
def get_account_lots(beancount_file, account, currency):
"""Get all lots for a specific account and currency using native
Beancount API"""
try:
# Load the beancount file
entries, errors, options_map = loader.load_file(beancount_file)
if errors:
print(f"Warning: Found {len(errors)} errors in Beancount file:")
for error in errors[:5]: # Show first 5 errors
print(f" {error}")
if len(errors) > 5:
print(f" ... and {len(errors) - 5} more errors")
# Run the query directly using beanquery
query = f"""
SELECT account,
units(sum(position)) as units,
cost_number as cost,
cost_currency as cost_currency,
cost_date as acquisition_date,
cost_label as lot_label
WHERE account = '{account}' AND currency = '{currency}'
GROUP BY account, cost_number, cost_currency, cost_date, cost_label
ORDER BY cost_date
"""
# This returns structured data, not CSV text!
result_types, result_rows = run_query(entries, options_map, query)
lots = []
for row in result_rows:
# Direct access to typed data instead of parsing CSV strings
units_inventory = row[1] # This is an Inventory object, not
Amount
# Skip empty inventories
if units_inventory is None:
continue
# Handle Beancount Inventory objects
try:
if hasattr(units_inventory, 'is_empty') and units_inventory
.is_empty():
continue
# Extract the Amount from the Inventory
# An Inventory can contain multiple positions, we want the
one for our currency
amount = None
if hasattr(units_inventory, '__iter__'):
for position in units_inventory:
if hasattr(position, 'units') and
position.units.currency
== currency:
amount = position.units
break
if amount is None or amount.number <= 0:
continue
lot = {
'account': str(row[0]) if row[0] else '',
'units': amount.number,
'cost': row[2] if row[2] is not None else None,
'cost_currency': str(row[3]) if row[3] else None,
'acquisition_date': str(row[4]) if row[4] else None,
'lot_label': str(row[5]) if row[5] else None
}
lots.append(lot)
except Exception as e:
print(f"Debug: Exception processing row: {e}")
continue
return lots
except Exception as e:
print(f"Error querying lots: {e}")
return []
def select_lots_fifo(lots, transfer_amount):
"""Select lots using FIFO method for the transfer amount"""
selected_lots = []
remaining = Decimal(str(transfer_amount))
# Sort by acquisition date (FIFO) - handle None dates
sorted_lots = sorted(lots, key=lambda x: x['acquisition_date'] or
'1900-01-01')
for lot in sorted_lots:
if remaining <= 0:
break
# Take either the full lot or remaining amount
take_amount = min(remaining, lot['units'])
selected_lot = {
'account': lot['account'],
'units': take_amount,
'cost': lot['cost'],
'cost_currency': lot['cost_currency'],
'acquisition_date': lot['acquisition_date'],
'lot_label': lot['lot_label']
}
selected_lots.append(selected_lot)
remaining -= take_amount
if remaining > 0:
print(f"Warning: Not enough units available. Short by {remaining}")
return selected_lots
def format_cost_spec(lot):
"""Format the cost specification for a lot"""
parts = []
if lot['cost'] and lot['cost_currency']:
parts.append(f"{lot['cost']} {lot['cost_currency']}")
if lot['acquisition_date']:
parts.append(lot['acquisition_date'])
if lot['lot_label']:
parts.append(f'"{lot["lot_label"]}"')
if parts:
return "{" + ", ".join(parts) + "}"
else:
return "{}"
def generate_transfer_transaction(date, from_account, to_account, currency,
description, selected_lots):
"""Generate a Beancount transfer transaction"""
lines = [f'{date} ! "{description}"']
for i, lot in enumerate(selected_lots):
cost_spec = format_cost_spec(lot)
# Format with proper spacing
from_line = f" {from_account:<45} -{lot['units']} {currency} {
cost_spec}"
to_line = f" {to_account:<45} {lot['units']} {currency} {cost_spec}
"
lines.append(from_line)
lines.append(to_line)
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(
description='Generate Beancount crypto transfer transactions with
automatic lot selection (v2 - Native API)'
)
parser.add_argument('beancount_file', help='Path to your Beancount file'
)
parser.add_argument('from_account', help='Source account (e.g.,
Assets:CryptoExchange)')
parser.add_argument('to_account', help='Destination account (e.g.,
Assets:PrivateWallet)')
parser.add_argument('amount', type=str, help='Amount to transfer')
parser.add_argument('currency', help='Currency symbol (e.g., BSV, BTC)')
parser.add_argument('description', help='Transaction description')
parser.add_argument('--date', help='Transaction date (YYYY-MM-DD)',
default=datetime.now().strftime('%Y-%m-%d'))
parser.add_argument('--method', choices=['FIFO', 'LIFO'], default='FIFO'
,
help='Lot selection method (default: FIFO)')
args = parser.parse_args()
# Convert relative path to absolute path
args.beancount_file = os.path.abspath(args.beancount_file)
# Check if file exists
if not os.path.exists(args.beancount_file):
print(f"Error: Beancount file '{args.beancount_file}' does not
exist")
print(f"Current directory: {os.getcwd()}")
print("Available files:")
for f in os.listdir('.'):
if f.endswith('.beancount') or f.endswith('.bean'):
print(f" {f}")
return
# Get lots from source account
print(f"Querying lots in {args.from_account} for {args.currency}...")
lots = get_account_lots(args.beancount_file, args.from_account, args
.currency)
if not lots:
print(f"No lots found in {args.from_account} for {args.currency}")
return
print(f"Found {len(lots)} lots:")
total_available = sum(lot['units'] for lot in lots)
print(f"Total available: {total_available} {args.currency}")
for i, lot in enumerate(lots):
label_display = f' ("{lot["lot_label"]}")' if lot['lot_label'] else
''
print(f" Lot {i+1}: {lot['units']} {args.currency} @ {lot['cost']}
{lot['cost_currency']} ({lot['acquisition_date']}){label_display}")
# Check if we have enough
transfer_amount = Decimal(args.amount)
if transfer_amount > total_available:
print(f"Error: Requested {transfer_amount} {args.currency} but only
{total_available} available")
return
# Select lots using specified method
if args.method == 'LIFO':
lots.reverse() # Reverse for LIFO
selected_lots = select_lots_fifo(lots, transfer_amount)
print(f"\nSelected lots for transfer ({args.method}):")
for i, lot in enumerate(selected_lots):
label_display = f' ("{lot["lot_label"]}")' if lot['lot_label'] else
''
print(f" {lot['units']} {args.currency} @ {lot['cost']} {lot[
'cost_currency']} ({lot['acquisition_date']}){label_display}")
# Generate transaction
transaction = generate_transfer_transaction(
args.date, args.from_account, args.to_account,
args.currency, args.description, selected_lots
)
print(f"\nGenerated transaction:")
print("=" * 80)
print(transaction)
print("=" * 80)
# Save to file option
save = input("\nSave to file? (y/N): ").lower().strip()
if save == 'y':
filename = f"transfer_{args.currency}_{args.date.replace('-', '')}
.beancount"
with open(filename, 'w') as f:
f.write(transaction + '\n')
print(f"Saved to {filename}")
if __name__ == '__main__':
if len(sys.argv) == 1:
# Example usage
print("Example usage:")
print('python transfer_generator_v2.py ledger.beancount
"Assets:CryptoExchange" "Assets:PrivateWallet" 20 BSV "Transfer to cold
storage"')
print("")
print("Optional arguments:")
print(" --date YYYY-MM-DD (default: today)")
print(" --method FIFO|LIFO (default: FIFO)")
sys.exit(1)
main()
```
It works, but there's still a bunch of manual work when I use it. If there
is a more elegant way to do this I would love to hear it.
-Chris
On Friday, June 27, 2025 at 7:22:46 AM UTC-4 [email protected] wrote:
> On Saturday, June 21, 2025 at 3:43:06 PM UTC+9:30 Red S wrote:
>
> Here’s a plugin
> <https://github.com/hoostus/beancount-asset-transfer-plugin> that does
> what you want. But more interestingly, the README on that page explains and
> shows you how to do it.
>
>
> Note that that plugin doesn't actually work due to beancount's loader
> running interpolation on incomplete booking entries before it runs
> transformation plugins. (See bug #1 in the project for more detail.)
>
--
You received this message because you are subscribed to the Google Groups
"Beancount" group.
To unsubscribe from this group and stop receiving emails from it, send an email
to [email protected].
To view this discussion visit
https://groups.google.com/d/msgid/beancount/4bc91995-d3f2-40d0-b5b6-fa36ddb5c5cbn%40googlegroups.com.