feat: GT·차트 2017~ 확장 및 sim 기간 3년으로 변경
DB 2017년 데이터에 맞춰 lookback 3447일로 GT·현물·선물 차트를 재생성하고, 2단계 sim은 최근 1095일(3년) 매매만 적용한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
612
docs/02_ground_truth/futures/ground_truth_chart_v3.html
Normal file
612
docs/02_ground_truth/futures/ground_truth_chart_v3.html
Normal file
@@ -0,0 +1,612 @@
|
||||
<!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>
|
||||
<button id="btn-flip-y" 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;
|
||||
let lastDetailEnd = 0;
|
||||
let yAxisInverted = false;
|
||||
|
||||
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
|
||||
const ARROW_HALF = 12;
|
||||
const ARROW_HEIGHT = 16;
|
||||
const LABEL_OFFSET_X = 16;
|
||||
const LABEL_GAP = 24;
|
||||
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
let axisMeasureCtx = null;
|
||||
|
||||
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 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 + LABEL_OFFSET_X;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.strokeText(label, lx, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, labelY);
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function visualUp(up) {
|
||||
return yAxisInverted ? !up : up;
|
||||
}
|
||||
|
||||
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 lineY = u.valToPos(markerChartPrice(m), "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const arrowUp = visualUp(up);
|
||||
const stackIdx = m.stack_index || 0;
|
||||
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
|
||||
const anchorY = lineY + stackShift;
|
||||
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
|
||||
const labelY = arrowUp
|
||||
? anchorY + ARROW_HEIGHT + LABEL_GAP
|
||||
: anchorY - ARROW_HEIGHT - LABEL_GAP;
|
||||
drawMarkerLabel(ctx, label, x, labelY, color);
|
||||
}
|
||||
|
||||
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 =>
|
||||
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 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;
|
||||
const arrowUp = visualUp(false);
|
||||
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
|
||||
const label = marker.label || "거래시작";
|
||||
ctx.font = MARKER_FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = arrowUp ? "top" : "bottom";
|
||||
const labelY = arrowUp
|
||||
? lineY + ARROW_HEIGHT + 12
|
||||
: lineY - ARROW_HEIGHT - 12;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.strokeText(label, x, labelY);
|
||||
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");
|
||||
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: [40, 10, 40, 10],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { dir: yAxisInverted ? -1 : 1 },
|
||||
},
|
||||
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;
|
||||
lastDetailEnd = end;
|
||||
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 },
|
||||
invertScale: yAxisInverted,
|
||||
},
|
||||
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 colorMap = {
|
||||
longOpen: COLORS.longOpen,
|
||||
longClose: COLORS.longClose,
|
||||
shortOpen: COLORS.shortOpen,
|
||||
shortClose: COLORS.shortClose,
|
||||
};
|
||||
const kindUp = {
|
||||
longOpen: true,
|
||||
longClose: false,
|
||||
shortOpen: false,
|
||||
shortClose: true,
|
||||
};
|
||||
const pending = [];
|
||||
[
|
||||
[DATA.long_open_markers, "longOpen"],
|
||||
[DATA.long_close_markers, "longClose"],
|
||||
[DATA.short_open_markers, "shortOpen"],
|
||||
[DATA.short_close_markers, "shortClose"],
|
||||
].forEach(([list, kind]) => {
|
||||
(list || []).forEach(m => {
|
||||
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
|
||||
});
|
||||
});
|
||||
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
|
||||
pending.forEach(({ m, kind }) => {
|
||||
const arrowUp = visualUp(kindUp[kind]);
|
||||
markers.push({
|
||||
time: m.time,
|
||||
position: arrowUp ? "belowBar" : "aboveBar",
|
||||
color: colorMap[kind],
|
||||
shape: arrowUp ? "arrowUp" : "arrowDown",
|
||||
size: 10,
|
||||
text: markerLabel(kind, m),
|
||||
});
|
||||
});
|
||||
}
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
detailSeries.setMarkers(markers);
|
||||
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 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() {
|
||||
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()}` : "";
|
||||
const simMode = !!DATA.sim_pnl;
|
||||
const simSuffix = simMode ? (m.sim_stage_suffix || " · 0단계 벤치마크") : "";
|
||||
document.getElementById("title").textContent =
|
||||
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
|
||||
if (simMode) {
|
||||
const panelTitle = document.getElementById("sim-panel-title");
|
||||
if (panelTitle) {
|
||||
const simDays = DATA.sim_pnl?.sim_lookback_days || m.sim_lookback_days || 1095;
|
||||
const simLabel = simDays >= 365
|
||||
? `최근 ${Math.round(simDays / 365)}년`
|
||||
: `최근 ${simDays}일`;
|
||||
panelTitle.textContent =
|
||||
(m.sim_stage_title || "0단계 선물 벤치마크 (GT 사후 타점)") +
|
||||
` (${simLabel} · 초기 40만원)`;
|
||||
}
|
||||
}
|
||||
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;
|
||||
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} | ${markerRange}${legendExtra}`;
|
||||
renderLegend();
|
||||
if (simMode) renderSimPanel();
|
||||
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-flip-y").onclick = toggleYFlip;
|
||||
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>
|
||||
Reference in New Issue
Block a user