Стоп-заявки, 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 мог потеряться, стоп мог сработать или истечь. Позиция без стопа - самое опасное, что остается на счете после рестарта. Поэтому на каждом
старте, первом и после рестарта, бот сначала проверяет защиту и только потом торгует:
- Подписки и
reconcile(журнал): стопы стоят за тем же шлюзом, что и заявки. positions()и список ждущих стопов (waiting). Позиции чужие тоже видны (купленные руками), поэтому бот работает только с теми, для которых знает цену стопа.- По каждой своей длинной позиции складываются количества стопов на продажу по этому инструменту. Хватает - позиция защищена, второй стоп не ставят. Нет ни одного - ставят стоп на всю позицию. Есть, но на часть - решает человек: остаток бот сам не ставит.
- Стоп ставит
protect_once. Если ответ наplace_stopпотерялся (Unknown), стоп ищут вactive_stop_orders, а не ставят заново: как в разделе «Потерянный ответ» выше. Сверка стопов идет по инструменту и стороне, а не поcid: после рестарта Т-Банкcidстопа не возвращает. - Позиции, которые остались без стопа (брокер отказал, стоп не нашелся), функция возвращает списком. Для них бот не открывает новых сделок и сообщает человеку.
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, а стоп останется в списке.
Листать можно клавишами Влево и Вправо.