diff --git a/app/.env.example b/app/.env.example index d704377..14075b6 100644 --- a/app/.env.example +++ b/app/.env.example @@ -4,6 +4,12 @@ APP_ENV=dev LOG_LEVEL=INFO TZ=Europe/Minsk +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=dzentra_bot +DB_USER=dzentra_bot +DB_PASSWORD=change_me + EXCHANGE_ENABLED=true EXCHANGE_NAME=dzengi EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com diff --git a/app/requirements.txt b/app/requirements.txt index d8ac221..d15d1d8 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,2 +1,3 @@ aiogram==3.13.1 python-dotenv==1.0.1 +psycopg[binary]==3.2.9 \ No newline at end of file diff --git a/app/src/bootstrap/app_factory.py b/app/src/bootstrap/app_factory.py index ad4c633..26b6669 100644 --- a/app/src/bootstrap/app_factory.py +++ b/app/src/bootstrap/app_factory.py @@ -5,12 +5,14 @@ from aiogram.client.default import DefaultBotProperties from src.bootstrap.logging import setup_logging from src.core.config import load_settings +from src.storage.schema import init_schema from src.telegram.routers import setup_routers def create_app() -> tuple[Bot, Dispatcher]: settings = load_settings() setup_logging(settings.log_level) + init_schema() bot = Bot( token=settings.bot_token, diff --git a/app/src/core/config.py b/app/src/core/config.py index d0a9964..3810158 100644 --- a/app/src/core/config.py +++ b/app/src/core/config.py @@ -21,6 +21,11 @@ class Settings: exchange_timeout_sec: int exchange_testnet: bool default_symbol: str + db_host: str + db_port: int + db_name: str + db_user: str + db_password: str def _parse_bool(raw_value: str, default: bool = False) -> bool: value = (raw_value or "").strip().lower() if not value: @@ -49,4 +54,9 @@ def load_settings() -> Settings: exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10), exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")), default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() or "BTC/USD_LEVERAGE", + db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost", + db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432), + db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot", + db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot", + db_password=os.getenv("DB_PASSWORD", "").strip(), ) \ No newline at end of file diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index 29f5785..b4e0ac8 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -1,137 +1,150 @@ from __future__ import annotations import platform +import re from dataclasses import dataclass from src.core.config import load_settings from src.core.constants import APP_NAME, APP_VERSION from src.integrations.exchange.service import ExchangeService +from src.storage.session import check_database_health @dataclass(slots=True) class ComponentStatus: name: str state: str - details: str + details: str = "" @dataclass(slots=True) class SystemSnapshot: app_name: str app_version: str - app_env: str - python_version: str - os_name: str + db_label: str timezone_name: str - exchange_enabled: bool - exchange_name: str + mode_label: str default_symbol: str - symbol_validation_message: str - private_auth_message: str components: list[ComponentStatus] +def _extract_postgres_version(raw: str) -> str: + if not raw: + return "PostgreSQL" + + match = re.search(r"PostgreSQL\s+(\d+(?:\.\d+)?)", raw) + if match: + return f"PostgreSQL {match.group(1)}" + + return "PostgreSQL" + + +def _build_exchange_status(exchange_service: ExchangeService, default_symbol: str) -> ComponentStatus: + try: + symbol_validation = exchange_service.validate_symbol(default_symbol) + except Exception as exc: + return ComponentStatus( + name="Биржа", + state="🔴", + details=f"Не удалось проверить инструмент: {exc}", + ) + + exchange_health = exchange_service.get_health() + if exchange_health.ok and symbol_validation.is_valid: + return ComponentStatus(name="Биржа", state="🟢") + + if not exchange_health.ok: + return ComponentStatus( + name="Биржа", + state="🔴", + details=exchange_health.message or "Ошибка подключения к API биржи.", + ) + + return ComponentStatus( + name="Биржа", + state="🔴", + details=symbol_validation.message or "Инструмент не прошел проверку.", + ) + + +def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus: + private_auth_health = exchange_service.get_private_auth_health() + if private_auth_health.ok: + return ComponentStatus(name="Аккаунт", state="🟢") + + return ComponentStatus( + name="Аккаунт", + state="🔴", + details=private_auth_health.message or "Ошибка private API.", + ) + + +def _build_database_status() -> tuple[ComponentStatus, str]: + db_ok, db_message = check_database_health() + db_label = _extract_postgres_version(db_message) + + if db_ok: + return ComponentStatus(name="База данных", state="🟢"), db_label + + return ( + ComponentStatus( + name="База данных", + state="🔴", + details=db_message or "Ошибка подключения к БД.", + ), + db_label, + ) + + +def _resolve_mode_label(exchange_testnet: bool) -> str: + return "ДЕМО аккаунт" if exchange_testnet else "РЕАЛЬНЫЙ аккаунт" + + def get_system_snapshot() -> SystemSnapshot: settings = load_settings() exchange_service = ExchangeService() - try: - symbol_validation = exchange_service.validate_symbol(settings.default_symbol) - except Exception as exc: - symbol_validation = None - symbol_validation_message = f"Не удалось проверить символ: {exc}" - else: - symbol_validation_message = symbol_validation.message - - exchange_health = exchange_service.get_health() - private_auth_health = exchange_service.get_private_auth_health() - - if exchange_health.ok and exchange_health.mode == "mock": - exchange_state = "🟡 mock mode" - elif exchange_health.ok: - exchange_state = "🟢 API OK" - else: - exchange_state = "🔴 ошибка" - - symbol_state = "🟢 OK" if symbol_validation and symbol_validation.is_valid else "🔴 ошибка" - private_auth_state = "🟢 OK" if private_auth_health.ok else "🔴 ошибка" + database_status, db_label = _build_database_status() + exchange_status = _build_exchange_status(exchange_service, settings.default_symbol) + account_status = _build_account_status(exchange_service) components = [ - ComponentStatus( - name="Бот", - state="🟢 работает", - details="Процесс бота запущен и обрабатывает команды.", - ), - ComponentStatus( - name="Telegram", - state="🟢 OK", - details="Polling активен, базовая маршрутизация подключена.", - ), - ComponentStatus( - name="Биржа", - state=exchange_state, - details=exchange_health.message, - ), - ComponentStatus( - name="Символ", - state=symbol_state, - details=symbol_validation_message, - ), - ComponentStatus( - name="Авторизация", - state=private_auth_state, - details=private_auth_health.message, - ), - ComponentStatus( - name="База данных", - state="🟡 не подключена", - details="Слой хранения пока только подготовлен структурно.", - ), + ComponentStatus(name="Приложение", state="🟢"), + database_status, + ComponentStatus(name="Telegram", state="🟢"), + exchange_status, + account_status, ] return SystemSnapshot( app_name=APP_NAME, app_version=APP_VERSION, - app_env=settings.app_env, - python_version=platform.python_version(), - os_name=f"{platform.system()} {platform.release()}", + db_label=db_label, timezone_name=settings.tz, - exchange_enabled=settings.exchange_enabled, - exchange_name=settings.exchange_name, + mode_label=_resolve_mode_label(settings.exchange_testnet), default_symbol=settings.default_symbol, - symbol_validation_message=symbol_validation_message, - private_auth_message=private_auth_health.message, components=components, ) +def _render_component(component: ComponentStatus) -> str: + if component.state == "🟢": + return f"{component.state} {component.name}" + + return f"{component.state} {component.name}\n— {component.details}" + + def build_system_text() -> str: snapshot = get_system_snapshot() - - component_lines = [] - for component in snapshot.components: - component_lines.append( - f"{component.state} {component.name}\n" - f"— {component.details}" - ) - - components_block = "\n\n".join(component_lines) + components_block = "\n".join(_render_component(component) for component in snapshot.components) return ( "⚙️ Система\n\n" - "Статус компонентов\n" f"{components_block}\n\n" - "Окружение\n" - f"- приложение: {snapshot.app_name} {snapshot.app_version}\n" - f"- env: {snapshot.app_env}\n" - f"- python: {snapshot.python_version}\n" - f"- os: {snapshot.os_name}\n" - f"- timezone: {snapshot.timezone_name}\n" - f"- exchange_enabled: {snapshot.exchange_enabled}\n" - f"- exchange_name: {snapshot.exchange_name}\n" - f"- default_symbol: {snapshot.default_symbol}\n\n" - "Справка\n" - "/start — стартовый экран\n" - "/menu — показать меню\n" - "/help — открыть системную справку" - ) + "🌐 Окружение\n" + f"• приложение: {snapshot.app_name} {snapshot.app_version}\n" + f"• база данных: {snapshot.db_label}\n" + f"• часовой пояс: {snapshot.timezone_name}\n" + f"• режим: {snapshot.mode_label}\n" + f"• инструмент: {snapshot.default_symbol}" + ) \ No newline at end of file diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 51a50db..c96c88e 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -242,4 +242,4 @@ class ExchangeService: price=float(price_raw), source="dzengi-demo-api", updated_at=updated_at, - ) + ) \ No newline at end of file diff --git a/app/src/storage/models.py b/app/src/storage/models.py new file mode 100644 index 0000000..c2e3cc3 --- /dev/null +++ b/app/src/storage/models.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class BalanceSnapshotRecord: + id: int | None + created_at: str + source: str + payload_json: str + + +@dataclass(slots=True) +class JournalEventRecord: + id: int | None + created_at: str + level: str + event_type: str + message: str + payload_json: str | None + + +@dataclass(slots=True) +class OrderDraftRecord: + id: int | None + created_at: str + symbol: str + side: str + order_type: str + quantity: str + status: str + payload_json: str | None diff --git a/app/src/storage/schema.py b/app/src/storage/schema.py new file mode 100644 index 0000000..6f91268 --- /dev/null +++ b/app/src/storage/schema.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from src.storage.session import get_connection + + +DDL = [ + ''' + CREATE TABLE IF NOT EXISTS balance_snapshots ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + source TEXT NOT NULL, + payload_json JSONB NOT NULL + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS journal_events ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + level TEXT NOT NULL, + event_type TEXT NOT NULL, + message TEXT NOT NULL, + payload_json JSONB + ) + ''', + ''' + CREATE TABLE IF NOT EXISTS order_drafts ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + symbol TEXT NOT NULL, + side TEXT NOT NULL, + order_type TEXT NOT NULL, + quantity NUMERIC(36, 18) NOT NULL, + status TEXT NOT NULL, + payload_json JSONB + ) + ''' +] + + +def init_schema() -> None: + with get_connection() as connection: + with connection.cursor() as cursor: + for statement in DDL: + cursor.execute(statement) diff --git a/app/src/storage/session.py b/app/src/storage/session.py new file mode 100644 index 0000000..937b4f5 --- /dev/null +++ b/app/src/storage/session.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from contextlib import contextmanager + +import psycopg + +from src.core.config import load_settings + + +def build_dsn() -> str: + settings = load_settings() + password_part = settings.db_password.replace("@", "%40") + return ( + f"postgresql://{settings.db_user}:{password_part}" + f"@{settings.db_host}:{settings.db_port}/{settings.db_name}" + ) + + +@contextmanager +def get_connection(): + connection = psycopg.connect(build_dsn(), autocommit=False) + try: + yield connection + connection.commit() + finally: + connection.close() + + +def check_database_health() -> tuple[bool, str]: + try: + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT version()") + row = cursor.fetchone() + if row is None: + return False, "PostgreSQL ping returned no rows." + + version = str(row[0]).strip() + except Exception as exc: + return False, f"PostgreSQL error: {exc}" + + return True, version diff --git a/docs/decisions/0009-postgres-storage-foundation.md b/docs/decisions/0009-postgres-storage-foundation.md new file mode 100644 index 0000000..d620bf2 --- /dev/null +++ b/docs/decisions/0009-postgres-storage-foundation.md @@ -0,0 +1,15 @@ +# 0009 — PostgreSQL for Storage Foundation + +## Решение +Сразу строить storage foundation на PostgreSQL, а не на SQLite. + +## Причины +- проект ориентирован на рост +- нужны нормальные concurrent writes и server DB +- удобно хранить JSONB payloads +- это снижает риск будущей миграции БД + +## Последствия +- dev setup становится чуть сложнее +- зато storage строится сразу на целевой БД +- journal, snapshots и order flow будут развиваться без смены движка diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md new file mode 100644 index 0000000..b4d536d --- /dev/null +++ b/docs/roadmap/master-roadmap.md @@ -0,0 +1,88 @@ +# Dzentra Bot — Master Roadmap + +## Stage 01 — Bootstrap +✔ структура проекта +✔ virtualenv +✔ запуск приложения +✔ базовый bootstrap + +--- + +## Stage 02 — System layer +✔ экран ⚙️ Система +✔ базовая диагностика +✔ структура компонентов + +--- + +## Stage 03 — Exchange Integration + +### 03.1 +✔ mock exchange + +### 03.2 +✔ время / timestamp + +### 03.3 +✔ exchangeInfo +✔ валидация символа + +### 03.4 +✔ private auth (HMAC, headers, timestamp) + +### 03.5 +✔ первый private endpoint (account) +✔ баланс +✔ экран 💼 Портфель +✔ UX улучшения + +--- + +## Stage 04 — Storage / Journal (NEXT) + +### 04.1 +⏳ storage foundation + +### 04.2 +⏳ journal / event log + +### 04.3 +⏳ repositories + +### 04.4 +⏳ UI integration + +--- + +## Stage 03.6 — Orders (после storage) +⏳ orders skeleton +⏳ dry-run режим +⏳ валидация ордеров + +--- + +## Stage 05 — Trading logic +⏳ стратегии +⏳ риск-менеджмент +⏳ сигналы + +--- + +## Stage 06 — Automation +⏳ авто-режим +⏳ планировщик +⏳ фоновые задачи + +--- + +## Stage 07 — Observability +⏳ логирование +⏳ алерты +⏳ метрики + +--- + +## Текущий статус + +👉 Stage 03.5 — завершён +👉 Следующий шаг: Stage 04.1 \ No newline at end of file diff --git a/docs/roadmap/stage-03-roadmap.md b/docs/roadmap/stage-03-roadmap.md new file mode 100644 index 0000000..ea7d678 --- /dev/null +++ b/docs/roadmap/stage-03-roadmap.md @@ -0,0 +1,75 @@ +# Stage 03 — Exchange Integration Roadmap + +## Цель +Интеграция с биржей: +- public API +- private API +- базовый UI + +--- + +## 03.1 — Mock exchange +✔ имитация данных +✔ структура клиента + +--- + +## 03.2 — Time handling +✔ обработка timestamp +✔ локализация времени + +--- + +## 03.3 — exchangeInfo +✔ загрузка символов +✔ валидация символа +✔ защита от ошибок API + +--- + +## 03.4 — Private auth +✔ API key +✔ secret +✔ HMAC подпись +✔ headers +✔ timestamp + +--- + +## 03.5 — Account + Portfolio +✔ private endpoint `/account` +✔ парсер баланса +✔ сервисный слой +✔ экран 💼 Портфель + +### UX улучшения +✔ иконки валют +✔ сортировка +✔ скрытие нулевых балансов +✔ группировка активов + +--- + +## Результат Stage 03 + +✔ бот умеет: +- получать цену +- валидировать символ +- работать с private API +- получать баланс +- отображать портфель + +--- + +## Ограничения + +- нет ордеров +- нет хранения +- нет журнала +- нет стратегии + +--- + +## Следующий шаг + +➡ Stage 04 — Storage / Journal \ No newline at end of file diff --git a/docs/roadmap/stage-04-roadmap.md b/docs/roadmap/stage-04-roadmap.md new file mode 100644 index 0000000..e8717a3 --- /dev/null +++ b/docs/roadmap/stage-04-roadmap.md @@ -0,0 +1,72 @@ +# Stage 04 — Storage / Journal Roadmap + +## Цель +Добавить слой хранения и журналирования. + +--- + +## 04.1 — Storage foundation + +Цель: +- подключить БД +- создать базовые модели + +Что добавить: +- storage/session.py +- storage/schema.py +- SQLite + +Сущности: +- BalanceSnapshot +- JournalEvent +- OrderDraft + +--- + +## 04.2 — Journal / event log + +Цель: +- логировать действия бота + +Что логировать: +- ошибки API +- запросы +- события системы + +--- + +## 04.3 — Repositories + +Цель: +- хранить данные + +Добавить: +- repository balance +- repository orders +- repository journal + +--- + +## 04.4 — UI integration + +Цель: +- показать storage в интерфейсе + +Добавить: +- статус БД в ⚙️ Система +- последние события +- snapshot баланса + +--- + +## Результат Stage 04 + +✔ данные сохраняются +✔ есть история +✔ есть журнал + +--- + +## Следующий шаг + +➡ Stage 03.6 — Orders skeleton \ No newline at end of file diff --git a/docs/stages/stage-04-1-storage.md b/docs/stages/stage-04-1-storage.md new file mode 100644 index 0000000..ce3a1ed --- /dev/null +++ b/docs/stages/stage-04-1-storage.md @@ -0,0 +1,142 @@ +# Stage 04.1 — Storage Foundation (PostgreSQL) and System Dashboard + +## Цель +Подключить слой хранения на PostgreSQL и одновременно привести системный экран к продовому виду (операционный dashboard). + +--- + +## Что добавлено + +### Storage +- PostgreSQL как основная база данных +- подключение через `psycopg` +- инициализация схемы при старте приложения +- базовые таблицы: + - balance_snapshots + - journal_events + - order_drafts +- health check БД + +--- + +### System Dashboard (⚙️ Система) +Экран системы переработан из технического вывода в операционный статус. + +#### Компоненты системы +Добавлен компактный статус всех ключевых частей: + +- Приложение +- База данных +- Telegram +- Биржа (public API + символ) +- Аккаунт (private API) + +Поведение: +- при 🟢 → только краткий статус +- при 🔴 → добавляется описание ошибки + +--- + +### Биржа (объединённый статус) +Объединены проверки: +- доступность public API +- валидность символа (exchangeInfo) + +Теперь это один сигнал: +- 🟢 Биржа → всё работает +- 🔴 Биржа → проблема (API или символ) + +--- + +### Аккаунт +Добавлен статус private API: + +- проверка API ключей +- проверка доступа к аккаунту + +--- + +## Блок "🌐 Окружение" + +Экран упрощён до минимально полезного набора параметров: + +🌐 Окружение +• приложение: Dzentra Bot 2.0.0 +• база данных: PostgreSQL 17.9 +• часовой пояс: Europe/Minsk +• режим: ДЕМО аккаунт / РЕАЛЬНЫЙ аккаунт +• инструмент: BTC/USD_LEVERAGE + +### Принципы отбора +Оставлены только параметры, которые: +- влияют на поведение системы +- важны для диагностики +- критичны для безопасности (режим торговли) + +Удалены: +- env +- exchange_enabled +- exchange_name +- os +- python + +--- + +## Режим работы (важное изменение) + +Вместо технического параметра: + +env: dev + +используется бизнес-понятие: + +режим: ДЕМО аккаунт / РЕАЛЬНЫЙ аккаунт + +Источник: +- exchange_testnet + +Зачем: +- снижает риск ошибок +- сразу понятно, используются ли реальные деньги + +--- + +## Отображение PostgreSQL + +Реализовано корректное отображение версии БД: + +PostgreSQL 17.9 + +Особенности: +- версия берётся напрямую из БД (SELECT version()) +- из строки извлекается только нужная часть +- исключены технические строки типа OK: db=... + +--- + +## Архитектурный результат + +После Stage 04.1 система получила: + +- полноценный storage layer (PostgreSQL) +- health-check инфраструктуры +- единый системный dashboard +- разделение: + - технических параметров + - пользовательского UI + +--- + +## Почему это важно + +- система готова к журналированию +- можно диагностировать проблемы без логов +- UI стал безопасным (режим торговли) +- база данных интегрирована как часть системы, а не внешний компонент + +--- + +## Что дальше + +- Stage 04.2 — Journal / event log +- Stage 04.3 — Repositories diff --git a/infra/compose/docker-compose.yml b/infra/compose/docker-compose.yml index 4c48af9..b5a47fa 100644 --- a/infra/compose/docker-compose.yml +++ b/infra/compose/docker-compose.yml @@ -1,4 +1,22 @@ services: + postgres: + image: postgres:17 + container_name: dzentra_postgres + restart: unless-stopped + environment: + POSTGRES_DB: dzentra_bot + POSTGRES_USER: dzentra_bot + POSTGRES_PASSWORD: change_me + ports: + - "5432:5432" + volumes: + - dzentra_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dzentra_bot -d dzentra_bot"] + interval: 5s + timeout: 3s + retries: 10 + bot: build: context: ../.. @@ -7,3 +25,9 @@ services: restart: unless-stopped env_file: - ../../app/.env + depends_on: + postgres: + condition: service_healthy + +volumes: + dzentra_postgres_data: \ No newline at end of file