vol_breakout 멀티종목 tick, vol_live HTML 모니터, 마감 봉만 저장하는 캔들 다운로드, 텔레그램 체결 알림, cron/watch 감시 스크립트 및 테스트를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
144 lines
4.5 KiB
Python
144 lines
4.5 KiB
Python
#!/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())
|