From c514b658627c996aa2bc7745c4184b31fd5fff64 Mon Sep 17 00:00:00 2001 From: ada Date: Sat, 6 Sep 2025 15:08:20 +0300 Subject: [PATCH] Init Repo --- .env.example | 2 + LICENSE | 21 ++ README.md | 347 +++++++++++++++++++++++++++++++ config.yml | 18 ++ main.py | 461 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 + tools/check_balance.py | 55 +++++ tools/check_figi.py | 42 ++++ tools/search.py | 50 +++++ 9 files changed, 1002 insertions(+) create mode 100644 .env.example create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.yml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 tools/check_balance.py create mode 100644 tools/check_figi.py create mode 100644 tools/search.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..09c8b2c --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +TINKOFF_TOKEN="your_tinvest_token" # Необходим токен с полным доступом. +TINKOFF_ACCOUNT_ID="your_account_id" # Опционально diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50554f9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Dmitry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..077ec26 --- /dev/null +++ b/README.md @@ -0,0 +1,347 @@ +# DCA Bot для Tinkoff Invest + +Автоматический бот для реализации стратегии Dollar Cost Averaging (DCA) через API Tinkoff Invest. Поддерживает работу с акциями, ETF, облигациями и валютами на реальном брокерском счете. + +## Что это такое? + +DCA (Dollar Cost Averaging) — это инвестиционная стратегия, при которой вы регулярно покупаете финансовые инструменты на фиксированную сумму, независимо от их текущей цены. Это помогает снизить влияние волатильности рынка на общую стоимость инвестиций. + +## Основные возможности + +- ✅ Автоматическое распределение бюджета по заданным весам между инструментами +- ✅ Поддержка акций, ETF, облигаций и валют +- ✅ Корректный расчет стоимости облигаций (номинал + НКД) +- ✅ Проверка торгового статуса инструментов +- ✅ Защита от ошибок недостатка средств с автоматическим уменьшением размера ордера +- ✅ Детальное логирование всех операций в CSV +- ✅ Режим симуляции (dry-run) для тестирования +- ✅ Буферы для комиссий и проскальзывания +- ✅ Работа только с реальными счетами (без песочницы) + +## Требования + +- Python 3.8+ +- Реальный брокерский счет в Tinkoff +- API токен с правами на торговлю + +## Установка + +1. Клонируйте репозиторий: +```bash +git clone +cd dca_bot +``` + +2. Установите зависимости: + +> Рекомендуется использование виртуального окружения Python. +```bash +pip install -r requirements.txt +``` + +3. Скопируйте файл с переменными окружения: +```bash +cp .env.example .env +``` + +4. Заполните `.env` файл: +```env +TINKOFF_TOKEN="ваш_токен" +TINKOFF_ACCOUNT_ID="ваш_id_счета" # опционально +``` + +## Настройка + +Создайте файл конфигурации `config.yml` со списком инструментов и их весами: + +```yaml +assets: + - figi: "BBG004730N88" # Сбербанк + weight: 0.3 # 30% + - figi: "BBG00475KKY8" # Яндекс + weight: 0.2 # 20% + - figi: "BBG333333333" # ОФЗ + weight: 0.3 # 30% + - figi: "BBG444444444" # Доллар США + weight: 0.2 # 20% +``` + +**Важно:** Сумма весов должна равняться 1.0! + +## Использование + +### Основная команда + +```bash +python main.py --config config.yml --rub-budget 10000 +``` + +### Режим симуляции (тестирование) + +```bash +python main.py --config config.yml --rub-budget 10000 --dry-run +``` + +### Дополнительные параметры + +```bash +python main.py \ + --config config.yml \ + --rub-budget 10000 \ + --dry-run \ + --fee-buf-bps 300 \ + --safe-rub-pct 0.97 \ + --wait-tradable-sec 60 \ + --poll-sec 10 +``` + +**Параметры:** +- `--config` — путь к YAML файлу конфигурации (обязательно) +- `--rub-budget` — общий бюджет в рублях для данного запуска (обязательно) +- `--dry-run` — режим симуляции, реальные ордера не размещаются +- `--fee-buf-bps` — буфер для комиссий в базисных пунктах (по умолчанию 300 = 3%) +- `--safe-rub-pct` — использовать только N% от доступных средств (по умолчанию 0.97 = 97%) +- `--wait-tradable-sec` — ждать до N секунд пока инструмент станет торгуемым (по умолчанию 0) +- `--poll-sec` — интервал проверки торгового статуса в секундах (по умолчанию 10) + +## Вспомогательные инструменты + +В папке `tools/` находятся полезные утилиты для работы с API Tinkoff Invest: + +### 1. Проверка баланса (`check_balance.py`) + +```bash +python tools/check_balance.py +``` + +**Назначение:** Показывает текущее состояние вашего брокерского счета. + +**Что показывает:** +- ID активного счета +- Свободные рубли (RUB free) +- Заблокированные рубли (RUB blocked) +- Доступные для торговли рубли (RUB available) +- Общая оценка портфеля в валютах (если доступно) + +**Пример вывода:** +``` +ACCOUNT_ID: 2235046505 +RUB free: 25000.00 +RUB blocked: 1500.00 +RUB available: 23500.00 +Portfolio total_amount_currencies: 150000.50 +``` + +**Когда использовать:** +- Перед запуском DCA бота для проверки достаточности средств +- Для мониторинга состояния счета +- При отладке проблем с размещением ордеров + +### 2. Проверка инструментов (`check_figi.py`) + +```bash +python tools/check_figi.py +``` + +**Назначение:** Проверяет корректность FIGI из вашей конфигурации и получает актуальные рыночные данные. + +**Настройка:** Отредактируйте список `FIGI` в начале файла, добавив ваши FIGI из `config.yml`: + +```python +FIGI = [ + "BBG004730N88", # Сбербанк + "BBG00475KKY8", # Яндекс + "BBG0013HGFT4", # USD000UTSTOM + # добавьте ваши FIGI здесь +] +``` + +**Что показывает:** +- Информацию об инструменте (тикер, название, размер лота, валюта) +- Текущие рыночные цены +- Список инструментов без доступных цен + +**Пример вывода:** +``` +BBG004730N88: SBER | ПАО Сбербанк | lot=10 | curr=rub +BBG00475KKY8: YNDX | Яндекс Н.В. | lot=1 | curr=rub +PRICE BBG004730N88: 285.50 +PRICE BBG00475KKY8: 2850.00 +Нет last price для: [] +``` + +**Когда использовать:** +- При настройке нового `config.yml` +- Для проверки актуальности FIGI перед запуском +- При отладке проблем с получением цен + +### 3. Поиск инструментов (`search.py`) + +```bash +python tools/search.py +``` + +**Назначение:** Помогает найти FIGI для нужных финансовых инструментов по тикеру или названию. + +**Текущие поисковые запросы в скрипте:** +- Поиск золота по тикеру "TGLD@" +- Поиск ОФЗ 26212 по названию +- Поиск ETF "TPAY" +- Поиск основных валют (USD, EUR, GBP, JPY) + +**Как настроить под свои нужды:** + +1. **Поиск по тикеру акции:** +```python +shares = client.instruments.shares() +for s in shares.instruments: + if s.ticker == "SBER": # замените на нужный тикер + print(s.ticker, s.figi, s.name) +``` + +2. **Поиск облигаций по названию:** +```python +bonds = client.instruments.bonds() +for b in bonds.instruments: + if "Сбербанк" in b.name: # замените на нужное название + print(b.name, b.figi) +``` + +3. **Поиск ETF:** +```python +etfs = client.instruments.etfs() +for e in etfs.instruments: + if "TECH" in e.ticker: # замените на нужный тикер + print(e.ticker, e.figi, e.name) +``` + +4. **Поиск валют:** +```python +currencies = client.instruments.currencies() +for cur in currencies.instruments: + if "USD" in (cur.ticker or ""): + print(cur.ticker, cur.figi, cur.name) +``` + +**Пример вывода:** +``` +TGLD@ BBG222222222 Золото +ОФЗ 26212 BBG00Y91R9T3 ОФЗ 26212 +TPAY BBG111111111 TCS Payments +USD000UTSTOM BBG0013HGFT4 Доллар США +``` + +**Когда использовать:** +- При составлении нового портфеля +- Для поиска альтернативных инструментов +- При обновлении FIGI (например, для новых выпусков ОФЗ) + +### Полезные советы по работе с инструментами + +1. **Всегда проверяйте FIGI** перед добавлением в конфигурацию с помощью `check_figi.py` + +2. **Для облигаций** FIGI может изменяться при новых выпусках, регулярно обновляйте + +3. **Валютные пары** имеют разные FIGI для разных сроков поставки (TOM, TOD, SPOT) + +4. **ETF и фонды** могут иметь ограничения по времени торговли + +5. **Используйте поиск** для нахождения похожих инструментов в той же отрасли + +### Автоматизация проверок + +Можно создать скрипт для автоматической проверки перед запуском DCA: + +```bash +#!/bin/bash +echo "=== Проверка баланса ===" +python tools/check_balance.py + +echo -e "\n=== Проверка инструментов ===" +python tools/check_figi.py + +echo -e "\n=== Запуск DCA (dry-run) ===" +python main.py --config config.yml --rub-budget 10000 --dry-run +``` + +## Логирование + +Все операции записываются в файл `orders_log.csv` со следующими полями: + +- `ts` — временная метка +- `period_key` — ключ периода (YYYY-MM) +- `figi` — FIGI инструмента +- `ticker` — тикер +- `name` — название инструмента +- `instrument_type` — тип (share, bond, etf, currency) +- `lots` — количество лотов +- `lot_size` — размер лота +- `filled_price_per_share` — цена исполнения за бумагу +- `cost_rub` — общая стоимость в рублях +- `status` — статус операции +- `order_request_id` — ID запроса (если не dry-run) + +## Особенности работы + +### Облигации +Для облигаций автоматически рассчитывается полная стоимость с учетом номинала и накопленного купонного дохода (НКД). + +### Защита от ошибок +- Автоматическое уменьшение размера ордера при ошибке 30042 (недостаток средств) +- Проверка торгового статуса инструментов +- Пропуск неторгуемых инструментов +- Буферы для комиссий и проскальзывания + +### Валюты +Поддерживаются только инструменты в рублях. Валютные инструменты пропускаются. + +## Безопасность + +1. **Никогда не коммитьте `.env` файл** — он содержит секретные токены +2. **Используйте токены только с необходимыми правами** +3. **Тестируйте конфигурацию в режиме `--dry-run`** +4. **Проверяйте баланс перед запуском** + +## Получение токена + +1. Перейдите в [личный кабинет Tinkoff Invest](https://www.tinkoff.ru/invest/) +2. Настройки → API → Создать токен +3. Выберите права: "Торговля" и "Чтение счетов" +4. Скопируйте токен в `.env` файл + +## Автоматизация + +Для регулярного запуска можно использовать cron: + +```bash +# Каждый понедельник в 10:00 +0 10 * * 1 cd /path/to/dca_bot && python main.py --config config.yml --rub-budget 10000 + +# Каждое 1 число месяца в 15:30 +30 15 1 * * cd /path/to/dca_bot && python main.py --config config.yml --rub-budget 50000 +``` + +## Структура проекта + +``` +dca_bot/ +├── main.py # Основной скрипт +├── config.yml # Конфигурация портфеля (создать самостоятельно) +├── requirements.txt # Зависимости Python +├── .env # Переменные окружения (создать из .env.example) +├── .env.example # Шаблон переменных окружения +├── orders_log.csv # Лог операций (создается автоматически) +├── tools/ +│ ├── check_balance.py # Проверка баланса +│ ├── check_figi.py # Проверка инструментов +│ └── search.py # Поиск инструментов +└── README.md # Этот файл +``` + +## Лицензия + +MIT License + +## Отказ от ответственности + +Этот бот предоставляется "как есть" без каких-либо гарантий. Автор не несет ответственности за возможные финансовые потери. Используйте на свой страх и риск. Всегда тестируйте в режиме `--dry-run` перед реальной торговлей. diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..1861346 --- /dev/null +++ b/config.yml @@ -0,0 +1,18 @@ +assets: + # ОФЗ-ПД 26212 (погашение 2028 г.) + - name: "ОФЗ 26212" + figi: "BBG00425VG07" + weight: 0.40 + + # Биржевой фонд Т-Банка (TPAY) + - name: "TPAY" + figi: "TCS00A108WX3" + weight: 0.25 + + - name: "TMOS@" + figi: "TCS60A101X76" + weight: 0.25 + + - name: "TGLD@" + figi: "TCS80A101X50" + weight: 0.10 diff --git a/main.py b/main.py new file mode 100644 index 0000000..c25fe6b --- /dev/null +++ b/main.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import argparse +import csv +import os +import sys +import time +import datetime as dt +from uuid import uuid4 +from decimal import Decimal, ROUND_DOWN +from typing import Dict, List, Optional + +import yaml +from dotenv import load_dotenv +from tinkoff.invest import ( + Client, + MoneyValue, + Quotation, + OrderDirection, + OrderType, + InstrumentIdType, +) +from tinkoff.invest.utils import quotation_to_decimal +from tinkoff.invest.exceptions import RequestError + + +CSV_LOG = "orders_log.csv" + + +# ----------------------------- utils ----------------------------- +def dec(v: Quotation | MoneyValue | None, default: str = "0") -> Decimal: + """Безопасное преобразование Quotation/MoneyValue -> Decimal.""" + if v is None: + return Decimal(default) + return quotation_to_decimal(v) + + +def ensure_csv(path: str): + if not os.path.exists(path): + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow( + [ + "ts", + "period_key", + "figi", + "ticker", + "name", + "instrument_type", + "lots", + "lot_size", + "filled_price_per_share", + "cost_rub", + "status", + "order_request_id", + ] + ) + + +def append_log(**kwargs): + ensure_csv(CSV_LOG) + with open(CSV_LOG, "a", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow( + [ + dt.datetime.now(dt.timezone.utc).isoformat(), + kwargs.get("period_key", ""), + kwargs.get("figi", ""), + kwargs.get("ticker", ""), + kwargs.get("name", ""), + kwargs.get("instrument_type", ""), + kwargs.get("lots", 0), + kwargs.get("lot_size", 0), + str(kwargs.get("filled_price_per_share", "")), + str(kwargs.get("cost_rub", "")), + kwargs.get("status", ""), + kwargs.get("order_request_id", ""), + ] + ) + + +def insert_report_separator(): + """Добавляет пустую строку в CSV перед новой серией транзакций.""" + ensure_csv(CSV_LOG) + with open(CSV_LOG, "a", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow([]) # просто пустая строка + + +# ----------------------------- config ----------------------------- +def load_config(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + assets = cfg.get("assets", []) + if not assets: + raise ValueError( + "Config must include non-empty 'assets' list under key 'assets'" + ) + total_w = sum(float(a.get("weight", 0.0)) for a in assets) + if abs(total_w - 1.0) > 1e-6: + raise ValueError(f"Sum of weights must be 1.0; got {total_w}") + for a in assets: + if "figi" not in a or "weight" not in a: + raise ValueError("Each asset must have 'figi' and 'weight' fields") + return cfg + + +# ----------------------------- market helpers ----------------------------- +def pick_account_id(client: Client, explicit: Optional[str]) -> str: + """Реальный брокерский счёт (без песочницы).""" + if explicit: + return explicit + accs = list(client.users.get_accounts().accounts) + if not accs: + raise RuntimeError( + "Нет реальных брокерских счетов. Проверь токен/права или создай счёт." + ) + return accs[0].id + + +def get_instrument_meta(client: Client, figi: str) -> dict: + """Мини‑карточка инструмента: лот, валюта, тип, UID, и (если доступны) nominal/aci для облигаций.""" + resp = client.instruments.get_instrument_by( + id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_FIGI, + id=figi, + ) + inst = resp.instrument + if inst is None: + raise ValueError(f"Instrument not found for FIGI {figi}") + + # Пытаемся взять номинал/НКД (есть не во всех версиях/для всех инструментов) + nominal = dec(getattr(inst, "nominal", None), "0") + aci_value = dec(getattr(inst, "aci_value", None), "0") + + return { + "name": inst.name, + "ticker": inst.ticker, + "lot": inst.lot, + "currency": inst.currency, # 'rub' + "min_price_increment": dec(inst.min_price_increment, "0.01"), + "uid": inst.uid, + "type": str( + getattr(inst, "instrument_type", "") + ).lower(), # 'bond' | 'share' | 'etf' ... + "nominal": nominal, # 0 если поле отсутствует + "aci_value": aci_value, # 0 если поле отсутствует + } + + +def fetch_last_prices(client: Client, figis: List[str]) -> Dict[str, Decimal]: + prices: Dict[str, Decimal] = {} + resp = client.market_data.get_last_prices(figi=figis) + for p in resp.last_prices: + prices[p.figi] = dec(p.price) + missing = [f for f in figis if f not in prices] + if missing: + raise RuntimeError(f"Missing last price for: {missing}") + return prices + + +def get_available_rub(client: Client, account_id: str) -> Decimal: + """Доступные RUB = свободные - заблокированные.""" + pos = client.operations.get_positions(account_id=account_id) + rub_money = next( + (m for m in pos.money if m.currency.lower() == "rub"), None) + rub_blocked = next( + (m for m in pos.blocked if m.currency.lower() == "rub"), None) + free_rub = dec(rub_money) if rub_money else Decimal(0) + blocked_rub = dec(rub_blocked) if rub_blocked else Decimal(0) + return free_rub - blocked_rub + + +# ----------------------------- sizing ----------------------------- +def floor_lots(budget_rub: Decimal, eff_lot_cost: Decimal) -> int: + """Сколько лотов помещается в бюджет.""" + if eff_lot_cost <= 0: + return 0 + lots = (budget_rub / eff_lot_cost).to_integral_value(rounding=ROUND_DOWN) + return int(max(lots, 0)) + + +# ----------------------------- main ----------------------------- +def main(): + parser = argparse.ArgumentParser( + description="DCA bot for Tinkoff Invest (MOEX, real account only, .env-based)" + ) + parser.add_argument("--config", required=True, help="Path to YAML config") + parser.add_argument( + "--rub-budget", + type=Decimal, + required=True, + help="Total RUB budget for this run, e.g. 10000", + ) + parser.add_argument( + "--dry-run", action="store_true", help="Simulation only, no real orders" + ) + parser.add_argument( + "--fee-buf-bps", + type=int, + default=300, + help="Buffer in bps for fees/slippage/rounding. Default 300 (3.00%)", + ) + parser.add_argument( + "--safe-rub-pct", + type=float, + default=0.97, + help="Use only N% of available RUB to avoid 30042. Default 0.97", + ) + parser.add_argument( + "--wait-tradable-sec", + type=int, + default=0, + help="Wait up to N seconds until instrument becomes tradable (0 = no wait)", + ) + parser.add_argument( + "--poll-sec", + type=int, + default=10, + help="Polling interval for trading status while waiting", + ) + args = parser.parse_args() + + load_dotenv() + token = os.environ.get("TINKOFF_TOKEN", "") + if not token: + print("ERROR: TINKOFF_TOKEN is not set in environment (.env)", file=sys.stderr) + sys.exit(2) + explicit_account = os.environ.get("TINKOFF_ACCOUNT_ID") or None + + cfg = load_config(args.config) + fee_buf = Decimal(args.fee_buf_bps) / Decimal(10000) + safe_mul = Decimal(str(args.safe_rub_pct)) + + with Client(token) as client: + account_id = pick_account_id(client, explicit_account) + + figis = [a["figi"] for a in cfg["assets"]] + prices = fetch_last_prices(client, figis) + metas = { + a["figi"]: get_instrument_meta(client, a["figi"]) for a in cfg["assets"] + } + + period_key = dt.datetime.now().strftime("%Y-%m") + + insert_report_separator() + + for asset in cfg["assets"]: + figi = asset["figi"] + weight = Decimal(str(asset["weight"])) + alloc = (args.rub_budget * weight).quantize(Decimal("0.01")) + meta = metas[figi] + price_last = prices[figi] # Quotation -> Decimal + lot = meta["lot"] + uid = meta["uid"] + inst_type = meta["type"] # 'bond'/'share'/'etf'/... + + # Пропуск не-RUB + if str(meta["currency"]).lower() != "rub": + append_log( + period_key=period_key, + figi=figi, + ticker=meta["ticker"], + name=meta["name"], + instrument_type=inst_type, + lots=0, + lot_size=lot, + filled_price_per_share=price_last, + cost_rub=Decimal(0), + status=f"SKIPPED_NON_RUB_CURRENCY({meta['currency']})", + order_request_id="", + ) + continue + + # --- Корректная цена за 1 бумагу --- + # Акции/ETF: last_price уже в ₽. + # Облигации: last_price — это % от номинала. Берём (price% * nominal) + НКД. + if inst_type == "bond": + nominal = meta.get("nominal") or Decimal("0") + if nominal <= 0: + nominal = Decimal("1000") # безопасный дефолт + aci = meta.get("aci_value") or Decimal("0") + clean_rub = (price_last / Decimal("100")) * nominal + price_per_share_effective = clean_rub + aci + else: + price_per_share_effective = price_last + + # Полная стоимость 1 лота + буфер + eff_lot_cost = ( + price_per_share_effective * + Decimal(lot) * (Decimal(1) + fee_buf) + ).quantize(Decimal("0.01")) + + # Первый расчёт лотов от аллокации + lots = floor_lots(alloc, eff_lot_cost) + if lots <= 0: + append_log( + period_key=period_key, + figi=figi, + ticker=meta["ticker"], + name=meta["name"], + instrument_type=inst_type, + lots=0, + lot_size=lot, + filled_price_per_share=price_per_share_effective, + cost_rub=Decimal(0), + status="SKIPPED_SMALL_ALLOC", + order_request_id="", + ) + continue + + # Проверим статус торгуемости; по желанию — подождём + def read_status(): + st = client.market_data.get_trading_status(instrument_id=uid) + return ( + bool(getattr(st, "api_trade_available_flag", False)), + bool(getattr(st, "market_order_available_flag", False)), + bool(getattr(st, "limit_order_available_flag", False)), + st.trading_status, + ) + + api_ok, mkt_ok, lim_ok, tr_stat = read_status() + waited = 0 + while ( + args.wait_tradable_sec > 0 + and api_ok + and (not mkt_ok and not lim_ok) + and waited < args.wait_tradable_sec + ): + time.sleep(max(1, int(args.poll_sec))) + waited += max(1, int(args.poll_sec)) + api_ok, mkt_ok, lim_ok, tr_stat = read_status() + + if not api_ok or (not mkt_ok and not lim_ok): + append_log( + period_key=period_key, + figi=figi, + ticker=meta["ticker"], + name=meta["name"], + instrument_type=inst_type, + lots=0, + lot_size=lot, + filled_price_per_share=price_per_share_effective, + cost_rub=Decimal(0), + status=f"SKIPPED_TRADING_UNAVAILABLE(api={api_ok},mkt={mkt_ok},lim={ + lim_ok},status={tr_stat},waited_s={waited})", + order_request_id="", + ) + continue + + order_type = ( + OrderType.ORDER_TYPE_MARKET + if mkt_ok + else OrderType.ORDER_TYPE_BESTPRICE + ) + + # Учтём реальный доступный кэш и добавим safety‑множитель + rub_available = get_available_rub(client, account_id) + safe_rub = (rub_available * safe_mul).quantize(Decimal("0.01")) + target_budget = min(alloc, safe_rub) + lots = min(lots, floor_lots(target_budget, eff_lot_cost)) + + if lots <= 0: + append_log( + period_key=period_key, + figi=figi, + ticker=meta["ticker"], + name=meta["name"], + instrument_type=inst_type, + lots=0, + lot_size=lot, + filled_price_per_share=price_per_share_effective, + cost_rub=Decimal(0), + status="SKIPPED_NOT_ENOUGH_RUB_AFTER_SAFE_CHECK", + order_request_id="", + ) + continue + + order_request_id = str(uuid4()) + status = "DRY_RUN" if args.dry_run else "PENDING" + filled_price = Decimal(0) + cost_rub = Decimal(0) + + if not args.dry_run: + # Авто‑даунсайз при 30042 (до 6 попыток) + attempts = 0 + cur_lots = lots + while cur_lots > 0 and attempts < 6: + try: + resp = client.orders.post_order( + instrument_id=uid, # используем UID + quantity=cur_lots, # КОЛ-ВО ЛОТОВ + account_id=account_id, + direction=OrderDirection.ORDER_DIRECTION_BUY, + order_type=order_type, + order_id=str(uuid4()), # новый UUID на попытку + ) + filled_price = dec(resp.executed_order_price) + cost_rub = dec(resp.total_order_amount) + status = str(resp.execution_report_status).replace( + "EXECUTION_REPORT_STATUS_", "" + ) + break + except RequestError as e: + if e.details == "30042": # Not enough assets for a margin trade + cur_lots -= 1 + attempts += 1 + continue + if ( + e.details == "30079" + ): # Instrument is not available for trading + status = f"SKIPPED_TRADING_UNAVAILABLE(api={api_ok},mkt={mkt_ok},lim={ + lim_ok},status={tr_stat})" + cur_lots = 0 + break + import traceback + + traceback.print_exc() + status = f"ERROR:{e!r}" + break + except Exception as e: + import traceback + + traceback.print_exc() + status = f"ERROR:{e!r}" + break + + if status.startswith("PENDING") and cur_lots != lots: + status = f"DOWNSIZED_FROM_{lots}_TO_{cur_lots}" + if ( + (cur_lots <= 0) + and not status.startswith("ERROR") + and "SKIPPED_TRADING_UNAVAILABLE" not in status + ): + status = "SKIPPED_NOT_ENOUGH_RUB_RUNTIME" + + lots_to_log = cur_lots + else: + lots_to_log = lots + + append_log( + period_key=period_key, + figi=figi, + ticker=meta["ticker"], + name=meta["name"], + instrument_type=inst_type, + lots=lots_to_log, + lot_size=lot, + filled_price_per_share=filled_price or price_per_share_effective, + cost_rub=cost_rub, + status=status, + order_request_id=order_request_id if not args.dry_run else "", + ) + + time.sleep(0.2) # бережно к rate limit + + print("Done. See orders_log.csv") + + +if __name__ == "__main__": + main() 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 diff --git a/tools/check_balance.py b/tools/check_balance.py new file mode 100644 index 0000000..273f479 --- /dev/null +++ b/tools/check_balance.py @@ -0,0 +1,55 @@ +# check_balance.py +from dotenv import load_dotenv +from tinkoff.invest import Client +from tinkoff.invest.utils import quotation_to_decimal +from decimal import Decimal +import os +import sys + + +def dec_money(mv) -> Decimal: + # Работает и для MoneyValue, и для Quotation + return quotation_to_decimal(mv) if mv is not None else Decimal(0) + + +load_dotenv() +token = os.environ.get("TINKOFF_TOKEN") +account_id = os.environ.get("TINKOFF_ACCOUNT_ID") + +if not token: + print("ERROR: TINKOFF_TOKEN is not set") + sys.exit(2) + +with Client(token) as client: + # Если account_id не задан, берём первый реальный счёт + if not account_id: + accs = client.users.get_accounts().accounts + if not accs: + print( + "Нет реальных счетов. Проверь права токена: accounts:read и наличие счёта.") + sys.exit(3) + account_id = accs[0].id + + # Универсальный способ: get_positions -> money (остатки) и blocked (заморожено) + pos = client.operations.get_positions(account_id=account_id) + + rub_money = next( + (m for m in pos.money if m.currency.lower() == "rub"), None) + rub_blocked = next( + (m for m in pos.blocked if m.currency.lower() == "rub"), None) + + rub_free = dec_money(rub_money) if rub_money else Decimal(0) + rub_frozen = dec_money(rub_blocked) if rub_blocked else Decimal(0) + rub_available = rub_free - rub_frozen + + print("ACCOUNT_ID:", account_id) + print("RUB free: ", rub_free) + print("RUB blocked: ", rub_frozen) + print("RUB available:", rub_available) + + # Дополнительно: оценка портфеля (если нужно) + pf = client.operations.get_portfolio(account_id=account_id) + total_currencies = dec_money(pf.total_amount_currencies) if hasattr( + pf, "total_amount_currencies") else None + if total_currencies is not None: + print("Portfolio total_amount_currencies:", total_currencies) diff --git a/tools/check_figi.py b/tools/check_figi.py new file mode 100644 index 0000000..07040fd --- /dev/null +++ b/tools/check_figi.py @@ -0,0 +1,42 @@ +# check_figi.py +from dotenv import load_dotenv +from tinkoff.invest import Client, InstrumentIdType +from tinkoff.invest.utils import quotation_to_decimal +import os +import sys + +FIGI = ["BBG00425VG07", "BBG0073DLHS1" + # вставь сюда твои FIGI из config.yaml + # "BBG004730N88", ... + ] + +load_dotenv() +token = os.environ.get("TINKOFF_TOKEN") +if not token: + print("ERROR: TINKOFF_TOKEN is not set", file=sys.stderr) + sys.exit(2) + +with Client(token) as client: + # Проверка карточек + for f in FIGI: + inst = client.instruments.get_instrument_by( + id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_FIGI, id=f + ).instrument + if not inst: + print(f"{f}: NOT FOUND") + continue + print( + f"{f}: {inst.ticker} | {inst.name} | lot={ + inst.lot} | curr={inst.currency}" + ) + + # Проверка last price + lp = client.market_data.get_last_prices(figi=FIGI) + seen = set() + for p in lp.last_prices: + price = quotation_to_decimal(p.price) + print(f"PRICE {p.figi}: {price}") + seen.add(p.figi) + missing = [f for f in FIGI if f not in seen] + if missing: + print("Нет last price для:", missing) diff --git a/tools/search.py b/tools/search.py new file mode 100644 index 0000000..888da14 --- /dev/null +++ b/tools/search.py @@ -0,0 +1,50 @@ +from tinkoff.invest import Client +from dotenv import load_dotenv +import os + +load_dotenv() +TOKEN = os.getenv("TINKOFF_TOKEN") + + +with Client(TOKEN) as client: + # Все акции + shares = client.instruments.shares() + for s in shares.instruments: + if s.ticker == "TGLD@": # ищем по тикеру + print(s.ticker, s.figi, s.name) + + # Все облигации + bonds = client.instruments.bonds() + for b in bonds.instruments: + if "ОФЗ 26212" in b.name: # ищем все ОФЗ по названию + print(b.name, b.figi) + + # Все ETF + etfs = client.instruments.etfs() + for e in etfs.instruments: + if "TPAY" in e.ticker: + print(e.ticker, e.figi, e.name) + + # Поиск валют по тикеру/названию и вывод figi + wanted = ["USD", "EUR", "GBP", "JPY"] + currencies = client.instruments.currencies() + for cur in currencies.instruments: + for w in wanted: + if w in (cur.ticker or "") or w in (cur.name or ""): + print(cur.ticker, cur.figi, cur.name) + break + + # Поиск золота по тикеру (ищем среди валют, фьючерсов, ETF и акций) + gold_tickers = ["TGLD@"] + lists_to_search = [ + client.instruments.currencies(), + client.instruments.futures(), + client.instruments.etfs(), + client.instruments.shares(), + ] + for lst in lists_to_search: + for inst in lst.instruments: + for gt in gold_tickers: + if gt in (inst.ticker or "") or gt in (inst.name or ""): + print(inst.ticker, inst.figi, inst.name) + break