diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py
index 4444766..5b7aa3c 100644
--- a/app/db/recommendation_queries.py
+++ b/app/db/recommendation_queries.py
@@ -38,6 +38,69 @@ def _safe_symbol(value: str) -> str:
return str(value or "").strip().upper()
+def _safe_float(value, default=0.0):
+ try:
+ return float(value or 0)
+ except Exception:
+ return default
+
+
+def _trade_marker(kind: str, label: str, time_value, price_value=0, source: str = "", ref_id=0, note: str = "") -> dict | None:
+ if not time_value:
+ return None
+ return {
+ "type": kind,
+ "label": label,
+ "time": str(time_value),
+ "price": _safe_float(price_value),
+ "source": source,
+ "id": _safe_int(ref_id),
+ "note": str(note or ""),
+ }
+
+
+def _build_trade_markers(paper_rows, order_rows, events) -> list[dict]:
+ """Build chart-ready markers from strategy-trading operations."""
+ markers = []
+ event_labels = {
+ "open": ("open", "开仓"),
+ "close": ("close", "平仓"),
+ "trailing_activate": ("trailing", "移盈启"),
+ "trailing_move": ("trailing", "移盈上"),
+ }
+ for row in events or []:
+ event_type = str(row.get("event_type") or "").strip()
+ kind, label = event_labels.get(event_type, ("event", event_type or "操作"))
+ markers.append(_trade_marker(kind, label, row.get("event_time"), row.get("price"), "paper_event", row.get("id"), row.get("message")))
+
+ for row in order_rows or []:
+ markers.append(_trade_marker("order", "挂单", row.get("created_at"), row.get("target_price"), "paper_order", row.get("id"), row.get("status")))
+ if row.get("filled_at"):
+ markers.append(_trade_marker("fill", "成交", row.get("filled_at"), row.get("fill_price") or row.get("target_price"), "paper_order", row.get("id"), "挂单成交"))
+ end_time = row.get("canceled_at")
+ if end_time and row.get("status") in ("canceled", "expired", "rejected"):
+ label = {"canceled": "撤单", "expired": "过期", "rejected": "拒绝"}.get(row.get("status"), "撤单")
+ markers.append(_trade_marker("cancel", label, end_time, row.get("target_price"), "paper_order", row.get("id"), row.get("cancel_reason")))
+
+ for row in paper_rows or []:
+ markers.append(_trade_marker("open", "开仓", row.get("opened_at"), row.get("entry_price"), "paper_trade", row.get("id"), row.get("source_status")))
+ if row.get("closed_at"):
+ markers.append(_trade_marker("close", "平仓", row.get("closed_at"), row.get("exit_price"), "paper_trade", row.get("id"), row.get("exit_reason")))
+
+ seen = set()
+ result = []
+ for marker in markers:
+ if not marker:
+ continue
+ key = (marker["type"], marker["time"], round(marker["price"], 10), marker["source"], marker["id"])
+ if key in seen:
+ continue
+ seen.add(key)
+ result.append(marker)
+ result.sort(key=lambda x: x.get("time") or "")
+ return result[-120:]
+
+
def _attach_paper_order(item: dict) -> dict:
order_id = item.get("paper_order_id")
if not order_id:
@@ -490,6 +553,8 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
item["hit_signals"] = _loads_json(item.get("hit_signals"), [])
item["miss_signals"] = _loads_json(item.get("miss_signals"), [])
reviews.append(item)
+ paper_trades = [dict(row) for row in paper_rows]
+ paper_orders = [dict(row) for row in order_rows]
events = []
for row in event_rows:
item = dict(row)
@@ -505,9 +570,10 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
"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_trades": paper_trades,
+ "paper_orders": paper_orders,
"paper_events": events,
+ "trade_markers": _build_trade_markers(paper_trades, paper_orders, events),
"onchain": {
"metric": dict(metric_row) if metric_row else {},
"events": onchain_events,
diff --git a/static/chart_widgets.js b/static/chart_widgets.js
index 862b495..4b206df 100644
--- a/static/chart_widgets.js
+++ b/static/chart_widgets.js
@@ -340,6 +340,17 @@ window.AlphaXCharts = (function () {
var tp1Time = payload && payload.tp1Time ? Date.parse(payload.tp1Time) : 0;
var slTime = payload && payload.slTime ? Date.parse(payload.slTime) : 0;
var actionStatus = (payload && payload.actionStatus) || "";
+ var tradeMarkers = ((payload && payload.tradeMarkers) || []).map(function (marker) {
+ return {
+ time: marker && marker.time ? Date.parse(marker.time) : 0,
+ price: Number((marker && marker.price) || 0),
+ label: String((marker && marker.label) || "操作"),
+ type: String((marker && marker.type) || "event"),
+ note: String((marker && marker.note) || "")
+ };
+ }).filter(function (marker) {
+ return marker.time > 0;
+ });
var refPrice = Number((payload && payload.refPrice) || entryPrice || candles[candles.length - 1].close || 0);
var decimals = priceDecimals(refPrice || entryPrice || candles[candles.length - 1].close);
@@ -364,6 +375,12 @@ window.AlphaXCharts = (function () {
maxPrice = Math.max(maxPrice, p);
}
});
+ tradeMarkers.forEach(function (marker) {
+ if (marker.price > 0) {
+ minPrice = Math.min(minPrice, marker.price);
+ maxPrice = Math.max(maxPrice, marker.price);
+ }
+ });
var padding = Math.max((maxPrice - minPrice) * 0.06, maxPrice * 0.001);
minPrice -= padding;
maxPrice += padding;
@@ -509,6 +526,59 @@ window.AlphaXCharts = (function () {
}
}
+ function markerColorFor(marker) {
+ var type = String((marker && marker.type) || "");
+ if (type === "open" || type === "fill") return color("--green", "#00b473");
+ if (type === "close") return color("--blue", "#4262ff");
+ if (type === "cancel") return color("--red", "#e53e3e");
+ if (type === "trailing") return "#a05a00";
+ if (type === "order") return color("--yellow-deep", "#fcb900");
+ return color("--stone", "#8e91a0");
+ }
+
+ function drawTradeMarker(m, marker, stack) {
+ var idx = nearestIndexFromTime(marker.time);
+ if (idx < 0) return;
+ var px = x(idx, m);
+ var rawY = marker.price > 0 ? y(marker.price, m) : y(candles[idx].close, m);
+ var py = Math.max(m.padT + 16, Math.min(m.padT + m.chartH - 12, rawY));
+ var markerColor = markerColorFor(marker);
+ var label = String(marker.label || "操作").slice(0, 4);
+ var labelY = Math.max(m.padT + 10, py - 20 - (stack % 3) * 18);
+ ctx.save();
+ ctx.strokeStyle = markerColor;
+ ctx.globalAlpha = 0.78;
+ ctx.setLineDash([3, 4]);
+ ctx.beginPath();
+ ctx.moveTo(px, m.padT);
+ ctx.lineTo(px, m.h - m.padB - m.volH + 10);
+ ctx.stroke();
+ ctx.restore();
+
+ ctx.beginPath();
+ ctx.arc(px, py, 4.5, 0, Math.PI * 2);
+ ctx.fillStyle = markerColor;
+ ctx.fill();
+ ctx.strokeStyle = "rgba(255,255,255,.92)";
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ ctx.font = "950 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
+ var tw = ctx.measureText(label).width + 12;
+ var x0 = Math.max(m.padL + 2, Math.min(m.w - m.padR - tw - 2, px - tw / 2));
+ ctx.fillStyle = "rgba(255,255,255,.92)";
+ ctx.strokeStyle = markerColor;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ roundedRect(ctx, x0, labelY - 9, tw, 18, 9);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = markerColor;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(label, x0 + tw / 2, labelY);
+ }
+
function drawLabels(m) {
ctx.fillStyle = color("--stone", "#8e91a0");
ctx.font = "850 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
@@ -554,9 +624,20 @@ window.AlphaXCharts = (function () {
drawPriceMarker(m, entryPrice, actionStatus === "等回踩" ? "回踩" : "入场", color("--blue", "#4262ff"));
drawPriceMarker(m, stopLoss, "SL", color("--red", "#e53e3e"));
drawCandles(m);
- if (recTime) drawEventMarker(m, recTime, actionStatus === "等回踩" ? "△" : "▲", actionStatus === "等回踩" ? "#8e91a0" : color("--blue", "#4262ff"), entryPrice);
- if (tp1Time) drawEventMarker(m, tp1Time, "✓", color("--green", "#00b473"), tp1);
- if (slTime) drawEventMarker(m, slTime, "×", color("--red", "#e53e3e"), stopLoss);
+ if (tradeMarkers.length) {
+ var stacks = {};
+ tradeMarkers.forEach(function (marker) {
+ var idx = nearestIndexFromTime(marker.time);
+ if (idx < 0) return;
+ stacks[idx] = stacks[idx] || 0;
+ drawTradeMarker(m, marker, stacks[idx]);
+ stacks[idx] += 1;
+ });
+ } else {
+ if (recTime) drawEventMarker(m, recTime, actionStatus === "等回踩" ? "△" : "▲", actionStatus === "等回踩" ? "#8e91a0" : color("--blue", "#4262ff"), entryPrice);
+ if (tp1Time) drawEventMarker(m, tp1Time, "✓", color("--green", "#00b473"), tp1);
+ if (slTime) drawEventMarker(m, slTime, "×", color("--red", "#e53e3e"), stopLoss);
+ }
drawFocus(m);
drawLabels(m);
}
diff --git a/static/opportunity_detail.html b/static/opportunity_detail.html
index b662861..3aae941 100644
--- a/static/opportunity_detail.html
+++ b/static/opportunity_detail.html
@@ -34,8 +34,8 @@ function decisionLog(r){var ep=r.entry_plan||{},mc=r.market_context||{};return e
function signals(r){return Array.isArray(r.signal_labels)&&r.signal_labels.length?r.signal_labels:(Array.isArray(r.signals)?r.signals:[])}
function aiInsight(r){return r.llm_insight&&r.llm_insight.content?r.llm_insight.content:null}
function renderRows(rows,opts){opts=opts||{};if(!rows||!rows.length)return'
'+(opts.empty||'暂无数据')+'
';return ''+rows.map(function(x){return '
'+esc(opts.time?opts.time(x):'--')+'
'+esc(opts.title?opts.title(x):'--')+'
'+esc(opts.sub?opts.sub(x):'')+'
'+esc(opts.val?opts.val(x):'')+'
'}).join('')+'
'}
-function renderDetail(d){var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),ep=r.entry_plan||{},entry=Number(ep.entry_price||r.entry_price||0),stop=Number(ep.stop_loss||r.stop_loss||0),tp1=Number(ep.tp1||ep.take_profit_1||r.tp1||0),sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r);$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&tp1&&stop?((tp1-entry)/(entry-stop)).toFixed(2):'--';var chg=entry&¤t?((current/entry-1)*100):0;var aiHtml=ai?''+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'暂无 AI 解读
';var onchain=d.onchain||{};var metric=onchain.metric||{};var root=''+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+esc(statusLabel(r))+' 总分 '+esc(r.rec_score||0)+' '+esc(r.strategy_version||'--')+'
当前价 '+price(current)+'
相对参考 '+pct(chg)+'
计划入场 '+price(entry)+'
止损 / 止盈 '+price(stop)+' / '+price(tp1)+'
盈亏比 '+esc(rr)+'
当前结论 '+esc(statusLabel(r))+'
原因 '+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'
入场模型 '+esc(clean(ep.entry_model||ep.entry_method||'--'))+'
失效条件 '+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'
机会分 '+num(sc.opportunity_score||0)+'
买点分 '+num(sc.entry_score||0)+'
风险扣分 '+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+' '}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
市场环境
'+esc(rg.label||rg.regime||'--')+'
'+(Array.isArray(rg.reasons)?rg.reasons:['市场环境已进入推荐上下文']).slice(0,8).map(function(s){return''+esc(clean(s))+' '}).join('')+'
链上与外部证据
正向 '+esc(onchain.positive_count||0)+' · 风险 '+esc(onchain.risk_count||0)+'
链上分 '+num(metric.onchain_score||0)+'
风险分 '+num(metric.risk_score||0)+'
'+renderRows(onchain.events,{empty:'暂无链上事件',time:function(x){return fmtTime(x.detected_at)},title:function(x){return x.signal_label||x.signal_code||'链上事件'},sub:function(x){return [x.chain,x.direction,x.tx_hash].filter(Boolean).join(' · ')},val:function(x){return x.value_usd?('$'+num(x.value_usd)):''},cls:function(x){return x.direction==='risk'?'red':'green'}})+'
'+renderRows(d.paper_orders,{empty:'暂无挂单',time:function(x){return fmtTime(x.created_at)},title:function(x){return '挂单 · '+(x.status||'--')},sub:function(x){return '目标 '+price(x.target_price)+' · 当前 '+price(x.current_price_at_create)},val:function(x){return x.cancel_reason||x.status||''}})+'
'+renderRows(d.paper_trades,{empty:'暂无持仓交易',time:function(x){return fmtTime(x.opened_at)},title:function(x){return '交易 · '+(x.status||'--')},sub:function(x){return '入场 '+price(x.entry_price)+' · 当前 '+price(x.current_price)},val:function(x){return pct(x.pnl_pct||x.realized_pnl_pct)},cls:function(x){return Number(x.pnl_pct||x.realized_pnl_pct||0)>=0?'green':'red'}})+'
'+renderRows(d.paper_events,{empty:'暂无交易事件',time:function(x){return fmtTime(x.event_time)},title:function(x){return x.event_type||'事件'},sub:function(x){return x.message||'--'},val:function(x){return price(x.price)}})+'
复盘记录
'+esc((d.summary||{}).review_count||0)+' 条
'+renderRows(d.reviews,{empty:'暂无复盘',time:function(x){return fmtTime(x.review_time)},title:function(x){return x.outcome||'复盘'},sub:function(x){return x.lesson||'--'},val:function(x){return pct(x.pnl_48h||0)},cls:function(x){return Number(x.pnl_48h||0)>=0?'green':'red'}})+'
';$('detailRoot').innerHTML=root;loadKline()}
-function loadKline(){var c=$('kline');if(!c)return;var active=document.querySelector('.kline-int-btn.active');var interval=active?active.dataset.int:'1h';fetch(API+'/api/kline?symbol='+encodeURIComponent(c.dataset.symbol)+'&interval='+interval+'&limit=100').then(function(r){return r.json()}).then(function(resp){var candles=resp.candles||[];if(!window.AlphaXCharts||!window.AlphaXCharts.renderKline)throw new Error('chart unavailable');c.innerHTML='';window.AlphaXCharts.renderKline(c,{symbol:c.dataset.symbol,candles:candles,entryPrice:Number(c.dataset.entryPrice||0),stopLoss:Number(c.dataset.stopLoss||0),tp1:Number(c.dataset.tp1||0),recTime:c.dataset.recTime||'',refPrice:Number(c.dataset.refPrice||0)});c.classList.remove('loading')}).catch(function(){c.innerHTML='K线加载失败
'})}
+function renderDetail(d){window.__opportunityDetail=d||{};var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),ep=r.entry_plan||{},entry=Number(ep.entry_price||r.entry_price||0),stop=Number(ep.stop_loss||r.stop_loss||0),tp1=Number(ep.tp1||ep.take_profit_1||r.tp1||0),sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r);$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&tp1&&stop?((tp1-entry)/(entry-stop)).toFixed(2):'--';var chg=entry&¤t?((current/entry-1)*100):0;var aiHtml=ai?''+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'暂无 AI 解读
';var onchain=d.onchain||{};var metric=onchain.metric||{};var markerCount=(d.trade_markers||[]).length;var root=''+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+esc(statusLabel(r))+' 总分 '+esc(r.rec_score||0)+' '+esc(r.strategy_version||'--')+'
当前价 '+price(current)+'
相对参考 '+pct(chg)+'
计划入场 '+price(entry)+'
止损 / 止盈 '+price(stop)+' / '+price(tp1)+'
盈亏比 '+esc(rr)+'
多周期 K 线
'+(markerCount?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
当前结论 '+esc(statusLabel(r))+'
原因 '+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'
入场模型 '+esc(clean(ep.entry_model||ep.entry_method||'--'))+'
失效条件 '+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'
机会分 '+num(sc.opportunity_score||0)+'
买点分 '+num(sc.entry_score||0)+'
风险扣分 '+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+' '}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
市场环境
'+esc(rg.label||rg.regime||'--')+'
'+(Array.isArray(rg.reasons)?rg.reasons:['市场环境已进入推荐上下文']).slice(0,8).map(function(s){return''+esc(clean(s))+' '}).join('')+'
链上与外部证据
正向 '+esc(onchain.positive_count||0)+' · 风险 '+esc(onchain.risk_count||0)+'
链上分 '+num(metric.onchain_score||0)+'
风险分 '+num(metric.risk_score||0)+'
'+renderRows(onchain.events,{empty:'暂无链上事件',time:function(x){return fmtTime(x.detected_at)},title:function(x){return x.signal_label||x.signal_code||'链上事件'},sub:function(x){return [x.chain,x.direction,x.tx_hash].filter(Boolean).join(' · ')},val:function(x){return x.value_usd?('$'+num(x.value_usd)):''},cls:function(x){return x.direction==='risk'?'red':'green'}})+'
'+renderRows(d.paper_orders,{empty:'暂无挂单',time:function(x){return fmtTime(x.created_at)},title:function(x){return '挂单 · '+(x.status||'--')},sub:function(x){return '目标 '+price(x.target_price)+' · 当前 '+price(x.current_price_at_create)},val:function(x){return x.cancel_reason||x.status||''}})+'
'+renderRows(d.paper_trades,{empty:'暂无持仓交易',time:function(x){return fmtTime(x.opened_at)},title:function(x){return '交易 · '+(x.status||'--')},sub:function(x){return '入场 '+price(x.entry_price)+' · 当前 '+price(x.current_price)},val:function(x){return pct(x.pnl_pct||x.realized_pnl_pct)},cls:function(x){return Number(x.pnl_pct||x.realized_pnl_pct||0)>=0?'green':'red'}})+'
'+renderRows(d.paper_events,{empty:'暂无交易事件',time:function(x){return fmtTime(x.event_time)},title:function(x){return x.event_type||'事件'},sub:function(x){return x.message||'--'},val:function(x){return price(x.price)}})+'
复盘记录
'+esc((d.summary||{}).review_count||0)+' 条
'+renderRows(d.reviews,{empty:'暂无复盘',time:function(x){return fmtTime(x.review_time)},title:function(x){return x.outcome||'复盘'},sub:function(x){return x.lesson||'--'},val:function(x){return pct(x.pnl_48h||0)},cls:function(x){return Number(x.pnl_48h||0)>=0?'green':'red'}})+'
';$('detailRoot').innerHTML=root;loadKline()}
+function loadKline(){var c=$('kline');if(!c)return;var active=document.querySelector('.kline-int-btn.active');var interval=active?active.dataset.int:'1h';fetch(API+'/api/kline?symbol='+encodeURIComponent(c.dataset.symbol)+'&interval='+interval+'&limit=100').then(function(r){return r.json()}).then(function(resp){var candles=resp.candles||[];if(!window.AlphaXCharts||!window.AlphaXCharts.renderKline)throw new Error('chart unavailable');c.innerHTML='';window.AlphaXCharts.renderKline(c,{symbol:c.dataset.symbol,candles:candles,entryPrice:Number(c.dataset.entryPrice||0),stopLoss:Number(c.dataset.stopLoss||0),tp1:Number(c.dataset.tp1||0),recTime:c.dataset.recTime||'',refPrice:Number(c.dataset.refPrice||0),tradeMarkers:(window.__opportunityDetail&&window.__opportunityDetail.trade_markers)||[]});c.classList.remove('loading')}).catch(function(){c.innerHTML='K线加载失败
'})}
function switchKline(btn){document.querySelectorAll('.kline-int-btn').forEach(function(b){b.classList.remove('active')});btn.classList.add('active');var c=$('kline');c.classList.add('loading');c.innerHTML='加载 K 线...
';loadKline()}
async function load(){var q=new URLSearchParams(location.search);var recId=q.get('rec_id')||'';var symbol=q.get('symbol')||'';var url=API+'/api/opportunity/detail?symbol='+encodeURIComponent(symbol)+'&rec_id='+encodeURIComponent(recId);try{var d=await (await fetch(url)).json();if(d.error){$('detailRoot').innerHTML='没有找到该机会
';return}renderDetail(d)}catch(e){$('detailRoot').innerHTML='机会详情加载失败
'}}
load();
diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py
index b0973da..0d99e70 100644
--- a/tests/test_paper_trading.py
+++ b/tests/test_paper_trading.py
@@ -17,6 +17,7 @@ from app.db.paper_trading import (
sync_pending_paper_orders,
sync_recommendation,
)
+from app.db.recommendation_queries import get_opportunity_detail
def _visible_card_text(card: dict) -> str:
@@ -104,6 +105,20 @@ def test_buy_now_opens_paper_trade_once(buy_now_rec):
assert trades[0]["pnl_pct"] == pytest.approx(1.0)
+def test_opportunity_detail_exposes_strategy_trade_markers(buy_now_rec):
+ sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
+ sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
+
+ detail = get_opportunity_detail(symbol="PAPER/USDT", rec_id=buy_now_rec["id"])
+ markers = detail["trade_markers"]
+
+ labels = [item["label"] for item in markers]
+ assert "开仓" in labels
+ assert "平仓" in labels
+ assert all(item["time"] for item in markers)
+ assert all(item["source"] in {"paper_event", "paper_trade", "paper_order"} for item in markers)
+
+
def test_default_paper_trade_uses_5000u_notional_5x_and_1000u_margin(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
monkeypatch.delenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", raising=False)