/beancount/plugins/sellgains.py

https://github.com/beancount/beancount · Python · 156 lines · 114 code · 17 blank · 25 comment · 17 complexity · 8fbb2f9b3f812980bebfdafcf90d4138 MD5 · raw file

  1. """A plugin that cross-checks declared gains against prices on lot sales.
  2. When you sell stock, the gains can be automatically implied by the corresponding
  3. cash amounts. For example, in the following transaction the 2nd and 3rd postings
  4. should match the value of the stock sold:
  5. 1999-07-31 * "Sell"
  6. Assets:US:BRS:Company:ESPP -81 ADSK {26.3125 USD}
  7. Assets:US:BRS:Company:Cash 2141.36 USD
  8. Expenses:Financial:Fees 0.08 USD
  9. Income:US:Company:ESPP:PnL -10.125 USD
  10. The cost basis is checked against: 2141.36 + 008 + -10.125. That is, the balance
  11. checks computes
  12. -81 x 26.3125 = -2131.3125 +
  13. 2141.36 +
  14. 0.08 +
  15. -10.125
  16. and checks that the residual is below a small tolerance.
  17. But... usually the income leg isn't given to you in statements. Beancount can
  18. automatically infer it using the balance, which is convenient, like this:
  19. 1999-07-31 * "Sell"
  20. Assets:US:BRS:Company:ESPP -81 ADSK {26.3125 USD}
  21. Assets:US:BRS:Company:Cash 2141.36 USD
  22. Expenses:Financial:Fees 0.08 USD
  23. Income:US:Company:ESPP:PnL
  24. Additionally, most often you have the sales prices given to you on your
  25. transaction confirmation statement, so you can enter this:
  26. 1999-07-31 * "Sell"
  27. Assets:US:BRS:Company:ESPP -81 ADSK {26.3125 USD} @ 26.4375 USD
  28. Assets:US:BRS:Company:Cash 2141.36 USD
  29. Expenses:Financial:Fees 0.08 USD
  30. Income:US:Company:ESPP:PnL
  31. So in theory, if the price is given (26.4375 USD), we could verify that the
  32. proceeds from the sale at the given price match non-Income postings. That is,
  33. verify that
  34. -81 x 26.4375 = -2141.4375 +
  35. 2141.36 +
  36. 0.08 +
  37. is below a small tolerance value. So this plugin does this.
  38. In general terms, it does the following: For transactions with postings that
  39. have a cost and a price, it verifies that the sum of the positions on all
  40. postings to non-income accounts is below tolerance.
  41. This provides yet another level of verification and allows you to elide the
  42. income amounts, knowing that the price is there to provide an extra level of
  43. error-checking in case you enter a typo.
  44. """
  45. __copyright__ = "Copyright (C) 2015-2017 Martin Blais"
  46. __license__ = "GNU GPLv2"
  47. import collections
  48. from beancount.core.number import ZERO
  49. from beancount.core import data
  50. from beancount.core import amount
  51. from beancount.core import convert
  52. from beancount.core import inventory
  53. from beancount.core import account_types
  54. from beancount.core import interpolate
  55. from beancount.parser import options
  56. __plugins__ = ('validate_sell_gains',)
  57. SellGainsError = collections.namedtuple('SellGainsError', 'source message entry')
  58. # A multiplier of the regular tolerance being used. This provides a little extra
  59. # space for satisfying two sets of constraints.
  60. EXTRA_TOLERANCE_MULTIPLIER = 2
  61. def validate_sell_gains(entries, options_map):
  62. """Check the sum of asset account totals for lots sold with a price on them.
  63. Args:
  64. entries: A list of directives.
  65. unused_options_map: An options map.
  66. Returns:
  67. A list of new errors, if any were found.
  68. """
  69. errors = []
  70. acc_types = options.get_account_types(options_map)
  71. proceed_types = set([acc_types.assets,
  72. acc_types.liabilities,
  73. acc_types.equity,
  74. acc_types.expenses])
  75. for entry in entries:
  76. if not isinstance(entry, data.Transaction):
  77. continue
  78. # Find transactions whose lots at cost all have a price.
  79. postings_at_cost = [posting
  80. for posting in entry.postings
  81. if posting.cost is not None]
  82. if not postings_at_cost or not all(posting.price is not None
  83. for posting in postings_at_cost):
  84. continue
  85. # Accumulate the total expected proceeds and the sum of the asset and
  86. # expenses legs.
  87. total_price = inventory.Inventory()
  88. total_proceeds = inventory.Inventory()
  89. for posting in entry.postings:
  90. # If the posting is held at cost, add the priced value to the balance.
  91. if posting.cost is not None:
  92. assert posting.price is not None
  93. price = posting.price
  94. total_price.add_amount(amount.mul(price, -posting.units.number))
  95. else:
  96. # Otherwise, use the weight and ignore postings to Income accounts.
  97. atype = account_types.get_account_type(posting.account)
  98. if atype in proceed_types:
  99. total_proceeds.add_amount(convert.get_weight(posting))
  100. # Compare inventories, currency by currency.
  101. dict_price = {pos.units.currency: pos.units.number
  102. for pos in total_price}
  103. dict_proceeds = {pos.units.currency: pos.units.number
  104. for pos in total_proceeds}
  105. tolerances = interpolate.infer_tolerances(entry.postings, options_map)
  106. invalid = False
  107. for currency, price_number in dict_price.items():
  108. # Accept a looser than usual tolerance because rounding occurs
  109. # differently. Also, it would be difficult for the user to satisfy
  110. # two sets of constraints manually.
  111. tolerance = tolerances.get(currency) * EXTRA_TOLERANCE_MULTIPLIER
  112. proceeds_number = dict_proceeds.pop(currency, ZERO)
  113. diff = abs(price_number - proceeds_number)
  114. if diff > tolerance:
  115. invalid = True
  116. break
  117. if invalid or dict_proceeds:
  118. errors.append(
  119. SellGainsError(
  120. entry.meta,
  121. "Invalid price vs. proceeds/gains: {} vs. {}".format(
  122. total_price, total_proceeds),
  123. entry))
  124. return entries, errors