feat: Ground Truth 2단계 1년 수익 시뮬 및 sim 차트 추가
분할 매수/매도 PnL 시뮬, 체결 타점·거래시작 마커, x축 unix 변환 수정. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -47,6 +47,12 @@ GROUND_TRUTH_CHART_V1_FILE=docs/02_ground_truth/ground_truth_chart_v1.html
|
||||
GROUND_TRUTH_CHART_V2_FILE=docs/02_ground_truth/ground_truth_chart_v2.html
|
||||
GROUND_TRUTH_CHART_V3_FILE=docs/02_ground_truth/ground_truth_chart_v3.html
|
||||
|
||||
# --- Ground Truth 2단계 (수익 시뮬, 최근 1년) ---
|
||||
GT_SIM_LOOKBACK_DAYS=365
|
||||
GROUND_TRUTH_CHART_SIM_V1_FILE=docs/02_ground_truth/ground_truth_chart_sim_v1.html
|
||||
GROUND_TRUTH_CHART_SIM_V2_FILE=docs/02_ground_truth/ground_truth_chart_sim_v2.html
|
||||
GROUND_TRUTH_CHART_SIM_V3_FILE=docs/02_ground_truth/ground_truth_chart_sim_v3.html
|
||||
|
||||
# --- 매매 기법 (2단계) ---
|
||||
TECHNIQUES_DIR=data/techniques
|
||||
ANALYSIS_REPORT_JSON=docs/03_analysis/comparison_report.json
|
||||
|
||||
3684
data/ground_truth/ground_truth_trades_v1.json
Normal file
3684
data/ground_truth/ground_truth_trades_v1.json
Normal file
File diff suppressed because it is too large
Load Diff
4449
data/ground_truth/ground_truth_trades_v2.json
Normal file
4449
data/ground_truth/ground_truth_trades_v2.json
Normal file
File diff suppressed because it is too large
Load Diff
6114
data/ground_truth/ground_truth_trades_v3.json
Normal file
6114
data/ground_truth/ground_truth_trades_v3.json
Normal file
File diff suppressed because it is too large
Load Diff
609
docs/02_ground_truth/ground_truth_chart_sim_v1.html
Normal file
609
docs/02_ground_truth/ground_truth_chart_sim_v1.html
Normal file
@@ -0,0 +1,609 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Ground Truth Chart</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="ground_truth_chart_sim_v1_data.js"></script>
|
||||
<style>
|
||||
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
|
||||
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
|
||||
h1 { margin: 0 0 6px; font-size: 20px; }
|
||||
.meta { font-size: 13px; color: #666; }
|
||||
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
|
||||
.toolbar-group:last-of-type { border-right: none; }
|
||||
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
|
||||
.toolbar button:hover { background: #f0f4f8; }
|
||||
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
|
||||
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
|
||||
.toolbar button.home:hover { background: #1b5e20; }
|
||||
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
|
||||
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
|
||||
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
|
||||
.sim-card .value { font-size: 18px; font-weight: bold; }
|
||||
.sim-card .value.positive { color: #2e7d32; }
|
||||
.sim-card .value.negative { color: #c62828; }
|
||||
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
|
||||
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
|
||||
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
|
||||
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
|
||||
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
|
||||
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
|
||||
.trade-table tr.skipped td { color: #999; }
|
||||
.trade-scroll { max-height: 240px; overflow: auto; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="title">Ground Truth Chart</h1>
|
||||
<div class="meta" id="meta"></div>
|
||||
</header>
|
||||
|
||||
<section class="sim-panel" id="sim-panel">
|
||||
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
|
||||
<div class="sim-grid" id="sim-grid"></div>
|
||||
<div class="sim-note" id="sim-note"></div>
|
||||
</section>
|
||||
<details id="trade-table-wrap">
|
||||
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
|
||||
<div class="trade-scroll">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
|
||||
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="trade-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</button>
|
||||
<button id="btn-prev-leg" title="이전 매수·매도 타점">◀ 이전</button>
|
||||
<button id="btn-next-leg" title="다음 매수·매도 타점">다음 ▶</button>
|
||||
<span class="leg-info" id="leg-info">타점 - / -</span>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-all" class="btn-period active">전체</button>
|
||||
<button id="btn-365d" class="btn-period">1년</button>
|
||||
<button id="btn-30d" class="btn-period">30일</button>
|
||||
<button id="btn-7d" class="btn-period">7일</button>
|
||||
<button id="btn-3d" class="btn-period">3일</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-zoom-in" title="확대">+ 확대</button>
|
||||
<button id="btn-zoom-out" title="축소">− 축소</button>
|
||||
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-markers" title="매수·매도 마커 표시/숨김">마커 숨김</button>
|
||||
<button id="btn-toggle-detail" title="상세 캔들 패널 표시/숨김">상세 패널</button>
|
||||
</div>
|
||||
<span id="status">데이터 로딩 중…</span>
|
||||
</div>
|
||||
<div id="overview"></div>
|
||||
<div id="detail-wrap">
|
||||
<h2 id="detail-title">상세 캔들</h2>
|
||||
<div id="detail"></div>
|
||||
</div>
|
||||
<script>
|
||||
let DATA = null;
|
||||
let overviewPlot = null;
|
||||
let detailChart = null;
|
||||
let detailSeries = null;
|
||||
let currentMode = "overview";
|
||||
let currentLegIdx = 0;
|
||||
let showMarkers = true;
|
||||
let detailVisible = false;
|
||||
let lastDetailStart = 0;
|
||||
|
||||
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||
let axisMeasureCtx = null;
|
||||
|
||||
function fmtPrice(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function measureTextWidth(text, font) {
|
||||
if (!axisMeasureCtx) {
|
||||
const c = document.createElement("canvas");
|
||||
axisMeasureCtx = c.getContext("2d");
|
||||
}
|
||||
axisMeasureCtx.font = font;
|
||||
return axisMeasureCtx.measureText(text).width;
|
||||
}
|
||||
|
||||
function yAxisLabelWidth() {
|
||||
const vals = DATA.close;
|
||||
if (!vals || !vals.length) return 88;
|
||||
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||
let lo = vals[0], hi = vals[0];
|
||||
for (let i = 1; i < vals.length; i++) {
|
||||
if (vals[i] < lo) lo = vals[i];
|
||||
if (vals[i] > hi) hi = vals[i];
|
||||
}
|
||||
samples.add(lo);
|
||||
samples.add(hi);
|
||||
samples.add((lo + hi) / 2);
|
||||
let maxW = 0;
|
||||
samples.forEach(v => {
|
||||
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||
});
|
||||
return Math.ceil(maxW) + 20;
|
||||
}
|
||||
|
||||
function updateLegInfo() {
|
||||
const total = DATA.buy_markers.length;
|
||||
const el = document.getElementById("leg-info");
|
||||
if (!total) { el.textContent = "타점 없음"; return; }
|
||||
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||
}
|
||||
|
||||
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
|
||||
function markerSuffix(signalType) {
|
||||
if (signalType === "pullback") return "*";
|
||||
if (signalType === "breakout") return "^";
|
||||
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||
return "";
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||
ctx.font = MARKER_FONT;
|
||||
const lx = x + 10;
|
||||
const ly = y + (up ? 28 : -20);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, lx, ly);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, ly);
|
||||
}
|
||||
|
||||
function drawSimStartMarker(u, marker) {
|
||||
if (!marker) return;
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(marker.time, "x", true);
|
||||
const y = u.valToPos(marker.price, "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const color = SIM_START_COLOR;
|
||||
const s = 10;
|
||||
const gap = 14;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - gap);
|
||||
ctx.lineTo(x - s, y - gap - 18);
|
||||
ctx.lineTo(x + s, y - gap - 18);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
const label = marker.label || "거래시작";
|
||||
ctx.font = MARKER_FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
const labelY = y - gap - 18 - 6;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, x, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x, labelY);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function drawMarkers(u, buys, sells) {
|
||||
if (!showMarkers) return;
|
||||
const ctx = u.ctx;
|
||||
const drawOne = (m, color, up) => {
|
||||
const x = u.valToPos(m.time, "x", true);
|
||||
const y = u.valToPos(m.price, "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
const s = 8;
|
||||
const gap = 12;
|
||||
if (up) {
|
||||
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||
} else {
|
||||
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||
};
|
||||
buys.forEach(m => {
|
||||
let color = "#2e7d32";
|
||||
if (m.signal_type === "breakout") color = "#ef6c00";
|
||||
else if (m.signal_type === "div_bull") color = "#7b1fa2";
|
||||
drawOne(m, color, true);
|
||||
});
|
||||
sells.forEach(m => {
|
||||
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
|
||||
drawOne(m, color, false);
|
||||
});
|
||||
}
|
||||
|
||||
function overviewXRange() {
|
||||
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
|
||||
const s = overviewPlot.scales.x;
|
||||
return { min: s.min, max: s.max };
|
||||
}
|
||||
|
||||
function setOverviewXRange(min, max) {
|
||||
const t0 = DATA.times[0];
|
||||
const t1 = DATA.times[DATA.times.length - 1];
|
||||
overviewPlot.setScale("x", {
|
||||
min: Math.max(t0, min),
|
||||
max: Math.min(t1, max),
|
||||
});
|
||||
}
|
||||
|
||||
function fitOverview() {
|
||||
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
|
||||
}
|
||||
|
||||
function zoomOverview(factor) {
|
||||
const { min, max } = overviewXRange();
|
||||
const mid = (min + max) / 2;
|
||||
const half = Math.max((max - min) * factor / 2, 3600);
|
||||
setOverviewXRange(mid - half, mid + half);
|
||||
}
|
||||
|
||||
function buildOverview(keepRange) {
|
||||
const prev = keepRange ? overviewXRange() : null;
|
||||
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||
const yAxisW = yAxisLabelWidth();
|
||||
const opts = {
|
||||
width: document.getElementById("overview").clientWidth,
|
||||
height: 480,
|
||||
padding: [14, 10, 14, 10],
|
||||
scales: { x: { time: true } },
|
||||
axes: [
|
||||
{ gap: 6 },
|
||||
{
|
||||
side: 3,
|
||||
size: yAxisW,
|
||||
gap: 10,
|
||||
font: AXIS_FONT,
|
||||
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: "종가", stroke: "#1565c0", width: 1 },
|
||||
],
|
||||
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||
hooks: {
|
||||
draw: [(u) => {
|
||||
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"));
|
||||
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 (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,
|
||||
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
DATA.sell_markers.forEach(m => {
|
||||
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||
time: m.time, position: "aboveBar",
|
||||
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||
shape: "arrowDown", size: 3,
|
||||
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
}
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
detailSeries.setMarkers(markers);
|
||||
detailChart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
function setActive(btnId) {
|
||||
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
|
||||
const el = document.getElementById(btnId);
|
||||
if (el) el.classList.add("active");
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
currentMode = "overview";
|
||||
setActive("btn-all");
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
else fitOverview();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
}
|
||||
|
||||
function nearestSellAfter(buyTime) {
|
||||
let best = null;
|
||||
for (const s of DATA.sell_markers) {
|
||||
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
|
||||
}
|
||||
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
|
||||
}
|
||||
|
||||
function jumpToLeg(idx) {
|
||||
const total = DATA.buy_markers.length;
|
||||
if (!total) return;
|
||||
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||
updateLegInfo();
|
||||
const buy = DATA.buy_markers[currentLegIdx];
|
||||
const sell = nearestSellAfter(buy.time);
|
||||
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
|
||||
const pad = span * 0.4;
|
||||
const vmin = buy.time - pad;
|
||||
const vmax = (sell ? sell.time : buy.time) + pad;
|
||||
|
||||
currentMode = "overview";
|
||||
setActive("btn-all");
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
setOverviewXRange(vmin, vmax);
|
||||
|
||||
let start = 0;
|
||||
for (let i = 0; i < DATA.times.length; i++) {
|
||||
if (DATA.times[i] >= vmin) { start = i; break; }
|
||||
}
|
||||
let end = DATA.times.length;
|
||||
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||
if (DATA.times[i] <= vmax) { end = i + 1; break; }
|
||||
}
|
||||
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
|
||||
: buy.signal_type === "breakout" ? "돌파 매수"
|
||||
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
|
||||
document.getElementById("detail-title").textContent =
|
||||
`B${buy.marker_id} ${buyLabel} — ${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
|
||||
buildDetailCandles(start, end);
|
||||
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
|
||||
document.getElementById("status").textContent =
|
||||
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
|
||||
}
|
||||
|
||||
function showPeriod(days, btnId, label) {
|
||||
currentMode = "detail";
|
||||
setActive(btnId);
|
||||
detailVisible = true;
|
||||
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
|
||||
const { start } = sliceLastDays(days);
|
||||
document.getElementById("detail-title").textContent =
|
||||
`${label} 3분봉 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
|
||||
buildDetailCandles(start);
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
const t0 = DATA.times[start];
|
||||
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
|
||||
document.getElementById("status").textContent = `${label} 구간 표시`;
|
||||
}
|
||||
|
||||
function applyZoom(factor) {
|
||||
if (currentMode === "detail" && detailChart) {
|
||||
const ts = detailChart.timeScale();
|
||||
const r = ts.getVisibleLogicalRange();
|
||||
if (!r) return;
|
||||
const mid = (r.from + r.to) / 2;
|
||||
const half = Math.max((r.to - r.from) * factor / 2, 10);
|
||||
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
|
||||
} else if (overviewPlot) {
|
||||
zoomOverview(factor);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFit() {
|
||||
if (currentMode === "detail" && detailChart) {
|
||||
detailChart.timeScale().fitContent();
|
||||
} else if (overviewPlot) {
|
||||
fitOverview();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function fmtMoney(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR") + "원";
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return sign + v.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function renderSimPanel() {
|
||||
const p = DATA.sim_pnl;
|
||||
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
|
||||
document.getElementById("sim-grid").innerHTML = [
|
||||
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
|
||||
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
|
||||
["손익", fmtMoney(p.total_pnl_krw), retClass],
|
||||
["수익률", fmtPct(p.total_return_pct), retClass],
|
||||
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
|
||||
["보유 코인", p.final_coin_qty.toFixed(8), ""],
|
||||
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
|
||||
["매수/매도", `${p.buys_executed}/${p.sells_executed}건`, ""],
|
||||
].map(([label, value, cls]) =>
|
||||
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
|
||||
).join("");
|
||||
document.getElementById("sim-note").textContent =
|
||||
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
|
||||
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
|
||||
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
|
||||
const tbody = document.getElementById("trade-body");
|
||||
tbody.innerHTML = "";
|
||||
(p.trades || []).forEach(t => {
|
||||
const tr = document.createElement("tr");
|
||||
if (t.skipped) tr.className = "skipped";
|
||||
tr.innerHTML = `
|
||||
<td>${t.datetime}</td>
|
||||
<td>${t.side === "buy" ? "매수" : "매도"}</td>
|
||||
<td>${t.signal_type}</td>
|
||||
<td>${fmtPrice(t.price)}</td>
|
||||
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
|
||||
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
|
||||
<td>${fmtMoney(t.cash_after)}</td>
|
||||
<td>${t.coin_after.toFixed(8)}</td>
|
||||
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById("trade-count").textContent = String((p.trades || []).length);
|
||||
}
|
||||
|
||||
|
||||
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}${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;
|
||||
const tierKey = (m.chart_tier || "v3").toLowerCase();
|
||||
const legend = tierKey === "v1"
|
||||
? "B=스윙매수 S=스윙매도"
|
||||
: 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} (${markerRange}) | ${legend}${legendExtra}`;
|
||||
if (simMode) renderSimPanel();
|
||||
updateLegInfo();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
|
||||
buildOverview(false);
|
||||
|
||||
document.getElementById("btn-home").onclick = goHome;
|
||||
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
|
||||
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
|
||||
document.getElementById("btn-all").onclick = goHome;
|
||||
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
|
||||
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
|
||||
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
|
||||
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
|
||||
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
|
||||
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
|
||||
document.getElementById("btn-fit").onclick = applyFit;
|
||||
document.getElementById("btn-markers").onclick = () => {
|
||||
showMarkers = !showMarkers;
|
||||
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
|
||||
if (overviewPlot) buildOverview(true);
|
||||
if (detailChart) buildDetailCandles(lastDetailStart);
|
||||
};
|
||||
document.getElementById("btn-toggle-detail").onclick = () => {
|
||||
detailVisible = !detailVisible;
|
||||
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
|
||||
if (detailVisible && !detailChart) {
|
||||
const { start } = sliceLastDays(7);
|
||||
buildDetailCandles(start);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("overview").addEventListener("dblclick", () => {
|
||||
if (currentMode === "overview") fitOverview();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (overviewPlot) buildOverview(true);
|
||||
});
|
||||
}
|
||||
try { init(); } catch (err) {
|
||||
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
docs/02_ground_truth/ground_truth_chart_sim_v1_data.js
Normal file
1
docs/02_ground_truth/ground_truth_chart_sim_v1_data.js
Normal file
File diff suppressed because one or more lines are too long
609
docs/02_ground_truth/ground_truth_chart_sim_v2.html
Normal file
609
docs/02_ground_truth/ground_truth_chart_sim_v2.html
Normal file
@@ -0,0 +1,609 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Ground Truth Chart</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="ground_truth_chart_sim_v2_data.js"></script>
|
||||
<style>
|
||||
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
|
||||
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
|
||||
h1 { margin: 0 0 6px; font-size: 20px; }
|
||||
.meta { font-size: 13px; color: #666; }
|
||||
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
|
||||
.toolbar-group:last-of-type { border-right: none; }
|
||||
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
|
||||
.toolbar button:hover { background: #f0f4f8; }
|
||||
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
|
||||
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
|
||||
.toolbar button.home:hover { background: #1b5e20; }
|
||||
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
|
||||
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
|
||||
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
|
||||
.sim-card .value { font-size: 18px; font-weight: bold; }
|
||||
.sim-card .value.positive { color: #2e7d32; }
|
||||
.sim-card .value.negative { color: #c62828; }
|
||||
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
|
||||
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
|
||||
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
|
||||
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
|
||||
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
|
||||
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
|
||||
.trade-table tr.skipped td { color: #999; }
|
||||
.trade-scroll { max-height: 240px; overflow: auto; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="title">Ground Truth Chart</h1>
|
||||
<div class="meta" id="meta"></div>
|
||||
</header>
|
||||
|
||||
<section class="sim-panel" id="sim-panel">
|
||||
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
|
||||
<div class="sim-grid" id="sim-grid"></div>
|
||||
<div class="sim-note" id="sim-note"></div>
|
||||
</section>
|
||||
<details id="trade-table-wrap">
|
||||
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
|
||||
<div class="trade-scroll">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
|
||||
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="trade-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</button>
|
||||
<button id="btn-prev-leg" title="이전 매수·매도 타점">◀ 이전</button>
|
||||
<button id="btn-next-leg" title="다음 매수·매도 타점">다음 ▶</button>
|
||||
<span class="leg-info" id="leg-info">타점 - / -</span>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-all" class="btn-period active">전체</button>
|
||||
<button id="btn-365d" class="btn-period">1년</button>
|
||||
<button id="btn-30d" class="btn-period">30일</button>
|
||||
<button id="btn-7d" class="btn-period">7일</button>
|
||||
<button id="btn-3d" class="btn-period">3일</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-zoom-in" title="확대">+ 확대</button>
|
||||
<button id="btn-zoom-out" title="축소">− 축소</button>
|
||||
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-markers" title="매수·매도 마커 표시/숨김">마커 숨김</button>
|
||||
<button id="btn-toggle-detail" title="상세 캔들 패널 표시/숨김">상세 패널</button>
|
||||
</div>
|
||||
<span id="status">데이터 로딩 중…</span>
|
||||
</div>
|
||||
<div id="overview"></div>
|
||||
<div id="detail-wrap">
|
||||
<h2 id="detail-title">상세 캔들</h2>
|
||||
<div id="detail"></div>
|
||||
</div>
|
||||
<script>
|
||||
let DATA = null;
|
||||
let overviewPlot = null;
|
||||
let detailChart = null;
|
||||
let detailSeries = null;
|
||||
let currentMode = "overview";
|
||||
let currentLegIdx = 0;
|
||||
let showMarkers = true;
|
||||
let detailVisible = false;
|
||||
let lastDetailStart = 0;
|
||||
|
||||
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||
let axisMeasureCtx = null;
|
||||
|
||||
function fmtPrice(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function measureTextWidth(text, font) {
|
||||
if (!axisMeasureCtx) {
|
||||
const c = document.createElement("canvas");
|
||||
axisMeasureCtx = c.getContext("2d");
|
||||
}
|
||||
axisMeasureCtx.font = font;
|
||||
return axisMeasureCtx.measureText(text).width;
|
||||
}
|
||||
|
||||
function yAxisLabelWidth() {
|
||||
const vals = DATA.close;
|
||||
if (!vals || !vals.length) return 88;
|
||||
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||
let lo = vals[0], hi = vals[0];
|
||||
for (let i = 1; i < vals.length; i++) {
|
||||
if (vals[i] < lo) lo = vals[i];
|
||||
if (vals[i] > hi) hi = vals[i];
|
||||
}
|
||||
samples.add(lo);
|
||||
samples.add(hi);
|
||||
samples.add((lo + hi) / 2);
|
||||
let maxW = 0;
|
||||
samples.forEach(v => {
|
||||
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||
});
|
||||
return Math.ceil(maxW) + 20;
|
||||
}
|
||||
|
||||
function updateLegInfo() {
|
||||
const total = DATA.buy_markers.length;
|
||||
const el = document.getElementById("leg-info");
|
||||
if (!total) { el.textContent = "타점 없음"; return; }
|
||||
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||
}
|
||||
|
||||
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
|
||||
function markerSuffix(signalType) {
|
||||
if (signalType === "pullback") return "*";
|
||||
if (signalType === "breakout") return "^";
|
||||
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||
return "";
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||
ctx.font = MARKER_FONT;
|
||||
const lx = x + 10;
|
||||
const ly = y + (up ? 28 : -20);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, lx, ly);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, ly);
|
||||
}
|
||||
|
||||
function drawSimStartMarker(u, marker) {
|
||||
if (!marker) return;
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(marker.time, "x", true);
|
||||
const y = u.valToPos(marker.price, "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const color = SIM_START_COLOR;
|
||||
const s = 10;
|
||||
const gap = 14;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - gap);
|
||||
ctx.lineTo(x - s, y - gap - 18);
|
||||
ctx.lineTo(x + s, y - gap - 18);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
const label = marker.label || "거래시작";
|
||||
ctx.font = MARKER_FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
const labelY = y - gap - 18 - 6;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, x, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x, labelY);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function drawMarkers(u, buys, sells) {
|
||||
if (!showMarkers) return;
|
||||
const ctx = u.ctx;
|
||||
const drawOne = (m, color, up) => {
|
||||
const x = u.valToPos(m.time, "x", true);
|
||||
const y = u.valToPos(m.price, "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
const s = 8;
|
||||
const gap = 12;
|
||||
if (up) {
|
||||
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||
} else {
|
||||
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||
};
|
||||
buys.forEach(m => {
|
||||
let color = "#2e7d32";
|
||||
if (m.signal_type === "breakout") color = "#ef6c00";
|
||||
else if (m.signal_type === "div_bull") color = "#7b1fa2";
|
||||
drawOne(m, color, true);
|
||||
});
|
||||
sells.forEach(m => {
|
||||
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
|
||||
drawOne(m, color, false);
|
||||
});
|
||||
}
|
||||
|
||||
function overviewXRange() {
|
||||
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
|
||||
const s = overviewPlot.scales.x;
|
||||
return { min: s.min, max: s.max };
|
||||
}
|
||||
|
||||
function setOverviewXRange(min, max) {
|
||||
const t0 = DATA.times[0];
|
||||
const t1 = DATA.times[DATA.times.length - 1];
|
||||
overviewPlot.setScale("x", {
|
||||
min: Math.max(t0, min),
|
||||
max: Math.min(t1, max),
|
||||
});
|
||||
}
|
||||
|
||||
function fitOverview() {
|
||||
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
|
||||
}
|
||||
|
||||
function zoomOverview(factor) {
|
||||
const { min, max } = overviewXRange();
|
||||
const mid = (min + max) / 2;
|
||||
const half = Math.max((max - min) * factor / 2, 3600);
|
||||
setOverviewXRange(mid - half, mid + half);
|
||||
}
|
||||
|
||||
function buildOverview(keepRange) {
|
||||
const prev = keepRange ? overviewXRange() : null;
|
||||
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||
const yAxisW = yAxisLabelWidth();
|
||||
const opts = {
|
||||
width: document.getElementById("overview").clientWidth,
|
||||
height: 480,
|
||||
padding: [14, 10, 14, 10],
|
||||
scales: { x: { time: true } },
|
||||
axes: [
|
||||
{ gap: 6 },
|
||||
{
|
||||
side: 3,
|
||||
size: yAxisW,
|
||||
gap: 10,
|
||||
font: AXIS_FONT,
|
||||
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: "종가", stroke: "#1565c0", width: 1 },
|
||||
],
|
||||
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||
hooks: {
|
||||
draw: [(u) => {
|
||||
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"));
|
||||
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 (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,
|
||||
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
DATA.sell_markers.forEach(m => {
|
||||
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||
time: m.time, position: "aboveBar",
|
||||
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||
shape: "arrowDown", size: 3,
|
||||
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
}
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
detailSeries.setMarkers(markers);
|
||||
detailChart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
function setActive(btnId) {
|
||||
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
|
||||
const el = document.getElementById(btnId);
|
||||
if (el) el.classList.add("active");
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
currentMode = "overview";
|
||||
setActive("btn-all");
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
else fitOverview();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
}
|
||||
|
||||
function nearestSellAfter(buyTime) {
|
||||
let best = null;
|
||||
for (const s of DATA.sell_markers) {
|
||||
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
|
||||
}
|
||||
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
|
||||
}
|
||||
|
||||
function jumpToLeg(idx) {
|
||||
const total = DATA.buy_markers.length;
|
||||
if (!total) return;
|
||||
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||
updateLegInfo();
|
||||
const buy = DATA.buy_markers[currentLegIdx];
|
||||
const sell = nearestSellAfter(buy.time);
|
||||
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
|
||||
const pad = span * 0.4;
|
||||
const vmin = buy.time - pad;
|
||||
const vmax = (sell ? sell.time : buy.time) + pad;
|
||||
|
||||
currentMode = "overview";
|
||||
setActive("btn-all");
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
setOverviewXRange(vmin, vmax);
|
||||
|
||||
let start = 0;
|
||||
for (let i = 0; i < DATA.times.length; i++) {
|
||||
if (DATA.times[i] >= vmin) { start = i; break; }
|
||||
}
|
||||
let end = DATA.times.length;
|
||||
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||
if (DATA.times[i] <= vmax) { end = i + 1; break; }
|
||||
}
|
||||
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
|
||||
: buy.signal_type === "breakout" ? "돌파 매수"
|
||||
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
|
||||
document.getElementById("detail-title").textContent =
|
||||
`B${buy.marker_id} ${buyLabel} — ${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
|
||||
buildDetailCandles(start, end);
|
||||
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
|
||||
document.getElementById("status").textContent =
|
||||
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
|
||||
}
|
||||
|
||||
function showPeriod(days, btnId, label) {
|
||||
currentMode = "detail";
|
||||
setActive(btnId);
|
||||
detailVisible = true;
|
||||
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
|
||||
const { start } = sliceLastDays(days);
|
||||
document.getElementById("detail-title").textContent =
|
||||
`${label} 3분봉 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
|
||||
buildDetailCandles(start);
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
const t0 = DATA.times[start];
|
||||
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
|
||||
document.getElementById("status").textContent = `${label} 구간 표시`;
|
||||
}
|
||||
|
||||
function applyZoom(factor) {
|
||||
if (currentMode === "detail" && detailChart) {
|
||||
const ts = detailChart.timeScale();
|
||||
const r = ts.getVisibleLogicalRange();
|
||||
if (!r) return;
|
||||
const mid = (r.from + r.to) / 2;
|
||||
const half = Math.max((r.to - r.from) * factor / 2, 10);
|
||||
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
|
||||
} else if (overviewPlot) {
|
||||
zoomOverview(factor);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFit() {
|
||||
if (currentMode === "detail" && detailChart) {
|
||||
detailChart.timeScale().fitContent();
|
||||
} else if (overviewPlot) {
|
||||
fitOverview();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function fmtMoney(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR") + "원";
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return sign + v.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function renderSimPanel() {
|
||||
const p = DATA.sim_pnl;
|
||||
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
|
||||
document.getElementById("sim-grid").innerHTML = [
|
||||
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
|
||||
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
|
||||
["손익", fmtMoney(p.total_pnl_krw), retClass],
|
||||
["수익률", fmtPct(p.total_return_pct), retClass],
|
||||
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
|
||||
["보유 코인", p.final_coin_qty.toFixed(8), ""],
|
||||
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
|
||||
["매수/매도", `${p.buys_executed}/${p.sells_executed}건`, ""],
|
||||
].map(([label, value, cls]) =>
|
||||
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
|
||||
).join("");
|
||||
document.getElementById("sim-note").textContent =
|
||||
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
|
||||
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
|
||||
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
|
||||
const tbody = document.getElementById("trade-body");
|
||||
tbody.innerHTML = "";
|
||||
(p.trades || []).forEach(t => {
|
||||
const tr = document.createElement("tr");
|
||||
if (t.skipped) tr.className = "skipped";
|
||||
tr.innerHTML = `
|
||||
<td>${t.datetime}</td>
|
||||
<td>${t.side === "buy" ? "매수" : "매도"}</td>
|
||||
<td>${t.signal_type}</td>
|
||||
<td>${fmtPrice(t.price)}</td>
|
||||
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
|
||||
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
|
||||
<td>${fmtMoney(t.cash_after)}</td>
|
||||
<td>${t.coin_after.toFixed(8)}</td>
|
||||
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById("trade-count").textContent = String((p.trades || []).length);
|
||||
}
|
||||
|
||||
|
||||
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}${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;
|
||||
const tierKey = (m.chart_tier || "v3").toLowerCase();
|
||||
const legend = tierKey === "v1"
|
||||
? "B=스윙매수 S=스윙매도"
|
||||
: 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} (${markerRange}) | ${legend}${legendExtra}`;
|
||||
if (simMode) renderSimPanel();
|
||||
updateLegInfo();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
|
||||
buildOverview(false);
|
||||
|
||||
document.getElementById("btn-home").onclick = goHome;
|
||||
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
|
||||
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
|
||||
document.getElementById("btn-all").onclick = goHome;
|
||||
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
|
||||
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
|
||||
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
|
||||
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
|
||||
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
|
||||
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
|
||||
document.getElementById("btn-fit").onclick = applyFit;
|
||||
document.getElementById("btn-markers").onclick = () => {
|
||||
showMarkers = !showMarkers;
|
||||
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
|
||||
if (overviewPlot) buildOverview(true);
|
||||
if (detailChart) buildDetailCandles(lastDetailStart);
|
||||
};
|
||||
document.getElementById("btn-toggle-detail").onclick = () => {
|
||||
detailVisible = !detailVisible;
|
||||
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
|
||||
if (detailVisible && !detailChart) {
|
||||
const { start } = sliceLastDays(7);
|
||||
buildDetailCandles(start);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("overview").addEventListener("dblclick", () => {
|
||||
if (currentMode === "overview") fitOverview();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (overviewPlot) buildOverview(true);
|
||||
});
|
||||
}
|
||||
try { init(); } catch (err) {
|
||||
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
docs/02_ground_truth/ground_truth_chart_sim_v2_data.js
Normal file
1
docs/02_ground_truth/ground_truth_chart_sim_v2_data.js
Normal file
File diff suppressed because one or more lines are too long
609
docs/02_ground_truth/ground_truth_chart_sim_v3.html
Normal file
609
docs/02_ground_truth/ground_truth_chart_sim_v3.html
Normal file
@@ -0,0 +1,609 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Ground Truth Chart</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="ground_truth_chart_sim_v3_data.js"></script>
|
||||
<style>
|
||||
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
|
||||
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
|
||||
h1 { margin: 0 0 6px; font-size: 20px; }
|
||||
.meta { font-size: 13px; color: #666; }
|
||||
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
|
||||
.toolbar-group:last-of-type { border-right: none; }
|
||||
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
|
||||
.toolbar button:hover { background: #f0f4f8; }
|
||||
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
|
||||
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
|
||||
.toolbar button.home:hover { background: #1b5e20; }
|
||||
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
|
||||
#status { font-size: 12px; color: #888; margin-left: auto; }
|
||||
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
|
||||
#detail-wrap { margin: 0 24px 12px; display: none; }
|
||||
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
|
||||
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
|
||||
|
||||
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
|
||||
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
|
||||
.sim-card .value { font-size: 18px; font-weight: bold; }
|
||||
.sim-card .value.positive { color: #2e7d32; }
|
||||
.sim-card .value.negative { color: #c62828; }
|
||||
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
|
||||
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
|
||||
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
|
||||
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
|
||||
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
|
||||
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
|
||||
.trade-table tr.skipped td { color: #999; }
|
||||
.trade-scroll { max-height: 240px; overflow: auto; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="title">Ground Truth Chart</h1>
|
||||
<div class="meta" id="meta"></div>
|
||||
</header>
|
||||
|
||||
<section class="sim-panel" id="sim-panel">
|
||||
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
|
||||
<div class="sim-grid" id="sim-grid"></div>
|
||||
<div class="sim-note" id="sim-note"></div>
|
||||
</section>
|
||||
<details id="trade-table-wrap">
|
||||
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
|
||||
<div class="trade-scroll">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
|
||||
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="trade-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</button>
|
||||
<button id="btn-prev-leg" title="이전 매수·매도 타점">◀ 이전</button>
|
||||
<button id="btn-next-leg" title="다음 매수·매도 타점">다음 ▶</button>
|
||||
<span class="leg-info" id="leg-info">타점 - / -</span>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-all" class="btn-period active">전체</button>
|
||||
<button id="btn-365d" class="btn-period">1년</button>
|
||||
<button id="btn-30d" class="btn-period">30일</button>
|
||||
<button id="btn-7d" class="btn-period">7일</button>
|
||||
<button id="btn-3d" class="btn-period">3일</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-zoom-in" title="확대">+ 확대</button>
|
||||
<button id="btn-zoom-out" title="축소">− 축소</button>
|
||||
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-markers" title="매수·매도 마커 표시/숨김">마커 숨김</button>
|
||||
<button id="btn-toggle-detail" title="상세 캔들 패널 표시/숨김">상세 패널</button>
|
||||
</div>
|
||||
<span id="status">데이터 로딩 중…</span>
|
||||
</div>
|
||||
<div id="overview"></div>
|
||||
<div id="detail-wrap">
|
||||
<h2 id="detail-title">상세 캔들</h2>
|
||||
<div id="detail"></div>
|
||||
</div>
|
||||
<script>
|
||||
let DATA = null;
|
||||
let overviewPlot = null;
|
||||
let detailChart = null;
|
||||
let detailSeries = null;
|
||||
let currentMode = "overview";
|
||||
let currentLegIdx = 0;
|
||||
let showMarkers = true;
|
||||
let detailVisible = false;
|
||||
let lastDetailStart = 0;
|
||||
|
||||
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||
let axisMeasureCtx = null;
|
||||
|
||||
function fmtPrice(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function measureTextWidth(text, font) {
|
||||
if (!axisMeasureCtx) {
|
||||
const c = document.createElement("canvas");
|
||||
axisMeasureCtx = c.getContext("2d");
|
||||
}
|
||||
axisMeasureCtx.font = font;
|
||||
return axisMeasureCtx.measureText(text).width;
|
||||
}
|
||||
|
||||
function yAxisLabelWidth() {
|
||||
const vals = DATA.close;
|
||||
if (!vals || !vals.length) return 88;
|
||||
const samples = new Set([vals[0], vals[vals.length - 1]]);
|
||||
let lo = vals[0], hi = vals[0];
|
||||
for (let i = 1; i < vals.length; i++) {
|
||||
if (vals[i] < lo) lo = vals[i];
|
||||
if (vals[i] > hi) hi = vals[i];
|
||||
}
|
||||
samples.add(lo);
|
||||
samples.add(hi);
|
||||
samples.add((lo + hi) / 2);
|
||||
let maxW = 0;
|
||||
samples.forEach(v => {
|
||||
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
|
||||
});
|
||||
return Math.ceil(maxW) + 20;
|
||||
}
|
||||
|
||||
function updateLegInfo() {
|
||||
const total = DATA.buy_markers.length;
|
||||
const el = document.getElementById("leg-info");
|
||||
if (!total) { el.textContent = "타점 없음"; return; }
|
||||
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
|
||||
}
|
||||
|
||||
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
|
||||
function markerSuffix(signalType) {
|
||||
if (signalType === "pullback") return "*";
|
||||
if (signalType === "breakout") return "^";
|
||||
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||
return "";
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, y, color, up) {
|
||||
ctx.font = MARKER_FONT;
|
||||
const lx = x + 10;
|
||||
const ly = y + (up ? 28 : -20);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, lx, ly);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, ly);
|
||||
}
|
||||
|
||||
function drawSimStartMarker(u, marker) {
|
||||
if (!marker) return;
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(marker.time, "x", true);
|
||||
const y = u.valToPos(marker.price, "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const color = SIM_START_COLOR;
|
||||
const s = 10;
|
||||
const gap = 14;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - gap);
|
||||
ctx.lineTo(x - s, y - gap - 18);
|
||||
ctx.lineTo(x + s, y - gap - 18);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
const label = marker.label || "거래시작";
|
||||
ctx.font = MARKER_FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
const labelY = y - gap - 18 - 6;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, x, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x, labelY);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function drawMarkers(u, buys, sells) {
|
||||
if (!showMarkers) return;
|
||||
const ctx = u.ctx;
|
||||
const drawOne = (m, color, up) => {
|
||||
const x = u.valToPos(m.time, "x", true);
|
||||
const y = u.valToPos(m.price, "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
const s = 8;
|
||||
const gap = 12;
|
||||
if (up) {
|
||||
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
|
||||
} else {
|
||||
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
|
||||
drawMarkerLabel(ctx, label, x, y, color, up);
|
||||
};
|
||||
buys.forEach(m => {
|
||||
let color = "#2e7d32";
|
||||
if (m.signal_type === "breakout") color = "#ef6c00";
|
||||
else if (m.signal_type === "div_bull") color = "#7b1fa2";
|
||||
drawOne(m, color, true);
|
||||
});
|
||||
sells.forEach(m => {
|
||||
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
|
||||
drawOne(m, color, false);
|
||||
});
|
||||
}
|
||||
|
||||
function overviewXRange() {
|
||||
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
|
||||
const s = overviewPlot.scales.x;
|
||||
return { min: s.min, max: s.max };
|
||||
}
|
||||
|
||||
function setOverviewXRange(min, max) {
|
||||
const t0 = DATA.times[0];
|
||||
const t1 = DATA.times[DATA.times.length - 1];
|
||||
overviewPlot.setScale("x", {
|
||||
min: Math.max(t0, min),
|
||||
max: Math.min(t1, max),
|
||||
});
|
||||
}
|
||||
|
||||
function fitOverview() {
|
||||
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
|
||||
}
|
||||
|
||||
function zoomOverview(factor) {
|
||||
const { min, max } = overviewXRange();
|
||||
const mid = (min + max) / 2;
|
||||
const half = Math.max((max - min) * factor / 2, 3600);
|
||||
setOverviewXRange(mid - half, mid + half);
|
||||
}
|
||||
|
||||
function buildOverview(keepRange) {
|
||||
const prev = keepRange ? overviewXRange() : null;
|
||||
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
|
||||
const yAxisW = yAxisLabelWidth();
|
||||
const opts = {
|
||||
width: document.getElementById("overview").clientWidth,
|
||||
height: 480,
|
||||
padding: [14, 10, 14, 10],
|
||||
scales: { x: { time: true } },
|
||||
axes: [
|
||||
{ gap: 6 },
|
||||
{
|
||||
side: 3,
|
||||
size: yAxisW,
|
||||
gap: 10,
|
||||
font: AXIS_FONT,
|
||||
values: (u, vals) => vals.map(v => fmtPrice(v)),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: "종가", stroke: "#1565c0", width: 1 },
|
||||
],
|
||||
cursor: { drag: { x: true, y: false, setScale: true } },
|
||||
hooks: {
|
||||
draw: [(u) => {
|
||||
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"));
|
||||
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 (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,
|
||||
text: "B" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
DATA.sell_markers.forEach(m => {
|
||||
if (m.time >= t0 && m.time <= t1) markers.push({
|
||||
time: m.time, position: "aboveBar",
|
||||
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
|
||||
shape: "arrowDown", size: 3,
|
||||
text: "S" + m.marker_id + markerSuffix(m.signal_type),
|
||||
});
|
||||
});
|
||||
}
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
detailSeries.setMarkers(markers);
|
||||
detailChart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
function setActive(btnId) {
|
||||
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
|
||||
const el = document.getElementById(btnId);
|
||||
if (el) el.classList.add("active");
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
currentMode = "overview";
|
||||
setActive("btn-all");
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
else fitOverview();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
}
|
||||
|
||||
function nearestSellAfter(buyTime) {
|
||||
let best = null;
|
||||
for (const s of DATA.sell_markers) {
|
||||
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
|
||||
}
|
||||
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
|
||||
}
|
||||
|
||||
function jumpToLeg(idx) {
|
||||
const total = DATA.buy_markers.length;
|
||||
if (!total) return;
|
||||
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||
updateLegInfo();
|
||||
const buy = DATA.buy_markers[currentLegIdx];
|
||||
const sell = nearestSellAfter(buy.time);
|
||||
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
|
||||
const pad = span * 0.4;
|
||||
const vmin = buy.time - pad;
|
||||
const vmax = (sell ? sell.time : buy.time) + pad;
|
||||
|
||||
currentMode = "overview";
|
||||
setActive("btn-all");
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
setOverviewXRange(vmin, vmax);
|
||||
|
||||
let start = 0;
|
||||
for (let i = 0; i < DATA.times.length; i++) {
|
||||
if (DATA.times[i] >= vmin) { start = i; break; }
|
||||
}
|
||||
let end = DATA.times.length;
|
||||
for (let i = DATA.times.length - 1; i >= 0; i--) {
|
||||
if (DATA.times[i] <= vmax) { end = i + 1; break; }
|
||||
}
|
||||
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
|
||||
: buy.signal_type === "breakout" ? "돌파 매수"
|
||||
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
|
||||
document.getElementById("detail-title").textContent =
|
||||
`B${buy.marker_id} ${buyLabel} — ${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
|
||||
buildDetailCandles(start, end);
|
||||
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
|
||||
document.getElementById("status").textContent =
|
||||
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
|
||||
}
|
||||
|
||||
function showPeriod(days, btnId, label) {
|
||||
currentMode = "detail";
|
||||
setActive(btnId);
|
||||
detailVisible = true;
|
||||
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
|
||||
const { start } = sliceLastDays(days);
|
||||
document.getElementById("detail-title").textContent =
|
||||
`${label} 3분봉 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
|
||||
buildDetailCandles(start);
|
||||
document.getElementById("overview").style.display = "block";
|
||||
if (!overviewPlot) buildOverview(false);
|
||||
const t0 = DATA.times[start];
|
||||
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
|
||||
document.getElementById("status").textContent = `${label} 구간 표시`;
|
||||
}
|
||||
|
||||
function applyZoom(factor) {
|
||||
if (currentMode === "detail" && detailChart) {
|
||||
const ts = detailChart.timeScale();
|
||||
const r = ts.getVisibleLogicalRange();
|
||||
if (!r) return;
|
||||
const mid = (r.from + r.to) / 2;
|
||||
const half = Math.max((r.to - r.from) * factor / 2, 10);
|
||||
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
|
||||
} else if (overviewPlot) {
|
||||
zoomOverview(factor);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFit() {
|
||||
if (currentMode === "detail" && detailChart) {
|
||||
detailChart.timeScale().fitContent();
|
||||
} else if (overviewPlot) {
|
||||
fitOverview();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function fmtMoney(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR") + "원";
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return sign + v.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function renderSimPanel() {
|
||||
const p = DATA.sim_pnl;
|
||||
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
|
||||
document.getElementById("sim-grid").innerHTML = [
|
||||
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
|
||||
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
|
||||
["손익", fmtMoney(p.total_pnl_krw), retClass],
|
||||
["수익률", fmtPct(p.total_return_pct), retClass],
|
||||
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
|
||||
["보유 코인", p.final_coin_qty.toFixed(8), ""],
|
||||
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
|
||||
["매수/매도", `${p.buys_executed}/${p.sells_executed}건`, ""],
|
||||
].map(([label, value, cls]) =>
|
||||
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
|
||||
).join("");
|
||||
document.getElementById("sim-note").textContent =
|
||||
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
|
||||
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
|
||||
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
|
||||
const tbody = document.getElementById("trade-body");
|
||||
tbody.innerHTML = "";
|
||||
(p.trades || []).forEach(t => {
|
||||
const tr = document.createElement("tr");
|
||||
if (t.skipped) tr.className = "skipped";
|
||||
tr.innerHTML = `
|
||||
<td>${t.datetime}</td>
|
||||
<td>${t.side === "buy" ? "매수" : "매도"}</td>
|
||||
<td>${t.signal_type}</td>
|
||||
<td>${fmtPrice(t.price)}</td>
|
||||
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
|
||||
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
|
||||
<td>${fmtMoney(t.cash_after)}</td>
|
||||
<td>${t.coin_after.toFixed(8)}</td>
|
||||
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById("trade-count").textContent = String((p.trades || []).length);
|
||||
}
|
||||
|
||||
|
||||
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}${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;
|
||||
const tierKey = (m.chart_tier || "v3").toLowerCase();
|
||||
const legend = tierKey === "v1"
|
||||
? "B=스윙매수 S=스윙매도"
|
||||
: 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} (${markerRange}) | ${legend}${legendExtra}`;
|
||||
if (simMode) renderSimPanel();
|
||||
updateLegInfo();
|
||||
document.getElementById("status").textContent =
|
||||
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
|
||||
buildOverview(false);
|
||||
|
||||
document.getElementById("btn-home").onclick = goHome;
|
||||
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
|
||||
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
|
||||
document.getElementById("btn-all").onclick = goHome;
|
||||
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
|
||||
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
|
||||
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
|
||||
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
|
||||
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
|
||||
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
|
||||
document.getElementById("btn-fit").onclick = applyFit;
|
||||
document.getElementById("btn-markers").onclick = () => {
|
||||
showMarkers = !showMarkers;
|
||||
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
|
||||
if (overviewPlot) buildOverview(true);
|
||||
if (detailChart) buildDetailCandles(lastDetailStart);
|
||||
};
|
||||
document.getElementById("btn-toggle-detail").onclick = () => {
|
||||
detailVisible = !detailVisible;
|
||||
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
|
||||
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
|
||||
if (detailVisible && !detailChart) {
|
||||
const { start } = sliceLastDays(7);
|
||||
buildDetailCandles(start);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("overview").addEventListener("dblclick", () => {
|
||||
if (currentMode === "overview") fitOverview();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (overviewPlot) buildOverview(true);
|
||||
});
|
||||
}
|
||||
try { init(); } catch (err) {
|
||||
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
docs/02_ground_truth/ground_truth_chart_sim_v3_data.js
Normal file
1
docs/02_ground_truth/ground_truth_chart_sim_v3_data.js
Normal file
File diff suppressed because one or more lines are too long
221
scripts/02_ground_truth_sim.py
Normal file
221
scripts/02_ground_truth_sim.py
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""2단계: Ground Truth 타점 기준 1년 수익 시뮬 + sim 차트 생성."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
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.data.candle_loader import load_candles
|
||||
from deepcoin.data.intervals import interval_label
|
||||
from deepcoin.ground_truth.chart import render_ground_truth_sim_chart
|
||||
from deepcoin.ground_truth.ground_truth import GtParams, build_ground_truth, save_ground_truth
|
||||
from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl
|
||||
|
||||
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 _base_params(settings: Settings, args: argparse.Namespace) -> GtParams:
|
||||
"""CLI·환경 설정을 반영한 공통 GT 파라미터."""
|
||||
return GtParams(
|
||||
interval_min=args.interval or settings.gt_interval_min,
|
||||
lookback_days=args.days or settings.gt_lookback_days,
|
||||
zigzag_reversal_pct=args.zigzag or settings.gt_zigzag_reversal_pct,
|
||||
min_leg_pct=args.min_leg or settings.gt_min_leg_pct,
|
||||
pullback_min_pct=settings.gt_pullback_min_pct,
|
||||
pullback_local_order=settings.gt_pullback_local_order,
|
||||
breakout_buffer_pct=settings.gt_breakout_buffer_pct,
|
||||
breakout_consolidation_bars=settings.gt_breakout_consolidation_bars,
|
||||
breakout_min_rally_pct=settings.gt_breakout_min_rally_pct,
|
||||
div_local_order=settings.gt_div_local_order,
|
||||
div_min_bars_between=settings.gt_div_min_bars_between,
|
||||
div_min_rsi_diff=settings.gt_div_min_rsi_diff,
|
||||
div_min_future_move_pct=settings.gt_div_min_future_move_pct,
|
||||
)
|
||||
|
||||
|
||||
def _tier_targets(settings: Settings) -> list[tuple[str, Path, Path, Path]]:
|
||||
"""티어별 (tier, json, chart, sim_chart) 경로."""
|
||||
return [
|
||||
(
|
||||
"v1",
|
||||
settings.ground_truth_v1_file,
|
||||
settings.ground_truth_chart_v1_file,
|
||||
settings.ground_truth_chart_sim_v1_file,
|
||||
),
|
||||
(
|
||||
"v2",
|
||||
settings.ground_truth_v2_file,
|
||||
settings.ground_truth_chart_v2_file,
|
||||
settings.ground_truth_chart_sim_v2_file,
|
||||
),
|
||||
(
|
||||
"v3",
|
||||
settings.ground_truth_file,
|
||||
settings.ground_truth_chart_v3_file,
|
||||
settings.ground_truth_chart_sim_v3_file,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _load_or_build_gt(
|
||||
settings: Settings,
|
||||
params: GtParams,
|
||||
json_path: Path,
|
||||
rebuild: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""GT JSON을 로드하거나 새로 생성한다."""
|
||||
if json_path.exists() and not rebuild:
|
||||
with json_path.open(encoding="utf-8") as fp:
|
||||
return json.load(fp)
|
||||
|
||||
result = build_ground_truth(
|
||||
db_path=settings.db_path,
|
||||
symbol=settings.symbol,
|
||||
coin_name=settings.coin_name,
|
||||
params=params,
|
||||
initial_cash_krw=settings.gt_initial_cash_krw,
|
||||
fee_rate=settings.gt_trading_fee_rate,
|
||||
)
|
||||
save_ground_truth(result, json_path)
|
||||
return result
|
||||
|
||||
|
||||
def _print_sim_summary(
|
||||
tier: str,
|
||||
sim_pnl: dict[str, Any],
|
||||
sim_chart_path: Path,
|
||||
) -> None:
|
||||
"""티어별 시뮬 요약을 출력한다."""
|
||||
print(f"\n=== 2단계 시뮬 {tier.upper()} ({TIER_DESCRIPTIONS[tier]}) ===")
|
||||
print(
|
||||
f"기간: {sim_pnl['period_from']} ~ {sim_pnl['period_to']} "
|
||||
f"({sim_pnl['sim_lookback_days']}일)"
|
||||
)
|
||||
print(
|
||||
f"초기 {sim_pnl['initial_cash_krw']:,.0f}원 → "
|
||||
f"최종 {sim_pnl['final_equity_krw']:,.0f}원 "
|
||||
f"({sim_pnl['total_return_pct']:+.2f}%)"
|
||||
)
|
||||
print(
|
||||
f"현금 {sim_pnl['final_cash_krw']:,.0f}원 + "
|
||||
f"코인 {sim_pnl['final_coin_qty']:.8f} "
|
||||
f"(평가 {sim_pnl['final_coin_value_krw']:,.0f}원)"
|
||||
)
|
||||
print(
|
||||
f"체결 매수 {sim_pnl['buys_executed']} / 매도 {sim_pnl['sells_executed']} | "
|
||||
f"스킵 매수 {sim_pnl['buys_skipped']} / 매도 {sim_pnl['sells_skipped']}"
|
||||
)
|
||||
print(f"sim 차트: {sim_chart_path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI 진입점."""
|
||||
parser = argparse.ArgumentParser(description="Ground Truth 1년 수익 시뮬 (2단계)")
|
||||
parser.add_argument("--interval", type=int, default=None, help="GT 인터벌(분)")
|
||||
parser.add_argument("--days", type=int, default=None, help="GT 타점 기간(일). 기본 730")
|
||||
parser.add_argument(
|
||||
"--sim-days",
|
||||
type=int,
|
||||
default=None,
|
||||
help="시뮬 기간(일). 기본 GT_SIM_LOOKBACK_DAYS 또는 365",
|
||||
)
|
||||
parser.add_argument("--zigzag", type=float, default=None, help="ZigZag 되돌림 %%")
|
||||
parser.add_argument("--min-leg", type=float, default=None, help="최소 레그 수익률 %%")
|
||||
parser.add_argument(
|
||||
"--tier",
|
||||
choices=("v1", "v2", "v3", "all"),
|
||||
default="all",
|
||||
help="대상 GT 버전",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rebuild-gt",
|
||||
action="store_true",
|
||||
help="GT JSON을 다시 생성 (없으면 자동 생성)",
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
_configure_logging(args.verbose)
|
||||
settings = load_settings()
|
||||
base = _base_params(settings, args)
|
||||
sim_days = args.sim_days or settings.gt_sim_lookback_days
|
||||
tiers = _tier_targets(settings)
|
||||
if args.tier != "all":
|
||||
tiers = [t for t in tiers if t[0] == args.tier]
|
||||
|
||||
logging.info(
|
||||
"2단계 시뮬: %s %s, GT %s일, sim %s일, 초기=%s원",
|
||||
settings.symbol,
|
||||
interval_label(base.interval_min),
|
||||
base.lookback_days,
|
||||
sim_days,
|
||||
f"{settings.gt_initial_cash_krw:,.0f}",
|
||||
)
|
||||
|
||||
print("\n=== Ground Truth 2단계 수익 시뮬 ===")
|
||||
print(f"초기 자본: {settings.gt_initial_cash_krw:,.0f}원 | 시뮬 기간: 최근 {sim_days}일")
|
||||
|
||||
for tier, json_path, _chart_path, sim_chart_path in tiers:
|
||||
params = replace(base, chart_tier=tier)
|
||||
gt_result = _load_or_build_gt(settings, params, json_path, args.rebuild_gt)
|
||||
|
||||
df = load_candles(
|
||||
db_path=settings.db_path,
|
||||
symbol=settings.symbol,
|
||||
interval_min=params.interval_min,
|
||||
lookback_days=base.lookback_days,
|
||||
)
|
||||
last_close = float(df["close"].iloc[-1])
|
||||
|
||||
sim_pnl = simulate_gt_signals_pnl(
|
||||
signals=gt_result.get("signals") or [],
|
||||
initial_cash_krw=settings.gt_initial_cash_krw,
|
||||
fee_rate=settings.gt_trading_fee_rate,
|
||||
sim_lookback_days=sim_days,
|
||||
data_end=gt_result["meta"]["data_to"],
|
||||
last_mark_price=last_close,
|
||||
)
|
||||
gt_result["sim_pnl"] = sim_pnl
|
||||
save_ground_truth(gt_result, json_path)
|
||||
|
||||
render_ground_truth_sim_chart(
|
||||
db_path=settings.db_path,
|
||||
symbol=settings.symbol,
|
||||
gt_result=gt_result,
|
||||
sim_pnl=sim_pnl,
|
||||
output_path=sim_chart_path,
|
||||
chart_lookback_days=settings.download_days,
|
||||
)
|
||||
_print_sim_summary(tier, sim_pnl, sim_chart_path)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -68,6 +68,10 @@ class Settings:
|
||||
ground_truth_chart_v1_file: Path
|
||||
ground_truth_chart_v2_file: Path
|
||||
ground_truth_chart_v3_file: Path
|
||||
ground_truth_chart_sim_v1_file: Path
|
||||
ground_truth_chart_sim_v2_file: Path
|
||||
ground_truth_chart_sim_v3_file: Path
|
||||
gt_sim_lookback_days: int
|
||||
gt_initial_cash_krw: float
|
||||
gt_trading_fee_rate: float
|
||||
# Techniques (2단계)
|
||||
@@ -121,6 +125,24 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
||||
gt_chart_v3 = _resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/02_ground_truth/ground_truth_chart_v3.html")
|
||||
)
|
||||
gt_chart_sim_v1 = _resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_CHART_SIM_V1_FILE",
|
||||
"docs/02_ground_truth/ground_truth_chart_sim_v1.html",
|
||||
)
|
||||
)
|
||||
gt_chart_sim_v2 = _resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_CHART_SIM_V2_FILE",
|
||||
"docs/02_ground_truth/ground_truth_chart_sim_v2.html",
|
||||
)
|
||||
)
|
||||
gt_chart_sim_v3 = _resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_CHART_SIM_V3_FILE",
|
||||
"docs/02_ground_truth/ground_truth_chart_sim_v3.html",
|
||||
)
|
||||
)
|
||||
|
||||
tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/techniques")
|
||||
tech_dir = Path(tech_dir_raw)
|
||||
@@ -165,6 +187,10 @@ def load_settings(env_path: Path | None = None) -> Settings:
|
||||
ground_truth_chart_v1_file=gt_chart_v1,
|
||||
ground_truth_chart_v2_file=gt_chart_v2,
|
||||
ground_truth_chart_v3_file=gt_chart_v3,
|
||||
ground_truth_chart_sim_v1_file=gt_chart_sim_v1,
|
||||
ground_truth_chart_sim_v2_file=gt_chart_sim_v2,
|
||||
ground_truth_chart_sim_v3_file=gt_chart_sim_v3,
|
||||
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_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")),
|
||||
techniques_dir=tech_dir,
|
||||
|
||||
@@ -22,6 +22,149 @@ def _data_js_path(html_path: Path) -> Path:
|
||||
return html_path.with_name(f"{html_path.stem}_data.js")
|
||||
|
||||
|
||||
def _to_unix_seconds(dt_series: pd.Series) -> list[int]:
|
||||
"""datetime Series를 uPlot/LWC용 unix 초 리스트로 변환한다.
|
||||
|
||||
pandas datetime64[ns/us/ms] 단위 차이에 관계없이 올바른 초 단위를 반환한다.
|
||||
|
||||
Args:
|
||||
dt_series: datetime 컬럼.
|
||||
|
||||
Returns:
|
||||
unix epoch 초 리스트.
|
||||
"""
|
||||
parsed = pd.to_datetime(dt_series)
|
||||
seconds = (parsed - pd.Timestamp("1970-01-01")) / pd.Timedelta(seconds=1)
|
||||
return seconds.astype(int).tolist()
|
||||
|
||||
|
||||
def _markers_from_executed_trades(
|
||||
sim_pnl: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""시뮬에서 실제 체결된 매수·매도만 마커로 변환한다."""
|
||||
buy_markers: list[dict[str, Any]] = []
|
||||
sell_markers: list[dict[str, Any]] = []
|
||||
|
||||
for trade in sim_pnl.get("trades") or []:
|
||||
if trade.get("skipped"):
|
||||
continue
|
||||
side = trade["side"]
|
||||
signal_type = trade.get("signal_type") or (
|
||||
"swing_low" if side == "buy" else "swing_high"
|
||||
)
|
||||
marker = {
|
||||
"time": int(pd.Timestamp(trade["datetime"]).timestamp()),
|
||||
"price": trade["price"],
|
||||
"marker_id": trade.get("marker_id") or trade.get("trade_id"),
|
||||
"signal_type": signal_type,
|
||||
}
|
||||
if side == "buy":
|
||||
buy_markers.append(marker)
|
||||
else:
|
||||
sell_markers.append(marker)
|
||||
|
||||
return buy_markers, sell_markers
|
||||
|
||||
|
||||
def _markers_from_gt_signals(
|
||||
gt_result: dict[str, Any],
|
||||
sim_period_from_ts: int | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""GT 신호에서 마커를 구성한다 (1단계 차트용)."""
|
||||
buy_markers: list[dict[str, Any]] = []
|
||||
sell_markers: list[dict[str, Any]] = []
|
||||
|
||||
for sig in gt_result.get("signals") or []:
|
||||
ts = int(pd.Timestamp(sig["datetime"]).timestamp())
|
||||
if sim_period_from_ts is not None and ts < sim_period_from_ts:
|
||||
continue
|
||||
marker = {
|
||||
"time": ts,
|
||||
"price": sig["price"],
|
||||
"marker_id": sig.get("marker_id", sig.get("leg_id")),
|
||||
"signal_type": sig.get(
|
||||
"signal_type",
|
||||
"swing_low" if sig["side"] == "buy" else "swing_high",
|
||||
),
|
||||
}
|
||||
if sig["side"] == "buy":
|
||||
buy_markers.append(marker)
|
||||
else:
|
||||
sell_markers.append(marker)
|
||||
|
||||
return buy_markers, sell_markers
|
||||
|
||||
|
||||
def _sim_start_marker(
|
||||
df: pd.DataFrame,
|
||||
sim_pnl: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""1년 시뮬 매매 시작 시점 마커를 구성한다."""
|
||||
period_from = sim_pnl.get("period_from")
|
||||
if not period_from:
|
||||
return None
|
||||
|
||||
start_ts = pd.Timestamp(period_from)
|
||||
parsed = pd.to_datetime(df["datetime"])
|
||||
idx = int(parsed.searchsorted(start_ts, side="left"))
|
||||
if idx >= len(df):
|
||||
idx = len(df) - 1
|
||||
row = df.iloc[idx]
|
||||
dt_str = str(row["datetime"])
|
||||
return {
|
||||
"time": int(pd.Timestamp(dt_str).timestamp()),
|
||||
"price": float(row["close"]),
|
||||
"datetime": dt_str,
|
||||
"label": "거래시작",
|
||||
}
|
||||
|
||||
|
||||
def _build_chart_payload(
|
||||
df: pd.DataFrame,
|
||||
gt_result: dict[str, Any],
|
||||
chart_days: int,
|
||||
gt_lookback_days: int,
|
||||
sim_pnl: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""차트 HTML용 JSON payload를 구성한다."""
|
||||
times = _to_unix_seconds(df["datetime"])
|
||||
|
||||
if sim_pnl is not None:
|
||||
buy_markers, sell_markers = _markers_from_executed_trades(sim_pnl)
|
||||
else:
|
||||
buy_markers, sell_markers = _markers_from_gt_signals(gt_result)
|
||||
|
||||
chart_meta = {
|
||||
**gt_result["meta"],
|
||||
"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()),
|
||||
"gt_marker_count": len(buy_markers),
|
||||
}
|
||||
if sim_pnl is not None:
|
||||
chart_meta["sim_period_from"] = sim_pnl.get("period_from")
|
||||
chart_meta["sim_period_to"] = sim_pnl.get("period_to")
|
||||
chart_meta["sim_lookback_days"] = sim_pnl.get("sim_lookback_days")
|
||||
payload: dict[str, Any] = {
|
||||
"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(),
|
||||
"buy_markers": buy_markers,
|
||||
"sell_markers": sell_markers,
|
||||
"meta": chart_meta,
|
||||
"bar_count": len(df),
|
||||
}
|
||||
if sim_pnl is not None:
|
||||
payload["sim_pnl"] = sim_pnl
|
||||
start_marker = _sim_start_marker(df, sim_pnl)
|
||||
if start_marker is not None:
|
||||
payload["sim_start_marker"] = start_marker
|
||||
return payload
|
||||
|
||||
|
||||
def render_ground_truth_chart(
|
||||
db_path: Path,
|
||||
symbol: str,
|
||||
@@ -54,42 +197,7 @@ def render_ground_truth_chart(
|
||||
if max_candles > 0 and len(df) > max_candles:
|
||||
df = df.iloc[-max_candles:].reset_index(drop=True)
|
||||
|
||||
times = (pd.to_datetime(df["datetime"]).astype("int64") // 10**9).astype(int).tolist()
|
||||
|
||||
buy_markers = []
|
||||
sell_markers = []
|
||||
for sig in gt_result.get("signals") or []:
|
||||
ts = int(pd.Timestamp(sig["datetime"]).timestamp())
|
||||
marker = {
|
||||
"time": ts,
|
||||
"price": sig["price"],
|
||||
"marker_id": sig.get("marker_id", sig.get("leg_id")),
|
||||
"signal_type": sig.get("signal_type", "swing_low" if sig["side"] == "buy" else "swing_high"),
|
||||
}
|
||||
if sig["side"] == "buy":
|
||||
buy_markers.append(marker)
|
||||
else:
|
||||
sell_markers.append(marker)
|
||||
|
||||
chart_meta = {
|
||||
**gt_result["meta"],
|
||||
"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()),
|
||||
"gt_marker_count": len(buy_markers),
|
||||
}
|
||||
payload = {
|
||||
"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(),
|
||||
"buy_markers": buy_markers,
|
||||
"sell_markers": sell_markers,
|
||||
"meta": chart_meta,
|
||||
"bar_count": len(df),
|
||||
}
|
||||
payload = _build_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)
|
||||
@@ -132,6 +240,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
#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; }
|
||||
__EXTRA_STYLES__
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -139,6 +248,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<h1 id="title">Ground Truth Chart</h1>
|
||||
<div class="meta" id="meta"></div>
|
||||
</header>
|
||||
__EXTRA_BODY__
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</button>
|
||||
@@ -223,6 +333,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
}
|
||||
|
||||
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
|
||||
function markerSuffix(signalType) {
|
||||
if (signalType === "pullback") return "*";
|
||||
@@ -243,6 +354,37 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
ctx.fillText(label, lx, ly);
|
||||
}
|
||||
|
||||
function drawSimStartMarker(u, marker) {
|
||||
if (!marker) return;
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(marker.time, "x", true);
|
||||
const y = u.valToPos(marker.price, "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const color = SIM_START_COLOR;
|
||||
const s = 10;
|
||||
const gap = 14;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - gap);
|
||||
ctx.lineTo(x - s, y - gap - 18);
|
||||
ctx.lineTo(x + s, y - gap - 18);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
const label = marker.label || "거래시작";
|
||||
ctx.font = MARKER_FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
const labelY = y - gap - 18 - 6;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.strokeText(label, x, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x, labelY);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function drawMarkers(u, buys, sells) {
|
||||
if (!showMarkers) return;
|
||||
const ctx = u.ctx;
|
||||
@@ -326,7 +468,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
],
|
||||
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"));
|
||||
@@ -381,6 +526,14 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
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({
|
||||
@@ -503,17 +656,21 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
}
|
||||
}
|
||||
|
||||
__EXTRA_SCRIPT__
|
||||
|
||||
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;
|
||||
@@ -523,8 +680,13 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
: 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()}봉 | 드래그=줌, 더블클릭=리셋`;
|
||||
@@ -575,5 +737,169 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
|
||||
|
||||
def _html_template(data_js_name: str) -> str:
|
||||
"""차트 HTML 템플릿을 생성한다."""
|
||||
return _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name)
|
||||
"""1단계 GT 차트 HTML 템플릿을 생성한다."""
|
||||
return _build_html_template(data_js_name, sim_mode=False)
|
||||
|
||||
|
||||
_SIM_EXTRA_STYLES = """
|
||||
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
|
||||
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
|
||||
.sim-card .value { font-size: 18px; font-weight: bold; }
|
||||
.sim-card .value.positive { color: #2e7d32; }
|
||||
.sim-card .value.negative { color: #c62828; }
|
||||
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
|
||||
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
|
||||
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
|
||||
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
|
||||
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
|
||||
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
|
||||
.trade-table tr.skipped td { color: #999; }
|
||||
.trade-scroll { max-height: 240px; overflow: auto; }
|
||||
"""
|
||||
|
||||
_SIM_EXTRA_BODY = """
|
||||
<section class="sim-panel" id="sim-panel">
|
||||
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
|
||||
<div class="sim-grid" id="sim-grid"></div>
|
||||
<div class="sim-note" id="sim-note"></div>
|
||||
</section>
|
||||
<details id="trade-table-wrap">
|
||||
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
|
||||
<div class="trade-scroll">
|
||||
<table class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
|
||||
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="trade-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
"""
|
||||
|
||||
_SIM_EXTRA_SCRIPT = """
|
||||
function fmtMoney(v) {
|
||||
return Math.round(v).toLocaleString("ko-KR") + "원";
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
const sign = v > 0 ? "+" : "";
|
||||
return sign + v.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function renderSimPanel() {
|
||||
const p = DATA.sim_pnl;
|
||||
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
|
||||
document.getElementById("sim-grid").innerHTML = [
|
||||
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
|
||||
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
|
||||
["손익", fmtMoney(p.total_pnl_krw), retClass],
|
||||
["수익률", fmtPct(p.total_return_pct), retClass],
|
||||
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
|
||||
["보유 코인", p.final_coin_qty.toFixed(8), ""],
|
||||
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
|
||||
["매수/매도", `${p.buys_executed}/${p.sells_executed}건`, ""],
|
||||
].map(([label, value, cls]) =>
|
||||
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
|
||||
).join("");
|
||||
document.getElementById("sim-note").textContent =
|
||||
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
|
||||
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
|
||||
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
|
||||
const tbody = document.getElementById("trade-body");
|
||||
tbody.innerHTML = "";
|
||||
(p.trades || []).forEach(t => {
|
||||
const tr = document.createElement("tr");
|
||||
if (t.skipped) tr.className = "skipped";
|
||||
tr.innerHTML = `
|
||||
<td>${t.datetime}</td>
|
||||
<td>${t.side === "buy" ? "매수" : "매도"}</td>
|
||||
<td>${t.signal_type}</td>
|
||||
<td>${fmtPrice(t.price)}</td>
|
||||
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
|
||||
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
|
||||
<td>${fmtMoney(t.cash_after)}</td>
|
||||
<td>${t.coin_after.toFixed(8)}</td>
|
||||
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById("trade-count").textContent = String((p.trades || []).length);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _build_html_template(data_js_name: str, sim_mode: bool) -> str:
|
||||
"""GT/시뮬 차트 HTML 템플릿을 생성한다."""
|
||||
html = _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name)
|
||||
if sim_mode:
|
||||
html = (
|
||||
html.replace("__EXTRA_STYLES__", _SIM_EXTRA_STYLES)
|
||||
.replace("__EXTRA_BODY__", _SIM_EXTRA_BODY)
|
||||
.replace("__EXTRA_SCRIPT__", _SIM_EXTRA_SCRIPT)
|
||||
)
|
||||
else:
|
||||
html = (
|
||||
html.replace("__EXTRA_STYLES__", "")
|
||||
.replace("__EXTRA_BODY__", "")
|
||||
.replace("__EXTRA_SCRIPT__", "")
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
def _sim_html_template(data_js_name: str) -> str:
|
||||
"""2단계 sim 차트 HTML 템플릿을 생성한다."""
|
||||
return _build_html_template(data_js_name, sim_mode=True)
|
||||
|
||||
|
||||
def render_ground_truth_sim_chart(
|
||||
db_path: Path,
|
||||
symbol: str,
|
||||
gt_result: dict[str, Any],
|
||||
sim_pnl: dict[str, Any],
|
||||
output_path: Path,
|
||||
chart_lookback_days: int | None = None,
|
||||
max_candles: int = DEFAULT_MAX_CANDLES,
|
||||
) -> Path:
|
||||
"""GT 타점 + 2단계 시뮬 수익 결과가 표시된 HTML 차트를 생성한다.
|
||||
|
||||
Args:
|
||||
db_path: SQLite 경로.
|
||||
symbol: 코인 심볼.
|
||||
gt_result: build_ground_truth 결과.
|
||||
sim_pnl: simulate_gt_signals_pnl 결과.
|
||||
output_path: HTML 출력 경로.
|
||||
chart_lookback_days: 차트 표시 일수.
|
||||
max_candles: 0이면 전체.
|
||||
|
||||
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_chart_payload(
|
||||
df, gt_result, chart_days, gt_lookback_days, sim_pnl=sim_pnl
|
||||
)
|
||||
|
||||
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
|
||||
output_path.write_text(_sim_html_template(data_js_name), encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -22,6 +23,28 @@ class LegPnl:
|
||||
btc_qty: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalTrade:
|
||||
"""신호 1건 실행 기록."""
|
||||
|
||||
trade_id: int
|
||||
side: str
|
||||
signal_type: str
|
||||
marker_id: int | None
|
||||
datetime: str
|
||||
price: float
|
||||
cash_before: float
|
||||
cash_after: float
|
||||
coin_before: float
|
||||
coin_after: float
|
||||
order_krw: float
|
||||
order_coin: float
|
||||
fee_krw: float
|
||||
cluster_size: int
|
||||
skipped: bool
|
||||
skip_reason: str | None = None
|
||||
|
||||
|
||||
def simulate_gt_pnl(
|
||||
legs: list[dict[str, Any]],
|
||||
initial_cash_krw: float = 400_000.0,
|
||||
@@ -99,3 +122,272 @@ def simulate_gt_pnl(
|
||||
"period_to": period_to,
|
||||
"leg_pnls": [asdict(x) for x in leg_pnls],
|
||||
}
|
||||
|
||||
|
||||
def _parse_signal_dt(value: str) -> datetime:
|
||||
"""GT signal datetime 문자열을 파싱한다."""
|
||||
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _cluster_signals(signals: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
|
||||
"""연속 동일 side 신호를 클러스터로 묶는다."""
|
||||
ordered = sorted(signals, key=lambda s: (s["bar_index"], s.get("marker_id", 0)))
|
||||
clusters: list[tuple[str, list[dict[str, Any]]]] = []
|
||||
current_side: str | None = None
|
||||
current: list[dict[str, Any]] = []
|
||||
|
||||
for sig in ordered:
|
||||
side = sig["side"]
|
||||
if current_side is None:
|
||||
current_side = side
|
||||
current = [sig]
|
||||
continue
|
||||
if side == current_side:
|
||||
current.append(sig)
|
||||
continue
|
||||
clusters.append((current_side, current))
|
||||
current_side = side
|
||||
current = [sig]
|
||||
|
||||
if current_side and current:
|
||||
clusters.append((current_side, current))
|
||||
return clusters
|
||||
|
||||
|
||||
def simulate_gt_signals_pnl(
|
||||
signals: list[dict[str, Any]],
|
||||
initial_cash_krw: float = 400_000.0,
|
||||
fee_rate: float = 0.0005,
|
||||
min_order_krw: float = 5_000.0,
|
||||
sim_lookback_days: int = 365,
|
||||
data_end: str | None = None,
|
||||
last_mark_price: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""GT 매수·매도 신호를 시간순 실행한 2단계 포트폴리오 시뮬레이션.
|
||||
|
||||
- 시뮬 기간: data_end 기준 최근 sim_lookback_days
|
||||
- 연속 매수: 가용 원화를 매수 신호 수로 균등 분할
|
||||
- 연속 매도: 보유 코인을 매도 신호 수로 균등 분할
|
||||
- 원화 부족 시 매수 스킵, 코인 없으면 매도 스킵
|
||||
|
||||
Args:
|
||||
signals: GT signals 리스트.
|
||||
initial_cash_krw: 시뮬 시작 원화.
|
||||
fee_rate: 편도 수수료율.
|
||||
min_order_krw: 최소 주문 금액.
|
||||
sim_lookback_days: 시뮬 기간(일).
|
||||
data_end: 데이터 종료 시각 문자열. None이면 마지막 신호 시각.
|
||||
last_mark_price: 미청산 코인 평가 가격. None이면 마지막 체결가.
|
||||
|
||||
Returns:
|
||||
요약 + 체결/스킵 내역 dict.
|
||||
"""
|
||||
if not signals:
|
||||
return _empty_signal_pnl(initial_cash_krw, fee_rate, sim_lookback_days)
|
||||
|
||||
end_dt = _parse_signal_dt(data_end) if data_end else max(
|
||||
_parse_signal_dt(s["datetime"]) for s in signals
|
||||
)
|
||||
start_dt = end_dt - timedelta(days=sim_lookback_days)
|
||||
start_str = start_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
period_signals = [
|
||||
s for s in signals if _parse_signal_dt(s["datetime"]) >= start_dt
|
||||
]
|
||||
if not period_signals:
|
||||
return _empty_signal_pnl(
|
||||
initial_cash_krw,
|
||||
fee_rate,
|
||||
sim_lookback_days,
|
||||
period_from=start_str,
|
||||
period_to=end_dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
cash = float(initial_cash_krw)
|
||||
coin_qty = 0.0
|
||||
trades: list[SignalTrade] = []
|
||||
trade_id = 0
|
||||
buys_executed = 0
|
||||
sells_executed = 0
|
||||
buys_skipped = 0
|
||||
sells_skipped = 0
|
||||
|
||||
mark_price = float(last_mark_price or period_signals[-1]["price"])
|
||||
|
||||
for side, cluster in _cluster_signals(period_signals):
|
||||
cluster_size = len(cluster)
|
||||
if side == "buy":
|
||||
budget = cash
|
||||
per_buy = budget / cluster_size if cluster_size else 0.0
|
||||
for sig in cluster:
|
||||
trade_id += 1
|
||||
price = float(sig["price"])
|
||||
cash_before = cash
|
||||
coin_before = coin_qty
|
||||
order_krw = min(per_buy, cash)
|
||||
|
||||
if order_krw < min_order_krw:
|
||||
buys_skipped += 1
|
||||
trades.append(
|
||||
SignalTrade(
|
||||
trade_id=trade_id,
|
||||
side="buy",
|
||||
signal_type=str(sig.get("signal_type", "buy")),
|
||||
marker_id=sig.get("marker_id"),
|
||||
datetime=sig["datetime"],
|
||||
price=price,
|
||||
cash_before=round(cash_before, 0),
|
||||
cash_after=round(cash, 0),
|
||||
coin_before=round(coin_before, 8),
|
||||
coin_after=round(coin_qty, 8),
|
||||
order_krw=0.0,
|
||||
order_coin=0.0,
|
||||
fee_krw=0.0,
|
||||
cluster_size=cluster_size,
|
||||
skipped=True,
|
||||
skip_reason="원화 부족",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
fee = order_krw * fee_rate
|
||||
bought = (order_krw - fee) / price
|
||||
cash -= order_krw
|
||||
coin_qty += bought
|
||||
buys_executed += 1
|
||||
trades.append(
|
||||
SignalTrade(
|
||||
trade_id=trade_id,
|
||||
side="buy",
|
||||
signal_type=str(sig.get("signal_type", "buy")),
|
||||
marker_id=sig.get("marker_id"),
|
||||
datetime=sig["datetime"],
|
||||
price=price,
|
||||
cash_before=round(cash_before, 0),
|
||||
cash_after=round(cash, 0),
|
||||
coin_before=round(coin_before, 8),
|
||||
coin_after=round(coin_qty, 8),
|
||||
order_krw=round(order_krw, 0),
|
||||
order_coin=round(bought, 8),
|
||||
fee_krw=round(fee, 0),
|
||||
cluster_size=cluster_size,
|
||||
skipped=False,
|
||||
)
|
||||
)
|
||||
else:
|
||||
budget_coin = coin_qty
|
||||
per_sell = budget_coin / cluster_size if cluster_size else 0.0
|
||||
for sig in cluster:
|
||||
trade_id += 1
|
||||
price = float(sig["price"])
|
||||
cash_before = cash
|
||||
coin_before = coin_qty
|
||||
order_coin = min(per_sell, coin_qty)
|
||||
order_krw = order_coin * price
|
||||
|
||||
if order_coin <= 0 or order_krw < min_order_krw:
|
||||
sells_skipped += 1
|
||||
trades.append(
|
||||
SignalTrade(
|
||||
trade_id=trade_id,
|
||||
side="sell",
|
||||
signal_type=str(sig.get("signal_type", "sell")),
|
||||
marker_id=sig.get("marker_id"),
|
||||
datetime=sig["datetime"],
|
||||
price=price,
|
||||
cash_before=round(cash_before, 0),
|
||||
cash_after=round(cash, 0),
|
||||
coin_before=round(coin_before, 8),
|
||||
coin_after=round(coin_qty, 8),
|
||||
order_krw=0.0,
|
||||
order_coin=0.0,
|
||||
fee_krw=0.0,
|
||||
cluster_size=cluster_size,
|
||||
skipped=True,
|
||||
skip_reason="코인 부족",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
gross = order_coin * price
|
||||
fee = gross * fee_rate
|
||||
cash += gross - fee
|
||||
coin_qty -= order_coin
|
||||
sells_executed += 1
|
||||
trades.append(
|
||||
SignalTrade(
|
||||
trade_id=trade_id,
|
||||
side="sell",
|
||||
signal_type=str(sig.get("signal_type", "sell")),
|
||||
marker_id=sig.get("marker_id"),
|
||||
datetime=sig["datetime"],
|
||||
price=price,
|
||||
cash_before=round(cash_before, 0),
|
||||
cash_after=round(cash, 0),
|
||||
coin_before=round(coin_before, 8),
|
||||
coin_after=round(coin_qty, 8),
|
||||
order_krw=round(gross, 0),
|
||||
order_coin=round(order_coin, 8),
|
||||
fee_krw=round(fee, 0),
|
||||
cluster_size=cluster_size,
|
||||
skipped=False,
|
||||
)
|
||||
)
|
||||
|
||||
coin_value = coin_qty * mark_price
|
||||
final_equity = cash + coin_value
|
||||
total_pnl = final_equity - initial_cash_krw
|
||||
total_return_pct = total_pnl / initial_cash_krw * 100.0
|
||||
|
||||
return {
|
||||
"mode": "signal_split",
|
||||
"initial_cash_krw": initial_cash_krw,
|
||||
"final_cash_krw": round(cash, 0),
|
||||
"final_coin_qty": round(coin_qty, 8),
|
||||
"final_mark_price": round(mark_price, 2),
|
||||
"final_coin_value_krw": round(coin_value, 0),
|
||||
"final_equity_krw": round(final_equity, 0),
|
||||
"total_pnl_krw": round(total_pnl, 0),
|
||||
"total_return_pct": round(total_return_pct, 2),
|
||||
"fee_rate": fee_rate,
|
||||
"sim_lookback_days": sim_lookback_days,
|
||||
"period_from": start_str,
|
||||
"period_to": end_dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"signals_in_period": len(period_signals),
|
||||
"buys_executed": buys_executed,
|
||||
"sells_executed": sells_executed,
|
||||
"buys_skipped": buys_skipped,
|
||||
"sells_skipped": sells_skipped,
|
||||
"trades": [asdict(t) for t in trades],
|
||||
}
|
||||
|
||||
|
||||
def _empty_signal_pnl(
|
||||
initial_cash_krw: float,
|
||||
fee_rate: float,
|
||||
sim_lookback_days: int,
|
||||
period_from: str | None = None,
|
||||
period_to: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""신호가 없을 때의 빈 시뮬 결과."""
|
||||
return {
|
||||
"mode": "signal_split",
|
||||
"initial_cash_krw": initial_cash_krw,
|
||||
"final_cash_krw": initial_cash_krw,
|
||||
"final_coin_qty": 0.0,
|
||||
"final_mark_price": 0.0,
|
||||
"final_coin_value_krw": 0.0,
|
||||
"final_equity_krw": initial_cash_krw,
|
||||
"total_pnl_krw": 0.0,
|
||||
"total_return_pct": 0.0,
|
||||
"fee_rate": fee_rate,
|
||||
"sim_lookback_days": sim_lookback_days,
|
||||
"period_from": period_from,
|
||||
"period_to": period_to,
|
||||
"signals_in_period": 0,
|
||||
"buys_executed": 0,
|
||||
"sells_executed": 0,
|
||||
"buys_skipped": 0,
|
||||
"sells_skipped": 0,
|
||||
"trades": [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user