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:
@@ -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
15
scripts/00_run_download_cron.sh
Executable 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 "$@"
|
||||
229
scripts/3_audit_ops_safety.py
Normal file
229
scripts/3_audit_ops_safety.py
Normal 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())
|
||||
14
scripts/3_ensure_vol_monitor_serve.sh
Executable file
14
scripts/3_ensure_vol_monitor_serve.sh
Executable 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"
|
||||
78
scripts/3_reconcile_signals.py
Normal file
78
scripts/3_reconcile_signals.py
Normal 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())
|
||||
13
scripts/3_refresh_vol_monitor.py
Normal file
13
scripts/3_refresh_vol_monitor.py
Normal 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__")
|
||||
@@ -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", "-")} ·
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
87
scripts/3_run_vol_breakout.py
Normal file
87
scripts/3_run_vol_breakout.py
Normal 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())
|
||||
14
scripts/3_run_vol_breakout_cron.sh
Executable file
14
scripts/3_run_vol_breakout_cron.sh
Executable 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
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())
|
||||
14
scripts/3_run_vol_monitor_cron.sh
Executable file
14
scripts/3_run_vol_monitor_cron.sh
Executable 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 "$@"
|
||||
55
scripts/3_run_vol_monitor_serve.sh
Executable file
55
scripts/3_run_vol_monitor_serve.sh
Executable 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
10
scripts/3_run_watch_cron.sh
Executable 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 "$@"
|
||||
13
scripts/3_serve_vol_monitor.py
Normal file
13
scripts/3_serve_vol_monitor.py
Normal 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
103
scripts/3_watch_ops.py
Normal 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
67
scripts/_cron_env.sh
Executable 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
|
||||
}
|
||||
30
scripts/com.bithumb.vol-monitor.plist.template
Normal file
30
scripts/com.bithumb.vol-monitor.plist.template
Normal 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>
|
||||
17
scripts/crontab.bithumb.example
Normal file
17
scripts/crontab.bithumb.example
Normal 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
88
scripts/install_crontab.sh
Executable 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
|
||||
82
scripts/install_vol_monitor_launchd.sh
Executable file
82
scripts/install_vol_monitor_launchd.sh
Executable 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
|
||||
Reference in New Issue
Block a user