""" 山寨币爆发监控系统 v11 — 第三层:爆发确认 + 入场方案(纯前瞻版) 只用量价齐飞+PA起爆点+放量突破做确认,不用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 get_burst_threshold, is_meme_coin, get_sector_for_coin, COIN_TO_SECTORS from app.db.altcoin_db import ( init_db, expire_old_states, expire_old_recommendations, get_candidates_for_confirm, update_state, get_conn, create_recommendation, log_screening, log_cron_run, should_push, log_push, update_latest_price_cache, get_recommendation_for_push, ) from app.integrations.feishu_push import push_recommendation_state_alert from app.config.config_loader import ( get_strategy_direction, vp_fly_params, confirm_min_score, confirm_volume_breakout_ratio, confirm_atr_multipliers, confirm_stop_loss_params, get_strategy_params, ) from app.core.opportunity_lifecycle import apply_entry_quality_gate from app.config.config_loader import _get_section as _get_cfg_section from app.core.pa_engine import ( classify_candles, calc_atr, find_supply_demand_zones, find_continuous_k, detect_ignition_point, full_pa_analysis, analyze_entry_point, detect_trend_exhaustion, ) exchange = ccxt.binance({"enableRateLimit": True}) REPO_ROOT = Path(__file__).resolve().parents[2] 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: return None def symbol_recently_closed(symbol: str, hours: int = 8) -> bool: """检查该币种最近N小时内是否有已完成的交易(止盈/止损)。 用于冷却期:刚止盈的币不宜立即追入。""" import sqlite3, os from datetime import datetime, timezone, timedelta db = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")) conn = sqlite3.connect(db) cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() row = conn.execute(""" SELECT COUNT(*) FROM recommendation WHERE symbol = ? AND status IN ('hit_tp1', 'hit_tp2', 'stopped_out') AND COALESCE(hit_tp1_time, hit_tp2_time, stopped_out_time, '') >= ? """, (symbol, cutoff)).fetchone() conn.close() return (row[0] or 0) > 0 def _event_time_from_age(df, age_bars: int): """把 age_bars 转成K线时间,用于候选信号新鲜度判断。""" try: if df is None or age_bars is None: return None idx = len(df) - 1 - int(age_bars) if idx < 0 or idx >= len(df): return None ts = df["timestamp"].iloc[idx] return ts.to_pydatetime() if hasattr(ts, "to_pydatetime") else ts except Exception: return None def _is_candidate_fresh(cand, event_times, max_hours=6): """候选新鲜度:当前触发或新近进入候选池,避免旧结构反复确认。""" now = datetime.now() fresh_events = [] for t in event_times or []: if not t: continue try: age_h = (now - t.replace(tzinfo=None)).total_seconds() / 3600 if age_h <= max_hours: fresh_events.append({"time": t.isoformat(), "age_hours": round(age_h, 2)}) except Exception: pass if fresh_events: return True, "current_trigger", fresh_events detected_at = cand.get("detected_at") or cand.get("updated_at") or cand.get("created_at") or "" try: if detected_at: age_h = (now - datetime.fromisoformat(str(detected_at).replace("Z", "").split("+")[0])).total_seconds() / 3600 if age_h <= max_hours: return True, "fresh_candidate_state", [{"time": str(detected_at), "age_hours": round(age_h, 2)}] # coin_state.detected_at 会被每轮扫描刷新,不适合作为“当前触发”。 # 超过窗口后只能作为历史结构背景;是否确认必须依赖当前K线/消息等新触发。 return True, "stale_structure_background_only", [{"time": str(detected_at), "age_hours": round(age_h, 2)}] except Exception: pass return True, "structure_candidate_unknown_age", [] def _build_trigger_context(fresh_reason, fresh_events, vp_data=None, stale_vp_count=0, stale_1h_ignitions=None, stale_d1_ignitions=None, bp_daily=None, entry_action=""): """生成用户可审计的触发上下文:区分当前触发、历史背景、消息触发。""" fresh_events = fresh_events or [] stale_1h_ignitions = stale_1h_ignitions or [] stale_d1_ignitions = stale_d1_ignitions or [] current = [] stale = [] if (vp_data or {}).get("vp_fly_count", 0) > 0: current.append({"type": "technical", "label": "当前1H量价齐飞", "source": "binance_ohlcv_1h"}) if stale_vp_count: stale.append({"type": "technical", "label": "历史1H量价齐飞", "source": "binance_ohlcv_1h", "count": stale_vp_count}) if stale_1h_ignitions: stale.append({"type": "technical", "label": "历史1H起爆点", "source": "pa_engine_1h", "count": len(stale_1h_ignitions)}) if stale_d1_ignitions: stale.append({"type": "technical", "label": "历史日线起爆点", "source": "pa_engine_1d", "count": len(stale_d1_ignitions)}) for e in fresh_events: current.append({"type": "technical", "label": "当前结构触发", "source": "pa_engine", **e}) if (bp_daily or {}).get("detected"): stale.append({"type": "technical_background", "label": "日线底部突破回踩背景", "source": "daily_structure"}) if fresh_reason == "stale_structure_background_only": status = "stale_background_only" label = "历史结构背景,缺少当前K线触发" elif current: status = "current_technical" label = "当前K线/形态触发" elif fresh_reason == "fresh_candidate_state": status = "fresh_candidate" label = "新近进入候选池" else: status = "background" label = "结构背景观察" return { "trigger_status": status, "trigger_label": label, "fresh_reason": fresh_reason or "", "current_triggers": current, "stale_background": stale, "entry_action": entry_action or "", "is_current_opportunity": status in ("current_technical", "fresh_candidate"), } # ==================== 上下文数据 enrichment ==================== def _spot_to_futures(symbol): """BTC/USDT → BTCUSDT""" return symbol.replace("/", "") def fetch_derivatives_context(symbol): """从 Binance 期货 API 获取衍生品情绪数据。 返回: {funding_rate, open_interest_change_24h, top_trader_long_pct, top_trader_long_short_ratio} 失败时返回空 dict。 """ futures_sym = _spot_to_futures(symbol) ctx = {} try: # 1. Funding Rate r = requests.get( f"https://fapi.binance.com/fapi/v1/premiumIndex?symbol={futures_sym}", timeout=5, ) if r.status_code == 200: ctx["funding_rate"] = float(r.json().get("lastFundingRate", 0) or 0) except Exception: pass try: # 2. Open Interest r = requests.get( f"https://fapi.binance.com/fapi/v1/openInterest?symbol={futures_sym}", timeout=5, ) if r.status_code == 200: ctx["open_interest"] = float(r.json().get("openInterest", 0) or 0) except Exception: pass try: # 3. 大户多空比 (取最近2条,对比OI变化) r = requests.get( f"https://fapi.binance.com/futures/data/topLongShortAccountRatio" f"?symbol={futures_sym}&period=5m&limit=2", timeout=5, ) if r.status_code == 200: data = r.json() if len(data) >= 1: last = data[-1] long_pct = round(float(last["longAccount"]) * 100, 1) short_pct = round(float(last["shortAccount"]) * 100, 1) ctx["top_trader_long_pct"] = long_pct ctx["top_trader_long_short_ratio"] = ( round(long_pct / short_pct, 2) if short_pct > 0 else 0 ) except Exception: pass # 4. OI 24h变化(通过 openInterestHist 日线数据计算) try: r = requests.get( f"https://fapi.binance.com/futures/data/openInterestHist" f"?symbol={futures_sym}&period=1d&limit=2", timeout=5, ) if r.status_code == 200: hist = r.json() if len(hist) >= 2: oi_yesterday = float(hist[0].get("sumOpenInterestValue", 0) or 0) oi_today = float(hist[1].get("sumOpenInterestValue", 0) or 0) if oi_yesterday > 0: ctx["open_interest_change_24h"] = round( (oi_today - oi_yesterday) / oi_yesterday * 100, 1 ) except Exception: pass # 兜底:没有 OI 24h 变化时标 0 if "open_interest_change_24h" not in ctx: ctx["open_interest_change_24h"] = 0 return ctx def compute_market_context(h1_df, price): """从已有 1H K线计算市场热度上下文。 返回: {volume_24h, turnover_acceleration_1h, turnover_acceleration_4h, change_24h} """ ctx = {} try: if h1_df is None or len(h1_df) < 2: return ctx # 24h成交量(近24根1H K线) recent_24h = h1_df.tail(24) ctx["volume_24h"] = round(float(recent_24h["volume"].sum()), 0) # 1H量能加速:最新1根 vs 近20根均值 avg_vol_1h = float(h1_df["volume"].rolling(20).mean().iloc[-1]) if avg_vol_1h > 0: ctx["turnover_acceleration_1h"] = round( float(h1_df["volume"].iloc[-1]) / avg_vol_1h, 2 ) else: ctx["turnover_acceleration_1h"] = 1.0 # 4H量能加速:近4根 vs 近12根均值 if len(h1_df) >= 16: recent_4h_vol = float(h1_df["volume"].tail(4).sum()) prev_12h_vol = float(h1_df["volume"].iloc[-16:-4].sum()) / 3 # 每4H均值 if prev_12h_vol > 0: ctx["turnover_acceleration_4h"] = round( recent_4h_vol / 4 / (prev_12h_vol / 4), 2 ) else: ctx["turnover_acceleration_4h"] = 1.0 else: ctx["turnover_acceleration_4h"] = float(ctx.get("turnover_acceleration_1h", 1.0)) # 24h涨跌 if len(h1_df) >= 24: price_24h_ago = float(h1_df["close"].iloc[-24]) if price_24h_ago > 0: ctx["change_24h"] = round((price - price_24h_ago) / price_24h_ago * 100, 1) else: ctx["change_24h"] = 0 else: ctx["change_24h"] = 0 except Exception: pass return ctx def compute_sector_context(symbol, cand_detail=None): """用 sector_map.py 推断板块联动上下文。 优先使用粗筛/细筛已计算的 sector_context,兜底自行推断。 返回: {sectors, hot_sectors, leader_symbol, leader_move_pct} """ ctx = {} try: sectors = get_sector_for_coin(symbol) ctx["sectors"] = sectors # 优先使用上游已计算的 sector_context(粗筛/细筛已做板块龙头检测) upstream = (cand_detail or {}).get("sector_context") or {} upstream_hot = upstream.get("hot_sectors") or [] upstream_leader = upstream.get("leader_symbol") or "" if upstream_hot: ctx["hot_sectors"] = upstream_hot elif sectors: ctx["hot_sectors"] = sectors[:1] else: ctx["hot_sectors"] = [] if upstream_leader: ctx["leader_symbol"] = upstream_leader else: ctx["leader_symbol"] = "" # leader_move_pct:优先上游 sector_context.leader_pct,兜底本币24h涨跌 upstream_leader_pct = upstream.get("leader_pct") if upstream_leader_pct is not None and upstream_leader_pct != 0: ctx["leader_move_pct"] = round(float(upstream_leader_pct), 1) else: ctx["leader_move_pct"] = round( float((cand_detail or {}).get("change_24h", 0) or 0), 1 ) except Exception: pass return ctx # ==================== 确认逻辑 ==================== def detect_volume_price_fly_1h(df_1h): """确认层量价齐飞检测。 “1H量价齐飞”必须是当前/近当前信号,默认只承认最近2根1H K线; 更早的放量阳线属于历史爆发背景,不能继续作为当前爆发确认。 """ if df_1h is None or len(df_1h) < 20: return {"vp_fly_count": 0, "max_vol_ratio": 0, "vp_fly_details": [], "stale_vp_fly_details": []} vp_cfg = vp_fly_params() avg_vol = df_1h["volume"].rolling(20).mean().iloc[-1] recent = df_1h.tail(12) vp_fly_count = 0 max_vol_ratio = 0 vp_fly_details = [] stale_vp_fly_details = [] max_signal_age_hours = vp_cfg.get("max_signal_age_hours", 1) 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 age_hours = len(recent) - 1 - i max_vol_ratio = max(max_vol_ratio, vol_ratio) 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), "age_hours": age_hours, "direction": "阳"} if age_hours <= max_signal_age_hours: vp_fly_count += 1 vp_fly_details.append(detail) else: detail["stale"] = True stale_vp_fly_details.append(detail) return { "vp_fly_count": vp_fly_count, "max_vol_ratio": round(max_vol_ratio, 1), "vp_fly_details": vp_fly_details, "stale_vp_fly_details": stale_vp_fly_details, "stale_vp_fly_count": len(stale_vp_fly_details), "latest_vp_age_hours": min((d.get("age_hours", 999) for d in vp_fly_details), default=None), } def _recent_pa_items(items, max_age_bars, direction=None): """过滤 PA 事件:只把当前/近当前事件当成触发信号。""" result = [] stale = [] for item in items or []: if direction is not None and item.get("direction") != direction: continue age = item.get("age_bars") if age is None and item.get("index") is not None: # 兼容旧结构:调用方传入的是 full_pa_analysis 当前窗口结果,index 可推断相对位置。 age = 999 if age is not None and age <= max_age_bars: result.append(item) else: stale.append(item) return result, stale def detect_breakout_pullback(df, timeframe="1d"): """日线/周线底部突破回踩检测(经典安全形态)。 模式:底部形成 → 放量突破关键阻力 → 回踩不破 → 起爆确认。 返回: {detected, score, signals, entry_zone, stop_level, quality} """ result = {"detected": False, "score": 0, "signals": [], "entry_zone": None, "stop_level": None, "quality": ""} if df is None or len(df) < 50: return result closes = df["close"].values highs = df["high"].values lows = df["low"].values volumes = df["volume"].values # 1. 找波段低点(局部最小值,左右各5根K线) swing_lows = [] for i in range(10, len(closes) - 5): if lows[i] == min(lows[i-10:i+5]): swing_lows.append({"idx": i, "low": lows[i], "close": closes[i]}) if len(swing_lows) < 2: return result # 2. 找最近的重要底部(最低的低点) recent_lows = [sl for sl in swing_lows if sl["idx"] >= len(closes) - 60] if not recent_lows: recent_lows = swing_lows[-5:] bottom = min(recent_lows, key=lambda x: x["low"]) # 3. 找底部之后的第一个波段高点(突破目标) swing_highs = [] for i in range(10, len(closes) - 5): if highs[i] == max(highs[i-10:i+5]): swing_highs.append({"idx": i, "high": highs[i]}) # 取底部之后的波段高点 post_bottom_highs = [sh for sh in swing_highs if sh["idx"] > bottom["idx"] and sh["idx"] < len(closes) - 5] if not post_bottom_highs: # 用底部到现在的最高点 max_idx = bottom["idx"] + np.argmax(highs[bottom["idx"]:]) post_bottom_highs = [{"idx": int(max_idx), "high": highs[int(max_idx)]}] breakout_level = post_bottom_highs[0]["high"] # 4. 检查突破:最近20根内是否有收盘价超过突破位 recent_closes = closes[-20:] broke_out = any(c > breakout_level * 1.005 for c in recent_closes) # 0.5% buffer if not broke_out: return result # 5. 检查回踩:突破后是否有回落,但现在是否稳在突破位之上 current_price = closes[-1] recent_lows_arr = lows[-10:] pullback_low = min(recent_lows_arr) # 回踩幅度 = (突破后最高 - 回踩最低) / 突破位 post_breakout_high = max(highs[-20:]) pullback_pct = (post_breakout_high - pullback_low) / breakout_level * 100 if breakout_level > 0 else 0 # 当前价格必须稳在突破位上方(或轻微跌破但快速收回) holding = current_price >= breakout_level * 0.98 if not holding and pullback_low < breakout_level * 0.96: return result # 跌破了,形态失败 # 6. 评分 score = 0 signals = [] # ---- 底部质量(K线形态+量价关系,替代数天数) ---- bottom_idx = bottom["idx"] bottom_price = float(bottom["low"]) # 底部观察窗口:底部K线前后各15根 zone_start = max(0, bottom_idx - 15) zone_end = min(len(closes), bottom_idx + 15) zone_closes = closes[zone_start:zone_end] # ① 底部附近量缩(卖盘枯竭) pre_vol = np.mean(volumes[max(0, bottom_idx - 20):bottom_idx]) if bottom_idx >= 20 else np.mean(volumes[:bottom_idx]) post_vol = np.mean(volumes[bottom_idx:min(len(volumes), bottom_idx + 10)]) vol_shrink_ratio = post_vol / pre_vol if pre_vol > 0 else 1 if vol_shrink_ratio <= 0.7: score += 4 signals.append(f"{timeframe} 底部缩量({vol_shrink_ratio:.1f}x)") elif vol_shrink_ratio <= 0.9: score += 2 signals.append(f"{timeframe} 底部量稳({vol_shrink_ratio:.1f}x)") # ② K线实体收窄(多空平衡) df_opens = df["open"].values[zone_start:zone_end] df_closes = zone_closes bodies_arr = np.abs(df_closes - df_opens) body_mean = np.mean(bodies_arr) body_recent_mean = np.mean(np.abs(closes[max(0, len(closes)-20):] - df["open"].values[max(0, len(closes)-20):])) body_shrink = body_mean / body_recent_mean if body_recent_mean > 0 else 1 if body_shrink <= 0.6: score += 3 signals.append(f"{timeframe} K线缩量收敛") # ③ 反转形态检测(底部附近找锤子线/吞没) reversal_found = False for i in range(max(0, bottom_idx - 3), min(len(closes), bottom_idx + 3)): o = float(df["open"].values[i]) c = float(closes[i]) h = float(highs[i]) l = float(lows[i]) body = abs(c - o) upper_wick = h - max(o, c) lower_wick = min(o, c) - l total_range = h - l if h > l else 0.0001 if body < total_range * 0.3 and lower_wick > body * 2 and c > o: reversal_found = True signals.append(f"{timeframe} 锤子线反转") score += 3 break if i > 0: prev_o = float(df["open"].values[i-1]) prev_c = float(closes[i-1]) if prev_c < prev_o and c > o and c > prev_o and o < prev_c: reversal_found = True signals.append(f"{timeframe} 看涨吞没") score += 4 break if not reversal_found: # Check for morning star (3-candle pattern) for i in range(max(0, bottom_idx - 5), min(len(closes) - 2, bottom_idx + 1)): c1 = float(closes[i]) c2 = float(closes[i+1]) c3 = float(closes[i+2]) o1 = float(df["open"].values[i]) o3 = float(df["open"].values[i+2]) if (c1 < o1) and abs(c2 - o1) < abs(c1 - o1) * 0.5 and c3 > o3 and c3 > c1: reversal_found = True signals.append(f"{timeframe} 晨星反转") score += 4 break # ④ 底部后主力吸筹(阳线放量 > 阴线量) up_vols = [] down_vols = [] for i in range(bottom_idx, min(len(closes), bottom_idx + 15)): if float(closes[i]) >= float(df["open"].values[i]): up_vols.append(float(volumes[i])) else: down_vols.append(float(volumes[i])) if up_vols and down_vols: up_avg = np.mean(up_vols) down_avg = np.mean(down_vols) if up_avg > down_avg * 1.3: score += 3 signals.append(f"{timeframe} 阳线放量吸筹({up_avg/down_avg:.1f}x)") elif up_avg > down_avg: score += 1 # ⑤ 二次回踩量更小(抛压消失) bottom_zone = bottom_price * 1.03 touches = [] for i in range(bottom_idx + 5, len(lows)): if float(lows[i]) <= bottom_zone and i > bottom_idx + 3: touches.append((i, float(volumes[i]))) if len(touches) >= 2: first_touch_vol = touches[0][1] last_touch_vol = touches[-1][1] if last_touch_vol < first_touch_vol * 0.7: score += 4 signals.append(f"{timeframe} 回踩缩量确认({len(touches)}次)") elif len(touches) >= 2: score += 2 signals.append(f"{timeframe} 多次回踩({len(touches)}次)") elif len(touches) == 1: score += 1 # ⑥ 底部形成时间(距离越远=越充分,但权重降低) bars_from_bottom = len(closes) - bottom_idx if bars_from_bottom >= 30: score += 2 signals.append(f"{timeframe} 筑底{bars_from_bottom}根") elif bars_from_bottom >= 15: score += 1 # 突破放量确认(最新1根量 vs 近30日中位数,≥3x才叫放量) breakout_vol = float(volumes[-1]) avg_vol = float(np.median(volumes[-30:])) if len(volumes) >= 30 else float(np.mean(volumes[-20:])) vol_ratio = breakout_vol / avg_vol if avg_vol > 0 else 1 if vol_ratio >= 3.0: score += 6 signals.append(f"{timeframe} 突破放量({vol_ratio:.1f}x)") elif vol_ratio >= 2.0: score += 3 signals.append(f"{timeframe} 突破量能确认({vol_ratio:.1f}x)") # 回踩质量(浅回踩=强支撑) if pullback_pct <= 3: score += 5 signals.append(f"{timeframe} 浅回踩({pullback_pct:.1f}%)") elif pullback_pct <= 6: score += 3 signals.append(f"{timeframe} 正常回踩({pullback_pct:.1f}%)") else: score += 1 # 当前价站稳突破位上方 price_above_breakout = (current_price - breakout_level) / breakout_level * 100 if price_above_breakout >= 1: score += 3 signals.append(f"{timeframe} 站稳突破位+{price_above_breakout:.1f}%") elif price_above_breakout >= 0: score += 2 elif price_above_breakout >= -2: score += 1 signals.append(f"{timeframe} 回踩突破位(-{abs(price_above_breakout):.1f}%)") # 质量评价 if score >= 15: quality = "优质" elif score >= 10: quality = "良好" elif score >= 6: quality = "可观察" else: quality = "弱" result["detected"] = True result["score"] = score result["signals"] = signals result["entry_zone"] = round(float(breakout_level), 6) result["stop_level"] = round(float(bottom["low"] * 0.97), 6) # 止损设在底部下方3% result["quality"] = quality return result def confirm_burst(symbol, cand): """对单个候选做爆发确认(v1.7.0:强共振旁路+量价齐飞双门控) cand: coin_state行数据,含leader_status/detail_json等 确认条件=量价齐飞K≥1 OR (起爆点≥10× + 蓄力≥4根 + 辅助信号≥1) 不用MACD/RSI/均线 """ score = 0 signals = [] confirmed = False entry_plan = {} # 提取cand数据(v1.7.0:用于辅助信号检测) cand_detail = json.loads(cand.get("detail_json", "{}")) leader_status = cand.get("leader_status", "") h1_df = fetch_klines(symbol, "1h", limit=100) m15_df = fetch_klines(symbol, "15m", limit=100) h4_df = fetch_klines(symbol, "4h", limit=100) d1_df = fetch_klines(symbol, "1d", limit=120) # 日线趋势安全检查+突破回踩 current_trigger_times = [] if h1_df is None or len(h1_df) < 50: return {"confirmed": False, "score": 0, "signals": ["数据不足"], "entry_plan": {}, "pa_1h": {}, "pa_15min": {}, "pa_1d": {}, "m30_aligned": False, "market_context": {}, "derivatives_context": {}, "sector_context": {}} price = float(h1_df["close"].iloc[-1]) atr_1h = calc_atr(h1_df, 14) confirm_cfg = _get_cfg_section("confirm") pa_recency_cfg = confirm_cfg.get("pa_recency", {}) # ---- 1H量价行为(核心前瞻信号) ---- vol_avg = float(h1_df["volume"].rolling(20).mean().iloc[-1]) vol_latest = float(h1_df["volume"].iloc[-1]) vol_ratio = vol_latest / vol_avg if vol_avg > 0 else 1 vp_data = detect_volume_price_fly_1h(h1_df) vp_fly_count = vp_data["vp_fly_count"] for d in vp_data.get("vp_fly_details", []): t = _event_time_from_age(h1_df, d.get("age_hours")) if t: current_trigger_times.append(t) stale_vp_count = vp_data.get("stale_vp_fly_count", 0) # 量价齐飞K≥2 → 极强确认 if vp_fly_count >= 2: signals.append(f"1H {vp_fly_count}根量价齐飞K(最强确认)") score += 8 elif vp_fly_count == 1: signals.append(f"1H 量价齐飞K(量{vp_data['max_vol_ratio']}x)") score += 5 elif vp_data.get("stale_vp_fly_count", 0) > 0: stale = vp_data.get("stale_vp_fly_details", [{}])[0] signals.append(f"1H历史放量阳线已过期({stale.get('age_hours')}小时前, 量{stale.get('vol_ratio')}x)") # 1H放量≥3x(但不是量价齐飞=量价背离) if vol_ratio >= 3 and vp_fly_count == 0: signals.append(f"1H放量({vol_ratio:.1f}x)但无量价齐飞(量价背离)") score += 1 # 低权重:量价背离是假信号 # ---- PA引擎:4H级别(阻力/支撑) ---- pa_4h = full_pa_analysis(h4_df, "4h") if h4_df is not None and len(h4_df) >= 30 else {} h4_zones = pa_4h.get("zones", []) high_q_supply = [z for z in h4_zones if z["type"] == "supply" and z["q_score"] >= 7] resistance = None if high_q_supply: resistance = high_q_supply[0]["top"] # ---- v1.7.7: 日线 PA 全分析(供需区 + 起爆点 + 动K,高权重)---- # 日线是最大的时间框架,信号强度远高于小时级 pa_1d = {} d1_zones = [] if d1_df is not None and len(d1_df) >= 50: pa_1d = full_pa_analysis(d1_df, "1d") d1_zones = pa_1d.get("zones", []) # 日线起爆点:只承认最近1根日线内发生;更早的日线起爆属于背景,不当作当前触发。 d1_ignitions = pa_1d.get("ignition_points", []) recent_d1_ignitions, stale_d1_ignitions = _recent_pa_items(d1_ignitions, confirm_cfg.get("pa_recency", {}).get("d1_ignition_max_age_bars", 1)) for ig in recent_d1_ignitions[-3:]: if ig["direction"] == 1: signals.append(f"日线 {ig['signal_type']}(强度{ig['strength_ratio']}×)") score += 6 # 日线×1.5 vs 4H的+4 elif ig["direction"] == -1: signals.append(f"日线 {ig['signal_type']}(强度{ig['strength_ratio']}×)") score += 3 for ig in recent_d1_ignitions: if ig.get("direction") == 1: t = _event_time_from_age(d1_df, ig.get("age_bars")) if t: current_trigger_times.append(t) if stale_d1_ignitions: ig = stale_d1_ignitions[-1] signals.append(f"日线历史起爆点已过期({ig.get('age_bars', '?')}根前, 强度{ig.get('strength_ratio')}×)") # 日线连续动K(阳) — 日线趋势确认 d1_candles = pa_1d.get("candles_class", []) recent_d1 = d1_candles[-5:] if len(d1_candles) >= 5 else d1_candles dy_d1 = sum(1 for c in recent_d1 if c["type"] == "dynamic" and c["direction"] == 1) if dy_d1 >= 3: signals.append(f"日线 {dy_d1}动K(阳)趋势确认") score += 5 elif dy_d1 >= 1: signals.append(f"日线 {dy_d1}动K(阳)") score += 2 # 日线需求区反弹 — 最强结构信号 d1_demand = [z for z in d1_zones if z["type"] == "demand" and z["q_score"] >= 5] if d1_demand and price > 0: nearest = min(d1_demand, key=lambda z: abs(z["top"] - price)) if nearest["top"] < price < nearest["top"] * 1.15: signals.append(f"日线需求区反弹(Q={nearest['q_score']} ${nearest['top']:.4f})") score += 6 # ---- 1H放量突破4H高质量阻力 ---- breakout_confirmed = False if resistance and vol_ratio >= confirm_volume_breakout_ratio(): prev_close_1h = float(h1_df["close"].iloc[-2]) if price > resistance and prev_close_1h < resistance: q_info = f"Q={high_q_supply[0]['q_score']}" if high_q_supply else "" signals.append(f"1H放量突破阻力${resistance:.4f}({q_info} 量{vol_ratio:.1f}x)") breakout_confirmed = True elif vol_ratio >= 5: # 5x以上放量即有效突破(山寨币历史数据不足以形成阻力区) signals.append(f"1H极放量({vol_ratio:.1f}x)") score += 2 # ---- PA引擎:1H级别分析 ---- pa_1h = full_pa_analysis(h1_df, "1h") if atr_1h > 0 else {} # ---- PA起爆点检测(1H):只承认最近2根1H内发生 ---- pa_1h_ignitions = pa_1h.get("ignition_points", []) recent_1h_ignitions, stale_1h_ignitions = _recent_pa_items( pa_1h_ignitions, pa_recency_cfg.get("h1_ignition_max_age_bars", 1), ) ignition_confirmed = False for ig in recent_1h_ignitions[-3:]: if ig["direction"] == 1: signals.append(f"1H {ig['signal_type']}(强度{ig['strength_ratio']}×)") score += 4 ignition_confirmed = True elif ig["direction"] == -1: signals.append(f"1H {ig['signal_type']}(强度{ig['strength_ratio']}×)") score += 2 for ig in recent_1h_ignitions: if ig.get("direction") == 1: t = _event_time_from_age(h1_df, ig.get("age_bars")) if t: current_trigger_times.append(t) if stale_1h_ignitions: ig = stale_1h_ignitions[-1] signals.append(f"1H历史起爆点已过期({ig.get('age_bars', '?')}根前, 强度{ig.get('strength_ratio')}×)") # ---- 1H动K(阳)+量递增 ---- pa_1h_candles = pa_1h.get("candles_class", []) recent_1h = pa_1h_candles[-6:] if len(pa_1h_candles) >= 6 else pa_1h_candles dy_1h = sum(1 for c in recent_1h if c["type"] == "dynamic" and c["direction"] == 1) if dy_1h >= 3: recent_1h_df = h1_df.tail(dy_1h + 1) vol_increasing = True for i in range(1, len(recent_1h_df)): if float(recent_1h_df["volume"].iloc[i]) < float(recent_1h_df["volume"].iloc[i-1]): vol_increasing = False break if vol_increasing: signals.append(f"1H {dy_1h}动K(阳)+量递增") score += 3 else: signals.append(f"1H {dy_1h}动K(阳)") score += 1 # ---- 1H趋势衰减检测 ---- pa_1h_exhaustion = pa_1h.get("trend_exhaustion", {}) if pa_1h_exhaustion.get("exhausted"): for es in pa_1h_exhaustion.get("signals", []): signals.append(f"⚠️ {es}") if pa_1h_exhaustion["severity"] == "high": score -= 3 elif pa_1h_exhaustion["severity"] == "medium": score -= 1 # ---- v1.7.7: 30min 桥接(填补 1H→15min 缺口)---- # 30min 是中间周期:确认 1H 趋势在中等周期是否有结构支撑 m30_df = fetch_klines(symbol, "30m", limit=60) m30_aligned = False if m30_df is not None and len(m30_df) >= 30 and atr_1h > 0: pa_30min = full_pa_analysis(m30_df, "30m") m30_candles = pa_30min.get("candles_class", []) recent_30 = m30_candles[-8:] if len(m30_candles) >= 8 else m30_candles dy_30 = sum(1 for c in recent_30 if c["type"] == "dynamic" and c["direction"] == 1) st_30 = sum(1 for c in recent_30 if c["type"] == "static") # 30min 与 1H 方向对齐:阳动K≥3 且 阴动K≤1 if dy_30 >= 3 and sum(1 for c in recent_30 if c["type"] == "dynamic" and c["direction"] == -1) <= 1: signals.append(f"30min {dy_30}阳动K(与1H共振)") score += 3 m30_aligned = True elif st_30 >= 4 and dy_30 >= 1: # 30min 蓄力中(静K多+少量动K)— 待突破,不加分但也不扣 signals.append(f"30min 蓄力({st_30}静K+{dy_30}阳动K)") m30_aligned = True # 不扣分,视为中性偏多 elif sum(1 for c in recent_30 if c["type"] == "dynamic" and c["direction"] == -1) >= 3: signals.append("⚠️ 30min 阴动K≥3(与1H背离)") score -= 2 # else: 无明确信号,不干预 # ---- PA引擎:15min入场点分析 ---- pa_15min_result = {} entry_action = "等回踩" # 默认保守,PA引擎会覆盖 # v1.7.8: PA引擎始终调用(有15min数据即可),不再依赖 direction 变量 # direction=0 时仍可基于 h1_df + m15_df 独立判断入场时机 direction = 1 if (vp_fly_count >= 1 or ignition_confirmed or breakout_confirmed) else 0 if m15_df is not None and len(m15_df) >= 20 and atr_1h > 0: # 始终调用 PA 引擎,不限制 direction。PA 内部自己判断即刻买入/等回踩/放弃 pa_15min_result = analyze_entry_point( h1_df=h1_df, m15_df=m15_df, atr_1h=atr_1h, zones_4h=h4_zones if h4_zones else [], direction=max(direction, 1), ) entry_action = pa_15min_result.get("action", "等回踩") if pa_15min_result.get("breakout_k_info"): bk = pa_15min_result["breakout_k_info"] t = _event_time_from_age(m15_df, bk.get("age_bars")) if t: current_trigger_times.append(t) atr_ratio = bk.get("atr_ratio", 0) if atr_ratio > 2.0: signals.append(f"15min 强突破K线(ATR×{atr_ratio:.1f})") score += 3 elif atr_ratio > 1.5: signals.append(f"15min 突破K线(ATR×{atr_ratio:.1f})") score += 2 if pa_15min_result.get("pullback_info"): pb = pa_15min_result["pullback_info"] signals.append(f"15min 回踩确认(${pb.get('low', 0):.4f}→${pb.get('high', 0):.4f})") score += 2 if pa_15min_result.get("false_breakout"): signals.append("⚠️ 15min假突破!排除") score -= 5 if entry_action == "即刻买入": signals.append("🟢 15min即刻入场信号") score += 3 elif entry_action == "等回踩": wait_price = pa_15min_result.get("wait_price", 0) if wait_price > 0: signals.append(f"🟡 15min等回踩到${wait_price:.4f}") elif entry_action == "放弃": signals.append("🔴 15min无入场信号") # ---- 日线底部突破回踩检测(提前到门控前,供高位过滤器复用)---- # 复用已拉取的 d1_df,零额外API调用 bp_daily = {"detected": False} try: if d1_df is not None and len(d1_df) >= 50: bp_daily = detect_breakout_pullback(d1_df, "日线") except Exception: pass # ---- 底部突破回踩加分(必须在最终确认判定前生效)---- if bp_daily.get("detected"): signals.extend(bp_daily.get("signals", [])) score += min(bp_daily["score"], 12) # ---- 最终确认判定(v1.7.0:双门控 — 量价齐飞 OR 强共振旁路)---- # 门控A:量价齐飞K ≥1(保留,历史最可靠) # 门控B:强共振旁路 — 起爆点≥10× + 蓄力≥4根 + 辅助信号≥1 # WIF案例: 起爆点15×+板块联动→等2天才放量,$0.193→$0.211(+9.3%) fresh_ok, fresh_reason, fresh_events = _is_candidate_fresh( cand, current_trigger_times, max_hours=confirm_cfg.get("current_trigger_max_age_hours", 6), ) if not fresh_ok: signals.append("⛔ 候选过期:无近6小时当前触发,避免旧结构反复确认") confirmed = fresh_ok and (vp_fly_count >= 1) if not confirmed and fresh_ok: # 旧放量/旧起爆不能直接确认;但如果当前 15min/日线/结构给出强分, # 允许作为“历史强背景 + 当前结构确认”。避免修复时效后把所有结构型机会一刀切为0。 structure_gate_score = confirm_cfg.get("structure_gate_min_score", 12) current_trigger_ok = bool(current_trigger_times) recent_candidate_ok = (fresh_reason == "fresh_candidate_state") if score >= structure_gate_score and entry_action in ("即刻买入", "可即刻买入") and (current_trigger_ok or recent_candidate_ok): if fresh_reason != "stale_structure_background_only" and (stale_vp_count > 0 or stale_1h_ignitions or stale_d1_ignitions or bp_daily.get("detected")): signals.append(f"🟡 历史强背景+当前结构确认(score≥{structure_gate_score})") confirmed = True # ---- v1.7.0: 强共振旁路(在量价齐飞门控未过时启用)---- if fresh_ok and not confirmed: bypass_cfg = _get_cfg_section("confirm").get("strong_resonance_bypass", {}) if bypass_cfg.get("enabled", True): # 1. 最大起爆点强度(1H + 4H + 日线) max_ig_strength = 0 for ig in recent_1h_ignitions[-5:]: if ig.get("direction") == 1: max_ig_strength = max(max_ig_strength, ig.get("strength_ratio", 0)) if pa_4h: pa_4h_ignitions = pa_4h.get("ignition_points", []) recent_4h_ignitions, _ = _recent_pa_items(pa_4h_ignitions, pa_recency_cfg.get("h4_ignition_max_age_bars", 1)) for ig in recent_4h_ignitions[-5:]: if ig.get("direction") == 1: max_ig_strength = max(max_ig_strength, ig.get("strength_ratio", 0)) if pa_1d: d1_ignitions = pa_1d.get("ignition_points", []) recent_d1_for_bypass, _ = _recent_pa_items(d1_ignitions, pa_recency_cfg.get("d1_ignition_max_age_bars", 1)) for ig in recent_d1_for_bypass[-5:]: if ig.get("direction") == 1: max_ig_strength = max(max_ig_strength, ig.get("strength_ratio", 0) * 1.5) # 日线权重1.5× # 2. 静K蓄力计数(1H最近12根) static_k_count = sum( 1 for c in (pa_1h_candles[-12:] if pa_1h_candles else []) if c.get("type") == "static" ) # 3. 辅助信号计数 aux_count = 0 # 板块联动:leader_status含"龙头" if leader_status and "龙头" in str(leader_status): aux_count += 1 # 大户偏多:>55% deriv = cand_detail.get("derivatives_context", {}) top_long = deriv.get("top_trader_long_pct") if deriv else None if top_long is not None and top_long > 55: aux_count += 1 # 日线筑底 if bp_daily.get("detected"): aux_count += 1 # 舆情共振:screener给了sentiment_bonus sentiment_bonus = cand_detail.get("sentiment_bonus") if sentiment_bonus is not None and sentiment_bonus > 0: aux_count += 1 min_ig = bypass_cfg.get("min_ignition_strength", 10) min_static = bypass_cfg.get("min_static_k_count", 4) min_aux = bypass_cfg.get("min_aux_signals", 1) if max_ig_strength >= min_ig and static_k_count >= min_static and aux_count >= min_aux: bonus = bypass_cfg.get("score_bonus", 3) signals.append( "🔥 强共振旁路(起爆{}×+蓄力{}根+辅助{})".format( max_ig_strength, static_k_count, aux_count ) ) confirmed = True score += bonus # ---- 日线趋势安全检查(v1.6.3)---- # 日线持续走低 → 不确认,即使有1H量价齐飞 if confirmed and d1_df is not None and len(d1_df) >= 20: d1_closes = d1_df["close"].values.astype(float) d1_sma20 = float(d1_closes[-20:].mean()) d1_last = float(d1_closes[-1]) # 近5日趋势:净涨幅 < 0 且至少3天在跌 recent5 = d1_closes[-5:] net_change_pct = (float(recent5[-1]) - float(recent5[0])) / float(recent5[0]) * 100 down_days = sum(1 for i in range(1, len(recent5)) if float(recent5[i]) <= float(recent5[i-1])) if d1_last < d1_sma20 and net_change_pct < -2 and down_days >= 3: signals.append(f"⛔ 日线持续走低(价{d1_last:.5f}gain_7d%)+ 无底部突破信号 → 拒绝 # v1.7.8: range_percentile 从80%降到70%(数据:高位>70%入场TP率仅1%) # confirm_cfg 已在函数开头读取,供高位过滤+等回踩降权共用 if confirmed and d1_df is not None and len(d1_df) >= 60: d1_cl_all = d1_df["close"].values.astype(float) d1_hi_all = d1_df["high"].values.astype(float) max_60d = float(d1_hi_all[-60:].max()) min_60d = float(d1_cl_all[-60:].min()) cur_close = float(d1_cl_all[-1]) range_60d = max_60d - min_60d position_pct = (cur_close - min_60d) / range_60d * 100 if range_60d > 0 else 50 close_7d = float(d1_cl_all[-7]) gain_7d = (cur_close - close_7d) / close_7d * 100 if close_7d > 0 else 0 high_cfg = confirm_cfg.get("high_position", {}) high_percentile = high_cfg.get("range_percentile", 70) high_gain = high_cfg.get("gain_7d_pct", 15) is_high = position_pct > high_percentile and gain_7d > high_gain has_bottom = bp_daily.get("detected", False) if is_high and not has_bottom: signals.append( f"⛔ 高位无底部突破拒绝(区间{position_pct:.0f}%, 7日涨{gain_7d:.0f}%)" ) confirmed = False confirmed = confirmed and score >= confirm_min_score() # === 等回踩降权 (v1.6.9) === # 数据: 等回踩37%失败率 vs 持有0% # confirm_cfg 已在上面高位过滤器中获取 pullback_cfg = confirm_cfg.get("pullback_penalty", {}) if pullback_cfg.get("enabled", True) and entry_action == "等回踩": penalty = pullback_cfg.get("score_deduction", 3) score -= penalty signals.append(f"⚠️ 等回踩降权(-{penalty}分)") confirmed = confirmed and score >= confirm_min_score() # 假突破排除 if pa_15min_result.get("false_breakout"): confirmed = False # 衰减严重时排除 if pa_1h_exhaustion.get("severity") == "high": confirmed = False # ---- 入场方案 ---- stop_cfg = confirm_stop_loss_params() if confirmed and atr_1h > 0: if entry_action == "即刻买入" and pa_15min_result: entry_price = round(float(price), 6) entry_method = "🟢15min即刻入场(突破进行中)" elif entry_action == "等回踩" and pa_15min_result.get("wait_price", 0) > 0: entry_price = round(pa_15min_result["wait_price"], 6) entry_method = f"🟡等回踩到${entry_price:.4f}(15min静K确认)" else: last_k_low = float(h1_df["low"].iloc[-1]) atr_multipliers = confirm_atr_multipliers() entry_price = round(float(last_k_low + atr_multipliers.get("entry_offset", 0.5) * atr_1h), 6) entry_method = "1H突破回踩确认(突破K线低点+0.5ATR)" # 🔴 v1.7.6 修复: 等回踩/突破回踩的入场价不能高于当前市价。 # 出现这种情况意味着价格在目标之下运行,不是真正的"回踩等待"。 # 此时应取当前价作为入场参考,或取当前价×0.995作为保守目标。 if entry_action != "即刻买入" and entry_price > price: entry_price = round(float(price), 6) entry_method = f"{entry_method}(入场价已修正为当前价${price:.4f})" signals.append("⚠️ 入场方案价高于现价,已修正为当前市价") # === ATR动态止损 (v1.6.8 → v1.7.1) === # 止损% = max(2×ATR_1h/price, 5%地板),min(止损%, 10%天花板) atr_stop_pct = (atr_1h * stop_cfg.get("atr_mult", 2.0)) / price stop_pct_final = max(atr_stop_pct, stop_cfg.get("floor_pct", 0.05)) stop_pct_final = min(stop_pct_final, stop_cfg.get("ceiling_pct", 0.10)) atr_stop_price = round(float(price * (1 - stop_pct_final)), 6) # 收集所有止损候选 → 取最低价(最宽止损) stop_candidates = [atr_stop_price] # Q≥5需求区兜底:有结构支撑优先用需求区底部 demand_zones = [z for z in h4_zones if z["type"] == "demand" and z["q_score"] >= stop_cfg.get("demand_zone_min_q", 5)] if demand_zones: zone_stop = round(demand_zones[0]["btm"] * stop_cfg.get("zone_discount", 0.98), 6) stop_candidates.append(zone_stop) # v1.7.1: 结构止损 — 最近swing_low下方(AR案例:入场$2.21→最低$2.06→$2.06止损存活) swing_lookback = stop_cfg.get("swing_low_lookback", 12) swing_buffer = stop_cfg.get("swing_low_buffer", 0.98) if len(h1_df) >= swing_lookback: swing_low = float(h1_df["low"].tail(swing_lookback).min()) if swing_low < price: swing_stop = round(swing_low * swing_buffer, 6) # 只取有效的结构止损(在入场价下方,且不过于极端) if swing_stop > price * 0.82: stop_candidates.append(swing_stop) signals.append(f"结构止损(swing_low${swing_low:.4f}×{swing_buffer})") stop_loss = min(stop_candidates) # 最低价=最宽止损 # === ATR动态止盈 (v1.6.8) === # TP1% = max(3×ATR_1h/price, 5%地板), TP2% = max(5×ATR_1h/price, 8%地板) atr_multipliers = confirm_atr_multipliers() tp1_atr_pct = (atr_1h * atr_multipliers.get("tp1", 3.0)) / price tp1_pct = max(tp1_atr_pct, atr_multipliers.get("tp1_floor", 0.05)) tp1_candidates = [round(float(price * (1 + tp1_pct)), 6)] if high_q_supply: tp1_candidates.append(round(high_q_supply[0]["top"], 6)) tp1 = min(tp1_candidates) tp2_atr_pct = (atr_1h * atr_multipliers.get("tp2", 5.0)) / price tp2_pct = max(tp2_atr_pct, atr_multipliers.get("tp2_floor", 0.08)) tp2 = round(float(price * (1 + tp2_pct)), 6) risk = price - stop_loss reward1 = tp1 - price reward2 = tp2 - price rr1 = round(reward1 / risk, 2) if risk > 0 else 0 rr2 = round(reward2 / risk, 2) if risk > 0 else 0 entry_plan = { "entry_price": entry_price, "entry_method": entry_method, "entry_action": entry_action, "stop_loss": stop_loss, "stop_pct": round(stop_pct_final * 100, 1), "tp1": tp1, "tp2": tp2, # v1.7.8: TP2已废除(历史0命中),保留字段向后兼容.主止盈=跟踪止盈 "rr1": rr1, "rr2": rr2, "atr_1h": round(float(atr_1h), 6), "current_price": round(float(price), 6), "risk_reward_ok": rr1 >= 1.5, "pa_15min_summary": pa_15min_result.get("reason", ""), "pa_1h_exhaustion": pa_1h_exhaustion.get("severity", "low"), "trailing_stop_level": 0.0, # v1.7.8: tracker动态填充,初始0 } # v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。 gated_action, gated_plan, gate_reasons = apply_entry_quality_gate( action_status="可即刻买入" if entry_action in ("即刻买入", "可即刻买入") else "等回踩", entry_plan=entry_plan, signals=signals, current_price=price, market_context=compute_market_context(h1_df, price), derivatives_context=cand_detail.get("derivatives_context", {}), sector_context=cand_detail.get("sector_context", {}), ) entry_plan = gated_plan entry_plan["entry_action"] = gated_action if gate_reasons: signals.append("⚠️ 买点质量闸门: " + ";".join(gate_reasons[:3])) if gated_action == "观察": score -= 2 # 周线突破回踩(需独立拉取) bp_weekly = {"detected": False} try: w1_df = fetch_klines(symbol, "1w", limit=52) if w1_df is not None and len(w1_df) >= 30: bp_weekly = detect_breakout_pullback(w1_df, "周线") except Exception: pass if bp_weekly.get("detected"): signals.extend(bp_weekly.get("signals", [])) score += min(bp_weekly["score"], 10) # ---- 计算上下文数据 ---- market_context = compute_market_context(h1_df, price) derivatives_context = fetch_derivatives_context(symbol) sector_context = compute_sector_context(symbol, cand_detail) trigger_context = _build_trigger_context( fresh_reason if 'fresh_reason' in locals() else "", fresh_events if 'fresh_events' in locals() else [], vp_data=vp_data if 'vp_data' in locals() else {}, stale_vp_count=stale_vp_count if 'stale_vp_count' in locals() else 0, stale_1h_ignitions=stale_1h_ignitions if 'stale_1h_ignitions' in locals() else [], stale_d1_ignitions=stale_d1_ignitions if 'stale_d1_ignitions' in locals() else [], bp_daily=bp_daily if 'bp_daily' in locals() else {}, entry_action=entry_action, ) market_context["trigger_context"] = trigger_context return { "confirmed": confirmed, "score": score, "signals": signals, "entry_plan": entry_plan if confirmed else {}, "price": round(float(price), 6), "pa_1h": pa_1h, "pa_15min": pa_15min_result, "pa_1d": pa_1d, "m30_aligned": m30_aligned, "entry_action": entry_action, "market_context": market_context, "derivatives_context": derivatives_context, "sector_context": sector_context, "fresh_reason": fresh_reason if 'fresh_reason' in locals() else "", "fresh_events": fresh_events if 'fresh_events' in locals() else [], "trigger_context": trigger_context if 'trigger_context' in locals() else {}, } def main(): started_at = datetime.now() try: init_db() expire_old_states() candidates = get_candidates_for_confirm() if not candidates: output = { "status": "no_candidates", "message": "无需要确认的候选(需加速状态+评分≥6)", "check_time": datetime.now().isoformat(), } print(json.dumps(output, ensure_ascii=False)) return output results = [] for cand in candidates: symbol = cand["symbol"] result = confirm_burst(symbol, cand) if result["confirmed"]: cand_detail = json.loads(cand.get("detail_json", "{}")) state_result = update_state( symbol, new_state="爆发", score=result["score"], anomaly_type=",".join(result["signals"][:3]), sector=cand_detail.get("sector", cand.get("sector", "")), leader_status=cand_detail.get("leader_status", cand.get("leader_status", "")), detail={**cand_detail, **result}, ) result["state_update"] = state_result # 飞书只是通知层:确认阶段不再绕过 recommendation 主链路直接推送。 # 先完成 create_recommendation + DB 主状态派生,再用同一条主链路结果决定是否通知。 log_screening( layer="确认", symbol=symbol, state="爆发", score=result["score"], price=result["price"], signals=result["signals"], sector=cand_detail.get("sector", cand.get("sector", "")), leader_status=cand_detail.get("leader_status", cand.get("leader_status", "")), is_meme=int(is_meme_coin(symbol)), ) # 🟢 只做做多!方向永远多头 rec_direction = get_strategy_direction() # 🔴 v1.7.7 冷却期:刚止盈/止损的币不立即重新推荐 # Sahara案例:17:24止盈(+5%)→17:40重新推荐→现价跌-3% cooldown_hours = 8 if symbol_recently_closed(symbol, hours=8) else 0 if cooldown_hours > 0: print(f"⏭ 跳过推荐({symbol}): 冷却期({cooldown_hours}h),刚止盈/止损不宜追") results.append({**result, "cooling_off": True}) continue ep = result["entry_plan"] rec_id = create_recommendation( symbol=symbol, rec_state="爆发", rec_score=result["score"], entry_price=result["price"], stop_loss=ep.get("stop_loss", 0), tp1=ep.get("tp1", 0), tp2=ep.get("tp2", 0), sector=cand_detail.get("sector", cand.get("sector", "")), signals=result["signals"], is_meme=int(is_meme_coin(symbol)), entry_plan=ep, direction=rec_direction, market_context=result.get("market_context"), derivatives_context=result.get("derivatives_context"), sector_context=result.get("sector_context"), ) update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm") result["rec_id"] = rec_id # 主链路派生状态是网站和飞书的唯一共同口径。只通知实时看板也会消费的有效推荐记录。 mainline_item = get_recommendation_for_push(rec_id) if mainline_item and mainline_item.get("execution_status") in ("buy_now", "wait_pullback"): push_type = "entry" if mainline_item.get("execution_status") == "buy_now" else "watch_pool" action = mainline_item.get("action_status", "") if should_push(symbol, push_type, action): try: push_recommendation_state_alert(mainline_item) log_push(symbol, push_type, action, rec_id=rec_id) except Exception as e: print(f"飞书推送失败({symbol}): {e}") else: print(f"⏭ 跳过推送({symbol}): {push_type}/{action} 12h冷却中") else: status = mainline_item.get("execution_status") if mainline_item else "missing" print(f"[飞书跳过] {symbol}: 主链路状态={status},不推旁路通知") else: result["state_update"] = {"should_alert": False, "reason": "未确认爆发"} results.append({"symbol": symbol, **result}) confirmed = [r for r in results if r["confirmed"]] unconfirmed = [r for r in results if not r["confirmed"]] output = { "status": "confirmed" if confirmed else "unconfirmed", "confirmed_count": len(confirmed), "unconfirmed_count": len(unconfirmed), "confirmed": confirmed, "unconfirmed": unconfirmed, "check_time": datetime.now().isoformat(), } 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_confirm.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 = { "confirmed_count": output.get("confirmed_count", 0), "unconfirmed_count": output.get("unconfirmed_count", 0), "processed_count": output.get("confirmed_count", 0) + output.get("unconfirmed_count", 0), } log_cron_run( job_name="确认", script_name="altcoin_confirm.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()