docs: Ground Truth 차트 v1/v2/v3 및 분석 리포트 추가
2년 GT 시각화 HTML·데이터와 2단계 기법 비교 리포트를 저장소에 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
399
docs/02_ground_truth/ground_truth_chart.html
Normal file
399
docs/02_ground_truth/ground_truth_chart.html
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>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_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; }
|
||||||
|
.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; }
|
||||||
|
#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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1 id="title">Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</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;
|
||||||
|
|
||||||
|
function fmtPrice(v) {
|
||||||
|
return Math.round(v).toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLegInfo() {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkers(u, buys, sells) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const drawOne = (m, color, up) => {
|
||||||
|
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 = 5;
|
||||||
|
if (up) {
|
||||||
|
ctx.moveTo(x, y + 10); ctx.lineTo(x - s, y + 18); ctx.lineTo(x + s, y + 18);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(x, y - 10); ctx.lineTo(x - s, y - 18); ctx.lineTo(x + s, y - 18);
|
||||||
|
}
|
||||||
|
ctx.closePath(); ctx.fill();
|
||||||
|
ctx.fillStyle = "#333";
|
||||||
|
ctx.font = "10px Malgun Gothic, Arial";
|
||||||
|
let suffix = "";
|
||||||
|
if (m.signal_type === "pullback") suffix = "*";
|
||||||
|
else if (m.signal_type === "breakout") suffix = "^";
|
||||||
|
else if (m.signal_type === "div_bull" || m.signal_type === "div_bear") suffix = "d";
|
||||||
|
const label = (up ? "B" : "S") + m.marker_id + suffix;
|
||||||
|
ctx.fillText(label, x + 6, y + (up ? 14 : -12));
|
||||||
|
};
|
||||||
|
buys.forEach(m => {
|
||||||
|
let color = "#2e7d32";
|
||||||
|
if (m.signal_type === "breakout") color = "#ef6c00";
|
||||||
|
else if (m.signal_type === "div_bull") color = "#7b1fa2";
|
||||||
|
drawOne(m, color, true);
|
||||||
|
});
|
||||||
|
sells.forEach(m => {
|
||||||
|
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
|
||||||
|
drawOne(m, color, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 opts = {
|
||||||
|
width: document.getElementById("overview").clientWidth,
|
||||||
|
height: 480,
|
||||||
|
scales: { x: { time: true } },
|
||||||
|
axes: [
|
||||||
|
{},
|
||||||
|
{ 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) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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 = "";
|
||||||
|
detailChart = LightweightCharts.createChart(wrap, {
|
||||||
|
layout: { background: { color: "#fff" }, textColor: "#333" },
|
||||||
|
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
|
||||||
|
timeScale: { timeVisible: true, secondsVisible: false },
|
||||||
|
width: wrap.clientWidth,
|
||||||
|
height: 360,
|
||||||
|
});
|
||||||
|
detailSeries = detailChart.addCandlestickSeries({
|
||||||
|
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) {
|
||||||
|
DATA.buy_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "belowBar",
|
||||||
|
color: m.signal_type === "breakout" ? "#ef6c00"
|
||||||
|
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
|
||||||
|
shape: "arrowUp",
|
||||||
|
text: "B" + m.marker_id + (m.signal_type === "pullback" ? "*"
|
||||||
|
: m.signal_type === "breakout" ? "^" : m.signal_type === "div_bull" ? "d" : ""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
DATA.sell_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "aboveBar",
|
||||||
|
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||||
|
shape: "arrowDown",
|
||||||
|
text: "S" + m.marker_id + (m.signal_type === "div_bear" ? "d" : ""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 nearestSellAfter(buyTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.sell_markers) {
|
||||||
|
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const buy = DATA.buy_markers[currentLegIdx];
|
||||||
|
const sell = nearestSellAfter(buy.time);
|
||||||
|
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = buy.time - pad;
|
||||||
|
const vmax = (sell ? sell.time : buy.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; }
|
||||||
|
}
|
||||||
|
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
|
||||||
|
: buy.signal_type === "breakout" ? "돌파 매수"
|
||||||
|
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} — ${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 3분봉 캔들 (${(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 init() {
|
||||||
|
DATA = window.CHART_DATA;
|
||||||
|
if (!DATA) throw new Error("ground_truth_chart_data.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}일`;
|
||||||
|
document.getElementById("title").textContent =
|
||||||
|
`${m.symbol} Ground Truth (${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;
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | B*=눌림 B^=돌파 Bd/Sd=다이버전스`;
|
||||||
|
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>
|
||||||
1
docs/02_ground_truth/ground_truth_chart_data.js
Normal file
1
docs/02_ground_truth/ground_truth_chart_data.js
Normal file
File diff suppressed because one or more lines are too long
469
docs/02_ground_truth/ground_truth_chart_v1.html
Normal file
469
docs/02_ground_truth/ground_truth_chart_v1.html
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>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_v1_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; }
|
||||||
|
.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">Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</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";
|
||||||
|
let axisMeasureCtx = null;
|
||||||
|
|
||||||
|
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 updateLegInfo() {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||||
|
|
||||||
|
function markerSuffix(signalType) {
|
||||||
|
if (signalType === "pullback") return "*";
|
||||||
|
if (signalType === "breakout") return "^";
|
||||||
|
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 drawMarkers(u, buys, sells) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const drawOne = (m, color, up) => {
|
||||||
|
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();
|
||||||
|
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||||
|
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||||
|
};
|
||||||
|
buys.forEach(m => {
|
||||||
|
let color = "#2e7d32";
|
||||||
|
if (m.signal_type === "breakout") color = "#ef6c00";
|
||||||
|
else if (m.signal_type === "div_bull") color = "#7b1fa2";
|
||||||
|
drawOne(m, color, true);
|
||||||
|
});
|
||||||
|
sells.forEach(m => {
|
||||||
|
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
|
||||||
|
drawOne(m, color, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
DATA.buy_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "belowBar",
|
||||||
|
color: m.signal_type === "breakout" ? "#ef6c00"
|
||||||
|
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
|
||||||
|
shape: "arrowUp", size: 3,
|
||||||
|
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
DATA.sell_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "aboveBar",
|
||||||
|
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||||
|
shape: "arrowDown", size: 3,
|
||||||
|
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 nearestSellAfter(buyTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.sell_markers) {
|
||||||
|
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const buy = DATA.buy_markers[currentLegIdx];
|
||||||
|
const sell = nearestSellAfter(buy.time);
|
||||||
|
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = buy.time - pad;
|
||||||
|
const vmax = (sell ? sell.time : buy.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; }
|
||||||
|
}
|
||||||
|
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
|
||||||
|
: buy.signal_type === "breakout" ? "돌파 매수"
|
||||||
|
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} — ${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 3분봉 캔들 (${(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 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 tierKey = (m.chart_tier || "v3").toLowerCase();
|
||||||
|
const legend = tierKey === "v1"
|
||||||
|
? "B=스윙매수 S=스윙매도"
|
||||||
|
: tierKey === "v2"
|
||||||
|
? "B/S=스윙 B*=눌림목"
|
||||||
|
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
|
||||||
|
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>
|
||||||
1
docs/02_ground_truth/ground_truth_chart_v1_data.js
Normal file
1
docs/02_ground_truth/ground_truth_chart_v1_data.js
Normal file
File diff suppressed because one or more lines are too long
469
docs/02_ground_truth/ground_truth_chart_v2.html
Normal file
469
docs/02_ground_truth/ground_truth_chart_v2.html
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>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_v2_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; }
|
||||||
|
.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">Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</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";
|
||||||
|
let axisMeasureCtx = null;
|
||||||
|
|
||||||
|
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 updateLegInfo() {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||||
|
|
||||||
|
function markerSuffix(signalType) {
|
||||||
|
if (signalType === "pullback") return "*";
|
||||||
|
if (signalType === "breakout") return "^";
|
||||||
|
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 drawMarkers(u, buys, sells) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const drawOne = (m, color, up) => {
|
||||||
|
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();
|
||||||
|
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||||
|
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||||
|
};
|
||||||
|
buys.forEach(m => {
|
||||||
|
let color = "#2e7d32";
|
||||||
|
if (m.signal_type === "breakout") color = "#ef6c00";
|
||||||
|
else if (m.signal_type === "div_bull") color = "#7b1fa2";
|
||||||
|
drawOne(m, color, true);
|
||||||
|
});
|
||||||
|
sells.forEach(m => {
|
||||||
|
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
|
||||||
|
drawOne(m, color, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
DATA.buy_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "belowBar",
|
||||||
|
color: m.signal_type === "breakout" ? "#ef6c00"
|
||||||
|
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
|
||||||
|
shape: "arrowUp", size: 3,
|
||||||
|
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
DATA.sell_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "aboveBar",
|
||||||
|
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||||
|
shape: "arrowDown", size: 3,
|
||||||
|
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 nearestSellAfter(buyTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.sell_markers) {
|
||||||
|
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const buy = DATA.buy_markers[currentLegIdx];
|
||||||
|
const sell = nearestSellAfter(buy.time);
|
||||||
|
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = buy.time - pad;
|
||||||
|
const vmax = (sell ? sell.time : buy.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; }
|
||||||
|
}
|
||||||
|
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
|
||||||
|
: buy.signal_type === "breakout" ? "돌파 매수"
|
||||||
|
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} — ${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 3분봉 캔들 (${(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 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 tierKey = (m.chart_tier || "v3").toLowerCase();
|
||||||
|
const legend = tierKey === "v1"
|
||||||
|
? "B=스윙매수 S=스윙매도"
|
||||||
|
: tierKey === "v2"
|
||||||
|
? "B/S=스윙 B*=눌림목"
|
||||||
|
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
|
||||||
|
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>
|
||||||
1
docs/02_ground_truth/ground_truth_chart_v2_data.js
Normal file
1
docs/02_ground_truth/ground_truth_chart_v2_data.js
Normal file
File diff suppressed because one or more lines are too long
469
docs/02_ground_truth/ground_truth_chart_v3.html
Normal file
469
docs/02_ground_truth/ground_truth_chart_v3.html
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>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; }
|
||||||
|
.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">Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</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";
|
||||||
|
let axisMeasureCtx = null;
|
||||||
|
|
||||||
|
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 updateLegInfo() {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||||
|
|
||||||
|
function markerSuffix(signalType) {
|
||||||
|
if (signalType === "pullback") return "*";
|
||||||
|
if (signalType === "breakout") return "^";
|
||||||
|
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 drawMarkers(u, buys, sells) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const drawOne = (m, color, up) => {
|
||||||
|
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();
|
||||||
|
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||||
|
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||||
|
};
|
||||||
|
buys.forEach(m => {
|
||||||
|
let color = "#2e7d32";
|
||||||
|
if (m.signal_type === "breakout") color = "#ef6c00";
|
||||||
|
else if (m.signal_type === "div_bull") color = "#7b1fa2";
|
||||||
|
drawOne(m, color, true);
|
||||||
|
});
|
||||||
|
sells.forEach(m => {
|
||||||
|
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
|
||||||
|
drawOne(m, color, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
DATA.buy_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "belowBar",
|
||||||
|
color: m.signal_type === "breakout" ? "#ef6c00"
|
||||||
|
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
|
||||||
|
shape: "arrowUp", size: 3,
|
||||||
|
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
DATA.sell_markers.forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time, position: "aboveBar",
|
||||||
|
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||||
|
shape: "arrowDown", size: 3,
|
||||||
|
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 nearestSellAfter(buyTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.sell_markers) {
|
||||||
|
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const total = DATA.buy_markers.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const buy = DATA.buy_markers[currentLegIdx];
|
||||||
|
const sell = nearestSellAfter(buy.time);
|
||||||
|
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = buy.time - pad;
|
||||||
|
const vmax = (sell ? sell.time : buy.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; }
|
||||||
|
}
|
||||||
|
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
|
||||||
|
: buy.signal_type === "breakout" ? "돌파 매수"
|
||||||
|
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} — ${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 3분봉 캔들 (${(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 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 tierKey = (m.chart_tier || "v3").toLowerCase();
|
||||||
|
const legend = tierKey === "v1"
|
||||||
|
? "B=스윙매수 S=스윙매도"
|
||||||
|
: tierKey === "v2"
|
||||||
|
? "B/S=스윙 B*=눌림목"
|
||||||
|
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
|
||||||
|
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>
|
||||||
1
docs/02_ground_truth/ground_truth_chart_v3_data.js
Normal file
1
docs/02_ground_truth/ground_truth_chart_v3_data.js
Normal file
File diff suppressed because one or more lines are too long
140
docs/03_analysis/comparison_report.html
Normal file
140
docs/03_analysis/comparison_report.html
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>DeepCoin 2단계 — GT 정합 비교</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #f5f5f5; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 8px; }
|
||||||
|
.meta { color: #666; margin-bottom: 20px; font-size: 14px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; background: #fff; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px 10px; text-align: center; font-size: 13px; }
|
||||||
|
th { background: #e8e8e8; }
|
||||||
|
tr:nth-child(even) { background: #fafafa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>DeepCoin 2단계 — Ground Truth 정합 비교</h1>
|
||||||
|
<div class="meta">
|
||||||
|
생성: 2026-06-08 23:21:02 |
|
||||||
|
BTC |
|
||||||
|
GT: 25레그, +1240.9% |
|
||||||
|
기간: 최근 365일
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>순위</th><th>기법</th><th>유형</th><th>인과</th><th>레그</th>
|
||||||
|
<th>수익률</th><th>매수 Recall</th><th>매도 Recall</th><th>레그 Recall</th>
|
||||||
|
<th>수익 포착</th><th>종합 Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>볼린저 역추세</td>
|
||||||
|
<td>indicator</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>102</td>
|
||||||
|
<td>+2467.6%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>48.0%</td>
|
||||||
|
<td>198.8%</td>
|
||||||
|
<td><strong>81.8</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>RSI 스윙</td>
|
||||||
|
<td>indicator</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>93</td>
|
||||||
|
<td>+2015.2%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>48.0%</td>
|
||||||
|
<td>162.4%</td>
|
||||||
|
<td><strong>81.8</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>MACD 크로스</td>
|
||||||
|
<td>indicator</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>82</td>
|
||||||
|
<td>+1389.3%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>48.0%</td>
|
||||||
|
<td>112.0%</td>
|
||||||
|
<td><strong>81.8</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>4</td>
|
||||||
|
<td>소형 스윙 하이브리드</td>
|
||||||
|
<td>hybrid</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>89</td>
|
||||||
|
<td>+7521.8%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>32.0%</td>
|
||||||
|
<td>606.1%</td>
|
||||||
|
<td><strong>76.2</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>5</td>
|
||||||
|
<td>돈치안 채널</td>
|
||||||
|
<td>swing</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>59</td>
|
||||||
|
<td>+764.8%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>44.0%</td>
|
||||||
|
<td>61.6%</td>
|
||||||
|
<td><strong>74.6</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>6</td>
|
||||||
|
<td>국소 극값</td>
|
||||||
|
<td>swing</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>63</td>
|
||||||
|
<td>+1633.6%</td>
|
||||||
|
<td>92.0%</td>
|
||||||
|
<td>76.0%</td>
|
||||||
|
<td>44.0%</td>
|
||||||
|
<td>131.6%</td>
|
||||||
|
<td><strong>72.4</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>7</td>
|
||||||
|
<td>인과 ZigZag</td>
|
||||||
|
<td>swing</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>24</td>
|
||||||
|
<td>+1150.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>96.0%</td>
|
||||||
|
<td>12.0%</td>
|
||||||
|
<td>92.7%</td>
|
||||||
|
<td><strong>67.1</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>8</td>
|
||||||
|
<td>EMA 크로스</td>
|
||||||
|
<td>indicator</td>
|
||||||
|
<td>Y</td>
|
||||||
|
<td>41</td>
|
||||||
|
<td>+388.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>100.0%</td>
|
||||||
|
<td>28.0%</td>
|
||||||
|
<td>31.3%</td>
|
||||||
|
<td><strong>64.5</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
132
docs/03_analysis/comparison_report.json
Normal file
132
docs/03_analysis/comparison_report.json
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
{
|
||||||
|
"generated_at": "2026-06-08 23:21:02",
|
||||||
|
"symbol": "BTC",
|
||||||
|
"gt": {
|
||||||
|
"leg_count": 25,
|
||||||
|
"return_pct": 1240.94,
|
||||||
|
"interval_label": "3분",
|
||||||
|
"lookback_days": 365
|
||||||
|
},
|
||||||
|
"ranking": [
|
||||||
|
{
|
||||||
|
"technique_id": "bb_reversal",
|
||||||
|
"technique_name": "볼린저 역추세",
|
||||||
|
"category": "indicator",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 102,
|
||||||
|
"tech_return_pct": 2467.57,
|
||||||
|
"buy_recall": 1.0,
|
||||||
|
"sell_recall": 1.0,
|
||||||
|
"leg_recall": 0.48,
|
||||||
|
"return_capture_ratio": 1.9885,
|
||||||
|
"score": 0.818,
|
||||||
|
"avg_buy_offset": 2.8,
|
||||||
|
"avg_sell_offset": 3.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"technique_id": "rsi_swing",
|
||||||
|
"technique_name": "RSI 스윙",
|
||||||
|
"category": "indicator",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 93,
|
||||||
|
"tech_return_pct": 2015.17,
|
||||||
|
"buy_recall": 1.0,
|
||||||
|
"sell_recall": 1.0,
|
||||||
|
"leg_recall": 0.48,
|
||||||
|
"return_capture_ratio": 1.6239,
|
||||||
|
"score": 0.818,
|
||||||
|
"avg_buy_offset": 11.5,
|
||||||
|
"avg_sell_offset": 7.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"technique_id": "macd_cross",
|
||||||
|
"technique_name": "MACD 크로스",
|
||||||
|
"category": "indicator",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 82,
|
||||||
|
"tech_return_pct": 1389.3,
|
||||||
|
"buy_recall": 1.0,
|
||||||
|
"sell_recall": 1.0,
|
||||||
|
"leg_recall": 0.48,
|
||||||
|
"return_capture_ratio": 1.1196,
|
||||||
|
"score": 0.818,
|
||||||
|
"avg_buy_offset": 4.0,
|
||||||
|
"avg_sell_offset": 5.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"technique_id": "minor_swing",
|
||||||
|
"technique_name": "소형 스윙 하이브리드",
|
||||||
|
"category": "hybrid",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 89,
|
||||||
|
"tech_return_pct": 7521.83,
|
||||||
|
"buy_recall": 1.0,
|
||||||
|
"sell_recall": 1.0,
|
||||||
|
"leg_recall": 0.32,
|
||||||
|
"return_capture_ratio": 6.0614,
|
||||||
|
"score": 0.762,
|
||||||
|
"avg_buy_offset": 0.0,
|
||||||
|
"avg_sell_offset": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"technique_id": "donchian",
|
||||||
|
"technique_name": "돈치안 채널",
|
||||||
|
"category": "swing",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 59,
|
||||||
|
"tech_return_pct": 764.78,
|
||||||
|
"buy_recall": 1.0,
|
||||||
|
"sell_recall": 1.0,
|
||||||
|
"leg_recall": 0.44,
|
||||||
|
"return_capture_ratio": 0.6163,
|
||||||
|
"score": 0.7464,
|
||||||
|
"avg_buy_offset": 3.3,
|
||||||
|
"avg_sell_offset": 9.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"technique_id": "local_extrema",
|
||||||
|
"technique_name": "국소 극값",
|
||||||
|
"category": "swing",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 63,
|
||||||
|
"tech_return_pct": 1633.57,
|
||||||
|
"buy_recall": 0.92,
|
||||||
|
"sell_recall": 0.76,
|
||||||
|
"leg_recall": 0.44,
|
||||||
|
"return_capture_ratio": 1.3164,
|
||||||
|
"score": 0.724,
|
||||||
|
"avg_buy_offset": 55.3,
|
||||||
|
"avg_sell_offset": 17.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"technique_id": "zigzag_causal",
|
||||||
|
"technique_name": "인과 ZigZag",
|
||||||
|
"category": "swing",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 24,
|
||||||
|
"tech_return_pct": 1150.01,
|
||||||
|
"buy_recall": 1.0,
|
||||||
|
"sell_recall": 0.96,
|
||||||
|
"leg_recall": 0.12,
|
||||||
|
"return_capture_ratio": 0.9267,
|
||||||
|
"score": 0.671,
|
||||||
|
"avg_buy_offset": 0.0,
|
||||||
|
"avg_sell_offset": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"technique_id": "ma_cross",
|
||||||
|
"technique_name": "EMA 크로스",
|
||||||
|
"category": "indicator",
|
||||||
|
"causal": true,
|
||||||
|
"leg_count": 41,
|
||||||
|
"tech_return_pct": 387.98,
|
||||||
|
"buy_recall": 1.0,
|
||||||
|
"sell_recall": 1.0,
|
||||||
|
"leg_recall": 0.28,
|
||||||
|
"return_capture_ratio": 0.3127,
|
||||||
|
"score": 0.6449,
|
||||||
|
"avg_buy_offset": 36.0,
|
||||||
|
"avg_sell_offset": 35.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user