mirror of
https://github.com/ada-dmitry/mirror_bot_tinv.git
synced 2026-09-24 00:00:17 +00:00
Инициализация репозитория для Зеркального Бота Т-Инвестиций
This commit is contained in:
@@ -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
|
||||
@@ -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**: Данное ПО предназначено только для образовательных целей. Автоматическая торговля связана с высокими рисками. Тщательно изучите все настройки перед использованием в продакшене.
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
PyYAML
|
||||
tinkoff_invest
|
||||
dotenv
|
||||
tinkoff
|
||||
tinkoff-invest
|
||||
tinkoff-investments
|
||||
Reference in New Issue
Block a user