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>
232 lines
11 KiB
Python
232 lines
11 KiB
Python
"""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
|