"""Recommendation and lifecycle-facing DB API.""" from datetime import datetime, timedelta from app.db.altcoin_db import ( PUSH_COOLDOWN_HOURS, _classify_recommendation_result, _derive_execution_fields, _is_actionable_execution_status, apply_recommendation_state_transition, update_recommendation_tracking, ) from app.db.schema import get_conn from app.services.llm_insights import attach_recommendation_insights def should_push(symbol: str, push_type: str, action_status: str = "") -> bool: """状态感知冷却判断。""" 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 return row[0] != action_status 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): """记录一次推送,保留推荐来源可追溯性。""" 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): """读取单条推荐并派生网站同口径展示状态,供推送层消费。""" 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 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 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 = {"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 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) 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, } __all__ = [ "apply_recommendation_state_transition", "get_active_recommendations", "get_active_recommendations_deduped", "get_recommendation_for_push", "log_push", "should_push", "update_recommendation_tracking", ]