diff --git a/README.md b/README.md index a698804..72046e7 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,46 @@ CoinHunter CLI is the executable tooling layer for CoinHunter. -- Code lives in this repository. -- User runtime data lives in `~/.coinhunter/`. -- Hermes skills can call this CLI instead of embedding large script collections. +• Code lives in this repository. +• User runtime data lives in ~/.coinhunter/ by default. +• Hermes skills can call this CLI instead of embedding large script collections. +• Runtime locations can be overridden with COINHUNTER_HOME, HERMES_HOME, COINHUNTER_ENV_FILE, and HERMES_BIN. -## Install (editable) +## Install + +Editable install: ```bash pip install -e . ``` -## Example commands +## Core commands ```bash +coinhunter --version +coinhunter doctor +coinhunter paths +coinhunter init coinhunter check-api coinhunter smart-executor balances coinhunter precheck coinhunter review-context 12 coinhunter market-probe bybit-ticker BTCUSDT ``` + +## Runtime model + +Default layout: + +• ~/.coinhunter/ stores config, positions, logs, reviews, and state. +• ~/.hermes/.env stores exchange credentials unless COINHUNTER_ENV_FILE overrides it. +• hermes is discovered from PATH first, then ~/.local/bin/hermes, unless HERMES_BIN overrides it. + +## Next refactor direction + +This repository now has a dedicated runtime layer and CLI diagnostics. The next major cleanup is to split command adapters from trading services so the internal architecture becomes: + +• commands/ +• services/ +• runtime/ +• domain logic diff --git a/src/coinhunter/auto_trader.py b/src/coinhunter/auto_trader.py index c395b50..f37ff92 100755 --- a/src/coinhunter/auto_trader.py +++ b/src/coinhunter/auto_trader.py @@ -18,10 +18,13 @@ from pathlib import Path import ccxt +from .runtime import get_runtime_paths, load_env_file + # ============== 配置 ============== -COINS_DIR = Path.home() / ".coinhunter" -POSITIONS_FILE = COINS_DIR / "positions.json" -ENV_FILE = Path.home() / ".hermes" / ".env" +PATHS = get_runtime_paths() +COINS_DIR = PATHS.root +POSITIONS_FILE = PATHS.positions_file +ENV_FILE = PATHS.env_file CST = timezone(timedelta(hours=8)) @@ -58,12 +61,7 @@ def save_positions(positions: list): def load_env(): - if ENV_FILE.exists(): - for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, val = line.split("=", 1) - os.environ.setdefault(key.strip(), val.strip()) + load_env_file(PATHS) def calculate_position_size(total_usdt: float, available_usdt: float, open_slots: int) -> float: diff --git a/src/coinhunter/check_api.py b/src/coinhunter/check_api.py index 55bf4a4..b6308bb 100755 --- a/src/coinhunter/check_api.py +++ b/src/coinhunter/check_api.py @@ -1,17 +1,12 @@ #!/usr/bin/env python3 """检查自动交易的环境配置是否就绪""" import os -from pathlib import Path + +from .runtime import load_env_file def main(): - env_file = Path.home() / ".hermes" / ".env" - if env_file.exists(): - for line in env_file.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - k, v = line.split("=", 1) - os.environ.setdefault(k.strip(), v.strip()) + load_env_file() api_key = os.getenv("BINANCE_API_KEY", "") secret = os.getenv("BINANCE_API_SECRET", "") diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index c4e8534..0a44ca4 100755 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -1,23 +1,35 @@ """CoinHunter unified CLI entrypoint.""" +from __future__ import annotations + import argparse import importlib import sys +from . import __version__ + MODULE_MAP = { - "smart-executor": "smart_executor", - "auto-trader": "auto_trader", - "precheck": "precheck", + "check-api": "check_api", + "doctor": "doctor", "external-gate": "external_gate", + "init": "init_user_state", + "market-probe": "market_probe", + "paths": "paths", + "precheck": "precheck", "review-context": "review_context", "review-engine": "review_engine", - "market-probe": "market_probe", - "check-api": "check_api", "rotate-external-gate-log": "rotate_external_gate_log", - "init": "init_user_state", + "smart-executor": "smart_executor", + "auto-trader": "auto_trader", } +class VersionAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + print(__version__) + raise SystemExit(0) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="coinhunter", @@ -25,7 +37,10 @@ def build_parser() -> argparse.ArgumentParser: formatter_class=argparse.RawTextHelpFormatter, epilog=( "Examples:\n" + " coinhunter doctor\n" + " coinhunter paths\n" " coinhunter check-api\n" + " coinhunter smart-executor balances\n" " coinhunter smart-executor hold\n" " coinhunter smart-executor --analysis '...' --reasoning '...' buy ENJUSDT 50\n" " coinhunter precheck\n" @@ -36,7 +51,8 @@ def build_parser() -> argparse.ArgumentParser: " coinhunter init\n" ), ) - parser.add_argument("command", choices=sorted(MODULE_MAP.keys())) + parser.add_argument("--version", nargs=0, action=VersionAction, help="Print installed version and exit") + parser.add_argument("command", nargs="?", choices=sorted(MODULE_MAP.keys()), help="CoinHunter command to run") parser.add_argument("args", nargs=argparse.REMAINDER) return parser @@ -59,6 +75,9 @@ def run_python_module(module_name: str, argv: list[str]) -> int: def main() -> int: parser = build_parser() parsed = parser.parse_args() + if not parsed.command: + parser.print_help() + return 0 module_name = MODULE_MAP[parsed.command] argv = list(parsed.args) if argv and argv[0] == "--": diff --git a/src/coinhunter/doctor.py b/src/coinhunter/doctor.py new file mode 100644 index 0000000..fe99c70 --- /dev/null +++ b/src/coinhunter/doctor.py @@ -0,0 +1,66 @@ +"""Runtime diagnostics for CoinHunter CLI.""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import sys + +from .runtime import ensure_runtime_dirs, get_runtime_paths, load_env_file, resolve_hermes_executable + + +REQUIRED_ENV_VARS = ["BINANCE_API_KEY", "BINANCE_API_SECRET"] + + +def main() -> int: + paths = ensure_runtime_dirs(get_runtime_paths()) + env_file = load_env_file(paths) + hermes_executable = resolve_hermes_executable(paths) + + env_checks = {} + missing_env = [] + for name in REQUIRED_ENV_VARS: + present = bool(os.getenv(name)) + env_checks[name] = present + if not present: + missing_env.append(name) + + file_checks = { + "env_file_exists": env_file.exists(), + "config_exists": paths.config_file.exists(), + "positions_exists": paths.positions_file.exists(), + "logrotate_config_exists": paths.logrotate_config.exists(), + } + dir_checks = { + "root_exists": paths.root.exists(), + "state_dir_exists": paths.state_dir.exists(), + "logs_dir_exists": paths.logs_dir.exists(), + "reviews_dir_exists": paths.reviews_dir.exists(), + "cache_dir_exists": paths.cache_dir.exists(), + } + command_checks = { + "hermes": bool(shutil.which("hermes") or paths.hermes_bin.exists()), + "logrotate": bool(shutil.which("logrotate") or shutil.which("/usr/sbin/logrotate")), + } + + report = { + "ok": not missing_env, + "python": sys.version.split()[0], + "platform": platform.platform(), + "env_file": str(env_file), + "hermes_executable": hermes_executable, + "paths": paths.as_dict(), + "env_checks": env_checks, + "missing_env": missing_env, + "file_checks": file_checks, + "dir_checks": dir_checks, + "command_checks": command_checks, + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/coinhunter/external_gate.py b/src/coinhunter/external_gate.py index 802c4bc..18f6c42 100755 --- a/src/coinhunter/external_gate.py +++ b/src/coinhunter/external_gate.py @@ -4,13 +4,13 @@ import json import subprocess import sys from datetime import datetime, timezone -from pathlib import Path -BASE_DIR = Path.home() / ".coinhunter" -STATE_DIR = BASE_DIR / "state" -LOCK_FILE = STATE_DIR / "external_gate.lock" +from .runtime import ensure_runtime_dirs, get_runtime_paths, resolve_hermes_executable + +PATHS = get_runtime_paths() +STATE_DIR = PATHS.state_dir +LOCK_FILE = PATHS.external_gate_lock COINHUNTER_MODULE = [sys.executable, "-m", "coinhunter"] -HERMES_BIN = Path.home() / ".local" / "bin" / "hermes" TRADE_JOB_ID = "4e6593fff158" @@ -34,7 +34,7 @@ def parse_json_output(text: str) -> dict: def main(): - STATE_DIR.mkdir(parents=True, exist_ok=True) + ensure_runtime_dirs(PATHS) with open(LOCK_FILE, "w", encoding="utf-8") as lockf: try: fcntl.flock(lockf.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) @@ -66,7 +66,7 @@ def main(): log(f"failed to mark run requested; stdout={mark.stdout.strip()} stderr={mark.stderr.strip()}") return 1 - trigger = run_cmd([str(HERMES_BIN), "cron", "run", TRADE_JOB_ID]) + trigger = run_cmd([resolve_hermes_executable(PATHS), "cron", "run", TRADE_JOB_ID]) if trigger.returncode != 0: log(f"failed to trigger trade cron job; stdout={trigger.stdout.strip()} stderr={trigger.stderr.strip()}") return 1 diff --git a/src/coinhunter/init_user_state.py b/src/coinhunter/init_user_state.py index 1465136..420773f 100755 --- a/src/coinhunter/init_user_state.py +++ b/src/coinhunter/init_user_state.py @@ -3,8 +3,11 @@ import json from datetime import datetime, timezone from pathlib import Path -ROOT = Path.home() / ".coinhunter" -CACHE_DIR = ROOT / "cache" +from .runtime import ensure_runtime_dirs, get_runtime_paths + +PATHS = get_runtime_paths() +ROOT = PATHS.root +CACHE_DIR = PATHS.cache_dir def now_iso(): @@ -19,8 +22,7 @@ def ensure_file(path: Path, payload: dict): def main(): - ROOT.mkdir(parents=True, exist_ok=True) - CACHE_DIR.mkdir(parents=True, exist_ok=True) + ensure_runtime_dirs(PATHS) created = [] ts = now_iso() diff --git a/src/coinhunter/logger.py b/src/coinhunter/logger.py index 295c5ed..eaf2485 100755 --- a/src/coinhunter/logger.py +++ b/src/coinhunter/logger.py @@ -3,10 +3,10 @@ import json import traceback from datetime import datetime, timezone, timedelta -from pathlib import Path -BASE_DIR = Path.home() / ".coinhunter" -LOG_DIR = BASE_DIR / "logs" +from .runtime import get_runtime_paths + +LOG_DIR = get_runtime_paths().logs_dir SCHEMA_VERSION = 2 CST = timezone(timedelta(hours=8)) diff --git a/src/coinhunter/paths.py b/src/coinhunter/paths.py new file mode 100644 index 0000000..188b32e --- /dev/null +++ b/src/coinhunter/paths.py @@ -0,0 +1,16 @@ +"""Print CoinHunter runtime paths.""" + +from __future__ import annotations + +import json + +from .runtime import get_runtime_paths + + +def main() -> int: + print(json.dumps(get_runtime_paths().as_dict(), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/coinhunter/precheck.py b/src/coinhunter/precheck.py index bdf661d..548e9a7 100755 --- a/src/coinhunter/precheck.py +++ b/src/coinhunter/precheck.py @@ -10,12 +10,15 @@ from zoneinfo import ZoneInfo import ccxt -BASE_DIR = Path.home() / ".coinhunter" -STATE_DIR = BASE_DIR / "state" -STATE_FILE = STATE_DIR / "precheck_state.json" -POSITIONS_FILE = BASE_DIR / "positions.json" -CONFIG_FILE = BASE_DIR / "config.json" -ENV_FILE = Path.home() / ".hermes" / ".env" +from .runtime import get_runtime_paths, load_env_file + +PATHS = get_runtime_paths() +BASE_DIR = PATHS.root +STATE_DIR = PATHS.state_dir +STATE_FILE = PATHS.precheck_state_file +POSITIONS_FILE = PATHS.positions_file +CONFIG_FILE = PATHS.config_file +ENV_FILE = PATHS.env_file BASE_PRICE_MOVE_TRIGGER_PCT = 0.025 BASE_PNL_TRIGGER_PCT = 0.03 @@ -66,13 +69,7 @@ def load_json(path: Path, default): def load_env(): - if not ENV_FILE.exists(): - return - for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, val = line.split("=", 1) - os.environ.setdefault(key.strip(), val.strip()) + load_env_file(PATHS) def load_positions(): diff --git a/src/coinhunter/review_engine.py b/src/coinhunter/review_engine.py index a79261d..e305634 100755 --- a/src/coinhunter/review_engine.py +++ b/src/coinhunter/review_engine.py @@ -9,20 +9,17 @@ from pathlib import Path import ccxt from .logger import get_logs_last_n_hours, log_error +from .runtime import get_runtime_paths, load_env_file -ENV_FILE = Path.home() / ".hermes" / ".env" -REVIEW_DIR = Path.home() / ".coinhunter" / "reviews" +PATHS = get_runtime_paths() +ENV_FILE = PATHS.env_file +REVIEW_DIR = PATHS.reviews_dir CST = timezone(timedelta(hours=8)) def load_env(): - if ENV_FILE.exists(): - for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, val = line.split("=", 1) - os.environ.setdefault(key.strip(), val.strip()) + load_env_file(PATHS) def get_exchange(): diff --git a/src/coinhunter/rotate_external_gate_log.py b/src/coinhunter/rotate_external_gate_log.py index 42695f6..3bacea1 100755 --- a/src/coinhunter/rotate_external_gate_log.py +++ b/src/coinhunter/rotate_external_gate_log.py @@ -1,19 +1,21 @@ #!/usr/bin/env python3 """Rotate external gate log using the user's logrotate config/state.""" +import shutil import subprocess -from pathlib import Path -BASE_DIR = Path.home() / ".coinhunter" -STATE_DIR = BASE_DIR / "state" -LOGROTATE_STATUS = STATE_DIR / "logrotate_external_gate.status" -LOGROTATE_CONF = BASE_DIR / "logrotate_external_gate.conf" -LOGS_DIR = BASE_DIR / "logs" +from .runtime import ensure_runtime_dirs, get_runtime_paths + +PATHS = get_runtime_paths() +STATE_DIR = PATHS.state_dir +LOGROTATE_STATUS = PATHS.logrotate_status +LOGROTATE_CONF = PATHS.logrotate_config +LOGS_DIR = PATHS.logs_dir def main(): - STATE_DIR.mkdir(parents=True, exist_ok=True) - LOGS_DIR.mkdir(parents=True, exist_ok=True) - cmd = ["/usr/sbin/logrotate", "-s", str(LOGROTATE_STATUS), str(LOGROTATE_CONF)] + ensure_runtime_dirs(PATHS) + logrotate_bin = shutil.which("logrotate") or "/usr/sbin/logrotate" + cmd = [logrotate_bin, "-s", str(LOGROTATE_STATUS), str(LOGROTATE_CONF)] result = subprocess.run(cmd, capture_output=True, text=True) if result.stdout.strip(): print(result.stdout.strip()) diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py new file mode 100644 index 0000000..dbe0fab --- /dev/null +++ b/src/coinhunter/runtime.py @@ -0,0 +1,107 @@ +"""Runtime paths and environment helpers for CoinHunter CLI.""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class RuntimePaths: + root: Path + cache_dir: Path + state_dir: Path + logs_dir: Path + reviews_dir: Path + config_file: Path + positions_file: Path + accounts_file: Path + executions_file: Path + watchlist_file: Path + notes_file: Path + positions_lock: Path + executions_lock: Path + precheck_state_file: Path + external_gate_lock: Path + logrotate_config: Path + logrotate_status: Path + hermes_home: Path + env_file: Path + hermes_bin: Path + + def as_dict(self) -> dict[str, str]: + return {key: str(value) for key, value in asdict(self).items()} + + +def _default_coinhunter_home() -> Path: + raw = os.getenv("COINHUNTER_HOME") + return Path(raw).expanduser() if raw else Path.home() / ".coinhunter" + + +def _default_hermes_home() -> Path: + raw = os.getenv("HERMES_HOME") + return Path(raw).expanduser() if raw else Path.home() / ".hermes" + + +def get_runtime_paths() -> RuntimePaths: + root = _default_coinhunter_home() + hermes_home = _default_hermes_home() + state_dir = root / "state" + return RuntimePaths( + root=root, + cache_dir=root / "cache", + state_dir=state_dir, + logs_dir=root / "logs", + reviews_dir=root / "reviews", + config_file=root / "config.json", + positions_file=root / "positions.json", + accounts_file=root / "accounts.json", + executions_file=root / "executions.json", + watchlist_file=root / "watchlist.json", + notes_file=root / "notes.json", + positions_lock=root / "positions.lock", + executions_lock=root / "executions.lock", + precheck_state_file=state_dir / "precheck_state.json", + external_gate_lock=state_dir / "external_gate.lock", + logrotate_config=root / "logrotate_external_gate.conf", + logrotate_status=state_dir / "logrotate_external_gate.status", + hermes_home=hermes_home, + env_file=Path(os.getenv("COINHUNTER_ENV_FILE", str(hermes_home / ".env"))).expanduser(), + hermes_bin=Path(os.getenv("HERMES_BIN", str(Path.home() / ".local" / "bin" / "hermes"))).expanduser(), + ) + + +def ensure_runtime_dirs(paths: RuntimePaths | None = None) -> RuntimePaths: + paths = paths or get_runtime_paths() + for directory in (paths.root, paths.cache_dir, paths.state_dir, paths.logs_dir, paths.reviews_dir): + directory.mkdir(parents=True, exist_ok=True) + return paths + + +def load_env_file(paths: RuntimePaths | None = None) -> Path: + paths = paths or get_runtime_paths() + if paths.env_file.exists(): + for line in paths.env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + return paths.env_file + + +def resolve_hermes_executable(paths: RuntimePaths | None = None) -> str: + paths = paths or get_runtime_paths() + discovered = shutil.which("hermes") + if discovered: + return discovered + return str(paths.hermes_bin) + + +def mask_secret(value: str | None, *, tail: int = 4) -> str: + if not value: + return "" + if len(value) <= tail: + return "*" * len(value) + return "*" * max(4, len(value) - tail) + value[-tail:] diff --git a/src/coinhunter/smart_executor.py b/src/coinhunter/smart_executor.py index 2292e7a..47815c6 100755 --- a/src/coinhunter/smart_executor.py +++ b/src/coinhunter/smart_executor.py @@ -20,13 +20,14 @@ from pathlib import Path import ccxt from .logger import log_decision, log_error, log_trade +from .runtime import get_runtime_paths, load_env_file -BASE_DIR = Path.home() / ".coinhunter" -POSITIONS_FILE = BASE_DIR / "positions.json" -POSITIONS_LOCK = BASE_DIR / "positions.lock" -EXECUTIONS_FILE = BASE_DIR / "executions.json" -EXECUTIONS_LOCK = BASE_DIR / "executions.lock" -ENV_FILE = Path.home() / ".hermes" / ".env" +PATHS = get_runtime_paths() +POSITIONS_FILE = PATHS.positions_file +POSITIONS_LOCK = PATHS.positions_lock +EXECUTIONS_FILE = PATHS.executions_file +EXECUTIONS_LOCK = PATHS.executions_lock +ENV_FILE = PATHS.env_file DRY_RUN = os.getenv("DRY_RUN", "false").lower() == "true" USDT_BUFFER_PCT = 0.03 MIN_REMAINING_DUST_USDT = 1.0 @@ -43,12 +44,7 @@ def bj_now_iso(): def load_env(): - if ENV_FILE.exists(): - for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, val = line.split("=", 1) - os.environ.setdefault(key.strip(), val.strip()) + load_env_file(PATHS) @contextmanager