From 632811af105c2f4d0f7be7707e386e32caa10f6c Mon Sep 17 00:00:00 2001 From: ada Date: Sat, 6 Sep 2025 11:25:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BD=D0=B8=D1=86=D0=B8=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=97=D0=B5=D1=80=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=91=D0=BE=D1=82=D0=B0=20=D0=A2-=D0=98=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 27 ++++ README.md | 155 ++++++++++++++++++++ main.py | 366 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 + 4 files changed, 554 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fdae783 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# токен владельца МАСТЕР-счёта (нужны права чтения портфеля/операций) +MASTER_TOKEN=your_master_token_here +MASTER_ACCOUNT_ID=your_master_account_id + +# токен владельца СЛЕЙВ-счёта (нужны права на выставление ордеров + портфель) +SLAVE_TOKEN=your_slave_token_here +SLAVE_ACCOUNT_ID=your_slave_account_id + +# Коэффициент копирования (0.5 = вдвое меньше, 2.0 = вдвое больше) +COEFF=0.1 + +# DRY-RUN режим: ордера не отправляются (true/false) +DRY_RUN=true + +# Интервал опроса для fallback-пулинга (в секундах) +POLL_INTERVAL_SEC=10 + +# Вайт-/блэк-листы FIGI (через запятую). Пусто — без ограничений. +ALLOW_FIGI= +DENY_FIGI= + +# Лимиты на лоты +MIN_LOTS=1 +MAX_LOTS_PER_ORDER=1000 + +# Файл состояния (чекпоинт для идемпотентности) +STATE_FILE=mirror_state.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a261b9 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# Mirror Bot Tinkoff Investments 🤖💰 + +Зеркальный торговый бот для платформы Тинькофф Инвестиции, который автоматически копирует сделки с мастер-счёта на слейв-счёт с настраиваемым коэффициентом. + +## Описание + +Бот отслеживает торговые операции на мастер-счёте и автоматически повторяет их на слейв-счёте с заданным коэффициентом. Это позволяет создавать стратегию копирования сделок или управления несколькими счетами одновременно. + +### Основные возможности + +- 🔄 **Автоматическое копирование сделок** с мастер-счёта на слейв-счёт +- 📊 **Настраиваемый коэффициент** копирования (можно торговать меньшими или большими объёмами) +- 🔍 **Два режима работы**: стриминг (real-time) и polling (fallback) +- ⚖️ **Автоматическая ребалансировка** портфеля слейв-счёта под мастер-счёт +- 🛡️ **Фильтрация инструментов** через белые/чёрные списки FIGI +- 🧪 **DRY-RUN режим** для тестирования без реальных ордеров +- 💾 **Сохранение состояния** для идемпотентности операций +- 🚫 **Исключение валютных позиций** (RUB, USD, EUR) + +## Установка + +### Требования + +- Python 3.8+ +- Токены Тинькофф Инвестиции для обоих счетов + +### Установка зависимостей + +> Рекомендуется использование виртуального окружения Python. + +```bash +pip install -r requirements.txt +``` + +### Настройка окружения + +1. Скопируйте файл `.env.example` в `.env`: +```bash +cp .env.example .env +``` + +2. Заполните переменные окружения в файле `.env`: + +## Использование + +### Запуск бота + +```bash +python main.py +``` + +### Получение токенов + +1. Перейдите в [Тинькофф Инвестиции](https://www.tinkoff.ru/invest/) +2. Откройте настройки API +3. Создайте токены с необходимыми правами: + - **Мастер-токен**: чтение операций и портфеля + - **Слейв-токен**: выставление ордеров + чтение портфеля + +### ID счетов + +ID счетов можно получить через API или в личном кабинете Тинькофф Инвестиции. + +## Конфигурация + +### Параметры + +| Параметр | Описание | Значение по умолчанию | +|----------|----------|----------------------| +| `COEFF` | Коэффициент копирования сделок | 1.0 | +| `DRY_RUN` | Тестовый режим без реальных ордеров | false | +| `POLL_INTERVAL_SEC` | Интервал polling-режима | 10 | +| `ALLOW_FIGI` | Белый список FIGI (через запятую) | пусто | +| `DENY_FIGI` | Чёрный список FIGI (через запятую) | пусто | +| `MIN_LOTS` | Минимальное количество лотов | 1 | +| `MAX_LOTS_PER_ORDER` | Максимальное количество лотов в ордере | 1000 | + +### Примеры коэффициентов + +- `COEFF=0.5` - слейв-счёт торгует вдвое меньшими объёмами +- `COEFF=1.0` - слейв-счёт повторяет объёмы мастер-счёта +- `COEFF=2.0` - слейв-счёт торгует вдвое большими объёмами + +## Принцип работы + +### Режимы работы + +1. **Streaming (основной)**: Использует WebSocket-стрим для получения сделок в реальном времени +2. **Polling (fallback)**: При сбое стрима переключается на периодический опрос операций + +### Алгоритм + +1. При запуске выполняется ребалансировка портфеля слейв-счёта +2. Бот подключается к потоку сделок мастер-счёта +3. При появлении новой сделки: + - Проверяется фильтр FIGI + - Вычисляется объём для слейв-счёта (мастер_объём × коэффициент) + - Выставляется рыночный ордер на слейв-счёте +4. Периодически выполняется ребалансировка (каждые 2 минуты) + +### Безопасность + +- Валютные позиции (RUB, USD, EUR) автоматически исключаются +- Сохранение состояния предотвращает дублирование ордеров +- DRY-RUN режим для безопасного тестирования +- Лимиты на минимальные и максимальные объёмы + +## Логирование + +Бот выводит подробную информацию о своей работе: + +- `[STREAM]` - события стрим-режима +- `[POLL]` - события polling-режима +- `[REBALANCE]` - операции ребалансировки +- `[REB]` - конкретные ордера ребалансировки + +Пример лога: +``` +Mirror bot starting… DRY_RUN= True COEFF= 1.0 +[REBALANCE] start +[STREAM] connect… Dry-run: True +[STREAM] SBER BBG004730N88: ORDER_DIRECTION_BUY master=10 -> slave=10 + -> SLAVE ORDER_DIRECTION_BUY 10 lot(s) BBG004730N88 (order_id=...) +``` + +## Устранение неполадок + +### Частые ошибки + +1. **"Заполните .env"** - проверьте заполнение всех обязательных переменных +2. **RequestError при ордерах** - проверьте права токена и баланс счёта +3. **Ошибки подключения** - проверьте интернет-соединение и валидность токенов + +### Файл состояния + +Файл `mirror_state.json` содержит: +- Время последней обработки операций +- Список ID обработанных операций (для предотвращения дублей) + +При проблемах можно удалить этот файл для сброса состояния. + +## Безопасность и ответственность + +⚠️ **ВАЖНО**: +- Этот бот выполняет реальные торговые операции с вашими деньгами +- Тщательно протестируйте настройки в DRY-RUN режиме +- Автор не несёт ответственности за торговые потери +- Используйте на свой страх и риск + +## Лицензия + +MIT License - используйте и изменяйте свободно. + + +**Disclaimer**: Данное ПО предназначено только для образовательных целей. Автоматическая торговля связана с высокими рисками. Тщательно изучите все настройки перед использованием в продакшене. diff --git a/main.py b/main.py new file mode 100644 index 0000000..f77d521 --- /dev/null +++ b/main.py @@ -0,0 +1,366 @@ +import asyncio +import json +import os +import time +import uuid +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Tuple + +from dotenv import load_dotenv +from dateutil.relativedelta import relativedelta +from tinkoff.invest import ( + Client, + InstrumentIdType, + OperationState, + OperationType, + OrderDirection, + OrderType, + RequestError, + TradesStreamResponse, + PortfolioResponse, +) + +# ===================== ENV ===================== +load_dotenv() + +MASTER_TOKEN = os.getenv("MASTER_TOKEN", "") +SLAVE_TOKEN = os.getenv("SLAVE_TOKEN", "") +MASTER = os.getenv("MASTER_ACCOUNT_ID", "") +SLAVE = os.getenv("SLAVE_ACCOUNT_ID", "") + +COEFF = float(os.getenv("COEFF", "1.0")) +POLL_INTERVAL_SEC = int(os.getenv("POLL_INTERVAL_SEC", "10")) +DRY_RUN = os.getenv("DRY_RUN", "false").lower() == "true" +ALLOW_FIGI = [x.strip() for x in os.getenv( + "ALLOW_FIGI", "").split(",") if x.strip()] +DENY_FIGI = [x.strip() for x in os.getenv( + "DENY_FIGI", "").split(",") if x.strip()] +MIN_LOTS = int(os.getenv("MIN_LOTS", "1")) +MAX_LOTS_PER_ORDER = int(os.getenv("MAX_LOTS_PER_ORDER", "1000")) +STATE_FILE = Path(os.getenv("STATE_FILE", "mirror_state.json")) + +assert ( + MASTER_TOKEN and SLAVE_TOKEN and MASTER and SLAVE +), "Заполните .env: MASTER_TOKEN, SLAVE_TOKEN, MASTER_ACCOUNT_ID, SLAVE_ACCOUNT_ID" + + +# ===================== STATE ===================== +class State: + def __init__(self, path: Path): + self.path = path + self.data = {"last_from": None, "processed_ids": []} + if path.exists(): + try: + self.data = json.loads(path.read_text()) + except Exception: + pass + + def save(self): + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(self.data, ensure_ascii=False, indent=2)) + tmp.replace(self.path) + + @property + def last_from(self) -> datetime: + val = self.data.get("last_from") + if val: + return datetime.fromisoformat(val) + return datetime.now(timezone.utc) - relativedelta(minutes=15) + + @last_from.setter + def last_from(self, dt: datetime): + self.data["last_from"] = dt.astimezone(timezone.utc).isoformat() + + def seen(self, op_id: str) -> bool: + return op_id in self.data.get("processed_ids", []) + + def mark_seen(self, op_id: str, keep_last_n: int = 10000): + ids = self.data.setdefault("processed_ids", []) + ids.append(op_id) + if len(ids) > keep_last_n: + del ids[: len(ids) - keep_last_n] + + +STATE = State(STATE_FILE) + +# ===================== HELPERS ===================== +# Фильтруем «валютные» позиции (их не копируем): RUB/USD/EUR том/туд +CURRENCY_FIGI_SET = { + "RUB000UTSTOM", + "USD000UTSTOM", + "EUR_RUB__TOM", + "USD000UTSTOD", + "EUR_RUB__TOD", # на всякий случай +} + + +def is_currency_figi(figi: str) -> bool: + return figi in CURRENCY_FIGI_SET or figi.startswith(("RUB", "USD", "EUR")) + + +def is_allowed(figi: str) -> bool: + if is_currency_figi(figi): + return False + if ALLOW_FIGI and figi not in ALLOW_FIGI: + return False + if DENY_FIGI and figi in DENY_FIGI: + return False + return True + + +def map_direction_from_operation(op_type: OperationType): + if op_type == OperationType.OPERATION_TYPE_BUY: + return OrderDirection.ORDER_DIRECTION_BUY + if op_type == OperationType.OPERATION_TYPE_SELL: + return OrderDirection.ORDER_DIRECTION_SELL + return None + + +def clamp_lots(lots: int) -> int: + lots = max(MIN_LOTS, lots) + lots = min(MAX_LOTS_PER_ORDER, lots) + return lots + + +def get_instrument_meta(client: Client, figi: str) -> Tuple[int, str]: + ins = client.instruments.get_instrument_by( + id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_FIGI, + id=figi, + ).instrument + lot = ins.lot or 1 + ticker = ins.ticker or figi + return lot, ticker + + +def place_market_order( + slave_client: Client, + account_id: str, + figi: str, + direction: OrderDirection, + lots: int, +): + order_id = str(uuid.uuid4()) + print( + f" -> SLAVE {direction.name} {lots} lot(s) {figi} (order_id={order_id})") + if DRY_RUN: + return True + try: + slave_client.orders.post_order( + account_id=account_id, + instrument_id=figi, # FIGI допустим как instrument_id + order_id=order_id, + quantity=lots, + direction=direction, + order_type=OrderType.ORDER_TYPE_MARKET, + ) + return True + except RequestError as re: + print(f" !! order rejected: {re}") + return False + except Exception as e: + print(f" !! order error: {e}") + return False + + +# ===================== PORTFOLIO / REBALANCE ===================== +def read_portfolio_positions(client: Client, account_id: str) -> Dict[str, int]: + lots_by_figi: Dict[str, int] = {} + try: + p: PortfolioResponse = client.operations.get_portfolio( + account_id=account_id) + for pos in p.positions: + figi = pos.figi + if not figi or is_currency_figi(figi): + continue + lots = int(getattr(pos.quantity, "units", 0)) + if lots != 0: + lots_by_figi[figi] = lots + except Exception as e: + print(f"[WARN] read_portfolio_positions({account_id}) failed: {e}") + return lots_by_figi + + +_last_rebalance_ts = 0.0 +REBALANCE_COOLDOWN_SEC = 120.0 + + +def rebalance_slave_to_master(master_client: Client, slave_client: Client): + print("[REBALANCE] start") + master_pos = read_portfolio_positions(master_client, MASTER) + slave_pos = read_portfolio_positions(slave_client, SLAVE) + + for figi, master_lots in master_pos.items(): + if not is_allowed(figi): + continue + target_slave_lots = int(round(master_lots * COEFF)) + current_slave_lots = slave_pos.get(figi, 0) + delta = target_slave_lots - current_slave_lots + if delta == 0: + continue + lot, ticker = get_instrument_meta( + master_client, figi + ) # meta из любого клиента ок + if delta > 0: + lots = clamp_lots(delta) + print( + f"[REB] {ticker} {figi}: BUY {lots} (target {target_slave_lots}, have { + current_slave_lots})" + ) + place_market_order( + slave_client, SLAVE, figi, OrderDirection.ORDER_DIRECTION_BUY, lots + ) + else: + lots = clamp_lots(-delta) + print( + f"[REB] {ticker} {figi}: SELL {lots} (target {target_slave_lots}, have { + current_slave_lots})" + ) + place_market_order( + slave_client, SLAVE, figi, OrderDirection.ORDER_DIRECTION_SELL, lots + ) + print("[REBALANCE] done") + + +def rebalance_with_cooldown(master_client: Client, slave_client: Client): + global _last_rebalance_ts + now = time.time() + if now - _last_rebalance_ts < REBALANCE_COOLDOWN_SEC: + print("[REBALANCE] skipped (cooldown)") + return + rebalance_slave_to_master(master_client, slave_client) + _last_rebalance_ts = now + + +# ===================== STREAM ===================== +async def mirror_via_trades_stream(): + """ + Ловим сделки мастера (OrdersStreamService.trades_stream) и зеркалим на слейв с кэфом. + Возвращаем False при ошибке, чтобы верхний цикл переключил fallback. + """ + print("[STREAM] connect… Dry-run:", DRY_RUN) + try: + with Client(MASTER_TOKEN) as master_client, Client(SLAVE_TOKEN) as slave_client: + rebalance_with_cooldown(master_client, slave_client) + + # ВАЖНО: trades_stream принимает именованный аргумент accounts + stream = master_client.orders_stream.trades_stream(accounts=[ + MASTER]) + + for resp in stream: + if not isinstance(resp, TradesStreamResponse): + continue + ev = resp.order_trades + if not ev: + continue + figi = ev.figi + if not figi or not is_allowed(figi): + continue + + master_lots = sum(int(t.quantity) for t in ev.trades) + if master_lots <= 0: + continue + + desired_lots = clamp_lots(int(round(master_lots * COEFF))) + if desired_lots <= 0: + continue + + lot, ticker = get_instrument_meta(master_client, figi) + print( + f"[STREAM] {ticker} {figi}: {ev.direction.name} master={ + master_lots} -> slave={desired_lots}" + ) + place_market_order( + slave_client, SLAVE, figi, ev.direction, desired_lots + ) + return True + except Exception as e: + print(f"[STREAM] error: {e}") + return False + + +# ===================== POLLING (fallback) ===================== +def poll_and_mirror_once(master_client: Client, slave_client: Client): + from_ts = STATE.last_from + to_ts = datetime.now(timezone.utc) + + ops = master_client.operations.get_operations( + account_id=MASTER, from_=from_ts, to=to_ts + ).operations + + new_ops = [] + for op in ops: + if op.state != OperationState.OPERATION_STATE_EXECUTED: + continue + if op.operation_type not in ( + OperationType.OPERATION_TYPE_BUY, + OperationType.OPERATION_TYPE_SELL, + ): + continue + if not op.figi or not is_allowed(op.figi): + continue + if not op.id or STATE.seen(op.id): + continue + lots = int(op.quantity or 0) or int(getattr(op, "quantity_lots", 0)) + if lots <= 0: + continue + new_ops.append(op) + + if new_ops: + print(f"[POLL] {len(new_ops)} new executed ops") + + for op in sorted(new_ops, key=lambda x: x.date): + direction = map_direction_from_operation(op.operation_type) + if direction is None: + STATE.mark_seen(op.id) + continue + + figi = op.figi + master_lots = int(op.quantity or 0) or int( + getattr(op, "quantity_lots", 0)) + desired_lots = clamp_lots(int(round(master_lots * COEFF))) + if desired_lots <= 0: + STATE.mark_seen(op.id) + continue + + try: + lot, ticker = get_instrument_meta(master_client, figi) + except Exception as e: + print(f"[POLL] meta err {figi}: {e}") + STATE.mark_seen(op.id) + continue + + print( + f"[POLL] {ticker} {figi}: {direction.name} { + master_lots} -> {desired_lots}" + ) + place_market_order(slave_client, SLAVE, figi, direction, desired_lots) + STATE.mark_seen(op.id) + + STATE.last_from = to_ts + STATE.save() + + +async def run_with_fallback(): + while True: + ok = await mirror_via_trades_stream() + if ok: + continue + print("[MAIN] switching to polling fallback") + with Client(MASTER_TOKEN) as master_client, Client(SLAVE_TOKEN) as slave_client: + rebalance_with_cooldown(master_client, slave_client) + while True: + try: + poll_and_mirror_once(master_client, slave_client) + except Exception as e: + print(f"[POLL] loop err: {e}") + time.sleep(POLL_INTERVAL_SEC) + + +# ===================== ENTRY ===================== +if __name__ == "__main__": + print("Mirror bot starting… DRY_RUN=", DRY_RUN, " COEFF=", COEFF) + try: + asyncio.run(run_with_fallback()) + except KeyboardInterrupt: + print("Bye") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98e82a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyYAML +tinkoff_invest +dotenv +tinkoff +tinkoff-invest +tinkoff-investments