1352 lines
56 KiB
Python
1352 lines
56 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
|
||
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, create_recommendation, 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,
|
||
)
|
||
|
||
exchange = ccxt.binance({"enableRateLimit": True})
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
|
||
# ==================== 排除列表 ====================
|
||
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"}
|
||
BTC_ETH = {"BTC", "ETH"}
|
||
GOLD_METAL = {"XAUT", "PAXG"}
|
||
BNB_CHAIN = {"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_all_tickers():
|
||
tickers = exchange.fetch_tickers()
|
||
usdt_pairs = {}
|
||
for symbol, info in tickers.items():
|
||
if "/USDT" in symbol:
|
||
base = symbol.split("/")[0]
|
||
if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN:
|
||
continue
|
||
if base in EXCLUDED_BASES:
|
||
continue
|
||
if base.endswith(EXCLUDED_BASE_SUFFIXES):
|
||
continue
|
||
if not base.isascii():
|
||
continue
|
||
vol_usd = info.get("quoteVolume", 0) or 0
|
||
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),
|
||
}
|
||
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 _build_signal_recency(cand):
|
||
"""把粗筛/细筛命中的信号按 current/stale 标记,避免旧形态冒充当下机会。"""
|
||
current = []
|
||
stale = []
|
||
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"})
|
||
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 layer1_coarse_filter():
|
||
"""粗筛 — 只检测量价行为+布林收窄,不计算任何滞后指标"""
|
||
print("=== 第一层:粗筛(v11纯前瞻) ===")
|
||
tickers = fetch_all_tickers()
|
||
funding_rates = fetch_funding_rates()
|
||
weights = get_dynamic_weights()
|
||
candidates = {}
|
||
|
||
# === 24h筛选历史豁免 (v1.6.9) ===
|
||
# 过去24h内在screening_log出现过的币,不受"涨太多"过滤限制
|
||
# 防止ICP/SUI类:系统早已盯上但被burst_threshold×1.5误挡
|
||
import sqlite3 as _sq
|
||
_c = _sq.connect(os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")))
|
||
_recent = _c.execute("""
|
||
SELECT DISTINCT symbol FROM screening_log
|
||
WHERE scan_time >= datetime('now', '-24 hours')
|
||
""").fetchall()
|
||
_c.close()
|
||
recently_screened = {r[0] for r in _recent}
|
||
print(f" 24h已筛选币种: {len(recently_screened)} 只,豁免涨太多过滤")
|
||
|
||
try:
|
||
exchange.fapiPublicGetTicker24hr()
|
||
except Exception:
|
||
futures_24h_map = {}
|
||
else:
|
||
futures_24h_map = {
|
||
item.get("symbol", "").replace("USDT", "/USDT"): item
|
||
for item in exchange.fapiPublicGetTicker24hr()
|
||
if item.get("symbol", "").endswith("USDT")
|
||
}
|
||
|
||
for symbol, info in tickers.items():
|
||
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
|
||
|
||
# 1H量价齐飞检测(核心)
|
||
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) >= 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 # 量价背离最低分
|
||
|
||
# 布林收窄检测(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))
|
||
|
||
# 资金费率极端(保留)
|
||
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)
|
||
if change > burst_threshold * 1.5 and symbol not in recently_screened:
|
||
continue
|
||
|
||
if anomalies:
|
||
# === 冲高回落检查:量价齐飞后持续阴跌→拒绝 ===
|
||
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,
|
||
"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,
|
||
}
|
||
|
||
# ==== 第二遍扫描:低成交量静K蓄力旁路 + 底部抬高 + 压缩放量 ====
|
||
bypass_cfg = get_screener_section("static_accumulation_bypass")
|
||
bypass_min_vol = bypass_cfg.get("min_volume_24h", 2000000)
|
||
bypass_min_vol_ratio = bypass_cfg.get("min_vol_ratio", 1.2)
|
||
bypass_count = 0
|
||
hl_count_total = 0
|
||
cs_count_total = 0
|
||
|
||
# 主门槛:第一遍扫描的最低成交量门槛
|
||
main_min_vol = min(MIN_24H_VOLUME_USD, MEME_MIN_24H_VOLUME_USD)
|
||
|
||
hl_cfg = get_screener_section("higher_lows")
|
||
cs_cfg = get_screener_section("compression_surge")
|
||
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")
|
||
|
||
for symbol, info in tickers.items():
|
||
if symbol in candidates:
|
||
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)
|
||
if change > burst_threshold * 1.5 and symbol not in recently_screened:
|
||
continue
|
||
|
||
meme = is_meme_coin(symbol)
|
||
fr = funding_rates.get(symbol, 0)
|
||
|
||
# 拉取4H数据(只拉一次,多个检测复用)
|
||
h4_df = fetch_klines(symbol, "4h", limit=100)
|
||
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)"
|
||
]
|
||
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,
|
||
}
|
||
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']}"]
|
||
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",
|
||
}
|
||
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']}"]
|
||
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",
|
||
}
|
||
cs_count_total += 1
|
||
added = True
|
||
|
||
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)}个候选(含{total_bypass}个旁路: 静K{bypass_count}+底抬{hl_count_total}+压放{cs_count_total})")
|
||
return candidates
|
||
|
||
|
||
# ==================== 第二层:细筛 ====================
|
||
|
||
def layer2_fine_filter(candidates):
|
||
"""细筛 — 静K蓄力+量价突变(山寨币专用 v1.5)"""
|
||
print("=== 第二层:细筛(v11纯前瞻) ===")
|
||
qualified = {}
|
||
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
|
||
|
||
# 继承布林数据(蓄力末期特征)
|
||
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
|
||
|
||
# 静K蓄力旁路:即使原始状态是过期,有静K蓄力+量比达标→至少蓄力
|
||
if (
|
||
state == "过期"
|
||
and static_accumulation
|
||
and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio
|
||
and score >= static_bypass_min_score
|
||
):
|
||
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)
|
||
):
|
||
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 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)
|
||
|
||
# 🟢 只做做多!空头信号只记录不加分,方向永远多头
|
||
# 空头起爆/空头加速只是衰减参考,不生成推荐
|
||
direction = get_strategy_direction()
|
||
direction_num = 1
|
||
|
||
qualified[symbol] = {
|
||
"state": state,
|
||
"score": score,
|
||
"signals": signals,
|
||
"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"),
|
||
},
|
||
"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,
|
||
},
|
||
}
|
||
|
||
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"],
|
||
)
|
||
|
||
if state == "加速":
|
||
rec_id = create_recommendation(
|
||
symbol=symbol, rec_state="加速", rec_score=score,
|
||
entry_price=cand["price"],
|
||
sector=sector_str, signals=signals,
|
||
is_meme=int(meme), entry_plan=None,
|
||
direction=direction,
|
||
force_reason=force_accumulate_reason or "",
|
||
base_state=base_state or "",
|
||
sector_signal_count=sector_signal_count,
|
||
market_context=qualified[symbol]["market_context"],
|
||
derivatives_context=qualified[symbol]["derivatives_context"],
|
||
sector_context=qualified[symbol]["sector_context"],
|
||
)
|
||
qualified[symbol]["rec_id"] = rec_id
|
||
|
||
print(f"细筛结果: {len(qualified)}个候选")
|
||
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 main():
|
||
started_at = datetime.now()
|
||
try:
|
||
init_db()
|
||
expire_old_states()
|
||
expire_old_recommendations()
|
||
|
||
candidates = layer1_coarse_filter()
|
||
|
||
if not candidates:
|
||
output = {
|
||
"status": "no_candidates",
|
||
"message": "粗筛无候选",
|
||
"check_time": datetime.now().isoformat(),
|
||
}
|
||
print(json.dumps(output, ensure_ascii=False))
|
||
return output
|
||
|
||
qualified, hot_sectors, leaders = layer2_fine_filter(candidates)
|
||
|
||
if not qualified:
|
||
output = {
|
||
"status": "no_qualified",
|
||
"message": "细筛无合格候选",
|
||
"candidates_count": len(candidates),
|
||
"check_time": datetime.now().isoformat(),
|
||
}
|
||
print(json.dumps(output, ensure_ascii=False))
|
||
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记录
|
||
|
||
output = {
|
||
"status": "screened",
|
||
"total_candidates": len(candidates),
|
||
"total_qualified": len(qualified),
|
||
"alerts": alert_results,
|
||
"all_qualified": qualified,
|
||
"check_time": datetime.now().isoformat(),
|
||
"weights_used": get_dynamic_weights(),
|
||
}
|
||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||
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),
|
||
"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__":
|
||
main()
|