feat(vol_breakout): 15m 현물 롱 라이브·모니터·cron 운영 추가
vol_breakout 멀티종목 tick, vol_live HTML 모니터, 마감 봉만 저장하는 캔들 다운로드, 텔레그램 체결 알림, cron/watch 감시 스크립트 및 테스트를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
315
scripts/3_run_vol_monitor.py
Executable file
315
scripts/3_run_vol_monitor.py
Executable file
@@ -0,0 +1,315 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user