#!/usr/bin/env python3 """vol_live 모니터 — JSON/HTML 갱신 + HTTP 서버 (통합). 기본 (인자 없음): 전체 갱신(--full) 후 서버 기동 python scripts/3_run_vol_monitor.py 갱신만: python scripts/3_run_vol_monitor.py --refresh-only 서버만: python scripts/3_run_vol_monitor.py --serve-only """ from __future__ import annotations import argparse import json import logging import os import sys import threading import time from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse _ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_ROOT / "src")) from bithumb.config import load_settings # noqa: E402 from bithumb.operations.vol_breakout_engine import load_vol_state # noqa: E402 from bithumb.operations.vol_live_monitor import ( # noqa: E402 fetch_live_balance_snapshot, patch_vol_monitor_balance, write_vol_monitor, ) logger = logging.getLogger("vol_monitor") _refresh_lock = threading.Lock() _balance_lock = threading.Lock() _CLIENT_GONE = (BrokenPipeError, ConnectionResetError) def _client_gone(exc: BaseException) -> bool: """브라우저가 응답 전 연결을 끊은 경우.""" return isinstance(exc, _CLIENT_GONE) def refresh_vol_live_monitor(*, write_html: bool = True) -> dict: """state + DB 캔들 기준 전체 JSON/HTML 갱신.""" settings = load_settings() state = load_vol_state(settings.vol_state_json) if settings.ops_mode == "live": try: bal = fetch_live_balance_snapshot(settings) snap = state.setdefault("portfolio_snapshot", {}) snap["cash_krw"] = bal.get("cash_krw", snap.get("cash_krw")) positions = snap.setdefault("positions", {}) for sym, qty in (bal.get("positions") or {}).items(): positions[sym] = qty except Exception as exc: # noqa: BLE001 logger.warning("live balance sync skipped: %s", exc) json_path, html_path = write_vol_monitor(settings, state) logger.debug("monitor written: %s", json_path) if write_html: logger.debug("html: %s", html_path) return {"ok": True, "json": str(json_path), "html": str(html_path)} def refresh_vol_live_balance() -> dict: """거래소 잔고만 JSON summary 패치.""" settings = load_settings() bal = fetch_live_balance_snapshot(settings) return patch_vol_monitor_balance(settings.vol_monitor_json, bal) def fetch_live_balance() -> dict: """서버 /api/balance용.""" settings = load_settings() if settings.ops_mode != "live": state = load_vol_state(settings.vol_state_json) snap = state.get("portfolio_snapshot") or {} return { "ok": True, "cash_krw": snap.get("cash_krw", 0), "positions": snap.get("positions") or {}, "mode": settings.ops_mode, } return fetch_live_balance_snapshot(settings) def _out_dir() -> Path: return load_settings().vol_monitor_html.parent def _api_refresh() -> dict: out_dir = _out_dir() json_path = out_dir / "vol_live_chart.json" with _refresh_lock: if not json_path.is_file(): return refresh_vol_live_monitor(write_html=False) return refresh_vol_live_balance() def _api_balance() -> dict: with _balance_lock: return fetch_live_balance() class MonitorHandler(SimpleHTTPRequestHandler): """vol_live 정적 파일 + /api/chart · /api/balance · /api/refresh.""" _static_dir: str | None = None _access_log: bool = False def __init__(self, *args, **kwargs) -> None: if MonitorHandler._static_dir is None: MonitorHandler._static_dir = str(_out_dir()) super().__init__(*args, directory=MonitorHandler._static_dir, **kwargs) def _chart_json_path(self) -> Path: return Path(self.directory) / "vol_live_chart.json" def _serve_chart_json(self) -> None: path = self._chart_json_path() if not path.is_file(): self.send_error(404, "chart json not found") return body: bytes | None = None for attempt in range(3): try: body = path.read_bytes() json.loads(body.decode("utf-8")) break except (json.JSONDecodeError, OSError): if attempt >= 2: self.send_error(503, "chart json temporarily unavailable") return time.sleep(0.05) if body is None: self.send_error(503, "chart json unavailable") return self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store, must-revalidate") self.send_header("Content-Length", str(len(body))) self.end_headers() try: self.wfile.write(body) except _CLIENT_GONE: logger.debug("client disconnected during chart json") def log_message(self, fmt: str, *args) -> None: """HTTP 접근 로그 — 기본 off (--verbose 시에만 출력).""" if not MonitorHandler._access_log: return logger.info("%s - %s", self.address_string(), fmt % args) def log_error(self, fmt: str, *args) -> None: """5xx 등 서버 오류만 기록 (favicon 404 제외).""" msg = fmt % args if "404" in msg and "File not found" in msg: return logger.warning("%s - %s", self.address_string(), msg) def _send_json(self, payload: dict, *, status: int = 200) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") try: self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store, must-revalidate") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) except _CLIENT_GONE: logger.debug("client disconnected before response sent") def _handle_refresh(self) -> None: try: self._send_json(_api_refresh()) except _CLIENT_GONE: pass except Exception as exc: # noqa: BLE001 if not _client_gone(exc): self._send_json({"ok": False, "error": str(exc)}, status=500) def _handle_balance(self) -> None: try: self._send_json(_api_balance()) except _CLIENT_GONE: pass except Exception as exc: # noqa: BLE001 if not _client_gone(exc): self._send_json({"ok": False, "error": str(exc)}, status=500) def end_headers(self) -> None: if self.path.endswith(".json"): self.send_header("Cache-Control", "no-store, must-revalidate") super().end_headers() def _request_path(self) -> str: return urlparse(self.path).path.rstrip("/") def do_POST(self) -> None: path = self._request_path() if path == "/api/refresh": self._handle_refresh() return self.send_error(404, "not found") def do_GET(self) -> None: path = self._request_path() if path == "/api/refresh": self._handle_refresh() return if path == "/api/balance": self._handle_balance() return if path == "/api/chart": self._serve_chart_json() return super().do_GET() def run_serve(*, access_log: bool = False, quiet: bool = True) -> int: """HTTP 서버 기동 (블로킹).""" from dotenv import load_dotenv MonitorHandler._access_log = access_log load_dotenv(_ROOT / ".env", override=False) port = int(os.environ.get("VOL_MONITOR_PORT", "8766")) out = _out_dir() out.mkdir(parents=True, exist_ok=True) url = f"http://127.0.0.1:{port}/vol_live_monitor.html" if quiet and not access_log: print(f"vol monitor {url} (Ctrl+C 종료)", flush=True) else: logger.info("모니터: %s", url) logger.info("출력 디렉터리: %s", out) try: server = ThreadingHTTPServer(("127.0.0.1", port), MonitorHandler) except OSError as exc: if exc.errno == 48: logger.error( "포트 %s 이미 사용 중 — lsof -iTCP:%s -sTCP:LISTEN 후 종료", port, port, ) else: logger.error("서버 bind 실패: %s", exc) return 1 try: server.serve_forever() except KeyboardInterrupt: logger.info("종료") return 0 def main(argv: list[str] | None = None) -> int: """CLI — 기본: 갱신 + 서버.""" parser = argparse.ArgumentParser( description="Bithumb vol_live 모니터 (갱신 + HTTP 서버)", ) mode = parser.add_mutually_exclusive_group() mode.add_argument( "--refresh-only", action="store_true", help="JSON/HTML 갱신만 (서버 미기동)", ) mode.add_argument( "--serve-only", action="store_true", help="HTTP 서버만 (갱신 생략)", ) parser.add_argument( "--balance-only", action="store_true", help="--refresh-only 와 함께: 잔고 summary만 패치", ) parser.add_argument( "-v", "--verbose", action="store_true", help="HTTP 접근·갱신 상세 로그 출력", ) args = parser.parse_args(argv) serve_mode = not args.refresh_only quiet_serve = serve_mode and not args.verbose log_level = logging.INFO if (args.verbose or args.refresh_only) else logging.WARNING logging.basicConfig( level=log_level, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) if not args.serve_only: if args.balance_only: out = refresh_vol_live_balance() else: out = refresh_vol_live_monitor() if args.verbose or args.refresh_only: logger.info("refresh done: %s", out) if not out.get("ok"): return 1 if args.refresh_only: return 0 return run_serve(access_log=args.verbose, quiet=quiet_serve) if __name__ == "__main__": raise SystemExit(main())