Stage 04.1 - storage foundation (PostgreSQL) and system dashboard UI

This commit is contained in:
2026-04-14 21:38:52 +03:00
parent 1deb676585
commit aad680e2f9
15 changed files with 649 additions and 90 deletions

View File

@@ -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

View File

@@ -1,2 +1,3 @@
aiogram==3.13.1
python-dotenv==1.0.1
psycopg[binary]==3.2.9

View File

@@ -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,

View File

@@ -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(),
)

View File

@@ -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} <b>{component.name}</b>"
return f"{component.state} <b>{component.name}</b>\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} <b>{component.name}</b>\n"
f"{component.details}"
)
components_block = "\n\n".join(component_lines)
components_block = "\n".join(_render_component(component) for component in snapshot.components)
return (
"<b>⚙️ Система</b>\n\n"
"<b>Статус компонентов</b>\n"
f"{components_block}\n\n"
"<b>Окружение</b>\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"
"<b>Справка</b>\n"
"/start — стартовый экран\n"
"/menu — показать меню\n"
"/help — открыть системную справку"
)
"<b>🌐 Окружение</b>\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}"
)

View File

@@ -242,4 +242,4 @@ class ExchangeService:
price=float(price_raw),
source="dzengi-demo-api",
updated_at=updated_at,
)
)

33
app/src/storage/models.py Normal file
View File

@@ -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

44
app/src/storage/schema.py Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -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 будут развиваться без смены движка

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: