diff --git a/.gitignore b/.gitignore index 52973aa..b4a5ad7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ venv/ .venv/ .idea /.idea +/myenv +myenv diff --git a/BybitBot_API.py b/BybitBot_API.py index 1eff3a4..7ead8b1 100644 --- a/BybitBot_API.py +++ b/BybitBot_API.py @@ -1,7 +1,7 @@ import asyncio import logging.config from aiogram import Bot, Dispatcher - +from aiogram.fsm.storage.redis import RedisStorage from app.services.Bybit.functions.bybit_ws import get_or_create_event_loop, set_event_loop from app.telegram.database.models import async_main from app.telegram.handlers.handlers import router @@ -17,8 +17,9 @@ from config import TOKEN_TG_BOT_1 logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger("main") +storage = RedisStorage.from_url("redis://localhost:6379/0") bot = Bot(token=TOKEN_TG_BOT_1) -dp = Dispatcher() +dp = Dispatcher(storage=storage) async def main() -> None: @@ -37,7 +38,10 @@ async def main() -> None: dp.include_router(router_register_bybit_api) dp.include_router(router_functions_bybit_trade) - await dp.start_polling(bot) + try: + await dp.start_polling(bot) + except asyncio.CancelledError: + logger.info("Bot is off") if __name__ == '__main__': diff --git a/app/services/Bybit/functions/Add_Bybit_API.py b/app/services/Bybit/functions/Add_Bybit_API.py index 2ee471e..e425446 100644 --- a/app/services/Bybit/functions/Add_Bybit_API.py +++ b/app/services/Bybit/functions/Add_Bybit_API.py @@ -1,9 +1,16 @@ from aiogram import F, Router import logging.config + +from app.services.Bybit.functions.functions import start_bybit_trade_message from logger_helper.logger_helper import LOGGING_CONFIG import app.telegram.Keyboards.inline_keyboards as inline_markup import app.telegram.Keyboards.reply_keyboards as reply_markup +import app.telegram.functions.main_settings.settings as func_main_settings +import app.telegram.functions.risk_management_settings.settings as func_rmanagement_settings +import app.telegram.functions.condition_settings.settings as func_condition_settings +import app.telegram.functions.additional_settings.settings as func_additional_settings + import app.telegram.database.requests as rq from aiogram.types import Message, CallbackQuery @@ -81,12 +88,24 @@ async def add_secret_key(message: Message, state: FSMContext) -> None: await state.update_data(secret_key=message.text) data = await state.get_data() + user = await rq.check_user(message.from_user.id) - await rq.update_api_key(message.from_user.id, data['api_key']) - await rq.update_secret_key(message.from_user.id, data['secret_key']) + await rq.upsert_api_keys(message.from_user.id, data['api_key'], data['secret_key']) await rq.set_new_user_symbol(message.from_user.id) await state.clear() - await message.answer('Данные добавлены, нажмите на профиль и начните торговлю!', + await message.answer('Данные добавлены.', reply_markup=reply_markup.base_buttons_markup) + + if user: + await start_bybit_trade_message(message) + else: + await rq.save_tg_id_new_user(message.from_user.id) + + await func_main_settings.reg_new_user_default_main_settings(message.from_user.id, message) + await func_rmanagement_settings.reg_new_user_default_risk_management_settings(message.from_user.id, + message) + await func_condition_settings.reg_new_user_default_condition_settings(message.from_user.id) + await func_additional_settings.reg_new_user_default_additional_settings(message.from_user.id, message) + await start_bybit_trade_message(message) \ No newline at end of file diff --git a/app/services/Bybit/functions/Futures.py b/app/services/Bybit/functions/Futures.py index f45d08b..875eef2 100644 --- a/app/services/Bybit/functions/Futures.py +++ b/app/services/Bybit/functions/Futures.py @@ -1,15 +1,14 @@ -import asyncio -import json +import asyncio +import logging.config 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.services.Bybit.functions.price_symbol as price_symbol import app.telegram.database.requests as rq import app.telegram.Keyboards.inline_keyboards as inline_markup +from logger_helper.logger_helper import LOGGING_CONFIG +from pybit import exceptions +from pybit.unified_trading import HTTP logging.config.dictConfig(LOGGING_CONFIG) logger = logging.getLogger("futures") @@ -35,11 +34,11 @@ def safe_float(val) -> float: Возвращает 0.0, если значение None, пустое или некорректное. """ try: - if val is None or val == '': + if val is None or val == "": return 0.0 return float(val) except (ValueError, TypeError): - logger.error("Некорректное значение для преобразования в float") + logger.error("Некорректное значение для преобразования в float", exc_info=True) return 0.0 @@ -47,25 +46,25 @@ def format_trade_details_position(data, commission_fee): """ Форматирует информацию о сделке в виде строки. """ - msg = data.get('data', [{}])[0] + 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)) + 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 = 'Продажа' + movement = "" + if side.lower() == "buy": + movement = "Покупка" + elif side.lower() == "sell": + movement = "Продажа" else: movement = side @@ -81,7 +80,7 @@ def format_trade_details_position(data, commission_fee): f"Комиссия за сделку: {commission:.6f}\n" f"Реализованная прибыль: {pnl:.6f} USDT" ) - if order_type == 'Market': + if order_type == "Market": return ( f"Сделка открыта:\n" f"Торговая пара: {symbol}\n" @@ -98,27 +97,27 @@ 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', '') + 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 = 'Продажа' + movement = "" + if side.lower() == "buy": + movement = "Покупка" + elif side.lower() == "sell": + movement = "Продажа" else: movement = side - if order_status.lower() == 'filled' and order_type.lower() == 'limit': + if order_status.lower() == "filled" and order_type.lower() == "limit": text = ( f"Ордер исполнен:\n" f"Торговая пара: {symbol}\n" @@ -131,10 +130,9 @@ def format_order_details_position(data): f"Стоп-лосс: {stop_loss:.6f}\n" f"Комиссия за сделку: {cum_exec_fee:.6f}\n" ) - logger.info(text) return text - elif order_status.lower() == 'new': + elif order_status.lower() == "new": text = ( f"Ордер создан:\n" f"Торговая пара: {symbol}\n" @@ -145,10 +143,9 @@ def format_order_details_position(data): f"Тейк-профит: {take_profit:.6f}\n" f"Стоп-лосс: {stop_loss:.6f}\n" ) - logger.info(text) return text - elif order_status.lower() == 'cancelled': + elif order_status.lower() == "cancelled": text = ( f"Ордер отменен:\n" f"Торговая пара: {symbol}\n" @@ -159,7 +156,6 @@ def format_order_details_position(data): f"Тейк-профит: {take_profit:.6f}\n" f"Стоп-лосс: {stop_loss:.6f}\n" ) - logger.info(text) return text return None @@ -169,66 +165,120 @@ def parse_pnl_from_msg(msg) -> float: Извлекает реализованную прибыль/убыток из сообщения. """ try: - data = msg.get('data', [{}])[0] - return float(data.get('execPnl', 0)) + data = msg.get("data", [{}])[0] + return float(data.get("execPnl", 0)) except Exception as e: - logger.error(f"Ошибка при извлечении реализованной прибыли: {e}") + logger.error("Ошибка при извлечении реализованной прибыли: %s", e) return 0.0 +async def calculate_total_budget(starting_quantity, martingale_factor, max_steps, commission_fee_percent, leverage, current_price): + """ + Вычисляет общий бюджет серии ставок с учётом цены пары, комиссии и кредитного плеча. + + Параметры: + - starting_quantity_usdt: стартовый размер ставки в долларах (USD) + - martingale_factor: множитель увеличения ставки при каждом проигрыше + - max_steps: максимальное количество шагов удвоения ставки + - commission_fee_percent: процент комиссии на одну операцию (открытие или закрытие) + - leverage: кредитное плечо + - current_price: текущая цена актива (например BTCUSDT) + + Возвращает: + - общий бюджет в долларах, который необходимо иметь на счету + """ + total = 0 + for step in range(max_steps): + quantity = starting_quantity * (martingale_factor ** step) # размер ставки на текущем шаге в USDT + + # Переводим ставку из USDT в количество актива по текущей цене + quantity_in_asset = quantity / current_price + + # Учитываем комиссию за вход и выход (умножаем на 2) + quantity_with_fee = quantity * (1 + 2 * commission_fee_percent / 100) + + # Учитываем кредитное плечо - реальные собственные вложения меньше + effective_quantity = quantity_with_fee / leverage + + total += effective_quantity + + # Возвращаем бюджет в USDT + total_usdt = total * current_price + return total_usdt + + 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 = 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', "ДА") + 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') + symbol = data.get("symbol") + 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')) + margin_mode = data_main_stgs.get("margin_type", "Isolated") + starting_quantity = safe_float(data_main_stgs.get("starting_quantity")) + martingale_factor = safe_float(data_main_stgs.get("martingale_factor")) + closed_size = safe_float(data.get("closedSize", 0)) + commission = safe_float(data.get("execFee", 0)) - trade_info = format_trade_details_position(data=msg, commission_fee=commission_fee) + if commission_fee == "Да": + pnl -= commission + + 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 closed_size == 0: + side = data.get("side", "") - if trigger == "Автоматический": + if side.lower() == "buy": + await rq.set_last_series_info(tg_id, last_side="Buy") + elif side.lower() == "sell": + await rq.set_last_series_info(tg_id, last_side="Sell") + + if trigger == "Автоматический" and closed_size > 0: if pnl < 0: - martingale_factor = safe_float(data_main_stgs.get('martingale_factor')) + + if trading_mode == 'Switch': + side = data_main_stgs.get("last_side") + else: + side = "Buy" if trading_mode == "Long" else "Sell" + 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) + 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) + await message.answer( + f"❗️ Сделка закрылась в минус, открываю новую сделку с увеличенной ставкой.\n" + ) + 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) + await message.answer( + "❗️ Прибыль достигнута, шаг мартингейла сброшен." + ) async def handle_order_message(message, msg: dict) -> None: @@ -248,21 +298,29 @@ async def error_max_step(message) -> None: """ Сообщение об ошибке превышения максимального количества шагов мартингейла. """ - logger.error('Сделка не была совершена, превышен лимит максимального количества ставок в серии.') - await message.answer('Сделка не была совершена, превышен лимит максимального количества ставок в серии.', - reply_markup=inline_markup.back_to_main) + 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) + 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'): +async def open_position( + tg_id, message, side: str, margin_mode: str, symbol, quantity, tpsl_mode="Full" +): """ Открывает позицию на Bybit с учётом настроек пользователя, маржи, размера лота, платформы и риска. @@ -271,103 +329,157 @@ async def open_position(tg_id, message, side: str, margin_mode: str, symbol, qua 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' + 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': + 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) + leverage = safe_float(data_main_stgs.get("size_leverage", 1)) - max_martingale_steps = int(data_main_stgs.get('maximal_quantity', 0)) + 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')) + max_risk_percent = safe_float(data_risk_stgs.get("max_risk_deal")) + loss_profit = safe_float(data_risk_stgs.get("price_loss")) + commission_fee = data_risk_stgs.get("commission_fee") + starting_quantity = safe_float(data_main_stgs.get('starting_quantity')) + martingale_factor = safe_float(data_main_stgs.get('martingale_factor')) + fee_info = client.get_fee_rates(category='linear', symbol=symbol) + instruments_resp = client.get_instruments_info(category="linear", symbol=symbol) + instrument = instruments_resp.get("result", {}).get("list", []) - if order_type == 'Limit' and limit_price: + if commission_fee == "Да": + commission_fee_percent = safe_float(fee_info['result']['list'][0]['takerFeeRate']) + else: + commission_fee_percent = 0.0 + + total_budget = await calculate_total_budget( + starting_quantity=starting_quantity, + martingale_factor=martingale_factor, + max_steps=max_martingale_steps, + commission_fee_percent=commission_fee_percent, + leverage=leverage, + current_price=entry_price, + ) + + balance = await balance_g.get_balance(tg_id, message) + if safe_float(balance) < total_budget: + logger.error( + f"Недостаточно средств для серии из {max_martingale_steps} шагов с текущими параметрами. " + f"Требуемый бюджет: {total_budget:.2f} USDT, доступно: {balance} USDT." + ) + await message.answer( + f"Недостаточно средств для серии из {max_martingale_steps} шагов с текущими параметрами. " + f"Требуемый бюджет: {total_budget:.2f} USDT, доступно: {balance} USDT.", + reply_markup=inline_markup.back_to_main, + ) + return + + 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) + adjusted_loss = potential_loss / leverage 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: + if adjusted_loss > allowed_loss: await error_max_risk(message) return - client.set_margin_mode(setMarginMode=bybit_margin_mode) + if max_martingale_steps < current_martingale: + await error_max_step(message) + return - leverage = int(data_main_stgs.get('size_leverage', 1)) - try: - resp = client.set_leverage( - category='linear', - symbol=symbol, - buyLeverage=str(leverage), - sellLeverage=str(leverage) + client.set_margin_mode(setMarginMode=bybit_margin_mode) + max_leverage = safe_float(instrument[0].get("leverageFilter", {}).get("maxLeverage", 0)) + + if safe_float(leverage) > max_leverage: + await message.answer( + f"Запрошенное кредитное плечо {leverage} превышает максимальное {max_leverage} для {symbol}. " + f"Устанавливаю максимальное.", + reply_markup=inline_markup.back_to_main, ) + logger.info( + f"Запрошенное кредитное плечо {leverage} превышает максимальное {max_leverage} для {symbol}. Устанавливаю максимальное.") + leverage_to_set = max_leverage + else: + leverage_to_set = safe_float(leverage) + + try: + client.set_leverage( + category="linear", + symbol=symbol, + buyLeverage=str(leverage_to_set), + sellLeverage=str(leverage_to_set), + ) + logger.info(f"Set leverage to {leverage_to_set} for {symbol}") 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 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) + instrument_info = instrument_info[0] + min_notional_value = float(instrument_info.get("lotSizeFilter", {}).get("minNotionalValue", 0)) + min_order_value = min_notional_value else: min_order_value = 5.0 order_value = float(quantity) * price_for_calc if order_value < min_order_value: + logger.error( + f"Сумма ордера слишком мала: {order_value:.2f} USDT. " + f"Минимум для торговли — {min_order_value} USDT. " + f"Пожалуйста, увеличьте количество позиций." + ) await message.answer( f"Сумма ордера слишком мала: {order_value:.2f} USDT. " f"Минимум для торговли — {min_order_value} USDT. " - f"Пожалуйста, увеличьте количество позиций.", reply_markup=inline_markup.back_to_main) + f"Пожалуйста, увеличьте количество позиций.", + reply_markup=inline_markup.back_to_main, + ) return False - if bybit_margin_mode == 'ISOLATED_MARGIN': + if bybit_margin_mode == "ISOLATED_MARGIN": # Открываем позицию response = client.place_order( - category='linear', + 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())}" + 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: + if response.get("retCode", -1) != 0: logger.error(f"Ошибка открытия ордера: {response}") - await message.answer(f"Ошибка открытия ордера", reply_markup=inline_markup.back_to_main) + 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)) + 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': + if side.lower() == "buy": take_profit_price = avg_price + (avg_price - liq_price) else: take_profit_price = avg_price - (liq_price - avg_price) @@ -376,45 +488,49 @@ async def open_position(tg_id, message, side: str, margin_mode: str, symbol, qua try: try: - client.set_tp_sl_mode(symbol=symbol, category='linear', tpSlMode='Full') + client.set_tp_sl_mode( + symbol=symbol, category="linear", tpSlMode="Full" + ) except exceptions.InvalidRequestError as e: - if 'same tp sl mode' in str(e): + if "same tp sl mode" in str(e): logger.info("Режим TP/SL уже установлен - пропускаем") else: raise resp = client.set_trading_stop( - category='linear', + category="linear", symbol=symbol, takeProfit=str(round(take_profit_price, 5)), - tpTriggerBy='LastPrice', - slTriggerBy='LastPrice', + tpTriggerBy="LastPrice", + slTriggerBy="LastPrice", positionIdx=0, reduceOnly=False, - tpslMode=tpsl_mode + 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) + 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') + client.set_tp_sl_mode(symbol=symbol, category="linear", tpSlMode="Full") except exceptions.InvalidRequestError as e: - if 'same tp sl mode' in str(e): + if "same tp sl mode" in str(e): logger.info("Режим TP/SL уже установлен - пропускаем") else: raise - if order_type == 'Market': + if order_type == "Market": base_price = entry_price else: base_price = limit_price - if side.lower() == 'buy': + if side.lower() == "buy": take_profit_price = base_price * (1 + loss_profit / 100) stop_loss_price = base_price * (1 - loss_profit / 100) else: @@ -424,24 +540,26 @@ async def open_position(tg_id, message, side: str, margin_mode: str, symbol, qua 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' + 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_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', + 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, + 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, @@ -449,104 +567,97 @@ async def open_position(tg_id, message, side: str, margin_mode: str, symbol, qua 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())}" + timeInForce="GTC", + orderLinkId=f"deal_{symbol}_{int(time.time())}", ) - if response.get('retCode', -1) == 0: + if response.get("retCode", -1) == 0: return True else: logger.error(f"Ошибка открытия ордера: {response}") - await message.answer(f"Ошибка открытия ордера", reply_markup=inline_markup.back_to_main) + 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 + logger.error("InvalidRequestError: %s", e) + error_text = str(e) + if "estimated will trigger liq" in error_text: + await message.answer( + "Лимитный ордер может вызвать мгновенную ликвидацию. Проверьте параметры ордера.", + reply_markup=inline_markup.back_to_main, + ) + elif "ab not enough for new order" in error_text: + await message.answer("Недостаточно средств для нового ордера", + reply_markup=inline_markup.back_to_main) 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) + logger.error("Ошибка при совершении сделки: %s", e) + await message.answer( + "Недостаточно средств для размещения нового ордера с заданным количеством и плечом.", + reply_markup=inline_markup.back_to_main, + ) + except Exception as e: + logger.error("Ошибка при совершении сделки: %s", e) + await message.answer( + "Возникла ошибка при попытке открыть позицию.", + reply_markup=inline_markup.back_to_main, + ) -async def set_take_profit_stop_loss(tg_id: int, message, take_profit_price: float, stop_loss_price: float, - tpsl_mode='Full'): +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) + 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}") + if "same tp sl mode" in str(e).lower(): + logger.info("Режим TP/SL уже установлен для %s - пропускаем", symbol) else: raise resp = client.set_trading_stop( - category='linear', + category="linear", symbol=symbol, takeProfit=str(round(take_profit_price, 5)), stopLoss=str(round(stop_loss_price, 5)), - tpTriggerBy='LastPrice', - slTriggerBy='LastPrice', + tpTriggerBy="LastPrice", + slTriggerBy="LastPrice", positionIdx=0, reduceOnly=False, - tpslMode=tpsl_mode + tpslMode=tpsl_mode, ) - if resp.get('retCode') != 0: - await message.answer(f"Ошибка обновления TP/SL: {resp.get('retMsg')}", - reply_markup=inline_markup.back_to_main) + 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) + 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) + await message.answer( + "Произошла ошибка при установке TP и SL.", + reply_markup=inline_markup.back_to_main, + ) async def cancel_all_tp_sl_orders(tg_id, symbol): @@ -556,15 +667,19 @@ 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', []) + 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')}") + 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: @@ -578,15 +693,21 @@ 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] + 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)) + await message.answer( + "📈 Ваши активные позиции:", + reply_markup=inline_markup.create_trades_inline_keyboard(active_symbols), + ) else: - await message.answer("❗️ У вас нет активных позиций.", reply_markup=inline_markup.back_to_main) + await message.answer( + "❗️ У вас нет активных позиций.", reply_markup=inline_markup.back_to_main + ) return @@ -595,12 +716,14 @@ 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', []) + 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) + if float(pos.get("size", 0)) == 0: + await message.answer( + "❗️ У вас нет активных позиций.", reply_markup=inline_markup.back_to_main + ) return text = ( @@ -613,7 +736,9 @@ async def get_active_positions_by_symbol(tg_id, symbol, message): f"Стоп-лосс: {pos.get('stopLoss')}\n" ) - await message.answer(text, reply_markup=inline_markup.create_close_deal_markup(symbol)) + await message.answer( + text, reply_markup=inline_markup.create_close_deal_markup(symbol) + ) async def get_active_orders(tg_id, message): @@ -621,16 +746,23 @@ 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'] + 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)) + 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) + await message.answer( + "❗️ У вас нет активных лимитных ордеров.", + reply_markup=inline_markup.back_to_main, + ) return @@ -639,15 +771,18 @@ 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) + 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' + 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) + await message.answer( + "Нет активных лимитных ордеров по данной торговой паре.", + reply_markup=inline_markup.back_to_main, + ) return texts = [] @@ -663,7 +798,9 @@ async def get_active_orders_by_symbol(tg_id, symbol, message): ) texts.append(text) - await message.answer("\n\n".join(texts), reply_markup=inline_markup.create_close_limit_markup(symbol)) + 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): @@ -675,15 +812,15 @@ async def close_user_trade(tg_id: int, symbol: str): client = await get_bybit_client(tg_id) positions_resp = client.get_positions(category="linear", symbol=symbol) - if positions_resp.get('retCode') != 0: + if positions_resp.get("retCode") != 0: return False - positions_list = positions_resp.get('result', {}).get('list', []) + 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') + qty = abs(safe_float(position.get("size"))) + side = position.get("side") if qty == 0: return False @@ -696,15 +833,18 @@ async def close_user_trade(tg_id: int, symbol: str): orderType="Market", qty=str(qty), timeInForce="GTC", - reduceOnly=True + reduceOnly=True, ) - if place_resp.get('retCode') == 0: + 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) + logger.error( + f"Ошибка закрытия сделки {symbol} для пользователя {tg_id}: {e}", + exc_info=True, + ) return False @@ -716,13 +856,19 @@ async def close_trade_after_delay(tg_id: int, message, symbol: str, delay_sec: i 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) + await message.answer( + f"Сделка {symbol} успешно закрыта по таймеру." + ) logger.info(f"Сделка {symbol} успешно закрыта по таймеру.") else: - await message.answer(f"Не удалось закрыть сделку {symbol} по таймеру.", - reply_markup=inline_markup.back_to_main) + 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) + await message.answer( + f"Закрытие сделки {symbol} по таймеру отменено.", + reply_markup=inline_markup.back_to_main, + ) logger.info(f"Закрытие сделки {symbol} по таймеру отменено.") diff --git a/app/services/Bybit/functions/balance.py b/app/services/Bybit/functions/balance.py index 7907d7f..35093d4 100644 --- a/app/services/Bybit/functions/balance.py +++ b/app/services/Bybit/functions/balance.py @@ -32,7 +32,7 @@ async def get_balance(tg_id: int, message) -> float: api_secret=secret_key ) - if api_key == 'None' or secret_key == 'None': + if api_key is None or secret_key is None: await message.answer('⚠️ Подключите платформу для торговли', reply_markup=inline_markup.connect_bybit_api_message) return 0 @@ -48,5 +48,5 @@ async def get_balance(tg_id: int, message) -> float: return 0 except Exception as e: logger.error(f"Ошибка при получении общего баланса: {e}") - await message.answer('⚠️ Ошибка при получении баланса') + await message.answer('Ошибка при подключении, повторите попытку', reply_markup=inline_markup.connect_bybit_api_message) return 0 diff --git a/app/services/Bybit/functions/bybit_ws.py b/app/services/Bybit/functions/bybit_ws.py index 961faa8..28516e3 100644 --- a/app/services/Bybit/functions/bybit_ws.py +++ b/app/services/Bybit/functions/bybit_ws.py @@ -1,5 +1,6 @@ import asyncio import logging.config + from pybit.unified_trading import WebSocket from websocket import WebSocketConnectionClosedException from logger_helper.logger_helper import LOGGING_CONFIG @@ -11,6 +12,30 @@ logger = logging.getLogger("bybit_ws") event_loop = None # Сюда нужно будет установить event loop из основного приложения active_ws_tasks = {} + +def on_ws_error(ws, error): + logger.error(f"WebSocket internal error: {error}") + # Запланировать переподключение через event loop + if event_loop: + asyncio.run_coroutine_threadsafe(reconnect_ws(ws), event_loop) + + +def on_ws_close(ws, close_status_code, close_msg): + logger.warning(f"WebSocket closed: {close_status_code} - {close_msg}") + # Запланировать переподключение через event loop + if event_loop: + asyncio.run_coroutine_threadsafe(reconnect_ws(ws), event_loop) + + +async def reconnect_ws(ws): + logger.info("Запускаем переподключение WebSocket...") + await asyncio.sleep(5) + try: + await ws.run_forever() + except WebSocketConnectionClosedException: + logger.info("WebSocket переподключение успешно завершено.") + + def get_or_create_event_loop() -> asyncio.AbstractEventLoop: """ Возвращает текущий активный цикл событий asyncio или создает новый, если его нет. @@ -46,6 +71,7 @@ def on_order_callback(message, msg): if event_loop is not None: from app.services.Bybit.functions.Futures import handle_order_message asyncio.run_coroutine_threadsafe(handle_order_message(message, msg), event_loop) + logger.info("Callback выполнен.") else: logger.error("Event loop не установлен, callback пропущен.") @@ -54,6 +80,7 @@ def on_execution_callback(message, ws_msg): if event_loop is not None: from app.services.Bybit.functions.Futures import handle_execution_message asyncio.run_coroutine_threadsafe(handle_execution_message(message, ws_msg), event_loop) + logger.info("Callback выполнен.") else: logger.error("Event loop не установлен, callback пропущен.") @@ -72,8 +99,12 @@ async def start_execution_ws(api_key: str, api_secret: str, message): continue ws = WebSocket(api_key=api_key, api_secret=api_secret, testnet=False, channel_type="private") + ws.on_error = on_ws_error + ws.on_close = on_ws_close + ws.subscribe("order", lambda ws_msg: on_order_callback(message, ws_msg)) ws.subscribe("execution", lambda ws_msg: on_execution_callback(message, ws_msg)) + while True: await asyncio.sleep(1) # Поддержание активности except WebSocketConnectionClosedException: diff --git a/app/services/Bybit/functions/functions.py b/app/services/Bybit/functions/functions.py index 57b6f65..d03999f 100644 --- a/app/services/Bybit/functions/functions.py +++ b/app/services/Bybit/functions/functions.py @@ -2,13 +2,14 @@ import logging.config from aiogram import F, Router +from app.services.Bybit.functions.bybit_ws import run_ws_for_user from app.telegram.functions.main_settings.settings import main_settings_message from logger_helper.logger_helper import LOGGING_CONFIG from app.services.Bybit.functions.Futures import (close_user_trade, set_take_profit_stop_loss, \ get_active_positions_by_symbol, get_active_orders_by_symbol, get_active_positions, get_active_orders, cancel_all_tp_sl_orders, - trading_cycle, open_position, close_trade_after_delay, safe_float, + open_position, close_trade_after_delay, safe_float, ) from app.services.Bybit.functions.balance import get_balance import app.telegram.Keyboards.inline_keyboards as inline_markup @@ -28,6 +29,8 @@ logger = logging.getLogger("functions") router_functions_bybit_trade = Router() +user_trade_tasks = {} + @router_functions_bybit_trade.callback_query(F.data.in_(['clb_start_trading', 'clb_back_to_main', 'back_to_main'])) async def clb_start_bybit_trade_message(callback: CallbackQuery) -> None: @@ -61,8 +64,10 @@ async def start_bybit_trade_message(message: Message) -> None: вместе с инструкциями по началу торговли. """ balance = await get_balance(message.from_user.id, message) + tg_id = message.from_user.id if balance: + await run_ws_for_user(tg_id, message) symbol = await rq.get_symbol(message.from_user.id) price = await get_price(message.from_user.id, symbol=symbol) @@ -86,6 +91,7 @@ async def update_symbol_for_trade_message(callback: CallbackQuery, state: FSMCon Начинает процедуру обновления торговой пары, переводит пользователя в состояние ожидания пары. """ await state.set_state(state_update_symbol.symbol) + await callback.answer() await callback.message.answer( text='Укажите торговую пару заглавными буквами без пробелов и лишних символов (пример: BTCUSDT): ', @@ -99,7 +105,6 @@ async def update_symbol_for_trade(message: Message, state: FSMContext) -> None: При успешном обновлении сохранит пару и отправит обновлённую информацию. """ user_input = message.text.strip().upper() - exists = await get_valid_symbols(message.from_user.id, user_input) if not exists: @@ -187,20 +192,25 @@ async def start_trading_process(callback: CallbackQuery) -> None: Проверяет API-ключи, режим торговли, маржинальный режим и открытые позиции, затем запускает торговый цикл с задержкой или без неё. """ + await callback.answer() tg_id = callback.from_user.id message = callback.message - data_main_stgs = await rq.get_user_main_settings(tg_id) symbol = await rq.get_symbol(tg_id) margin_mode = data_main_stgs.get('margin_type', 'Isolated') trading_mode = data_main_stgs.get('trading_mode') - switch_mode = data_main_stgs.get('switch_mode_enabled') starting_quantity = safe_float(data_main_stgs.get('starting_quantity')) + switch_state = data_main_stgs.get("switch_state", "По направлению") - side = None - if switch_mode == 'Включено': - switch_state = data_main_stgs.get('switch_state', 'Long') - side = 'Buy' if switch_state == 'Long' else 'Sell' + if trading_mode == 'Switch': + if switch_state == "По направлению": + side = data_main_stgs.get("last_side") + else: + side = data_main_stgs.get("last_side") + if side.lower() == "buy": + side = "Sell" + else: + side = "Buy" else: if trading_mode == 'Long': side = 'Buy' @@ -209,7 +219,6 @@ async def start_trading_process(callback: CallbackQuery) -> None: else: await message.answer(f"Режим торговли '{trading_mode}' пока не поддерживается.", reply_markup=inline_markup.back_to_main) - await callback.answer() return await message.answer("Начинаю торговлю с использованием текущих настроек...") @@ -221,14 +230,39 @@ async def start_trading_process(callback: CallbackQuery) -> None: timer_minute = timer_data or 0 if timer_minute > 0: - await trading_cycle(tg_id, message, side=side, margin_mode=margin_mode, symbol=symbol, - starting_quantity=starting_quantity) - await message.answer(f"Торговля начнётся через {timer_minute} мин.") - await rq.update_user_timer(tg_id, minutes=0) + await message.answer(f"Торговля начнётся через {timer_minute} мин.", reply_markup=inline_markup.cancel_start) + + async def delay_start(): + try: + await asyncio.sleep(timer_minute * 60) + await open_position(tg_id, message, side, margin_mode, symbol=symbol, quantity=starting_quantity) + await rq.update_user_timer(tg_id, minutes=0) + except asyncio.exceptions.CancelledError: + logger.exception(f"Торговый цикл для пользователя {tg_id} был отменён.") + raise + + task = asyncio.create_task(delay_start()) + user_trade_tasks[tg_id] = task else: await open_position(tg_id, message, side, margin_mode, symbol=symbol, quantity=starting_quantity) - await callback.answer() + +@router_functions_bybit_trade.callback_query(F.data == "clb_cancel_start") +async def cancel_start_trading(callback: CallbackQuery): + tg_id = callback.from_user.id + task = user_trade_tasks.get(tg_id) + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + user_trade_tasks.pop(tg_id, None) + await rq.update_user_timer(tg_id, minutes=0) + await callback.message.answer("Запуск торговли отменён.", reply_markup=inline_markup.back_to_main) + await callback.message.edit_reply_markup(reply_markup=None) + else: + await callback.answer("Нет запланированной задачи запуска.", show_alert=True) @router_functions_bybit_trade.callback_query(F.data == "clb_my_deals") @@ -437,7 +471,8 @@ async def process_close_delay(message: Message, state: FSMContext) -> None: symbol = data.get("symbol") delay = delay_minutes * 60 - await message.answer(f"Закрытие сделки {symbol} запланировано через {delay_minutes} мин.") + await message.answer(f"Закрытие сделки {symbol} запланировано через {delay_minutes} мин.", + reply_markup=inline_markup.back_to_main) await close_trade_after_delay(message.from_user.id, message, symbol, delay) await state.clear() @@ -448,7 +483,7 @@ async def reset_martingale(callback: CallbackQuery) -> None: Сбрасывает шаги мартингейла пользователя. """ tg_id = callback.from_user.id - await rq.update_martingale_step(tg_id, 0) + await rq.update_martingale_step(tg_id, 1) await callback.answer("Сброс шагов выполнен.") await main_settings_message(tg_id, callback.message) @@ -479,11 +514,11 @@ async def stop_immediately(callback: CallbackQuery): @router_functions_bybit_trade.callback_query(F.data == "stop_with_timer") async def stop_with_timer_start(callback: CallbackQuery, state: FSMContext): """ - Запускает диалог с пользователем для задания задержки перед остановкой торговли. + Запускает диалог с пользователем для задания задержки до остановки торговли. """ await state.set_state(CloseTradeTimerState.waiting_for_trade) - await callback.message.answer("Введите задержку в минутах перед остановкой торговли:", + await callback.message.answer("Введите задержку в минутах до остановки торговли:", reply_markup=inline_markup.cancel) await callback.answer() @@ -505,7 +540,8 @@ async def process_stop_delay(message: Message, state: FSMContext): tg_id = message.from_user.id delay_seconds = delay_minutes * 60 - await message.answer(f"Торговля будет остановлена через {delay_minutes} минут.") + await message.answer(f"Торговля будет остановлена через {delay_minutes} минут.", + reply_markup=inline_markup.back_to_main) await asyncio.sleep(delay_seconds) await rq.update_trigger(tg_id, "Ручной") await message.answer("Автоматическая торговля остановлена.", reply_markup=inline_markup.back_to_main) diff --git a/app/telegram/Keyboards/inline_keyboards.py b/app/telegram/Keyboards/inline_keyboards.py index 0040b50..8efe82f 100644 --- a/app/telegram/Keyboards/inline_keyboards.py +++ b/app/telegram/Keyboards/inline_keyboards.py @@ -9,6 +9,10 @@ settings_markup = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="Запуск", callback_data='clb_start_trading')] ]) +cancel_start = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="Отменить запуск", callback_data="clb_cancel_start")] +]) + back_btn_list_settings = [InlineKeyboardButton(text="Назад", callback_data='clb_back_to_special_settings_message')] # Кнопка для возврата к списку каталога настроек back_btn_list_settings_markup = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", @@ -25,8 +29,8 @@ special_settings_markup = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="Основные настройки", callback_data='clb_change_main_settings'), InlineKeyboardButton(text="Риск-менеджмент", callback_data='clb_change_risk_management_settings')], - [InlineKeyboardButton(text="Условия запуска", callback_data='clb_change_condition_settings'), - InlineKeyboardButton(text="Дополнительные параметры", callback_data='clb_change_additional_settings')], + [InlineKeyboardButton(text="Условия запуска", callback_data='clb_change_condition_settings')], + # InlineKeyboardButton(text="Дополнительные параметры", callback_data='clb_change_additional_settings')], [InlineKeyboardButton(text="Подключить Bybit", callback_data='clb_new_user_connect_bybit_api_message')], back_btn_to_main ]) @@ -46,10 +50,8 @@ trading_markup = InlineKeyboardMarkup(inline_keyboard=[ ]) start_trading_markup = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="На главную", callback_data='back_to_main')], [InlineKeyboardButton(text="Начать торговлю", callback_data="clb_start_chatbot_trading")], - [InlineKeyboardButton(text="Установить таймер", callback_data="clb_set_timer")], - + [InlineKeyboardButton(text="На главную", callback_data='back_to_main')], ]) cancel = InlineKeyboardMarkup(inline_keyboard=[ @@ -73,7 +75,7 @@ back_to_main = InlineKeyboardMarkup(inline_keyboard=[ main_settings_markup = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text='Режим торговли', callback_data='clb_change_trading_mode'), - InlineKeyboardButton(text='Режим свитч', callback_data='clb_change_switch_mode'), + InlineKeyboardButton(text='Состояние свитча', callback_data='clb_change_switch_state'), InlineKeyboardButton(text='Тип маржи', callback_data='clb_change_margin_type')], [InlineKeyboardButton(text='Размер кредитного плеча', callback_data='clb_change_size_leverage'), @@ -101,14 +103,14 @@ risk_management_settings_markup = InlineKeyboardMarkup(inline_keyboard=[ condition_settings_markup = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text='Режим торговли', callback_data='clb_change_mode'), InlineKeyboardButton(text='Таймер', callback_data='clb_change_timer')], - - [InlineKeyboardButton(text='Фильтр волатильности', callback_data='clb_change_filter_volatility'), - InlineKeyboardButton(text='Внешние сигналы', callback_data='clb_change_external_cues')], - - [InlineKeyboardButton(text='Сигналы TradingView', callback_data='clb_change_tradingview_cues'), - InlineKeyboardButton(text='Webhook URL', callback_data='clb_change_webhook')], - - [InlineKeyboardButton(text='AI - аналитика', callback_data='clb_change_ai_analytics')], + # + # [InlineKeyboardButton(text='Фильтр волатильности', callback_data='clb_change_filter_volatility'), + # InlineKeyboardButton(text='Внешние сигналы', callback_data='clb_change_external_cues')], + # + # [InlineKeyboardButton(text='Сигналы TradingView', callback_data='clb_change_tradingview_cues'), + # InlineKeyboardButton(text='Webhook URL', callback_data='clb_change_webhook')], + # + # [InlineKeyboardButton(text='AI - аналитика', callback_data='clb_change_ai_analytics')], back_btn_list_settings, back_btn_to_main @@ -127,7 +129,8 @@ additional_settings_markup = InlineKeyboardMarkup(inline_keyboard=[ trading_mode_markup = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="Лонг", callback_data="trade_mode_long"), InlineKeyboardButton(text="Шорт", callback_data="trade_mode_short"), - InlineKeyboardButton(text="Смарт", callback_data="trade_mode_smart")], + InlineKeyboardButton(text="Свитч", callback_data="trade_mode_switch")], + # InlineKeyboardButton(text="Смарт", callback_data="trade_mode_smart")], back_btn_list_settings, back_btn_to_main @@ -137,8 +140,7 @@ margin_type_markup = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="Изолированный", callback_data="margin_type_isolated"), InlineKeyboardButton(text="Кросс", callback_data="margin_type_cross")], - back_btn_list_settings, - back_btn_to_main + back_btn_list_settings ]) trigger_markup = InlineKeyboardMarkup(inline_keyboard=[ # ИЗМЕНИТЬ НА INLINE @@ -196,6 +198,7 @@ def create_close_limit_markup(symbol: str) -> InlineKeyboardMarkup: timer_markup = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="Установить таймер", callback_data="clb_set_timer")], + [InlineKeyboardButton(text="Удалить таймер", callback_data="clb_delete_timer")], back_btn_to_main ]) @@ -208,14 +211,7 @@ stop_choice_markup = InlineKeyboardMarkup( ] ) -buttons_on_off_markup_for_switch = InlineKeyboardMarkup(inline_keyboard=[ # ИЗМЕНИТЬ НА INLINE - [InlineKeyboardButton(text='Включить', callback_data="clb_on_switch"), - InlineKeyboardButton(text='Выключить', callback_data="clb_off_switch")], - [InlineKeyboardButton(text="Изменить состояние", callback_data="clb_switch_state")], - back_btn_to_main -]) - switch_state_markup = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text='Long', callback_data="clb_long_switch"), - InlineKeyboardButton(text='Short', callback_data="clb_short_switch")], + [InlineKeyboardButton(text='По направлению', callback_data="clb_long_switch"), + InlineKeyboardButton(text='Против направления', callback_data="clb_short_switch")], ]) \ No newline at end of file diff --git a/app/telegram/database/models.py b/app/telegram/database/models.py index a7e30f8..c3d6803 100644 --- a/app/telegram/database/models.py +++ b/app/telegram/database/models.py @@ -54,10 +54,10 @@ class User_Bybit_API(Base): id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) - api_key = mapped_column(String(18), default='None') - secret_key = mapped_column(String(36), default='None') + api_key = mapped_column(String(18), unique=True, nullable=True) + secret_key = mapped_column(String(36), unique=True, nullable=True) class User_Symbol(Base): @@ -65,25 +65,12 @@ class User_Symbol(Base): Модель таблицы user_main_settings. Хранит основные настройки торговли для пользователя. - - Атрибуты: - id (int): Внутренний первичный ключ записи. - tg_id (int): Внешний ключ на Telegram пользователя. - trading_mode (str): Режим торговли (ForeignKey на trading_modes.mode). - margin_type (str): Тип маржи (ForeignKey на margin_types.type). - size_leverage (int): Кредитное плечо. - starting_quantity (int): Начальный объём позиции. - martingale_factor (int): Коэффициент мартингейла. - martingale_step (int): Текущий шаг мартингейла. - maximal_quantity (int): Максимальное количество шагов мартингейла. - entry_order_type (str): Тип входа (Market или Limit). - limit_order_price (str, optional): Цена лимитного ордера, если используется. """ __tablename__ = 'user_symbols' id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) symbol = mapped_column(String(18), default='PENGUUSDT') @@ -94,7 +81,7 @@ class Trading_Mode(Base): Атрибуты: id (int): Первичный ключ. - mode (str): Уникальный режим (например, 'Long', 'Short'). + mode (str): Уникальный режим (например, 'Long', 'Short', 'Switch). """ __tablename__ = 'trading_modes' @@ -123,7 +110,7 @@ class Trigger(Base): Справочник триггеров для сделок. Атрибуты: - id (int): Первичный ключ.. + id (int): Первичный ключ. """ __tablename__ = 'triggers' @@ -148,24 +135,25 @@ class User_Main_Settings(Base): maximal_quantity (int): Максимальное число шагов мартингейла. entry_order_type (str): Тип ордера входа (Market/Limit). limit_order_price (Optional[str]): Цена лимитного ордера, если есть. + last_side (str): Последняя сторона ордера. """ __tablename__ = 'user_main_settings' id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) trading_mode = mapped_column(ForeignKey("trading_modes.mode")) margin_type = mapped_column(ForeignKey("margin_types.type")) - switch_mode_enabled = mapped_column(String(15), default="Выключен") - switch_state = mapped_column(String(10), default='Long') + switch_state = mapped_column(String(10), default='По направлению') size_leverage = mapped_column(Integer(), default=1) starting_quantity = mapped_column(Integer(), default=1) martingale_factor = mapped_column(Integer(), default=1) - martingale_step = mapped_column(Integer(), default=0) + martingale_step = mapped_column(Integer(), default=1) maximal_quantity = mapped_column(Integer(), default=10) entry_order_type = mapped_column(String(10), default='Market') limit_order_price = mapped_column(Numeric(18, 15), nullable=True) + last_side = mapped_column(String(10), default='Buy') class User_Risk_Management_Settings(Base): @@ -184,7 +172,7 @@ class User_Risk_Management_Settings(Base): id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) price_profit = mapped_column(Integer(), default=1) price_loss = mapped_column(Integer(), default=1) @@ -211,9 +199,9 @@ class User_Condition_Settings(Base): id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) - trigger = mapped_column(String(15), default='Ручной') + trigger = mapped_column(String(15), default='Автоматический') filter_time = mapped_column(String(25), default='???') filter_volatility = mapped_column(Boolean, default=False) external_cues = mapped_column(Boolean, default=False) @@ -237,7 +225,7 @@ class User_Additional_Settings(Base): id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) pattern_save = mapped_column(Boolean, default=False) autostart = mapped_column(Boolean, default=False) @@ -260,7 +248,7 @@ class USER_DEALS(Base): id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) symbol = mapped_column(String(18), default='PENGUUSDT') side = mapped_column(String(10), nullable=False) @@ -282,7 +270,7 @@ class UserTimer(Base): __tablename__ = 'user_timers' id: Mapped[int] = mapped_column(primary_key=True) - tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id")) + tg_id = mapped_column(ForeignKey("user_telegram_id.tg_id"), unique=True, nullable=True) timer_minutes = mapped_column(Integer, nullable=False, default=0) timer_start = mapped_column(DateTime, default=datetime.utcnow) timer_end = mapped_column(DateTime, nullable=True) @@ -296,7 +284,7 @@ async def async_main(): await conn.run_sync(Base.metadata.create_all) # Заполнение таблиц - modes = ['Long', 'Short', 'Smart'] + modes = ['Long', 'Short', 'Switch', 'Smart'] for mode in modes: result = await conn.execute(select(Trading_Mode).where(Trading_Mode.mode == mode)) if not result.first(): @@ -309,3 +297,10 @@ async def async_main(): if not result.first(): logger.info("Заполение таблицы типов маржи") await conn.execute(Margin_type.__table__.insert().values(type=type)) + + last_side = ['Buy', 'Sell'] + for side in last_side: + result = await conn.execute(select(User_Main_Settings).where(User_Main_Settings.last_side == side)) + if not result.first(): + logger.info("Заполение таблицы последнего направления") + await conn.execute(User_Main_Settings.__table__.insert().values(last_side=side)) diff --git a/app/telegram/database/requests.py b/app/telegram/database/requests.py index ad7951d..11e70e8 100644 --- a/app/telegram/database/requests.py +++ b/app/telegram/database/requests.py @@ -239,17 +239,21 @@ async def update_symbol(tg_id: int, symbol: str) -> None: await session.commit() -async def update_api_key(tg_id: int, api: str) -> None: +async def upsert_api_keys(tg_id: int, api_key: str, secret_key: str) -> None: """Обновить API ключ пользователя.""" async with async_session() as session: - await session.execute(update(UBA).where(UBA.tg_id == tg_id).values(api_key=api)) - await session.commit() - - -async def update_secret_key(tg_id: int, api: str) -> None: - """Обновить секретный ключ пользователя.""" - async with async_session() as session: - await session.execute(update(UBA).where(UBA.tg_id == tg_id).values(secret_key=api)) + result = await session.execute(select(UBA).where(UBA.tg_id == tg_id)) + user = result.scalars().first() + if user: + if api_key is not None: + user.api_key = api_key + if secret_key is not None: + user.secret_key = secret_key + logger.info(f"Обновлены ключи для пользователя {tg_id}") + else: + new_user = UBA(tg_id=tg_id, api_key=api_key, secret_key=secret_key) + session.add(new_user) + logger.info(f"Добавлен новый пользователь {tg_id} с ключами") await session.commit() @@ -303,36 +307,20 @@ async def get_user_main_settings(tg_id): """Получить основные настройки пользователя.""" async with async_session() as session: user = await session.scalar(select(UMS).where(UMS.tg_id == tg_id)) - if user: - logger.info("Получение основных настроек пользователя %s", tg_id) - - trading_mode = await session.scalar(select(UMS.trading_mode).where(UMS.tg_id == tg_id)) - margin_mode = await session.scalar(select(UMS.margin_type).where(UMS.tg_id == tg_id)) - switch_mode_enabled = await session.scalar(select(UMS.switch_mode_enabled).where(UMS.tg_id == tg_id)) - switch_state = await session.scalar(select(UMS.switch_state).where(UMS.tg_id == tg_id)) - size_leverage = await session.scalar(select(UMS.size_leverage).where(UMS.tg_id == tg_id)) - starting_quantity = await session.scalar(select(UMS.starting_quantity).where(UMS.tg_id == tg_id)) - martingale_factor = await session.scalar(select(UMS.martingale_factor).where(UMS.tg_id == tg_id)) - maximal_quantity = await session.scalar(select(UMS.maximal_quantity).where(UMS.tg_id == tg_id)) - entry_order_type = await session.scalar(select(UMS.entry_order_type).where(UMS.tg_id == tg_id)) - limit_order_price = await session.scalar(select(UMS.limit_order_price).where(UMS.tg_id == tg_id)) - martingale_step = await session.scalar(select(UMS.martingale_step).where(UMS.tg_id == tg_id)) - data = { - 'trading_mode': trading_mode, - 'margin_type': margin_mode, - 'switch_mode_enabled': switch_mode_enabled, - 'switch_state': switch_state, - 'size_leverage': size_leverage, - 'starting_quantity': starting_quantity, - 'martingale_factor': martingale_factor, - 'maximal_quantity': maximal_quantity, - 'entry_order_type': entry_order_type, - 'limit_order_price': limit_order_price, - 'martingale_step': martingale_step, + 'trading_mode': user.trading_mode, + 'margin_type': user.margin_type, + 'switch_state': user.switch_state, + 'size_leverage': user.size_leverage, + 'starting_quantity': user.starting_quantity, + 'martingale_factor': user.martingale_factor, + 'maximal_quantity': user.maximal_quantity, + 'entry_order_type': user.entry_order_type, + 'limit_order_price': user.limit_order_price, + 'martingale_step': user.martingale_step, + 'last_side': user.last_side, } - return data @@ -575,4 +563,23 @@ async def update_trigger(tg_id, trigger): async with async_session() as session: await session.execute(update(UCS).where(UCS.tg_id == tg_id).values(trigger=trigger)) + await session.commit() + + +async def set_last_series_info(tg_id: int, last_side: str): + async with async_session() as session: + async with session.begin(): + # Обновляем запись + result = await session.execute( + update(UMS) + .where(UMS.tg_id == tg_id) + .values(last_side=last_side) + ) + if result.rowcount == 0: + # Если запись не существует, создаём новую + new_entry = UMS( + tg_id=tg_id, + last_side=last_side, + ) + session.add(new_entry) await session.commit() \ No newline at end of file diff --git a/app/telegram/functions/condition_settings/settings.py b/app/telegram/functions/condition_settings/settings.py index a33d8c9..9958269 100644 --- a/app/telegram/functions/condition_settings/settings.py +++ b/app/telegram/functions/condition_settings/settings.py @@ -29,12 +29,7 @@ async def main_settings_message(id, message): text = f""" Условия запуска - Режим торговли: {trigger} -- Таймер: установить таймер / остановить таймер -- Фильтр волатильности / объёма: включить/отключить -- Интеграции и внешние сигналы: -- Использовать сигналы TradingView: да / нет -- Использовать AI-аналитику от ChatGPT: да / не -- Webhook URL для сигналов (если используется TradingView): +- Таймер: установить таймер / удалить таймер """ await message.answer(text=text, parse_mode='html', reply_markup=inline_markup.condition_settings_markup) @@ -42,7 +37,7 @@ async def main_settings_message(id, message): async def trigger_message(id, message, state: FSMContext): await state.set_state(condition_settings.trigger) text = ''' -- Автоматический: торговля будет продолжаться до условии остановки. +- Автоматический: торговля будет происходить в рамках серии ставок. - Ручной: торговля будет происходить только в ручном режиме. - Выберите тип триггера:''' @@ -73,7 +68,7 @@ async def timer_message(id, message: Message, state: FSMContext): return await message.answer( - f"Таймер: {timer_info['timer_minutes']} мин\n", + f"Таймер установлен на: {timer_info['timer_minutes']} мин\n", reply_markup=inline_markup.timer_markup ) @@ -81,7 +76,7 @@ async def timer_message(id, message: Message, state: FSMContext): @condition_settings_router.callback_query(F.data == "clb_set_timer") async def set_timer_callback(callback: CallbackQuery, state: FSMContext): await state.set_state(condition_settings.timer) # состояние для ввода времени - await callback.message.answer("Введите время работы в минутах (например, 60):") + await callback.message.answer("Введите время работы в минутах (например, 60):", reply_markup=inline_markup.cancel) await callback.answer() @@ -94,14 +89,21 @@ async def process_timer_input(message: Message, state: FSMContext): return await rq.update_user_timer(message.from_user.id, minutes) - await message.answer(f"Таймер установлен на {minutes} минут.\nНажмите кнопку 'Начать торговлю' для запуска.", - reply_markup=inline_markup.start_trading_markup) - + logger.info("Timer set for user %s: %s minutes", message.from_user.id, minutes) + await timer_message(message.from_user.id, message, state) await state.clear() except ValueError: await message.reply("Пожалуйста, введите корректное число.") +@condition_settings_router.callback_query(F.data == "clb_delete_timer") +async def delete_timer_callback(callback: CallbackQuery, state: FSMContext): + await state.clear() + await rq.update_user_timer(callback.from_user.id, 0) + logger.info("Timer deleted for user %s", callback.from_user.id) + await timer_message(callback.from_user.id, callback.message, state) + await callback.answer() + async def filter_volatility_message(message, state): text = '''Фильтр волатильности @@ -138,4 +140,4 @@ async def ai_analytics_message(message, state): Описание... ''' - await message.answer(text=text, parse_mode='html', reply_markup=inline_markup.buttons_yes_no_markup) + await message.answer(text=text, parse_mode='html', reply_markup=inline_markup.buttons_yes_no_markup) \ No newline at end of file diff --git a/app/telegram/functions/main_settings/settings.py b/app/telegram/functions/main_settings/settings.py index 65a08e3..1f94590 100644 --- a/app/telegram/functions/main_settings/settings.py +++ b/app/telegram/functions/main_settings/settings.py @@ -5,6 +5,9 @@ import app.telegram.Keyboards.inline_keyboards as inline_markup from pybit.unified_trading import HTTP import app.telegram.database.requests as rq from aiogram.types import Message, CallbackQuery + +from app.services.Bybit.functions.price_symbol import get_price +from app.services.Bybit.functions.Futures import safe_float, calculate_total_budget, get_bybit_client from app.states.States import update_main_settings from logger_helper.logger_helper import LOGGING_CONFIG @@ -24,20 +27,54 @@ async def reg_new_user_default_main_settings(id, message): async def main_settings_message(id, message): - data = await rq.get_user_main_settings(id) + try: + data = await rq.get_user_main_settings(id) + tg_id = id - await message.answer(f"""Основные настройки - -- Режим торговли: {data['trading_mode']} -- Режим свитч: {data['switch_mode_enabled']} -- Состояние свитча: {data['switch_state']} -- Тип маржи: {data['margin_type']} -- Размер кредитного плеча: х{data['size_leverage']} -- Начальная ставка: {data['starting_quantity']} -- Коэффициент мартингейла: {data['martingale_factor']} -- Количество ставок в серии: {data['martingale_step']} -- Максимальное количество ставок в серии: {data['maximal_quantity']} -""", parse_mode='html', reply_markup=inline_markup.main_settings_markup) + data_main_stgs = await rq.get_user_main_settings(id) + data_risk_stgs = await rq.get_user_risk_management_settings(id) + client = await get_bybit_client(tg_id) + symbol = await rq.get_symbol(tg_id) + max_martingale_steps = (data_main_stgs or {}).get('maximal_quantity', 0) + commission_fee = (data_risk_stgs or {}).get('commission_fee') + starting_quantity = safe_float((data_main_stgs or {}).get('starting_quantity')) + martingale_factor = safe_float((data_main_stgs or {}).get('martingale_factor')) + fee_info = client.get_fee_rates(category='linear', symbol=symbol) + leverage = safe_float((data_main_stgs or {}).get('size_leverage')) + price = await get_price(tg_id, symbol=symbol) + entry_price = safe_float(price) + + if commission_fee == "Да": + commission_fee_percent = safe_float(fee_info['result']['list'][0]['takerFeeRate']) + else: + commission_fee_percent = 0.0 + + total_budget = await calculate_total_budget( + starting_quantity=starting_quantity, + martingale_factor=martingale_factor, + max_steps=max_martingale_steps, + commission_fee_percent=commission_fee_percent, + leverage=leverage, + current_price=entry_price, + ) + + await message.answer(f"""Основные настройки + + - Режим торговли: {data['trading_mode']} + - Состояние свитча: {data['switch_state']} + - Направление последней сделки: {data['last_side']} + - Тип маржи: {data['margin_type']} + - Размер кредитного плеча: х{data['size_leverage']} + - Начальная ставка: {data['starting_quantity']} + - Коэффициент мартингейла: {data['martingale_factor']} + - Текущий шаг: {data['martingale_step']} + - Максимальное количество ставок в серии: {data['maximal_quantity']} + + - Требуемый бюджет: {total_budget:.2f} USDT + """, parse_mode='html', reply_markup=inline_markup.main_settings_markup) + except PermissionError as e: + logger.error("Authenticated endpoints require keys: %s", e) + await message.answer("Вы не авторизованы.", reply_markup=inline_markup.connect_bybit_api_message) async def trading_mode_message(message, state): @@ -49,7 +86,7 @@ async def trading_mode_message(message, state): Шорт — метод продажи активов, взятых в кредит, чтобы получить прибыль от снижения цены. -Смарт — автоматизированный режим, который подбирает оптимальную стратегию в зависимости от текущих рыночных условий. +Свитч — динамическое переключение между торговыми режимами для максимизации эффективности. Выберите ниже для изменений: """, parse_mode='html', reply_markup=inline_markup.trading_mode_markup) @@ -77,12 +114,19 @@ async def state_trading_mode(callback: CallbackQuery, state): await state.clear() + case 'trade_mode_switch': + await callback.message.answer(f"✅ Изменено: {data_settings['trading_mode']} → Switch") + await rq.update_trade_mode_user(id, 'Switch') + await main_settings_message(id, callback.message) + + await state.clear() + case 'trade_mode_smart': await callback.message.answer(f"✅ Изменено: {data_settings['trading_mode']} → Smart") await rq.update_trade_mode_user(id, 'Smart') await main_settings_message(id, callback.message) - await state.clear() + await state.clear() except Exception as e: logger.error(e) @@ -91,45 +135,27 @@ async def switch_mode_enabled_message(message, state): await state.set_state(update_main_settings.switch_mode_enabled) await message.edit_text( - """Свитч — динамическое переключение между торговыми режимами для максимизации эффективности. + f""" Состояние свитча + По направлению - по направлению последней сделки предыдущей серии + Против направления - против направления последней сделки предыдущей серии + + По умолчанию при первом запуске бота, направление сделки установлено на "Buy". Выберите ниже для изменений:""", parse_mode='html', - reply_markup=inline_markup.buttons_on_off_markup_for_switch) - - -@router_main_settings.callback_query(lambda c: c.data in ["clb_on_switch", "clb_off_switch"]) -async def state_switch_mode_enabled(callback: CallbackQuery, state): - await callback.answer() - tg_id = callback.from_user.id - val = "Включить" if callback.data == "clb_on_switch" else "Выключить" - if val == "Включить": - await rq.update_switch_mode_enabled(tg_id, "Включено") - await callback.answer(f"Включено") - await main_settings_message(tg_id, callback.message) - else: - await rq.update_switch_mode_enabled(tg_id, "Выключено") - await callback.answer(f"Выключено") - await main_settings_message(tg_id, callback.message) - await state.clear() - - -@router_main_settings.callback_query(lambda c: c.data in ["clb_switch_state"]) -async def state_switch_mode_enabled(callback: CallbackQuery): - await callback.answer() - await callback.message.answer("Выберите состояние свитча:", reply_markup=inline_markup.switch_state_markup) + reply_markup=inline_markup.switch_state_markup) @router_main_settings.callback_query(lambda c: c.data in ["clb_long_switch", "clb_short_switch"]) async def state_switch_mode_enabled(callback: CallbackQuery, state): await callback.answer() tg_id = callback.from_user.id - val = "Long" if callback.data == "clb_long_switch" else "Short" - if val == "Long": - await rq.update_switch_state(tg_id, "Long") + val = "По направлению" if callback.data == "clb_long_switch" else "Против направления" + if val == "По направлению": + await rq.update_switch_state(tg_id, "По направлению") await callback.message.answer(f"Состояние свитча: {val}") await main_settings_message(tg_id, callback.message) else: - await rq.update_switch_state(tg_id, "Short") + await rq.update_switch_state(tg_id, "Против направления") await callback.message.answer(f"Состояние свитча: {val}") await main_settings_message(tg_id, callback.message) await state.clear() @@ -144,23 +170,47 @@ async def size_leverage_message(message, state): @router_main_settings.message(update_main_settings.size_leverage) async def state_size_leverage(message: Message, state): + try: + leverage = float(message.text) + if leverage <= 0: + raise ValueError("Неверное значение") + except ValueError: + await message.answer( + "Ошибка: пожалуйста, введите положительное число для кредитного плеча." + "\nПопробуйте снова." + ) + return + await state.update_data(size_leverage=message.text) data = await state.get_data() - data_settings = await rq.get_user_main_settings(message.from_user.id) + tg_id = message.from_user.id + symbol = await rq.get_symbol(tg_id) + leverage = data['size_leverage'] + client = await get_bybit_client(tg_id) - if data['size_leverage'].isdigit() and int(data['size_leverage']) <= 100: - await message.answer(f"✅ Изменено: {data_settings['size_leverage']} → {data['size_leverage']}") + instruments_resp = client.get_instruments_info(category="linear", symbol=symbol) + info = instruments_resp.get("result", {}).get("list", []) - await rq.update_size_leverange(message.from_user.id, data['size_leverage']) + max_leverage = safe_float(info[0].get("leverageFilter", {}).get("maxLeverage", 0)) + + if safe_float(leverage) > max_leverage: + await message.answer( + f"Запрошенное кредитное плечо {leverage} превышает максимальное {max_leverage} для {symbol}. " + f"Устанавливаю максимальное.", + reply_markup=inline_markup.back_to_main, + ) + logger.info( + f"Запрошенное кредитное плечо {leverage} превышает максимальное {max_leverage} для {symbol}. Устанавливаю максимальное.") + + await rq.update_size_leverange(message.from_user.id, max_leverage) await main_settings_message(message.from_user.id, message) - await state.clear() else: - await message.answer( - f'⛔️ Ошибка: ваше значение ({data['size_leverage']}) или выше лимита (100) или вы вводите неверные символы') - + await message.answer(f"✅ Изменено: {leverage}") + await rq.update_size_leverange(message.from_user.id, safe_float(leverage)) await main_settings_message(message.from_user.id, message) + await state.clear() async def martingale_factor_message(message, state): @@ -213,45 +263,55 @@ async def margin_type_message(message, state): @router_main_settings.callback_query(update_main_settings.margin_type) async def state_margin_type(callback: CallbackQuery, state): - tg_id = callback.from_user.id - api_key = await rq.get_bybit_api_key(tg_id) - secret_key = await rq.get_bybit_secret_key(tg_id) - data_settings = await rq.get_user_main_settings(tg_id) - client = HTTP(api_key=api_key, api_secret=secret_key) - try: - active_positions = client.get_positions(category='linear', settleCoin='USDT') + callback_data = callback.data + if callback_data in ['margin_type_isolated', 'margin_type_cross']: + tg_id = callback.from_user.id + api_key = await rq.get_bybit_api_key(tg_id) + secret_key = await rq.get_bybit_secret_key(tg_id) + data_settings = await rq.get_user_main_settings(tg_id) + symbol = await rq.get_symbol(tg_id) + client = HTTP(api_key=api_key, api_secret=secret_key) + try: + active_positions = client.get_positions(category='linear', settleCoin="USDT") - positions = active_positions.get('result', {}).get('list', []) - except Exception as e: - logger.error(f"error: {e}") - positions = [] + positions = active_positions.get('result', {}).get('list', []) + except Exception as e: + logger.error(f"error: {e}") + positions = [] - for pos in positions: - size = pos.get('size') - if float(size) > 0: - await callback.answer( - "⚠️ Маржинальный режим нельзя менять при открытой позиции", - show_alert=True - ) - return - try: - match callback.data: - case 'margin_type_isolated': - await callback.message.answer(f"✅ Изменено: {data_settings['margin_type']} → Isolated") + for pos in positions: + size = pos.get('size') + if float(size) > 0: + await callback.answer( + "⚠️ Маржинальный режим нельзя менять при открытой позиции" + ) + return - await rq.update_margin_type(tg_id, 'Isolated') - await main_settings_message(tg_id, callback.message) + try: + match callback.data: + case 'margin_type_isolated': + await callback.answer() + await callback.message.answer(f"✅ Изменено: {data_settings['margin_type']} → Isolated") - await state.clear() - case 'margin_type_cross': - await callback.message.answer(f"✅ Изменено: {data_settings['margin_type']} → Cross") + await rq.update_margin_type(tg_id, 'Isolated') + await main_settings_message(tg_id, callback.message) - await rq.update_margin_type(tg_id, 'Cross') - await main_settings_message(tg_id, callback.message) + await state.clear() + case 'margin_type_cross': + await callback.answer() + await callback.message.answer(f"✅ Изменено: {data_settings['margin_type']} → Cross") - await state.clear() - except Exception as e: - logger.error(f"error: {e}") + await rq.update_margin_type(tg_id, 'Cross') + await main_settings_message(tg_id, callback.message) + + await state.clear() + except Exception as e: + logger.error(f"error: {e}") + else: + await callback.answer() + await main_settings_message(callback.from_user.id, callback.message) + + await state.clear() async def starting_quantity_message(message, state): diff --git a/app/telegram/handlers/handlers.py b/app/telegram/handlers/handlers.py index aad0658..aad7e65 100644 --- a/app/telegram/handlers/handlers.py +++ b/app/telegram/handlers/handlers.py @@ -1,7 +1,7 @@ import logging.config from aiogram import F, Router -from aiogram.filters import CommandStart +from aiogram.filters import CommandStart, Command from aiogram.types import Message, CallbackQuery from aiogram.fsm.context import FSMContext @@ -23,7 +23,7 @@ logger = logging.getLogger("handlers") router = Router() - +@router.message(Command("start")) @router.message(CommandStart()) async def start_message(message: Message) -> None: """ @@ -37,6 +37,7 @@ async def start_message(message: Message) -> None: await func.start_message(message) +@router.message(Command("profile")) @router.message(F.text == "👤 Профиль") async def profile_message(message: Message) -> None: """ @@ -52,6 +53,12 @@ async def profile_message(message: Message) -> None: if user and balance: await run_ws_for_user(tg_id, message) await func.profile_message(message.from_user.username, message) + else: + await rq.save_tg_id_new_user(message.from_user.id) + await func_main_settings.reg_new_user_default_main_settings(message.from_user.id, message) + await func_rmanagement_settings.reg_new_user_default_risk_management_settings(message.from_user.id, message) + await func_condition_settings.reg_new_user_default_condition_settings(message.from_user.id) + await func_additional_settings.reg_new_user_default_additional_settings(message.from_user.id, message) @router.callback_query(F.data == "clb_start_chatbot_message") @@ -64,6 +71,8 @@ async def clb_profile_msg(callback: CallbackQuery) -> None: Args: callback (CallbackQuery): Полученный колбэк. """ + tg_id = callback.from_user.id + message = callback.message user = await rq.check_user(callback.from_user.id) balance = await get_balance(callback.from_user.id, callback.message) first_name = callback.from_user.first_name or "" @@ -71,6 +80,7 @@ async def clb_profile_msg(callback: CallbackQuery) -> None: username = f"{first_name} {last_name}".strip() or callback.from_user.username or "Пользователь" if user and balance: + await run_ws_for_user(tg_id, message) await func.profile_message(callback.from_user.username, callback.message) else: await rq.save_tg_id_new_user(callback.from_user.id) @@ -164,7 +174,7 @@ async def clb_change_additional_message(callback: CallbackQuery) -> None: # Конкретные настройки каталогов list_main_settings = ['clb_change_trading_mode', - 'clb_change_switch_mode', + 'clb_change_switch_state', 'clb_change_margin_type', 'clb_change_size_leverage', 'clb_change_starting_quantity', @@ -188,7 +198,7 @@ async def clb_main_settings_msg(callback: CallbackQuery, state: FSMContext) -> N match callback.data: case 'clb_change_trading_mode': await func_main_settings.trading_mode_message(callback.message, state) - case 'clb_change_switch_mode': + case 'clb_change_switch_state': await func_main_settings.switch_mode_enabled_message(callback.message, state) case 'clb_change_margin_type': await func_main_settings.margin_type_message(callback.message, state) diff --git a/logger_helper/logger_helper.py b/logger_helper/logger_helper.py index f28566e..7f17351 100644 --- a/logger_helper/logger_helper.py +++ b/logger_helper/logger_helper.py @@ -80,7 +80,7 @@ LOGGING_CONFIG = { "level": "DEBUG", "propagate": False, }, - "conditions_settings": { + "condition_settings": { "handlers": ["console", "timed_rotating_file"], "level": "DEBUG", "propagate": False, diff --git a/requirements.txt b/requirements.txt index 66baab5..beef837 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,22 +6,40 @@ aiosignal==1.4.0 aiosqlite==0.21.0 annotated-types==0.7.0 attrs==25.3.0 +black==25.1.0 certifi==2025.8.3 charset-normalizer==3.4.3 +click==8.2.1 +colorama==0.4.6 dotenv==0.9.9 +flake8==7.3.0 +flake8-bugbear==24.12.12 +flake8-pie==0.16.0 frozenlist==1.7.0 greenlet==3.2.4 idna==3.10 +isort==6.0.1 magic-filter==1.0.12 +mando==0.7.1 +mccabe==0.7.0 multidict==6.6.4 +mypy_extensions==1.1.0 nest-asyncio==1.6.0 +packaging==25.0 +pathspec==0.12.1 +platformdirs==4.4.0 propcache==0.3.2 pybit==5.11.0 +pycodestyle==2.14.0 pycryptodome==3.23.0 pydantic==2.11.7 pydantic_core==2.33.2 +pyflakes==3.4.0 python-dotenv==1.1.1 +radon==6.0.1 +redis==6.4.0 requests==2.32.5 +six==1.17.0 SQLAlchemy==2.0.43 typing-inspection==0.4.1 typing_extensions==4.14.1