Эшу быдлокодит
310 subscribers
138 photos
12 videos
7 files
178 links
Дневник C# разработчика.

Личка: @EshuMarabo
Гитхаб: https://github.com/vladzvx

Стек: C#, PostgreSQL
Download Telegram
Последние двое выходных я провёл на Волге чуть выше Твери. Там уже нет судоходства, количество лодочников приемлемое, более менее адекватные подъезды к воде и должна быть рыба.

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

К выбору точки ловли я впервые за долгие годы подошёл "по науке" от рыболовных блогеров: эхолотом нашел в воде место с перепадом глубин и обе рыбалки бомбил чисто его. Результат на фото: с каждой рыбалки привезли по три килограмма подлещиков.

#рыбалка
#волга
👏8👍6🔥31
#klhztrader. Часть -1. Метрики.

Небольшая заметка на полях о прометеусе. Оставлю тут, чтобы не утратить обретенные знания.

Раньше я многократно пользовался Прометеусом для отображения метрик приложения. Рантайм шарпа по дефолту экспортирует достаточно информативный набор метрик, в т.ч. потребляемая приложением память, в т.ч. с разбивкой по поколениям сборки мусора. Добавлять кастомные метрики, позволяющие мониторить различные аспекты разрабатываемого приложения - более менее стандартная практика. И тут я задался вопросом, а как разделить метрики на группы (смапленные на разные пути: /metrics, /metrics/critical и т.д.), чтобы можно было опрашивать более критичные метрики с большей периодичностью.

Промучавшись некоторое время, я пришёл к следующему решению:
1. Основную группу метрик маппить стандартным способом, дефолтным .MapMetrics() в Program.cs
2. Для своих метрик создать свой CollectorRegistry, дальше методом Metrics.WithCustomRegistry создать экзмепляр фабрики MetricFactory, который заинжектить в приложение.
3. В местах, где требуются нестандартные метрики забирать MetricFactory и с его помощью создавать экземпляры метрик в нужных местах.
4. Отдачу своих метрик в прометеус осуществлять через самописный контроллер, в который инжектится CollectorRegistry. Дальше метрики в качестве text/plain отдаются контроллером.

#prometheus
👍31
#klhztrader. Часть 0. Автодеплой проекта.

Запишу инструкцию по настройке основы самого простого автодеплоя с помощью gitea actions. Она основана на документации, но обходит несколько подводных камней на которые я наткнулся в процессе.

Цель: исполнять bash - команды на удаленном сервере по коммиту в репозиторий, раннер крутится как демон, не в контейнере. При этом, для деплоя нам скорее всего потребуется установленный на сервер докер. Работу без докера я не проверял.

1. Качаем бинарник gitea actions runner с сайта на сервер (для совместимости с доступной мне версией gitea я взял раннер двухлетней давности).
wget https://dl.gitea.com/act_runner/0.2.3/act_runner-0.2.3-linux-amd64

2. Переименовываем скачанный файл в act_runner и разрешаем ему исполняться: chmod +x act_runner

3. Добавляем пользователя act_runner, я выдал ему sudo привилегии.

4. Запускаем ./act_runner register и идём по шагам, вставляя по запросу url gitea, registration token из настроек gitea (settings => actions => runners => Create new Runner), как-то называем раннер, затем нас спрашивают добавить labels для активации. Добавляем my_project_deploy_cmd:host
Это указывает runner-у выполнять джобы, помеченные как my_project_deploy_cmd непосредственно на сервере, а не внутри контейнера.

5. Убеждаемся в интерфейсе gitea - появился ли там созданный раннер.

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

[Unit]
Description=Gitea Actions runner
Documentation=https://gitea.com/gitea/act_runner
After=docker.service

[Service]
ExecStart=/home/my_user/act_runner daemon
ExecReload=/bin/kill -s HUP $MAINPID
WorkingDirectory=/home/my_user
TimeoutSec=0
RestartSec=10
Restart=always
User=act_runner

[Install]
WantedBy=multi-user.target

Дальше как в инструкции:
sudo systemctl daemon-reload
sudo systemctl enable act_runner --now


7. Включаем enable actions в настройках репозитория.
8. Добавляем в корень репозитория папку .gitea, в неё - папку workflows, в нее - файл test.yaml со следующим содержимым:

name: test
on:
- push

jobs:
test:
runs-on: my_project_deploy_cmd
name: test action
steps:
- name: test
run: echo "Hello from Gitea Action11s!" && docker ps -a`


Важно, чтобы в runs-on был указан label, который мы задали в конце шага 4. Будет выполнен вывод текста в командную строку, а затем - запрос вывода статусов всех докер-контейнеров на сервере.

9. Имея в распоряжении командную строку, мы можем проделать любые манипуляции с сервером и завести деплой в том виде, в котором нам удобно.

#gitea
#devops
👍7
#klhztrader, Часть 1. Анонс.

Последние два месяца я все свободное от работы время занимался новым проектом: ботом для торговли на Мосбирже. Предыдущие два поста про метрики и gitea были написаны на этапе подготовки к деплою mvp бота. Все дальнейшие посты, касающиеся торгового бота будут под тегом #klhztrader.

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

Немного поторговав руками я понял, что слишком азартен, надо много работать над собой. Зато при просмотре графиков котировок у меня возникают флешбеки из универа и с первой работы в IT: там мне довольно много приходилось иметь дело с временными рядами.

Обычно такие вещи пишут на питоне, но я в гробу его видал, а под c# нашлось отличное api от T-Bank-а, поставляемое в качестве пакета, подключаемого в проект. Указываешь токен доступа - и можно торговать.

Дальше будет длинный конспект процесса разработки и результаты экспериментов. Работы все ещё активно ведутся, не переключайтесь. Код пока не выкладываю, возможно выложу потом.
🔥132
#klhztrader. Часть 2. Визуализация данных.

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

В шарпе все несколько сложнее. Изначально я хотел вкрячить какую-то визуализацию прямо в бота по принципу server-side rendering. Я потыкал несколько библиотек для построения графиков. Все они предназначены для серьезной разработки, но ведь моя цель - торговый бот, а не классный визуализатор данных!

Сунулся в React.js, с которым я капельку знаком - за два года там чего-то навертели в гайдах по быстрому старту, старт нифига не быстрый. Vue.js оказался сильно дружелюбнее. Но добрый человек @vekhden_speak посоветовал попробовать для визуализации графану (при попытке сложить данные в прометеус, так, чтобы было удобно, и родился один из прошлых постов). В графане я и остался.

В итоге я пришел к тому, что локально на ноуте кручу данные, укладываю результаты экспериментов в таблицу в постгресе и отсматриваю результаты работы на нескольких дашбордах в графане.
🔥4
#klhztrader. Часть 3. Инфраструктура.

Бот хостится на виртуалке, 4 х 2.2ГГц, 4Гб оперативки и HDD на 80 Гб, хостер - ruvds. Пока таких ресурсов хватает с головой.

На сервере в докер композе живут шарповый сервис бота, Prometheus, Loki, Graphana, и PostgreSQL. Для мониторинга состояния сервера в качестве обычного демона установлен прометеусовский node_exporter.

Для автодеплоя используется сервер gitea, на которой меня любезно пустил @ssleg. О моих сражениях с настройкой раннера я писал ранее.

Так как проектом занимаюсь я один, было решено в плане мониторинга и обработки логов остановиться на прометеусовском стеке. Я не очень люблю Loki, но для небольшого проекта он оказался идеальным: диск и оперативку потребляет по минимуму, без проблем стыкуется с графаной. Логов у меня не много: 4-5 записей в секунду, во основном меня интересуют ошибки, при том сортировать/фильтровать их не нужно. А тот же ELK съел бы у меня все ресурсы сервера, не дав ничего нового.

В итоге я собрал симпатичный дашборд для мониторинга состояния сервера. На нем четыре графика:
1. Оперативная память. Два графика - доступная память и потребление моего сервиса.
2. Утилизация CPU. По графику на каждое из ядер + отдельно график доли моего сервиса в общем потреблении.
3. Свободное место на диске.
4. Нагрузка на сеть.

Для экстренного управления сделан тетеграм бот. Он позволяет ребутнуть бота (Environment.Exit), бот вырубается, а затем средствами докера автоматически запускается. Ещё можно включить/выключить реальные покупки и продажи, а также докупить или сбросить активы на бирже.
🔥4👍3
#klhztrader. Часть 4. Архитектура приложения, некоторые особенности реализации.

Я подписываюсь на поток данных биржи (стрим grpc), после чего распространяю эти данные по своему приложению через каналы (Channel<T>), обернутые в абстракцию, чтобы при желании можно было без боли воткнуть туда брокер сообщений и разорвать бота на любое количество процессов.

История торгов за последние несколько часов нужна достаточно часто, тягать ее каждый раз из базы - не вариант. Значит - нужно кеширование. Redis/Tarantool я тащить не стал, сделал самописный кэш. Первые итерации были на основе массивов, но в итоге я пришел к кэшу на базе LinkedList (первый раз использовал его на практике).

Он оказался идеальным решением, чтобы держать в памяти данные за определенный промежуток времени. Добавляем новый элемент с помощью AddLast, а дальше в цикле while удаляем с помощью RemoveFirst всё вылезшее за пределы заданного временного интервала. Доступ к листу идёт через обычный lock. Для вычислений данные копируются в массив, который отдаётся запросившему.

Все используемые мной в проекте модели для хранения данных имеют поля с доступами {get;init;}, что защищает меня от чудес, которые могут проявиться при изменении объектов из нескольких потоков. Можно было бы упороться в readonly struct, но это уже для высоких нагрузок, которых у меня не предвидится.
👍2🔥1
#klhztrader. Часть 5. Некоторые биржевые понятия.

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

Позиция - любой актив, который есть у нас в наличии. Мы можем открыть (войти в) позицию - приобрести некоторое количество активов. Или закрыть (выйти из) - избавиться от нее, получив деньги.

Лонг - позиция, открытая в надежде на рост цены актива.

Шорт - позиция, открытая в надежде на падение цены актива.

Волатильность - нестабильность цены актива.

Брокер - прокладка между частным лицом и Мосбиржей, в моем случае - T-инвестиции.

Маржинальная торговля (маржиналка) - торговля с привлечением заёмных средств брокера. Прибыль или убыток начисляется два раза в рабочий день во время клиринга.

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

Фьючерс - производная от реально существующей ценной бумаги, по дефолту торгуется маржинально. По сути, торговля ведётся ожиданиями на цену.

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

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

Заявки - заявки на покупку или продажу актива. Лимитная заявка - заявка на покупку/продажу не более чем N ценных бумаг по цене M.

Совокупность лимитных заявок образует стакан. Назван он так из-за одной из самых популярных форм отображения, при случае приложу скрин. Заявки можно бесплатно выставлять и отменять сколько угодно раз, комиссия взимается только за исполнение.

Самый быстрый способ что-то купить/продать - сделать рыночную заявку. Если хватает лимитных заявок в стакане, она съедает их оттуда. Соответственно, чтобы сместить цену в какую-то сторону, нужно чтобы часть стакана была съедена рыночными заявками (а часть будет отменена пользователями и роботами). Если лимитных заявок в стакане не хватает на удовлетворение рыночной заявки - будет выброшена ошибка.
5👍3
#klhztrader. Часть 6. Выбор первого актива для торговли.

Основным активом, на торговле которым я пока сосредоточился, стал фьючерс на индекс Мосбиржи (IMOEXF). Индекс представляет собой расчетный актив (то есть в природе такого актива, привязанного к конкретной компании не существует).

В индекс вносят вклад ценные бумаги крупнейших компаний российского рынка: Сбер, Газпром, Лукойл и т.д. По большому счету, он является индикатором состояния российского рынка в целом. Торгую я фьючерсом на него, т.е. ожиданиями на цену несуществующей фигни:)

Выбор обусловлен несколькими факторами, основной из которых - так сложились звёзды. Кроме того, у него нашлось ещё несколько преимуществ:
1. Всегда когда открыта биржа, кто-то им торгует.
2. У него интересное соотношение минимального шага цены к комиссии. Прибыль может принести практически любое шевеление цены, если поймаешь конечно:)
3. Как следствие п. 1 - в стакане зазор между максимальной ценой покупки и минимальной ценой продажи (спред) равен минимальному шагу цены, что упрощает логику торговли.
4. У него очень часто проявляется выраженная периодическая компонента. Я подумал: уж ее то я с преобладанием Фурье кааак выловлю!

Цена фьючерса представлена в пунктах (пт), 1 пт = 10 рублей.
🔥5
#klhztrader. Часть 7. Чудеса во взаимодействии с биржей.

Первое что я стал запрашивать - актуальные значения цен активов. Подавляющее большинство графиков с биржи представлено в виде красивеньких свечей. Биржа и брокер свободно отдают историю свечей любого размера, от 5 сек до суток, и есть даже метод для подписки на них. Конечно же я им воспользовался! Но оказалось, что есть нюанс: свечи приходят с задержкой в минуту и больше.

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

И только третья попытка оказалась пригодна для торговли в реальном времени: подписка сделки по активу.

Еще были увлекательные приключения с учётом активов. Два раза в рабочий день - около 14 и 19 часов происходит клиринг, в результате которого начисляется вариационная маржа на фьючерсы. Цена, относительно которой маржа будет начислена после закрытии позиции или во время следующего клиринга меняется, но в данных, отдаваемых биржей, не фигурирует. Я подписался на стрим моих операций, чтобы отлавливать момент входа/выхода из позиции, но вот проблема, брокер иногда отдаёт данные об открытии/закрытии позиции по два раза.

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

В итоге, учёт активов и отладка открытия закрытия стоили мне около 10 тыс потерь. То непередаваемое чувство, когда бот вовремя закрывает лонг, всё отлично. Но случайно открываются два шорта, которые тебя обилечивают рублей на 500-600 за несколько минут. Я отказался от самописного учёта активов, есть единственный источник истины - биржа. И сделал всё взаимодействие с биржей строго однопоточным в рамках каждого из счетов, через блокировку. Запрос, ответ, синхронизация портфеля, пропускаем следующий запрос. А проблему переоценки фьючерсов во время клиринга я решил радикально: за несколько минут до него я тупо сбрасываю все фьючерсы вне зависимости от цены. И судя по тому, как периодически колбасит цену за несколько минут до клиринга, так делает не только мой бот:)
👍5😁1
#klhztrader. Часть 8. Первая торговая стратегия.

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

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

Суть стратегии следующая: берутся ~2 последние минуты истории торгов, делятся на два интервала: (-120,-10] и (-10,0] секунд, которые аппроксимируются прямыми. В зависимости от взаимного направления прямых открывается шорт или лонг. Критерий значимости точки - небольшое изменение цены в левой прямой (-1,+1) и резкое - в правой <-1.5 или >1.5.

В течение пары часов бот торганул на 600р в плюс, потом на 800р в минус, после чего был загашен до релиза следующей стратегии.
👍4😁3
#klhztrader. Часть 9. Стратегия скользящих средних.

Это - одна из базовых стратегий для алготрейднинга. Вычисляются среднее значение цены на бОльшем и меньшем временных интервалах. Точки пересечения графиков этих средних - потенциальные точки входа и выхода.

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

В итоге, около месяца было потрачено на безуспешные попытки вывести эту стратегию в плюс. Дальше будут описаны несколько костылей, которые я перепробовал и которые были полезны с т.з. саморазвития.
😁4👍2
#klhztrader. Часть 10.1. Вводная про преобразование Фурье.

Самые увлекательные приключения у меня были с преобразованием Фурье.

Преобразование Фурье, если совсем просто, это представление сигнала в виде набора периодических функций с разными частотами. Было у нас по оси x время, а по оси y - значение. Выполнили прямое преобразование Фурье - получили по оси x - частоту, по оси y - амплитуду периодической функции. Одно из названий результата - спектр. Выполнили обратное преобразование - получили исходный сигнал. Если к спектру применить какую-то трансформирующую функцию (фильтр), то после обратного преобразования сигнал изменится. Например, если обнулить высокие частоты - сигнал почистится от шумов. Довольно обыденная вещь, владеть которой должен любой инженер.

Но есть некоторые нюансы, которые я изложу ниже. Самое первое, что поджидает бодрую личинку инженера, сдавшую курс матана и, возможно, поигравшуюся со спектроанализатором, при попытке использовать готовое преобразование Фурье из любого из математических пакетов - спектр НЕ ТОТ.

Реализованное во всех математических пакетах быстрое дискретное преобразование Фурье (FFT) выдаёт спектр ощутимо отличающийся от привычного. В привычном случае у нас ось частот (x) идёт слева направо, мы рисуем ее пересечение с осью амплитуд частотных компонент в нуле и радуемся жизни. В результате быстрого дискретного преобразования осей частот по факту две. Одна идёт с начала массива, содержащего результат преобразования и занимает его первую половину. По ней идут положительные частоты. Вторая начинается в конце массива, и идет к середине, соответствует отрицательным частотам и занимает вторую половину массива с результатом преобразования. Наглядную иллюстрацию выложу в следующем посте.

Соответственно, накладывая фильтры на спектр, надо помнить об отрицательных частотах и воздействовать на них согласно задаче.

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

P.S. Раньше я касался преобразования фурье в далёком 2021 году, когда ещё не забросил диссертацию.
🔥3
Иллюстрация к предыдущему посту. На верхней части рисунка - классическое представление спектра, в том виде, в котором он предстаёт на экранах спектроанализаторов и в котором осознается на парах матана.

А на нижней части я извратил исходный график до вида, в котором он выдаётся из FFT.
🔥5
#klhztrader. Часть 10.2. Попытки применения преобразования Фурье в торговле.

Первое что бросается в глаза при просмотре графика цен бумаг, которыми активно торгуют (например IMOEXF) - некая периодичность, когда рост сменяется падением и так по кругу. Я попробовал применить преобразование Фурье для решения задачи "Куда не может пойти IMOEXF в ближайшее время?".

Алгоритм решения задачи примерно такой:
1. С помощью пакета MathNet.Numerics выполняем преобразование Фурье.
2. Выбрасываем из спектра низкочастотные и высокочастотные компоненты (оставляем диапазон [60, 2] минут).
3. Рассчитываем обратное преобразование для полученного спектра на основе суммы косинусов фаз гармоник, умноженных на их амплитуду.
4. Рассчитываем обратное преобразование для производных гармоник спектра на основе суммы синусов фаз гармоник, умноженных на их амплитуду, от него оставляем только знак, чтобы понимать, мы сейчас растем или убываем.
5. Проверяем к какой части относится последняя точка - текущее время.

На рисунке ниже приведены некоторые ответы, который мне давало преобразование Фурье:
1. Если последняя точка попадает в первый дециль (фиолетовые точки) и имеет положительный знак - расти уже некуда, но предпосылок для падения пока нет.
2. Если последняя точка ниже медианы (светло-красные точки) и знак отрицательный - то в ближайшее время график будет убывать.
3. Если последняя точка в нижнем дециле (ярко-красные точки) и знак отрицательный - то убывать уже некуда, но предпосылок для роста не наблюдается.


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

Соответственно, пока место у преобразования Фурье графика цены - на свалке в архиве, а единственная (при том дальняя) перспектива - максимизация профита, чтобы входить-выходить из позиции в нужную фазу колебания, добавляя 2-3 пункта к результату. Я отложил его до лучших времен и двинулся к следующей стратегии, о которой расскажу отдельно.
🔥5
price - зеленый график - цена IMOEXF
fft_lower - светло-красные точки - места, где график скорее всего будет только убывать.
fft_lower_10 - ярко-красные точки - места, где графику убывать некуда, но расти пока тоже не будет.
fft_upper_10 - фиолетовые точки - места, где графику расти некуда, но убывать пока тоже не будет.
👍1👏1
Все свои 7.5 лет в айти я забивал насколько мог на форматирование sql кода. Маленькими буквами? Или капсом? Правильные отступы? Да нахрен надо!

И вот настал день: передо мной простыня sql кода на 7 тыс строк. При том на оракловом диалекте sql который я не знаю от слова совсем.

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

Люди, которые писали хранимки, составляющие этот пакет, выдерживали жёсткие стандарты оформления кода. В итоге, большая часть моих проблем решилась написанием простенького парсера, который собрал мне примерную картину. На написание парсера ушло около 1.5 часов.

А будь там sql в моем стиле - я угрохал бы пару дней как минимум.
🔥13👍1
#klhztrader. Часть 11. Уровни поддержки и сопротивления.

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

Каким путем я пошёл:
1. Вычисляется гистограмма графика цены за определенный период времени (у меня это 2-3 часа).
2. Выполняется свертка гистограммы с ядерной функцией - параболой. В оригинале результат свертки - что-то вроде интеграла произведения двух графиков. Мне лучше подошло стандартное отклонение между гистограммой и ядром, умноженным на максимальное значение гистограммы в области пересечения.
3. Выделяются пики в гистограмме, берутся первые 4. Если пики накладываются друг на друга - они объединяются в один.

Полученные границы выделенных я объявил уровнями поддержки/сопротивления.

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