"""Recommendation and lifecycle-facing DB API.""" import json from datetime import datetime from app.db.recommendation_commands import apply_recommendation_state_transition from app.db.recommendation_state import ( classify_recommendation_result as _classify_recommendation_result, derive_execution_fields as _derive_execution_fields, is_actionable_execution_status as _is_actionable_execution_status, ) from app.core.strategy_registry import normalize_strategy_code, strategy_label from app.core.trade_direction import normalize_trade_side, side_label from app.db.push_queries import get_recommendation_for_push, log_push, should_push from app.db.schema import get_conn from app.db.tracking_queries import update_recommendation_tracking from app.services.llm_insights import attach_recommendation_insights def _loads_json(value, fallback=None): try: if isinstance(value, str) and value.strip(): return json.loads(value) if value: return value except Exception: pass return fallback if fallback is not None else {} def _safe_int(value, default=0): try: return int(value or 0) except Exception: return default def _safe_symbol(value: str) -> str: return str(value or "").strip().upper() def _safe_float(value, default=0.0): try: return float(value or 0) except Exception: return default def _trade_marker(kind: str, label: str, time_value, price_value=0, source: str = "", ref_id=0, note: str = "") -> dict | None: if not time_value: return None return { "type": kind, "label": label, "time": str(time_value), "price": _safe_float(price_value), "source": source, "id": _safe_int(ref_id), "note": str(note or ""), } def _build_trade_markers(paper_rows, order_rows, events) -> list[dict]: """Build chart-ready markers from strategy-trading operations.""" markers = [] event_labels = { "open": ("open", "开仓"), "close": ("close", "平仓"), "trailing_activate": ("trailing", "移盈启"), "trailing_move": ("trailing", "移盈上"), } for row in events or []: event_type = str(row.get("event_type") or "").strip() kind, label = event_labels.get(event_type, ("event", event_type or "操作")) markers.append(_trade_marker(kind, label, row.get("event_time"), row.get("price"), "paper_event", row.get("id"), row.get("message"))) for row in order_rows or []: markers.append(_trade_marker("order", "挂单", row.get("created_at"), row.get("target_price"), "paper_order", row.get("id"), row.get("status"))) if row.get("filled_at"): markers.append(_trade_marker("fill", "成交", row.get("filled_at"), row.get("fill_price") or row.get("target_price"), "paper_order", row.get("id"), "挂单成交")) end_time = row.get("canceled_at") if end_time and row.get("status") in ("canceled", "expired", "rejected"): label = {"canceled": "撤单", "expired": "过期", "rejected": "拒绝"}.get(row.get("status"), "撤单") markers.append(_trade_marker("cancel", label, end_time, row.get("target_price"), "paper_order", row.get("id"), row.get("cancel_reason"))) for row in paper_rows or []: markers.append(_trade_marker("open", "开仓", row.get("opened_at"), row.get("entry_price"), "paper_trade", row.get("id"), row.get("source_status"))) if row.get("closed_at"): markers.append(_trade_marker("close", "平仓", row.get("closed_at"), row.get("exit_price"), "paper_trade", row.get("id"), row.get("exit_reason"))) seen = set() result = [] for marker in markers: if not marker: continue key = (marker["type"], marker["time"], round(marker["price"], 10), marker["source"], marker["id"]) if key in seen: continue seen.add(key) result.append(marker) result.sort(key=lambda x: x.get("time") or "") return result[-120:] def _attach_paper_order(item: dict) -> dict: order_id = item.get("paper_order_id") if not order_id: item["paper_order"] = None item["paper_order_status"] = "" return item order = { "id": order_id, "recommendation_id": item.get("paper_order_recommendation_id") or item.get("id"), "symbol": item.get("paper_order_symbol") or item.get("symbol"), "side": item.get("paper_order_side") or "long", "order_type": item.get("paper_order_type") or "limit", "status": item.get("paper_order_status_raw") or "", "target_price": item.get("paper_order_target_price") or 0, "current_price_at_create": item.get("paper_order_current_price_at_create") or 0, "fill_price": item.get("paper_order_fill_price") or 0, "stop_loss": item.get("paper_order_stop_loss") or 0, "tp1": item.get("paper_order_tp1") or 0, "tp2": item.get("paper_order_tp2") or 0, "created_at": item.get("paper_order_created_at") or "", "updated_at": item.get("paper_order_updated_at") or "", "expires_at": item.get("paper_order_expires_at") or "", "filled_at": item.get("paper_order_filled_at") or "", "canceled_at": item.get("paper_order_canceled_at") or "", "cancel_reason": item.get("paper_order_cancel_reason") or "", "strategy_code": normalize_strategy_code(item.get("paper_order_strategy_code") or item.get("strategy_code")), "strategy_name": strategy_label(item.get("paper_order_strategy_code") or item.get("strategy_code")), "strategy_signal_id": _safe_int(item.get("paper_order_strategy_signal_id") or item.get("strategy_signal_id")), } item["paper_order"] = order item["paper_order_status"] = order["status"] return item def _decorate_recommendation(item: dict) -> dict: item = dict(item or {}) item["signals"] = _loads_json(item.get("signals"), []) item["signal_codes"] = _loads_json(item.get("signal_codes_json"), []) item["signal_labels"] = _loads_json(item.get("signal_labels_json"), []) item["entry_plan"] = _loads_json(item.get("entry_plan_json"), {}) item["market_context"] = _loads_json(item.get("market_context_json"), {}) item["derivatives_context"] = _loads_json(item.get("derivatives_context_json"), {}) item["sector_context"] = _loads_json(item.get("sector_context_json"), {}) item["strategy_code"] = normalize_strategy_code(item.get("strategy_code")) item["strategy_name"] = strategy_label(item["strategy_code"]) item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {}) item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {}) side = normalize_trade_side( item["entry_plan"].get("side") or item["market_context"].get("side") or item.get("direction") or (item["strategy_snapshot"].get("direction") if isinstance(item.get("strategy_snapshot"), dict) else "") ) item["side"] = side item["side_label"] = side_label(side) item["entry_plan"]["side"] = side 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) _attach_paper_order(item) return item def get_active_recommendations(actionable_only: bool = 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)) entry_plan = _loads_json(item.get("entry_plan_json"), {}) side = normalize_trade_side(entry_plan.get("side") or item.get("direction")) item["side"] = side item["side_label"] = side_label(side) item["entry_plan"] = entry_plan item["entry_plan"]["side"] = side 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: bool = True, version: str = "", hours: float = 0, watch_symbols=None, limit: int = 0, offset: int = 0, with_meta: bool = False, side: str = "", ): """同 symbol 只保留最新 active 推荐,并附带派生执行状态。""" 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: where += " AND rec_time >= %s" params.append((datetime.now() - timedelta(hours=hours)).isoformat()) side = normalize_trade_side(side) if side else "" if side: where += " AND direction=%s" params.append("空头启动" if side == "short" else "多头启动") 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, po.id AS paper_order_id, po.recommendation_id AS paper_order_recommendation_id, po.symbol AS paper_order_symbol, po.side AS paper_order_side, po.order_type AS paper_order_type, po.status AS paper_order_status_raw, po.target_price AS paper_order_target_price, po.current_price_at_create AS paper_order_current_price_at_create, po.fill_price AS paper_order_fill_price, po.stop_loss AS paper_order_stop_loss, po.tp1 AS paper_order_tp1, po.tp2 AS paper_order_tp2, po.created_at AS paper_order_created_at, po.updated_at AS paper_order_updated_at, po.expires_at AS paper_order_expires_at, po.filled_at AS paper_order_filled_at, po.canceled_at AS paper_order_canceled_at, po.cancel_reason AS paper_order_cancel_reason, po.strategy_code AS paper_order_strategy_code, po.strategy_signal_id AS paper_order_strategy_signal_id FROM recommendation r LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol LEFT JOIN paper_orders po ON po.recommendation_id = r.id JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE {where} GROUP BY symbol, direction ) latest ON latest.max_id = r.id ORDER BY r.rec_time DESC """, tuple(params), ).fetchall() conn.close() all_items = [] summary = { "buy_now": 0, "wait_pullback": 0, "observe": 0, "observe_strong": 0, "observe_weak": 0, "expired": 0, "total": 0, "discovery_burst": 0, "executable_now": 0, "planned_entry": 0, "watch_pool": 0, "long_count": 0, "short_count": 0, } now = datetime.now() for row in rows: item = _decorate_recommendation(dict(row)) 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 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("side") == "short": summary["short_count"] += 1 else: summary["long_count"] += 1 if item.get("is_discovery_burst"): summary["discovery_burst"] += 1 if item.get("is_executable_now"): summary["executable_now"] += 1 if item.get("execution_status") == "wait_pullback": summary["planned_entry"] += 1 if item.get("is_watch_pool"): summary["watch_pool"] += 1 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) summary["expired_filtered"] = summary.pop("expired", 0) if not with_meta: return attach_recommendation_insights(all_items) page_items = all_items[offset : offset + limit] if limit else all_items[offset:] attach_recommendation_insights(page_items) 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_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None: """Return a symbol-centered opportunity detail dossier.""" symbol = _safe_symbol(symbol) rec_id = _safe_int(rec_id) conn = get_conn() params = [] if rec_id > 0: rec_row = conn.execute("SELECT * FROM recommendation WHERE id=%s", (rec_id,)).fetchone() elif symbol: rec_row = conn.execute( """ SELECT * FROM recommendation WHERE symbol=%s ORDER BY rec_time DESC, id DESC LIMIT 1 """, (symbol,), ).fetchone() else: conn.close() return None current = _decorate_recommendation(dict(rec_row)) if rec_row else None if current and not symbol: symbol = _safe_symbol(current.get("symbol")) if not symbol: conn.close() return None history_rows = conn.execute( """ SELECT * FROM recommendation WHERE symbol=%s ORDER BY rec_time DESC, id DESC LIMIT 30 """, (symbol,), ).fetchall() screening_rows = conn.execute( """ SELECT * FROM screening_log WHERE symbol=%s ORDER BY scan_time DESC, id DESC LIMIT 80 """, (symbol,), ).fetchall() review_rows = conn.execute( """ SELECT * FROM review_log WHERE symbol=%s ORDER BY review_time DESC, id DESC LIMIT 40 """, (symbol,), ).fetchall() paper_rows = conn.execute( """ SELECT * FROM paper_trades WHERE symbol=%s ORDER BY opened_at DESC, id DESC LIMIT 30 """, (symbol,), ).fetchall() order_rows = conn.execute( """ SELECT * FROM paper_orders WHERE symbol=%s ORDER BY created_at DESC, id DESC LIMIT 30 """, (symbol,), ).fetchall() event_rows = conn.execute( """ SELECT * FROM paper_trade_events WHERE symbol=%s ORDER BY event_time DESC, id DESC LIMIT 80 """, (symbol,), ).fetchall() latest_price = conn.execute("SELECT * FROM latest_price_cache WHERE symbol=%s", (symbol,)).fetchone() coin_state = conn.execute("SELECT * FROM coin_state WHERE symbol=%s ORDER BY detected_at DESC LIMIT 1", (symbol,)).fetchone() conn.close() history = [_decorate_recommendation(dict(row)) for row in history_rows] attach_recommendation_insights(history[:5]) if current: attach_recommendation_insights([current]) elif coin_state: detail = _loads_json(coin_state["detail_json"], {}) current = { "id": 0, "symbol": symbol, "rec_time": coin_state["detected_at"], "rec_state": coin_state["state"], "rec_score": coin_state["score"], "current_price": detail.get("price") or detail.get("current_price") or 0, "entry_price": detail.get("price") or detail.get("current_price") or 0, "signals": detail.get("signals") if isinstance(detail.get("signals"), list) else [], "entry_plan": {}, "execution_status": "observe", "execution_label": "观察候选", "display_bucket": "watch_pool", "observe_tier": "weak" if _safe_int(coin_state["score"]) < 4 else "strong", "source": "coin_state", "side": "long", "side_label": "多", } screening = [] for row in screening_rows: item = dict(row) item["signals"] = _loads_json(item.get("signals"), []) item["detail_json"] = _loads_json(item.get("detail_json"), {}) screening.append(item) reviews = [] for row in review_rows: item = dict(row) item["triggered_signals"] = _loads_json(item.get("triggered_signals"), []) item["hit_signals"] = _loads_json(item.get("hit_signals"), []) item["miss_signals"] = _loads_json(item.get("miss_signals"), []) reviews.append(item) paper_trades = [dict(row) for row in paper_rows] paper_orders = [dict(row) for row in order_rows] events = [] for row in event_rows: item = dict(row) item["detail_json"] = _loads_json(item.get("detail_json"), {}) events.append(item) return { "symbol": symbol, "current": current, "latest_price": dict(latest_price) if latest_price else {}, "history": history, "screening": screening, "reviews": reviews, "paper_trades": paper_trades, "paper_orders": paper_orders, "paper_events": events, "trade_markers": _build_trade_markers(paper_trades, paper_orders, events), "summary": { "history_count": len(history), "screening_count": len(screening), "review_count": len(reviews), "paper_trade_count": len(paper_rows), "paper_order_count": len(order_rows), "paper_event_count": len(events), }, } __all__ = [ "apply_recommendation_state_transition", "get_active_recommendations", "get_active_recommendations_deduped", "get_opportunity_detail", "get_recommendation_for_push", "log_push", "should_push", "update_recommendation_tracking", ]