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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user