This commit is contained in:
aaron 2026-05-14 01:20:47 +08:00
parent 0fe1b4878e
commit 1390ae1e9f
20 changed files with 1232 additions and 337 deletions

View File

@ -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,

View File

@ -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()]

View File

@ -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 "{}",

View File

@ -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",

View File

@ -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:

View File

@ -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

View File

@ -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"):

View File

@ -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)

View File

@ -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

View File

@ -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主题扩散

View File

@ -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)}
</style>
{% 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 =
'<div class="stats-main">' +
'<div class="'+allCls+'" onclick="setFilter(\'\')"><span class="dot all"></span><span>全部机会</span><span class="val">'+total+'</span></div>' +
'<div class="'+allCls+'" onclick="setFilter(\'\')"><span class="dot all"></span><span>全部候选</span><span class="val">'+total+'</span></div>' +
'<div class="'+bCls+'" onclick="setFilter(\'buy_now\')"><span class="dot buy"></span><span>入场窗口</span><span class="val">'+buy+'</span></div>' +
'<div class="'+oCls+'" onclick="setFilter(\'observe\')"><span class="dot obs"></span><span>重点观察</span><span class="val">'+observeStrong+'</span></div>' +
'<div class="'+wCls+'" onclick="setFilter(\'weak_observe\')"><span class="dot weak"></span><span>弱观察</span><span class="val">'+observeWeak+'</span></div>' +
@ -598,7 +623,7 @@ function renderLiveCards(data, weakCount) {
var items = Array.isArray(data) ? data : [];
if (!items.length) {
var weakOnly = weakCount ? '<div class="weak-summary"><span>当前只有 '+weakCount+' 个弱观察候选,已默认收起,避免干扰主机会流。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '';
$('liveCards').innerHTML = weakOnly || '<div class="empty-state"><p>暂无实时看板信号<br>系统持续扫描中,有机会会实时更新</p></div>'; return;
$('liveCards').innerHTML = weakOnly || '<div class="empty-state"><p>暂无实时推荐或观察候选<br>系统持续扫描中,有机会会实时更新</p></div>'; 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 ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
var entryPlanHtml = '<div class="entry-plan">' +
'<div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">机会所处阶段</span></div>'+
'<div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">触发/计划价</span></div>'+
'<div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">跌破则逻辑失效</span></div>'+
'<div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">参考位 '+fmtP(spaceRef)+'</span></div>'+
'</div>';
var entryPlanHtml = '';
if (isTradePlan) {
entryPlanHtml = '<div class="entry-plan">' +
'<div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">机会所处阶段</span></div>'+
'<div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">触发/计划价</span></div>'+
'<div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">跌破则逻辑失效</span></div>'+
'<div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">参考位 '+fmtP(spaceRef)+'</span></div>'+
'</div>';
} else {
entryPlanHtml = '<div class="entry-plan">' +
'<div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">观察池候选</span></div>'+
'<div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div>'+
'<div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">需15m/1H当前信号</span></div>'+
'<div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div>'+
'</div>';
}
var triggerCauseHtml = triggerCause ? '<div class="trigger-cause"><span class="tc-label">'+(hasQualityGate?'观察原因':'触发依据')+'</span><span class="tc-value">'+(hasQualityGate ? cleanDisplayText(r.observe_reason || triggerCause).slice(0,96) : triggerCause.slice(0,80))+'</span></div>' : '';
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 = '<div class="trigger-meta '+tCls+'"><span>'+cleanDisplayText(tLabel).slice(0,32)+'</span>'+(sub?'<small>'+cleanDisplayText(sub).slice(0,72)+'</small>':'')+'</div>';
}
var contextHtml = (triggerCauseHtml || triggerBadgeHtml) ? '<div class="signal-context">'+triggerCauseHtml+triggerBadgeHtml+'</div>' : '';
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div>'+
'<div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+
'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+
(isWeakObserve ? weakNoteHtml : entryPlanHtml)+
(!isWeakObserve && trustHtml?'<div class="trust-row">'+trustHtml+'</div>':'')+
triggerCauseHtml+
triggerBadgeHtml+
contextHtml+
(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+
'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
}

View File

@ -173,17 +173,19 @@ function renderUserReport(d){
'<div class="report-card"><div class="report-title">最近最该关注的错误</div><div class="report-text">系统不只看成功因子,也会记录反复导致失败的原因,避免下一轮继续犯同样的错。</div><div class="report-list">'+failHtml+'</div></div>';
}
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 '<div class="kpi"><div class="label">'+k[0]+'</div><div class="value">'+k[1]+'</div><div class="note">'+k[2]+'</div></div>';}).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='<div class="gate-head"><div class="gate-title">本轮是否发布</div>'+badge(latest)+'</div><div class="gate-text"><b>干净样本起点:</b>'+esc(ds.clean_started_at||dry.clean_started_at||'未设置')+';样本窗口:'+esc(ds.sample_window||dry.sample_window||'all_history')+'。旧污染样本只作解释,不会直接改变线上策略。</div><div class="gate-text"><b>最近发布原因:</b>'+esc(ov.latest_release_reason||'暂无发布决策说明')+'</div><div class="gate-text"><b>预演结论:</b>'+esc(dry.release_reason||ds.release_reason||'只读评估,不写库、不升版')+'</div><div class="gate-grid"><div class="gate-mini"><span>干净复盘样本</span><b>'+esc(ds.review_sample_count||dry.review_sample_count||0)+'</b></div><div class="gate-mini"><span>污染历史候选</span><b>'+esc(ds.dirty_history_candidate_count||dry.dirty_history_candidate_count||0)+'</b></div><div class="gate-mini"><span>可灰度</span><b>'+esc(ds.gray_ready_count||dry.gray_ready_count||0)+'</b></div><div class="gate-mini"><span>是否发布</span><b>'+(dry.would_bump_version?'是':'否')+'</b></div></div>'; }
function renderTimeline(items){ if(!items.length){$('timeline').innerHTML='<div class="empty">暂无迭代记录</div>';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 '<div class="iter'+cls+'" onclick="this.classList.toggle(\'open\')"><div class="iter-head"><span class="ver">'+esc(it.strategy_version||'--')+'</span><span class="title">'+esc(it.title||'复盘迭代')+'</span>'+badge(decision)+'<span class="time">'+fmtTime(it.created_at)+'</span></div><div class="metrics"><span class="metric">爆发 <b>'+(metrics.hit_count||0)+'</b></span><span class="metric">横盘 <b>'+(metrics.flat_count||0)+'</b></span><span class="metric">失败 <b>'+(metrics.fail_count||0)+'</b></span><span class="metric">候选 <b>'+((it.candidate_rules||[]).length)+'</b></span><span class="metric">置信 <b>'+esc(it.confidence_level||'--')+'</b></span></div><div class="summary">'+esc((it.release_reason||it.version_change_summary||it.summary||'').slice(0,280))+'</div><div class="detail">'+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')+'</div></div>'; }).join(''); }
function renderTimeline(items){ if(!items.length){$('timeline').innerHTML='<div class="empty">暂无迭代记录</div>';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 '<div class="iter'+cls+'" onclick="this.classList.toggle(\'open\')"><div class="iter-head"><span class="ver">'+esc(it.strategy_version||'--')+'</span><span class="title">'+esc(it.title||'复盘迭代')+'</span>'+badge(decision)+'<span class="time">'+fmtTime(it.created_at)+'</span></div><div class="metrics"><span class="metric">有效 <b>'+(metrics.effective_review_count!=null?metrics.effective_review_count:((metrics.hit_count||0)+(metrics.flat_count||0)+(metrics.fail_count||0)))+'</b></span><span class="metric">爆发 <b>'+(metrics.hit_count||0)+'</b></span><span class="metric">横盘 <b>'+(metrics.flat_count||0)+'</b></span><span class="metric">失败 <b>'+(metrics.fail_count||0)+'</b></span><span class="metric">样本不足 <b>'+(metrics.insufficient_tracking_count||0)+'</b></span><span class="metric">候选 <b>'+((it.candidate_rules||[]).length)+'</b></span><span class="metric">置信 <b>'+esc(it.confidence_level||'--')+'</b></span></div><div class="summary">'+esc((it.release_reason||it.version_change_summary||it.summary||'').slice(0,280))+'</div><div class="detail">'+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')+'</div></div>'; }).join(''); }
function renderSection(label,items,cls){ if(!items||!items.length)return ''; return '<div class="section"><div class="section-label">'+label+'</div>'+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 '<div class="item '+cls+'">'+esc(t+c)+'</div>'; }).join('')+'</div>'; }
function renderCandidateMini(items){ if(!items.length)return ''; return '<div class="section"><div class="section-label">本轮候选规则</div>'+items.slice(0,8).map(function(x){return '<div class="item">'+esc(x.description||x.signal||'候选规则')+' · 置信 '+esc(x.confidence_score||0)+' · 样本 '+esc(x.sample_size||0)+' · '+esc(x.status||'candidate')+'</div>';}).join('')+'</div>'; }
function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTML='<div class="empty">暂无待验证规律</div>';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML='<table class="table"><thead><tr><th>当前阶段</th><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>为什么还没发布</th></tr></thead><tbody>'+items.map(function(c){var d=dryMap[c.id]||{};return '<tr><td>'+badge(c.status||'candidate')+'</td><td>'+badge(d.dry_run_status||c.status||'candidate')+'</td><td class="rule-name">'+esc(c.rule_description||c.signal_name||'--')+'</td><td>'+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+'</td><td>'+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+'</td><td class="score">'+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+'</td><td>'+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+'</td><td class="reason">'+esc(d.gate_reason||'等待样本验证')+'</td></tr>';}).join('')+'</tbody></table>'; }
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='<div class="empty">暂无待验证规律</div>';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML='<table class="table"><thead><tr><th>来源</th><th>当前阶段</th><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>为什么还没发布</th></tr></thead><tbody>'+items.map(function(c){var d=dryMap[c.id]||{};return '<tr><td><span class="tag">'+esc(sourceLabel(c))+'</span></td><td>'+badge(c.status||'candidate')+'</td><td>'+badge(d.dry_run_status||c.status||'candidate')+'</td><td class="rule-name">'+esc(c.rule_description||c.signal_name||'--')+'</td><td>'+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+'</td><td>'+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+'</td><td class="score">'+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+'</td><td>'+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+'</td><td class="reason">'+esc(d.gate_reason||'等待样本验证')+'</td></tr>';}).join('')+'</tbody></table>'; }
function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='<div class="empty">暂无待验证规律可评估</div>';return;} $('dryrun').innerHTML='<div class="gate-text">当前版本 '+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?'是':'否')+'。</div><div class="gate-text"><b>灰度标准:</b>'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'</div><table class="table"><thead><tr><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>原因</th></tr></thead><tbody>'+items.map(function(x){return '<tr><td>'+badge(x.dry_run_status||'candidate')+'</td><td class="rule-name">'+esc(x.rule_description||x.signal_name||'--')+'</td><td>'+esc(x.sample_size||0)+'</td><td>'+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+'</td><td class="score">'+esc(x.confidence_score||0)+'</td><td>'+esc(x.avg_pnl||0)+'</td><td class="reason">'+esc(x.gate_reason||'--')+'</td></tr>';}).join('')+'</tbody></table>'; }
function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return '<span class="failure-chip">'+esc(f.type)+' · '+esc(f.count)+'</span>';}).join(''):'<div class="empty">暂无失败模式</div>'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '<div class="item warn"><b>'+esc(f.symbol||'--')+'</b> · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · PnL '+esc(f.pnl_pct||0)+'</div>';}).join(''):'<div class="empty">暂无失败样本</div>'; }
function renderVersions(items){ if(!items.length){$('versions').innerHTML='<div class="empty">暂无版本表现</div>';return;} $('versions').innerHTML='<table class="table"><thead><tr><th>版本</th><th>推荐数</th><th>成功</th><th>失败</th><th>待观察</th><th>成功率</th><th>均值收益</th></tr></thead><tbody>'+items.map(function(v){return '<tr><td class="rule-name">'+esc(v.strategy_version)+'</td><td>'+esc(v.recommendation_count)+'</td><td>'+esc(v.success_count)+'</td><td>'+esc(v.failed_count)+'</td><td>'+esc(v.pending_count)+'</td><td class="score">'+esc(v.success_rate_pct)+'</td><td>'+esc(v.avg_pnl_pct)+'</td></tr>';}).join('')+'</tbody></table>'; }

View File

@ -12,7 +12,7 @@
{% endblock %}
{% block extra_head_css %}
<style>
.shell{width:min(100% - 40px,1180px);margin:0 auto;padding:24px 0 48px}.page-head{margin-bottom:20px}.page-head h1{font-size:28px;letter-spacing:-.8px}.page-head p{color:var(--stone);font-size:14px;margin-top:4px}.metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:12px}.metric{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;text-align:center}.metric .num{font-size:30px;font-weight:900;letter-spacing:-.8px}.metric .lbl{font-size:12px;color:var(--stone);font-weight:700;margin-top:4px}.disclaimer{font-size:12px;color:var(--stone);background:var(--surface);border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);padding:10px 14px;margin-bottom:16px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;margin-bottom:16px}.panel h2{font-size:16px;margin-bottom:12px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:9px 8px;border-bottom:1px solid var(--hairline-soft);font-size:12px;text-align:left}.table th{color:var(--stone);font-weight:800}.pos{color:var(--green);font-weight:800}.neg{color:var(--red);font-weight:800}.tag{display:inline-flex;border-radius:var(--radius-full);background:var(--surface);padding:3px 8px;font-weight:800}.empty{color:var(--stone);font-size:13px;padding:16px;background:var(--surface);border-radius:var(--radius-lg)}@media(max-width:980px){.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:820px){.grid{grid-template-columns:1fr}.shell{width:min(100% - 24px,1180px)}}
.shell{width:min(100% - 40px,1180px);margin:0 auto;padding:24px 0 48px}.page-head{margin-bottom:20px}.page-head h1{font-size:28px;letter-spacing:-.8px}.page-head p{color:var(--stone);font-size:14px;margin-top:4px}.metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:12px}.metric{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;text-align:center}.metric .num{font-size:30px;font-weight:900;letter-spacing:-.8px}.metric .lbl{font-size:12px;color:var(--stone);font-weight:700;margin-top:4px}.disclaimer{font-size:12px;color:var(--stone);background:var(--surface);border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);padding:10px 14px;margin-bottom:16px}.flow{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}.flow-step{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-lg);padding:12px}.flow-step b{display:block;color:var(--ink);font-size:13px;margin-bottom:5px}.flow-step span{display:block;color:var(--stone);font-size:12px;line-height:1.55}.flow-link{color:var(--primary);font-weight:900;text-decoration:none}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;margin-bottom:16px}.panel h2{font-size:16px;margin-bottom:12px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:9px 8px;border-bottom:1px solid var(--hairline-soft);font-size:12px;text-align:left}.table th{color:var(--stone);font-weight:800}.pos{color:var(--green);font-weight:800}.neg{color:var(--red);font-weight:800}.tag{display:inline-flex;border-radius:var(--radius-full);background:var(--surface);padding:3px 8px;font-weight:800}.empty{color:var(--stone);font-size:13px;padding:16px;background:var(--surface);border-radius:var(--radius-lg)}@media(max-width:980px){.metrics{grid-template-columns:repeat(2,1fr)}.flow{grid-template-columns:repeat(2,1fr)}}@media(max-width:820px){.grid{grid-template-columns:1fr}.shell{width:min(100% - 24px,1180px)}}@media(max-width:520px){.flow{grid-template-columns:1fr}}
</style>
{% endblock %}
{% block content %}
@ -20,6 +20,12 @@
<div class="page-head"><h1>策略</h1><p>系统可信度、版本表现、因子归因与市场环境归因。</p></div>
<div class="metrics" id="metrics"></div>
<div class="disclaimer">数据基于历史信号跟踪,仅用于策略研究与模型评估,不构成收益承诺或投资建议。</div>
<div class="flow">
<div class="flow-step"><b>发现</b><span>粗筛只进观察/候选,不直接计交易绩效。</span></div>
<div class="flow-step"><b>确认</b><span>只有新鲜触发和入场计划达标才进入可交易口径。</span></div>
<div class="flow-step"><b>复盘</b><span>只用48h窗口样本更新有效表现。</span></div>
<div class="flow-step"><b>迭代</b><span>候选规则经发布闸门后才生效。<a class="flow-link" href="/iteration">查看迭代</a></span></div>
</div>
<div class="grid">
<section class="panel"><h2>版本表现</h2><div id="versionPerf"></div></section>
<section class="panel"><h2>市场环境归因</h2><div id="envPerf"></div></section>

View File

@ -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]})

View File

@ -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%)"]

View File

@ -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()

View File

@ -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

View File

@ -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 == []

View File

@ -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"]

View File

@ -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),
)