feat: 현물 GT 기반 선물 Ground Truth 차트 v1~v3 추가
현물 GT 타점을 롱·숏 4색 마커(상·하방 매수·매도)로 변환해 futures HTML 차트를 생성한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -53,7 +53,11 @@ GROUND_TRUTH_CHART_SIM_V1_FILE=docs/02_ground_truth/spot/ground_truth_chart_sim_
|
|||||||
GROUND_TRUTH_CHART_SIM_V2_FILE=docs/02_ground_truth/spot/ground_truth_chart_sim_v2.html
|
GROUND_TRUTH_CHART_SIM_V2_FILE=docs/02_ground_truth/spot/ground_truth_chart_sim_v2.html
|
||||||
GROUND_TRUTH_CHART_SIM_V3_FILE=docs/02_ground_truth/spot/ground_truth_chart_sim_v3.html
|
GROUND_TRUTH_CHART_SIM_V3_FILE=docs/02_ground_truth/spot/ground_truth_chart_sim_v3.html
|
||||||
|
|
||||||
# 선물(futures) GT·차트 경로는 docs/02_ground_truth/futures/, data/ground_truth/futures/ 사용 예정
|
# --- 선물 Ground Truth 차트 (현물 GT 타점 기반, 롱·숏 4색) ---
|
||||||
|
GROUND_TRUTH_FUTURES_CHART_V1_FILE=docs/02_ground_truth/futures/ground_truth_chart_v1.html
|
||||||
|
GROUND_TRUTH_FUTURES_CHART_V2_FILE=docs/02_ground_truth/futures/ground_truth_chart_v2.html
|
||||||
|
GROUND_TRUTH_FUTURES_CHART_V3_FILE=docs/02_ground_truth/futures/ground_truth_chart_v3.html
|
||||||
|
|
||||||
# GT 타점 차트(v1~v3)는 docs/02_ground_truth/gt/, 현물 sim 차트는 docs/02_ground_truth/spot/
|
# GT 타점 차트(v1~v3)는 docs/02_ground_truth/gt/, 현물 sim 차트는 docs/02_ground_truth/spot/
|
||||||
|
|
||||||
# --- 매매 기법 (2단계) ---
|
# --- 매매 기법 (2단계) ---
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ python scripts/01_download.py
|
|||||||
# 1. Ground Truth (매수·매도 벤치마크 타점)
|
# 1. Ground Truth (매수·매도 벤치마크 타점)
|
||||||
python scripts/02_ground_truth.py
|
python scripts/02_ground_truth.py
|
||||||
|
|
||||||
|
# 1-b. 선물 GT 차트 (현물 GT → 롱·숏 4색)
|
||||||
|
python scripts/02_ground_truth_futures.py
|
||||||
|
|
||||||
# 2. 매매 기법 실행 및 GT 정합 비교
|
# 2. 매매 기법 실행 및 GT 정합 비교
|
||||||
python scripts/03_run_techniques.py
|
python scripts/03_run_techniques.py
|
||||||
```
|
```
|
||||||
@@ -113,13 +116,14 @@ DeepCoin/
|
|||||||
│ ├── 02_ground_truth/
|
│ ├── 02_ground_truth/
|
||||||
│ │ ├── gt/ # 1단계 GT 타점 차트 (v1~v3)
|
│ │ ├── gt/ # 1단계 GT 타점 차트 (v1~v3)
|
||||||
│ │ ├── spot/ # 2단계 현물 sim 차트
|
│ │ ├── spot/ # 2단계 현물 sim 차트
|
||||||
│ │ └── futures/ # 선물 GT·sim 차트 (예정)
|
│ │ └── futures/ # 선물 GT 차트 (롱·숏 4색, v1~v3)
|
||||||
│ └── 03_analysis/
|
│ └── 03_analysis/
|
||||||
└── coins.db
|
└── coins.db
|
||||||
```
|
```
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
|
|
||||||
|
- 2026-06-09: 선물 GT 차트 v1~v3 추가 (현물 GT → 롱·숏 4색 마커)
|
||||||
- 2026-06-09: docs/02_ground_truth를 gt(타점)·spot(sim)·futures 3폴더로 분리
|
- 2026-06-09: docs/02_ground_truth를 gt(타점)·spot(sim)·futures 3폴더로 분리
|
||||||
- 2026-06-09: Ground Truth 산출물 현물(spot)/선물(futures) 폴더 분리
|
- 2026-06-09: Ground Truth 산출물 현물(spot)/선물(futures) 폴더 분리
|
||||||
- 2026-06-08: 2단계 매매 기법 8종 + GT 정합 비교 리포트 추가
|
- 2026-06-08: 2단계 매매 기법 8종 + GT 정합 비교 리포트 추가
|
||||||
|
|||||||
495
docs/02_ground_truth/futures/ground_truth_chart_v1.html
Normal file
495
docs/02_ground_truth/futures/ground_truth_chart_v1.html
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Futures Ground Truth Chart</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script src="ground_truth_chart_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; }
|
||||||
|
.legend { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||||||
|
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
|
||||||
|
.toolbar-group:last-of-type { border-right: none; }
|
||||||
|
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.toolbar button:hover { background: #f0f4f8; }
|
||||||
|
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
|
||||||
|
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
|
||||||
|
.toolbar button.home:hover { background: #1b5e20; }
|
||||||
|
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||||
|
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||||
|
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||||
|
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||||
|
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||||
|
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1 id="title">Futures Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
<div class="legend" id="legend"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 화면으로 복귀">홈</button>
|
||||||
|
<button id="btn-prev-leg" title="이전 롱 레그">◀ 이전</button>
|
||||||
|
<button id="btn-next-leg" title="다음 롱 레그">다음 ▶</button>
|
||||||
|
<span class="leg-info" id="leg-info">타점 - / -</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-all" class="btn-period active">전체</button>
|
||||||
|
<button id="btn-365d" class="btn-period">1년</button>
|
||||||
|
<button id="btn-30d" class="btn-period">30일</button>
|
||||||
|
<button id="btn-7d" class="btn-period">7일</button>
|
||||||
|
<button id="btn-3d" class="btn-period">3일</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-zoom-in" title="확대">+ 확대</button>
|
||||||
|
<button id="btn-zoom-out" title="축소">− 축소</button>
|
||||||
|
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
|
||||||
|
<button id="btn-toggle-detail" title="상세 캔들 패널">상세 패널</button>
|
||||||
|
</div>
|
||||||
|
<span id="status">데이터 로딩 중…</span>
|
||||||
|
</div>
|
||||||
|
<div id="overview"></div>
|
||||||
|
<div id="detail-wrap">
|
||||||
|
<h2 id="detail-title">상세 캔들</h2>
|
||||||
|
<div id="detail"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let DATA = null;
|
||||||
|
let overviewPlot = null;
|
||||||
|
let detailChart = null;
|
||||||
|
let detailSeries = null;
|
||||||
|
let currentMode = "overview";
|
||||||
|
let currentLegIdx = 0;
|
||||||
|
let showMarkers = true;
|
||||||
|
let detailVisible = false;
|
||||||
|
let lastDetailStart = 0;
|
||||||
|
|
||||||
|
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||||
|
const MARKER_FONT = "bold 17px Malgun Gothic, Arial, sans-serif";
|
||||||
|
let axisMeasureCtx = null;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
longOpen: "#1565c0",
|
||||||
|
longClose: "#64b5f6",
|
||||||
|
shortOpen: "#c62828",
|
||||||
|
shortClose: "#ff8a65",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtPrice(v) {
|
||||||
|
return Math.round(v).toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureTextWidth(text, font) {
|
||||||
|
if (!axisMeasureCtx) {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
axisMeasureCtx = c.getContext("2d");
|
||||||
|
}
|
||||||
|
axisMeasureCtx.font = font;
|
||||||
|
return axisMeasureCtx.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yAxisLabelWidth() {
|
||||||
|
const vals = DATA.close;
|
||||||
|
if (!vals || !vals.length) return 88;
|
||||||
|
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||||
|
let lo = vals[0], hi = vals[0];
|
||||||
|
for (let i = 1; i < vals.length; i++) {
|
||||||
|
if (vals[i] < lo) lo = vals[i];
|
||||||
|
if (vals[i] > hi) hi = vals[i];
|
||||||
|
}
|
||||||
|
samples.add(lo);
|
||||||
|
samples.add(hi);
|
||||||
|
samples.add((lo + hi) / 2);
|
||||||
|
let maxW = 0;
|
||||||
|
samples.forEach(v => {
|
||||||
|
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||||
|
});
|
||||||
|
return Math.ceil(maxW) + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerSuffix(signalType) {
|
||||||
|
if (signalType === "pullback") return "*";
|
||||||
|
if (signalType === "breakout") return "^";
|
||||||
|
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerLabel(kind, m) {
|
||||||
|
const id = m.marker_id;
|
||||||
|
const sfx = markerSuffix(m.signal_type);
|
||||||
|
if (kind === "longOpen") return "L↑" + id + sfx;
|
||||||
|
if (kind === "longClose") return "L↓" + id + sfx;
|
||||||
|
if (kind === "shortOpen") return "S↓" + id + sfx;
|
||||||
|
return "S↑" + id + sfx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||||
|
ctx.font = MARKER_FONT;
|
||||||
|
const lx = x + 10;
|
||||||
|
const ly = y + (up ? 28 : -20);
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||||
|
ctx.strokeText(label, lx, ly);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillText(label, lx, ly);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOneMarker(u, m, color, up, label) {
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const x = u.valToPos(m.time, "x", true);
|
||||||
|
const y = u.valToPos(m.price, "y", true);
|
||||||
|
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
const s = 8;
|
||||||
|
const gap = 12;
|
||||||
|
if (up) {
|
||||||
|
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFuturesMarkers(u) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
(DATA.long_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
|
||||||
|
(DATA.long_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longClose, false, markerLabel("longClose", m)));
|
||||||
|
(DATA.short_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortOpen, false, markerLabel("shortOpen", m)));
|
||||||
|
(DATA.short_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLegInfo() {
|
||||||
|
const total = (DATA.long_open_markers || []).length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `롱 레그 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overviewXRange() {
|
||||||
|
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
|
||||||
|
const s = overviewPlot.scales.x;
|
||||||
|
return { min: s.min, max: s.max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverviewXRange(min, max) {
|
||||||
|
const t0 = DATA.times[0];
|
||||||
|
const t1 = DATA.times[DATA.times.length - 1];
|
||||||
|
overviewPlot.setScale("x", {
|
||||||
|
min: Math.max(t0, min),
|
||||||
|
max: Math.min(t1, max),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitOverview() {
|
||||||
|
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOverview(factor) {
|
||||||
|
const { min, max } = overviewXRange();
|
||||||
|
const mid = (min + max) / 2;
|
||||||
|
const half = Math.max((max - min) * factor / 2, 3600);
|
||||||
|
setOverviewXRange(mid - half, mid + half);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverview(keepRange) {
|
||||||
|
const prev = keepRange ? overviewXRange() : null;
|
||||||
|
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||||
|
const yAxisW = yAxisLabelWidth();
|
||||||
|
const opts = {
|
||||||
|
width: document.getElementById("overview").clientWidth,
|
||||||
|
height: 480,
|
||||||
|
padding: [14, 10, 14, 10],
|
||||||
|
scales: { x: { time: true } },
|
||||||
|
axes: [
|
||||||
|
{ gap: 6 },
|
||||||
|
{
|
||||||
|
side: 3,
|
||||||
|
size: yAxisW,
|
||||||
|
gap: 10,
|
||||||
|
font: AXIS_FONT,
|
||||||
|
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [{}, { label: "종가", stroke: "#1565c0", width: 1 }],
|
||||||
|
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||||
|
hooks: { draw: [(u) => drawFuturesMarkers(u)] },
|
||||||
|
};
|
||||||
|
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
|
||||||
|
if (prev && keepRange) setOverviewXRange(prev.min, prev.max);
|
||||||
|
else fitOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceLastDays(days) {
|
||||||
|
const cutoff = DATA.times[DATA.times.length - 1] - days * 86400;
|
||||||
|
let start = 0;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] < cutoff) { start = i + 1; break; }
|
||||||
|
}
|
||||||
|
return { start, end: DATA.times.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetailCandles(startIdx, endIdx) {
|
||||||
|
lastDetailStart = startIdx;
|
||||||
|
const end = endIdx || DATA.times.length;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
const wrap = document.getElementById("detail");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const priceAxisW = yAxisLabelWidth();
|
||||||
|
detailChart = LightweightCharts.createChart(wrap, {
|
||||||
|
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
|
||||||
|
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
|
||||||
|
rightPriceScale: { visible: false },
|
||||||
|
leftPriceScale: {
|
||||||
|
borderVisible: true,
|
||||||
|
minimumWidth: priceAxisW,
|
||||||
|
scaleMargins: { top: 0.08, bottom: 0.08 },
|
||||||
|
},
|
||||||
|
timeScale: { timeVisible: true, secondsVisible: false },
|
||||||
|
width: wrap.clientWidth,
|
||||||
|
height: 360,
|
||||||
|
});
|
||||||
|
detailSeries = detailChart.addCandlestickSeries({
|
||||||
|
priceScaleId: "left",
|
||||||
|
upColor: "#c62828", downColor: "#1565c0",
|
||||||
|
borderUpColor: "#c62828", borderDownColor: "#1565c0",
|
||||||
|
wickUpColor: "#c62828", wickDownColor: "#1565c0",
|
||||||
|
});
|
||||||
|
const candles = [];
|
||||||
|
for (let i = startIdx; i < end; i++) {
|
||||||
|
candles.push({
|
||||||
|
time: DATA.times[i],
|
||||||
|
open: DATA.open[i], high: DATA.high[i],
|
||||||
|
low: DATA.low[i], close: DATA.close[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
detailSeries.setData(candles);
|
||||||
|
const t0 = DATA.times[startIdx];
|
||||||
|
const t1 = DATA.times[end - 1];
|
||||||
|
const markers = [];
|
||||||
|
if (showMarkers) {
|
||||||
|
const add = (list, kind, up) => {
|
||||||
|
const colorMap = {
|
||||||
|
longOpen: COLORS.longOpen,
|
||||||
|
longClose: COLORS.longClose,
|
||||||
|
shortOpen: COLORS.shortOpen,
|
||||||
|
shortClose: COLORS.shortClose,
|
||||||
|
};
|
||||||
|
(list || []).forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time,
|
||||||
|
position: up ? "belowBar" : "aboveBar",
|
||||||
|
color: colorMap[kind],
|
||||||
|
shape: up ? "arrowUp" : "arrowDown",
|
||||||
|
size: 3,
|
||||||
|
text: markerLabel(kind, m),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
add(DATA.long_open_markers, "longOpen", true);
|
||||||
|
add(DATA.long_close_markers, "longClose", false);
|
||||||
|
add(DATA.short_open_markers, "shortOpen", false);
|
||||||
|
add(DATA.short_close_markers, "shortClose", true);
|
||||||
|
}
|
||||||
|
markers.sort((a, b) => a.time - b.time);
|
||||||
|
detailSeries.setMarkers(markers);
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(btnId) {
|
||||||
|
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
|
||||||
|
const el = document.getElementById(btnId);
|
||||||
|
if (el) el.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
else fitOverview();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestLongCloseAfter(openTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.long_close_markers || []) {
|
||||||
|
if (s.time >= openTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || (DATA.long_close_markers || [])[(DATA.long_close_markers || []).length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const opens = DATA.long_open_markers || [];
|
||||||
|
const total = opens.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const lo = opens[currentLegIdx];
|
||||||
|
const lc = nearestLongCloseAfter(lo.time);
|
||||||
|
const span = lc ? Math.max(lc.time - lo.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = lo.time - pad;
|
||||||
|
const vmax = (lc ? lc.time : lo.time) + pad;
|
||||||
|
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
setOverviewXRange(vmin, vmax);
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < DATA.times.length; i++) {
|
||||||
|
if (DATA.times[i] >= vmin) { start = i; break; }
|
||||||
|
}
|
||||||
|
let end = DATA.times.length;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] <= vmax) { end = i + 1; break; }
|
||||||
|
}
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`L↑${lo.marker_id} 상방 매수 — ${new Date(lo.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const closeText = lc ? ` → 상방 매도 ${fmtPrice(lc.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`L↑${lo.marker_id} ${fmtPrice(lo.price)}${closeText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPeriod(days, btnId, label) {
|
||||||
|
currentMode = "detail";
|
||||||
|
setActive(btnId);
|
||||||
|
detailVisible = true;
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
|
||||||
|
const { start } = sliceLastDays(days);
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`${label} 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
|
||||||
|
buildDetailCandles(start);
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
const t0 = DATA.times[start];
|
||||||
|
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
|
||||||
|
document.getElementById("status").textContent = `${label} 구간 표시`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom(factor) {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
const ts = detailChart.timeScale();
|
||||||
|
const r = ts.getVisibleLogicalRange();
|
||||||
|
if (!r) return;
|
||||||
|
const mid = (r.from + r.to) / 2;
|
||||||
|
const half = Math.max((r.to - r.from) * factor / 2, 10);
|
||||||
|
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
zoomOverview(factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFit() {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
fitOverview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend() {
|
||||||
|
const items = [
|
||||||
|
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
|
||||||
|
[COLORS.longClose, "L↓ 상방 매도 (롱 청산)"],
|
||||||
|
[COLORS.shortOpen, "S↓ 하방 매수 (숏 진입)"],
|
||||||
|
[COLORS.shortClose, "S↑ 하방 매도 (숏 청산)"],
|
||||||
|
];
|
||||||
|
document.getElementById("legend").innerHTML = items.map(([color, text]) =>
|
||||||
|
`<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${text}</span>`
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
DATA = window.CHART_DATA;
|
||||||
|
if (!DATA) throw new Error("차트 데이터 JS 없음");
|
||||||
|
const m = DATA.meta;
|
||||||
|
const chartDays = m.chart_lookback_days || m.lookback_days;
|
||||||
|
const gtDays = m.gt_lookback_days || m.lookback_days;
|
||||||
|
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
|
||||||
|
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
|
||||||
|
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
|
||||||
|
document.getElementById("title").textContent =
|
||||||
|
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
|
||||||
|
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
|
||||||
|
const chartFrom = m.chart_data_from || m.data_from;
|
||||||
|
const chartTo = m.chart_data_to || m.data_to;
|
||||||
|
const lo = (DATA.long_open_markers || []).length;
|
||||||
|
const lc = (DATA.long_close_markers || []).length;
|
||||||
|
const so = (DATA.short_open_markers || []).length;
|
||||||
|
const sc = (DATA.short_close_markers || []).length;
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
|
||||||
|
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | GT ${gtLabel} | 현물 GT 타점 기반`;
|
||||||
|
renderLegend();
|
||||||
|
updateLegInfo();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
|
||||||
|
buildOverview(false);
|
||||||
|
|
||||||
|
document.getElementById("btn-home").onclick = goHome;
|
||||||
|
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
|
||||||
|
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
|
||||||
|
document.getElementById("btn-all").onclick = goHome;
|
||||||
|
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
|
||||||
|
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
|
||||||
|
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
|
||||||
|
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
|
||||||
|
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
|
||||||
|
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
|
||||||
|
document.getElementById("btn-fit").onclick = applyFit;
|
||||||
|
document.getElementById("btn-markers").onclick = () => {
|
||||||
|
showMarkers = !showMarkers;
|
||||||
|
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
if (detailChart) buildDetailCandles(lastDetailStart);
|
||||||
|
};
|
||||||
|
document.getElementById("btn-toggle-detail").onclick = () => {
|
||||||
|
detailVisible = !detailVisible;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
|
||||||
|
if (detailVisible && !detailChart) {
|
||||||
|
const { start } = sliceLastDays(7);
|
||||||
|
buildDetailCandles(start);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById("overview").addEventListener("dblclick", () => {
|
||||||
|
if (currentMode === "overview") fitOverview();
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try { init(); } catch (err) {
|
||||||
|
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
495
docs/02_ground_truth/futures/ground_truth_chart_v2.html
Normal file
495
docs/02_ground_truth/futures/ground_truth_chart_v2.html
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Futures Ground Truth Chart</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script src="ground_truth_chart_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; }
|
||||||
|
.legend { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||||||
|
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
|
||||||
|
.toolbar-group:last-of-type { border-right: none; }
|
||||||
|
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.toolbar button:hover { background: #f0f4f8; }
|
||||||
|
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
|
||||||
|
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
|
||||||
|
.toolbar button.home:hover { background: #1b5e20; }
|
||||||
|
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||||
|
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||||
|
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||||
|
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||||
|
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||||
|
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1 id="title">Futures Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
<div class="legend" id="legend"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 화면으로 복귀">홈</button>
|
||||||
|
<button id="btn-prev-leg" title="이전 롱 레그">◀ 이전</button>
|
||||||
|
<button id="btn-next-leg" title="다음 롱 레그">다음 ▶</button>
|
||||||
|
<span class="leg-info" id="leg-info">타점 - / -</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-all" class="btn-period active">전체</button>
|
||||||
|
<button id="btn-365d" class="btn-period">1년</button>
|
||||||
|
<button id="btn-30d" class="btn-period">30일</button>
|
||||||
|
<button id="btn-7d" class="btn-period">7일</button>
|
||||||
|
<button id="btn-3d" class="btn-period">3일</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-zoom-in" title="확대">+ 확대</button>
|
||||||
|
<button id="btn-zoom-out" title="축소">− 축소</button>
|
||||||
|
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
|
||||||
|
<button id="btn-toggle-detail" title="상세 캔들 패널">상세 패널</button>
|
||||||
|
</div>
|
||||||
|
<span id="status">데이터 로딩 중…</span>
|
||||||
|
</div>
|
||||||
|
<div id="overview"></div>
|
||||||
|
<div id="detail-wrap">
|
||||||
|
<h2 id="detail-title">상세 캔들</h2>
|
||||||
|
<div id="detail"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let DATA = null;
|
||||||
|
let overviewPlot = null;
|
||||||
|
let detailChart = null;
|
||||||
|
let detailSeries = null;
|
||||||
|
let currentMode = "overview";
|
||||||
|
let currentLegIdx = 0;
|
||||||
|
let showMarkers = true;
|
||||||
|
let detailVisible = false;
|
||||||
|
let lastDetailStart = 0;
|
||||||
|
|
||||||
|
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||||
|
const MARKER_FONT = "bold 17px Malgun Gothic, Arial, sans-serif";
|
||||||
|
let axisMeasureCtx = null;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
longOpen: "#1565c0",
|
||||||
|
longClose: "#64b5f6",
|
||||||
|
shortOpen: "#c62828",
|
||||||
|
shortClose: "#ff8a65",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtPrice(v) {
|
||||||
|
return Math.round(v).toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureTextWidth(text, font) {
|
||||||
|
if (!axisMeasureCtx) {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
axisMeasureCtx = c.getContext("2d");
|
||||||
|
}
|
||||||
|
axisMeasureCtx.font = font;
|
||||||
|
return axisMeasureCtx.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yAxisLabelWidth() {
|
||||||
|
const vals = DATA.close;
|
||||||
|
if (!vals || !vals.length) return 88;
|
||||||
|
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||||
|
let lo = vals[0], hi = vals[0];
|
||||||
|
for (let i = 1; i < vals.length; i++) {
|
||||||
|
if (vals[i] < lo) lo = vals[i];
|
||||||
|
if (vals[i] > hi) hi = vals[i];
|
||||||
|
}
|
||||||
|
samples.add(lo);
|
||||||
|
samples.add(hi);
|
||||||
|
samples.add((lo + hi) / 2);
|
||||||
|
let maxW = 0;
|
||||||
|
samples.forEach(v => {
|
||||||
|
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||||
|
});
|
||||||
|
return Math.ceil(maxW) + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerSuffix(signalType) {
|
||||||
|
if (signalType === "pullback") return "*";
|
||||||
|
if (signalType === "breakout") return "^";
|
||||||
|
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerLabel(kind, m) {
|
||||||
|
const id = m.marker_id;
|
||||||
|
const sfx = markerSuffix(m.signal_type);
|
||||||
|
if (kind === "longOpen") return "L↑" + id + sfx;
|
||||||
|
if (kind === "longClose") return "L↓" + id + sfx;
|
||||||
|
if (kind === "shortOpen") return "S↓" + id + sfx;
|
||||||
|
return "S↑" + id + sfx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||||
|
ctx.font = MARKER_FONT;
|
||||||
|
const lx = x + 10;
|
||||||
|
const ly = y + (up ? 28 : -20);
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||||
|
ctx.strokeText(label, lx, ly);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillText(label, lx, ly);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOneMarker(u, m, color, up, label) {
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const x = u.valToPos(m.time, "x", true);
|
||||||
|
const y = u.valToPos(m.price, "y", true);
|
||||||
|
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
const s = 8;
|
||||||
|
const gap = 12;
|
||||||
|
if (up) {
|
||||||
|
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFuturesMarkers(u) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
(DATA.long_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
|
||||||
|
(DATA.long_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longClose, false, markerLabel("longClose", m)));
|
||||||
|
(DATA.short_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortOpen, false, markerLabel("shortOpen", m)));
|
||||||
|
(DATA.short_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLegInfo() {
|
||||||
|
const total = (DATA.long_open_markers || []).length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `롱 레그 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overviewXRange() {
|
||||||
|
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
|
||||||
|
const s = overviewPlot.scales.x;
|
||||||
|
return { min: s.min, max: s.max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverviewXRange(min, max) {
|
||||||
|
const t0 = DATA.times[0];
|
||||||
|
const t1 = DATA.times[DATA.times.length - 1];
|
||||||
|
overviewPlot.setScale("x", {
|
||||||
|
min: Math.max(t0, min),
|
||||||
|
max: Math.min(t1, max),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitOverview() {
|
||||||
|
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOverview(factor) {
|
||||||
|
const { min, max } = overviewXRange();
|
||||||
|
const mid = (min + max) / 2;
|
||||||
|
const half = Math.max((max - min) * factor / 2, 3600);
|
||||||
|
setOverviewXRange(mid - half, mid + half);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverview(keepRange) {
|
||||||
|
const prev = keepRange ? overviewXRange() : null;
|
||||||
|
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||||
|
const yAxisW = yAxisLabelWidth();
|
||||||
|
const opts = {
|
||||||
|
width: document.getElementById("overview").clientWidth,
|
||||||
|
height: 480,
|
||||||
|
padding: [14, 10, 14, 10],
|
||||||
|
scales: { x: { time: true } },
|
||||||
|
axes: [
|
||||||
|
{ gap: 6 },
|
||||||
|
{
|
||||||
|
side: 3,
|
||||||
|
size: yAxisW,
|
||||||
|
gap: 10,
|
||||||
|
font: AXIS_FONT,
|
||||||
|
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [{}, { label: "종가", stroke: "#1565c0", width: 1 }],
|
||||||
|
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||||
|
hooks: { draw: [(u) => drawFuturesMarkers(u)] },
|
||||||
|
};
|
||||||
|
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
|
||||||
|
if (prev && keepRange) setOverviewXRange(prev.min, prev.max);
|
||||||
|
else fitOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceLastDays(days) {
|
||||||
|
const cutoff = DATA.times[DATA.times.length - 1] - days * 86400;
|
||||||
|
let start = 0;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] < cutoff) { start = i + 1; break; }
|
||||||
|
}
|
||||||
|
return { start, end: DATA.times.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetailCandles(startIdx, endIdx) {
|
||||||
|
lastDetailStart = startIdx;
|
||||||
|
const end = endIdx || DATA.times.length;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
const wrap = document.getElementById("detail");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const priceAxisW = yAxisLabelWidth();
|
||||||
|
detailChart = LightweightCharts.createChart(wrap, {
|
||||||
|
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
|
||||||
|
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
|
||||||
|
rightPriceScale: { visible: false },
|
||||||
|
leftPriceScale: {
|
||||||
|
borderVisible: true,
|
||||||
|
minimumWidth: priceAxisW,
|
||||||
|
scaleMargins: { top: 0.08, bottom: 0.08 },
|
||||||
|
},
|
||||||
|
timeScale: { timeVisible: true, secondsVisible: false },
|
||||||
|
width: wrap.clientWidth,
|
||||||
|
height: 360,
|
||||||
|
});
|
||||||
|
detailSeries = detailChart.addCandlestickSeries({
|
||||||
|
priceScaleId: "left",
|
||||||
|
upColor: "#c62828", downColor: "#1565c0",
|
||||||
|
borderUpColor: "#c62828", borderDownColor: "#1565c0",
|
||||||
|
wickUpColor: "#c62828", wickDownColor: "#1565c0",
|
||||||
|
});
|
||||||
|
const candles = [];
|
||||||
|
for (let i = startIdx; i < end; i++) {
|
||||||
|
candles.push({
|
||||||
|
time: DATA.times[i],
|
||||||
|
open: DATA.open[i], high: DATA.high[i],
|
||||||
|
low: DATA.low[i], close: DATA.close[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
detailSeries.setData(candles);
|
||||||
|
const t0 = DATA.times[startIdx];
|
||||||
|
const t1 = DATA.times[end - 1];
|
||||||
|
const markers = [];
|
||||||
|
if (showMarkers) {
|
||||||
|
const add = (list, kind, up) => {
|
||||||
|
const colorMap = {
|
||||||
|
longOpen: COLORS.longOpen,
|
||||||
|
longClose: COLORS.longClose,
|
||||||
|
shortOpen: COLORS.shortOpen,
|
||||||
|
shortClose: COLORS.shortClose,
|
||||||
|
};
|
||||||
|
(list || []).forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time,
|
||||||
|
position: up ? "belowBar" : "aboveBar",
|
||||||
|
color: colorMap[kind],
|
||||||
|
shape: up ? "arrowUp" : "arrowDown",
|
||||||
|
size: 3,
|
||||||
|
text: markerLabel(kind, m),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
add(DATA.long_open_markers, "longOpen", true);
|
||||||
|
add(DATA.long_close_markers, "longClose", false);
|
||||||
|
add(DATA.short_open_markers, "shortOpen", false);
|
||||||
|
add(DATA.short_close_markers, "shortClose", true);
|
||||||
|
}
|
||||||
|
markers.sort((a, b) => a.time - b.time);
|
||||||
|
detailSeries.setMarkers(markers);
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(btnId) {
|
||||||
|
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
|
||||||
|
const el = document.getElementById(btnId);
|
||||||
|
if (el) el.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
else fitOverview();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestLongCloseAfter(openTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.long_close_markers || []) {
|
||||||
|
if (s.time >= openTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || (DATA.long_close_markers || [])[(DATA.long_close_markers || []).length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const opens = DATA.long_open_markers || [];
|
||||||
|
const total = opens.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const lo = opens[currentLegIdx];
|
||||||
|
const lc = nearestLongCloseAfter(lo.time);
|
||||||
|
const span = lc ? Math.max(lc.time - lo.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = lo.time - pad;
|
||||||
|
const vmax = (lc ? lc.time : lo.time) + pad;
|
||||||
|
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
setOverviewXRange(vmin, vmax);
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < DATA.times.length; i++) {
|
||||||
|
if (DATA.times[i] >= vmin) { start = i; break; }
|
||||||
|
}
|
||||||
|
let end = DATA.times.length;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] <= vmax) { end = i + 1; break; }
|
||||||
|
}
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`L↑${lo.marker_id} 상방 매수 — ${new Date(lo.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const closeText = lc ? ` → 상방 매도 ${fmtPrice(lc.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`L↑${lo.marker_id} ${fmtPrice(lo.price)}${closeText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPeriod(days, btnId, label) {
|
||||||
|
currentMode = "detail";
|
||||||
|
setActive(btnId);
|
||||||
|
detailVisible = true;
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
|
||||||
|
const { start } = sliceLastDays(days);
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`${label} 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
|
||||||
|
buildDetailCandles(start);
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
const t0 = DATA.times[start];
|
||||||
|
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
|
||||||
|
document.getElementById("status").textContent = `${label} 구간 표시`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom(factor) {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
const ts = detailChart.timeScale();
|
||||||
|
const r = ts.getVisibleLogicalRange();
|
||||||
|
if (!r) return;
|
||||||
|
const mid = (r.from + r.to) / 2;
|
||||||
|
const half = Math.max((r.to - r.from) * factor / 2, 10);
|
||||||
|
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
zoomOverview(factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFit() {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
fitOverview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend() {
|
||||||
|
const items = [
|
||||||
|
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
|
||||||
|
[COLORS.longClose, "L↓ 상방 매도 (롱 청산)"],
|
||||||
|
[COLORS.shortOpen, "S↓ 하방 매수 (숏 진입)"],
|
||||||
|
[COLORS.shortClose, "S↑ 하방 매도 (숏 청산)"],
|
||||||
|
];
|
||||||
|
document.getElementById("legend").innerHTML = items.map(([color, text]) =>
|
||||||
|
`<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${text}</span>`
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
DATA = window.CHART_DATA;
|
||||||
|
if (!DATA) throw new Error("차트 데이터 JS 없음");
|
||||||
|
const m = DATA.meta;
|
||||||
|
const chartDays = m.chart_lookback_days || m.lookback_days;
|
||||||
|
const gtDays = m.gt_lookback_days || m.lookback_days;
|
||||||
|
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
|
||||||
|
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
|
||||||
|
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
|
||||||
|
document.getElementById("title").textContent =
|
||||||
|
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
|
||||||
|
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
|
||||||
|
const chartFrom = m.chart_data_from || m.data_from;
|
||||||
|
const chartTo = m.chart_data_to || m.data_to;
|
||||||
|
const lo = (DATA.long_open_markers || []).length;
|
||||||
|
const lc = (DATA.long_close_markers || []).length;
|
||||||
|
const so = (DATA.short_open_markers || []).length;
|
||||||
|
const sc = (DATA.short_close_markers || []).length;
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
|
||||||
|
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | GT ${gtLabel} | 현물 GT 타점 기반`;
|
||||||
|
renderLegend();
|
||||||
|
updateLegInfo();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
|
||||||
|
buildOverview(false);
|
||||||
|
|
||||||
|
document.getElementById("btn-home").onclick = goHome;
|
||||||
|
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
|
||||||
|
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
|
||||||
|
document.getElementById("btn-all").onclick = goHome;
|
||||||
|
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
|
||||||
|
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
|
||||||
|
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
|
||||||
|
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
|
||||||
|
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
|
||||||
|
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
|
||||||
|
document.getElementById("btn-fit").onclick = applyFit;
|
||||||
|
document.getElementById("btn-markers").onclick = () => {
|
||||||
|
showMarkers = !showMarkers;
|
||||||
|
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
if (detailChart) buildDetailCandles(lastDetailStart);
|
||||||
|
};
|
||||||
|
document.getElementById("btn-toggle-detail").onclick = () => {
|
||||||
|
detailVisible = !detailVisible;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
|
||||||
|
if (detailVisible && !detailChart) {
|
||||||
|
const { start } = sliceLastDays(7);
|
||||||
|
buildDetailCandles(start);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById("overview").addEventListener("dblclick", () => {
|
||||||
|
if (currentMode === "overview") fitOverview();
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try { init(); } catch (err) {
|
||||||
|
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
495
docs/02_ground_truth/futures/ground_truth_chart_v3.html
Normal file
495
docs/02_ground_truth/futures/ground_truth_chart_v3.html
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Futures Ground Truth Chart</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script src="ground_truth_chart_v3_data.js"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
|
||||||
|
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
|
||||||
|
h1 { margin: 0 0 6px; font-size: 20px; }
|
||||||
|
.meta { font-size: 13px; color: #666; }
|
||||||
|
.legend { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||||||
|
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
|
||||||
|
.toolbar-group:last-of-type { border-right: none; }
|
||||||
|
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.toolbar button:hover { background: #f0f4f8; }
|
||||||
|
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
|
||||||
|
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
|
||||||
|
.toolbar button.home:hover { background: #1b5e20; }
|
||||||
|
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||||
|
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||||
|
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||||
|
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||||
|
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||||
|
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1 id="title">Futures Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
<div class="legend" id="legend"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 화면으로 복귀">홈</button>
|
||||||
|
<button id="btn-prev-leg" title="이전 롱 레그">◀ 이전</button>
|
||||||
|
<button id="btn-next-leg" title="다음 롱 레그">다음 ▶</button>
|
||||||
|
<span class="leg-info" id="leg-info">타점 - / -</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-all" class="btn-period active">전체</button>
|
||||||
|
<button id="btn-365d" class="btn-period">1년</button>
|
||||||
|
<button id="btn-30d" class="btn-period">30일</button>
|
||||||
|
<button id="btn-7d" class="btn-period">7일</button>
|
||||||
|
<button id="btn-3d" class="btn-period">3일</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-zoom-in" title="확대">+ 확대</button>
|
||||||
|
<button id="btn-zoom-out" title="축소">− 축소</button>
|
||||||
|
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
|
||||||
|
<button id="btn-toggle-detail" title="상세 캔들 패널">상세 패널</button>
|
||||||
|
</div>
|
||||||
|
<span id="status">데이터 로딩 중…</span>
|
||||||
|
</div>
|
||||||
|
<div id="overview"></div>
|
||||||
|
<div id="detail-wrap">
|
||||||
|
<h2 id="detail-title">상세 캔들</h2>
|
||||||
|
<div id="detail"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let DATA = null;
|
||||||
|
let overviewPlot = null;
|
||||||
|
let detailChart = null;
|
||||||
|
let detailSeries = null;
|
||||||
|
let currentMode = "overview";
|
||||||
|
let currentLegIdx = 0;
|
||||||
|
let showMarkers = true;
|
||||||
|
let detailVisible = false;
|
||||||
|
let lastDetailStart = 0;
|
||||||
|
|
||||||
|
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||||
|
const MARKER_FONT = "bold 17px Malgun Gothic, Arial, sans-serif";
|
||||||
|
let axisMeasureCtx = null;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
longOpen: "#1565c0",
|
||||||
|
longClose: "#64b5f6",
|
||||||
|
shortOpen: "#c62828",
|
||||||
|
shortClose: "#ff8a65",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtPrice(v) {
|
||||||
|
return Math.round(v).toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureTextWidth(text, font) {
|
||||||
|
if (!axisMeasureCtx) {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
axisMeasureCtx = c.getContext("2d");
|
||||||
|
}
|
||||||
|
axisMeasureCtx.font = font;
|
||||||
|
return axisMeasureCtx.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yAxisLabelWidth() {
|
||||||
|
const vals = DATA.close;
|
||||||
|
if (!vals || !vals.length) return 88;
|
||||||
|
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||||
|
let lo = vals[0], hi = vals[0];
|
||||||
|
for (let i = 1; i < vals.length; i++) {
|
||||||
|
if (vals[i] < lo) lo = vals[i];
|
||||||
|
if (vals[i] > hi) hi = vals[i];
|
||||||
|
}
|
||||||
|
samples.add(lo);
|
||||||
|
samples.add(hi);
|
||||||
|
samples.add((lo + hi) / 2);
|
||||||
|
let maxW = 0;
|
||||||
|
samples.forEach(v => {
|
||||||
|
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||||
|
});
|
||||||
|
return Math.ceil(maxW) + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerSuffix(signalType) {
|
||||||
|
if (signalType === "pullback") return "*";
|
||||||
|
if (signalType === "breakout") return "^";
|
||||||
|
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerLabel(kind, m) {
|
||||||
|
const id = m.marker_id;
|
||||||
|
const sfx = markerSuffix(m.signal_type);
|
||||||
|
if (kind === "longOpen") return "L↑" + id + sfx;
|
||||||
|
if (kind === "longClose") return "L↓" + id + sfx;
|
||||||
|
if (kind === "shortOpen") return "S↓" + id + sfx;
|
||||||
|
return "S↑" + id + sfx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||||
|
ctx.font = MARKER_FONT;
|
||||||
|
const lx = x + 10;
|
||||||
|
const ly = y + (up ? 28 : -20);
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||||
|
ctx.strokeText(label, lx, ly);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillText(label, lx, ly);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOneMarker(u, m, color, up, label) {
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const x = u.valToPos(m.time, "x", true);
|
||||||
|
const y = u.valToPos(m.price, "y", true);
|
||||||
|
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
const s = 8;
|
||||||
|
const gap = 12;
|
||||||
|
if (up) {
|
||||||
|
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFuturesMarkers(u) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
(DATA.long_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
|
||||||
|
(DATA.long_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longClose, false, markerLabel("longClose", m)));
|
||||||
|
(DATA.short_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortOpen, false, markerLabel("shortOpen", m)));
|
||||||
|
(DATA.short_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLegInfo() {
|
||||||
|
const total = (DATA.long_open_markers || []).length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `롱 레그 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overviewXRange() {
|
||||||
|
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
|
||||||
|
const s = overviewPlot.scales.x;
|
||||||
|
return { min: s.min, max: s.max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverviewXRange(min, max) {
|
||||||
|
const t0 = DATA.times[0];
|
||||||
|
const t1 = DATA.times[DATA.times.length - 1];
|
||||||
|
overviewPlot.setScale("x", {
|
||||||
|
min: Math.max(t0, min),
|
||||||
|
max: Math.min(t1, max),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitOverview() {
|
||||||
|
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOverview(factor) {
|
||||||
|
const { min, max } = overviewXRange();
|
||||||
|
const mid = (min + max) / 2;
|
||||||
|
const half = Math.max((max - min) * factor / 2, 3600);
|
||||||
|
setOverviewXRange(mid - half, mid + half);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverview(keepRange) {
|
||||||
|
const prev = keepRange ? overviewXRange() : null;
|
||||||
|
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||||
|
const yAxisW = yAxisLabelWidth();
|
||||||
|
const opts = {
|
||||||
|
width: document.getElementById("overview").clientWidth,
|
||||||
|
height: 480,
|
||||||
|
padding: [14, 10, 14, 10],
|
||||||
|
scales: { x: { time: true } },
|
||||||
|
axes: [
|
||||||
|
{ gap: 6 },
|
||||||
|
{
|
||||||
|
side: 3,
|
||||||
|
size: yAxisW,
|
||||||
|
gap: 10,
|
||||||
|
font: AXIS_FONT,
|
||||||
|
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [{}, { label: "종가", stroke: "#1565c0", width: 1 }],
|
||||||
|
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||||
|
hooks: { draw: [(u) => drawFuturesMarkers(u)] },
|
||||||
|
};
|
||||||
|
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
|
||||||
|
if (prev && keepRange) setOverviewXRange(prev.min, prev.max);
|
||||||
|
else fitOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceLastDays(days) {
|
||||||
|
const cutoff = DATA.times[DATA.times.length - 1] - days * 86400;
|
||||||
|
let start = 0;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] < cutoff) { start = i + 1; break; }
|
||||||
|
}
|
||||||
|
return { start, end: DATA.times.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetailCandles(startIdx, endIdx) {
|
||||||
|
lastDetailStart = startIdx;
|
||||||
|
const end = endIdx || DATA.times.length;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
const wrap = document.getElementById("detail");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const priceAxisW = yAxisLabelWidth();
|
||||||
|
detailChart = LightweightCharts.createChart(wrap, {
|
||||||
|
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
|
||||||
|
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
|
||||||
|
rightPriceScale: { visible: false },
|
||||||
|
leftPriceScale: {
|
||||||
|
borderVisible: true,
|
||||||
|
minimumWidth: priceAxisW,
|
||||||
|
scaleMargins: { top: 0.08, bottom: 0.08 },
|
||||||
|
},
|
||||||
|
timeScale: { timeVisible: true, secondsVisible: false },
|
||||||
|
width: wrap.clientWidth,
|
||||||
|
height: 360,
|
||||||
|
});
|
||||||
|
detailSeries = detailChart.addCandlestickSeries({
|
||||||
|
priceScaleId: "left",
|
||||||
|
upColor: "#c62828", downColor: "#1565c0",
|
||||||
|
borderUpColor: "#c62828", borderDownColor: "#1565c0",
|
||||||
|
wickUpColor: "#c62828", wickDownColor: "#1565c0",
|
||||||
|
});
|
||||||
|
const candles = [];
|
||||||
|
for (let i = startIdx; i < end; i++) {
|
||||||
|
candles.push({
|
||||||
|
time: DATA.times[i],
|
||||||
|
open: DATA.open[i], high: DATA.high[i],
|
||||||
|
low: DATA.low[i], close: DATA.close[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
detailSeries.setData(candles);
|
||||||
|
const t0 = DATA.times[startIdx];
|
||||||
|
const t1 = DATA.times[end - 1];
|
||||||
|
const markers = [];
|
||||||
|
if (showMarkers) {
|
||||||
|
const add = (list, kind, up) => {
|
||||||
|
const colorMap = {
|
||||||
|
longOpen: COLORS.longOpen,
|
||||||
|
longClose: COLORS.longClose,
|
||||||
|
shortOpen: COLORS.shortOpen,
|
||||||
|
shortClose: COLORS.shortClose,
|
||||||
|
};
|
||||||
|
(list || []).forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time,
|
||||||
|
position: up ? "belowBar" : "aboveBar",
|
||||||
|
color: colorMap[kind],
|
||||||
|
shape: up ? "arrowUp" : "arrowDown",
|
||||||
|
size: 3,
|
||||||
|
text: markerLabel(kind, m),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
add(DATA.long_open_markers, "longOpen", true);
|
||||||
|
add(DATA.long_close_markers, "longClose", false);
|
||||||
|
add(DATA.short_open_markers, "shortOpen", false);
|
||||||
|
add(DATA.short_close_markers, "shortClose", true);
|
||||||
|
}
|
||||||
|
markers.sort((a, b) => a.time - b.time);
|
||||||
|
detailSeries.setMarkers(markers);
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(btnId) {
|
||||||
|
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
|
||||||
|
const el = document.getElementById(btnId);
|
||||||
|
if (el) el.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
else fitOverview();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestLongCloseAfter(openTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.long_close_markers || []) {
|
||||||
|
if (s.time >= openTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || (DATA.long_close_markers || [])[(DATA.long_close_markers || []).length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const opens = DATA.long_open_markers || [];
|
||||||
|
const total = opens.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const lo = opens[currentLegIdx];
|
||||||
|
const lc = nearestLongCloseAfter(lo.time);
|
||||||
|
const span = lc ? Math.max(lc.time - lo.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = lo.time - pad;
|
||||||
|
const vmax = (lc ? lc.time : lo.time) + pad;
|
||||||
|
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
setOverviewXRange(vmin, vmax);
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < DATA.times.length; i++) {
|
||||||
|
if (DATA.times[i] >= vmin) { start = i; break; }
|
||||||
|
}
|
||||||
|
let end = DATA.times.length;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] <= vmax) { end = i + 1; break; }
|
||||||
|
}
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`L↑${lo.marker_id} 상방 매수 — ${new Date(lo.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const closeText = lc ? ` → 상방 매도 ${fmtPrice(lc.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`L↑${lo.marker_id} ${fmtPrice(lo.price)}${closeText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPeriod(days, btnId, label) {
|
||||||
|
currentMode = "detail";
|
||||||
|
setActive(btnId);
|
||||||
|
detailVisible = true;
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
|
||||||
|
const { start } = sliceLastDays(days);
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`${label} 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
|
||||||
|
buildDetailCandles(start);
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
const t0 = DATA.times[start];
|
||||||
|
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
|
||||||
|
document.getElementById("status").textContent = `${label} 구간 표시`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom(factor) {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
const ts = detailChart.timeScale();
|
||||||
|
const r = ts.getVisibleLogicalRange();
|
||||||
|
if (!r) return;
|
||||||
|
const mid = (r.from + r.to) / 2;
|
||||||
|
const half = Math.max((r.to - r.from) * factor / 2, 10);
|
||||||
|
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
zoomOverview(factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFit() {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
fitOverview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend() {
|
||||||
|
const items = [
|
||||||
|
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
|
||||||
|
[COLORS.longClose, "L↓ 상방 매도 (롱 청산)"],
|
||||||
|
[COLORS.shortOpen, "S↓ 하방 매수 (숏 진입)"],
|
||||||
|
[COLORS.shortClose, "S↑ 하방 매도 (숏 청산)"],
|
||||||
|
];
|
||||||
|
document.getElementById("legend").innerHTML = items.map(([color, text]) =>
|
||||||
|
`<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${text}</span>`
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
DATA = window.CHART_DATA;
|
||||||
|
if (!DATA) throw new Error("차트 데이터 JS 없음");
|
||||||
|
const m = DATA.meta;
|
||||||
|
const chartDays = m.chart_lookback_days || m.lookback_days;
|
||||||
|
const gtDays = m.gt_lookback_days || m.lookback_days;
|
||||||
|
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
|
||||||
|
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
|
||||||
|
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
|
||||||
|
document.getElementById("title").textContent =
|
||||||
|
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
|
||||||
|
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
|
||||||
|
const chartFrom = m.chart_data_from || m.data_from;
|
||||||
|
const chartTo = m.chart_data_to || m.data_to;
|
||||||
|
const lo = (DATA.long_open_markers || []).length;
|
||||||
|
const lc = (DATA.long_close_markers || []).length;
|
||||||
|
const so = (DATA.short_open_markers || []).length;
|
||||||
|
const sc = (DATA.short_close_markers || []).length;
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
|
||||||
|
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | GT ${gtLabel} | 현물 GT 타점 기반`;
|
||||||
|
renderLegend();
|
||||||
|
updateLegInfo();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
|
||||||
|
buildOverview(false);
|
||||||
|
|
||||||
|
document.getElementById("btn-home").onclick = goHome;
|
||||||
|
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
|
||||||
|
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
|
||||||
|
document.getElementById("btn-all").onclick = goHome;
|
||||||
|
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
|
||||||
|
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
|
||||||
|
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
|
||||||
|
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
|
||||||
|
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
|
||||||
|
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
|
||||||
|
document.getElementById("btn-fit").onclick = applyFit;
|
||||||
|
document.getElementById("btn-markers").onclick = () => {
|
||||||
|
showMarkers = !showMarkers;
|
||||||
|
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
if (detailChart) buildDetailCandles(lastDetailStart);
|
||||||
|
};
|
||||||
|
document.getElementById("btn-toggle-detail").onclick = () => {
|
||||||
|
detailVisible = !detailVisible;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
|
||||||
|
if (detailVisible && !detailChart) {
|
||||||
|
const { start } = sliceLastDays(7);
|
||||||
|
buildDetailCandles(start);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById("overview").addEventListener("dblclick", () => {
|
||||||
|
if (currentMode === "overview") fitOverview();
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try { init(); } catch (err) {
|
||||||
|
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
110
scripts/02_ground_truth_futures.py
Normal file
110
scripts/02_ground_truth_futures.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""선물 Ground Truth 차트 — 현물 GT 타점을 롱·숏 4색 마커로 시각화."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
if str(SRC) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
|
from deepcoin.config import Settings, load_settings
|
||||||
|
from deepcoin.ground_truth.futures_chart import render_futures_ground_truth_chart
|
||||||
|
|
||||||
|
TIER_DESCRIPTIONS = {
|
||||||
|
"v1": "스윙만 (최소 매수·매도)",
|
||||||
|
"v2": "스윙 + 눌림목",
|
||||||
|
"v3": "스윙 + 눌림목 + 돌파 + 다이버전스",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_logging(verbose: bool) -> None:
|
||||||
|
"""로깅 레벨을 설정한다."""
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_targets(settings: Settings, tier_arg: str) -> list[tuple[str, Path, Path]]:
|
||||||
|
"""생성할 티어 목록 (tier, spot_json_path, futures_chart_path)."""
|
||||||
|
all_tiers: dict[str, tuple[Path, Path]] = {
|
||||||
|
"v1": (settings.ground_truth_v1_file, settings.ground_truth_futures_chart_v1_file),
|
||||||
|
"v2": (settings.ground_truth_v2_file, settings.ground_truth_futures_chart_v2_file),
|
||||||
|
"v3": (settings.ground_truth_file, settings.ground_truth_futures_chart_v3_file),
|
||||||
|
}
|
||||||
|
if tier_arg == "all":
|
||||||
|
return [(t, *paths) for t, paths in all_tiers.items()]
|
||||||
|
return [(tier_arg, *all_tiers[tier_arg])]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_gt(json_path: Path) -> dict[str, Any]:
|
||||||
|
"""현물 GT JSON을 로드한다."""
|
||||||
|
with json_path.open(encoding="utf-8") as fp:
|
||||||
|
return json.load(fp)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(tier: str, gt_result: dict[str, Any], chart_path: Path) -> None:
|
||||||
|
"""티어별 선물 차트 요약을 출력한다."""
|
||||||
|
meta = gt_result["meta"]
|
||||||
|
summary = gt_result["summary"]
|
||||||
|
print(f"\n=== 선물 GT 차트 {tier.upper()} ({TIER_DESCRIPTIONS[tier]}) ===")
|
||||||
|
print(f"대상: {meta['symbol']} ({meta['interval_label']})")
|
||||||
|
print(f"GT 기간: {meta['data_from']} ~ {meta['data_to']}")
|
||||||
|
print(
|
||||||
|
f"현물 GT 타점: 매수 {summary['buy_count']} / 매도 {summary['sell_count']} "
|
||||||
|
f"→ 선물 상방·하방 각 {summary['buy_count']}/{summary['sell_count']} 마커"
|
||||||
|
)
|
||||||
|
print(f"차트: {chart_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""CLI 진입점."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="현물 GT 기반 선물 Ground Truth 차트 (롱·숏 4색)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tier",
|
||||||
|
choices=("v1", "v2", "v3", "all"),
|
||||||
|
default="all",
|
||||||
|
help="대상 GT 버전",
|
||||||
|
)
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_configure_logging(args.verbose)
|
||||||
|
settings = load_settings()
|
||||||
|
tiers = _tier_targets(settings, args.tier)
|
||||||
|
|
||||||
|
print("\n=== 선물 Ground Truth 차트 생성 ===")
|
||||||
|
print("현물 GT 타점 → L↑상방매수 L↓상방매도 S↓하방매수 S↑하방매도")
|
||||||
|
|
||||||
|
for tier, json_path, chart_path in tiers:
|
||||||
|
if not json_path.exists():
|
||||||
|
logging.error("현물 GT JSON 없음: %s — 먼저 02_ground_truth.py 실행", json_path)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
gt_result = _load_gt(json_path)
|
||||||
|
render_futures_ground_truth_chart(
|
||||||
|
db_path=settings.db_path,
|
||||||
|
symbol=settings.symbol,
|
||||||
|
gt_result=gt_result,
|
||||||
|
output_path=chart_path,
|
||||||
|
chart_lookback_days=settings.download_days,
|
||||||
|
)
|
||||||
|
_print_summary(tier, gt_result, chart_path)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -71,6 +71,9 @@ class Settings:
|
|||||||
ground_truth_chart_sim_v1_file: Path
|
ground_truth_chart_sim_v1_file: Path
|
||||||
ground_truth_chart_sim_v2_file: Path
|
ground_truth_chart_sim_v2_file: Path
|
||||||
ground_truth_chart_sim_v3_file: Path
|
ground_truth_chart_sim_v3_file: Path
|
||||||
|
ground_truth_futures_chart_v1_file: Path
|
||||||
|
ground_truth_futures_chart_v2_file: Path
|
||||||
|
ground_truth_futures_chart_v3_file: Path
|
||||||
gt_sim_lookback_days: int
|
gt_sim_lookback_days: int
|
||||||
gt_initial_cash_krw: float
|
gt_initial_cash_krw: float
|
||||||
gt_trading_fee_rate: float
|
gt_trading_fee_rate: float
|
||||||
@@ -143,6 +146,24 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
|||||||
"docs/02_ground_truth/spot/ground_truth_chart_sim_v3.html",
|
"docs/02_ground_truth/spot/ground_truth_chart_sim_v3.html",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
gt_futures_chart_v1 = _resolve_project_path(
|
||||||
|
os.getenv(
|
||||||
|
"GROUND_TRUTH_FUTURES_CHART_V1_FILE",
|
||||||
|
"docs/02_ground_truth/futures/ground_truth_chart_v1.html",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gt_futures_chart_v2 = _resolve_project_path(
|
||||||
|
os.getenv(
|
||||||
|
"GROUND_TRUTH_FUTURES_CHART_V2_FILE",
|
||||||
|
"docs/02_ground_truth/futures/ground_truth_chart_v2.html",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gt_futures_chart_v3 = _resolve_project_path(
|
||||||
|
os.getenv(
|
||||||
|
"GROUND_TRUTH_FUTURES_CHART_V3_FILE",
|
||||||
|
"docs/02_ground_truth/futures/ground_truth_chart_v3.html",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/techniques")
|
tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/techniques")
|
||||||
tech_dir = Path(tech_dir_raw)
|
tech_dir = Path(tech_dir_raw)
|
||||||
@@ -190,6 +211,9 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
|||||||
ground_truth_chart_sim_v1_file=gt_chart_sim_v1,
|
ground_truth_chart_sim_v1_file=gt_chart_sim_v1,
|
||||||
ground_truth_chart_sim_v2_file=gt_chart_sim_v2,
|
ground_truth_chart_sim_v2_file=gt_chart_sim_v2,
|
||||||
ground_truth_chart_sim_v3_file=gt_chart_sim_v3,
|
ground_truth_chart_sim_v3_file=gt_chart_sim_v3,
|
||||||
|
ground_truth_futures_chart_v1_file=gt_futures_chart_v1,
|
||||||
|
ground_truth_futures_chart_v2_file=gt_futures_chart_v2,
|
||||||
|
ground_truth_futures_chart_v3_file=gt_futures_chart_v3,
|
||||||
gt_sim_lookback_days=int(os.getenv("GT_SIM_LOOKBACK_DAYS", "365")),
|
gt_sim_lookback_days=int(os.getenv("GT_SIM_LOOKBACK_DAYS", "365")),
|
||||||
gt_initial_cash_krw=float(os.getenv("GT_INITIAL_CASH_KRW", "400000")),
|
gt_initial_cash_krw=float(os.getenv("GT_INITIAL_CASH_KRW", "400000")),
|
||||||
gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")),
|
gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")),
|
||||||
|
|||||||
58
src/deepcoin/ground_truth/futures.py
Normal file
58
src/deepcoin/ground_truth/futures.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""현물 Ground Truth 신호를 선물 롱·숏 타점으로 변환한다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def futures_markers_from_gt_signals(
|
||||||
|
gt_result: dict[str, Any],
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""현물 GT 신호를 선물 4종 마커(상·하방 매수·매도)로 변환한다.
|
||||||
|
|
||||||
|
현물 GT의 스윙 저점(buy)과 고점(sell)을 동일 가격·시각에
|
||||||
|
롱·숏 양방향 선물 타점으로 매핑한다.
|
||||||
|
|
||||||
|
- buy → 상방 매수(롱 진입), 하방 매도(숏 청산)
|
||||||
|
- sell → 상방 매도(롱 청산), 하방 매수(숏 진입)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gt_result: build_ground_truth 결과 또는 동일 스키마 JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
long_open, long_close, short_open, short_close 마커 리스트.
|
||||||
|
"""
|
||||||
|
long_open: list[dict[str, Any]] = []
|
||||||
|
long_close: list[dict[str, Any]] = []
|
||||||
|
short_open: list[dict[str, Any]] = []
|
||||||
|
short_close: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for sig in gt_result.get("signals") or []:
|
||||||
|
side = sig["side"]
|
||||||
|
signal_type = sig.get(
|
||||||
|
"signal_type",
|
||||||
|
"swing_low" if side == "buy" else "swing_high",
|
||||||
|
)
|
||||||
|
marker_id = sig.get("marker_id", sig.get("leg_id"))
|
||||||
|
base = {
|
||||||
|
"time": int(pd.Timestamp(sig["datetime"]).timestamp()),
|
||||||
|
"price": sig["price"],
|
||||||
|
"marker_id": marker_id,
|
||||||
|
"signal_type": signal_type,
|
||||||
|
"leg_id": sig.get("leg_id"),
|
||||||
|
}
|
||||||
|
if side == "buy":
|
||||||
|
long_open.append({**base, "position": "long", "action": "open"})
|
||||||
|
short_close.append({**base, "position": "short", "action": "close"})
|
||||||
|
else:
|
||||||
|
long_close.append({**base, "position": "long", "action": "close"})
|
||||||
|
short_open.append({**base, "position": "short", "action": "open"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"long_open": long_open,
|
||||||
|
"long_close": long_close,
|
||||||
|
"short_open": short_open,
|
||||||
|
"short_close": short_close,
|
||||||
|
}
|
||||||
583
src/deepcoin/ground_truth/futures_chart.py
Normal file
583
src/deepcoin/ground_truth/futures_chart.py
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
"""선물 Ground Truth 차트 HTML 생성 (롱·숏 4색 마커)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepcoin.data.candle_loader import load_candles
|
||||||
|
from deepcoin.ground_truth.chart import DEFAULT_MAX_CANDLES, _data_js_path, _to_unix_seconds
|
||||||
|
from deepcoin.ground_truth.futures import futures_markers_from_gt_signals
|
||||||
|
|
||||||
|
_FUTURES_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Futures Ground Truth Chart</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
|
<script src="__DATA_JS_NAME__"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
|
||||||
|
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
|
||||||
|
h1 { margin: 0 0 6px; font-size: 20px; }
|
||||||
|
.meta { font-size: 13px; color: #666; }
|
||||||
|
.legend { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||||||
|
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
|
||||||
|
.toolbar-group:last-of-type { border-right: none; }
|
||||||
|
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.toolbar button:hover { background: #f0f4f8; }
|
||||||
|
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
|
||||||
|
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
|
||||||
|
.toolbar button.home:hover { background: #1b5e20; }
|
||||||
|
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||||
|
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||||
|
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||||
|
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||||
|
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||||
|
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1 id="title">Futures Ground Truth Chart</h1>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
<div class="legend" id="legend"></div>
|
||||||
|
</header>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-home" class="home" title="전체 화면으로 복귀">홈</button>
|
||||||
|
<button id="btn-prev-leg" title="이전 롱 레그">◀ 이전</button>
|
||||||
|
<button id="btn-next-leg" title="다음 롱 레그">다음 ▶</button>
|
||||||
|
<span class="leg-info" id="leg-info">타점 - / -</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-all" class="btn-period active">전체</button>
|
||||||
|
<button id="btn-365d" class="btn-period">1년</button>
|
||||||
|
<button id="btn-30d" class="btn-period">30일</button>
|
||||||
|
<button id="btn-7d" class="btn-period">7일</button>
|
||||||
|
<button id="btn-3d" class="btn-period">3일</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-zoom-in" title="확대">+ 확대</button>
|
||||||
|
<button id="btn-zoom-out" title="축소">− 축소</button>
|
||||||
|
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
|
||||||
|
<button id="btn-toggle-detail" title="상세 캔들 패널">상세 패널</button>
|
||||||
|
</div>
|
||||||
|
<span id="status">데이터 로딩 중…</span>
|
||||||
|
</div>
|
||||||
|
<div id="overview"></div>
|
||||||
|
<div id="detail-wrap">
|
||||||
|
<h2 id="detail-title">상세 캔들</h2>
|
||||||
|
<div id="detail"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let DATA = null;
|
||||||
|
let overviewPlot = null;
|
||||||
|
let detailChart = null;
|
||||||
|
let detailSeries = null;
|
||||||
|
let currentMode = "overview";
|
||||||
|
let currentLegIdx = 0;
|
||||||
|
let showMarkers = true;
|
||||||
|
let detailVisible = false;
|
||||||
|
let lastDetailStart = 0;
|
||||||
|
|
||||||
|
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||||
|
const MARKER_FONT = "bold 17px Malgun Gothic, Arial, sans-serif";
|
||||||
|
let axisMeasureCtx = null;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
longOpen: "#1565c0",
|
||||||
|
longClose: "#64b5f6",
|
||||||
|
shortOpen: "#c62828",
|
||||||
|
shortClose: "#ff8a65",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtPrice(v) {
|
||||||
|
return Math.round(v).toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureTextWidth(text, font) {
|
||||||
|
if (!axisMeasureCtx) {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
axisMeasureCtx = c.getContext("2d");
|
||||||
|
}
|
||||||
|
axisMeasureCtx.font = font;
|
||||||
|
return axisMeasureCtx.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yAxisLabelWidth() {
|
||||||
|
const vals = DATA.close;
|
||||||
|
if (!vals || !vals.length) return 88;
|
||||||
|
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||||
|
let lo = vals[0], hi = vals[0];
|
||||||
|
for (let i = 1; i < vals.length; i++) {
|
||||||
|
if (vals[i] < lo) lo = vals[i];
|
||||||
|
if (vals[i] > hi) hi = vals[i];
|
||||||
|
}
|
||||||
|
samples.add(lo);
|
||||||
|
samples.add(hi);
|
||||||
|
samples.add((lo + hi) / 2);
|
||||||
|
let maxW = 0;
|
||||||
|
samples.forEach(v => {
|
||||||
|
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||||
|
});
|
||||||
|
return Math.ceil(maxW) + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerSuffix(signalType) {
|
||||||
|
if (signalType === "pullback") return "*";
|
||||||
|
if (signalType === "breakout") return "^";
|
||||||
|
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerLabel(kind, m) {
|
||||||
|
const id = m.marker_id;
|
||||||
|
const sfx = markerSuffix(m.signal_type);
|
||||||
|
if (kind === "longOpen") return "L↑" + id + sfx;
|
||||||
|
if (kind === "longClose") return "L↓" + id + sfx;
|
||||||
|
if (kind === "shortOpen") return "S↓" + id + sfx;
|
||||||
|
return "S↑" + id + sfx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||||
|
ctx.font = MARKER_FONT;
|
||||||
|
const lx = x + 10;
|
||||||
|
const ly = y + (up ? 28 : -20);
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||||
|
ctx.strokeText(label, lx, ly);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillText(label, lx, ly);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOneMarker(u, m, color, up, label) {
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const x = u.valToPos(m.time, "x", true);
|
||||||
|
const y = u.valToPos(m.price, "y", true);
|
||||||
|
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
const s = 8;
|
||||||
|
const gap = 12;
|
||||||
|
if (up) {
|
||||||
|
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFuturesMarkers(u) {
|
||||||
|
if (!showMarkers) return;
|
||||||
|
(DATA.long_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
|
||||||
|
(DATA.long_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.longClose, false, markerLabel("longClose", m)));
|
||||||
|
(DATA.short_open_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortOpen, false, markerLabel("shortOpen", m)));
|
||||||
|
(DATA.short_close_markers || []).forEach(m =>
|
||||||
|
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLegInfo() {
|
||||||
|
const total = (DATA.long_open_markers || []).length;
|
||||||
|
const el = document.getElementById("leg-info");
|
||||||
|
if (!total) { el.textContent = "타점 없음"; return; }
|
||||||
|
el.textContent = `롱 레그 ${currentLegIdx + 1} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overviewXRange() {
|
||||||
|
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
|
||||||
|
const s = overviewPlot.scales.x;
|
||||||
|
return { min: s.min, max: s.max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverviewXRange(min, max) {
|
||||||
|
const t0 = DATA.times[0];
|
||||||
|
const t1 = DATA.times[DATA.times.length - 1];
|
||||||
|
overviewPlot.setScale("x", {
|
||||||
|
min: Math.max(t0, min),
|
||||||
|
max: Math.min(t1, max),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitOverview() {
|
||||||
|
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOverview(factor) {
|
||||||
|
const { min, max } = overviewXRange();
|
||||||
|
const mid = (min + max) / 2;
|
||||||
|
const half = Math.max((max - min) * factor / 2, 3600);
|
||||||
|
setOverviewXRange(mid - half, mid + half);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverview(keepRange) {
|
||||||
|
const prev = keepRange ? overviewXRange() : null;
|
||||||
|
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||||
|
const yAxisW = yAxisLabelWidth();
|
||||||
|
const opts = {
|
||||||
|
width: document.getElementById("overview").clientWidth,
|
||||||
|
height: 480,
|
||||||
|
padding: [14, 10, 14, 10],
|
||||||
|
scales: { x: { time: true } },
|
||||||
|
axes: [
|
||||||
|
{ gap: 6 },
|
||||||
|
{
|
||||||
|
side: 3,
|
||||||
|
size: yAxisW,
|
||||||
|
gap: 10,
|
||||||
|
font: AXIS_FONT,
|
||||||
|
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [{}, { label: "종가", stroke: "#1565c0", width: 1 }],
|
||||||
|
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||||
|
hooks: { draw: [(u) => drawFuturesMarkers(u)] },
|
||||||
|
};
|
||||||
|
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
|
||||||
|
if (prev && keepRange) setOverviewXRange(prev.min, prev.max);
|
||||||
|
else fitOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceLastDays(days) {
|
||||||
|
const cutoff = DATA.times[DATA.times.length - 1] - days * 86400;
|
||||||
|
let start = 0;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] < cutoff) { start = i + 1; break; }
|
||||||
|
}
|
||||||
|
return { start, end: DATA.times.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetailCandles(startIdx, endIdx) {
|
||||||
|
lastDetailStart = startIdx;
|
||||||
|
const end = endIdx || DATA.times.length;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
const wrap = document.getElementById("detail");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const priceAxisW = yAxisLabelWidth();
|
||||||
|
detailChart = LightweightCharts.createChart(wrap, {
|
||||||
|
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
|
||||||
|
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
|
||||||
|
rightPriceScale: { visible: false },
|
||||||
|
leftPriceScale: {
|
||||||
|
borderVisible: true,
|
||||||
|
minimumWidth: priceAxisW,
|
||||||
|
scaleMargins: { top: 0.08, bottom: 0.08 },
|
||||||
|
},
|
||||||
|
timeScale: { timeVisible: true, secondsVisible: false },
|
||||||
|
width: wrap.clientWidth,
|
||||||
|
height: 360,
|
||||||
|
});
|
||||||
|
detailSeries = detailChart.addCandlestickSeries({
|
||||||
|
priceScaleId: "left",
|
||||||
|
upColor: "#c62828", downColor: "#1565c0",
|
||||||
|
borderUpColor: "#c62828", borderDownColor: "#1565c0",
|
||||||
|
wickUpColor: "#c62828", wickDownColor: "#1565c0",
|
||||||
|
});
|
||||||
|
const candles = [];
|
||||||
|
for (let i = startIdx; i < end; i++) {
|
||||||
|
candles.push({
|
||||||
|
time: DATA.times[i],
|
||||||
|
open: DATA.open[i], high: DATA.high[i],
|
||||||
|
low: DATA.low[i], close: DATA.close[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
detailSeries.setData(candles);
|
||||||
|
const t0 = DATA.times[startIdx];
|
||||||
|
const t1 = DATA.times[end - 1];
|
||||||
|
const markers = [];
|
||||||
|
if (showMarkers) {
|
||||||
|
const add = (list, kind, up) => {
|
||||||
|
const colorMap = {
|
||||||
|
longOpen: COLORS.longOpen,
|
||||||
|
longClose: COLORS.longClose,
|
||||||
|
shortOpen: COLORS.shortOpen,
|
||||||
|
shortClose: COLORS.shortClose,
|
||||||
|
};
|
||||||
|
(list || []).forEach(m => {
|
||||||
|
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||||
|
time: m.time,
|
||||||
|
position: up ? "belowBar" : "aboveBar",
|
||||||
|
color: colorMap[kind],
|
||||||
|
shape: up ? "arrowUp" : "arrowDown",
|
||||||
|
size: 3,
|
||||||
|
text: markerLabel(kind, m),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
add(DATA.long_open_markers, "longOpen", true);
|
||||||
|
add(DATA.long_close_markers, "longClose", false);
|
||||||
|
add(DATA.short_open_markers, "shortOpen", false);
|
||||||
|
add(DATA.short_close_markers, "shortClose", true);
|
||||||
|
}
|
||||||
|
markers.sort((a, b) => a.time - b.time);
|
||||||
|
detailSeries.setMarkers(markers);
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(btnId) {
|
||||||
|
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
|
||||||
|
const el = document.getElementById(btnId);
|
||||||
|
if (el) el.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
else fitOverview();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestLongCloseAfter(openTime) {
|
||||||
|
let best = null;
|
||||||
|
for (const s of DATA.long_close_markers || []) {
|
||||||
|
if (s.time >= openTime && (!best || s.time < best.time)) best = s;
|
||||||
|
}
|
||||||
|
return best || (DATA.long_close_markers || [])[(DATA.long_close_markers || []).length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLeg(idx) {
|
||||||
|
const opens = DATA.long_open_markers || [];
|
||||||
|
const total = opens.length;
|
||||||
|
if (!total) return;
|
||||||
|
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||||
|
updateLegInfo();
|
||||||
|
const lo = opens[currentLegIdx];
|
||||||
|
const lc = nearestLongCloseAfter(lo.time);
|
||||||
|
const span = lc ? Math.max(lc.time - lo.time, 86400) : 86400 * 3;
|
||||||
|
const pad = span * 0.4;
|
||||||
|
const vmin = lo.time - pad;
|
||||||
|
const vmax = (lc ? lc.time : lo.time) + pad;
|
||||||
|
|
||||||
|
currentMode = "overview";
|
||||||
|
setActive("btn-all");
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
setOverviewXRange(vmin, vmax);
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < DATA.times.length; i++) {
|
||||||
|
if (DATA.times[i] >= vmin) { start = i; break; }
|
||||||
|
}
|
||||||
|
let end = DATA.times.length;
|
||||||
|
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||||
|
if (DATA.times[i] <= vmax) { end = i + 1; break; }
|
||||||
|
}
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`L↑${lo.marker_id} 상방 매수 — ${new Date(lo.time * 1000).toLocaleString("ko-KR")}`;
|
||||||
|
buildDetailCandles(start, end);
|
||||||
|
const closeText = lc ? ` → 상방 매도 ${fmtPrice(lc.price)}` : "";
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`L↑${lo.marker_id} ${fmtPrice(lo.price)}${closeText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPeriod(days, btnId, label) {
|
||||||
|
currentMode = "detail";
|
||||||
|
setActive(btnId);
|
||||||
|
detailVisible = true;
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
|
||||||
|
const { start } = sliceLastDays(days);
|
||||||
|
document.getElementById("detail-title").textContent =
|
||||||
|
`${label} 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
|
||||||
|
buildDetailCandles(start);
|
||||||
|
document.getElementById("overview").style.display = "block";
|
||||||
|
if (!overviewPlot) buildOverview(false);
|
||||||
|
const t0 = DATA.times[start];
|
||||||
|
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
|
||||||
|
document.getElementById("status").textContent = `${label} 구간 표시`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom(factor) {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
const ts = detailChart.timeScale();
|
||||||
|
const r = ts.getVisibleLogicalRange();
|
||||||
|
if (!r) return;
|
||||||
|
const mid = (r.from + r.to) / 2;
|
||||||
|
const half = Math.max((r.to - r.from) * factor / 2, 10);
|
||||||
|
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
zoomOverview(factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFit() {
|
||||||
|
if (currentMode === "detail" && detailChart) {
|
||||||
|
detailChart.timeScale().fitContent();
|
||||||
|
} else if (overviewPlot) {
|
||||||
|
fitOverview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend() {
|
||||||
|
const items = [
|
||||||
|
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
|
||||||
|
[COLORS.longClose, "L↓ 상방 매도 (롱 청산)"],
|
||||||
|
[COLORS.shortOpen, "S↓ 하방 매수 (숏 진입)"],
|
||||||
|
[COLORS.shortClose, "S↑ 하방 매도 (숏 청산)"],
|
||||||
|
];
|
||||||
|
document.getElementById("legend").innerHTML = items.map(([color, text]) =>
|
||||||
|
`<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${text}</span>`
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
DATA = window.CHART_DATA;
|
||||||
|
if (!DATA) throw new Error("차트 데이터 JS 없음");
|
||||||
|
const m = DATA.meta;
|
||||||
|
const chartDays = m.chart_lookback_days || m.lookback_days;
|
||||||
|
const gtDays = m.gt_lookback_days || m.lookback_days;
|
||||||
|
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
|
||||||
|
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
|
||||||
|
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
|
||||||
|
document.getElementById("title").textContent =
|
||||||
|
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
|
||||||
|
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
|
||||||
|
const chartFrom = m.chart_data_from || m.data_from;
|
||||||
|
const chartTo = m.chart_data_to || m.data_to;
|
||||||
|
const lo = (DATA.long_open_markers || []).length;
|
||||||
|
const lc = (DATA.long_close_markers || []).length;
|
||||||
|
const so = (DATA.short_open_markers || []).length;
|
||||||
|
const sc = (DATA.short_close_markers || []).length;
|
||||||
|
document.getElementById("meta").textContent =
|
||||||
|
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
|
||||||
|
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | GT ${gtLabel} | 현물 GT 타점 기반`;
|
||||||
|
renderLegend();
|
||||||
|
updateLegInfo();
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||||
|
|
||||||
|
buildOverview(false);
|
||||||
|
|
||||||
|
document.getElementById("btn-home").onclick = goHome;
|
||||||
|
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
|
||||||
|
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
|
||||||
|
document.getElementById("btn-all").onclick = goHome;
|
||||||
|
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
|
||||||
|
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
|
||||||
|
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
|
||||||
|
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
|
||||||
|
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
|
||||||
|
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
|
||||||
|
document.getElementById("btn-fit").onclick = applyFit;
|
||||||
|
document.getElementById("btn-markers").onclick = () => {
|
||||||
|
showMarkers = !showMarkers;
|
||||||
|
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
if (detailChart) buildDetailCandles(lastDetailStart);
|
||||||
|
};
|
||||||
|
document.getElementById("btn-toggle-detail").onclick = () => {
|
||||||
|
detailVisible = !detailVisible;
|
||||||
|
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||||
|
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
|
||||||
|
if (detailVisible && !detailChart) {
|
||||||
|
const { start } = sliceLastDays(7);
|
||||||
|
buildDetailCandles(start);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById("overview").addEventListener("dblclick", () => {
|
||||||
|
if (currentMode === "overview") fitOverview();
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (overviewPlot) buildOverview(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try { init(); } catch (err) {
|
||||||
|
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_futures_chart_payload(
|
||||||
|
df,
|
||||||
|
gt_result: dict[str, Any],
|
||||||
|
chart_days: int,
|
||||||
|
gt_lookback_days: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""선물 GT 차트용 JSON payload를 구성한다."""
|
||||||
|
markers = futures_markers_from_gt_signals(gt_result)
|
||||||
|
times = _to_unix_seconds(df["datetime"])
|
||||||
|
chart_meta = {
|
||||||
|
**gt_result["meta"],
|
||||||
|
"market_type": "futures",
|
||||||
|
"chart_lookback_days": chart_days,
|
||||||
|
"gt_lookback_days": gt_lookback_days,
|
||||||
|
"chart_data_from": str(df["datetime"].min()),
|
||||||
|
"chart_data_to": str(df["datetime"].max()),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"times": times,
|
||||||
|
"open": df["open"].astype(float).tolist(),
|
||||||
|
"high": df["high"].astype(float).tolist(),
|
||||||
|
"low": df["low"].astype(float).tolist(),
|
||||||
|
"close": df["close"].astype(float).tolist(),
|
||||||
|
"long_open_markers": markers["long_open"],
|
||||||
|
"long_close_markers": markers["long_close"],
|
||||||
|
"short_open_markers": markers["short_open"],
|
||||||
|
"short_close_markers": markers["short_close"],
|
||||||
|
"meta": chart_meta,
|
||||||
|
"bar_count": len(df),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_futures_ground_truth_chart(
|
||||||
|
db_path: Path,
|
||||||
|
symbol: str,
|
||||||
|
gt_result: dict[str, Any],
|
||||||
|
output_path: Path,
|
||||||
|
chart_lookback_days: int | None = None,
|
||||||
|
max_candles: int = DEFAULT_MAX_CANDLES,
|
||||||
|
) -> Path:
|
||||||
|
"""현물 GT 타점을 선물 롱·숏 4색 마커로 표시한 HTML 차트를 생성한다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: SQLite 경로.
|
||||||
|
symbol: 코인 심볼.
|
||||||
|
gt_result: build_ground_truth 결과 또는 spot GT JSON.
|
||||||
|
output_path: HTML 출력 경로.
|
||||||
|
chart_lookback_days: 차트 표시 일수. None이면 GT lookback과 동일.
|
||||||
|
max_candles: 0이면 전체, 양수면 최근 N봉만.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML 저장 경로.
|
||||||
|
"""
|
||||||
|
interval_min = gt_result["meta"]["interval_min"]
|
||||||
|
gt_lookback_days = gt_result["meta"]["lookback_days"]
|
||||||
|
chart_days = chart_lookback_days if chart_lookback_days is not None else gt_lookback_days
|
||||||
|
|
||||||
|
df = load_candles(db_path, symbol, interval_min, lookback_days=chart_days)
|
||||||
|
if max_candles > 0 and len(df) > max_candles:
|
||||||
|
df = df.iloc[-max_candles:].reset_index(drop=True)
|
||||||
|
|
||||||
|
payload = _build_futures_chart_payload(df, gt_result, chart_days, gt_lookback_days)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data_path = _data_js_path(output_path)
|
||||||
|
with data_path.open("w", encoding="utf-8") as fp:
|
||||||
|
fp.write("window.CHART_DATA=")
|
||||||
|
json.dump(payload, fp, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
fp.write(";")
|
||||||
|
|
||||||
|
data_js_name = data_path.name
|
||||||
|
html = _FUTURES_HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name)
|
||||||
|
output_path.write_text(html, encoding="utf-8")
|
||||||
|
return output_path
|
||||||
Reference in New Issue
Block a user