""" 山寨币爆发监控系统 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 sys.path.insert(0, os.path.dirname(__file__)) from sector_map import get_burst_threshold, is_meme_coin, get_sector_for_coin, COIN_TO_SECTORS from 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 feishu_push import push_recommendation_state_alert from 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 opportunity_lifecycle import apply_entry_quality_gate from config_loader import _get_section as _get_cfg_section from 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}) 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", os.path.join(os.path.dirname(__file__), "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()