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:
@@ -30,6 +30,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>
|
||||
@@ -38,6 +39,7 @@
|
||||
<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>
|
||||
@@ -80,7 +82,12 @@
|
||||
let lastDetailStart = 0;
|
||||
|
||||
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||
const MARKER_FONT = "bold 17px Malgun Gothic, Arial, sans-serif";
|
||||
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
|
||||
const ARROW_HALF = 6;
|
||||
const ARROW_HEIGHT = 8;
|
||||
const LABEL_OFFSET_X = 8;
|
||||
const LABEL_GAP = 12;
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
let axisMeasureCtx = null;
|
||||
|
||||
const COLORS = {
|
||||
@@ -138,39 +145,63 @@
|
||||
return "S↑" + id + sfx;
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||
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 + 10;
|
||||
const ly = y + (up ? 28 : -20);
|
||||
ctx.lineWidth = 3;
|
||||
const lx = x + LABEL_OFFSET_X;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, lx, ly);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.strokeText(label, lx, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, ly);
|
||||
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 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);
|
||||
const xOff = m.x_offset_px || 0;
|
||||
const x = u.valToPos(m.time, "x", true) + xOff;
|
||||
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();
|
||||
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||
drawTriangleOnLine(ctx, x, lineY, up, color);
|
||||
const labelY = up
|
||||
? lineY + ARROW_HEIGHT + LABEL_GAP
|
||||
: lineY - ARROW_HEIGHT - LABEL_GAP;
|
||||
drawMarkerLabel(ctx, label, x, labelY, color);
|
||||
}
|
||||
|
||||
function drawFuturesMarkers(u) {
|
||||
if (!showMarkers) return;
|
||||
drawSimStartMarker(u, DATA.sim_start_marker);
|
||||
(DATA.long_open_markers || []).forEach(m =>
|
||||
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
|
||||
(DATA.long_close_markers || []).forEach(m =>
|
||||
@@ -181,6 +212,29 @@
|
||||
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
|
||||
}
|
||||
|
||||
function drawSimStartMarker(u, marker) {
|
||||
if (!marker) return;
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(marker.time, "x", true);
|
||||
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const color = SIM_START_COLOR;
|
||||
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";
|
||||
}
|
||||
|
||||
function updateLegInfo() {
|
||||
const total = (DATA.long_open_markers || []).length;
|
||||
const el = document.getElementById("leg-info");
|
||||
@@ -221,7 +275,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 },
|
||||
@@ -303,7 +357,7 @@
|
||||
position: up ? "belowBar" : "aboveBar",
|
||||
color: colorMap[kind],
|
||||
shape: up ? "arrowUp" : "arrowDown",
|
||||
size: 3,
|
||||
size: 5,
|
||||
text: markerLabel(kind, m),
|
||||
});
|
||||
});
|
||||
@@ -414,6 +468,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function renderLegend() {
|
||||
const items = [
|
||||
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
|
||||
@@ -435,8 +491,10 @@
|
||||
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
|
||||
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
|
||||
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
|
||||
const simMode = !!DATA.sim_pnl;
|
||||
const simSuffix = simMode ? " · 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;
|
||||
@@ -444,10 +502,15 @@
|
||||
const lc = (DATA.long_close_markers || []).length;
|
||||
const so = (DATA.short_open_markers || []).length;
|
||||
const sc = (DATA.short_close_markers || []).length;
|
||||
const markerRange = simMode && m.sim_period_from
|
||||
? `체결 L↑${lo}/L↓${lc} · S↓${so}/S↑${sc} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
|
||||
: `GT ${gtLabel} | 현물 GT 타점 기반`;
|
||||
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
|
||||
document.getElementById("meta").textContent =
|
||||
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
|
||||
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | GT ${gtLabel} | 현물 GT 타점 기반`;
|
||||
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | ${markerRange}${legendExtra}`;
|
||||
renderLegend();
|
||||
if (simMode) renderSimPanel();
|
||||
updateLegInfo();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
|
||||
Reference in New Issue
Block a user