feat(rsi_dca): 7종 RSI 정액 매수 라이브 전략·모니터 고도화·수집 안정화

RSI DCA 전략 (신규)
- rsi_dca_engine/runner/control: 1분봉 RSI(14) 종목별 기준선 상향 돌파 시 정액 매수,
  일 상한, 신호 45분 유효, 킬스위치, 인터벌 변경 시 커서 재초기화, 체결가 거래소 보정
- scripts: 3_run_rsi_dca(.py/_cron.sh), 백테스트·인터벌 비교, go-live 스위치
- 설정: RSI_DCA_* (모드·종목·기준선·종목별 오버라이드·일 상한 등)

모니터 (vol_live_monitor / vol_monitor_chart)
- 분봉 탭(/api/candles), RSI(14) 패널·종목별 기준선, 3패널 시간축 정렬, KST 표기
- 자동매수 ON/OFF 패널(/api/rsi/status·toggle), 빌드 해시 기반 자동 새로고침, 지연 경고
- 요약표: 거래소 평균매입가 기준 보유원금·수익률, 총평가 손익, 수익률순 동적 정렬
- 잔고 스냅샷을 계좌 전체 조회 1회로 통합, 실시간 시세 반영

데이터·수집
- candle_store/loader: SQL 범위·LIMIT 조회로 대형 테이블 전량 스캔 제거
- 절전·재부팅 후 공백 자동 백필(gap_backfill, 00_backfill_gaps) 및 cron 연동
- 수집 cron 분할(매분 핵심 분봉·5분 전체), 프로젝트 한정 lock 패턴, exec 제거로 lock 정리 복구
- 모니터 종목(VOL_MONITOR_SYMBOLS)·수집 종목 7종 분리, 한글 코인명 추가

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
dsyoon
2026-09-24 18:30:31 +09:00
parent 0d6c57db62
commit 48dcdd0ae8
27 changed files with 2727 additions and 84 deletions

100
scripts/00_backfill_gaps.py Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""절전·재부팅 후 캔들 공백 자동 백필 (cron 수집 전 단계).
증분 수집(최신 200봉)으로 못 채우는 공백이 있는 (심볼, 인터벌)만 골라
00_download_candles.py --full --days N 으로 채운다. 공백이 없으면 즉시 종료(약 0.1초).
python scripts/00_backfill_gaps.py # 점검 후 필요 시 백필
python scripts/00_backfill_gaps.py --dry-run # 계획만 출력
python scripts/00_backfill_gaps.py --dry-run --assume-now "2026-09-12 09:00:00"
"""
from __future__ import annotations
import argparse
import logging
import subprocess
import sys
from datetime import datetime
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 # noqa: E402
from bithumb.data.candle_store import CandleStore # noqa: E402
from bithumb.data.gap_backfill import plan_backfill # noqa: E402
logger = logging.getLogger("backfill")
def main() -> int:
parser = argparse.ArgumentParser(description="캔들 공백 자동 백필")
parser.add_argument("--dry-run", action="store_true", help="계획만 출력")
parser.add_argument("--assume-now", default=None, help="테스트용 기준 시각 'YYYY-MM-DD HH:MM:SS'")
parser.add_argument("--max-days", type=int, default=30, help="백필 상한 일수 (기본 30)")
parser.add_argument("--safety", type=float, default=0.9, help="증분 커버리지 안전계수 (기본 0.9)")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
settings = load_settings()
now = datetime.strptime(args.assume_now, "%Y-%m-%d %H:%M:%S") if args.assume_now else datetime.now()
symbols = list(settings.download_symbols)
intervals = list(settings.download_intervals)
store = CandleStore(settings.db_path)
try:
db_max = {
sym: {iv: store.get_max_datetime(sym, iv) for iv in intervals}
for sym in symbols
}
finally:
store.close()
plan = plan_backfill(
db_max, now=now, intervals=intervals,
batch_size=settings.candle_count, safety=args.safety, max_days=args.max_days,
)
if plan.empty:
logger.info("공백 없음 (증분 수집 범위 내) — 백필 생략")
return 0
logger.warning("캔들 공백 감지 → 백필 계획: %s", plan.describe())
if args.dry_run:
return 0
# 인터벌별 필요 일수가 다르므로 일수별로 묶어 실행 (요청 수 최소화)
by_days: dict[int, dict[str, set[int]]] = {}
for sym, m in plan.needs.items():
for iv, days in m.items():
by_days.setdefault(days, {}).setdefault(sym, set()).add(iv)
python = sys.executable
rc_all = 0
for days in sorted(by_days):
sym_ivs = by_days[days]
syms = ",".join(sorted(sym_ivs))
ivs = ",".join(str(i) for i in sorted({iv for s in sym_ivs.values() for iv in s}))
cmd = [
python, str(ROOT / "scripts" / "00_download_candles.py"),
"--full", "--days", str(days), "--symbols", syms, "--intervals", ivs,
]
logger.warning("백필 실행: --full --days %s --symbols %s --intervals %s", days, syms, ivs)
rc = subprocess.call(cmd, cwd=str(ROOT))
if rc != 0:
logger.error("백필 실패 rc=%s (days=%s)", rc, days)
rc_all = rc
if rc_all == 0:
logger.warning("백필 완료: %s", plan.describe())
return rc_all
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -6,10 +6,27 @@ 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
DL_SCRIPT="${CRON_PROJECT_ROOT}/scripts/00_download.py"
# 7종목 증분 — 20분 초과 시 hung 으로 간주. 패턴은 이 프로젝트의 절대 경로로 한정
# (다른 프로젝트(Binance 등)의 scripts/00_download.py 와 pgrep/pkill 이 섞이지 않도록)
if ! acquire_cron_lock "$LOCKDIR" "$DL_SCRIPT" 1200; then
exit 0
fi
PYTHON="$(resolve_bithumb_python)" || exit 1
"$PYTHON" scripts/00_download.py "$@"
# 절전·재부팅 복귀 시: 증분 범위(200봉)를 넘는 공백이 있으면 필요한 분봉·일수만 먼저 백필 (공백 없으면 ~0.1초)
"$PYTHON" "${CRON_PROJECT_ROOT}/scripts/00_backfill_gaps.py" || echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] 공백 백필 실패 — 증분 수집은 계속 진행" >&2
# 인자가 없으면: 매분 핵심 분봉(1,3,5,15)만, 5분 배수 분에는 DOWNLOAD_INTERVALS 전체.
# 1분봉 전략의 캔들 지연을 줄이기 위한 분할 (환경변수 DOWNLOAD_CORE_INTERVALS 로 조정)
if [ "$#" -eq 0 ]; then
minute="$(date +%M)"
if [ $((10#$minute % 5)) -ne 0 ]; then
CORE="${DOWNLOAD_CORE_INTERVALS:-1,3,5,15}"
echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] core intervals only: ${CORE}"
"$PYTHON" "$DL_SCRIPT" --intervals "$CORE"
exit 0 # 핵심 분봉 회차 종료 (EXIT 트랩이 lock 정리)
fi
fi
"$PYTHON" "$DL_SCRIPT" "$@"

64
scripts/3_go_live_rsi_dca.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# RSI DCA 정액 매수 live 전환/복귀 — 사용자가 직접 실행하는 스위치.
# bash scripts/3_go_live_rsi_dca.sh # live 전환: .env RSI_DCA_MODE=live + cron 줄 활성화 + crontab 적용
# bash scripts/3_go_live_rsi_dca.sh --paper # paper 복귀 (cron 유지, 주문 없음)
# bash scripts/3_go_live_rsi_dca.sh --dry-run # 변경 없이 수행 내용만 출력
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV="${ROOT}/.env"
EXAMPLE="${ROOT}/scripts/crontab.bithumb.example"
RSI_LINE_RE='3_run_rsi_dca_cron\.sh'
MODE="live"
DRY=0
for a in "$@"; do
case "$a" in
--paper) MODE="paper" ;;
--live) MODE="live" ;;
--dry-run) DRY=1 ;;
-h|--help) sed -n 2,5p "$0"; exit 0 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac
done
[ -f "$ENV" ] || { echo ".env 없음: $ENV" >&2; exit 1; }
cur="$(grep -E '^RSI_DCA_MODE=' "$ENV" | tail -1 | cut -d= -f2 | tr -d '[:space:]' || true)"
echo "현재 RSI_DCA_MODE=${cur:-<미설정>} → 목표 ${MODE}"
echo "대상: $(grep -E '^RSI_DCA_SYMBOLS=' "$ENV" | cut -d= -f2) | 규칙: $(grep -E '^RSI_DCA_LEVELS=' "$ENV" | cut -d= -f2) | 일 상한: $(grep -E '^RSI_DCA_DAILY_MAX_KRW=' "$ENV" | cut -d= -f2)원"
if [ "$MODE" = "live" ] && [ -f "${ROOT}/data/spot/operations/rsi.kill" ]; then
echo "주의: 킬스위치 파일(data/spot/operations/rsi.kill)이 있어 매수가 차단됩니다. 해제: rm data/spot/operations/rsi.kill"
fi
if [ "$DRY" = "1" ]; then
echo "[dry-run] 1) .env RSI_DCA_MODE=${MODE} 로 변경"
echo "[dry-run] 2) ${EXAMPLE} 의 RSI cron 줄 주석 해제"
echo "[dry-run] 3) bash scripts/install_crontab.sh --apply"
exit 0
fi
# 1) .env 모드
if grep -qE '^RSI_DCA_MODE=' "$ENV"; then
sed -i '' -E "s/^RSI_DCA_MODE=.*/RSI_DCA_MODE=${MODE}/" "$ENV"
else
printf '\nRSI_DCA_MODE=%s\n' "$MODE" >> "$ENV"
fi
echo "1) .env: $(grep -E '^RSI_DCA_MODE=' "$ENV")"
# 2) cron 예시에서 RSI 줄 활성화 (이미 활성화면 그대로)
if grep -qE "^# \* \* \* \* \* .*${RSI_LINE_RE}" "$EXAMPLE"; then
sed -i '' -E "s|^# (\* \* \* \* \* .*${RSI_LINE_RE}.*)$|\1|" "$EXAMPLE"
fi
echo "2) cron 예시: $(grep -E "${RSI_LINE_RE}" "$EXAMPLE" | grep -vE '^#' | head -1 | cut -c1-70)…"
# 3) crontab 적용 (BITHUMB 블록 갱신 — 캔들 수집·모니터·RSI tick)
bash "${ROOT}/scripts/install_crontab.sh" --apply
echo "3) crontab RSI 줄:"; crontab -l | grep -E "${RSI_LINE_RE}" || echo " (없음)"
echo
if [ "$MODE" = "live" ]; then
echo "완료: 다음 1분 tick부터 실거래 매수. 첫 tick은 미초기화 종목 커서 설정만 하고, 이후 봉부터 매수합니다."
echo "긴급 차단: touch ${ROOT}/data/spot/operations/rsi.kill | paper 복귀: bash scripts/3_go_live_rsi_dca.sh --paper"
echo "확인: python scripts/3_run_rsi_dca.py --status / tail -f data/spot/operations/rsi_dca_cron.log"
else
echo "완료: paper 모드. cron은 유지되며 주문은 나가지 않습니다."
fi

106
scripts/3_run_rsi_dca.py Executable file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""RSI DCA 현물 정액 매수 tick (15m RSI 30/35 상향 돌파, 매도 없음).
python scripts/3_run_rsi_dca.py # RSI_DCA_MODE(.env, 기본 paper) 1회 tick
python scripts/3_run_rsi_dca.py --mode paper --loop 60
python scripts/3_run_rsi_dca.py --mode live # 실제 주문 — 사용자 직접 실행
python scripts/3_run_rsi_dca.py --status # 상태만 출력
"""
from __future__ import annotations
import argparse
import json
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 # noqa: E402
from bithumb.operations.rsi_dca_engine import load_state # noqa: E402
from bithumb.operations.rsi_dca_runner import RsiDcaRunner # noqa: E402
def _configure_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def _print_status(settings, mode: str) -> None:
st = load_state(settings.rsi_dca_state_json, mode)
d = st.get("daily") or {}
t = st.get("totals") or {}
print(f"strategy={st.get('strategy')} mode={st.get('mode')} last_run={st.get('last_run_at')}")
print(f"daily {d.get('date')} spent={float(d.get('spent_krw') or 0):,.0f}/{settings.rsi_dca_daily_max_krw:,.0f} count={d.get('count')}")
print(f"totals spent={float(t.get('spent_krw') or 0):,.0f} count={t.get('count')} trades={len(st.get('trades') or [])}")
for sym, s in (st.get("symbols") or {}).items():
rsi = s.get("last_rsi")
print(f" {sym:4s} init={s.get('initialized')} cursor={s.get('last_confirm_time')} "
f"rsi={rsi if rsi is None else round(rsi, 1)} buys={s.get('buy_count')} spent={float(s.get('spent_krw') or 0):,.0f}")
def main() -> int:
parser = argparse.ArgumentParser(description="Bithumb RSI DCA 정액 매수 tick")
parser.add_argument("--mode", choices=("paper", "live"), default=None,
help="기본: .env RSI_DCA_MODE (paper)")
parser.add_argument("--loop", type=int, default=0, metavar="SEC")
parser.add_argument("--status", action="store_true", help="상태 출력만")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
_configure_logging(args.verbose)
settings = load_settings()
mode = (args.mode or settings.rsi_dca_mode or "paper").lower()
if args.status:
_print_status(settings, mode)
return 0
if mode == "live":
print("경고: live — 실제 주문 발생 (RSI 정액 매수, 매도 없음)")
levels = ", ".join(f"RSI {lv:g}↑ {krw:,.0f}원" for lv, krw in settings.rsi_dca_levels)
print(
f"rsi_dca {mode} | symbols={settings.rsi_dca_symbols} | {settings.rsi_dca_interval_min}m "
f"RSI({settings.rsi_dca_period}) | {levels} | 일 상한 {settings.rsi_dca_daily_max_krw:,.0f}원"
)
def _once() -> dict:
runner = RsiDcaRunner(settings, mode=mode)
report = runner.tick()
if not report.get("ok"):
print(f" skip: {report.get('note')}")
return report
d = report.get("daily") or {}
for row in report.get("results") or []:
if "error" in row:
print(f" {row['symbol']}: ERROR {row['error']}")
continue
rsi = row.get("rsi")
print(f" {row['symbol']:4s} rsi={'-' if rsi is None else f'{rsi:.1f}':>5s} fills={row['fills']} {row['note']}")
print(f" daily {d.get('date')} spent={float(d.get('spent_krw') or 0):,.0f} "
f"remaining={float(d.get('remaining_krw') or 0):,.0f} kill_switch={report.get('kill_switch')}")
return report
if args.loop <= 0:
_once()
return 0
while True:
try:
_once()
except KeyboardInterrupt:
print("\n종료")
return 0
except Exception: # noqa: BLE001
logging.exception("rsi_dca loop tick failed")
time.sleep(args.loop)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""RSI DCA 백테스트 — DB 15m 캔들로 최근 N일 신호·매수·평가 산출.
python scripts/3_run_rsi_dca_backtest.py --days 90
python scripts/3_run_rsi_dca_backtest.py --days 90 --levels 30:20000,35:10000 --daily-max 60000
"""
from __future__ import annotations
import argparse
import json
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 # noqa: E402
from bithumb.data.candle_loader import load_candles # noqa: E402
from bithumb.operations.rsi_dca_engine import ( # noqa: E402
RsiDcaConfig,
backtest_rsi_dca,
parse_levels,
)
from bithumb.operations.rsi_dca_runner import config_from_settings # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description="RSI DCA 백테스트")
parser.add_argument("--days", type=int, default=90)
parser.add_argument("--symbols", default=None, help="쉼표 구분 (기본 RSI_DCA_SYMBOLS)")
parser.add_argument("--levels", default=None, help="예 30:20000,35:10000")
parser.add_argument("--daily-max", type=float, default=None)
parser.add_argument("--out", default="docs/spot/3_operations/rsi_dca_backtest.json")
args = parser.parse_args()
settings = load_settings()
base = config_from_settings(settings)
cfg = RsiDcaConfig(
symbols=[s.strip().upper() for s in args.symbols.split(",")] if args.symbols else base.symbols,
interval_min=base.interval_min,
period=base.period,
levels=parse_levels(args.levels) if args.levels else base.levels,
daily_max_krw=args.daily_max if args.daily_max is not None else base.daily_max_krw,
lookback_days=base.lookback_days,
max_bars_per_tick=base.max_bars_per_tick,
max_signal_age_min=base.max_signal_age_min,
min_order_krw=base.min_order_krw,
fee_rate=base.fee_rate,
slippage_rate=base.slippage_rate,
fee_lock_rate=base.fee_lock_rate,
)
candles = {}
for sym in cfg.symbols:
# RSI 워밍업을 위해 여유 있게 로드
df = load_candles(settings.db_path, sym, cfg.interval_min, lookback_days=args.days + 10)
candles[sym] = df
rep = backtest_rsi_dca(candles, cfg, days=args.days)
levels_txt = ", ".join(f"RSI {lv:g}↑ {krw:,.0f}원" for lv, krw in cfg.levels)
print(f"RSI DCA 백테스트 · 최근 {args.days}일 · {cfg.interval_min}m RSI({cfg.period}) · {levels_txt} · 일 상한 {cfg.daily_max_krw:,.0f}원")
print(f"수수료 {cfg.fee_rate*100:.3f}% · 슬리피지 {cfg.slippage_rate*100:.3f}% · 매도 없음(마지막 종가 평가)")
print()
print(f"{'종목':5s} {'신호':>5s} {'매수':>5s} {'투입(원)':>12s} {'평가(원)':>12s} {'손익%':>7s}")
for sym in cfg.symbols:
v = rep["per_symbol"].get(sym)
if not v:
print(f"{sym:5s} {'데이터없음':>5s}")
continue
print(f"{sym:5s} {int(v['signals']):5d} {int(v['buys']):5d} {v['spent_krw']:12,.0f} {v['value_krw']:12,.0f} {v['pnl_pct']:7.2f}")
print("-" * 52)
print(f"{'합계':5s} {rep['signals']:5d} {rep['buys']:5d} {rep['total_spent_krw']:12,.0f} {rep['total_value_krw']:12,.0f} {rep['total_pnl_pct']:7.2f}")
print(f"일 상한으로 스킵된 신호: {rep['skipped_daily_cap']}건 · 일평균 투입 {rep['avg_daily_spent_krw']:,.0f}원 (신호 구간 {rep['span_days']}일)")
out = ROOT / args.out
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(rep, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
print(f"\n저장: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

18
scripts/3_run_rsi_dca_cron.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# RSI DCA 정액 매수 tick (cron 1분). 모드는 .env RSI_DCA_MODE (기본 paper).
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/rsi.tick.lock.d"
RSI_SCRIPT="${CRON_PROJECT_ROOT}/scripts/3_run_rsi_dca.py"
if ! acquire_cron_lock "$LOCKDIR" "$RSI_SCRIPT" 600; then
exit 0
fi
PYTHON="$(resolve_bithumb_python)" || exit 1
# 같은 분에 시작하는 캔들 수집(핵심 분봉 12~21초)이 끝난 뒤 판정하도록 지연 → 봉 마감 후 약 1.5분 내 매수
sleep "${RSI_DCA_TICK_DELAY_SEC:-25}"
echo "$(date '+%Y-%m-%d %H:%M:%S') tick"
"$PYTHON" "$RSI_SCRIPT" "$@"

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""RSI DCA 인터벌 비교 실험 — 동일 규칙(30↑ 2만원, 35↑ 1만원, 일 상한)으로 분봉별 수익률 비교.
python scripts/3_run_rsi_dca_interval_sweep.py --days 90
python scripts/3_run_rsi_dca_interval_sweep.py --days 90 --intervals 1,3,5,10,15,30,60,240,1440
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
import pandas as pd
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 # noqa: E402
from bithumb.data.candle_loader import load_candles # noqa: E402
from bithumb.operations.rsi_dca_engine import ( # noqa: E402
RsiDcaConfig,
backtest_rsi_dca,
parse_levels,
)
from bithumb.operations.rsi_dca_runner import config_from_settings # noqa: E402
LABEL = {1: "1분", 3: "3분", 5: "5분", 10: "10분", 15: "15분", 30: "30분", 60: "1시간", 240: "4시간", 1440: "1일"}
def _with(cfg: RsiDcaConfig, **kw) -> RsiDcaConfig:
d = {k: getattr(cfg, k) for k in cfg.__dataclass_fields__}
d.update(kw)
return RsiDcaConfig(**d)
def daily_dca_benchmark(
daily_by_symbol: dict[str, pd.DataFrame],
*,
days: int,
daily_krw: float,
fee_rate: float,
slippage_rate: float,
) -> dict:
"""기준선: 매일 종가에 일 예산을 종목 수로 균등 분할 매수 (매도 없음)."""
syms = [s for s, df in daily_by_symbol.items() if df is not None and not df.empty]
if not syms:
return {}
per = daily_krw / len(syms)
spent = 0.0
value = 0.0
buys = 0
for sym in syms:
d = daily_by_symbol[sym].copy()
d["datetime"] = pd.to_datetime(d["datetime"])
d = d.sort_values("datetime")
start = d["datetime"].max() - pd.Timedelta(days=days)
w = d[d["datetime"] >= start]
last = float(d["close"].iloc[-1])
coin = 0.0
for px in w["close"].astype(float):
fill = px * (1.0 + slippage_rate)
coin += per * (1.0 - fee_rate) / fill
spent += per
buys += 1
value += coin * last
return {
"label": "매일 정액 분할(기준선)",
"buys": buys,
"total_spent_krw": spent,
"total_value_krw": value,
"total_pnl_pct": (value / spent - 1.0) * 100.0 if spent else 0.0,
"avg_daily_spent_krw": daily_krw,
}
def main() -> int:
parser = argparse.ArgumentParser(description="RSI DCA 인터벌 비교")
parser.add_argument("--days", type=int, default=90)
parser.add_argument("--intervals", default="1,3,5,10,15,30,60,240,1440")
parser.add_argument("--levels", default=None)
parser.add_argument("--daily-max", type=float, default=None)
parser.add_argument("--out", default="docs/spot/3_operations/rsi_dca_interval_sweep.json")
args = parser.parse_args()
settings = load_settings()
base = config_from_settings(settings)
if args.levels:
base = _with(base, levels=parse_levels(args.levels))
if args.daily_max is not None:
base = _with(base, daily_max_krw=args.daily_max)
intervals = [int(x) for x in args.intervals.split(",") if x.strip()]
results = []
for iv in intervals:
cfg = _with(base, interval_min=iv)
warm_days = math.ceil(cfg.period * 3 * iv / 1440) + 2
candles = {}
for sym in cfg.symbols:
candles[sym] = load_candles(settings.db_path, sym, iv, lookback_days=args.days + warm_days)
rep = backtest_rsi_dca(candles, cfg, days=args.days)
bars = sum(len(df) for df in candles.values() if df is not None)
rep["interval_min"] = iv
rep["label"] = LABEL.get(iv, f"{iv}분")
rep["bars_loaded"] = bars
rep.pop("trades", None)
results.append(rep)
print(f" {rep['label']:>4s} 완료 · 신호 {rep['signals']:,} · 매수 {rep['buys']} · 손익 {rep['total_pnl_pct']:+.2f}%", flush=True)
daily = {s: load_candles(settings.db_path, s, 1440, lookback_days=args.days + 5) for s in base.symbols}
bench = daily_dca_benchmark(
daily, days=args.days, daily_krw=base.daily_max_krw,
fee_rate=base.fee_rate, slippage_rate=base.slippage_rate,
)
levels_txt = ", ".join(f"RSI {lv:g}↑ {krw:,.0f}원" for lv, krw in base.levels)
print()
print(f"RSI DCA 인터벌 비교 · 최근 {args.days}일 · {len(base.symbols)}종 · RSI({base.period}) · {levels_txt} · 일 상한 {base.daily_max_krw:,.0f}원")
print(f"수수료 {base.fee_rate*100:.3f}% · 슬리피지 {base.slippage_rate*100:.3f}% · 매도 없음 · 마지막 종가 평가")
print()
hdr = f"{'인터벌':>6s} {'신호':>7s} {'매수':>5s} {'상한스킵':>7s} {'투입(원)':>11s} {'평가(원)':>11s} {'손익%':>7s} {'손익(원)':>10s} {'일평균투입':>9s}"
print(hdr)
for r in sorted(results, key=lambda x: -x["total_pnl_pct"]):
pnl = r["total_value_krw"] - r["total_spent_krw"]
print(f"{r['label']:>6s} {r['signals']:7,d} {r['buys']:5d} {r['skipped_daily_cap']:7,d} {r['total_spent_krw']:11,.0f} {r['total_value_krw']:11,.0f} {r['total_pnl_pct']:7.2f} {pnl:10,.0f} {r['avg_daily_spent_krw']:9,.0f}")
if bench:
pnl = bench["total_value_krw"] - bench["total_spent_krw"]
print("-" * len(hdr))
print(f"{'기준선':>6s} {'-':>7s} {bench['buys']:5d} {'-':>7s} {bench['total_spent_krw']:11,.0f} {bench['total_value_krw']:11,.0f} {bench['total_pnl_pct']:7.2f} {pnl:10,.0f} {bench['avg_daily_spent_krw']:9,.0f} (매일 종가 6만원 균등 분할)")
best = max(results, key=lambda x: x["total_pnl_pct"])
print(f"\n최고 수익률: {best['label']} ({best['total_pnl_pct']:+.2f}%)")
print("종목별 (최고 인터벌):")
for sym in base.symbols:
v = best["per_symbol"].get(sym)
if v:
print(f" {sym:4s} 신호 {int(v['signals']):5d} 매수 {int(v['buys']):4d} 투입 {v['spent_krw']:10,.0f} 평가 {v['value_krw']:10,.0f} {v['pnl_pct']:+7.2f}%")
out = ROOT / args.out
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({
"days": args.days, "levels": base.levels, "daily_max_krw": base.daily_max_krw,
"symbols": base.symbols, "results": results, "benchmark_daily_dca": bench,
}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
print(f"\n저장: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -22,14 +22,16 @@ import threading
import time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from urllib.parse import parse_qs, 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.rsi_dca_control import rsi_status, set_rsi_enabled # noqa: E402
from bithumb.operations.vol_breakout_engine import load_vol_state # noqa: E402
from bithumb.operations.vol_live_monitor import ( # noqa: E402
build_candles_api_payload,
fetch_live_balance_snapshot,
patch_vol_monitor_balance,
write_vol_monitor,
@@ -58,6 +60,7 @@ def refresh_vol_live_monitor(*, write_html: bool = True) -> dict:
positions = snap.setdefault("positions", {})
for sym, qty in (bal.get("positions") or {}).items():
positions[sym] = qty
snap["avg_prices"] = dict(bal.get("avg_prices") or {})
except Exception as exc: # noqa: BLE001
logger.warning("live balance sync skipped: %s", exc)
@@ -108,8 +111,48 @@ def _api_balance() -> dict:
return fetch_live_balance()
_rsi_lock = threading.Lock()
def _api_rsi_status() -> dict:
return rsi_status(load_settings())
def _api_rsi_toggle(query: str) -> dict:
"""/api/rsi/toggle[?enable=1|0] — 파라미터 없으면 반전."""
qs = parse_qs(query or "")
raw = (qs.get("enable") or [""])[0].strip().lower()
settings = load_settings()
with _rsi_lock:
if raw in ("1", "true", "on", "yes"):
target = True
elif raw in ("0", "false", "off", "no"):
target = False
else:
target = not rsi_status(settings)["enabled"]
out = set_rsi_enabled(settings, target)
logger.warning("RSI 자동매수 %s (mode=%s)", "ON" if target else "OFF", out.get("mode"))
return out
def _api_candles(query: str) -> dict:
"""/api/candles?symbol=TRX&interval=5[&bars=800] — DB 직접 조회."""
qs = parse_qs(query or "")
symbol = (qs.get("symbol") or [""])[0]
interval = (qs.get("interval") or ["15"])[0]
bars_raw = (qs.get("bars") or [""])[0]
settings = load_settings()
max_bars = None
if bars_raw:
try:
max_bars = max(10, min(int(bars_raw), 20000))
except ValueError:
max_bars = None
return build_candles_api_payload(settings, symbol, interval, max_bars=max_bars)
class MonitorHandler(SimpleHTTPRequestHandler):
"""vol_live 정적 파일 + /api/chart · /api/balance · /api/refresh."""
"""vol_live 정적 파일 + /api/chart · /api/candles · /api/balance · /api/refresh · /api/rsi/status · /api/rsi/toggle."""
_static_dir: str | None = None
_access_log: bool = False
@@ -195,18 +238,32 @@ class MonitorHandler(SimpleHTTPRequestHandler):
self._send_json({"ok": False, "error": str(exc)}, status=500)
def end_headers(self) -> None:
if self.path.endswith(".json"):
p = urlparse(self.path).path
if p.endswith((".json", ".html")) or p in ("", "/"):
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
def _request_path(self) -> str:
return urlparse(self.path).path.rstrip("/")
def _handle_json_call(self, fn, *args) -> None:
try:
out = fn(*args)
self._send_json(out, status=200 if out.get("ok") else 400)
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 do_POST(self) -> None:
path = self._request_path()
if path == "/api/refresh":
self._handle_refresh()
return
if path == "/api/rsi/toggle":
self._handle_json_call(_api_rsi_toggle, urlparse(self.path).query)
return
self.send_error(404, "not found")
def do_GET(self) -> None:
@@ -214,6 +271,19 @@ class MonitorHandler(SimpleHTTPRequestHandler):
if path == "/api/refresh":
self._handle_refresh()
return
if path == "/api/rsi/status":
self._handle_json_call(_api_rsi_status)
return
if path == "/api/candles":
try:
out = _api_candles(urlparse(self.path).query)
self._send_json(out, status=200 if out.get("ok") else 400)
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)
return
if path == "/api/balance":
self._handle_balance()
return

View File

@@ -6,9 +6,10 @@ 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
MON_SCRIPT="${CRON_PROJECT_ROOT}/scripts/3_run_vol_monitor.py"
if ! acquire_cron_lock "$LOCKDIR" "$MON_SCRIPT --refresh-only" 300; then
exit 0
fi
PYTHON="$(resolve_bithumb_python)" || exit 1
"$PYTHON" scripts/3_run_vol_monitor.py --refresh-only "$@"
"$PYTHON" "$MON_SCRIPT" --refresh-only "$@"

View File

@@ -7,17 +7,20 @@
# 비활성화(주석): bash scripts/install_crontab.sh --disable
# 제거: bash scripts/install_crontab.sh --remove
# 캔들 증분 (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
# 캔들 증분 (DOWNLOAD_SYMBOLS × 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
# RSI DCA 정액 매수 tick — 매 1분 (모드 .env RSI_DCA_MODE, 기본 paper). 활성화 시 # 제거
* * * * * /Users/dsyoon/workspace/bithumb/scripts/3_run_rsi_dca_cron.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/rsi_dca_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
*/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
# vol_live 모니터 HTTP 서버(8766) — 2분마다 미기동 시 nohup 기동
# */2 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_ensure_vol_monitor_serve.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_monitor_serve.log 2>&1
*/2 * * * * /Users/dsyoon/workspace/bithumb/scripts/3_ensure_vol_monitor_serve.sh >> /Users/dsyoon/workspace/bithumb/data/spot/operations/vol_monitor_serve.log 2>&1
# (선택) 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

View File

@@ -8,7 +8,7 @@ MARKER_END="# BITHUMB vol_breakout cron (end)"
MARKER_BEGIN_ALT="# BITHUMB (begin)"
MARKER_END_ALT="# BITHUMB (end)"
EXAMPLE="${ROOT}/scripts/crontab.bithumb.example"
BITHUMB_CRON_RE='bithumb/scripts/(00_run_download_cron|3_run_vol_breakout_cron|3_run_vol_monitor_cron|3_ensure_vol_monitor_serve|3_run_watch_cron)\.sh'
BITHUMB_CRON_RE='bithumb/scripts/(00_run_download_cron|3_run_vol_breakout_cron|3_run_rsi_dca_cron|3_run_vol_monitor_cron|3_ensure_vol_monitor_serve|3_run_watch_cron)\.sh'
usage() {
cat <<EOF
@@ -26,6 +26,7 @@ Usage: $(basename "$0") [--show|--apply|--disable|--enable|--remove]
로그:
data/common/download_cron.log
data/spot/operations/vol_breakout_cron.log
data/spot/operations/rsi_dca_cron.log
data/spot/operations/vol_monitor_cron.log
data/spot/operations/vol_monitor_serve.log
EOF