From 1da08415f17e00cc22e24032da9accce2e9f0be7 Mon Sep 17 00:00:00 2001 From: Tacit Lab Date: Mon, 20 Apr 2026 16:13:57 +0800 Subject: [PATCH] feat: split portfolio and opportunity decision models --- CLAUDE.md | 6 +- README.md | 10 +- src/coinhunter/cli.py | 78 +++++---- src/coinhunter/config.py | 27 ++- src/coinhunter/runtime.py | 8 +- .../services/opportunity_service.py | 156 ++++-------------- src/coinhunter/services/portfolio_service.py | 109 ++++++++++++ src/coinhunter/services/signal_service.py | 78 +++++++++ tests/test_cli.py | 8 +- tests/test_opportunity_service.py | 41 +++-- 10 files changed, 326 insertions(+), 195 deletions(-) create mode 100644 src/coinhunter/services/portfolio_service.py create mode 100644 src/coinhunter/services/signal_service.py diff --git a/CLAUDE.md b/CLAUDE.md index 39e652a..4e6336b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 1724548..eb67647 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,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 @@ -122,6 +128,8 @@ coinhunter catlog -n 10 -o 10 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 @@ -148,7 +156,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` | diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index 7d760eb..2fe4a47 100644 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -28,6 +28,7 @@ from .services import ( account_service, market_service, opportunity_service, + portfolio_service, trade_service, ) @@ -346,26 +347,26 @@ Fields: TUI Output: RECOMMENDATIONS count=3 1. BTCUSDT action=add score=0.7500 - · trend, momentum, and breakout are aligned - trend=1.0 momentum=0.02 breakout=0.85 volume_confirmation=1.2 volatility=0.01 concentration=0.3 + · market signal is strong and position still has room + trend=1.0 momentum=0.02 breakout=0.85 volume_confirmation=1.2 volatility=0.01 position_weight=0.3 2. ETHUSDT action=hold score=0.6000 - · trend remains constructive - trend=1.0 momentum=0.01 breakout=0.5 volume_confirmation=1.0 volatility=0.02 concentration=0.2 + · market structure remains supportive for holding + trend=1.0 momentum=0.01 breakout=0.5 volume_confirmation=1.0 volatility=0.02 position_weight=0.2 3. SOLUSDT action=trim score=-0.2000 - · position concentration is high - trend=-1.0 momentum=-0.01 breakout=0.3 volume_confirmation=0.8 volatility=0.03 concentration=0.5 + · position weight is above the portfolio risk budget + trend=-1.0 momentum=-0.01 breakout=0.3 volume_confirmation=0.8 volatility=0.03 position_weight=0.5 JSON Output: { "recommendations": [ - {"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["trend, momentum, and breakout are aligned"], - "metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "concentration": 0.3}} + {"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["market signal is strong and position still has room"], + "metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "position_weight": 0.3}} ] } Fields: symbol – trading pair (e.g. "BTCUSDT") - action – enum: "add" | "hold" | "trim" | "exit" | "observe" - score – composite score (float, can be negative) + action – enum: "add" | "hold" | "trim" | "exit" | "review" + score – shared market signal score (float, can be negative) reasons – list of human-readable explanations metrics – scoring breakdown trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up) @@ -373,20 +374,20 @@ Fields: breakout – proximity to recent high, 0-1 (float) volume_confirmation – volume ratio vs average (float, >1 = above average) volatility – price range relative to current (float) - concentration – position weight in portfolio (float, 0-1) + position_weight – position weight in portfolio (float, 0-1) """, "json": """\ JSON Output: { "recommendations": [ - {"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["trend, momentum, and breakout are aligned"], - "metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "concentration": 0.3}} + {"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["market signal is strong and position still has room"], + "metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "position_weight": 0.3}} ] } Fields: symbol – trading pair (e.g. "BTCUSDT") - action – enum: "add" | "hold" | "trim" | "exit" | "observe" - score – composite score (float, can be negative) + action – enum: "add" | "hold" | "trim" | "exit" | "review" + score – shared market signal score (float, can be negative) reasons – list of human-readable explanations metrics – scoring breakdown trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up) @@ -394,52 +395,57 @@ Fields: breakout – proximity to recent high, 0-1 (float) volume_confirmation – volume ratio vs average (float, >1 = above average) volatility – price range relative to current (float) - concentration – position weight in portfolio (float, 0-1) + position_weight – position weight in portfolio (float, 0-1) """, }, "opportunity": { "tui": """\ TUI Output: RECOMMENDATIONS count=5 - 1. ETHUSDT action=add score=0.8200 - · trend, momentum, and breakout are aligned + 1. ETHUSDT action=enter score=0.8200 + · trend, momentum, and breakout are aligned for a fresh entry · base asset ETH passed liquidity and tradability filters - trend=1.0 momentum=0.03 breakout=0.9 volume_confirmation=1.5 volatility=0.02 concentration=0.0 - 2. BTCUSDT action=hold score=0.6000 - · trend remains constructive + trend=1.0 momentum=0.03 breakout=0.9 volume_confirmation=1.5 volatility=0.02 signal_score=0.82 position_weight=0.0 + 2. BTCUSDT action=watch score=0.6000 + · market structure is constructive but still needs confirmation · base asset BTC passed liquidity and tradability filters - trend=1.0 momentum=0.01 breakout=0.6 volume_confirmation=1.1 volatility=0.01 concentration=0.3 + · symbol is already held, so the opportunity score is discounted for overlap + trend=1.0 momentum=0.01 breakout=0.6 volume_confirmation=1.1 volatility=0.01 signal_score=0.78 position_weight=0.3 JSON Output: { "recommendations": [ - {"symbol": "ETHUSDT", "action": "add", "score": 0.82, - "reasons": ["trend, momentum, and breakout are aligned", "base asset ETH passed liquidity and tradability filters"], - "metrics": {"trend": 1.0, "momentum": 0.03, "breakout": 0.9, "volume_confirmation": 1.5, "volatility": 0.02, "concentration": 0.0}} + {"symbol": "ETHUSDT", "action": "enter", "score": 0.82, + "reasons": ["trend, momentum, and breakout are aligned for a fresh entry", "base asset ETH passed liquidity and tradability filters"], + "metrics": {"trend": 1.0, "momentum": 0.03, "breakout": 0.9, "volume_confirmation": 1.5, "volatility": 0.02, "signal_score": 0.82, "position_weight": 0.0}} ] } Fields: symbol – trading pair (e.g. "ETHUSDT") - action – enum: "add" | "hold" | "trim" | "exit" | "observe" - score – composite score (float, can be negative) + action – enum: "enter" | "watch" | "skip" + score – opportunity score after overlap/risk discounts reasons – list of human-readable explanations (includes liquidity filter note for scan) - metrics – scoring breakdown (same as portfolio) + metrics – scoring breakdown + signal_score – raw shared market signal score before overlap discount + position_weight – current portfolio overlap in the same symbol """, "json": """\ JSON Output: { "recommendations": [ - {"symbol": "ETHUSDT", "action": "add", "score": 0.82, - "reasons": ["trend, momentum, and breakout are aligned", "base asset ETH passed liquidity and tradability filters"], - "metrics": {"trend": 1.0, "momentum": 0.03, "breakout": 0.9, "volume_confirmation": 1.5, "volatility": 0.02, "concentration": 0.0}} + {"symbol": "ETHUSDT", "action": "enter", "score": 0.82, + "reasons": ["trend, momentum, and breakout are aligned for a fresh entry", "base asset ETH passed liquidity and tradability filters"], + "metrics": {"trend": 1.0, "momentum": 0.03, "breakout": 0.9, "volume_confirmation": 1.5, "volatility": 0.02, "signal_score": 0.82, "position_weight": 0.0}} ] } Fields: symbol – trading pair (e.g. "ETHUSDT") - action – enum: "add" | "hold" | "trim" | "exit" | "observe" - score – composite score (float, can be negative) + action – enum: "enter" | "watch" | "skip" + score – opportunity score after overlap/risk discounts reasons – list of human-readable explanations (includes liquidity filter note for scan) - metrics – scoring breakdown (same as portfolio) + metrics – scoring breakdown + signal_score – raw shared market signal score before overlap discount + position_weight – current portfolio overlap in the same symbol """, }, "upgrade": { @@ -771,7 +777,7 @@ def build_parser() -> argparse.ArgumentParser: portfolio_parser = subparsers.add_parser( "portfolio", aliases=["pf", "p"], help="Score current holdings", - description="Score your current spot holdings and generate add/hold/trim/exit recommendations.", + description="Review current spot holdings and generate add/hold/trim/exit recommendations.", ) _add_global_flags(portfolio_parser) @@ -1051,7 +1057,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "portfolio": spot_client = _load_spot_client(config) with with_spinner("Analyzing portfolio...", enabled=not args.agent): - result = opportunity_service.analyze_portfolio(config, spot_client=spot_client) + result = portfolio_service.analyze_portfolio(config, spot_client=spot_client) print_output(result, agent=args.agent) return 0 diff --git a/src/coinhunter/config.py b/src/coinhunter/config.py index 69cfb8b..9e8ebb9 100644 --- a/src/coinhunter/config.py +++ b/src/coinhunter/config.py @@ -38,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" diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py index dcc093d..71bd602 100644 --- a/src/coinhunter/runtime.py +++ b/src/coinhunter/runtime.py @@ -334,7 +334,13 @@ def _render_tui(payload: Any) -> None: 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 + _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}" diff --git a/src/coinhunter/services/opportunity_service.py b/src/coinhunter/services/opportunity_service.py index 7d64c68..bb29413 100644 --- a/src/coinhunter/services/opportunity_service.py +++ b/src/coinhunter/services/opportunity_service.py @@ -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,132 +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), - } - - 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), +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)), } - 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_stats([symbol], window="1d") - 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( @@ -155,7 +48,9 @@ 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() @@ -167,14 +62,19 @@ def scan_opportunities( 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( diff --git a/src/coinhunter/services/portfolio_service.py b/src/coinhunter/services/portfolio_service.py new file mode 100644 index 0000000..accfbea --- /dev/null +++ b/src/coinhunter/services/portfolio_service.py @@ -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 diff --git a/src/coinhunter/services/signal_service.py b/src/coinhunter/services/signal_service.py new file mode 100644 index 0000000..f19dd6a --- /dev/null +++ b/src/coinhunter/services/signal_service.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index cadb947..3b0107d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -140,7 +140,7 @@ class CLITestCase(unittest.TestCase): patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}), patch.object(cli, "SpotBinanceClient"), patch.object( - cli.opportunity_service, "analyze_portfolio", return_value={"scores": [{"asset": "BTC", "score": 0.75}]} + 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) @@ -148,7 +148,7 @@ class CLITestCase(unittest.TestCase): ): result = cli.main(["portfolio"]) self.assertEqual(result, 0) - self.assertEqual(captured["payload"]["scores"][0]["asset"], "BTC") + self.assertEqual(captured["payload"]["recommendations"][0]["symbol"], "BTCUSDT") def test_opportunity_dispatches(self): captured = {} @@ -161,7 +161,7 @@ class CLITestCase(unittest.TestCase): patch.object( cli.opportunity_service, "scan_opportunities", - return_value={"opportunities": [{"symbol": "BTCUSDT", "score": 0.82}]}, + return_value={"recommendations": [{"symbol": "BTCUSDT", "score": 0.82}]}, ), patch.object( cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) @@ -169,7 +169,7 @@ class CLITestCase(unittest.TestCase): ): result = cli.main(["opportunity", "-s", "BTCUSDT", "ETHUSDT"]) self.assertEqual(result, 0) - self.assertEqual(captured["payload"]["opportunities"][0]["symbol"], "BTCUSDT") + self.assertEqual(captured["payload"]["recommendations"][0]["symbol"], "BTCUSDT") def test_catlog_dispatches(self): captured = {} diff --git a/tests/test_opportunity_service.py b/tests/test_opportunity_service.py index b01da33..6ec7c64 100644 --- a/tests/test_opportunity_service.py +++ b/tests/test_opportunity_service.py @@ -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: @@ -105,28 +105,40 @@ class OpportunityServiceTestCase(unittest.TestCase): self.config = { "market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []}, "trading": {"dust_usdt_threshold": 10.0}, + "signal": { + "lookback_interval": "1h", + "trend": 1.0, + "momentum": 1.0, + "breakout": 0.8, + "volume": 0.7, + "volatility_penalty": 0.5, + }, "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, - }, + "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, **kwargs: 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): @@ -135,8 +147,9 @@ class OpportunityServiceTestCase(unittest.TestCase): 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)