├── .busted ├── .gitattributes ├── CHANGELOG.md ├── README.md ├── hacktrade.lua ├── robots ├── reverse.lua └── spreader.lua ├── templates └── minimal.lua └── tests.lua /.busted: -------------------------------------------------------------------------------- 1 | return { 2 | default = { 3 | verbose = true, 4 | ROOT = {"tests.lua"} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | В данном файле будет вестись вся история проекта. 3 | 4 | Формат построен на основе [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | версии проекта ведутся в соответствии с [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## 5.0.1 8 | ### Багфиксы 9 | - завершение fill при ошибке в OnTransReply - ранее продолжался polling => заявки пытались выставиться повторно 10 | 11 | ## 5.0.0 12 | ### Добавлено 13 | - поддержка торговли USA (мосбиржа) и ETF (проверено на finex etfs) 14 | ### Изменения 15 | - номера счетов необходимо задавать без завершающего "/", он добавится автоматически. Не backward-compatible! 16 | ### Багфиксы 17 | - !!! при торговле с нескольких счетов некорректно загружались размеры позиций по инструментам, одновременно используемых на нескольких счетах 18 | 19 | ## 4.0.0 20 | ### Добавлено 21 | - 2: order:load для загрузки текущих позиций по таблицам quik'а 22 | - order:fill для ожидания выполнения ордера 23 | - 8: повторные попытки вытащить данные для Indicator 24 | - ServerInfo для вытаскивания серверных параметров 25 | - Код рынка для ETFs 26 | - поддержка Except 27 | ### Изменения 28 | - теперь лог открывается в режиме "a" (append), чтобы записи добавлялись в конец. Раньше при новом открытии запись шла поверх имеющихся данных 29 | - более детальный логгинг 30 | - отправка заявки по рынку при нулевой цене в SmartOrder 31 | - исправлен тест падающий в lua5.3/5.4 32 | - отбрасывание дробной части .0 при вызове sendTransaction 33 | - копируются данные индикаторов вместо их изменения in-place 34 | 35 | ## 3.0.0 36 | ### Изменения 37 | - 7: при запросе поля ind.values[-1] возвращается таблица всех имеющихся значений. Раньше возвращалось значение "close", backward incompatible изменение 38 | - тесты снятия заявки при изменении SmartOrder 39 | - баг невозможности запроса несуществующего индекса в Indicator 40 | 41 | ### Багфиксы 42 | - backport корректной работы при одновременном приходе OnTransReply/OnOrder в момент sendTransaction 43 | 44 | ## 2.0.0 45 | ### Добавлено 46 | - базовое покрытие тестами 47 | 48 | ### Изменения 49 | - при работе с MarketData возвращается quantity вместо volume для консистентности с getQuoteLevel2 50 | 51 | ### Багфиксы 52 | - при работе с OnTransReply ordernum заменен на order_num в соответствии с документацией quik 53 | - при работе с getQuoteLevel2 volume заменен на quantity в соответствии с документацией quik 54 | 55 | ## [1.4] 56 | Форкнутая версия от заброшенного 5 лет назад https://github.com/hacktrade/hacktrade 57 | Вариант развития проекта [BetterQuik](https://github.com/BetterQuik/framework) видел, но не оценил, поэтому форкнул более простой для понимания идей автора исходный вариант 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HackTrade 2 | ============= 3 | Фреймворк для написания торговых роботов 4 | 5 | Краткое руководство 6 | ------------------- 7 | В данном руководстве рассказано из каких частей состоит торговый робот. Руководство расчитано на пользователя с начальными знаниями программирования или языка **Lua**. 8 | 9 | Итак, минимальный робот в **HackTrade** состоит из 3-х элементов: 10 | 11 | 1. Исполнения основного модуля 12 | 2. Определения основной функции 13 | 3. Вызова функции **Trade**, для осуществления торговых действий 14 | 15 | ```lua 16 | dofile("hacktrade.lua") 17 | 18 | function Robot() 19 | Trade() 20 | end 21 | ``` 22 | 23 | Фреймворк состоит из одного файла [hacktrade.lua](hacktrade.lua). Для инициализации фреймворка вы должны в первую очередь исполнить файл с его кодом. Для этого используется стандартная функция **dofile** в которой указывается путь к файлу (в приведённом примере фреймворк находится в той же папке, что и робот). 24 | 25 | Следующий шаг - объявление функции **Robot**. Эта функция должна содержать код робота, при этом она выполнена в формате сопрограммы и может быть явно прервана на любом этапе выполнения. Это позволяет фреймворку заходить в функцию и продолжать её выполнение, тем самым вы не должны явно вставлять код обработки заявок и получения данных, фреймворк это сделает за вас, только передайте ему управление. Такой подход к написанию робота позволяет вам без лишних управляющих структур запрограммировать конечный автомат. Это даёт возможность создавать предсказуемых и наджных роботов. 26 | 27 | Чтобы передать управление фреймворку и обработать заявки объявлена специальная функция **Trade**. Когда вы вызываете эту функцию, выполнение функции **Robot** прерывается и фреймворк получает управления для совершения полезной работы: пересчёт заявок, выставление и снятие заявок. Когда фреймворк закончит полезную работу, то он снова вызовет функцию **Robot** с того же места, где последний раз вызывалась функция **Trade**. 28 | 29 | Когда в функции **Robot** исполнятся все строки, робот завершится. Поэтому робот, приведённый выше, завершиться практически сразу после запуска. Чтобы ваш робот торговал продолжительное время, вам необходимо создать бесконечный цикл, в котором вызывается функция **Trade**: 30 | 31 | ```lua 32 | dofile("hacktrade.lua") 33 | 34 | function Robot() 35 | while true do 36 | Trade() 37 | end 38 | end 39 | ``` 40 | 41 | Теперь ваш робот будет работать, пока вы его явно не остановите. 42 | 43 | Рыночные данные 44 | --------------- 45 | 46 | Для получения рыночных данных доступны два типа объектов: **MarketData** и **Indicator**. Первый позволяет получить данные о последней сделке и стакане, а второй информацию с графиков. Добавим объект **MarketData** к нашему роботу: 47 | 48 | 49 | ```lua 50 | dofile("hacktrade.lua") 51 | 52 | function Robot() 53 | 54 | feed = MarketData{ 55 | market = "MCODE", 56 | ticker = "TCKR", 57 | } 58 | 59 | while true do 60 | Trade() 61 | end 62 | end 63 | ``` 64 | 65 | Для создания экземпляра объекта необходимо передать параметры: *код рынка* и *тикер*. В результате создаётся объект и сохраняется в переменную **feed**. Экземпляр будет использоваться для получения цены последней сделки. 66 | 67 | Чтобы получить различные параметры торгового инструмента, обратитесь к атрибутам объекта **feed**; 68 | 69 | ```lua 70 | feed.last -- Цена последней сделки 71 | feed.quantity -- Количество в лотах 72 | feed.high -- Максимальная цена за торговую сессию 73 | feed.bid -- Лучшая цена спроса 74 | feed.offer -- Лучшая цена предложения 75 | feed.sec_scale -- Точность цены 76 | feed.sec_price_step -- Минимальный шаг цены 77 | ``` 78 | 79 | Доступны и другие параметры, названия которых вы можете посмотреть в документации к терминалу **QUIK**. 80 | 81 | Умные заявки 82 | ------------ 83 | 84 | В даннмом разделе представлена реализация stateful-заявок, которые помнят своё внутреннее состояние и могут динамически принимать изменения параметров. Этот подход сильно отличается от традиционных лимитных заявок. 85 | 86 | На данный функционал вдохновила система **QATCH**. Заявка в фреймворке **HackTrade** представляет собой динамическую лимитную заявку с изменяемой ценой. При этом также может меняться количество и даже направление. Главное, то нужно запомнить, "умная заявка" будет пытаться набирать указанное количество лотов по заданной цене. Даже если вы снимите заявку торговой систем, **SmartOrder** породит новую и продолжить добирать заданное ранее количество (представьте, что **SmartOrder** - это заявка, которая гарантированно набирает зданный объём). 87 | 88 | Для большинства роботов на хватит одной умной заявки, но вы можете создавать их больше, например для арбитражной стратегии. где для каждого инструмента должна быть своя умная заявка, либо для маркет-мейкера, когда в рынке стоят сразу несколько заявок. Давайте создадим одну такую заявку: 89 | 90 | ```lua 91 | dofile("hacktrade.lua") 92 | 93 | function Robot() 94 | 95 | feed = MarketData{ 96 | market = "MCODE", 97 | ticker = "TCKR", 98 | } 99 | 100 | order = SmartOrder{ 101 | market = "MCODE", 102 | ticker = "TCKR", 103 | account = "ACNT1234567890", 104 | client = "775533", 105 | } 106 | 107 | while true do 108 | Trade() 109 | end 110 | end 111 | ``` 112 | 113 | Для создания заявки требуется указать: рынок, тикер с которыми будет работать заявка, а также номер счёта и код клиента, чтобы определить, от имени какого пользователя и по какому счёту будет торговать система (если у вас много счетов, можете создать одного универсального работа, либо склонировать его и адаптировать под разные счета и инструменты). 114 | 115 | Чтобы открыть позицию требуемого размера вам нужно вызвать метод **update** указав цену и желаемое количество: 116 | 117 | ```lua 118 | order:update(123.5, 15) 119 | ``` 120 | 121 | В приведённом примере заявка будет покупать *15* лотов по *123.5*. Допустим, заявка заполнена на *9* лотов, но рынок сильно ушёл не в вашу пользу. Не проблема, вы можете исполнить следующий код: 122 | 123 | ```lua 124 | order:update(131.0, 15) 125 | ``` 126 | 127 | Теперь "умная заявка" снимет лимитную заявку на *15* лотов по цене *123.5* (которая заполнена на *9* лотов) и выставит новую по цене *131* и с количеством *6*; фактически заявка докупит лоты по новой цене. 128 | 129 | Но если вы увеличите или уменьшите количество (а не только цену), то заявка будет докупать или ликвидировать часть позиции. Например, предыдующая заявка была исполнена и вы хотите уменьшить позицию: 130 | 131 | ```lua 132 | order:update(129.5, 10) 133 | ``` 134 | 135 | В таком случае выставится лимитная заявка на продажу (!) *5* лотов по цене *129.5*. Фактически, заявка приводит вашу позицию к желаемому значению по указанной цене. 136 | 137 | Были рассмотрены длинные позиции. Чтобы указать заявке занять короткую позицию передайте отрицательное количество: 138 | 139 | ```lua 140 | order:update(129.5, -10) 141 | ``` 142 | 143 | Если бы предпоследнее указание было бы исполнено и у нас было *10* лотов (лонг), то последнее указание бы инициировало продажу 20 (!) лотов. То есть заявка бы заяняла позицию шорт на 10 лотов, при этом цена сделки не изменилась с предыдущего шага. 144 | 145 | Если захотите открыть длинную позицию, просто передайте полоительное значение. Даже если короткая позиция открыта не полностью, заявка это учтёт. 146 | 147 | Перейдём к нашему роботу: конечно же, вы можете получать цену из **feed**! Пример: 148 | 149 | 150 | ```lua 151 | dofile("hacktrade.lua") 152 | 153 | function Robot() 154 | 155 | feed = MarketData{ 156 | market = "MCODE", 157 | ticker = "TCKR", 158 | } 159 | 160 | order = SmartOrder{ 161 | market = "MCODE", 162 | ticker = "TCKR", 163 | account = "ACNT1234567890", 164 | client = "775533", 165 | } 166 | 167 | while true do 168 | order:update(feed.last, 1) 169 | Trade() 170 | end 171 | end 172 | ``` 173 | 174 | Робот просто откроет лонг в *1* лот по цене последней сделки. **Внмание!** Так как у нас обновление заявки происходит в цикле, периодически передавая управление фреймворку, то при изменении цены последней сделки наш робот будет подстраивать цену лимитированной заявки, пока не получит долгожданный лот. Фактически, робот догоняет цену! 175 | 176 | Теперь нужно сделать логику более полезной, привязав принятие решений об открытии позиций к торговым индикаторам. В дополнение скажу, что на умных заявках не сложно реализовать ступенчатые заявки, такие как айсберг (очень простой пример): 177 | 178 | ```lua 179 | while true do 180 | if order.position == 0 then 181 | order:update(feed.last, 10) 182 | elseif order.position == 10 then 183 | order:update(feed.last, 20) 184 | end 185 | Trade() 186 | end 187 | ``` 188 | 189 | Получение данных индикаторов 190 | ---------------------------- 191 | 192 | Даваайте дополним робота техническим индикатором (скользящей средней). Для этого воспользуемся классом **Indicator** (формально в **Lua** нет классов, в фреймворк добавлено немного магии с метатаблицами): 193 | 194 | ```lua 195 | ind = Indicator{tag = "MYIND"} 196 | ``` 197 | 198 | Для создания индикатора требуется указать тег - идентификатор, который можно привязать к графику в терминале **QUIK** (как это сделать, посмотрите в документации). Теперь вы можете получать доступ к значениям индикатора. существует много способов, так как индикаторы иногда состоят из множества линий. Также раелизована обратная индексация, когда значения можно получать с конца не зная количество значений (свечек). Примеры: 199 | 200 | ```lua 201 | ind[1] -- Первое значение цены закрытия (синоним ind.closes_0[1]) 202 | ind[-1] -- Последнее значение цены закрытия (синоним ind.closes_0[-1]) 203 | ind.values[1] -- Таблица со всеми параметрами первой свечки 204 | ind.values[-1] -- Таблица со всеми параметрами последней свечки 205 | ind.closes[-2] -- Предпоследнее значение цены закрытия 206 | ind.closes_0[-2] -- Нулевая линия, предпоследнее значение цены закрытия 207 | ind.opens_1[-5] -- Первая линия, цена открытия 5-й свечки с конца 208 | ind.values_1[-1] -- Первая линия, таблица со всеми параметрами последней свечки 209 | ``` 210 | 211 | Обратите внимание, что вам вернётся столько значений, сколько задано в параметрах графика. Не добавляйте на график слишком много значений, это может замедлить работу программы. 212 | 213 | Давайте дополним нашего робота реакцией на положение цены последней сделки относительно индикатора: 214 | 215 | ```lua 216 | dofile("hacktrade.lua") 217 | 218 | function Robot() 219 | 220 | feed = MarketData{ 221 | market = "MCODE", 222 | ticker = "TCKR", 223 | } 224 | 225 | order = SmartOrder{ 226 | market = "MCODE", 227 | ticker = "TCKR", 228 | account = "ACNT1234567890", 229 | client = "775533", 230 | } 231 | 232 | ind = Indicator{tag = "MYIND"} 233 | 234 | while true do 235 | if feed.last > ind[-2] then 236 | order:update(feed.last, 1) 237 | elseif feed.last < ind[-2] then 238 | order:update(feed.last, -1) 239 | end 240 | Trade() 241 | end 242 | end 243 | ``` 244 | 245 | Чтобы закрыть позицию вы можете, при определённых условиях, выполнить код: 246 | 247 | ```lua 248 | order:update(feed.last, 0) 249 | ``` 250 | 251 | Робот готов. Это классический реверс по скользящей средней. Вы можете менять значение графика в реальном времени. Робот подстроится! 252 | 253 | В качестве дополнения давайте посмотрим робота, который покупает на дешевле цены последней сделки и продаёт дороже (приведён только основной цикл): 254 | 255 | ```lua 256 | while true do 257 | if feed.last > ind[-2] then 258 | order:update(feed.last, 1) 259 | elseif feed.last < ind[-2] then 260 | order:update(feed.last, -1) 261 | end 262 | Trade() 263 | end 264 | ``` 265 | 266 | Немного о терминале QUIK 267 | ------------------------ 268 | При разработке рекомендуется использовать `lua5.3` т.к. именно это версия используется в свежих QUIK 269 | 270 | 271 | Разработчикам 272 | ------------- 273 | Код покрыт тестами, использующими [busted](https://olivinelabs.com/busted/), для которого есть хорошая документация и подробно описан процесс [установки](https://olivinelabs.com/busted/#usage). Запускаются они следующим образом: 274 | 275 | ```bash 276 | $ busted 277 | ``` 278 | Можно также удобно просмотреть список всех проверяемых сценариев без фактического запуска 279 | ```bash 280 | $ busted -l 281 | ``` 282 | 283 | Участие в проекте 284 | ----------------- 285 | Уже возможно, присылайте патчи. Просьба весь изменяемый функционал покрывать автотестами. 286 | Позже опишу более простой процесс с Pull Request. 287 | -------------------------------------------------------------------------------- /hacktrade.lua: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ffeast/hacktrade/42c9bee474b48b22aa3d9412b61769f02e577485/hacktrade.lua -------------------------------------------------------------------------------- /robots/reverse.lua: -------------------------------------------------------------------------------- 1 | dofile("../hacktrade.lua") 2 | 3 | function Robot() 4 | 5 | feed = MarketData{ 6 | market = "QJSIM", 7 | ticker = "SBER", 8 | } 9 | 10 | order = SmartOrder{ 11 | account = "NL0011100043", 12 | client = "74808", 13 | market = "QJSIM", 14 | ticker = "SBER", 15 | } 16 | 17 | ind = Indicator{ 18 | tag = "MAVG", 19 | } 20 | 21 | size = 1 22 | 23 | while true do 24 | if feed.last > ind.closes[-1] then 25 | order:update(feed.last, size) 26 | else 27 | order:update(feed.last, -size) 28 | end 29 | Trade() 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /robots/spreader.lua: -------------------------------------------------------------------------------- 1 | dofile(getScriptPath() .. "\\hacktrade.lua") 2 | 3 | function Robot() 4 | 5 | feed = MarketData{ 6 | market = "QJSIM", 7 | ticker = "SBER", 8 | } 9 | 10 | order = SmartOrder{ 11 | account = "NL0011100043", 12 | client = "74924", 13 | market = "QJSIM", 14 | ticker = "SBER", 15 | } 16 | 17 | while working do 18 | repeat 19 | order:update(feed.bids[1].price, 3) 20 | Trade() 21 | until order.filled 22 | repeat 23 | order:update(feed.offers[1].price, 0) 24 | Trade() 25 | until order.filled 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /templates/minimal.lua: -------------------------------------------------------------------------------- 1 | dofile("../hacktrade.lua") 2 | 3 | function Robot() 4 | 5 | feed = MarketData{ 6 | market="", 7 | ticker="" 8 | } 9 | 10 | order = SmartOrder{ 11 | account="", 12 | client="", 13 | market="", 14 | ticker="", 15 | } 16 | 17 | ind = Indicator{ 18 | tag="" 19 | } 20 | 21 | while true do 22 | Trade() 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /tests.lua: -------------------------------------------------------------------------------- 1 | describe("hacktrade", function() 2 | before_each(function() 3 | dofile("hacktrade.lua") 4 | stub(log, "open") 5 | stub(log, "close") 6 | stub(log, "log") 7 | end) 8 | 9 | describe("при запуске робота", function() 10 | 11 | before_each(function() 12 | _G.Robot = nil 13 | _G.Start = nil 14 | _G.Stop = nil 15 | _G.sleep = nil 16 | _G.sendTransaction = nil 17 | end) 18 | 19 | it("происходит корректный возврат из Trade()", function() 20 | local called = false 21 | _G.Robot = function() 22 | Trade() 23 | called = true 24 | end 25 | main() 26 | assert.is.truthy(called) 27 | end) 28 | 29 | it("Start()/Robot()/Stop() выполняются в нужном порядке", function() 30 | local calls = {} 31 | _G.Start = function() 32 | table.insert(calls, "Start") 33 | end 34 | _G.Robot = function() 35 | table.insert(calls, "Robot") 36 | end 37 | _G.Stop = function() 38 | table.insert(calls, "Stop") 39 | end 40 | main() 41 | assert.are.same(calls, {"Start", "Robot", "Stop"}) 42 | end) 43 | 44 | it("робот завершается при сигнале остановки от quik", function() 45 | local resumed = false 46 | _G.Robot = function() 47 | Trade() 48 | OnStop(true) 49 | Trade() 50 | resumed = true 51 | end 52 | main() 53 | assert.is.falsy(resumed) 54 | end) 55 | end) 56 | 57 | describe("при загрузке имеющихся позиций в SmartOrder", function() 58 | local order 59 | 60 | local trade = function() 61 | order:load() 62 | end 63 | 64 | before_each(function() 65 | _G.getNumberOf = nil 66 | _G.getItem = nil 67 | _G.Robot = trade 68 | _G.getNumberOf = function() 69 | return 1 70 | end 71 | _G.getSecurityInfo = function(class_code, sec_code) 72 | if class_code == 'TQBR' and sec_code == order.ticker then 73 | return { 74 | lot_size = 10 75 | } 76 | end 77 | return nil 78 | end 79 | end) 80 | 81 | describe("при работе с неизвестными рынками", function() 82 | before_each(function() 83 | order = SmartOrder{ 84 | market = "NO_SUCH_MARKET", 85 | ticker = "T1", 86 | account = "A1", 87 | client = "C1" 88 | } 89 | end) 90 | 91 | describe("при загрузке", function() 92 | it("возникает ошибка", function() 93 | assert.has_error(function() main() end) 94 | end) 95 | end) 96 | end) 97 | 98 | describe("при работе с акциями", function() 99 | before_each(function() 100 | order = SmartOrder{ 101 | market = "TQBR", 102 | ticker = "T1", 103 | account = "A1", 104 | client = "C1" 105 | } 106 | end) 107 | describe("при наличии совпадающих лонгов с того же счета", function() 108 | before_each(function() 109 | _G.getItem = function() 110 | return { 111 | sec_code = "T1", 112 | limit_kind = 2, 113 | currentbal = 10, 114 | client_code = "C1" 115 | } 116 | end 117 | main() 118 | end) 119 | 120 | it("они загружаются", function() 121 | assert.are.equal(order.planned, 1) 122 | end) 123 | 124 | it("filled корректно считается", function() 125 | assert.is_truthy(order.filled) 126 | end) 127 | end) 128 | 129 | describe("при наличии совпадающих лонгов с другого счета", function() 130 | before_each(function() 131 | _G.getItem = function() 132 | return { 133 | sec_code = "T1", 134 | limit_kind = 2, 135 | currentbal = 10, 136 | client_code = "C2" 137 | } 138 | end 139 | main() 140 | end) 141 | 142 | it("они не загружаются", function() 143 | assert.are.equal(order.planned, 0) 144 | end) 145 | end) 146 | 147 | describe("при наличии совпадающих шортов с того же счета", function() 148 | before_each(function() 149 | _G.getItem = function() 150 | return { 151 | sec_code = "T1", 152 | limit_kind = 2, 153 | currentbal = -10, 154 | client_code = "C1" 155 | } 156 | end 157 | main() 158 | end) 159 | 160 | it("они загружаются", function() 161 | assert.are.equal(order.planned, -1) 162 | end) 163 | 164 | it("filled корректно считается", function() 165 | assert.is_truthy(order.filled) 166 | end) 167 | end) 168 | 169 | end) 170 | 171 | describe("при работе с фьючами", function() 172 | 173 | before_each(function() 174 | order = SmartOrder{ 175 | market = "SPBFUT", 176 | ticker = "T1", 177 | account = "A1", 178 | client = "C1" 179 | } 180 | end) 181 | 182 | describe("при наличии лонгов", function() 183 | before_each(function() 184 | _G.getItem = function() 185 | return { 186 | seccode = "T1", 187 | totalnet = 1 188 | } 189 | end 190 | main() 191 | end) 192 | 193 | it("они загружаются", function() 194 | assert.are.equal(order.planned, 1) 195 | end) 196 | 197 | it("filled корректно считается", function() 198 | assert.is_truthy(order.filled) 199 | end) 200 | end) 201 | 202 | describe("при наличии шортов фьючей", function() 203 | before_each(function() 204 | _G.getItem = function() 205 | return { 206 | seccode = "T1", 207 | totalnet = -1 208 | } 209 | end 210 | main() 211 | end) 212 | 213 | it("они загружаются", function() 214 | assert.are.equal(order.planned, -1) 215 | end) 216 | 217 | it("filled корректно считается", function() 218 | assert.is_truthy(order.filled) 219 | end) 220 | end) 221 | 222 | describe("при наличии прочих позиций", function() 223 | before_each(function() 224 | _G.getItem = function() 225 | return { 226 | seccode = "T2", 227 | totalnet = 1 228 | } 229 | end 230 | main() 231 | end) 232 | it("они игнорируются", function() 233 | assert.are.equal(order.planned, 0) 234 | end) 235 | end) 236 | end) 237 | end) 238 | 239 | describe("при создании SmartOrder", function() 240 | local order 241 | local trade = function() 242 | Trade() 243 | end 244 | 245 | before_each(function() 246 | order = SmartOrder{ 247 | market = "M1", 248 | ticker = "T1", 249 | account = "A1", 250 | client = "C1", 251 | max_tries = 2 252 | } 253 | order:update(10.0, 2) 254 | _G.Robot = trade 255 | end) 256 | 257 | describe("и одновременном срабатывании OnTransReply", function() 258 | before_each(function() 259 | _G.sendTransaction = function() 260 | OnTransReply({ 261 | trans_id = order.trans_id, 262 | status = 3, 263 | order_num = 2 264 | }) 265 | end 266 | main() 267 | end) 268 | 269 | it("сохраняется номер заявки", function() 270 | assert.are.same(2, order.order.number) 271 | end) 272 | end) 273 | 274 | describe("и одновременном срабатывании OnTransReply и OnOrder", function() 275 | before_each(function() 276 | _G.sendTransaction = function() 277 | OnTransReply({ 278 | trans_id = order.trans_id, 279 | status = 3, 280 | order_num = 2 281 | }) 282 | OnOrder({ 283 | trans_id = order.trans_id, 284 | qty = 2, 285 | balance = 0, 286 | flags = 0x2 287 | }) 288 | end 289 | main() 290 | end) 291 | 292 | it("order.order деактивируется", function() 293 | assert.are.same({ 294 | sign = 1.0, 295 | number = 2, 296 | price = 10.0, 297 | quantity = 2, 298 | active = false, 299 | filled = 2 300 | }, order.order) 301 | end) 302 | end) 303 | 304 | describe("при вызове ожидания выполнения ордера", function() 305 | 306 | before_each(function() 307 | _G.Robot = function() 308 | order:fill() 309 | end 310 | _G.sendTransaction = function() 311 | end 312 | end) 313 | 314 | describe("при срабатывании в пределах отведенных попыток", function() 315 | before_each(function() 316 | _G.sleep = mock(function() 317 | OnOrder({ 318 | trans_id = order.trans_id, 319 | qty = 2, 320 | balance = 0, 321 | flags = 0x2 322 | }) 323 | end) 324 | main() 325 | end) 326 | it("fill выполняется", function() 327 | assert.is_true(order.filled) 328 | end) 329 | end) 330 | 331 | describe("при не срабатывании в пределах отведенных попыток", function() 332 | before_each(function() 333 | _G.sleep = mock(function() 334 | end) 335 | end) 336 | it("возникает ошибка", function() 337 | assert.has_error(function() main() end) 338 | assert.stub(_G.sleep).was.called(2) 339 | end) 340 | end) 341 | 342 | describe("при неудачном выставлении заявки", function() 343 | before_each(function() 344 | _G.sleep = mock(function() 345 | OnTransReply({ 346 | trans_id = order.trans_id, 347 | status = QUIK.TRANS_REPLY.REJECTED, 348 | order_num = nil 349 | }) 350 | end) 351 | end) 352 | it("возникает ошибка", function() 353 | assert.has_error(function() main() end) 354 | assert.stub(_G.sleep).was.called(1) 355 | end) 356 | end) 357 | end) 358 | 359 | describe("при нулевой цене", function() 360 | before_each(function() 361 | order:update(0, nil) 362 | _G.sendTransaction = mock(function() 363 | return "" 364 | end) 365 | main() 366 | end) 367 | 368 | it("в терминал уходит заявка по рынку", function() 369 | assert.stub(_G.sendTransaction).was.called_with({ 370 | ACCOUNT = "A1", 371 | CLIENT_CODE = "C1/", 372 | CLASSCODE = "M1", 373 | SECCODE = "T1", 374 | TYPE = "M", 375 | TRANS_ID = tostring(order.trans_id), 376 | ACTION = "NEW_ORDER", 377 | OPERATION = "B", 378 | PRICE = tostring(0), 379 | QUANTITY = tostring(2) 380 | }) 381 | end) 382 | end) 383 | 384 | 385 | describe("и отсутствии гонок", function() 386 | before_each(function() 387 | _G.sendTransaction = mock(function() 388 | return "" 389 | end) 390 | main() 391 | end) 392 | 393 | it("в терминал уходит заявка", function() 394 | assert.stub(_G.sendTransaction).was.called_with({ 395 | ACCOUNT = "A1", 396 | CLIENT_CODE = "C1/", 397 | CLASSCODE = "M1", 398 | SECCODE = "T1", 399 | TYPE = "L", 400 | TRANS_ID = tostring(order.trans_id), 401 | ACTION = "NEW_ORDER", 402 | OPERATION = "B", 403 | PRICE = tostring(10.0), 404 | QUANTITY = tostring(2) 405 | }) 406 | end) 407 | it("обновлено внутреннее состояние", function() 408 | assert.are.same({ 409 | sign = 1.0, 410 | price = 10.0, 411 | quantity = 2, 412 | active = true, 413 | filled = 0 414 | }, order.order) 415 | assert.are.equal(order.planned, 2) 416 | assert.are.equal(order.remainder, 2) 417 | assert.is.falsy(order.filled) 418 | end) 419 | 420 | -- вызовы OnOrder / OnTransReply могут происходить в любом порядке, 421 | -- ответ суппорта quik 422 | -- https://forum.quik.ru/messages/forum10/message24910/topic2839/#message24910 423 | 424 | describe("при OnTransReply с успешным статусом и известным id", function() 425 | local cancel_tran 426 | before_each(function() 427 | OnTransReply({ 428 | trans_id = order.trans_id, 429 | status = 3, 430 | order_num = 2 431 | }) 432 | cancel_tran = { 433 | ACCOUNT = "A1", 434 | CLIENT_CODE = "C1/", 435 | CLASSCODE = "M1", 436 | SECCODE = "T1", 437 | TRANS_ID = "666", 438 | ACTION = "KILL_ORDER", 439 | ORDER_KEY=tostring(order.order.number) 440 | } 441 | end) 442 | 443 | it("сохраняется номер заявки", function() 444 | assert.are.same(2, order.order.number) 445 | end) 446 | 447 | describe("при увеличении числа лотов", function() 448 | before_each(function() 449 | order:update(nil, order.planned + 1) 450 | main() 451 | end) 452 | 453 | it("выставляется заявка на отмену", function() 454 | assert.stub(_G.sendTransaction).was.called_with(cancel_tran) 455 | end) 456 | 457 | describe("при выполнении заявки на отмену", function() 458 | before_each(function() 459 | OnTransReply({ 460 | trans_id = order.trans_id, 461 | status = 4 462 | }) 463 | end) 464 | 465 | it("order сносится", function() 466 | assert.is_nil(order.order) 467 | end) 468 | end) 469 | end) 470 | 471 | describe("при уменьшении числа лотов", function() 472 | before_each(function() 473 | order:update(nil, order.planned - 1) 474 | main() 475 | end) 476 | 477 | it("выставляется заявка на отмену", function() 478 | assert.stub(_G.sendTransaction).was.called_with(cancel_tran) 479 | end) 480 | end) 481 | 482 | describe("при изменении цены", function() 483 | before_each(function() 484 | order:update(order.price + 1, nil) 485 | main() 486 | end) 487 | 488 | it("выставляется заявка на отмену", function() 489 | assert.stub(_G.sendTransaction).was.called_with(cancel_tran) 490 | end) 491 | end) 492 | 493 | describe("при последующем успешном OnOrder", function() 494 | before_each(function() 495 | OnOrder({ 496 | trans_id = order.trans_id, 497 | qty = 2, 498 | balance = 0, 499 | flags = 0x2 500 | }) 501 | end) 502 | 503 | it("order.order деактивируется", function() 504 | assert.are.same({ 505 | sign = 1.0, 506 | number = 2, 507 | price = 10.0, 508 | quantity = 2, 509 | active = false, 510 | filled = 2 511 | }, order.order) 512 | end) 513 | end) 514 | 515 | describe("при двух подряд идущих частичных OnOrder", function() 516 | before_each(function() 517 | OnOrder({ 518 | trans_id = order.trans_id, 519 | qty = 2, 520 | balance = 1, 521 | flags = 0x2 522 | }) 523 | OnOrder({ 524 | trans_id = order.trans_id, 525 | qty = 2, 526 | balance = 0, 527 | flags = 0x2 528 | }) 529 | end) 530 | 531 | it("order снимается", function() 532 | assert.are.same({ 533 | sign = 1.0, 534 | price = 10.0, 535 | number = 2, 536 | quantity = 2, 537 | active = false, 538 | filled = 2 539 | }, order.order) 540 | end) 541 | end) 542 | end) 543 | 544 | describe("при OnTransReply в неизвестным id", function() 545 | before_each(function() 546 | OnTransReply({ 547 | trans_id = order.trans_id + 1, 548 | status = 3, 549 | order_num = 2 550 | }) 551 | end) 552 | 553 | it("order не меняется", function() 554 | assert.are.same({ 555 | sign = 1.0, 556 | price = 10.0, 557 | quantity = 2, 558 | active = true, 559 | filled = 0 560 | }, order.order) 561 | end) 562 | end) 563 | 564 | describe("при OnTransReply со статусом отличным от 3", function() 565 | before_each(function() 566 | OnTransReply({ 567 | trans_id = order.trans_id, 568 | status = 2, 569 | order_num = 2 570 | }) 571 | end) 572 | 573 | it("order удаляется", function() 574 | assert.is_nil(order.order) 575 | end) 576 | end) 577 | 578 | describe("при OnOrder с полным выполнением", function() 579 | before_each(function() 580 | OnOrder({ 581 | trans_id = order.trans_id, 582 | qty = 2, 583 | balance = 0, 584 | flags = 0x2 585 | }) 586 | end) 587 | 588 | it("order снимается", function() 589 | assert.are.same({ 590 | sign = 1.0, 591 | price = 10.0, 592 | quantity = 2, 593 | active = false, 594 | filled = 2 595 | }, order.order) 596 | end) 597 | 598 | describe("при последующем пересчете", function() 599 | before_each(function() 600 | order:process() 601 | end) 602 | 603 | it("order.order удаляется", function() 604 | assert.is_nil(order.order) 605 | end) 606 | 607 | it("параметры order обновляются", function() 608 | assert.are.equal(order.position, 2) 609 | assert.is.truthy(order.filled) 610 | assert.are.equal(order.remainder, 0) 611 | end) 612 | end) 613 | 614 | describe("при последующем успешном OnTransReply", function() 615 | before_each(function() 616 | OnTransReply({ 617 | trans_id = order.trans_id, 618 | status = 3, 619 | order_num = 2 620 | }) 621 | end) 622 | 623 | it("сохраняется номер заявки", function() 624 | assert.are.same(2, order.order.number) 625 | end) 626 | end) 627 | end) 628 | 629 | describe("при OnOrder с частичным выполнением", function() 630 | before_each(function() 631 | OnOrder({ 632 | trans_id = order.trans_id, 633 | qty = 2, 634 | balance = 1, 635 | flags = 0x1 636 | }) 637 | end) 638 | 639 | it("остаток в order уменьшается и order не снимается", function() 640 | assert.are.same({ 641 | sign = 1.0, 642 | price = 10.0, 643 | quantity = 2, 644 | active = true, 645 | filled = 1 646 | }, order.order) 647 | end) 648 | 649 | describe("при последующем пересчете", function() 650 | before_each(function() 651 | order:process() 652 | end) 653 | 654 | it("order.order остается", function() 655 | assert.is_not_nil(order.order) 656 | end) 657 | 658 | it("параметры order не меняются", function() 659 | assert.are.equal(0, order.position) 660 | assert.is.falsy(order.filled) 661 | assert.are.equal(2, order.remainder) 662 | end) 663 | end) 664 | 665 | describe("при последующем успешном OnTransReply", function() 666 | before_each(function() 667 | OnTransReply({ 668 | trans_id = order.trans_id, 669 | status = 3, 670 | order_num = 2 671 | }) 672 | end) 673 | 674 | it("сохраняется номер заявки", function() 675 | assert.are.same(2, order.order.number) 676 | end) 677 | end) 678 | end) 679 | 680 | describe("при OnOrder с неизвестным id", function() 681 | before_each(function() 682 | OnOrder({ 683 | trans_id = order.trans_id + 1, 684 | qty = 2, 685 | balance = 1, 686 | flags = 0x1 687 | }) 688 | end) 689 | it("имеющийся order не меняется", function() 690 | assert.are.same({ 691 | sign = 1.0, 692 | price = 10.0, 693 | quantity = 2, 694 | active = true, 695 | filled = 0 696 | }, order.order) 697 | end) 698 | end) 699 | end) 700 | end) 701 | 702 | describe("для объекта ServerInfo", function() 703 | local server 704 | describe("при запросе любого значения", function() 705 | before_each(function() 706 | _G.getInfoParam = mock(function() 707 | end) 708 | server = ServerInfo{} 709 | end) 710 | it("запрос пробрасывается в getInfoParam", function() 711 | local _ = server.servertime 712 | assert.stub(_G.getInfoParam).was.called() 713 | end) 714 | it("ключ преобразуется в верхний регистр", function() 715 | local _ = server.serverTime 716 | assert.stub(_G.getInfoParam).was.called_with('SERVERTIME') 717 | end) 718 | end) 719 | describe("при найденом ответе", function() 720 | before_each(function() 721 | _G.getInfoParam = function() 722 | return "test" 723 | end 724 | server = ServerInfo{} 725 | end) 726 | it("он возвращается в неизменном виде", function() 727 | assert.are.same(server.someValue, "test") 728 | end) 729 | end) 730 | describe("при пустом ответе", function() 731 | before_each(function() 732 | _G.getInfoParam = function() 733 | return "" 734 | end 735 | server = ServerInfo{} 736 | end) 737 | it("возвращается nil", function() 738 | assert.is_nil(server.someValue) 739 | end) 740 | end) 741 | describe("при retry_on_empty = true", function() 742 | before_each(function() 743 | local c = {called = 0} 744 | _G.getInfoParam = mock(function() 745 | c.called = c.called + 1 746 | if c.called == 1 then 747 | return nil 748 | elseif c.called == 2 then 749 | return '' 750 | elseif c.called == 3 then 751 | return 1 752 | end 753 | end) 754 | _G.sleep = mock(function() 755 | end) 756 | server = ServerInfo{retry_on_empty = true} 757 | _ = server.test 758 | end) 759 | it("делается повторная попытка", function() 760 | assert.stub(_G.getInfoParam).was.called(3) 761 | end) 762 | it("и делается sleep", function() 763 | assert.stub(_G.sleep).was.called(2) 764 | end) 765 | end) 766 | end) 767 | 768 | describe("для объекта Indicator", function() 769 | local indicator 770 | before_each(function() 771 | _G.getCandlesByIndex = function(tag, line, first_candle, count) 772 | local data = {} 773 | data[0] = { 774 | { 775 | open = 1, 776 | close = 2 777 | }, 778 | { 779 | open = 2, 780 | close = 3 781 | }, 782 | { 783 | open = 3, 784 | close = 4 785 | } 786 | } 787 | data[1] = { 788 | { 789 | open = 10, 790 | close = 20 791 | }, 792 | { 793 | open = 20, 794 | close = 30 795 | }, 796 | { 797 | open = 30, 798 | close = 40 799 | } 800 | } 801 | return data[line], 3, "test" 802 | end 803 | end) 804 | 805 | describe("при отдаче данных с 3-й попытки", function() 806 | before_each(function() 807 | local c = {called = 0} 808 | _G.getNumCandles = mock(function() 809 | c.called = c.called + 1 810 | if c.called == 3 then 811 | return 1 812 | end 813 | return 0 814 | end) 815 | _G.sleep = mock(function() 816 | end) 817 | end) 818 | 819 | describe("и дефолтном ограничении на число попыток", function() 820 | before_each(function() 821 | indicator = Indicator{tag = "test"} 822 | end) 823 | 824 | it("они в итоге извлекаются", function() 825 | local _ = indicator.values[1] 826 | assert.stub(_G.getNumCandles).was.called(3) 827 | assert.stub(_G.sleep).was.called(2) 828 | end) 829 | end) 830 | 831 | describe("при меньшем числе заданных попыток", function() 832 | before_each(function() 833 | indicator = Indicator{tag = "test", max_tries = 2} 834 | end) 835 | 836 | it("возвращается ошибка", function() 837 | assert.has_error(function() 838 | _ = indicator.values[1] 839 | end) 840 | assert.stub(_G.getNumCandles).was.called(2) 841 | end) 842 | end) 843 | end) 844 | 845 | describe("при успешном получении данных", function() 846 | before_each(function() 847 | _G.getNumCandles = function(tag) 848 | return 3 849 | end 850 | indicator = Indicator{tag = "test"} 851 | end) 852 | 853 | it("по числовому ключу возвращается close первого индикатора", function() 854 | assert.are.same(2, indicator[1]) 855 | end) 856 | 857 | it("по ключу values возвращается все атрибуты", function() 858 | assert.are.same({open = 1, close = 2}, indicator.values[1]) 859 | end) 860 | 861 | it("явный запрос по первому ключу без указания номера линии", function() 862 | assert.are.equal(2, indicator.closes[1]) 863 | assert.are.equal(1, indicator.opens[1]) 864 | end) 865 | 866 | it("явный запрос по последнему ключу без указания номера линии", function() 867 | assert.are.equal(4, indicator.closes[3]) 868 | assert.are.equal(3, indicator.opens[3]) 869 | end) 870 | 871 | it("явный запрос по ключу с указанием номера линии", function() 872 | assert.are.equal(20, indicator.closes_1[1]) 873 | assert.are.equal(10, indicator.opens_1[1]) 874 | end) 875 | 876 | it("по числовому ключу можно запросить данные с конца", function() 877 | assert.are.same(4, indicator[-1]) 878 | end) 879 | 880 | it("по ключу values можно запросить данные с конца", function() 881 | assert.are.same({open = 3, close = 4}, indicator.values[-1]) 882 | end) 883 | 884 | it("явный запрос по ключу с конца без указания номера линии", function() 885 | assert.are.equal(4, indicator.closes[-1]) 886 | assert.are.equal(3, indicator.opens[-1]) 887 | end) 888 | 889 | it("явный запрос по ключу с конца с указанием номера линии", function() 890 | assert.are.equal(40, indicator.closes_1[-1]) 891 | assert.are.equal(30, indicator.opens_1[-1]) 892 | assert.are.same({open = 30, close = 40}, indicator.values_1[-1]) 893 | end) 894 | 895 | it("запросы по несуществующим индексам", function() 896 | assert.is_nil(indicator[-100]) 897 | assert.is_nil(indicator[100]) 898 | end) 899 | end) 900 | end) 901 | 902 | describe("для объекта MarketData", function() 903 | local feed 904 | 905 | describe("при непустом стакане", function() 906 | before_each(function() 907 | _G.getQuoteLevel2 = function(class_code, sec_code) 908 | return { 909 | bid_count = 2, 910 | offer_count = 3, 911 | bid = { 912 | {price = "10.0", quantity = "1"}, 913 | {price = "11.0", quantity = "2"} 914 | }, 915 | offer = { 916 | {price = "12.0", quantity = "1"}, 917 | {price = "13.0", quantity = "2"}, 918 | {price = "14.0", quantity = "3"} 919 | } 920 | } 921 | end 922 | _G.getParamEx = function(class_code, sec_code, param_name) 923 | return {param_type = 3, param_value = "ok"} 924 | end 925 | feed = MarketData{ 926 | market = "M1", 927 | ticker = "T1" 928 | } 929 | end) 930 | 931 | it("по ключу bids убывающая таблица числовых бидов", function() 932 | assert.are.same({ 933 | {price = 11.0, quantity = 2}, 934 | {price = 10.0, quantity = 1} 935 | }, feed.bids) 936 | end) 937 | 938 | it("по ключу offers возрастающая таблица числовых оферов", function() 939 | assert.are.same({ 940 | {price = 12.0, quantity = 1}, 941 | {price = 13.0, quantity = 2}, 942 | {price = 14.0, quantity = 3} 943 | }, feed.offers) 944 | end) 945 | 946 | it("по прочим ключам запрос адресуется в getParamEx", function() 947 | assert.are.equal("ok", feed.test) 948 | end) 949 | end) 950 | 951 | describe("при пустом стакане", function() 952 | before_each(function() 953 | _G.getQuoteLevel2 = function(class_code, sec_code) 954 | return { 955 | bid_count = 0, 956 | offer_count = 0, 957 | bid = nil, 958 | offer = nil 959 | } 960 | end 961 | _G.getParamEx = function(class_code, sec_code, param_name) 962 | return {} 963 | end 964 | feed = MarketData{ 965 | market = "M1", 966 | ticker = "T1" 967 | } 968 | end) 969 | 970 | it("по ключу bids отдается {}", function() 971 | assert.are.same({}, feed.bids) 972 | end) 973 | 974 | it("по ключу offers отдается {}", function() 975 | assert.are.same({}, feed.offers) 976 | end) 977 | 978 | it("по прочим ключам отдается nil", function() 979 | assert.is_nil(feed.test) 980 | end) 981 | end) 982 | end) 983 | end) 984 | --------------------------------------------------------------------------------