refactor(smart_executor): split monolithic executor into clean service modules
- Extract 7 focused services from smart_executor.py: - trade_common: constants, timezone, logging, dry-run state - file_utils: file locking + atomic JSON helpers - smart_executor_parser: argparse + legacy argument compatibility - execution_state: decision deduplication (executions.json) - portfolio_service: positions.json + exchange reconciliation - exchange_service: ccxt wrapper, balances, order prep - trade_execution: buy/sell/rebalance/hold actions - Turn smart_executor.py into a thin backward-compatible facade - Fix critical dry-run bug: module-level DRY_RUN copy caused real orders in dry-run mode; replace with mutable dict + is_dry_run() function - Fix dry-run polluting positions.json: skip save_positions() when dry-run - Fix rebalance dry-run budget: use sell_order cost instead of real balance - Add full legacy CLI compatibility for old --decision HOLD --dry-run style
This commit is contained in:
125
src/coinhunter/services/exchange_service.py
Normal file
125
src/coinhunter/services/exchange_service.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Exchange helpers (ccxt, markets, balances, order prep)."""
|
||||
import math
|
||||
import os
|
||||
|
||||
import ccxt
|
||||
|
||||
from ..runtime import get_runtime_paths, load_env_file
|
||||
from .trade_common import log
|
||||
|
||||
PATHS = get_runtime_paths()
|
||||
|
||||
|
||||
def load_env():
|
||||
load_env_file(PATHS)
|
||||
|
||||
|
||||
def get_exchange():
|
||||
load_env()
|
||||
api_key = os.getenv("BINANCE_API_KEY")
|
||||
secret = os.getenv("BINANCE_API_SECRET")
|
||||
if not api_key or not secret:
|
||||
raise RuntimeError("缺少 BINANCE_API_KEY 或 BINANCE_API_SECRET")
|
||||
ex = ccxt.binance(
|
||||
{
|
||||
"apiKey": api_key,
|
||||
"secret": secret,
|
||||
"options": {"defaultType": "spot", "createMarketBuyOrderRequiresPrice": False},
|
||||
"enableRateLimit": True,
|
||||
}
|
||||
)
|
||||
ex.load_markets()
|
||||
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"不支持的 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)
|
||||
if vol < 200_000:
|
||||
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"{sym} 无法获取有效 ask 价格")
|
||||
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"{sym} 买入数量 {qty} 小于最小数量 {min_amt}")
|
||||
est_cost = qty * ask
|
||||
if min_cost and est_cost < float(min_cost):
|
||||
raise RuntimeError(f"{sym} 买入金额 ${est_cost:.4f} 小于最小成交额 ${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"{sym} 无法获取有效 bid 价格")
|
||||
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"{sym} 卖出数量 {qty} 小于最小数量 {min_amt}")
|
||||
est_cost = qty * bid
|
||||
if min_cost and est_cost < float(min_cost):
|
||||
raise RuntimeError(f"{sym} 卖出金额 ${est_cost:.4f} 小于最小成交额 ${float(min_cost):.4f}")
|
||||
return sym, qty, bid, est_cost
|
||||
Reference in New Issue
Block a user