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.
- 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

View File

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

View File

@@ -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", "")

View File

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

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 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

View File

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

View File

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

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

View File

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

View File

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

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
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