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..3529873 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -6,6 +6,7 @@ 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) @@ -45,6 +46,7 @@ def get_system_snapshot() -> SystemSnapshot: exchange_health = exchange_service.get_health() private_auth_health = exchange_service.get_private_auth_health() + db_ok, db_message = check_database_health() if exchange_health.ok and exchange_health.mode == "mock": exchange_state = "🟡 mock mode" @@ -55,6 +57,7 @@ def get_system_snapshot() -> SystemSnapshot: symbol_state = "🟢 OK" if symbol_validation and symbol_validation.is_valid else "🔴 ошибка" private_auth_state = "🟢 OK" if private_auth_health.ok else "🔴 ошибка" + db_state = "🟢 OK" if db_ok else "🔴 ошибка" components = [ ComponentStatus( @@ -84,8 +87,8 @@ def get_system_snapshot() -> SystemSnapshot: ), ComponentStatus( name="База данных", - state="🟡 не подключена", - details="Слой хранения пока только подготовлен структурно.", + state=db_state, + details=db_message, ), ] 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..404f710 --- /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 current_database(), current_user, version()") + row = cursor.fetchone() + if row is None: + return False, "PostgreSQL ping returned no rows." + db_name, db_user, version = row + except Exception as exc: + return False, f"PostgreSQL error: {exc}" + + version_short = str(version).split(",")[0] + return True, f"PostgreSQL OK: db={db_name}, user={db_user}, {version_short}" 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-foundation-postgres.md b/docs/stages/stage-04-1-storage-foundation-postgres.md new file mode 100644 index 0000000..b13937e --- /dev/null +++ b/docs/stages/stage-04-1-storage-foundation-postgres.md @@ -0,0 +1,22 @@ +# Stage 04.1 — Storage Foundation (PostgreSQL) + +## Цель +Подключить первый слой хранения сразу на PostgreSQL и заложить базу для журналирования и order flow. + +## Что добавлено +- PostgreSQL как основная БД +- инициализация схемы при старте приложения +- таблицы: + - balance_snapshots + - journal_events + - order_drafts +- health check БД в `⚙️ Система` + +## Почему PostgreSQL +- подходит как целевая БД на рост +- удобно для JSONB, событий и order flow +- не требует будущей смены движка при росте проекта + +## Что дальше +- 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..79658c6 100644 --- a/infra/compose/docker-compose.yml +++ b/infra/compose/docker-compose.yml @@ -1,4 +1,16 @@ 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 bot: build: context: ../.. @@ -7,3 +19,7 @@ services: restart: unless-stopped env_file: - ../../app/.env + depends_on: + - postgres +volumes: + dzentra_postgres_data: \ No newline at end of file