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 = '
' + - '
全部机会'+total+'
' + + '
全部候选'+total+'
' + '
入场窗口'+buy+'
' + '
重点观察'+observeStrong+'
' + '
弱观察'+observeWeak+'
' + @@ -598,7 +623,7 @@ function renderLiveCards(data, weakCount) { var items = Array.isArray(data) ? data : []; if (!items.length) { var weakOnly = weakCount ? '
当前只有 '+weakCount+' 个弱观察候选,已默认收起,避免干扰主机会流。
' : ''; - $('liveCards').innerHTML = weakOnly || '

暂无实时看板信号
系统持续扫描中,有机会会实时更新

'; return; + $('liveCards').innerHTML = weakOnly || '

暂无实时推荐或观察候选
系统持续扫描中,有机会会实时更新

'; return; } var order = { buy_now: 0, wait_pullback: 1, observe: 2, holding: 3, completed: 4, invalid: 9 }; items.sort(function(a,b){ @@ -635,6 +660,7 @@ function renderRecCard(r) { var phase = opportunityPhase(r, entryMethod, signalText); var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = phase.label === '等回踩' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak', isObs = r.display_bucket === 'watch_pool' || r.execution_status !== 'buy_now'; var isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed'; + var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered; // ---- Action badge with expiry/surge detection ---- var recMs = r.rec_time ? new Date(r.rec_time).getTime() : 0; @@ -709,12 +735,22 @@ function renderRecCard(r) { } var trustHtml = trustWindowHtml(); var weakNoteHtml = isWeakObserve ? '
'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'
' : ''; - var entryPlanHtml = '
' + - '
阶段'+phase.short+'机会所处阶段
'+ - '
'+entryLabel+''+fmtP(entryRef)+'触发/计划价
'+ - '
风险边界'+fmtP(riskLine)+'跌破则逻辑失效
'+ - '
上方空间'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'参考位 '+fmtP(spaceRef)+'
'+ - '
'; + var entryPlanHtml = ''; + if (isTradePlan) { + entryPlanHtml = '
' + + '
阶段'+phase.short+'机会所处阶段
'+ + '
'+entryLabel+''+fmtP(entryRef)+'触发/计划价
'+ + '
风险边界'+fmtP(riskLine)+'跌破则逻辑失效
'+ + '
上方空间'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'参考位 '+fmtP(spaceRef)+'
'+ + '
'; + } else { + entryPlanHtml = '
' + + '
阶段'+phase.short+'观察池候选
'+ + '
当前参考'+fmtP(price)+'不是入场价
'+ + '
确认条件待触发需15m/1H当前信号
'+ + '
绩效口径不计入未成交易推荐
'+ + '
'; + } var triggerCauseHtml = triggerCause ? '
'+(hasQualityGate?'观察原因':'触发依据')+''+(hasQualityGate ? cleanDisplayText(r.observe_reason || triggerCause).slice(0,96) : triggerCause.slice(0,80))+'
' : ''; var triggerCtx = (r.market_context && r.market_context.trigger_context) || (r.sector_context && r.sector_context.trigger_context) || ep.trigger_context || {}; var curTriggers = Array.isArray(triggerCtx.current_triggers) ? triggerCtx.current_triggers : []; @@ -723,17 +759,18 @@ function renderRecCard(r) { if (triggerCtx.trigger_status || curTriggers.length || staleTriggers.length) { var tCls = /news/.test(triggerCtx.trigger_status || '') ? 'event' : (/stale/.test(triggerCtx.trigger_status || '') ? 'stale' : 'current'); var tLabel = triggerCtx.trigger_label || (curTriggers.length ? '当前触发' : '历史背景'); + if (tCls === 'stale') tLabel = '历史背景'; var firstCur = curTriggers[0] || {}; var sub = firstCur.title || firstCur.label || (staleTriggers[0] && staleTriggers[0].label) || ''; triggerBadgeHtml = '
'+cleanDisplayText(tLabel).slice(0,32)+''+(sub?''+cleanDisplayText(sub).slice(0,72)+'':'')+'
'; } + var contextHtml = (triggerCauseHtml || triggerBadgeHtml) ? '
'+triggerCauseHtml+triggerBadgeHtml+'
' : ''; return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+actionBadge+''+score+''+st.label+'
'+ '
$'+priceFmt+''+changeHtml+'
'+ '
'+ (isWeakObserve ? weakNoteHtml : entryPlanHtml)+ (!isWeakObserve && trustHtml?'
'+trustHtml+'
':'')+ - triggerCauseHtml+ - triggerBadgeHtml+ + contextHtml+ (sigHtml?'
'+sigHtml+'
':'')+ '
'; } diff --git a/static/iteration.html b/static/iteration.html index c734954..4396845 100644 --- a/static/iteration.html +++ b/static/iteration.html @@ -173,17 +173,19 @@ function renderUserReport(d){ '
最近最该关注的错误
系统不只看成功因子,也会记录反复导致失败的原因,避免下一轮继续犯同样的错。
'+failHtml+'
'; } -function renderKpis(d){ var ov=d.overview||{}, st=ov.candidate_status_counts||{}, rd=ov.release_decision_counts||{}, dry=ov.dry_run_summary||{}; $('kpis').innerHTML=[ - ['复盘样本', dry.review_sample_count||0, '修复后干净样本数量'], +function renderKpis(d){ var ov=d.overview||{}, st=ov.candidate_status_counts||{}, rd=ov.release_decision_counts||{}, dry=ov.dry_run_summary||{}, latest=(d.logs&&d.logs[0]&&d.logs[0].metrics)||{}; $('kpis').innerHTML=[ + ['有效计权', latest.effective_review_count!=null?latest.effective_review_count:(dry.review_sample_count||0), '只统计有48h窗口的交易样本'], + ['样本不足', latest.insufficient_tracking_count||0, '缺少price_tracking时只记录,不调权'], ['待验证规律', ov.candidate_count||0, '观察中 '+(st.candidate||0)+' / 灰度 '+(st.gray||0)+' / 旧样本参考 '+(dry.dirty_history_candidate_count||0)], ['可灰度规律', dry.gray_ready_count||0, '达到门槛才会进入灰度'], ['正式发布', (rd.release||0), '真正改变线上策略的次数'] ].map(function(k){return '
'+k[0]+'
'+k[1]+'
'+k[2]+'
';}).join(''); } function renderGate(d){ var ov=d.overview||{}, dry=(d.dry_run||{}), ds=ov.dry_run_summary||{}; var latest=ov.latest_release_decision||'hold'; $('gateBox').innerHTML='
本轮是否发布
'+badge(latest)+'
干净样本起点:'+esc(ds.clean_started_at||dry.clean_started_at||'未设置')+';样本窗口:'+esc(ds.sample_window||dry.sample_window||'all_history')+'。旧污染样本只作解释,不会直接改变线上策略。
最近发布原因:'+esc(ov.latest_release_reason||'暂无发布决策说明')+'
预演结论:'+esc(dry.release_reason||ds.release_reason||'只读评估,不写库、不升版')+'
干净复盘样本'+esc(ds.review_sample_count||dry.review_sample_count||0)+'
污染历史候选'+esc(ds.dirty_history_candidate_count||dry.dirty_history_candidate_count||0)+'
可灰度'+esc(ds.gray_ready_count||dry.gray_ready_count||0)+'
是否发布'+(dry.would_bump_version?'是':'否')+'
'; } -function renderTimeline(items){ if(!items.length){$('timeline').innerHTML='
暂无迭代记录
';return;} $('timeline').innerHTML=items.map(function(it){ var decision=it.release_decision||'unknown'; var metrics=it.metrics||{}; var cls=decision==='release'?' release':decision==='gray'?' gray':''; return '
'+esc(it.strategy_version||'--')+''+esc(it.title||'复盘迭代')+''+badge(decision)+''+fmtTime(it.created_at)+'
爆发 '+(metrics.hit_count||0)+'横盘 '+(metrics.flat_count||0)+'失败 '+(metrics.fail_count||0)+'候选 '+((it.candidate_rules||[]).length)+'置信 '+esc(it.confidence_level||'--')+'
'+esc((it.release_reason||it.version_change_summary||it.summary||'').slice(0,280))+'
'+renderSection('成功因子',(it.success_analysis&&it.success_analysis.top_success_factors)||[],'good')+renderSection('失败模式',(it.failure_analysis&&it.failure_analysis.failure_types)||[],'warn')+renderCandidateMini(it.candidate_rules||[])+renderSection('动作',it.actions||[],'')+renderSection('问题',it.problems||[],'warn')+'
'; }).join(''); } +function renderTimeline(items){ if(!items.length){$('timeline').innerHTML='
暂无迭代记录
';return;} $('timeline').innerHTML=items.map(function(it){ var decision=it.release_decision||'unknown'; var metrics=it.metrics||{}; var cls=decision==='release'?' release':decision==='gray'?' gray':''; return '
'+esc(it.strategy_version||'--')+''+esc(it.title||'复盘迭代')+''+badge(decision)+''+fmtTime(it.created_at)+'
有效 '+(metrics.effective_review_count!=null?metrics.effective_review_count:((metrics.hit_count||0)+(metrics.flat_count||0)+(metrics.fail_count||0)))+'爆发 '+(metrics.hit_count||0)+'横盘 '+(metrics.flat_count||0)+'失败 '+(metrics.fail_count||0)+'样本不足 '+(metrics.insufficient_tracking_count||0)+'候选 '+((it.candidate_rules||[]).length)+'置信 '+esc(it.confidence_level||'--')+'
'+esc((it.release_reason||it.version_change_summary||it.summary||'').slice(0,280))+'
'+renderSection('成功因子',(it.success_analysis&&it.success_analysis.top_success_factors)||[],'good')+renderSection('失败模式',(it.failure_analysis&&it.failure_analysis.failure_types)||[],'warn')+renderCandidateMini(it.candidate_rules||[])+renderSection('动作',it.actions||[],'')+renderSection('问题',it.problems||[],'warn')+'
'; }).join(''); } function renderSection(label,items,cls){ if(!items||!items.length)return ''; return '
'+items.slice(0,10).map(function(x){ var t=typeof x==='string'?x:(x.label||x.type||x.signal||x.description||JSON.stringify(x)); var c=x.count?(' · '+x.count):''; return '
'+esc(t+c)+'
'; }).join('')+'
'; } function renderCandidateMini(items){ if(!items.length)return ''; return '
'+items.slice(0,8).map(function(x){return '
'+esc(x.description||x.signal||'候选规则')+' · 置信 '+esc(x.confidence_score||0)+' · 样本 '+esc(x.sample_size||0)+' · '+esc(x.status||'candidate')+'
';}).join('')+'
'; } -function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTML='
暂无待验证规律
';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML=''+items.map(function(c){var d=dryMap[c.id]||{};return '';}).join('')+'
当前阶段预演结论规律样本成功/失败可信度平均表现为什么还没发布
'+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||'等待样本验证')+'
'; } +function sourceLabel(c){ var s=String(c.source||''); if(s==='reverse_analysis')return '涨幅榜逆向'; if(s.indexOf('dual_attribution_success')===0)return '成功复盘'; if(s.indexOf('dual_attribution_failure')===0)return '失败复盘'; if(s.indexOf('signal_deprecation')===0)return '低绩效信号'; if(s.indexOf('dirty_history')===0)return '历史参考'; return s||'研究池'; } +function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTML='
暂无待验证规律
';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML=''+items.map(function(c){var d=dryMap[c.id]||{};return '';}).join('')+'
来源当前阶段预演结论规律样本成功/失败可信度平均表现为什么还没发布
'+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||'等待样本验证')+'
'; } function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='
暂无待验证规律可评估
';return;} $('dryrun').innerHTML='
当前版本 '+esc(dry.current_version||'--')+';干净样本起点 '+esc(dry.clean_started_at||'未设置')+';干净复盘样本 '+esc(dry.review_sample_count||0)+';污染历史候选 '+esc(dry.dirty_history_candidate_count||0)+';可灰度 '+esc(dry.gray_ready_count||0)+';是否发布:'+(dry.would_bump_version?'是':'否')+'。
灰度标准:'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'
'+items.map(function(x){return '';}).join('')+'
预演结论规律样本成功/失败可信度平均表现原因
'+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||'--')+'
'; } function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return ''+esc(f.type)+' · '+esc(f.count)+'';}).join(''):'
暂无失败模式
'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '
'+esc(f.symbol||'--')+' · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · PnL '+esc(f.pnl_pct||0)+'
';}).join(''):'
暂无失败样本
'; } function renderVersions(items){ if(!items.length){$('versions').innerHTML='
暂无版本表现
';return;} $('versions').innerHTML=''+items.map(function(v){return '';}).join('')+'
版本推荐数成功失败待观察成功率均值收益
'+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)+'
'; } diff --git a/static/strategy.html b/static/strategy.html index 6736dd4..ff32ad5 100644 --- a/static/strategy.html +++ b/static/strategy.html @@ -12,7 +12,7 @@ {% endblock %} {% block extra_head_css %} {% endblock %} {% block content %} @@ -20,6 +20,12 @@

策略

系统可信度、版本表现、因子归因与市场环境归因。

数据基于历史信号跟踪,仅用于策略研究与模型评估,不构成收益承诺或投资建议。
+
+
发现粗筛只进观察/候选,不直接计交易绩效。
+
确认只有新鲜触发和入场计划达标才进入可交易口径。
+
复盘只用48h窗口样本更新有效表现。
+
迭代候选规则经发布闸门后才生效。查看迭代
+

版本表现

市场环境归因

diff --git a/tests/test_confirm_freshness_gate.py b/tests/test_confirm_freshness_gate.py index 1731f5c..08dd669 100644 --- a/tests/test_confirm_freshness_gate.py +++ b/tests/test_confirm_freshness_gate.py @@ -19,11 +19,18 @@ def test_candidate_fresh_when_state_detected_recently_without_current_trigger(): def test_candidate_stale_when_no_recent_trigger_and_old_state(): cand = {"symbol": "TEST/USDT", "detected_at": (datetime.now() - timedelta(hours=8)).isoformat()} ok, reason, events = _is_candidate_fresh(cand, [], max_hours=6) - assert ok is True + assert ok is False assert reason == "stale_structure_background_only" assert events and events[0]["age_hours"] > 6 +def test_candidate_unknown_age_is_not_fresh_without_current_trigger(): + ok, reason, events = _is_candidate_fresh({"symbol": "TEST/USDT"}, [], max_hours=6) + assert ok is False + assert reason == "structure_candidate_unknown_age" + assert events == [] + + def test_event_time_from_age_maps_latest_and_previous_bar(): now = pd.Timestamp(datetime.now().replace(minute=0, second=0, microsecond=0)) df = pd.DataFrame({"timestamp": [now - pd.Timedelta(hours=2), now - pd.Timedelta(hours=1), now]}) diff --git a/tests/test_observation_candidates_api.py b/tests/test_observation_candidates_api.py new file mode 100644 index 0000000..6f6f961 --- /dev/null +++ b/tests/test_observation_candidates_api.py @@ -0,0 +1,61 @@ +import json +import os +import sys +from datetime import datetime + +PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if PROJECT_DIR not in sys.path: + sys.path.insert(0, PROJECT_DIR) + +from app.db import altcoin_db +from app.db.analytics import get_observation_candidates + + +def test_observation_candidates_expose_coin_state_as_watch_pool(monkeypatch, tmp_path): + db_path = tmp_path / "obs.db" + monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path)) + import app.db.schema as schema + + monkeypatch.setattr(schema, "DB_PATH", str(db_path), raising=False) + altcoin_db.init_db() + + detail = { + "price": 1.23, + "signals": ["当前4H静K蓄力", "大户偏多(69%)"], + "market_context": {"trigger_context": {"trigger_status": "current"}}, + "derivatives_context": {"top_trader_long_pct": 69}, + "sector_context": {"hot_sectors": ["AI_DePIN"]}, + } + conn = altcoin_db.get_conn() + conn.execute( + """ + INSERT INTO coin_state ( + symbol, state, score, anomaly_type, sector, leader_status, + detected_at, detail_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "OBS/USDT", + "蓄力", + 5, + "", + "AI_DePIN", + "", + datetime.now().isoformat(), + json.dumps(detail, ensure_ascii=False), + ), + ) + conn.commit() + conn.close() + + data = get_observation_candidates(limit=10) + + assert data["summary"]["source"] == "coin_state" + assert len(data["items"]) == 1 + item = data["items"][0] + assert item["symbol"] == "OBS/USDT" + assert item["display_bucket"] == "watch_pool" + assert item["execution_status"] == "observe" + assert item["entry_triggered"] == 0 + assert item["current_price"] == 1.23 + assert item["signals"] == ["当前4H静K蓄力", "大户偏多(69%)"] diff --git a/tests/test_recommendation_execution_status.py b/tests/test_recommendation_execution_status.py index 03b4321..843f024 100644 --- a/tests/test_recommendation_execution_status.py +++ b/tests/test_recommendation_execution_status.py @@ -243,6 +243,25 @@ class RecommendationExecutionStatusTests(unittest.TestCase): self.assertIn(row[0], ('等回踩', '观察')) self.assertIn('entry_quality_gate', json.loads(row[1])) + def test_create_recommendation_stores_stable_signal_codes(self): + rec_id = altcoin_db.create_recommendation( + symbol='SIG/USDT', + rec_state='爆发', + rec_score=12, + entry_price=1.0, + signals=['1H 量价齐飞K(量3.7x)', '1H 量价齐飞K(量9.1x)', '15min 回踩确认'], + entry_plan={'entry_action': '可即刻买入', 'entry_price': 1.0, 'risk_reward_ok': True, 'rr1': 2.0}, + direction='多头启动', + ) + conn = sqlite3.connect(self.db_path) + row = conn.execute("SELECT signal_codes_json, signal_labels_json FROM recommendation WHERE id=?", (rec_id,)).fetchone() + conn.close() + codes = json.loads(row[0]) + labels = json.loads(row[1]) + self.assertEqual(codes.count('vp_fly_1h_current'), 1) + self.assertIn('pullback_15m_confirm', codes) + self.assertEqual(len(labels), 3) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_reverse_analysis_control.py b/tests/test_reverse_analysis_control.py new file mode 100644 index 0000000..a30ee45 --- /dev/null +++ b/tests/test_reverse_analysis_control.py @@ -0,0 +1,73 @@ +import os +import sys + +PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if PROJECT_DIR not in sys.path: + sys.path.insert(0, PROJECT_DIR) + +from app.analysis import reverse_analysis + + +def test_pattern_summary_includes_control_lift(): + gainers = [ + {"has_static_accumulation": True, "has_ignition_point": True}, + {"has_static_accumulation": True, "has_ignition_point": False}, + {"has_static_accumulation": False, "has_ignition_point": True}, + ] + controls = [ + {"has_static_accumulation": True, "has_ignition_point": False}, + {"has_static_accumulation": False, "has_ignition_point": False}, + {"has_static_accumulation": False, "has_ignition_point": False}, + ] + + summary = reverse_analysis.compute_pattern_summary(gainers, len(gainers), control_features=controls) + by_feature = {item["feature"]: item for item in summary} + + assert by_feature["has_ignition_point"]["percentage"] == 66.7 + assert by_feature["has_ignition_point"]["control_percentage"] == 0.0 + assert by_feature["has_ignition_point"]["lift"] > 1 + assert by_feature["has_static_accumulation"]["control_count"] == 1 + + +def test_discover_new_rules_requires_lift_when_control_exists(monkeypatch): + created = [] + monkeypatch.setattr( + reverse_analysis, + "upsert_strategy_rule_candidate", + lambda **kwargs: created.append(kwargs) or 101, + ) + pattern_summary = [ + { + "feature": "has_static_accumulation", + "percentage": 80.0, + "count": 8, + "total": 10, + "control_percentage": 75.0, + "control_count": 15, + "control_total": 20, + "lift": 1.06, + }, + { + "feature": "has_ignition_point", + "percentage": 70.0, + "count": 7, + "total": 10, + "control_percentage": 20.0, + "control_count": 4, + "control_total": 20, + "lift": 3.38, + }, + ] + + rules = reverse_analysis.discover_new_rules( + pattern_summary, + all_features=[{}] * 10, + sector_alignments=[], + significance_pct=60, + min_lift=1.5, + ) + + assert len(rules) == 1 + assert rules[0]["conditions"] == {"has_ignition_point": True} + assert created[0]["signal_name"] == "has_ignition_point" + assert created[0]["fail_count"] == 4 diff --git a/tests/test_review_accuracy_pipeline.py b/tests/test_review_accuracy_pipeline.py new file mode 100644 index 0000000..43adc8d --- /dev/null +++ b/tests/test_review_accuracy_pipeline.py @@ -0,0 +1,217 @@ +import json +import os +import sqlite3 +import sys +from datetime import datetime, timedelta + +import pytest + +PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if PROJECT_DIR not in sys.path: + sys.path.insert(0, PROJECT_DIR) + +from app.config import config_loader +from app.db import altcoin_db +from app.services import review_engine + + +@pytest.fixture +def temp_review_env(monkeypatch, tmp_path): + db_path = tmp_path / "review.db" + rules_path = tmp_path / "rules.yaml" + monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path)) + monkeypatch.setattr(config_loader, "RULES_PATH", str(rules_path)) + monkeypatch.setattr(review_engine, "get_conn", altcoin_db.get_conn) + config_loader._cache = None + config_loader._cache_mtime = None + rules_path.write_text( + """ +strategy: + mode: long_only + direction: 多头启动 +confirm: {} +screener: {} +tracker: {} +signal_weights: {} +review: + hit_threshold_pct: 5.0 + fail_threshold_pct: -3.0 + missed_explosion_pct: 20.0 +reverse_analysis: {} +learned_rules: [] +meta: + strategy_version: v-test + """.strip(), + encoding="utf-8", + ) + altcoin_db.init_db() + return db_path + + +def _insert_rec(db_path, **kwargs): + defaults = dict( + symbol="REV/USDT", + rec_time="2026-05-10T00:00:00", + rec_state="爆发", + rec_score=80, + entry_price=100.0, + stop_loss=95.0, + tp1=110.0, + tp2=120.0, + sector="", + signals=json.dumps(["1H 量价齐飞K(量3.7x)", "15min 回踩确认"], ensure_ascii=False), + signal_codes_json=json.dumps(["vp_fly_1h_current", "pullback_15m_confirm"], ensure_ascii=False), + signal_labels_json=json.dumps(["1H 量价齐飞K(量3.7x)", "15min 回踩确认"], ensure_ascii=False), + is_meme=0, + status="active", + current_price=80.0, + max_price=160.0, + min_price=70.0, + pnl_pct=-20.0, + max_pnl_pct=60.0, + max_drawdown_pct=-30.0, + hit_tp1_time="", + hit_tp2_time="", + stopped_out_time="", + expired_time="", + last_track_time="2026-05-13T00:00:00", + entry_plan_json=json.dumps({"entry_action": "可即刻买入", "entry_price": 100.0}, ensure_ascii=False), + action_status="可即刻买入", + execution_status="buy_now", + display_bucket="realtime", + lifecycle_state="buyable", + entry_triggered=1, + direction="多头启动", + ) + defaults.update(kwargs) + conn = sqlite3.connect(db_path) + cols = ",".join(defaults.keys()) + placeholders = ",".join(["?"] * len(defaults)) + cur = conn.execute(f"INSERT INTO recommendation ({cols}) VALUES ({placeholders})", tuple(defaults.values())) + conn.commit() + conn.close() + return cur.lastrowid + + +def test_reviewable_recommendations_exclude_untriggered_watch_pool(temp_review_env): + db_path = str(temp_review_env) + _insert_rec( + db_path, + symbol="WAIT/USDT", + action_status="等回踩", + execution_status="wait_pullback", + display_bucket="watch_pool", + entry_triggered=0, + ) + _insert_rec(db_path, symbol="BUY/USDT") + + rows = review_engine._get_reviewable_recommendations(datetime.fromisoformat("2026-05-12T01:00:00")) + + assert [row["symbol"] for row in rows] == ["BUY/USDT"] + + +def test_review_uses_48h_price_tracking_window_not_current_price(temp_review_env): + db_path = str(temp_review_env) + rec_id = _insert_rec(db_path) + conn = sqlite3.connect(db_path) + for hours, price in [(1, 101.0), (5, 106.0), (47, 103.0), (60, 150.0)]: + ts = datetime.fromisoformat("2026-05-10T00:00:00") + timedelta(hours=hours) + conn.execute( + "INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct) VALUES (?, 'REV/USDT', ?, ?, 0)", + (rec_id, ts.isoformat(), price), + ) + conn.commit() + conn.row_factory = sqlite3.Row + rec = dict(conn.execute("SELECT * FROM recommendation WHERE id=?", (rec_id,)).fetchone()) + conn.close() + + review = review_engine.review_recommendation(rec) + + assert review["outcome"] == "爆发" + assert review["pnl_48h"] == 3.0 + assert review["max_pnl_48h"] == 6.0 + assert review["window_metrics"]["source"] == "price_tracking" + + +def test_review_updates_signal_performance_by_code_not_label(temp_review_env): + db_path = str(temp_review_env) + rec_id = _insert_rec(db_path) + conn = sqlite3.connect(db_path) + conn.execute( + "INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct) VALUES (?, 'REV/USDT', '2026-05-10T01:00:00', 106.0, 6.0)", + (rec_id,), + ) + conn.commit() + conn.row_factory = sqlite3.Row + rec = dict(conn.execute("SELECT * FROM recommendation WHERE id=?", (rec_id,)).fetchone()) + conn.close() + + review_engine.review_recommendation(rec) + + conn = sqlite3.connect(db_path) + rows = conn.execute("SELECT signal_type,total_count,hit_count FROM signal_performance ORDER BY signal_type").fetchall() + conn.close() + stats = {row[0]: (row[1], row[2]) for row in rows} + assert stats["vp_fly_1h_current"] == (1, 1) + assert "1H 量价齐飞K(量3.7x)" not in stats + + +def test_review_without_tracking_is_not_counted_as_signal_performance(temp_review_env): + db_path = str(temp_review_env) + rec_id = _insert_rec(db_path) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + rec = dict(conn.execute("SELECT * FROM recommendation WHERE id=?", (rec_id,)).fetchone()) + conn.close() + + review = review_engine.review_recommendation(rec) + + assert review["outcome"] == "样本不足" + assert review["skipped"] is True + assert review["window_metrics"]["source"] == "insufficient_tracking" + + conn = sqlite3.connect(db_path) + rows = conn.execute("SELECT signal_type,total_count FROM signal_performance").fetchall() + review_rows = conn.execute("SELECT outcome, lesson FROM review_log WHERE rec_id=?", (rec_id,)).fetchall() + conn.close() + assert rows == [] + assert review_rows[0][0] == "样本不足" + assert "不进入推荐绩效/信号权重" in review_rows[0][1] + + +def test_run_review_does_not_double_upsert_reverse_rules(monkeypatch, temp_review_env): + calls = [] + monkeypatch.setattr(review_engine, "_get_reviewable_recommendations", lambda now=None: []) + monkeypatch.setattr(review_engine, "adjust_signal_weights", lambda: []) + monkeypatch.setattr(review_engine, "_deprecate_low_performance_signals", lambda: []) + monkeypatch.setattr(review_engine, "scan_missed_explosions", lambda: []) + monkeypatch.setattr(review_engine, "_extract_rules_from_review", lambda: []) + monkeypatch.setattr(review_engine, "get_signal_weights", lambda: {}) + monkeypatch.setattr(review_engine, "_compute_effect_summary", lambda now, lookback_days=7: {"hit_rate_pct": 0, "avg_pnl": 0}) + monkeypatch.setattr(review_engine, "_scan_stable_fiat_pollution", lambda now, lookback_days=7: {}) + monkeypatch.setattr(review_engine, "_build_dual_attribution", lambda results, effect_summary: { + "success_analysis": {"sample_count": 0, "top_success_factors": []}, + "failure_analysis": {"sample_count": 0, "failure_types": []}, + "candidate_rules": [], + "release_decision": "hold", + "release_reason": "样本不足", + "confidence_level": "low", + "promotion_state": "research_only", + }) + monkeypatch.setattr(review_engine, "refresh_strategy_candidate_performance", lambda: []) + monkeypatch.setattr(review_engine, "_release_candidate_rules_if_ready", lambda dual, effect: { + "released": False, + "release_decision": "hold", + "release_reason": "样本不足", + "released_rules": [], + "new_version": "", + }) + monkeypatch.setattr(review_engine.reverse_analysis, "run_reverse_analysis", lambda: { + "new_rules": [{"candidate_id": 77, "description": "逆向候选", "type": "bonus", "conditions": {"has_ignition_point": True}}], + }) + monkeypatch.setattr(review_engine, "upsert_strategy_rule_candidate", lambda **kwargs: calls.append(kwargs) or 88) + + result = review_engine.run_review(push_enabled=False) + + assert result["reverse_analysis"]["new_rules"][0]["candidate_id"] == 77 + assert calls == [] diff --git a/tests/test_screener_optimizations.py b/tests/test_screener_optimizations.py index 27cc631..a781a83 100644 --- a/tests/test_screener_optimizations.py +++ b/tests/test_screener_optimizations.py @@ -162,7 +162,6 @@ def test_static_accumulation_bypass_promotes_expired_to_accumulate(monkeypatch): ) monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None) monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: None) - monkeypatch.setattr(altcoin_screener, "create_recommendation", lambda **kwargs: 456) monkeypatch.setattr(altcoin_screener, "get_sector_for_coin", lambda symbol: []) monkeypatch.setattr(altcoin_screener, "dynamic_leader_detection", lambda perf: {}) monkeypatch.setattr(altcoin_screener, "SECTOR_MEMBERS", {}) @@ -214,8 +213,6 @@ def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch): ) monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None) monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: None) - created = [] - monkeypatch.setattr(altcoin_screener, "create_recommendation", lambda **kwargs: created.append(kwargs) or 789) monkeypatch.setattr(altcoin_screener, "get_sector_for_coin", lambda symbol: []) monkeypatch.setattr(altcoin_screener, "dynamic_leader_detection", lambda perf: {}) monkeypatch.setattr(altcoin_screener, "SECTOR_MEMBERS", {}) @@ -238,4 +235,5 @@ def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch): assert qualified["PNT/USDT"]["base_state"] == "蓄力" assert qualified["PNT/USDT"]["force_reason"] == "强静K蓄力直升加速" assert any("强静K蓄力直升加速" in s for s in qualified["PNT/USDT"]["signals"]) - assert created and created[0]["force_reason"] == "强静K蓄力直升加速" + assert qualified["PNT/USDT"]["candidate_stage"] == "confirm_pending" + assert "rec_id" not in qualified["PNT/USDT"] diff --git a/tests/test_strategy_revision_marker.py b/tests/test_strategy_revision_marker.py index 39b63e7..13e9b6d 100644 --- a/tests/test_strategy_revision_marker.py +++ b/tests/test_strategy_revision_marker.py @@ -62,8 +62,9 @@ def _insert_recommendation(conn, rec_id, symbol, rec_time): stop_loss, tp1, tp2, sector, signals, is_meme, status, current_price, max_price, min_price, pnl_pct, max_pnl_pct, max_drawdown_pct, hit_tp1_time, hit_tp2_time, stopped_out_time, - expired_time, last_track_time, entry_plan_json, action_status, direction - ) VALUES (?, ?, ?, '加速', 8, 1.0, 0, 0, 0, '', '[]', 0, 'active', 1.0, 1.0, 1.0, 0, 0, 0, '', '', '', '', ?, '{}', '持有', '多头启动') + expired_time, last_track_time, entry_plan_json, action_status, + execution_status, display_bucket, lifecycle_state, entry_triggered, direction + ) VALUES (?, ?, ?, '加速', 8, 1.0, 0, 0, 0, '', '[]', 0, 'active', 1.0, 1.0, 1.0, 0, 0, 0, '', '', '', '', ?, '{"entry_action":"可即刻买入","entry_price":1.0}', '可即刻买入', 'buy_now', 'realtime', 'buyable', 1, '多头启动') """, (rec_id, symbol, rec_time, rec_time), )