feat: split portfolio and opportunity decision models
This commit is contained in:
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user