Привет, Хабр! Меня зовут Иван, и сегодня я хочу поделиться историей о своём пет-проекте A-Zero. Истории про провалы традиционно интереснее историй об успехах, и моя как раз такая (почти). Довольно бодроначинавшийся проект чуть было не свёл меня с ума из‑за одной единственной фичи, «просочившейся» в MVP, и сейчас я расскажу, как я из этого выкарабкался и чему научился по дороге.

Дисклеймер: в тексте присутствует некоторое количество терминов, относящихся к трейдингу. Для удобства не столь искушённого читателя большинство из них снабжены всплывающими подсказками с пояснениями.

Мир, где всё просто

Всё началось со слегка безумной идеи написать с нуля фреймворк для алгоритмического крипто-трейдинга в качестве сольного пет-проекта. Я понимал, что единственный шанс для начинающего Java-разработчика (меня) преуспеть в таком нелёгком деле — чётко следовать принципу итеративной сложности. Нужно было начать с чего-то совсем элементарного и очень маленькими порциями достраивать функционал — как будто играешь в Дженгу и боишься, что от каждой следующей палочки всё рухнет.

Поначалу всё шло довольно бодро, и мы с проектом дожили до релиза 0.1.0 — он состоял из CLI утилиты для выгрузки исторических данных, плюс я успел прикрутить небольшой CI пайплайн. Вскоре у меня уже были готовы основные интерфейсы и базовая реализация движка для бэктестинга, который умел моделировать спотовые трейды. Казалось, что фундамент моей башни в Дженге заложен, и теперь осталось только докладывать на него палочки-фичи, а на горизонте уже замаячил следующий релиз с полноценной утилитой для бэктестинга — нужно было только реализовать логику описания стратегий и написать поверх всего этого CLI-обёртку.

Стоит сказать, что слово "базовая" в применении к реализации движка тут не было скромностью — он действительно был безумно простым и использовал очень базовую логику:

  • Один счёт (double balance).

  • Только одна открытая позиция в один момент времени.

  • Простая логика: купил — баланс уменьшился, продал — увеличился.

И вот тут предыдущий успех, видимо, заставил меня расслабиться, и...

Кроличья нора

Я решил, что такой релиз получится уж слишком скучным и примитивным. А вот если добавить в него всего одну «палочку» — шорт‑трейды... Тут, каюсь, сыграли роль сразу два фактора: во‑первых, отсутствие у меня чёткого плана по MVP, во‑вторых — немного замутнившиеся воспоминания из моего прошлого трейдерского опыта о том, как трейдинг, собственно, устроен. Поэтому я практически машинально добавил в модель стратегии возможность шортить активы, и только потом уже задумался, рассчитан ли на это мой движок — но обо всём по порядку.

Изначально мне казалось, что добавление шорт‑трейдов — фича чисто номинальная, и на логику программы в целом не повлияет. Первым звоночком стало нарушение модели баланса (double balance). Дело в том, что изначально она была призвана играть двоякую роль: с одной стороны — отражать покупательную способность в ходе симуляции (то есть какие трейды мы можем себе позволить), с другой — показывать состояние нашего капитала, то есть сколько мы заработали/потеряли.

Но при открытии шорта мы не тратим, а получаем средства — и, наоборот, теряем при закрытии. Так модель double balance моментально перестала работать. «Ничего, просто чуть‑чуть усложним модель!» — оптимистично подумал я и взял в руки следующую палочку Дженги: полноценный Map<String, Double> кошелёк, отслеживающий баланс (в т.ч. отрицательный) каждого имеющегося актива.

Теперь я мог учитывать, что при открытии шорта для базовой валюты баланс уменьшается, а для валюты котировки — увеличивается. Также пришлось добавить движку два режима симуляции: маржинальная торговля и спотовая торговля, а ещё поддерживать в симуляции неограниченное количество открытых позиций.

И вот тут я ощутил, что начинаю падать в кроличью нору маржинальной торговли. Теперь для реалистичной симуляции мне нужно было рассчитывать ещё и такие вещи как:

  • Залог: Просто так взять актив в долг нельзя — биржа рассчитывает требуемый объём обеспечения заёма имеющимися у трейдера средствами. В современных крипто-биржах существует концепт "объединённого торгового аккаунта", для которых это обеспечение рассчитывается исходя из балансов всех активов на аккаунте. Биржи предоставляют некоторую документацию касательно алгоритма этого расчёта, которую мне пришлось изучить, а затем практически полностью переписывать логику исполнения ордеров.

// Рассчитываем необходимую начальную маржу для новой позиции
BigDecimal imr = calculateInitialMarginRate();
BigDecimal newMarginRequired = positionValue.multiply(imr);
BigDecimal totalEquity = calculateTotalEquity(this.currentPrices);
BigDecimal existingMargin = calculateTotalInitialMargin();

// Проверяем, достаточно ли у трейдера общей эквити для открытия
if (totalEquity.subtract(existingMargin).compareTo(newMarginRequired) < 0) {
    log.warn(
        "MARGIN CHECK FAILED: Cannot open {} position for {}. Required: {}, Available: {}",
        direction, symbol, newMarginRequired, totalEquity.subtract(existingMargin));
    return;
}
  • Поддерживающая маржа: Помимо расчёта обеспечения в момент заёма актива, постоянно проверяется, что общая стоимость активов трейдера не упала ниже уровня поддержания маржи (зависит от общего объёма заёмных активов; обычно равен определённой доле от изначального обеспечения при заёме). Нужно было добавить дополнительную логику на каждом цикле симуляции.

// Обновляем актуальную информацию о ценах активов
context.updateCurrentPrices(currentPrices);

// С помощью хелперов проверяем, нужно ли запускать ликвидацию
if (config.getAccountMode() == AccountMode.MARGIN) {
    if (context.isMarginCallTriggered()) {
        context.liquidateAllPositions();
    }
}
  • Принудительная ликвидация: Самая страшная и самая важная часть. Если баланс активов падает ниже уровня поддерживающей маржи (см. предыдущий пункт), происходит margin call — биржа начинает принудительно продавать активы трейдера, чтобы покрыть недостаток обеспечения. Это необходимо было реализовать, чтобы симуляция была "честной".

Сложнее всего было, конечно, остановиться: можно было провести ещё неопределённое количество времени в попытках сделать симуляцию всё более и более реалистичной, но в какой-то момент я сказал себе "стоп". Во-первых, это нарушало бы принцип итеративной сложности. Во-вторых, очень сильно замедлило бы меня. В-третьих, не было особенно осмысленным: бэктестер призван служить первой "дешёвой" ступенью анализа, а следующий этап — демо-трейдинг, реализация которого у меня есть дальше в дорожной карте, — в любом случае решил бы проблему с неточной симуляцией. В итоге я уговорил себя сделать следующие упрощения:

  • Явную формулу для расчёта объёма обеспечения заёма найти не удалось (точнее, формула из документации банально расходилась с тем, что я видел на самой бирже), поэтому в движке я использовал заведомо более «агрессивную» формулу — это значило, что движок позволит стратегии взять в долг чуть меньше, чем, возможно, позволила бы биржа (что безопасно).

  • Вместо каскадной ликвидации (продажи активов в определённом биржей порядке) при принудительной ликвидации движок моделирует закрытие всех позиций — на мой взгляд, это простительная неточность, так как принудительная ликвидация сама по себе означает «фейл» стратегии, и моделировать реальные последствия не так критично для реализма.

На руинах API

Разобравшись с тем, что фактически стало полным рефакторингом движка для бэктестинга, я обнаружил, что, помимо изменений в логике, теперь у меня полностью разрушились контракты API. Вот пара примеров:

  • Пример 1: Эволюция TradingContext. Изначально это был крайне минималистичный интерфейс, описывающий взаимодействие стратегии с "биржей". В начале стало очевидно, что примитивные методы executeBuy/Sell плохо смотрятся в контексте нескольких режимов торговли (спот и маржинальная) — вместо них появился более абстрактный и семантичный submitOrder. После этого, во многом в процессе тестирования, стало понятно, что интерфейс слишком закрытый — нужен гораздо более широкой read-only доступ к состоянию аккаунта. Здесь помогла ментальная модель "TradingContext — абстракция над веб-интерфейсом криптобиржи". Так появились методы для получения состояния кошелька, общей стоимости активов на аккаунте и т.д.

  • Пример 2: Эволюция Strategy и рождение MarketEvent. В сценарии, где стратегия взаимодействовала с несколькими активами одновременно, простой метод onCandle(Candle c, ...) уже не работал — тип Candle (свеча) был намеренно сделан минималистичным и не давал контекста о том, по какому активу получена информация. Из этого родился новый тип MarketEvent — "обёртка" над свечой и символом актива, а интерфейс Strategy теперь содержал onMarketEvent(MarketEvent event, ...). Так стратегия получала всю нужную её внутренней логике информацию — а ещё это сделало интерфейс более гибким: при необходимости я мог бы расширить тип MarketEvent, не меняя контракты API.

С одной стороны, можно с уверенностью сказать, что это были правильные и необходимые изменения. С другой — они происходили абсолютно не в том порядке, в котором я хотел бы их вносить. Вместо "отлично, работает, давайте улучшать" получилось "оно сейчас развалится, если я ничего не сделаю".

TDD?

Неожиданно для меня там, где в борьбе с потихоньку наступавшим в проекте хаосом паттерны проектирования мне уже не помогали, на помощь пришли тесты — но не совсем в привычном их понимании.

Из-за внезапного бурного разрастания бизнес-логики стало сложно держать в голове не только то, как именно она работает, но даже то, что она вообще говоря должна делать. И здесь тесты оказались прекрасным инструментом — не для того, чтобы верифицировать поведение, а для того, чтобы его прояснять. Фактически, я пытался декларативно описывать желаемое поведение через тесты (почти как в Test-Driven Development), чтобы затем на падающих тестах смотреть, в чём именно ошибка — в бизнес-логике или в моих от неё ожиданиях.

Например, при тестировании движка для бэктестинга неожиданно стал падать тест spot_shortAttempt_ShouldThrowException. Вместо исключения при попытке в режиме спот-торговли продать актив, которого нет на балансе аккаунта, система... Не делала ничего. Проверив, по какой ветке идёт бизнес-логика, я выяснил, что вместо того, чтобы обрабатывать такой ордер как шорт-трейд, движок воспринимал его как попытку продать актив в большем количестве, чем есть у трейдера — и просто игнорировала его, выводя предупреждающее сообщение.

Поведение системы оказалось правильнее моего собственного понимания — там, где я ожидал, что невозможная операция приведёт к ошибке, движок просто корректно её игнорировал. В этот момент я понял, что тестами можно не просто ловить баги и отслеживать, где логика работает не так, как задумано — хорошо написанные тесты помогают выявить места, где логика изначально была задумана неправильно.

Отдельным вызовом при тестировании стала дилемма "инкапсуляция vs. тестируемость". Поскольку многие компоненты были довольно сложными stateful объектами, тестировать их без верификации внутреннего состояния было практически бессмысленно — но как сделать внутреннее состояние доступным в тестах, не засоряя публичный API? Решением стали аккуратно подобранные package-private методы, создававшие специальное тестовое API с минимальной необходимой "площадью покрытия":

// package-private метод для мониторинга завершённых трейдов в тестах
List<Trade> getExecutedTradesForTest() {
    return List.copyOf(this.executedTrades); // Возвращяем безопасную immutable копию
}

Хэппи энд

В итоге, спустя пару недель рефакторинга и доработки, релиз 0.2.0 был готов. Помимо CLI-бэктестера и YAML формата для описания трейдинговых стратегий, в нём теперь было гораздо более надёжное, гибкое и близкое к реальности ядро в виде бэктест-движка и API-контрактов. Но самым ценным для меня, пожалуй, стали не фичи, а сам опыт, который я приобрёл в процессе разработки:

  1. Беспощадное "M" в MVP. Невероятно трудно в процессе написания кода не хвататься за каждую возможность что-нибудь "улучшить" и добавить в свою башню в Дженге ещё одну палочку. Но, как показал мой кейс, очень важно иметь дисциплину этого всё-таки не делать — иначе всю башню придётся бесконечно пересобирать заново. Правильным решением в моём случае было бы остановиться в MVP на реализации логики спотовой торговли, а потом итеративно усложнять уже функционирующий движок.

  2. Упрощай, прежде чем усложнять. В процессе добавления в движок логики маржинальной торговли в какой-то момент начало казаться, что каждая новая реализованная концепция тянет за собой ещё две нереализованных. Для продуктивной разработки гораздо ценнее иметь что-то простое и работающее, поэтому было важно "провести черту" в том, насколько точно я хочу симулировать реальность. Логику всегда можно усложнить впоследствии — по результатам тестов, которые покажут, где именно эти усложнения действительно необходимы. А для этого нужно сначала создать что-то, что уже можно будет тестировать.

  3. Тесты как инструмент прояснения, а не только проверки. Я не могу с чистой совестью назвать свой подход реальным TDD — всё-таки тесты писались уже после того, как была написана основная масса логики. И всё-таки в критический момент именно тесты как раз оказались таким островком предсказуемости и стабильности, благодаря которому проект не развалился. Можно сказать, что в ходе разработки у меня самопроизвольно зародился некий паттерн TDC — "Test Driven Clarification". И я уверен, что обязательно прибегну к этой практике в дальнейших этапах работы над моим проектом.

Спасибо, что дочитали мою первую публикацию до конца :-) Буду рад услышать ваши мысли и критику в комментариях. Расскажите, какие «простые» фичи стопорили ваши проекты?

Весь код ядра проекта A-Zero открыт и доступен на GitHub.

Комментарии (1)


  1. kuza2000
    13.11.2025 06:21

    Мне знакома эта тема. И мне кажется, что вы пытаетесь сделать совсем не то. Вы пытаетесь сделать максимально точное моделирование на истории, а это не имеет практического смысла. Вся точность моделирования убивается шумом.

    Зачем, например, считать ликвидацию? Ну, выдало моделирование, что ликвидировало на истории с депозитом 1000. А с 1002 - не ликвидировало. И что дает это знание? Что нужно идти торговать на реальном счете с депозитом 1002? Вообще, ликвидация, это то, к чему при нормальном риск-менеджменте вы даже близко не подойдете.

    И зачем просчитывать работу под залог других активов? Везде условия могут быть разными. А есть еще DEX, причем разные варианты... Вы же не пытаетесь просчитать ADL? Только проскальзывание можно заложить (очень примерно и условно), и это отдельная задачка. Вряд ли кто-то ожидал то проскальзывание, которое было 10 октября.

    На мой взгляд, бэк-тестинг должен выдать в качестве результатов основные результаты моделирования - просадку, коэффициент шарпа и другие подобные параметры. На основании их становится понятно - может стратегия быть прибыльной или нет. Никакие ликвидации просчитывать не нужно. Можно даже совсем не учитывать размер депозита, считать его бесконечным. А дальше уже на основе этих параметров расчитывать риск-менеджмент. Кстати, при такой постановке задачи, шортовые трейды добавляются достаточно легко, и заголовок статьи становится уже не актуальным :)