18 Commits
v1.0.0 ... main

Author SHA1 Message Date
3855477155 refactor: flatten account command to a single balances view
Remove overview/balances/positions subcommands in favor of one
`account` command that returns all balances with an `is_dust` flag.
Add descriptions to every parser and expose -a/--agent and --doc
on all leaf commands for better help discoverability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 18:19:19 +08:00
d629c25232 fix: resolve merge conflicts and lint issues
- Merge origin/main changes (flattened buy/sell commands, --doc flag, aliases)
- Fix spinner placement for buy/sell commands
- Fix duplicate alias key 'p' in canonical subcommands
- Remove unused mypy ignore comments in spot_client.py
- Fix nested with statements in tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:59:53 +08:00
4602583760 Merge remote-tracking branch 'origin/main' into main 2026-04-17 16:57:40 +08:00
ca0625b199 chore: bump version to 2.1.1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:51:55 +08:00
a0e01ca56f chore: bump version to 2.1.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:44:58 +08:00
f528575aa8 feat: add catlog command, agent flag reorder, and TUI polish
- Add `coinhunter catlog` with limit/offset pagination for audit logs
- Optimize audit log reading with deque to avoid loading all history
- Allow `-a/--agent` flag after subcommands
- Fix upgrade spinner artifact and empty line issues
- Render audit log TUI as timeline with low-saturation event colors
- Convert audit timestamps to local timezone in TUI
- Remove futures-related capabilities
- Add conda environment.yml for development
- Bump version to 2.0.9 and update README

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:42:47 +08:00
9224621d7e feat: add CLI aliases, flatten trade commands, and introduce --doc
- Add `coin` entry-point alias alongside `coinhunter`
- Add short aliases for all commands (e.g., a/acc, m, opp/o, b, s)
- Flatten `buy` and `sell` to top-level commands; remove `trade` parent
- Add `--doc` flag to print output schema and field descriptions per command
- Update README and tests
- Bump version to 2.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 00:40:24 +08:00
6923013694 fix: remove recvWindow from exchange_info wrapper
Binance exchangeInfo endpoint does not accept recvWindow, causing
RuntimeError when calling opportunity scan or any command that hits
exchange_info().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:21:45 +08:00
0f862957b0 refactor: remove all futures-related capabilities
Delete USDT-M futures support since the user's Binance API key does not
support futures trading. This simplifies the CLI to spot-only:

- Remove futures client wrapper (um_futures_client.py)
- Remove futures trade commands and close position logic
- Simplify account service to spot-only (no market_type field)
- Remove futures references from opportunity service
- Update README and tests to reflect spot-only architecture
- Bump version to 2.0.7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:10:15 +08:00
680bd3d33c fix: allow -a/--agent flag after subcommands
- Preprocess argv to reorder agent flag before subcommand parsing.
- Enables usage like `coinhunter account overview -s -f -a`.
- Bump version to 2.0.6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:19:12 +08:00
f06a1a34f1 feat: Braille spinner, shell completions, TUI polish
- Add with_spinner context manager with cyan Braille animation for human mode.
- Wrap all query/execution commands in cli.py with loading spinners.
- Integrate shtab: auto-install shell completions during init for zsh/bash.
- Add `completion` subcommand for manual script generation.
- Fix stale output_format default in DEFAULT_CONFIG.
- Add help descriptions to all second-level subcommands.
- Bump version to 2.0.5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:12:23 +08:00
536425e8ea feat: add Braille spinner, shell completions, and TUI polish
- Add with_spinner context manager with cyan Braille animation for human mode.
- Wrap all query/execution commands in cli.py with loading spinners.
- Integrate shtab: auto-install shell completions during init for zsh/bash.
- Add `completion` subcommand for manual script generation.
- Fix stale output_format default in DEFAULT_CONFIG (json → tui).
- Add help descriptions to all second-level subcommands.
- Version 2.0.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:11:40 +08:00
b857ea33f3 refactor: rename update command to upgrade
- Align CLI verb with pipx/pip terminology (`pipx upgrade`).
- Rename internal `self_update` to `self_upgrade` for consistency.
- Update README and tests accordingly.
- Bump version to 2.0.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:50:14 +08:00
cdc90a9be1 fix: clean up update TUI output and suppress noisy stderr
- Add dedicated render branch for self_update results.
- Hide progress-only stderr on success to eliminate pipx noise.
- Remove generic "RESULT" heading from fallback key-value output.
- Bump version to 2.0.3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:47:08 +08:00
9395978440 feat: human-friendly TUI output with --agent flag for JSON/compact
- Replace default JSON output with styled TUI tables and ANSI colors.
- Add -a/--agent global flag: small payloads → JSON, large → pipe-delimited compact.
- Update README to reflect new output behavior and remove JSON-first references.
- Bump version to 2.0.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:36:23 +08:00
b78845eb43 feat: add self-update command and bump to 2.0.1
- Add `coinhunter update` CLI command for pipx/pip upgrade
- README: document update behavior and recommend pipx install
- Dynamic version badge with cacheSeconds=60
- Version bump: 2.0.0 → 2.0.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:00:43 +08:00
52cd76a750 refactor: rewrite to CoinHunter V2 flat architecture
Replace the V1 commands/services split with a flat, direct architecture:
- cli.py dispatches directly to service functions
- New services: account, market, trade, opportunity
- Thin Binance wrappers: spot_client, um_futures_client
- Add audit logging, runtime paths, and TOML config
- Remove legacy V1 code: commands/, precheck, review engine, smart executor
- Add ruff + mypy toolchain and fix edge cases in trade params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:22:29 +08:00
3819e35a7b docs: recommend pipx for end-user installation to avoid externally-managed-environment errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 01:37:31 +08:00
78 changed files with 2694 additions and 5375 deletions

29
.gitignore vendored
View File

@@ -1,8 +1,35 @@
# Python
__pycache__/
*.pyc
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Virtual environments
.venv/
venv/
# Build artifacts
dist/
build/
*.egg-info/
# IDE / editors
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
# Secrets / local env
.env
*.env
# Claude local overrides
.claude/skills/gstack/

134
CLAUDE.md
View File

@@ -4,111 +4,59 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Development commands
- **Editable install:** `pip install -e .`
- **Run the CLI locally:** `python -m coinhunter --help`
- **Install for end users:** `./scripts/install_local.sh` (standard `pip install -e .` wrapper)
- **Tests:** There is no test suite yet. The README lists next priorities as adding pytest coverage for runtime paths, state manager, and trigger analyzer.
- **Lint / type-check:** Not configured yet.
## CLI command routing
`src/coinhunter/cli.py` is the single entrypoint. It resolves aliases to canonical commands, maps canonical commands to Python modules via `MODULE_MAP`, then imports the module and calls `module.main()` after mutating `sys.argv` to match the display name.
Active commands live in `src/coinhunter/commands/` and are thin adapters that delegate to `src/coinhunter/services/`. Root-level backward-compat facades (e.g., `precheck.py`, `smart_executor.py`, `review_engine.py`, `review_context.py`) re-export the moved commands.
- **Install (dev):** `pip install -e ".[dev]"`
- **Run CLI locally:** `python -m coinhunter --help`
- **Run tests:** `pytest` or `python -m pytest tests/`
- **Run single test file:** `pytest tests/test_cli.py -v`
- **Lint:** `ruff check src tests`
- **Format:** `ruff format src tests`
- **Type-check:** `mypy src`
## Architecture
### Layer responsibilities
CoinHunter V2 is a Binance-first crypto trading CLI with a flat, direct architecture:
- **CLI (`cli.py`)** — argument parsing, alias resolution, module dispatch.
- **Commands (`commands/`)** — thin, stateless adapters that call into services.
- **Services (`services/`)** — orchestration, domain logic, and exchange interaction.
- **Runtime (`runtime.py`)** — path resolution, env loading, hermes binary discovery.
- **Logger (`logger.py`)** — structured JSONL logging to `~/.coinhunter/logs/`.
- **`src/coinhunter/cli.py`** — Single entrypoint (`main()`). Uses `argparse` to parse commands and directly dispatches to service functions. There is no separate `commands/` adapter layer.
- **`src/coinhunter/services/`** — Contains all domain logic:
- `account_service.py` — balances, positions, overview
- `market_service.py` — tickers, klines, scan universe, symbol normalization
- `trade_service.py` — spot and USDT-M futures order execution
- `opportunity_service.py` — portfolio recommendations and market scanning
- **`src/coinhunter/binance/`** — Thin wrappers around official Binance connectors:
- `spot_client.py` wraps `binance.spot.Spot`
- `um_futures_client.py` wraps `binance.um_futures.UMFutures`
Both normalize request errors into `RuntimeError` and handle single/multi-symbol ticker responses.
- **`src/coinhunter/config.py`** — `load_config()`, `get_binance_credentials()`, `ensure_init_files()`.
- **`src/coinhunter/runtime.py`** — `RuntimePaths`, `get_runtime_paths()`, `print_json()`.
- **`src/coinhunter/audit.py`** — Writes JSONL audit events to dated files.
### Runtime and environment
## Runtime and environment
`runtime.py` defines `RuntimePaths` and `get_runtime_paths()`. User data lives in `~/.coinhunter/` by default (override with `COINHUNTER_HOME`). Credentials are loaded from `~/.hermes/.env` by default (override with `COINHUNTER_ENV_FILE`). Modules should call `get_runtime_paths()` at function scope rather than eagerly at import time.
User data lives in `~/.coinhunter/` by default (override with `COINHUNTER_HOME`):
### Smart executor (`exec`)
- `config.toml` — runtime, binance, trading, and opportunity settings
- `.env``BINANCE_API_KEY` and `BINANCE_API_SECRET`
- `logs/audit_YYYYMMDD.jsonl` — structured audit log
`commands/smart_executor.py``services/smart_executor_service.py``services/trade_execution.py`.
Run `coinhunter init` to generate the config and env templates.
Supported verbs: `bal`, `overview`, `hold`, `buy SYMBOL USDT`, `flat SYMBOL`, `rotate FROM TO`.
## Key conventions
- `smart_executor_parser.py` normalizes legacy argv and exposes `parse_cli_args()`.
- `trade_common.py` holds a global dry-run flag (`set_dry_run` / `is_dry_run`).
- `execution_state.py` tracks decision IDs in JSON to prevent duplicate executions.
- `exchange_service.py` wraps `ccxt.binance` and handles symbol normalization.
- `portfolio_service.py` manages `positions.json` load/save/reconcile.
- **Symbol normalization:** `market_service.normalize_symbol()` strips `/`, `-`, `_`, and uppercases the symbol. CLI inputs like `ETH/USDT`, `eth-usdt`, and `ETHUSDT` are all normalized to `ETHUSDT`.
- **Dry-run behavior:** Trade commands support `--dry-run`. If omitted, the default falls back to `trading.dry_run_default` in `config.toml`.
- **Client injection:** Service functions accept `spot_client` / `futures_client` as keyword arguments. This enables easy unit testing with mocks.
- **Error handling:** Binance client wrappers catch `requests.exceptions.SSLError` and `RequestException` and re-raise as human-readable `RuntimeError`. The CLI catches all exceptions in `main()` and prints `error: {message}` to stderr with exit code 1.
### Precheck
## Testing
`commands/precheck.py``services/precheck_service.py`.
Tests live in `tests/` and use `unittest.TestCase` with `unittest.mock.patch`. The test suite covers:
The precheck workflow:
1. Load and sanitize state (`precheck_state.py` — clears stale triggers and run requests).
2. Build a market snapshot (`precheck_snapshot.py``snapshot_builder.py`).
3. Analyze whether to trigger deep analysis (`precheck_analysis.py``trigger_analyzer.py`).
4. Update and save state (`precheck_state.update_state_after_observation`).
- `test_cli.py` — parser smoke tests and dispatch behavior
- `test_config_runtime.py` — config loading, env parsing, path resolution
- `test_account_market_services.py` — balance/position/ticker/klines logic with mocked clients
- `test_trade_service.py` — spot and futures trade execution paths
- `test_opportunity_service.py` — portfolio and scan scoring logic
State is stored in `~/.coinhunter/state/precheck_state.json`.
## Notes
### Review commands
- `review N` (`commands/review_context.py``services/review_service.py`) — generates review context for the last N hours.
- `recap N` (`commands/review_engine.py``services/review_service.py`) — generates a full review report by reading JSONL decision/trade/error logs, computing PnL estimates, missed opportunities, and saving a report to `~/.coinhunter/reviews/`.
### Logging model
`logger.py` writes JSONL to dated files in `~/.coinhunter/logs/`:
- `decisions_YYYYMMDD.jsonl`
- `trades_YYYYMMDD.jsonl`
- `errors_YYYYMMDD.jsonl`
- `snapshots_YYYYMMDD.jsonl`
Use `get_logs_last_n_hours(log_type, hours)` to query recent entries.
## Common command reference
```bash
# Diagnostics
python -m coinhunter diag
python -m coinhunter paths
python -m coinhunter api-check
# Execution (dry-run)
python -m coinhunter exec bal
python -m coinhunter exec overview
python -m coinhunter exec buy ENJUSDT 50 --dry-run
python -m coinhunter exec flat ENJUSDT --dry-run
# Precheck
python -m coinhunter precheck
python -m coinhunter precheck --ack "analysis completed"
python -m coinhunter precheck --mark-run-requested "reason"
# Review
python -m coinhunter review 12
python -m coinhunter recap 12
```
## Skill routing
When the user's request matches an available skill, ALWAYS invoke it using the Skill
tool as your FIRST action. Do NOT answer directly, do NOT use other tools first.
The skill has specialized workflows that produce better results than ad-hoc answers.
Key routing rules:
- Product ideas, "is this worth building", brainstorming → invoke office-hours
- Bugs, errors, "why is this broken", 500 errors → invoke investigate
- Ship, deploy, push, create PR → invoke ship
- QA, test the site, find bugs → invoke qa
- Code review, check my diff → invoke review
- Update docs after shipping → invoke document-release
- Weekly retro → invoke retro
- Design system, brand → invoke design-consultation
- Visual audit, design polish → invoke design-review
- Architecture review → invoke plan-eng-review
- Save progress, checkpoint, resume → invoke checkpoint
- Code quality, health check → invoke health
- `AGENTS.md` in this repo is stale and describes a prior V1 architecture (commands/, smart executor, precheck, review engine). Do not rely on it.

371
README.md
View File

@@ -3,314 +3,179 @@
</p>
<p align="center">
<img src="https://readme-typing-svg.demolab.com?font=JetBrains+Mono&weight=500&size=22&duration=2800&pause=800&color=F7B93E&center=true&vCenter=true&width=600&lines=Spot+Trading+Orchestration+for+Terminal+Cowboys;Precheck+%E2%86%92+Execute+%E2%86%92+Review+%E2%86%92+Repeat;JSON-first+CLI+with+Dry-run+Safety" alt="Typing SVG" />
<img src="https://readme-typing-svg.demolab.com?font=JetBrains+Mono&weight=500&size=22&duration=2800&pause=800&color=F7B93E&center=true&vCenter=true&width=600&lines=Binance-first+Trading+CLI;Account+%E2%86%92+Market+%E2%86%92+Trade+%E2%86%92+Opportunity;Human-friendly+TUI+%7C+Agent+Mode" alt="Typing SVG" />
</p>
<p align="center">
<strong>Runtime-safe trading operations, precheck orchestration, review tooling, and market probes.</strong>
<strong>A Binance-first crypto trading CLI for balances, market data, opportunity scanning, and execution.</strong>
</p>
<p align="center">
<a href="https://pypi.org/project/coinhunter/"><img src="https://img.shields.io/pypi/v/coinhunter?style=flat-square&color=F7B93E&labelColor=1a1a1a" /></a>
<a href="https://pypi.org/project/coinhunter/"><img src="https://img.shields.io/pypi/v/coinhunter?style=flat-square&color=F7B93E&labelColor=1a1a1a&cacheSeconds=60" /></a>
<a href="#"><img src="https://img.shields.io/badge/python-3.10%2B-3776ab?style=flat-square&logo=python&logoColor=white&labelColor=1a1a1a" /></a>
<a href="#"><img src="https://img.shields.io/badge/tests-96%20passed-22c55e?style=flat-square&labelColor=1a1a1a" /></a>
<a href="#"><img src="https://img.shields.io/badge/tests-passing-22c55e?style=flat-square&labelColor=1a1a1a" /></a>
<a href="#"><img src="https://img.shields.io/badge/lint-ruff%20%2B%20mypy-8b5cf6?style=flat-square&labelColor=1a1a1a" /></a>
</p>
---
## What is this?
## Install
`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)
```bash
pip install coinhunter
```
This installs the latest stable release and creates the `coinhunter` console script entry point.
Verify:
For end users, install from PyPI with [pipx](https://pipx.pypa.io/) (recommended) to avoid polluting your system Python:
```bash
pipx install coinhunter
coinhunter --help
```
You can also use the shorter `coin` alias:
```bash
coin --help
```
Check the installed version:
```bash
coinhunter --version
```
### Development install (editable)
If you're working on this repo locally:
To update later:
```bash
pip install -e ".[dev]"
pipx upgrade coinhunter
```
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/`
If you are using **zsh** or **bash**, `init` will also generate and install shell completion scripts automatically, and update your rc file (`~/.zshrc` or `~/.bashrc`) if needed.
`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
By default, CoinHunter prints human-friendly TUI tables. Add `--agent` to any command to get JSON output (or compact pipe-delimited tables for large datasets).
Add `--doc` to any command to see its output schema and field descriptions (great for AI agents):
```bash
coinhunter api-check
coin buy --doc
coin market klines --doc
```
Run the precheck workflow:
### Examples
```bash
coinhunter precheck
coinhunter precheck --ack "analysis completed"
# Account (aliases: a, acc)
coinhunter account
coinhunter account --agent
coin a
# Market (aliases: m)
coinhunter market tickers BTCUSDT ETH/USDT sol-usdt
coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50
coin m tk BTCUSDT ETHUSDT
coin m k BTCUSDT -i 1h -l 50
# Trade (buy / sell are now top-level commands)
coinhunter buy BTCUSDT --quote 100 --dry-run
coinhunter sell BTCUSDT --qty 0.01 --type limit --price 90000
coin b BTCUSDT -Q 100 -d
coin s BTCUSDT -q 0.01 -t limit -p 90000
# Opportunities (aliases: opp, o)
coinhunter opportunity portfolio
coinhunter opportunity scan
coinhunter opportunity scan --symbols BTCUSDT ETHUSDT SOLUSDT
coin opp pf
coin o scan -s BTCUSDT ETHUSDT
# Audit log
coinhunter catlog
coinhunter catlog -n 20
coinhunter catlog -n 10 -o 10
# Self-upgrade
coinhunter upgrade
coin upgrade
# Shell completion (manual)
coinhunter completion zsh > ~/.zsh/completions/_coinhunter
coinhunter completion bash > ~/.local/share/bash-completion/completions/coinhunter
```
Run the external gate:
`upgrade` will try `pipx upgrade coinhunter` first, and fall back to `pip install --upgrade coinhunter` if pipx is not available.
```bash
coinhunter gate
```
## Architecture
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:
CoinHunter V2 uses a flat, direct architecture:
```json
{
"external_gate": {
"trigger_command": ["hermes", "cron", "run", "JOB_ID"]
}
}
```
| Layer | Responsibility | Key Files |
|-------|----------------|-----------|
| **CLI** | Single entrypoint, argument parsing | `cli.py` |
| **Binance** | Thin API wrappers with unified error handling | `binance/spot_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, TUI/JSON/compact output | `runtime.py` |
| **Audit** | Structured JSONL logging | `audit.py` |
- Set to `null` or `[]` to explicitly disable the external trigger.
## Logging
### 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`
---
Use `coinhunter catlog` to read recent entries in the terminal. It aggregates across all days and supports pagination with `-n/--limit` and `-o/--offset`.
## Development Status
## Development
The codebase is actively maintained and refactored in small, safe steps.
Clone the repo and install in editable mode:
**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
```bash
git clone https://git.tacitlab.cc/TacitLab/coinhunter-cli.git
cd coinhunter-cli
pip install -e ".[dev]"
```
**Next priorities:**
- 🔧 Add basic CI (lint + compileall + pytest)
- 🔧 Unify output contract (JSON-first with `--pretty` option)
Or use the provided Conda environment:
---
```bash
conda env create -f environment.yml
conda activate coinhunter
```
## Philosophy
Run quality checks:
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
```

9
environment.yml Normal file
View File

@@ -0,0 +1,9 @@
name: coinhunter
channels:
- defaults
- conda-forge
dependencies:
- python>=3.10
- pip
- pip:
- -e ".[dev]"

View File

@@ -4,23 +4,27 @@ build-backend = "setuptools.build_meta"
[project]
name = "coinhunter"
version = "1.0.0"
description = "CoinHunter trading CLI with user runtime data in ~/.coinhunter"
version = "2.1.1"
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"
]
authors = [
{name = "Tacit Lab", email = "ouyangcarlos@gmail.com"}
"binance-connector>=3.9.0",
"shtab>=1.7.0",
"tomli>=2.0.1; python_version < '3.11'",
]
[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"
coin = "coinhunter.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
@@ -28,16 +32,19 @@ package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v"
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
ignore = ["E501"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
warn_unused_ignores = true
ignore_missing_imports = true

View File

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

View File

@@ -1 +1,8 @@
__version__ = "0.1.0"
"""CoinHunter V2."""
try:
from importlib.metadata import version
__version__ = version("coinhunter")
except Exception: # pragma: no cover
__version__ = "unknown"

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

@@ -0,0 +1,70 @@
"""Audit logging for CoinHunter V2."""
from __future__ import annotations
import json
from collections import deque
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
def read_audit_log(paths: RuntimePaths | None = None, limit: int = 10, offset: int = 0) -> list[dict[str, Any]]:
paths = ensure_runtime_dirs(paths or get_runtime_paths())
logs_dir = _resolve_audit_dir(paths)
if not logs_dir.exists():
return []
audit_files = sorted(logs_dir.glob("audit_*.jsonl"), reverse=True)
needed = offset + limit
chunks: list[list[dict[str, Any]]] = []
total = 0
for audit_file in audit_files:
remaining = needed - total
if remaining <= 0:
break
entries: list[dict[str, Any]] = []
with audit_file.open("r", encoding="utf-8") as handle:
entries = list(deque((json.loads(line) for line in handle if line.strip()), maxlen=remaining))
if entries:
chunks.append(entries)
total += len(entries)
if not chunks:
return []
all_entries: list[dict[str, Any]] = []
for chunk in reversed(chunks):
all_entries.extend(chunk)
start = -(offset + limit) if (offset + limit) <= len(all_entries) else -len(all_entries)
if offset == 0:
return all_entries[start:]
return all_entries[start:-offset]

View File

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

View File

@@ -0,0 +1,78 @@
"""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] = {}
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]
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]
def klines(self, symbol: str, interval: str, limit: int) -> list[list[Any]]:
return self._call("klines", self._client.klines, symbol=symbol, interval=interval, limit=limit) # type: ignore[no-any-return]
def new_order(self, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("recvWindow", self.recv_window)
return self._call("new order", self._client.new_order, **kwargs) # type: ignore[no-any-return]

View File

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

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

@@ -1,146 +1,506 @@
"""CoinHunter unified CLI entrypoint."""
"""CoinHunter V2 CLI."""
from __future__ import annotations
import argparse
import importlib
import sys
from typing import Any
from . import __version__
from .audit import read_audit_log
from .binance.spot_client import SpotBinanceClient
from .config import ensure_init_files, get_binance_credentials, load_config
from .runtime import (
get_runtime_paths,
install_shell_completion,
print_output,
self_upgrade,
with_spinner,
)
from .services import (
account_service,
market_service,
opportunity_service,
trade_service,
)
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",
EPILOG = """\
examples:
coin init
coin account
coin m tk BTCUSDT ETHUSDT
coin m k BTCUSDT -i 1h -l 50
coin buy BTCUSDT -Q 100 -d
coin sell BTCUSDT --qty 0.01 --type limit --price 90000
coin opp scan -s BTCUSDT ETHUSDT
coin upgrade
"""
COMMAND_DOCS: dict[str, str] = {
"init": """\
Output: JSON
{
"root": "~/.coinhunter",
"files_created": ["config.toml", ".env"],
"completion": {"shell": "zsh", "installed": true}
}
Fields:
root runtime directory path
files_created list of generated files
completion shell completion installation status
""",
"account": """\
Output: JSON
{
"balances": [
{"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false}
]
}
Fields:
asset asset symbol
free available balance
locked frozen/locked balance
total free + locked
notional_usdt estimated value in USDT
is_dust true if value is below dust threshold
""",
"market/tickers": """\
Output: JSON object keyed by normalized symbol
{
"BTCUSDT": {"lastPrice": "70000.00", "priceChangePercent": "2.5", "volume": "12345.67"}
}
Fields:
lastPrice latest traded price
priceChangePercent 24h change %
volume 24h base volume
""",
"market/klines": """\
Output: JSON object keyed by symbol, value is array of OHLCV candles
{
"BTCUSDT": [
{"open_time": 1713000000000, "open": 69000.0, "high": 69500.0, "low": 68800.0, "close": 69200.0, "volume": 100.5}
]
}
Fields per candle:
open_time candle open timestamp (ms)
open/high/low/close OHLC prices
volume traded base volume
""",
"buy": """\
Output: JSON
{
"trade": {
"market_type": "spot",
"symbol": "BTCUSDT",
"side": "BUY",
"order_type": "MARKET",
"status": "DRY_RUN",
"dry_run": true,
"request_payload": {...},
"response_payload": {...}
}
}
Fields:
market_type "spot"
side "BUY"
order_type MARKET or LIMIT
status order status from exchange (or DRY_RUN)
dry_run whether simulated
request_payload normalized order sent to Binance
response_payload raw exchange response
""",
"sell": """\
Output: JSON
{
"trade": {
"market_type": "spot",
"symbol": "BTCUSDT",
"side": "SELL",
"order_type": "LIMIT",
"status": "FILLED",
"dry_run": false,
"request_payload": {...},
"response_payload": {...}
}
}
Fields:
market_type "spot"
side "SELL"
order_type MARKET or LIMIT
status order status from exchange (or DRY_RUN)
dry_run whether simulated
request_payload normalized order sent to Binance
response_payload raw exchange response
""",
"opportunity/portfolio": """\
Output: JSON
{
"scores": [
{"asset": "BTC", "score": 0.75, "metrics": {"volatility": 0.02, "trend": 0.01}}
]
}
Fields:
asset scored asset
score composite opportunity score (0-1)
metrics breakdown of contributing signals
""",
"opportunity/scan": """\
Output: JSON
{
"opportunities": [
{"symbol": "ETHUSDT", "score": 0.82, "signals": ["momentum", "volume_spike"]}
]
}
Fields:
symbol trading pair scanned
score opportunity score (0-1)
signals list of triggered signal names
""",
"upgrade": """\
Output: JSON
{
"command": "pip install --upgrade coinhunter",
"returncode": 0,
"stdout": "...",
"stderr": ""
}
Fields:
command shell command executed
returncode process exit code (0 = success)
stdout command standard output
stderr command standard error
""",
"completion": """\
Output: shell script text (not JSON)
# bash/zsh completion script for coinhunter
...
Fields:
(raw shell script suitable for sourcing)
""",
}
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"),
]
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)
class VersionAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
print(__version__)
raise SystemExit(0)
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,
)
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"
),
description="Binance-first trading CLI for account balances, market data, trade execution, and opportunity scanning.",
epilog=EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
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("-v", "--version", action="version", version=__version__)
parser.add_argument("-a", "--agent", action="store_true", help="Output in agent-friendly format (JSON or compact)")
parser.add_argument("--doc", action="store_true", help="Show output schema and field descriptions for the command")
subparsers = parser.add_subparsers(dest="command")
def _add_global_flags(p: argparse.ArgumentParser) -> None:
p.add_argument("-a", "--agent", action="store_true", help="Output in agent-friendly format (JSON or compact)")
p.add_argument("--doc", action="store_true", help="Show output schema and field descriptions for the command")
init_parser = subparsers.add_parser(
"init", help="Generate config.toml, .env, and log directory",
description="Initialize the CoinHunter runtime directory with config.toml, .env, and shell completions.",
)
parser.add_argument("args", nargs=argparse.REMAINDER)
init_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing files")
_add_global_flags(init_parser)
account_parser = subparsers.add_parser(
"account", aliases=["acc", "a"], help="List asset balances and notional values",
description="List all non-zero spot balances with free/locked totals, notional USDT value, and dust flag.",
)
_add_global_flags(account_parser)
market_parser = subparsers.add_parser(
"market", aliases=["m"], help="Batch market queries",
description="Query market data: 24h tickers and OHLCV klines for one or more trading pairs.",
)
market_subparsers = market_parser.add_subparsers(dest="market_command")
tickers_parser = market_subparsers.add_parser(
"tickers", aliases=["tk", "t"], help="Fetch 24h ticker data",
description="Fetch 24h ticker statistics (last price, change %, volume) for one or more symbols.",
)
tickers_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query (e.g. BTCUSDT ETH/USDT)")
_add_global_flags(tickers_parser)
klines_parser = market_subparsers.add_parser(
"klines", aliases=["k"], help="Fetch OHLCV klines",
description="Fetch OHLCV candlestick data for one or more symbols.",
)
klines_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query")
klines_parser.add_argument("-i", "--interval", default="1h", help="Kline interval (default: 1h)")
klines_parser.add_argument("-l", "--limit", type=int, default=100, help="Number of candles (default: 100)")
_add_global_flags(klines_parser)
buy_parser = subparsers.add_parser(
"buy", aliases=["b"], help="Buy base asset",
description="Place a spot BUY order. Market buys require --quote. Limit buys require --qty and --price.",
)
buy_parser.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
buy_parser.add_argument("-q", "--qty", type=float, help="Base asset quantity (limit orders)")
buy_parser.add_argument("-Q", "--quote", type=float, help="Quote asset amount (market buy only)")
buy_parser.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)")
buy_parser.add_argument("-p", "--price", type=float, help="Limit price")
buy_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
_add_global_flags(buy_parser)
sell_parser = subparsers.add_parser(
"sell", aliases=["s"], help="Sell base asset",
description="Place a spot SELL order. Requires --qty. Limit sells also require --price.",
)
sell_parser.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
sell_parser.add_argument("-q", "--qty", type=float, help="Base asset quantity")
sell_parser.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)")
sell_parser.add_argument("-p", "--price", type=float, help="Limit price")
sell_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
_add_global_flags(sell_parser)
opportunity_parser = subparsers.add_parser(
"opportunity", aliases=["opp", "o"], help="Portfolio analysis and market scanning",
description="Analyze your portfolio and scan the market for trading opportunities.",
)
opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command")
portfolio_parser = opportunity_subparsers.add_parser(
"portfolio", aliases=["pf", "p"], help="Score current holdings",
description="Score your current spot holdings and generate add/hold/trim/exit recommendations.",
)
_add_global_flags(portfolio_parser)
scan_parser = opportunity_subparsers.add_parser(
"scan", help="Scan market for opportunities",
description="Scan the market for trading opportunities and return the top-N candidates with signals.",
)
scan_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols")
_add_global_flags(scan_parser)
upgrade_parser = subparsers.add_parser(
"upgrade", help="Upgrade coinhunter to the latest version",
description="Upgrade the coinhunter package using pipx (preferred) or pip.",
)
_add_global_flags(upgrade_parser)
catlog_parser = subparsers.add_parser(
"catlog", help="Read recent audit log entries",
description="Read recent audit log entries across all days with optional limit and offset pagination.",
)
catlog_parser.add_argument("-n", "--limit", type=int, default=10, help="Number of entries (default: 10)")
catlog_parser.add_argument(
"-o", "--offset", type=int, default=0, help="Skip the most recent N entries (default: 0)"
)
_add_global_flags(catlog_parser)
completion_parser = subparsers.add_parser(
"completion", help="Generate shell completion script",
description="Generate a shell completion script for bash or zsh.",
)
completion_parser.add_argument("shell", choices=["bash", "zsh"], help="Target shell")
_add_global_flags(completion_parser)
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
_CANONICAL_COMMANDS = {
"b": "buy",
"s": "sell",
"acc": "account",
"a": "account",
"m": "market",
"opp": "opportunity",
"o": "opportunity",
}
_CANONICAL_SUBCOMMANDS = {
"tk": "tickers",
"t": "tickers",
"k": "klines",
"pf": "portfolio",
}
_COMMANDS_WITH_SUBCOMMANDS = {"market", "opportunity"}
def main() -> int:
parser = build_parser()
parsed = parser.parse_args()
if not parsed.command:
parser.print_help()
def _get_doc_key(argv: list[str]) -> str | None:
"""Infer command/subcommand from argv for --doc lookup."""
tokens = [a for a in argv if a != "--doc" and not a.startswith("-")]
if not tokens:
return None
cmd = _CANONICAL_COMMANDS.get(tokens[0], tokens[0])
if cmd in _COMMANDS_WITH_SUBCOMMANDS and len(tokens) > 1:
sub = _CANONICAL_SUBCOMMANDS.get(tokens[1], tokens[1])
return f"{cmd}/{sub}"
return cmd
def _reorder_flag(argv: list[str], flag: str, short_flag: str | None = None) -> list[str]:
"""Move a global flag from after subcommands to before them so argparse can parse it."""
flags = {flag}
if short_flag:
flags.add(short_flag)
subcommand_idx: int | None = None
for i, arg in enumerate(argv):
if not arg.startswith("-"):
subcommand_idx = i
break
if subcommand_idx is None:
return argv
new_argv: list[str] = []
present = False
for i, arg in enumerate(argv):
if i >= subcommand_idx and arg in flags:
present = True
continue
new_argv.append(arg)
if present:
new_argv.insert(subcommand_idx, flag)
return new_argv
def main(argv: list[str] | None = None) -> int:
raw_argv = argv if argv is not None else sys.argv[1:]
if "--doc" in raw_argv:
doc_key = _get_doc_key(raw_argv)
if doc_key is None:
print("Available docs: " + ", ".join(sorted(COMMAND_DOCS.keys())))
return 0
doc = COMMAND_DOCS.get(doc_key, f"No documentation available for {doc_key}.")
print(doc)
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}")
parser = build_parser()
raw_argv = _reorder_flag(raw_argv, "--agent", "-a")
args = parser.parse_args(raw_argv)
if __name__ == "__main__":
raise SystemExit(main())
# Normalize aliases to canonical command names
if args.command:
args.command = _CANONICAL_COMMANDS.get(args.command, args.command)
for attr in ("account_command", "market_command", "opportunity_command"):
val = getattr(args, attr, None)
if val:
setattr(args, attr, _CANONICAL_SUBCOMMANDS.get(val, val))
try:
if not args.command:
parser.print_help()
return 0
if args.command == "init":
init_result = ensure_init_files(get_runtime_paths(), force=args.force)
init_result["completion"] = install_shell_completion(parser)
print_output(init_result, agent=args.agent)
return 0
if args.command == "completion":
import shtab
print(shtab.complete(parser, shell=args.shell, preamble=""))
return 0
config = load_config()
if args.command == "account":
spot_client = _load_spot_client(config)
with with_spinner("Fetching balances...", enabled=not args.agent):
result = account_service.get_balances(config, spot_client=spot_client)
print_output(result, agent=args.agent)
return 0
if args.command == "market":
spot_client = _load_spot_client(config)
if args.market_command == "tickers":
with with_spinner("Fetching tickers...", enabled=not args.agent):
result = market_service.get_tickers(config, args.symbols, spot_client=spot_client)
print_output(result, agent=args.agent)
return 0
if args.market_command == "klines":
with with_spinner("Fetching klines...", enabled=not args.agent):
result = market_service.get_klines(
config,
args.symbols,
interval=args.interval,
limit=args.limit,
spot_client=spot_client,
)
print_output(result, agent=args.agent)
return 0
parser.error("market requires one of: tickers, klines")
if args.command == "buy":
spot_client = _load_spot_client(config)
with with_spinner("Placing order...", enabled=not args.agent):
result = trade_service.execute_spot_trade(
config,
side="buy",
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,
)
print_output(result, agent=args.agent)
return 0
if args.command == "sell":
spot_client = _load_spot_client(config)
with with_spinner("Placing order...", enabled=not args.agent):
result = trade_service.execute_spot_trade(
config,
side="sell",
symbol=args.symbol,
qty=args.qty,
quote=None,
order_type=args.type,
price=args.price,
dry_run=True if args.dry_run else None,
spot_client=spot_client,
)
print_output(result, agent=args.agent)
return 0
if args.command == "opportunity":
spot_client = _load_spot_client(config)
if args.opportunity_command == "portfolio":
with with_spinner("Analyzing portfolio...", enabled=not args.agent):
result = opportunity_service.analyze_portfolio(config, spot_client=spot_client)
print_output(result, agent=args.agent)
return 0
if args.opportunity_command == "scan":
with with_spinner("Scanning opportunities...", enabled=not args.agent):
result = opportunity_service.scan_opportunities(
config, spot_client=spot_client, symbols=args.symbols
)
print_output(result, agent=args.agent)
return 0
parser.error("opportunity requires `portfolio` or `scan`")
if args.command == "upgrade":
with with_spinner("Upgrading coinhunter...", enabled=not args.agent):
result = self_upgrade()
print_output(result, agent=args.agent)
return 0
if args.command == "catlog":
with with_spinner("Reading audit logs...", enabled=not args.agent):
entries = read_audit_log(limit=args.limit, offset=args.offset)
print_output(
{"entries": entries, "limit": args.limit, "offset": args.offset, "total": len(entries)},
agent=args.agent,
)
return 0
parser.error(f"Unsupported command {args.command}")
return 2
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

View File

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

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""Check whether the trading environment is ready and API permissions are sufficient."""
import json
import os
import ccxt
from ..runtime import load_env_file
def main():
load_env_file()
api_key = os.getenv("BINANCE_API_KEY", "")
secret = os.getenv("BINANCE_API_SECRET", "")
if not api_key or api_key.startswith("***") or api_key.startswith("your_"):
print(json.dumps({"ok": False, "error": "BINANCE_API_KEY not configured"}, ensure_ascii=False))
return 1
if not secret or secret.startswith("***") or secret.startswith("your_"):
print(json.dumps({"ok": False, "error": "BINANCE_API_SECRET not configured"}, ensure_ascii=False))
return 1
try:
ex = ccxt.binance({
"apiKey": api_key,
"secret": secret,
"options": {"defaultType": "spot"},
"enableRateLimit": True,
})
balance = ex.fetch_balance()
except Exception as e:
print(json.dumps({"ok": False, "error": f"Failed to connect or fetch balance: {e}"}, ensure_ascii=False))
return 1
read_permission = bool(balance and isinstance(balance, dict))
spot_trading_enabled = None
try:
restrictions = ex.sapi_get_account_api_restrictions()
spot_trading_enabled = restrictions.get("enableSpotAndMarginTrading") or restrictions.get("enableSpotTrading")
except Exception:
pass
report = {
"ok": read_permission,
"read_permission": read_permission,
"spot_trading_enabled": spot_trading_enabled,
"note": "spot_trading_enabled may be null if the key lacks permission to query restrictions; it does not necessarily mean trading is disabled.",
}
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if read_permission else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env python3
import fcntl
import json
import subprocess
import sys
from datetime import datetime, timezone
from ..runtime import ensure_runtime_dirs, get_runtime_paths
def _paths():
return get_runtime_paths()
COINHUNTER_MODULE = [sys.executable, "-m", "coinhunter"]
def utc_now():
return datetime.now(timezone.utc).isoformat()
def log(message: str):
print(f"[{utc_now()}] {message}", file=sys.stderr)
def run_cmd(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(args, capture_output=True, text=True)
def parse_json_output(text: str) -> dict:
text = (text or "").strip()
if not text:
return {}
return json.loads(text) # type: ignore[no-any-return]
def _load_config() -> dict:
config_path = _paths().config_file
if not config_path.exists():
return {}
try:
return json.loads(config_path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
except Exception:
return {}
def _resolve_trigger_command(paths) -> list[str] | None:
config = _load_config()
gate_config = config.get("external_gate", {})
if "trigger_command" not in gate_config:
return None
trigger = gate_config["trigger_command"]
if trigger is None:
return None
if isinstance(trigger, str):
return [trigger]
if isinstance(trigger, list):
if not trigger:
return None
return [str(item) for item in trigger]
log(f"warn: unexpected trigger_command type {type(trigger).__name__}; skipping trigger")
return None
def main():
paths = _paths()
ensure_runtime_dirs(paths)
result = {"ok": False, "triggered": False, "reason": "", "logs": []}
lock_file = paths.external_gate_lock
def append_log(msg: str):
log(msg)
result["logs"].append(msg)
with open(lock_file, "w", encoding="utf-8") as lockf:
try:
fcntl.flock(lockf.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
append_log("gate already running; skip")
result["reason"] = "already_running"
print(json.dumps(result, ensure_ascii=False))
return 0
precheck = run_cmd(COINHUNTER_MODULE + ["precheck"])
if precheck.returncode != 0:
append_log(f"precheck returned non-zero ({precheck.returncode}); stdout={precheck.stdout.strip()} stderr={precheck.stderr.strip()}")
result["reason"] = "precheck_failed"
print(json.dumps(result, ensure_ascii=False))
return 1
try:
data = parse_json_output(precheck.stdout)
except Exception as e:
append_log(f"failed to parse precheck JSON: {e}; raw={precheck.stdout.strip()[:1000]}")
result["reason"] = "precheck_parse_error"
print(json.dumps(result, ensure_ascii=False))
return 1
if not data.get("ok"):
append_log("precheck reported failure; skip model run")
result["reason"] = "precheck_not_ok"
print(json.dumps(result, ensure_ascii=False))
return 1
if not data.get("should_analyze"):
append_log("no trigger; skip model run")
result["ok"] = True
result["reason"] = "no_trigger"
print(json.dumps(result, ensure_ascii=False))
return 0
if data.get("run_requested"):
append_log(f"trigger already queued at {data.get('run_requested_at')}; skip duplicate")
result["ok"] = True
result["reason"] = "already_queued"
print(json.dumps(result, ensure_ascii=False))
return 0
mark = run_cmd(COINHUNTER_MODULE + ["precheck", "--mark-run-requested", "external-gate queued cron run"])
if mark.returncode != 0:
append_log(f"failed to mark run requested; stdout={mark.stdout.strip()} stderr={mark.stderr.strip()}")
result["reason"] = "mark_failed"
print(json.dumps(result, ensure_ascii=False))
return 1
trigger_cmd = _resolve_trigger_command(paths)
if trigger_cmd is None:
append_log("trigger_command is disabled; skipping external trigger")
result["ok"] = True
result["reason"] = "trigger_disabled"
print(json.dumps(result, ensure_ascii=False))
return 0
trigger = run_cmd(trigger_cmd)
if trigger.returncode != 0:
append_log(f"failed to trigger trade job; cmd={' '.join(trigger_cmd)}; stdout={trigger.stdout.strip()} stderr={trigger.stderr.strip()}")
result["reason"] = "trigger_failed"
print(json.dumps(result, ensure_ascii=False))
return 1
reasons = ", ".join(data.get("reasons", [])) or "unknown"
append_log(f"queued trade job via {' '.join(trigger_cmd)}; reasons={reasons}")
if trigger.stdout.strip():
append_log(trigger.stdout.strip())
result["ok"] = True
result["triggered"] = True
result["reason"] = reasons
result["command"] = trigger_cmd
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,95 +0,0 @@
#!/usr/bin/env python3
import json
from datetime import datetime, timezone
from pathlib import Path
from ..runtime import ensure_runtime_dirs, get_runtime_paths
def _paths():
return get_runtime_paths()
def now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def ensure_file(path: Path, payload: dict):
if path.exists():
return False
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return True
def main():
paths = _paths()
ensure_runtime_dirs(paths)
created = []
ts = now_iso()
templates = {
paths.root / "config.json": {
"default_exchange": "bybit",
"default_quote_currency": "USDT",
"timezone": "Asia/Shanghai",
"preferred_chains": ["solana", "base"],
"external_gate": {
"trigger_command": None,
"_comment": "Set to a command list like ['hermes', 'cron', 'run', 'JOB_ID'] or null to disable"
},
"trading": {
"usdt_buffer_pct": 0.03,
"min_remaining_dust_usdt": 1.0,
"_comment": "Adjust buffer and dust thresholds for your account size"
},
"precheck": {
"base_price_move_trigger_pct": 0.025,
"base_pnl_trigger_pct": 0.03,
"base_portfolio_move_trigger_pct": 0.03,
"base_candidate_score_trigger_ratio": 1.15,
"base_force_analysis_after_minutes": 180,
"base_cooldown_minutes": 45,
"top_candidates": 10,
"min_actionable_usdt": 12.0,
"min_real_position_value_usdt": 8.0,
"blacklist": ["USDC", "BUSD", "TUSD", "FDUSD", "USTC", "PAXG"],
"hard_stop_pct": -0.08,
"hard_moon_pct": 0.25,
"min_change_pct": 1.0,
"max_price_cap": None,
"hard_reason_dedup_minutes": 15,
"max_pending_trigger_minutes": 30,
"max_run_request_minutes": 20,
"_comment": "Tune trigger sensitivity without redeploying code"
},
"created_at": ts,
"updated_at": ts,
},
paths.root / "accounts.json": {
"accounts": []
},
paths.root / "positions.json": {
"positions": []
},
paths.root / "watchlist.json": {
"watchlist": []
},
paths.root / "notes.json": {
"notes": []
},
}
for path, payload in templates.items():
if ensure_file(path, payload):
created.append(str(path))
print(json.dumps({
"root": str(paths.root),
"created": created,
"cache_dir": str(paths.cache_dir),
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env python3
"""CLI adapter for review context."""
import json
import sys
from ..services.review_service import generate_review
def main():
hours = int(sys.argv[1]) if len(sys.argv) > 1 else 12
review = generate_review(hours)
compact = {
"review_period_hours": review.get("review_period_hours", hours),
"review_timestamp": review.get("review_timestamp"),
"total_decisions": review.get("total_decisions", 0),
"total_trades": review.get("total_trades", 0),
"total_errors": review.get("total_errors", 0),
"stats": review.get("stats", {}),
"insights": review.get("insights", []),
"recommendations": review.get("recommendations", []),
"decision_quality_top": review.get("decision_quality", [])[:5],
"should_report": bool(
review.get("total_decisions", 0)
or review.get("total_trades", 0)
or review.get("total_errors", 0)
or review.get("insights")
),
}
print(json.dumps(compact, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env python3
"""CLI adapter for review engine."""
import json
import sys
from ..services.review_service import generate_review, save_review
def main():
try:
hours = int(sys.argv[1]) if len(sys.argv) > 1 else 1
review = generate_review(hours)
path = save_review(review)
print(json.dumps({"ok": True, "saved_path": path, "review": review}, ensure_ascii=False, indent=2))
except Exception as e:
from ..logger import log_error
log_error("review_engine", e)
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False), file=sys.stderr)
raise SystemExit(1)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""Rotate external gate log using the user's logrotate config/state."""
import json
import shutil
import subprocess
from ..runtime import ensure_runtime_dirs, get_runtime_paths
def _paths():
return get_runtime_paths()
def main():
paths = _paths()
ensure_runtime_dirs(paths)
logrotate_bin = shutil.which("logrotate") or "/usr/sbin/logrotate"
cmd = [logrotate_bin, "-s", str(paths.logrotate_status), str(paths.logrotate_config)]
result = subprocess.run(cmd, capture_output=True, text=True)
output = {
"ok": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0 if result.returncode == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,96 +0,0 @@
#!/usr/bin/env python3
"""Backward-compatible facade for the precheck workflow.
The reusable implementation now lives under ``coinhunter.services``.
Keep this module importable and executable so older entrypoints continue to work.
"""
from __future__ import annotations
import sys
from importlib import import_module
from .services.precheck_service import run as _run_service
_CORE_EXPORTS = {
"BASE_PRICE_MOVE_TRIGGER_PCT",
"BASE_PNL_TRIGGER_PCT",
"BASE_PORTFOLIO_MOVE_TRIGGER_PCT",
"BASE_CANDIDATE_SCORE_TRIGGER_RATIO",
"BASE_FORCE_ANALYSIS_AFTER_MINUTES",
"BASE_COOLDOWN_MINUTES",
"TOP_CANDIDATES",
"MIN_ACTIONABLE_USDT",
"MIN_REAL_POSITION_VALUE_USDT",
"BLACKLIST",
"HARD_STOP_PCT",
"HARD_MOON_PCT",
"MIN_CHANGE_PCT",
"MAX_PRICE_CAP",
"HARD_REASON_DEDUP_MINUTES",
"MAX_PENDING_TRIGGER_MINUTES",
"MAX_RUN_REQUEST_MINUTES",
"utc_now",
"utc_iso",
"parse_ts",
"load_json",
"load_env",
"load_positions",
"load_state",
"load_config",
"clear_run_request_fields",
"sanitize_state_for_stale_triggers",
"save_state",
"stable_hash",
"get_exchange",
"fetch_ohlcv_batch",
"compute_ohlcv_metrics",
"enrich_candidates_and_positions",
"regime_from_pct",
"to_float",
"norm_symbol",
"get_local_now",
"session_label",
"top_candidates_from_tickers",
"build_snapshot",
"build_adaptive_profile",
"analyze_trigger",
"update_state_after_observation",
}
# Path-related exports are now lazy in precheck_core
_PATH_EXPORTS = {
"PATHS",
"BASE_DIR",
"STATE_DIR",
"STATE_FILE",
"POSITIONS_FILE",
"CONFIG_FILE",
"ENV_FILE",
}
_STATE_EXPORTS = {"mark_run_requested", "ack_analysis"}
__all__ = sorted(_CORE_EXPORTS | _PATH_EXPORTS | _STATE_EXPORTS | {"main"})
def __getattr__(name: str):
if name in _CORE_EXPORTS:
return getattr(import_module(".services.precheck_core", __package__), name)
if name in _PATH_EXPORTS:
return getattr(import_module(".services.precheck_core", __package__), name)
if name in _STATE_EXPORTS:
return getattr(import_module(".services.precheck_state", __package__), name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return sorted(set(globals()) | set(__all__))
def main():
return _run_service(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env python3
"""Backward-compatible facade for review engine.
The executable implementation lives in ``coinhunter.commands.review_engine``.
Core logic is in ``coinhunter.services.review_service``.
"""
from __future__ import annotations
from importlib import import_module
# Re-export service functions for backward compatibility
_EXPORT_MAP = {
"load_env": (".services.review_service", "load_env"),
"get_exchange": (".services.review_service", "get_exchange"),
"ensure_review_dir": (".services.review_service", "ensure_review_dir"),
"norm_symbol": (".services.review_service", "norm_symbol"),
"fetch_current_price": (".services.review_service", "fetch_current_price"),
"analyze_trade": (".services.review_service", "analyze_trade"),
"analyze_hold_passes": (".services.review_service", "analyze_hold_passes"),
"analyze_cash_misses": (".services.review_service", "analyze_cash_misses"),
"generate_review": (".services.review_service", "generate_review"),
"save_review": (".services.review_service", "save_review"),
"print_review": (".services.review_service", "print_review"),
}
__all__ = sorted(set(_EXPORT_MAP) | {"main"})
def __getattr__(name: str):
if name not in _EXPORT_MAP:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr_name = _EXPORT_MAP[name]
module = import_module(module_name, __package__)
return getattr(module, attr_name)
def __dir__():
return sorted(set(globals()) | set(__all__))
def main():
from .commands.review_engine import main as _main
return _main()
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

@@ -1,127 +1,551 @@
"""Runtime paths and environment helpers for CoinHunter CLI."""
"""Runtime helpers for CoinHunter V2."""
from __future__ import annotations
import argparse
import csv
import io
import json
import os
import re
import shutil
from dataclasses import asdict, dataclass
import subprocess
import sys
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict, dataclass, is_dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any
try:
import shtab
except Exception: # pragma: no cover
shtab = None # type: ignore[assignment]
@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 print_json(payload: Any) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=json_default))
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 self_upgrade() -> dict[str, Any]:
if shutil.which("pipx"):
cmd = ["pipx", "upgrade", "coinhunter"]
else:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "coinhunter"]
result = subprocess.run(cmd, capture_output=True, text=True)
return {
"command": " ".join(cmd),
"returncode": result.returncode,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
}
def get_user_config(key: str, default=None):
"""Read a dotted key from the user config file."""
paths = get_runtime_paths()
# ---------------------------------------------------------------------------
# TUI / Agent output helpers
# ---------------------------------------------------------------------------
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
_BOLD = "\033[1m"
_RESET = "\033[0m"
_CYAN = "\033[36m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_RED = "\033[31m"
_DIM = "\033[2m"
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def _color(text: str, color: str) -> str:
return f"{color}{text}{_RESET}"
def _cell_width(text: str) -> int:
return len(_strip_ansi(text))
def _pad(text: str, width: int, align: str = "left") -> str:
pad = width - _cell_width(text)
if align == "right":
return " " * pad + text
return text + " " * pad
def _fmt_number(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
s = f"{value:,.4f}"
s = s.rstrip("0").rstrip(".")
return s
return str(value)
def _fmt_local_ts(ts: str) -> str:
try:
config = json.loads(paths.config_file.read_text(encoding="utf-8"))
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return default
for part in key.split("."):
if isinstance(config, dict):
config = config.get(part)
if config is None:
return default
return ts
def _event_color(event: str) -> str:
if "failed" in event or "error" in event:
return f"{_DIM}{_RED}"
if event.startswith("trade"):
return f"{_DIM}{_GREEN}"
if event.startswith("opportunity"):
return f"{_DIM}{_YELLOW}"
return _DIM
def _is_large_dataset(payload: Any, threshold: int = 8) -> bool:
if isinstance(payload, dict):
for value in payload.values():
if isinstance(value, list) and len(value) > threshold:
return True
return False
def _print_compact(payload: dict[str, Any]) -> None:
target_key = None
target_rows: list[Any] = []
for key, value in payload.items():
if isinstance(value, list) and len(value) > len(target_rows):
target_key = key
target_rows = value
if target_rows and isinstance(target_rows[0], dict):
headers = list(target_rows[0].keys())
output = io.StringIO()
writer = csv.writer(output, delimiter="|", lineterminator="\n")
writer.writerow(headers)
for row in target_rows:
writer.writerow([str(row.get(h, "")) for h in headers])
print(f"mode=compact|source={target_key}")
print(output.getvalue().strip())
else:
for key, value in payload.items():
print(f"{key}={value}")
def _h_line(widths: list[int], left: str, mid: str, right: str) -> str:
parts = ["" * (w + 2) for w in widths]
return left + mid.join(parts) + right
def _print_box_table(
title: str,
headers: list[str],
rows: list[list[str]],
aligns: list[str] | None = None,
) -> None:
if not rows:
print(f"{_BOLD}{_CYAN}{title}{_RESET}")
print(" (empty)")
return
aligns = aligns or ["left"] * len(headers)
col_widths = [_cell_width(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
col_widths[i] = max(col_widths[i], _cell_width(cell))
if title:
print(f"{_BOLD}{_CYAN}{title}{_RESET}")
print(_h_line(col_widths, "", "", ""))
header_cells = [_pad(headers[i], col_widths[i], aligns[i]) for i in range(len(headers))]
print("" + "".join(header_cells) + "")
print(_h_line(col_widths, "", "", ""))
for row in rows:
cells = [_pad(row[i], col_widths[i], aligns[i]) for i in range(len(row))]
print("" + "".join(cells) + "")
print(_h_line(col_widths, "", "", ""))
def _render_tui(payload: Any) -> None:
if not isinstance(payload, dict):
print(str(payload))
return
if "balances" in payload:
rows = payload["balances"]
table_rows: list[list[str]] = []
for r in rows:
is_dust = r.get("is_dust", False)
dust_label = f"{_DIM}dust{_RESET}" if is_dust else ""
table_rows.append(
[
r.get("asset", ""),
_fmt_number(r.get("free", 0)),
_fmt_number(r.get("locked", 0)),
_fmt_number(r.get("total", 0)),
_fmt_number(r.get("notional_usdt", 0)),
dust_label,
]
)
_print_box_table(
"BALANCES",
["Asset", "Free", "Locked", "Total", "Notional (USDT)", ""],
table_rows,
aligns=["left", "right", "right", "right", "right", "left"],
)
return
if "positions" in payload:
rows = payload["positions"]
table_rows = []
for r in rows:
entry = _fmt_number(r.get("entry_price")) if r.get("entry_price") is not None else ""
pnl = _fmt_number(r.get("unrealized_pnl")) if r.get("unrealized_pnl") is not None else ""
table_rows.append(
[
r.get("market_type", ""),
r.get("symbol", ""),
r.get("side", ""),
_fmt_number(r.get("quantity", 0)),
entry,
_fmt_number(r.get("mark_price", 0)),
_fmt_number(r.get("notional_usdt", 0)),
pnl,
]
)
_print_box_table(
"POSITIONS",
["Market", "Symbol", "Side", "Qty", "Entry", "Mark", "Notional", "PnL"],
table_rows,
aligns=["left", "left", "left", "right", "right", "right", "right", "right"],
)
return
if "tickers" in payload:
rows = payload["tickers"]
table_rows = []
for r in rows:
pct = r.get("price_change_pct", 0)
pct_str = _color(f"{pct:+.2f}%", _GREEN if pct >= 0 else _RED)
table_rows.append(
[
r.get("symbol", ""),
_fmt_number(r.get("last_price", 0)),
pct_str,
_fmt_number(r.get("quote_volume", 0)),
]
)
_print_box_table(
"24H TICKERS",
["Symbol", "Last Price", "Change %", "Quote Volume"],
table_rows,
aligns=["left", "right", "right", "right"],
)
return
if "klines" in payload:
rows = payload["klines"]
print(
f"\n{_BOLD}{_CYAN} KLINES {_RESET} interval={payload.get('interval')} limit={payload.get('limit')} count={len(rows)}"
)
display_rows = rows[:10]
table_rows = []
for r in display_rows:
table_rows.append(
[
r.get("symbol", ""),
str(r.get("open_time", ""))[:10],
_fmt_number(r.get("open", 0)),
_fmt_number(r.get("high", 0)),
_fmt_number(r.get("low", 0)),
_fmt_number(r.get("close", 0)),
_fmt_number(r.get("volume", 0)),
]
)
_print_box_table(
"",
["Symbol", "Time", "Open", "High", "Low", "Close", "Vol"],
table_rows,
aligns=["left", "left", "right", "right", "right", "right", "right"],
)
if len(rows) > 10:
print(f" {_DIM}... and {len(rows) - 10} more rows{_RESET}")
return
if "trade" in payload:
t = payload["trade"]
status = t.get("status", "UNKNOWN")
status_color = _GREEN if status == "FILLED" else _YELLOW if status == "DRY_RUN" else _CYAN
print(f"\n{_BOLD}{_CYAN} TRADE RESULT {_RESET}")
print(f" Market: {t.get('market_type', '').upper()}")
print(f" Symbol: {t.get('symbol', '')}")
print(f" Side: {t.get('side', '')}")
print(f" Type: {t.get('order_type', '')}")
print(f" Status: {_color(status, status_color)}")
print(f" Dry Run: {_fmt_number(t.get('dry_run', False))}")
return
if "recommendations" in payload:
rows = payload["recommendations"]
print(f"\n{_BOLD}{_CYAN} RECOMMENDATIONS {_RESET} count={len(rows)}")
for i, r in enumerate(rows, 1):
score = r.get("score", 0)
action = r.get("action", "")
action_color = (
_GREEN if action == "add" else _YELLOW if action == "hold" else _RED if action == "exit" else _CYAN
)
print(
f" {i}. {_BOLD}{r.get('symbol', '')}{_RESET} action={_color(action, action_color)} score={score:.4f}"
)
for reason in r.get("reasons", []):
print(f" · {reason}")
metrics = r.get("metrics", {})
if metrics:
metric_str = " ".join(f"{k}={v}" for k, v in metrics.items())
print(f" {_DIM}{metric_str}{_RESET}")
return
if "command" in payload and "returncode" in payload:
rc = payload.get("returncode", 0)
stdout = payload.get("stdout", "")
stderr = payload.get("stderr", "")
if rc == 0:
print(f"{_GREEN}{_RESET} Update completed")
else:
return default
return config if config is not None else default
print(f"{_RED}{_RESET} Update failed (exit code {rc})")
if stdout:
for line in stdout.strip().splitlines():
print(f" {line}")
if rc != 0 and stderr:
print(f" {_YELLOW}Details:{_RESET}")
for line in stderr.strip().splitlines():
print(f" {line}")
return
if "entries" in payload:
rows = payload["entries"]
print(f"\n{_BOLD}{_CYAN} AUDIT LOG {_RESET}")
if not rows:
print(" (no audit entries)")
return
for r in rows:
ts = _fmt_local_ts(r.get("timestamp", ""))
event = r.get("event", "")
detail_parts: list[str] = []
for key in ("symbol", "side", "qty", "quote_amount", "order_type", "status", "dry_run", "error"):
val = r.get(key)
if val is not None:
detail_parts.append(f"{key}={val}")
if not detail_parts:
for key, val in r.items():
if key not in ("timestamp", "event") and not isinstance(val, (dict, list)):
detail_parts.append(f"{key}={val}")
print(f"\n {_DIM}{ts}{_RESET} {_event_color(event)}{event}{_RESET}")
if detail_parts:
print(f" {' '.join(detail_parts)}")
return
if "created_or_updated" in payload:
print(f"\n{_BOLD}{_CYAN} INITIALIZED {_RESET}")
print(f" Root: {payload.get('root', '')}")
print(f" Config: {payload.get('config_file', '')}")
print(f" Env: {payload.get('env_file', '')}")
print(f" Logs: {payload.get('logs_dir', '')}")
files = payload.get("created_or_updated", [])
if files:
action = "overwritten" if payload.get("force") else "created"
print(f" Files {action}: {', '.join(files)}")
comp = payload.get("completion", {})
if comp.get("installed"):
print(f"\n {_GREEN}{_RESET} Shell completions installed for {comp.get('shell', '')}")
print(f" Path: {comp.get('path', '')}")
if comp.get("hint"):
print(f" Hint: {comp.get('hint', '')}")
elif comp.get("reason"):
print(f"\n Shell completions: {comp.get('reason', '')}")
return
# Generic fallback for single-list payloads
if len(payload) == 1:
key, value = next(iter(payload.items()))
if isinstance(value, list) and value and isinstance(value[0], dict):
_render_tui({key: value})
return
# Simple key-value fallback
for key, value in payload.items():
if isinstance(value, str) and "\n" in value:
print(f" {key}:")
for line in value.strip().splitlines():
print(f" {line}")
else:
print(f" {key}: {value}")
def print_output(payload: Any, *, agent: bool = False) -> None:
if agent:
if _is_large_dataset(payload):
_print_compact(payload)
else:
print_json(payload)
else:
_render_tui(payload)
# ---------------------------------------------------------------------------
# Spinner / loading animation
# ---------------------------------------------------------------------------
_SPINNER_FRAMES = ["", "", "", "", "", "", "", "", "", ""]
class _SpinnerThread(threading.Thread):
def __init__(self, message: str, interval: float = 0.08) -> None:
super().__init__(daemon=True)
self.message = message
self.interval = interval
self._stop_event = threading.Event()
def run(self) -> None:
i = 0
while not self._stop_event.is_set():
frame = _SPINNER_FRAMES[i % len(_SPINNER_FRAMES)]
sys.stdout.write(f"\r{_CYAN}{frame}{_RESET} {self.message} ")
sys.stdout.flush()
self._stop_event.wait(self.interval)
i += 1
def stop(self) -> None:
self._stop_event.set()
self.join()
sys.stdout.write("\r\033[K")
sys.stdout.flush()
@contextmanager
def with_spinner(message: str, *, enabled: bool = True) -> Iterator[None]:
if not enabled or not sys.stdout.isatty():
yield
return
spinner = _SpinnerThread(message)
spinner.start()
try:
yield
finally:
spinner.stop()
def _detect_shell() -> str:
shell = os.getenv("SHELL", "")
if "zsh" in shell:
return "zsh"
if "bash" in shell:
return "bash"
return ""
def _zshrc_path() -> Path:
return Path.home() / ".zshrc"
def _bashrc_path() -> Path:
return Path.home() / ".bashrc"
def _rc_contains(rc_path: Path, snippet: str) -> bool:
if not rc_path.exists():
return False
return snippet in rc_path.read_text(encoding="utf-8")
def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]:
if shtab is None:
return {"shell": None, "installed": False, "reason": "shtab is not installed"}
shell = _detect_shell()
if not shell:
return {"shell": None, "installed": False, "reason": "unable to detect shell from $SHELL"}
script = shtab.complete(parser, shell=shell, preamble="")
installed_path: Path | None = None
hint: str | None = None
if shell == "zsh":
comp_dir = Path.home() / ".zsh" / "completions"
comp_dir.mkdir(parents=True, exist_ok=True)
installed_path = comp_dir / "_coinhunter"
installed_path.write_text(script, encoding="utf-8")
rc_path = _zshrc_path()
fpath_line = "fpath+=(~/.zsh/completions)"
if not _rc_contains(rc_path, fpath_line):
rc_path.write_text(
fpath_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else fpath_line + "\n",
encoding="utf-8",
)
hint = "Added fpath+=(~/.zsh/completions) to ~/.zshrc; restart your terminal or run 'compinit'"
else:
hint = "Run 'compinit' or restart your terminal to activate completions"
elif shell == "bash":
comp_dir = Path.home() / ".local" / "share" / "bash-completion" / "completions"
comp_dir.mkdir(parents=True, exist_ok=True)
installed_path = comp_dir / "coinhunter"
installed_path.write_text(script, encoding="utf-8")
rc_path = _bashrc_path()
source_line = '[[ -r "~/.local/share/bash-completion/completions/coinhunter" ]] && . "~/.local/share/bash-completion/completions/coinhunter"'
if not _rc_contains(rc_path, source_line):
rc_path.write_text(
source_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else source_line + "\n",
encoding="utf-8",
)
hint = "Added bash completion source line to ~/.bashrc; restart your terminal"
else:
hint = "Restart your terminal or source ~/.bashrc to activate completions"
return {
"shell": shell,
"installed": True,
"path": str(installed_path) if installed_path else None,
"hint": hint,
}

View File

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

View File

@@ -0,0 +1,119 @@
"""Account and position services."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
@dataclass
class AssetBalance:
asset: str
free: float
locked: float
total: float
notional_usdt: float
is_dust: bool
@dataclass
class PositionView:
symbol: str
quantity: float
entry_price: float | None
mark_price: float
notional_usdt: float
side: str
@dataclass
class AccountOverview:
total_equity_usdt: float
spot_equity_usdt: float
spot_asset_count: int
spot_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],
*,
spot_client: Any,
) -> 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]] = []
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"]
notional = total * price_map.get(asset, 0.0)
rows.append(
asdict(
AssetBalance(
asset=asset,
free=free,
locked=locked,
total=total,
notional_usdt=notional,
is_dust=notional < dust,
)
)
)
return {"balances": rows}
def get_positions(
config: dict[str, Any],
*,
spot_client: Any,
) -> 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]] = []
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"]
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(
symbol=quote if asset == quote else f"{asset}{quote}",
quantity=quantity,
entry_price=None,
mark_price=mark_price,
notional_usdt=notional,
side="LONG",
)
)
)
return {"positions": rows}

View File

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

View File

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

View File

@@ -1,39 +0,0 @@
"""Generic data helpers for precheck."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
def load_json(path: Path, default):
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def stable_hash(data) -> str:
payload = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha1(payload.encode("utf-8")).hexdigest()
def to_float(value, default=0.0):
try:
if value is None:
return default
return float(value)
except Exception:
return default
def norm_symbol(symbol: str) -> str:
s = symbol.upper().replace("-", "").replace("_", "")
if "/" in s:
return s
if s.endswith("USDT"):
return s[:-4] + "/USDT"
return s

View File

@@ -1,150 +0,0 @@
"""Exchange helpers (ccxt, markets, balances, order prep)."""
import math
import os
import time
__all__ = [
"load_env",
"get_exchange",
"norm_symbol",
"storage_symbol",
"fetch_balances",
"build_market_snapshot",
"market_and_ticker",
"floor_to_step",
"prepare_buy_quantity",
"prepare_sell_quantity",
]
import ccxt
from ..runtime import get_runtime_paths, get_user_config, load_env_file
_exchange_cache = None
_exchange_cached_at = None
CACHE_TTL_SECONDS = 3600
def load_env():
load_env_file(get_runtime_paths())
def get_exchange(force_new: bool = False):
global _exchange_cache, _exchange_cached_at
now = time.time()
if not force_new and _exchange_cache is not None and _exchange_cached_at is not None:
ttl = get_user_config("exchange.cache_ttl_seconds", CACHE_TTL_SECONDS)
if now - _exchange_cached_at < ttl:
return _exchange_cache
load_env()
api_key = os.getenv("BINANCE_API_KEY")
secret = os.getenv("BINANCE_API_SECRET")
if not api_key or not secret:
raise RuntimeError("Missing BINANCE_API_KEY or BINANCE_API_SECRET")
ex = ccxt.binance(
{
"apiKey": api_key,
"secret": secret,
"options": {"defaultType": "spot", "createMarketBuyOrderRequiresPrice": False},
"enableRateLimit": True,
}
)
ex.load_markets()
_exchange_cache = ex
_exchange_cached_at = now
return ex
def norm_symbol(symbol: str) -> str:
s = symbol.upper().replace("-", "").replace("_", "")
if "/" in s:
return s
if s.endswith("USDT"):
return s[:-4] + "/USDT"
raise ValueError(f"Unsupported symbol: {symbol}")
def storage_symbol(symbol: str) -> str:
return norm_symbol(symbol).replace("/", "")
def fetch_balances(ex):
bal = ex.fetch_balance()["free"]
return {k: float(v) for k, v in bal.items() if float(v) > 0}
def build_market_snapshot(ex):
try:
tickers = ex.fetch_tickers()
except Exception:
return {}
snapshot = {}
for sym, t in tickers.items():
if not sym.endswith("/USDT"):
continue
price = t.get("last")
if price is None or float(price) <= 0:
continue
vol = float(t.get("quoteVolume") or 0)
min_volume = get_user_config("exchange.min_quote_volume", 200_000)
if vol < min_volume:
continue
base = sym.replace("/", "")
snapshot[base] = {
"lastPrice": round(float(price), 8),
"price24hPcnt": round(float(t.get("percentage") or 0), 4),
"highPrice24h": round(float(t.get("high") or 0), 8) if t.get("high") else None,
"lowPrice24h": round(float(t.get("low") or 0), 8) if t.get("low") else None,
"turnover24h": round(float(vol), 2),
}
return snapshot
def market_and_ticker(ex, symbol: str):
sym = norm_symbol(symbol)
market = ex.market(sym)
ticker = ex.fetch_ticker(sym)
return sym, market, ticker
def floor_to_step(value: float, step: float) -> float:
if not step or step <= 0:
return value
return math.floor(value / step) * step
def prepare_buy_quantity(ex, symbol: str, amount_usdt: float):
from .trade_common import USDT_BUFFER_PCT
sym, market, ticker = market_and_ticker(ex, symbol)
ask = float(ticker.get("ask") or ticker.get("last") or 0)
if ask <= 0:
raise RuntimeError(f"No valid ask price for {sym}")
budget = amount_usdt * (1 - USDT_BUFFER_PCT)
raw_qty = budget / ask
qty = float(ex.amount_to_precision(sym, raw_qty))
min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0
min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0
if min_amt and qty < float(min_amt):
raise RuntimeError(f"Buy quantity {qty} for {sym} below minimum {min_amt}")
est_cost = qty * ask
if min_cost and est_cost < float(min_cost):
raise RuntimeError(f"Buy cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}")
return sym, qty, ask, est_cost
def prepare_sell_quantity(ex, symbol: str, free_qty: float):
sym, market, ticker = market_and_ticker(ex, symbol)
bid = float(ticker.get("bid") or ticker.get("last") or 0)
if bid <= 0:
raise RuntimeError(f"No valid bid price for {sym}")
qty = float(ex.amount_to_precision(sym, free_qty))
min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") or 0
min_cost = (market.get("limits", {}).get("cost", {}) or {}).get("min") or 0
if min_amt and qty < float(min_amt):
raise RuntimeError(f"Sell quantity {qty} for {sym} below minimum {min_amt}")
est_cost = qty * bid
if min_cost and est_cost < float(min_cost):
raise RuntimeError(f"Sell cost ${est_cost:.4f} for {sym} below minimum ${float(min_cost):.4f}")
return sym, qty, bid, est_cost

View File

@@ -1,54 +0,0 @@
"""Execution state helpers (decision deduplication, executions.json)."""
import hashlib
__all__ = [
"default_decision_id",
"load_executions",
"save_executions",
"record_execution_state",
"get_execution_state",
]
from ..runtime import get_runtime_paths
from .file_utils import load_json_locked, read_modify_write_json, save_json_locked
def _paths():
return get_runtime_paths()
def default_decision_id(action: str, argv_tail: list[str]) -> str:
from datetime import datetime
from .trade_common import CST
now = datetime.now(CST)
bucket_min = (now.minute // 15) * 15
bucket = now.strftime(f"%Y%m%dT%H{bucket_min:02d}")
raw = f"{bucket}|{action}|{'|'.join(argv_tail)}"
return hashlib.sha1(raw.encode()).hexdigest()[:16]
def load_executions() -> dict:
paths = _paths()
data = load_json_locked(paths.executions_file, paths.executions_lock, {"executions": {}})
return data.get("executions", {}) # type: ignore[no-any-return]
def save_executions(executions: dict):
paths = _paths()
save_json_locked(paths.executions_file, paths.executions_lock, {"executions": executions})
def record_execution_state(decision_id: str, payload: dict):
paths = _paths()
read_modify_write_json(
paths.executions_file,
paths.executions_lock,
{"executions": {}},
lambda data: data.setdefault("executions", {}).__setitem__(decision_id, payload) or data,
)
def get_execution_state(decision_id: str):
return load_executions().get(decision_id)

View File

@@ -1,76 +0,0 @@
"""File locking and atomic JSON helpers."""
import fcntl
import json
import os
__all__ = [
"locked_file",
"atomic_write_json",
"load_json_locked",
"save_json_locked",
"read_modify_write_json",
]
from contextlib import contextmanager
from pathlib import Path
@contextmanager
def locked_file(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
fd = None
try:
fd = os.open(path, os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
yield fd
finally:
if fd is not None:
try:
os.fsync(fd)
except Exception:
pass
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except Exception:
pass
os.close(fd)
def atomic_write_json(path: Path, data: dict):
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, path)
def load_json_locked(path: Path, lock_path: Path, default):
with locked_file(lock_path):
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def save_json_locked(path: Path, lock_path: Path, data: dict):
with locked_file(lock_path):
atomic_write_json(path, data)
def read_modify_write_json(path: Path, lock_path: Path, default, modifier):
"""Atomic read-modify-write under a single file lock.
Loads JSON from *path* (or uses *default* if missing/invalid),
calls ``modifier(data)``, then atomically writes the result back.
If *modifier* returns None, the mutated *data* is written.
"""
with locked_file(lock_path):
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
data = default
else:
data = default
result = modifier(data)
atomic_write_json(path, result if result is not None else data)

View File

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

View File

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

View File

@@ -0,0 +1,206 @@
"""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, spot_client=spot_client)["positions"]
positions = [item for item in positions if 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, spot_client=spot_client)["positions"]
concentration_map = {normalize_symbol(item["symbol"]): float(item["notional_usdt"]) for item in held_positions}
total_held = sum(concentration_map.values()) or 1.0
universe = get_scan_universe(config, spot_client=spot_client, symbols=symbols)[:scan_limit]
recommendations = []
for ticker in universe:
symbol = normalize_symbol(ticker["symbol"])
klines = spot_client.klines(symbol=symbol, interval="1h", limit=24)
closes = [float(item[4]) for item in klines]
volumes = [float(item[5]) for item in klines]
concentration = concentration_map.get(symbol, 0.0) / total_held
score, metrics = _score_candidate(closes, volumes, ticker, weights, concentration)
action, reasons = _action_for(score, concentration)
if symbol.endswith(quote):
reasons.append(f"base asset {base_asset(symbol, quote)} passed liquidity and tradability filters")
recommendations.append(
asdict(
OpportunityRecommendation(
symbol=symbol,
action=action,
score=round(score, 4),
reasons=reasons,
metrics=metrics,
)
)
)
payload = {"recommendations": sorted(recommendations, key=lambda item: item["score"], reverse=True)[:top_n]}
audit_event(
"opportunity_scan_generated",
{
"market_type": "spot",
"symbol": None,
"side": None,
"qty": None,
"quote_amount": None,
"order_type": None,
"dry_run": True,
"request_payload": {"mode": "scan", "symbols": [normalize_symbol(item) for item in symbols or []]},
"response_payload": payload,
"status": "generated",
"error": None,
},
)
return payload

View File

@@ -1,96 +0,0 @@
"""Portfolio state helpers (positions.json, reconcile with exchange)."""
from ..runtime import get_runtime_paths
__all__ = [
"load_positions",
"save_positions",
"update_positions",
"upsert_position",
"reconcile_positions_with_exchange",
]
from .file_utils import load_json_locked, read_modify_write_json, save_json_locked
from .trade_common import bj_now_iso
def _paths():
return get_runtime_paths()
def load_positions() -> list:
paths = _paths()
data = load_json_locked(paths.positions_file, paths.positions_lock, {"positions": []})
return data.get("positions", []) # type: ignore[no-any-return]
def save_positions(positions: list):
paths = _paths()
save_json_locked(paths.positions_file, paths.positions_lock, {"positions": positions})
def update_positions(modifier):
"""Atomic read-modify-write for positions under a single lock.
*modifier* receives the current positions list and may mutate it in-place
or return a new list. If it returns None, the mutated list is saved.
"""
paths = _paths()
def _modify(data):
positions = data.get("positions", [])
result = modifier(positions)
data["positions"] = result if result is not None else positions
return data
read_modify_write_json(paths.positions_file, paths.positions_lock, {"positions": []}, _modify)
def upsert_position(positions: list, position: dict):
sym = position["symbol"]
for i, existing in enumerate(positions):
if existing.get("symbol") == sym:
positions[i] = position
return positions
positions.append(position)
return positions
def reconcile_positions_with_exchange(ex, positions_hint: list | None = None):
from .exchange_service import fetch_balances
balances = fetch_balances(ex)
reconciled = []
def _modify(data):
nonlocal reconciled
existing = data.get("positions", [])
existing_by_symbol = {p.get("symbol"): p for p in existing}
if positions_hint is not None:
existing_by_symbol.update({p.get("symbol"): p for p in positions_hint})
reconciled = []
for asset, qty in balances.items():
if asset == "USDT":
continue
if qty <= 0:
continue
sym = f"{asset}USDT"
old = existing_by_symbol.get(sym, {})
reconciled.append(
{
"account_id": old.get("account_id", "binance-main"),
"symbol": sym,
"base_asset": asset,
"quote_asset": "USDT",
"market_type": "spot",
"quantity": qty,
"avg_cost": old.get("avg_cost"),
"opened_at": old.get("opened_at", bj_now_iso()),
"updated_at": bj_now_iso(),
"note": old.get("note", "Reconciled from Binance balances"),
}
)
data["positions"] = reconciled
return data
paths = _paths()
read_modify_write_json(paths.positions_file, paths.positions_lock, {"positions": []}, _modify)
return reconciled, balances

View File

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

View File

@@ -1,25 +0,0 @@
"""Precheck constants and thresholds."""
from __future__ import annotations
from ..runtime import get_user_config
_BASE = "precheck"
BASE_PRICE_MOVE_TRIGGER_PCT = get_user_config(f"{_BASE}.base_price_move_trigger_pct", 0.025)
BASE_PNL_TRIGGER_PCT = get_user_config(f"{_BASE}.base_pnl_trigger_pct", 0.03)
BASE_PORTFOLIO_MOVE_TRIGGER_PCT = get_user_config(f"{_BASE}.base_portfolio_move_trigger_pct", 0.03)
BASE_CANDIDATE_SCORE_TRIGGER_RATIO = get_user_config(f"{_BASE}.base_candidate_score_trigger_ratio", 1.15)
BASE_FORCE_ANALYSIS_AFTER_MINUTES = get_user_config(f"{_BASE}.base_force_analysis_after_minutes", 180)
BASE_COOLDOWN_MINUTES = get_user_config(f"{_BASE}.base_cooldown_minutes", 45)
TOP_CANDIDATES = get_user_config(f"{_BASE}.top_candidates", 10)
MIN_ACTIONABLE_USDT = get_user_config(f"{_BASE}.min_actionable_usdt", 12.0)
MIN_REAL_POSITION_VALUE_USDT = get_user_config(f"{_BASE}.min_real_position_value_usdt", 8.0)
BLACKLIST = set(get_user_config(f"{_BASE}.blacklist", ["USDC", "BUSD", "TUSD", "FDUSD", "USTC", "PAXG"]))
HARD_STOP_PCT = get_user_config(f"{_BASE}.hard_stop_pct", -0.08)
HARD_MOON_PCT = get_user_config(f"{_BASE}.hard_moon_pct", 0.25)
MIN_CHANGE_PCT = get_user_config(f"{_BASE}.min_change_pct", 1.0)
MAX_PRICE_CAP = get_user_config(f"{_BASE}.max_price_cap", None)
HARD_REASON_DEDUP_MINUTES = get_user_config(f"{_BASE}.hard_reason_dedup_minutes", 15)
MAX_PENDING_TRIGGER_MINUTES = get_user_config(f"{_BASE}.max_pending_trigger_minutes", 30)
MAX_RUN_REQUEST_MINUTES = get_user_config(f"{_BASE}.max_run_request_minutes", 20)

View File

@@ -1,107 +0,0 @@
"""Backward-compatible facade for precheck internals.
The reusable implementation has been split into smaller modules:
- precheck_constants : paths and thresholds
- time_utils : UTC/local time helpers
- data_utils : json, hash, float, symbol normalization
- state_manager : load/save/sanitize state
- market_data : exchange, ohlcv, metrics
- candidate_scoring : top candidate selection
- snapshot_builder : build_snapshot
- adaptive_profile : trigger profile builder
- trigger_analyzer : analyze_trigger
Keep this module importable so older entrypoints continue to work.
"""
from __future__ import annotations
from importlib import import_module
from ..runtime import get_runtime_paths
_PATH_ALIASES = {
"PATHS": lambda: get_runtime_paths(),
"BASE_DIR": lambda: get_runtime_paths().root,
"STATE_DIR": lambda: get_runtime_paths().state_dir,
"STATE_FILE": lambda: get_runtime_paths().precheck_state_file,
"POSITIONS_FILE": lambda: get_runtime_paths().positions_file,
"CONFIG_FILE": lambda: get_runtime_paths().config_file,
"ENV_FILE": lambda: get_runtime_paths().env_file,
}
_MODULE_MAP = {
"BASE_PRICE_MOVE_TRIGGER_PCT": ".precheck_constants",
"BASE_PNL_TRIGGER_PCT": ".precheck_constants",
"BASE_PORTFOLIO_MOVE_TRIGGER_PCT": ".precheck_constants",
"BASE_CANDIDATE_SCORE_TRIGGER_RATIO": ".precheck_constants",
"BASE_FORCE_ANALYSIS_AFTER_MINUTES": ".precheck_constants",
"BASE_COOLDOWN_MINUTES": ".precheck_constants",
"TOP_CANDIDATES": ".precheck_constants",
"MIN_ACTIONABLE_USDT": ".precheck_constants",
"MIN_REAL_POSITION_VALUE_USDT": ".precheck_constants",
"BLACKLIST": ".precheck_constants",
"HARD_STOP_PCT": ".precheck_constants",
"HARD_MOON_PCT": ".precheck_constants",
"MIN_CHANGE_PCT": ".precheck_constants",
"MAX_PRICE_CAP": ".precheck_constants",
"HARD_REASON_DEDUP_MINUTES": ".precheck_constants",
"MAX_PENDING_TRIGGER_MINUTES": ".precheck_constants",
"MAX_RUN_REQUEST_MINUTES": ".precheck_constants",
"utc_now": ".time_utils",
"utc_iso": ".time_utils",
"parse_ts": ".time_utils",
"get_local_now": ".time_utils",
"session_label": ".time_utils",
"load_json": ".data_utils",
"stable_hash": ".data_utils",
"to_float": ".data_utils",
"norm_symbol": ".data_utils",
"load_env": ".state_manager",
"load_positions": ".state_manager",
"load_state": ".state_manager",
"load_config": ".state_manager",
"clear_run_request_fields": ".state_manager",
"sanitize_state_for_stale_triggers": ".state_manager",
"save_state": ".state_manager",
"update_state_after_observation": ".state_manager",
"get_exchange": ".market_data",
"fetch_ohlcv_batch": ".market_data",
"compute_ohlcv_metrics": ".market_data",
"enrich_candidates_and_positions": ".market_data",
"regime_from_pct": ".market_data",
"_liquidity_score": ".candidate_scoring",
"_breakout_score": ".candidate_scoring",
"top_candidates_from_tickers": ".candidate_scoring",
"build_snapshot": ".snapshot_builder",
"build_adaptive_profile": ".adaptive_profile",
"_candidate_weight": ".adaptive_profile",
"analyze_trigger": ".trigger_analyzer",
}
__all__ = sorted(set(_MODULE_MAP) | set(_PATH_ALIASES) | {"main"})
def __getattr__(name: str):
if name in _PATH_ALIASES:
return _PATH_ALIASES[name]()
if name not in _MODULE_MAP:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name = _MODULE_MAP[name]
module = import_module(module_name, __package__)
return getattr(module, name)
def __dir__():
return sorted(set(globals()) | set(__all__))
def main():
import sys
from .precheck_service import run as _run_service
return _run_service(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,44 +0,0 @@
"""Service entrypoint for precheck workflows."""
from __future__ import annotations
import json
__all__ = ["run"]
import sys
from . import precheck_analysis, precheck_snapshot, precheck_state
def run(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if argv and argv[0] == "--ack":
precheck_state.ack_analysis(" ".join(argv[1:]).strip())
return 0
if argv and argv[0] == "--mark-run-requested":
precheck_state.mark_run_requested(" ".join(argv[1:]).strip())
return 0
try:
captured = {}
def _modifier(state):
state = precheck_state.sanitize_state_for_stale_triggers(state)
snapshot = precheck_snapshot.build_snapshot()
analysis = precheck_analysis.analyze_trigger(snapshot, state)
new_state = precheck_state.update_state_after_observation(state, snapshot, analysis)
state.clear()
state.update(new_state)
captured["analysis"] = analysis
return state
precheck_state.modify_state(_modifier)
result = {"ok": True, **captured["analysis"]}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except Exception as exc:
payload = precheck_analysis.build_failure_payload(exc)
result = {"ok": False, **payload}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 1

View File

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

View File

@@ -1,41 +0,0 @@
"""State helpers for precheck orchestration."""
from __future__ import annotations
import json
from .state_manager import (
load_state,
modify_state,
)
from .time_utils import utc_iso
def mark_run_requested(note: str = "") -> dict:
def _modifier(state):
state["run_requested_at"] = utc_iso()
state["run_request_note"] = note
return state
modify_state(_modifier)
state = load_state()
payload = {"ok": True, "run_requested_at": state["run_requested_at"], "note": note}
print(json.dumps(payload, ensure_ascii=False))
return payload
def ack_analysis(note: str = "") -> dict:
def _modifier(state):
state["last_deep_analysis_at"] = utc_iso()
state["pending_trigger"] = False
state["pending_reasons"] = []
state["last_ack_note"] = note
state.pop("run_requested_at", None)
state.pop("run_request_note", None)
return state
modify_state(_modifier)
state = load_state()
payload = {"ok": True, "acked_at": state["last_deep_analysis_at"], "note": note}
print(json.dumps(payload, ensure_ascii=False))
return payload

View File

@@ -1,292 +0,0 @@
"""Review generation service for CoinHunter."""
from __future__ import annotations
import json
__all__ = [
"ensure_review_dir",
"norm_symbol",
"fetch_current_price",
"analyze_trade",
"analyze_hold_passes",
"analyze_cash_misses",
"generate_review",
"save_review",
"print_review",
]
from datetime import datetime, timedelta, timezone
from pathlib import Path
from ..logger import get_logs_last_n_hours
from ..runtime import get_runtime_paths
from .exchange_service import get_exchange
CST = timezone(timedelta(hours=8))
def _paths():
return get_runtime_paths()
def _review_dir() -> Path:
return _paths().reviews_dir # type: ignore[no-any-return]
def _exchange():
return get_exchange()
def ensure_review_dir():
_review_dir().mkdir(parents=True, exist_ok=True)
def norm_symbol(symbol: str) -> str:
s = symbol.upper().replace("-", "").replace("_", "")
if "/" in s:
return s
if s.endswith("USDT"):
return s[:-4] + "/USDT"
return s
def fetch_current_price(ex, symbol: str):
try:
return float(ex.fetch_ticker(norm_symbol(symbol))["last"])
except Exception:
return None
def analyze_trade(trade: dict, ex) -> dict:
symbol = trade.get("symbol")
price = trade.get("price")
action = trade.get("action", "")
current_price = fetch_current_price(ex, symbol) if symbol else None
pnl_estimate = None
outcome = "neutral"
if price and current_price and symbol:
change_pct = (current_price - float(price)) / float(price) * 100
if action == "BUY":
pnl_estimate = round(change_pct, 2)
outcome = "good" if change_pct > 2 else "bad" if change_pct < -2 else "neutral"
elif action == "SELL_ALL":
pnl_estimate = round(-change_pct, 2)
outcome = "good" if change_pct < -2 else "missed" if change_pct > 2 else "neutral"
return {
"timestamp": trade.get("timestamp"),
"symbol": symbol,
"action": action,
"decision_id": trade.get("decision_id"),
"execution_price": price,
"current_price": current_price,
"pnl_estimate_pct": pnl_estimate,
"outcome_assessment": outcome,
}
def analyze_hold_passes(decisions: list, ex) -> list:
misses = []
for d in decisions:
if d.get("decision") != "HOLD":
continue
analysis = d.get("analysis")
if not isinstance(analysis, dict):
continue
opportunities = analysis.get("opportunities_evaluated", [])
market_snapshot = d.get("market_snapshot", {})
if not opportunities or not market_snapshot:
continue
for op in opportunities:
verdict = op.get("verdict", "")
if "PASS" not in verdict and "pass" not in verdict:
continue
symbol = op.get("symbol", "")
snap = market_snapshot.get(symbol) or market_snapshot.get(symbol.replace("/", ""))
if not snap:
continue
decision_price = None
if isinstance(snap, dict):
decision_price = float(snap.get("lastPrice", 0)) or float(snap.get("last", 0))
elif isinstance(snap, (int, float, str)):
decision_price = float(snap)
if not decision_price:
continue
current_price = fetch_current_price(ex, symbol)
if not current_price:
continue
change_pct = (current_price - decision_price) / decision_price * 100
if change_pct > 3:
misses.append({
"timestamp": d.get("timestamp"),
"symbol": symbol,
"decision_price": round(decision_price, 8),
"current_price": round(current_price, 8),
"change_pct": round(change_pct, 2),
"verdict_snippet": verdict[:80],
})
return misses
def analyze_cash_misses(decisions: list, ex) -> list:
misses = []
watchlist = set()
for d in decisions:
snap = d.get("market_snapshot", {})
if isinstance(snap, dict):
for k in snap.keys():
if k.endswith("USDT"):
watchlist.add(k)
for d in decisions:
ts = d.get("timestamp")
balances = d.get("balances") or d.get("balances_before", {})
if not balances:
continue
total = sum(float(v) if isinstance(v, (int, float, str)) else 0 for v in balances.values())
usdt = float(balances.get("USDT", 0))
if total == 0 or (usdt / total) < 0.9:
continue
snap = d.get("market_snapshot", {})
if not isinstance(snap, dict):
continue
for symbol, data in snap.items():
if not symbol.endswith("USDT"):
continue
decision_price = None
if isinstance(data, dict):
decision_price = float(data.get("lastPrice", 0)) or float(data.get("last", 0))
elif isinstance(data, (int, float, str)):
decision_price = float(data)
if not decision_price:
continue
current_price = fetch_current_price(ex, symbol)
if not current_price:
continue
change_pct = (current_price - decision_price) / decision_price * 100
if change_pct > 5:
misses.append({
"timestamp": ts,
"symbol": symbol,
"decision_price": round(decision_price, 8),
"current_price": round(current_price, 8),
"change_pct": round(change_pct, 2),
})
seen: dict[str, dict] = {}
for m in misses:
sym = m["symbol"]
if sym not in seen or m["change_pct"] > seen[sym]["change_pct"]:
seen[sym] = m
return list(seen.values())
def generate_review(hours: int = 1) -> dict:
decisions = get_logs_last_n_hours("decisions", hours)
trades = get_logs_last_n_hours("trades", hours)
errors = get_logs_last_n_hours("errors", hours)
review: dict = {
"review_period_hours": hours,
"review_timestamp": datetime.now(CST).isoformat(),
"total_decisions": len(decisions),
"total_trades": len(trades),
"total_errors": len(errors),
"decision_quality": [],
"stats": {},
"insights": [],
"recommendations": [],
}
if not decisions and not trades:
review["insights"].append("No decisions or trades in this period")
return review
ex = get_exchange()
outcomes = {"good": 0, "neutral": 0, "bad": 0, "missed": 0}
pnl_samples = []
for trade in trades:
analysis = analyze_trade(trade, ex)
review["decision_quality"].append(analysis)
outcomes[analysis["outcome_assessment"]] += 1
if analysis["pnl_estimate_pct"] is not None:
pnl_samples.append(analysis["pnl_estimate_pct"])
hold_pass_misses = analyze_hold_passes(decisions, ex)
cash_misses = analyze_cash_misses(decisions, ex)
total_missed = outcomes["missed"] + len(hold_pass_misses) + len(cash_misses)
review["stats"] = {
"good_decisions": outcomes["good"],
"neutral_decisions": outcomes["neutral"],
"bad_decisions": outcomes["bad"],
"missed_opportunities": total_missed,
"missed_sell_all": outcomes["missed"],
"missed_hold_passes": len(hold_pass_misses),
"missed_cash_sits": len(cash_misses),
"avg_estimated_edge_pct": round(sum(pnl_samples) / len(pnl_samples), 2) if pnl_samples else None,
}
if errors:
review["insights"].append(f"{len(errors)} execution/system errors this period; robustness needs attention")
if outcomes["bad"] > outcomes["good"]:
review["insights"].append("Recent trade quality is weak; consider lowering frequency or raising entry threshold")
if total_missed > 0:
parts = []
if outcomes["missed"]:
parts.append(f"sold then rallied {outcomes['missed']} times")
if hold_pass_misses:
parts.append(f"missed after PASS {len(hold_pass_misses)} times")
if cash_misses:
parts.append(f"missed while sitting in cash {len(cash_misses)} times")
review["insights"].append("Opportunities missed: " + ", ".join(parts) + "; consider relaxing trend-following or entry conditions")
if outcomes["good"] >= max(1, outcomes["bad"] + total_missed):
review["insights"].append("Recent decisions are generally acceptable")
if not trades and decisions:
review["insights"].append("Decisions without trades; may be due to waiting on sidelines, minimum notional limits, or execution interception")
if len(trades) < len(decisions) * 0.1 and decisions:
review["insights"].append("Many decisions did not convert to trades; check if minimum notional/step-size/fee buffer thresholds are too high")
if hold_pass_misses:
for m in hold_pass_misses[:3]:
review["insights"].append(f"PASS'd {m['symbol']} during HOLD, then it rose {m['change_pct']}%")
if cash_misses:
for m in cash_misses[:3]:
review["insights"].append(f"{m['symbol']} rose {m['change_pct']}% while portfolio was mostly USDT")
review["recommendations"] = [
"Check whether minimum-notional/precision rejections are blocking small-capital execution",
"If estimated edge is negative for two consecutive review periods, reduce rebalancing frequency next hour",
"If error logs are increasing, prioritize defensive mode (hold more USDT)",
]
return review
def save_review(review: dict) -> str:
ensure_review_dir()
ts = datetime.now(CST).strftime("%Y%m%d_%H%M%S")
path = _review_dir() / f"review_{ts}.json"
path.write_text(json.dumps(review, indent=2, ensure_ascii=False), encoding="utf-8")
return str(path)
def print_review(review: dict):
print("=" * 50)
print("📊 Coin Hunter Review Report")
print(f"Review time: {review['review_timestamp']}")
print(f"Period: last {review['review_period_hours']} hours")
print(f"Total decisions: {review['total_decisions']} | Total trades: {review['total_trades']} | Total errors: {review['total_errors']}")
stats = review.get("stats", {})
print("\nDecision quality:")
print(f" ✓ Good: {stats.get('good_decisions', 0)}")
print(f" ○ Neutral: {stats.get('neutral_decisions', 0)}")
print(f" ✗ Bad: {stats.get('bad_decisions', 0)}")
print(f" ↗ Missed opportunities: {stats.get('missed_opportunities', 0)}")
if stats.get("avg_estimated_edge_pct") is not None:
print(f" Avg estimated edge: {stats['avg_estimated_edge_pct']}%")
if review.get("insights"):
print("\n💡 Insights:")
for item in review["insights"]:
print(f"{item}")
if review.get("recommendations"):
print("\n🔧 Recommendations:")
for item in review["recommendations"]:
print(f"{item}")
print("=" * 50)

View File

@@ -1,249 +0,0 @@
"""CLI parser and legacy argument normalization for smart executor."""
import argparse
__all__ = [
"COMMAND_CANONICAL",
"add_shared_options",
"build_parser",
"normalize_legacy_argv",
"parse_cli_args",
"cli_action_args",
]
COMMAND_CANONICAL = {
"bal": "balances",
"balances": "balances",
"balance": "balances",
"acct": "status",
"overview": "status",
"status": "status",
"hold": "hold",
"buy": "buy",
"flat": "sell-all",
"sell-all": "sell-all",
"sell_all": "sell-all",
"rotate": "rebalance",
"rebalance": "rebalance",
"orders": "orders",
"cancel": "cancel",
"order-status": "order-status",
"order_status": "order-status",
}
def add_shared_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--decision-id", help="Override the decision ID; otherwise one is derived automatically")
parser.add_argument("--analysis", help="Persist analysis text with the execution record")
parser.add_argument("--reasoning", help="Persist reasoning text with the execution record")
parser.add_argument("--dry-run", action="store_true", help="Simulate the command without placing live orders")
def build_parser() -> argparse.ArgumentParser:
shared = argparse.ArgumentParser(add_help=False)
add_shared_options(shared)
parser = argparse.ArgumentParser(
prog="coinhunter exec",
description="Professional execution console for account inspection and spot trading workflows",
formatter_class=argparse.RawTextHelpFormatter,
parents=[shared],
epilog=(
"Preferred verbs:\n"
" bal Print live balances as stable JSON\n"
" overview Print balances, positions, and market snapshot as stable JSON\n"
" hold Record a hold decision without trading\n"
" buy SYMBOL USDT Buy a symbol using a USDT notional amount\n"
" flat SYMBOL Exit an entire symbol position\n"
" rotate FROM TO Rotate exposure from one symbol into another\n"
" orders List open spot orders\n"
" order-status SYMBOL ORDER_ID Get status of a specific order\n"
" cancel SYMBOL [ORDER_ID] Cancel an open order (cancels newest if ORDER_ID omitted)\n\n"
"Examples:\n"
" coinhunter exec bal\n"
" coinhunter exec overview\n"
" coinhunter exec hold\n"
" coinhunter exec buy ENJUSDT 100\n"
" coinhunter exec flat ENJUSDT --dry-run\n"
" coinhunter exec rotate PEPEUSDT ETHUSDT\n"
" coinhunter exec orders\n"
" coinhunter exec order-status ENJUSDT 123456\n"
" coinhunter exec cancel ENJUSDT 123456\n\n"
"Legacy forms remain supported for backward compatibility:\n"
" balances, balance -> bal\n"
" acct, status -> overview\n"
" sell-all, sell_all -> flat\n"
" rebalance -> rotate\n"
" order_status -> order-status\n"
" HOLD / BUY / SELL_ALL / REBALANCE via --decision are still accepted\n"
),
)
subparsers = parser.add_subparsers(
dest="command",
metavar="{bal,overview,hold,buy,flat,rotate,orders,order-status,cancel,...}",
)
subparsers.add_parser("bal", parents=[shared], help="Preferred: print live balances as stable JSON")
subparsers.add_parser("overview", parents=[shared], help="Preferred: print the account overview as stable JSON")
subparsers.add_parser("hold", parents=[shared], help="Preferred: record a hold decision without trading")
buy = subparsers.add_parser("buy", parents=[shared], help="Preferred: buy a symbol with a USDT notional amount")
buy.add_argument("symbol")
buy.add_argument("amount_usdt", type=float)
flat = subparsers.add_parser("flat", parents=[shared], help="Preferred: exit an entire symbol position")
flat.add_argument("symbol")
rebalance = subparsers.add_parser("rotate", parents=[shared], help="Preferred: rotate exposure from one symbol into another")
rebalance.add_argument("from_symbol")
rebalance.add_argument("to_symbol")
subparsers.add_parser("orders", parents=[shared], help="List open spot orders")
order_status = subparsers.add_parser("order-status", parents=[shared], help="Get status of a specific order")
order_status.add_argument("symbol")
order_status.add_argument("order_id")
cancel = subparsers.add_parser("cancel", parents=[shared], help="Cancel an open order")
cancel.add_argument("symbol")
cancel.add_argument("order_id", nargs="?")
subparsers.add_parser("balances", parents=[shared], help=argparse.SUPPRESS)
subparsers.add_parser("balance", parents=[shared], help=argparse.SUPPRESS)
subparsers.add_parser("acct", parents=[shared], help=argparse.SUPPRESS)
subparsers.add_parser("status", parents=[shared], help=argparse.SUPPRESS)
sell_all = subparsers.add_parser("sell-all", parents=[shared], help=argparse.SUPPRESS)
sell_all.add_argument("symbol")
sell_all_legacy = subparsers.add_parser("sell_all", parents=[shared], help=argparse.SUPPRESS)
sell_all_legacy.add_argument("symbol")
rebalance_legacy = subparsers.add_parser("rebalance", parents=[shared], help=argparse.SUPPRESS)
rebalance_legacy.add_argument("from_symbol")
rebalance_legacy.add_argument("to_symbol")
order_status_legacy = subparsers.add_parser("order_status", parents=[shared], help=argparse.SUPPRESS)
order_status_legacy.add_argument("symbol")
order_status_legacy.add_argument("order_id")
subparsers._choices_actions = [
action
for action in subparsers._choices_actions
if action.help != argparse.SUPPRESS
]
return parser
def normalize_legacy_argv(argv: list[str]) -> list[str]:
if not argv:
return argv
action_aliases = {
"HOLD": ["hold"],
"hold": ["hold"],
"bal": ["balances"],
"acct": ["status"],
"overview": ["status"],
"flat": ["sell-all"],
"rotate": ["rebalance"],
"SELL_ALL": ["sell-all"],
"sell_all": ["sell-all"],
"sell-all": ["sell-all"],
"BUY": ["buy"],
"buy": ["buy"],
"REBALANCE": ["rebalance"],
"rebalance": ["rebalance"],
"BALANCE": ["balances"],
"balance": ["balances"],
"BALANCES": ["balances"],
"balances": ["balances"],
"STATUS": ["status"],
"status": ["status"],
"OVERVIEW": ["status"],
"ORDERS": ["orders"],
"orders": ["orders"],
"CANCEL": ["cancel"],
"cancel": ["cancel"],
"ORDER_STATUS": ["order-status"],
"order_status": ["order-status"],
"order-status": ["order-status"],
}
has_legacy_flag = any(t.startswith("--decision") for t in argv)
if not has_legacy_flag:
for idx, token in enumerate(argv):
if token in action_aliases:
prefix = argv[:idx]
suffix = argv[idx + 1 :]
return prefix + action_aliases[token] + suffix
if argv[0].startswith("-"):
legacy = argparse.ArgumentParser(add_help=False)
legacy.add_argument("--decision")
legacy.add_argument("--symbol")
legacy.add_argument("--from-symbol")
legacy.add_argument("--to-symbol")
legacy.add_argument("--amount-usdt", type=float)
legacy.add_argument("--decision-id")
legacy.add_argument("--analysis")
legacy.add_argument("--reasoning")
legacy.add_argument("--dry-run", action="store_true")
ns, unknown = legacy.parse_known_args(argv)
if ns.decision:
decision = (ns.decision or "").strip().upper()
rebuilt = []
if ns.decision_id:
rebuilt += ["--decision-id", ns.decision_id]
if ns.analysis:
rebuilt += ["--analysis", ns.analysis]
if ns.reasoning:
rebuilt += ["--reasoning", ns.reasoning]
if ns.dry_run:
rebuilt += ["--dry-run"]
if decision == "HOLD":
rebuilt += ["hold"]
elif decision == "SELL_ALL":
if not ns.symbol:
raise RuntimeError("Legacy --decision SELL_ALL requires --symbol")
rebuilt += ["sell-all", ns.symbol]
elif decision == "BUY":
if not ns.symbol or ns.amount_usdt is None:
raise RuntimeError("Legacy --decision BUY requires --symbol and --amount-usdt")
rebuilt += ["buy", ns.symbol, str(ns.amount_usdt)]
elif decision == "REBALANCE":
if not ns.from_symbol or not ns.to_symbol:
raise RuntimeError("Legacy --decision REBALANCE requires --from-symbol and --to-symbol")
rebuilt += ["rebalance", ns.from_symbol, ns.to_symbol]
else:
raise RuntimeError(f"Unsupported legacy decision: {decision}")
return rebuilt + unknown
return argv
def parse_cli_args(argv: list[str]):
parser = build_parser()
normalized = normalize_legacy_argv(argv)
args = parser.parse_args(normalized)
if not args.command:
parser.print_help()
raise SystemExit(1)
args.command = COMMAND_CANONICAL.get(args.command, args.command)
return args, normalized
def cli_action_args(args, action: str) -> list[str]:
if action == "sell_all":
return [args.symbol]
if action == "buy":
return [args.symbol, str(args.amount_usdt)]
if action == "rebalance":
return [args.from_symbol, args.to_symbol]
if action == "order_status":
return [args.symbol, args.order_id]
if action == "cancel":
return [args.symbol, args.order_id] if args.order_id else [args.symbol]
return []

View File

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

View File

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

View File

@@ -1,160 +0,0 @@
"""State management for precheck workflows."""
from __future__ import annotations
from datetime import timedelta
__all__ = [
"load_env",
"load_positions",
"load_state",
"modify_state",
"load_config",
"clear_run_request_fields",
"sanitize_state_for_stale_triggers",
"save_state",
"update_state_after_observation",
]
from ..runtime import get_runtime_paths, load_env_file
from .data_utils import load_json
from .file_utils import load_json_locked, read_modify_write_json, save_json_locked
from .precheck_constants import (
MAX_PENDING_TRIGGER_MINUTES,
MAX_RUN_REQUEST_MINUTES,
)
from .time_utils import parse_ts, utc_iso, utc_now
def _paths():
return get_runtime_paths()
def load_env() -> None:
load_env_file(_paths())
def load_positions():
return load_json(_paths().positions_file, {}).get("positions", [])
def load_state():
paths = _paths()
return load_json_locked(paths.precheck_state_file, paths.precheck_state_lock, {})
def modify_state(modifier):
"""Atomic read-modify-write for precheck state."""
paths = _paths()
read_modify_write_json(
paths.precheck_state_file,
paths.precheck_state_lock,
{},
modifier,
)
def load_config():
return load_json(_paths().config_file, {})
def clear_run_request_fields(state: dict):
state.pop("run_requested_at", None)
state.pop("run_request_note", None)
def sanitize_state_for_stale_triggers(state: dict):
sanitized = dict(state)
notes = []
now = utc_now()
run_requested_at = parse_ts(sanitized.get("run_requested_at"))
last_deep_analysis_at = parse_ts(sanitized.get("last_deep_analysis_at"))
last_triggered_at = parse_ts(sanitized.get("last_triggered_at"))
pending_trigger = bool(sanitized.get("pending_trigger"))
if run_requested_at and last_deep_analysis_at and last_deep_analysis_at >= run_requested_at:
clear_run_request_fields(sanitized)
if pending_trigger and (not last_triggered_at or last_deep_analysis_at >= last_triggered_at):
sanitized["pending_trigger"] = False
sanitized["pending_reasons"] = []
sanitized["last_ack_note"] = (
f"auto-cleared completed trigger at {utc_iso()} because last_deep_analysis_at >= run_requested_at"
)
pending_trigger = False
notes.append(
f"Auto-cleared completed run_requested marker: last_deep_analysis_at {last_deep_analysis_at.isoformat()} >= run_requested_at {run_requested_at.isoformat()}"
)
run_requested_at = None
if run_requested_at and now - run_requested_at > timedelta(minutes=MAX_RUN_REQUEST_MINUTES):
clear_run_request_fields(sanitized)
notes.append(
f"Auto-cleared stale run_requested marker: waited {(now - run_requested_at).total_seconds() / 60:.1f} minutes, exceeding {MAX_RUN_REQUEST_MINUTES} minutes"
)
run_requested_at = None
pending_anchor = run_requested_at or last_triggered_at or last_deep_analysis_at
if pending_trigger and pending_anchor and now - pending_anchor > timedelta(minutes=MAX_PENDING_TRIGGER_MINUTES):
sanitized["pending_trigger"] = False
sanitized["pending_reasons"] = []
sanitized["last_ack_note"] = (
f"auto-recovered stale pending trigger at {utc_iso()} after waiting "
f"{(now - pending_anchor).total_seconds() / 60:.1f} minutes"
)
notes.append(
f"Auto-recovered stale pending_trigger: trigger was dangling for {(now - pending_anchor).total_seconds() / 60:.1f} minutes, exceeding {MAX_PENDING_TRIGGER_MINUTES} minutes"
)
sanitized["_stale_recovery_notes"] = notes
return sanitized
def save_state(state: dict):
paths = _paths()
paths.state_dir.mkdir(parents=True, exist_ok=True)
state_to_save = dict(state)
state_to_save.pop("_stale_recovery_notes", None)
save_json_locked(
paths.precheck_state_file,
paths.precheck_state_lock,
state_to_save,
)
def update_state_after_observation(state: dict, snapshot: dict, analysis: dict):
new_state = dict(state)
new_state.update({
"last_observed_at": snapshot["generated_at"],
"last_snapshot_hash": snapshot["snapshot_hash"],
"last_positions_hash": snapshot["positions_hash"],
"last_candidates_hash": snapshot["candidates_hash"],
"last_portfolio_value_usdt": snapshot["portfolio_value_usdt"],
"last_market_regime": snapshot["market_regime"],
"last_positions_map": {
p["symbol"]: {"last_price": p.get("last_price"), "pnl_pct": p.get("pnl_pct")}
for p in snapshot["positions"]
},
"last_top_candidate": snapshot["top_candidates"][0] if snapshot["top_candidates"] else None,
"last_candidates_layers": snapshot.get("top_candidates_layers", {}),
"last_adaptive_profile": analysis.get("adaptive_profile", {}),
})
if analysis["should_analyze"]:
new_state["pending_trigger"] = True
new_state["pending_reasons"] = analysis["details"]
new_state["last_triggered_at"] = snapshot["generated_at"]
new_state["last_trigger_snapshot_hash"] = snapshot["snapshot_hash"]
new_state["last_trigger_hard_reasons"] = analysis.get("hard_reasons", [])
new_state["last_trigger_signal_delta"] = analysis.get("signal_delta", 0.0)
last_hard_reasons_at = dict(state.get("last_hard_reasons_at", {}))
for hr in analysis.get("hard_reasons", []):
last_hard_reasons_at[hr] = snapshot["generated_at"]
cutoff = utc_now() - timedelta(hours=24)
pruned: dict[str, str] = {}
for k, v in last_hard_reasons_at.items():
ts = parse_ts(v)
if ts and ts > cutoff:
pruned[k] = v
new_state["last_hard_reasons_at"] = pruned
return new_state

View File

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

View File

@@ -1,28 +0,0 @@
"""Common trade utilities (time, logging, constants)."""
import os
import sys
from datetime import datetime, timedelta, timezone
from ..runtime import get_user_config
CST = timezone(timedelta(hours=8))
_DRY_RUN = {"value": os.getenv("DRY_RUN", "false").lower() == "true"}
USDT_BUFFER_PCT = get_user_config("trading.usdt_buffer_pct", 0.03)
MIN_REMAINING_DUST_USDT = get_user_config("trading.min_remaining_dust_usdt", 1.0)
def is_dry_run() -> bool:
return _DRY_RUN["value"]
def set_dry_run(value: bool):
_DRY_RUN["value"] = value
def log(msg: str):
print(f"[{datetime.now(CST).strftime('%Y-%m-%d %H:%M:%S')} CST] {msg}", file=sys.stderr)
def bj_now_iso():
return datetime.now(CST).isoformat()

View File

@@ -1,243 +0,0 @@
"""Trade execution actions (buy, sell, rebalance, hold, status)."""
import json
__all__ = [
"print_json",
"build_decision_context",
"market_sell",
"market_buy",
"action_sell_all",
"action_buy",
"action_rebalance",
"command_status",
"command_balances",
"command_orders",
"command_order_status",
"command_cancel",
]
from ..logger import log_decision, log_trade
from .exchange_service import (
build_market_snapshot,
fetch_balances,
norm_symbol,
prepare_buy_quantity,
prepare_sell_quantity,
storage_symbol,
)
from .portfolio_service import (
load_positions,
reconcile_positions_with_exchange,
update_positions,
upsert_position,
)
from .trade_common import USDT_BUFFER_PCT, bj_now_iso, is_dry_run, log
def print_json(payload: dict) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
def build_decision_context(ex, action: str, argv_tail: list[str], decision_id: str):
balances = fetch_balances(ex)
positions = load_positions()
return {
"decision_id": decision_id,
"balances_before": balances,
"positions_before": positions,
"decision": action.upper(),
"action_taken": f"{action} {' '.join(argv_tail)}".strip(),
"risk_level": "high" if len(positions) <= 1 else "medium",
"data_sources": ["binance"],
}
def market_sell(ex, symbol: str, qty: float, decision_id: str):
sym, qty, bid, est_cost = prepare_sell_quantity(ex, symbol, qty)
if is_dry_run():
log(f"[DRY RUN] SELL {sym} qty {qty}")
return {"id": f"dry-sell-{decision_id}", "symbol": sym, "amount": qty, "price": bid, "cost": est_cost, "status": "closed"}
order = ex.create_market_sell_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-sell"})
return order
def market_buy(ex, symbol: str, amount_usdt: float, decision_id: str):
sym, qty, ask, est_cost = prepare_buy_quantity(ex, symbol, amount_usdt)
if is_dry_run():
log(f"[DRY RUN] BUY {sym} amount ${est_cost:.4f} qty {qty}")
return {"id": f"dry-buy-{decision_id}", "symbol": sym, "amount": qty, "price": ask, "cost": est_cost, "status": "closed"}
order = ex.create_market_buy_order(sym, qty, params={"newClientOrderId": f"ch-{decision_id}-buy"})
return order
def action_sell_all(ex, symbol: str, decision_id: str, decision_context: dict):
balances_before = fetch_balances(ex)
base = norm_symbol(symbol).split("/")[0]
qty = float(balances_before.get(base, 0))
if qty <= 0:
raise RuntimeError(f"{base} balance is zero, cannot sell")
order = market_sell(ex, symbol, qty, decision_id)
if is_dry_run():
positions_after = load_positions()
balances_after = balances_before
else:
positions_after, balances_after = reconcile_positions_with_exchange(ex)
log_trade(
"SELL_ALL",
norm_symbol(symbol),
qty=order.get("amount"),
price=order.get("price"),
amount_usdt=order.get("cost"),
note="Smart executor sell_all",
decision_id=decision_id,
order_id=order.get("id"),
status=order.get("status"),
balances_before=balances_before,
balances_after=balances_after,
)
log_decision(
{
**decision_context,
"balances_after": balances_after,
"positions_after": positions_after,
"execution_result": {"order": order},
"analysis": decision_context.get("analysis", ""),
"reasoning": decision_context.get("reasoning", "sell_all execution"),
}
)
return order
def action_buy(ex, symbol: str, amount_usdt: float, decision_id: str, decision_context: dict, simulated_usdt_balance: float | None = None):
balances_before = fetch_balances(ex) if simulated_usdt_balance is None else {"USDT": simulated_usdt_balance}
usdt = float(balances_before.get("USDT", 0))
if usdt < amount_usdt:
raise RuntimeError(f"Insufficient USDT balance (${usdt:.4f} < ${amount_usdt:.4f})")
order = market_buy(ex, symbol, amount_usdt, decision_id)
sym_store = storage_symbol(symbol)
price = float(order.get("price") or 0)
qty = float(order.get("amount") or 0)
position = {
"account_id": "binance-main",
"symbol": sym_store,
"base_asset": norm_symbol(symbol).split("/")[0],
"quote_asset": "USDT",
"market_type": "spot",
"quantity": qty,
"avg_cost": price,
"opened_at": bj_now_iso(),
"updated_at": bj_now_iso(),
"note": "Smart executor entry",
}
if is_dry_run():
balances_after = balances_before
positions_after = load_positions()
upsert_position(positions_after, position)
else:
update_positions(lambda p: upsert_position(p, position))
positions_after, balances_after = reconcile_positions_with_exchange(ex, [position])
log_trade(
"BUY",
norm_symbol(symbol),
qty=qty,
amount_usdt=order.get("cost"),
price=price,
note="Smart executor buy",
decision_id=decision_id,
order_id=order.get("id"),
status=order.get("status"),
balances_before=balances_before,
balances_after=balances_after,
)
log_decision(
{
**decision_context,
"balances_after": balances_after,
"positions_after": positions_after,
"execution_result": {"order": order},
"analysis": decision_context.get("analysis", ""),
"reasoning": decision_context.get("reasoning", "buy execution"),
}
)
return order
def action_rebalance(ex, from_symbol: str, to_symbol: str, decision_id: str, decision_context: dict):
sell_order = action_sell_all(ex, from_symbol, decision_id + "s", decision_context)
if is_dry_run():
sell_cost = float(sell_order.get("cost") or 0)
spend = sell_cost * (1 - USDT_BUFFER_PCT)
simulated_usdt = sell_cost
else:
balances = fetch_balances(ex)
usdt = float(balances.get("USDT", 0))
spend = usdt * (1 - USDT_BUFFER_PCT)
simulated_usdt = None
if spend < 5:
raise RuntimeError(f"USDT ${spend:.4f} insufficient after sell, cannot buy new token")
buy_order = action_buy(ex, to_symbol, spend, decision_id + "b", decision_context, simulated_usdt_balance=simulated_usdt)
return {"sell": sell_order, "buy": buy_order}
def command_status(ex):
balances = fetch_balances(ex)
positions = load_positions()
market_snapshot = build_market_snapshot(ex)
payload = {
"balances": balances,
"positions": positions,
"market_snapshot": market_snapshot,
}
print_json(payload)
return payload
def command_balances(ex):
balances = fetch_balances(ex)
payload = {"balances": balances}
print_json(payload)
return balances
def command_orders(ex):
sym = None
try:
orders = ex.fetch_open_orders(symbol=sym) if sym else ex.fetch_open_orders()
except Exception as e:
raise RuntimeError(f"Failed to fetch open orders: {e}")
payload = {"orders": orders}
print_json(payload)
return orders
def command_order_status(ex, symbol: str, order_id: str):
sym = norm_symbol(symbol)
try:
order = ex.fetch_order(order_id, sym)
except Exception as e:
raise RuntimeError(f"Failed to fetch order {order_id}: {e}")
payload = {"order": order}
print_json(payload)
return order
def command_cancel(ex, symbol: str, order_id: str | None):
sym = norm_symbol(symbol)
if is_dry_run():
log(f"[DRY RUN] Would cancel order {order_id or '(newest)'} on {sym}")
return {"dry_run": True, "symbol": sym, "order_id": order_id}
if not order_id:
try:
open_orders = ex.fetch_open_orders(sym)
except Exception as e:
raise RuntimeError(f"Failed to fetch open orders for {sym}: {e}")
if not open_orders:
raise RuntimeError(f"No open orders to cancel for {sym}")
order_id = str(open_orders[-1]["id"])
try:
result = ex.cancel_order(order_id, sym)
except Exception as e:
raise RuntimeError(f"Failed to cancel order {order_id} on {sym}: {e}")
payload = {"cancelled": True, "symbol": sym, "order_id": order_id, "result": result}
print_json(payload)
return result

View File

@@ -0,0 +1,156 @@
"""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}

View File

@@ -1,317 +0,0 @@
"""Trigger analysis logic for precheck."""
from __future__ import annotations
from datetime import timedelta
from .adaptive_profile import _candidate_weight, build_adaptive_profile
from .data_utils import to_float
from .precheck_constants import (
HARD_MOON_PCT,
HARD_REASON_DEDUP_MINUTES,
HARD_STOP_PCT,
MIN_REAL_POSITION_VALUE_USDT,
)
from .time_utils import parse_ts, utc_now
def analyze_trigger(snapshot: dict, state: dict):
reasons = []
details = list(state.get("_stale_recovery_notes", []))
hard_reasons = []
soft_reasons = []
soft_score = 0.0
profile = build_adaptive_profile(snapshot)
market = snapshot.get("market_regime", {})
now = utc_now()
last_positions_hash = state.get("last_positions_hash")
last_portfolio_value = state.get("last_portfolio_value_usdt")
last_market_regime = state.get("last_market_regime", {})
last_positions_map = state.get("last_positions_map", {})
last_top_candidate = state.get("last_top_candidate")
pending_trigger = bool(state.get("pending_trigger"))
run_requested_at = parse_ts(state.get("run_requested_at"))
last_deep_analysis_at = parse_ts(state.get("last_deep_analysis_at"))
last_triggered_at = parse_ts(state.get("last_triggered_at"))
last_trigger_snapshot_hash = state.get("last_trigger_snapshot_hash")
last_hard_reasons_at = state.get("last_hard_reasons_at", {})
price_trigger = profile["price_move_trigger_pct"]
pnl_trigger = profile["pnl_trigger_pct"]
portfolio_trigger = profile["portfolio_move_trigger_pct"]
candidate_ratio_trigger = profile["candidate_score_trigger_ratio"]
force_minutes = profile["force_analysis_after_minutes"]
cooldown_minutes = profile["cooldown_minutes"]
soft_score_threshold = profile["soft_score_threshold"]
if pending_trigger:
reasons.append("pending-trigger-unacked")
hard_reasons.append("pending-trigger-unacked")
details.append("Previous deep analysis trigger has not been acknowledged yet")
if run_requested_at:
details.append(f"External gate requested analysis at {run_requested_at.isoformat()}")
if not last_deep_analysis_at:
reasons.append("first-analysis")
hard_reasons.append("first-analysis")
details.append("No deep analysis has been recorded yet")
elif now - last_deep_analysis_at >= timedelta(minutes=force_minutes):
reasons.append("stale-analysis")
hard_reasons.append("stale-analysis")
details.append(f"Time since last deep analysis exceeds {force_minutes} minutes")
if last_positions_hash and snapshot["positions_hash"] != last_positions_hash:
reasons.append("positions-changed")
hard_reasons.append("positions-changed")
details.append("Position structure has changed")
if last_portfolio_value not in (None, 0):
lpf = float(str(last_portfolio_value))
portfolio_delta = abs(snapshot["portfolio_value_usdt"] - lpf) / max(lpf, 1e-9)
if portfolio_delta >= portfolio_trigger:
if portfolio_delta >= 1.0:
reasons.append("portfolio-extreme-move")
hard_reasons.append("portfolio-extreme-move")
details.append(f"Portfolio value moved extremely {portfolio_delta:.1%}, exceeding 100%, treated as hard trigger")
else:
reasons.append("portfolio-move")
soft_reasons.append("portfolio-move")
soft_score += 1.0
details.append(f"Portfolio value moved {portfolio_delta:.1%}, threshold {portfolio_trigger:.1%}")
for pos in snapshot["positions"]:
symbol = pos["symbol"]
prev = last_positions_map.get(symbol, {})
cur_price = pos.get("last_price")
prev_price = prev.get("last_price")
cur_pnl = pos.get("pnl_pct")
prev_pnl = prev.get("pnl_pct")
market_value = to_float(pos.get("market_value_usdt"), 0)
actionable_position = market_value >= MIN_REAL_POSITION_VALUE_USDT
if cur_price and prev_price:
price_move = abs(cur_price - prev_price) / max(prev_price, 1e-9)
if price_move >= price_trigger:
reasons.append(f"price-move:{symbol}")
soft_reasons.append(f"price-move:{symbol}")
soft_score += 1.0 if actionable_position else 0.4
details.append(f"{symbol} price moved {price_move:.1%}, threshold {price_trigger:.1%}")
if cur_pnl is not None and prev_pnl is not None:
pnl_move = abs(cur_pnl - prev_pnl)
if pnl_move >= pnl_trigger:
reasons.append(f"pnl-move:{symbol}")
soft_reasons.append(f"pnl-move:{symbol}")
soft_score += 1.0 if actionable_position else 0.4
details.append(f"{symbol} PnL moved {pnl_move:.1%}, threshold {pnl_trigger:.1%}")
if cur_pnl is not None:
stop_band = -0.06 if actionable_position else -0.12
take_band = 0.14 if actionable_position else 0.25
if cur_pnl <= stop_band or cur_pnl >= take_band:
reasons.append(f"risk-band:{symbol}")
hard_reasons.append(f"risk-band:{symbol}")
details.append(f"{symbol} near execution threshold, current PnL {cur_pnl:.1%}")
if cur_pnl <= HARD_STOP_PCT:
reasons.append(f"hard-stop:{symbol}")
hard_reasons.append(f"hard-stop:{symbol}")
details.append(f"{symbol} PnL exceeded {HARD_STOP_PCT:.1%}, emergency hard trigger")
current_market = snapshot.get("market_regime", {})
if last_market_regime:
if current_market.get("btc_regime") != last_market_regime.get("btc_regime"):
reasons.append("btc-regime-change")
hard_reasons.append("btc-regime-change")
details.append(f"BTC regime changed from {last_market_regime.get('btc_regime')} to {current_market.get('btc_regime')}")
if current_market.get("eth_regime") != last_market_regime.get("eth_regime"):
reasons.append("eth-regime-change")
hard_reasons.append("eth-regime-change")
details.append(f"ETH regime changed from {last_market_regime.get('eth_regime')} to {current_market.get('eth_regime')}")
for cand in snapshot.get("top_candidates", []):
if cand.get("change_24h_pct", 0) >= HARD_MOON_PCT * 100:
reasons.append(f"hard-moon:{cand['symbol']}")
hard_reasons.append(f"hard-moon:{cand['symbol']}")
details.append(f"Candidate {cand['symbol']} 24h change {cand['change_24h_pct']:.1f}%, hard moon trigger")
candidate_weight = _candidate_weight(snapshot, profile)
last_layers = state.get("last_candidates_layers", {})
current_layers = snapshot.get("top_candidates_layers", {})
for band in ("major", "mid", "meme"):
cur_band = current_layers.get(band, [])
prev_band = last_layers.get(band, [])
cur_leader = cur_band[0] if cur_band else None
prev_leader = prev_band[0] if prev_band else None
if cur_leader and prev_leader and cur_leader["symbol"] != prev_leader["symbol"]:
score_ratio = cur_leader.get("score", 0) / max(prev_leader.get("score", 0.0001), 0.0001)
if score_ratio >= candidate_ratio_trigger:
reasons.append(f"new-leader-{band}:{cur_leader['symbol']}")
soft_reasons.append(f"new-leader-{band}:{cur_leader['symbol']}")
soft_score += candidate_weight * 0.7
details.append(
f"{band} tier new leader {cur_leader['symbol']} replaced {prev_leader['symbol']}, score ratio {score_ratio:.2f}"
)
current_leader = snapshot.get("top_candidates", [{}])[0] if snapshot.get("top_candidates") else None
if last_top_candidate and current_leader:
if current_leader.get("symbol") != last_top_candidate.get("symbol"):
score_ratio = current_leader.get("score", 0) / max(last_top_candidate.get("score", 0.0001), 0.0001)
if score_ratio >= candidate_ratio_trigger:
reasons.append("new-leader")
soft_reasons.append("new-leader")
soft_score += candidate_weight
details.append(
f"New candidate {current_leader.get('symbol')} leads previous top, score ratio {score_ratio:.2f}, threshold {candidate_ratio_trigger:.2f}"
)
elif current_leader and not last_top_candidate:
reasons.append("candidate-leader-init")
soft_reasons.append("candidate-leader-init")
soft_score += candidate_weight
details.append(f"First recorded candidate leader {current_leader.get('symbol')}")
def _signal_delta() -> float:
delta = 0.0
if last_trigger_snapshot_hash and snapshot.get("snapshot_hash") != last_trigger_snapshot_hash:
delta += 0.5
if snapshot["positions_hash"] != last_positions_hash:
delta += 1.5
for pos in snapshot["positions"]:
symbol = pos["symbol"]
prev = last_positions_map.get(symbol, {})
cur_price = pos.get("last_price")
prev_price = prev.get("last_price")
cur_pnl = pos.get("pnl_pct")
prev_pnl = prev.get("pnl_pct")
if cur_price and prev_price and abs(cur_price - prev_price) / max(prev_price, 1e-9) >= 0.02:
delta += 0.5
if cur_pnl is not None and prev_pnl is not None and abs(cur_pnl - prev_pnl) >= 0.03:
delta += 0.5
last_leader = state.get("last_top_candidate")
if current_leader and last_leader and current_leader.get("symbol") != last_leader.get("symbol"):
delta += 1.0
for band in ("major", "mid", "meme"):
cur_band = current_layers.get(band, [])
prev_band = last_layers.get(band, [])
cur_l = cur_band[0] if cur_band else None
prev_l = prev_band[0] if prev_band else None
if cur_l and prev_l and cur_l.get("symbol") != prev_l.get("symbol"):
delta += 0.5
if last_market_regime:
if current_market.get("btc_regime") != last_market_regime.get("btc_regime"):
delta += 1.5
if current_market.get("eth_regime") != last_market_regime.get("eth_regime"):
delta += 1.5
if last_portfolio_value not in (None, 0):
lpf = float(str(last_portfolio_value))
portfolio_delta = abs(snapshot["portfolio_value_usdt"] - lpf) / max(lpf, 1e-9)
if portfolio_delta >= 0.05:
delta += 1.0
last_trigger_hard_types = {r.split(":")[0] for r in (state.get("last_trigger_hard_reasons") or [])}
current_hard_types = {r.split(":")[0] for r in hard_reasons}
if current_hard_types - last_trigger_hard_types:
delta += 2.0
return delta
signal_delta = _signal_delta()
effective_cooldown = cooldown_minutes
if signal_delta < 1.0:
effective_cooldown = max(cooldown_minutes, 90)
elif signal_delta >= 2.5:
effective_cooldown = max(0, cooldown_minutes - 15)
cooldown_active = bool(last_triggered_at and now - last_triggered_at < timedelta(minutes=effective_cooldown))
dedup_window = timedelta(minutes=HARD_REASON_DEDUP_MINUTES)
for hr in list(hard_reasons):
last_at = parse_ts(last_hard_reasons_at.get(hr))
if last_at and now - last_at < dedup_window:
hard_reasons.remove(hr)
details.append(f"{hr} triggered recently, deduplicated within {HARD_REASON_DEDUP_MINUTES} minutes")
hard_trigger = bool(hard_reasons)
if profile.get("dust_mode") and not hard_trigger and soft_score < soft_score_threshold + 1.0:
details.append("Dust-mode portfolio: raising soft-trigger threshold to avoid noise")
if profile.get("dust_mode") and not profile.get("new_entries_allowed") and any(
r in {"new-leader", "candidate-leader-init"} for r in soft_reasons
):
details.append("Available capital below executable threshold; new candidates are observation-only")
soft_score = max(0.0, soft_score - 0.75)
should_analyze = hard_trigger or soft_score >= soft_score_threshold
if cooldown_active and not hard_trigger and should_analyze:
should_analyze = False
details.append(f"In {cooldown_minutes} minute cooldown window; soft trigger logged but not escalated")
if cooldown_active and not hard_trigger and reasons and soft_score < soft_score_threshold:
details.append(f"In {cooldown_minutes} minute cooldown window with insufficient soft signal ({soft_score:.2f} < {soft_score_threshold:.2f})")
status = "deep_analysis_required" if should_analyze else "stable"
compact_lines = [
f"Status: {status}",
f"Portfolio: ${snapshot['portfolio_value_usdt']:.4f} | Free USDT: ${snapshot['free_usdt']:.4f}",
f"Session: {snapshot['session']} | TZ: {snapshot['timezone']}",
f"BTC/ETH: {market.get('btc_regime')} ({market.get('btc_24h_pct')}%), {market.get('eth_regime')} ({market.get('eth_24h_pct')}%) | Volatility score {market.get('volatility_score')}",
f"Profile: capital={profile['capital_band']}, session={profile['session_mode']}, volatility={profile['volatility_mode']}, dust={profile['dust_mode']}",
f"Thresholds: price={price_trigger:.1%}, pnl={pnl_trigger:.1%}, portfolio={portfolio_trigger:.1%}, candidate={candidate_ratio_trigger:.2f}, cooldown={effective_cooldown}m({cooldown_minutes}m base), force={force_minutes}m",
f"Soft score: {soft_score:.2f} / {soft_score_threshold:.2f}",
f"Signal delta: {signal_delta:.1f}",
]
if snapshot["positions"]:
compact_lines.append("Positions:")
for pos in snapshot["positions"][:4]:
pnl = pos.get("pnl_pct")
pnl_text = f"{pnl:+.1%}" if pnl is not None else "n/a"
compact_lines.append(
f"- {pos['symbol']}: qty={pos['quantity']}, px={pos.get('last_price')}, pnl={pnl_text}, value=${pos.get('market_value_usdt')}"
)
else:
compact_lines.append("Positions: no spot positions currently")
if snapshot["top_candidates"]:
compact_lines.append("Candidates:")
for cand in snapshot["top_candidates"]:
compact_lines.append(
f"- {cand['symbol']}: score={cand['score']}, 24h={cand['change_24h_pct']}%, vol=${cand['volume_24h']}"
)
layers = snapshot.get("top_candidates_layers", {})
for band, band_cands in layers.items():
if band_cands:
compact_lines.append(f"{band} tier:")
for cand in band_cands:
compact_lines.append(
f"- {cand['symbol']}: score={cand['score']}, 24h={cand['change_24h_pct']}%, vol=${cand['volume_24h']}"
)
if details:
compact_lines.append("Trigger notes:")
for item in details:
compact_lines.append(f"- {item}")
return {
"generated_at": snapshot["generated_at"],
"status": status,
"should_analyze": should_analyze,
"pending_trigger": pending_trigger,
"run_requested": bool(run_requested_at),
"run_requested_at": run_requested_at.isoformat() if run_requested_at else None,
"cooldown_active": cooldown_active,
"effective_cooldown_minutes": effective_cooldown,
"signal_delta": round(signal_delta, 2),
"reasons": reasons,
"hard_reasons": hard_reasons,
"soft_reasons": soft_reasons,
"soft_score": round(soft_score, 3),
"adaptive_profile": profile,
"portfolio_value_usdt": snapshot["portfolio_value_usdt"],
"free_usdt": snapshot["free_usdt"],
"market_regime": snapshot["market_regime"],
"session": snapshot["session"],
"positions": snapshot["positions"],
"top_candidates": snapshot["top_candidates"],
"top_candidates_layers": layers,
"snapshot_hash": snapshot["snapshot_hash"],
"compact_summary": "\n".join(compact_lines),
"details": details,
}

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""Backward-compatible facade for smart executor workflows.
The executable implementation lives in ``coinhunter.services.smart_executor_service``.
This module stays importable for older callers without importing the whole trading
stack up front.
"""
from __future__ import annotations
import sys
from importlib import import_module
_EXPORT_MAP = {
"PATHS": (".runtime", "get_runtime_paths"),
"ENV_FILE": (".runtime", "get_runtime_paths"),
"load_env_file": (".runtime", "load_env_file"),
"CST": (".services.trade_common", "CST"),
"USDT_BUFFER_PCT": (".services.trade_common", "USDT_BUFFER_PCT"),
"MIN_REMAINING_DUST_USDT": (".services.trade_common", "MIN_REMAINING_DUST_USDT"),
"is_dry_run": (".services.trade_common", "is_dry_run"),
"log": (".services.trade_common", "log"),
"bj_now_iso": (".services.trade_common", "bj_now_iso"),
"set_dry_run": (".services.trade_common", "set_dry_run"),
"locked_file": (".services.file_utils", "locked_file"),
"atomic_write_json": (".services.file_utils", "atomic_write_json"),
"load_json_locked": (".services.file_utils", "load_json_locked"),
"save_json_locked": (".services.file_utils", "save_json_locked"),
"build_parser": (".services.smart_executor_parser", "build_parser"),
"normalize_legacy_argv": (".services.smart_executor_parser", "normalize_legacy_argv"),
"parse_cli_args": (".services.smart_executor_parser", "parse_cli_args"),
"cli_action_args": (".services.smart_executor_parser", "cli_action_args"),
"default_decision_id": (".services.execution_state", "default_decision_id"),
"record_execution_state": (".services.execution_state", "record_execution_state"),
"get_execution_state": (".services.execution_state", "get_execution_state"),
"load_executions": (".services.execution_state", "load_executions"),
"save_executions": (".services.execution_state", "save_executions"),
"load_positions": (".services.portfolio_service", "load_positions"),
"save_positions": (".services.portfolio_service", "save_positions"),
"update_positions": (".services.portfolio_service", "update_positions"),
"upsert_position": (".services.portfolio_service", "upsert_position"),
"reconcile_positions_with_exchange": (".services.portfolio_service", "reconcile_positions_with_exchange"),
"get_exchange": (".services.exchange_service", "get_exchange"),
"norm_symbol": (".services.exchange_service", "norm_symbol"),
"storage_symbol": (".services.exchange_service", "storage_symbol"),
"fetch_balances": (".services.exchange_service", "fetch_balances"),
"build_market_snapshot": (".services.exchange_service", "build_market_snapshot"),
"market_and_ticker": (".services.exchange_service", "market_and_ticker"),
"floor_to_step": (".services.exchange_service", "floor_to_step"),
"prepare_buy_quantity": (".services.exchange_service", "prepare_buy_quantity"),
"prepare_sell_quantity": (".services.exchange_service", "prepare_sell_quantity"),
"build_decision_context": (".services.trade_execution", "build_decision_context"),
"market_sell": (".services.trade_execution", "market_sell"),
"market_buy": (".services.trade_execution", "market_buy"),
"action_sell_all": (".services.trade_execution", "action_sell_all"),
"action_buy": (".services.trade_execution", "action_buy"),
"action_rebalance": (".services.trade_execution", "action_rebalance"),
"command_status": (".services.trade_execution", "command_status"),
"command_balances": (".services.trade_execution", "command_balances"),
"command_orders": (".services.trade_execution", "command_orders"),
"command_order_status": (".services.trade_execution", "command_order_status"),
"command_cancel": (".services.trade_execution", "command_cancel"),
}
__all__ = sorted(set(_EXPORT_MAP) | {"ENV_FILE", "PATHS", "load_env", "main"})
def __getattr__(name: str):
if name == "PATHS":
runtime = import_module(".runtime", __package__)
return runtime.get_runtime_paths()
if name == "ENV_FILE":
runtime = import_module(".runtime", __package__)
return runtime.get_runtime_paths().env_file
if name == "load_env":
return load_env
if name not in _EXPORT_MAP:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr_name = _EXPORT_MAP[name]
module = import_module(module_name, __package__)
if name == "PATHS":
return getattr(module, attr_name)()
if name == "ENV_FILE":
return getattr(module, attr_name)().env_file
return getattr(module, attr_name)
def __dir__():
return sorted(set(globals()) | set(__all__))
def load_env():
runtime = import_module(".runtime", __package__)
runtime.load_env_file(runtime.get_runtime_paths())
def main(argv=None):
from .services.smart_executor_service import run as _run_service
return _run_service(sys.argv[1:] if argv is None else argv)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,95 @@
"""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 AccountMarketServicesTestCase(unittest.TestCase):
def test_get_balances_with_dust_flag(self):
config = {
"market": {"default_quote": "USDT"},
"trading": {"dust_usdt_threshold": 10.0},
}
payload = account_service.get_balances(
config,
spot_client=FakeSpotClient(),
)
balances = {item["asset"]: item for item in payload["balances"]}
self.assertFalse(balances["USDT"]["is_dust"])
self.assertFalse(balances["BTC"]["is_dust"])
self.assertTrue(balances["DOGE"]["is_dust"])
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"])

View File

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

View File

@@ -1,58 +1,140 @@
"""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("buy", help_text)
self.assertIn("sell", help_text)
self.assertIn("opportunity", help_text)
self.assertIn("--doc", 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,
"install_shell_completion",
return_value={"shell": "zsh", "installed": True, "path": "/tmp/ch/_coinhunter"},
),
patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
),
):
result = cli.main(["init", "--force"])
self.assertEqual(result, 0)
self.assertTrue(captured["payload"]["force"])
self.assertIn("completion", captured["payload"])
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"])
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())
class TestRunPythonModule:
def test_runs_module_main_and_returns_int(self):
result = run_python_module("commands.paths", [], "coinhunter paths")
assert result == 0
def test_buy_dispatches(self):
captured = {}
with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object(
cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}
), patch.object(
cli, "SpotBinanceClient"
), patch.object(
cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}}
), patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
):
result = cli.main(["buy", "BTCUSDT", "-Q", "100"])
self.assertEqual(result, 0)
self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN")
def test_mutates_sys_argv(self):
import sys
def test_sell_dispatches(self):
captured = {}
with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object(
cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}
), patch.object(
cli, "SpotBinanceClient"
), patch.object(
cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}}
), patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
):
result = cli.main(["sell", "BTCUSDT", "-q", "0.01"])
self.assertEqual(result, 0)
self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN")
original = sys.argv[:]
run_python_module("commands.paths", ["--help"], "coinhunter paths")
assert sys.argv == original
def test_doc_flag_prints_documentation(self):
import io
from unittest.mock import patch
stdout = io.StringIO()
with patch("sys.stdout", stdout):
result = cli.main(["market", "tickers", "--doc"])
self.assertEqual(result, 0)
output = stdout.getvalue()
self.assertIn("lastPrice", output)
self.assertIn("BTCUSDT", output)
def test_account_dispatches(self):
captured = {}
with (
patch.object(
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "trading": {"dust_usdt_threshold": 10.0}}
),
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
patch.object(cli, "SpotBinanceClient"),
patch.object(
cli.account_service, "get_balances", return_value={"balances": [{"asset": "BTC", "is_dust": False}]}
),
patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
),
):
result = cli.main(["account"])
self.assertEqual(result, 0)
self.assertEqual(captured["payload"]["balances"][0]["asset"], "BTC")
def test_upgrade_dispatches(self):
captured = {}
with (
patch.object(cli, "self_upgrade", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}),
patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
),
):
result = cli.main(["upgrade"])
self.assertEqual(result, 0)
self.assertEqual(captured["payload"]["returncode"], 0)
def test_catlog_dispatches(self):
captured = {}
with (
patch.object(
cli, "read_audit_log", return_value=[{"timestamp": "2026-04-17T12:00:00Z", "event": "test_event"}]
),
patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
),
):
result = cli.main(["catlog", "-n", "5", "-o", "10"])
self.assertEqual(result, 0)
self.assertEqual(captured["payload"]["limit"], 5)
self.assertEqual(captured["payload"]["offset"], 10)
self.assertIn("entries", captured["payload"])
self.assertEqual(captured["payload"]["total"], 1)

View File

@@ -0,0 +1,101 @@
"""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")),
self.assertRaisesRegex(RuntimeError, "Set COINHUNTER_HOME to a writable directory"),
):
ensure_init_files(paths)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

108
tests/test_trade_service.py Normal file
View File

@@ -0,0 +1,108 @@
"""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 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_spot_market_buy_requires_quote(self):
with (
patch.object(trade_service, "audit_event", return_value=None),
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),
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),
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(),
)