den4ik2003

Untitled

Jul 1st, 2026
39
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 21.34 KB | None | 0 0
  1. from pydmm_connectors.core.logger import MarketLogger
  2. from pydmm_strategy.volume.config import VolumeMode
  3. from pydmm_connectors.models import Ticker
  4. from pydmm_connectors.models.connector_responses import Depth, Balance, BestBook
  5. from pydmm_strategy.volume.market_factory import VolumeMarketFactory, MarketDataType, MarketSnapshot
  6. from enum import auto, Enum
  7. from dataclasses import dataclass
  8.  
  9. import numpy as np
  10. import time
  11. from typing import Tuple, Optional, List, Any, Callable
  12. from concurrent.futures import Future
  13. from concurrent.futures import ThreadPoolExecutor
  14. from threading import Thread
  15.  
  16. class ExecutionStatus(Enum):
  17.     SUCCESS = auto()
  18.     FAILURE = auto()  # просто ошибка, не критичная
  19.     STOPPED = auto()  # Бот нужно остановить
  20.     _SUCCESS_REBALANCED = auto()  # (внутренний статус): Сделали ребаланс -> основной трейд в этой итерации не делаем
  21.  
  22. @dataclass
  23. class ExecutorInput:
  24.     price: float
  25.     quantity: float
  26.     target_price: float
  27.     book: BestBook
  28.     side: Optional[bool] = None  # True=buy, False=sell. Used by pam_cex path; None for spot self-trades.
  29.     is_5m_open: bool = False     # True when this iter fires right after a 5m candle boundary (set by CandleCloser)
  30.  
  31.  
  32. class VolumeExecutor:
  33.     def __init__(
  34.         self,
  35.         market_factory: VolumeMarketFactory,
  36.         logger: MarketLogger,
  37.         ticker: Ticker,
  38.         mode: VolumeMode,
  39.         n_markets: int,
  40.         force_rebalance: bool,
  41.         bbo_spread_size: float,
  42.         buy_ratio: float,
  43.         tight_mode_enabled: bool,
  44.         tight_min_base: float,
  45.         tight_min_quote: float,
  46.         min_quote_size: float = 0.0,
  47.     ):
  48.         self._logger = logger
  49.         self._market_factory = market_factory
  50.         self._thread_pool = ThreadPoolExecutor(max_workers=8) # TODO: отдельный тред пул для экзекутора
  51.         self._mode = mode
  52.         self._n_markets = n_markets
  53.         self.ticker = ticker
  54.         self._min_quote_size = min_quote_size if min_quote_size > 0 else ticker.min_notional
  55.         self._step_size = 0.1 ** ticker.price_prec
  56.         self._i1 = 0
  57.         self._i2 = 0
  58.         self._last_trade_price: Optional[float] = None
  59.         self._bbo_spread_size = bbo_spread_size
  60.         self._buy_ratio = buy_ratio
  61.         self._tight_mode_enabled = tight_mode_enabled
  62.         self._tight_min_base = tight_min_base
  63.         self._tight_min_quote = tight_min_quote
  64.         self._maker_taker_delay = 0.01
  65.         # Anti-overflow self-match side selection (see _pick_self_trade_side): when the quoted
  66.         # price sits in the outer band of the spread, choose which leg is the maker so the TAKER
  67.         # leg points toward the FAR edge. Band is a fraction of the spread, floored to a tick
  68.         # count so it stays meaningful on narrow spreads.
  69.         self._edge_band_frac = 0.10
  70.         self._edge_band_min_ticks = 1
  71.         if force_rebalance:
  72.             book = self._market_factory.get_market_data_and_log(MarketDataType.BEST_BOOK.value).best_book
  73.             self._logger.event('force_rebalance_book', bid=book.bid_price, ask=book.ask_price)
  74.             self._last_trade_price = (book.ask_price + book.bid_price) / 2
  75.  
  76.     def _generate_random_indexes(self) -> Tuple[int, int]:
  77.         if self._mode == VolumeMode.SMART and self._n_markets > 1:
  78.             i1 = np.random.randint(0, self._n_markets)
  79.             i2 = np.random.randint(0, self._n_markets - 1)
  80.             i2 += (i2 >= i1)
  81.         else:
  82.             i1 = 0
  83.             i2 = 0
  84.  
  85.         return i1, i2
  86.  
  87.     def _check_last_price_and_log(self) -> bool:
  88.         """Helper: проверяет last_price и логирует ошибку если None"""
  89.         if self._last_trade_price is None:
  90.             self._logger.error(
  91.                 'there will be no trades! '
  92.                 'turn on "Force rebalance" flag '
  93.                 '(be careful if the price has changed significantly)'
  94.             )
  95.             return True
  96.         return False
  97.  
  98.     # TODO: не использовать book
  99.     def _execute_bbo_trade(self, order_futures: List[Future[Any]], qty: float, book: BestBook, depth: Depth) -> float:
  100.         ask_qty, bid_qty = 0, 0
  101.         if len(depth.asks) > 0:
  102.             ask_qty = depth.asks[0].quantity
  103.         if len(depth.bids) > 0:
  104.             bid_qty = depth.bids[0].quantity
  105.         qty = max(
  106.             min(qty, min(ask_qty, bid_qty)),
  107.             self._min_quote_size / book.bid_price
  108.         )
  109.         self._logger.event(
  110.             'bbo trade',
  111.             qty=qty,
  112.             amt_bid=qty * book.bid_price,
  113.             amt_ask=qty * book.ask_price,
  114.         )
  115.         if book.bid_price <= 0 or (book.ask_price - book.bid_price) / book.bid_price >= self._bbo_spread_size:  # TODO: config
  116.             self._logger.error(f"Can't do BBO trade: spread is too wide")
  117.             return 0
  118.         else:
  119.             order_futures.append(self._thread_pool.submit(self._market_factory.new_limit, 0, book.bid_price, qty, False))
  120.             order_futures.append(self._thread_pool.submit(self._market_factory.new_limit, 0, book.ask_price, qty, True))
  121.             return qty * (book.bid_price + book.ask_price)
  122.  
  123.     def _execute_smart_trade(self, order_futures: List[Future[Any]], price: float, qty: float) -> float:
  124.         if np.random.random() <= self._buy_ratio: # TODO: config
  125.             order_futures.append(self._thread_pool.submit(self._market_factory.new_limit_maker, self._i1, price, qty, False))
  126.             time.sleep(self._maker_taker_delay)
  127.             order_futures.append(self._thread_pool.submit(self._market_factory.new_limit, self._i2, price, qty, True))
  128.             return qty * price
  129.         else:
  130.             order_futures.append(self._thread_pool.submit(self._market_factory.new_limit_maker, self._i1, price, qty, True))
  131.             time.sleep(self._maker_taker_delay)
  132.             order_futures.append(self._thread_pool.submit(self._market_factory.new_limit, self._i2, price, qty, False))
  133.             return qty * price
  134.  
  135.     def _execute_tight_trade(self, request: ExecutorInput, balance: Balance) -> float:
  136.         book = request.book
  137.         qty = request.quantity
  138.         target_rounded = round(request.target_price, self.ticker.price_prec)
  139.         self._logger.event(
  140.             'execution in tight',
  141.             bid=book.bid_price,
  142.             ask=book.ask_price,
  143.             target_rounded=target_rounded,
  144.         )
  145.  
  146.         if book.bid_price <= target_rounded <= book.ask_price:
  147.             if request.is_5m_open and (request.price == book.ask_price or request.price == book.bid_price):
  148.                 is_buy = (request.price == book.ask_price)
  149.                 self._logger.event('tight 5m opener', side='buy' if is_buy else 'sell')
  150.             else:
  151.                 is_buy = np.random.randint(0, 1000) / 1000 < self._buy_ratio
  152.         else:
  153.             is_buy = (target_rounded >= book.ask_price)
  154.         if is_buy:
  155.             qty = max(qty, self._min_quote_size / book.ask_price)
  156.             if balance.quote - qty * book.ask_price >= self._tight_min_quote:
  157.                 self._logger.event('tight buy ok', qty=qty)
  158.                 self._market_factory.new_limit(self._i1, book.ask_price, qty, True) # убрал поток
  159.                 self._last_trade_price = book.ask_price
  160.                 return book.ask_price * qty
  161.             else:
  162.                 self._logger.event(
  163.                     'tight buy skip',
  164.                     quote_bal=balance.quote,
  165.                     need=qty * book.ask_price,
  166.                     min_quote=self._tight_min_quote,
  167.                 )
  168.                 return 0
  169.         else:
  170.             qty = max(qty, self._min_quote_size / book.bid_price)
  171.             if balance.base - qty >= self._tight_min_base:
  172.                 self._logger.event('tight sell ok', qty=qty)
  173.                 self._market_factory.new_limit(self._i1, book.bid_price, qty, False) # убрал поток
  174.                 self._last_trade_price = book.bid_price
  175.                 return book.bid_price * qty
  176.             else:
  177.                 self._logger.event(
  178.                     'tight sell skip',
  179.                     base_bal=balance.base,
  180.                     qty=qty,
  181.                     min_base=self._tight_min_base,
  182.                 )
  183.                 return 0
  184.  
  185.     def _execute_open_5m_tight_trade(self, request: ExecutorInput, balance: Balance) -> float:
  186.         """Срабатывает когда узкий спред и нужно открыть пятиминутку. Нужна только для улучшения читаемости."""
  187.         self._logger.event('try to do tight 5m opener')
  188.         return self._execute_tight_trade(request, balance)
  189.  
  190.     def _pick_self_trade_side(self, price: float, book: BestBook, default_buy: bool) -> bool:
  191.         """Pick which leg of the self-match is the maker, to keep the taker leg from crossing
  192.        into a resting (e.g. MM-bot) order at the near edge of the spread.
  193.  
  194.        Near the LOWER edge the danger is the bid jumping up into a SELL taker, so we make the
  195.        maker a SELL => taker BUY, which is immune to bid-side moves (a BUY only matches asks
  196.        <= price; the nearest ask is a whole spread away). Near the UPPER edge we mirror it:
  197.        maker BUY => taker SELL. In the middle of the spread the directional risk is low, so we
  198.        keep the caller's default (buy_ratio) side untouched.
  199.  
  200.        Returns buy_trade: True => taker BUY / maker SELL.
  201.  
  202.        Applies only within the outer band of the spread. The band is a fraction of the spread
  203.        (_edge_band_frac), floored to _edge_band_min_ticks so it stays meaningful when the spread
  204.        is only a few ticks wide.
  205.        """
  206.         spread = book.ask_price - book.bid_price
  207.         if spread <= 0:
  208.             return default_buy
  209.  
  210.         eps = self._step_size * 1e-3
  211.         edge_band = max(self._edge_band_frac * spread, self._edge_band_min_ticks * self._step_size) + eps
  212.         dist_from_bid = price - book.bid_price
  213.         dist_from_ask = book.ask_price - price
  214.  
  215.         if dist_from_bid <= edge_band and dist_from_bid <= dist_from_ask:
  216.             buy_trade, region = True, 'lower_edge'    # maker SELL, taker BUY
  217.         elif dist_from_ask <= edge_band and dist_from_ask < dist_from_bid:
  218.             buy_trade, region = False, 'upper_edge'   # maker BUY, taker SELL
  219.         else:
  220.             return default_buy  # middle of the spread -> keep buy_ratio, no log (common no-op)
  221.  
  222.         # Only log when the edge rule actually engages.
  223.         self._logger.event(
  224.             'self trade side select',
  225.             region=region,
  226.             maker_side='sell' if buy_trade else 'buy',
  227.             price=price, bid=book.bid_price, ask=book.ask_price,
  228.         )
  229.         return buy_trade
  230.  
  231.     def _execute_simple_self_trade(
  232.         self,
  233.         order_futures: List[Future[Any]],
  234.         buy_trade: bool,
  235.         price: float,
  236.         qty: float,
  237.         use_limit_only: bool = False,
  238.     ) -> float:
  239.         self._logger.event(
  240.             'self trade preview',
  241.             side='buy' if buy_trade else 'sell',
  242.             p=price,
  243.             amt=price * qty,
  244.         )
  245.  
  246.         if use_limit_only:
  247.             order_futures.append(
  248.                 self._thread_pool.submit(
  249.                     self._market_factory.new_limit, 0, price, qty, not buy_trade
  250.                 )
  251.             )
  252.         else: # primary
  253.             order_futures.append(
  254.                 self._thread_pool.submit(
  255.                     self._market_factory.new_limit_maker, 0, price, qty, not buy_trade
  256.                 )
  257.             )
  258.         time.sleep(self._maker_taker_delay)
  259.         order_futures.append(
  260.             self._thread_pool.submit(
  261.                 self._market_factory.new_limit, 0, price, qty, buy_trade
  262.             )
  263.         )
  264.         return price * qty
  265.    
  266.     def _execute_open_5m_self_trade(
  267.         self,
  268.         order_futures: List[Future[Any]],
  269.         buy_trade: bool,
  270.         price: float,
  271.         qty: float,
  272.     ) -> float:
  273.         self._logger.event('open 5m candle self trade')
  274.  
  275.         return self._execute_simple_self_trade(
  276.             order_futures, buy_trade, price, qty, use_limit_only=True
  277.         )
  278.  
  279.     def _execute_rebalance_trade(
  280.         self,
  281.         balances: Tuple[Balance, Balance],
  282.         book: BestBook,
  283.         qty: float,
  284.         price: float,
  285.     ) -> Tuple[ExecutionStatus, float]:
  286.  
  287.         exec_amt = 0.0
  288.  
  289.         if balances[0].base < qty:
  290.             self._logger.warning(f"base balance ({balances[0].base}) lower than trade quantity ({qty})")
  291.             if self._check_last_price_and_log():
  292.                 return (ExecutionStatus.STOPPED, 0)
  293.             if abs(book.ask_price / self._last_trade_price - 1) <= 0.01:
  294.                 self._market_factory.new_limit(self._i1, book.ask_price, qty, True)
  295.                 self._last_trade_price = book.ask_price
  296.                 exec_amt = qty * book.ask_price
  297.  
  298.         elif balances[0].quote < qty * price: # TODO
  299.             self._logger.warning(f"base balance ({balances[0].quote}) lower than trade quantity ({qty * price})")
  300.             if self._check_last_price_and_log():
  301.                 return (ExecutionStatus.STOPPED, 0)
  302.             if abs(book.bid_price / self._last_trade_price - 1) <= 0.01:
  303.                 self._market_factory.new_limit(self._i1, book.bid_price, qty, False)
  304.                 self._last_trade_price = book.bid_price
  305.                 exec_amt = qty * book.bid_price
  306.  
  307.         elif balances[1].base < qty: # TODO: два if это х2 объём, но при этом хочется это сделать отдельным if
  308.             self._logger.warning(f"base balance ({balances[0].base}) lower than trade quantity ({qty})")
  309.             if self._check_last_price_and_log():
  310.                 return (ExecutionStatus.STOPPED, 0)
  311.             if abs(book.ask_price / self._last_trade_price - 1) <= 0.01: # TODO: если else, то это неудача по сути
  312.                 self._market_factory.new_limit(self._i2, book.ask_price, qty, True)
  313.                 self._last_trade_price = book.ask_price
  314.                 exec_amt = qty * book.ask_price
  315.  
  316.         elif balances[1].quote < qty * price: # TODO
  317.             self._logger.warning(f"base balance ({balances[0].quote}) lower than trade quantity ({qty * price})")
  318.             if self._check_last_price_and_log():
  319.                 return (ExecutionStatus.STOPPED, 0)
  320.             if abs(book.bid_price / self._last_trade_price - 1) <= 0.01:
  321.                 self._market_factory.new_limit(self._i2, book.bid_price, qty, False)
  322.                 self._last_trade_price = book.bid_price
  323.                 exec_amt = book.bid_price * qty
  324.  
  325.         else:
  326.             return (ExecutionStatus.SUCCESS, 0)
  327.  
  328.         return (ExecutionStatus._SUCCESS_REBALANCED, exec_amt)
  329.  
  330.     def execute_volume_trade(self, request: ExecutorInput, snapshot: MarketSnapshot) -> Tuple[ExecutionStatus, float] | None:
  331.         self._i1, self._i2 = self._generate_random_indexes()
  332.         balances: Tuple[Balance, Balance] = (snapshot.balance[self._i1], snapshot.balance[self._i2])
  333.         depth: Depth = snapshot.depth
  334.         book: BestBook = request.book
  335.         price = request.price
  336.  
  337.         execution_status, exec_amt = self._execute_rebalance_trade(balances, book, request.quantity, request.price)
  338.         if execution_status == ExecutionStatus.STOPPED:
  339.             return (ExecutionStatus.STOPPED, exec_amt)
  340.  
  341.         if execution_status == ExecutionStatus._SUCCESS_REBALANCED:
  342.             self._logger.event('rebalance ok')
  343.             return (ExecutionStatus.SUCCESS, exec_amt)
  344.  
  345.         if execution_status == ExecutionStatus.SUCCESS:
  346.             order_futures: List[Future[Any]] = []
  347.  
  348.             if request.price <= book.ask_price - self._step_size / 2 and request.price >= book.bid_price + self._step_size / 2:
  349.                
  350.                 buy_trade = (np.random.random() <= self._buy_ratio)
  351.  
  352.                 if self._mode == VolumeMode.BBO:
  353.                     exec_amt = self._execute_bbo_trade(order_futures, request.quantity, book, depth)
  354.  
  355.                 elif self._mode == VolumeMode.SMART:
  356.                     exec_amt = self._execute_smart_trade(order_futures, request.price, request.quantity)
  357.  
  358.                 elif request.is_5m_open:
  359.                     exec_amt = self._execute_open_5m_self_trade(
  360.                         order_futures, buy_trade=buy_trade, price=request.price, qty=request.quantity
  361.                     )
  362.  
  363.                 else:
  364.                     buy_trade = self._pick_self_trade_side(request.price, book, default_buy=buy_trade)
  365.                     exec_amt = self._execute_simple_self_trade(
  366.                         order_futures, buy_trade=buy_trade, price=request.price, qty=request.quantity
  367.                     )
  368.  
  369.             elif self._tight_mode_enabled:
  370.                 if request.is_5m_open:
  371.                     exec_amt = self._execute_open_5m_tight_trade(request, balances[0])
  372.                 else:
  373.                     exec_amt = self._execute_tight_trade(request, balances[0])
  374.                 price = self._last_trade_price
  375.  
  376.             elif self._mode == VolumeMode.BBO:
  377.                 self._logger.event('BBO trade in tight...')
  378.                 exec_amt = self._execute_bbo_trade(order_futures, request.quantity, book, depth)
  379.  
  380.             else:
  381.                 self._logger.event(
  382.                     'trade_skip_out_of_bounds',
  383.                     price=request.price,
  384.                     bid=book.bid_price,
  385.                     ask=book.ask_price,
  386.                 )
  387.                 return (ExecutionStatus.FAILURE, exec_amt)
  388.  
  389.             self._last_trade_price = price
  390.  
  391.         return (ExecutionStatus.SUCCESS, exec_amt)
  392.  
  393.     def execute_pam_trade(self, request: ExecutorInput, snapshot: MarketSnapshot) -> Tuple[ExecutionStatus, float] | None:
  394.         """Place a single limit order for periodic-auction matching (binance.pam_cex).
  395.  
  396.        Side is dictated by the quoter (strict alternation). No maker+taker pair: in PAM
  397.        the auction matches crossed levels, so we just submit our side and wait.
  398.        """
  399.         if request.side is None:
  400.             self._logger.error('execute_pam_trade: side is None')
  401.             return (ExecutionStatus.FAILURE, 0.0)
  402.         is_buy = bool(request.side)
  403.  
  404.         balances = snapshot.balance or []
  405.         if balances:
  406.             bal = balances[0]
  407.             need_quote = request.price * request.quantity
  408.             if is_buy and bal.quote < need_quote:
  409.                 self._logger.warning(
  410.                     f'pam_trade buy: insufficient quote {bal.quote} < {need_quote}'
  411.                 )
  412.                 return (ExecutionStatus.FAILURE, 0.0)
  413.             if not is_buy and bal.base < request.quantity:
  414.                 self._logger.warning(
  415.                     f'pam_trade sell: insufficient base {bal.base} < {request.quantity}'
  416.                 )
  417.                 return (ExecutionStatus.FAILURE, 0.0)
  418.  
  419.         self._logger.event(
  420.             'pam_trade',
  421.             side='buy' if is_buy else 'sell',
  422.             p=request.price,
  423.             q=request.quantity,
  424.             amt=request.price * request.quantity,
  425.         )
  426.         try:
  427.             if self._mode == VolumeMode.SMART:
  428.                 id = np.random.randint(0, 2)
  429.                 self._market_factory.new_limit(id, request.price, request.quantity, is_buy)
  430.                 self._market_factory.new_limit(id ^ 1, request.price, request.quantity, not is_buy)
  431.             else:
  432.                 self._market_factory.new_limit(id, request.price, request.quantity, is_buy)
  433.  
  434.         except Exception as e:
  435.             self._logger.error(f'pam_trade place error: {e}')
  436.             return (ExecutionStatus.FAILURE, 0.0)
  437.  
  438.         self._last_trade_price = request.price
  439.         return (ExecutionStatus.SUCCESS, request.price * request.quantity)
  440.  
  441.     def execute_cancel_orders(self):
  442.         def _safe_call(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
  443.             try:
  444.                 fn(*args, **kwargs)
  445.             except Exception as e:
  446.                 self._logger.error(f"VolumeExecutor.execute_cancel_orders exception in thread: {e}")
  447.  
  448.         Thread(target=_safe_call, args=(self._market_factory.cancel_open_orders, self._i1)).start()
  449.         if self._i1 != self._i2:
  450.             Thread(target=_safe_call, args=(self._market_factory.cancel_open_orders, self._i2)).start()
  451.  
  452.     def verify_last_trade_matched(self) -> Tuple[bool, float]:
  453.         """Re-query private trades on {i1, i2} to confirm the self-trade matched.
  454.  
  455.        Returns (matched, matched_quote_volume); the volume double-counts (maker + taker legs).
  456.        {i1, i2} covers all paths: DEFAULT/BBO use index 0, SMART uses i1 and i2."""
  457.         matched_volume = 0.0
  458.         trade_count = 0
  459.         for idx in {self._i1, self._i2}:
  460.             try:
  461.                 trades = self._market_factory.get_new_private_trades(idx)
  462.             except Exception as e:
  463.                 self._logger.error(f'verify_last_trade_matched: get_new_private_trades({idx}) error: {e}')
  464.                 continue
  465.             for t in (trades or []):
  466.                 trade_count += 1
  467.                 matched_volume += float(t.price) * float(t.quantity)
  468.         return (trade_count > 0, matched_volume)
  469.  
  470.     def get_last_trade_price(self) -> Optional[float]:
  471.         return self._last_trade_price
  472.  
Advertisement
Add Comment
Please, Sign In to add comment