Skip to content

Core Accounting Plugins

beancount-blue provides four specialized plugins for Beancount that automate common, complex bookkeeping workflows:

  1. Amortize: Spread large or recurring expenses across multiple months.
  2. UK Capital Gains: HMRC-compliant capital gains calculations and lot adjustments.
  3. Clear Residual Lots: Clear fractional lot dust before closing investment accounts.
  4. Auto-Tagger: Automatically apply tags to transactions based on participating accounts.

Amortize

The amortize plugin aggregates expenses posted to designated accounts and spreads them evenly over a configurable number of months.

Key Features

  • Creates a monthly adjusting transaction that debits/credits the expense account and offsets against an Equity:Amortization holding account.
  • Tags all generated adjusting transactions with #amort.
  • If a transaction has a tag (e.g. #holiday-2026), the adjustments are grouped by that tag, keeping distinct events separated.
  • Fully configurable amortization period (in months) and decimal precision per account.

Configuration Example

Add the plugin to your ledger:

plugin "beancount_blue.amortize" "{
    'accounts': {
        'Expenses:Renovation': {
            'months': 12,
            'decimals': 2,
        },
        'Expenses:Insurance': {
            'months': 12,
            'decimals': 2,
        }
    }
}"

2023-01-15 * "Annual Home Insurance"
  Expenses:Insurance  1200.00 GBP
  Assets:Bank        -1200.00 GBP

Resulting Behavior

The £1,200.00 expense recorded in January is amortized over 12 months (£100.00/month from January to December) using an equity holding account (Equity:Amortization:Insurance), ensuring your monthly income and expense reports reflect true monthly consumption.


UK Capital Gains

The calc_gains plugin automates capital gains tax calculations for investment and trading portfolios according to UK HMRC regulations.

Key Features

  • Section 104 Holding Pools: Implements the average-cost pool method (cost_avg) for shares and securities.
  • Lot Rebalancing (lots_adjust): Automatically liquidates previous inventory positions and generates matching capital gains/loss postings to your designated income or equity account.
  • Cost Basis Tracking: Accurately adjusts the cost basis of holdings following acquisitions and redemptions.

Configuration Example

option "booking_method" "NONE"

plugin "beancount_blue.calc_gains" "{
    'accounts': {
        'Assets:Investments:GIA': {
            'method': 'cost_avg',
            'counterAccount': 'Income:CapitalGains',
            'lots_adjust': True
        }
    }
}"

2023-01-25 * "Buy Share A"
  Assets:Investments:GIA   10 VUSA {{ 50.00 GBP }}
  Assets:Bank             -50.00 GBP

2023-01-26 * "Buy Share A"
  Assets:Investments:GIA   10 VUSA {{ 90.00 GBP }}
  Assets:Bank             -90.00 GBP

2023-02-25 * "Sell Share A"
  Assets:Investments:GIA   -4 VUSA {{ 40.00 GBP }}
  Assets:Bank              40.00 GBP

Clear Residual Lots

When using Beancount's NONE booking method, selling assets might not clear the original purchase lots with exact floating-point precision. This leaves tiny fractional "dust" positions (e.g. 0.000001 LOT) that prevent accounts from being cleanly closed and clutter Fava reports.

The clear_residual_lots plugin detects pending account closures and automatically injects a balancing transaction immediately before the close directive to zero out any residual lots.

Configuration Example

Pass the balancing account name as the plugin argument:

plugin "beancount_blue.clear_residual_lots" "Equity:Gains"

2020-01-01 open Assets:Investments:Crypto
...
2024-12-30 close Assets:Investments:Crypto

Auto-Tagger

The tag plugin automatically applies tags to transactions whenever specific accounts are involved, simplifying filtering, reporting, and categorization.

Configuration Example

plugin "beancount_blue.tag" "{
    'accounts': {
        'Expenses:Groceries': 'groceries',
        'Expenses:Entertainment': 'fun',
        'Income:Salary': 'taxable'
    }
}"

2024-03-01 * "Tesco"
  Expenses:Groceries  45.00 GBP
  Assets:Current:Monzo -45.00 GBP
; This transaction will automatically receive the #groceries tag.

API Reference

beancount_blue.amortize

Amortize expenses over a period of months.

This plugin will amortize all transactions in an Expense account in one aggregate transaction across multiple months.

Key features
  • It creates a single transaction each month to adjust the net expense to the amortized amount. It uses the Equity account if it needs to adjust the net expense over the time period.
  • It tags all adjustments with #amort so they can be filter out all amortization adjustments.
  • If the transaction has a tag, then the adjustments grouped by the tag and have both #amort and the transaction tag. This allows you to divide up different holidays by tag, for example.
  • You can configure the decimals for rounding and number of months.

This is best explained through a demonstration.

Example book:

; Configure the Expenses:Renovation account to be amortized over 12 months.
;
; It will use the Equity:Amortization:Renovation account as the holding account.
;
plugin "beancount_blue.amortize" "{
        'accounts': {
                'Expenses:Renovation': {
                    'months': 12,
                    'decimals': 2,
                },
        }
}"

2023-01-15 * "Assorted Purchase"
  Expenses:Renovation  1000.00 GBP
  Assets:Bank         -1000.00 GBP

2023-01-25 * "Assorted Purchase 2"
  Expenses:Renovation  200.00 GBP
  Assets:Bank         -200.00 GBP

2023-02-15 * "Assorted Purchase 3"
  Expenses:Renovation  360.00 GBP
  Assets:Bank         -360.00 GBP
What will happen as a result of the above
  • The first two transactions in January are aggregated (1200 GBP) and then divided up over 12 months, so 100 GBP a month from Jan 2023 to Dec 2023.
  • The transaction in February is divided up over 12 months, so 30 GBP a month from Feb 2023 to Jan 2024.

amortize(entries, _, config_str)

Amortize expenses over a period of months.

This function is the entry point for the Beancount plugin. It takes the existing entries, the Beancount options, and a configuration string.

The configuration string should be a Python dictionary literal that specifies which accounts to amortize and over how many months.

Example configuration:

.. code-block:: beancount

plugin "beancount_blue.amortize" "{
    'accounts': {
        'Expenses:Software': {'months': 12},
        'Expenses:Subscriptions': {'months': 12},
    }
}"

Parameters:

Name Type Description Default
entries Entries

A list of beancount entries.

required
_ Any

The Beancount options map (not used).

required
config_str str

A string containing the configuration for the plugin.

required

Returns:

Type Description
tuple[Entries, list[AmortizeError]]

A tuple of the modified entries and a list of errors.

Source code in beancount_blue/amortize.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def amortize(entries: Entries, _: Any, config_str: str) -> tuple[Entries, list[AmortizeError]]:
    """Amortize expenses over a period of months.

    This function is the entry point for the Beancount plugin. It takes the
    existing entries, the Beancount options, and a configuration string.

    The configuration string should be a Python dictionary literal that specifies
    which accounts to amortize and over how many months.

    Example configuration:

    .. code-block:: beancount

        plugin "beancount_blue.amortize" "{
            'accounts': {
                'Expenses:Software': {'months': 12},
                'Expenses:Subscriptions': {'months': 12},
            }
        }"

    Args:
        entries: A list of beancount entries.
        _: The Beancount options map (not used).
        config_str: A string containing the configuration for the plugin.

    Returns:
        A tuple of the modified entries and a list of errors.
    """

    config = ast.literal_eval(config_str)
    accounts = config.get("accounts", None)
    if not accounts:
        return entries, [AmortizeError(source=None, message="no accounts defined", entry=None)]

    new_entries = entries[:]

    errors: list[AmortizeError] = []
    for config_acct, acct_config in accounts.items():
        if config_acct.startswith("Expenses:"):
            acct = config_acct.replace("Expenses:", "Equity:Amortization:")
        elif config_acct.startswith("Income:"):
            acct = config_acct.replace("Income:", "Equity:Amortization:")
        else:
            raise Exception(f"amortize requires Expenses: or Income: accounts, got {config_acct}")  # noqa: TRY002, TRY003
        counteraccount = config_acct
        months = acct_config.get("months", None)
        if months is None:
            errors.append(AmortizeError(source=None, message=f"no months for account {config_acct}", entry=None))
            continue
        decimals = acct_config.get("decimals", 2)

        # Collect all of the trading histories
        cashflow: dict[tuple[str, str], defaultdict[date, Decimal]] = {}
        src = {}
        for _, entry in enumerate(entries):
            if not isinstance(entry, Transaction):
                continue
            for _, post in enumerate(entry.postings):
                if post.account != config_acct:
                    continue
                if len(entry.tags) > 1:
                    errors.append(AmortizeError(entry=entry, message="must be zero or one tag only", source=None))
                    continue
                if not post.units or not post.units.number:
                    errors.append(
                        AmortizeError(entry=entry, message="cannot amortize a posting without units", source=None)
                    )
                    continue
                tag = next(iter(entry.tags)) if entry.tags else ""
                key = (tag, post.units.currency)
                if key not in cashflow:
                    cashflow[key] = defaultdict(Decimal)
                    src[key] = {
                        "lineno": entry.meta["lineno"],
                        "filename": entry.meta["filename"],
                    }
                remaining_amt = -1 * post.units.number
                amort_months = months
                if "amortization_months" in entry.meta:
                    amort_months = int(entry.meta["amortization_months"])
                quantizer = Decimal("1e-" + str(decimals))
                for i in range(amort_months):
                    v = (remaining_amt / (amort_months - i)).quantize(quantizer)
                    cashflow_amt = v
                    cashflow_date = (
                        entry.date + relativedelta.relativedelta(months=i) + relativedelta.relativedelta(day=31)
                    )
                    cashflow[key][cashflow_date] += cashflow_amt + (post.units.number if i == 0 else 0)
                    remaining_amt -= cashflow_amt

        for key, amts in cashflow.items():
            narration = "Amortization Adjustment"
            if key[0]:
                narration = narration + f" for {key[0]}"
            for ndate, amt in amts.items():
                if amt == Decimal(0):
                    continue
                new_entries.append(
                    Transaction(
                        date=ndate,
                        meta=src[key],
                        flag=FLAG_OKAY,
                        payee="Amortized",
                        narration=narration,
                        tags=frozenset({key[0], "amort"}) if key[0] else frozenset({"amort"}),
                        links=frozenset(),
                        postings=[
                            Posting(acct, Amount(number=amt, currency=key[1]), None, None, None, {}),
                            Posting(counteraccount, Amount(number=-1 * amt, currency=key[1]), None, None, None, {}),
                        ],
                    )
                )

    return new_entries, errors

beancount_blue.calc_gains

Calculate capital gains.

Account

An account that holds securities.

Source code in beancount_blue/calc_gains.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
class Account:
    """An account that holds securities."""

    account: str
    config: dict[str, Any]
    cost_currency: dict[str, str]
    history: dict[str, list[Trade]]
    last_balance: dict[str, Decimal]
    method: Callable[[list[Trade]], list[Decimal]]
    cacct: str
    lots_adjust: bool

    def __init__(self, account: str, config: dict[str, Any]):
        """Initialize the account.

        Args:
            account: The name of the account.
            config: The configuration for the account.
        """
        self.account = account
        self.config = config
        self.cost_currency = {}
        self.history = {}
        self.last_balance = {}

        if self.config.get("method", "") not in METHODS:
            raise ValueError(f"Account {self.account} has no valid method, must be one of {', '.join(METHODS.keys())}")  # noqa: TRY003

        self.method = METHODS[self.config.get("method", "")]

        if "counterAccount" not in self.config:
            raise ValueError(f"Account {self.account} has no valid counter account")  # noqa: TRY003

        self.cacct = str(self.config["counterAccount"])

        self.lots_adjust = bool(self.config.get("lots_adjust", False))

    def process(self, entries: Entries):
        """Process the entries as configured.

        Args:
            entries: The set of entries
        """
        # Add in counteraccount configuration
        for trades in self.history.values():
            inventory = Inventory()
            adjs = self.method(trades)
            # if self.lots_adjust:
            #    print(f"Calculated cost consideration: {adjs}")
            for trade in trades:
                trans = entries[trade.postingId[0]]
                if not isinstance(trans, Transaction):
                    continue

                new_cost_consideration = adjs.pop(0) if trade.realizing else trade.price * trade.units

                # Liquidiate previous holdings
                liquidated_balance = Decimal(0)
                liquidated_cost = Decimal(0)
                if self.lots_adjust:
                    for pos in inventory.get_positions():
                        if not pos.cost or not pos.units.number or pos.units.number == ZERO:
                            continue
                        liquidated_cost += pos.cost.number * pos.units.number
                        liquidated_balance += pos.units.number
                        trans.postings.append(
                            Posting(self.account, -pos.units, pos.cost, None, None, None),
                        )
                    inventory = Inventory()

                # New cost basis after realizing trade - bring in a new holding
                posting = trans.postings[trade.postingId[1]]
                if posting.units and posting.cost:
                    trans.postings[trade.postingId[1]] = posting._replace(
                        units=posting.units._replace(
                            number=liquidated_balance + trade.units,
                        ),
                        cost=posting.cost._replace(
                            number=(new_cost_consideration / trade.units),
                            date=trans.date,
                        ),
                    )
                inventory.add_position(trans.postings[trade.postingId[1]])

                # Calculate the counteramount
                camt = trade.price * trade.units
                camt -= (liquidated_balance + trade.units) * (new_cost_consideration / trade.units)
                camt += liquidated_cost
                if camt != Decimal(0):
                    cost = trans.postings[trade.postingId[1]].cost
                    if cost and cost.currency:
                        trans.postings.append(
                            Posting(
                                account=self.cacct,
                                units=Amount(number=camt, currency=cost.currency),
                                cost=None,
                                price=None,
                                flag=None,
                                meta={"note": "full_adjustment" if self.lots_adjust else "part_adjust"},
                            )
                        )

    def add_posting(self, postingId: PostingID, entry: Transaction, posting: Posting) -> str | None:
        """Add a posting to the account.

        Args:
            postingId: The ID of the posting.
            entry: The entry containing the posting.
            posting: The posting to add.

        Returns:
            An error message if there was an error, otherwise None.
        """
        if posting.cost is None:
            return f"posting on {entry.date} in {posting.account} has no cost"
        if posting.units is None or posting.units.number is None:
            return f"posting on {entry.date} in {posting.account} has no units"

        # Validate the cost currency for this asset
        asset_currency = posting.units.currency
        cost_currency = posting.cost.currency
        if asset_currency not in self.cost_currency:
            if cost_currency:
                self.cost_currency[asset_currency] = cost_currency
        elif self.cost_currency[asset_currency] != cost_currency:
            return (
                f"account {self.account} has inconsistent cost currencies for "
                f"{asset_currency}: {self.cost_currency[asset_currency]} and {cost_currency}"
            )

        if posting.cost.date and posting.cost.date != entry.date:
            return f"cost date {posting.cost.date} is different from transaction date {entry.date}"

        # Get the last balance
        balance = self.last_balance.get(asset_currency, Decimal(0))

        # Determine if realizing
        # print(posting)
        if (balance > 0 and posting.units.number < 0) or (balance < 0 and posting.units.number > 0):
            realizing = True
        else:
            realizing = False

        # Add the trade
        price = posting.cost.number_per if isinstance(posting.cost, CostSpec) else posting.cost.number
        if price is None:
            return f"cost {posting.cost} has no price!"

        self.history.setdefault(posting.units.currency, []).append(
            Trade(
                postingId=postingId,
                date=entry.date,
                balance=balance,
                units=posting.units.number,
                price=price,
                consideration=posting.units.number * price,
                realizing=realizing,
            )
        )

        # Update the last balance
        self.last_balance[posting.units.currency] = balance + posting.units.number

        return None

__init__(account, config)

Initialize the account.

Parameters:

Name Type Description Default
account str

The name of the account.

required
config dict[str, Any]

The configuration for the account.

required
Source code in beancount_blue/calc_gains.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def __init__(self, account: str, config: dict[str, Any]):
    """Initialize the account.

    Args:
        account: The name of the account.
        config: The configuration for the account.
    """
    self.account = account
    self.config = config
    self.cost_currency = {}
    self.history = {}
    self.last_balance = {}

    if self.config.get("method", "") not in METHODS:
        raise ValueError(f"Account {self.account} has no valid method, must be one of {', '.join(METHODS.keys())}")  # noqa: TRY003

    self.method = METHODS[self.config.get("method", "")]

    if "counterAccount" not in self.config:
        raise ValueError(f"Account {self.account} has no valid counter account")  # noqa: TRY003

    self.cacct = str(self.config["counterAccount"])

    self.lots_adjust = bool(self.config.get("lots_adjust", False))

add_posting(postingId, entry, posting)

Add a posting to the account.

Parameters:

Name Type Description Default
postingId PostingID

The ID of the posting.

required
entry Transaction

The entry containing the posting.

required
posting Posting

The posting to add.

required

Returns:

Type Description
str | None

An error message if there was an error, otherwise None.

Source code in beancount_blue/calc_gains.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def add_posting(self, postingId: PostingID, entry: Transaction, posting: Posting) -> str | None:
    """Add a posting to the account.

    Args:
        postingId: The ID of the posting.
        entry: The entry containing the posting.
        posting: The posting to add.

    Returns:
        An error message if there was an error, otherwise None.
    """
    if posting.cost is None:
        return f"posting on {entry.date} in {posting.account} has no cost"
    if posting.units is None or posting.units.number is None:
        return f"posting on {entry.date} in {posting.account} has no units"

    # Validate the cost currency for this asset
    asset_currency = posting.units.currency
    cost_currency = posting.cost.currency
    if asset_currency not in self.cost_currency:
        if cost_currency:
            self.cost_currency[asset_currency] = cost_currency
    elif self.cost_currency[asset_currency] != cost_currency:
        return (
            f"account {self.account} has inconsistent cost currencies for "
            f"{asset_currency}: {self.cost_currency[asset_currency]} and {cost_currency}"
        )

    if posting.cost.date and posting.cost.date != entry.date:
        return f"cost date {posting.cost.date} is different from transaction date {entry.date}"

    # Get the last balance
    balance = self.last_balance.get(asset_currency, Decimal(0))

    # Determine if realizing
    # print(posting)
    if (balance > 0 and posting.units.number < 0) or (balance < 0 and posting.units.number > 0):
        realizing = True
    else:
        realizing = False

    # Add the trade
    price = posting.cost.number_per if isinstance(posting.cost, CostSpec) else posting.cost.number
    if price is None:
        return f"cost {posting.cost} has no price!"

    self.history.setdefault(posting.units.currency, []).append(
        Trade(
            postingId=postingId,
            date=entry.date,
            balance=balance,
            units=posting.units.number,
            price=price,
            consideration=posting.units.number * price,
            realizing=realizing,
        )
    )

    # Update the last balance
    self.last_balance[posting.units.currency] = balance + posting.units.number

    return None

process(entries)

Process the entries as configured.

Parameters:

Name Type Description Default
entries Entries

The set of entries

required
Source code in beancount_blue/calc_gains.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def process(self, entries: Entries):
    """Process the entries as configured.

    Args:
        entries: The set of entries
    """
    # Add in counteraccount configuration
    for trades in self.history.values():
        inventory = Inventory()
        adjs = self.method(trades)
        # if self.lots_adjust:
        #    print(f"Calculated cost consideration: {adjs}")
        for trade in trades:
            trans = entries[trade.postingId[0]]
            if not isinstance(trans, Transaction):
                continue

            new_cost_consideration = adjs.pop(0) if trade.realizing else trade.price * trade.units

            # Liquidiate previous holdings
            liquidated_balance = Decimal(0)
            liquidated_cost = Decimal(0)
            if self.lots_adjust:
                for pos in inventory.get_positions():
                    if not pos.cost or not pos.units.number or pos.units.number == ZERO:
                        continue
                    liquidated_cost += pos.cost.number * pos.units.number
                    liquidated_balance += pos.units.number
                    trans.postings.append(
                        Posting(self.account, -pos.units, pos.cost, None, None, None),
                    )
                inventory = Inventory()

            # New cost basis after realizing trade - bring in a new holding
            posting = trans.postings[trade.postingId[1]]
            if posting.units and posting.cost:
                trans.postings[trade.postingId[1]] = posting._replace(
                    units=posting.units._replace(
                        number=liquidated_balance + trade.units,
                    ),
                    cost=posting.cost._replace(
                        number=(new_cost_consideration / trade.units),
                        date=trans.date,
                    ),
                )
            inventory.add_position(trans.postings[trade.postingId[1]])

            # Calculate the counteramount
            camt = trade.price * trade.units
            camt -= (liquidated_balance + trade.units) * (new_cost_consideration / trade.units)
            camt += liquidated_cost
            if camt != Decimal(0):
                cost = trans.postings[trade.postingId[1]].cost
                if cost and cost.currency:
                    trans.postings.append(
                        Posting(
                            account=self.cacct,
                            units=Amount(number=camt, currency=cost.currency),
                            cost=None,
                            price=None,
                            flag=None,
                            meta={"note": "full_adjustment" if self.lots_adjust else "part_adjust"},
                        )
                    )

GainsCalculatorError

Bases: NamedTuple

An error that occurred during capital gains calculation.

Source code in beancount_blue/calc_gains.py
33
34
35
36
37
38
class GainsCalculatorError(NamedTuple):
    """An error that occurred during capital gains calculation."""

    source: Meta
    message: str
    entry: object

Trade

Bases: NamedTuple

A trade in a security.

Source code in beancount_blue/calc_gains.py
21
22
23
24
25
26
27
28
29
30
class Trade(NamedTuple):
    """A trade in a security."""

    postingId: PostingID
    balance: Decimal  # total units (before this trade)
    date: datetime.date  # date of trade
    units: Decimal  # units of trade (+/-)
    price: Decimal  # price of trade
    consideration: Decimal  # units * price
    realizing: bool  # whether units brings balance closer to zero

calc_gains(entries, _, config_str)

Calculate capital gains for UK tax purposes.

Parameters:

Name Type Description Default
entries Entries

A list of beancount entries.

required
config_str str

A string containing the configuration for the plugin.

required

Returns:

Type Description
tuple[list[Directive], list[GainsCalculatorError]]

A tuple of the modified entries and a list of errors.

Source code in beancount_blue/calc_gains.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def calc_gains(entries: Entries, _, config_str: str) -> tuple[list[Directive], list[GainsCalculatorError]]:
    """Calculate capital gains for UK tax purposes.

    Args:
        entries: A list of beancount entries.
        config_str: A string containing the configuration for the plugin.

    Returns:
        A tuple of the modified entries and a list of errors.
    """
    accounts: dict[str, Account] = {}

    config = ast.literal_eval(config_str)
    for acct, acct_config in config.get("accounts", {}).items():
        accounts[acct] = Account(acct, acct_config)

    errors: list[GainsCalculatorError] = []

    # Collect all of the trading histories
    for transId, entry in enumerate(entries):
        if not isinstance(entry, Transaction):
            continue
        for postId, post in enumerate(entry.postings):
            if post.account not in accounts:
                continue
            if not post.cost and not post.price:
                continue
            if not post.cost:
                errors.append(GainsCalculatorError(source=entry.meta, message="missing cost", entry=entry))
                continue
            err_msg = accounts[post.account].add_posting((transId, postId), entry, post)
            if err_msg:
                errors.append(GainsCalculatorError(source=entry.meta, message=err_msg, entry=entry))

    # Apply adjustments to the entries
    new_entries = entries.copy()

    # Process accounts
    for account in accounts.values():
        account.process(new_entries)

    return new_entries, errors

get_realizing_cost_consideration(trades)

Calculate the average cost of a list of trades.

This function implements the "average cost" method of calculating capital
gains. It averages the cost of all lots purchased and uses that average

cost to determine the gain or loss on a sale.

Args:
    trades: A list of trades.

Returns:
    A list of cost basis for all realizing trades.
Source code in beancount_blue/calc_gains.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def get_realizing_cost_consideration(trades: list[Trade]) -> list[Decimal]:
    """Calculate the average cost of a list of trades.

        This function implements the "average cost" method of calculating capital
        gains. It averages the cost of all lots purchased and uses that average
    cost
        to determine the gain or loss on a sale.

        Args:
            trades: A list of trades.

        Returns:
            A list of cost basis for all realizing trades.
    """

    total_units = Decimal(0)
    total_cost = Decimal(0)
    cost_consideration: list[Decimal] = []
    for _, trade in enumerate(trades):
        if trade.realizing:
            cost_consideration.append(trade.units * (total_cost / total_units))
        total_cost += trade.price * trade.units
        total_units += trade.units
    return cost_consideration

beancount_blue.clear_residual_lots

Automatically clear residual lots from closed accounts.

This plugin is designed to solve a specific problem that can occur when using the 'NONE' booking method in Beancount. With this method, sales of assets do not always perfectly clear the original purchase lots, which can leave small residual amounts in the account's inventory. This can cause issues with some tools, like Fava, which may continue to display accounts that should be closed.

This plugin solves this by creating a balancing transaction to clear out any remaining lots just before the account is closed.

Example configuration:

.. code-block:: beancount

plugin "beancount_blue.clear_residual_lots" "Equity:Gains"

clear_residual_lots(entries, _, config_str)

The main plugin function.

Parameters:

Name Type Description Default
entries Entries

The full list of Beancount entries.

required
_ Any

The Beancount options map.

required
config_str str

The string provided in the plugin configuration, which should be the name of the balancing account.

required

Returns: A tuple of (new_entries, errors).

Source code in beancount_blue/clear_residual_lots.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def clear_residual_lots(entries: Entries, _: Any, config_str: str) -> tuple[Entries, list[Any]]:
    """
    The main plugin function.

    Args:
        entries:    The full list of Beancount entries.
        _:          The Beancount options map.
        config_str: The string provided in the plugin configuration, which
                    should be the name of the balancing account.
    Returns:
        A tuple of (new_entries, errors).
    """
    if not config_str:
        raise ValueError(  # noqa: TRY003
            "Plugin 'clear_residual_lots' requires a balancing account "
            "to be specified in the configuration string. \n"
            'Example: plugin "beancount_blue.clear_residual_lots" "Equity:Gains"'
        )

    balance_account = config_str

    # Find all closed accounts
    closed_accounts = {entry.account: entry.date for entry in entries if isinstance(entry, data.Close)}

    # Nothing closed -- nothing to do
    if not closed_accounts:
        return entries, []

    # Calculate all residual inventory across closed accounts
    residual_inventories: defaultdict[str, inventory.Inventory] = defaultdict(inventory.Inventory)
    for entry in entries:
        if isinstance(entry, data.Transaction):
            for posting in entry.postings:
                if posting.account in closed_accounts:
                    residual_inventories[posting.account].add_position(posting)

    # Generate balancing transactions for accounts with residuals.
    balancing_txns: dict[str, data.Transaction] = {}
    for account, residual_inv in residual_inventories.items():
        # Only process accounts that have a non-empty inventory.
        if residual_inv.is_empty():
            continue

        postings: list[data.Posting] = []
        # Create postings to cancel out every lot in the residual inventory.
        for pos in residual_inv.get_positions():
            if pos.units.number == ZERO:
                continue

            # Add a posting to negate the residual lot.
            postings.extend([
                data.Posting(account, -pos.units, pos.cost, None, None, None),
                data.Posting(balance_account, pos.units, pos.cost, None, None, None),
            ])

        if not postings:
            continue

        close_date = closed_accounts[account]
        balancing_date = close_date - timedelta(days=1)
        meta = data.new_metadata("beancount_blue.clear_residual_lots", 0)
        narration = f"Automatically clear residual lots from closed account: {account}"

        balancing_txns[account] = data.Transaction(
            meta, balancing_date, FLAG_OKAY, "", narration, data.EMPTY_SET, data.EMPTY_SET, postings
        )

    # Skip if no balancing transactions
    if not balancing_txns:
        return entries, []

    return entries + list(balancing_txns.values()), []

beancount_blue.tag

Tag transactions based on account.

This plugin automatically adds tags to transactions based on the accounts they involve. This can be useful for categorizing transactions and for generating reports.

For example, you can configure the plugin to add the tag "shopping" to any transaction that involves the account "Expenses:Shopping".

Example configuration:

.. code-block:: beancount

plugin "beancount_blue.tag" "{
    'accounts': {
        'Expenses:Shopping': 'shopping',
        'Expenses:Groceries': 'groceries'
    }
}"

tag(entries, _, config_str)

Tag transactions based on account.

This function is the entry point for the Beancount plugin. It takes the existing entries, the Beancount options, and a configuration string.

The configuration string should be a Python dictionary literal that maps account names to the tags that should be applied.

Parameters:

Name Type Description Default
entries Entries

A list of beancount entries.

required
_ Any

The Beancount options map (not used).

required
config_str str

A string containing the configuration for the plugin.

required

Returns:

Type Description
tuple[Entries, list[Any]]

A tuple of the modified entries and a list of errors.

Source code in beancount_blue/tag.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def tag(entries: Entries, _: Any, config_str: str) -> tuple[Entries, list[Any]]:
    """Tag transactions based on account.

    This function is the entry point for the Beancount plugin. It takes the
    existing entries, the Beancount options, and a configuration string.

    The configuration string should be a Python dictionary literal that maps
    account names to the tags that should be applied.

    Args:
        entries: A list of beancount entries.
        _: The Beancount options map (not used).
        config_str: A string containing the configuration for the plugin.

    Returns:
        A tuple of the modified entries and a list of errors.
    """
    config = ast.literal_eval(config_str)
    accounts = config.get("accounts", None)
    if not accounts:
        return entries, ["no accounts defined"]

    new_entries = entries[:]

    for acct, tag in accounts.items():
        for transId, entry in enumerate(new_entries):
            if not isinstance(entry, Transaction):
                continue
            if all(post.account != acct and not post.account.startswith(acct + ":") for post in entry.postings):
                continue
            new_entries[transId] = entry._replace(tags=frozenset(set(entry.tags).union([tag])))

    return new_entries, []