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()
|
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:
|
def _attach_paper_order(item: dict) -> dict:
|
||||||
order_id = item.get("paper_order_id")
|
order_id = item.get("paper_order_id")
|
||||||
if not 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["hit_signals"] = _loads_json(item.get("hit_signals"), [])
|
||||||
item["miss_signals"] = _loads_json(item.get("miss_signals"), [])
|
item["miss_signals"] = _loads_json(item.get("miss_signals"), [])
|
||||||
reviews.append(item)
|
reviews.append(item)
|
||||||
|
paper_trades = [dict(row) for row in paper_rows]
|
||||||
|
paper_orders = [dict(row) for row in order_rows]
|
||||||
events = []
|
events = []
|
||||||
for row in event_rows:
|
for row in event_rows:
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
@ -505,9 +570,10 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
|
|||||||
"history": history,
|
"history": history,
|
||||||
"screening": screening,
|
"screening": screening,
|
||||||
"reviews": reviews,
|
"reviews": reviews,
|
||||||
"paper_trades": [dict(row) for row in paper_rows],
|
"paper_trades": paper_trades,
|
||||||
"paper_orders": [dict(row) for row in order_rows],
|
"paper_orders": paper_orders,
|
||||||
"paper_events": events,
|
"paper_events": events,
|
||||||
|
"trade_markers": _build_trade_markers(paper_trades, paper_orders, events),
|
||||||
"onchain": {
|
"onchain": {
|
||||||
"metric": dict(metric_row) if metric_row else {},
|
"metric": dict(metric_row) if metric_row else {},
|
||||||
"events": onchain_events,
|
"events": onchain_events,
|
||||||
|
|||||||
@ -340,6 +340,17 @@ window.AlphaXCharts = (function () {
|
|||||||
var tp1Time = payload && payload.tp1Time ? Date.parse(payload.tp1Time) : 0;
|
var tp1Time = payload && payload.tp1Time ? Date.parse(payload.tp1Time) : 0;
|
||||||
var slTime = payload && payload.slTime ? Date.parse(payload.slTime) : 0;
|
var slTime = payload && payload.slTime ? Date.parse(payload.slTime) : 0;
|
||||||
var actionStatus = (payload && payload.actionStatus) || "";
|
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 refPrice = Number((payload && payload.refPrice) || entryPrice || candles[candles.length - 1].close || 0);
|
||||||
var decimals = priceDecimals(refPrice || entryPrice || candles[candles.length - 1].close);
|
var decimals = priceDecimals(refPrice || entryPrice || candles[candles.length - 1].close);
|
||||||
|
|
||||||
@ -364,6 +375,12 @@ window.AlphaXCharts = (function () {
|
|||||||
maxPrice = Math.max(maxPrice, p);
|
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);
|
var padding = Math.max((maxPrice - minPrice) * 0.06, maxPrice * 0.001);
|
||||||
minPrice -= padding;
|
minPrice -= padding;
|
||||||
maxPrice += 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) {
|
function drawLabels(m) {
|
||||||
ctx.fillStyle = color("--stone", "#8e91a0");
|
ctx.fillStyle = color("--stone", "#8e91a0");
|
||||||
ctx.font = "850 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
|
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, entryPrice, actionStatus === "等回踩" ? "回踩" : "入场", color("--blue", "#4262ff"));
|
||||||
drawPriceMarker(m, stopLoss, "SL", color("--red", "#e53e3e"));
|
drawPriceMarker(m, stopLoss, "SL", color("--red", "#e53e3e"));
|
||||||
drawCandles(m);
|
drawCandles(m);
|
||||||
if (recTime) drawEventMarker(m, recTime, actionStatus === "等回踩" ? "△" : "▲", actionStatus === "等回踩" ? "#8e91a0" : color("--blue", "#4262ff"), entryPrice);
|
if (tradeMarkers.length) {
|
||||||
if (tp1Time) drawEventMarker(m, tp1Time, "✓", color("--green", "#00b473"), tp1);
|
var stacks = {};
|
||||||
if (slTime) drawEventMarker(m, slTime, "×", color("--red", "#e53e3e"), stopLoss);
|
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);
|
drawFocus(m);
|
||||||
drawLabels(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_pending_paper_orders,
|
||||||
sync_recommendation,
|
sync_recommendation,
|
||||||
)
|
)
|
||||||
|
from app.db.recommendation_queries import get_opportunity_detail
|
||||||
|
|
||||||
|
|
||||||
def _visible_card_text(card: dict) -> str:
|
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)
|
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):
|
def test_default_paper_trade_uses_5000u_notional_5x_and_1000u_margin(monkeypatch):
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||||
monkeypatch.delenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", raising=False)
|
monkeypatch.delenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", raising=False)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user