- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
298 lines
14 KiB
Python
298 lines
14 KiB
Python
"""스윙 청산 RSI 워밍업 (하드닝 R4 2026-10-09) — swing/exit_service 회귀 테스트.
|
|
|
|
[MEDIUM] 청산 RSI 가 KIS 30봉 일봉으로 계산돼 Wilder RSI(14) 시드 영향이 남았다(운영 실측
|
|
RSI≥55 판정 불일치 6.9%). 이제 장기 이력(네이버 → KIS 기간 조회)을 하루 1회 캐시하고 KIS 30봉과
|
|
날짜로 병합해 RSI 를 계산한다. 실패·불일치·대기 초과면 기존 30봉 RSI 로 폴백한다.
|
|
|
|
규약(CLAUDE.md §4.1): 외부 조회 목은 예외 발생형 또는 명시적 반환, 주문 목은 공통 종단.
|
|
"""
|
|
import asyncio
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import app.services.mean_reversion_strategy as mr
|
|
from app.services.mean_reversion_strategy import ExitSignal, calculate_rsi
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset(monkeypatch):
|
|
import app.services.swing.exit_service as es
|
|
for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr",
|
|
"_presell_check_failures", "_no_price_skips", "_recent_exits",
|
|
"_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last",
|
|
"_exit_history_cache", "_exit_history_tasks"):
|
|
monkeypatch.setattr(es, name, {})
|
|
monkeypatch.setattr(es, "_exit_rsi_source_counts", {"merged": 0, "kis30": 0})
|
|
from app.core import log_throttle
|
|
log_throttle.reset_for_tests()
|
|
# 실수로 외부 조회가 일어나면 즉시 실패(예외 발생형). 개별 테스트가 필요 시 덮는다.
|
|
import app.services.naver_finance as nf
|
|
monkeypatch.setattr(nf, "get_naver_daily_chart",
|
|
AsyncMock(side_effect=AssertionError("네이버 실호출 금지")))
|
|
return es
|
|
|
|
|
|
def _dates(n, end=None):
|
|
from app.core.timeutil import today_kst
|
|
end = end or today_kst()
|
|
out, d = [], end
|
|
while len(out) < n:
|
|
if d.weekday() < 5:
|
|
out.append(d.strftime("%Y%m%d"))
|
|
d -= timedelta(days=1)
|
|
return list(reversed(out))
|
|
|
|
|
|
def _series(n):
|
|
# 결정적 등락 — 30봉 RSI 와 전체 이력 RSI 가 확실히 갈리도록 앞쪽에 강한 추세를 둔다.
|
|
closes, p = [], 10000.0
|
|
for i in range(n):
|
|
p += (120 if i < n - 40 else 0) + (((i * 37) % 11) - 5) * 30
|
|
closes.append(round(p, 0))
|
|
return closes
|
|
|
|
|
|
def _bars(dates, closes):
|
|
return [{"stck_bsop_date": d, "stck_clpr": str(c), "stck_hgpr": str(c + 100),
|
|
"stck_lwpr": str(c - 100)} for d, c in zip(dates, closes)]
|
|
|
|
|
|
def _fixture_bars(n_hist=130, n_recent=30):
|
|
dates = _dates(n_hist)
|
|
closes = _series(n_hist)
|
|
history = _bars(dates[:-1], closes[:-1]) # 이력은 어제까지
|
|
recent = _bars(dates[-n_recent:], closes[-n_recent:]) # KIS 30봉 (오늘 포함)
|
|
return history, recent, closes
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 병합 순수 로직
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
def test_merge_gives_full_history_rsi(_reset):
|
|
es = _reset
|
|
history, recent, closes = _fixture_bars()
|
|
merged = es._merge_exit_bars_v2(history, recent)
|
|
assert merged is not None and len(merged) == len(closes)
|
|
rsi_full = calculate_rsi([float(b["stck_clpr"]) for b in merged])
|
|
assert rsi_full == pytest.approx(calculate_rsi(closes))
|
|
# 결함 재현: 30봉 RSI 는 전체 이력 RSI 와 다르다
|
|
assert abs(calculate_rsi(closes[-30:]) - rsi_full) > 0.5
|
|
|
|
|
|
def test_merge_today_bar_comes_from_recent_even_if_history_differs(_reset):
|
|
es = _reset
|
|
history, recent, closes = _fixture_bars()
|
|
# 이력에 오늘 봉이 섞여 있고 값이 다르다(장중 진행) → 최근 봉이 이기고 검증 대상에서 제외
|
|
today = recent[-1]["stck_bsop_date"]
|
|
history = history + [{"stck_bsop_date": today, "stck_clpr": str(closes[-1] * 1.05)}]
|
|
merged = es._merge_exit_bars_v2(history, recent)
|
|
assert merged is not None and merged[-1] is recent[-1]
|
|
|
|
|
|
def test_merge_rejects_close_mismatch(_reset):
|
|
es = _reset
|
|
history, recent, _ = _fixture_bars()
|
|
# 겹치는 확정 봉 하나의 종가가 허용 오차를 넘게 다르다(수정주가 차이 등)
|
|
bad = dict(recent[5])
|
|
tol = es._EXIT_HISTORY_CLOSE_TOL
|
|
bad["stck_clpr"] = str(float(bad["stck_clpr"]) * (1 + tol * 3))
|
|
recent = recent[:5] + [bad] + recent[6:]
|
|
assert es._merge_exit_bars_v2(history, recent) is None
|
|
|
|
|
|
def test_merge_rejects_no_overlap_or_no_older(_reset):
|
|
es = _reset
|
|
history, recent, _ = _fixture_bars()
|
|
# 겹침 없음(이력이 최근 봉 시작 이전에서 끊김)
|
|
first = recent[0]["stck_bsop_date"]
|
|
gap_hist = [b for b in history if b["stck_bsop_date"] < first]
|
|
assert es._merge_exit_bars_v2(gap_hist, recent) is None
|
|
# 더 이른 봉이 없음(이득 없음)
|
|
only_overlap = [b for b in history if b["stck_bsop_date"] >= first]
|
|
assert es._merge_exit_bars_v2(only_overlap, recent) is None
|
|
assert es._merge_exit_bars_v2(None, recent) is None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# _exit_rsi_v2 — 조회·캐시·폴백
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_rsi_uses_history_and_caches_daily(monkeypatch, _reset):
|
|
es = _reset
|
|
history, recent, closes = _fixture_bars()
|
|
fetch = AsyncMock(return_value=(history, "naver"))
|
|
monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", fetch)
|
|
c30 = [float(b["stck_clpr"]) for b in recent]
|
|
r1 = await es._exit_rsi_v2("005930", recent, c30)
|
|
r2 = await es._exit_rsi_v2("005930", recent, c30)
|
|
assert r1 == pytest.approx(calculate_rsi(closes)) and r2 == r1
|
|
assert fetch.await_count == 1 # 하루 1회
|
|
assert es._exit_rsi_source_counts["merged"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_rsi_falls_back_when_sources_raise(monkeypatch, _reset):
|
|
"""네이버·KIS 기간 조회가 모두 예외 → 30봉 RSI, 실패는 재시도 간격 동안 캐시."""
|
|
es = _reset
|
|
import app.services.naver_finance as nf
|
|
naver = AsyncMock(side_effect=RuntimeError("naver down"))
|
|
monkeypatch.setattr(nf, "get_naver_daily_chart", naver)
|
|
api = MagicMock()
|
|
api._quote_delegate = lambda: None
|
|
api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis down"))
|
|
monkeypatch.setattr(es, "korea_investment_api", api)
|
|
_, recent, _ = _fixture_bars()
|
|
c30 = [float(b["stck_clpr"]) for b in recent]
|
|
r = await es._exit_rsi_v2("005930", recent, c30)
|
|
assert r == pytest.approx(calculate_rsi(c30))
|
|
r = await es._exit_rsi_v2("005930", recent, c30)
|
|
assert naver.await_count == 1 and api.get_daily_ohlcv_range.await_count == 1
|
|
assert es._exit_rsi_source_counts["kis30"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_rsi_kis_range_used_when_naver_fails(monkeypatch, _reset):
|
|
es = _reset
|
|
import app.services.naver_finance as nf
|
|
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("x")))
|
|
history, recent, closes = _fixture_bars()
|
|
api = MagicMock()
|
|
api._quote_delegate = lambda: None
|
|
api.get_daily_ohlcv_range = AsyncMock(return_value=list(reversed(history))) # KIS 는 최신→과거
|
|
monkeypatch.setattr(es, "korea_investment_api", api)
|
|
c30 = [float(b["stck_clpr"]) for b in recent]
|
|
r = await es._exit_rsi_v2("005930", recent, c30)
|
|
assert r == pytest.approx(calculate_rsi(closes))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_rsi_slow_fetch_does_not_block_cycle(monkeypatch, _reset):
|
|
"""조회가 대기 상한을 넘기면 이번 사이클은 30봉 RSI, 완료 후 다음 사이클에 병합 RSI."""
|
|
es = _reset
|
|
history, recent, closes = _fixture_bars()
|
|
gate = asyncio.Event()
|
|
|
|
async def _slow(code):
|
|
await gate.wait()
|
|
return history, "naver"
|
|
monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", _slow)
|
|
monkeypatch.setattr(es, "EXIT_HISTORY_WAIT_SEC", 0.01)
|
|
c30 = [float(b["stck_clpr"]) for b in recent]
|
|
r1 = await es._exit_rsi_v2("005930", recent, c30)
|
|
assert r1 == pytest.approx(calculate_rsi(c30))
|
|
gate.set()
|
|
await asyncio.sleep(0)
|
|
for _ in range(5):
|
|
if not es._exit_history_tasks:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
r2 = await es._exit_rsi_v2("005930", recent, c30)
|
|
assert r2 == pytest.approx(calculate_rsi(closes))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_rsi_undated_bars_skip_fetch(monkeypatch, _reset):
|
|
es = _reset
|
|
fetch = AsyncMock(side_effect=AssertionError("날짜 없는 봉에서 이력 조회 금지"))
|
|
monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", fetch)
|
|
recent = [{"stck_clpr": str(10000 + i * 10)} for i in range(30)]
|
|
c30 = [float(b["stck_clpr"]) for b in recent]
|
|
r = await es._exit_rsi_v2("005930", recent, c30)
|
|
assert r == pytest.approx(calculate_rsi(c30))
|
|
assert fetch.await_count == 0
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 청산 루프 통합 — check_exit_signal 에 전달되는 RSI, 손절 우선
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _loop_env(monkeypatch, es, recent, captured, signal=None):
|
|
import app.services.trade_executor as te
|
|
import app.services.db_worker as dbw
|
|
import app.services.portfolio_cache_bridge as pcb
|
|
import app.services.adaptive_strategy as ad
|
|
from app.core.timeutil import now_kst
|
|
from app.services.daytrading.position_manager import day_trading_state
|
|
|
|
price = float(recent[-1]["stck_clpr"])
|
|
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
|
|
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
|
|
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
|
|
holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10,
|
|
average_price=price, current_price=price, market="KRX",
|
|
updated_at=now_kst())
|
|
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding]))
|
|
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
|
|
at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None,
|
|
created_at=now_kst() - timedelta(days=2), entry_reason=None,
|
|
entry_price=price)
|
|
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at))
|
|
api = MagicMock()
|
|
api._quote_delegate = lambda: None
|
|
api.get_daily_chart = AsyncMock(return_value={"output2": list(reversed(recent))})
|
|
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(int(price))}})
|
|
api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [
|
|
{"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]})
|
|
api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis range down"))
|
|
monkeypatch.setattr(es, "korea_investment_api", api)
|
|
|
|
def _ces(**kw):
|
|
captured.append(kw["rsi"])
|
|
return signal
|
|
monkeypatch.setattr(es, "check_exit_signal", _ces)
|
|
monkeypatch.setattr(es, "_time_stop_signal_v2", lambda *a, **k: None)
|
|
monkeypatch.setattr(es, "_resolve_time_stop_entry_v3", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(day_trading_state, "positions", {})
|
|
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
|
|
sent = []
|
|
|
|
async def _spo(**kw):
|
|
sent.append((kw["stock_code"], kw["quantity"]))
|
|
return {"rt_cd": "0", "output": {"ODNO": "9"}}
|
|
monkeypatch.setattr(te, "safe_place_order", _spo)
|
|
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock())
|
|
monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock())
|
|
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock())
|
|
monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock())
|
|
monkeypatch.setattr(pcb, "reflect_sell", AsyncMock())
|
|
monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock())
|
|
monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock())
|
|
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
|
|
return sent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_loop_passes_full_history_rsi(monkeypatch, _reset):
|
|
es = _reset
|
|
history, recent, closes = _fixture_bars()
|
|
import app.services.naver_finance as nf
|
|
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=history))
|
|
captured = []
|
|
_loop_env(monkeypatch, es, recent, captured)
|
|
es._recent_exits.clear()
|
|
await es.check_and_execute_exit_v2(MagicMock())
|
|
assert captured and captured[0] == pytest.approx(calculate_rsi(closes))
|
|
assert captured[0] != pytest.approx(calculate_rsi([float(b["stck_clpr"]) for b in recent]))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_loop_history_failure_keeps_stop_loss(monkeypatch, _reset):
|
|
"""이력 조회가 전부 실패해도 30봉 RSI 로 판정되고 손절 신호는 그대로 주문된다(§8.1-11)."""
|
|
es = _reset
|
|
_, recent, _ = _fixture_bars()
|
|
import app.services.naver_finance as nf
|
|
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("down")))
|
|
captured = []
|
|
sent = _loop_env(monkeypatch, es, recent, captured,
|
|
signal=ExitSignal(action="SELL_ALL", reason="손절 테스트", sell_pct=1.0))
|
|
es._recent_exits.clear()
|
|
await es.check_and_execute_exit_v2(MagicMock())
|
|
assert captured[0] == pytest.approx(calculate_rsi([float(b["stck_clpr"]) for b in recent]))
|
|
assert sent == [("005930", 10)]
|