MoexBuilder: как я создаю библиотеку на Python. Часть 1
Вступление
Привет, Хабр! Это моя первая статья и в ней я хочу не только поделиться опытом, полученным в ходе реализации собственного проекта, но и услышать обратную связь\критику\предложения\замечания относительно принятых мною решений. Моя статья не предложит вам «подписаться на телегу» или что-то подобное, я просто расскажу о том, чего добился на текущий момент.
О проекте
Идея проекта — создание библиотеки на Python для упрощения работы с ISS MOEX. Если кратко, ISS MOEX — это информационно-статистический сервер Московской Биржи, с которым можно взаимодействовать по протоколу HTTP. Количество реализованных методов впечатляет, с ними можно ознакомиться тут.
Обращаясь к ISS MOEX в различных предыдущих проектах, я задумался о том, что каждый раз реализую примерно одно и тоже — получаю данных о текущих ценах на акции, считаю динамику, строю графики и т.д. В какой-то момент я просто сказал себе «Хватит это терпеть!» и приступил к созданию библиотеки, которую впоследствии можно было бы переиспользовать для различных целей и задач. На текущий момент я решил ограничиться поддержкой российского рынка акций и ключевого индекса — IMOEX. Не исключено, что в будущем список поддерживаемых инструментов и бумаг будет расширяться (если, конечно, проект окажется полезным).
Цель проекта — создать удобный, интуитивно понятный способ взаимодействия с ISS MOEX, реализовав наиболее популярный функционал.
Проблема #1. Определение торговых/рабочих дней в текущем году
Первая проблема с которой я столкнулся — определение торговых/рабочих дней в текущем году. Мне бы хотелось, чтобы объект класса MOEX
реализовывал методы last_trade_day
и is_trading_now
.
Брать «в лоб», например, сегодняшний день нельзя, так как запуск программы может производится и в выходной день, и в таком случае хотелось бы ориентироваться на пятницу, как на последний торговый/рабочий день. При этом, нельзя просто «сместиться» на пятницу, так как надо учитывать, что не все пятницы являются торговыми/рабочими днями, так как среди них могут быть праздничные дни. При этом, праздники могут быть протяженностью хоть в два, хоть в три, хоть в десять дней, что не дает нам возможности точно определить на сколько дней назад требуется произвести смещение (оно будет всегда разным).
Не успев до конца осознать эту проблему я с ужасом понял, что кроме торговых/рабочих дней, которые объявлены праздниками у нас также существуют выходные дни, которые объявлены торговыми/рабочими днями. Такой, например, была суббота — 2024–04–27. Иными словами, если запуск программы производится 2024–04–27, то смещение назад вообще не требуется. Подобные кейсы уничтожают возможность написать что-то вроде:
def is_not_trade_date(check_date: datetime.date) -> bool:
return datetime.weekday(check_date) in (5, 6)
и пойти дальше.
Очевидно, что простого решения тут нет. Вот, что придумал я.
Для начала, потребуется завести несколько констант:
# Calendar
MAX_DAYS_WEEKENDS: int = 15
FIRST_TRADE_DAY: str = '2024-01-08 00:00:00'
WEEKENDS: tuple = (
'2024-01-01',
'2024-01-02',
'2024-01-03',
'2024-01-04',
'2024-01-05',
'2024-02-23',
'2024-03-08',
'2024-04-29',
'2024-04-30',
'2024-05-01',
'2024-05-09',
'2024-05-10',
'2024-06-12',
'2024-11-04',
'2024-12-30',
'2024-12-31'
)
WORKDAYS: tuple = ('2024-04-27', '2024-11-02')
DATE_FRMT: str = '%Y-%m-%d'
TIME_FRMT: str = '%H:%M:%S'
DATETIME_FRMT: str = '%Y-%m-%d %H:%M:%S'
TIME_DAY_START: str = '10:01:00'
TIME_DAY_OVER: str = '18:55:00'
Среди них стоит отметить отдельно:
WEEKENDS
— кортеж, торговых/рабочих дней, которые были объявлены праздничными;WORKDAYS
— кортеж выходных дней, которые были объявлены торговыми/рабочими.
Оперируя данными константами у меня родилось несколько вариантов определения последнего торгового/рабочего дня.
Вариант 1.
Создаем полный перечень всех действительно торговых/рабочих дней.
Иными словами, генерируем список, в котором указаны все дни года (начиная с 2024–01–08) по check_date
включительно, без выходных дней (datetime.weekday(check_date) not in (5, 6)
) и без дат, указанных в кортеже WEEKENDS
. Кроме этого, нужно не забыть добавить даты, указанные в WORKDAYS
и вот мы получили полный перечень всех действительно торговых/рабочих дней. В отсортированном списке максимальным значением и будет последний торговым день. Тестируя данный подход я обнаружил, что кое-что можно оптимизировать — нам точно не требуется получать все даты от начала года. Если мы попадаем на долгие праздники (например, майские). то гарантированно не будет ситуации, что праздничные дни протекают более 14 дней подряд. То есть на практике достаточно генерировать диапазон от check_date-14
до check_date
включительно. Это позволило существенно улучшить производительность вычислений. Вот пример данного решения.
from datetime import datetime, timedelta
from values.constans import WEEKENDS, WORKDAYS, DATE_FRMT
# Дата для проверки, по умолчанию - сегодня
check_date = datetime.today().date()
# Дата начала окна для проверки check_date-14
start_date = check_date - timedelta(days=14)
# Генерируем список всех дат - от check_date-14 до check_date включительно
dates_list = [(start_date + timedelta(days=n)).strftime(DATE_FRMT) for n in range(15)]
# Фильтруем список по признаку, что дата не является объявленным праздником и что дата не суббота/воскресенье
filtered_dates = list(filter(lambda x: x not in WEEKENDS and datetime.weekday(datetime.strptime(x, DATE_FRMT)) not in (5, 6), dates_list))
# Добавляем к полученному списку даты торговых/рабочих дней, которые были выходными и делаем сортировку
sorted_dates = sorted(filtered_dates + list(WORKDAYS))
# Максимальный день из этого списка и есть последний торговый день
last_trade_day = max(sorted_dates)
Вариант 2.
Дело в том, что на практике у нас почти никогда не будет ситуации, где потребуется выбор дня аж check_date-14
. На самом деле почти всегда (даже в праздничные дни) мы получим -2 или -3, так как даже между майскими есть торговые/рабочие дни, а январские праздники мы не учитываем, так как перед ними торговые/рабочие дни были лишь в 2023 году, а мы смотрим лишь на текущий (2024) год. Таким образом, получается, что первый вариант алгоритма производит много вычислений впустую. Можно было бы ограничить смещение 3 или 4 днями, но это кажется рискованным шагом. В результате тестовых запусков и сравнений производительности, родилось следующее.
@classmethod
def is_not_trade_date(cls, check_date: datetime.date) -> bool:
"""
Function to determine the specified day is a trading day or not.
Args:
check_date: specified date for check.
Returns:
result of check.
"""
return check_date in cls.WEEKENDS or (datetime.weekday(check_date) in (5, 6) and check_date not in cls.WORKDAYS)
@classmethod
def get_last_trade_day(cls, start_dt: datetime = datetime.now()) -> dict[str, bool]:
"""
Function which determines last trading day and flag for trading at the moment.
Args:
start_dt: day to start check.
Returns:
First element: last trading day.
Second element: flag for trading at the moment.
"""
if (trade_date := cls.datetime_format(start_dt)) <= cls.datetime_format(cnst.FIRST_TRADE_DAY):
raise ce.InitialDateLessFirstDate(f'First trading day in the year `{cnst.FIRST_TRADE_DAY}`.')
if trade_date.time() < cls.to_time(cnst.TIME_DAY_START):
trade_date: datetime.date = trade_date - timedelta(1)
for _ in range(cnst.MAX_DAYS_WEEKENDS):
if cls.is_not_trade_date(trade_date):
# уточню, что функция get_next_date_for_check делает по сути trade_date - timedelta(1)
trade_date: datetime.date = cls.get_next_date_for_check(trade_date)
else:
is_today_trade_day: bool = trade_date.date() == datetime.today().date()
is_trading_now: bool = is_today_trade_day and (
cls.to_time(cnst.TIME_DAY_START) < trade_date.time() < cls.to_time(cnst.TIME_DAY_OVER)
)
result = {
'trade_date': cls.from_date(trade_date),
'is_trading_now': is_trading_now
}
return result
raise ce.TooManyDaysOffInARow(
f'The number of consecutive days off cannot exceed `{cnst.MAX_DAYS_WEEKENDS}` days.'
)
Кратко опишу, что я тут делаю:
Проверка, что переданная дата (по умолчанию сегодня) больше, чем
FIRST_TRADE_DAY
Если нет, то возбуждаю кастомное исключениеInitialDateLessFirstDate
.Проверка, что текущее время больше, чем время начало торгов на Московской Бирже. Если нет, то торговый день в лучшем случае был вчера.
Начало цикла из максимум 15 попыток. Проверяем указанный день, что он торговый, если нет, то отнимаем 1 день и снова проверяем. Находим ближайший торговый день (в общем случае 2–3 итерации цикла).
Определяем проходят ли торги в данный момент. Для этого проверяем, равен ли текущий день последнему торговому дню и попадает ли текущее время в интервал времени работы Московской Бирже.
Если цикла завершился без возврата результата, то возбуждается кастомное исключение
TooManyDaysOffInARow
.
Данное решение показало лучшую производительность, поэтому я остановился на именно на нем.
Спасибо за внимание!