Files
coinhunter-cli/tests/test_opportunity_service.py
TacitLab 003212de99 refactor: simplify opportunity actions to entry/watch/avoid with confidence
- Remove dead scoring code (_score_candidate, _action_for, etc.) and
  align action decisions directly with score_opportunity_signal metrics.
- Reduce action surface from trigger/setup/chase/skip to entry/watch/avoid.
- Add confidence field (0..100) mapped from edge_score.
- Update evaluate/optimize ground-truth mapping and tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 01:08:34 +08:00

437 lines
15 KiB
Python

"""Signal, opportunity, and portfolio service tests."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from coinhunter.services import (
opportunity_service,
portfolio_service,
research_service,
signal_service,
)
class FakeSpotClient:
def account_info(self):
return {
"balances": [
{"asset": "USDT", "free": "50", "locked": "0"},
{"asset": "BTC", "free": "0.01", "locked": "0"},
{"asset": "ETH", "free": "0.5", "locked": "0"},
{"asset": "DOGE", "free": "1", "locked": "0"},
]
}
def ticker_price(self, symbols=None):
mapping = {
"BTCUSDT": {"symbol": "BTCUSDT", "price": "60000"},
"ETHUSDT": {"symbol": "ETHUSDT", "price": "3000"},
"DOGEUSDT": {"symbol": "DOGEUSDT", "price": "0.1"},
}
return [mapping[symbol] for symbol in symbols]
def ticker_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",
},
}
if not symbols:
return list(rows.values())
return [rows[symbol] for symbol in symbols]
def exchange_info(self):
return {
"symbols": [
{"symbol": "BTCUSDT", "status": "TRADING"},
{"symbol": "ETHUSDT", "status": "TRADING"},
{"symbol": "SOLUSDT", "status": "TRADING"},
{"symbol": "DOGEUSDT", "status": "TRADING"},
]
}
def klines(self, symbol, interval, limit):
curves = {
"BTCUSDT": [50000, 52000, 54000, 56000, 58000, 59000, 60000],
"ETHUSDT": [2600, 2650, 2700, 2800, 2900, 2950, 3000],
"SOLUSDT": [120, 125, 130, 135, 140, 145, 150],
"DOGEUSDT": [0.11, 0.108, 0.105, 0.103, 0.102, 0.101, 0.1],
}[symbol]
rows = []
for index, close in enumerate(curves[-limit:]):
rows.append(
[
index,
close * 0.98,
close * 1.01,
close * 0.97,
close,
100 + index * 10,
index + 1,
close * (100 + index * 10),
]
)
return rows
class DustOverlapSpotClient(FakeSpotClient):
def account_info(self):
return {"balances": [{"asset": "XRP", "free": "5", "locked": "0"}]}
def ticker_price(self, symbols=None):
mapping = {"XRPUSDT": {"symbol": "XRPUSDT", "price": "1.5"}}
return [mapping[symbol] for symbol in symbols]
def ticker_stats(self, symbols=None, *, window="1d"):
rows = {
"XRPUSDT": {
"symbol": "XRPUSDT",
"lastPrice": "1.5",
"priceChangePercent": "10",
"quoteVolume": "5000000",
"highPrice": "1.52",
"lowPrice": "1.2",
}
}
if not symbols:
return list(rows.values())
return [rows[symbol] for symbol in symbols]
def exchange_info(self):
return {"symbols": [{"symbol": "XRPUSDT", "status": "TRADING"}]}
def klines(self, symbol, interval, limit):
rows = []
setup_curve = [
1.4151,
1.4858,
1.3868,
1.5,
1.4009,
1.5142,
1.4151,
1.5,
1.4292,
1.4858,
1.4434,
1.4717,
1.4505,
1.4575,
1.4547,
1.4604,
1.4575,
1.4632,
1.4599,
1.466,
1.4618,
1.4698,
1.4745,
1.5,
]
for index, close in enumerate(setup_curve[-limit:]):
rows.append([index, close * 0.98, close * 1.01, close * 0.97, close, 100 + index * 10, index + 1, close * 100])
return rows
class OpportunityPatternSpotClient:
def account_info(self):
return {"balances": [{"asset": "USDT", "free": "100", "locked": "0"}]}
def ticker_price(self, symbols=None):
return []
def ticker_stats(self, symbols=None, *, window="1d"):
rows = {
"SETUPUSDT": {
"symbol": "SETUPUSDT",
"lastPrice": "106",
"priceChangePercent": "4",
"quoteVolume": "10000000",
"highPrice": "107",
"lowPrice": "98",
},
"CHASEUSDT": {
"symbol": "CHASEUSDT",
"lastPrice": "150",
"priceChangePercent": "18",
"quoteVolume": "9000000",
"highPrice": "152",
"lowPrice": "120",
},
}
if not symbols:
return list(rows.values())
return [rows[symbol] for symbol in symbols]
def exchange_info(self):
return {
"symbols": [
{"symbol": "SETUPUSDT", "status": "TRADING"},
{"symbol": "CHASEUSDT", "status": "TRADING"},
]
}
def klines(self, symbol, interval, limit):
curves = {
"SETUPUSDT": [
100,
105,
98,
106,
99,
107,
100,
106,
101,
105,
102,
104,
102.5,
103,
102.8,
103.2,
103.0,
103.4,
103.1,
103.6,
103.3,
103.8,
104.2,
106,
],
"CHASEUSDT": [120, 125, 130, 135, 140, 145, 150],
}[symbol]
rows = []
for index, close in enumerate(curves[-limit:]):
rows.append([index, close * 0.98, close * 1.01, close * 0.97, close, 100 + index * 20, index + 1, close * 100])
return rows
class OpportunityServiceTestCase(unittest.TestCase):
def setUp(self):
self.config = {
"market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []},
"trading": {"dust_usdt_threshold": 10.0},
"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,
"entry_threshold": 1.5,
"watch_threshold": 0.6,
"overlap_penalty": 0.6,
"auto_research": False,
"research_provider": "coingecko",
"research_timeout_seconds": 4.0,
"risk_limits": {
"min_liquidity": 0.0,
"max_overextension": 0.08,
"max_downside_risk": 0.3,
"max_unlock_risk": 0.75,
"max_regulatory_risk": 0.75,
"min_quality_for_add": 0.0,
},
"weights": {
"trend": 1.0,
"momentum": 1.0,
"breakout": 0.8,
"pullback": 0.4,
"volume": 0.7,
"liquidity": 0.3,
"trend_alignment": 0.8,
"fundamental": 0.8,
"tokenomics": 0.7,
"catalyst": 0.5,
"adoption": 0.4,
"smart_money": 0.3,
"volatility_penalty": 0.5,
"overextension_penalty": 0.7,
"downside_penalty": 0.5,
"unlock_penalty": 0.8,
"regulatory_penalty": 0.4,
"position_concentration_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(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=OpportunityPatternSpotClient(),
)
self.assertEqual([item["symbol"] for item in payload["recommendations"]], ["SETUPUSDT", "CHASEUSDT"])
self.assertEqual([item["action"] for item in payload["recommendations"]], ["entry", "avoid"])
self.assertGreater(payload["recommendations"][0]["metrics"]["setup_score"], 0.6)
self.assertGreater(payload["recommendations"][1]["metrics"]["extension_penalty"], 1.0)
def test_scan_respects_ignore_dust_for_overlap_penalty(self):
client = DustOverlapSpotClient()
base_config = self.config | {
"opportunity": self.config["opportunity"] | {
"top_n": 1,
"ignore_dust": True,
"overlap_penalty": 2.0,
}
}
with patch.object(opportunity_service, "audit_event", return_value=None):
ignored = opportunity_service.scan_opportunities(base_config, spot_client=client, symbols=["XRPUSDT"])
included = opportunity_service.scan_opportunities(
base_config | {"opportunity": base_config["opportunity"] | {"ignore_dust": False}},
spot_client=client,
symbols=["XRPUSDT"],
)
ignored_rec = ignored["recommendations"][0]
included_rec = included["recommendations"][0]
self.assertEqual(ignored_rec["action"], "entry")
self.assertEqual(ignored_rec["metrics"]["position_weight"], 0.0)
self.assertEqual(included_rec["action"], "entry")
self.assertEqual(included_rec["metrics"]["position_weight"], 1.0)
self.assertLess(included_rec["score"], ignored_rec["score"])
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)
def test_scan_uses_automatic_external_research(self):
config = self.config | {
"opportunity": self.config["opportunity"]
| {
"auto_research": True,
"top_n": 2,
}
}
with (
patch.object(opportunity_service, "audit_event", return_value=None),
patch.object(
opportunity_service,
"get_external_research",
return_value={
"SOLUSDT": {
"fundamental": 0.9,
"tokenomics": 0.8,
"catalyst": 0.9,
"adoption": 0.8,
"smart_money": 0.7,
"unlock_risk": 0.1,
"regulatory_risk": 0.1,
"research_confidence": 0.9,
}
},
) as research_mock,
):
payload = opportunity_service.scan_opportunities(config, spot_client=FakeSpotClient())
research_mock.assert_called_once()
sol = next(item for item in payload["recommendations"] if item["symbol"] == "SOLUSDT")
self.assertEqual(sol["metrics"]["fundamental"], 0.9)
self.assertEqual(sol["metrics"]["research_confidence"], 0.9)
def test_weak_setup_and_trigger_becomes_avoid(self):
metrics = {
"extension_penalty": 0.0,
"recent_runup": 0.0,
"breakout_pct": -0.01,
"setup_score": 0.12,
"trigger_score": 0.18,
"edge_score": 0.0,
}
action, reasons, confidence = opportunity_service._action_for_opportunity(
2.5,
metrics,
{
"entry_threshold": 1.5,
"watch_threshold": 0.6,
"min_trigger_score": 0.45,
"min_setup_score": 0.35,
},
)
self.assertEqual(action, "avoid")
self.assertIn("setup, trigger, or overall quality is too weak", reasons[0])
self.assertEqual(confidence, 50)
class ResearchServiceTestCase(unittest.TestCase):
def test_coingecko_market_data_becomes_research_signals(self):
signals = research_service._coingecko_market_to_signals(
{
"id": "solana",
"symbol": "sol",
"market_cap": 80_000_000_000,
"fully_diluted_valuation": 95_000_000_000,
"total_volume": 5_000_000_000,
"market_cap_rank": 6,
"circulating_supply": 550_000_000,
"total_supply": 600_000_000,
"max_supply": None,
"price_change_percentage_7d_in_currency": 12.0,
"price_change_percentage_30d_in_currency": 35.0,
"price_change_percentage_200d_in_currency": 80.0,
},
is_trending=True,
)
self.assertGreater(signals["fundamental"], 0.6)
self.assertGreater(signals["tokenomics"], 0.8)
self.assertGreater(signals["catalyst"], 0.6)
self.assertLess(signals["unlock_risk"], 0.2)