This commit is contained in:
aaron 2026-05-30 23:23:31 +08:00
parent 4d5a3cc235
commit 9d3201080f
4 changed files with 169 additions and 7 deletions

View File

@ -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,

View File

@ -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);
}

File diff suppressed because one or more lines are too long

View File

@ -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)