den4ik2003

Untitled

Aug 5th, 2026
21
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 38.63 KB | None | 0 0
  1. import asyncio
  2. import sys
  3. import aiopg
  4. import numpy as np
  5. import time
  6. from configparser import ConfigParser
  7. from datetime import datetime, date, timezone, timedelta
  8.  
  9. class DBReader:
  10.    
  11.     def __init__(self, path_to_config, max_connections=3):
  12.         parser = ConfigParser()
  13.         parser.read(path_to_config)
  14.  
  15.         self.db_params = {}
  16.         if parser.has_section('postgresql'):
  17.             params = parser.items('postgresql')
  18.             for param in params:
  19.                 self.db_params[param[0]] = param[1]
  20.  
  21.         self.pool = None
  22.         self.max_connections = max_connections
  23.         self.symbol_id_to_ticket, self.symbols_id_in_group = {}, {}
  24.         self.timezones = {'MEI': 9}
  25.  
  26.     async def init_pool(self, side=False):
  27.         if self.pool is None:
  28.             self.pool = await aiopg.create_pool(**self.db_params, minsize=1, maxsize=self.max_connections)
  29.             await self.update_symbols_list(side)
  30.  
  31.     async def update_symbols_list(self, side=False):
  32.         if side:
  33.             additional = ""
  34.         else:
  35.             additional = """
  36.                         JOIN id_group_relation USING (symbol_id)
  37.                         JOIN groups USING (group_id)
  38.                         """
  39.         symbols_query = f"""
  40.            SELECT symbol_id, base, quote, exchange_name {", username" if side else ''}
  41.            FROM symbols JOIN exchanges USING (exchange_id)
  42.            {additional}
  43.            WHERE exchange_type = 'spot'
  44.        """
  45.         symbols = await self.execute_select(symbols_query)
  46.         names = {}
  47.  
  48.         self.symbols_id_in_group, self.symbol_id_to_ticket = {}, {}
  49.  
  50.         for symbol in symbols:
  51.             name = (symbol[1] + '/' + symbol[2] + ' ' + symbol[3].lower())
  52.             group = symbol[1]
  53.             if side:
  54.                 name = (symbol[1] + '/' + symbol[2] + ' ' + symbol[3].lower() + ' ' + symbol[4].lower())
  55.                 group = (symbol[1] + '_' + symbol[2] + '.' + symbol[4].lower())
  56.  
  57.             if group not in self.symbols_id_in_group:
  58.                 self.symbols_id_in_group[group] = []
  59.                
  60.             self.symbols_id_in_group[group].append(symbol[0])
  61.             self.symbol_id_to_ticket[symbol[0]] = name
  62.  
  63.         print('[UPDATED] symbols_id_in_group:', self.symbols_id_in_group)
  64.         print('[UPDATED] symbol_id_to_ticket:', self.symbol_id_to_ticket)
  65.  
  66.     async def get_balances_by_group(self, group, strategy=None):
  67.         print('A', file=sys.stderr)
  68.         balances_answer = ''
  69.  
  70.         for symbol in self.symbols_id_in_group[group]:
  71.             balance_query = f"""
  72.                SELECT time, base, quote
  73.                FROM balance_{symbol}
  74.                {f"WHERE strategy = '{strategy}'" if strategy else ''}
  75.                ORDER BY time DESC
  76.                LIMIT 1;
  77.            """
  78.  
  79.             balance_record = await self.execute_select(balance_query)
  80.            
  81.             if not balance_record:
  82.                 continue
  83.  
  84.             balance_record = balance_record[0]
  85.             token = self.symbol_id_to_ticket[symbol].split('/')[0]
  86.  
  87.             base = str(round(float(balance_record[1]), 2))
  88.             quote = str(round(float(balance_record[2]), 2))
  89.             time = balance_record[0].replace(tzinfo=timezone.utc).astimezone(timezone(timedelta(hours=self.timezones.get(token, 0)))).strftime("%Y-%m-%d %H:%M:%S")
  90.  
  91.             balances_answer += self.symbol_id_to_ticket[symbol] + ':\n'
  92.             balances_answer += 'time: ' + time + '\n'
  93.             balances_answer += f'{token}: ' + base + '\n'
  94.             balances_answer += 'USDT: ' + quote + '\n\n'
  95.            
  96.         print('B', file=sys.stderr)
  97.         return balances_answer
  98.  
  99.     async def get_volumes_by_group(self, group):
  100.         volumes_answer = ''
  101.         for symbol in self.symbols_id_in_group[group]:
  102.             public_trades_24h_sum_query = f"""
  103.                SELECT SUM(price * quantity)
  104.                FROM public_trade_{symbol}
  105.                WHERE time > NOW() - interval '1 day'
  106.            """
  107.             res = await self.execute_select(public_trades_24h_sum_query)
  108.             if not res:
  109.                 continue
  110.             res = res[0]
  111.             public_trades_24h_sum = 0
  112.             if res and res[0]:
  113.                 public_trades_24h_sum = np.round(res[0], 2)
  114.  
  115.             user_trades_24h_sum_query = f"""
  116.                SELECT SUM(price * quantity) / 2
  117.                FROM user_trade_{symbol}
  118.                WHERE strategy = 'V' AND time > NOW() - interval '1 day'
  119.            """
  120.             res = await self.execute_select(user_trades_24h_sum_query)
  121.             if not res:
  122.                 continue
  123.             res = res[0]
  124.             user_trades_24h_sum = 0
  125.             if res and res[0]:
  126.                 user_trades_24h_sum = np.round(res[0], 2)
  127.            
  128.             volumes_answer += self.symbol_id_to_ticket[symbol] + ':\n'
  129.             volumes_answer += f'total liquidity (24h): {public_trades_24h_sum}\n'
  130.             volumes_answer += f'volume bot liquidity (24h): {user_trades_24h_sum}\n\n'
  131.  
  132.         return volumes_answer
  133.  
  134.     @staticmethod
  135.     def _fmt_price(price):
  136.         """Format price removing floating point artifacts."""
  137.         s = f'{float(price):.10f}'.rstrip('0').rstrip('.')
  138.         return s
  139.  
  140.     @staticmethod
  141.     def _fmt_usdt(value, signed=False):
  142.         """Format USDT amount as HTML to be wrapped in <code>...</code>.
  143.        Each thousands-group becomes its own <code> span so the space
  144.        between groups renders proportionally (tight) while digits keep
  145.        their <code> highlight.
  146.        Examples (wrapper <code>...</code> added by caller):
  147.            47516   -> '47</code> <code>516'
  148.            566     -> '566'
  149.            -865437 -> '-865</code> <code>437'
  150.        signed=True forces a '+' prefix for non-negative values."""
  151.         rounded = int(round(float(value)))
  152.         sign = ''
  153.         if rounded < 0:
  154.             sign = '-'
  155.             rounded = -rounded
  156.         elif signed:
  157.             sign = '+'
  158.         s = str(rounded)
  159.         groups = []
  160.         while s:
  161.             groups.insert(0, s[-3:])
  162.             s = s[:-3]
  163.         return sign + '</code> <code>'.join(groups)
  164.  
  165.     async def get_volume_report_by_group(self, group, date_from, date_to):
  166.         report_answer = ''
  167.         for symbol in self.symbols_id_in_group[group]:
  168.             # Daily volume + is_taker split + daily fee joined
  169.             daily_query = f"""
  170.                WITH daily_fees AS (
  171.                    SELECT DISTINCT ON (DATE(time))
  172.                           DATE(time) AS day, maker, taker
  173.                    FROM fees
  174.                    WHERE symbol_id = {symbol}
  175.                      AND time >= '{date_from}'::date
  176.                      AND time < '{date_to}'::date + interval '1 day'
  177.                    ORDER BY DATE(time), time DESC
  178.                ),
  179.                daily_trades AS (
  180.                    SELECT DATE(time) AS day,
  181.                           SUM(price * quantity) / 2 AS volume,
  182.                           SUM(CASE WHEN is_taker THEN price * quantity ELSE 0 END) AS taker_vol,
  183.                           SUM(CASE WHEN NOT is_taker THEN price * quantity ELSE 0 END) AS maker_vol
  184.                    FROM user_trade_{symbol}
  185.                    WHERE strategy = 'V'
  186.                      AND time >= '{date_from}'::date
  187.                      AND time < '{date_to}'::date + interval '1 day'
  188.                    GROUP BY DATE(time)
  189.                )
  190.                SELECT dt.day, dt.volume, dt.taker_vol, dt.maker_vol, df.maker, df.taker
  191.                FROM daily_trades dt
  192.                LEFT JOIN daily_fees df ON dt.day = df.day
  193.                ORDER BY dt.day
  194.            """
  195.             daily_rows = await self.execute_select(daily_query)
  196.  
  197.             price_query = f"""
  198.                SELECT MAX(price), MIN(price)
  199.                FROM public_trade_{symbol}
  200.                WHERE time >= '{date_from}'::date
  201.                  AND time < '{date_to}'::date + interval '1 day'
  202.                  AND price > 0
  203.            """
  204.             price_res = await self.execute_select(price_query)
  205.  
  206.             max_price, min_price = None, None
  207.             if price_res and price_res[0] and price_res[0][0] is not None:
  208.                 max_price, min_price = price_res[0][0], price_res[0][1]
  209.  
  210.             # --- Public (market) volume per day ---
  211.             public_daily_query = f"""
  212.                SELECT DATE(time) AS day, SUM(price * quantity)
  213.                FROM public_trade_{symbol}
  214.                WHERE time >= '{date_from}'::date
  215.                  AND time < '{date_to}'::date + interval '1 day'
  216.                GROUP BY DATE(time)
  217.                ORDER BY day
  218.            """
  219.             public_rows = await self.execute_select(public_daily_query)
  220.  
  221.             # Echo lookup: V-bot daily volume (with /2 self-trade adjustment from daily_query).
  222.             volume_by_day = {}
  223.             if daily_rows:
  224.                 for row in daily_rows:
  225.                     volume_by_day[row[0]] = float(row[1]) if row[1] else 0
  226.  
  227.             public_section = ''
  228.             if public_rows:
  229.                 public_section += '<b>Market volume (public | echo):</b>\n'
  230.                 pub_total = 0
  231.                 echo_total = 0
  232.                 pub_days = 0
  233.                 for row in public_rows:
  234.                     day_str = row[0].strftime('%d.%m') if hasattr(row[0], 'strftime') else str(row[0])
  235.                     day_pub = float(row[1]) if row[1] else 0
  236.                     day_echo = volume_by_day.get(row[0], 0)
  237.                     pub_total += day_pub
  238.                     echo_total += day_echo
  239.                     pub_days += 1
  240.                     public_section += (
  241.                         f'  {day_str}:  '
  242.                         f'<code>{self._fmt_usdt(day_pub)}</code> | '
  243.                         f'<code>{self._fmt_usdt(day_echo)}</code> USDT\n'
  244.                     )
  245.                 pub_avg = pub_total / pub_days if pub_days > 0 else 0
  246.                 public_section += f'<b>Market total:</b> <code>{self._fmt_usdt(pub_total)}</code> USDT\n'
  247.                 public_section += f'<b>Echo total:</b> <code>{self._fmt_usdt(echo_total)}</code> USDT\n'
  248.                 public_section += f'<b>Market avg daily:</b> <code>{self._fmt_usdt(pub_avg)}</code> USDT\n'
  249.  
  250.             # --- Volume bot section (only if V trades exist) ---
  251.             volume_section = ''
  252.             if daily_rows:
  253.                 total_volume = 0
  254.                 total_commission = 0
  255.                 n_days = 0
  256.                 commission_days = 0
  257.                 for row in daily_rows:
  258.                     day_str = row[0].strftime('%d.%m') if hasattr(row[0], 'strftime') else str(row[0])
  259.                     day_vol = float(row[1]) if row[1] else 0
  260.                     taker_vol = float(row[2]) if row[2] else 0
  261.                     maker_vol = float(row[3]) if row[3] else 0
  262.                     maker_fee = row[4]
  263.                     taker_fee = row[5]
  264.  
  265.                     total_volume += day_vol
  266.                     n_days += 1
  267.  
  268.                     if maker_fee is not None and taker_fee is not None:
  269.                         # fees are stored as percentages (e.g. 0.07 = 0.07%)
  270.                         maker_fee_f = float(maker_fee)
  271.                         taker_fee_f = float(taker_fee)
  272.                         day_commission = (maker_vol * maker_fee_f + taker_vol * taker_fee_f) / 100
  273.                         total_commission += day_commission
  274.                         commission_days += 1
  275.                         volume_section += (
  276.                             f'  {day_str}:  <code>{self._fmt_usdt(day_vol)}</code> USDT'
  277.                             f'  (fee: <code>{self._fmt_usdt(day_commission)}</code> USDT'
  278.                             f', maker {round(maker_fee_f, 4)}%, taker {round(taker_fee_f, 4)}%)\n'
  279.                         )
  280.                     else:
  281.                         volume_section += (
  282.                             f'  {day_str}:  <code>{self._fmt_usdt(day_vol)}</code> USDT'
  283.                             f'  (fee: n/a)\n'
  284.                         )
  285.                 volume_section += '\n'
  286.  
  287.                 avg_volume = total_volume / n_days if n_days > 0 else 0
  288.                 volume_section += f'<b>Total:</b> <code>{self._fmt_usdt(total_volume)}</code> USDT\n'
  289.                 volume_section += f'<b>Avg daily:</b> <code>{self._fmt_usdt(avg_volume)}</code> USDT\n'
  290.                 if commission_days > 0:
  291.                     volume_section += f'<b>Total fees:</b> <code>{self._fmt_usdt(total_commission)}</code> USDT\n'
  292.                 else:
  293.                     volume_section += '<b>Total fees:</b> no fee data\n'
  294.  
  295.             # --- Treasury Building section (only if T trades exist) ---
  296.             tb_daily_query = f"""
  297.                SELECT DATE(time) AS day, SUM(price * quantity)
  298.                FROM user_trade_{symbol}
  299.                WHERE strategy = 'T'
  300.                  AND time >= '{date_from}'::date
  301.                  AND time < '{date_to}'::date + interval '1 day'
  302.                GROUP BY DATE(time)
  303.                ORDER BY day
  304.            """
  305.             tb_rows = await self.execute_select(tb_daily_query)
  306.  
  307.             tb_section = ''
  308.             if tb_rows:
  309.                 tb_mode_res = await self.execute_select(
  310.                     f"SELECT mode FROM tb_params WHERE symbol_id = {symbol}"
  311.                 )
  312.                 tb_mode = None
  313.                 if tb_mode_res and tb_mode_res[0]:
  314.                     tb_mode = tb_mode_res[0][0]
  315.  
  316.                 mode_label = {'B': 'Buyback', 'C': 'Cashout'}.get(tb_mode, f'TB ({tb_mode or "?"})')
  317.                 tb_section += f'<b>Treasury Building — {mode_label}:</b>\n'
  318.                 tb_total = 0
  319.                 tb_days = 0
  320.                 for row in tb_rows:
  321.                     day_str = row[0].strftime('%d.%m') if hasattr(row[0], 'strftime') else str(row[0])
  322.                     day_tb = float(row[1]) if row[1] else 0
  323.                     tb_total += day_tb
  324.                     tb_days += 1
  325.                     tb_section += f'  {day_str}:  <code>{self._fmt_usdt(day_tb)}</code> USDT\n'
  326.                 tb_avg = tb_total / tb_days if tb_days > 0 else 0
  327.                 tb_section += f'<b>TB total:</b> <code>{self._fmt_usdt(tb_total)}</code> USDT\n'
  328.                 tb_section += f'<b>TB avg daily:</b> <code>{self._fmt_usdt(tb_avg)}</code> USDT\n'
  329.  
  330.                 # --- Внутренние переливы T <-> M/V/W ---
  331.                 # Match: same price, same quantity, |Δt| <= 0.5s, opposite is_taker.
  332.                 # DISTINCT ON (t.ctid) — каждый T-трейд учитывается максимум один раз,
  333.                 # выбирается ближайший по времени контрагент.
  334.                 overflow_query = f"""
  335.                    WITH t_trades AS (
  336.                        SELECT ctid, time, price, quantity, is_taker
  337.                        FROM user_trade_{symbol}
  338.                        WHERE strategy = 'T'
  339.                          AND time >= '{date_from}'::date
  340.                          AND time < '{date_to}'::date + interval '1 day'
  341.                          AND is_taker IS NOT NULL
  342.                    ),
  343.                    matched AS (
  344.                        SELECT DISTINCT ON (t.ctid)
  345.                            t.time, t.price, t.quantity,
  346.                            c.strategy AS counterpart
  347.                        FROM t_trades t
  348.                        JOIN user_trade_{symbol} c
  349.                          ON c.strategy IN ('M', 'V', 'W')
  350.                         AND c.price = t.price
  351.                         AND c.quantity = t.quantity
  352.                         AND ABS(EXTRACT(EPOCH FROM (c.time - t.time))) <= 60
  353.                         AND c.is_taker IS NOT NULL
  354.                         AND c.is_taker != t.is_taker
  355.                        ORDER BY t.ctid, ABS(EXTRACT(EPOCH FROM (c.time - t.time)))
  356.                    )
  357.                    SELECT DATE(time) AS day, counterpart,
  358.                           SUM(price * quantity) AS volume, COUNT(*) AS pairs
  359.                    FROM matched
  360.                    GROUP BY DATE(time), counterpart
  361.                    ORDER BY counterpart, day
  362.                """
  363.                 overflow_rows = await self.execute_select(overflow_query)
  364.  
  365.                 # Diagnostic: how many T trades total vs how many got matched
  366.                 t_count_query = f"""
  367.                    SELECT
  368.                        COUNT(*) AS total,
  369.                        COUNT(*) FILTER (WHERE is_taker IS NULL) AS null_is_taker
  370.                    FROM user_trade_{symbol}
  371.                    WHERE strategy = 'T'
  372.                      AND time >= '{date_from}'::date
  373.                      AND time < '{date_to}'::date + interval '1 day'
  374.                """
  375.                 t_count_res = await self.execute_select(t_count_query)
  376.                 t_total = int(t_count_res[0][0]) if t_count_res and t_count_res[0] else 0
  377.                 t_null_taker = int(t_count_res[0][1]) if t_count_res and t_count_res[0] else 0
  378.  
  379.                 if overflow_rows:
  380.                     by_pair = {}
  381.                     for row in overflow_rows:
  382.                         day_str = row[0].strftime('%d.%m') if hasattr(row[0], 'strftime') else str(row[0])
  383.                         cp = row[1]
  384.                         vol = float(row[2]) if row[2] else 0
  385.                         pairs = int(row[3]) if row[3] else 0
  386.                         by_pair.setdefault(cp, []).append((day_str, vol, pairs))
  387.  
  388.                     matched_pairs_total = sum(p for days in by_pair.values() for _, _, p in days)
  389.                     tb_section += '\n<b>Internal flow (T ↔ ...):</b>\n'
  390.                     for cp in ('M', 'V', 'W'):
  391.                         if cp not in by_pair:
  392.                             continue
  393.                         pair_total = sum(v for _, v, _ in by_pair[cp])
  394.                         pair_pairs = sum(p for _, _, p in by_pair[cp])
  395.                         tb_section += f'  <b>{cp}-T</b> ({pair_pairs} pairs):\n'
  396.                         for day_str, vol, pairs in by_pair[cp]:
  397.                             tb_section += f'    {day_str}:  <code>{self._fmt_usdt(vol)}</code> USDT ({pairs})\n'
  398.                         tb_section += f'    <b>Total:</b> <code>{self._fmt_usdt(pair_total)}</code> USDT\n'
  399.  
  400.                     match_pct = round(100 * matched_pairs_total / t_total, 1) if t_total else 0
  401.                     tb_section += (
  402.                         f'<i>Match rate: {matched_pairs_total}/{t_total} '
  403.                         f'T-trades matched ({match_pct}%)</i>\n'
  404.                     )
  405.                     if t_null_taker:
  406.                         tb_section += (
  407.                             f'<i>Note: {t_null_taker} T-trades have NULL is_taker '
  408.                             f'(excluded from matching)</i>\n'
  409.                         )
  410.                 elif t_null_taker == t_total and t_total > 0:
  411.                     tb_section += (
  412.                         '\n<i>Internal flow: cannot detect — all T-trades have '
  413.                         'NULL is_taker</i>\n'
  414.                     )
  415.                 else:
  416.                     tb_section += '\n<i>Internal flow: no matches detected</i>\n'
  417.  
  418.             # If none of the sections have data — skip the symbol entirely
  419.             if not volume_section and not tb_section and not public_section:
  420.                 continue
  421.  
  422.             report_answer += f'<b>📊 {self.symbol_id_to_ticket[symbol]}</b>\n'
  423.             report_answer += f'<i>{date_from} — {date_to}</i>\n\n'
  424.  
  425.             sections = [s for s in (public_section, volume_section, tb_section) if s]
  426.             report_answer += '\n'.join(sections)
  427.  
  428.             if max_price is not None:
  429.                 report_answer += f'<b>Max price:</b> {self._fmt_price(max_price)}\n'
  430.                 report_answer += f'<b>Min price:</b> {self._fmt_price(min_price)}\n'
  431.  
  432.             report_answer += '\n'
  433.  
  434.         return report_answer
  435.  
  436.     async def get_mm_report_by_group(self, group, date_from, date_to):
  437.         report_answer = ''
  438.         for symbol in self.symbols_id_in_group[group]:
  439.             # Average spread per day and total
  440.             spread_query = f"""
  441.                SELECT DATE(time) as day,
  442.                       AVG(CASE WHEN bid_price > 0 THEN (ask_price - bid_price) / bid_price ELSE NULL END) as avg_spread
  443.                FROM book_{symbol}
  444.                WHERE time >= '{date_from}'::date
  445.                  AND time < '{date_to}'::date + interval '1 day'
  446.                GROUP BY DATE(time)
  447.                ORDER BY day
  448.            """
  449.             spread_rows = await self.execute_select(spread_query)
  450.  
  451.             # Average liquidity +-2%
  452.             depth_query = f"""
  453.                SELECT AVG(ask2), AVG(bid2)
  454.                FROM depth_{symbol}
  455.                WHERE time >= '{date_from}'::date
  456.                  AND time < '{date_to}'::date + interval '1 day'
  457.            """
  458.             depth_res = await self.execute_select(depth_query)
  459.  
  460.             # Rebalances: first and last balance in period for strategy M
  461.             first_balance_query = f"""
  462.                SELECT base, quote
  463.                FROM balance_{symbol}
  464.                WHERE strategy = 'M'
  465.                  AND time >= '{date_from}'::date
  466.                  AND time < '{date_to}'::date + interval '1 day'
  467.                ORDER BY time ASC
  468.                LIMIT 1
  469.            """
  470.             last_balance_query = f"""
  471.                SELECT base, quote
  472.                FROM balance_{symbol}
  473.                WHERE strategy = 'M'
  474.                  AND time >= '{date_from}'::date
  475.                  AND time < '{date_to}'::date + interval '1 day'
  476.                ORDER BY time DESC
  477.                LIMIT 1
  478.            """
  479.             first_bal = await self.execute_select(first_balance_query)
  480.             last_bal = await self.execute_select(last_balance_query)
  481.  
  482.             # Min/max price
  483.             price_query = f"""
  484.                SELECT MAX(price), MIN(price)
  485.                FROM public_trade_{symbol}
  486.                WHERE time >= '{date_from}'::date
  487.                  AND time < '{date_to}'::date + interval '1 day'
  488.                  AND price > 0
  489.            """
  490.             price_res = await self.execute_select(price_query)
  491.  
  492.             # Deposits/withdrawals — period totals split by side (base / quote).
  493.             # Base side is shown in base units with USDT-equivalent in parens.
  494.             deposits_query = f"""
  495.                SELECT
  496.                    SUM(CASE WHEN delta_base  > 0 THEN delta_base          ELSE 0 END) AS base_dep,
  497.                    SUM(CASE WHEN delta_base  > 0 THEN delta_base_notional ELSE 0 END) AS base_dep_usd,
  498.                    SUM(CASE WHEN delta_base  < 0 THEN delta_base          ELSE 0 END) AS base_wd,
  499.                    SUM(CASE WHEN delta_base  < 0 THEN delta_base_notional ELSE 0 END) AS base_wd_usd,
  500.                    SUM(CASE WHEN delta_quote > 0 THEN delta_quote         ELSE 0 END) AS quote_dep,
  501.                    SUM(CASE WHEN delta_quote < 0 THEN delta_quote         ELSE 0 END) AS quote_wd
  502.                FROM mm_deposits
  503.                WHERE symbol_id = {symbol}
  504.                  AND time >= '{date_from}'::date
  505.                  AND time < '{date_to}'::date + interval '1 day'
  506.            """
  507.             deposits_rows = await self.execute_select(deposits_query)
  508.             base_dep = base_dep_usd = base_wd = base_wd_usd = quote_dep = quote_wd = 0.0
  509.             if deposits_rows and deposits_rows[0]:
  510.                 r = deposits_rows[0]
  511.                 base_dep      = float(r[0]) if r[0] else 0.0
  512.                 base_dep_usd  = float(r[1]) if r[1] else 0.0
  513.                 base_wd       = float(r[2]) if r[2] else 0.0
  514.                 base_wd_usd   = float(r[3]) if r[3] else 0.0
  515.                 quote_dep     = float(r[4]) if r[4] else 0.0
  516.                 quote_wd      = float(r[5]) if r[5] else 0.0
  517.             has_deposits = (
  518.                 base_dep != 0 or base_wd != 0 or quote_dep != 0 or quote_wd != 0
  519.             )
  520.  
  521.             # Current price for rebalance notional (latest public trade)
  522.             current_price_query = f"""
  523.                SELECT price
  524.                FROM public_trade_{symbol}
  525.                WHERE price > 0
  526.                ORDER BY time DESC
  527.                LIMIT 1
  528.            """
  529.             current_price_res = await self.execute_select(current_price_query)
  530.             current_price = None
  531.             if current_price_res and current_price_res[0]:
  532.                 current_price = float(current_price_res[0][0])
  533.  
  534.             token = self.symbol_id_to_ticket[symbol].split('/')[0]
  535.  
  536.             has_mm_data = bool(
  537.                 spread_rows
  538.                 or (depth_res and depth_res[0] and depth_res[0][0] is not None)
  539.                 or (first_bal and last_bal and first_bal[0] and last_bal[0])
  540.                 or has_deposits
  541.             )
  542.             # If MM bot is not running on this symbol — skip it entirely
  543.             if not has_mm_data:
  544.                 continue
  545.  
  546.             report_answer += f'<b>📈 {self.symbol_id_to_ticket[symbol]}</b>\n'
  547.             report_answer += f'<i>{date_from} — {date_to}</i>\n\n'
  548.  
  549.             # Spread per day
  550.             if spread_rows:
  551.                 total_spread_sum = 0
  552.                 n_days = 0
  553.                 report_answer += '<b>Avg spread by day:</b>\n'
  554.                 for row in spread_rows:
  555.                     day_str = row[0].strftime('%d.%m') if hasattr(row[0], 'strftime') else str(row[0])
  556.                     avg_sp = float(row[1]) * 100 if row[1] else 0
  557.                     total_spread_sum += avg_sp
  558.                     n_days += 1
  559.                     report_answer += f'  {day_str}:  <code>{round(avg_sp, 3)}%</code>\n'
  560.                 avg_spread_total = round(total_spread_sum / n_days, 3) if n_days > 0 else 0
  561.                 report_answer += f'<b>Avg spread (period):</b> <code>{avg_spread_total}%</code>\n\n'
  562.  
  563.             # Depth +-2%
  564.             if depth_res and depth_res[0] and depth_res[0][0] is not None:
  565.                 avg_ask2 = float(depth_res[0][0])
  566.                 avg_bid2 = float(depth_res[0][1])
  567.                 report_answer += f'<b>Avg liquidity +2%:</b> <code>{self._fmt_usdt(avg_ask2)}</code> USDT\n'
  568.                 report_answer += f'<b>Avg liquidity -2%:</b> <code>{self._fmt_usdt(avg_bid2)}</code> USDT\n\n'
  569.  
  570.             # Min/max price — own block, separated by blank line.
  571.             if price_res and price_res[0] and price_res[0][0] is not None:
  572.                 report_answer += f'<b>Max price:</b> {self._fmt_price(price_res[0][0])}\n'
  573.                 report_answer += f'<b>Min price:</b> {self._fmt_price(price_res[0][1])}\n\n'
  574.  
  575.             # Rebalance
  576.             if first_bal and last_bal and first_bal[0] and last_bal[0]:
  577.                 delta_base = round(float(last_bal[0][0]) - float(first_bal[0][0]), 2)
  578.                 delta_quote = float(last_bal[0][1]) - float(first_bal[0][1])
  579.                 sign_base = '+' if delta_base >= 0 else ''
  580.                 rebalance_line = (
  581.                     f'<b>Rebalance:</b> <code>{self._fmt_usdt(delta_quote, signed=True)}</code> USDT, '
  582.                     f'{sign_base}{delta_base} {token}'
  583.                 )
  584.                 if current_price is not None:
  585.                     notional = delta_base * current_price
  586.                     rebalance_line += f' (notional: <code>{self._fmt_usdt(notional, signed=True)}</code> USDT)'
  587.                 report_answer += rebalance_line + '\n'
  588.  
  589.             # Deposits / withdrawals — period totals, base & quote shown separately.
  590.             # All four lines are printed when the symbol had any event; empty categories show "0".
  591.             # If the symbol had no events at all in the period — single "none" line.
  592.             if has_deposits:
  593.                 def _s(v, d):
  594.                     r = round(v, d)
  595.                     return f'+{r}' if r > 0 else str(r)
  596.                 def _s_usdt(v):
  597.                     """Same HTML-fragment trick as _fmt_usdt: caller wraps
  598.                    in <code>...</code>; groups become separate spans so
  599.                    spaces between them are not monospace-wide."""
  600.                     rounded = int(round(float(v)))
  601.                     sign = ''
  602.                     if rounded < 0:
  603.                         sign = '-'
  604.                         rounded = -rounded
  605.                     elif rounded > 0:
  606.                         sign = '+'
  607.                     s = str(rounded)
  608.                     groups = []
  609.                     while s:
  610.                         groups.insert(0, s[-3:])
  611.                         s = s[:-3]
  612.                     return sign + '</code> <code>'.join(groups)
  613.                 report_answer += (
  614.                     f'<b>Base deposits:</b> <code>{_s(base_dep, 4)}</code> {token} '
  615.                     f'(<code>{_s_usdt(base_dep_usd)}</code> USDT)\n'
  616.                 )
  617.                 report_answer += (
  618.                     f'<b>Base withdrawals:</b> <code>{_s(base_wd, 4)}</code> {token} '
  619.                     f'(<code>{_s_usdt(base_wd_usd)}</code> USDT)\n'
  620.                 )
  621.                 report_answer += f'<b>Quote deposits:</b> <code>{_s_usdt(quote_dep)}</code> USDT\n'
  622.                 report_answer += f'<b>Quote withdrawals:</b> <code>{_s_usdt(quote_wd)}</code> USDT\n'
  623.             else:
  624.                 report_answer += '<b>Deposits/Withdrawals:</b> none\n'
  625.  
  626.             report_answer += '\n'
  627.  
  628.         return report_answer
  629.  
  630.     async def get_side_cashout_report_by_group(self, group, date_from, date_to):
  631.         """Report TB (buyback/cashout) stats from the side-cashout database.
  632.        Side DB has no V/M strategies — only TB trades."""
  633.         report_answer = ''
  634.         for symbol in self.symbols_id_in_group.get(group, []):
  635.             tb_daily_query = f"""
  636.                SELECT DATE(time) AS day,
  637.                       SUM(price * quantity) AS notional,
  638.                       SUM(quantity)         AS qty
  639.                FROM user_trade_{symbol}
  640.                WHERE time >= '{date_from}'::date
  641.                  AND time < '{date_to}'::date + interval '1 day'
  642.                GROUP BY DATE(time)
  643.                ORDER BY day
  644.            """
  645.             tb_rows = await self.execute_select(tb_daily_query)
  646.             if not tb_rows:
  647.                 continue
  648.  
  649.             tb_mode_res = await self.execute_select(
  650.                 f"SELECT mode FROM tb_params WHERE symbol_id = {symbol}"
  651.             )
  652.             tb_mode = None
  653.             if tb_mode_res and tb_mode_res[0]:
  654.                 tb_mode = tb_mode_res[0][0]
  655.             mode_label = {'B': 'Buyback', 'C': 'Cashout'}.get(tb_mode, f'TB ({tb_mode or "?"})')
  656.  
  657.             report_answer += f'<b>📊 {self.symbol_id_to_ticket[symbol]}</b>\n'
  658.             report_answer += f'<i>{date_from} — {date_to}</i>\n\n'
  659.             report_answer += f'<b>Treasury Building — {mode_label}:</b>\n'
  660.  
  661.             tb_total = 0
  662.             tb_total_qty = 0
  663.             tb_days = 0
  664.             for row in tb_rows:
  665.                 day_str = row[0].strftime('%d.%m') if hasattr(row[0], 'strftime') else str(row[0])
  666.                 day_notional = float(row[1]) if row[1] else 0
  667.                 day_qty = float(row[2]) if row[2] else 0
  668.                 tb_total += day_notional
  669.                 tb_total_qty += day_qty
  670.                 tb_days += 1
  671.                 day_vwap = day_notional / day_qty if day_qty > 0 else 0
  672.                 report_answer += (
  673.                     f'  {day_str}:  <code>{self._fmt_usdt(day_notional)}</code> USDT'
  674.                     f'  @ avg <code>{self._fmt_price(day_vwap)}</code>\n'
  675.                 )
  676.             tb_avg_daily = tb_total / tb_days if tb_days > 0 else 0
  677.             tb_vwap = tb_total / tb_total_qty if tb_total_qty > 0 else 0
  678.             report_answer += f'<b>TB total:</b> <code>{self._fmt_usdt(tb_total)}</code> USDT\n'
  679.             report_answer += f'<b>TB avg daily:</b> <code>{self._fmt_usdt(tb_avg_daily)}</code> USDT\n'
  680.             report_answer += f'<b>TB avg price:</b> <code>{self._fmt_price(tb_vwap)}</code>\n\n'
  681.  
  682.         return report_answer
  683.  
  684.     async def get_active_orders(self, group):
  685.         orders = []
  686.         for symbol in self.symbols_id_in_group[group]:
  687.             print(f'DEBUG symbol: {symbol} {self.symbol_id_to_ticket[symbol]}')
  688.             active_orders_query = f"""
  689.                SELECT time, price, quantity, is_bid
  690.                FROM active_orders_{symbol}
  691.                WHERE strategy = 'M'
  692.            """
  693.             db_orders = await self.execute_select(active_orders_query)
  694.             if not db_orders:
  695.                 continue
  696.             for order in db_orders:
  697.                 orders.append({'symbol': self.symbol_id_to_ticket[symbol], 'time': str(order[0]), 'price': order[1], 'quantity': order[2], 'side': 'BUY' if order[3] else 'SELL'})
  698.  
  699.         return orders
  700.    
  701.     async def get_last_statuses(self):
  702.         statuses = {}
  703.  
  704.         status_query = f"""
  705.            SELECT DISTINCT ON (strategy, symbol_id) time, symbol_id, strategy, status
  706.            FROM status
  707.            WHERE "time" >= now() - interval '15 minutes'
  708.            ORDER BY strategy, symbol_id, "time" DESC;
  709.        """
  710.         db_status = await self.execute_select(status_query)
  711.         if not db_status:
  712.             return statuses
  713.  
  714.         for status in db_status:
  715.             if status[1] not in self.symbol_id_to_ticket:
  716.                 continue
  717.             symbol = status[1]
  718.             if symbol not in statuses:
  719.                 statuses[symbol] = []
  720.             statuses[symbol].append({'time': status[0], 'strategy': status[2], 'status': status[3], 'symbol': self.symbol_id_to_ticket[status[1]]})
  721.  
  722.         return statuses
  723.  
  724.     async def get_book_by_group(self, group):
  725.         books = []
  726.         for symbol in self.symbols_id_in_group.get(group, []):
  727.             book_query = f"""
  728.                SELECT time, ask_price, bid_price
  729.                FROM book_{symbol}
  730.                ORDER BY time DESC
  731.                LIMIT 1;
  732.            """
  733.             db_book = await self.execute_select(book_query)
  734.             if not db_book:
  735.                 continue
  736.             book_record = db_book[-1]
  737.             ask_price, bid_price = book_record[1], book_record[2]
  738.             if bid_price == 0 and ask_price == 0:
  739.                 continue
  740.             spread = (ask_price - bid_price) / bid_price
  741.             books.append(
  742.                 {
  743.                     'symbol': self.symbol_id_to_ticket[symbol],
  744.                     'time': str(book_record[0]),
  745.                     'spread': spread,
  746.                     'ask_price': ask_price,
  747.                     'bid_price': bid_price
  748.                 }
  749.             )
  750.  
  751.         return books
  752.  
  753.     async def get_flat_candles_by_group(self, group):
  754.         """For each symbol in the group inspect the two LAST CLOSED 15-minute
  755.        windows built from market trades (public_trade). A window is "flat"
  756.        when its open price (first trade) equals its close price (last trade).
  757.  
  758.        Returns a list of dicts:
  759.            {'symbol', 'flat': bool, 'last_bucket': int_epoch,
  760.             'candles': [(bucket_epoch, open, close, n), ...]}
  761.  
  762.        A window only counts if it had at least TWO trades (HAVING count >= 2):
  763.        with a single trade open == close trivially, which is a false "flat"
  764.        and explicitly not interesting. Windows with < 2 trades are dropped, so
  765.        if either of the last two windows is single-trade or empty the symbol
  766.        is skipped entirely (treated as "no data" — caller won't alert/re-arm).
  767.  
  768.        Bucketing is epoch-based (floor(epoch/900)*900, no date_bin). Note the
  769.        `time >= now() - interval` prefilter compares UTC-naive `time` with
  770.        timestamptz, so it is cast by session timezone — like every other
  771.        query in this file it assumes the session runs in UTC.
  772.        """
  773.         results = []
  774.         for symbol in self.symbols_id_in_group.get(group, []):
  775.             # cur.open_epoch = start of the current (still open) 15-min window.
  776.             # We look only at the two windows before it:
  777.             #   [open-1800, open-900)  and  [open-900, open)
  778.             # The oldest needed trade is open-1800, i.e. up to 45 min before
  779.             # now(), so the index prefilter must reach back at least that far
  780.             # (50 min leaves margin); the epoch bounds below cut exactly.
  781.             flat_query = f"""
  782.                WITH cur AS (
  783.                    SELECT floor(extract(epoch FROM now()) / 900) * 900 AS open_epoch
  784.                )
  785.                SELECT
  786.                    floor(extract(epoch FROM time) / 900) * 900 AS bucket_epoch,
  787.                    (array_agg(price ORDER BY time ASC ))[1] AS open_price,
  788.                    (array_agg(price ORDER BY time DESC))[1] AS close_price,
  789.                    count(*) AS n
  790.                FROM public_trade_{symbol}, cur
  791.                WHERE time >= now() - interval '50 minutes'
  792.                  AND extract(epoch FROM time) >= cur.open_epoch - 1800
  793.                  AND extract(epoch FROM time) <  cur.open_epoch
  794.                GROUP BY bucket_epoch
  795.                HAVING count(*) >= 2
  796.                ORDER BY bucket_epoch DESC
  797.            """
  798.             rows = await self.execute_select(flat_query)
  799.             # Need both of the last two closed windows present, each with >= 2
  800.             # trades (single-trade / empty windows were dropped by HAVING).
  801.             if not rows or len(rows) < 2:
  802.                 continue
  803.  
  804.             last_two = rows[:2]
  805.             both_flat = all(r[1] == r[2] for r in last_two)
  806.  
  807.             results.append(
  808.                 {
  809.                     'symbol': self.symbol_id_to_ticket[symbol],
  810.                     'flat': both_flat,
  811.                     'last_bucket': int(last_two[0][0]),
  812.                     'candles': [
  813.                         (int(r[0]), r[1], r[2], int(r[3])) for r in last_two
  814.                     ],
  815.                 }
  816.             )
  817.  
  818.         return results
  819.  
  820.     async def execute_select(self, query):
  821.         try:
  822.             async with self.pool.acquire() as conn:
  823.                 print(f'AWAITED: {int(time.time()*1000)}', file=sys.stderr)
  824.                 async with conn.cursor() as cursor:
  825.                     await cursor.execute(query)
  826.                     print(f'FINISHED: {int(time.time()*1000)}', file=sys.stderr)
  827.                     return await cursor.fetchall()
  828.         except Exception as e:
  829.             print('[EXCEPTION] execute_select:', e)
  830.             return
  831.        
  832.     async def close_connection(self):
  833.         if self.pool:
  834.             self.pool.close()
  835.             await self.pool.wait_closed()
  836.             self.pool = None
  837.  
  838.         if self.pool:
  839.             self.pool.close()
  840.             await self.pool.wait_closed()
  841.             self.pool = None
  842.  
Advertisement
Add Comment
Please, Sign In to add comment