refactor: Git에서 데이터 제거, 설정·코드만 유지
파이프라인 산출물(data/, docs/)을 Git 추적에서 제외하고 히스토리를 단일 커밋으로 재구성해 저장소 용량을 경량화한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3
src/deepcoin/__init__.py
Normal file
3
src/deepcoin/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""DeepCoin — 빗썸 암호화폐 데이터 수집·분석."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
src/deepcoin/api/__init__.py
Normal file
1
src/deepcoin/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""외부 API 클라이언트."""
|
||||
134
src/deepcoin/api/bithumb.py
Normal file
134
src/deepcoin/api/bithumb.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""빗썸 Public REST API — 캔들 조회."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from deepcoin.data.intervals import INTERVAL_DAILY, INTERVAL_MONTHLY, INTERVAL_WEEKLY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 인터벌(분) → 빗썸 candles API 경로 세그먼트
|
||||
_CALENDAR_PATHS: dict[int, str] = {
|
||||
INTERVAL_DAILY: "days",
|
||||
INTERVAL_WEEKLY: "weeks",
|
||||
INTERVAL_MONTHLY: "months",
|
||||
}
|
||||
|
||||
KST_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
KST_FMT_T = "%Y-%m-%dT%H:%M:%S"
|
||||
|
||||
|
||||
def parse_kst_datetime(value: str) -> datetime:
|
||||
"""KST 캔들 시각 문자열을 datetime으로 변환한다.
|
||||
|
||||
Args:
|
||||
value: `yyyy-MM-dd HH:mm:ss` 또는 ISO 형식.
|
||||
|
||||
Returns:
|
||||
naive datetime (KST 기준).
|
||||
"""
|
||||
normalized = value.strip().replace("T", " ")
|
||||
return datetime.strptime(normalized, KST_FMT)
|
||||
|
||||
|
||||
def format_kst_datetime(dt: datetime) -> str:
|
||||
"""datetime을 빗썸 `to` 파라미터 형식으로 포맷한다.
|
||||
|
||||
Args:
|
||||
dt: KST 기준 시각.
|
||||
|
||||
Returns:
|
||||
`yyyy-MM-dd HH:mm:ss` 문자열.
|
||||
"""
|
||||
return dt.strftime(KST_FMT)
|
||||
|
||||
|
||||
class BithumbCandleClient:
|
||||
"""빗썸 캔들 API 클라이언트."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://api.bithumb.com",
|
||||
count: int = 200,
|
||||
sleep_sec: float = 0.35,
|
||||
retries: int = 3,
|
||||
) -> None:
|
||||
"""클라이언트를 초기화한다.
|
||||
|
||||
Args:
|
||||
base_url: API 베이스 URL.
|
||||
count: 요청당 캔들 개수 (최대 200).
|
||||
sleep_sec: 연속 요청 간 대기(초).
|
||||
retries: 실패 시 재시도 횟수.
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.count = min(max(count, 1), 200)
|
||||
self.sleep_sec = sleep_sec
|
||||
self.retries = retries
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"accept": "application/json"})
|
||||
|
||||
def fetch_candles(
|
||||
self,
|
||||
market: str,
|
||||
interval_min: int,
|
||||
to_kst: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""캔들 배치를 조회한다 (최신순).
|
||||
|
||||
Args:
|
||||
market: 거래 페어 (예: KRW-WLD).
|
||||
interval_min: 분 단위. 1440이면 일봉 API 사용.
|
||||
to_kst: 조회 기준 시각(KST). 해당 시각 캔들은 제외.
|
||||
|
||||
Returns:
|
||||
캔들 dict 리스트. API 오류 시 빈 리스트.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: 재시도 후에도 네트워크 실패.
|
||||
"""
|
||||
if interval_min in _CALENDAR_PATHS:
|
||||
segment = _CALENDAR_PATHS[interval_min]
|
||||
url = f"{self.base_url}/v1/candles/{segment}"
|
||||
params: dict[str, Any] = {"market": market, "count": self.count}
|
||||
else:
|
||||
url = f"{self.base_url}/v1/candles/minutes/{interval_min}"
|
||||
params = {"market": market, "count": self.count}
|
||||
|
||||
if to_kst is not None:
|
||||
params["to"] = format_kst_datetime(to_kst)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, self.retries + 1):
|
||||
try:
|
||||
response = self._session.get(url, params=params, timeout=30)
|
||||
if response.status_code == 429:
|
||||
wait = self.sleep_sec * attempt * 3
|
||||
logger.warning("Rate limit 429 — %ss 대기 후 재시도", wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and "error" in payload:
|
||||
logger.error("API error: %s", payload["error"])
|
||||
return []
|
||||
if not isinstance(payload, list):
|
||||
logger.error("Unexpected response type: %s", type(payload))
|
||||
return []
|
||||
time.sleep(self.sleep_sec)
|
||||
return payload
|
||||
except requests.RequestException as exc:
|
||||
last_error = exc
|
||||
wait = self.sleep_sec * attempt * 2
|
||||
logger.warning("Request failed (%s/%s): %s", attempt, self.retries, exc)
|
||||
time.sleep(wait)
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
return []
|
||||
262
src/deepcoin/config.py
Normal file
262
src/deepcoin/config.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""환경 변수 로드 및 애플리케이션 설정."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from deepcoin.data.intervals import DEFAULT_DOWNLOAD_INTERVALS
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _resolve_project_path(raw: str) -> Path:
|
||||
"""프로젝트 루트 기준 상대 경로를 절대 경로로 변환한다."""
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
path = _PROJECT_ROOT / path
|
||||
return path
|
||||
|
||||
|
||||
def _parse_int_list(raw: str) -> list[int]:
|
||||
"""쉼표 구분 정수 목록을 파싱한다.
|
||||
|
||||
Args:
|
||||
raw: 예) "3,5,10,15"
|
||||
|
||||
Returns:
|
||||
정수 리스트. 빈 입력이면 빈 리스트.
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return []
|
||||
return [int(part.strip()) for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""DeepCoin 실행 설정."""
|
||||
|
||||
symbol: str
|
||||
coin_name: str
|
||||
api_url: str
|
||||
candle_count: int
|
||||
download_intervals: list[int]
|
||||
download_days: int
|
||||
db_path: Path
|
||||
request_sleep_sec: float
|
||||
request_retries: int
|
||||
# 0단계: GT 타점 (현물·선물 공통 파라미터)
|
||||
gt_interval_min: int
|
||||
gt_lookback_days: int
|
||||
gt_zigzag_reversal_pct: float
|
||||
gt_min_leg_pct: float
|
||||
gt_pullback_min_pct: float
|
||||
gt_pullback_local_order: int
|
||||
gt_breakout_buffer_pct: float
|
||||
gt_breakout_consolidation_bars: int
|
||||
gt_breakout_min_rally_pct: float
|
||||
gt_div_local_order: int
|
||||
gt_div_min_bars_between: int
|
||||
gt_div_min_rsi_diff: float
|
||||
gt_div_min_future_move_pct: float
|
||||
ground_truth_file: Path
|
||||
ground_truth_v1_file: Path
|
||||
ground_truth_v2_file: Path
|
||||
ground_truth_chart_v1_file: Path
|
||||
ground_truth_chart_v2_file: Path
|
||||
ground_truth_chart_v3_file: Path
|
||||
ground_truth_futures_file: Path
|
||||
ground_truth_futures_v1_file: Path
|
||||
ground_truth_futures_v2_file: Path
|
||||
ground_truth_futures_chart_v1_file: Path
|
||||
ground_truth_futures_chart_v2_file: Path
|
||||
ground_truth_futures_chart_v3_file: Path
|
||||
# 현물 1단계: GT sim
|
||||
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
|
||||
# 현물 2단계: 인과 기법 GT 정합
|
||||
techniques_dir: Path
|
||||
analysis_report_json: Path
|
||||
analysis_report_html: Path
|
||||
signal_type_report_json: Path
|
||||
signal_type_report_html: Path
|
||||
gt_align_tolerance_bars: int
|
||||
mtf_report_json: Path
|
||||
mtf_report_html: Path
|
||||
mtf_rules_json: Path
|
||||
causal_sim_report_json: Path
|
||||
causal_sim_report_html: Path
|
||||
|
||||
@property
|
||||
def market(self) -> str:
|
||||
"""빗썸 마켓 코드 (예: KRW-BTC)."""
|
||||
return f"KRW-{self.symbol}"
|
||||
|
||||
|
||||
def load_settings(env_path: Path | None = None) -> Settings:
|
||||
"""`.env`를 로드하고 Settings를 반환한다.
|
||||
|
||||
Args:
|
||||
env_path: `.env` 경로. None이면 프로젝트 루트.
|
||||
|
||||
Returns:
|
||||
Settings 인스턴스.
|
||||
"""
|
||||
path = env_path or (_PROJECT_ROOT / ".env")
|
||||
load_dotenv(path, override=False)
|
||||
|
||||
default_intervals = ",".join(str(i) for i in DEFAULT_DOWNLOAD_INTERVALS)
|
||||
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_path = Path(db_raw)
|
||||
if not db_path.is_absolute():
|
||||
db_path = _PROJECT_ROOT / db_path
|
||||
|
||||
tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/spot/techniques")
|
||||
tech_dir = Path(tech_dir_raw)
|
||||
if not tech_dir.is_absolute():
|
||||
tech_dir = _PROJECT_ROOT / tech_dir
|
||||
|
||||
return Settings(
|
||||
symbol=os.getenv("SYMBOL", "BTC").upper(),
|
||||
coin_name=os.getenv("COIN_NAME", "비트코인"),
|
||||
api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"),
|
||||
candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")),
|
||||
download_intervals=intervals,
|
||||
download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")),
|
||||
db_path=db_path,
|
||||
request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")),
|
||||
request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")),
|
||||
gt_interval_min=int(os.getenv("GT_INTERVAL_MIN", "3")),
|
||||
gt_lookback_days=int(os.getenv("GT_LOOKBACK_DAYS", "3447")),
|
||||
gt_zigzag_reversal_pct=float(os.getenv("GT_ZIGZAG_REVERSAL_PCT", "5.0")),
|
||||
gt_min_leg_pct=float(os.getenv("GT_MIN_LEG_PCT", "3.0")),
|
||||
gt_pullback_min_pct=float(os.getenv("GT_PULLBACK_MIN_PCT", "1.5")),
|
||||
gt_pullback_local_order=int(os.getenv("GT_PULLBACK_LOCAL_ORDER", "10")),
|
||||
gt_breakout_buffer_pct=float(os.getenv("GT_BREAKOUT_BUFFER_PCT", "0.1")),
|
||||
gt_breakout_consolidation_bars=int(os.getenv("GT_BREAKOUT_CONSOLIDATION_BARS", "200")),
|
||||
gt_breakout_min_rally_pct=float(os.getenv("GT_BREAKOUT_MIN_RALLY_PCT", "2.0")),
|
||||
gt_div_local_order=int(os.getenv("GT_DIV_LOCAL_ORDER", "20")),
|
||||
gt_div_min_bars_between=int(os.getenv("GT_DIV_MIN_BARS_BETWEEN", "1500")),
|
||||
gt_div_min_rsi_diff=float(os.getenv("GT_DIV_MIN_RSI_DIFF", "5.0")),
|
||||
gt_div_min_future_move_pct=float(os.getenv("GT_DIV_MIN_FUTURE_MOVE_PCT", "4.0")),
|
||||
ground_truth_file=_resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_FILE", "data/spot/ground_truth/ground_truth_trades_v3.json")
|
||||
),
|
||||
ground_truth_v1_file=_resolve_project_path(
|
||||
os.getenv("GROUND_TRUTH_V1_FILE", "data/spot/ground_truth/ground_truth_trades_v1.json")
|
||||
),
|
||||
ground_truth_v2_file=_resolve_project_path(
|
||||
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")
|
||||
),
|
||||
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")
|
||||
),
|
||||
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")
|
||||
),
|
||||
ground_truth_futures_file=_resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_FUTURES_FILE",
|
||||
"data/futures/ground_truth/ground_truth_trades_v3.json",
|
||||
)
|
||||
),
|
||||
ground_truth_futures_v1_file=_resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_FUTURES_V1_FILE",
|
||||
"data/futures/ground_truth/ground_truth_trades_v1.json",
|
||||
)
|
||||
),
|
||||
ground_truth_futures_v2_file=_resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_FUTURES_V2_FILE",
|
||||
"data/futures/ground_truth/ground_truth_trades_v2.json",
|
||||
)
|
||||
),
|
||||
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",
|
||||
)
|
||||
),
|
||||
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",
|
||||
)
|
||||
),
|
||||
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",
|
||||
)
|
||||
),
|
||||
ground_truth_chart_sim_v1_file=_resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_CHART_SIM_V1_FILE",
|
||||
"docs/spot/1_simulation/ground_truth_chart_sim_v1.html",
|
||||
)
|
||||
),
|
||||
ground_truth_chart_sim_v2_file=_resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_CHART_SIM_V2_FILE",
|
||||
"docs/spot/1_simulation/ground_truth_chart_sim_v2.html",
|
||||
)
|
||||
),
|
||||
ground_truth_chart_sim_v3_file=_resolve_project_path(
|
||||
os.getenv(
|
||||
"GROUND_TRUTH_CHART_SIM_V3_FILE",
|
||||
"docs/spot/1_simulation/ground_truth_chart_sim_v3.html",
|
||||
)
|
||||
),
|
||||
gt_sim_lookback_days=int(os.getenv("GT_SIM_LOOKBACK_DAYS", "1095")),
|
||||
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,
|
||||
analysis_report_json=_resolve_project_path(
|
||||
os.getenv("ANALYSIS_REPORT_JSON", "docs/spot/2_analysis/comparison_report.json")
|
||||
),
|
||||
analysis_report_html=_resolve_project_path(
|
||||
os.getenv("ANALYSIS_REPORT_HTML", "docs/spot/2_analysis/comparison_report.html")
|
||||
),
|
||||
signal_type_report_json=_resolve_project_path(
|
||||
os.getenv("SIGNAL_TYPE_REPORT_JSON", "docs/spot/2_analysis/signal_type_report.json")
|
||||
),
|
||||
signal_type_report_html=_resolve_project_path(
|
||||
os.getenv("SIGNAL_TYPE_REPORT_HTML", "docs/spot/2_analysis/signal_type_report.html")
|
||||
),
|
||||
gt_align_tolerance_bars=int(os.getenv("GT_ALIGN_TOLERANCE_BARS", "480")),
|
||||
mtf_report_json=_resolve_project_path(
|
||||
os.getenv("MTF_REPORT_JSON", "docs/spot/2_analysis/mtf_correlation_report.json")
|
||||
),
|
||||
mtf_report_html=_resolve_project_path(
|
||||
os.getenv("MTF_REPORT_HTML", "docs/spot/2_analysis/mtf_correlation_report.html")
|
||||
),
|
||||
mtf_rules_json=_resolve_project_path(
|
||||
os.getenv("MTF_RULES_JSON", "data/spot/mtf/mtf_rules_v3.json")
|
||||
),
|
||||
causal_sim_report_json=_resolve_project_path(
|
||||
os.getenv(
|
||||
"CAUSAL_SIM_REPORT_JSON",
|
||||
"docs/spot/2_analysis/causal_sim_report.json",
|
||||
)
|
||||
),
|
||||
causal_sim_report_html=_resolve_project_path(
|
||||
os.getenv(
|
||||
"CAUSAL_SIM_REPORT_HTML",
|
||||
"docs/spot/2_analysis/causal_sim_report.html",
|
||||
)
|
||||
),
|
||||
)
|
||||
27
src/deepcoin/evaluation/__init__.py
Normal file
27
src/deepcoin/evaluation/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Ground Truth 정합 평가."""
|
||||
|
||||
from deepcoin.evaluation.gt_align import align_with_ground_truth
|
||||
from deepcoin.evaluation.mtf_report import (
|
||||
build_mtf_correlation_report,
|
||||
render_mtf_html,
|
||||
save_mtf_report,
|
||||
)
|
||||
from deepcoin.evaluation.report import build_comparison_report, render_comparison_html, save_comparison_report
|
||||
from deepcoin.evaluation.signal_type_report import (
|
||||
build_signal_type_report,
|
||||
render_signal_type_html,
|
||||
save_signal_type_report,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"align_with_ground_truth",
|
||||
"build_comparison_report",
|
||||
"render_comparison_html",
|
||||
"save_comparison_report",
|
||||
"build_signal_type_report",
|
||||
"render_signal_type_html",
|
||||
"save_signal_type_report",
|
||||
"build_mtf_correlation_report",
|
||||
"render_mtf_html",
|
||||
"save_mtf_report",
|
||||
]
|
||||
265
src/deepcoin/evaluation/causal_sim.py
Normal file
265
src/deepcoin/evaluation/causal_sim.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""현물 2단계: 인과 기법 시뮬 (1단계와 동일 거래 기간 · 초기 자본)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deepcoin.ground_truth.chart import render_ground_truth_sim_chart
|
||||
from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl
|
||||
from deepcoin.techniques.base import TechniqueResult
|
||||
|
||||
|
||||
def normalize_signals_for_sim(signals: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""기법 신호를 1단계 sim 엔진 형식으로 정규화한다."""
|
||||
ordered = sorted(signals, key=lambda s: s["datetime"])
|
||||
buy_id = 0
|
||||
sell_id = 0
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for sig in ordered:
|
||||
side = sig["side"]
|
||||
if side == "buy":
|
||||
buy_id += 1
|
||||
marker_id = buy_id
|
||||
default_type = "swing_low"
|
||||
else:
|
||||
sell_id += 1
|
||||
marker_id = sell_id
|
||||
default_type = "swing_high"
|
||||
normalized.append(
|
||||
{
|
||||
"side": side,
|
||||
"datetime": sig["datetime"],
|
||||
"price": float(sig["price"]),
|
||||
"bar_index": sig.get("bar_index", 0),
|
||||
"marker_id": marker_id,
|
||||
"signal_type": sig.get("signal_type", default_type),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def run_technique_causal_sim(
|
||||
result: TechniqueResult,
|
||||
*,
|
||||
initial_cash_krw: float,
|
||||
fee_rate: float,
|
||||
sim_lookback_days: int,
|
||||
data_end: str,
|
||||
last_mark_price: float,
|
||||
) -> dict[str, Any]:
|
||||
"""인과 기법 신호로 1단계와 동일 규칙의 포트폴리오 sim을 실행한다."""
|
||||
signals = normalize_signals_for_sim(result.signals)
|
||||
return simulate_gt_signals_pnl(
|
||||
signals=signals,
|
||||
initial_cash_krw=initial_cash_krw,
|
||||
fee_rate=fee_rate,
|
||||
sim_lookback_days=sim_lookback_days,
|
||||
data_end=data_end,
|
||||
last_mark_price=last_mark_price,
|
||||
)
|
||||
|
||||
|
||||
def _gt_shell_for_chart(
|
||||
gt_meta: dict[str, Any],
|
||||
technique: TechniqueResult,
|
||||
) -> dict[str, Any]:
|
||||
"""차트 렌더용 최소 GT 구조를 만든다."""
|
||||
return {
|
||||
"meta": {
|
||||
**gt_meta,
|
||||
"technique_id": technique.technique_id,
|
||||
"technique_name": technique.technique_name,
|
||||
"stage": "spot_2_causal_sim",
|
||||
},
|
||||
"signals": [],
|
||||
}
|
||||
|
||||
|
||||
def technique_sim_chart_path(analysis_dir: Path, technique_id: str) -> Path:
|
||||
"""기법별 sim 차트 HTML 경로."""
|
||||
return analysis_dir / f"technique_chart_sim_{technique_id}.html"
|
||||
|
||||
|
||||
def render_technique_sim_chart(
|
||||
*,
|
||||
db_path: Path,
|
||||
symbol: str,
|
||||
gt_meta: dict[str, Any],
|
||||
result: TechniqueResult,
|
||||
sim_pnl: dict[str, Any],
|
||||
output_path: Path,
|
||||
chart_lookback_days: int,
|
||||
) -> Path:
|
||||
"""인과 기법 sim 차트 HTML을 생성한다."""
|
||||
gt_shell = _gt_shell_for_chart(gt_meta, result)
|
||||
return render_ground_truth_sim_chart(
|
||||
db_path=db_path,
|
||||
symbol=symbol,
|
||||
gt_result=gt_shell,
|
||||
sim_pnl=sim_pnl,
|
||||
output_path=output_path,
|
||||
chart_lookback_days=chart_lookback_days,
|
||||
)
|
||||
|
||||
|
||||
def stage1_benchmark_from_gt(gt_result: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""1단계 GT sim 벤치마크(v3)를 추출한다."""
|
||||
sim = gt_result.get("sim_pnl")
|
||||
if not sim:
|
||||
return None
|
||||
return {
|
||||
"label": "1단계 GT sim (v3, 사후 최적 타점)",
|
||||
"period_from": sim.get("period_from"),
|
||||
"period_to": sim.get("period_to"),
|
||||
"sim_lookback_days": sim.get("sim_lookback_days"),
|
||||
"initial_cash_krw": sim.get("initial_cash_krw"),
|
||||
"final_equity_krw": sim.get("final_equity_krw"),
|
||||
"total_return_pct": sim.get("total_return_pct"),
|
||||
"buys_executed": sim.get("buys_executed"),
|
||||
"sells_executed": sim.get("sells_executed"),
|
||||
}
|
||||
|
||||
|
||||
def build_causal_sim_report(
|
||||
results: list[TechniqueResult],
|
||||
gt_result: dict[str, Any],
|
||||
symbol: str,
|
||||
sim_pnls: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""2단계 인과 sim 요약 리포트를 생성한다."""
|
||||
gt_meta = gt_result.get("meta", {})
|
||||
benchmark = stage1_benchmark_from_gt(gt_result)
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
for result in results:
|
||||
sim = sim_pnls.get(result.technique_id, {})
|
||||
align = result.alignment or {}
|
||||
rows.append(
|
||||
{
|
||||
"technique_id": result.technique_id,
|
||||
"technique_name": result.technique_name,
|
||||
"category": result.category,
|
||||
"causal": result.causal,
|
||||
"sim_return_pct": sim.get("total_return_pct", 0.0),
|
||||
"final_equity_krw": sim.get("final_equity_krw", 0.0),
|
||||
"buys_executed": sim.get("buys_executed", 0),
|
||||
"sells_executed": sim.get("sells_executed", 0),
|
||||
"buys_skipped": sim.get("buys_skipped", 0),
|
||||
"sells_skipped": sim.get("sells_skipped", 0),
|
||||
"gt_align_score": align.get("score", 0.0),
|
||||
"chart_file": f"technique_chart_sim_{result.technique_id}.html",
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"symbol": symbol,
|
||||
"description": (
|
||||
"2단계 인과 sim — 1단계와 동일 거래 기간·초기 자본. "
|
||||
"1단계는 사후 GT 타점, 2단계는 인과 기법 신호로 체결."
|
||||
),
|
||||
"sim_period_from": period_from,
|
||||
"sim_period_to": period_to,
|
||||
"sim_lookback_days": gt_meta.get("sim_lookback_days")
|
||||
or (benchmark or {}).get("sim_lookback_days"),
|
||||
"stage1_benchmark_v3": benchmark,
|
||||
"ranking": rows,
|
||||
}
|
||||
|
||||
|
||||
def save_causal_sim_report(report: dict[str, Any], json_path: Path) -> Path:
|
||||
"""인과 sim 리포트 JSON을 저장한다."""
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with json_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(report, fp, ensure_ascii=False, indent=2)
|
||||
return json_path
|
||||
|
||||
|
||||
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 {}
|
||||
rows = report.get("ranking", [])
|
||||
|
||||
bench_row = ""
|
||||
if benchmark:
|
||||
bench_row = f"""
|
||||
<tr class="benchmark">
|
||||
<td>—</td>
|
||||
<td>{benchmark.get('label', '1단계 GT sim')}</td>
|
||||
<td>benchmark</td>
|
||||
<td>{benchmark.get('final_equity_krw', 0):,.0f}</td>
|
||||
<td><strong>{benchmark.get('total_return_pct', 0):+.2f}%</strong></td>
|
||||
<td>{benchmark.get('buys_executed', 0)} / {benchmark.get('sells_executed', 0)}</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</tr>"""
|
||||
|
||||
table_rows = bench_row
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
chart = row.get("chart_file", "")
|
||||
table_rows += f"""
|
||||
<tr>
|
||||
<td>{idx}</td>
|
||||
<td><a href="{chart}">{row['technique_name']}</a></td>
|
||||
<td>{row['category']}</td>
|
||||
<td>{row.get('final_equity_krw', 0):,.0f}</td>
|
||||
<td>{row.get('sim_return_pct', 0):+.2f}%</td>
|
||||
<td>{row.get('buys_executed', 0)} / {row.get('sells_executed', 0)}</td>
|
||||
<td>{row.get('gt_align_score', 0)*100:.1f}</td>
|
||||
<td><a href="{chart}">차트</a></td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>DeepCoin 2단계 — 인과 sim</title>
|
||||
<style>
|
||||
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #f5f5f5; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 8px; }}
|
||||
.meta {{ color: #666; margin-bottom: 16px; font-size: 14px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; background: #fff; font-size: 13px; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px 10px; text-align: right; }}
|
||||
th {{ background: #eee; text-align: center; }}
|
||||
td:nth-child(1), td:nth-child(2), td:nth-child(3) {{ text-align: left; }}
|
||||
tr.benchmark {{ background: #fff8e1; }}
|
||||
a {{ color: #1565c0; text-decoration: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>현물 2단계 — 인과 기법 sim</h1>
|
||||
<p class="meta">
|
||||
{report.get('symbol', '')} |
|
||||
거래 기간: {report.get('sim_period_from', '')} ~ {report.get('sim_period_to', '')} |
|
||||
생성: {report.get('generated_at', '')}
|
||||
</p>
|
||||
<p class="meta">{report.get('description', '')}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>기법</th>
|
||||
<th>카테고리</th>
|
||||
<th>최종 평가(원)</th>
|
||||
<th>수익률</th>
|
||||
<th>체결(매수/매도)</th>
|
||||
<th>GT정합</th>
|
||||
<th>차트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
return html_path
|
||||
381
src/deepcoin/evaluation/gt_align.py
Normal file
381
src/deepcoin/evaluation/gt_align.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Ground Truth와 기법 신호·레그 정합 평가."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# v3 GT 신호 유형 (ground_truth.py signal_type 필드와 동일)
|
||||
GT_SIGNAL_TYPES: tuple[str, ...] = (
|
||||
"swing_low",
|
||||
"pullback",
|
||||
"breakout",
|
||||
"div_bull",
|
||||
"swing_high",
|
||||
"div_bear",
|
||||
)
|
||||
|
||||
SIGNAL_TYPE_LABELS: dict[str, str] = {
|
||||
"swing_low": "스윙 매수 (B)",
|
||||
"pullback": "눌림목 (B*)",
|
||||
"breakout": "돌파 (B^)",
|
||||
"div_bull": "상승 다이버전스 (Bd)",
|
||||
"swing_high": "스윙 매도 (S)",
|
||||
"div_bear": "하락 다이버전스 (Sd)",
|
||||
}
|
||||
|
||||
SIGNAL_TYPE_SIDE: dict[str, str] = {
|
||||
"swing_low": "buy",
|
||||
"pullback": "buy",
|
||||
"breakout": "buy",
|
||||
"div_bull": "buy",
|
||||
"swing_high": "sell",
|
||||
"div_bear": "sell",
|
||||
}
|
||||
|
||||
# 신호 유형별 1차 정합 대상 기법 (리포트 하이라이트용)
|
||||
SIGNAL_TYPE_PRIMARY_TECHNIQUES: dict[str, list[str]] = {
|
||||
"swing_low": [
|
||||
"zigzag_causal", "minor_swing", "pivot_swing", "fractal_swing",
|
||||
"composite_swing",
|
||||
],
|
||||
"pullback": [
|
||||
"ema_pullback", "fib_pullback", "support_bounce",
|
||||
"local_extrema", "bb_reversal", "composite_pullback",
|
||||
],
|
||||
"breakout": [
|
||||
"donchian", "range_breakout", "keltner_breakout",
|
||||
"bb_squeeze_breakout", "volume_breakout", "composite_breakout",
|
||||
],
|
||||
"div_bull": [
|
||||
"rsi_divergence", "macd_divergence", "obv_divergence",
|
||||
"rsi_swing", "composite_divergence",
|
||||
],
|
||||
"swing_high": [
|
||||
"zigzag_causal", "minor_swing", "pivot_swing", "fractal_swing",
|
||||
"composite_swing",
|
||||
],
|
||||
"div_bear": [
|
||||
"rsi_divergence", "macd_divergence", "obv_divergence",
|
||||
"rsi_swing", "composite_divergence",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _bar_distance(a: int, b: int) -> int:
|
||||
"""두 봉 인덱스 간 절대 거리."""
|
||||
return abs(a - b)
|
||||
|
||||
|
||||
def _match_signal(
|
||||
gt_bar: int,
|
||||
candidates: list[dict[str, Any]],
|
||||
tolerance_bars: int,
|
||||
used: set[int],
|
||||
) -> dict[str, Any] | None:
|
||||
"""GT 신호에 가장 가까운 기법 신호를 찾는다."""
|
||||
best: dict[str, Any] | None = None
|
||||
best_dist = tolerance_bars + 1
|
||||
best_idx = -1
|
||||
|
||||
for idx, candidate in enumerate(candidates):
|
||||
if idx in used:
|
||||
continue
|
||||
pivot_bar = candidate.get("pivot_bar_index")
|
||||
compare_bar = pivot_bar if pivot_bar is not None else candidate["bar_index"]
|
||||
dist = _bar_distance(gt_bar, compare_bar)
|
||||
if dist <= tolerance_bars and dist < best_dist:
|
||||
best = candidate
|
||||
best_dist = dist
|
||||
best_idx = idx
|
||||
|
||||
if best is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"matched": True,
|
||||
"gt_bar_index": gt_bar,
|
||||
"tech_bar_index": best.get("pivot_bar_index") or best["bar_index"],
|
||||
"signal_bar_index": best["bar_index"],
|
||||
"bar_offset": best_dist,
|
||||
"tech_price": best["price"],
|
||||
"tech_datetime": best["datetime"],
|
||||
"candidate_index": best_idx,
|
||||
}
|
||||
|
||||
|
||||
def align_signals(
|
||||
gt_signals: list[dict[str, Any]],
|
||||
tech_signals: list[dict[str, Any]],
|
||||
tolerance_bars: int,
|
||||
side: str,
|
||||
) -> dict[str, Any]:
|
||||
"""GT 신호와 기법 신호의 정합률을 계산한다.
|
||||
|
||||
Args:
|
||||
gt_signals: GT signals 리스트.
|
||||
tech_signals: 기법 신호 dict 리스트.
|
||||
tolerance_bars: 허용 봉 오차.
|
||||
side: buy | sell.
|
||||
|
||||
Returns:
|
||||
정합 메트릭 dict.
|
||||
"""
|
||||
gt_filtered = [s for s in gt_signals if s["side"] == side]
|
||||
tech_filtered = [s for s in tech_signals if s["side"] == side]
|
||||
|
||||
used: set[int] = set()
|
||||
hits: list[dict[str, Any]] = []
|
||||
misses: list[dict[str, Any]] = []
|
||||
|
||||
for gt_sig in gt_filtered:
|
||||
match = _match_signal(gt_sig["bar_index"], tech_filtered, tolerance_bars, used)
|
||||
if match:
|
||||
used.add(match["candidate_index"])
|
||||
hits.append({**match, "gt_datetime": gt_sig["datetime"], "gt_price": gt_sig["price"]})
|
||||
else:
|
||||
misses.append(
|
||||
{
|
||||
"gt_bar_index": gt_sig["bar_index"],
|
||||
"gt_datetime": gt_sig["datetime"],
|
||||
"gt_price": gt_sig["price"],
|
||||
}
|
||||
)
|
||||
|
||||
gt_count = len(gt_filtered)
|
||||
hit_count = len(hits)
|
||||
recall = hit_count / gt_count if gt_count else 0.0
|
||||
precision = hit_count / len(tech_filtered) if tech_filtered else 0.0
|
||||
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
|
||||
avg_offset = sum(h["bar_offset"] for h in hits) / hit_count if hit_count else 0.0
|
||||
|
||||
return {
|
||||
"side": side,
|
||||
"gt_count": gt_count,
|
||||
"tech_count": len(tech_filtered),
|
||||
"hit_count": hit_count,
|
||||
"miss_count": len(misses),
|
||||
"recall": round(recall, 4),
|
||||
"precision": round(precision, 4),
|
||||
"f1": round(f1, 4),
|
||||
"avg_bar_offset": round(avg_offset, 1),
|
||||
"hits": hits,
|
||||
"misses": misses,
|
||||
}
|
||||
|
||||
|
||||
def align_legs(
|
||||
gt_legs: list[dict[str, Any]],
|
||||
tech_legs: list[dict[str, Any]],
|
||||
tolerance_bars: int,
|
||||
) -> dict[str, Any]:
|
||||
"""GT 레그와 기법 레그의 정합률을 계산한다."""
|
||||
captured: list[dict[str, Any]] = []
|
||||
missed: list[dict[str, Any]] = []
|
||||
used_tech: set[int] = set()
|
||||
|
||||
for gt_leg in gt_legs:
|
||||
best_leg: dict[str, Any] | None = None
|
||||
best_score = tolerance_bars * 2 + 1
|
||||
best_id = -1
|
||||
|
||||
for tech_leg in tech_legs:
|
||||
tid = int(tech_leg["leg_id"])
|
||||
if tid in used_tech:
|
||||
continue
|
||||
buy_dist = _bar_distance(gt_leg["buy_bar_index"], tech_leg["buy_bar_index"])
|
||||
sell_dist = _bar_distance(gt_leg["sell_bar_index"], tech_leg["sell_bar_index"])
|
||||
score = buy_dist + sell_dist
|
||||
if buy_dist <= tolerance_bars and sell_dist <= tolerance_bars and score < best_score:
|
||||
best_leg = tech_leg
|
||||
best_score = score
|
||||
best_id = tid
|
||||
|
||||
if best_leg:
|
||||
used_tech.add(best_id)
|
||||
captured.append(
|
||||
{
|
||||
"gt_leg_id": gt_leg["leg_id"],
|
||||
"tech_leg_id": best_leg["leg_id"],
|
||||
"gt_buy": gt_leg["buy_datetime"],
|
||||
"tech_buy": best_leg["buy_datetime"],
|
||||
"gt_sell": gt_leg["sell_datetime"],
|
||||
"tech_sell": best_leg["sell_datetime"],
|
||||
"buy_bar_offset": _bar_distance(gt_leg["buy_bar_index"], best_leg["buy_bar_index"]),
|
||||
"sell_bar_offset": _bar_distance(gt_leg["sell_bar_index"], best_leg["sell_bar_index"]),
|
||||
"gt_leg_pct": gt_leg["leg_pct"],
|
||||
"tech_leg_pct": best_leg["leg_pct"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
missed.append(
|
||||
{
|
||||
"gt_leg_id": gt_leg["leg_id"],
|
||||
"buy_datetime": gt_leg["buy_datetime"],
|
||||
"sell_datetime": gt_leg["sell_datetime"],
|
||||
"leg_pct": gt_leg["leg_pct"],
|
||||
}
|
||||
)
|
||||
|
||||
gt_count = len(gt_legs)
|
||||
recall = len(captured) / gt_count if gt_count else 0.0
|
||||
|
||||
return {
|
||||
"gt_leg_count": gt_count,
|
||||
"tech_leg_count": len(tech_legs),
|
||||
"captured_count": len(captured),
|
||||
"missed_count": len(missed),
|
||||
"leg_recall": round(recall, 4),
|
||||
"captured": captured,
|
||||
"missed": missed,
|
||||
}
|
||||
|
||||
|
||||
def align_with_ground_truth(
|
||||
gt_result: dict[str, Any],
|
||||
technique_signals: list[dict[str, Any]],
|
||||
technique_legs: list[dict[str, Any]],
|
||||
tolerance_bars: int,
|
||||
) -> dict[str, Any]:
|
||||
"""GT 대비 기법 정합 결과 전체를 반환한다."""
|
||||
gt_signals = gt_result.get("signals", [])
|
||||
gt_legs = gt_result.get("legs", [])
|
||||
gt_pnl = gt_result.get("pnl", {})
|
||||
|
||||
buy_align = align_signals(gt_signals, technique_signals, tolerance_bars, "buy")
|
||||
sell_align = align_signals(gt_signals, technique_signals, tolerance_bars, "sell")
|
||||
leg_align = align_legs(gt_legs, technique_legs, tolerance_bars)
|
||||
|
||||
tech_return = 0.0
|
||||
if technique_legs:
|
||||
from deepcoin.ground_truth.pnl import simulate_gt_pnl
|
||||
|
||||
tech_pnl = simulate_gt_pnl(
|
||||
technique_legs,
|
||||
initial_cash_krw=gt_pnl.get("initial_cash_krw", 400_000),
|
||||
fee_rate=gt_pnl.get("fee_rate", 0.0005),
|
||||
)
|
||||
tech_return = tech_pnl["total_return_pct"]
|
||||
|
||||
gt_return = gt_pnl.get("total_return_pct", 0.0)
|
||||
return_capture = tech_return / gt_return if gt_return else 0.0
|
||||
|
||||
by_signal_type = align_all_signal_types(gt_signals, technique_signals, tolerance_bars)
|
||||
|
||||
return {
|
||||
"tolerance_bars": tolerance_bars,
|
||||
"buy": buy_align,
|
||||
"sell": sell_align,
|
||||
"legs": leg_align,
|
||||
"by_signal_type": by_signal_type,
|
||||
"gt_return_pct": gt_return,
|
||||
"tech_return_pct": tech_return,
|
||||
"return_capture_ratio": round(return_capture, 4),
|
||||
"score": round(
|
||||
(
|
||||
buy_align["recall"] * 0.25
|
||||
+ sell_align["recall"] * 0.25
|
||||
+ leg_align["leg_recall"] * 0.35
|
||||
+ min(return_capture, 1.0) * 0.15
|
||||
),
|
||||
4,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def align_signal_type(
|
||||
gt_signals: list[dict[str, Any]],
|
||||
tech_signals: list[dict[str, Any]],
|
||||
tolerance_bars: int,
|
||||
signal_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""특정 GT 신호 유형에 대한 기법 정합률을 계산한다.
|
||||
|
||||
Args:
|
||||
gt_signals: GT signals 리스트 (signal_type 필드 포함).
|
||||
tech_signals: 기법 신호 dict 리스트.
|
||||
tolerance_bars: 허용 봉 오차.
|
||||
signal_type: swing_low | pullback | breakout | div_bull | swing_high | div_bear.
|
||||
|
||||
Returns:
|
||||
신호 유형별 정합 메트릭 dict.
|
||||
"""
|
||||
side = SIGNAL_TYPE_SIDE.get(signal_type, "buy")
|
||||
gt_filtered = [
|
||||
s for s in gt_signals if s.get("signal_type") == signal_type and s.get("side") == side
|
||||
]
|
||||
result = align_signals(gt_filtered, tech_signals, tolerance_bars, side)
|
||||
result["signal_type"] = signal_type
|
||||
result["signal_label"] = SIGNAL_TYPE_LABELS.get(signal_type, signal_type)
|
||||
result["primary_techniques"] = SIGNAL_TYPE_PRIMARY_TECHNIQUES.get(signal_type, [])
|
||||
return result
|
||||
|
||||
|
||||
def align_all_signal_types(
|
||||
gt_signals: list[dict[str, Any]],
|
||||
tech_signals: list[dict[str, Any]],
|
||||
tolerance_bars: int,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""모든 GT 신호 유형에 대한 정합 결과를 반환한다."""
|
||||
present_types = {s.get("signal_type") for s in gt_signals if s.get("signal_type")}
|
||||
types_to_run = [t for t in GT_SIGNAL_TYPES if t in present_types]
|
||||
return {
|
||||
signal_type: align_signal_type(gt_signals, tech_signals, tolerance_bars, signal_type)
|
||||
for signal_type in types_to_run
|
||||
}
|
||||
|
||||
|
||||
def summarize_signal_type_matrix(
|
||||
technique_results: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""기법별·신호유형별 recall 매트릭스 요약을 생성한다.
|
||||
|
||||
Args:
|
||||
technique_results: technique_id, technique_name, by_signal_type 키를 가진 dict 리스트.
|
||||
|
||||
Returns:
|
||||
신호 유형별 최고 recall 기법이 포함된 행 리스트.
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for signal_type in GT_SIGNAL_TYPES:
|
||||
label = SIGNAL_TYPE_LABELS.get(signal_type, signal_type)
|
||||
primary = SIGNAL_TYPE_PRIMARY_TECHNIQUES.get(signal_type, [])
|
||||
entries: list[dict[str, Any]] = []
|
||||
|
||||
for tech in technique_results:
|
||||
by_type = tech.get("by_signal_type") or {}
|
||||
align = by_type.get(signal_type)
|
||||
if not align or align.get("gt_count", 0) == 0:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"technique_id": tech["technique_id"],
|
||||
"technique_name": tech["technique_name"],
|
||||
"recall": align.get("recall", 0.0),
|
||||
"hit_count": align.get("hit_count", 0),
|
||||
"gt_count": align.get("gt_count", 0),
|
||||
"avg_bar_offset": align.get("avg_bar_offset", 0.0),
|
||||
"is_primary": tech["technique_id"] in primary,
|
||||
}
|
||||
)
|
||||
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
entries.sort(key=lambda e: e["recall"], reverse=True)
|
||||
best = entries[0]
|
||||
gt_count = best["gt_count"]
|
||||
rows.append(
|
||||
{
|
||||
"signal_type": signal_type,
|
||||
"signal_label": label,
|
||||
"side": SIGNAL_TYPE_SIDE.get(signal_type, ""),
|
||||
"gt_count": gt_count,
|
||||
"primary_techniques": primary,
|
||||
"best_technique_id": best["technique_id"],
|
||||
"best_technique_name": best["technique_name"],
|
||||
"best_recall": best["recall"],
|
||||
"best_avg_offset": best["avg_bar_offset"],
|
||||
"ranking": entries,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
494
src/deepcoin/evaluation/mtf_report.py
Normal file
494
src/deepcoin/evaluation/mtf_report.py
Normal file
@@ -0,0 +1,494 @@
|
||||
"""GT v3 타점과 MTF 피처 상태 상관 분석 리포트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.evaluation.gt_align import SIGNAL_TYPE_LABELS, SIGNAL_TYPE_SIDE
|
||||
from deepcoin.mtf.extractor import MtfFeatureExtractor, MtfSnapshot
|
||||
from deepcoin.mtf.features import FEATURE_NAMES
|
||||
|
||||
NUMERIC_FEATURES: tuple[str, ...] = (
|
||||
"close_vs_ema60_pct",
|
||||
"ema60_slope_5_pct",
|
||||
"rsi14",
|
||||
"macd_hist",
|
||||
"bb_position",
|
||||
"atr_pct",
|
||||
"zigzag_leg_pct",
|
||||
)
|
||||
|
||||
CATEGORICAL_FEATURES: tuple[str, ...] = (
|
||||
"zigzag_direction",
|
||||
"trend_bias",
|
||||
)
|
||||
|
||||
|
||||
def _parse_dt(raw: str) -> pd.Timestamp:
|
||||
"""datetime 문자열을 Timestamp로 변환한다."""
|
||||
return pd.Timestamp(raw)
|
||||
|
||||
|
||||
def filter_signals_in_period(
|
||||
signals: list[dict[str, Any]],
|
||||
period_from: str,
|
||||
period_to: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""기간 내 GT 신호만 필터한다."""
|
||||
start = _parse_dt(period_from)
|
||||
end = _parse_dt(period_to)
|
||||
out: list[dict[str, Any]] = []
|
||||
for sig in signals:
|
||||
dt = _parse_dt(sig["datetime"])
|
||||
if start <= dt <= end:
|
||||
out.append(sig)
|
||||
return out
|
||||
|
||||
|
||||
def compute_sim_period(
|
||||
data_end: str,
|
||||
lookback_days: int,
|
||||
) -> tuple[str, str]:
|
||||
"""sim lookback 기준 분석 구간 시작·종료를 반환한다."""
|
||||
end = _parse_dt(data_end)
|
||||
start = end - timedelta(days=lookback_days)
|
||||
return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def build_excluded_bar_indices(
|
||||
signals: list[dict[str, Any]],
|
||||
exclude_bars: int,
|
||||
) -> set[int]:
|
||||
"""음성 샘플에서 제외할 3분봉 bar_index 집합."""
|
||||
excluded: set[int] = set()
|
||||
for sig in signals:
|
||||
center = int(sig["bar_index"])
|
||||
for offset in range(-exclude_bars, exclude_bars + 1):
|
||||
excluded.add(center + offset)
|
||||
return excluded
|
||||
|
||||
|
||||
def sample_negative_datetimes(
|
||||
base_df: pd.DataFrame,
|
||||
period_from: str,
|
||||
period_to: str,
|
||||
excluded_indices: set[int],
|
||||
sample_count: int,
|
||||
seed: int = 42,
|
||||
) -> list[str]:
|
||||
"""분석 구간에서 GT 주변을 제외한 랜덤 3분봉 시각을 샘플링한다."""
|
||||
start = _parse_dt(period_from)
|
||||
end = _parse_dt(period_to)
|
||||
mask = (base_df["datetime"] >= start) & (base_df["datetime"] <= end)
|
||||
candidates: list[str] = []
|
||||
for idx, row in base_df.loc[mask].iterrows():
|
||||
if int(idx) in excluded_indices:
|
||||
continue
|
||||
candidates.append(pd.Timestamp(row["datetime"]).strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
rng = random.Random(seed)
|
||||
if len(candidates) <= sample_count:
|
||||
return candidates
|
||||
return rng.sample(candidates, sample_count)
|
||||
|
||||
|
||||
def _cohens_d(group_a: list[float], group_b: list[float]) -> float | None:
|
||||
"""두 표본 간 Cohen's d 효과 크기."""
|
||||
if len(group_a) < 2 or len(group_b) < 2:
|
||||
return None
|
||||
mean_a = sum(group_a) / len(group_a)
|
||||
mean_b = sum(group_b) / len(group_b)
|
||||
var_a = sum((x - mean_a) ** 2 for x in group_a) / (len(group_a) - 1)
|
||||
var_b = sum((x - mean_b) ** 2 for x in group_b) / (len(group_b) - 1)
|
||||
pooled = math.sqrt(((len(group_a) - 1) * var_a + (len(group_b) - 1) * var_b) / (len(group_a) + len(group_b) - 2))
|
||||
if pooled == 0:
|
||||
return None
|
||||
return (mean_a - mean_b) / pooled
|
||||
|
||||
|
||||
def _feature_value(snapshot: MtfSnapshot, tf_label: str, feature: str) -> Any:
|
||||
"""스냅샷에서 TF·피처 값을 꺼낸다."""
|
||||
tf = snapshot.timeframes.get(tf_label, {})
|
||||
if not tf.get("available"):
|
||||
return None
|
||||
return tf.get(feature)
|
||||
|
||||
|
||||
def _summarize_numeric(
|
||||
pos_values: list[float],
|
||||
neg_values: list[float],
|
||||
) -> dict[str, Any]:
|
||||
"""수치 피처 양성·음성 분포 요약."""
|
||||
if not pos_values:
|
||||
return {"positive_count": 0, "negative_count": len(neg_values)}
|
||||
|
||||
pos_mean = sum(pos_values) / len(pos_values)
|
||||
neg_mean = sum(neg_values) / len(neg_values) if neg_values else None
|
||||
pos_sorted = sorted(pos_values)
|
||||
med_idx = len(pos_sorted) // 2
|
||||
pos_median = pos_sorted[med_idx]
|
||||
|
||||
return {
|
||||
"positive_count": len(pos_values),
|
||||
"negative_count": len(neg_values),
|
||||
"positive_mean": round(pos_mean, 4),
|
||||
"positive_median": round(pos_median, 4),
|
||||
"negative_mean": round(neg_mean, 4) if neg_mean is not None else None,
|
||||
"mean_delta": round(pos_mean - neg_mean, 4) if neg_mean is not None else None,
|
||||
"cohens_d": round(_cohens_d(pos_values, neg_values) or 0.0, 4)
|
||||
if neg_values and _cohens_d(pos_values, neg_values) is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _summarize_categorical(
|
||||
pos_values: list[str],
|
||||
neg_values: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""범주형 피처 분포 요약."""
|
||||
def _ratio(values: list[str], target: str) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(1 for v in values if v == target) / len(values), 4)
|
||||
|
||||
pos_counts: dict[str, int] = {}
|
||||
for v in pos_values:
|
||||
pos_counts[v] = pos_counts.get(v, 0) + 1
|
||||
|
||||
neg_counts: dict[str, int] = {}
|
||||
for v in neg_values:
|
||||
neg_counts[v] = neg_counts.get(v, 0) + 1
|
||||
|
||||
return {
|
||||
"positive_count": len(pos_values),
|
||||
"negative_count": len(neg_values),
|
||||
"positive_distribution": pos_counts,
|
||||
"negative_distribution": neg_counts,
|
||||
"positive_bullish_ratio": _ratio(pos_values, "bullish")
|
||||
if pos_values and pos_values[0] in ("bullish", "bearish")
|
||||
else None,
|
||||
"negative_bullish_ratio": _ratio(neg_values, "bullish")
|
||||
if neg_values and neg_values[0] in ("bullish", "bearish")
|
||||
else None,
|
||||
"positive_up_ratio": _ratio(pos_values, "up")
|
||||
if pos_values and pos_values[0] in ("up", "down", "none")
|
||||
else None,
|
||||
"negative_up_ratio": _ratio(neg_values, "up")
|
||||
if neg_values and neg_values[0] in ("up", "down", "none")
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def analyze_feature_correlations(
|
||||
labeled_snapshots: list[dict[str, Any]],
|
||||
negative_snapshots: list[MtfSnapshot],
|
||||
tf_labels: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""신호 유형·TF·피처별 GT vs 음성 비교."""
|
||||
neg_list = list(negative_snapshots)
|
||||
by_type: dict[str, list[MtfSnapshot]] = {}
|
||||
for item in labeled_snapshots:
|
||||
st = item["signal_type"]
|
||||
snap = item.get("snapshot")
|
||||
if isinstance(snap, MtfSnapshot):
|
||||
by_type.setdefault(st, []).append(snap)
|
||||
|
||||
type_reports: dict[str, Any] = {}
|
||||
|
||||
for signal_type, pos_snaps in sorted(by_type.items()):
|
||||
tf_report: dict[str, Any] = {}
|
||||
|
||||
for tf_label in tf_labels:
|
||||
numeric_rows: dict[str, Any] = {}
|
||||
for feat in NUMERIC_FEATURES:
|
||||
pos_vals: list[float] = []
|
||||
for snap in pos_snaps:
|
||||
val = _feature_value(snap, tf_label, feat)
|
||||
if val is not None and isinstance(val, (int, float)):
|
||||
pos_vals.append(float(val))
|
||||
|
||||
neg_vals: list[float] = []
|
||||
for snap in neg_list:
|
||||
val = _feature_value(snap, tf_label, feat)
|
||||
if val is not None and isinstance(val, (int, float)):
|
||||
neg_vals.append(float(val))
|
||||
|
||||
summary = _summarize_numeric(pos_vals, neg_vals)
|
||||
summary["feature"] = feat
|
||||
numeric_rows[feat] = summary
|
||||
|
||||
cat_rows: dict[str, Any] = {}
|
||||
for feat in CATEGORICAL_FEATURES:
|
||||
pos_vals = [
|
||||
str(v)
|
||||
for snap in pos_snaps
|
||||
if (v := _feature_value(snap, tf_label, feat)) is not None
|
||||
]
|
||||
neg_vals = [
|
||||
str(v)
|
||||
for snap in neg_list
|
||||
if (v := _feature_value(snap, tf_label, feat)) is not None
|
||||
]
|
||||
cat_rows[feat] = _summarize_categorical(pos_vals, neg_vals)
|
||||
|
||||
ranked = sorted(
|
||||
numeric_rows.values(),
|
||||
key=lambda r: abs(r.get("cohens_d") or 0.0),
|
||||
reverse=True,
|
||||
)
|
||||
tf_report[tf_label] = {
|
||||
"numeric": numeric_rows,
|
||||
"categorical": cat_rows,
|
||||
"top_numeric_features": ranked[:3],
|
||||
}
|
||||
|
||||
type_reports[signal_type] = {
|
||||
"label": SIGNAL_TYPE_LABELS.get(signal_type, signal_type),
|
||||
"side": SIGNAL_TYPE_SIDE.get(signal_type, ""),
|
||||
"sample_count": len(pos_snaps),
|
||||
"timeframes": tf_report,
|
||||
}
|
||||
|
||||
return type_reports
|
||||
|
||||
|
||||
def rank_global_feature_importance(
|
||||
type_reports: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""신호 유형·TF·피처별 |Cohen's d| 전역 순위."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for signal_type, block in type_reports.items():
|
||||
for tf_label, tf_data in block.get("timeframes", {}).items():
|
||||
for feat, summary in tf_data.get("numeric", {}).items():
|
||||
d = summary.get("cohens_d")
|
||||
if d is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"signal_type": signal_type,
|
||||
"signal_label": block.get("label", signal_type),
|
||||
"side": block.get("side", ""),
|
||||
"timeframe": tf_label,
|
||||
"feature": feat,
|
||||
"cohens_d": d,
|
||||
"abs_cohens_d": abs(d),
|
||||
"positive_mean": summary.get("positive_mean"),
|
||||
"negative_mean": summary.get("negative_mean"),
|
||||
"mean_delta": summary.get("mean_delta"),
|
||||
"positive_count": summary.get("positive_count", 0),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: r["abs_cohens_d"], reverse=True)
|
||||
return rows[:50]
|
||||
|
||||
|
||||
def build_mtf_correlation_report(
|
||||
gt_result: dict[str, Any],
|
||||
extractor: MtfFeatureExtractor,
|
||||
lookback_days: int,
|
||||
negative_sample_count: int = 2000,
|
||||
exclude_bars: int = 60,
|
||||
seed: int = 42,
|
||||
) -> dict[str, Any]:
|
||||
"""GT v3 타점 MTF 상관 리포트 본문을 생성한다.
|
||||
|
||||
Args:
|
||||
gt_result: Ground Truth JSON.
|
||||
extractor: MTF 피처 추출기.
|
||||
lookback_days: 분석 구간(일).
|
||||
negative_sample_count: 음성 샘플 수.
|
||||
exclude_bars: GT 주변 제외 3분봉 수.
|
||||
seed: 음성 샘플 RNG seed.
|
||||
|
||||
Returns:
|
||||
리포트 dict.
|
||||
"""
|
||||
meta = gt_result.get("meta", {})
|
||||
signals = gt_result.get("signals") or []
|
||||
data_end = meta.get("data_to", "")
|
||||
period_from, period_to = compute_sim_period(data_end, lookback_days)
|
||||
period_signals = filter_signals_in_period(signals, period_from, period_to)
|
||||
|
||||
tf_labels = [extractor.store.interval_label(iv) for iv in extractor.intervals]
|
||||
|
||||
labeled: list[dict[str, Any]] = []
|
||||
missing = 0
|
||||
for sig in period_signals:
|
||||
snap = extractor.extract_at(sig["datetime"])
|
||||
if snap is None:
|
||||
missing += 1
|
||||
continue
|
||||
labeled.append(
|
||||
{
|
||||
"signal_type": sig.get("signal_type", "unknown"),
|
||||
"side": sig.get("side", ""),
|
||||
"datetime": sig["datetime"],
|
||||
"bar_index": sig.get("bar_index"),
|
||||
"snapshot": snap,
|
||||
}
|
||||
)
|
||||
|
||||
excluded = build_excluded_bar_indices(period_signals, exclude_bars)
|
||||
neg_dts = sample_negative_datetimes(
|
||||
extractor.store.base_df,
|
||||
period_from,
|
||||
period_to,
|
||||
excluded,
|
||||
negative_sample_count,
|
||||
seed=seed,
|
||||
)
|
||||
neg_snaps = extractor.extract_many(neg_dts)
|
||||
|
||||
type_reports = analyze_feature_correlations(labeled, neg_snaps, tf_labels)
|
||||
global_rank = rank_global_feature_importance(type_reports)
|
||||
|
||||
type_counts: dict[str, int] = {}
|
||||
for sig in period_signals:
|
||||
st = sig.get("signal_type", "unknown")
|
||||
type_counts[st] = type_counts.get(st, 0) + 1
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"symbol": meta.get("symbol", ""),
|
||||
"chart_tier": meta.get("chart_tier", "v3"),
|
||||
"analysis": {
|
||||
"lookback_days": lookback_days,
|
||||
"period_from": period_from,
|
||||
"period_to": period_to,
|
||||
"base_interval_min": extractor.base_interval_min,
|
||||
"timeframes": [
|
||||
{"interval_min": iv, "label": extractor.store.interval_label(iv)}
|
||||
for iv in extractor.intervals
|
||||
],
|
||||
"feature_names": list(FEATURE_NAMES),
|
||||
"numeric_features": list(NUMERIC_FEATURES),
|
||||
"categorical_features": list(CATEGORICAL_FEATURES),
|
||||
"negative_exclude_bars": exclude_bars,
|
||||
"negative_sample_requested": negative_sample_count,
|
||||
"negative_sample_count": len(neg_snaps),
|
||||
},
|
||||
"gt": {
|
||||
"signals_in_period": len(period_signals),
|
||||
"snapshots_extracted": len(labeled),
|
||||
"snapshots_missing": missing,
|
||||
"signal_type_counts": type_counts,
|
||||
},
|
||||
"signal_type_labels": SIGNAL_TYPE_LABELS,
|
||||
"by_signal_type": type_reports,
|
||||
"global_feature_ranking": global_rank,
|
||||
}
|
||||
|
||||
|
||||
def save_mtf_report(report: dict[str, Any], json_path: Path) -> Path:
|
||||
"""MTF 상관 리포트 JSON 저장."""
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _serialize(obj: Any) -> Any:
|
||||
if isinstance(obj, MtfSnapshot):
|
||||
return obj.to_dict()
|
||||
raise TypeError(f"not serializable: {type(obj)}")
|
||||
|
||||
serializable = json.loads(json.dumps(report, default=_serialize, ensure_ascii=False))
|
||||
with json_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(serializable, fp, ensure_ascii=False, indent=2)
|
||||
return json_path
|
||||
|
||||
|
||||
def render_mtf_html(report: dict[str, Any], html_path: Path) -> Path:
|
||||
"""MTF 상관 리포트 HTML을 생성한다."""
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
analysis = report.get("analysis", {})
|
||||
gt = report.get("gt", {})
|
||||
ranking = report.get("global_feature_ranking", [])[:30]
|
||||
by_type = report.get("by_signal_type", {})
|
||||
|
||||
rank_rows = ""
|
||||
for row in ranking:
|
||||
rank_rows += (
|
||||
f"<tr>"
|
||||
f"<td>{row.get('signal_label', '')}</td>"
|
||||
f"<td>{row.get('timeframe', '')}</td>"
|
||||
f"<td>{row.get('feature', '')}</td>"
|
||||
f"<td>{row.get('cohens_d', '')}</td>"
|
||||
f"<td>{row.get('positive_mean', '')}</td>"
|
||||
f"<td>{row.get('negative_mean', '')}</td>"
|
||||
f"<td>{row.get('positive_count', '')}</td>"
|
||||
f"</tr>"
|
||||
)
|
||||
|
||||
type_sections = ""
|
||||
for signal_type, block in by_type.items():
|
||||
label = block.get("label", signal_type)
|
||||
count = block.get("sample_count", 0)
|
||||
tf_bits = []
|
||||
for tf_label, tf_data in block.get("timeframes", {}).items():
|
||||
tops = tf_data.get("top_numeric_features") or []
|
||||
if not tops:
|
||||
continue
|
||||
items = ", ".join(
|
||||
f"{t.get('feature')} (d={t.get('cohens_d')})"
|
||||
for t in tops[:2]
|
||||
)
|
||||
tf_bits.append(f"<li><b>{tf_label}</b>: {items}</li>")
|
||||
type_sections += (
|
||||
f"<section><h3>{label} ({signal_type}) — {count}건</h3>"
|
||||
f"<ul>{''.join(tf_bits) or '<li>데이터 없음</li>'}</ul></section>"
|
||||
)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>MTF GT v3 상관 분석</title>
|
||||
<style>
|
||||
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #fafafa; }}
|
||||
h1, h2, h3 {{ color: #222; }}
|
||||
.meta {{ background: #fff; border: 1px solid #ddd; padding: 16px; border-radius: 4px; margin-bottom: 20px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; background: #fff; margin: 12px 0 24px; }}
|
||||
th, td {{ border: 1px solid #eee; padding: 8px 10px; text-align: left; font-size: 13px; }}
|
||||
th {{ background: #f0f0f0; }}
|
||||
section {{ background: #fff; border: 1px solid #ddd; padding: 16px; margin-bottom: 16px; border-radius: 4px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>GT v3 · 멀티 TF 상태 상관 분석</h1>
|
||||
<div class="meta">
|
||||
<p><b>생성</b>: {report.get("generated_at", "")}</p>
|
||||
<p><b>구간</b>: {analysis.get("period_from", "")} ~ {analysis.get("period_to", "")}
|
||||
({analysis.get("lookback_days", "")}일)</p>
|
||||
<p><b>GT 신호</b>: {gt.get("signals_in_period", 0)}건 · 스냅샷 {gt.get("snapshots_extracted", 0)}건
|
||||
· 음성 샘플 {analysis.get("negative_sample_count", 0)}건</p>
|
||||
<p><b>TF</b>: {", ".join(t["label"] for t in analysis.get("timeframes", []))}</p>
|
||||
<p>양성=GT v3 타점 시각의 인과 MTF 스냅샷 / 음성=동일 구간 랜덤 3분봉(±{analysis.get("negative_exclude_bars")}봉 제외)</p>
|
||||
</div>
|
||||
|
||||
<h2>전역 피처 중요도 (|Cohen's d| 상위)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신호 유형</th><th>TF</th><th>피처</th><th>Cohen d</th>
|
||||
<th>GT 평균</th><th>음성 평균</th><th>GT n</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rank_rows or "<tr><td colspan='7'>없음</td></tr>"}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>신호 유형별 TF 요약</h2>
|
||||
{type_sections or "<p>없음</p>"}
|
||||
|
||||
<p style="font-size:12px;color:#666;">상세 수치는 JSON 리포트 참조.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
return html_path
|
||||
132
src/deepcoin/evaluation/report.py
Normal file
132
src/deepcoin/evaluation/report.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""2단계: 인과 기법 GT 정합 비교 리포트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deepcoin.techniques.base import TechniqueResult
|
||||
|
||||
|
||||
def build_comparison_report(
|
||||
results: list[TechniqueResult],
|
||||
gt_result: dict[str, Any],
|
||||
symbol: str,
|
||||
) -> dict[str, Any]:
|
||||
"""기법 비교 요약 리포트를 생성한다."""
|
||||
gt_meta = gt_result.get("meta", {})
|
||||
gt_pnl = gt_result.get("pnl", {})
|
||||
gt_summary = gt_result.get("summary", {})
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for result in results:
|
||||
align = result.alignment or {}
|
||||
buy = align.get("buy", {})
|
||||
sell = align.get("sell", {})
|
||||
legs = align.get("legs", {})
|
||||
rows.append(
|
||||
{
|
||||
"technique_id": result.technique_id,
|
||||
"technique_name": result.technique_name,
|
||||
"category": result.category,
|
||||
"causal": result.causal,
|
||||
"leg_count": result.summary.get("leg_count", 0),
|
||||
"tech_return_pct": result.pnl.get("total_return_pct", 0.0),
|
||||
"buy_recall": buy.get("recall", 0.0),
|
||||
"sell_recall": sell.get("recall", 0.0),
|
||||
"leg_recall": legs.get("leg_recall", 0.0),
|
||||
"return_capture_ratio": align.get("return_capture_ratio", 0.0),
|
||||
"score": align.get("score", 0.0),
|
||||
"avg_buy_offset": buy.get("avg_bar_offset", 0.0),
|
||||
"avg_sell_offset": sell.get("avg_bar_offset", 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: r["score"], reverse=True)
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"symbol": symbol,
|
||||
"gt": {
|
||||
"leg_count": gt_summary.get("leg_count", 0),
|
||||
"return_pct": gt_pnl.get("total_return_pct", 0.0),
|
||||
"interval_label": gt_meta.get("interval_label", ""),
|
||||
"lookback_days": gt_meta.get("lookback_days", 365),
|
||||
},
|
||||
"ranking": rows,
|
||||
}
|
||||
|
||||
|
||||
def save_comparison_report(report: dict[str, Any], json_path: Path) -> Path:
|
||||
"""비교 리포트 JSON을 저장한다."""
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with json_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(report, fp, ensure_ascii=False, indent=2)
|
||||
return json_path
|
||||
|
||||
|
||||
def render_comparison_html(report: dict[str, Any], html_path: Path) -> Path:
|
||||
"""비교 리포트 HTML을 생성한다."""
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rows = report.get("ranking", [])
|
||||
gt = report.get("gt", {})
|
||||
|
||||
table_rows = ""
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
table_rows += f"""
|
||||
<tr>
|
||||
<td>{idx}</td>
|
||||
<td>{row['technique_name']}</td>
|
||||
<td>{row['category']}</td>
|
||||
<td>{'Y' if row['causal'] else 'N'}</td>
|
||||
<td>{row['leg_count']}</td>
|
||||
<td>{row['tech_return_pct']:+.1f}%</td>
|
||||
<td>{row['buy_recall']*100:.1f}%</td>
|
||||
<td>{row['sell_recall']*100:.1f}%</td>
|
||||
<td>{row['leg_recall']*100:.1f}%</td>
|
||||
<td>{row['return_capture_ratio']*100:.1f}%</td>
|
||||
<td><strong>{row['score']*100:.1f}</strong></td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>DeepCoin 2단계 — 인과 GT 정합</title>
|
||||
<style>
|
||||
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #f5f5f5; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 8px; }}
|
||||
.meta {{ color: #666; margin-bottom: 20px; font-size: 14px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; background: #fff; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px 10px; text-align: center; font-size: 13px; }}
|
||||
th {{ background: #e8e8e8; }}
|
||||
tr:nth-child(even) {{ background: #fafafa; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>DeepCoin 2단계 — 인과 기법 Ground Truth 정합</h1>
|
||||
<div class="meta">
|
||||
생성: {report.get('generated_at', '')} |
|
||||
{report.get('symbol', '')} |
|
||||
GT: {gt.get('leg_count', 0)}레그, {gt.get('return_pct', 0):+.1f}% |
|
||||
기간: 최근 {gt.get('lookback_days', 365)}일
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순위</th><th>기법</th><th>유형</th><th>인과</th><th>레그</th>
|
||||
<th>수익률</th><th>매수 Recall</th><th>매도 Recall</th><th>레그 Recall</th>
|
||||
<th>수익 포착</th><th>종합 Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{table_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with html_path.open("w", encoding="utf-8") as fp:
|
||||
fp.write(html)
|
||||
return html_path
|
||||
216
src/deepcoin/evaluation/signal_type_report.py
Normal file
216
src/deepcoin/evaluation/signal_type_report.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""v3 GT 신호 유형별 기법 정합 리포트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deepcoin.evaluation.gt_align import (
|
||||
SIGNAL_TYPE_LABELS,
|
||||
SIGNAL_TYPE_PRIMARY_TECHNIQUES,
|
||||
summarize_signal_type_matrix,
|
||||
)
|
||||
from deepcoin.techniques.base import TechniqueResult
|
||||
|
||||
|
||||
def build_signal_type_report(
|
||||
results: list[TechniqueResult],
|
||||
gt_result: dict[str, Any],
|
||||
symbol: str,
|
||||
) -> dict[str, Any]:
|
||||
"""신호 유형별 GT 정합 리포트를 생성한다.
|
||||
|
||||
Args:
|
||||
results: 기법 실행 결과 리스트.
|
||||
gt_result: Ground Truth JSON dict.
|
||||
symbol: 거래 심볼.
|
||||
|
||||
Returns:
|
||||
리포트 dict.
|
||||
"""
|
||||
gt_meta = gt_result.get("meta", {})
|
||||
gt_summary = gt_result.get("summary", {})
|
||||
gt_signals = gt_result.get("signals", [])
|
||||
|
||||
type_counts: dict[str, int] = {}
|
||||
for sig in gt_signals:
|
||||
st = sig.get("signal_type", "")
|
||||
if st:
|
||||
type_counts[st] = type_counts.get(st, 0) + 1
|
||||
|
||||
tech_rows: list[dict[str, Any]] = []
|
||||
for result in results:
|
||||
align = result.alignment or {}
|
||||
by_type = align.get("by_signal_type") or {}
|
||||
tech_rows.append(
|
||||
{
|
||||
"technique_id": result.technique_id,
|
||||
"technique_name": result.technique_name,
|
||||
"category": result.category,
|
||||
"causal": result.causal,
|
||||
"score": align.get("score", 0.0),
|
||||
"by_signal_type": by_type,
|
||||
}
|
||||
)
|
||||
|
||||
matrix = summarize_signal_type_matrix(tech_rows)
|
||||
|
||||
technique_detail: list[dict[str, Any]] = []
|
||||
for result in results:
|
||||
align = result.alignment or {}
|
||||
by_type = align.get("by_signal_type") or {}
|
||||
type_recalls: dict[str, Any] = {}
|
||||
for signal_type, type_align in by_type.items():
|
||||
type_recalls[signal_type] = {
|
||||
"label": type_align.get("signal_label", signal_type),
|
||||
"gt_count": type_align.get("gt_count", 0),
|
||||
"hit_count": type_align.get("hit_count", 0),
|
||||
"recall": type_align.get("recall", 0.0),
|
||||
"avg_bar_offset": type_align.get("avg_bar_offset", 0.0),
|
||||
"is_primary": result.technique_id
|
||||
in SIGNAL_TYPE_PRIMARY_TECHNIQUES.get(signal_type, []),
|
||||
}
|
||||
technique_detail.append(
|
||||
{
|
||||
"technique_id": result.technique_id,
|
||||
"technique_name": result.technique_name,
|
||||
"category": result.category,
|
||||
"overall_score": align.get("score", 0.0),
|
||||
"signal_types": type_recalls,
|
||||
}
|
||||
)
|
||||
|
||||
technique_detail.sort(key=lambda r: r["overall_score"], reverse=True)
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"symbol": symbol,
|
||||
"chart_tier": gt_meta.get("chart_tier", "v3"),
|
||||
"gt": {
|
||||
"interval_label": gt_meta.get("interval_label", ""),
|
||||
"lookback_days": gt_meta.get("lookback_days", 730),
|
||||
"signal_type_counts": type_counts,
|
||||
"buy_count": gt_summary.get("buy_count", 0),
|
||||
"sell_count": gt_summary.get("sell_count", 0),
|
||||
"leg_count": gt_summary.get("leg_count", 0),
|
||||
},
|
||||
"signal_type_labels": SIGNAL_TYPE_LABELS,
|
||||
"primary_technique_map": SIGNAL_TYPE_PRIMARY_TECHNIQUES,
|
||||
"best_by_signal_type": matrix,
|
||||
"techniques": technique_detail,
|
||||
}
|
||||
|
||||
|
||||
def save_signal_type_report(report: dict[str, Any], json_path: Path) -> Path:
|
||||
"""신호 유형 리포트 JSON을 저장한다."""
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with json_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(report, fp, ensure_ascii=False, indent=2)
|
||||
return json_path
|
||||
|
||||
|
||||
def render_signal_type_html(report: dict[str, Any], html_path: Path) -> Path:
|
||||
"""신호 유형 리포트 HTML을 생성한다."""
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
gt = report.get("gt", {})
|
||||
counts = gt.get("signal_type_counts", {})
|
||||
best_rows = report.get("best_by_signal_type", [])
|
||||
techniques = report.get("techniques", [])
|
||||
|
||||
count_cells = ""
|
||||
for signal_type, label in report.get("signal_type_labels", {}).items():
|
||||
count = counts.get(signal_type, 0)
|
||||
if count:
|
||||
count_cells += f"<tr><td>{label}</td><td>{signal_type}</td><td>{count}</td></tr>"
|
||||
|
||||
best_table = ""
|
||||
for row in best_rows:
|
||||
primary = ", ".join(row.get("primary_techniques", []))
|
||||
best_table += f"""
|
||||
<tr>
|
||||
<td>{row['signal_label']}</td>
|
||||
<td>{row['gt_count']}</td>
|
||||
<td>{primary}</td>
|
||||
<td><strong>{row['best_technique_name']}</strong></td>
|
||||
<td>{row['best_recall']*100:.1f}%</td>
|
||||
<td>{row['best_avg_offset']:.1f}봉</td>
|
||||
</tr>"""
|
||||
|
||||
tech_headers = "".join(
|
||||
f"<th>{label.split('(')[0].strip()}</th>"
|
||||
for label in report.get("signal_type_labels", {}).values()
|
||||
)
|
||||
tech_rows = ""
|
||||
for tech in techniques:
|
||||
cells = ""
|
||||
for signal_type in report.get("signal_type_labels", {}):
|
||||
st = tech.get("signal_types", {}).get(signal_type)
|
||||
if st and st.get("gt_count", 0) > 0:
|
||||
star = "*" if st.get("is_primary") else ""
|
||||
cells += f"<td>{st['recall']*100:.0f}%{star}</td>"
|
||||
else:
|
||||
cells += "<td>-</td>"
|
||||
tech_rows += f"""
|
||||
<tr>
|
||||
<td>{tech['technique_name']}</td>
|
||||
<td>{tech['overall_score']*100:.1f}</td>
|
||||
{cells}
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>DeepCoin — v3 신호 유형별 GT 정합</title>
|
||||
<style>
|
||||
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; color: #333; background: #f5f5f5; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||
h2 {{ font-size: 16px; margin-top: 28px; margin-bottom: 8px; }}
|
||||
.meta {{ color: #666; margin-bottom: 20px; font-size: 14px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; background: #fff; margin-bottom: 16px; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px 10px; text-align: center; font-size: 13px; }}
|
||||
th {{ background: #e8e8e8; }}
|
||||
tr:nth-child(even) {{ background: #fafafa; }}
|
||||
.note {{ font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>DeepCoin — v3 신호 유형별 Ground Truth 정합</h1>
|
||||
<div class="meta">
|
||||
생성: {report.get('generated_at', '')} |
|
||||
{report.get('symbol', '')} |
|
||||
Tier: {report.get('chart_tier', 'v3')} |
|
||||
기간: 최근 {gt.get('lookback_days', 730)}일 |
|
||||
매수 {gt.get('buy_count', 0)} / 매도 {gt.get('sell_count', 0)} / 레그 {gt.get('leg_count', 0)}
|
||||
</div>
|
||||
|
||||
<h2>GT 신호 유형 분포</h2>
|
||||
<table>
|
||||
<thead><tr><th>라벨</th><th>signal_type</th><th>건수</th></tr></thead>
|
||||
<tbody>{count_cells}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>신호 유형별 최고 Recall 기법</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>신호 유형</th><th>GT 건수</th><th>1차 기법</th><th>최고 기법</th><th>Recall</th><th>평균 오차</th></tr>
|
||||
</thead>
|
||||
<tbody>{best_table}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>기법 × 신호 유형 Recall 매트릭스</h2>
|
||||
<p class="note">* = 해당 신호 유형 1차 정합 대상 기법</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>기법</th><th>종합 Score</th>{tech_headers}</tr>
|
||||
</thead>
|
||||
<tbody>{tech_rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with html_path.open("w", encoding="utf-8") as fp:
|
||||
fp.write(html)
|
||||
return html_path
|
||||
1
src/deepcoin/ground_truth/__init__.py
Normal file
1
src/deepcoin/ground_truth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Ground Truth — 사후 벤치마크 매수·매도 타점."""
|
||||
162
src/deepcoin/ground_truth/breakout.py
Normal file
162
src/deepcoin/ground_truth/breakout.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""돌파 매수 타점 (Ground Truth 보조, 사후 최적)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.ground_truth.zigzag import Pivot
|
||||
|
||||
|
||||
class _LegLike(Protocol):
|
||||
"""레그 최소 인터페이스."""
|
||||
|
||||
leg_id: int
|
||||
buy_bar_index: int
|
||||
sell_bar_index: int
|
||||
sell_price: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BreakoutBuy:
|
||||
"""돌파 매수 타점."""
|
||||
|
||||
bar_index: int
|
||||
price: float
|
||||
datetime: pd.Timestamp
|
||||
leg_id: int
|
||||
resistance_price: float
|
||||
|
||||
|
||||
def find_breakout_buy_pivots(
|
||||
df: pd.DataFrame,
|
||||
legs: list[_LegLike],
|
||||
pullback_buys: list[Pivot],
|
||||
breakout_buffer_pct: float = 0.1,
|
||||
min_bars_after_anchor: int = 10,
|
||||
consolidation_bars: int = 200,
|
||||
leg_end_margin_bars: int = 80,
|
||||
min_rally_to_sell_pct: float = 2.0,
|
||||
) -> list[BreakoutBuy]:
|
||||
"""각 레그에서 횡보·눌림 후 저항 돌파 시점을 찾는다 (1단계 GT, 미래 허용).
|
||||
|
||||
눌림목 이후 구간 고점을 저항으로 보고, 종가가 저항을 돌파한
|
||||
첫 봉을 레그당 돌파 매수 1개로 표시한다.
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame.
|
||||
legs: ZigZag 스윙 레그.
|
||||
pullback_buys: 눌림목 Pivot (레그별 매칭).
|
||||
breakout_buffer_pct: 저항 대비 돌파 확인 버퍼(%).
|
||||
min_bars_after_anchor: 눌림목/구간 시작 후 최소 대기 봉.
|
||||
consolidation_bars: 횡보 저항 산정 구간(봉).
|
||||
leg_end_margin_bars: 매도 직전 제외 봉.
|
||||
min_rally_to_sell_pct: 돌파 후 레그 고점까지 최소 추가 상승(%).
|
||||
|
||||
Returns:
|
||||
BreakoutBuy 리스트.
|
||||
"""
|
||||
if not legs or len(df) < 20:
|
||||
return []
|
||||
|
||||
highs = df["high"].astype(float).values
|
||||
closes = df["close"].astype(float).values
|
||||
pullback_by_leg = _map_pullback_to_legs(legs, pullback_buys)
|
||||
breakouts: list[BreakoutBuy] = []
|
||||
buffer = breakout_buffer_pct / 100.0
|
||||
|
||||
for leg in legs:
|
||||
anchor = pullback_by_leg.get(leg.leg_id)
|
||||
if anchor is not None:
|
||||
zone_start = anchor.bar_index
|
||||
else:
|
||||
leg_len = leg.sell_bar_index - leg.buy_bar_index
|
||||
zone_start = max(
|
||||
leg.buy_bar_index + 120,
|
||||
leg.sell_bar_index - int(leg_len * 0.35),
|
||||
)
|
||||
|
||||
zone_end = min(
|
||||
zone_start + consolidation_bars,
|
||||
leg.sell_bar_index - leg_end_margin_bars - 5,
|
||||
)
|
||||
search_start = zone_start + min_bars_after_anchor
|
||||
search_end = leg.sell_bar_index - leg_end_margin_bars
|
||||
if zone_end <= zone_start or search_end <= search_start + 5:
|
||||
continue
|
||||
|
||||
resistance = float(highs[zone_start:zone_end].max())
|
||||
if resistance <= 0:
|
||||
continue
|
||||
|
||||
breakout = _first_breakout_above(
|
||||
df=df,
|
||||
highs=highs,
|
||||
closes=closes,
|
||||
search_start=max(search_start, zone_end),
|
||||
search_end=search_end,
|
||||
resistance=resistance,
|
||||
buffer=buffer,
|
||||
sell_price=float(leg.sell_price),
|
||||
min_rally_pct=min_rally_to_sell_pct,
|
||||
)
|
||||
if breakout is None:
|
||||
continue
|
||||
|
||||
bar_idx, price, resistance = breakout
|
||||
breakouts.append(
|
||||
BreakoutBuy(
|
||||
bar_index=bar_idx,
|
||||
price=round(price, 2),
|
||||
datetime=pd.Timestamp(df.iloc[bar_idx]["datetime"]),
|
||||
leg_id=leg.leg_id,
|
||||
resistance_price=round(resistance, 2),
|
||||
)
|
||||
)
|
||||
|
||||
return breakouts
|
||||
|
||||
|
||||
def _map_pullback_to_legs(legs: list[_LegLike], pullback_buys: list[Pivot]) -> dict[int, Pivot]:
|
||||
"""눌림목을 소속 레그에 매핑한다."""
|
||||
mapping: dict[int, Pivot] = {}
|
||||
for pivot in pullback_buys:
|
||||
for leg in legs:
|
||||
if leg.buy_bar_index < pivot.bar_index < leg.sell_bar_index:
|
||||
prev = mapping.get(leg.leg_id)
|
||||
if prev is None or pivot.bar_index > prev.bar_index:
|
||||
mapping[leg.leg_id] = pivot
|
||||
return mapping
|
||||
|
||||
|
||||
def _first_breakout_above(
|
||||
df: pd.DataFrame,
|
||||
highs,
|
||||
closes,
|
||||
search_start: int,
|
||||
search_end: int,
|
||||
resistance: float,
|
||||
buffer: float,
|
||||
sell_price: float,
|
||||
min_rally_pct: float,
|
||||
) -> tuple[int, float, float] | None:
|
||||
"""고정 저항선 상향 돌파 첫 봉을 반환한다."""
|
||||
threshold = resistance * (1 + buffer)
|
||||
|
||||
for i in range(search_start, search_end):
|
||||
close_val = float(closes[i])
|
||||
if close_val <= threshold:
|
||||
continue
|
||||
|
||||
future_max = float(highs[i : search_end + 1].max())
|
||||
rally_pct = (future_max - close_val) / close_val * 100.0
|
||||
sell_rally_pct = (sell_price - close_val) / close_val * 100.0
|
||||
|
||||
if rally_pct < min_rally_pct or sell_rally_pct < min_rally_pct:
|
||||
continue
|
||||
|
||||
return i, close_val, resistance
|
||||
|
||||
return None
|
||||
1005
src/deepcoin/ground_truth/chart.py
Normal file
1005
src/deepcoin/ground_truth/chart.py
Normal file
File diff suppressed because it is too large
Load Diff
303
src/deepcoin/ground_truth/divergence.py
Normal file
303
src/deepcoin/ground_truth/divergence.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""RSI·MACD 다이버전스 매수·매도 타점 (Ground Truth, 사후 검증)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.indicators import macd, rsi
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DivergenceSignal:
|
||||
"""다이버전스 신호."""
|
||||
|
||||
side: str # buy | sell
|
||||
bar_index: int
|
||||
price: float
|
||||
datetime: pd.Timestamp
|
||||
indicator: str # rsi | macd_hist
|
||||
price_prev: float
|
||||
price_curr: float
|
||||
ind_prev: float
|
||||
ind_curr: float
|
||||
|
||||
|
||||
def find_divergence_signals(
|
||||
df: pd.DataFrame,
|
||||
local_order: int = 15,
|
||||
min_bars_between: int = 100,
|
||||
max_pair_lookback_bars: int = 5000,
|
||||
rsi_period: int = 14,
|
||||
min_rsi_diff: float = 2.0,
|
||||
min_macd_hist_diff: float = 0.0,
|
||||
min_price_move_pct: float = 1.5,
|
||||
future_bars: int = 2000,
|
||||
min_future_move_pct: float = 2.0,
|
||||
) -> tuple[list[DivergenceSignal], list[DivergenceSignal]]:
|
||||
"""가격·지표 다이버전스 매수·매도 후보를 찾는다.
|
||||
|
||||
- 상승 다이버전스(매수): 가격 LL + RSI/MACD HL
|
||||
- 하락 다이버전스(매도): 가격 HH + RSI/MACD LH
|
||||
- 미래 데이터로 이후 유의미한 반등·하락을 사후 검증
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame.
|
||||
local_order: 국소 극값 반경(봉).
|
||||
min_bars_between: 연속 다이버전스 최소 간격(봉).
|
||||
max_pair_lookback_bars: 비교할 이전 극값 최대 거리(봉).
|
||||
rsi_period: RSI 기간.
|
||||
min_rsi_diff: RSI 다이버전스 최소 차이(포인트).
|
||||
min_macd_hist_diff: MACD 히스토그램 최소 차이.
|
||||
min_price_move_pct: 극값 간 최소 가격 변동(%).
|
||||
future_bars: 사후 검증 구간(봉).
|
||||
min_future_move_pct: 사후 최소 가격 변동(%).
|
||||
|
||||
Returns:
|
||||
(매수 신호, 매도 신호) 리스트.
|
||||
"""
|
||||
if len(df) < local_order * 4 + rsi_period:
|
||||
return [], []
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
rsi_vals = rsi(close, period=rsi_period)
|
||||
_, _, macd_hist = macd(close)
|
||||
|
||||
lows = _find_local_extrema(df, low, rsi_vals, macd_hist, local_order, "low")
|
||||
highs = _find_local_extrema(df, high, rsi_vals, macd_hist, local_order, "high")
|
||||
|
||||
bull_rsi = _detect_bullish(
|
||||
df, lows, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct,
|
||||
max_pair_lookback_bars, future_bars, min_future_move_pct, high,
|
||||
)
|
||||
bear_rsi = _detect_bearish(
|
||||
df, highs, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct,
|
||||
max_pair_lookback_bars, future_bars, min_future_move_pct, low,
|
||||
)
|
||||
|
||||
bull_macd: list[DivergenceSignal] = []
|
||||
bear_macd: list[DivergenceSignal] = []
|
||||
if min_macd_hist_diff > 0:
|
||||
bull_macd = _detect_bullish(
|
||||
df, lows, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct,
|
||||
max_pair_lookback_bars, future_bars, min_future_move_pct, high,
|
||||
)
|
||||
bear_macd = _detect_bearish(
|
||||
df, highs, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct,
|
||||
max_pair_lookback_bars, future_bars, min_future_move_pct, low,
|
||||
)
|
||||
|
||||
buys = _dedupe_signals(
|
||||
_merge_same_bar(bull_rsi + bull_macd), min_bars_between, prefer_lower=True
|
||||
)
|
||||
sells = _dedupe_signals(
|
||||
_merge_same_bar(bear_rsi + bear_macd), min_bars_between, prefer_lower=False
|
||||
)
|
||||
return buys, sells
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ExtremePoint:
|
||||
"""국소 극값."""
|
||||
|
||||
bar_index: int
|
||||
price: float
|
||||
rsi: float
|
||||
macd_hist: float
|
||||
datetime: pd.Timestamp
|
||||
|
||||
|
||||
def _find_local_extrema(
|
||||
df: pd.DataFrame,
|
||||
series: pd.Series,
|
||||
rsi_vals: pd.Series,
|
||||
macd_hist: pd.Series,
|
||||
order: int,
|
||||
side: str,
|
||||
) -> list[_ExtremePoint]:
|
||||
"""국소 저점·고점을 수집한다."""
|
||||
points: list[_ExtremePoint] = []
|
||||
values = series.values
|
||||
|
||||
for i in range(order, len(df) - order):
|
||||
window = values[i - order : i + order + 1]
|
||||
val = float(values[i])
|
||||
if side == "low" and val > window.min():
|
||||
continue
|
||||
if side == "high" and val < window.max():
|
||||
continue
|
||||
if pd.isna(rsi_vals.iloc[i]) or pd.isna(macd_hist.iloc[i]):
|
||||
continue
|
||||
points.append(
|
||||
_ExtremePoint(
|
||||
bar_index=i,
|
||||
price=val,
|
||||
rsi=float(rsi_vals.iloc[i]),
|
||||
macd_hist=float(macd_hist.iloc[i]),
|
||||
datetime=pd.Timestamp(df.iloc[i]["datetime"]),
|
||||
)
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def _detect_bullish(
|
||||
df: pd.DataFrame,
|
||||
lows: list[_ExtremePoint],
|
||||
indicator: pd.Series,
|
||||
indicator_name: str,
|
||||
min_ind_diff: float,
|
||||
min_price_move_pct: float,
|
||||
max_lookback: int,
|
||||
future_bars: int,
|
||||
min_future_move_pct: float,
|
||||
highs,
|
||||
) -> list[DivergenceSignal]:
|
||||
"""상승 다이버전스 매수를 탐지한다."""
|
||||
signals: list[DivergenceSignal] = []
|
||||
if len(lows) < 2:
|
||||
return signals
|
||||
|
||||
for idx in range(1, len(lows)):
|
||||
curr = lows[idx]
|
||||
for prev in reversed(lows[:idx]):
|
||||
if curr.bar_index - prev.bar_index > max_lookback:
|
||||
break
|
||||
if curr.bar_index - prev.bar_index < 20:
|
||||
continue
|
||||
|
||||
price_move = (prev.price - curr.price) / prev.price * 100.0
|
||||
if price_move < min_price_move_pct:
|
||||
continue
|
||||
|
||||
ind_prev = float(indicator.iloc[prev.bar_index])
|
||||
ind_curr = float(indicator.iloc[curr.bar_index])
|
||||
if pd.isna(ind_prev) or pd.isna(ind_curr):
|
||||
continue
|
||||
if not (curr.price < prev.price and ind_curr > ind_prev + min_ind_diff):
|
||||
continue
|
||||
|
||||
end = min(len(df), curr.bar_index + future_bars + 1)
|
||||
future_high = float(highs[curr.bar_index:end].max())
|
||||
rally = (future_high - curr.price) / curr.price * 100.0
|
||||
if rally < min_future_move_pct:
|
||||
continue
|
||||
|
||||
signals.append(
|
||||
DivergenceSignal(
|
||||
side="buy",
|
||||
bar_index=curr.bar_index,
|
||||
price=round(curr.price, 2),
|
||||
datetime=curr.datetime,
|
||||
indicator=indicator_name,
|
||||
price_prev=round(prev.price, 2),
|
||||
price_curr=round(curr.price, 2),
|
||||
ind_prev=round(ind_prev, 4),
|
||||
ind_curr=round(ind_curr, 4),
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def _detect_bearish(
|
||||
df: pd.DataFrame,
|
||||
highs: list[_ExtremePoint],
|
||||
indicator: pd.Series,
|
||||
indicator_name: str,
|
||||
min_ind_diff: float,
|
||||
min_price_move_pct: float,
|
||||
max_lookback: int,
|
||||
future_bars: int,
|
||||
min_future_move_pct: float,
|
||||
lows,
|
||||
) -> list[DivergenceSignal]:
|
||||
"""하락 다이버전스 매도를 탐지한다."""
|
||||
signals: list[DivergenceSignal] = []
|
||||
if len(highs) < 2:
|
||||
return signals
|
||||
|
||||
for idx in range(1, len(highs)):
|
||||
curr = highs[idx]
|
||||
for prev in reversed(highs[:idx]):
|
||||
if curr.bar_index - prev.bar_index > max_lookback:
|
||||
break
|
||||
if curr.bar_index - prev.bar_index < 20:
|
||||
continue
|
||||
|
||||
price_move = (curr.price - prev.price) / prev.price * 100.0
|
||||
if price_move < min_price_move_pct:
|
||||
continue
|
||||
|
||||
ind_prev = float(indicator.iloc[prev.bar_index])
|
||||
ind_curr = float(indicator.iloc[curr.bar_index])
|
||||
if pd.isna(ind_prev) or pd.isna(ind_curr):
|
||||
continue
|
||||
if not (curr.price > prev.price and ind_curr < ind_prev - min_ind_diff):
|
||||
continue
|
||||
|
||||
end = min(len(df), curr.bar_index + future_bars + 1)
|
||||
future_low = float(lows[curr.bar_index:end].min())
|
||||
drop = (curr.price - future_low) / curr.price * 100.0
|
||||
if drop < min_future_move_pct:
|
||||
continue
|
||||
|
||||
signals.append(
|
||||
DivergenceSignal(
|
||||
side="sell",
|
||||
bar_index=curr.bar_index,
|
||||
price=round(curr.price, 2),
|
||||
datetime=curr.datetime,
|
||||
indicator=indicator_name,
|
||||
price_prev=round(prev.price, 2),
|
||||
price_curr=round(curr.price, 2),
|
||||
ind_prev=round(ind_prev, 4),
|
||||
ind_curr=round(ind_curr, 4),
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def _merge_same_bar(signals: list[DivergenceSignal]) -> list[DivergenceSignal]:
|
||||
"""동일 봉·동일 방향 신호를 하나로 합친다."""
|
||||
by_bar: dict[int, DivergenceSignal] = {}
|
||||
for signal in signals:
|
||||
prev = by_bar.get(signal.bar_index)
|
||||
if prev is None:
|
||||
by_bar[signal.bar_index] = signal
|
||||
continue
|
||||
prev_diff = abs(prev.ind_curr - prev.ind_prev)
|
||||
curr_diff = abs(signal.ind_curr - signal.ind_prev)
|
||||
if curr_diff > prev_diff:
|
||||
by_bar[signal.bar_index] = signal
|
||||
return sorted(by_bar.values(), key=lambda s: s.bar_index)
|
||||
|
||||
|
||||
def _dedupe_signals(
|
||||
signals: list[DivergenceSignal],
|
||||
min_bars: int,
|
||||
prefer_lower: bool,
|
||||
) -> list[DivergenceSignal]:
|
||||
"""근접 신호를 병합한다."""
|
||||
if not signals:
|
||||
return []
|
||||
|
||||
sorted_signals = sorted(signals, key=lambda s: s.bar_index)
|
||||
merged: list[DivergenceSignal] = [sorted_signals[0]]
|
||||
|
||||
for signal in sorted_signals[1:]:
|
||||
last = merged[-1]
|
||||
if signal.bar_index - last.bar_index < min_bars:
|
||||
if prefer_lower and signal.price < last.price:
|
||||
merged[-1] = signal
|
||||
elif not prefer_lower and signal.price > last.price:
|
||||
merged[-1] = signal
|
||||
else:
|
||||
merged.append(signal)
|
||||
|
||||
return merged
|
||||
128
src/deepcoin/ground_truth/futures.py
Normal file
128
src/deepcoin/ground_truth/futures.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""현물 Ground Truth 신호를 선물 롱·숏 타점으로 변환한다."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def futures_events_from_gt_signals(
|
||||
signals: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""현물 GT 신호를 선물 이벤트 스트림으로 변환한다.
|
||||
|
||||
동일 봉에서 청산 이벤트를 먼저, 진입 이벤트를 나중에 배치한다.
|
||||
|
||||
Args:
|
||||
signals: GT signals 리스트.
|
||||
|
||||
Returns:
|
||||
position, action, event_order 등이 포함된 이벤트 리스트.
|
||||
"""
|
||||
events: list[dict[str, Any]] = []
|
||||
for sig in signals:
|
||||
side = sig["side"]
|
||||
signal_type = sig.get(
|
||||
"signal_type",
|
||||
"swing_low" if side == "buy" else "swing_high",
|
||||
)
|
||||
base = {
|
||||
"datetime": sig["datetime"],
|
||||
"price": sig["price"],
|
||||
"bar_index": sig.get("bar_index", 0),
|
||||
"marker_id": sig.get("marker_id", sig.get("leg_id")),
|
||||
"signal_type": signal_type,
|
||||
"leg_id": sig.get("leg_id"),
|
||||
}
|
||||
if side == "buy":
|
||||
events.append({**base, "position": "short", "action": "close", "event_order": 0})
|
||||
events.append({**base, "position": "long", "action": "open", "event_order": 1})
|
||||
else:
|
||||
events.append({**base, "position": "long", "action": "close", "event_order": 0})
|
||||
events.append({**base, "position": "short", "action": "open", "event_order": 1})
|
||||
return events
|
||||
|
||||
|
||||
def futures_markers_from_gt_signals(
|
||||
gt_result: dict[str, Any],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""현물 GT 신호를 선물 4종 마커(상·하방 매수·매도)로 변환한다.
|
||||
|
||||
현물 GT의 스윙 저점(buy)과 고점(sell)을 동일 가격·시각에
|
||||
롱·숏 양방향 선물 타점으로 매핑한다.
|
||||
|
||||
- buy → 상방 매수(롱 진입), 하방 매도(숏 청산)
|
||||
- sell → 상방 매도(롱 청산), 하방 매수(숏 진입)
|
||||
|
||||
Args:
|
||||
gt_result: build_ground_truth 결과 또는 동일 스키마 JSON.
|
||||
|
||||
Returns:
|
||||
long_open, long_close, short_open, short_close 마커 리스트.
|
||||
"""
|
||||
long_open: list[dict[str, Any]] = []
|
||||
long_close: list[dict[str, Any]] = []
|
||||
short_open: list[dict[str, Any]] = []
|
||||
short_close: list[dict[str, Any]] = []
|
||||
|
||||
for sig in gt_result.get("signals") or []:
|
||||
side = sig["side"]
|
||||
signal_type = sig.get(
|
||||
"signal_type",
|
||||
"swing_low" if side == "buy" else "swing_high",
|
||||
)
|
||||
marker_id = sig.get("marker_id", sig.get("leg_id"))
|
||||
base = {
|
||||
"time": int(pd.Timestamp(sig["datetime"]).timestamp()),
|
||||
"price": sig["price"],
|
||||
"marker_id": marker_id,
|
||||
"signal_type": signal_type,
|
||||
"leg_id": sig.get("leg_id"),
|
||||
}
|
||||
if side == "buy":
|
||||
long_open.append({**base, "position": "long", "action": "open"})
|
||||
short_close.append({**base, "position": "short", "action": "close"})
|
||||
else:
|
||||
long_close.append({**base, "position": "long", "action": "close"})
|
||||
short_open.append({**base, "position": "short", "action": "open"})
|
||||
|
||||
return {
|
||||
"long_open": long_open,
|
||||
"long_close": long_close,
|
||||
"short_open": short_open,
|
||||
"short_close": short_close,
|
||||
}
|
||||
|
||||
|
||||
def futures_markers_from_executed_trades(
|
||||
sim_pnl: dict[str, Any],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""시뮬 체결 내역에서 4종 선물 마커를 구성한다."""
|
||||
markers: dict[str, list[dict[str, Any]]] = {
|
||||
"long_open": [],
|
||||
"long_close": [],
|
||||
"short_open": [],
|
||||
"short_close": [],
|
||||
}
|
||||
|
||||
for trade in sim_pnl.get("trades") or []:
|
||||
if trade.get("skipped"):
|
||||
continue
|
||||
position = trade["position"]
|
||||
action = trade["action"]
|
||||
key = f"{position}_{action}"
|
||||
if key not in ("long_open", "long_close", "short_open", "short_close"):
|
||||
continue
|
||||
markers[key].append(
|
||||
{
|
||||
"time": int(pd.Timestamp(trade["datetime"]).timestamp()),
|
||||
"price": trade["price"],
|
||||
"marker_id": trade.get("marker_id") or trade.get("trade_id"),
|
||||
"signal_type": trade.get("signal_type", ""),
|
||||
"position": position,
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
|
||||
return markers
|
||||
763
src/deepcoin/ground_truth/futures_chart.py
Normal file
763
src/deepcoin/ground_truth/futures_chart.py
Normal file
@@ -0,0 +1,763 @@
|
||||
"""선물 Ground Truth 차트 HTML 생성 (롱·숏 4색 마커)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deepcoin.data.candle_loader import load_candles
|
||||
from deepcoin.ground_truth.chart import (
|
||||
DEFAULT_MAX_CANDLES,
|
||||
_data_js_path,
|
||||
_enrich_markers_chart_price,
|
||||
_sim_start_marker,
|
||||
_to_unix_seconds,
|
||||
)
|
||||
from deepcoin.ground_truth.futures import futures_markers_from_gt_signals
|
||||
|
||||
|
||||
def _stack_marker_positions(
|
||||
long_open: list[dict[str, Any]],
|
||||
long_close: list[dict[str, Any]],
|
||||
short_open: list[dict[str, Any]],
|
||||
short_close: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""동일 시각에 겹치는 마커에 세로 스택 인덱스를 부여한다.
|
||||
|
||||
같은 봉의 L↓·S↓ 등이 좌우로 벌어지지 않고 동일 X에서 위아래로 쌓이도록 한다.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
groups: dict[int, list[dict[str, Any]]] = defaultdict(list)
|
||||
for markers in (long_open, long_close, short_open, short_close):
|
||||
for marker in markers:
|
||||
groups[int(marker["time"])].append(marker)
|
||||
|
||||
kind_rank = {
|
||||
"long_close": 0,
|
||||
"short_open": 1,
|
||||
"long_open": 2,
|
||||
"short_close": 3,
|
||||
}
|
||||
|
||||
for markers_at_time in groups.values():
|
||||
if len(markers_at_time) <= 1:
|
||||
for marker in markers_at_time:
|
||||
marker["stack_index"] = 0
|
||||
continue
|
||||
markers_at_time.sort(
|
||||
key=lambda m: kind_rank.get(f"{m.get('position')}_{m.get('action')}", 9)
|
||||
)
|
||||
for index, marker in enumerate(markers_at_time):
|
||||
marker["stack_index"] = index
|
||||
marker.pop("x_offset_px", None)
|
||||
|
||||
_FUTURES_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Futures 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="__DATA_JS_NAME__"></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; }
|
||||
.legend { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||||
.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; }
|
||||
__EXTRA_STYLES__
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="title">Futures Ground Truth Chart</h1>
|
||||
<div class="meta" id="meta"></div>
|
||||
<div class="legend" id="legend"></div>
|
||||
</header>
|
||||
__EXTRA_BODY__
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-home" class="home" title="전체 화면으로 복귀">홈</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>
|
||||
<button id="btn-flip-y" 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;
|
||||
let lastDetailEnd = 0;
|
||||
let yAxisInverted = false;
|
||||
|
||||
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
|
||||
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
|
||||
const ARROW_HALF = 12;
|
||||
const ARROW_HEIGHT = 16;
|
||||
const LABEL_OFFSET_X = 16;
|
||||
const LABEL_GAP = 24;
|
||||
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
|
||||
const SIM_START_COLOR = "#7b1fa2";
|
||||
let axisMeasureCtx = null;
|
||||
|
||||
const COLORS = {
|
||||
longOpen: "#1565c0",
|
||||
longClose: "#64b5f6",
|
||||
shortOpen: "#c62828",
|
||||
shortClose: "#ff8a65",
|
||||
};
|
||||
|
||||
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 markerSuffix(signalType) {
|
||||
if (signalType === "pullback") return "*";
|
||||
if (signalType === "breakout") return "^";
|
||||
if (signalType === "div_bull" || signalType === "div_bear") return "d";
|
||||
return "";
|
||||
}
|
||||
|
||||
function markerLabel(kind, m) {
|
||||
const id = m.marker_id;
|
||||
const sfx = markerSuffix(m.signal_type);
|
||||
if (kind === "longOpen") return "L↑" + id + sfx;
|
||||
if (kind === "longClose") return "L↓" + id + sfx;
|
||||
if (kind === "shortOpen") return "S↓" + id + sfx;
|
||||
return "S↑" + id + sfx;
|
||||
}
|
||||
|
||||
function markerChartPrice(m) {
|
||||
if (m.chart_price != null) return m.chart_price;
|
||||
let lo = 0;
|
||||
let hi = DATA.times.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (DATA.times[mid] < m.time) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return DATA.close[lo];
|
||||
}
|
||||
|
||||
function drawMarkerLabel(ctx, label, x, labelY, color) {
|
||||
ctx.font = MARKER_FONT;
|
||||
const lx = x + LABEL_OFFSET_X;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.strokeText(label, lx, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, lx, labelY);
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function visualUp(up) {
|
||||
return yAxisInverted ? !up : up;
|
||||
}
|
||||
|
||||
function drawTriangleOnLine(ctx, x, lineY, up, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
if (up) {
|
||||
ctx.moveTo(x - ARROW_HALF, lineY);
|
||||
ctx.lineTo(x + ARROW_HALF, lineY);
|
||||
ctx.lineTo(x, lineY + ARROW_HEIGHT);
|
||||
} else {
|
||||
ctx.moveTo(x - ARROW_HALF, lineY);
|
||||
ctx.lineTo(x + ARROW_HALF, lineY);
|
||||
ctx.lineTo(x, lineY - ARROW_HEIGHT);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawOneMarker(u, m, color, up, label) {
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(m.time, "x", true);
|
||||
const lineY = u.valToPos(markerChartPrice(m), "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const arrowUp = visualUp(up);
|
||||
const stackIdx = m.stack_index || 0;
|
||||
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
|
||||
const anchorY = lineY + stackShift;
|
||||
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
|
||||
const labelY = arrowUp
|
||||
? anchorY + ARROW_HEIGHT + LABEL_GAP
|
||||
: anchorY - ARROW_HEIGHT - LABEL_GAP;
|
||||
drawMarkerLabel(ctx, label, x, labelY, color);
|
||||
}
|
||||
|
||||
function drawFuturesMarkers(u) {
|
||||
if (!showMarkers) return;
|
||||
drawSimStartMarker(u, DATA.sim_start_marker);
|
||||
(DATA.long_open_markers || []).forEach(m =>
|
||||
drawOneMarker(u, m, COLORS.longOpen, true, markerLabel("longOpen", m)));
|
||||
(DATA.long_close_markers || []).forEach(m =>
|
||||
drawOneMarker(u, m, COLORS.longClose, false, markerLabel("longClose", m)));
|
||||
(DATA.short_open_markers || []).forEach(m =>
|
||||
drawOneMarker(u, m, COLORS.shortOpen, false, markerLabel("shortOpen", m)));
|
||||
(DATA.short_close_markers || []).forEach(m =>
|
||||
drawOneMarker(u, m, COLORS.shortClose, true, markerLabel("shortClose", m)));
|
||||
}
|
||||
|
||||
function drawSimStartMarker(u, marker) {
|
||||
if (!marker) return;
|
||||
const ctx = u.ctx;
|
||||
const x = u.valToPos(marker.time, "x", true);
|
||||
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
|
||||
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
|
||||
const color = SIM_START_COLOR;
|
||||
const arrowUp = visualUp(false);
|
||||
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
|
||||
const label = marker.label || "거래시작";
|
||||
ctx.font = MARKER_FONT;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = arrowUp ? "top" : "bottom";
|
||||
const labelY = arrowUp
|
||||
? lineY + ARROW_HEIGHT + 12
|
||||
: lineY - ARROW_HEIGHT - 12;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.strokeText(label, x, labelY);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x, labelY);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
}
|
||||
|
||||
function updateLegInfo() {
|
||||
const total = (DATA.long_open_markers || []).length;
|
||||
const el = document.getElementById("leg-info");
|
||||
if (!total) { el.textContent = "타점 없음"; return; }
|
||||
el.textContent = `롱 레그 ${currentLegIdx + 1} / ${total}`;
|
||||
}
|
||||
|
||||
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: [40, 10, 40, 10],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { dir: yAxisInverted ? -1 : 1 },
|
||||
},
|
||||
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) => drawFuturesMarkers(u)] },
|
||||
};
|
||||
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;
|
||||
lastDetailEnd = end;
|
||||
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 },
|
||||
invertScale: yAxisInverted,
|
||||
},
|
||||
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 (showMarkers) {
|
||||
const colorMap = {
|
||||
longOpen: COLORS.longOpen,
|
||||
longClose: COLORS.longClose,
|
||||
shortOpen: COLORS.shortOpen,
|
||||
shortClose: COLORS.shortClose,
|
||||
};
|
||||
const kindUp = {
|
||||
longOpen: true,
|
||||
longClose: false,
|
||||
shortOpen: false,
|
||||
shortClose: true,
|
||||
};
|
||||
const pending = [];
|
||||
[
|
||||
[DATA.long_open_markers, "longOpen"],
|
||||
[DATA.long_close_markers, "longClose"],
|
||||
[DATA.short_open_markers, "shortOpen"],
|
||||
[DATA.short_close_markers, "shortClose"],
|
||||
].forEach(([list, kind]) => {
|
||||
(list || []).forEach(m => {
|
||||
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
|
||||
});
|
||||
});
|
||||
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
|
||||
pending.forEach(({ m, kind }) => {
|
||||
const arrowUp = visualUp(kindUp[kind]);
|
||||
markers.push({
|
||||
time: m.time,
|
||||
position: arrowUp ? "belowBar" : "aboveBar",
|
||||
color: colorMap[kind],
|
||||
shape: arrowUp ? "arrowUp" : "arrowDown",
|
||||
size: 10,
|
||||
text: markerLabel(kind, m),
|
||||
});
|
||||
});
|
||||
}
|
||||
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 nearestLongCloseAfter(openTime) {
|
||||
let best = null;
|
||||
for (const s of DATA.long_close_markers || []) {
|
||||
if (s.time >= openTime && (!best || s.time < best.time)) best = s;
|
||||
}
|
||||
return best || (DATA.long_close_markers || [])[(DATA.long_close_markers || []).length - 1];
|
||||
}
|
||||
|
||||
function jumpToLeg(idx) {
|
||||
const opens = DATA.long_open_markers || [];
|
||||
const total = opens.length;
|
||||
if (!total) return;
|
||||
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
|
||||
updateLegInfo();
|
||||
const lo = opens[currentLegIdx];
|
||||
const lc = nearestLongCloseAfter(lo.time);
|
||||
const span = lc ? Math.max(lc.time - lo.time, 86400) : 86400 * 3;
|
||||
const pad = span * 0.4;
|
||||
const vmin = lo.time - pad;
|
||||
const vmax = (lc ? lc.time : lo.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; }
|
||||
}
|
||||
document.getElementById("detail-title").textContent =
|
||||
`L↑${lo.marker_id} 상방 매수 — ${new Date(lo.time * 1000).toLocaleString("ko-KR")}`;
|
||||
buildDetailCandles(start, end);
|
||||
const closeText = lc ? ` → 상방 매도 ${fmtPrice(lc.price)}` : "";
|
||||
document.getElementById("status").textContent =
|
||||
`L↑${lo.marker_id} ${fmtPrice(lo.price)}${closeText}`;
|
||||
}
|
||||
|
||||
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} 캔들 (${(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 toggleYFlip() {
|
||||
yAxisInverted = !yAxisInverted;
|
||||
const btn = document.getElementById("btn-flip-y");
|
||||
btn.classList.toggle("active", yAxisInverted);
|
||||
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
|
||||
if (overviewPlot) buildOverview(true);
|
||||
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
|
||||
}
|
||||
|
||||
__EXTRA_SCRIPT__
|
||||
|
||||
function renderLegend() {
|
||||
const items = [
|
||||
[COLORS.longOpen, "L↑ 상방 매수 (롱 진입)"],
|
||||
[COLORS.longClose, "L↓ 상방 매도 (롱 청산)"],
|
||||
[COLORS.shortOpen, "S↓ 하방 매수 (숏 진입)"],
|
||||
[COLORS.shortClose, "S↑ 하방 매도 (숏 청산)"],
|
||||
];
|
||||
document.getElementById("legend").innerHTML = items.map(([color, text]) =>
|
||||
`<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${text}</span>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function init() {
|
||||
DATA = window.CHART_DATA;
|
||||
if (!DATA) throw new Error("차트 데이터 JS 없음");
|
||||
const m = DATA.meta;
|
||||
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 simMode = !!DATA.sim_pnl;
|
||||
const simSuffix = simMode ? (m.sim_stage_suffix || " · 1단계 sim") : "";
|
||||
document.getElementById("title").textContent =
|
||||
`${m.symbol} 선물 Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
|
||||
if (simMode) {
|
||||
const panelTitle = document.getElementById("sim-panel-title");
|
||||
if (panelTitle) {
|
||||
const simDays = DATA.sim_pnl?.sim_lookback_days || m.sim_lookback_days || 1095;
|
||||
const simLabel = simDays >= 365
|
||||
? `최근 ${Math.round(simDays / 365)}년`
|
||||
: `최근 ${simDays}일`;
|
||||
const initCash = DATA.sim_pnl?.initial_cash_krw || 0;
|
||||
const initLabel = initCash ? `${Math.round(initCash).toLocaleString()}원` : "";
|
||||
panelTitle.textContent =
|
||||
(m.sim_stage_title || "1단계 선물 sim (GT 사후 타점)") +
|
||||
` (${simLabel}${initLabel ? ` · 초기 ${initLabel}` : ""})`;
|
||||
}
|
||||
}
|
||||
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 lo = (DATA.long_open_markers || []).length;
|
||||
const lc = (DATA.long_close_markers || []).length;
|
||||
const so = (DATA.short_open_markers || []).length;
|
||||
const sc = (DATA.short_close_markers || []).length;
|
||||
const markerRange = simMode && m.sim_period_from
|
||||
? `체결 L↑${lo}/L↓${lc} · S↓${so}/S↑${sc} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
|
||||
: `GT ${gtLabel} | 현물 GT 타점 기반`;
|
||||
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
|
||||
document.getElementById("meta").textContent =
|
||||
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | ` +
|
||||
`상방 ${lo}/${lc} · 하방 ${so}/${sc} | ${markerRange}${legendExtra}`;
|
||||
renderLegend();
|
||||
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-flip-y").onclick = toggleYFlip;
|
||||
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>"""
|
||||
|
||||
|
||||
def _build_futures_chart_payload(
|
||||
df,
|
||||
gt_result: dict[str, Any],
|
||||
chart_days: int,
|
||||
gt_lookback_days: int,
|
||||
) -> dict[str, Any]:
|
||||
"""선물 GT 차트용 JSON payload를 구성한다."""
|
||||
markers = futures_markers_from_gt_signals(gt_result)
|
||||
times = _to_unix_seconds(df["datetime"])
|
||||
closes = df["close"].astype(float).tolist()
|
||||
chart_meta = {
|
||||
**gt_result["meta"],
|
||||
"market_type": "futures",
|
||||
"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()),
|
||||
}
|
||||
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": closes,
|
||||
"long_open_markers": _enrich_markers_chart_price(markers["long_open"], times, closes),
|
||||
"long_close_markers": _enrich_markers_chart_price(markers["long_close"], times, closes),
|
||||
"short_open_markers": _enrich_markers_chart_price(markers["short_open"], times, closes),
|
||||
"short_close_markers": _enrich_markers_chart_price(markers["short_close"], times, closes),
|
||||
"meta": chart_meta,
|
||||
"bar_count": len(df),
|
||||
}
|
||||
_stack_marker_positions(
|
||||
payload["long_open_markers"],
|
||||
payload["long_close_markers"],
|
||||
payload["short_open_markers"],
|
||||
payload["short_close_markers"],
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def render_futures_ground_truth_chart(
|
||||
db_path: Path,
|
||||
symbol: str,
|
||||
gt_result: dict[str, Any],
|
||||
output_path: Path,
|
||||
chart_lookback_days: int | None = None,
|
||||
max_candles: int = DEFAULT_MAX_CANDLES,
|
||||
) -> Path:
|
||||
"""현물 GT 타점을 선물 롱·숏 4색 마커로 표시한 HTML 차트를 생성한다.
|
||||
|
||||
Args:
|
||||
db_path: SQLite 경로.
|
||||
symbol: 코인 심볼.
|
||||
gt_result: build_ground_truth 결과 또는 spot GT JSON.
|
||||
output_path: HTML 출력 경로.
|
||||
chart_lookback_days: 차트 표시 일수. None이면 GT lookback과 동일.
|
||||
max_candles: 0이면 전체, 양수면 최근 N봉만.
|
||||
|
||||
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_futures_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)
|
||||
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
|
||||
html = _futures_html_template(data_js_name)
|
||||
output_path.write_text(html, encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
|
||||
def _futures_html_template(data_js_name: str) -> str:
|
||||
"""선물 GT 차트 HTML 템플릿을 생성한다."""
|
||||
return (
|
||||
_FUTURES_HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name)
|
||||
.replace("__EXTRA_STYLES__", "")
|
||||
.replace("__EXTRA_BODY__", "")
|
||||
.replace("__EXTRA_SCRIPT__", "")
|
||||
)
|
||||
411
src/deepcoin/ground_truth/ground_truth.py
Normal file
411
src/deepcoin/ground_truth/ground_truth.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""Ground Truth 매수·매도 타점 생성 (1단계 · 0단계 sim 입력)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.data.candle_loader import load_candles
|
||||
from deepcoin.data.intervals import interval_label
|
||||
from deepcoin.ground_truth.pnl import simulate_gt_pnl
|
||||
from deepcoin.ground_truth.breakout import find_breakout_buy_pivots
|
||||
from deepcoin.ground_truth.divergence import find_divergence_signals
|
||||
from deepcoin.ground_truth.pullback import find_pullback_buy_pivots
|
||||
from deepcoin.ground_truth.zigzag import Pivot, find_zigzag_pivots
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GtParams:
|
||||
"""Ground Truth 생성 파라미터."""
|
||||
|
||||
interval_min: int
|
||||
lookback_days: int
|
||||
zigzag_reversal_pct: float
|
||||
min_leg_pct: float
|
||||
pullback_min_pct: float = 1.5
|
||||
pullback_local_order: int = 10
|
||||
breakout_buffer_pct: float = 0.1
|
||||
breakout_consolidation_bars: int = 200
|
||||
breakout_min_rally_pct: float = 2.0
|
||||
div_local_order: int = 20
|
||||
div_min_bars_between: int = 1500
|
||||
div_min_rsi_diff: float = 5.0
|
||||
div_min_future_move_pct: float = 4.0
|
||||
chart_tier: str = "v3"
|
||||
|
||||
|
||||
def _tier_flags(tier: str) -> tuple[bool, bool, bool]:
|
||||
"""차트 버전별 보조 신호 포함 여부 (눌림목, 돌파, 다이버전스).
|
||||
|
||||
v1: ZigZag 스윙만 (레그당 1매수·1매도 최소)
|
||||
v2: 스윙 + 눌림목
|
||||
v3: v2 + 돌파 + 다이버전스
|
||||
"""
|
||||
tier = tier.lower()
|
||||
if tier == "v1":
|
||||
return False, False, False
|
||||
if tier == "v2":
|
||||
return True, False, False
|
||||
return True, True, True
|
||||
|
||||
|
||||
@dataclass
|
||||
class GtLeg:
|
||||
"""매수→매도 1레그 (최대 스윙 수익 구간)."""
|
||||
|
||||
leg_id: int
|
||||
buy_datetime: str
|
||||
buy_price: float
|
||||
buy_bar_index: int
|
||||
sell_datetime: str
|
||||
sell_price: float
|
||||
sell_bar_index: int
|
||||
leg_pct: float
|
||||
bars_held: int
|
||||
|
||||
|
||||
def build_ground_truth(
|
||||
db_path: Path,
|
||||
symbol: str,
|
||||
coin_name: str,
|
||||
params: GtParams,
|
||||
initial_cash_krw: float = 400_000.0,
|
||||
fee_rate: float = 0.0005,
|
||||
) -> dict[str, Any]:
|
||||
"""최근 1년 구간에서 사후 최적 스윙 레그(1매수·1매도) GT를 생성한다.
|
||||
|
||||
미래 데이터를 사용해 ZigZag 스윙 저점 매수·고점 매도 쌍을 찾는다.
|
||||
1단계 벤치마크: 최대 스윙 수익을 포착하는 타점.
|
||||
|
||||
Args:
|
||||
db_path: SQLite 경로.
|
||||
symbol: 코인 심볼.
|
||||
coin_name: 코인 이름.
|
||||
params: GT 파라미터.
|
||||
initial_cash_krw: 수익률 계산 초기 자본 (1년 시작 시점).
|
||||
fee_rate: 거래 수수료율.
|
||||
|
||||
Returns:
|
||||
JSON 직렬화 가능한 GT 결과 dict.
|
||||
"""
|
||||
df = load_candles(
|
||||
db_path=db_path,
|
||||
symbol=symbol,
|
||||
interval_min=params.interval_min,
|
||||
lookback_days=params.lookback_days,
|
||||
)
|
||||
|
||||
pivots = find_zigzag_pivots(df, reversal_pct=params.zigzag_reversal_pct)
|
||||
legs = _pivots_to_legs(pivots, min_leg_pct=params.min_leg_pct)
|
||||
leg_dicts = [asdict(leg) for leg in legs]
|
||||
include_pullback, include_breakout, include_divergence = _tier_flags(params.chart_tier)
|
||||
|
||||
pullback_buys: list[Pivot] = []
|
||||
if include_pullback:
|
||||
pullback_buys = find_pullback_buy_pivots(
|
||||
df,
|
||||
legs=legs,
|
||||
min_pullback_pct=params.pullback_min_pct,
|
||||
local_order=params.pullback_local_order,
|
||||
)
|
||||
|
||||
breakout_buys = []
|
||||
if include_breakout:
|
||||
breakout_buys = find_breakout_buy_pivots(
|
||||
df,
|
||||
legs=legs,
|
||||
pullback_buys=pullback_buys,
|
||||
breakout_buffer_pct=params.breakout_buffer_pct,
|
||||
consolidation_bars=params.breakout_consolidation_bars,
|
||||
min_rally_to_sell_pct=params.breakout_min_rally_pct,
|
||||
)
|
||||
|
||||
div_buys: list = []
|
||||
div_sells: list = []
|
||||
if include_divergence:
|
||||
div_buys, div_sells = find_divergence_signals(
|
||||
df,
|
||||
local_order=params.div_local_order,
|
||||
min_bars_between=params.div_min_bars_between,
|
||||
min_rsi_diff=params.div_min_rsi_diff,
|
||||
min_future_move_pct=params.div_min_future_move_pct,
|
||||
)
|
||||
|
||||
mode_map = {
|
||||
"v1": "optimal_swing_legs",
|
||||
"v2": "optimal_swing_legs_with_pullback",
|
||||
"v3": "optimal_swing_legs_with_pullback_breakout_divergence",
|
||||
}
|
||||
mode = mode_map.get(params.chart_tier.lower(), mode_map["v3"])
|
||||
|
||||
signals = _build_signals(legs, pullback_buys, breakout_buys, div_buys, div_sells)
|
||||
summary = _summarize(legs, signals)
|
||||
pnl = simulate_gt_pnl(leg_dicts, initial_cash_krw=initial_cash_krw, fee_rate=fee_rate)
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"symbol": symbol.upper(),
|
||||
"coin_name": coin_name,
|
||||
"interval_min": params.interval_min,
|
||||
"interval_label": interval_label(params.interval_min),
|
||||
"lookback_days": params.lookback_days,
|
||||
"chart_tier": params.chart_tier.lower(),
|
||||
"mode": mode,
|
||||
"zigzag_reversal_pct": params.zigzag_reversal_pct,
|
||||
"min_leg_pct": params.min_leg_pct,
|
||||
"pullback_min_pct": params.pullback_min_pct,
|
||||
"initial_cash_krw": initial_cash_krw,
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"data_from": str(df["datetime"].min()),
|
||||
"data_to": str(df["datetime"].max()),
|
||||
"bar_count": len(df),
|
||||
"pivot_count": len(pivots),
|
||||
"pullback_buy_count": len(pullback_buys),
|
||||
"breakout_buy_count": len(breakout_buys),
|
||||
"breakout_buffer_pct": params.breakout_buffer_pct,
|
||||
"divergence_buy_count": len(div_buys),
|
||||
"divergence_sell_count": len(div_sells),
|
||||
},
|
||||
"legs": leg_dicts,
|
||||
"signals": signals,
|
||||
"summary": summary,
|
||||
"pnl": pnl,
|
||||
}
|
||||
|
||||
|
||||
def save_ground_truth(result: dict[str, Any], output_path: Path) -> Path:
|
||||
"""GT 결과를 JSON으로 저장한다."""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(result, fp, ensure_ascii=False, indent=2)
|
||||
return output_path
|
||||
|
||||
|
||||
def _pivots_to_legs(pivots: list[Pivot], min_leg_pct: float) -> list[GtLeg]:
|
||||
"""스윙 저점→고점을 1매수·1매도 레그로 변환한다."""
|
||||
legs: list[GtLeg] = []
|
||||
leg_id = 0
|
||||
i = 0
|
||||
|
||||
while i < len(pivots) - 1:
|
||||
buy_pivot = pivots[i]
|
||||
sell_pivot = pivots[i + 1]
|
||||
|
||||
if buy_pivot.side != "low" or sell_pivot.side != "high":
|
||||
i += 1
|
||||
continue
|
||||
if sell_pivot.bar_index <= buy_pivot.bar_index:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
leg_pct = (sell_pivot.price - buy_pivot.price) / buy_pivot.price * 100.0
|
||||
if leg_pct < min_leg_pct:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
leg_id += 1
|
||||
legs.append(
|
||||
GtLeg(
|
||||
leg_id=leg_id,
|
||||
buy_datetime=buy_pivot.datetime.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
buy_price=round(buy_pivot.price, 2),
|
||||
buy_bar_index=buy_pivot.bar_index,
|
||||
sell_datetime=sell_pivot.datetime.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
sell_price=round(sell_pivot.price, 2),
|
||||
sell_bar_index=sell_pivot.bar_index,
|
||||
leg_pct=round(leg_pct, 2),
|
||||
bars_held=sell_pivot.bar_index - buy_pivot.bar_index,
|
||||
)
|
||||
)
|
||||
i += 2
|
||||
|
||||
return legs
|
||||
|
||||
|
||||
def _build_signals(
|
||||
legs: list[GtLeg],
|
||||
pullback_buys: list[Pivot],
|
||||
breakout_buys: list,
|
||||
div_buys: list,
|
||||
div_sells: list,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""스윙·눌림목·돌파·다이버전스 신호를 통합한다."""
|
||||
signals: list[dict[str, Any]] = []
|
||||
buy_marker_id = 0
|
||||
sell_marker_id = 0
|
||||
|
||||
existing_buy_bars: set[int] = {leg.buy_bar_index for leg in legs}
|
||||
existing_sell_bars: set[int] = {leg.sell_bar_index for leg in legs}
|
||||
nearby_tolerance = 120
|
||||
|
||||
for leg in legs:
|
||||
buy_marker_id += 1
|
||||
signals.append(
|
||||
{
|
||||
"marker_id": buy_marker_id,
|
||||
"leg_id": leg.leg_id,
|
||||
"side": "buy",
|
||||
"signal_type": "swing_low",
|
||||
"datetime": leg.buy_datetime,
|
||||
"price": leg.buy_price,
|
||||
"bar_index": leg.buy_bar_index,
|
||||
}
|
||||
)
|
||||
sell_marker_id += 1
|
||||
existing_sell_bars.add(leg.sell_bar_index)
|
||||
signals.append(
|
||||
{
|
||||
"marker_id": sell_marker_id,
|
||||
"leg_id": leg.leg_id,
|
||||
"side": "sell",
|
||||
"signal_type": "swing_high",
|
||||
"datetime": leg.sell_datetime,
|
||||
"price": leg.sell_price,
|
||||
"bar_index": leg.sell_bar_index,
|
||||
"leg_pct": leg.leg_pct,
|
||||
}
|
||||
)
|
||||
|
||||
for pivot in pullback_buys:
|
||||
if _is_near_existing_buy(pivot.bar_index, existing_buy_bars, nearby_tolerance):
|
||||
continue
|
||||
buy_marker_id += 1
|
||||
existing_buy_bars.add(pivot.bar_index)
|
||||
signals.append(
|
||||
{
|
||||
"marker_id": buy_marker_id,
|
||||
"leg_id": None,
|
||||
"side": "buy",
|
||||
"signal_type": "pullback",
|
||||
"datetime": pivot.datetime.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"price": round(pivot.price, 2),
|
||||
"bar_index": pivot.bar_index,
|
||||
}
|
||||
)
|
||||
|
||||
for breakout in breakout_buys:
|
||||
if _is_near_existing_buy(breakout.bar_index, existing_buy_bars, nearby_tolerance):
|
||||
continue
|
||||
buy_marker_id += 1
|
||||
existing_buy_bars.add(breakout.bar_index)
|
||||
signals.append(
|
||||
{
|
||||
"marker_id": buy_marker_id,
|
||||
"leg_id": breakout.leg_id,
|
||||
"side": "buy",
|
||||
"signal_type": "breakout",
|
||||
"datetime": breakout.datetime.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"price": breakout.price,
|
||||
"bar_index": breakout.bar_index,
|
||||
"resistance_price": breakout.resistance_price,
|
||||
}
|
||||
)
|
||||
|
||||
div_tolerance = 400
|
||||
for div in div_buys:
|
||||
if _is_near_bar(div.bar_index, existing_buy_bars, div_tolerance):
|
||||
continue
|
||||
buy_marker_id += 1
|
||||
existing_buy_bars.add(div.bar_index)
|
||||
signals.append(_divergence_to_dict(div, buy_marker_id, "div_bull"))
|
||||
|
||||
for div in div_sells:
|
||||
if _is_near_bar(div.bar_index, existing_sell_bars, div_tolerance):
|
||||
continue
|
||||
sell_marker_id += 1
|
||||
existing_sell_bars.add(div.bar_index)
|
||||
signals.append(_divergence_to_dict(div, sell_marker_id, "div_bear"))
|
||||
|
||||
signals.sort(key=lambda s: (s["bar_index"], _signal_sort_key(s)))
|
||||
return signals
|
||||
|
||||
|
||||
def _divergence_to_dict(div, marker_id: int, signal_type: str) -> dict[str, Any]:
|
||||
"""DivergenceSignal을 GT signal dict로 변환한다."""
|
||||
return {
|
||||
"marker_id": marker_id,
|
||||
"leg_id": None,
|
||||
"side": div.side,
|
||||
"signal_type": signal_type,
|
||||
"datetime": div.datetime.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"price": div.price,
|
||||
"bar_index": div.bar_index,
|
||||
"indicator": div.indicator,
|
||||
"price_prev": div.price_prev,
|
||||
"ind_prev": div.ind_prev,
|
||||
"ind_curr": div.ind_curr,
|
||||
}
|
||||
|
||||
|
||||
def _signal_sort_key(signal: dict[str, Any]) -> int:
|
||||
"""동일 봉에서 신호 유형 정렬 우선순위."""
|
||||
order = {
|
||||
"swing_low": 0,
|
||||
"pullback": 1,
|
||||
"breakout": 2,
|
||||
"div_bull": 3,
|
||||
"swing_high": 4,
|
||||
"div_bear": 5,
|
||||
}
|
||||
return order.get(signal.get("signal_type", ""), 9)
|
||||
|
||||
|
||||
def _is_near_bar(bar_index: int, existing_bars: set[int], tolerance: int) -> bool:
|
||||
"""기존 타점과 너무 가까우면 보조 신호를 제외한다."""
|
||||
for existing in existing_bars:
|
||||
if abs(bar_index - existing) <= tolerance:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_near_existing_buy(bar_index: int, existing_bars: set[int], tolerance: int) -> bool:
|
||||
"""기존 매수와 너무 가까우면 보조 매수를 제외한다."""
|
||||
return _is_near_bar(bar_index, existing_bars, tolerance)
|
||||
|
||||
|
||||
def _summarize(legs: list[GtLeg], signals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""GT 요약 통계."""
|
||||
buy_count = sum(1 for s in signals if s["side"] == "buy")
|
||||
sell_count = sum(1 for s in signals if s["side"] == "sell")
|
||||
pullback_count = sum(1 for s in signals if s.get("signal_type") == "pullback")
|
||||
breakout_count = sum(1 for s in signals if s.get("signal_type") == "breakout")
|
||||
div_buy_count = sum(1 for s in signals if s.get("signal_type") == "div_bull")
|
||||
div_sell_count = sum(1 for s in signals if s.get("signal_type") == "div_bear")
|
||||
|
||||
if not legs:
|
||||
return {
|
||||
"leg_count": 0,
|
||||
"buy_count": buy_count,
|
||||
"sell_count": sell_count,
|
||||
"pullback_buy_count": pullback_count,
|
||||
"breakout_buy_count": breakout_count,
|
||||
"divergence_buy_count": div_buy_count,
|
||||
"divergence_sell_count": div_sell_count,
|
||||
"avg_leg_pct": 0.0,
|
||||
"median_leg_pct": 0.0,
|
||||
"max_leg_pct": 0.0,
|
||||
"min_leg_pct": 0.0,
|
||||
"avg_bars_held": 0.0,
|
||||
}
|
||||
|
||||
pcts = [leg.leg_pct for leg in legs]
|
||||
bars = [leg.bars_held for leg in legs]
|
||||
return {
|
||||
"leg_count": len(legs),
|
||||
"buy_count": buy_count,
|
||||
"sell_count": sell_count,
|
||||
"pullback_buy_count": pullback_count,
|
||||
"breakout_buy_count": breakout_count,
|
||||
"divergence_buy_count": div_buy_count,
|
||||
"divergence_sell_count": div_sell_count,
|
||||
"avg_leg_pct": round(sum(pcts) / len(pcts), 2),
|
||||
"median_leg_pct": round(float(pd.Series(pcts).median()), 2),
|
||||
"max_leg_pct": round(max(pcts), 2),
|
||||
"min_leg_pct": round(min(pcts), 2),
|
||||
"avg_bars_held": round(sum(bars) / len(bars), 1),
|
||||
}
|
||||
59
src/deepcoin/ground_truth/order_sizing.py
Normal file
59
src/deepcoin/ground_truth/order_sizing.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""총평가금액 구간별 매수(현금) 상한."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 총평가금액(원) 구간 — 높은 구간이 우선 적용
|
||||
EQUITY_TIER_100M = 100_000_000
|
||||
EQUITY_TIER_1B = 1_000_000_000
|
||||
EQUITY_TIER_10B = 10_000_000_000
|
||||
|
||||
BUY_SIZING_RULE_LABEL = "총평가 1억↑ 현금 10% · 10억↑ 5% · 100억↑ 1%"
|
||||
|
||||
|
||||
def buy_cash_pct(equity_krw: float) -> float | None:
|
||||
"""총평가금액에 따른 1회 매수 허용 현금 비율.
|
||||
|
||||
Args:
|
||||
equity_krw: 현재 총평가금액(원).
|
||||
|
||||
Returns:
|
||||
허용 비율(0~1). 1억 미만이면 None(비율 상한 없음).
|
||||
"""
|
||||
if equity_krw >= EQUITY_TIER_10B:
|
||||
return 0.01
|
||||
if equity_krw >= EQUITY_TIER_1B:
|
||||
return 0.05
|
||||
if equity_krw >= EQUITY_TIER_100M:
|
||||
return 0.10
|
||||
return None
|
||||
|
||||
|
||||
def max_buy_from_cash(equity_krw: float, cash_krw: float) -> float:
|
||||
"""구간별 규칙을 반영한 1회 매수 최대 금액.
|
||||
|
||||
Args:
|
||||
equity_krw: 현재 총평가금액(원).
|
||||
cash_krw: 보유 현금(원).
|
||||
|
||||
Returns:
|
||||
매수에 사용 가능한 최대 원화.
|
||||
"""
|
||||
cash = max(float(cash_krw), 0.0)
|
||||
pct = buy_cash_pct(equity_krw)
|
||||
if pct is None:
|
||||
return cash
|
||||
return cash * pct
|
||||
|
||||
|
||||
def buy_sizing_metadata() -> dict[str, Any]:
|
||||
"""시뮬 결과·차트에 포함할 매수 상한 메타."""
|
||||
return {
|
||||
"buy_sizing_rule": BUY_SIZING_RULE_LABEL,
|
||||
"buy_sizing_tiers": [
|
||||
{"min_equity_krw": EQUITY_TIER_100M, "max_cash_pct": 0.10},
|
||||
{"min_equity_krw": EQUITY_TIER_1B, "max_cash_pct": 0.05},
|
||||
{"min_equity_krw": EQUITY_TIER_10B, "max_cash_pct": 0.01},
|
||||
],
|
||||
}
|
||||
400
src/deepcoin/ground_truth/pnl.py
Normal file
400
src/deepcoin/ground_truth/pnl.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""Ground Truth 기준 초기 자본 누적 수익률 계산."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from deepcoin.ground_truth.order_sizing import buy_sizing_metadata, max_buy_from_cash
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegPnl:
|
||||
"""레그별 손익 (매수→매도 1쌍)."""
|
||||
|
||||
leg_id: int
|
||||
buy_datetime: str
|
||||
sell_datetime: str
|
||||
buy_price: float
|
||||
sell_price: float
|
||||
cash_before: float
|
||||
cash_after: float
|
||||
leg_return_pct: float
|
||||
cumulative_return_pct: float
|
||||
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,
|
||||
fee_rate: float = 0.0005,
|
||||
min_order_krw: float = 5_000.0,
|
||||
) -> dict[str, Any]:
|
||||
"""GT 레그(1매수·1매도)를 순서대로 실행한 누적 수익률.
|
||||
|
||||
- 초기 현금 전액 매수 → 전량 매도 반복 (복리)
|
||||
- 1단계 Ground Truth 벤치마크용
|
||||
|
||||
Args:
|
||||
legs: GT legs 리스트.
|
||||
initial_cash_krw: 초기 원화.
|
||||
fee_rate: 편도 수수료율.
|
||||
min_order_krw: 최소 주문 금액.
|
||||
|
||||
Returns:
|
||||
요약 + 레그별 손익 dict.
|
||||
"""
|
||||
cash = float(initial_cash_krw)
|
||||
leg_pnls: list[LegPnl] = []
|
||||
skipped = 0
|
||||
|
||||
for leg in legs:
|
||||
buy_price = float(leg["buy_price"])
|
||||
sell_price = float(leg["sell_price"])
|
||||
cash_before = cash
|
||||
|
||||
if cash < min_order_krw:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
buy_fee = cash * fee_rate
|
||||
btc_bought = (cash - buy_fee) / buy_price
|
||||
cash = 0.0
|
||||
|
||||
sell_gross = btc_bought * sell_price
|
||||
sell_fee = sell_gross * fee_rate
|
||||
cash = sell_gross - sell_fee
|
||||
|
||||
leg_return = (cash - cash_before) / cash_before * 100.0
|
||||
cumulative = (cash - initial_cash_krw) / initial_cash_krw * 100.0
|
||||
|
||||
leg_pnls.append(
|
||||
LegPnl(
|
||||
leg_id=int(leg["leg_id"]),
|
||||
buy_datetime=leg["buy_datetime"],
|
||||
sell_datetime=leg["sell_datetime"],
|
||||
buy_price=buy_price,
|
||||
sell_price=sell_price,
|
||||
cash_before=round(cash_before, 0),
|
||||
cash_after=round(cash, 0),
|
||||
leg_return_pct=round(leg_return, 2),
|
||||
cumulative_return_pct=round(cumulative, 2),
|
||||
btc_qty=round(btc_bought, 8),
|
||||
)
|
||||
)
|
||||
|
||||
final_cash = cash
|
||||
total_return_pct = (final_cash - initial_cash_krw) / initial_cash_krw * 100.0
|
||||
|
||||
period_from = leg_pnls[0].buy_datetime if leg_pnls else None
|
||||
period_to = leg_pnls[-1].sell_datetime if leg_pnls else None
|
||||
|
||||
return {
|
||||
"initial_cash_krw": initial_cash_krw,
|
||||
"final_cash_krw": round(final_cash, 0),
|
||||
"total_pnl_krw": round(final_cash - initial_cash_krw, 0),
|
||||
"total_return_pct": round(total_return_pct, 2),
|
||||
"fee_rate": fee_rate,
|
||||
"legs_traded": len(leg_pnls),
|
||||
"legs_skipped": skipped,
|
||||
"period_from": period_from,
|
||||
"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
|
||||
- 연속 매수: 가용 원화를 매수 신호 수로 균등 분할
|
||||
- 총평가 1억↑ 현금 10% · 10억↑ 5% · 100억↑ 1% 상한
|
||||
- 연속 매도: 보유 코인을 매도 신호 수로 균등 분할 (상한 없음)
|
||||
- 원화 부족 시 매수 스킵, 코인 없으면 매도 스킵
|
||||
|
||||
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
|
||||
equity = cash + coin_qty * price
|
||||
cash_cap = max_buy_from_cash(equity, cash)
|
||||
order_krw = min(per_buy, cash, cash_cap)
|
||||
|
||||
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,
|
||||
**buy_sizing_metadata(),
|
||||
"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,
|
||||
**buy_sizing_metadata(),
|
||||
"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": [],
|
||||
}
|
||||
118
src/deepcoin/ground_truth/pullback.py
Normal file
118
src/deepcoin/ground_truth/pullback.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""상승 추세 중 눌림목 매수 타점 (Ground Truth 보조)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from deepcoin.ground_truth.zigzag import Pivot
|
||||
|
||||
|
||||
class _LegLike(Protocol):
|
||||
"""레그 최소 인터페이스."""
|
||||
|
||||
buy_bar_index: int
|
||||
sell_bar_index: int
|
||||
|
||||
|
||||
def find_pullback_buy_pivots(
|
||||
df: pd.DataFrame,
|
||||
legs: list[_LegLike],
|
||||
min_pullback_pct: float = 1.5,
|
||||
local_order: int = 10,
|
||||
swing_near_bars: int = 120,
|
||||
leg_end_margin_bars: int = 100,
|
||||
) -> list[Pivot]:
|
||||
"""각 스윙 레그 구간에서 돌파 직전 최적 눌림목 1개를 찾는다 (1단계 GT).
|
||||
|
||||
빨간 원 유형(9/27, 7/8 등) — 스윙 매수 후·매도 전 구간의
|
||||
가장 깊은 되돌림 저점을 레그당 1개만 선택한다.
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame.
|
||||
legs: ZigZag 스윙 레그.
|
||||
min_pullback_pct: 직전 고점 대비 최소 되돌림(%).
|
||||
local_order: 국소 저점 판별 반경(봉).
|
||||
swing_near_bars: 스윙 매수 직후 구간 제외(봉).
|
||||
leg_end_margin_bars: 매도 직전 구간 제외(봉).
|
||||
|
||||
Returns:
|
||||
시간순 Pivot(low) 리스트.
|
||||
"""
|
||||
if not legs or len(df) < local_order * 2 + 10:
|
||||
return []
|
||||
|
||||
highs = df["high"].astype(float).values
|
||||
lows = df["low"].astype(float).values
|
||||
pullbacks: list[Pivot] = []
|
||||
|
||||
for leg in legs:
|
||||
full_start = leg.buy_bar_index + swing_near_bars
|
||||
full_end = leg.sell_bar_index - leg_end_margin_bars
|
||||
if full_end <= full_start + local_order * 2:
|
||||
continue
|
||||
|
||||
leg_len = leg.sell_bar_index - leg.buy_bar_index
|
||||
pre_breakout_start = leg.sell_bar_index - int(leg_len * 0.35)
|
||||
start = max(full_start, pre_breakout_start)
|
||||
end = full_end
|
||||
|
||||
best_idx = _best_pullback_in_range(
|
||||
df, lows, highs, leg.buy_bar_index, start, end, local_order, min_pullback_pct
|
||||
)
|
||||
if best_idx is None:
|
||||
best_idx = _best_pullback_in_range(
|
||||
df, lows, highs, leg.buy_bar_index, full_start, full_end, local_order, min_pullback_pct
|
||||
)
|
||||
if best_idx is None:
|
||||
continue
|
||||
|
||||
pullbacks.append(
|
||||
Pivot(
|
||||
bar_index=best_idx,
|
||||
side="low",
|
||||
price=float(lows[best_idx]),
|
||||
datetime=pd.Timestamp(df.iloc[best_idx]["datetime"]),
|
||||
)
|
||||
)
|
||||
|
||||
return pullbacks
|
||||
|
||||
|
||||
def _best_pullback_in_range(
|
||||
df: pd.DataFrame,
|
||||
lows,
|
||||
highs,
|
||||
buy_bar_index: int,
|
||||
start: int,
|
||||
end: int,
|
||||
local_order: int,
|
||||
min_pullback_pct: float,
|
||||
) -> int | None:
|
||||
"""구간 내 최대 되돌림 국소 저점 bar_index를 반환한다."""
|
||||
if end <= start + local_order * 2:
|
||||
return None
|
||||
|
||||
best_idx: int | None = None
|
||||
best_price = float("inf")
|
||||
|
||||
for i in range(max(start, local_order), min(end, len(df) - local_order)):
|
||||
low_val = float(lows[i])
|
||||
window = lows[i - local_order : i + local_order + 1]
|
||||
if low_val > float(window.min()):
|
||||
continue
|
||||
|
||||
region_high = float(highs[buy_bar_index : i + 1].max())
|
||||
if region_high <= 0:
|
||||
continue
|
||||
|
||||
pullback_pct = (region_high - low_val) / region_high * 100.0
|
||||
if pullback_pct < min_pullback_pct:
|
||||
continue
|
||||
|
||||
if low_val < best_price:
|
||||
best_price = low_val
|
||||
best_idx = i
|
||||
|
||||
return best_idx
|
||||
173
src/deepcoin/ground_truth/swing_signals.py
Normal file
173
src/deepcoin/ground_truth/swing_signals.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""독립 매수·매도 스윙 타점 탐지 (레그 1:1 강제 없음)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.ground_truth.zigzag import Pivot, find_zigzag_pivots
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SwingSignal:
|
||||
"""매수 또는 매도 타점."""
|
||||
|
||||
signal_id: int
|
||||
side: str # buy | sell
|
||||
bar_index: int
|
||||
price: float
|
||||
datetime: pd.Timestamp
|
||||
source: str # minor_zigzag | local_extrema
|
||||
swing_pct: float
|
||||
|
||||
|
||||
def find_swing_signals(
|
||||
df: pd.DataFrame,
|
||||
minor_reversal_pct: float = 2.5,
|
||||
local_order: int = 20,
|
||||
min_swing_pct: float = 2.0,
|
||||
min_bars_between: int = 30,
|
||||
) -> tuple[list[SwingSignal], list[SwingSignal]]:
|
||||
"""적절한 매수·매도 타점을 독립적으로 찾는다.
|
||||
|
||||
- 소형 ZigZag(되돌림 2.5% 등): 눌림목·반등 고점
|
||||
- 국소 극값: 횡보 후 돌파 전 저점(빨간 원 구간 유형) 보완
|
||||
- 매수·매도를 1:1 레그로 묶지 않음
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame.
|
||||
minor_reversal_pct: 소형 ZigZag 되돌림 %.
|
||||
local_order: 국소 극값 탐지 반경(봉).
|
||||
min_swing_pct: 국소 극값 인정 최소 변동 %.
|
||||
min_bars_between: 동일 방향 신호 최소 간격(봉).
|
||||
|
||||
Returns:
|
||||
(매수 신호 리스트, 매도 신호 리스트) — 각각 시간순.
|
||||
"""
|
||||
minor_pivots = find_zigzag_pivots(df, reversal_pct=minor_reversal_pct)
|
||||
local_lows = _find_local_lows(df, order=local_order, min_swing_pct=min_swing_pct)
|
||||
local_highs = _find_local_highs(df, order=local_order, min_swing_pct=min_swing_pct)
|
||||
|
||||
buy_candidates = [p for p in minor_pivots if p.side == "low"]
|
||||
sell_candidates = [p for p in minor_pivots if p.side == "high"]
|
||||
|
||||
buy_candidates.extend(local_lows)
|
||||
sell_candidates.extend(local_highs)
|
||||
|
||||
buys = _to_signals(_dedupe_pivots(buy_candidates, min_bars_between, prefer="low"), "buy")
|
||||
sells = _to_signals(_dedupe_pivots(sell_candidates, min_bars_between, prefer="high"), "sell")
|
||||
|
||||
return buys, sells
|
||||
|
||||
|
||||
def _find_local_lows(
|
||||
df: pd.DataFrame,
|
||||
order: int,
|
||||
min_swing_pct: float,
|
||||
) -> list[Pivot]:
|
||||
"""최근 고점 대비 되돌림 후 형성된 국소 저점."""
|
||||
pivots: list[Pivot] = []
|
||||
lookback = order * 4
|
||||
|
||||
for i in range(order, len(df) - order):
|
||||
low_val = float(df.iloc[i]["low"])
|
||||
window = df["low"].iloc[i - order : i + order + 1]
|
||||
if low_val > window.min():
|
||||
continue
|
||||
|
||||
start = max(0, i - lookback)
|
||||
recent_high = float(df["high"].iloc[start : i + 1].max())
|
||||
if recent_high <= 0:
|
||||
continue
|
||||
drop_pct = (recent_high - low_val) / recent_high * 100.0
|
||||
if drop_pct < min_swing_pct:
|
||||
continue
|
||||
|
||||
pivots.append(
|
||||
Pivot(
|
||||
bar_index=i,
|
||||
side="low",
|
||||
price=low_val,
|
||||
datetime=pd.Timestamp(df.iloc[i]["datetime"]),
|
||||
)
|
||||
)
|
||||
return pivots
|
||||
|
||||
|
||||
def _find_local_highs(
|
||||
df: pd.DataFrame,
|
||||
order: int,
|
||||
min_swing_pct: float,
|
||||
) -> list[Pivot]:
|
||||
"""최근 저점 대비 반등 후 형성된 국소 고점."""
|
||||
pivots: list[Pivot] = []
|
||||
lookback = order * 4
|
||||
|
||||
for i in range(order, len(df) - order):
|
||||
high_val = float(df.iloc[i]["high"])
|
||||
window = df["high"].iloc[i - order : i + order + 1]
|
||||
if high_val < window.max():
|
||||
continue
|
||||
|
||||
start = max(0, i - lookback)
|
||||
recent_low = float(df["low"].iloc[start : i + 1].min())
|
||||
if recent_low <= 0:
|
||||
continue
|
||||
rise_pct = (high_val - recent_low) / recent_low * 100.0
|
||||
if rise_pct < min_swing_pct:
|
||||
continue
|
||||
|
||||
pivots.append(
|
||||
Pivot(
|
||||
bar_index=i,
|
||||
side="high",
|
||||
price=high_val,
|
||||
datetime=pd.Timestamp(df.iloc[i]["datetime"]),
|
||||
)
|
||||
)
|
||||
return pivots
|
||||
|
||||
|
||||
def _dedupe_pivots(
|
||||
pivots: list[Pivot],
|
||||
min_bars: int,
|
||||
prefer: str,
|
||||
) -> list[Pivot]:
|
||||
"""가까운 동일 방향 피벗을 병합한다."""
|
||||
if not pivots:
|
||||
return []
|
||||
|
||||
sorted_pivots = sorted(pivots, key=lambda p: p.bar_index)
|
||||
merged: list[Pivot] = [sorted_pivots[0]]
|
||||
|
||||
for pivot in sorted_pivots[1:]:
|
||||
last = merged[-1]
|
||||
if pivot.bar_index - last.bar_index < min_bars:
|
||||
if prefer == "low" and pivot.price < last.price:
|
||||
merged[-1] = pivot
|
||||
elif prefer == "high" and pivot.price > last.price:
|
||||
merged[-1] = pivot
|
||||
else:
|
||||
merged.append(pivot)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _to_signals(pivots: list[Pivot], side: str) -> list[SwingSignal]:
|
||||
"""Pivot 리스트를 SwingSignal로 변환한다."""
|
||||
source = "local_extrema"
|
||||
signals: list[SwingSignal] = []
|
||||
for idx, pivot in enumerate(pivots, start=1):
|
||||
signals.append(
|
||||
SwingSignal(
|
||||
signal_id=idx,
|
||||
side=side,
|
||||
bar_index=pivot.bar_index,
|
||||
price=round(pivot.price, 2),
|
||||
datetime=pivot.datetime,
|
||||
source=source,
|
||||
swing_pct=0.0,
|
||||
)
|
||||
)
|
||||
return signals
|
||||
120
src/deepcoin/ground_truth/zigzag.py
Normal file
120
src/deepcoin/ground_truth/zigzag.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""ZigZag 스윙 피벗 탐지 (Ground Truth용, 미래 데이터 허용)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pivot:
|
||||
"""스윙 고점/저점."""
|
||||
|
||||
bar_index: int
|
||||
side: str # "low" | "high"
|
||||
price: float
|
||||
datetime: pd.Timestamp
|
||||
|
||||
|
||||
def find_zigzag_pivots(
|
||||
df: pd.DataFrame,
|
||||
reversal_pct: float = 5.0,
|
||||
) -> list[Pivot]:
|
||||
"""ZigZag 알고리즘으로 교대 스윙 피벗을 찾는다.
|
||||
|
||||
가격이 직전 극값 대비 `reversal_pct`% 이상 되돌림되면 피벗을 확정한다.
|
||||
Ground Truth 라벨링용이므로 전체 시계열(미래 포함)을 사용한다.
|
||||
|
||||
Args:
|
||||
df: datetime, high, low, close 컬럼 DataFrame.
|
||||
reversal_pct: 피벗 확정 최소 되돌림 비율(%).
|
||||
|
||||
Returns:
|
||||
시간순 피벗 리스트 (low/high 교대).
|
||||
"""
|
||||
if len(df) < 3:
|
||||
return []
|
||||
|
||||
threshold = reversal_pct / 100.0
|
||||
pivots: list[Pivot] = []
|
||||
|
||||
direction: str | None = None
|
||||
extreme_idx = 0
|
||||
extreme_price = float(df.iloc[0]["close"])
|
||||
anchor_price = extreme_price
|
||||
|
||||
for i in range(1, len(df)):
|
||||
high = float(df.iloc[i]["high"])
|
||||
low = float(df.iloc[i]["low"])
|
||||
|
||||
if direction is None:
|
||||
if high >= anchor_price * (1 + threshold):
|
||||
direction = "up"
|
||||
extreme_idx = i
|
||||
extreme_price = high
|
||||
elif low <= anchor_price * (1 - threshold):
|
||||
direction = "down"
|
||||
extreme_idx = i
|
||||
extreme_price = low
|
||||
continue
|
||||
|
||||
if direction == "up":
|
||||
if high >= extreme_price:
|
||||
extreme_price = high
|
||||
extreme_idx = i
|
||||
if low <= extreme_price * (1 - threshold):
|
||||
pivots.append(_make_pivot(df, extreme_idx, "high", extreme_price))
|
||||
direction = "down"
|
||||
extreme_idx = i
|
||||
extreme_price = low
|
||||
anchor_price = extreme_price
|
||||
else:
|
||||
if low <= extreme_price:
|
||||
extreme_price = low
|
||||
extreme_idx = i
|
||||
if high >= extreme_price * (1 + threshold):
|
||||
pivots.append(_make_pivot(df, extreme_idx, "low", extreme_price))
|
||||
direction = "up"
|
||||
extreme_idx = i
|
||||
extreme_price = high
|
||||
anchor_price = extreme_price
|
||||
|
||||
# 마지막 미확정 극값도 피벗으로 추가 (방향에 따라)
|
||||
if direction == "up":
|
||||
pivots.append(_make_pivot(df, extreme_idx, "high", extreme_price))
|
||||
elif direction == "down":
|
||||
pivots.append(_make_pivot(df, extreme_idx, "low", extreme_price))
|
||||
|
||||
return _normalize_alternating(pivots)
|
||||
|
||||
|
||||
def _make_pivot(df: pd.DataFrame, idx: int, side: str, price: float) -> Pivot:
|
||||
"""Pivot 객체를 생성한다."""
|
||||
return Pivot(
|
||||
bar_index=idx,
|
||||
side=side,
|
||||
price=price,
|
||||
datetime=pd.Timestamp(df.iloc[idx]["datetime"]),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_alternating(pivots: list[Pivot]) -> list[Pivot]:
|
||||
"""인접 동일 side 피벗을 제거하고 low/high 교대를 맞춘다."""
|
||||
if not pivots:
|
||||
return []
|
||||
|
||||
cleaned: list[Pivot] = [pivots[0]]
|
||||
for pivot in pivots[1:]:
|
||||
last = cleaned[-1]
|
||||
if pivot.side == last.side:
|
||||
# 같은 방향이면 더 극단적인 가격으로 갱신
|
||||
if pivot.side == "high" and pivot.price >= last.price:
|
||||
cleaned[-1] = pivot
|
||||
elif pivot.side == "low" and pivot.price <= last.price:
|
||||
cleaned[-1] = pivot
|
||||
else:
|
||||
cleaned.append(pivot)
|
||||
|
||||
# 첫 피벗이 high면 앞 low가 누락된 경우 — GT에서는 허용
|
||||
return cleaned
|
||||
23
src/deepcoin/mtf/__init__.py
Normal file
23
src/deepcoin/mtf/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""멀티 타임프레임(MTF) 인과 피처 추출."""
|
||||
|
||||
from deepcoin.mtf.alignment import as_of_from_signal_bar, last_complete_bar_index
|
||||
from deepcoin.mtf.extractor import MtfFeatureExtractor, MtfSnapshot
|
||||
from deepcoin.mtf.filter import MtfSignalFilter, score_mtf_rules
|
||||
from deepcoin.mtf.rules import MtfRule, MtfRuleSet, derive_rules_from_report, load_mtf_rules, save_mtf_rules
|
||||
from deepcoin.mtf.store import MTF_INTERVALS, MultiTimeframeStore
|
||||
|
||||
__all__ = [
|
||||
"MTF_INTERVALS",
|
||||
"MultiTimeframeStore",
|
||||
"MtfFeatureExtractor",
|
||||
"MtfSnapshot",
|
||||
"MtfSignalFilter",
|
||||
"MtfRule",
|
||||
"MtfRuleSet",
|
||||
"derive_rules_from_report",
|
||||
"load_mtf_rules",
|
||||
"save_mtf_rules",
|
||||
"score_mtf_rules",
|
||||
"as_of_from_signal_bar",
|
||||
"last_complete_bar_index",
|
||||
]
|
||||
94
src/deepcoin/mtf/alignment.py
Normal file
94
src/deepcoin/mtf/alignment.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""MTF 봉 정렬 — 미완성 봉 제외, look-ahead 방지."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def bar_close_datetime(open_dt: pd.Timestamp, interval_min: int) -> pd.Timestamp:
|
||||
"""봉 시가 시각 기준 종가(마감) 시각을 반환한다.
|
||||
|
||||
Args:
|
||||
open_dt: 봉 시가(오픈) 시각.
|
||||
interval_min: 분 단위 인터벌 코드.
|
||||
|
||||
Returns:
|
||||
봉이 완전히 마감된 시각.
|
||||
"""
|
||||
return open_dt + pd.Timedelta(minutes=interval_min)
|
||||
|
||||
|
||||
def as_of_from_signal_bar(
|
||||
signal_open: pd.Timestamp,
|
||||
base_interval_min: int = 3,
|
||||
) -> pd.Timestamp:
|
||||
"""3분봉 신호 봉 종가 시점(판단 시각)을 반환한다.
|
||||
|
||||
Args:
|
||||
signal_open: 신호가 찍힌 기준 봉(3분)의 시가 시각.
|
||||
base_interval_min: 기준(체결) 인터벌 분.
|
||||
|
||||
Returns:
|
||||
해당 봉이 마감된 시각 = signal_open + base_interval_min.
|
||||
"""
|
||||
return bar_close_datetime(signal_open, base_interval_min)
|
||||
|
||||
|
||||
def is_bar_complete(
|
||||
open_dt: pd.Timestamp,
|
||||
interval_min: int,
|
||||
as_of: pd.Timestamp,
|
||||
) -> bool:
|
||||
"""as_of 시점에 해당 봉이 완전히 마감되었는지 판별한다."""
|
||||
return bar_close_datetime(open_dt, interval_min) <= as_of
|
||||
|
||||
|
||||
def last_complete_bar_index(
|
||||
datetimes: pd.Series,
|
||||
interval_min: int,
|
||||
as_of: pd.Timestamp,
|
||||
) -> int | None:
|
||||
"""as_of 이전(포함)에 마감된 마지막 봉 인덱스를 반환한다.
|
||||
|
||||
Args:
|
||||
datetimes: 봉 시가 시각 시리즈 (오름차순).
|
||||
interval_min: 분 단위 인터벌.
|
||||
as_of: 판단 시각 — 이 시각까지 마감된 봉만 사용.
|
||||
|
||||
Returns:
|
||||
마지막 완성 봉 인덱스. 없으면 None.
|
||||
"""
|
||||
if datetimes.empty:
|
||||
return None
|
||||
|
||||
close_times = datetimes + pd.to_timedelta(interval_min, unit="m")
|
||||
valid = close_times <= as_of
|
||||
if not valid.any():
|
||||
return None
|
||||
return int(valid.to_numpy().nonzero()[0][-1])
|
||||
|
||||
|
||||
def resolve_bar_index(
|
||||
datetimes: pd.Series,
|
||||
interval_min: int,
|
||||
base_interval_min: int,
|
||||
signal_open: pd.Timestamp,
|
||||
as_of: pd.Timestamp,
|
||||
) -> int | None:
|
||||
"""기준 TF는 신호 봉, 상위 TF는 마감 완료 봉 인덱스를 반환한다.
|
||||
|
||||
Args:
|
||||
datetimes: 해당 TF 봉 시가 시각.
|
||||
interval_min: 조회 TF 분 코드.
|
||||
base_interval_min: 체결 기준 TF (3분).
|
||||
signal_open: GT/신호 3분봉 시가 시각.
|
||||
as_of: 판단 시각(3분봉 종가).
|
||||
|
||||
Returns:
|
||||
피처 추출에 사용할 bar index.
|
||||
"""
|
||||
if interval_min == base_interval_min:
|
||||
matches = datetimes[datetimes == signal_open]
|
||||
if not matches.empty:
|
||||
return int(matches.index[0])
|
||||
return last_complete_bar_index(datetimes, interval_min, as_of)
|
||||
114
src/deepcoin/mtf/extractor.py
Normal file
114
src/deepcoin/mtf/extractor.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""시점별 멀티 TF 피처 스냅샷 추출."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.mtf.alignment import as_of_from_signal_bar, resolve_bar_index
|
||||
from deepcoin.mtf.features import snapshot_at_index
|
||||
from deepcoin.mtf.store import MTF_INTERVALS, MultiTimeframeStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class MtfSnapshot:
|
||||
"""단일 판단 시점의 멀티 TF 피처 묶음."""
|
||||
|
||||
signal_datetime: str
|
||||
as_of: str
|
||||
base_interval_min: int
|
||||
timeframes: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""JSON 직렬화용 dict."""
|
||||
return {
|
||||
"signal_datetime": self.signal_datetime,
|
||||
"as_of": self.as_of,
|
||||
"base_interval_min": self.base_interval_min,
|
||||
"timeframes": self.timeframes,
|
||||
}
|
||||
|
||||
|
||||
class MtfFeatureExtractor:
|
||||
"""GT/신호 시각에서 인과 MTF 피처 스냅샷을 추출한다."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MultiTimeframeStore,
|
||||
base_interval_min: int = 3,
|
||||
intervals: tuple[int, ...] | None = None,
|
||||
) -> None:
|
||||
"""Extractor를 생성한다.
|
||||
|
||||
Args:
|
||||
store: 로드된 MultiTimeframeStore.
|
||||
base_interval_min: 체결·신호 기준 TF (3분).
|
||||
intervals: 추출 TF 목록. None이면 store.intervals.
|
||||
"""
|
||||
self.store = store
|
||||
self.base_interval_min = base_interval_min
|
||||
self.intervals = intervals or store.intervals
|
||||
store.load()
|
||||
|
||||
def extract_at(self, signal_datetime: str | pd.Timestamp) -> MtfSnapshot | None:
|
||||
"""신호 시각(3분봉 시가)에서 MTF 스냅샷을 추출한다.
|
||||
|
||||
미래 데이터·미완성 상위 TF 봉은 사용하지 않는다.
|
||||
|
||||
Args:
|
||||
signal_datetime: GT signals.datetime (3분봉 시가).
|
||||
|
||||
Returns:
|
||||
MtfSnapshot. 기준 3분봉이 없으면 None.
|
||||
"""
|
||||
signal_open = pd.Timestamp(signal_datetime)
|
||||
as_of = as_of_from_signal_bar(signal_open, self.base_interval_min)
|
||||
signal_str = signal_open.strftime("%Y-%m-%d %H:%M:%S")
|
||||
as_of_str = as_of.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
base_df = self.store.get_raw(self.base_interval_min)
|
||||
if signal_open not in base_df["datetime"].values:
|
||||
return None
|
||||
|
||||
timeframes: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for interval_min in self.intervals:
|
||||
label = self.store.interval_label(interval_min)
|
||||
feat_df = self.store.get_features(interval_min)
|
||||
bar_idx = resolve_bar_index(
|
||||
feat_df["datetime"],
|
||||
interval_min,
|
||||
self.base_interval_min,
|
||||
signal_open,
|
||||
as_of,
|
||||
)
|
||||
if bar_idx is None:
|
||||
timeframes[label] = {"interval_min": interval_min, "available": False}
|
||||
continue
|
||||
|
||||
snap = snapshot_at_index(feat_df, bar_idx)
|
||||
snap["interval_min"] = interval_min
|
||||
snap["interval_label"] = label
|
||||
snap["available"] = True
|
||||
timeframes[label] = snap
|
||||
|
||||
return MtfSnapshot(
|
||||
signal_datetime=signal_str,
|
||||
as_of=as_of_str,
|
||||
base_interval_min=self.base_interval_min,
|
||||
timeframes=timeframes,
|
||||
)
|
||||
|
||||
def extract_many(
|
||||
self,
|
||||
datetimes: list[str],
|
||||
) -> list[MtfSnapshot]:
|
||||
"""여러 시각의 스냅샷을 순서대로 추출한다."""
|
||||
results: list[MtfSnapshot] = []
|
||||
for dt in datetimes:
|
||||
snap = self.extract_at(dt)
|
||||
if snap is not None:
|
||||
results.append(snap)
|
||||
return results
|
||||
184
src/deepcoin/mtf/features.py
Normal file
184
src/deepcoin/mtf/features.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""TF별 기술적 피처 계산 (인과, 봉 인덱스 기준)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.indicators import atr, bollinger_bands, ema, macd, rsi
|
||||
|
||||
FEATURE_NAMES: tuple[str, ...] = (
|
||||
"close",
|
||||
"ema60",
|
||||
"close_vs_ema60_pct",
|
||||
"ema60_slope_5_pct",
|
||||
"rsi14",
|
||||
"macd_hist",
|
||||
"bb_position",
|
||||
"atr_pct",
|
||||
"zigzag_direction",
|
||||
"zigzag_leg_pct",
|
||||
"trend_bias",
|
||||
)
|
||||
|
||||
|
||||
def compute_feature_frame(
|
||||
df: pd.DataFrame,
|
||||
reversal_pct: float = 5.0,
|
||||
) -> pd.DataFrame:
|
||||
"""OHLCV에 MTF 분석용 피처 컬럼을 추가한다.
|
||||
|
||||
Args:
|
||||
df: datetime, open, high, low, close, volume.
|
||||
reversal_pct: 인과 ZigZag 되돌림 %.
|
||||
|
||||
Returns:
|
||||
피처 컬럼이 추가된 DataFrame (원본 컬럼 유지).
|
||||
"""
|
||||
out = df.copy()
|
||||
close = out["close"].astype(float)
|
||||
high = out["high"].astype(float)
|
||||
low = out["low"].astype(float)
|
||||
|
||||
ema60 = ema(close, 60)
|
||||
out["ema60"] = ema60
|
||||
out["close_vs_ema60_pct"] = (close - ema60) / ema60.replace(0, pd.NA) * 100.0
|
||||
ema_shift = ema60.shift(5)
|
||||
out["ema60_slope_5_pct"] = (ema60 - ema_shift) / ema_shift.replace(0, pd.NA) * 100.0
|
||||
|
||||
out["rsi14"] = rsi(close, 14)
|
||||
_, _, macd_hist = macd(close)
|
||||
out["macd_hist"] = macd_hist
|
||||
|
||||
bb_mid, bb_upper, bb_lower = bollinger_bands(close, 20, 2.0)
|
||||
band_width = (bb_upper - bb_lower).replace(0, pd.NA)
|
||||
out["bb_position"] = (close - bb_lower) / band_width
|
||||
|
||||
atr14 = atr(high, low, close, 14)
|
||||
out["atr_pct"] = atr14 / close.replace(0, pd.NA) * 100.0
|
||||
|
||||
zz_dir, zz_leg = _compute_zigzag_series(out, reversal_pct)
|
||||
out["zigzag_direction"] = zz_dir
|
||||
out["zigzag_leg_pct"] = zz_leg
|
||||
|
||||
out["trend_bias"] = pd.Series(
|
||||
["bullish" if c > e else "bearish" for c, e in zip(close, ema60, strict=False)],
|
||||
index=out.index,
|
||||
dtype="object",
|
||||
)
|
||||
out.loc[ema60.isna(), "trend_bias"] = pd.NA
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _compute_zigzag_series(
|
||||
df: pd.DataFrame,
|
||||
reversal_pct: float,
|
||||
) -> tuple[pd.Series, pd.Series]:
|
||||
"""각 봉 시점의 인과 ZigZag 방향·leg %를 계산한다.
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame.
|
||||
reversal_pct: 피벗 확정 되돌림 %.
|
||||
|
||||
Returns:
|
||||
(direction_series, leg_pct_series). direction은 up/down/none.
|
||||
"""
|
||||
n = len(df)
|
||||
directions = ["none"] * n
|
||||
leg_pcts: list[float | None] = [None] * n
|
||||
|
||||
if n < 2:
|
||||
return pd.Series(directions, index=df.index), pd.Series(leg_pcts, index=df.index)
|
||||
|
||||
threshold = reversal_pct / 100.0
|
||||
direction: str | None = None
|
||||
extreme_idx = 0
|
||||
extreme_price = float(df.iloc[0]["close"])
|
||||
pivot_price = extreme_price
|
||||
|
||||
for i in range(n):
|
||||
high = float(df.iloc[i]["high"])
|
||||
low = float(df.iloc[i]["low"])
|
||||
|
||||
if direction is None:
|
||||
if high >= extreme_price * (1 + threshold):
|
||||
direction = "up"
|
||||
extreme_idx = i
|
||||
extreme_price = high
|
||||
pivot_price = extreme_price
|
||||
elif low <= extreme_price * (1 - threshold):
|
||||
direction = "down"
|
||||
extreme_idx = i
|
||||
extreme_price = low
|
||||
pivot_price = extreme_price
|
||||
elif direction == "up":
|
||||
if high >= extreme_price:
|
||||
extreme_price = high
|
||||
extreme_idx = i
|
||||
if low <= extreme_price * (1 - threshold):
|
||||
pivot_price = extreme_price
|
||||
direction = "down"
|
||||
extreme_idx = i
|
||||
extreme_price = low
|
||||
else:
|
||||
if low <= extreme_price:
|
||||
extreme_price = low
|
||||
extreme_idx = i
|
||||
if high >= extreme_price * (1 + threshold):
|
||||
pivot_price = extreme_price
|
||||
direction = "up"
|
||||
extreme_idx = i
|
||||
extreme_price = high
|
||||
|
||||
directions[i] = direction or "none"
|
||||
ref = pivot_price if pivot_price else float(df.iloc[i]["close"])
|
||||
price = float(df.iloc[i]["close"])
|
||||
if ref:
|
||||
leg_pcts[i] = round((price - ref) / ref * 100.0, 4)
|
||||
|
||||
return pd.Series(directions, index=df.index), pd.Series(leg_pcts, index=df.index)
|
||||
|
||||
|
||||
def snapshot_at_index(feat_df: pd.DataFrame, bar_index: int) -> dict[str, Any]:
|
||||
"""단일 TF·단일 bar_index의 피처 dict를 반환한다.
|
||||
|
||||
Args:
|
||||
feat_df: compute_feature_frame 출력.
|
||||
bar_index: 행 인덱스 (0-based).
|
||||
|
||||
Returns:
|
||||
FEATURE_NAMES 키를 포함한 피처 dict. bar 메타 포함.
|
||||
"""
|
||||
if bar_index < 0 or bar_index >= len(feat_df):
|
||||
return {}
|
||||
|
||||
row = feat_df.iloc[bar_index]
|
||||
dt = pd.Timestamp(row["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _num(key: str) -> float | None:
|
||||
val = row.get(key)
|
||||
if val is None or pd.isna(val):
|
||||
return None
|
||||
return round(float(val), 4)
|
||||
|
||||
return {
|
||||
"bar_index": bar_index,
|
||||
"bar_datetime": dt,
|
||||
"close": _num("close"),
|
||||
"ema60": _num("ema60"),
|
||||
"close_vs_ema60_pct": _num("close_vs_ema60_pct"),
|
||||
"ema60_slope_5_pct": _num("ema60_slope_5_pct"),
|
||||
"rsi14": _num("rsi14"),
|
||||
"macd_hist": _num("macd_hist"),
|
||||
"bb_position": _num("bb_position"),
|
||||
"atr_pct": _num("atr_pct"),
|
||||
"zigzag_direction": str(row.get("zigzag_direction", "none")),
|
||||
"zigzag_leg_pct": _num("zigzag_leg_pct"),
|
||||
"trend_bias": (
|
||||
None
|
||||
if pd.isna(row.get("trend_bias"))
|
||||
else str(row.get("trend_bias"))
|
||||
),
|
||||
}
|
||||
161
src/deepcoin/mtf/filter.py
Normal file
161
src/deepcoin/mtf/filter.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""MTF 규칙 기반 composite 신호 필터."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from deepcoin.mtf.trend_gate import HtfTrendGate
|
||||
from deepcoin.mtf.extractor import MtfFeatureExtractor, MtfSnapshot
|
||||
from deepcoin.mtf.rules import MtfRule, MtfRuleSet
|
||||
|
||||
|
||||
def evaluate_rule(rule: MtfRule, snapshot: MtfSnapshot) -> bool | None:
|
||||
"""단일 규칙을 스냅샷에 적용한다.
|
||||
|
||||
Args:
|
||||
rule: MTF 규칙.
|
||||
snapshot: 시점 MTF 스냅샷.
|
||||
|
||||
Returns:
|
||||
True/False 또는 피처 없음 시 None.
|
||||
"""
|
||||
tf = snapshot.timeframes.get(rule.timeframe_label, {})
|
||||
if not tf.get("available"):
|
||||
return None
|
||||
|
||||
value = tf.get(rule.feature)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
numeric = float(value)
|
||||
if rule.operator == "<=":
|
||||
return numeric <= rule.threshold
|
||||
return numeric >= rule.threshold
|
||||
|
||||
|
||||
def score_mtf_rules(
|
||||
signal_type: str,
|
||||
snapshot: MtfSnapshot,
|
||||
rule_set: MtfRuleSet,
|
||||
) -> dict[str, Any]:
|
||||
"""신호 유형 규칙 충족 점수를 계산한다.
|
||||
|
||||
Returns:
|
||||
passed, total_evaluated, total_rules, details 키를 가진 dict.
|
||||
"""
|
||||
rules = rule_set.rules_for(signal_type)
|
||||
if not rules:
|
||||
return {
|
||||
"passed": True,
|
||||
"total_evaluated": 0,
|
||||
"total_rules": 0,
|
||||
"required_pass": rule_set.min_rules_pass,
|
||||
"details": [],
|
||||
"reason": "no_rules_for_type",
|
||||
}
|
||||
|
||||
details: list[dict[str, Any]] = []
|
||||
passed = 0
|
||||
evaluated = 0
|
||||
|
||||
for rule in rules:
|
||||
result = evaluate_rule(rule, snapshot)
|
||||
detail = {
|
||||
"timeframe": rule.timeframe_label,
|
||||
"feature": rule.feature,
|
||||
"operator": rule.operator,
|
||||
"threshold": rule.threshold,
|
||||
"result": result,
|
||||
}
|
||||
details.append(detail)
|
||||
if result is None:
|
||||
continue
|
||||
evaluated += 1
|
||||
if result:
|
||||
passed += 1
|
||||
|
||||
required = min(rule_set.min_rules_pass, len(rules))
|
||||
if evaluated == 0:
|
||||
ok = True
|
||||
reason = "no_features_available"
|
||||
elif evaluated < required:
|
||||
ok = passed == evaluated
|
||||
reason = "partial_eval_all_pass"
|
||||
else:
|
||||
ok = passed >= required
|
||||
reason = "score_ok" if ok else "score_below_min"
|
||||
|
||||
return {
|
||||
"passed": ok,
|
||||
"passed_count": passed,
|
||||
"total_evaluated": evaluated,
|
||||
"total_rules": len(rules),
|
||||
"required_pass": required,
|
||||
"details": details,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
class MtfSignalFilter:
|
||||
"""composite_v3 신호에 MTF 규칙 필터를 적용한다."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
extractor: MtfFeatureExtractor,
|
||||
rule_set: MtfRuleSet,
|
||||
trend_gate: HtfTrendGate | None = None,
|
||||
) -> None:
|
||||
"""Filter 생성."""
|
||||
self.extractor = extractor
|
||||
self.rule_set = rule_set
|
||||
self.trend_gate = trend_gate
|
||||
self._cache: dict[str, MtfSnapshot | None] = {}
|
||||
|
||||
def _snapshot(self, signal_datetime: str) -> MtfSnapshot | None:
|
||||
"""스냅샷 캐시 조회."""
|
||||
if signal_datetime not in self._cache:
|
||||
self._cache[signal_datetime] = self.extractor.extract_at(signal_datetime)
|
||||
return self._cache[signal_datetime]
|
||||
|
||||
def passes(self, signal: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
|
||||
"""단일 GT 스키마 신호가 MTF 규칙을 통과하는지 판별."""
|
||||
signal_type = str(signal.get("signal_type", ""))
|
||||
snap = self._snapshot(str(signal["datetime"]))
|
||||
if snap is None:
|
||||
return False, {"reason": "snapshot_missing", "passed": False}
|
||||
|
||||
if self.trend_gate and self.trend_gate.enabled:
|
||||
snap_dict = snap.to_dict()
|
||||
side = str(signal.get("side", ""))
|
||||
if side == "buy":
|
||||
ok_gate, gate_reason = self.trend_gate.allows_buy(snap_dict)
|
||||
else:
|
||||
ok_gate, gate_reason = self.trend_gate.allows_sell(snap_dict)
|
||||
if not ok_gate:
|
||||
return False, {"reason": "htf_gate_blocked", "gate": gate_reason, "passed": False}
|
||||
|
||||
score = score_mtf_rules(signal_type, snap, self.rule_set)
|
||||
return bool(score["passed"]), score
|
||||
|
||||
def filter_signals(
|
||||
self,
|
||||
signals: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""신호를 kept / rejected 로 분리한다."""
|
||||
kept: list[dict[str, Any]] = []
|
||||
rejected: list[dict[str, Any]] = []
|
||||
|
||||
for sig in sorted(signals, key=lambda s: s["bar_index"]):
|
||||
ok, meta = self.passes(sig)
|
||||
sig_copy = dict(sig)
|
||||
sig_copy["mtf_filter"] = meta
|
||||
if ok:
|
||||
kept.append(sig_copy)
|
||||
else:
|
||||
rejected.append(sig_copy)
|
||||
|
||||
for idx, sig in enumerate(kept, start=1):
|
||||
sig["marker_id"] = idx
|
||||
sig["leg_id"] = idx
|
||||
|
||||
return kept, rejected
|
||||
106
src/deepcoin/mtf/precompute.py
Normal file
106
src/deepcoin/mtf/precompute.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""3분봉 타임라인 ↔ 각 TF bar index 사전 정렬 (벡터화)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.mtf.alignment import as_of_from_signal_bar
|
||||
from deepcoin.mtf.features import snapshot_at_index
|
||||
from deepcoin.mtf.store import MultiTimeframeStore
|
||||
|
||||
|
||||
def _vectorized_tf_indices(
|
||||
base_opens: pd.Series,
|
||||
tf_opens: pd.Series,
|
||||
base_interval_min: int,
|
||||
tf_interval_min: int,
|
||||
) -> np.ndarray:
|
||||
"""3m 각 봉에 대한 TF bar index 배열을 벡터화 계산한다."""
|
||||
n = len(base_opens)
|
||||
result = np.full(n, -1, dtype=np.int64)
|
||||
|
||||
if tf_opens.empty:
|
||||
return result
|
||||
|
||||
as_ofs = base_opens + pd.to_timedelta(base_interval_min, unit="m")
|
||||
tf_closes = tf_opens + pd.to_timedelta(tf_interval_min, unit="m")
|
||||
close_vals = tf_closes.to_numpy(dtype="datetime64[ns]")
|
||||
as_of_vals = as_ofs.to_numpy(dtype="datetime64[ns]")
|
||||
open_vals = tf_opens.to_numpy(dtype="datetime64[ns]")
|
||||
base_open_vals = base_opens.to_numpy(dtype="datetime64[ns]")
|
||||
|
||||
if tf_interval_min == base_interval_min:
|
||||
pos = np.searchsorted(open_vals, base_open_vals)
|
||||
valid = (pos < len(open_vals)) & (open_vals[pos] == base_open_vals)
|
||||
result[valid] = pos[valid]
|
||||
return result
|
||||
|
||||
pos = np.searchsorted(close_vals, as_of_vals, side="right") - 1
|
||||
valid = pos >= 0
|
||||
result[valid] = pos[valid]
|
||||
return result
|
||||
|
||||
|
||||
def build_3m_to_tf_bar_indices(
|
||||
base_df: pd.DataFrame,
|
||||
store: MultiTimeframeStore,
|
||||
base_interval_min: int = 3,
|
||||
) -> dict[int, np.ndarray]:
|
||||
"""각 3m bar index에 대응하는 TF별 feature bar index를 계산한다.
|
||||
|
||||
Args:
|
||||
base_df: 3m OHLCV (store.base_df와 동일 타임라인).
|
||||
store: 로드된 MTF store.
|
||||
base_interval_min: 기준 TF 분.
|
||||
|
||||
Returns:
|
||||
interval_min → int64 ndarray (-1 = 미사용).
|
||||
"""
|
||||
store.load()
|
||||
base_opens = base_df["datetime"]
|
||||
mapping: dict[int, np.ndarray] = {}
|
||||
|
||||
for interval_min in store.intervals:
|
||||
feat_df = store.get_features(interval_min)
|
||||
mapping[interval_min] = _vectorized_tf_indices(
|
||||
base_opens,
|
||||
feat_df["datetime"],
|
||||
base_interval_min,
|
||||
interval_min,
|
||||
)
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
def snapshot_at_3m_bar(
|
||||
bar_index: int,
|
||||
store: MultiTimeframeStore,
|
||||
tf_bar_indices: dict[int, np.ndarray],
|
||||
base_interval_min: int = 3,
|
||||
) -> dict:
|
||||
"""사전 계산된 인덱스로 MTF 스냅샷 dict를 빠르게 조립한다."""
|
||||
base_df = store.base_df
|
||||
signal_open = pd.Timestamp(base_df.iloc[bar_index]["datetime"])
|
||||
as_of = as_of_from_signal_bar(signal_open, base_interval_min)
|
||||
|
||||
timeframes: dict[str, dict] = {}
|
||||
for interval_min in store.intervals:
|
||||
label = store.interval_label(interval_min)
|
||||
tf_idx = int(tf_bar_indices[interval_min][bar_index])
|
||||
if tf_idx < 0:
|
||||
timeframes[label] = {"interval_min": interval_min, "available": False}
|
||||
continue
|
||||
feat_df = store.get_features(interval_min)
|
||||
snap = snapshot_at_index(feat_df, tf_idx)
|
||||
snap["interval_min"] = interval_min
|
||||
snap["interval_label"] = label
|
||||
snap["available"] = True
|
||||
timeframes[label] = snap
|
||||
|
||||
return {
|
||||
"signal_datetime": signal_open.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"as_of": as_of.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"base_interval_min": base_interval_min,
|
||||
"timeframes": timeframes,
|
||||
}
|
||||
255
src/deepcoin/mtf/rules.py
Normal file
255
src/deepcoin/mtf/rules.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""MTF 상관 리포트 기반 규칙 정의·도출."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from deepcoin.evaluation.gt_align import GT_SIGNAL_TYPES
|
||||
|
||||
Operator = Literal["<=", ">="]
|
||||
|
||||
# BTC 가격 스케일에 민감한 지표는 자동 규칙에서 제외
|
||||
_EXCLUDED_AUTO_FEATURES: frozenset[str] = frozenset({"macd_hist", "zigzag_leg_pct", "close"})
|
||||
|
||||
# 자동 규칙에 사용할 안정 피처
|
||||
_PREFERRED_FEATURES: tuple[str, ...] = (
|
||||
"close_vs_ema60_pct",
|
||||
"ema60_slope_5_pct",
|
||||
"rsi14",
|
||||
"bb_position",
|
||||
"atr_pct",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MtfRule:
|
||||
"""단일 MTF 조건 (신호 유형별)."""
|
||||
|
||||
signal_type: str
|
||||
timeframe_label: str
|
||||
interval_min: int
|
||||
feature: str
|
||||
operator: Operator
|
||||
threshold: float
|
||||
cohens_d: float
|
||||
positive_mean: float
|
||||
negative_mean: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""JSON 직렬화 dict."""
|
||||
return {
|
||||
"signal_type": self.signal_type,
|
||||
"timeframe_label": self.timeframe_label,
|
||||
"interval_min": self.interval_min,
|
||||
"feature": self.feature,
|
||||
"operator": self.operator,
|
||||
"threshold": self.threshold,
|
||||
"cohens_d": self.cohens_d,
|
||||
"positive_mean": self.positive_mean,
|
||||
"negative_mean": self.negative_mean,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> MtfRule:
|
||||
"""dict에서 MtfRule 생성."""
|
||||
return cls(
|
||||
signal_type=str(raw["signal_type"]),
|
||||
timeframe_label=str(raw["timeframe_label"]),
|
||||
interval_min=int(raw["interval_min"]),
|
||||
feature=str(raw["feature"]),
|
||||
operator=raw["operator"], # type: ignore[arg-type]
|
||||
threshold=float(raw["threshold"]),
|
||||
cohens_d=float(raw.get("cohens_d", 0.0)),
|
||||
positive_mean=float(raw.get("positive_mean", 0.0)),
|
||||
negative_mean=float(raw.get("negative_mean", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MtfRuleSet:
|
||||
"""신호 유형별 MTF 규칙 묶음."""
|
||||
|
||||
version: str = "v1"
|
||||
min_rules_pass: int = 2
|
||||
min_cohens_d: float = 1.2
|
||||
max_rules_per_type: int = 4
|
||||
rules_by_type: dict[str, list[MtfRule]] = field(default_factory=dict)
|
||||
source_report: str = ""
|
||||
|
||||
def rules_for(self, signal_type: str) -> list[MtfRule]:
|
||||
"""신호 유형에 해당하는 규칙 목록."""
|
||||
return self.rules_by_type.get(signal_type, [])
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""JSON 직렬화 dict."""
|
||||
return {
|
||||
"version": self.version,
|
||||
"min_rules_pass": self.min_rules_pass,
|
||||
"min_cohens_d": self.min_cohens_d,
|
||||
"max_rules_per_type": self.max_rules_per_type,
|
||||
"source_report": self.source_report,
|
||||
"rules_by_type": {
|
||||
st: [r.to_dict() for r in rules]
|
||||
for st, rules in self.rules_by_type.items()
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> MtfRuleSet:
|
||||
"""dict에서 MtfRuleSet 생성."""
|
||||
rules_by_type: dict[str, list[MtfRule]] = {}
|
||||
for st, items in (raw.get("rules_by_type") or {}).items():
|
||||
rules_by_type[st] = [MtfRule.from_dict(item) for item in items]
|
||||
return cls(
|
||||
version=str(raw.get("version", "v1")),
|
||||
min_rules_pass=int(raw.get("min_rules_pass", 2)),
|
||||
min_cohens_d=float(raw.get("min_cohens_d", 1.2)),
|
||||
max_rules_per_type=int(raw.get("max_rules_per_type", 4)),
|
||||
rules_by_type=rules_by_type,
|
||||
source_report=str(raw.get("source_report", "")),
|
||||
)
|
||||
|
||||
|
||||
def _label_to_interval(label: str, tf_list: list[dict[str, Any]]) -> int:
|
||||
"""TF 라벨 → interval_min."""
|
||||
for item in tf_list:
|
||||
if item.get("label") == label:
|
||||
return int(item["interval_min"])
|
||||
raise KeyError(f"unknown timeframe label: {label}")
|
||||
|
||||
|
||||
def derive_rules_from_report(
|
||||
report: dict[str, Any],
|
||||
min_cohens_d: float = 1.2,
|
||||
max_rules_per_type: int = 4,
|
||||
min_rules_pass: int = 2,
|
||||
preferred_features: tuple[str, ...] = _PREFERRED_FEATURES,
|
||||
) -> MtfRuleSet:
|
||||
"""MTF 상관 리포트에서 신호 유형별 규칙 후보를 도출한다.
|
||||
|
||||
임계값은 GT(양성) 평균과 음성 평균의 중간값으로 설정한다.
|
||||
|
||||
Args:
|
||||
report: build_mtf_correlation_report 출력 JSON.
|
||||
min_cohens_d: |Cohen's d| 최소값.
|
||||
max_rules_per_type: 유형당 최대 규칙 수.
|
||||
min_rules_pass: 필터 통과에 필요한 최소 충족 규칙 수.
|
||||
preferred_features: 우선 사용 피처.
|
||||
|
||||
Returns:
|
||||
MtfRuleSet.
|
||||
"""
|
||||
analysis = report.get("analysis", {})
|
||||
tf_list = analysis.get("timeframes") or []
|
||||
by_type = report.get("by_signal_type") or {}
|
||||
|
||||
rule_set = MtfRuleSet(
|
||||
min_rules_pass=min_rules_pass,
|
||||
min_cohens_d=min_cohens_d,
|
||||
max_rules_per_type=max_rules_per_type,
|
||||
source_report=str(report.get("generated_at", "")),
|
||||
)
|
||||
|
||||
for signal_type in GT_SIGNAL_TYPES:
|
||||
block = by_type.get(signal_type)
|
||||
if not block:
|
||||
continue
|
||||
|
||||
candidates: list[tuple[float, MtfRule]] = []
|
||||
|
||||
for tf_label, tf_data in (block.get("timeframes") or {}).items():
|
||||
interval_min = _label_to_interval(tf_label, tf_list)
|
||||
numeric = tf_data.get("numeric") or {}
|
||||
|
||||
for feat_name, summary in numeric.items():
|
||||
if feat_name in _EXCLUDED_AUTO_FEATURES:
|
||||
continue
|
||||
if feat_name not in preferred_features:
|
||||
continue
|
||||
|
||||
d = summary.get("cohens_d")
|
||||
if d is None or abs(float(d)) < min_cohens_d:
|
||||
continue
|
||||
|
||||
pos_mean = float(summary.get("positive_mean", 0.0))
|
||||
neg_mean = float(summary.get("negative_mean", 0.0))
|
||||
threshold = round((pos_mean + neg_mean) / 2.0, 4)
|
||||
operator: Operator = "<=" if pos_mean < neg_mean else ">="
|
||||
|
||||
rule = MtfRule(
|
||||
signal_type=signal_type,
|
||||
timeframe_label=tf_label,
|
||||
interval_min=interval_min,
|
||||
feature=feat_name,
|
||||
operator=operator,
|
||||
threshold=threshold,
|
||||
cohens_d=float(d),
|
||||
positive_mean=pos_mean,
|
||||
negative_mean=neg_mean,
|
||||
)
|
||||
candidates.append((abs(float(d)), rule))
|
||||
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
picked: list[MtfRule] = []
|
||||
for _, rule in candidates:
|
||||
key = (rule.timeframe_label, rule.feature)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
picked.append(rule)
|
||||
if len(picked) >= max_rules_per_type:
|
||||
break
|
||||
|
||||
if picked:
|
||||
rule_set.rules_by_type[signal_type] = picked
|
||||
|
||||
return rule_set
|
||||
|
||||
|
||||
def save_mtf_rules(rule_set: MtfRuleSet, json_path: Path) -> Path:
|
||||
"""규칙 JSON 저장."""
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with json_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(rule_set.to_dict(), fp, ensure_ascii=False, indent=2)
|
||||
return json_path
|
||||
|
||||
|
||||
def load_mtf_rules(json_path: Path) -> MtfRuleSet:
|
||||
"""규칙 JSON 로드."""
|
||||
with json_path.open(encoding="utf-8") as fp:
|
||||
return MtfRuleSet.from_dict(json.load(fp))
|
||||
|
||||
|
||||
def load_or_derive_mtf_rules(
|
||||
rules_path: Path,
|
||||
report_path: Path,
|
||||
min_cohens_d: float = 1.2,
|
||||
max_rules_per_type: int = 4,
|
||||
min_rules_pass: int = 2,
|
||||
force_derive: bool = False,
|
||||
) -> MtfRuleSet:
|
||||
"""규칙 파일이 있으면 로드, 없거나 force면 리포트에서 재도출."""
|
||||
if rules_path.exists() and not force_derive:
|
||||
return load_mtf_rules(rules_path)
|
||||
|
||||
if not report_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"MTF 규칙/리포트 없음: rules={rules_path}, report={report_path}. "
|
||||
"먼저 scripts/2_run_mtf_analysis.py 실행"
|
||||
)
|
||||
|
||||
with report_path.open(encoding="utf-8") as fp:
|
||||
report = json.load(fp)
|
||||
|
||||
rule_set = derive_rules_from_report(
|
||||
report,
|
||||
min_cohens_d=min_cohens_d,
|
||||
max_rules_per_type=max_rules_per_type,
|
||||
min_rules_pass=min_rules_pass,
|
||||
)
|
||||
save_mtf_rules(rule_set, rules_path)
|
||||
return rule_set
|
||||
86
src/deepcoin/mtf/store.py
Normal file
86
src/deepcoin/mtf/store.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""멀티 TF 캔들 로드 및 피처 프레임 캐시."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.data.candle_loader import load_candles
|
||||
from deepcoin.data.intervals import DEFAULT_DOWNLOAD_INTERVALS, interval_label
|
||||
from deepcoin.mtf.features import compute_feature_frame
|
||||
|
||||
MTF_INTERVALS: tuple[int, ...] = tuple(DEFAULT_DOWNLOAD_INTERVALS)
|
||||
|
||||
|
||||
class MultiTimeframeStore:
|
||||
"""10개 TF 캔들·피처 프레임을 로드·캐시한다."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: Path,
|
||||
symbol: str,
|
||||
intervals: tuple[int, ...] | None = None,
|
||||
lookback_days: int | None = None,
|
||||
zigzag_reversal_pct: float = 5.0,
|
||||
) -> None:
|
||||
"""Store를 초기화한다 (데이터는 load() 호출 시 로드).
|
||||
|
||||
Args:
|
||||
db_path: SQLite 경로.
|
||||
symbol: 코인 심볼.
|
||||
intervals: 사용 TF 목록. None이면 MTF_INTERVALS.
|
||||
lookback_days: 최근 N일만 로드. None이면 전체.
|
||||
zigzag_reversal_pct: 인과 ZigZag 되돌림 %.
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.symbol = symbol.upper()
|
||||
self.intervals = intervals or MTF_INTERVALS
|
||||
self.lookback_days = lookback_days
|
||||
self.zigzag_reversal_pct = zigzag_reversal_pct
|
||||
self._raw: dict[int, pd.DataFrame] = {}
|
||||
self._features: dict[int, pd.DataFrame] = {}
|
||||
self._loaded = False
|
||||
|
||||
def load(self) -> None:
|
||||
"""모든 TF 캔들과 피처 컬럼을 로드한다."""
|
||||
if self._loaded:
|
||||
return
|
||||
|
||||
for interval_min in self.intervals:
|
||||
df = load_candles(
|
||||
self.db_path,
|
||||
self.symbol,
|
||||
interval_min,
|
||||
lookback_days=self.lookback_days,
|
||||
)
|
||||
self._raw[interval_min] = df
|
||||
self._features[interval_min] = compute_feature_frame(
|
||||
df,
|
||||
reversal_pct=self.zigzag_reversal_pct,
|
||||
)
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def get_raw(self, interval_min: int) -> pd.DataFrame:
|
||||
"""원본 OHLCV DataFrame을 반환한다."""
|
||||
self.load()
|
||||
if interval_min not in self._raw:
|
||||
raise KeyError(f"interval {interval_min} not loaded")
|
||||
return self._raw[interval_min]
|
||||
|
||||
def get_features(self, interval_min: int) -> pd.DataFrame:
|
||||
"""피처 컬럼이 추가된 DataFrame을 반환한다."""
|
||||
self.load()
|
||||
if interval_min not in self._features:
|
||||
raise KeyError(f"interval {interval_min} not loaded")
|
||||
return self._features[interval_min]
|
||||
|
||||
def interval_label(self, interval_min: int) -> str:
|
||||
"""인터벌 한글 라벨."""
|
||||
return interval_label(interval_min)
|
||||
|
||||
@property
|
||||
def base_df(self) -> pd.DataFrame:
|
||||
"""체결 기준 3분봉 DataFrame."""
|
||||
return self.get_raw(3)
|
||||
55
src/deepcoin/mtf/trend_gate.py
Normal file
55
src/deepcoin/mtf/trend_gate.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""고TF(60분·일봉) 추세 게이트 — 극단 구간 매매 억제."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HtfTrendGate:
|
||||
"""고TF EMA60 대비 위치로 매수·매도 허용 여부를 판단한다."""
|
||||
|
||||
enabled: bool = True
|
||||
buy_block_daily_below_pct: float = -25.0
|
||||
buy_block_60m_below_pct: float = -15.0
|
||||
sell_block_daily_above_pct: float = 35.0
|
||||
sell_block_60m_above_pct: float = 20.0
|
||||
|
||||
def _tf_value(self, snapshot: dict[str, Any], label: str, feature: str) -> float | None:
|
||||
"""스냅샷 dict에서 TF 피처 값."""
|
||||
tf = snapshot.get("timeframes", {}).get(label, {})
|
||||
if not tf.get("available"):
|
||||
return None
|
||||
val = tf.get(feature)
|
||||
if val is None:
|
||||
return None
|
||||
return float(val)
|
||||
|
||||
def allows_buy(self, snapshot: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""매수 허용 여부."""
|
||||
if not self.enabled:
|
||||
return True, "disabled"
|
||||
|
||||
daily = self._tf_value(snapshot, "일봉", "close_vs_ema60_pct")
|
||||
h60 = self._tf_value(snapshot, "60분", "close_vs_ema60_pct")
|
||||
|
||||
if daily is not None and daily < self.buy_block_daily_below_pct:
|
||||
return False, f"daily_ema60={daily:.1f}%<{self.buy_block_daily_below_pct}"
|
||||
if h60 is not None and h60 < self.buy_block_60m_below_pct:
|
||||
return False, f"60m_ema60={h60:.1f}%<{self.buy_block_60m_below_pct}"
|
||||
return True, "ok"
|
||||
|
||||
def allows_sell(self, snapshot: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""매도 허용 여부."""
|
||||
if not self.enabled:
|
||||
return True, "disabled"
|
||||
|
||||
daily = self._tf_value(snapshot, "일봉", "close_vs_ema60_pct")
|
||||
h60 = self._tf_value(snapshot, "60분", "close_vs_ema60_pct")
|
||||
|
||||
if daily is not None and daily > self.sell_block_daily_above_pct:
|
||||
return False, f"daily_ema60={daily:.1f}%>{self.sell_block_daily_above_pct}"
|
||||
if h60 is not None and h60 > self.sell_block_60m_above_pct:
|
||||
return False, f"60m_ema60={h60:.1f}%>{self.sell_block_60m_above_pct}"
|
||||
return True, "ok"
|
||||
20
src/deepcoin/techniques/__init__.py
Normal file
20
src/deepcoin/techniques/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""2단계: Ground Truth 정합 매매 기법."""
|
||||
|
||||
from deepcoin.techniques.registry import (
|
||||
get_all_techniques,
|
||||
get_composite_techniques,
|
||||
get_single_techniques,
|
||||
list_technique_ids,
|
||||
techniques_by_category,
|
||||
)
|
||||
from deepcoin.techniques.runner import run_all_techniques, run_technique
|
||||
|
||||
__all__ = [
|
||||
"get_all_techniques",
|
||||
"get_single_techniques",
|
||||
"get_composite_techniques",
|
||||
"list_technique_ids",
|
||||
"techniques_by_category",
|
||||
"run_all_techniques",
|
||||
"run_technique",
|
||||
]
|
||||
53
src/deepcoin/techniques/adx_trend.py
Normal file
53
src/deepcoin/techniques/adx_trend.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""ADX 추세 강도 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import adx
|
||||
|
||||
|
||||
class AdxTrendTechnique(BaseTechnique):
|
||||
"""ADX 강세 구간 +DI/-DI 크로스 매수·매도."""
|
||||
|
||||
technique_id = "adx_trend"
|
||||
technique_name = "ADX 추세"
|
||||
category = "trend"
|
||||
causal = True
|
||||
description = "ADX(14) 강세 + DI 크로스 추세 신호"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"period": 14, "adx_threshold": 25.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
period = int(params.extra.get("period", 14))
|
||||
adx_threshold = float(params.extra.get("adx_threshold", 25.0))
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
adx_line, plus_di, minus_di = adx(high, low, close, period=period)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(period + 2, len(df)):
|
||||
if pd.isna(adx_line.iloc[i]):
|
||||
continue
|
||||
|
||||
if float(adx_line.iloc[i]) < adx_threshold:
|
||||
continue
|
||||
|
||||
prev_plus = float(plus_di.iloc[i - 1])
|
||||
prev_minus = float(minus_di.iloc[i - 1])
|
||||
curr_plus = float(plus_di.iloc[i])
|
||||
curr_minus = float(minus_di.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
|
||||
if prev_plus <= prev_minus and curr_plus > curr_minus:
|
||||
signals.append(make_signal(df, i, c, "buy", "adx_bull_trend", confidence=0.67))
|
||||
elif prev_plus >= prev_minus and curr_plus < curr_minus:
|
||||
signals.append(make_signal(df, i, c, "sell", "adx_bear_trend", confidence=0.67))
|
||||
|
||||
return signals
|
||||
64
src/deepcoin/techniques/atr_channel.py
Normal file
64
src/deepcoin/techniques/atr_channel.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""ATR 채널 역추세 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import atr, ema
|
||||
|
||||
|
||||
class AtrChannelTechnique(BaseTechnique):
|
||||
"""EMA ± ATR 채널 터치 후 반전."""
|
||||
|
||||
technique_id = "atr_channel"
|
||||
technique_name = "ATR 채널"
|
||||
category = "volatility"
|
||||
causal = True
|
||||
description = "EMA(20) ± ATR(14)×2 채널 반전"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"ema_span": 20, "atr_period": 14, "atr_mult": 2.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
ema_span = int(params.extra.get("ema_span", 20))
|
||||
atr_period = int(params.extra.get("atr_period", 14))
|
||||
atr_mult = float(params.extra.get("atr_mult", 2.0))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
mid = ema(close, ema_span)
|
||||
atr_vals = atr(high, low, close, period=atr_period)
|
||||
upper = mid + atr_mult * atr_vals
|
||||
lower = mid - atr_mult * atr_vals
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
touched_lower = False
|
||||
touched_upper = False
|
||||
start = max(ema_span, atr_period)
|
||||
|
||||
for i in range(start, len(df)):
|
||||
if pd.isna(lower.iloc[i]):
|
||||
continue
|
||||
|
||||
l = float(low.iloc[i])
|
||||
h = float(high.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
lo = float(lower.iloc[i])
|
||||
u = float(upper.iloc[i])
|
||||
|
||||
if l <= lo:
|
||||
touched_lower = True
|
||||
if touched_lower and c > lo:
|
||||
signals.append(make_signal(df, i, c, "buy", "atr_channel_lower", confidence=0.68))
|
||||
touched_lower = False
|
||||
|
||||
if h >= u:
|
||||
touched_upper = True
|
||||
if touched_upper and c < u:
|
||||
signals.append(make_signal(df, i, c, "sell", "atr_channel_upper", confidence=0.68))
|
||||
touched_upper = False
|
||||
|
||||
return signals
|
||||
85
src/deepcoin/techniques/base.py
Normal file
85
src/deepcoin/techniques/base.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""매매 기법 공통 인터페이스 및 결과 타입."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class TechniqueSignal:
|
||||
"""기법이 생성한 매수·매도 신호."""
|
||||
|
||||
side: str # buy | sell
|
||||
bar_index: int
|
||||
price: float
|
||||
datetime: str
|
||||
pivot_bar_index: int | None = None
|
||||
confidence: float = 1.0
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""JSON 직렬화용 dict."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TechniqueParams:
|
||||
"""기법 실행 공통 파라미터."""
|
||||
|
||||
interval_min: int
|
||||
lookback_days: int
|
||||
min_leg_pct: float
|
||||
initial_cash_krw: float
|
||||
fee_rate: float
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TechniqueResult:
|
||||
"""기법 실행 결과 (GT JSON과 호환 구조)."""
|
||||
|
||||
technique_id: str
|
||||
technique_name: str
|
||||
category: str
|
||||
causal: bool
|
||||
description: str
|
||||
params: dict[str, Any]
|
||||
signals: list[dict[str, Any]]
|
||||
legs: list[dict[str, Any]]
|
||||
summary: dict[str, Any]
|
||||
pnl: dict[str, Any]
|
||||
alignment: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""JSON 직렬화용 dict."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class BaseTechnique(ABC):
|
||||
"""Ground Truth 정합을 목표로 하는 매매 기법 베이스."""
|
||||
|
||||
technique_id: str
|
||||
technique_name: str
|
||||
category: str
|
||||
causal: bool
|
||||
description: str
|
||||
|
||||
@abstractmethod
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
"""캔들 DataFrame에서 매수·매도 신호를 생성한다.
|
||||
|
||||
Args:
|
||||
df: datetime, open, high, low, close, volume 컬럼.
|
||||
params: 공통 실행 파라미터.
|
||||
|
||||
Returns:
|
||||
시간순 신호 리스트.
|
||||
"""
|
||||
|
||||
def default_extra_params(self) -> dict[str, Any]:
|
||||
"""기법별 기본 추가 파라미터."""
|
||||
return {}
|
||||
80
src/deepcoin/techniques/bb_reversal.py
Normal file
80
src/deepcoin/techniques/bb_reversal.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""볼린저 밴드 역추세 매매."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.indicators import bollinger_bands, ema
|
||||
|
||||
|
||||
class BbReversalTechnique(BaseTechnique):
|
||||
"""BB 하단 터치 후 반등 매수, 상단 터치 후 하락 매도."""
|
||||
|
||||
technique_id = "bb_reversal"
|
||||
technique_name = "볼린저 역추세"
|
||||
category = "indicator"
|
||||
causal = True
|
||||
description = "BB(20,2) 하단 매수·상단 매도 + EMA 추세 필터"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 20, "num_std": 2.0, "ema_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 20))
|
||||
num_std = float(params.extra.get("num_std", 2.0))
|
||||
ema_span = int(params.extra.get("ema_span", 60))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
_, upper, lower = bollinger_bands(close, window=window, num_std=num_std)
|
||||
trend = ema(close, ema_span)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
in_oversold = False
|
||||
in_overbought = False
|
||||
|
||||
for i in range(window, len(df)):
|
||||
if pd.isna(lower.iloc[i]) or pd.isna(upper.iloc[i]):
|
||||
continue
|
||||
|
||||
low_i = float(low.iloc[i])
|
||||
high_i = float(high.iloc[i])
|
||||
close_i = float(close.iloc[i])
|
||||
lower_i = float(lower.iloc[i])
|
||||
upper_i = float(upper.iloc[i])
|
||||
trend_i = float(trend.iloc[i])
|
||||
dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if low_i <= lower_i:
|
||||
in_oversold = True
|
||||
if in_oversold and close_i > lower_i and close_i >= trend_i * 0.98:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="buy",
|
||||
bar_index=i,
|
||||
price=round(close_i, 2),
|
||||
datetime=dt,
|
||||
confidence=0.7,
|
||||
reason="bb_lower_bounce",
|
||||
)
|
||||
)
|
||||
in_oversold = False
|
||||
|
||||
if high_i >= upper_i:
|
||||
in_overbought = True
|
||||
if in_overbought and close_i < upper_i:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="sell",
|
||||
bar_index=i,
|
||||
price=round(close_i, 2),
|
||||
datetime=dt,
|
||||
confidence=0.7,
|
||||
reason="bb_upper_reject",
|
||||
)
|
||||
)
|
||||
in_overbought = False
|
||||
|
||||
return signals
|
||||
61
src/deepcoin/techniques/bb_squeeze_breakout.py
Normal file
61
src/deepcoin/techniques/bb_squeeze_breakout.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""볼린저 스퀴즈 돌파 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import bollinger_bands
|
||||
|
||||
|
||||
class BbSqueezeBreakoutTechnique(BaseTechnique):
|
||||
"""BB 폭 축소(스퀴즈) 후 상·하단 돌파."""
|
||||
|
||||
technique_id = "bb_squeeze_breakout"
|
||||
technique_name = "BB 스퀴즈 돌파"
|
||||
category = "breakout"
|
||||
causal = True
|
||||
description = "볼린저 밴드 스퀴즈 후 돌파 (B^)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 20, "squeeze_pctile": 20, "lookback": 100}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 20))
|
||||
squeeze_pctile = float(params.extra.get("squeeze_pctile", 20))
|
||||
lookback = int(params.extra.get("lookback", 100))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
mid, upper, lower = bollinger_bands(close, window=window)
|
||||
width = (upper - lower) / mid.replace(0, pd.NA) * 100.0
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
in_squeeze = False
|
||||
|
||||
for i in range(window + lookback, len(df)):
|
||||
if pd.isna(width.iloc[i]):
|
||||
continue
|
||||
|
||||
hist = width.iloc[i - lookback : i].dropna()
|
||||
if hist.empty:
|
||||
continue
|
||||
|
||||
threshold = float(hist.quantile(squeeze_pctile / 100.0))
|
||||
w = float(width.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
u = float(upper.iloc[i])
|
||||
lo = float(lower.iloc[i])
|
||||
prev_c = float(close.iloc[i - 1])
|
||||
|
||||
if w <= threshold:
|
||||
in_squeeze = True
|
||||
|
||||
if in_squeeze and prev_c <= u and c > u:
|
||||
signals.append(make_signal(df, i, c, "buy", "bb_squeeze_breakout_up", confidence=0.75))
|
||||
in_squeeze = False
|
||||
elif in_squeeze and prev_c >= lo and c < lo:
|
||||
signals.append(make_signal(df, i, c, "sell", "bb_squeeze_breakout_down", confidence=0.75))
|
||||
in_squeeze = False
|
||||
|
||||
return signals
|
||||
57
src/deepcoin/techniques/cci_extreme.py
Normal file
57
src/deepcoin/techniques/cci_extreme.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""CCI 극값 반전 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal, safe_float
|
||||
from deepcoin.techniques.indicators import cci
|
||||
|
||||
|
||||
class CciExtremeTechnique(BaseTechnique):
|
||||
"""CCI -100 이탈 후 복귀 매수, +100 이탈 후 복귀 매도."""
|
||||
|
||||
technique_id = "cci_extreme"
|
||||
technique_name = "CCI 극값"
|
||||
category = "momentum"
|
||||
causal = True
|
||||
description = "CCI(20) 과매도·과매수 반전"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"period": 20, "low_level": -100.0, "high_level": 100.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
period = int(params.extra.get("period", 20))
|
||||
low_level = float(params.extra.get("low_level", -100.0))
|
||||
high_level = float(params.extra.get("high_level", 100.0))
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
cci_vals = cci(high, low, close, period=period)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
was_oversold = False
|
||||
was_overbought = False
|
||||
|
||||
for i in range(period + 1, len(df)):
|
||||
curr = safe_float(cci_vals.iloc[i])
|
||||
prev = safe_float(cci_vals.iloc[i - 1])
|
||||
c = safe_float(close.iloc[i])
|
||||
if curr is None or prev is None or c is None:
|
||||
continue
|
||||
|
||||
if curr < low_level:
|
||||
was_oversold = True
|
||||
if was_oversold and prev < low_level <= curr:
|
||||
signals.append(make_signal(df, i, c, "buy", "cci_oversold_exit", confidence=0.65))
|
||||
was_oversold = False
|
||||
|
||||
if curr > high_level:
|
||||
was_overbought = True
|
||||
if was_overbought and prev > high_level >= curr:
|
||||
signals.append(make_signal(df, i, c, "sell", "cci_overbought_exit", confidence=0.65))
|
||||
was_overbought = False
|
||||
|
||||
return signals
|
||||
126
src/deepcoin/techniques/composite_base.py
Normal file
126
src/deepcoin/techniques/composite_base.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""유형별·전체 복합 기법 공통 로직."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.indicators import ema
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeightedEvent:
|
||||
"""봉 단위 가중 투표 이벤트."""
|
||||
|
||||
bar_index: int
|
||||
side: str
|
||||
weight: float
|
||||
source: str
|
||||
|
||||
|
||||
def collect_weighted_events(
|
||||
techniques: list[BaseTechnique],
|
||||
weights: dict[str, tuple[float, float]],
|
||||
df: pd.DataFrame,
|
||||
params: TechniqueParams,
|
||||
) -> list[WeightedEvent]:
|
||||
"""하위 기법 신호를 가중 이벤트로 수집한다."""
|
||||
events: list[WeightedEvent] = []
|
||||
for technique in techniques:
|
||||
merged_extra = {**technique.default_extra_params(), **params.extra}
|
||||
run_params = TechniqueParams(
|
||||
interval_min=params.interval_min,
|
||||
lookback_days=params.lookback_days,
|
||||
min_leg_pct=params.min_leg_pct,
|
||||
initial_cash_krw=params.initial_cash_krw,
|
||||
fee_rate=params.fee_rate,
|
||||
extra=merged_extra,
|
||||
)
|
||||
raw = technique.generate_signals(df, run_params)
|
||||
buy_w, sell_w = weights.get(technique.technique_id, (1.0, 1.0))
|
||||
for sig in raw:
|
||||
weight = buy_w if sig.side == "buy" else sell_w
|
||||
events.append(
|
||||
WeightedEvent(
|
||||
bar_index=sig.bar_index,
|
||||
side=sig.side,
|
||||
weight=weight,
|
||||
source=technique.technique_id,
|
||||
)
|
||||
)
|
||||
events.sort(key=lambda e: e.bar_index)
|
||||
return events
|
||||
|
||||
|
||||
def cluster_events(events: list[WeightedEvent], merge_bars: int) -> list[list[WeightedEvent]]:
|
||||
"""인접 봉의 이벤트를 클러스터로 묶는다."""
|
||||
if not events:
|
||||
return []
|
||||
|
||||
clusters: list[list[WeightedEvent]] = [[events[0]]]
|
||||
for event in events[1:]:
|
||||
last_bar = max(e.bar_index for e in clusters[-1])
|
||||
if event.bar_index - last_bar <= merge_bars:
|
||||
clusters[-1].append(event)
|
||||
else:
|
||||
clusters.append([event])
|
||||
return clusters
|
||||
|
||||
|
||||
def score_clusters_to_signals(
|
||||
df: pd.DataFrame,
|
||||
clusters: list[list[WeightedEvent]],
|
||||
*,
|
||||
min_score: float,
|
||||
trend_span: int = 60,
|
||||
use_trend_filter: bool = True,
|
||||
) -> list[TechniqueSignal]:
|
||||
"""클러스터 점수를 TechniqueSignal로 변환한다."""
|
||||
close = df["close"].astype(float)
|
||||
trend = ema(close, trend_span) if use_trend_filter else None
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for cluster in clusters:
|
||||
buy_score = sum(e.weight for e in cluster if e.side == "buy")
|
||||
sell_score = sum(e.weight for e in cluster if e.side == "sell")
|
||||
bar_index = max(e.bar_index for e in cluster)
|
||||
sources = sorted({e.source for e in cluster})
|
||||
|
||||
if bar_index >= len(df):
|
||||
continue
|
||||
if use_trend_filter and trend is not None and pd.isna(trend.iloc[bar_index]):
|
||||
continue
|
||||
|
||||
price = float(close.iloc[bar_index])
|
||||
dt = pd.Timestamp(df.iloc[bar_index]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
trend_val = float(trend.iloc[bar_index]) if use_trend_filter and trend is not None else price
|
||||
|
||||
if buy_score >= min_score and buy_score > sell_score:
|
||||
if not use_trend_filter or price > trend_val:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="buy",
|
||||
bar_index=bar_index,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=round(min(buy_score / 5.0, 1.0), 2),
|
||||
reason=f"composite_buy score={buy_score:.1f} [{','.join(sources)}]",
|
||||
)
|
||||
)
|
||||
elif sell_score >= min_score and sell_score > buy_score:
|
||||
if not use_trend_filter or price < trend_val:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="sell",
|
||||
bar_index=bar_index,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=round(min(sell_score / 5.0, 1.0), 2),
|
||||
reason=f"composite_sell score={sell_score:.1f} [{','.join(sources)}]",
|
||||
)
|
||||
)
|
||||
|
||||
signals.sort(key=lambda s: s.bar_index)
|
||||
return signals
|
||||
59
src/deepcoin/techniques/composite_breakout.py
Normal file
59
src/deepcoin/techniques/composite_breakout.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""돌파 유형 복합 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.bb_squeeze_breakout import BbSqueezeBreakoutTechnique
|
||||
from deepcoin.techniques.composite_base import (
|
||||
cluster_events,
|
||||
collect_weighted_events,
|
||||
score_clusters_to_signals,
|
||||
)
|
||||
from deepcoin.techniques.donchian import DonchianTechnique
|
||||
from deepcoin.techniques.keltner_breakout import KeltnerBreakoutTechnique
|
||||
from deepcoin.techniques.macd_cross import MacdCrossTechnique
|
||||
from deepcoin.techniques.range_breakout import RangeBreakoutTechnique
|
||||
from deepcoin.techniques.volume_breakout import VolumeBreakoutTechnique
|
||||
|
||||
_SUB = [
|
||||
DonchianTechnique(),
|
||||
RangeBreakoutTechnique(),
|
||||
KeltnerBreakoutTechnique(),
|
||||
BbSqueezeBreakoutTechnique(),
|
||||
VolumeBreakoutTechnique(),
|
||||
MacdCrossTechnique(),
|
||||
]
|
||||
|
||||
_WEIGHTS: dict[str, tuple[float, float]] = {
|
||||
"donchian": (2.0, 2.0),
|
||||
"range_breakout": (2.0, 2.0),
|
||||
"keltner_breakout": (1.8, 1.8),
|
||||
"bb_squeeze_breakout": (1.8, 1.8),
|
||||
"volume_breakout": (1.5, 1.5),
|
||||
"macd_cross": (1.2, 1.2),
|
||||
}
|
||||
|
||||
|
||||
class CompositeBreakoutTechnique(BaseTechnique):
|
||||
"""돌파 B^ 유형 전담 복합 기법."""
|
||||
|
||||
technique_id = "composite_breakout"
|
||||
technique_name = "돌파 복합"
|
||||
category = "composite"
|
||||
causal = True
|
||||
description = "돌파·모멘텀 기법 가중 투표 (B^)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"min_score": 1.8, "merge_bars": 3, "trend_ema_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
min_score = float(params.extra.get("min_score", 1.8))
|
||||
merge_bars = int(params.extra.get("merge_bars", 3))
|
||||
trend_span = int(params.extra.get("trend_ema_span", 60))
|
||||
events = collect_weighted_events(_SUB, _WEIGHTS, df, params)
|
||||
clusters = cluster_events(events, merge_bars=merge_bars)
|
||||
return score_clusters_to_signals(
|
||||
df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=False,
|
||||
)
|
||||
56
src/deepcoin/techniques/composite_divergence.py
Normal file
56
src/deepcoin/techniques/composite_divergence.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""다이버전스 유형 복합 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.composite_base import (
|
||||
cluster_events,
|
||||
collect_weighted_events,
|
||||
score_clusters_to_signals,
|
||||
)
|
||||
from deepcoin.techniques.macd_cross import MacdCrossTechnique
|
||||
from deepcoin.techniques.macd_divergence import MacdDivergenceTechnique
|
||||
from deepcoin.techniques.obv_divergence import ObvDivergenceTechnique
|
||||
from deepcoin.techniques.rsi_divergence import RsiDivergenceTechnique
|
||||
from deepcoin.techniques.rsi_swing import RsiSwingTechnique
|
||||
|
||||
_SUB = [
|
||||
RsiDivergenceTechnique(),
|
||||
MacdDivergenceTechnique(),
|
||||
ObvDivergenceTechnique(),
|
||||
RsiSwingTechnique(),
|
||||
MacdCrossTechnique(),
|
||||
]
|
||||
|
||||
_WEIGHTS: dict[str, tuple[float, float]] = {
|
||||
"rsi_divergence": (2.5, 2.5),
|
||||
"macd_divergence": (2.5, 2.5),
|
||||
"obv_divergence": (2.0, 2.0),
|
||||
"rsi_swing": (1.2, 1.2),
|
||||
"macd_cross": (1.0, 1.0),
|
||||
}
|
||||
|
||||
|
||||
class CompositeDivergenceTechnique(BaseTechnique):
|
||||
"""다이버전스 Bd/Sd 유형 전담 복합 기법."""
|
||||
|
||||
technique_id = "composite_divergence"
|
||||
technique_name = "다이버전스 복합"
|
||||
category = "composite"
|
||||
causal = True
|
||||
description = "RSI/MACD/OBV 다이버전스 가중 투표 (Bd/Sd)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"min_score": 2.0, "merge_bars": 5, "trend_ema_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
min_score = float(params.extra.get("min_score", 2.0))
|
||||
merge_bars = int(params.extra.get("merge_bars", 5))
|
||||
trend_span = int(params.extra.get("trend_ema_span", 60))
|
||||
events = collect_weighted_events(_SUB, _WEIGHTS, df, params)
|
||||
clusters = cluster_events(events, merge_bars=merge_bars)
|
||||
return score_clusters_to_signals(
|
||||
df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=False,
|
||||
)
|
||||
61
src/deepcoin/techniques/composite_full.py
Normal file
61
src/deepcoin/techniques/composite_full.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""전체 기법 통합 복합."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.composite_base import (
|
||||
cluster_events,
|
||||
collect_weighted_events,
|
||||
score_clusters_to_signals,
|
||||
)
|
||||
def _build_full_sub_techniques() -> list[BaseTechnique]:
|
||||
"""복합 제외 단일 기법 목록을 반환한다."""
|
||||
from deepcoin.techniques.registry import get_single_techniques
|
||||
|
||||
return get_single_techniques()
|
||||
|
||||
|
||||
_CATEGORY_WEIGHT: dict[str, tuple[float, float]] = {
|
||||
"swing": (2.0, 2.0),
|
||||
"pullback": (1.8, 1.8),
|
||||
"breakout": (1.8, 1.8),
|
||||
"divergence": (2.0, 2.0),
|
||||
"indicator": (1.2, 1.2),
|
||||
"trend": (1.0, 1.0),
|
||||
"momentum": (1.0, 1.0),
|
||||
"volatility": (1.2, 1.2),
|
||||
"structure": (1.2, 1.2),
|
||||
"volume": (1.0, 1.0),
|
||||
"hybrid": (1.8, 1.8),
|
||||
}
|
||||
|
||||
|
||||
class CompositeFullTechnique(BaseTechnique):
|
||||
"""등록된 모든 단일 기법 가중 투표 통합."""
|
||||
|
||||
technique_id = "composite_full"
|
||||
technique_name = "전체 통합 복합"
|
||||
category = "composite"
|
||||
causal = True
|
||||
description = "전체 인과 기법 가중 투표 + EMA 추세 필터"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"min_score": 4.0, "merge_bars": 3, "trend_ema_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
min_score = float(params.extra.get("min_score", 4.0))
|
||||
merge_bars = int(params.extra.get("merge_bars", 3))
|
||||
trend_span = int(params.extra.get("trend_ema_span", 60))
|
||||
|
||||
sub = _build_full_sub_techniques()
|
||||
weights = {
|
||||
t.technique_id: _CATEGORY_WEIGHT.get(t.category, (1.0, 1.0))
|
||||
for t in sub
|
||||
}
|
||||
events = collect_weighted_events(sub, weights, df, params)
|
||||
clusters = cluster_events(events, merge_bars=merge_bars)
|
||||
return score_clusters_to_signals(
|
||||
df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True,
|
||||
)
|
||||
59
src/deepcoin/techniques/composite_pullback.py
Normal file
59
src/deepcoin/techniques/composite_pullback.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""눌림목 유형 복합 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.bb_reversal import BbReversalTechnique
|
||||
from deepcoin.techniques.composite_base import (
|
||||
cluster_events,
|
||||
collect_weighted_events,
|
||||
score_clusters_to_signals,
|
||||
)
|
||||
from deepcoin.techniques.ema_pullback import EmaPullbackTechnique
|
||||
from deepcoin.techniques.fib_pullback import FibPullbackTechnique
|
||||
from deepcoin.techniques.keltner_reversal import KeltnerReversalTechnique
|
||||
from deepcoin.techniques.local_extrema import LocalExtremaTechnique
|
||||
from deepcoin.techniques.support_bounce import SupportBounceTechnique
|
||||
|
||||
_SUB = [
|
||||
EmaPullbackTechnique(),
|
||||
FibPullbackTechnique(),
|
||||
SupportBounceTechnique(),
|
||||
BbReversalTechnique(),
|
||||
LocalExtremaTechnique(),
|
||||
KeltnerReversalTechnique(),
|
||||
]
|
||||
|
||||
_WEIGHTS: dict[str, tuple[float, float]] = {
|
||||
"ema_pullback": (2.0, 2.0),
|
||||
"fib_pullback": (2.0, 2.0),
|
||||
"support_bounce": (1.8, 1.8),
|
||||
"bb_reversal": (1.8, 1.8),
|
||||
"local_extrema": (1.5, 1.5),
|
||||
"keltner_reversal": (1.2, 1.2),
|
||||
}
|
||||
|
||||
|
||||
class CompositePullbackTechnique(BaseTechnique):
|
||||
"""눌림목 B* 유형 전담 복합 기법."""
|
||||
|
||||
technique_id = "composite_pullback"
|
||||
technique_name = "눌림목 복합"
|
||||
category = "composite"
|
||||
causal = True
|
||||
description = "눌림목·역추세 기법 가중 투표 (B*)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"min_score": 2.0, "merge_bars": 3, "trend_ema_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
min_score = float(params.extra.get("min_score", 2.0))
|
||||
merge_bars = int(params.extra.get("merge_bars", 3))
|
||||
trend_span = int(params.extra.get("trend_ema_span", 60))
|
||||
events = collect_weighted_events(_SUB, _WEIGHTS, df, params)
|
||||
clusters = cluster_events(events, merge_bars=merge_bars)
|
||||
return score_clusters_to_signals(
|
||||
df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True,
|
||||
)
|
||||
62
src/deepcoin/techniques/composite_swing.py
Normal file
62
src/deepcoin/techniques/composite_swing.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""스윙 유형 복합 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.composite_base import (
|
||||
cluster_events,
|
||||
collect_weighted_events,
|
||||
score_clusters_to_signals,
|
||||
)
|
||||
from deepcoin.techniques.donchian import DonchianTechnique
|
||||
from deepcoin.techniques.fractal_swing import FractalSwingTechnique
|
||||
from deepcoin.techniques.local_extrema import LocalExtremaTechnique
|
||||
from deepcoin.techniques.minor_swing import MinorSwingTechnique
|
||||
from deepcoin.techniques.pivot_swing import PivotSwingTechnique
|
||||
from deepcoin.techniques.swing_failure import SwingFailureTechnique
|
||||
from deepcoin.techniques.zigzag_causal import ZigzagCausalTechnique
|
||||
|
||||
_SUB = [
|
||||
ZigzagCausalTechnique(),
|
||||
MinorSwingTechnique(),
|
||||
PivotSwingTechnique(),
|
||||
FractalSwingTechnique(),
|
||||
LocalExtremaTechnique(),
|
||||
DonchianTechnique(),
|
||||
SwingFailureTechnique(),
|
||||
]
|
||||
|
||||
_WEIGHTS: dict[str, tuple[float, float]] = {
|
||||
"zigzag_causal": (2.5, 2.5),
|
||||
"minor_swing": (2.0, 2.0),
|
||||
"pivot_swing": (1.8, 1.8),
|
||||
"fractal_swing": (1.5, 1.5),
|
||||
"local_extrema": (1.5, 1.5),
|
||||
"donchian": (1.2, 1.2),
|
||||
"swing_failure": (1.0, 1.0),
|
||||
}
|
||||
|
||||
|
||||
class CompositeSwingTechnique(BaseTechnique):
|
||||
"""스윙 B/S 유형 전담 복합 기법."""
|
||||
|
||||
technique_id = "composite_swing"
|
||||
technique_name = "스윙 복합"
|
||||
category = "composite"
|
||||
causal = True
|
||||
description = "스윙 저점·고점 전담 기법 가중 투표 (B/S)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"min_score": 2.5, "merge_bars": 3, "trend_ema_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
min_score = float(params.extra.get("min_score", 2.5))
|
||||
merge_bars = int(params.extra.get("merge_bars", 3))
|
||||
trend_span = int(params.extra.get("trend_ema_span", 60))
|
||||
events = collect_weighted_events(_SUB, _WEIGHTS, df, params)
|
||||
clusters = cluster_events(events, merge_bars=merge_bars)
|
||||
return score_clusters_to_signals(
|
||||
df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True,
|
||||
)
|
||||
97
src/deepcoin/techniques/composite_v3.py
Normal file
97
src/deepcoin/techniques/composite_v3.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""v3 GT 6종 신호를 가중 투표로 통합하는 복합 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.bb_reversal import BbReversalTechnique
|
||||
from deepcoin.techniques.composite_base import (
|
||||
cluster_events,
|
||||
collect_weighted_events,
|
||||
score_clusters_to_signals,
|
||||
)
|
||||
from deepcoin.techniques.donchian import DonchianTechnique
|
||||
from deepcoin.techniques.ema_pullback import EmaPullbackTechnique
|
||||
from deepcoin.techniques.fib_pullback import FibPullbackTechnique
|
||||
from deepcoin.techniques.fractal_swing import FractalSwingTechnique
|
||||
from deepcoin.techniques.keltner_breakout import KeltnerBreakoutTechnique
|
||||
from deepcoin.techniques.local_extrema import LocalExtremaTechnique
|
||||
from deepcoin.techniques.macd_cross import MacdCrossTechnique
|
||||
from deepcoin.techniques.macd_divergence import MacdDivergenceTechnique
|
||||
from deepcoin.techniques.minor_swing import MinorSwingTechnique
|
||||
from deepcoin.techniques.obv_divergence import ObvDivergenceTechnique
|
||||
from deepcoin.techniques.pivot_swing import PivotSwingTechnique
|
||||
from deepcoin.techniques.range_breakout import RangeBreakoutTechnique
|
||||
from deepcoin.techniques.rsi_divergence import RsiDivergenceTechnique
|
||||
from deepcoin.techniques.rsi_swing import RsiSwingTechnique
|
||||
from deepcoin.techniques.support_bounce import SupportBounceTechnique
|
||||
from deepcoin.techniques.zigzag_causal import ZigzagCausalTechnique
|
||||
|
||||
_TECHNIQUE_WEIGHTS: dict[str, tuple[float, float]] = {
|
||||
"zigzag_causal": (2.5, 2.5),
|
||||
"minor_swing": (2.0, 2.0),
|
||||
"pivot_swing": (1.8, 1.8),
|
||||
"fractal_swing": (1.5, 1.5),
|
||||
"local_extrema": (1.5, 1.5),
|
||||
"ema_pullback": (2.0, 1.0),
|
||||
"fib_pullback": (2.0, 1.0),
|
||||
"support_bounce": (1.5, 1.0),
|
||||
"bb_reversal": (1.5, 1.5),
|
||||
"donchian": (1.5, 1.5),
|
||||
"range_breakout": (1.8, 1.0),
|
||||
"keltner_breakout": (1.5, 1.0),
|
||||
"macd_cross": (1.2, 1.2),
|
||||
"rsi_divergence": (2.0, 2.0),
|
||||
"macd_divergence": (2.0, 2.0),
|
||||
"obv_divergence": (1.8, 1.8),
|
||||
"rsi_swing": (1.5, 1.5),
|
||||
}
|
||||
|
||||
_SUB_TECHNIQUES: list[BaseTechnique] = [
|
||||
ZigzagCausalTechnique(),
|
||||
MinorSwingTechnique(),
|
||||
PivotSwingTechnique(),
|
||||
FractalSwingTechnique(),
|
||||
LocalExtremaTechnique(),
|
||||
EmaPullbackTechnique(),
|
||||
FibPullbackTechnique(),
|
||||
SupportBounceTechnique(),
|
||||
BbReversalTechnique(),
|
||||
DonchianTechnique(),
|
||||
RangeBreakoutTechnique(),
|
||||
KeltnerBreakoutTechnique(),
|
||||
MacdCrossTechnique(),
|
||||
RsiDivergenceTechnique(),
|
||||
MacdDivergenceTechnique(),
|
||||
ObvDivergenceTechnique(),
|
||||
RsiSwingTechnique(),
|
||||
]
|
||||
|
||||
|
||||
class CompositeV3Technique(BaseTechnique):
|
||||
"""v3 GT 6종 신호를 가중 투표로 재현하는 통합 인과 기법."""
|
||||
|
||||
technique_id = "composite_v3"
|
||||
technique_name = "v3 통합 스코어링"
|
||||
category = "composite"
|
||||
causal = True
|
||||
description = "v3 GT 6종 신호 유형별 핵심 기법 가중 투표 + EMA(60) 추세 필터"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {
|
||||
"min_score": 2.5,
|
||||
"merge_bars": 3,
|
||||
"trend_ema_span": 60,
|
||||
"reversal_pct": 5.0,
|
||||
}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
min_score = float(params.extra.get("min_score", 2.5))
|
||||
merge_bars = int(params.extra.get("merge_bars", 3))
|
||||
trend_span = int(params.extra.get("trend_ema_span", 60))
|
||||
events = collect_weighted_events(_SUB_TECHNIQUES, _TECHNIQUE_WEIGHTS, df, params)
|
||||
clusters = cluster_events(events, merge_bars=merge_bars)
|
||||
return score_clusters_to_signals(
|
||||
df, clusters, min_score=min_score, trend_span=trend_span, use_trend_filter=True,
|
||||
)
|
||||
76
src/deepcoin/techniques/donchian.py
Normal file
76
src/deepcoin/techniques/donchian.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""돈치안 채널 스윙."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
|
||||
|
||||
class DonchianTechnique(BaseTechnique):
|
||||
"""N봉 최저가 터치 후 반등 매수, N봉 최고가 터치 후 하락 매도."""
|
||||
|
||||
technique_id = "donchian"
|
||||
technique_name = "돈치안 채널"
|
||||
category = "swing"
|
||||
causal = True
|
||||
description = "돈치안(40) 채널 하단 매수·상단 매도"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 40}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 40))
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
|
||||
lower_channel = low.rolling(window=window, min_periods=window).min()
|
||||
upper_channel = high.rolling(window=window, min_periods=window).max()
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
touched_lower = False
|
||||
touched_upper = False
|
||||
|
||||
for i in range(window, len(df)):
|
||||
if pd.isna(lower_channel.iloc[i]):
|
||||
continue
|
||||
|
||||
low_i = float(low.iloc[i])
|
||||
high_i = float(high.iloc[i])
|
||||
close_i = float(close.iloc[i])
|
||||
lower_i = float(lower_channel.iloc[i])
|
||||
upper_i = float(upper_channel.iloc[i])
|
||||
dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if low_i <= lower_i:
|
||||
touched_lower = True
|
||||
if touched_lower and close_i > lower_i * 1.005:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="buy",
|
||||
bar_index=i,
|
||||
price=round(close_i, 2),
|
||||
datetime=dt,
|
||||
confidence=0.65,
|
||||
reason="donchian_lower_bounce",
|
||||
)
|
||||
)
|
||||
touched_lower = False
|
||||
|
||||
if high_i >= upper_i:
|
||||
touched_upper = True
|
||||
if touched_upper and close_i < upper_i * 0.995:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="sell",
|
||||
bar_index=i,
|
||||
price=round(close_i, 2),
|
||||
datetime=dt,
|
||||
confidence=0.65,
|
||||
reason="donchian_upper_reject",
|
||||
)
|
||||
)
|
||||
touched_upper = False
|
||||
|
||||
return signals
|
||||
62
src/deepcoin/techniques/ema_pullback.py
Normal file
62
src/deepcoin/techniques/ema_pullback.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""EMA 눌림목 반등 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import ema
|
||||
|
||||
|
||||
class EmaPullbackTechnique(BaseTechnique):
|
||||
"""상승 추세에서 EMA 터치 후 반등 매수, 하락 추세에서 EMA 터치 후 하락 매도."""
|
||||
|
||||
technique_id = "ema_pullback"
|
||||
technique_name = "EMA 눌림목"
|
||||
category = "pullback"
|
||||
causal = True
|
||||
description = "EMA(20/60) 눌림목 반등 매수·되돌림 매도 (B*)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"fast_span": 20, "slow_span": 60, "touch_pct": 0.5}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
fast_span = int(params.extra.get("fast_span", 20))
|
||||
slow_span = int(params.extra.get("slow_span", 60))
|
||||
touch_pct = float(params.extra.get("touch_pct", 0.5)) / 100.0
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
ema_fast = ema(close, fast_span)
|
||||
ema_slow = ema(close, slow_span)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
touched_fast_buy = False
|
||||
touched_fast_sell = False
|
||||
|
||||
for i in range(slow_span + 1, len(df)):
|
||||
if pd.isna(ema_fast.iloc[i]) or pd.isna(ema_slow.iloc[i]):
|
||||
continue
|
||||
|
||||
c = float(close.iloc[i])
|
||||
l = float(low.iloc[i])
|
||||
h = float(high.iloc[i])
|
||||
ef = float(ema_fast.iloc[i])
|
||||
es = float(ema_slow.iloc[i])
|
||||
prev_c = float(close.iloc[i - 1])
|
||||
|
||||
if c > es and l <= ef * (1 + touch_pct):
|
||||
touched_fast_buy = True
|
||||
if touched_fast_buy and prev_c <= ef and c > ef and c > es:
|
||||
signals.append(make_signal(df, i, c, "buy", "ema_pullback_buy", confidence=0.74))
|
||||
touched_fast_buy = False
|
||||
|
||||
if c < es and h >= ef * (1 - touch_pct):
|
||||
touched_fast_sell = True
|
||||
if touched_fast_sell and prev_c >= ef and c < ef and c < es:
|
||||
signals.append(make_signal(df, i, c, "sell", "ema_pullback_sell", confidence=0.74))
|
||||
touched_fast_sell = False
|
||||
|
||||
return signals
|
||||
106
src/deepcoin/techniques/fib_pullback.py
Normal file
106
src/deepcoin/techniques/fib_pullback.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""피보나치 되돌림 눌림목 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import find_confirmed_pivots, make_signal
|
||||
from deepcoin.techniques.indicators import ema
|
||||
|
||||
|
||||
class FibPullbackTechnique(BaseTechnique):
|
||||
"""스윙 고저점 기준 피보나치 38.2~61.8% 구간 터치 후 반전."""
|
||||
|
||||
technique_id = "fib_pullback"
|
||||
technique_name = "피보나치 눌림목"
|
||||
category = "pullback"
|
||||
causal = True
|
||||
description = "피보나치 38.2~61.8% 되돌림 구간 매수·매도 (B*)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"order": 15, "fib_low": 0.382, "fib_high": 0.618, "trend_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
order = int(params.extra.get("order", 15))
|
||||
fib_low = float(params.extra.get("fib_low", 0.382))
|
||||
fib_high = float(params.extra.get("fib_high", 0.618))
|
||||
trend_span = int(params.extra.get("trend_span", 60))
|
||||
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
trend = ema(close, trend_span)
|
||||
low_pivots = find_confirmed_pivots(low, order, "low")
|
||||
high_pivots = find_confirmed_pivots(high, order, "high")
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(1, len(low_pivots)):
|
||||
swing_low_idx, swing_low = low_pivots[i]
|
||||
swing_high_idx, swing_high = _prior_high(high_pivots, swing_low_idx)
|
||||
if swing_high_idx is None or swing_high <= swing_low:
|
||||
continue
|
||||
confirm = swing_low_idx + order
|
||||
if confirm >= len(df):
|
||||
continue
|
||||
leg = swing_high - swing_low
|
||||
zone_top = swing_high - leg * fib_low
|
||||
zone_bot = swing_high - leg * fib_high
|
||||
for j in range(confirm, min(len(df), confirm + order * 4)):
|
||||
if float(close.iloc[j]) < float(trend.iloc[j]):
|
||||
break
|
||||
if zone_bot <= float(low.iloc[j]) <= zone_top and float(close.iloc[j]) > zone_bot:
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, j, float(close.iloc[j]), "buy",
|
||||
"fib_pullback_buy", pivot_bar_index=swing_low_idx, confidence=0.73,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
for i in range(1, len(high_pivots)):
|
||||
swing_high_idx, swing_high = high_pivots[i]
|
||||
swing_low_idx, swing_low = _prior_low(low_pivots, swing_high_idx)
|
||||
if swing_low_idx is None or swing_high <= swing_low:
|
||||
continue
|
||||
confirm = swing_high_idx + order
|
||||
if confirm >= len(df):
|
||||
continue
|
||||
leg = swing_high - swing_low
|
||||
zone_bot = swing_low + leg * fib_low
|
||||
zone_top = swing_low + leg * fib_high
|
||||
for j in range(confirm, min(len(df), confirm + order * 4)):
|
||||
if float(close.iloc[j]) > float(trend.iloc[j]):
|
||||
break
|
||||
if zone_bot <= float(high.iloc[j]) <= zone_top and float(close.iloc[j]) < zone_top:
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, j, float(close.iloc[j]), "sell",
|
||||
"fib_pullback_sell", pivot_bar_index=swing_high_idx, confidence=0.73,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def _prior_high(
|
||||
pivots: list[tuple[int, float]], before_idx: int,
|
||||
) -> tuple[int | None, float | None]:
|
||||
"""before_idx 이전 최근 고점 피벗을 반환한다."""
|
||||
candidates = [(idx, val) for idx, val in pivots if idx < before_idx]
|
||||
if not candidates:
|
||||
return None, None
|
||||
idx, val = max(candidates, key=lambda x: x[0])
|
||||
return idx, val
|
||||
|
||||
|
||||
def _prior_low(
|
||||
pivots: list[tuple[int, float]], before_idx: int,
|
||||
) -> tuple[int | None, float | None]:
|
||||
"""before_idx 이전 최근 저점 피벗을 반환한다."""
|
||||
candidates = [(idx, val) for idx, val in pivots if idx < before_idx]
|
||||
if not candidates:
|
||||
return None, None
|
||||
idx, val = max(candidates, key=lambda x: x[0])
|
||||
return idx, val
|
||||
53
src/deepcoin/techniques/fractal_swing.py
Normal file
53
src/deepcoin/techniques/fractal_swing.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Williams 프랙탈 스윙 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import dedupe_signals, find_fractal_pivots, make_signal
|
||||
|
||||
|
||||
class FractalSwingTechnique(BaseTechnique):
|
||||
"""프랙탈 저점·고점 확정 시 매수·매도."""
|
||||
|
||||
technique_id = "fractal_swing"
|
||||
technique_name = "프랙탈 스윙"
|
||||
category = "swing"
|
||||
causal = True
|
||||
description = "Williams 프랙탈 스윙 저점 매수·고점 매도"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"span": 2, "min_bars_between": 20}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
span = int(params.extra.get("span", 2))
|
||||
min_bars = int(params.extra.get("min_bars_between", 20))
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
low_fractals, high_fractals = find_fractal_pivots(low, high, span=span)
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for pivot_idx, pivot_val in low_fractals:
|
||||
confirm_idx = pivot_idx + span
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, pivot_val, "buy",
|
||||
"fractal_low", pivot_bar_index=pivot_idx, confidence=0.7,
|
||||
)
|
||||
)
|
||||
|
||||
for pivot_idx, pivot_val in high_fractals:
|
||||
confirm_idx = pivot_idx + span
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, pivot_val, "sell",
|
||||
"fractal_high", pivot_bar_index=pivot_idx, confidence=0.7,
|
||||
)
|
||||
)
|
||||
|
||||
return dedupe_signals(signals, min_bars=min_bars)
|
||||
186
src/deepcoin/techniques/helpers.py
Normal file
186
src/deepcoin/techniques/helpers.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""기법 공통 유틸리티 (신호 병합·다이버전스·피벗 탐지)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import TechniqueSignal
|
||||
|
||||
|
||||
def safe_float(value: object) -> float | None:
|
||||
"""Series 스칼라를 float로 변환한다. NA/NaN이면 None."""
|
||||
if pd.isna(value):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def format_datetime(df: pd.DataFrame, bar_index: int) -> str:
|
||||
"""봉 인덱스의 datetime 문자열을 반환한다."""
|
||||
return pd.Timestamp(df.iloc[bar_index]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def dedupe_signals(signals: list[TechniqueSignal], min_bars: int) -> list[TechniqueSignal]:
|
||||
"""동일 방향 근접 신호를 병합한다."""
|
||||
if not signals:
|
||||
return []
|
||||
|
||||
sorted_signals = sorted(signals, key=lambda s: s.bar_index)
|
||||
merged: list[TechniqueSignal] = [sorted_signals[0]]
|
||||
|
||||
for signal in sorted_signals[1:]:
|
||||
last = merged[-1]
|
||||
if signal.side == last.side and signal.bar_index - last.bar_index < min_bars:
|
||||
if signal.side == "buy" and signal.price < last.price:
|
||||
merged[-1] = signal
|
||||
elif signal.side == "sell" and signal.price > last.price:
|
||||
merged[-1] = signal
|
||||
else:
|
||||
merged.append(signal)
|
||||
|
||||
return sorted(merged, key=lambda s: s.bar_index)
|
||||
|
||||
|
||||
def merge_opposite_signals(
|
||||
signals: list[TechniqueSignal],
|
||||
min_bars: int,
|
||||
) -> list[TechniqueSignal]:
|
||||
"""방향 무관 근접 신호 중 confidence 높은 쪽을 유지한다."""
|
||||
if not signals:
|
||||
return []
|
||||
|
||||
sorted_signals = sorted(signals, key=lambda s: s.bar_index)
|
||||
merged: list[TechniqueSignal] = [sorted_signals[0]]
|
||||
|
||||
for signal in sorted_signals[1:]:
|
||||
last = merged[-1]
|
||||
if signal.bar_index - last.bar_index < min_bars:
|
||||
if signal.confidence > last.confidence:
|
||||
merged[-1] = signal
|
||||
else:
|
||||
merged.append(signal)
|
||||
|
||||
return sorted(merged, key=lambda s: s.bar_index)
|
||||
|
||||
|
||||
def find_confirmed_pivots(
|
||||
series: pd.Series,
|
||||
order: int,
|
||||
mode: str,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""order봉 지연 확정 피벗 인덱스·값 목록을 반환한다.
|
||||
|
||||
Args:
|
||||
series: high 또는 low 시리즈.
|
||||
order: 좌우 비교 봉 수.
|
||||
mode: low | high.
|
||||
|
||||
Returns:
|
||||
(pivot_bar_index, pivot_value) 리스트.
|
||||
"""
|
||||
pivots: list[tuple[int, float]] = []
|
||||
values = series.astype(float)
|
||||
|
||||
for pivot_idx in range(order, len(values) - order):
|
||||
window = values.iloc[pivot_idx - order : pivot_idx + order + 1]
|
||||
val = float(values.iloc[pivot_idx])
|
||||
if mode == "low" and val <= window.min():
|
||||
pivots.append((pivot_idx, val))
|
||||
elif mode == "high" and val >= window.max():
|
||||
pivots.append((pivot_idx, val))
|
||||
|
||||
return pivots
|
||||
|
||||
|
||||
def find_fractal_pivots(
|
||||
low: pd.Series,
|
||||
high: pd.Series,
|
||||
span: int = 2,
|
||||
) -> tuple[list[tuple[int, float]], list[tuple[int, float]]]:
|
||||
"""Williams 프랙탈 피벗 (span봉 후 확정)을 반환한다."""
|
||||
low_fractals: list[tuple[int, float]] = []
|
||||
high_fractals: list[tuple[int, float]] = []
|
||||
|
||||
for pivot_idx in range(span, len(low) - span):
|
||||
low_window = low.iloc[pivot_idx - span : pivot_idx + span + 1].astype(float)
|
||||
high_window = high.iloc[pivot_idx - span : pivot_idx + span + 1].astype(float)
|
||||
low_val = float(low.iloc[pivot_idx])
|
||||
high_val = float(high.iloc[pivot_idx])
|
||||
|
||||
if low_val == low_window.min():
|
||||
low_fractals.append((pivot_idx, low_val))
|
||||
if high_val == high_window.max():
|
||||
high_fractals.append((pivot_idx, high_val))
|
||||
|
||||
return low_fractals, high_fractals
|
||||
|
||||
|
||||
def detect_bullish_divergence(
|
||||
price_pivots: list[tuple[int, float]],
|
||||
indicator: pd.Series,
|
||||
min_bars_between: int = 10,
|
||||
max_bars_between: int = 500,
|
||||
) -> list[tuple[int, int]]:
|
||||
"""가격 저점 하락·지표 저점 상승 다이버전스 (확정봉, 피벗2) 쌍을 반환한다."""
|
||||
pairs: list[tuple[int, int]] = []
|
||||
for i in range(1, len(price_pivots)):
|
||||
idx1, p1 = price_pivots[i - 1]
|
||||
idx2, p2 = price_pivots[i]
|
||||
gap = idx2 - idx1
|
||||
if gap < min_bars_between or gap > max_bars_between:
|
||||
continue
|
||||
if p2 >= p1:
|
||||
continue
|
||||
ind1 = float(indicator.iloc[idx1])
|
||||
ind2 = float(indicator.iloc[idx2])
|
||||
if pd.isna(ind1) or pd.isna(ind2):
|
||||
continue
|
||||
if ind2 > ind1:
|
||||
pairs.append((idx2, idx2))
|
||||
return pairs
|
||||
|
||||
|
||||
def detect_bearish_divergence(
|
||||
price_pivots: list[tuple[int, float]],
|
||||
indicator: pd.Series,
|
||||
min_bars_between: int = 10,
|
||||
max_bars_between: int = 500,
|
||||
) -> list[tuple[int, int]]:
|
||||
"""가격 고점 상승·지표 고점 하락 다이버전스 쌍을 반환한다."""
|
||||
pairs: list[tuple[int, int]] = []
|
||||
for i in range(1, len(price_pivots)):
|
||||
idx1, p1 = price_pivots[i - 1]
|
||||
idx2, p2 = price_pivots[i]
|
||||
gap = idx2 - idx1
|
||||
if gap < min_bars_between or gap > max_bars_between:
|
||||
continue
|
||||
if p2 <= p1:
|
||||
continue
|
||||
ind1 = float(indicator.iloc[idx1])
|
||||
ind2 = float(indicator.iloc[idx2])
|
||||
if pd.isna(ind1) or pd.isna(ind2):
|
||||
continue
|
||||
if ind2 < ind1:
|
||||
pairs.append((idx2, idx2))
|
||||
return pairs
|
||||
|
||||
|
||||
def make_signal(
|
||||
df: pd.DataFrame,
|
||||
bar_index: int,
|
||||
price: float,
|
||||
side: str,
|
||||
reason: str,
|
||||
*,
|
||||
pivot_bar_index: int | None = None,
|
||||
confidence: float = 0.7,
|
||||
) -> TechniqueSignal:
|
||||
"""TechniqueSignal을 생성한다."""
|
||||
return TechniqueSignal(
|
||||
side=side,
|
||||
bar_index=bar_index,
|
||||
price=round(price, 2),
|
||||
datetime=format_datetime(df, bar_index),
|
||||
pivot_bar_index=pivot_bar_index,
|
||||
confidence=confidence,
|
||||
reason=reason,
|
||||
)
|
||||
51
src/deepcoin/techniques/ichimoku_trend.py
Normal file
51
src/deepcoin/techniques/ichimoku_trend.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""일목 전환·기준선 크로스 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import ichimoku
|
||||
|
||||
|
||||
class IchimokuTrendTechnique(BaseTechnique):
|
||||
"""전환선이 기준선 상향·하향 돌파 시 매수·매도."""
|
||||
|
||||
technique_id = "ichimoku_trend"
|
||||
technique_name = "일목 추세"
|
||||
category = "trend"
|
||||
causal = True
|
||||
description = "일목 전환선·기준선 크로스 추세 신호"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"tenkan": 9, "kijun": 26}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
tenkan_span = int(params.extra.get("tenkan", 9))
|
||||
kijun_span = int(params.extra.get("kijun", 26))
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
tenkan, kijun = ichimoku(high, low, tenkan=tenkan_span, kijun=kijun_span)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
start = kijun_span + 1
|
||||
|
||||
for i in range(start, len(df)):
|
||||
if pd.isna(tenkan.iloc[i]) or pd.isna(kijun.iloc[i]):
|
||||
continue
|
||||
|
||||
prev_t = float(tenkan.iloc[i - 1])
|
||||
prev_k = float(kijun.iloc[i - 1])
|
||||
curr_t = float(tenkan.iloc[i])
|
||||
curr_k = float(kijun.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
|
||||
if prev_t <= prev_k and curr_t > curr_k:
|
||||
signals.append(make_signal(df, i, c, "buy", "ichimoku_bull_cross", confidence=0.66))
|
||||
elif prev_t >= prev_k and curr_t < curr_k:
|
||||
signals.append(make_signal(df, i, c, "sell", "ichimoku_bear_cross", confidence=0.66))
|
||||
|
||||
return signals
|
||||
295
src/deepcoin/techniques/indicators.py
Normal file
295
src/deepcoin/techniques/indicators.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""기술적 지표 계산 (인과 신호용)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def ema(series: pd.Series, span: int) -> pd.Series:
|
||||
"""지수이동평균을 계산한다."""
|
||||
return series.ewm(span=span, adjust=False).mean()
|
||||
|
||||
|
||||
def sma(series: pd.Series, window: int) -> pd.Series:
|
||||
"""단순이동평균을 계산한다."""
|
||||
return series.rolling(window=window, min_periods=window).mean()
|
||||
|
||||
|
||||
def bollinger_bands(
|
||||
close: pd.Series,
|
||||
window: int = 20,
|
||||
num_std: float = 2.0,
|
||||
) -> tuple[pd.Series, pd.Series, pd.Series]:
|
||||
"""볼린저 밴드 (중심, 상단, 하단)를 계산한다."""
|
||||
mid = sma(close, window)
|
||||
std = close.rolling(window=window, min_periods=window).std()
|
||||
upper = mid + num_std * std
|
||||
lower = mid - num_std * std
|
||||
return mid, upper, lower
|
||||
|
||||
|
||||
def rsi(close: pd.Series, period: int = 14) -> pd.Series:
|
||||
"""RSI(상대강도지수)를 계산한다."""
|
||||
delta = close.diff()
|
||||
gain = delta.clip(lower=0.0)
|
||||
loss = -delta.clip(upper=0.0)
|
||||
avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
rs = avg_gain / avg_loss.replace(0, pd.NA)
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def macd(
|
||||
close: pd.Series,
|
||||
fast: int = 12,
|
||||
slow: int = 26,
|
||||
signal: int = 9,
|
||||
) -> tuple[pd.Series, pd.Series, pd.Series]:
|
||||
"""MACD, 시그널, 히스토그램을 계산한다."""
|
||||
ema_fast = ema(close, fast)
|
||||
ema_slow = ema(close, slow)
|
||||
macd_line = ema_fast - ema_slow
|
||||
signal_line = ema(macd_line, signal)
|
||||
hist = macd_line - signal_line
|
||||
return macd_line, signal_line, hist
|
||||
|
||||
|
||||
def atr(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
period: int = 14,
|
||||
) -> pd.Series:
|
||||
"""Average True Range (ATR)를 계산한다.
|
||||
|
||||
Args:
|
||||
high: 고가 시리즈.
|
||||
low: 저가 시리즈.
|
||||
close: 종가 시리즈.
|
||||
period: ATR 기간.
|
||||
|
||||
Returns:
|
||||
ATR 시리즈.
|
||||
"""
|
||||
prev_close = close.shift(1)
|
||||
tr = pd.concat(
|
||||
[
|
||||
(high - low).abs(),
|
||||
(high - prev_close).abs(),
|
||||
(low - prev_close).abs(),
|
||||
],
|
||||
axis=1,
|
||||
).max(axis=1)
|
||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
|
||||
|
||||
def stochastic(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
k_period: int = 14,
|
||||
d_period: int = 3,
|
||||
) -> tuple[pd.Series, pd.Series]:
|
||||
"""Stochastic %K, %D를 계산한다."""
|
||||
import numpy as np
|
||||
|
||||
close_f = close.astype(float)
|
||||
lowest = low.astype(float).rolling(window=k_period, min_periods=k_period).min()
|
||||
highest = high.astype(float).rolling(window=k_period, min_periods=k_period).max()
|
||||
range_hl = highest - lowest
|
||||
pct_k = pd.Series(
|
||||
np.where(range_hl > 0, 100.0 * (close_f - lowest) / range_hl, np.nan),
|
||||
index=close.index,
|
||||
dtype=float,
|
||||
)
|
||||
pct_d = pct_k.rolling(window=d_period, min_periods=d_period).mean()
|
||||
return pct_k, pct_d
|
||||
|
||||
|
||||
def cci(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
period: int = 20,
|
||||
) -> pd.Series:
|
||||
"""Commodity Channel Index를 계산한다."""
|
||||
tp = (high + low + close) / 3.0
|
||||
sma_tp = sma(tp, period)
|
||||
mean_dev = (tp - sma_tp).abs().rolling(window=period, min_periods=period).mean()
|
||||
return (tp - sma_tp) / (0.015 * mean_dev.replace(0, pd.NA))
|
||||
|
||||
|
||||
def roc(close: pd.Series, period: int = 12) -> pd.Series:
|
||||
"""Rate of Change(%)를 계산한다."""
|
||||
prev = close.shift(period)
|
||||
return (close - prev) / prev.replace(0, pd.NA) * 100.0
|
||||
|
||||
|
||||
def adx(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
period: int = 14,
|
||||
) -> tuple[pd.Series, pd.Series, pd.Series]:
|
||||
"""ADX, +DI, -DI를 계산한다."""
|
||||
up_move = high.diff()
|
||||
down_move = -low.diff()
|
||||
plus_dm = up_move.where((up_move > down_move) & (up_move > 0), 0.0)
|
||||
minus_dm = down_move.where((down_move > up_move) & (down_move > 0), 0.0)
|
||||
atr_vals = atr(high, low, close, period=period)
|
||||
plus_di = 100 * ema(plus_dm, period) / atr_vals.replace(0, pd.NA)
|
||||
minus_di = 100 * ema(minus_dm, period) / atr_vals.replace(0, pd.NA)
|
||||
dx = (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, pd.NA) * 100
|
||||
adx_line = ema(dx, period)
|
||||
return adx_line, plus_di, minus_di
|
||||
|
||||
|
||||
def keltner_channels(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
ema_span: int = 20,
|
||||
atr_period: int = 10,
|
||||
atr_mult: float = 2.0,
|
||||
) -> tuple[pd.Series, pd.Series, pd.Series]:
|
||||
"""Keltner 채널 (중심, 상단, 하단)을 계산한다."""
|
||||
mid = ema(close, ema_span)
|
||||
atr_vals = atr(high, low, close, period=atr_period)
|
||||
upper = mid + atr_mult * atr_vals
|
||||
lower = mid - atr_mult * atr_vals
|
||||
return mid, upper, lower
|
||||
|
||||
|
||||
def obv(close: pd.Series, volume: pd.Series) -> pd.Series:
|
||||
"""On-Balance Volume을 계산한다."""
|
||||
direction = close.diff().apply(lambda x: 1 if x > 0 else (-1 if x < 0 else 0))
|
||||
return (direction * volume.astype(float)).cumsum()
|
||||
|
||||
|
||||
def supertrend(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
period: int = 10,
|
||||
multiplier: float = 3.0,
|
||||
) -> tuple[pd.Series, pd.Series]:
|
||||
"""Supertrend 라인과 방향(1=상승, -1=하락)을 계산한다."""
|
||||
atr_vals = atr(high, low, close, period=period)
|
||||
hl2 = (high + low) / 2.0
|
||||
basic_upper = hl2 + multiplier * atr_vals
|
||||
basic_lower = hl2 - multiplier * atr_vals
|
||||
|
||||
final_upper = basic_upper.copy()
|
||||
final_lower = basic_lower.copy()
|
||||
direction = pd.Series(1, index=close.index, dtype=float)
|
||||
st_line = pd.Series(index=close.index, dtype=float)
|
||||
|
||||
for i in range(1, len(close)):
|
||||
if pd.isna(final_upper.iloc[i]) or pd.isna(final_lower.iloc[i]):
|
||||
continue
|
||||
|
||||
if basic_upper.iloc[i] < final_upper.iloc[i - 1] or close.iloc[i - 1] > final_upper.iloc[i - 1]:
|
||||
final_upper.iloc[i] = basic_upper.iloc[i]
|
||||
else:
|
||||
final_upper.iloc[i] = final_upper.iloc[i - 1]
|
||||
|
||||
if basic_lower.iloc[i] > final_lower.iloc[i - 1] or close.iloc[i - 1] < final_lower.iloc[i - 1]:
|
||||
final_lower.iloc[i] = basic_lower.iloc[i]
|
||||
else:
|
||||
final_lower.iloc[i] = final_lower.iloc[i - 1]
|
||||
|
||||
if direction.iloc[i - 1] == 1:
|
||||
if close.iloc[i] < final_lower.iloc[i]:
|
||||
direction.iloc[i] = -1
|
||||
else:
|
||||
direction.iloc[i] = 1
|
||||
else:
|
||||
if close.iloc[i] > final_upper.iloc[i]:
|
||||
direction.iloc[i] = 1
|
||||
else:
|
||||
direction.iloc[i] = -1
|
||||
|
||||
st_line.iloc[i] = final_lower.iloc[i] if direction.iloc[i] == 1 else final_upper.iloc[i]
|
||||
|
||||
return st_line, direction
|
||||
|
||||
|
||||
def parabolic_sar(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
af_step: float = 0.02,
|
||||
af_max: float = 0.2,
|
||||
) -> pd.Series:
|
||||
"""Parabolic SAR 근사값을 계산한다."""
|
||||
length = len(close)
|
||||
sar = pd.Series(index=close.index, dtype=float)
|
||||
if length < 2:
|
||||
return sar
|
||||
|
||||
bull = True
|
||||
af = af_step
|
||||
ep = float(high.iloc[0])
|
||||
sar.iloc[0] = float(low.iloc[0])
|
||||
|
||||
for i in range(1, length):
|
||||
prev_sar = float(sar.iloc[i - 1]) if not pd.isna(sar.iloc[i - 1]) else float(low.iloc[0])
|
||||
curr_sar = prev_sar + af * (ep - prev_sar)
|
||||
h = float(high.iloc[i])
|
||||
low_i = float(low.iloc[i])
|
||||
|
||||
if bull:
|
||||
curr_sar = min(curr_sar, float(low.iloc[i - 1]), low_i)
|
||||
if low_i < curr_sar:
|
||||
bull = False
|
||||
curr_sar = ep
|
||||
ep = low_i
|
||||
af = af_step
|
||||
else:
|
||||
if h > ep:
|
||||
ep = h
|
||||
af = min(af + af_step, af_max)
|
||||
else:
|
||||
curr_sar = max(curr_sar, float(high.iloc[i - 1]), h)
|
||||
if h > curr_sar:
|
||||
bull = True
|
||||
curr_sar = ep
|
||||
ep = h
|
||||
af = af_step
|
||||
else:
|
||||
if low_i < ep:
|
||||
ep = low_i
|
||||
af = min(af + af_step, af_max)
|
||||
|
||||
sar.iloc[i] = curr_sar
|
||||
|
||||
return sar
|
||||
|
||||
|
||||
def ichimoku(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
tenkan: int = 9,
|
||||
kijun: int = 26,
|
||||
) -> tuple[pd.Series, pd.Series]:
|
||||
"""일목 전환선·기준선을 계산한다 (인과 신호용 간소 버전)."""
|
||||
tenkan_sen = (high.rolling(tenkan).max() + low.rolling(tenkan).min()) / 2.0
|
||||
kijun_sen = (high.rolling(kijun).max() + low.rolling(kijun).min()) / 2.0
|
||||
return tenkan_sen, kijun_sen
|
||||
|
||||
|
||||
def rolling_pivot_points(
|
||||
high: pd.Series,
|
||||
low: pd.Series,
|
||||
close: pd.Series,
|
||||
window: int = 60,
|
||||
) -> tuple[pd.Series, pd.Series, pd.Series]:
|
||||
"""롤링 피벗 P, S1, R1을 계산한다."""
|
||||
prev_high = high.shift(1).rolling(window).max()
|
||||
prev_low = low.shift(1).rolling(window).min()
|
||||
prev_close = close.shift(1)
|
||||
pivot = (prev_high + prev_low + prev_close) / 3.0
|
||||
s1 = 2 * pivot - prev_high
|
||||
r1 = 2 * pivot - prev_low
|
||||
return pivot, s1, r1
|
||||
|
||||
53
src/deepcoin/techniques/keltner_breakout.py
Normal file
53
src/deepcoin/techniques/keltner_breakout.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Keltner 채널 돌파 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import keltner_channels
|
||||
|
||||
|
||||
class KeltnerBreakoutTechnique(BaseTechnique):
|
||||
"""Keltner 상단 돌파 매수, 하단 이탈 매도."""
|
||||
|
||||
technique_id = "keltner_breakout"
|
||||
technique_name = "Keltner 돌파"
|
||||
category = "breakout"
|
||||
causal = True
|
||||
description = "Keltner 채널 상·하단 돌파 (B^)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"ema_span": 20, "atr_period": 10, "atr_mult": 2.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
ema_span = int(params.extra.get("ema_span", 20))
|
||||
atr_period = int(params.extra.get("atr_period", 10))
|
||||
atr_mult = float(params.extra.get("atr_mult", 2.0))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
_, upper, lower = keltner_channels(
|
||||
high, low, close, ema_span=ema_span, atr_period=atr_period, atr_mult=atr_mult,
|
||||
)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
start = ema_span + atr_period
|
||||
|
||||
for i in range(start + 1, len(df)):
|
||||
if pd.isna(upper.iloc[i]) or pd.isna(lower.iloc[i]):
|
||||
continue
|
||||
|
||||
prev_c = float(close.iloc[i - 1])
|
||||
c = float(close.iloc[i])
|
||||
u = float(upper.iloc[i])
|
||||
lo = float(lower.iloc[i])
|
||||
|
||||
if prev_c <= float(upper.iloc[i - 1]) and c > u:
|
||||
signals.append(make_signal(df, i, c, "buy", "keltner_breakout_up", confidence=0.72))
|
||||
elif prev_c >= float(lower.iloc[i - 1]) and c < lo:
|
||||
signals.append(make_signal(df, i, c, "sell", "keltner_breakout_down", confidence=0.72))
|
||||
|
||||
return signals
|
||||
63
src/deepcoin/techniques/keltner_reversal.py
Normal file
63
src/deepcoin/techniques/keltner_reversal.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Keltner 채널 역추세 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import keltner_channels
|
||||
|
||||
|
||||
class KeltnerReversalTechnique(BaseTechnique):
|
||||
"""Keltner 하단 터치 후 반등 매수, 상단 터치 후 하락 매도."""
|
||||
|
||||
technique_id = "keltner_reversal"
|
||||
technique_name = "Keltner 역추세"
|
||||
category = "volatility"
|
||||
causal = True
|
||||
description = "Keltner 채널 하단 매수·상단 매도"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"ema_span": 20, "atr_period": 10, "atr_mult": 2.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
ema_span = int(params.extra.get("ema_span", 20))
|
||||
atr_period = int(params.extra.get("atr_period", 10))
|
||||
atr_mult = float(params.extra.get("atr_mult", 2.0))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
_, upper, lower = keltner_channels(
|
||||
high, low, close, ema_span=ema_span, atr_period=atr_period, atr_mult=atr_mult,
|
||||
)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
touched_lower = False
|
||||
touched_upper = False
|
||||
start = ema_span + atr_period
|
||||
|
||||
for i in range(start, len(df)):
|
||||
if pd.isna(lower.iloc[i]):
|
||||
continue
|
||||
|
||||
l = float(low.iloc[i])
|
||||
h = float(high.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
lo = float(lower.iloc[i])
|
||||
u = float(upper.iloc[i])
|
||||
|
||||
if l <= lo:
|
||||
touched_lower = True
|
||||
if touched_lower and c > lo:
|
||||
signals.append(make_signal(df, i, c, "buy", "keltner_lower_bounce", confidence=0.69))
|
||||
touched_lower = False
|
||||
|
||||
if h >= u:
|
||||
touched_upper = True
|
||||
if touched_upper and c < u:
|
||||
signals.append(make_signal(df, i, c, "sell", "keltner_upper_reject", confidence=0.69))
|
||||
touched_upper = False
|
||||
|
||||
return signals
|
||||
130
src/deepcoin/techniques/legs.py
Normal file
130
src/deepcoin/techniques/legs.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""신호 → 1매수·1매도 레그 변환."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from deepcoin.techniques.base import TechniqueSignal
|
||||
|
||||
|
||||
def signals_to_legs(
|
||||
signals: list[TechniqueSignal],
|
||||
min_leg_pct: float = 3.0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""시간순 신호를 1매수·1매도 레그로 짝짓는다.
|
||||
|
||||
포지션 없을 때만 매수, 보유 중일 때만 매도를 인정한다.
|
||||
|
||||
Args:
|
||||
signals: 시간순 TechniqueSignal 리스트.
|
||||
min_leg_pct: 최소 레그 수익률(%) 필터.
|
||||
|
||||
Returns:
|
||||
GT legs와 동일 스키마의 레그 dict 리스트.
|
||||
"""
|
||||
sorted_signals = sorted(signals, key=lambda s: s.bar_index)
|
||||
legs: list[dict[str, Any]] = []
|
||||
pending_buy: TechniqueSignal | None = None
|
||||
leg_id = 0
|
||||
|
||||
for signal in sorted_signals:
|
||||
if signal.price <= 0:
|
||||
continue
|
||||
|
||||
if signal.side == "buy":
|
||||
if pending_buy is None:
|
||||
pending_buy = signal
|
||||
elif signal.price < pending_buy.price:
|
||||
pending_buy = signal
|
||||
continue
|
||||
|
||||
if signal.side != "sell" or pending_buy is None:
|
||||
continue
|
||||
|
||||
if pending_buy.price <= 0:
|
||||
pending_buy = None
|
||||
continue
|
||||
|
||||
leg_pct = (signal.price - pending_buy.price) / pending_buy.price * 100.0
|
||||
if leg_pct < min_leg_pct:
|
||||
continue
|
||||
|
||||
leg_id += 1
|
||||
legs.append(
|
||||
{
|
||||
"leg_id": leg_id,
|
||||
"buy_datetime": pending_buy.datetime,
|
||||
"buy_price": round(pending_buy.price, 2),
|
||||
"buy_bar_index": pending_buy.bar_index,
|
||||
"sell_datetime": signal.datetime,
|
||||
"sell_price": round(signal.price, 2),
|
||||
"sell_bar_index": signal.bar_index,
|
||||
"leg_pct": round(leg_pct, 2),
|
||||
"bars_held": signal.bar_index - pending_buy.bar_index,
|
||||
}
|
||||
)
|
||||
pending_buy = None
|
||||
|
||||
return legs
|
||||
|
||||
|
||||
def summarize_legs(legs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""레그 요약 통계를 계산한다."""
|
||||
if not legs:
|
||||
return {
|
||||
"leg_count": 0,
|
||||
"buy_count": 0,
|
||||
"sell_count": 0,
|
||||
"avg_leg_pct": 0.0,
|
||||
"median_leg_pct": 0.0,
|
||||
"max_leg_pct": 0.0,
|
||||
"min_leg_pct": 0.0,
|
||||
"avg_bars_held": 0.0,
|
||||
}
|
||||
|
||||
pcts = [float(leg["leg_pct"]) for leg in legs]
|
||||
bars = [int(leg["bars_held"]) for leg in legs]
|
||||
pcts_sorted = sorted(pcts)
|
||||
mid = len(pcts_sorted) // 2
|
||||
median = (
|
||||
pcts_sorted[mid]
|
||||
if len(pcts_sorted) % 2 == 1
|
||||
else (pcts_sorted[mid - 1] + pcts_sorted[mid]) / 2
|
||||
)
|
||||
|
||||
return {
|
||||
"leg_count": len(legs),
|
||||
"buy_count": len(legs),
|
||||
"sell_count": len(legs),
|
||||
"avg_leg_pct": round(sum(pcts) / len(pcts), 2),
|
||||
"median_leg_pct": round(median, 2),
|
||||
"max_leg_pct": round(max(pcts), 2),
|
||||
"min_leg_pct": round(min(pcts), 2),
|
||||
"avg_bars_held": round(sum(bars) / len(bars), 1),
|
||||
}
|
||||
|
||||
|
||||
def legs_to_signal_dicts(legs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""레그를 GT signals 형식으로 변환한다."""
|
||||
signals: list[dict[str, Any]] = []
|
||||
for leg in legs:
|
||||
signals.append(
|
||||
{
|
||||
"leg_id": leg["leg_id"],
|
||||
"side": "buy",
|
||||
"datetime": leg["buy_datetime"],
|
||||
"price": leg["buy_price"],
|
||||
"bar_index": leg["buy_bar_index"],
|
||||
}
|
||||
)
|
||||
signals.append(
|
||||
{
|
||||
"leg_id": leg["leg_id"],
|
||||
"side": "sell",
|
||||
"datetime": leg["sell_datetime"],
|
||||
"price": leg["sell_price"],
|
||||
"bar_index": leg["sell_bar_index"],
|
||||
"leg_pct": leg["leg_pct"],
|
||||
}
|
||||
)
|
||||
return signals
|
||||
125
src/deepcoin/techniques/local_extrema.py
Normal file
125
src/deepcoin/techniques/local_extrema.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""국소 극값 기반 매수·매도 (눌림목·반등 고점)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
|
||||
|
||||
class LocalExtremaTechnique(BaseTechnique):
|
||||
"""좌우 N봉 대비 국소 저점·고점에서 신호 (인과: 확정 지연 order봉)."""
|
||||
|
||||
technique_id = "local_extrema"
|
||||
technique_name = "국소 극값"
|
||||
category = "swing"
|
||||
causal = True
|
||||
description = "국소 저점 매수·고점 매도 (눌림목 유형 포착, 9/27 구간 등)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"order": 20, "min_swing_pct": 2.0, "min_bars_between": 30}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
order = int(params.extra.get("order", 20))
|
||||
min_swing_pct = float(params.extra.get("min_swing_pct", 2.0))
|
||||
min_bars = int(params.extra.get("min_bars_between", 30))
|
||||
buys = _find_local_low_signals(df, order=order, min_swing_pct=min_swing_pct)
|
||||
sells = _find_local_high_signals(df, order=order, min_swing_pct=min_swing_pct)
|
||||
return _dedupe_signals(buys + sells, min_bars=min_bars)
|
||||
|
||||
|
||||
def _find_local_low_signals(
|
||||
df: pd.DataFrame,
|
||||
order: int,
|
||||
min_swing_pct: float,
|
||||
) -> list[TechniqueSignal]:
|
||||
"""국소 저점 매수 신호 (order봉 후 확정)."""
|
||||
signals: list[TechniqueSignal] = []
|
||||
lookback = order * 4
|
||||
|
||||
for pivot_idx in range(order, len(df) - order):
|
||||
low_val = float(df.iloc[pivot_idx]["low"])
|
||||
window = df["low"].iloc[pivot_idx - order : pivot_idx + order + 1]
|
||||
if low_val > window.min():
|
||||
continue
|
||||
|
||||
start = max(0, pivot_idx - lookback)
|
||||
recent_high = float(df["high"].iloc[start : pivot_idx + 1].max())
|
||||
if recent_high <= 0:
|
||||
continue
|
||||
drop_pct = (recent_high - low_val) / recent_high * 100.0
|
||||
if drop_pct < min_swing_pct:
|
||||
continue
|
||||
|
||||
confirm_idx = pivot_idx + order
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="buy",
|
||||
bar_index=confirm_idx,
|
||||
price=round(low_val, 2),
|
||||
datetime=pd.Timestamp(df.iloc[confirm_idx]["datetime"]).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
pivot_bar_index=pivot_idx,
|
||||
confidence=min(1.0, drop_pct / 10.0),
|
||||
reason="local_low",
|
||||
)
|
||||
)
|
||||
return signals
|
||||
|
||||
|
||||
def _find_local_high_signals(
|
||||
df: pd.DataFrame,
|
||||
order: int,
|
||||
min_swing_pct: float,
|
||||
) -> list[TechniqueSignal]:
|
||||
"""국소 고점 매도 신호 (order봉 후 확정)."""
|
||||
signals: list[TechniqueSignal] = []
|
||||
lookback = order * 4
|
||||
|
||||
for pivot_idx in range(order, len(df) - order):
|
||||
high_val = float(df.iloc[pivot_idx]["high"])
|
||||
window = df["high"].iloc[pivot_idx - order : pivot_idx + order + 1]
|
||||
if high_val < window.max():
|
||||
continue
|
||||
|
||||
start = max(0, pivot_idx - lookback)
|
||||
recent_low = float(df["low"].iloc[start : pivot_idx + 1].min())
|
||||
if recent_low <= 0:
|
||||
continue
|
||||
rise_pct = (high_val - recent_low) / recent_low * 100.0
|
||||
if rise_pct < min_swing_pct:
|
||||
continue
|
||||
|
||||
confirm_idx = pivot_idx + order
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="sell",
|
||||
bar_index=confirm_idx,
|
||||
price=round(high_val, 2),
|
||||
datetime=pd.Timestamp(df.iloc[confirm_idx]["datetime"]).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
pivot_bar_index=pivot_idx,
|
||||
confidence=min(1.0, rise_pct / 10.0),
|
||||
reason="local_high",
|
||||
)
|
||||
)
|
||||
return signals
|
||||
|
||||
|
||||
def _dedupe_signals(signals: list[TechniqueSignal], min_bars: int) -> list[TechniqueSignal]:
|
||||
"""동일 방향 근접 신호를 병합한다."""
|
||||
if not signals:
|
||||
return []
|
||||
|
||||
sorted_signals = sorted(signals, key=lambda s: s.bar_index)
|
||||
merged: list[TechniqueSignal] = [sorted_signals[0]]
|
||||
|
||||
for signal in sorted_signals[1:]:
|
||||
last = merged[-1]
|
||||
if signal.side == last.side and signal.bar_index - last.bar_index < min_bars:
|
||||
if signal.side == "buy" and signal.price < last.price:
|
||||
merged[-1] = signal
|
||||
elif signal.side == "sell" and signal.price > last.price:
|
||||
merged[-1] = signal
|
||||
else:
|
||||
merged.append(signal)
|
||||
|
||||
return sorted(merged, key=lambda s: s.bar_index)
|
||||
68
src/deepcoin/techniques/ma_cross.py
Normal file
68
src/deepcoin/techniques/ma_cross.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""이동평균 골든·데드 크로스."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.indicators import ema
|
||||
|
||||
|
||||
class MaCrossTechnique(BaseTechnique):
|
||||
"""단기 EMA가 장기 EMA를 상향 돌파 시 매수, 하향 돌파 시 매도."""
|
||||
|
||||
technique_id = "ma_cross"
|
||||
technique_name = "EMA 크로스"
|
||||
category = "indicator"
|
||||
causal = True
|
||||
description = "EMA(20/60) 골든크로스 매수·데드크로스 매도"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"fast_span": 20, "slow_span": 60}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
fast_span = int(params.extra.get("fast_span", 20))
|
||||
slow_span = int(params.extra.get("slow_span", 60))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
fast = ema(close, fast_span)
|
||||
slow = ema(close, slow_span)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
start = max(fast_span, slow_span)
|
||||
|
||||
for i in range(start + 1, len(df)):
|
||||
if pd.isna(fast.iloc[i]) or pd.isna(slow.iloc[i]):
|
||||
continue
|
||||
|
||||
prev_fast = float(fast.iloc[i - 1])
|
||||
prev_slow = float(slow.iloc[i - 1])
|
||||
curr_fast = float(fast.iloc[i])
|
||||
curr_slow = float(slow.iloc[i])
|
||||
price = float(close.iloc[i])
|
||||
dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if prev_fast <= prev_slow and curr_fast > curr_slow:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="buy",
|
||||
bar_index=i,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=0.6,
|
||||
reason="golden_cross",
|
||||
)
|
||||
)
|
||||
elif prev_fast >= prev_slow and curr_fast < curr_slow:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="sell",
|
||||
bar_index=i,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=0.6,
|
||||
reason="death_cross",
|
||||
)
|
||||
)
|
||||
|
||||
return signals
|
||||
68
src/deepcoin/techniques/macd_cross.py
Normal file
68
src/deepcoin/techniques/macd_cross.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""MACD 시그널선 크로스."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.indicators import macd
|
||||
|
||||
|
||||
class MacdCrossTechnique(BaseTechnique):
|
||||
"""MACD가 시그널선을 상향 돌파 시 매수, 하향 돌파 시 매도."""
|
||||
|
||||
technique_id = "macd_cross"
|
||||
technique_name = "MACD 크로스"
|
||||
category = "indicator"
|
||||
causal = True
|
||||
description = "MACD(12,26,9) 시그널선 골든·데드 크로스"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"fast": 12, "slow": 26, "signal": 9}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
fast = int(params.extra.get("fast", 12))
|
||||
slow = int(params.extra.get("slow", 26))
|
||||
signal_span = int(params.extra.get("signal", 9))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
macd_line, signal_line, _ = macd(close, fast=fast, slow=slow, signal=signal_span)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
start = slow + signal_span
|
||||
|
||||
for i in range(start + 1, len(df)):
|
||||
if pd.isna(macd_line.iloc[i]) or pd.isna(signal_line.iloc[i]):
|
||||
continue
|
||||
|
||||
prev_macd = float(macd_line.iloc[i - 1])
|
||||
prev_sig = float(signal_line.iloc[i - 1])
|
||||
curr_macd = float(macd_line.iloc[i])
|
||||
curr_sig = float(signal_line.iloc[i])
|
||||
price = float(close.iloc[i])
|
||||
dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if prev_macd <= prev_sig and curr_macd > curr_sig:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="buy",
|
||||
bar_index=i,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=0.6,
|
||||
reason="macd_bull_cross",
|
||||
)
|
||||
)
|
||||
elif prev_macd >= prev_sig and curr_macd < curr_sig:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="sell",
|
||||
bar_index=i,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=0.6,
|
||||
reason="macd_bear_cross",
|
||||
)
|
||||
)
|
||||
|
||||
return signals
|
||||
76
src/deepcoin/techniques/macd_divergence.py
Normal file
76
src/deepcoin/techniques/macd_divergence.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""MACD 다이버전스 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import (
|
||||
dedupe_signals,
|
||||
detect_bearish_divergence,
|
||||
detect_bullish_divergence,
|
||||
find_confirmed_pivots,
|
||||
make_signal,
|
||||
)
|
||||
from deepcoin.techniques.indicators import macd
|
||||
|
||||
|
||||
class MacdDivergenceTechnique(BaseTechnique):
|
||||
"""MACD 히스토그램 상승·하락 다이버전스."""
|
||||
|
||||
technique_id = "macd_divergence"
|
||||
technique_name = "MACD 다이버전스"
|
||||
category = "divergence"
|
||||
causal = True
|
||||
description = "MACD 히스토그램 상승(Bd)·하락(Sd) 다이버전스"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {
|
||||
"fast": 12, "slow": 26, "signal": 9,
|
||||
"order": 12, "min_bars_between": 15, "max_bars_between": 400,
|
||||
}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
fast = int(params.extra.get("fast", 12))
|
||||
slow = int(params.extra.get("slow", 26))
|
||||
signal_span = int(params.extra.get("signal", 9))
|
||||
order = int(params.extra.get("order", 12))
|
||||
min_bars = int(params.extra.get("min_bars_between", 15))
|
||||
max_bars = int(params.extra.get("max_bars_between", 400))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
_, _, hist = macd(close, fast=fast, slow=slow, signal=signal_span)
|
||||
|
||||
low_pivots = find_confirmed_pivots(low, order, "low")
|
||||
high_pivots = find_confirmed_pivots(high, order, "high")
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for pivot_idx, _ in detect_bullish_divergence(
|
||||
low_pivots, hist, min_bars_between=min_bars, max_bars_between=max_bars,
|
||||
):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, float(close.iloc[confirm_idx]), "buy",
|
||||
"macd_bull_divergence", pivot_bar_index=pivot_idx, confidence=0.77,
|
||||
)
|
||||
)
|
||||
|
||||
for pivot_idx, _ in detect_bearish_divergence(
|
||||
high_pivots, hist, min_bars_between=min_bars, max_bars_between=max_bars,
|
||||
):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, float(close.iloc[confirm_idx]), "sell",
|
||||
"macd_bear_divergence", pivot_bar_index=pivot_idx, confidence=0.77,
|
||||
)
|
||||
)
|
||||
|
||||
return dedupe_signals(signals, min_bars=min_bars)
|
||||
67
src/deepcoin/techniques/minor_swing.py
Normal file
67
src/deepcoin/techniques/minor_swing.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""소형 ZigZag + 국소 극값 하이브리드."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.local_extrema import LocalExtremaTechnique
|
||||
from deepcoin.techniques.zigzag_causal import find_causal_zigzag_signals
|
||||
|
||||
|
||||
class MinorSwingTechnique(BaseTechnique):
|
||||
"""2.5% 인과 ZigZag와 국소 극값을 결합한 하이브리드."""
|
||||
|
||||
technique_id = "minor_swing"
|
||||
technique_name = "소형 스윙 하이브리드"
|
||||
category = "hybrid"
|
||||
causal = True
|
||||
description = "소형 ZigZag(2.5%) + 국소 극값 — GT 중간 눌림목 보완"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {
|
||||
"reversal_pct": 2.5,
|
||||
"order": 15,
|
||||
"min_swing_pct": 2.0,
|
||||
"min_bars_between": 20,
|
||||
}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
reversal_pct = float(params.extra.get("reversal_pct", 2.5))
|
||||
zz_signals = find_causal_zigzag_signals(df, reversal_pct=reversal_pct)
|
||||
|
||||
local_params = TechniqueParams(
|
||||
interval_min=params.interval_min,
|
||||
lookback_days=params.lookback_days,
|
||||
min_leg_pct=params.min_leg_pct,
|
||||
initial_cash_krw=params.initial_cash_krw,
|
||||
fee_rate=params.fee_rate,
|
||||
extra={
|
||||
"order": int(params.extra.get("order", 15)),
|
||||
"min_swing_pct": float(params.extra.get("min_swing_pct", 2.0)),
|
||||
"min_bars_between": int(params.extra.get("min_bars_between", 20)),
|
||||
},
|
||||
)
|
||||
local_signals = LocalExtremaTechnique().generate_signals(df, local_params)
|
||||
return _merge_signals(zz_signals + local_signals, min_bars=20)
|
||||
|
||||
|
||||
def _merge_signals(signals: list[TechniqueSignal], min_bars: int) -> list[TechniqueSignal]:
|
||||
"""방향별 근접 신호를 병합한다."""
|
||||
if not signals:
|
||||
return []
|
||||
|
||||
sorted_signals = sorted(signals, key=lambda s: s.bar_index)
|
||||
merged: list[TechniqueSignal] = [sorted_signals[0]]
|
||||
|
||||
for signal in sorted_signals[1:]:
|
||||
last = merged[-1]
|
||||
if signal.side == last.side and signal.bar_index - last.bar_index < min_bars:
|
||||
if signal.side == "buy" and signal.price < last.price:
|
||||
merged[-1] = signal
|
||||
elif signal.side == "sell" and signal.price > last.price:
|
||||
merged[-1] = signal
|
||||
else:
|
||||
merged.append(signal)
|
||||
|
||||
return sorted(merged, key=lambda s: s.bar_index)
|
||||
71
src/deepcoin/techniques/obv_divergence.py
Normal file
71
src/deepcoin/techniques/obv_divergence.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""OBV 다이버전스 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import (
|
||||
dedupe_signals,
|
||||
detect_bearish_divergence,
|
||||
detect_bullish_divergence,
|
||||
find_confirmed_pivots,
|
||||
make_signal,
|
||||
)
|
||||
from deepcoin.techniques.indicators import obv
|
||||
|
||||
|
||||
class ObvDivergenceTechnique(BaseTechnique):
|
||||
"""OBV 상승·하락 다이버전스."""
|
||||
|
||||
technique_id = "obv_divergence"
|
||||
technique_name = "OBV 다이버전스"
|
||||
category = "divergence"
|
||||
causal = True
|
||||
description = "OBV 상승(Bd)·하락(Sd) 다이버전스"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"order": 12, "min_bars_between": 15, "max_bars_between": 400}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
order = int(params.extra.get("order", 12))
|
||||
min_bars = int(params.extra.get("min_bars_between", 15))
|
||||
max_bars = int(params.extra.get("max_bars_between", 400))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
volume = df["volume"].astype(float)
|
||||
obv_vals = obv(close, volume)
|
||||
|
||||
low_pivots = find_confirmed_pivots(low, order, "low")
|
||||
high_pivots = find_confirmed_pivots(high, order, "high")
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for pivot_idx, _ in detect_bullish_divergence(
|
||||
low_pivots, obv_vals, min_bars_between=min_bars, max_bars_between=max_bars,
|
||||
):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, float(close.iloc[confirm_idx]), "buy",
|
||||
"obv_bull_divergence", pivot_bar_index=pivot_idx, confidence=0.76,
|
||||
)
|
||||
)
|
||||
|
||||
for pivot_idx, _ in detect_bearish_divergence(
|
||||
high_pivots, obv_vals, min_bars_between=min_bars, max_bars_between=max_bars,
|
||||
):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, float(close.iloc[confirm_idx]), "sell",
|
||||
"obv_bear_divergence", pivot_bar_index=pivot_idx, confidence=0.76,
|
||||
)
|
||||
)
|
||||
|
||||
return dedupe_signals(signals, min_bars=min_bars)
|
||||
46
src/deepcoin/techniques/parabolic_sar_signal.py
Normal file
46
src/deepcoin/techniques/parabolic_sar_signal.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Parabolic SAR 추세 전환 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import parabolic_sar
|
||||
|
||||
|
||||
class ParabolicSarTechnique(BaseTechnique):
|
||||
"""SAR가 가격 위·아래 전환 시 매도·매수."""
|
||||
|
||||
technique_id = "parabolic_sar"
|
||||
technique_name = "Parabolic SAR"
|
||||
category = "trend"
|
||||
causal = True
|
||||
description = "Parabolic SAR 추세 전환 신호"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"af_step": 0.02, "af_max": 0.2}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
sar = parabolic_sar(high, low, close)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(2, len(df)):
|
||||
if pd.isna(sar.iloc[i]) or pd.isna(sar.iloc[i - 1]):
|
||||
continue
|
||||
|
||||
prev_c = float(close.iloc[i - 1])
|
||||
c = float(close.iloc[i])
|
||||
prev_sar = float(sar.iloc[i - 1])
|
||||
curr_sar = float(sar.iloc[i])
|
||||
|
||||
if prev_c <= prev_sar and c > curr_sar:
|
||||
signals.append(make_signal(df, i, c, "buy", "psar_bull_flip", confidence=0.65))
|
||||
elif prev_c >= prev_sar and c < curr_sar:
|
||||
signals.append(make_signal(df, i, c, "sell", "psar_bear_flip", confidence=0.65))
|
||||
|
||||
return signals
|
||||
60
src/deepcoin/techniques/pivot_points.py
Normal file
60
src/deepcoin/techniques/pivot_points.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""롤링 피벗 포인트 반등 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import rolling_pivot_points
|
||||
|
||||
|
||||
class PivotPointsTechnique(BaseTechnique):
|
||||
"""S1 지지 반등 매수, R1 저항 거부 매도."""
|
||||
|
||||
technique_id = "pivot_points"
|
||||
technique_name = "피벗 포인트"
|
||||
category = "structure"
|
||||
causal = True
|
||||
description = "롤링 피벗 S1/R1 반전"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 60, "touch_pct": 0.2}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 60))
|
||||
touch_pct = float(params.extra.get("touch_pct", 0.2)) / 100.0
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
_, s1, r1 = rolling_pivot_points(high, low, close, window=window)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
touched_s1 = False
|
||||
touched_r1 = False
|
||||
|
||||
for i in range(window + 1, len(df)):
|
||||
if pd.isna(s1.iloc[i]) or pd.isna(r1.iloc[i]):
|
||||
continue
|
||||
|
||||
c = float(close.iloc[i])
|
||||
l = float(low.iloc[i])
|
||||
h = float(high.iloc[i])
|
||||
s1_i = float(s1.iloc[i])
|
||||
r1_i = float(r1.iloc[i])
|
||||
prev_c = float(close.iloc[i - 1])
|
||||
|
||||
if l <= s1_i * (1 + touch_pct):
|
||||
touched_s1 = True
|
||||
if touched_s1 and prev_c <= s1_i and c > s1_i:
|
||||
signals.append(make_signal(df, i, c, "buy", "pivot_s1_bounce", confidence=0.67))
|
||||
touched_s1 = False
|
||||
|
||||
if h >= r1_i * (1 - touch_pct):
|
||||
touched_r1 = True
|
||||
if touched_r1 and prev_c >= r1_i and c < r1_i:
|
||||
signals.append(make_signal(df, i, c, "sell", "pivot_r1_reject", confidence=0.67))
|
||||
touched_r1 = False
|
||||
|
||||
return signals
|
||||
52
src/deepcoin/techniques/pivot_swing.py
Normal file
52
src/deepcoin/techniques/pivot_swing.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""피벗 스윙 고저점 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import dedupe_signals, find_confirmed_pivots, make_signal
|
||||
|
||||
|
||||
class PivotSwingTechnique(BaseTechnique):
|
||||
"""좌우 N봉 피벗 저점·고점 확정 시 매수·매도."""
|
||||
|
||||
technique_id = "pivot_swing"
|
||||
technique_name = "피벗 스윙"
|
||||
category = "swing"
|
||||
causal = True
|
||||
description = "피벗 저점 매수·고점 매도 (스윙 B/S)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"order": 12, "min_bars_between": 24}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
order = int(params.extra.get("order", 12))
|
||||
min_bars = int(params.extra.get("min_bars_between", 24))
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for pivot_idx, pivot_val in find_confirmed_pivots(low, order, "low"):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, pivot_val, "buy",
|
||||
"pivot_low", pivot_bar_index=pivot_idx, confidence=0.75,
|
||||
)
|
||||
)
|
||||
|
||||
for pivot_idx, pivot_val in find_confirmed_pivots(high, order, "high"):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, pivot_val, "sell",
|
||||
"pivot_high", pivot_bar_index=pivot_idx, confidence=0.75,
|
||||
)
|
||||
)
|
||||
|
||||
return dedupe_signals(signals, min_bars=min_bars)
|
||||
49
src/deepcoin/techniques/range_breakout.py
Normal file
49
src/deepcoin/techniques/range_breakout.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""레인지 돌파 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
|
||||
|
||||
class RangeBreakoutTechnique(BaseTechnique):
|
||||
"""N봉 고가·저가 레인지 상향·하향 돌파."""
|
||||
|
||||
technique_id = "range_breakout"
|
||||
technique_name = "레인지 돌파"
|
||||
category = "breakout"
|
||||
causal = True
|
||||
description = "N봉 레인지 상·하단 돌파 (B^)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 40, "buffer_pct": 0.1}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 40))
|
||||
buffer_pct = float(params.extra.get("buffer_pct", 0.1)) / 100.0
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
range_high = high.shift(1).rolling(window).max()
|
||||
range_low = low.shift(1).rolling(window).min()
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(window + 1, len(df)):
|
||||
if pd.isna(range_high.iloc[i]) or pd.isna(range_low.iloc[i]):
|
||||
continue
|
||||
|
||||
c = float(close.iloc[i])
|
||||
prev_c = float(close.iloc[i - 1])
|
||||
rh = float(range_high.iloc[i])
|
||||
rl = float(range_low.iloc[i])
|
||||
|
||||
if prev_c <= rh and c > rh * (1 + buffer_pct):
|
||||
signals.append(make_signal(df, i, c, "buy", "range_breakout_up", confidence=0.73))
|
||||
elif prev_c >= rl and c < rl * (1 - buffer_pct):
|
||||
signals.append(make_signal(df, i, c, "sell", "range_breakout_down", confidence=0.73))
|
||||
|
||||
return signals
|
||||
139
src/deepcoin/techniques/registry.py
Normal file
139
src/deepcoin/techniques/registry.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""매매 기법 레지스트리."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique
|
||||
from deepcoin.techniques.adx_trend import AdxTrendTechnique
|
||||
from deepcoin.techniques.atr_channel import AtrChannelTechnique
|
||||
from deepcoin.techniques.bb_reversal import BbReversalTechnique
|
||||
from deepcoin.techniques.bb_squeeze_breakout import BbSqueezeBreakoutTechnique
|
||||
from deepcoin.techniques.cci_extreme import CciExtremeTechnique
|
||||
from deepcoin.techniques.composite_breakout import CompositeBreakoutTechnique
|
||||
from deepcoin.techniques.composite_divergence import CompositeDivergenceTechnique
|
||||
from deepcoin.techniques.composite_full import CompositeFullTechnique
|
||||
from deepcoin.techniques.composite_pullback import CompositePullbackTechnique
|
||||
from deepcoin.techniques.composite_swing import CompositeSwingTechnique
|
||||
from deepcoin.techniques.composite_v3 import CompositeV3Technique
|
||||
from deepcoin.techniques.donchian import DonchianTechnique
|
||||
from deepcoin.techniques.ema_pullback import EmaPullbackTechnique
|
||||
from deepcoin.techniques.fib_pullback import FibPullbackTechnique
|
||||
from deepcoin.techniques.fractal_swing import FractalSwingTechnique
|
||||
from deepcoin.techniques.ichimoku_trend import IchimokuTrendTechnique
|
||||
from deepcoin.techniques.keltner_breakout import KeltnerBreakoutTechnique
|
||||
from deepcoin.techniques.keltner_reversal import KeltnerReversalTechnique
|
||||
from deepcoin.techniques.local_extrema import LocalExtremaTechnique
|
||||
from deepcoin.techniques.ma_cross import MaCrossTechnique
|
||||
from deepcoin.techniques.macd_cross import MacdCrossTechnique
|
||||
from deepcoin.techniques.macd_divergence import MacdDivergenceTechnique
|
||||
from deepcoin.techniques.minor_swing import MinorSwingTechnique
|
||||
from deepcoin.techniques.obv_divergence import ObvDivergenceTechnique
|
||||
from deepcoin.techniques.parabolic_sar_signal import ParabolicSarTechnique
|
||||
from deepcoin.techniques.pivot_points import PivotPointsTechnique
|
||||
from deepcoin.techniques.pivot_swing import PivotSwingTechnique
|
||||
from deepcoin.techniques.range_breakout import RangeBreakoutTechnique
|
||||
from deepcoin.techniques.roc_reversal import RocReversalTechnique
|
||||
from deepcoin.techniques.rsi_divergence import RsiDivergenceTechnique
|
||||
from deepcoin.techniques.rsi_swing import RsiSwingTechnique
|
||||
from deepcoin.techniques.stochastic_cross import StochasticCrossTechnique
|
||||
from deepcoin.techniques.supertrend_signal import SupertrendTechnique
|
||||
from deepcoin.techniques.support_bounce import SupportBounceTechnique
|
||||
from deepcoin.techniques.support_resistance import SupportResistanceTechnique
|
||||
from deepcoin.techniques.swing_failure import SwingFailureTechnique
|
||||
from deepcoin.techniques.volume_breakout import VolumeBreakoutTechnique
|
||||
from deepcoin.techniques.volume_spike import VolumeSpikeTechnique
|
||||
from deepcoin.techniques.zigzag_causal import ZigzagCausalTechnique
|
||||
|
||||
# 카테고리별 단일 기법 (인과, 미래 데이터 미사용)
|
||||
_SINGLE_TECHNIQUES: list[BaseTechnique] = [
|
||||
# swing
|
||||
ZigzagCausalTechnique(),
|
||||
MinorSwingTechnique(),
|
||||
LocalExtremaTechnique(),
|
||||
PivotSwingTechnique(),
|
||||
FractalSwingTechnique(),
|
||||
SwingFailureTechnique(),
|
||||
DonchianTechnique(),
|
||||
# pullback
|
||||
EmaPullbackTechnique(),
|
||||
FibPullbackTechnique(),
|
||||
SupportBounceTechnique(),
|
||||
# breakout
|
||||
KeltnerBreakoutTechnique(),
|
||||
RangeBreakoutTechnique(),
|
||||
VolumeBreakoutTechnique(),
|
||||
BbSqueezeBreakoutTechnique(),
|
||||
# divergence
|
||||
RsiDivergenceTechnique(),
|
||||
MacdDivergenceTechnique(),
|
||||
ObvDivergenceTechnique(),
|
||||
# indicator (기존)
|
||||
BbReversalTechnique(),
|
||||
MaCrossTechnique(),
|
||||
RsiSwingTechnique(),
|
||||
MacdCrossTechnique(),
|
||||
# trend
|
||||
SupertrendTechnique(),
|
||||
AdxTrendTechnique(),
|
||||
IchimokuTrendTechnique(),
|
||||
ParabolicSarTechnique(),
|
||||
# momentum
|
||||
StochasticCrossTechnique(),
|
||||
CciExtremeTechnique(),
|
||||
RocReversalTechnique(),
|
||||
# volatility
|
||||
KeltnerReversalTechnique(),
|
||||
AtrChannelTechnique(),
|
||||
# structure
|
||||
PivotPointsTechnique(),
|
||||
SupportResistanceTechnique(),
|
||||
# volume
|
||||
VolumeSpikeTechnique(),
|
||||
]
|
||||
|
||||
# 복합 기법
|
||||
_COMPOSITE_TECHNIQUES: list[BaseTechnique] = [
|
||||
CompositeSwingTechnique(),
|
||||
CompositePullbackTechnique(),
|
||||
CompositeBreakoutTechnique(),
|
||||
CompositeDivergenceTechnique(),
|
||||
CompositeV3Technique(),
|
||||
CompositeFullTechnique(),
|
||||
]
|
||||
|
||||
_ALL_TECHNIQUES: list[BaseTechnique] = _SINGLE_TECHNIQUES + _COMPOSITE_TECHNIQUES
|
||||
|
||||
|
||||
def get_single_techniques() -> list[BaseTechnique]:
|
||||
"""복합 제외 단일 기법 목록을 반환한다."""
|
||||
return list(_SINGLE_TECHNIQUES)
|
||||
|
||||
|
||||
def get_composite_techniques() -> list[BaseTechnique]:
|
||||
"""복합 기법 목록을 반환한다."""
|
||||
return list(_COMPOSITE_TECHNIQUES)
|
||||
|
||||
|
||||
def get_all_techniques() -> list[BaseTechnique]:
|
||||
"""등록된 모든 매매 기법을 반환한다."""
|
||||
return list(_ALL_TECHNIQUES)
|
||||
|
||||
|
||||
def get_technique(technique_id: str) -> BaseTechnique | None:
|
||||
"""ID로 기법을 조회한다."""
|
||||
for technique in _ALL_TECHNIQUES:
|
||||
if technique.technique_id == technique_id:
|
||||
return technique
|
||||
return None
|
||||
|
||||
|
||||
def list_technique_ids() -> list[str]:
|
||||
"""기법 ID 목록을 반환한다."""
|
||||
return [t.technique_id for t in _ALL_TECHNIQUES]
|
||||
|
||||
|
||||
def techniques_by_category() -> dict[str, list[str]]:
|
||||
"""카테고리별 기법 ID 목록을 반환한다."""
|
||||
result: dict[str, list[str]] = {}
|
||||
for technique in _ALL_TECHNIQUES:
|
||||
result.setdefault(technique.category, []).append(technique.technique_id)
|
||||
return result
|
||||
55
src/deepcoin/techniques/roc_reversal.py
Normal file
55
src/deepcoin/techniques/roc_reversal.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""ROC 반전 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal, safe_float
|
||||
from deepcoin.techniques.indicators import roc
|
||||
|
||||
|
||||
class RocReversalTechnique(BaseTechnique):
|
||||
"""ROC 극단 하락 후 반등 매수, 극단 상승 후 하락 매도."""
|
||||
|
||||
technique_id = "roc_reversal"
|
||||
technique_name = "ROC 반전"
|
||||
category = "momentum"
|
||||
causal = True
|
||||
description = "ROC(12) 극값 반전 신호"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"period": 12, "low_pct": -5.0, "high_pct": 5.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
period = int(params.extra.get("period", 12))
|
||||
low_pct = float(params.extra.get("low_pct", -5.0))
|
||||
high_pct = float(params.extra.get("high_pct", 5.0))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
roc_vals = roc(close, period=period)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
was_low = False
|
||||
was_high = False
|
||||
|
||||
for i in range(period + 2, len(df)):
|
||||
curr = safe_float(roc_vals.iloc[i])
|
||||
prev = safe_float(roc_vals.iloc[i - 1])
|
||||
c = safe_float(close.iloc[i])
|
||||
if curr is None or prev is None or c is None:
|
||||
continue
|
||||
|
||||
if curr < low_pct:
|
||||
was_low = True
|
||||
if was_low and prev < low_pct <= curr:
|
||||
signals.append(make_signal(df, i, c, "buy", "roc_bull_reversal", confidence=0.64))
|
||||
was_low = False
|
||||
|
||||
if curr > high_pct:
|
||||
was_high = True
|
||||
if was_high and prev > high_pct >= curr:
|
||||
signals.append(make_signal(df, i, c, "sell", "roc_bear_reversal", confidence=0.64))
|
||||
was_high = False
|
||||
|
||||
return signals
|
||||
71
src/deepcoin/techniques/rsi_divergence.py
Normal file
71
src/deepcoin/techniques/rsi_divergence.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""RSI 다이버전스 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import (
|
||||
dedupe_signals,
|
||||
detect_bearish_divergence,
|
||||
detect_bullish_divergence,
|
||||
find_confirmed_pivots,
|
||||
make_signal,
|
||||
)
|
||||
from deepcoin.techniques.indicators import rsi
|
||||
|
||||
|
||||
class RsiDivergenceTechnique(BaseTechnique):
|
||||
"""RSI 상승·하락 다이버전스 매수·매도."""
|
||||
|
||||
technique_id = "rsi_divergence"
|
||||
technique_name = "RSI 다이버전스"
|
||||
category = "divergence"
|
||||
causal = True
|
||||
description = "RSI 상승(Bd)·하락(Sd) 다이버전스"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"period": 14, "order": 12, "min_bars_between": 15, "max_bars_between": 400}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
period = int(params.extra.get("period", 14))
|
||||
order = int(params.extra.get("order", 12))
|
||||
min_bars = int(params.extra.get("min_bars_between", 15))
|
||||
max_bars = int(params.extra.get("max_bars_between", 400))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
rsi_vals = rsi(close, period=period)
|
||||
|
||||
low_pivots = find_confirmed_pivots(low, order, "low")
|
||||
high_pivots = find_confirmed_pivots(high, order, "high")
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for pivot_idx, _ in detect_bullish_divergence(
|
||||
low_pivots, rsi_vals, min_bars_between=min_bars, max_bars_between=max_bars,
|
||||
):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, float(close.iloc[confirm_idx]), "buy",
|
||||
"rsi_bull_divergence", pivot_bar_index=pivot_idx, confidence=0.78,
|
||||
)
|
||||
)
|
||||
|
||||
for pivot_idx, _ in detect_bearish_divergence(
|
||||
high_pivots, rsi_vals, min_bars_between=min_bars, max_bars_between=max_bars,
|
||||
):
|
||||
confirm_idx = pivot_idx + order
|
||||
if confirm_idx >= len(df):
|
||||
continue
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, confirm_idx, float(close.iloc[confirm_idx]), "sell",
|
||||
"rsi_bear_divergence", pivot_bar_index=pivot_idx, confidence=0.78,
|
||||
)
|
||||
)
|
||||
|
||||
return dedupe_signals(signals, min_bars=min_bars)
|
||||
74
src/deepcoin/techniques/rsi_swing.py
Normal file
74
src/deepcoin/techniques/rsi_swing.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""RSI 과매도·과매수 스윙."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.indicators import rsi
|
||||
|
||||
|
||||
class RsiSwingTechnique(BaseTechnique):
|
||||
"""RSI 30 이탈 후 복귀 시 매수, 70 이탈 후 복귀 시 매도."""
|
||||
|
||||
technique_id = "rsi_swing"
|
||||
technique_name = "RSI 스윙"
|
||||
category = "indicator"
|
||||
causal = True
|
||||
description = "RSI(14) 과매도 반등 매수·과매수 하락 매도"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"period": 14, "oversold": 30.0, "overbought": 70.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
period = int(params.extra.get("period", 14))
|
||||
oversold = float(params.extra.get("oversold", 30.0))
|
||||
overbought = float(params.extra.get("overbought", 70.0))
|
||||
|
||||
close = df["close"].astype(float)
|
||||
rsi_vals = rsi(close, period=period)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
was_oversold = False
|
||||
was_overbought = False
|
||||
|
||||
for i in range(period + 1, len(df)):
|
||||
if pd.isna(rsi_vals.iloc[i]):
|
||||
continue
|
||||
|
||||
curr = float(rsi_vals.iloc[i])
|
||||
prev = float(rsi_vals.iloc[i - 1])
|
||||
price = float(close.iloc[i])
|
||||
dt = pd.Timestamp(df.iloc[i]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if curr < oversold:
|
||||
was_oversold = True
|
||||
if was_oversold and prev < oversold <= curr:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="buy",
|
||||
bar_index=i,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=0.65,
|
||||
reason="rsi_oversold_exit",
|
||||
)
|
||||
)
|
||||
was_oversold = False
|
||||
|
||||
if curr > overbought:
|
||||
was_overbought = True
|
||||
if was_overbought and prev > overbought >= curr:
|
||||
signals.append(
|
||||
TechniqueSignal(
|
||||
side="sell",
|
||||
bar_index=i,
|
||||
price=round(price, 2),
|
||||
datetime=dt,
|
||||
confidence=0.65,
|
||||
reason="rsi_overbought_exit",
|
||||
)
|
||||
)
|
||||
was_overbought = False
|
||||
|
||||
return signals
|
||||
206
src/deepcoin/techniques/runner.py
Normal file
206
src/deepcoin/techniques/runner.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""매매 기법 실행 및 결과 저장."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from deepcoin.data.candle_loader import load_candles
|
||||
from deepcoin.data.intervals import interval_label
|
||||
from deepcoin.evaluation.gt_align import align_with_ground_truth
|
||||
from deepcoin.ground_truth.pnl import simulate_gt_pnl
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueResult
|
||||
from deepcoin.techniques.legs import legs_to_signal_dicts, signals_to_legs, summarize_legs
|
||||
from deepcoin.techniques.registry import get_all_techniques, get_technique
|
||||
|
||||
|
||||
def run_technique(
|
||||
technique: BaseTechnique,
|
||||
df: pd.DataFrame,
|
||||
params: TechniqueParams,
|
||||
gt_result: dict[str, Any] | None = None,
|
||||
tolerance_bars: int = 480,
|
||||
) -> TechniqueResult:
|
||||
"""단일 기법을 실행하고 GT 정합을 계산한다.
|
||||
|
||||
Args:
|
||||
technique: 실행할 기법.
|
||||
df: 캔들 DataFrame.
|
||||
params: 실행 파라미터.
|
||||
gt_result: Ground Truth JSON dict (정합 평가용).
|
||||
tolerance_bars: GT 신호 매칭 허용 봉 수.
|
||||
|
||||
Returns:
|
||||
TechniqueResult.
|
||||
"""
|
||||
merged_extra = {**technique.default_extra_params(), **params.extra}
|
||||
run_params = TechniqueParams(
|
||||
interval_min=params.interval_min,
|
||||
lookback_days=params.lookback_days,
|
||||
min_leg_pct=params.min_leg_pct,
|
||||
initial_cash_krw=params.initial_cash_krw,
|
||||
fee_rate=params.fee_rate,
|
||||
extra=merged_extra,
|
||||
)
|
||||
|
||||
raw_signals = technique.generate_signals(df, run_params)
|
||||
raw_signals = [s for s in raw_signals if s.price > 0]
|
||||
legs = signals_to_legs(raw_signals, min_leg_pct=run_params.min_leg_pct)
|
||||
summary = summarize_legs(legs)
|
||||
pnl = simulate_gt_pnl(
|
||||
legs,
|
||||
initial_cash_krw=run_params.initial_cash_krw,
|
||||
fee_rate=run_params.fee_rate,
|
||||
)
|
||||
|
||||
alignment = None
|
||||
if gt_result is not None:
|
||||
alignment = align_with_ground_truth(
|
||||
gt_result=gt_result,
|
||||
technique_signals=[s.to_dict() for s in raw_signals],
|
||||
technique_legs=legs,
|
||||
tolerance_bars=tolerance_bars,
|
||||
)
|
||||
|
||||
return TechniqueResult(
|
||||
technique_id=technique.technique_id,
|
||||
technique_name=technique.technique_name,
|
||||
category=technique.category,
|
||||
causal=technique.causal,
|
||||
description=technique.description,
|
||||
params={
|
||||
"interval_min": run_params.interval_min,
|
||||
"lookback_days": run_params.lookback_days,
|
||||
"min_leg_pct": run_params.min_leg_pct,
|
||||
"initial_cash_krw": run_params.initial_cash_krw,
|
||||
"fee_rate": run_params.fee_rate,
|
||||
**merged_extra,
|
||||
},
|
||||
signals=[s.to_dict() for s in raw_signals],
|
||||
legs=legs,
|
||||
summary=summary,
|
||||
pnl=pnl,
|
||||
alignment=alignment,
|
||||
)
|
||||
|
||||
|
||||
def run_all_techniques(
|
||||
db_path: Path,
|
||||
symbol: str,
|
||||
params: TechniqueParams,
|
||||
gt_result: dict[str, Any] | None = None,
|
||||
tolerance_bars: int = 480,
|
||||
technique_ids: list[str] | None = None,
|
||||
on_result: Callable[[TechniqueResult], None] | None = None,
|
||||
skip_errors: bool = True,
|
||||
) -> list[TechniqueResult]:
|
||||
"""등록된 기법을 일괄 실행한다.
|
||||
|
||||
Args:
|
||||
on_result: 기법 1건 완료 시 호출 (즉시 저장 등).
|
||||
skip_errors: True면 실패 기법은 건너뛰고 계속 실행.
|
||||
"""
|
||||
df = load_candles(
|
||||
db_path=db_path,
|
||||
symbol=symbol,
|
||||
interval_min=params.interval_min,
|
||||
lookback_days=params.lookback_days,
|
||||
)
|
||||
|
||||
techniques = get_all_techniques()
|
||||
if technique_ids:
|
||||
techniques = [t for t in techniques if t.technique_id in technique_ids]
|
||||
|
||||
results: list[TechniqueResult] = []
|
||||
total = len(techniques)
|
||||
for idx, technique in enumerate(techniques, start=1):
|
||||
try:
|
||||
result = run_technique(
|
||||
technique=technique,
|
||||
df=df,
|
||||
params=params,
|
||||
gt_result=gt_result,
|
||||
tolerance_bars=tolerance_bars,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("기법 실행 실패: %s", technique.technique_id)
|
||||
if not skip_errors:
|
||||
raise
|
||||
continue
|
||||
results.append(result)
|
||||
if on_result is not None:
|
||||
on_result(result)
|
||||
pct = idx / total * 100.0 if total else 100.0
|
||||
logger.info(
|
||||
"기법 진행 %d/%d (%.1f%%) — %s",
|
||||
idx,
|
||||
total,
|
||||
pct,
|
||||
technique.technique_id,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def load_technique_result(path: Path) -> TechniqueResult:
|
||||
"""저장된 기법 JSON을 TechniqueResult로 로드한다."""
|
||||
with path.open(encoding="utf-8") as fp:
|
||||
payload = json.load(fp)
|
||||
return TechniqueResult(
|
||||
technique_id=payload["technique_id"],
|
||||
technique_name=payload["technique_name"],
|
||||
category=payload["category"],
|
||||
causal=payload["causal"],
|
||||
description=payload.get("description", ""),
|
||||
params=payload.get("params", {}),
|
||||
signals=payload.get("signals", []),
|
||||
legs=payload.get("legs", []),
|
||||
summary=payload.get("summary", {}),
|
||||
pnl=payload.get("pnl", {}),
|
||||
alignment=payload.get("alignment"),
|
||||
)
|
||||
|
||||
|
||||
def load_technique_results(
|
||||
output_dir: Path,
|
||||
technique_ids: list[str] | None = None,
|
||||
) -> list[TechniqueResult]:
|
||||
"""저장된 기법 JSON 목록을 로드한다."""
|
||||
if technique_ids:
|
||||
paths = [output_dir / f"{tid}.json" for tid in technique_ids]
|
||||
else:
|
||||
paths = sorted(output_dir.glob("*.json"))
|
||||
|
||||
results: list[TechniqueResult] = []
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
results.append(load_technique_result(path))
|
||||
return results
|
||||
|
||||
|
||||
def save_technique_result(result: TechniqueResult, output_dir: Path) -> Path:
|
||||
"""기법 결과를 JSON으로 저장한다."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / f"{result.technique_id}.json"
|
||||
payload = result.to_dict()
|
||||
payload["meta"] = {
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"interval_label": interval_label(int(result.params["interval_min"])),
|
||||
}
|
||||
with out_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(payload, fp, ensure_ascii=False, indent=2)
|
||||
return out_path
|
||||
|
||||
|
||||
def load_ground_truth(gt_path: Path) -> dict[str, Any]:
|
||||
"""Ground Truth JSON을 로드한다."""
|
||||
with gt_path.open(encoding="utf-8") as fp:
|
||||
return json.load(fp)
|
||||
53
src/deepcoin/techniques/stochastic_cross.py
Normal file
53
src/deepcoin/techniques/stochastic_cross.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Stochastic 크로스 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import stochastic
|
||||
|
||||
|
||||
class StochasticCrossTechnique(BaseTechnique):
|
||||
"""Stochastic %K가 %D 상향·하향 돌파 시 매수·매도."""
|
||||
|
||||
technique_id = "stochastic_cross"
|
||||
technique_name = "Stochastic 크로스"
|
||||
category = "momentum"
|
||||
causal = True
|
||||
description = "Stochastic(14,3) %K/%D 크로스"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"k_period": 14, "d_period": 3, "oversold": 20.0, "overbought": 80.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
k_period = int(params.extra.get("k_period", 14))
|
||||
d_period = int(params.extra.get("d_period", 3))
|
||||
oversold = float(params.extra.get("oversold", 20.0))
|
||||
overbought = float(params.extra.get("overbought", 80.0))
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
pct_k, pct_d = stochastic(high, low, close, k_period=k_period, d_period=d_period)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
start = k_period + d_period
|
||||
|
||||
for i in range(start + 1, len(df)):
|
||||
if pd.isna(pct_k.iloc[i]) or pd.isna(pct_d.iloc[i]):
|
||||
continue
|
||||
|
||||
prev_k = float(pct_k.iloc[i - 1])
|
||||
prev_d = float(pct_d.iloc[i - 1])
|
||||
curr_k = float(pct_k.iloc[i])
|
||||
curr_d = float(pct_d.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
|
||||
if prev_k <= prev_d and curr_k > curr_d and curr_k < oversold + 15:
|
||||
signals.append(make_signal(df, i, c, "buy", "stoch_bull_cross", confidence=0.66))
|
||||
elif prev_k >= prev_d and curr_k < curr_d and curr_k > overbought - 15:
|
||||
signals.append(make_signal(df, i, c, "sell", "stoch_bear_cross", confidence=0.66))
|
||||
|
||||
return signals
|
||||
48
src/deepcoin/techniques/supertrend_signal.py
Normal file
48
src/deepcoin/techniques/supertrend_signal.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Supertrend 추세 전환 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
from deepcoin.techniques.indicators import supertrend
|
||||
|
||||
|
||||
class SupertrendTechnique(BaseTechnique):
|
||||
"""Supertrend 방향 전환 시 매수·매도."""
|
||||
|
||||
technique_id = "supertrend"
|
||||
technique_name = "Supertrend"
|
||||
category = "trend"
|
||||
causal = True
|
||||
description = "Supertrend 상승·하락 전환 신호"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"period": 10, "multiplier": 3.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
period = int(params.extra.get("period", 10))
|
||||
multiplier = float(params.extra.get("multiplier", 3.0))
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
_, direction = supertrend(high, low, close, period=period, multiplier=multiplier)
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(period + 2, len(df)):
|
||||
if pd.isna(direction.iloc[i]) or pd.isna(direction.iloc[i - 1]):
|
||||
continue
|
||||
|
||||
prev_dir = float(direction.iloc[i - 1])
|
||||
curr_dir = float(direction.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
|
||||
if prev_dir < 0 < curr_dir:
|
||||
signals.append(make_signal(df, i, c, "buy", "supertrend_bull_flip", confidence=0.68))
|
||||
elif prev_dir > 0 > curr_dir:
|
||||
signals.append(make_signal(df, i, c, "sell", "supertrend_bear_flip", confidence=0.68))
|
||||
|
||||
return signals
|
||||
60
src/deepcoin/techniques/support_bounce.py
Normal file
60
src/deepcoin/techniques/support_bounce.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""지지·저항 반등 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
|
||||
|
||||
class SupportBounceTechnique(BaseTechnique):
|
||||
"""롤링 지지선 터치 후 반등 매수, 저항선 터치 후 하락 매도."""
|
||||
|
||||
technique_id = "support_bounce"
|
||||
technique_name = "지지·저항 반등"
|
||||
category = "pullback"
|
||||
causal = True
|
||||
description = "N봉 지지·저항 터치 후 반전 (B*)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 80, "touch_pct": 0.3}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 80))
|
||||
touch_pct = float(params.extra.get("touch_pct", 0.3)) / 100.0
|
||||
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
support = low.shift(1).rolling(window).min()
|
||||
resistance = high.shift(1).rolling(window).max()
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
touched_support = False
|
||||
touched_resistance = False
|
||||
|
||||
for i in range(window + 1, len(df)):
|
||||
if pd.isna(support.iloc[i]) or pd.isna(resistance.iloc[i]):
|
||||
continue
|
||||
|
||||
c = float(close.iloc[i])
|
||||
l = float(low.iloc[i])
|
||||
h = float(high.iloc[i])
|
||||
sup = float(support.iloc[i])
|
||||
res = float(resistance.iloc[i])
|
||||
prev_c = float(close.iloc[i - 1])
|
||||
|
||||
if l <= sup * (1 + touch_pct):
|
||||
touched_support = True
|
||||
if touched_support and prev_c <= sup and c > sup:
|
||||
signals.append(make_signal(df, i, c, "buy", "support_bounce", confidence=0.71))
|
||||
touched_support = False
|
||||
|
||||
if h >= res * (1 - touch_pct):
|
||||
touched_resistance = True
|
||||
if touched_resistance and prev_c >= res and c < res:
|
||||
signals.append(make_signal(df, i, c, "sell", "resistance_reject", confidence=0.71))
|
||||
touched_resistance = False
|
||||
|
||||
return signals
|
||||
64
src/deepcoin/techniques/support_resistance.py
Normal file
64
src/deepcoin/techniques/support_resistance.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""스윙 기반 지지·저항 구조 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import dedupe_signals, find_confirmed_pivots, make_signal
|
||||
|
||||
|
||||
class SupportResistanceTechnique(BaseTechnique):
|
||||
"""확정 스윙 고저점을 지지·저항으로 활용한 반전."""
|
||||
|
||||
technique_id = "support_resistance"
|
||||
technique_name = "구조적 지지·저항"
|
||||
category = "structure"
|
||||
causal = True
|
||||
description = "스윙 피벗 기반 S/R 반전"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"order": 15, "touch_pct": 0.4, "max_age_bars": 500}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
order = int(params.extra.get("order", 15))
|
||||
touch_pct = float(params.extra.get("touch_pct", 0.4)) / 100.0
|
||||
max_age = int(params.extra.get("max_age_bars", 500))
|
||||
|
||||
low = df["low"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
low_pivots = find_confirmed_pivots(low, order, "low")
|
||||
high_pivots = find_confirmed_pivots(high, order, "high")
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(order * 2, len(df)):
|
||||
c = float(close.iloc[i])
|
||||
l = float(low.iloc[i])
|
||||
h = float(high.iloc[i])
|
||||
|
||||
for pivot_idx, pivot_val in reversed(low_pivots):
|
||||
if pivot_idx >= i or i - pivot_idx > max_age:
|
||||
continue
|
||||
if l <= pivot_val * (1 + touch_pct) and c > pivot_val:
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, i, c, "buy", "sr_support_bounce",
|
||||
pivot_bar_index=pivot_idx, confidence=0.7,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
for pivot_idx, pivot_val in reversed(high_pivots):
|
||||
if pivot_idx >= i or i - pivot_idx > max_age:
|
||||
continue
|
||||
if h >= pivot_val * (1 - touch_pct) and c < pivot_val:
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, i, c, "sell", "sr_resistance_reject",
|
||||
pivot_bar_index=pivot_idx, confidence=0.7,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
return dedupe_signals(signals, min_bars=order)
|
||||
67
src/deepcoin/techniques/swing_failure.py
Normal file
67
src/deepcoin/techniques/swing_failure.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""스윙 실패(페일드) 패턴 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import dedupe_signals, find_confirmed_pivots, make_signal
|
||||
|
||||
|
||||
class SwingFailureTechnique(BaseTechnique):
|
||||
"""이전 스윙 고점 돌파 실패 후 매도, 저점 이탈 실패 후 매수."""
|
||||
|
||||
technique_id = "swing_failure"
|
||||
technique_name = "스윙 실패"
|
||||
category = "swing"
|
||||
causal = True
|
||||
description = "스윙 고저점 돌파 실패(페일드 브레이크아웃) 반전 신호"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"order": 10, "min_bars_between": 30}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
order = int(params.extra.get("order", 10))
|
||||
min_bars = int(params.extra.get("min_bars_between", 30))
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
high_pivots = find_confirmed_pivots(high, order, "high")
|
||||
low_pivots = find_confirmed_pivots(low, order, "low")
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(1, len(high_pivots)):
|
||||
prev_idx, prev_high = high_pivots[i - 1]
|
||||
pivot_idx, pivot_high = high_pivots[i]
|
||||
if pivot_high <= prev_high:
|
||||
continue
|
||||
search_start = pivot_idx + order
|
||||
search_end = min(len(df), search_start + order * 6)
|
||||
for j in range(search_start, search_end):
|
||||
if float(high.iloc[j]) > prev_high and float(close.iloc[j]) < prev_high:
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, j, float(close.iloc[j]), "sell",
|
||||
"swing_failure_high", pivot_bar_index=pivot_idx, confidence=0.72,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
for i in range(1, len(low_pivots)):
|
||||
prev_idx, prev_low = low_pivots[i - 1]
|
||||
pivot_idx, pivot_low = low_pivots[i]
|
||||
if pivot_low >= prev_low:
|
||||
continue
|
||||
search_start = pivot_idx + order
|
||||
search_end = min(len(df), search_start + order * 6)
|
||||
for j in range(search_start, search_end):
|
||||
if float(low.iloc[j]) < prev_low and float(close.iloc[j]) > prev_low:
|
||||
signals.append(
|
||||
make_signal(
|
||||
df, j, float(close.iloc[j]), "buy",
|
||||
"swing_failure_low", pivot_bar_index=pivot_idx, confidence=0.72,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
return dedupe_signals(signals, min_bars=min_bars)
|
||||
55
src/deepcoin/techniques/volume_breakout.py
Normal file
55
src/deepcoin/techniques/volume_breakout.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""거래량 동반 돌파 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
|
||||
|
||||
class VolumeBreakoutTechnique(BaseTechnique):
|
||||
"""거래량 급증과 함께 레인지 상·하단 돌파."""
|
||||
|
||||
technique_id = "volume_breakout"
|
||||
technique_name = "거래량 돌파"
|
||||
category = "breakout"
|
||||
causal = True
|
||||
description = "거래량 스파이크 + 레인지 돌파 (B^)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 40, "vol_mult": 1.8}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 40))
|
||||
vol_mult = float(params.extra.get("vol_mult", 1.8))
|
||||
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
volume = df["volume"].astype(float)
|
||||
range_high = high.shift(1).rolling(window).max()
|
||||
range_low = low.shift(1).rolling(window).min()
|
||||
vol_ma = volume.rolling(window).mean()
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(window + 1, len(df)):
|
||||
if pd.isna(range_high.iloc[i]) or pd.isna(vol_ma.iloc[i]):
|
||||
continue
|
||||
|
||||
c = float(close.iloc[i])
|
||||
v = float(volume.iloc[i])
|
||||
vma = float(vol_ma.iloc[i])
|
||||
if vma <= 0 or v < vma * vol_mult:
|
||||
continue
|
||||
|
||||
rh = float(range_high.iloc[i])
|
||||
rl = float(range_low.iloc[i])
|
||||
|
||||
if c > rh:
|
||||
signals.append(make_signal(df, i, c, "buy", "volume_breakout_up", confidence=0.74))
|
||||
elif c < rl:
|
||||
signals.append(make_signal(df, i, c, "sell", "volume_breakout_down", confidence=0.74))
|
||||
|
||||
return signals
|
||||
60
src/deepcoin/techniques/volume_spike.py
Normal file
60
src/deepcoin/techniques/volume_spike.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""거래량 스파이크 반전 기법."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
from deepcoin.techniques.helpers import make_signal
|
||||
|
||||
|
||||
class VolumeSpikeTechnique(BaseTechnique):
|
||||
"""거래량 급증 봉의 방향 반전 신호."""
|
||||
|
||||
technique_id = "volume_spike"
|
||||
technique_name = "거래량 스파이크"
|
||||
category = "volume"
|
||||
causal = True
|
||||
description = "거래량 스파이크 후 반전 (클라이맥스)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"window": 30, "vol_mult": 2.5, "min_body_pct": 0.5}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
window = int(params.extra.get("window", 30))
|
||||
vol_mult = float(params.extra.get("vol_mult", 2.5))
|
||||
min_body_pct = float(params.extra.get("min_body_pct", 0.5)) / 100.0
|
||||
|
||||
open_ = df["open"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
volume = df["volume"].astype(float)
|
||||
vol_ma = volume.rolling(window).mean()
|
||||
|
||||
signals: list[TechniqueSignal] = []
|
||||
|
||||
for i in range(window + 1, len(df) - 1):
|
||||
if pd.isna(vol_ma.iloc[i]):
|
||||
continue
|
||||
|
||||
v = float(volume.iloc[i])
|
||||
vma = float(vol_ma.iloc[i])
|
||||
if vma <= 0 or v < vma * vol_mult:
|
||||
continue
|
||||
|
||||
o = float(open_.iloc[i])
|
||||
c = float(close.iloc[i])
|
||||
body_pct = abs(c - o) / o if o > 0 else 0
|
||||
if body_pct < min_body_pct:
|
||||
continue
|
||||
|
||||
next_c = float(close.iloc[i + 1])
|
||||
if c < o and next_c > c:
|
||||
signals.append(
|
||||
make_signal(df, i + 1, next_c, "buy", "volume_climax_buy", confidence=0.63)
|
||||
)
|
||||
elif c > o and next_c < c:
|
||||
signals.append(
|
||||
make_signal(df, i + 1, next_c, "sell", "volume_climax_sell", confidence=0.63)
|
||||
)
|
||||
|
||||
return signals
|
||||
112
src/deepcoin/techniques/zigzag_causal.py
Normal file
112
src/deepcoin/techniques/zigzag_causal.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""인과 ZigZag — 피벗 확정 시점에 매수·매도 신호."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deepcoin.techniques.base import BaseTechnique, TechniqueParams, TechniqueSignal
|
||||
|
||||
|
||||
class ZigzagCausalTechnique(BaseTechnique):
|
||||
"""되돌림 % 확정 시 피벗을 인정하는 인과 ZigZag 기법.
|
||||
|
||||
Ground Truth ZigZag와 동일 임계값을 쓰되, 미래 데이터 없이
|
||||
피벗이 확정된 봉에서만 신호를 발생시킨다.
|
||||
"""
|
||||
|
||||
technique_id = "zigzag_causal"
|
||||
technique_name = "인과 ZigZag"
|
||||
category = "swing"
|
||||
causal = True
|
||||
description = "되돌림 % 확정 시 스윙 저점 매수·고점 매도 (GT ZigZag 인과 버전)"
|
||||
|
||||
def default_extra_params(self) -> dict:
|
||||
return {"reversal_pct": 5.0}
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, params: TechniqueParams) -> list[TechniqueSignal]:
|
||||
reversal_pct = float(params.extra.get("reversal_pct", 5.0))
|
||||
return find_causal_zigzag_signals(df, reversal_pct=reversal_pct)
|
||||
|
||||
|
||||
def find_causal_zigzag_signals(
|
||||
df: pd.DataFrame,
|
||||
reversal_pct: float = 5.0,
|
||||
) -> list[TechniqueSignal]:
|
||||
"""인과 ZigZag 신호를 생성한다.
|
||||
|
||||
Args:
|
||||
df: OHLCV DataFrame.
|
||||
reversal_pct: 피벗 확정 최소 되돌림(%).
|
||||
|
||||
Returns:
|
||||
시간순 신호 리스트.
|
||||
"""
|
||||
if len(df) < 3:
|
||||
return []
|
||||
|
||||
threshold = reversal_pct / 100.0
|
||||
signals: list[TechniqueSignal] = []
|
||||
direction: str | None = None
|
||||
extreme_idx = 0
|
||||
extreme_price = float(df.iloc[0]["close"])
|
||||
|
||||
for i in range(1, len(df)):
|
||||
high = float(df.iloc[i]["high"])
|
||||
low = float(df.iloc[i]["low"])
|
||||
|
||||
if direction is None:
|
||||
if high >= extreme_price * (1 + threshold):
|
||||
direction = "up"
|
||||
extreme_idx = i
|
||||
extreme_price = high
|
||||
elif low <= extreme_price * (1 - threshold):
|
||||
direction = "down"
|
||||
extreme_idx = i
|
||||
extreme_price = low
|
||||
continue
|
||||
|
||||
if direction == "up":
|
||||
if high >= extreme_price:
|
||||
extreme_price = high
|
||||
extreme_idx = i
|
||||
if low <= extreme_price * (1 - threshold):
|
||||
signals.append(
|
||||
_make_signal(df, i, extreme_idx, extreme_price, "sell", reversal_pct)
|
||||
)
|
||||
direction = "down"
|
||||
extreme_idx = i
|
||||
extreme_price = low
|
||||
else:
|
||||
if low <= extreme_price:
|
||||
extreme_price = low
|
||||
extreme_idx = i
|
||||
if high >= extreme_price * (1 + threshold):
|
||||
signals.append(
|
||||
_make_signal(df, i, extreme_idx, extreme_price, "buy", reversal_pct)
|
||||
)
|
||||
direction = "up"
|
||||
extreme_idx = i
|
||||
extreme_price = high
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def _make_signal(
|
||||
df: pd.DataFrame,
|
||||
confirm_idx: int,
|
||||
pivot_idx: int,
|
||||
pivot_price: float,
|
||||
side: str,
|
||||
reversal_pct: float,
|
||||
) -> TechniqueSignal:
|
||||
"""확정 봉 기준 TechniqueSignal을 생성한다."""
|
||||
dt = pd.Timestamp(df.iloc[confirm_idx]["datetime"]).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return TechniqueSignal(
|
||||
side=side,
|
||||
bar_index=confirm_idx,
|
||||
price=round(pivot_price, 2),
|
||||
datetime=dt,
|
||||
pivot_bar_index=pivot_idx,
|
||||
confidence=min(1.0, reversal_pct / 10.0),
|
||||
reason=f"zigzag_{side}_confirmed",
|
||||
)
|
||||
Reference in New Issue
Block a user