#!/usr/bin/env python3 """3단계: paper/live 운영 1회 tick (신호 확인·체결).""" from __future__ import annotations import argparse import atexit import logging import os import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) from bithumb.config import load_settings from bithumb.operations.runner import OperationsRunner def _write_loop_pid(path: Path) -> None: """loop PID 파일 기록 (watch 재시작용).""" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(f"{os.getpid()}\n", encoding="utf-8") def _remove_loop_pid(path: Path) -> None: """loop 종료 시 PID 파일 삭제.""" try: path.unlink(missing_ok=True) except OSError: pass def _configure_logging(verbose: bool) -> None: level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( level=level, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) def main() -> int: """CLI 진입점.""" parser = argparse.ArgumentParser(description="3단계: Bithumb 운영 tick") parser.add_argument( "--mode", choices=("paper", "live"), default=None, help="OPS_MODE 덮어쓰기 (기본 .env)", ) parser.add_argument( "--no-sync", action="store_true", help="캔들 증분 동기화 생략", ) parser.add_argument( "--loop", type=int, default=0, metavar="SEC", help="N초마다 반복 실행 (0=1회)", ) parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args() _configure_logging(args.verbose) if args.mode: import os os.environ["OPS_MODE"] = args.mode settings = load_settings() if settings.ops_mode == "live": if not settings.bithumb_access_key or not settings.bithumb_secret_key: print("live 모드에는 BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 가 필요합니다.", file=sys.stderr) return 1 print("경고: live 모드 — 실제 주문이 발생할 수 있습니다.") runner = OperationsRunner(settings) sync = not args.no_sync if args.loop > 0 and settings.ops_loop_pid_file is not None: _write_loop_pid(settings.ops_loop_pid_file) atexit.register(_remove_loop_pid, settings.ops_loop_pid_file) while True: loop_started = time.monotonic() try: report = runner.tick(sync_candles=sync) except Exception as exc: logging.exception("운영 tick 예외 (복구 후 계속)") if runner.telegram.is_active: runner.telegram.notify_ops_error( mode=settings.ops_mode, symbol=settings.symbol, technique_id=settings.ops_technique_id, stage="main_loop", error=str(exc), ) if args.loop <= 0: break elapsed = time.monotonic() - loop_started sleep_sec = max(0.0, float(args.loop) - elapsed) time.sleep(sleep_sec) continue port = report.get("portfolio") or {} print("\n=== 3단계 운영 tick ===") if report.get("error"): print(f"오류: [{report.get('error_stage')}] {report.get('error_message')}") print(f"모드: {report.get('mode', settings.ops_mode)}") print( f"최신 봉 후보: {report.get('latest_bar_candidates', 0)} · " f"필터 통과: {report.get('filtered_signals', 0)} · " f"처리 bar: {report.get('pending_bars', [])}" ) print(f"이번 체결: {len(report.get('executions', []))}건") print( f"포트폴리오: 현금 {port.get('cash_krw', 0):,.0f}원 · " f"코인 {port.get('coin_qty', 0):.8f} {settings.symbol}" ) print(f"리포트: {settings.ops_report_json}") if report.get("ledger_pending_count") is not None: print( f"ledger pending: {report.get('ledger_pending_count')} · " f"backlog dropped: {report.get('backlog_dropped_count', 0)} · " f"tick: {report.get('last_tick_duration_sec')}s" ) if args.loop <= 0: break elapsed = time.monotonic() - loop_started sleep_sec = max(0.0, float(args.loop) - elapsed) time.sleep(sleep_sec) return 0 if __name__ == "__main__": raise SystemExit(main())