feat(ledger): связь перевода ZenMoney с брокерским пополнением
Случай C из плана §1.6: без него одни и те же деньги считаются дважды, а перевод на брокерский счёт выглядит расходом. match_flows — чистая функция над двумя последовательностями кандидатов, как apply() в lots.py; rebuild_flow_links её обвязка с сессией. Скоринг: та же валюта, тот же счёт, |Δ| ≤ max(1 ₽, 0,5 %), разрыв ≤ 3 РАБОЧИХ дня. Рабочих, а не календарных: деньги на брокерский счёт в субботу не приходят, и календарное окно систематически теряло бы пятничные переводы. Праздники не моделируются — лишний праздник делает матчер строже, а не наглее. Направление выбрано по данным, а не по тексту плана. План говорит «outcome на зеркальный счёт = пополнение», но зеркальный ZM-счёт это отражение брокерского кэша: деньги идут «карта → зеркало» (income на зеркале), а на брокере в тот же день deposit. На живых данных income-соглашение даёт 525 пар, обратное — 4. Для маршрута через правило broker_target, где брокера в ZenMoney нет вовсе, направление остаётся как в плане: outcome ↔ deposit. Жадность 1:1 по (дельта суммы, разрыв в днях): пара берётся, только если свободны обе стороны. Ручные линки не пересобираются — обе их стороны исключаются из пулов, иначе автоматика молча переписывала бы решение человека. Связанная транзакция получает flow_type = internal_transfer здесь, а не в classify: классификатор не может знать о линках, которых на момент его работы ещё нет.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""ZenMoney transfer <-> broker deposit matching: the pure core on synthetic candidates,
|
||||
plus one pass over the real tables."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event, make_txn
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.matching import (
|
||||
BrokerCandidate,
|
||||
FlowDirection,
|
||||
ZmCandidate,
|
||||
business_days_between,
|
||||
match_flows,
|
||||
rebuild_flow_links,
|
||||
)
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountLink,
|
||||
AccountRole,
|
||||
CashTxn,
|
||||
EventKind,
|
||||
FlowLink,
|
||||
FlowLinkKind,
|
||||
FlowType,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
BROKER = 42
|
||||
_seq = iter(range(1, 10_000))
|
||||
|
||||
|
||||
def zm(day: str, amount, *, currency="RUB", direction=FlowDirection.to_broker, account=BROKER):
|
||||
return ZmCandidate(
|
||||
cash_txn_id=next(_seq),
|
||||
date=date.fromisoformat(day),
|
||||
amount=D(amount),
|
||||
currency=currency,
|
||||
direction=direction,
|
||||
broker_account_id=account,
|
||||
route="account_link",
|
||||
)
|
||||
|
||||
|
||||
def broker(day: str, amount, *, currency="RUB", direction=FlowDirection.to_broker, account=BROKER):
|
||||
return BrokerCandidate(
|
||||
event_id=next(_seq),
|
||||
account_id=account,
|
||||
date=date.fromisoformat(day),
|
||||
amount=D(amount),
|
||||
currency=currency,
|
||||
direction=direction,
|
||||
)
|
||||
|
||||
|
||||
def test_exact_pair_matches_with_full_confidence():
|
||||
a, b = zm("2026-03-10", "10000"), broker("2026-03-10", "10000")
|
||||
|
||||
result = match_flows([a], [b])
|
||||
|
||||
assert len(result.matches) == 1
|
||||
match = result.matches[0]
|
||||
assert (match.zm, match.broker) == (a, b)
|
||||
assert match.amount_delta == D(0)
|
||||
assert match.day_gap == 0
|
||||
assert match.confidence == D(1)
|
||||
assert result.unmatched_zm == [] and result.unmatched_broker == []
|
||||
|
||||
|
||||
def test_small_difference_is_still_the_same_money():
|
||||
# 40 ₽ on 10 000 is inside 0.5 %, and a day later is inside the window
|
||||
result = match_flows([zm("2026-03-10", "10000")], [broker("2026-03-11", "9960")])
|
||||
|
||||
assert len(result.matches) == 1
|
||||
assert result.matches[0].amount_delta == D(40)
|
||||
assert result.matches[0].day_gap == 1
|
||||
assert D(0) < result.matches[0].confidence < D(1)
|
||||
|
||||
|
||||
def test_a_difference_over_the_tolerance_is_not_a_pair():
|
||||
# 60 ₽ on 10 000 is 0.6 % — past the relative tolerance, and past 1 ₽ absolute
|
||||
result = match_flows([zm("2026-03-10", "10000")], [broker("2026-03-10", "9940")])
|
||||
|
||||
assert result.matches == []
|
||||
assert len(result.unmatched_zm) == 1 and len(result.unmatched_broker) == 1
|
||||
|
||||
|
||||
def test_different_currencies_never_pair():
|
||||
result = match_flows([zm("2026-03-10", "1000", currency="USD")], [broker("2026-03-10", "1000")])
|
||||
|
||||
assert result.matches == []
|
||||
|
||||
|
||||
def test_another_broker_account_never_pairs():
|
||||
result = match_flows([zm("2026-03-10", "1000")], [broker("2026-03-10", "1000", account=99)])
|
||||
|
||||
assert result.matches == []
|
||||
|
||||
|
||||
def test_weekend_does_not_count_towards_the_gap():
|
||||
# 2026-03-13 is a Friday, 2026-03-18 the next Wednesday: Mon Tue Wed == 3 business days
|
||||
assert business_days_between(date(2026, 3, 13), date(2026, 3, 18)) == 3
|
||||
assert business_days_between(date(2026, 3, 13), date(2026, 3, 16)) == 1
|
||||
|
||||
inside = match_flows([zm("2026-03-13", "5000")], [broker("2026-03-18", "5000")])
|
||||
assert len(inside.matches) == 1
|
||||
|
||||
# one weekday further is four business days — out of the window
|
||||
outside = match_flows([zm("2026-03-13", "5000")], [broker("2026-03-19", "5000")])
|
||||
assert outside.matches == []
|
||||
|
||||
|
||||
def test_two_candidates_for_one_amount_produce_one_link():
|
||||
first, second = zm("2026-03-10", "10000"), zm("2026-03-11", "10000")
|
||||
|
||||
result = match_flows([first, second], [broker("2026-03-10", "10000")])
|
||||
|
||||
assert len(result.matches) == 1
|
||||
# the same-day candidate wins the tie: equal delta, smaller gap
|
||||
assert result.matches[0].zm is first
|
||||
assert [c.cash_txn_id for c in result.unmatched_zm] == [second.cash_txn_id]
|
||||
assert result.unmatched_broker == []
|
||||
|
||||
|
||||
def test_the_closer_amount_wins_over_the_closer_date():
|
||||
exact_later = zm("2026-03-12", "10000")
|
||||
approximate_same_day = zm("2026-03-10", "9990")
|
||||
|
||||
result = match_flows([exact_later, approximate_same_day], [broker("2026-03-10", "10000")])
|
||||
|
||||
assert result.matches[0].zm is exact_later
|
||||
|
||||
|
||||
def test_withdrawal_matches_the_other_direction_only():
|
||||
out = zm("2026-03-10", "7000", direction=FlowDirection.from_broker)
|
||||
|
||||
paired = match_flows([out], [broker("2026-03-10", "7000", direction=FlowDirection.from_broker)])
|
||||
assert len(paired.matches) == 1
|
||||
|
||||
crossed = match_flows([out], [broker("2026-03-10", "7000")])
|
||||
assert crossed.matches == []
|
||||
|
||||
|
||||
async def test_rebuild_links_a_real_transfer_and_reclassifies_it(app):
|
||||
card = await make_account(name="Карта")
|
||||
broker_account = await make_account(
|
||||
name="ИИС", kind=AccountKind.broker, role=AccountRole.investment, source="tinvest"
|
||||
)
|
||||
mirror = await make_account(
|
||||
name="ИИС (зеркало)", include_in_net_worth=False, mirror_of_account_id=broker_account
|
||||
)
|
||||
d = today_local() - timedelta(days=10)
|
||||
|
||||
linked = await make_txn(
|
||||
d, income="10000", income_account_id=mirror, outcome="10000", outcome_account_id=card
|
||||
)
|
||||
# same shape, but no broker event within reach — stays a leftover
|
||||
orphan = await make_txn(
|
||||
d - timedelta(days=60),
|
||||
income="3333",
|
||||
income_account_id=mirror,
|
||||
outcome="3333",
|
||||
outcome_account_id=card,
|
||||
)
|
||||
event_id = await make_event(
|
||||
d + timedelta(days=1), account_id=broker_account, kind=EventKind.deposit, amount="10000"
|
||||
)
|
||||
# a withdrawal nobody transferred back — the other kind of leftover
|
||||
await make_event(d, account_id=broker_account, kind=EventKind.withdrawal, amount="-500")
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_flow_links(session)
|
||||
await session.commit()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
links = (await session.execute(select(FlowLink))).scalars().all()
|
||||
assert len(links) == 1
|
||||
link = links[0]
|
||||
assert (link.cash_txn_id, link.event_id) == (linked, event_id)
|
||||
assert link.kind == FlowLinkKind.auto
|
||||
assert link.amount_delta == D(0)
|
||||
assert link.day_gap == 1
|
||||
|
||||
rows = (await session.execute(select(CashTxn.id, CashTxn.flow_type))).all()
|
||||
flows = {r.id: r.flow_type for r in rows}
|
||||
assert flows[linked] == FlowType.internal_transfer
|
||||
# an unmatched transfer keeps whatever `classify` decided; here nothing ran, so the
|
||||
# default stands — the matcher must not touch rows it cannot explain
|
||||
assert flows[orphan] == FlowType.other
|
||||
|
||||
|
||||
async def test_account_link_maps_an_account_without_a_mirror(app):
|
||||
card = await make_account(name="Карта")
|
||||
broker_account = await make_account(
|
||||
name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, source="tinvest"
|
||||
)
|
||||
zm_twin = await make_account(name="Брокерский (ZM)")
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(AccountLink(zm_account_id=zm_twin, broker_account_id=broker_account))
|
||||
await session.commit()
|
||||
|
||||
d = today_local() - timedelta(days=5)
|
||||
txn_id = await make_txn(
|
||||
d, income="25000", income_account_id=zm_twin, outcome="25000", outcome_account_id=card
|
||||
)
|
||||
event_id = await make_event(
|
||||
d, account_id=broker_account, kind=EventKind.deposit, amount="25000"
|
||||
)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_flow_links(session)
|
||||
await session.commit()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
link = (await session.execute(select(FlowLink))).scalar_one()
|
||||
assert (link.cash_txn_id, link.event_id) == (txn_id, event_id)
|
||||
assert link.note == "account_link/to_broker"
|
||||
Reference in New Issue
Block a user