Files
Bithumb/src/deepcoin/operations/runner.py
xavis 58802bdc5f feat(spot): 3단계 운영 파이프라인 — composite_v3 + MTF paper/live
MTF 필터 백테스트, paper/live 체결, 빗썸 Private API 연동 및 운영 스크립트·설계 문서를 추가해 2단계 전략을 실거래 단계에 연결한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:27:34 +09:00

193 lines
6.9 KiB
Python

"""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)