Странствующий техник
Подкинуть работёнку
Назад к статьям
DEV23 июля 2026·7 MIN

Баг дублирования позиций в торговом боте: как dict с ключом-символом терял сделки

Разбор реального бага в paper-trading боте на Python: словарь с ключом-символом перезаписывал сделки, потому что боту не хватало multiposition-поддержки. Рабочий рецепт диагностики и рефакторинга position tracking через Claude Code.

Мой торговый бот на 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 задач и что каждая закрыла

  1. Миграция БД — колонка trades.position_id, идемпотентная через try/except ADD COLUMN
  2. Запись и закрытие сделок по ID вместо символа монеты
  3. Ядро исполнения ордеров — генерация ID вида XLM-1, поддержка нескольких открытых позиций на монету
  4. Мониторинг позиций — сбор списка тикеров из pos["symbol"], а не из ключа словаря
  5. Telegram-бот — кнопка закрытия по position_id, отдельная команда закрыть всё по монете
  6. Бэктестинг — поиск позиции по pos["symbol"] вместо ключа
  7. Финальная проверка — 53 теста зелёных, 3 обоснованно пропущены

Похожий разбор бага в реальном торговом боте — с более тяжёлыми последствиями, потому что там были настоящие деньги — есть в посте про торгового бота для Bybit.

Pro Tips: Prompting and Execution

  • Если словарь используется как хранилище сущностей — проверь, действительно ли ключ уникален. Название монеты кажется естественным ключом, пока не появится вторая сделка по той же монете. Уникальный ID почти всегда безопаснее природного идентификатора вроде тикера или email.
  • ORDER BY ... LIMIT 1 без явного ID — сигнал возможного бага перезаписи. Если запись ищется как "самая свежая", а не по конкретному идентификатору, задай вопрос — что если подходящих записей несколько.
  • Перед большим рефакторингом используй brainstorming и writing-plans skills, а не пиши код сразу. Разбивка на маленькие задачи с ревью после каждой поймала баг с отключёнными тестами, который иначе остался бы незамеченным.
  • Закладывай multiposition-поддержку в архитектуру заранее, если стратегия допускает повторные входы. Большинство готовых paper-trading платформ по умолчанию мёржат сделки по символу в одну позицию — это системное поведение индустрии, а не редкий баг конкретной реализации.

Common Pitfalls

  • Списывать пропавшие данные на баг отображения. Позиция, которая не показывается в интерфейсе, может быть физически перезаписана в памяти — это разные по цене ошибки.
  • Доверять зелёному прогону тестов, не проверив, что все тесты вообще запустились. skip и pass выглядят одинаково безобидно в общем отчёте, но за skip может стоять сломанный импорт, из-за которого целый файл тестов не запускался месяцами.
  • Чинить только симптом, забыв про ORDER BY LIMIT 1 в SQL. Смена ключа словаря в памяти без правки запроса в базе данных оставила бы тот же баг на уровне хранилища.

Идентификация

Денис Заплахов

Исследую Пустоши рутинных задач: настраиваю AI-инструменты, создаю скиллы и тестирую нейросети на реальных кейсах.

Досье в Pip-Boy 3000
© 2026 79z.ru · Странствующий техник