From e2127c77d1ac71511ade126c5e483cf9816a73e7 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Mon, 25 May 2026 11:32:12 +0800 Subject: [PATCH] 1 --- app/db/recommendation_queries.py | 233 +++++++++++++++++++++++++++++- app/web/routes_pages.py | 7 + app/web/routes_recommendations.py | 11 +- static/app.html | 61 ++++++-- static/opportunity_detail.html | 43 ++++++ tests/test_pipeline_runs_api.py | 9 ++ 6 files changed, 343 insertions(+), 21 deletions(-) create mode 100644 static/opportunity_detail.html diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py index 02f4e0b..d0b1859 100644 --- a/app/db/recommendation_queries.py +++ b/app/db/recommendation_queries.py @@ -1,5 +1,6 @@ """Recommendation and lifecycle-facing DB API.""" +import json from datetime import datetime, timedelta from app.db.recommendation_commands import apply_recommendation_state_transition @@ -14,6 +15,28 @@ 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: @@ -45,6 +68,23 @@ def _attach_paper_order(item: dict) -> dict: 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() @@ -234,12 +274,7 @@ def get_active_recommendations_deduped( } 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) - _attach_paper_order(item) + item = _decorate_recommendation(dict(row)) is_expired = False if hours > 0: @@ -295,10 +330,196 @@ def get_active_recommendations_deduped( "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", diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py index 774a5f4..ff7bc88 100644 --- a/app/web/routes_pages.py +++ b/app/web/routes_pages.py @@ -198,6 +198,13 @@ def build_router(templates, repo_root: Path, stock_report_template: str): resp.headers["Expires"] = "0" return resp + @router.get("/opportunity", response_class=HTMLResponse) + async def opportunity_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + return render_page("opportunity_detail.html", request, active_nav="app") + @router.get("/market", response_class=HTMLResponse) async def market_page(request: Request): user, redirect = require_page_user(request) diff --git a/app/web/routes_recommendations.py b/app/web/routes_recommendations.py index 348d5ce..db6cf4c 100644 --- a/app/web/routes_recommendations.py +++ b/app/web/routes_recommendations.py @@ -13,7 +13,7 @@ from app.db.analytics import ( get_stats, ) from app.db.llm_insights import get_llm_insight_by_id, list_llm_insights -from app.db.recommendation_queries import get_active_recommendations, get_active_recommendations_deduped +from app.db.recommendation_queries import get_active_recommendations, get_active_recommendations_deduped, get_opportunity_detail from app.db.short_tf_signals import get_short_tf_signal_review from app.config.config_loader import get_signal_weights from app.web.shared import ( @@ -114,6 +114,15 @@ async def api_recommendations_active( return get_active_recommendations(actionable_only=actionable_only) +@router.get("/api/opportunity/detail") +async def api_opportunity_detail(symbol: str = "", rec_id: int = 0, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + detail = get_opportunity_detail(symbol=symbol, rec_id=rec_id) + if not detail: + return {"error": "opportunity not found", "symbol": symbol, "rec_id": rec_id} + return detail + + @router.get("/api/observations/active") async def api_observations_active( limit: int = 50, diff --git a/static/app.html b/static/app.html index f3a1723..b13112a 100644 --- a/static/app.html +++ b/static/app.html @@ -75,6 +75,29 @@ @media(max-width:820px){ .cards{ grid-template-columns: 1fr; } } .card { border: 1px solid var(--hairline-soft); background: var(--canvas); border-radius: var(--radius-xl); overflow: hidden; transition: .18s; cursor: pointer; } .card:hover { border-color: var(--hairline); box-shadow: 0 4px 12px rgba(5,0,56,.04); } +.card.summary-card { padding: 16px; display: grid; gap: 12px; } +.summary-top { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.summary-main { min-width:0; } +.summary-symbol { display:flex; align-items:center; gap:10px; min-width:0; } +.summary-symbol .coin-symbol { font-size:18px; } +.summary-meta { margin-top:5px; color:var(--stone); font-size:11px; font-weight:800; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.summary-score { display:flex; align-items:center; gap:8px; flex-shrink:0; } +.summary-price-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; } +.summary-stat { border:1px solid var(--hairline-soft); background:var(--surface); border-radius:var(--radius-lg); padding:9px 10px; min-width:0; } +.summary-stat span { display:block; color:var(--stone); font-size:10px; font-weight:900; line-height:1.2; } +.summary-stat b { display:block; margin-top:4px; color:var(--ink); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; font-weight:950; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.summary-stat b.green { color:var(--green); } +.summary-stat b.red { color:var(--red); } +.summary-decision { border:1px solid var(--hairline-soft); background:var(--surface); border-radius:var(--radius-lg); padding:10px 12px; display:grid; gap:3px; } +.summary-decision.buy { background:var(--green-light); border-color:rgba(0,180,115,.18); } +.summary-decision.wait { background:var(--yellow-light); border-color:rgba(252,185,0,.24); } +.summary-decision.observe,.summary-decision.weak { background:rgba(66,98,255,.045); border-color:rgba(66,98,255,.14); } +.summary-decision h3 { font-size:13px; font-weight:950; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.summary-decision p { color:var(--stone); font-size:11px; font-weight:800; line-height:1.4; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.summary-tags { display:flex; align-items:center; justify-content:space-between; gap:10px; border-top:1px solid var(--hairline-soft); padding-top:10px; } +.summary-chips { display:flex; gap:5px; flex-wrap:wrap; min-width:0; } +.summary-chip { display:inline-flex; border:1px solid var(--hairline-soft); background:var(--surface); border-radius:999px; padding:4px 8px; color:var(--slate); font-size:11px; font-weight:850; max-width:150px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.detail-link { color:var(--blue); font-size:12px; font-weight:950; white-space:nowrap; } .card-bar { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 0; gap: 8px; } .coin-left { display: flex; align-items: center; gap: 10px; min-width: 0; } .coin-icon { width: 36px; height: 36px; border-radius: var(--radius-md); background: var(--surface); display: grid; place-items: center; font-weight: 800; font-size: 12px; color: var(--steel); border: 1px solid var(--hairline); flex-shrink: 0; } @@ -292,6 +315,9 @@ .signal-level-strip { grid-template-columns: 1fr; margin: 0 14px 8px; } .entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; } .decision-strip { margin: 0 14px 8px; grid-template-columns: 86px minmax(0,1fr); } + .summary-price-row { grid-template-columns:repeat(2,minmax(0,1fr)); } + .summary-top { display:block; } + .summary-score { margin-top:10px; } .onchain-brief { margin: 0 14px 8px; } .strategy-diagnostics { margin: 0 14px 8px; } .score-split { grid-template-columns: 1fr; } @@ -624,6 +650,14 @@ function fmtPrice(p, decimals) { var d = decimals == null ? priceDecimals(p) : decimals; return p.toFixed(d); } +function opportunityUrl(r) { + var symbol = encodeURIComponent((r && r.symbol) || ''); + var recId = encodeURIComponent((r && r.id) || ''); + return '/opportunity?symbol=' + symbol + (recId ? '&rec_id=' + recId : ''); +} +function goOpportunity(r) { + location.href = opportunityUrl(r); +} // ====== LIVE ====== function isExpiredRec(r) { @@ -680,8 +714,6 @@ async function loadContent(reset) { $('liveCards').innerHTML = fallbackItems.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount && !currentFilter ? '
该候选存在兼容问题,点击进入详情查看原始记录。
'+decisionReason+'
'+execText+'
加载失败