refactor: rewrite to CoinHunter V2 flat architecture

Replace the V1 commands/services split with a flat, direct architecture:
- cli.py dispatches directly to service functions
- New services: account, market, trade, opportunity
- Thin Binance wrappers: spot_client, um_futures_client
- Add audit logging, runtime paths, and TOML config
- Remove legacy V1 code: commands/, precheck, review engine, smart executor
- Add ruff + mypy toolchain and fix edge cases in trade params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 17:22:29 +08:00
parent 3819e35a7b
commit 52cd76a750
78 changed files with 2023 additions and 5407 deletions

View File

@@ -1 +1,3 @@
__version__ = "0.1.0"
"""CoinHunter V2."""
__version__ = "2.0.0"

39
src/coinhunter/audit.py Normal file
View File

@@ -0,0 +1,39 @@
"""Audit logging for CoinHunter V2."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .config import load_config, resolve_log_dir
from .runtime import RuntimePaths, ensure_runtime_dirs, get_runtime_paths, json_default
_audit_dir_cache: dict[str, Path] = {}
def _resolve_audit_dir(paths: RuntimePaths) -> Path:
key = str(paths.root)
if key not in _audit_dir_cache:
config = load_config(paths)
_audit_dir_cache[key] = resolve_log_dir(config, paths)
return _audit_dir_cache[key]
def _audit_path(paths: RuntimePaths | None = None) -> Path:
paths = ensure_runtime_dirs(paths or get_runtime_paths())
logs_dir = _resolve_audit_dir(paths)
logs_dir.mkdir(parents=True, exist_ok=True)
return logs_dir / f"audit_{datetime.now(timezone.utc).strftime('%Y%m%d')}.jsonl"
def audit_event(event: str, payload: dict[str, Any], paths: RuntimePaths | None = None) -> dict[str, Any]:
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": event,
**payload,
}
with _audit_path(paths).open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, ensure_ascii=False, default=json_default) + "\n")
return entry

View File

@@ -0,0 +1 @@
"""Official Binance connector wrappers."""

View File

@@ -0,0 +1,75 @@
"""Thin wrapper around the official Binance Spot connector."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from requests.exceptions import RequestException, SSLError
class SpotBinanceClient:
def __init__(
self,
*,
api_key: str,
api_secret: str,
base_url: str,
recv_window: int,
client: Any | None = None,
) -> None:
self.recv_window = recv_window
if client is not None:
self._client = client
return
try:
from binance.spot import Spot
except ModuleNotFoundError as exc: # pragma: no cover
raise RuntimeError("binance-connector is not installed") from exc
self._client = Spot(api_key=api_key, api_secret=api_secret, base_url=base_url)
def _call(self, operation: str, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except SSLError as exc:
raise RuntimeError(
"Binance Spot request failed because TLS certificate verification failed. "
"This usually means the local Python trust store is incomplete or a proxy is intercepting HTTPS. "
"Update the local CA trust chain or configure the host environment with the correct corporate/root CA."
) from exc
except RequestException as exc:
raise RuntimeError(f"Binance Spot request failed during {operation}: {exc}") from exc
def account_info(self) -> dict[str, Any]:
return self._call("account info", self._client.account, recvWindow=self.recv_window) # type: ignore[no-any-return]
def exchange_info(self, symbol: str | None = None) -> dict[str, Any]:
kwargs: dict[str, Any] = {"recvWindow": self.recv_window}
if symbol:
kwargs["symbol"] = symbol
return self._call("exchange info", self._client.exchange_info, **kwargs) # type: ignore[no-any-return]
def ticker_24h(self, symbols: list[str] | None = None) -> list[dict[str, Any]]:
if not symbols:
response = self._call("24h ticker", self._client.ticker_24hr)
elif len(symbols) == 1:
response = self._call("24h ticker", self._client.ticker_24hr, symbol=symbols[0])
else:
response = self._call("24h ticker", self._client.ticker_24hr, symbols=symbols)
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def ticker_price(self, symbols: list[str] | None = None) -> list[dict[str, Any]]:
if not symbols:
response = self._call("ticker price", self._client.ticker_price)
elif len(symbols) == 1:
response = self._call("ticker price", self._client.ticker_price, symbol=symbols[0])
else:
response = self._call("ticker price", self._client.ticker_price, symbols=symbols)
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def klines(self, symbol: str, interval: str, limit: int) -> list[list[Any]]:
return self._call("klines", self._client.klines, symbol=symbol, interval=interval, limit=limit) # type: ignore[no-any-return]
def new_order(self, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("recvWindow", self.recv_window)
return self._call("new order", self._client.new_order, **kwargs) # type: ignore[no-any-return]

View File

@@ -0,0 +1,73 @@
"""Thin wrapper around the official Binance USDT-M Futures connector."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from requests.exceptions import RequestException, SSLError
class UMFuturesClient:
def __init__(
self,
*,
api_key: str,
api_secret: str,
base_url: str,
recv_window: int,
client: Any | None = None,
) -> None:
self.recv_window = recv_window
if client is not None:
self._client = client
return
try:
from binance.um_futures import UMFutures
except ModuleNotFoundError as exc: # pragma: no cover
raise RuntimeError("binance-futures-connector is not installed") from exc
self._client = UMFutures(key=api_key, secret=api_secret, base_url=base_url)
def _call(self, operation: str, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except SSLError as exc:
raise RuntimeError(
"Binance UM Futures request failed because TLS certificate verification failed. "
"This usually means the local Python trust store is incomplete or a proxy is intercepting HTTPS. "
"Update the local CA trust chain or configure the host environment with the correct corporate/root CA."
) from exc
except RequestException as exc:
raise RuntimeError(f"Binance UM Futures request failed during {operation}: {exc}") from exc
def balance(self) -> list[dict[str, Any]]:
return self._call("balance", self._client.balance, recvWindow=self.recv_window) # type: ignore[no-any-return]
def position_risk(self, symbol: str | None = None) -> list[dict[str, Any]]:
kwargs: dict[str, Any] = {"recvWindow": self.recv_window}
if symbol:
kwargs["symbol"] = symbol
response = self._call("position risk", self._client.position_risk, **kwargs)
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def ticker_24h(self, symbols: list[str] | None = None) -> list[dict[str, Any]]:
if not symbols:
response = self._call("24h ticker", self._client.ticker_24hr_price_change)
elif len(symbols) == 1:
response = self._call("24h ticker", self._client.ticker_24hr_price_change, symbol=symbols[0])
else:
response = [self._call("24h ticker", self._client.ticker_24hr_price_change, symbol=symbol) for symbol in symbols]
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def ticker_price(self, symbols: list[str] | None = None) -> list[dict[str, Any]]:
if not symbols:
response = self._call("ticker price", self._client.ticker_price)
elif len(symbols) == 1:
response = self._call("ticker price", self._client.ticker_price, symbol=symbols[0])
else:
response = [self._call("ticker price", self._client.ticker_price, symbol=symbol) for symbol in symbols]
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def new_order(self, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("recvWindow", self.recv_window)
return self._call("new order", self._client.new_order, **kwargs) # type: ignore[no-any-return]

View File

@@ -1,8 +0,0 @@
"""Backward-compatible facade for check_api."""
from __future__ import annotations
from .commands.check_api import main
if __name__ == "__main__":
raise SystemExit(main())

344
src/coinhunter/cli.py Executable file → Normal file
View File

@@ -1,146 +1,238 @@
"""CoinHunter unified CLI entrypoint."""
"""CoinHunter V2 CLI."""
from __future__ import annotations
import argparse
import importlib
import sys
from typing import Any
from . import __version__
MODULE_MAP = {
"check-api": "commands.check_api",
"doctor": "commands.doctor",
"external-gate": "commands.external_gate",
"init": "commands.init_user_state",
"market-probe": "commands.market_probe",
"paths": "commands.paths",
"precheck": "commands.precheck",
"review-context": "commands.review_context",
"review-engine": "commands.review_engine",
"rotate-external-gate-log": "commands.rotate_external_gate_log",
"smart-executor": "commands.smart_executor",
}
ALIASES = {
"api-check": "check-api",
"diag": "doctor",
"env": "paths",
"gate": "external-gate",
"pre": "precheck",
"probe": "market-probe",
"review": "review-context",
"recap": "review-engine",
"rotate-gate-log": "rotate-external-gate-log",
"rotate-log": "rotate-external-gate-log",
"scan": "precheck",
"setup": "init",
"exec": "smart-executor",
}
COMMAND_HELP = [
("api-check", "check-api", "Validate exchange/API connectivity"),
("diag", "doctor", "Inspect runtime wiring and diagnostics"),
("gate", "external-gate", "Run external gate orchestration"),
("setup", "init", "Initialize user runtime state"),
("env", "paths", "Print runtime path resolution"),
("pre, scan", "precheck", "Run precheck workflow"),
("probe", "market-probe", "Query external market data"),
("review", "review-context", "Generate review context"),
("recap", "review-engine", "Generate review recap/engine output"),
("rotate-gate-log, rotate-log", "rotate-external-gate-log", "Rotate external gate logs"),
("exec", "smart-executor", "Trading and execution actions"),
]
from .binance.spot_client import SpotBinanceClient
from .binance.um_futures_client import UMFuturesClient
from .config import ensure_init_files, get_binance_credentials, load_config
from .runtime import get_runtime_paths, print_json
from .services import account_service, market_service, opportunity_service, trade_service
def _command_listing() -> str:
lines = []
for names, canonical, summary in COMMAND_HELP:
label = names if canonical is None else f"{names} (alias for {canonical})"
lines.append(f" {label:<45} {summary}")
return "\n".join(lines)
def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> SpotBinanceClient:
credentials = get_binance_credentials()
binance_config = config["binance"]
return SpotBinanceClient(
api_key=credentials["api_key"],
api_secret=credentials["api_secret"],
base_url=binance_config["spot_base_url"],
recv_window=int(binance_config["recv_window"]),
client=client,
)
class VersionAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
print(__version__)
raise SystemExit(0)
def _load_futures_client(config: dict[str, Any], *, client: Any | None = None) -> UMFuturesClient:
credentials = get_binance_credentials()
binance_config = config["binance"]
return UMFuturesClient(
api_key=credentials["api_key"],
api_secret=credentials["api_secret"],
base_url=binance_config["futures_base_url"],
recv_window=int(binance_config["recv_window"]),
client=client,
)
def _resolve_market_flags(args: argparse.Namespace) -> tuple[bool, bool]:
if getattr(args, "spot", False) or getattr(args, "futures", False):
return bool(getattr(args, "spot", False)), bool(getattr(args, "futures", False))
return True, True
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="coinhunter",
description="CoinHunter trading operations CLI",
formatter_class=argparse.RawTextHelpFormatter,
epilog=(
"Commands:\n"
f"{_command_listing()}\n\n"
"Examples:\n"
" coinhunter diag\n"
" coinhunter env\n"
" coinhunter setup\n"
" coinhunter api-check\n"
" coinhunter exec bal\n"
" 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 '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, rotate, orders, order-status, and cancel.\n"
"Legacy command names remain supported for backward compatibility.\n"
),
)
parser.add_argument("--version", nargs=0, action=VersionAction, help="Print installed version and exit")
parser.add_argument(
"command",
nargs="?",
metavar="COMMAND",
help="Command to run. Use --help to see canonical names and short aliases.",
)
parser.add_argument("args", nargs=argparse.REMAINDER)
parser = argparse.ArgumentParser(prog="coinhunter", description="CoinHunter V2 Binance-first trading CLI")
parser.add_argument("--version", action="version", version=__version__)
subparsers = parser.add_subparsers(dest="command")
init_parser = subparsers.add_parser("init", help="Generate config.toml, .env, and log directory")
init_parser.add_argument("--force", action="store_true")
account_parser = subparsers.add_parser("account", help="Account overview, balances, and positions")
account_subparsers = account_parser.add_subparsers(dest="account_command")
for name in ("overview", "balances", "positions"):
sub = account_subparsers.add_parser(name)
sub.add_argument("--spot", action="store_true")
sub.add_argument("--futures", action="store_true")
market_parser = subparsers.add_parser("market", help="Batch market queries")
market_subparsers = market_parser.add_subparsers(dest="market_command")
tickers_parser = market_subparsers.add_parser("tickers")
tickers_parser.add_argument("symbols", nargs="+")
klines_parser = market_subparsers.add_parser("klines")
klines_parser.add_argument("symbols", nargs="+")
klines_parser.add_argument("--interval", default="1h")
klines_parser.add_argument("--limit", type=int, default=100)
trade_parser = subparsers.add_parser("trade", help="Spot and futures trade execution")
trade_subparsers = trade_parser.add_subparsers(dest="trade_market")
spot_parser = trade_subparsers.add_parser("spot")
spot_subparsers = spot_parser.add_subparsers(dest="trade_action")
for side in ("buy", "sell"):
sub = spot_subparsers.add_parser(side)
sub.add_argument("symbol")
sub.add_argument("--qty", type=float)
sub.add_argument("--quote", type=float)
sub.add_argument("--type", choices=["market", "limit"], default="market")
sub.add_argument("--price", type=float)
sub.add_argument("--dry-run", action="store_true")
futures_parser = trade_subparsers.add_parser("futures")
futures_subparsers = futures_parser.add_subparsers(dest="trade_action")
for side in ("buy", "sell"):
sub = futures_subparsers.add_parser(side)
sub.add_argument("symbol")
sub.add_argument("--qty", type=float, required=True)
sub.add_argument("--type", choices=["market", "limit"], default="market")
sub.add_argument("--price", type=float)
sub.add_argument("--reduce-only", action="store_true")
sub.add_argument("--dry-run", action="store_true")
close_parser = futures_subparsers.add_parser("close")
close_parser.add_argument("symbol")
close_parser.add_argument("--dry-run", action="store_true")
opportunity_parser = subparsers.add_parser("opportunity", help="Portfolio analysis and market scanning")
opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command")
opportunity_subparsers.add_parser("portfolio")
scan_parser = opportunity_subparsers.add_parser("scan")
scan_parser.add_argument("--symbols", nargs="*")
return parser
def run_python_module(module_name: str, argv: list[str], display_name: str) -> int:
module = importlib.import_module(f".{module_name}", package="coinhunter")
if not hasattr(module, "main"):
raise RuntimeError(f"Module {module_name} has no main()")
old_argv = sys.argv[:]
try:
sys.argv = [display_name, *argv]
result = module.main()
return int(result) if isinstance(result, int) else 0
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else 0
finally:
sys.argv = old_argv
def main() -> int:
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
parsed = parser.parse_args()
if not parsed.command:
parser.print_help()
return 0
command = ALIASES.get(parsed.command, parsed.command)
if command not in MODULE_MAP:
parser.error(
f"invalid command: {parsed.command!r}. Use `coinhunter --help` to see supported commands and aliases."
)
module_name = MODULE_MAP[command]
argv = list(parsed.args)
if argv and argv[0] == "--":
argv = argv[1:]
return run_python_module(module_name, argv, f"coinhunter {parsed.command}")
args = parser.parse_args(argv)
try:
if not args.command:
parser.print_help()
return 0
if args.command == "init":
print_json(ensure_init_files(get_runtime_paths(), force=args.force))
return 0
if __name__ == "__main__":
raise SystemExit(main())
config = load_config()
if args.command == "account":
include_spot, include_futures = _resolve_market_flags(args)
spot_client = _load_spot_client(config) if include_spot else None
futures_client = _load_futures_client(config) if include_futures else None
if args.account_command == "overview":
print_json(
account_service.get_overview(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
)
)
return 0
if args.account_command == "balances":
print_json(
account_service.get_balances(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
)
)
return 0
if args.account_command == "positions":
print_json(
account_service.get_positions(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
)
)
return 0
parser.error("account requires one of: overview, balances, positions")
if args.command == "market":
spot_client = _load_spot_client(config)
if args.market_command == "tickers":
print_json(market_service.get_tickers(config, args.symbols, spot_client=spot_client))
return 0
if args.market_command == "klines":
print_json(
market_service.get_klines(
config,
args.symbols,
interval=args.interval,
limit=args.limit,
spot_client=spot_client,
)
)
return 0
parser.error("market requires one of: tickers, klines")
if args.command == "trade":
if args.trade_market == "spot":
spot_client = _load_spot_client(config)
print_json(
trade_service.execute_spot_trade(
config,
side=args.trade_action,
symbol=args.symbol,
qty=args.qty,
quote=args.quote,
order_type=args.type,
price=args.price,
dry_run=True if args.dry_run else None,
spot_client=spot_client,
)
)
return 0
if args.trade_market == "futures":
futures_client = _load_futures_client(config)
if args.trade_action == "close":
print_json(
trade_service.close_futures_position(
config,
symbol=args.symbol,
dry_run=True if args.dry_run else None,
futures_client=futures_client,
)
)
return 0
print_json(
trade_service.execute_futures_trade(
config,
side=args.trade_action,
symbol=args.symbol,
qty=args.qty,
order_type=args.type,
price=args.price,
reduce_only=args.reduce_only,
dry_run=True if args.dry_run else None,
futures_client=futures_client,
)
)
return 0
parser.error("trade requires `spot` or `futures`")
if args.command == "opportunity":
spot_client = _load_spot_client(config)
if args.opportunity_command == "portfolio":
print_json(opportunity_service.analyze_portfolio(config, spot_client=spot_client))
return 0
if args.opportunity_command == "scan":
print_json(opportunity_service.scan_opportunities(config, spot_client=spot_client, symbols=args.symbols))
return 0
parser.error("opportunity requires `portfolio` or `scan`")
parser.error(f"Unsupported command {args.command}")
return 2
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

View File

@@ -1 +0,0 @@
"""CLI command adapters for CoinHunter."""

View File

@@ -1,56 +0,0 @@
#!/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
def main():
load_env_file()
api_key = os.getenv("BINANCE_API_KEY", "")
secret = os.getenv("BINANCE_API_SECRET", "")
if not api_key or api_key.startswith("***") or api_key.startswith("your_"):
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(json.dumps({"ok": False, "error": "BINANCE_API_SECRET not configured"}, ensure_ascii=False))
return 1
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__":
raise SystemExit(main())

View File

@@ -1,65 +0,0 @@
"""Runtime diagnostics for CoinHunter CLI."""
from __future__ import annotations
import json
import os
import platform
import shutil
import sys
from ..runtime import ensure_runtime_dirs, get_runtime_paths, load_env_file, resolve_hermes_executable
REQUIRED_ENV_VARS = ["BINANCE_API_KEY", "BINANCE_API_SECRET"]
def main() -> int:
paths = ensure_runtime_dirs(get_runtime_paths())
env_file = load_env_file(paths)
hermes_executable = resolve_hermes_executable(paths)
env_checks = {}
missing_env = []
for name in REQUIRED_ENV_VARS:
present = bool(os.getenv(name))
env_checks[name] = present
if not present:
missing_env.append(name)
file_checks = {
"env_file_exists": env_file.exists(),
"config_exists": paths.config_file.exists(),
"positions_exists": paths.positions_file.exists(),
"logrotate_config_exists": paths.logrotate_config.exists(),
}
dir_checks = {
"root_exists": paths.root.exists(),
"state_dir_exists": paths.state_dir.exists(),
"logs_dir_exists": paths.logs_dir.exists(),
"reviews_dir_exists": paths.reviews_dir.exists(),
"cache_dir_exists": paths.cache_dir.exists(),
}
command_checks = {
"hermes": bool(shutil.which("hermes") or paths.hermes_bin.exists()),
"logrotate": bool(shutil.which("logrotate") or shutil.which("/usr/sbin/logrotate")),
}
report = {
"ok": not missing_env,
"python": sys.version.split()[0],
"platform": platform.platform(),
"env_file": str(env_file),
"hermes_executable": hermes_executable,
"paths": paths.as_dict(),
"env_checks": env_checks,
"missing_env": missing_env,
"file_checks": file_checks,
"dir_checks": dir_checks,
"command_checks": command_checks,
}
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env python3
import fcntl
import json
import subprocess
import sys
from datetime import datetime, timezone
from ..runtime import ensure_runtime_dirs, get_runtime_paths
def _paths():
return get_runtime_paths()
COINHUNTER_MODULE = [sys.executable, "-m", "coinhunter"]
def utc_now():
return datetime.now(timezone.utc).isoformat()
def log(message: str):
print(f"[{utc_now()}] {message}", file=sys.stderr)
def run_cmd(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(args, capture_output=True, text=True)
def parse_json_output(text: str) -> dict:
text = (text or "").strip()
if not text:
return {}
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():
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:
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:
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:
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"):
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"):
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:
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_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:
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"
append_log(f"queued trade job via {' '.join(trigger_cmd)}; reasons={reasons}")
if 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
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,95 +0,0 @@
#!/usr/bin/env python3
import json
from datetime import datetime, timezone
from pathlib import Path
from ..runtime import ensure_runtime_dirs, get_runtime_paths
def _paths():
return get_runtime_paths()
def now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def ensure_file(path: Path, payload: dict):
if path.exists():
return False
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return True
def main():
paths = _paths()
ensure_runtime_dirs(paths)
created = []
ts = now_iso()
templates = {
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,
},
paths.root / "accounts.json": {
"accounts": []
},
paths.root / "positions.json": {
"positions": []
},
paths.root / "watchlist.json": {
"watchlist": []
},
paths.root / "notes.json": {
"notes": []
},
}
for path, payload in templates.items():
if ensure_file(path, payload):
created.append(str(path))
print(json.dumps({
"root": str(paths.root),
"created": created,
"cache_dir": str(paths.cache_dir),
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -1,242 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import urllib.parse
import urllib.request
DEFAULT_TIMEOUT = 20
def fetch_json(url, headers=None, timeout=DEFAULT_TIMEOUT):
merged_headers = {
"Accept": "application/json",
"User-Agent": "Mozilla/5.0 (compatible; OpenClaw Coin Hunter/1.0)",
}
if headers:
merged_headers.update(headers)
req = urllib.request.Request(url, headers=merged_headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
return json.loads(data.decode("utf-8"))
def print_json(data):
print(json.dumps(data, ensure_ascii=False, indent=2))
def bybit_ticker(symbol: str):
url = (
"https://api.bybit.com/v5/market/tickers?category=spot&symbol="
+ urllib.parse.quote(symbol.upper())
)
payload = fetch_json(url)
items = payload.get("result", {}).get("list", [])
if not items:
raise SystemExit(f"No Bybit spot ticker found for {symbol}")
item = items[0]
out = {
"provider": "bybit",
"symbol": symbol.upper(),
"lastPrice": item.get("lastPrice"),
"price24hPcnt": item.get("price24hPcnt"),
"highPrice24h": item.get("highPrice24h"),
"lowPrice24h": item.get("lowPrice24h"),
"turnover24h": item.get("turnover24h"),
"volume24h": item.get("volume24h"),
"bid1Price": item.get("bid1Price"),
"ask1Price": item.get("ask1Price"),
}
print_json(out)
def bybit_klines(symbol: str, interval: str, limit: int):
params = urllib.parse.urlencode({
"category": "spot",
"symbol": symbol.upper(),
"interval": interval,
"limit": str(limit),
})
url = f"https://api.bybit.com/v5/market/kline?{params}"
payload = fetch_json(url)
rows = payload.get("result", {}).get("list", [])
out = {
"provider": "bybit",
"symbol": symbol.upper(),
"interval": interval,
"candles": [
{
"startTime": r[0],
"open": r[1],
"high": r[2],
"low": r[3],
"close": r[4],
"volume": r[5],
"turnover": r[6],
}
for r in rows
],
}
print_json(out)
def dexscreener_search(query: str):
url = "https://api.dexscreener.com/latest/dex/search/?q=" + urllib.parse.quote(query)
payload = fetch_json(url)
pairs = payload.get("pairs") or []
out = []
for p in pairs[:10]:
out.append({
"chainId": p.get("chainId"),
"dexId": p.get("dexId"),
"pairAddress": p.get("pairAddress"),
"url": p.get("url"),
"baseToken": p.get("baseToken"),
"quoteToken": p.get("quoteToken"),
"priceUsd": p.get("priceUsd"),
"liquidityUsd": (p.get("liquidity") or {}).get("usd"),
"fdv": p.get("fdv"),
"marketCap": p.get("marketCap"),
"volume24h": (p.get("volume") or {}).get("h24"),
"buys24h": ((p.get("txns") or {}).get("h24") or {}).get("buys"),
"sells24h": ((p.get("txns") or {}).get("h24") or {}).get("sells"),
})
print_json({"provider": "dexscreener", "query": query, "pairs": out})
def dexscreener_token(chain: str, address: str):
url = f"https://api.dexscreener.com/tokens/v1/{urllib.parse.quote(chain)}/{urllib.parse.quote(address)}"
payload = fetch_json(url)
pairs = payload if isinstance(payload, list) else payload.get("pairs") or []
out = []
for p in pairs[:10]:
out.append({
"chainId": p.get("chainId"),
"dexId": p.get("dexId"),
"pairAddress": p.get("pairAddress"),
"baseToken": p.get("baseToken"),
"quoteToken": p.get("quoteToken"),
"priceUsd": p.get("priceUsd"),
"liquidityUsd": (p.get("liquidity") or {}).get("usd"),
"fdv": p.get("fdv"),
"marketCap": p.get("marketCap"),
"volume24h": (p.get("volume") or {}).get("h24"),
})
print_json({"provider": "dexscreener", "chain": chain, "address": address, "pairs": out})
def coingecko_search(query: str):
url = "https://api.coingecko.com/api/v3/search?query=" + urllib.parse.quote(query)
payload = fetch_json(url)
coins = payload.get("coins") or []
out = []
for c in coins[:10]:
out.append({
"id": c.get("id"),
"name": c.get("name"),
"symbol": c.get("symbol"),
"marketCapRank": c.get("market_cap_rank"),
"thumb": c.get("thumb"),
})
print_json({"provider": "coingecko", "query": query, "coins": out})
def coingecko_coin(coin_id: str):
params = urllib.parse.urlencode({
"localization": "false",
"tickers": "false",
"market_data": "true",
"community_data": "false",
"developer_data": "false",
"sparkline": "false",
})
url = f"https://api.coingecko.com/api/v3/coins/{urllib.parse.quote(coin_id)}?{params}"
payload = fetch_json(url)
md = payload.get("market_data") or {}
out = {
"provider": "coingecko",
"id": payload.get("id"),
"symbol": payload.get("symbol"),
"name": payload.get("name"),
"marketCapRank": payload.get("market_cap_rank"),
"currentPriceUsd": (md.get("current_price") or {}).get("usd"),
"marketCapUsd": (md.get("market_cap") or {}).get("usd"),
"fullyDilutedValuationUsd": (md.get("fully_diluted_valuation") or {}).get("usd"),
"totalVolumeUsd": (md.get("total_volume") or {}).get("usd"),
"priceChangePercentage24h": md.get("price_change_percentage_24h"),
"priceChangePercentage7d": md.get("price_change_percentage_7d"),
"priceChangePercentage30d": md.get("price_change_percentage_30d"),
"circulatingSupply": md.get("circulating_supply"),
"totalSupply": md.get("total_supply"),
"maxSupply": md.get("max_supply"),
"homepage": (payload.get("links") or {}).get("homepage", [None])[0],
}
print_json(out)
def birdeye_token(address: str):
api_key = os.getenv("BIRDEYE_API_KEY") or os.getenv("BIRDEYE_APIKEY")
if not api_key:
raise SystemExit("Birdeye requires BIRDEYE_API_KEY in the environment")
url = "https://public-api.birdeye.so/defi/token_overview?address=" + urllib.parse.quote(address)
payload = fetch_json(url, headers={
"x-api-key": api_key,
"x-chain": "solana",
})
print_json({"provider": "birdeye", "address": address, "data": payload.get("data")})
def build_parser():
parser = argparse.ArgumentParser(description="Coin Hunter market data probe")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("bybit-ticker", help="Fetch Bybit spot ticker")
p.add_argument("symbol")
p = sub.add_parser("bybit-klines", help="Fetch Bybit spot klines")
p.add_argument("symbol")
p.add_argument("--interval", default="60", help="Bybit interval, e.g. 1, 5, 15, 60, 240, D")
p.add_argument("--limit", type=int, default=10)
p = sub.add_parser("dex-search", help="Search DexScreener by query")
p.add_argument("query")
p = sub.add_parser("dex-token", help="Fetch DexScreener token pairs by chain/address")
p.add_argument("chain")
p.add_argument("address")
p = sub.add_parser("gecko-search", help="Search CoinGecko")
p.add_argument("query")
p = sub.add_parser("gecko-coin", help="Fetch CoinGecko coin by id")
p.add_argument("coin_id")
p = sub.add_parser("birdeye-token", help="Fetch Birdeye token overview (Solana)")
p.add_argument("address")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.command == "bybit-ticker":
bybit_ticker(args.symbol)
elif args.command == "bybit-klines":
bybit_klines(args.symbol, args.interval, args.limit)
elif args.command == "dex-search":
dexscreener_search(args.query)
elif args.command == "dex-token":
dexscreener_token(args.chain, args.address)
elif args.command == "gecko-search":
coingecko_search(args.query)
elif args.command == "gecko-coin":
coingecko_coin(args.coin_id)
elif args.command == "birdeye-token":
birdeye_token(args.address)
else:
parser.error("Unknown command")
if __name__ == "__main__":
main()

View File

@@ -1,16 +0,0 @@
"""Print CoinHunter runtime paths."""
from __future__ import annotations
import json
from ..runtime import get_runtime_paths
def main() -> int:
print(json.dumps(get_runtime_paths().as_dict(), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,15 +0,0 @@
"""CLI adapter for precheck."""
from __future__ import annotations
import sys
from ..services.precheck_service import run
def main() -> int:
return run(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,34 +0,0 @@
#!/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()

View File

@@ -1,24 +0,0 @@
#!/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())

View File

@@ -1,31 +0,0 @@
#!/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
def _paths():
return get_runtime_paths()
def main():
paths = _paths()
ensure_runtime_dirs(paths)
logrotate_bin = shutil.which("logrotate") or "/usr/sbin/logrotate"
cmd = [logrotate_bin, "-s", str(paths.logrotate_status), str(paths.logrotate_config)]
result = subprocess.run(cmd, capture_output=True, text=True)
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__":
raise SystemExit(main())

View File

@@ -1,15 +0,0 @@
"""CLI adapter for smart executor."""
from __future__ import annotations
import sys
from ..services.smart_executor_service import run
def main() -> int:
return run(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())

132
src/coinhunter/config.py Normal file
View File

@@ -0,0 +1,132 @@
"""Configuration and secret loading for CoinHunter V2."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from .runtime import RuntimePaths, ensure_runtime_dirs, get_runtime_paths
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib
DEFAULT_CONFIG = """[runtime]
timezone = "Asia/Shanghai"
log_dir = "logs"
output_format = "json"
[binance]
spot_base_url = "https://api.binance.com"
futures_base_url = "https://fapi.binance.com"
recv_window = 5000
[market]
default_quote = "USDT"
universe_allowlist = []
universe_denylist = []
[trading]
spot_enabled = true
futures_enabled = true
dry_run_default = false
dust_usdt_threshold = 10.0
[opportunity]
min_quote_volume = 1000000.0
top_n = 10
scan_limit = 50
ignore_dust = true
lookback_intervals = ["1h", "4h", "1d"]
[opportunity.weights]
trend = 1.0
momentum = 1.0
breakout = 0.8
volume = 0.7
volatility_penalty = 0.5
position_concentration_penalty = 0.6
"""
DEFAULT_ENV = "BINANCE_API_KEY=\nBINANCE_API_SECRET=\n"
def _permission_denied_message(paths: RuntimePaths, exc: PermissionError) -> RuntimeError:
return RuntimeError(
"Unable to initialize CoinHunter runtime files because the target directory is not writable: "
f"{paths.root}. Set COINHUNTER_HOME to a writable directory or rerun with permissions that can write there. "
f"Original error: {exc}"
)
def ensure_init_files(paths: RuntimePaths | None = None, *, force: bool = False) -> dict[str, Any]:
paths = paths or get_runtime_paths()
try:
ensure_runtime_dirs(paths)
except PermissionError as exc:
raise _permission_denied_message(paths, exc) from exc
created: list[str] = []
updated: list[str] = []
for path, content in ((paths.config_file, DEFAULT_CONFIG), (paths.env_file, DEFAULT_ENV)):
if force or not path.exists():
try:
path.write_text(content, encoding="utf-8")
except PermissionError as exc:
raise _permission_denied_message(paths, exc) from exc
(updated if force and path.exists() else created).append(str(path))
return {
"root": str(paths.root),
"config_file": str(paths.config_file),
"env_file": str(paths.env_file),
"logs_dir": str(paths.logs_dir),
"created_or_updated": created + updated,
"force": force,
}
def load_config(paths: RuntimePaths | None = None) -> dict[str, Any]:
paths = paths or get_runtime_paths()
if not paths.config_file.exists():
raise RuntimeError(f"Missing config file at {paths.config_file}. Run `coinhunter init` first.")
return tomllib.loads(paths.config_file.read_text(encoding="utf-8")) # type: ignore[no-any-return]
def load_env_file(paths: RuntimePaths | None = None) -> dict[str, str]:
paths = paths or get_runtime_paths()
loaded: dict[str, str] = {}
if not paths.env_file.exists():
return loaded
for raw_line in paths.env_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
loaded[key] = value
os.environ[key] = value
return loaded
def get_binance_credentials(paths: RuntimePaths | None = None) -> dict[str, str]:
load_env_file(paths)
api_key = os.getenv("BINANCE_API_KEY", "").strip()
api_secret = os.getenv("BINANCE_API_SECRET", "").strip()
if not api_key or not api_secret:
runtime_paths = paths or get_runtime_paths()
raise RuntimeError(
"Missing BINANCE_API_KEY or BINANCE_API_SECRET. "
f"Populate {runtime_paths.env_file} or export them in the environment."
)
return {"api_key": api_key, "api_secret": api_secret}
def resolve_log_dir(config: dict[str, Any], paths: RuntimePaths | None = None) -> Path:
paths = paths or get_runtime_paths()
raw = config.get("runtime", {}).get("log_dir", "logs")
value = Path(raw).expanduser()
return value if value.is_absolute() else paths.root / value

View File

@@ -1,8 +0,0 @@
"""Backward-compatible facade for doctor."""
from __future__ import annotations
from .commands.doctor import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,8 +0,0 @@
"""Backward-compatible facade for external_gate."""
from __future__ import annotations
from .commands.external_gate import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,8 +0,0 @@
"""Backward-compatible facade for init_user_state."""
from __future__ import annotations
from .commands.init_user_state import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,120 +0,0 @@
#!/usr/bin/env python3
"""Coin Hunter structured logger."""
import json
import traceback
__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
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)
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"
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
def log_event(prefix: str, payload: dict):
entry = {
"schema_version": SCHEMA_VERSION,
"timestamp": bj_now().isoformat(),
**payload,
}
_append_jsonl(prefix, entry)
return entry
def log_decision(data: dict):
return log_event("decisions", data)
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,
"qty": qty,
"amount_usdt": amount_usdt,
"price": price,
"note": note,
**extra,
}
return log_event("trades", payload)
def log_snapshot(market_data: dict, note: str = "", **extra):
return log_event("snapshots", {"market_data": market_data, "note": note, **extra})
def log_error(where: str, error: Exception | str, **extra):
payload = {
"where": where,
"error_type": error.__class__.__name__ if isinstance(error, Exception) else "Error",
"error": str(error),
"traceback": traceback.format_exc() if isinstance(error, Exception) else None,
**extra,
}
return log_event("errors", payload)
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"
if not log_file.exists():
return []
entries = []
with open(log_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def get_logs_last_n_hours(log_type: str, n_hours: int = 1) -> list:
now = bj_now()
cutoff = now - timedelta(hours=n_hours)
entries = []
for offset in [0, -1]:
date_str = (now + timedelta(days=offset)).strftime("%Y%m%d")
for entry in get_logs_by_date(log_type, date_str):
try:
ts = datetime.fromisoformat(entry["timestamp"])
except Exception:
continue
if ts >= cutoff:
entries.append(entry)
entries.sort(key=lambda x: x.get("timestamp", ""))
return entries

View File

@@ -1,8 +0,0 @@
"""Backward-compatible facade for market_probe."""
from __future__ import annotations
from .commands.market_probe import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,8 +0,0 @@
"""Backward-compatible facade for paths."""
from __future__ import annotations
from .commands.paths import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,96 +0,0 @@
#!/usr/bin/env python3
"""Backward-compatible facade for the precheck workflow.
The reusable implementation now lives under ``coinhunter.services``.
Keep this module importable and executable so older entrypoints continue to work.
"""
from __future__ import annotations
import sys
from importlib import import_module
from .services.precheck_service import run as _run_service
_CORE_EXPORTS = {
"BASE_PRICE_MOVE_TRIGGER_PCT",
"BASE_PNL_TRIGGER_PCT",
"BASE_PORTFOLIO_MOVE_TRIGGER_PCT",
"BASE_CANDIDATE_SCORE_TRIGGER_RATIO",
"BASE_FORCE_ANALYSIS_AFTER_MINUTES",
"BASE_COOLDOWN_MINUTES",
"TOP_CANDIDATES",
"MIN_ACTIONABLE_USDT",
"MIN_REAL_POSITION_VALUE_USDT",
"BLACKLIST",
"HARD_STOP_PCT",
"HARD_MOON_PCT",
"MIN_CHANGE_PCT",
"MAX_PRICE_CAP",
"HARD_REASON_DEDUP_MINUTES",
"MAX_PENDING_TRIGGER_MINUTES",
"MAX_RUN_REQUEST_MINUTES",
"utc_now",
"utc_iso",
"parse_ts",
"load_json",
"load_env",
"load_positions",
"load_state",
"load_config",
"clear_run_request_fields",
"sanitize_state_for_stale_triggers",
"save_state",
"stable_hash",
"get_exchange",
"fetch_ohlcv_batch",
"compute_ohlcv_metrics",
"enrich_candidates_and_positions",
"regime_from_pct",
"to_float",
"norm_symbol",
"get_local_now",
"session_label",
"top_candidates_from_tickers",
"build_snapshot",
"build_adaptive_profile",
"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 | _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}")
def __dir__():
return sorted(set(globals()) | set(__all__))
def main():
return _run_service(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env python3
"""Backward-compatible facade for review context.
The executable implementation lives in ``coinhunter.commands.review_context``.
"""
from __future__ import annotations
from .commands.review_context import main
if __name__ == "__main__":
main()

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env python3
"""Backward-compatible facade for review engine.
The executable implementation lives in ``coinhunter.commands.review_engine``.
Core logic is in ``coinhunter.services.review_service``.
"""
from __future__ import annotations
from importlib import import_module
# 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 __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 __dir__():
return sorted(set(globals()) | set(__all__))
def main():
from .commands.review_engine import main as _main
return _main()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,8 +0,0 @@
"""Backward-compatible facade for rotate_external_gate_log."""
from __future__ import annotations
from .commands.rotate_external_gate_log import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,127 +1,52 @@
"""Runtime paths and environment helpers for CoinHunter CLI."""
"""Runtime helpers for CoinHunter V2."""
from __future__ import annotations
import json
import os
import shutil
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, is_dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class RuntimePaths:
root: Path
cache_dir: Path
state_dir: Path
logs_dir: Path
reviews_dir: Path
config_file: Path
positions_file: Path
accounts_file: Path
executions_file: Path
watchlist_file: Path
notes_file: Path
positions_lock: Path
executions_lock: Path
precheck_state_file: Path
precheck_state_lock: Path
external_gate_lock: Path
logrotate_config: Path
logrotate_status: Path
hermes_home: Path
env_file: Path
hermes_bin: Path
logs_dir: Path
def as_dict(self) -> dict[str, str]:
return {key: str(value) for key, value in asdict(self).items()}
def _default_coinhunter_home() -> Path:
raw = os.getenv("COINHUNTER_HOME")
return Path(raw).expanduser() if raw else Path.home() / ".coinhunter"
def _default_hermes_home() -> Path:
raw = os.getenv("HERMES_HOME")
return Path(raw).expanduser() if raw else Path.home() / ".hermes"
def get_runtime_paths() -> RuntimePaths:
root = _default_coinhunter_home()
hermes_home = _default_hermes_home()
state_dir = root / "state"
root = Path(os.getenv("COINHUNTER_HOME", "~/.coinhunter")).expanduser()
return RuntimePaths(
root=root,
cache_dir=root / "cache",
state_dir=state_dir,
config_file=root / "config.toml",
env_file=root / ".env",
logs_dir=root / "logs",
reviews_dir=root / "reviews",
config_file=root / "config.json",
positions_file=root / "positions.json",
accounts_file=root / "accounts.json",
executions_file=root / "executions.json",
watchlist_file=root / "watchlist.json",
notes_file=root / "notes.json",
positions_lock=root / "positions.lock",
executions_lock=root / "executions.lock",
precheck_state_file=state_dir / "precheck_state.json",
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",
hermes_home=hermes_home,
env_file=Path(os.getenv("COINHUNTER_ENV_FILE", str(hermes_home / ".env"))).expanduser(),
hermes_bin=Path(os.getenv("HERMES_BIN", str(Path.home() / ".local" / "bin" / "hermes"))).expanduser(),
)
def ensure_runtime_dirs(paths: RuntimePaths | None = None) -> RuntimePaths:
paths = paths or get_runtime_paths()
for directory in (paths.root, paths.cache_dir, paths.state_dir, paths.logs_dir, paths.reviews_dir):
directory.mkdir(parents=True, exist_ok=True)
paths.root.mkdir(parents=True, exist_ok=True)
paths.logs_dir.mkdir(parents=True, exist_ok=True)
return paths
def load_env_file(paths: RuntimePaths | None = None) -> Path:
paths = paths or get_runtime_paths()
if paths.env_file.exists():
for line in paths.env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
return paths.env_file
def json_default(value: Any) -> Any:
if is_dataclass(value) and not isinstance(value, type):
return asdict(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Path):
return str(value)
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def resolve_hermes_executable(paths: RuntimePaths | None = None) -> str:
paths = paths or get_runtime_paths()
discovered = shutil.which("hermes")
if discovered:
return discovered
return str(paths.hermes_bin)
def mask_secret(value: str | None, *, tail: int = 4) -> str:
if not value:
return ""
if len(value) <= tail:
return "*" * len(value)
return "*" * max(4, len(value) - tail) + value[-tail:]
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
def print_json(payload: Any) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=json_default))

View File

@@ -1 +1 @@
"""Application services for CoinHunter."""
"""Service layer for CoinHunter V2."""

View File

@@ -0,0 +1,285 @@
"""Account and position services."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
from .market_service import normalize_symbol
@dataclass
class AssetBalance:
market_type: str
asset: str
free: float
locked: float
total: float
notional_usdt: float
@dataclass
class PositionView:
market_type: str
symbol: str
quantity: float
entry_price: float | None
mark_price: float
notional_usdt: float
unrealized_pnl: float | None
side: str
@dataclass
class AccountOverview:
total_equity_usdt: float
spot_equity_usdt: float
futures_equity_usdt: float
spot_asset_count: int
futures_position_count: int
def _spot_price_map(spot_client: Any, quote: str, assets: list[str]) -> dict[str, float]:
symbols = [f"{asset}{quote}" for asset in assets if asset != quote]
price_map = {quote: 1.0}
if not symbols:
return price_map
for item in spot_client.ticker_price(symbols):
symbol = item.get("symbol", "")
if symbol.endswith(quote):
price_map[symbol.removesuffix(quote)] = float(item.get("price", 0.0))
return price_map
def _spot_account_data(spot_client: Any, quote: str) -> tuple[list[dict[str, Any]], list[str], dict[str, float]]:
account = spot_client.account_info()
balances = account.get("balances", [])
assets = [item["asset"] for item in balances if float(item.get("free", 0)) + float(item.get("locked", 0)) > 0]
price_map = _spot_price_map(spot_client, quote, assets)
return balances, assets, price_map
def get_balances(
config: dict[str, Any],
*,
include_spot: bool,
include_futures: bool,
spot_client: Any | None = None,
futures_client: Any | None = None,
) -> dict[str, Any]:
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
rows: list[dict[str, Any]] = []
if include_spot and spot_client is not None:
balances, _, price_map = _spot_account_data(spot_client, quote)
for item in balances:
free = float(item.get("free", 0.0))
locked = float(item.get("locked", 0.0))
total = free + locked
if total <= 0:
continue
asset = item["asset"]
rows.append(
asdict(
AssetBalance(
market_type="spot",
asset=asset,
free=free,
locked=locked,
total=total,
notional_usdt=total * price_map.get(asset, 0.0),
)
)
)
if include_futures and futures_client is not None:
for item in futures_client.balance():
balance = float(item.get("balance", 0.0))
available = float(item.get("availableBalance", balance))
if balance <= 0:
continue
asset = item["asset"]
rows.append(
asdict(
AssetBalance(
market_type="futures",
asset=asset,
free=available,
locked=max(balance - available, 0.0),
total=balance,
notional_usdt=balance if asset == quote else 0.0,
)
)
)
return {"balances": rows}
def get_positions(
config: dict[str, Any],
*,
include_spot: bool,
include_futures: bool,
spot_client: Any | None = None,
futures_client: Any | None = None,
) -> dict[str, Any]:
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
dust = float(config.get("trading", {}).get("dust_usdt_threshold", 0.0))
rows: list[dict[str, Any]] = []
if include_spot and spot_client is not None:
balances, _, price_map = _spot_account_data(spot_client, quote)
for item in balances:
quantity = float(item.get("free", 0.0)) + float(item.get("locked", 0.0))
if quantity <= 0:
continue
asset = item["asset"]
symbol = quote if asset == quote else f"{asset}{quote}"
mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0)
notional = quantity * mark_price
if notional < dust:
continue
rows.append(
asdict(
PositionView(
market_type="spot",
symbol=symbol,
quantity=quantity,
entry_price=None,
mark_price=mark_price,
notional_usdt=notional,
unrealized_pnl=None,
side="LONG",
)
)
)
if include_futures and futures_client is not None:
for item in futures_client.position_risk():
quantity = float(item.get("positionAmt", 0.0))
notional = abs(float(item.get("notional", 0.0)))
if quantity == 0 or notional < dust:
continue
side = "LONG" if quantity > 0 else "SHORT"
rows.append(
asdict(
PositionView(
market_type="futures",
symbol=normalize_symbol(item["symbol"]),
quantity=abs(quantity),
entry_price=float(item.get("entryPrice", 0.0)),
mark_price=float(item.get("markPrice", 0.0)),
notional_usdt=notional,
unrealized_pnl=float(item.get("unRealizedProfit", 0.0)),
side=side,
)
)
)
return {"positions": rows}
def get_overview(
config: dict[str, Any],
*,
include_spot: bool,
include_futures: bool,
spot_client: Any | None = None,
futures_client: Any | None = None,
) -> dict[str, Any]:
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
dust = float(config.get("trading", {}).get("dust_usdt_threshold", 0.0))
balances: list[dict[str, Any]] = []
positions: list[dict[str, Any]] = []
if include_spot and spot_client is not None:
spot_balances, _, price_map = _spot_account_data(spot_client, quote)
for item in spot_balances:
free = float(item.get("free", 0.0))
locked = float(item.get("locked", 0.0))
total = free + locked
if total <= 0:
continue
asset = item["asset"]
balances.append(
asdict(
AssetBalance(
market_type="spot",
asset=asset,
free=free,
locked=locked,
total=total,
notional_usdt=total * price_map.get(asset, 0.0),
)
)
)
mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0)
notional = total * mark_price
if notional >= dust:
positions.append(
asdict(
PositionView(
market_type="spot",
symbol=quote if asset == quote else f"{asset}{quote}",
quantity=total,
entry_price=None,
mark_price=mark_price,
notional_usdt=notional,
unrealized_pnl=None,
side="LONG",
)
)
)
if include_futures and futures_client is not None:
for item in futures_client.balance():
balance = float(item.get("balance", 0.0))
available = float(item.get("availableBalance", balance))
if balance <= 0:
continue
asset = item["asset"]
balances.append(
asdict(
AssetBalance(
market_type="futures",
asset=asset,
free=available,
locked=max(balance - available, 0.0),
total=balance,
notional_usdt=balance if asset == quote else 0.0,
)
)
)
for item in futures_client.position_risk():
quantity = float(item.get("positionAmt", 0.0))
notional = abs(float(item.get("notional", 0.0)))
if quantity == 0 or notional < dust:
continue
side = "LONG" if quantity > 0 else "SHORT"
positions.append(
asdict(
PositionView(
market_type="futures",
symbol=normalize_symbol(item["symbol"]),
quantity=abs(quantity),
entry_price=float(item.get("entryPrice", 0.0)),
mark_price=float(item.get("markPrice", 0.0)),
notional_usdt=notional,
unrealized_pnl=float(item.get("unRealizedProfit", 0.0)),
side=side,
)
)
)
spot_equity = sum(item["notional_usdt"] for item in balances if item["market_type"] == "spot")
futures_equity = sum(item["notional_usdt"] for item in balances if item["market_type"] == "futures")
overview = asdict(
AccountOverview(
total_equity_usdt=spot_equity + futures_equity,
spot_equity_usdt=spot_equity,
futures_equity_usdt=futures_equity,
spot_asset_count=sum(1 for item in balances if item["market_type"] == "spot"),
futures_position_count=sum(1 for item in positions if item["market_type"] == "futures"),
)
)
return {"overview": overview, "balances": balances, "positions": positions}

View File

@@ -1,105 +0,0 @@
"""Adaptive trigger profile builder for precheck."""
from __future__ import annotations
from .data_utils import to_float
from .precheck_constants import (
BASE_CANDIDATE_SCORE_TRIGGER_RATIO,
BASE_COOLDOWN_MINUTES,
BASE_FORCE_ANALYSIS_AFTER_MINUTES,
BASE_PNL_TRIGGER_PCT,
BASE_PORTFOLIO_MOVE_TRIGGER_PCT,
BASE_PRICE_MOVE_TRIGGER_PCT,
MIN_ACTIONABLE_USDT,
MIN_REAL_POSITION_VALUE_USDT,
)
def build_adaptive_profile(snapshot: dict):
portfolio_value = snapshot.get("portfolio_value_usdt", 0)
free_usdt = snapshot.get("free_usdt", 0)
session = snapshot.get("session")
market = snapshot.get("market_regime", {})
volatility_score = to_float(market.get("volatility_score"), 0)
leader_score = to_float(market.get("leader_score"), 0)
actionable_positions = int(snapshot.get("actionable_positions") or 0)
largest_position_value = to_float(snapshot.get("largest_position_value_usdt"), 0)
capital_band = "micro" if portfolio_value < 25 else "small" if portfolio_value < 100 else "normal"
session_mode = "quiet" if session in {"overnight", "asia-morning"} else "active"
volatility_mode = "high" if volatility_score >= 2.5 or leader_score >= 120 else "normal"
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_PNL_TRIGGER_PCT
portfolio_trigger = BASE_PORTFOLIO_MOVE_TRIGGER_PCT
candidate_ratio = BASE_CANDIDATE_SCORE_TRIGGER_RATIO
force_minutes = BASE_FORCE_ANALYSIS_AFTER_MINUTES
cooldown_minutes = BASE_COOLDOWN_MINUTES
soft_score_threshold = 2.0
if capital_band == "micro":
price_trigger += 0.02
pnl_trigger += 0.03
portfolio_trigger += 0.04
candidate_ratio += 0.25
force_minutes += 180
cooldown_minutes += 30
soft_score_threshold += 1.0
elif capital_band == "small":
price_trigger += 0.01
pnl_trigger += 0.01
portfolio_trigger += 0.01
candidate_ratio += 0.1
force_minutes += 60
cooldown_minutes += 10
soft_score_threshold += 0.5
if session_mode == "quiet":
price_trigger += 0.01
pnl_trigger += 0.01
portfolio_trigger += 0.01
candidate_ratio += 0.05
soft_score_threshold += 0.5
else:
force_minutes = max(120, force_minutes - 30)
if volatility_mode == "high":
price_trigger = max(0.02, price_trigger - 0.01)
pnl_trigger = max(0.025, pnl_trigger - 0.005)
portfolio_trigger = max(0.025, portfolio_trigger - 0.005)
candidate_ratio = max(1.1, candidate_ratio - 0.1)
cooldown_minutes = max(20, cooldown_minutes - 10)
soft_score_threshold = max(1.0, soft_score_threshold - 0.5)
if dust_mode:
candidate_ratio += 0.3
force_minutes += 180
cooldown_minutes += 30
soft_score_threshold += 1.5
return {
"capital_band": capital_band,
"session_mode": session_mode,
"volatility_mode": volatility_mode,
"dust_mode": dust_mode,
"price_move_trigger_pct": round(price_trigger, 4),
"pnl_trigger_pct": round(pnl_trigger, 4),
"portfolio_move_trigger_pct": round(portfolio_trigger, 4),
"candidate_score_trigger_ratio": round(candidate_ratio, 4),
"force_analysis_after_minutes": int(force_minutes),
"cooldown_minutes": int(cooldown_minutes),
"soft_score_threshold": round(soft_score_threshold, 2),
"new_entries_allowed": free_usdt >= MIN_ACTIONABLE_USDT and not dust_mode,
"switching_allowed": actionable_positions > 0 or portfolio_value >= 25,
}
def _candidate_weight(snapshot: dict, profile: dict) -> float:
if not profile.get("new_entries_allowed"):
return 0.5
if profile.get("volatility_mode") == "high":
return 1.5
if snapshot.get("session") in {"europe-open", "us-session"}:
return 1.25
return 1.0

View File

@@ -1,71 +0,0 @@
"""Candidate coin scoring and selection for precheck."""
from __future__ import annotations
import re
from .data_utils import to_float
from .precheck_constants import BLACKLIST, MAX_PRICE_CAP, MIN_CHANGE_PCT, TOP_CANDIDATES
def _liquidity_score(volume: float) -> float:
return min(1.0, max(0.0, volume / 50_000_000))
def _breakout_score(price: float, avg_price: float | None) -> float:
if not avg_price or avg_price <= 0:
return 0.0
return (price - avg_price) / avg_price
def top_candidates_from_tickers(tickers: dict):
candidates = []
for symbol, ticker in tickers.items():
if not symbol.endswith("/USDT"):
continue
base = symbol.replace("/USDT", "")
if base in BLACKLIST:
continue
if not re.fullmatch(r"[A-Z0-9]{2,20}", base):
continue
price = to_float(ticker.get("last"))
change_pct = to_float(ticker.get("percentage"))
volume = to_float(ticker.get("quoteVolume"))
high = to_float(ticker.get("high"))
low = to_float(ticker.get("low"))
avg_price = to_float(ticker.get("average"), None)
if price <= 0:
continue
if MAX_PRICE_CAP is not None and price > MAX_PRICE_CAP:
continue
if volume < 500_000:
continue
if change_pct < MIN_CHANGE_PCT:
continue
momentum = change_pct / 10.0
liquidity = _liquidity_score(volume)
breakout = _breakout_score(price, avg_price)
score = round(momentum * 0.5 + liquidity * 0.3 + breakout * 0.2, 4)
band = "major" if price >= 10 else "mid" if price >= 1 else "meme"
distance_from_high = (high - price) / max(high, 1e-9) if high else None
candidates.append({
"symbol": symbol,
"base": base,
"price": round(price, 8),
"change_24h_pct": round(change_pct, 2),
"volume_24h": round(volume, 2),
"breakout_pct": round(breakout * 100, 2),
"high_24h": round(high, 8) if high else None,
"low_24h": round(low, 8) if low else None,
"distance_from_high_pct": round(distance_from_high * 100, 2) if distance_from_high is not None else None,
"score": score,
"band": band,
})
candidates.sort(key=lambda x: x["score"], reverse=True)
global_top = candidates[:TOP_CANDIDATES]
layers: dict[str, list[dict]] = {"major": [], "mid": [], "meme": []}
for c in candidates:
layers[c["band"]].append(c)
for k in layers:
layers[k] = layers[k][:5]
return global_top, layers

View File

@@ -1,39 +0,0 @@
"""Generic data helpers for precheck."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
def load_json(path: Path, default):
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def stable_hash(data) -> str:
payload = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha1(payload.encode("utf-8")).hexdigest()
def to_float(value, default=0.0):
try:
if value is None:
return default
return float(value)
except Exception:
return default
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

View File

@@ -1,150 +0,0 @@
"""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, get_user_config, load_env_file
_exchange_cache = None
_exchange_cached_at = None
CACHE_TTL_SECONDS = 3600
def load_env():
load_env_file(get_runtime_paths())
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("Missing BINANCE_API_KEY or BINANCE_API_SECRET")
ex = ccxt.binance(
{
"apiKey": api_key,
"secret": secret,
"options": {"defaultType": "spot", "createMarketBuyOrderRequiresPrice": False},
"enableRateLimit": True,
}
)
ex.load_markets()
_exchange_cache = ex
_exchange_cached_at = now
return ex
def norm_symbol(symbol: str) -> str:
s = symbol.upper().replace("-", "").replace("_", "")
if "/" in s:
return s
if s.endswith("USDT"):
return s[:-4] + "/USDT"
raise ValueError(f"Unsupported symbol: {symbol}")
def storage_symbol(symbol: str) -> str:
return norm_symbol(symbol).replace("/", "")
def fetch_balances(ex):
bal = ex.fetch_balance()["free"]
return {k: float(v) for k, v in bal.items() if float(v) > 0}
def build_market_snapshot(ex):
try:
tickers = ex.fetch_tickers()
except Exception:
return {}
snapshot = {}
for sym, t in tickers.items():
if not sym.endswith("/USDT"):
continue
price = t.get("last")
if price is None or float(price) <= 0:
continue
vol = float(t.get("quoteVolume") or 0)
min_volume = get_user_config("exchange.min_quote_volume", 200_000)
if vol < min_volume:
continue
base = sym.replace("/", "")
snapshot[base] = {
"lastPrice": round(float(price), 8),
"price24hPcnt": round(float(t.get("percentage") or 0), 4),
"highPrice24h": round(float(t.get("high") or 0), 8) if t.get("high") else None,
"lowPrice24h": round(float(t.get("low") or 0), 8) if t.get("low") else None,
"turnover24h": round(float(vol), 2),
}
return snapshot
def market_and_ticker(ex, symbol: str):
sym = norm_symbol(symbol)
market = ex.market(sym)
ticker = ex.fetch_ticker(sym)
return sym, market, ticker
def floor_to_step(value: float, step: float) -> float:
if not step or step <= 0:
return value
return math.floor(value / step) * step
def prepare_buy_quantity(ex, symbol: str, amount_usdt: float):
from .trade_common import USDT_BUFFER_PCT
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"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"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"Buy cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}")
return sym, qty, ask, est_cost
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"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"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"Sell cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}")
return sym, qty, bid, est_cost

View File

@@ -1,54 +0,0 @@
"""Execution state helpers (decision deduplication, executions.json)."""
import hashlib
__all__ = [
"default_decision_id",
"load_executions",
"save_executions",
"record_execution_state",
"get_execution_state",
]
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)
bucket_min = (now.minute // 15) * 15
bucket = now.strftime(f"%Y%m%dT%H{bucket_min:02d}")
raw = f"{bucket}|{action}|{'|'.join(argv_tail)}"
return hashlib.sha1(raw.encode()).hexdigest()[:16]
def load_executions() -> dict:
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):
paths = _paths()
save_json_locked(paths.executions_file, paths.executions_lock, {"executions": executions})
def record_execution_state(decision_id: str, payload: dict):
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):
return load_executions().get(decision_id)

View File

@@ -1,76 +0,0 @@
"""File locking and atomic JSON helpers."""
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
@contextmanager
def locked_file(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
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):
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, path)
def load_json_locked(path: Path, lock_path: Path, default):
with locked_file(lock_path):
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return 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)

View File

@@ -1,133 +0,0 @@
"""Market data fetching and metric computation for precheck."""
from __future__ import annotations
import os
import ccxt
from .data_utils import norm_symbol, to_float
def get_exchange():
from ..runtime import load_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:
raise RuntimeError("Missing BINANCE_API_KEY or BINANCE_API_SECRET in ~/.hermes/.env")
ex = ccxt.binance({
"apiKey": api_key,
"secret": secret,
"options": {"defaultType": "spot"},
"enableRateLimit": True,
})
ex.load_markets()
return ex
def fetch_ohlcv_batch(ex, symbols: set, timeframe: str, limit: int):
results = {}
for sym in sorted(symbols):
try:
ohlcv = ex.fetch_ohlcv(sym, timeframe=timeframe, limit=limit)
if ohlcv and len(ohlcv) >= 2:
results[sym] = ohlcv
except Exception:
pass
return results
def compute_ohlcv_metrics(ohlcv_1h, ohlcv_4h, current_price, volume_24h=None):
metrics = {}
if ohlcv_1h and len(ohlcv_1h) >= 2:
closes = [c[4] for c in ohlcv_1h]
volumes = [c[5] for c in ohlcv_1h]
metrics["change_1h_pct"] = round((closes[-1] - closes[-2]) / closes[-2] * 100, 2) if closes[-2] != 0 else None
if len(closes) >= 5:
metrics["change_4h_pct"] = round((closes[-1] - closes[-5]) / closes[-5] * 100, 2) if closes[-5] != 0 else None
recent_vol = sum(volumes[-4:]) / 4 if len(volumes) >= 4 else None
metrics["volume_1h_avg"] = round(recent_vol, 2) if recent_vol else None
highs = [c[2] for c in ohlcv_1h[-4:]]
lows = [c[3] for c in ohlcv_1h[-4:]]
metrics["high_4h"] = round(max(highs), 8) if highs else None
metrics["low_4h"] = round(min(lows), 8) if lows else None
if ohlcv_4h and len(ohlcv_4h) >= 2:
closes_4h = [c[4] for c in ohlcv_4h]
volumes_4h = [c[5] for c in ohlcv_4h]
metrics["change_4h_pct_from_4h"] = round((closes_4h[-1] - closes_4h[-2]) / closes_4h[-2] * 100, 2) if closes_4h[-2] != 0 else None
recent_vol_4h = sum(volumes_4h[-2:]) / 2 if len(volumes_4h) >= 2 else None
metrics["volume_4h_avg"] = round(recent_vol_4h, 2) if recent_vol_4h else None
highs_4h = [c[2] for c in ohlcv_4h]
lows_4h = [c[3] for c in ohlcv_4h]
metrics["high_24h_calc"] = round(max(highs_4h), 8) if highs_4h else None
metrics["low_24h_calc"] = round(min(lows_4h), 8) if lows_4h else None
if highs_4h and lows_4h:
avg_price = sum(closes_4h) / len(closes_4h)
metrics["volatility_4h_pct"] = round((max(highs_4h) - min(lows_4h)) / avg_price * 100, 2)
if current_price:
if metrics.get("high_4h"):
metrics["distance_from_4h_high_pct"] = round((metrics["high_4h"] - current_price) / metrics["high_4h"] * 100, 2)
if metrics.get("low_4h"):
metrics["distance_from_4h_low_pct"] = round((current_price - metrics["low_4h"]) / metrics["low_4h"] * 100, 2)
if metrics.get("high_24h_calc"):
metrics["distance_from_24h_high_pct"] = round((metrics["high_24h_calc"] - current_price) / metrics["high_24h_calc"] * 100, 2)
if metrics.get("low_24h_calc"):
metrics["distance_from_24h_low_pct"] = round((current_price - metrics["low_24h_calc"]) / metrics["low_24h_calc"] * 100, 2)
if volume_24h and volume_24h > 0 and metrics.get("volume_1h_avg"):
daily_avg_1h = volume_24h / 24
metrics["volume_1h_multiple"] = round(metrics["volume_1h_avg"] / daily_avg_1h, 2)
if volume_24h and volume_24h > 0 and metrics.get("volume_4h_avg"):
daily_avg_4h = volume_24h / 6
metrics["volume_4h_multiple"] = round(metrics["volume_4h_avg"] / daily_avg_4h, 2)
return metrics
def enrich_candidates_and_positions(global_candidates, candidate_layers, positions_view, tickers, ex):
symbols = set()
for c in global_candidates:
symbols.add(c["symbol"])
for p in positions_view:
sym = p.get("symbol")
if sym:
sym_ccxt = norm_symbol(sym)
symbols.add(sym_ccxt)
ohlcv_1h = fetch_ohlcv_batch(ex, symbols, "1h", 24)
ohlcv_4h = fetch_ohlcv_batch(ex, symbols, "4h", 12)
def _apply(target_list):
for item in target_list:
sym = item.get("symbol")
if not sym:
continue
sym_ccxt = norm_symbol(sym)
v24h = to_float(tickers.get(sym_ccxt, {}).get("quoteVolume"))
metrics = compute_ohlcv_metrics(
ohlcv_1h.get(sym_ccxt),
ohlcv_4h.get(sym_ccxt),
item.get("price") or item.get("last_price"),
volume_24h=v24h,
)
item["metrics"] = metrics
_apply(global_candidates)
for band_list in candidate_layers.values():
_apply(band_list)
_apply(positions_view)
return global_candidates, candidate_layers, positions_view
def regime_from_pct(pct: float | None) -> str:
if pct is None:
return "unknown"
if pct >= 2.0:
return "bullish"
if pct <= -2.0:
return "bearish"
return "neutral"

View File

@@ -0,0 +1,143 @@
"""Market data services and symbol normalization."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
def normalize_symbol(symbol: str) -> str:
return symbol.upper().replace("/", "").replace("-", "").replace("_", "").strip()
def normalize_symbols(symbols: list[str]) -> list[str]:
seen: set[str] = set()
normalized: list[str] = []
for symbol in symbols:
value = normalize_symbol(symbol)
if value and value not in seen:
normalized.append(value)
seen.add(value)
return normalized
def base_asset(symbol: str, quote_asset: str) -> str:
symbol = normalize_symbol(symbol)
return symbol[: -len(quote_asset)] if symbol.endswith(quote_asset) else symbol
@dataclass
class TickerView:
symbol: str
last_price: float
price_change_pct: float
quote_volume: float
@dataclass
class KlineView:
symbol: str
interval: str
open_time: int
open: float
high: float
low: float
close: float
volume: float
close_time: int
quote_volume: float
def get_tickers(config: dict[str, Any], symbols: list[str], *, spot_client: Any) -> dict[str, Any]:
normalized = normalize_symbols(symbols)
rows = []
for ticker in spot_client.ticker_24h(normalized):
rows.append(
asdict(
TickerView(
symbol=normalize_symbol(ticker["symbol"]),
last_price=float(ticker.get("lastPrice") or ticker.get("last_price") or 0.0),
price_change_pct=float(ticker.get("priceChangePercent") or ticker.get("price_change_percent") or 0.0),
quote_volume=float(ticker.get("quoteVolume") or ticker.get("quote_volume") or 0.0),
)
)
)
return {"tickers": rows}
def get_klines(
config: dict[str, Any],
symbols: list[str],
*,
interval: str,
limit: int,
spot_client: Any,
) -> dict[str, Any]:
normalized = normalize_symbols(symbols)
rows = []
for symbol in normalized:
for item in spot_client.klines(symbol=symbol, interval=interval, limit=limit):
rows.append(
asdict(
KlineView(
symbol=symbol,
interval=interval,
open_time=int(item[0]),
open=float(item[1]),
high=float(item[2]),
low=float(item[3]),
close=float(item[4]),
volume=float(item[5]),
close_time=int(item[6]),
quote_volume=float(item[7]),
)
)
)
return {"interval": interval, "limit": limit, "klines": rows}
def get_scan_universe(
config: dict[str, Any],
*,
spot_client: Any,
symbols: list[str] | None = None,
) -> list[dict[str, Any]]:
market_config = config.get("market", {})
opportunity_config = config.get("opportunity", {})
quote = str(market_config.get("default_quote", "USDT")).upper()
allowlist = set(normalize_symbols(market_config.get("universe_allowlist", [])))
denylist = set(normalize_symbols(market_config.get("universe_denylist", [])))
requested = set(normalize_symbols(symbols or []))
min_quote_volume = float(opportunity_config.get("min_quote_volume", 0.0))
exchange_info = spot_client.exchange_info()
status_map = {normalize_symbol(item["symbol"]): item.get("status", "") for item in exchange_info.get("symbols", [])}
rows: list[dict[str, Any]] = []
for ticker in spot_client.ticker_24h(list(requested) if requested else None):
symbol = normalize_symbol(ticker["symbol"])
if not symbol.endswith(quote):
continue
if allowlist and symbol not in allowlist:
continue
if symbol in denylist:
continue
if requested and symbol not in requested:
continue
if status_map.get(symbol) != "TRADING":
continue
quote_volume = float(ticker.get("quoteVolume") or 0.0)
if quote_volume < min_quote_volume:
continue
rows.append(
{
"symbol": symbol,
"last_price": float(ticker.get("lastPrice") or 0.0),
"price_change_pct": float(ticker.get("priceChangePercent") or 0.0),
"quote_volume": quote_volume,
"high_price": float(ticker.get("highPrice") or 0.0),
"low_price": float(ticker.get("lowPrice") or 0.0),
}
)
rows.sort(key=lambda item: float(item["quote_volume"]), reverse=True)
return rows

View File

@@ -0,0 +1,208 @@
"""Opportunity analysis services."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from statistics import mean
from typing import Any
from ..audit import audit_event
from .account_service import get_positions
from .market_service import base_asset, get_scan_universe, normalize_symbol
@dataclass
class OpportunityRecommendation:
symbol: str
action: str
score: float
reasons: list[str]
metrics: dict[str, float]
def _safe_pct(new: float, old: float) -> float:
if old == 0:
return 0.0
return (new - old) / old
def _score_candidate(closes: list[float], volumes: list[float], ticker: dict[str, Any], weights: dict[str, float], concentration: float) -> tuple[float, dict[str, float]]:
if len(closes) < 2 or not volumes:
return 0.0, {
"trend": 0.0,
"momentum": 0.0,
"breakout": 0.0,
"volume_confirmation": 1.0,
"volatility": 0.0,
"concentration": round(concentration, 4),
}
current = closes[-1]
sma_short = mean(closes[-5:]) if len(closes) >= 5 else current
sma_long = mean(closes[-20:]) if len(closes) >= 20 else mean(closes)
trend = 1.0 if current >= sma_short >= sma_long else -1.0 if current < sma_short < sma_long else 0.0
momentum = (
_safe_pct(closes[-1], closes[-2]) * 0.5
+ (_safe_pct(closes[-1], closes[-5]) * 0.3 if len(closes) >= 5 else 0.0)
+ float(ticker.get("price_change_pct", 0.0)) / 100.0 * 0.2
)
recent_high = max(closes[-20:]) if len(closes) >= 20 else max(closes)
breakout = 1.0 - max((recent_high - current) / recent_high, 0.0)
avg_volume = mean(volumes[:-1]) if len(volumes) > 1 else volumes[-1]
volume_confirmation = volumes[-1] / avg_volume if avg_volume else 1.0
volume_score = min(max(volume_confirmation - 1.0, -1.0), 2.0)
volatility = (max(closes[-10:]) - min(closes[-10:])) / current if len(closes) >= 10 and current else 0.0
score = (
weights.get("trend", 1.0) * trend
+ weights.get("momentum", 1.0) * momentum
+ weights.get("breakout", 0.8) * breakout
+ weights.get("volume", 0.7) * volume_score
- weights.get("volatility_penalty", 0.5) * volatility
- weights.get("position_concentration_penalty", 0.6) * concentration
)
metrics = {
"trend": round(trend, 4),
"momentum": round(momentum, 4),
"breakout": round(breakout, 4),
"volume_confirmation": round(volume_confirmation, 4),
"volatility": round(volatility, 4),
"concentration": round(concentration, 4),
}
return score, metrics
def _action_for(score: float, concentration: float) -> tuple[str, list[str]]:
reasons: list[str] = []
if concentration >= 0.5 and score < 0.4:
reasons.append("position concentration is high")
return "trim", reasons
if score >= 1.5:
reasons.append("trend, momentum, and breakout are aligned")
return "add", reasons
if score >= 0.6:
reasons.append("trend remains constructive")
return "hold", reasons
if score <= -0.2:
reasons.append("momentum and structure have weakened")
return "exit", reasons
reasons.append("signal is mixed and needs confirmation")
return "observe", reasons
def analyze_portfolio(config: dict[str, Any], *, spot_client: Any) -> dict[str, Any]:
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
weights = config.get("opportunity", {}).get("weights", {})
positions = get_positions(config, include_spot=True, include_futures=False, spot_client=spot_client)["positions"]
positions = [item for item in positions if item["market_type"] == "spot" and item["symbol"] != quote]
total_notional = sum(item["notional_usdt"] for item in positions) or 1.0
recommendations = []
for position in positions:
symbol = normalize_symbol(position["symbol"])
klines = spot_client.klines(symbol=symbol, interval="1h", limit=24)
closes = [float(item[4]) for item in klines]
volumes = [float(item[5]) for item in klines]
tickers = spot_client.ticker_24h([symbol])
ticker = tickers[0] if tickers else {"priceChangePercent": "0"}
concentration = position["notional_usdt"] / total_notional
score, metrics = _score_candidate(
closes,
volumes,
{
"price_change_pct": float(ticker.get("priceChangePercent") or 0.0),
},
weights,
concentration,
)
action, reasons = _action_for(score, concentration)
recommendations.append(
asdict(
OpportunityRecommendation(
symbol=symbol,
action=action,
score=round(score, 4),
reasons=reasons,
metrics=metrics,
)
)
)
payload = {"recommendations": sorted(recommendations, key=lambda item: item["score"], reverse=True)}
audit_event(
"opportunity_portfolio_generated",
{
"market_type": "spot",
"symbol": None,
"side": None,
"qty": None,
"quote_amount": None,
"order_type": None,
"dry_run": True,
"request_payload": {"mode": "portfolio"},
"response_payload": payload,
"status": "generated",
"error": None,
},
)
return payload
def scan_opportunities(
config: dict[str, Any],
*,
spot_client: Any,
symbols: list[str] | None = None,
) -> dict[str, Any]:
opportunity_config = config.get("opportunity", {})
weights = opportunity_config.get("weights", {})
scan_limit = int(opportunity_config.get("scan_limit", 50))
top_n = int(opportunity_config.get("top_n", 10))
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
held_positions = get_positions(config, include_spot=True, include_futures=False, spot_client=spot_client)["positions"]
concentration_map = {
normalize_symbol(item["symbol"]): float(item["notional_usdt"])
for item in held_positions
if item["market_type"] == "spot"
}
total_held = sum(concentration_map.values()) or 1.0
universe = get_scan_universe(config, spot_client=spot_client, symbols=symbols)[:scan_limit]
recommendations = []
for ticker in universe:
symbol = normalize_symbol(ticker["symbol"])
klines = spot_client.klines(symbol=symbol, interval="1h", limit=24)
closes = [float(item[4]) for item in klines]
volumes = [float(item[5]) for item in klines]
concentration = concentration_map.get(symbol, 0.0) / total_held
score, metrics = _score_candidate(closes, volumes, ticker, weights, concentration)
action, reasons = _action_for(score, concentration)
if symbol.endswith(quote):
reasons.append(f"base asset {base_asset(symbol, quote)} passed liquidity and tradability filters")
recommendations.append(
asdict(
OpportunityRecommendation(
symbol=symbol,
action=action,
score=round(score, 4),
reasons=reasons,
metrics=metrics,
)
)
)
payload = {"recommendations": sorted(recommendations, key=lambda item: item["score"], reverse=True)[:top_n]}
audit_event(
"opportunity_scan_generated",
{
"market_type": "spot",
"symbol": None,
"side": None,
"qty": None,
"quote_amount": None,
"order_type": None,
"dry_run": True,
"request_payload": {"mode": "scan", "symbols": [normalize_symbol(item) for item in symbols or []]},
"response_payload": payload,
"status": "generated",
"error": None,
},
)
return payload

View File

@@ -1,96 +0,0 @@
"""Portfolio state helpers (positions.json, reconcile with exchange)."""
from ..runtime import get_runtime_paths
__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
def _paths():
return get_runtime_paths()
def load_positions() -> list:
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):
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):
sym = position["symbol"]
for i, existing in enumerate(positions):
if existing.get("symbol") == sym:
positions[i] = position
return positions
positions.append(position)
return positions
def reconcile_positions_with_exchange(ex, positions_hint: list | None = None):
from .exchange_service import fetch_balances
balances = fetch_balances(ex)
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

View File

@@ -1,21 +0,0 @@
"""Analysis helpers for precheck."""
from __future__ import annotations
from .time_utils import utc_iso
def build_failure_payload(exc: Exception) -> dict:
return {
"generated_at": utc_iso(),
"status": "deep_analysis_required",
"should_analyze": True,
"pending_trigger": True,
"cooldown_active": False,
"reasons": ["precheck-error"],
"hard_reasons": ["precheck-error"],
"soft_reasons": [],
"soft_score": 0,
"details": [str(exc)],
"compact_summary": f"Precheck failed, falling back to deep analysis: {exc}",
}

View File

@@ -1,25 +0,0 @@
"""Precheck constants and thresholds."""
from __future__ import annotations
from ..runtime import get_user_config
_BASE = "precheck"
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)

View File

@@ -1,107 +0,0 @@
"""Backward-compatible facade for precheck internals.
The reusable implementation has been split into smaller modules:
- precheck_constants : paths and thresholds
- time_utils : UTC/local time helpers
- data_utils : json, hash, float, symbol normalization
- state_manager : load/save/sanitize state
- market_data : exchange, ohlcv, metrics
- candidate_scoring : top candidate selection
- snapshot_builder : build_snapshot
- adaptive_profile : trigger profile builder
- trigger_analyzer : analyze_trigger
Keep this module importable so older entrypoints continue to work.
"""
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 = {
"BASE_PRICE_MOVE_TRIGGER_PCT": ".precheck_constants",
"BASE_PNL_TRIGGER_PCT": ".precheck_constants",
"BASE_PORTFOLIO_MOVE_TRIGGER_PCT": ".precheck_constants",
"BASE_CANDIDATE_SCORE_TRIGGER_RATIO": ".precheck_constants",
"BASE_FORCE_ANALYSIS_AFTER_MINUTES": ".precheck_constants",
"BASE_COOLDOWN_MINUTES": ".precheck_constants",
"TOP_CANDIDATES": ".precheck_constants",
"MIN_ACTIONABLE_USDT": ".precheck_constants",
"MIN_REAL_POSITION_VALUE_USDT": ".precheck_constants",
"BLACKLIST": ".precheck_constants",
"HARD_STOP_PCT": ".precheck_constants",
"HARD_MOON_PCT": ".precheck_constants",
"MIN_CHANGE_PCT": ".precheck_constants",
"MAX_PRICE_CAP": ".precheck_constants",
"HARD_REASON_DEDUP_MINUTES": ".precheck_constants",
"MAX_PENDING_TRIGGER_MINUTES": ".precheck_constants",
"MAX_RUN_REQUEST_MINUTES": ".precheck_constants",
"utc_now": ".time_utils",
"utc_iso": ".time_utils",
"parse_ts": ".time_utils",
"get_local_now": ".time_utils",
"session_label": ".time_utils",
"load_json": ".data_utils",
"stable_hash": ".data_utils",
"to_float": ".data_utils",
"norm_symbol": ".data_utils",
"load_env": ".state_manager",
"load_positions": ".state_manager",
"load_state": ".state_manager",
"load_config": ".state_manager",
"clear_run_request_fields": ".state_manager",
"sanitize_state_for_stale_triggers": ".state_manager",
"save_state": ".state_manager",
"update_state_after_observation": ".state_manager",
"get_exchange": ".market_data",
"fetch_ohlcv_batch": ".market_data",
"compute_ohlcv_metrics": ".market_data",
"enrich_candidates_and_positions": ".market_data",
"regime_from_pct": ".market_data",
"_liquidity_score": ".candidate_scoring",
"_breakout_score": ".candidate_scoring",
"top_candidates_from_tickers": ".candidate_scoring",
"build_snapshot": ".snapshot_builder",
"build_adaptive_profile": ".adaptive_profile",
"_candidate_weight": ".adaptive_profile",
"analyze_trigger": ".trigger_analyzer",
}
__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]
module = import_module(module_name, __package__)
return getattr(module, name)
def __dir__():
return sorted(set(globals()) | set(__all__))
def main():
import sys
from .precheck_service import run as _run_service
return _run_service(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,44 +0,0 @@
"""Service entrypoint for precheck workflows."""
from __future__ import annotations
import json
__all__ = ["run"]
import sys
from . import precheck_analysis, precheck_snapshot, precheck_state
def run(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if argv and argv[0] == "--ack":
precheck_state.ack_analysis(" ".join(argv[1:]).strip())
return 0
if argv and argv[0] == "--mark-run-requested":
precheck_state.mark_run_requested(" ".join(argv[1:]).strip())
return 0
try:
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:
payload = precheck_analysis.build_failure_payload(exc)
result = {"ok": False, **payload}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 1

View File

@@ -1,4 +0,0 @@
"""Snapshot construction helpers for precheck."""
from __future__ import annotations

View File

@@ -1,41 +0,0 @@
"""State helpers for precheck orchestration."""
from __future__ import annotations
import json
from .state_manager import (
load_state,
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()
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()
payload = {"ok": True, "acked_at": state["last_deep_analysis_at"], "note": note}
print(json.dumps(payload, ensure_ascii=False))
return payload

View File

@@ -1,292 +0,0 @@
"""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)

View File

@@ -1,249 +0,0 @@
"""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",
"balances": "balances",
"balance": "balances",
"acct": "status",
"overview": "status",
"status": "status",
"hold": "hold",
"buy": "buy",
"flat": "sell-all",
"sell-all": "sell-all",
"sell_all": "sell-all",
"rotate": "rebalance",
"rebalance": "rebalance",
"orders": "orders",
"cancel": "cancel",
"order-status": "order-status",
"order_status": "order-status",
}
def add_shared_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--decision-id", help="Override the decision ID; otherwise one is derived automatically")
parser.add_argument("--analysis", help="Persist analysis text with the execution record")
parser.add_argument("--reasoning", help="Persist reasoning text with the execution record")
parser.add_argument("--dry-run", action="store_true", help="Simulate the command without placing live orders")
def build_parser() -> argparse.ArgumentParser:
shared = argparse.ArgumentParser(add_help=False)
add_shared_options(shared)
parser = argparse.ArgumentParser(
prog="coinhunter exec",
description="Professional execution console for account inspection and spot trading workflows",
formatter_class=argparse.RawTextHelpFormatter,
parents=[shared],
epilog=(
"Preferred verbs:\n"
" bal Print live balances as stable JSON\n"
" overview Print balances, positions, and market snapshot as stable JSON\n"
" 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"
" 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"
" 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,orders,order-status,cancel,...}",
)
subparsers.add_parser("bal", parents=[shared], help="Preferred: print live balances as stable JSON")
subparsers.add_parser("overview", parents=[shared], help="Preferred: print the account overview as stable JSON")
subparsers.add_parser("hold", parents=[shared], help="Preferred: record a hold decision without trading")
buy = subparsers.add_parser("buy", parents=[shared], help="Preferred: buy a symbol with a USDT notional amount")
buy.add_argument("symbol")
buy.add_argument("amount_usdt", type=float)
flat = subparsers.add_parser("flat", parents=[shared], help="Preferred: exit an entire symbol position")
flat.add_argument("symbol")
rebalance = subparsers.add_parser("rotate", parents=[shared], help="Preferred: rotate exposure from one symbol into another")
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)
subparsers.add_parser("status", parents=[shared], help=argparse.SUPPRESS)
sell_all = subparsers.add_parser("sell-all", parents=[shared], help=argparse.SUPPRESS)
sell_all.add_argument("symbol")
sell_all_legacy = subparsers.add_parser("sell_all", parents=[shared], help=argparse.SUPPRESS)
sell_all_legacy.add_argument("symbol")
rebalance_legacy = subparsers.add_parser("rebalance", parents=[shared], help=argparse.SUPPRESS)
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
if action.help != argparse.SUPPRESS
]
return parser
def normalize_legacy_argv(argv: list[str]) -> list[str]:
if not argv:
return argv
action_aliases = {
"HOLD": ["hold"],
"hold": ["hold"],
"bal": ["balances"],
"acct": ["status"],
"overview": ["status"],
"flat": ["sell-all"],
"rotate": ["rebalance"],
"SELL_ALL": ["sell-all"],
"sell_all": ["sell-all"],
"sell-all": ["sell-all"],
"BUY": ["buy"],
"buy": ["buy"],
"REBALANCE": ["rebalance"],
"rebalance": ["rebalance"],
"BALANCE": ["balances"],
"balance": ["balances"],
"BALANCES": ["balances"],
"balances": ["balances"],
"STATUS": ["status"],
"status": ["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)
if not has_legacy_flag:
for idx, token in enumerate(argv):
if token in action_aliases:
prefix = argv[:idx]
suffix = argv[idx + 1 :]
return prefix + action_aliases[token] + suffix
if argv[0].startswith("-"):
legacy = argparse.ArgumentParser(add_help=False)
legacy.add_argument("--decision")
legacy.add_argument("--symbol")
legacy.add_argument("--from-symbol")
legacy.add_argument("--to-symbol")
legacy.add_argument("--amount-usdt", type=float)
legacy.add_argument("--decision-id")
legacy.add_argument("--analysis")
legacy.add_argument("--reasoning")
legacy.add_argument("--dry-run", action="store_true")
ns, unknown = legacy.parse_known_args(argv)
if ns.decision:
decision = (ns.decision or "").strip().upper()
rebuilt = []
if ns.decision_id:
rebuilt += ["--decision-id", ns.decision_id]
if ns.analysis:
rebuilt += ["--analysis", ns.analysis]
if ns.reasoning:
rebuilt += ["--reasoning", ns.reasoning]
if ns.dry_run:
rebuilt += ["--dry-run"]
if decision == "HOLD":
rebuilt += ["hold"]
elif decision == "SELL_ALL":
if not ns.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("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("Legacy --decision REBALANCE requires --from-symbol and --to-symbol")
rebuilt += ["rebalance", ns.from_symbol, ns.to_symbol]
else:
raise RuntimeError(f"Unsupported legacy decision: {decision}")
return rebuilt + unknown
return argv
def parse_cli_args(argv: list[str]):
parser = build_parser()
normalized = normalize_legacy_argv(argv)
args = parser.parse_args(normalized)
if not args.command:
parser.print_help()
raise SystemExit(1)
args.command = COMMAND_CANONICAL.get(args.command, args.command)
return args, normalized
def cli_action_args(args, action: str) -> list[str]:
if action == "sell_all":
return [args.symbol]
if action == "buy":
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 []

View File

@@ -1,142 +0,0 @@
"""Service entrypoint for smart executor workflows."""
from __future__ import annotations
import os
__all__ = ["run"]
import sys
from ..logger import log_decision, log_error
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 cli_action_args, parse_cli_args
from .trade_common import bj_now_iso, log, set_dry_run
from .trade_execution import (
action_buy,
action_rebalance,
action_sell_all,
build_decision_context,
command_balances,
command_cancel,
command_order_status,
command_orders,
command_status,
print_json,
)
def run(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
args, normalized_argv = parse_cli_args(argv)
action = args.command.replace("-", "_")
argv_tail = cli_action_args(args, action)
decision_id = (
args.decision_id
or os.getenv("DECISION_ID")
or default_decision_id(action, normalized_argv)
)
if args.dry_run:
set_dry_run(True)
previous = get_execution_state(decision_id)
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} already executed successfully, skipping duplicate")
return 0
try:
from .exchange_service import get_exchange
ex = get_exchange()
if read_only_action:
if action in {"balance", "balances"}:
command_balances(ex)
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)
if args.analysis:
decision_context["analysis"] = args.analysis
elif os.getenv("DECISION_ANALYSIS"):
decision_context["analysis"] = os.getenv("DECISION_ANALYSIS")
if args.reasoning:
decision_context["reasoning"] = args.reasoning
elif os.getenv("DECISION_REASONING"):
decision_context["reasoning"] = os.getenv("DECISION_REASONING")
record_execution_state(
decision_id,
{"status": "pending", "started_at": bj_now_iso(), "action": action, "args": argv_tail},
)
if action == "sell_all":
result = action_sell_all(ex, args.symbol, decision_id, decision_context)
elif action == "buy":
result = action_buy(ex, args.symbol, float(args.amount_usdt), decision_id, decision_context)
elif action == "rebalance":
result = action_rebalance(ex, args.from_symbol, args.to_symbol, decision_id, decision_context)
elif action == "hold":
balances = fetch_balances(ex)
positions = load_positions()
market_snapshot = build_market_snapshot(ex)
log_decision(
{
**decision_context,
"balances_after": balances,
"positions_after": positions,
"market_snapshot": market_snapshot,
"analysis": decision_context.get("analysis", "hold"),
"reasoning": decision_context.get("reasoning", "hold"),
"execution_result": {"status": "hold"},
}
)
log("😴 Decision: hold, no action")
result = {"status": "hold"}
else:
raise RuntimeError(f"Unknown action: {action}; run --help for valid CLI usage")
record_execution_state(
decision_id,
{
"status": "success",
"finished_at": bj_now_iso(),
"action": action,
"args": argv_tail,
"result": result,
},
)
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:
record_execution_state(
decision_id,
{
"status": "failed",
"finished_at": bj_now_iso(),
"action": action,
"args": argv_tail,
"error": str(exc),
},
)
log_error(
"smart_executor",
exc,
decision_id=decision_id,
action=action,
args=argv_tail,
)
log(f"❌ Execution failed: {exc}")
return 1

View File

@@ -1,110 +0,0 @@
"""Snapshot construction for precheck."""
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_REAL_POSITION_VALUE_USDT
from .state_manager import load_config, load_positions
from .time_utils import get_local_now, utc_iso
def build_snapshot():
config = load_config()
local_dt, tz_name = get_local_now(config)
ex = get_exchange()
positions = load_positions()
tickers = ex.fetch_tickers()
balances = ex.fetch_balance()["free"]
free_usdt = to_float(balances.get("USDT"))
positions_view = []
total_position_value = 0.0
largest_position_value = 0.0
actionable_positions = 0
for pos in positions:
symbol = pos.get("symbol") or ""
sym_ccxt = norm_symbol(symbol)
ticker = tickers.get(sym_ccxt, {})
last = to_float(ticker.get("last"), None)
qty = to_float(pos.get("quantity"))
avg_cost = to_float(pos.get("avg_cost"), None)
value = round(qty * last, 4) if last is not None else None
pnl_pct = round((last - avg_cost) / avg_cost, 4) if last is not None and avg_cost else None
high = to_float(ticker.get("high"))
low = to_float(ticker.get("low"))
distance_from_high = (high - last) / max(high, 1e-9) if high and last else None
if value is not None:
total_position_value += value
largest_position_value = max(largest_position_value, value)
if value >= MIN_REAL_POSITION_VALUE_USDT:
actionable_positions += 1
positions_view.append({
"symbol": symbol,
"base_asset": pos.get("base_asset"),
"quantity": qty,
"avg_cost": avg_cost,
"last_price": last,
"market_value_usdt": value,
"pnl_pct": pnl_pct,
"high_24h": round(high, 8) if high else None,
"low_24h": round(low, 8) if low else None,
"distance_from_high_pct": round(distance_from_high * 100, 2) if distance_from_high is not None else None,
})
btc_pct = to_float((tickers.get("BTC/USDT") or {}).get("percentage"), None)
eth_pct = to_float((tickers.get("ETH/USDT") or {}).get("percentage"), None)
global_candidates, candidate_layers = top_candidates_from_tickers(tickers)
global_candidates, candidate_layers, positions_view = enrich_candidates_and_positions(
global_candidates, candidate_layers, positions_view, tickers, ex
)
leader_score = global_candidates[0]["score"] if global_candidates else 0.0
portfolio_value = round(free_usdt + total_position_value, 4)
volatility_score = round(max(abs(to_float(btc_pct, 0)), abs(to_float(eth_pct, 0))), 2)
position_structure = [
{
"symbol": p.get("symbol"),
"base_asset": p.get("base_asset"),
"quantity": round(to_float(p.get("quantity"), 0), 10),
"avg_cost": to_float(p.get("avg_cost"), None),
}
for p in positions_view
]
snapshot = {
"generated_at": utc_iso(),
"timezone": tz_name,
"local_time": local_dt.isoformat(),
"session": get_local_now(config)[0] if False else None, # will be replaced below
"free_usdt": round(free_usdt, 4),
"portfolio_value_usdt": portfolio_value,
"largest_position_value_usdt": round(largest_position_value, 4),
"actionable_positions": actionable_positions,
"positions": positions_view,
"positions_hash": stable_hash(position_structure),
"top_candidates": global_candidates,
"top_candidates_layers": candidate_layers,
"candidates_hash": stable_hash({"global": global_candidates, "layers": candidate_layers}),
"market_regime": {
"btc_24h_pct": round(btc_pct, 2) if btc_pct is not None else None,
"btc_regime": regime_from_pct(btc_pct),
"eth_24h_pct": round(eth_pct, 2) if eth_pct is not None else None,
"eth_regime": regime_from_pct(eth_pct),
"volatility_score": volatility_score,
"leader_score": round(leader_score, 4),
},
}
# fix session after the fact to avoid re-fetching config
snapshot["session"] = None
from .time_utils import session_label
snapshot["session"] = session_label(local_dt)
snapshot["snapshot_hash"] = stable_hash({
"portfolio_value_usdt": snapshot["portfolio_value_usdt"],
"positions_hash": snapshot["positions_hash"],
"candidates_hash": snapshot["candidates_hash"],
"market_regime": snapshot["market_regime"],
"session": snapshot["session"],
})
return snapshot

View File

@@ -1,160 +0,0 @@
"""State management for precheck workflows."""
from __future__ import annotations
from datetime import timedelta
__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 (
MAX_PENDING_TRIGGER_MINUTES,
MAX_RUN_REQUEST_MINUTES,
)
from .time_utils import parse_ts, utc_iso, utc_now
def _paths():
return get_runtime_paths()
def load_env() -> None:
load_env_file(_paths())
def load_positions():
return load_json(_paths().positions_file, {}).get("positions", [])
def load_state():
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(_paths().config_file, {})
def clear_run_request_fields(state: dict):
state.pop("run_requested_at", None)
state.pop("run_request_note", None)
def sanitize_state_for_stale_triggers(state: dict):
sanitized = dict(state)
notes = []
now = utc_now()
run_requested_at = parse_ts(sanitized.get("run_requested_at"))
last_deep_analysis_at = parse_ts(sanitized.get("last_deep_analysis_at"))
last_triggered_at = parse_ts(sanitized.get("last_triggered_at"))
pending_trigger = bool(sanitized.get("pending_trigger"))
if run_requested_at and last_deep_analysis_at and last_deep_analysis_at >= run_requested_at:
clear_run_request_fields(sanitized)
if pending_trigger and (not last_triggered_at or last_deep_analysis_at >= last_triggered_at):
sanitized["pending_trigger"] = False
sanitized["pending_reasons"] = []
sanitized["last_ack_note"] = (
f"auto-cleared completed trigger at {utc_iso()} because last_deep_analysis_at >= run_requested_at"
)
pending_trigger = False
notes.append(
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"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
pending_anchor = run_requested_at or last_triggered_at or last_deep_analysis_at
if pending_trigger and pending_anchor and now - pending_anchor > timedelta(minutes=MAX_PENDING_TRIGGER_MINUTES):
sanitized["pending_trigger"] = False
sanitized["pending_reasons"] = []
sanitized["last_ack_note"] = (
f"auto-recovered stale pending trigger at {utc_iso()} after waiting "
f"{(now - pending_anchor).total_seconds() / 60:.1f} minutes"
)
notes.append(
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
return sanitized
def save_state(state: dict):
paths = _paths()
paths.state_dir.mkdir(parents=True, exist_ok=True)
state_to_save = dict(state)
state_to_save.pop("_stale_recovery_notes", None)
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):
new_state = dict(state)
new_state.update({
"last_observed_at": snapshot["generated_at"],
"last_snapshot_hash": snapshot["snapshot_hash"],
"last_positions_hash": snapshot["positions_hash"],
"last_candidates_hash": snapshot["candidates_hash"],
"last_portfolio_value_usdt": snapshot["portfolio_value_usdt"],
"last_market_regime": snapshot["market_regime"],
"last_positions_map": {
p["symbol"]: {"last_price": p.get("last_price"), "pnl_pct": p.get("pnl_pct")}
for p in snapshot["positions"]
},
"last_top_candidate": snapshot["top_candidates"][0] if snapshot["top_candidates"] else None,
"last_candidates_layers": snapshot.get("top_candidates_layers", {}),
"last_adaptive_profile": analysis.get("adaptive_profile", {}),
})
if analysis["should_analyze"]:
new_state["pending_trigger"] = True
new_state["pending_reasons"] = analysis["details"]
new_state["last_triggered_at"] = snapshot["generated_at"]
new_state["last_trigger_snapshot_hash"] = snapshot["snapshot_hash"]
new_state["last_trigger_hard_reasons"] = analysis.get("hard_reasons", [])
new_state["last_trigger_signal_delta"] = analysis.get("signal_delta", 0.0)
last_hard_reasons_at = dict(state.get("last_hard_reasons_at", {}))
for hr in analysis.get("hard_reasons", []):
last_hard_reasons_at[hr] = snapshot["generated_at"]
cutoff = utc_now() - timedelta(hours=24)
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

View File

@@ -1,49 +0,0 @@
"""Time utilities for precheck."""
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def utc_iso() -> str:
return utc_now().isoformat()
def parse_ts(value: str | None) -> datetime | None:
if not value:
return None
try:
ts = datetime.fromisoformat(value)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts
except Exception:
return None
def get_local_now(config: dict) -> tuple[datetime, str]:
tz_name = config.get("timezone") or "Asia/Shanghai"
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Asia/Shanghai")
tz_name = "Asia/Shanghai"
return utc_now().astimezone(tz), tz_name
def session_label(local_dt: datetime) -> str:
hour = local_dt.hour
if 0 <= hour < 7:
return "overnight"
if 7 <= hour < 12:
return "asia-morning"
if 12 <= hour < 17:
return "asia-afternoon"
if 17 <= hour < 21:
return "europe-open"
return "us-session"

View File

@@ -1,28 +0,0 @@
"""Common trade utilities (time, logging, constants)."""
import os
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 = 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:
return _DRY_RUN["value"]
def set_dry_run(value: bool):
_DRY_RUN["value"] = value
def log(msg: str):
print(f"[{datetime.now(CST).strftime('%Y-%m-%d %H:%M:%S')} CST] {msg}", file=sys.stderr)
def bj_now_iso():
return datetime.now(CST).isoformat()

View File

@@ -1,243 +0,0 @@
"""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,
prepare_buy_quantity,
prepare_sell_quantity,
storage_symbol,
)
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:
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
def build_decision_context(ex, action: str, argv_tail: list[str], decision_id: str):
balances = fetch_balances(ex)
positions = load_positions()
return {
"decision_id": decision_id,
"balances_before": balances,
"positions_before": positions,
"decision": action.upper(),
"action_taken": f"{action} {' '.join(argv_tail)}".strip(),
"risk_level": "high" if len(positions) <= 1 else "medium",
"data_sources": ["binance"],
}
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] 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
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] 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
def action_sell_all(ex, symbol: str, decision_id: str, decision_context: dict):
balances_before = fetch_balances(ex)
base = norm_symbol(symbol).split("/")[0]
qty = float(balances_before.get(base, 0))
if qty <= 0:
raise RuntimeError(f"{base} balance is zero, cannot sell")
order = market_sell(ex, symbol, qty, decision_id)
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),
qty=order.get("amount"),
price=order.get("price"),
amount_usdt=order.get("cost"),
note="Smart executor sell_all",
decision_id=decision_id,
order_id=order.get("id"),
status=order.get("status"),
balances_before=balances_before,
balances_after=balances_after,
)
log_decision(
{
**decision_context,
"balances_after": balances_after,
"positions_after": positions_after,
"execution_result": {"order": order},
"analysis": decision_context.get("analysis", ""),
"reasoning": decision_context.get("reasoning", "sell_all execution"),
}
)
return order
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"Insufficient USDT balance (${usdt:.4f} < ${amount_usdt:.4f})")
order = market_buy(ex, symbol, amount_usdt, decision_id)
sym_store = storage_symbol(symbol)
price = float(order.get("price") or 0)
qty = float(order.get("amount") or 0)
position = {
"account_id": "binance-main",
"symbol": sym_store,
"base_asset": norm_symbol(symbol).split("/")[0],
"quote_asset": "USDT",
"market_type": "spot",
"quantity": qty,
"avg_cost": price,
"opened_at": bj_now_iso(),
"updated_at": bj_now_iso(),
"note": "Smart executor entry",
}
if is_dry_run():
balances_after = balances_before
positions_after = load_positions()
upsert_position(positions_after, position)
else:
update_positions(lambda p: upsert_position(p, position))
positions_after, balances_after = reconcile_positions_with_exchange(ex, [position])
log_trade(
"BUY",
norm_symbol(symbol),
qty=qty,
amount_usdt=order.get("cost"),
price=price,
note="Smart executor buy",
decision_id=decision_id,
order_id=order.get("id"),
status=order.get("status"),
balances_before=balances_before,
balances_after=balances_after,
)
log_decision(
{
**decision_context,
"balances_after": balances_after,
"positions_after": positions_after,
"execution_result": {"order": order},
"analysis": decision_context.get("analysis", ""),
"reasoning": decision_context.get("reasoning", "buy execution"),
}
)
return order
def action_rebalance(ex, from_symbol: str, to_symbol: str, decision_id: str, decision_context: dict):
sell_order = action_sell_all(ex, from_symbol, decision_id + "s", decision_context)
if is_dry_run():
sell_cost = float(sell_order.get("cost") or 0)
spend = sell_cost * (1 - USDT_BUFFER_PCT)
simulated_usdt = sell_cost
else:
balances = fetch_balances(ex)
usdt = float(balances.get("USDT", 0))
spend = usdt * (1 - USDT_BUFFER_PCT)
simulated_usdt = None
if spend < 5:
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}
def command_status(ex):
balances = fetch_balances(ex)
positions = load_positions()
market_snapshot = build_market_snapshot(ex)
payload = {
"balances": balances,
"positions": positions,
"market_snapshot": market_snapshot,
}
print_json(payload)
return payload
def command_balances(ex):
balances = fetch_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

View File

@@ -0,0 +1,263 @@
"""Trade execution services."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
from ..audit import audit_event
from .market_service import normalize_symbol
@dataclass
class TradeIntent:
market_type: str
symbol: str
side: str
order_type: str
qty: float | None
quote_amount: float | None
price: float | None
reduce_only: bool
dry_run: bool
@dataclass
class TradeResult:
market_type: str
symbol: str
side: str
order_type: str
status: str
dry_run: bool
request_payload: dict[str, Any]
response_payload: dict[str, Any]
def _default_dry_run(config: dict[str, Any], dry_run: bool | None) -> bool:
if dry_run is not None:
return dry_run
return bool(config.get("trading", {}).get("dry_run_default", False))
def _trade_log_payload(intent: TradeIntent, payload: dict[str, Any], *, status: str, error: str | None = None) -> dict[str, Any]:
return {
"market_type": intent.market_type,
"symbol": intent.symbol,
"side": intent.side,
"qty": intent.qty,
"quote_amount": intent.quote_amount,
"order_type": intent.order_type,
"dry_run": intent.dry_run,
"request_payload": payload,
"response_payload": {} if error else payload,
"status": status,
"error": error,
}
def execute_spot_trade(
config: dict[str, Any],
*,
side: str,
symbol: str,
qty: float | None,
quote: float | None,
order_type: str,
price: float | None,
dry_run: bool | None,
spot_client: Any,
) -> dict[str, Any]:
normalized_symbol = normalize_symbol(symbol)
order_type = order_type.upper()
side = side.upper()
is_dry_run = _default_dry_run(config, dry_run)
if side == "BUY" and order_type == "MARKET":
if quote is None:
raise RuntimeError("Spot market buy requires --quote")
if qty is not None:
raise RuntimeError("Spot market buy accepts --quote only; do not pass --qty")
if side == "SELL":
if qty is None:
raise RuntimeError("Spot sell requires --qty")
if quote is not None:
raise RuntimeError("Spot sell accepts --qty only; do not pass --quote")
if order_type == "LIMIT" and (qty is None or price is None):
raise RuntimeError("Limit orders require both --qty and --price")
payload: dict[str, Any] = {
"symbol": normalized_symbol,
"side": side,
"type": order_type,
}
if qty is not None:
payload["quantity"] = qty
if quote is not None:
payload["quoteOrderQty"] = quote
if price is not None:
payload["price"] = price
payload["timeInForce"] = "GTC"
intent = TradeIntent(
market_type="spot",
symbol=normalized_symbol,
side=side,
order_type=order_type,
qty=qty,
quote_amount=quote,
price=price,
reduce_only=False,
dry_run=is_dry_run,
)
audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"))
if is_dry_run:
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
result = asdict(
TradeResult(
market_type="spot",
symbol=normalized_symbol,
side=side,
order_type=order_type,
status="DRY_RUN",
dry_run=True,
request_payload=payload,
response_payload=response,
)
)
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response})
return {"trade": result}
try:
response = spot_client.new_order(**payload)
except Exception as exc:
audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc)))
raise RuntimeError(f"Spot order failed: {exc}") from exc
result = asdict(
TradeResult(
market_type="spot",
symbol=normalized_symbol,
side=side,
order_type=order_type,
status=str(response.get("status", "UNKNOWN")),
dry_run=False,
request_payload=payload,
response_payload=response,
)
)
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response})
return {"trade": result}
def execute_futures_trade(
config: dict[str, Any],
*,
side: str,
symbol: str,
qty: float,
order_type: str,
price: float | None,
reduce_only: bool,
dry_run: bool | None,
futures_client: Any,
) -> dict[str, Any]:
normalized_symbol = normalize_symbol(symbol)
order_type = order_type.upper()
side = side.upper()
is_dry_run = _default_dry_run(config, dry_run)
if qty <= 0:
raise RuntimeError("Futures orders require a positive --qty")
if order_type == "LIMIT" and price is None:
raise RuntimeError("Futures limit orders require --price")
payload: dict[str, Any] = {
"symbol": normalized_symbol,
"side": side,
"type": order_type,
"quantity": qty,
"reduceOnly": "true" if reduce_only else "false",
}
if price is not None:
payload["price"] = price
payload["timeInForce"] = "GTC"
intent = TradeIntent(
market_type="futures",
symbol=normalized_symbol,
side=side,
order_type=order_type,
qty=qty,
quote_amount=None,
price=price,
reduce_only=reduce_only,
dry_run=is_dry_run,
)
audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"))
if is_dry_run:
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
result = asdict(
TradeResult(
market_type="futures",
symbol=normalized_symbol,
side=side,
order_type=order_type,
status="DRY_RUN",
dry_run=True,
request_payload=payload,
response_payload=response,
)
)
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response})
return {"trade": result}
try:
response = futures_client.new_order(**payload)
except Exception as exc:
audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc)))
raise RuntimeError(f"Futures order failed: {exc}") from exc
result = asdict(
TradeResult(
market_type="futures",
symbol=normalized_symbol,
side=side,
order_type=order_type,
status=str(response.get("status", "UNKNOWN")),
dry_run=False,
request_payload=payload,
response_payload=response,
)
)
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response})
return {"trade": result}
def close_futures_position(
config: dict[str, Any],
*,
symbol: str,
dry_run: bool | None,
futures_client: Any,
) -> dict[str, Any]:
normalized_symbol = normalize_symbol(symbol)
positions = futures_client.position_risk(normalized_symbol)
target = next((item for item in positions if normalize_symbol(item["symbol"]) == normalized_symbol), None)
if target is None:
raise RuntimeError(f"No futures position found for {normalized_symbol}")
position_amt = float(target.get("positionAmt", 0.0))
if position_amt == 0:
raise RuntimeError(f"No open futures position for {normalized_symbol}")
side = "SELL" if position_amt > 0 else "BUY"
return execute_futures_trade(
config,
side=side,
symbol=normalized_symbol,
qty=abs(position_amt),
order_type="MARKET",
price=None,
reduce_only=True,
dry_run=dry_run,
futures_client=futures_client,
)

View File

@@ -1,317 +0,0 @@
"""Trigger analysis logic for precheck."""
from __future__ import annotations
from datetime import timedelta
from .adaptive_profile import _candidate_weight, build_adaptive_profile
from .data_utils import to_float
from .precheck_constants import (
HARD_MOON_PCT,
HARD_REASON_DEDUP_MINUTES,
HARD_STOP_PCT,
MIN_REAL_POSITION_VALUE_USDT,
)
from .time_utils import parse_ts, utc_now
def analyze_trigger(snapshot: dict, state: dict):
reasons = []
details = list(state.get("_stale_recovery_notes", []))
hard_reasons = []
soft_reasons = []
soft_score = 0.0
profile = build_adaptive_profile(snapshot)
market = snapshot.get("market_regime", {})
now = utc_now()
last_positions_hash = state.get("last_positions_hash")
last_portfolio_value = state.get("last_portfolio_value_usdt")
last_market_regime = state.get("last_market_regime", {})
last_positions_map = state.get("last_positions_map", {})
last_top_candidate = state.get("last_top_candidate")
pending_trigger = bool(state.get("pending_trigger"))
run_requested_at = parse_ts(state.get("run_requested_at"))
last_deep_analysis_at = parse_ts(state.get("last_deep_analysis_at"))
last_triggered_at = parse_ts(state.get("last_triggered_at"))
last_trigger_snapshot_hash = state.get("last_trigger_snapshot_hash")
last_hard_reasons_at = state.get("last_hard_reasons_at", {})
price_trigger = profile["price_move_trigger_pct"]
pnl_trigger = profile["pnl_trigger_pct"]
portfolio_trigger = profile["portfolio_move_trigger_pct"]
candidate_ratio_trigger = profile["candidate_score_trigger_ratio"]
force_minutes = profile["force_analysis_after_minutes"]
cooldown_minutes = profile["cooldown_minutes"]
soft_score_threshold = profile["soft_score_threshold"]
if pending_trigger:
reasons.append("pending-trigger-unacked")
hard_reasons.append("pending-trigger-unacked")
details.append("Previous deep analysis trigger has not been acknowledged yet")
if run_requested_at:
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("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"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("Position structure has changed")
if last_portfolio_value not in (None, 0):
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 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 value moved {portfolio_delta:.1%}, threshold {portfolio_trigger:.1%}")
for pos in snapshot["positions"]:
symbol = pos["symbol"]
prev = last_positions_map.get(symbol, {})
cur_price = pos.get("last_price")
prev_price = prev.get("last_price")
cur_pnl = pos.get("pnl_pct")
prev_pnl = prev.get("pnl_pct")
market_value = to_float(pos.get("market_value_usdt"), 0)
actionable_position = market_value >= MIN_REAL_POSITION_VALUE_USDT
if cur_price and prev_price:
price_move = abs(cur_price - prev_price) / max(prev_price, 1e-9)
if price_move >= price_trigger:
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 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 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} 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} 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 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 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"Candidate {cand['symbol']} 24h change {cand['change_24h_pct']:.1f}%, hard moon trigger")
candidate_weight = _candidate_weight(snapshot, profile)
last_layers = state.get("last_candidates_layers", {})
current_layers = snapshot.get("top_candidates_layers", {})
for band in ("major", "mid", "meme"):
cur_band = current_layers.get(band, [])
prev_band = last_layers.get(band, [])
cur_leader = cur_band[0] if cur_band else None
prev_leader = prev_band[0] if prev_band else None
if cur_leader and prev_leader and cur_leader["symbol"] != prev_leader["symbol"]:
score_ratio = cur_leader.get("score", 0) / max(prev_leader.get("score", 0.0001), 0.0001)
if score_ratio >= candidate_ratio_trigger:
reasons.append(f"new-leader-{band}:{cur_leader['symbol']}")
soft_reasons.append(f"new-leader-{band}:{cur_leader['symbol']}")
soft_score += candidate_weight * 0.7
details.append(
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
if last_top_candidate and current_leader:
if current_leader.get("symbol") != last_top_candidate.get("symbol"):
score_ratio = current_leader.get("score", 0) / max(last_top_candidate.get("score", 0.0001), 0.0001)
if score_ratio >= candidate_ratio_trigger:
reasons.append("new-leader")
soft_reasons.append("new-leader")
soft_score += candidate_weight
details.append(
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"First recorded candidate leader {current_leader.get('symbol')}")
def _signal_delta() -> float:
delta = 0.0
if last_trigger_snapshot_hash and snapshot.get("snapshot_hash") != last_trigger_snapshot_hash:
delta += 0.5
if snapshot["positions_hash"] != last_positions_hash:
delta += 1.5
for pos in snapshot["positions"]:
symbol = pos["symbol"]
prev = last_positions_map.get(symbol, {})
cur_price = pos.get("last_price")
prev_price = prev.get("last_price")
cur_pnl = pos.get("pnl_pct")
prev_pnl = prev.get("pnl_pct")
if cur_price and prev_price and abs(cur_price - prev_price) / max(prev_price, 1e-9) >= 0.02:
delta += 0.5
if cur_pnl is not None and prev_pnl is not None and abs(cur_pnl - prev_pnl) >= 0.03:
delta += 0.5
last_leader = state.get("last_top_candidate")
if current_leader and last_leader and current_leader.get("symbol") != last_leader.get("symbol"):
delta += 1.0
for band in ("major", "mid", "meme"):
cur_band = current_layers.get(band, [])
prev_band = last_layers.get(band, [])
cur_l = cur_band[0] if cur_band else None
prev_l = prev_band[0] if prev_band else None
if cur_l and prev_l and cur_l.get("symbol") != prev_l.get("symbol"):
delta += 0.5
if last_market_regime:
if current_market.get("btc_regime") != last_market_regime.get("btc_regime"):
delta += 1.5
if current_market.get("eth_regime") != last_market_regime.get("eth_regime"):
delta += 1.5
if last_portfolio_value not in (None, 0):
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 [])}
current_hard_types = {r.split(":")[0] for r in hard_reasons}
if current_hard_types - last_trigger_hard_types:
delta += 2.0
return delta
signal_delta = _signal_delta()
effective_cooldown = cooldown_minutes
if signal_delta < 1.0:
effective_cooldown = max(cooldown_minutes, 90)
elif signal_delta >= 2.5:
effective_cooldown = max(0, cooldown_minutes - 15)
cooldown_active = bool(last_triggered_at and now - last_triggered_at < timedelta(minutes=effective_cooldown))
dedup_window = timedelta(minutes=HARD_REASON_DEDUP_MINUTES)
for hr in list(hard_reasons):
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} 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("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("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"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"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: {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("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"
compact_lines.append(
f"- {pos['symbol']}: qty={pos['quantity']}, px={pos.get('last_price')}, pnl={pnl_text}, value=${pos.get('market_value_usdt')}"
)
else:
compact_lines.append("Positions: no spot positions currently")
if snapshot["top_candidates"]:
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']}"
)
layers = snapshot.get("top_candidates_layers", {})
for band, band_cands in layers.items():
if band_cands:
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("Trigger notes:")
for item in details:
compact_lines.append(f"- {item}")
return {
"generated_at": snapshot["generated_at"],
"status": status,
"should_analyze": should_analyze,
"pending_trigger": pending_trigger,
"run_requested": bool(run_requested_at),
"run_requested_at": run_requested_at.isoformat() if run_requested_at else None,
"cooldown_active": cooldown_active,
"effective_cooldown_minutes": effective_cooldown,
"signal_delta": round(signal_delta, 2),
"reasons": reasons,
"hard_reasons": hard_reasons,
"soft_reasons": soft_reasons,
"soft_score": round(soft_score, 3),
"adaptive_profile": profile,
"portfolio_value_usdt": snapshot["portfolio_value_usdt"],
"free_usdt": snapshot["free_usdt"],
"market_regime": snapshot["market_regime"],
"session": snapshot["session"],
"positions": snapshot["positions"],
"top_candidates": snapshot["top_candidates"],
"top_candidates_layers": layers,
"snapshot_hash": snapshot["snapshot_hash"],
"compact_summary": "\n".join(compact_lines),
"details": details,
}

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""Backward-compatible facade for smart executor workflows.
The executable implementation lives in ``coinhunter.services.smart_executor_service``.
This module stays importable for older callers without importing the whole trading
stack up front.
"""
from __future__ import annotations
import sys
from importlib import import_module
_EXPORT_MAP = {
"PATHS": (".runtime", "get_runtime_paths"),
"ENV_FILE": (".runtime", "get_runtime_paths"),
"load_env_file": (".runtime", "load_env_file"),
"CST": (".services.trade_common", "CST"),
"USDT_BUFFER_PCT": (".services.trade_common", "USDT_BUFFER_PCT"),
"MIN_REMAINING_DUST_USDT": (".services.trade_common", "MIN_REMAINING_DUST_USDT"),
"is_dry_run": (".services.trade_common", "is_dry_run"),
"log": (".services.trade_common", "log"),
"bj_now_iso": (".services.trade_common", "bj_now_iso"),
"set_dry_run": (".services.trade_common", "set_dry_run"),
"locked_file": (".services.file_utils", "locked_file"),
"atomic_write_json": (".services.file_utils", "atomic_write_json"),
"load_json_locked": (".services.file_utils", "load_json_locked"),
"save_json_locked": (".services.file_utils", "save_json_locked"),
"build_parser": (".services.smart_executor_parser", "build_parser"),
"normalize_legacy_argv": (".services.smart_executor_parser", "normalize_legacy_argv"),
"parse_cli_args": (".services.smart_executor_parser", "parse_cli_args"),
"cli_action_args": (".services.smart_executor_parser", "cli_action_args"),
"default_decision_id": (".services.execution_state", "default_decision_id"),
"record_execution_state": (".services.execution_state", "record_execution_state"),
"get_execution_state": (".services.execution_state", "get_execution_state"),
"load_executions": (".services.execution_state", "load_executions"),
"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"),
"norm_symbol": (".services.exchange_service", "norm_symbol"),
"storage_symbol": (".services.exchange_service", "storage_symbol"),
"fetch_balances": (".services.exchange_service", "fetch_balances"),
"build_market_snapshot": (".services.exchange_service", "build_market_snapshot"),
"market_and_ticker": (".services.exchange_service", "market_and_ticker"),
"floor_to_step": (".services.exchange_service", "floor_to_step"),
"prepare_buy_quantity": (".services.exchange_service", "prepare_buy_quantity"),
"prepare_sell_quantity": (".services.exchange_service", "prepare_sell_quantity"),
"build_decision_context": (".services.trade_execution", "build_decision_context"),
"market_sell": (".services.trade_execution", "market_sell"),
"market_buy": (".services.trade_execution", "market_buy"),
"action_sell_all": (".services.trade_execution", "action_sell_all"),
"action_buy": (".services.trade_execution", "action_buy"),
"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"})
def __getattr__(name: str):
if name == "PATHS":
runtime = import_module(".runtime", __package__)
return runtime.get_runtime_paths()
if name == "ENV_FILE":
runtime = import_module(".runtime", __package__)
return runtime.get_runtime_paths().env_file
if name == "load_env":
return load_env
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__)
if name == "PATHS":
return getattr(module, attr_name)()
if name == "ENV_FILE":
return getattr(module, attr_name)().env_file
return getattr(module, attr_name)
def __dir__():
return sorted(set(globals()) | set(__all__))
def load_env():
runtime = import_module(".runtime", __package__)
runtime.load_env_file(runtime.get_runtime_paths())
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)
if __name__ == "__main__":
raise SystemExit(main())