funora 0.0.1.dev2__py3-none-any.whl

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 (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_money.py ADDED
@@ -0,0 +1,279 @@
1
+ """Денежная сумма как доменный тип.
2
+
3
+ Тип объявлен спецификацией давно и не был построен ни разу: цена заказа
4
+ отдавалась текстом, а арифметики не существовало вовсе. Вызывающий складывал
5
+ цены своим кодом - в котором нет ни проверки валют, ни защиты от плавающей
6
+ точки, ради которых тип и объявлен.
7
+
8
+ СБОРКА ИЗ ЗНАКА ПОЯВИЛАСЬ 31.08.2026, и появилась не потому, что мы передумали
9
+ осторожничать. Появилось НАБЛЮДЕНИЕ, которого не хватало.
10
+
11
+ Прежде здесь стояло: «придуманная таблица символов молча приписала бы чужую
12
+ валюту чужому заказу». Это верно, пока набор валют ОТКРЫТ: знак доллара носят
13
+ полтора десятка валют, и выбирать из них наугад нельзя.
14
+
15
+ Набор оказался ЗАМКНУТ, и замкнутость наблюдена ДВАЖДЫ НЕЗАВИСИМО.
16
+
17
+ ПЕРВОЕ. Область вывода средств несёт настройки объектом, и ключи объекта
18
+ сохраняются дословно: currencies.eur, currencies.rub, currencies.usd. Ровно
19
+ три, и это перечень самой площадки, а не наш.
20
+
21
+ ВТОРОЕ. Переключатель в шапке перечисляет ПРОЧИЕ валюты, не текущую, и всякий
22
+ раз их ровно две: {USD, EUR} при рублёвой настройке, {RUB, EUR} после
23
+ переключения на доллары. Две альтернативы при трёх валютах - то же число с
24
+ другой стороны.
25
+
26
+ Знаков в span.unit наблюдалось тоже ровно три - ₽, $, € - на 877 вхождениях
27
+ шести разных страниц. Столкновений в таблице нет: три знака, три кода, взаимно
28
+ однозначно.
29
+
30
+ ЧТО ОСТАЛОСЬ ВЕРНЫМ ИЗ ПРЕЖНЕГО. Переключатель говорит о НАСТРОЙКЕ ПОКАЗА, и
31
+ сумма заказа за ней не следует: после переключения список продаж остался с теми
32
+ же тридцатью четырьмя рублёвыми знаками. Сумма заказа показана в валюте ЗАКАЗА.
33
+
34
+ Поэтому код берётся ИЗ ЗНАКА РЯДОМ С САМОЙ СУММОЙ, а не из шапки. Шапка годится
35
+ только там, где суммы за настройкой следуют, - на витрине продавца это
36
+ наблюдено трижды, по одному разу на каждое положение переключателя, 164-165 цен
37
+ в каждом.
38
+
39
+ ЧЕТВЁРТАЯ ВАЛЮТА ОТКАЗЫВАЕТ ГРОМКО. Заведи площадка ещё одну - появится
40
+ четвёртый знак, и таблица его не найдёт. Это ровно то, чего мы хотим: молчаливая
41
+ подстановка приписала бы сумме чужую валюту, а отказ виден сразу.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import re
47
+ from dataclasses import dataclass
48
+ from typing import Final
49
+
50
+ from .errors import CurrencyMismatchError, ValidationError
51
+ from .extraction import CURRENCY_BY_SYMBOL, MONEY_MAX_MINOR, MONEY_MAX_SCALE, MONEY_MIN_MINOR
52
+
53
+ __all__ = ["Money", "CURRENCY_BY_SYMBOL", "currency_of_symbol"]
54
+
55
+ #: Знак валюты -> код ISO 4217.
56
+ #:
57
+ #: ТАБЛИЦА ПОЛНАЯ, А НЕ ПРИМЕРНАЯ, и это её главное свойство. Набор валют
58
+ #: площадки наблюдён замкнутым дважды независимо (см. шапку модуля), знаков в
59
+ #: span.unit наблюдалось ровно три, столкновений нет.
60
+ #:
61
+ #: Замкнутость - не украшение: именно она отличает эту таблицу от выдумки.
62
+ #: Знак доллара носят полтора десятка валют мира, и выбор среди них наугад был
63
+ #: бы гаданием. Выбор среди ТРЁХ объявленных площадкой - чтение.
64
+
65
+
66
+ def currency_of_symbol(symbol: str) -> str:
67
+ """Переводит наблюдённый знак валюты в код ISO 4217.
68
+
69
+ ОТКАЗЫВАЕТ ГРОМКО НА НЕЗНАКОМОМ. Заведи площадка четвёртую валюту - появится
70
+ четвёртый знак, и здесь будет отказ. Подставить вместо него что-нибудь
71
+ правдоподобное значило бы приписать сумме чужую валюту, а увидел бы это не
72
+ разработчик, а продавец.
73
+
74
+ Аргументы:
75
+ symbol (str): Знак, прочитанный рядом с суммой.
76
+
77
+ Возвращает:
78
+ str: Код ISO 4217.
79
+
80
+ Raises:
81
+ ValidationError: Если знак не из наблюдённого набора.
82
+ """
83
+ code = CURRENCY_BY_SYMBOL.get(symbol.strip())
84
+ if code is None:
85
+ known = ", ".join(sorted(CURRENCY_BY_SYMBOL))
86
+ raise ValidationError(
87
+ f"знак валюты {symbol!r} не из наблюдённого набора ({known}). "
88
+ "Площадка объявляет три валюты, и четвёртой мы не видели; "
89
+ "подставить код наугад значило бы приписать сумме чужую валюту"
90
+ )
91
+ return code
92
+
93
+
94
+ #: Код валюты по ISO 4217: ровно три заглавные латинские буквы.
95
+ _CURRENCY: Final[re.Pattern[str]] = re.compile(r"^[A-Z]{3}$")
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class Money:
100
+ """Денежная сумма в минорных единицах.
101
+
102
+ Целое число, а не дробное. Дробное представление не воспроизводится
103
+ одинаково в разных языках: 0.1 + 0.2 даёт 0.30000000000000004 всюду, где
104
+ считают двоичной дробью, и сумма двух заказов расходится с той, что видит
105
+ площадка.
106
+
107
+ Attributes:
108
+ amount_minor (int): Сумма в минорных единицах, в границах int64. Знак допустим: возвраты
109
+ и корректировки бывают отрицательными.
110
+ currency (str): Код валюты по ISO 4217, три заглавные латинские буквы.
111
+ scale (int): Степень десяти от 0 до 6 для перевода в минорные единицы.
112
+ Двойка означает копейки: 123410 при scale 2 это 1234.10.
113
+ """
114
+
115
+ amount_minor: int
116
+ currency: str
117
+ scale: int = 2
118
+
119
+ def __post_init__(self) -> None:
120
+ """Проверяет, что сумма выразима.
121
+
122
+ Returns:
123
+ None
124
+
125
+ Raises:
126
+ ValidationError: Если код валюты неверен, сумма не целая либо
127
+ выходит за int64, разрядность не целая или вне диапазона 0-6.
128
+ """
129
+ if not isinstance(self.currency, str) or not _CURRENCY.fullmatch(self.currency):
130
+ raise ValidationError(
131
+ f"код валюты {self.currency!r} не по ISO 4217: нужны ровно три "
132
+ "заглавные латинские буквы. Символ валюты кодом не является - "
133
+ "один и тот же знак носят несколько валют"
134
+ )
135
+ if isinstance(self.amount_minor, bool) or not isinstance(self.amount_minor, int):
136
+ raise ValidationError(
137
+ f"сумма {self.amount_minor!r} не целая. Минорные единицы целы по "
138
+ "определению, а дробное представление не воспроизводится одинаково "
139
+ "в разных языках"
140
+ )
141
+ if not MONEY_MIN_MINOR <= self.amount_minor <= MONEY_MAX_MINOR:
142
+ raise ValidationError("сумма выходит за границы int64")
143
+ if type(self.scale) is not int or not 0 <= self.scale <= MONEY_MAX_SCALE:
144
+ raise ValidationError(f"разрядность должна быть целым числом от 0 до {MONEY_MAX_SCALE}")
145
+
146
+ def _same_kind(self, other: Money) -> None:
147
+ """Проверяет, что суммы одного рода.
148
+
149
+ Args:
150
+ other (Money): Вторая сумма.
151
+
152
+ Returns:
153
+ None
154
+
155
+ Raises:
156
+ CurrencyMismatchError: Если валюты разные.
157
+ ValidationError: Если валюта одна, а разрядность разная.
158
+ """
159
+ if self.currency != other.currency:
160
+ raise CurrencyMismatchError(
161
+ f"арифметика между {self.currency} и {other.currency} запрещена. "
162
+ "Курс - величина, меняющаяся ежеминутно и здесь неизвестная; "
163
+ "сложить их значило бы выдать выдуманное число за сумму"
164
+ )
165
+ if self.scale != other.scale:
166
+ raise ValidationError(
167
+ f"одна валюта {self.currency} с разной разрядностью: {self.scale} и "
168
+ f"{other.scale}. Складывать копейки с сотыми копейки нельзя - "
169
+ "разойдётся на два порядка"
170
+ )
171
+
172
+ def __add__(self, other: Money) -> Money:
173
+ """Складывает две суммы одной валюты.
174
+
175
+ Args:
176
+ other (Money): Вторая сумма.
177
+
178
+ Returns:
179
+ Money: Сумма.
180
+
181
+ Raises:
182
+ CurrencyMismatchError: Если валюты разные.
183
+ """
184
+ self._same_kind(other)
185
+ return Money(self.amount_minor + other.amount_minor, self.currency, self.scale)
186
+
187
+ def __sub__(self, other: Money) -> Money:
188
+ """Вычитает сумму той же валюты.
189
+
190
+ Args:
191
+ other (Money): Вычитаемое.
192
+
193
+ Returns:
194
+ Money: Разность. Отрицательная разность допустима.
195
+
196
+ Raises:
197
+ CurrencyMismatchError: Если валюты разные.
198
+ """
199
+ self._same_kind(other)
200
+ return Money(self.amount_minor - other.amount_minor, self.currency, self.scale)
201
+
202
+ def __mul__(self, times: int) -> Money:
203
+ """Умножает сумму на целое число.
204
+
205
+ Умножение только на целое. Умножение на дробное - это доля, а доля
206
+ требует правила округления, которого спецификация не задаёт: округлять
207
+ вверх, вниз либо к ближайшему - три разных ответа на один вопрос, и
208
+ выбрать за вызывающего нельзя.
209
+
210
+ Args:
211
+ times (int): Множитель, например число единиц товара.
212
+
213
+ Returns:
214
+ Money: Произведение.
215
+
216
+ Raises:
217
+ ValidationError: Если множитель не целый.
218
+ """
219
+ if isinstance(times, bool) or not isinstance(times, int):
220
+ raise ValidationError(
221
+ f"множитель {times!r} не целый. Дробный множитель - это доля, а "
222
+ "доля требует правила округления, которого контракт не задаёт"
223
+ )
224
+ return Money(self.amount_minor * times, self.currency, self.scale)
225
+
226
+ def __lt__(self, other: Money) -> bool:
227
+ """Сравнивает суммы одной валюты.
228
+
229
+ Args:
230
+ other (Money): Вторая сумма.
231
+
232
+ Returns:
233
+ bool: True, если эта сумма меньше.
234
+
235
+ Raises:
236
+ CurrencyMismatchError: Если валюты разные. Сравнение здесь не
237
+ безобиднее сложения: «дешевле» между валютами так же требует
238
+ курса.
239
+ """
240
+ self._same_kind(other)
241
+ return self.amount_minor < other.amount_minor
242
+
243
+ def __le__(self, other: Money) -> bool:
244
+ """Сравнивает суммы одной валюты.
245
+
246
+ Args:
247
+ other (Money): Вторая сумма.
248
+
249
+ Returns:
250
+ bool: True, если эта сумма не больше.
251
+ """
252
+ self._same_kind(other)
253
+ return self.amount_minor <= other.amount_minor
254
+
255
+ def __str__(self) -> str:
256
+ """Показывает сумму человеку.
257
+
258
+ Returns:
259
+ str: Мажорные единицы с разделителем и код валюты.
260
+ """
261
+ if self.scale == 0:
262
+ return f"{self.amount_minor} {self.currency}"
263
+ units, minor = divmod(abs(self.amount_minor), 10**self.scale)
264
+ sign = "-" if self.amount_minor < 0 else ""
265
+ return f"{sign}{units}.{minor:0{self.scale}d} {self.currency}"
266
+
267
+
268
+ _DISPLAY_PRICE: Final[re.Pattern[str]] = re.compile(
269
+ rf"(?:[0-9]+|[1-9][0-9]{{0,2}}(?: [0-9]{{3}})+)(?:\.[0-9]{{1,{MONEY_MAX_SCALE}}})?"
270
+ )
271
+
272
+
273
+ def parse_display_price(text: str, symbol: str) -> Money:
274
+ """Нормализует наблюдённую цену рынка в scale 6, без float и округления."""
275
+ if len(text) > 128 or _DISPLAY_PRICE.fullmatch(text) is None:
276
+ raise ValidationError("неподдерживаемый формат показанной цены")
277
+ units, _, fraction = text.replace(" ", "").partition(".")
278
+ amount = int(units) * 10**MONEY_MAX_SCALE + int(fraction.ljust(MONEY_MAX_SCALE, "0"))
279
+ return Money(amount, currency_of_symbol(symbol), MONEY_MAX_SCALE)
funora/_monitoring.py ADDED
@@ -0,0 +1,396 @@
1
+ """Описание наблюдений и чистое продвижение истории публичной выдачи."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from dataclasses import asdict, dataclass
9
+ from fractions import Fraction
10
+ from typing import Any
11
+
12
+ from ._diff import Event, make_event
13
+ from ._money import Money
14
+ from ._snapshot import MarketSnapshot, fingerprint_of
15
+ from ._watch import incomplete, primed
16
+ from .budget import MARKET_ABSENCES, MARKET_HISTORY_LIMIT, MARKET_INTERVAL_MS
17
+ from .errors import ConfigurationError, ValidationError
18
+ from .events import EventType
19
+ from .operations import OPERATIONS
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class MarketWatch:
24
+ """Одна выдача /lots/: устойчивое имя, раздел и интервал между чтениями.
25
+
26
+ interval_ms принадлежит политике клиента, а не лимитам FunPay.
27
+ Допуск набора проверяет monitoring.plan; фактические запросы - Budget.
28
+ """
29
+
30
+ watch_id: str
31
+ node_id: str
32
+ interval_ms: int = MARKET_INTERVAL_MS
33
+
34
+ def __post_init__(self) -> None:
35
+ if not isinstance(self.watch_id, str) or not re.fullmatch(
36
+ r"[A-Za-z0-9_.-]{1,128}", self.watch_id
37
+ ):
38
+ raise ValidationError("watch_id: нужны 1-128 латинских букв, цифр, _, . или -")
39
+ if not isinstance(self.node_id, str) or not re.fullmatch(r"[0-9]{1,128}", self.node_id):
40
+ raise ValidationError("node_id: нужны 1-128 цифр ASCII")
41
+ if type(self.interval_ms) is not int or self.interval_ms <= 0:
42
+ raise ValidationError("interval_ms должен быть положительным целым числом")
43
+ try:
44
+ finite = math.isfinite(self.interval_ms / 1000)
45
+ except OverflowError:
46
+ finite = False
47
+ if not finite:
48
+ raise ValidationError("interval_ms не представим часами runtime")
49
+
50
+ @property
51
+ def requests_per_second(self) -> Fraction:
52
+ """Прогноз одного тика из профиля операции адаптера, без округления."""
53
+ return Fraction(OPERATIONS["market.snapshot"].cost_hint * 1000, self.interval_ms)
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class MonitoringLimit:
58
+ """Прогноз для сетевого ведра, включая уже зарегистрированные наблюдения."""
59
+
60
+ bucket: str
61
+ requests_per_second: Fraction
62
+ available_per_second: Fraction
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class MonitoringPlan:
67
+ """Проверка набора без HTTP и расхода токенов; это не резервирование."""
68
+
69
+ admitted: bool
70
+ requests_per_second: Fraction
71
+ limits: tuple[MonitoringLimit, ...]
72
+ rejected_watch_ids: tuple[str, ...]
73
+ reason: str | None = None
74
+
75
+
76
+ def validate_watches(watches: tuple[MarketWatch, ...]) -> None:
77
+ if not watches or any(not isinstance(one, MarketWatch) for one in watches):
78
+ raise ValidationError("нужен непустой набор MarketWatch")
79
+ if len({one.watch_id for one in watches}) != len(watches):
80
+ raise ValidationError("watch_id не должны повторяться")
81
+
82
+
83
+ def initial_cursor(watches: tuple[MarketWatch, ...]) -> dict[str, Any]:
84
+ return {
85
+ "market": {
86
+ one.watch_id: {
87
+ "config": asdict(one),
88
+ "sequence": 0,
89
+ "baseline": False,
90
+ "offers": {},
91
+ "absences": {},
92
+ }
93
+ for one in watches
94
+ }
95
+ }
96
+
97
+
98
+ def validate_market_cursor(value: Any) -> dict[str, Any]:
99
+ """Повреждение истории не превращается в холодный старт."""
100
+ if not isinstance(value, dict) or set(value) != {"market"}:
101
+ raise ValueError("неверный состав курсора рынка")
102
+ watches = value["market"]
103
+ if not isinstance(watches, dict) or not watches:
104
+ raise ValueError("пустой набор наблюдений")
105
+ for watch_id, record in watches.items():
106
+ if not isinstance(record, dict) or set(record) != {
107
+ "config",
108
+ "sequence",
109
+ "baseline",
110
+ "offers",
111
+ "absences",
112
+ }:
113
+ raise ValueError("неверный состав истории")
114
+ config = record["config"]
115
+ if not isinstance(config, dict) or set(config) != {"watch_id", "node_id", "interval_ms"}:
116
+ raise ValueError("неверный состав наблюдения")
117
+ try:
118
+ watch = MarketWatch(**config)
119
+ except ValidationError as exc:
120
+ raise ValueError("неверное наблюдение") from exc
121
+ if (
122
+ watch.watch_id != watch_id
123
+ or type(record["sequence"]) is not int
124
+ or record["sequence"] < 0
125
+ ):
126
+ raise ValueError("неверный владелец или номер снимка")
127
+ if type(record["baseline"]) is not bool:
128
+ raise ValueError("неверный признак полного основания")
129
+ offers, absences = record["offers"], record["absences"]
130
+ if (
131
+ not isinstance(offers, dict)
132
+ or not isinstance(absences, dict)
133
+ or set(absences) - set(offers)
134
+ ):
135
+ raise ValueError("неверный состав предложений или отсутствий")
136
+ for offer_id, entry in offers.items():
137
+ if (
138
+ not isinstance(offer_id, str)
139
+ or not offer_id
140
+ or not isinstance(entry, dict)
141
+ or set(entry) != {"price", "seller_id"}
142
+ ):
143
+ raise ValueError("неверное предложение")
144
+ if entry["seller_id"] is not None and (
145
+ not isinstance(entry["seller_id"], str)
146
+ or not re.fullmatch(r"[0-9]{1,128}", entry["seller_id"])
147
+ ):
148
+ raise ValueError("неверный продавец")
149
+ money = entry["price"]
150
+ if money is not None:
151
+ if not isinstance(money, dict) or set(money) != {
152
+ "amount_minor",
153
+ "currency",
154
+ "scale",
155
+ }:
156
+ raise ValueError("неверный состав цены")
157
+ try:
158
+ Money(**money)
159
+ except ValidationError as exc:
160
+ raise ValueError("неверная цена") from exc
161
+ if any(
162
+ type(count) is not int or not 1 <= count < MARKET_ABSENCES
163
+ for count in absences.values()
164
+ ):
165
+ raise ValueError("неверный счётчик отсутствий")
166
+ if record["sequence"] == 0 and (offers or absences or record["baseline"]):
167
+ raise ValueError("состояние до первого снимка не пусто")
168
+ return value
169
+
170
+
171
+ def history_size(cursor: dict[str, Any]) -> int:
172
+ """Считает записи всех наблюдений; один offer_id в двух выдачах - две записи."""
173
+ return sum(len(record["offers"]) for record in cursor["market"].values())
174
+
175
+
176
+ def check_history_limit(size: int, limit: int) -> None:
177
+ """Переполнение останавливает работу; сохранённые данные не вытесняются."""
178
+ if type(limit) is not int or limit <= 0:
179
+ raise ConfigurationError("history_limit должен быть положительным целым числом")
180
+ if size > limit:
181
+ raise ConfigurationError(
182
+ f"история рынка: {size} записей превышают history_limit={limit}; "
183
+ "увеличьте предел и продолжите наблюдение с прежним файлом состояния"
184
+ )
185
+
186
+
187
+ def observe_market(
188
+ cursor: dict[str, Any],
189
+ watch: MarketWatch,
190
+ snapshot: MarketSnapshot,
191
+ *,
192
+ account_id: str,
193
+ read_interval_ms: int | None = None,
194
+ history_limit: int = MARKET_HISTORY_LIMIT,
195
+ ) -> tuple[dict[str, Any], tuple[Event, ...]]:
196
+ """Положительные наблюдения дополняют историю; отсутствие требует полноты."""
197
+ if snapshot.node_id != watch.node_id or snapshot.query_fingerprint != fingerprint_of(
198
+ watch.node_id
199
+ ):
200
+ raise ValidationError("снимок принадлежит другой выдаче")
201
+ if read_interval_ms is not None and (type(read_interval_ms) is not int or read_interval_ms < 0):
202
+ raise ValidationError("интервал чтения должен быть неотрицательным целым")
203
+ previous = cursor["market"][watch.watch_id]
204
+ size = history_size(cursor) + sum(
205
+ offer_id not in previous["offers"] for offer_id in snapshot.offers
206
+ )
207
+ if snapshot.is_complete:
208
+ size -= sum(
209
+ offer_id not in snapshot.offers and count + 1 >= MARKET_ABSENCES
210
+ for offer_id, count in previous["absences"].items()
211
+ )
212
+ check_history_limit(size, history_limit)
213
+ # Новая независимая позиция; прежняя остаётся до подтверждения доставки.
214
+ target = json.loads(json.dumps(cursor, ensure_ascii=True, allow_nan=False))
215
+ record = target["market"][watch.watch_id]
216
+ sequence = record["sequence"] + 1
217
+ revision = json.dumps([watch.watch_id, sequence], ensure_ascii=True, separators=(",", ":"))
218
+ events: list[Event] = []
219
+ if sequence == 1:
220
+ events.append(primed(account_id, snapshot.taken_at, ("market",), watch_id=watch.watch_id))
221
+ elif read_interval_ms is not None and read_interval_ms >= 2 * watch.interval_ms:
222
+ events.append(
223
+ make_event(
224
+ account_id=account_id,
225
+ event_type=EventType.WATCH_DEGRADED,
226
+ entity_id=watch.watch_id,
227
+ revision=revision,
228
+ observed_at=snapshot.taken_at,
229
+ key_field="watch_id",
230
+ key_value=watch.watch_id,
231
+ payload={
232
+ "watch_id": watch.watch_id,
233
+ "requested_interval_ms": watch.interval_ms,
234
+ "effective_interval_ms": read_interval_ms,
235
+ "reason_code": "schedule_overrun",
236
+ },
237
+ )
238
+ )
239
+ if not snapshot.is_complete:
240
+ events.append(
241
+ incomplete(
242
+ account_id,
243
+ snapshot.taken_at,
244
+ entity="market",
245
+ entity_ref=watch.node_id,
246
+ reason=snapshot.reason or "incomplete",
247
+ rows_total=snapshot.rows_total,
248
+ rows_accepted=snapshot.rows_accepted,
249
+ watch_id=watch.watch_id,
250
+ )
251
+ )
252
+ record["absences"] = {}
253
+
254
+ def emit(kind: EventType, offer_id: str, payload: dict[str, Any]) -> None:
255
+ events.append(
256
+ make_event(
257
+ account_id=account_id,
258
+ event_type=kind,
259
+ entity_id=offer_id,
260
+ revision=revision,
261
+ observed_at=snapshot.taken_at,
262
+ key_field="watch_id",
263
+ key_value=watch.watch_id,
264
+ payload={"watch_id": watch.watch_id, "offer_id": offer_id, **payload},
265
+ )
266
+ )
267
+
268
+ offers = record["offers"]
269
+ for offer_id, entry in snapshot.offers.items():
270
+ previous = offers.get(offer_id)
271
+ price = asdict(entry.price.value) if entry.price.is_observed else None
272
+ seller = re.fullmatch(r"(?:https://funpay\.com)?/users/([0-9]{1,128})/", entry.seller_href)
273
+ seller_id = seller[1] if seller else None
274
+ if previous is None:
275
+ if record["baseline"] and price is not None and seller_id is not None:
276
+ emit(
277
+ EventType.MARKET_OFFER_APPEARED,
278
+ offer_id,
279
+ {
280
+ "seller_id": seller_id,
281
+ "price": price,
282
+ "query_fingerprint": snapshot.query_fingerprint,
283
+ },
284
+ )
285
+ elif price is not None and previous["price"] is not None and price != previous["price"]:
286
+ emit(
287
+ EventType.MARKET_PRICE_CHANGED,
288
+ offer_id,
289
+ {"before": previous["price"], "latest": price, "aggregate": None},
290
+ )
291
+ offers[offer_id] = {
292
+ "price": price if price is not None else previous["price"] if previous else None,
293
+ "seller_id": seller_id
294
+ if seller_id is not None
295
+ else previous["seller_id"]
296
+ if previous
297
+ else None,
298
+ }
299
+ record["absences"].pop(offer_id, None)
300
+
301
+ if snapshot.is_complete:
302
+ for offer_id in sorted(set(offers) - set(snapshot.offers)):
303
+ count = record["absences"].get(offer_id, 0) + 1
304
+ if count >= MARKET_ABSENCES:
305
+ emit(
306
+ EventType.MARKET_OFFER_DISAPPEARED,
307
+ offer_id,
308
+ {
309
+ "query_fingerprint": snapshot.query_fingerprint,
310
+ "consecutive_absences": count,
311
+ "filters_affect_visibility": False,
312
+ },
313
+ )
314
+ del offers[offer_id]
315
+ record["absences"].pop(offer_id, None)
316
+ else:
317
+ record["absences"][offer_id] = count
318
+ record["baseline"] = True
319
+ record["sequence"] = sequence
320
+ return target, tuple(events)
321
+
322
+
323
+ def validate_market_transition(
324
+ before: dict[str, Any],
325
+ after: dict[str, Any],
326
+ events: tuple[Event, ...],
327
+ ) -> str:
328
+ """Одна непринятая партия подтверждает ровно один следующий снимок."""
329
+ previous: dict[str, Any] = before["market"]
330
+ latest = after.get("market", {})
331
+ if set(previous) != set(latest) or any(
332
+ one["config"] != latest[key]["config"] for key, one in previous.items()
333
+ ):
334
+ raise ValueError("другой набор наблюдений")
335
+ changed = [key for key in previous if previous[key] != latest[key]]
336
+ if len(changed) != 1:
337
+ raise ValueError("партия должна продвигать один снимок")
338
+ watch_id = changed[0]
339
+ if latest[watch_id]["sequence"] != previous[watch_id]["sequence"] + 1:
340
+ raise ValueError("пропущен номер снимка")
341
+ if any(event.payload.get("watch_id") != watch_id for event in events):
342
+ raise ValueError("партия и снимок принадлежат разным наблюдениям")
343
+ return watch_id
344
+
345
+
346
+ def validate_market_payload(kind: EventType, payload: dict[str, Any], watch: MarketWatch) -> None:
347
+ """Восстановленная цена и ссылка должны соблюдать тот же контракт, что новые."""
348
+ if kind is EventType.WATCH_DEGRADED:
349
+ if (
350
+ set(payload)
351
+ != {"watch_id", "requested_interval_ms", "effective_interval_ms", "reason_code"}
352
+ or payload["watch_id"] != watch.watch_id
353
+ or type(payload["requested_interval_ms"]) is not int
354
+ or payload["requested_interval_ms"] != watch.interval_ms
355
+ or type(payload["effective_interval_ms"]) is not int
356
+ or payload["effective_interval_ms"] < 2 * watch.interval_ms
357
+ or payload["reason_code"] != "schedule_overrun"
358
+ ):
359
+ raise ValueError("неверное ухудшение интервала наблюдения")
360
+ return
361
+ fields = {
362
+ EventType.MARKET_OFFER_APPEARED: {"seller_id", "price", "query_fingerprint"},
363
+ EventType.MARKET_OFFER_DISAPPEARED: {
364
+ "query_fingerprint",
365
+ "consecutive_absences",
366
+ "filters_affect_visibility",
367
+ },
368
+ EventType.MARKET_PRICE_CHANGED: {"before", "latest", "aggregate"},
369
+ }[kind]
370
+ if set(payload) != fields | {"watch_id", "offer_id"}:
371
+ raise ValueError("неверный состав рыночного события")
372
+ if "query_fingerprint" in fields and payload["query_fingerprint"] != fingerprint_of(
373
+ watch.node_id
374
+ ):
375
+ raise ValueError("событие другой выдачи")
376
+ if kind is EventType.MARKET_OFFER_APPEARED and (
377
+ not isinstance(payload["seller_id"], str)
378
+ or not re.fullmatch(r"[0-9]{1,128}", payload["seller_id"])
379
+ ):
380
+ raise ValueError("неверный продавец события")
381
+ if kind is EventType.MARKET_PRICE_CHANGED and payload["aggregate"] is not None:
382
+ raise ValueError("сжатая партия не поддерживается")
383
+ if kind is EventType.MARKET_OFFER_DISAPPEARED and (
384
+ type(payload["consecutive_absences"]) is not int
385
+ or payload["consecutive_absences"] < 1
386
+ or payload["filters_affect_visibility"] is not False
387
+ ):
388
+ raise ValueError("неверное подтверждение отсутствия")
389
+ for name in fields & {"price", "before", "latest"}:
390
+ money = payload[name]
391
+ if not isinstance(money, dict) or set(money) != {"amount_minor", "currency", "scale"}:
392
+ raise ValueError("неверный состав цены события")
393
+ try:
394
+ Money(**money)
395
+ except ValidationError as exc:
396
+ raise ValueError("неверная цена события") from exc