English
preview
Машинное обучение в условиях ограничений (Часть 1): Настраиваемый набор правил для расчета размера позиции в проп-фирмах

Машинное обучение в условиях ограничений (Часть 1): Настраиваемый набор правил для расчета размера позиции в проп-фирмах

MetaTrader 5 — Статистика и анализ |
11 0
Patrick Murimi Njoroge
Patrick Murimi Njoroge

Содержание

  1. Введение
  2. Почему именно задача оптимизации с ограничениями?
  3. Что было жестко задано в коде в части 11
  4. Набор настраиваемых правил (PropFirmRuleSet)
  5. Рефакторинг PropFirmAccountState
  6. Рефакторинг модификаторов размера позиции
  7. Проверка: соответствие поведения исходной реализации
  8. Проблема в рабочем коде: три константы, которые нигде не считывались
  9. Мнение: как удержаться от желания сделать всё настраиваемым
  10. Заключение
  11. Прикрепленные файлы


Введение

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

Часть 11 серии «Архитектура машинного обучения в MetaTrader 5» дала ответ на второй вопрос с помощью PropFirmAwareSizer — модуля расчета размера позиции, который автоматически уменьшает позиции по мере исчерпания бюджета допустимого убытка, не требуя ручного задания логики пороговых условий. Это хорошо работает. При этом круг решаемых им задач уже, чем можно предположить по названию: каждое реализованное в нем правило — целевые значения прибыли по этапам, дневной лимит убытка и возможность его увеличения за счет внутридневной прибыли, нижняя граница стоимости счета, снижение доли засчитываемой прибыли в новостном окне — задано константой, взятой из одной программы проп-фирмы: Stellar 2-Step Challenge от FundedNext. Класс называется PropFirmAwareSizer, а не FundedNextAwareSizer, но до появления этой статьи он мог правильно рассчитывать размер позиций только для той проп-фирмы, для которой был написан. В этой статье предполагается, что читатель знаком с механикой испытаний проп-фирм — этапами, дневными и общими лимитами убытка, статусом финансируемого счета, — но не с конкретным кодом из части 11; везде, где рефакторинг напрямую затрагивает этот код, приводятся соответствующие фрагменты «до» и «после», так что помнить его наизусть не требуется.

Этот пробел не бросается в глаза, если читать Часть 11 отдельно. Это становится очевидным, когда читатель, участвующий в другой программе проп-фирмы — с фиксированным дневным лимитом, который не увеличивается, с иным снижением доли засчитываемой прибыли, с другой структурой этапов, — пытается повторно использовать код и обнаруживает, что правила жестко заданы в телах классов, а не передаются в качестве аргументов. Чтобы сделать модуль расчета размера позиции универсальным, в таком случае приходится напрямую редактировать файл bet_sizing/prop_firm_sizer.py, а не просто задавать конфигурацию. Если точно проследить, какие константы затрагивает такое обобщение, обнаруживается и то, чего не выявили тесты самой части 11: три из семи констант правил были объявлены, но ни один метод вообще не считывал их значения; эта находка подробно рассмотрена в разделе 8.

Эта статья открывает новую серию «Машинное обучение в условиях ограничений», а не продолжает серию «Архитектура машинного обучения в MetaTrader 5». Целевая аудитория здесь уже: это читатели, которые уже выбрали участие в программе проп-фирмы с предоставлением финансируемого счета и для которых дневной бюджет допустимого убытка — основное ограничение при расчете размера позиции, а не дополнительный механизм контроля риска. В этой серии торговля в проп-фирме рассматривается как то, чем она по своей структуре является для такого читателя: задача оптимизации с ограничениями и выпуклой функцией выплат, а не обычная задача расчета размера позиции с дополнительным учетом. Эта статья закладывает основу, на которой строится вся остальная часть серии. В ней жестко заданные в коде правила из части 11 выделяются в явный набор PropFirmRuleSet, класс PropFirmAccountState и модификаторы размера позиции перерабатываются для использования этого набора, а результат рефакторинга проверяется на соответствие исходной реализации с жестко заданными в коде правилами на смоделированной траектории средств счета. В последующих частях каждый модификатор вновь рассматривается по очереди: цепочка калибровки параметра w, коэффициент снижения риска в зависимости от прогресса достижения цели этапа, снижение доли засчитываемой прибыли в новостном окне и верхняя граница стоп-лосса в рамках поиска оптимального торгового правила из части 15.


Почему именно задача оптимизации с ограничениями?

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

Две структурные особенности отличают этот подход от обычного дополнительного механизма управления риском. Во-первых, бюджет допустимого убытка зависит от траектории: дневной лимит в программе проп-фирмы с динамическим дневным лимитом является функцией всех предшествующих внутридневных колебаний прибыли и убытка за текущую сессию, а не статическим числом, рассчитанным один раз. Функция расчета размера позиции, которая не получает на вход текущее состояние счета, не может корректно реагировать, как бы хорошо ни был откалиброван ее базовый сигнал. Во-вторых, этап испытания характеризуется выпуклой функцией выплат. Успешное прохождение позволяет получить за фиксированную плату за оценку финансируемый счет с неограниченным потенциалом участия в прибыли; при неудаче теряется только эта плата. Эта асимметрия зеркально отражает ту, которую расчет размера позиции по критерию Келли уже учитывает для соотношений выигрышей и проигрышей. Однако она действует на уровне всего счета, а не отдельной сделки, и говорит в пользу уменьшения размера позиции по мере приближения к цели, а не наращивания риска ради использования преимущества. Это прямо противоположно тому, что рекомендовала бы доля по Келли без ограничений при наличии статистического преимущества.

В работе Мастерса (1995) и книге Лопеса де Прадо «Advances in Financial Machine Learning» (AFML) рассматривается расчет размера позиции в условиях неопределенности оценки сигнала. Ни в одной из этих работ не рассматривается расчет размера позиции при жестком, зависящем от траектории ограничении на капитал, внешнем по отношению к модели. Именно этот пробел восполняет PropFirmAwareSizer: это полноценное расширение набора инструментов AFML, а не частное решение под конкретную проп-фирму — при условии, что компоненты, зависящие от конкретной проп-фирмы, отделены от логики обработки ограничений. В части 11 была разработана логика. В этой статье выполняется разделение компонентов.


Что было жестко задано в коде в части 11

В исходном модуле правила были объявлены как приватные константы в начале файла:

# bet_sizing/prop_firm_sizer.py — FundedNext Stellar 2-Step rule constants
_PHASE_TARGETS = {
    Phase.CHALLENGE_PHASE_1: 0.08,
    Phase.CHALLENGE_PHASE_2: 0.05,
    Phase.FUNDED: None,
}
_DAILY_LOSS_LIMIT_PCT = 0.05
_OVERALL_LOSS_LIMIT_PCT = 0.10
_NEWS_PROFIT_CREDIT = 0.40
_MIN_TRADING_DAYS = 5
_MAX_LEVERAGE = 100
_COMMISSION_PER_LOT = 5.0

PropFirmAccountState.daily_limit, .overall_floor и .phase_progress считывают эти значения напрямую. Функция news_window_factor также считывала их напрямую для снижения доли засчитываемой прибыли в новостном окне. С арифметикой все было в порядке: каждое свойство вычисляло именно то, что было заявлено в строках документации. Проблема заключается в связанности компонентов, а не в корректности вычислений: правило увеличения дневного лимита убытка, целевые показатели этапов и снижение доли засчитываемой прибыли в новостном окне определяются условиями конкретной программы проп-фирмы, а не математикой расчета размера позиции. При этом правила программы и математическая логика находились в одном пространстве имен на уровне модуля без какой-либо границы между ними.


Набор настраиваемых правил (PropFirmRuleSet)

Решение — неизменяемый класс данных, содержащий все значения, которые могут различаться в правилах программ проп-фирм. При этом программа Stellar 2-Step от FundedNext представлена одним именованным экземпляром этого класса, а не неявным вариантом по умолчанию:

from dataclasses import dataclass
from typing import Optional

from .phase import Phase

@dataclass(frozen=True)
class PropFirmRuleSet:
    """Constraint set for a prop-firm evaluation/funded program."""

    phase_targets: dict[Phase, Optional[float]]
    daily_loss_limit_pct: float
    daily_loss_is_dynamic: bool
    overall_loss_limit_pct: float
    min_trading_days: int
    max_leverage: int
    drawdown_basis: str
    allows_overnight_positions: bool
    news_profit_credit: float = 1.0
    news_window_minutes: float = 5.0
    commission_per_lot: float = 0.0

    def __post_init__(self) -> None:
        if not 0.0 < self.daily_loss_limit_pct <= 1.0:
            raise ValueError("daily_loss_limit_pct must be in (0, 1]")
        if not 0.0 < self.overall_loss_limit_pct <= 1.0:
            raise ValueError("overall_loss_limit_pct must be in (0, 1]")
        if not 0.0 <= self.news_profit_credit <= 1.0:
            raise ValueError("news_profit_credit must be in [0, 1]")
        if self.drawdown_basis not in ("balance", "equity"):
            raise ValueError('drawdown_basis must be "balance" or "equity"')

# drawdown_basis="equity": FundedNext's own help center confirms floating P&L
# counts against both the daily and overall loss limits in real time.
# allows_overnight_positions=True: FundedNext's CFD Stellar models permit
# overnight holding, with swap charges applied.
FUNDEDNEXT_STELLAR_2STEP = PropFirmRuleSet(
    phase_targets={
        Phase.CHALLENGE_PHASE_1: 0.08,
        Phase.CHALLENGE_PHASE_2: 0.05,
        Phase.FUNDED: None,
    },
    daily_loss_limit_pct=0.05,
    daily_loss_is_dynamic=True,
    overall_loss_limit_pct=0.10,
    min_trading_days=5,
    max_leverage=100,
    drawdown_basis="equity",
    allows_overnight_positions=True,
    news_profit_credit=0.40,
    news_window_minutes=5.0,
    commission_per_lot=5.0,
)

Два решения, касающихся полей, стоит пояснить явно, а не оставлять неявными в коде. daily_loss_is_dynamic — это булево значение, а не процент, поскольку динамическое увеличение дневного лимита — качественно иное правило, чем фиксированный дневной лимит, а не просто другая параметризация того же правила. Сведение этих двух вариантов к одному числовому полю скрыло бы структурное различие за внешним. Phase остается в отдельном модуле (phase.py), а не в rule_set.py или account_state.py, поскольку он нужен обоим модулям, а Python не допускает циклического импорта между ними.

Третья пара полей — drawdown_basis и allows_overnight_positions — отражает различие, еще более важное, чем два предыдущих: ошибка здесь означает не просто неверное размещение значения, а измерение совершенно другой величины. Поля phase_targets, daily_loss_limit_pct и остальные описывают, каковы ограничения программы. Поле drawdown_basis определяет, по какой величине фактически проверяется соблюдение этих ограничений. В справочном центре самой FundedNext прямо указано, что плавающий убыток по открытой позиции учитывается при проверке как дневного, так и общего лимита убытка с момента его возникновения, а не только после закрытия сделки. Позиция с внутридневным убытком в 2 000 долларов расходует 2 000 долларов дневного бюджета допустимого убытка, даже если ее так и не закроют. Это правило с базой расчета по средствам счета: определяющим показателем является current_equity (баланс с учетом реализованного результата плюс последнее значение плавающей прибыли и убытка), а не current_balance (только реализованная прибыль и убыток).

Лишь немногие программы исключают плавающую прибыль и убыток из проверок просадки до реализации этого результата — параметр drawdown_basis позволяет PropFirmAccountState определять, какой показатель используется для проверки лимитов данного счета, вместо того чтобы считать один показатель универсальным. Параметр allows_overnight_positions отражает связанный, но отдельный факт: разрешает ли программа вообще переносить открытую позицию через суточный сброс. Программы CFD Stellar от FundedNext разрешают такой перенос с начислением свопов; другие программы требуют закрыть все позиции до суточного сброса. В разделе 5 объясняется, почему именно эти два параметра в совокупности определяют, что reset_daily() может и чего не может делать с прибылью и убытком по еще открытой позиции.

Стоит явно обозначить еще одно разграничение, поскольку далее статья опирается на него, не повторяя каждый раз. PropFirmRuleSet хранит статические сведения об условиях программы: значения, одинаковые для всех счетов в рамках этой программы. PropFirmAccountState хранит изменяемое, зависящее от траектории состояние одного конкретного счета, а также ссылку на набор правил, по которому работает этот счет. Отдельные функции, такие как news_window_factor, не хранят ни того, ни другого; это чистые функции без состояния, которые получают все необходимые значения из набора правил в виде явных аргументов от вызывающего кода, имеющего доступ к состоянию счета. Эта третья категория не противоречит разграничению первых двух — именно она позволяет изолированно проводить модульное тестирование news_window_factor, не создавая для проверки экземпляр PropFirmAccountState.

Прежде чем подробнее рассматривать PropFirmAccountState, стоит взглянуть на всю цепочку целиком. PropFirmRuleSet и PropFirmAccountState представляют данные об ограничениях проп-фирмы: первый — статические, второй — производные от них; оба напрямую передают данные в PropFirmAwareSizer. Функция derisking_factor также считывает данные из PropFirmAccountState (а именно phase_progress), но показана отдельной ветвью, а не частью цепочки ограничений, поскольку ее пороговые значения определяются стратегией, а не задаются в PropFirmRuleSet. Именно такое разграничение обосновывается в подразделе «Мнение» раздела 9.

Обзор архитектуры: ограничения проп-фирмы и реакция стратегии

Обзор архитектуры: ограничения проп-фирмы и реакция стратегии

Рисунок 1. Блок-схема цепочки «PropFirmRuleSet» → «PropFirmAccountState» → «PropFirmAwareSizer»

  • Ограничения проп-фирмы (синий цвет): PropFirmRuleSet и PropFirmAccountState — сведения о программе и обусловленном ею состоянии счета; и те и другие непосредственно используются модулем расчета размера позиции.
  • Реакция стратегии (фиолетовый цвет): функция derisking_factor считывает данные из того же PropFirmAccountState, но показана другим цветом и на отдельной ветви, поскольку ее пороговые значения не входят в PropFirmRuleSet.
  • PropFirmAwareSizer (зеленый цвет) — единственный узел, использующий обе ветви: он применяет реакцию стратегии как множитель к результату калибровки на основе бюджета допустимого убытка, а не рассматривает ее как входные данные, задаваемые проп-фирмой.


Рефакторинг PropFirmAccountState

Теперь PropFirmAccountState принимает поле rule_set наряду с initial_balance и phase, и каждое свойство, которое ранее считывало константу модуля, теперь вместо этого считывает соответствующее поле в self.rule_set. Именно у свойства daily_limit поведение разветвляется в зависимости от новой конфигурации, поскольку оно реализует различие между динамическим дневным лимитом и фиксированным дневным лимитом:

# Before
@property
def daily_limit(self) -> float:
    base = self.initial_balance * _DAILY_LOSS_LIMIT_PCT
    return base + max(0.0, self.intraday_realized_pnl)

# After
@property
def daily_limit(self) -> float:
    base = self.initial_balance * self.rule_set.daily_loss_limit_pct
    if self.rule_set.daily_loss_is_dynamic:
        return base + max(0.0, self.intraday_realized_pnl)
    return base

overall_floor и phase_progress построены по одному и тому же шаблону: арифметические вычисления остаются неизменными, меняется лишь источник константы — она переносится из области видимости модуля в self.rule_set. Одно дополнение выходит за рамки чистого рефакторинга. Переменная min_trading_days была объявлена в исходном модуле, но ни один метод ее не считывал; теперь она используется новым свойством meets_min_trading_days. Это никак не меняет поведение расчета размера позиции — ничто в PropFirmAwareSizer не считывает это свойство, — но означает, что поле теперь явно используется, что важно по причине, описанной ниже в разделе «Проблема в рабочем коде». Ничто в этом подпакете не переводит счет с одного этапа на другой автоматически: PropFirmAccountState.phase задается вызывающим кодом один раз и остается неизменным в течение всего времени существования экземпляра, поскольку переход с этапа CHALLENGE_PHASE_1 на CHALLENGE_PHASE_2 или FUNDED — это бизнес-событие, которое отслеживает вызывающий код: платформа испытания подтверждает успешное прохождение. Такой переход нельзя определить только по прибыли и убытку с помощью расчетов размера позиции.

current_balance это баланс счета с учетом реализованного финансового результата; он ведется накопительным итогом: banked_balance (накопленные реализованные средства счета на начало текущего дня) плюс реализованный финансовый результат за текущий день за вычетом комиссий. Этот показатель никогда не включает плавающую прибыль и убыток, поэтому прибыль за несколько дней и комиссии, накопленные за несколько вызовов, корректно сохраняются при суточном сбросе reset_daily(), без повторного учета позиции, оставшейся открытой после сброса: reset_daily() фиксирует current_balance, а не текущий снимок плавающей прибыли и убытка, поскольку этот снимок при следующем вызове считывается заново, а не накапливается. current_equity — это свойство, а не хранимое поле: current_balance плюс значение unrealized_pnl, переданное при последнем вызове update(). То, какой из этих двух параметров фактически определяет значения daily_limit, daily_loss_used, overall_remaining и phase_progress, зависит от значения rule_set.drawdown_basis:

@property
def daily_loss_used(self) -> float:
    """Computed fresh from today's net P&L, not accumulated call-by-call,
    so a recovery within the same day is reflected immediately."""
    net = self.intraday_realized_pnl - self.intraday_fees
    if self.rule_set.drawdown_basis == "equity":
        net += self.unrealized_pnl
    return max(0.0, -net)

В справочном центре самой FundedNext подробно разобран именно тот случай, который должна воспроизвести эта формула: реализованная прибыль в размере 2 000 долларов по закрытой сделке в сочетании с плавающим убытком в размере 6 000 долларов по еще открытой сделке дает дневной убыток в размере 4 000 долларов. Это не два отдельно отслеживаемых показателя, а единая величина, при проверке которой прибыль и убыток учитываются совместно. Прибыль, за счет которой увеличивается daily_limit, когда daily_loss_is_dynamic имеет значение True, должна быть реализованной даже при использовании средств счета в качестве базы расчета. Это соответствует описанию самой FundedNext: лимит увеличивается за счет уже зафиксированной прибыли. Если бы плавающая прибыль увеличивала доступный бюджет допустимого убытка, а плавающий убыток при этом уже уменьшал его, риск для счета по позиции, развернувшейся в течение дня, удвоился бы. Прилагаемый набор тестов точно воспроизводит оба опубликованных FundedNext расчетных примера, включая случай нарушения при точном достижении лимита, в виде постоянных регрессионных тестов, а не разовых ручных проверок.

Практический эффект единственной приведенной выше ветви if self.rule_set.daily_loss_is_dynamic проще увидеть на конкретных числах, чем понять из абстрактных рассуждений. На рисунке 2 для обеих конфигураций используется одна и та же смоделированная траектория внутридневной прибыли и убытка и сравниваются рассчитываемые ими дневные бюджеты допустимого убытка, а также значения risk_budget_pct, которые на основе этих бюджетов в итоге передаются модулю расчета размера позиции.

Динамический и фиксированный дневной бюджет допустимого убытка и результирующий входной параметр расчета размера позиции

Динамический и фиксированный дневной бюджет допустимого убытка и результирующий входной параметр расчета размера позиции

Рисунок 2. Иллюстрация обобщенного подхода к дневному лимиту на двух панелях

  • Левая панель: дневной бюджет допустимого убытка при наборе правил FUNDEDNEXT_STELLAR_2STEP (динамический, увеличивается за счет внутридневной прибыли) в сравнении с набором правил с фиксированным лимитом и той же базовой ставкой. В обоих случаях используется одна и та же смоделированная траектория внутридневной прибыли и убытка: сначала просадка примерно на 1 800 долларов, затем восстановление до внутридневной прибыли в 1 200 долларов. Значение daily_loss_used одинаково в обеих конфигурациях, поскольку учет убытков не зависит от набора правил.
  • Правая панель: итоговое значение risk_budget_pct — величина, которая фактически поступает в модуль расчета размера позиции. Результаты двух конфигураций начинают расходиться, как только счет выходит в прибыль в течение дня, поскольку динамическое правило увеличивает дневной бюджет допустимого убытка на сумму этой прибыли, а фиксированное — нет.

В этом сценарии просадка остается достаточно небольшой (1 800 долларов при общем запасе допустимого убытка в 10 000 долларов, то есть 10 % от баланса в 100 000 долларов), поэтому дневное ограничение остается определяющим на протяжении всего периода в обеих конфигурациях. Этот пример показывает, что после рефакторинга PropFirmAccountState при соответствующей настройке корректно воспроизводит увеличение лимита по правилам FundedNext, а при настройке на программу с фиксированным лимитом дает существенно иной, но также корректный результат. Этот пример не показывает поведение вблизи нижней границы стоимости счета, где overall_remaining, не зависящий от daily_loss_is_dynamic, в конечном итоге стал бы определяющим ограничением для обеих конфигураций и две линии на правой панели сошлись бы. Сценарий с более глубокой просадкой для этого режима станет естественным дополнением к части 2, когда вновь будет рассматриваться цепочка калибровки параметра w, которая фактически использует risk_budget_pct.


Рефакторинг модификаторов размера позиции

Ранее функция news_window_factor считывала _NEWS_PROFIT_CREDIT как жестко заданное в ее теле значение 0.40. Теперь эта функция принимает news_profit_credit как явный параметр, а PropFirmAwareSizer.size() получает и это значение, и news_window_minutes из state.rule_set в момент вызова, а не из аргументов конструктора самого модуля расчета размера позиции:

def news_window_factor(
    current_time: datetime,
    news_times: list[datetime],
    news_profit_credit: float,
    window_minutes: float = 5.0,
    phase: Phase = Phase.CHALLENGE_PHASE_1,
) -> float:
    if phase != Phase.FUNDED or not news_times or news_profit_credit >= 1.0:
        return 1.0
    window = pd.Timedelta(minutes=window_minutes)
    ct = pd.Timestamp(current_time).tz_localize(None)
    for nt in news_times:
        if abs(ct - pd.Timestamp(nt).tz_localize(None)) <= window:
            return news_profit_credit
    return 1.0

В результате аргумент news_window_minutes удален из конструктора PropFirmAwareSizer — это намеренное изменение публичного API по сравнению с версией из части 11. Ширина окна настраивалась в модуле расчета размера позиции и отдельно была жестко задана в константе снижения доли засчитываемой прибыли: так для одного и того же параметра правил программы возникали два источника истины. derisking_factor намеренно не рассматривается в этом разделе: он не изменился, а раздел 9 объясняет, почему его и не следует менять.


Проверка: соответствие поведения исходной реализации

Рефакторинг, который меняет источник каждой константы, но не затрагивает арифметические операции, предполагает конкретное, проверяемое утверждение: в той единственной конфигурации, которая существовала до изменения, поведение модуля расчета размера позиции должно остаться полностью прежним. Это утверждение можно опровергнуть, поэтому набор тестов проверяет его напрямую, а не ограничивается изолированной проверкой нового кода. Простая повторная реализация исходного PropFirmAccountState — с дословно скопированными, жестко заданными в коде константами и без зависимости от PropFirmRuleSet — выполняется параллельно с версией после рефакторинга в ходе случайного моделирования прибыли и убытка за двадцать дней, по десять баров в день. Свойства, которые корректно учитывают только один день независимо от числа вызовов, — daily_limit, daily_remaining, daily_loss_used, overall_floor — на каждом шаге проверяются на равенство соответствующим значениям эталонной реализации, поскольку их обработка ни в одной из реализаций не изменилась. current_balance и производные от него свойства (phase_profit_pct, overall_remaining, phase_progress) учитывают накопленный результат по всей моделируемой траектории, а не сбрасываются каждый день. Поэтому их проверяют непосредственно по корректному расчету накопительного итога — initial_balance плюс все реализованные прибыли и убытки за двадцать дней, — а не сравнением с простой эталонной реализацией, которую сравнение при единственном вызове не позволило бы содержательно проверить:

rng = np.random.default_rng(42)
orig = _OriginalAccountState(initial_balance=100_000.0, phase=Phase.CHALLENGE_PHASE_1)
new = PropFirmAccountState(
    initial_balance=100_000.0, phase=Phase.CHALLENGE_PHASE_1, rule_set=FUNDEDNEXT_STELLAR_2STEP
)

total_pnl = total_fees = 0.0
for day in range(20):
    for _bar in range(10):
        pnl = float(rng.normal(50, 300))
        fees = float(abs(rng.normal(2, 1)))
        orig.update(pnl, 0.0, fees)
        new.update(pnl, 0.0, fees)
        total_pnl += pnl
        total_fees += fees
        # assert day-scoped properties equal orig; balance/progress checked below
    orig.reset_daily()
    new.reset_daily()

assert new.current_balance == 100_000.0 + total_pnl - total_fees

Наряду с этой проверкой полный набор тестов воспроизводит оба опубликованных FundedNext примера расчета дневного лимита убытка — включая случай нарушения при точном достижении лимита — в качестве постоянных регрессионных тестов; подтверждает, что набор правил с базой расчета по балансу действительно исключает плавающую прибыль и убыток, тогда как набор правил с базой расчета по средствам счета их учитывает; проверяет валидацию PropFirmRuleSet; проверяет искусственно заданный набор правил с фиксированным дневным лимитом, чтобы убедиться, что обобщение действительно работает, а не просто воспроизводит единственный исходный случай; охватывает неизменившиеся пороговые значения derisking_factor и kelly_payoff_multiplier; а также выполняет сквозную проверку PropFirmAwareSizer.size() на тестовых событиях и вероятностях, включая ветвь с исчерпанным бюджетом допустимого убытка. Полный код скрипта находится в приложенном архиве. Такой тест не доказывает абсолютную корректность кода после рефакторинга. Он доказывает, что рефакторинг не внес незаметных изменений в поведение, наблюдаемое модулем расчета размера позиции и на которое уже полагались читатели предыдущей статьи. При этом накопительный учет баланса и различие между балансом и средствами счета проверяются по опубликованным расчетам самой программы проп-фирмы, а не только на внутреннюю согласованность.


Проблема в рабочем коде: три константы, которые нигде не считывались

Самым полезным результатом этого рефакторинга стала не сама абстракция. Им стало то, что удалось выявить, проследив каждое чтение констант исходного модуля, — факт, который не обнаруживается при обычном для рецензента чтении модуля сверху вниз: min_trading_days, max_leverage и commission_per_lot были объявлены в модуле из части 11, но не использовались ни одним его методом. Они выглядели как настройки. По сути, они были скорее документацией, записанной в виде кода, и никакое внимательное чтение само по себе этого не выявит: для этого нужно проверить, что каждая константа действительно где-то считывается.

commission_per_lot — наиболее интересный из трех случаев, поскольку в части 14 уже был определен правильный способ получения такого значения: измерять его по данным брокера, а не угадывать. В описанной в той статье модели TransactionCostModel параметр commission_per_lot трактуется как ставка комиссии за одну сторону сделки, полученная с помощью скрипта сбора данных на MQL5, который берет выборки из истории спредов и свопов самого брокера. Удвоение комиссии для полного цикла сделки выполняется явно и задокументировано, чтобы исключить незаметный двойной учет. Для _COMMISSION_PER_LOT = 5.0 в модуле расчета размера позиции с учетом правил проп-фирмы нет такого обоснования происхождения значения, как нет и строки документации, указывающей, задана ли комиссия за одну сторону сделки или за полный цикл сделки. Это было правдоподобное на вид число в модуле, который ни разу не умножал его на объем позиции в лотах, прямо рядом с _DAILY_LOSS_LIMIT_PCT, которую тот же модуль использовал при каждом вызове.

Этот рефакторинг не подключает commission_per_lot к фактическому расчету прибыли и убытка: это по-прежнему выходит за рамки части 1 и относится к той будущей части, где данный параметр действительно понадобится для расчета размера позиции. Это делает пропуск явным, а не скрытым, и указывает на правильное исправление: когда commission_per_lot действительно начнет влиять на работу этого подпакета, его значение следует получать вызовом TransactionCostModel.summary(), как описано в части 14, а не повторно вводить вручную в виде константы. Константу, которую нигде не используют, легко упустить из виду при обычном просмотре кода; если к тому же неизвестно, на основании каких измерений получено ее значение, это потенциальная ошибка, которая может проявиться, когда код наконец начнет использовать эту константу.


Мнение: как удержаться от желания сделать всё настраиваемым

derisking_factor — коэффициент снижения риска, который уменьшает размер позиции, когда прогресс достижения цели этапа превышает 80 %, и достигает минимального значения при 100 % — 0.30 или 0.10 в зависимости от того, выполнено ли уже установленное набором правил требование к минимальному числу торговых дней; механизм действия второго минимального значения будет рассмотрен в одной из следующих частей, а не здесь. В ходе этого рефакторинга все остальные числовые параметры модуля расчета размера позиции, связанные с проп-фирмой, были перенесены в PropFirmRuleSet . Эти пороговые значения туда не перенесли, хотя соблазн всё же включить их — пока код класса данных уже был открыт для редактирования и всем остальным магическим числам находилось место — был достаточно сильным, чтобы прямо о нем сказать, а не просто молча отказаться от этой идеи.

Не стоит поддаваться этому соблазну, потому что кривая снижения риска не является частью условий ни одной проп-фирмы. Она отражает принятое в этой стратегии решение о защите выпуклой функции выплат, поэтому ее не следует закладывать в набор правил. Автор другой торговой стратегии, торгующий на том же самом счете FundedNext, вполне может выбрать кривую 90 %/50 % или вообще отказаться от снижения риска, не изменяя при этом ни одного положения правил FundedNext. Если бы этот выбор был заложен в PropFirmRuleSet , то два независимых решения — что требует проп-фирма и как стратегия решает реагировать на собственный прогресс — выглядели бы как одно, причем обнаружить его можно было бы лишь при чтении поля, а не логики стратегии. Граница класса данных здесь выполняет реальную функцию: она отделяет ограничение от реакции на него, и это разделение имеет большее значение, чем незначительное удобство, заключающееся в том, что все настраиваемые значения собраны в одном объекте.


Заключение

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

  • Отделяйте ограничение от реакции на него. PropFirmRuleSet хранит сведения о программе проп-фирмы; пороговые значения derisking_factor остаются за его пределами, поскольку это выбор стратегии, а не правило проп-фирмы.
  • Различие между динамическим и фиксированным дневным лимитом — структурное, а не просто различие в значении параметра. Представляйте его логическим значением, а не числом, которое в одном из случаев оказывается равным нулю.
  • Для накопительного состояния нужен накопитель, а не повторный расчет. current_balance отслеживается на основе banked_balance с учетом накопительных итогов прибыли и убытка и комиссий за текущий день, поэтому его значение остается корректным независимо от числа прошедших дней и количества вызовов в течение дня.
  • Баланс и средства счета нельзя использовать как взаимозаменяемые значения по умолчанию. Правила FundedNext предусматривают проверку плавающей прибыли и убытка в реальном времени; программа проп-фирмы, в которой такой проверки нет, — исключение, а не правило, и параметр drawdown_basis нужен для того, чтобы при обработке состояния счета никогда не приходилось предполагать, какой из этих двух показателей используется.
  • Неиспользуемая константа — потенциальный источник ошибки, если код впоследствии начнет ее считывать. min_trading_days, max_leverage и commission_per_lot не использовались в модуле из части 11; именно обобщение правил наконец позволило выявить эту проблему.

В части 2 мы вернемся к цепочке калибровки параметра w — компоненту WParamCalibrator и управляемому им модификатору с сигмоидальным масштабированием, — которая теперь считывает risk_budget_pct из состояния счета, учитывающего набор правил, а не из состояния счета, рассчитанного только на FundedNext.

У рефакторинга есть своя цена, о которой стоит сказать прямо, а не умалчивать. Теперь PropFirmRuleSet — зависимость, версии которой в каждой части этой серии нужно контролировать наряду с версиями самой стратегии. Жестко заданная в коде константа модуля не может незаметно рассинхронизироваться с фактическими действующими условиями проп-фирмы, поскольку у нее нет внешней конфигурации, от которой она могла бы отклониться; экземпляр PropFirmRuleSet может, если правила программы проп-фирмы изменятся, а экземпляр в работающей стратегии так и не будет приведен в соответствие с ними. Это новый тип сбоя, который данная абстракция создает, а не устраняет. Для серии, которая должна поддерживать несколько программ проп-фирм, такой компромисс оправдан, но это именно компромисс, а не безусловный выигрыш, и в следующей части, где используется значение PropFirmRuleSet, его следует рассматривать как входные данные, которые могут устареть, а не как константу, верную по определению.

Прикрепленные файлы

1. phase.py afml.prop_firm Перечисление Phase, вынесенное в отдельный файл во избежание циклического импорта.
2. rule_set.py afml.prop_firm Класс данных PropFirmRuleSet и предустановка FUNDEDNEXT_STELLAR_2STEP.
3. account_state.py afml.prop_firm PropFirmAccountState, переработанный для использования PropFirmRuleSet: значение current_balance ведется накопительным итогом между днями и вызовами, а дневной и общий лимиты учитывают базу расчета просадки drawdown_basis.
4. sizing.py afml.prop_firm WParamCalibrator, kelly_payoff_multiplier, derisking_factor, news_window_factor, PropFirmAwareSizer, make_sizer и prop_firm_sl_ceiling с устраненным дублированием кода.
5. test_prop_firm.py afml.prop_firm Набор тестов: валидация набора правил, проверка дневных и накопительных показателей состояния счета, проверка применимости к набору , отличному от правил FundedNext, проверка пороговых значений модификаторов и сквозной тест базовой работоспособности модуля расчета размера позиции.


Дополнительная литература

  • Лопес де Прадо, М. (2018). Достижения в области финансового машинного обучения (Advances in Financial Machine Learning). John Wiley & Sons.
  • Masters, T. (1995). Neural, Novel & Hybrid Algorithms for Time Series Prediction. John Wiley & Sons.

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/24237

Прикрепленные файлы |
MQL5.zip (31.8 KB)
Тики из MetaTrader 5 без копирования (Часть 1): Мост к разделяемой памяти на основе интерфейса Arrow C Data Interface Тики из MetaTrader 5 без копирования (Часть 1): Мост к разделяемой памяти на основе интерфейса Arrow C Data Interface
В этой статье реализуется мост без блокировок для обмена через разделяемую память в MetaTrader 5, который записывает тики в памяти в колоночном формате Apache Arrow через интерфейс Arrow C Data Interface. В ней подробно описаны расположение столбцов, двойная буферизация с использованием seqlock и стратегия формирования пакетов. Вы получите полный исходный код класса писателя, советника для потоковой передачи тиков и скрипта самопроверки, который проверяет корректность работы ещё до подключения читателя на Python.
Свинговые экстремумы и откаты в MQL 5 (Часть 4): Динамическая глубина отката на основе моделей волатильности Свинговые экстремумы и откаты в MQL 5 (Часть 4): Динамическая глубина отката на основе моделей волатильности
В этой статье бинарная проверка свингов заменяется моделью отката, нормализованной по волатильности. Глубина коррекции измеряется как доля предшествующего импульса и калибруется с учетом режима волатильности, определяемого по скользящему ATR, а для входа в сделку требуются оценка качества не ниже минимальной и подтверждение сигналами структуры или ликвидности. Пятиуровневая архитектура объединяет обнаружение, проверку, картирование ликвидности, оценку с учетом рыночного режима и исполнение сделок, помогая отфильтровывать слабые коррекции и динамически подбирать размер стоп-лоссов под текущие условия.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Как запустить локальную LLM для MetaTrader 5 без Ollama? Как запустить локальную LLM для MetaTrader 5 без Ollama?
Статья посвящена созданию локальной связки MetaTrader 5 и компактной LLM без Ollama. Рассматривается архитектура MAX TURBO, которая напрямую управляет llama.cpp, GGUF-моделью, HTTP-сессией и двумя уровнями кэша, а технические признаки из MT5 преобразуются в короткий запрос для прогноза направления, уверенности и цены через 24 часа. Отдельное внимание уделено честной проверке системы: различиям между backtest и live, учёту риска, издержек и ограничениям текущей реализации