Горячие клавиши

← и →: предыдущая и следующая глава

S или /: поиск по книге

?: показать эту подсказку

Esc: закрыть подсказку или поиск

Стоп-заявки, Python

Инструкция. Цель: поставить стоп-заявку, посмотреть, что она ждет, найти ее после потерянного ответа и снять. Что такое стоп-заявка, чем различаются брокеры и почему так, написано в общей части: Стоп-заявки (примеры там на Rust, таблицы и правила одни и те же). Здесь то, что зависит от Python. Код ниже - куски программы stops.py, она запускается тестом против имитаций всех брокеров.

Стоп-заявка лежит у брокера и ждет цены. Пока цена не дошла до заданной, на бирже ее нет. Когда дошла, брокер выставляет обычную заявку, рыночную или лимитную. Она есть у всех брокеров: (await client.caps()).ud.supports_stop_orders.

Методы

МетодЧто делает
await client.place_stop(cid=, instrument=, side=, kind=, qty=, stop_price=, limit_price=None, expiry=None)ставит стоп-заявку, возвращает OrderAck с номером брокера
await client.cancel_stop(stop)снимает не сработавшую. stop - StopOrderRef.cid(cid) (голый uuid.UUID значит то же) или StopOrderRef.broker(номер)
await client.active_stop_orders()список StopOrderSnapshot: стоп-заявки, которые ждут срабатывания
await client.subscribe_stop_orders()поток статусов стопов. У всех брокеров Unsupported (capability == "stop_order_stream")

Виды (StopOrderKind): STOP_LOSS (защита позиции), TAKE_PROFIT (фиксация прибыли), STOP_LIMIT (как стоп-лосс, но после срабатывания всегда лимитная заявка, limit_price обязателен). Срок (StopExpiry): good_till_cancel(), end_of_day(), good_till_date(момент). Без expiry стоп живет до отмены. Цены и количество - Decimal, float отвергается, как везде. Количество в штуках инструмента, не в лотах.

Поставить

async def protect(
    client: brokapi.Client, instrument: brokapi.InstrumentId, stop_price: Decimal
) -> tuple[uuid.UUID, brokapi.OrderAck]:
    """Puts a stop-loss that sells one lot if the price falls to `stop_price`, at market."""
    params = await client.trading_params(instrument)  # the unit of the quantity is the instrument, not the lot
    cid = brokapi.new_cid()  # made by the bot, and written to its journal BEFORE the call, as for any order
    ack = await client.place_stop(
        cid=cid,
        instrument=instrument,
        side=brokapi.Side.SELL,
        kind=brokapi.StopOrderKind.STOP_LOSS,
        qty=params.lot_size,
        stop_price=stop_price,
        # limit_price=None: a market order at the trigger. expiry=None: until cancelled.
    )
    # Keep ack.broker_id too: after a restart T-Bank cannot find a stop by its cid, only by the id of the broker.
    return cid, ack

Правила те же, что у обычной заявки. cid придумывает бот и записывает в свой журнал до вызова. Он уходит не больше одного раза: повтор с тем же cid вернет Rejected с кодом duplicate_place, второго стопа у брокера не появится. Стопы стоят за тем же шлюзом: до reconcile будет NotReconciled. Проверки без сети (нет цены у стоп-лимита, срок, которого нет у брокера, количество не кратно лоту) библиотека делает сама до отправки, они не тратят квоту и дают Rejected.

Что ждет и что сработало

async def waiting(client: brokapi.Client) -> list[brokapi.StopOrderSnapshot]:
    """The stops that have not triggered yet. This list is the only way to learn that one has: there is no event."""
    return [stop for stop in await client.active_stop_orders() if stop.state.kind is brokapi.StopOrderStateKind.ACTIVE]

reconcile и active_orders стопов не показывают, у них свой список. О срабатывании узнать можно только опросом: события для стопов нет (потока статусов не дает ни один брокер, библиотека сама не опрашивает). Когда стоп сработал, брокер ставит обычную заявку, и ее ведет обычный поток заявок. Связь между стопом и порожденной заявкой библиотека не строит.

Потерянный ответ

Unknown у place_stop значит то же, что у place: стоп мог дойти до брокера. Повторять с тем же cid нельзя, ставить новый стоп наугад тоже: выйдет два. Смотрите в список. Есть три исхода, их возвращает protect_once:

  • Нашелся стоп с вашим cid (Placement.stop). Он ваш, ничего больше не делайте. БКС, Финам, АЛОР и Bybit возвращают cid сами, Т-Банк - нет, его коннектор узнает стоп по тому, что вы просили, и по времени (подробности в Т-Банк).
  • Нашлись стопы без cid на той же бумаге и в ту же сторону (Placement.unclear). Один из них может быть вашим, сказать наверняка нельзя: у Т-Банка так выглядит и стоп прошлого запуска, и стоп, поставленный вручную. Второй стоп не ставьте. Посмотрите их сами и, если один ваш, дальше работайте с ним по broker_id.
  • Ничего нет (оба поля пустые). Стопа у брокера нет, можно вызвать protect_once еще раз: он возьмет новый cid. Рецепт сначала ждет несколько секунд (wait) и смотрит второй раз, потому что медленный брокер принимает стоп с опозданием.
@dataclass(frozen=True)
class Placement:
    """What is known about the stop after `protect_once`. At most one of the two fields is filled."""

    stop: brokapi.StopOrderSnapshot | None = None
    """Our stop is at the broker, found by its `cid`."""
    unclear: tuple[brokapi.StopOrderSnapshot, ...] = ()
    """Stops without a `cid` on the same instrument and side. One of them may be ours, there is no way to tell. Do not
    place another one: a person looks, or the bot takes one of them by its `broker_id` if it knows it is its own."""
    # Both empty: there is no stop at the broker. It is safe to call `protect_once` again (it makes a NEW cid).


async def protect_once(
    client: brokapi.Client,
    instrument: brokapi.InstrumentId,
    stop_price: Decimal,
    wait: float = 5.0,
    *,
    qty: Decimal | None = None,
) -> Placement:
    """Puts the stop; when the answer is lost, looks for it at the broker instead of putting it again.

    `qty` is in units of the instrument; without it the stop covers one lot."""
    cid = brokapi.new_cid()
    side = brokapi.Side.SELL
    try:
        await client.place_stop(
            cid=cid,
            instrument=instrument,
            side=side,
            kind=brokapi.StopOrderKind.STOP_LOSS,
            qty=qty if qty is not None else (await client.trading_params(instrument)).lot_size,
            stop_price=stop_price,
        )
    except brokapi.Rejected:
        return Placement()  # the broker said no: there is no stop
    except (brokapi.Unknown, brokapi.Transport):
        pass  # it may be there. NEVER place again under this cid: look.

    async def look() -> Placement:
        stops = await client.active_stop_orders()
        ours = next((stop for stop in stops if stop.cid == cid), None)
        if ours is not None:
            return Placement(stop=ours)
        unclear = (
            stop
            for stop in stops
            if stop.cid is None and stop.instrument.key == instrument.key and stop.side is side
        )
        return Placement(unclear=tuple(unclear))

    found = await look()
    if found.stop is None and not found.unclear:
        await asyncio.sleep(wait)  # a slow broker takes the stop late: look once more before saying "nothing"
        found = await look()
    return found

Как проверить свой код на этом сбое: mock.lose_stop_answers() у имитации любого брокера. Брокер принимает стоп, а ответ теряется.

Бот со стопом: старт и рестарт

Бот держит позицию под защитой стопа. Бот упал или его перезапустили, пока позиция была открыта, и теперь неизвестно, стоит ли стоп: его могли не успеть поставить, ответ на place_stop мог потеряться, стоп мог сработать или истечь. Позиция без стопа - самое опасное, что остается на счете после рестарта. Поэтому на каждом старте, первом и после рестарта, бот сначала проверяет защиту и только потом торгует:

  1. Подписки и reconcile(журнал): стопы стоят за тем же шлюзом, что и заявки.
  2. positions() и список ждущих стопов (waiting). Позиции чужие тоже видны (купленные руками), поэтому бот работает только с теми, для которых знает цену стопа.
  3. По каждой своей длинной позиции складываются количества стопов на продажу по этому инструменту. Хватает - позиция защищена, второй стоп не ставят. Нет ни одного - ставят стоп на всю позицию. Есть, но на часть - решает человек: остаток бот сам не ставит.
  4. Стоп ставит protect_once. Если ответ на place_stop потерялся (Unknown), стоп ищут в active_stop_orders, а не ставят заново: как в разделе «Потерянный ответ» выше. Сверка стопов идет по инструменту и стороне, а не по cid: после рестарта Т-Банк cid стопа не возвращает.
  5. Позиции, которые остались без стопа (брокер отказал, стоп не нашелся), функция возвращает списком. Для них бот не открывает новых сделок и сообщает человеку.
async def protect_on_start(
    client: brokapi.Client,
    ledger: Sequence[uuid.UUID],
    stop_price_of: Callable[[brokapi.Position], Decimal | None],
) -> list[brokapi.Position]:
    """Called once, right after the subscriptions, on every start of the bot (the first one and every restart).

    `stop_price_of(position)` is the stop price the bot wants for its own position, or None for a position that is not
    the bot's (the account may hold others, bought by hand): those are left alone.

    Returns the positions that are NOT protected after it. For them the bot does not trade and tells a person."""
    await client.reconcile(ledger)  # the gate of the commands opens; stop orders are behind it too
    unprotected = []
    stops = await waiting(client)  # the stops of every earlier run, whoever put them
    for position in await client.positions():
        stop_price = stop_price_of(position)
        if position.qty <= 0 or stop_price is None:
            continue  # nothing to protect, or not ours (a short position needs a stop on the other side: not in this recipe)
        covered = sum(
            stop.qty
            for stop in stops
            if stop.instrument.key == position.instrument.key and stop.side is brokapi.Side.SELL
        )
        if covered >= position.qty:
            continue  # the stop of the earlier run is there: do not put a second one
        if covered > 0:
            unprotected.append(position)  # a stop for a part: a person decides what to do with the rest
            continue
        # No stop at all. `protect_once` puts it for the whole position. At `Unknown` it looks for the stop at the
        # broker (active_stop_orders) and never sends the same cid again.
        placed = await protect_once(client, position.instrument, stop_price, qty=position.qty)
        if placed.stop is None:
            unprotected.append(position)  # refused, or the stop is not found, or there is one without a cid to look at
    return unprotected

Цену стопа знает бот: он берет ее из своего журнала (цена входа и правило), поэтому она приходит параметром stop_price_of. Для позиции, которая не бота, функция возвращает None, и такую позицию рецепт не трогает. О срабатывании стопа события нет: если позиции или стопа нет на старте, это не ошибка, а возможный результат срабатывания, и бот проверяет это по positions() и по порожденной заявке. Короткие позиции (стоп на покупку) в рецепт не входят.

Снять

async def release(client: brokapi.Client, stop: brokapi.StopOrderSnapshot) -> None:
    """Takes the stop off, by the id of the broker: that works after a restart for every broker."""
    await client.cancel_stop(brokapi.StopOrderRef.broker(stop.broker_id))

Снимать надежнее по номеру брокера (OrderAck.broker_id, StopOrderSnapshot.broker_id): его знают все брокеры и после перезапуска. StopOrderRef.cid(cid) Т-Банк после перезапуска не найдет и вернет Rejected с кодом unknown_stop_order: он не возвращает cid, связь хранится в памяти процесса, который поставил заявку. Повторная отмена ничего не создает. Неизвестный стоп или уже сработавший - Rejected.

Что отличается у брокеров

Подробная таблица на странице Стоп-заявки, коротко:

  • Срок. «До отмены» есть у всех (у Bybit только оно). «До конца дня» у Финама и АЛОРа, «до даты» у Т-Банка и АЛОРа (у Финама только для тейк-профита). Чего нет, то Rejected с кодом unsupported_expiry.
  • Цена после срабатывания тейк-профита. У Финама нет: Rejected, unsupported_limit_price. У БКС, Т-Банка, АЛОРа и Bybit есть.
  • cid в списке. У Т-Банка пока работает процесс, который поставил заявку. После перезапуска StopOrderSnapshot.cid пуст. Исключение - стоп с потерянным ответом: его cid коннектор восстанавливает, если такой стоп один.

Как проверить

Для этих команд нужен клон репозитория: тесты и примеры в пакет pip install brokapi не входят. Версия пакета должна быть той же, что у клона (последний выпуск и его тег).

git clone https://github.com/runinfra/brokapi && cd brokapi
pip install brokapi pytest pytest-asyncio
cd crates/python
pytest tests/test_stops.py tests/test_examples.py tests/test_guides.py

Тесты идут на имитациях всех брокеров: стоп далеко от рынка ждет в списке и снимается, повтор с тем же cid не дублирует, стоп-лимит без цены отвергается до отправки, потерянный ответ ищется в списке (все четыре брокера, и случай, когда рядом стоит такой же стоп без cid). Срабатывание стопа проверено на имитациях БКС и Финама (mock.fill(cid, ...): стоп уходит из списка, заявка исполнена). Имитация Т-Банка срабатывание стопа не умеет: mock.fill для него вернет ValueError, а стоп останется в списке.

Листать можно клавишами Влево и Вправо.

brokapi by RunInfra