import asyncio import json import time import logging.config from pybit import exceptions from pybit.unified_trading import HTTP from logger_helper.logger_helper import LOGGING_CONFIG import app.services.Bybit.functions.price_symbol as price_symbol import app.services.Bybit.functions.balance as balance_g import app.telegram.database.requests as rq import app.telegram.Keyboards.inline_keyboards as inline_markup logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger("futures") processed_trade_ids = set() async def get_bybit_client(tg_id): """ Асинхронно получает экземпляр клиента Bybit. :param tg_id: int - ID пользователя Telegram :return: HTTP - экземпляр клиента Bybit """ api_key = await rq.get_bybit_api_key(tg_id) secret_key = await rq.get_bybit_secret_key(tg_id) return HTTP(api_key=api_key, api_secret=secret_key) def safe_float(val) -> float: """ Безопасное преобразование значения в float. Возвращает 0.0, если значение None, пустое или некорректное. """ try: if val is None or val == '': return 0.0 return float(val) except (ValueError, TypeError): logger.error("Некорректное значение для преобразования в float") return 0.0 def format_trade_details_position(data, commission_fee): """ Форматирует информацию о сделке в виде строки. """ msg = data.get('data', [{}])[0] closed_size = safe_float(msg.get('closedSize', 0)) symbol = msg.get('symbol', 'N/A') entry_price = safe_float(msg.get('execPrice', 0)) qty = safe_float(msg.get('execQty', 0)) order_type = msg.get('orderType', 'N/A') side = msg.get('side', '') commission = safe_float(msg.get('execFee', 0)) pnl = safe_float(msg.get('execPnl', 0)) if commission_fee == "Да": pnl -= commission movement = '' if side.lower() == 'buy': movement = 'Покупка' elif side.lower() == 'sell': movement = 'Продажа' else: movement = side if closed_size > 0: return ( f"Сделка закрыта:\n" f"Торговая пара: {symbol}\n" f"Цена исполнения: {entry_price:.6f}\n" f"Количество: {qty}\n" f"Закрыто позиций: {closed_size}\n" f"Тип ордера: {order_type}\n" f"Движение: {movement}\n" f"Комиссия за сделку: {commission:.6f}\n" f"Реализованная прибыль: {pnl:.6f} USDT" ) if order_type == 'Market': return ( f"Сделка открыта:\n" f"Торговая пара: {symbol}\n" f"Цена исполнения: {entry_price:.6f}\n" f"Количество: {qty}\n" f"Тип ордера: {order_type}\n" f"Движение: {movement}\n" f"Комиссия за сделку: {commission:.6f}" ) return None def format_order_details_position(data): """ Форматирует информацию об ордере в виде строки. """ msg = data.get('data', [{}])[0] price = safe_float(msg.get('price', 0)) qty = safe_float(msg.get('qty', 0)) cum_exec_qty = safe_float(msg.get('cumExecQty', 0)) cum_exec_fee = safe_float(msg.get('cumExecFee', 0)) take_profit = safe_float(msg.get('takeProfit', 0)) stop_loss = safe_float(msg.get('stopLoss', 0)) order_status = msg.get('orderStatus', 'N/A') symbol = msg.get('symbol', 'N/A') order_type = msg.get('orderType', 'N/A') side = msg.get('side', '') movement = '' if side.lower() == 'buy': movement = 'Покупка' elif side.lower() == 'sell': movement = 'Продажа' else: movement = side if order_status.lower() == 'filled' and order_type.lower() == 'limit': text = ( f"Ордер исполнен:\n" f"Торговая пара: {symbol}\n" f"Цена исполнения: {price:.6f}\n" f"Количество: {qty}\n" f"Исполнено позиций: {cum_exec_qty}\n" f"Тип ордера: {order_type}\n" f"Движение: {movement}\n" f"Тейк-профит: {take_profit:.6f}\n" f"Стоп-лосс: {stop_loss:.6f}\n" f"Комиссия за сделку: {cum_exec_fee:.6f}\n" ) logger.info(text) return text elif order_status.lower() == 'new': text = ( f"Ордер создан:\n" f"Торговая пара: {symbol}\n" f"Цена: {price:.6f}\n" f"Количество: {qty}\n" f"Тип ордера: {order_type}\n" f"Движение: {movement}\n" f"Тейк-профит: {take_profit:.6f}\n" f"Стоп-лосс: {stop_loss:.6f}\n" ) logger.info(text) return text elif order_status.lower() == 'cancelled': text = ( f"Ордер отменен:\n" f"Торговая пара: {symbol}\n" f"Цена: {price:.6f}\n" f"Количество: {qty}\n" f"Тип ордера: {order_type}\n" f"Движение: {movement}\n" f"Тейк-профит: {take_profit:.6f}\n" f"Стоп-лосс: {stop_loss:.6f}\n" ) logger.info(text) return text return None def parse_pnl_from_msg(msg) -> float: """ Извлекает реализованную прибыль/убыток из сообщения. """ try: data = msg.get('data', [{}])[0] return float(data.get('execPnl', 0)) except Exception as e: logger.error(f"Ошибка при извлечении реализованной прибыли: {e}") return 0.0 async def handle_execution_message(message, msg): """ Обработчик сообщений об исполнении сделки. Логирует событие и проверяет условия для мартингейла и TP. """ # logger.info(f"Исполнена сделка:\n{json.dumps(msg, indent=4, ensure_ascii=False)}") tg_id = message.from_user.id data = msg.get('data', [{}])[0] data_main_risk_stgs = await rq.get_user_risk_management_settings(tg_id) commission_fee = data_main_risk_stgs.get('commission_fee', "ДА") pnl = parse_pnl_from_msg(msg) data_main_stgs = await rq.get_user_main_settings(tg_id) symbol = data.get('symbol') switch_mode = data_main_stgs.get('switch_mode', 'Включено') trading_mode = data_main_stgs.get('trading_mode', 'Long') trigger = await rq.get_for_registration_trigger(tg_id) margin_mode = data_main_stgs.get('margin_type', 'Isolated') starting_quantity = safe_float(data_main_stgs.get('starting_quantity')) trade_info = format_trade_details_position(data=msg, commission_fee=commission_fee) if trade_info: await message.answer(f"{trade_info}", reply_markup=inline_markup.back_to_main) side = None if switch_mode == 'Включено': switch_state = data_main_stgs.get('switch_state', 'Long') side = 'Buy' if switch_state == 'Long' else 'Sell' else: if trading_mode == 'Long': side = 'Buy' elif trading_mode == 'Short': side = 'Sell' else: side = 'Buy' if trigger == "Автоматический": if pnl < 0: martingale_factor = safe_float(data_main_stgs.get('martingale_factor')) current_martingale = await rq.get_martingale_step(tg_id) current_martingale_step = int(current_martingale) current_martingale += 1 next_quantity = float(starting_quantity) * (float(martingale_factor) ** current_martingale_step) await rq.update_martingale_step(tg_id, current_martingale) await open_position(tg_id, message, side=side, margin_mode=margin_mode, symbol=symbol, quantity=next_quantity) elif pnl > 0: await rq.update_martingale_step(tg_id, 0) await message.answer("❗️ Прибыль достигнута, шаг мартингейла сброшен. " "Начинаем новую серию ставок") await open_position(tg_id, message, side=side, margin_mode=margin_mode, symbol=symbol, quantity=starting_quantity) async def handle_order_message(message, msg: dict) -> None: """ Обработчик сообщений об исполнении ордера. Логирует событие и проверяет условия для мартингейла и TP. """ # logger.info(f"Исполнен ордер:\n{json.dumps(msg, indent=4, ensure_ascii=False)}") trade_info = format_order_details_position(msg) if trade_info: await message.answer(f"{trade_info}", reply_markup=inline_markup.back_to_main) async def error_max_step(message) -> None: """ Сообщение об ошибке превышения максимального количества шагов мартингейла. """ logger.error('Сделка не была совершена, превышен лимит максимального количества ставок в серии.') await message.answer('Сделка не была совершена, превышен лимит максимального количества ставок в серии.', reply_markup=inline_markup.back_to_main) async def error_max_risk(message) -> None: """ Сообщение об ошибке превышения риск-лимита сделки. """ logger.error('Сделка не была совершена, риск убытка превышает допустимый лимит.') await message.answer('Сделка не была совершена, риск убытка превышает допустимый лимит.', reply_markup=inline_markup.back_to_main) async def open_position(tg_id, message, side: str, margin_mode: str, symbol, quantity, tpsl_mode='Full'): """ Открывает позицию на Bybit с учётом настроек пользователя, маржи, размера лота, платформы и риска. Возвращает True при успехе, False при ошибках открытия ордера, None при исключениях. """ try: client = await get_bybit_client(tg_id) data_main_stgs = await rq.get_user_main_settings(tg_id) order_type = data_main_stgs.get('entry_order_type') bybit_margin_mode = 'ISOLATED_MARGIN' if margin_mode == 'Isolated' else 'REGULAR_MARGIN' limit_price = None if order_type == 'Limit': limit_price = await rq.get_limit_price(tg_id) data_risk_stgs = await rq.get_user_risk_management_settings(tg_id) balance = await balance_g.get_balance(tg_id, message) price = await price_symbol.get_price(tg_id, symbol=symbol) entry_price = safe_float(price) max_martingale_steps = int(data_main_stgs.get('maximal_quantity', 0)) current_martingale = await rq.get_martingale_step(tg_id) max_risk_percent = safe_float(data_risk_stgs.get('max_risk_deal')) loss_profit = safe_float(data_risk_stgs.get('price_loss')) if order_type == 'Limit' and limit_price: price_for_calc = limit_price else: price_for_calc = entry_price potential_loss = safe_float(quantity) * price_for_calc * (loss_profit / 100) allowed_loss = safe_float(balance) * (max_risk_percent / 100) if max_martingale_steps == current_martingale: await error_max_step(message) return if potential_loss > allowed_loss: await error_max_risk(message) return client.set_margin_mode(setMarginMode=bybit_margin_mode) leverage = int(data_main_stgs.get('size_leverage', 1)) try: resp = client.set_leverage( category='linear', symbol=symbol, buyLeverage=str(leverage), sellLeverage=str(leverage) ) except exceptions.InvalidRequestError as e: if "110043" in str(e): logger.info(f"Leverage already set to {leverage} for {symbol}") else: raise e instruments_resp = client.get_instruments_info(category='linear', symbol=symbol) if instruments_resp.get('retCode') == 0: instrument_info = instruments_resp.get('result', {}).get('list', []) if instrument_info: instrument = instrument_info[0] min_order_qty = float(instrument.get('minOrderQty', 0)) min_order_value_api = float(instrument.get('minOrderValue', 0)) if min_order_value_api == 0: min_order_value_api = 5.0 min_order_value_calc = min_order_qty * price_for_calc if min_order_qty > 0 else 0 min_order_value = max(min_order_value_calc, min_order_value_api) else: min_order_value = 5.0 order_value = float(quantity) * price_for_calc if order_value < min_order_value: await message.answer( f"Сумма ордера слишком мала: {order_value:.2f} USDT. " f"Минимум для торговли — {min_order_value} USDT. " f"Пожалуйста, увеличьте количество позиций.", reply_markup=inline_markup.back_to_main) return False if bybit_margin_mode == 'ISOLATED_MARGIN': # Открываем позицию response = client.place_order( category='linear', symbol=symbol, side=side, orderType=order_type, qty=str(quantity), price=str(limit_price) if order_type == 'Limit' and limit_price else None, timeInForce='GTC', orderLinkId=f"deal_{symbol}_{int(time.time())}" ) if response.get('retCode', -1) != 0: logger.error(f"Ошибка открытия ордера: {response}") await message.answer(f"Ошибка открытия ордера", reply_markup=inline_markup.back_to_main) return False # Получаем цену ликвидации positions = client.get_positions(category='linear', symbol=symbol) pos = positions.get('result', {}).get('list', [{}])[0] avg_price = float(pos.get('avgPrice', 0)) liq_price = safe_float(pos.get('liqPrice', 0)) if liq_price > 0 and avg_price > 0: if side.lower() == 'buy': take_profit_price = avg_price + (avg_price - liq_price) else: take_profit_price = avg_price - (liq_price - avg_price) take_profit_price = max(take_profit_price, 0) try: try: client.set_tp_sl_mode(symbol=symbol, category='linear', tpSlMode='Full') except exceptions.InvalidRequestError as e: if 'same tp sl mode' in str(e): logger.info("Режим TP/SL уже установлен - пропускаем") else: raise resp = client.set_trading_stop( category='linear', symbol=symbol, takeProfit=str(round(take_profit_price, 5)), tpTriggerBy='LastPrice', slTriggerBy='LastPrice', positionIdx=0, reduceOnly=False, tpslMode=tpsl_mode ) except Exception as e: logger.error(f"Ошибка установки TP/SL: {e}") await message.answer('Ошибка при установке Take Profit и Stop Loss.', reply_markup=inline_markup.back_to_main) return False else: logger.warning("Не удалось получить цену ликвидации для позиции") else: # REGULAR_MARGIN try: client.set_tp_sl_mode(symbol=symbol, category='linear', tpSlMode='Full') except exceptions.InvalidRequestError as e: if 'same tp sl mode' in str(e): logger.info("Режим TP/SL уже установлен - пропускаем") else: raise if order_type == 'Market': base_price = entry_price else: base_price = limit_price if side.lower() == 'buy': take_profit_price = base_price * (1 + loss_profit / 100) stop_loss_price = base_price * (1 - loss_profit / 100) else: take_profit_price = base_price * (1 - loss_profit / 100) stop_loss_price = base_price * (1 + loss_profit / 100) take_profit_price = max(take_profit_price, 0) stop_loss_price = max(stop_loss_price, 0) if tpsl_mode == 'Full': tp_order_type = 'Market' sl_order_type = 'Market' tp_limit_price = None sl_limit_price = None else: # Partial tp_order_type = 'Limit' sl_order_type = 'Limit' tp_limit_price = take_profit_price sl_limit_price = stop_loss_price response = client.place_order( category='linear', symbol=symbol, side=side, orderType=order_type, qty=str(quantity), price=str(limit_price) if order_type == 'Limit' and limit_price else None, takeProfit=str(take_profit_price), tpOrderType=tp_order_type, tpLimitPrice=str(tp_limit_price) if tp_limit_price else None, stopLoss=str(stop_loss_price), slOrderType=sl_order_type, slLimitPrice=str(sl_limit_price) if sl_limit_price else None, tpslMode=tpsl_mode, timeInForce='GTC', orderLinkId=f"deal_{symbol}_{int(time.time())}" ) if response.get('retCode', -1) == 0: return True else: logger.error(f"Ошибка открытия ордера: {response}") await message.answer(f"Ошибка открытия ордера", reply_markup=inline_markup.back_to_main) return False return None except exceptions.InvalidRequestError as e: logger.error(f"InvalidRequestError: {e}", exc_info=True) await message.answer('Недостаточно средств для размещения нового ордера с заданным количеством и плечом.', reply_markup=inline_markup.back_to_main) except Exception as e: logger.error(f"Ошибка при совершении сделки: {e}", exc_info=True) await message.answer('Возникла ошибка при попытке открыть позицию.', reply_markup=inline_markup.back_to_main) async def trading_cycle(tg_id, message, side, margin_mode, symbol, starting_quantity): """ Цикл торговой логики с учётом таймера пользователя. """ try: timer_data = await rq.get_user_timer(tg_id) timer_min = 0 if isinstance(timer_data, dict): timer_min = timer_data.get('timer_minutes') or timer_data.get('timer') or 0 else: timer_min = timer_data or 0 timer_sec = timer_min * 60 if timer_sec > 0: await asyncio.sleep(timer_sec) await open_position(tg_id, message, side=side, margin_mode=margin_mode, symbol=symbol, quantity=starting_quantity) except asyncio.CancelledError: logger.info(f"Торговый цикл для пользователя {tg_id} был отменён.", exc_info=True) async def set_take_profit_stop_loss(tg_id: int, message, take_profit_price: float, stop_loss_price: float, tpsl_mode='Full'): """ Устанавливает уровни Take Profit и Stop Loss для открытой позиции. """ symbol = await rq.get_symbol(tg_id) data_main_stgs = await rq.get_user_main_settings(tg_id) trading_mode = data_main_stgs.get('trading_mode') side = None if trading_mode == 'Long': side = 'Buy' elif trading_mode == 'Short': side = 'Sell' if side is None: await message.answer("Не удалось определить сторону сделки.") return client = await get_bybit_client(tg_id) await cancel_all_tp_sl_orders(tg_id, symbol) try: try: client.set_tp_sl_mode(symbol=symbol, category='linear', tpSlMode=tpsl_mode) except exceptions.InvalidRequestError as e: if 'same tp sl mode' in str(e).lower(): logger.info(f"Режим TP/SL уже установлен для {symbol}") else: raise resp = client.set_trading_stop( category='linear', symbol=symbol, takeProfit=str(round(take_profit_price, 5)), stopLoss=str(round(stop_loss_price, 5)), tpTriggerBy='LastPrice', slTriggerBy='LastPrice', positionIdx=0, reduceOnly=False, tpslMode=tpsl_mode ) if resp.get('retCode') != 0: await message.answer(f"Ошибка обновления TP/SL: {resp.get('retMsg')}", reply_markup=inline_markup.back_to_main) return await message.answer( f"ТП и СЛ успешно установлены:\nТейк-профит: {take_profit_price:.5f}\nСтоп-лосс: {stop_loss_price:.5f}", reply_markup=inline_markup.back_to_main) except Exception as e: logger.error(f"Ошибка установки TP/SL для {symbol}: {e}", exc_info=True) await message.answer("Произошла ошибка при установке TP и SL.", reply_markup=inline_markup.back_to_main) async def cancel_all_tp_sl_orders(tg_id, symbol): """ Отменяет лимитные ордера для указанного символа. """ client = await get_bybit_client(tg_id) last_response = None try: orders_resp = client.get_open_orders(category='linear', symbol=symbol) orders = orders_resp.get('result', {}).get('list', []) for order in orders: order_id = order.get('orderId') order_symbol = order.get('symbol') cancel_resp = client.cancel_order(category='linear', symbol=symbol, orderId=order_id) if cancel_resp.get('retCode') != 0: logger.warning(f"Не удалось отменить ордер {order_id}: {cancel_resp.get('retMsg')}") else: last_response = order_symbol except Exception as e: logger.error(f"Ошибка при отмене ордера: {e}") return last_response async def get_active_positions(tg_id, message): """ Показывает активные позиции пользователя. """ client = await get_bybit_client(tg_id) active_positions = client.get_positions(category='linear', settleCoin='USDT') positions = active_positions.get('result', {}).get('list', []) active_symbols = [pos.get('symbol') for pos in positions if float(pos.get('size', 0)) > 0] if active_symbols: await message.answer("📈 Ваши активные позиции:", reply_markup=inline_markup.create_trades_inline_keyboard(active_symbols)) else: await message.answer("❗️ У вас нет активных позиций.", reply_markup=inline_markup.back_to_main) return async def get_active_positions_by_symbol(tg_id, symbol, message): """ Показывает активные позиции пользователя по символу. """ client = await get_bybit_client(tg_id) active_positions = client.get_positions(category='linear', symbol=symbol) positions = active_positions.get('result', {}).get('list', []) pos = positions[0] if positions else None if float(pos.get('size', 0)) == 0: await message.answer("❗️ У вас нет активных позиций.", reply_markup=inline_markup.back_to_main) return text = ( f"Торговая пара: {pos.get('symbol')}\n" f"Цена входа: {pos.get('avgPrice')}\n" f"Движение: {pos.get('side')}\n" f"Кредитное плечо: {pos.get('leverage')}x\n" f"Количество: {pos.get('size')}\n" f"Тейк-профит: {pos.get('takeProfit')}\n" f"Стоп-лосс: {pos.get('stopLoss')}\n" ) await message.answer(text, reply_markup=inline_markup.create_close_deal_markup(symbol)) async def get_active_orders(tg_id, message): """ Показывает активные лимитные ордера пользователя. """ client = await get_bybit_client(tg_id) response = client.get_open_orders(category='linear', settleCoin='USDT', orderType='Limit') orders = response.get('result', {}).get('list', []) limit_orders = [order for order in orders if order.get('orderType') == 'Limit'] if limit_orders: symbols = [order['symbol'] for order in limit_orders] await message.answer("📈 Ваши активные лимитные ордера:", reply_markup=inline_markup.create_trades_inline_keyboard_limits(symbols)) else: await message.answer("❗️ У вас нет активных лимитных ордеров.", reply_markup=inline_markup.back_to_main) return async def get_active_orders_by_symbol(tg_id, symbol, message): """ Показывает активные лимитные ордера пользователя по символу. """ client = await get_bybit_client(tg_id) active_orders = client.get_open_orders(category='linear', symbol=symbol) limit_orders = [ order for order in active_orders.get('result', {}).get('list', []) if order.get('orderType') == 'Limit' ] if not limit_orders: await message.answer("Нет активных лимитных ордеров по данной торговой паре.", reply_markup=inline_markup.back_to_main) return texts = [] for order in limit_orders: text = ( f"Торговая пара: {order.get('symbol')}\n" f"Тип ордера: {order.get('orderType')}\n" f"Сторона: {order.get('side')}\n" f"Цена: {order.get('price')}\n" f"Количество: {order.get('qty')}\n" f"Тейк-профит: {order.get('takeProfit')}\n" f"Стоп-лосс: {order.get('stopLoss')}\n" ) texts.append(text) await message.answer("\n\n".join(texts), reply_markup=inline_markup.create_close_limit_markup(symbol)) async def close_user_trade(tg_id: int, symbol: str): """ Закрывает открытые позиции пользователя по символу рыночным ордером. Возвращает True при успехе, False при ошибках. """ try: client = await get_bybit_client(tg_id) positions_resp = client.get_positions(category="linear", symbol=symbol) if positions_resp.get('retCode') != 0: return False positions_list = positions_resp.get('result', {}).get('list', []) if not positions_list: return False position = positions_list[0] qty = abs(safe_float(position.get('size'))) side = position.get('side') if qty == 0: return False close_side = "Sell" if side == "Buy" else "Buy" place_resp = client.place_order( category="linear", symbol=symbol, side=close_side, orderType="Market", qty=str(qty), timeInForce="GTC", reduceOnly=True ) if place_resp.get('retCode') == 0: return True else: return False except Exception as e: logger.error(f"Ошибка закрытия сделки {symbol} для пользователя {tg_id}: {e}", exc_info=True) return False async def close_trade_after_delay(tg_id: int, message, symbol: str, delay_sec: int): """ Закрывает сделку пользователя после задержки delay_sec секунд. """ try: await asyncio.sleep(delay_sec) result = await close_user_trade(tg_id, symbol) if result: await message.answer(f"Сделка {symbol} успешно закрыта по таймеру.", reply_markup=inline_markup.back_to_main) logger.info(f"Сделка {symbol} успешно закрыта по таймеру.") else: await message.answer(f"Не удалось закрыть сделку {symbol} по таймеру.", reply_markup=inline_markup.back_to_main) logger.error(f"Не удалось закрыть сделку {symbol} по таймеру.") except asyncio.CancelledError: await message.answer(f"Закрытие сделки {symbol} по таймеру отменено.", reply_markup=inline_markup.back_to_main) logger.info(f"Закрытие сделки {symbol} по таймеру отменено.")