diff --git a/src/coinhunter/services/account_service.py b/src/coinhunter/services/account_service.py index dae9493..57485d9 100644 --- a/src/coinhunter/services/account_service.py +++ b/src/coinhunter/services/account_service.py @@ -90,6 +90,7 @@ def get_positions( config: dict[str, Any], *, spot_client: Any, + ignore_dust: bool = True, ) -> dict[str, Any]: quote = str(config.get("market", {}).get("default_quote", "USDT")).upper() dust = float(config.get("trading", {}).get("dust_usdt_threshold", 0.0)) @@ -102,7 +103,7 @@ def get_positions( asset = item["asset"] mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0) notional = quantity * mark_price - if notional < dust: + if ignore_dust and notional < dust: continue rows.append( asdict( diff --git a/src/coinhunter/services/opportunity_service.py b/src/coinhunter/services/opportunity_service.py index bb29413..cac65cf 100644 --- a/src/coinhunter/services/opportunity_service.py +++ b/src/coinhunter/services/opportunity_service.py @@ -48,13 +48,14 @@ def scan_opportunities( symbols: list[str] | None = None, ) -> dict[str, Any]: opportunity_config = config.get("opportunity", {}) + ignore_dust = bool(opportunity_config.get("ignore_dust", True)) signal_weights = get_signal_weights(config) interval = get_signal_interval(config) thresholds = _opportunity_thresholds(config) scan_limit = int(opportunity_config.get("scan_limit", 50)) top_n = int(opportunity_config.get("top_n", 10)) quote = str(config.get("market", {}).get("default_quote", "USDT")).upper() - held_positions = get_positions(config, spot_client=spot_client)["positions"] + held_positions = get_positions(config, spot_client=spot_client, ignore_dust=ignore_dust)["positions"] concentration_map = {normalize_symbol(item["symbol"]): float(item["notional_usdt"]) for item in held_positions} total_held = sum(concentration_map.values()) or 1.0 diff --git a/tests/test_account_market_services.py b/tests/test_account_market_services.py index 0cf8fd2..7add31c 100644 --- a/tests/test_account_market_services.py +++ b/tests/test_account_market_services.py @@ -93,3 +93,14 @@ class AccountMarketServicesTestCase(unittest.TestCase): universe = market_service.get_scan_universe(config, spot_client=FakeSpotClient()) self.assertEqual([item["symbol"] for item in universe], ["BTCUSDT", "ETHUSDT"]) + + def test_get_positions_can_include_dust(self): + config = { + "market": {"default_quote": "USDT"}, + "trading": {"dust_usdt_threshold": 10.0}, + } + ignored = account_service.get_positions(config, spot_client=FakeSpotClient()) + included = account_service.get_positions(config, spot_client=FakeSpotClient(), ignore_dust=False) + + self.assertEqual([item["symbol"] for item in ignored["positions"]], ["USDT", "BTCUSDT"]) + self.assertEqual([item["symbol"] for item in included["positions"]], ["USDT", "BTCUSDT", "DOGEUSDT"]) diff --git a/tests/test_opportunity_service.py b/tests/test_opportunity_service.py index 6ec7c64..3805e30 100644 --- a/tests/test_opportunity_service.py +++ b/tests/test_opportunity_service.py @@ -100,6 +100,39 @@ class FakeSpotClient: 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 = [] + for index, close in enumerate([1.0, 1.1, 1.2, 1.3, 1.4, 1.45, 1.5][-limit:]): + rows.append([index, close * 0.98, close * 1.01, close * 0.97, close, 100 + index * 10, index + 1, close * 100]) + return rows + + class OpportunityServiceTestCase(unittest.TestCase): def setUp(self): self.config = { @@ -149,6 +182,31 @@ class OpportunityServiceTestCase(unittest.TestCase): self.assertEqual([item["symbol"] for item in payload["recommendations"]], ["SOLUSDT", "BTCUSDT"]) self.assertEqual([item["action"] for item in payload["recommendations"]], ["enter", "enter"]) + 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"], "enter") + self.assertEqual(ignored_rec["metrics"]["position_weight"], 0.0) + self.assertEqual(included_rec["action"], "skip") + 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)