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:
100
scripts/00_backfill_gaps.py
Executable file
100
scripts/00_backfill_gaps.py
Executable 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())
|
||||
@@ -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
64
scripts/3_go_live_rsi_dca.sh
Executable 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
106
scripts/3_run_rsi_dca.py
Executable 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())
|
||||
86
scripts/3_run_rsi_dca_backtest.py
Executable file
86
scripts/3_run_rsi_dca_backtest.py
Executable 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
18
scripts/3_run_rsi_dca_cron.sh
Executable 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" "$@"
|
||||
154
scripts/3_run_rsi_dca_interval_sweep.py
Executable file
154
scripts/3_run_rsi_dca_interval_sweep.py
Executable 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user