Раздел 30 · Web3 на Rust

Движок стратегий: заявки, риск и честный бэктест

senior~80 мин

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

Движок стратегий: заявки, риск и честный бэктест

Стакан собран, задержка измерена. Осталось то, ради чего всё строилось: превратить состояние рынка в заявки и не разориться при этом. Урок состоит из трёх слоёв, и разделение между ними важнее содержимого каждого. Стратегия решает, что хочет. Слой заявок решает, можно ли. Бэктест отвечает, стоило ли, и делает это честно, то есть моделируя задержку и очередь.

Три слоя и границы между ними

Соблазн написать всё одной функцией огромен: увидел перекос, послал заявку. Так делать нельзя, и причина не в эстетике.

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

Слой заявок знает, что висит, что подтверждено и какая у нас позиция. Он же проверяет риск, и проверяет его до отправки.

Исполнитель знает про провод. В бэктесте его роль играет симулятор, в проде сетевой стек.

Граница между стратегией и слоем заявок проходит по типу:

/// Чего хочет стратегия.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
    Place {
        side: Side,
        price: u32,
        quantity: u64,
    },
    Cancel(ClientOrderId),
}

/// Что стратегия видит в момент решения.
#[derive(Debug, Clone, Copy)]
pub struct View<'a> {
    pub book: &'a Book,
    pub position: i64,
    pub now: u64,
}

/// Стратегия: из вида рынка в намерения.
pub trait Strategy {
    /// Возвращает намерения. Пустой список это нормальный ответ: не торговать
    /// это тоже решение, и чаще всего правильное.
    fn decide(&mut self, view: View<'_>, open: &[OrderRecord]) -> Vec<Intent>;

    fn name(&self) -> &'static str;
}

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

Маркетмейкер: три числа определяют всё

Стратегия, которая стоит по обе стороны и живёт на спреде. Экономику мы разбирали в уроке 44, здесь пишем код.

#[derive(Debug, Clone)]
pub struct MarketMaker {
    /// Насколько отойти от справедливой цены.
    pub half_spread: f64,
    pub size: u64,
    pub max_position: i64,
    /// Насколько сдвинуть обе котировки за каждый лот позиции.
    pub skew_per_lot: f64,
    /// Насколько цена должна разойтись с желаемой, чтобы переставлять заявку.
    pub requote_ticks: u32,
}

impl MarketMaker {
    /// Справедливая цена с поправкой на позицию.
    pub fn fair(&self, book: &Book, position: i64) -> Option<f64> {
        let base = book.microprice()?;
        Some(base - position as f64 * self.skew_per_lot)
    }

    fn target(&self, fair: f64, side: Side) -> u32 {
        let raw = match side {
            Side::Bid => (fair - self.half_spread).floor(),
            Side::Ask => (fair + self.half_spread).ceil(),
        };
        raw.max(1.0) as u32
    }
}

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

half_spread это ширина котировки. Уже спред значит больше сделок и больше риска остаться с невыгодной позицией. Шире значит меньше сделок, но каждая выгоднее. В рынке шириной в один тик выбора почти нет: пол-тика означает «встать в очередь на самой вершине», а тик уже уводит во второй уровень, куда очередь доходит редко.

skew_per_lot это главный приём маркетмейкинга и он неочевиден. Купил лишнего, сдвинул обе котировки вниз. Теперь ты продаёшь охотнее и покупаешь неохотнее, и позиция сама возвращается к нулю. Никакого отдельного механизма закрытия позиции не нужно, за тебя это делает рынок.

max_position это жёсткая граница, за которой мы просто перестаём котировать одну из сторон.

requote_ticks это то, о чём забывают. Ноль означает переставлять заявку на каждое изменение микроцены, а каждая перестановка обнуляет место в очереди. Движок с нулевым порогом торгует очень активно и не исполняется почти никогда.

impl Strategy for MarketMaker {
    fn decide(&mut self, view: View<'_>, open: &[OrderRecord]) -> Vec<Intent> {
        let Some(fair) = self.fair(view.book, view.position) else {
            // Одна из сторон стакана пуста: справедливой цены нет, котировать
            // нечего. Молчание тут честнее выдуманного числа.
            return Vec::new();
        };

        let mut intents = Vec::new();
        for side in [Side::Bid, Side::Ask] {
            let want = self.target(fair, side);

            // Позиция уже на пределе с этой стороны: не добавляем.
            let blocked = match side {
                Side::Bid => view.position >= self.max_position,
                Side::Ask => view.position <= -self.max_position,
            };

            let existing = open.iter().find(|order| order.side == side);
            match existing {
                Some(order) if blocked => intents.push(Intent::Cancel(order.id)),
                Some(order) => {
                    let drift = order.price.abs_diff(want);
                    if drift >= self.requote_ticks {
                        // Снимаем сейчас, поставим на следующем решении: пока
                        // отмена не подтверждена, вторая заявка с той же
                        // стороны это удвоенный риск.
                        intents.push(Intent::Cancel(order.id));
                    }
                }
                None if blocked => {}
                None => intents.push(Intent::Place {
                    side,
                    price: want,
                    quantity: self.size,
                }),
            }
        }
        intents
    }
}

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

Моментум: платить за скорость входа

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

Сигнал простой: микроцена ушла от своего скользящего среднего дальше, чем на порог.

/// Экспоненциальное скользящее среднее.
///
/// Память в одно число и обновление в одно умножение. Обычное среднее
/// по окну требует хранить окно и вычитать выпавшее значение, а это лишняя
/// память и лишний проход на горячем пути.
#[derive(Debug, Clone, Copy)]
pub struct Ewma {
    alpha: f64,
    value: Option<f64>,
}

impl Ewma {
    pub fn update(&mut self, sample: f64) -> f64 {
        let next = match self.value {
            None => sample,
            Some(previous) => self.alpha * sample + (1.0 - self.alpha) * previous,
        };
        self.value = Some(next);
        next
    }
}

impl Strategy for Momentum {
    fn decide(&mut self, view: View<'_>, open: &[OrderRecord]) -> Vec<Intent> {
        // Пока предыдущая заявка не отработала, новых не шлём: у агрессивной
        // стратегии двойная отправка это двойная позиция.
        if !open.is_empty() {
            return Vec::new();
        }
        let Some(deviation) = self.deviation(view.book) else {
            return Vec::new();
        };
        if deviation.abs() < self.threshold {
            return Vec::new();
        }

        let side = if deviation > 0.0 { Side::Bid } else { Side::Ask };
        // Бьём по лучшей цене противоположной стороны: заявка исполнится
        // сразу, и за это мы платим спред.
        let price = match side {
            Side::Bid => view.book.best_ask(),
            Side::Ask => view.book.best_bid(),
        };
        let Some(price) = price else {
            return Vec::new();
        };

        vec![Intent::Place {
            side,
            price,
            quantity: self.size,
        }]
    }
}

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

Слой заявок: состояние и деньги

Заявка живёт не мгновение. Она уходит в провод, ждёт подтверждения, стоит, частично исполняется, снимается. Каждое из этих состояний означает разное для риска.

/// Жизнь заявки. Переходы односторонние: назад из `Filled` дороги нет.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderState {
    /// Ушла в провод, подтверждения нет.
    Pending,
    /// Биржа подтвердила, заявка стоит в стакане.
    Live,
    /// Часть исполнена, остаток стоит.
    PartiallyFilled,
    /// Исполнена целиком.
    Filled,
    /// Отмена отправлена, подтверждения нет.
    Cancelling,
    /// Снята.
    Cancelled,
    /// Биржа отказала.
    Rejected,
}

impl OrderState {
    /// Живёт ли заявка на бирже прямо сейчас (или может там жить).
    pub fn is_open(self) -> bool {
        matches!(
            self,
            OrderState::Pending
                | OrderState::Live
                | OrderState::PartiallyFilled
                | OrderState::Cancelling
        )
    }
}

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

Деньги считаем целыми. Касса растёт при продаже и падает при покупке:

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Ledger {
    pub cash: i64,
    pub position: i64,
    pub filled_lots: u64,
    pub trades: u64,
}

impl Ledger {
    pub fn apply_fill(&mut self, side: Side, price: u32, quantity: u64) {
        let notional = price as i64 * quantity as i64;
        match side {
            Side::Bid => {
                self.cash -= notional;
                self.position += quantity as i64;
            }
            Side::Ask => {
                self.cash += notional;
                self.position -= quantity as i64;
            }
        }
        self.filled_lots += quantity;
        self.trades += 1;
    }

    /// Прибыль по рынку: касса плюс позиция, оценённая по текущей цене.
    pub fn mark_to_market(&self, mark: f64) -> f64 {
        self.cash as f64 + self.position as f64 * mark
    }
}

Пока позиция не нулевая, прибыль это оценка, а не факт. Число cash само по себе ничего не значит: продал на миллион и остался в короткой позиции это не прибыль, это обязательство.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.