Skip to content

Commit

Permalink
Merge b5576e2 into e2c601f
Browse files Browse the repository at this point in the history
  • Loading branch information
carljm committed Dec 6, 2020
2 parents e2c601f + b5576e2 commit 278fca3
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 13 deletions.
49 changes: 36 additions & 13 deletions beancount_import/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,10 +467,10 @@ def combine_transactions_using_match_set(
txns: Tuple[Transaction, Transaction], is_cleared: IsClearedFunction,
match_set: PostingMatchSet) -> Transaction:
"""Combines two transactions.
Metadata is merged (it is assumed that the metadata keys, other than
ignorable ones, are disjoint).
If a cleared transaction is matched with multiple transactions (guaranteed
to be uncleared), the result is a single merged transaction.
Expand Down Expand Up @@ -699,11 +699,12 @@ def get_aggregate_posting_candidates(
3. Subsets must not contain cleared postings, or postings with a `cost` or
`price` specification, or with `MISSING` units.
4. All postings in a subset must have the same `units.currency`, and the
same sign of `units.number` (i.e. positive or negative).
4. All postings in a subset must have the same `units.currency`.
5. Subsets may not sum to zero, or contain any sub-subsets that sum to zero.
6. To limit the computational cost, subsets are limited to at most 4
elements, except that all maximal subsets are also returned.
elements, except that all same-sign maximal subsets are also returned.
The returned subsets are not, in general, disjoint.
Expand All @@ -713,21 +714,42 @@ def get_aggregate_posting_candidates(
the sum of the `units` of each posting in the subset.
"""
possible_sets = collections.OrderedDict(
) # type: Dict[Tuple[str, str, bool], List[Posting]]
) # type: Dict[Tuple[str, str], List[Posting]]
for posting in postings:
if (posting.price is not None or posting.cost is not None or
posting.units is None or posting.units is MISSING):
continue
if is_cleared(posting):
continue
possible_sets.setdefault((posting.account, posting.units.currency,
posting.units.number > ZERO),
possible_sets.setdefault((posting.account, posting.units.currency),
[]).append(posting)
results = []
max_subset_size = 4

def add_subset(account, currency, subset):
sum_to_zero = set() # type: Set[Tuple[int, ...]]

def posting_set_id(postings):
return tuple(id(x) for x in postings)

def partition(predicate, postings):
t = []
f = []
for p in postings:
if predicate(p):
t.append(p)
else:
f.append(p)
return t, f

def add_subset(account, currency, subset, check_zero=True):
total = sum(x.units.number for x in subset)
if check_zero:
if total == ZERO:
sum_to_zero.add(posting_set_id(subset))
return
for subsubset_size in range(2, len(subset)):
for subsubset in itertools.combinations(subset, subsubset_size):
if posting_set_id(subsubset) in sum_to_zero:
return
aggregate_posting = Posting(
account=account,
units=Amount(currency=currency, number=total),
Expand All @@ -737,11 +759,12 @@ def add_subset(account, currency, subset):
meta=None)
results.append((aggregate_posting, tuple(subset)))

for (account, currency, _), posting_list in possible_sets.items():
for (account, currency), posting_list in possible_sets.items():
if len(posting_list) == 1:
continue
if len(posting_list) > max_subset_size:
add_subset(account, currency, posting_list)
for samesign_list in partition(lambda p: p.units.number > ZERO, posting_list):
if len(samesign_list) > max_subset_size:
add_subset(account, currency, samesign_list, check_zero=False)
for subset_size in range(
2, min(len(posting_list) + 1, max_subset_size + 1)):
for subset in itertools.combinations(posting_list, subset_size):
Expand Down
135 changes: 135 additions & 0 deletions beancount_import/matching_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,3 +737,138 @@ def test_nonmatch_fuzzy_amount():
note: "B"
Expenses:FIXME -99.98 USD
""")

def test_match_grouped_differing_signs():
# Can group postings of differing signs to make a match.
assert_match(
pending_candidate="""
2020-12-05 * "Narration"
note1: "A"
Expenses:FIXME:A 1.23 USD
note1: "B"
Expenses:FIXME:A -0.12 USD
note1: "C"
Assets:Bank -1.11 USD
note2: "A"
""",
journal="""
2020-12-05 * "Narration"
note3: "E"
Assets:Bank -1.11 USD
cleared: TRUE
note3: "A"
Expenses:Foo 1.11 USD
note4: "A"
""",
matches="""
2020-12-05 * "Narration"
note1: "A"
note3: "E"
Assets:Bank -1.11 USD
cleared: TRUE
note2: "A"
note3: "A"
Expenses:Foo 1.23 USD
note1: "B"
note4: "A"
Expenses:Foo -0.12 USD
note1: "C"
note4: "A"
""",
)

def test_match_grouped_differing_signs_sum_zero():
# Cannot make a matching group that contains canceling transactions.
assert_match(
pending_candidate="""
2020-12-05 * "Narration"
note1: "A"
Expenses:FIXME 1.35 USD
note1: "B"
Expenses:FIXME 2.90 USD
note1: "C"
Expenses:FIXME -1.35 USD
note1: "D"
Expenses:FIXME -2.90 USD
note1: "E"
""",
journal="""
2020-12-05 * "Narration"
note3: "A"
Assets:Bank -1.35 USD
cleared: TRUE
note2: "A"
Expenses:Foo 1.35 USD
note3: "B"
""",
matches="""
2020-12-05 * "Narration"
note1: "A"
note3: "A"
Assets:Bank -1.35 USD
cleared: TRUE
note1: "D"
note2: "A"
Expenses:Foo 1.35 USD
note1: "B"
note3: "B"
Expenses:FIXME 2.90 USD
note1: "C"
Expenses:FIXME -2.90 USD
note1: "E"
""",
)

def test_match_grouped_maximal_differing_signs():
# Maximal matching groups are still per-sign.
assert_match(
pending_candidate="""
2020-12-05 * "Narration"
note1: "A"
Expenses:FIXME 1 USD
note1: "B"
Expenses:FIXME 2 USD
note1: "C"
Expenses:FIXME 3 USD
note1: "D"
Expenses:FIXME 4 USD
note1: "E"
Expenses:FIXME 5 USD
note1: "F"
Expenses:FIXME -15 USD
note1: "G"
""",
journal="""
2020-12-05 * "Narration"
note2: "A"
Assets:Bank -15 USD
cleared: TRUE
note2: "B"
Expenses:Foo 15 USD
note2: "C"
""",
matches="""
2020-12-05 * "Narration"
note1: "A"
note2: "A"
Assets:Bank -15 USD
cleared: TRUE
note1: "G"
note2: "B"
Expenses:Foo 1 USD
note1: "B"
note2: "C"
Expenses:Foo 2 USD
note1: "C"
note2: "C"
Expenses:Foo 3 USD
note1: "D"
note2: "C"
Expenses:Foo 4 USD
note1: "E"
note2: "C"
Expenses:Foo 5 USD
note1: "F"
note2: "C"
""",
)

0 comments on commit 278fca3

Please sign in to comment.