From 9166022b3cf8524caee829b72a749cc1c6bf2b4c Mon Sep 17 00:00:00 2001 From: Sergey Date: Mon, 13 Apr 2026 22:54:01 +0300 Subject: [PATCH] Stage 03.1 - mock exchange integration --- app/.env.example | 10 +- app/src/core/config.py | 37 ++-- app/src/core/system_status.py | 24 ++- app/src/integrations/exchange/__init__.py | 1 + app/src/integrations/exchange/mock_data.py | 37 ++++ app/src/integrations/exchange/models.py | 26 +++ app/src/integrations/exchange/service.py | 38 ++++ app/src/telegram/handlers/market.py | 17 +- docs/decisions/0005-exchange-integration.md | 14 ++ docs/git_flow_dzentra_bot.md | 189 ++++++++++++++++++++ docs/stages/stage-03-1-integration-mock.md | 45 +++++ 11 files changed, 421 insertions(+), 17 deletions(-) create mode 100644 app/src/integrations/exchange/__init__.py create mode 100644 app/src/integrations/exchange/mock_data.py create mode 100644 app/src/integrations/exchange/models.py create mode 100644 app/src/integrations/exchange/service.py create mode 100644 docs/decisions/0005-exchange-integration.md create mode 100644 docs/git_flow_dzentra_bot.md create mode 100644 docs/stages/stage-03-1-integration-mock.md diff --git a/app/.env.example b/app/.env.example index 9ad2539..f028aac 100644 --- a/app/.env.example +++ b/app/.env.example @@ -2,4 +2,12 @@ BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE BOT_PARSE_MODE=HTML APP_ENV=dev LOG_LEVEL=INFO -TZ=Europe/Madrid +TZ=Europe/Minsk +EXCHANGE_ENABLED=false +EXCHANGE_NAME=dzengi +EXCHANGE_BASE_URL= +EXCHANGE_API_KEY= +EXCHANGE_API_SECRET= +EXCHANGE_TIMEOUT_SEC=10 +EXCHANGE_TESTNET=false +DEFAULT_SYMBOL=BTCUSDT \ No newline at end of file diff --git a/app/src/core/config.py b/app/src/core/config.py index 0eaae85..8e683fa 100644 --- a/app/src/core/config.py +++ b/app/src/core/config.py @@ -1,17 +1,11 @@ from __future__ import annotations - import os from dataclasses import dataclass from pathlib import Path - from dotenv import load_dotenv - - BASE_DIR = Path(__file__).resolve().parents[2] ENV_FILE = BASE_DIR / ".env" load_dotenv(ENV_FILE) - - @dataclass(slots=True) class Settings: bot_token: str @@ -19,17 +13,40 @@ class Settings: app_env: str log_level: str tz: str - - + exchange_enabled: bool + exchange_name: str + exchange_base_url: str + exchange_api_key: str + exchange_api_secret: str + exchange_timeout_sec: int + exchange_testnet: bool + default_symbol: str +def _parse_bool(raw_value: str, default: bool = False) -> bool: + value = (raw_value or "").strip().lower() + if not value: + return default + return value in {"1", "true", "yes", "on"} +def _parse_int(raw_value: str, default: int) -> int: + value = (raw_value or "").strip() + if not value: + return default + return int(value) def load_settings() -> Settings: bot_token = os.getenv("BOT_TOKEN", "").strip() if not bot_token: raise RuntimeError("BOT_TOKEN is not set in app/.env") - return Settings( bot_token=bot_token, bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML", app_env=os.getenv("APP_ENV", "dev").strip() or "dev", log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", tz=os.getenv("TZ", "Europe/Madrid").strip() or "Europe/Madrid", - ) + exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")), + exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi", + exchange_base_url=os.getenv("EXCHANGE_BASE_URL", "").strip(), + exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(), + exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(), + 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", "BTCUSDT").strip() or "BTCUSDT", + ) \ No newline at end of file diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index 3bc652a..f4acad2 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -3,8 +3,9 @@ from __future__ import annotations import platform from dataclasses import dataclass -from src.core.constants import APP_NAME, APP_VERSION from src.core.config import load_settings +from src.core.constants import APP_NAME, APP_VERSION +from src.integrations.exchange.service import ExchangeService @dataclass(slots=True) @@ -22,11 +23,22 @@ class SystemSnapshot: python_version: str os_name: str timezone_name: str + exchange_enabled: bool + exchange_name: str components: list[ComponentStatus] def get_system_snapshot() -> SystemSnapshot: settings = load_settings() + exchange_service = ExchangeService() + exchange_health = exchange_service.get_health() + + if exchange_health.ok and exchange_health.mode == "mock": + exchange_state = "🟡 mock mode" + elif exchange_health.ok: + exchange_state = "🟢 OK" + else: + exchange_state = "🔴 attention" components = [ ComponentStatus( @@ -41,8 +53,8 @@ def get_system_snapshot() -> SystemSnapshot: ), ComponentStatus( name="Биржа", - state="🟡 не подключена", - details="Интеграция с биржей будет добавлена на следующем этапе.", + state=exchange_state, + details=exchange_health.message, ), ComponentStatus( name="База данных", @@ -58,6 +70,8 @@ def get_system_snapshot() -> SystemSnapshot: python_version=platform.python_version(), os_name=f"{platform.system()} {platform.release()}", timezone_name=settings.tz, + exchange_enabled=settings.exchange_enabled, + exchange_name=settings.exchange_name, components=components, ) @@ -83,7 +97,9 @@ def build_system_text() -> str: f"- env: {snapshot.app_env}\n" f"- python: {snapshot.python_version}\n" f"- os: {snapshot.os_name}\n" - f"- timezone: {snapshot.timezone_name}\n\n" + f"- timezone: {snapshot.timezone_name}\n" + f"- exchange_enabled: {snapshot.exchange_enabled}\n" + f"- exchange_name: {snapshot.exchange_name}\n\n" "Справка\n" "/start — стартовый экран\n" "/menu — показать меню\n" diff --git a/app/src/integrations/exchange/__init__.py b/app/src/integrations/exchange/__init__.py new file mode 100644 index 0000000..1f56c7d --- /dev/null +++ b/app/src/integrations/exchange/__init__.py @@ -0,0 +1 @@ +"""Exchange integration package.""" diff --git a/app/src/integrations/exchange/mock_data.py b/app/src/integrations/exchange/mock_data.py new file mode 100644 index 0000000..36ee4c9 --- /dev/null +++ b/app/src/integrations/exchange/mock_data.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice + + +def mock_exchange_health() -> ExchangeHealth: + return ExchangeHealth( + ok=True, + mode="mock", + message="Биржа работает в mock mode. Реальный API пока не подключен.", + ) + + +def mock_ticker_price(symbol: str) -> TickerPrice: + symbol = symbol.upper().strip() + fake_prices = { + "BTCUSDT": 68425.10, + "ETHUSDT": 3521.44, + "BNBUSDT": 612.33, + } + price = fake_prices.get(symbol, 100.00) + updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + return TickerPrice( + symbol=symbol, + price=price, + source="mock", + updated_at=updated_at, + ) + + +def mock_balance_summary() -> list[BalanceSummary]: + return [ + BalanceSummary(currency="USDT", available=1500.00, locked=0.0, source="mock"), + BalanceSummary(currency="BTC", available=0.025, locked=0.0, source="mock"), + ] diff --git a/app/src/integrations/exchange/models.py b/app/src/integrations/exchange/models.py new file mode 100644 index 0000000..27389bf --- /dev/null +++ b/app/src/integrations/exchange/models.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class ExchangeHealth: + ok: bool + mode: str + message: str + + +@dataclass(slots=True) +class TickerPrice: + symbol: str + price: float + source: str + updated_at: str + + +@dataclass(slots=True) +class BalanceSummary: + currency: str + available: float + locked: float + source: str diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py new file mode 100644 index 0000000..b05758d --- /dev/null +++ b/app/src/integrations/exchange/service.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from src.core.config import load_settings +from src.integrations.exchange.mock_data import ( + mock_balance_summary, + mock_exchange_health, + mock_ticker_price, +) +from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice + + +class ExchangeService: + def __init__(self) -> None: + self.settings = load_settings() + + def get_health(self) -> ExchangeHealth: + if not self.settings.exchange_enabled: + return mock_exchange_health() + + if not self.settings.exchange_api_key or not self.settings.exchange_api_secret: + return ExchangeHealth( + ok=False, + mode="configured_without_keys", + message="Интеграция включена, но API key / secret не заданы.", + ) + + return ExchangeHealth( + ok=False, + mode="real_pending", + message="Реальный REST client еще не подключен. Пока доступен только mock mode.", + ) + + def get_price(self, symbol: str | None = None) -> TickerPrice: + symbol_to_use = symbol or self.settings.default_symbol + return mock_ticker_price(symbol_to_use) + + def get_balance_summary(self) -> list[BalanceSummary]: + return mock_balance_summary() diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index af95359..12b0a55 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from aiogram import F, Router from aiogram.types import Message -from src.telegram.menus import MARKET_TEXT +from src.integrations.exchange.service import ExchangeService router = Router(name="market") @@ -9,4 +11,15 @@ router = Router(name="market") @router.message(F.text == "📈 Рынок") async def open_market(message: Message) -> None: - await message.answer(MARKET_TEXT) + service = ExchangeService() + ticker = service.get_price() + + text = ( + "📈 Рынок\n\n" + f"Символ: {ticker.symbol}\n" + f"Цена: {ticker.price:.2f}\n" + f"Источник: {ticker.source}\n" + f"Обновлено: {ticker.updated_at}" + ) + + await message.answer(text) diff --git a/docs/decisions/0005-exchange-integration.md b/docs/decisions/0005-exchange-integration.md new file mode 100644 index 0000000..66b7767 --- /dev/null +++ b/docs/decisions/0005-exchange-integration.md @@ -0,0 +1,14 @@ +# 0005 — Exchange Integration Start + +## Решение +Начать интеграцию с биржей не с реальных ордеров, а с mock / readonly уровня. + +## Причины +- это безопаснее +- это позволяет сначала стабилизировать transport/config/UX +- это дает быстрый результат в интерфейсе + +## Последствия +- первые экраны работают в mock mode +- реальный REST client будет добавлен позже +- рынок и система начинают зависеть от exchange service, а не от raw handlers logic diff --git a/docs/git_flow_dzentra_bot.md b/docs/git_flow_dzentra_bot.md new file mode 100644 index 0000000..139fac8 --- /dev/null +++ b/docs/git_flow_dzentra_bot.md @@ -0,0 +1,189 @@ +# Git Setup Flow: dzentra_bot + +## Полный процесс: от `git init` до первого `push` в Gitea + +--- + +## 📍 0. Перейти в корень проекта + +```bash +cd ~/vsprojects/dzentra_bot +pwd +``` + +Ожидаемо: +``` +.../vsprojects/dzentra_bot +``` + +--- + +## 🧱 1. Инициализация Git + +```bash +git init +``` + +Проверка: +```bash +git status +``` + +--- + +## 📦 2. Проверка `.gitignore` + +```bash +cat .gitignore +``` + +Должны быть строки: +``` +app/.env +app/.venv/ +__pycache__/ +.DS_Store +``` + +--- + +## ➕ 3. Добавить все файлы + +```bash +git add . +``` + +Проверка: +```bash +git status +``` + +--- + +## 💾 4. Первый commit + +```bash +git commit -m "bootstrap v2 stable start" +``` + +--- + +## 🔗 5. Подключить удалённый репозиторий (Gitea) + +```bash +git remote add origin https://gitadmin@git.segeba.by/gitadmin/dzentra_bot.git +``` + +Проверка: +```bash +git remote -v +``` + +--- + +## 🌿 6. Установить ветку main + +```bash +git branch -M main +``` + +--- + +## 🔐 7. Настроить сохранение токена (macOS) + +```bash +git config --global credential.helper osxkeychain +``` + +--- + +## 🚀 8. Первый push + +```bash +git push -u origin main +``` + +При запросе: + +### Username: +``` +gitadmin +``` + +### Password: +👉 вставить **Personal Access Token из Gitea** + +--- + +## ✅ 9. Проверка + +```bash +git status +git branch -vv +``` + +Проверить в Gitea — файлы должны появиться. + +--- + +# 🔁 Дальнейшая работа + +## Каждый цикл разработки + +```bash +git status +git add . +git commit -m "описание изменения" +git push +``` + +--- + +# 🖥 Работа с проектом + +## Запуск бота + +```bash +cd app +source .venv/bin/activate +python -m src.main +``` + +## Git всегда из корня + +```bash +cd ~/vsprojects/dzentra_bot +git status +``` + +--- + +# 🚀 Deploy на Synology + +```bash +git pull +sudo docker compose -f infra/compose/docker-compose.yml up --build -d +``` + +--- + +# ⚠️ Важно + +Не коммитить: + +``` +app/.env +app/.venv +ключи +пароли +логи +``` + +--- + +# 🎯 Итог + +Git используется как: +- система контроля версий +- история проекта +- мост между Mac и Synology diff --git a/docs/stages/stage-03-1-integration-mock.md b/docs/stages/stage-03-1-integration-mock.md new file mode 100644 index 0000000..437a4c8 --- /dev/null +++ b/docs/stages/stage-03-1-integration-mock.md @@ -0,0 +1,45 @@ +# Stage 03.1 — Mock Integration + +## Цель +Оживить экран `📈 Рынок` и добавить первый статус интеграции с биржей без риска реальной торговли. + +## Что добавляется +- exchange settings в `config.py` +- модели интеграции +- mock data +- exchange service +- экран `📈 Рынок` с mock price +- экран `⚙️ Система` со статусом интеграции + +## Какой результат нужен +### Рынок +Пользователь нажимает `📈 Рынок` и видит: +- символ +- цену +- источник `mock` +- время обновления + +### Система +Пользователь нажимает `⚙️ Система` и видит: +- Биржа: mock mode +- exchange_enabled +- exchange_name + +## Почему это правильный шаг +- не нужен реальный API в первый день +- можно спокойно отладить архитектуру +- рынок перестает быть заглушкой +- system screen становится полезнее + +## Как проверить +1. Запустить бота локально +2. Нажать `📈 Рынок` +3. Нажать `⚙️ Система` +4. Проверить `/help` + +## Рекомендуемые commit messages +```bash +git commit -m "add exchange config and mock models" +git commit -m "connect market screen to mock exchange service" +git commit -m "show exchange mock health in system screen" +```