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:
47
tests/test_gap_backfill.py
Normal file
47
tests/test_gap_backfill.py
Normal 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
231
tests/test_rsi_dca.py
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user