diff --git a/app/analysis/reverse_analysis.py b/app/analysis/reverse_analysis.py index 5ed17b4..6c2ab3e 100644 --- a/app/analysis/reverse_analysis.py +++ b/app/analysis/reverse_analysis.py @@ -25,10 +25,41 @@ from app.db.altcoin_db import get_conn, record_missed_explosion, upsert_strategy from app.core.pa_engine import full_pa_analysis, classify_candles, calc_atr from app.core.sector_map import get_sector_for_coin, COIN_TO_SECTORS, SECTOR_MEMBERS from app.config import config_loader +from app.services.altcoin_screener import ( + STABLECOINS, + WRAPPED, + BTC_ETH, + GOLD_METAL, + BNB_CHAIN, + EXCLUDED_BASES, + EXCLUDED_BASE_SUFFIXES, +) BINANCE_API = "https://api.binance.com/api/v3" +FEATURE_LABELS = { + "has_ignition_point": "起爆点(静K→动K转折)", + "has_q7_zone": "Q≥7高质量供需区", + "has_q7_demand_nearby": "Q≥7需求区在起爆价附近(<3%)", + "has_continuous_k": "连续K多头加速", + "has_static_accumulation": "静K蓄力(占比≥15%)", + "has_volume_surge_before": "起爆前2倍+放量", + "has_bullish_breakout_pattern": "连续5K收盘价抬高", +} + + +def _is_altcoin_usdt_symbol(symbol_str): + if not symbol_str or not symbol_str.endswith("USDT"): + return False + base = symbol_str.replace("USDT", "").upper() + if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN: + return False + if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES): + return False + return base.isascii() + + # ==================== 数据获取 ==================== def fetch_24h_tickers(): @@ -246,7 +277,7 @@ def check_sector_alignment(symbol, top_gainers, config): # ==================== 共性特征统计与规律发现 ==================== -def compute_pattern_summary(all_features, total_count): +def compute_pattern_summary(all_features, total_count, control_features=None): """ 统计所有top gainer的共性特征占比 返回: [{feature_name, count, percentage, description}] @@ -266,27 +297,31 @@ def compute_pattern_summary(all_features, total_count): if detail_key in feat and feat[detail_key]: feature_details[key].extend(feat[detail_key]) - # 特征名 → 中文描述映射 - FEATURE_LABELS = { - "has_ignition_point": "起爆点(静K→动K转折)", - "has_q7_zone": "Q≥7高质量供需区", - "has_q7_demand_nearby": "Q≥7需求区在起爆价附近(<3%)", - "has_continuous_k": "连续K多头加速", - "has_static_accumulation": "静K蓄力(占比≥15%)", - "has_volume_surge_before": "起爆前2倍+放量", - "has_bullish_breakout_pattern": "连续5K收盘价抬高", - } + control_features = control_features or [] + control_total = len(control_features) + control_counts = Counter() + for feat in control_features: + for key, value in feat.items(): + if key.startswith("has_") and value: + control_counts[key] += 1 summary = [] for feat_key, count in feature_counts.most_common(): pct = round(count / total_count * 100, 1) + control_count = control_counts.get(feat_key, 0) + control_pct = round(control_count / control_total * 100, 1) if control_total else 0.0 + lift = round((pct + 1.0) / (control_pct + 1.0), 2) if control_total else 0.0 label = FEATURE_LABELS.get(feat_key, feat_key) summary.append({ "feature": feat_key, "label": label, "count": count, "percentage": pct, + "control_count": control_count, + "control_percentage": control_pct, + "lift": lift, "total": total_count, + "control_total": control_total, }) # 补充:即使未达到阈值的特征也记录(只是不触发learned_rule) @@ -297,84 +332,98 @@ def compute_pattern_summary(all_features, total_count): "label": label, "count": 0, "percentage": 0.0, + "control_count": control_counts.get(feat_key, 0), + "control_percentage": round(control_counts.get(feat_key, 0) / control_total * 100, 1) if control_total else 0.0, + "lift": 0.0, "total": total_count, + "control_total": control_total, }) - # 排序:percentage降序 - summary.sort(key=lambda x: x["percentage"], reverse=True) + # 排序:优先看相对对照组的提升,再看涨幅榜占比 + summary.sort(key=lambda x: (x.get("lift", 0), x["percentage"]), reverse=True) return summary -def discover_new_rules(pattern_summary, all_features, sector_alignments, significance_pct=60.0): +def _rule_for_feature(feat_key, pct, lift=0): + lift_note = f",相对对照组提升{lift}x" if lift else "" + if feat_key == "has_ignition_point": + return { + "type": "bonus", + "description": f"涨幅榜{pct}%有起爆点(静K→动K){lift_note} → 起爆点是爆发前候选信号", + "conditions": {"has_ignition_point": True}, + "score_adjust": 2, + "source": "reverse_analysis", + } + if feat_key == "has_q7_zone": + return { + "type": "bonus", + "description": f"涨幅榜{pct}%有Q≥7供需区{lift_note} → 高质量供需区是爆发支撑候选", + "conditions": {"has_q7_zone": True}, + "score_adjust": 2, + "source": "reverse_analysis", + } + if feat_key == "has_q7_demand_nearby": + return { + "type": "bonus", + "description": f"涨幅榜{pct}%有Q≥7需求区在起爆价附近{lift_note} → 需求区支撑是爆发候选因子", + "conditions": {"has_q7_demand_nearby": True}, + "score_adjust": 3, + "source": "reverse_analysis", + } + if feat_key == "has_continuous_k": + return { + "type": "bonus", + "description": f"涨幅榜{pct}%有连续K多头加速{lift_note} → 趋势加速是爆发前兆候选", + "conditions": {"has_continuous_k_bullish": True}, + "score_adjust": 2, + "source": "reverse_analysis", + } + if feat_key == "has_static_accumulation": + return { + "type": "bonus", + "description": f"涨幅榜{pct}%有静K蓄力(占比≥15%){lift_note} → 蓄力是爆发候选条件", + "conditions": {"static_ratio_min": 0.15}, + "score_adjust": 1, + "source": "reverse_analysis", + } + if feat_key == "has_volume_surge_before": + return { + "type": "bonus", + "description": f"涨幅榜{pct}%起爆前2倍+放量{lift_note} → 量能先行是爆发预警候选", + "conditions": {"vol_surge_ratio_min": 2.0}, + "score_adjust": 2, + "source": "reverse_analysis", + } + if feat_key == "has_bullish_breakout_pattern": + return { + "type": "bonus", + "description": f"涨幅榜{pct}%连续5K收盘价抬高{lift_note} → 价格加速是爆发前形态候选", + "conditions": {"has_bullish_breakout_pattern": True}, + "score_adjust": 1, + "source": "reverse_analysis", + } + return None + + +def discover_new_rules(pattern_summary, all_features, sector_alignments, significance_pct=60.0, min_lift=1.5): """ 当共性特征占比≥significance_pct时,自动生成learned_rule 返回: [{rule_id, description, conditions, score_adjust}] """ new_rules = [] - significant_features = [p for p in pattern_summary if p["percentage"] >= significance_pct] + total_analyzed = len(all_features) + significant_features = [ + p for p in pattern_summary + if p["percentage"] >= significance_pct and (not p.get("control_total") or p.get("lift", 0) >= min_lift) + ] for pattern in significant_features: feat_key = pattern["feature"] pct = pattern["percentage"] - - # 根据特征类型生成不同规则 - if feat_key == "has_ignition_point": - rule = { - "type": "bonus", - "description": f"涨幅榜{pct}%有起爆点(静K→动K) → 起爆点是爆发前必现信号", - "conditions": {"has_ignition_point": True}, - "score_adjust": 2, - "source": "reverse_analysis", - } - elif feat_key == "has_q7_zone": - rule = { - "type": "bonus", - "description": f"涨幅榜{pct}%有Q≥7供需区 → 高质量供需区是爆发支撑", - "conditions": {"has_q7_zone": True}, - "score_adjust": 2, - "source": "reverse_analysis", - } - elif feat_key == "has_q7_demand_nearby": - rule = { - "type": "bonus", - "description": f"涨幅榜{pct}%有Q≥7需求区在起爆价附近 → 需求区支撑是爆发关键", - "conditions": {"has_q7_demand_nearby": True}, - "score_adjust": 3, - "source": "reverse_analysis", - } - elif feat_key == "has_continuous_k": - rule = { - "type": "bonus", - "description": f"涨幅榜{pct}%有连续K多头加速 → 趋势加速是爆发前兆", - "conditions": {"has_continuous_k_bullish": True}, - "score_adjust": 2, - "source": "reverse_analysis", - } - elif feat_key == "has_static_accumulation": - rule = { - "type": "bonus", - "description": f"涨幅榜{pct}%有静K蓄力(占比≥15%) → 蓄力是爆发必要条件", - "conditions": {"static_ratio_min": 0.15}, - "score_adjust": 1, - "source": "reverse_analysis", - } - elif feat_key == "has_volume_surge_before": - rule = { - "type": "bonus", - "description": f"涨幅榜{pct}%起爆前2倍+放量 → 量能先行是爆发预警", - "conditions": {"vol_surge_ratio_min": 2.0}, - "score_adjust": 2, - "source": "reverse_analysis", - } - elif feat_key == "has_bullish_breakout_pattern": - rule = { - "type": "bonus", - "description": f"涨幅榜{pct}%连续5K收盘价抬高 → 价格加速是爆发前形态", - "conditions": {"has_bullish_breakout_pattern": True}, - "score_adjust": 1, - "source": "reverse_analysis", - } - else: + count = int(pattern.get("count") or 0) + lift = float(pattern.get("lift") or 0) + rule = _rule_for_feature(feat_key, pct, lift=lift) + if not rule: continue # 新体系:逆向分析只生成候选规则,不直接写 learned_rules,避免涨幅榜小样本污染主策略。 @@ -385,11 +434,11 @@ def discover_new_rules(pattern_summary, all_features, sector_alignments, signifi rule_description=rule.get("description", ""), support_count=int(count), success_count=int(count), - fail_count=0, - confidence_score=round(min(95, pct), 1), + fail_count=int(pattern.get("control_count") or 0), + confidence_score=round(min(95, pct * max(lift, 1.0) / 2), 1), sample_size=int(total_analyzed), status="candidate", - notes="逆向涨幅榜规律,需等待推荐样本验证后再发布", + notes=f"逆向涨幅榜规律,已做对照组校验(lift={lift}),仍需等待推荐样本验证后再发布", source_ref=f"reverse:{feat_key}", ) new_rules.append(rule) @@ -441,6 +490,8 @@ def run_reverse_analysis(): lookback_hours = config.get("lookback_hours", 72) min_gain_pct = config.get("min_gain_pct", 10.0) significance_pct = config.get("significance_threshold_pct", 60.0) + control_sample_size = config.get("control_sample_size", top_n) + min_lift = config.get("min_lift", 1.5) # 1. 拉涨幅榜 tickers = fetch_24h_tickers() @@ -450,26 +501,27 @@ def run_reverse_analysis(): # 排序涨幅榜 gainers = [] + eligible_universe = [] for t in tickers: symbol_str = t["symbol"] - if not symbol_str.endswith("USDT"): + if not _is_altcoin_usdt_symbol(symbol_str): continue base = symbol_str.replace("USDT", "") - if base in ("BTC", "ETH", "BNB", "USDT"): - continue formatted = f"{base}/USDT" change_pct = float(t["priceChangePercent"]) volume_24h = float(t["quoteVolume"]) last_price = float(t["lastPrice"]) + item = { + "symbol": formatted, + "gain_pct": round(change_pct, 2), + "price": last_price, + "volume_24h": volume_24h, + "sector": get_sector_for_coin(formatted), + } + eligible_universe.append(item) if change_pct >= min_gain_pct: - gainers.append({ - "symbol": formatted, - "gain_pct": round(change_pct, 2), - "price": last_price, - "volume_24h": volume_24h, - "sector": get_sector_for_coin(formatted), - }) + gainers.append(item) # 按涨幅排序,取Top N gainers.sort(key=lambda x: x["gain_pct"], reverse=True) @@ -481,6 +533,15 @@ def run_reverse_analysis(): print(f"[reverse_analysis] 涨幅榜共{len(gainers)}只>{min_gain_pct}%, 其中{len(unrecommended_gainers)}只未被推荐") + gainer_symbols = {g["symbol"] for g in gainers} + control_candidates = [ + item for item in eligible_universe + if item["symbol"] not in gainer_symbols and item["gain_pct"] < min_gain_pct + ] + # 对照组取成交额较高但未大涨的山寨,保证“没涨”不是因为死币没流动性。 + control_candidates.sort(key=lambda x: (x["volume_24h"], -abs(x["gain_pct"])), reverse=True) + control_group = control_candidates[:control_sample_size] + # 3. 对每个未被推荐的暴涨币做PA分析 all_features = [] sector_alignments = [] @@ -547,12 +608,32 @@ def run_reverse_analysis(): "vol_surge_ratio": features.get("vol_surge_ratio", 0), }) + control_features = [] + control_details = [] + for item in control_group: + symbol = item["symbol"] + pre_klines, explosion_klines = fetch_klines_before(symbol, lookback_hours, "1h") + features = extract_pre_explosion_features(symbol, pre_klines, explosion_klines, config) + control_features.append(features) + control_details.append({ + "symbol": symbol, + "gain_pct": item["gain_pct"], + "volume_24h": item["volume_24h"], + "features": [key for key, value in features.items() if key.startswith("has_") and value], + }) + # 4. 统计共性特征 total_analyzed = len(all_features) - pattern_summary = compute_pattern_summary(all_features, total_analyzed) + pattern_summary = compute_pattern_summary(all_features, total_analyzed, control_features=control_features) # 5. 发现新规律 - new_rules = discover_new_rules(pattern_summary, all_features, sector_alignments, significance_pct) + new_rules = discover_new_rules( + pattern_summary, + all_features, + sector_alignments, + significance_pct, + min_lift=min_lift, + ) # 更新meta config_loader.update_meta("last_reverse_analysis", datetime.now().isoformat()) @@ -563,8 +644,10 @@ def run_reverse_analysis(): "total_gainers": len(gainers), "total_unrecommended": len(unrecommended_gainers), "total_analyzed": total_analyzed, + "control_analyzed": len(control_features), "top_gainers": gainers[:10], # feishu推送只取TOP10 "missed_details": missed_details, + "control_details": control_details[:10], "pattern_summary": pattern_summary, "new_rules": new_rules, "sector_alignments": sector_alignments, diff --git a/app/core/signal_taxonomy.py b/app/core/signal_taxonomy.py new file mode 100644 index 0000000..53cf262 --- /dev/null +++ b/app/core/signal_taxonomy.py @@ -0,0 +1,97 @@ +"""Stable signal taxonomy for recommendation analytics. + +Display labels are allowed to change often; these codes are the analytics keys +used by review, signal performance, and candidate-rule generation. +""" + +from __future__ import annotations + +import re +from typing import Any, Iterable + + +SIGNAL_CODE_LABELS = { + "vp_fly_1h_current": "1H当前量价齐飞", + "vp_fly_1h_stale": "1H历史量价齐飞", + "volume_divergence_1h": "1H量价背离", + "static_accum_4h": "4H静K蓄力", + "higher_lows_4h": "4H底部抬高", + "compression_surge_4h": "4H压缩放量", + "ignition_1h_current": "1H当前起爆点", + "ignition_4h_current": "4H当前起爆点", + "ignition_d1_current": "日线当前起爆点", + "ignition_stale": "历史起爆点", + "dynamic_k_1h_bull": "1H多头动K", + "dynamic_k_d1_bull": "日线多头动K", + "breakout_pullback_d1": "日线突破回踩", + "breakout_15m_current": "15min当前突破", + "pullback_15m_confirm": "15min回踩确认", + "strong_resonance_bypass": "强共振旁路", + "entry_quality_gate": "买点质量闸门", + "top_trader_long": "大户偏多", + "sector_rotation": "板块联动", + "sentiment_resonance": "舆情共振", + "funding_extreme": "资金费率极端", + "trend_exhaustion": "趋势衰减", + "false_breakout": "假突破", + "high_position_reject": "高位拒绝", + "risk_reward_bad": "盈亏比不合格", + "unknown": "未分类信号", +} + + +_PATTERNS = [ + ("vp_fly_1h_stale", ("历史放量阳线", "历史量价齐飞", "量价齐飞已过期")), + ("vp_fly_1h_current", ("量价齐飞", "量价齐飞K")), + ("volume_divergence_1h", ("量价背离", "放量但无量价齐飞")), + ("static_accum_4h", ("静K蓄力", "静K旁路")), + ("higher_lows_4h", ("底部抬高",)), + ("compression_surge_4h", ("压缩放量",)), + ("ignition_stale", ("历史起爆点", "起爆点已过期", "旧起爆")), + ("ignition_d1_current", ("日线", "起爆点")), + ("ignition_4h_current", ("4H", "起爆点")), + ("ignition_1h_current", ("1H", "起爆点")), + ("dynamic_k_d1_bull", ("日线", "动K")), + ("dynamic_k_1h_bull", ("1H", "动K")), + ("breakout_pullback_d1", ("日线", "突破", "回踩")), + ("breakout_15m_current", ("15min", "突破")), + ("pullback_15m_confirm", ("15min", "回踩确认")), + ("strong_resonance_bypass", ("强共振旁路",)), + ("entry_quality_gate", ("买点质量闸门",)), + ("top_trader_long", ("大户偏多",)), + ("sector_rotation", ("板块联动", "龙头")), + ("sentiment_resonance", ("舆情共振",)), + ("funding_extreme", ("资金费率极端",)), + ("trend_exhaustion", ("衰减", "反转", "阴动K")), + ("false_breakout", ("假突破", "冲高回落")), + ("high_position_reject", ("高位", "追高")), + ("risk_reward_bad", ("risk_reward_ok=false", "rr1=", "盈亏比")), +] + + +def signal_code(signal: Any) -> str: + text = str(signal or "").strip() + if not text: + return "unknown" + normalized = re.sub(r"\s+", "", text) + for code, needles in _PATTERNS: + if all(str(needle).replace(" ", "") in normalized for needle in needles): + return code + return "unknown" + + +def signal_label_for_code(code: str) -> str: + return SIGNAL_CODE_LABELS.get(code or "unknown", SIGNAL_CODE_LABELS["unknown"]) + + +def signal_codes(signals: Iterable[Any]) -> list[str]: + seen = [] + for sig in signals or []: + code = signal_code(sig) + if code not in seen: + seen.append(code) + return seen + + +def signal_labels(signals: Iterable[Any]) -> list[str]: + return [str(sig) for sig in (signals or []) if str(sig).strip()] diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index 121aab3..7240d87 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -18,6 +18,7 @@ from app.core.opportunity_lifecycle import ( normalize_action_status, is_executed_lifecycle, ) +from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels REPO_ROOT = Path(__file__).resolve().parents[2] DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")) @@ -187,6 +188,8 @@ def init_db(): ("ALTER TABLE recommendation ADD COLUMN state_reason TEXT DEFAULT ''", "DB迁移: recommendation表已添加state_reason字段"), ("ALTER TABLE recommendation ADD COLUMN entry_triggered INTEGER DEFAULT 0", "DB迁移: recommendation表已添加entry_triggered字段"), ("ALTER TABLE recommendation ADD COLUMN archived_at TEXT DEFAULT ''", "DB迁移: recommendation表已添加archived_at字段"), + ("ALTER TABLE recommendation ADD COLUMN signal_codes_json TEXT DEFAULT '[]'", "DB迁移: recommendation表已添加signal_codes_json字段"), + ("ALTER TABLE recommendation ADD COLUMN signal_labels_json TEXT DEFAULT '[]'", "DB迁移: recommendation表已添加signal_labels_json字段"), ]: try: conn.execute(sql) @@ -519,6 +522,14 @@ def _derive_minimal_state_fields(status, action_status, entry_plan=None): reason = "观察池,未触发入场" return _state_fields_for_storage(status, action, execution_status, reason) + +def _serialized_signal_payload(signals): + labels = build_signal_labels(signals if isinstance(signals, list) else _normalize_signals(signals)) + codes = build_signal_codes(labels) + stored_signals = json.dumps(labels, ensure_ascii=False) if isinstance(signals, list) else signals + return stored_signals, json.dumps(codes, ensure_ascii=False), json.dumps(labels, ensure_ascii=False) + + def create_recommendation(symbol, rec_state, rec_score, entry_price, stop_loss=0, tp1=0, tp2=0, sector="", signals="", is_meme=0, entry_plan=None, direction="中性", @@ -539,6 +550,7 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason = _derive_minimal_state_fields( "active", incoming_action, entry_plan or {} ) + stored_signals, signal_codes_json, signal_labels_json = _serialized_signal_payload(signals) # 当前状态唯一:同一 symbol 同一时间只允许一条可执行/观察主记录; # 但兼容粗筛蓄力→加速/爆发的状态迁移测试:无 entry_plan 的旧粗筛记录仍可新建演化轨迹。 duplicate_cursor = conn.execute( @@ -560,7 +572,7 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, conn.execute(""" UPDATE recommendation SET rec_state=?, rec_score=?, sector=COALESCE(NULLIF(?, ''), sector), - signals=?, is_meme=?, direction=?, strategy_version=?, + signals=?, signal_codes_json=?, signal_labels_json=?, is_meme=?, direction=?, strategy_version=?, force_reason=COALESCE(NULLIF(?, ''), force_reason), base_state=COALESCE(NULLIF(?, ''), base_state), sector_signal_count=MAX(COALESCE(sector_signal_count,0), ?), @@ -574,7 +586,7 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, WHERE id=? """, ( merged_state, merged_score, sector, - json.dumps(signals, ensure_ascii=False) if isinstance(signals, list) else signals, + stored_signals, signal_codes_json, signal_labels_json, is_meme, direction, strategy_version, force_reason or "", base_state or "", int(sector_signal_count or 0), json.dumps(entry_plan or {}, ensure_ascii=False), @@ -592,17 +604,17 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, cursor = conn.execute(""" INSERT INTO recommendation (symbol, rec_time, rec_state, rec_score, entry_price, - stop_loss, tp1, tp2, sector, signals, is_meme, direction, + stop_loss, tp1, tp2, sector, signals, signal_codes_json, signal_labels_json, is_meme, direction, current_price, max_price, min_price, last_track_time, entry_plan_json, force_reason, base_state, sector_signal_count, market_context_json, derivatives_context_json, sector_context_json, action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, strategy_version) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( symbol, now, rec_state, rec_score_pct, entry_price, stop_loss, tp1, tp2, sector, - json.dumps(signals, ensure_ascii=False) if isinstance(signals, list) else signals, + stored_signals, signal_codes_json, signal_labels_json, is_meme, direction, entry_price, entry_price, entry_price, now, json.dumps(entry_plan, ensure_ascii=False) if entry_plan else "{}", diff --git a/app/db/analytics.py b/app/db/analytics.py index ab4ac73..b0c10c5 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -27,6 +27,97 @@ def get_screening_history(hours=24, limit=100): return [dict(r) for r in rows] +def _loads_json(value, fallback): + try: + if isinstance(value, str) and value.strip(): + return json.loads(value) + if value: + return value + except Exception: + pass + return fallback + + +def get_observation_candidates(limit=50): + """Return current coarse-screen observation candidates for the watch pool.""" + conn = get_conn() + try: + limit = max(1, min(int(limit or 50), 200)) + except Exception: + limit = 50 + rows = conn.execute( + """ + SELECT * FROM coin_state + WHERE state != '过期' + ORDER BY score DESC, detected_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + conn.close() + + items = [] + for row in rows: + r = dict(row) + detail = _loads_json(r.get("detail_json"), {}) + signals = detail.get("signals") + if not isinstance(signals, list): + signals = [] + price = float(detail.get("price") or detail.get("current_price") or 0) + market_context = detail.get("market_context") if isinstance(detail.get("market_context"), dict) else {} + derivatives_context = detail.get("derivatives_context") if isinstance(detail.get("derivatives_context"), dict) else {} + sector_context = detail.get("sector_context") if isinstance(detail.get("sector_context"), dict) else {} + observe_tier = "weak" if int(r.get("score") or 0) < 4 else "strong" + reason = "粗筛观察候选,等待确认层给出当前触发和完整入场计划" + items.append({ + "id": f"obs:{r.get('symbol')}", + "symbol": r.get("symbol"), + "rec_time": r.get("detected_at"), + "rec_state": r.get("state"), + "rec_score": int(r.get("score") or 0), + "entry_price": price, + "current_price": price, + "stop_loss": 0, + "tp1": 0, + "tp2": 0, + "sector": r.get("sector") or detail.get("sector") or "", + "signals": signals, + "status": "active", + "action_status": "观察", + "execution_status": "observe", + "execution_label": "观察候选", + "execution_reason": reason, + "display_bucket": "watch_pool", + "lifecycle_state": "watching", + "entry_triggered": 0, + "entry_plan": { + "entry_action": "观察", + "entry_method": reason, + "entry_price": price, + "current_price": price, + }, + "observe_tier": observe_tier, + "observe_reason": reason, + "direction": detail.get("direction") or "多头启动", + "market_context": market_context, + "derivatives_context": derivatives_context, + "sector_context": sector_context, + "recommendation_result": "pending", + "recommendation_result_label": "观察候选", + "source": "coin_state", + }) + return { + "items": items, + "summary": { + "total": len(items), + "candidate_count": len(items), + "source": "coin_state", + "note": "初筛观察池,不计入推荐绩效", + }, + "has_more": False, + } + + def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False): """获取推荐列表。""" conn = get_conn() @@ -661,6 +752,7 @@ def get_cron_run_summary(hours=24): __all__ = [ "get_all_recommendations", + "get_observation_candidates", "get_cron_run_logs", "get_cron_run_summary", "get_review_stats", diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index bd1c6db..920a53a 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -119,10 +119,10 @@ def _is_candidate_fresh(cand, event_times, max_hours=6): 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)}] + return False, "stale_structure_background_only", [{"time": str(detected_at), "age_hours": round(age_h, 2)}] except Exception: pass - return True, "structure_candidate_unknown_age", [] + return False, "structure_candidate_unknown_age", [] @@ -927,6 +927,7 @@ def confirm_burst(symbol, cand): 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): @@ -983,6 +984,7 @@ def confirm_burst(symbol, cand): max_ig_strength, static_k_count, aux_count ) ) + bypass_confirmed = True confirmed = True score += bonus @@ -1143,6 +1145,14 @@ def confirm_burst(symbol, cand): derivatives_context=cand_detail.get("derivatives_context", {}), sector_context=cand_detail.get("sector_context", {}), ) + 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: diff --git a/app/services/altcoin_screener.py b/app/services/altcoin_screener.py index cd9414c..6fcd5b1 100644 --- a/app/services/altcoin_screener.py +++ b/app/services/altcoin_screener.py @@ -34,8 +34,7 @@ from app.core.sector_map import ( ) from app.db.altcoin_db import ( init_db, expire_old_states, update_state, get_candidates_for_confirm, - log_screening, create_recommendation, expire_old_recommendations, - log_cron_run, + log_screening, expire_old_recommendations, log_cron_run, ) from app.config.config_loader import ( get_signal_weights, @@ -1156,20 +1155,9 @@ def layer2_fine_filter(candidates): ) if state == "加速": - rec_id = create_recommendation( - symbol=symbol, rec_state="加速", rec_score=score, - entry_price=cand["price"], - sector=sector_str, signals=signals, - is_meme=int(meme), entry_plan=None, - direction=direction, - force_reason=force_accumulate_reason or "", - base_state=base_state or "", - sector_signal_count=sector_signal_count, - market_context=qualified[symbol]["market_context"], - derivatives_context=qualified[symbol]["derivatives_context"], - sector_context=qualified[symbol]["sector_context"], - ) - qualified[symbol]["rec_id"] = rec_id + # 初筛只负责机会发现和候选入池。交易推荐必须由确认层生成完整 entry_plan 后写入 recommendation, + # 避免把“涨幅榜共性候选/观察池”污染成已推荐交易样本。 + qualified[symbol]["candidate_stage"] = "confirm_pending" print(f"细筛结果: {len(qualified)}个候选") return qualified, hot_sectors, leaders diff --git a/app/services/review_engine.py b/app/services/review_engine.py index 02c9acf..cec2ca7 100644 --- a/app/services/review_engine.py +++ b/app/services/review_engine.py @@ -24,6 +24,8 @@ from app.db.altcoin_db import ( refresh_strategy_candidate_performance, ) from app.core.pa_engine import classify_candles, calc_atr, full_pa_analysis +from app.core.opportunity_lifecycle import derive_display_bucket, normalize_action_status +from app.core.signal_taxonomy import signal_code, signal_label_for_code, signal_codes from app.config.config_loader import ( get_review_params, update_meta, get_learned_rules, add_learned_rule, get_rules_snapshot, diff_rule_snapshots, get_meta, update_signal_weight, @@ -104,28 +106,51 @@ def _get_strategy_revision_started_at(): def _get_reviewable_recommendations(now=None): - """获取所有未复盘推荐,并遵守当前策略改版起始时间。""" + """获取所有未复盘且已进入执行口径的推荐。 + + 观察池、等回踩未触发、粗筛直写的无 entry_plan 样本只参与漏选/候选研究, + 不进入推荐绩效复盘,避免把“发现机会”当成“交易推荐”。 + """ now = now or datetime.now() conn = get_conn() revision_started_at = _get_strategy_revision_started_at() + executable_filter = """ + AND ( + COALESCE(entry_triggered,0)=1 + OR status IN ('hit_tp1','hit_tp2','stopped_out') + OR COALESCE(execution_status,'') IN ('buy_now','completed','holding') + OR COALESCE(action_status,'') IN ('可即刻买入','止盈1','止盈2','跟踪止盈','止损') + ) + AND NOT ( + status='active' + AND COALESCE(entry_triggered,0)=0 + AND ( + COALESCE(execution_status,'') IN ('wait_pullback','observe') + OR COALESCE(display_bucket,'watch_pool')='watch_pool' + OR COALESCE(action_status,'') IN ('等回踩','观察','持有','') + ) + ) + """ if revision_started_at: rows = conn.execute( - """ + f""" SELECT * FROM recommendation WHERE julianday(?) - julianday(rec_time) > 1 AND rec_time >= ? AND id NOT IN (SELECT rec_id FROM review_log) + {executable_filter} ORDER BY rec_time ASC """, (now.isoformat(), revision_started_at), ).fetchall() else: rows = conn.execute( - """ + f""" SELECT * FROM recommendation WHERE julianday(?) - julianday(rec_time) > 1 AND id NOT IN (SELECT rec_id FROM review_log) + {executable_filter} ORDER BY rec_time ASC """, (now.isoformat(),), @@ -135,6 +160,102 @@ def _get_reviewable_recommendations(now=None): return rows +def _is_reviewable_execution(rec): + status = str(rec.get("status") or "active").strip() + action = normalize_action_status(rec.get("action_status"), status) + execution_status = str(rec.get("execution_status") or "").strip() + if status in ("hit_tp1", "hit_tp2", "stopped_out"): + return True + bucket = derive_display_bucket(status, action, execution_status) + if not rec.get("entry_triggered") and ( + execution_status in ("wait_pullback", "observe") + or bucket.get("display_bucket") == "watch_pool" + or action in ("等回踩", "观察", "持有") + ): + return False + return ( + bool(rec.get("entry_triggered")) + or status in ("hit_tp1", "hit_tp2", "stopped_out") + or execution_status in ("buy_now", "completed", "holding") + or action in ("可即刻买入", "止盈1", "止盈2", "跟踪止盈", "止损") + ) + + +def _window_price_metrics(rec, hours=48): + """Calculate realized review-window metrics from price_tracking snapshots.""" + entry_price = float(rec.get("entry_price") or 0) + rec_time = rec.get("rec_time") or "" + rec_id = rec.get("id") + if not entry_price or not rec_time or not rec_id: + return { + "pnl_pct": 0, + "max_pnl_pct": 0, + "min_pnl_pct": 0, + "source": "insufficient_tracking", + "sample_count": 0, + "quality": "insufficient", + "reason": "missing_entry_or_rec_time", + } + try: + start = datetime.fromisoformat(str(rec_time)) + except Exception: + start = None + if not start: + return { + "pnl_pct": 0, + "max_pnl_pct": 0, + "min_pnl_pct": 0, + "source": "insufficient_tracking", + "sample_count": 0, + "quality": "insufficient", + "reason": "invalid_rec_time", + } + + end = start + timedelta(hours=hours) + conn = get_conn() + rows = conn.execute( + """ + SELECT price, track_time FROM price_tracking + WHERE rec_id=? AND track_time >= ? AND track_time <= ? + ORDER BY track_time ASC + """, + (rec_id, start.isoformat(), end.isoformat()), + ).fetchall() + conn.close() + if not rows: + return { + "pnl_pct": 0, + "max_pnl_pct": 0, + "min_pnl_pct": 0, + "source": "insufficient_tracking", + "sample_count": 0, + "quality": "insufficient", + "reason": "missing_price_tracking_window", + } + + prices = [float(r["price"] or 0) for r in rows if float(r["price"] or 0) > 0] + if not prices: + return { + "pnl_pct": 0, + "max_pnl_pct": 0, + "min_pnl_pct": 0, + "source": "insufficient_tracking", + "sample_count": 0, + "quality": "insufficient", + "reason": "invalid_price_tracking_prices", + } + close_price = prices[-1] + return { + "pnl_pct": round((close_price / entry_price - 1) * 100, 2), + "max_pnl_pct": round((max(prices) / entry_price - 1) * 100, 2), + "min_pnl_pct": round((min(prices) / entry_price - 1) * 100, 2), + "source": "price_tracking", + "sample_count": len(prices), + "quality": "complete", + "reason": "", + } + + # ==================== 1. 推荐归因复盘 ==================== def fetch_klines(symbol, interval="1h", limit=96): @@ -222,26 +343,73 @@ def review_recommendation(rec): rec_time = rec["rec_time"] rec_id = rec["id"] signals_raw = rec["signals"] - current_price = rec["current_price"] or get_current_price(symbol) + if not _is_reviewable_execution(rec): + return { + "rec_id": rec_id, + "symbol": symbol, + "outcome": "未执行", + "pnl_48h": 0, + "max_pnl_48h": 0, + "triggered_signals": [], + "hit_signals": [], + "miss_signals": [], + "lesson": "观察池/等回踩未触发样本不进入推荐绩效复盘", + "skipped": True, + } # 解析信号列表 try: signals = json.loads(signals_raw) if isinstance(signals_raw, str) else signals_raw except Exception: signals = [] + codes_raw = rec.get("signal_codes_json") or "" + try: + signal_code_list = json.loads(codes_raw) if isinstance(codes_raw, str) and codes_raw else [] + except Exception: + signal_code_list = [] + if not signal_code_list: + signal_code_list = signal_codes(signals) + review_signals = signal_code_list or ["unknown"] - # 计算盈亏 - if current_price <= 0 or entry_price <= 0: - pnl_pct = 0 - max_pnl_pct = rec["max_pnl_pct"] or 0 - else: - pnl_pct = round((current_price / entry_price - 1) * 100, 2) - max_pnl_pct = rec["max_pnl_pct"] or round((rec.get("max_price") or entry_price) / entry_price - 1 * 100, 2) + # 计算固定48h窗口盈亏:优先使用 price_tracking,不用复盘运行时现价污染历史结果。 + metrics = _window_price_metrics(rec, hours=48) + pnl_pct = metrics["pnl_pct"] + max_pnl_pct = metrics["max_pnl_pct"] + min_pnl_pct = metrics.get("min_pnl_pct", 0) + if metrics.get("quality") != "complete": + lesson = ( + "48h price_tracking窗口样本不足,复盘只记录占位,不进入推荐绩效/信号权重。" + f"原因: {metrics.get('reason') or metrics.get('source')}" + ) + record_review( + rec_id, + symbol, + "样本不足", + 0, + 0, + review_signals, + [], + [], + lesson, + ) + return { + "rec_id": rec_id, + "symbol": symbol, + "outcome": "样本不足", + "pnl_48h": 0, + "max_pnl_48h": 0, + "triggered_signals": review_signals, + "hit_signals": [], + "miss_signals": [], + "lesson": lesson, + "window_metrics": metrics, + "skipped": True, + } # 判定结果 if max_pnl_pct >= thresholds["hit_threshold_pct"]: outcome = "爆发" - elif pnl_pct <= thresholds["fail_threshold_pct"]: + elif pnl_pct <= thresholds["fail_threshold_pct"] or min_pnl_pct <= thresholds["fail_threshold_pct"]: outcome = "失败" else: outcome = "横盘" @@ -289,41 +457,44 @@ def review_recommendation(rec): hit_signals.append("放量持续") # 对原始信号逐个归因(增强版 — 精确验证) - for sig in signals: - sig_cat = get_signal_category(sig) + for code in review_signals: + label = signal_label_for_code(code) + sig_cat = get_signal_category(label) if outcome == "爆发": # 爆发了 → 前瞻/PA信号需验证是否真正延续 if sig_cat in ("前瞻", "PA"): - verified = _verify_signal_in_post_rec_pa(sig, post_rec_pa_result) + verified = _verify_signal_in_post_rec_pa(label, post_rec_pa_result) if verified: - hit_signals.append(sig) + hit_signals.append(code) else: # 信号触发了但未延续 → 可能是假信号,不过爆发了就算部分命中 - hit_signals.append(f"{sig}(未延续)") + hit_signals.append(f"{code}:unverified") else: - miss_signals.append(sig) # 滞后指标只是事后确认 + miss_signals.append(code) # 滞后指标只是事后确认 elif outcome == "失败": # 失败了 → 所有信号都是假信号 - miss_signals.append(sig) + miss_signals.append(code) else: # 横盘 → 滞后信号假信号概率高 if sig_cat == "滞后": - miss_signals.append(sig) + miss_signals.append(code) elif sig_cat in ("前瞻", "PA"): # 前瞻信号没骗人但也不够强 - miss_signals.append(sig) + miss_signals.append(code) # 生成教训 - lesson = _generate_lesson(outcome, hit_signals, miss_signals, signals) + lesson = _generate_lesson(outcome, hit_signals, miss_signals, review_signals) + if metrics.get("source") == "price_tracking": + lesson = (lesson + "; " if lesson else "") + f"复盘收益来自48h price_tracking窗口({metrics.get('sample_count', 0)}个快照)" # 写入复盘记录 record_review(rec_id, symbol, outcome, pnl_pct, max_pnl_pct, - signals, hit_signals, miss_signals, lesson) + review_signals, hit_signals, miss_signals, lesson) # 更新每个信号的绩效统计 is_hit = outcome == "爆发" - for sig in signals: - update_signal_performance(sig, get_signal_category(sig), is_hit, pnl_pct) + for code in review_signals: + update_signal_performance(code, get_signal_category(signal_label_for_code(code)), is_hit, pnl_pct) return { "rec_id": rec_id, @@ -331,10 +502,11 @@ def review_recommendation(rec): "outcome": outcome, "pnl_48h": pnl_pct, "max_pnl_48h": max_pnl_pct, - "triggered_signals": signals, + "triggered_signals": review_signals, "hit_signals": hit_signals, "miss_signals": miss_signals, "lesson": lesson, + "window_metrics": metrics, } @@ -1047,6 +1219,8 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su hit_count = sum(1 for r in results.get("review_details", []) if r.get("outcome") == "爆发") fail_count = sum(1 for r in results.get("review_details", []) if r.get("outcome") == "失败") flat_count = sum(1 for r in results.get("review_details", []) if r.get("outcome") == "横盘") + insufficient_count = sum(1 for r in results.get("review_details", []) if r.get("outcome") == "样本不足") + effective_review_count = hit_count + fail_count + flat_count findings = [] problems = [] @@ -1067,6 +1241,8 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su problems.append(f"{symbol} 复盘结果为失败,需检查触发信号是否偏滞后或追高") elif item.get("outcome") == "横盘" and symbol and len(problems) < 5: problems.append(f"{symbol} 仅横盘,说明信号强度不足或确认条件不够") + elif item.get("outcome") == "样本不足" and symbol and len(problems) < 5: + problems.append(f"{symbol} 缺少48h price_tracking窗口,已跳过绩效计权") for adj in results.get("weight_adjustments", [])[:8]: actions.append(adj) @@ -1147,9 +1323,11 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su summary = results.get("summary") or "" metrics = { "reviews_done": results.get("reviews_done", 0), + "effective_review_count": effective_review_count, "hit_count": hit_count, "fail_count": fail_count, "flat_count": flat_count, + "insufficient_tracking_count": insufficient_count, "missed_explosions": len(results.get("missed_explosions", [])), "weight_adjustments": len(results.get("weight_adjustments", [])), "signal_deprecations": len(results.get("signal_deprecations", [])), @@ -1232,6 +1410,21 @@ def _release_candidate_rules_if_ready(dual_attribution, effect_summary): "new_version": new_ver, } + +def _iteration_log_dual_fields(dual_attribution): + """Keep only fields supported by log_strategy_iteration().""" + allowed = { + "success_analysis", + "failure_analysis", + "candidate_rules", + "release_decision", + "release_reason", + "confidence_level", + "promotion_state", + } + return {k: v for k, v in (dual_attribution or {}).items() if k in allowed} + + def run_review(push_enabled: bool = True, compact: bool = False): """执行完整复盘流程(增强版 — 含逆向分析 + 飞书推送 + 规律提炼)""" before_rules = get_rules_snapshot() @@ -1248,6 +1441,7 @@ def run_review(push_enabled: bool = True, compact: bool = False): "missed_explosions": [], "new_learned_rules": [], "candidate_rules": [], + "candidate_performance": [], "reverse_analysis": None, "summary": "", } @@ -1280,30 +1474,16 @@ def run_review(push_enabled: bool = True, compact: bool = False): print(f"[review_engine] 逆向分析失败: {e}") results["reverse_analysis"] = {"error": str(e)} - # 5.5 新体系:候选规则先进入研究池,不再因为发现规律就自动升版。 + # 5.5 新体系:reverse_analysis.discover_new_rules 已经把候选写入 DB。 + # 这里仅保留结果用于报告/推送,避免同一涨幅榜共性被二次 upsert 污染候选池。 reverse_new_rules = (results.get("reverse_analysis") or {}).get("new_rules", []) or [] - for rule in reverse_new_rules: - desc = rule.get("description", "") - if desc: - rule["candidate_id"] = upsert_strategy_rule_candidate( - source="reverse_analysis", - rule_type=rule.get("type", "bonus"), - signal_name=",".join((rule.get("conditions") or {}).keys()), - rule_description=desc, - support_count=0, - success_count=0, - fail_count=0, - confidence_score=55, - sample_size=0, - status="candidate", - notes="逆向分析发现,等待推荐样本验证后再发布", - source_ref=f"reverse:{','.join((rule.get('conditions') or {}).keys())}", - ) # 6. 生成总结 hit_count = sum(1 for r in results["review_details"] if r["outcome"] == "爆发") fail_count = sum(1 for r in results["review_details"] if r["outcome"] == "失败") flat_count = sum(1 for r in results["review_details"] if r["outcome"] == "横盘") + insufficient_count = sum(1 for r in results["review_details"] if r["outcome"] == "样本不足") + effective_review_count = hit_count + fail_count + flat_count # 信号绩效汇总 weights = get_signal_weights() @@ -1313,6 +1493,7 @@ def run_review(push_enabled: bool = True, compact: bool = False): results["summary"] = ( f"本次复盘{results['reviews_done']}条推荐: " + f"有效计权{effective_review_count}条,样本不足{insufficient_count}条;" f"爆发{hit_count} 横盘{flat_count} 失败{fail_count} | " f"漏选爆发{len(results['missed_explosions'])}只 | " f"权重调整{len(results['weight_adjustments'])}项 | " @@ -1363,7 +1544,7 @@ def run_review(push_enabled: bool = True, compact: bool = False): pollution_summary = _scan_stable_fiat_pollution(now, lookback_days=7) dual_attribution = _build_dual_attribution(results, effect_summary) candidate_performance = refresh_strategy_candidate_performance() - dual_attribution["candidate_performance"] = candidate_performance + results["candidate_performance"] = candidate_performance release_gate = _release_candidate_rules_if_ready(dual_attribution, effect_summary) if release_gate.get("released"): current_meta = get_meta() @@ -1377,7 +1558,7 @@ def run_review(push_enabled: bool = True, compact: bool = False): effect_summary=effect_summary, pollution_summary=pollution_summary, ) - iteration_log.update(dual_attribution) + iteration_log.update(_iteration_log_dual_fields(dual_attribution)) iteration_log["release_decision"] = release_gate.get("release_decision") or dual_attribution.get("release_decision") iteration_log["release_reason"] = release_gate.get("release_reason") or dual_attribution.get("release_reason") if release_gate.get("released"): diff --git a/app/web/routes_recommendations.py b/app/web/routes_recommendations.py index 1c311dc..11bc372 100644 --- a/app/web/routes_recommendations.py +++ b/app/web/routes_recommendations.py @@ -5,6 +5,7 @@ from app.db.analytics import ( get_all_recommendations, get_cron_run_logs, get_cron_run_summary, + get_observation_candidates, get_review_stats, get_screening_history, get_stats, @@ -66,6 +67,15 @@ async def api_recommendations_active( return get_active_recommendations(actionable_only=actionable_only) +@router.get("/api/observations/active") +async def api_observations_active( + limit: int = 50, + altcoin_session: str = Cookie(default=""), +): + require_api_user_with_subscription(altcoin_session) + return get_observation_candidates(limit=limit) + + @router.get("/api/personalization") async def api_personalization(altcoin_session: str = Cookie(default="")): user = require_api_user_with_subscription(altcoin_session) diff --git a/docker-compose.yml b/docker-compose.yml index 1bf3cbe..4d02e7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: volumes: - ./data:/app/data - ./logs:/app/logs - - ./rules.yaml:/app/rules.yaml:ro + - ./rules.yaml:/app/rules.yaml healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8190/api/stats >/dev/null || exit 1"] interval: 30s @@ -43,4 +43,4 @@ services: volumes: - ./data:/app/data - ./logs:/app/logs - - ./rules.yaml:/app/rules.yaml:ro + - ./rules.yaml:/app/rules.yaml diff --git a/rules.yaml b/rules.yaml index 7851f6e..a90d80a 100644 --- a/rules.yaml +++ b/rules.yaml @@ -161,7 +161,8 @@ confirm: tp1_floor: 0.05 tp2: 5.0 tp2_floor: 0.08 - note: v1.7.8 TP2已废除(历史0命中). 保留tp2参数仅用于向后兼容. 主要止盈方式=跟踪止盈(tracker.trailing_stop). TP1作为提醒目标. + note: v1.7.8 TP2已废除(历史0命中). 保留tp2参数仅用于向后兼容. 主要止盈方式=跟踪止盈(tracker.trailing_stop). + TP1作为提醒目标. stop_loss: atr_mult: 2.0 floor_pct: 0.05 @@ -268,6 +269,159 @@ reverse_analysis: check_supply_demand: true check_volume_pattern: true check_sector_alignment: true +event_driven: + enabled: true + poll_interval_min: 1 + decision_target_seconds: 60 + news_time_window_hours: 3 + max_event_age_hours: 6 + dedup_window_hours: 24 + min_importance_level: A + sources: + binance_listing: + enabled: true + weight: S + url: https://www.binance.com/bapi/composite/v1/public/cms/article/list/query?type=1&catalogId=48&pageNo=1&pageSize=20 + note: Binance New Cryptocurrency Listing,含现货/合约/Launchpool等,对山寨短线冲击最大 + binance_latest: + enabled: true + weight: A + url: https://www.binance.com/bapi/composite/v1/public/cms/article/list/query?type=1&catalogId=49&pageNo=1&pageSize=20 + note: Binance Latest News,用于补充重大服务/产品变更 + coingecko_trending: + enabled: true + weight: B + note: 只作为热度源;单独不直接推荐,必须技术确认 + google_news_rss: + enabled: false + weight: B + note: 旧闻污染严重,默认不作为触发源;后续仅在严格时间窗+白名单媒体下启用 + importance: + s_keywords: + - will list + - will launch + - futures will launch + - perpetual contract + - launchpool + - megadrop + - hodler airdrops + - coinbase will add + - upbit listing + - bithumb listing + a_keywords: + - margin will add + - new trading pairs + - earn + - convert + - roadmap + - mainnet + - tokenomics + - airdrop + - burn + - buyback + - partnership + - integration + - upgrade + negative_keywords: + - delist + - suspend trading + - remove + - cease trading + - risk warning + technical_check: + min_tech_score_recommend: 6 + min_tech_score_observe: 3 + reject_if_24h_gain_gt: 30 + warn_if_24h_gain_gt: 18 + reject_if_funding_gt: 0.003 + allow_static_accumulation: true + allow_volume_breakout: true + allow_ignition: true + push: + recommend: true + observe: true + risk: true + cooldown_hours: 6 + theme_expansion: + enabled: true + min_theme_importance: A + max_expanded_symbols: 12 + static_accumulation_bonus: + enabled: true + min_static_count: 8 + score_bonus: 3 + note: 重大生态事件命中后,强静K蓄力币提前升权,防止DOGS类未起爆前被粗筛漏掉 + themes: + ton_ecosystem: + name: TON/Telegram生态 + keywords: + - telegram + - durov + - ton foundation + - ton ecosystem + - ton validator + - ton fees + - ton.org + - notcoin + - dogs + - hamster kombat + primary_symbols: + - TON + symbols: + - TON + - NOT + - DOGS + - HMSTR + - CATI + - MAJOR + note: Telegram/TON重大事件会外溢到NOT、DOGS等生态币,属于主题性大行情优先源 + base_ecosystem: + name: Base生态 + keywords: + - base ecosystem + - coinbase base + - base chain + - on base + primary_symbols: [] + symbols: + - VIRTUAL + - AERO + - DEGEN + - BRETT + note: Base链主题扩散 + solana_meme: + name: Solana Meme生态 + keywords: + - solana meme + - solana ecosystem + - sol meme + primary_symbols: + - SOL + symbols: + - BONK + - WIF + - POPCAT + - MEW + note: Solana meme主题扩散 +meta: + version: 1 + last_review: '2026-05-14T01:10:42.599449' + last_reverse_analysis: '2026-05-14T01:11:19.360232' + total_reviews: 20 + total_rules_learned: 37 + iteration_count: 25 + strategy_version: v1.7.11 + strategy_revision_started_at: '2026-05-09T01:20:00' + strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记' + rules_checksum: 4dd0c430f414775a + rules_last_verified: '2026-05-08T16:24:54' + factor_recency_fixed_at: '2026-05-11T07:41:19' + clean_review_started_at: '2026-05-11T07:41:19' + dirty_history_reason: 因子时效性修复前,历史推荐可能把旧放量/旧起爆/旧突破当成当前触发;旧样本仅用于诊断,不参与正式发布 + legacy_learned_rules_disabled_at: '2026-05-13T06:54:41' + legacy_learned_rules_disabled_count: 37 + legacy_learned_rules_reason: 复盘/自学习收敛到迭代发布闸门;历史 learned_rules 未经发布闸门验证,全部禁用为污染参考 + total_rules_learned_active: 0 learned_rules: - type: bonus description: 爆发案例中3次出现3静K蓄力组合 → 此信号组合预测爆发有效 @@ -956,25 +1110,6 @@ learned_rules: source_original: signal_deprecation release_version: '' candidate_id: null -meta: - version: 1 - last_review: '2026-05-13T00:30:04.002425' - last_reverse_analysis: '2026-05-11T00:30:21.952019' - total_reviews: 19 - total_rules_learned: 37 - iteration_count: 24 - strategy_version: v1.7.11 - strategy_revision_started_at: '2026-05-09T01:20:00' - strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记' - rules_checksum: 4dd0c430f414775a - rules_last_verified: '2026-05-08T16:24:54' - factor_recency_fixed_at: '2026-05-11T07:41:19' - clean_review_started_at: '2026-05-11T07:41:19' - dirty_history_reason: 因子时效性修复前,历史推荐可能把旧放量/旧起爆/旧突破当成当前触发;旧样本仅用于诊断,不参与正式发布 - legacy_learned_rules_disabled_at: '2026-05-13T06:54:41' - legacy_learned_rules_disabled_count: 37 - legacy_learned_rules_reason: 复盘/自学习收敛到迭代发布闸门;历史 learned_rules 未经发布闸门验证,全部禁用为污染参考 - total_rules_learned_active: 0 monitoring: untouched_rate: description: 未触达率自动监控:当已关闭推荐中未触发TP/SL的比例过高时自动bump min_score @@ -996,137 +1131,3 @@ monitoring: - signal_weights - tracker - sentiment -event_driven: - enabled: true - poll_interval_min: 1 - decision_target_seconds: 60 - news_time_window_hours: 3 - max_event_age_hours: 6 - dedup_window_hours: 24 - min_importance_level: A - sources: - binance_listing: - enabled: true - weight: S - url: https://www.binance.com/bapi/composite/v1/public/cms/article/list/query?type=1&catalogId=48&pageNo=1&pageSize=20 - note: Binance New Cryptocurrency Listing,含现货/合约/Launchpool等,对山寨短线冲击最大 - binance_latest: - enabled: true - weight: A - url: https://www.binance.com/bapi/composite/v1/public/cms/article/list/query?type=1&catalogId=49&pageNo=1&pageSize=20 - note: Binance Latest News,用于补充重大服务/产品变更 - coingecko_trending: - enabled: true - weight: B - note: 只作为热度源;单独不直接推荐,必须技术确认 - google_news_rss: - enabled: false - weight: B - note: 旧闻污染严重,默认不作为触发源;后续仅在严格时间窗+白名单媒体下启用 - importance: - s_keywords: - - will list - - will launch - - futures will launch - - perpetual contract - - launchpool - - megadrop - - hodler airdrops - - coinbase will add - - upbit listing - - bithumb listing - a_keywords: - - margin will add - - new trading pairs - - earn - - convert - - roadmap - - mainnet - - tokenomics - - airdrop - - burn - - buyback - - partnership - - integration - - upgrade - negative_keywords: - - delist - - suspend trading - - remove - - cease trading - - risk warning - technical_check: - min_tech_score_recommend: 6 - min_tech_score_observe: 3 - reject_if_24h_gain_gt: 30 - warn_if_24h_gain_gt: 18 - reject_if_funding_gt: 0.003 - allow_static_accumulation: true - allow_volume_breakout: true - allow_ignition: true - push: - recommend: true - observe: true - risk: true - cooldown_hours: 6 - theme_expansion: - enabled: true - min_theme_importance: A - max_expanded_symbols: 12 - static_accumulation_bonus: - enabled: true - min_static_count: 8 - score_bonus: 3 - note: 重大生态事件命中后,强静K蓄力币提前升权,防止DOGS类未起爆前被粗筛漏掉 - themes: - ton_ecosystem: - name: TON/Telegram生态 - keywords: - - telegram - - durov - - ton foundation - - ton ecosystem - - ton validator - - ton fees - - ton.org - - notcoin - - dogs - - hamster kombat - primary_symbols: - - TON - symbols: - - TON - - NOT - - DOGS - - HMSTR - - CATI - - MAJOR - note: Telegram/TON重大事件会外溢到NOT、DOGS等生态币,属于主题性大行情优先源 - base_ecosystem: - name: Base生态 - keywords: - - base ecosystem - - coinbase base - - base chain - - on base - primary_symbols: [] - symbols: - - VIRTUAL - - AERO - - DEGEN - - BRETT - note: Base链主题扩散 - solana_meme: - name: Solana Meme生态 - keywords: - - solana meme - - solana ecosystem - - sol meme - primary_symbols: - - SOL - symbols: - - BONK - - WIF - - POPCAT - - MEW - note: Solana meme主题扩散 diff --git a/static/app.html b/static/app.html index 7b4a708..de046ff 100644 --- a/static/app.html +++ b/static/app.html @@ -159,6 +159,18 @@ .trigger-cause { margin: 0 18px 8px; padding: 8px 10px; border: 1px solid rgba(66,98,255,.12); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); display: flex; align-items: center; gap: 8px; min-width: 0; } .trigger-cause .tc-label { flex-shrink: 0; color: var(--blue); font-size: 10px; font-weight: 900; line-height: 1.2; } .trigger-cause .tc-value { color: var(--slate); font-size: 12px; font-weight: 700; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.signal-context { display: flex; flex-direction: column; gap: 6px; padding: 0 18px 8px; } +.signal-context .trigger-cause, +.signal-context .trigger-meta { margin: 0; } +.trigger-meta { padding: 8px 10px; border-radius: var(--radius-lg); border: 1px solid var(--hairline-soft); background: var(--surface); font-size: 12px; color: var(--stone); display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.trigger-meta span { font-size: 10px; font-weight: 900; color: var(--stone); line-height: 1.2; } +.trigger-meta small { font-size: 11px; color: var(--stone); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trigger-meta.current { border-color: rgba(0,180,115,.18); background: rgba(0,180,115,.045); } +.trigger-meta.current span { color: var(--green); } +.trigger-meta.event { border-color: rgba(66,98,255,.16); background: rgba(66,98,255,.04); } +.trigger-meta.event span { color: var(--blue); } +.trigger-meta.stale { border-color: var(--hairline-soft); background: var(--surface); } +.trigger-meta.stale span { color: var(--muted); } .trust-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 18px 10px; } .trust-pill { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 8px 10px; min-width: 0; } .trust-pill .trust-label { display: block; font-size: 10px; color: var(--stone); font-weight: 700; text-transform: uppercase; margin-bottom: 3px; } @@ -231,6 +243,9 @@ .entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; } .trigger-cause { margin: 0 14px 8px; align-items: flex-start; } .trigger-cause .tc-value { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + .signal-context { padding: 0 14px 8px; } + .signal-context .trigger-cause { margin: 0; } + .trigger-meta small { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .trust-row { grid-template-columns: 1fr; padding: 0 14px 8px; } } @@ -242,7 +257,6 @@ .card-footer { padding: 6px 14px 12px; } } -.trigger-meta{margin:10px 0 0;padding:9px 10px;border-radius:12px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.04);font-size:12px;color:var(--text-secondary);display:flex;flex-direction:column;gap:3px}.trigger-meta span{font-weight:700;color:var(--text-primary)}.trigger-meta small{font-size:11px;color:var(--text-muted);line-height:1.35}.trigger-meta.current{border-color:rgba(0,180,115,.25);background:rgba(0,180,115,.08)}.trigger-meta.event{border-color:rgba(66,98,255,.28);background:rgba(66,98,255,.09)}.trigger-meta.stale{border-color:rgba(255,183,77,.25);background:rgba(255,183,77,.08)} {% endblock %} @@ -523,6 +537,17 @@ async function loadContent(reset) { var resp = await fetch(url); var page = await resp.json(); var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []); + if (!items.length && offset === 0) { + var obsResp = await fetch(API+'/api/observations/active?limit='+liveLimit); + if (obsResp.ok) { + var obsPage = await obsResp.json(); + var obsItems = Array.isArray(obsPage.items) ? obsPage.items : []; + if (obsItems.length) { + page = obsPage; + items = obsItems; + } + } + } liveSummary = page.summary || liveSummary; liveHasMore = !!page.has_more; if (reset === false) { @@ -587,7 +612,7 @@ function renderLiveStats(data) { var wCls = 'stat-chip weak-chip' + (currentFilter === 'weak_observe' ? ' filterable active' : ' filterable'); $('liveStats').innerHTML = '
暂无实时看板信号
系统持续扫描中,有机会会实时更新
暂无实时推荐或观察候选
系统持续扫描中,有机会会实时更新
| 当前阶段 | 预演结论 | 规律 | 样本 | 成功/失败 | 可信度 | 平均表现 | 为什么还没发布 |
|---|---|---|---|---|---|---|---|
| '+badge(c.status||'candidate')+' | '+badge(d.dry_run_status||c.status||'candidate')+' | '+esc(c.rule_description||c.signal_name||'--')+' | '+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+' | '+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+' | '+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+' | '+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+' | '+esc(d.gate_reason||'等待样本验证')+' |
| 来源 | 当前阶段 | 预演结论 | 规律 | 样本 | 成功/失败 | 可信度 | 平均表现 | 为什么还没发布 |
|---|---|---|---|---|---|---|---|---|
| '+esc(sourceLabel(c))+' | '+badge(c.status||'candidate')+' | '+badge(d.dry_run_status||c.status||'candidate')+' | '+esc(c.rule_description||c.signal_name||'--')+' | '+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+' | '+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+' | '+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+' | '+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+' | '+esc(d.gate_reason||'等待样本验证')+' |
| 预演结论 | 规律 | 样本 | 成功/失败 | 可信度 | 平均表现 | 原因 |
|---|---|---|---|---|---|---|
| '+badge(x.dry_run_status||'candidate')+' | '+esc(x.rule_description||x.signal_name||'--')+' | '+esc(x.sample_size||0)+' | '+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+' | '+esc(x.confidence_score||0)+' | '+esc(x.avg_pnl||0)+' | '+esc(x.gate_reason||'--')+' |
| 版本 | 推荐数 | 成功 | 失败 | 待观察 | 成功率 | 均值收益 |
|---|---|---|---|---|---|---|
| '+esc(v.strategy_version)+' | '+esc(v.recommendation_count)+' | '+esc(v.success_count)+' | '+esc(v.failed_count)+' | '+esc(v.pending_count)+' | '+esc(v.success_rate_pct)+' | '+esc(v.avg_pnl_pct)+' |
系统可信度、版本表现、因子归因与市场环境归因。