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