- Fix TOCTOU race conditions by wrapping read-modify-write cycles under single-file locks in execution_state, portfolio_service, precheck_state, state_manager, and precheck_service. - Add missing test coverage (96 tests total): - test_review_service.py (15 tests) - test_check_api.py (6 tests) - test_external_gate.py main branches (+10 tests) - test_trade_execution.py new commands (+8 tests) - Unify all agent-consumed JSON messages to English. - Config-ize hardcoded values (volume filter, schema_version) via get_user_config with sensible defaults. - Add 1-hour TTL to exchange cache with force_new override. - Add ruff and mypy to dev dependencies; fix all type errors. - Add __all__ declarations to 11 service modules. - Sync README with new commands, config tuning docs, and PyPI badge. - Publish package as coinhunter==1.0.0 on PyPI with MIT license. - Deprecate coinhunter-cli==1.0.1 with runtime warning. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
151 lines
4.8 KiB
Python
151 lines
4.8 KiB
Python
"""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
|