feat: 선물 sim 차트 추가 및 GT/선물 마커 UI 개선

선물 1년 PnL 시뮬(40만원)과 sim 차트 v1~v3를 추가하고, 현물·선물 GT 차트 마커를 종가선 밀착 삼각형으로 통일한다. 동시각 L↓/S↓ 겹침은 가로 stagger로 분리하며 동그라미 표시는 제거한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 19:58:12 +09:00
parent 2dedfae82d
commit 5d27f90560
48 changed files with 6772 additions and 392 deletions

View File

@@ -30,6 +30,7 @@
#detail-wrap { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
</style>
</head>
<body>
@@ -38,6 +39,7 @@
<div class="meta" id="meta"></div>
<div class="legend" id="legend"></div>
</header>
<div class="toolbar">
<div class="toolbar-group">
<button id="btn-home" class="home" title="전체 화면으로 복귀"></button>
@@ -80,7 +82,12 @@
let lastDetailStart = 0;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 17px 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 SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
const COLORS = {
@@ -138,39 +145,63 @@
return "S↑" + id + sfx;
}
function drawMarkerLabel(ctx, label, x, y, color, up) {
function markerChartPrice(m) {
if (m.chart_price != null) return m.chart_price;
let lo = 0;
let hi = DATA.times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (DATA.times[mid] < m.time) lo = mid + 1;
else hi = mid;
}
return DATA.close[lo];
}
function drawMarkerLabel(ctx, label, x, labelY, color) {
ctx.font = MARKER_FONT;
const lx = x + 10;
const ly = y + (up ? 28 : -20);
ctx.lineWidth = 3;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, lx, ly);
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
ctx.fillStyle = color;
ctx.fillText(label, lx, ly);
ctx.fillText(label, lx, labelY);
ctx.textBaseline = "alphabetic";
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
if (up) {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY + ARROW_HEIGHT);
} else {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY - ARROW_HEIGHT);
}
ctx.closePath();
ctx.fill();
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const x = u.valToPos(m.time, "x", true);
const y = u.valToPos(m.price, "y", true);
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
ctx.fillStyle = color;
ctx.beginPath();
const s = 8;
const gap = 12;
if (up) {
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
} else {
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
}
ctx.closePath();
ctx.fill();
drawMarkerLabel(ctx, label, x, y, color, up);
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
function drawFuturesMarkers(u) {
if (!showMarkers) return;
drawSimStartMarker(u, DATA.sim_start_marker);
(DATA.long_open_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
(DATA.long_close_markers || []).forEach(m =>
@@ -181,6 +212,29 @@
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
}
function drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
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 label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
function updateLegInfo() {
const total = (DATA.long_open_markers || []).length;
const el = document.getElementById("leg-info");
@@ -221,7 +275,7 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -303,7 +357,7 @@
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 3,
size: 5,
text: markerLabel(kind, m),
});
});
@@ -414,6 +468,8 @@
}
}
function renderLegend() {
const items = [
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
@@ -435,8 +491,10 @@
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}` : `${chartDays}`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}` : `${gtDays}`;
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
const simMode = !!DATA.sim_pnl;
const simSuffix = simMode ? " · 2단계 선물 시뮬" : "";
document.getElementById("title").textContent =
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to;
@@ -444,10 +502,15 @@
const lc = (DATA.long_close_markers || []).length;
const so = (DATA.short_open_markers || []).length;
const sc = (DATA.short_close_markers || []).length;
const markerRange = simMode && m.sim_period_from
? `체결 L↑${lo}/L↓${lc} · S↓${so}/S↑${sc} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
: `GT ${gtLabel} | 현물 GT 타점 기반`;
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
document.getElementById("meta").textContent =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | GT ${gtLabel} | 현물 GT 타점 기반`;
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | ${markerRange}${legendExtra}`;
renderLegend();
if (simMode) renderSimPanel();
updateLegInfo();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;