diff --git a/CLAUDE.md b/CLAUDE.md
index 3dbc013..ee76b51 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Editable install:** `pip install -e .`
- **Run the CLI locally:** `python -m coinhunter --help`
-- **Install for end users:** `./scripts/install_local.sh` (creates `~/.local/bin/coinhunter`)
+- **Install for end users:** `./scripts/install_local.sh` (standard `pip install -e .` wrapper)
- **Tests:** There is no test suite yet. The README lists next priorities as adding pytest coverage for runtime paths, state manager, and trigger analyzer.
- **Lint / type-check:** Not configured yet.
@@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`src/coinhunter/cli.py` is the single entrypoint. It resolves aliases to canonical commands, maps canonical commands to Python modules via `MODULE_MAP`, then imports the module and calls `module.main()` after mutating `sys.argv` to match the display name.
-Active commands live in `src/coinhunter/commands/` and are thin adapters that delegate to `src/coinhunter/services/`. A few legacy commands (`review_context.py`, `review_engine.py`) still live at the `src/coinhunter/` root and are imported directly by `cli.py`. Root-level backward-compat facades (e.g., `precheck.py`, `smart_executor.py`) re-export the moved commands.
+Active commands live in `src/coinhunter/commands/` and are thin adapters that delegate to `src/coinhunter/services/`. Root-level backward-compat facades (e.g., `precheck.py`, `smart_executor.py`, `review_engine.py`, `review_context.py`) re-export the moved commands.
## Architecture
@@ -28,7 +28,7 @@ Active commands live in `src/coinhunter/commands/` and are thin adapters that de
### Runtime and environment
-`runtime.py` defines `RuntimePaths` and `get_runtime_paths()`. User data lives in `~/.coinhunter/` by default (override with `COINHUNTER_HOME`). Credentials are loaded from `~/.hermes/.env` by default (override with `COINHUNTER_ENV_FILE`). Many modules eagerly instantiate `PATHS = get_runtime_paths()` at import time.
+`runtime.py` defines `RuntimePaths` and `get_runtime_paths()`. User data lives in `~/.coinhunter/` by default (override with `COINHUNTER_HOME`). Credentials are loaded from `~/.hermes/.env` by default (override with `COINHUNTER_ENV_FILE`). Modules should call `get_runtime_paths()` at function scope rather than eagerly at import time.
### Smart executor (`exec`)
@@ -56,8 +56,8 @@ State is stored in `~/.coinhunter/state/precheck_state.json`.
### Review commands
-- `review N` (`review_context.py`) — generates review context for the last N hours.
-- `recap N` (`review_engine.py`) — generates a full review report by reading JSONL decision/trade/error logs, computing PnL estimates, missed opportunities, and saving a report to `~/.coinhunter/reviews/`.
+- `review N` (`commands/review_context.py` → `services/review_service.py`) — generates review context for the last N hours.
+- `recap N` (`commands/review_engine.py` → `services/review_service.py`) — generates a full review report by reading JSONL decision/trade/error logs, computing PnL estimates, missed opportunities, and saving a report to `~/.coinhunter/reviews/`.
### Logging model
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..13cffdb
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Tacit Lab
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index b8ee897..fa7f559 100644
--- a/README.md
+++ b/README.md
@@ -23,17 +23,19 @@
+
-
---
## What is this?
-`coinhunter-cli` is the **executable tooling layer** for CoinHunter — an installable Python CLI that handles trading operations, market probes, precheck orchestration, and review workflows.
+`coinhunter` is the **executable tooling layer** for CoinHunter — an installable Python CLI that handles trading operations, market probes, precheck orchestration, and review workflows.
+
+> **Note:** The old package name `coinhunter-cli` is deprecated. Please install `coinhunter` going forward.
| Layer | Responsibility | Location |
|-------|----------------|----------|
@@ -74,7 +76,8 @@ src/coinhunter/
│ ├── smart_executor_parser.py
│ ├── execution_state.py
│ ├── precheck_service.py
-│ ├── precheck_constants.py # thresholds & paths
+│ ├── review_service.py # review generation logic
+│ ├── precheck_constants.py # thresholds
│ ├── time_utils.py # UTC/local time helpers
│ ├── data_utils.py # json, hash, float, symbol norm
│ ├── state_manager.py # state load/save/sanitize
@@ -96,21 +99,13 @@ src/coinhunter/
## Installation
-### Quick user install
+### From PyPI (recommended)
```bash
-./scripts/install_local.sh
+pip install coinhunter
```
-Creates:
-- Virtualenv: `~/.local/share/coinhunter-cli/venv`
-- Launcher: `~/.local/bin/coinhunter`
-
-### Editable dev install
-
-```bash
-pip install -e .
-```
+This installs the latest stable release and creates the `coinhunter` console script entry point.
Verify:
@@ -119,6 +114,22 @@ coinhunter --help
coinhunter --version
```
+### Development install (editable)
+
+If you're working on this repo locally:
+
+```bash
+pip install -e ".[dev]"
+```
+
+Or use the convenience script:
+
+```bash
+./scripts/install_local.sh
+```
+
+A thin wrapper that runs `pip install -e .` and verifies the entrypoint is on your PATH.
+
---
## Command Reference
@@ -136,6 +147,9 @@ coinhunter exec hold # record a HOLD decision
coinhunter exec buy ENJUSDT 50 # buy $50 of ENJUSDT
coinhunter exec flat ENJUSDT # sell entire ENJUSDT position
coinhunter exec rotate PEPEUSDT ETHUSDT # rotate exposure
+coinhunter exec orders # list open spot orders
+coinhunter exec order-status ENJUSDT 123456 # check specific order
+coinhunter exec cancel ENJUSDT 123456 # cancel an open order
coinhunter gate # external gate orchestration
coinhunter review 12 # generate review context (last 12h)
coinhunter recap 12 # generate review report (last 12h)
@@ -208,6 +222,45 @@ Run the external gate:
coinhunter gate
```
+The gate reads `trigger_command` from `~/.coinhunter/config.json` under `external_gate`.
+- By default, no external trigger is configured — gate runs precheck and marks state, then exits cleanly.
+- Set `trigger_command` to a command list to integrate with your own scheduler:
+
+```json
+{
+ "external_gate": {
+ "trigger_command": ["hermes", "cron", "run", "JOB_ID"]
+ }
+}
+```
+
+- Set to `null` or `[]` to explicitly disable the external trigger.
+
+### Dynamic tuning via `config.json`
+
+You can override internal defaults without editing code by adding keys to `~/.coinhunter/config.json`:
+
+```json
+{
+ "external_gate": {
+ "trigger_command": ["hermes", "cron", "run", "JOB_ID"]
+ },
+ "exchange": {
+ "min_quote_volume": 200000,
+ "cache_ttl_seconds": 3600
+ },
+ "logging": {
+ "schema_version": 2
+ }
+}
+```
+
+| Key | Default | Effect |
+|-----|---------|--------|
+| `exchange.min_quote_volume` | `200000` | Minimum 24h quote volume for a symbol to appear in market snapshots |
+| `exchange.cache_ttl_seconds` | `3600` | How long the ccxt exchange instance (and `load_markets()` result) is cached |
+| `logging.schema_version` | `2` | Schema version stamped on every JSONL log entry |
+
---
## Runtime Model
@@ -248,14 +301,17 @@ The codebase is actively maintained and refactored in small, safe steps.
- ✅ Extracted `smart-executor` into `commands/` + `services/`
- ✅ Extracted `precheck` into 9 focused service modules
- ✅ Migrated all active command modules into `commands/`
+- ✅ Extracted `review_engine.py` core logic into `services/review_service.py`
+- ✅ Removed eager `PATHS` instantiation across services and commands
+- ✅ Fixed `smart_executor.py` lazy-loading facade
+- ✅ Standardized install to use `pip install -e .`
+- ✅ Made `external_gate` trigger_command configurable (no longer hardcodes hermes)
- ✅ Removed dead `auto-trader` command
- ✅ Backward-compatible root facades preserved
**Next priorities:**
-- 🧪 Add pytest coverage for runtime paths, state manager, and trigger analyzer
-- 🔧 Extract `review_engine.py` core logic into `services/`
-- 🔧 Unify output contract (JSON-first with `--pretty` option)
- 🔧 Add basic CI (lint + compileall + pytest)
+- 🔧 Unify output contract (JSON-first with `--pretty` option)
---
diff --git a/pyproject.toml b/pyproject.toml
index d99e62e..05dda85 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,10 +3,11 @@ requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
-name = "coinhunter-cli"
-version = "0.1.0"
+name = "coinhunter"
+version = "1.0.0"
description = "CoinHunter trading CLI with user runtime data in ~/.coinhunter"
readme = "README.md"
+license = {text = "MIT"}
requires-python = ">=3.10"
dependencies = [
"ccxt>=4.4.0"
@@ -15,6 +16,9 @@ authors = [
{name = "Tacit Lab", email = "ouyangcarlos@gmail.com"}
]
+[project.optional-dependencies]
+dev = ["pytest>=8.0", "ruff>=0.4.0", "mypy>=1.0"]
+
[project.scripts]
coinhunter = "coinhunter.cli:main"
@@ -23,3 +27,17 @@ package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
+
+[tool.ruff]
+line-length = 120
+target-version = "py310"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I"]
+ignore = ["E501"]
+
+[tool.mypy]
+python_version = "3.10"
+warn_return_any = true
+warn_unused_configs = true
+ignore_missing_imports = true
diff --git a/scripts/install_local.sh b/scripts/install_local.sh
index 84de9dc..8d57ba0 100755
--- a/scripts/install_local.sh
+++ b/scripts/install_local.sh
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
-APP_HOME="${COINHUNTER_APP_HOME:-$HOME/.local/share/coinhunter-cli}"
-VENV_DIR="$APP_HOME/venv"
+# Standard local install using pip editable mode.
+# This creates the 'coinhunter' console script entry point as defined in pyproject.toml.
+
BIN_DIR="${COINHUNTER_BIN_DIR:-$HOME/.local/bin}"
-LAUNCHER="$BIN_DIR/coinhunter"
PYTHON_BIN="${PYTHON:-}"
if [[ -z "$PYTHON_BIN" ]]; then
@@ -18,18 +18,13 @@ if [[ -z "$PYTHON_BIN" ]]; then
fi
fi
-mkdir -p "$APP_HOME" "$BIN_DIR"
+mkdir -p "$BIN_DIR"
-"$PYTHON_BIN" -m venv "$VENV_DIR"
-"$VENV_DIR/bin/python" -m pip install --upgrade pip setuptools wheel
-"$VENV_DIR/bin/python" -m pip install --upgrade "$(pwd)"
+"$PYTHON_BIN" -m pip install --upgrade pip setuptools wheel
+"$PYTHON_BIN" -m pip install --upgrade -e "$(pwd)[dev]"
-cat >"$LAUNCHER" < argparse.ArgumentParser:
" coinhunter exec overview\n"
" coinhunter exec hold\n"
" coinhunter exec --analysis '...' --reasoning '...' buy ENJUSDT 50\n"
+ " coinhunter exec orders\n"
+ " coinhunter exec order-status ENJUSDT 123456\n"
+ " coinhunter exec cancel ENJUSDT 123456\n"
" coinhunter pre\n"
- " coinhunter pre --ack '分析完成:HOLD'\n"
+ " coinhunter pre --ack 'Analysis complete: HOLD'\n"
" coinhunter gate\n"
" coinhunter review 12\n"
" coinhunter recap 12\n"
" coinhunter probe bybit-ticker BTCUSDT\n"
"\n"
- "Preferred exec verbs are bal, overview, hold, buy, flat, and rotate.\n"
+ "Preferred exec verbs are bal, overview, hold, buy, flat, rotate, orders, order-status, and cancel.\n"
"Legacy command names remain supported for backward compatibility.\n"
),
)
diff --git a/src/coinhunter/commands/check_api.py b/src/coinhunter/commands/check_api.py
index 8f1e533..498d47e 100755
--- a/src/coinhunter/commands/check_api.py
+++ b/src/coinhunter/commands/check_api.py
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
-"""检查自动交易的环境配置是否就绪"""
+"""Check whether the trading environment is ready and API permissions are sufficient."""
+import json
import os
+import ccxt
+
from ..runtime import load_env_file
@@ -12,14 +15,41 @@ def main():
secret = os.getenv("BINANCE_API_SECRET", "")
if not api_key or api_key.startswith("***") or api_key.startswith("your_"):
- print("❌ 未配置 BINANCE_API_KEY")
+ print(json.dumps({"ok": False, "error": "BINANCE_API_KEY not configured"}, ensure_ascii=False))
return 1
if not secret or secret.startswith("***") or secret.startswith("your_"):
- print("❌ 未配置 BINANCE_API_SECRET")
+ print(json.dumps({"ok": False, "error": "BINANCE_API_SECRET not configured"}, ensure_ascii=False))
return 1
- print("✅ API 配置正常")
- return 0
+ try:
+ ex = ccxt.binance({
+ "apiKey": api_key,
+ "secret": secret,
+ "options": {"defaultType": "spot"},
+ "enableRateLimit": True,
+ })
+ balance = ex.fetch_balance()
+ except Exception as e:
+ print(json.dumps({"ok": False, "error": f"Failed to connect or fetch balance: {e}"}, ensure_ascii=False))
+ return 1
+
+ read_permission = bool(balance and isinstance(balance, dict))
+
+ spot_trading_enabled = None
+ try:
+ restrictions = ex.sapi_get_account_api_restrictions()
+ spot_trading_enabled = restrictions.get("enableSpotAndMarginTrading") or restrictions.get("enableSpotTrading")
+ except Exception:
+ pass
+
+ report = {
+ "ok": read_permission,
+ "read_permission": read_permission,
+ "spot_trading_enabled": spot_trading_enabled,
+ "note": "spot_trading_enabled may be null if the key lacks permission to query restrictions; it does not necessarily mean trading is disabled.",
+ }
+ print(json.dumps(report, ensure_ascii=False, indent=2))
+ return 0 if read_permission else 1
if __name__ == "__main__":
diff --git a/src/coinhunter/commands/doctor.py b/src/coinhunter/commands/doctor.py
index ce1d220..fb377d4 100644
--- a/src/coinhunter/commands/doctor.py
+++ b/src/coinhunter/commands/doctor.py
@@ -10,7 +10,6 @@ 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"]
diff --git a/src/coinhunter/commands/external_gate.py b/src/coinhunter/commands/external_gate.py
index 154f099..2485477 100755
--- a/src/coinhunter/commands/external_gate.py
+++ b/src/coinhunter/commands/external_gate.py
@@ -5,13 +5,14 @@ import subprocess
import sys
from datetime import datetime, timezone
-from ..runtime import ensure_runtime_dirs, get_runtime_paths, resolve_hermes_executable
+from ..runtime import ensure_runtime_dirs, get_runtime_paths
+
+
+def _paths():
+ return get_runtime_paths()
+
-PATHS = get_runtime_paths()
-STATE_DIR = PATHS.state_dir
-LOCK_FILE = PATHS.external_gate_lock
COINHUNTER_MODULE = [sys.executable, "-m", "coinhunter"]
-TRADE_JOB_ID = "4e6593fff158"
def utc_now():
@@ -19,7 +20,7 @@ def utc_now():
def log(message: str):
- print(f"[{utc_now()}] {message}")
+ print(f"[{utc_now()}] {message}", file=sys.stderr)
def run_cmd(args: list[str]) -> subprocess.CompletedProcess:
@@ -30,51 +31,129 @@ def parse_json_output(text: str) -> dict:
text = (text or "").strip()
if not text:
return {}
- return json.loads(text)
+ return json.loads(text) # type: ignore[no-any-return]
+
+
+def _load_config() -> dict:
+ config_path = _paths().config_file
+ if not config_path.exists():
+ return {}
+ try:
+ return json.loads(config_path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
+ except Exception:
+ return {}
+
+
+def _resolve_trigger_command(paths) -> list[str] | None:
+ config = _load_config()
+ gate_config = config.get("external_gate", {})
+
+ if "trigger_command" not in gate_config:
+ return None
+
+ trigger = gate_config["trigger_command"]
+
+ if trigger is None:
+ return None
+
+ if isinstance(trigger, str):
+ return [trigger]
+
+ if isinstance(trigger, list):
+ if not trigger:
+ return None
+ return [str(item) for item in trigger]
+
+ log(f"warn: unexpected trigger_command type {type(trigger).__name__}; skipping trigger")
+ return None
def main():
- ensure_runtime_dirs(PATHS)
- with open(LOCK_FILE, "w", encoding="utf-8") as lockf:
+ paths = _paths()
+ ensure_runtime_dirs(paths)
+ result = {"ok": False, "triggered": False, "reason": "", "logs": []}
+ lock_file = paths.external_gate_lock
+
+ def append_log(msg: str):
+ log(msg)
+ result["logs"].append(msg)
+
+ with open(lock_file, "w", encoding="utf-8") as lockf:
try:
fcntl.flock(lockf.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
- log("gate already running; skip")
+ append_log("gate already running; skip")
+ result["reason"] = "already_running"
+ print(json.dumps(result, ensure_ascii=False))
return 0
precheck = run_cmd(COINHUNTER_MODULE + ["precheck"])
if precheck.returncode != 0:
- log(f"precheck returned non-zero ({precheck.returncode}); stdout={precheck.stdout.strip()} stderr={precheck.stderr.strip()}")
+ append_log(f"precheck returned non-zero ({precheck.returncode}); stdout={precheck.stdout.strip()} stderr={precheck.stderr.strip()}")
+ result["reason"] = "precheck_failed"
+ print(json.dumps(result, ensure_ascii=False))
return 1
try:
data = parse_json_output(precheck.stdout)
except Exception as e:
- log(f"failed to parse precheck JSON: {e}; raw={precheck.stdout.strip()[:1000]}")
+ append_log(f"failed to parse precheck JSON: {e}; raw={precheck.stdout.strip()[:1000]}")
+ result["reason"] = "precheck_parse_error"
+ print(json.dumps(result, ensure_ascii=False))
+ return 1
+
+ if not data.get("ok"):
+ append_log("precheck reported failure; skip model run")
+ result["reason"] = "precheck_not_ok"
+ print(json.dumps(result, ensure_ascii=False))
return 1
if not data.get("should_analyze"):
- log("no trigger; skip model run")
+ append_log("no trigger; skip model run")
+ result["ok"] = True
+ result["reason"] = "no_trigger"
+ print(json.dumps(result, ensure_ascii=False))
return 0
if data.get("run_requested"):
- log(f"trigger already queued at {data.get('run_requested_at')}; skip duplicate")
+ append_log(f"trigger already queued at {data.get('run_requested_at')}; skip duplicate")
+ result["ok"] = True
+ result["reason"] = "already_queued"
+ print(json.dumps(result, ensure_ascii=False))
return 0
mark = run_cmd(COINHUNTER_MODULE + ["precheck", "--mark-run-requested", "external-gate queued cron run"])
if mark.returncode != 0:
- log(f"failed to mark run requested; stdout={mark.stdout.strip()} stderr={mark.stderr.strip()}")
+ append_log(f"failed to mark run requested; stdout={mark.stdout.strip()} stderr={mark.stderr.strip()}")
+ result["reason"] = "mark_failed"
+ print(json.dumps(result, ensure_ascii=False))
return 1
- trigger = run_cmd([resolve_hermes_executable(PATHS), "cron", "run", TRADE_JOB_ID])
+ trigger_cmd = _resolve_trigger_command(paths)
+ if trigger_cmd is None:
+ append_log("trigger_command is disabled; skipping external trigger")
+ result["ok"] = True
+ result["reason"] = "trigger_disabled"
+ print(json.dumps(result, ensure_ascii=False))
+ return 0
+
+ trigger = run_cmd(trigger_cmd)
if trigger.returncode != 0:
- log(f"failed to trigger trade cron job; stdout={trigger.stdout.strip()} stderr={trigger.stderr.strip()}")
+ append_log(f"failed to trigger trade job; cmd={' '.join(trigger_cmd)}; stdout={trigger.stdout.strip()} stderr={trigger.stderr.strip()}")
+ result["reason"] = "trigger_failed"
+ print(json.dumps(result, ensure_ascii=False))
return 1
reasons = ", ".join(data.get("reasons", [])) or "unknown"
- log(f"queued trade job {TRADE_JOB_ID}; reasons={reasons}")
+ append_log(f"queued trade job via {' '.join(trigger_cmd)}; reasons={reasons}")
if trigger.stdout.strip():
- log(trigger.stdout.strip())
+ append_log(trigger.stdout.strip())
+
+ result["ok"] = True
+ result["triggered"] = True
+ result["reason"] = reasons
+ result["command"] = trigger_cmd
+ print(json.dumps(result, ensure_ascii=False))
return 0
diff --git a/src/coinhunter/commands/init_user_state.py b/src/coinhunter/commands/init_user_state.py
index ca95724..c4a273b 100755
--- a/src/coinhunter/commands/init_user_state.py
+++ b/src/coinhunter/commands/init_user_state.py
@@ -5,9 +5,9 @@ from pathlib import Path
from ..runtime import ensure_runtime_dirs, get_runtime_paths
-PATHS = get_runtime_paths()
-ROOT = PATHS.root
-CACHE_DIR = PATHS.cache_dir
+
+def _paths():
+ return get_runtime_paths()
def now_iso():
@@ -22,30 +22,60 @@ def ensure_file(path: Path, payload: dict):
def main():
- ensure_runtime_dirs(PATHS)
+ paths = _paths()
+ ensure_runtime_dirs(paths)
created = []
ts = now_iso()
templates = {
- ROOT / "config.json": {
+ paths.root / "config.json": {
"default_exchange": "bybit",
"default_quote_currency": "USDT",
"timezone": "Asia/Shanghai",
"preferred_chains": ["solana", "base"],
+ "external_gate": {
+ "trigger_command": None,
+ "_comment": "Set to a command list like ['hermes', 'cron', 'run', 'JOB_ID'] or null to disable"
+ },
+ "trading": {
+ "usdt_buffer_pct": 0.03,
+ "min_remaining_dust_usdt": 1.0,
+ "_comment": "Adjust buffer and dust thresholds for your account size"
+ },
+ "precheck": {
+ "base_price_move_trigger_pct": 0.025,
+ "base_pnl_trigger_pct": 0.03,
+ "base_portfolio_move_trigger_pct": 0.03,
+ "base_candidate_score_trigger_ratio": 1.15,
+ "base_force_analysis_after_minutes": 180,
+ "base_cooldown_minutes": 45,
+ "top_candidates": 10,
+ "min_actionable_usdt": 12.0,
+ "min_real_position_value_usdt": 8.0,
+ "blacklist": ["USDC", "BUSD", "TUSD", "FDUSD", "USTC", "PAXG"],
+ "hard_stop_pct": -0.08,
+ "hard_moon_pct": 0.25,
+ "min_change_pct": 1.0,
+ "max_price_cap": None,
+ "hard_reason_dedup_minutes": 15,
+ "max_pending_trigger_minutes": 30,
+ "max_run_request_minutes": 20,
+ "_comment": "Tune trigger sensitivity without redeploying code"
+ },
"created_at": ts,
"updated_at": ts,
},
- ROOT / "accounts.json": {
+ paths.root / "accounts.json": {
"accounts": []
},
- ROOT / "positions.json": {
+ paths.root / "positions.json": {
"positions": []
},
- ROOT / "watchlist.json": {
+ paths.root / "watchlist.json": {
"watchlist": []
},
- ROOT / "notes.json": {
+ paths.root / "notes.json": {
"notes": []
},
}
@@ -55,9 +85,9 @@ def main():
created.append(str(path))
print(json.dumps({
- "root": str(ROOT),
+ "root": str(paths.root),
"created": created,
- "cache_dir": str(CACHE_DIR),
+ "cache_dir": str(paths.cache_dir),
}, ensure_ascii=False, indent=2))
diff --git a/src/coinhunter/commands/market_probe.py b/src/coinhunter/commands/market_probe.py
index 2e41344..0b8d954 100755
--- a/src/coinhunter/commands/market_probe.py
+++ b/src/coinhunter/commands/market_probe.py
@@ -2,7 +2,6 @@
import argparse
import json
import os
-import sys
import urllib.parse
import urllib.request
diff --git a/src/coinhunter/commands/review_context.py b/src/coinhunter/commands/review_context.py
new file mode 100644
index 0000000..add6528
--- /dev/null
+++ b/src/coinhunter/commands/review_context.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""CLI adapter for review context."""
+
+import json
+import sys
+
+from ..services.review_service import generate_review
+
+
+def main():
+ hours = int(sys.argv[1]) if len(sys.argv) > 1 else 12
+ review = generate_review(hours)
+ compact = {
+ "review_period_hours": review.get("review_period_hours", hours),
+ "review_timestamp": review.get("review_timestamp"),
+ "total_decisions": review.get("total_decisions", 0),
+ "total_trades": review.get("total_trades", 0),
+ "total_errors": review.get("total_errors", 0),
+ "stats": review.get("stats", {}),
+ "insights": review.get("insights", []),
+ "recommendations": review.get("recommendations", []),
+ "decision_quality_top": review.get("decision_quality", [])[:5],
+ "should_report": bool(
+ review.get("total_decisions", 0)
+ or review.get("total_trades", 0)
+ or review.get("total_errors", 0)
+ or review.get("insights")
+ ),
+ }
+ print(json.dumps(compact, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/coinhunter/commands/review_engine.py b/src/coinhunter/commands/review_engine.py
new file mode 100644
index 0000000..b4da85d
--- /dev/null
+++ b/src/coinhunter/commands/review_engine.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+"""CLI adapter for review engine."""
+
+import json
+import sys
+
+from ..services.review_service import generate_review, save_review
+
+
+def main():
+ try:
+ hours = int(sys.argv[1]) if len(sys.argv) > 1 else 1
+ review = generate_review(hours)
+ path = save_review(review)
+ print(json.dumps({"ok": True, "saved_path": path, "review": review}, ensure_ascii=False, indent=2))
+ except Exception as e:
+ from ..logger import log_error
+ log_error("review_engine", e)
+ print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False), file=sys.stderr)
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/coinhunter/commands/rotate_external_gate_log.py b/src/coinhunter/commands/rotate_external_gate_log.py
index 335fb5f..82e5ab6 100755
--- a/src/coinhunter/commands/rotate_external_gate_log.py
+++ b/src/coinhunter/commands/rotate_external_gate_log.py
@@ -1,27 +1,30 @@
#!/usr/bin/env python3
"""Rotate external gate log using the user's logrotate config/state."""
+import json
import shutil
import subprocess
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 _paths():
+ return get_runtime_paths()
def main():
- ensure_runtime_dirs(PATHS)
+ paths = _paths()
+ ensure_runtime_dirs(paths)
logrotate_bin = shutil.which("logrotate") or "/usr/sbin/logrotate"
- cmd = [logrotate_bin, "-s", str(LOGROTATE_STATUS), str(LOGROTATE_CONF)]
+ cmd = [logrotate_bin, "-s", str(paths.logrotate_status), str(paths.logrotate_config)]
result = subprocess.run(cmd, capture_output=True, text=True)
- if result.stdout.strip():
- print(result.stdout.strip())
- if result.stderr.strip():
- print(result.stderr.strip())
- return result.returncode
+ output = {
+ "ok": result.returncode == 0,
+ "returncode": result.returncode,
+ "stdout": result.stdout.strip(),
+ "stderr": result.stderr.strip(),
+ }
+ print(json.dumps(output, ensure_ascii=False, indent=2))
+ return 0 if result.returncode == 0 else 1
if __name__ == "__main__":
diff --git a/src/coinhunter/logger.py b/src/coinhunter/logger.py
index eaf2485..62893fc 100755
--- a/src/coinhunter/logger.py
+++ b/src/coinhunter/logger.py
@@ -2,28 +2,41 @@
"""Coin Hunter structured logger."""
import json
import traceback
-from datetime import datetime, timezone, timedelta
-from .runtime import get_runtime_paths
+__all__ = [
+ "SCHEMA_VERSION",
+ "log_decision",
+ "log_trade",
+ "log_snapshot",
+ "log_error",
+ "get_logs_by_date",
+ "get_logs_last_n_hours",
+]
+from datetime import datetime, timedelta, timezone
-LOG_DIR = get_runtime_paths().logs_dir
-SCHEMA_VERSION = 2
+from .runtime import get_runtime_paths, get_user_config
+
+SCHEMA_VERSION = get_user_config("logging.schema_version", 2)
CST = timezone(timedelta(hours=8))
+def _log_dir():
+ return get_runtime_paths().logs_dir
+
+
def bj_now():
return datetime.now(CST)
def ensure_dir():
- LOG_DIR.mkdir(parents=True, exist_ok=True)
+ _log_dir().mkdir(parents=True, exist_ok=True)
def _append_jsonl(prefix: str, payload: dict):
ensure_dir()
date_str = bj_now().strftime("%Y%m%d")
- log_file = LOG_DIR / f"{prefix}_{date_str}.jsonl"
+ log_file = _log_dir() / f"{prefix}_{date_str}.jsonl"
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
@@ -42,8 +55,8 @@ def log_decision(data: dict):
return log_event("decisions", data)
-def log_trade(action: str, symbol: str, qty: float = None, amount_usdt: float = None,
- price: float = None, note: str = "", **extra):
+def log_trade(action: str, symbol: str, qty: float | None = None, amount_usdt: float | None = None,
+ price: float | None = None, note: str = "", **extra):
payload = {
"action": action,
"symbol": symbol,
@@ -71,10 +84,10 @@ def log_error(where: str, error: Exception | str, **extra):
return log_event("errors", payload)
-def get_logs_by_date(log_type: str, date_str: str = None) -> list:
+def get_logs_by_date(log_type: str, date_str: str | None = None) -> list:
if date_str is None:
date_str = bj_now().strftime("%Y%m%d")
- log_file = LOG_DIR / f"{log_type}_{date_str}.jsonl"
+ log_file = _log_dir() / f"{log_type}_{date_str}.jsonl"
if not log_file.exists():
return []
entries = []
diff --git a/src/coinhunter/precheck.py b/src/coinhunter/precheck.py
index e408fc0..b8aaa68 100644
--- a/src/coinhunter/precheck.py
+++ b/src/coinhunter/precheck.py
@@ -13,13 +13,6 @@ from importlib import import_module
from .services.precheck_service import run as _run_service
_CORE_EXPORTS = {
- "PATHS",
- "BASE_DIR",
- "STATE_DIR",
- "STATE_FILE",
- "POSITIONS_FILE",
- "CONFIG_FILE",
- "ENV_FILE",
"BASE_PRICE_MOVE_TRIGGER_PCT",
"BASE_PNL_TRIGGER_PCT",
"BASE_PORTFOLIO_MOVE_TRIGGER_PCT",
@@ -64,14 +57,28 @@ _CORE_EXPORTS = {
"analyze_trigger",
"update_state_after_observation",
}
+
+# Path-related exports are now lazy in precheck_core
+_PATH_EXPORTS = {
+ "PATHS",
+ "BASE_DIR",
+ "STATE_DIR",
+ "STATE_FILE",
+ "POSITIONS_FILE",
+ "CONFIG_FILE",
+ "ENV_FILE",
+}
+
_STATE_EXPORTS = {"mark_run_requested", "ack_analysis"}
-__all__ = sorted(_CORE_EXPORTS | _STATE_EXPORTS | {"main"})
+__all__ = sorted(_CORE_EXPORTS | _PATH_EXPORTS | _STATE_EXPORTS | {"main"})
def __getattr__(name: str):
if name in _CORE_EXPORTS:
return getattr(import_module(".services.precheck_core", __package__), name)
+ if name in _PATH_EXPORTS:
+ return getattr(import_module(".services.precheck_core", __package__), name)
if name in _STATE_EXPORTS:
return getattr(import_module(".services.precheck_state", __package__), name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/coinhunter/review_context.py b/src/coinhunter/review_context.py
index 8a605be..5c414fd 100755
--- a/src/coinhunter/review_context.py
+++ b/src/coinhunter/review_context.py
@@ -1,32 +1,12 @@
#!/usr/bin/env python3
-import json
-import sys
+"""Backward-compatible facade for review context.
-from . import review_engine
+The executable implementation lives in ``coinhunter.commands.review_context``.
+"""
+from __future__ import annotations
-def main():
- hours = int(sys.argv[1]) if len(sys.argv) > 1 else 12
- review = review_engine.generate_review(hours)
- compact = {
- "review_period_hours": review.get("review_period_hours", hours),
- "review_timestamp": review.get("review_timestamp"),
- "total_decisions": review.get("total_decisions", 0),
- "total_trades": review.get("total_trades", 0),
- "total_errors": review.get("total_errors", 0),
- "stats": review.get("stats", {}),
- "insights": review.get("insights", []),
- "recommendations": review.get("recommendations", []),
- "decision_quality_top": review.get("decision_quality", [])[:5],
- "should_report": bool(
- review.get("total_decisions", 0)
- or review.get("total_trades", 0)
- or review.get("total_errors", 0)
- or review.get("insights")
- ),
- }
- print(json.dumps(compact, ensure_ascii=False, indent=2))
-
+from .commands.review_context import main
if __name__ == "__main__":
main()
diff --git a/src/coinhunter/review_engine.py b/src/coinhunter/review_engine.py
index e305634..b61fc6c 100755
--- a/src/coinhunter/review_engine.py
+++ b/src/coinhunter/review_engine.py
@@ -1,312 +1,48 @@
#!/usr/bin/env python3
-"""Coin Hunter hourly review engine."""
-import json
-import os
-import sys
-from datetime import datetime, timezone, timedelta
-from pathlib import Path
+"""Backward-compatible facade for review engine.
-import ccxt
+The executable implementation lives in ``coinhunter.commands.review_engine``.
+Core logic is in ``coinhunter.services.review_service``.
+"""
-from .logger import get_logs_last_n_hours, log_error
-from .runtime import get_runtime_paths, load_env_file
+from __future__ import annotations
-PATHS = get_runtime_paths()
-ENV_FILE = PATHS.env_file
-REVIEW_DIR = PATHS.reviews_dir
+from importlib import import_module
-CST = timezone(timedelta(hours=8))
+# Re-export service functions for backward compatibility
+_EXPORT_MAP = {
+ "load_env": (".services.review_service", "load_env"),
+ "get_exchange": (".services.review_service", "get_exchange"),
+ "ensure_review_dir": (".services.review_service", "ensure_review_dir"),
+ "norm_symbol": (".services.review_service", "norm_symbol"),
+ "fetch_current_price": (".services.review_service", "fetch_current_price"),
+ "analyze_trade": (".services.review_service", "analyze_trade"),
+ "analyze_hold_passes": (".services.review_service", "analyze_hold_passes"),
+ "analyze_cash_misses": (".services.review_service", "analyze_cash_misses"),
+ "generate_review": (".services.review_service", "generate_review"),
+ "save_review": (".services.review_service", "save_review"),
+ "print_review": (".services.review_service", "print_review"),
+}
+
+__all__ = sorted(set(_EXPORT_MAP) | {"main"})
-def load_env():
- load_env_file(PATHS)
+def __getattr__(name: str):
+ if name not in _EXPORT_MAP:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ module_name, attr_name = _EXPORT_MAP[name]
+ module = import_module(module_name, __package__)
+ return getattr(module, attr_name)
-def get_exchange():
- load_env()
- ex = ccxt.binance({
- "apiKey": os.getenv("BINANCE_API_KEY"),
- "secret": os.getenv("BINANCE_API_SECRET"),
- "options": {"defaultType": "spot"},
- "enableRateLimit": True,
- })
- ex.load_markets()
- return ex
-
-
-def ensure_review_dir():
- REVIEW_DIR.mkdir(parents=True, exist_ok=True)
-
-
-def norm_symbol(symbol: str) -> str:
- s = symbol.upper().replace("-", "").replace("_", "")
- if "/" in s:
- return s
- if s.endswith("USDT"):
- return s[:-4] + "/USDT"
- return s
-
-
-def fetch_current_price(ex, symbol: str):
- try:
- return float(ex.fetch_ticker(norm_symbol(symbol))["last"])
- except Exception:
- return None
-
-
-def analyze_trade(trade: dict, ex) -> dict:
- symbol = trade.get("symbol")
- price = trade.get("price")
- action = trade.get("action", "")
- current_price = fetch_current_price(ex, symbol) if symbol else None
- pnl_estimate = None
- outcome = "neutral"
- if price and current_price and symbol:
- change_pct = (current_price - float(price)) / float(price) * 100
- if action == "BUY":
- pnl_estimate = round(change_pct, 2)
- outcome = "good" if change_pct > 2 else "bad" if change_pct < -2 else "neutral"
- elif action == "SELL_ALL":
- pnl_estimate = round(-change_pct, 2)
- # Lowered missed threshold: >2% is a missed opportunity in short-term trading
- outcome = "good" if change_pct < -2 else "missed" if change_pct > 2 else "neutral"
- return {
- "timestamp": trade.get("timestamp"),
- "symbol": symbol,
- "action": action,
- "decision_id": trade.get("decision_id"),
- "execution_price": price,
- "current_price": current_price,
- "pnl_estimate_pct": pnl_estimate,
- "outcome_assessment": outcome,
- }
-
-
-def analyze_hold_passes(decisions: list, ex) -> list:
- """Check HOLD decisions where an opportunity was explicitly PASSed but later rallied."""
- misses = []
- for d in decisions:
- if d.get("decision") != "HOLD":
- continue
- analysis = d.get("analysis")
- if not isinstance(analysis, dict):
- continue
- opportunities = analysis.get("opportunities_evaluated", [])
- market_snapshot = d.get("market_snapshot", {})
- if not opportunities or not market_snapshot:
- continue
- for op in opportunities:
- verdict = op.get("verdict", "")
- if "PASS" not in verdict and "pass" not in verdict:
- continue
- symbol = op.get("symbol", "")
- # Try to extract decision-time price from market_snapshot
- snap = market_snapshot.get(symbol) or market_snapshot.get(symbol.replace("/", ""))
- if not snap:
- continue
- decision_price = None
- if isinstance(snap, dict):
- decision_price = float(snap.get("lastPrice", 0)) or float(snap.get("last", 0))
- elif isinstance(snap, (int, float, str)):
- decision_price = float(snap)
- if not decision_price:
- continue
- current_price = fetch_current_price(ex, symbol)
- if not current_price:
- continue
- change_pct = (current_price - decision_price) / decision_price * 100
- if change_pct > 3: # >3% rally after being passed = missed watch
- misses.append({
- "timestamp": d.get("timestamp"),
- "symbol": symbol,
- "decision_price": round(decision_price, 8),
- "current_price": round(current_price, 8),
- "change_pct": round(change_pct, 2),
- "verdict_snippet": verdict[:80],
- })
- return misses
-
-
-def analyze_cash_misses(decisions: list, ex) -> list:
- """If portfolio was mostly USDT but a watchlist coin rallied >5%, flag it."""
- misses = []
- watchlist = set()
- for d in decisions:
- snap = d.get("market_snapshot", {})
- if isinstance(snap, dict):
- for k in snap.keys():
- if k.endswith("USDT"):
- watchlist.add(k)
- for d in decisions:
- ts = d.get("timestamp")
- balances = d.get("balances") or d.get("balances_before", {})
- if not balances:
- continue
- total = sum(float(v) if isinstance(v, (int, float, str)) else 0 for v in balances.values())
- usdt = float(balances.get("USDT", 0))
- if total == 0 or (usdt / total) < 0.9:
- continue
- # Portfolio mostly cash — check watchlist performance
- snap = d.get("market_snapshot", {})
- if not isinstance(snap, dict):
- continue
- for symbol, data in snap.items():
- if not symbol.endswith("USDT"):
- continue
- decision_price = None
- if isinstance(data, dict):
- decision_price = float(data.get("lastPrice", 0)) or float(data.get("last", 0))
- elif isinstance(data, (int, float, str)):
- decision_price = float(data)
- if not decision_price:
- continue
- current_price = fetch_current_price(ex, symbol)
- if not current_price:
- continue
- change_pct = (current_price - decision_price) / decision_price * 100
- if change_pct > 5:
- misses.append({
- "timestamp": ts,
- "symbol": symbol,
- "decision_price": round(decision_price, 8),
- "current_price": round(current_price, 8),
- "change_pct": round(change_pct, 2),
- })
- # Deduplicate by symbol keeping the worst miss
- seen = {}
- for m in misses:
- sym = m["symbol"]
- if sym not in seen or m["change_pct"] > seen[sym]["change_pct"]:
- seen[sym] = m
- return list(seen.values())
-
-
-def generate_review(hours: int = 1) -> dict:
- decisions = get_logs_last_n_hours("decisions", hours)
- trades = get_logs_last_n_hours("trades", hours)
- errors = get_logs_last_n_hours("errors", hours)
-
- review = {
- "review_period_hours": hours,
- "review_timestamp": datetime.now(CST).isoformat(),
- "total_decisions": len(decisions),
- "total_trades": len(trades),
- "total_errors": len(errors),
- "decision_quality": [],
- "stats": {},
- "insights": [],
- "recommendations": [],
- }
-
- if not decisions and not trades:
- review["insights"].append("本周期无决策/交易记录")
- return review
-
- ex = get_exchange()
- outcomes = {"good": 0, "neutral": 0, "bad": 0, "missed": 0}
- pnl_samples = []
-
- for trade in trades:
- analysis = analyze_trade(trade, ex)
- review["decision_quality"].append(analysis)
- outcomes[analysis["outcome_assessment"]] += 1
- if analysis["pnl_estimate_pct"] is not None:
- pnl_samples.append(analysis["pnl_estimate_pct"])
-
- # New: analyze missed opportunities from HOLD / cash decisions
- hold_pass_misses = analyze_hold_passes(decisions, ex)
- cash_misses = analyze_cash_misses(decisions, ex)
- total_missed = outcomes["missed"] + len(hold_pass_misses) + len(cash_misses)
-
- review["stats"] = {
- "good_decisions": outcomes["good"],
- "neutral_decisions": outcomes["neutral"],
- "bad_decisions": outcomes["bad"],
- "missed_opportunities": total_missed,
- "missed_sell_all": outcomes["missed"],
- "missed_hold_passes": len(hold_pass_misses),
- "missed_cash_sits": len(cash_misses),
- "avg_estimated_edge_pct": round(sum(pnl_samples) / len(pnl_samples), 2) if pnl_samples else None,
- }
-
- if errors:
- review["insights"].append(f"本周期出现 {len(errors)} 次执行/系统错误,健壮性需优先关注")
- if outcomes["bad"] > outcomes["good"]:
- review["insights"].append("最近交易质量偏弱,建议降低交易频率或提高入场门槛")
- if total_missed > 0:
- parts = []
- if outcomes["missed"]:
- parts.append(f"卖出后继续上涨 {outcomes['missed']} 次")
- if hold_pass_misses:
- parts.append(f"PASS 后错失 {len(hold_pass_misses)} 次")
- if cash_misses:
- parts.append(f"空仓观望错失 {len(cash_misses)} 次")
- review["insights"].append("存在错失机会: " + ",".join(parts) + ",建议放宽趋势跟随或入场条件")
- if outcomes["good"] >= max(1, outcomes["bad"] + total_missed):
- review["insights"].append("近期决策总体可接受")
- if not trades and decisions:
- review["insights"].append("有决策无成交,可能是观望、最小成交额限制或执行被拦截")
- if len(trades) < len(decisions) * 0.1 and decisions:
- review["insights"].append("大量决策未转化为交易,需检查执行门槛(最小成交额/精度/手续费缓冲)是否过高")
- if hold_pass_misses:
- for m in hold_pass_misses[:3]:
- review["insights"].append(f"HOLD 时 PASS 了 {m['symbol']},之后上涨 {m['change_pct']}%")
- if cash_misses:
- for m in cash_misses[:3]:
- review["insights"].append(f"持仓以 USDT 为主时 {m['symbol']} 上涨 {m['change_pct']}%")
-
- review["recommendations"] = [
- "优先检查最小成交额/精度拒单是否影响小资金执行",
- "若连续两个复盘周期 edge 为负,下一小时减少换仓频率",
- "若错误日志增加,优先进入防守模式(多持 USDT)",
- ]
- return review
-
-
-def save_review(review: dict):
- ensure_review_dir()
- ts = datetime.now(CST).strftime("%Y%m%d_%H%M%S")
- path = REVIEW_DIR / f"review_{ts}.json"
- path.write_text(json.dumps(review, indent=2, ensure_ascii=False), encoding="utf-8")
- return str(path)
-
-
-def print_review(review: dict):
- print("=" * 50)
- print("📊 Coin Hunter 小时复盘报告")
- print(f"复盘时间: {review['review_timestamp']}")
- print(f"统计周期: 过去 {review['review_period_hours']} 小时")
- print(f"总决策数: {review['total_decisions']} | 总交易数: {review['total_trades']} | 总错误数: {review['total_errors']}")
- stats = review.get("stats", {})
- print("\n决策质量统计:")
- print(f" ✓ 优秀: {stats.get('good_decisions', 0)}")
- print(f" ○ 中性: {stats.get('neutral_decisions', 0)}")
- print(f" ✗ 失误: {stats.get('bad_decisions', 0)}")
- print(f" ↗ 错过机会: {stats.get('missed_opportunities', 0)}")
- if stats.get("avg_estimated_edge_pct") is not None:
- print(f" 平均估计 edge: {stats['avg_estimated_edge_pct']}%")
- if review.get("insights"):
- print("\n💡 见解:")
- for item in review["insights"]:
- print(f" • {item}")
- if review.get("recommendations"):
- print("\n🔧 优化建议:")
- for item in review["recommendations"]:
- print(f" • {item}")
- print("=" * 50)
+def __dir__():
+ return sorted(set(globals()) | set(__all__))
def main():
- try:
- hours = int(sys.argv[1]) if len(sys.argv) > 1 else 1
- review = generate_review(hours)
- path = save_review(review)
- print_review(review)
- print(f"复盘已保存至: {path}")
- except Exception as e:
- log_error("review_engine", e)
- raise
+ from .commands.review_engine import main as _main
+ return _main()
if __name__ == "__main__":
- main()
+ raise SystemExit(main())
diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py
index dbe0fab..60e7a3f 100644
--- a/src/coinhunter/runtime.py
+++ b/src/coinhunter/runtime.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
import os
import shutil
from dataclasses import asdict, dataclass
@@ -24,6 +25,7 @@ class RuntimePaths:
positions_lock: Path
executions_lock: Path
precheck_state_file: Path
+ precheck_state_lock: Path
external_gate_lock: Path
logrotate_config: Path
logrotate_status: Path
@@ -64,6 +66,7 @@ def get_runtime_paths() -> RuntimePaths:
positions_lock=root / "positions.lock",
executions_lock=root / "executions.lock",
precheck_state_file=state_dir / "precheck_state.json",
+ precheck_state_lock=state_dir / "precheck_state.lock",
external_gate_lock=state_dir / "external_gate.lock",
logrotate_config=root / "logrotate_external_gate.conf",
logrotate_status=state_dir / "logrotate_external_gate.status",
@@ -105,3 +108,20 @@ def mask_secret(value: str | None, *, tail: int = 4) -> str:
if len(value) <= tail:
return "*" * len(value)
return "*" * max(4, len(value) - tail) + value[-tail:]
+
+
+def get_user_config(key: str, default=None):
+ """Read a dotted key from the user config file."""
+ paths = get_runtime_paths()
+ try:
+ config = json.loads(paths.config_file.read_text(encoding="utf-8"))
+ except Exception:
+ return default
+ for part in key.split("."):
+ if isinstance(config, dict):
+ config = config.get(part)
+ if config is None:
+ return default
+ else:
+ return default
+ return config if config is not None else default
diff --git a/src/coinhunter/services/adaptive_profile.py b/src/coinhunter/services/adaptive_profile.py
index 8b49b4a..71319d6 100644
--- a/src/coinhunter/services/adaptive_profile.py
+++ b/src/coinhunter/services/adaptive_profile.py
@@ -31,7 +31,7 @@ def build_adaptive_profile(snapshot: dict):
dust_mode = free_usdt < MIN_ACTIONABLE_USDT and largest_position_value < MIN_REAL_POSITION_VALUE_USDT
price_trigger = BASE_PRICE_MOVE_TRIGGER_PCT
- pnl_trigger = BASE_PN_L_TRIGGER_PCT
+ pnl_trigger = BASE_PNL_TRIGGER_PCT
portfolio_trigger = BASE_PORTFOLIO_MOVE_TRIGGER_PCT
candidate_ratio = BASE_CANDIDATE_SCORE_TRIGGER_RATIO
force_minutes = BASE_FORCE_ANALYSIS_AFTER_MINUTES
diff --git a/src/coinhunter/services/candidate_scoring.py b/src/coinhunter/services/candidate_scoring.py
index 71b2dfb..da41a32 100644
--- a/src/coinhunter/services/candidate_scoring.py
+++ b/src/coinhunter/services/candidate_scoring.py
@@ -63,7 +63,7 @@ def top_candidates_from_tickers(tickers: dict):
})
candidates.sort(key=lambda x: x["score"], reverse=True)
global_top = candidates[:TOP_CANDIDATES]
- layers = {"major": [], "mid": [], "meme": []}
+ layers: dict[str, list[dict]] = {"major": [], "mid": [], "meme": []}
for c in candidates:
layers[c["band"]].append(c)
for k in layers:
diff --git a/src/coinhunter/services/exchange_service.py b/src/coinhunter/services/exchange_service.py
index 5f916c6..59370d1 100644
--- a/src/coinhunter/services/exchange_service.py
+++ b/src/coinhunter/services/exchange_service.py
@@ -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
diff --git a/src/coinhunter/services/execution_state.py b/src/coinhunter/services/execution_state.py
index 3a34a1a..860a4ff 100644
--- a/src/coinhunter/services/execution_state.py
+++ b/src/coinhunter/services/execution_state.py
@@ -1,17 +1,25 @@
"""Execution state helpers (decision deduplication, executions.json)."""
import hashlib
-from ..runtime import get_runtime_paths
-from .file_utils import load_json_locked, save_json_locked
-from .trade_common import bj_now_iso
+__all__ = [
+ "default_decision_id",
+ "load_executions",
+ "save_executions",
+ "record_execution_state",
+ "get_execution_state",
+]
-PATHS = get_runtime_paths()
-EXECUTIONS_FILE = PATHS.executions_file
-EXECUTIONS_LOCK = PATHS.executions_lock
+from ..runtime import get_runtime_paths
+from .file_utils import load_json_locked, read_modify_write_json, save_json_locked
+
+
+def _paths():
+ return get_runtime_paths()
def default_decision_id(action: str, argv_tail: list[str]) -> str:
from datetime import datetime
+
from .trade_common import CST
now = datetime.now(CST)
@@ -22,17 +30,24 @@ def default_decision_id(action: str, argv_tail: list[str]) -> str:
def load_executions() -> dict:
- return load_json_locked(EXECUTIONS_FILE, EXECUTIONS_LOCK, {"executions": {}}).get("executions", {})
+ paths = _paths()
+ data = load_json_locked(paths.executions_file, paths.executions_lock, {"executions": {}})
+ return data.get("executions", {}) # type: ignore[no-any-return]
def save_executions(executions: dict):
- save_json_locked(EXECUTIONS_FILE, EXECUTIONS_LOCK, {"executions": executions})
+ paths = _paths()
+ save_json_locked(paths.executions_file, paths.executions_lock, {"executions": executions})
def record_execution_state(decision_id: str, payload: dict):
- executions = load_executions()
- executions[decision_id] = payload
- save_executions(executions)
+ paths = _paths()
+ read_modify_write_json(
+ paths.executions_file,
+ paths.executions_lock,
+ {"executions": {}},
+ lambda data: data.setdefault("executions", {}).__setitem__(decision_id, payload) or data,
+ )
def get_execution_state(decision_id: str):
diff --git a/src/coinhunter/services/file_utils.py b/src/coinhunter/services/file_utils.py
index 661332f..6d277a4 100644
--- a/src/coinhunter/services/file_utils.py
+++ b/src/coinhunter/services/file_utils.py
@@ -2,6 +2,14 @@
import fcntl
import json
import os
+
+__all__ = [
+ "locked_file",
+ "atomic_write_json",
+ "load_json_locked",
+ "save_json_locked",
+ "read_modify_write_json",
+]
from contextlib import contextmanager
from pathlib import Path
@@ -9,13 +17,22 @@ from pathlib import Path
@contextmanager
def locked_file(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
- with open(path, "a+", encoding="utf-8") as f:
- fcntl.flock(f.fileno(), fcntl.LOCK_EX)
- f.seek(0)
- yield f
- f.flush()
- os.fsync(f.fileno())
- fcntl.flock(f.fileno(), fcntl.LOCK_UN)
+ fd = None
+ try:
+ fd = os.open(path, os.O_RDWR | os.O_CREAT)
+ fcntl.flock(fd, fcntl.LOCK_EX)
+ yield fd
+ finally:
+ if fd is not None:
+ try:
+ os.fsync(fd)
+ except Exception:
+ pass
+ try:
+ fcntl.flock(fd, fcntl.LOCK_UN)
+ except Exception:
+ pass
+ os.close(fd)
def atomic_write_json(path: Path, data: dict):
@@ -38,3 +55,22 @@ def load_json_locked(path: Path, lock_path: Path, default):
def save_json_locked(path: Path, lock_path: Path, data: dict):
with locked_file(lock_path):
atomic_write_json(path, data)
+
+
+def read_modify_write_json(path: Path, lock_path: Path, default, modifier):
+ """Atomic read-modify-write under a single file lock.
+
+ Loads JSON from *path* (or uses *default* if missing/invalid),
+ calls ``modifier(data)``, then atomically writes the result back.
+ If *modifier* returns None, the mutated *data* is written.
+ """
+ with locked_file(lock_path):
+ if path.exists():
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ data = default
+ else:
+ data = default
+ result = modifier(data)
+ atomic_write_json(path, result if result is not None else data)
diff --git a/src/coinhunter/services/market_data.py b/src/coinhunter/services/market_data.py
index 5dc2886..3ad2cc1 100644
--- a/src/coinhunter/services/market_data.py
+++ b/src/coinhunter/services/market_data.py
@@ -7,15 +7,12 @@ import os
import ccxt
from .data_utils import norm_symbol, to_float
-from .precheck_constants import BLACKLIST, MAX_PRICE_CAP, MIN_CHANGE_PCT
-from .time_utils import utc_now
def get_exchange():
from ..runtime import load_env_file
- from .precheck_constants import ENV_FILE
- load_env_file(ENV_FILE)
+ load_env_file()
api_key = os.getenv("BINANCE_API_KEY")
secret = os.getenv("BINANCE_API_SECRET")
if not api_key or not secret:
diff --git a/src/coinhunter/services/portfolio_service.py b/src/coinhunter/services/portfolio_service.py
index 4478553..1d8d4ce 100644
--- a/src/coinhunter/services/portfolio_service.py
+++ b/src/coinhunter/services/portfolio_service.py
@@ -1,19 +1,47 @@
"""Portfolio state helpers (positions.json, reconcile with exchange)."""
from ..runtime import get_runtime_paths
-from .file_utils import load_json_locked, save_json_locked
+
+__all__ = [
+ "load_positions",
+ "save_positions",
+ "update_positions",
+ "upsert_position",
+ "reconcile_positions_with_exchange",
+]
+from .file_utils import load_json_locked, read_modify_write_json, save_json_locked
from .trade_common import bj_now_iso
-PATHS = get_runtime_paths()
-POSITIONS_FILE = PATHS.positions_file
-POSITIONS_LOCK = PATHS.positions_lock
+
+def _paths():
+ return get_runtime_paths()
def load_positions() -> list:
- return load_json_locked(POSITIONS_FILE, POSITIONS_LOCK, {"positions": []}).get("positions", [])
+ paths = _paths()
+ data = load_json_locked(paths.positions_file, paths.positions_lock, {"positions": []})
+ return data.get("positions", []) # type: ignore[no-any-return]
def save_positions(positions: list):
- save_json_locked(POSITIONS_FILE, POSITIONS_LOCK, {"positions": positions})
+ paths = _paths()
+ save_json_locked(paths.positions_file, paths.positions_lock, {"positions": positions})
+
+
+def update_positions(modifier):
+ """Atomic read-modify-write for positions under a single lock.
+
+ *modifier* receives the current positions list and may mutate it in-place
+ or return a new list. If it returns None, the mutated list is saved.
+ """
+ paths = _paths()
+
+ def _modify(data):
+ positions = data.get("positions", [])
+ result = modifier(positions)
+ data["positions"] = result if result is not None else positions
+ return data
+
+ read_modify_write_json(paths.positions_file, paths.positions_lock, {"positions": []}, _modify)
def upsert_position(positions: list, position: dict):
@@ -26,32 +54,43 @@ def upsert_position(positions: list, position: dict):
return positions
-def reconcile_positions_with_exchange(ex, positions: list):
+def reconcile_positions_with_exchange(ex, positions_hint: list | None = None):
from .exchange_service import fetch_balances
balances = fetch_balances(ex)
- existing_by_symbol = {p.get("symbol"): p for p in positions}
reconciled = []
- for asset, qty in balances.items():
- if asset == "USDT":
- continue
- if qty <= 0:
- continue
- sym = f"{asset}USDT"
- old = existing_by_symbol.get(sym, {})
- reconciled.append(
- {
- "account_id": old.get("account_id", "binance-main"),
- "symbol": sym,
- "base_asset": asset,
- "quote_asset": "USDT",
- "market_type": "spot",
- "quantity": qty,
- "avg_cost": old.get("avg_cost"),
- "opened_at": old.get("opened_at", bj_now_iso()),
- "updated_at": bj_now_iso(),
- "note": old.get("note", "Reconciled from Binance balances"),
- }
- )
- save_positions(reconciled)
+
+ def _modify(data):
+ nonlocal reconciled
+ existing = data.get("positions", [])
+ existing_by_symbol = {p.get("symbol"): p for p in existing}
+ if positions_hint is not None:
+ existing_by_symbol.update({p.get("symbol"): p for p in positions_hint})
+ reconciled = []
+ for asset, qty in balances.items():
+ if asset == "USDT":
+ continue
+ if qty <= 0:
+ continue
+ sym = f"{asset}USDT"
+ old = existing_by_symbol.get(sym, {})
+ reconciled.append(
+ {
+ "account_id": old.get("account_id", "binance-main"),
+ "symbol": sym,
+ "base_asset": asset,
+ "quote_asset": "USDT",
+ "market_type": "spot",
+ "quantity": qty,
+ "avg_cost": old.get("avg_cost"),
+ "opened_at": old.get("opened_at", bj_now_iso()),
+ "updated_at": bj_now_iso(),
+ "note": old.get("note", "Reconciled from Binance balances"),
+ }
+ )
+ data["positions"] = reconciled
+ return data
+
+ paths = _paths()
+ read_modify_write_json(paths.positions_file, paths.positions_lock, {"positions": []}, _modify)
return reconciled, balances
diff --git a/src/coinhunter/services/precheck_analysis.py b/src/coinhunter/services/precheck_analysis.py
index 0135000..28190c8 100644
--- a/src/coinhunter/services/precheck_analysis.py
+++ b/src/coinhunter/services/precheck_analysis.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from .time_utils import utc_iso
-from .trigger_analyzer import analyze_trigger
def build_failure_payload(exc: Exception) -> dict:
@@ -18,5 +17,5 @@ def build_failure_payload(exc: Exception) -> dict:
"soft_reasons": [],
"soft_score": 0,
"details": [str(exc)],
- "compact_summary": f"预检查失败,转入深度分析兑底: {exc}",
+ "compact_summary": f"Precheck failed, falling back to deep analysis: {exc}",
}
diff --git a/src/coinhunter/services/precheck_constants.py b/src/coinhunter/services/precheck_constants.py
index e776afb..d5e91b2 100644
--- a/src/coinhunter/services/precheck_constants.py
+++ b/src/coinhunter/services/precheck_constants.py
@@ -1,31 +1,25 @@
-"""Precheck constants and runtime paths."""
+"""Precheck constants and thresholds."""
from __future__ import annotations
-from ..runtime import get_runtime_paths
+from ..runtime import get_user_config
-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 = "precheck"
-BASE_PRICE_MOVE_TRIGGER_PCT = 0.025
-BASE_PNL_TRIGGER_PCT = 0.03
-BASE_PORTFOLIO_MOVE_TRIGGER_PCT = 0.03
-BASE_CANDIDATE_SCORE_TRIGGER_RATIO = 1.15
-BASE_FORCE_ANALYSIS_AFTER_MINUTES = 180
-BASE_COOLDOWN_MINUTES = 45
-TOP_CANDIDATES = 10
-MIN_ACTIONABLE_USDT = 12.0
-MIN_REAL_POSITION_VALUE_USDT = 8.0
-BLACKLIST = {"USDC", "BUSD", "TUSD", "FDUSD", "USTC", "PAXG"}
-HARD_STOP_PCT = -0.08
-HARD_MOON_PCT = 0.25
-MIN_CHANGE_PCT = 1.0
-MAX_PRICE_CAP = None
-HARD_REASON_DEDUP_MINUTES = 15
-MAX_PENDING_TRIGGER_MINUTES = 30
-MAX_RUN_REQUEST_MINUTES = 20
+BASE_PRICE_MOVE_TRIGGER_PCT = get_user_config(f"{_BASE}.base_price_move_trigger_pct", 0.025)
+BASE_PNL_TRIGGER_PCT = get_user_config(f"{_BASE}.base_pnl_trigger_pct", 0.03)
+BASE_PORTFOLIO_MOVE_TRIGGER_PCT = get_user_config(f"{_BASE}.base_portfolio_move_trigger_pct", 0.03)
+BASE_CANDIDATE_SCORE_TRIGGER_RATIO = get_user_config(f"{_BASE}.base_candidate_score_trigger_ratio", 1.15)
+BASE_FORCE_ANALYSIS_AFTER_MINUTES = get_user_config(f"{_BASE}.base_force_analysis_after_minutes", 180)
+BASE_COOLDOWN_MINUTES = get_user_config(f"{_BASE}.base_cooldown_minutes", 45)
+TOP_CANDIDATES = get_user_config(f"{_BASE}.top_candidates", 10)
+MIN_ACTIONABLE_USDT = get_user_config(f"{_BASE}.min_actionable_usdt", 12.0)
+MIN_REAL_POSITION_VALUE_USDT = get_user_config(f"{_BASE}.min_real_position_value_usdt", 8.0)
+BLACKLIST = set(get_user_config(f"{_BASE}.blacklist", ["USDC", "BUSD", "TUSD", "FDUSD", "USTC", "PAXG"]))
+HARD_STOP_PCT = get_user_config(f"{_BASE}.hard_stop_pct", -0.08)
+HARD_MOON_PCT = get_user_config(f"{_BASE}.hard_moon_pct", 0.25)
+MIN_CHANGE_PCT = get_user_config(f"{_BASE}.min_change_pct", 1.0)
+MAX_PRICE_CAP = get_user_config(f"{_BASE}.max_price_cap", None)
+HARD_REASON_DEDUP_MINUTES = get_user_config(f"{_BASE}.hard_reason_dedup_minutes", 15)
+MAX_PENDING_TRIGGER_MINUTES = get_user_config(f"{_BASE}.max_pending_trigger_minutes", 30)
+MAX_RUN_REQUEST_MINUTES = get_user_config(f"{_BASE}.max_run_request_minutes", 20)
diff --git a/src/coinhunter/services/precheck_core.py b/src/coinhunter/services/precheck_core.py
index ad0b6fc..21a3909 100644
--- a/src/coinhunter/services/precheck_core.py
+++ b/src/coinhunter/services/precheck_core.py
@@ -18,14 +18,19 @@ from __future__ import annotations
from importlib import import_module
+from ..runtime import get_runtime_paths
+
+_PATH_ALIASES = {
+ "PATHS": lambda: get_runtime_paths(),
+ "BASE_DIR": lambda: get_runtime_paths().root,
+ "STATE_DIR": lambda: get_runtime_paths().state_dir,
+ "STATE_FILE": lambda: get_runtime_paths().precheck_state_file,
+ "POSITIONS_FILE": lambda: get_runtime_paths().positions_file,
+ "CONFIG_FILE": lambda: get_runtime_paths().config_file,
+ "ENV_FILE": lambda: get_runtime_paths().env_file,
+}
+
_MODULE_MAP = {
- "PATHS": ".precheck_constants",
- "BASE_DIR": ".precheck_constants",
- "STATE_DIR": ".precheck_constants",
- "STATE_FILE": ".precheck_constants",
- "POSITIONS_FILE": ".precheck_constants",
- "CONFIG_FILE": ".precheck_constants",
- "ENV_FILE": ".precheck_constants",
"BASE_PRICE_MOVE_TRIGGER_PCT": ".precheck_constants",
"BASE_PNL_TRIGGER_PCT": ".precheck_constants",
"BASE_PORTFOLIO_MOVE_TRIGGER_PCT": ".precheck_constants",
@@ -74,10 +79,12 @@ _MODULE_MAP = {
"analyze_trigger": ".trigger_analyzer",
}
-__all__ = sorted(set(_MODULE_MAP) | {"main"})
+__all__ = sorted(set(_MODULE_MAP) | set(_PATH_ALIASES) | {"main"})
def __getattr__(name: str):
+ if name in _PATH_ALIASES:
+ return _PATH_ALIASES[name]()
if name not in _MODULE_MAP:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name = _MODULE_MAP[name]
@@ -90,8 +97,9 @@ def __dir__():
def main():
- from .precheck_service import run as _run_service
import sys
+
+ from .precheck_service import run as _run_service
return _run_service(sys.argv[1:])
diff --git a/src/coinhunter/services/precheck_service.py b/src/coinhunter/services/precheck_service.py
index b68f509..29a90e8 100644
--- a/src/coinhunter/services/precheck_service.py
+++ b/src/coinhunter/services/precheck_service.py
@@ -3,6 +3,8 @@
from __future__ import annotations
import json
+
+__all__ = ["run"]
import sys
from . import precheck_analysis, precheck_snapshot, precheck_state
@@ -19,12 +21,24 @@ def run(argv: list[str] | None = None) -> int:
return 0
try:
- state = precheck_state.sanitize_state_for_stale_triggers(precheck_state.load_state())
- snapshot = precheck_snapshot.build_snapshot()
- analysis = precheck_analysis.analyze_trigger(snapshot, state)
- precheck_state.save_state(precheck_state.update_state_after_observation(state, snapshot, analysis))
- print(json.dumps(analysis, ensure_ascii=False, indent=2))
+ captured = {}
+
+ def _modifier(state):
+ state = precheck_state.sanitize_state_for_stale_triggers(state)
+ snapshot = precheck_snapshot.build_snapshot()
+ analysis = precheck_analysis.analyze_trigger(snapshot, state)
+ new_state = precheck_state.update_state_after_observation(state, snapshot, analysis)
+ state.clear()
+ state.update(new_state)
+ captured["analysis"] = analysis
+ return state
+
+ precheck_state.modify_state(_modifier)
+ result = {"ok": True, **captured["analysis"]}
+ print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except Exception as exc:
- print(json.dumps(precheck_analysis.build_failure_payload(exc), ensure_ascii=False, indent=2))
- return 0
+ payload = precheck_analysis.build_failure_payload(exc)
+ result = {"ok": False, **payload}
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 1
diff --git a/src/coinhunter/services/precheck_snapshot.py b/src/coinhunter/services/precheck_snapshot.py
index 4d0c957..bb45581 100644
--- a/src/coinhunter/services/precheck_snapshot.py
+++ b/src/coinhunter/services/precheck_snapshot.py
@@ -2,4 +2,3 @@
from __future__ import annotations
-from .snapshot_builder import build_snapshot
diff --git a/src/coinhunter/services/precheck_state.py b/src/coinhunter/services/precheck_state.py
index 29018e4..f54ae90 100644
--- a/src/coinhunter/services/precheck_state.py
+++ b/src/coinhunter/services/precheck_state.py
@@ -6,32 +6,36 @@ import json
from .state_manager import (
load_state,
- sanitize_state_for_stale_triggers,
- save_state,
- update_state_after_observation,
+ modify_state,
)
from .time_utils import utc_iso
def mark_run_requested(note: str = "") -> dict:
+ def _modifier(state):
+ state["run_requested_at"] = utc_iso()
+ state["run_request_note"] = note
+ return state
+
+ modify_state(_modifier)
state = load_state()
- state["run_requested_at"] = utc_iso()
- state["run_request_note"] = note
- save_state(state)
payload = {"ok": True, "run_requested_at": state["run_requested_at"], "note": note}
print(json.dumps(payload, ensure_ascii=False))
return payload
def ack_analysis(note: str = "") -> dict:
+ def _modifier(state):
+ state["last_deep_analysis_at"] = utc_iso()
+ state["pending_trigger"] = False
+ state["pending_reasons"] = []
+ state["last_ack_note"] = note
+ state.pop("run_requested_at", None)
+ state.pop("run_request_note", None)
+ return state
+
+ modify_state(_modifier)
state = load_state()
- state["last_deep_analysis_at"] = utc_iso()
- state["pending_trigger"] = False
- state["pending_reasons"] = []
- state["last_ack_note"] = note
- state.pop("run_requested_at", None)
- state.pop("run_request_note", None)
- save_state(state)
payload = {"ok": True, "acked_at": state["last_deep_analysis_at"], "note": note}
print(json.dumps(payload, ensure_ascii=False))
return payload
diff --git a/src/coinhunter/services/review_service.py b/src/coinhunter/services/review_service.py
new file mode 100644
index 0000000..57cc79a
--- /dev/null
+++ b/src/coinhunter/services/review_service.py
@@ -0,0 +1,292 @@
+"""Review generation service for CoinHunter."""
+
+from __future__ import annotations
+
+import json
+
+__all__ = [
+ "ensure_review_dir",
+ "norm_symbol",
+ "fetch_current_price",
+ "analyze_trade",
+ "analyze_hold_passes",
+ "analyze_cash_misses",
+ "generate_review",
+ "save_review",
+ "print_review",
+]
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from ..logger import get_logs_last_n_hours
+from ..runtime import get_runtime_paths
+from .exchange_service import get_exchange
+
+CST = timezone(timedelta(hours=8))
+
+
+def _paths():
+ return get_runtime_paths()
+
+
+def _review_dir() -> Path:
+ return _paths().reviews_dir # type: ignore[no-any-return]
+
+
+def _exchange():
+ return get_exchange()
+
+
+def ensure_review_dir():
+ _review_dir().mkdir(parents=True, exist_ok=True)
+
+
+def norm_symbol(symbol: str) -> str:
+ s = symbol.upper().replace("-", "").replace("_", "")
+ if "/" in s:
+ return s
+ if s.endswith("USDT"):
+ return s[:-4] + "/USDT"
+ return s
+
+
+def fetch_current_price(ex, symbol: str):
+ try:
+ return float(ex.fetch_ticker(norm_symbol(symbol))["last"])
+ except Exception:
+ return None
+
+
+def analyze_trade(trade: dict, ex) -> dict:
+ symbol = trade.get("symbol")
+ price = trade.get("price")
+ action = trade.get("action", "")
+ current_price = fetch_current_price(ex, symbol) if symbol else None
+ pnl_estimate = None
+ outcome = "neutral"
+ if price and current_price and symbol:
+ change_pct = (current_price - float(price)) / float(price) * 100
+ if action == "BUY":
+ pnl_estimate = round(change_pct, 2)
+ outcome = "good" if change_pct > 2 else "bad" if change_pct < -2 else "neutral"
+ elif action == "SELL_ALL":
+ pnl_estimate = round(-change_pct, 2)
+ outcome = "good" if change_pct < -2 else "missed" if change_pct > 2 else "neutral"
+ return {
+ "timestamp": trade.get("timestamp"),
+ "symbol": symbol,
+ "action": action,
+ "decision_id": trade.get("decision_id"),
+ "execution_price": price,
+ "current_price": current_price,
+ "pnl_estimate_pct": pnl_estimate,
+ "outcome_assessment": outcome,
+ }
+
+
+def analyze_hold_passes(decisions: list, ex) -> list:
+ misses = []
+ for d in decisions:
+ if d.get("decision") != "HOLD":
+ continue
+ analysis = d.get("analysis")
+ if not isinstance(analysis, dict):
+ continue
+ opportunities = analysis.get("opportunities_evaluated", [])
+ market_snapshot = d.get("market_snapshot", {})
+ if not opportunities or not market_snapshot:
+ continue
+ for op in opportunities:
+ verdict = op.get("verdict", "")
+ if "PASS" not in verdict and "pass" not in verdict:
+ continue
+ symbol = op.get("symbol", "")
+ snap = market_snapshot.get(symbol) or market_snapshot.get(symbol.replace("/", ""))
+ if not snap:
+ continue
+ decision_price = None
+ if isinstance(snap, dict):
+ decision_price = float(snap.get("lastPrice", 0)) or float(snap.get("last", 0))
+ elif isinstance(snap, (int, float, str)):
+ decision_price = float(snap)
+ if not decision_price:
+ continue
+ current_price = fetch_current_price(ex, symbol)
+ if not current_price:
+ continue
+ change_pct = (current_price - decision_price) / decision_price * 100
+ if change_pct > 3:
+ misses.append({
+ "timestamp": d.get("timestamp"),
+ "symbol": symbol,
+ "decision_price": round(decision_price, 8),
+ "current_price": round(current_price, 8),
+ "change_pct": round(change_pct, 2),
+ "verdict_snippet": verdict[:80],
+ })
+ return misses
+
+
+def analyze_cash_misses(decisions: list, ex) -> list:
+ misses = []
+ watchlist = set()
+ for d in decisions:
+ snap = d.get("market_snapshot", {})
+ if isinstance(snap, dict):
+ for k in snap.keys():
+ if k.endswith("USDT"):
+ watchlist.add(k)
+ for d in decisions:
+ ts = d.get("timestamp")
+ balances = d.get("balances") or d.get("balances_before", {})
+ if not balances:
+ continue
+ total = sum(float(v) if isinstance(v, (int, float, str)) else 0 for v in balances.values())
+ usdt = float(balances.get("USDT", 0))
+ if total == 0 or (usdt / total) < 0.9:
+ continue
+ snap = d.get("market_snapshot", {})
+ if not isinstance(snap, dict):
+ continue
+ for symbol, data in snap.items():
+ if not symbol.endswith("USDT"):
+ continue
+ decision_price = None
+ if isinstance(data, dict):
+ decision_price = float(data.get("lastPrice", 0)) or float(data.get("last", 0))
+ elif isinstance(data, (int, float, str)):
+ decision_price = float(data)
+ if not decision_price:
+ continue
+ current_price = fetch_current_price(ex, symbol)
+ if not current_price:
+ continue
+ change_pct = (current_price - decision_price) / decision_price * 100
+ if change_pct > 5:
+ misses.append({
+ "timestamp": ts,
+ "symbol": symbol,
+ "decision_price": round(decision_price, 8),
+ "current_price": round(current_price, 8),
+ "change_pct": round(change_pct, 2),
+ })
+ seen: dict[str, dict] = {}
+ for m in misses:
+ sym = m["symbol"]
+ if sym not in seen or m["change_pct"] > seen[sym]["change_pct"]:
+ seen[sym] = m
+ return list(seen.values())
+
+
+def generate_review(hours: int = 1) -> dict:
+ decisions = get_logs_last_n_hours("decisions", hours)
+ trades = get_logs_last_n_hours("trades", hours)
+ errors = get_logs_last_n_hours("errors", hours)
+
+ review: dict = {
+ "review_period_hours": hours,
+ "review_timestamp": datetime.now(CST).isoformat(),
+ "total_decisions": len(decisions),
+ "total_trades": len(trades),
+ "total_errors": len(errors),
+ "decision_quality": [],
+ "stats": {},
+ "insights": [],
+ "recommendations": [],
+ }
+
+ if not decisions and not trades:
+ review["insights"].append("No decisions or trades in this period")
+ return review
+
+ ex = get_exchange()
+ outcomes = {"good": 0, "neutral": 0, "bad": 0, "missed": 0}
+ pnl_samples = []
+
+ for trade in trades:
+ analysis = analyze_trade(trade, ex)
+ review["decision_quality"].append(analysis)
+ outcomes[analysis["outcome_assessment"]] += 1
+ if analysis["pnl_estimate_pct"] is not None:
+ pnl_samples.append(analysis["pnl_estimate_pct"])
+
+ hold_pass_misses = analyze_hold_passes(decisions, ex)
+ cash_misses = analyze_cash_misses(decisions, ex)
+ total_missed = outcomes["missed"] + len(hold_pass_misses) + len(cash_misses)
+
+ review["stats"] = {
+ "good_decisions": outcomes["good"],
+ "neutral_decisions": outcomes["neutral"],
+ "bad_decisions": outcomes["bad"],
+ "missed_opportunities": total_missed,
+ "missed_sell_all": outcomes["missed"],
+ "missed_hold_passes": len(hold_pass_misses),
+ "missed_cash_sits": len(cash_misses),
+ "avg_estimated_edge_pct": round(sum(pnl_samples) / len(pnl_samples), 2) if pnl_samples else None,
+ }
+
+ if errors:
+ review["insights"].append(f"{len(errors)} execution/system errors this period; robustness needs attention")
+ if outcomes["bad"] > outcomes["good"]:
+ review["insights"].append("Recent trade quality is weak; consider lowering frequency or raising entry threshold")
+ if total_missed > 0:
+ parts = []
+ if outcomes["missed"]:
+ parts.append(f"sold then rallied {outcomes['missed']} times")
+ if hold_pass_misses:
+ parts.append(f"missed after PASS {len(hold_pass_misses)} times")
+ if cash_misses:
+ parts.append(f"missed while sitting in cash {len(cash_misses)} times")
+ review["insights"].append("Opportunities missed: " + ", ".join(parts) + "; consider relaxing trend-following or entry conditions")
+ if outcomes["good"] >= max(1, outcomes["bad"] + total_missed):
+ review["insights"].append("Recent decisions are generally acceptable")
+ if not trades and decisions:
+ review["insights"].append("Decisions without trades; may be due to waiting on sidelines, minimum notional limits, or execution interception")
+ if len(trades) < len(decisions) * 0.1 and decisions:
+ review["insights"].append("Many decisions did not convert to trades; check if minimum notional/step-size/fee buffer thresholds are too high")
+ if hold_pass_misses:
+ for m in hold_pass_misses[:3]:
+ review["insights"].append(f"PASS'd {m['symbol']} during HOLD, then it rose {m['change_pct']}%")
+ if cash_misses:
+ for m in cash_misses[:3]:
+ review["insights"].append(f"{m['symbol']} rose {m['change_pct']}% while portfolio was mostly USDT")
+
+ review["recommendations"] = [
+ "Check whether minimum-notional/precision rejections are blocking small-capital execution",
+ "If estimated edge is negative for two consecutive review periods, reduce rebalancing frequency next hour",
+ "If error logs are increasing, prioritize defensive mode (hold more USDT)",
+ ]
+ return review
+
+
+def save_review(review: dict) -> str:
+ ensure_review_dir()
+ ts = datetime.now(CST).strftime("%Y%m%d_%H%M%S")
+ path = _review_dir() / f"review_{ts}.json"
+ path.write_text(json.dumps(review, indent=2, ensure_ascii=False), encoding="utf-8")
+ return str(path)
+
+
+def print_review(review: dict):
+ print("=" * 50)
+ print("📊 Coin Hunter Review Report")
+ print(f"Review time: {review['review_timestamp']}")
+ print(f"Period: last {review['review_period_hours']} hours")
+ print(f"Total decisions: {review['total_decisions']} | Total trades: {review['total_trades']} | Total errors: {review['total_errors']}")
+ stats = review.get("stats", {})
+ print("\nDecision quality:")
+ print(f" ✓ Good: {stats.get('good_decisions', 0)}")
+ print(f" ○ Neutral: {stats.get('neutral_decisions', 0)}")
+ print(f" ✗ Bad: {stats.get('bad_decisions', 0)}")
+ print(f" ↗ Missed opportunities: {stats.get('missed_opportunities', 0)}")
+ if stats.get("avg_estimated_edge_pct") is not None:
+ print(f" Avg estimated edge: {stats['avg_estimated_edge_pct']}%")
+ if review.get("insights"):
+ print("\n💡 Insights:")
+ for item in review["insights"]:
+ print(f" • {item}")
+ if review.get("recommendations"):
+ print("\n🔧 Recommendations:")
+ for item in review["recommendations"]:
+ print(f" • {item}")
+ print("=" * 50)
diff --git a/src/coinhunter/services/smart_executor_parser.py b/src/coinhunter/services/smart_executor_parser.py
index 4d44f66..91b153e 100644
--- a/src/coinhunter/services/smart_executor_parser.py
+++ b/src/coinhunter/services/smart_executor_parser.py
@@ -1,6 +1,15 @@
"""CLI parser and legacy argument normalization for smart executor."""
import argparse
+__all__ = [
+ "COMMAND_CANONICAL",
+ "add_shared_options",
+ "build_parser",
+ "normalize_legacy_argv",
+ "parse_cli_args",
+ "cli_action_args",
+]
+
COMMAND_CANONICAL = {
"bal": "balances",
@@ -16,6 +25,10 @@ COMMAND_CANONICAL = {
"sell_all": "sell-all",
"rotate": "rebalance",
"rebalance": "rebalance",
+ "orders": "orders",
+ "cancel": "cancel",
+ "order-status": "order-status",
+ "order_status": "order-status",
}
@@ -41,25 +54,32 @@ def build_parser() -> argparse.ArgumentParser:
" hold Record a hold decision without trading\n"
" buy SYMBOL USDT Buy a symbol using a USDT notional amount\n"
" flat SYMBOL Exit an entire symbol position\n"
- " rotate FROM TO Rotate exposure from one symbol into another\n\n"
+ " rotate FROM TO Rotate exposure from one symbol into another\n"
+ " orders List open spot orders\n"
+ " order-status SYMBOL ORDER_ID Get status of a specific order\n"
+ " cancel SYMBOL [ORDER_ID] Cancel an open order (cancels newest if ORDER_ID omitted)\n\n"
"Examples:\n"
" coinhunter exec bal\n"
" coinhunter exec overview\n"
" coinhunter exec hold\n"
" coinhunter exec buy ENJUSDT 100\n"
" coinhunter exec flat ENJUSDT --dry-run\n"
- " coinhunter exec rotate PEPEUSDT ETHUSDT\n\n"
+ " coinhunter exec rotate PEPEUSDT ETHUSDT\n"
+ " coinhunter exec orders\n"
+ " coinhunter exec order-status ENJUSDT 123456\n"
+ " coinhunter exec cancel ENJUSDT 123456\n\n"
"Legacy forms remain supported for backward compatibility:\n"
" balances, balance -> bal\n"
" acct, status -> overview\n"
" sell-all, sell_all -> flat\n"
" rebalance -> rotate\n"
+ " order_status -> order-status\n"
" HOLD / BUY / SELL_ALL / REBALANCE via --decision are still accepted\n"
),
)
subparsers = parser.add_subparsers(
dest="command",
- metavar="{bal,overview,hold,buy,flat,rotate,...}",
+ metavar="{bal,overview,hold,buy,flat,rotate,orders,order-status,cancel,...}",
)
subparsers.add_parser("bal", parents=[shared], help="Preferred: print live balances as stable JSON")
@@ -77,6 +97,16 @@ def build_parser() -> argparse.ArgumentParser:
rebalance.add_argument("from_symbol")
rebalance.add_argument("to_symbol")
+ subparsers.add_parser("orders", parents=[shared], help="List open spot orders")
+
+ order_status = subparsers.add_parser("order-status", parents=[shared], help="Get status of a specific order")
+ order_status.add_argument("symbol")
+ order_status.add_argument("order_id")
+
+ cancel = subparsers.add_parser("cancel", parents=[shared], help="Cancel an open order")
+ cancel.add_argument("symbol")
+ cancel.add_argument("order_id", nargs="?")
+
subparsers.add_parser("balances", parents=[shared], help=argparse.SUPPRESS)
subparsers.add_parser("balance", parents=[shared], help=argparse.SUPPRESS)
subparsers.add_parser("acct", parents=[shared], help=argparse.SUPPRESS)
@@ -91,6 +121,10 @@ def build_parser() -> argparse.ArgumentParser:
rebalance_legacy.add_argument("from_symbol")
rebalance_legacy.add_argument("to_symbol")
+ order_status_legacy = subparsers.add_parser("order_status", parents=[shared], help=argparse.SUPPRESS)
+ order_status_legacy.add_argument("symbol")
+ order_status_legacy.add_argument("order_id")
+
subparsers._choices_actions = [
action
for action in subparsers._choices_actions
@@ -126,7 +160,13 @@ def normalize_legacy_argv(argv: list[str]) -> list[str]:
"STATUS": ["status"],
"status": ["status"],
"OVERVIEW": ["status"],
- "overview": ["status"],
+ "ORDERS": ["orders"],
+ "orders": ["orders"],
+ "CANCEL": ["cancel"],
+ "cancel": ["cancel"],
+ "ORDER_STATUS": ["order-status"],
+ "order_status": ["order-status"],
+ "order-status": ["order-status"],
}
has_legacy_flag = any(t.startswith("--decision") for t in argv)
@@ -166,18 +206,18 @@ def normalize_legacy_argv(argv: list[str]) -> list[str]:
rebuilt += ["hold"]
elif decision == "SELL_ALL":
if not ns.symbol:
- raise RuntimeError("旧式 --decision SELL_ALL 需要搭配 --symbol")
+ raise RuntimeError("Legacy --decision SELL_ALL requires --symbol")
rebuilt += ["sell-all", ns.symbol]
elif decision == "BUY":
if not ns.symbol or ns.amount_usdt is None:
- raise RuntimeError("旧式 --decision BUY 需要 --symbol 和 --amount-usdt")
+ raise RuntimeError("Legacy --decision BUY requires --symbol and --amount-usdt")
rebuilt += ["buy", ns.symbol, str(ns.amount_usdt)]
elif decision == "REBALANCE":
if not ns.from_symbol or not ns.to_symbol:
- raise RuntimeError("旧式 --decision REBALANCE 需要 --from-symbol 和 --to-symbol")
+ raise RuntimeError("Legacy --decision REBALANCE requires --from-symbol and --to-symbol")
rebuilt += ["rebalance", ns.from_symbol, ns.to_symbol]
else:
- raise RuntimeError(f"不支持的旧式 decision: {decision}")
+ raise RuntimeError(f"Unsupported legacy decision: {decision}")
return rebuilt + unknown
@@ -202,4 +242,8 @@ def cli_action_args(args, action: str) -> list[str]:
return [args.symbol, str(args.amount_usdt)]
if action == "rebalance":
return [args.from_symbol, args.to_symbol]
+ if action == "order_status":
+ return [args.symbol, args.order_id]
+ if action == "cancel":
+ return [args.symbol, args.order_id] if args.order_id else [args.symbol]
return []
diff --git a/src/coinhunter/services/smart_executor_service.py b/src/coinhunter/services/smart_executor_service.py
index fe82968..b29d8e8 100644
--- a/src/coinhunter/services/smart_executor_service.py
+++ b/src/coinhunter/services/smart_executor_service.py
@@ -3,21 +3,27 @@
from __future__ import annotations
import os
+
+__all__ = ["run"]
import sys
from ..logger import log_decision, log_error
-from .exchange_service import fetch_balances, build_market_snapshot
+from .exchange_service import build_market_snapshot, fetch_balances
from .execution_state import default_decision_id, get_execution_state, record_execution_state
from .portfolio_service import load_positions
-from .smart_executor_parser import parse_cli_args, cli_action_args
-from .trade_common import is_dry_run, log, set_dry_run, bj_now_iso
+from .smart_executor_parser import cli_action_args, parse_cli_args
+from .trade_common import bj_now_iso, log, set_dry_run
from .trade_execution import (
- command_balances,
- command_status,
- build_decision_context,
- action_sell_all,
action_buy,
action_rebalance,
+ action_sell_all,
+ build_decision_context,
+ command_balances,
+ command_cancel,
+ command_order_status,
+ command_orders,
+ command_status,
+ print_json,
)
@@ -36,9 +42,9 @@ def run(argv: list[str] | None = None) -> int:
set_dry_run(True)
previous = get_execution_state(decision_id)
- read_only_action = action in {"balance", "balances", "status"}
+ read_only_action = action in {"balance", "balances", "status", "orders", "order_status", "cancel"}
if previous and previous.get("status") == "success" and not read_only_action:
- log(f"⚠️ decision_id={decision_id} 已执行成功,跳过重复执行")
+ log(f"⚠️ decision_id={decision_id} already executed successfully, skipping duplicate")
return 0
try:
@@ -48,8 +54,14 @@ def run(argv: list[str] | None = None) -> int:
if read_only_action:
if action in {"balance", "balances"}:
command_balances(ex)
- else:
+ elif action == "status":
command_status(ex)
+ elif action == "orders":
+ command_orders(ex)
+ elif action == "order_status":
+ command_order_status(ex, args.symbol, args.order_id)
+ elif action == "cancel":
+ command_cancel(ex, args.symbol, getattr(args, "order_id", None))
return 0
decision_context = build_decision_context(ex, action, argv_tail, decision_id)
@@ -88,10 +100,10 @@ def run(argv: list[str] | None = None) -> int:
"execution_result": {"status": "hold"},
}
)
- log("😴 决策: 持续持有,无操作")
+ log("😴 Decision: hold, no action")
result = {"status": "hold"}
else:
- raise RuntimeError(f"未知动作: {action};请运行 --help 查看正确 CLI 用法")
+ raise RuntimeError(f"Unknown action: {action}; run --help for valid CLI usage")
record_execution_state(
decision_id,
@@ -103,7 +115,9 @@ def run(argv: list[str] | None = None) -> int:
"result": result,
},
)
- log(f"✅ 执行完成 decision_id={decision_id}")
+ if not read_only_action:
+ print_json({"ok": True, "decision_id": decision_id, "action": action, "result": result})
+ log(f"Execution completed decision_id={decision_id}")
return 0
except Exception as exc:
@@ -124,5 +138,5 @@ def run(argv: list[str] | None = None) -> int:
action=action,
args=argv_tail,
)
- log(f"❌ 执行失败: {exc}")
+ log(f"❌ Execution failed: {exc}")
return 1
diff --git a/src/coinhunter/services/snapshot_builder.py b/src/coinhunter/services/snapshot_builder.py
index 6c16a80..c90c092 100644
--- a/src/coinhunter/services/snapshot_builder.py
+++ b/src/coinhunter/services/snapshot_builder.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from .candidate_scoring import top_candidates_from_tickers
from .data_utils import norm_symbol, stable_hash, to_float
from .market_data import enrich_candidates_and_positions, get_exchange, regime_from_pct
-from .precheck_constants import MIN_ACTIONABLE_USDT, MIN_REAL_POSITION_VALUE_USDT
+from .precheck_constants import MIN_REAL_POSITION_VALUE_USDT
from .state_manager import load_config, load_positions
from .time_utils import get_local_now, utc_iso
diff --git a/src/coinhunter/services/state_manager.py b/src/coinhunter/services/state_manager.py
index d6a7b11..8ee2125 100644
--- a/src/coinhunter/services/state_manager.py
+++ b/src/coinhunter/services/state_manager.py
@@ -4,34 +4,58 @@ from __future__ import annotations
from datetime import timedelta
-from ..runtime import load_env_file
+__all__ = [
+ "load_env",
+ "load_positions",
+ "load_state",
+ "modify_state",
+ "load_config",
+ "clear_run_request_fields",
+ "sanitize_state_for_stale_triggers",
+ "save_state",
+ "update_state_after_observation",
+]
+
+from ..runtime import get_runtime_paths, load_env_file
from .data_utils import load_json
+from .file_utils import load_json_locked, read_modify_write_json, save_json_locked
from .precheck_constants import (
- CONFIG_FILE,
- ENV_FILE,
MAX_PENDING_TRIGGER_MINUTES,
MAX_RUN_REQUEST_MINUTES,
- POSITIONS_FILE,
- STATE_DIR,
- STATE_FILE,
)
from .time_utils import parse_ts, utc_iso, utc_now
+def _paths():
+ return get_runtime_paths()
+
+
def load_env() -> None:
- load_env_file()
+ load_env_file(_paths())
def load_positions():
- return load_json(POSITIONS_FILE, {}).get("positions", [])
+ return load_json(_paths().positions_file, {}).get("positions", [])
def load_state():
- return load_json(STATE_FILE, {})
+ paths = _paths()
+ return load_json_locked(paths.precheck_state_file, paths.precheck_state_lock, {})
+
+
+def modify_state(modifier):
+ """Atomic read-modify-write for precheck state."""
+ paths = _paths()
+ read_modify_write_json(
+ paths.precheck_state_file,
+ paths.precheck_state_lock,
+ {},
+ modifier,
+ )
def load_config():
- return load_json(CONFIG_FILE, {})
+ return load_json(_paths().config_file, {})
def clear_run_request_fields(state: dict):
@@ -58,14 +82,14 @@ def sanitize_state_for_stale_triggers(state: dict):
)
pending_trigger = False
notes.append(
- f"自动清理已完成的 run_requested 标记:最近深度分析时间 {last_deep_analysis_at.isoformat()} >= 请求时间 {run_requested_at.isoformat()}"
+ f"Auto-cleared completed run_requested marker: last_deep_analysis_at {last_deep_analysis_at.isoformat()} >= run_requested_at {run_requested_at.isoformat()}"
)
run_requested_at = None
if run_requested_at and now - run_requested_at > timedelta(minutes=MAX_RUN_REQUEST_MINUTES):
clear_run_request_fields(sanitized)
notes.append(
- f"自动清理超时 run_requested 标记:已等待 {(now - run_requested_at).total_seconds() / 60:.1f} 分钟,超过 {MAX_RUN_REQUEST_MINUTES} 分钟"
+ f"Auto-cleared stale run_requested marker: waited {(now - run_requested_at).total_seconds() / 60:.1f} minutes, exceeding {MAX_RUN_REQUEST_MINUTES} minutes"
)
run_requested_at = None
@@ -78,7 +102,7 @@ def sanitize_state_for_stale_triggers(state: dict):
f"{(now - pending_anchor).total_seconds() / 60:.1f} minutes"
)
notes.append(
- f"自动解除 pending_trigger:触发状态已悬挂 {(now - pending_anchor).total_seconds() / 60:.1f} 分钟,超过 {MAX_PENDING_TRIGGER_MINUTES} 分钟"
+ f"Auto-recovered stale pending_trigger: trigger was dangling for {(now - pending_anchor).total_seconds() / 60:.1f} minutes, exceeding {MAX_PENDING_TRIGGER_MINUTES} minutes"
)
sanitized["_stale_recovery_notes"] = notes
@@ -86,12 +110,16 @@ def sanitize_state_for_stale_triggers(state: dict):
def save_state(state: dict):
- import json
- STATE_DIR.mkdir(parents=True, exist_ok=True)
+ paths = _paths()
+ paths.state_dir.mkdir(parents=True, exist_ok=True)
state_to_save = dict(state)
state_to_save.pop("_stale_recovery_notes", None)
- STATE_FILE.write_text(json.dumps(state_to_save, indent=2, ensure_ascii=False), encoding="utf-8")
+ save_json_locked(
+ paths.precheck_state_file,
+ paths.precheck_state_lock,
+ state_to_save,
+ )
def update_state_after_observation(state: dict, snapshot: dict, analysis: dict):
@@ -123,6 +151,10 @@ def update_state_after_observation(state: dict, snapshot: dict, analysis: dict):
for hr in analysis.get("hard_reasons", []):
last_hard_reasons_at[hr] = snapshot["generated_at"]
cutoff = utc_now() - timedelta(hours=24)
- pruned = {k: v for k, v in last_hard_reasons_at.items() if parse_ts(v) and parse_ts(v) > cutoff}
+ pruned: dict[str, str] = {}
+ for k, v in last_hard_reasons_at.items():
+ ts = parse_ts(v)
+ if ts and ts > cutoff:
+ pruned[k] = v
new_state["last_hard_reasons_at"] = pruned
return new_state
diff --git a/src/coinhunter/services/time_utils.py b/src/coinhunter/services/time_utils.py
index ad2be63..e088087 100644
--- a/src/coinhunter/services/time_utils.py
+++ b/src/coinhunter/services/time_utils.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timezone
from zoneinfo import ZoneInfo
diff --git a/src/coinhunter/services/trade_common.py b/src/coinhunter/services/trade_common.py
index 0d1f454..44e642d 100644
--- a/src/coinhunter/services/trade_common.py
+++ b/src/coinhunter/services/trade_common.py
@@ -1,12 +1,15 @@
"""Common trade utilities (time, logging, constants)."""
import os
-from datetime import datetime, timezone, timedelta
+import sys
+from datetime import datetime, timedelta, timezone
+
+from ..runtime import get_user_config
CST = timezone(timedelta(hours=8))
_DRY_RUN = {"value": os.getenv("DRY_RUN", "false").lower() == "true"}
-USDT_BUFFER_PCT = 0.03
-MIN_REMAINING_DUST_USDT = 1.0
+USDT_BUFFER_PCT = get_user_config("trading.usdt_buffer_pct", 0.03)
+MIN_REMAINING_DUST_USDT = get_user_config("trading.min_remaining_dust_usdt", 1.0)
def is_dry_run() -> bool:
@@ -18,7 +21,7 @@ def set_dry_run(value: bool):
def log(msg: str):
- print(f"[{datetime.now(CST).strftime('%Y-%m-%d %H:%M:%S')} CST] {msg}")
+ print(f"[{datetime.now(CST).strftime('%Y-%m-%d %H:%M:%S')} CST] {msg}", file=sys.stderr)
def bj_now_iso():
diff --git a/src/coinhunter/services/trade_execution.py b/src/coinhunter/services/trade_execution.py
index 482c71b..edea345 100644
--- a/src/coinhunter/services/trade_execution.py
+++ b/src/coinhunter/services/trade_execution.py
@@ -1,17 +1,37 @@
"""Trade execution actions (buy, sell, rebalance, hold, status)."""
import json
+__all__ = [
+ "print_json",
+ "build_decision_context",
+ "market_sell",
+ "market_buy",
+ "action_sell_all",
+ "action_buy",
+ "action_rebalance",
+ "command_status",
+ "command_balances",
+ "command_orders",
+ "command_order_status",
+ "command_cancel",
+]
+
from ..logger import log_decision, log_trade
from .exchange_service import (
+ build_market_snapshot,
fetch_balances,
norm_symbol,
- storage_symbol,
- build_market_snapshot,
prepare_buy_quantity,
prepare_sell_quantity,
+ storage_symbol,
)
-from .portfolio_service import load_positions, save_positions, upsert_position, reconcile_positions_with_exchange
-from .trade_common import is_dry_run, USDT_BUFFER_PCT, log, bj_now_iso
+from .portfolio_service import (
+ load_positions,
+ reconcile_positions_with_exchange,
+ update_positions,
+ upsert_position,
+)
+from .trade_common import USDT_BUFFER_PCT, bj_now_iso, is_dry_run, log
def print_json(payload: dict) -> None:
@@ -35,7 +55,7 @@ def build_decision_context(ex, action: str, argv_tail: list[str], decision_id: s
def market_sell(ex, symbol: str, qty: float, decision_id: str):
sym, qty, bid, est_cost = prepare_sell_quantity(ex, symbol, qty)
if is_dry_run():
- log(f"[DRY RUN] 卖出 {sym} 数量 {qty}")
+ log(f"[DRY RUN] SELL {sym} qty {qty}")
return {"id": f"dry-sell-{decision_id}", "symbol": sym, "amount": qty, "price": bid, "cost": est_cost, "status": "closed"}
order = ex.create_market_sell_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-sell"})
return order
@@ -44,7 +64,7 @@ def market_sell(ex, symbol: str, qty: float, decision_id: str):
def market_buy(ex, symbol: str, amount_usdt: float, decision_id: str):
sym, qty, ask, est_cost = prepare_buy_quantity(ex, symbol, amount_usdt)
if is_dry_run():
- log(f"[DRY RUN] 买入 {sym} 金额 ${est_cost:.4f} 数量 {qty}")
+ log(f"[DRY RUN] BUY {sym} amount ${est_cost:.4f} qty {qty}")
return {"id": f"dry-buy-{decision_id}", "symbol": sym, "amount": qty, "price": ask, "cost": est_cost, "status": "closed"}
order = ex.create_market_buy_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-buy"})
return order
@@ -55,13 +75,13 @@ def action_sell_all(ex, symbol: str, decision_id: str, decision_context: dict):
base = norm_symbol(symbol).split("/")[0]
qty = float(balances_before.get(base, 0))
if qty <= 0:
- raise RuntimeError(f"{base} 余额为0,无法卖出")
+ raise RuntimeError(f"{base} balance is zero, cannot sell")
order = market_sell(ex, symbol, qty, decision_id)
- positions_after, balances_after = (
- reconcile_positions_with_exchange(ex, load_positions())
- if not is_dry_run()
- else (load_positions(), balances_before)
- )
+ if is_dry_run():
+ positions_after = load_positions()
+ balances_after = balances_before
+ else:
+ positions_after, balances_after = reconcile_positions_with_exchange(ex)
log_trade(
"SELL_ALL",
norm_symbol(symbol),
@@ -88,13 +108,12 @@ def action_sell_all(ex, symbol: str, decision_id: str, decision_context: dict):
return order
-def action_buy(ex, symbol: str, amount_usdt: float, decision_id: str, decision_context: dict, simulated_usdt_balance: float = None):
+def action_buy(ex, symbol: str, amount_usdt: float, decision_id: str, decision_context: dict, simulated_usdt_balance: float | None = None):
balances_before = fetch_balances(ex) if simulated_usdt_balance is None else {"USDT": simulated_usdt_balance}
usdt = float(balances_before.get("USDT", 0))
if usdt < amount_usdt:
- raise RuntimeError(f"USDT 余额不足(${usdt:.4f} < ${amount_usdt:.4f})")
+ raise RuntimeError(f"Insufficient USDT balance (${usdt:.4f} < ${amount_usdt:.4f})")
order = market_buy(ex, symbol, amount_usdt, decision_id)
- positions_existing = load_positions()
sym_store = storage_symbol(symbol)
price = float(order.get("price") or 0)
qty = float(order.get("amount") or 0)
@@ -110,18 +129,13 @@ def action_buy(ex, symbol: str, amount_usdt: float, decision_id: str, decision_c
"updated_at": bj_now_iso(),
"note": "Smart executor entry",
}
- upsert_position(positions_existing, position)
if is_dry_run():
balances_after = balances_before
- positions_after = positions_existing
+ positions_after = load_positions()
+ upsert_position(positions_after, position)
else:
- save_positions(positions_existing)
- positions_after, balances_after = reconcile_positions_with_exchange(ex, positions_existing)
- for p in positions_after:
- if p["symbol"] == sym_store and price:
- p["avg_cost"] = price
- p["updated_at"] = bj_now_iso()
- save_positions(positions_after)
+ update_positions(lambda p: upsert_position(p, position))
+ positions_after, balances_after = reconcile_positions_with_exchange(ex, [position])
log_trade(
"BUY",
norm_symbol(symbol),
@@ -160,7 +174,7 @@ def action_rebalance(ex, from_symbol: str, to_symbol: str, decision_id: str, dec
spend = usdt * (1 - USDT_BUFFER_PCT)
simulated_usdt = None
if spend < 5:
- raise RuntimeError(f"卖出后 USDT ${spend:.4f} 不足,无法买入新币")
+ raise RuntimeError(f"USDT ${spend:.4f} insufficient after sell, cannot buy new token")
buy_order = action_buy(ex, to_symbol, spend, decision_id + "b", decision_context, simulated_usdt_balance=simulated_usdt)
return {"sell": sell_order, "buy": buy_order}
@@ -183,3 +197,47 @@ def command_balances(ex):
payload = {"balances": balances}
print_json(payload)
return balances
+
+
+def command_orders(ex):
+ sym = None
+ try:
+ orders = ex.fetch_open_orders(symbol=sym) if sym else ex.fetch_open_orders()
+ except Exception as e:
+ raise RuntimeError(f"Failed to fetch open orders: {e}")
+ payload = {"orders": orders}
+ print_json(payload)
+ return orders
+
+
+def command_order_status(ex, symbol: str, order_id: str):
+ sym = norm_symbol(symbol)
+ try:
+ order = ex.fetch_order(order_id, sym)
+ except Exception as e:
+ raise RuntimeError(f"Failed to fetch order {order_id}: {e}")
+ payload = {"order": order}
+ print_json(payload)
+ return order
+
+
+def command_cancel(ex, symbol: str, order_id: str | None):
+ sym = norm_symbol(symbol)
+ if is_dry_run():
+ log(f"[DRY RUN] Would cancel order {order_id or '(newest)'} on {sym}")
+ return {"dry_run": True, "symbol": sym, "order_id": order_id}
+ if not order_id:
+ try:
+ open_orders = ex.fetch_open_orders(sym)
+ except Exception as e:
+ raise RuntimeError(f"Failed to fetch open orders for {sym}: {e}")
+ if not open_orders:
+ raise RuntimeError(f"No open orders to cancel for {sym}")
+ order_id = str(open_orders[-1]["id"])
+ try:
+ result = ex.cancel_order(order_id, sym)
+ except Exception as e:
+ raise RuntimeError(f"Failed to cancel order {order_id} on {sym}: {e}")
+ payload = {"cancelled": True, "symbol": sym, "order_id": order_id, "result": result}
+ print_json(payload)
+ return result
diff --git a/src/coinhunter/services/trigger_analyzer.py b/src/coinhunter/services/trigger_analyzer.py
index 732e0fd..dd57ea0 100644
--- a/src/coinhunter/services/trigger_analyzer.py
+++ b/src/coinhunter/services/trigger_analyzer.py
@@ -10,11 +10,9 @@ from .precheck_constants import (
HARD_MOON_PCT,
HARD_REASON_DEDUP_MINUTES,
HARD_STOP_PCT,
- MAX_PENDING_TRIGGER_MINUTES,
- MAX_RUN_REQUEST_MINUTES,
MIN_REAL_POSITION_VALUE_USDT,
)
-from .time_utils import parse_ts, utc_iso, utc_now
+from .time_utils import parse_ts, utc_now
def analyze_trigger(snapshot: dict, state: dict):
@@ -51,36 +49,37 @@ def analyze_trigger(snapshot: dict, state: dict):
if pending_trigger:
reasons.append("pending-trigger-unacked")
hard_reasons.append("pending-trigger-unacked")
- details.append("上次已触发深度分析但尚未确认完成")
+ details.append("Previous deep analysis trigger has not been acknowledged yet")
if run_requested_at:
- details.append(f"外部门控已在 {run_requested_at.isoformat()} 请求运行分析任务")
+ details.append(f"External gate requested analysis at {run_requested_at.isoformat()}")
if not last_deep_analysis_at:
reasons.append("first-analysis")
hard_reasons.append("first-analysis")
- details.append("尚未记录过深度分析")
+ details.append("No deep analysis has been recorded yet")
elif now - last_deep_analysis_at >= timedelta(minutes=force_minutes):
reasons.append("stale-analysis")
hard_reasons.append("stale-analysis")
- details.append(f"距离上次深度分析已超过 {force_minutes} 分钟")
+ details.append(f"Time since last deep analysis exceeds {force_minutes} minutes")
if last_positions_hash and snapshot["positions_hash"] != last_positions_hash:
reasons.append("positions-changed")
hard_reasons.append("positions-changed")
- details.append("持仓结构发生变化")
+ details.append("Position structure has changed")
if last_portfolio_value not in (None, 0):
- portfolio_delta = abs(snapshot["portfolio_value_usdt"] - last_portfolio_value) / max(last_portfolio_value, 1e-9)
+ lpf = float(str(last_portfolio_value))
+ portfolio_delta = abs(snapshot["portfolio_value_usdt"] - lpf) / max(lpf, 1e-9)
if portfolio_delta >= portfolio_trigger:
if portfolio_delta >= 1.0:
reasons.append("portfolio-extreme-move")
hard_reasons.append("portfolio-extreme-move")
- details.append(f"组合净值剧烈变化 {portfolio_delta:.1%},超过 100%,视为硬触发")
+ details.append(f"Portfolio value moved extremely {portfolio_delta:.1%}, exceeding 100%, treated as hard trigger")
else:
reasons.append("portfolio-move")
soft_reasons.append("portfolio-move")
soft_score += 1.0
- details.append(f"组合净值变化 {portfolio_delta:.1%},阈值 {portfolio_trigger:.1%}")
+ details.append(f"Portfolio value moved {portfolio_delta:.1%}, threshold {portfolio_trigger:.1%}")
for pos in snapshot["positions"]:
symbol = pos["symbol"]
@@ -98,42 +97,42 @@ def analyze_trigger(snapshot: dict, state: dict):
reasons.append(f"price-move:{symbol}")
soft_reasons.append(f"price-move:{symbol}")
soft_score += 1.0 if actionable_position else 0.4
- details.append(f"{symbol} 价格变化 {price_move:.1%},阈值 {price_trigger:.1%}")
+ details.append(f"{symbol} price moved {price_move:.1%}, threshold {price_trigger:.1%}")
if cur_pnl is not None and prev_pnl is not None:
pnl_move = abs(cur_pnl - prev_pnl)
if pnl_move >= pnl_trigger:
reasons.append(f"pnl-move:{symbol}")
soft_reasons.append(f"pnl-move:{symbol}")
soft_score += 1.0 if actionable_position else 0.4
- details.append(f"{symbol} 盈亏变化 {pnl_move:.1%},阈值 {pnl_trigger:.1%}")
+ details.append(f"{symbol} PnL moved {pnl_move:.1%}, threshold {pnl_trigger:.1%}")
if cur_pnl is not None:
stop_band = -0.06 if actionable_position else -0.12
take_band = 0.14 if actionable_position else 0.25
if cur_pnl <= stop_band or cur_pnl >= take_band:
reasons.append(f"risk-band:{symbol}")
hard_reasons.append(f"risk-band:{symbol}")
- details.append(f"{symbol} 接近执行阈值,当前盈亏 {cur_pnl:.1%}")
+ details.append(f"{symbol} near execution threshold, current PnL {cur_pnl:.1%}")
if cur_pnl <= HARD_STOP_PCT:
reasons.append(f"hard-stop:{symbol}")
hard_reasons.append(f"hard-stop:{symbol}")
- details.append(f"{symbol} 盈亏超过 {HARD_STOP_PCT:.1%},触发紧急硬触发")
+ details.append(f"{symbol} PnL exceeded {HARD_STOP_PCT:.1%}, emergency hard trigger")
current_market = snapshot.get("market_regime", {})
if last_market_regime:
if current_market.get("btc_regime") != last_market_regime.get("btc_regime"):
reasons.append("btc-regime-change")
hard_reasons.append("btc-regime-change")
- details.append(f"BTC 由 {last_market_regime.get('btc_regime')} 切换为 {current_market.get('btc_regime')}")
+ details.append(f"BTC regime changed from {last_market_regime.get('btc_regime')} to {current_market.get('btc_regime')}")
if current_market.get("eth_regime") != last_market_regime.get("eth_regime"):
reasons.append("eth-regime-change")
hard_reasons.append("eth-regime-change")
- details.append(f"ETH 由 {last_market_regime.get('eth_regime')} 切换为 {current_market.get('eth_regime')}")
+ details.append(f"ETH regime changed from {last_market_regime.get('eth_regime')} to {current_market.get('eth_regime')}")
for cand in snapshot.get("top_candidates", []):
if cand.get("change_24h_pct", 0) >= HARD_MOON_PCT * 100:
reasons.append(f"hard-moon:{cand['symbol']}")
hard_reasons.append(f"hard-moon:{cand['symbol']}")
- details.append(f"候选币 {cand['symbol']} 24h 涨幅 {cand['change_24h_pct']:.1f}%,触发强势硬触发")
+ details.append(f"Candidate {cand['symbol']} 24h change {cand['change_24h_pct']:.1f}%, hard moon trigger")
candidate_weight = _candidate_weight(snapshot, profile)
@@ -151,7 +150,7 @@ def analyze_trigger(snapshot: dict, state: dict):
soft_reasons.append(f"new-leader-{band}:{cur_leader['symbol']}")
soft_score += candidate_weight * 0.7
details.append(
- f"{band} 层新榜首 {cur_leader['symbol']} 替代 {prev_leader['symbol']},score 比例 {score_ratio:.2f}"
+ f"{band} tier new leader {cur_leader['symbol']} replaced {prev_leader['symbol']}, score ratio {score_ratio:.2f}"
)
current_leader = snapshot.get("top_candidates", [{}])[0] if snapshot.get("top_candidates") else None
@@ -163,13 +162,13 @@ def analyze_trigger(snapshot: dict, state: dict):
soft_reasons.append("new-leader")
soft_score += candidate_weight
details.append(
- f"新候选币 {current_leader.get('symbol')} 领先上次榜首,score 比例 {score_ratio:.2f},阈值 {candidate_ratio_trigger:.2f}"
+ f"New candidate {current_leader.get('symbol')} leads previous top, score ratio {score_ratio:.2f}, threshold {candidate_ratio_trigger:.2f}"
)
elif current_leader and not last_top_candidate:
reasons.append("candidate-leader-init")
soft_reasons.append("candidate-leader-init")
soft_score += candidate_weight
- details.append(f"首次记录候选榜首 {current_leader.get('symbol')}")
+ details.append(f"First recorded candidate leader {current_leader.get('symbol')}")
def _signal_delta() -> float:
delta = 0.0
@@ -204,7 +203,8 @@ def analyze_trigger(snapshot: dict, state: dict):
if current_market.get("eth_regime") != last_market_regime.get("eth_regime"):
delta += 1.5
if last_portfolio_value not in (None, 0):
- portfolio_delta = abs(snapshot["portfolio_value_usdt"] - last_portfolio_value) / max(last_portfolio_value, 1e-9)
+ lpf = float(str(last_portfolio_value))
+ portfolio_delta = abs(snapshot["portfolio_value_usdt"] - lpf) / max(lpf, 1e-9)
if portfolio_delta >= 0.05:
delta += 1.0
last_trigger_hard_types = {r.split(":")[0] for r in (state.get("last_trigger_hard_reasons") or [])}
@@ -227,41 +227,41 @@ def analyze_trigger(snapshot: dict, state: dict):
last_at = parse_ts(last_hard_reasons_at.get(hr))
if last_at and now - last_at < dedup_window:
hard_reasons.remove(hr)
- details.append(f"{hr} 近期已触发,{HARD_REASON_DEDUP_MINUTES}分钟内去重")
+ details.append(f"{hr} triggered recently, deduplicated within {HARD_REASON_DEDUP_MINUTES} minutes")
hard_trigger = bool(hard_reasons)
if profile.get("dust_mode") and not hard_trigger and soft_score < soft_score_threshold + 1.0:
- details.append("微型资金/粉尘仓位模式:抬高软触发门槛,避免无意义分析")
+ details.append("Dust-mode portfolio: raising soft-trigger threshold to avoid noise")
if profile.get("dust_mode") and not profile.get("new_entries_allowed") and any(
r in {"new-leader", "candidate-leader-init"} for r in soft_reasons
):
- details.append("当前可用资金低于可执行阈值,新候选币仅做观察,不单独触发深度分析")
+ details.append("Available capital below executable threshold; new candidates are observation-only")
soft_score = max(0.0, soft_score - 0.75)
should_analyze = hard_trigger or soft_score >= soft_score_threshold
if cooldown_active and not hard_trigger and should_analyze:
should_analyze = False
- details.append(f"处于 {cooldown_minutes} 分钟冷却窗口,软触发先记录不升级")
+ details.append(f"In {cooldown_minutes} minute cooldown window; soft trigger logged but not escalated")
if cooldown_active and not hard_trigger and reasons and soft_score < soft_score_threshold:
- details.append(f"处于 {cooldown_minutes} 分钟冷却窗口,且软信号强度不足 ({soft_score:.2f} < {soft_score_threshold:.2f})")
+ details.append(f"In {cooldown_minutes} minute cooldown window with insufficient soft signal ({soft_score:.2f} < {soft_score_threshold:.2f})")
status = "deep_analysis_required" if should_analyze else "stable"
compact_lines = [
- f"状态: {status}",
- f"组合净值: ${snapshot['portfolio_value_usdt']:.4f} | 可用USDT: ${snapshot['free_usdt']:.4f}",
- f"本地时段: {snapshot['session']} | 时区: {snapshot['timezone']}",
- f"BTC/ETH: {market.get('btc_regime')} ({market.get('btc_24h_pct')}%), {market.get('eth_regime')} ({market.get('eth_24h_pct')}%) | 波动分数 {market.get('volatility_score')}",
- f"门控画像: capital={profile['capital_band']}, session={profile['session_mode']}, volatility={profile['volatility_mode']}, dust={profile['dust_mode']}",
- f"阈值: price={price_trigger:.1%}, pnl={pnl_trigger:.1%}, portfolio={portfolio_trigger:.1%}, candidate={candidate_ratio_trigger:.2f}, cooldown={effective_cooldown}m({cooldown_minutes}m基础), force={force_minutes}m",
- f"软信号分: {soft_score:.2f} / {soft_score_threshold:.2f}",
- f"信号变化度: {signal_delta:.1f}",
+ f"Status: {status}",
+ f"Portfolio: ${snapshot['portfolio_value_usdt']:.4f} | Free USDT: ${snapshot['free_usdt']:.4f}",
+ f"Session: {snapshot['session']} | TZ: {snapshot['timezone']}",
+ f"BTC/ETH: {market.get('btc_regime')} ({market.get('btc_24h_pct')}%), {market.get('eth_regime')} ({market.get('eth_24h_pct')}%) | Volatility score {market.get('volatility_score')}",
+ f"Profile: capital={profile['capital_band']}, session={profile['session_mode']}, volatility={profile['volatility_mode']}, dust={profile['dust_mode']}",
+ f"Thresholds: price={price_trigger:.1%}, pnl={pnl_trigger:.1%}, portfolio={portfolio_trigger:.1%}, candidate={candidate_ratio_trigger:.2f}, cooldown={effective_cooldown}m({cooldown_minutes}m base), force={force_minutes}m",
+ f"Soft score: {soft_score:.2f} / {soft_score_threshold:.2f}",
+ f"Signal delta: {signal_delta:.1f}",
]
if snapshot["positions"]:
- compact_lines.append("持仓:")
+ compact_lines.append("Positions:")
for pos in snapshot["positions"][:4]:
pnl = pos.get("pnl_pct")
pnl_text = f"{pnl:+.1%}" if pnl is not None else "n/a"
@@ -269,9 +269,9 @@ def analyze_trigger(snapshot: dict, state: dict):
f"- {pos['symbol']}: qty={pos['quantity']}, px={pos.get('last_price')}, pnl={pnl_text}, value=${pos.get('market_value_usdt')}"
)
else:
- compact_lines.append("持仓: 当前无现货仓位")
+ compact_lines.append("Positions: no spot positions currently")
if snapshot["top_candidates"]:
- compact_lines.append("候选榜:")
+ compact_lines.append("Candidates:")
for cand in snapshot["top_candidates"]:
compact_lines.append(
f"- {cand['symbol']}: score={cand['score']}, 24h={cand['change_24h_pct']}%, vol=${cand['volume_24h']}"
@@ -279,13 +279,13 @@ def analyze_trigger(snapshot: dict, state: dict):
layers = snapshot.get("top_candidates_layers", {})
for band, band_cands in layers.items():
if band_cands:
- compact_lines.append(f"{band} 层:")
+ compact_lines.append(f"{band} tier:")
for cand in band_cands:
compact_lines.append(
f"- {cand['symbol']}: score={cand['score']}, 24h={cand['change_24h_pct']}%, vol=${cand['volume_24h']}"
)
if details:
- compact_lines.append("触发说明:")
+ compact_lines.append("Trigger notes:")
for item in details:
compact_lines.append(f"- {item}")
diff --git a/src/coinhunter/smart_executor.py b/src/coinhunter/smart_executor.py
index 3154971..64b190a 100644
--- a/src/coinhunter/smart_executor.py
+++ b/src/coinhunter/smart_executor.py
@@ -11,8 +11,6 @@ from __future__ import annotations
import sys
from importlib import import_module
-from .services.smart_executor_service import run as _run_service
-
_EXPORT_MAP = {
"PATHS": (".runtime", "get_runtime_paths"),
"ENV_FILE": (".runtime", "get_runtime_paths"),
@@ -39,6 +37,7 @@ _EXPORT_MAP = {
"save_executions": (".services.execution_state", "save_executions"),
"load_positions": (".services.portfolio_service", "load_positions"),
"save_positions": (".services.portfolio_service", "save_positions"),
+ "update_positions": (".services.portfolio_service", "update_positions"),
"upsert_position": (".services.portfolio_service", "upsert_position"),
"reconcile_positions_with_exchange": (".services.portfolio_service", "reconcile_positions_with_exchange"),
"get_exchange": (".services.exchange_service", "get_exchange"),
@@ -58,6 +57,9 @@ _EXPORT_MAP = {
"action_rebalance": (".services.trade_execution", "action_rebalance"),
"command_status": (".services.trade_execution", "command_status"),
"command_balances": (".services.trade_execution", "command_balances"),
+ "command_orders": (".services.trade_execution", "command_orders"),
+ "command_order_status": (".services.trade_execution", "command_order_status"),
+ "command_cancel": (".services.trade_execution", "command_cancel"),
}
__all__ = sorted(set(_EXPORT_MAP) | {"ENV_FILE", "PATHS", "load_env", "main"})
@@ -93,6 +95,7 @@ def load_env():
def main(argv=None):
+ from .services.smart_executor_service import run as _run_service
return _run_service(sys.argv[1:] if argv is None else argv)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_check_api.py b/tests/test_check_api.py
new file mode 100644
index 0000000..d633ee9
--- /dev/null
+++ b/tests/test_check_api.py
@@ -0,0 +1,70 @@
+"""Tests for check_api command."""
+
+import json
+from unittest.mock import MagicMock, patch
+
+from coinhunter.commands import check_api
+
+
+class TestMain:
+ def test_missing_api_key(self, monkeypatch, capsys):
+ monkeypatch.setenv("BINANCE_API_KEY", "")
+ monkeypatch.setenv("BINANCE_API_SECRET", "secret")
+ rc = check_api.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert out["ok"] is False
+ assert "BINANCE_API_KEY" in out["error"]
+
+ def test_missing_api_secret(self, monkeypatch, capsys):
+ monkeypatch.setenv("BINANCE_API_KEY", "key")
+ monkeypatch.setenv("BINANCE_API_SECRET", "")
+ rc = check_api.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert out["ok"] is False
+ assert "BINANCE_API_SECRET" in out["error"]
+
+ def test_placholder_api_key(self, monkeypatch, capsys):
+ monkeypatch.setenv("BINANCE_API_KEY", "your_api_key")
+ monkeypatch.setenv("BINANCE_API_SECRET", "secret")
+ rc = check_api.main()
+ assert rc == 1
+
+ def test_balance_fetch_failure(self, monkeypatch, capsys):
+ monkeypatch.setenv("BINANCE_API_KEY", "key")
+ monkeypatch.setenv("BINANCE_API_SECRET", "secret")
+ mock_ex = MagicMock()
+ mock_ex.fetch_balance.side_effect = Exception("Network error")
+ with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex):
+ rc = check_api.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert "Failed to connect" in out["error"]
+
+ def test_success_with_spot_trading(self, monkeypatch, capsys):
+ monkeypatch.setenv("BINANCE_API_KEY", "key")
+ monkeypatch.setenv("BINANCE_API_SECRET", "secret")
+ mock_ex = MagicMock()
+ mock_ex.fetch_balance.return_value = {"USDT": 100.0}
+ mock_ex.sapi_get_account_api_restrictions.return_value = {"enableSpotTrading": True}
+ with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex):
+ rc = check_api.main()
+ assert rc == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["ok"] is True
+ assert out["read_permission"] is True
+ assert out["spot_trading_enabled"] is True
+
+ def test_success_restrictions_query_fails(self, monkeypatch, capsys):
+ monkeypatch.setenv("BINANCE_API_KEY", "key")
+ monkeypatch.setenv("BINANCE_API_SECRET", "secret")
+ mock_ex = MagicMock()
+ mock_ex.fetch_balance.return_value = {"USDT": 100.0}
+ mock_ex.sapi_get_account_api_restrictions.side_effect = Exception("no permission")
+ with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex):
+ rc = check_api.main()
+ assert rc == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["spot_trading_enabled"] is None
+ assert "may be null" in out["note"]
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..e760ff5
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,58 @@
+"""Tests for CLI routing and parser behavior."""
+
+import pytest
+
+from coinhunter.cli import ALIASES, MODULE_MAP, build_parser, run_python_module
+
+
+class TestAliases:
+ def test_all_aliases_resolve_to_canonical(self):
+ for alias, canonical in ALIASES.items():
+ assert canonical in MODULE_MAP, f"alias {alias!r} points to missing canonical {canonical!r}"
+
+ def test_no_alias_is_itself_an_alias_loop(self):
+ for alias in ALIASES:
+ assert alias not in ALIASES.values() or alias in MODULE_MAP
+
+
+class TestModuleMap:
+ def test_all_modules_exist(self):
+ import importlib
+
+ for command, module_name in MODULE_MAP.items():
+ full = f"coinhunter.{module_name}"
+ mod = importlib.import_module(full)
+ assert hasattr(mod, "main"), f"{full} missing main()"
+
+
+class TestBuildParser:
+ def test_help_includes_commands(self):
+ parser = build_parser()
+ help_text = parser.format_help()
+ assert "coinhunter diag" in help_text
+ assert "coinhunter exec" in help_text
+
+ def test_parses_command_and_args(self):
+ parser = build_parser()
+ ns = parser.parse_args(["exec", "bal"])
+ assert ns.command == "exec"
+ assert "bal" in ns.args
+
+ def test_version_action_exits(self):
+ parser = build_parser()
+ with pytest.raises(SystemExit) as exc:
+ parser.parse_args(["--version"])
+ assert exc.value.code == 0
+
+
+class TestRunPythonModule:
+ def test_runs_module_main_and_returns_int(self):
+ result = run_python_module("commands.paths", [], "coinhunter paths")
+ assert result == 0
+
+ def test_mutates_sys_argv(self):
+ import sys
+
+ original = sys.argv[:]
+ run_python_module("commands.paths", ["--help"], "coinhunter paths")
+ assert sys.argv == original
diff --git a/tests/test_exchange_service.py b/tests/test_exchange_service.py
new file mode 100644
index 0000000..165f213
--- /dev/null
+++ b/tests/test_exchange_service.py
@@ -0,0 +1,98 @@
+"""Tests for exchange helpers and caching."""
+
+import pytest
+
+from coinhunter.services import exchange_service as es
+
+
+class TestNormSymbol:
+ def test_already_normalized(self):
+ assert es.norm_symbol("BTC/USDT") == "BTC/USDT"
+
+ def test_raw_symbol(self):
+ assert es.norm_symbol("BTCUSDT") == "BTC/USDT"
+
+ def test_lowercase(self):
+ assert es.norm_symbol("btcusdt") == "BTC/USDT"
+
+ def test_dash_separator(self):
+ assert es.norm_symbol("BTC-USDT") == "BTC/USDT"
+
+ def test_underscore_separator(self):
+ assert es.norm_symbol("BTC_USDT") == "BTC/USDT"
+
+ def test_unsupported_symbol(self):
+ with pytest.raises(ValueError):
+ es.norm_symbol("BTC")
+
+
+class TestStorageSymbol:
+ def test_converts_to_flat(self):
+ assert es.storage_symbol("BTC/USDT") == "BTCUSDT"
+
+ def test_raw_input(self):
+ assert es.storage_symbol("ETHUSDT") == "ETHUSDT"
+
+
+class TestFloorToStep:
+ def test_no_step(self):
+ assert es.floor_to_step(1.234, 0) == 1.234
+
+ def test_floors_to_step(self):
+ assert es.floor_to_step(1.234, 0.1) == pytest.approx(1.2)
+
+ def test_exact_multiple(self):
+ assert es.floor_to_step(12, 1) == 12
+
+
+class TestGetExchangeCaching:
+ def test_returns_cached_instance(self, monkeypatch):
+ monkeypatch.setenv("BINANCE_API_KEY", "test_key")
+ monkeypatch.setenv("BINANCE_API_SECRET", "test_secret")
+
+ class FakeEx:
+ def load_markets(self):
+ pass
+
+ # Reset cache and patch ccxt.binance
+ original_cache = es._exchange_cache
+ original_cached_at = es._exchange_cached_at
+ try:
+ es._exchange_cache = None
+ es._exchange_cached_at = None
+ monkeypatch.setattr(es.ccxt, "binance", lambda *a, **kw: FakeEx())
+
+ first = es.get_exchange()
+ second = es.get_exchange()
+ assert first is second
+
+ third = es.get_exchange(force_new=True)
+ assert third is not first
+ finally:
+ es._exchange_cache = original_cache
+ es._exchange_cached_at = original_cached_at
+
+ def test_cache_expires_after_ttl(self, monkeypatch):
+ monkeypatch.setenv("BINANCE_API_KEY", "test_key")
+ monkeypatch.setenv("BINANCE_API_SECRET", "test_secret")
+
+ class FakeEx:
+ def load_markets(self):
+ pass
+
+ original_cache = es._exchange_cache
+ original_cached_at = es._exchange_cached_at
+ try:
+ es._exchange_cache = None
+ es._exchange_cached_at = None
+ monkeypatch.setattr(es.ccxt, "binance", lambda *a, **kw: FakeEx())
+ monkeypatch.setattr(es, "load_env", lambda: None)
+
+ first = es.get_exchange()
+ # Simulate time passing beyond TTL
+ es._exchange_cached_at -= es.CACHE_TTL_SECONDS + 1
+ second = es.get_exchange()
+ assert first is not second
+ finally:
+ es._exchange_cache = original_cache
+ es._exchange_cached_at = original_cached_at
diff --git a/tests/test_external_gate.py b/tests/test_external_gate.py
new file mode 100644
index 0000000..f04bae0
--- /dev/null
+++ b/tests/test_external_gate.py
@@ -0,0 +1,203 @@
+"""Tests for external_gate configurable hook."""
+
+import json
+from unittest.mock import MagicMock, patch
+
+from coinhunter.commands import external_gate
+
+
+class TestResolveTriggerCommand:
+ def test_missing_config_means_disabled(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ # no config file exists
+ cmd = external_gate._resolve_trigger_command(paths)
+ assert cmd is None
+
+ def test_uses_explicit_list_from_config(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({
+ "external_gate": {"trigger_command": ["my-scheduler", "run", "job-123"]}
+ }), encoding="utf-8")
+ cmd = external_gate._resolve_trigger_command(paths)
+ assert cmd == ["my-scheduler", "run", "job-123"]
+
+ def test_null_means_disabled(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({
+ "external_gate": {"trigger_command": None}
+ }), encoding="utf-8")
+ cmd = external_gate._resolve_trigger_command(paths)
+ assert cmd is None
+
+ def test_empty_list_means_disabled(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({
+ "external_gate": {"trigger_command": []}
+ }), encoding="utf-8")
+ cmd = external_gate._resolve_trigger_command(paths)
+ assert cmd is None
+
+ def test_string_gets_wrapped(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({
+ "external_gate": {"trigger_command": "my-script.sh"}
+ }), encoding="utf-8")
+ cmd = external_gate._resolve_trigger_command(paths)
+ assert cmd == ["my-script.sh"]
+
+ def test_unexpected_type_returns_none_and_warns(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({
+ "external_gate": {"trigger_command": 42}
+ }), encoding="utf-8")
+ cmd = external_gate._resolve_trigger_command(paths)
+ assert cmd is None
+
+
+class TestMain:
+ def test_already_running(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.state_dir.mkdir(parents=True, exist_ok=True)
+ # Acquire the lock in this process so main() sees it as busy
+ import fcntl
+ with open(paths.external_gate_lock, "w", encoding="utf-8") as f:
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
+ rc = external_gate.main()
+ assert rc == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "already_running"
+
+ def test_precheck_failure(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ fake_result = MagicMock(returncode=1, stdout="err", stderr="")
+ with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
+ rc = external_gate.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "precheck_failed"
+
+ def test_precheck_parse_error(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ fake_result = MagicMock(returncode=0, stdout="not-json", stderr="")
+ with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
+ rc = external_gate.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "precheck_parse_error"
+
+ def test_precheck_not_ok(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ fake_result = MagicMock(returncode=0, stdout=json.dumps({"ok": False}), stderr="")
+ with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
+ rc = external_gate.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "precheck_not_ok"
+
+ def test_no_trigger(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ fake_result = MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": False}), stderr="")
+ with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
+ rc = external_gate.main()
+ assert rc == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "no_trigger"
+
+ def test_already_queued(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ fake_result = MagicMock(
+ returncode=0,
+ stdout=json.dumps({"ok": True, "should_analyze": True, "run_requested": True, "run_requested_at": "2024-01-01T00:00:00Z"}),
+ stderr="",
+ )
+ with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
+ rc = external_gate.main()
+ assert rc == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "already_queued"
+
+ def test_trigger_disabled(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": None}}), encoding="utf-8")
+ # First call is precheck, second is mark-run-requested
+ responses = [
+ MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""),
+ MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""),
+ ]
+ with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
+ rc = external_gate.main()
+ assert rc == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "trigger_disabled"
+
+ def test_trigger_success(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["echo", "ok"]}}), encoding="utf-8")
+ responses = [
+ MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True, "reasons": ["price-move"]}), stderr=""),
+ MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""),
+ MagicMock(returncode=0, stdout="triggered", stderr=""),
+ ]
+ with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
+ rc = external_gate.main()
+ assert rc == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["triggered"] is True
+ assert out["reason"] == "price-move"
+
+ def test_trigger_command_failure(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["false"]}}), encoding="utf-8")
+ responses = [
+ MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""),
+ MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""),
+ MagicMock(returncode=1, stdout="", stderr="fail"),
+ ]
+ with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
+ rc = external_gate.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "trigger_failed"
+
+ def test_mark_run_requested_failure(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = external_gate._paths()
+ paths.root.mkdir(parents=True, exist_ok=True)
+ paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["echo", "ok"]}}), encoding="utf-8")
+ responses = [
+ MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""),
+ MagicMock(returncode=1, stdout="err", stderr=""),
+ ]
+ with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
+ rc = external_gate.main()
+ assert rc == 1
+ out = json.loads(capsys.readouterr().out)
+ assert out["reason"] == "mark_failed"
diff --git a/tests/test_review_service.py b/tests/test_review_service.py
new file mode 100644
index 0000000..1703956
--- /dev/null
+++ b/tests/test_review_service.py
@@ -0,0 +1,194 @@
+"""Tests for review_service."""
+
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+from coinhunter.services import review_service as rs
+
+
+class TestAnalyzeTrade:
+ def test_buy_good_when_price_up(self):
+ ex = None
+ trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
+ with patch.object(rs, "fetch_current_price", return_value=52000.0):
+ result = rs.analyze_trade(trade, ex)
+ assert result["action"] == "BUY"
+ assert result["pnl_estimate_pct"] == 4.0
+ assert result["outcome_assessment"] == "good"
+
+ def test_buy_bad_when_price_down(self):
+ trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
+ with patch.object(rs, "fetch_current_price", return_value=48000.0):
+ result = rs.analyze_trade(trade, None)
+ assert result["pnl_estimate_pct"] == -4.0
+ assert result["outcome_assessment"] == "bad"
+
+ def test_sell_all_missed_when_price_up(self):
+ trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "SELL_ALL"}
+ with patch.object(rs, "fetch_current_price", return_value=53000.0):
+ result = rs.analyze_trade(trade, None)
+ assert result["pnl_estimate_pct"] == -6.0
+ assert result["outcome_assessment"] == "missed"
+
+ def test_neutral_when_small_move(self):
+ trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
+ with patch.object(rs, "fetch_current_price", return_value=50100.0):
+ result = rs.analyze_trade(trade, None)
+ assert result["outcome_assessment"] == "neutral"
+
+ def test_none_when_no_price(self):
+ trade = {"symbol": "BTC/USDT", "action": "BUY"}
+ result = rs.analyze_trade(trade, None)
+ assert result["pnl_estimate_pct"] is None
+ assert result["outcome_assessment"] == "neutral"
+
+
+class TestAnalyzeHoldPasses:
+ def test_finds_passed_opportunities_that_rise(self):
+ decisions = [
+ {
+ "timestamp": "2024-01-01T00:00:00Z",
+ "decision": "HOLD",
+ "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
+ "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
+ }
+ ]
+ with patch.object(rs, "fetch_current_price", return_value=110.0):
+ result = rs.analyze_hold_passes(decisions, None)
+ assert len(result) == 1
+ assert result[0]["symbol"] == "ETH/USDT"
+ assert result[0]["change_pct"] == 10.0
+
+ def test_ignores_non_pass_verdicts(self):
+ decisions = [
+ {
+ "decision": "HOLD",
+ "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "BUY"}]},
+ "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
+ }
+ ]
+ with patch.object(rs, "fetch_current_price", return_value=110.0):
+ result = rs.analyze_hold_passes(decisions, None)
+ assert result == []
+
+ def test_ignores_small_moves(self):
+ decisions = [
+ {
+ "decision": "HOLD",
+ "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
+ "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
+ }
+ ]
+ with patch.object(rs, "fetch_current_price", return_value=102.0):
+ result = rs.analyze_hold_passes(decisions, None)
+ assert result == []
+
+
+class TestAnalyzeCashMisses:
+ def test_finds_cash_sit_misses(self):
+ decisions = [
+ {
+ "timestamp": "2024-01-01T00:00:00Z",
+ "balances": {"USDT": 100.0},
+ "market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}},
+ }
+ ]
+ with patch.object(rs, "fetch_current_price", return_value=54000.0):
+ result = rs.analyze_cash_misses(decisions, None)
+ assert len(result) == 1
+ assert result[0]["symbol"] == "BTCUSDT"
+ assert result[0]["change_pct"] == 8.0
+
+ def test_requires_mostly_usdt(self):
+ decisions = [
+ {
+ "balances": {"USDT": 10.0, "BTC": 90.0},
+ "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
+ }
+ ]
+ with patch.object(rs, "fetch_current_price", return_value=200.0):
+ result = rs.analyze_cash_misses(decisions, None)
+ assert result == []
+
+ def test_dedupes_by_best_change(self):
+ decisions = [
+ {
+ "timestamp": "2024-01-01T00:00:00Z",
+ "balances": {"USDT": 100.0},
+ "market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}},
+ },
+ {
+ "timestamp": "2024-01-01T01:00:00Z",
+ "balances": {"USDT": 100.0},
+ "market_snapshot": {"BTCUSDT": {"lastPrice": 52000.0}},
+ },
+ ]
+ with patch.object(rs, "fetch_current_price", return_value=56000.0):
+ result = rs.analyze_cash_misses(decisions, None)
+ assert len(result) == 1
+ # best change is from 50000 -> 56000 = 12%
+ assert result[0]["change_pct"] == 12.0
+
+
+class TestGenerateReview:
+ def test_empty_period(self, monkeypatch):
+ monkeypatch.setattr(rs, "get_logs_last_n_hours", lambda log_type, hours: [])
+ review = rs.generate_review(1)
+ assert review["total_decisions"] == 0
+ assert review["total_trades"] == 0
+ assert any("No decisions or trades" in i for i in review["insights"])
+
+ def test_with_trades_and_decisions(self, monkeypatch):
+ trades = [
+ {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY", "timestamp": "2024-01-01T00:00:00Z"}
+ ]
+ decisions = [
+ {
+ "timestamp": "2024-01-01T00:00:00Z",
+ "decision": "HOLD",
+ "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
+ "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
+ }
+ ]
+ errors = [{"message": "oops"}]
+
+ def _logs(log_type, hours):
+ return {"decisions": decisions, "trades": trades, "errors": errors}.get(log_type, [])
+
+ monkeypatch.setattr(rs, "get_logs_last_n_hours", _logs)
+ monkeypatch.setattr(rs, "fetch_current_price", lambda ex, sym: 110.0 if "ETH" in sym else 52000.0)
+ review = rs.generate_review(1)
+ assert review["total_trades"] == 1
+ assert review["total_decisions"] == 1
+ assert review["stats"]["good_decisions"] == 1
+ assert review["stats"]["missed_hold_passes"] == 1
+ assert any("execution/system errors" in i for i in review["insights"])
+
+
+class TestSaveReview:
+ def test_writes_file(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ review = {"review_timestamp": "2024-01-01T00:00:00+08:00", "review_period_hours": 1}
+ path = rs.save_review(review)
+ saved = json.loads(Path(path).read_text(encoding="utf-8"))
+ assert saved["review_period_hours"] == 1
+
+
+class TestPrintReview:
+ def test_outputs_report(self, capsys):
+ review = {
+ "review_timestamp": "2024-01-01T00:00:00+08:00",
+ "review_period_hours": 1,
+ "total_decisions": 2,
+ "total_trades": 1,
+ "total_errors": 0,
+ "stats": {"good_decisions": 1, "neutral_decisions": 0, "bad_decisions": 0, "missed_opportunities": 0},
+ "insights": ["Insight A"],
+ "recommendations": ["Rec B"],
+ }
+ rs.print_review(review)
+ captured = capsys.readouterr()
+ assert "Coin Hunter Review Report" in captured.out
+ assert "Insight A" in captured.out
+ assert "Rec B" in captured.out
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
new file mode 100644
index 0000000..749366e
--- /dev/null
+++ b/tests/test_runtime.py
@@ -0,0 +1,63 @@
+"""Tests for runtime path resolution."""
+
+from pathlib import Path
+
+import pytest
+
+from coinhunter.runtime import RuntimePaths, ensure_runtime_dirs, get_runtime_paths, mask_secret
+
+
+class TestGetRuntimePaths:
+ def test_defaults_point_to_home_dot_coinhunter(self):
+ paths = get_runtime_paths()
+ assert paths.root == Path.home() / ".coinhunter"
+
+ def test_respects_coinhunter_home_env(self, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", "~/custom_ch")
+ paths = get_runtime_paths()
+ assert paths.root == Path.home() / "custom_ch"
+
+ def test_respects_hermes_home_env(self, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", "~/custom_hermes")
+ paths = get_runtime_paths()
+ assert paths.hermes_home == Path.home() / "custom_hermes"
+ assert paths.env_file == Path.home() / "custom_hermes" / ".env"
+
+ def test_returns_frozen_dataclass(self):
+ paths = get_runtime_paths()
+ assert isinstance(paths, RuntimePaths)
+ with pytest.raises(AttributeError):
+ paths.root = Path("/tmp")
+
+ def test_as_dict_returns_strings(self):
+ paths = get_runtime_paths()
+ d = paths.as_dict()
+ assert isinstance(d, dict)
+ assert all(isinstance(v, str) for v in d.values())
+ assert "root" in d
+
+
+class TestEnsureRuntimeDirs:
+ def test_creates_directories(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
+ paths = get_runtime_paths()
+ returned = ensure_runtime_dirs(paths)
+ assert returned.root.exists()
+ assert returned.state_dir.exists()
+ assert returned.logs_dir.exists()
+ assert returned.cache_dir.exists()
+ assert returned.reviews_dir.exists()
+
+
+class TestMaskSecret:
+ def test_empty_string(self):
+ assert mask_secret("") == ""
+
+ def test_short_value(self):
+ assert mask_secret("ab") == "**"
+
+ def test_masks_all_but_tail(self):
+ assert mask_secret("supersecret", tail=4) == "*******cret"
+
+ def test_none_returns_empty(self):
+ assert mask_secret(None) == ""
diff --git a/tests/test_smart_executor_service.py b/tests/test_smart_executor_service.py
new file mode 100644
index 0000000..6ef1b96
--- /dev/null
+++ b/tests/test_smart_executor_service.py
@@ -0,0 +1,98 @@
+"""Tests for smart executor deduplication and routing."""
+
+
+from coinhunter.services import exchange_service
+from coinhunter.services import smart_executor_service as ses
+
+
+class FakeArgs:
+ def __init__(self, command="buy", symbol="BTCUSDT", amount_usdt=50, dry_run=False,
+ decision_id=None, analysis=None, reasoning=None, order_id=None,
+ from_symbol=None, to_symbol=None):
+ self.command = command
+ self.symbol = symbol
+ self.amount_usdt = amount_usdt
+ self.dry_run = dry_run
+ self.decision_id = decision_id
+ self.analysis = analysis
+ self.reasoning = reasoning
+ self.order_id = order_id
+ self.from_symbol = from_symbol
+ self.to_symbol = to_symbol
+
+
+class TestDeduplication:
+ def test_skips_duplicate_mutating_action(self, monkeypatch, capsys):
+ monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="buy"), ["BTCUSDT", "50"]))
+ monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "success"})
+ get_exchange_calls = []
+ monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex")
+
+ result = ses.run(["buy", "BTCUSDT", "50"])
+ assert result == 0
+ assert get_exchange_calls == []
+ captured = capsys.readouterr()
+ assert "already executed successfully" in captured.err
+
+ def test_allows_duplicate_read_only_action(self, monkeypatch):
+ monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="balances"), ["balances"]))
+ monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "success"})
+ monkeypatch.setattr(ses, "command_balances", lambda ex: None)
+ get_exchange_calls = []
+ monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex")
+
+ result = ses.run(["bal"])
+ assert result == 0
+ assert len(get_exchange_calls) == 1
+
+ def test_allows_retry_after_failure(self, monkeypatch):
+ monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="buy"), ["BTCUSDT", "50"]))
+ monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "failed"})
+ monkeypatch.setattr(ses, "record_execution_state", lambda did, state: None)
+ monkeypatch.setattr(ses, "build_decision_context", lambda ex, action, tail, did: {})
+ monkeypatch.setattr(ses, "action_buy", lambda *a, **k: {"id": "123"})
+ monkeypatch.setattr(ses, "print_json", lambda d: None)
+ get_exchange_calls = []
+ monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex")
+
+ result = ses.run(["buy", "BTCUSDT", "50"])
+ assert result == 0
+ assert len(get_exchange_calls) == 1
+
+
+class TestReadOnlyRouting:
+ def test_routes_balances(self, monkeypatch):
+ monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="balances"), ["balances"]))
+ monkeypatch.setattr(ses, "get_execution_state", lambda did: None)
+ routed = []
+ monkeypatch.setattr(ses, "command_balances", lambda ex: routed.append("balances"))
+ monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex")
+
+ result = ses.run(["balances"])
+ assert result == 0
+ assert routed == ["balances"]
+
+ def test_routes_orders(self, monkeypatch):
+ monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="orders"), ["orders"]))
+ monkeypatch.setattr(ses, "get_execution_state", lambda did: None)
+ routed = []
+ monkeypatch.setattr(ses, "command_orders", lambda ex: routed.append("orders"))
+ monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex")
+
+ result = ses.run(["orders"])
+ assert result == 0
+ assert routed == ["orders"]
+
+ def test_routes_order_status(self, monkeypatch):
+ monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (
+ FakeArgs(command="order-status", symbol="BTCUSDT", order_id="123"),
+ ["order-status", "BTCUSDT", "123"]
+ ))
+ monkeypatch.setattr(ses, "get_execution_state", lambda did: None)
+ routed = []
+ monkeypatch.setattr(ses, "command_order_status", lambda ex, sym, oid: routed.append((sym, oid)))
+ monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex")
+
+ result = ses.run(["order-status", "BTCUSDT", "123"])
+ assert result == 0
+ assert routed == [("BTCUSDT", "123")]
diff --git a/tests/test_state_manager.py b/tests/test_state_manager.py
new file mode 100644
index 0000000..a01f2fe
--- /dev/null
+++ b/tests/test_state_manager.py
@@ -0,0 +1,99 @@
+"""Tests for state_manager precheck utilities."""
+
+from datetime import timedelta, timezone
+
+from coinhunter.services.state_manager import (
+ clear_run_request_fields,
+ sanitize_state_for_stale_triggers,
+ update_state_after_observation,
+)
+from coinhunter.services.time_utils import utc_iso, utc_now
+
+
+class TestClearRunRequestFields:
+ def test_removes_run_fields(self):
+ state = {"run_requested_at": utc_iso(), "run_request_note": "test"}
+ clear_run_request_fields(state)
+ assert "run_requested_at" not in state
+ assert "run_request_note" not in state
+
+
+class TestSanitizeStateForStaleTriggers:
+ def test_no_changes_when_clean(self):
+ state = {"pending_trigger": False}
+ result = sanitize_state_for_stale_triggers(state)
+ assert result["pending_trigger"] is False
+ assert result["_stale_recovery_notes"] == []
+
+ def test_clears_completed_run_request(self):
+ state = {
+ "pending_trigger": True,
+ "run_requested_at": utc_iso(),
+ "last_deep_analysis_at": utc_iso(),
+ }
+ result = sanitize_state_for_stale_triggers(state)
+ assert result["pending_trigger"] is False
+ assert "run_requested_at" not in result
+ assert any("completed run_requested" in note for note in result["_stale_recovery_notes"])
+
+ def test_clears_stale_run_request(self):
+ old = (utc_now() - timedelta(minutes=60)).replace(tzinfo=timezone.utc).isoformat()
+ state = {
+ "pending_trigger": False,
+ "run_requested_at": old,
+ }
+ result = sanitize_state_for_stale_triggers(state)
+ assert "run_requested_at" not in result
+ assert any("stale run_requested" in note for note in result["_stale_recovery_notes"])
+
+ def test_recovers_stale_pending_trigger(self):
+ old = (utc_now() - timedelta(minutes=60)).replace(tzinfo=timezone.utc).isoformat()
+ state = {
+ "pending_trigger": True,
+ "last_triggered_at": old,
+ }
+ result = sanitize_state_for_stale_triggers(state)
+ assert result["pending_trigger"] is False
+ assert any("stale pending_trigger" in note for note in result["_stale_recovery_notes"])
+
+
+class TestUpdateStateAfterObservation:
+ def test_updates_last_observed_fields(self):
+ state = {}
+ snapshot = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "snapshot_hash": "abc",
+ "positions_hash": "pos123",
+ "candidates_hash": "can456",
+ "portfolio_value_usdt": 100.0,
+ "market_regime": "neutral",
+ "positions": [],
+ "top_candidates": [],
+ }
+ analysis = {"should_analyze": False, "details": [], "adaptive_profile": {}}
+ result = update_state_after_observation(state, snapshot, analysis)
+ assert result["last_observed_at"] == snapshot["generated_at"]
+ assert result["last_snapshot_hash"] == "abc"
+
+ def test_sets_pending_trigger_when_should_analyze(self):
+ state = {}
+ snapshot = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "snapshot_hash": "abc",
+ "positions_hash": "pos123",
+ "candidates_hash": "can456",
+ "portfolio_value_usdt": 100.0,
+ "market_regime": "neutral",
+ "positions": [],
+ "top_candidates": [],
+ }
+ analysis = {
+ "should_analyze": True,
+ "details": ["price move"],
+ "adaptive_profile": {},
+ "hard_reasons": ["moon"],
+ "signal_delta": 1.5,
+ }
+ result = update_state_after_observation(state, snapshot, analysis)
+ assert result["pending_trigger"] is True
+ assert result["pending_reasons"] == ["price move"]
diff --git a/tests/test_trade_execution.py b/tests/test_trade_execution.py
new file mode 100644
index 0000000..15ede42
--- /dev/null
+++ b/tests/test_trade_execution.py
@@ -0,0 +1,160 @@
+"""Tests for trade execution dry-run paths."""
+
+import pytest
+
+from coinhunter.services import trade_execution as te
+from coinhunter.services.trade_common import set_dry_run
+
+
+class TestMarketSellDryRun:
+ def test_returns_dry_run_order(self, monkeypatch):
+ set_dry_run(True)
+ monkeypatch.setattr(
+ te, "prepare_sell_quantity",
+ lambda ex, sym, qty: ("BTC/USDT", 0.5, 60000.0, 30000.0)
+ )
+ order = te.market_sell(None, "BTCUSDT", 0.5, "dec-123")
+ assert order["id"] == "dry-sell-dec-123"
+ assert order["symbol"] == "BTC/USDT"
+ assert order["amount"] == 0.5
+ assert order["status"] == "closed"
+ set_dry_run(False)
+
+
+class TestMarketBuyDryRun:
+ def test_returns_dry_run_order(self, monkeypatch):
+ set_dry_run(True)
+ monkeypatch.setattr(
+ te, "prepare_buy_quantity",
+ lambda ex, sym, amt: ("ETH/USDT", 1.0, 3000.0, 3000.0)
+ )
+ order = te.market_buy(None, "ETHUSDT", 100, "dec-456")
+ assert order["id"] == "dry-buy-dec-456"
+ assert order["symbol"] == "ETH/USDT"
+ assert order["amount"] == 1.0
+ assert order["status"] == "closed"
+ set_dry_run(False)
+
+
+class TestActionSellAll:
+ def test_raises_when_balance_zero(self, monkeypatch):
+ monkeypatch.setattr(te, "fetch_balances", lambda ex: {"BTC": 0})
+ with pytest.raises(RuntimeError, match="balance is zero"):
+ te.action_sell_all(None, "BTCUSDT", "dec-789", {})
+
+ def test_dry_run_does_not_reconcile(self, monkeypatch):
+ set_dry_run(True)
+ monkeypatch.setattr(te, "fetch_balances", lambda ex: {"BTC": 0.5})
+ monkeypatch.setattr(
+ te, "market_sell",
+ lambda ex, sym, qty, did: {"id": "dry-1", "amount": qty, "price": 60000.0, "cost": 30000.0, "status": "closed"}
+ )
+ monkeypatch.setattr(te, "reconcile_positions_with_exchange", lambda ex, hint=None: ([], {}))
+ monkeypatch.setattr(te, "log_trade", lambda *a, **k: None)
+ monkeypatch.setattr(te, "log_decision", lambda *a, **k: None)
+
+ result = te.action_sell_all(None, "BTCUSDT", "dec-789", {"analysis": "test"})
+ assert result["id"] == "dry-1"
+ set_dry_run(False)
+
+
+class TestActionBuy:
+ def test_raises_when_insufficient_usdt(self, monkeypatch):
+ monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 10.0})
+ with pytest.raises(RuntimeError, match="Insufficient USDT"):
+ te.action_buy(None, "BTCUSDT", 50, "dec-abc", {})
+
+ def test_dry_run_skips_save(self, monkeypatch):
+ set_dry_run(True)
+ monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0})
+ monkeypatch.setattr(
+ te, "market_buy",
+ lambda ex, sym, amt, did: {"id": "dry-2", "amount": 0.01, "price": 50000.0, "cost": 500.0, "status": "closed"}
+ )
+ monkeypatch.setattr(te, "load_positions", lambda: [])
+ monkeypatch.setattr(te, "upsert_position", lambda positions, pos: positions.append(pos))
+ monkeypatch.setattr(te, "reconcile_positions_with_exchange", lambda ex, hint=None: ([], {}))
+ monkeypatch.setattr(te, "log_trade", lambda *a, **k: None)
+ monkeypatch.setattr(te, "log_decision", lambda *a, **k: None)
+
+ result = te.action_buy(None, "BTCUSDT", 50, "dec-abc", {})
+ assert result["id"] == "dry-2"
+ set_dry_run(False)
+
+
+class TestCommandBalances:
+ def test_returns_balances(self, monkeypatch, capsys):
+ monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0, "BTC": 0.5})
+ result = te.command_balances(None)
+ assert result == {"USDT": 100.0, "BTC": 0.5}
+ captured = capsys.readouterr()
+ assert '"USDT": 100.0' in captured.out
+
+
+class TestCommandStatus:
+ def test_returns_snapshot(self, monkeypatch, capsys):
+ monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0})
+ monkeypatch.setattr(te, "load_positions", lambda: [])
+ monkeypatch.setattr(te, "build_market_snapshot", lambda ex: {"BTC/USDT": 50000.0})
+ result = te.command_status(None)
+ assert result["balances"]["USDT"] == 100.0
+ captured = capsys.readouterr()
+ assert '"balances"' in captured.out
+
+
+class TestCommandOrders:
+ def test_returns_orders(self, monkeypatch, capsys):
+ orders = [{"id": "1", "symbol": "BTC/USDT"}]
+ monkeypatch.setattr(te, "print_json", lambda d: None)
+ ex = type("Ex", (), {"fetch_open_orders": lambda self: orders})()
+ result = te.command_orders(ex)
+ assert result == orders
+
+
+class TestCommandOrderStatus:
+ def test_returns_order(self, monkeypatch, capsys):
+ order = {"id": "123", "status": "open"}
+ monkeypatch.setattr(te, "print_json", lambda d: None)
+ ex = type("Ex", (), {"fetch_order": lambda self, oid, sym: order})()
+ result = te.command_order_status(ex, "BTCUSDT", "123")
+ assert result == order
+
+
+class TestCommandCancel:
+ def test_dry_run_returns_early(self, monkeypatch):
+ set_dry_run(True)
+ monkeypatch.setattr(te, "log", lambda msg: None)
+ result = te.command_cancel(None, "BTCUSDT", "123")
+ assert result["dry_run"] is True
+ set_dry_run(False)
+
+ def test_cancel_by_order_id(self, monkeypatch):
+ set_dry_run(False)
+ monkeypatch.setattr(te, "print_json", lambda d: None)
+ ex = type("Ex", (), {"cancel_order": lambda self, oid, sym: {"id": oid, "status": "canceled"}})()
+ result = te.command_cancel(ex, "BTCUSDT", "123")
+ assert result["status"] == "canceled"
+
+ def test_cancel_latest_when_no_order_id(self, monkeypatch):
+ set_dry_run(False)
+ monkeypatch.setattr(te, "print_json", lambda d: None)
+ ex = type("Ex", (), {
+ "fetch_open_orders": lambda self, sym: [{"id": "999"}, {"id": "888"}],
+ "cancel_order": lambda self, oid, sym: {"id": oid, "status": "canceled"},
+ })()
+ result = te.command_cancel(ex, "BTCUSDT", None)
+ assert result["id"] == "888"
+
+ def test_cancel_raises_when_no_open_orders(self, monkeypatch):
+ set_dry_run(False)
+ ex = type("Ex", (), {"fetch_open_orders": lambda self, sym: []})()
+ with pytest.raises(RuntimeError, match="No open orders"):
+ te.command_cancel(ex, "BTCUSDT", None)
+
+
+class TestPrintJson:
+ def test_outputs_sorted_json(self, capsys):
+ te.print_json({"b": 2, "a": 1})
+ captured = capsys.readouterr()
+ assert '"a": 1' in captured.out
+ assert '"b": 2' in captured.out