feat: 선물 sim 차트 추가 및 GT/선물 마커 UI 개선
선물 1년 PnL 시뮬(40만원)과 sim 차트 v1~v3를 추가하고, 현물·선물 GT 차트 마커를 종가선 밀착 삼각형으로 통일한다. 동시각 L↓/S↓ 겹침은 가로 stagger로 분리하며 동그라미 표시는 제거한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -34,6 +35,7 @@
|
||||
<h1 id="title">Ground Truth Chart</h1>
|
||||
<div class="meta" id="meta"></div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</button>
|
||||
@@ -117,7 +119,11 @@
|
||||
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||
}
|
||||
|
||||
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
const ARROW_HALF = 6;
|
||||
const ARROW_HEIGHT = 8;
|
||||
const LABEL_OFFSET_X = 8;
|
||||
|
||||
function markerSuffix(signalType) {
|
||||
if (signalType === "pullback") return "*";
|
||||
@@ -126,37 +132,85 @@
|
||||
return "";
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||
ctx.font = MARKER_FONT;
|
||||
const lx = x + 10;
|
||||
const ly = y + (up ? 28 : -20);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, lx, ly);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, ly);
|
||||
function markerChartPrice(m) {
|
||||
if (m.chart_price != null) return m.chart_price;
|
||||
let lo = 0;
|
||||
let hi = DATA.times.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (DATA.times[mid] < m.time) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return DATA.close[lo];
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, labelY, color) {
|
||||
ctx.font = MARKER_FONT;
|
||||
const lx = x + LABEL_OFFSET_X;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.strokeText(label, lx, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, labelY);
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function drawTriangleOnLine(ctx, x, lineY, up, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
if (up) {
|
||||
ctx.moveTo(x - ARROW_HALF, lineY);
|
||||
ctx.lineTo(x + ARROW_HALF, lineY);
|
||||
ctx.lineTo(x, lineY + ARROW_HEIGHT);
|
||||
} else {
|
||||
ctx.moveTo(x - ARROW_HALF, lineY);
|
||||
ctx.lineTo(x + ARROW_HALF, lineY);
|
||||
ctx.lineTo(x, lineY - ARROW_HEIGHT);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawSimStartMarker(u, marker) {
|
||||
if (!marker) return;
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(marker.time, "x", true);
|
||||
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const color = SIM_START_COLOR;
|
||||
drawTriangleOnLine(ctx, x, lineY, false, color);
|
||||
const label = marker.label || "거래시작";
|
||||
ctx.font = MARKER_FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
const labelY = lineY - ARROW_HEIGHT - 6;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.strokeText(label, x, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x, labelY);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
const LABEL_GAP = 12;
|
||||
|
||||
function drawMarkers(u, buys, sells) {
|
||||
if (!showMarkers) return;
|
||||
const ctx = u.ctx;
|
||||
const drawOne = (m, color, up) => {
|
||||
const x = u.valToPos(m.time, "x", true);
|
||||
const y = u.valToPos(m.price, "y", true);
|
||||
const lineY = u.valToPos(markerChartPrice(m), "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
const s = 8;
|
||||
const gap = 12;
|
||||
if (up) {
|
||||
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||
} else {
|
||||
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
drawTriangleOnLine(ctx, x, lineY, up, color);
|
||||
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||
const labelY = up
|
||||
? lineY + ARROW_HEIGHT + LABEL_GAP
|
||||
: lineY - ARROW_HEIGHT - LABEL_GAP;
|
||||
drawMarkerLabel(ctx, label, x, labelY, color);
|
||||
};
|
||||
buys.forEach(m => {
|
||||
let color = "#2e7d32";
|
||||
@@ -203,7 +257,7 @@
|
||||
const opts = {
|
||||
width: document.getElementById("overview").clientWidth,
|
||||
height: 480,
|
||||
padding: [14, 10, 14, 10],
|
||||
padding: [28, 10, 28, 10],
|
||||
scales: { x: { time: true } },
|
||||
axes: [
|
||||
{ gap: 6 },
|
||||
@@ -221,7 +275,10 @@
|
||||
],
|
||||
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||
hooks: {
|
||||
draw: [(u) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
|
||||
draw: [(u) => {
|
||||
drawSimStartMarker(u, DATA.sim_start_marker);
|
||||
drawMarkers(u, DATA.buy_markers, DATA.sell_markers);
|
||||
}],
|
||||
},
|
||||
};
|
||||
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
|
||||
@@ -276,13 +333,21 @@
|
||||
const t0 = DATA.times[startIdx];
|
||||
const t1 = DATA.times[end - 1];
|
||||
const markers = [];
|
||||
if (DATA.sim_start_marker) {
|
||||
const sm = DATA.sim_start_marker;
|
||||
if (sm.time >= t0 && sm.time <= t1) markers.push({
|
||||
time: sm.time, position: "aboveBar",
|
||||
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
|
||||
text: sm.label || "거래시작",
|
||||
});
|
||||
}
|
||||
if (showMarkers) {
|
||||
DATA.buy_markers.forEach(m => {
|
||||
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||
time: m.time, position: "belowBar",
|
||||
color: m.signal_type === "breakout" ? "#ef6c00"
|
||||
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
|
||||
shape: "arrowUp", size: 3,
|
||||
shape: "arrowUp", size: 5,
|
||||
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
@@ -290,7 +355,7 @@
|
||||
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||
time: m.time, position: "aboveBar",
|
||||
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||
shape: "arrowDown", size: 3,
|
||||
shape: "arrowDown", size: 5,
|
||||
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
@@ -398,17 +463,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function init() {
|
||||
DATA = window.CHART_DATA;
|
||||
if (!DATA) throw new Error("차트 데이터 JS 없음");
|
||||
const m = DATA.meta;
|
||||
const simMode = !!DATA.sim_pnl;
|
||||
const chartDays = m.chart_lookback_days || m.lookback_days;
|
||||
const gtDays = m.gt_lookback_days || m.lookback_days;
|
||||
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
|
||||
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
|
||||
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
|
||||
const simSuffix = simMode ? " · 2단계 시뮬" : "";
|
||||
document.getElementById("title").textContent =
|
||||
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
|
||||
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
|
||||
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
|
||||
const chartFrom = m.chart_data_from || m.data_from;
|
||||
const chartTo = m.chart_data_to || m.data_to;
|
||||
@@ -418,8 +487,13 @@
|
||||
: tierKey === "v2"
|
||||
? "B/S=스윙 B*=눌림목"
|
||||
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
|
||||
const markerRange = simMode && m.sim_period_from
|
||||
? `체결 ${DATA.buy_markers.length}/${DATA.sell_markers.length} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
|
||||
: gtLabel;
|
||||
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
|
||||
document.getElementById("meta").textContent =
|
||||
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
|
||||
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${markerRange}) | ${legend}${legendExtra}`;
|
||||
if (simMode) renderSimPanel();
|
||||
updateLegInfo();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
|
||||
Reference in New Issue
Block a user