Как построить советник, работающий автоматически (Часть 08): OnTradeTransaction

Daniel Jose | 21 февраля, 2023

Введение

В предыдущих статьях Как построить советник, работающий автоматически (Часть 06): Виды счетов (I) и Как построить советник, работающий автоматически (Часть 07): Виды счетов (II), мы сосредоточились на попытке показать, что мы должны быть осторожны при разработке советника, который торгует автоматически.

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

Рисунок 01

Pисунок 01 - Поток сообщений


На рисунке 01 можно увидеть поток сообщений, который позволяет советнику отправлять ордера или запросы на торговый сервер. Обратите внимание на направление стрелок.

Единственный момент, когда стрелки являются двунаправленными - это когда класс C_Orders отправляет ордер на сервер, используя функцию OrderSend, потому что тогда он получит ответ от сервера через структуру. Кроме этого момента, все остальные точки являются направленными, но здесь я показываю только процесс для отправки рыночных ордеров или для размещения ордеров в книге заявок. Таким образом получается очень простая система.

В случае со 100% автоматическим советником ещё остаются некоторые вещи, которые нам здесь понадобятся. А для советника с минимальной автоматизацией нам ещё нужно добавить некоторые детали, но всё произойдет между советником и классом C_Manager. Ни в одном другом моменте нам больше не придется добавлять код. Хотя в советнике, который работает автоматически на 100%, нам придется убрать класс C_Mouse (так как для 100% автоматического советника он будет бесполезен), очень важно понимать что из себя представляет поток сообщений, потому что без этого понимания мы не сможем идти дальше к следующим частям статьи.


Как добавить функции управления и доступности

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

Первое, что мы сделаем - добавим 3 новые функции в класс C_Manager. Они будут служить классу, чтобы он мог «освободить» советника или для того, чтобы узнать его запланированные действия. Первая из этих функций показана ниже:

inline void EraseTicketPendig(const ulong ticket)
                        {
                                m_TicketPending = (ticket == m_TicketPending ? 0 : m_TicketPending);
                        }

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

Следующую добавленную функцию можно увидеть в коде ниже:

                void PedingToPosition(void)
                        {
                                ResetLastError();
                                if ((m_bAccountHedging) && (m_Position.Ticket > 0)) SetUserError(ERR_Unknown);
                                        else m_Position.Ticket = (m_Position.Ticket == 0 ? m_TicketPending : m_Position.Ticket);
                                m_TicketPending = 0;
                                if (_LastError != ERR_SUCCESS) UpdatePosition(m_Position.Ticket);
                                CheckToleranceLevel();
                        }

Советник использует этот код для сообщения классу C_Manager о том, что отложенный ордер только что был преобразован в позицию или в позицию были внесены некоторые изменения. Обратите внимание, что при выполнении этой функции тикет отложенного ордера будет удален классом C_Manager, что позволит советнику установить новый отложенный ордер. Однако, дело продолжится только в том случае, если не возникнет никакой критической ошибки, которая будет анализироваться данной функцией, рассмотренной в предыдущей статье. Но эта функция на самом деле не работает одна, ей нужна ещё одна функция, которую мы покажем ниже:

                void UpdatePosition(const ulong ticket)
                        {
                                int ret;
                                
                                if ((ticket == 0) || (ticket != m_Position.Ticket)) return;
                                if (PositionSelectByTicket(m_Position.Ticket))
                                {
                                        ret = SetInfoPositions();
                                        m_StaticLeverage += (ret > 0 ? ret : 0);
                                }else ZeroMemory(m_Position);
                                ResetLastError();
                        }

В классе C_Manager нет ещё двух функций, но поскольку это функции автоматизации, мы пока не будем подробно рассматривать их.

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

Рисунок 02

Рисунок 02 - Поток сообщений с новыми реализованными функциями


Данный поток может показаться вам слишком сложным или совсем нефункциональным, но это именно то, что реализуется в данный момент.

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

На основе этого понимания, мы пройдемся по коду советника в текущей ситуации, прежде чем перейти к автоматизации. Но для тех, кто этого не видел, скажу, что код советника не претерпел серьёзных изменений со времени последней статьи, в которой он появился Как построить советник, работающий автоматически (Часть 05): Ручные триггеры (II). Единственные внесённые изменения, показаны чуть ниже:

int OnInit()
{
        manager = new C_Manager(def_MAGIC_NUMBER, user03, user02, user01, user04, user08);
        mouse = new C_Mouse(user05, user06, user07, user03, user02, user01);
        (*manager).CheckToleranceLevel();

        return INIT_SUCCEEDED;
}

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

Первое, что действительно нужно понять - это то, что платформа MetaTrader 5, как и язык MQL5, не являются обычными инструментами, то есть мы не будем создавать программу, которая должна постоянно искать информацию. Это потому, что система основана на событиях, а не на процессах. В событийно-обоснованном программировании вам не нужно думать о шагах, надо мыслить по-другому.

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

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

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

Это первый пункт: изначально используйте язык MQL5 для решения проблем. Если вам этого почему-то будет недостаточно, то добавьте конкретные функции. Используйте другой язык в качестве вспомогательного, например C/C++ или даже Python, но сначала попробуйте использовать MQL5.

Второй пункт: не следует пытаться захватить информацию, откуда бы она ни происходила. Вы просто должны всегда, когда это возможно, использовать события, которые будет генерировать платформа MetaTrader 5, и реагировать на них.

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

Основываясь на этих 3 пунктах, мы сможем выбрать 3 варианта взаимодействия советника с классом C_Manager или любым другим классом, которому необходимо получать данные, предоставляемые платформой MetaTrader 5. Первый вариант - использовать триггер события для каждого нового полученного тикета. Это событие вызовет функцию OnTick. Если честно, я не советую использовать эту функцию. О причинах поговорим в другой раз.

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

Последний вариант - использовать событие Trade, которое запускает функцию OnTrade. Он активируется каждый раз, когда происходит изменение в системе ордеров, как запуском новых ордеров, так и изменением позиций. А вот функция OnTrade в некоторых случаях не очень подходит, хотя в других случаях она избавит нас от выполнения определенных задач и, следовательно, упростит многое. Вместо использования функции OnTrade, мы будем использовать функцию OnTradeTransaction.


Что такое OnTradeTransaction и для чего она нужна?

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

Чтобы всё было проще объяснить, по крайней мере, на этом начальном этапе, давайте рассмотрим код функции, находящейся в этом советнике:

void OnTradeTransaction(const MqlTradeTransaction &trans, const MqlTradeRequest &request, const MqlTradeResult &result)
{
        switch (trans.type)
        {
                case TRADE_TRANSACTION_POSITION:
                        manager.UpdatePosition(trans.position);
                        break;
                case TRADE_TRANSACTION_ORDER_DELETE:
                        if (trans.order == trans.position) (*manager).PendingToPosition();
                        else (*manager).UpdatePosition(trans.position);
                        break;
                case TRADE_TRANSACTION_REQUEST: if ((request.symbol == _Symbol) && (result.retcode == TRADE_RETCODE_DONE) && (request.magic == def_MAGIC_NUMBER)) switch (request.action)
                        {
                                case TRADE_ACTION_DEAL:
                                        (*manager).UpdatePosition(request.order);
                                        break;
                                case TRADE_ACTION_SLTP:
                                        (*manager).UpdatePosition(trans.position);
                                        break;
                                case TRADE_ACTION_REMOVE:
                                        (*manager).EraseTicketPending(request.order);
                                        break;
                        }
                        break;
        }
}

Я знаю, что этот код кажется довольно странным для многих, особенно для тех, кто привык использовать другие методы, чтобы узнать о происходящем с их ордерами и позициями, но я вам гарантирую, что если вы действительно поймете, как работает функция OnTradeTransaction, то вы начнете применять её во всех своих советниках, потому что она действительно очень быстро помогает.

Однако, чтобы объяснить, как это работает, по возможности будем избегать разговоров о данных файла журнала, потому что, если вы попытаетесь увидеть логику, следуя файлам или шаблонам, найденным в файлах журналов, то вы можете сойти с ума. Это связано с тем, что иногда у данных не будет какой-либо закономерности. Причина в том, что эта функция является обработчиком событий. Данные события исходят от торгового сервера, так что забудьте о файлах журнала. Мы сосредоточимся на управлении событиями, которые отправляются с торгового сервера, независимо от порядка их появления.

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

Обратите внимание, что в некоторых моментах я использую переменную, которая будет ссылаться на структуру MqlTradeTransaction. Эта структура довольно сложна, но сложная с точки зрения того, что сервер видит и понимает. Однако, для нас это зависит от каждого типа вещей, которые мы действительно хотим проверить, проанализировать и узнать. Что именно нас интересует, так это поле type данной структуры, поэтому с этого момента у нас появляется эта система переключения. В этом коде мы имеем дело только с тремя типами транзакций, выполняемых сервером: TRADE_TRANSACTION_REQUEST, TRADE_TRANSACTION_ORDER_DELETE y TRADE_TRANSACTION_POSITION. Именно поэтому они используются здесь.

Поскольку довольно сложно объяснить любой из типов транзакций, не имея для него примера, давайте начнем с TRADE_TRANSACTION_POSITION, так как у него только одна строка, как видно из следующего фрагмента:

                case TRADE_TRANSACTION_POSITION:
                        manager.UpdatePosition(trans.position);
                        break;

Это событие будет происходить всегда, когда что-то случилось в открытой позиции, но не в любой позиции, а только в той, которая подверглась какой-либо модификации. Сервер сообщает о ней, и мы передаем ее в класс C_Manager, чтобы она обновлялась, если это та позиция, которую наблюдает советник. В противном случае она будет проигнорирована. Это экономит нам много времени в попытках выяснить то, какая позиция на самом деле была изменена.

Следующим в списке является TRADE_TRANSACTION_ORDER_DELETE. У него есть код, который с виду довольно запутанный и лишенный какой-либо логики, что можно увидеть в следующем фрагменте:

                case TRADE_TRANSACTION_ORDER_DELETE:
                        if (trans.order == trans.position) (*manager).PendingToPosition();
                        else (*manager).UpdatePosition(trans.position);
                        break;

Когда ордер преобразуется в позицию, то этот ордер вызывает событие, в котором сервер сообщает, что он был удален. То же самое происходит, и такое же событие срабатывает при закрытии позиции. Разница между событием, сообщающим о преобразовании ордера в позицию и о закрытии позиции, заключается в значении, которое передается сервером.

Когда ордер преобразуется в позицию, то он имеет то же значение, что указано в тикете позиции, поэтому мы сообщаем классу C_Manager, что ордер был преобразован в позицию. Когда позиция закрывается, эти значения будут другими, но также может случиться так, что у нас будет открытая позиция и добавлен ордер, или открытый объем будет уменьшен; в обоих случаях значение, указанное в trans.order и trans.position будет другим. В этом случае мы делаем запрос на обновление класса C_Manager..

В некоторых случаях это событие может идти вместе с событием TRADE_TRANSACTION_POSITION, но это не всегда так. Чтобы доступно объяснить, давайте разделим информацию, потому что понять этот код очень важно.

Сначала разберемся со случаем, когда trans.order равен trans.position, ведь они могут быть одинаковыми. Поэтому не думайте, что они всегда будут разными. Когда они равны, сервер запускает перечисление TRADE_TRANSACTION_ORDER_DELETE, но это не происходит само по себе, а сопровождается другими перечислениями. Мы не должны заниматься всеми ими, только конкретно этим. Сервер будет сообщать нам, что ордер только что превратился в позицию. В это время ордер будет закрыт, а позиция будет открыта с тем же значением тикета, что и ордер, который закрылся,

но может случиться так, что сервер не пришлет нам перечисление TRADE_TRANSACTION_POSITION. Даже если изначально вы будете ждать этого перечисления, то сервер может просто не запустить его, но вероятнее всего, что это вызовет удаление. Указанные значения будут строго одинаковыми. В этом случае мы знаем, что это ордер, который был в книге заявок и что он стал позицией, но в случае с рыночными ордерами всё работает немного по-другому, этот случай мы увидим позже.

Теперь, если trans.order отличается от trans.position, сервер также запустит другие перечисления, но, таким же образом, нам не надо дожидаться того, что именно оно появится. Может случиться так, что сервер не запустит его, но активирует то перечисление, которое мы используем. В данном случае это указывает на то, что позиция только что была закрыта по какой-то причине, которую мы здесь не анализируем. В любом случае мы получим информацию о структурах, полученных событием TradeTransaction. Вот почему этот обработчик событий так интересен: не приходится выходить в поисках информации. События там есть, надо только зайти в соответствующую структуру и прочитать, что там сообщается. И мы поймем, почему проверки выполняются именно таким образом.

Обычно в программах, которые не используют этот обработчик событий, программист создает цикл, который переберет все открытые позиции или отложенные ордера. Он будет пытаться узнать, какой из них был выполнен или закрыт. Это пустая трата времени, так как заставляет советника заниматься совершенно бесполезными вещами, которые легче зафиксировать. Это связано с тем, что торговый сервер уже сделал всю тяжелую работу за нас, сообщив нам, какой отложенный ордер был закрыт, какая позиция была открыта или какая позиция была закрыта. И мы тут запускаем циклы, чтобы узнать эту информацию.

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

                case TRADE_TRANSACTION_REQUEST: if ((request.symbol == _Symbol) && (result.retcode == TRADE_RETCODE_DONE) && (request.magic == def_MAGIC_NUMBER)) switch (request.action)
                        {
                                case TRADE_ACTION_DEAL:
                                        (*manager).UpdatePosition(request.order);
                                        break;
                                case TRADE_ACTION_SLTP:
                                        (*manager).UpdatePosition(trans.position);
                                        break;
                                case TRADE_ACTION_REMOVE:
                                        (*manager).EraseTicketPending(request.order);
                                        break;
                        }
                        break;

Это перечисление, TRADE_TRANSACTION_REQUEST, срабатывает почти во всех случаях. Было бы довольно странно, что он не сработал. Таким образом, многие проверки возможно реализовать внутри него, но поскольку это перечисление, запущенное сервером, нам нужно дополнительно фильтровать его.

Однако, если вас это утешит, сервер обычно запускает данное перечисление по какому-то запросу, сделанному советником или платформой. Это в случаях, когда вы делаете что-то, связанное с системой ордеров. Но не надо всегда рассчитывать на это, так как иногда сервер просто запускает такое перечисление. Иногда он делает это без очевидной причины, поэтому приходится фильтровать то, что сообщается.

Сначала фильтруем актив. Для этого можно использовать любую из структур, но я предпочитаю использовать вот эту. Далее проверяем, принят ли запрос сервером, для чего используем вот такую проверку. И , наконец, мы проверим магический номер для дальнейшей фильтрации вещей. Теперь наступает самая запутанная часть. Я говорю так, потому что пользователь обычно не умеет заполнять остальную часть кода.

Когда для проверки типа действия используется переключатель, мы не анализируем (и не собираемся анализировать) действия, выполняемое на сервере. Это не то, чем мы на самом деле займемся. Мы собираемся осуществить только одну итеративную проверку того, что было отправлено на сервер советником или платформой. Есть 6 типов действий, и все они вполне классические. Давайте перейдем к ним. Они представляют собой перечисление ENUM_TRADE_REQUEST_ACTIONS. Чтобы упростить задачу, обратимся к следующей таблице. Она та же самая, что и в документации. Я позаимствовал её, чтобы облегчить объяснение, но мое описание немного отличается от того, что есть в документации.

Вид действия Описание действия
TRADE_ACTION_DEAL Разместить торговый ордер, который будет исполнен по рыночной цене
TRADE_ACTION_PENDING Разместить ордер в книге заявок для исполнения по заданным параметрам
TRADE_ACTION_SLTP Изменять значения стоп-лосса и тейк-профита позиции
TRADE_ACTION_MODIFY Изменить параметры отложенного ордера, который находится в книге заявок
TRADE_ACTION_REMOVE Удалить отложенный ордер, который всё ещё находится в книге заявок
TRADE_ACTION_CLOSE_BY Закрыть позицию

Таблица 01

Если вы действительно следите за тем, что мы программировали с самого начала этой серии статей, но не уделяли должного внимания, то при программировании, придется проверить, где поле Тип действия, присутствующее в таблице 01, использовалось в нашем коде. И я уже не говорю об обработчике события OnTradeTransaction, потому что это не считается. Кроме того, эти перечислители уже использовались. Но где?! В классе C_Orders.

Откройте исходный код и в классе C_Orders посмотрите на следующие процедуры: CreateOrder, ToMarket, ModifyPricePoints иClosePosition. Таким образом, мы рассмотрим каждую из них, за исключением процедуры ClosePosition, где я не использую перечисление TRADE_ACTION_CLOSE_BY.

И почему это важно для нас именно здесь, в обработчике события OnTradeTransaction? Причина в том, что эти перечисления будут теми же самыми, что и те, которые будут и могут быть видны, когда мы анализируем, к какому типу действия относится перечисление TRADE_TRANSACTION_REQUEST. Поэтому в коде обработчика события OnTradeTransaction мы видим перечисления TRADE_ACTION_DEAL и TRADE_ACTION_SLTP, а также TRADE_ACTION_REMOVE, потому что именно на них советник должен обращать внимание.

Но как же остальные? Наша цель здесь - создание автоматического советника, а остальные не важны, они используются для других вещей. Если вы хотите посмотреть применение других типов, вы можете посмотреть следующую статью: Разработка торгового советника с нуля (Часть 25): Обеспечиваем надежность системы (II), в которой я показываю возможности использования других перечислений. 

Хорошо, после моего объяснения, откуда берутся эти операторы case, давайте разберемся, что делает каждый из них, начиная с того, который мы видим ниже:

        case TRADE_ACTION_DEAL:
                (*manager).UpdatePosition(request.order);
                break;

При исполнении рыночного ордера вызывается этот перечислитель. Но так происходит не только в этом операторе case, ведь есть и другой, при котором он тоже вызывается. Когда я говорил о перечислении TRADE_TRANSACTION_ORDER_DELETE, я сказал, что был такой оператор case, где значения trans.order и trans.postion могли совпадать, а это второй случай, при котором данное перечисление TRADE_ACTION_DEAL запускается торговым сервером. Так что, теперь мы можем добавить заявленное значение тикета как открытую позицию. Но обратите внимание, что если что-то произойдет, например, другая позиция всё еще открыта, то возникнет ошибка, которая приведет к прекращению выполнения советника; это не видно здесь, но можно увидеть в коде UpdatePosition

Следующий код в последовательности можно увидеть в следующем фрагменте:

        case TRADE_ACTION_SLTP:
                (*manager).UpdatePosition(trans.position);
                break;

Данный перечислитель сработает при изменении лимитных значений всем известных тейк-профита и стоп-лосса, он просто обновит значения stop и take. Эта версия очень простая, и существуют способы сделать ее немного интереснее, но пока этого достаточно. И последнее перечисление, которое у нас будет в коде, находится прямо во фрагменте ниже:

        case TRADE_ACTION_REMOVE:
                (*manager).EraseTicketPending(request.order);
                break;

Этот фрагмент срабатывает при удалении ордера, и требует оповещения класса C_Manager, чтобы советник мог успешно отправить отложенный ордер. Обычно отложенный ордер не удаляется из книги заявок, но может случиться так, что это сделал пользователь или трейдер. Если ордер случайно или преднамеренно удален из книги заявок, а советник не уведомляет об этом класс C_Manager, то это помешает советнику отправить еще один отложенный ордер.


Заключение

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

В прикрепленном файле вы найдете код советника, который мы презентовали и объясняли в последних 3-х статьях. Рекомендуем внимательно изучить этот код, чтобы понять, как всё работает на самом деле, так как это очень поможет вам на следующих этапах нашей работы.