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..8a0628b 100644
--- a/app/src/core/system_status.py
+++ b/app/src/core/system_status.py
@@ -6,132 +6,150 @@ 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
+ db_label: str
python_version: str
os_name: str
+ app_env: str
timezone_name: str
exchange_enabled: bool
exchange_name: 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 or "PostgreSQL " not in raw:
+ return "Postgres"
+
+ tail = raw.split("PostgreSQL ", 1)[1]
+ version = tail.split(" ", 1)[0].strip()
+ return f"Postgres {version}" if version else "Postgres"
+
+
+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 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,
+ db_label=db_label,
python_version=platform.python_version(),
os_name=f"{platform.system()} {platform.release()}",
+ app_env=settings.app_env,
timezone_name=settings.tz,
exchange_enabled=settings.exchange_enabled,
exchange_name=settings.exchange_name,
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"• python: {snapshot.python_version}\n"
+ f"• os: {snapshot.os_name}\n"
+ f"• env: {snapshot.app_env}\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}"
)
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..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/docs/stages/stage-04-1-ui-polish.md b/docs/stages/stage-04-1-ui-polish.md
new file mode 100644
index 0000000..81e4c72
--- /dev/null
+++ b/docs/stages/stage-04-1-ui-polish.md
@@ -0,0 +1,25 @@
+# Stage 04.1+ — System Screen UI Polish
+
+## Цель
+Сделать экран `⚙️ Система` короче, чище и понятнее без изменения бизнес-логики.
+
+## Что изменено
+- убраны формулировки вида `OK База данных`
+- заголовки компонентов теперь выглядят единообразно
+- текст PostgreSQL health сокращён до компактного вида
+- блок окружения переведён на более аккуратный формат
+
+## Что НЕ менялось
+- логика health checks
+- подключение PostgreSQL
+- проверка public/private API
+- symbol validation
+
+## Результат
+Экран `⚙️ Система` стал:
+- компактнее
+- понятнее
+- ближе к продовому UI
+
+## Рекомендуемый commit
+`Stage 04.1+ - polish system screen UI`
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