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)+'
多周期 K 线
计划价、止损、止盈会在图上标注
加载 K 线...
决策与计划
列表页只保留摘要,这里看完整依据

当前结论

'+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}})+'
';$('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?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
加载 K 线...
决策与计划
列表页只保留摘要,这里看完整依据

当前结论

'+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}})+'
';$('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)