Торговый робот для QUIK на Lua: с нуля до боевого скрипта
QUIK с встроенным Lua-движком — самый популярный путь для частного алготрейдера на российском рынке: терминал есть у каждого брокера, скрипт может торговать любыми инструментами, доступен прямо в окне Excel-таблицы. Разбираем архитектуру робота на QUIK Lua, ключевые функции API (стакан, свечи, sendTransaction), скелет работающего скрипта с ATR-каналом, как тестировать и разворачивать в боевом терминале и где у этой связки потолок.
Почему QUIK Lua
QUIK — это торговый терминал, который дают практически все российские брокеры (Финам, Сбер, БКС, Открытие, Алор и др.). Он встроен в торговый процесс брокера: вы видите в нём свой реальный счёт, отправляете реальные заявки. Внутри QUIK работает Lua-интерпретатор (версия 5.3 в современных сборках) с расширенным API для рыночных данных и торговых операций.
Альтернативы — Tinkoff Invest API (gRPC, отдельный sandbox), Algorithmic Trading через MetaTrader 5 (другой набор инструментов), TSLab (визуальное программирование). Но именно QUIK Lua остаётся дефолтным выбором частного алготрейдера на российском рынке: ничего не надо настраивать, код запускается прямо в терминале брокера, доступны все инструменты MOEX и СПБ.
Минусы у QUIK Lua тоже значимые. Главное — терминал должен быть запущен на машине пользователя круглосуточно (нет облачного режима), а сам Lua однопоточный и блокирует UI терминала на тяжёлых операциях. Для high-frequency и распределённых стратегий QUIK не подходит. Но для большинства частных стратегий — на 1–10 сделок в день с таймфреймом 1–60 минут — связки QUIK Lua более чем достаточно.
Архитектура робота: что есть и чего нет
QUIK Lua даёт скрипту три фундаментальных вещи:
- Доступ к рыночным данным — стакан, тиковые сделки, исторические свечи, текущие котировки.
- Возможность отправлять заявки и получать ответы —
sendTransaction, колбэкиOnOrder,OnTrade. - Колбэки на события —
OnAllTrade(тиковая лента),OnQuote(изменение котировки),OnDisconnected(потеря связи).
Чего нет в QUIK Lua, в отличие от Python-стека:
- Удобной работы со временем (
os.timeесть, но пояса и парсинг — отдельная боль). - Хороших структур данных (ассоциативные массивы есть, но без специализированных классов).
- Многопоточности — все скрипты выполняются последовательно в основном потоке терминала.
- Полноценных HTTP-запросов из коробки (есть
socket, но требуется отдельная библиотека LuaSocket). - Тестирующего фреймворка — бэктест на исторических данных делается вне QUIK (на Python с теми же индикаторами).
Установка и первый скрипт
QUIK уже включает Lua. Файл скрипта — обычный .lua, кладётся в любую директорию (рекомендую отдельную папку ~/quik-bots/<strategy-name>/).
Пустой скелет, который терминал может загрузить:
-- main.lua
function main()
-- Главный цикл скрипта; вызывается QUIK после запуска.
while is_run do
sleep(1000) -- 1 секунда между итерациями
end
end
is_run = true
function OnStop()
is_run = false
return 1
end
В терминале: Сервисы → Lua-скрипты → Загрузить — указать путь к .lua. После загрузки запустите кнопкой «Выполнить». Если скрипт упал — в той же панели появится ошибка.
OnStop — обязательный колбэк, который терминал вызывает при остановке скрипта. Если его нет, скрипт не остановится корректно при закрытии терминала.
Ключевые функции API
Получение текущей цены
local last_price = getParamEx("TQBR", "SBER", "LAST").param_value
TQBR — режим торгов «акции и паи Т+1», SBER — тикер, LAST — параметр «последняя цена». Полный список параметров — в документации QUIK. Часто используют: LAST, BID, OFFER, VOLTODAY, OPENPOSITION.
Стакан
local ob = getQuoteLevel2("TQBR", "SBER")
-- ob.bid_count, ob.offer_count
-- ob.bid[1].price, ob.bid[1].quantity (лучший бид)
-- ob.offer[1].price, ob.offer[1].quantity (лучший аск)
В стакане 50 уровней с каждой стороны — этого достаточно для любой стратегии, кроме чисто маркет-мейкерных.
Исторические свечи
function get_candles(class, sec, interval, count)
-- INTERVAL_M5 = 5-минутки, INTERVAL_M15, INTERVAL_M30, INTERVAL_H1, ...
local ds = CreateDataSource(class, sec, interval)
while ds:Size() == 0 do sleep(50) end
local candles = {}
local size = ds:Size()
local start = math.max(1, size - count + 1)
for i = start, size do
table.insert(candles, {
o = ds:O(i), h = ds:H(i),
l = ds:L(i), c = ds:C(i),
v = ds:V(i),
})
end
return candles
end
CreateDataSource создаёт «подписку» на свечи. Первый вызов может занять 1–3 секунды, пока QUIK подгружает историю с сервера. Дальше QUIK сам обновляет данные при появлении новых свечей.
Отправка заявки
function send_order(class, sec, op, price, qty)
local trans_id = math.random(1, 2^31 - 1)
local t = {
TRANS_ID = tostring(trans_id),
CLASSCODE = class,
SECCODE = sec,
ACTION = "NEW_ORDER",
ACCOUNT = "L01-00000F00", -- ваш торговый счёт
CLIENT_CODE = "12345", -- ваш клиент-код у брокера
TYPE = "L", -- L = лимитка, M = маркет
OPERATION = op, -- "B" (buy) или "S" (sell)
QUANTITY = tostring(qty),
PRICE = string.format("%.2f", price),
}
local result = sendTransaction(t)
if result ~= "" then
message("Ошибка sendTransaction: " .. result, 3)
return nil
end
return trans_id
end
Поля ACCOUNT и CLIENT_CODE уникальны для каждого брокера — их можно посмотреть в окне «Поручения» через любую заявку, отправленную руками.
sendTransaction возвращает пустую строку при успешной отправке (не подтверждение исполнения!). Реальный результат — в колбэке OnTransReply и далее OnOrder/OnTrade.
Колбэки
function OnTransReply(reply)
if reply.status == 3 then
-- транзакция принята торговой системой
log("order accepted: " .. reply.order_num)
else
log("transaction rejected: " .. reply.result_descr)
end
end
function OnOrder(order)
-- order.flags: 1 = активна, 2 = снята; и др. биты
log(string.format("order %d: %s, qty=%d, balance=%d",
order.order_num, order.sec_code, order.qty, order.balance))
end
function OnTrade(trade)
log(string.format("trade %s @ %.2f x %d",
trade.sec_code, trade.price, trade.qty))
end
order.balance — остаток неисполненной части заявки. order.qty - order.balance = исполненный объём.
Скелет работающего робота
Соберём ATR-канал-стратегию: покупаем при пробое верхней границы канала, продаём при пробое нижней. Только лонг.
-- atr_channel_bot.lua
-- ============= конфигурация =============
local CONFIG = {
class = "TQBR",
sec = "SBER",
interval = INTERVAL_M5,
candles_n = 200,
atr_period = 14,
channel_n = 20, -- окно Donchian
atr_mult = 0.5, -- сдвиг канала
risk_pct = 0.5, -- риск 0.5% депозита на сделку
deposit = 100000, -- размер расчётного депозита
}
-- ============= состояние =============
local state = {
candles = {},
position = 0, -- текущая длинная позиция в лотах
last_signal = nil, -- "long" | nil
pending_trans_id = nil,
}
is_run = true
function main()
state.candles = get_candles(CONFIG.class, CONFIG.sec,
CONFIG.interval, CONFIG.candles_n)
log("loaded " .. #state.candles .. " candles")
while is_run do
update_last_candle()
local signal = compute_signal()
if signal == "long" and state.position == 0 then
local entry = state.candles[#state.candles].c
local stop = entry - 2 * atr(state.candles, CONFIG.atr_period)
local qty = position_size(entry, stop)
if qty > 0 then
send_order(CONFIG.class, CONFIG.sec, "B", entry, qty)
state.position = qty
state.last_signal = "long"
log(string.format("LONG entry=%.2f stop=%.2f qty=%d",
entry, stop, qty))
end
end
if state.position > 0 then
check_stop_loss()
end
sleep(2000) -- цикл раз в 2 секунды
end
end
-- ============= индикаторы =============
function atr(candles, period)
local trs = {}
for i = 2, #candles do
local c = candles[i]
local prev_c = candles[i-1]
local tr = math.max(
c.h - c.l,
math.abs(c.h - prev_c.c),
math.abs(c.l - prev_c.c)
)
table.insert(trs, tr)
end
-- Среднее за последние period значений
local sum = 0
for i = #trs - period + 1, #trs do
sum = sum + trs[i]
end
return sum / period
end
function compute_signal()
local n = #state.candles
if n < CONFIG.channel_n + CONFIG.atr_period then return nil end
local highs = {}
for i = n - CONFIG.channel_n, n - 1 do
table.insert(highs, state.candles[i].h)
end
local channel_top = math.max(table.unpack(highs))
local last_close = state.candles[n].c
local cur_atr = atr(state.candles, CONFIG.atr_period)
if last_close > channel_top + CONFIG.atr_mult * cur_atr then
return "long"
end
return nil
end
-- ============= риск и позиция =============
function position_size(entry, stop)
local risk_per_lot = math.abs(entry - stop)
if risk_per_lot < 0.01 then return 0 end
local risk_money = CONFIG.deposit * CONFIG.risk_pct / 100
return math.floor(risk_money / risk_per_lot)
end
function check_stop_loss()
local last = state.candles[#state.candles].c
local stop_level = state.candles[#state.candles].o
- 2 * atr(state.candles, CONFIG.atr_period)
if last < stop_level then
send_order(CONFIG.class, CONFIG.sec, "S", last, state.position)
state.position = 0
state.last_signal = nil
log(string.format("STOP-LOSS exit @ %.2f", last))
end
end
Это рабочий каркас, в котором отсутствуют важные production-вещи: проверка статусов заявок, обработка частичных исполнений, журналирование в файл, логика на случай разрыва связи. Их добавление — отдельный объём работы примерно на 100–150 строк кода.
Бэктест: его нет
В QUIK Lua встроенного бэктестера не существует. Чтобы протестировать стратегию на истории, надо:
- Выгрузить исторические свечи через
getCandlesили из ISS MOEX API. - Запустить бэктест на Python (например, на
pandas+ кастомный движок илиbacktrader/vectorbt) с теми же расчётами индикаторов. - Перенести найденные параметры в Lua-код и запустить на демо-счёте брокера.
Это два разных «языка» (Python и Lua) с двумя реализациями одного и того же индикатора — и ошибки расхождения очень частая боль. Решение — писать индикаторы максимально просто и юнит-тестировать выходы на одном и том же датасете в обоих стеках, добиваясь идентичных значений до 4–5 знака после запятой.
Тестирование на демо-счёте
Большинство брокеров дают тренировочный счёт, на который можно указать в QUIK через отдельный логин. На нём — обязательный месяц работы перед боевым счётом:
- Скрипт должен пережить ночное отключение и переподключиться.
- Заявки должны корректно сниматься при остановке скрипта.
- Логи должны содержать достаточно информации, чтобы реконструировать любую сделку.
- Должны быть проверены поведения на гэпах, экспирациях фьючерсов, корпоративных событиях.
В Сбере, Тинькофф, Финаме демо-счёт — отдельный URL и отдельные ACCOUNT/CLIENT_CODE. Нельзя просто «переключить флаг» — нужны два разных набора реквизитов, и логика робота должна уметь работать с обоими.
Развёртывание и мониторинг
Боевой запуск:
- Машина — стационарный домашний компьютер или VPS у российского хостера (Selectel, Reg.ru). VPN-маршруты в Москву уменьшают сетевую задержку до брокера.
- Аптайм QUIK — при разрыве сессия восстанавливается автоматически, но скрипт может «зависнуть» на старых данных. Колбэк
OnConnected/OnDisconnectedобязателен для перезагрузкиDataSource. - Телеграм-уведомления — самый простой способ контроля. Шлёте
https://api.telegram.org/bot<token>/sendMessageчерезsocket.http(LuaSocket). На каждую сделку и на каждый аномальный лог. - Журналирование в файл — все сделки, все ошибки, статусы заявок. Текстовый CSV — простой и достаточный формат.
Если QUIK падает (UI зависает, не отвечает) — брокер не предоставляет «чёрного хода», единственный способ — перезапустить терминал. Поэтому критичные стратегии не запускают на QUIK в одиночку: дублирующая защита через брокерский «глобальный стоп» (если есть) или через отдельный watchdog-скрипт, который проверяет состояние терминала каждую минуту.
Подводные камни
- Однопоточность. Любая блокирующая операция в одном скрипте остановит остальные. Не делайте долгих вычислений в
OnAllTrade— они засинхронят прокачку котировок. - GUI-зависимость. Терминал должен быть открыт; нельзя свернуть его в трей или скрыть — некоторые версии QUIK останавливают обновление в фоне.
- Перезапуск терминала. При перезапуске скрипты не загружаются автоматически — придётся нажимать «Выполнить» руками. Решение — использовать
qlua-loader(сторонняя утилита) или собирать скрипт в DLL черезlua_exec. - Версии Lua у разных брокеров отличаются. Финам — Lua 5.3, Сбер — местами 5.1. Проверяйте перед миграцией кода между терминалами.
- Транзакции с типом M (маркет). Маркет-заявки на низколиквидных бумагах могут исполниться по очень плохой цене. По умолчанию шлите лимитки.
- Время серверов.
os.time()возвращает локальное время компьютера. Время биржи — отдельное поле в структурах ответа, и на ночных перерасчётах локальное и биржевое могут различаться.
Когда уйти из QUIK
QUIK Lua оптимален в трёх сценариях:
- Стратегия с 1–10 сделками в день на 1–5 инструментах.
- Таймфрейм 5 минут и выше.
- Не требуется облачный деплой / отказоустойчивость уровня дата-центра.
Если стратегия требует высокой частоты (тиковая ML-модель, маркет-мейкинг), многопоточности (одновременно 50+ инструментов) или облачного деплоя (бот должен работать без вашего компьютера) — переходите на:
- Tinkoff Invest API (gRPC) — Python/Go/.NET.
- Plaza-2 — прямой доступ FORTS, для срочного рынка.
- MOEX SPECTRA — требует отдельного соглашения с биржей.
С Tinkoff API типичный путь — переписать ту же стратегию с Lua на Python, использовав те же расчёты индикаторов, и развернуть в Docker-контейнере на VPS. Это занимает 30–60 часов, и взамен вы получаете нормальный язык, нормальный CI и возможность выключить компьютер на ночь.
Что запомнить
- QUIK Lua — путь по умолчанию для частного алготрейдера на MOEX: ничего не надо настраивать, любой брокер.
- Базовое API:
getParamEx,getQuoteLevel2,CreateDataSource,sendTransaction, колбэкиOnOrder/OnTrade/OnAllTrade. - Бэктестера в QUIK нет — тестировать надо на Python и переносить параметры.
- Типичный production-робот = ~300–500 строк Lua + журнал + Telegram-уведомления + watchdog.
- Однопоточность и GUI-зависимость QUIK — главные ограничения; для тиковых и распределённых стратегий нужен Tinkoff API или Plaza-2.
- Месяц на демо-счёте перед боевым запуском — обязательный шаг, иначе первые недели дадут серию мелких ошибок с реальными деньгами.