refactor: address high-priority debt and publish to PyPI
- 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>
This commit is contained in:
@@ -1,25 +1,47 @@
|
||||
"""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, load_env_file
|
||||
from .trade_common import log
|
||||
from ..runtime import get_runtime_paths, get_user_config, load_env_file
|
||||
|
||||
PATHS = get_runtime_paths()
|
||||
_exchange_cache = None
|
||||
_exchange_cached_at = None
|
||||
|
||||
CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
def load_env():
|
||||
load_env_file(PATHS)
|
||||
load_env_file(get_runtime_paths())
|
||||
|
||||
|
||||
def get_exchange():
|
||||
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("缺少 BINANCE_API_KEY 或 BINANCE_API_SECRET")
|
||||
raise RuntimeError("Missing BINANCE_API_KEY or BINANCE_API_SECRET")
|
||||
ex = ccxt.binance(
|
||||
{
|
||||
"apiKey": api_key,
|
||||
@@ -29,6 +51,8 @@ def get_exchange():
|
||||
}
|
||||
)
|
||||
ex.load_markets()
|
||||
_exchange_cache = ex
|
||||
_exchange_cached_at = now
|
||||
return ex
|
||||
|
||||
|
||||
@@ -38,7 +62,7 @@ def norm_symbol(symbol: str) -> str:
|
||||
return s
|
||||
if s.endswith("USDT"):
|
||||
return s[:-4] + "/USDT"
|
||||
raise ValueError(f"不支持的 symbol: {symbol}")
|
||||
raise ValueError(f"Unsupported symbol: {symbol}")
|
||||
|
||||
|
||||
def storage_symbol(symbol: str) -> str:
|
||||
@@ -63,7 +87,8 @@ def build_market_snapshot(ex):
|
||||
if price is None or float(price) <= 0:
|
||||
continue
|
||||
vol = float(t.get("quoteVolume") or 0)
|
||||
if vol < 200_000:
|
||||
min_volume = get_user_config("exchange.min_quote_volume", 200_000)
|
||||
if vol < min_volume:
|
||||
continue
|
||||
base = sym.replace("/", "")
|
||||
snapshot[base] = {
|
||||
@@ -95,17 +120,17 @@ def prepare_buy_quantity(ex, symbol: str, amount_usdt: float):
|
||||
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 价格")
|
||||
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"{sym} 买入数量 {qty} 小于最小数量 {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"{sym} 买入金额 ${est_cost:.4f} 小于最小成交额 ${float(min_cost):.4f}")
|
||||
raise RuntimeError(f"Buy cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}")
|
||||
return sym, qty, ask, est_cost
|
||||
|
||||
|
||||
@@ -113,13 +138,13 @@ 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 价格")
|
||||
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"{sym} 卖出数量 {qty} 小于最小数量 {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"{sym} 卖出金额 ${est_cost:.4f} 小于最小成交额 ${float(min_cost):.4f}")
|
||||
raise RuntimeError(f"Sell cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}")
|
||||
return sym, qty, bid, est_cost
|
||||
|
||||
Reference in New Issue
Block a user