feat: split portfolio and opportunity decision models
This commit is contained in:
@@ -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:
|
- **`src/coinhunter/services/`** — Contains all domain logic:
|
||||||
- `account_service.py` — balances, positions, overview
|
- `account_service.py` — balances, positions, overview
|
||||||
- `market_service.py` — tickers, klines, scan universe, symbol normalization
|
- `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
|
- `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:
|
- **`src/coinhunter/binance/`** — Thin wrappers around official Binance connectors:
|
||||||
- `spot_client.py` wraps `binance.spot.Spot`
|
- `spot_client.py` wraps `binance.spot.Spot`
|
||||||
- `um_futures_client.py` wraps `binance.um_futures.UMFutures`
|
- `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`):
|
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`
|
- `.env` — `BINANCE_API_KEY` and `BINANCE_API_SECRET`
|
||||||
- `logs/audit_YYYYMMDD.jsonl` — structured audit log
|
- `logs/audit_YYYYMMDD.jsonl` — structured audit log
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -70,6 +70,12 @@ BINANCE_API_KEY=
|
|||||||
BINANCE_API_SECRET=
|
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`.
|
Override the default home directory with `COINHUNTER_HOME`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -122,6 +128,8 @@ coinhunter catlog -n 10 -o 10
|
|||||||
coinhunter config get # show all config
|
coinhunter config get # show all config
|
||||||
coinhunter config get binance.recv_window
|
coinhunter config get binance.recv_window
|
||||||
coinhunter config set opportunity.top_n 20
|
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 trading.dry_run_default true
|
||||||
coinhunter config set market.universe_allowlist BTCUSDT,ETHUSDT
|
coinhunter config set market.universe_allowlist BTCUSDT,ETHUSDT
|
||||||
coinhunter config key YOUR_API_KEY # or omit value to prompt interactively
|
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` |
|
| **CLI** | Single entrypoint, argument parsing | `cli.py` |
|
||||||
| **Binance** | Thin API wrappers with unified error handling | `binance/spot_client.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` |
|
| **Config** | TOML config, `.env` secrets, path resolution | `config.py` |
|
||||||
| **Runtime** | Paths, TUI/JSON/compact output | `runtime.py` |
|
| **Runtime** | Paths, TUI/JSON/compact output | `runtime.py` |
|
||||||
| **Audit** | Structured JSONL logging | `audit.py` |
|
| **Audit** | Structured JSONL logging | `audit.py` |
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .services import (
|
|||||||
account_service,
|
account_service,
|
||||||
market_service,
|
market_service,
|
||||||
opportunity_service,
|
opportunity_service,
|
||||||
|
portfolio_service,
|
||||||
trade_service,
|
trade_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,26 +347,26 @@ Fields:
|
|||||||
TUI Output:
|
TUI Output:
|
||||||
RECOMMENDATIONS count=3
|
RECOMMENDATIONS count=3
|
||||||
1. BTCUSDT action=add score=0.7500
|
1. BTCUSDT action=add score=0.7500
|
||||||
· trend, momentum, and breakout are aligned
|
· 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 concentration=0.3
|
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
|
2. ETHUSDT action=hold score=0.6000
|
||||||
· trend remains constructive
|
· market structure remains supportive for holding
|
||||||
trend=1.0 momentum=0.01 breakout=0.5 volume_confirmation=1.0 volatility=0.02 concentration=0.2
|
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
|
3. SOLUSDT action=trim score=-0.2000
|
||||||
· position concentration is high
|
· position weight is above the portfolio risk budget
|
||||||
trend=-1.0 momentum=-0.01 breakout=0.3 volume_confirmation=0.8 volatility=0.03 concentration=0.5
|
trend=-1.0 momentum=-0.01 breakout=0.3 volume_confirmation=0.8 volatility=0.03 position_weight=0.5
|
||||||
|
|
||||||
JSON Output:
|
JSON Output:
|
||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
{"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["trend, momentum, and breakout are aligned"],
|
{"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, "concentration": 0.3}}
|
"metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "position_weight": 0.3}}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Fields:
|
Fields:
|
||||||
symbol – trading pair (e.g. "BTCUSDT")
|
symbol – trading pair (e.g. "BTCUSDT")
|
||||||
action – enum: "add" | "hold" | "trim" | "exit" | "observe"
|
action – enum: "add" | "hold" | "trim" | "exit" | "review"
|
||||||
score – composite score (float, can be negative)
|
score – shared market signal score (float, can be negative)
|
||||||
reasons – list of human-readable explanations
|
reasons – list of human-readable explanations
|
||||||
metrics – scoring breakdown
|
metrics – scoring breakdown
|
||||||
trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up)
|
trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up)
|
||||||
@@ -373,20 +374,20 @@ Fields:
|
|||||||
breakout – proximity to recent high, 0-1 (float)
|
breakout – proximity to recent high, 0-1 (float)
|
||||||
volume_confirmation – volume ratio vs average (float, >1 = above average)
|
volume_confirmation – volume ratio vs average (float, >1 = above average)
|
||||||
volatility – price range relative to current (float)
|
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": """\
|
||||||
JSON Output:
|
JSON Output:
|
||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
{"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["trend, momentum, and breakout are aligned"],
|
{"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, "concentration": 0.3}}
|
"metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "position_weight": 0.3}}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Fields:
|
Fields:
|
||||||
symbol – trading pair (e.g. "BTCUSDT")
|
symbol – trading pair (e.g. "BTCUSDT")
|
||||||
action – enum: "add" | "hold" | "trim" | "exit" | "observe"
|
action – enum: "add" | "hold" | "trim" | "exit" | "review"
|
||||||
score – composite score (float, can be negative)
|
score – shared market signal score (float, can be negative)
|
||||||
reasons – list of human-readable explanations
|
reasons – list of human-readable explanations
|
||||||
metrics – scoring breakdown
|
metrics – scoring breakdown
|
||||||
trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up)
|
trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up)
|
||||||
@@ -394,52 +395,57 @@ Fields:
|
|||||||
breakout – proximity to recent high, 0-1 (float)
|
breakout – proximity to recent high, 0-1 (float)
|
||||||
volume_confirmation – volume ratio vs average (float, >1 = above average)
|
volume_confirmation – volume ratio vs average (float, >1 = above average)
|
||||||
volatility – price range relative to current (float)
|
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": {
|
"opportunity": {
|
||||||
"tui": """\
|
"tui": """\
|
||||||
TUI Output:
|
TUI Output:
|
||||||
RECOMMENDATIONS count=5
|
RECOMMENDATIONS count=5
|
||||||
1. ETHUSDT action=add score=0.8200
|
1. ETHUSDT action=enter score=0.8200
|
||||||
· trend, momentum, and breakout are aligned
|
· trend, momentum, and breakout are aligned for a fresh entry
|
||||||
· base asset ETH passed liquidity and tradability filters
|
· 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
|
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=hold score=0.6000
|
2. BTCUSDT action=watch score=0.6000
|
||||||
· trend remains constructive
|
· market structure is constructive but still needs confirmation
|
||||||
· base asset BTC passed liquidity and tradability filters
|
· 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:
|
JSON Output:
|
||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
{"symbol": "ETHUSDT", "action": "add", "score": 0.82,
|
{"symbol": "ETHUSDT", "action": "enter", "score": 0.82,
|
||||||
"reasons": ["trend, momentum, and breakout are aligned", "base asset ETH passed liquidity and tradability filters"],
|
"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, "concentration": 0.0}}
|
"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:
|
Fields:
|
||||||
symbol – trading pair (e.g. "ETHUSDT")
|
symbol – trading pair (e.g. "ETHUSDT")
|
||||||
action – enum: "add" | "hold" | "trim" | "exit" | "observe"
|
action – enum: "enter" | "watch" | "skip"
|
||||||
score – composite score (float, can be negative)
|
score – opportunity score after overlap/risk discounts
|
||||||
reasons – list of human-readable explanations (includes liquidity filter note for scan)
|
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": """\
|
||||||
JSON Output:
|
JSON Output:
|
||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
{"symbol": "ETHUSDT", "action": "add", "score": 0.82,
|
{"symbol": "ETHUSDT", "action": "enter", "score": 0.82,
|
||||||
"reasons": ["trend, momentum, and breakout are aligned", "base asset ETH passed liquidity and tradability filters"],
|
"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, "concentration": 0.0}}
|
"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:
|
Fields:
|
||||||
symbol – trading pair (e.g. "ETHUSDT")
|
symbol – trading pair (e.g. "ETHUSDT")
|
||||||
action – enum: "add" | "hold" | "trim" | "exit" | "observe"
|
action – enum: "enter" | "watch" | "skip"
|
||||||
score – composite score (float, can be negative)
|
score – opportunity score after overlap/risk discounts
|
||||||
reasons – list of human-readable explanations (includes liquidity filter note for scan)
|
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": {
|
"upgrade": {
|
||||||
@@ -771,7 +777,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
portfolio_parser = subparsers.add_parser(
|
portfolio_parser = subparsers.add_parser(
|
||||||
"portfolio", aliases=["pf", "p"], help="Score current holdings",
|
"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)
|
_add_global_flags(portfolio_parser)
|
||||||
|
|
||||||
@@ -1051,7 +1057,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
if args.command == "portfolio":
|
if args.command == "portfolio":
|
||||||
spot_client = _load_spot_client(config)
|
spot_client = _load_spot_client(config)
|
||||||
with with_spinner("Analyzing portfolio...", enabled=not args.agent):
|
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)
|
print_output(result, agent=args.agent)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -38,20 +38,29 @@ spot_enabled = true
|
|||||||
dry_run_default = false
|
dry_run_default = false
|
||||||
dust_usdt_threshold = 10.0
|
dust_usdt_threshold = 10.0
|
||||||
|
|
||||||
[opportunity]
|
[signal]
|
||||||
min_quote_volume = 1000000.0
|
lookback_interval = "1h"
|
||||||
top_n = 10
|
|
||||||
scan_limit = 50
|
|
||||||
ignore_dust = true
|
|
||||||
lookback_intervals = ["1h", "4h", "1d"]
|
|
||||||
|
|
||||||
[opportunity.weights]
|
|
||||||
trend = 1.0
|
trend = 1.0
|
||||||
momentum = 1.0
|
momentum = 1.0
|
||||||
breakout = 0.8
|
breakout = 0.8
|
||||||
volume = 0.7
|
volume = 0.7
|
||||||
volatility_penalty = 0.5
|
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"
|
DEFAULT_ENV = "BINANCE_API_KEY=\nBINANCE_API_SECRET=\n"
|
||||||
|
|||||||
@@ -334,7 +334,13 @@ def _render_tui(payload: Any) -> None:
|
|||||||
score = r.get("score", 0)
|
score = r.get("score", 0)
|
||||||
action = r.get("action", "")
|
action = r.get("action", "")
|
||||||
action_color = (
|
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(
|
print(
|
||||||
f" {i}. {_BOLD}{r.get('symbol', '')}{_RESET} action={_color(action, action_color)} score={score:.4f}"
|
f" {i}. {_BOLD}{r.get('symbol', '')}{_RESET} action={_color(action, action_color)} score={score:.4f}"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Opportunity analysis services."""
|
"""Opportunity scanning services."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from statistics import mean
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..audit import audit_event
|
from ..audit import audit_event
|
||||||
from .account_service import get_positions
|
from .account_service import get_positions
|
||||||
from .market_service import base_asset, get_scan_universe, normalize_symbol
|
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
|
@dataclass
|
||||||
@@ -20,132 +20,25 @@ class OpportunityRecommendation:
|
|||||||
metrics: dict[str, float]
|
metrics: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
def _safe_pct(new: float, old: float) -> float:
|
def _opportunity_thresholds(config: dict[str, Any]) -> dict[str, float]:
|
||||||
if old == 0:
|
opportunity_config = config.get("opportunity", {})
|
||||||
return 0.0
|
return {
|
||||||
return (new - old) / old
|
"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)),
|
||||||
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 = (
|
def _action_for_opportunity(score: float, thresholds: dict[str, float]) -> tuple[str, list[str]]:
|
||||||
weights.get("trend", 1.0) * trend
|
|
||||||
+ weights.get("momentum", 1.0) * momentum
|
|
||||||
+ weights.get("breakout", 0.8) * breakout
|
|
||||||
+ weights.get("volume", 0.7) * volume_score
|
|
||||||
- weights.get("volatility_penalty", 0.5) * volatility
|
|
||||||
- weights.get("position_concentration_penalty", 0.6) * concentration
|
|
||||||
)
|
|
||||||
metrics = {
|
|
||||||
"trend": round(trend, 4),
|
|
||||||
"momentum": round(momentum, 4),
|
|
||||||
"breakout": round(breakout, 4),
|
|
||||||
"volume_confirmation": round(volume_confirmation, 4),
|
|
||||||
"volatility": round(volatility, 4),
|
|
||||||
"concentration": round(concentration, 4),
|
|
||||||
}
|
|
||||||
return score, metrics
|
|
||||||
|
|
||||||
|
|
||||||
def _action_for(score: float, concentration: float) -> tuple[str, list[str]]:
|
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if concentration >= 0.5 and score < 0.4:
|
if score >= thresholds["entry_threshold"]:
|
||||||
reasons.append("position concentration is high")
|
reasons.append("trend, momentum, and breakout are aligned for a fresh entry")
|
||||||
return "trim", reasons
|
return "enter", reasons
|
||||||
if score >= 1.5:
|
if score >= thresholds["watch_threshold"]:
|
||||||
reasons.append("trend, momentum, and breakout are aligned")
|
reasons.append("market structure is constructive but still needs confirmation")
|
||||||
return "add", reasons
|
return "watch", reasons
|
||||||
if score >= 0.6:
|
reasons.append("edge is too weak for a new entry")
|
||||||
reasons.append("trend remains constructive")
|
return "skip", reasons
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def scan_opportunities(
|
def scan_opportunities(
|
||||||
@@ -155,7 +48,9 @@ def scan_opportunities(
|
|||||||
symbols: list[str] | None = None,
|
symbols: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
opportunity_config = config.get("opportunity", {})
|
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))
|
scan_limit = int(opportunity_config.get("scan_limit", 50))
|
||||||
top_n = int(opportunity_config.get("top_n", 10))
|
top_n = int(opportunity_config.get("top_n", 10))
|
||||||
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
||||||
@@ -167,14 +62,19 @@ def scan_opportunities(
|
|||||||
recommendations = []
|
recommendations = []
|
||||||
for ticker in universe:
|
for ticker in universe:
|
||||||
symbol = normalize_symbol(ticker["symbol"])
|
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]
|
closes = [float(item[4]) for item in klines]
|
||||||
volumes = [float(item[5]) for item in klines]
|
volumes = [float(item[5]) for item in klines]
|
||||||
concentration = concentration_map.get(symbol, 0.0) / total_held
|
concentration = concentration_map.get(symbol, 0.0) / total_held
|
||||||
score, metrics = _score_candidate(closes, volumes, ticker, weights, concentration)
|
signal_score, metrics = score_market_signal(closes, volumes, ticker, signal_weights)
|
||||||
action, reasons = _action_for(score, concentration)
|
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):
|
if symbol.endswith(quote):
|
||||||
reasons.append(f"base asset {base_asset(symbol, quote)} passed liquidity and tradability filters")
|
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(
|
recommendations.append(
|
||||||
asdict(
|
asdict(
|
||||||
OpportunityRecommendation(
|
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
|
||||||
@@ -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, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||||
patch.object(cli, "SpotBinanceClient"),
|
patch.object(cli, "SpotBinanceClient"),
|
||||||
patch.object(
|
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(
|
patch.object(
|
||||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||||
@@ -148,7 +148,7 @@ class CLITestCase(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
result = cli.main(["portfolio"])
|
result = cli.main(["portfolio"])
|
||||||
self.assertEqual(result, 0)
|
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):
|
def test_opportunity_dispatches(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
@@ -161,7 +161,7 @@ class CLITestCase(unittest.TestCase):
|
|||||||
patch.object(
|
patch.object(
|
||||||
cli.opportunity_service,
|
cli.opportunity_service,
|
||||||
"scan_opportunities",
|
"scan_opportunities",
|
||||||
return_value={"opportunities": [{"symbol": "BTCUSDT", "score": 0.82}]},
|
return_value={"recommendations": [{"symbol": "BTCUSDT", "score": 0.82}]},
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
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"])
|
result = cli.main(["opportunity", "-s", "BTCUSDT", "ETHUSDT"])
|
||||||
self.assertEqual(result, 0)
|
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):
|
def test_catlog_dispatches(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Opportunity service tests."""
|
"""Signal, opportunity, and portfolio service tests."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from coinhunter.services import opportunity_service
|
from coinhunter.services import opportunity_service, portfolio_service, signal_service
|
||||||
|
|
||||||
|
|
||||||
class FakeSpotClient:
|
class FakeSpotClient:
|
||||||
@@ -105,28 +105,40 @@ class OpportunityServiceTestCase(unittest.TestCase):
|
|||||||
self.config = {
|
self.config = {
|
||||||
"market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []},
|
"market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []},
|
||||||
"trading": {"dust_usdt_threshold": 10.0},
|
"trading": {"dust_usdt_threshold": 10.0},
|
||||||
"opportunity": {
|
"signal": {
|
||||||
"scan_limit": 10,
|
"lookback_interval": "1h",
|
||||||
"top_n": 5,
|
|
||||||
"min_quote_volume": 1000.0,
|
|
||||||
"weights": {
|
|
||||||
"trend": 1.0,
|
"trend": 1.0,
|
||||||
"momentum": 1.0,
|
"momentum": 1.0,
|
||||||
"breakout": 0.8,
|
"breakout": 0.8,
|
||||||
"volume": 0.7,
|
"volume": 0.7,
|
||||||
"volatility_penalty": 0.5,
|
"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):
|
def test_portfolio_analysis_ignores_dust_and_emits_recommendations(self):
|
||||||
events = []
|
events = []
|
||||||
with patch.object(opportunity_service, "audit_event", side_effect=lambda event, payload, **kwargs: events.append(event)):
|
with patch.object(portfolio_service, "audit_event", side_effect=lambda event, payload, **kwargs: events.append(event)):
|
||||||
payload = opportunity_service.analyze_portfolio(self.config, spot_client=FakeSpotClient())
|
payload = portfolio_service.analyze_portfolio(self.config, spot_client=FakeSpotClient())
|
||||||
symbols = [item["symbol"] for item in payload["recommendations"]]
|
symbols = [item["symbol"] for item in payload["recommendations"]]
|
||||||
self.assertNotIn("DOGEUSDT", symbols)
|
self.assertNotIn("DOGEUSDT", symbols)
|
||||||
self.assertEqual(symbols, ["BTCUSDT", "ETHUSDT"])
|
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"])
|
self.assertEqual(events, ["opportunity_portfolio_generated"])
|
||||||
|
|
||||||
def test_scan_is_deterministic(self):
|
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.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["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):
|
def test_signal_score_handles_empty_klines(self):
|
||||||
score, metrics = opportunity_service._score_candidate([], [], {"price_change_pct": 1.0}, {}, 0.0)
|
score, metrics = signal_service.score_market_signal([], [], {"price_change_pct": 1.0}, {})
|
||||||
self.assertEqual(score, 0.0)
|
self.assertEqual(score, 0.0)
|
||||||
self.assertEqual(metrics["trend"], 0.0)
|
self.assertEqual(metrics["trend"], 0.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user