feat(spot): 3단계 운영 파이프라인 — composite_v3 + MTF paper/live
MTF 필터 백테스트, paper/live 체결, 빗썸 Private API 연동 및 운영 스크립트·설계 문서를 추가해 2단계 전략을 실거래 단계에 연결한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
192
src/deepcoin/operations/runner.py
Normal file
192
src/deepcoin/operations/runner.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user