xync-db 0.4.2.dev13__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/PKG-INFO +1 -1
  2. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/0001_initial.py +2 -2
  3. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/models/__init__.py +87 -1
  4. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/db/ad.py +2 -1
  5. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/.gitignore +0 -0
  6. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/README.md +0 -0
  7. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/pyproject.toml +0 -0
  8. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/__init__.py +0 -0
  9. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/config.py +0 -0
  10. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/enums.py +0 -0
  11. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/exceptions.py +0 -0
  12. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/graph.py +0 -0
  13. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/i18n.py +0 -0
  14. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/logo.png +0 -0
  15. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/CLAUDE.md +0 -0
  16. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/__init__.py +0 -0
  17. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/0002_triggers.py +0 -0
  18. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/0003_files_seed.py +0 -0
  19. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/0004_all_seed.py +0 -0
  20. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/0005_credex_del.py +0 -0
  21. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/0006_credex_orphan.py +0 -0
  22. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/0007_agent_auth_notify.py +0 -0
  23. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/__init__.py +0 -0
  24. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/seed/__init__.py +0 -0
  25. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/seed/a_actor_seed.py +0 -0
  26. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/seed/a_tg_seed.py +0 -0
  27. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/seed/b_htx_seed.py +0 -0
  28. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/migrations/models/seed/xync_files.sql +0 -0
  29. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/models/CLAUDE.md +0 -0
  30. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/py.typed +0 -0
  31. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/shared.py +0 -0
  32. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/__init__.py +0 -0
  33. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/db/__init__.py +0 -0
  34. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/db/actor.py +0 -0
  35. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/db/common.py +0 -0
  36. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/db/cred.py +0 -0
  37. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/db/order.py +0 -0
  38. {xync_db-0.4.2.dev13 → xync_db-0.4.4}/src/xync_db/typs/db/pm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xync-db
3
- Version: 0.4.2.dev13
3
+ Version: 0.4.4
4
4
  Summary: Xync DB schema: Tortoise ORM models, enums, pydantic DTOs.
5
5
  Author-email: Mike Artemiev <mixartemev@gmail.com>
6
6
  License: LicenseRef-EULA
@@ -10,7 +10,7 @@ from tortoise.fields.db_defaults import Now
10
10
  from tortoise.migrations import operations as ops
11
11
  from tortoise.migrations.constraints import CheckConstraint
12
12
  from x_auth.enums import Lang, PeerType, Role
13
- from x_model.field import DatetimeSecField, UInt1Field, UInt2Field, UInt4Field, UInt8Field, UInt16Field, UniqBinaryField
13
+ from x_model.field import DatetimeSecField, UInt1Field, UInt2Field, UInt4Field, UInt8Field, UniqBinaryField
14
14
 
15
15
  from xync_db.enums import (
16
16
  AbuserType,
@@ -1587,7 +1587,7 @@ class Migration(migrations.Migration):
1587
1587
  ("detail", fields.CharField(max_length=255)),
1588
1588
  ("name", fields.CharField(null=True, max_length=127)),
1589
1589
  ("extra", fields.CharField(null=True, max_length=255)),
1590
- ("qr", UInt16Field(null=True)),
1590
+ ("qr", fields.BinaryField(null=True)),
1591
1591
  (
1592
1592
  "person",
1593
1593
  fields.ForeignKeyField(
@@ -675,6 +675,59 @@ class User(TgUser, TsTrait):
675
675
  tx.id = UUID(bytes=tx.pack)
676
676
  return await tx.sign(sign)
677
677
 
678
+ async def _dedup_hot_by_slot(self, mads: list[MyAd]) -> list[MyAd]:
679
+ """Группирует объявы по (биржа, направление, слот) и оставляет ПО ОДНОЙ на группу. Выбор внутри
680
+ группы (стратегия выдачи hot тейкеру):
681
+ 1) сильнейший критерий — агент-мейкер, с которым у юзера ДОЛЬШЕ всего не было завершённых сделок
682
+ (ни одной → этот агент первым);
683
+ 2) при равенстве — у кого больший остаток в объявлении (quantity монеты / amount фиата).
684
+ Направление = пара+сторона; группа скоупится биржей (PairSide глобальна — одна на coin+cur для всех
685
+ бирж). Слот считает `Ad.slot_of` по `CurEx.minimum` (свой на каждую биржу+валюту). Объявы вне слотов
686
+ (битый min_fiat) отбрасываем. Ждёт mads с prefetch ad__maker__ex и ad__pair_side__pair__cur."""
687
+ if not mads:
688
+ return mads
689
+ maker_ids = {m.ad.maker_id for m in mads}
690
+ # все акторы юзера (на всех биржах) — его сторона в любой сделке (как тейкер ИЛИ как мейкер)
691
+ user_actor_ids = set(await Actor.filter(person__user_id=self.id).values_list("id", flat=True))
692
+ # давность последней ЗАВЕРШЁННОЙ сделки юзера с каждым агентом-мейкером (по агенту-actor'у)
693
+ last_deal: dict[int, datetime] = {}
694
+ if user_actor_ids:
695
+ for mk_id, tk_id, deal_ts in (
696
+ await Order.filter(status=OrderStatus.completed)
697
+ .filter(
698
+ Q(ad__maker_id__in=maker_ids, taker_id__in=user_actor_ids)
699
+ | Q(taker_id__in=maker_ids, ad__maker_id__in=user_actor_ids)
700
+ )
701
+ .values_list("ad__maker_id", "taker_id", "created_at")
702
+ ):
703
+ cp = mk_id if mk_id in maker_ids else tk_id # контрагент = сторона агента-мейкера
704
+ # сравниваем только со значением того же источника (БД) — без naive/aware-микса с sentinel
705
+ if deal_ts and ((prev := last_deal.get(cp)) is None or deal_ts > prev):
706
+ last_deal[cp] = deal_ts
707
+ minimums = {
708
+ (cx.ex_id, cx.cur_id): cx.minimum
709
+ for cx in await CurEx.filter(
710
+ ex_id__in={m.ad.maker.ex_id for m in mads},
711
+ cur_id__in={m.ad.pair_side.pair.cur_id for m in mads},
712
+ )
713
+ }
714
+
715
+ def rank(m: MyAd) -> tuple:
716
+ ts = last_deal.get(m.ad.maker_id)
717
+ qty = m.ad.quantity or m.ad.amount or 0
718
+ # (0,…) «ни разу не торговали» < любого (1,…); внутри — старее сделка раньше; затем больший остаток
719
+ return (1 if ts else 0, ts or datetime.min, -qty)
720
+
721
+ best: dict[tuple, MyAd] = {}
722
+ for m in mads:
723
+ mn = minimums.get((m.ad.maker.ex_id, m.ad.pair_side.pair.cur_id))
724
+ if (slot := Ad.slot_of(m.ad.min_fiat, m.ad.filtered, mn)) is None:
725
+ continue # неклассифицируемая объява — не показываем
726
+ key = (m.ad.maker.ex_id, m.ad.pair_side_id, slot)
727
+ if key not in best or rank(m) < rank(best[key]):
728
+ best[key] = m
729
+ return list(best.values())
730
+
678
731
  # доступные hot объявления для юзера с его текущими балансами # noqa: RUF003 - intentional Russian
679
732
  # mine=True — наоборот: только собственные hot-объявления юзера, без балансовой фильтрации
680
733
  async def get_hot_ads(
@@ -689,6 +742,8 @@ class User(TgUser, TsTrait):
689
742
  # один Q: исключаем строго пересечение (Tortoise's exclude(**kwargs) отрицал бы условия порознь)
690
743
  adq = adq.exclude(Q(hex__isnull=True, ad__maker__ex_id__in=SHARE_HEX_EX_IDS))
691
744
  bads = await adq.filter(ad__pair_side__is_sell=False)
745
+ if not mine: # тейкеру — по 1 объяве на (биржа, направление, слот); свои (settings) отдаём все
746
+ bads = await self._dedup_hot_by_slot(bads)
692
747
  # остаток каждой монеты (asset.free) у акторов персон этого юзера, по бирже и монете # noqa: RUF003
693
748
  coin_balances: dict[int, dict[int, int]] = {}
694
749
  for ex_id, coin_id, total in (
@@ -712,6 +767,7 @@ class User(TgUser, TsTrait):
712
767
  minimums = {cx.cur_id: cx.minimum for cx in curexs}
713
768
  balances = {cur_id: b for cur_id, b in balances.items() if minimums.get(cur_id) and b >= minimums[cur_id]}
714
769
  sads = await adq.filter(ad__pair_side__is_sell=True, ad__pair_side__pair__cur_id__in=balances.keys()).all()
770
+ sads = await self._dedup_hot_by_slot(sads) # not mine — по 1 объяве на (биржа, направление, слот)
715
771
 
716
772
  return bads, sads, balances, coin_balances
717
773
 
@@ -1201,6 +1257,29 @@ class Ad(Model, TsTrait):
1201
1257
  "price",
1202
1258
  }
1203
1259
 
1260
+ @staticmethod
1261
+ def slot_of(min_fiat: int, filtered: bool, minimum: int) -> tuple[int, int] | None:
1262
+ """Слот hot-объявы — пара `(множитель_мин_лимита, фильтрованность)` по её нижнему лимиту и
1263
+ наличию доп. условия для тейкера. Минимальный лимит валюты на бирже свой — `CurEx.minimum`.
1264
+ Слоты в одном направлении (пара+сторона, на каждой бирже):
1265
+ (1, 1) — «1»: мин. лимит, С условием (ОБЯЗАТЕЛЬНЫЙ — его всегда размещает каждый мейкер);
1266
+ (1, 0) — «1nf»: мин. лимит, БЕЗ условия;
1267
+ (10, 1) — «10»: от 10 мин. лимитов;
1268
+ (60, 1) — «60»: от 60 мин. лимитов.
1269
+ Диапазоны 5..10 / 23..60 ловят объявы, ужатые нехваткой баланса (real_min_fiat = min(k*min,
1270
+ cur_reserve) дал множитель меньше номинала слота). Слоты 10/60 нормализуем как фильтрованные —
1271
+ нефильтрован лишь «1nf». `None` — min_fiat не попадает ни в один слот (битая/чужая объява)."""
1272
+ if not minimum:
1273
+ return None
1274
+ k = int(min_fiat / minimum)
1275
+ if k == 1:
1276
+ return 1, int(filtered)
1277
+ if 5 <= k <= 10:
1278
+ return 10, 1
1279
+ if 23 <= k <= 60:
1280
+ return 60, 1
1281
+ return None
1282
+
1204
1283
  @staticmethod
1205
1284
  async def _sync_pms(ad: Ad, pms: Collection[Pm] | None) -> None:
1206
1285
  """Синк m2m pms (clear+add), только если набор передан и отличается от текущего.
@@ -1259,6 +1338,13 @@ class Ad(Model, TsTrait):
1259
1338
  await cls._sync_pms(ad, pms) # новая локальная заготовка exid=0
1260
1339
  return ad, True
1261
1340
 
1341
+ @classmethod
1342
+ async def remove(cls, id: int) -> None:
1343
+ """ЕДИНСТВЕННЫЙ writer удаления строки Ad (парный к upsert). Физически сносит объяву — каскадом
1344
+ уходят MyAd/Race, m2m (ad_pm/myad_cred) и ордера по ней (on_delete=CASCADE → потеря истории).
1345
+ Звать ТОЛЬКО после успешного удаления на бирже (controllers/hot.del_ad)."""
1346
+ await cls.filter(id=id).delete()
1347
+
1262
1348
  class Meta:
1263
1349
  table_description = "P2P Advertisements"
1264
1350
  unique_together = (("exid", "maker"),)
@@ -1309,7 +1395,7 @@ class MyAd(Model): # Road
1309
1395
  @classmethod
1310
1396
  # запрос для получения объяв всех агентов этой биржи для разогрева
1311
1397
  def hot_mads_query(cls, ex_ids: list[int], active: bool = True):
1312
- fltr = dict(credexs__cred__ovr_pm_id=0, ad__maker__agent__status__gte=3)
1398
+ fltr = dict(credexs__cred__ovr_pm_id=0, ad__maker__agent__status__in=[2, 3])
1313
1399
  if active:
1314
1400
  fltr["ad__status"] = AdStatus.active
1315
1401
  if ex_ids:
@@ -26,7 +26,8 @@ class _Ad(BaseModel):
26
26
 
27
27
  @model_validator(mode="after")
28
28
  def amount_quantity(self):
29
- if not self.amount and not self.quantity:
29
+ if self.status.value and self.quantity is None and self.amount is None:
30
+ # если объява не активна, то можно допустить что не указаны ни quantity, ни amount
30
31
  raise ValueError("either amount or quantity is required")
31
32
  # elif self.amount and not self.quantity:
32
33
  # self.quantity = self.amount / self.price
File without changes
File without changes
File without changes