Создание ботов для торговли криптовалютами и акциями на Postgres

Сразу скажу, в этой статье не будут описаны алгоритмы торговли, хотя они есть и приносят прибыль. Моя цель показать вам удобный инструмент для создания ботов и поделится результатом пятилетней работы. Если кому-то не интересны боты, то здесь есть одно техническое решение, которое как вишенка на торте может многих заинтересовать, аналог dbms_pipe в ORACLE, реализованный на чистом pgsql, ну, а также много других моих технических решений.  Это не основная моя работа, скорее хобби. Сначала был просто интерес написать алгоритм, который гарантировал если не плюс, то хотя бы сохранение средств на падающем рынке, про растущий рынок говорить не интересно там и так все хорошо. По образованию я не экономист, , а советский айтишник, и в качестве своих невидимых соперников рассматривал волков с Уолт стрит. Для меня это был вызов сродни изобретению вечного двигателя, но с возможностью реализации. В разработке алгоритма торговли мне сильно помогла книга Эдвина Лефевра «Воспоминания биржевого спекулянта». Итак, приступим.

Требование к боту

  1. Бот должен зарабатывать денег больше чем банковские вклады, то есть больше чем 5% в валюте. Сразу скажу это достигнуто, бот работает уже 3 года и доход даже на падающем рынке не опускался ниже 35%. В хороший год достигал 100%. Алгоритмы здесь рассматривать не буду. Так же понимаю, что возможны и убытки и математически доказать, что всегда будет прибыль невозможно, и я всегда готов что будут убытки.

  2. . Легкость разработки алгоритмов и прогон этих алгоритмов на исторических данных. Я постараюсь вас убедить в этой статье, что лучше pgsql вы не найдете. Главное инструмент должен позволять вам сосредотачиваться именно на алгоритме, а не программировании.

  3. Надежность бота, он должен работать 24 на 7. Это реализовано, падает бинанс, но не бот.

  4. Производительность, сейчас я торгую 350 парами криптовалют в паре к USDT и никаких проблем с производительностью, нет. Раз в минуту я опрашиваю биржу по этим парам и в течении 30 секунд все данные по всем парам обработаны. Запас прочности еще есть.

  5. Легкость администрирования. Вся реализация написана на pgsql, достаточно делать импорт экспорт. Проблем с администрированием нет.

Почему Postgres

  1. Удобный пакет pgsql-http который позволяет общаться с внешним миром из сохраненных процедур.

  2. Быстрая и удобная работа с json.

  3. Большой набор математический функций.

  4. Удобный пакет dblink позволяющий запускать много процессов postgres.

  5. Sql прекрасный язык для обработки данных и синхронизации работы с данными, это важно когда вдруг два или три бота решат в один момент купить или продать свой актив.

  6. Удобный IDE в виде pgadmin, с любого рабочего места можно ничего не устанавливая через браузер, работать с кодом.

  7. Я знаю и люблю postgres как в прочем и oracle, но oracle за любовь денег хочет.

Как пример выше сказанного привожу запрос который показывает объём в стакане бинанса  на покупку с отклонением в 3% вниз и в верх.

WITH depth AS (SELECT (content::jsonb)->'asks' a, (content::jsonb)->'bids' b FROM   
    http_get('https://api.binance.com/api/v1/depth?symbol=BTCUSDT&limit=5000'))  
SELECT  
    (SELECT va  FROM (
        SELECT (j->>0)::float8 pa, sum((j->>1)::float8) OVER (ORDER BY j->>0) va FROM  
            jsonb_array_elements(a) j) t WHERE pa <= (a#>>'{0,0}')::float8 * 1.03 ORDER BY va DESC LIMIT 1) va,
    (SELECT vb   FROM (
        SELECT (j->>0)::float8 pb, sum((j->>1)::float8) OVER (ORDER BY j->>0 DESC) vb FROM  
            jsonb_array_elements(b) j) t WHERE pb >= (b#>>'{0,0}')::float8 * 0.97 ORDER BY vb DESC LIMIT 1) vb
FROM depth

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

Общая архитектура системы.

Система состоит из следующих модулей

  1. Основная программа, написана на pgsql.

  2. Боты, запускаемые с определенным интервалов времени, боты делятся на служебные и боты для торговли. Боты для торговли разделяются на боты, торгующие в LONG и боты, торгующие в SHORT. Служебные боты — это бот для работы с телеграмм и бот, который может определять общую стратегию торговли для всех пар, в данный момент я его не использую, поскольку не смог ответить на вопрос, на до ли связывать между собой результаты торгов разными парами. Сейчас они у меня торгуются не зависимо. Это как пример игры в казино вы играете за разными столами, и если в сумме вы выиграйте, прекращать играть за всеми столами или продолжать играть на всех столах дальше. Эмулятор показал мне что лучше торговать не зависимо от результата торговли в других парах, но я не уверен, что это правильно. Боты реализованы на pgsql.

  3. Бот выполняющий асинхронные команды, которые получает из pipe, реализация на pgsql.

  4. Программа для посылки сигнала останов, основной программе, реализация на pgsql.

  5. Командные файлы операционной системы для запуска и останова основной программы, написаны на bash.

Есть список ботов, которые надо запускать через определенные интервалы времени. Бот — это сохраненная процедура на pgsql. Боты запускаются выполнятся параллельно, каждый в своем процессе postgres. Боты делятся на два класса торговые и служебные. Торговые боты одинаковые, но можно реализовать и разные, каждый торгует своей парой криптовалют. Служебных ботов несколько, бот для работы с телеграмм, бот для выполнения асинхронных операций (эмуляция автономных транзакций), такие как логирование или вывод сообщений в телеграмм и прочие вспомогательные боты.  Главная программа запускает боты в цикле, с определенным интервалом времени. Можно было это сделать более правильно так что бы каждый бот возвращал время, когда его надо запустить, но это я отложил на будущее.

CREATE TABLE crypto_bot2.bots
(
    key text primary key,
    name text ,
    params jsonb,
    is_run boolean,
    comment text 
)  

В таблице bots содержится список ботов, которые будут запускаться. Key — это уникальный ключ бота, для торговых ботов он означает имя торговой пары, name имя бота, имя сохраненной процедуры, params — это переменные бота с которыми он работает, is_run флаг который показывает бот надо запускать или нет, и последние поле — это комментарий. Вот как верхняя выглядит часть данной таблицы. В таблице содержатся как служебные боты, так и боты для торговли. Боты для торговли делятся на торгующие в Long и Short. Для торговли в Short я использую монеты DOWN которые предоставляет binance, но в будущем предполагаю работать с маржинальной торговлей, поскольку возможностей больше. Вот как выглядит таблица в базе.

e516ce776d9bff0d67f6a9a048fccca5.png

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

bot_cmd = CASE WHEN is_restart THEN 'restart' ELSE 'start' END;	
    LOOP
        wt = clock_timestamp();
        SELECT * INTO st FROM  crypto_bot2.bot_state t ;
        IF (st.status = 'STOP') THEN
           bot_cmd = 'stop';
       ELSE IF (clock_timestamp() - start_time > restart_interval)	THEN
           SELECT count(*) INTO wi FROM crypto_bot2.bots WHERE params->>'buy_price' IS NOT NULL;
           IF (wi = 0 OR clock_timestamp() - start_time > restart_interval * 5) THEN
               bot_cmd = 'pre_restart';
           END IF;	
    END IF; END IF;
    FOR bot IN (SELECT * FROM crypto_bot2.bots WHERE is_run) LOOP
    dblnk = dblinks->bot.key;
    IF (dblnk IS NULL) THEN
            dblnk = 'dblink_' || bot.key;
            PERFORM crypto_bot2.create_dblink(dblnk);
            PERFORM dblink_exec(dblnk, format('CALL crypto_bot2.init_bot(''%s'')', dblnk));
            dblinks = CASE WHEN dblinks IS NULL THEN hstore(bot.key, dblnk) 
	              ELSE dblinks || hstore(bot.key, dblnk) END; 
        END IF;
        IF (dblink_is_busy(dblnk) = 0) THEN
             PERFORM crypto_bot2.clear_dblink(dblnk);
             PERFORM dblink_send_query(dblnk, format('SELECT crypto_bot2.%s(''%s'', ''%s'')', bot.name, bot.key,
             bot_cmd));
        END IF;	
    END LOOP;	
    EXIT WHEN bot_cmd IN ('stop', 'pre_restart');
    wi = req_time - EXTRACT(epoch FROM(clock_timestamp() - wt));
    IF (wi > 0) THEN
        PERFORM pg_sleep(wi);
    END IF;
    bot_cmd = 'work';
END LOOP;
FOR bot IN (SELECT * FROM each(dblinks)) LOOP
    PERFORM dblink_disconnect(bot.value);
END LOOP;

Как вы можете видеть я использую асинхронный запуск через dblink_send_query, в качестве параметра боту передаётся его key. Данный цикл выполняется раз в 5 секунд. Теперь посмотрим на стартовую часть, когда бота.

BEGIN
    SELECT params INTO p_params FROM crypto_bot2.bots WHERE key=p_pair;
    save_params = p_params;
    last_call = current_setting(last_call_key, true)::timestamptz;
    IF (last_call IS NULL OR (clock_timestamp() - last_call) > '55S'::interval 
        OR clock_timestamp() - date_trunc('minute', clock_timestamp()) < '5S'::interval) THEN

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

IF (save_params != p_params) THEN
      UPDATE crypto_bot2.bots SET params = p_params WHERE key=p_pair;
END IF;

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

Пакет pgsql-http

https://github.com/pramsey/pgsql-http

Это основное расширение, который я использую для общения с внешним миром.  Я сделал небольшую обертку для обработки ошибок сети. Хочу поблагодарить автора данного расширения. Обертка для выполнения https запросов, в случае ошибки пробуем повторить запрос .

CREATE OR REPLACE FUNCTION crypto_bot2.https_get(
    url text,
    params hstore DEFAULT ''::hstore,
    OUT status integer,
OUT content text)
RETURNS record
    LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
    r record;
    error text;
BEGIN	
    url = url || CASE WHEN array_length(akeys(params), 1) > 0 THEN '?' ELSE '' END;
    FOR r IN SELECT key, value FROM  each(params) LOOP
        url = url || '&' || r.key || '=' || urlencode(r.value);
    END LOOP;
    BEGIN
        SELECT h.status, h.content INTO status, content FROM http_get(url) h;
        IF (status != 200) THEN
            PERFORM pg_sleep(1);
            SELECT h.status, h.content INTO status, content FROM http_get(url) h;
        END IF;
    EXCEPTION WHEN OTHERS THEN
             PERFORM pg_sleep(1);
             SELECT h.status, h.content INTO status, content FROM http_get(url) h;
    END;	
    IF (status != 200) THEN
        RAISE EXCEPTION  USING HINT = url,  ERRCODE = 'BW002', MESSAGE = 'Http status =' || status;
    END IF;
EXCEPTION WHEN OTHERS THEN
    GET STACKED DIAGNOSTICS error = MESSAGE_TEXT;
    RAISE EXCEPTION  USING HINT = url, ERRCODE = 'BW002', MESSAGE = error;
END;
$BODY$;

Подключаем телеграмм

Выше я немного рассказал о удобном пакете pgsql-http.  На нем держится все взаимодействие с внешним миром. Для управления ботом я выбрал мессенджер телеграмм. Поскольку он обладает самым удобным API. Я не буду описывать шаги создания бота в телеграмме, в документации к нему все написано, просто покажу процедуры для взаимодействия с сервером телеграмма.

CREATE OR REPLACE FUNCTION crypto_bot2.tlg_call_server(
    method text,
    params hstore DEFAULT ''::hstore)
    RETURNS json
    LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
    url text = 'https://api.telegram.org/botXXXX:YYYYYYYY/';
     j json;
     http_status int;
     sql_state text;
    sql_msg text;
    sql_hint text;
BEGIN
    url = url || method;
    SELECT status, content::json INTO http_status, j FROM  crypto_bot2.https_get(url, params);
    RETURN j;
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS
    sql_state = RETURNED_SQLSTATE,
    sql_msg = MESSAGE_TEXT,
    sql_hint = PG_EXCEPTION_HINT;	
    PERFORM crypto_bot2.tlg_error('%s %s %s', sql_state, sql_msg, sql_hint);
    RETURN null;
END;
$BODY$;

Процедура для отсылки сообщения выглядит так

CREATE OR REPLACE FUNCTION crypto_bot2.tlg_send_msg(
    chat_id text,
    msg text,
    VARIADIC param text[] DEFAULT NULL::text[])
    RETURNS json
    LANGUAGE 'plpgsql'
AS $BODY$
BEGIN
    RETURN  crypto_bot2.tlg_call_server('sendMessage', 
				hstore(array['chat_id', chat_id::text, 'text', 
						format(msg, VARIADIC param)]));
END;
$BODY$;

Бот для работы с телеграмм.

CREATE OR REPLACE FUNCTION2 crypto_bot2.tlg_bot(key text, cmd text)
    RETURNS integer
    LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
    u_id int8;
    params hstore ;
    msgs json;
    m json;
    jt json;
    user_id int8;
    send_text text;
    send_ids int8[];
    s text;
    sql_state text;
    sql_msg text;
    sql_hint text;
    st record;
    r record;
    pr float8;
    btc float8;
    usd float8;
    c int;
BEGIN
    IF (cmd = 'start') THEN
        LOOP
            msgs = crypto_bot2.tlg_call_server('getUpdates') j;
            EXIT WHEN msgs->>'ok' = 'true';
        END LOOP;	
        FOR m IN SELECT json_array_elements(msgs->'result') LOOP
             u_id = m->>'update_id';
        END LOOP;
        DELETE FROM crypto_bot2.tlg_state;
        INSERT INTO crypto_bot2.tlg_state(tlg_error_on, tlg_warning_on, tlg_log_on, tlg_update_id)
        VALUES(true, false, true, u_id);
        DELETE FROM crypto_bot2.tlg_messages;   
             RETURN 0;
    END IF;
	
    SELECT * INTO st FROM crypto_bot2.tlg_state;
     u_id = st.tlg_update_id + 1;
     params = (CASE WHEN (u_id IS NOT NULL) THEN 'offset=>"' || u_id || '"' ELSE '' END)::hstore ||  
           'timeout=>4'::hstore;
     msgs = crypto_bot2.tlg_call_server('getUpdates', params);
     u_id = null;
     IF (msgs->>'ok' = 'true') THEN	
         FOR m IN SELECT json_array_elements(msgs->'result') LOOP
             u_id = (m->>'update_id')::int8;
             user_id = coalesce(m#>>'{message,from,id}',m#>>'{callback_query,from,id}')::int8;
             IF (user_id  != crypto_bot2.tlg_my_id()::int4) THEN
                 jt = crypto_bot2.tlg_send_msg(user_id::text, '%s %s не мешай работать, а то хозяину пожалуюсь', 
		m#>>'{message,from,first_name}', m#>>'{message,from,last_name}');
	 jt = crypto_bot2.tlg_send_msg(crypto_bot2.tlg_my_id(), '%s %s лезет не туда ', 
		m#>>'{message,from,first_name}', m#>>'{message,from,last_name}');			
             ELSE 
                 SELECT * INTO r FROM crypto_bot2.binance_state2 bs;
	CASE coalesce(m#>>'{message,text}', '%@sell_command%&')
                WHEN '%@sell_command%&' THEN
	 -- обработка команд
                WHEN '/status' THEN
	WHEN '/error' THEN
	    UPDATE crypto_bot2.tlg_state SET tlg_error_on = NOT tlg_error_on;
	     s = CASE WHEN NOT st.tlg_error_on 
                    THEN 'Сообщения об ошибках включены' ELSE 'Сообщения об ошибках выключены' END;
	WHEN '/war' THEN
	WHEN '/risk' THEN
	WHEN '/add_sup' THEN
                …..
					
	WHEN '/trade_stop' THEN
	    UPDATE crypto_bot2.binance_state2 SET trade_status = 'STOP';
	    PERFORM crypto_bot2.log('Торговля остановлена');
	    s = 'Спасибо хозяин, отдыхаю';				
               WHEN '/trade_start' THEN
	    UPDATE crypto_bot2.binance_state2 SET is_sell = false, trade_status = 'RUN';
	    PERFORM crypto_bot2.log('Торговля продолжена');
	    s = 'Пошел копать';
	WHEN '/pgadmin_start' THEN
	    s = 'Pgadmin стартовал';
	    COPY (SELECT 'password') TO PROGRAM 'sudo -S systemctl start httpd';
	WHEN '/pgadmin_stop' THEN
	    s = 'Pgadmin остановлен';
	    COPY (SELECT 'password') TO PROGRAM 'sudo -S systemctl stop httpd';
	ELSE
                    s = 'Ой, хозяин не отвлекай';
	END CASE;
	IF (s IS NOT NULL) THEN
	    jt = crypto_bot2.tlg_send_msg(user_id::text, s);
	END IF;
            END IF;
        END LOOP;
    END IF;
    IF (u_id IS NOT NULL) THEN
        UPDATE crypto_bot2.tlg_state SET tlg_update_id = u_id;
    END IF;	
    SELECT array_agg(id), string_agg(msg, chr(10)) INTO send_ids, send_text FROM crypto_bot2.tlg_messages;
    IF (send_text IS NOT NULL) THEN
        FOR i IN 1.. (length(send_text)/2048 + 1) LOOP
             jt = crypto_bot2.tlg_send2_msg(substring(send_text, (i - 1) * 2048, 2048));
             IF (jt->>'ok' != 'true') THEN
                 EXIT;
             END IF;
        END LOOP;
        IF (jt->>'ok' = 'true') THEN
            DELETE FROM crypto_bot2.tlg_messages WHERE id IN (SELECT * FROM unnest(send_ids));
        END IF;	
    END IF;
    RETURN 0;
    EXCEPTION WHEN OTHERS THEN
          GET STACKED DIAGNOSTICS
          sql_state = RETURNED_SQLSTATE,
          sql_msg = MESSAGE_TEXT,
          sql_hint = PG_EXCEPTION_HINT;	
    PERFORM crypto_bot2.tlg_error('%s %s %s', sql_state, sql_msg, sql_hint);
     RETURN -1;
END;
$BODY$;

Бот запускается раз в 5 секунд и проверяет новые сообщения от телеграмм, а так же отправляет сообщения от торговых ботов.

DBMS_PIPE средствами pgsql

В ORACLE есть удобная вещь, это автономные транзакции. Для логирования в таблицу они просто необходимы, иначе в случае ошибки произойдет rollback, и вы ничего не увидите. Вначале я использовал dblink, запускал отдельный процесс, для эмуляции автономной транзакции, но это было не очень красиво, хотелось что-то более изящного. В результате был написан dbms_pipe для postgres.

Создаем fifo mkfifo crypto_bot_pipe.

Создаем процедуру для чтения из pipe .

CREATE OR REPLACE PROCEDURE crypto_bot2.async_cmd_bot()
LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
    cmd text;
    rc record;
    sql_state text;
    sql_msg text;
    sql_hint text;
BEGIN
    CREATE TEMP TABLE IF NOT EXISTS msg_table(msg text);
    LOOP
        COPY msg_table FROM '/var/lib/pgsql/crypto_bot_pipe';
        FOR rc IN (SELECT * FROM msg_table) LOOP
            cmd = rc.msg;
            IF (cmd = 'stop') THEN
                EXIT;
            ELSE
               BEGIN
	    						EXECUTE cmd;
								EXCEPTION WHEN OTHERS THEN
	    					GET STACKED DIAGNOSTICS
	        			sql_state = RETURNED_SQLSTATE,
	        			sql_msg = MESSAGE_TEXT,
	        			sql_hint = PG_EXCEPTION_HINT;
	            -- обработка ошибки
							END;	
							COMMIT;
            END IF;
        END LOOP;
        TRUNCATE msg_table;
        EXIT WHEN cmd ='stop';
    END LOOP;
END;
$BODY$;

Используем команду copy для чтения из pipe в таблицу и затем проходим по этой таблице и выполняем sql запросы и так до тех пор, пока не получим команду stop.  Обязательно поставте внутри процедуры commit иначе ничего не уведите в других сессиях. Сейчас покажу как это использовать.

Создаем процедуру для записи в pipe.

CREATE OR REPLACE FUNCTION crypto_bot2.send_async_cmd(cmd text)
    RETURNS integer
    LANGUAGE 'plpgsql'
    AS $BODY$
BEGIN
    EXECUTE format('COPY (SELECT %s) TO PROGRAM ''cat >>/var/lib/pgsql/crypto_bot_pipe''', quote_literal(cmd)); 
    RETURN 0;	
END;
$BODY$;

Как видим все очень просто, но это позволяет создать некий аналог автономных транзакций, что мне очень помогло. Это решение до сих пор мне очень нравится.

Выполнение системных команд из pgsql

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

COPY (SELECT 'root password') TO PROGRAM 'sudo -S systemctl start httpd';

Логирование действий бота

Система логирование подразумевает фиксацию ошибок бота и его действий, например, по покупки или продажи криптовалюты. Логирование обязательно должно быть асинхронным. Например, в случае продажи криптовалюты мы хотим вывести сообщение в телеграмм, а сервер телеграмма не доступен в этот момент. Если делать это синхронно, то мы должны зависнуть в данной точке и ждать доступности телеграмма, что для нас не приемлемо. Мы записываем сообщение в таблицу используя эмуляцию механизма автономных транзакций, описанную выше и затем срабатывает триггер на таблице логирование и происходит отсылка сообщений в телеграмм. Таким образом мы нигде не зависаем и гарантировано получим уведомление, а также в случае ролбэка в боте, в таблице логера останется запись. Главное не отсылать сообщение об ошибки самого телеграмма в телеграмм.

CREATE TABLE crypto_bot2.log
(
    id BIGSERIAL PRIMARY KEY,
    l_date timestamp with time zone,
    l_type text,
    msg text,
    CONSTRAINT log_pkey PRIMARY KEY (id)
);
CREATE TRIGGER tlg_send_log
    AFTER INSERT
    ON crypto_bot2.log
    FOR EACH ROW
    WHEN (new.l_type <> 'TLG_ERROR'::text)
    EXECUTE PROCEDURE crypto_bot2.tlg_send_log();

CREATE FUNCTION crypto_bot2.tlg_send_log()
    RETURNS trigger
    LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
    st record;
BEGIN
    SELECT * INTO st FROM crypto_bot2.tlg_state;
    IF ((st.tlg_error_on AND NEW.l_type = 'ERROR') OR (st.tlg_log_on AND NEW.l_type = 'LOG') OR     
        (st.tlg_warning_on AND NEW.l_type = 'WARNING')) THEN
	INSERT INTO crypto_bot2.tlg_messages(msg) VALUES(NEW.msg);
    END IF;
    RETURN NULL;
END;
$BODY$;

В функции tlg_send_log  сообщения необходимые для отправки в телеграмм помещаются в таблицу tlg_messages, при очередном запуске телеграмм бота они будут отосланы в телеграмм.

А вот функция логирования.

CREATE OR REPLACE FUNCTION crypto_bot2.log_commit(
msg text,
VARIADIC param text[] DEFAULT NULL::text[])
 RETURNS integer
 LANGUAGE 'plpgsql'
 AS $BODY$
BEGIN
    PERFORM crypto_bot2.send_async_cmd('SELECT crypto_bot2.log(' || 
    quote_literal(format(msg, VARIADIC param)) || ')');
    RETURN 0;
END;
$BODY$;

CREATE OR REPLACE FUNCTION crypto_bot2.log(
    msg text,
    VARIADIC param text[] DEFAULT NULL::text[])
    RETURNS integer
    LANGUAGE 'plpgsql'
    AS $BODY$
    BEGIN
     INSERT INTO crypto_bot2.log(l_date, l_type, msg) VALUES (clock_timestamp(), 'LOG', format(msg, VARIADIC param));
     RETURN 0;	
    END;
$BODY$;

Еще раз вкратце как работает логирование, код у вас перед глазами.

Вызывается функция log_commit, эта функция через pipe посылает команду в другой сессии выполнить функцию log, которая просто вставит сообщение в таблицу log, но это в другой сессии и сразу закомитится. Так что если основная сессия даст сбой log у нас останется.  На таблице log у нас сработает тригер который проверит надо ли отсылать данное сообщение в телеграмм и если надо то положит сообщение в таблицу tlg_messages, и когда запустится, мой телеграмм бот он отошлет это сообщение мне. Свои дети и свои идеи самое лучшее, мне кажется это хорошее решение. Вот кусок кода из телеграм бота который отсылает сообщения

SELECT array_agg(id), string_agg(msg, chr(10)) INTO send_ids, send_text FROM crypto_bot2.tlg_messages;
IF (send_text IS NOT NULL) THEN
    FOR i IN 1.. (length(send_text)/2048 + 1) LOOP
        jt = crypto_bot2.tlg_send2_msg(substring(send_text, (i - 1) * 2048, 2048));
	IF (jt->>'ok' != 'true') THEN
	    EXIT;
	END IF;
    END LOOP;
    IF (jt->>'ok' = 'true') THEN
	DELETE FROM crypto_bot2.tlg_messages WHERE id IN (SELECT * FROM unnest(send_ids));
    END IF;	
END IF;

Если произойдет сбой в отправке, то сообщения не будут удалены из таблицы tls_messages и будут повторно отправлены позже.

Драйвер для доступа к бирже Binance.

Я начинал торговать на wex, затем на kraken и сейчас перебрался на binance. Писал драйверы для всех этих бирж, а так же для тинькоф когда хотел торговать акциями. Здесь пример драйвера для доступа к binance. Так же при чтении данных с binance, биржа не любит когда ее часто опрашивают, учитывая что я торгую 350 пар, то есть делаю 350 запросов в минуту, то надо учитывать просьбы биржи, когда просят замедлить опросы. Для этого написал отдельную маленькую процедуру для опроса биржи. Готов предоставить имеющиеся драйверы для кракена, тинькова, или разработать для других бирж если кого-то заинтересует.

CREATE OR REPLACE FUNCTION crypto_bot2.binance_call_trade_server(
    path text,
    param text DEFAULT NULL::text,
    http_method text DEFAULT 'GET'::text)
    RETURNS jsonb
    LANGUAGE 'plpgsql'
    AS $BODY$
    DECLARE
        url text = 'https://api.binance.com/api/v3';
j text;
key text = 'KEYXXXXXX';
sign text = 'signYYYYYYY';
        p text;
u text;
signature text; 
req http_request;
key_header http_header;
http_status integer;
error text;
BEGIN
    key_header.field = 'X-MBX-APIKEY';
     key_header.value = key;
     p = CASE WHEN param IS NULL THEN 'timestamp=' ELSE param || '×tamp=' END ||   
        round((extract(epoch from clock_timestamp())*1000)::numeric,0);
     signature = encode(hmac(p,sign,'sha256'),'hex'); 
     p = p || '&signature=' || signature;
     req.method = http_method;
     req.headers	= array[key_header];
     u = url || path;
     IF (lower(http_method) = 'get') THEN		
         req.uri =  u || '?' || p;
     ELSE IF (lower(http_method) = 'post') THEN
         req.uri = u;
         req.content_type = 'application/x-www-form-urlencoded';
	    req.content = p;
	END IF; END IF;
	BEGIN
	    SELECT status, content INTO http_status, j FROM http(req);
	    IF (http_status NOT IN (200, 400)) THEN
	        PERFORM pg_sleep(1);
	        SELECT status, content INTO http_status, j FROM http(req);	
	    END IF;
	    IF (http_status != 200) THEN		
	        RAISE EXCEPTION  USING HINT = format('%s?%s', u, p::text),  ERRCODE = 'BW002', MESSAGE = j;
	    END IF;
	    EXCEPTION WHEN OTHERS THEN
	         PERFORM pg_sleep(1);
	         SELECT status, content INTO http_status, j FROM http(req);
	     END;
	     IF (http_status != 200) THEN		
	          RAISE EXCEPTION  USING HINT = format('%s?%s', u, p::text),  ERRCODE = 'BW002', MESSAGE = j;
	    END IF;
	    RETURN j::jsonb;
    END;
   $BODY$;

В случае ошибки пытаюсь повторить запрос если нет, то возвращаю ошибку и бот попробует все повторить заново чуть позже.

CREATE OR REPLACE FUNCTION crypto_bot2.bhttps_get(
    url text,
    params hstore DEFAULT ''::hstore,
     OUT status integer,
      OUT content text)
RETURNS record
LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
    r record;
    error text;
BEGIN	
    url = url || CASE WHEN array_length(akeys(params), 1) > 0 THEN '?' ELSE '' END;
    FOR r IN SELECT key, value FROM  each(params) LOOP
          url = url || '&' || r.key || '=' || urlencode(r.value);
    END LOOP;
SELECT h.status, h.content INTO status, content FROM http_get(url) h;
 -- Бинанс иногда просит притормозить с запросами
 IF (status  = 429) THEN
     PERFORM pg_sleep(10);
 END IF;
  IF (status = 418) THEN
      PERFORM pg_sleep(30);
  END IF;
  IF (status = 403) THEN
       PERFORM pg_sleep(120);
  END IF;	
   IF (status != 200) THEN
       RAISE EXCEPTION  USING HINT = url,  ERRCODE = 'BW002', MESSAGE = 'Http status =' || status;
    END IF;
    EXCEPTION WHEN OTHERS THEN
     GET STACKED DIAGNOSTICS error = MESSAGE_TEXT;
          RAISE EXCEPTION  USING HINT = url, ERRCODE = 'BW002', MESSAGE = error;
   END;
   $BODY$;

Бот для торговли

Код бота для торговли я не привожу. Отмечу только главные вещи, на которые следует обратить внимание если вы будете его реализовывать. Решение о покупке мой бот принимает раз в минуту, опрашивая биржу, затем если бот купил крипту я опрашиваю биржу каждые 5 секунд для принятия решения о продаже, одновременно у меня бывает куплено от 0 до 8 различных крипто валют. Опрос каждые 5 секунд это эмуляция стоп ордера, конечно если биржа упадет и у меня на руках будет криптовалюта, то придется продать ее с убытком, но пока такого не было, и стратегия предусматривает продажу до момента обвала. Хотя такой риск есть, и я его понимаю. Стоп ордера не ставлю, поскольку стратегия не предусматривает продажи по определенной цене, а продажа и покупка происходит при совпадении ряда факторов. Опрашивать биржу чаще раза в минуту если у вас на руках нет купленной криптовалюты не стоит, биржа вас быстро забанит особенно если вы торгуете большим количеством коинов, а принимать решение на основе частых опросов точно не стоит. Никогда не стоит вкладывать все деньги в один коин, и обязательно бот должен фиксировать убытки и не надеется, что все будет хорошо. Число сделок, которые мне не приносят убыток в среднем 72%, но это не значит, что все, что без убытка это прибыль, примерно половина сделок из этих 72% закрывается без прибыли. Самое интересное что при прогоне на акциях этот алгоритм так же показал процент сделок без убытка в 75%, что в пределах погрешности. Меня это обнадежило. Так же я сделал управление ботом, которое позволяет мне продать купленные коины в любой момент, главное у меня нет кнопок их купить, потому что бот при покупке коинов всегда знает в какой момент он будет фиксировать убыток, ну, а прибыль при желании я могу и сам зафиксировать, это иногда полезно на падающем рынке. На растущем рынке лучше все доверить технике, у которой нет ни жадности, ни нервов, ни надежды.

Запуск основной программы

Основная программа запускается и останавливается через командный файл. В centos 7 была известная проблема утечки памяти в библиотеки curl в случае https соединения, данную библиотеку использует пакет pgsql-http. Поэтому приходилось перезапускать бота раз в сутки. Сейчас в centos 8 проблемы нет и бот может работать без перезагрузки, но я оставил данное решение, поскольку в этом случае можно отключить автоматический vacuum в постгрессе и делать его в ручную, что может повысить производительность, но это мысли о будущем.  Так же при старте компьютера проверяется был ли бот запущен и если был, то он запускается снова. Было два раза, когда сервер у провайдера перезапускался и это меня выручало. Тут все просто. Запуск и останов осуществляются следующими командами.

Файл cryptostart.sh

#!/bin/bash
if [ -f crypto_start ]; then
    psql -c 'select crypto_bot2.start(true)'
else
    touch crypto_start
    psql -c 'select crypto_bot2.start(false)'
fi
while [ -f crypto_start ]; do
     psql -c 'select crypto_bot2.start(true)'
done

Файл cryptostop.sh

#!/bin/bash
rm -f crypto_start
psql -c 'select crypto_bot2.stop()'

Файл cryptorestart.sh

#!/bin/bash
if [ -f crypto_start ]; then
while [ -f crypto_start ]; do
     psql -c 'select crypto_bot2.start(true)'
done
fi

Запуск выполняется командой

nohup ./cryptostart.sh &

Файл cryptorestart.sh положил в rc.local

sudo -i -u postgres bash -c 'nohup /var/lib/pgsql/cryptorestart.sh &' 

Основная программа

CREATE OR REPLACE FUNCTION crypto_bot2.start(is_restart boolean DEFAULT false)
RETURNS integer
LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
    st record;
    wt  timestamptz;
    start_time timestamptz;
    wi int;
    dblinks hstore;
    bot_cmd text;
    bot record;
    dblnk text;
    restart_interval interval = '2D'::interval;
    req_time int = 5;
    async_bot text = 'dblink_ASYNC_BOT';
    BEGIN	
        SELECT * INTO st FROM crypto_bot2.bot_state;
IF (st.status = 'RUN' AND NOT is_restart) THEN
    RETURN -1;
        END IF;
-- Запускаем чтение из pipe 
PERFORM crypto_bot2.create_dblink(async_bot);
PERFORM dblink_send_query(async_bot, 'CALL crypto_bot2.async_cmd_bot()');
IF (is_restart) THEN
    PERFORM crypto_bot2.warning_commit('Перегрузился');
ELSE
    PERFORM crypto_bot2.send_async_cmd('DELETE FROM crypto_bot2.bot_state; 
    INSERT INTO   crypto_bot2.bot_state(status, start_time) VALUES(''RUN'', now());');
    PERFORM crypto_bot2.tlg_send_msg(crypto_bot2.tlg_my_id(),'Хозяин, пошел работать');
    PERFORM crypto_bot2.log_commit('Старт');
END IF;
start_time = now();
bot_cmd = CASE WHEN is_restart THEN 'restart' ELSE 'start' END;	
LOOP
    wt = clock_timestamp();
    SELECT * INTO st FROM  crypto_bot2.bot_state t ;
    IF (st.status = 'STOP') THEN
	bot_cmd = 'stop';
	ELSE IF (clock_timestamp() - start_time > restart_interval) THEN
	    SELECT count(*) INTO wi FROM crypto_bot2.bots WHERE params->>'buy_price' IS NOT NULL;
	    IF (wi = 0 OR clock_timestamp() - start_time > restart_interval * 5) THEN
	        bot_cmd = 'pre_restart';
	    END IF;	
    END IF; END IF;
    FOR bot IN (SELECT * FROM crypto_bot2.bots WHERE is_run) LOOP
        dblnk = dblinks->bot.key;
	IF (dblnk IS NULL) THEN
	    dblnk = 'dblink_' || bot.key;
	    PERFORM crypto_bot2.create_dblink(dblnk);
	    PERFORM dblink_exec(dblnk, format('CALL crypto_bot2.init_bot(''%s'')', dblnk));
	    dblinks = CASE WHEN dblinks IS NULL THEN hstore(bot.key, dblnk) 
	                     ELSE dblinks || hstore(bot.key, dblnk) END; 
       END IF;
       IF (dblink_is_busy(dblnk) = 0) THEN
          PERFORM crypto_bot2.clear_dblink(dblnk);
	  PERFORM dblink_send_query(dblnk, format('SELECT crypto_bot2.%s(''%s'', ''%s'')', bot.name, bot.key,
          bot_cmd));
       END IF;	
    END LOOP;	
    EXIT WHEN bot_cmd IN ('stop', 'pre_restart');
     wi = req_time - EXTRACT(epoch FROM(clock_timestamp() - wt));
     IF (wi > 0) THEN
          PERFORM pg_sleep(wi);
     END IF;
     bot_cmd = 'work';
END LOOP;
FOR bot IN (SELECT * FROM each(dblinks)) LOOP
    PERFORM dblink_disconnect(bot.value);
END LOOP;
IF (bot_cmd = 'stop') THEN
    PERFORM crypto_bot2.log_commit('Бот остановлен');
END IF;
-- Останавливаем чтение из pipe
PERFORM crypto_bot2.send_async_cmd('stop');
DELETE FROM crypto_bot2.log WHERE l_date < clock_timestamp() - '7D'::interval AND l_type != 'LOG';
    -- Удаляем данные для бинанса их слишком много 
    DELETE FROM crypto_bot2.binance_trades WHERE  t_date < clock_timestamp() - '1D'::interval;
    DELETE FROM crypto_bot2.binance_data WHERE t_date < clock_timestamp() - '60D'::interval;
    PERFORM dblink_disconnect(async_bot);
    IF (bot_cmd = 'stop') THEN
        PERFORM crypto_bot2.tlg_send_msg(crypto_bot2.tlg_my_id(), 'Хозяин, я закончил работать');
    END IF;	
    RETURN 0;
END;
$BODY$;

CREATE OR REPLACE FUNCTION crypto_bot2.stop()
RETURNS integer
LANGUAGE 'plpgsql'
AS $BODY$
BEGIN
    UPDATE crypto_bot2.bot_state SET status =  'STOP';
    RETURN 0;
END;
$BODY$;

Таблица bot_state

CREATE TABLE crypto_bot2.bot_state
(
    status text , 
    start_time timestamp with time zone
)

Комментариев к основной программе у меня нет, она приведена целиком. Маленький комментарий, боту запрещено перегружаться если у нас куплена крипта. Если будут вопросы готов ответить.

Настройки Postgres

Пришлось немного повозится с конфигурированием базы, поскольку одновременно запускается около 400 процессов постгреса и обрабатывается от 5000 до 50000 операций купли продажи на бирже, в зависимости от поведения рынка. Еще все это надо обсчитать и принять решение. Меня приятно удивило что со всем этим постгрес легко справляется на недорогом облачном хостинге за 14 евро. Параметры компьютера 4 ядра, 8Гб памяти и 160Гб диск. Использую 13 версию постгрес. Здесь привожу только те параметры, которые менял.

max_connections = 1000

shared_buffers = 1024MB 

max_files_per_process = 1500

wal_buffers = 32MB

commit_delay = 100000

Вот как выглядит мой дашбоард с 350 процессами постгресс.

69a79c47fdcf1453bdc295227459e02b.png

Эмуляция работы боты

Я сохраняю все данные собранные в процессе работа бота в таблице, эти данные агрегированы и сохранены все произведенные вычисления, чтобы не повторять вычисления снова. Всегда можно запустить эмулятор, который проходит по этим данным и показывает теоретическое поведение бота. В большинстве случаев оно совпадает с реальным, особенно для маленьких сумм.  При больших суммах бот всегда смотрит корзину и покупает так что бы не продавить корзину больше заданной величины, у меня это 0.015%. Это важно для альтов с маленькой капитализацией. В результате получается расхождение с эмулятором так как состояние корзины в каждый момент времени не сохранить. Так же сейчас я стал перед покупкой смотреть корзину и решение зависит так же от ее состояния, увеличило количество удачный сделок на 5%-10%, это так же не может быть обработано эмулятором.

Эмулятор — это, наверное, самая важная часть бота, я начинал работу именно с него. Сначала я скачал все исторические данные с биржи kraken и изобретал алгоритмы торговли. То, что бот написан целиком на pqsql позволяет сосредоточится именно на алгоритмах, а не на программировании, учитывая, что все необходимые математические функции прекрасно реализованы в postgresql и так же замечательно реализовано работа с json, ну и sql отличная вещь.  Когда у меня получились рабочие алгоритмы я перешел к реальной торговле. Еще раз убеждаюсь, что выбор правильного инструмента — это очень важно.

Обеспечение отказоустойчивости

Я арендую виртуальную машину у hetzner.de, и меня все устраивает, единственное я советую для торговли на binance размещать компьютер в дата центре в Германии, а не в Финляндии. Почему-то сетевых ошибок при доступе к binance из Финляндии гораздо больше чем из Германии. Однажды возникла мысль, если денег будет под управлением будет много как обеспечить надежность в случае обрыва соединения с биржей. Сразу скажу я еще не реализовал данную схему, поскольку на яхту пока не заработал. Однако продумал как это сделать. Объясню только схему. Есть два компьютера установленные в разных дата центрах, желательно на разных континентах. На них работает наша торговая система, одна в рабочем режиме, другая в резерве. Когда рабочая система падает, резервная система переходит в рабочий режим. В электронике есть такой забавный прием watchdog — аппаратно реализованная схема контроля над зависанием системы. Представляет собой таймер, который периодически сбрасывается контролируемой системой. Если сброса не произошло в течение некоторого интервала времени, происходит принудительная перезагрузка системы. В качестве таймера будем использовать ордер, на бирже который заведомо не может выполнится. Например, купить биток за рубль. Мы будем ставить этот ордер раз в 5 минут и убирать и если наша основная система зависнет и, не уберет ордер в течении определенного времени, то управление будет передано резервной системе, и она станет основной. Суть идеи использовать ордер биржи для обеспечения отказоустойчивости.

Выводы

Всегда есть риск потерять деньги, например, биржа закроется или криптовалюту запретят.  Для игры на понижения я использую монеты DOWN которые есть на binance. Правильнее оформлять шорт ордера, тогда вариантов монет намного больше, но пока я до этого не дошел.  Я собирался перейти на торговлю акциями, прогонял тесты на исторических данных по акциям и результат так же был положительным, от 25% до 50% процентов в год, но хорошего апи для торговли не было, а сейчас и подавно вариантов торговать в России нет. Главное отличие алгоритма торговли акциями от алгоритма торговли криптой это временной интервал на основе которого вы принимаете решение, он больше в случае торговли акциями поскольку они менее волотильны, а также торговля криптой идет круглосуточно, а при торговли акциями надо ориентироваться на американские торги.  Сама идея алгоритма что для крипты что для акций одинакова. Главное, чтобы бот фиксировал убытки и не зависал в надежде что все будет хорошо. Еще дам совет, в алгоритме должно быть как можно меньше коэффициентов, а если они есть то типа таких 0.5, 0.25, 1./3. Никогда не подгоняйте коэффициенты к историческим данным. Лучшая проверка алгоритма если у вас совершенно новая идея и вы ее прогнали на исторических данных желательно за большой срок и на разных парах криптовалют или акций и у вас сразу получился на выходе плюс. У меня в телеграмме есть канал куда бот пишет свои действия, можете заглянуть в него, не призываю копировать его решения, но если интересно можете поглядеть https://t.me/criptozhenya.  И еще совет не торгуйте руками, я на этом погорел, на LUNE, хотел много и сразу. Код в git я не выкладывал, но, если кого заинтересует могу вычистить торговых ботов и сделать экспорт приложения.

Главный вывод: можно делать торговых ботов, которые торгуют в плюс, их делать лучше самому и не верить тем, кто предлагает платные сигналы. Мой телеграмм для связи если возникли вопросы https://t.me/antokols .

© Habrahabr.ru