"""3단계 운영 러너 — 캔들 동기화·신호·체결.""" from __future__ import annotations import json import logging import subprocess import sys from datetime import datetime from pathlib import Path from typing import Any from deepcoin.config import Settings from deepcoin.ground_truth.pnl import _cluster_signals from deepcoin.operations.executor import create_executor from deepcoin.operations.signal_pipeline import ( filter_signals_for_ops, generate_raw_signals, load_ops_candles, ) from deepcoin.operations.state_store import load_state, reset_daily_trade_count, save_state logger = logging.getLogger(__name__) def _project_root() -> Path: return Path(__file__).resolve().parents[3] def sync_candles_if_enabled(settings: Settings) -> bool: """증분 캔들 수집 스크립트를 실행한다.""" if not settings.ops_sync_candles: return False script = _project_root() / "scripts" / "00_download.py" if not script.exists(): logger.warning("캔들 동기화 스크립트 없음: %s", script) return False logger.info("캔들 증분 동기화 실행...") subprocess.run( [sys.executable, str(script)], cwd=str(_project_root()), check=False, ) return True def _signals_on_bar(signals: list[dict[str, Any]], bar_index: int) -> list[dict[str, Any]]: """특정 bar_index 신호만 반환.""" return [s for s in signals if int(s.get("bar_index", -1)) == bar_index] def _pending_bar_indices( kept: list[dict[str, Any]], last_bar_index: int, latest_bar_index: int, ) -> list[int]: """체결 대상 bar_index 목록 (최초 실행은 최신 봉만).""" if last_bar_index < 0: return [latest_bar_index] if any( int(s.get("bar_index", -1)) == latest_bar_index for s in kept ) else [] return sorted( { int(s.get("bar_index", -1)) for s in kept if int(s.get("bar_index", -1)) > last_bar_index } ) def _cluster_pending(pending: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]: """연속 동일 side 신호를 클러스터로 묶는다.""" normalized = [ { "side": s["side"], "datetime": s["datetime"], "price": s["price"], "bar_index": s.get("bar_index", 0), "signal_type": s.get("signal_type", ""), "marker_id": s.get("marker_id"), } for s in pending ] return _cluster_signals(normalized) class OperationsRunner: """3단계 운영 1회 tick 실행.""" def __init__(self, settings: Settings) -> None: self.settings = settings self.executor = create_executor(settings) self.state = load_state( settings.ops_state_json, initial_cash_krw=settings.gt_initial_cash_krw, ) self.state["portfolio"]["mode"] = settings.ops_mode def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]: """신호 확인 및 체결 1회.""" if sync_candles if sync_candles is not None else self.settings.ops_sync_candles: sync_candles_if_enabled(self.settings) df = load_ops_candles(self.settings) latest_bar = int(len(df) - 1) gen = generate_raw_signals(self.settings, df=df, use_cache=True) # tick: 최신 봉 후보만 MTF 평가 (전기간 MTF는 백테스트 전용) bar_candidates = _signals_on_bar(gen["raw_signals"], latest_bar) filtered = filter_signals_for_ops(self.settings, bar_candidates) kept = filtered["kept"] reset_daily_trade_count(self.state) last_bar = int(self.state.get("last_processed_bar_index", -1)) target_bars = _pending_bar_indices(kept, last_bar, latest_bar) executions: list[dict[str, Any]] = [] max_daily = self.settings.ops_daily_max_trades for bar_idx in target_bars: bar_signals = _signals_on_bar(kept, bar_idx) clusters = _cluster_pending(bar_signals) for side, cluster in clusters: if self.state["trades_today_count"] >= max_daily: logger.warning("일일 체결 상한(%d) 도달 — 중단", max_daily) break cluster_size = len(cluster) for sig in cluster: full_sig = next( (k for k in kept if k["datetime"] == sig["datetime"]), sig, ) trade = self.executor.execute_signal( full_sig, self.state["portfolio"], cluster_size=cluster_size, ) record = { "datetime": full_sig["datetime"], "side": full_sig["side"], "signal_type": full_sig.get("signal_type"), "price": full_sig["price"], "bar_index": bar_idx, "trade": trade.to_dict(), "mtf_filter": full_sig.get("mtf_filter"), } executions.append(record) if trade.executed: self.state["trades_today_count"] += 1 if bar_idx > last_bar: last_bar = bar_idx self.state["last_processed_bar_index"] = bar_idx if bar_signals: self.state["last_processed_datetime"] = bar_signals[-1]["datetime"] pipeline = { "technique_id": gen["technique_id"], "raw_count": len(bar_candidates), "kept_count": len(kept), "rejected_count": len(filtered["rejected"]), "latest_bar_index": latest_bar, } now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.state["last_run_at"] = now if executions: self.state.setdefault("trade_history", []).extend(executions) report = { "generated_at": now, "mode": self.settings.ops_mode, "technique_id": pipeline["technique_id"], "raw_signals": pipeline["raw_count"], "filtered_signals": pipeline["kept_count"], "pending_bars": target_bars, "latest_bar_candidates": pipeline["raw_count"], "executions": executions, "portfolio": self.state["portfolio"], "trades_today_count": self.state["trades_today_count"], "last_processed_bar_index": self.state["last_processed_bar_index"], } save_state(self.settings.ops_state_json, self.state) self._save_report(report) return report def _save_report(self, report: dict[str, Any]) -> None: """최신 운영 리포트 저장.""" path = self.settings.ops_report_json path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as fp: json.dump(report, fp, ensure_ascii=False, indent=2)