""" 山寨币爆发监控系统 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, update_latest_price_cache, downgrade_active_entries_for_market_risk, ) 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.core.strategy_registry import ( BOX_RETEST_1H_STRATEGY, BOX_RETEST_4H_STRATEGY, BREAKDOWN_RETEST_SHORT_1H_STRATEGY, MAIN_COMPOSITE_STRATEGY, ) from app.core.trade_direction import direction_label, normalize_trade_side from app.core.opportunity_level import ( attach_opportunity_level, classify_opportunity_level, level_tp_parameters, select_level_stop_loss, ) from app.core.opportunity_funnel import build_screening_detail from app.core.factor_scoring import FactorScorer from app.core.market_regime import classify_market_regime from app.db.onchain_db import get_onchain_factor_context from app.db.strategy_signal_queries import insert_strategy_signal from app.services.market_overview import get_crypto_market_overview from app.strategies.altcoin_breakout import ( build_compression_breakout_4h_signal, build_intraday_momentum_15m_signal, build_volume_ignition_1h_signal, ) from app.strategies.box_retest_4h import build_box_retest_1h_signal, build_box_retest_4h_signal from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h 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, ) def _confirm_cfg_value(key, default): try: return _get_cfg_section("confirm").get(key, default) except Exception: return default def _confirm_http_timeout() -> float: return float(os.getenv("ALPHAX_CONFIRM_HTTP_TIMEOUT_SECONDS") or _confirm_cfg_value("http_timeout_seconds", 2.5) or 2.5) def _confirm_kline_timeout_ms() -> int: return int(float(os.getenv("ALPHAX_CONFIRM_KLINE_TIMEOUT_MS") or _confirm_cfg_value("kline_timeout_ms", 4500) or 4500)) exchange = ccxt.binance({"enableRateLimit": True, "timeout": _confirm_kline_timeout_ms()}) REPO_ROOT = Path(__file__).resolve().parents[2] def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: dict) -> dict: """Build and persist a standard strategy signal when an independent strategy matches.""" bp_1h = result.get("box_breakout_pullback_1h") or (result.get("market_context") or {}).get("box_breakout_pullback_1h") or {} bp_4h = result.get("box_breakout_pullback_4h") or (result.get("market_context") or {}).get("box_breakout_pullback_4h") or {} short_1h = result.get("short_breakdown_retest_1h") or (result.get("market_context") or {}).get("short_breakdown_retest_1h") or {} market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {} signal_candidates = [] signal_candidates.extend([ build_volume_ignition_1h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), build_compression_breakout_4h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), build_intraday_momentum_15m_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), ]) if bp_1h.get("detected"): signal_candidates.append( build_box_retest_1h_signal( symbol=symbol, current_price=result.get("price") or 0, detection=bp_1h, entry_plan=entry_plan or {}, market_regime=market_regime, decision_log=result.get("decision_log") or {}, ) ) if bp_4h.get("detected"): signal_candidates.append( build_box_retest_4h_signal( symbol=symbol, current_price=result.get("price") or 0, detection=bp_4h, entry_plan=entry_plan or {}, market_regime=market_regime, decision_log=result.get("decision_log") or {}, ) ) if short_1h.get("detected"): signal_candidates.append( build_breakdown_retest_short_1h_signal( symbol=symbol, current_price=result.get("price") or 0, detection=short_1h, entry_plan=entry_plan or {}, market_regime=market_regime, decision_log=result.get("decision_log") or {}, ) ) saved_payloads = [] for signal in [item for item in signal_candidates if item]: saved_payloads.append(insert_strategy_signal(signal)) if not saved_payloads: return {} def _rank(payload: dict) -> tuple: status_rank = {"candidate": 3, "observe": 2, "risk": 1, "rejected": 0}.get(str(payload.get("signal_status") or payload.get("status") or ""), 0) trigger = payload.get("trigger") or {} age = _safe_age_bars(trigger.get("pullback_age_bars")) return (status_rank, float(payload.get("confidence") or 0), -age, float(payload.get("score") or 0)) payload = sorted(saved_payloads, key=_rank, reverse=True)[0] return { "strategy_code": payload.get("strategy_code"), "strategy_signal_id": payload.get("strategy_signal_id") or payload.get("id") or 0, "strategy_snapshot": payload, "factor_roles": payload.get("factor_roles") or {}, } def fetch_klines(symbol, timeframe, limit=200): try: exchange.timeout = _confirm_kline_timeout_ms() 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小时内是否有已完成的交易(止盈/止损)。 用于冷却期:刚止盈的币不宜立即追入。""" from datetime import datetime, timezone, timedelta from app.db.schema import get_conn conn = get_conn() cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() row = conn.execute(""" SELECT COUNT(*) FROM recommendation WHERE symbol = %s AND status IN ('hit_tp1', 'hit_tp2', 'stopped_out') AND COALESCE(hit_tp1_time, hit_tp2_time, stopped_out_time, '') >= %s """, (symbol, cutoff)).fetchone() paper_row = conn.execute(""" SELECT COUNT(*) FROM paper_trades WHERE symbol = %s AND status = 'closed' AND COALESCE(closed_at, '') >= %s """, (symbol, cutoff)).fetchone() conn.close() return ((row[0] or 0) + (paper_row[0] or 0)) > 0 def _active_recommendation_id(symbol: str, side: str = "") -> int: """Return the current non-history recommendation id for merge/write diagnostics.""" conn = get_conn() try: row = conn.execute( """ SELECT id FROM recommendation WHERE symbol=%s AND (%s = '' OR direction=%s) AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY id DESC LIMIT 1 """, (symbol, direction_label(side) if side else "", direction_label(side) if side else ""), ).fetchone() return int(row["id"] if row else 0) finally: conn.close() def _log_confirmed_screening(symbol, result, cand, cand_detail, recommendation_meta=None): recommendation_meta = recommendation_meta or {} detail = { "candidate_stage": "trade_confirm", "confirmed": True, "confirmation_status": "confirmed", "final_action": (result.get("entry_plan") or {}).get("entry_action", ""), "fresh_reason": result.get("fresh_reason", ""), "trigger_context": result.get("trigger_context") or {}, "entry_plan": result.get("entry_plan") or {}, **recommendation_meta, } 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)), detail=build_screening_detail( layer="确认", state="爆发", signals=result.get("signals", []), detail=detail, ), ) 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 _safe_age_bars(value, default=999) -> int: try: if value is None or value == "": return default return int(value) except Exception: return default 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 False, "stale_structure_background_only", [{"time": str(detected_at), "age_hours": round(age_h, 2)}] except Exception: pass return False, "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, bp_1h=None, bp_4h=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)}) fresh_event_bucket = current fresh_event_label = "当前结构触发" if fresh_reason == "stale_structure_background_only": fresh_event_bucket = stale fresh_event_label = "历史结构触发" for e in fresh_events: fresh_event_bucket.append({"type": "technical", "label": fresh_event_label, "source": "pa_engine", **e}) if (bp_daily or {}).get("detected"): stale.append({"type": "technical_background", "label": "日线底部突破回踩背景", "source": "daily_structure"}) if (bp_1h or {}).get("detected"): age = bp_1h.get("pullback_age_bars") item = { "type": "technical", "label": "1H箱体突破回踩", "source": "box_breakout_pullback_1h", "entry_zone": bp_1h.get("entry_zone"), "pullback_kind": bp_1h.get("pullback_kind"), "age_bars": age, } if age is not None and int(age) <= 2: current.append(item) else: stale.append({**item, "type": "technical_background"}) if (bp_4h or {}).get("detected"): age = bp_4h.get("pullback_age_bars") item = { "type": "technical", "label": "4H箱体突破回踩", "source": "box_breakout_pullback_4h", "entry_zone": bp_4h.get("entry_zone"), "pullback_kind": bp_4h.get("pullback_kind"), "age_bars": age, } if age is not None and int(age) <= 1: current.append(item) else: stale.append({**item, "type": "technical_background"}) 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 = {} timeout = _confirm_http_timeout() try: # 1. Funding Rate r = requests.get( f"https://fapi.binance.com/fapi/v1/premiumIndex?symbol={futures_sym}", timeout=timeout, ) 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=timeout, ) 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=timeout, ) 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=timeout, ) 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 _onchain_base_delta(event): code = str((event or {}).get("signal_code") or "") value_usd = float((event or {}).get("value_usd") or 0) confidence = float((event or {}).get("confidence") or 0) base = { "whale_accumulation": 2.5, "smart_money_buying": 2.5, "exchange_outflow": 2.0, "dex_volume_spike": 2.0, "liquidity_add": 1.5, "liquidity_remove_risk": 2.5, "exchange_inflow_risk": 2.5, "holder_concentration_risk": 2.0, }.get(code, 1.0) if value_usd >= 1_000_000 or confidence >= 85: base += 0.5 return min(base, 3.5) def _apply_onchain_factor_score(symbol, factor_scorer): """Score mapped NodeReal evidence as a first-class strategy factor.""" try: ctx = get_onchain_factor_context(symbol, hours=24) except Exception: return 0.0, [], {"has_data": False, "error": "onchain_context_unavailable"} if not ctx.get("has_data"): return 0.0, [], ctx score_delta = 0.0 signals = [] metric = ctx.get("metrics") or {} onchain_score = float(metric.get("onchain_score") or 0) risk_score = float(metric.get("risk_score") or 0) positive_events = ctx.get("positive_events") or [] risk_events = ctx.get("risk_events") or [] for event in positive_events[:3]: code = event.get("signal_code") or "unknown" label = event.get("signal_label") or code delta = factor_scorer.delta( code, _onchain_base_delta(event), evidence=f"NodeReal正向链上事件: {label}", value={"value_usd": event.get("value_usd"), "confidence": event.get("confidence")}, ) score_delta += delta signals.append(f"链上正向: {label}(置信{event.get('confidence') or 0}, ${float(event.get('value_usd') or 0):.0f})") for event in risk_events[:3]: code = event.get("signal_code") or "holder_concentration_risk" label = event.get("signal_label") or code delta = factor_scorer.delta( code, -_onchain_base_delta(event), evidence=f"NodeReal风险链上事件: {label}", value={"value_usd": event.get("value_usd"), "confidence": event.get("confidence")}, ) score_delta += delta signals.append(f"⚠️ 链上风险: {label}(置信{event.get('confidence') or 0}, ${float(event.get('value_usd') or 0):.0f})") if onchain_score >= 75 and not positive_events: delta = factor_scorer.delta( "smart_money_buying", 1.5, evidence="NodeReal综合链上重要性分>=75", value=onchain_score, ) score_delta += delta signals.append(f"链上综合重要性高({onchain_score:.0f})") if risk_score >= 50 and not risk_events: delta = factor_scorer.delta( "holder_concentration_risk", -1.5, evidence="NodeReal综合链上风险分>=50", value=risk_score, ) score_delta += delta signals.append(f"⚠️ 链上综合风险高({risk_score:.0f})") ctx["score_delta"] = round(score_delta, 3) return score_delta, signals, ctx def _current_market_regime_context(): """Read latest market snapshot and classify regime without live network fallback.""" try: overview = get_crypto_market_overview(allow_live_fallback=False) regime = classify_market_regime(overview) return { "market_regime": regime, "market_snapshot": { "updated_at": overview.get("updated_at", ""), "snapshot_source": overview.get("snapshot_source", ""), "snapshot_missing": bool(overview.get("snapshot_missing")), "sample_count": overview.get("sample_count", 0), "advance_decline_ratio": overview.get("advance_decline_ratio", 0), "avg_change_24h": overview.get("avg_change_24h", 0), "hot_count_5pct": overview.get("hot_count_5pct", 0), "crash_count_5pct": overview.get("crash_count_5pct", 0), }, } except Exception as exc: return { "market_regime": { "regime": "unknown", "label": "市场环境读取失败", "risk_level": "medium", "position_multiplier": 0.75, "reasons": [str(exc)[:160]], }, "market_snapshot": {"snapshot_missing": True}, } def _decision_log(module: str, decision: str, *, score: float = 0.0, reasons=None, evidence=None, risk_flags=None) -> dict: return { "module": module, "decision": decision, "score": round(float(score or 0), 3), "reasons": reasons or [], "risk_flags": risk_flags or [], "evidence": evidence or {}, "created_at": datetime.now().isoformat(timespec="seconds"), } def _apply_market_risk_entry_gate(entry_plan: dict, signals: list, market_regime: dict) -> tuple[dict, str]: """Annotate market risk as position sizing guidance instead of a hard blocker.""" plan = dict(entry_plan or {}) regime = market_regime if isinstance(market_regime, dict) else {} risk_level = str(regime.get("risk_level") or "").strip().lower() position_multiplier = max(0.0, float(regime.get("position_multiplier") or 0)) current_action = str(plan.get("entry_action") or "").strip() if risk_level != "critical": return plan, "" if current_action not in {"可即刻买入", "即刻买入", "等回踩"}: return plan, "" if position_multiplier <= 0: position_multiplier = 0.25 reason = f"全市场处于 critical 风险,不再一刀切拦截,按 {position_multiplier:.0%} 仓位执行并禁止追高" plan["market_risk_gate"] = { "blocked_action": "", "final_action": current_action, "risk_level": risk_level, "position_multiplier": position_multiplier, "reasons": [reason], } if not any("市场风控闸门" in str(sig) for sig in signals): signals.append(f"⚠️ 市场风控闸门: {reason}") return plan, reason # ==================== 确认逻辑 ==================== 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 _detect_box_breakout_pullback( df, *, timeframe_label="4H", lookback=24, max_wait_bars=8, recent_window=36, min_box_width_pct=3, max_box_width_pct=45, breakout_close_multiplier=1.006, min_breakout_vol_ratio=1.15, ): """底部箱体突破回踩检测。 模式:底部箱体横盘 -> 放量突破箱体上沿 -> 回踩箱体上沿/EMA不破。 这类形态比单根K线因子更像完整交易剧本,所以单独输出可复盘证据。 """ result = { "detected": False, "score": 0, "signals": [], "entry_zone": None, "stop_level": None, "quality": "", "pullback_kind": "", "pullback_age_bars": None, } if df is None or len(df) < int(lookback) + int(max_wait_bars) + 8: return result work = df.copy() for col in ("open", "high", "low", "close", "volume"): work[col] = pd.to_numeric(work[col], errors="coerce") work = work.dropna(subset=["open", "high", "low", "close", "volume"]).reset_index(drop=True) if len(work) < int(lookback) + int(max_wait_bars) + 8: return result work["ema5"] = work["close"].ewm(span=5, adjust=False).mean() work["ema25"] = work["close"].ewm(span=25, adjust=False).mean() # 只回看最近一段,避免很久以前的箱体形态反复污染当前确认。 start = max(int(lookback), len(work) - int(recent_window)) end = len(work) - 1 best = None for i in range(start, end): base = work.iloc[i - int(lookback):i] if len(base) < int(lookback): continue box_high = float(base["high"].quantile(0.92)) box_low = float(base["low"].quantile(0.08)) if box_high <= 0 or box_low <= 0: continue box_width_pct = (box_high - box_low) / box_low * 100 if box_width_pct <= float(min_box_width_pct) or box_width_pct > float(max_box_width_pct): continue row = work.iloc[i] vol_median = float(base["volume"].median() or 0) breakout_vol_ratio = float(row["volume"]) / vol_median if vol_median > 0 else 1.0 broke_out = ( float(row["close"]) > box_high * float(breakout_close_multiplier) and float(row["close"]) > float(row["open"]) and breakout_vol_ratio >= float(min_breakout_vol_ratio) ) if not broke_out: continue for j in range(i + 1, min(len(work), i + int(max_wait_bars) + 1)): pb = work.iloc[j] low = float(pb["low"]) close = float(pb["close"]) ema5 = float(pb["ema5"]) ema25 = float(pb["ema25"]) touch_box = low <= box_high * 1.035 and close >= box_high * 0.985 touch_ema5 = low <= ema5 * 1.012 and close >= ema5 * 0.985 touch_ema25 = low <= ema25 * 1.012 and close >= ema25 * 0.985 failed = close < box_high * 0.975 or low < box_low * 0.985 if failed: break if not (touch_box or touch_ema5 or touch_ema25): continue score = 5 signals = [] if box_width_pct <= 20: score += 2 elif box_width_pct <= 32: score += 1 if breakout_vol_ratio >= 2: score += 2 elif breakout_vol_ratio >= 1.5: score += 1 if touch_box and (touch_ema5 or touch_ema25): score += 2 elif touch_box or touch_ema5 or touch_ema25: score += 1 age_bars = len(work) - 1 - j if age_bars <= 1: score += 1 pullback_parts = [] if touch_box: pullback_parts.append("箱体上沿") if touch_ema5: pullback_parts.append("EMA5") if touch_ema25: pullback_parts.append("EMA25") pullback_kind = "+".join(pullback_parts) or "回踩承接" signals.append( "{}箱体突破回踩({} ${:.6g}, 量{:.1f}x)".format( timeframe_label, pullback_kind, box_high, breakout_vol_ratio, ) ) signals.append("{}底部箱体宽度{:.1f}%".format(timeframe_label, box_width_pct)) quality = "优质" if score >= 10 else "良好" if score >= 7 else "可观察" if score >= 5 else "弱" candidate = { "detected": True, "score": score, "signals": signals, "entry_zone": round(box_high, 8), "stop_level": round(min(float(pb["low"]), box_low) * 0.97, 8), "quality": quality, "pullback_kind": pullback_kind, "pullback_age_bars": int(age_bars), "box_high": round(box_high, 8), "box_low": round(box_low, 8), "box_width_pct": round(box_width_pct, 3), "breakout_vol_ratio": round(breakout_vol_ratio, 3), "breakout_index": int(i), "pullback_index": int(j), } if best is None or (candidate["pullback_index"], candidate["score"]) > (best["pullback_index"], best["score"]): best = candidate break if best: result.update(best) return result def detect_box_breakout_pullback_4h(df, lookback=24, max_wait_bars=8): """4H底部箱体突破回踩检测。""" return _detect_box_breakout_pullback( df, timeframe_label="4H", lookback=lookback, max_wait_bars=max_wait_bars, recent_window=36, min_box_width_pct=3, max_box_width_pct=45, breakout_close_multiplier=1.006, min_breakout_vol_ratio=1.15, ) def detect_box_breakout_pullback_1h(df, lookback=36, max_wait_bars=10): """1H底部箱体突破回踩检测。 1H 用于捕捉 4H 成型前的更早入场结构,但只作为策略候选证据, 仍需后续入场质量和全局风控过滤。 """ return _detect_box_breakout_pullback( df, timeframe_label="1H", lookback=lookback, max_wait_bars=max_wait_bars, recent_window=72, min_box_width_pct=2, max_box_width_pct=32, breakout_close_multiplier=1.004, min_breakout_vol_ratio=1.2, ) 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 = {} factor_scorer = FactorScorer.from_runtime() # 提取cand数据(v1.7.0:用于辅助信号检测) cand_detail = json.loads(cand.get("detail_json", "{}")) leader_status = cand.get("leader_status", "") trade_side = normalize_trade_side( cand.get("side") or cand.get("direction") or cand_detail.get("side") or (cand_detail.get("market_context") or {}).get("side") or cand_detail.get("direction") ) cand_change_24h = 0.0 try: cand_change_24h = float(cand.get("change_24h") or cand_detail.get("change_24h") or 0) except Exception: cand_change_24h = 0.0 cand_signal_text = " ".join(str(x) for x in (json.loads(cand.get("signals", "[]")) if isinstance(cand.get("signals"), str) and cand.get("signals", "").strip().startswith("[") else [cand.get("signals", "")])) cand_is_top_gainer = bool(cand_detail.get("top_gainer_24h") or "24h强势榜" in cand_signal_text or cand_change_24h >= get_burst_threshold(symbol) * 1.5) 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": {}, "factor_score_breakdown": {"total_delta": 0, "items": []}} 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", {}) # Defensive defaults: confirm runs under scheduler on many symbols with # different data availability. Keep optional higher-timeframe signals from # crashing the whole batch when a branch is skipped or an API returns less # data than expected. stale_vp_count = 0 stale_1h_ignitions = [] stale_d1_ignitions = [] bp_1h = {"detected": False} bp_4h = {"detected": False} bp_daily = {"detected": False} short_1h = ( cand_detail.get("short_breakdown_retest_1h") or (cand_detail.get("market_context") or {}).get("short_breakdown_retest_1h") or {"detected": False} ) upstream_sector_context = cand_detail.get("sector_context") or {} if upstream_sector_context.get("hot_sectors") or upstream_sector_context.get("leader_symbol"): signals.append( "板块联动: {}{}".format( ",".join(upstream_sector_context.get("hot_sectors") or []), f" 龙头{upstream_sector_context.get('leader_symbol')}" if upstream_sector_context.get("leader_symbol") else "", ) ) score += factor_scorer.delta( "sector_rotation", 2, evidence="粗筛/细筛板块热度共振", value=upstream_sector_context, ) if float(cand_detail.get("sentiment_bonus") or 0) > 0: signals.append(f"舆情共振(+{cand_detail.get('sentiment_bonus')})") score += factor_scorer.delta( "sentiment_resonance", min(2, float(cand_detail.get("sentiment_bonus") or 0)), evidence="上游舆情热度进入候选", value=cand_detail.get("sentiment_bonus"), ) upstream_deriv = cand_detail.get("derivatives_context") or {} top_long = upstream_deriv.get("top_trader_long_pct") if top_long is not None and float(top_long or 0) > 55: signals.append(f"大户偏多({float(top_long):.0f}%)") score += factor_scorer.delta( "top_trader_long", 1, evidence="Binance futures top trader long pct > 55%", value=top_long, ) onchain_delta, onchain_signals, onchain_context = _apply_onchain_factor_score(symbol, factor_scorer) if onchain_signals: signals.extend(onchain_signals) score += onchain_delta else: onchain_context = onchain_context or {"has_data": False} # ---- v1.8 新增因子:RS + OI/Funding + 多周期对齐 ---- rs_context = {} oi_funding_context = {} alignment_context = {} try: from app.core.relative_strength import compute_relative_strength, rs_factor_score rs_context = compute_relative_strength(symbol) rs_delta, rs_label = rs_factor_score(rs_context) if rs_delta != 0 and rs_label: signals.append(rs_label) rs_code = "rs_strong" if rs_delta > 0 else "rs_weak" if rs_context.get("independent_strength"): rs_code = "rs_independent_strength" score += factor_scorer.delta(rs_code, rs_delta, evidence=rs_label, value=rs_context.get("rs_score")) except Exception: pass try: from app.core.oi_funding import oi_funding_factor_scores # Determine price position for funding analysis _price_pos = "neutral" if cand_change_24h > 8: _price_pos = "high" elif cand_change_24h < -3: _price_pos = "low" oi_funding_result = oi_funding_factor_scores( symbol, price_change_pct=cand_change_24h, price_position=_price_pos, ) oi_funding_context = oi_funding_result for factor in oi_funding_result.get("factors", []): signals.append(factor["label"]) score += factor_scorer.delta( factor["code"], factor["score"], evidence=factor["label"], value=factor.get("score"), ) except Exception: pass try: from app.core.timeframe_alignment import compute_timeframe_alignment, alignment_factor_score _trade_dir = "short" if trade_side == "short" else "long" alignment_context = compute_timeframe_alignment( df_d1=d1_df, df_4h=h4_df, df_1h=h1_df, trade_direction=_trade_dir, ) align_delta, align_label = alignment_factor_score(alignment_context) if align_delta != 0 and align_label: signals.append(align_label) align_code = "tf_alignment_full" if alignment_context.get("alignment_score", 0) >= 3 else \ "tf_alignment_double" if alignment_context.get("alignment_score", 0) == 2 else \ "tf_alignment_single_penalty" if alignment_context.get("alignment_score", 0) == 1 else \ "tf_alignment_conflict_penalty" score += factor_scorer.delta(align_code, align_delta, evidence=align_label, value=alignment_context.get("alignment_score")) except Exception: pass # ---- v1.8.1 新增因子:VCP + Volume Profile + 突破质量 ---- vcp_context = {} vp_context = {} try: from app.core.vcp_detector import detect_vcp _vcp_dir = "short" if trade_side == "short" else "long" vcp_result = detect_vcp(h4_df, direction=_vcp_dir) if isinstance(vcp_result, dict) and vcp_result.get("detected"): vcp_context = vcp_result vcp_signal = vcp_result.get("signal", "") vcp_score = vcp_result.get("score", 0) if vcp_signal: signals.append(vcp_signal) if trade_side == "short": vcp_code = "vcp_bear_breakdown" if vcp_result.get("breakdown") else "vcp_bear_forming" else: vcp_code = "vcp_bull_breakout" if vcp_result.get("breakout") else "vcp_bull_forming" score += factor_scorer.delta(vcp_code, vcp_score, evidence=vcp_signal, value=vcp_result.get("contractions")) except Exception: pass try: from app.core.volume_profile import vp_factor_context _vp_dir = "short" if trade_side == "short" else "long" vp_context = vp_factor_context(h4_df, price, trade_direction=_vp_dir) vp_delta = vp_context.get("score_delta", 0) vp_signal = vp_context.get("signal", "") if vp_delta != 0 and vp_signal: signals.append(vp_signal) vp_code = "vp_path_clear" if vp_delta > 0 else "vp_path_blocked" score += factor_scorer.delta(vp_code, vp_delta, evidence=vp_signal, value=vp_context.get("path_analysis")) except Exception: pass # ---- 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 += factor_scorer.delta("vp_fly_1h_current", 8, evidence="1H量价齐飞K>=2", value=vp_fly_count) elif vp_fly_count == 1: signals.append(f"1H 量价齐飞K(量{vp_data['max_vol_ratio']}x)") score += factor_scorer.delta("vp_fly_1h_current", 5, evidence="1H量价齐飞K=1", value=vp_data.get("max_vol_ratio")) 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 += factor_scorer.delta("volume_divergence_1h", 1, evidence="1H放量但价格行为未确认", value=round(vol_ratio, 2)) # ---- 1H箱体突破回踩:比4H更早的结构候选,仍需买点/风控过滤 ---- try: if h1_df is not None and len(h1_df) >= 60: bp_1h = detect_box_breakout_pullback_1h(h1_df) except Exception: bp_1h = {"detected": False} if bp_1h.get("detected"): signals.extend(bp_1h.get("signals", [])) score += factor_scorer.add_existing( "box_breakout_pullback_1h", bp_1h.get("score", 0), evidence="1H底部箱体突破后回踩箱体上沿/均线", value=bp_1h, cap=8, ) t = _event_time_from_age(h1_df, bp_1h.get("pullback_age_bars")) if t and _safe_age_bars(bp_1h.get("pullback_age_bars")) <= 2: current_trigger_times.append(t) if trade_side == "short": try: if not short_1h.get("detected") and h1_df is not None and len(h1_df) >= 60: short_1h = detect_breakdown_retest_short_1h(h1_df, change_24h=cand_change_24h) except Exception: short_1h = {"detected": False} if short_1h.get("detected"): signals.extend(short_1h.get("signals") or ["1H破位反抽做空结构"]) score += factor_scorer.add_existing( "breakdown_retest_1h_short", short_1h.get("score", 0), evidence="1H箱体下破后反抽失败", value=short_1h, cap=9, ) t = _event_time_from_age(h1_df, short_1h.get("pullback_age_bars")) if t and _safe_age_bars(short_1h.get("pullback_age_bars")) <= 2: current_trigger_times.append(t) # ---- 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"] # ---- 4H箱体突破回踩:完整结构模型,优先作为“可执行形态”记录和复盘 ---- try: if h4_df is not None and len(h4_df) >= 40: bp_4h = detect_box_breakout_pullback_4h(h4_df) except Exception: bp_4h = {"detected": False} if bp_4h.get("detected"): signals.extend(bp_4h.get("signals", [])) score += factor_scorer.add_existing( "box_breakout_pullback_4h", bp_4h.get("score", 0), evidence="4H底部箱体突破后回踩箱体上沿/均线", value=bp_4h, cap=10, ) t = _event_time_from_age(h4_df, bp_4h.get("pullback_age_bars")) if t and _safe_age_bars(bp_4h.get("pullback_age_bars")) <= 1: current_trigger_times.append(t) # ---- v1.8.1 突破/破位质量评估 ---- breakout_quality_context = {} try: from app.core.signal_quality import estimate_breakout_quality, breakout_quality_factor_score # Determine breakout level from detected patterns _bq_level = 0 _bq_dir = "short" if trade_side == "short" else "long" _bq_zones = h4_zones if 'h4_zones' in dir() else [] if bp_4h.get("detected") and _bq_dir == "long": _bq_level = float(bp_4h.get("box_top") or bp_4h.get("breakout_level") or 0) elif bp_1h.get("detected") and _bq_dir == "long": _bq_level = float(bp_1h.get("box_top") or bp_1h.get("breakout_level") or 0) elif short_1h.get("detected") and _bq_dir == "short": _bq_level = float(short_1h.get("breakdown_level") or short_1h.get("box_bottom") or 0) if _bq_level > 0 and h1_df is not None: breakout_quality_context = estimate_breakout_quality( df=h1_df, breakout_bar_index=-1, breakout_level=_bq_level, direction=_bq_dir, atr=atr_1h, nearby_zones=_bq_zones, ) bq_delta, bq_signal = breakout_quality_factor_score(breakout_quality_context) if bq_delta != 0 and bq_signal: signals.append(bq_signal) bq_code = "breakout_quality_high" if bq_delta > 0 else "breakout_quality_low" score += factor_scorer.delta(bq_code, bq_delta, evidence=bq_signal, value=breakout_quality_context.get("quality_score")) except Exception: pass # ---- 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 += factor_scorer.delta("ignition_d1_current", 6, evidence="日线当前多头起爆点", value=ig.get("strength_ratio")) elif ig["direction"] == -1: signals.append(f"日线 {ig['signal_type']}(强度{ig['strength_ratio']}×)") score += factor_scorer.delta("trend_exhaustion", -3, evidence="日线空头起爆风险", value=ig.get("strength_ratio")) 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 += factor_scorer.delta("dynamic_k_d1_bull", 5, evidence="日线阳动K>=3", value=dy_d1) elif dy_d1 >= 1: signals.append(f"日线 {dy_d1}动K(阳)") score += factor_scorer.delta("dynamic_k_d1_bull", 2, evidence="日线阳动K>=1", value=dy_d1) # 日线需求区反弹 — 最强结构信号 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 += factor_scorer.delta("breakout_pullback_d1", 6, evidence="日线需求区附近反弹", value=nearest.get("q_score")) # ---- 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 += factor_scorer.delta("vp_fly_1h_current", 2, evidence="1H极放量兜底", value=round(vol_ratio, 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 += factor_scorer.delta("ignition_1h_current", 4, evidence="1H当前多头起爆点", value=ig.get("strength_ratio")) ignition_confirmed = True elif ig["direction"] == -1: signals.append(f"1H {ig['signal_type']}(强度{ig['strength_ratio']}×)") score += factor_scorer.delta("trend_exhaustion", -2, evidence="1H空头起爆风险", value=ig.get("strength_ratio")) 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 += factor_scorer.delta("dynamic_k_1h_bull", 3, evidence="1H阳动K且量递增", value=dy_1h) else: signals.append(f"1H {dy_1h}动K(阳)") score += factor_scorer.delta("dynamic_k_1h_bull", 1, evidence="1H阳动K", value=dy_1h) # ---- 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 += factor_scorer.delta("trend_exhaustion", -3, evidence="1H高强度趋势衰竭", value=pa_1h_exhaustion.get("severity")) elif pa_1h_exhaustion["severity"] == "medium": score += factor_scorer.delta("trend_exhaustion", -1, evidence="1H中等趋势衰竭", value=pa_1h_exhaustion.get("severity")) # ---- 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 += factor_scorer.delta("dynamic_k_1h_bull", 3, evidence="30min与1H多头共振", value=dy_30) 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 += factor_scorer.delta("trend_exhaustion", -2, evidence="30min阴动K与1H背离", value="30m_bear_dynamic") # 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 += factor_scorer.delta("breakout_15m_current", 3, evidence="15min强突破K", value=round(atr_ratio, 2)) elif atr_ratio > 1.5: signals.append(f"15min 突破K线(ATR×{atr_ratio:.1f})") score += factor_scorer.delta("breakout_15m_current", 2, evidence="15min突破K", value=round(atr_ratio, 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 += factor_scorer.delta("pullback_15m_confirm", 2, evidence="15min回踩后承接", value=pb) if pa_15min_result.get("false_breakout"): signals.append("⚠️ 15min假突破!排除") score += factor_scorer.delta("false_breakout", -5, evidence="15min假突破", value=True) if entry_action == "即刻买入": signals.append("🟢 15min即刻入场信号") score += factor_scorer.delta("breakout_15m_current", 3, evidence="15min即刻入场信号", value=entry_action) 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调用 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 += factor_scorer.add_existing("breakout_pullback_d1", bp_daily.get("score", 0), evidence="日线底部突破回踩模型", value=bp_daily, cap=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 trade_side != "short" else bool(short_1h.get("detected") and short_1h.get("retest_rejected"))) 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 or {}).get("detected") or (bp_1h or {}).get("detected") or (bp_4h or {}).get("detected") ): signals.append(f"🟡 历史强背景+当前结构确认(score≥{structure_gate_score})") confirmed = True # ---- v1.7.0: 强共振旁路(在量价齐飞门控未过时启用)---- bypass_confirmed = False 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 # 4H箱体突破回踩:更接近交易级别的结构形态 if bp_4h.get("detected") and _safe_age_bars(bp_4h.get("pullback_age_bars")) <= 3: aux_count += 1 # 1H箱体突破回踩:更早的小时级结构,只在较新鲜时作为辅助确认 if bp_1h.get("detected") and _safe_age_bars(bp_1h.get("pullback_age_bars")) <= 4: 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 ) ) bypass_confirmed = True confirmed = True score += factor_scorer.delta("strong_resonance_bypass", bonus, evidence="起爆强度+静K蓄力+辅助信号", value={"max_ig_strength": max_ig_strength, "static_k_count": static_k_count, "aux_count": aux_count}) # ---- 日线趋势安全检查(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 += factor_scorer.delta("risk_reward_bad", -penalty, evidence="等回踩历史失败率较高,降低确认分", value=entry_action) 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: short_entry_plan = None if trade_side == "short": entry_action = "即刻买入" if short_1h.get("retest_rejected") else "等回踩" retest_zone = short_1h.get("retest_zone") or short_1h.get("breakdown_level") or 0 if entry_action == "即刻买入": entry_price = round(float(price), 6) entry_method = "空头1H破位反抽失败,当前可开空" else: if isinstance(retest_zone, (list, tuple)) and len(retest_zone) >= 2: zone_mid = sum(float(x or 0) for x in retest_zone[:2]) / 2 else: zone_mid = float(retest_zone or price) entry_price = round(max(float(price), zone_mid), 6) entry_method = f"等反抽到${entry_price:.4f}(箱体下沿反压)" stop_loss = round(float(short_1h.get("stop_level") or price * 1.055), 6) tp1 = round(float(short_1h.get("target_1") or max(price * 0.9, price - (stop_loss - price) * 1.8)), 6) tp2 = round(max(tp1 * 0.94, price - (stop_loss - price) * 2.8), 6) risk = stop_loss - price reward1 = price - tp1 reward2 = price - tp2 rr1 = round(reward1 / risk, 2) if risk > 0 else 0 rr2 = round(reward2 / risk, 2) if risk > 0 else 0 level_meta = { "opportunity_level": "structure_watch", "opportunity_level_label": "结构机会", "holding_horizon": "短线", "entry_model": "1H箱体破位反抽", "stop_model": "反抽高点上方", "tp_model": "下方量度空间", "max_action": "buy_now" if entry_action == "即刻买入" else "wait_pullback", } entry_plan = { "side": "short", "entry_price": entry_price, "entry_method": entry_method, "entry_action": entry_action, "stop_loss": stop_loss, "stop_pct": round(((stop_loss - price) / price) * 100, 1) if price > 0 else 0, "tp1": tp1, "tp2": tp2, "rr1": rr1, "rr2": rr2, "atr_1h": round(float(atr_1h), 6), "current_price": round(float(price), 6), "risk_reward_ok": rr1 >= 1.3, "pa_15min_summary": "空头结构以1H反抽失败为主,15m仅作执行辅助", "pa_1h_exhaustion": "low", "stop_basis": "short_retest_high", "tp_basis": "box_breakdown_measured_move", } entry_plan = attach_opportunity_level(entry_plan, level_meta) short_entry_plan = dict(entry_plan) gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY elif 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("⚠️ 入场方案价高于现价,已修正为当前市价") # ---- v1.8 多周期对齐度入场降级 ---- try: if alignment_context and alignment_context.get("alignment_score", 3) < 2: from app.core.timeframe_alignment import gate_entry_by_alignment gated_action, gate_reason = gate_entry_by_alignment(entry_action, alignment_context) if gated_action != entry_action: signals.append(f"⚠️ {gate_reason}") entry_action = gated_action except Exception: pass # === 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})") level_meta = classify_opportunity_level( signals=signals, entry_plan={ "entry_action": entry_action, "entry_price": entry_price, "current_price": round(float(price), 6), "pa_15min_summary": pa_15min_result.get("reason", ""), }, market_context=compute_market_context(h1_df, price), derivatives_context=cand_detail.get("derivatives_context", {}), sector_context=cand_detail.get("sector_context", {}), m30_aligned=m30_aligned, ) opportunity_level = level_meta.get("opportunity_level", "structure_watch") stop_loss, stop_basis = select_level_stop_loss( level=opportunity_level, price=price, entry_price=entry_price, stop_candidates=stop_candidates, ) if stop_loss <= 0: stop_loss = min(stop_candidates) stop_pct_final = max((price - stop_loss) / price, 0) if price > 0 and stop_loss > 0 else stop_pct_final # === 分级动态止盈 === # 日内启动更重视近端兑现;结构/主题级别使用更宽目标。 atr_multipliers = confirm_atr_multipliers() level_tp = level_tp_parameters(opportunity_level) tp1_atr_pct = (atr_1h * level_tp.get("tp1_atr", atr_multipliers.get("tp1", 3.0))) / price tp1_pct = max(tp1_atr_pct, level_tp.get("tp1_floor", 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 * level_tp.get("tp2_atr", atr_multipliers.get("tp2", 5.0))) / price tp2_pct = max(tp2_atr_pct, level_tp.get("tp2_floor", 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, "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"), "stop_basis": stop_basis, "tp_basis": level_meta.get("tp_model", ""), } entry_plan = attach_opportunity_level(entry_plan, level_meta) if trade_side == "short" and short_entry_plan is not None: entry_plan = short_entry_plan gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY else: gate_strategy_code = ( BOX_RETEST_1H_STRATEGY if bp_1h.get("detected") else BOX_RETEST_4H_STRATEGY if bp_4h.get("detected") else MAIN_COMPOSITE_STRATEGY ) entry_plan.setdefault("strategy_code", gate_strategy_code) # 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", {}), strategy_code=gate_strategy_code, ) if bypass_confirmed and vp_fly_count == 0 and not current_trigger_times and gated_action == "可即刻买入": gated_action = "等回踩" gated_plan["entry_quality_gate"] = { "blocked_action": "可即刻买入", "final_action": "等回踩", "reasons": ["强共振旁路缺少当前1H/15min触发,最高进入等待回踩"], } gate_reasons.append("强共振旁路缺少当前1H/15min触发,最高进入等待回踩") entry_plan = gated_plan entry_plan["entry_action"] = gated_action if gate_reasons: signals.append("⚠️ 买点质量闸门: " + ";".join(gate_reasons[:3])) if gated_action == "观察": score += factor_scorer.delta("entry_quality_gate", -2, evidence="买点质量闸门降为观察", value=gate_reasons[:3]) regime_context = _current_market_regime_context() market_regime = regime_context.get("market_regime") or {} market_risk_level = str(market_regime.get("risk_level") or "").strip().lower() # 周线突破回踩是高成本兜底证据;全局禁止开仓时不再额外拉取,避免确认链路被低价值 API 拖慢。 bp_weekly = {"detected": False} try: should_fetch_weekly = market_risk_level != "critical" and score >= max(confirm_min_score(), 8) if should_fetch_weekly: 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 += factor_scorer.add_existing("breakout_pullback_w1", bp_weekly.get("score", 0), evidence="周线突破回踩模型", value=bp_weekly, cap=10) # ---- 计算上下文数据 ---- market_context = compute_market_context(h1_df, price) derivatives_context = upstream_deriv if isinstance(upstream_deriv, dict) else {} if market_risk_level != "critical" or not derivatives_context: live_derivatives = fetch_derivatives_context(symbol) derivatives_context = {**derivatives_context, **(live_derivatives or {})} sector_context = compute_sector_context(symbol, cand_detail) if entry_plan: entry_plan, market_risk_gate_reason = _apply_market_risk_entry_gate(entry_plan, signals, market_regime) if market_risk_gate_reason: score += factor_scorer.delta( "entry_quality_gate", -2, evidence="全局市场风险闸门降为观察", value=market_risk_gate_reason, ) factor_score_breakdown = factor_scorer.summary() opportunity_score = round(float(factor_score_breakdown.get("opportunity_score") or 0), 3) entry_score = round(float(factor_score_breakdown.get("entry_score") or 0), 3) risk_score = round(float(factor_score_breakdown.get("risk_score") or 0), 3) 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 {}, bp_1h=bp_1h if 'bp_1h' in locals() else {}, bp_4h=bp_4h if 'bp_4h' in locals() else {}, entry_action=entry_action, ) market_context["trigger_context"] = trigger_context market_context["box_breakout_pullback_1h"] = bp_1h if 'bp_1h' in locals() else {} market_context["box_breakout_pullback_4h"] = bp_4h if 'bp_4h' in locals() else {} market_context["short_breakdown_retest_1h"] = short_1h if 'short_1h' in locals() else {} market_context["side"] = trade_side market_context["factor_score_breakdown"] = factor_score_breakdown market_context["onchain_context"] = onchain_context market_context["market_regime"] = market_regime market_context["market_snapshot"] = regime_context.get("market_snapshot") or {} market_context["score_components"] = { "total_score": round(float(score), 3), "opportunity_score": opportunity_score, "entry_score": entry_score, "risk_score": risk_score, } market_context["decision_log"] = _decision_log( "confirm_burst", "confirmed" if confirmed else "rejected", score=score, reasons=[] if confirmed else signals[-5:], risk_flags=[ f"market_regime:{market_regime.get('regime', 'unknown')}", f"market_risk:{market_regime.get('risk_level', 'medium')}", ], evidence={ "opportunity_score": opportunity_score, "entry_score": entry_score, "risk_score": risk_score, "trigger_status": trigger_context.get("trigger_status"), }, ) if entry_plan: entry_plan["side"] = trade_side entry_plan["factor_score_breakdown"] = factor_score_breakdown entry_plan["onchain_context"] = onchain_context entry_plan["market_regime"] = market_regime entry_plan["score_components"] = market_context["score_components"] entry_plan["decision_log"] = market_context["decision_log"] return { "confirmed": confirmed, "score": round(float(score), 3), "opportunity_score": opportunity_score, "entry_score": entry_score, "risk_score": risk_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, "box_breakout_pullback_1h": bp_1h if 'bp_1h' in locals() else {}, "box_breakout_pullback_4h": bp_4h if 'bp_4h' in locals() else {}, "short_breakdown_retest_1h": short_1h if 'short_1h' in locals() else {}, "side": trade_side, "m30_aligned": m30_aligned, "entry_action": (entry_plan or {}).get("entry_action") or entry_action, "market_context": market_context, "derivatives_context": derivatives_context, "sector_context": sector_context, "onchain_context": onchain_context, "market_regime": market_regime, "factor_score_breakdown": factor_score_breakdown, "decision_log": market_context["decision_log"], "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 {}, "rs_context": rs_context if 'rs_context' in locals() else {}, "oi_funding_context": oi_funding_context if 'oi_funding_context' in locals() else {}, "alignment_context": alignment_context if 'alignment_context' in locals() else {}, "vcp_context": vcp_context if 'vcp_context' in locals() else {}, "vp_context": vp_context if 'vp_context' in locals() else {}, "breakout_quality_context": breakout_quality_context if 'breakout_quality_context' in locals() else {}, } def _watch_candidate_plan(symbol, result, cand_detail): """把强势但未形成交易买点的样本写成机会观察,不触发策略交易。""" market_context = result.get("market_context") or {} signals = list(result.get("signals") or []) price = float(result.get("price") or 0) side = normalize_trade_side(result.get("side") or market_context.get("side") or cand_detail.get("side")) level_meta = classify_opportunity_level( signals=signals, entry_plan={ "side": side, "entry_action": "观察", "entry_price": price, "current_price": price, "pa_15min_summary": (result.get("pa_15min") or {}).get("reason", ""), }, market_context=market_context, derivatives_context=result.get("derivatives_context") or {}, sector_context=result.get("sector_context") or cand_detail.get("sector_context", {}), m30_aligned=bool(result.get("m30_aligned")), ) if level_meta.get("opportunity_level") not in ("momentum_watch", "structure_watch"): level_meta = { **level_meta, "opportunity_level": "momentum_watch", "label": "强势观察", "max_action": "observe", } plan = { "side": side, "entry_action": "观察", "entry_price": price, "current_price": price, "risk_reward_ok": False, "rr1": 0, "watch_reason": "强势异动已发现,但当前交易确认未通过", "watch_points": [ "等待1H重新放量或15m回踩后再确认", "等待RR恢复到可交易区间", "若跌破短线结构低点则失效", ], } return attach_opportunity_level(plan, level_meta) def _should_publish_watch_candidate(cand, result): """强势榜/当前形态未过交易闸门时,仍进入用户机会观察池。""" if result.get("confirmed"): return False score = float(result.get("score") or 0) market_context = result.get("market_context") or {} change_24h = 0.0 try: change_24h = float(market_context.get("change_24h") or cand.get("change_24h") or 0) except Exception: change_24h = 0.0 signals = " ".join(str(x) for x in (result.get("signals") or [])) trigger_context = result.get("trigger_context") or {} has_current_trigger = bool(trigger_context.get("current_triggers")) or "15min即刻入场" in signals or "15min 强突破" in signals is_top_gainer = "24h强势榜" in signals or change_24h >= get_burst_threshold(cand.get("symbol") or "") * 1.5 severe_risk = any(k in signals for k in ("假突破", "日线持续走低", "高位无底部突破拒绝")) return not severe_risk and is_top_gainer and (has_current_trigger or score >= confirm_min_score()) def _result_brief(item: dict) -> dict: ctx = item.get("market_context") or {} trigger = item.get("trigger_context") or ctx.get("trigger_context") or {} decision = item.get("decision_log") or ctx.get("decision_log") or {} signal_text = " ".join(str(x) for x in (item.get("signals") or [])) inferred_strategy = "" if "1H箱体突破回踩" in signal_text: inferred_strategy = "box_retest_1h_v1" elif "4H箱体突破回踩" in signal_text: inferred_strategy = "box_retest_4h_v1" return { "symbol": item.get("symbol"), "confirmed": bool(item.get("confirmed")), "score": item.get("score"), "action": item.get("entry_action") or (item.get("entry_plan") or {}).get("entry_action") or "", "strategy_code": item.get("strategy_code") or (item.get("strategy_snapshot") or {}).get("strategy_code") or inferred_strategy, "rec_id": item.get("rec_id") or 0, "published_watch": bool(item.get("published_watch")), "trigger_status": trigger.get("trigger_status") or "", "risk_flags": decision.get("risk_flags") or [], "signals": list(item.get("signals") or [])[:6], "state_update": item.get("state_update") or {}, } def _summarize_output(output: dict) -> dict: return { "status": output.get("status"), "processed_count": output.get("processed_count", output.get("confirmed_count", 0) + output.get("unconfirmed_count", 0)), "confirmed_count": output.get("confirmed_count", 0), "unconfirmed_count": output.get("unconfirmed_count", 0), "market_risk_downgraded_count": output.get("market_risk_downgraded_count", 0), "stopped_reason": output.get("stopped_reason", ""), "confirmed": [_result_brief(r) for r in output.get("confirmed", [])], "unconfirmed": [_result_brief(r) for r in output.get("unconfirmed", [])], "check_time": output.get("check_time"), } def _emit_output(output, compact: bool = False, verbose: bool = False): payload = output if verbose else _summarize_output(output) print(json.dumps(payload, ensure_ascii=False, separators=(",", ":") if compact else None, indent=None if compact else 2, default=str)) def main(compact: bool = False, verbose: bool = False, limit: int | None = None, max_seconds: int | None = None): started_at = datetime.now() try: init_db() expire_old_states() regime_context = _current_market_regime_context() market_regime = regime_context.get("market_regime") or {} downgrade_result = {"updated_count": 0} confirm_cfg = _get_cfg_section("confirm") candidate_limit = max(1, min(int(limit or confirm_cfg.get("max_candidates_per_run") or 8), 50)) run_budget_seconds = max(5, int(max_seconds or confirm_cfg.get("max_run_seconds") or 90)) candidates = get_candidates_for_confirm(limit=candidate_limit) if not candidates: output = { "status": "no_candidates", "message": "无需要确认的候选(需加速状态+评分≥6)", "market_risk_downgraded_count": downgrade_result.get("updated_count", 0), "check_time": datetime.now().isoformat(), } _emit_output(output, compact=compact, verbose=verbose) return output results = [] stopped_reason = "" for cand in candidates: elapsed = (datetime.now() - started_at).total_seconds() if elapsed >= run_budget_seconds: stopped_reason = f"max_seconds_exceeded:{run_budget_seconds}" break 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 状态派生,再用同一条状态结果决定是否通知。 # 🔴 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),刚止盈/止损不宜追") _log_confirmed_screening( symbol, result, cand, cand_detail, { "recommendation_status": "skipped", "recommendation_skip_reason": "cooling_off_recent_closed_trade", "cooldown_hours": cooldown_hours, }, ) results.append({**result, "cooling_off": True}) continue ep = result["entry_plan"] rec_direction = direction_label(ep.get("side") or result.get("side")) rec_entry_price = ep.get("entry_price") or result["price"] if normalize_trade_side(ep.get("side")) != "short" and ep.get("entry_action") in ("等回踩", "观察") and result.get("price"): plan_stop = float(ep.get("stop_loss") or 0) plan_tp1 = float(ep.get("tp1") or 0) plan_entry = float(ep.get("entry_price") or 0) if plan_entry > 0 and (plan_stop >= plan_entry or (plan_tp1 > 0 and plan_tp1 <= plan_entry)): rec_entry_price = result["price"] previous_rec_id = _active_recommendation_id(symbol, ep.get("side") or result.get("side")) strategy_ctx = _strategy_context_for_recommendation(symbol, result, ep) if strategy_ctx.get("strategy_code"): ep["strategy_code"] = strategy_ctx["strategy_code"] rec_id = create_recommendation( symbol=symbol, rec_state="爆发", rec_score=result["score"], entry_price=rec_entry_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"), **strategy_ctx, ) update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm") result["rec_id"] = rec_id write_action = "merged_existing" if previous_rec_id and int(rec_id) == previous_rec_id else "created" _log_confirmed_screening( symbol, result, cand, cand_detail, { "recommendation_status": "written", "recommendation_write_action": write_action, "rec_id": int(rec_id), "previous_rec_id": previous_rec_id or 0, }, ) else: cand_detail = json.loads(cand.get("detail_json", "{}")) log_screening( layer="确认", symbol=symbol, state=cand.get("state", "蓄力"), score=result.get("score", 0), price=result.get("price", 0), signals=result.get("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)), detail=build_screening_detail( layer="确认", state=cand.get("state", "蓄力"), signals=result.get("signals", []), detail={ "candidate_stage": "trade_confirm", "confirmed": False, "confirmation_status": "rejected", "reason": "确认未通过", "entry_plan": result.get("entry_plan") or {}, "fresh_reason": result.get("fresh_reason", ""), "trigger_context": result.get("trigger_context") or {}, }, ), ) if _should_publish_watch_candidate(cand, result): watch_plan = _watch_candidate_plan(symbol, result, cand_detail) strategy_ctx = _strategy_context_for_recommendation(symbol, result, watch_plan) if strategy_ctx.get("strategy_code"): watch_plan["strategy_code"] = strategy_ctx["strategy_code"] rec_id = create_recommendation( symbol=symbol, rec_state="观察", rec_score=max(result.get("score", 0), confirm_min_score()), entry_price=result.get("price", 0), stop_loss=0, tp1=0, tp2=0, sector=cand_detail.get("sector", cand.get("sector", "")), signals=result.get("signals", []), is_meme=int(is_meme_coin(symbol)), entry_plan=watch_plan, direction=direction_label(watch_plan.get("side") or result.get("side")), market_context=result.get("market_context"), derivatives_context=result.get("derivatives_context"), sector_context=result.get("sector_context"), **strategy_ctx, ) update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm_watch") result["rec_id"] = rec_id result["published_watch"] = True result["state_update"] = {"should_alert": False, "reason": "强势异动进入机会观察池"} 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), "processed_count": len(results), "candidate_limit": candidate_limit, "market_risk_downgraded_count": downgrade_result.get("updated_count", 0), "stopped_reason": stopped_reason, "confirmed": confirmed, "unconfirmed": unconfirmed, "check_time": datetime.now().isoformat(), } _emit_output(output, compact=compact, verbose=verbose) 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__": import argparse parser = argparse.ArgumentParser(description="AlphaX Agent 爆发确认主流程") parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON,便于脚本消费") args = parser.parse_args() main(compact=args.compact)