303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""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",
|
|
]
|