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

@@ -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