Stage 04.3 - repositories, balance snapshots and environment mode fix
This commit is contained in:
@@ -26,6 +26,8 @@ class Settings:
|
|||||||
db_name: str
|
db_name: str
|
||||||
db_user: str
|
db_user: str
|
||||||
db_password: str
|
db_password: str
|
||||||
|
def is_demo_mode(self) -> bool:
|
||||||
|
return "demo" in self.exchange_base_url.lower()
|
||||||
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:
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ def _build_journal_status() -> ComponentStatus:
|
|||||||
return ComponentStatus(name="Журнал", state="🔴", details=message)
|
return ComponentStatus(name="Журнал", state="🔴", details=message)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mode_label(exchange_testnet: bool) -> str:
|
def _resolve_mode_label(settings) -> str:
|
||||||
return "ДЕМО аккаунт" if exchange_testnet else "РЕАЛЬНЫЙ аккаунт"
|
is_demo = "demo" in settings.exchange_base_url.lower()
|
||||||
|
return "ДЕМО аккаунт" if is_demo else "РЕАЛЬНЫЙ аккаунт"
|
||||||
|
|
||||||
|
|
||||||
def get_system_snapshot() -> SystemSnapshot:
|
def get_system_snapshot() -> SystemSnapshot:
|
||||||
@@ -134,7 +135,7 @@ def get_system_snapshot() -> SystemSnapshot:
|
|||||||
app_version=APP_VERSION,
|
app_version=APP_VERSION,
|
||||||
db_label=db_label,
|
db_label=db_label,
|
||||||
timezone_name=settings.tz,
|
timezone_name=settings.tz,
|
||||||
mode_label=_resolve_mode_label(settings.exchange_testnet),
|
mode_label=_resolve_mode_label(settings),
|
||||||
default_symbol=settings.default_symbol,
|
default_symbol=settings.default_symbol,
|
||||||
components=components,
|
components=components,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -312,9 +312,15 @@ class ExchangeService:
|
|||||||
else:
|
else:
|
||||||
updated_at = "n/a"
|
updated_at = "n/a"
|
||||||
|
|
||||||
|
source = (
|
||||||
|
"dzengi-demo-api"
|
||||||
|
if "demo" in self.settings.exchange_base_url.lower()
|
||||||
|
else "dzengi-api"
|
||||||
|
)
|
||||||
|
|
||||||
return TickerPrice(
|
return TickerPrice(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
price=float(price_raw),
|
price=float(price_raw),
|
||||||
source="dzengi-demo-api",
|
source=source,
|
||||||
updated_at=updated_at,
|
updated_at=updated_at,
|
||||||
)
|
)
|
||||||
60
app/src/storage/repositories/balance_snapshots.py
Normal file
60
app/src/storage/repositories/balance_snapshots.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.storage.session import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceSnapshotRepository:
|
||||||
|
def add_snapshot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
payload_json = json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'''
|
||||||
|
INSERT INTO balance_snapshots (source, payload_json)
|
||||||
|
VALUES (%s, %s::jsonb)
|
||||||
|
''',
|
||||||
|
(source, payload_json),
|
||||||
|
)
|
||||||
|
|
||||||
|
def count_snapshots(self) -> int:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM balance_snapshots")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
def list_recent_snapshots(self, limit: int = 5) -> list[dict[str, str]]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'''
|
||||||
|
SELECT created_at, source, payload_json::text
|
||||||
|
FROM balance_snapshots
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
''',
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
items: list[dict[str, str]] = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"created_at": str(row[0]),
|
||||||
|
"source": str(row[1]),
|
||||||
|
"payload_json": str(row[2]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
66
app/src/storage/repositories/order_drafts.py
Normal file
66
app/src/storage/repositories/order_drafts.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.storage.session import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDraftRepository:
|
||||||
|
def add_draft(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
order_type: str,
|
||||||
|
quantity: str,
|
||||||
|
status: str = "draft",
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None
|
||||||
|
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'''
|
||||||
|
INSERT INTO order_drafts (symbol, side, order_type, quantity, status, payload_json)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
||||||
|
''',
|
||||||
|
(symbol, side, order_type, quantity, status, payload_json),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_recent_drafts(self, limit: int = 10) -> list[dict[str, str]]:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'''
|
||||||
|
SELECT created_at, symbol, side, order_type, quantity::text, status
|
||||||
|
FROM order_drafts
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
''',
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
items: list[dict[str, str]] = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"created_at": str(row[0]),
|
||||||
|
"symbol": str(row[1]),
|
||||||
|
"side": str(row[2]),
|
||||||
|
"order_type": str(row[3]),
|
||||||
|
"quantity": str(row[4]),
|
||||||
|
"status": str(row[5]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def count_drafts(self) -> int:
|
||||||
|
with get_connection() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM order_drafts")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
return int(row[0]) if row else 0
|
||||||
@@ -5,7 +5,7 @@ from aiogram.types import Message
|
|||||||
|
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
from src.integrations.exchange.exceptions import ExchangeError
|
||||||
from src.integrations.exchange.models import BalanceSummary
|
from src.integrations.exchange.models import BalanceSummary
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.trading.accounts.service import AccountsService
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ def _safe_log_error(
|
|||||||
|
|
||||||
@router.message(F.text == "💼 Портфель")
|
@router.message(F.text == "💼 Портфель")
|
||||||
async def open_portfolio(message: Message) -> None:
|
async def open_portfolio(message: Message) -> None:
|
||||||
service = ExchangeService()
|
service = AccountsService()
|
||||||
journal = JournalService()
|
journal = JournalService()
|
||||||
|
|
||||||
user_id = message.from_user.id if message.from_user else None
|
user_id = message.from_user.id if message.from_user else None
|
||||||
@@ -143,7 +143,7 @@ async def open_portfolio(message: Message) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
balances = service.get_balance_summary()
|
balances = service.get_live_balance_summary()
|
||||||
except ExchangeError as exc:
|
except ExchangeError as exc:
|
||||||
_safe_log_error(
|
_safe_log_error(
|
||||||
journal,
|
journal,
|
||||||
@@ -232,4 +232,4 @@ async def open_portfolio(message: Message) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
text = "\n".join(lines).rstrip()
|
text = "\n".join(lines).rstrip()
|
||||||
await message.answer(text)
|
await message.answer(text)
|
||||||
|
|||||||
56
app/src/trading/accounts/service.py
Normal file
56
app/src/trading/accounts/service.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.integrations.exchange.models import BalanceSummary
|
||||||
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.storage.repositories.balance_snapshots import BalanceSnapshotRepository
|
||||||
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.exchange_service = ExchangeService()
|
||||||
|
self.snapshot_repository = BalanceSnapshotRepository()
|
||||||
|
self.journal = JournalService()
|
||||||
|
|
||||||
|
def get_live_balance_summary(self) -> list[BalanceSummary]:
|
||||||
|
balances = self.exchange_service.get_balance_summary()
|
||||||
|
self._save_snapshot(balances)
|
||||||
|
return balances
|
||||||
|
|
||||||
|
def _save_snapshot(self, balances: list[BalanceSummary]) -> None:
|
||||||
|
payload = {
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"currency": item.currency,
|
||||||
|
"available": item.available,
|
||||||
|
"locked": item.locked,
|
||||||
|
"source": item.source,
|
||||||
|
}
|
||||||
|
for item in balances
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.snapshot_repository.add_snapshot(
|
||||||
|
source="portfolio_screen",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
self.journal.log_warning(
|
||||||
|
"balance_snapshot_error",
|
||||||
|
f"Не удалось сохранить snapshot баланса: {exc}",
|
||||||
|
{"assets_count": len(balances)},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.journal.log_info(
|
||||||
|
"balance_snapshot_saved",
|
||||||
|
f"Snapshot баланса сохранён. Активов: {len(balances)}",
|
||||||
|
{"assets_count": len(balances)},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
14
docs/decisions/0011-repositories-before-orders.md
Normal file
14
docs/decisions/0011-repositories-before-orders.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# 0011 — Repositories before Orders
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Сначала добавить repository слой для snapshots и drafts, и только потом переходить к order flow.
|
||||||
|
|
||||||
|
## Причины
|
||||||
|
- order flow без repositories быстро приводит к смешению SQL и бизнес-логики
|
||||||
|
- snapshots баланса — безопасный и полезный use case
|
||||||
|
- это подготавливает архитектуру к следующим этапам
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
- storage становится частью бизнес-логики
|
||||||
|
- Telegram handlers становятся тоньше
|
||||||
|
- следующий этап с order drafts будет проще и чище
|
||||||
49
docs/stages/stage-04-3-repositories.md
Normal file
49
docs/stages/stage-04-3-repositories.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Stage 04.3 — Repositories & Balance Snapshots
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Сделать storage частью бизнес-логики:
|
||||||
|
- вынести SQL в repositories
|
||||||
|
- добавить слой AccountsService
|
||||||
|
- начать сохранять состояние системы (snapshots)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что реализовано
|
||||||
|
|
||||||
|
### Repository слой
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
|
||||||
|
#### 1. BalanceSnapshotRepository
|
||||||
|
Работа с таблицей `balance_snapshots`:
|
||||||
|
|
||||||
|
- `add_snapshot` — сохранение снимка баланса
|
||||||
|
- `count_snapshots`
|
||||||
|
- `list_recent_snapshots`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. OrderDraftRepository
|
||||||
|
Подготовка к order flow:
|
||||||
|
|
||||||
|
- `add_draft`
|
||||||
|
- `list_recent_drafts`
|
||||||
|
- `count_drafts`
|
||||||
|
|
||||||
|
(используется на следующих этапах)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Service слой
|
||||||
|
|
||||||
|
Добавлен:
|
||||||
|
|
||||||
|
#### AccountsService
|
||||||
|
|
||||||
|
Функции:
|
||||||
|
- получение live баланса через ExchangeService
|
||||||
|
- сохранение snapshot в PostgreSQL
|
||||||
|
- логирование через Journal
|
||||||
|
|
||||||
|
```text
|
||||||
|
exchange → accounts service → repository → database
|
||||||
Reference in New Issue
Block a user