Мой торговый бот на Python открывал несколько сделок подряд по одной монете, но в статистике и Telegram-сводке показывал только одну. Причина — классический баг дублирования позиций: словарь использовал название монеты как единственный ключ, и каждый новый вход тихо перезаписывал предыдущий. Русскоязычные гайды по созданию торговых ботов на Python (в основном на Хабре) решают эту задачу превентивно — таймером между сделками или лимитом на количество открытых позиций, но не описывают диагностику и исправление, когда бот уже работает и баг всплыл на реальных данных. Ниже — как я нашёл причину пропажи сделок по логам, какой промпт использовал для диагностики через Claude Code, и рабочий план рефакторинга на 7 шагов с найденным по пути багом в отключённых тестах.
Стек AI-инструментов
- Claude Code — диагностика бага по логам и написание плана рефакторинга
- Superpowers skills (
brainstorming,writing-plans) — обсуждение архитектуры перед правкой кода - pytest — обнаружение скрытого сбоя импорта в тестах
Симптом: сделки есть в логах, но не в статистике
Бот на бумажных деньгах (paper trading — без реальных денег, для проверки стратегий) несколько раз подряд открывал сделку по XLM/USDT в разных точках графика. Лог подтверждал оба входа:
[Jul 16 14:04:15] ⚡ RANGE_BUY: XLM/USDT:USDT | Стратегия: range | Цена: $0.188110
[Jul 16 14:04:15] 📝 Бумажная покупка: XLM/USDT:USDT @ 0.18765378 (stake $5.00, quality 1.00)
А Telegram-бот, который должен был показать обе позиции, показывал одну запись на монету — как будто второй сделки не было. Это не редкий частный случай: большинство готовых paper-trading платформ по умолчанию мёржат все ордера по символу в одну позицию и не поддерживают несколько независимых сделок по одному тикеру без явной настройки multiposition-режима. Кастомный движок исполнения ордеров наследует ту же ловушку, если её не спроектировать заранее.
Промпт, с которого начался разбор:
Судя по логам, я вижу, что были условия для покупки несколько раз одной и той же монетой, но видимо в разных местах графика вход, однако бот не умеет, по крайней мере, в папер трейд фиксировать эти позиции отдельно. Он показывает мне, что одна позиция на одну монету, хотя фактически входов было несколько, проанализируй логи и исправь ситуацию, чтобы можно было несколько сделок открывать по одной монете и открытые позиции/статистика по ним корректно фиксировались и отображались в тг боте.
Диагностика: словарь с ключом-символом как источник бага
Разбор занял несколько минут. Позиции в paper-режиме хранились так:
self.paper_positions = {}Ключ — символ монеты: paper_positions["XLM/USDT"]. Одна монета — один ключ — одна запись в памяти. При открытии второй сделки бот не добавлял новую запись, а перезаписывал значение по тому же ключу:
self.paper_positions[symbol] = {...}Первая позиция не закрывалась — событие закрытия не логировалось, запись просто переставала существовать. При этом баланс, списанный под первую сделку, оставался списанным, а вторая покупка списывала баланс ещё раз поверх — итоговая цифра в статистике не билась с реальным количеством входов.
Проблема оказалась глубже одного словаря
Название монеты как ключ было зашито не в одном месте, а буквально во всех точках, где бот работает с позициями: открытие сделки, закрытие, мониторинг открытых позиций, принудительное закрытие по команде, проверка "уже открыта ли позиция по этой монете" в роутере стратегий, и кнопки закрытия в Telegram-боте.
Отдельная находка ждала в базе данных. Функция закрытия сделки искала нужную запись так:
symbol = ? AND ts_close IS NULL ORDER BY ts_open DESC LIMIT 1То есть закрытие всегда било по самой свежей открытой сделке по символу — тот же баг с перезаписью, только на уровне SQL, а не оперативной памяти. При нескольких открытых позициях по одной монете это гарантированно закрывало не ту запись.
Этот паттерн — ORDER BY ... DESC LIMIT 1 вместо закрытия по явному идентификатору записи — редко разбирают отдельно, хотя он встречается в любой системе, где "самая свежая запись" неявно считается уникальной. Работает нормально, пока по ключу гарантированно одна запись. Ломается тихо, без исключения и без ошибки в логах, в момент, когда это условие перестаёт быть истинным.
Это была не однострочная правка — она затрагивала базу данных, Telegram-интерфейс и всю логику исполнения. Поэтому вместо немедленной правки кода я прогнал задачу через brainstorming skill, чтобы сначала зафиксировать архитектуру решения.
Решение: уникальный ID вместо символа монеты
Дизайн: вместо dict[символ → позиция] — dict[ID позиции → позиция], где ID выглядит как XLM-1, XLM-2 — читаемо и привязано к монете, но уникально для каждого входа. Символ остался внутри самой записи, просто перестал быть единственным способом её найти. По сути это переход от naive position tracking к структуре, где каждая позиция — самостоятельная сущность со своим ID, ценой входа, размером и прибылью/убытком, а не строка в таблице "одна монета — одна запись".
В базу добавилась колонка position_id, закрытие сделки стало происходить по конкретному ID вместо "самой свежей по символу". Кнопки в Telegram получили номер конкретной сделки, а команда "закрыть всё по монете" осталась работать отдельно.
Работу разбили на 7 задач через writing-plans skill — с отдельным ревью после каждой, прежде чем переходить к следующей.
Побочная находка: тесты, которые молча не запускались
На третьей задаче — самой центральной, где менялась структура ключа в движке исполнения — выяснилось, что часть тестов для этого модуля не проходила вообще. Не падала с ошибкой — просто помечалась как skip при каждом прогоне.
Причина: один тестовый файл импортировал класс движка так:
from main import ExecutionEngineХотя рабочий путь импорта давно был другим:
from core.execution import ExecutionEngineЭто значило, что логика трейлинг-стопов, тайм-аутов и частичного закрытия позиций не проверялась тестами уже какое-то время — и никто об этом не знал. Исправление одной строки импорта вернуло тесты в строй и сразу нашло реальную ошибку в закрытии позиций, никак не связанную с основным рефакторингом.
Итог: 7 задач и что каждая закрыла
- Миграция БД — колонка
trades.position_id, идемпотентная черезtry/except ADD COLUMN - Запись и закрытие сделок по ID вместо символа монеты
- Ядро исполнения ордеров — генерация ID вида
XLM-1, поддержка нескольких открытых позиций на монету - Мониторинг позиций — сбор списка тикеров из
pos["symbol"], а не из ключа словаря - Telegram-бот — кнопка закрытия по
position_id, отдельная команда закрыть всё по монете - Бэктестинг — поиск позиции по
pos["symbol"]вместо ключа - Финальная проверка — 53 теста зелёных, 3 обоснованно пропущены
Похожий разбор бага в реальном торговом боте — с более тяжёлыми последствиями, потому что там были настоящие деньги — есть в посте про торгового бота для Bybit.
Pro Tips: Prompting and Execution
- Если словарь используется как хранилище сущностей — проверь, действительно ли ключ уникален. Название монеты кажется естественным ключом, пока не появится вторая сделка по той же монете. Уникальный ID почти всегда безопаснее природного идентификатора вроде тикера или email.
ORDER BY ... LIMIT 1без явного ID — сигнал возможного бага перезаписи. Если запись ищется как "самая свежая", а не по конкретному идентификатору, задай вопрос — что если подходящих записей несколько.- Перед большим рефакторингом используй
brainstormingиwriting-plansskills, а не пиши код сразу. Разбивка на маленькие задачи с ревью после каждой поймала баг с отключёнными тестами, который иначе остался бы незамеченным. - Закладывай multiposition-поддержку в архитектуру заранее, если стратегия допускает повторные входы. Большинство готовых paper-trading платформ по умолчанию мёржат сделки по символу в одну позицию — это системное поведение индустрии, а не редкий баг конкретной реализации.
Common Pitfalls
- Списывать пропавшие данные на баг отображения. Позиция, которая не показывается в интерфейсе, может быть физически перезаписана в памяти — это разные по цене ошибки.
- Доверять зелёному прогону тестов, не проверив, что все тесты вообще запустились.
skipиpassвыглядят одинаково безобидно в общем отчёте, но заskipможет стоять сломанный импорт, из-за которого целый файл тестов не запускался месяцами. - Чинить только симптом, забыв про
ORDER BY LIMIT 1в SQL. Смена ключа словаря в памяти без правки запроса в базе данных оставила бы тот же баг на уровне хранилища.