Files
Bithumb/docs/02_ground_truth/futures/ground_truth_chart_v3.html
dsyoon 2dedfae82d feat: 현물 GT 기반 선물 Ground Truth 차트 v1~v3 추가
현물 GT 타점을 롱·숏 4색 마커(상·하방 매수·매도)로 변환해 futures HTML 차트를 생성한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 19:06:08 +09:00

495 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Futures Ground Truth Chart</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="ground_truth_chart_v3_data.js"></script>
<style>
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
h1 { margin: 0 0 6px; font-size: 20px; }
.meta { font-size: 13px; color: #666; }
.legend { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
.toolbar-group:last-of-type { border-right: none; }
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
.toolbar button:hover { background: #f0f4f8; }
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
.toolbar button.home:hover { background: #1b5e20; }
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
#status { font-size: 12px; color: #888; margin-left: auto; }
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
#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>
<header>
<h1 id="title">Futures Ground Truth Chart</h1>
<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>
<button id="btn-prev-leg" title="이전 롱 레그">◀ 이전</button>
<button id="btn-next-leg" title="다음 롱 레그">다음 ▶</button>
<span class="leg-info" id="leg-info">타점 - / -</span>
</div>
<div class="toolbar-group">
<button id="btn-all" class="btn-period active">전체</button>
<button id="btn-365d" class="btn-period">1년</button>
<button id="btn-30d" class="btn-period">30일</button>
<button id="btn-7d" class="btn-period">7일</button>
<button id="btn-3d" class="btn-period">3일</button>
</div>
<div class="toolbar-group">
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
<button id="btn-toggle-detail" title="상세 캔들 패널">상세 패널</button>
</div>
<span id="status">데이터 로딩 중…</span>
</div>
<div id="overview"></div>
<div id="detail-wrap">
<h2 id="detail-title">상세 캔들</h2>
<div id="detail"></div>
</div>
<script>
let DATA = null;
let overviewPlot = null;
let detailChart = null;
let detailSeries = null;
let currentMode = "overview";
let currentLegIdx = 0;
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 17px Malgun Gothic, Arial, sans-serif";
let axisMeasureCtx = null;
const COLORS = {
longOpen: "#1565c0",
longClose: "#64b5f6",
shortOpen: "#c62828",
shortClose: "#ff8a65",
};
function fmtPrice(v) {
return Math.round(v).toLocaleString("ko-KR");
}
function measureTextWidth(text, font) {
if (!axisMeasureCtx) {
const c = document.createElement("canvas");
axisMeasureCtx = c.getContext("2d");
}
axisMeasureCtx.font = font;
return axisMeasureCtx.measureText(text).width;
}
function yAxisLabelWidth() {
const vals = DATA.close;
if (!vals || !vals.length) return 88;
const samples = new Set([vals[0], vals[vals.length - 1]]);
let lo = vals[0], hi = vals[0];
for (let i = 1; i < vals.length; i++) {
if (vals[i] < lo) lo = vals[i];
if (vals[i] > hi) hi = vals[i];
}
samples.add(lo);
samples.add(hi);
samples.add((lo + hi) / 2);
let maxW = 0;
samples.forEach(v => {
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
});
return Math.ceil(maxW) + 20;
}
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
if (signalType === "breakout") return "^";
if (signalType === "div_bull" || signalType === "div_bear") return "d";
return "";
}
function markerLabel(kind, m) {
const id = m.marker_id;
const sfx = markerSuffix(m.signal_type);
if (kind === "longOpen") return "L↑" + id + sfx;
if (kind === "longClose") return "L↓" + id + sfx;
if (kind === "shortOpen") return "S↓" + id + sfx;
return "S↑" + id + sfx;
}
function drawMarkerLabel(ctx, label, x, y, color, up) {
ctx.font = MARKER_FONT;
const lx = x + 10;
const ly = y + (up ? 28 : -20);
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, lx, ly);
ctx.fillStyle = color;
ctx.fillText(label, lx, ly);
}
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);
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);
}
function drawFuturesMarkers(u) {
if (!showMarkers) return;
(DATA.long_open_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
(DATA.long_close_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.longClose, false, markerLabel("longClose", m)));
(DATA.short_open_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.shortOpen, false, markerLabel("shortOpen", m)));
(DATA.short_close_markers || []).forEach(m =>
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
}
function updateLegInfo() {
const total = (DATA.long_open_markers || []).length;
const el = document.getElementById("leg-info");
if (!total) { el.textContent = "타점 없음"; return; }
el.textContent = `롱 레그 ${currentLegIdx + 1} / ${total}`;
}
function overviewXRange() {
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
const s = overviewPlot.scales.x;
return { min: s.min, max: s.max };
}
function setOverviewXRange(min, max) {
const t0 = DATA.times[0];
const t1 = DATA.times[DATA.times.length - 1];
overviewPlot.setScale("x", {
min: Math.max(t0, min),
max: Math.min(t1, max),
});
}
function fitOverview() {
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
}
function zoomOverview(factor) {
const { min, max } = overviewXRange();
const mid = (min + max) / 2;
const half = Math.max((max - min) * factor / 2, 3600);
setOverviewXRange(mid - half, mid + half);
}
function buildOverview(keepRange) {
const prev = keepRange ? overviewXRange() : null;
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
const yAxisW = yAxisLabelWidth();
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
{
side: 3,
size: yAxisW,
gap: 10,
font: AXIS_FONT,
values: (u, vals) => vals.map(v => fmtPrice(v)),
},
],
series: [{}, { label: "종가", stroke: "#1565c0", width: 1 }],
cursor: { drag: { x: true, y: false, setScale: true } },
hooks: { draw: [(u) => drawFuturesMarkers(u)] },
};
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
if (prev && keepRange) setOverviewXRange(prev.min, prev.max);
else fitOverview();
}
function sliceLastDays(days) {
const cutoff = DATA.times[DATA.times.length - 1] - days * 86400;
let start = 0;
for (let i = DATA.times.length - 1; i >= 0; i--) {
if (DATA.times[i] < cutoff) { start = i + 1; break; }
}
return { start, end: DATA.times.length };
}
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
const priceAxisW = yAxisLabelWidth();
detailChart = LightweightCharts.createChart(wrap, {
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
rightPriceScale: { visible: false },
leftPriceScale: {
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
height: 360,
});
detailSeries = detailChart.addCandlestickSeries({
priceScaleId: "left",
upColor: "#c62828", downColor: "#1565c0",
borderUpColor: "#c62828", borderDownColor: "#1565c0",
wickUpColor: "#c62828", wickDownColor: "#1565c0",
});
const candles = [];
for (let i = startIdx; i < end; i++) {
candles.push({
time: DATA.times[i],
open: DATA.open[i], high: DATA.high[i],
low: DATA.low[i], close: DATA.close[i],
});
}
detailSeries.setData(candles);
const t0 = DATA.times[startIdx];
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: 3,
text: markerLabel(kind, m),
});
});
};
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);
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
detailChart.timeScale().fitContent();
}
function setActive(btnId) {
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
const el = document.getElementById(btnId);
if (el) el.classList.add("active");
}
function goHome() {
currentMode = "overview";
setActive("btn-all");
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
else fitOverview();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
}
function nearestLongCloseAfter(openTime) {
let best = null;
for (const s of DATA.long_close_markers || []) {
if (s.time >= openTime && (!best || s.time < best.time)) best = s;
}
return best || (DATA.long_close_markers || [])[(DATA.long_close_markers || []).length - 1];
}
function jumpToLeg(idx) {
const opens = DATA.long_open_markers || [];
const total = opens.length;
if (!total) return;
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
updateLegInfo();
const lo = opens[currentLegIdx];
const lc = nearestLongCloseAfter(lo.time);
const span = lc ? Math.max(lc.time - lo.time, 86400) : 86400 * 3;
const pad = span * 0.4;
const vmin = lo.time - pad;
const vmax = (lc ? lc.time : lo.time) + pad;
currentMode = "overview";
setActive("btn-all");
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
setOverviewXRange(vmin, vmax);
let start = 0;
for (let i = 0; i < DATA.times.length; i++) {
if (DATA.times[i] >= vmin) { start = i; break; }
}
let end = DATA.times.length;
for (let i = DATA.times.length - 1; i >= 0; i--) {
if (DATA.times[i] <= vmax) { end = i + 1; break; }
}
document.getElementById("detail-title").textContent =
`L↑${lo.marker_id} 상방 매수 — ${new Date(lo.time * 1000).toLocaleString("ko-KR")}`;
buildDetailCandles(start, end);
const closeText = lc ? ` → 상방 매도 ${fmtPrice(lc.price)}` : "";
document.getElementById("status").textContent =
`L↑${lo.marker_id} ${fmtPrice(lo.price)}${closeText}`;
}
function showPeriod(days, btnId, label) {
currentMode = "detail";
setActive(btnId);
detailVisible = true;
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
const { start } = sliceLastDays(days);
document.getElementById("detail-title").textContent =
`${label} 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
buildDetailCandles(start);
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
const t0 = DATA.times[start];
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
document.getElementById("status").textContent = `${label} 구간 표시`;
}
function applyZoom(factor) {
if (currentMode === "detail" && detailChart) {
const ts = detailChart.timeScale();
const r = ts.getVisibleLogicalRange();
if (!r) return;
const mid = (r.from + r.to) / 2;
const half = Math.max((r.to - r.from) * factor / 2, 10);
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
} else if (overviewPlot) {
zoomOverview(factor);
}
}
function applyFit() {
if (currentMode === "detail" && detailChart) {
detailChart.timeScale().fitContent();
} else if (overviewPlot) {
fitOverview();
}
}
function renderLegend() {
const items = [
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
[COLORS.longClose, "L↓ 상방 매도 (롱 청산)"],
[COLORS.shortOpen, "S↓ 하방 매수 (숏 진입)"],
[COLORS.shortClose, "S↑ 하방 매도 (숏 청산)"],
];
document.getElementById("legend").innerHTML = items.map(([color, text]) =>
`<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${text}</span>`
).join("");
}
function init() {
DATA = window.CHART_DATA;
if (!DATA) throw new Error("차트 데이터 JS 없음");
const m = DATA.meta;
const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days;
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()}` : "";
document.getElementById("title").textContent =
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to;
const lo = (DATA.long_open_markers || []).length;
const lc = (DATA.long_close_markers || []).length;
const so = (DATA.short_open_markers || []).length;
const sc = (DATA.short_close_markers || []).length;
document.getElementById("meta").textContent =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | GT ${gtLabel} | 현물 GT 타점 기반`;
renderLegend();
updateLegInfo();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
buildOverview(false);
document.getElementById("btn-home").onclick = goHome;
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
document.getElementById("btn-all").onclick = goHome;
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
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-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart);
};
document.getElementById("btn-toggle-detail").onclick = () => {
detailVisible = !detailVisible;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
if (detailVisible && !detailChart) {
const { start } = sliceLastDays(7);
buildDetailCandles(start);
}
};
document.getElementById("overview").addEventListener("dblclick", () => {
if (currentMode === "overview") fitOverview();
});
window.addEventListener("resize", () => {
if (overviewPlot) buildOverview(true);
});
}
try { init(); } catch (err) {
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
}
</script>
</body>
</html>