235 lines
9.4 KiB
Python
235 lines
9.4 KiB
Python
import math
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from synthgen import dictionary
|
||
from synthgen.config import Config
|
||
from synthgen.counterparties import plan as make_plan
|
||
from synthgen.economy import last_day, months
|
||
from synthgen.group import build_group
|
||
from synthgen.intragroup import build, pair_balance, plan_pairs
|
||
from synthgen.periods import book_start, build_periods
|
||
|
||
D = Decimal
|
||
|
||
|
||
def _make(**kw):
|
||
mean = kw.pop("mean", 10)
|
||
cfg = Config(group_size=kw.pop("group_size", 5), periods=kw.pop("periods", 6), osv_counterparties_mean=mean, **kw)
|
||
g = build_group(cfg, dictionary.load())
|
||
ps = build_periods(cfg.start, cfg.periods)
|
||
books, pairs = build(cfg, g, ps, make_plan(cfg, g))
|
||
return cfg, g, ps, books, pairs
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def built():
|
||
return _make(vgo_mismatch_share=D("0.3"))
|
||
|
||
|
||
def test_plan_pairs_deterministic_and_loans():
|
||
cfg = Config(group_size=6)
|
||
g = build_group(cfg, dictionary.load())
|
||
p1, p2 = plan_pairs(cfg, g), plan_pairs(cfg, g)
|
||
assert p1 == p2
|
||
loans = [p for p in p1 if p.kind == "loan"]
|
||
assert [p.b for p in loans] == [m.code for m in g.members[1:]]
|
||
assert all(p.mismatch is None for p in p1) and all(p.a == "C01" for p in loans)
|
||
|
||
|
||
def test_pairs_mirror(built):
|
||
cfg, g, ps, books, pairs = built
|
||
clean = [p for p in pairs if not p.mismatches]
|
||
assert clean
|
||
for pair in clean:
|
||
for per in ps:
|
||
ba, bb = pair_balance(books, pair, per.end)
|
||
assert ba == -bb, (pair, per.key)
|
||
assert any(pair_balance(books, p, ps[0].end)[0] != 0 for p in clean if p.kind == "trade")
|
||
assert any(pair_balance(books, p, ps[0].end)[0] != 0 for p in clean if p.kind == "loan")
|
||
|
||
|
||
def _years(ps):
|
||
return sorted({p.year for p in ps})
|
||
|
||
|
||
def test_mismatch_share():
|
||
cfg, g, ps, books, pairs = _make(group_size=10, vgo_mismatch_share=D("0.1"), mean=5)
|
||
n = len(pairs)
|
||
for y in _years(ps):
|
||
got = sum(1 for p in pairs for m in p.mismatches if m.year == y)
|
||
assert got == max(1, math.floor(D("0.1") * n)), y
|
||
cfg0, g0, ps0, books0, pairs0 = _make(group_size=10, vgo_mismatch_share=D("0"), mean=5, periods=2)
|
||
assert all(not p.mismatches and p.mismatch is None for p in pairs0)
|
||
|
||
|
||
def test_mismatch_mix(built):
|
||
cfg, g, ps, books, pairs = built
|
||
for y in _years(ps):
|
||
mms = [m for p in pairs for m in p.mismatches if m.year == y]
|
||
assert len(mms) >= 2
|
||
small = [m for m in mms if D(1) <= m.pct <= D(4)]
|
||
large = [m for m in mms if D(8) <= m.pct <= D(25)]
|
||
assert len(small) == len(mms) // 2
|
||
assert len(large) == len(mms) - len(mms) // 2
|
||
for m in mms:
|
||
assert m.above_threshold == (m.pct > 5)
|
||
|
||
|
||
def test_mismatch_lifecycle(built):
|
||
cfg, g, ps, books, pairs = built
|
||
checked_removal = 0
|
||
for pair in pairs:
|
||
for m in pair.mismatches:
|
||
for per in ps:
|
||
if per.year != m.year or per.key < m.period_key:
|
||
continue
|
||
ba, bb = pair_balance(books, pair, per.end)
|
||
diff = (ba + bb) # зеркальные стороны: сумма = разница по модулю со знаком стороны
|
||
assert abs(diff) == m.amount, (pair, per.key)
|
||
# у стороны с проводкой модуль сальдо больше
|
||
with_entry = ba if m.side == pair.a else bb
|
||
other = bb if m.side == pair.a else ba
|
||
assert abs(with_entry) - abs(other) == m.amount
|
||
# pct — от меньшей стороны на конец периода расхождения (в следующих периодах сальдо торговли иное)
|
||
if per.key == m.period_key:
|
||
assert abs(m.pct - (m.amount / abs(other) * 100)) < D("0.01")
|
||
jan = date(m.year + 1, 1, 31)
|
||
if jan <= ps[-1].end:
|
||
ba, bb = pair_balance(books, pair, jan)
|
||
assert ba == -bb
|
||
checked_removal += 1
|
||
assert checked_removal > 0
|
||
|
||
|
||
def test_dividends_no_balance(built):
|
||
cfg, g, ps, books, pairs = built
|
||
codes = {m.code for m in g.members}
|
||
for lg in books.values():
|
||
for per in ps:
|
||
assert lg.balance(per.end, "76.03") == {}
|
||
head = books["C01"]
|
||
t = head.turnover(date(2025, 4, 1), date(2025, 6, 30), "76.03")
|
||
assert t and all(dt > 0 and ct > 0 for dt, ct in t.values())
|
||
for code in codes - {"C01"}:
|
||
t = books[code].turnover(date(2025, 4, 1), date(2025, 6, 30), "76.03")
|
||
assert t and any(ct > 0 for dt, ct in t.values())
|
||
|
||
|
||
def test_shares(built):
|
||
cfg, g, ps, books, pairs = built
|
||
start = book_start(ps)
|
||
head = books["C01"]
|
||
h58 = {an.cp: v for (a, an), v in head.balance(start, "58.01").items()}
|
||
assert set(h58) == {m.code for m in g.members[1:]}
|
||
for m in g.members[1:]:
|
||
c80 = {an.cp: v for (a, an), v in books[m.code].balance(start, "80.09").items()}
|
||
assert c80[head.company] == -h58[m.code]
|
||
if m.minority is None:
|
||
assert set(c80) == {"C01"} and m.head_share == D("1.00")
|
||
else:
|
||
assert set(c80) == {"C01", m.minority.inn}
|
||
assert c80[m.minority.inn] < 0
|
||
# миноритарий не попал к головной
|
||
assert not any(an.cp in {m.minority.inn for m in g.members if m.minority} for (_, an) in head.balance(start, "58") if an)
|
||
assert any(m.minority for m in g.members)
|
||
|
||
|
||
def test_books_still_balanced(built):
|
||
cfg, g, ps, books, pairs = built
|
||
start = book_start(ps)
|
||
for lg in books.values():
|
||
for day in (start, date(2025, 3, 31), date(2025, 12, 31), ps[-1].end):
|
||
assert sum(lg.balance(day).values()) == 0
|
||
assert lg.balance(day, "000") == {}
|
||
assert not [p for p in lg.postings if "000" in (p.dt, p.ct) and p.day > start]
|
||
for per in ps:
|
||
for acct in ("90", "91"):
|
||
assert lg.balance(per.end, acct) == {}
|
||
for y, mth in months(ps):
|
||
assert lg.balance(last_day(y, mth), "51").get(("51", None), D(0)) >= 0, (lg.company, y, mth)
|
||
|
||
|
||
def test_group_analytics_not_mixed(built):
|
||
cfg, g, ps, books, pairs = built
|
||
codes = {m.code for m in g.members}
|
||
ext = {o.inn for o in g.externals}
|
||
seen_group = seen_ext = False
|
||
for lg in books.values():
|
||
for acct, an in lg.balance(ps[-1].end):
|
||
if an is None or not an.cp:
|
||
continue
|
||
if acct.startswith(("60", "62")):
|
||
assert an.cp in codes or an.cp in ext, (acct, an)
|
||
seen_group |= an.cp in codes
|
||
seen_ext |= an.cp in ext
|
||
assert not (an.cp in codes and an.cp in ext)
|
||
assert seen_group and seen_ext
|
||
for lg in books.values():
|
||
for p in lg.postings:
|
||
for acct, an in ((p.dt, p.dt_an), (p.ct, p.ct_an)):
|
||
if an is not None and an.cp in codes:
|
||
assert acct.split(".")[0] in {"58", "60", "62", "66", "67", "76", "80", "91"}, acct
|
||
|
||
|
||
def test_loan_mismatch_on_principal(built):
|
||
cfg, g, ps, books, pairs = built
|
||
loans = [p for p in pairs if p.kind == "loan" and p.mismatches]
|
||
assert loans
|
||
for p in loans:
|
||
for m in p.mismatches:
|
||
memos = {x.memo for x in books[p.a].postings + books[p.b].postings if x.memo == "транш в пути"}
|
||
assert memos == {"транш в пути"}
|
||
ba = _sum_loan(books[p.a], m, "58.03", p.b)
|
||
assert ba != 0
|
||
for per in ps: # проценты не копятся: 76.09 на конец любого периода = 0
|
||
assert books[p.a].balance(per.end, "76.09") == {} and books[p.b].balance(per.end, "76.09") == {}
|
||
|
||
|
||
def _sum_loan(lg, m, acct, cp):
|
||
return sum(v for (a, an), v in lg.balance(date(m.year, 12, 31), acct).items() if an is not None and an.cp == cp)
|
||
|
||
|
||
def test_seller_cap(built):
|
||
cfg, g, ps, books, pairs = built
|
||
codes = {m.code for m in g.members}
|
||
for code, lg in books.items():
|
||
rev = lg.meta["model"].rev_year
|
||
for year in {p.year for p in ps}:
|
||
tot = sum(
|
||
(x.amount for x in lg.postings if x.memo == "реализация (ВГО)" and x.day.year == year),
|
||
D(0),
|
||
)
|
||
assert tot <= D("0.4") * D("1.2") * rev * D("1.1") + D("0.01"), (code, year)
|
||
assert any(x.memo == "реализация (ВГО)" for lg in books.values() for x in lg.postings)
|
||
|
||
|
||
def test_build_deterministic():
|
||
r1 = _make(vgo_mismatch_share=D("0.3"), periods=4)
|
||
r2 = _make(vgo_mismatch_share=D("0.3"), periods=4)
|
||
assert r1[4] == r2[4]
|
||
for code in r1[3]:
|
||
assert r1[3][code].postings == r2[3][code].postings
|
||
|
||
|
||
def test_mismatch_odd_count():
|
||
cfg = Config(group_size=5)
|
||
n = len(plan_pairs(cfg, build_group(cfg, dictionary.load())))
|
||
share = D("3.5") / n # k = floor(3.5) = 3: одна малая, две крупные
|
||
cfg, g, ps, books, pairs = _make(group_size=5, vgo_mismatch_share=share, periods=2)
|
||
for y in _years(ps):
|
||
mms = [m for p in pairs for m in p.mismatches if m.year == y]
|
||
assert len(mms) == 3
|
||
assert sum(1 for m in mms if m.pct < 5) == 1
|
||
assert sum(1 for m in mms if m.pct > 5) == 2
|
||
|
||
|
||
def test_edge_group_two():
|
||
cfg, g, ps, books, pairs = _make(group_size=2, periods=4, vgo_mismatch_share=D("0.5"))
|
||
assert [p.kind for p in pairs if p.kind == "loan"] == ["loan"]
|
||
for lg in books.values():
|
||
assert sum(lg.balance(ps[-1].end).values()) == 0
|
||
assert any(p.mismatches for p in pairs)
|