Від 130 до 5595 тестів: як Binance trading bot перетворився на software platform
Чому тестів стало у десятки разів більше — і як recovery, replay, fault tolerance та research contracts змінили архітектуру сильніше, ніж нові торгові стратегії.
Я почав писати цього бота більше року тому.
Наприкінці
На той момент у проєкті було понад 130 тестів. Я вже використовував Decimal замість float для фінансових розрахунків, централізований error handling, circuit breaker, type checking, Hypothesis для property-based testing і власний performance benchmark.
Тоді це здавалося вже досить зрілою системою.
Сьогодні останній зафіксований full-suite run цього самого проєкту виглядає так:
5595 passed
10 skipped
12 subtests
Одразу важливе уточнення: 5595 — це останній зафіксований full-suite result, а не твердження, що на поточному HEAD pytest --collect-only обов’язково збере рівно 5595 items.
Але ця стаття взагалі не про те, як я «написав ще п’ять тисяч тестів».
Саме число майже нічого не доводить. Один parametrized test може породжувати десятки pytest items. Property-based test може проганяти багато generated examples. Integration matrix може множити одну перевірку на різні стани, адаптери та failure modes.
Набагато цікавіше інше:
чому системі взагалі знадобилося перевіряти так багато різних сценаріїв?
За цей час trading bot перестав бути просто набором модулів навколо Binance API. У кодовій базі сформувалися окремі execution і recovery semantics, lifecycle management, risk domain, backtesting/replay, research, monitoring, observability, metrics, operator UI, persistence і configuration.
У якийсь момент я помітив, що більшість нових тестів перевіряють уже не окремі функції.
Вони перевіряють стани системи, переходи між ними і те, що станеться, якщо реальний світ перерве нормальний порядок виконання посередині операції.
Саме про цю еволюцію ця стаття.
130 тестів були не мало. Просто система була меншою
Є спокуса подивитися на старі 130+ tests і сказати: «Ну так, тоді проєкт був сирий».
Але це було б неправильно.
Той фундамент нікуди не зник.
Centralized error handling досі потрібен. Circuit breaker нікуди не подівся. Decimal усе ще правильніший за float для фінансової арифметики. Type checking, BotContext, Hypothesis і benchmark залишилися частиною системи.
Змінилася не стільки якість старих рішень, скільки масштаб відповідальності системи.
У старій версії значну частину correctness можна було сформулювати приблизно так:
Чи правильно працює ця функція або модуль для заданого input?
Наприклад:
- чи правильно округлюється price відповідно до tick size;
- чи коректно класифікується network exception;
- чи відкривається circuit breaker після потрібної кількості помилок;
- чи config validation відхиляє неправильне значення;
- чи financial calculation повертає очікуваний
Decimal.
Це нормальні й необхідні тести.
Але production trading system має неприємну властивість: її стан існує не лише всередині Python process.
Частина стану є на exchange.
Частина — у пам’яті процесу.
Частина — на диску.
Одна частина операції може вже завершитися, а наступна — ще ні.
І якщо процес падає саме між двома такими кроками, після restart система повинна зрозуміти не тільки що вона хотіла зробити, а й що реально вже сталося.
У цей момент питання змінюється.
Воно вже не звучить як:
Чи правильний return value?
Воно звучить так:
Що буде, якщо exchange уже підтвердив fill, але локальний process упав до того, як position була повністю зафіксована?
І це зовсім інший клас тестування.
Чому 5595 тестів самі по собі нічого не доводять
Великий test count дуже легко перетворити на vanity metric.
10 000 tests звучить сильніше за 500 tests, але з цього автоматично не випливає, що перша система надійніша.
Можна написати тисячі тестів для trivial getters.
Можна через parametrization розмножити десяток test functions до кількох тисяч collected items.
Можна замокати половину системи так, що suite буде green, а production path поводитиметься зовсім інакше.
Тому мені цікавіша не абсолютна кількість тестів, а які ризики вони закривають.
У моєму випадку ріст не відбувся одним стрибком.
Git history дозволяє відновити кілька full-suite checkpoints:
1411
→ 1427
→ 1511
→ 1524
→ 1708
→ 2656
→ 3605
→ 4399
Між evidence points для 1411 і 4399 — понад 500 commits.
Тобто це не один magic refactor, після якого тестів раптом стало втричі більше, а цілі місяці розвитку системи.
Важливо й те, що спосіб запуску pytest за цей період принципово не змінився: і на початку, і майже в кінці цього діапазону canonical runner запускав pytest tests/.
Ріст ішов кількома хвилями.
Спочатку розширювався runtime/execution layer: guards, loss-streak і kill-switch semantics, data freshness, config invariants та backtesting regressions.
Потім почалася значно більша хвиля: research tooling, telemetry, observability, scanner contracts, outcome datasets, risk controls і compatibility/regression suites отримали власні test families.
Далі research став contract-driven subsystem: з deterministic replay, source provenance, no-future-leakage checks, evidence contracts, promotion/parity gates та adversarial cases.
А ближче до 4399 verification дедалі частіше перевіряв уже не окремий return value, а sequence of events, provenance, failure paths і cross-subsystem parity.
Тобто test suite зростав не тому, що я поставив собі ціль «написати п’ять тисяч тестів».
Він зростав разом із кількістю contracts, states і failure scenarios, за які почала відповідати система.
Тому фраза:
У мене 5595 тестів.
для мене менш цікава, ніж:
Система має тисячі перевірок, тому що тепер існує значно більше допустимих і недопустимих станів, переходів і failure windows.
Один із найнаочніших прикладів цього з’явився на наступному етапі — в order-state recovery.
Найдорожчий вимір тестування — це state
Уявімо максимально спрощений сценарій.
Trading system відправляє order на Binance.
Exchange його виконує.
У найпростішій версії коду це могло б виглядати приблизно так:
order = place_order(...)
save_position(order)
Для такого коду легко написати кілька unit tests.
У production усе цікавіше.
Exchange — зовнішня система зі своїм authoritative state.
Локально нам теж потрібне durable representation того, що відбулося.
У спрощеному вигляді recovery path можна представити так:
Exchange FILLED
↓
PREPARED
↓
positions
↓
application ledger
↓
checkpoint
А тепер просте питання:
що буде, якщо process упаде на кожній із цих стрілок?
Exchange уже бачить fill, але local state — ще ні.
Positions записалися, але ledger — ні.
Ledger є, але checkpoint не встиг оновитися.
Під час downtime order перейшов із partial fill у full fill.
Recovery уже один раз успішно відпрацював, але після наступного restart запускається ще раз.
Другий запуск має повторно застосувати той самий fill?
Ні.
Отже, recovery має бути idempotent.
Звідси з’являються сценарії на кшталт:
exchange fill + missing local state
partial fill → larger partial → FILLED
positions written + ledger missing
ledger written + checkpoint missing
torn/corrupt journal
orphan application
ghost position
repeated recovery → exact no-op
startup quarantine
І тут проявляється принципова відмінність від звичайного unit testing.
Складність уже росте не приблизно як кількість functions.
Вона більше схожа на:
possible states
× transition points
× failure windows
× recovery paths
В одному з recovery slices було близько 130 focused tests плюс окремий lifecycle/trade/recovery integration set.
Для мене це дуже символічно.
Колись приблизно 130 тестів перевіряли весь проєкт.
Тепер приблизно стільки може знадобитися, щоб описати поведінку лише одного класу crash/recovery scenarios.
І звідси ще один важливий висновок:
Code coverage майже нічого не говорить про crash coverage.
Можна виконати кожен рядок save_position() і отримати прекрасний coverage report.
Але це не відповість на питання, що станеться, якщо process загине після filesystem write, але до наступного durable transition.
Або якщо exchange state уже змінився.
Або якщо journal пошкоджений.
Або якщо recovery запускається повторно.
Для stateful systems саме такі проміжні стани часто є найнебезпечнішими.
Live і backtest мають говорити однією мовою
Ще одна велика причина росту test suite — backtesting.
У маленькому проєкті backtest легко уявити як окремий script:
historical candles
↓
strategy
↓
PnL
Проблема такого підходу в тому, що дуже швидко виникають дві різні торгові системи.
Одна — реальна.
Друга — та, яку ми тестуємо на історії.
І вони можуть мати однакову назву strategy, але різну semantics.
Наприклад:
- різне округлення quantity;
- інша commission model;
- інша execution price;
- інша поведінка partial fills;
- інший порядок events;
- інша stop-loss logic;
- інший момент оновлення state.
У такому випадку красивий backtest фактично нічого не гарантує для live execution.
Тому з часом backtesting у проєкті перетворився на окремий subsystem.
З’явилися concepts на кшталт:
real adapter
simulated adapter
executor replay
event-time semantics
execution calibration
replay cases
parity checks
І головне питання стало не:
Чи працює backtester?
А:
Чи поводиться simulated execution достатньо близько до live execution у тих частинах semantics, які ми вважаємо однаковими?
Спрощено це можна представити так:
live semantics
≈ real adapter
≈ simulated adapter
≈ historical replay
≈ event-time replay
Тепер trading rule недостатньо протестувати один раз.
Потрібно перевірити її поведінку в live contract, simulated path, replay path, historical data path і parity layer.
Так test space знову множиться.
І це, на мою думку, значно корисніша причина мати тисячі тестів, ніж просто намагатися збільшити coverage percentage.
Property-based testing теж має ціну
Hypothesis у проєкті з’явився ще давно.
І досі це один із найкорисніших інструментів там, де hand-picked examples легко пропускають edge cases.
Наприклад:
Decimal arithmetic
rounding
tick/step sizes
config validation
volatility calculations
TP/SL invariants
circuit breaker behavior
atomic writes
file/path constraints
Замість перевірки одного значення:
assert round_price(1.2345) == ...
можна перевіряти invariant на великій кількості generated values.
Але тут виникає інша проблема.
Коли property tests стає багато, computational cost теж починає мати значення.
У canonical full-validation run кількість Hypothesis examples уже доводиться обмежувати.
Не тому, що property-based testing «поганий».
А тому, що verification теж має бюджет.
На маленькому проєкті можна сказати:
Проганяй максимум examples.
На великому доводиться питати:
Яка кількість examples дає достатню confidence для routine validation, а які property-heavy перевірки краще винести в окремий profile?
Після певного масштабу тестування саме починає потребувати архітектури.
Fault tolerance — це не просто retry
У ранній версії проєкту fault tolerance значною мірою будувався навколо знайомих abstractions:
retry
error classification
circuit breaker
Усе це залишилося.
Але з часом стало очевидно, що «retry request» — дуже мала частина проблеми.
Наприклад, timeout.
Наївна версія виглядає просто:
try:
request()
except TimeoutError:
retry()
Але в реальній системі виникають інші питання.
- Чи завершився попередній request насправді?
- Чи залишився HTTP call працювати у background?
- Чи можемо ми безпечно повторити операцію?
- Який total retry budget?
- Що робити після DNS incident?
- Коли circuit breaker має повернутися в healthy state?
- Як не створити retry storm?
- Як гарантувати, що exception/logging path не засвітить credentials?
Тому fault tolerance поступово стала окремою behavior surface.
Тестувати треба вже не сам exception handler, а lifecycle конкретного failure class.
І кожен такий lifecycle породжує нові scenarios.
Research теж отримав contracts
Ще один момент, після якого слово «bot» почало дедалі гірше описувати проєкт, — research layer.
У маленькому trading project research часто живе окремо:
notebook.ipynb
experiment.py
some_csv.csv
Потім щось спрацювало — і логіка якимось чином потрапила в production code.
Проблема очевидна: через кілька місяців уже важко довести, на яких data, assumptions та execution semantics був отриманий результат.
Тому research у моєму випадку теж поступово отримав власні contracts:
data contracts
data splits
acceptance gates
execution parity gates
promotion rules
outcome datasets
source binding
reproducible artifacts
Це ще один verification domain.
Тести вже захищають не тільки runtime.
Вони починають відповідати на питання:
Чи можна відтворити цей research result?
Чи відповідають input data очікуваному contract?
Чи не змінилася execution semantics між experiment і production?
Чи можна взагалі допустити цей artifact до наступного етапу?
Тобто verification surface росте ще раз.
Темна сторона 5595 тестів
Було б нечесно закінчити статтю словами:
Тепер у мене 5595 тестів, усе чудово.
Не все чудово.
Великий test suite створює власний technical debt.
Full suite уже не миттєвий
За зафіксованими runs повний pytest займає приблизно
І це ще не весь validation pipeline.
Повна перевірка виглядає приблизно так:
config contract
↓
fail-safe checks
↓
mypy
↓
pytest
↓
performance benchmark
Тобто feedback loop уже має реальну вартість.
Hypothesis довелося обмежувати
Для routine full runs кількість generated examples доводиться cap’ити.
Інакше property-heavy suite стає занадто дорогим.
Немає нормального test partitioning
Це, мабуть, один із найбільших поточних architecture gaps.
Suite уже достатньо великий, але поки немає добре оформлених профілів на кшталт:
unit-fast
contract
integration
recovery
replay
property-heavy
benchmark
Навіть quick validation усе ще занадто близька до full-suite execution.
Parallel execution недостатньо детермінований
pytest-xdist може використовуватися, якщо він присутній у environment, але це не оформлено як повністю deterministic dependency/configuration contract.
Тобто різні dev environments можуть виконувати canonical script трохи по-різному.
Документація відстає
Це класична проблема швидкого росту.
У repo досі є testing documentation зі старими числами на кшталт десятків test files або сотень tests.
Code і verification evidence ростуть швидше, ніж handbook.
Немає current enforced coverage gate
У старій статті я писав про coverage понад 70%.
Сьогодні я б цю цифру вже не повторював без нового вимірювання.
pytest-cov у development dependencies є, але current canonical validation pipeline не використовує coverage як enforced gate.
І це добре показує різницю між:
у нас багато тестів
і:
у нас зріла test platform.
Це не одне й те саме.
Що я б змінив у test architecture тепер
Після кількох тисяч тестів наступна проблема — не додати ще тисячу.
Наступна проблема — керувати test portfolio.
Я б рухався приблизно до такого поділу:
PR:
unit
contract
static checks
merge:
integration
recovery
parity
nightly:
property-heavy
replay
benchmark
manual/release:
research evidence
operational verification
Тоді кожен layer відповідає на своє питання.
PR має швидко сказати:
Я зламав basic behavior?
Merge validation:
Я порушив subsystem interaction?
Nightly:
Чи залишаються invariants правильними на ширшому просторі inputs і replay scenarios?
Release/manual:
Чи є достатньо evidence, щоб активувати consequential change?
Тобто test architecture починає нагадувати production architecture.
І це, мабуть, наступна стадія еволюції проєкту.
Що зі старої статті я сьогодні сформулював би інакше
Перша стаття не стала «неправильною».
Навпаки, більшість її фундаментальних рішень пережили подальший розвиток.
Але деякі формулювання сьогодні я вже уточнив би.
«Понад 130 тестів»
Історично правильно.
Сьогодні — просто дуже старий snapshot.
«Coverage >70%»
Цю цифру я зараз не повторював би без нового authoritative measurement.
Current validation не має coverage threshold gate.
«mypy —strict»
Type checking залишається частиною validation, але current strictness уже не варто описувати як глобальний --strict для всієї codebase.
Різні legacy/hotspot modules мають різний рівень strictness.
Thread-safe state
Thread safety нікуди не зникла.
Але тепер correctness значно ширший:
memory state
+
durable state
+
external exchange state
+
recovery
+
idempotency
Circuit breaker
Він усе ще потрібен.
Але тепер це лише один компонент fault-tolerance model, а не її центр.
То це ще bot чи вже platform?
Я довго уникав слова platform, бо ним дуже легко назвати будь-який великий repository.
Але зараз воно вже здається мені виправданим — із важливим уточненням.
Це не SaaS platform.
Не multi-tenant product.
І не framework «для всіх».
Це trading/research/execution platform всередині одного operational system.
У ній є окремі domains для:
exchange access
execution
durable recovery
risk
lifecycle
backtesting
replay
research
monitoring
observability
operator control
persistence
validation
І кожен новий domain додає не тільки code.
Він додає нові contracts.
Нові states.
Нові failure modes.
Нові transitions.
А отже — нові tests.
Висновок
Коли я писав попередню статтю, 130 тестів здавалися великим test suite.
І для тієї версії системи це справді був великий suite.
Сьогодні останній recorded full run — 5595 passed.
Але головна зміна не в тому, що число стало у десятки разів більшим.
Головне — що саме тепер перевіряють ці тести.
Ранні тести переважно доводили, що код правильно виконує свої функції.
Тисячі сучасних checks дедалі частіше відповідають на інші питання:
чи переживе система неправильний порядок подій, падіння процесу, stale state, partial write, повторний recovery, network incident або розходження між live і replay semantics?
І на цьому масштабі я прийшов до доволі простого висновку:
production-ready — це не стан, якого одного разу досягнув.
Це рухома межа.
Що більше відповідальності бере на себе система, то більше випадків вона повинна вміти не тільки обробити, а й довести тестами, що обробляє їх передбачувано.
А наступна проблема після 5595 тестів — уже не написати
Наступна проблема — навчитися керувати всією verification system так само дисципліновано, як production code.
Попередня стаття:
«Як перетворити Python-скрипт для трейдингу на production-ready систему»
https://dou.ua/forums/topic/57171/
Відкрита документація про проєкт:
https://github.com/dturovskiy/TradingBinanceBot
Основний runtime repository наразі приватний. Публічний репозиторій містить документацію та частину відкритих матеріалів про систему, але не повністю відображає її поточний стан і не містить production code та повний test suite.

7 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівНепогано, є куча коду і купа тестів :)
Проблеми з консистентністю вирішують на рівні архітектури штуками типу transactional outbox / temporal
Головне питання — що по пнл на лайв трейдах за півроку/рік чи скільки воно крутиться в проді? Експерименти це звісно круто, але бектести зовсім не гарантують перформанс на лайві
Transactional outbox і Temporal нормальні підходи, але вони не прибирають потребу в reconciliation із самою біржею. Ордер уже може бути виконаний зовні, а локальний state ще не зафіксований. Тому recovery та idempotency у мене винесені в окремий шар.
Щодо PnL, ця стаття не про прибутковість, а про engineering evolution системи. Real money mainnet track record за пів року або рік у мене поки немає, тому підміняти його цифрами з backtest чи testnet не хочу. З тим, що backtest не гарантує live performance, повністю згоден
Почитав цю статю, попередній топік , документацію на гіті але нажаль так і не зрозумів що саме робить бот які принципи його роботи ?
Підкажіть будь-ласка якщо несекрет
Так, це не секрет :)
І ви правильно помітили: у статті я більше розповідаю про архітектуру, reliability і testing, а в публічному GitHub навмисно не викладаю current strategy та production/runtime code. Тому сама торгова логіка там справді описана не дуже явно.
Якщо дуже спрощено, система працює так: отримує market data з Binance → шукає торгові setup’и за технічними сигналами → пропускає кандидата через фільтри ринкового стану, ліквідності та risk-management → і лише після цього execution layer може відкрити позицію.
Для виходу є окремі правила TP/SL, time-based exit, break-even/trailing та інші захисні механізми. При цьому execution, risk-management і сама strategy розділені: сигнал ще не означає автоматичний ордер.
Окремо є backtesting/replay та research layer. Він ізольований від live order placement і використовується для перевірки гіпотез, historical replay та порівняння поведінки з live semantics.
Тобто зараз я б назвав це не стільки «ботом з однією формулою купівлі», скільки trading/research/execution platform, у якій strategy — лише один із компонентів.
Мабуть, варто буде окремо написати статтю саме про trading flow і strategy lifecycle — без публікації конкретних поточних thresholds
Купує дешевше та продає дорожче)
Якщо це не таємниця, то чи можна узнати яку доходність ваш Binance trading bot показує на історичних даних?
Якщо коротко, то стабільної цифри на кшталт «бот дає X% річних» у мене зараз немає, і я не хочу вибирати найкрасивіший backtest та видавати його за реальну дохідність.
Я проганяю різні стратегії та їх версії на historical/replay data з окремими train/validation/OOS windows, execution assumptions і cost model. Результати між ними суттєво відрізняються, а кілька на перший погляд хороших кандидатів після більш жорсткої перевірки не підтверджувалися.
Тому на поточному етапі я не вважаю, що в проєкту вже доведений стабільний after-fee edge, з якого коректно було б вивести очікувану річну дохідність.
Власне, це одна з причин, чому проєкт так сильно виріс у сторону research/replay/evidence: мені цікавіше мати систему, яка вміє чесно «вбити» красивий backtest, ніж показати +N% на одному вдало вибраному історичному відрізку.
Якщо буде достатньо стабільний результат на holdout/OOS і з реалістичними fees/slippage — тоді вже можна буде предметно говорити про return, drawdown і risk-adjusted performance