feat: add runtime diagnostics and path management

This commit is contained in:
2026-04-15 16:55:45 +08:00
parent 7586685d5f
commit 6eefabb9ff
14 changed files with 304 additions and 85 deletions

View File

@@ -2,22 +2,46 @@
CoinHunter CLI is the executable tooling layer for CoinHunter. CoinHunter CLI is the executable tooling layer for CoinHunter.
- Code lives in this repository. Code lives in this repository.
- User runtime data lives in `~/.coinhunter/`. User runtime data lives in ~/.coinhunter/ by default.
- Hermes skills can call this CLI instead of embedding large script collections. 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 ```bash
pip install -e . pip install -e .
``` ```
## Example commands ## Core commands
```bash ```bash
coinhunter --version
coinhunter doctor
coinhunter paths
coinhunter init
coinhunter check-api coinhunter check-api
coinhunter smart-executor balances coinhunter smart-executor balances
coinhunter precheck coinhunter precheck
coinhunter review-context 12 coinhunter review-context 12
coinhunter market-probe bybit-ticker BTCUSDT 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

View File

@@ -18,10 +18,13 @@ from pathlib import Path
import ccxt import ccxt
from .runtime import get_runtime_paths, load_env_file
# ============== 配置 ============== # ============== 配置 ==============
COINS_DIR = Path.home() / ".coinhunter" PATHS = get_runtime_paths()
POSITIONS_FILE = COINS_DIR / "positions.json" COINS_DIR = PATHS.root
ENV_FILE = Path.home() / ".hermes" / ".env" POSITIONS_FILE = PATHS.positions_file
ENV_FILE = PATHS.env_file
CST = timezone(timedelta(hours=8)) CST = timezone(timedelta(hours=8))
@@ -58,12 +61,7 @@ def save_positions(positions: list):
def load_env(): def load_env():
if ENV_FILE.exists(): load_env_file(PATHS)
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())
def calculate_position_size(total_usdt: float, available_usdt: float, open_slots: int) -> float: def calculate_position_size(total_usdt: float, available_usdt: float, open_slots: int) -> float:

View File

@@ -1,17 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""检查自动交易的环境配置是否就绪""" """检查自动交易的环境配置是否就绪"""
import os import os
from pathlib import Path
from .runtime import load_env_file
def main(): def main():
env_file = Path.home() / ".hermes" / ".env" load_env_file()
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())
api_key = os.getenv("BINANCE_API_KEY", "") api_key = os.getenv("BINANCE_API_KEY", "")
secret = os.getenv("BINANCE_API_SECRET", "") secret = os.getenv("BINANCE_API_SECRET", "")

View File

@@ -1,23 +1,35 @@
"""CoinHunter unified CLI entrypoint.""" """CoinHunter unified CLI entrypoint."""
from __future__ import annotations
import argparse import argparse
import importlib import importlib
import sys import sys
from . import __version__
MODULE_MAP = { MODULE_MAP = {
"smart-executor": "smart_executor", "check-api": "check_api",
"auto-trader": "auto_trader", "doctor": "doctor",
"precheck": "precheck",
"external-gate": "external_gate", "external-gate": "external_gate",
"init": "init_user_state",
"market-probe": "market_probe",
"paths": "paths",
"precheck": "precheck",
"review-context": "review_context", "review-context": "review_context",
"review-engine": "review_engine", "review-engine": "review_engine",
"market-probe": "market_probe",
"check-api": "check_api",
"rotate-external-gate-log": "rotate_external_gate_log", "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: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="coinhunter", prog="coinhunter",
@@ -25,7 +37,10 @@ def build_parser() -> argparse.ArgumentParser:
formatter_class=argparse.RawTextHelpFormatter, formatter_class=argparse.RawTextHelpFormatter,
epilog=( epilog=(
"Examples:\n" "Examples:\n"
" coinhunter doctor\n"
" coinhunter paths\n"
" coinhunter check-api\n" " coinhunter check-api\n"
" coinhunter smart-executor balances\n"
" coinhunter smart-executor hold\n" " coinhunter smart-executor hold\n"
" coinhunter smart-executor --analysis '...' --reasoning '...' buy ENJUSDT 50\n" " coinhunter smart-executor --analysis '...' --reasoning '...' buy ENJUSDT 50\n"
" coinhunter precheck\n" " coinhunter precheck\n"
@@ -36,7 +51,8 @@ def build_parser() -> argparse.ArgumentParser:
" coinhunter init\n" " 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) parser.add_argument("args", nargs=argparse.REMAINDER)
return parser return parser
@@ -59,6 +75,9 @@ def run_python_module(module_name: str, argv: list[str]) -> int:
def main() -> int: def main() -> int:
parser = build_parser() parser = build_parser()
parsed = parser.parse_args() parsed = parser.parse_args()
if not parsed.command:
parser.print_help()
return 0
module_name = MODULE_MAP[parsed.command] module_name = MODULE_MAP[parsed.command]
argv = list(parsed.args) argv = list(parsed.args)
if argv and argv[0] == "--": if argv and argv[0] == "--":

66
src/coinhunter/doctor.py Normal file
View File

@@ -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())

View File

@@ -4,13 +4,13 @@ import json
import subprocess import subprocess
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
BASE_DIR = Path.home() / ".coinhunter" from .runtime import ensure_runtime_dirs, get_runtime_paths, resolve_hermes_executable
STATE_DIR = BASE_DIR / "state"
LOCK_FILE = STATE_DIR / "external_gate.lock" PATHS = get_runtime_paths()
STATE_DIR = PATHS.state_dir
LOCK_FILE = PATHS.external_gate_lock
COINHUNTER_MODULE = [sys.executable, "-m", "coinhunter"] COINHUNTER_MODULE = [sys.executable, "-m", "coinhunter"]
HERMES_BIN = Path.home() / ".local" / "bin" / "hermes"
TRADE_JOB_ID = "4e6593fff158" TRADE_JOB_ID = "4e6593fff158"
@@ -34,7 +34,7 @@ def parse_json_output(text: str) -> dict:
def main(): def main():
STATE_DIR.mkdir(parents=True, exist_ok=True) ensure_runtime_dirs(PATHS)
with open(LOCK_FILE, "w", encoding="utf-8") as lockf: with open(LOCK_FILE, "w", encoding="utf-8") as lockf:
try: try:
fcntl.flock(lockf.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) 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()}") log(f"failed to mark run requested; stdout={mark.stdout.strip()} stderr={mark.stderr.strip()}")
return 1 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: if trigger.returncode != 0:
log(f"failed to trigger trade cron job; stdout={trigger.stdout.strip()} stderr={trigger.stderr.strip()}") log(f"failed to trigger trade cron job; stdout={trigger.stdout.strip()} stderr={trigger.stderr.strip()}")
return 1 return 1

View File

@@ -3,8 +3,11 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
ROOT = Path.home() / ".coinhunter" from .runtime import ensure_runtime_dirs, get_runtime_paths
CACHE_DIR = ROOT / "cache"
PATHS = get_runtime_paths()
ROOT = PATHS.root
CACHE_DIR = PATHS.cache_dir
def now_iso(): def now_iso():
@@ -19,8 +22,7 @@ def ensure_file(path: Path, payload: dict):
def main(): def main():
ROOT.mkdir(parents=True, exist_ok=True) ensure_runtime_dirs(PATHS)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
created = [] created = []
ts = now_iso() ts = now_iso()

View File

@@ -3,10 +3,10 @@
import json import json
import traceback import traceback
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from pathlib import Path
BASE_DIR = Path.home() / ".coinhunter" from .runtime import get_runtime_paths
LOG_DIR = BASE_DIR / "logs"
LOG_DIR = get_runtime_paths().logs_dir
SCHEMA_VERSION = 2 SCHEMA_VERSION = 2
CST = timezone(timedelta(hours=8)) CST = timezone(timedelta(hours=8))

16
src/coinhunter/paths.py Normal file
View File

@@ -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())

View File

@@ -10,12 +10,15 @@ from zoneinfo import ZoneInfo
import ccxt import ccxt
BASE_DIR = Path.home() / ".coinhunter" from .runtime import get_runtime_paths, load_env_file
STATE_DIR = BASE_DIR / "state"
STATE_FILE = STATE_DIR / "precheck_state.json" PATHS = get_runtime_paths()
POSITIONS_FILE = BASE_DIR / "positions.json" BASE_DIR = PATHS.root
CONFIG_FILE = BASE_DIR / "config.json" STATE_DIR = PATHS.state_dir
ENV_FILE = Path.home() / ".hermes" / ".env" 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_PRICE_MOVE_TRIGGER_PCT = 0.025
BASE_PNL_TRIGGER_PCT = 0.03 BASE_PNL_TRIGGER_PCT = 0.03
@@ -66,13 +69,7 @@ def load_json(path: Path, default):
def load_env(): def load_env():
if not ENV_FILE.exists(): load_env_file(PATHS)
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())
def load_positions(): def load_positions():

View File

@@ -9,20 +9,17 @@ from pathlib import Path
import ccxt import ccxt
from .logger import get_logs_last_n_hours, log_error 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" PATHS = get_runtime_paths()
REVIEW_DIR = Path.home() / ".coinhunter" / "reviews" ENV_FILE = PATHS.env_file
REVIEW_DIR = PATHS.reviews_dir
CST = timezone(timedelta(hours=8)) CST = timezone(timedelta(hours=8))
def load_env(): def load_env():
if ENV_FILE.exists(): load_env_file(PATHS)
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())
def get_exchange(): def get_exchange():

View File

@@ -1,19 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Rotate external gate log using the user's logrotate config/state.""" """Rotate external gate log using the user's logrotate config/state."""
import shutil
import subprocess import subprocess
from pathlib import Path
BASE_DIR = Path.home() / ".coinhunter" from .runtime import ensure_runtime_dirs, get_runtime_paths
STATE_DIR = BASE_DIR / "state"
LOGROTATE_STATUS = STATE_DIR / "logrotate_external_gate.status" PATHS = get_runtime_paths()
LOGROTATE_CONF = BASE_DIR / "logrotate_external_gate.conf" STATE_DIR = PATHS.state_dir
LOGS_DIR = BASE_DIR / "logs" LOGROTATE_STATUS = PATHS.logrotate_status
LOGROTATE_CONF = PATHS.logrotate_config
LOGS_DIR = PATHS.logs_dir
def main(): def main():
STATE_DIR.mkdir(parents=True, exist_ok=True) ensure_runtime_dirs(PATHS)
LOGS_DIR.mkdir(parents=True, exist_ok=True) logrotate_bin = shutil.which("logrotate") or "/usr/sbin/logrotate"
cmd = ["/usr/sbin/logrotate", "-s", str(LOGROTATE_STATUS), str(LOGROTATE_CONF)] cmd = [logrotate_bin, "-s", str(LOGROTATE_STATUS), str(LOGROTATE_CONF)]
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.stdout.strip(): if result.stdout.strip():
print(result.stdout.strip()) print(result.stdout.strip())

107
src/coinhunter/runtime.py Normal file
View File

@@ -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:]

View File

@@ -20,13 +20,14 @@ from pathlib import Path
import ccxt import ccxt
from .logger import log_decision, log_error, log_trade from .logger import log_decision, log_error, log_trade
from .runtime import get_runtime_paths, load_env_file
BASE_DIR = Path.home() / ".coinhunter" PATHS = get_runtime_paths()
POSITIONS_FILE = BASE_DIR / "positions.json" POSITIONS_FILE = PATHS.positions_file
POSITIONS_LOCK = BASE_DIR / "positions.lock" POSITIONS_LOCK = PATHS.positions_lock
EXECUTIONS_FILE = BASE_DIR / "executions.json" EXECUTIONS_FILE = PATHS.executions_file
EXECUTIONS_LOCK = BASE_DIR / "executions.lock" EXECUTIONS_LOCK = PATHS.executions_lock
ENV_FILE = Path.home() / ".hermes" / ".env" ENV_FILE = PATHS.env_file
DRY_RUN = os.getenv("DRY_RUN", "false").lower() == "true" DRY_RUN = os.getenv("DRY_RUN", "false").lower() == "true"
USDT_BUFFER_PCT = 0.03 USDT_BUFFER_PCT = 0.03
MIN_REMAINING_DUST_USDT = 1.0 MIN_REMAINING_DUST_USDT = 1.0
@@ -43,12 +44,7 @@ def bj_now_iso():
def load_env(): def load_env():
if ENV_FILE.exists(): load_env_file(PATHS)
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())
@contextmanager @contextmanager