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