1
This commit is contained in:
parent
4d5a3cc235
commit
9d3201080f
@ -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,
|
||||
|
||||
@ -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 (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
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user