Files
Bithumb/scripts/3_run_vol_monitor.py
dsyoon 72de8d534e feat(vol_breakout): 15m 현물 롱 라이브·모니터·cron 운영 추가
vol_breakout 멀티종목 tick, vol_live HTML 모니터, 마감 봉만 저장하는 캔들 다운로드,
텔레그램 체결 알림, cron/watch 감시 스크립트 및 테스트를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 08:31:14 +09:00

316 lines
10 KiB
Python
Executable File

#!/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())