Инициализация репозитория для Зеркального Бота Т-Инвестиций

This commit is contained in:
ada
2025-09-06 11:25:32 +03:00
commit 632811af10
4 changed files with 554 additions and 0 deletions
+27
View File
@@ -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
+155
View File
@@ -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**: Данное ПО предназначено только для образовательных целей. Автоматическая торговля связана с высокими рисками. Тщательно изучите все настройки перед использованием в продакшене.
+366
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
PyYAML
tinkoff_invest
dotenv
tinkoff
tinkoff-invest
tinkoff-investments