Stage 04.1 - storage foundation with postgres
This commit is contained in:
@@ -4,6 +4,12 @@ APP_ENV=dev
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
TZ=Europe/Minsk
|
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_ENABLED=true
|
||||||
EXCHANGE_NAME=dzengi
|
EXCHANGE_NAME=dzengi
|
||||||
EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com
|
EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
aiogram==3.13.1
|
aiogram==3.13.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
@@ -5,12 +5,14 @@ from aiogram.client.default import DefaultBotProperties
|
|||||||
|
|
||||||
from src.bootstrap.logging import setup_logging
|
from src.bootstrap.logging import setup_logging
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
|
from src.storage.schema import init_schema
|
||||||
from src.telegram.routers import setup_routers
|
from src.telegram.routers import setup_routers
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> tuple[Bot, Dispatcher]:
|
def create_app() -> tuple[Bot, Dispatcher]:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
setup_logging(settings.log_level)
|
setup_logging(settings.log_level)
|
||||||
|
init_schema()
|
||||||
|
|
||||||
bot = Bot(
|
bot = Bot(
|
||||||
token=settings.bot_token,
|
token=settings.bot_token,
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class Settings:
|
|||||||
exchange_timeout_sec: int
|
exchange_timeout_sec: int
|
||||||
exchange_testnet: bool
|
exchange_testnet: bool
|
||||||
default_symbol: str
|
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:
|
def _parse_bool(raw_value: str, default: bool = False) -> bool:
|
||||||
value = (raw_value or "").strip().lower()
|
value = (raw_value or "").strip().lower()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -49,4 +54,9 @@ def load_settings() -> Settings:
|
|||||||
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
|
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
|
||||||
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
||||||
default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() or "BTC/USD_LEVERAGE",
|
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(),
|
||||||
)
|
)
|
||||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.core.constants import APP_NAME, APP_VERSION
|
from src.core.constants import APP_NAME, APP_VERSION
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.storage.session import check_database_health
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -45,6 +46,7 @@ def get_system_snapshot() -> SystemSnapshot:
|
|||||||
|
|
||||||
exchange_health = exchange_service.get_health()
|
exchange_health = exchange_service.get_health()
|
||||||
private_auth_health = exchange_service.get_private_auth_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":
|
if exchange_health.ok and exchange_health.mode == "mock":
|
||||||
exchange_state = "🟡 mock mode"
|
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 "🔴 ошибка"
|
symbol_state = "🟢 OK" if symbol_validation and symbol_validation.is_valid else "🔴 ошибка"
|
||||||
private_auth_state = "🟢 OK" if private_auth_health.ok else "🔴 ошибка"
|
private_auth_state = "🟢 OK" if private_auth_health.ok else "🔴 ошибка"
|
||||||
|
db_state = "🟢 OK" if db_ok else "🔴 ошибка"
|
||||||
|
|
||||||
components = [
|
components = [
|
||||||
ComponentStatus(
|
ComponentStatus(
|
||||||
@@ -84,8 +87,8 @@ def get_system_snapshot() -> SystemSnapshot:
|
|||||||
),
|
),
|
||||||
ComponentStatus(
|
ComponentStatus(
|
||||||
name="База данных",
|
name="База данных",
|
||||||
state="🟡 не подключена",
|
state=db_state,
|
||||||
details="Слой хранения пока только подготовлен структурно.",
|
details=db_message,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
33
app/src/storage/models.py
Normal file
33
app/src/storage/models.py
Normal 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
44
app/src/storage/schema.py
Normal 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)
|
||||||
42
app/src/storage/session.py
Normal file
42
app/src/storage/session.py
Normal 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 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}"
|
||||||
15
docs/decisions/0009-postgres-storage-foundation.md
Normal file
15
docs/decisions/0009-postgres-storage-foundation.md
Normal 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 будут развиваться без смены движка
|
||||||
88
docs/roadmap/master-roadmap.md
Normal file
88
docs/roadmap/master-roadmap.md
Normal 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
|
||||||
75
docs/roadmap/stage-03-roadmap.md
Normal file
75
docs/roadmap/stage-03-roadmap.md
Normal 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
|
||||||
72
docs/roadmap/stage-04-roadmap.md
Normal file
72
docs/roadmap/stage-04-roadmap.md
Normal 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
|
||||||
22
docs/stages/stage-04-1-storage-foundation-postgres.md
Normal file
22
docs/stages/stage-04-1-storage-foundation-postgres.md
Normal file
@@ -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
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
services:
|
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:
|
bot:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
@@ -7,3 +19,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ../../app/.env
|
- ../../app/.env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
volumes:
|
||||||
|
dzentra_postgres_data:
|
||||||
Reference in New Issue
Block a user