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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button> <button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button> <button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button> <button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button> <button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -118,13 +119,16 @@
let showMarkers = true; let showMarkers = true;
let detailVisible = false; let detailVisible = false;
let lastDetailStart = 0; let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif"; const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif"; const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6; const ARROW_HALF = 12;
const ARROW_HEIGHT = 8; const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 8; const LABEL_OFFSET_X = 16;
const LABEL_GAP = 12; const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2"; const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null; let axisMeasureCtx = null;
@@ -199,7 +203,7 @@
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X; const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.lineWidth = 2; ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY); ctx.strokeText(label, lx, labelY);
@@ -224,16 +228,23 @@
ctx.fill(); ctx.fill();
} }
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) { function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx; const ctx = u.ctx;
const xOff = m.x_offset_px || 0; const x = u.valToPos(m.time, "x", true);
const x = u.valToPos(m.time, "x", true) + xOff;
const lineY = u.valToPos(markerChartPrice(m), "y", true); const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color); const arrowUp = visualUp(up);
const labelY = up const stackIdx = m.stack_index || 0;
? lineY + ARROW_HEIGHT + LABEL_GAP const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
: lineY - ARROW_HEIGHT - LABEL_GAP; 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); drawMarkerLabel(ctx, label, x, labelY, color);
} }
@@ -257,13 +268,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true); const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR; 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 || "거래시작"; const label = marker.label || "거래시작";
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "bottom"; ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = lineY - ARROW_HEIGHT - 6; const labelY = arrowUp
ctx.lineWidth = 2; ? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY); ctx.strokeText(label, x, labelY);
@@ -313,8 +327,11 @@
const opts = { const opts = {
width: document.getElementById("overview").clientWidth, width: document.getElementById("overview").clientWidth,
height: 480, height: 480,
padding: [28, 10, 28, 10], padding: [40, 10, 40, 10],
scales: { x: { time: true } }, scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [ axes: [
{ gap: 6 }, { gap: 6 },
{ {
@@ -346,6 +363,7 @@
function buildDetailCandles(startIdx, endIdx) { function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx; lastDetailStart = startIdx;
const end = endIdx || DATA.times.length; const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none"; document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail"); const wrap = document.getElementById("detail");
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -358,6 +376,7 @@
borderVisible: true, borderVisible: true,
minimumWidth: priceAxisW, minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 }, scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
}, },
timeScale: { timeVisible: true, secondsVisible: false }, timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth, width: wrap.clientWidth,
@@ -382,28 +401,42 @@
const t1 = DATA.times[end - 1]; const t1 = DATA.times[end - 1];
const markers = []; const markers = [];
if (showMarkers) { if (showMarkers) {
const add = (list, kind, up) => { const colorMap = {
const colorMap = { longOpen: COLORS.longOpen,
longOpen: COLORS.longOpen, longClose: COLORS.longClose,
longClose: COLORS.longClose, shortOpen: COLORS.shortOpen,
shortOpen: COLORS.shortOpen, shortClose: COLORS.shortClose,
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),
});
});
}; };
add(DATA.long_open_markers, "longOpen", true); const kindUp = {
add(DATA.long_close_markers, "longClose", false); longOpen: true,
add(DATA.short_open_markers, "shortOpen", false); longClose: false,
add(DATA.short_close_markers, "shortClose", true); 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 up = kindUp[kind];
const arrowUp = visualUp(up);
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); markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers); detailSeries.setMarkers(markers);
@@ -506,6 +539,15 @@
} }
} }
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);
}
function fmtMoney(v) { function fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원"; return Math.round(v).toLocaleString("ko-KR") + "원";
@@ -622,6 +664,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6); document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4); document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit; document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => { document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers; showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시"; document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -96,6 +96,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button> <button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button> <button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button> <button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button> <button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -118,13 +119,16 @@
let showMarkers = true; let showMarkers = true;
let detailVisible = false; let detailVisible = false;
let lastDetailStart = 0; let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif"; const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif"; const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6; const ARROW_HALF = 12;
const ARROW_HEIGHT = 8; const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 8; const LABEL_OFFSET_X = 16;
const LABEL_GAP = 12; const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2"; const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null; let axisMeasureCtx = null;
@@ -199,7 +203,7 @@
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X; const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.lineWidth = 2; ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY); ctx.strokeText(label, lx, labelY);
@@ -224,16 +228,23 @@
ctx.fill(); ctx.fill();
} }
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) { function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx; const ctx = u.ctx;
const xOff = m.x_offset_px || 0; const x = u.valToPos(m.time, "x", true);
const x = u.valToPos(m.time, "x", true) + xOff;
const lineY = u.valToPos(markerChartPrice(m), "y", true); const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color); const arrowUp = visualUp(up);
const labelY = up const stackIdx = m.stack_index || 0;
? lineY + ARROW_HEIGHT + LABEL_GAP const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
: lineY - ARROW_HEIGHT - LABEL_GAP; 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); drawMarkerLabel(ctx, label, x, labelY, color);
} }
@@ -257,13 +268,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true); const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR; 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 || "거래시작"; const label = marker.label || "거래시작";
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "bottom"; ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = lineY - ARROW_HEIGHT - 6; const labelY = arrowUp
ctx.lineWidth = 2; ? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY); ctx.strokeText(label, x, labelY);
@@ -313,8 +327,11 @@
const opts = { const opts = {
width: document.getElementById("overview").clientWidth, width: document.getElementById("overview").clientWidth,
height: 480, height: 480,
padding: [28, 10, 28, 10], padding: [40, 10, 40, 10],
scales: { x: { time: true } }, scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [ axes: [
{ gap: 6 }, { gap: 6 },
{ {
@@ -346,6 +363,7 @@
function buildDetailCandles(startIdx, endIdx) { function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx; lastDetailStart = startIdx;
const end = endIdx || DATA.times.length; const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none"; document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail"); const wrap = document.getElementById("detail");
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -358,6 +376,7 @@
borderVisible: true, borderVisible: true,
minimumWidth: priceAxisW, minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 }, scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
}, },
timeScale: { timeVisible: true, secondsVisible: false }, timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth, width: wrap.clientWidth,
@@ -382,28 +401,42 @@
const t1 = DATA.times[end - 1]; const t1 = DATA.times[end - 1];
const markers = []; const markers = [];
if (showMarkers) { if (showMarkers) {
const add = (list, kind, up) => { const colorMap = {
const colorMap = { longOpen: COLORS.longOpen,
longOpen: COLORS.longOpen, longClose: COLORS.longClose,
longClose: COLORS.longClose, shortOpen: COLORS.shortOpen,
shortOpen: COLORS.shortOpen, shortClose: COLORS.shortClose,
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),
});
});
}; };
add(DATA.long_open_markers, "longOpen", true); const kindUp = {
add(DATA.long_close_markers, "longClose", false); longOpen: true,
add(DATA.short_open_markers, "shortOpen", false); longClose: false,
add(DATA.short_close_markers, "shortClose", true); 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 up = kindUp[kind];
const arrowUp = visualUp(up);
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); markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers); detailSeries.setMarkers(markers);
@@ -506,6 +539,15 @@
} }
} }
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);
}
function fmtMoney(v) { function fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원"; return Math.round(v).toLocaleString("ko-KR") + "원";
@@ -622,6 +664,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6); document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4); document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit; document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => { document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers; showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시"; document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -96,6 +96,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button> <button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button> <button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button> <button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button> <button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -118,13 +119,16 @@
let showMarkers = true; let showMarkers = true;
let detailVisible = false; let detailVisible = false;
let lastDetailStart = 0; let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif"; const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif"; const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6; const ARROW_HALF = 12;
const ARROW_HEIGHT = 8; const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 8; const LABEL_OFFSET_X = 16;
const LABEL_GAP = 12; const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2"; const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null; let axisMeasureCtx = null;
@@ -199,7 +203,7 @@
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X; const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.lineWidth = 2; ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY); ctx.strokeText(label, lx, labelY);
@@ -224,16 +228,23 @@
ctx.fill(); ctx.fill();
} }
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) { function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx; const ctx = u.ctx;
const xOff = m.x_offset_px || 0; const x = u.valToPos(m.time, "x", true);
const x = u.valToPos(m.time, "x", true) + xOff;
const lineY = u.valToPos(markerChartPrice(m), "y", true); const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color); const arrowUp = visualUp(up);
const labelY = up const stackIdx = m.stack_index || 0;
? lineY + ARROW_HEIGHT + LABEL_GAP const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
: lineY - ARROW_HEIGHT - LABEL_GAP; 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); drawMarkerLabel(ctx, label, x, labelY, color);
} }
@@ -257,13 +268,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true); const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR; 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 || "거래시작"; const label = marker.label || "거래시작";
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "bottom"; ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = lineY - ARROW_HEIGHT - 6; const labelY = arrowUp
ctx.lineWidth = 2; ? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY); ctx.strokeText(label, x, labelY);
@@ -313,8 +327,11 @@
const opts = { const opts = {
width: document.getElementById("overview").clientWidth, width: document.getElementById("overview").clientWidth,
height: 480, height: 480,
padding: [28, 10, 28, 10], padding: [40, 10, 40, 10],
scales: { x: { time: true } }, scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [ axes: [
{ gap: 6 }, { gap: 6 },
{ {
@@ -346,6 +363,7 @@
function buildDetailCandles(startIdx, endIdx) { function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx; lastDetailStart = startIdx;
const end = endIdx || DATA.times.length; const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none"; document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail"); const wrap = document.getElementById("detail");
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -358,6 +376,7 @@
borderVisible: true, borderVisible: true,
minimumWidth: priceAxisW, minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 }, scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
}, },
timeScale: { timeVisible: true, secondsVisible: false }, timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth, width: wrap.clientWidth,
@@ -382,28 +401,42 @@
const t1 = DATA.times[end - 1]; const t1 = DATA.times[end - 1];
const markers = []; const markers = [];
if (showMarkers) { if (showMarkers) {
const add = (list, kind, up) => { const colorMap = {
const colorMap = { longOpen: COLORS.longOpen,
longOpen: COLORS.longOpen, longClose: COLORS.longClose,
longClose: COLORS.longClose, shortOpen: COLORS.shortOpen,
shortOpen: COLORS.shortOpen, shortClose: COLORS.shortClose,
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),
});
});
}; };
add(DATA.long_open_markers, "longOpen", true); const kindUp = {
add(DATA.long_close_markers, "longClose", false); longOpen: true,
add(DATA.short_open_markers, "shortOpen", false); longClose: false,
add(DATA.short_close_markers, "shortClose", true); 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 up = kindUp[kind];
const arrowUp = visualUp(up);
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); markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers); detailSeries.setMarkers(markers);
@@ -506,6 +539,15 @@
} }
} }
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);
}
function fmtMoney(v) { function fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원"; return Math.round(v).toLocaleString("ko-KR") + "원";
@@ -622,6 +664,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6); document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4); document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit; document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => { document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers; showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시"; document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button> <button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button> <button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button> <button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button> <button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -80,13 +81,16 @@
let showMarkers = true; let showMarkers = true;
let detailVisible = false; let detailVisible = false;
let lastDetailStart = 0; let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif"; const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif"; const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6; const ARROW_HALF = 12;
const ARROW_HEIGHT = 8; const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 8; const LABEL_OFFSET_X = 16;
const LABEL_GAP = 12; const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2"; const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null; let axisMeasureCtx = null;
@@ -161,7 +165,7 @@
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X; const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.lineWidth = 2; ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY); ctx.strokeText(label, lx, labelY);
@@ -186,16 +190,23 @@
ctx.fill(); ctx.fill();
} }
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) { function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx; const ctx = u.ctx;
const xOff = m.x_offset_px || 0; const x = u.valToPos(m.time, "x", true);
const x = u.valToPos(m.time, "x", true) + xOff;
const lineY = u.valToPos(markerChartPrice(m), "y", true); const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color); const arrowUp = visualUp(up);
const labelY = up const stackIdx = m.stack_index || 0;
? lineY + ARROW_HEIGHT + LABEL_GAP const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
: lineY - ARROW_HEIGHT - LABEL_GAP; 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); drawMarkerLabel(ctx, label, x, labelY, color);
} }
@@ -219,13 +230,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true); const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR; 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 || "거래시작"; const label = marker.label || "거래시작";
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "bottom"; ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = lineY - ARROW_HEIGHT - 6; const labelY = arrowUp
ctx.lineWidth = 2; ? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY); ctx.strokeText(label, x, labelY);
@@ -275,8 +289,11 @@
const opts = { const opts = {
width: document.getElementById("overview").clientWidth, width: document.getElementById("overview").clientWidth,
height: 480, height: 480,
padding: [28, 10, 28, 10], padding: [40, 10, 40, 10],
scales: { x: { time: true } }, scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [ axes: [
{ gap: 6 }, { gap: 6 },
{ {
@@ -308,6 +325,7 @@
function buildDetailCandles(startIdx, endIdx) { function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx; lastDetailStart = startIdx;
const end = endIdx || DATA.times.length; const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none"; document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail"); const wrap = document.getElementById("detail");
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -320,6 +338,7 @@
borderVisible: true, borderVisible: true,
minimumWidth: priceAxisW, minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 }, scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
}, },
timeScale: { timeVisible: true, secondsVisible: false }, timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth, width: wrap.clientWidth,
@@ -344,28 +363,42 @@
const t1 = DATA.times[end - 1]; const t1 = DATA.times[end - 1];
const markers = []; const markers = [];
if (showMarkers) { if (showMarkers) {
const add = (list, kind, up) => { const colorMap = {
const colorMap = { longOpen: COLORS.longOpen,
longOpen: COLORS.longOpen, longClose: COLORS.longClose,
longClose: COLORS.longClose, shortOpen: COLORS.shortOpen,
shortOpen: COLORS.shortOpen, shortClose: COLORS.shortClose,
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),
});
});
}; };
add(DATA.long_open_markers, "longOpen", true); const kindUp = {
add(DATA.long_close_markers, "longClose", false); longOpen: true,
add(DATA.short_open_markers, "shortOpen", false); longClose: false,
add(DATA.short_close_markers, "shortClose", true); 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 up = kindUp[kind];
const arrowUp = visualUp(up);
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); markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers); detailSeries.setMarkers(markers);
@@ -468,6 +501,15 @@
} }
} }
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);
}
function renderLegend() { function renderLegend() {
@@ -528,6 +570,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6); document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4); document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit; document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => { document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers; showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시"; document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button> <button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button> <button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button> <button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button> <button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -80,13 +81,16 @@
let showMarkers = true; let showMarkers = true;
let detailVisible = false; let detailVisible = false;
let lastDetailStart = 0; let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif"; const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif"; const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6; const ARROW_HALF = 12;
const ARROW_HEIGHT = 8; const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 8; const LABEL_OFFSET_X = 16;
const LABEL_GAP = 12; const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2"; const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null; let axisMeasureCtx = null;
@@ -161,7 +165,7 @@
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X; const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.lineWidth = 2; ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY); ctx.strokeText(label, lx, labelY);
@@ -186,16 +190,23 @@
ctx.fill(); ctx.fill();
} }
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) { function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx; const ctx = u.ctx;
const xOff = m.x_offset_px || 0; const x = u.valToPos(m.time, "x", true);
const x = u.valToPos(m.time, "x", true) + xOff;
const lineY = u.valToPos(markerChartPrice(m), "y", true); const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color); const arrowUp = visualUp(up);
const labelY = up const stackIdx = m.stack_index || 0;
? lineY + ARROW_HEIGHT + LABEL_GAP const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
: lineY - ARROW_HEIGHT - LABEL_GAP; 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); drawMarkerLabel(ctx, label, x, labelY, color);
} }
@@ -219,13 +230,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true); const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR; 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 || "거래시작"; const label = marker.label || "거래시작";
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "bottom"; ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = lineY - ARROW_HEIGHT - 6; const labelY = arrowUp
ctx.lineWidth = 2; ? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY); ctx.strokeText(label, x, labelY);
@@ -275,8 +289,11 @@
const opts = { const opts = {
width: document.getElementById("overview").clientWidth, width: document.getElementById("overview").clientWidth,
height: 480, height: 480,
padding: [28, 10, 28, 10], padding: [40, 10, 40, 10],
scales: { x: { time: true } }, scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [ axes: [
{ gap: 6 }, { gap: 6 },
{ {
@@ -308,6 +325,7 @@
function buildDetailCandles(startIdx, endIdx) { function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx; lastDetailStart = startIdx;
const end = endIdx || DATA.times.length; const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none"; document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail"); const wrap = document.getElementById("detail");
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -320,6 +338,7 @@
borderVisible: true, borderVisible: true,
minimumWidth: priceAxisW, minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 }, scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
}, },
timeScale: { timeVisible: true, secondsVisible: false }, timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth, width: wrap.clientWidth,
@@ -344,28 +363,42 @@
const t1 = DATA.times[end - 1]; const t1 = DATA.times[end - 1];
const markers = []; const markers = [];
if (showMarkers) { if (showMarkers) {
const add = (list, kind, up) => { const colorMap = {
const colorMap = { longOpen: COLORS.longOpen,
longOpen: COLORS.longOpen, longClose: COLORS.longClose,
longClose: COLORS.longClose, shortOpen: COLORS.shortOpen,
shortOpen: COLORS.shortOpen, shortClose: COLORS.shortClose,
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),
});
});
}; };
add(DATA.long_open_markers, "longOpen", true); const kindUp = {
add(DATA.long_close_markers, "longClose", false); longOpen: true,
add(DATA.short_open_markers, "shortOpen", false); longClose: false,
add(DATA.short_close_markers, "shortClose", true); 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 up = kindUp[kind];
const arrowUp = visualUp(up);
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); markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers); detailSeries.setMarkers(markers);
@@ -468,6 +501,15 @@
} }
} }
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);
}
function renderLegend() { function renderLegend() {
@@ -528,6 +570,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6); document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4); document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit; document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => { document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers; showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시"; document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button> <button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button> <button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button> <button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button> <button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -80,13 +81,16 @@
let showMarkers = true; let showMarkers = true;
let detailVisible = false; let detailVisible = false;
let lastDetailStart = 0; let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif"; const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif"; const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6; const ARROW_HALF = 12;
const ARROW_HEIGHT = 8; const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 8; const LABEL_OFFSET_X = 16;
const LABEL_GAP = 12; const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2"; const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null; let axisMeasureCtx = null;
@@ -161,7 +165,7 @@
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X; const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.lineWidth = 2; ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY); ctx.strokeText(label, lx, labelY);
@@ -186,16 +190,23 @@
ctx.fill(); ctx.fill();
} }
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) { function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx; const ctx = u.ctx;
const xOff = m.x_offset_px || 0; const x = u.valToPos(m.time, "x", true);
const x = u.valToPos(m.time, "x", true) + xOff;
const lineY = u.valToPos(markerChartPrice(m), "y", true); const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color); const arrowUp = visualUp(up);
const labelY = up const stackIdx = m.stack_index || 0;
? lineY + ARROW_HEIGHT + LABEL_GAP const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
: lineY - ARROW_HEIGHT - LABEL_GAP; 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); drawMarkerLabel(ctx, label, x, labelY, color);
} }
@@ -219,13 +230,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true); const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR; 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 || "거래시작"; const label = marker.label || "거래시작";
ctx.font = MARKER_FONT; ctx.font = MARKER_FONT;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "bottom"; ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = lineY - ARROW_HEIGHT - 6; const labelY = arrowUp
ctx.lineWidth = 2; ? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY); ctx.strokeText(label, x, labelY);
@@ -275,8 +289,11 @@
const opts = { const opts = {
width: document.getElementById("overview").clientWidth, width: document.getElementById("overview").clientWidth,
height: 480, height: 480,
padding: [28, 10, 28, 10], padding: [40, 10, 40, 10],
scales: { x: { time: true } }, scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [ axes: [
{ gap: 6 }, { gap: 6 },
{ {
@@ -308,6 +325,7 @@
function buildDetailCandles(startIdx, endIdx) { function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx; lastDetailStart = startIdx;
const end = endIdx || DATA.times.length; const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none"; document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail"); const wrap = document.getElementById("detail");
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -320,6 +338,7 @@
borderVisible: true, borderVisible: true,
minimumWidth: priceAxisW, minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 }, scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
}, },
timeScale: { timeVisible: true, secondsVisible: false }, timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth, width: wrap.clientWidth,
@@ -344,28 +363,42 @@
const t1 = DATA.times[end - 1]; const t1 = DATA.times[end - 1];
const markers = []; const markers = [];
if (showMarkers) { if (showMarkers) {
const add = (list, kind, up) => { const colorMap = {
const colorMap = { longOpen: COLORS.longOpen,
longOpen: COLORS.longOpen, longClose: COLORS.longClose,
longClose: COLORS.longClose, shortOpen: COLORS.shortOpen,
shortOpen: COLORS.shortOpen, shortClose: COLORS.shortClose,
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),
});
});
}; };
add(DATA.long_open_markers, "longOpen", true); const kindUp = {
add(DATA.long_close_markers, "longClose", false); longOpen: true,
add(DATA.short_open_markers, "shortOpen", false); longClose: false,
add(DATA.short_close_markers, "shortClose", true); 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 up = kindUp[kind];
const arrowUp = visualUp(up);
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); markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers); detailSeries.setMarkers(markers);
@@ -468,6 +501,15 @@
} }
} }
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);
}
function renderLegend() { function renderLegend() {
@@ -528,6 +570,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6); document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4); document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit; document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => { document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers; showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시"; document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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