1
This commit is contained in:
parent
1e3cddefd1
commit
e2127c77d1
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '') + (liveHasMore ? '<div class="load-more-row"><button class="load-more-btn" onclick="loadMoreLive()">加载更多</button></div>' : '<div class="page-hint">已加载全部实时记录</div>');
|
||||
}
|
||||
$('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 ? '<span class="price-change zero"><span class="pc-label">参考</span><span class="pc-value">'+(change>0?'+':'')+change.toFixed(1)+'%</span></span>' : '';
|
||||
return '<div class="card"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+esc(symbol)+'</span></div></div><div class="badge-group"><span class="action-badge weak">'+esc(status)+'</span><span class="score-badge tier-none"><span class="score-num">'+score+'</span><span class="score-label">评分</span></span></div></div><div class="price-bar"><span class="price">'+(price ? '$'+fmtPrice(price) : '--')+'</span>'+changeHtml+'</div><div class="decision-strip observe"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">观察</span></div><div class="decision-body"><span class="decision-focus">降级展示</span><span class="decision-reason">该候选存在兼容问题,已用安全卡片显示。</span></div></div><div class="entry-plan"><div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">观察</span><span class="ep-sub">降级展示</span></div><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+(price ? '$'+fmtPrice(price) : '--')+'</span><span class="ep-sub">基础字段</span></div><div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">--</span><span class="ep-sub">待补充</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">--</span><span class="ep-sub">安全降级</span></div></div><div class="card-footer"><span>'+fmtTime(r && r.rec_time)+'</span><span class="card-ver">'+esc((r && r.strategy_version) || '')+'</span></div></div>';
|
||||
return '<a class="card summary-card" href="'+opportunityUrl(r||{symbol:symbol})+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+esc(symbol)+'</span><div class="summary-meta">'+fmtTime(r && r.rec_time)+' · 安全降级展示</div></div></div></div><div class="summary-score"><span class="action-badge weak">'+esc(status)+'</span><span class="score-badge tier-none"><span class="score-num">'+score+'</span><span class="score-label">评分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>'+(price ? '$'+fmtPrice(price) : '--')+'</b></div><div class="summary-stat"><span>相对参考</span><b>'+esc(change==null?'--':((change>0?'+':'')+change.toFixed(1)+'%'))+'</b></div><div class="summary-stat"><span>阶段</span><b>观察</b></div><div class="summary-stat"><span>版本</span><b>'+esc((r && r.strategy_version) || '--')+'</b></div></div><div class="summary-decision observe"><h3>观察</h3><p>该候选存在兼容问题,点击进入详情查看原始记录。</p></div><div class="summary-tags"><div class="summary-chips"><span class="summary-chip">降级展示</span></div><span class="detail-link">查看详情</span></div></a>';
|
||||
}
|
||||
|
||||
function renderRecCard(r) {
|
||||
@ -922,7 +954,13 @@ function renderRecCard(r) {
|
||||
} else {
|
||||
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">观察重点</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未形成交易机会</span></div><div class="ep-item"><span class="ep-label">观察阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || '观察池候选')+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>';
|
||||
}
|
||||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group"><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+renderPaperOrderBrief(r)+renderStrategyDiagnostics(r)+signalLevelHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
||||
var detailHref = opportunityUrl(r);
|
||||
var riskText = riskLine ? fmtP(riskLine) : '--';
|
||||
var targetText = spaceRef ? fmtP(spaceRef) : '--';
|
||||
var compactSignals = sigs.slice(0,3).map(function(s){ return '<span class="summary-chip">'+displaySignalText(s)+'</span>'; }).join('');
|
||||
var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? '<span class="summary-chip">'+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'</span>' : '';
|
||||
var orderChip = r.paper_order && r.paper_order.id ? '<span class="summary-chip">挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'</span>' : '';
|
||||
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score"><span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?'计划回踩':'计划入场')+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>止损 / 目标</span><b>'+riskText+' / '+targetText+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
|
||||
} 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 '<div class=\"card\">'+
|
||||
'<div class=\"card-bar\"><div class=\"coin-left\"><div class=\"coin-icon\">'+base.slice(0,2).toUpperCase()+'</div><div><span class=\"coin-symbol\">'+base+'</span></div></div><span class=\"hist-result-badge '+resultCls+'\">'+outcomeText+'</span></div>'+
|
||||
'<div class=\"h-pnl-row\">'+(hasPaper ? '<span class=\"price h-entry-price\">$'+fmtN(entryP)+'</span><span class=\"h-arrow neutral\">→</span><span class=\"price h-exit-price '+(paper.status === 'closed' ? resultCls : 'muted')+'\">'+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'</span>' : '<span class=\"price h-entry-price muted\">未执行</span><span class=\"h-arrow neutral\">→</span><span class=\"price h-exit-price muted\">失效/归档</span>')+'<span class=\"hist-score-pill '+scoreCls+'\">总分 '+score+'</span><span class=\"h-duration\">'+duration+'</span></div>'+
|
||||
'<div class=\"hist-metric-row\"><div class=\"hist-metric\"><span class=\"hm-label\">交易阶段</span><span class=\"hm-val '+(hasPaper ? 'win' : 'blue')+'\">'+(hasPaper ? (paper.status === 'closed' ? '已完成策略交易' : '策略交易持有中') : '未执行归档')+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">结果说明</span><span class=\"hm-val blue\">'+outcomeDetail+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">执行状态</span><span class=\"hm-val '+(Number(r.entry_triggered||0)?'win':'blue')+'\">'+execText+'</span></div></div>'+
|
||||
renderStrategyDiagnostics(r)+
|
||||
'<div class=\"kline-wrap\" id=\"wrap_'+hid+'\"><div class=\"kline-int-bar\"><button class=\"kline-int-btn\" data-int=\"15m\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">15m</button><button class=\"kline-int-btn active\" data-int=\"1h\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">1H</button><button class=\"kline-int-btn\" data-int=\"4h\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">4H</button><button class=\"kline-int-btn\" data-int=\"1d\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">1D</button></div><div class=\"kline-container loading\" id=\"'+hid+'\" data-symbol=\"'+(r.symbol||'')+'\" data-entry-price=\"'+hEntryPrice+'\" data-stop-loss=\"'+hSl+'\" data-tp1=\"'+hTp+'\" data-rec-time=\"'+hEntryTime+'\" data-tp1-time=\"'+hTpTime+'\" data-sl-time=\"'+hSlTime+'\" data-ref-price=\"'+(r.current_price||hEntryPrice||hTp||hSl||0)+'\" data-status=\"'+(r.status||'')+'\" ><div class=\"chart-loading\"><svg class=\"spin\" width=\"16\" height=\"16\" color=\"#8e91a0\"><use href=\"#svg-spinner\"/></svg></div></div></div>'+
|
||||
(sigHtml?'<div class=\"signals-row\">'+sigHtml+'</div>':'')+
|
||||
'<div class=\"card-footer hist-footer\"><span>'+fmtTime(r.rec_time)+'</span><span class=\"card-ver\">'+(r.strategy_version||'')+'</span></div></div>';
|
||||
return '<a class=\"card summary-card\" href=\"'+opportunityUrl(r)+'\">'+
|
||||
'<div class=\"summary-top\"><div class=\"summary-main\"><div class=\"summary-symbol\"><div class=\"coin-icon\">'+base.slice(0,2).toUpperCase()+'</div><div><span class=\"coin-symbol\">'+base+'</span><div class=\"summary-meta\">'+fmtTime(r.rec_time)+' · '+signalStateText+' · '+duration+'</div></div></div></div><div class=\"summary-score\"><span class=\"hist-result-badge '+resultCls+'\">'+outcomeText+'</span><span class=\"score-badge '+st.cls+'\"><span class=\"score-num\">'+score+'</span><span class=\"score-label\">总分</span></span></div></div>'+
|
||||
'<div class=\"summary-price-row\"><div class=\"summary-stat\"><span>执行状态</span><b>'+(hasPaper ? (paper.status === 'closed' ? '已完成' : '持有中') : '未执行')+'</b></div><div class=\"summary-stat\"><span>入场 / 退出</span><b>'+(hasPaper ? ('$'+fmtN(entryP)+' / '+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')) : '未执行 / 归档')+'</b></div><div class=\"summary-stat\"><span>交易收益</span><b class=\"'+(afterMove>=0?'green':'red')+'\">'+(hasPaper ? afterMoveSign+afterMove.toFixed(1)+'%' : '--')+'</b></div><div class=\"summary-stat\"><span>最大浮盈 / 回撤</span><b>'+maxPnl.toFixed(1)+'% / '+maxDd.toFixed(1)+'%</b></div></div>'+
|
||||
'<div class=\"summary-decision '+(resultCls==='win'?'buy':resultCls==='loss'?'wait':'observe')+'\"><h3>'+outcomeDetail+'</h3><p>'+execText+'</p></div>'+
|
||||
'<div class=\"summary-tags\"><div class=\"summary-chips\">'+(sigHtml||'<span class=\"summary-chip\">归档样本</span>')+'</div><span class=\"detail-link\">查看详情</span></div></a>';
|
||||
}).join('');
|
||||
var loadMoreHtml = historyHasMore ? '<div class="history-load-more"><button class="load-more-btn" id="historyLoadMoreBtn" onclick="loadMoreHistory()">加载更多</button></div>' : '<div class="history-page-hint">已加载全部历史记录</div>';
|
||||
$('historyCards').innerHTML = cardsHtml + loadMoreHtml;
|
||||
// Auto-load visible history K-lines only
|
||||
setTimeout(function() { loadAllKlines('#historyCards'); }, 200);
|
||||
} catch(e) { $('historyCards').innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
|
||||
finally { historyLoading = false; }
|
||||
}
|
||||
|
||||
43
static/opportunity_detail.html
Normal file
43
static/opportunity_detail.html
Normal file
File diff suppressed because one or more lines are too long
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user