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 ? '
另有 '+weakCount+' 个弱观察候选已收起。
' : '') + (liveHasMore ? '
' : '
已加载全部实时记录
'); } $('liveCount').textContent = ''; - // Load K-lines after DOM has fully settled - setTimeout(function() { loadAllKlines('#liveCards'); }, 150); } catch(e) { console.error('loadContent failed', e); var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); }); @@ -791,7 +823,7 @@ function renderLiveFallbackCard(r) { var entry = Number((r && r.entry_price) || 0); var change = price && entry ? ((price - entry) / entry * 100) : null; var changeHtml = change != null ? '参考'+(change>0?'+':'')+change.toFixed(1)+'%' : ''; - return '
'+base.slice(0,2).toUpperCase()+'
'+esc(symbol)+'
'+esc(status)+''+score+'评分
'+(price ? '$'+fmtPrice(price) : '--')+''+changeHtml+'
最终建议观察
降级展示该候选存在兼容问题,已用安全卡片显示。
阶段观察降级展示
当前参考'+(price ? '$'+fmtPrice(price) : '--')+'基础字段
确认条件--待补充
绩效口径--安全降级
'; + return '
'+base.slice(0,2).toUpperCase()+'
'+esc(symbol)+'
'+fmtTime(r && r.rec_time)+' · 安全降级展示
'+esc(status)+''+score+'评分
当前价'+(price ? '$'+fmtPrice(price) : '--')+'
相对参考'+esc(change==null?'--':((change>0?'+':'')+change.toFixed(1)+'%'))+'
阶段观察
版本'+esc((r && r.strategy_version) || '--')+'

观察

该候选存在兼容问题,点击进入详情查看原始记录。

降级展示
查看详情
'; } function renderRecCard(r) { @@ -922,7 +954,13 @@ function renderRecCard(r) { } else { entryPlanHtml = '
当前参考'+fmtP(price)+'不是入场价
观察重点待触发'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'
绩效口径不计入未形成交易机会
观察阶段'+cleanDisplayText(horizon || '观察池候选')+''+cleanDisplayText(levelLabel)+'
'; } - return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+score+'总分
$'+priceFmt+''+changeHtml+'
'+decisionHtml+renderPaperOrderBrief(r)+renderStrategyDiagnostics(r)+signalLevelHtml+onchainHtml+aiInsightHtml+'
'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'
'+sigHtml+'
':'')+'
'; + var detailHref = opportunityUrl(r); + var riskText = riskLine ? fmtP(riskLine) : '--'; + var targetText = spaceRef ? fmtP(spaceRef) : '--'; + var compactSignals = sigs.slice(0,3).map(function(s){ return ''+displaySignalText(s)+''; }).join(''); + var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? ''+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'' : ''; + var orderChip = r.paper_order && r.paper_order.id ? '挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'' : ''; + return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+fmtTime(r.rec_time)+' · '+cleanDisplayText(levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'
'+phase.label+''+score+'总分
当前价$'+priceFmt+'
'+changeLabel+''+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'
'+(isWait?'计划回踩':'计划入场')+''+fmtP(entryRef || price)+'
止损 / 目标'+riskText+' / '+targetText+'

'+decisionTitle+' · '+decisionFocus+'

'+decisionReason+'

'+(compactSignals||'暂无明确信号')+onchainChip+orderChip+'
查看详情
'; } catch (e) { console.error('renderRecCard hard fail', r && r.symbol, e); return renderLiveFallbackCard(r); @@ -1130,19 +1168,14 @@ async function loadHistoryRecommendations(reset) { var signalStateText = hasPaper ? '已执行归档' : (historyArchiveFilter === 'invalid' ? '失效归档' : '未执行归档'); var outcomeText = outcome.label; var outcomeDetail = outcome.detail; - return '
'+ - '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+outcomeText+'
'+ - '
'+(hasPaper ? '$'+fmtN(entryP)+''+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'' : '未执行失效/归档')+'总分 '+score+''+duration+'
'+ - '
交易阶段'+(hasPaper ? (paper.status === 'closed' ? '已完成策略交易' : '策略交易持有中') : '未执行归档')+'
结果说明'+outcomeDetail+'
执行状态'+execText+'
'+ - renderStrategyDiagnostics(r)+ - '
'+ - (sigHtml?'
'+sigHtml+'
':'')+ - '
'; + return ''+ + '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+fmtTime(r.rec_time)+' · '+signalStateText+' · '+duration+'
'+outcomeText+''+score+'总分
'+ + '
执行状态'+(hasPaper ? (paper.status === 'closed' ? '已完成' : '持有中') : '未执行')+'
入场 / 退出'+(hasPaper ? ('$'+fmtN(entryP)+' / '+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')) : '未执行 / 归档')+'
交易收益=0?'green':'red')+'\">'+(hasPaper ? afterMoveSign+afterMove.toFixed(1)+'%' : '--')+'
最大浮盈 / 回撤'+maxPnl.toFixed(1)+'% / '+maxDd.toFixed(1)+'%
'+ + '

'+outcomeDetail+'

'+execText+'

'+ + '
'+(sigHtml||'归档样本')+'
查看详情
'; }).join(''); var loadMoreHtml = historyHasMore ? '
' : '
已加载全部历史记录
'; $('historyCards').innerHTML = cardsHtml + loadMoreHtml; - // Auto-load visible history K-lines only - setTimeout(function() { loadAllKlines('#historyCards'); }, 200); } catch(e) { $('historyCards').innerHTML = '

加载失败

'; } finally { historyLoading = false; } } diff --git a/static/opportunity_detail.html b/static/opportunity_detail.html new file mode 100644 index 0000000..d1866bf --- /dev/null +++ b/static/opportunity_detail.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}AlphaX Agent — 机会详情{% endblock %} +{% block extra_head_css %} + +{% endblock %} +{% block content %} +
+
+ +
--
+
+
加载机会详情...
+
+{% endblock %} +{% block extra_script %} + + +{% endblock %} diff --git a/tests/test_pipeline_runs_api.py b/tests/test_pipeline_runs_api.py index 0b57ad6..97f9e7b 100644 --- a/tests/test_pipeline_runs_api.py +++ b/tests/test_pipeline_runs_api.py @@ -345,6 +345,15 @@ def test_pipeline_page_filters_missed_rows_as_missed(temp_db): assert "['missed','漏选'" in html +def test_opportunity_detail_page_available(temp_db): + client = TestClient(web_server.app) + + resp = client.get("/opportunity?symbol=AAA%2FUSDT") + assert resp.status_code == 200 + assert "机会详情" in resp.text + assert "/api/opportunity/detail" in resp.text + + def test_user_nav_keeps_review_center_but_hides_legacy_research_pages(temp_db): client = TestClient(web_server.app) resp = client.get("/app")