"""Recommendation and lifecycle-facing DB API.""" import json from datetime import datetime, timedelta 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.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 _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 "", } 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"), {}) 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)) if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): continue result.append(item) return _attach_onchain_context(result) def _attach_onchain_context(items): if not items: return items symbols = sorted({item.get("symbol") for item in items if item.get("symbol")}) if not symbols: return items placeholders = ",".join(["%s"] * len(symbols)) try: conn = get_conn() rows = conn.execute( f""" SELECT m.* FROM onchain_token_metrics m JOIN ( SELECT symbol, MAX(metric_time) AS max_time FROM onchain_token_metrics WHERE symbol IN ({placeholders}) GROUP BY symbol ) latest ON latest.symbol=m.symbol AND latest.max_time=m.metric_time """, tuple(symbols), ).fetchall() events = conn.execute( f""" SELECT * FROM onchain_events WHERE symbol IN ({placeholders}) AND detected_at >= %s ORDER BY detected_at::timestamp DESC, id DESC """, (*symbols, (datetime.now() - timedelta(hours=24)).isoformat()), ).fetchall() conn.close() except Exception: return items metrics = {row["symbol"]: dict(row) for row in rows} by_symbol = {} for row in events: by_symbol.setdefault(row["symbol"], []).append(dict(row)) for item in items: metric = metrics.get(item.get("symbol")) or {} evs = by_symbol.get(item.get("symbol")) or [] if not metric and not evs: continue risk_events = [e for e in evs if e.get("direction") == "risk"] positive_events = [e for e in evs if e.get("direction") == "positive"] if risk_events: headline = risk_events[0].get("signal_label") or "链上风险升温" elif positive_events: headline = positive_events[0].get("signal_label") or "链上资金异动" else: headline = "链上异动" item["onchain_context"] = { "headline": headline, "chain": metric.get("chain") or (evs[0].get("chain") if evs else ""), "onchain_score": metric.get("onchain_score") or 0, "risk_score": metric.get("risk_score") or 0, "dex_volume_usd": metric.get("dex_volume_usd") or 0, "liquidity_usd": metric.get("liquidity_usd") or 0, "event_count_24h": len(evs), "risk_event_count_24h": len(risk_events), "top_events": [ { "signal_code": e.get("signal_code"), "signal_label": e.get("signal_label"), "direction": e.get("direction"), "value_usd": e.get("value_usd") or 0, "detected_at": e.get("detected_at"), } for e in evs[:3] ], } return items 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, ): """同 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()) 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 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 ) 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, } 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("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: _attach_onchain_context(all_items) return attach_recommendation_insights(all_items) page_items = all_items[offset : offset + limit] if limit else all_items[offset:] _attach_onchain_context(page_items) 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() metric_row = conn.execute( """ SELECT * FROM onchain_token_metrics WHERE symbol=%s ORDER BY metric_time DESC, id DESC LIMIT 1 """, (symbol,), ).fetchone() onchain_rows = conn.execute( """ SELECT * FROM onchain_events WHERE symbol=%s ORDER BY detected_at DESC, id DESC LIMIT 60 """, (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", } 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) events = [] for row in event_rows: item = dict(row) item["detail_json"] = _loads_json(item.get("detail_json"), {}) events.append(item) onchain_events = [dict(row) for row in onchain_rows] positive_onchain = sum(1 for row in onchain_events if row.get("direction") == "positive") risk_onchain = sum(1 for row in onchain_events if row.get("direction") == "risk") return { "symbol": symbol, "current": current, "latest_price": dict(latest_price) if latest_price else {}, "history": history, "screening": screening, "reviews": reviews, "paper_trades": [dict(row) for row in paper_rows], "paper_orders": [dict(row) for row in order_rows], "paper_events": events, "onchain": { "metric": dict(metric_row) if metric_row else {}, "events": onchain_events, "positive_count": positive_onchain, "risk_count": risk_onchain, }, "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), "onchain_event_count": len(onchain_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", ]