From 52cd76a75082db886ced075e4d52f73ad8aa3637 Mon Sep 17 00:00:00 2001
From: Tacit Lab
-
+
- Runtime-safe trading operations, precheck orchestration, review tooling, and market probes. + A Binance-first crypto trading CLI for balances, market data, opportunity scanning, and execution.
--- -## What is this? - -`coinhunter` is the **executable tooling layer** for CoinHunter — an installable Python CLI that handles trading operations, market probes, precheck orchestration, and review workflows. - -> **Note:** The old package name `coinhunter-cli` is deprecated. Please install `coinhunter` going forward. - -| Layer | Responsibility | Location | -|-------|----------------|----------| -| **CLI** | Top-level command router | `cli.py` | -| **Commands** | Thin argument adapters | `commands/` | -| **Services** | Orchestration & execution logic | `services/` | -| **Runtime** | Paths, env, locks, config | `runtime.py` | -| **User Data** | State, logs, reviews, positions | `~/.coinhunter/` | - -> **Separation of concerns:** Code lives in this repo. Your data lives in `~/.coinhunter/`. Strategy and prompting live in Hermes skills. - ---- - -## Project Structure - -```text -src/coinhunter/ -├── cli.py # Unified command router -├── runtime.py # Runtime paths + env loading -├── logger.py # Structured logging utilities -├── commands/ # CLI adapters (thin, stateless) -│ ├── precheck.py -│ ├── smart_executor.py -│ ├── check_api.py -│ ├── doctor.py -│ ├── external_gate.py -│ ├── init_user_state.py -│ ├── market_probe.py -│ ├── paths.py -│ ├── review_context.py -│ ├── review_engine.py -│ └── rotate_external_gate_log.py -├── services/ # Orchestration & domain logic -│ ├── exchange_service.py -│ ├── portfolio_service.py -│ ├── trade_execution.py -│ ├── smart_executor_service.py -│ ├── smart_executor_parser.py -│ ├── execution_state.py -│ ├── precheck_service.py -│ ├── review_service.py # review generation logic -│ ├── precheck_constants.py # thresholds -│ ├── time_utils.py # UTC/local time helpers -│ ├── data_utils.py # json, hash, float, symbol norm -│ ├── state_manager.py # state load/save/sanitize -│ ├── market_data.py # exchange, OHLCV, metrics -│ ├── candidate_scoring.py # coin selection & scoring -│ ├── snapshot_builder.py # precheck snapshot construction -│ ├── adaptive_profile.py # trigger profile builder -│ ├── trigger_analyzer.py # trigger analysis core -│ ├── precheck_analysis.py # failure payloads -│ ├── precheck_snapshot.py # snapshot facade -│ ├── precheck_state.py # state facade -│ └── precheck_core.py # backward-compat export facade -├── precheck.py # Backward-compat root facade -├── smart_executor.py # Backward-compat root facade -└── *.py # Other compat / utility modules -``` - ---- - -## Installation - -### From PyPI (recommended) - -`coinhunter` is a standalone CLI application. Use [pipx](https://pypa.github.io/pipx/) to install it in an isolated environment: - -```bash -pipx install coinhunter -``` - -This installs the latest stable release and creates the `coinhunter` console script entry point. - -Verify: - -```bash -coinhunter --help -coinhunter --version -``` - -> On newer Linux distributions, installing directly into the system Python with `pip install coinhunter` will fail with `externally-managed-environment`. Use `pipx` or a virtual environment instead. - -### Development install (editable) - -If you're working on this repo locally: +## Install ```bash pip install -e ".[dev]" +coinhunter --help ``` -Or use the convenience script: - -```bash -./scripts/install_local.sh -``` - -A thin wrapper that runs `pip install -e .` and verifies the entrypoint is on your PATH. - ---- - -## Command Reference - -### Short aliases (recommended) - -```bash -coinhunter diag # runtime diagnostics -coinhunter paths # print resolved paths -coinhunter api-check # validate exchange credentials -coinhunter precheck # run precheck snapshot + trigger analysis -coinhunter exec bal # print balances as JSON -coinhunter exec overview # account overview as JSON -coinhunter exec hold # record a HOLD decision -coinhunter exec buy ENJUSDT 50 # buy $50 of ENJUSDT -coinhunter exec flat ENJUSDT # sell entire ENJUSDT position -coinhunter exec rotate PEPEUSDT ETHUSDT # rotate exposure -coinhunter exec orders # list open spot orders -coinhunter exec order-status ENJUSDT 123456 # check specific order -coinhunter exec cancel ENJUSDT 123456 # cancel an open order -coinhunter gate # external gate orchestration -coinhunter review 12 # generate review context (last 12h) -coinhunter recap 12 # generate review report (last 12h) -coinhunter probe bybit-ticker BTCUSDT # market probe -coinhunter rotate-log # rotate external gate logs -``` - -### Legacy long forms (still supported) - -```bash -coinhunter doctor -coinhunter check-api -coinhunter smart-executor bal -coinhunter review-context 12 -coinhunter review-engine 12 -coinhunter market-probe bybit-ticker BTCUSDT -coinhunter external-gate -coinhunter rotate-external-gate-log -``` - -### All supported commands - -| Canonical | Aliases | -|-----------|---------| -| `check-api` | `api-check` | -| `doctor` | `diag` | -| `external-gate` | `gate` | -| `init` | — | -| `market-probe` | `probe` | -| `paths` | — | -| `precheck` | — | -| `review-context` | `review` | -| `review-engine` | `recap` | -| `rotate-external-gate-log` | `rotate-gate-log`, `rotate-log` | -| `smart-executor` | `exec` | - ---- - -## Quickstart - -Initialize runtime state: +## Initialize runtime ```bash coinhunter init +coinhunter init --force ``` -Inspect the environment: +This creates: + +- `~/.coinhunter/config.toml` +- `~/.coinhunter/.env` +- `~/.coinhunter/logs/` + +`config.toml` stores runtime and strategy settings. `.env` stores: ```bash -coinhunter paths -coinhunter diag +BINANCE_API_KEY= +BINANCE_API_SECRET= ``` -Validate API keys: +Override the default home directory with `COINHUNTER_HOME`. + +## Commands ```bash -coinhunter api-check +# Account +coinhunter account overview +coinhunter account balances --spot --futures +coinhunter account positions --spot + +# Market +coinhunter market tickers BTCUSDT ETH/USDT sol-usdt +coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50 + +# Trade (Spot) +coinhunter trade spot buy BTCUSDT --quote 100 --dry-run +coinhunter trade spot sell BTCUSDT --qty 0.01 --type limit --price 90000 + +# Trade (Futures) +coinhunter trade futures buy BTCUSDT --qty 0.01 --dry-run +coinhunter trade futures sell BTCUSDT --qty 0.01 --reduce-only +coinhunter trade futures close BTCUSDT + +# Opportunities +coinhunter opportunity portfolio +coinhunter opportunity scan +coinhunter opportunity scan --symbols BTCUSDT ETHUSDT SOLUSDT ``` -Run the precheck workflow: +## Architecture -```bash -coinhunter precheck -coinhunter precheck --ack "analysis completed" -``` +CoinHunter V2 uses a flat, direct architecture: -Run the external gate: +| Layer | Responsibility | Key Files | +|-------|----------------|-----------| +| **CLI** | Single entrypoint, argument parsing | `cli.py` | +| **Binance** | Thin API wrappers with unified error handling | `binance/spot_client.py`, `binance/um_futures_client.py` | +| **Services** | Domain logic | `services/account_service.py`, `services/market_service.py`, `services/trade_service.py`, `services/opportunity_service.py` | +| **Config** | TOML config, `.env` secrets, path resolution | `config.py` | +| **Runtime** | Paths, JSON helpers | `runtime.py` | +| **Audit** | Structured JSONL logging | `audit.py` | -```bash -coinhunter gate -``` +## Logging -The gate reads `trigger_command` from `~/.coinhunter/config.json` under `external_gate`. -- By default, no external trigger is configured — gate runs precheck and marks state, then exits cleanly. -- Set `trigger_command` to a command list to integrate with your own scheduler: - -```json -{ - "external_gate": { - "trigger_command": ["hermes", "cron", "run", "JOB_ID"] - } -} -``` - -- Set to `null` or `[]` to explicitly disable the external trigger. - -### Dynamic tuning via `config.json` - -You can override internal defaults without editing code by adding keys to `~/.coinhunter/config.json`: - -```json -{ - "external_gate": { - "trigger_command": ["hermes", "cron", "run", "JOB_ID"] - }, - "exchange": { - "min_quote_volume": 200000, - "cache_ttl_seconds": 3600 - }, - "logging": { - "schema_version": 2 - } -} -``` - -| Key | Default | Effect | -|-----|---------|--------| -| `exchange.min_quote_volume` | `200000` | Minimum 24h quote volume for a symbol to appear in market snapshots | -| `exchange.cache_ttl_seconds` | `3600` | How long the ccxt exchange instance (and `load_markets()` result) is cached | -| `logging.schema_version` | `2` | Schema version stamped on every JSONL log entry | - ---- - -## Runtime Model - -Default data layout: +Audit logs are written to: ```text -~/.coinhunter/ -├── config.json -├── positions.json -├── accounts.json -├── watchlist.json -├── notes.json -├── executions.json -├── logs/ -├── reviews/ -├── cache/ -└── state/ - ├── precheck_state.json - └── external_gate.lock +~/.coinhunter/logs/audit_YYYYMMDD.jsonl ``` -Credential resolution: +Events include: -- Binance API keys are read from `~/.hermes/.env` by default. -- Override with `COINHUNTER_ENV_FILE`. -- Override home with `COINHUNTER_HOME` or `HERMES_HOME`. -- `hermes` binary is resolved from `PATH`, then `~/.local/bin/hermes`, unless `HERMES_BIN` is set. +- `trade_submitted` +- `trade_filled` +- `trade_failed` +- `opportunity_portfolio_generated` +- `opportunity_scan_generated` ---- +## Development -## Development Status - -The codebase is actively maintained and refactored in small, safe steps. - -**Recently completed:** -- ✅ Unified CLI entrypoint with short aliases -- ✅ Extracted `smart-executor` into `commands/` + `services/` -- ✅ Extracted `precheck` into 9 focused service modules -- ✅ Migrated all active command modules into `commands/` -- ✅ Extracted `review_engine.py` core logic into `services/review_service.py` -- ✅ Removed eager `PATHS` instantiation across services and commands -- ✅ Fixed `smart_executor.py` lazy-loading facade -- ✅ Standardized install to use `pip install -e .` -- ✅ Made `external_gate` trigger_command configurable (no longer hardcodes hermes) -- ✅ Removed dead `auto-trader` command -- ✅ Backward-compatible root facades preserved - -**Next priorities:** -- 🔧 Add basic CI (lint + compileall + pytest) -- 🔧 Unify output contract (JSON-first with `--pretty` option) - ---- - -## Philosophy - -CoinHunter is evolving toward: - -- **Professional execution** — scientific position sizing, not moonshot gambling -- **Maintainable architecture** — clear boundaries between CLI, services, and domain logic -- **Safer operations** — dry-run, validation gates, and explicit decision logging -- **Agent-friendly interfaces** — stable JSON outputs and predictable command contracts -- **Less dependence on prompt-only correctness** — logic belongs in code, not just in system prompts - -This repo is where that evolution happens. +```bash +pytest tests/ # run tests +ruff check src tests # lint +mypy src # type check +``` diff --git a/pyproject.toml b/pyproject.toml index 05dda85..c676dfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,20 +4,26 @@ build-backend = "setuptools.build_meta" [project] name = "coinhunter" -version = "1.0.0" -description = "CoinHunter trading CLI with user runtime data in ~/.coinhunter" +version = "2.0.0" +description = "Binance-first trading CLI for balances, market data, opportunity scanning, and execution." readme = "README.md" license = {text = "MIT"} requires-python = ">=3.10" dependencies = [ - "ccxt>=4.4.0" + "binance-connector>=3.9.0", + "binance-futures-connector>=4.1.0", + "tomli>=2.0.1; python_version < '3.11'", ] authors = [ {name = "Tacit Lab", email = "ouyangcarlos@gmail.com"} ] [project.optional-dependencies] -dev = ["pytest>=8.0", "ruff>=0.4.0", "mypy>=1.0"] +dev = [ + "pytest>=8.0", + "ruff>=0.5.0", + "mypy>=1.10.0", +] [project.scripts] coinhunter = "coinhunter.cli:main" @@ -28,16 +34,24 @@ package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.ruff] -line-length = 120 target-version = "py310" +line-length = 120 [tool.ruff.lint] -select = ["E", "F", "W", "I"] +select = ["E", "F", "I", "UP", "W"] ignore = ["E501"] +[tool.ruff.lint.pydocstyle] +convention = "google" + [tool.mypy] python_version = "3.10" warn_return_any = true warn_unused_configs = true +disallow_untyped_defs = true ignore_missing_imports = true +exclude = [".venv", "build"] diff --git a/scripts/install_local.sh b/scripts/install_local.sh deleted file mode 100755 index 8d57ba0..0000000 --- a/scripts/install_local.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Standard local install using pip editable mode. -# This creates the 'coinhunter' console script entry point as defined in pyproject.toml. - -BIN_DIR="${COINHUNTER_BIN_DIR:-$HOME/.local/bin}" -PYTHON_BIN="${PYTHON:-}" - -if [[ -z "$PYTHON_BIN" ]]; then - if command -v python3 >/dev/null 2>&1; then - PYTHON_BIN="$(command -v python3)" - elif command -v python >/dev/null 2>&1; then - PYTHON_BIN="$(command -v python)" - else - echo "error: python3/python not found in PATH" >&2 - exit 1 - fi -fi - -mkdir -p "$BIN_DIR" - -"$PYTHON_BIN" -m pip install --upgrade pip setuptools wheel -"$PYTHON_BIN" -m pip install --upgrade -e "$(pwd)[dev]" - -echo "Installed coinhunter in editable mode." -echo " python: $PYTHON_BIN" -echo " entrypoint: $(command -v coinhunter || echo 'not in PATH')" -echo "" -echo "Make sure '$BIN_DIR' is in your PATH if the entrypoint is not found." diff --git a/src/coinhunter/__init__.py b/src/coinhunter/__init__.py index 3dc1f76..23f8bf1 100644 --- a/src/coinhunter/__init__.py +++ b/src/coinhunter/__init__.py @@ -1 +1,3 @@ -__version__ = "0.1.0" +"""CoinHunter V2.""" + +__version__ = "2.0.0" diff --git a/src/coinhunter/audit.py b/src/coinhunter/audit.py new file mode 100644 index 0000000..8d7ee32 --- /dev/null +++ b/src/coinhunter/audit.py @@ -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 diff --git a/src/coinhunter/binance/__init__.py b/src/coinhunter/binance/__init__.py new file mode 100644 index 0000000..960f4ea --- /dev/null +++ b/src/coinhunter/binance/__init__.py @@ -0,0 +1 @@ +"""Official Binance connector wrappers.""" diff --git a/src/coinhunter/binance/spot_client.py b/src/coinhunter/binance/spot_client.py new file mode 100644 index 0000000..06fce25 --- /dev/null +++ b/src/coinhunter/binance/spot_client.py @@ -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] diff --git a/src/coinhunter/binance/um_futures_client.py b/src/coinhunter/binance/um_futures_client.py new file mode 100644 index 0000000..e80ebab --- /dev/null +++ b/src/coinhunter/binance/um_futures_client.py @@ -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] diff --git a/src/coinhunter/check_api.py b/src/coinhunter/check_api.py deleted file mode 100755 index 15dbe7f..0000000 --- a/src/coinhunter/check_api.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py old mode 100755 new mode 100644 index f2b1ace..c5b1c32 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -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 diff --git a/src/coinhunter/commands/__init__.py b/src/coinhunter/commands/__init__.py deleted file mode 100644 index 7838ce4..0000000 --- a/src/coinhunter/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CLI command adapters for CoinHunter.""" diff --git a/src/coinhunter/commands/check_api.py b/src/coinhunter/commands/check_api.py deleted file mode 100755 index 498d47e..0000000 --- a/src/coinhunter/commands/check_api.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/commands/doctor.py b/src/coinhunter/commands/doctor.py deleted file mode 100644 index fb377d4..0000000 --- a/src/coinhunter/commands/doctor.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/commands/external_gate.py b/src/coinhunter/commands/external_gate.py deleted file mode 100755 index 2485477..0000000 --- a/src/coinhunter/commands/external_gate.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/commands/init_user_state.py b/src/coinhunter/commands/init_user_state.py deleted file mode 100755 index c4a273b..0000000 --- a/src/coinhunter/commands/init_user_state.py +++ /dev/null @@ -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() diff --git a/src/coinhunter/commands/market_probe.py b/src/coinhunter/commands/market_probe.py deleted file mode 100755 index 0b8d954..0000000 --- a/src/coinhunter/commands/market_probe.py +++ /dev/null @@ -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() diff --git a/src/coinhunter/commands/paths.py b/src/coinhunter/commands/paths.py deleted file mode 100644 index 270bab2..0000000 --- a/src/coinhunter/commands/paths.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/commands/precheck.py b/src/coinhunter/commands/precheck.py deleted file mode 100644 index 8f38d99..0000000 --- a/src/coinhunter/commands/precheck.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/commands/review_context.py b/src/coinhunter/commands/review_context.py deleted file mode 100644 index add6528..0000000 --- a/src/coinhunter/commands/review_context.py +++ /dev/null @@ -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() diff --git a/src/coinhunter/commands/review_engine.py b/src/coinhunter/commands/review_engine.py deleted file mode 100644 index b4da85d..0000000 --- a/src/coinhunter/commands/review_engine.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/commands/rotate_external_gate_log.py b/src/coinhunter/commands/rotate_external_gate_log.py deleted file mode 100755 index 82e5ab6..0000000 --- a/src/coinhunter/commands/rotate_external_gate_log.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/commands/smart_executor.py b/src/coinhunter/commands/smart_executor.py deleted file mode 100644 index 3ab77b8..0000000 --- a/src/coinhunter/commands/smart_executor.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/config.py b/src/coinhunter/config.py new file mode 100644 index 0000000..a96d8bd --- /dev/null +++ b/src/coinhunter/config.py @@ -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 diff --git a/src/coinhunter/doctor.py b/src/coinhunter/doctor.py deleted file mode 100644 index 24f1497..0000000 --- a/src/coinhunter/doctor.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Backward-compatible facade for doctor.""" - -from __future__ import annotations - -from .commands.doctor import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/coinhunter/external_gate.py b/src/coinhunter/external_gate.py deleted file mode 100755 index f5d0c64..0000000 --- a/src/coinhunter/external_gate.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/init_user_state.py b/src/coinhunter/init_user_state.py deleted file mode 100755 index d5925fd..0000000 --- a/src/coinhunter/init_user_state.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/logger.py b/src/coinhunter/logger.py deleted file mode 100755 index 62893fc..0000000 --- a/src/coinhunter/logger.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/market_probe.py b/src/coinhunter/market_probe.py deleted file mode 100755 index 6cb7504..0000000 --- a/src/coinhunter/market_probe.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/paths.py b/src/coinhunter/paths.py deleted file mode 100644 index ad4dacc..0000000 --- a/src/coinhunter/paths.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Backward-compatible facade for paths.""" - -from __future__ import annotations - -from .commands.paths import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/coinhunter/precheck.py b/src/coinhunter/precheck.py deleted file mode 100644 index b8aaa68..0000000 --- a/src/coinhunter/precheck.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/review_context.py b/src/coinhunter/review_context.py deleted file mode 100755 index 5c414fd..0000000 --- a/src/coinhunter/review_context.py +++ /dev/null @@ -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() diff --git a/src/coinhunter/review_engine.py b/src/coinhunter/review_engine.py deleted file mode 100755 index b61fc6c..0000000 --- a/src/coinhunter/review_engine.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/rotate_external_gate_log.py b/src/coinhunter/rotate_external_gate_log.py deleted file mode 100755 index 28db305..0000000 --- a/src/coinhunter/rotate_external_gate_log.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py index 60e7a3f..45aaf7c 100644 --- a/src/coinhunter/runtime.py +++ b/src/coinhunter/runtime.py @@ -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)) diff --git a/src/coinhunter/services/__init__.py b/src/coinhunter/services/__init__.py index 63957c1..d90a75d 100644 --- a/src/coinhunter/services/__init__.py +++ b/src/coinhunter/services/__init__.py @@ -1 +1 @@ -"""Application services for CoinHunter.""" +"""Service layer for CoinHunter V2.""" diff --git a/src/coinhunter/services/account_service.py b/src/coinhunter/services/account_service.py new file mode 100644 index 0000000..8953671 --- /dev/null +++ b/src/coinhunter/services/account_service.py @@ -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} diff --git a/src/coinhunter/services/adaptive_profile.py b/src/coinhunter/services/adaptive_profile.py deleted file mode 100644 index 71319d6..0000000 --- a/src/coinhunter/services/adaptive_profile.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/candidate_scoring.py b/src/coinhunter/services/candidate_scoring.py deleted file mode 100644 index da41a32..0000000 --- a/src/coinhunter/services/candidate_scoring.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/data_utils.py b/src/coinhunter/services/data_utils.py deleted file mode 100644 index 5a74ea6..0000000 --- a/src/coinhunter/services/data_utils.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/exchange_service.py b/src/coinhunter/services/exchange_service.py deleted file mode 100644 index 59370d1..0000000 --- a/src/coinhunter/services/exchange_service.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/execution_state.py b/src/coinhunter/services/execution_state.py deleted file mode 100644 index 860a4ff..0000000 --- a/src/coinhunter/services/execution_state.py +++ /dev/null @@ -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) diff --git a/src/coinhunter/services/file_utils.py b/src/coinhunter/services/file_utils.py deleted file mode 100644 index 6d277a4..0000000 --- a/src/coinhunter/services/file_utils.py +++ /dev/null @@ -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) diff --git a/src/coinhunter/services/market_data.py b/src/coinhunter/services/market_data.py deleted file mode 100644 index 3ad2cc1..0000000 --- a/src/coinhunter/services/market_data.py +++ /dev/null @@ -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" diff --git a/src/coinhunter/services/market_service.py b/src/coinhunter/services/market_service.py new file mode 100644 index 0000000..84c0384 --- /dev/null +++ b/src/coinhunter/services/market_service.py @@ -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 diff --git a/src/coinhunter/services/opportunity_service.py b/src/coinhunter/services/opportunity_service.py new file mode 100644 index 0000000..feb8383 --- /dev/null +++ b/src/coinhunter/services/opportunity_service.py @@ -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 diff --git a/src/coinhunter/services/portfolio_service.py b/src/coinhunter/services/portfolio_service.py deleted file mode 100644 index 1d8d4ce..0000000 --- a/src/coinhunter/services/portfolio_service.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/precheck_analysis.py b/src/coinhunter/services/precheck_analysis.py deleted file mode 100644 index 28190c8..0000000 --- a/src/coinhunter/services/precheck_analysis.py +++ /dev/null @@ -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}", - } diff --git a/src/coinhunter/services/precheck_constants.py b/src/coinhunter/services/precheck_constants.py deleted file mode 100644 index d5e91b2..0000000 --- a/src/coinhunter/services/precheck_constants.py +++ /dev/null @@ -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) diff --git a/src/coinhunter/services/precheck_core.py b/src/coinhunter/services/precheck_core.py deleted file mode 100644 index 21a3909..0000000 --- a/src/coinhunter/services/precheck_core.py +++ /dev/null @@ -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()) diff --git a/src/coinhunter/services/precheck_service.py b/src/coinhunter/services/precheck_service.py deleted file mode 100644 index 29a90e8..0000000 --- a/src/coinhunter/services/precheck_service.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/precheck_snapshot.py b/src/coinhunter/services/precheck_snapshot.py deleted file mode 100644 index bb45581..0000000 --- a/src/coinhunter/services/precheck_snapshot.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Snapshot construction helpers for precheck.""" - -from __future__ import annotations - diff --git a/src/coinhunter/services/precheck_state.py b/src/coinhunter/services/precheck_state.py deleted file mode 100644 index f54ae90..0000000 --- a/src/coinhunter/services/precheck_state.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/review_service.py b/src/coinhunter/services/review_service.py deleted file mode 100644 index 57cc79a..0000000 --- a/src/coinhunter/services/review_service.py +++ /dev/null @@ -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) diff --git a/src/coinhunter/services/smart_executor_parser.py b/src/coinhunter/services/smart_executor_parser.py deleted file mode 100644 index 91b153e..0000000 --- a/src/coinhunter/services/smart_executor_parser.py +++ /dev/null @@ -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 [] diff --git a/src/coinhunter/services/smart_executor_service.py b/src/coinhunter/services/smart_executor_service.py deleted file mode 100644 index b29d8e8..0000000 --- a/src/coinhunter/services/smart_executor_service.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/snapshot_builder.py b/src/coinhunter/services/snapshot_builder.py deleted file mode 100644 index c90c092..0000000 --- a/src/coinhunter/services/snapshot_builder.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/state_manager.py b/src/coinhunter/services/state_manager.py deleted file mode 100644 index 8ee2125..0000000 --- a/src/coinhunter/services/state_manager.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/time_utils.py b/src/coinhunter/services/time_utils.py deleted file mode 100644 index e088087..0000000 --- a/src/coinhunter/services/time_utils.py +++ /dev/null @@ -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" diff --git a/src/coinhunter/services/trade_common.py b/src/coinhunter/services/trade_common.py deleted file mode 100644 index 44e642d..0000000 --- a/src/coinhunter/services/trade_common.py +++ /dev/null @@ -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() diff --git a/src/coinhunter/services/trade_execution.py b/src/coinhunter/services/trade_execution.py deleted file mode 100644 index edea345..0000000 --- a/src/coinhunter/services/trade_execution.py +++ /dev/null @@ -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 diff --git a/src/coinhunter/services/trade_service.py b/src/coinhunter/services/trade_service.py new file mode 100644 index 0000000..f9e0915 --- /dev/null +++ b/src/coinhunter/services/trade_service.py @@ -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, + ) diff --git a/src/coinhunter/services/trigger_analyzer.py b/src/coinhunter/services/trigger_analyzer.py deleted file mode 100644 index dd57ea0..0000000 --- a/src/coinhunter/services/trigger_analyzer.py +++ /dev/null @@ -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, - } diff --git a/src/coinhunter/smart_executor.py b/src/coinhunter/smart_executor.py deleted file mode 100644 index 64b190a..0000000 --- a/src/coinhunter/smart_executor.py +++ /dev/null @@ -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()) diff --git a/tests/test_account_market_services.py b/tests/test_account_market_services.py new file mode 100644 index 0000000..e1967b1 --- /dev/null +++ b/tests/test_account_market_services.py @@ -0,0 +1,80 @@ +"""Account and market service tests.""" + +from __future__ import annotations + +import unittest + +from coinhunter.services import account_service, market_service + + +class FakeSpotClient: + def account_info(self): + return { + "balances": [ + {"asset": "USDT", "free": "120.0", "locked": "0"}, + {"asset": "BTC", "free": "0.01", "locked": "0"}, + {"asset": "DOGE", "free": "1", "locked": "0"}, + ] + } + + def ticker_price(self, symbols=None): + prices = { + "BTCUSDT": {"symbol": "BTCUSDT", "price": "60000"}, + "DOGEUSDT": {"symbol": "DOGEUSDT", "price": "0.1"}, + } + if not symbols: + return list(prices.values()) + return [prices[symbol] for symbol in symbols] + + def ticker_24h(self, symbols=None): + rows = [ + {"symbol": "BTCUSDT", "lastPrice": "60000", "priceChangePercent": "4.5", "quoteVolume": "10000000", "highPrice": "61000", "lowPrice": "58000"}, + {"symbol": "ETHUSDT", "lastPrice": "3000", "priceChangePercent": "3.0", "quoteVolume": "8000000", "highPrice": "3050", "lowPrice": "2900"}, + {"symbol": "DOGEUSDT", "lastPrice": "0.1", "priceChangePercent": "1.0", "quoteVolume": "200", "highPrice": "0.11", "lowPrice": "0.09"}, + ] + if not symbols: + return rows + wanted = set(symbols) + return [row for row in rows if row["symbol"] in wanted] + + def exchange_info(self): + return {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}, {"symbol": "ETHUSDT", "status": "TRADING"}, {"symbol": "DOGEUSDT", "status": "BREAK"}]} + + +class FakeFuturesClient: + def balance(self): + return [{"asset": "USDT", "balance": "250.0", "availableBalance": "200.0"}] + + def position_risk(self, symbol=None): + return [{"symbol": "BTCUSDT", "positionAmt": "0.02", "notional": "1200", "entryPrice": "59000", "markPrice": "60000", "unRealizedProfit": "20"}] + + +class AccountMarketServicesTestCase(unittest.TestCase): + def test_account_overview_and_dust_filter(self): + config = { + "market": {"default_quote": "USDT"}, + "trading": {"dust_usdt_threshold": 10.0}, + } + payload = account_service.get_overview( + config, + include_spot=True, + include_futures=True, + spot_client=FakeSpotClient(), + futures_client=FakeFuturesClient(), + ) + self.assertEqual(payload["overview"]["spot_equity_usdt"], 720.1) + self.assertEqual(payload["overview"]["futures_equity_usdt"], 250.0) + symbols = {item["symbol"] for item in payload["positions"]} + self.assertNotIn("DOGEUSDT", symbols) + self.assertIn("BTCUSDT", symbols) + + def test_market_tickers_and_scan_universe(self): + config = { + "market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []}, + "opportunity": {"min_quote_volume": 1000}, + } + tickers = market_service.get_tickers(config, ["btc/usdt", "ETH-USDT"], spot_client=FakeSpotClient()) + self.assertEqual([item["symbol"] for item in tickers["tickers"]], ["BTCUSDT", "ETHUSDT"]) + + universe = market_service.get_scan_universe(config, spot_client=FakeSpotClient()) + self.assertEqual([item["symbol"] for item in universe], ["BTCUSDT", "ETHUSDT"]) diff --git a/tests/test_check_api.py b/tests/test_check_api.py deleted file mode 100644 index d633ee9..0000000 --- a/tests/test_check_api.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for check_api command.""" - -import json -from unittest.mock import MagicMock, patch - -from coinhunter.commands import check_api - - -class TestMain: - def test_missing_api_key(self, monkeypatch, capsys): - monkeypatch.setenv("BINANCE_API_KEY", "") - monkeypatch.setenv("BINANCE_API_SECRET", "secret") - rc = check_api.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert out["ok"] is False - assert "BINANCE_API_KEY" in out["error"] - - def test_missing_api_secret(self, monkeypatch, capsys): - monkeypatch.setenv("BINANCE_API_KEY", "key") - monkeypatch.setenv("BINANCE_API_SECRET", "") - rc = check_api.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert out["ok"] is False - assert "BINANCE_API_SECRET" in out["error"] - - def test_placholder_api_key(self, monkeypatch, capsys): - monkeypatch.setenv("BINANCE_API_KEY", "your_api_key") - monkeypatch.setenv("BINANCE_API_SECRET", "secret") - rc = check_api.main() - assert rc == 1 - - def test_balance_fetch_failure(self, monkeypatch, capsys): - monkeypatch.setenv("BINANCE_API_KEY", "key") - monkeypatch.setenv("BINANCE_API_SECRET", "secret") - mock_ex = MagicMock() - mock_ex.fetch_balance.side_effect = Exception("Network error") - with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex): - rc = check_api.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert "Failed to connect" in out["error"] - - def test_success_with_spot_trading(self, monkeypatch, capsys): - monkeypatch.setenv("BINANCE_API_KEY", "key") - monkeypatch.setenv("BINANCE_API_SECRET", "secret") - mock_ex = MagicMock() - mock_ex.fetch_balance.return_value = {"USDT": 100.0} - mock_ex.sapi_get_account_api_restrictions.return_value = {"enableSpotTrading": True} - with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex): - rc = check_api.main() - assert rc == 0 - out = json.loads(capsys.readouterr().out) - assert out["ok"] is True - assert out["read_permission"] is True - assert out["spot_trading_enabled"] is True - - def test_success_restrictions_query_fails(self, monkeypatch, capsys): - monkeypatch.setenv("BINANCE_API_KEY", "key") - monkeypatch.setenv("BINANCE_API_SECRET", "secret") - mock_ex = MagicMock() - mock_ex.fetch_balance.return_value = {"USDT": 100.0} - mock_ex.sapi_get_account_api_restrictions.side_effect = Exception("no permission") - with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex): - rc = check_api.main() - assert rc == 0 - out = json.loads(capsys.readouterr().out) - assert out["spot_trading_enabled"] is None - assert "may be null" in out["note"] diff --git a/tests/test_cli.py b/tests/test_cli.py index e760ff5..dd8d234 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,58 +1,38 @@ -"""Tests for CLI routing and parser behavior.""" +"""CLI tests for CoinHunter V2.""" -import pytest +from __future__ import annotations -from coinhunter.cli import ALIASES, MODULE_MAP, build_parser, run_python_module +import io +import unittest +from unittest.mock import patch + +from coinhunter import cli -class TestAliases: - def test_all_aliases_resolve_to_canonical(self): - for alias, canonical in ALIASES.items(): - assert canonical in MODULE_MAP, f"alias {alias!r} points to missing canonical {canonical!r}" - - def test_no_alias_is_itself_an_alias_loop(self): - for alias in ALIASES: - assert alias not in ALIASES.values() or alias in MODULE_MAP - - -class TestModuleMap: - def test_all_modules_exist(self): - import importlib - - for command, module_name in MODULE_MAP.items(): - full = f"coinhunter.{module_name}" - mod = importlib.import_module(full) - assert hasattr(mod, "main"), f"{full} missing main()" - - -class TestBuildParser: - def test_help_includes_commands(self): - parser = build_parser() +class CLITestCase(unittest.TestCase): + def test_help_includes_v2_commands(self): + parser = cli.build_parser() help_text = parser.format_help() - assert "coinhunter diag" in help_text - assert "coinhunter exec" in help_text + self.assertIn("init", help_text) + self.assertIn("account", help_text) + self.assertIn("opportunity", help_text) - def test_parses_command_and_args(self): - parser = build_parser() - ns = parser.parse_args(["exec", "bal"]) - assert ns.command == "exec" - assert "bal" in ns.args + def test_init_dispatches(self): + captured = {} + with patch.object(cli, "ensure_init_files", return_value={"force": True, "root": "/tmp/ch"}), patch.object( + cli, "print_json", side_effect=lambda payload: captured.setdefault("payload", payload) + ): + result = cli.main(["init", "--force"]) + self.assertEqual(result, 0) + self.assertTrue(captured["payload"]["force"]) - def test_version_action_exits(self): - parser = build_parser() - with pytest.raises(SystemExit) as exc: - parser.parse_args(["--version"]) - assert exc.value.code == 0 + def test_old_command_is_rejected(self): + with self.assertRaises(SystemExit): + cli.main(["exec", "bal"]) - -class TestRunPythonModule: - def test_runs_module_main_and_returns_int(self): - result = run_python_module("commands.paths", [], "coinhunter paths") - assert result == 0 - - def test_mutates_sys_argv(self): - import sys - - original = sys.argv[:] - run_python_module("commands.paths", ["--help"], "coinhunter paths") - assert sys.argv == original + def test_runtime_error_is_rendered_cleanly(self): + stderr = io.StringIO() + with patch.object(cli, "load_config", side_effect=RuntimeError("boom")), patch("sys.stderr", stderr): + result = cli.main(["market", "tickers", "BTCUSDT"]) + self.assertEqual(result, 1) + self.assertIn("error: boom", stderr.getvalue()) diff --git a/tests/test_config_runtime.py b/tests/test_config_runtime.py new file mode 100644 index 0000000..37bfd9f --- /dev/null +++ b/tests/test_config_runtime.py @@ -0,0 +1,79 @@ +"""Config and runtime tests.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from coinhunter.config import ensure_init_files, get_binance_credentials, load_config, load_env_file +from coinhunter.runtime import get_runtime_paths + + +class ConfigRuntimeTestCase(unittest.TestCase): + def test_init_files_created_in_coinhunter_home(self): + with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(os.environ, {"COINHUNTER_HOME": str(Path(tmp_dir) / "home")}, clear=False): + paths = get_runtime_paths() + payload = ensure_init_files(paths) + self.assertTrue(paths.config_file.exists()) + self.assertTrue(paths.env_file.exists()) + self.assertTrue(paths.logs_dir.exists()) + self.assertEqual(payload["root"], str(paths.root)) + + def test_load_config_and_env(self): + with tempfile.TemporaryDirectory() as tmp_dir, patch.dict( + os.environ, + {"COINHUNTER_HOME": str(Path(tmp_dir) / "home")}, + clear=False, + ): + paths = get_runtime_paths() + ensure_init_files(paths) + paths.env_file.write_text("BINANCE_API_KEY=abc\nBINANCE_API_SECRET=def\n", encoding="utf-8") + + config = load_config(paths) + loaded = load_env_file(paths) + + self.assertEqual(config["market"]["default_quote"], "USDT") + self.assertEqual(loaded["BINANCE_API_KEY"], "abc") + self.assertEqual(os.environ["BINANCE_API_SECRET"], "def") + + def test_env_file_overrides_existing_environment(self): + with tempfile.TemporaryDirectory() as tmp_dir, patch.dict( + os.environ, + {"COINHUNTER_HOME": str(Path(tmp_dir) / "home"), "BINANCE_API_KEY": "old_key"}, + clear=False, + ): + paths = get_runtime_paths() + ensure_init_files(paths) + paths.env_file.write_text("BINANCE_API_KEY=new_key\nBINANCE_API_SECRET=new_secret\n", encoding="utf-8") + + load_env_file(paths) + + self.assertEqual(os.environ["BINANCE_API_KEY"], "new_key") + self.assertEqual(os.environ["BINANCE_API_SECRET"], "new_secret") + + def test_missing_credentials_raise(self): + with tempfile.TemporaryDirectory() as tmp_dir, patch.dict( + os.environ, + {"COINHUNTER_HOME": str(Path(tmp_dir) / "home")}, + clear=False, + ): + os.environ.pop("BINANCE_API_KEY", None) + os.environ.pop("BINANCE_API_SECRET", None) + paths = get_runtime_paths() + ensure_init_files(paths) + with self.assertRaisesRegex(RuntimeError, "Missing BINANCE_API_KEY"): + get_binance_credentials(paths) + + def test_permission_error_is_explained(self): + with tempfile.TemporaryDirectory() as tmp_dir, patch.dict( + os.environ, + {"COINHUNTER_HOME": str(Path(tmp_dir) / "home")}, + clear=False, + ): + paths = get_runtime_paths() + with patch("coinhunter.config.ensure_runtime_dirs", side_effect=PermissionError("no write access")): + with self.assertRaisesRegex(RuntimeError, "Set COINHUNTER_HOME to a writable directory"): + ensure_init_files(paths) diff --git a/tests/test_exchange_service.py b/tests/test_exchange_service.py deleted file mode 100644 index 165f213..0000000 --- a/tests/test_exchange_service.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Tests for exchange helpers and caching.""" - -import pytest - -from coinhunter.services import exchange_service as es - - -class TestNormSymbol: - def test_already_normalized(self): - assert es.norm_symbol("BTC/USDT") == "BTC/USDT" - - def test_raw_symbol(self): - assert es.norm_symbol("BTCUSDT") == "BTC/USDT" - - def test_lowercase(self): - assert es.norm_symbol("btcusdt") == "BTC/USDT" - - def test_dash_separator(self): - assert es.norm_symbol("BTC-USDT") == "BTC/USDT" - - def test_underscore_separator(self): - assert es.norm_symbol("BTC_USDT") == "BTC/USDT" - - def test_unsupported_symbol(self): - with pytest.raises(ValueError): - es.norm_symbol("BTC") - - -class TestStorageSymbol: - def test_converts_to_flat(self): - assert es.storage_symbol("BTC/USDT") == "BTCUSDT" - - def test_raw_input(self): - assert es.storage_symbol("ETHUSDT") == "ETHUSDT" - - -class TestFloorToStep: - def test_no_step(self): - assert es.floor_to_step(1.234, 0) == 1.234 - - def test_floors_to_step(self): - assert es.floor_to_step(1.234, 0.1) == pytest.approx(1.2) - - def test_exact_multiple(self): - assert es.floor_to_step(12, 1) == 12 - - -class TestGetExchangeCaching: - def test_returns_cached_instance(self, monkeypatch): - monkeypatch.setenv("BINANCE_API_KEY", "test_key") - monkeypatch.setenv("BINANCE_API_SECRET", "test_secret") - - class FakeEx: - def load_markets(self): - pass - - # Reset cache and patch ccxt.binance - original_cache = es._exchange_cache - original_cached_at = es._exchange_cached_at - try: - es._exchange_cache = None - es._exchange_cached_at = None - monkeypatch.setattr(es.ccxt, "binance", lambda *a, **kw: FakeEx()) - - first = es.get_exchange() - second = es.get_exchange() - assert first is second - - third = es.get_exchange(force_new=True) - assert third is not first - finally: - es._exchange_cache = original_cache - es._exchange_cached_at = original_cached_at - - def test_cache_expires_after_ttl(self, monkeypatch): - monkeypatch.setenv("BINANCE_API_KEY", "test_key") - monkeypatch.setenv("BINANCE_API_SECRET", "test_secret") - - class FakeEx: - def load_markets(self): - pass - - original_cache = es._exchange_cache - original_cached_at = es._exchange_cached_at - try: - es._exchange_cache = None - es._exchange_cached_at = None - monkeypatch.setattr(es.ccxt, "binance", lambda *a, **kw: FakeEx()) - monkeypatch.setattr(es, "load_env", lambda: None) - - first = es.get_exchange() - # Simulate time passing beyond TTL - es._exchange_cached_at -= es.CACHE_TTL_SECONDS + 1 - second = es.get_exchange() - assert first is not second - finally: - es._exchange_cache = original_cache - es._exchange_cached_at = original_cached_at diff --git a/tests/test_external_gate.py b/tests/test_external_gate.py deleted file mode 100644 index f04bae0..0000000 --- a/tests/test_external_gate.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Tests for external_gate configurable hook.""" - -import json -from unittest.mock import MagicMock, patch - -from coinhunter.commands import external_gate - - -class TestResolveTriggerCommand: - def test_missing_config_means_disabled(self, tmp_path, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - # no config file exists - cmd = external_gate._resolve_trigger_command(paths) - assert cmd is None - - def test_uses_explicit_list_from_config(self, tmp_path, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({ - "external_gate": {"trigger_command": ["my-scheduler", "run", "job-123"]} - }), encoding="utf-8") - cmd = external_gate._resolve_trigger_command(paths) - assert cmd == ["my-scheduler", "run", "job-123"] - - def test_null_means_disabled(self, tmp_path, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({ - "external_gate": {"trigger_command": None} - }), encoding="utf-8") - cmd = external_gate._resolve_trigger_command(paths) - assert cmd is None - - def test_empty_list_means_disabled(self, tmp_path, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({ - "external_gate": {"trigger_command": []} - }), encoding="utf-8") - cmd = external_gate._resolve_trigger_command(paths) - assert cmd is None - - def test_string_gets_wrapped(self, tmp_path, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({ - "external_gate": {"trigger_command": "my-script.sh"} - }), encoding="utf-8") - cmd = external_gate._resolve_trigger_command(paths) - assert cmd == ["my-script.sh"] - - def test_unexpected_type_returns_none_and_warns(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({ - "external_gate": {"trigger_command": 42} - }), encoding="utf-8") - cmd = external_gate._resolve_trigger_command(paths) - assert cmd is None - - -class TestMain: - def test_already_running(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.state_dir.mkdir(parents=True, exist_ok=True) - # Acquire the lock in this process so main() sees it as busy - import fcntl - with open(paths.external_gate_lock, "w", encoding="utf-8") as f: - fcntl.flock(f.fileno(), fcntl.LOCK_EX) - rc = external_gate.main() - assert rc == 0 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "already_running" - - def test_precheck_failure(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - fake_result = MagicMock(returncode=1, stdout="err", stderr="") - with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result): - rc = external_gate.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "precheck_failed" - - def test_precheck_parse_error(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - fake_result = MagicMock(returncode=0, stdout="not-json", stderr="") - with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result): - rc = external_gate.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "precheck_parse_error" - - def test_precheck_not_ok(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - fake_result = MagicMock(returncode=0, stdout=json.dumps({"ok": False}), stderr="") - with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result): - rc = external_gate.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "precheck_not_ok" - - def test_no_trigger(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - fake_result = MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": False}), stderr="") - with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result): - rc = external_gate.main() - assert rc == 0 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "no_trigger" - - def test_already_queued(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - fake_result = MagicMock( - returncode=0, - stdout=json.dumps({"ok": True, "should_analyze": True, "run_requested": True, "run_requested_at": "2024-01-01T00:00:00Z"}), - stderr="", - ) - with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result): - rc = external_gate.main() - assert rc == 0 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "already_queued" - - def test_trigger_disabled(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": None}}), encoding="utf-8") - # First call is precheck, second is mark-run-requested - responses = [ - MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""), - MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""), - ] - with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses): - rc = external_gate.main() - assert rc == 0 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "trigger_disabled" - - def test_trigger_success(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["echo", "ok"]}}), encoding="utf-8") - responses = [ - MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True, "reasons": ["price-move"]}), stderr=""), - MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""), - MagicMock(returncode=0, stdout="triggered", stderr=""), - ] - with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses): - rc = external_gate.main() - assert rc == 0 - out = json.loads(capsys.readouterr().out) - assert out["triggered"] is True - assert out["reason"] == "price-move" - - def test_trigger_command_failure(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["false"]}}), encoding="utf-8") - responses = [ - MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""), - MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""), - MagicMock(returncode=1, stdout="", stderr="fail"), - ] - with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses): - rc = external_gate.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "trigger_failed" - - def test_mark_run_requested_failure(self, tmp_path, monkeypatch, capsys): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = external_gate._paths() - paths.root.mkdir(parents=True, exist_ok=True) - paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["echo", "ok"]}}), encoding="utf-8") - responses = [ - MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""), - MagicMock(returncode=1, stdout="err", stderr=""), - ] - with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses): - rc = external_gate.main() - assert rc == 1 - out = json.loads(capsys.readouterr().out) - assert out["reason"] == "mark_failed" diff --git a/tests/test_opportunity_service.py b/tests/test_opportunity_service.py new file mode 100644 index 0000000..938f6e9 --- /dev/null +++ b/tests/test_opportunity_service.py @@ -0,0 +1,94 @@ +"""Opportunity service tests.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from coinhunter.services import opportunity_service + + +class FakeSpotClient: + def account_info(self): + return { + "balances": [ + {"asset": "USDT", "free": "50", "locked": "0"}, + {"asset": "BTC", "free": "0.01", "locked": "0"}, + {"asset": "ETH", "free": "0.5", "locked": "0"}, + {"asset": "DOGE", "free": "1", "locked": "0"}, + ] + } + + def ticker_price(self, symbols=None): + mapping = { + "BTCUSDT": {"symbol": "BTCUSDT", "price": "60000"}, + "ETHUSDT": {"symbol": "ETHUSDT", "price": "3000"}, + "DOGEUSDT": {"symbol": "DOGEUSDT", "price": "0.1"}, + } + return [mapping[symbol] for symbol in symbols] + + def ticker_24h(self, symbols=None): + rows = { + "BTCUSDT": {"symbol": "BTCUSDT", "lastPrice": "60000", "priceChangePercent": "5", "quoteVolume": "9000000", "highPrice": "60200", "lowPrice": "55000"}, + "ETHUSDT": {"symbol": "ETHUSDT", "lastPrice": "3000", "priceChangePercent": "3", "quoteVolume": "8000000", "highPrice": "3100", "lowPrice": "2800"}, + "SOLUSDT": {"symbol": "SOLUSDT", "lastPrice": "150", "priceChangePercent": "8", "quoteVolume": "10000000", "highPrice": "152", "lowPrice": "130"}, + "DOGEUSDT": {"symbol": "DOGEUSDT", "lastPrice": "0.1", "priceChangePercent": "1", "quoteVolume": "100", "highPrice": "0.11", "lowPrice": "0.09"}, + } + if not symbols: + return list(rows.values()) + return [rows[symbol] for symbol in symbols] + + def exchange_info(self): + return {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}, {"symbol": "ETHUSDT", "status": "TRADING"}, {"symbol": "SOLUSDT", "status": "TRADING"}, {"symbol": "DOGEUSDT", "status": "TRADING"}]} + + def klines(self, symbol, interval, limit): + curves = { + "BTCUSDT": [50000, 52000, 54000, 56000, 58000, 59000, 60000], + "ETHUSDT": [2600, 2650, 2700, 2800, 2900, 2950, 3000], + "SOLUSDT": [120, 125, 130, 135, 140, 145, 150], + "DOGEUSDT": [0.11, 0.108, 0.105, 0.103, 0.102, 0.101, 0.1], + }[symbol] + rows = [] + for index, close in enumerate(curves[-limit:]): + rows.append([index, close * 0.98, close * 1.01, close * 0.97, close, 100 + index * 10, index + 1, close * (100 + index * 10)]) + return rows + + +class OpportunityServiceTestCase(unittest.TestCase): + def setUp(self): + self.config = { + "market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []}, + "trading": {"dust_usdt_threshold": 10.0}, + "opportunity": { + "scan_limit": 10, + "top_n": 5, + "min_quote_volume": 1000.0, + "weights": { + "trend": 1.0, + "momentum": 1.0, + "breakout": 0.8, + "volume": 0.7, + "volatility_penalty": 0.5, + "position_concentration_penalty": 0.6, + }, + }, + } + + def test_portfolio_analysis_ignores_dust_and_emits_recommendations(self): + events = [] + with patch.object(opportunity_service, "audit_event", side_effect=lambda event, payload: events.append(event)): + payload = opportunity_service.analyze_portfolio(self.config, spot_client=FakeSpotClient()) + symbols = [item["symbol"] for item in payload["recommendations"]] + self.assertNotIn("DOGEUSDT", symbols) + self.assertEqual(symbols, ["BTCUSDT", "ETHUSDT"]) + self.assertEqual(events, ["opportunity_portfolio_generated"]) + + def test_scan_is_deterministic(self): + with patch.object(opportunity_service, "audit_event", return_value=None): + payload = opportunity_service.scan_opportunities(self.config | {"opportunity": self.config["opportunity"] | {"top_n": 2}}, spot_client=FakeSpotClient()) + self.assertEqual([item["symbol"] for item in payload["recommendations"]], ["SOLUSDT", "BTCUSDT"]) + + def test_score_candidate_handles_empty_klines(self): + score, metrics = opportunity_service._score_candidate([], [], {"price_change_pct": 1.0}, {}, 0.0) + self.assertEqual(score, 0.0) + self.assertEqual(metrics["trend"], 0.0) diff --git a/tests/test_review_service.py b/tests/test_review_service.py deleted file mode 100644 index 1703956..0000000 --- a/tests/test_review_service.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Tests for review_service.""" - -import json -from pathlib import Path -from unittest.mock import patch - -from coinhunter.services import review_service as rs - - -class TestAnalyzeTrade: - def test_buy_good_when_price_up(self): - ex = None - trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"} - with patch.object(rs, "fetch_current_price", return_value=52000.0): - result = rs.analyze_trade(trade, ex) - assert result["action"] == "BUY" - assert result["pnl_estimate_pct"] == 4.0 - assert result["outcome_assessment"] == "good" - - def test_buy_bad_when_price_down(self): - trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"} - with patch.object(rs, "fetch_current_price", return_value=48000.0): - result = rs.analyze_trade(trade, None) - assert result["pnl_estimate_pct"] == -4.0 - assert result["outcome_assessment"] == "bad" - - def test_sell_all_missed_when_price_up(self): - trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "SELL_ALL"} - with patch.object(rs, "fetch_current_price", return_value=53000.0): - result = rs.analyze_trade(trade, None) - assert result["pnl_estimate_pct"] == -6.0 - assert result["outcome_assessment"] == "missed" - - def test_neutral_when_small_move(self): - trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"} - with patch.object(rs, "fetch_current_price", return_value=50100.0): - result = rs.analyze_trade(trade, None) - assert result["outcome_assessment"] == "neutral" - - def test_none_when_no_price(self): - trade = {"symbol": "BTC/USDT", "action": "BUY"} - result = rs.analyze_trade(trade, None) - assert result["pnl_estimate_pct"] is None - assert result["outcome_assessment"] == "neutral" - - -class TestAnalyzeHoldPasses: - def test_finds_passed_opportunities_that_rise(self): - decisions = [ - { - "timestamp": "2024-01-01T00:00:00Z", - "decision": "HOLD", - "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]}, - "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}}, - } - ] - with patch.object(rs, "fetch_current_price", return_value=110.0): - result = rs.analyze_hold_passes(decisions, None) - assert len(result) == 1 - assert result[0]["symbol"] == "ETH/USDT" - assert result[0]["change_pct"] == 10.0 - - def test_ignores_non_pass_verdicts(self): - decisions = [ - { - "decision": "HOLD", - "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "BUY"}]}, - "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}}, - } - ] - with patch.object(rs, "fetch_current_price", return_value=110.0): - result = rs.analyze_hold_passes(decisions, None) - assert result == [] - - def test_ignores_small_moves(self): - decisions = [ - { - "decision": "HOLD", - "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]}, - "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}}, - } - ] - with patch.object(rs, "fetch_current_price", return_value=102.0): - result = rs.analyze_hold_passes(decisions, None) - assert result == [] - - -class TestAnalyzeCashMisses: - def test_finds_cash_sit_misses(self): - decisions = [ - { - "timestamp": "2024-01-01T00:00:00Z", - "balances": {"USDT": 100.0}, - "market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}}, - } - ] - with patch.object(rs, "fetch_current_price", return_value=54000.0): - result = rs.analyze_cash_misses(decisions, None) - assert len(result) == 1 - assert result[0]["symbol"] == "BTCUSDT" - assert result[0]["change_pct"] == 8.0 - - def test_requires_mostly_usdt(self): - decisions = [ - { - "balances": {"USDT": 10.0, "BTC": 90.0}, - "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}}, - } - ] - with patch.object(rs, "fetch_current_price", return_value=200.0): - result = rs.analyze_cash_misses(decisions, None) - assert result == [] - - def test_dedupes_by_best_change(self): - decisions = [ - { - "timestamp": "2024-01-01T00:00:00Z", - "balances": {"USDT": 100.0}, - "market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}}, - }, - { - "timestamp": "2024-01-01T01:00:00Z", - "balances": {"USDT": 100.0}, - "market_snapshot": {"BTCUSDT": {"lastPrice": 52000.0}}, - }, - ] - with patch.object(rs, "fetch_current_price", return_value=56000.0): - result = rs.analyze_cash_misses(decisions, None) - assert len(result) == 1 - # best change is from 50000 -> 56000 = 12% - assert result[0]["change_pct"] == 12.0 - - -class TestGenerateReview: - def test_empty_period(self, monkeypatch): - monkeypatch.setattr(rs, "get_logs_last_n_hours", lambda log_type, hours: []) - review = rs.generate_review(1) - assert review["total_decisions"] == 0 - assert review["total_trades"] == 0 - assert any("No decisions or trades" in i for i in review["insights"]) - - def test_with_trades_and_decisions(self, monkeypatch): - trades = [ - {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY", "timestamp": "2024-01-01T00:00:00Z"} - ] - decisions = [ - { - "timestamp": "2024-01-01T00:00:00Z", - "decision": "HOLD", - "analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]}, - "market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}}, - } - ] - errors = [{"message": "oops"}] - - def _logs(log_type, hours): - return {"decisions": decisions, "trades": trades, "errors": errors}.get(log_type, []) - - monkeypatch.setattr(rs, "get_logs_last_n_hours", _logs) - monkeypatch.setattr(rs, "fetch_current_price", lambda ex, sym: 110.0 if "ETH" in sym else 52000.0) - review = rs.generate_review(1) - assert review["total_trades"] == 1 - assert review["total_decisions"] == 1 - assert review["stats"]["good_decisions"] == 1 - assert review["stats"]["missed_hold_passes"] == 1 - assert any("execution/system errors" in i for i in review["insights"]) - - -class TestSaveReview: - def test_writes_file(self, tmp_path, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - review = {"review_timestamp": "2024-01-01T00:00:00+08:00", "review_period_hours": 1} - path = rs.save_review(review) - saved = json.loads(Path(path).read_text(encoding="utf-8")) - assert saved["review_period_hours"] == 1 - - -class TestPrintReview: - def test_outputs_report(self, capsys): - review = { - "review_timestamp": "2024-01-01T00:00:00+08:00", - "review_period_hours": 1, - "total_decisions": 2, - "total_trades": 1, - "total_errors": 0, - "stats": {"good_decisions": 1, "neutral_decisions": 0, "bad_decisions": 0, "missed_opportunities": 0}, - "insights": ["Insight A"], - "recommendations": ["Rec B"], - } - rs.print_review(review) - captured = capsys.readouterr() - assert "Coin Hunter Review Report" in captured.out - assert "Insight A" in captured.out - assert "Rec B" in captured.out diff --git a/tests/test_runtime.py b/tests/test_runtime.py deleted file mode 100644 index 749366e..0000000 --- a/tests/test_runtime.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for runtime path resolution.""" - -from pathlib import Path - -import pytest - -from coinhunter.runtime import RuntimePaths, ensure_runtime_dirs, get_runtime_paths, mask_secret - - -class TestGetRuntimePaths: - def test_defaults_point_to_home_dot_coinhunter(self): - paths = get_runtime_paths() - assert paths.root == Path.home() / ".coinhunter" - - def test_respects_coinhunter_home_env(self, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", "~/custom_ch") - paths = get_runtime_paths() - assert paths.root == Path.home() / "custom_ch" - - def test_respects_hermes_home_env(self, monkeypatch): - monkeypatch.setenv("HERMES_HOME", "~/custom_hermes") - paths = get_runtime_paths() - assert paths.hermes_home == Path.home() / "custom_hermes" - assert paths.env_file == Path.home() / "custom_hermes" / ".env" - - def test_returns_frozen_dataclass(self): - paths = get_runtime_paths() - assert isinstance(paths, RuntimePaths) - with pytest.raises(AttributeError): - paths.root = Path("/tmp") - - def test_as_dict_returns_strings(self): - paths = get_runtime_paths() - d = paths.as_dict() - assert isinstance(d, dict) - assert all(isinstance(v, str) for v in d.values()) - assert "root" in d - - -class TestEnsureRuntimeDirs: - def test_creates_directories(self, tmp_path, monkeypatch): - monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path)) - paths = get_runtime_paths() - returned = ensure_runtime_dirs(paths) - assert returned.root.exists() - assert returned.state_dir.exists() - assert returned.logs_dir.exists() - assert returned.cache_dir.exists() - assert returned.reviews_dir.exists() - - -class TestMaskSecret: - def test_empty_string(self): - assert mask_secret("") == "" - - def test_short_value(self): - assert mask_secret("ab") == "**" - - def test_masks_all_but_tail(self): - assert mask_secret("supersecret", tail=4) == "*******cret" - - def test_none_returns_empty(self): - assert mask_secret(None) == "" diff --git a/tests/test_smart_executor_service.py b/tests/test_smart_executor_service.py deleted file mode 100644 index 6ef1b96..0000000 --- a/tests/test_smart_executor_service.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Tests for smart executor deduplication and routing.""" - - -from coinhunter.services import exchange_service -from coinhunter.services import smart_executor_service as ses - - -class FakeArgs: - def __init__(self, command="buy", symbol="BTCUSDT", amount_usdt=50, dry_run=False, - decision_id=None, analysis=None, reasoning=None, order_id=None, - from_symbol=None, to_symbol=None): - self.command = command - self.symbol = symbol - self.amount_usdt = amount_usdt - self.dry_run = dry_run - self.decision_id = decision_id - self.analysis = analysis - self.reasoning = reasoning - self.order_id = order_id - self.from_symbol = from_symbol - self.to_symbol = to_symbol - - -class TestDeduplication: - def test_skips_duplicate_mutating_action(self, monkeypatch, capsys): - monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="buy"), ["BTCUSDT", "50"])) - monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "success"}) - get_exchange_calls = [] - monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex") - - result = ses.run(["buy", "BTCUSDT", "50"]) - assert result == 0 - assert get_exchange_calls == [] - captured = capsys.readouterr() - assert "already executed successfully" in captured.err - - def test_allows_duplicate_read_only_action(self, monkeypatch): - monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="balances"), ["balances"])) - monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "success"}) - monkeypatch.setattr(ses, "command_balances", lambda ex: None) - get_exchange_calls = [] - monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex") - - result = ses.run(["bal"]) - assert result == 0 - assert len(get_exchange_calls) == 1 - - def test_allows_retry_after_failure(self, monkeypatch): - monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="buy"), ["BTCUSDT", "50"])) - monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "failed"}) - monkeypatch.setattr(ses, "record_execution_state", lambda did, state: None) - monkeypatch.setattr(ses, "build_decision_context", lambda ex, action, tail, did: {}) - monkeypatch.setattr(ses, "action_buy", lambda *a, **k: {"id": "123"}) - monkeypatch.setattr(ses, "print_json", lambda d: None) - get_exchange_calls = [] - monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex") - - result = ses.run(["buy", "BTCUSDT", "50"]) - assert result == 0 - assert len(get_exchange_calls) == 1 - - -class TestReadOnlyRouting: - def test_routes_balances(self, monkeypatch): - monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="balances"), ["balances"])) - monkeypatch.setattr(ses, "get_execution_state", lambda did: None) - routed = [] - monkeypatch.setattr(ses, "command_balances", lambda ex: routed.append("balances")) - monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex") - - result = ses.run(["balances"]) - assert result == 0 - assert routed == ["balances"] - - def test_routes_orders(self, monkeypatch): - monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="orders"), ["orders"])) - monkeypatch.setattr(ses, "get_execution_state", lambda did: None) - routed = [] - monkeypatch.setattr(ses, "command_orders", lambda ex: routed.append("orders")) - monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex") - - result = ses.run(["orders"]) - assert result == 0 - assert routed == ["orders"] - - def test_routes_order_status(self, monkeypatch): - monkeypatch.setattr(ses, "parse_cli_args", lambda argv: ( - FakeArgs(command="order-status", symbol="BTCUSDT", order_id="123"), - ["order-status", "BTCUSDT", "123"] - )) - monkeypatch.setattr(ses, "get_execution_state", lambda did: None) - routed = [] - monkeypatch.setattr(ses, "command_order_status", lambda ex, sym, oid: routed.append((sym, oid))) - monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex") - - result = ses.run(["order-status", "BTCUSDT", "123"]) - assert result == 0 - assert routed == [("BTCUSDT", "123")] diff --git a/tests/test_state_manager.py b/tests/test_state_manager.py deleted file mode 100644 index a01f2fe..0000000 --- a/tests/test_state_manager.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Tests for state_manager precheck utilities.""" - -from datetime import timedelta, timezone - -from coinhunter.services.state_manager import ( - clear_run_request_fields, - sanitize_state_for_stale_triggers, - update_state_after_observation, -) -from coinhunter.services.time_utils import utc_iso, utc_now - - -class TestClearRunRequestFields: - def test_removes_run_fields(self): - state = {"run_requested_at": utc_iso(), "run_request_note": "test"} - clear_run_request_fields(state) - assert "run_requested_at" not in state - assert "run_request_note" not in state - - -class TestSanitizeStateForStaleTriggers: - def test_no_changes_when_clean(self): - state = {"pending_trigger": False} - result = sanitize_state_for_stale_triggers(state) - assert result["pending_trigger"] is False - assert result["_stale_recovery_notes"] == [] - - def test_clears_completed_run_request(self): - state = { - "pending_trigger": True, - "run_requested_at": utc_iso(), - "last_deep_analysis_at": utc_iso(), - } - result = sanitize_state_for_stale_triggers(state) - assert result["pending_trigger"] is False - assert "run_requested_at" not in result - assert any("completed run_requested" in note for note in result["_stale_recovery_notes"]) - - def test_clears_stale_run_request(self): - old = (utc_now() - timedelta(minutes=60)).replace(tzinfo=timezone.utc).isoformat() - state = { - "pending_trigger": False, - "run_requested_at": old, - } - result = sanitize_state_for_stale_triggers(state) - assert "run_requested_at" not in result - assert any("stale run_requested" in note for note in result["_stale_recovery_notes"]) - - def test_recovers_stale_pending_trigger(self): - old = (utc_now() - timedelta(minutes=60)).replace(tzinfo=timezone.utc).isoformat() - state = { - "pending_trigger": True, - "last_triggered_at": old, - } - result = sanitize_state_for_stale_triggers(state) - assert result["pending_trigger"] is False - assert any("stale pending_trigger" in note for note in result["_stale_recovery_notes"]) - - -class TestUpdateStateAfterObservation: - def test_updates_last_observed_fields(self): - state = {} - snapshot = { - "generated_at": "2024-01-01T00:00:00Z", - "snapshot_hash": "abc", - "positions_hash": "pos123", - "candidates_hash": "can456", - "portfolio_value_usdt": 100.0, - "market_regime": "neutral", - "positions": [], - "top_candidates": [], - } - analysis = {"should_analyze": False, "details": [], "adaptive_profile": {}} - result = update_state_after_observation(state, snapshot, analysis) - assert result["last_observed_at"] == snapshot["generated_at"] - assert result["last_snapshot_hash"] == "abc" - - def test_sets_pending_trigger_when_should_analyze(self): - state = {} - snapshot = { - "generated_at": "2024-01-01T00:00:00Z", - "snapshot_hash": "abc", - "positions_hash": "pos123", - "candidates_hash": "can456", - "portfolio_value_usdt": 100.0, - "market_regime": "neutral", - "positions": [], - "top_candidates": [], - } - analysis = { - "should_analyze": True, - "details": ["price move"], - "adaptive_profile": {}, - "hard_reasons": ["moon"], - "signal_delta": 1.5, - } - result = update_state_after_observation(state, snapshot, analysis) - assert result["pending_trigger"] is True - assert result["pending_reasons"] == ["price move"] diff --git a/tests/test_trade_execution.py b/tests/test_trade_execution.py deleted file mode 100644 index 15ede42..0000000 --- a/tests/test_trade_execution.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Tests for trade execution dry-run paths.""" - -import pytest - -from coinhunter.services import trade_execution as te -from coinhunter.services.trade_common import set_dry_run - - -class TestMarketSellDryRun: - def test_returns_dry_run_order(self, monkeypatch): - set_dry_run(True) - monkeypatch.setattr( - te, "prepare_sell_quantity", - lambda ex, sym, qty: ("BTC/USDT", 0.5, 60000.0, 30000.0) - ) - order = te.market_sell(None, "BTCUSDT", 0.5, "dec-123") - assert order["id"] == "dry-sell-dec-123" - assert order["symbol"] == "BTC/USDT" - assert order["amount"] == 0.5 - assert order["status"] == "closed" - set_dry_run(False) - - -class TestMarketBuyDryRun: - def test_returns_dry_run_order(self, monkeypatch): - set_dry_run(True) - monkeypatch.setattr( - te, "prepare_buy_quantity", - lambda ex, sym, amt: ("ETH/USDT", 1.0, 3000.0, 3000.0) - ) - order = te.market_buy(None, "ETHUSDT", 100, "dec-456") - assert order["id"] == "dry-buy-dec-456" - assert order["symbol"] == "ETH/USDT" - assert order["amount"] == 1.0 - assert order["status"] == "closed" - set_dry_run(False) - - -class TestActionSellAll: - def test_raises_when_balance_zero(self, monkeypatch): - monkeypatch.setattr(te, "fetch_balances", lambda ex: {"BTC": 0}) - with pytest.raises(RuntimeError, match="balance is zero"): - te.action_sell_all(None, "BTCUSDT", "dec-789", {}) - - def test_dry_run_does_not_reconcile(self, monkeypatch): - set_dry_run(True) - monkeypatch.setattr(te, "fetch_balances", lambda ex: {"BTC": 0.5}) - monkeypatch.setattr( - te, "market_sell", - lambda ex, sym, qty, did: {"id": "dry-1", "amount": qty, "price": 60000.0, "cost": 30000.0, "status": "closed"} - ) - monkeypatch.setattr(te, "reconcile_positions_with_exchange", lambda ex, hint=None: ([], {})) - monkeypatch.setattr(te, "log_trade", lambda *a, **k: None) - monkeypatch.setattr(te, "log_decision", lambda *a, **k: None) - - result = te.action_sell_all(None, "BTCUSDT", "dec-789", {"analysis": "test"}) - assert result["id"] == "dry-1" - set_dry_run(False) - - -class TestActionBuy: - def test_raises_when_insufficient_usdt(self, monkeypatch): - monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 10.0}) - with pytest.raises(RuntimeError, match="Insufficient USDT"): - te.action_buy(None, "BTCUSDT", 50, "dec-abc", {}) - - def test_dry_run_skips_save(self, monkeypatch): - set_dry_run(True) - monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0}) - monkeypatch.setattr( - te, "market_buy", - lambda ex, sym, amt, did: {"id": "dry-2", "amount": 0.01, "price": 50000.0, "cost": 500.0, "status": "closed"} - ) - monkeypatch.setattr(te, "load_positions", lambda: []) - monkeypatch.setattr(te, "upsert_position", lambda positions, pos: positions.append(pos)) - monkeypatch.setattr(te, "reconcile_positions_with_exchange", lambda ex, hint=None: ([], {})) - monkeypatch.setattr(te, "log_trade", lambda *a, **k: None) - monkeypatch.setattr(te, "log_decision", lambda *a, **k: None) - - result = te.action_buy(None, "BTCUSDT", 50, "dec-abc", {}) - assert result["id"] == "dry-2" - set_dry_run(False) - - -class TestCommandBalances: - def test_returns_balances(self, monkeypatch, capsys): - monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0, "BTC": 0.5}) - result = te.command_balances(None) - assert result == {"USDT": 100.0, "BTC": 0.5} - captured = capsys.readouterr() - assert '"USDT": 100.0' in captured.out - - -class TestCommandStatus: - def test_returns_snapshot(self, monkeypatch, capsys): - monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0}) - monkeypatch.setattr(te, "load_positions", lambda: []) - monkeypatch.setattr(te, "build_market_snapshot", lambda ex: {"BTC/USDT": 50000.0}) - result = te.command_status(None) - assert result["balances"]["USDT"] == 100.0 - captured = capsys.readouterr() - assert '"balances"' in captured.out - - -class TestCommandOrders: - def test_returns_orders(self, monkeypatch, capsys): - orders = [{"id": "1", "symbol": "BTC/USDT"}] - monkeypatch.setattr(te, "print_json", lambda d: None) - ex = type("Ex", (), {"fetch_open_orders": lambda self: orders})() - result = te.command_orders(ex) - assert result == orders - - -class TestCommandOrderStatus: - def test_returns_order(self, monkeypatch, capsys): - order = {"id": "123", "status": "open"} - monkeypatch.setattr(te, "print_json", lambda d: None) - ex = type("Ex", (), {"fetch_order": lambda self, oid, sym: order})() - result = te.command_order_status(ex, "BTCUSDT", "123") - assert result == order - - -class TestCommandCancel: - def test_dry_run_returns_early(self, monkeypatch): - set_dry_run(True) - monkeypatch.setattr(te, "log", lambda msg: None) - result = te.command_cancel(None, "BTCUSDT", "123") - assert result["dry_run"] is True - set_dry_run(False) - - def test_cancel_by_order_id(self, monkeypatch): - set_dry_run(False) - monkeypatch.setattr(te, "print_json", lambda d: None) - ex = type("Ex", (), {"cancel_order": lambda self, oid, sym: {"id": oid, "status": "canceled"}})() - result = te.command_cancel(ex, "BTCUSDT", "123") - assert result["status"] == "canceled" - - def test_cancel_latest_when_no_order_id(self, monkeypatch): - set_dry_run(False) - monkeypatch.setattr(te, "print_json", lambda d: None) - ex = type("Ex", (), { - "fetch_open_orders": lambda self, sym: [{"id": "999"}, {"id": "888"}], - "cancel_order": lambda self, oid, sym: {"id": oid, "status": "canceled"}, - })() - result = te.command_cancel(ex, "BTCUSDT", None) - assert result["id"] == "888" - - def test_cancel_raises_when_no_open_orders(self, monkeypatch): - set_dry_run(False) - ex = type("Ex", (), {"fetch_open_orders": lambda self, sym: []})() - with pytest.raises(RuntimeError, match="No open orders"): - te.command_cancel(ex, "BTCUSDT", None) - - -class TestPrintJson: - def test_outputs_sorted_json(self, capsys): - te.print_json({"b": 2, "a": 1}) - captured = capsys.readouterr() - assert '"a": 1' in captured.out - assert '"b": 2' in captured.out diff --git a/tests/test_trade_service.py b/tests/test_trade_service.py new file mode 100644 index 0000000..ed67446 --- /dev/null +++ b/tests/test_trade_service.py @@ -0,0 +1,124 @@ +"""Trade execution tests.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from coinhunter.services import trade_service + + +class FakeSpotClient: + def __init__(self): + self.calls = [] + + def new_order(self, **kwargs): + self.calls.append(kwargs) + return {"symbol": kwargs["symbol"], "status": "FILLED", "orderId": 1} + + +class FakeFuturesClient: + def __init__(self): + self.calls = [] + + def new_order(self, **kwargs): + self.calls.append(kwargs) + return {"symbol": kwargs["symbol"], "status": "FILLED", "orderId": 2} + + def position_risk(self, symbol=None): + return [{"symbol": "BTCUSDT", "positionAmt": "-0.02", "notional": "-1200"}] + + +class TradeServiceTestCase(unittest.TestCase): + def test_spot_market_buy_dry_run_does_not_call_client(self): + events = [] + with patch.object(trade_service, "audit_event", side_effect=lambda event, payload: events.append((event, payload))): + client = FakeSpotClient() + payload = trade_service.execute_spot_trade( + {"trading": {"dry_run_default": False}}, + side="buy", + symbol="btc/usdt", + qty=None, + quote=100, + order_type="market", + price=None, + dry_run=True, + spot_client=client, + ) + self.assertEqual(payload["trade"]["status"], "DRY_RUN") + self.assertEqual(client.calls, []) + self.assertEqual([event for event, _ in events], ["trade_submitted", "trade_filled"]) + + def test_spot_limit_sell_maps_payload(self): + with patch.object(trade_service, "audit_event", return_value=None): + client = FakeSpotClient() + payload = trade_service.execute_spot_trade( + {"trading": {"dry_run_default": False}}, + side="sell", + symbol="BTCUSDT", + qty=0.1, + quote=None, + order_type="limit", + price=90000, + dry_run=False, + spot_client=client, + ) + self.assertEqual(payload["trade"]["status"], "FILLED") + self.assertEqual(client.calls[0]["timeInForce"], "GTC") + + def test_futures_close_uses_opposite_side(self): + with patch.object(trade_service, "audit_event", return_value=None): + client = FakeFuturesClient() + payload = trade_service.close_futures_position( + {"trading": {"dry_run_default": False}}, + symbol="BTCUSDT", + dry_run=False, + futures_client=client, + ) + self.assertEqual(payload["trade"]["side"], "BUY") + self.assertEqual(client.calls[0]["reduceOnly"], "true") + + def test_spot_market_buy_requires_quote(self): + with patch.object(trade_service, "audit_event", return_value=None): + with self.assertRaisesRegex(RuntimeError, "requires --quote"): + trade_service.execute_spot_trade( + {"trading": {"dry_run_default": False}}, + side="buy", + symbol="BTCUSDT", + qty=None, + quote=None, + order_type="market", + price=None, + dry_run=False, + spot_client=FakeSpotClient(), + ) + + def test_spot_market_buy_rejects_qty(self): + with patch.object(trade_service, "audit_event", return_value=None): + with self.assertRaisesRegex(RuntimeError, "accepts --quote only"): + trade_service.execute_spot_trade( + {"trading": {"dry_run_default": False}}, + side="buy", + symbol="BTCUSDT", + qty=0.1, + quote=100, + order_type="market", + price=None, + dry_run=False, + spot_client=FakeSpotClient(), + ) + + def test_spot_market_sell_rejects_quote(self): + with patch.object(trade_service, "audit_event", return_value=None): + with self.assertRaisesRegex(RuntimeError, "accepts --qty only"): + trade_service.execute_spot_trade( + {"trading": {"dry_run_default": False}}, + side="sell", + symbol="BTCUSDT", + qty=0.1, + quote=100, + order_type="market", + price=None, + dry_run=False, + spot_client=FakeSpotClient(), + )