xync-client 0.0.141__py3-none-any.whl → 0.0.156.dev18__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 (40) hide show
  1. xync_client/Abc/AdLoader.py +5 -0
  2. xync_client/Abc/Agent.py +354 -8
  3. xync_client/Abc/Ex.py +432 -25
  4. xync_client/Abc/HasAbotUid.py +10 -0
  5. xync_client/Abc/InAgent.py +0 -11
  6. xync_client/Abc/PmAgent.py +34 -26
  7. xync_client/Abc/xtype.py +57 -3
  8. xync_client/Bybit/InAgent.py +233 -409
  9. xync_client/Bybit/agent.py +844 -777
  10. xync_client/Bybit/etype/__init__.py +0 -0
  11. xync_client/Bybit/etype/ad.py +54 -86
  12. xync_client/Bybit/etype/cred.py +29 -9
  13. xync_client/Bybit/etype/order.py +75 -103
  14. xync_client/Bybit/ex.py +35 -48
  15. xync_client/Gmail/__init__.py +119 -98
  16. xync_client/Htx/agent.py +213 -40
  17. xync_client/Htx/etype/ad.py +40 -16
  18. xync_client/Htx/etype/order.py +194 -0
  19. xync_client/Htx/ex.py +17 -19
  20. xync_client/Mexc/agent.py +268 -0
  21. xync_client/Mexc/api.py +1255 -0
  22. xync_client/Mexc/etype/ad.py +52 -1
  23. xync_client/Mexc/etype/order.py +354 -0
  24. xync_client/Mexc/ex.py +34 -22
  25. xync_client/Okx/1.py +14 -0
  26. xync_client/Okx/agent.py +39 -0
  27. xync_client/Okx/ex.py +8 -8
  28. xync_client/Pms/Payeer/agent.py +396 -0
  29. xync_client/Pms/Payeer/login.py +1 -59
  30. xync_client/Pms/Payeer/trade.py +58 -0
  31. xync_client/Pms/Volet/__init__.py +82 -63
  32. xync_client/Pms/Volet/api.py +5 -4
  33. xync_client/loader.py +2 -0
  34. xync_client/pm_unifier.py +1 -1
  35. {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/METADATA +5 -1
  36. {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/RECORD +38 -29
  37. xync_client/Pms/Payeer/__init__.py +0 -253
  38. xync_client/Pms/Payeer/api.py +0 -25
  39. {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/WHEEL +0 -0
  40. {xync_client-0.0.141.dist-info → xync_client-0.0.156.dev18.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,5 @@
1
+ from xync_schema import models
2
+
3
+
4
+ class AdLoader:
5
+ ex: models.Ex
xync_client/Abc/Agent.py CHANGED
@@ -1,21 +1,333 @@
1
+ import logging
1
2
  from abc import abstractmethod
3
+ from asyncio import create_task, sleep
4
+ from collections import defaultdict
5
+ from typing import Literal
2
6
 
3
7
  from pydantic import BaseModel
4
8
  from pyro_client.client.file import FileClient
9
+ from x_client import df_hdrs
5
10
  from x_client.aiohttp import Client as HttpClient
11
+ from xync_bot import XyncBot
12
+ from xync_client.Abc.PmAgent import PmAgentClient
13
+
14
+ from xync_client.Abc.InAgent import BaseInAgentClient
15
+
16
+ from xync_client.Bybit.etype.order import TakeAdReq
6
17
  from xync_schema import models
7
- from xync_schema.models import OrderStatus, Coin, Cur, Ad, AdStatus, Actor
18
+ from xync_schema.models import OrderStatus, Coin, Cur, Ad, AdStatus, Actor, Agent
8
19
  from xync_schema.xtype import BaseAd
9
20
 
10
21
  from xync_client.Abc.Ex import BaseExClient
11
- from xync_client.Abc.xtype import CredExOut, BaseOrderReq, BaseAdUpdate
22
+ from xync_client.Abc.xtype import CredExOut, BaseOrderReq, BaseAdUpdate, AdUpd, GetAds
23
+ from xync_client.Gmail import GmClient
24
+
25
+
26
+ class BaseAgentClient(HttpClient, BaseInAgentClient):
27
+ actor: Actor
28
+ agent: Agent
29
+ bbot: XyncBot
30
+ fbot: FileClient
31
+ ex_client: BaseExClient
32
+ pm_clients: dict[int, PmAgentClient] # {pm_id: PmAgentClient}
33
+ api: HttpClient
34
+ cred_x2e: dict[int, int] = {}
35
+ cred_e2x: dict[int, int] = {}
36
+
37
+ def __init__(
38
+ self,
39
+ agent: Agent, # agent.actor.person.user
40
+ ex_client: BaseExClient,
41
+ fbot: FileClient,
42
+ bbot: XyncBot,
43
+ pm_clients: dict[int, PmAgentClient] = None,
44
+ headers: dict[str, str] = df_hdrs,
45
+ cookies: dict[str, str] = None,
46
+ proxy: models.Proxy = None,
47
+ ):
48
+ self.bbot = bbot
49
+ self.fbot = fbot
50
+ self.agent: Agent = agent
51
+ self.actor: Actor = agent.actor
52
+ self.gmail = agent.actor.person.user.gmail and GmClient(agent.actor.person.user)
53
+ self.ex_client: BaseExClient = ex_client
54
+ self.pm_clients: dict[int, PmAgentClient] = defaultdict()
55
+ super().__init__(self.actor.ex.host_p2p, headers, cookies, proxy) # and proxy.str()
56
+ # start
57
+ create_task(self.start())
58
+
59
+ async def x2e_cred(self, cred_id: int) -> int: # cred.exid
60
+ if not self.cred_x2e.get(cred_id):
61
+ self.cred_x2e[cred_id] = (await models.CredEx.get(cred_id=cred_id)).exid
62
+ self.cred_e2x[self.cred_x2e[cred_id]] = cred_id
63
+ return self.cred_x2e[cred_id]
64
+
65
+ async def e2x_cred(self, exid: int) -> int: # cred.id
66
+ if not self.cred_e2x.get(exid):
67
+ self.cred_e2x[exid] = (await models.CredEx.get(exid=exid, ex=self.ex_client.ex)).cred_id
68
+ self.cred_x2e[self.cred_e2x[exid]] = exid
69
+ return self.cred_e2x[exid]
70
+
71
+ async def start(self):
72
+ if self.agent.status & 1: # щас пока юзаем статус чисто как бул актив
73
+ for race in await models.Race.filter(started=True, road__ad__maker_id=self.agent.actor_id).prefetch_related(
74
+ "road__ad__pair_side__pair__cur", "road__credexs__cred"
75
+ ):
76
+ create_task(self.racing(race))
77
+
78
+ async def racing(self, race: models.Race):
79
+ pair = race.road.ad.pair_side.pair
80
+ taker_side: int = not race.road.ad.pair_side.is_sell
81
+ # конвертим наши параметры гонки в ex-овые для конкретной биржи текущего агента
82
+ coinex: models.CoinEx = await models.CoinEx.get(coin_id=pair.coin_id, ex=self.actor.ex).prefetch_related("coin")
83
+ curex: models.CurEx = await models.CurEx.get(cur_id=pair.cur_id, ex=self.actor.ex).prefetch_related("cur")
84
+ creds = [c.cred for c in race.road.credexs]
85
+ pm_ids = [pm.id for pm in race.road.ad.pms]
86
+ pmexs: list[models.PmEx] = [pmex for pm in race.road.ad.pms for pmex in pm.pmexs if pmex.ex_id == 4]
87
+ post_pm_ids = {c.cred.ovr_pm_id for c in race.road.credexs if c.cred.ovr_pm_id}
88
+ post_pmexs = set(await models.PmEx.filter(pm_id__in=post_pm_ids, ex=self.actor.ex).prefetch_related("pm"))
89
+
90
+ k = (-1) ** taker_side # on_buy=1, on_sell=-1
91
+ sleep_sec = 3 # 1 if set(pms) & {"volet"} and coinex.coin_id == 1 else 5
92
+ _lstat, volume = None, 0
93
+
94
+ # погнали цикл гонки
95
+ while self.actor.person.user.status > 0: # todo: separate agents, not whole user.activity
96
+ # подгружаем из бд обновления по текущей гонке
97
+ await race.refresh_from_db()
98
+ if not race.started: # пока выключена
99
+ await sleep(5)
100
+ continue
101
+
102
+ # конверт бд int фильтровочной суммы в float конкретной биржи
103
+ amt = race.filter_amount * 10**-curex.cur.scale if race.filter_amount else None
104
+ ceils = await self.get_ceils(coinex, curex, pmexs, 0.003, 0, amt, post_pmexs)
105
+ race.ceil = int(ceils[taker_side] * 10**curex.scale)
106
+ await race.save()
107
+
108
+ last_vol = volume
109
+ if taker_side: # гонка в стакане продажи - мы покупаем монету за ФИАТ
110
+ fiat = max(await models.Fiat.filter(cred_id__in=[c.id for c in creds]), key=lambda x: x.amount)
111
+ volume = (fiat.amount * 10**-curex.cur.scale) / (race.road.ad.price * 10**-curex.scale)
112
+ else: # гонка в стакане покупки - мы продаем МОНЕТУ за фиат
113
+ asset = await models.Asset.get(addr__actor=self.actor, addr__coin_id=coinex.coin_id)
114
+ volume = asset.free * 10**-coinex.scale
115
+ volume = str(round(volume, coinex.scale))
116
+ get_ads_req = GetAds(
117
+ coin_id=pair.coin_id, cur_id=pair.cur_id, is_sell=bool(taker_side), pm_ids=pm_ids, amount=amt, limit=50
118
+ )
119
+ try:
120
+ ads: list[Ad] = await self.ex_client.ads(get_ads_req)
121
+ except Exception:
122
+ await sleep(1)
123
+ ads: list[Ad] = await self.ads(coinex, curex, taker_side, pmexs, amt, 50, race.vm_filter, post_pmexs)
124
+
125
+ self.overprice_filter(ads, race.ceil * 10**-curex.scale, k) # обрезаем сверху все ads дороже нашего потолка
12
126
 
127
+ if not ads:
128
+ print(coinex.exid, curex.exid, taker_side, "no ads!")
129
+ await sleep(15)
130
+ continue
131
+ # определяем наше текущее место в уже обрезанном списке ads
132
+ if not (cur_plc := [i for i, ad in enumerate(ads) if int(ad.userId) == self.actor.exid]):
133
+ logging.warning(f"No racing in {pmexs[0].name} {'-' if taker_side else '+'}{coinex.exid}/{curex.exid}")
134
+ await sleep(15)
135
+ continue
136
+ (cur_plc,) = cur_plc # может упасть если в списке > 1 наш ad
137
+ [(await self.ex_client.cond_load(ad, race.road.ad.pair_side, True))[0] for ad in ads[:cur_plc]]
138
+ # rivals = [
139
+ # (await models.RaceStat.update_or_create({"place": plc, "price": ad.price, "premium": ad.premium}, ad=ad))[
140
+ # 0
141
+ # ]
142
+ # for plc, ad in enumerate(rads)
143
+ # ]
144
+ mad: Ad = ads.pop(cur_plc)
145
+ # if (
146
+ # not (lstat := lstat or await race.stats.order_by("-created_at").first())
147
+ # or lstat.place != cur_plc
148
+ # or lstat.price != float(mad.price)
149
+ # or set(rivals) != set(await lstat.rivals)
150
+ # ):
151
+ # lstat = await models.RaceStat.create(race=race, place=cur_plc, price=mad.price, premium=mad.premium)
152
+ # await lstat.rivals.add(*rivals)
153
+ if not ads:
154
+ await sleep(60)
155
+ continue
156
+ if not (cad := self.get_cad(ads, race.ceil * 10**-curex.scale, k, race.target_place, cur_plc)):
157
+ continue
158
+ new_price = round(float(cad.price) - k * step(mad, cad, curex.scale), curex.scale)
159
+ if (
160
+ float(mad.price) == new_price and volume == last_vol
161
+ ): # Если место уже нужное или нужная цена и так уже стоит
162
+ print(
163
+ f"{'v' if taker_side else '^'}{mad.price}",
164
+ end=f"[{race.ceil * 10**-curex.scale}+{cur_plc}] ",
165
+ flush=True,
166
+ )
167
+ await sleep(sleep_sec)
168
+ continue
169
+ if cad.priceType: # Если цена конкурента плавающая, то повышаем себе не цену, а %
170
+ new_premium = (float(mad.premium) or float(cad.premium)) - k * step(mad, cad, 2)
171
+ # if float(mad.premium) == new_premium: # Если нужный % и так уже стоит
172
+ # if mad.priceType and cur_plc != race.target_place:
173
+ # new_premium -= k * step(mad, cad, 2)
174
+ # elif volume == last_vol:
175
+ # print(end="v" if taker_side else "^", flush=True)
176
+ # await sleep(sleep_sec)
177
+ # continue
178
+ mad.premium = str(round(new_premium, 2))
179
+ mad.priceType = cad.priceType
180
+ mad.quantity = volume
181
+ mad.maxAmount = str(2_000_000 if curex.cur_id == 1 else 40_000)
182
+ # req = AdUpdateRequest.model_validate(
183
+ # {
184
+ # **mad.model_dump(),
185
+ # "price": str(round(new_price, curex.scale)),
186
+ # "paymentIds": [str(cx.exid) for cx in race.road.credexs],
187
+ # }
188
+ # )
189
+ # try:
190
+ # print(
191
+ # f"c{race.ceil * 10**-curex.scale}+{cur_plc} {coinex.coin.ticker}{'-' if taker_side else '+'}{req.price}{curex.cur.ticker}"
192
+ # f"{[pm.norm for pm in race.road.ad.pms]}{f'({req.premium}%)' if req.premium != '0' else ''} "
193
+ # f"t{race.target_place} ;",
194
+ # flush=True,
195
+ # )
196
+ # _res = self.ad_upd(req)
197
+ # except FailedRequestError as e:
198
+ # if ExcCode(e.status_code) == ExcCode.FixPriceLimit:
199
+ # if limits := re.search(
200
+ # r"The fixed price set is lower than ([0-9]+\.?[0-9]{0,2}) or higher than ([0-9]+\.?[0-9]{0,2})",
201
+ # e.message,
202
+ # ):
203
+ # req.price = limits.group(1 if taker_side else 2)
204
+ # if req.price != mad.price:
205
+ # _res = self.ad_upd(req)
206
+ # else:
207
+ # raise e
208
+ # elif ExcCode(e.status_code) == ExcCode.InsufficientBalance:
209
+ # asset = await models.Asset.get(addr__actor=self.actor, addr__coin_id=coinex.coin_id)
210
+ # req.quantity = str(round(asset.free * 10**-coinex.scale, coinex.scale))
211
+ # _res = self.ad_upd(req)
212
+ # elif ExcCode(e.status_code) == ExcCode.RareLimit:
213
+ # if not (
214
+ # sads := [
215
+ # ma
216
+ # for ma in self.my_ads(False)
217
+ # if (
218
+ # ma.currencyId == curex.exid
219
+ # and ma.tokenId == coinex.exid
220
+ # and taker_side != ma.side
221
+ # and set(ma.payments) == set([pe.exid for pe in pmexs])
222
+ # )
223
+ # ]
224
+ # ):
225
+ # logging.error(f"Need reserve Ad {'sell' if taker_side else 'buy'} {coinex.exid}/{curex.exid}")
226
+ # await sleep(90)
227
+ # continue
228
+ # self.ad_del(ad_id=int(mad.id))
229
+ # req.id = sads[0].id
230
+ # req.actionType = "ACTIVE"
231
+ # self.api.update_ad(**req.model_dump())
232
+ # logging.warning(f"Ad#{mad.id} recreated")
233
+ # # elif ExcCode(e.status_code) == ExcCode.Timestamp:
234
+ # # await sleep(3)
235
+ # else:
236
+ # raise e
237
+ # except (ReadTimeoutError, ConnectionDoesNotExistError):
238
+ # logging.warning("Connection failed. Restarting..")
239
+ await sleep(6)
13
240
 
14
- class BaseAgentClient(HttpClient):
15
- def __init__(self, actor: Actor, bot: FileClient, headers: dict[str, str] = None, cookies: dict[str, str] = None):
16
- self.actor: Actor = actor
17
- super().__init__(actor.ex.host_p2p, headers, cookies)
18
- self.ex_client: BaseExClient = self.actor.ex.client(bot)
241
+ async def get_books(
242
+ self,
243
+ coinex: models.CoinEx,
244
+ curex: models.CurEx,
245
+ pmexs: list[models.PmEx],
246
+ amount: int,
247
+ post_pmexs: list[models.PmEx] = None,
248
+ ) -> tuple[list[Ad], list[Ad]]:
249
+ buy: list[Ad] = await self.ads(coinex, curex, False, pmexs, amount, 40, False, post_pmexs)
250
+ sell: list[Ad] = await self.ads(coinex, curex, True, pmexs, amount, 30, False, post_pmexs)
251
+ return buy, sell
252
+
253
+ async def get_spread(
254
+ self, bb: list[Ad], sb: list[Ad], perc: float, place: int = 0
255
+ ) -> tuple[tuple[float, float], float, int] | None:
256
+ if len(bb) and len(sb):
257
+ buy_price, sell_price = float(bb[place].price), float(sb[place].price)
258
+ half_spread = (buy_price - sell_price) / (buy_price + sell_price)
259
+ if half_spread * 2 < perc:
260
+ return await self.get_spread(bb, sb, perc, place)
261
+ return (buy_price, sell_price), half_spread, place
262
+ return None
263
+
264
+ async def get_ceils(
265
+ self,
266
+ coinex: models.CoinEx,
267
+ curex: models.CurEx,
268
+ pmexs: list[models.PmEx],
269
+ min_prof=0.02,
270
+ place: int = 0,
271
+ amount: int = None,
272
+ post_pmexs: set[models.PmEx] = None,
273
+ ) -> tuple[float, float]: # todo: refact to Pairex
274
+ for pmc_id in {pmx.pm_id for pmx in pmexs} | set(self.pm_clients.keys()):
275
+ if ceils := self.pm_clients[pmc_id].get_ceils():
276
+ return ceils
277
+ bb, sb = await self.get_books(coinex, curex, pmexs, amount, post_pmexs)
278
+ perc = list(post_pmexs or pmexs)[0].pm.fee * 0.0001 + min_prof
279
+ (bf, sf), _hp, _zplace = await self.get_spread(bb, sb, perc, place)
280
+ mdl = (bf + sf) / 2 # middle price
281
+ bc, sc = mdl + mdl * (perc / 2), mdl - mdl * (perc / 2)
282
+ return bc, sc
283
+
284
+ async def mad_upd(self, mad: Ad, attrs: dict, cxids: list[str]):
285
+ if not [setattr(mad, k, v) for k, v in attrs.items() if getattr(mad, k) != v]:
286
+ print(end="v" if mad.side else "^", flush=True)
287
+ return await sleep(5)
288
+ # req = AdUpdateRequest.model_validate({**mad.model_dump(), "paymentIds": cxids})
289
+ # try:
290
+ # return self.ad_upd(req)
291
+ # except FailedRequestError as e:
292
+ # if ExcCode(e.status_code) == ExcCode.FixPriceLimit:
293
+ # if limits := re.search(
294
+ # r"The fixed price set is lower than ([0-9]+\.?[0-9]{0,2}) or higher than ([0-9]+\.?[0-9]{0,2})",
295
+ # e.message,
296
+ # ):
297
+ # return await self.mad_upd(mad, {"price": limits.group(1 if mad.side else 2)}, cxids)
298
+ # elif ExcCode(e.status_code) == ExcCode.RareLimit:
299
+ # await sleep(180)
300
+ # else:
301
+ # raise e
302
+ # except (ReadTimeoutError, ConnectionDoesNotExistError):
303
+ # logging.warning("Connection failed. Restarting..")
304
+ # print("-" if mad.side else "+", end=req.price, flush=True)
305
+ await sleep(60)
306
+
307
+ def overprice_filter(self, ads: list[Ad], ceil: float, k: Literal[-1, 1]):
308
+ # вырезаем ads с ценами выше потолка
309
+ if ads and (ceil - float(ads[0].price)) * k > 0:
310
+ if int(ads[0].userId) != self.actor.exid:
311
+ ads.pop(0)
312
+ self.overprice_filter(ads, ceil, k)
313
+
314
+ def get_cad(self, ads: list[Ad], ceil: float, k: Literal[-1, 1], target_place: int, cur_plc: int) -> Ad:
315
+ if not ads:
316
+ return None
317
+ # чью цену будем обгонять, предыдущей или слещующей объявы?
318
+ # cad: Ad = ads[place] if cur_plc > place else ads[cur_plc]
319
+ # переделал пока на жесткую установку целевого места, даже если текущее выше:
320
+ if len(ads) <= target_place:
321
+ logging.error(f"target place {target_place} not found in ads {len(ads)}-lenght list")
322
+ target_place = len(ads) - 1
323
+ cad: Ad = ads[target_place]
324
+ # а цена обгоняемой объявы не выше нашего потолка?
325
+ if (float(cad.price) - ceil) * k <= 0:
326
+ # тогда берем следующую
327
+ ads.pop(target_place)
328
+ cad = self.get_cad(ads, ceil, k, target_place, cur_plc)
329
+ # todo: добавить фильтр по лимитам min-max
330
+ return cad
19
331
 
20
332
  # 0: Получшение ордеров в статусе status, по монете coin, в валюте coin, в направлении is_sell: bool
21
333
  @abstractmethod
@@ -74,13 +386,27 @@ class BaseAgentClient(HttpClient):
74
386
  @abstractmethod
75
387
  async def my_ads(self, status: AdStatus = None) -> list[BaseAd]: ...
76
388
 
389
+ @abstractmethod
390
+ async def x2e_req_ad_upd(self, xreq: AdUpd) -> BaseAdUpdate: ...
391
+
77
392
  # 30: Создание объявления
78
393
  @abstractmethod
79
394
  async def ad_new(self, ad: BaseAd) -> Ad: ...
80
395
 
396
+ async def ad_upd(self, xreq: AdUpd) -> Ad:
397
+ xreq.credexs = await models.CredEx.filter(
398
+ ex_id=self.actor.ex_id,
399
+ cred__pmcur__pm_id__in=xreq.pm_ids,
400
+ cred__pmcur__cur_id=xreq.cur_id,
401
+ cred__person_id=self.actor.person_id,
402
+ ).prefetch_related("cred__pmcur")
403
+ # xreq.credexs = credexs
404
+ ereq = await self.x2e_req_ad_upd(xreq)
405
+ return await self._ad_upd(ereq)
406
+
81
407
  # 31: Редактирование объявления
82
408
  @abstractmethod
83
- async def ad_upd(self, ad: BaseAdUpdate) -> Ad: ...
409
+ async def _ad_upd(self, ad: BaseAdUpdate) -> Ad: ...
84
410
 
85
411
  # 32: Удаление
86
412
  @abstractmethod
@@ -115,6 +441,9 @@ class BaseAgentClient(HttpClient):
115
441
  @abstractmethod
116
442
  async def my_assets(self) -> dict: ...
117
443
 
444
+ @abstractmethod
445
+ async def take_ad(self, req: TakeAdReq): ...
446
+
118
447
  # Сохранение объявления (с Pm/Cred-ами) в бд
119
448
  # async def ad_pydin2db(self, ad_pydin: AdSaleIn | AdBuyIn) -> Ad:
120
449
  # ad_db = await self.ex_client.ad_pydin2db(ad_pydin)
@@ -130,3 +459,20 @@ class BaseAgentClient(HttpClient):
130
459
  # if banks: # only for SBP
131
460
  # await cred_db.banks.add(*[await PmExBank.get(exid=b) for b in banks])
132
461
  # return True
462
+
463
+
464
+ def step_is_need(mad, cad) -> bool:
465
+ # todo: пока не решен непонятный кейс, почему то конкурент по всем параметрам слабже, но в списке ранжируется выше.
466
+ # текущая версия: recentExecuteRate округляется до целого, но на бэке байбита его дробная часть больше
467
+ return (
468
+ bool(set(cad.authTag) & {"VA2", "BA"})
469
+ or cad.recentExecuteRate > mad.recentExecuteRate
470
+ or (
471
+ cad.recentExecuteRate
472
+ == mad.recentExecuteRate # and cad.finishNum > mad.finishNum # пока прибавляем для равных
473
+ )
474
+ )
475
+
476
+
477
+ def step(mad, cad, scale: int = 2) -> float:
478
+ return float(int(step_is_need(mad, cad)) * 10**-scale).__round__(scale)