Поиск по материалам

Торговый робот для 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 даёт скрипту три фундаментальных вещи:

  1. Доступ к рыночным данным — стакан, тиковые сделки, исторические свечи, текущие котировки.
  2. Возможность отправлять заявки и получать ответыsendTransaction, колбэки OnOrder, OnTrade.
  3. Колбэки на события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 встроенного бэктестера не существует. Чтобы протестировать стратегию на истории, надо:

  1. Выгрузить исторические свечи через getCandles или из ISS MOEX API.
  2. Запустить бэктест на Python (например, на pandas + кастомный движок или backtrader/vectorbt) с теми же расчётами индикаторов.
  3. Перенести найденные параметры в Lua-код и запустить на демо-счёте брокера.

Это два разных «языка» (Python и Lua) с двумя реализациями одного и того же индикатора — и ошибки расхождения очень частая боль. Решение — писать индикаторы максимально просто и юнит-тестировать выходы на одном и том же датасете в обоих стеках, добиваясь идентичных значений до 4–5 знака после запятой.

Тестирование на демо-счёте

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

  • Скрипт должен пережить ночное отключение и переподключиться.
  • Заявки должны корректно сниматься при остановке скрипта.
  • Логи должны содержать достаточно информации, чтобы реконструировать любую сделку.
  • Должны быть проверены поведения на гэпах, экспирациях фьючерсов, корпоративных событиях.

В Сбере, Тинькофф, Финаме демо-счёт — отдельный URL и отдельные ACCOUNT/CLIENT_CODE. Нельзя просто «переключить флаг» — нужны два разных набора реквизитов, и логика робота должна уметь работать с обоими.

Развёртывание и мониторинг

Боевой запуск:

  1. Машина — стационарный домашний компьютер или VPS у российского хостера (Selectel, Reg.ru). VPN-маршруты в Москву уменьшают сетевую задержку до брокера.
  2. Аптайм QUIK — при разрыве сессия восстанавливается автоматически, но скрипт может «зависнуть» на старых данных. Колбэк OnConnected/OnDisconnected обязателен для перезагрузки DataSource.
  3. Телеграм-уведомления — самый простой способ контроля. Шлёте https://api.telegram.org/bot<token>/sendMessage через socket.http (LuaSocket). На каждую сделку и на каждый аномальный лог.
  4. Журналирование в файл — все сделки, все ошибки, статусы заявок. Текстовый 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. Стратегия с 1–10 сделками в день на 1–5 инструментах.
  2. Таймфрейм 5 минут и выше.
  3. Не требуется облачный деплой / отказоустойчивость уровня дата-центра.

Если стратегия требует высокой частоты (тиковая 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.
  • Месяц на демо-счёте перед боевым запуском — обязательный шаг, иначе первые недели дадут серию мелких ошибок с реальными деньгами.
QUIK Lua торговый робот MOEX алготрейдинг