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:
dsyoon
2026-06-29 08:31:14 +09:00
parent 8413bbd536
commit 72de8d534e
58 changed files with 6008 additions and 1286 deletions

View File

@@ -16,7 +16,7 @@ if str(SRC) not in sys.path:
from dataclasses import replace
from bithumb.config import load_settings
from bithumb.config import load_settings, resolve_coin_name
from bithumb.data.candle_store import CandleStore
from bithumb.data.downloader import CandleDownloader
from bithumb.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label
@@ -32,6 +32,58 @@ def _configure_logging(verbose: bool) -> None:
)
def _parse_symbols(raw: str | None, defaults: list[str]) -> list[str]:
"""CLI --symbols 또는 기본 DOWNLOAD_SYMBOLS 목록."""
if raw:
return [part.strip().upper() for part in raw.split(",") if part.strip()]
return list(defaults)
def _log_interval_estimates(
*,
store: CandleStore,
symbol: str,
intervals: list[int],
days: int,
full: bool,
batch_size: int,
sleep_sec: float,
log: logging.Logger,
) -> None:
"""인터벌별 예상 API 요청 수를 로깅한다."""
for interval in intervals:
if full:
est = estimate_download_requests(interval, days, batch_size=batch_size)
log.info(
"예상 API 요청: %s %s%s회 (풀 다운, sleep %.2fs)",
symbol,
interval_label(interval),
est,
sleep_sec,
)
continue
_, _, db_max = store.get_range(symbol, interval)
if db_max is None:
est = estimate_download_requests(interval, days, batch_size=batch_size)
log.info(
"예상 API 요청: %s %s%s회 (DB 없음 → 풀 다운)",
symbol,
interval_label(interval),
est,
)
else:
gap_days = max(1, (datetime.now() - db_max).days + 1)
est = estimate_download_requests(interval, gap_days, batch_size=batch_size)
log.info(
"예상 API 요청: %s %s%s회 (증분, DB=%s, 갭≈%s일)",
symbol,
interval_label(interval),
est,
db_max.strftime("%Y-%m-%d %H:%M:%S"),
gap_days,
)
def main() -> int:
"""CLI 진입점."""
parser = argparse.ArgumentParser(
@@ -54,6 +106,12 @@ def main() -> int:
default=None,
help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체",
)
parser.add_argument(
"--symbols",
type=str,
default=None,
help="쉼표 구분 심볼 (기본: .env DOWNLOAD_SYMBOLS 또는 SYMBOL)",
)
parser.add_argument(
"--include-1min",
action="store_true",
@@ -63,8 +121,10 @@ def main() -> int:
args = parser.parse_args()
_configure_logging(args.verbose)
settings = load_settings()
base_settings = load_settings()
symbols = _parse_symbols(args.symbols, base_settings.download_symbols)
settings = base_settings
if args.intervals:
settings = replace(
settings,
@@ -83,76 +143,59 @@ def main() -> int:
log = logging.getLogger(__name__)
log.info(
"대상=%s DB=%s mode=%s days=%s intervals=%s",
settings.market,
",".join(f"KRW-{s}" for s in symbols),
settings.db_path,
mode_label,
days,
settings.download_intervals,
)
for interval in settings.download_intervals:
est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
log.info(
"예상 API 요청: %s%s회 (sleep %.2fs)",
interval_label(interval),
est,
settings.request_sleep_sec,
)
store = CandleStore(settings.db_path)
exit_code = 0
try:
for interval in settings.download_intervals:
if args.full:
est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
log.info(
"예상 API 요청: %s%s회 (풀 다운, sleep %.2fs)",
interval_label(interval),
est,
settings.request_sleep_sec,
)
else:
_, _, db_max = store.get_range(settings.symbol, interval)
if db_max is None:
est = estimate_download_requests(interval, days, batch_size=settings.candle_count)
log.info(
"예상 API 요청: %s%s회 (DB 없음 → 풀 다운)",
interval_label(interval),
est,
)
else:
gap_days = max(1, (datetime.now() - db_max).days + 1)
est = estimate_download_requests(interval, gap_days, batch_size=settings.candle_count)
log.info(
"예상 API 요청: %s%s회 (증분, DB=%s, 갭≈%s일)",
interval_label(interval),
est,
db_max.strftime("%Y-%m-%d %H:%M:%S"),
gap_days,
)
downloader = CandleDownloader(settings)
results = downloader.download_all(store, days=days, full=args.full)
print(f"\n=== 수집 완료 ({mode_label}) ===")
for result in results:
count, min_dt, max_dt = store.get_range(settings.symbol, result.interval_min)
min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-"
max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
if result.mode == "uptodate":
flag = "UPTODATE"
elif result.reached_target:
flag = "OK"
else:
flag = "PARTIAL"
label = interval_label(result.interval_min)
print(
f"[{flag}] {label} ({result.interval_min}) mode={result.mode} | "
f"requests={result.requests} upsert={result.saved_rows} "
f"db_rows={count} range={min_s} ~ {max_s}"
for symbol in symbols:
symbol_settings = replace(
settings,
symbol=symbol,
coin_name=resolve_coin_name(symbol),
)
print(f"\n=== {symbol} ({symbol_settings.coin_name}) ===")
_log_interval_estimates(
store=store,
symbol=symbol,
intervals=symbol_settings.download_intervals,
days=days,
full=args.full,
batch_size=symbol_settings.candle_count,
sleep_sec=symbol_settings.request_sleep_sec,
log=log,
)
downloader = CandleDownloader(symbol_settings)
results = downloader.download_all(store, days=days, full=args.full)
print(f"\n--- {symbol} 수집 완료 ({mode_label}) ---")
for result in results:
count, min_dt, max_dt = store.get_range(symbol, result.interval_min)
min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-"
max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
if result.mode == "uptodate":
flag = "UPTODATE"
elif result.reached_target:
flag = "OK"
else:
flag = "PARTIAL"
exit_code = 1
label = interval_label(result.interval_min)
print(
f"[{flag}] {symbol} {label} ({result.interval_min}) mode={result.mode} | "
f"requests={result.requests} upsert={result.saved_rows} "
f"db_rows={count} range={min_s} ~ {max_s}"
)
finally:
store.close()
return 0
return exit_code
if __name__ == "__main__":

15
scripts/00_run_download_cron.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Bithumb 캔들 증분 수집 (cron 1분, DOWNLOAD_SYMBOLS 전체)
set -euo pipefail
# shellcheck source=scripts/_cron_env.sh
source "$(dirname "$0")/_cron_env.sh"
ensure_cron_log_dir "data/common"
LOCKDIR="data/common/download.lock.d"
# 3종목×11 TF 증분 — 20분 초과 시 hung 으로 간주
if ! acquire_cron_lock "$LOCKDIR" "scripts/00_download.py" 1200; then
exit 0
fi
PYTHON="$(resolve_bithumb_python)" || exit 1
"$PYTHON" scripts/00_download.py "$@"

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""운영 신호·체결·거래소 주문 최종 점검 (놓침/중복)."""
from __future__ import annotations
import json
import sys
from collections import Counter
from datetime import datetime, timedelta
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.api.bithumb_private import BithumbPrivateClient
from bithumb.config import load_settings
from bithumb.operations.exchange_reconcile import (
_known_order_uuids,
_match_orders_to_signals,
_unsettled_signals_for_reconcile,
reconcile_exchange_fills,
)
from bithumb.operations.runner import (
_history_index,
_is_settled,
_is_signal_api_executable,
_ledger_pending_signals,
_settle_expired_backlog,
)
from bithumb.operations.signal_pipeline import (
filter_signals_for_ops,
generate_raw_signals,
load_ops_candles,
)
from bithumb.operations.state_store import load_state
def _parse_dt(value: str) -> datetime:
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
def main() -> int:
settings = load_settings()
state = load_state(
settings.ops_state_json,
initial_cash_krw=settings.gt_initial_cash_krw,
)
df = load_ops_candles(settings)
latest_bar = len(df) - 1
gen = generate_raw_signals(
settings,
df=df,
use_cache=True,
force_tail_refresh=(
settings.ops_mode == "live" and settings.ops_live_force_tail_refresh
),
)
all_kept = filter_signals_for_ops(settings, gen["raw_signals"])["kept"]
trade_history = list(state.get("trade_history") or [])
live_since = (
str(state["live_initialized_at"])
if settings.ops_mode == "live" and state.get("live_initialized_at")
else None
)
max_age = settings.ops_ledger_execute_max_age_minutes
now = datetime.now()
# tick과 동일 순서 시뮬레이션 (state 파일은 쓰지 않음)
sim_history = list(trade_history)
exchange_reconciled: list[dict] = []
stale_settled: list[dict] = []
if settings.ops_mode == "live" and settings.ops_exchange_reconcile:
client = BithumbPrivateClient(
access_key=settings.bithumb_access_key,
secret_key=settings.bithumb_secret_key,
base_url=settings.api_url,
sleep_sec=settings.request_sleep_sec,
retries=settings.request_retries,
)
exchange_reconciled = reconcile_exchange_fills(
all_kept,
sim_history,
client=client,
market=settings.market,
lookback_hours=settings.ops_exchange_order_lookback_hours,
match_window_min=settings.ops_exchange_match_window_min,
lookback_days=settings.ops_ledger_lookback_days,
)
sim_history.extend(exchange_reconciled)
stale_settled = _settle_expired_backlog(
all_kept,
sim_history,
max_age_minutes=max_age,
live_since=live_since,
lookback_days=settings.ops_ledger_lookback_days,
)
sim_history.extend(stale_settled)
ledger_pending = _ledger_pending_signals(
all_kept,
sim_history,
latest_bar_index=latest_bar,
lookback_days=settings.ops_ledger_lookback_days,
)
executable_pending: list[dict] = []
expired_pending: list[dict] = []
for sig in ledger_pending:
ok, reason = _is_signal_api_executable(
sig,
max_age_minutes=max_age,
live_since=live_since,
now=now,
)
if ok:
executable_pending.append(sig)
else:
expired_pending.append({**sig, "skip_reason": reason})
# 중복 uuid
uuids = []
for rec in sim_history:
trade = rec.get("trade") or {}
if trade.get("executed"):
resp = trade.get("api_response")
if isinstance(resp, dict) and resp.get("uuid"):
uuids.append(str(resp["uuid"]))
uuid_counts = Counter(uuids)
dup_uuids = {u: c for u, c in uuid_counts.items() if c > 1}
# history 내 동일 신호 executed 중복
idx = _history_index(sim_history)
executed_keys = [
key
for key, rec in idx.items()
if (rec.get("trade") or {}).get("executed")
]
raw_executed_count = sum(
1
for rec in sim_history
if (rec.get("trade") or {}).get("executed")
)
# 거래소 주문 vs 미정산 (reconcile 후에도 남는 orphan)
orphan_orders = 0
unmatched_unsettled = len(
_unsettled_signals_for_reconcile(
all_kept,
sim_history,
lookback_days=settings.ops_ledger_lookback_days,
)
)
if settings.ops_mode == "live":
client = BithumbPrivateClient(
access_key=settings.bithumb_access_key,
secret_key=settings.bithumb_secret_key,
base_url=settings.api_url,
sleep_sec=settings.request_sleep_sec,
retries=settings.request_retries,
)
since = now - timedelta(hours=settings.ops_exchange_order_lookback_hours)
orders = client.fetch_filled_orders_since(settings.market, since)
known = _known_order_uuids(sim_history)
unsettled = _unsettled_signals_for_reconcile(
all_kept,
sim_history,
lookback_days=settings.ops_ledger_lookback_days,
)
matches = _match_orders_to_signals(
orders,
unsettled,
match_window_min=settings.ops_exchange_match_window_min,
known_uuids=set(known),
)
orphan_orders = len(orders) - len(matches) - len(
[u for u in known if u in {str(o.get("uuid")) for o in orders}]
)
print("=== 최종 점검 (놓침/중복) ===")
print(f"mode: {settings.ops_mode}")
print(f"now: {now.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"kept_signals: {len(all_kept)} | latest_bar: {latest_bar}")
print(f"trade_history (raw): {len(trade_history)}")
print()
print("[tick 시뮬레이션 — exchange → stale 순]")
print(f" exchange_reconciled (would add): {len(exchange_reconciled)}")
print(f" stale_settled (would add): {len(stale_settled)}")
print(f" ledger_pending (after sim): {len(ledger_pending)}")
print(f" executable_pending (API 체결 대상): {len(executable_pending)}")
print(f" expired_still_pending (버그 의심): {len(expired_pending)}")
print()
print("[중복]")
print(f" executed records (raw): {raw_executed_count}")
print(f" unique executed keys: {len(executed_keys)}")
print(f" duplicate uuid in history: {len(dup_uuids)}")
if dup_uuids:
for u, c in list(dup_uuids.items())[:5]:
print(f" uuid={u} count={c}")
print()
print("[놓침 위험]")
print(f" unsettled after sim: {unmatched_unsettled}")
print(f" orphan exchange orders (approx): {max(orphan_orders, 0)}")
if executable_pending:
print(" executable_pending 목록:")
for sig in executable_pending[:15]:
print(f" {sig['datetime']} {sig['side']}")
if len(executable_pending) > 15:
print(f" ... 외 {len(executable_pending) - 15}")
if expired_pending:
print(" expired_still_pending (stale 미적용 의심):")
for sig in expired_pending[:10]:
print(f" {sig['datetime']} {sig['side']}{sig.get('skip_reason')}")
ok = (
len(dup_uuids) == 0
and len(expired_pending) == 0
and len(executable_pending) <= settings.ops_max_backlog_per_tick
)
print()
print("RESULT:", "PASS" if ok else "REVIEW_NEEDED")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# vol_live 모니터 HTTP 서버 — 8766 미수신 시 기동 (cron/수동)
set -euo pipefail
# shellcheck source=scripts/_cron_env.sh
source "$(dirname "$0")/_cron_env.sh"
PORT="${VOL_MONITOR_PORT:-8766}"
export VOL_MONITOR_PORT="$PORT"
if curl -sf -o /dev/null --connect-timeout 2 "http://127.0.0.1:${PORT}/vol_live_monitor.html"; then
exit 0
fi
exec bash "${CRON_PROJECT_ROOT}/scripts/3_run_vol_monitor_serve.sh"

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""ledger backlog 신호 조회·일괄 처리 (dry-run / execute)."""
from __future__ import annotations
import argparse
import sys
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.reconcile import inspect_ops_backlog
from bithumb.operations.runner import OperationsRunner
def main() -> int:
"""CLI 진입점."""
parser = argparse.ArgumentParser(description="운영 backlog 신호 reconcile")
parser.add_argument(
"--dry-run",
action="store_true",
help="pending 목록만 출력 (기본)",
)
parser.add_argument(
"--execute",
action="store_true",
help="OperationsRunner tick 1회로 backlog 처리",
)
parser.add_argument(
"--no-sync",
action="store_true",
help="execute 시 캔들 sync 생략",
)
args = parser.parse_args()
settings = load_settings()
info = inspect_ops_backlog(settings)
merged = info["merged_pending"]
summary = info["summary"]
print("=== backlog inspect ===")
print(f"signal_refresh: {info.get('signal_refresh')}")
print(f"force_tail_refresh: {info.get('force_tail_refresh')}")
print(f"ledger_pending: {len(info['ledger_pending'])}")
print(f"catchup_pending: {len(info['catchup_pending'])}")
print(f"merged_pending: {len(merged)}")
print(f"backlog_signal_count: {summary['backlog_signal_count']}")
print(f"backlog_oldest: {summary['backlog_oldest_datetime']}")
print(f"backlog_dropped (per tick limit): {info['backlog_dropped']}")
for sig in merged[:50]:
print(f" {sig['datetime']} {sig['side']} bar={sig.get('bar_index')}")
if len(merged) > 50:
print(f" ... 외 {len(merged) - 50}")
if args.execute:
from bithumb.operations.runner import OperationsRunner as OpsRunner
if settings.ops_mode == "live":
print("\n경고: live execute — 실제 주문이 발생할 수 있습니다.")
runner = OpsRunner(settings)
report = runner.tick(sync_candles=not args.no_sync)
print(f"\nexecute 완료: 체결 {len(report.get('executions', []))}")
print(f"ledger_pending_count: {report.get('ledger_pending_count')}")
print(f"backlog_dropped_count: {report.get('backlog_dropped_count')}")
return 0
if not args.dry_run and not args.execute:
print("\n(--dry-run 기본, --execute 로 처리)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""vol_live 모니터 JSON 갱신 — 3_run_vol_monitor.py --refresh-only 래퍼."""
from __future__ import annotations
import runpy
import sys
from pathlib import Path
if __name__ == "__main__":
target = Path(__file__).resolve().parent / "3_run_vol_monitor.py"
sys.argv = [str(target), "--refresh-only", *sys.argv[1:]]
runpy.run_path(str(target), run_name="__main__")

View File

@@ -63,7 +63,7 @@ def _write_index_html(
<body>
<h1>Bithumb Live — 운영 백테스트</h1>
<p class="meta">
{report.get("symbol", "BTC")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})<br>
{report.get("symbol", "TRX")} · {report.get("technique_name", "")} ({report.get("technique_id", "")})<br>
sim 기간: 최근 {report.get("sim_lookback_days", 1095)}일 ·
슬리피지 {report.get("slippage_rate", 0) * 100:.2f}% ·
일 체결 상한 {report.get("daily_max_trades", "-")} ·

View File

@@ -4,7 +4,9 @@
from __future__ import annotations
import argparse
import atexit
import logging
import os
import sys
import time
from pathlib import Path
@@ -18,6 +20,20 @@ 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(
@@ -66,7 +82,12 @@ def main() -> int:
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:
@@ -81,7 +102,9 @@ def main() -> int:
)
if args.loop <= 0:
break
time.sleep(args.loop)
elapsed = time.monotonic() - loop_started
sleep_sec = max(0.0, float(args.loop) - elapsed)
time.sleep(sleep_sec)
continue
port = report.get("portfolio") or {}
@@ -100,10 +123,18 @@ def main() -> int:
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
time.sleep(args.loop)
elapsed = time.monotonic() - loop_started
sleep_sec = max(0.0, float(args.loop) - elapsed)
time.sleep(sleep_sec)
return 0

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""vol_breakout 현물 롱 — TRX/NEAR/WLD 멀티 tick (Binance 15m ATR 이식)."""
from __future__ import annotations
import argparse
import logging
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.vol_breakout_runner import VolBreakoutRunner
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="Bithumb vol_breakout 현물 롱 tick")
parser.add_argument("--mode", choices=("paper", "live"), default=None)
parser.add_argument("--loop", type=int, default=0, metavar="SEC")
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 not settings.ops_symbols:
print("OPS_SYMBOLS 또는 DOWNLOAD_SYMBOLS(BTC 제외)가 필요합니다.", file=sys.stderr)
return 1
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 — 실제 주문 가능")
print(
f"vol_breakout {settings.ops_mode} | symbols={settings.ops_symbols} | "
f"lookback={settings.vol_lookback} atr={settings.vol_atr_mult} "
f"buy_split={settings.vol_buy_split or settings.vol_wallet_pct} "
f"exit={settings.vol_exit_enabled}"
)
def _once() -> dict:
runner = VolBreakoutRunner(settings)
report = runner.tick()
for row in report.get("results") or []:
print(
f" {row.get('symbol')}: fills={row.get('fills')} "
f"note={row.get('note')}"
)
return report
if args.loop <= 0:
_once()
return 0
while True:
try:
_once()
except KeyboardInterrupt:
print("\n종료")
return 0
except Exception:
logging.exception("vol loop tick failed")
time.sleep(args.loop)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# vol_breakout 현물 롱 tick (cron 1분)
set -euo pipefail
# shellcheck source=scripts/_cron_env.sh
source "$(dirname "$0")/_cron_env.sh"
ensure_cron_log_dir "data/spot/operations"
LOCKDIR="data/spot/operations/vol.tick.lock.d"
if ! acquire_cron_lock "$LOCKDIR" "scripts/3_run_vol_breakout.py" 600; then
exit 0
fi
PYTHON="$(resolve_bithumb_python)" || exit 1
"$PYTHON" scripts/3_run_vol_breakout.py "$@"

315
scripts/3_run_vol_monitor.py Executable file
View 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())

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# vol_live 모니터 JSON/HTML 갱신 (cron 5분 — tick 실패 시 백업용)
set -euo pipefail
# shellcheck source=scripts/_cron_env.sh
source "$(dirname "$0")/_cron_env.sh"
ensure_cron_log_dir "docs/spot/3_operations"
LOCKDIR="data/spot/operations/vol.monitor.lock.d"
if ! acquire_cron_lock "$LOCKDIR" "scripts/3_run_vol_monitor.py" 300; then
exit 0
fi
PYTHON="$(resolve_bithumb_python)" || exit 1
"$PYTHON" scripts/3_run_vol_monitor.py --refresh-only "$@"

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# vol_live 모니터 HTTP 서버 (포트 기본 8766 — Binance 8765와 분리)
set -euo pipefail
# shellcheck source=scripts/_cron_env.sh
source "$(dirname "$0")/_cron_env.sh"
PIDFILE="${CRON_PROJECT_ROOT}/data/spot/operations/vol_monitor.pid"
PORT="${VOL_MONITOR_PORT:-8766}"
export VOL_MONITOR_PORT="$PORT"
if [ "${1:-}" = "--stop" ]; then
if [ -f "$PIDFILE" ]; then
pid="$(cat "$PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
echo "stopped pid $pid"
fi
rm -f "$PIDFILE"
else
echo "pid file 없음"
fi
exit 0
fi
if [ -f "$PIDFILE" ]; then
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
if lsof -iTCP:"$PORT" -sTCP:LISTEN -p "$old_pid" >/dev/null 2>&1; then
echo "이미 실행 중: pid $old_pid → http://127.0.0.1:${PORT}/vol_live_monitor.html"
exit 0
fi
kill "$old_pid" 2>/dev/null || true
fi
rm -f "$PIDFILE"
fi
ensure_cron_log_dir "data/spot/operations"
LOG="${CRON_PROJECT_ROOT}/data/spot/operations/vol_monitor_serve.log"
PYTHON="$(resolve_bithumb_python)" || exit 1
nohup "$PYTHON" "${CRON_PROJECT_ROOT}/scripts/3_run_vol_monitor.py" --serve-only >> "$LOG" 2>&1 &
pid=$!
disown "$pid" 2>/dev/null || true
echo "$pid" > "$PIDFILE"
sleep 2
if curl -sf -o /dev/null "http://127.0.0.1:${PORT}/vol_live_monitor.html"; then
echo "모니터 시작: http://127.0.0.1:${PORT}/vol_live_monitor.html"
echo "로그: $LOG"
echo "종료: bash scripts/3_run_vol_monitor_serve.sh --stop"
else
echo "서버 기동 실패 — 로그 확인: $LOG" >&2
tail -20 "$LOG" 2>/dev/null || true
exit 1
fi

10
scripts/3_run_watch_cron.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# read-only 감시 (cron 5분) — watch 프로세스 중복 방지
set -euo pipefail
# shellcheck source=scripts/_cron_env.sh
source "$(dirname "$0")/_cron_env.sh"
ensure_cron_log_dir "data/spot/operations"
PYTHON="$(resolve_bithumb_python)" || exit 1
"$PYTHON" scripts/3_watch_ops.py "$@"

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""vol_live 모니터 HTTP 서버 — 3_run_vol_monitor.py --serve-only 래퍼."""
from __future__ import annotations
import runpy
import sys
from pathlib import Path
if __name__ == "__main__":
target = Path(__file__).resolve().parent / "3_run_vol_monitor.py"
sys.argv = [str(target), "--serve-only", *sys.argv[1:]]
runpy.run_path(str(target), run_name="__main__")

103
scripts/3_watch_ops.py Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""read-only 감시 + 불일치 시 조치 tick / loop 재시작."""
from __future__ import annotations
import argparse
import logging
import sys
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.ops_lock import ops_tick_lock
from bithumb.operations.watch_ops import (
inspect_ops_watch,
inspect_vol_watch,
is_vol_breakout_ops,
remediate_ops_watch,
remediate_vol_watch,
)
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="운영 read-only 감시 + 조치")
parser.add_argument(
"--dry-run",
action="store_true",
help="점검·알림만 (tick/재시작 없음)",
)
parser.add_argument(
"--inspect-only",
action="store_true",
help="stdout 출력만 (텔레그램·조치 없음)",
)
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
_configure_logging(args.verbose)
settings = load_settings()
watch_lock = settings.ops_tick_lock_path
if watch_lock is not None:
watch_lock = watch_lock.parent / "ops.watch.lock"
if watch_lock is not None:
with ops_tick_lock(watch_lock, blocking=False) as acquired:
if not acquired:
print("watch already running — skip")
return 0
return _run_watch(settings, args)
return _run_watch(settings, args)
def _run_watch(settings, args) -> int:
vol_mode = is_vol_breakout_ops(settings)
report = inspect_vol_watch(settings) if vol_mode else inspect_ops_watch(settings)
print("=== ops watch ===")
if vol_mode:
print("mode: vol_breakout")
print(f"checked_at: {report.checked_at}")
print(f"ledger_pending: {report.ledger_pending}")
print(f"executable_pending: {report.executable_pending}")
print(f"tick_age_sec: {report.tick_age_sec}")
print(f"loop_running: {report.loop_running}")
for issue in report.issues:
print(f" [{issue.severity}] {issue.kind}: {issue.message}")
if args.inspect_only:
return 1 if report.issues else 0
if not report.issues:
print("OK — 조치 없음")
return 0
result = (
remediate_vol_watch(settings, report, dry_run=args.dry_run)
if vol_mode
else remediate_ops_watch(settings, report, dry_run=args.dry_run)
)
print("\n=== remediation ===")
for action in result.actions:
print(f" action: {action}")
for msg in result.messages:
print(f" {msg}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

67
scripts/_cron_env.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# cron 래퍼 공통 — 프로젝트 루트, PYTHONPATH, 인터프리터 탐색.
# shellcheck disable=SC2034
set -euo pipefail
_CRON_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CRON_PROJECT_ROOT="$(cd "${_CRON_SCRIPT_DIR}/.." && pwd)"
cd "${CRON_PROJECT_ROOT}"
export PYTHONPATH=src
# 로그 디렉터리 (cron 리다이렉트 전에 mkdir -p 가능)
ensure_cron_log_dir() {
local dir="$1"
mkdir -p "$dir"
}
# python-dotenv + pandas 등 프로젝트 의존성이 있는 python3
resolve_bithumb_python() {
local candidate=""
for candidate in \
"${BITHUMB_PYTHON:-}" \
"${HOME}/opt/anaconda3/envs/coin/bin/python3" \
"${HOME}/opt/anaconda3/envs/ncue/bin/python3" \
"${HOME}/miniconda3/envs/xavis/bin/python3" \
"$(command -v python3 2>/dev/null || true)"; do
if [ -n "$candidate" ] && [ -x "$candidate" ] \
&& "$candidate" -c "import dotenv" 2>/dev/null; then
echo "$candidate"
return 0
fi
done
echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] python-dotenv 가능한 python3를 찾지 못함 (BITHUMB_PYTHON 설정)" >&2
return 1
}
# mkdir 기반 잠금 — stale/hung 프로세스 정리 후 획득
# 사용: acquire_cron_lock LOCKDIR pgrep_pattern MAX_AGE_SEC
acquire_cron_lock() {
local lockdir="$1"
local pgrep_pattern="$2"
local max_age_sec="${3:-900}"
local now_ts pid elapsed lock_mtime
now_ts="$(date +%s)"
if [ -d "$lockdir" ]; then
lock_mtime="$(stat -f %m "$lockdir" 2>/dev/null || stat -c %Y "$lockdir" 2>/dev/null || echo 0)"
elapsed=$((now_ts - lock_mtime))
if pgrep -f "$pgrep_pattern" >/dev/null 2>&1; then
if [ "$elapsed" -gt "$max_age_sec" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] hung ${pgrep_pattern} (${elapsed}s) — 종료 후 lock 정리" >&2
pkill -f "$pgrep_pattern" 2>/dev/null || true
sleep 1
fi
fi
if ! pgrep -f "$pgrep_pattern" >/dev/null 2>&1; then
echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] stale lock 정리: ${lockdir}" >&2
rmdir "$lockdir" 2>/dev/null || true
fi
fi
if ! mkdir "$lockdir" 2>/dev/null; then
echo "$(date '+%Y-%m-%d %H:%M:%S') [SKIP] ${pgrep_pattern} 실행 중 (lock ${lockdir})" >&2
return 1
fi
trap 'rmdir "'"$lockdir"'" 2>/dev/null || true' EXIT INT TERM
return 0
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.bithumb.vol-monitor</string>
<key>ProgramArguments</key>
<array>
<string>__PYTHON__</string>
<string>__ROOT__/scripts/3_run_vol_monitor.py</string>
</array>
<key>WorkingDirectory</key>
<string>__ROOT__</string>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONPATH</key>
<string>src</string>
<key>VOL_MONITOR_PORT</key>
<string>8766</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>__LOG__</string>
<key>StandardErrorPath</key>
<string>__LOG__</string>
</dict>
</plist>

View File

@@ -0,0 +1,17 @@
# Bithumb vol_breakout 운영 cron (install_crontab.sh 로 등록)
# 프로젝트: /Users/dsyoon/workspace/bithumb
# Python: BITHUMB_PYTHON 또는 coin/ncue conda (scripts/_cron_env.sh)
# 캔들 증분 (TRX,NEAR,WLD × DOWNLOAD_INTERVALS) — 매 1분
* * * * * /Users/dsyoon/workspace/bithumb/scripts/00_run_download_cron.sh >> /Users/dsyoon/workspace/bithumb/data/common/download_cron.log 2>&1
# vol_breakout 15m flip tick — 매 1분
* * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_vol_breakout_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_breakout_cron.log 2>&1
# vol_live 모니터 JSON/HTML 백업 갱신 — 5분마다
*/5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_vol_monitor_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_monitor_cron.log 2>&1
# 모니터 HTTP 서버(8766) — 터미널: python scripts/3_run_vol_monitor.py
# (선택) fractal 운영 감시 — vol 전용이면 주석 유지
# */5 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_watch_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/watch_cron.log 2>&1

88
scripts/install_crontab.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Bithumb cron 등록 — 기존 crontab에 BITHUMB 블록 병합/갱신
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MARKER_BEGIN="# BITHUMB vol_breakout cron (begin)"
MARKER_END="# BITHUMB vol_breakout cron (end)"
EXAMPLE="${ROOT}/scripts/crontab.bithumb.example"
usage() {
cat <<EOF
Usage: $(basename "$0") [--apply|--show|--remove]
--show 등록될 cron 블록만 출력 (기본)
--apply crontab에 BITHUMB 블록 병합 후 설치
--remove crontab에서 BITHUMB 블록 제거
환경 변수:
BITHUMB_PYTHON cron에서 사용할 python3 (예: ~/opt/anaconda3/envs/coin/bin/python3)
로그:
data/common/download_cron.log
data/spot/operations/vol_breakout_cron.log
data/spot/operations/vol_monitor_cron.log
EOF
}
render_block() {
{
echo "$MARKER_BEGIN"
sed "s|/Users/dsyoon/workspace/bithumb|${ROOT}|g" "$EXAMPLE" \
| grep -v '^#' | grep -v '^[[:space:]]*$'
echo "$MARKER_END"
}
}
strip_block() {
awk -v b="$MARKER_BEGIN" -v e="$MARKER_END" '
$0 == b { skip=1; next }
$0 == e { skip=0; next }
skip { next }
/^# BITHUMB/ { next }
/bithumb\/scripts\/(00_run_download_cron|3_run_vol_breakout_cron|3_run_vol_monitor_cron|3_run_watch_cron)\.sh/ { next }
{ print }
'
}
ensure_dirs() {
mkdir -p \
"${ROOT}/data/common" \
"${ROOT}/data/spot/operations" \
"${ROOT}/docs/spot/3_operations"
}
ACTION="${1:---show}"
case "$ACTION" in
--show)
ensure_dirs
render_block
;;
--apply)
ensure_dirs
chmod +x "${ROOT}/scripts/"*.sh 2>/dev/null || true
tmp="$(mktemp)"
crontab -l 2>/dev/null | strip_block > "$tmp" || true
render_block >> "$tmp"
crontab "$tmp"
rm -f "$tmp"
echo "crontab installed. 확인: crontab -l"
;;
--remove)
tmp="$(mktemp)"
if crontab -l 2>/dev/null | strip_block > "$tmp"; then
crontab "$tmp"
echo "BITHUMB cron block removed."
else
echo "crontab empty or not found."
fi
rm -f "$tmp"
;;
-h|--help)
usage
;;
*)
usage >&2
exit 1
;;
esac

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# macOS LaunchAgent — vol_live 모니터(8766) 로그인 시 자동 기동·유지
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LABEL="com.bithumb.vol-monitor"
PLIST_SRC="${ROOT}/scripts/com.bithumb.vol-monitor.plist.template"
PLIST_DST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
LOG="${ROOT}/data/spot/operations/vol_monitor_serve.log"
usage() {
cat <<EOF
Usage: $(basename "$0") [--install|--uninstall|--status]
--install LaunchAgent 등록 + 즉시 기동 (로그인·크래시 시 자동 재시작)
--uninstall LaunchAgent 제거
--status 실행 상태 확인
접속: http://127.0.0.1:8766/vol_live_monitor.html
EOF
}
resolve_python() {
# shellcheck source=scripts/_cron_env.sh
source "${ROOT}/scripts/_cron_env.sh"
resolve_bithumb_python
}
render_plist() {
local python_bin="$1"
mkdir -p "${ROOT}/data/spot/operations"
sed \
-e "s|__ROOT__|${ROOT}|g" \
-e "s|__PYTHON__|${python_bin}|g" \
-e "s|__LOG__|${LOG}|g" \
"$PLIST_SRC"
}
cmd="${1:---status}"
case "$cmd" in
--install)
PYTHON="$(resolve_python)" || exit 1
mkdir -p "${HOME}/Library/LaunchAgents"
render_plist "$PYTHON" > "$PLIST_DST"
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST_DST"
launchctl enable "gui/$(id -u)/${LABEL}" 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/${LABEL}" 2>/dev/null || true
sleep 2
if curl -sf -o /dev/null --connect-timeout 3 "http://127.0.0.1:8766/vol_live_monitor.html"; then
echo "LaunchAgent 설치 완료 — http://127.0.0.1:8766/vol_live_monitor.html"
else
echo "LaunchAgent 등록됨. 접속 안 되면 로그 확인: $LOG" >&2
tail -15 "$LOG" 2>/dev/null || true
fi
;;
--uninstall)
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
rm -f "$PLIST_DST"
echo "LaunchAgent 제거됨"
;;
--status)
if launchctl print "gui/$(id -u)/${LABEL}" >/dev/null 2>&1; then
echo "LaunchAgent: 등록됨"
launchctl print "gui/$(id -u)/${LABEL}" 2>/dev/null | grep -E "state =|pid =|last exit" || true
else
echo "LaunchAgent: 미등록"
fi
if curl -sf -o /dev/null --connect-timeout 2 "http://127.0.0.1:8766/vol_live_monitor.html"; then
echo "HTTP 8766: 응답 OK"
else
echo "HTTP 8766: 연결 불가 (서버 미기동)"
fi
;;
-h|--help)
usage
;;
*)
usage >&2
exit 1
;;
esac