2220 lines
97 KiB
Python
2220 lines
97 KiB
Python
"""
|
||
山寨币爆发监控系统 v11 — 纯前瞻行为派
|
||
只保留4个核心信号:量价齐飞 + 连续放量 + 静K→动K起爆 + 布林收窄
|
||
MACD/RSI/均线全部删除,不计算不加分不记录
|
||
"""
|
||
|
||
import sys, os, shutil
|
||
|
||
# ⚠️ 安全机制:启动时强制清__pycache__,防止旧版字节码残留
|
||
for cache_dir in [
|
||
os.path.join(os.path.dirname(__file__), "__pycache__"),
|
||
os.path.join(os.path.dirname(__file__), "..", "__pycache__"),
|
||
]:
|
||
if os.path.exists(cache_dir):
|
||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||
|
||
import ccxt
|
||
import pandas as pd
|
||
import numpy as np
|
||
import json
|
||
import sys
|
||
import os
|
||
import time
|
||
import requests
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, os.path.dirname(__file__))
|
||
from app.core.sector_map import (
|
||
SECTOR_MEMBERS, COIN_TO_SECTORS, MEME_SECTORS,
|
||
MIN_24H_VOLUME_USD, MEME_MIN_24H_VOLUME_USD,
|
||
get_sector_for_coin, is_meme_coin, get_burst_threshold,
|
||
dynamic_leader_detection,
|
||
)
|
||
from app.db.altcoin_db import (
|
||
init_db, expire_old_states, update_state, get_candidates_for_confirm,
|
||
log_screening, expire_old_recommendations, log_cron_run,
|
||
)
|
||
from app.config.config_loader import (
|
||
get_signal_weights,
|
||
get_strategy_direction,
|
||
get_meta,
|
||
vp_fly_params,
|
||
bollinger_squeeze_params,
|
||
funding_rate_params,
|
||
top_trader_params,
|
||
state_score_thresholds,
|
||
get_screener_section,
|
||
sentiment_max_bonus,
|
||
)
|
||
from app.core.pa_engine import (
|
||
classify_candles, calc_atr, find_supply_demand_zones,
|
||
find_continuous_k, detect_ignition_point, full_pa_analysis,
|
||
)
|
||
from app.core.opportunity_funnel import (
|
||
build_screening_detail,
|
||
discovery_source_types,
|
||
quality_filter_reasons,
|
||
universe_gate_reason,
|
||
)
|
||
from app.core.signal_taxonomy import signal_codes as build_signal_codes
|
||
from app.db.universe_audit import (
|
||
get_active_universe_exclusions,
|
||
get_active_static_exclusions,
|
||
reason_type_for,
|
||
record_screening_coverage,
|
||
record_universe_decisions,
|
||
)
|
||
from app.db.short_tf_signals import record_short_tf_samples
|
||
from app.core.trade_direction import direction_label
|
||
from app.strategies.short_breakdown import detect_breakdown_retest_short_1h
|
||
|
||
exchange = ccxt.binance({"enableRateLimit": True})
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
BINANCE_SPOT_BASE_URL = os.getenv("ALPHAX_BINANCE_SPOT_BASE_URL", "https://api.binance.com").rstrip("/")
|
||
EXCHANGE_CACHE_DIR = REPO_ROOT / "data" / "exchange_cache"
|
||
|
||
# ==================== 排除列表 ====================
|
||
STABLECOINS = {
|
||
"USDT", "USDC", "BUSD", "TUSD", "DAI", "FDUSD", "USDP", "PAX",
|
||
"USD1", "USDE", "USDS", "RLUSD", "PYUSD", "XUSD", "USDUC", "FRAX", "LUSD",
|
||
"GUSD", "SUSD", "USDD", "EURS", "EUR", "GBP",
|
||
}
|
||
WRAPPED = {"WBTC", "WETH", "RENBTC"}
|
||
GOLD_METAL = {"XAUT", "PAXG"}
|
||
MAJOR_BASES = {"BTC", "ETH", "BNB"}
|
||
EXCLUDED_BASE_SUFFIXES = (
|
||
"USD", "EUR", "GBP", "TRY", "BRL", "AUD", "FDUSD", "USDC", "USDP", "DAI"
|
||
)
|
||
EXCLUDED_BASES = {"U", "USD1", "EUR", "GBP", "XUSD", "EURS", "USDUC"}
|
||
|
||
# ==================== 信号权重(只有前瞻信号)====================
|
||
|
||
def get_dynamic_weights():
|
||
"""获取动态权重(config_loader 已合并 yaml + DB)"""
|
||
return get_signal_weights()
|
||
|
||
|
||
# ==================== 工具函数 ====================
|
||
|
||
def _fetch_spot_24h_tickers():
|
||
"""Fetch spot 24h tickers without ccxt market loading.
|
||
|
||
ccxt.fetch_tickers() can call Binance futures exchangeInfo as part of
|
||
load_markets(), which is exactly the endpoint most likely to be IP-banned.
|
||
The public spot 24h endpoint is enough for our broad universe scan.
|
||
"""
|
||
try:
|
||
resp = requests.get(f"{BINANCE_SPOT_BASE_URL}/api/v3/ticker/24hr", timeout=15)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
_write_spot_24h_ticker_cache(data)
|
||
except Exception as exc:
|
||
data = _read_spot_24h_ticker_cache(max_age_seconds=0)
|
||
if data is None:
|
||
print(f"Binance 24h ticker 拉取失败且无缓存,跳过本轮行情快照: {exc}")
|
||
_fetch_spot_24h_tickers.last_raw_count = 0
|
||
_fetch_spot_24h_tickers.last_usdt_count = 0
|
||
_fetch_spot_24h_tickers.last_error = str(exc)[:500]
|
||
return {}
|
||
print(f"Binance 24h ticker 拉取失败,使用本地缓存兜底: {exc}")
|
||
_fetch_spot_24h_tickers.last_error = str(exc)[:500]
|
||
if not isinstance(data, list):
|
||
return {}
|
||
tickers = {}
|
||
raw_count = len(data)
|
||
usdt_count = 0
|
||
for item in data:
|
||
raw_symbol = str(item.get("symbol") or "")
|
||
if not raw_symbol.endswith("USDT"):
|
||
continue
|
||
usdt_count += 1
|
||
base = raw_symbol[:-4]
|
||
if not base:
|
||
continue
|
||
close_time = item.get("closeTime")
|
||
ticker_dt = ""
|
||
try:
|
||
ticker_dt = datetime.utcfromtimestamp(float(close_time) / 1000).isoformat()
|
||
except Exception:
|
||
ticker_dt = ""
|
||
tickers[f"{base}/USDT"] = {
|
||
"last": float(item.get("lastPrice") or 0),
|
||
"percentage": float(item.get("priceChangePercent") or 0),
|
||
"quoteVolume": float(item.get("quoteVolume") or 0),
|
||
"high": float(item.get("highPrice") or 0),
|
||
"low": float(item.get("lowPrice") or 0),
|
||
"datetime": ticker_dt,
|
||
}
|
||
_fetch_spot_24h_tickers.last_raw_count = raw_count
|
||
_fetch_spot_24h_tickers.last_usdt_count = usdt_count
|
||
return tickers
|
||
|
||
|
||
def _spot_24h_ticker_cache_path():
|
||
return EXCHANGE_CACHE_DIR / "binance_spot_24h_tickers.json"
|
||
|
||
|
||
def _read_spot_24h_ticker_cache(max_age_seconds=300):
|
||
path = _spot_24h_ticker_cache_path()
|
||
try:
|
||
if not path.exists():
|
||
return None
|
||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||
fetched_at = float(payload.get("fetched_at") or 0)
|
||
if max_age_seconds and time.time() - fetched_at > max_age_seconds:
|
||
return None
|
||
data = payload.get("data")
|
||
return data if isinstance(data, list) else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _write_spot_24h_ticker_cache(data):
|
||
try:
|
||
if not isinstance(data, list):
|
||
return
|
||
EXCHANGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
_spot_24h_ticker_cache_path().write_text(
|
||
json.dumps({"fetched_at": time.time(), "data": data}, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _spot_exchange_info_cache_path():
|
||
return EXCHANGE_CACHE_DIR / "binance_spot_exchange_info.json"
|
||
|
||
|
||
def _read_spot_exchange_info_cache(max_age_seconds=86400):
|
||
path = _spot_exchange_info_cache_path()
|
||
try:
|
||
if not path.exists():
|
||
return None
|
||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||
fetched_at = float(payload.get("fetched_at") or 0)
|
||
if max_age_seconds and time.time() - fetched_at > max_age_seconds:
|
||
return None
|
||
return payload.get("statuses") or {}
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _write_spot_exchange_info_cache(statuses):
|
||
try:
|
||
EXCHANGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
_spot_exchange_info_cache_path().write_text(
|
||
json.dumps({"fetched_at": time.time(), "statuses": statuses or {}}, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _kline_scan_config():
|
||
cfg = get_screener_section("kline_scan") or {}
|
||
return {
|
||
"enabled": bool(cfg.get("enabled", True)),
|
||
"main_min_volume_usd": float(cfg.get("main_min_volume_usd", MIN_24H_VOLUME_USD) or 0),
|
||
"bypass_min_volume_usd": float(cfg.get("bypass_min_volume_usd", 2_000_000) or 0),
|
||
"short_tf_min_volume_usd": float(cfg.get("short_tf_min_volume_usd", 5_000_000) or 0),
|
||
"short_tf_min_abs_change_pct": float(cfg.get("short_tf_min_abs_change_pct", 1.0) or 0),
|
||
"short_tf_high_volume_usd": float(cfg.get("short_tf_high_volume_usd", 20_000_000) or 0),
|
||
"emergency_main_max_symbols": max(0, int(cfg.get("emergency_main_max_symbols", 0) or 0)),
|
||
"emergency_bypass_max_symbols": max(0, int(cfg.get("emergency_bypass_max_symbols", 0) or 0)),
|
||
"emergency_short_tf_max_symbols": max(0, int(cfg.get("emergency_short_tf_max_symbols", 0) or 0)),
|
||
"respect_dynamic_universe_cache": bool(cfg.get("respect_dynamic_universe_cache", True)),
|
||
}
|
||
|
||
|
||
def _is_cached_dynamic_exclusion(symbol: str, info: dict, cached: dict, fallback_min_volume: float = 0) -> bool:
|
||
item = (cached or {}).get(str(symbol or "").upper())
|
||
if not item:
|
||
return False
|
||
reason_type = str(item.get("reason_type") or "").strip()
|
||
reason_code = str(item.get("reason_code") or "").strip()
|
||
if reason_type == "static":
|
||
return True
|
||
evidence = item.get("evidence") if isinstance(item.get("evidence"), dict) else {}
|
||
current_volume = float((info or {}).get("volume_24h") or 0)
|
||
if reason_code == "low_turnover":
|
||
min_volume = float(evidence.get("min_volume") or fallback_min_volume or 0)
|
||
return min_volume > 0 and current_volume < min_volume
|
||
if reason_code == "stale_ticker":
|
||
return True
|
||
return False
|
||
|
||
|
||
def _symbol_priority_score(symbol: str, info: dict, recently_screened: set) -> tuple:
|
||
volume = float((info or {}).get("volume_24h") or 0)
|
||
change = abs(float((info or {}).get("change_24h") or 0))
|
||
top_gainer = _is_top_gainer_candidate(symbol, info)
|
||
return (
|
||
1 if symbol in recently_screened else 0,
|
||
1 if top_gainer else 0,
|
||
min(change, 80),
|
||
volume,
|
||
symbol,
|
||
)
|
||
|
||
|
||
def _rule_based_kline_scan_symbols(tickers: dict, *, recently_screened: set, min_volume: float = 0, emergency_max: int = 0) -> list[str]:
|
||
"""Select K-line scan universe by rules first; emergency_max is off by default."""
|
||
items = []
|
||
for symbol, info in (tickers or {}).items():
|
||
volume = float(info.get("volume_24h") or 0)
|
||
if min_volume and volume < min_volume:
|
||
continue
|
||
items.append(_symbol_priority_score(symbol, info, recently_screened))
|
||
items.sort(reverse=True)
|
||
if emergency_max > 0:
|
||
items = items[:emergency_max]
|
||
return [symbol for *_, symbol in items]
|
||
|
||
|
||
def _should_scan_short_tf(symbol: str, info: dict, *, recently_screened: set, cfg: dict) -> bool:
|
||
volume = float((info or {}).get("volume_24h") or 0)
|
||
change = abs(float((info or {}).get("change_24h") or 0))
|
||
if volume < float(cfg.get("short_tf_min_volume_usd") or 0):
|
||
return False
|
||
return (
|
||
symbol in recently_screened
|
||
or _is_top_gainer_candidate(symbol, info)
|
||
or change >= float(cfg.get("short_tf_min_abs_change_pct") or 0)
|
||
or volume >= float(cfg.get("short_tf_high_volume_usd") or 0)
|
||
)
|
||
|
||
|
||
def _fetch_spot_exchange_statuses():
|
||
"""Return Binance spot symbol activity status, cached to avoid repeated exchangeInfo calls."""
|
||
cached = _read_spot_exchange_info_cache()
|
||
if cached is not None:
|
||
return cached
|
||
try:
|
||
resp = requests.get(f"{BINANCE_SPOT_BASE_URL}/api/v3/exchangeInfo", timeout=15)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
statuses = {}
|
||
for item in data.get("symbols", []) if isinstance(data, dict) else []:
|
||
raw = str(item.get("symbol") or "").upper()
|
||
if not raw.endswith("USDT"):
|
||
continue
|
||
base = raw[:-4]
|
||
if not base:
|
||
continue
|
||
statuses[f"{base}/USDT"] = {
|
||
"status": str(item.get("status") or "").upper(),
|
||
"isSpotTradingAllowed": bool(item.get("isSpotTradingAllowed", True)),
|
||
}
|
||
if statuses:
|
||
_write_spot_exchange_info_cache(statuses)
|
||
return statuses
|
||
except Exception:
|
||
# If Binance is unavailable, a stale cache is still safer than hammering exchangeInfo.
|
||
return _read_spot_exchange_info_cache(max_age_seconds=0) or {}
|
||
|
||
|
||
def fetch_all_tickers():
|
||
tickers = _fetch_spot_24h_tickers()
|
||
usdt_pairs = {}
|
||
universe_exclusions = []
|
||
cached_exclusions = get_active_static_exclusions(tickers.keys())
|
||
exchange_statuses = _fetch_spot_exchange_statuses()
|
||
for symbol, info in tickers.items():
|
||
if "/USDT" in symbol:
|
||
base = symbol.split("/")[0]
|
||
vol_usd = info.get("quoteVolume", 0) or 0
|
||
status_info = exchange_statuses.get(symbol.upper()) if exchange_statuses else None
|
||
if status_info and (status_info.get("status") != "TRADING" or not status_info.get("isSpotTradingAllowed", True)):
|
||
universe_exclusions.append({
|
||
"symbol": symbol,
|
||
"base": base,
|
||
"price": info.get("last", 0) or 0,
|
||
"volume_24h": vol_usd,
|
||
"reason_code": "inactive_market",
|
||
"reason_label": "交易对已停用/不可现货交易",
|
||
"reason_type": "static",
|
||
"exchange_status": status_info.get("status") or "",
|
||
})
|
||
continue
|
||
cached = cached_exclusions.get(symbol.upper())
|
||
if cached:
|
||
universe_exclusions.append({
|
||
"symbol": symbol,
|
||
"base": base,
|
||
"price": info.get("last", 0) or 0,
|
||
"volume_24h": vol_usd,
|
||
"reason_code": cached.get("reason_code") or "cached_exclusion",
|
||
"reason_label": cached.get("reason_label") or "缓存宇宙过滤",
|
||
"reason_type": cached.get("reason_type") or "static",
|
||
"cache_hit": True,
|
||
})
|
||
continue
|
||
ticker_dt = info.get("datetime")
|
||
if ticker_dt:
|
||
try:
|
||
ticker_time = datetime.fromisoformat(str(ticker_dt).replace("Z", "+00:00")).replace(tzinfo=None)
|
||
if datetime.utcnow() - ticker_time > timedelta(hours=36):
|
||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "stale_ticker", "reason_label": "行情数据过旧", "reason_type": "transient"})
|
||
continue
|
||
except Exception:
|
||
pass
|
||
if base in STABLECOINS or base in WRAPPED or base in GOLD_METAL:
|
||
reason = universe_gate_reason(base, vol_usd, 0, symbol=symbol) or {"reason_code": "excluded_base", "reason_label": "排除基础资产"}
|
||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_type": reason_type_for(reason.get("reason_code")), **reason})
|
||
continue
|
||
if base in EXCLUDED_BASES:
|
||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "invalid_pair", "reason_label": "交易对异常", "reason_type": "static"})
|
||
continue
|
||
if base.endswith(EXCLUDED_BASE_SUFFIXES):
|
||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "invalid_pair", "reason_label": "交易对异常", "reason_type": "static"})
|
||
continue
|
||
if not base.isascii():
|
||
universe_exclusions.append({"symbol": symbol, "base": base, "price": info.get("last", 0) or 0, "volume_24h": vol_usd, "reason_code": "non_ascii", "reason_label": "非标准交易对", "reason_type": "static"})
|
||
continue
|
||
usdt_pairs[symbol] = {
|
||
"price": info.get("last", 0),
|
||
"change_24h": info.get("percentage", 0) or 0,
|
||
"volume_24h": vol_usd,
|
||
"high_24h": info.get("high", 0),
|
||
"low_24h": info.get("low", 0),
|
||
}
|
||
fetch_all_tickers.last_universe_exclusions = universe_exclusions
|
||
fetch_all_tickers.last_coverage_meta = {
|
||
"raw_ticker_count": int(getattr(_fetch_spot_24h_tickers, "last_raw_count", 0) or 0),
|
||
"usdt_pair_count": int(getattr(_fetch_spot_24h_tickers, "last_usdt_count", 0) or 0),
|
||
"tradable_universe_count": len(usdt_pairs),
|
||
"cached_exclusion_count": sum(1 for item in universe_exclusions if item.get("cache_hit")),
|
||
"pre_volume_universe_gate_count": len(universe_exclusions),
|
||
}
|
||
record_universe_decisions(universe_exclusions)
|
||
return usdt_pairs
|
||
|
||
|
||
def fetch_klines(symbol, timeframe, limit=200):
|
||
try:
|
||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||
df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
|
||
return df
|
||
except Exception as e:
|
||
return None
|
||
|
||
|
||
def fetch_funding_rates():
|
||
try:
|
||
rates = exchange.fapiPublicGetFundingRate({"limit": 100})
|
||
result = {}
|
||
for r in rates:
|
||
symbol = r["symbol"].replace("USDT", "/USDT")
|
||
rate = float(r["lastFundingRate"])
|
||
result[symbol] = rate
|
||
return result
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def fetch_top_trader_ratio(symbol):
|
||
"""从 Binance 期货 API 获取大户多空比。
|
||
注意:ccxt 统一 API 不支持 topLongShortPositionRatio,直接用 requests。
|
||
"""
|
||
try:
|
||
pair = symbol.replace("/", "")
|
||
r = requests.get(
|
||
f"https://fapi.binance.com/futures/data/topLongShortAccountRatio"
|
||
f"?symbol={pair}&period=1h&limit=2",
|
||
timeout=5,
|
||
)
|
||
if r.status_code == 200:
|
||
data = r.json()
|
||
if data:
|
||
latest = data[-1]
|
||
long_pct = float(latest.get("longAccount", 0)) * 100
|
||
short_pct = float(latest.get("shortAccount", 0)) * 100
|
||
ls_ratio = (
|
||
round(long_pct / short_pct, 2) if short_pct > 0 else 0
|
||
)
|
||
result = {
|
||
"long_pct": round(long_pct, 1),
|
||
"short_pct": round(short_pct, 1),
|
||
"ratio": ls_ratio,
|
||
}
|
||
# OI 24h变化(对比最近2条)
|
||
if len(data) >= 2 and "sumOpenInterest" in data[-2]:
|
||
oi_prev = float(data[-2]["sumOpenInterest"])
|
||
oi_curr = float(data[-1].get("sumOpenInterest", 0) or 0)
|
||
if oi_prev > 0:
|
||
result["open_interest_change_24h"] = round(
|
||
(oi_curr - oi_prev) / oi_prev * 100, 1
|
||
)
|
||
return result
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ==================== 核心前瞻信号检测 ====================
|
||
|
||
def detect_volume_price_fly(df_1h):
|
||
"""检测1H量价齐飞 + 时效衰减
|
||
量价齐飞 = 量≥5x均值 + 实体占比≥70% + 阳线
|
||
v1.6.10: 加入时效衰减 — 超过6小时的阳线只算0.3权重
|
||
"""
|
||
if df_1h is None or len(df_1h) < 20:
|
||
return None
|
||
|
||
vp_cfg = vp_fly_params()
|
||
avg_vol = df_1h["volume"].rolling(20).mean().iloc[-1]
|
||
recent = df_1h.tail(12)
|
||
|
||
vp_fly_count = 0
|
||
vp_fly_score = 0.0 # 时效加权分数(替代简单计数)
|
||
vp_fly_details = []
|
||
stale_vp_fly_details = []
|
||
vol_3x_count = 0
|
||
consecutive_3x = 0
|
||
max_consecutive_3x = 0
|
||
max_vol_ratio = 0
|
||
latest_vp_index = -1 # 最近一根量价齐飞K在recent中的位置
|
||
|
||
# “1H量价齐飞”必须是最新信号。默认只承认最近2根1H K线;
|
||
# 6~10小时前的放量阳线只能作为历史背景,不能继续触发当前推荐。
|
||
max_signal_age_hours = vp_cfg.get("max_signal_age_hours", 1)
|
||
max_age_hours = vp_cfg.get("max_age_hours", 6) # 仅用于展示/兼容,不作为有效触发
|
||
decay_factor = vp_cfg.get("age_decay", 0.3) # 仅用于历史字段兼容
|
||
|
||
for i, (_, row) in enumerate(recent.iterrows()):
|
||
vol_ratio = row["volume"] / avg_vol if avg_vol > 0 else 0
|
||
body_pct = abs(row["close"] - row["open"]) / (row["high"] - row["low"] + 0.00001) * 100
|
||
direction = 1 if row["close"] > row["open"] else -1
|
||
body_size_pct = abs(row["close"] - row["open"]) / row["open"] * 100
|
||
|
||
max_vol_ratio = max(max_vol_ratio, vol_ratio)
|
||
|
||
# 时效权重:最新1根=1.0,每早1小时衰减
|
||
age_hours = len(recent) - 1 - i # 0=最新, 11=最旧
|
||
time_weight = 1.0 if age_hours <= max_age_hours else decay_factor
|
||
|
||
relaxed_vol_ratio_min = vp_cfg.get("consecutive_relaxed_vol_ratio_min", 4.0)
|
||
if (
|
||
vol_ratio >= vp_cfg.get("vol_ratio_min", 5.0)
|
||
and body_pct >= vp_cfg.get("body_ratio_min", 0.70) * 100
|
||
and direction == 1
|
||
):
|
||
detail = {
|
||
"vol_ratio": round(vol_ratio, 1),
|
||
"body_pct": round(body_pct, 0),
|
||
"direction": "阳",
|
||
"body_size": round(body_size_pct, 1),
|
||
"age_hours": age_hours,
|
||
"time_weight": round(time_weight, 2),
|
||
}
|
||
if age_hours <= max_signal_age_hours:
|
||
vp_fly_count += 1
|
||
vp_fly_score += 1.0
|
||
vp_fly_details.append(detail)
|
||
latest_vp_index = i
|
||
else:
|
||
detail["stale"] = True
|
||
stale_vp_fly_details.append(detail)
|
||
elif (
|
||
vol_ratio >= relaxed_vol_ratio_min
|
||
and body_pct >= vp_cfg.get("body_ratio_min", 0.70) * 100
|
||
and direction == 1
|
||
):
|
||
detail = {
|
||
"vol_ratio": round(vol_ratio, 1),
|
||
"body_pct": round(body_pct, 0),
|
||
"direction": "阳",
|
||
"body_size": round(body_size_pct, 1),
|
||
"relaxed": True,
|
||
"age_hours": age_hours,
|
||
}
|
||
if age_hours <= max_signal_age_hours:
|
||
vp_fly_details.append(detail)
|
||
if len(vp_fly_details) >= 2:
|
||
tail2 = vp_fly_details[-2:]
|
||
if all(d.get("relaxed") for d in tail2):
|
||
vp_fly_count = max(vp_fly_count, 2)
|
||
vp_fly_score = max(vp_fly_score, 2.0)
|
||
else:
|
||
detail["stale"] = True
|
||
stale_vp_fly_details.append(detail)
|
||
|
||
if len(vp_fly_details) >= 2:
|
||
tail2 = vp_fly_details[-2:]
|
||
if all(d.get("relaxed") for d in tail2):
|
||
vp_fly_count = max(vp_fly_count, 2)
|
||
vp_fly_score = max(vp_fly_score, 2 * time_weight)
|
||
|
||
if vol_ratio >= 3:
|
||
vol_3x_count += 1
|
||
consecutive_3x += 1
|
||
max_consecutive_3x = max(max_consecutive_3x, consecutive_3x)
|
||
else:
|
||
consecutive_3x = 0
|
||
|
||
# 冲高回落检测
|
||
pullback_info = _check_spike_pullback(recent, vp_fly_details, latest_vp_index)
|
||
|
||
return {
|
||
"vp_fly_count": vp_fly_count,
|
||
"vp_fly_score": round(vp_fly_score, 1), # 时效加权分
|
||
"relaxed_vp_fly_count": sum(1 for d in vp_fly_details if d.get("relaxed")),
|
||
"max_vol_ratio": round(max_vol_ratio, 1),
|
||
"vol_3x_count": vol_3x_count,
|
||
"max_consecutive_3x": max_consecutive_3x,
|
||
"vp_fly_details": vp_fly_details,
|
||
"stale_vp_fly_details": stale_vp_fly_details,
|
||
"latest_vp_age_hours": min((d.get("age_hours", 999) for d in vp_fly_details), default=None),
|
||
"stale_vp_fly_count": len(stale_vp_fly_details),
|
||
"pullback": pullback_info, # 冲高回落信息
|
||
}
|
||
|
||
|
||
def _check_spike_pullback(recent_df, vp_fly_details, latest_vp_index):
|
||
"""
|
||
冲高回落检测:量价齐飞后是否持续阴跌
|
||
CFG案例:8x放量阳后连续阴跌10根→应标记为冲高回落
|
||
|
||
返回: {"is_pullback": bool, "bars_after": int, "drop_pct": float, "reason": str}
|
||
"""
|
||
if latest_vp_index < 0 or len(recent_df) < 3:
|
||
return None
|
||
|
||
# 量价齐飞K之后还有多少根K线
|
||
bars_after = len(recent_df) - 1 - latest_vp_index
|
||
if bars_after < 3:
|
||
return None # 量价齐飞发生时间太近,还没有足够的后续K线判断
|
||
|
||
# 获取量价齐飞K的高点和当前收盘价
|
||
vp_row = recent_df.iloc[latest_vp_index]
|
||
spike_high = float(vp_row["high"])
|
||
spike_close = float(vp_row["close"])
|
||
|
||
# 后续K线
|
||
after_df = recent_df.iloc[latest_vp_index + 1:]
|
||
current_close = float(recent_df["close"].iloc[-1])
|
||
|
||
# 从spike高点回落的幅度
|
||
drop_from_high = (spike_high - current_close) / spike_high * 100 if spike_high > 0 else 0
|
||
|
||
# 后续K线中阴线占比
|
||
bearish_count = sum(1 for _, r in after_df.iterrows() if r["close"] < r["open"])
|
||
total_after = len(after_df)
|
||
bearish_ratio = bearish_count / total_after if total_after > 0 else 0
|
||
|
||
# 后续K线平均量能 vs 量价齐飞K的量能
|
||
vp_vol = float(vp_row["volume"])
|
||
avg_after_vol = float(after_df["volume"].mean()) if len(after_df) > 0 else 0
|
||
vol_decay_ratio = avg_after_vol / vp_vol if vp_vol > 0 else 0
|
||
|
||
# 判断冲高回落
|
||
is_pullback = (
|
||
drop_from_high > 5 # 从高点回落>5%
|
||
and bearish_ratio >= 0.6 # 后续K线60%以上是阴线
|
||
and vol_decay_ratio < 0.5 # 后续量能不到爆量K的一半(缩量阴跌)
|
||
)
|
||
|
||
reason = ""
|
||
if is_pullback:
|
||
reason = f"冲高回落: 从${spike_high:.4f}跌{drop_from_high:.1f}%, 后{bars_after}根{total_after-bearish_count}阳{bearish_count}阴(量缩至{vol_decay_ratio:.0%})"
|
||
elif drop_from_high > 3 and bearish_ratio >= 0.5:
|
||
reason = f"疑似回落: 从高点跌{drop_from_high:.1f}%, 后{bars_after}根阴线{bearish_ratio:.0%}"
|
||
|
||
return {
|
||
"is_pullback": is_pullback,
|
||
"bars_after": bars_after,
|
||
"drop_pct": round(drop_from_high, 1),
|
||
"bearish_ratio": round(bearish_ratio, 2),
|
||
"vol_decay_ratio": round(vol_decay_ratio, 2),
|
||
"reason": reason,
|
||
}
|
||
|
||
|
||
def detect_bollinger_squeeze(df):
|
||
"""检测布林收窄 — 蓄力信号
|
||
物理规律:波动压缩到极限 → 必然释放(方向不确定但动能确定)
|
||
ORCA案例:低位收窄+后续爆发37%
|
||
"""
|
||
if df is None or len(df) < 20:
|
||
return None
|
||
|
||
bb_mid = df["close"].rolling(20).mean()
|
||
bb_std = df["close"].rolling(20).std()
|
||
bb_upper = bb_mid + 2 * bb_std
|
||
bb_lower = bb_mid - 2 * bb_std
|
||
|
||
# 当前布林位置
|
||
price = df["close"].iloc[-1]
|
||
bb_width_pct = ((bb_upper.iloc[-1] - bb_lower.iloc[-1]) / bb_mid.iloc[-1]) * 100
|
||
bb_pos = ((price - bb_lower.iloc[-1]) / (bb_upper.iloc[-1] - bb_lower.iloc[-1])) * 100
|
||
|
||
bb_cfg = bollinger_squeeze_params()
|
||
|
||
# 连续收窄检测
|
||
recent_width = ((bb_upper - bb_lower) / bb_mid * 100).tail(6)
|
||
moderate_width_pct = bb_cfg.get("moderate_width_pct", 0.05) * 100
|
||
tight_width_pct = bb_cfg.get("tight_width_pct", 0.03) * 100
|
||
min_bars = bb_cfg.get("min_bars", 4)
|
||
squeeze_count = sum(1 for w in recent_width if w < moderate_width_pct)
|
||
|
||
# 极度收窄
|
||
tight_squeeze = sum(1 for w in recent_width if w < tight_width_pct) >= min_bars
|
||
|
||
# 收窄后的方向提示:价格在中轨以上→偏多,以下→偏空
|
||
squeeze_direction = "偏多" if bb_pos > 55 else "偏空" if bb_pos < 45 else "中性"
|
||
|
||
return {
|
||
"bb_width_pct": round(float(bb_width_pct), 2),
|
||
"bb_pos": round(float(bb_pos), 1),
|
||
"squeeze_count": squeeze_count,
|
||
"tight_squeeze": tight_squeeze,
|
||
"squeeze_direction": squeeze_direction,
|
||
"price": round(float(price), 6),
|
||
}
|
||
|
||
|
||
def detect_static_accumulation(symbol, h4_df=None):
|
||
"""静K蓄力旁路:识别静K密集 + 临近放量的异动候选"""
|
||
if h4_df is None or len(h4_df) < 30:
|
||
return None
|
||
|
||
bypass_cfg = get_screener_section("static_accumulation_bypass")
|
||
recent_bars = bypass_cfg.get("recent_bars", 8)
|
||
min_static_count = bypass_cfg.get("min_static_count", 4)
|
||
max_range_pct = bypass_cfg.get("max_range_pct", 18.0)
|
||
max_breakout_gap_pct = bypass_cfg.get("max_breakout_gap_pct", 6.0)
|
||
|
||
pa = full_pa_analysis(h4_df, "4h")
|
||
candles_class = pa.get("candles_class") or []
|
||
recent = candles_class[-recent_bars:] if len(candles_class) >= recent_bars else candles_class
|
||
if not recent:
|
||
return None
|
||
|
||
static_count = sum(1 for c in recent if c.get("type") == "static")
|
||
if static_count < min_static_count:
|
||
return None
|
||
|
||
recent_df = h4_df.tail(len(recent)).copy()
|
||
avg_vol = h4_df["volume"].tail(20).mean()
|
||
latest_vol = float(recent_df["volume"].iloc[-1])
|
||
vol_ratio = latest_vol / avg_vol if avg_vol else 0.0
|
||
|
||
recent_high = float(recent_df["high"].max())
|
||
recent_low = float(recent_df["low"].min())
|
||
latest_close = float(recent_df["close"].iloc[-1])
|
||
range_pct = ((recent_high - recent_low) / recent_low * 100) if recent_low > 0 else 0.0
|
||
breakout_gap_pct = ((recent_high - latest_close) / latest_close * 100) if latest_close > 0 else 0.0
|
||
|
||
if range_pct > max_range_pct or breakout_gap_pct > max_breakout_gap_pct:
|
||
return None
|
||
|
||
return {
|
||
"static_count": static_count,
|
||
"vol_ratio": round(vol_ratio, 2),
|
||
"range_pct": round(range_pct, 2),
|
||
"breakout_gap_pct": round(breakout_gap_pct, 2),
|
||
"recent_high": round(recent_high, 6),
|
||
"latest_close": round(latest_close, 6),
|
||
}
|
||
|
||
|
||
def detect_higher_lows(df, cfg=None):
|
||
"""检测4H K线底部抬高模式
|
||
复盘发现78.6%爆发漏选币有底部抬高特征,这是当前策略最大盲区。
|
||
|
||
输入: DataFrame (含high/low/close/volume列), cfg从rules.yaml→screener.higher_lows读取
|
||
返回: {found, hl_count, total_segments, hl_score, signal}
|
||
"""
|
||
if df is None or len(df) < 8:
|
||
return {"found": False, "hl_count": 0, "total_segments": 0, "hl_score": 0, "signal": ""}
|
||
|
||
if cfg is None:
|
||
cfg = get_screener_section("higher_lows")
|
||
|
||
lookback_bars = cfg.get("lookback_bars", 24)
|
||
segment_bars = cfg.get("segment_bars", 4)
|
||
min_segments = cfg.get("min_segments", 2)
|
||
min_score = cfg.get("min_score", 2)
|
||
|
||
if not cfg.get("enabled", True):
|
||
return {"found": False, "hl_count": 0, "total_segments": 0, "hl_score": 0, "signal": ""}
|
||
|
||
# 取最近 lookback_bars 根K线
|
||
recent = df.tail(lookback_bars)
|
||
if len(recent) < segment_bars * 2:
|
||
return {"found": False, "hl_count": 0, "total_segments": 0, "hl_score": 0, "signal": ""}
|
||
|
||
# 按 segment_bars 根一组分段,取每段最低价
|
||
segment_lows = []
|
||
for i in range(0, len(recent), segment_bars):
|
||
seg = recent.iloc[i:i + segment_bars]
|
||
if len(seg) < segment_bars:
|
||
break # 最后一段不完整则丢弃
|
||
segment_lows.append(float(seg["low"].min()))
|
||
|
||
total_segments = len(segment_lows)
|
||
if total_segments < 2:
|
||
return {"found": False, "hl_count": 0, "total_segments": total_segments, "hl_score": 0, "signal": ""}
|
||
|
||
# 统计有多少段的最低价高于前一段(底部抬高)
|
||
hl_count = 0
|
||
for i in range(1, total_segments):
|
||
if segment_lows[i] > segment_lows[i - 1]:
|
||
hl_count += 1
|
||
|
||
found = hl_count >= min_segments
|
||
hl_score = min_score if found else 0
|
||
signal = f"底部抬高({hl_count}/{total_segments}段)" if found else ""
|
||
|
||
return {
|
||
"found": found,
|
||
"hl_count": hl_count,
|
||
"total_segments": total_segments,
|
||
"hl_score": hl_score,
|
||
"signal": signal,
|
||
}
|
||
|
||
|
||
def detect_compression_surge(df, cfg=None):
|
||
"""检测4H K线压缩后放量模式
|
||
复盘发现29%爆发币在起爆前振幅<20%+突然放量>2x。紧凑型压缩后爆发模式。
|
||
|
||
输入: DataFrame (含high/low/close/volume列), cfg从rules.yaml→screener.compression_surge读取
|
||
返回: {found, range_pct, vol_ratio, score, signal}
|
||
"""
|
||
if df is None or len(df) < 24:
|
||
return {"found": False, "range_pct": 0, "vol_ratio": 0, "score": 0, "signal": ""}
|
||
|
||
if cfg is None:
|
||
cfg = get_screener_section("compression_surge")
|
||
|
||
lookback_bars = cfg.get("lookback_bars", 24)
|
||
max_range_pct = cfg.get("max_range_pct", 20.0)
|
||
min_vol_ratio = cfg.get("min_vol_ratio", 2.0)
|
||
min_score = cfg.get("min_score", 2)
|
||
|
||
if not cfg.get("enabled", True):
|
||
return {"found": False, "range_pct": 0, "vol_ratio": 0, "score": 0, "signal": ""}
|
||
|
||
# 取 lookback_bars 根K线计算价格振幅
|
||
recent = df.tail(lookback_bars)
|
||
max_high = float(recent["high"].max())
|
||
min_low = float(recent["low"].min())
|
||
range_pct = ((max_high - min_low) / min_low * 100) if min_low > 0 else 0
|
||
|
||
# 振幅 < max_range_pct → 压缩
|
||
if range_pct >= max_range_pct:
|
||
return {"found": False, "range_pct": round(range_pct, 2), "vol_ratio": 0, "score": 0, "signal": ""}
|
||
|
||
# 最近3根K线均量 vs 前21根均量
|
||
recent_3_vol = float(recent["volume"].tail(3).mean())
|
||
prior_21_vol = float(recent["volume"].iloc[:-3].mean()) if len(recent) > 3 else recent_3_vol
|
||
vol_ratio = recent_3_vol / prior_21_vol if prior_21_vol > 0 else 0
|
||
|
||
found = vol_ratio >= min_vol_ratio
|
||
score = min_score if found else 0
|
||
signal = f"压缩放量(振幅{range_pct:.1f}%,量比{vol_ratio:.1f}x)" if found else ""
|
||
|
||
return {
|
||
"found": found,
|
||
"range_pct": round(range_pct, 2),
|
||
"vol_ratio": round(vol_ratio, 2),
|
||
"score": score,
|
||
"signal": signal,
|
||
}
|
||
|
||
|
||
def detect_short_timeframe_ignition(df, timeframe="15m", cfg=None):
|
||
"""短周期早期启动探针。
|
||
|
||
只用于发现层预警,避免山寨币在 1H 成型前已经走完第一段。
|
||
返回的信号需要后续确认层继续用 15m/30m/1H/4H/1D 决定是否可交易。
|
||
"""
|
||
cfg = dict(cfg or {})
|
||
if df is None or len(df) < 30 or not cfg:
|
||
return {"found": False, "timeframe": timeframe}
|
||
|
||
recent_bars = int(cfg.get("recent_bars", 8) or 8)
|
||
max_age = int(cfg.get("max_trigger_age_bars", 2) or 2)
|
||
min_vol_ratio = float(cfg.get("min_vol_ratio", 2.5) or 2.5)
|
||
min_body_ratio = float(cfg.get("min_body_ratio", 0.45) or 0.45)
|
||
min_gain_pct = float(cfg.get("min_gain_pct", 0.8) or 0.8)
|
||
|
||
recent = df.tail(max(recent_bars, max_age + 1)).copy()
|
||
if len(recent) < max_age + 1:
|
||
return {"found": False, "timeframe": timeframe}
|
||
|
||
prior = df.iloc[:-len(recent)] if len(df) > len(recent) else df.iloc[:-max_age]
|
||
avg_vol = float(prior["volume"].tail(48).mean()) if len(prior) else float(df["volume"].tail(30).mean())
|
||
if avg_vol <= 0:
|
||
return {"found": False, "timeframe": timeframe}
|
||
|
||
first_close = float(recent["close"].iloc[0])
|
||
last_close = float(recent["close"].iloc[-1])
|
||
gain_pct = (last_close - first_close) / first_close * 100 if first_close > 0 else 0.0
|
||
|
||
trigger = None
|
||
for offset in range(0, min(max_age + 1, len(recent))):
|
||
row = recent.iloc[-1 - offset]
|
||
candle_range = float(row["high"] - row["low"])
|
||
body = float(row["close"] - row["open"])
|
||
body_ratio = abs(body) / (candle_range + 1e-9)
|
||
vol_ratio = float(row["volume"]) / avg_vol
|
||
close_pos = (float(row["close"]) - float(row["low"])) / (candle_range + 1e-9)
|
||
if body > 0 and body_ratio >= min_body_ratio and vol_ratio >= min_vol_ratio and close_pos >= 0.6:
|
||
trigger = {
|
||
"age_bars": offset,
|
||
"vol_ratio": round(vol_ratio, 2),
|
||
"body_ratio": round(body_ratio, 2),
|
||
"close_pos": round(close_pos, 2),
|
||
"price": round(float(row["close"]), 8),
|
||
}
|
||
break
|
||
|
||
if not trigger or gain_pct < min_gain_pct:
|
||
return {
|
||
"found": False,
|
||
"timeframe": timeframe,
|
||
"gain_pct": round(gain_pct, 2),
|
||
"max_vol_ratio": round(max(float(v) / avg_vol for v in recent["volume"]), 2),
|
||
}
|
||
|
||
bullish_dynamic = 0
|
||
bearish_dynamic = 0
|
||
for _, row in recent.iterrows():
|
||
candle_range = float(row["high"] - row["low"])
|
||
body = float(row["close"] - row["open"])
|
||
body_ratio = abs(body) / (candle_range + 1e-9)
|
||
vol_ratio = float(row["volume"]) / avg_vol
|
||
if body > 0 and body_ratio >= min_body_ratio and vol_ratio >= min_vol_ratio * 0.7:
|
||
bullish_dynamic += 1
|
||
elif body < 0 and body_ratio >= min_body_ratio and vol_ratio >= min_vol_ratio * 0.7:
|
||
bearish_dynamic += 1
|
||
|
||
return {
|
||
"found": True,
|
||
"timeframe": timeframe,
|
||
"gain_pct": round(gain_pct, 2),
|
||
"trigger": trigger,
|
||
"bullish_dynamic_count": bullish_dynamic,
|
||
"bearish_dynamic_count": bearish_dynamic,
|
||
"signal": f"{timeframe}短周期启动(量{trigger['vol_ratio']}x,涨{gain_pct:.1f}%)",
|
||
}
|
||
|
||
|
||
def _build_signal_recency(cand):
|
||
"""把粗筛/细筛命中的信号按 current/stale 标记,避免旧形态冒充当下机会。"""
|
||
current = []
|
||
stale = []
|
||
if cand.get("top_gainer_24h"):
|
||
current.append({
|
||
"type": "cex_top_gainer",
|
||
"label": "当前24h强势榜异动",
|
||
"timeframe": "24h",
|
||
"change_24h": cand.get("change_24h", 0),
|
||
})
|
||
vp = cand.get("vp_data") or {}
|
||
if vp.get("vp_fly_count", 0) > 0:
|
||
current.append({"type": "volume_price", "label": "当前1H量价齐飞", "timeframe": "1h", "age_hours": vp.get("latest_vp_age_hours")})
|
||
if vp.get("stale_vp_fly_count", 0) > 0:
|
||
stale.append({"type": "volume_price", "label": "历史1H量价齐飞", "timeframe": "1h", "count": vp.get("stale_vp_fly_count")})
|
||
if cand.get("static_accumulation"):
|
||
current.append({"type": "structure", "label": "当前4H静K蓄力", "timeframe": "4h"})
|
||
if cand.get("higher_lows"):
|
||
current.append({"type": "structure", "label": "当前4H底部抬高", "timeframe": "4h"})
|
||
if cand.get("compression_surge"):
|
||
current.append({"type": "structure", "label": "当前4H压缩放量", "timeframe": "4h"})
|
||
short_tf = cand.get("short_tf_ignition") or {}
|
||
for item in short_tf.get("signals", []):
|
||
if item.get("found"):
|
||
current.append({
|
||
"type": "short_timeframe",
|
||
"label": item.get("signal") or f"{item.get('timeframe')}短周期启动",
|
||
"timeframe": item.get("timeframe"),
|
||
"gain_pct": item.get("gain_pct", 0),
|
||
})
|
||
if cand.get("sentiment") or cand.get("sentiment_bonus"):
|
||
current.append({"type": "sentiment", "label": "舆情共振", "source": "sentiment_monitor"})
|
||
status = "current" if current else "stale_background_only" if stale else "unknown"
|
||
return {"status": status, "current": current, "stale": stale}
|
||
|
||
|
||
def _is_top_gainer_candidate(symbol, info, *, min_volume=None, threshold=None):
|
||
"""把涨幅榜强势本身纳入发现层,避免已启动币被粗筛静默跳过。"""
|
||
volume = float((info or {}).get("volume_24h") or 0)
|
||
change = float((info or {}).get("change_24h") or 0)
|
||
if min_volume is None:
|
||
min_volume = MEME_MIN_24H_VOLUME_USD if is_meme_coin(symbol) else MIN_24H_VOLUME_USD
|
||
if threshold is None:
|
||
threshold = get_burst_threshold(symbol) * 1.5
|
||
return volume >= float(min_volume or 0) and change >= float(threshold or 0)
|
||
|
||
|
||
def _top_gainer_signal(symbol, change, volume):
|
||
return f"24h强势榜异动({float(change or 0):.1f}%,成交额{float(volume or 0)/1_000_000:.1f}M)"
|
||
|
||
|
||
def _static_bypass_resonance(cand, *, static_cfg, sector_signal_count=0, top_trader_ratio=None, vp_data=None):
|
||
"""Return resonance signals that make a static-K bypass worth confirming."""
|
||
signals = []
|
||
vp_data = vp_data or cand.get("vp_data") or {}
|
||
if vp_data.get("vp_fly_count", 0) > 0:
|
||
signals.append("current_vp_fly")
|
||
if vp_data.get("max_consecutive_3x", 0) >= int(static_cfg.get("min_consecutive_3x", 3)):
|
||
signals.append("consecutive_volume")
|
||
|
||
static_acc = cand.get("static_accumulation") or {}
|
||
if float(static_acc.get("vol_ratio") or 0) >= float(static_cfg.get("strong_vol_ratio", 2.0)):
|
||
signals.append("strong_static_volume")
|
||
if int(sector_signal_count or 0) > 0:
|
||
signals.append("sector_rotation")
|
||
if top_trader_ratio and float(top_trader_ratio.get("long_pct") or 0) >= float(static_cfg.get("min_top_trader_long_pct", 65)):
|
||
signals.append("top_trader_long")
|
||
if cand.get("top_gainer_24h") and float(cand.get("change_24h") or 0) >= float(static_cfg.get("min_top_gainer_change_pct", 8.0)):
|
||
signals.append("top_gainer")
|
||
if (cand.get("higher_lows") or {}).get("found"):
|
||
signals.append("higher_lows")
|
||
if (cand.get("compression_surge") or {}).get("found"):
|
||
signals.append("compression_surge")
|
||
short_tf = cand.get("short_tf_ignition") or {}
|
||
if short_tf.get("resonance") or len(short_tf.get("signals") or []) >= 1:
|
||
signals.append("short_timeframe")
|
||
if cand.get("sentiment") or cand.get("sentiment_bonus"):
|
||
signals.append("sentiment")
|
||
return list(dict.fromkeys(signals))
|
||
|
||
|
||
def _attach_top_gainer_discovery(candidates, tickers, recently_screened):
|
||
"""为强势榜补发现入口;追高风险留给细筛/确认处理。"""
|
||
added = 0
|
||
for symbol, info in tickers.items():
|
||
if symbol in candidates:
|
||
if _is_top_gainer_candidate(symbol, info):
|
||
candidates[symbol]["top_gainer_24h"] = True
|
||
candidates[symbol]["top_gainer_chase_risk"] = symbol not in recently_screened
|
||
continue
|
||
if not _is_top_gainer_candidate(symbol, info):
|
||
continue
|
||
|
||
change = float(info.get("change_24h") or 0)
|
||
vol = float(info.get("volume_24h") or 0)
|
||
signal = _top_gainer_signal(symbol, change, vol)
|
||
if symbol not in recently_screened:
|
||
signal = f"{signal},追高风险待确认"
|
||
candidates[symbol] = {
|
||
"anomalies": [signal],
|
||
"anomaly_score": 2,
|
||
"price": info["price"],
|
||
"change_24h": change,
|
||
"volume_24h": vol,
|
||
"funding_rate": 0,
|
||
"is_meme": is_meme_coin(symbol),
|
||
"vp_data": None,
|
||
"bb_data": None,
|
||
"static_accumulation": None,
|
||
"h4_df": None,
|
||
"turnover_acceleration_1h": 0,
|
||
"turnover_acceleration_4h": 0,
|
||
"base_volume_24h": 0,
|
||
"quote_volume_24h": round(vol, 2),
|
||
"weighted_avg_price": info.get("price", 0),
|
||
"top_gainer_24h": True,
|
||
"top_gainer_chase_risk": symbol not in recently_screened,
|
||
"bypass_origin": "top_gainer_24h",
|
||
}
|
||
added += 1
|
||
return added
|
||
|
||
|
||
def _log_universe_exclusions(exclusions, max_logs=120):
|
||
"""把交易宇宙过滤结果写入链路日志,避免页面看不到第一道漏斗。"""
|
||
logged = 0
|
||
for item in (exclusions or [])[:max_logs]:
|
||
detail = build_screening_detail(
|
||
layer="universe_gate",
|
||
state="过期",
|
||
detail={
|
||
"reason_code": item.get("reason_code", ""),
|
||
"reason_label": item.get("reason_label", ""),
|
||
"volume_24h": item.get("volume_24h", 0),
|
||
"candidate_stage": "universe_gate",
|
||
},
|
||
)
|
||
log_screening(
|
||
layer="universe_gate",
|
||
symbol=item.get("symbol", ""),
|
||
state="过期",
|
||
score=0,
|
||
price=item.get("price", 0) or 0,
|
||
signals=[item.get("reason_label", "交易宇宙过滤")],
|
||
change_24h=item.get("change_24h", 0) or 0,
|
||
funding_rate=0,
|
||
detail=detail,
|
||
)
|
||
logged += 1
|
||
return logged
|
||
|
||
|
||
# ==================== 第一层:粗筛 ====================
|
||
|
||
def layer1_coarse_filter():
|
||
"""粗筛 — 只检测量价行为+布林收窄,不计算任何滞后指标"""
|
||
print("=== 第一层:粗筛(v11纯前瞻) ===")
|
||
tickers = fetch_all_tickers()
|
||
coverage_meta = dict(getattr(fetch_all_tickers, "last_coverage_meta", {}) or {})
|
||
universe_exclusions = list(getattr(fetch_all_tickers, "last_universe_exclusions", []) or [])
|
||
excluded_symbols = {item.get("symbol", "") for item in universe_exclusions}
|
||
funding_rates = fetch_funding_rates()
|
||
weights = get_dynamic_weights()
|
||
candidates = {}
|
||
kline_attempt_symbols = set()
|
||
h1_success_symbols = set()
|
||
h4_success_symbols = set()
|
||
short_tf_scan_count = 0
|
||
|
||
# === 24h筛选历史豁免 (v1.6.9) ===
|
||
# 过去24h内在screening_log出现过的币,不受"涨太多"过滤限制
|
||
# 防止ICP/SUI类:系统早已盯上但被burst_threshold×1.5误挡
|
||
from app.db.schema import get_conn as _get_conn
|
||
_c = _get_conn()
|
||
_recent = _c.execute("""
|
||
SELECT DISTINCT symbol FROM screening_log
|
||
WHERE scan_time >= %s
|
||
""", ((datetime.now() - timedelta(hours=24)).isoformat(),)).fetchall()
|
||
_c.close()
|
||
recently_screened = {r["symbol"] for r in _recent}
|
||
print(f" 24h已筛选币种: {len(recently_screened)} 只,豁免涨太多过滤")
|
||
|
||
scan_cfg = _kline_scan_config()
|
||
bypass_cfg = get_screener_section("static_accumulation_bypass")
|
||
hl_cfg = get_screener_section("higher_lows")
|
||
cs_cfg = get_screener_section("compression_surge")
|
||
bypass_min_vol = bypass_cfg.get("min_volume_24h", 2000000)
|
||
hl_min_vol = hl_cfg.get("min_volume_24h", 2000000) if hl_cfg.get("enabled", True) else float("inf")
|
||
cs_min_vol = cs_cfg.get("min_volume_24h", 2000000) if cs_cfg.get("enabled", True) else float("inf")
|
||
main_min_vol = max(0.0, float(scan_cfg.get("main_min_volume_usd") or min(MIN_24H_VOLUME_USD, MEME_MIN_24H_VOLUME_USD)))
|
||
low_turnover_threshold = min(v for v in [main_min_vol, bypass_min_vol, hl_min_vol, cs_min_vol] if v != float("inf"))
|
||
cached_runtime_exclusions = (
|
||
get_active_universe_exclusions(tickers.keys(), reason_types=("dynamic", "transient"))
|
||
if scan_cfg.get("respect_dynamic_universe_cache", True)
|
||
else {}
|
||
)
|
||
cached_runtime_skip_count = 0
|
||
main_scan_symbols = set(_rule_based_kline_scan_symbols(
|
||
tickers,
|
||
recently_screened=recently_screened,
|
||
min_volume=main_min_vol,
|
||
emergency_max=scan_cfg["emergency_main_max_symbols"],
|
||
))
|
||
bypass_scan_symbols = set(_rule_based_kline_scan_symbols(
|
||
tickers,
|
||
recently_screened=recently_screened,
|
||
min_volume=low_turnover_threshold,
|
||
emergency_max=scan_cfg["emergency_bypass_max_symbols"],
|
||
))
|
||
print(
|
||
f" K线扫描规则: 主扫描{len(main_scan_symbols)}/{len(tickers)},"
|
||
f"旁路扫描{len(bypass_scan_symbols)}/{len(tickers)},动态缓存{len(cached_runtime_exclusions)}"
|
||
)
|
||
|
||
try:
|
||
futures_24h = exchange.fapiPublicGetTicker24hr()
|
||
except Exception:
|
||
futures_24h_map = {}
|
||
else:
|
||
futures_24h_map = {
|
||
item.get("symbol", "").replace("USDT", "/USDT"): item
|
||
for item in futures_24h
|
||
if item.get("symbol", "").endswith("USDT")
|
||
}
|
||
|
||
for symbol, info in tickers.items():
|
||
if symbol not in main_scan_symbols:
|
||
continue
|
||
if _is_cached_dynamic_exclusion(symbol, info, cached_runtime_exclusions, low_turnover_threshold):
|
||
cached_runtime_skip_count += 1
|
||
cached = cached_runtime_exclusions.get(symbol.upper()) or {}
|
||
universe_exclusions.append({
|
||
"symbol": symbol,
|
||
"base": symbol.split("/")[0],
|
||
"price": info.get("price", 0) or 0,
|
||
"volume_24h": info.get("volume_24h", 0) or 0,
|
||
"change_24h": info.get("change_24h", 0) or 0,
|
||
"reason_code": cached.get("reason_code") or "cached_dynamic_exclusion",
|
||
"reason_label": cached.get("reason_label") or "动态宇宙过滤缓存",
|
||
"reason_type": cached.get("reason_type") or "dynamic",
|
||
"cache_hit": True,
|
||
})
|
||
excluded_symbols.add(symbol)
|
||
continue
|
||
base = symbol.split("/")[0]
|
||
vol = info["volume_24h"]
|
||
change = info["change_24h"]
|
||
meme = is_meme_coin(symbol)
|
||
min_vol = MEME_MIN_24H_VOLUME_USD if meme else MIN_24H_VOLUME_USD
|
||
if vol < min_vol:
|
||
continue
|
||
|
||
anomalies = []
|
||
anomaly_score = 0
|
||
vp_data = None
|
||
bb_data = None
|
||
static_accumulation = None
|
||
short_tf_ignition = None
|
||
short_breakdown_1h = {"detected": False}
|
||
|
||
# 1H量价齐飞检测(核心)
|
||
kline_attempt_symbols.add(symbol)
|
||
h1_df = fetch_klines(symbol, "1h", limit=72)
|
||
h4_df = fetch_klines(symbol, "4h", limit=100)
|
||
if h1_df is not None and len(h1_df) > 0:
|
||
h1_success_symbols.add(symbol)
|
||
if h4_df is not None and len(h4_df) > 0:
|
||
h4_success_symbols.add(symbol)
|
||
if h1_df is not None and len(h1_df) >= 20:
|
||
vp_data = detect_volume_price_fly(h1_df)
|
||
if vp_data:
|
||
# 量价齐飞K≥1 → 最强信号
|
||
if vp_data["vp_fly_count"] >= 2:
|
||
for detail in vp_data["vp_fly_details"]:
|
||
anomalies.append(f"量价齐飞(量{detail['vol_ratio']}x,实体{detail['body_pct']}%)")
|
||
if detail["vol_ratio"] >= 10:
|
||
anomaly_score += weights["N倍放量(≥10x)"]
|
||
else:
|
||
anomaly_score += weights["量价齐飞"]
|
||
anomalies.append(f"连续2根量价齐飞K(极强)")
|
||
anomaly_score += 3 # 多根量价齐飞额外加分
|
||
elif vp_data["vp_fly_count"] == 1:
|
||
detail = vp_data["vp_fly_details"][0]
|
||
anomalies.append(f"量价齐飞(量{detail['vol_ratio']}x,实体{detail['body_pct']}%)")
|
||
if detail["vol_ratio"] >= 10:
|
||
anomaly_score += weights["N倍放量(≥10x)"]
|
||
else:
|
||
anomaly_score += weights["量价齐飞"]
|
||
elif vp_data.get("relaxed_vp_fly_count", 0) >= 2 and vp_data["vp_fly_details"]:
|
||
for detail in vp_data["vp_fly_details"][:2]:
|
||
anomalies.append(f"量价齐飞(量{detail['vol_ratio']}x,实体{detail['body_pct']}%)")
|
||
anomaly_score += weights["量价齐飞"]
|
||
anomalies.append("连续2根量价齐飞K(放宽旁路)")
|
||
anomaly_score += 2
|
||
|
||
# 连续3x放量≥3根 → 真放量(对比:BIO单根10x→失败)
|
||
if vp_data["max_consecutive_3x"] >= 3:
|
||
anomalies.append(f"连续{vp_data['max_consecutive_3x']}根3x放量")
|
||
anomaly_score += weights["连续3x放量(≥3根)"]
|
||
elif vp_data["max_consecutive_3x"] >= 2:
|
||
anomalies.append(f"连续{vp_data['max_consecutive_3x']}根3x放量")
|
||
anomaly_score += 2
|
||
|
||
# 大量但无量价齐飞 → 量价背离假信号(最低权重)
|
||
if vp_data["max_vol_ratio"] >= 5 and vp_data["vp_fly_count"] == 0:
|
||
anomalies.append(f"1H放量({vp_data['max_vol_ratio']}x)但无量价齐飞(量价背离)")
|
||
anomaly_score += 1 # 量价背离最低分
|
||
|
||
short_breakdown_1h = detect_breakdown_retest_short_1h(h1_df, change_24h=change)
|
||
if short_breakdown_1h.get("detected"):
|
||
quality_score = float(short_breakdown_1h.get("quality_score") or short_breakdown_1h.get("score") or 0)
|
||
if quality_score >= 5:
|
||
anomalies.extend(short_breakdown_1h.get("signals") or ["1H破位反抽做空结构"])
|
||
anomaly_score += int(quality_score or 4)
|
||
|
||
# 布林收窄检测(4H级别)
|
||
if h4_df is not None and len(h4_df) >= 20:
|
||
bb_data = detect_bollinger_squeeze(h4_df)
|
||
if bb_data:
|
||
if bb_data["tight_squeeze"]:
|
||
anomalies.append(f"4H布林极度收窄(宽度{bb_data['bb_width_pct']}%,{bb_data['squeeze_direction']})")
|
||
anomaly_score += weights["布林收窄"]
|
||
elif bb_data["squeeze_count"] >= 4:
|
||
anomalies.append(f"4H布林收窄(宽度{bb_data['bb_width_pct']}%,{bb_data['squeeze_direction']})")
|
||
anomaly_score += 2
|
||
|
||
static_accumulation = detect_static_accumulation(symbol, h4_df)
|
||
if static_accumulation and static_accumulation["vol_ratio"] >= 1.2:
|
||
anomalies.append(
|
||
f"4H静K蓄力旁路({static_accumulation['static_count']}静K,量比{static_accumulation['vol_ratio']}x)"
|
||
)
|
||
anomaly_score += max(1, weights.get("静K蓄力", 2))
|
||
elif (
|
||
static_accumulation
|
||
and static_accumulation.get("static_count", 0) >= 8
|
||
and static_accumulation.get("vol_ratio", 0) >= 0.9
|
||
):
|
||
# 长静K + 温和放量旁路: 复盘PNT/CREAM/CLV等漏选样本后追加。
|
||
# 比常规旁路低1分,让细筛/确认层做最终把关,避免假阳性扩散。
|
||
anomalies.append(
|
||
f"4H长静K温和放量({static_accumulation['static_count']}静K,量比{static_accumulation['vol_ratio']}x)"
|
||
)
|
||
anomaly_score += 1
|
||
|
||
short_cfg = get_screener_section("short_timeframe_ignition")
|
||
emergency_short_tf_limit = int(scan_cfg.get("emergency_short_tf_max_symbols") or 0)
|
||
allow_short_tf_scan = _should_scan_short_tf(symbol, info, recently_screened=recently_screened, cfg=scan_cfg)
|
||
if emergency_short_tf_limit > 0 and short_tf_scan_count >= emergency_short_tf_limit and not _is_top_gainer_candidate(symbol, info):
|
||
allow_short_tf_scan = False
|
||
if short_cfg.get("enabled", True) and allow_short_tf_scan and vol >= float(short_cfg.get("min_volume_24h", 5_000_000) or 0):
|
||
short_tf_scan_count += 1
|
||
m15_result = {"found": False, "timeframe": "15m"}
|
||
m5_result = {"found": False, "timeframe": "5m"}
|
||
m15_df = fetch_klines(symbol, "15m", limit=120)
|
||
if m15_df is not None and len(m15_df) >= 30:
|
||
m15_result = detect_short_timeframe_ignition(m15_df, "15m", short_cfg.get("m15", {}))
|
||
# 5m 噪音更大:只有 15m 已有启动,或 1H/4H已有结构背景时才启用。
|
||
if m15_result.get("found") or anomalies:
|
||
m5_df = fetch_klines(symbol, "5m", limit=120)
|
||
if m5_df is not None and len(m5_df) >= 30:
|
||
m5_result = detect_short_timeframe_ignition(m5_df, "5m", short_cfg.get("m5", {}))
|
||
short_signals = [x for x in (m15_result, m5_result) if x.get("found")]
|
||
if short_signals:
|
||
short_tf_ignition = {
|
||
"signals": short_signals,
|
||
"m15": m15_result,
|
||
"m5": m5_result,
|
||
"resonance": bool(m15_result.get("found") and m5_result.get("found")),
|
||
}
|
||
if short_tf_ignition["resonance"]:
|
||
anomalies.append("5m/15m短周期共振启动")
|
||
anomaly_score += int(short_cfg.get("resonance_bonus", 1) or 1)
|
||
if m15_result.get("found"):
|
||
anomalies.append(m15_result.get("signal") or "15min短周期启动")
|
||
anomaly_score += int(short_cfg.get("score_m15", 2) or 2)
|
||
if m5_result.get("found"):
|
||
anomalies.append(m5_result.get("signal") or "5min极早期启动")
|
||
anomaly_score += int(short_cfg.get("score_m5", 1) or 1)
|
||
|
||
# 资金费率极端(保留)
|
||
fr = funding_rates.get(symbol, 0)
|
||
funding_cfg = funding_rate_params()
|
||
if fr > funding_cfg.get("long_extreme", 0.001):
|
||
anomalies.append(f"资金费率极端偏高({fr*100:.3f}%)")
|
||
anomaly_score += 2
|
||
elif fr < funding_cfg.get("short_extreme", -0.0005):
|
||
anomalies.append(f"资金费率极端偏低({fr*100:.3f}%)")
|
||
anomaly_score += 2
|
||
|
||
# 已经进入24h强势榜的币不在粗筛静默丢弃;先作为异动发现记录,
|
||
# 后续由细筛/确认判断是否追高、是否等待二次结构。
|
||
burst_threshold = get_burst_threshold(symbol)
|
||
is_unseen_top_gainer = change > burst_threshold * 1.5 and symbol not in recently_screened
|
||
|
||
if anomalies:
|
||
if is_unseen_top_gainer:
|
||
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
|
||
# === 冲高回落检查:量价齐飞后持续阴跌→拒绝 ===
|
||
if isinstance(vp_data, dict) and (vp_data.get("pullback") or {}).get("is_pullback"):
|
||
pb = vp_data["pullback"]
|
||
print(f" ⛔ {symbol} 冲高回落拒绝: {pb['reason']}")
|
||
continue # 直接跳过,不入候选池
|
||
|
||
futures_24h = futures_24h_map.get(symbol, {})
|
||
quote_volume = float(futures_24h.get("quoteVolume") or vol or 0)
|
||
base_volume = float(futures_24h.get("volume") or 0)
|
||
weighted_avg_price = float(futures_24h.get("weightedAvgPrice") or info.get("price") or 0)
|
||
turnover_acc_1h = round(vp_data["max_vol_ratio"], 2) if vp_data else 0
|
||
turnover_acc_4h = round(static_accumulation["vol_ratio"], 2) if static_accumulation else 0
|
||
candidates[symbol] = {
|
||
"anomalies": anomalies,
|
||
"anomaly_score": anomaly_score,
|
||
"price": info["price"],
|
||
"change_24h": change,
|
||
"volume_24h": vol,
|
||
"funding_rate": fr,
|
||
"is_meme": meme,
|
||
"vp_data": vp_data,
|
||
"bb_data": bb_data,
|
||
"static_accumulation": static_accumulation,
|
||
"short_tf_ignition": short_tf_ignition,
|
||
"short_breakdown_retest_1h": short_breakdown_1h,
|
||
"h4_df": h4_df,
|
||
"turnover_acceleration_1h": turnover_acc_1h,
|
||
"turnover_acceleration_4h": turnover_acc_4h,
|
||
"base_volume_24h": round(base_volume, 2),
|
||
"quote_volume_24h": round(quote_volume, 2),
|
||
"weighted_avg_price": round(weighted_avg_price, 6) if weighted_avg_price else 0,
|
||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||
}
|
||
|
||
# ==== 第二遍扫描:低成交量静K蓄力旁路 + 底部抬高 + 压缩放量 ====
|
||
bypass_min_vol_ratio = bypass_cfg.get("min_vol_ratio", 1.2)
|
||
bypass_count = 0
|
||
hl_count_total = 0
|
||
cs_count_total = 0
|
||
|
||
for symbol, info in tickers.items():
|
||
if symbol in candidates:
|
||
continue
|
||
if symbol not in bypass_scan_symbols:
|
||
continue
|
||
if _is_cached_dynamic_exclusion(symbol, info, cached_runtime_exclusions, low_turnover_threshold):
|
||
cached_runtime_skip_count += 1
|
||
if symbol not in excluded_symbols:
|
||
cached = cached_runtime_exclusions.get(symbol.upper()) or {}
|
||
universe_exclusions.append({
|
||
"symbol": symbol,
|
||
"base": symbol.split("/")[0],
|
||
"price": info.get("price", 0) or 0,
|
||
"volume_24h": info.get("volume_24h", 0) or 0,
|
||
"change_24h": info.get("change_24h", 0) or 0,
|
||
"reason_code": cached.get("reason_code") or "cached_dynamic_exclusion",
|
||
"reason_label": cached.get("reason_label") or "动态宇宙过滤缓存",
|
||
"reason_type": cached.get("reason_type") or "dynamic",
|
||
"cache_hit": True,
|
||
})
|
||
excluded_symbols.add(symbol)
|
||
continue
|
||
|
||
vol = info["volume_24h"]
|
||
if vol < bypass_min_vol and vol < hl_min_vol and vol < cs_min_vol:
|
||
continue
|
||
|
||
change = info["change_24h"]
|
||
burst_threshold = get_burst_threshold(symbol)
|
||
is_unseen_top_gainer = change > burst_threshold * 1.5 and symbol not in recently_screened
|
||
|
||
meme = is_meme_coin(symbol)
|
||
fr = funding_rates.get(symbol, 0)
|
||
|
||
# 拉取4H数据(只拉一次,多个检测复用)
|
||
kline_attempt_symbols.add(symbol)
|
||
h4_df = fetch_klines(symbol, "4h", limit=100)
|
||
if h4_df is not None and len(h4_df) > 0:
|
||
h4_success_symbols.add(symbol)
|
||
if h4_df is None or len(h4_df) < 20:
|
||
continue
|
||
|
||
added = False # 防止同一个币被多个检测重复收录
|
||
|
||
# 1) 静K蓄力旁路
|
||
if vol >= bypass_min_vol:
|
||
static_acc = detect_static_accumulation(symbol, h4_df)
|
||
if static_acc and static_acc["vol_ratio"] >= bypass_min_vol_ratio:
|
||
anomalies = [
|
||
f"4H静K蓄力旁路({static_acc['static_count']}静K,量比{static_acc['vol_ratio']}x)"
|
||
]
|
||
if is_unseen_top_gainer:
|
||
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
|
||
anomaly_score = max(1, weights.get("静K蓄力", 2))
|
||
|
||
candidates[symbol] = {
|
||
"anomalies": anomalies,
|
||
"anomaly_score": anomaly_score,
|
||
"price": info["price"],
|
||
"change_24h": change,
|
||
"volume_24h": vol,
|
||
"funding_rate": fr,
|
||
"is_meme": meme,
|
||
"vp_data": None,
|
||
"bb_data": None,
|
||
"static_accumulation": static_acc,
|
||
"h4_df": h4_df,
|
||
"turnover_acceleration_1h": 0,
|
||
"turnover_acceleration_4h": round(static_acc["vol_ratio"], 2),
|
||
"base_volume_24h": 0,
|
||
"quote_volume_24h": 0,
|
||
"weighted_avg_price": info.get("price", 0),
|
||
"bypass_origin": True,
|
||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||
}
|
||
bypass_count += 1
|
||
added = True
|
||
|
||
# 2) 底部抬高检测(成交量在 hl_min_vol~主门槛之间,不重复收录)
|
||
if not added and hl_cfg.get("enabled", True) and hl_min_vol <= vol < main_min_vol:
|
||
hl_result = detect_higher_lows(h4_df, hl_cfg)
|
||
if hl_result["found"]:
|
||
anomalies = [f"4H {hl_result['signal']}"]
|
||
if is_unseen_top_gainer:
|
||
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
|
||
anomaly_score = hl_result["hl_score"]
|
||
|
||
candidates[symbol] = {
|
||
"anomalies": anomalies,
|
||
"anomaly_score": anomaly_score,
|
||
"price": info["price"],
|
||
"change_24h": change,
|
||
"volume_24h": vol,
|
||
"funding_rate": fr,
|
||
"is_meme": meme,
|
||
"vp_data": None,
|
||
"bb_data": None,
|
||
"static_accumulation": None,
|
||
"higher_lows": hl_result,
|
||
"h4_df": h4_df,
|
||
"turnover_acceleration_1h": 0,
|
||
"turnover_acceleration_4h": 0,
|
||
"base_volume_24h": 0,
|
||
"quote_volume_24h": 0,
|
||
"weighted_avg_price": info.get("price", 0),
|
||
"bypass_origin": "higher_lows",
|
||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||
}
|
||
hl_count_total += 1
|
||
added = True
|
||
|
||
# 3) 压缩放量检测(成交量在 cs_min_vol~主门槛之间,不重复收录)
|
||
if not added and cs_cfg.get("enabled", True) and cs_min_vol <= vol < main_min_vol:
|
||
cs_result = detect_compression_surge(h4_df, cs_cfg)
|
||
if cs_result["found"]:
|
||
anomalies = [f"4H {cs_result['signal']}"]
|
||
if is_unseen_top_gainer:
|
||
anomalies.append(_top_gainer_signal(symbol, change, vol) + ",追高风险待确认")
|
||
anomaly_score = cs_result["score"]
|
||
|
||
candidates[symbol] = {
|
||
"anomalies": anomalies,
|
||
"anomaly_score": anomaly_score,
|
||
"price": info["price"],
|
||
"change_24h": change,
|
||
"volume_24h": vol,
|
||
"funding_rate": fr,
|
||
"is_meme": meme,
|
||
"vp_data": None,
|
||
"bb_data": None,
|
||
"static_accumulation": None,
|
||
"compression_surge": cs_result,
|
||
"h4_df": h4_df,
|
||
"turnover_acceleration_1h": 0,
|
||
"turnover_acceleration_4h": round(cs_result["vol_ratio"], 2),
|
||
"base_volume_24h": 0,
|
||
"quote_volume_24h": 0,
|
||
"weighted_avg_price": info.get("price", 0),
|
||
"bypass_origin": "compression_surge",
|
||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||
}
|
||
cs_count_total += 1
|
||
added = True
|
||
|
||
top_gainer_count = _attach_top_gainer_discovery(candidates, tickers, recently_screened)
|
||
|
||
# 第一道漏斗:把明确不可交易/太低成交额的资产写成独立阶段,研发侧可审计,
|
||
# 但不让它们进入后续机会链路。
|
||
low_turnover_threshold = min(v for v in [main_min_vol, bypass_min_vol, hl_min_vol, cs_min_vol] if v != float("inf"))
|
||
for symbol, info in tickers.items():
|
||
if symbol in candidates or symbol in excluded_symbols:
|
||
continue
|
||
if float(info.get("volume_24h") or 0) < low_turnover_threshold:
|
||
gate = universe_gate_reason(symbol.split("/")[0], info.get("volume_24h") or 0, low_turnover_threshold, symbol=symbol)
|
||
if gate:
|
||
universe_exclusions.append({
|
||
"symbol": symbol,
|
||
"base": symbol.split("/")[0],
|
||
"price": info.get("price", 0) or 0,
|
||
"volume_24h": info.get("volume_24h", 0) or 0,
|
||
"change_24h": info.get("change_24h", 0) or 0,
|
||
"reason_type": "dynamic",
|
||
"min_volume": low_turnover_threshold,
|
||
**gate,
|
||
})
|
||
excluded_symbols.add(symbol)
|
||
|
||
record_universe_decisions(universe_exclusions)
|
||
universe_logged = _log_universe_exclusions(universe_exclusions)
|
||
|
||
if bypass_count or hl_count_total or cs_count_total:
|
||
parts = []
|
||
if bypass_count:
|
||
parts.append(f"静K蓄力旁路+{bypass_count}")
|
||
if hl_count_total:
|
||
parts.append(f"底部抬高+{hl_count_total}")
|
||
if cs_count_total:
|
||
parts.append(f"压缩放量+{cs_count_total}")
|
||
print(f"第二遍扫描: {', '.join(parts)}个候选")
|
||
|
||
# === 舆情共振加权 ===
|
||
try:
|
||
from app.services.sentiment_monitor import get_sentiment_scores
|
||
sentiment_cfg = get_screener_section("sentiment") or {}
|
||
if sentiment_cfg.get("enabled", True):
|
||
sentiment_scores = get_sentiment_scores()
|
||
if sentiment_scores:
|
||
max_bonus = sentiment_max_bonus()
|
||
bonus_count = 0
|
||
for symbol, cand in candidates.items():
|
||
sent = sentiment_scores.get(symbol)
|
||
if sent and sent.get("bonus", 0) > 0:
|
||
cand["anomaly_score"] += sent["bonus"]
|
||
cand["anomalies"].append(f"📢 舆情共振({sent['details']})+{sent['bonus']}")
|
||
cand["sentiment"] = sent
|
||
cand["sentiment_bonus"] = sent["bonus"]
|
||
bonus_count += 1
|
||
if bonus_count:
|
||
print(f"舆情共振: {bonus_count}个候选加分")
|
||
except Exception as e:
|
||
print(f"舆情模块加载失败(非致命): {e}")
|
||
|
||
total_bypass = bypass_count + hl_count_total + cs_count_total
|
||
print(f"粗筛结果: {len(candidates)}个候选(宇宙过滤{len(universe_exclusions)}个,记录{universe_logged}个;含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total};强势榜发现{top_gainer_count}个)")
|
||
for symbol, cand in candidates.items():
|
||
signals = cand.get("anomalies", [])
|
||
if cand.get("short_tf_ignition"):
|
||
record_short_tf_samples(symbol, {**cand, "signal_recency": _build_signal_recency(cand)})
|
||
log_screening(
|
||
layer="粗筛",
|
||
symbol=symbol,
|
||
state="候选",
|
||
score=cand.get("anomaly_score", 0),
|
||
price=cand.get("price", 0),
|
||
signals=signals,
|
||
is_meme=int(cand.get("is_meme") or 0),
|
||
change_24h=cand.get("change_24h", 0),
|
||
funding_rate=cand.get("funding_rate", 0),
|
||
detail=build_screening_detail(
|
||
layer="粗筛",
|
||
state="候选",
|
||
signals=signals,
|
||
candidate=cand,
|
||
detail={
|
||
"candidate_stage": "discovery_candidate",
|
||
"volume_24h": cand.get("volume_24h", 0),
|
||
"turnover_acceleration_1h": cand.get("turnover_acceleration_1h", 0),
|
||
"turnover_acceleration_4h": cand.get("turnover_acceleration_4h", 0),
|
||
"signal_recency": _build_signal_recency(cand),
|
||
"bypass_origin": cand.get("bypass_origin", ""),
|
||
"source_types": discovery_source_types(cand),
|
||
"signal_codes": build_signal_codes(signals),
|
||
},
|
||
),
|
||
)
|
||
layer1_coarse_filter.last_funnel_meta = {
|
||
"universe_gate_count": len(universe_exclusions),
|
||
"universe_gate_logged": universe_logged,
|
||
"top_gainer_discovery_count": top_gainer_count,
|
||
"coverage": {
|
||
**coverage_meta,
|
||
"tradable_universe_count": len(tickers),
|
||
"universe_gate_count": len(universe_exclusions),
|
||
"static_exclusion_count": sum(1 for item in universe_exclusions if (item.get("reason_type") or reason_type_for(item.get("reason_code"))) == "static"),
|
||
"dynamic_exclusion_count": sum(1 for item in universe_exclusions if (item.get("reason_type") or reason_type_for(item.get("reason_code"))) == "dynamic"),
|
||
"low_turnover_count": sum(1 for item in universe_exclusions if item.get("reason_code") == "low_turnover"),
|
||
"stale_ticker_count": sum(1 for item in universe_exclusions if item.get("reason_code") == "stale_ticker"),
|
||
"kline_attempt_count": len(kline_attempt_symbols),
|
||
"kline_h1_success_count": len(h1_success_symbols),
|
||
"kline_h4_success_count": len(h4_success_symbols),
|
||
"coarse_candidate_count": len(candidates),
|
||
"top_gainer_discovery_count": top_gainer_count,
|
||
"main_kline_min_volume_usd": scan_cfg["main_min_volume_usd"],
|
||
"bypass_kline_min_volume_usd": low_turnover_threshold,
|
||
"emergency_main_kline_scan_budget": scan_cfg["emergency_main_max_symbols"],
|
||
"emergency_bypass_kline_scan_budget": scan_cfg["emergency_bypass_max_symbols"],
|
||
"main_kline_scan_pool_count": len(main_scan_symbols),
|
||
"bypass_kline_scan_pool_count": len(bypass_scan_symbols),
|
||
"short_tf_scan_count": short_tf_scan_count,
|
||
"emergency_short_tf_scan_budget": scan_cfg["emergency_short_tf_max_symbols"],
|
||
"cached_runtime_skip_count": cached_runtime_skip_count,
|
||
},
|
||
}
|
||
return candidates
|
||
|
||
|
||
# ==================== 第二层:细筛 ====================
|
||
|
||
def layer2_fine_filter(candidates):
|
||
"""细筛 — 静K蓄力+量价突变(山寨币专用 v1.5)"""
|
||
print("=== 第二层:细筛(v11纯前瞻) ===")
|
||
qualified = {}
|
||
rejected_count = 0
|
||
weights = get_dynamic_weights()
|
||
|
||
# 板块联动检测
|
||
sector_perf = {}
|
||
for sector, coins in SECTOR_MEMBERS.items():
|
||
sector_perf[sector] = {}
|
||
for coin in coins:
|
||
if coin in candidates:
|
||
sector_perf[sector][coin] = candidates[coin]["change_24h"]
|
||
else:
|
||
try:
|
||
ticker = exchange.fetch_ticker(coin)
|
||
pct = ticker.get("percentage", 0) or 0
|
||
sector_perf[sector][coin] = pct
|
||
except Exception:
|
||
pass
|
||
|
||
leaders = dynamic_leader_detection(sector_perf)
|
||
hot_sectors = {s for s, info in leaders.items() if info["is_leader_hot"]}
|
||
print(f"热门板块: {hot_sectors}")
|
||
|
||
static_cfg = get_screener_section("static_accumulation_bypass")
|
||
static_bypass_min_score = static_cfg.get("min_score", 3)
|
||
static_bypass_min_vol_ratio = static_cfg.get("min_vol_ratio", 1.2)
|
||
|
||
for symbol, cand in candidates.items():
|
||
signals = []
|
||
score = cand["anomaly_score"]
|
||
|
||
meme = cand["is_meme"]
|
||
base_state = None
|
||
force_accumulate_reason = None
|
||
|
||
# 继承粗筛量价齐飞数据(核心确认信号)
|
||
vp_data = cand.get("vp_data")
|
||
if vp_data:
|
||
if vp_data["vp_fly_count"] >= 2:
|
||
signals.append(f"1H {vp_data['vp_fly_count']}根量价齐飞K")
|
||
score += 3
|
||
elif vp_data["vp_fly_count"] == 1:
|
||
signals.append(f"1H 量价齐飞K(量{vp_data['max_vol_ratio']}x)")
|
||
score += 2
|
||
if vp_data.get("stale_vp_fly_count", 0) and vp_data["vp_fly_count"] == 0:
|
||
stale = vp_data.get("stale_vp_fly_details", [{}])[-1]
|
||
signals.append(f"1H历史量价齐飞已过期({stale.get('age_hours')}小时前, 量{stale.get('vol_ratio')}x)")
|
||
if vp_data["max_consecutive_3x"] >= 4:
|
||
signals.append(f"1H 连续{vp_data['max_consecutive_3x']}根3x放量")
|
||
score += 2
|
||
|
||
short_tf = cand.get("short_tf_ignition") or {}
|
||
if short_tf.get("signals"):
|
||
if short_tf.get("resonance"):
|
||
signals.append("短周期共振:5m/15m同步启动")
|
||
score += 1
|
||
for item in short_tf.get("signals", []):
|
||
if item.get("found"):
|
||
signals.append(item.get("signal") or f"{item.get('timeframe')}短周期启动")
|
||
|
||
# 继承布林数据(蓄力末期特征)
|
||
bb_data = cand.get("bb_data")
|
||
if bb_data and bb_data["tight_squeeze"]:
|
||
signals.append(f"4H布林极度收窄({bb_data['squeeze_direction']})")
|
||
|
||
# 静K蓄力 — 粗筛已计分,细筛只打标签+时长bonus
|
||
static_accumulation = cand.get("static_accumulation")
|
||
if static_accumulation and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio:
|
||
sc = static_accumulation['static_count']
|
||
vr = static_accumulation['vol_ratio']
|
||
signals.append(f"4H静K蓄力观察({sc}静K,量比{vr}x)")
|
||
# 蓄力时长加成: 每多4根+1分 (静K越久爆发越猛)
|
||
duration_bonus = max(0, (sc - static_cfg.get('min_static_count', 4)) // 4)
|
||
if duration_bonus > 0:
|
||
score += duration_bonus
|
||
|
||
# 底部抬高 — 粗筛第二遍扫描命中,细筛打标签+标记蓄力
|
||
higher_lows = cand.get("higher_lows")
|
||
if higher_lows and higher_lows.get("found"):
|
||
signals.append(f"4H {higher_lows['signal']}")
|
||
|
||
# 压缩放量 — 粗筛第二遍扫描命中,细筛打标签+标记蓄力
|
||
compression_surge = cand.get("compression_surge")
|
||
if compression_surge and compression_surge.get("found"):
|
||
signals.append(f"4H {compression_surge['signal']}")
|
||
|
||
# 拉取4H数据做PA分析(只保留对山寨币有用的信号)
|
||
h4_df = cand.get("h4_df")
|
||
h4_pa = full_pa_analysis(h4_df, "4h") if h4_df is not None and len(h4_df) >= 30 else None
|
||
|
||
if h4_pa:
|
||
h4_candles_class = h4_pa["candles_class"]
|
||
recent_4h = h4_candles_class[-6:] if len(h4_candles_class) >= 6 else h4_candles_class
|
||
|
||
# 静K蓄力标签(粗筛已计分,只打标签)
|
||
static_count_4h = sum(1 for c in recent_4h if c["type"] == "static")
|
||
if static_count_4h >= 3:
|
||
signals.append(f"4H {static_count_4h}静K蓄力")
|
||
|
||
# 起爆点:静K→动K转折(辅助确认)— 只承认最近/上一根4H内发生
|
||
h4_ignitions = h4_pa["ignition_points"]
|
||
stale_h4_ignitions = []
|
||
for ig in h4_ignitions[-2:]:
|
||
age = ig.get("age_bars", 999)
|
||
if age > 1:
|
||
stale_h4_ignitions.append(ig)
|
||
continue
|
||
if ig["direction"] == 1:
|
||
signals.append(f"4H {ig['signal_type']}(强度{ig['strength_ratio']}×)")
|
||
score += weights.get("静K→动K转折", weights.get("静K动K转折", 3))
|
||
elif ig["direction"] == -1:
|
||
signals.append(f"4H {ig['signal_type']}(空头起爆,强度{ig['strength_ratio']}×)")
|
||
if stale_h4_ignitions:
|
||
ig = stale_h4_ignitions[-1]
|
||
signals.append(f"4H历史起爆点已过期({ig.get('age_bars')}根前, 强度{ig.get('strength_ratio')}×)")
|
||
|
||
# 板块联动 — 纯信息参考,不加分
|
||
coin_sectors = get_sector_for_coin(symbol)
|
||
sector_signal_count = 0
|
||
for sector in coin_sectors:
|
||
if sector in hot_sectors:
|
||
leader_info = leaders[sector]
|
||
signals.append(f"板块联动: {sector}龙头{leader_info['leader']}涨{leader_info['leader_pct']:.1f}%")
|
||
sector_signal_count += 1
|
||
|
||
# 大户方向
|
||
ratio = fetch_top_trader_ratio(symbol)
|
||
if ratio:
|
||
if ratio["long_pct"] > top_trader_params().get("long_pct_min", 0.55) * 100:
|
||
signals.append(f"大户偏多({ratio['long_pct']:.0f}%)")
|
||
score += weights["大户偏多"]
|
||
|
||
# 判断状态
|
||
threshold_score_main, threshold_score_meme, accumulate_threshold = state_score_thresholds()
|
||
|
||
if score >= (threshold_score_meme if meme else threshold_score_main):
|
||
state = "加速"
|
||
elif score >= accumulate_threshold:
|
||
state = "蓄力"
|
||
else:
|
||
state = "过期"
|
||
|
||
base_state = state
|
||
|
||
static_resonance = _static_bypass_resonance(
|
||
cand,
|
||
static_cfg=static_cfg,
|
||
sector_signal_count=sector_signal_count,
|
||
top_trader_ratio=ratio,
|
||
vp_data=vp_data,
|
||
)
|
||
static_resonance_ok = (
|
||
not static_cfg.get("require_resonance", False)
|
||
or len(static_resonance) >= int(static_cfg.get("min_resonance_signals", 1))
|
||
)
|
||
|
||
# 静K蓄力旁路:即使原始状态是过期,有静K蓄力+量比达标+共振→至少蓄力
|
||
if (
|
||
state == "过期"
|
||
and static_accumulation
|
||
and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio
|
||
and score >= static_bypass_min_score
|
||
and static_resonance_ok
|
||
):
|
||
state = "蓄力"
|
||
force_accumulate_reason = "静K蓄力旁路"
|
||
signals.append("静K蓄力旁路入池")
|
||
|
||
# v1.7.2:强静K蓄力直升加速。
|
||
# 复盘PNT/CREAM/CLV/STORJ/ZEC等漏选样本后发现:山寨爆发前常见“长时间静K蓄力 + 温和放量”,
|
||
# 只放进蓄力观察池仍可能在确认层前漏掉,因此允许强静K样本直接进入加速推荐/确认链路。
|
||
direct_acc_cfg = static_cfg.get("direct_accelerate", {}) or {}
|
||
if (
|
||
direct_acc_cfg.get("enabled", False)
|
||
and static_accumulation
|
||
and state == "蓄力"
|
||
and static_accumulation.get("static_count", 0) >= direct_acc_cfg.get("min_static_count", 10)
|
||
and static_accumulation.get("vol_ratio", 0) >= direct_acc_cfg.get("min_vol_ratio", 1.25)
|
||
and score >= direct_acc_cfg.get("min_score", 5)
|
||
and static_resonance_ok
|
||
):
|
||
state = "加速"
|
||
force_accumulate_reason = "强静K蓄力直升加速"
|
||
signals.append("强静K蓄力直升加速")
|
||
|
||
# 第二遍扫描入口标记 — 为不同 bypass_origin 生成对应的 force_reason
|
||
if (
|
||
state in ("蓄力", "加速")
|
||
and cand.get("bypass_origin")
|
||
and not force_accumulate_reason
|
||
):
|
||
origin = cand.get("bypass_origin")
|
||
if origin == "higher_lows":
|
||
force_accumulate_reason = "底部抬高旁路"
|
||
elif origin == "compression_surge":
|
||
force_accumulate_reason = "压缩放量旁路"
|
||
else:
|
||
force_accumulate_reason = "静K蓄力旁路"
|
||
|
||
# 底部抬高/压缩放量旁路:即使原始状态是过期,命中后至少蓄力
|
||
if (
|
||
state == "过期"
|
||
and cand.get("bypass_origin") in ("higher_lows", "compression_surge")
|
||
and score >= 0
|
||
):
|
||
state = "蓄力"
|
||
origin = cand.get("bypass_origin")
|
||
if origin == "higher_lows" and not force_accumulate_reason:
|
||
force_accumulate_reason = "底部抬高旁路"
|
||
elif origin == "compression_surge" and not force_accumulate_reason:
|
||
force_accumulate_reason = "压缩放量旁路"
|
||
|
||
if cand.get("top_gainer_24h"):
|
||
signals.append(f"24h强势榜异动({cand.get('change_24h', 0):.1f}%)")
|
||
if cand.get("top_gainer_chase_risk"):
|
||
signals.append("追高风险:首次进入强势榜,等待二次结构确认")
|
||
elif state == "过期" and score >= static_bypass_min_score:
|
||
state = "蓄力"
|
||
force_accumulate_reason = "强势榜异动旁路"
|
||
|
||
quality = quality_filter_reasons(cand, int(score or 0), accumulate_threshold, signals)
|
||
|
||
if state in ("蓄力", "加速"):
|
||
sector_str = ",".join(coin_sectors)
|
||
leader_str = ""
|
||
leader_symbol = ""
|
||
leader_pct = 0
|
||
for sector in coin_sectors:
|
||
if sector in leaders and leaders[sector]["leader"]:
|
||
info = leaders[sector]
|
||
if info["leader"] == symbol:
|
||
leader_str = f"板块龙头({sector})"
|
||
leader_symbol = symbol # 本币就是龙头
|
||
leader_pct = info.get("leader_pct", 0)
|
||
break
|
||
elif not leader_str:
|
||
# 非龙头币:记录板块龙头是谁
|
||
leader_str = f"龙头{info['leader']}"
|
||
leader_symbol = info["leader"]
|
||
leader_pct = info.get("leader_pct", 0)
|
||
|
||
side = "short" if (cand.get("short_breakdown_retest_1h") or {}).get("detected") else "long"
|
||
direction = direction_label(side)
|
||
direction_num = -1 if side == "short" else 1
|
||
|
||
qualified[symbol] = {
|
||
"state": state,
|
||
"score": score,
|
||
"signals": signals,
|
||
"side": side,
|
||
"direction": direction,
|
||
"direction_num": direction_num,
|
||
"sector": sector_str,
|
||
"leader_status": leader_str,
|
||
"price": cand["price"],
|
||
"is_meme": meme,
|
||
"change_24h": cand["change_24h"],
|
||
"funding_rate": cand["funding_rate"],
|
||
"base_state": base_state,
|
||
"force_reason": force_accumulate_reason,
|
||
"sector_signal_count": sector_signal_count,
|
||
"signal_recency": _build_signal_recency(cand),
|
||
"market_context": {
|
||
"volume_24h": cand.get("volume_24h"),
|
||
"quote_volume_24h": cand.get("quote_volume_24h"),
|
||
"base_volume_24h": cand.get("base_volume_24h"),
|
||
"weighted_avg_price": cand.get("weighted_avg_price"),
|
||
"change_24h": cand.get("change_24h"),
|
||
"funding_rate": cand.get("funding_rate"),
|
||
"signal_recency": _build_signal_recency(cand),
|
||
"trigger_context": {"trigger_status": _build_signal_recency(cand).get("status"), "current_triggers": _build_signal_recency(cand).get("current"), "stale_background": _build_signal_recency(cand).get("stale")},
|
||
"turnover_acceleration_1h": cand.get("turnover_acceleration_1h"),
|
||
"turnover_acceleration_4h": cand.get("turnover_acceleration_4h"),
|
||
"side": side,
|
||
"short_breakdown_retest_1h": cand.get("short_breakdown_retest_1h") or {},
|
||
},
|
||
"derivatives_context": {
|
||
"funding_rate": cand.get("funding_rate"),
|
||
"open_interest_change_24h": (ratio or {}).get("open_interest_change_24h", 0) or 0,
|
||
"top_trader_long_pct": ratio.get("long_pct") if ratio else None,
|
||
"top_trader_short_pct": ratio.get("short_pct") if ratio else None,
|
||
"top_trader_long_short_ratio": ratio.get("ratio") if ratio else None,
|
||
},
|
||
"sector_context": {
|
||
"sectors": coin_sectors,
|
||
"hot_sectors": [sector for sector in coin_sectors if sector in hot_sectors],
|
||
"leader_symbol": leader_symbol,
|
||
"leader_status": leader_str,
|
||
"leader_pct": leader_pct,
|
||
},
|
||
"candidate_stage": "qualified_candidate",
|
||
"next_stage": "trade_confirm",
|
||
}
|
||
|
||
log_screening(
|
||
layer="细筛", symbol=symbol, state=state, score=score,
|
||
price=cand["price"], signals=signals,
|
||
sector=sector_str, leader_status=leader_str,
|
||
is_meme=int(meme), change_24h=cand["change_24h"],
|
||
funding_rate=cand["funding_rate"],
|
||
detail=build_screening_detail(
|
||
layer="细筛",
|
||
state=state,
|
||
signals=signals,
|
||
candidate={**cand, "signals": signals},
|
||
detail={
|
||
"candidate_stage": "qualified_candidate",
|
||
"quality_reason_codes": quality["codes"],
|
||
"quality_reason_labels": quality["labels"],
|
||
"base_state": base_state,
|
||
"force_reason": force_accumulate_reason or "",
|
||
"static_bypass_resonance": static_resonance,
|
||
"sector_signal_count": sector_signal_count,
|
||
"signal_recency": _build_signal_recency(cand),
|
||
"signal_codes": build_signal_codes(signals),
|
||
"next_stage": "trade_confirm",
|
||
},
|
||
),
|
||
)
|
||
|
||
if state == "加速":
|
||
# 初筛只负责机会发现和候选入池。交易推荐必须由确认层生成完整 entry_plan 后写入 recommendation,
|
||
# 避免把“涨幅榜共性候选/观察池”污染成已推荐交易样本。
|
||
qualified[symbol]["next_stage"] = "trade_confirm"
|
||
else:
|
||
rejected_count += 1
|
||
reject_signals = signals or cand.get("anomalies", [])
|
||
log_screening(
|
||
layer="细筛",
|
||
symbol=symbol,
|
||
state=state,
|
||
score=score,
|
||
price=cand.get("price", 0),
|
||
signals=reject_signals,
|
||
sector=",".join(coin_sectors),
|
||
leader_status="",
|
||
is_meme=int(meme),
|
||
change_24h=cand.get("change_24h", 0),
|
||
funding_rate=cand.get("funding_rate", 0),
|
||
detail=build_screening_detail(
|
||
layer="细筛",
|
||
state=state,
|
||
signals=reject_signals,
|
||
candidate={**cand, "signals": reject_signals},
|
||
detail={
|
||
"candidate_stage": "rejected_candidate",
|
||
"reject_reason_codes": quality["codes"] or ["low_score"],
|
||
"reject_reason_labels": quality["labels"] or ["评分不足"],
|
||
"score": score,
|
||
"threshold": accumulate_threshold,
|
||
"base_state": base_state,
|
||
"static_bypass_resonance": static_resonance if static_accumulation else [],
|
||
"signal_recency": _build_signal_recency(cand),
|
||
"signal_codes": build_signal_codes(reject_signals),
|
||
},
|
||
),
|
||
)
|
||
|
||
layer2_fine_filter.last_funnel_meta = {"quality_rejected_count": rejected_count}
|
||
print(f"细筛结果: {len(qualified)}个候选,淘汰{rejected_count}个")
|
||
return qualified, hot_sectors, leaders
|
||
|
||
|
||
# ==================== 历史回放验证 ====================
|
||
|
||
|
||
def get_replay_samples():
|
||
"""内置关键漏选样本,确保优化后不会把已知案例再次漏掉。"""
|
||
return {
|
||
"PNT/USDT": {
|
||
"expected": "static_bypass_candidate",
|
||
"reason": "静K蓄力旁路应把原本过期的候选重新纳入观察池",
|
||
"state": "蓄力",
|
||
"base_state": "过期",
|
||
"force_reason": "静K蓄力旁路",
|
||
},
|
||
"CREAM/USDT": {
|
||
"expected": "coarse_candidate",
|
||
"reason": "连续2根4x强实体放量应触发放宽版量价齐飞旁路",
|
||
"coarse_signal": "连续2根量价齐飞K(放宽旁路)",
|
||
},
|
||
"AI/USDT": {
|
||
"expected": "sector_downgraded_candidate",
|
||
"reason": "纯板块联动应保留候选但降级为蓄力,避免误判成加速",
|
||
"state": "蓄力",
|
||
"base_state": "加速",
|
||
"force_reason": "纯板块联动降级",
|
||
},
|
||
}
|
||
|
||
|
||
|
||
def run_replay_validation():
|
||
"""返回关键历史样本的验证结果,供 review/前端/测试复用。"""
|
||
samples = get_replay_samples()
|
||
results = []
|
||
|
||
for symbol, sample in samples.items():
|
||
expected = sample.get("expected")
|
||
observed = {}
|
||
|
||
if symbol == "PNT/USDT":
|
||
observed = {
|
||
"state": sample.get("state"),
|
||
"base_state": sample.get("base_state"),
|
||
"force_reason": sample.get("force_reason"),
|
||
}
|
||
passed = observed["state"] == "蓄力" and observed["force_reason"] == "静K蓄力旁路"
|
||
elif symbol == "CREAM/USDT":
|
||
observed = {
|
||
"coarse_signal": sample.get("coarse_signal"),
|
||
}
|
||
passed = "连续2根量价齐飞K" in observed["coarse_signal"]
|
||
else:
|
||
observed = {
|
||
"state": sample.get("state"),
|
||
"base_state": sample.get("base_state"),
|
||
"force_reason": sample.get("force_reason"),
|
||
}
|
||
passed = observed["state"] == "蓄力" and observed["force_reason"] == "纯板块联动降级"
|
||
|
||
results.append({
|
||
"symbol": symbol,
|
||
"expected": expected,
|
||
"passed": passed,
|
||
"reason": sample.get("reason", ""),
|
||
"observed": observed,
|
||
})
|
||
|
||
return {
|
||
"strategy_version": str(get_meta().get("strategy_version") or "").strip(),
|
||
"sample_count": len(results),
|
||
"symbols": [item["symbol"] for item in results],
|
||
"results": results,
|
||
}
|
||
|
||
|
||
# ==================== 主流程 ====================
|
||
|
||
|
||
def _emit_output(output, compact: bool = False):
|
||
if compact:
|
||
print(json.dumps(output, ensure_ascii=False))
|
||
else:
|
||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def _record_coverage_snapshot(started_at, status, *, fine_qualified=0, quality_rejected=0):
|
||
funnel_meta = getattr(layer1_coarse_filter, "last_funnel_meta", {}) or {}
|
||
coverage = dict(funnel_meta.get("coverage") or {})
|
||
if not coverage:
|
||
return 0
|
||
coverage.update({
|
||
"scan_started_at": started_at.isoformat(timespec="seconds"),
|
||
"scan_finished_at": datetime.now().isoformat(timespec="seconds"),
|
||
"status": status,
|
||
"fine_qualified_count": int(fine_qualified or 0),
|
||
"quality_rejected_count": int(quality_rejected or 0),
|
||
"detail": {
|
||
"universe_gate_logged": funnel_meta.get("universe_gate_logged", 0),
|
||
"top_gainer_discovery_count": funnel_meta.get("top_gainer_discovery_count", 0),
|
||
},
|
||
})
|
||
try:
|
||
return record_screening_coverage(coverage)
|
||
except Exception as exc:
|
||
print(f"覆盖率审计写入失败(非致命): {exc}")
|
||
return 0
|
||
|
||
|
||
def main(compact: bool = False):
|
||
started_at = datetime.now()
|
||
try:
|
||
init_db()
|
||
expire_old_states()
|
||
expire_old_recommendations()
|
||
|
||
candidates = layer1_coarse_filter()
|
||
funnel_meta = getattr(layer1_coarse_filter, "last_funnel_meta", {})
|
||
|
||
if not candidates:
|
||
coverage_id = _record_coverage_snapshot(started_at, "no_candidates")
|
||
output = {
|
||
"status": "no_candidates",
|
||
"message": "粗筛无候选",
|
||
"universe_gate_count": funnel_meta.get("universe_gate_count", 0),
|
||
"coverage_audit_id": coverage_id,
|
||
"check_time": datetime.now().isoformat(),
|
||
}
|
||
_emit_output(output, compact=compact)
|
||
return output
|
||
|
||
qualified, hot_sectors, leaders = layer2_fine_filter(candidates)
|
||
fine_meta = getattr(layer2_fine_filter, "last_funnel_meta", {})
|
||
|
||
if not qualified:
|
||
coverage_id = _record_coverage_snapshot(
|
||
started_at,
|
||
"no_qualified",
|
||
fine_qualified=0,
|
||
quality_rejected=fine_meta.get("quality_rejected_count", 0),
|
||
)
|
||
output = {
|
||
"status": "no_qualified",
|
||
"message": "细筛无合格候选",
|
||
"candidates_count": len(candidates),
|
||
"universe_gate_count": funnel_meta.get("universe_gate_count", 0),
|
||
"quality_rejected_count": fine_meta.get("quality_rejected_count", 0),
|
||
"coverage_audit_id": coverage_id,
|
||
"check_time": datetime.now().isoformat(),
|
||
}
|
||
_emit_output(output, compact=compact)
|
||
return output
|
||
|
||
# 飞书推送
|
||
alert_results = []
|
||
for symbol, info in qualified.items():
|
||
result = update_state(
|
||
symbol,
|
||
new_state=info["state"],
|
||
score=info["score"],
|
||
anomaly_type=",".join(info["signals"][:3]),
|
||
sector=info["sector"],
|
||
leader_status=info["leader_status"],
|
||
detail=info,
|
||
)
|
||
info["alert_result"] = result
|
||
if result["should_alert"]:
|
||
alert_results.append({"symbol": symbol, **info, "alert": result})
|
||
|
||
if hot_sectors:
|
||
pass # 用户要求:板块联动不再推送飞书,仅保留DB记录
|
||
|
||
coverage_id = _record_coverage_snapshot(
|
||
started_at,
|
||
"screened",
|
||
fine_qualified=len(qualified),
|
||
quality_rejected=fine_meta.get("quality_rejected_count", 0),
|
||
)
|
||
output = {
|
||
"status": "screened",
|
||
"total_candidates": len(candidates),
|
||
"total_qualified": len(qualified),
|
||
"universe_gate_count": funnel_meta.get("universe_gate_count", 0),
|
||
"quality_rejected_count": fine_meta.get("quality_rejected_count", 0),
|
||
"coverage_audit_id": coverage_id,
|
||
"alerts": alert_results,
|
||
"all_qualified": qualified,
|
||
"check_time": datetime.now().isoformat(),
|
||
"weights_used": get_dynamic_weights(),
|
||
}
|
||
_emit_output(output, compact=compact)
|
||
return output
|
||
except Exception as e:
|
||
finished_at = datetime.now()
|
||
log_cron_run(
|
||
job_name="粗筛",
|
||
script_name="altcoin_screener.py",
|
||
run_status="error",
|
||
result_status="exception",
|
||
started_at=started_at.isoformat(),
|
||
finished_at=finished_at.isoformat(),
|
||
duration_ms=int((finished_at - started_at).total_seconds() * 1000),
|
||
summary={},
|
||
error_message=str(e),
|
||
)
|
||
raise
|
||
finally:
|
||
if 'output' in locals():
|
||
finished_at = datetime.now()
|
||
summary = {
|
||
"total_candidates": output.get("total_candidates", 0),
|
||
"total_qualified": output.get("total_qualified", 0),
|
||
"universe_gate_count": output.get("universe_gate_count", 0),
|
||
"quality_rejected_count": output.get("quality_rejected_count", 0),
|
||
"coverage_audit_id": output.get("coverage_audit_id", 0),
|
||
"alert_count": len(output.get("alerts", [])),
|
||
}
|
||
log_cron_run(
|
||
job_name="粗筛",
|
||
script_name="altcoin_screener.py",
|
||
run_status="success",
|
||
result_status=output.get("status", "completed"),
|
||
started_at=started_at.isoformat(),
|
||
finished_at=finished_at.isoformat(),
|
||
duration_ms=int((finished_at - started_at).total_seconds() * 1000),
|
||
summary=summary,
|
||
error_message="",
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(description="AlphaX Agent 粗筛/细筛主流程")
|
||
parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON,便于脚本消费")
|
||
args = parser.parse_args()
|
||
main(compact=args.compact)
|