"""Exchange helpers (ccxt, markets, balances, order prep).""" import math import os import time __all__ = [ "load_env", "get_exchange", "norm_symbol", "storage_symbol", "fetch_balances", "build_market_snapshot", "market_and_ticker", "floor_to_step", "prepare_buy_quantity", "prepare_sell_quantity", ] import ccxt from ..runtime import get_runtime_paths, get_user_config, load_env_file _exchange_cache = None _exchange_cached_at = None CACHE_TTL_SECONDS = 3600 def load_env(): load_env_file(get_runtime_paths()) def get_exchange(force_new: bool = False): global _exchange_cache, _exchange_cached_at now = time.time() if not force_new and _exchange_cache is not None and _exchange_cached_at is not None: ttl = get_user_config("exchange.cache_ttl_seconds", CACHE_TTL_SECONDS) if now - _exchange_cached_at < ttl: return _exchange_cache load_env() api_key = os.getenv("BINANCE_API_KEY") secret = os.getenv("BINANCE_API_SECRET") if not api_key or not secret: raise RuntimeError("Missing BINANCE_API_KEY or BINANCE_API_SECRET") ex = ccxt.binance( { "apiKey": api_key, "secret": secret, "options": {"defaultType": "spot", "createMarketBuyOrderRequiresPrice": False}, "enableRateLimit": True, } ) ex.load_markets() _exchange_cache = ex _exchange_cached_at = now return ex def norm_symbol(symbol: str) -> str: s = symbol.upper().replace("-", "").replace("_", "") if "/" in s: return s if s.endswith("USDT"): return s[:-4] + "/USDT" raise ValueError(f"Unsupported symbol: {symbol}") def storage_symbol(symbol: str) -> str: return norm_symbol(symbol).replace("/", "") def fetch_balances(ex): bal = ex.fetch_balance()["free"] return {k: float(v) for k, v in bal.items() if float(v) > 0} def build_market_snapshot(ex): try: tickers = ex.fetch_tickers() except Exception: return {} snapshot = {} for sym, t in tickers.items(): if not sym.endswith("/USDT"): continue price = t.get("last") if price is None or float(price) <= 0: continue vol = float(t.get("quoteVolume") or 0) min_volume = get_user_config("exchange.min_quote_volume", 200_000) if vol < min_volume: continue base = sym.replace("/", "") snapshot[base] = { "lastPrice": round(float(price), 8), "price24hPcnt": round(float(t.get("percentage") or 0), 4), "highPrice24h": round(float(t.get("high") or 0), 8) if t.get("high") else None, "lowPrice24h": round(float(t.get("low") or 0), 8) if t.get("low") else None, "turnover24h": round(float(vol), 2), } return snapshot def market_and_ticker(ex, symbol: str): sym = norm_symbol(symbol) market = ex.market(sym) ticker = ex.fetch_ticker(sym) return sym, market, ticker def floor_to_step(value: float, step: float) -> float: if not step or step <= 0: return value return math.floor(value / step) * step def prepare_buy_quantity(ex, symbol: str, amount_usdt: float): from .trade_common import USDT_BUFFER_PCT sym, market, ticker = market_and_ticker(ex, symbol) ask = float(ticker.get("ask") or ticker.get("last") or 0) if ask <= 0: raise RuntimeError(f"No valid ask price for {sym}") budget = amount_usdt * (1 - USDT_BUFFER_PCT) raw_qty = budget / ask qty = float(ex.amount_to_precision(sym, raw_qty)) min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0 min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0 if min_amt and qty < float(min_amt): raise RuntimeError(f"Buy quantity {qty} for {sym} below minimum {min_amt}") est_cost = qty * ask if min_cost and est_cost < float(min_cost): raise RuntimeError(f"Buy cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}") return sym, qty, ask, est_cost def prepare_sell_quantity(ex, symbol: str, free_qty: float): sym, market, ticker = market_and_ticker(ex, symbol) bid = float(ticker.get("bid") or ticker.get("last") or 0) if bid <= 0: raise RuntimeError(f"No valid bid price for {sym}") qty = float(ex.amount_to_precision(sym, free_qty)) min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0 min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0 if min_amt and qty < float(min_amt): raise RuntimeError(f"Sell quantity {qty} for {sym} below minimum {min_amt}") est_cost = qty * bid if min_cost and est_cost < float(min_cost): raise RuntimeError(f"Sell cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}") return sym, qty, bid, est_cost