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

View File

@@ -136,6 +136,8 @@ OPS_SIZING_RULES_JSON=data/spot/operations/sizing_rules.json
# fractal_swing(3분)과 별도 프로세스 — 동시 live 시 KRW 경합 주의
OPS_SYMBOLS=TRX,NEAR,WLD
VOL_STATE_JSON=data/spot/operations/vol_breakout_state.json
# 모니터 차트 표시 종목 (매매 OPS_SYMBOLS와 분리). 비우면 OPS_SYMBOLS
VOL_MONITOR_SYMBOLS=XRP,TRX,WLD,SOL,ETH,ADA,SUI
VOL_LOOKBACK=14
VOL_ATR_MULT=2.0
VOL_LOOKBACK_DAYS=60
@@ -162,6 +164,28 @@ VOL_MONITOR_DAYS=14
# cron (vol_breakout): bash scripts/install_crontab.sh --apply
# BITHUMB_PYTHON=/Users/dsyoon/opt/anaconda3/envs/coin/bin/python3
# --- RSI DCA 정액 매수 (15m RSI 30/35 상향 돌파 · 매도 없음 · 2026-09-07) ---
# 모드는 OPS_MODE와 분리. live 전환: RSI_DCA_MODE=live (실제 주문 발생)
RSI_DCA_MODE=paper
RSI_DCA_SYMBOLS=XRP,TRX,WLD,SOL,ETH,ADA,SUI
RSI_DCA_INTERVAL_MIN=15
RSI_DCA_PERIOD=14
# level:원 — 30 상향 돌파 2만원, 35 상향 돌파 1만원 (둘 다 발생 가능)
RSI_DCA_LEVELS=30:20000,35:10000
# 종목별 기준선 오버라이드 (없는 종목은 RSI_DCA_LEVELS). 예: XRP=19:10000;TRX=32:10000
RSI_DCA_LEVELS_BY_SYMBOL=
# 일(KST) 총 매수 상한 — 초과하는 주문은 스킵
RSI_DCA_DAILY_MAX_KRW=60000
RSI_DCA_LOOKBACK_DAYS=20
RSI_DCA_MAX_BARS_PER_TICK=8
# 봉 마감 후 N분 지난 신호는 매수하지 않음 (장애 복구 시 몰아 매수 방지)
RSI_DCA_MAX_SIGNAL_AGE_MIN=45
RSI_DCA_STATE_JSON=data/spot/operations/rsi_dca_state.json
RSI_DCA_REPORT_JSON=docs/spot/3_operations/rsi_dca_report.json
RSI_DCA_TICK_LOCK_PATH=data/spot/operations/rsi.tick.lock
# 파일이 존재하면 신규 매수 차단: touch data/spot/operations/rsi.kill
RSI_DCA_KILL_SWITCH_PATH=data/spot/operations/rsi.kill
# 폴더 구조: data|docs / {common, spot}
# common — coins.db 등 공유 리소스
# spot — 현물 GT·기법·분석·운영

4
.gitignore vendored
View File

@@ -114,3 +114,7 @@ ENV/
# Rope project settings
.ropeproject
# 로컬 설정 백업 (API 키 포함) — 커밋 금지
.env.bak*
.env.local.backup

View File

@@ -302,6 +302,7 @@ crontab -l # 확인
| 모니터 JSON | 5분 | `3_run_vol_monitor_cron.sh` | `data/spot/operations/vol_monitor_cron.log` |
- hung 프로세스: 다운로드 20분·vol tick 10분 초과 시 자동 종료 후 lock 정리 (`scripts/_cron_env.sh`)
- **공백 자동 백필**: 절전·재부팅 복귀 시 `00_run_download_cron.sh`가 `00_backfill_gaps.py`로 증분 범위(200봉)를 넘는 (심볼, 분봉)만 감지해 `--full --days N`으로 채운 뒤 증분 수집을 진행 (`--dry-run --assume-now`로 계획 확인)
- Python: `coin` / `ncue` conda 우선. 다른 환경이면 `.env` 또는 crontab에 `BITHUMB_PYTHON=...` 설정
- 모니터 UI (8766):
```bash
@@ -325,6 +326,30 @@ bash scripts/00_run_download_cron.sh # 수동 1회
bash scripts/3_run_vol_breakout_cron.sh
```
### RSI DCA 정액 매수 (15m RSI 30/35 상향 돌파 · 매도 없음)
7종(XRP, TRX, WLD, SOL, ETH, ADA, SUI) 15분봉 RSI(14)가 **종가 확정 기준**으로 기준선을 상향 돌파하면 정액 매수한다. 자동 매도는 없다(보유분 수동 관리).
| 규칙 | 값 | 설정 |
|------|----|------|
| 30 상향 돌파 | 20,000원 매수 | `RSI_DCA_LEVELS=30:20000,35:10000` |
| 35 상향 돌파 | 10,000원 매수 (30 돌파와 독립, 둘 다 발생 가능) | 〃 |
| 일(KST) 매수 상한 | 60,000원 — 초과 주문은 스킵 | `RSI_DCA_DAILY_MAX_KRW` |
| 신호 유효 시간 | 봉 마감 후 45분 (장애 복구 시 몰아 매수 방지) | `RSI_DCA_MAX_SIGNAL_AGE_MIN` |
| 모드 | `RSI_DCA_MODE=paper` 기본. **live는 OPS_MODE와 별개**로 명시 전환 | `RSI_DCA_MODE` |
| 킬스위치 | `data/spot/operations/rsi.kill` 존재 시 신규 매수 차단 | `RSI_DCA_KILL_SWITCH_PATH` |
```bash
python scripts/3_run_rsi_dca_backtest.py --days 90 # 신호·투입·평가 (docs/spot/3_operations/rsi_dca_backtest.json)
python scripts/3_run_rsi_dca.py # paper 1회 tick (첫 실행은 커서 초기화만, 매수 없음)
python scripts/3_run_rsi_dca.py --status # 상태 확인
touch data/spot/operations/rsi.kill # 긴급 차단
```
cron(1분): `scripts/crontab.bithumb.example`의 `3_run_rsi_dca_cron.sh` 줄 주석 해제 후 `bash scripts/install_crontab.sh --apply`.
live 전환은 `.env`에서 `RSI_DCA_MODE=live` 로 바꾼 뒤 cron 또는 `python scripts/3_run_rsi_dca.py --mode live` 를 **사용자가 직접** 실행한다.
체결·상태: `data/spot/operations/rsi_dca_state.json`, `docs/spot/3_operations/rsi_dca_report.json`, 텔레그램 알림. 모니터 차트(8766)에 매수 마커 표시.
### fractal watch 점검
```bash
@@ -502,6 +527,7 @@ OPS_DAILY_MAX_TRADES=20
## 변경 이력
- **2026-09-07:** 수집 대상 7종(XRP,TRX,WLD,SOL,ETH,ADA,SUI)·`VOL_MONITOR_SYMBOLS` 분리, 모니터 분봉 탭(`/api/candles`)·RSI(14) 패널, RSI DCA 정액 매수 전략(`rsi_dca_engine/runner`, 백테스트, cron 래퍼) 추가
- **2026-06-14:** ledger pending, exchange reconcile, max_age backlog, watch 5분 감시·조치, ops.tick.lock, README 전면 갱신
- **2026-06-13:** fractal_swing live — 슬리피지·sync·tail·텔레그램; ops_default sim **+1,873,140%**
- **2026-06-13:** 프로젝트명 Bithumb, 선물 파이프라인 제거

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

View File

@@ -189,6 +189,13 @@ class BithumbPrivateClient:
}
return self._request("POST", "/v1/orders", params=params)
def get_order(self, uuid: str) -> dict[str, Any]:
"""단일 주문 상세 (GET /v1/order) — executed_volume, executed_funds, paid_fee, trades."""
payload = self._request("GET", "/v1/order", params={"uuid": str(uuid)})
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
return payload["data"]
return payload if isinstance(payload, dict) else {}
def get_orders(
self,
market: str,

View File

@@ -42,6 +42,11 @@ def resolve_coin_name(symbol: str) -> str:
"TRX": "트론",
"NEAR": "니어프로토콜",
"WLD": "월드코인",
"XRP": "리플",
"SOL": "솔라나",
"ETH": "이더리움",
"ADA": "에이다",
"SUI": "수이",
}
return names.get(symbol.upper(), symbol.upper())
@@ -175,6 +180,25 @@ class Settings:
vol_monitor_json: Path
vol_monitor_html: Path
vol_monitor_days: int
vol_monitor_symbols: list[str]
vol_monitor_intervals: list[int]
vol_monitor_max_bars: int
vol_monitor_avg_price: dict[str, float]
# RSI DCA 정액 매수 (15m RSI 30/35 상향 돌파, 매도 없음)
rsi_dca_mode: str
rsi_dca_symbols: list[str]
rsi_dca_interval_min: int
rsi_dca_period: int
rsi_dca_levels: list[tuple[float, float]]
rsi_dca_levels_by_symbol: dict[str, list[tuple[float, float]]]
rsi_dca_daily_max_krw: float
rsi_dca_lookback_days: int
rsi_dca_max_bars_per_tick: int
rsi_dca_max_signal_age_min: int
rsi_dca_state_json: Path
rsi_dca_report_json: Path
rsi_dca_tick_lock_path: Path | None
rsi_dca_kill_switch_path: Path | None
telegram_bot_token: str
telegram_chat_id: str
ops_telegram_enabled: bool
@@ -444,6 +468,40 @@ def load_settings(env_path: Path | None = None) -> Settings:
)
),
vol_monitor_days=int(os.getenv("VOL_MONITOR_DAYS", "14")),
# 모니터 표시 종목 — 비우면 ops_symbols(매매 종목)
vol_monitor_symbols=_parse_symbol_list(os.getenv("VOL_MONITOR_SYMBOLS", "")),
# 모니터 분봉 탭 (분 단위) · 분봉당 최대 표시 봉수
vol_monitor_intervals=_parse_int_list(
os.getenv("VOL_MONITOR_INTERVALS", "1,3,5,10,15,30,60,240,1440")
),
vol_monitor_max_bars=int(os.getenv("VOL_MONITOR_MAX_BARS", "1500")),
# 수동 보유분 평균 매입가 (수익률 표시용). 예: "TRX:470,XRP:1900"
vol_monitor_avg_price=_parse_symbol_price_map(os.getenv("VOL_MONITOR_AVG_PRICE", "")),
# RSI DCA — 모드는 OPS_MODE와 분리 (기본 paper). live 전환은 RSI_DCA_MODE=live
rsi_dca_mode=os.getenv("RSI_DCA_MODE", "paper").strip().lower(),
rsi_dca_symbols=_parse_symbol_list(os.getenv("RSI_DCA_SYMBOLS", ""))
or [s for s in download_symbols if s != "BTC"],
rsi_dca_interval_min=int(os.getenv("RSI_DCA_INTERVAL_MIN", "15")),
rsi_dca_period=int(os.getenv("RSI_DCA_PERIOD", "14")),
rsi_dca_levels=_parse_rsi_levels(os.getenv("RSI_DCA_LEVELS", "30:20000,35:10000")),
# 종목별 오버라이드: "XRP=19:10000;TRX=32:10000" (없는 종목은 RSI_DCA_LEVELS)
rsi_dca_levels_by_symbol=_parse_rsi_levels_by_symbol(os.getenv("RSI_DCA_LEVELS_BY_SYMBOL", "")),
rsi_dca_daily_max_krw=float(os.getenv("RSI_DCA_DAILY_MAX_KRW", "60000")),
rsi_dca_lookback_days=int(os.getenv("RSI_DCA_LOOKBACK_DAYS", "20")),
rsi_dca_max_bars_per_tick=int(os.getenv("RSI_DCA_MAX_BARS_PER_TICK", "8")),
rsi_dca_max_signal_age_min=int(os.getenv("RSI_DCA_MAX_SIGNAL_AGE_MIN", "45")),
rsi_dca_state_json=_resolve_project_path(
os.getenv("RSI_DCA_STATE_JSON", "data/spot/operations/rsi_dca_state.json")
),
rsi_dca_report_json=_resolve_project_path(
os.getenv("RSI_DCA_REPORT_JSON", "docs/spot/3_operations/rsi_dca_report.json")
),
rsi_dca_tick_lock_path=_resolve_project_path_optional(
os.getenv("RSI_DCA_TICK_LOCK_PATH", "data/spot/operations/rsi.tick.lock")
),
rsi_dca_kill_switch_path=_resolve_project_path_optional(
os.getenv("RSI_DCA_KILL_SWITCH_PATH", "data/spot/operations/rsi.kill")
),
telegram_bot_token=os.getenv("COIN_TELEGRAM_BOT_TOKEN", "").strip(),
telegram_chat_id=os.getenv("COIN_TELEGRAM_CHAT_ID", "").strip(),
ops_telegram_enabled=_parse_ops_telegram_enabled(
@@ -454,6 +512,47 @@ def load_settings(env_path: Path | None = None) -> Settings:
)
def _parse_rsi_levels(raw: str) -> list[tuple[float, float]]:
"""'30:20000,35:10000' → [(30.0, 20000.0), (35.0, 10000.0)] (level 오름차순)."""
out: list[tuple[float, float]] = []
for part in (raw or "").split(","):
part = part.strip()
if not part:
continue
lv, _, krw = part.partition(":")
out.append((float(lv), float(krw)))
return sorted(out, key=lambda x: x[0])
def _parse_symbol_price_map(raw: str) -> dict[str, float]:
"""'TRX:470,XRP:1900' → {"TRX": 470.0, "XRP": 1900.0}."""
out: dict[str, float] = {}
for part in (raw or "").split(","):
part = part.strip()
if not part or ":" not in part:
continue
sym, _, px = part.partition(":")
try:
out[sym.strip().upper()] = float(px)
except ValueError:
continue
return out
def _parse_rsi_levels_by_symbol(raw: str) -> dict[str, list[tuple[float, float]]]:
"""'XRP=19:10000;TRX=32:10000,35:5000' → 종목별 레벨 dict."""
out: dict[str, list[tuple[float, float]]] = {}
for part in (raw or "").split(";"):
part = part.strip()
if not part or "=" not in part:
continue
sym, _, lv = part.partition("=")
levels = _parse_rsi_levels(lv)
if levels:
out[sym.strip().upper()] = levels
return out
def _parse_ops_telegram_enabled(raw: str, *, bot_token: str, chat_id: str) -> bool:
"""운영 텔레그램 알림 on/off.

View File

@@ -30,7 +30,12 @@ def load_candles(
"""
store = CandleStore(db_path)
try:
df = store.read_dataframe(symbol, interval_min)
since = None
if lookback_days is not None and lookback_days > 0:
_, _, db_max = store.get_range(symbol, interval_min)
if db_max is not None:
since = db_max - timedelta(days=lookback_days)
df = store.read_dataframe(symbol, interval_min, since=since)
finally:
store.close()
@@ -42,3 +47,17 @@ def load_candles(
df = df[df["datetime"] >= cutoff].reset_index(drop=True)
return df
def load_last_candles(
db_path: Path | str,
symbol: str,
interval_min: int,
last_n: int,
) -> pd.DataFrame:
"""최신 N봉만 로드 (차트 API용, SQL LIMIT)."""
store = CandleStore(db_path)
try:
return store.read_dataframe(symbol, interval_min, last_n=int(last_n))
finally:
store.close()

View File

@@ -78,18 +78,24 @@ class CandleStore:
``(row_count, min_dt, max_dt)``. 데이터 없으면 ``(0, None, None)``.
"""
table = self.table_name(symbol, interval_min)
code = symbol.upper()
try:
row = self._conn.execute(
f"SELECT COUNT(*), MIN(ymdhms), MAX(ymdhms) FROM {table} WHERE CODE = ?",
(symbol.upper(),),
# (CODE, ymdhms) 인덱스를 타는 ORDER BY … LIMIT 1 — 대형 테이블(수백만 행)에서도 즉시 응답
mn = self._conn.execute(
f"SELECT ymdhms FROM {table} WHERE CODE = ? ORDER BY ymdhms ASC LIMIT 1", (code,)
).fetchone()
if mn is None or mn[0] is None:
return 0, None, None
mx = self._conn.execute(
f"SELECT ymdhms FROM {table} WHERE CODE = ? ORDER BY ymdhms DESC LIMIT 1", (code,)
).fetchone()
cnt = self._conn.execute(
f"SELECT COUNT(*) FROM {table} WHERE CODE = ?", (code,)
).fetchone()
except sqlite3.OperationalError:
return 0, None, None
if row is None or row[0] == 0 or row[1] is None:
return 0, None, None
return int(row[0]), parse_kst_datetime(str(row[1])), parse_kst_datetime(str(row[2]))
return int(cnt[0] if cnt else 0), parse_kst_datetime(str(mn[0])), parse_kst_datetime(str(mx[0]))
def delete_incomplete_tail(
self,
@@ -116,28 +122,61 @@ class CandleStore:
self._conn.commit()
return cur.rowcount
def read_dataframe(self, symbol: str, interval_min: int) -> pd.DataFrame:
def get_max_datetime(self, symbol: str, interval_min: int) -> datetime | None:
"""마지막 봉 시각만 (인덱스 ORDER BY … LIMIT 1, COUNT 없이 즉시)."""
table = self.table_name(symbol, interval_min)
try:
row = self._conn.execute(
f"SELECT ymdhms FROM {table} WHERE CODE = ? ORDER BY ymdhms DESC LIMIT 1",
(symbol.upper(),),
).fetchone()
except sqlite3.OperationalError:
return None
return parse_kst_datetime(str(row[0])) if row and row[0] else None
def read_dataframe(
self,
symbol: str,
interval_min: int,
*,
since: datetime | str | None = None,
last_n: int | None = None,
) -> pd.DataFrame:
"""캔들을 pandas DataFrame으로 읽는다.
Args:
symbol: 코인 심볼.
interval_min: 분 단위 인터벌.
since: 이 시각 이상만 (SQL WHERE, 대형 테이블 전체 스캔 방지).
last_n: 최신 N행만 (SQL ORDER BY DESC LIMIT).
Returns:
소문자 OHLCV 컬럼 DataFrame. 테이블 없으면 빈 DataFrame.
소문자 OHLCV 컬럼 DataFrame(시간 오름차순). 테이블 없으면 빈 DataFrame.
"""
table = self.table_name(symbol, interval_min)
try:
raw = pd.read_sql_query(
f"""
params: list = [symbol.upper()]
where = "WHERE CODE = ?"
if since is not None:
since_s = since.strftime("%Y-%m-%d %H:%M:%S") if isinstance(since, datetime) else str(since)
where += " AND ymdhms >= ?"
params.append(since_s)
if last_n is not None and last_n > 0:
sql = f"""
SELECT ymdhms, Open, High, Low, Close, Volume FROM (
SELECT ymdhms, Open, High, Low, Close, Volume
FROM {table}
WHERE CODE = ?
FROM {table} {where}
ORDER BY ymdhms DESC LIMIT ?
) ORDER BY ymdhms ASC
"""
params.append(int(last_n))
else:
sql = f"""
SELECT ymdhms, Open, High, Low, Close, Volume
FROM {table} {where}
ORDER BY ymdhms ASC
""",
self._conn,
params=(symbol.upper(),),
)
"""
try:
raw = pd.read_sql_query(sql, self._conn, params=tuple(params))
except Exception:
return pd.DataFrame(
columns=["datetime", "open", "high", "low", "close", "volume"]

View File

@@ -0,0 +1,90 @@
"""캔들 공백 자동 백필 계획 — 절전·재부팅 후 증분 수집 범위(200봉)를 넘는 공백을 감지한다.
증분 수집은 인터벌당 최신 200봉만 받으므로, DB 마지막 봉과 현재 시각의 차이가
``200 × interval × 안전계수`` 를 넘으면 그 인터벌은 풀 다운(--full --days N)으로 채워야 한다.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import datetime
from typing import Iterable
@dataclass(frozen=True)
class BackfillPlan:
"""백필 계획 — 심볼별 (인터벌 → 필요 일수)."""
needs: dict[str, dict[int, int]] = field(default_factory=dict) # {symbol: {interval_min: days}}
@property
def empty(self) -> bool:
return not self.needs
def symbols(self) -> list[str]:
return sorted(self.needs)
def intervals(self) -> list[int]:
out: set[int] = set()
for m in self.needs.values():
out.update(m)
return sorted(out)
def max_days(self) -> int:
return max((d for m in self.needs.values() for d in m.values()), default=0)
def describe(self) -> str:
parts = []
for sym in self.symbols():
iv_txt = ", ".join(f"{iv}m→{days}d" for iv, days in sorted(self.needs[sym].items()))
parts.append(f"{sym}[{iv_txt}]")
return " ".join(parts) if parts else "no gap"
def coverage_minutes(interval_min: int, *, batch_size: int = 200, safety: float = 0.9) -> float:
"""증분 1회(batch_size봉)가 덮는 시간(분) × 안전계수."""
return batch_size * int(interval_min) * safety
def days_needed(lag_minutes: float, *, extra_days: int = 1) -> int:
"""공백(분)을 채우기 위한 풀 다운 일수 (여유 extra_days 포함, 최소 1)."""
return max(1, math.ceil(lag_minutes / 1440.0) + int(extra_days))
def plan_backfill(
db_max_by_symbol_interval: dict[str, dict[int, datetime | None]],
*,
now: datetime,
intervals: Iterable[int],
batch_size: int = 200,
safety: float = 0.9,
max_days: int = 30,
) -> BackfillPlan:
"""DB 마지막 봉 시각으로 백필 필요 여부·일수를 계산한다.
Args:
db_max_by_symbol_interval: {symbol: {interval_min: 마지막 봉 시각 or None}}.
now: 기준 시각.
intervals: 점검할 인터벌 목록.
batch_size: 증분 1회 봉 수(빗썸 200).
safety: 커버리지 안전계수(0.9 → 200봉의 90% 넘으면 백필).
max_days: 백필 상한 일수(그 이상 공백은 상한만큼만).
Returns:
BackfillPlan. 데이터가 전혀 없는 (symbol, interval)은 최초 적재 대상이 아니므로 제외한다.
"""
needs: dict[str, dict[int, int]] = {}
for sym, by_iv in db_max_by_symbol_interval.items():
for iv in intervals:
db_max = by_iv.get(iv)
if db_max is None:
continue # 미적재 테이블은 별도 초기 적재(--full) 대상
lag_min = (now - db_max).total_seconds() / 60.0
# 마지막 봉 이후 아직 마감되지 않은 1봉은 공백이 아님
lag_min -= iv
if lag_min <= coverage_minutes(iv, batch_size=batch_size, safety=safety):
continue
days = min(days_needed(lag_min), max_days)
needs.setdefault(sym.upper(), {})[int(iv)] = days
return BackfillPlan(needs=needs)

View File

@@ -0,0 +1,142 @@
"""RSI DCA 온/오프 제어 — 킬스위치 파일 기반 (모니터 UI·API용)."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
TICK_ALIVE_SEC = 180 # 마지막 tick 이후 이 시간 내면 '가동 중'
def _load_json(path: Path | None) -> dict[str, Any]:
if not path:
return {}
p = Path(path)
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return {}
def rsi_enabled(settings: Any) -> bool:
"""킬스위치 파일이 없으면 ON."""
p = getattr(settings, "rsi_dca_kill_switch_path", None)
return not (p is not None and Path(p).exists())
def set_rsi_enabled(settings: Any, enabled: bool) -> dict[str, Any]:
"""ON → 킬스위치 삭제, OFF → 킬스위치 생성. 결과 status 반환."""
p = getattr(settings, "rsi_dca_kill_switch_path", None)
if p is None:
return {"ok": False, "error": "RSI_DCA_KILL_SWITCH_PATH 미설정"}
path = Path(p)
path.parent.mkdir(parents=True, exist_ok=True)
if enabled:
if path.exists():
path.unlink()
else:
path.write_text(
f"off by monitor {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n",
encoding="utf-8",
)
st = rsi_status(settings)
st["changed"] = True
return st
def _env_file_mode(settings: Any) -> str | None:
""".env 파일의 RSI_DCA_MODE를 매번 직접 읽는다.
장기 실행 서버는 기동 시 os.environ에 올라간 값이 고정되어(load_dotenv override=False)
.env 변경이 반영되지 않으므로, 파일을 직접 파싱해 현재 값을 보여준다.
"""
env_path = getattr(settings, "env_path", None)
if not env_path:
env_path = Path(__file__).resolve().parents[3] / ".env"
try:
for line in Path(env_path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("RSI_DCA_MODE="):
val = line.split("=", 1)[1].split("#", 1)[0].strip().strip('"').strip("'").lower()
if val in ("paper", "live"):
return val
except Exception: # noqa: BLE001
return None
return None
def html_build_stamp(settings: Any) -> str | None:
"""서버가 제공하는 모니터 HTML의 PAGE_BUILD 값 (탭 자동 새로고침 판단용)."""
path = getattr(settings, "vol_monitor_html", None)
if not path:
return None
try:
import re
text = Path(path).read_text(encoding="utf-8")
m = re.search(r'const PAGE_BUILD = "([^"]+)"', text)
return m.group(1) if m else None
except Exception: # noqa: BLE001
return None
def rsi_status(settings: Any, *, now: datetime | None = None) -> dict[str, Any]:
"""모니터 표시용 상태 요약."""
now = now or datetime.now()
state = _load_json(getattr(settings, "rsi_dca_state_json", None))
report = _load_json(getattr(settings, "rsi_dca_report_json", None))
daily = state.get("daily") or {}
totals = state.get("totals") or {}
last_run = state.get("last_run_at") or report.get("last_run_at")
tick_age = None
if last_run:
try:
tick_age = (now - datetime.strptime(str(last_run)[:19], "%Y-%m-%d %H:%M:%S")).total_seconds()
except ValueError:
tick_age = None
# 실행 중 규칙(러너가 기록) 우선 — 서버 프로세스의 env 캐시 회피
daily_max = float(state.get("daily_max_krw") or getattr(settings, "rsi_dca_daily_max_krw", 0) or 0)
today = now.strftime("%Y-%m-%d")
spent_today = float(daily.get("spent_krw") or 0.0) if daily.get("date") == today else 0.0
symbols = {}
for sym, s in (state.get("symbols") or {}).items():
symbols[sym] = {
"rsi": s.get("last_rsi"),
"cursor": s.get("last_confirm_time"),
"buys": s.get("buy_count", 0),
"spent_krw": s.get("spent_krw", 0.0),
"initialized": s.get("initialized", False),
}
trades = list(state.get("trades") or [])[-5:]
return {
"ok": True,
"enabled": rsi_enabled(settings),
"mode": _env_file_mode(settings) or str(getattr(settings, "rsi_dca_mode", "paper") or "paper"),
"state_mode": state.get("mode"),
"last_run_at": last_run,
"tick_age_sec": None if tick_age is None else int(tick_age),
"tick_alive": tick_age is not None and 0 <= tick_age <= TICK_ALIVE_SEC,
"daily": {
"date": today,
"spent_krw": spent_today,
"max_krw": daily_max,
"remaining_krw": max(daily_max - spent_today, 0.0),
"count": int(daily.get("count") or 0) if daily.get("date") == today else 0,
},
"totals": {
"spent_krw": float(totals.get("spent_krw") or 0.0),
"count": int(totals.get("count") or 0),
},
# 실제 실행 중인 규칙은 러너가 state에 기록한 값을 우선 (서버 프로세스의 env 캐시 회피)
"levels": state.get("levels") or [list(x) for x in (getattr(settings, "rsi_dca_levels", None) or [])],
"levels_by_symbol": state.get("levels_by_symbol") or {},
"interval_min": state.get("interval_min") or getattr(settings, "rsi_dca_interval_min", None),
"symbols": symbols,
"recent_trades": trades,
"kill_switch_path": str(getattr(settings, "rsi_dca_kill_switch_path", "") or ""),
"html_build": html_build_stamp(settings),
}

View File

@@ -0,0 +1,518 @@
"""RSI DCA 현물 정액 매수 — 15분봉 RSI가 기준선을 상향 돌파하는 봉 마감에 고정 금액 매수.
규칙(2026-09-07 확정):
- RSI(14) 종가 확정 기준. prev <= level < now 이면 해당 level 매수.
- 30 상향 돌파 20,000원, 35 상향 돌파 10,000원. 두 조건은 독립적으로 모두 발생 가능.
- 일(KST) 총 매수 상한 60,000원. 상한을 넘기는 주문은 스킵.
- 자동 매도 없음 (보유분은 수동 관리).
"""
from __future__ import annotations
import json
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
STRATEGY = "rsi_dca_15m_spot_buy"
# 매수 실행 콜백: (symbol, krw, ref_price) -> {"ok", "order_krw", "order_coin", "price", "error", "api_response"}
BuyFn = Callable[[str, float, float], dict[str, Any]]
# ---------------------------------------------------------------------------
# 지표
# ---------------------------------------------------------------------------
def _rsi_value(avg_gain: float, avg_loss: float) -> float:
if avg_loss == 0:
return 100.0
if avg_gain == 0:
return 0.0
return 100.0 - 100.0 / (1.0 + avg_gain / avg_loss)
def wilder_rsi(close: pd.Series | list[float], period: int = 14) -> pd.Series:
"""Wilder RSI — 첫 평균은 단순평균, 이후 (prev*(n-1)+cur)/n.
모니터 차트(JS computeRSI)와 동일한 수식·시드를 사용한다.
index period 이전 값은 NaN.
"""
series = pd.Series(close, dtype="float64")
values = series.to_numpy()
n = len(values)
out = np.full(n, np.nan)
if n <= period or period <= 0:
return pd.Series(out, index=series.index)
deltas = np.diff(values)
gains = np.where(deltas > 0, deltas, 0.0)
losses = np.where(deltas < 0, -deltas, 0.0)
avg_g = float(gains[:period].mean())
avg_l = float(losses[:period].mean())
out[period] = _rsi_value(avg_g, avg_l)
for i in range(period + 1, n):
avg_g = (avg_g * (period - 1) + gains[i - 1]) / period
avg_l = (avg_l * (period - 1) + losses[i - 1]) / period
out[i] = _rsi_value(avg_g, avg_l)
return pd.Series(out, index=series.index)
def closed_candles(df: pd.DataFrame, interval_min: int, now: datetime) -> pd.DataFrame:
"""마감된 봉만 남긴다 (봉 시작 + interval <= now)."""
if df.empty:
return df
out = df.copy()
out["datetime"] = pd.to_datetime(out["datetime"])
cutoff = pd.Timestamp(now) - pd.Timedelta(minutes=interval_min)
out = out[out["datetime"] <= cutoff]
return out.reset_index(drop=True)
def cross_up_levels(
prev_rsi: float,
cur_rsi: float,
levels: list[tuple[float, float]],
) -> list[tuple[float, float]]:
"""prev <= level < cur 를 만족하는 (level, krw) 목록 (level 오름차순)."""
if prev_rsi is None or cur_rsi is None:
return []
if math.isnan(prev_rsi) or math.isnan(cur_rsi):
return []
hits = [(lv, krw) for lv, krw in levels if prev_rsi <= lv < cur_rsi]
return sorted(hits, key=lambda x: x[0])
def parse_levels_by_symbol(raw: str) -> dict[str, list[tuple[float, float]]]:
"""'XRP=19:10000;TRX=32:10000,35:5000' → {"XRP": [(19,10000)], "TRX": [(32,10000),(35,5000)]}."""
out: dict[str, list[tuple[float, float]]] = {}
for part in (raw or "").split(";"):
part = part.strip()
if not part or "=" not in part:
continue
sym, _, lv = part.partition("=")
levels = parse_levels(lv)
if levels:
out[sym.strip().upper()] = levels
return out
def parse_levels(raw: str) -> list[tuple[float, float]]:
"""'30:20000,35:10000' → [(30.0, 20000.0), (35.0, 10000.0)]."""
out: list[tuple[float, float]] = []
for part in (raw or "").split(","):
part = part.strip()
if not part:
continue
lv, _, krw = part.partition(":")
out.append((float(lv), float(krw)))
return sorted(out, key=lambda x: x[0])
# ---------------------------------------------------------------------------
# 설정·상태
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class RsiDcaConfig:
"""전략 파라미터."""
symbols: list[str]
interval_min: int = 15
period: int = 14
levels: list[tuple[float, float]] = field(default_factory=lambda: [(30.0, 20000.0), (35.0, 10000.0)])
daily_max_krw: float = 60000.0
lookback_days: int = 20
max_bars_per_tick: int = 8
max_signal_age_min: int = 45
min_order_krw: float = 5000.0
fee_rate: float = 0.0005
slippage_rate: float = 0.0005
fee_lock_rate: float = 0.0025
# 종목별 레벨 오버라이드 {"TRX": [(32, 10000)], ...} — 없으면 levels 사용
levels_by_symbol: dict[str, list[tuple[float, float]]] = field(default_factory=dict)
def levels_for(self, symbol: str) -> list[tuple[float, float]]:
"""종목별 레벨 (없으면 공통 levels)."""
ov = (self.levels_by_symbol or {}).get(str(symbol).upper())
return list(ov) if ov else list(self.levels)
def _default_sym_state() -> dict[str, Any]:
return {
"initialized": False,
"last_confirm_time": None,
"last_rsi": None,
"last_price": 0.0,
"buy_count": 0,
"spent_krw": 0.0,
"coin_qty_est": 0.0,
}
def empty_state(mode: str) -> dict[str, Any]:
return {
"strategy": STRATEGY,
"mode": mode,
"symbols": {},
"daily": {"date": None, "spent_krw": 0.0, "count": 0},
"totals": {"spent_krw": 0.0, "count": 0},
"trades": [],
"events": [],
"last_run_at": None,
}
def load_state(path: Path, mode: str) -> dict[str, Any]:
if not path.exists():
return empty_state(mode)
with path.open(encoding="utf-8") as f:
state = json.load(f)
base = empty_state(mode)
for k, v in base.items():
state.setdefault(k, v)
return state
def save_state(path: Path, state: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
tmp.replace(path)
def paper_buy_fn(cfg: RsiDcaConfig) -> BuyFn:
"""paper 체결 — 슬리피지·수수료 반영 추정 수량."""
def _buy(symbol: str, krw: float, ref_price: float) -> dict[str, Any]:
px = float(ref_price) * (1.0 + cfg.slippage_rate)
if px <= 0:
return {"ok": False, "error": "price<=0"}
order_krw = float(math.floor(krw))
coin = order_krw * (1.0 - cfg.fee_rate) / px
return {"ok": True, "order_krw": order_krw, "order_coin": coin, "price": px, "api_response": None}
return _buy
def apply_fill_to_trade(rec: dict[str, Any], order: dict[str, Any]) -> bool:
"""거래소 주문 상세(executed_funds/volume/paid_fee)로 기록의 체결가·수량·수수료를 실제값으로 보정.
Returns:
보정 완료(주문 done·체결량>0) 여부.
"""
try:
vol = float(order.get("executed_volume") or 0.0)
funds = float(order.get("executed_funds") or 0.0)
fee = float(order.get("paid_fee") or 0.0)
except (TypeError, ValueError):
return False
state = str(order.get("state") or "")
if vol <= 0 or funds <= 0:
return False
rec.setdefault("price_ref", rec.get("price"))
rec["order_coin"] = vol
rec["order_krw"] = funds
rec["price"] = funds / vol # 실제 평균 체결가
rec["fee_krw"] = fee
rec["fill_reconciled"] = state == "done"
rec["fill_state"] = state
return state == "done"
# ---------------------------------------------------------------------------
# 엔진
# ---------------------------------------------------------------------------
@dataclass
class SymbolTickResult:
symbol: str
note: str
processed_bars: int = 0
fills: int = 0
last_rsi: float | None = None
last_price: float = 0.0
trade_records: list[dict[str, Any]] = field(default_factory=list)
class RsiDcaEngine:
"""상태(state dict)를 갱신하며 종목별 tick을 처리한다."""
def __init__(
self,
cfg: RsiDcaConfig,
state: dict[str, Any],
*,
mode: str,
buy_fn: BuyFn,
available_cash_fn: Callable[[], float | None] | None = None,
) -> None:
self.cfg = cfg
self.state = state
self.mode = mode
self._buy = buy_fn
self._avail_cash = available_cash_fn
# -- 상태 헬퍼 -----------------------------------------------------------
def sym_state(self, symbol: str) -> dict[str, Any]:
root = self.state.setdefault("symbols", {})
st = root.setdefault(symbol.upper(), _default_sym_state())
for k, v in _default_sym_state().items():
st.setdefault(k, v)
return st
def _daily(self, now: datetime) -> dict[str, Any]:
d = self.state.setdefault("daily", {"date": None, "spent_krw": 0.0, "count": 0})
today = now.strftime("%Y-%m-%d")
if d.get("date") != today:
d["date"] = today
d["spent_krw"] = 0.0
d["count"] = 0
return d
def daily_remaining_krw(self, now: datetime) -> float:
d = self._daily(now)
return max(self.cfg.daily_max_krw - float(d.get("spent_krw") or 0.0), 0.0)
def _push_event(self, rec: dict[str, Any], *, keep: int = 300) -> None:
events = self.state.setdefault("events", [])
events.append(rec)
if len(events) > keep:
del events[: len(events) - keep]
def _push_trade(self, rec: dict[str, Any], *, keep: int = 2000) -> None:
trades = self.state.setdefault("trades", [])
trades.append(rec)
if len(trades) > keep:
del trades[: len(trades) - keep]
# -- 핵심 ------------------------------------------------------------------
def process_symbol(
self,
symbol: str,
df_closed: pd.DataFrame,
*,
now: datetime,
block_entry: bool = False,
) -> SymbolTickResult:
"""마감 봉 DataFrame(datetime, close …)으로 신규 봉을 판정·매수한다."""
sym = symbol.upper()
st = self.sym_state(sym)
cfg = self.cfg
if df_closed.empty or len(df_closed) <= cfg.period + 1:
return SymbolTickResult(symbol=sym, note="no_data")
df = df_closed.copy()
df["datetime"] = pd.to_datetime(df["datetime"])
rsi = wilder_rsi(df["close"].astype(float), cfg.period)
last_idx = len(df) - 1
last_time = df["datetime"].iloc[last_idx]
last_price = float(df["close"].iloc[last_idx])
last_rsi = float(rsi.iloc[last_idx]) if not math.isnan(rsi.iloc[last_idx]) else None
if not st.get("initialized"):
st["initialized"] = True
st["last_confirm_time"] = str(last_time)[:19]
st["last_rsi"] = last_rsi
st["last_price"] = last_price
return SymbolTickResult(
symbol=sym, note=f"initialized {str(last_time)[:19]}",
last_rsi=last_rsi, last_price=last_price,
)
cursor = pd.Timestamp(st["last_confirm_time"]) if st.get("last_confirm_time") else None
new_idx = [i for i in range(len(df)) if cursor is None or df["datetime"].iloc[i] > cursor]
if not new_idx:
st["last_rsi"] = last_rsi
st["last_price"] = last_price
return SymbolTickResult(
symbol=sym, note=f"no_new_bar {str(last_time)[:19]}",
last_rsi=last_rsi, last_price=last_price,
)
if len(new_idx) > cfg.max_bars_per_tick:
skipped = len(new_idx) - cfg.max_bars_per_tick
self._push_event({
"ts": now.strftime("%Y-%m-%d %H:%M:%S"), "symbol": sym,
"type": "catchup_truncated", "skipped_bars": skipped,
})
new_idx = new_idx[-cfg.max_bars_per_tick:]
result = SymbolTickResult(symbol=sym, note="", last_rsi=last_rsi, last_price=last_price)
notes: list[str] = []
for i in new_idx:
if i == 0:
st["last_confirm_time"] = str(df["datetime"].iloc[i])[:19]
continue
bar_open = df["datetime"].iloc[i]
bar_close = bar_open + pd.Timedelta(minutes=cfg.interval_min)
prev_rsi = float(rsi.iloc[i - 1]) if not math.isnan(rsi.iloc[i - 1]) else None
cur_rsi = float(rsi.iloc[i]) if not math.isnan(rsi.iloc[i]) else None
price = float(df["close"].iloc[i])
result.processed_bars += 1
hits = cross_up_levels(prev_rsi, cur_rsi, cfg.levels_for(sym)) if prev_rsi is not None and cur_rsi is not None else []
for level, krw in hits:
bar_key = str(bar_open)[:19]
base = {
"ts": now.strftime("%Y-%m-%d %H:%M:%S"), "symbol": sym, "bar_time": bar_key,
"level": level, "krw": krw, "rsi_prev": round(prev_rsi, 2), "rsi": round(cur_rsi, 2),
}
age_min = (pd.Timestamp(now) - bar_close).total_seconds() / 60.0
if age_min > cfg.max_signal_age_min:
self._push_event({**base, "type": "expired", "age_min": round(age_min, 1)})
notes.append(f"expired L{level:g} {bar_key}")
continue
if block_entry:
self._push_event({**base, "type": "kill_switch"})
notes.append(f"kill_switch L{level:g} {bar_key}")
continue
remaining = self.daily_remaining_krw(now)
if krw > remaining + 1e-9:
self._push_event({**base, "type": "daily_cap", "remaining_krw": remaining})
notes.append(f"daily_cap L{level:g} {bar_key}")
continue
if krw < cfg.min_order_krw:
self._push_event({**base, "type": "below_min_order"})
notes.append(f"below_min L{level:g}")
continue
if self._avail_cash is not None:
avail = self._avail_cash()
need = krw * (1.0 + cfg.fee_lock_rate)
if avail is not None and avail < need:
self._push_event({**base, "type": "insufficient_cash", "available_krw": avail})
notes.append(f"no_cash L{level:g} {bar_key}")
continue
try:
fill = self._buy(sym, krw, price)
except Exception as exc: # noqa: BLE001
logger.exception("rsi_dca buy failed %s", sym)
fill = {"ok": False, "error": str(exc)}
if not fill.get("ok"):
self._push_event({**base, "type": "buy_failed", "error": str(fill.get("error"))})
notes.append(f"buy_fail L{level:g} {bar_key}")
continue
order_krw = float(fill.get("order_krw") or krw)
order_coin = float(fill.get("order_coin") or 0.0)
fill_px = float(fill.get("price") or price)
rec = {
"symbol": sym, "side": "buy", "ts": str(bar_close)[:19], "bar_time": bar_key,
"price": fill_px, "price_ref": price, "order_krw": order_krw, "order_coin": order_coin,
"fill_reconciled": bool(fill.get("fill_reconciled")) or self.mode != "live",
"fee_krw": fill.get("fee_krw"),
"level": level, "rsi_prev": round(prev_rsi, 2), "rsi": round(cur_rsi, 2),
"reason": f"rsi_cross_up_{level:g}", "mode": self.mode,
"executed_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"api_response": fill.get("api_response"),
}
self._push_trade(rec)
result.trade_records.append(rec)
result.fills += 1
d = self._daily(now)
d["spent_krw"] = float(d.get("spent_krw") or 0.0) + order_krw
d["count"] = int(d.get("count") or 0) + 1
t = self.state.setdefault("totals", {"spent_krw": 0.0, "count": 0})
t["spent_krw"] = float(t.get("spent_krw") or 0.0) + order_krw
t["count"] = int(t.get("count") or 0) + 1
st["buy_count"] = int(st.get("buy_count") or 0) + 1
st["spent_krw"] = float(st.get("spent_krw") or 0.0) + order_krw
st["coin_qty_est"] = float(st.get("coin_qty_est") or 0.0) + order_coin
notes.append(f"buy L{level:g} {order_krw:,.0f}원 {bar_key}")
st["last_confirm_time"] = str(bar_open)[:19]
st["last_rsi"] = cur_rsi
st["last_price"] = price
result.note = "; ".join(notes) if notes else f"no_signal {str(last_time)[:19]} rsi={last_rsi:.1f}" if last_rsi is not None else "no_signal"
return result
# ---------------------------------------------------------------------------
# 백테스트 (종목 병합 · 일 상한 공유)
# ---------------------------------------------------------------------------
def backtest_rsi_dca(
candles_by_symbol: dict[str, pd.DataFrame],
cfg: RsiDcaConfig,
*,
days: int | None = None,
) -> dict[str, Any]:
"""15m 종가 기준 RSI 교차 정액 매수 재생. 일 상한은 전 종목 공유(시간순)."""
rows: list[dict[str, Any]] = []
last_price: dict[str, float] = {}
for sym, df in candles_by_symbol.items():
if df is None or df.empty:
continue
d = df.copy()
d["datetime"] = pd.to_datetime(d["datetime"])
d = d.sort_values("datetime").reset_index(drop=True)
rsi = wilder_rsi(d["close"].astype(float), cfg.period)
start = d["datetime"].max() - pd.Timedelta(days=days) if days else None
last_price[sym.upper()] = float(d["close"].iloc[-1])
for i in range(1, len(d)):
if start is not None and d["datetime"].iloc[i] < start:
continue
p, c = rsi.iloc[i - 1], rsi.iloc[i]
if math.isnan(p) or math.isnan(c):
continue
for level, krw in cross_up_levels(float(p), float(c), cfg.levels_for(sym)):
rows.append({
"symbol": sym.upper(), "bar_time": d["datetime"].iloc[i],
"level": level, "krw": krw, "rsi_prev": float(p), "rsi": float(c),
"price": float(d["close"].iloc[i]),
})
rows.sort(key=lambda r: (r["bar_time"], r["symbol"], r["level"]))
daily_date = None
daily_spent = 0.0
trades: list[dict[str, Any]] = []
skipped_cap = 0
per_sym: dict[str, dict[str, float]] = {}
for r in rows:
day = r["bar_time"].strftime("%Y-%m-%d")
if day != daily_date:
daily_date, daily_spent = day, 0.0
if r["krw"] > cfg.daily_max_krw - daily_spent + 1e-9:
skipped_cap += 1
continue
px = r["price"] * (1.0 + cfg.slippage_rate)
coin = r["krw"] * (1.0 - cfg.fee_rate) / px
daily_spent += r["krw"]
trades.append({**r, "bar_time": str(r["bar_time"])[:19], "fill_price": px, "order_coin": coin})
ps = per_sym.setdefault(r["symbol"], {"signals": 0, "buys": 0, "spent_krw": 0.0, "coin": 0.0})
ps["buys"] += 1
ps["spent_krw"] += r["krw"]
ps["coin"] += coin
for r in rows:
per_sym.setdefault(r["symbol"], {"signals": 0, "buys": 0, "spent_krw": 0.0, "coin": 0.0})["signals"] += 1
total_spent = sum(v["spent_krw"] for v in per_sym.values())
total_value = 0.0
for sym, v in per_sym.items():
v["value_krw"] = v["coin"] * last_price.get(sym, 0.0)
v["pnl_pct"] = (v["value_krw"] / v["spent_krw"] - 1.0) * 100.0 if v["spent_krw"] > 0 else 0.0
total_value += v["value_krw"]
span_days = None
if rows:
span_days = max((rows[-1]["bar_time"] - rows[0]["bar_time"]).days, 1)
return {
"strategy": STRATEGY,
"config": {
"levels": cfg.levels, "levels_by_symbol": cfg.levels_by_symbol,
"daily_max_krw": cfg.daily_max_krw, "period": cfg.period,
"interval_min": cfg.interval_min, "fee_rate": cfg.fee_rate, "slippage_rate": cfg.slippage_rate,
},
"days": days, "span_days": span_days,
"signals": len(rows), "buys": len(trades), "skipped_daily_cap": skipped_cap,
"total_spent_krw": total_spent, "total_value_krw": total_value,
"total_pnl_pct": (total_value / total_spent - 1.0) * 100.0 if total_spent > 0 else 0.0,
"avg_daily_spent_krw": total_spent / span_days if span_days else 0.0,
"per_symbol": per_sym,
"trades": trades,
}

View File

@@ -0,0 +1,240 @@
"""RSI DCA 러너 — 설정 로드, 거래소 클라이언트, lock, 텔레그램, 상태 저장."""
from __future__ import annotations
import json
import logging
import math
from datetime import datetime
from pathlib import Path
from typing import Any
from bithumb.api.bithumb_private import BithumbPrivateClient
from bithumb.config import Settings
from bithumb.data.candle_loader import load_candles
from bithumb.notifications.telegram import create_telegram_notifier
from bithumb.operations.ops_lock import ops_tick_lock
from bithumb.operations.rsi_dca_engine import (
STRATEGY,
BuyFn,
RsiDcaConfig,
RsiDcaEngine,
apply_fill_to_trade,
closed_candles,
load_state,
paper_buy_fn,
save_state,
)
logger = logging.getLogger(__name__)
def config_from_settings(settings: Settings) -> RsiDcaConfig:
return RsiDcaConfig(
symbols=list(settings.rsi_dca_symbols),
interval_min=settings.rsi_dca_interval_min,
period=settings.rsi_dca_period,
levels=list(settings.rsi_dca_levels),
daily_max_krw=settings.rsi_dca_daily_max_krw,
lookback_days=settings.rsi_dca_lookback_days,
max_bars_per_tick=settings.rsi_dca_max_bars_per_tick,
max_signal_age_min=settings.rsi_dca_max_signal_age_min,
min_order_krw=settings.ops_min_order_krw,
fee_rate=settings.gt_trading_fee_rate,
slippage_rate=settings.ops_slippage_rate,
fee_lock_rate=settings.ops_exchange_fee_lock_rate,
levels_by_symbol=dict(getattr(settings, "rsi_dca_levels_by_symbol", None) or {}),
)
def live_buy_fn(client: BithumbPrivateClient, cfg: RsiDcaConfig) -> BuyFn:
"""빗썸 시장가 매수(원화 금액). 수량은 참조가 기준 추정치."""
def _buy(symbol: str, krw: float, ref_price: float) -> dict[str, Any]:
market = f"KRW-{symbol.upper()}"
order_krw = float(math.floor(krw))
resp = client.market_buy_krw(market, order_krw)
px = float(ref_price) if ref_price > 0 else 0.0
coin_est = order_krw * (1.0 - cfg.fee_rate) / px if px > 0 else 0.0
out = {"ok": True, "order_krw": order_krw, "order_coin": coin_est, "price": px, "api_response": resp}
# 시장가는 즉시 체결되므로 상세 조회로 실제 체결가·수량 반영 (실패 시 추정값 유지, 다음 tick에서 보정)
uuid = (resp or {}).get("uuid") if isinstance(resp, dict) else None
if uuid:
try:
order = client.get_order(str(uuid))
tmp: dict[str, Any] = {}
if apply_fill_to_trade(tmp, order):
out.update({"order_krw": tmp["order_krw"], "order_coin": tmp["order_coin"],
"price": tmp["price"], "fee_krw": tmp["fee_krw"], "fill_reconciled": True})
except Exception as exc: # noqa: BLE001
logger.warning("체결 상세 조회 실패 %s: %s", uuid, exc)
return out
return _buy
class RsiDcaRunner:
"""7종 RSI 정액 매수 tick."""
def __init__(self, settings: Settings, *, mode: str | None = None) -> None:
self.settings = settings
self.mode = (mode or settings.rsi_dca_mode or "paper").lower()
self.cfg = config_from_settings(settings)
self.state = load_state(settings.rsi_dca_state_json, self.mode)
self.state["strategy"] = STRATEGY
self.state["mode"] = self.mode
# 인터벌이 바뀌면 이전 인터벌 커서로 소급 판정하지 않도록 종목 커서 재초기화
prev_iv = self.state.get("interval_min")
if prev_iv is not None and int(prev_iv) != int(self.cfg.interval_min):
for sym_st in (self.state.get("symbols") or {}).values():
sym_st["initialized"] = False
sym_st["last_confirm_time"] = None
logger.warning("rsi_dca interval %s→%s: 종목 커서 재초기화", prev_iv, self.cfg.interval_min)
self.state.setdefault("events", []).append({
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "type": "interval_changed",
"from": prev_iv, "to": self.cfg.interval_min,
})
self.state["interval_min"] = int(self.cfg.interval_min)
self.state["daily_max_krw"] = float(self.cfg.daily_max_krw)
self.state["levels"] = [list(x) for x in self.cfg.levels]
self.state["levels_by_symbol"] = {k: [list(x) for x in v] for k, v in self.cfg.levels_by_symbol.items()}
self._client: BithumbPrivateClient | None = None
if self.mode == "live":
if not settings.bithumb_access_key or not settings.bithumb_secret_key:
raise RuntimeError("live: BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 필요")
self._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,
)
buy_fn = live_buy_fn(self._client, self.cfg)
avail_fn = self._available_krw
else:
buy_fn = paper_buy_fn(self.cfg)
avail_fn = None
self.engine = RsiDcaEngine(
self.cfg, self.state, mode=self.mode, buy_fn=buy_fn, available_cash_fn=avail_fn,
)
self.telegram = create_telegram_notifier(
settings.telegram_bot_token,
settings.telegram_chat_id,
enabled=settings.ops_telegram_enabled,
)
# -- 헬퍼 ----------------------------------------------------------------
def _available_krw(self) -> float | None:
if self._client is None:
return None
try:
avail, _ = self._client.get_balance("KRW")
return float(avail)
except Exception as exc: # noqa: BLE001
logger.warning("KRW 잔고 조회 실패: %s", exc)
return None
def _reconcile_fills(self, *, max_orders: int = 10) -> int:
"""live 매수 기록 중 실제 체결가 미반영 건을 거래소 주문 상세로 보정."""
if self._client is None:
return 0
fixed = 0
for rec in reversed(self.state.get("trades") or []):
if rec.get("fill_reconciled") or rec.get("mode") != "live":
continue
resp = rec.get("api_response")
uuid = resp.get("uuid") if isinstance(resp, dict) else None
if not uuid:
continue
try:
order = self._client.get_order(str(uuid))
except Exception as exc: # noqa: BLE001
logger.warning("체결 보정 조회 실패 %s: %s", uuid, exc)
continue
if apply_fill_to_trade(rec, order):
fixed += 1
logger.info("체결 보정 %s %s: price=%.4f coin=%.6f fee=%s", rec.get("symbol"), uuid, rec["price"], rec["order_coin"], rec.get("fee_krw"))
max_orders -= 1
if max_orders <= 0:
break
return fixed
def _kill_switch_active(self) -> bool:
p = self.settings.rsi_dca_kill_switch_path
return p is not None and Path(p).exists()
def _notify_trade(self, rec: dict[str, Any], now: datetime) -> None:
if not self.telegram.is_active:
return
d = self.state.get("daily") or {}
mode_txt = "실거래" if self.mode == "live" else "페이퍼"
text = (
f"[{mode_txt}] RSI 정액 매수\n"
f"{rec['symbol']}KRW · {self.cfg.interval_min}분 RSI {rec['rsi_prev']}→{rec['rsi']} "
f"({rec['level']:g} 상향 돌파)\n"
f"금액 {rec['order_krw']:,.0f}원 · 가격 {rec['price']:,.2f} · 수량 {rec['order_coin']:.4f}\n"
f"봉 {rec['bar_time']} · 오늘 누적 {float(d.get('spent_krw') or 0):,.0f}/{self.cfg.daily_max_krw:,.0f}원"
)
try:
self.telegram.send_message(text)
except Exception: # noqa: BLE001
logger.exception("텔레그램 알림 실패")
# -- tick -----------------------------------------------------------------
def tick(self, *, skip_lock: bool = False) -> dict[str, Any]:
lock_path = self.settings.rsi_dca_tick_lock_path
if lock_path and not skip_lock:
with ops_tick_lock(Path(lock_path), blocking=False) as acquired:
if not acquired:
return {"ok": False, "note": "lock_busy"}
return self._tick_impl()
return self._tick_impl()
def _tick_impl(self) -> dict[str, Any]:
now = datetime.now()
now_s = now.strftime("%Y-%m-%d %H:%M:%S")
block = self._kill_switch_active()
results: list[dict[str, Any]] = []
fills = 0
try:
self._reconcile_fills()
except Exception: # noqa: BLE001
logger.exception("fill reconcile failed")
for sym in self.cfg.symbols:
try:
df = load_candles(
self.settings.db_path, sym, self.cfg.interval_min,
lookback_days=self.cfg.lookback_days,
)
df = closed_candles(df, self.cfg.interval_min, now)
res = self.engine.process_symbol(sym, df, now=now, block_entry=block)
except Exception as exc: # noqa: BLE001
logger.exception("rsi_dca tick failed %s", sym)
results.append({"symbol": sym, "error": str(exc)})
continue
for rec in res.trade_records:
self._notify_trade(rec, now)
fills += res.fills
results.append({
"symbol": sym, "note": res.note, "fills": res.fills,
"bars": res.processed_bars, "rsi": res.last_rsi, "price": res.last_price,
})
self.state["last_run_at"] = now_s
save_state(self.settings.rsi_dca_state_json, self.state)
d = self.state.get("daily") or {}
report = {
"ok": True, "strategy": STRATEGY, "mode": self.mode, "symbols": self.cfg.symbols,
"kill_switch": block, "fills": fills, "results": results, "last_run_at": now_s,
"daily": {**d, "max_krw": self.cfg.daily_max_krw,
"remaining_krw": self.engine.daily_remaining_krw(now)},
"totals": self.state.get("totals"),
}
try:
p = Path(self.settings.rsi_dca_report_json)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
except Exception: # noqa: BLE001
logger.exception("rsi_dca report write failed")
return report

View File

@@ -10,7 +10,7 @@ from typing import Any
import pandas as pd
from bithumb.config import Settings, resolve_coin_name
from bithumb.data.candle_loader import load_candles
from bithumb.data.candle_loader import load_candles, load_last_candles
from bithumb.operations.multi_portfolio import in_long_position
from bithumb.operations.vol_monitor_chart import write_vol_monitor_html
from bithumb.simulation.vol_breakout import drop_incomplete_base_bar
@@ -250,6 +250,45 @@ def _summary_html(summary: dict[str, Any]) -> str:
return " · ".join(lines)
def fetch_ticker_prices(settings: Settings, symbols: list[str]) -> dict[str, float]:
"""공개 ticker로 현재가 일괄 조회. 실패 시 빈 dict (호출측은 캔들 종가로 대체)."""
if not symbols:
return {}
try:
import requests
markets = ",".join(f"KRW-{s.upper()}" for s in symbols)
url = f"{settings.api_url.rstrip('/')}/v1/ticker"
resp = requests.get(url, params={"markets": markets}, timeout=5)
resp.raise_for_status()
out: dict[str, float] = {}
for row in resp.json() or []:
m = str(row.get("market", ""))
if m.startswith("KRW-"):
out[m[4:].upper()] = float(row.get("trade_price") or 0.0)
return out
except Exception: # noqa: BLE001
return {}
def _load_rsi_dca_trades(settings: Settings) -> list[dict[str, Any]]:
"""RSI DCA 상태 파일의 체결 기록 (차트 마커용). 없으면 빈 목록."""
path = getattr(settings, "rsi_dca_state_json", None)
if not path:
return []
try:
p = Path(path)
if not p.exists():
return []
data = json.loads(p.read_text(encoding="utf-8"))
out = []
for tr in data.get("trades") or []:
out.append({**tr, "reason": tr.get("reason") or "rsi_dca"})
return out
except Exception: # noqa: BLE001
return []
def build_vol_monitor_payload(
settings: Settings,
state: dict[str, Any],
@@ -257,10 +296,22 @@ def build_vol_monitor_payload(
tick_report: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""모니터 JSON 페이로드."""
symbols = list(settings.ops_symbols)
symbols = list(getattr(settings, "vol_monitor_symbols", None) or settings.ops_symbols)
days = float(settings.vol_monitor_days or 14)
sym_state = state.get("symbols") or {}
trades = list(state.get("trades") or [])
rsi_trades = _load_rsi_dca_trades(settings)
# 전략 매수 체결(자동)의 종목별 누적 투입·수량 → 평균 매입가. 수동 보유분은 VOL_MONITOR_AVG_PRICE 로 보완
cost_krw: dict[str, float] = {}
cost_coin: dict[str, float] = {}
for tr in rsi_trades: # RSI 자동 매수만 (매도 없는 전략이므로 누적 = 보유 원가)
if str(tr.get("side", "")) != "buy":
continue
s_ = str(tr.get("symbol", "")).upper()
cost_krw[s_] = cost_krw.get(s_, 0.0) + float(tr.get("order_krw") or 0.0)
cost_coin[s_] = cost_coin.get(s_, 0.0) + float(tr.get("order_coin") or 0.0)
manual_avg = dict(getattr(settings, "vol_monitor_avg_price", None) or {})
live_px = fetch_ticker_prices(settings, symbols)
snap = state.get("portfolio_snapshot") or {}
cash = float(snap.get("cash_krw") or 0.0)
@@ -276,7 +327,7 @@ def build_vol_monitor_payload(
next_15m, sec_until = _next_15m_close(df_closed)
st = sym_state.get(sym.upper()) or sym_state.get(sym) or {}
qty = float((snap.get("positions") or {}).get(sym, 0) or 0.0)
price = float(df_closed["close"].iloc[-1]) if not df_closed.empty else 0.0
price = float(live_px.get(sym.upper()) or (df_closed["close"].iloc[-1] if not df_closed.empty else 0.0))
holding = in_long_position(
{"positions": {sym: {"coin_qty": qty}}},
sym,
@@ -285,7 +336,23 @@ def build_vol_monitor_payload(
)
if price > 0:
total_equity += qty * price
# 평균매입가 우선순위: 거래소 계좌(avg_buy_price) > 자동매수 체결 누적 > 수동 설정(VOL_MONITOR_AVG_PRICE)
exch_avg = float((snap.get("avg_prices") or {}).get(sym.upper()) or 0.0)
avg_price = 0.0
if exch_avg > 0:
avg_price = exch_avg
elif cost_coin.get(sym.upper(), 0.0) > 0:
avg_price = cost_krw[sym.upper()] / cost_coin[sym.upper()]
elif sym.upper() in manual_avg and manual_avg[sym.upper()] > 0:
avg_price = float(manual_avg[sym.upper()])
pnl_pct = (price / avg_price - 1.0) * 100.0 if (avg_price > 0 and price > 0 and qty > 0) else None
symbol_summary[sym] = {
"avg_price": round(avg_price, 6) if avg_price else None,
"avg_price_source": "exchange" if exch_avg > 0 else ("auto" if cost_coin.get(sym.upper(), 0.0) > 0 else ("manual" if avg_price else None)),
"holding_cost_krw": round(qty * avg_price, 0) if avg_price else None, # 현재 보유분 원금 (매도 시 즉시 감소)
"auto_cost_krw": round(cost_krw.get(sym.upper(), 0.0), 0),
"auto_buys": sum(1 for t in rsi_trades if str(t.get("symbol", "")).upper() == sym.upper()),
"pnl_pct": None if pnl_pct is None else round(pnl_pct, 2),
"name": resolve_coin_name(sym),
"in_position": holding,
"coin_qty": qty,
@@ -297,24 +364,26 @@ def build_vol_monitor_payload(
}
symbol_blocks[sym] = {
"candles_15m": _candles_payload(df_closed, days=days),
"markers": _trade_markers(trades, sym),
"markers": _trade_markers(trades + rsi_trades, sym),
}
panel = _merged_close_panel(symbol_dfs, symbols, days=days)
seed_krw = max(total_equity, 1.0)
equity_strategy: list[dict[str, float | int]] = []
equity_buyhold: list[dict[str, float | int]] = []
if not panel.empty:
# 캔들이 아직 없는 종목(신규 수집 중)은 수익률 곡선에서 제외
panel_syms = [s for s in symbols if s.upper() in panel.columns]
if not panel.empty and panel_syms:
window_start = pd.Timestamp(panel["datetime"].iloc[0])
equity_strategy = build_spot_strategy_equity_series(
panel,
symbols,
panel_syms,
trades,
seed_krw=seed_krw,
current_equity=total_equity,
window_start=window_start,
)
equity_buyhold = build_multi_buyhold_series(panel, symbols, seed_krw)
equity_buyhold = build_multi_buyhold_series(panel, panel_syms, seed_krw)
summary = {
"mode": settings.ops_mode,
@@ -336,16 +405,72 @@ def build_vol_monitor_payload(
"trades": trades[-100:],
"last_tick": tick_report or {},
"ops_symbols": symbols,
"intervals": list(getattr(settings, "vol_monitor_intervals", None) or [INTERVAL_MIN]),
"equity": {
"strategy": equity_strategy,
"buyhold": equity_buyhold,
"seed_krw": round(seed_krw, 0),
"label_strategy": "vol_breakout",
"label_buyhold": "B&H 1/3×3",
"label_buyhold": f"B&H 1/{len(panel_syms)}×{len(panel_syms)}",
},
}
_INTERVAL_LABELS = {
1: "1분", 3: "3분", 5: "5분", 10: "10분", 15: "15분", 30: "30분",
60: "1시간", 240: "4시간", 1440: "1일", 10080: "1주", 43200: "1월",
}
def interval_label(interval_min: int) -> str:
"""분봉 코드 → 표시 라벨."""
return _INTERVAL_LABELS.get(int(interval_min), f"{int(interval_min)}분")
def build_candles_api_payload(
settings: Settings,
symbol: str,
interval_min: int,
*,
max_bars: int | None = None,
) -> dict[str, Any]:
"""`/api/candles` — DB 기준 특정 종목·분봉 최근 N봉 OHLC (lightweight-charts용)."""
sym = str(symbol or "").strip().upper()
allowed_syms = {
s.upper() for s in (getattr(settings, "vol_monitor_symbols", None) or settings.ops_symbols)
}
allowed_iv = set(getattr(settings, "vol_monitor_intervals", None) or [INTERVAL_MIN])
if sym not in allowed_syms:
return {"ok": False, "error": f"unknown symbol: {sym}"}
try:
iv = int(interval_min)
except (TypeError, ValueError):
return {"ok": False, "error": f"bad interval: {interval_min}"}
if iv not in allowed_iv:
return {"ok": False, "error": f"interval not allowed: {iv}"}
limit = int(max_bars or getattr(settings, "vol_monitor_max_bars", 0) or 1500)
df = load_last_candles(settings.db_path, sym, iv, limit)
rows: list[dict[str, float | int]] = []
for _, row in df.iterrows():
rows.append({
"time": _epoch_kst(row["datetime"]),
"open": float(row["open"]),
"high": float(row["high"]),
"low": float(row["low"]),
"close": float(row["close"]),
})
return {
"ok": True,
"symbol": sym,
"interval": iv,
"label": interval_label(iv),
"count": len(rows),
"last": str(df["datetime"].iloc[-1])[:19] if not df.empty else None,
"candles": rows,
}
def write_vol_monitor(
settings: Settings,
state: dict[str, Any],
@@ -412,17 +537,35 @@ def fetch_live_balance_snapshot(settings: Settings) -> dict[str, Any]:
sleep_sec=settings.request_sleep_sec,
retries=settings.request_retries,
)
krw, _ = client.get_balance("KRW")
positions: dict[str, float] = {}
total = float(krw)
for sym in settings.ops_symbols:
qty, _ = client.get_balance(sym)
positions[sym] = float(qty)
if qty > 0:
pass # price optional for total
# 모니터 표시 종목 + 매매 종목 + RSI 종목 전체를 한 번의 계정 조회로 채운다
symbols: list[str] = []
for group in (
getattr(settings, "vol_monitor_symbols", None) or [],
settings.ops_symbols or [],
getattr(settings, "rsi_dca_symbols", None) or [],
):
for sym in group:
if sym.upper() not in symbols:
symbols.append(sym.upper())
accounts = client.get_accounts()
by_cur: dict[str, tuple[float, float]] = {}
avg_prices: dict[str, float] = {}
for acc in accounts or []:
cur = str(acc.get("currency", "")).upper()
try:
by_cur[cur] = (float(acc.get("balance") or 0.0), float(acc.get("locked") or 0.0))
avg = float(acc.get("avg_buy_price") or 0.0) # 빗썸 계좌 평균 매입가 (수동·자동 매수 모두 반영)
if avg > 0:
avg_prices[cur] = avg
except (TypeError, ValueError):
continue
krw = by_cur.get("KRW", (0.0, 0.0))[0]
positions: dict[str, float] = {sym: by_cur.get(sym, (0.0, 0.0))[0] for sym in symbols}
return {
"ok": True,
"cash_krw": round(float(krw), 0),
"positions": positions,
"avg_prices": {sym: avg_prices[sym] for sym in symbols if sym in avg_prices},
"prices": fetch_ticker_prices(settings, symbols),
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}

View File

@@ -24,6 +24,20 @@ _MONITOR_HTML = """<!DOCTYPE html>
#btnUpdate:hover {{ background:#eee; }}
#btnUpdate:disabled {{ opacity:0.55; cursor:wait; }}
#meta {{ font-size:12px; color:#555; margin-bottom:6px; }}
.rsiBox {{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:6px 0 2px; padding:6px 10px;
border:1px solid #e5e5e5; border-radius:6px; background:#fafafa; font-size:12px; }}
.rsiBox .ttl {{ font-weight:bold; color:#333; }}
.badge {{ display:inline-block; padding:1px 8px; border-radius:10px; font-size:11px; color:#fff; background:#888; }}
.badge.live {{ background:#c62828; }}
.badge.paper {{ background:#607d8b; }}
.badge.on {{ background:#2e7d32; }}
.badge.off {{ background:#9e9e9e; }}
.badge.dead {{ background:#ef6c00; }}
#btnRsiToggle {{ font-size:12px; padding:3px 14px; border-radius:4px; border:1px solid #999; cursor:pointer; background:#fff; }}
#btnRsiToggle.on {{ background:#2e7d32; color:#fff; border-color:#2e7d32; }}
#btnRsiToggle.off {{ background:#eee; color:#333; }}
#btnRsiToggle:disabled {{ opacity:0.55; cursor:wait; }}
.rsiBox .dim {{ color:#777; }}
table.summary {{ border-collapse:collapse; font-size:12px; width:100%; margin-top:6px; }}
table.summary th, table.summary td {{ border:1px solid #ddd; padding:5px 10px; text-align:center; white-space:nowrap; }}
table.summary th {{ background:#f5f5f5; color:#666; font-weight:normal; font-size:11px; }}
@@ -33,35 +47,66 @@ _MONITOR_HTML = """<!DOCTYPE html>
cursor:pointer; font-size:13px;
}}
.tab.active {{ background:#333; color:#fff; border-color:#333; }}
#priceChart {{ width:100%; height:44vh; border-bottom:1px solid #eee; }}
#equityChart {{ width:100%; height:32vh; }}
.tabs.intervals {{ padding-top:0; border-bottom:1px solid #eee; }}
.tabs.intervals .tab {{ padding:3px 10px; font-size:12px; }}
.tabs.intervals .tab.active {{ background:#555; border-color:#555; }}
.tabs.intervals .label {{ font-size:12px; color:#777; align-self:center; margin-right:4px; }}
#priceChart {{ width:100%; height:40vh; border-bottom:1px solid #eee; }}
#rsiChart {{ width:100%; height:18vh; border-bottom:1px solid #eee; }}
#equityChart {{ width:100%; height:24vh; }}
.panelLabel {{ font-size:11px; color:#888; padding:2px 14px 0; }}
#err {{ display:none; padding:10px 14px; color:#b91c1c; font-size:13px; }}
#stale {{ display:none; padding:8px 14px; background:#fff7e6; color:#8a5a00; border-bottom:1px solid #f3d9a4; font-size:13px; }}
#reloadHint {{ font-size:12px; color:#888; padding:4px 14px; }}
</style>
</head>
<body>
<header>
<div class="titleRow">
<h1>Bithumb vol_breakout (15m spot long)</h1>
<h1>Bithumb 라이브 모니터</h1>
<button id="btnUpdate" type="button" title="거래소 잔고 즉시 갱신">Update</button>
</div>
<div id="meta">로딩 중…</div>
<div class="rsiBox" id="rsiBox">
<span class="ttl">RSI 자동매수</span>
<span id="rsiMode" class="badge">-</span>
<span id="rsiState" class="badge">-</span>
<button id="btnRsiToggle" type="button" title="킬스위치 토글 (OFF=신규 매수 차단)">…</button>
<span id="rsiTick" class="dim">tick -</span>
<span id="rsiDaily" class="dim">오늘 -</span>
<span id="rsiTotals" class="dim"></span>
</div>
<div id="summary"></div>
</header>
<div id="err"></div>
<div id="stale"></div>
<div class="tabs" id="tabs"></div>
<div class="tabs intervals" id="intervalTabs"></div>
<div id="reloadHint"></div>
<div id="priceChart"></div>
<div class="panelLabel" id="rsiLabel">RSI(14)</div>
<div id="rsiChart"></div>
<div class="panelLabel">수익률(%) · 전략 vs B&H</div>
<div id="equityChart"></div>
<script>
const JSON_URL = {json_url};
const PAGE_BUILD = {page_build};
let lastAutoReloadAt = 0;
const REFRESH_MS = {refresh_ms};
const BALANCE_POLL_MS = {balance_poll_ms};
let payload = null;
let activeSym = null;
let activeInterval = 15;
let intervalChosenByUser = false;
let candleCache = {{}};
let candleReqSeq = 0;
let priceChart = null;
let eqChart = null;
let rsiChart = null;
let rsiSeries = null;
let rsiLevelLines = [];
const RSI_PERIOD = 14;
let candleSeries = null;
let stratSeries = null;
let bhSeries = null;
@@ -74,18 +119,55 @@ function fmtKrw(v) {{
function renderSummary(data) {{
const s = data.summary || {{}};
const syms = data.ops_symbols || Object.keys(s.symbols || {{}});
const baseSyms = data.ops_symbols || Object.keys(s.symbols || {{}});
// 종목 컬럼을 수익률 내림차순으로 동적 정렬 (보유·수익률 산출 가능 종목 우선, 미보유는 뒤)
const pnlOf = (sym) => {{
const r = (s.symbols || {{}})[sym] || {{}};
const q = Number(r.coin_qty || 0), px = Number(r.last_price || 0), avg = Number(r.avg_price || 0);
if (q > 0 && px > 0 && avg > 0) return (px / avg - 1) * 100;
return (q * px >= 1) ? -Infinity + 1 : -Infinity; // 보유했지만 수익률 미상 → 미보유보다 앞
}};
const syms = [...baseSyms].sort((a, b) => pnlOf(b) - pnlOf(a) || baseSyms.indexOf(a) - baseSyms.indexOf(b));
let html = '<table class="summary"><thead><tr>';
html += '<th>KRW</th><th>총평가</th><th>모드</th>';
for (const sym of syms) html += `<th>${{sym}}</th>`;
html += `<th title="빗썸 원화 현금${{s.balance_at ? ' · 잔고 갱신 ' + s.balance_at : ''}}">KRW</th><th title="현금 + 보유 코인 시세 평가">총평가</th>`;
syms.forEach((sym, i) => {{ html += `<th title="수익률 내림차순 정렬">${{sym}}${{i === 0 && isFinite(pnlOf(sym)) ? ' <span style="color:#999;font-weight:normal">▼</span>' : ''}}</th>`; }});
html += '</tr></thead><tbody><tr>';
html += `<td>${{fmtKrw(s.cash_krw)}}</td>`;
html += `<td>${{fmtKrw(s.total_equity_krw)}}</td>`;
html += `<td>${{s.mode || '-'}}</td>`;
// 보유 코인 미실현 손익: Σ(수량×시세) − Σ(수량×평균매입가). 입출금·실현손익과 무관
let totCost = 0, totVal = 0;
for (const sym of syms) {{
const r = (s.symbols || {{}})[sym] || {{}};
const q = Number(r.coin_qty || 0), px = Number(r.last_price || 0), avg = Number(r.avg_price || 0);
if (q > 0 && px > 0 && avg > 0) {{ totCost += q * avg; totVal += q * px; }}
}}
let pnlHtml = '';
if (totCost > 0) {{
const pnl = totVal - totCost, pct = pnl / totCost * 100;
const col = pnl >= 0 ? '#c62828' : '#1565c0';
pnlHtml = ` <span style="color:${{col}};font-weight:bold" title="보유 코인 평가손익 · 원금 ${{fmtKrw(totCost)}}원 → 평가 ${{fmtKrw(totVal)}}원">(${{pnl >= 0 ? '+' : ''}}${{fmtKrw(pnl)}}, ${{pct >= 0 ? '+' : ''}}${{pct.toFixed(2)}}%)</span>`;
}}
html += `<td>${{fmtKrw(s.total_equity_krw)}}${{pnlHtml}}</td>`;
for (const sym of syms) {{
const row = (s.symbols || {{}})[sym] || {{}};
const pos = row.in_position ? '롱' : '플랫';
html += `<td>${{pos}} · ${{Number(row.coin_qty||0).toFixed(4)}} · sig ${{row.last_signal||0}}</td>`;
const qty = Number(row.coin_qty || 0);
const val = qty * Number(row.last_price || 0);
const avgPx = Number(row.avg_price || 0);
const cost = avgPx > 0 ? qty * avgPx : 0; // 현재 보유분 원금 — 매도하면 즉시 감소
let pnlTxt = '';
if (row.avg_price && row.last_price && qty > 0) {{
const pnl = (Number(row.last_price) / Number(row.avg_price) - 1) * 100;
const col = pnl >= 0 ? '#c62828' : '#1565c0';
pnlTxt = `<span style="color:${{col}};font-weight:bold">${{pnl >= 0 ? '+' : ''}}${{pnl.toFixed(2)}}%</span>`;
}} else if (qty > 0 && val >= 5000) {{
pnlTxt = '<span style="color:#999" title="매입가 미상 — .env VOL_MONITOR_AVG_PRICE=TRX:470 형식으로 입력">수익률 -</span>';
}}
// 매수 금액(자동매수 투입 원금) · 수익률. 자동매수 원금이 없는 수동 보유분은 평가금액으로 대체 표기
let cell = '-';
if (val >= 1) {{
const amt = cost > 0 ? `${{fmtKrw(cost)}}원` : `${{fmtKrw(val)}}원`; // 원금 없으면 평가금액
cell = `${{amt}}${{pnlTxt ? ' · ' + pnlTxt : ''}}`;
}}
html += `<td title="보유 ${{qty.toFixed(4)}} · 평균매입가 ${{avgPx ? avgPx.toLocaleString('ko-KR') : '-'}} · 평가 ${{fmtKrw(val)}}원">${{cell}}</td>`;
}}
html += '</tr></tbody></table>';
document.getElementById('summary').innerHTML = html;
@@ -104,17 +186,83 @@ function renderTabs(symbols) {{
}}
}}
const INTERVAL_LABELS = {{1:'1분',3:'3분',5:'5분',10:'10분',15:'15분',30:'30분',60:'1시간',240:'4시간',1440:'1일',10080:'1주',43200:'1월'}};
function renderIntervalTabs(intervals) {{
const el = document.getElementById('intervalTabs');
el.innerHTML = '';
const lab = document.createElement('span');
lab.className = 'label';
lab.textContent = '분봉';
el.appendChild(lab);
for (const iv of intervals) {{
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tab' + (Number(iv) === Number(activeInterval) ? ' active' : '');
btn.textContent = INTERVAL_LABELS[iv] || (iv + '분');
btn.onclick = () => {{ activeInterval = Number(iv); intervalChosenByUser = true; renderIntervalTabs(intervals); drawChart(); }};
el.appendChild(btn);
}}
}}
async function fetchCandles(sym, interval) {{
const key = sym + '@' + interval;
const cached = candleCache[key];
const now = Date.now();
if (cached && (now - cached.at) < 20000) return cached.candles;
const r = await fetch(`/api/candles?symbol=${{encodeURIComponent(sym)}}&interval=${{interval}}&t=${{now}}`, {{ cache: 'no-store' }});
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'candles fail');
candleCache[key] = {{ at: now, candles: j.candles || [] }};
return candleCache[key].candles;
}}
function snapMarkersToCandles(markers, candles) {{
if (!candles.length) return markers || [];
const times = candles.map(c => c.time);
const out = [];
for (const m of (markers || [])) {{
let lo = 0, hi = times.length - 1, idx = -1;
while (lo <= hi) {{
const mid = (lo + hi) >> 1;
if (times[mid] <= m.time) {{ idx = mid; lo = mid + 1; }} else {{ hi = mid - 1; }}
}}
if (idx >= 0) out.push({{ ...m, time: times[idx] }});
}}
out.sort((a, b) => a.time - b.time);
return out;
}}
const KST = 'Asia/Seoul';
const kstParts = (t) => {{
const d = new Date(t * 1000);
const p = new Intl.DateTimeFormat('ko-KR', {{ timeZone: KST, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }}).formatToParts(d);
const g = (k) => (p.find(x => x.type === k) || {{}}).value;
return {{ y: g('year'), m: g('month'), d: g('day'), hh: g('hour') === '24' ? '00' : g('hour'), mm: g('minute') }};
}};
const kstTimeFormatter = (t) => {{ const k = kstParts(t); return `${{k.y}}-${{k.m}}-${{k.d}} ${{k.hh}}:${{k.mm}} KST`; }};
// tickMarkType: 0 Year, 1 Month, 2 DayOfMonth, 3 Time, 4 TimeWithSeconds
const kstTickFormatter = (t, type) => {{
const k = kstParts(t);
if (type === 0) return k.y;
if (type === 1) return `${{k.y}}.${{k.m}}`;
if (type === 2) return `${{Number(k.m)}}/${{Number(k.d)}}`;
return `${{k.hh}}:${{k.mm}}`;
}};
function chartOpts() {{
return {{
layout: {{ background: {{ color: '#fff' }}, textColor: '#333' }},
grid: {{ vertLines: {{ color: '#f0f0f0' }}, horzLines: {{ color: '#f0f0f0' }} }},
timeScale: {{ timeVisible: true, secondsVisible: false }},
localization: {{ timeFormatter: kstTimeFormatter, locale: 'ko-KR' }},
timeScale: {{ timeVisible: true, secondsVisible: false, rightOffset: 3, barSpacing: 6, tickMarkFormatter: kstTickFormatter }},
rightPriceScale: {{ borderColor: '#d0d0d0' }},
}};
}}
function resizeCharts() {{
const pairs = [['priceChart', priceChart], ['equityChart', eqChart]];
const pairs = [['priceChart', priceChart], ['rsiChart', rsiChart], ['equityChart', eqChart]];
for (const [id, chart] of pairs) {{
if (!chart) continue;
const el = document.getElementById(id);
@@ -125,34 +273,83 @@ function resizeCharts() {{
}}
function resampleSeriesToCandles(series, candles) {{
if (!candles.length) return series || [];
if (!series || !series.length) return [];
if (!candles.length) return [];
if (!series || !series.length) return candles.map(c => ({{ time: c.time }}));
const map = new Map(series.map(p => [p.time, p.value]));
let last = series[0].value;
const firstT = series[0].time;
let last = null;
const out = [];
for (const c of candles) {{
if (map.has(c.time)) last = map.get(c.time);
out.push({{ time: c.time, value: last }});
else if (last == null && c.time >= firstT) last = series[0].value;
out.push(last == null ? {{ time: c.time }} : {{ time: c.time, value: last }});
}}
return out;
}}
function computeRSI(candles, period) {{
const n = candles.length;
if (n <= period) return candles.map(c => ({{ time: c.time }}));
let gain = 0, loss = 0;
for (let i = 1; i <= period; i++) {{
const d = candles[i].close - candles[i - 1].close;
if (d >= 0) gain += d; else loss -= d;
}}
let avgGain = gain / period, avgLoss = loss / period;
const out = [];
for (let i = 0; i < period; i++) out.push({{ time: candles[i].time }}); // 공백(whitespace) — 시간축 정렬용
const rsiOf = (g, l) => (l === 0 ? 100 : (g === 0 ? 0 : 100 - 100 / (1 + g / l)));
out.push({{ time: candles[period].time, value: rsiOf(avgGain, avgLoss) }});
for (let i = period + 1; i < n; i++) {{
const d = candles[i].close - candles[i - 1].close;
avgGain = (avgGain * (period - 1) + (d > 0 ? d : 0)) / period;
avgLoss = (avgLoss * (period - 1) + (d < 0 ? -d : 0)) / period;
out.push({{ time: candles[i].time, value: rsiOf(avgGain, avgLoss) }});
}}
return out;
}}
function rsiLevelsForSymbol(sym) {{
if (!rsiStatus) return [];
const by = rsiStatus.levels_by_symbol || {{}};
const ls = (by[sym] && by[sym].length) ? by[sym] : (rsiStatus.levels || []);
return ls.map(l => ({{ level: Number(l[0]), krw: Number(l[1]) }}));
}}
function updateRsiLevelLines() {{
if (!rsiSeries || !activeSym) return;
for (const ln of rsiLevelLines) {{ try {{ rsiSeries.removePriceLine(ln); }} catch (_) {{}} }}
rsiLevelLines = [];
const iv = rsiStatus && rsiStatus.interval_min ? Number(rsiStatus.interval_min) : null;
for (const l of rsiLevelsForSymbol(activeSym)) {{
rsiLevelLines.push(rsiSeries.createPriceLine({{
price: l.level, color: '#2e7d32', lineWidth: 2, lineStyle: 0, axisLabelVisible: true,
title: `매수 ${{l.level}}↑ ${{fmtKrw(l.krw)}}원` + (iv && iv !== activeInterval ? ` (${{INTERVAL_LABELS[iv] || iv + '분'}} 기준)` : ''),
}}));
}}
const lab = document.getElementById('rsiLabel');
if (lab) {{
const base = lab.textContent.split(' · 기준선')[0];
const lv = rsiLevelsForSymbol(activeSym).map(l => l.level).join('/');
lab.textContent = base + (lv ? ` · 기준선 ${{activeSym}} RSI ${{lv}}` : '');
}}
}}
function setupChartSync() {{
if (!priceChart || !eqChart) return;
priceChart.timeScale().subscribeVisibleLogicalRangeChange(() => {{
const charts = [priceChart, rsiChart, eqChart].filter(Boolean);
if (charts.length < 2) return;
const syncFrom = (src) => () => {{
if (chartSyncLock) return;
const range = src.timeScale().getVisibleLogicalRange();
if (!range) return;
chartSyncLock = true;
const range = priceChart.timeScale().getVisibleLogicalRange();
if (range) eqChart.timeScale().setVisibleLogicalRange(range);
for (const dst of charts) {{
if (dst === src) continue;
try {{ dst.timeScale().setVisibleLogicalRange(range); }} catch (_) {{}}
}}
requestAnimationFrame(() => {{ chartSyncLock = false; }});
}});
eqChart.timeScale().subscribeVisibleLogicalRangeChange(() => {{
if (chartSyncLock) return;
chartSyncLock = true;
const range = eqChart.timeScale().getVisibleLogicalRange();
if (range) priceChart.timeScale().setVisibleLogicalRange(range);
requestAnimationFrame(() => {{ chartSyncLock = false; }});
}});
}};
for (const c of charts) c.timeScale().subscribeVisibleLogicalRangeChange(syncFrom(c));
}}
function initChart() {{
@@ -163,6 +360,14 @@ function initChart() {{
borderUpColor: '#c62828', borderDownColor: '#1565c0',
wickUpColor: '#c62828', wickDownColor: '#1565c0',
}});
rsiChart = LightweightCharts.createChart(document.getElementById('rsiChart'), {{
...opts, rightPriceScale: {{ borderColor: '#d0d0d0', scaleMargins: {{ top: 0.04, bottom: 0.04 }} }},
}});
rsiSeries = rsiChart.addLineSeries({{
color: '#6a1b9a', lineWidth: 1.5, title: 'RSI',
autoscaleInfoProvider: () => ({{ priceRange: {{ minValue: 0, maxValue: 100 }} }}),
}});
rsiSeries.createPriceLine({{ price: 50, color: '#ccc', lineWidth: 1, lineStyle: 3, axisLabelVisible: false, title: '' }});
eqChart = LightweightCharts.createChart(document.getElementById('equityChart'), opts);
stratSeries = eqChart.addLineSeries({{ color: '#c0392b', lineWidth: 2, title: '전략' }});
bhSeries = eqChart.addLineSeries({{ color: '#888888', lineWidth: 1, lineStyle: 2, title: 'B&H 1/3' }});
@@ -179,28 +384,70 @@ function enhanceMarkers(markers) {{
}});
}}
function drawChart() {{
async function drawChart() {{
if (!payload || !activeSym || !candleSeries) return;
const block = (payload.symbols || {{}})[activeSym] || {{}};
const candles = block.candles_15m || [];
const markers = enhanceMarkers(block.markers || []);
const sym = activeSym, iv = activeInterval, seq = ++candleReqSeq;
let candles = [];
try {{
candles = await fetchCandles(sym, iv);
}} catch (e) {{
candles = (iv === 15) ? (block.candles_15m || []) : [];
if (iv !== 15) {{
document.getElementById('err').style.display = 'block';
document.getElementById('err').textContent = `${{sym}} ${{INTERVAL_LABELS[iv] || iv + '분'}} 캔들 로드 실패: ` + e.message;
}}
}}
if (seq !== candleReqSeq || sym !== activeSym || iv !== activeInterval) return;
const staleEl = document.getElementById('stale');
if (candles.length) {{
const lastT = candles[candles.length - 1].time;
const lastBarAgeMin = (Date.now() / 1000 - lastT) / 60 - iv;
const lastTxt = new Date(lastT * 1000).toLocaleTimeString('ko-KR', {{ hour: '2-digit', minute: '2-digit' }});
if (lastBarAgeMin > Math.max(5, iv * 3)) {{
staleEl.style.display = 'block';
staleEl.textContent = `데이터 지연: ${{sym}} ${{INTERVAL_LABELS[iv] || iv + '분'}} 마지막 봉 ${{lastTxt}} (${{lastBarAgeMin.toFixed(0)}}분 전)` +
(rsiStatus && !rsiStatus.tick_alive ? ' · 매수 tick 미가동 — 노트북 절전/네트워크 확인' : ' · 수집 지연 — 잠시 후 자동 복구');
if (rsiStatus && rsiStatus.tick_alive && lastBarAgeMin > Math.max(10, iv * 3)) maybeAutoReload('stale candles ' + lastBarAgeMin.toFixed(0) + 'min');
}} else {{
staleEl.style.display = 'none';
}}
}}
const markers = snapMarkersToCandles(enhanceMarkers(block.markers || []), candles);
chartSyncLock = true;
candleSeries.setData(candles);
candleSeries.setMarkers(markers);
priceChart.timeScale().fitContent();
const eq = payload.equity || {{}};
const strategy = resampleSeriesToCandles(eq.strategy || [], candles);
const buyhold = resampleSeriesToCandles(eq.buyhold || [], candles);
if (stratSeries) stratSeries.setData(strategy);
if (bhSeries) bhSeries.setData(buyhold);
if (eqChart) eqChart.timeScale().fitContent();
if (bhSeries) {{ bhSeries.setData(buyhold); bhSeries.applyOptions({{ title: eq.label_buyhold || 'B&H' }}); }}
let rsiLast = null;
if (rsiSeries) {{
const rsi = computeRSI(candles, RSI_PERIOD);
rsiSeries.setData(rsi);
rsiLast = rsi.length ? rsi[rsi.length - 1].value : null;
const lab = document.getElementById('rsiLabel');
if (lab) lab.textContent = `RSI(${{RSI_PERIOD}})` + (rsiLast != null ? ` · 현재 ${{rsiLast.toFixed(1)}}` : '');
updateRsiLevelLines();
}}
priceChart.timeScale().fitContent();
const lr = priceChart.timeScale().getVisibleLogicalRange();
for (const c of [rsiChart, eqChart]) {{
if (!c) continue;
try {{ if (lr) c.timeScale().setVisibleLogicalRange(lr); else c.timeScale().fitContent(); }} catch (_) {{ c.timeScale().fitContent(); }}
}}
requestAnimationFrame(() => {{ chartSyncLock = false; }});
const s = payload.summary || {{}};
const row = (s.symbols || {{}})[activeSym] || {{}};
const stratPct = Number(s.strategy_return_pct || 0);
const bhPct = Number(s.buyhold_return_pct || 0);
document.getElementById('reloadHint').textContent =
`${{activeSym}} · next 15m ${{row.next_15m || '-'}}` +
`${{activeSym}} · ${{INTERVAL_LABELS[iv] || iv + '분'}} ${{candles.length}}봉` +
(rsiLast != null ? ` · RSI ${{rsiLast.toFixed(1)}}` : '') +
` · next 15m ${{row.next_15m || '-'}}` +
(row.seconds_until_15m != null ? ` (${{row.seconds_until_15m}}s)` : '') +
` · 전략 ${{stratPct >= 0 ? '+' : ''}}${{stratPct.toFixed(2)}}%` +
` · B&H ${{bhPct >= 0 ? '+' : ''}}${{bhPct.toFixed(2)}}%` +
@@ -214,8 +461,11 @@ async function loadData() {{
if (!r.ok) throw new Error('HTTP ' + r.status);
payload = await r.json();
document.getElementById('err').style.display = 'none';
const reloadReason = (() => {{ try {{ return sessionStorage.getItem('vol_monitor_reload_reason'); }} catch (_) {{ return null; }} }})();
document.getElementById('meta').textContent =
'갱신: ' + (payload.updated_at || '-') +
'화면 갱신 ' + new Date().toLocaleTimeString('ko-KR', {{ hour12: false }}) +
' · 서버 JSON ' + (payload.updated_at || '-') + ' · 빌드 ' + PAGE_BUILD +
(reloadReason ? ' · 자동 새로고침: ' + reloadReason : '') +
(payload.last_tick && payload.last_tick.last_run_at ? ' · tick ' + payload.last_tick.last_run_at : '') +
(payload.summary ? (
' · 전략 ' + (Number(payload.summary.strategy_return_pct||0)).toFixed(2) + '%' +
@@ -225,6 +475,7 @@ async function loadData() {{
const syms = payload.ops_symbols || Object.keys(payload.symbols || {{}});
if (!activeSym && syms.length) activeSym = syms[0];
renderTabs(syms);
renderIntervalTabs(payload.intervals || [15]);
drawChart();
}} catch (e) {{
const errEl = document.getElementById('err');
@@ -255,6 +506,90 @@ async function refreshBalance() {{
}}
}}
let rsiStatus = null;
function renderRsiStatus(st) {{
const changed = JSON.stringify((rsiStatus||{{}}).levels_by_symbol) !== JSON.stringify(st.levels_by_symbol) || JSON.stringify((rsiStatus||{{}}).levels) !== JSON.stringify(st.levels);
rsiStatus = st;
if (!intervalChosenByUser && st.interval_min && Number(st.interval_min) !== activeInterval
&& payload && (payload.intervals || []).map(Number).includes(Number(st.interval_min))) {{
activeInterval = Number(st.interval_min);
renderIntervalTabs(payload.intervals || [15]);
drawChart();
}} else if (changed) {{
updateRsiLevelLines();
}}
const mode = String(st.mode || 'paper');
const modeEl = document.getElementById('rsiMode');
modeEl.textContent = mode === 'live' ? 'LIVE 실거래' : 'PAPER';
modeEl.className = 'badge ' + (mode === 'live' ? 'live' : 'paper');
const stEl = document.getElementById('rsiState');
stEl.textContent = st.enabled ? 'ON' : 'OFF (차단)';
stEl.className = 'badge ' + (st.enabled ? 'on' : 'off');
const btn = document.getElementById('btnRsiToggle');
btn.textContent = st.enabled ? '자동매수 끄기' : '자동매수 켜기';
btn.className = st.enabled ? 'on' : 'off';
btn.disabled = false;
const tickEl = document.getElementById('rsiTick');
if (st.last_run_at) {{
tickEl.textContent = (st.tick_alive ? 'tick 가동 ' : 'tick 미가동 · 마지막 ') + st.last_run_at;
tickEl.className = st.tick_alive ? 'dim' : 'badge dead';
}} else {{
tickEl.textContent = 'tick 기록 없음 (cron 미등록)';
tickEl.className = 'badge dead';
}}
const d = st.daily || {{}};
document.getElementById('rsiDaily').textContent =
`오늘 ${{fmtKrw(d.spent_krw)}} / ${{fmtKrw(d.max_krw)}}원 (${{d.count || 0}}건, 잔여 ${{fmtKrw(d.remaining_krw)}}원)`;
const t = st.totals || {{}};
document.getElementById('rsiTotals').textContent =
`누적 ${{fmtKrw(t.spent_krw)}}원 · ${{t.count || 0}}건` +
(st.interval_min ? ` · ${{st.interval_min}}분봉` : '') +
((st.levels || []).length ? ' · 공통 ' + st.levels.map(l => `RSI ${{l[0]}}↑ ${{fmtKrw(l[1])}}원`).join(', ') : '') +
(st.levels_by_symbol && Object.keys(st.levels_by_symbol).length
? ' · 종목별 ' + Object.entries(st.levels_by_symbol).map(([s, ls]) => `${{s}} ${{ls.map(l => l[0]).join('/')}}`).join(' ')
: '');
}}
function maybeAutoReload(reason) {{
const now = Date.now();
if (now - lastAutoReloadAt < 10 * 60 * 1000) return; // 10분에 1회 이상 재로드하지 않음
lastAutoReloadAt = now;
try {{ sessionStorage.setItem('vol_monitor_reload_reason', reason + ' @ ' + new Date().toLocaleTimeString('sv-SE')); }} catch (_) {{}}
location.reload();
}}
async function pollRsiStatus() {{
try {{
const r = await fetch('/api/rsi/status?t=' + Date.now(), {{ cache: 'no-store' }});
if (!r.ok) return;
const j = await r.json();
// 서버의 페이지 빌드가 바뀌면(코드 갱신) 열려 있는 탭을 자동 새로고침
if (j.ok && j.html_build && PAGE_BUILD && j.html_build !== PAGE_BUILD) {{ maybeAutoReload('html_build ' + PAGE_BUILD + '→' + j.html_build); return; }}
if (j.ok) renderRsiStatus(j);
}} catch (_) {{}}
}}
async function toggleRsi() {{
const btn = document.getElementById('btnRsiToggle');
const turningOn = !(rsiStatus && rsiStatus.enabled);
const mode = rsiStatus ? String(rsiStatus.mode || 'paper') : 'paper';
if (turningOn && mode === 'live') {{
if (!confirm('실거래(LIVE) 자동매수를 켭니다. 15분봉 RSI 30/35 상향 돌파 시 실제 매수 주문이 나갑니다. 계속할까요?')) return;
}}
btn.disabled = true;
try {{
const r = await fetch('/api/rsi/toggle?enable=' + (turningOn ? '1' : '0') + '&t=' + Date.now(), {{ method: 'POST', cache: 'no-store' }});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'toggle fail');
renderRsiStatus(j);
}} catch (e) {{
document.getElementById('err').style.display = 'block';
document.getElementById('err').textContent = '자동매수 전환 실패: ' + e.message;
btn.disabled = false;
}}
}}
async function pollBalance() {{
try {{
const r = await fetch('/api/balance?t=' + Date.now(), {{ cache: 'no-store' }});
@@ -262,13 +597,27 @@ async function pollBalance() {{
const j = await r.json();
if (j.ok && payload && payload.summary) {{
payload.summary.cash_krw = j.cash_krw;
let total = Number(j.cash_krw || 0);
if (j.prices && payload.summary.symbols) {{
for (const [sym, px] of Object.entries(j.prices)) {{
if (payload.summary.symbols[sym] && Number(px) > 0) payload.summary.symbols[sym].last_price = Number(px);
}}
}}
if (j.avg_prices && payload.summary.symbols) {{
for (const [sym, px] of Object.entries(j.avg_prices)) {{
if (payload.summary.symbols[sym] && Number(px) > 0) payload.summary.symbols[sym].avg_price = Number(px);
}}
}}
if (j.positions) {{
for (const [sym, qty] of Object.entries(j.positions)) {{
if (payload.summary.symbols && payload.summary.symbols[sym]) {{
payload.summary.symbols[sym].coin_qty = qty;
total += Number(qty) * Number(payload.summary.symbols[sym].last_price || 0);
}}
}}
payload.summary.total_equity_krw = total;
}}
payload.summary.balance_at = new Date().toLocaleTimeString('ko-KR', {{ hour12: false }});
renderSummary(payload);
}}
}} catch (_) {{}}
@@ -277,6 +626,7 @@ async function pollBalance() {{
if (location.protocol === 'file:') {{
document.getElementById('meta').textContent = 'HTTP 서버 필요 — python scripts/3_run_vol_monitor.py 실행';
document.getElementById('btnUpdate').disabled = true;
document.getElementById('btnRsiToggle').disabled = true;
}} else {{
try {{
if (typeof LightweightCharts === 'undefined') throw new Error('lightweight-charts CDN 로드 실패');
@@ -286,7 +636,10 @@ if (location.protocol === 'file:') {{
document.getElementById('err').textContent = '차트 라이브러리 로드 실패: ' + e.message;
}}
document.getElementById('btnUpdate').addEventListener('click', refreshBalance);
document.getElementById('btnRsiToggle').addEventListener('click', toggleRsi);
loadData();
pollRsiStatus();
setInterval(pollRsiStatus, BALANCE_POLL_MS);
setInterval(loadData, REFRESH_MS);
setInterval(pollBalance, BALANCE_POLL_MS);
}}
@@ -305,8 +658,16 @@ def write_vol_monitor_html(
) -> Path:
"""JSON fetch형 멀티 종목 모니터 HTML 기록."""
json_url = json.dumps(json_filename)
import hashlib
# 내용 해시 기반 빌드값 — HTML을 다시 써도 코드가 같으면 열려 있는 탭이 재로드되지 않음
digest = hashlib.sha1(
(_MONITOR_HTML + json_url + str(int(refresh_ms)) + str(int(balance_poll_ms))).encode("utf-8")
).hexdigest()[:10]
page_build = json.dumps(digest)
html = (
_MONITOR_HTML.replace("{json_url}", json_url)
.replace("{page_build}", page_build)
.replace("{refresh_ms}", str(int(refresh_ms)))
.replace("{balance_poll_ms}", str(int(balance_poll_ms)))
.replace("{{", "{")

View File

@@ -0,0 +1,47 @@
"""절전·재부팅 후 캔들 공백 백필 계획 테스트."""
from __future__ import annotations
from datetime import datetime, timedelta
from bithumb.data.gap_backfill import coverage_minutes, days_needed, plan_backfill
def test_coverage_and_days() -> None:
assert coverage_minutes(1) == 180.0 # 200봉 × 1분 × 0.9
assert coverage_minutes(15) == 2700.0
assert days_needed(50) == 2 # 1일 미만 공백 → ceil(0.03)+1
assert days_needed(1500) == 3 # 1.04일 → 2+1
def test_plan_no_gap_within_incremental_range() -> None:
now = datetime(2026, 9, 10, 8, 11)
db = {"XRP": {1: now - timedelta(minutes=50), 15: now - timedelta(minutes=60)}}
plan = plan_backfill(db, now=now, intervals=[1, 15])
assert plan.empty and plan.describe() == "no gap"
def test_plan_detects_1m_gap_only() -> None:
now = datetime(2026, 9, 10, 8, 11)
# 1분봉 5시간 공백(> 180분) → 백필, 15분봉은 200봉(50h) 범위 내
db = {
"XRP": {1: now - timedelta(hours=5), 15: now - timedelta(hours=5)},
"SUI": {1: now - timedelta(minutes=30), 15: now - timedelta(minutes=30)},
}
plan = plan_backfill(db, now=now, intervals=[1, 15])
assert plan.needs == {"XRP": {1: 2}}
assert plan.symbols() == ["XRP"] and plan.intervals() == [1] and plan.max_days() == 2
def test_plan_long_outage_multiple_intervals_and_cap() -> None:
now = datetime(2026, 9, 20, 0, 0)
db = {"ETH": {1: now - timedelta(days=5), 15: now - timedelta(days=5), 1440: now - timedelta(days=5)}}
plan = plan_backfill(db, now=now, intervals=[1, 15, 1440], max_days=4)
# 1m·15m은 5일 공백 → 6일 필요하나 상한 4일, 일봉은 200봉(180일) 범위 내
assert plan.needs == {"ETH": {1: 4, 15: 4}}
def test_plan_skips_never_loaded_tables() -> None:
now = datetime(2026, 9, 10, 8, 11)
plan = plan_backfill({"NEW": {1: None, 15: None}}, now=now, intervals=[1, 15])
assert plan.empty

231
tests/test_rsi_dca.py Normal file
View File

@@ -0,0 +1,231 @@
"""RSI DCA 엔진 단위 테스트."""
from __future__ import annotations
from datetime import datetime
import pandas as pd
import pytest
from bithumb.operations.rsi_dca_engine import (
RsiDcaConfig,
RsiDcaEngine,
backtest_rsi_dca,
closed_candles,
cross_up_levels,
empty_state,
paper_buy_fn,
parse_levels,
wilder_rsi,
)
def _cfg(**kw) -> RsiDcaConfig:
base = dict(symbols=["AAA"], interval_min=15, period=3, daily_max_krw=60000.0,
max_signal_age_min=45, min_order_krw=5000.0)
base.update(kw)
return RsiDcaConfig(**base)
def _df(closes, start="2026-09-01 00:00:00"):
return pd.DataFrame({
"datetime": pd.date_range(start, periods=len(closes), freq="15min"),
"open": closes, "high": [c + 1 for c in closes], "low": [c - 1 for c in closes],
"close": closes, "volume": [1.0] * len(closes),
})
def test_wilder_rsi_bounds_and_warmup() -> None:
up = wilder_rsi([1, 2, 3, 4, 5, 6, 7], period=3)
assert up.iloc[:3].isna().all()
assert up.iloc[3:].eq(100.0).all()
down = wilder_rsi([7, 6, 5, 4, 3, 2, 1], period=3)
assert down.iloc[3:].eq(0.0).all()
mixed = wilder_rsi([10, 11, 10, 12, 11, 13, 12, 14], period=3)
assert mixed.dropna().between(0, 100).all()
def test_cross_up_levels_both_fire_and_ordering() -> None:
levels = parse_levels("35:10000,30:20000")
assert levels == [(30.0, 20000.0), (35.0, 10000.0)]
assert cross_up_levels(29.0, 36.0, levels) == [(30.0, 20000.0), (35.0, 10000.0)]
assert cross_up_levels(31.0, 36.0, levels) == [(35.0, 10000.0)]
assert cross_up_levels(30.0, 31.0, levels) == [(30.0, 20000.0)] # prev == level 포함
assert cross_up_levels(36.0, 40.0, levels) == []
assert cross_up_levels(36.0, 29.0, levels) == [] # 하향은 무시
assert cross_up_levels(float("nan"), 40.0, levels) == []
def test_closed_candles_drops_forming_bar() -> None:
df = _df([1, 2, 3, 4]) # 00:00, 00:15, 00:30, 00:45
out = closed_candles(df, 15, datetime(2026, 9, 1, 0, 50))
assert len(out) == 3 # 00:45 봉은 01:00 마감 → 미포함
out2 = closed_candles(df, 15, datetime(2026, 9, 1, 1, 0))
assert len(out2) == 4
def test_engine_initializes_without_buying_then_buys_on_cross() -> None:
cfg = _cfg()
state = empty_state("paper")
eng = RsiDcaEngine(cfg, state, mode="paper", buy_fn=paper_buy_fn(cfg))
# 하락 후 회복: RSI(3) 0 근처에서 위로
closes = [100, 99, 98, 97, 96, 95, 94]
df = _df(closes)
now = datetime(2026, 9, 1, 2, 0)
r0 = eng.process_symbol("AAA", df, now=now)
assert r0.note.startswith("initialized") and r0.fills == 0
# 다음 봉 급반등 → RSI 0→>35 : 30·35 둘 다 매수 (3만원)
df2 = _df(closes + [120])
now2 = datetime(2026, 9, 1, 2, 15)
r1 = eng.process_symbol("AAA", df2, now=now2)
assert r1.fills == 2
assert sorted(t["level"] for t in r1.trade_records) == [30.0, 35.0]
assert sum(t["order_krw"] for t in r1.trade_records) == 30000
assert state["daily"]["spent_krw"] == 30000
assert state["symbols"]["AAA"]["last_confirm_time"] == "2026-09-01 01:45:00"
# 같은 봉 재실행 → 중복 매수 없음
r2 = eng.process_symbol("AAA", df2, now=now2)
assert r2.fills == 0 and r2.note.startswith("no_new_bar")
def test_engine_daily_cap_and_reset() -> None:
cfg = _cfg(daily_max_krw=50000.0)
state = empty_state("paper")
eng = RsiDcaEngine(cfg, state, mode="paper", buy_fn=paper_buy_fn(cfg))
closes = [100, 99, 98, 97, 96, 95, 94]
eng.process_symbol("AAA", _df(closes), now=datetime(2026, 9, 1, 2, 0))
# 1차 사이클: 3만원
seq = closes + [120]
eng.process_symbol("AAA", _df(seq), now=datetime(2026, 9, 1, 2, 15))
# 다시 하락 후 반등 → 2차 사이클: 남은 2만원으로 30↑(2만) 체결, 35↑(1만)은 상한 스킵
seq2 = seq + [100, 90, 80, 70, 60, 130]
r = eng.process_symbol("AAA", _df(seq2), now=datetime(2026, 9, 1, 3, 45))
assert r.fills == 1 and r.trade_records[0]["level"] == 30.0
assert state["daily"]["spent_krw"] == 50000
assert any(e["type"] == "daily_cap" for e in state["events"])
# 날짜가 바뀌면 상한 리셋
assert eng.daily_remaining_krw(datetime(2026, 9, 2, 0, 1)) == 50000.0
def test_engine_expired_signal_not_bought() -> None:
cfg = _cfg(max_signal_age_min=45)
state = empty_state("paper")
eng = RsiDcaEngine(cfg, state, mode="paper", buy_fn=paper_buy_fn(cfg))
closes = [100, 99, 98, 97, 96, 95, 94]
eng.process_symbol("AAA", _df(closes), now=datetime(2026, 9, 1, 2, 0))
df2 = _df(closes + [120]) # 신호 봉 01:45 (마감 02:00)
r = eng.process_symbol("AAA", df2, now=datetime(2026, 9, 1, 3, 30)) # 90분 경과
assert r.fills == 0
assert all(e["type"] == "expired" for e in state["events"])
assert state["symbols"]["AAA"]["last_confirm_time"] == "2026-09-01 01:45:00"
def test_engine_kill_switch_blocks() -> None:
cfg = _cfg()
state = empty_state("paper")
eng = RsiDcaEngine(cfg, state, mode="paper", buy_fn=paper_buy_fn(cfg))
closes = [100, 99, 98, 97, 96, 95, 94]
eng.process_symbol("AAA", _df(closes), now=datetime(2026, 9, 1, 2, 0))
r = eng.process_symbol("AAA", _df(closes + [120]), now=datetime(2026, 9, 1, 2, 15), block_entry=True)
assert r.fills == 0 and any(e["type"] == "kill_switch" for e in state["events"])
def test_backtest_shared_daily_cap_across_symbols() -> None:
cfg = _cfg(symbols=["AAA", "BBB"], daily_max_krw=30000.0)
closes = [100, 99, 98, 97, 96, 95, 94, 120]
rep = backtest_rsi_dca({"AAA": _df(closes), "BBB": _df(closes)}, cfg)
# 각 종목 2신호(30·35) = 4신호, 일 상한 3만원 → AAA 2건(3만) 체결, BBB 2건 스킵
assert rep["signals"] == 4
assert rep["buys"] == 2
assert rep["skipped_daily_cap"] == 2
assert rep["total_spent_krw"] == pytest.approx(30000.0)
def test_rsi_control_toggle_and_status(tmp_path) -> None:
from types import SimpleNamespace
from bithumb.operations.rsi_dca_control import rsi_enabled, rsi_status, set_rsi_enabled
kill = tmp_path / "rsi.kill"
state = tmp_path / "state.json"
state.write_text(
'{"mode":"paper","last_run_at":"2026-09-07 13:00:00","daily":{"date":"2026-09-07","spent_krw":30000,"count":2},'
'"totals":{"spent_krw":90000,"count":6},"symbols":{"XRP":{"last_rsi":41.2,"last_confirm_time":"2026-09-07 12:45:00","buy_count":1,"spent_krw":20000,"initialized":true}},'
'"trades":[{"symbol":"XRP","side":"buy","ts":"2026-09-07 12:45:00","order_krw":20000}]}',
encoding="utf-8",
)
settings = SimpleNamespace(
rsi_dca_kill_switch_path=kill, rsi_dca_state_json=state, rsi_dca_report_json=tmp_path / "r.json",
rsi_dca_mode="live", rsi_dca_daily_max_krw=60000.0, rsi_dca_levels=[(30.0, 20000.0), (35.0, 10000.0)],
)
assert rsi_enabled(settings) is True
st = rsi_status(settings, now=datetime(2026, 9, 7, 13, 1))
assert st["enabled"] and st["mode"] == "live" and st["tick_alive"]
assert st["daily"]["spent_krw"] == 30000 and st["daily"]["remaining_krw"] == 30000
assert st["symbols"]["XRP"]["rsi"] == 41.2 and len(st["recent_trades"]) == 1
off = set_rsi_enabled(settings, False)
assert off["ok"] and off["enabled"] is False and kill.exists()
on = set_rsi_enabled(settings, True)
assert on["ok"] and on["enabled"] is True and not kill.exists()
# tick 5분 전이면 미가동, 날짜 바뀌면 오늘 투입 0
st2 = rsi_status(settings, now=datetime(2026, 9, 8, 9, 0))
assert not st2["tick_alive"] and st2["daily"]["spent_krw"] == 0
def test_levels_by_symbol_override() -> None:
from bithumb.operations.rsi_dca_engine import parse_levels_by_symbol
ov = parse_levels_by_symbol("xrp=19:10000; TRX=32:10000,35:5000 ;bad")
assert ov == {"XRP": [(19.0, 10000.0)], "TRX": [(32.0, 10000.0), (35.0, 5000.0)]}
cfg = _cfg(symbols=["AAA", "BBB"], levels=[(30.0, 20000.0)], levels_by_symbol={"BBB": [(90.0, 10000.0)]})
assert cfg.levels_for("aaa") == [(30.0, 20000.0)]
assert cfg.levels_for("BBB") == [(90.0, 10000.0)]
closes = [100, 99, 98, 97, 96, 95, 94, 120]
rep = backtest_rsi_dca({"AAA": _df(closes), "BBB": _df(closes)}, cfg)
# RSI(3) 0→100 급등: AAA는 공통 30↑(2만원), BBB는 오버라이드 90↑(1만원) 적용
assert rep["per_symbol"]["AAA"]["buys"] == 1 and rep["per_symbol"]["AAA"]["spent_krw"] == 20000.0
assert rep["per_symbol"]["BBB"]["buys"] == 1 and rep["per_symbol"]["BBB"]["spent_krw"] == 10000.0
assert rep["trades"][0]["level"] != rep["trades"][1]["level"]
def test_runner_resets_cursors_when_interval_changes(tmp_path, monkeypatch) -> None:
import json
from types import SimpleNamespace
from bithumb.operations import rsi_dca_runner as rr
state_path = tmp_path / "state.json"
state_path.write_text(json.dumps({
"mode": "paper", "interval_min": 15,
"symbols": {"XRP": {"initialized": True, "last_confirm_time": "2026-09-07 13:45:00"}},
}), encoding="utf-8")
settings = SimpleNamespace(
rsi_dca_mode="paper", rsi_dca_symbols=["XRP"], rsi_dca_interval_min=1, rsi_dca_period=14,
rsi_dca_levels=[(20.0, 10000.0)], rsi_dca_levels_by_symbol={"XRP": [(19.0, 10000.0)]},
rsi_dca_daily_max_krw=60000.0, rsi_dca_lookback_days=3, rsi_dca_max_bars_per_tick=8,
rsi_dca_max_signal_age_min=45, ops_min_order_krw=5000.0, gt_trading_fee_rate=0.0005,
ops_slippage_rate=0.0005, ops_exchange_fee_lock_rate=0.0025, rsi_dca_state_json=state_path,
telegram_bot_token="", telegram_chat_id="", ops_telegram_enabled=False,
)
runner = rr.RsiDcaRunner(settings, mode="paper")
st = runner.state["symbols"]["XRP"]
assert st["initialized"] is False and st["last_confirm_time"] is None
assert runner.state["interval_min"] == 1
assert runner.state["levels_by_symbol"] == {"XRP": [[19.0, 10000.0]]}
assert any(e["type"] == "interval_changed" for e in runner.state["events"])
def test_apply_fill_to_trade_uses_actual_funds_and_volume() -> None:
from bithumb.operations.rsi_dca_engine import apply_fill_to_trade
rec = {"price": 1087.54, "order_krw": 10000.0, "order_coin": 9.195, "fill_reconciled": False}
order = {"state": "done", "executed_volume": "9.216589861751152073", "executed_funds": "10000", "paid_fee": "4"}
assert apply_fill_to_trade(rec, order) is True
assert rec["order_coin"] == pytest.approx(9.21659, rel=1e-5)
assert rec["price"] == pytest.approx(1085.0, rel=1e-4)
assert rec["fee_krw"] == 4.0 and rec["fill_reconciled"] is True and rec["price_ref"] == 1087.54
# 미체결/부분 정보는 보정하지 않음
rec2 = {"price": 1.0}
assert apply_fill_to_trade(rec2, {"state": "wait", "executed_volume": "0", "executed_funds": "0"}) is False
assert rec2["price"] == 1.0

View File

@@ -76,3 +76,36 @@ def test_write_vol_monitor_html_no_format_error(tmp_path: Path) -> None:
import subprocess
subprocess.run(["node", "--check", str(js_path)], check=True, capture_output=True)
def test_candles_api_payload_filters_and_limits(monkeypatch) -> None:
"""/api/candles — 허용 종목·분봉 검증, max_bars 제한, 최신봉 순서."""
from types import SimpleNamespace
from bithumb.operations import vol_live_monitor as m
df = pd.DataFrame({
"datetime": pd.date_range("2026-06-01 10:00:00", periods=5, freq="5min"),
"open": [1.0, 2.0, 3.0, 4.0, 5.0],
"high": [1.5, 2.5, 3.5, 4.5, 5.5],
"low": [0.5, 1.5, 2.5, 3.5, 4.5],
"close": [1.2, 2.2, 3.2, 4.2, 5.2],
"volume": [1, 1, 1, 1, 1],
})
monkeypatch.setattr(m, "load_last_candles", lambda _db, _sym, _iv, n: df.tail(int(n)).reset_index(drop=True))
settings = SimpleNamespace(
db_path="x.db",
ops_symbols=["TRX"],
vol_monitor_symbols=["XRP", "TRX"],
vol_monitor_intervals=[1, 5, 15],
vol_monitor_max_bars=3,
)
out = m.build_candles_api_payload(settings, "trx", "5")
assert out["ok"] and out["symbol"] == "TRX" and out["interval"] == 5
assert out["count"] == 3 and out["label"] == "5분"
assert [c["close"] for c in out["candles"]] == [3.2, 4.2, 5.2]
assert out["last"] == "2026-06-01 10:20:00"
assert not m.build_candles_api_payload(settings, "BTC", 5)["ok"]
assert not m.build_candles_api_payload(settings, "TRX", 7)["ok"]
assert not m.build_candles_api_payload(settings, "TRX", "abc")["ok"]