Files
Dmitry ff4d63e829 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:
классификатор не может знать о линках, которых на момент его работы ещё нет.
2026-09-18 15:05:53 +03:00

221 lines
7.7 KiB
Python

"""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"