"""매매 기법 실행 및 결과 저장.""" 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)