import json import math from datetime import datetime, timezone from typing import Any from sqlalchemy.orm import Session from app.coinex import CoinExClient from app.models import MarketRule class MarketRuleService: def __init__(self, coinex: CoinExClient) -> None: self.coinex = coinex async def sync(self, db: Session, markets: list[str] | None = None, quote_asset: str = 'USDT') -> dict[str, Any]: payload = await self.coinex.get_market_info() rows = payload.get('data') or [] if isinstance(rows, dict): rows = [rows] wanted = {m.upper() for m in markets or []} synced = 0 active_markets: list[str] = [] for row in rows: if not isinstance(row, dict): continue market = str(row.get('market') or row.get('name') or '').upper() if not market: continue quote = str(row.get('quote_ccy') or row.get('quote_currency') or row.get('quote') or quote_asset).upper() if wanted and market not in wanted: continue if not wanted and quote != quote_asset.upper(): continue rule = db.query(MarketRule).filter(MarketRule.market == market).first() if rule is None: rule = MarketRule(market=market) db.add(rule) rule.base_asset = str(row.get('base_ccy') or row.get('base_currency') or row.get('base') or market.replace(quote, '')).upper() rule.quote_asset = quote rule.min_amount = self._float(row.get('min_amount') or row.get('min_base_amount') or row.get('min_asset_amount'), 0.0) rule.min_quote_amount = self._float(row.get('min_quote_amount') or row.get('min_amount_value') or row.get('min_value'), 0.0) rule.amount_precision = self._int(row.get('amount_precision') or row.get('base_ccy_precision') or row.get('trading_precision'), 8) rule.price_precision = self._int(row.get('price_precision') or row.get('quote_ccy_precision'), 8) rule.maker_fee_rate = self._float(row.get('maker_fee_rate') or row.get('maker_fee') or row.get('maker'), 0.002) rule.taker_fee_rate = self._float(row.get('taker_fee_rate') or row.get('taker_fee') or row.get('taker'), 0.002) rule.is_trading_enabled = str(row.get('status') or row.get('state') or 'online').lower() not in {'offline', 'disabled', 'suspend'} rule.raw_json = json.dumps(row, ensure_ascii=False) rule.synced_at = datetime.now(timezone.utc) synced += 1 if rule.is_trading_enabled: active_markets.append(market) db.commit() return {'synced': synced, 'markets': active_markets} def active_markets(self, db: Session, quote_asset: str = 'USDT', fallback: list[str] | None = None) -> list[str]: rows = db.query(MarketRule).filter(MarketRule.quote_asset == quote_asset.upper(), MarketRule.is_trading_enabled.is_(True)).order_by(MarketRule.market.asc()).all() markets = [row.market for row in rows] return markets or list(fallback or []) def get(self, db: Session, market: str) -> MarketRule | None: return db.query(MarketRule).filter(MarketRule.market == market.upper()).first() def ensure_amount(self, db: Session, market: str, quote_amount: float, price: float) -> tuple[float, str]: rule = self.get(db, market) if price <= 0: raise ValueError('price must be positive') raw_amount = quote_amount / price if rule is None: return raw_amount, 'Лимиты CoinEx еще не синхронизированы, используется расчет без округления.' amount = self.floor_amount(raw_amount, rule.amount_precision) min_amount = rule.min_amount or 0.0 min_quote = rule.min_quote_amount or 0.0 if min_amount and amount < min_amount: raise ValueError(f'amount ниже лимита CoinEx: {amount} < {min_amount}') if min_quote and amount * price < min_quote: raise ValueError(f'quote amount ниже лимита CoinEx: {amount * price:.8f} < {min_quote}') return amount, f'Лимиты CoinEx применены: amount_precision={rule.amount_precision}, min_amount={rule.min_amount}, min_quote={rule.min_quote_amount}, taker_fee={rule.taker_fee_rate}' def floor_amount(self, amount: float, precision: int) -> float: factor = 10 ** max(0, precision) return math.floor(amount * factor) / factor def _float(self, value: Any, default: float) -> float: try: return float(value) except (TypeError, ValueError): return default def _int(self, value: Any, default: int) -> int: try: return int(value) except (TypeError, ValueError): return default