""" 山寨币监控 — 数据库层 全量记录筛选结果 + 价格跟踪 + 盈亏验证 """ import json import re from datetime import datetime, timedelta from app.config.config_loader import get_meta, get_screener_section, confirm_state_cooldown_hours from app.core.opportunity_lifecycle import ( apply_entry_quality_gate, normalize_json_object, derive_display_bucket, normalize_action_status, is_executed_lifecycle, ) from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels from app.db.postgres_connection import apply_migrations, connect as pg_connect, table_columns def get_conn(): return pg_connect() def init_db(): apply_migrations() print("PostgreSQL schema migrations checked") # === 推送去重 === PUSH_COOLDOWN_HOURS = 12 def should_push(symbol: str, push_type: str, action_status: str = "") -> bool: """检查是否可以推送: - 如果提供了 action_status:同状态+同type冷却期内已推过 → False,状态变了无条件放行 - 如果未提供 action_status(如burst):同type冷却期内任何推过 → False """ conn = get_conn() cutoff = (datetime.now() - timedelta(hours=PUSH_COOLDOWN_HOURS)).isoformat() if action_status: # 状态感知:只拦同状态重复推,状态变了永远放行 row = conn.execute( "SELECT action_status FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", (symbol, push_type, cutoff), ).fetchone() conn.close() if row is None: return True # 冷却期内没推过,放行 last_status = row[0] return last_status != action_status # 状态变了放行,相同则冷却 else: # 无状态(burst):同type任何推过就冷却 row = conn.execute( "SELECT id FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", (symbol, push_type, cutoff), ).fetchone() conn.close() return row is None def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int = 0): """记录一次推送。rec_id 可选,作为主链路推荐记录的可追溯来源。""" conn = get_conn() try: conn.execute( "INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (%s,%s,%s,%s,%s)", (symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()), ) conn.commit() finally: conn.close() def get_recommendation_for_push(rec_id: int): """读取单条推荐并派生网站同口径展示状态,供推送层消费。 飞书/其他通知渠道只能消费这个主链路派生结果,不能自行基于事件或 entry_plan 做推荐判断。 """ try: rec_id = int(rec_id or 0) except Exception: rec_id = 0 if rec_id <= 0: return None conn = get_conn() row = conn.execute(""" SELECT r.*, lpc.price AS latest_cache_price, lpc.updated_at AS latest_cache_updated_at FROM recommendation r LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol WHERE r.id=%s """, (rec_id,)).fetchone() conn.close() if not row: return None item = dict(row) rec_result, rec_result_label = _classify_recommendation_result(item) item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label return _derive_execution_fields(item) # ==================== 筛选记录 ==================== def log_screening(layer, symbol, state, score, price, signals, sector="", leader_status="", is_meme=0, change_24h=0, funding_rate=0, detail=None): """记录一次筛选结果""" conn = get_conn() conn.execute(""" INSERT INTO screening_log (scan_time, layer, symbol, state, score, price, signals, sector, leader_status, is_meme, change_24h, funding_rate, detail_json) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( datetime.now().isoformat(), layer, symbol, state, score, price, json.dumps(signals, ensure_ascii=False) if isinstance(signals, list) else signals, sector, leader_status, is_meme, change_24h, funding_rate, json.dumps(detail, ensure_ascii=False) if detail else "{}", )) conn.commit() conn.close() # ==================== 推荐记录 ==================== def _state_fields_for_storage(status, action_status, execution_status="", reason=""): bucket = derive_display_bucket(status or "active", action_status, execution_status) return ( bucket.get("execution_status", execution_status or "observe"), bucket.get("display_bucket", "watch_pool"), bucket.get("lifecycle_state", "watching"), 1 if is_executed_lifecycle(status or "active", action_status, bucket.get("execution_status")) else 0, reason or "", ) def _derive_minimal_state_fields(status, action_status, entry_plan=None): action = normalize_action_status(action_status, status) if action == "可即刻买入": execution_status = "buy_now" reason = "主链路确认当前入场窗口" elif action == "等回踩": execution_status = "wait_pullback" reason = "等待回踩触发,未触发前不计推荐收益" elif action == "持有": execution_status = "holding" reason = "已进入持仓跟踪" elif action in ("止盈1", "止盈2", "跟踪止盈"): execution_status = "completed" reason = "利润管理/阶段兑现" elif action in ("止损", "衰减", "反转", "放弃", "过期", "归档") or status in ("stopped_out", "expired", "invalid", "archived"): execution_status = "invalid" reason = "机会失效,归入历史复盘" else: execution_status = "observe" 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="中性", force_reason="", base_state="", sector_signal_count=0, market_context=None, derivatives_context=None, sector_context=None): """创建推荐记录(加速/爆发时调用) direction: 多头启动/空头启动/中性 — 推荐的方向标签 注意:rec_score 入参为原始分(0~30范围),落库时转为百分制(0~100),分母 30。 """ # 原始分 → 百分制(分母 30,天花板 100) raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0 rec_score_pct = min(raw_pct, 100) strategy_version = str(get_meta().get("strategy_version") or "").strip() now = datetime.now().isoformat() conn = get_conn() incoming_action = normalize_action_status((entry_plan or {}).get("entry_action", "观察") if entry_plan else "观察", "active") 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( """ SELECT * FROM recommendation WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY id DESC LIMIT 1 """, (symbol,), ) duplicate_row = duplicate_cursor.fetchone() if hasattr(duplicate_cursor, "fetchone") else None if duplicate_row and (entry_plan or duplicate_row["rec_state"] == rec_state): # 同一币种已有当前主记录时更新该记录,不再制造多个 active。 # 无 entry_plan 的粗筛状态迁移仍允许保留演化轨迹。 existing_id = duplicate_row["id"] if hasattr(duplicate_row, "keys") else duplicate_row[0] existing_score = duplicate_row["rec_score"] or 0 merged_state = rec_state merged_score = max(existing_score, rec_score_pct) conn.execute(""" UPDATE recommendation SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector), signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s, force_reason=COALESCE(NULLIF(%s, ''), force_reason), base_state=COALESCE(NULLIF(%s, ''), base_state), sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s), entry_plan_json=CASE WHEN %s != '{}' THEN %s ELSE entry_plan_json END, market_context_json=%s, derivatives_context_json=%s, sector_context_json=%s, action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status ELSE COALESCE(NULLIF(%s, ''), action_status) END, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s """, ( merged_state, merged_score, sector, 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), json.dumps(entry_plan or {}, ensure_ascii=False), json.dumps(market_context or {}, ensure_ascii=False), json.dumps(derivatives_context or {}, ensure_ascii=False), json.dumps(sector_context or {}, ensure_ascii=False), incoming_action if entry_plan else "", incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason, existing_id, )) conn.commit() conn.close() return existing_id cursor = conn.execute(""" INSERT INTO recommendation (symbol, rec_time, rec_state, rec_score, entry_price, 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 (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( symbol, now, rec_state, rec_score_pct, entry_price, stop_loss, tp1, tp2, sector, 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 "{}", force_reason or "", base_state or "", int(sector_signal_count or 0), json.dumps(market_context or {}, ensure_ascii=False), json.dumps(derivatives_context or {}, ensure_ascii=False), json.dumps(sector_context or {}, ensure_ascii=False), incoming_action, incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason, strategy_version, )) rec_id = cursor.fetchone()["id"] conn.commit() conn.close() return rec_id def update_recommendation_tracking(rec_id, current_price): """更新推荐记录的跟踪价格和盈亏。 v1.7.9+: TP1 只代表阶段兑现/启动跟踪止盈,不再把记录移出 active; 这样 TP1 后继续推高的收益会继续计入 current/max_pnl。 """ conn = get_conn() row = conn.execute("SELECT entry_price, max_price, min_price, symbol FROM recommendation WHERE id=%s", (rec_id,)).fetchone() if not row: conn.close() return entry_price = row["entry_price"] old_max = row["max_price"] or entry_price old_min = row["min_price"] or entry_price new_max = max(old_max, current_price) new_min = min(old_min, current_price) pnl_pct = round((current_price / entry_price - 1) * 100, 2) max_pnl_pct = round((new_max / entry_price - 1) * 100, 2) max_drawdown_pct = round((new_min / entry_price - 1) * 100, 2) status = "active" tp1_reached = False rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=%s", (rec_id,)).fetchone() if rec and rec["status"] == "active": if rec["tp2"] and current_price >= rec["tp2"]: status = "hit_tp2" elif rec["stop_loss"] and current_price <= rec["stop_loss"]: status = "stopped_out" elif rec["tp1"] and current_price >= rec["tp1"]: status = "hit_tp1" tp1_reached = True elif rec["tp1"] == 0 and pnl_pct >= 15: status = "hit_tp1" tp1_reached = True now = datetime.now().isoformat() if status != "active": action_for_status = {"hit_tp1": "止盈1", "hit_tp2": "止盈2", "stopped_out": "止损"}.get(status, "持有") execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(status, action_for_status, {}) conn.execute(""" UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s, pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, status=%s, action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, last_track_time=%s, hit_tp1_time=CASE WHEN %s='hit_tp1' THEN %s ELSE hit_tp1_time END, hit_tp2_time=CASE WHEN %s='hit_tp2' THEN %s ELSE hit_tp2_time END, stopped_out_time=CASE WHEN %s='stopped_out' THEN %s ELSE stopped_out_time END WHERE id=%s """, (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, status, action_for_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, now, status, now, status, now, status, now, rec_id)) else: conn.execute(""" UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s, pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, last_track_time=%s, hit_tp1_time=CASE WHEN %s=1 THEN COALESCE(NULLIF(hit_tp1_time,''), %s) ELSE hit_tp1_time END WHERE id=%s """, (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, now, 1 if tp1_reached else 0, now, rec_id)) symbol = row["symbol"] update_latest_price_cache(symbol, current_price, updated_at=now, source="tracker", conn=conn) conn.execute(""" INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct) VALUES (%s, %s, %s, %s, %s) """, (rec_id, symbol, now, current_price, pnl_pct)) conn.commit() conn.close() return {"status": status, "tp1_reached": tp1_reached, "pnl_pct": pnl_pct, "max_pnl_pct": max_pnl_pct, "max_drawdown_pct": max_drawdown_pct} def expire_old_recommendations(hours=48): """超过48小时的active推荐标记为expired""" conn = get_conn() cutoff = (datetime.now() - timedelta(hours=float(hours or 48))).isoformat() execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields( "expired", "过期", {} ) conn.execute(""" UPDATE recommendation SET status='expired', action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈') THEN action_status ELSE '过期' END, expired_time=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE status='active' AND rec_time < %s """, (datetime.now().isoformat(), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, cutoff)) conn.commit() conn.close() def _entry_window_policy(entry_price, current_price, rec_time, event_time=None, window_hours=2.0, up_deviation_pct=1.5, down_deviation_pct=1.2): """阶段1:入场窗口可信度规则。 - 入场窗口默认有效 2 小时;超过后降级观察。 - 当前价向上脱离触发价 >1.5%:不追高,降级等回踩。 - 当前价向下跌破触发价 >1.2%:买点失效,降级观察。 """ event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") try: entry_price = float(entry_price or 0) current_price = float(current_price or 0) except Exception: entry_price = 0 current_price = 0 deviation_pct = round((current_price / entry_price - 1) * 100, 2) if entry_price and current_price else 0.0 age_minutes = 0.0 try: start = datetime.fromisoformat(str(rec_time)) end = datetime.fromisoformat(str(event_time)) age_minutes = round((end - start).total_seconds() / 60.0, 1) except Exception: age_minutes = 0.0 remaining_minutes = round(max(0.0, window_hours * 60.0 - age_minutes), 1) result = { "status": "active", "label": "入场窗口有效", "reason": "入场窗口仍在有效期内,价格未明显脱离触发价", "age_minutes": age_minutes, "remaining_minutes": remaining_minutes, "window_hours": window_hours, "entry_price": entry_price, "current_price": current_price, "deviation_pct": deviation_pct, "max_up_deviation_pct": up_deviation_pct, "max_down_deviation_pct": down_deviation_pct, } if age_minutes > window_hours * 60.0: result.update({ "status": "expired", "label": "窗口已过期", "reason": f"入场窗口超过有效期 {window_hours:g} 小时,避免沿用旧信号追入", "remaining_minutes": 0.0, }) elif deviation_pct > up_deviation_pct: result.update({ "status": "price_left_up", "label": "价格已上脱离", "reason": f"当前价较触发价上脱离 {deviation_pct:.2f}%,超过 {up_deviation_pct:g}% 阈值,避免追高", }) elif deviation_pct < -down_deviation_pct: result.update({ "status": "price_left_down", "label": "价格已下破", "reason": f"当前价较触发价下破 {abs(deviation_pct):.2f}%,买点动能失效,转观察", }) return result def _risk_suggestion(entry_price, stop_loss, tp1, risk_budget_pct=1.0, max_position_pct=100.0): """把入场价/止损转换成可执行仓位建议。""" try: entry_price = float(entry_price or 0) stop_loss = float(stop_loss or 0) tp1 = float(tp1 or 0) except Exception: entry_price = stop_loss = tp1 = 0 stop_distance_pct = round(abs(entry_price - stop_loss) / entry_price * 100, 2) if entry_price and stop_loss else 0.0 suggested_position_pct = round(min(max_position_pct, risk_budget_pct / stop_distance_pct * 100), 2) if stop_distance_pct else 0.0 tp1_profit_pct = round((tp1 / entry_price - 1) * 100, 2) if entry_price and tp1 else 0.0 rr = round(tp1_profit_pct / stop_distance_pct, 2) if stop_distance_pct else 0.0 max_loss_pct = round(suggested_position_pct * stop_distance_pct / 100, 2) if suggested_position_pct else 0.0 return { "risk_budget_pct": risk_budget_pct, "stop_distance_pct": stop_distance_pct, "suggested_position_pct": suggested_position_pct, "max_loss_pct": max_loss_pct, "tp1_profit_pct": tp1_profit_pct, "rr": rr, "max_position_pct": max_position_pct, "valid": bool(entry_price and stop_loss and stop_distance_pct > 0), } def update_latest_price_cache(symbol, price, updated_at=None, source="tracker", conn=None): """Upsert 最新行情缓存。看板读取这张小表,不再依赖 price_tracking 高频流水表。""" symbol = str(symbol or "").strip().upper() try: price = float(price or 0) except Exception: price = 0 if not symbol or price <= 0: return False updated_at = updated_at or datetime.now().isoformat() owns_conn = conn is None if owns_conn: conn = get_conn() conn.execute(""" INSERT INTO latest_price_cache (symbol, price, updated_at, source) VALUES (%s, %s, %s, %s) ON CONFLICT(symbol) DO UPDATE SET price=excluded.price, updated_at=excluded.updated_at, source=excluded.source """, (symbol, price, updated_at, source)) if owns_conn: conn.commit() conn.close() return True def get_latest_price_cache(symbols): """批量读取最新行情缓存,返回 {symbol: {price, updated_at, source}}。""" normalized = [] for sym in symbols or []: sym = str(sym or "").strip().upper() if sym and sym not in normalized: normalized.append(sym) if not normalized: return {} conn = get_conn() placeholders = ",".join(["%s"] * len(normalized)) rows = conn.execute( f"SELECT symbol, price, updated_at, source FROM latest_price_cache WHERE symbol IN ({placeholders})", tuple(normalized), ).fetchall() conn.close() return {row["symbol"]: dict(row) for row in rows} def _latest_tracking_price(rec_id, fallback=0): """旧兼容函数:不再读取 price_tracking,避免看板/API 依赖高频流水大表。 最新现价应来自 latest_price_cache;没有缓存时回退 recommendation.current_price。 """ return fallback or 0 def _execution_fields_from_persisted_state(item, entry_plan=None): """只基于DB主状态派生展示状态;不得用 entry_plan.initial_action 反向提升主状态。""" entry_plan = entry_plan if entry_plan is not None else _normalize_entry_plan(item.get("entry_plan_json")) status = (item.get("status") or "active").strip() action_status = normalize_action_status(item.get("action_status") or "持有", status) bucket = derive_display_bucket(status, action_status, "") lifecycle = bucket.get("lifecycle_state") execution_status = bucket.get("execution_status") if execution_status == "completed": return "completed", "✅ 已兑现,仅观察", f"该机会已进入{action_status or '利润管理'}阶段,仅作为持仓跟踪记录" if execution_status == "invalid": if action_status == "止损": reason = "该机会已触发风险边界,原入场逻辑失效" elif action_status == "衰减": reason = "该机会已出现趋势衰减,追高性价比下降" elif action_status == "反转": reason = "该机会已出现趋势反转,原多头逻辑被破坏" elif action_status == "放弃": reason = "该机会已被标记为放弃,不再满足入场条件" else: reason = "该机会观察周期结束或逻辑失效,已归入历史复盘" return "invalid", "🔴 已失效,勿追", reason if execution_status == "buy_now": stop = str(entry_plan.get("stop_loss", "")) if entry_plan else "" return "buy_now", "🟢 现在可买", "推荐时就是可即刻买入;主链路确认当前仍在入场窗口" + ((",风险边界 " + stop) if stop else "") if execution_status == "wait_pullback": gate = entry_plan.get("entry_quality_gate") or {} if gate.get("reasons"): reason = "等待更优位置;" + ";".join(gate.get("reasons", [])[:3]) else: reason = "等待回踩至 " + (str(entry_plan.get("entry_price", "")) if entry_plan else "参考价") + " 附近再评估" return "wait_pullback", "🟡 等回踩,不追高", reason if execution_status == "holding": return "holding", "持仓跟踪", "该机会已触发入场,进入持仓跟踪" gate = entry_plan.get("entry_quality_gate") or {} if gate.get("reasons"): reason = "机会结构仍在观察;" + ";".join(gate.get("reasons", [])[:3]) else: reason = "暂无明确入场窗口,继续观察" return "observe", "观察池", reason def apply_recommendation_state_transition(rec_id, requested_action, current_price, event_time=None, signals=None): """主链路状态迁移:唯一允许把价格事件落成 action_status 的入口。 返回值同时作为推送层 payload 来源;推送层不得再自行判定交易状态。 """ event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") conn = get_conn() row = conn.execute(""" SELECT * FROM recommendation WHERE id=%s """, (rec_id,)).fetchone() if not row: conn.close() return {"updated": False, "push_required": False, "reason": "not_found"} item = dict(row) previous_action = (item.get("action_status") or "持有").strip() entry_plan = _normalize_entry_plan(item.get("entry_plan_json")) terminal_map = {"hit_tp2": "止盈2", "stopped_out": "止损"} status = (item.get("status") or "active").strip() final_action = normalize_action_status(terminal_map.get(status, requested_action), status) if status not in terminal_map: final_action, entry_plan, gate_reasons = apply_entry_quality_gate( action_status=final_action, entry_plan=entry_plan, signals=signals if signals is not None else item.get("signals"), current_price=current_price, market_context=normalize_json_object(item.get("market_context_json")), derivatives_context=normalize_json_object(item.get("derivatives_context_json")), sector_context=normalize_json_object(item.get("sector_context_json")), ) else: gate_reasons = [] window_entry_price = item.get("entry_price") or current_price or 0 window_rec_time = item.get("rec_time") or event_time if final_action == "可即刻买入" and previous_action != "可即刻买入": window_entry_price = current_price window_rec_time = event_time entry_window = _entry_window_policy(window_entry_price, current_price, window_rec_time, event_time) if final_action == "可即刻买入" and previous_action == "可即刻买入": if entry_window["status"] == "expired": final_action = "观察" gate_reasons.append(entry_window["reason"]) elif entry_window["status"] == "price_left_up": final_action = "等回踩" gate_reasons.append(entry_window["reason"]) elif entry_window["status"] == "price_left_down": final_action = "观察" gate_reasons.append(entry_window["reason"]) should_reset_entry = final_action == "可即刻买入" and previous_action != "可即刻买入" if should_reset_entry: max_price = current_price min_price = current_price pnl_pct = 0.0 max_pnl_pct = 0.0 max_drawdown_pct = 0.0 rec_time = event_time entry_price = current_price else: old_entry = item.get("entry_price") or current_price or 0 old_max = item.get("max_price") or old_entry old_min = item.get("min_price") or old_entry max_price = max(old_max, current_price) if current_price else old_max min_price = min(old_min, current_price) if current_price else old_min entry_price = old_entry rec_time = item.get("rec_time") pnl_pct = round((current_price / old_entry - 1) * 100, 2) if old_entry and current_price else item.get("pnl_pct", 0) max_pnl_pct = round((max_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_pnl_pct", 0) max_drawdown_pct = round((min_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_drawdown_pct", 0) execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( {**item, "action_status": final_action, "status": status}, entry_plan ) execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage( status, final_action, execution_status, execution_reason ) push_required = final_action in ("可即刻买入", "跟踪止盈") and previous_action != final_action and execution_status in ("buy_now", "completed") conn.execute(""" UPDATE recommendation SET action_status=%s, entry_plan_json=%s, current_price=%s, max_price=%s, min_price=%s, pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, last_track_time=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, rec_time=CASE WHEN %s=1 THEN %s ELSE rec_time END, entry_price=CASE WHEN %s=1 THEN %s ELSE entry_price END WHERE id=%s """, ( final_action, json.dumps(entry_plan, ensure_ascii=False), current_price, max_price, min_price, pnl_pct, max_pnl_pct, max_drawdown_pct, event_time, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, 1 if should_reset_entry else 0, rec_time, 1 if should_reset_entry else 0, entry_price, rec_id, )) conn.commit() conn.close() return { "updated": True, "id": rec_id, "symbol": item.get("symbol"), "previous_action_status": previous_action, "action_status": final_action, "execution_status": execution_status, "execution_label": execution_label, "execution_reason": execution_reason, "display_bucket": display_bucket, "lifecycle_state": lifecycle_state, "entry_triggered": entry_triggered, "entry_price": entry_price, "current_price": current_price, "pnl_pct": pnl_pct, "stop_loss": item.get("stop_loss") or entry_plan.get("stop_loss") or 0, "tp1": item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, "tp2": item.get("tp2") or entry_plan.get("tp2") or 0, "entry_plan": entry_plan, "entry_window": entry_window, "risk_suggestion": _risk_suggestion( entry_price, item.get("stop_loss") or entry_plan.get("stop_loss") or 0, item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, ), "gate_reasons": gate_reasons, "push_required": push_required, "push_symbol": item.get("symbol"), "push_entry_price": entry_price, "push_current_price": current_price, "push_pnl_pct": pnl_pct, "push_signals": signals or [], } def recompute_all_recommendation_state_fields(conn=None): """回填统一状态机派生字段。只读 status/action_status,不改变历史交易价格。""" owns_conn = conn is None if owns_conn: conn = get_conn() rows = conn.execute("SELECT id,status,action_status,entry_plan_json FROM recommendation").fetchall() updated = 0 for row in rows: ep = _normalize_entry_plan(row["entry_plan_json"]) action = normalize_action_status(row["action_status"], row["status"]) execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( {"status": row["status"], "action_status": action, "entry_plan_json": row["entry_plan_json"]}, ep ) execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage( row["status"], action, execution_status, execution_reason ) conn.execute( """UPDATE recommendation SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s""", (action, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, row["id"]), ) updated += 1 if owns_conn: conn.commit() conn.close() return updated def update_recommendation_action_status(rec_id, action_status): """更新推荐记录的操作状态。 保护规则:如果推荐已经真实止盈/止损结案,不能再被后续动态入场逻辑覆盖成 “可即刻买入/持有/等回踩”。v1.7.5 进一步增加买点质量闸门: entry_plan 为等回踩、risk_reward_ok=false、rr过低或追高距离过远时,拒绝把 action_status 写成“可即刻买入”。 """ conn = get_conn() row = conn.execute(""" SELECT status, action_status, entry_plan_json, signals, current_price, market_context_json, derivatives_context_json, sector_context_json FROM recommendation WHERE id=%s """, (rec_id,)).fetchone() terminal_map = { "hit_tp1": "止盈1", "hit_tp2": "止盈2", "stopped_out": "止损", } entry_plan = {} if row: if row["status"] in terminal_map and action_status not in ("止盈1", "止盈2", "止损", "跟踪止盈"): action_status = terminal_map[row["status"]] else: entry_plan = _normalize_entry_plan(row["entry_plan_json"]) gated_action, gated_plan, _ = apply_entry_quality_gate( action_status=action_status, entry_plan=entry_plan, signals=row["signals"], current_price=row["current_price"] or 0, market_context=normalize_json_object(row["market_context_json"]), derivatives_context=normalize_json_object(row["derivatives_context_json"]), sector_context=normalize_json_object(row["sector_context_json"]), ) action_status = gated_action entry_plan = gated_plan if entry_plan: execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( {"status": row["status"] if row else "active", "action_status": action_status, "entry_plan_json": json.dumps(entry_plan, ensure_ascii=False)}, entry_plan, ) execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage( row["status"] if row else "active", action_status, execution_status, execution_reason ) conn.execute(""" UPDATE recommendation SET action_status=%s, entry_plan_json=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s """, (action_status, json.dumps(entry_plan, ensure_ascii=False), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id)) else: execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(row["status"] if row else "active", action_status, {}) conn.execute(""" UPDATE recommendation SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s """, (action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id)) conn.commit() conn.close() def update_entry_timing(rec_id: int, entry_price: float, rec_time: str): """更新入场到位的时间和价格。当tracker检测到可即刻买入时调用。""" conn = get_conn() conn.execute( "UPDATE recommendation SET rec_time=%s, entry_price=%s, current_price=%s, pnl_pct=0 WHERE id=%s", (rec_time, entry_price, entry_price, rec_id) ) conn.commit() conn.close() def _normalize_entry_plan(entry_plan_json): try: if isinstance(entry_plan_json, dict): return entry_plan_json if entry_plan_json: return json.loads(entry_plan_json) except Exception: pass return {} def _normalize_json_object(payload): try: if isinstance(payload, dict): return payload if payload: parsed = json.loads(payload) if isinstance(parsed, dict): return parsed except Exception: pass return {} def _normalize_signals(payload): """signals 字段从 SQLite TEXT 列读出是 JSON 字符串,必须解析为数组才能交给前端 JS .map()。""" try: if isinstance(payload, list): return payload if isinstance(payload, str) and payload.strip(): parsed = json.loads(payload) if isinstance(parsed, list): return parsed except Exception: pass return [] def _observe_tier(item): """观察池分层:strong=值得用户关注,weak=弱观察/低质量候选。""" status = str(item.get("execution_status") or "") if status in ("buy_now", "wait_pullback") or item.get("display_bucket") == "realtime": return "strong", "入场/等待类有效机会" try: score = float(item.get("rec_score") or 0) except Exception: score = 0 signals = item.get("signals") or [] if isinstance(signals, str): signals = _normalize_signals(signals) sig_text = " ".join(str(x) for x in signals) force_reason = str(item.get("force_reason") or "") derivatives = _normalize_json_object(item.get("derivatives_context_json") or item.get("derivatives_context")) market = _normalize_json_object(item.get("market_context_json") or item.get("market_context")) if not derivatives and isinstance(item.get("derivatives_context"), dict): derivatives = item.get("derivatives_context") or {} if not market and isinstance(item.get("market_context"), dict): market = item.get("market_context") or {} long_pct = 0.0 try: long_pct = float(derivatives.get("top_trader_long_pct") or 0) except Exception: long_pct = 0.0 acc1 = 0.0 acc4 = 0.0 try: acc1 = float(market.get("turnover_acceleration_1h") or 0) acc4 = float(market.get("turnover_acceleration_4h") or 0) except Exception: pass stale_only = ("已过期" in sig_text or "历史" in sig_text) and not any(k in sig_text for k in ("当前", "新近", "刚刚", "入场窗口", "量价齐飞")) weak_reasons = [] if score < 50: weak_reasons.append(f"评分偏低({int(score)})") if stale_only: weak_reasons.append("主要触发来自历史/过期信号") if "静K蓄力旁路" in force_reason and acc4 < 1.3 and acc1 < 1.3: weak_reasons.append("静K旁路量能不足") gate = {} try: ep = item.get("entry_plan") or _normalize_json_object(item.get("entry_plan_json")) gate = ep.get("entry_quality_gate") or {} except Exception: gate = {} gate_reasons = gate.get("reasons") or [] gate_reason_text = ";".join(str(x) for x in gate_reasons[:3]) if any("回踩参考已到" in str(x) and "不达标" in str(x) for x in gate_reasons): return "weak" if score < 55 else "strong", (gate_reason_text or "回踩参考已到,但实时盈亏比不达标") + ";暂不构成入场窗口,继续观察是否重新恢复可买盈亏比" strong_context = score >= 65 or long_pct >= 75 or max(acc1, acc4) >= 1.5 if weak_reasons and not strong_context: return "weak", ";".join(weak_reasons[:3]) if gate_reason_text: return "strong", gate_reason_text + ";继续观察结构是否恢复" return "strong", "观察池有效候选" def _derive_execution_fields(item): entry_plan = _normalize_entry_plan(item.get("entry_plan_json")) market_context = _normalize_json_object(item.get("market_context_json")) derivatives_context = _normalize_json_object(item.get("derivatives_context_json")) sector_context = _normalize_json_object(item.get("sector_context_json")) signals = _normalize_signals(item.get("signals")) item["signals"] = signals initial_action = normalize_action_status(entry_plan.get("entry_action") or item.get("action_status") or "持有", item.get("status") or "active") action_status = normalize_action_status(item.get("action_status") or initial_action or "持有", item.get("status") or "active") # 新建爆发推荐可能还没被 tracker 跑到,DB action_status 仍是默认“持有”。 # 此时以前端展示和实时看板过滤应以确认层写入的 entry_plan.entry_action 为准, # 但后续 tracker 一旦写入明确状态,仍以 DB 主状态优先。 if action_status == "持有" and initial_action in ("可即刻买入", "等回踩", "观察"): action_status = initial_action current_price_for_window = item.get("latest_cache_price") or item.get("current_price") or item.get("entry_price") or 0 action_status, entry_plan, _entry_gate_reasons = apply_entry_quality_gate( action_status=action_status, entry_plan=entry_plan, signals=item.get("signals"), current_price=current_price_for_window, market_context=market_context, derivatives_context=derivatives_context, sector_context=sector_context, ) if initial_action == "可即刻买入" and action_status != "可即刻买入": initial_action = action_status status = (item.get("status") or "active").strip() force_reason = (item.get("force_reason") or "").strip() base_state = (item.get("base_state") or "").strip() sector_signal_count = item.get("sector_signal_count") strategy_version = str(item.get("strategy_version") or "").strip() if not strategy_version: strategy_version = str(get_meta().get("strategy_version") or "").strip() if current_price_for_window: item["current_price"] = current_price_for_window try: entry_price_for_pnl = float(item.get("entry_price") or 0) current_price_float = float(current_price_for_window or 0) if entry_price_for_pnl > 0 and current_price_float > 0: item["pnl_pct"] = round((current_price_float - entry_price_for_pnl) / entry_price_for_pnl * 100, 2) except Exception: pass if item.get("latest_cache_updated_at"): item["current_price_updated_at"] = item.get("latest_cache_updated_at") entry_window = _entry_window_policy( item.get("entry_price") or entry_plan.get("entry_price") or 0, current_price_for_window, item.get("rec_time") or "", ) if action_status == "可即刻买入" else {} # 实时看板用 hours 参数过滤过期机会;派生层不再因为旧 rec_time 反向篡改主状态,避免展示/测试口径分裂。 item_for_execution = {**item, "action_status": action_status} execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(item_for_execution, entry_plan) bucket_fields = derive_display_bucket(status, action_status, execution_status) execution_status = bucket_fields.get("execution_status") or execution_status item["initial_action"] = initial_action item["action_status"] = normalize_action_status(action_status, status) item["execution_status"] = execution_status item["execution_label"] = execution_label item["execution_reason"] = execution_reason item["display_bucket"] = bucket_fields.get("display_bucket") item["lifecycle_state"] = bucket_fields.get("lifecycle_state") item["entry_triggered"] = 1 if is_executed_lifecycle(status, item["action_status"], execution_status) else 0 # 派生状态可能被买点质量闸门从“等回踩”降为“观察”,同步刷新展示桶,避免卡片仍停留在旧等待态。 bucket_fields = derive_display_bucket(status, item["action_status"], item["execution_status"]) item["execution_status"] = bucket_fields.get("execution_status") or item["execution_status"] item["display_bucket"] = bucket_fields.get("display_bucket") item["lifecycle_state"] = bucket_fields.get("lifecycle_state") observe_tier, observe_reason = _observe_tier(item) item["observe_tier"] = observe_tier item["observe_reason"] = observe_reason item["entry_plan"] = entry_plan item["entry_window"] = entry_window if entry_window and entry_window.get("status") != "active": item["entry_window_alert"] = entry_window item["risk_suggestion"] = _risk_suggestion( item.get("entry_price") or entry_plan.get("entry_price") or 0, item.get("stop_loss") or entry_plan.get("stop_loss") or 0, item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, ) item["market_context"] = market_context item["derivatives_context"] = derivatives_context item["sector_context"] = sector_context item["force_reason"] = force_reason item["base_state"] = base_state item["sector_signal_count"] = sector_signal_count item["strategy_version"] = strategy_version item["strategy_version_label"] = f"策略版本 {strategy_version}" if strategy_version else "" return item def _is_actionable_execution_status(status): """实时可操作口径:包含现在可买 + 等回踩计划;但收益只统计已执行交易。""" return status in ("buy_now", "wait_pullback") def _is_executed_trade(item): """收益统计口径:只有真实触发入场/持仓/退出的样本才计算收益。 buy_now 是当前入场窗口,不等同于已成交;等回踩/观察永远不计推荐收益。 """ status = (item.get("status") or "").strip() action_status = normalize_action_status(item.get("action_status"), status) execution_status = item.get("execution_status") or "" try: entry_triggered = int(item.get("entry_triggered") or 0) == 1 except Exception: entry_triggered = False if entry_triggered: return True if status in ("hit_tp1", "hit_tp2", "stopped_out"): return True if item.get("display_bucket") == "position" or execution_status in ("holding", "completed"): return True return is_executed_lifecycle(status, action_status, execution_status) def _classify_recommendation_result(item): status = item.get("status") or "" pnl_pct = item.get("pnl_pct") or 0 max_pnl_pct = item.get("max_pnl_pct") or 0 max_drawdown_pct = item.get("max_drawdown_pct") or 0 if status in ("hit_tp1", "hit_tp2"): return "success", "✅ 止盈成功" if status == "stopped_out": return "failed", "❌ 止损失败" # 计划/观察未实际触发入场前,不按推荐生成价计算成功/失败。 if not _is_executed_trade(item): return "pending", "⏳ 未执行" if status == "expired": if max_pnl_pct >= 5: return "success", "✅ 交易成功" if pnl_pct <= -3 or max_drawdown_pct <= -5: return "failed", "❌ 交易失败" return "pending", "⏳ 跟踪中" if status == "active": if max_pnl_pct >= 5: return "success", "✅ 交易成功" if pnl_pct <= -3 or max_drawdown_pct <= -5: return "failed", "❌ 交易失败" return "pending", "⏳ 跟踪中" return "pending", "⏳ 未执行" # ==================== 查询API ==================== def get_active_recommendations(actionable_only=False): """获取所有active推荐。默认保留全量,实时页请使用去重视图的可执行口径。""" conn = get_conn() rows = conn.execute(""" SELECT * FROM recommendation WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY rec_time DESC """).fetchall() conn.close() result = [] for row in rows: item = _derive_execution_fields(dict(row)) if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): continue result.append(item) return result def get_active_recommendations_deduped(actionable_only=True, version="", hours=0, watch_symbols=None, limit=0, offset=0, with_meta=False): """获取去重后的active推荐(同symbol只保留最新一条),并附带推荐结果判定。 version 为空时不按版本过滤;hours>0 时只取最近 N 小时信号。 with_meta=True 时返回分页对象,兼容实时看板首屏分页加载。""" conn = get_conn() where = "status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'" params = [] version = str(version or "").strip() if version: where += " AND strategy_version=%s" params.append(version) if watch_symbols: symbols = [str(s).strip().upper() for s in watch_symbols if str(s).strip()] if symbols: where += " AND symbol IN (" + ",".join(["%s"] * len(symbols)) + ")" params.extend(symbols) try: hours = float(hours or 0) except Exception: hours = 0 if hours > 0: cutoff = (datetime.now() - timedelta(hours=hours)).isoformat() where += " AND rec_time >= %s" params.append(cutoff) try: limit = max(0, int(limit or 0)) except Exception: limit = 0 try: offset = max(0, int(offset or 0)) except Exception: offset = 0 rows = conn.execute(f""" SELECT r.*, lpc.price AS latest_cache_price, lpc.updated_at AS latest_cache_updated_at FROM recommendation r LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE {where} GROUP BY symbol ) latest ON latest.max_id = r.id ORDER BY r.rec_time DESC """, tuple(params)).fetchall() conn.close() all_items = [] # 实时看板只输出当前有效机会;过期/失效样本属于历史/复盘,不再进入实时列表或 summary。 summary = {"buy_now": 0, "wait_pullback": 0, "observe": 0, "observe_strong": 0, "observe_weak": 0, "expired": 0, "total": 0} now = datetime.now() for row in rows: item = dict(row) rec_result, rec_result_label = _classify_recommendation_result(item) item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label _derive_execution_fields(item) is_expired = False if hours > 0: try: rec_time = item.get("rec_time") if rec_time: is_expired = (now - datetime.fromisoformat(str(rec_time))).total_seconds() > hours * 3600 except Exception: is_expired = False if item.get("execution_status") == "invalid" or item.get("status") in ("invalid", "expired", "archived") or item.get("display_bucket") == "history": is_expired = True # 带 hours 的实时看板请求必须过滤旧/脏/过期;不带 hours 的内部/测试查询保留全量派生结果。 if is_expired: summary["expired"] += 1 continue if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): continue all_items.append(item) if item.get("execution_status") == "buy_now": summary["buy_now"] += 1 elif item.get("execution_status") == "wait_pullback": summary["wait_pullback"] += 1 else: summary["observe"] += 1 if item.get("observe_tier") == "weak": summary["observe_weak"] += 1 else: summary["observe_strong"] += 1 summary["total"] = len(all_items) # expired 仅作内部审计计数,不属于实时机会流;API 对外不暴露,避免前端/用户继续看到过期入口。 summary["expired_filtered"] = summary.pop("expired", 0) if not with_meta: try: from app.services.llm_insights import attach_recommendation_insights return attach_recommendation_insights(all_items) except Exception: return all_items page_items = all_items[offset: offset + limit] if limit else all_items[offset:] try: from app.services.llm_insights import attach_recommendation_insights attach_recommendation_insights(page_items) except Exception: pass return { "items": page_items, "total": len(all_items), "limit": limit, "offset": offset, "has_more": bool(limit and offset + len(page_items) < len(all_items)), "summary": summary, } def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False): """兼容导出:推荐列表查询已迁移到 analytics 模块。""" from app.db.analytics import get_all_recommendations as _get_all_recommendations return _get_all_recommendations( limit=limit, decision_only=decision_only, version=version, offset=offset, with_meta=with_meta, ) def get_screening_history(hours=24, limit=100): """获取最近N小时的筛选记录""" conn = get_conn() rows = conn.execute(""" SELECT * FROM screening_log WHERE layer='细筛' AND scan_time >= %s ORDER BY score DESC, scan_time DESC LIMIT %s """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(), limit)).fetchall() conn.close() return [dict(r) for r in rows] def get_stats(): """兼容导出:统计聚合已迁移到 analytics 模块。""" from app.db.analytics import get_stats as _get_stats return _get_stats() # ==================== 原有状态跟踪(兼容) ==================== def update_state(symbol, new_state, score=0, anomaly_type="", sector="", leader_status="", detail=None): """更新币状态(兼容旧接口)""" conn = get_conn() row = conn.execute("SELECT * FROM coin_state WHERE symbol=%s", (symbol,)).fetchone() if row: old_state = row["state"] old_score = row["score"] last_alert_time = row["last_alert_time"] or "" last_alert_level = row["last_alert_level"] or "" # 状态升级逻辑 state_order = {"过期": 0, "蓄力": 1, "加速": 2, "爆发": 3} should_alert = False alert_level = "low" if state_order.get(new_state, 0) > state_order.get(old_state, 0): should_alert = True alert_level = "high" if new_state == "爆发" else "medium" if new_state == "加速" else "low" elif new_state == old_state: # 同级别:检查状态冷却,避免频繁重复推荐 cooldown_hours = confirm_state_cooldown_hours() if last_alert_time: try: last_dt = datetime.fromisoformat(last_alert_time) hours_since = (datetime.now() - last_dt).total_seconds() / 3600 if hours_since < cooldown_hours: conn.close() return {"should_alert": False, "alert_level": "none", "reason": f"状态冷却中({hours_since:.1f}h<{cooldown_hours}h)"} except Exception: pass if score > old_score: # 同级别分数提升,检查冷却 if not last_alert_time: should_alert = True alert_level = "medium" else: try: last_dt = datetime.fromisoformat(last_alert_time) hours_since = (datetime.now() - last_dt).total_seconds() / 3600 cooldown = 12 # 12h同币不重复推送同级别 if hours_since >= cooldown: should_alert = True alert_level = "medium" except Exception: should_alert = True alert_level = "medium" conn.execute(""" UPDATE coin_state SET state=%s, score=%s, anomaly_type=%s, sector=%s, leader_status=%s, detected_at=%s, detail_json=%s WHERE symbol=%s """, ( new_state, score, anomaly_type, sector, leader_status, datetime.now().isoformat(), json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}", symbol, )) if should_alert: conn.execute(""" UPDATE coin_state SET last_alert_time=%s, last_alert_level=%s WHERE symbol=%s """, (datetime.now().isoformat(), alert_level, symbol)) conn.commit() conn.close() return {"should_alert": should_alert, "alert_level": alert_level} else: # 新币,首次检测 conn.execute(""" INSERT INTO coin_state (symbol, state, score, anomaly_type, sector, leader_status, detected_at, last_alert_time, last_alert_level, detail_json) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( symbol, new_state, score, anomaly_type, sector, leader_status, datetime.now().isoformat(), datetime.now().isoformat(), "low", json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}", )) conn.commit() conn.close() return {"should_alert": True, "alert_level": "low"} def get_candidates_for_confirm(): """获取需要确认层检查的候选。 优先处理最近一轮粗筛/细筛刚更新的候选,避免旧 coin_state 中的高分候选 抢占确认层,导致链路日志里“细筛通过”和“确认处理”对不上。 """ try: _, _, accumulate_threshold = state_score_thresholds() except Exception: accumulate_threshold = 3 conn = get_conn() rows = conn.execute(""" SELECT * FROM coin_state WHERE state IN ('加速', '蓄力') AND score >= %s AND detected_at >= %s ORDER BY detected_at DESC, score DESC """, (accumulate_threshold, (datetime.now() - timedelta(minutes=45)).isoformat())).fetchall() if not rows: rows = conn.execute(""" SELECT * FROM coin_state WHERE state IN ('加速', '蓄力') AND score >= 5 ORDER BY detected_at DESC, score DESC """).fetchall() conn.close() return [dict(r) for r in rows] def get_all_active(): conn = get_conn() rows = conn.execute("SELECT * FROM coin_state WHERE state != '过期'").fetchall() conn.close() return [dict(r) for r in rows] def expire_old_states(hours=24): conn = get_conn() conn.execute(""" UPDATE coin_state SET state='过期' WHERE state != '过期' AND detected_at < %s """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)) conn.commit() conn.close() # ==================== 复盘相关 ==================== def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h, triggered_signals, hit_signals, miss_signals, lesson): """写入一条复盘记录""" conn = get_conn() conn.execute(""" INSERT INTO review_log (rec_id, symbol, review_time, outcome, pnl_48h, max_pnl_48h, triggered_signals, hit_signals, miss_signals, lesson) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (rec_id, symbol, datetime.now().isoformat(), outcome, pnl_48h, max_pnl_48h, json.dumps(triggered_signals, ensure_ascii=False) if isinstance(triggered_signals, list) else triggered_signals, json.dumps(hit_signals, ensure_ascii=False) if isinstance(hit_signals, list) else hit_signals, json.dumps(miss_signals, ensure_ascii=False) if isinstance(miss_signals, list) else miss_signals, lesson)) conn.commit() conn.close() def update_signal_performance(signal_type, category, is_hit, pnl): """更新信号绩效统计(每次复盘后调用)""" conn = get_conn() row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone() if row: total = row["total_count"] + 1 hits = row["hit_count"] + (1 if is_hit else 0) misses = row["miss_count"] + (0 if is_hit else 1) old_avg_pnl = row["avg_pnl"] # 滚动平均 new_avg_pnl = round((old_avg_pnl * (total - 1) + pnl) / total, 2) hit_rate = round(hits / total * 100, 1) if total > 0 else 0 conn.execute(""" UPDATE signal_performance SET total_count=%s, hit_count=%s, miss_count=%s, hit_rate=%s, avg_pnl=%s, weight=%s, last_updated=%s WHERE signal_type=%s """, (total, hits, misses, hit_rate, new_avg_pnl, hit_rate / 50, datetime.now().isoformat(), signal_type)) else: conn.execute(""" INSERT INTO signal_performance (signal_type, category, total_count, hit_count, miss_count, hit_rate, avg_pnl, weight, last_updated) VALUES (%s, %s, 1, %s, %s, %s, %s, %s, %s) """, (signal_type, category, 1 if is_hit else 0, 0 if is_hit else 1, 100 if is_hit else 0, pnl, 2.0 if is_hit else 0, datetime.now().isoformat())) conn.commit() conn.close() def get_signal_weights(): """获取所有信号的当前权重(screener动态调权用)""" conn = get_conn() rows = conn.execute("SELECT signal_type, category, weight, hit_rate, avg_pnl, total_count FROM signal_performance").fetchall() conn.close() return {row["signal_type"]: dict(row) for row in rows} def record_missed_explosion(symbol, price_at_detect, price_before, gain_pct, reason_missed, features_detected, lesson): """写入一条漏选复盘记录""" conn = get_conn() conn.execute(""" INSERT INTO missed_explosions (symbol, detect_time, price_at_detect, price_before, gain_pct, reason_missed, features_detected, lesson) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, (symbol, datetime.now().isoformat(), price_at_detect, price_before, gain_pct, json.dumps(reason_missed, ensure_ascii=False) if isinstance(reason_missed, list) else reason_missed, json.dumps(features_detected, ensure_ascii=False) if isinstance(features_detected, list) else features_detected, lesson)) conn.commit() conn.close() def get_review_stats(): """兼容导出:复盘统计已迁移到 analytics 模块。""" from app.db.analytics import get_review_stats as _get_review_stats return _get_review_stats( conn_provider=get_conn, iteration_logs_getter=get_strategy_iteration_logs, iteration_summary_getter=get_strategy_iteration_summary, ) def _loads_json_field(value, fallback): try: return json.loads(value) if isinstance(value, str) else (value if value is not None else fallback) except Exception: return fallback def log_strategy_iteration(run_date=None, trigger_source="daily_review", title="", summary="", findings=None, problems=None, actions=None, changed_rules=None, metrics=None, related_symbols=None, config_diff=None, effect_summary=None, pollution_summary=None, strategy_version="", version_change_summary="", success_analysis=None, failure_analysis=None, candidate_rules=None, release_decision="", release_reason="", confidence_level="", promotion_state="research_only"): """兼容导出:策略迭代写入已迁移到 review_queries 模块。""" from app.db.review_queries import log_strategy_iteration as _log_strategy_iteration return _log_strategy_iteration( run_date=run_date, trigger_source=trigger_source, title=title, summary=summary, findings=findings, problems=problems, actions=actions, changed_rules=changed_rules, metrics=metrics, related_symbols=related_symbols, config_diff=config_diff, effect_summary=effect_summary, pollution_summary=pollution_summary, strategy_version=strategy_version, version_change_summary=version_change_summary, success_analysis=success_analysis, failure_analysis=failure_analysis, candidate_rules=candidate_rules, release_decision=release_decision, release_reason=release_reason, confidence_level=confidence_level, promotion_state=promotion_state, conn_provider=get_conn, ) def get_strategy_iteration_logs(limit=30): """兼容导出:策略迭代日志查询已迁移到 review_queries 模块。""" from app.db.review_queries import get_strategy_iteration_logs as _get_strategy_iteration_logs return _get_strategy_iteration_logs(limit=limit, conn_provider=get_conn, json_loader=_loads_json_field) def upsert_strategy_rule_candidate(source, rule_type, signal_name, rule_description, support_count=0, success_count=0, fail_count=0, avg_pnl=0, max_gain=0, max_drawdown=0, confidence_score=0, sample_size=0, status="candidate", release_version="", notes="", source_ref=""): """新增或更新候选规则。研究结论先沉淀到候选池,避免样本不足时污染主策略。""" conn = get_conn() now = datetime.now().isoformat() existing = conn.execute(""" SELECT id FROM strategy_rule_candidate WHERE source=%s AND rule_type=%s AND signal_name=%s AND rule_description=%s ORDER BY id DESC LIMIT 1 """, (source or "", rule_type or "", signal_name or "", rule_description or "")).fetchone() if existing: conn.execute(""" UPDATE strategy_rule_candidate SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s, max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, release_version=%s, notes=%s, source_ref=COALESCE(NULLIF(%s, ''), source_ref), created_at=%s WHERE id=%s """, (support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "", now, existing["id"])) candidate_id = existing["id"] else: cur = conn.execute(""" INSERT INTO strategy_rule_candidate ( created_at, source, rule_type, signal_name, rule_description, support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, confidence_score, sample_size, status, release_version, notes, source_ref ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, (now, source or "", rule_type or "", signal_name or "", rule_description or "", support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "")) candidate_id = cur.fetchone()["id"] conn.commit() conn.close() return candidate_id def record_strategy_failure_pattern(symbol, version="", failure_type="", failure_reason="", signal_combo=None, market_context=None, entry_quality_issue="", pnl_pct=0, max_drawdown_pct=0, lesson=""): """记录失败模式,用于失败归因统计。""" conn = get_conn() conn.execute(""" INSERT INTO strategy_failure_pattern ( created_at, symbol, version, failure_type, failure_reason, signal_combo, market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( datetime.now().isoformat(), symbol or "", version or "", failure_type or "", failure_reason or "", json.dumps(signal_combo or [], ensure_ascii=False, default=str), json.dumps(market_context or {}, ensure_ascii=False, default=str), entry_quality_issue or "", pnl_pct or 0, max_drawdown_pct or 0, lesson or "", )) conn.commit() conn.close() def get_strategy_rule_candidates(limit=50, status=None): """获取候选规则列表。""" conn = get_conn() params = [] where = "" if status: where = "WHERE status=%s" params.append(status) rows = conn.execute(f""" SELECT * FROM strategy_rule_candidate {where} ORDER BY confidence_score DESC, sample_size DESC, created_at DESC LIMIT %s """, (*params, limit)).fetchall() conn.close() return [dict(r) for r in rows] def update_strategy_rule_candidate_status(candidate_id, status, release_version="", notes_append=""): """更新候选规则生命周期状态。""" conn = get_conn() row = conn.execute("SELECT notes FROM strategy_rule_candidate WHERE id=%s", (candidate_id,)).fetchone() if not row: conn.close() return False notes = (row["notes"] or "").strip() if notes_append: notes = (notes + "\n" if notes else "") + f"[{datetime.now().isoformat()}] {notes_append}" conn.execute(""" UPDATE strategy_rule_candidate SET status=%s, release_version=COALESCE(NULLIF(%s, ''), release_version), notes=%s, created_at=%s WHERE id=%s """, (status or "candidate", release_version or "", notes, datetime.now().isoformat(), candidate_id)) conn.commit() conn.close() return True def get_strategy_failure_patterns(limit=50): """获取失败模式明细。""" conn = get_conn() rows = conn.execute(""" SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC LIMIT %s """, (limit,)).fetchall() conn.close() items = [] for r in rows: item = dict(r) item["signal_combo"] = _loads_json_field(item.get("signal_combo"), []) item["market_context"] = _loads_json_field(item.get("market_context_json"), {}) items.append(item) return items def _candidate_signal_key(signal_text): """候选规则归因用的轻量信号归一化。保持与 review_engine._signal_key 语义接近。""" text = str(signal_text or "") key_map = { "量价齐飞": "vp_fly", "N倍放量": "vol_Nx", "放量": "1h_vol", "供需区突破": "zone_break", "供给区突破": "zone_break", "站稳突破": "zone_break", "起爆点": "ignition", "静K→动K": "ignition", "静K蓄力": "sk_accum", "连续3K": "cont3k", "连续K": "cont_k", "Q≥7": "q7_break", "动K": "dyn_k", "过期": "stale_signal", "历史": "stale_signal", "追高": "chase_high", "假突破": "false_breakout", "量价背离": "vp_divergence", } for marker, key in key_map.items(): if marker in text: return key return text[:12] def _get_factor_recency_fixed_at(): """因子时效性修复完成时间:此时间前的推荐视为污染历史参考,不参与正式发布。""" try: meta = get_meta() or {} except Exception: meta = {} return (meta.get("factor_recency_fixed_at") or meta.get("clean_review_started_at") or "").strip() def _is_dirty_history_candidate(candidate): source = str(candidate.get("source") or "") notes = str(candidate.get("notes") or "") source_ref = str(candidate.get("source_ref") or "") return source in ("history_review_auto", "dirty_history_reference") or "dirty_history" in source_ref or "污染历史" in notes def _candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, current_status="candidate", min_gray_samples=10, min_gray_confidence=65): """候选规则生命周期状态判定。""" if current_status == "active": return "active" if sample_size >= min_gray_samples and confidence >= min_gray_confidence and (avg_pnl > 0 or rule_type == "penalty"): return "gray" if sample_size >= 8 and ((rule_type != "penalty" and confidence < 35) or avg_pnl <= -3): return "rejected" if current_status in ("gray", "rejected"): return current_status return "candidate" def _classify_failure_type_from_text(review): """历史失败模式回填用的本地分类器,避免 altcoin_db 反向依赖 review_engine。""" signals = review.get("triggered_signals") or [] miss = review.get("miss_signals") or [] lesson = review.get("lesson") or "" text = " ".join([str(x) for x in signals + miss]) + " " + str(lesson or "") pnl = float(review.get("pnl_48h") or 0) outcome = review.get("outcome") or "" if any(k in text for k in ["过期", "历史", "旧放量", "age_bars", "已过期", "小时前", "旧起爆"]): return "过期因子误判", "历史放量/起爆/突破不能当作当前触发信号,必须做时效闸门" if any(k in text for k in ["假突破", "突破失败", "未站稳", "冲高回落"]): return "假突破", "突破后没有站稳或快速回落,需要增加站稳/承接确认" if any(k in text for k in ["量价背离", "缩量上涨", "放量下跌", "无量拉升"]): return "量价背离", "价格动作与成交量不匹配,量能确认不足" if any(k in text for k in ["高位", "追高", "涨幅过大", "乖离"]): return "追高风险", "入场位置偏高,盈亏比和回撤风险恶化" if any(k in text for k in ["承接不足", "无承接", "上影线", "砸盘"]): return "高位无承接", "高位出现抛压但缺少买盘承接" if any(k in text for k in ["板块退潮", "热点退潮", "龙头走弱", "板块分歧"]): return "板块退潮", "板块热度回落,个币信号容易失效" if any(k in text for k in ["BTC", "大盘", "反向共振", "系统性"]): return "BTC/大盘反向共振", "大盘方向与个币信号冲突,需要宏观/主流币过滤" if any(k in text for k in ["止损", "盈亏比", "RR", "止盈"]): return "止损/盈亏比不合理", "止损或止盈结构不合理,导致信号收益风险不匹配" if "滞后" in text or "MACD" in text or "RSI" in text: return "滞后信号追高", "滞后指标占比高,容易形成事后确认/追高失败" if "缺乏前瞻" in text or "前瞻" not in text: return "前瞻信号不足", "缺少量价/PA等前瞻性确认" if "横盘" in text or outcome == "横盘": return "信号强度不足", "触发后未形成有效爆发,确认条件偏弱" if "回撤" in text or pnl < -3: return "入场点太晚", "入场后回撤/亏损明显,买点可能滞后或确认过慢" return "未分类失败", "需要继续积累样本做二级归因" def backfill_strategy_failure_patterns(limit=2000, dry_run=False): """从历史 review_log 回填失败模式库,按 rec_id 去重。""" conn = get_conn() rows = conn.execute(""" SELECT rl.*, r.strategy_version, r.max_drawdown_pct FROM review_log rl LEFT JOIN recommendation r ON r.id = rl.rec_id WHERE rl.outcome IN ('失败','横盘') ORDER BY rl.review_time DESC LIMIT %s """, (limit,)).fetchall() existing = set() for r in conn.execute("SELECT market_context_json FROM strategy_failure_pattern").fetchall(): ctx = _loads_json_field(r["market_context_json"], {}) if ctx.get("rec_id") is not None: existing.add(str(ctx.get("rec_id"))) inserted = 0 skipped = 0 type_counts = {} examples = [] for row in rows: item = dict(row) rec_id = item.get("rec_id") if str(rec_id) in existing: skipped += 1 continue triggered = _loads_json_field(item.get("triggered_signals"), []) or [] miss = _loads_json_field(item.get("miss_signals"), []) or [] item["triggered_signals"] = triggered item["miss_signals"] = miss ftype, reason = _classify_failure_type_from_text(item) type_counts[ftype] = type_counts.get(ftype, 0) + 1 if len(examples) < 10: examples.append({"rec_id": rec_id, "symbol": item.get("symbol"), "failure_type": ftype, "reason": reason}) if not dry_run: conn.execute(""" INSERT INTO strategy_failure_pattern ( created_at, symbol, version, failure_type, failure_reason, signal_combo, market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( datetime.now().isoformat(), item.get("symbol") or "", item.get("strategy_version") or "", ftype, reason, json.dumps(triggered, ensure_ascii=False, default=str), json.dumps({"source": "history_backfill", "rec_id": rec_id, "outcome": item.get("outcome"), "review_time": item.get("review_time")}, ensure_ascii=False, default=str), reason, float(item.get("pnl_48h") or 0), float(item.get("max_drawdown_pct") or 0), item.get("lesson") or "", )) existing.add(str(rec_id)) inserted += 1 if not dry_run: conn.commit() conn.close() return {"dry_run": dry_run, "scanned": len(rows), "inserted": inserted, "skipped_existing": skipped, "type_counts": type_counts, "examples": examples} def generate_candidates_from_review_history(min_samples=20, min_bonus_confidence=55, max_penalty_confidence=40, dry_run=False): """从历史 review_log 自动生成候选规则池。""" conn = get_conn() rows = conn.execute(""" SELECT rl.*, r.max_drawdown_pct FROM review_log rl LEFT JOIN recommendation r ON r.id = rl.rec_id ORDER BY rl.review_time DESC """).fetchall() buckets = {} for row in rows: item = dict(row) triggered = _loads_json_field(item.get("triggered_signals"), []) or [] hit = _loads_json_field(item.get("hit_signals"), []) or [] miss = _loads_json_field(item.get("miss_signals"), []) or [] keys = {_candidate_signal_key(x) for x in list(triggered) + list(hit) + list(miss) if str(x).strip()} if not keys: continue for key in keys: b = buckets.setdefault(key, {"sample_size": 0, "success_count": 0, "fail_count": 0, "pnl_values": [], "dd_values": []}) b["sample_size"] += 1 if item.get("outcome") == "爆发": b["success_count"] += 1 elif item.get("outcome") in ("失败", "横盘"): b["fail_count"] += 1 b["pnl_values"].append(float(item.get("pnl_48h") or 0)) b["dd_values"].append(float(item.get("max_drawdown_pct") or 0)) generated = [] for key, b in buckets.items(): sample = b["sample_size"] if sample < min_samples: continue resolved = b["success_count"] + b["fail_count"] confidence = round(b["success_count"] / resolved * 100, 1) if resolved else 0 avg_pnl = round(sum(b["pnl_values"]) / len(b["pnl_values"]), 2) if b["pnl_values"] else 0 max_gain = round(max(b["pnl_values"]), 2) if b["pnl_values"] else 0 max_drawdown = round(min(b["dd_values"]), 2) if b["dd_values"] else 0 rule_type = "bonus" if confidence >= min_bonus_confidence and avg_pnl > 0 else "penalty" if confidence <= max_penalty_confidence else "observe" if rule_type == "observe": continue status = _candidate_status_for_metrics(rule_type, sample, confidence, avg_pnl, "candidate") if rule_type == "bonus": desc = f"历史样本候选加分因子:{key},样本{sample},成功{b['success_count']},失败/横盘{b['fail_count']},置信{confidence}%,均值{avg_pnl}%" else: desc = f"历史样本候选惩罚因子:{key},样本{sample},成功{b['success_count']},失败/横盘{b['fail_count']},置信{confidence}%,均值{avg_pnl}%" candidate = { "source": "dirty_history_reference", "rule_type": rule_type, "signal_name": key, "rule_description": desc, "support_count": sample, "success_count": b["success_count"], "fail_count": b["fail_count"], "avg_pnl": avg_pnl, "max_gain": max_gain, "max_drawdown": max_drawdown, "confidence_score": confidence, "sample_size": sample, "status": status, "source_ref": f"dirty_history:{key}", } generated.append(candidate) if not dry_run: upsert_strategy_rule_candidate( source=candidate["source"], rule_type=rule_type, signal_name=key, rule_description=desc, support_count=sample, success_count=b["success_count"], fail_count=b["fail_count"], avg_pnl=avg_pnl, max_gain=max_gain, max_drawdown=max_drawdown, confidence_score=confidence, sample_size=sample, status=status, notes="历史review_log自动生成:候选规则仍需灰度验证后才可发布", source_ref=candidate["source_ref"], ) if not dry_run: conn.commit() conn.close() generated.sort(key=lambda x: (-x["sample_size"], x["rule_type"], -x["confidence_score"])) return {"dry_run": dry_run, "review_rows": len(rows), "generated_count": len(generated), "generated": generated[:80]} def dry_run_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65): """不写库的候选规则表现 dry-run,用于复盘系统验收。""" conn = get_conn() candidates = [dict(r) for r in conn.execute("SELECT * FROM strategy_rule_candidate").fetchall()] clean_started_at = _get_factor_recency_fixed_at() if clean_started_at: review_rows = conn.execute(""" SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time FROM review_log rl LEFT JOIN recommendation r ON r.id = rl.rec_id WHERE r.rec_time >= %s ORDER BY rl.review_time DESC """, (clean_started_at,)).fetchall() else: review_rows = conn.execute(""" SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time FROM review_log rl LEFT JOIN recommendation r ON r.id = rl.rec_id ORDER BY rl.review_time DESC """).fetchall() failure_rows = [dict(r) for r in conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()] try: current_version = str(get_meta().get("strategy_version") or "").strip() except Exception: current_version = "" conn.close() review_items = [] for row in review_rows: item = dict(row) triggered = _loads_json_field(item.get("triggered_signals"), []) or [] hit = _loads_json_field(item.get("hit_signals"), []) or [] miss = _loads_json_field(item.get("miss_signals"), []) or [] all_sigs = list(triggered) + list(hit) + list(miss) item["signal_keys"] = {_candidate_signal_key(x) for x in all_sigs} item["all_signal_text"] = " ".join(str(x) for x in all_sigs) review_items.append(item) evaluated = [] for c in candidates: status = c.get("status") or "candidate" source = c.get("source") or "" rule_type = c.get("rule_type") or "" signal_name = c.get("signal_name") or "" source_ref = c.get("source_ref") or "" dirty_history = _is_dirty_history_candidate(c) if dirty_history: evaluated.append({**c, "sample_size": 0, "support_count": 0, "success_count": 0, "fail_count": 0, "dry_run_status": "dirty_history", "release_gate_passed": False, "gate_reason": "因子时效修复前的污染历史参考:不参与干净样本统计,不允许发布"}) continue if status == "active": evaluated.append({**c, "dry_run_status": "active", "release_gate_passed": True, "gate_reason": "已正式生效,不参与dry-run降级"}) continue if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty": ftype = signal_name or source_ref.replace("failure:", "") matched = [r for r in failure_rows if (r.get("failure_type") or "") == ftype or ftype in (r.get("failure_reason") or "")] sample_size = len(matched) success_count = 0 fail_count = sample_size pnl_values = [float(r.get("pnl_pct") or 0) for r in matched] dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched] confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(c.get("confidence_score") or 0) else: key = signal_name or source_ref.replace("review:", "") matched = [item for item in review_items if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"])] sample_size = len(matched) success_count = sum(1 for r in matched if r.get("outcome") == "爆发") fail_count = sum(1 for r in matched if r.get("outcome") in ("失败", "横盘")) pnl_values = [float(r.get("pnl_48h") or 0) for r in matched] dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched] resolved = success_count + fail_count confidence = round(success_count / resolved * 100, 1) if resolved else float(c.get("confidence_score") or 0) avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(c.get("avg_pnl") or 0) max_gain = round(max(pnl_values), 2) if pnl_values else float(c.get("max_gain") or 0) max_drawdown = round(min(dd_values), 2) if dd_values else float(c.get("max_drawdown") or 0) dry_status = _candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, status, min_gray_samples, min_gray_confidence) gate_passed = dry_status in ("gray", "active") if dry_status == "gray": gate_reason = f"样本{sample_size}≥{min_gray_samples},置信{confidence}%≥{min_gray_confidence},avg_pnl={avg_pnl}%:可进入灰度,仍不升版" elif dry_status == "rejected": gate_reason = f"样本{sample_size}已足够但置信/收益不达标:淘汰,不允许发布" else: gate_reason = f"样本{sample_size}或置信{confidence}%不足:只研究不发布" evaluated.append({ **c, "sample_size": sample_size, "support_count": sample_size, "success_count": success_count, "fail_count": fail_count, "avg_pnl": avg_pnl, "max_gain": max_gain, "max_drawdown": max_drawdown, "confidence_score": confidence, "dry_run_status": dry_status, "release_gate_passed": gate_passed, "gate_reason": gate_reason, }) gray_ready = [x for x in evaluated if x.get("dry_run_status") == "gray"] active_ready = [x for x in evaluated if x.get("dry_run_status") == "active"] rejected = [x for x in evaluated if x.get("dry_run_status") == "rejected"] can_release = False release_reason = "dry-run只评估候选规则表现,不执行 learned_rules 写入或版本升级" return { "dry_run": True, "current_version": current_version, "review_sample_count": len(review_items), "clean_started_at": clean_started_at, "sample_window": "clean_after_factor_recency_fix" if clean_started_at else "all_history", "dirty_history_candidate_count": sum(1 for c in candidates if _is_dirty_history_candidate(c)), "candidate_count": len(candidates), "gray_ready_count": len(gray_ready), "active_count": len(active_ready), "rejected_count": len(rejected), "would_bump_version": can_release, "release_reason": release_reason, "gate_policy": { "gray": f"sample_size≥{min_gray_samples} 且 confidence≥{min_gray_confidence} 且 avg_pnl>0(penalty规则可不要求avg_pnl>0)", "reject": "sample_size≥8 且 confidence<35 或 avg_pnl≤-3", "release": "dry-run不发布;正式发布仍由复盘发布闸门统一控制", }, "evaluated_candidates": sorted(evaluated, key=lambda x: (x.get("dry_run_status") != "gray", -float(x.get("sample_size") or 0), -float(x.get("confidence_score") or 0)))[:80], } def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65): """刷新候选规则表现。 用 review_log + recommendation 复盘结果回填候选规则的 sample/success/fail/avg_pnl, 并按门槛自动 candidate/gray/rejected,active 不降级。 """ conn = get_conn() candidates = conn.execute("SELECT * FROM strategy_rule_candidate").fetchall() clean_started_at = _get_factor_recency_fixed_at() if clean_started_at: review_rows = conn.execute(""" SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time FROM review_log rl LEFT JOIN recommendation r ON r.id = rl.rec_id WHERE r.rec_time >= %s ORDER BY rl.review_time DESC """, (clean_started_at,)).fetchall() else: review_rows = conn.execute(""" SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time FROM review_log rl LEFT JOIN recommendation r ON r.id = rl.rec_id ORDER BY rl.review_time DESC """).fetchall() failure_rows = conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall() def loads(value, fallback): return _loads_json_field(value, fallback) review_items = [] for row in review_rows: item = dict(row) triggered = loads(item.get("triggered_signals"), []) or [] hit = loads(item.get("hit_signals"), []) or [] miss = loads(item.get("miss_signals"), []) or [] all_sigs = list(triggered) + list(hit) + list(miss) item["signal_keys"] = {_candidate_signal_key(x) for x in all_sigs} item["all_signal_text"] = " ".join(str(x) for x in all_sigs) review_items.append(item) updated = [] for cand in candidates: c = dict(cand) cid = c["id"] status = c.get("status") or "candidate" if status == "active": continue source = c.get("source") or "" rule_type = c.get("rule_type") or "" signal_name = c.get("signal_name") or "" source_ref = c.get("source_ref") or "" desc = c.get("rule_description") or "" if _is_dirty_history_candidate(c): updated.append({ "id": cid, "signal_name": signal_name, "source": source, "rule_type": rule_type, "sample_size": 0, "success_count": 0, "fail_count": 0, "confidence_score": c.get("confidence_score") or 0, "avg_pnl": c.get("avg_pnl") or 0, "status": "dirty_history", "description": desc, "gate_reason": "污染历史参考,不参与干净样本刷新", }) continue matched = [] if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty": ftype = signal_name or source_ref.replace("failure:", "") frows = [dict(r) for r in failure_rows if (r["failure_type"] or "") == ftype or ftype in (r["failure_reason"] or "")] sample_size = len(frows) success_count = 0 fail_count = sample_size pnl_values = [float(r.get("pnl_pct") or 0) for r in frows] dd_values = [float(r.get("max_drawdown_pct") or 0) for r in frows] confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(c.get("confidence_score") or 0) else: key = signal_name or source_ref.replace("review:", "") for item in review_items: if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"]): matched.append(item) sample_size = len(matched) success_count = sum(1 for r in matched if r.get("outcome") == "爆发") fail_count = sum(1 for r in matched if r.get("outcome") in ("失败", "横盘")) pnl_values = [float(r.get("pnl_48h") or 0) for r in matched] dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched] resolved = success_count + fail_count confidence = round(success_count / resolved * 100, 1) if resolved else float(c.get("confidence_score") or 0) avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(c.get("avg_pnl") or 0) max_gain = round(max(pnl_values), 2) if pnl_values else float(c.get("max_gain") or 0) max_drawdown = round(min(dd_values), 2) if dd_values else float(c.get("max_drawdown") or 0) new_status = _candidate_status_for_metrics( rule_type, sample_size, confidence, avg_pnl, status, min_gray_samples=min_gray_samples, min_gray_confidence=min_gray_confidence, ) note = (c.get("notes") or "").strip() audit_note = f"[{datetime.now().isoformat()}] 自动评估: 样本{sample_size}, 成功{success_count}, 失败{fail_count}, 置信{confidence}%, avg_pnl={avg_pnl}%, status={new_status}" if audit_note not in note: note = (note + "\n" if note else "") + audit_note conn.execute(""" UPDATE strategy_rule_candidate SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s, max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, notes=%s, created_at=%s WHERE id=%s """, (sample_size, success_count, fail_count, avg_pnl, max_gain, max_drawdown, confidence, sample_size, new_status, note, datetime.now().isoformat(), cid)) updated.append({ "id": cid, "signal_name": signal_name, "source": source, "rule_type": rule_type, "sample_size": sample_size, "success_count": success_count, "fail_count": fail_count, "confidence_score": confidence, "avg_pnl": avg_pnl, "status": new_status, "description": desc, }) conn.commit() conn.close() return updated def get_strategy_iteration_dashboard(days=30): """迭代页聚合数据:总览 + 候选规则 + 失败模式 + 时间线。""" summary = get_strategy_iteration_summary(days=days) candidates = get_strategy_rule_candidates(limit=80) failures = get_strategy_failure_patterns(limit=80) logs = get_strategy_iteration_logs(limit=40) status_counts = {} source_counts = {} for c in candidates: status_counts[c.get("status") or "candidate"] = status_counts.get(c.get("status") or "candidate", 0) + 1 source_counts[c.get("source") or "unknown"] = source_counts.get(c.get("source") or "unknown", 0) + 1 failure_counts = {} for f in failures: ft = f.get("failure_type") or "未分类" failure_counts[ft] = failure_counts.get(ft, 0) + 1 release_counts = {} for log in logs: rd = log.get("release_decision") or "unknown" release_counts[rd] = release_counts.get(rd, 0) + 1 dry_run = dry_run_strategy_candidate_performance() latest_log = logs[0] if logs else {} return { "summary": summary, "overview": { "total_logs": len(logs), "candidate_count": len(candidates), "candidate_status_counts": status_counts, "candidate_source_counts": source_counts, "failure_type_counts": [{"type": k, "count": v} for k, v in sorted(failure_counts.items(), key=lambda x: (-x[1], x[0]))], "release_decision_counts": release_counts, "latest_release_decision": latest_log.get("release_decision") or "hold", "latest_release_reason": latest_log.get("release_reason") or latest_log.get("version_change_summary") or "暂无发布决策说明", "dry_run_summary": { "review_sample_count": dry_run.get("review_sample_count", 0), "clean_started_at": dry_run.get("clean_started_at", ""), "sample_window": dry_run.get("sample_window", "all_history"), "dirty_history_candidate_count": dry_run.get("dirty_history_candidate_count", 0), "candidate_count": dry_run.get("candidate_count", 0), "gray_ready_count": dry_run.get("gray_ready_count", 0), "rejected_count": dry_run.get("rejected_count", 0), "would_bump_version": dry_run.get("would_bump_version", False), "release_reason": dry_run.get("release_reason", ""), }, }, "dry_run": dry_run, "candidates": candidates, "failures": failures, "logs": logs, } def get_strategy_iteration_summary(days=30): """兼容导出:策略迭代汇总已迁移到 review_queries 模块。""" from app.db.review_queries import get_strategy_iteration_summary as _get_strategy_iteration_summary return _get_strategy_iteration_summary(days=days, conn_provider=get_conn, json_loader=_loads_json_field) def log_cron_run(job_name, script_name, run_status, result_status="", started_at="", finished_at="", duration_ms=0, summary=None, error_message=""): """记录一次 cron 运行汇总""" conn = get_conn() conn.execute(""" INSERT INTO cron_run_log ( job_name, script_name, run_status, result_status, started_at, finished_at, duration_ms, summary_json, error_message ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( job_name, script_name, run_status, result_status, started_at or datetime.now().isoformat(), finished_at or datetime.now().isoformat(), int(duration_ms or 0), json.dumps(summary or {}, ensure_ascii=False, default=str), (error_message or "")[:1000], )) conn.commit() conn.close() def get_cron_run_logs(limit=50, job_name=None): """获取 cron 运行日志列表""" conn = get_conn() sql = """ SELECT * FROM cron_run_log {where_clause} ORDER BY started_at DESC, id DESC LIMIT %s """ params = [] where_clause = "" if job_name: where_clause = "WHERE job_name = %s" params.append(job_name) params.append(limit) rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall() conn.close() result = [] for row in rows: item = dict(row) try: item["summary_json"] = json.loads(item.get("summary_json") or "{}") except Exception: item["summary_json"] = {} result.append(item) return result def get_cron_run_summary(hours=24): """获取 cron 运行汇总统计""" conn = get_conn() now_iso = datetime.now().isoformat() rows = conn.execute(""" SELECT * FROM cron_run_log WHERE started_at >= %s ORDER BY started_at DESC, id DESC """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)).fetchall() conn.close() logs = [] job_stats = {} total_runs = 0 success_runs = 0 error_runs = 0 total_duration = 0 for row in rows: item = dict(row) try: item["summary_json"] = json.loads(item.get("summary_json") or "{}") except Exception: item["summary_json"] = {} logs.append(item) total_runs += 1 total_duration += item.get("duration_ms") or 0 if item.get("run_status") == "success": success_runs += 1 else: error_runs += 1 job = item.get("job_name") or "unknown" stat = job_stats.setdefault(job, { "job_name": job, "runs": 0, "success_runs": 0, "error_runs": 0, "avg_duration_ms": 0, "last_status": "", "last_result_status": "", "last_started_at": "", "last_finished_at": "", "last_error_message": "", }) stat["runs"] += 1 if item.get("run_status") == "success": stat["success_runs"] += 1 else: stat["error_runs"] += 1 stat["avg_duration_ms"] += item.get("duration_ms") or 0 if not stat["last_started_at"]: stat["last_status"] = item.get("run_status", "") stat["last_result_status"] = item.get("result_status", "") stat["last_started_at"] = item.get("started_at", "") stat["last_finished_at"] = item.get("finished_at", "") stat["last_error_message"] = item.get("error_message", "") for stat in job_stats.values(): stat["success_rate"] = round(stat["success_runs"] / stat["runs"] * 100, 1) if stat["runs"] else 0 stat["avg_duration_ms"] = round(stat["avg_duration_ms"] / stat["runs"]) if stat["runs"] else 0 overall = { "hours": hours, "total_runs": total_runs, "success_runs": success_runs, "error_runs": error_runs, "success_rate": round(success_runs / total_runs * 100, 1) if total_runs else 0, "avg_duration_ms": round(total_duration / total_runs) if total_runs else 0, } return { "overall": overall, "job_stats": sorted(job_stats.values(), key=lambda x: x["job_name"]), "recent_logs": logs[:20], } if __name__ == "__main__": init_db() stats = get_stats() print(f"DB初始化完成: {stats}") def _safe_list_json(value): try: if isinstance(value, list): return value if isinstance(value, str) and value.strip(): parsed = json.loads(value) return parsed if isinstance(parsed, list) else [] except Exception: pass return [] def _safe_dict_json(value): try: if isinstance(value, dict): return value if isinstance(value, str) and value.strip(): parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {} except Exception: pass return {} def get_strategy_insights(): """阶段3:策略可信度看板数据 — 总体表现、因子归因、市场环境归因。 只统计已出结果的样本,避免策略页把仍在实时看板里的 active 浮亏/回撤 与历史推荐页的已完成样本混在一起,造成口径不一致。 """ conn = get_conn() rows = conn.execute("SELECT * FROM recommendation ORDER BY rec_time DESC").fetchall() conn.close() raw_items = [dict(r) for r in rows] def outcome(item): status = item.get("status") or "" if status in ("hit_tp1", "hit_tp2"): return "success" if status == "stopped_out": return "failed" if (item.get("max_pnl_pct") or 0) >= 5: return "success" if (item.get("pnl_pct") or 0) <= -3 or (item.get("max_drawdown_pct") or 0) <= -5: return "failed" return "pending" items = [x for x in raw_items if outcome(x) in ("success", "failed")] total = len(items) success = sum(1 for x in items if outcome(x) == "success") failed = sum(1 for x in items if outcome(x) == "failed") resolved = success + failed pnl_values = [float(x.get("pnl_pct") or 0) for x in items] gains = [p for p in pnl_values if p > 0] losses = [p for p in pnl_values if p < 0] overview = { "total_signals": total, "resolved_count": resolved, "success_count": success, "failed_count": failed, "pending_count": total - resolved, "win_rate_pct": round(success / resolved * 100, 1) if resolved else 0, "avg_pnl_pct": round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else 0, "avg_gain_pct": round(sum(gains) / len(gains), 2) if gains else 0, "avg_loss_pct": round(sum(losses) / len(losses), 2) if losses else 0, "max_gain_pct": round(max([float(x.get("max_pnl_pct") or x.get("pnl_pct") or 0) for x in items] or [0]), 2), "max_drawdown_pct": round(min([float(x.get("max_drawdown_pct") or 0) for x in items] or [0]), 2), } def add_bucket(bucket_map, key, item): if not key: return b = bucket_map.setdefault(key, {"total_count": 0, "success_count": 0, "failed_count": 0, "pending_count": 0, "pnl_values": [], "max_gains": [], "drawdowns": []}) b["total_count"] += 1 oc = outcome(item) if oc == "success": b["success_count"] += 1 elif oc == "failed": b["failed_count"] += 1 else: b["pending_count"] += 1 b["pnl_values"].append(float(item.get("pnl_pct") or 0)) b["max_gains"].append(float(item.get("max_pnl_pct") or item.get("pnl_pct") or 0)) b["drawdowns"].append(float(item.get("max_drawdown_pct") or 0)) def env_buckets_from_market_context(mc): """把当前实际存在的 market_context_json 数值字段转成可归因桶。 旧版本只读取 btc_trend/market_regime 等枚举字段,但当前入库字段主要是 change_24h、turnover_acceleration、volume_24h/funding_rate,导致市场环境归因为空。 """ buckets = [] try: change_24h = float(mc.get("change_24h", 0) or 0) turn_1h = float(mc.get("turnover_acceleration_1h", 0) or 0) turn_4h = float(mc.get("turnover_acceleration_4h", 0) or 0) volume_24h = float(mc.get("volume_24h") or mc.get("quote_volume_24h") or 0) funding = float(mc.get("funding_rate", 0) or 0) except Exception: change_24h = turn_1h = turn_4h = volume_24h = funding = 0 if change_24h >= 8: buckets.append("24h涨幅:强势拉升≥8%") elif change_24h >= 3: buckets.append("24h涨幅:温和上涨3-8%") elif change_24h <= -3: buckets.append("24h涨幅:回撤≤-3%") else: buckets.append("24h涨幅:震荡-3~3%") if turn_1h >= 3: buckets.append("1h成交加速:爆量≥3x") elif turn_1h >= 1.5: buckets.append("1h成交加速:放量1.5-3x") elif turn_1h > 0: buckets.append("1h成交加速:平量<1.5x") if turn_4h >= 3: buckets.append("4h成交加速:爆量≥3x") elif turn_4h >= 1.5: buckets.append("4h成交加速:放量1.5-3x") elif turn_4h > 0: buckets.append("4h成交加速:平量<1.5x") if volume_24h >= 100_000_000: buckets.append("24h成交额:高流动性≥1亿") elif volume_24h >= 10_000_000: buckets.append("24h成交额:中等流动性1千万-1亿") elif volume_24h > 0: buckets.append("24h成交额:低流动性<1千万") if funding >= 0.0005: buckets.append("资金费率:多头拥挤") elif funding <= -0.0005: buckets.append("资金费率:空头拥挤") return buckets factor_map = {} env_map = {} version_map = {} for item in items: for factor in _safe_list_json(item.get("signals")): add_bucket(factor_map, str(factor).strip(), item) mc = _safe_dict_json(item.get("market_context_json")) added_env = False for key in ("btc_trend", "market_regime", "altcoin_regime", "sentiment"): if mc.get(key): add_bucket(env_map, f"{key}:{mc.get(key)}", item) added_env = True for bucket in env_buckets_from_market_context(mc): add_bucket(env_map, bucket, item) added_env = True if item.get("strategy_version"): add_bucket(version_map, str(item.get("strategy_version")).strip(), item) def version_sort_key(version: str): text = str(version or '').strip() if text.startswith('v') or text.startswith('V'): text = text[1:] parts = [] for chunk in text.replace('-', '.').split('.'): if chunk.isdigit(): parts.append(int(chunk)) else: m = re.match(r'^(\d+)', chunk) if m: parts.append(int(m.group(1))) else: parts.append(chunk) return tuple(parts) def serialize(name_key, bucket_map, sort_by_version=False): rows = [] for key, b in bucket_map.items(): resolved_count = b["success_count"] + b["failed_count"] rows.append({ name_key: key, "total_count": b["total_count"], "success_count": b["success_count"], "failed_count": b["failed_count"], "pending_count": b["pending_count"], "win_rate_pct": round(b["success_count"] / resolved_count * 100, 1) if resolved_count else 0, "avg_pnl_pct": round(sum(b["pnl_values"]) / len(b["pnl_values"]), 2) if b["pnl_values"] else 0, "max_gain_pct": round(max(b["max_gains"] or [0]), 2), "max_drawdown_pct": round(min(b["drawdowns"] or [0]), 2), }) if sort_by_version: rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["total_count"], x["win_rate_pct"]), reverse=True) else: rows.sort(key=lambda x: (-x["total_count"], -x["win_rate_pct"], x[name_key])) return rows return { "overview": overview, "factor_attribution": serialize("factor", factor_map)[:30], "market_environment": serialize("environment", env_map)[:20], "version_performance": serialize("strategy_version", version_map, sort_by_version=True)[:20], }