feat(spot): 2단계 인과 기법 분석 파이프라인 마무리
common/spot/futures 경로 정비, 캔들 데이터 모듈 복원, MTF 규칙 자동 저장 및 2단계 설계·최종 정리 문서를 반영해 3단계 착수 기반을 확정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
1
src/deepcoin/data/__init__.py
Normal file
1
src/deepcoin/data/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""캔들 수집·저장·조회."""
|
||||
44
src/deepcoin/data/candle_loader.py
Normal file
44
src/deepcoin/data/candle_loader.py
Normal 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
|
||||
191
src/deepcoin/data/candle_store.py
Normal file
191
src/deepcoin/data/candle_store.py
Normal 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)
|
||||
185
src/deepcoin/data/downloader.py
Normal file
185
src/deepcoin/data/downloader.py
Normal 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,
|
||||
)
|
||||
89
src/deepcoin/data/intervals.py
Normal file
89
src/deepcoin/data/intervals.py
Normal 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))
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user