feat: 선물 차트 마커 UI 개선 및 동일 타점 세로 스택

마커 라벨 2배 확대, Y축 뒤집기 버튼 추가, 같은 시각 마커를 좌우 대신 위아래로 표시한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-09 21:12:06 +09:00
parent a6e0d4081a
commit 281f4cfa25
23 changed files with 1603 additions and 1302 deletions

View File

@@ -371,11 +371,11 @@ __EXTRA_BODY__
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -400,7 +400,7 @@ __EXTRA_BODY__
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -437,8 +437,8 @@ __EXTRA_BODY__
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
const labelY = lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -448,7 +448,7 @@ __EXTRA_BODY__
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
const LABEL_GAP = 24;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
@@ -509,7 +509,7 @@ __EXTRA_BODY__
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
padding: [40, 10, 40, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -589,7 +589,7 @@ __EXTRA_BODY__
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
color: SIM_START_COLOR, shape: "arrowDown", size: 6,
text: sm.label || "거래시작",
});
}
@@ -599,7 +599,7 @@ __EXTRA_BODY__
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 5,
shape: "arrowUp", size: 10,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -607,7 +607,7 @@ __EXTRA_BODY__
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 5,
shape: "arrowDown", size: 10,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});

View File

@@ -20,16 +20,15 @@ from deepcoin.ground_truth.futures import (
)
def _stagger_marker_positions(
def _stack_marker_positions(
long_open: list[dict[str, Any]],
long_close: list[dict[str, Any]],
short_open: list[dict[str, Any]],
short_close: list[dict[str, Any]],
stagger_px: int = 18,
) -> None:
"""동일 시각에 겹치는 마커에 수평 오프셋을 부여한다.
"""동일 시각에 겹치는 마커에 세로 스택 인덱스를 부여한다.
GT 매도 봉에서 L↓(롱 청산)과 S↓(숏 진입)이 같은 좌표에 겹치는 문제를 방지한다.
같은 봉의 L↓·S↓ 등이 좌우로 벌어지지 않고 동일 X에서 위아래로 쌓이도록 한다.
"""
from collections import defaultdict
@@ -47,13 +46,15 @@ def _stagger_marker_positions(
for markers_at_time in groups.values():
if len(markers_at_time) <= 1:
for marker in markers_at_time:
marker["stack_index"] = 0
continue
markers_at_time.sort(
key=lambda m: kind_rank.get(f"{m.get('position')}_{m.get('action')}", 9)
)
count = len(markers_at_time)
for index, marker in enumerate(markers_at_time):
marker["x_offset_px"] = int((index - (count - 1) / 2) * stagger_px)
marker["stack_index"] = index
marker.pop("x_offset_px", None)
_FUTURES_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ko">
@@ -115,6 +116,7 @@ __EXTRA_BODY__
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -137,13 +139,16 @@ __EXTRA_BODY__
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -218,7 +223,7 @@ __EXTRA_BODY__
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -227,6 +232,10 @@ __EXTRA_BODY__
ctx.textBaseline = "alphabetic";
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
@@ -245,14 +254,17 @@ __EXTRA_BODY__
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -276,13 +288,16 @@ __EXTRA_BODY__
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -332,8 +347,11 @@ __EXTRA_BODY__
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -365,6 +383,7 @@ __EXTRA_BODY__
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -377,6 +396,7 @@ __EXTRA_BODY__
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -401,28 +421,41 @@ __EXTRA_BODY__
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const arrowUp = visualUp(kindUp[kind]);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -525,6 +558,15 @@ __EXTRA_BODY__
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
__EXTRA_SCRIPT__
function renderLegend() {
@@ -585,6 +627,7 @@ __EXTRA_SCRIPT__
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
@@ -646,7 +689,7 @@ def _build_futures_chart_payload(
"meta": chart_meta,
"bar_count": len(df),
}
_stagger_marker_positions(
_stack_marker_positions(
payload["long_open_markers"],
payload["long_close_markers"],
payload["short_open_markers"],
@@ -857,7 +900,7 @@ def _build_futures_sim_chart_payload(
start_marker = _sim_start_marker(df, sim_pnl)
if start_marker is not None:
payload["sim_start_marker"] = start_marker
_stagger_marker_positions(
_stack_marker_positions(
payload["long_open_markers"],
payload["long_close_markers"],
payload["short_open_markers"],