Compare commits
11 Commits
9224621d7e
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 69f447f538 | |||
| 1da08415f1 | |||
| 4312b16288 | |||
| cf26a3dd3a | |||
| e37993c8b5 | |||
| 3855477155 | |||
| d629c25232 | |||
| 4602583760 | |||
| ca0625b199 | |||
| a0e01ca56f | |||
| f528575aa8 |
@@ -20,8 +20,10 @@ CoinHunter V2 is a Binance-first crypto trading CLI with a flat, direct architec
|
||||
- **`src/coinhunter/services/`** — Contains all domain logic:
|
||||
- `account_service.py` — balances, positions, overview
|
||||
- `market_service.py` — tickers, klines, scan universe, symbol normalization
|
||||
- `signal_service.py` — shared market signal scoring used by scan and portfolio analysis
|
||||
- `portfolio_service.py` — held-position review and add/hold/trim/exit recommendations
|
||||
- `trade_service.py` — spot and USDT-M futures order execution
|
||||
- `opportunity_service.py` — portfolio recommendations and market scanning
|
||||
- `opportunity_service.py` — market scanning and entry/watch/skip recommendations
|
||||
- **`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`
|
||||
@@ -34,7 +36,7 @@ CoinHunter V2 is a Binance-first crypto trading CLI with a flat, direct architec
|
||||
|
||||
User data lives in `~/.coinhunter/` by default (override with `COINHUNTER_HOME`):
|
||||
|
||||
- `config.toml` — runtime, binance, trading, and opportunity settings
|
||||
- `config.toml` — runtime, binance, trading, signal, opportunity, and portfolio settings
|
||||
- `.env` — `BINANCE_API_KEY` and `BINANCE_API_SECRET`
|
||||
- `logs/audit_YYYYMMDD.jsonl` — structured audit log
|
||||
|
||||
|
||||
73
README.md
73
README.md
@@ -19,6 +19,14 @@
|
||||
|
||||
---
|
||||
|
||||
## What's New in 3.0
|
||||
|
||||
- **Split decision models** — portfolio (add/hold/trim/exit) and opportunity (enter/watch/skip) now use independent scoring logic.
|
||||
- **Configurable ticker windows** — `market tickers` supports `--window 1h`, `4h`, or `1d`.
|
||||
- **Live / dry-run audit logs** — audit logs are written to separate subdirectories; use `catlog --dry-run` to review simulations.
|
||||
- **Flattened commands** — `account`, `opportunity`, and `config` are now top-level for fewer keystrokes.
|
||||
- **Runtime config management** — `config get`, `config set`, and `config key/secret` let you edit settings without touching files manually.
|
||||
|
||||
## Install
|
||||
|
||||
For end users, install from PyPI with [pipx](https://pipx.pypa.io/) (recommended) to avoid polluting your system Python:
|
||||
@@ -61,6 +69,8 @@ This creates:
|
||||
|
||||
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.
|
||||
|
||||
`init` interactively prompts for your Binance API key and secret if they are missing. Use `--no-prompt` to skip this.
|
||||
|
||||
`config.toml` stores runtime and strategy settings. `.env` stores:
|
||||
|
||||
```bash
|
||||
@@ -68,6 +78,12 @@ BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
```
|
||||
|
||||
Strategy settings are split into three blocks:
|
||||
|
||||
- `[signal]` for shared market-signal weights and lookback interval
|
||||
- `[opportunity]` for scan thresholds, liquidity filters, and top-N output
|
||||
- `[portfolio]` for add/hold/trim/exit thresholds and max position weight
|
||||
|
||||
Override the default home directory with `COINHUNTER_HOME`.
|
||||
|
||||
## Commands
|
||||
@@ -85,16 +101,14 @@ coin market klines --doc
|
||||
|
||||
```bash
|
||||
# Account (aliases: a, acc)
|
||||
coinhunter account overview
|
||||
coinhunter account overview --agent
|
||||
coin a ov
|
||||
coin acc bal
|
||||
coin a pos
|
||||
coinhunter account
|
||||
coinhunter account --agent
|
||||
coin a
|
||||
|
||||
# Market (aliases: m)
|
||||
coinhunter market tickers BTCUSDT ETH/USDT sol-usdt
|
||||
coinhunter market tickers BTCUSDT ETH/USDT sol-usdt --window 1h
|
||||
coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50
|
||||
coin m tk BTCUSDT ETHUSDT
|
||||
coin m tk BTCUSDT ETHUSDT -w 1d
|
||||
coin m k BTCUSDT -i 1h -l 50
|
||||
|
||||
# Trade (buy / sell are now top-level commands)
|
||||
@@ -103,12 +117,34 @@ 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
|
||||
# Portfolio (aliases: pf, p)
|
||||
coinhunter portfolio
|
||||
coinhunter portfolio --agent
|
||||
coin pf
|
||||
|
||||
# Opportunity scanning (aliases: o)
|
||||
coinhunter opportunity
|
||||
coinhunter opportunity --symbols BTCUSDT ETHUSDT SOLUSDT
|
||||
coin o -s BTCUSDT ETHUSDT
|
||||
|
||||
# Audit log
|
||||
coinhunter catlog
|
||||
coinhunter catlog -n 20
|
||||
coinhunter catlog -n 10 -o 10
|
||||
coinhunter catlog --dry-run
|
||||
|
||||
# Configuration management (aliases: cfg, c)
|
||||
coinhunter config get # show all config
|
||||
coinhunter config get binance.recv_window
|
||||
coinhunter config set opportunity.top_n 20
|
||||
coinhunter config set signal.lookback_interval 4h
|
||||
coinhunter config set portfolio.max_position_weight 0.25
|
||||
coinhunter config set trading.dry_run_default true
|
||||
coinhunter config set market.universe_allowlist BTCUSDT,ETHUSDT
|
||||
coinhunter config key YOUR_API_KEY # or omit value to prompt interactively
|
||||
coinhunter config secret YOUR_SECRET # or omit value to prompt interactively
|
||||
coin c get opportunity.top_n
|
||||
coin c set trading.dry_run_default false
|
||||
|
||||
# Self-upgrade
|
||||
coinhunter upgrade
|
||||
@@ -129,7 +165,7 @@ CoinHunter V2 uses a flat, direct architecture:
|
||||
|-------|----------------|-----------|
|
||||
| **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` |
|
||||
| **Services** | Domain logic | `services/account_service.py`, `services/market_service.py`, `services/signal_service.py`, `services/opportunity_service.py`, `services/portfolio_service.py`, `services/trade_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` |
|
||||
@@ -150,6 +186,8 @@ Events include:
|
||||
- `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
|
||||
|
||||
Clone the repo and install in editable mode:
|
||||
@@ -160,6 +198,13 @@ cd coinhunter-cli
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Or use the provided Conda environment:
|
||||
|
||||
```bash
|
||||
conda env create -f environment.yml
|
||||
conda activate coinhunter
|
||||
```
|
||||
|
||||
Run quality checks:
|
||||
|
||||
```bash
|
||||
|
||||
9
environment.yml
Normal file
9
environment.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: coinhunter
|
||||
channels:
|
||||
- defaults
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- python>=3.10
|
||||
- pip
|
||||
- pip:
|
||||
- -e ".[dev]"
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "coinhunter"
|
||||
version = "2.1.0"
|
||||
version = "3.0.0"
|
||||
description = "Binance-first trading CLI for balances, market data, opportunity scanning, and execution."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
@@ -13,9 +13,7 @@ dependencies = [
|
||||
"binance-connector>=3.9.0",
|
||||
"shtab>=1.7.0",
|
||||
"tomli>=2.0.1; python_version < '3.11'",
|
||||
]
|
||||
authors = [
|
||||
{name = "Tacit Lab", email = "ouyangcarlos@gmail.com"}
|
||||
"tomli-w>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -37,13 +35,10 @@ where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 120
|
||||
addopts = "-v"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "W"]
|
||||
select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
@@ -52,7 +47,5 @@ convention = "google"
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
warn_unused_ignores = true
|
||||
ignore_missing_imports = true
|
||||
exclude = [".venv", "build"]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -21,19 +22,57 @@ def _resolve_audit_dir(paths: RuntimePaths) -> Path:
|
||||
return _audit_dir_cache[key]
|
||||
|
||||
|
||||
def _audit_path(paths: RuntimePaths | None = None) -> Path:
|
||||
def _audit_path(paths: RuntimePaths | None = None, *, dry_run: bool = False) -> 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"
|
||||
subdir = logs_dir / ("dryrun" if dry_run else "live")
|
||||
subdir.mkdir(parents=True, exist_ok=True)
|
||||
return subdir / 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]:
|
||||
def audit_event(
|
||||
event: str, payload: dict[str, Any], paths: RuntimePaths | None = None, *, dry_run: bool = False
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"event": event,
|
||||
**payload,
|
||||
}
|
||||
with _audit_path(paths).open("a", encoding="utf-8") as handle:
|
||||
with _audit_path(paths, dry_run=dry_run).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, *, dry_run: bool = False
|
||||
) -> 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 []
|
||||
subdir = logs_dir / ("dryrun" if dry_run else "live")
|
||||
if not subdir.exists():
|
||||
return []
|
||||
audit_files = sorted(subdir.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]
|
||||
|
||||
@@ -5,7 +5,10 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from requests.exceptions import RequestException, SSLError
|
||||
from requests.exceptions import (
|
||||
RequestException,
|
||||
SSLError,
|
||||
)
|
||||
|
||||
|
||||
class SpotBinanceClient:
|
||||
@@ -49,14 +52,15 @@ class SpotBinanceClient:
|
||||
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])
|
||||
def ticker_stats(self, symbols: list[str] | None = None, *, window: str = "1d") -> list[dict[str, Any]]:
|
||||
kwargs: dict[str, Any] = {"windowSize": window}
|
||||
if symbols:
|
||||
if len(symbols) == 1:
|
||||
kwargs["symbol"] = symbols[0]
|
||||
else:
|
||||
response = self._call("24h ticker", self._client.ticker_24hr, symbols=symbols)
|
||||
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
|
||||
kwargs["symbols"] = symbols
|
||||
response = self._call("ticker stats", self._client.ticker, **kwargs)
|
||||
return response if isinstance(response, list) else [response]
|
||||
|
||||
def ticker_price(self, symbols: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
if not symbols:
|
||||
@@ -65,7 +69,7 @@ class SpotBinanceClient:
|
||||
response = self._call("ticker price", self._client.ticker_price, symbol=symbols[0])
|
||||
else:
|
||||
response = self._call("ticker price", self._client.ticker_price, symbols=symbols)
|
||||
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
|
||||
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]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,11 @@ try:
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
import tomli as tomllib
|
||||
|
||||
try:
|
||||
import tomli_w
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
tomli_w = None # type: ignore[assignment]
|
||||
|
||||
|
||||
DEFAULT_CONFIG = """[runtime]
|
||||
timezone = "Asia/Shanghai"
|
||||
@@ -33,20 +38,29 @@ 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]
|
||||
[signal]
|
||||
lookback_interval = "1h"
|
||||
trend = 1.0
|
||||
momentum = 1.0
|
||||
breakout = 0.8
|
||||
volume = 0.7
|
||||
volatility_penalty = 0.5
|
||||
position_concentration_penalty = 0.6
|
||||
|
||||
[opportunity]
|
||||
min_quote_volume = 1000000.0
|
||||
top_n = 10
|
||||
scan_limit = 50
|
||||
ignore_dust = true
|
||||
entry_threshold = 1.5
|
||||
watch_threshold = 0.6
|
||||
overlap_penalty = 0.6
|
||||
|
||||
[portfolio]
|
||||
add_threshold = 1.5
|
||||
hold_threshold = 0.6
|
||||
trim_threshold = 0.2
|
||||
exit_threshold = -0.2
|
||||
max_position_weight = 0.6
|
||||
"""
|
||||
|
||||
DEFAULT_ENV = "BINANCE_API_KEY=\nBINANCE_API_SECRET=\n"
|
||||
@@ -128,3 +142,72 @@ def resolve_log_dir(config: dict[str, Any], paths: RuntimePaths | None = None) -
|
||||
raw = config.get("runtime", {}).get("log_dir", "logs")
|
||||
value = Path(raw).expanduser()
|
||||
return value if value.is_absolute() else paths.root / value
|
||||
|
||||
|
||||
def get_config_value(config: dict[str, Any], key_path: str) -> Any:
|
||||
keys = key_path.split(".")
|
||||
node = config
|
||||
for key in keys:
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
return None
|
||||
node = node[key]
|
||||
return node
|
||||
|
||||
|
||||
def set_config_value(config_file: Path, key_path: str, value: Any) -> None:
|
||||
if tomli_w is None:
|
||||
raise RuntimeError("tomli-w is not installed. Run `pip install tomli-w`.")
|
||||
if not config_file.exists():
|
||||
raise RuntimeError(f"Config file not found: {config_file}")
|
||||
config = tomllib.loads(config_file.read_text(encoding="utf-8"))
|
||||
keys = key_path.split(".")
|
||||
node = config
|
||||
for key in keys[:-1]:
|
||||
if key not in node:
|
||||
node[key] = {}
|
||||
node = node[key]
|
||||
|
||||
# Coerce type from existing value when possible
|
||||
existing = node.get(keys[-1])
|
||||
if isinstance(existing, bool) and isinstance(value, str):
|
||||
value = value.lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(existing, (int, float)) and isinstance(value, str):
|
||||
try:
|
||||
value = type(existing)(value)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Cannot set {key_path} to {value!r}: expected {type(existing).__name__}, got {value!r}"
|
||||
) from exc
|
||||
elif isinstance(existing, list) and isinstance(value, str):
|
||||
value = [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
node[keys[-1]] = value
|
||||
config_file.write_text(tomli_w.dumps(config), encoding="utf-8")
|
||||
|
||||
|
||||
def get_env_value(paths: RuntimePaths | None = None, key: str = "") -> str:
|
||||
paths = paths or get_runtime_paths()
|
||||
if not paths.env_file.exists():
|
||||
return ""
|
||||
env_data = load_env_file(paths)
|
||||
return env_data.get(key, "")
|
||||
|
||||
|
||||
def set_env_value(paths: RuntimePaths | None = None, key: str = "", value: str = "") -> None:
|
||||
paths = paths or get_runtime_paths()
|
||||
if not paths.env_file.exists():
|
||||
raise RuntimeError(f"Env file not found: {paths.env_file}. Run `coin init` first.")
|
||||
|
||||
lines = paths.env_file.read_text(encoding="utf-8").splitlines()
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(f"{key}=") or stripped.startswith(f"{key} ="):
|
||||
lines[i] = f"{key}={value}"
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
lines.append(f"{key}={value}")
|
||||
|
||||
paths.env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
os.environ[key] = value
|
||||
|
||||
@@ -126,6 +126,24 @@ def _fmt_number(value: Any) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _fmt_local_ts(ts: str) -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
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():
|
||||
@@ -195,39 +213,27 @@ def _render_tui(payload: Any) -> None:
|
||||
print(str(payload))
|
||||
return
|
||||
|
||||
if "overview" in payload:
|
||||
overview = payload.get("overview", {})
|
||||
print(f"\n{_BOLD}{_CYAN} ACCOUNT OVERVIEW {_RESET}")
|
||||
print(f" Total Equity: {_GREEN}{_fmt_number(overview.get('total_equity_usdt', 0))} USDT{_RESET}")
|
||||
print(f" Spot Assets: {_fmt_number(overview.get('spot_asset_count', 0))}")
|
||||
print(f" Positions: {_fmt_number(overview.get('spot_position_count', 0))}")
|
||||
if payload.get("balances"):
|
||||
print()
|
||||
_render_tui({"balances": payload["balances"]})
|
||||
if payload.get("positions"):
|
||||
print()
|
||||
_render_tui({"positions": payload["positions"]})
|
||||
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("market_type", ""),
|
||||
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",
|
||||
["Market", "Asset", "Free", "Locked", "Total", "Notional (USDT)"],
|
||||
["Asset", "Free", "Locked", "Total", "Notional (USDT)", ""],
|
||||
table_rows,
|
||||
aligns=["left", "left", "right", "right", "right", "right"],
|
||||
aligns=["left", "right", "right", "right", "right", "left"],
|
||||
)
|
||||
return
|
||||
|
||||
@@ -272,7 +278,7 @@ def _render_tui(payload: Any) -> None:
|
||||
]
|
||||
)
|
||||
_print_box_table(
|
||||
"24H TICKERS",
|
||||
f"TICKERS window={payload.get('window', '1d')}",
|
||||
["Symbol", "Last Price", "Change %", "Quote Volume"],
|
||||
table_rows,
|
||||
aligns=["left", "right", "right", "right"],
|
||||
@@ -281,7 +287,9 @@ def _render_tui(payload: Any) -> None:
|
||||
|
||||
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)}")
|
||||
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:
|
||||
@@ -325,8 +333,18 @@ def _render_tui(payload: Any) -> None:
|
||||
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}")
|
||||
action_color = (
|
||||
_GREEN
|
||||
if action in {"add", "enter"}
|
||||
else _YELLOW
|
||||
if action in {"hold", "watch", "review"}
|
||||
else _RED
|
||||
if action in {"exit", "skip", "trim"}
|
||||
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", {})
|
||||
@@ -340,9 +358,9 @@ def _render_tui(payload: Any) -> None:
|
||||
stdout = payload.get("stdout", "")
|
||||
stderr = payload.get("stderr", "")
|
||||
if rc == 0:
|
||||
print(f"\n{_GREEN}✓{_RESET} Update completed")
|
||||
print(f"{_GREEN}✓{_RESET} Update completed")
|
||||
else:
|
||||
print(f"\n{_RED}✗{_RESET} Update failed (exit code {rc})")
|
||||
print(f"{_RED}✗{_RESET} Update failed (exit code {rc})")
|
||||
if stdout:
|
||||
for line in stdout.strip().splitlines():
|
||||
print(f" {line}")
|
||||
@@ -352,6 +370,29 @@ def _render_tui(payload: Any) -> None:
|
||||
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', '')}")
|
||||
@@ -391,9 +432,6 @@ def _render_tui(payload: Any) -> None:
|
||||
|
||||
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)
|
||||
@@ -474,6 +512,13 @@ def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]:
|
||||
return {"shell": None, "installed": False, "reason": "unable to detect shell from $SHELL"}
|
||||
|
||||
script = shtab.complete(parser, shell=shell, preamble="")
|
||||
# Also register completion for the "coinhunter" alias
|
||||
prog = parser.prog.replace("-", "_")
|
||||
func = f"_shtab_{prog}"
|
||||
if shell == "bash":
|
||||
script += f"\ncomplete -o filenames -F {func} coinhunter\n"
|
||||
elif shell == "zsh":
|
||||
script += f"\ncompdef {func} coinhunter\n"
|
||||
installed_path: Path | None = None
|
||||
hint: str | None = None
|
||||
|
||||
@@ -485,7 +530,10 @@ def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]:
|
||||
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")
|
||||
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"
|
||||
@@ -497,7 +545,10 @@ def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]:
|
||||
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")
|
||||
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"
|
||||
|
||||
@@ -13,6 +13,7 @@ class AssetBalance:
|
||||
locked: float
|
||||
total: float
|
||||
notional_usdt: float
|
||||
is_dust: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -59,6 +60,7 @@ def get_balances(
|
||||
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:
|
||||
@@ -68,6 +70,7 @@ def get_balances(
|
||||
if total <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
notional = total * price_map.get(asset, 0.0)
|
||||
rows.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
@@ -75,7 +78,8 @@ def get_balances(
|
||||
free=free,
|
||||
locked=locked,
|
||||
total=total,
|
||||
notional_usdt=total * price_map.get(asset, 0.0),
|
||||
notional_usdt=notional,
|
||||
is_dust=notional < dust,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -113,60 +117,3 @@ def get_positions(
|
||||
)
|
||||
)
|
||||
return {"positions": rows}
|
||||
|
||||
|
||||
def get_overview(
|
||||
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))
|
||||
balances: list[dict[str, Any]] = []
|
||||
positions: list[dict[str, Any]] = []
|
||||
|
||||
spot_balances, _, price_map = _spot_account_data(spot_client, quote)
|
||||
for item in spot_balances:
|
||||
free = float(item.get("free", 0.0))
|
||||
locked = float(item.get("locked", 0.0))
|
||||
total = free + locked
|
||||
if total <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
balances.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
asset=asset,
|
||||
free=free,
|
||||
locked=locked,
|
||||
total=total,
|
||||
notional_usdt=total * price_map.get(asset, 0.0),
|
||||
)
|
||||
)
|
||||
)
|
||||
mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0)
|
||||
notional = total * mark_price
|
||||
if notional >= dust:
|
||||
positions.append(
|
||||
asdict(
|
||||
PositionView(
|
||||
symbol=quote if asset == quote else f"{asset}{quote}",
|
||||
quantity=total,
|
||||
entry_price=None,
|
||||
mark_price=mark_price,
|
||||
notional_usdt=notional,
|
||||
side="LONG",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
spot_equity = sum(item["notional_usdt"] for item in balances)
|
||||
overview = asdict(
|
||||
AccountOverview(
|
||||
total_equity_usdt=spot_equity,
|
||||
spot_equity_usdt=spot_equity,
|
||||
spot_asset_count=len(balances),
|
||||
spot_position_count=len(positions),
|
||||
)
|
||||
)
|
||||
return {"overview": overview, "balances": balances, "positions": positions}
|
||||
|
||||
@@ -48,21 +48,23 @@ class KlineView:
|
||||
quote_volume: float
|
||||
|
||||
|
||||
def get_tickers(config: dict[str, Any], symbols: list[str], *, spot_client: Any) -> dict[str, Any]:
|
||||
def get_tickers(config: dict[str, Any], symbols: list[str], *, spot_client: Any, window: str = "1d") -> dict[str, Any]:
|
||||
normalized = normalize_symbols(symbols)
|
||||
rows = []
|
||||
for ticker in spot_client.ticker_24h(normalized):
|
||||
for ticker in spot_client.ticker_stats(normalized, window=window):
|
||||
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),
|
||||
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}
|
||||
return {"tickers": rows, "window": window}
|
||||
|
||||
|
||||
def get_klines(
|
||||
@@ -101,6 +103,7 @@ def get_scan_universe(
|
||||
*,
|
||||
spot_client: Any,
|
||||
symbols: list[str] | None = None,
|
||||
window: str = "1d",
|
||||
) -> list[dict[str, Any]]:
|
||||
market_config = config.get("market", {})
|
||||
opportunity_config = config.get("opportunity", {})
|
||||
@@ -114,7 +117,7 @@ def get_scan_universe(
|
||||
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):
|
||||
for ticker in spot_client.ticker_stats(list(requested) if requested else None, window=window):
|
||||
symbol = normalize_symbol(ticker["symbol"])
|
||||
if not symbol.endswith(quote):
|
||||
continue
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Opportunity analysis services."""
|
||||
"""Opportunity scanning 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
|
||||
from .signal_service import get_signal_interval, get_signal_weights, score_market_signal
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -20,130 +20,25 @@ class OpportunityRecommendation:
|
||||
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),
|
||||
def _opportunity_thresholds(config: dict[str, Any]) -> dict[str, float]:
|
||||
opportunity_config = config.get("opportunity", {})
|
||||
return {
|
||||
"entry_threshold": float(opportunity_config.get("entry_threshold", 1.5)),
|
||||
"watch_threshold": float(opportunity_config.get("watch_threshold", 0.6)),
|
||||
"overlap_penalty": float(opportunity_config.get("overlap_penalty", 0.6)),
|
||||
}
|
||||
|
||||
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]]:
|
||||
def _action_for_opportunity(score: float, thresholds: dict[str, 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
|
||||
if score >= thresholds["entry_threshold"]:
|
||||
reasons.append("trend, momentum, and breakout are aligned for a fresh entry")
|
||||
return "enter", reasons
|
||||
if score >= thresholds["watch_threshold"]:
|
||||
reasons.append("market structure is constructive but still needs confirmation")
|
||||
return "watch", reasons
|
||||
reasons.append("edge is too weak for a new entry")
|
||||
return "skip", reasons
|
||||
|
||||
|
||||
def scan_opportunities(
|
||||
@@ -153,29 +48,33 @@ def scan_opportunities(
|
||||
symbols: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
opportunity_config = config.get("opportunity", {})
|
||||
weights = opportunity_config.get("weights", {})
|
||||
signal_weights = get_signal_weights(config)
|
||||
interval = get_signal_interval(config)
|
||||
thresholds = _opportunity_thresholds(config)
|
||||
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
|
||||
}
|
||||
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)
|
||||
klines = spot_client.klines(symbol=symbol, interval=interval, 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)
|
||||
signal_score, metrics = score_market_signal(closes, volumes, ticker, signal_weights)
|
||||
score = signal_score - thresholds["overlap_penalty"] * concentration
|
||||
action, reasons = _action_for_opportunity(score, thresholds)
|
||||
metrics["signal_score"] = round(signal_score, 4)
|
||||
metrics["position_weight"] = round(concentration, 4)
|
||||
if symbol.endswith(quote):
|
||||
reasons.append(f"base asset {base_asset(symbol, quote)} passed liquidity and tradability filters")
|
||||
if concentration > 0:
|
||||
reasons.append("symbol is already held, so the opportunity score is discounted for overlap")
|
||||
recommendations.append(
|
||||
asdict(
|
||||
OpportunityRecommendation(
|
||||
|
||||
109
src/coinhunter/services/portfolio_service.py
Normal file
109
src/coinhunter/services/portfolio_service.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Portfolio analysis and position management signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..audit import audit_event
|
||||
from .account_service import get_positions
|
||||
from .market_service import normalize_symbol
|
||||
from .signal_service import get_signal_interval, get_signal_weights, score_market_signal
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortfolioRecommendation:
|
||||
symbol: str
|
||||
action: str
|
||||
score: float
|
||||
reasons: list[str]
|
||||
metrics: dict[str, float]
|
||||
|
||||
|
||||
def _portfolio_thresholds(config: dict[str, Any]) -> dict[str, float]:
|
||||
portfolio_config = config.get("portfolio", {})
|
||||
return {
|
||||
"add_threshold": float(portfolio_config.get("add_threshold", 1.5)),
|
||||
"hold_threshold": float(portfolio_config.get("hold_threshold", 0.6)),
|
||||
"trim_threshold": float(portfolio_config.get("trim_threshold", 0.2)),
|
||||
"exit_threshold": float(portfolio_config.get("exit_threshold", -0.2)),
|
||||
"max_position_weight": float(portfolio_config.get("max_position_weight", 0.6)),
|
||||
}
|
||||
|
||||
|
||||
def _action_for_position(score: float, concentration: float, thresholds: dict[str, float]) -> tuple[str, list[str]]:
|
||||
reasons: list[str] = []
|
||||
max_weight = thresholds["max_position_weight"]
|
||||
if concentration >= max_weight and score < thresholds["hold_threshold"]:
|
||||
reasons.append("position weight is above the portfolio risk budget")
|
||||
return "trim", reasons
|
||||
if score >= thresholds["add_threshold"] and concentration < max_weight:
|
||||
reasons.append("market signal is strong and position still has room")
|
||||
return "add", reasons
|
||||
if score >= thresholds["hold_threshold"]:
|
||||
reasons.append("market structure remains supportive for holding")
|
||||
return "hold", reasons
|
||||
if score <= thresholds["exit_threshold"]:
|
||||
reasons.append("market signal has weakened enough to justify an exit review")
|
||||
return "exit", reasons
|
||||
if score <= thresholds["trim_threshold"]:
|
||||
reasons.append("edge has faded and the position should be reduced")
|
||||
return "trim", reasons
|
||||
reasons.append("signal is mixed and the position needs review")
|
||||
return "review", reasons
|
||||
|
||||
|
||||
def analyze_portfolio(config: dict[str, Any], *, spot_client: Any) -> dict[str, Any]:
|
||||
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
||||
signal_weights = get_signal_weights(config)
|
||||
interval = get_signal_interval(config)
|
||||
thresholds = _portfolio_thresholds(config)
|
||||
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=interval, limit=24)
|
||||
closes = [float(item[4]) for item in klines]
|
||||
volumes = [float(item[5]) for item in klines]
|
||||
tickers = spot_client.ticker_stats([symbol], window="1d")
|
||||
ticker = tickers[0] if tickers else {"priceChangePercent": "0"}
|
||||
concentration = position["notional_usdt"] / total_notional
|
||||
score, metrics = score_market_signal(
|
||||
closes,
|
||||
volumes,
|
||||
{"price_change_pct": float(ticker.get("priceChangePercent") or 0.0)},
|
||||
signal_weights,
|
||||
)
|
||||
action, reasons = _action_for_position(score, concentration, thresholds)
|
||||
metrics["position_weight"] = round(concentration, 4)
|
||||
recommendations.append(
|
||||
asdict(
|
||||
PortfolioRecommendation(
|
||||
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
|
||||
78
src/coinhunter/services/signal_service.py
Normal file
78
src/coinhunter/services/signal_service.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Shared market signal scoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from statistics import mean
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _safe_pct(new: float, old: float) -> float:
|
||||
if old == 0:
|
||||
return 0.0
|
||||
return (new - old) / old
|
||||
|
||||
|
||||
def get_signal_weights(config: dict[str, Any]) -> dict[str, float]:
|
||||
signal_config = config.get("signal", {})
|
||||
return {
|
||||
"trend": float(signal_config.get("trend", 1.0)),
|
||||
"momentum": float(signal_config.get("momentum", 1.0)),
|
||||
"breakout": float(signal_config.get("breakout", 0.8)),
|
||||
"volume": float(signal_config.get("volume", 0.7)),
|
||||
"volatility_penalty": float(signal_config.get("volatility_penalty", 0.5)),
|
||||
}
|
||||
|
||||
|
||||
def get_signal_interval(config: dict[str, Any]) -> str:
|
||||
signal_config = config.get("signal", {})
|
||||
if signal_config.get("lookback_interval"):
|
||||
return str(signal_config["lookback_interval"])
|
||||
return "1h"
|
||||
|
||||
|
||||
def score_market_signal(
|
||||
closes: list[float],
|
||||
volumes: list[float],
|
||||
ticker: dict[str, Any],
|
||||
weights: dict[str, 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,
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
metrics = {
|
||||
"trend": round(trend, 4),
|
||||
"momentum": round(momentum, 4),
|
||||
"breakout": round(breakout, 4),
|
||||
"volume_confirmation": round(volume_confirmation, 4),
|
||||
"volatility": round(volatility, 4),
|
||||
}
|
||||
return score, metrics
|
||||
@@ -40,7 +40,9 @@ def _default_dry_run(config: dict[str, Any], dry_run: bool | None) -> bool:
|
||||
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]:
|
||||
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,
|
||||
@@ -110,7 +112,7 @@ def execute_spot_trade(
|
||||
dry_run=is_dry_run,
|
||||
)
|
||||
|
||||
audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"))
|
||||
audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"), dry_run=intent.dry_run)
|
||||
if is_dry_run:
|
||||
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
|
||||
result = asdict(
|
||||
@@ -125,13 +127,15 @@ def execute_spot_trade(
|
||||
response_payload=response,
|
||||
)
|
||||
)
|
||||
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response})
|
||||
audit_event(
|
||||
"trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response}, dry_run=intent.dry_run
|
||||
)
|
||||
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)))
|
||||
audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc)), dry_run=intent.dry_run)
|
||||
raise RuntimeError(f"Spot order failed: {exc}") from exc
|
||||
|
||||
result = asdict(
|
||||
@@ -146,5 +150,8 @@ def execute_spot_trade(
|
||||
response_payload=response,
|
||||
)
|
||||
)
|
||||
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response})
|
||||
audit_event(
|
||||
"trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response},
|
||||
dry_run=intent.dry_run,
|
||||
)
|
||||
return {"trade": result}
|
||||
|
||||
@@ -26,11 +26,32 @@ class FakeSpotClient:
|
||||
return list(prices.values())
|
||||
return [prices[symbol] for symbol in symbols]
|
||||
|
||||
def ticker_24h(self, symbols=None):
|
||||
def ticker_stats(self, symbols=None, *, window="1d"):
|
||||
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"},
|
||||
{
|
||||
"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
|
||||
@@ -38,23 +59,29 @@ class FakeSpotClient:
|
||||
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"}]}
|
||||
return {
|
||||
"symbols": [
|
||||
{"symbol": "BTCUSDT", "status": "TRADING"},
|
||||
{"symbol": "ETHUSDT", "status": "TRADING"},
|
||||
{"symbol": "DOGEUSDT", "status": "BREAK"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class AccountMarketServicesTestCase(unittest.TestCase):
|
||||
def test_account_overview_and_dust_filter(self):
|
||||
def test_get_balances_with_dust_flag(self):
|
||||
config = {
|
||||
"market": {"default_quote": "USDT"},
|
||||
"trading": {"dust_usdt_threshold": 10.0},
|
||||
}
|
||||
payload = account_service.get_overview(
|
||||
payload = account_service.get_balances(
|
||||
config,
|
||||
spot_client=FakeSpotClient(),
|
||||
)
|
||||
self.assertEqual(payload["overview"]["total_equity_usdt"], 720.1)
|
||||
symbols = {item["symbol"] for item in payload["positions"]}
|
||||
self.assertNotIn("DOGEUSDT", symbols)
|
||||
self.assertIn("BTCUSDT", symbols)
|
||||
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 = {
|
||||
|
||||
@@ -17,15 +17,22 @@ class CLITestCase(unittest.TestCase):
|
||||
self.assertIn("account", help_text)
|
||||
self.assertIn("buy", help_text)
|
||||
self.assertIn("sell", help_text)
|
||||
self.assertIn("portfolio", help_text)
|
||||
self.assertIn("opportunity", help_text)
|
||||
self.assertIn("--doc", help_text)
|
||||
|
||||
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(
|
||||
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)
|
||||
@@ -73,23 +80,171 @@ class CLITestCase(unittest.TestCase):
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN")
|
||||
|
||||
def test_doc_flag_prints_documentation(self):
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_doc_flag_prints_tui_documentation(self):
|
||||
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("TUI Output", output)
|
||||
self.assertIn("Last Price", output)
|
||||
self.assertIn("BTCUSDT", output)
|
||||
|
||||
def test_doc_flag_prints_json_documentation(self):
|
||||
stdout = io.StringIO()
|
||||
with patch("sys.stdout", stdout):
|
||||
result = cli.main(["market", "tickers", "--doc", "--agent"])
|
||||
self.assertEqual(result, 0)
|
||||
output = stdout.getvalue()
|
||||
self.assertIn("JSON Output", output)
|
||||
self.assertIn("last_price", 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(
|
||||
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_portfolio_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}}
|
||||
),
|
||||
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||
patch.object(cli, "SpotBinanceClient"),
|
||||
patch.object(
|
||||
cli.portfolio_service, "analyze_portfolio", return_value={"recommendations": [{"symbol": "BTCUSDT", "score": 0.75}]}
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["portfolio"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["recommendations"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
def test_opportunity_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}}
|
||||
),
|
||||
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||
patch.object(cli, "SpotBinanceClient"),
|
||||
patch.object(
|
||||
cli.opportunity_service,
|
||||
"scan_opportunities",
|
||||
return_value={"recommendations": [{"symbol": "BTCUSDT", "score": 0.82}]},
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["opportunity", "-s", "BTCUSDT", "ETHUSDT"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["recommendations"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
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)
|
||||
|
||||
def test_config_get_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(cli, "load_config", return_value={"binance": {"recv_window": 5000}}),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["config", "get", "binance.recv_window"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["binance.recv_window"], 5000)
|
||||
|
||||
def test_config_set_dispatches(self):
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||
f.write('[binance]\nrecv_window = 5000\n')
|
||||
tmp_path = f.name
|
||||
|
||||
with patch.object(cli, "get_runtime_paths") as mock_paths:
|
||||
mock_paths.return_value.config_file = __import__("pathlib").Path(tmp_path)
|
||||
result = cli.main(["config", "set", "binance.recv_window", "10000"])
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
# Verify the file was updated
|
||||
content = __import__("pathlib").Path(tmp_path).read_text()
|
||||
self.assertIn("recv_window = 10000", content)
|
||||
__import__("os").unlink(tmp_path)
|
||||
|
||||
def test_config_key_dispatches(self):
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
||||
f.write("BINANCE_API_KEY=\n")
|
||||
tmp_path = f.name
|
||||
|
||||
with patch.object(cli, "get_runtime_paths") as mock_paths:
|
||||
mock_paths.return_value.env_file = __import__("pathlib").Path(tmp_path)
|
||||
result = cli.main(["config", "key", "test_key_value"])
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
content = __import__("pathlib").Path(tmp_path).read_text()
|
||||
self.assertIn("BINANCE_API_KEY=test_key_value", content)
|
||||
__import__("os").unlink(tmp_path)
|
||||
|
||||
def test_config_secret_dispatches(self):
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
||||
f.write("BINANCE_API_SECRET=\n")
|
||||
tmp_path = f.name
|
||||
|
||||
with patch.object(cli, "get_runtime_paths") as mock_paths:
|
||||
mock_paths.return_value.env_file = __import__("pathlib").Path(tmp_path)
|
||||
result = cli.main(["config", "secret", "test_secret_value"])
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
content = __import__("pathlib").Path(tmp_path).read_text()
|
||||
self.assertIn("BINANCE_API_SECRET=test_secret_value", content)
|
||||
__import__("os").unlink(tmp_path)
|
||||
|
||||
@@ -8,13 +8,21 @@ 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.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):
|
||||
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())
|
||||
@@ -23,10 +31,13 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
self.assertEqual(payload["root"], str(paths.root))
|
||||
|
||||
def test_load_config_and_env(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
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)
|
||||
@@ -40,10 +51,13 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
self.assertEqual(os.environ["BINANCE_API_SECRET"], "def")
|
||||
|
||||
def test_env_file_overrides_existing_environment(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
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)
|
||||
@@ -55,10 +69,13 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
self.assertEqual(os.environ["BINANCE_API_SECRET"], "new_secret")
|
||||
|
||||
def test_missing_credentials_raise(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
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)
|
||||
@@ -68,12 +85,17 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
get_binance_credentials(paths)
|
||||
|
||||
def test_permission_error_is_explained(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp_dir,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = get_runtime_paths()
|
||||
with patch("coinhunter.config.ensure_runtime_dirs", side_effect=PermissionError("no write access")):
|
||||
with self.assertRaisesRegex(RuntimeError, "Set COINHUNTER_HOME to a writable directory"):
|
||||
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)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Opportunity service tests."""
|
||||
"""Signal, opportunity, and portfolio service tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from coinhunter.services import opportunity_service
|
||||
from coinhunter.services import opportunity_service, portfolio_service, signal_service
|
||||
|
||||
|
||||
class FakeSpotClient:
|
||||
@@ -27,19 +27,54 @@ class FakeSpotClient:
|
||||
}
|
||||
return [mapping[symbol] for symbol in symbols]
|
||||
|
||||
def ticker_24h(self, symbols=None):
|
||||
def ticker_stats(self, symbols=None, *, window="1d"):
|
||||
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"},
|
||||
"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"}]}
|
||||
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 = {
|
||||
@@ -50,7 +85,18 @@ class FakeSpotClient:
|
||||
}[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)])
|
||||
rows.append(
|
||||
[
|
||||
index,
|
||||
close * 0.98,
|
||||
close * 1.01,
|
||||
close * 0.97,
|
||||
close,
|
||||
100 + index * 10,
|
||||
index + 1,
|
||||
close * (100 + index * 10),
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -59,36 +105,51 @@ class OpportunityServiceTestCase(unittest.TestCase):
|
||||
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": {
|
||||
"signal": {
|
||||
"lookback_interval": "1h",
|
||||
"trend": 1.0,
|
||||
"momentum": 1.0,
|
||||
"breakout": 0.8,
|
||||
"volume": 0.7,
|
||||
"volatility_penalty": 0.5,
|
||||
"position_concentration_penalty": 0.6,
|
||||
},
|
||||
"opportunity": {
|
||||
"scan_limit": 10,
|
||||
"top_n": 5,
|
||||
"min_quote_volume": 1000.0,
|
||||
"entry_threshold": 1.5,
|
||||
"watch_threshold": 0.6,
|
||||
"overlap_penalty": 0.6,
|
||||
},
|
||||
"portfolio": {
|
||||
"add_threshold": 1.5,
|
||||
"hold_threshold": 0.6,
|
||||
"trim_threshold": 0.2,
|
||||
"exit_threshold": -0.2,
|
||||
"max_position_weight": 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())
|
||||
with patch.object(portfolio_service, "audit_event", side_effect=lambda event, payload, **kwargs: events.append(event)):
|
||||
payload = portfolio_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(payload["recommendations"][0]["action"], "add")
|
||||
self.assertEqual(payload["recommendations"][1]["action"], "hold")
|
||||
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())
|
||||
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"])
|
||||
self.assertEqual([item["action"] for item in payload["recommendations"]], ["enter", "enter"])
|
||||
|
||||
def test_score_candidate_handles_empty_klines(self):
|
||||
score, metrics = opportunity_service._score_candidate([], [], {"price_change_pct": 1.0}, {}, 0.0)
|
||||
def test_signal_score_handles_empty_klines(self):
|
||||
score, metrics = signal_service.score_market_signal([], [], {"price_change_pct": 1.0}, {})
|
||||
self.assertEqual(score, 0.0)
|
||||
self.assertEqual(metrics["trend"], 0.0)
|
||||
|
||||
@@ -20,7 +20,9 @@ class FakeSpotClient:
|
||||
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))):
|
||||
with patch.object(
|
||||
trade_service, "audit_event", side_effect=lambda event, payload, **kwargs: events.append((event, payload))
|
||||
):
|
||||
client = FakeSpotClient()
|
||||
payload = trade_service.execute_spot_trade(
|
||||
{"trading": {"dry_run_default": False}},
|
||||
@@ -55,8 +57,10 @@ class TradeServiceTestCase(unittest.TestCase):
|
||||
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):
|
||||
with self.assertRaisesRegex(RuntimeError, "requires --quote"):
|
||||
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",
|
||||
@@ -70,8 +74,10 @@ class TradeServiceTestCase(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_spot_market_buy_rejects_qty(self):
|
||||
with patch.object(trade_service, "audit_event", return_value=None):
|
||||
with self.assertRaisesRegex(RuntimeError, "accepts --quote only"):
|
||||
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",
|
||||
@@ -85,8 +91,10 @@ class TradeServiceTestCase(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_spot_market_sell_rejects_quote(self):
|
||||
with patch.object(trade_service, "audit_event", return_value=None):
|
||||
with self.assertRaisesRegex(RuntimeError, "accepts --qty only"):
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user