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__":