578 lines
24 KiB
Python
578 lines
24 KiB
Python
import os
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
import pandas as pd
|
|
import requests
|
|
|
|
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},
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
altcoin_screener,
|
|
"_fetch_spot_24h_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},
|
|
},
|
|
)
|
|
monkeypatch.setattr(altcoin_screener, "get_active_static_exclusions", lambda symbols: {})
|
|
monkeypatch.setattr(altcoin_screener, "_fetch_spot_exchange_statuses", lambda: {})
|
|
monkeypatch.setattr(altcoin_screener, "record_universe_decisions", lambda items: len(list(items or [])))
|
|
|
|
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 test_fetch_all_tickers_filters_inactive_and_stale_markets(monkeypatch):
|
|
fresh_time = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
monkeypatch.setattr(
|
|
altcoin_screener,
|
|
"_fetch_spot_24h_tickers",
|
|
lambda: {
|
|
"CREAM/USDT": {"last": 2.1, "percentage": 65, "quoteVolume": 200000, "datetime": "2026-05-08T15:24:40.529"},
|
|
"PNT/USDT": {"last": 0.03, "percentage": 45, "quoteVolume": 200000, "datetime": fresh_time},
|
|
"FIDA/USDT": {"last": 0.02, "percentage": 35, "quoteVolume": 2000000, "datetime": fresh_time},
|
|
"DEAD/USDT": {"last": 1.0, "percentage": 1, "quoteVolume": 2000000, "datetime": fresh_time},
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
altcoin_screener,
|
|
"_fetch_spot_exchange_statuses",
|
|
lambda: {"DEAD/USDT": {"status": "BREAK", "isSpotTradingAllowed": False}},
|
|
)
|
|
monkeypatch.setattr(
|
|
altcoin_screener,
|
|
"get_active_static_exclusions",
|
|
lambda symbols: {"PNT/USDT": {"reason_code": "invalid_pair", "reason_label": "缓存交易对异常", "reason_type": "static"}},
|
|
)
|
|
monkeypatch.setattr(altcoin_screener, "record_universe_decisions", lambda items: len(list(items or [])))
|
|
|
|
pairs = altcoin_screener.fetch_all_tickers()
|
|
|
|
assert "CREAM/USDT" not in pairs
|
|
assert "PNT/USDT" not in pairs
|
|
assert "DEAD/USDT" not in pairs
|
|
assert "FIDA/USDT" in pairs
|
|
exclusions = getattr(altcoin_screener.fetch_all_tickers, "last_universe_exclusions", [])
|
|
assert any(x["symbol"] == "CREAM/USDT" and x["reason_code"] == "stale_ticker" for x in exclusions)
|
|
assert any(x["symbol"] == "PNT/USDT" and x.get("cache_hit") for x in exclusions)
|
|
assert any(x["symbol"] == "DEAD/USDT" and x["reason_code"] == "inactive_market" for x in exclusions)
|
|
|
|
|
|
def test_fetch_spot_24h_tickers_uses_cache_when_dns_fails(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(altcoin_screener, "EXCHANGE_CACHE_DIR", tmp_path)
|
|
cached_data = [
|
|
{
|
|
"symbol": "AIUSDT",
|
|
"lastPrice": "1.23",
|
|
"priceChangePercent": "8.5",
|
|
"quoteVolume": "1234567",
|
|
"highPrice": "1.4",
|
|
"lowPrice": "1.0",
|
|
"closeTime": 1770000000000,
|
|
}
|
|
]
|
|
altcoin_screener._write_spot_24h_ticker_cache(cached_data)
|
|
|
|
def fail_get(*args, **kwargs):
|
|
raise requests.exceptions.ConnectionError("dns failed")
|
|
|
|
monkeypatch.setattr(altcoin_screener.requests, "get", fail_get)
|
|
|
|
tickers = altcoin_screener._fetch_spot_24h_tickers()
|
|
|
|
assert tickers["AI/USDT"]["last"] == 1.23
|
|
assert tickers["AI/USDT"]["percentage"] == 8.5
|
|
assert tickers["AI/USDT"]["quoteVolume"] == 1234567
|
|
|
|
|
|
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_short_timeframe_ignition_detects_recent_15m_start():
|
|
rows = []
|
|
price = 1.0
|
|
for _ in range(40):
|
|
rows.append({"open": price, "high": price * 1.004, "low": price * 0.996, "close": price * 1.001, "volume": 100.0})
|
|
price *= 1.001
|
|
rows.extend([
|
|
{"open": price, "high": price * 1.01, "low": price * 0.998, "close": price * 1.008, "volume": 160.0},
|
|
{"open": price * 1.008, "high": price * 1.035, "low": price * 1.004, "close": price * 1.031, "volume": 420.0},
|
|
])
|
|
df = pd.DataFrame(rows)
|
|
|
|
result = altcoin_screener.detect_short_timeframe_ignition(
|
|
df,
|
|
"15m",
|
|
{"recent_bars": 8, "max_trigger_age_bars": 2, "min_vol_ratio": 2.5, "min_body_ratio": 0.45, "min_gain_pct": 0.8},
|
|
)
|
|
|
|
assert result["found"] is True
|
|
assert result["timeframe"] == "15m"
|
|
assert result["trigger"]["age_bars"] <= 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_static_accumulation_requires_resonance_when_configured(monkeypatch):
|
|
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
|
monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3))
|
|
logged = []
|
|
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,
|
|
"require_resonance": True,
|
|
"min_resonance_signals": 2,
|
|
},
|
|
}.get(name, {}),
|
|
)
|
|
monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None)
|
|
monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: logged.append(kwargs))
|
|
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({
|
|
"QUIET/USDT": {
|
|
"anomaly_score": 2,
|
|
"price": 1.0,
|
|
"change_24h": 1.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 "QUIET/USDT" not in qualified
|
|
reject = next(item for item in logged if item["symbol"] == "QUIET/USDT")
|
|
assert reject["state"] == "过期"
|
|
assert reject["detail"]["static_bypass_resonance"] == []
|
|
|
|
|
|
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)
|
|
|
|
|
|
def test_layer1_keeps_unseen_top_gainer_as_discovery_candidate(monkeypatch):
|
|
logged = []
|
|
|
|
monkeypatch.setattr(altcoin_screener, "fetch_all_tickers", lambda: {
|
|
"RONIN/USDT": {"volume_24h": 11_000_000, "change_24h": 43.0, "price": 1.2},
|
|
})
|
|
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: 5)
|
|
monkeypatch.setattr(altcoin_screener, "fetch_klines", lambda symbol, timeframe, limit=200: None)
|
|
monkeypatch.setattr(
|
|
altcoin_screener,
|
|
"get_screener_section",
|
|
lambda name=None: {
|
|
"static_accumulation_bypass": {"min_volume_24h": 2_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 "RONIN/USDT" in candidates
|
|
cand = candidates["RONIN/USDT"]
|
|
assert cand["top_gainer_24h"] is True
|
|
assert cand["top_gainer_chase_risk"] is True
|
|
assert any("24h强势榜异动" in s for s in cand["anomalies"])
|
|
coarse = next(item for item in logged if item["layer"] == "粗筛" and item["symbol"] == "RONIN/USDT")
|
|
assert coarse["detail"]["candidate_stage"] == "discovery_candidate"
|
|
assert "cex_top_gainer" in coarse["detail"]["source_types"]
|
|
assert "cex_top_gainer_24h" in coarse["detail"]["signal_codes"]
|
|
|
|
|
|
def test_top_gainer_quality_rejected_as_chase_not_silent(monkeypatch):
|
|
logged = []
|
|
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
|
monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (6, 9, 3))
|
|
monkeypatch.setattr(
|
|
altcoin_screener,
|
|
"get_screener_section",
|
|
lambda name=None: {
|
|
"static_accumulation_bypass": {
|
|
"min_score": 2,
|
|
"min_vol_ratio": 1.2,
|
|
"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: logged.append(kwargs))
|
|
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({
|
|
"RONIN/USDT": {
|
|
"anomaly_score": 2,
|
|
"price": 1.2,
|
|
"change_24h": 43.0,
|
|
"volume_24h": 11_000_000,
|
|
"funding_rate": 0.0,
|
|
"is_meme": False,
|
|
"vp_data": None,
|
|
"bb_data": None,
|
|
"static_accumulation": None,
|
|
"h4_df": None,
|
|
"top_gainer_24h": True,
|
|
"top_gainer_chase_risk": True,
|
|
"anomalies": ["24h强势榜异动(43.0%,成交额11.0M),追高风险待确认"],
|
|
}
|
|
})
|
|
|
|
assert "RONIN/USDT" not in qualified
|
|
reject = next(item for item in logged if item["layer"] == "细筛" and item["symbol"] == "RONIN/USDT")
|
|
assert reject["detail"]["candidate_stage"] == "rejected_candidate"
|
|
assert "high_chase_risk" in reject["detail"]["reject_reason_codes"]
|
|
assert "low_score" in reject["detail"]["reject_reason_codes"]
|
|
|
|
|
|
def test_top_gainer_without_chase_risk_is_not_rejected_only_for_low_score(monkeypatch):
|
|
logged = []
|
|
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
|
monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (6, 9, 3))
|
|
monkeypatch.setattr(
|
|
altcoin_screener,
|
|
"get_screener_section",
|
|
lambda name=None: {
|
|
"static_accumulation_bypass": {
|
|
"min_score": 2,
|
|
"min_vol_ratio": 1.2,
|
|
"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: logged.append(kwargs))
|
|
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({
|
|
"RONIN/USDT": {
|
|
"anomaly_score": 2,
|
|
"price": 1.2,
|
|
"change_24h": 43.0,
|
|
"volume_24h": 11_000_000,
|
|
"funding_rate": 0.0,
|
|
"is_meme": False,
|
|
"vp_data": None,
|
|
"bb_data": None,
|
|
"static_accumulation": None,
|
|
"h4_df": None,
|
|
"top_gainer_24h": True,
|
|
"top_gainer_chase_risk": False,
|
|
"anomalies": ["24h强势榜异动(43.0%,成交额11.0M)"],
|
|
}
|
|
})
|
|
|
|
# 低分强势榜候选仍可被保留为异动候选,而不是直接纯拒绝掉。
|
|
assert "RONIN/USDT" in qualified
|
|
fine = next(item for item in logged if item["layer"] == "细筛" and item["symbol"] == "RONIN/USDT")
|
|
assert fine["detail"]["candidate_stage"] == "qualified_candidate"
|
|
assert "high_chase_risk" not in fine["detail"]["reject_reason_codes"] if "reject_reason_codes" in fine["detail"] else True
|