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

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

Начало

Обчитавшись книгами, статьями, облазив блоги. Было понятно, так не запрыгнуть и не начать грести деньги лопатой. Можно было остановится и купить ETF/Облигации/Фонды, а большую часть денег остановить в подушке безопасности, пить сок и лежать под пальмой.

Но, лёгкий путь решил не выбирать, так ведь совсем неинтересно.

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

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

Встреча

Отправной точкой стала Московская биржа и её api, а если точнее и сухо, то информационно-статистический сервер Московской Биржи / ISS MOEX. 5 слов, а звучит как машина по уничтожению человечества.

На главной странице iss moex предоставлен обширный список запросов без регистрации и смс, без ограничений на вызовы, с менее сносным описанием и все это выдаётся в удобном по выбору формате. Я влюбился ????. Пиши обертку не хочу и анализируй все что душе угодно.

Один из моментов, который я усвоил, за свою маленькую карьеру. Всё уже давно за нас написали, а еще аккуратно задокументировали и уложили с любовью в GitHub, хаха, но нет.

Все ясно, топаем в GitHub с запросом iss moex. Пару часов анализа репозиториев, я понял следующее.

На GitHub выделяются пару лагерей по языкам: Python, Java, Go и C#. Python самый активный лагерь, не удивительно, столько инструментов для анализа. Но, увы хотелось бы что-то сносное на C#. Еще одна причина в копилку, почему я начал этот проект, устранить несправедливость, внеся немного своего говно-кода.

Много заброшенных репозиториев и не удивительно. На странице api биржи, можно подсчитать количество запросов, а именно - 141 и это без учета скрытых запросов, по типу bondization. Для каждого нужно написать класс с запросом, потом еще класс с ответом. Короче говоря, тихий ужас, постараемся так не делать и найти альтернативное решение.

Конструктор путей

Для проблемы, которую описал выше, я нашел/украл решение в виде fluent/dot notation конструктора путей, через методы расширения. Приведу пример. Нужно получить спецификацию инструмента, вызываем по цепочки нужные пути.

Securities().Security("MOEX");

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

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

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

Объект для всех запросов

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

Без паники, снова топаем на страницу с апи, вызываем парочку запросов и любуемся на ответ в виде сырой html страницы. Уже понимаете куда я веду? Все ответы по структуре одинаковы и имеют вид таблицы. Структура следующая:

В именах колонки указан так же тип данных
В именах колонки указан так же тип данных

В структуру, которую описал выше, вписывается любой ответ от московской биржи. Так зачем писать множество подобных ответов, придумывать сотни схожих имен для класса ответа? Честно, я не знаю.

А еще в C# существует один козырь, с помощью которого, подобную структуру будет еще проще описать, но его я оставил на десерт.

База

Ну, наконец-то, душные главы прошли. Одна болтовня и сладкие речи, а результата нет. Пора писать код и оценивать результат.

Начнем. Нужен класс, который будет оперировать с URL. В URL есть 3 части которые будут динамически изменяться. Это пути, параметры и расширение.

Для путей и запросов добавим коллекции

List<string> Paths
IDictionary<string, string> Queries

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

public void AddPath(string path) => Paths.Add(path)
public void AddQuery(KeyValuePair<string, string> query) => Queries.Add(query)

Отлично. Теперь создадим статическую обертку-расширение, в которой будет вызывать предыдущие методы.

public static IIssRequst Path(this IIssRequst request, string path)
public static IIssRequst Query(
  this IIssRequst request, KeyValuePair<string, string> query)
public static IIssRequst Extension(this IIssRequst request, Extension extension)

Теперь можем делать вот такое непотребство.

var request = new IssRequest().Path("engines");

Кручу верчу

Общая концепция понятна, пора перейти к реализации конструктора путей. Делаем так.

public static IIssRequst Securities(this IIssRequst request) 
{ 
  request.AddPath("securities"); 
  return request; 
}

и повторяем 88 раз ????.

Так, а теперь серьезно. Еще раз себе напомним, ЭВМ на нас работает, а не мы на нее.

На странице со списком всех запросов смотрим внимательно. Каждый запрос обернут в </dt>. Остается спарсить все запросы, разбить полные пути на массив и из них выделить только уникальные. Берем в руки HtmlAgilityPack и идем в бой.

.SelectNodes("//dt").SelectMany(node => node.InnerText.Split("/")).Distinct();

Чистим от мусора список с путями, генерируем методы, реализацию оставлю за кадром. За минут 5 у нас готовы все запросы и не надо больше годами писать в issue добавить недостающий запрос. Слава автоматизации ????. Теперь можно реализовать любой запрос из списка на iss moex.

Теперь можем делать вот такие выкрутасы.

var request = new IssRequest().Engines().Engine(Engine.Stock).Markets();
var longRequst = new IssRequest().Statistics()
        .Engines().Engine(Engine.Stock)
        .Markets().Market(StockMarket.Bonds)
        .Path("bondization", "SU25084RMFS3");

Ключ от всех запросов

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

[JsonConverter(typeof(IssResponseJsonConverter))]
public record IssResponse(IDictionary<string, Table> Tables);
public record Table(IEnumerable<Column> Columns, IEnumerable<Row> Rows);
public record Row(IDictionary<string, object> Values);
public record Column(string Name);

Структура та же как я и описывал пару глав назад. Займемся парсингом. Создадим класс для десериализации ответа и унаследуем его от JsonConverter.

Погорим немного о структуре и парсинге ответа в формате json. Первый объект таблица с уникальным именем. Внутри него 3 объекта metadata, columns, data. Columns - это массив с именами колонок, Data - массив с данными с привязкой к номеру колонки.

metadata, прости, но тебя проигнорирую
metadata, прости, но тебя проигнорирую

Структура парсинга следующая: Переходим к таблице, читаем columns, data, превращаем их в Column и Row добавляем в Table и повторяем так пока не прочитаем все таблицы.

Читаем детей

var metadataJtoken = JToken.Load(reader);
var columnsJtoken = JToken.Load(reader);
var dataJtoken = JToken.Load(reader);

Читаем название колонок.

var columns = JArray.Load(columnsJtoken.First.CreateReader())
            .ToObject<IEnumerable<string>>()
            .Select(item => new Column(item.ToPascalCase()));

Читаем данные из строк.

var data = JArray.Load(dataJtoken.First.CreateReader())
            .ToObject<IEnumerable<IEnumerable<object>>>();

После, связываем название колонки и данные в ней.

var rows = data.Select(data => data
            .Zip(columns, (value, column) => new { value, column })
            .ToDictionary(item => item.column.Name, item => item.value))
            .Select(dic => new Row(dic));

Пропускаем все лишнее и возвращаемся к чтению следующей таблицы.

Теперь при десериализации ответа указываем IssResponse и IssResponseJsonConverter сам подтянется к конвертации. Теперь можно читать любой ответ.

var tables = new IssRequest().Engines().Fetch().ToResponse();
var engine = tables["Engines"].Rows[0].Values["Name"];

Это будет работать с любым запросом. Не надо создавать сотни однотипных классов со схожими именами, полями и не нужно их придумывать для каждого ????.

Красота, но можно ли сделать еще лучше? Конечно же! Пристегнитесь мы начинаем погружение.

Хочу то, не знаю что

Для тех кто знал, забыл или не знал. В C# есть тип dynamic. Он на самом деле object, который умеет обходить проверку типа во время компиляции, а значит можно с ним работать как с любым типом, но вовремя выполнения тип все равно будет проверен.

А значит, можно делать так.

dynamic a = 1;
var sum = a + 3;

С другой стороны есть класс ExpandoObject - который предоставляет возможности динамически добавлять члены экземпляров.

dynamic @object = new ExpandoObject();
@object.number = 10;
var sum = @object.number + 3;

Самое интересное, что ExpandoObject наследует IDictionary<string,object>. Значит нам ничего не запрещает сделать так.

var @object = new ExpandoObject() as IDictionary<string, object>
@object["key"] = "value"

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

dynamic @object = new ExpandoObject();
var dictionary = @object as IDictionary<string, object>;
dictionary["key"] = "value";
var value = @object.key;

И это работает, то есть на ходу можно строит класс, который на самом деле словарь ключ/объект. Но пусть это останется магией и волшебством . А еще к ExpandoObject можно привязывать методы.

Ладно, простите, отвлекся. Возвращаемся и вспоминаем, что наш ответ с таблицами - словарь, а еще строки с данными - словарь типа ключ/объект. Так что мешает это все запихнуть в ExpandoObject?

Получаем IssResponse и для каждой таблицы делаем следующее.

dynamic @object = new ExpandoObject();
@object.Columns = table.Columns;

@object.Rows = table.Rows.Select(row =>
{
    dynamic values = new ExpandoObject();
    var prop = values as IDictionary<string, object>;
    row.Values.ToLookup(item => prop[item.Key] = item.Value);

    return values;
}).ToList();

Теперь можно вытащить кролика из шляпы ????.

var response = new IssRequest()
     .Engines().Engine(Engine.Stock)
     .Markets().Fetch().ToDynamic();

var market = response.Markets.Rows[0].Name;

Хочу все и сразу

Вот и подходим к заключительному этапу. К чему один ответ, если их тысячи? Давайте получим их всех!

Хм, прям всю тысячу сразу? А как?

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

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

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

Где INDEX - текущая страница, но в виде полученного объема данных. Если INDEX равен 500 значит мы уже получили 500 элементов. TOTAL - общее количество данных, а PAGESIZE - количество данных на страницу. Добавляем все 3 поля в класс Сursor.

Чтобы наш итератор заработал, нам нужны два метода. Метод который который получает данные из текущей страницы. Метод который переходит на следующую страницу с учетом следующего ограничения: (INDEX + PAGESIZE) < TOTAL.

public bool TryNext()
{
    if (index + pageSize >= total) return false;

    index += pageSize;

    return true;    
}
public IDictionary<string, Table> Current()
{
    var query = new KeyValuePair<string, string>("start", index);
    Request.AddQuery(query);

    return Request.Fetch().ToResponse();
}

Теперь достаем знания, которые запомнили и делаем следующее.

public IEnumerable<IDictionary<string, Table>> Next()
{
    do
    {
        yield return Current();
    } while (TryNext());
}

Добавим ключевое слово yield и вернем IEnumerable. Сообщая то, что метод является итерируемым и его можно перебирать в цикле.

var responses = new IssRequest()
    .Path("history/engines/stock/markets/shares/securities/MOEX")
    .Fetch()
    .ToCursor().Iterator;

foreach (var response in responses)
{
    var table = response["History"].Rows.ElementAt(0).Values["Open"];
}

Теперь мы готовы к парсингу Московской Биржи и к базовому анализу. Но это уже другая история.

Прощание

Фух, вроде рассказал самое нужно. Спасибо что прочитали мою эпопею до конца, простите за столь длинное полотно, ужимал материал как мог.

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

Буду рад оказанной помощи, критике и комментариям. На этом прошу откланиваться, спасибо тебе читатель за то, что уделил мне время.

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


  1. litos
    15.07.2022 05:54
    +2

    А потом в мире что-то меняется и весь индекс падает на 30% вместо роста …


    1. urvanov
      15.07.2022 06:04
      +1

      Или цена на бездумно выбранный ETF FXRB становится равной 0. Всякое бывает. Биржа же.


    1. kuza2000
      15.07.2022 09:25

      А потом в мире что-то меняется и весь индекс падает на 30% вместо роста …

      ... и заработали те, кто в шортах)


    1. mbobka
      16.07.2022 03:02

      Не-а. Как ныли так и продолжают ныть. Ничего не меняется.


  1. jerd3007
    15.07.2022 08:53

    Спасибо за труд. Легко читается, интересный подход к решению. У джуна горят глаза, значит будут свернуты горы)


    1. Kataanee Автор
      15.07.2022 08:54

      Спасибо. Рад стараться, на этом моя эпопея еще не заканчивается. Постараюсь в ближайшее закончить еще один проект и написать о нем.


      1. jerd3007
        15.07.2022 09:10

        Буду ждать. Всегда приятно читать увлеченного грамотного человека.


  1. Palesandr
    15.07.2022 09:27

    Тоже написал шпаргалку для себя. Через xml работал.


  1. Earthsea
    15.07.2022 09:46
    +2

    Рубль достиг своего максимума к евро за 7 лет, и покупать привязанные к нему активы на московской бирже мягко говоря ну очень нежелательно, получается что покупаем на пике, продаем на дне. Если еще и кредитное плечо использовать, то получится полный набор начинающего инвестора.

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

    Для себя я решил, что покупка наличных евро - самое то. Пусть даже по 75 рублей при официальном курсе 58. Разбогатеть и приумножить не получится, но если я купил 1500 евро, то я знаю что у меня лежат 1500 евро. Сколько это в рублях сейчас или через год-два-три-пять мне фиолетово, если рубль еще укрепится то я просто куплю больше евро на ту же сумму в рублях.

    Почему евро а не доллар? Во-первых, я переезжать именно в США не собираюсь. Если лично вы хотите туда, можно брать доллары, промежуточные конвертации в будущем это лишние потери. Если планируете переезжать в страны еврозоны - берите евро. Другие валюты не так доступны в наших банках, да и планы через несколько лет могут поменяться, поэтому если не в страны еврозоны, то все равно евро, но не доллар. Потому что, во-вторых, евро к доллару сильно упал (точнее доллар вырос, но в нашем контексте это неважно), и выгодно покупать как раз евро - через какое-то время он может начать к доллару расти.


    1. zlat_zlat
      15.07.2022 09:50
      +1

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


    1. Nbx
      15.07.2022 16:54
      +1

      Для себя я решил, что покупка наличных евро — самое то.
      А какую цель приследуете?

      С такой инфляцией, хранение наличных (хоть бумажных, хоть виртуальных) так себе стратегия.


      1. mbobka
        16.07.2022 03:03
        +2

        Особенно выбор евро удачен.