Деньги, цены и количества
Цена, количество, лот, баланс и комиссия в библиотеке - точные десятичные числа. f64 для них нигде не используется.
Страница объясняет, почему так, как создавать числа, как округлять цену до шага и что делает библиотека при переводе
форматов брокеров.
Python. Цена, количество и деньги - decimal.Decimal. В вызов можно передать Decimal, int или строку, float отвергается с TypeError. Округление до шага - brokapi.round_to_step(цена, шаг, RoundMode.DOWN), правила те же, что у round_to_step в Rust. Время - datetime с часовым поясом.
Три типа
| Тип | Что это | Где встречается |
|---|---|---|
Price | цена одной бумаги | заявки, стакан, сделки, свечи, stop_price, price_step |
Qty | количество в единицах инструмента, размер лота | заявки, стакан, сделки, исполнения, позиции, объем свечи, lot_size, min_qty |
Money | сумма денег | баланс (available, blocked), комиссия, price_step_value |
Внутри у каждого Decimal (крейт rust_decimal): целое на 96 бит и до 28 знаков после запятой. Типы разные нарочно:
количество нельзя подставить вместо цены, а сумму в рублях вместо количества. Библиотека отдает Decimal и макрос dec!
из brokapi_core.
Почему не f64
Брокеры присылают цены десятичными числами, и сетка биржи тоже десятичная. В двоичной дроби 244.3 не лежит ровно:
assert_ne!(0.1_f64 + 0.2, 0.3); // 0.30000000000000004
assert_ne!(244.3_f64 % 0.01, 0.0); // остаток 6.3e-15: цена «не на шаге»
assert_ne!((109.32_f64 / 0.01).fract(), 0.0); // 10931.999999999998 шагов
Для заявки это значит: цена, которая на бирже верная, у вас «не кратна шагу», а округление до шага уезжает на одну
копейку. Суммы портфеля набегают копейками. С Decimal те же числа точные: 0.1 + 0.2 == 0.3, 244.3 кратно 0.01.
Эталон, NautilusTrader, хранит цену, количество и деньги целыми числами с фиксированным масштабом. Мы взяли другое:
Decimal со своим масштабом у каждого числа. Брокеры дают разное число знаков (Т-Банк до девяти, БКС до восьми у цены,
Финам строку любой длины), и один общий масштаб либо режет цену, либо сужает диапазон. Скорость целых нам не нужна: это
слой подключения, а не движок, между числом и биржей сетевая задержка в миллисекунды.
Как создать число
use brokapi::core::{Decimal, Price, Qty, dec};
let a = Price::new(dec!(250.15)); // из литерала
let b: Price = "250.15".parse()?; // из текста: файл, форма, переменная окружения
let c = Price::from(250); // из целого
let d = Price::from_scaled(25015, 2); // из мантиссы и числа знаков: 250.15
assert_eq!(a, b);
let lots = Qty::from(10);
let sum: Qty = [Qty::from(1), Qty::from(2)].into_iter().sum();
- Запятая не разделитель:
"12,5"- ошибка разбора (NumberError::Parse). - Числа сравниваются по значению:
Price244.30равно244.3. А текст сохраняет запись:244.30печатается244.30. Price * DecimalдаетPrice(так считается «на 1% ниже»:bid * Decimal::new(99, 2)). Масштаб произведения больше:244.30 * 0.99печатается241.8570. Послеround_to_stepдо шага 0.01 значение241.85, а печать может быть241.8500: значение то же, масштаб записи не важен.- Из
f64число делается только явно и с округлением до заданного числа знаков:Price::from_f64_lossy(244.3, 2). Имя говорит, что точность потеряна:f64уже принес двоичный шум, округление его только прячет. Обратно -to_f64_lossy(), для графика или строки лога, но не для цены заявки. - В JSON число - строка:
"244.30". Так его не округлит ни один читатель.
Как округлить до шага цены
Шаг цены и размер лота лежат в TradingParams (Справочник API). Округление точное, сетка начинается с нуля:
use brokapi::core::{Price, RoundMode, dec};
let step: Price = "0.05".parse()?;
let p: Price = "101.03".parse()?;
assert_eq!(p.round_to_step(step, RoundMode::Down)?, "101.00".parse()?); // покупка: не дороже расчета
assert_eq!(p.round_to_step(step, RoundMode::Up)?, "101.05".parse()?); // продажа: не дешевле расчета
assert_eq!(p.round_to_step(step, RoundMode::Nearest)?, "101.05".parse()?); // ближайшая, половина - от нуля
assert!(p.round_to_step(step, RoundMode::Up)?.is_on_step(step));
Если параметры инструмента уже прочитаны (trading_params), короче так: params.round_price(price, mode) и
params.round_qty(qty, mode). Нулевой или отрицательный шаг - ошибка NumberError::StepNotPositive.
Проверка цены коннектором
Коннектор не знает шаг сам: он узнает его от вас. У каждого коннектора заявок есть price_grid(). Заполните его один раз
на инструмент:
let params = md.trading_params(&sber).await?;
ud.price_grid().learn(¶ms);
Дальше place, replace и place_stop проверяют цену заявки (и stop_price, limit_price). Цена не на шаге -
Rejected с кодом price_off_step, до отправки: квота не тратится, cid остается свободным. Если шаг инструменту
не передан, цена не проверяется, решает брокер. Таблицу можно задать и при создании: поле price_grid конфига
UserDataConfig, одну на несколько коннекторов (копии таблицы - один и тот же список).
Количество проверяется так же и по той же таблице: price_grid().learn(¶ms) запоминает и шаг цены, и размер лота. Количество,
которое не является целым числом лотов, это Rejected с кодом invalid_qty, до отправки. Если размер лота инструменту не
передан, количество с лотом не сверяется, решает брокер (у Т-Банка лот коннектор читает сам, см. ниже). Нуль, отрицательное и
дробное количество отказывают всегда, где брокер берет только целые. Ничего не округляется: округленная заявка купила бы не то,
что вы просили.
Количество: единицы инструмента, не лоты
Qty везде значит число единиц инструмента: штук акций, облигаций, единиц валюты, контрактов. Одно и то же в заявке, в
уровне стакана, в сделке рынка, в исполнении, в позиции и в объеме свечи, у любого брокера. Так же считает
NautilusTrader и сама биржа. Брокер, который считает лотами (Т-Банк), переводится коннектором в обе стороны: ваш код лотов не видит.
Что такое лот и единица на разных рынках:
| Рынок | Единица (Qty = 1) | Лот (TradingParams::lot_size) | Пример |
|---|---|---|---|
| Акции | одна акция | число акций в лоте на бирже: 1, 10, 100 | у SBER лот 10: заявка на 30 это три лота |
| Облигации | одна облигация | обычно 1 | цена в процентах от номинала, а не в рублях: Price 98,5 значит 98,5% номинала. Сколько это в деньгах, считает бот: номинал, процент и накопленный купон |
| Валюта на бирже | одна единица валюты | обычно 1000 | у пары с лотом 1000 заявка на 3000 это три лота |
| Фьючерсы и опционы | один контракт | 1 | цена в пунктах, стоимость шага в деньгах - price_step_value. Сколько базового актива в контракте, библиотека не передает |
Правила, одинаковые для всех:
- Количество заявки положительное и кратное лоту. Иначе
Rejectedс кодомinvalid_qty, ничего не отправлено,cidне потрачен. - Лот узнается из
TradingParams::lot_size(в единицах), минимальное количествоmin_qtyравно одному лоту. - Позиция, сделка и объем свечи приходят в единицах так, как их присылает брокер, коннектор пересчитывает только то, что брокер считает лотами.
Что делает каждый коннектор:
| Брокер | Что считает API | Что делает коннектор |
|---|---|---|
| БКС | orderQuantity в штуках (документация: «Количество в заявке (шт.)»), целое не меньше 1 | передает число как есть. Дробное и нуль: invalid_qty. Кратность лоту проверяется, если лот передан в price_grid() |
| Т-Банк | заявки, стакан, сделки рынка и свечи в лотах, свои сделки и позиции в штуках | узнает лот инструмента один раз (GetInstrumentBy, запрос к квоте) и переводит: заявку делит на лот, стакан, сделки рынка, свечи и статусы заявок умножает. Не кратно лоту: invalid_qty до отправки |
| Финам | штуки, число десятичное | передает как есть. Кратность лоту проверяется, если лот передан в price_grid() |
| АЛОР | заявка в лотах, ответы заявок, сделок и позиций и в лотах, и в штуках, а уровни стакана, лента и свечи - как пришло (единицу объема в них документация не называет); объем лучших цен в котировках - в лотах | читает размер лота один раз (lotsize справочника), делит заявку на лот и читает штуки из ответов. Некратное лоту: invalid_qty, ничего не округляется. Объем котировки умножает на лот |
| Bybit | количество в единицах базовой монеты, десятичное, у каждого контракта свой шаг (qtyStep) и минимум (minOrderQty) | читает правила контракта один раз и до отправки отказывает: не кратное шагу или меньше минимума invalid_qty, цена не на шаге price_off_step. Минимальную стоимость заявки и предельное количество не проверяет: ответит биржа |
Как числа переводятся из формата брокера
Ни один путь не идет через f64. Что делает каждый коннектор:
| Брокер | Формат | В библиотеку | Из библиотеки |
|---|---|---|---|
| Т-Банк | Quotation и MoneyValue: целая часть units и миллиардные доли nano, у обеих частей знак числа | units + nano / 10^9, точно | на части: до 9 знаков, иначе Rejected { invalid_price }, молчаливого округления нет |
| Финам | google.type.Decimal (строка, допускает знак и порядок) и google.type.Money (units, nanos) | строка разбирается как есть | строка из числа без порядка, запись сохраняется: 244.30 уходит как "244.30" |
| БКС | числа JSON | сразу в Decimal из текста ответа (serde_json с arbitrary_precision) | JSON-числом с теми же знаками |
| Bybit | числа в строках JSON ("0.073547") | сразу в Decimal из текста ответа | строкой с теми же знаками |
Что именно сохраняется: 244.30 не теряет нуль на конце, 0.0001 остается 0.0001, число с 19 знаками не
обрезается. Это проверено тестами на всех форматах, в том числе случайными числами туда и обратно.
Проценты в моделях БКС (доля в портфеле, доходность, изменение в процентах, ставка купона) - это
отношения, а не деньги, цены или количества: поэтому они f64. Деньги и цены в brokapi-bcs-protocol - Decimal.
Листать можно клавишами Влево и Вправо.