import os import sys import pandas as pd PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if PROJECT_DIR not in sys.path: sys.path.insert(0, PROJECT_DIR) from app.services import altcoin_screener def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch): monkeypatch.setattr( altcoin_screener.exchange, "fetch_tickers", lambda: { "BTC/USDT": {"last": 1, "percentage": 1, "quoteVolume": 100}, "ETH/USDT": {"last": 2, "percentage": 2, "quoteVolume": 200}, "BNB/USDT": {"last": 3, "percentage": 3, "quoteVolume": 300}, "RLUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "BFUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "EUR/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "AI/USDT": {"last": 1, "percentage": 5, "quoteVolume": 1000}, "USD1/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "U/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "XUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "FRAX/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "LUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "GUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "SUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "USDD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "EURS/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, "AUD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100}, }, ) pairs = altcoin_screener.fetch_all_tickers() assert "AI/USDT" in pairs assert "BTC/USDT" in pairs assert "ETH/USDT" in pairs assert "BNB/USDT" in pairs assert "RLUSD/USDT" not in pairs assert "BFUSD/USDT" not in pairs assert "EUR/USDT" not in pairs assert "USD1/USDT" not in pairs assert "U/USDT" not in pairs assert "XUSD/USDT" not in pairs assert "FRAX/USDT" not in pairs assert "LUSD/USDT" not in pairs assert "GUSD/USDT" not in pairs assert "SUSD/USDT" not in pairs assert "USDD/USDT" not in pairs assert "EURS/USDT" not in pairs assert "AUD/USDT" not in pairs def _mock_weights(): return { "量价齐飞": 5, "N倍放量": 5, "连续3x放量": 4, "布林收窄": 3, "静K蓄力": 2, "Q≥7供给区突破": 4, "Q7供给区突破": 4, "动K(阳)+量递增": 3, "动K阳量递增": 3, "连续K加速": 3, "板块联动": 3, "大户偏多": 1, "静K→动K转折": 4, "静K动K转折": 4, "1H放量(量价背离)": 1, } def test_volume_price_fly_accepts_two_consecutive_4x_bars(monkeypatch): monkeypatch.setattr( altcoin_screener, "vp_fly_params", lambda: {"vol_ratio_min": 5.0, "body_ratio_min": 0.70, "consecutive_relaxed_vol_ratio_min": 4.0}, ) rows = [] for i in range(20): rows.append({"open": 1.0, "high": 1.03, "low": 0.99, "close": 1.01, "volume": 100.0}) rows.extend([ {"open": 1.00, "high": 1.10, "low": 0.99, "close": 1.09, "volume": 650.0}, {"open": 1.09, "high": 1.20, "low": 1.08, "close": 1.18, "volume": 670.0}, ]) df = pd.DataFrame(rows) vp = altcoin_screener.detect_volume_price_fly(df) assert vp["vp_fly_count"] == 2 assert len(vp["vp_fly_details"]) == 2 def test_layer1_keeps_high_momentum_breakout_without_5x_vp(monkeypatch): monkeypatch.setattr(altcoin_screener, "fetch_all_tickers", lambda: { "CREAM/USDT": {"price": 2.1, "change_24h": 12.0, "volume_24h": 8000000}, }) monkeypatch.setattr(altcoin_screener, "fetch_funding_rates", lambda: {}) monkeypatch.setattr(altcoin_screener, "is_meme_coin", lambda symbol: False) monkeypatch.setattr(altcoin_screener, "get_burst_threshold", lambda symbol: 20) monkeypatch.setattr(altcoin_screener, "funding_rate_params", lambda: {"long_extreme": 0.001, "short_extreme": -0.0005}) monkeypatch.setattr(altcoin_screener, "detect_bollinger_squeeze", lambda df: None) monkeypatch.setattr(altcoin_screener, "vp_fly_params", lambda: {"vol_ratio_min": 5.0, "body_ratio_min": 0.70, "consecutive_relaxed_vol_ratio_min": 4.0}) monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", lambda: { "量价齐飞": 5, "N倍放量(≥10x)": 6, "连续3x放量(≥3根)": 4, "布林收窄": 3, "静K蓄力": 2, "1H放量(量价背离)": 1, }) h1_rows = [] for _ in range(20): h1_rows.append([0, 1.0, 1.03, 0.99, 1.01, 100.0]) h1_rows.extend([ [0, 1.00, 1.10, 0.99, 1.09, 650.0], [0, 1.09, 1.20, 1.08, 1.18, 670.0], ]) h4_rows = [] price = 1.0 for _ in range(30): h4_rows.append([0, price, price * 1.01, price * 0.99, price * 1.002, 100.0]) price *= 1.001 def fake_fetch_klines(symbol, timeframe, limit=100): data = h1_rows if timeframe == '1h' else h4_rows return pd.DataFrame(data, columns=['timestamp','open','high','low','close','volume']) monkeypatch.setattr(altcoin_screener, "fetch_klines", fake_fetch_klines) candidates = altcoin_screener.layer1_coarse_filter() vp = altcoin_screener.detect_volume_price_fly(fake_fetch_klines('CREAM/USDT', '1h')) assert vp["vp_fly_count"] == 2 assert "CREAM/USDT" in candidates assert any("量价齐飞" in s for s in candidates["CREAM/USDT"]["anomalies"]) assert any("连续2根量价齐飞K" in s for s in candidates["CREAM/USDT"]["anomalies"]) def test_static_accumulation_bypass_promotes_expired_to_accumulate(monkeypatch): monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights) monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3)) monkeypatch.setattr( altcoin_screener, "get_screener_section", lambda name=None: { "sector_rotation": { "bonus_weight": 0, "min_non_sector_signals_for_accelerate": 2, "sector_only_max_state": "蓄力", }, "static_accumulation_bypass": { "min_score": 2, "min_vol_ratio": 1.0, "min_static_count": 3, }, }.get(name, {}), ) monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None) monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: None) monkeypatch.setattr(altcoin_screener, "get_sector_for_coin", lambda symbol: []) monkeypatch.setattr(altcoin_screener, "dynamic_leader_detection", lambda perf: {}) monkeypatch.setattr(altcoin_screener, "SECTOR_MEMBERS", {}) qualified, _, _ = altcoin_screener.layer2_fine_filter({ "PNT/USDT": { "anomaly_score": 2, "price": 1.0, "change_24h": 4.0, "funding_rate": 0.0, "is_meme": False, "vp_data": None, "bb_data": None, "static_accumulation": {"static_count": 5, "vol_ratio": 1.1}, "h4_df": None, } }) assert qualified["PNT/USDT"]["state"] == "蓄力" assert qualified["PNT/USDT"]["base_state"] == "过期" assert qualified["PNT/USDT"]["force_reason"] == "静K蓄力旁路" assert any("静K蓄力旁路入池" in s for s in qualified["PNT/USDT"]["signals"]) def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch): monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights) monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3)) monkeypatch.setattr( altcoin_screener, "get_screener_section", lambda name=None: { "sector_rotation": { "bonus_weight": 0, "min_non_sector_signals_for_accelerate": 2, "sector_only_max_state": "蓄力", }, "static_accumulation_bypass": { "min_score": 2, "min_vol_ratio": 1.0, "min_static_count": 3, "direct_accelerate": { "enabled": True, "min_static_count": 10, "min_vol_ratio": 1.25, "min_score": 5, }, }, }.get(name, {}), ) monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None) monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: None) monkeypatch.setattr(altcoin_screener, "get_sector_for_coin", lambda symbol: []) monkeypatch.setattr(altcoin_screener, "dynamic_leader_detection", lambda perf: {}) monkeypatch.setattr(altcoin_screener, "SECTOR_MEMBERS", {}) qualified, _, _ = altcoin_screener.layer2_fine_filter({ "PNT/USDT": { "anomaly_score": 5, "price": 1.0, "change_24h": 4.0, "funding_rate": 0.0, "is_meme": False, "vp_data": None, "bb_data": None, "static_accumulation": {"static_count": 12, "vol_ratio": 1.4}, "h4_df": None, } }) assert qualified["PNT/USDT"]["state"] == "加速" assert qualified["PNT/USDT"]["base_state"] == "蓄力" assert qualified["PNT/USDT"]["force_reason"] == "强静K蓄力直升加速" assert any("强静K蓄力直升加速" in s for s in qualified["PNT/USDT"]["signals"]) assert qualified["PNT/USDT"]["candidate_stage"] == "qualified_candidate" assert qualified["PNT/USDT"]["next_stage"] == "trade_confirm" assert "rec_id" not in qualified["PNT/USDT"] def test_layer1_logs_coarse_candidate_details(monkeypatch): logged = [] h4_df = pd.DataFrame({ "open": [1.0] * 24, "high": [1.01] * 24, "low": [0.99] * 24, "close": [1.0] * 24, "volume": [1000] * 24, }) monkeypatch.setattr(altcoin_screener, "fetch_all_tickers", lambda: { "DOGE/USDT": {"volume_24h": 20_000_000, "change_24h": 3.5, "price": 0.1}, }) monkeypatch.setattr(altcoin_screener, "fetch_funding_rates", lambda: {}) monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights) monkeypatch.setattr(altcoin_screener, "is_meme_coin", lambda symbol: False) monkeypatch.setattr(altcoin_screener, "get_burst_threshold", lambda symbol: 20) monkeypatch.setattr(altcoin_screener, "fetch_klines", lambda symbol, timeframe, limit=200: h4_df if timeframe == "4h" else None) monkeypatch.setattr( altcoin_screener, "detect_static_accumulation", lambda symbol, df: {"static_count": 6, "vol_ratio": 1.5}, ) monkeypatch.setattr( altcoin_screener, "get_screener_section", lambda name=None: { "static_accumulation_bypass": {"min_volume_24h": 1_000_000, "min_vol_ratio": 1.2}, "higher_lows": {"enabled": False}, "compression_surge": {"enabled": False}, "sentiment": {"enabled": False}, }.get(name, {}), ) monkeypatch.setattr(altcoin_screener.exchange, "fapiPublicGetTicker24hr", lambda: []) monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: logged.append(kwargs)) candidates = altcoin_screener.layer1_coarse_filter() assert "DOGE/USDT" in candidates assert any(item["layer"] == "粗筛" and item["symbol"] == "DOGE/USDT" for item in logged)