feat(spot): 2단계 인과 기법 분석 파이프라인 마무리

common/spot/futures 경로 정비, 캔들 데이터 모듈 복원, MTF 규칙 자동 저장 및 2단계 설계·최종 정리 문서를 반영해 3단계 착수 기반을 확정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xavis
2026-06-12 16:09:32 +09:00
parent 741c949470
commit 2d515dd669
18 changed files with 2073 additions and 335 deletions

View File

@@ -116,7 +116,7 @@ def load_settings(env_path: Path | None = None) -> Settings:
intervals_raw = os.getenv("DOWNLOAD_INTERVALS", default_intervals)
intervals = sorted(set(_parse_int_list(intervals_raw)))
db_raw = os.getenv("DB_PATH", "coins.db")
db_raw = os.getenv("DB_PATH", "data/common/coins.db")
db_path = Path(db_raw)
if not db_path.is_absolute():
db_path = _PROJECT_ROOT / db_path
@@ -159,13 +159,13 @@ def load_settings(env_path: Path | None = None) -> Settings:
os.getenv("GROUND_TRUTH_V2_FILE", "data/spot/ground_truth/ground_truth_trades_v2.json")
),
ground_truth_chart_v1_file=_resolve_project_path(
os.getenv("GROUND_TRUTH_CHART_V1_FILE", "docs/0_ground_truth/spot/ground_truth_chart_v1.html")
os.getenv("GROUND_TRUTH_CHART_V1_FILE", "docs/spot/0_ground_truth/ground_truth_chart_v1.html")
),
ground_truth_chart_v2_file=_resolve_project_path(
os.getenv("GROUND_TRUTH_CHART_V2_FILE", "docs/0_ground_truth/spot/ground_truth_chart_v2.html")
os.getenv("GROUND_TRUTH_CHART_V2_FILE", "docs/spot/0_ground_truth/ground_truth_chart_v2.html")
),
ground_truth_chart_v3_file=_resolve_project_path(
os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/0_ground_truth/spot/ground_truth_chart_v3.html")
os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/spot/0_ground_truth/ground_truth_chart_v3.html")
),
ground_truth_futures_file=_resolve_project_path(
os.getenv(
@@ -188,19 +188,19 @@ def load_settings(env_path: Path | None = None) -> Settings:
ground_truth_futures_chart_v1_file=_resolve_project_path(
os.getenv(
"GROUND_TRUTH_FUTURES_CHART_V1_FILE",
"docs/0_ground_truth/futures/ground_truth_chart_v1.html",
"docs/futures/0_ground_truth/ground_truth_chart_v1.html",
)
),
ground_truth_futures_chart_v2_file=_resolve_project_path(
os.getenv(
"GROUND_TRUTH_FUTURES_CHART_V2_FILE",
"docs/0_ground_truth/futures/ground_truth_chart_v2.html",
"docs/futures/0_ground_truth/ground_truth_chart_v2.html",
)
),
ground_truth_futures_chart_v3_file=_resolve_project_path(
os.getenv(
"GROUND_TRUTH_FUTURES_CHART_V3_FILE",
"docs/0_ground_truth/futures/ground_truth_chart_v3.html",
"docs/futures/0_ground_truth/ground_truth_chart_v3.html",
)
),
ground_truth_chart_sim_v1_file=_resolve_project_path(

View File

@@ -0,0 +1 @@
"""캔들 수집·저장·조회."""

View File

@@ -0,0 +1,44 @@
"""SQLite 캔들 조회."""
from __future__ import annotations
from datetime import timedelta
from pathlib import Path
import pandas as pd
from deepcoin.data.candle_store import CandleStore
def load_candles(
db_path: Path | str,
symbol: str,
interval_min: int,
lookback_days: int | None = None,
) -> pd.DataFrame:
"""DB에서 캔들 DataFrame을 로드한다.
Args:
db_path: SQLite 경로.
symbol: 코인 심볼 (예: BTC).
interval_min: 분 단위 인터벌 코드.
lookback_days: 최근 N일만 사용. None이면 전체.
Returns:
``datetime``, ``open``, ``high``, ``low``, ``close``, ``volume`` 컬럼.
시간 오름차순 정렬.
"""
store = CandleStore(db_path)
try:
df = store.read_dataframe(symbol, interval_min)
finally:
store.close()
if df.empty:
return df
if lookback_days is not None and lookback_days > 0:
cutoff = df["datetime"].max() - timedelta(days=lookback_days)
df = df[df["datetime"] >= cutoff].reset_index(drop=True)
return df

View File

@@ -0,0 +1,191 @@
"""SQLite 캔들 저장소."""
from __future__ import annotations
import sqlite3
from datetime import datetime
from pathlib import Path
import pandas as pd
from deepcoin.api.bithumb import parse_kst_datetime
class CandleStore:
"""``{SYMBOL}_{interval}`` 테이블에 OHLCV를 저장·조회한다."""
_CREATE_SQL = """
CREATE TABLE IF NOT EXISTS {table} (
CODE text,
NAME text,
ymdhms datetime,
ymd text,
hms text,
Close REAL,
Open REAL,
High REAL,
Low REAL,
Volume REAL
)
"""
_INDEX_SQL = "CREATE INDEX IF NOT EXISTS {table}_idx ON {table}(CODE, ymdhms)"
_UNIQUE_SQL = "CREATE UNIQUE INDEX IF NOT EXISTS {table}_uk ON {table}(CODE, ymdhms)"
def __init__(self, db_path: Path | str) -> None:
"""저장소를 연다.
Args:
db_path: SQLite 파일 경로.
"""
self.db_path = Path(db_path)
self._conn = sqlite3.connect(self.db_path)
def close(self) -> None:
"""DB 연결을 닫는다."""
self._conn.close()
@staticmethod
def table_name(symbol: str, interval_min: int) -> str:
"""테이블명을 반환한다."""
return f"{symbol.upper()}_{interval_min}"
def ensure_table(self, symbol: str, interval_min: int) -> str:
"""테이블·인덱스가 없으면 생성한다.
Returns:
테이블명.
"""
table = self.table_name(symbol, interval_min)
self._conn.execute(self._CREATE_SQL.format(table=table))
self._conn.execute(self._INDEX_SQL.format(table=table))
self._conn.execute(self._UNIQUE_SQL.format(table=table))
self._conn.commit()
return table
def get_range(
self,
symbol: str,
interval_min: int,
) -> tuple[int, datetime | None, datetime | None]:
"""저장된 행 수와 최소·최대 시각을 반환한다.
Args:
symbol: 코인 심볼.
interval_min: 분 단위 인터벌.
Returns:
``(row_count, min_dt, max_dt)``. 데이터 없으면 ``(0, None, None)``.
"""
table = self.table_name(symbol, interval_min)
try:
row = self._conn.execute(
f"SELECT COUNT(*), MIN(ymdhms), MAX(ymdhms) FROM {table} WHERE CODE = ?",
(symbol.upper(),),
).fetchone()
except sqlite3.OperationalError:
return 0, None, None
if row is None or row[0] == 0 or row[1] is None:
return 0, None, None
return int(row[0]), parse_kst_datetime(str(row[1])), parse_kst_datetime(str(row[2]))
def read_dataframe(self, symbol: str, interval_min: int) -> pd.DataFrame:
"""캔들을 pandas DataFrame으로 읽는다.
Args:
symbol: 코인 심볼.
interval_min: 분 단위 인터벌.
Returns:
소문자 OHLCV 컬럼 DataFrame. 테이블 없으면 빈 DataFrame.
"""
table = self.table_name(symbol, interval_min)
try:
raw = pd.read_sql_query(
f"""
SELECT ymdhms, Open, High, Low, Close, Volume
FROM {table}
WHERE CODE = ?
ORDER BY ymdhms ASC
""",
self._conn,
params=(symbol.upper(),),
)
except Exception:
return pd.DataFrame(
columns=["datetime", "open", "high", "low", "close", "volume"]
)
if raw.empty:
return pd.DataFrame(
columns=["datetime", "open", "high", "low", "close", "volume"]
)
raw["datetime"] = pd.to_datetime(raw["ymdhms"])
raw = raw.rename(
columns={
"Open": "open",
"High": "high",
"Low": "low",
"Close": "close",
"Volume": "volume",
}
)
return raw[["datetime", "open", "high", "low", "close", "volume"]].reset_index(
drop=True
)
def upsert_rows(
self,
symbol: str,
coin_name: str,
interval_min: int,
rows: list[tuple],
) -> int:
"""캔들 행을 upsert한다.
Args:
symbol: 코인 심볼.
coin_name: 코인 이름.
interval_min: 분 단위 인터벌.
rows: ``(ymdhms, open, high, low, close, volume)`` 튜플 리스트.
Returns:
저장(시도) 행 수.
"""
if not rows:
return 0
table = self.ensure_table(symbol, interval_min)
code = symbol.upper()
payload: list[tuple] = []
for ymdhms, open_p, high_p, low_p, close_p, volume in rows:
dt = parse_kst_datetime(str(ymdhms)) if isinstance(ymdhms, str) else ymdhms
ymd = dt.strftime("%Y-%m-%d")
hms = dt.strftime("%H:%M:%S")
payload.append(
(
code,
coin_name,
dt.strftime("%Y-%m-%d %H:%M:%S"),
ymd,
hms,
close_p,
open_p,
high_p,
low_p,
volume,
)
)
self._conn.executemany(
f"""
INSERT OR REPLACE INTO {table}
(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
payload,
)
self._conn.commit()
return len(payload)

View File

@@ -0,0 +1,185 @@
"""빗썸 캔들 역방향 수집."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from deepcoin.api.bithumb import BithumbCandleClient, parse_kst_datetime
from deepcoin.config import Settings
from deepcoin.data.candle_store import CandleStore
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DownloadResult:
"""인터벌별 수집 결과."""
interval_min: int
mode: str
requests: int
saved_rows: int
reached_target: bool
def _candle_rows_from_api(
candles: list[dict[str, Any]],
) -> list[tuple[str, float, float, float, float, float]]:
"""API 응답을 DB upsert 튜플로 변환한다."""
rows: list[tuple[str, float, float, float, float, float]] = []
for candle in candles:
ts = candle.get("candle_date_time_kst") or candle.get("candle_date_time_utc")
if not ts:
continue
rows.append(
(
str(ts).replace("T", " "),
float(candle["opening_price"]),
float(candle["high_price"]),
float(candle["low_price"]),
float(candle["trade_price"]),
float(candle.get("candle_acc_trade_volume", 0.0)),
)
)
return rows
class CandleDownloader:
"""설정 기반 캔들 다운로더."""
def __init__(self, settings: Settings) -> None:
"""다운로더를 초기화한다.
Args:
settings: 애플리케이션 설정.
"""
self.settings = settings
self._client = BithumbCandleClient(
base_url=settings.api_url,
count=settings.candle_count,
sleep_sec=settings.request_sleep_sec,
retries=settings.request_retries,
)
def download_all(
self,
store: CandleStore,
*,
days: int,
full: bool = False,
) -> list[DownloadResult]:
"""모든 인터벌을 수집한다.
Args:
store: 캔들 저장소.
days: 풀 다운 목표 일수.
full: True면 목표 일수까지 역방향 풀 다운.
Returns:
인터벌별 DownloadResult 리스트.
"""
results: list[DownloadResult] = []
for interval_min in self.settings.download_intervals:
results.append(
self._download_interval(store, interval_min, days=days, full=full)
)
return results
def _download_interval(
self,
store: CandleStore,
interval_min: int,
*,
days: int,
full: bool,
) -> DownloadResult:
"""단일 인터벌을 수집한다."""
symbol = self.settings.symbol
count_before, _, db_max = store.get_range(symbol, interval_min)
target_from = datetime.now() - timedelta(days=max(1, days))
if full or db_max is None:
mode = "full"
stop_at = target_from
else:
mode = "incremental"
if db_max >= datetime.now() - timedelta(minutes=max(interval_min, 1)):
return DownloadResult(
interval_min=interval_min,
mode="uptodate",
requests=0,
saved_rows=0,
reached_target=True,
)
stop_at = db_max - timedelta(minutes=interval_min)
to_kst: datetime | None = None
requests = 0
saved_rows = 0
reached_target = False
oldest_seen: datetime | None = None
while True:
candles = self._client.fetch_candles(
self.settings.market,
interval_min,
to_kst=to_kst,
)
requests += 1
if not candles:
break
rows = _candle_rows_from_api(candles)
if not rows:
break
saved_rows += store.upsert_rows(
symbol,
self.settings.coin_name,
interval_min,
rows,
)
batch_oldest = min(parse_kst_datetime(r[0]) for r in rows)
if oldest_seen is None or batch_oldest < oldest_seen:
oldest_seen = batch_oldest
if batch_oldest <= stop_at:
reached_target = True
break
to_kst = batch_oldest
if to_kst <= stop_at:
reached_target = True
break
if mode == "full" and oldest_seen is not None and oldest_seen <= target_from:
reached_target = True
if mode == "incremental" and requests == 0 and count_before > 0:
return DownloadResult(
interval_min=interval_min,
mode="uptodate",
requests=0,
saved_rows=0,
reached_target=True,
)
logger.info(
"수집 완료 %s_%s mode=%s requests=%s saved=%s reached=%s",
symbol,
interval_min,
mode,
requests,
saved_rows,
reached_target,
)
return DownloadResult(
interval_min=interval_min,
mode=mode,
requests=requests,
saved_rows=saved_rows,
reached_target=reached_target,
)

View File

@@ -0,0 +1,89 @@
"""봉 간격 상수 및 라벨·다운로드 추정 유틸."""
from __future__ import annotations
import math
INTERVAL_1MIN = 1
INTERVAL_DAILY = 1440
INTERVAL_WEEKLY = 10080
INTERVAL_MONTHLY = 43200
CALENDAR_INTERVALS: frozenset[int] = frozenset(
{INTERVAL_DAILY, INTERVAL_WEEKLY, INTERVAL_MONTHLY}
)
DEFAULT_DOWNLOAD_INTERVALS: tuple[int, ...] = (
1,
3,
5,
10,
15,
30,
60,
240,
INTERVAL_DAILY,
INTERVAL_WEEKLY,
INTERVAL_MONTHLY,
)
def interval_label(interval_min: int) -> str:
"""인터벌 코드를 사람이 읽기 쉬운 라벨로 변환한다.
Args:
interval_min: 분 단위 인터벌 코드.
Returns:
예: ``3분``, ``일``, ``주``, ``월``.
"""
if interval_min == INTERVAL_DAILY:
return ""
if interval_min == INTERVAL_WEEKLY:
return ""
if interval_min == INTERVAL_MONTHLY:
return ""
if interval_min < INTERVAL_DAILY:
return f"{interval_min}"
if interval_min % 1440 == 0:
return f"{interval_min // 1440}"
return f"{interval_min}"
def bars_per_day(interval_min: int) -> float:
"""하루당 예상 봉 수를 반환한다.
Args:
interval_min: 분 단위 인터벌 코드.
Returns:
일봉 이상은 1 미만(주·월)일 수 있다.
"""
if interval_min == INTERVAL_DAILY:
return 1.0
if interval_min == INTERVAL_WEEKLY:
return 1.0 / 7.0
if interval_min == INTERVAL_MONTHLY:
return 1.0 / 30.0
return (24 * 60) / interval_min
def estimate_download_requests(
interval_min: int,
days: int,
batch_size: int = 200,
) -> int:
"""역방향 페이지네이션 시 예상 API 요청 횟수를 추정한다.
Args:
interval_min: 분 단위 인터벌 코드.
days: 수집 목표 일수.
batch_size: 요청당 캔들 수.
Returns:
최소 1회.
"""
days = max(1, days)
batch_size = max(1, batch_size)
total_bars = max(1, math.ceil(days * bars_per_day(interval_min)))
return max(1, math.ceil(total_bars / batch_size))

View File

@@ -62,6 +62,12 @@ def run_technique_causal_sim(
)
def _chart_meta_base(gt_meta: dict[str, Any]) -> dict[str, Any]:
"""차트용 GT 메타에서 chart_tier 등 2단계에 혼동되는 필드를 제거한다."""
skip = {"chart_tier"}
return {k: v for k, v in gt_meta.items() if k not in skip}
def _gt_shell_for_chart(
gt_meta: dict[str, Any],
technique: TechniqueResult,
@@ -69,7 +75,7 @@ def _gt_shell_for_chart(
"""차트 렌더용 최소 GT 구조를 만든다."""
return {
"meta": {
**gt_meta,
**_chart_meta_base(gt_meta),
"technique_id": technique.technique_id,
"technique_name": technique.technique_name,
"stage": "spot_2_causal_sim",
@@ -83,6 +89,67 @@ def technique_sim_chart_path(analysis_dir: Path, technique_id: str) -> Path:
return analysis_dir / f"technique_chart_sim_{technique_id}.html"
def best_technique_chart_path(analysis_dir: Path) -> Path:
"""1단계 v3 sim 차트와 대조용 — 최고 수익 인과 기법 sim 차트 경로."""
return analysis_dir / "causal_sim_chart_best_technique.html"
def pick_best_technique_row(report: dict[str, Any]) -> dict[str, Any] | None:
"""인과 sim 리포트에서 수익률 1위 기법 행을 반환한다."""
rows = report.get("ranking") or []
return rows[0] if rows else None
def render_best_technique_comparison_chart(
*,
db_path: Path,
symbol: str,
gt_result: dict[str, Any],
result: TechniqueResult,
sim_pnl: dict[str, Any],
output_path: Path,
chart_lookback_days: int,
stage1_chart_ref: str = "docs/spot/1_simulation/ground_truth_chart_sim_v3.html",
) -> Path:
"""수익률 1위 인과 기법 sim을 1단계 v3 sim 차트와 동일 UI·기간으로 렌더한다.
Args:
db_path: SQLite 경로.
symbol: 코인 심볼.
gt_result: v3 GT JSON (메타·비교 기준).
result: 최고 수익 기법 결과.
sim_pnl: 해당 기법 3년 인과 sim 결과.
output_path: HTML 출력 경로.
chart_lookback_days: 1단계와 동일 캔들 표시 일수 (보통 DOWNLOAD_DAYS).
stage1_chart_ref: 1단계 v3 sim 차트 상대 경로 (안내용).
Returns:
HTML 저장 경로.
"""
gt_meta = gt_result.get("meta", {})
benchmark = stage1_benchmark_from_gt(gt_result)
comparison_gt = {
"meta": {
**_chart_meta_base(gt_meta),
"technique_id": result.technique_id,
"technique_name": result.technique_name,
"stage": "spot_2_causal_sim_best",
"comparison_ref": stage1_chart_ref,
"comparison_label": "1단계 ground_truth_chart_sim_v3.html (사후 GT)",
"stage1_benchmark_return_pct": (benchmark or {}).get("total_return_pct"),
},
"signals": [],
}
return render_ground_truth_sim_chart(
db_path=db_path,
symbol=symbol,
gt_result=comparison_gt,
sim_pnl=sim_pnl,
output_path=output_path,
chart_lookback_days=chart_lookback_days,
)
def render_technique_sim_chart(
*,
db_path: Path,
@@ -157,6 +224,7 @@ def build_causal_sim_report(
rows.sort(key=lambda r: r["sim_return_pct"], reverse=True)
period_from = benchmark.get("period_from") if benchmark else None
period_to = benchmark.get("period_to") if benchmark else None
best = rows[0] if rows else None
return {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
@@ -170,6 +238,8 @@ def build_causal_sim_report(
"sim_lookback_days": gt_meta.get("sim_lookback_days")
or (benchmark or {}).get("sim_lookback_days"),
"stage1_benchmark_v3": benchmark,
"best_technique": best,
"best_technique_chart": "causal_sim_chart_best_technique.html",
"ranking": rows,
}
@@ -186,8 +256,20 @@ def render_causal_sim_html(report: dict[str, Any], html_path: Path) -> Path:
"""인과 sim 리포트 HTML을 생성한다."""
html_path.parent.mkdir(parents=True, exist_ok=True)
benchmark = report.get("stage1_benchmark_v3") or {}
best = report.get("best_technique") or {}
best_chart = report.get("best_technique_chart", "")
rows = report.get("ranking", [])
best_note = ""
if best and best_chart:
best_note = (
f'<p class="meta">수익률 1위 기법 '
f'<a href="{best_chart}">{best.get("technique_name", "")}</a> '
f'({best.get("technique_id", "")}, {best.get("sim_return_pct", 0):+.2f}%) — '
f'1단계 v3 sim 차트와 동일 형식: '
f'<a href="{best_chart}">causal_sim_chart_best_technique.html</a></p>'
)
bench_row = ""
if benchmark:
bench_row = f"""
@@ -242,6 +324,7 @@ def render_causal_sim_html(report: dict[str, Any], html_path: Path) -> Path:
생성: {report.get('generated_at', '')}
</p>
<p class="meta">{report.get('description', '')}</p>
{best_note}
<table>
<thead>
<tr>

View File

@@ -254,7 +254,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Ground Truth Chart</title>
<title>DeepCoin 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>
@@ -284,7 +284,7 @@ __EXTRA_STYLES__
</head>
<body>
<header>
<h1 id="title">Ground Truth Chart</h1>
<h1 id="title">DeepCoin Chart</h1>
<div class="meta" id="meta"></div>
</header>
__EXTRA_BODY__
@@ -717,6 +717,71 @@ __EXTRA_BODY__
__EXTRA_SCRIPT__
function resolveChartPresentation(m, simMode, chartLabel, gtLabel, simPnl) {
const stage = m.stage || "";
const techniqueName = m.technique_name || "";
const techniqueId = m.technique_id || "";
const simDays = (simPnl && simPnl.sim_lookback_days) || 1095;
const simLabel = simDays >= 365
? `최근 ${Math.round(simDays / 365)}년`
: `최근 ${simDays}일`;
const initCash = (simPnl && simPnl.initial_cash_krw) || 0;
const initLabel = initCash ? ` · 초기 ${Math.round(initCash).toLocaleString()}원` : "";
if (stage === "spot_2_causal_sim_best") {
const bench = m.stage1_benchmark_return_pct;
const benchNote = Number.isFinite(bench)
? ` | 1단계 v3 GT sim ${bench >= 0 ? "+" : ""}${bench.toFixed(2)}%`
: "";
return {
pageTitle: `${m.symbol} 인과 sim — ${techniqueName}`,
title: `${m.symbol} 2단계 인과 sim · 최고 수익 (${techniqueName}, ${m.interval_label}) — 차트 ${chartLabel}`,
panelTitle: `2단계 인과 sim · 최고 수익 기법 (${simLabel}${initLabel})`,
legend: "B=매수 S=매도 | 과거 데이터만 · 인과 기법 신호",
simNoteExtra: `기법 ${techniqueId} | 미래 데이터 미사용${benchNote}`,
};
}
if (stage === "spot_2_causal_sim" || techniqueId) {
return {
pageTitle: `${m.symbol} 인과 sim — ${techniqueName}`,
title: `${m.symbol} 2단계 인과 sim (${techniqueName}, ${m.interval_label}) — 차트 ${chartLabel}`,
panelTitle: `2단계 인과 sim (${simLabel}${initLabel})`,
legend: "B=매수 S=매도 | 과거 데이터만 · 인과 기법 신호",
simNoteExtra: `기법 ${techniqueId} | 미래 데이터 미사용`,
};
}
if (simMode) {
const tier = m.chart_tier ? ` ${String(m.chart_tier).toUpperCase()}` : "";
const tierKey = (m.chart_tier || "v3").toLowerCase();
const legend = tierKey === "v1"
? "B=스윙매수 S=스윙매도"
: tierKey === "v2"
? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
return {
pageTitle: `${m.symbol} GT sim${tier}`,
title: `${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel} · 1단계 sim`,
panelTitle: `1단계 GT sim (${simLabel}${initLabel}) · 사후 최적 타점`,
legend: legend,
simNoteExtra: "사후 GT 타점 · 미래 데이터 사용 (벤치마크)",
};
}
const tier = m.chart_tier ? ` ${String(m.chart_tier).toUpperCase()}` : "";
const tierKey = (m.chart_tier || "v3").toLowerCase();
const legend = tierKey === "v1"
? "B=스윙매수 S=스윙매도"
: tierKey === "v2"
? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
return {
pageTitle: `${m.symbol} Ground Truth${tier}`,
title: `${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`,
panelTitle: "",
legend: legend,
simNoteExtra: "",
};
}
function init() {
DATA = window.CHART_DATA;
if (!DATA) throw new Error("차트 데이터 JS 없음");
@@ -726,35 +791,27 @@ __EXTRA_SCRIPT__
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 ? " · 1단계 sim" : "";
document.getElementById("title").textContent =
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
const pres = resolveChartPresentation(m, simMode, chartLabel, gtLabel, DATA.sim_pnl);
document.title = pres.pageTitle;
document.getElementById("title").textContent = pres.title;
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}`;
let metaLine =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${markerRange}) | ${pres.legend}${legendExtra}`;
if (m.comparison_label) {
metaLine += ` | 대조: ${m.comparison_label}`;
}
document.getElementById("meta").textContent = metaLine;
window.__SIM_NOTE_EXTRA__ = pres.simNoteExtra || "";
if (simMode) {
const simDays = DATA.sim_pnl.sim_lookback_days || 1095;
const simLabel = simDays >= 365
? `최근 ${Math.round(simDays / 365)}년`
: `최근 ${simDays}일`;
const panelTitle = document.getElementById("sim-panel-title");
if (panelTitle) {
const initCash = DATA.sim_pnl?.initial_cash_krw || 0;
const initLabel = initCash ? `${Math.round(initCash).toLocaleString()}원` : "";
panelTitle.textContent = `1단계 수익 sim (${simLabel}${initLabel ? ` · 초기 ${initLabel}` : ""})`;
if (panelTitle && pres.panelTitle) {
panelTitle.textContent = pres.panelTitle;
}
renderSimPanel();
}
@@ -834,7 +891,7 @@ _SIM_EXTRA_STYLES = """
_SIM_EXTRA_BODY = """
<section class="sim-panel" id="sim-panel">
<h2 id="sim-panel-title">1단계 수익 sim</h2>
<h2 id="sim-panel-title">수익 sim</h2>
<div class="sim-grid" id="sim-grid"></div>
<div class="sim-note" id="sim-note"></div>
</section>
@@ -910,7 +967,8 @@ _SIM_EXTRA_SCRIPT = """
`시뮬 기간: ${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)}%` +
(p.buy_sizing_rule ? ` | 매수 ${p.buy_sizing_rule}` : "");
(p.buy_sizing_rule ? ` | 매수 ${p.buy_sizing_rule}` : "") +
(window.__SIM_NOTE_EXTRA__ ? ` | ${window.__SIM_NOTE_EXTRA__}` : "");
const tbody = document.getElementById("trade-body");
tbody.innerHTML = "";
(p.trades || []).forEach(t => {