"""재기동 내구성 회귀 테스트 (2026-09-28) ## 사건 장중 재기동 시 DT 포지션이 고아가 되고 당일 ORB 가 영구 0건이 됐다. 근본원인: `daytrading/scheduler.py` 가 복원(`load_orb_cache` / `restore_from_db`)을 마친 뒤, 첫 루프에서 `reset_if_new_day()` 를 호출한다. 새 프로세스는 `last_reset_date is None` 이고 복원 경로가 이 필드를 설정하지 않으므로 조건이 무조건 참이 되어 **방금 복원한 positions/orb_cache 를 전부 지웠다.** 복원 성공 로그는 그대로 출력되므로 조용히 실패했다. ## 검증하는 불변식 2개 (둘이 동시에 통과해야 한다) (a) 새 프로세스 기동 + 복원 → 첫 루프의 reset 후에도 포지션·ORB 가 살아있다 (b) 날짜가 실제로 바뀌면 정상 초기화되고 closed_today / daily_pnl / loss_streak_today 가 리셋된다 (§7 일일 -3% 한도·3연패 쿨다운·재진입 차단 보호) 추가: F2(ORB 전일 캔들 혼입 차단), F3(종료 drain 이 포지션 추적 계열 커밋), F4(DLQ 재처리가 큐 경유). """ from datetime import date, datetime, time as dtime, timedelta from unittest.mock import AsyncMock import pytest from app.services.daytrading.position_manager import DayTradingState def _fresh_state() -> DayTradingState: """기동 직후의 새 프로세스 상태 — last_reset_date is None.""" st = DayTradingState() assert st.last_reset_date is None return st def _simulate_restore(st: DayTradingState) -> None: """load_orb_cache() / restore_from_db() 가 채워 넣는 것과 동일한 결과.""" st.orb_cache["005930"] = object() st.positions["005930"] = { "stock_name": "삼성전자", "strategy": "ORB", "entry_price": 70000, "stop_loss": 68600, "target_price": 72100, "quantity": 10, "highest_price": 70500, "entry_time": datetime.now(), "restored_from_db": True, } # ── (a) 새 프로세스 기동 + 복원 → 첫 루프에서 지워지지 않는다 ──────────────── def test_restored_state_survives_first_scheduler_cycle(): st = _fresh_state() # 스케줄러 기동 시퀀스: begin_restore() → 복원 st.begin_restore() _simulate_restore(st) # 첫 루프의 reset_if_new_day() reset_fired = st.reset_if_new_day() assert reset_fired is False, "같은 날 첫 루프의 reset 은 no-op 이어야 한다" assert "005930" in st.positions, "복원한 포지션이 첫 루프에서 지워졌다" assert "005930" in st.orb_cache, "복원한 ORB 캐시가 첫 루프에서 지워졌다" def test_without_begin_restore_the_bug_reproduces(): """회귀 가드: begin_restore() 없이는 첫 루프가 복원분을 지운다(사건 재현).""" st = _fresh_state() _simulate_restore(st) assert st.reset_if_new_day() is True assert st.positions == {} assert st.orb_cache == {} def test_begin_restore_sets_today_boundary(): st = _fresh_state() st.begin_restore() from app.services.daytrading.position_manager import _today_kst assert st.last_reset_date == _today_kst() # ── (b) 날짜 경과 → 정상 초기화 (일일 게이트 누수 방지) ────────────────────── def test_new_day_resets_daily_gates(): st = _fresh_state() st.begin_restore() _simulate_restore(st) # 전일 상태 축적: 손실 한도·연패 쿨다운·재진입 차단 st.closed_today.add("000660") st.update_pnl(-500000.0, is_full_close=True) st.update_pnl(-300000.0, is_full_close=True) st.last_total_assets = 12_345_678.0 assert st.loss_streak_today == 2 assert st.daily_pnl == -800000.0 # 자정 경과 (프로세스는 계속 가동) st.last_reset_date = st.last_reset_date - timedelta(days=1) assert st.reset_if_new_day() is True, "날짜가 바뀌면 반드시 초기화되어야 한다" assert st.closed_today == set(), "전일 closed_today 누수 → 당일 재진입 차단 오작동" assert st.daily_pnl == 0.0, "전일 손익 누수 → 일일 -3% 한도 오작동" assert st.loss_streak_today == 0, "전일 연패 누수 → 3연패 쿨다운 오작동" assert st.positions == {} assert st.orb_cache == {} assert st.last_total_assets == 0.0 assert st.portfolio_cache_dirty is True def test_begin_restore_after_date_change_also_resets(): """장 마감 후 자정을 넘겨 재기동해도 전일 상태를 물려받지 않는다.""" st = _fresh_state() st.begin_restore() st.closed_today.add("005930") st.loss_streak_today = 3 st.last_reset_date = st.last_reset_date - timedelta(days=3) assert st.begin_restore() is True assert st.closed_today == set() assert st.loss_streak_today == 0 assert st.is_cooldown_active() is False # ── F2: ORB 소급수집 — 전일 캔들 혼입 차단 ────────────────────────────────── def test_filter_today_candles_drops_previous_day(): """get_minute_chart_full_day 는 FID_PW_DATA_INCU_YN='Y' 라 전일 봉이 섞인다. calculate_opening_range 는 시각만 보고 날짜를 보지 않으므로 필터가 없으면 전일 09:00~09:29 봉이 ORB 고저·거래량에 합산된다. """ from app.services.daytrading.entry_service import filter_today_candles from app.services.day_trading_strategy import ( MinuteCandle, calculate_opening_range, ) import pytz today = datetime.now(pytz.timezone("Asia/Seoul")).strftime("%Y%m%d") prev = ( datetime.now(pytz.timezone("Asia/Seoul")) - timedelta(days=1) ).strftime("%Y%m%d") def _c(d, hhmm, o, h, l, c, v): return MinuteCandle( date=d, time=hhmm + "00", open=o, high=h, low=l, close=c, volume=v, cum_volume=v, cum_amount=float(c * v), ) candles = [ # 전일 09:00~09:05 — 훨씬 넓은 레인지 + 큰 거래량 (오염원) _c(prev, "0900", 50000, 60000, 40000, 55000, 100000), _c(prev, "0901", 55000, 61000, 41000, 56000, 100000), _c(prev, "0902", 56000, 62000, 42000, 57000, 100000), _c(prev, "0903", 57000, 63000, 43000, 58000, 100000), _c(prev, "0904", 58000, 64000, 44000, 59000, 100000), # 당일 09:00~09:04 — 좁은 레인지 _c(today, "0900", 70000, 70500, 69800, 70200, 1000), _c(today, "0901", 70200, 70600, 70000, 70400, 1100), _c(today, "0902", 70400, 70700, 70100, 70500, 1200), _c(today, "0903", 70500, 70800, 70200, 70600, 1300), _c(today, "0904", 70600, 70900, 70300, 70700, 1400), ] filtered = filter_today_candles(candles) assert len(filtered) == 5 assert all(c.date == today for c in filtered) orb = calculate_opening_range(filtered) assert orb is not None assert orb.high == 70900 assert orb.low == 69800 assert orb.open_price == 70000 assert orb.total_volume == 6000 # 필터 없이 넘기면 오염된다 (회귀 가드) dirty = calculate_opening_range(candles) assert dirty is not None assert dirty.low == 40000, "전일 저가가 ORB 저점을 오염시킨다" assert dirty.total_volume == 506000, "전일 거래량이 평균분당거래량을 오염시킨다" assert dirty.open_price == 50000, "전일 시가가 갭 필터 기준을 오염시킨다" def test_filter_today_candles_passthrough_when_no_date(): """stck_bsop_date 가 비어 있는 응답에서 전체가 탈락하면 안 된다.""" from app.services.daytrading.entry_service import filter_today_candles from app.services.day_trading_strategy import MinuteCandle candles = [ MinuteCandle(date="", time="090000", open=1, high=2, low=1, close=2, volume=1, cum_volume=1, cum_amount=2.0) ] assert filter_today_candles(candles) == candles # ── F3: 종료 drain 이 포지션 추적 계열을 커밋한다 ──────────────────────────── class _FakeSession: """AsyncSession 대체 — SELECT 결과를 주입하고 commit 횟수를 센다.""" def __init__(self, record=None): self._record = record self.commits = 0 self.added = [] async def execute(self, *a, **k): rec = self._record class _Scalars: def first(self_inner): return rec def all(self_inner): return [rec] if rec is not None else [] class _Res: def scalars(self_inner): return _Scalars() def scalar_one_or_none(self_inner): return rec return _Res() async def commit(self): self.commits += 1 def add(self, obj): self.added.append(obj) class _FakeActiveTrade: """ActiveTrade 행 대체 — setattr 결과를 그대로 보관한다.""" def __init__(self, **kw): self.stock_code = kw.get("stock_code") self.status = "active" self.quantity = kw.get("quantity") self.remaining_pct = kw.get("remaining_pct", 1.0) self.highest_since_entry = kw.get("highest_since_entry") self.updated_at = None @pytest.mark.asyncio async def test_upsert_active_trade_updates_existing_row_and_commits(): """[C1 회귀] 기존 active 행에 대한 UPDATE 가 실제로 반영되고 commit 된다. 2026-09-28: 핸들러 추출 시 `for key, val in data.items()` 안의 `continue` 가 `return` 으로 잘못 바뀌어, **'stock_code' 가 모든 payload 의 첫 키**인 탓에 기존 행 UPDATE 가 전부 첫 이터레이션에서 함수를 빠져나갔다. 예외가 없어 워커는 성공 처리하고 DLQ 에도 남지 않는 조용한 전면 무효화였다. 파급: 분할매도 잔량 갱신(2026-09-23 '4주 보유 중 7주 매도' 재발 경로), remaining_pct_multiply(MDD 축소), highest_since_entry(트레일링) 전부. 소스 문자열 매칭이 아니라 **핸들러를 실제 호출해** 필드 갱신 + commit 1회를 본다. """ from app.services import db_worker record = _FakeActiveTrade(stock_code="005930", quantity=10, remaining_pct=1.0) session = _FakeSession(record=record) await db_worker._handle_upsert_active_trade(session, { "stock_code": "005930", # ← 첫 키. continue 가 아니면 여기서 탈출한다 "quantity": 4, # 분할매도 후 잔량 "highest_since_entry": 71000.0, # 트레일링 기준가 "strategy_name": "day_trade", }) assert record.quantity == 4, "분할매도 잔량 갱신이 반영되지 않았다 (과다매도 경로)" assert record.highest_since_entry == 71000.0, "트레일링 기준가 갱신이 반영되지 않았다" assert session.commits == 1, "commit() 에 도달하지 못했다" @pytest.mark.asyncio async def test_upsert_active_trade_applies_remaining_pct_multiply(): """[C1 회귀] MDD 축소의 remaining_pct_multiply 가 계산 후 커밋된다.""" from app.services import db_worker record = _FakeActiveTrade(stock_code="000660", quantity=10, remaining_pct=0.7) session = _FakeSession(record=record) await db_worker._handle_upsert_active_trade(session, { "stock_code": "000660", "remaining_pct_multiply": 0.5, "quantity": 5, }) assert record.remaining_pct == pytest.approx(0.35) assert record.quantity == 5 assert session.commits == 1 @pytest.mark.asyncio async def test_upsert_active_trade_skips_create_without_required_keys(): """active 행이 없고 필수 키가 없으면 생성하지 않고 조용히 끝낸다 (기존 동작).""" from app.services import db_worker session = _FakeSession(record=None) await db_worker._handle_upsert_active_trade(session, {"stock_code": "005930"}) assert session.added == [] assert session.commits == 0 @pytest.mark.asyncio async def test_close_and_portfolio_handlers_commit(): """close_active_trade / upsert_portfolio 도 실제로 커밋한다.""" from app.services import db_worker record = _FakeActiveTrade(stock_code="005930", quantity=10) session = _FakeSession(record=record) await db_worker._handle_close_active_trade( session, {"stock_code": "005930", "strategy_name": "day_trade"} ) assert record.status == "closed" assert session.commits == 1 class _Pf: def __init__(self): self.stock_code = "005930" self.quantity = 10 pf = _Pf() session2 = _FakeSession(record=pf) await db_worker._handle_upsert_portfolio( session2, {"stock_code": "005930", "quantity": 4} ) assert pf.quantity == 4 assert session2.commits == 1 @pytest.mark.asyncio async def test_drain_commits_position_tracking_types(): """종료 drain 이 포지션 추적 계열을 DLQ 가 아니라 공통 핸들러로 커밋한다. 배선(어느 핸들러가 걸렸는지)은 소스로, 커밋 동작은 위 핸들러 테스트로 각각 본다. """ import inspect from app.services import db_worker src = inspect.getsource(db_worker.db_writer_worker) drain = src[src.index("큐 drain"):] for t, handler in ( ("upsert_portfolio", "_handle_upsert_portfolio"), ("upsert_active_trade", "_handle_upsert_active_trade"), ("close_active_trade", "_handle_close_active_trade"), ): assert f'drain_type == "{t}"' in drain, f"{t} drain 분기 없음" assert handler in drain, f"{t} 가 공통 핸들러를 쓰지 않는다" # 정상 경로도 같은 핸들러를 써야 한다 (복제 금지) for handler in ("_handle_upsert_portfolio", "_handle_upsert_active_trade", "_handle_close_active_trade"): assert src.count(handler) >= 2, f"{handler} 가 한쪽 경로에만 배선됨" def test_no_stray_return_in_active_trade_update_loop(): """[C1] UPDATE 루프의 스킵은 return 이 아니라 continue 여야 한다.""" import inspect from app.services import db_worker src = inspect.getsource(db_worker._handle_upsert_active_trade) loop = src[src.index("for key, val in data.items():"):] # 주석은 제외하고 실행문만 본다 (주석에 'return' 이라는 단어가 들어 있다) code = "\n".join( l for l in loop.splitlines() if l.strip() and not l.strip().startswith("#") ) assert "continue" in code assert "return" not in code, "루프 안 return 은 commit 을 건너뛴다" # ── F4: DLQ 재처리는 큐를 경유한다 (직접 DB 쓰기 금지) ────────────────────── @pytest.mark.asyncio async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch): from app.services import db_worker dlq = tmp_path / "dlq" dlq.mkdir() monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") import json as _json good = dlq / "20260928_100000_upsert_active_trade_abcd1234.json" good.write_text(_json.dumps({ "task": {"type": "upsert_active_trade", "data": {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, "_retry_count": 3}, "error": "shutdown_drain", }), encoding="utf-8") broken = dlq / "20260928_100001_upsert_portfolio_deadbeef.json" broken.write_text("{not json", encoding="utf-8") enqueued = [] async def _fake_enqueue(task): enqueued.append(task) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) count = await db_worker.replay_dlq() assert count == 1 assert len(enqueued) == 1 assert enqueued[0]["type"] == "upsert_active_trade" assert "_retry_count" not in enqueued[0], "재시도 카운터가 초기화되어야 한다" # 처리분은 이동 → 다음 기동에서 재처리 무한루프가 생기지 않는다 assert not good.exists() assert (dlq / "processed" / good.name).exists() # 손상 파일은 남겨 사람이 본다 assert broken.exists() # 두 번째 호출은 손상 파일만 남으므로 재큐잉 0건 enqueued.clear() assert await db_worker.replay_dlq() == 0 assert enqueued == [] @pytest.mark.asyncio async def test_replay_dlq_empty_dir(tmp_path, monkeypatch): from app.services import db_worker monkeypatch.setattr(db_worker, "DLQ_DIR", tmp_path / "nope") assert await db_worker.replay_dlq() == 0 # ── F6: 스캔 처리량 — 선조회·라운드로빈·시간예산 ──────────────────────────── @pytest.mark.asyncio async def test_prefetch_scans_all_watchlist_within_budget(monkeypatch): """20종목 감시목록이 한 사이클에 전부 스캔된다. 이전에는 종목별 분봉 조회가 순차라 스케줄러 _guard(180초)에 잘려 앞쪽 5~6종목만 검사됐다. 선조회를 제한 동시성으로 돌려 예산 안에 들어오게 한다. """ from app.services.daytrading import entry_service as es from app.services.daytrading.position_manager import day_trading_state codes = [f"{i:06d}" for i in range(1, 21)] fetched = [] async def fake_fetch(code): fetched.append(code) await __import__("asyncio").sleep(0.05) # 조회 지연 모사 return [object()] monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) es._candle_cache.clear() n = await es._prefetch_minute_charts(codes) assert n == 20, "20종목 모두 선조회되어야 한다" assert sorted(fetched) == sorted(codes) # 동시성이 걸려 있어 순차(20×0.05=1.0초)보다 빠르다 assert es._SCAN_PREFETCH_CONCURRENCY >= 2 # 캐시 히트는 재조회하지 않는다 fetched.clear() assert await es._prefetch_minute_charts(codes) == 20 assert fetched == [] day_trading_state.positions.clear() @pytest.mark.asyncio async def test_prefetch_slow_stock_does_not_block_others(monkeypatch): """한 종목이 느려도 나머지는 스캔된다 (종목 단위 타임아웃).""" import asyncio as aio from app.services.daytrading import entry_service as es async def fake_fetch(code): if code == "000001": await aio.sleep(10) return [object()] monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) monkeypatch.setattr(es, "_SCAN_PREFETCH_TIMEOUT", 0.05) es._candle_cache.clear() n = await es._prefetch_minute_charts(["000001", "000002", "000003"]) assert n == 2 assert "000001" not in es._candle_cache @pytest.mark.asyncio async def test_get_candles_cached_falls_back_to_fetch(monkeypatch): """선조회 miss 면 그 자리에서 조회한다 (_try_enter 시그니처 무변경).""" import inspect from app.services.daytrading import entry_service as es calls = [] async def fake_fetch(code): calls.append(code) return [object()] monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) es._candle_cache.clear() assert await es._get_candles_cached("005930") assert calls == ["005930"] assert await es._get_candles_cached("005930") assert calls == ["005930"], "캐시 히트 시 재조회 금지" # §4: 기존 시그니처 무변경 (테스트 목이 빗나가지 않게) sig = list(inspect.signature(es._try_enter).parameters) assert sig == ["stock_code", "stock_name", "total_assets", "available_cash", "is_lunch_break"] def test_scan_time_budget_below_guard_timeout(): """시간 예산이 스케줄러 _guard 타임아웃보다 작아야 '취소' 대신 '로그'로 끝난다.""" from app.services.daytrading import entry_service as es from app.services.daytrading import scheduler as sched assert es._SCAN_TIME_BUDGET < sched.STEP_TIMEOUT src = __import__("inspect").getsource(es.scan_and_enter) assert "_scan_offset" in src, "라운드로빈 오프셋 없음" assert "미스캔" in src, "잘린 종목 수를 로그에 남겨야 한다" # ── F7: 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 ───────────────── def _c5(vol, close=101.0, open_=100.0): from app.services.day_trading_strategy import MinuteCandle return MinuteCandle(date="20260928", time="090000", open=open_, high=close + 1, low=open_ - 1, close=close, volume=vol, cum_volume=vol, cum_amount=float(close * vol)) def test_volume_surge_ratio_uses_completed_bars(): from app.services.day_trading_strategy import volume_surge_ratio # [-3]=1000(완성), [-2]=1500(완성), [-1]=100(형성 중) candles = [_c5(900), _c5(1000), _c5(1500), _c5(100)] assert volume_surge_ratio(candles) == pytest.approx(1.5) # 완성봉이 2개 미만이면 None assert volume_surge_ratio([_c5(100), _c5(200)]) is None # 분모 0 방어 assert volume_surge_ratio([_c5(0), _c5(100), _c5(50)]) is None def test_vwap_and_rsi_bb_gate_pass_with_forming_bar_partial_volume(): """형성 중 봉의 부분 거래량 때문에 탈락하던 케이스가 통과한다. 임계값(DAY_TRADE_VOLUME_MULTIPLIER)은 바꾸지 않았다 — 비교 대상만 고쳤다. """ from app.core.config import settings from app.services.day_trading_strategy import ( MinuteCandle, check_vwap_bounce_signal, check_rsi_bb_signal, volume_surge_ratio, ) mult = settings.DAY_TRADE_VOLUME_MULTIPLIER # 완성봉 거래량이 mult 배로 늘었지만, 형성 중 봉은 아직 1/5 만 찼다 prev_vol = 1000 last_vol = int(prev_vol * mult) + 10 forming_vol = last_vol // 5 # RSI 과매도 + BB 하단 이탈을 만드는 하락 시퀀스 (20봉 이상) closes = [200 - i * 1.0 for i in range(22)] + [170.0, 160.0] candles = [] for i, c in enumerate(closes): vol = 1000 if i == len(closes) - 3: vol = prev_vol elif i == len(closes) - 2: vol = last_vol elif i == len(closes) - 1: vol = forming_vol candles.append(MinuteCandle( date="20260928", time=f"09{i:02d}00", open=c - 1.0, high=c + 1.0, low=c - 2.0, close=c, volume=vol, cum_volume=vol * (i + 1), cum_amount=float(c * vol), )) # 마지막 형성 중 봉은 양봉 (반등) last = candles[-1] candles[-1] = MinuteCandle( date=last.date, time=last.time, open=last.close - 3.0, high=last.close + 1.0, low=last.close - 4.0, close=last.close, volume=last.volume, cum_volume=last.cum_volume, cum_amount=last.cum_amount, ) # 게이트 자체: 수정 후 통과 / 수정 전 방식이라면 탈락 assert volume_surge_ratio(candles) >= mult assert candles[-1].volume < candles[-2].volume * mult, "옛 비교 방식이라면 탈락하는 데이터" price = candles[-1].close sig_rsi_bb = check_rsi_bb_signal("005930", "삼성전자", candles, price) assert sig_rsi_bb is not None, "거래량 게이트가 형성 중 봉 때문에 막히면 안 된다" # §4: description 에 임계값 하드코딩 금지 — 실효값이 들어간다 assert f"{mult}" in sig_rsi_bb.reason assert "완성봉 기준" in sig_rsi_bb.reason sig_vwap = check_vwap_bounce_signal("005930", "삼성전자", candles, price, price) assert sig_vwap is not None assert f"{mult}" in sig_vwap.reason def test_volume_threshold_unchanged(): """임계값은 건드리지 않았다 (완화 아님).""" from app.core.config import settings assert settings.DAY_TRADE_VOLUME_MULTIPLIER == 1.3 assert settings.DAY_TRADE_VWAP_RSI_THRESHOLD == 45.0 assert settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD == 35.0 # ── F8: watchlist 교체가 ORB 보유 종목을 버리지 않는다 ────────────────────── @pytest.mark.asyncio async def test_watchlist_refresh_preserves_orb_holders(monkeypatch): """30분 갱신(=09:30 첫 사이클 즉시 발동)이 ORB 수집분을 버리면 안 된다.""" from app.services.daytrading import entry_service as es from app.services.daytrading.position_manager import day_trading_state es.set_watchlist([{"code": "111111", "name": "ORB보유"}, {"code": "222222", "name": "ORB없음"}]) day_trading_state.orb_cache.clear() day_trading_state.set_orb("111111", object()) # 당일 배치가 전혀 다른 종목을 상위로 올린 상황 new_rows = [{"code": "333333", "name": "신규A"}, {"code": "444444", "name": "신규B"}] async def fake_query(*a, **k): return new_rows # populate 의 DB 구간만 대체 — SELECT 결과 주입 class _Row: def __init__(self, code, name): self.stock_code = code self.stock_name = name self.score = 5 class _Res: def __init__(self, rows): self._rows = rows def all(self): return self._rows def scalar_one_or_none(self): return date.today() class _Session: async def __aenter__(self): return self async def __aexit__(self, *a): return False def __init__(self): self._n = 0 async def execute(self, *a, **k): self._n += 1 if self._n == 1: return _Res([]) # max(analysis_date) → scalar_one_or_none return _Res([_Row(r["code"], r["name"]) for r in new_rows]) import app.core.database as dbmod monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _Session()) count = await es.populate_daytrading_watchlist() wl = es.get_watchlist() assert count == 3 assert "111111" in wl, "ORB 보유 종목이 교체로 유실됐다 → 당일 ORB 진입 불가" assert "333333" in wl and "444444" in wl assert "222222" not in wl, "ORB 없는 옛 종목은 교체된다" assert es.is_watchlist_auto() is True day_trading_state.orb_cache.clear() es.set_watchlist([]) def test_watchlist_uses_latest_analysis_date_not_today_only(): """e0178aa(2026-09-22)의 `max(analysis_date <= today)` 완화가 살아 있는지.""" import inspect from app.services.daytrading import entry_service as es src = inspect.getsource(es.populate_daytrading_watchlist) assert "func.max(StockAnalysis.analysis_date)" in src assert "StockAnalysis.analysis_date <= today" in src assert "StockAnalysis.analysis_date == latest_date" in src def test_scheduler_populates_watchlist_before_orb_collection(): """09:00~09:30 분기에서 watchlist 구성이 ORB 수집보다 먼저 와야 한다.""" import inspect from app.services.daytrading import scheduler as sched src = inspect.getsource(sched.daytrading_scheduler) branch = src[src.index("is_opening_range_period()"):] i_pop = branch.index("populate_daytrading_watchlist()") i_orb = branch.index("collect_opening_ranges()") assert i_pop < i_orb, "ORB 수집보다 watchlist 구성이 먼저여야 한다" # ── F9: 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화 ─────── class _FakeRedis: """setex_json / get_json 대체 — 인메모리 저장소.""" def __init__(self): self.store: dict = {} async def setex_json(self, key, ttl, data): self.store[key] = data return True async def get_json(self, key): return self.store.get(key) @pytest.fixture def fake_redis(monkeypatch): """app.core.redis 를 테스트 시점에 덮는다 (import 시점 패치는 순서 의존 — §4.1).""" import app.core.redis as rd fr = _FakeRedis() monkeypatch.setattr(rd, "setex_json", fr.setex_json) monkeypatch.setattr(rd, "get_json", fr.get_json) return fr @pytest.mark.asyncio async def test_daily_guards_restored_on_same_day_restart(fake_redis): """같은 날 재기동 → 일일 -3% 한도·3연패 쿨다운·재진입 차단이 복원된다.""" st = _fresh_state() st.begin_restore() st.update_pnl(-250_000.0, is_full_close=True) st.update_pnl(-150_000.0, is_full_close=True) st.closed_today.update({"005930", "000660"}) assert await st.save_daily_state() is True # 새 프로세스 기동 (인메모리 전부 초기값) st2 = _fresh_state() st2.begin_restore() assert st2.daily_pnl == 0.0 and st2.loss_streak_today == 0 and st2.closed_today == set() assert await st2.load_daily_state() is True assert st2.daily_pnl == -400_000.0, "일일 손실한도가 리셋되면 -3% 를 맞은 날 매매가 재개된다" assert st2.loss_streak_today == 2 assert st2.closed_today == {"005930", "000660"} assert st2.is_closed_today("005930") is True, "손절 종목 당일 재진입이 열린다" # 연패가 3이면 쿨다운도 복원되어야 한다 st.update_pnl(-100_000.0, is_full_close=True) await st.save_daily_state() st3 = _fresh_state() st3.begin_restore() await st3.load_daily_state() assert st3.loss_streak_today == 3 assert st3.is_cooldown_active() is True @pytest.mark.asyncio async def test_daily_guards_not_restored_after_date_change(fake_redis): """날짜가 바뀐 뒤 기동 → 복원하지 않고 초기화 (F1 불변식 ②와 동시 통과). 전일 closed_today / daily_pnl 이 새면 다음 날 매매가 근거 없이 차단되거나 전일 손실이 당일 한도를 먹는다. """ from app.services.daytrading.position_manager import _today_kst st = _fresh_state() st.begin_restore() st.update_pnl(-900_000.0, is_full_close=True) st.closed_today.add("005930") await st.save_daily_state() # 저장된 페이로드의 날짜를 전일로 위조 + 키도 전일로 이동 (자정 경과 모사) today_key = f"daytrading:daily:{_today_kst()}" payload = dict(fake_redis.store.pop(today_key)) payload["date"] = (_today_kst() - timedelta(days=1)).isoformat() fake_redis.store[today_key] = payload # 키는 오늘, 내용은 전일 → 날짜 게이트가 잡아야 한다 st2 = _fresh_state() st2.begin_restore() assert await st2.load_daily_state() is False, "전일 페이로드를 복원하면 안 된다" assert st2.daily_pnl == 0.0 assert st2.loss_streak_today == 0 assert st2.closed_today == set() # 전일 키만 남아 있고 당일 키가 없는 경우 (정상적인 자정 경과) fake_redis.store.clear() fake_redis.store[f"daytrading:daily:{_today_kst() - timedelta(days=1)}"] = payload st3 = _fresh_state() st3.begin_restore() assert await st3.load_daily_state() is False assert st3.daily_pnl == 0.0 and st3.closed_today == set() @pytest.mark.asyncio async def test_daily_guard_restore_never_weakens_state(fake_redis): """복원이 현재 상태를 약화시키지 않는다 (보수적 병합).""" st = _fresh_state() st.begin_restore() st.update_pnl(-100_000.0, is_full_close=True) st.closed_today.add("005930") await st.save_daily_state() st2 = _fresh_state() st2.begin_restore() # 기동 후 이미 더 큰 손실·다른 청산이 발생한 상태에서 복원 st2.update_pnl(-500_000.0, is_full_close=True) st2.closed_today.add("000660") st2.loss_streak_today = 2 await st2.load_daily_state() assert st2.daily_pnl == -500_000.0, "더 보수적인(큰) 손실이 유지되어야 한다" assert st2.loss_streak_today == 2, "연패는 최대값 유지" assert st2.closed_today == {"005930", "000660"}, "재진입 차단은 합집합" @pytest.mark.asyncio async def test_daily_guard_redis_failure_is_not_silent(monkeypatch, caplog): """Redis 장애 시 조용히 실패하지 않는다 (§4: debug 금지).""" import logging import app.core.redis as rd async def _boom(*a, **k): raise RuntimeError("redis down") monkeypatch.setattr(rd, "setex_json", _boom) monkeypatch.setattr(rd, "get_json", _boom) st = _fresh_state() st.begin_restore() with caplog.at_level(logging.WARNING): assert await st.save_daily_state() is False assert await st.load_daily_state() is False levels = {r.levelno for r in caplog.records} assert levels and min(levels) >= logging.WARNING, "warning 이상으로 남아야 한다" assert any("안전장치" in r.message for r in caplog.records) def test_daily_guard_save_is_wired_into_exit_path(): """청산 경로가 변경 직후 저장을 호출하는지 (배선 누락 방지).""" import inspect from app.services.daytrading import exit_service as es from app.services.daytrading import scheduler as sched src = inspect.getsource(es) assert src.count("save_daily_state()") >= 2, "청산/전일청산 양쪽에 배선되어야 한다" # 복원은 begin_restore() 이후여야 한다 (순서가 뒤바뀌면 복원값이 초기화된다) ssrc = inspect.getsource(sched.daytrading_scheduler) assert ssrc.index("begin_restore()") < ssrc.index("load_daily_state()") @pytest.mark.asyncio async def test_mdd_reduction_persists_daily_guards(monkeypatch): """[F9 후속] MDD 축소가 DT 포지션을 닫으면 일일 안전장치가 저장된다. 저장이 없으면 축소 직후 재기동 시 closed_today 가 비어, MDD -10%/-20% 구간에서 방금 강제 축소한 종목을 그날 다시 매수할 수 있다. """ from app.services import mdd_manager as mm from app.services.daytrading.position_manager import day_trading_state saved = [] async def fake_save(): saved.append(True) return True async def fake_reflect_sell(*a, **k): return None import app.services.portfolio_cache_bridge as pcb monkeypatch.setattr(pcb, "reflect_sell", fake_reflect_sell) monkeypatch.setattr(day_trading_state, "save_daily_state", fake_save) day_trading_state.positions.clear() day_trading_state.closed_today.clear() day_trading_state.positions["005930"] = {"quantity": 5, "entry_price": 70000} # 전량 축소 (잔고 잔량 0) → remove_position + closed_today 추가 await mm._reflect_reduction("005930", sell_qty=5, price=71000, remaining_qty=0) assert "005930" not in day_trading_state.positions assert day_trading_state.is_closed_today("005930") is True assert saved, "closed_today 가 바뀌었는데 save_daily_state() 가 호출되지 않았다" # 부분 축소(포지션 유지)는 closed_today 가 바뀌지 않으므로 저장하지 않는다 saved.clear() day_trading_state.positions["000660"] = {"quantity": 10, "entry_price": 200000} await mm._reflect_reduction("000660", sell_qty=3, price=201000, remaining_qty=7) assert day_trading_state.positions["000660"]["quantity"] == 7 assert saved == [] day_trading_state.positions.clear() day_trading_state.closed_today.clear() def test_legacy_day_trading_endpoint_state_is_dead_path(): """`api/endpoints/day_trading.py` 의 `_closed_today` 는 엔진 상태와 분리된 죽은 경로다. 유일한 변경 지점(_check_exits / _execute_eod_flat)은 run_day_trading_cycle 에서만 도달하고, 그 호출부는 DEPRECATED 인 schedulers.day_trade_scheduler() 뿐이며 main.py lifespan 에서 기동되지 않는다. 따라서 저장을 붙이지 않는다. """ import inspect import app.main as main_mod from app.services import schedulers as sch main_src = inspect.getsource(main_mod) assert "day_trade_scheduler()" not in main_src, ( "레거시 스케줄러가 기동되면 _closed_today 경로가 되살아난다 → F9 저장 배선 필요" ) assert "daytrading_scheduler()" in main_src assert "DEPRECATED" in inspect.getdoc(sch.day_trade_scheduler) # ── 벽시계 의존 제거 헬퍼 ───────────────────────────────────────────────────── # # `scan_and_enter()` 는 `settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN`(기본 15:00) 이후 # 조기 리턴한다. 시각을 고정하지 않으면 **테스트가 몇 시에 돌리느냐에 따라** 결과가 # 달라진다(15:05 에 실측 1 failed). 임계값을 하드코딩하지 않고 settings 에서 역산한 # "컷오프 1시간 전"으로 고정한다(§4.1). def _freeze_entry_window(monkeypatch, es): """진입 윈도우 안으로 시각을 고정한다. **날짜는 실제 오늘 그대로** 둔다. 날짜까지 바꾸면 당일 분봉 날짜 비교·상태 리셋 판정과 어긋난다. 고정 대상은 `entry_service` 모듈이 참조하는 `datetime` 뿐이며, 다른 모듈은 실제 시계를 본다. """ import pytz from app.core.config import settings as _s kst = pytz.timezone("Asia/Seoul") cutoff_h, cutoff_m = _s.DAY_TRADE_ENTRY_CUTOFF_HOUR, _s.DAY_TRADE_ENTRY_CUTOFF_MIN minutes = max(9 * 60 + 30, cutoff_h * 60 + cutoff_m - 60) # 컷오프 1시간 전(≥09:30) frozen = kst.localize(datetime.combine( datetime.now(kst).date(), dtime(minutes // 60, minutes % 60) )) class _FrozenDatetime(datetime): @classmethod def now(cls, tz=None): return frozen if tz is not None else frozen.replace(tzinfo=None) monkeypatch.setattr(es, "datetime", _FrozenDatetime) return frozen # ── C2: 재기동 직후 '첫' 스캔에서 일일 손실한도가 차단되어야 한다 ─────────── @pytest.mark.asyncio async def test_daily_loss_limit_blocks_on_first_scan_after_restart(fake_redis, monkeypatch): """`last_total_assets` 가 0 이면 첫 사이클에서 -3% 한도가 뚫린다. is_daily_loss_exceeded() 는 total_assets<=0 에서 무조건 False 이고, scan_and_enter() 는 잔고 조회(함수 뒤쪽)보다 **먼저** 이 필드를 읽는다. """ from app.core.config import settings from app.services.day_trading_strategy import is_daily_loss_exceeded from app.services.daytrading import entry_service as es from app.services.daytrading.position_manager import DayTradingState # -3.5% 를 맞고 중단된 날의 상태를 저장 st = DayTradingState() st.begin_restore() st.last_total_assets = 100_000_000.0 st.update_pnl(-3_500_000.0, is_full_close=True) assert await st.save_daily_state() is True # 새 프로세스 기동 → 복원 st2 = DayTradingState() st2.begin_restore() assert st2.last_total_assets == 0.0 assert is_daily_loss_exceeded(st2.last_total_assets, -3_500_000.0) is False, ( "복원 전에는 게이트가 무력 — 이것이 C2 의 버그 조건" ) assert await st2.load_daily_state() is True assert st2.last_total_assets == 100_000_000.0 assert is_daily_loss_exceeded(st2.last_total_assets, st2.daily_pnl) is True, ( "재기동 직후 첫 스캔에서 일일 -3% 한도가 차단되어야 한다" ) # 실제 scan_and_enter() 의 첫 게이트에서 진입 0건으로 끝나는지 확인 monkeypatch.setattr(es, "day_trading_state", st2) monkeypatch.setattr(es.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)) monkeypatch.setattr(es.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) monkeypatch.setattr(es, "is_daytrading_allowed_by_playbook", lambda *a, **k: True) async def _boom_enter(*a, **k): raise AssertionError("일일 손실한도를 통과해 진입을 시도했다") monkeypatch.setattr(es, "_try_enter", _boom_enter) _freeze_entry_window(monkeypatch, es) # 컷오프 게이트가 아니라 손실한도로 막혔음을 보장 es.set_watchlist([{"code": "005930", "name": "삼성전자"}]) entries = await es.scan_and_enter() assert entries == [] assert settings.DAY_TRADE_DAILY_LOSS_LIMIT < 0 es.set_watchlist([]) @pytest.mark.asyncio async def test_last_total_assets_merge_picks_conservative_denominator(fake_redis): """분모는 작은 쪽(=손실률이 더 크게 나오는 쪽)이 보수적. 0 은 후보에서 제외.""" from app.services.daytrading.position_manager import DayTradingState st = DayTradingState() st.begin_restore() st.last_total_assets = 80_000_000.0 st.daily_pnl = -1_000.0 await st.save_daily_state() st2 = DayTradingState() st2.begin_restore() st2.last_total_assets = 120_000_000.0 # 기동 후 조회된 더 큰 총자산 await st2.load_daily_state() assert st2.last_total_assets == 80_000_000.0, "큰 분모는 게이트를 느슨하게 만든다" # 저장값이 0 이면 현재값을 유지 (0 은 게이트를 무력화하므로 선택 금지) st3 = DayTradingState() st3.begin_restore() st3.last_total_assets = 0.0 st3.daily_pnl = -1_000.0 await st3.save_daily_state() st4 = DayTradingState() st4.begin_restore() st4.last_total_assets = 50_000_000.0 await st4.load_daily_state() assert st4.last_total_assets == 50_000_000.0 # ── M1: 청산이 ORB 소급수집보다 먼저 ──────────────────────────────────────── def test_orb_backfill_deferred_until_after_first_exit_check(): """기동 시퀀스에서 backfill 을 await 하지 않고, 루프의 청산 체크 뒤에 실행한다. §8.1-11 청산 우선. backfill 은 최대 300초를 점유하므로 기동 직후에 await 하면 손절·트레일링·시간손절·EOD 가 전부 그만큼 늦어진다. """ import inspect from app.services.daytrading import scheduler as sched src = inspect.getsource(sched.daytrading_scheduler) startup = src[:src.index("while True:")] assert "backfill_opening_ranges" not in startup, "기동 시퀀스에서 await 하면 안 된다" assert "_orb_backfill_pending = True" in startup loop = src[src.index("while True:"):] i_exit = loop.index("check_and_exit_positions()") i_backfill = loop.index("backfill_opening_ranges") assert i_exit < i_backfill, "청산 체크가 ORB 소급수집보다 먼저여야 한다" assert "_orb_backfill_pending = False" in loop, "1회만 실행되어야 한다" # ── M2: 오염(전일 봉만 돌아옴)과 날짜 필드 부재를 구분 ────────────────────── def test_filter_today_candles_discards_previous_day_only_response(caplog): """날짜가 다 있는데 당일 봉이 0건이면 폐기 + warning (조용한 오염 방지).""" import logging from app.services.daytrading.entry_service import filter_today_candles from app.services.day_trading_strategy import MinuteCandle prev = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") candles = [ MinuteCandle(date=prev, time=f"09{i:02d}00", open=100, high=101, low=99, close=100, volume=10, cum_volume=10, cum_amount=1000.0) for i in range(10) ] with caplog.at_level(logging.WARNING): out = filter_today_candles(candles) assert out == [], "전일 캔들로 ORB 를 계산하면 고저·거래량이 오염된다" assert any("당일" in r.message and r.levelno >= logging.WARNING for r in caplog.records), "조용히 실패하면 안 된다" def test_filter_today_candles_passthrough_only_when_date_missing(): """날짜 필드가 비어 있는 응답만 통과시킨다 (목/레거시 방어).""" from app.services.daytrading.entry_service import filter_today_candles from app.services.day_trading_strategy import MinuteCandle undated = [MinuteCandle(date="", time="090000", open=1, high=2, low=1, close=2, volume=1, cum_volume=1, cum_amount=2.0)] assert filter_today_candles(undated) == undated assert filter_today_candles([]) == [] # ── C3/F10: 지표 시계열 vs 세션 앵커 지표의 입력 분리 ─────────────────────── # # ⚠️ 사실관계(2026-09-28 정정): 조회 계층(korea_investment_api.get_minute_chart_full_day, # M4 2026-09-27)이 이미 당일 봉만 반환하므로 프로덕션에서 전일 봉은 들어오지 않는다. # 즉 워밍업은 당일 봉으로만 채워지고 VWAP ~10:15 / RSI_BB ~10:40 이전 발화 불가가 # **현재의 알려진 제약**이다(조회 계층 변경 + 백테스트가 필요한 별건). # 아래 테스트는 "워밍업이 확보돼 있다"를 주장하지 않는다. 조회 계층이 무엇을 주더라도 # **세션 앵커 지표(VWAP·ORB 거래량·현재가)에는 당일 봉만 흘러간다**는 배선을 고정한다. def _min_candle(d, hhmmss, close, volume=1000, open_=None): from app.services.day_trading_strategy import MinuteCandle op = close - 1.0 if open_ is None else open_ return MinuteCandle(date=d, time=hhmmss, open=op, high=max(op, close) + 1.0, low=min(op, close) - 1.0, close=close, volume=volume, cum_volume=volume, cum_amount=float(close * volume)) @pytest.mark.asyncio async def test_entry_path_keeps_warmup_but_anchors_session_indicators(monkeypatch): """조회 계층이 전일 봉을 섞어 주더라도 세션 지표는 당일 봉만 받는다. 프로덕션에서는 조회 계층이 이미 당일만 주므로 이 입력(전일+당일)은 **가정적**이다. 검증 대상은 배선이다: 지표용 5분봉에는 받은 시계열 전체가 흘러가고(워밍업 가능), VWAP·현재가에는 당일 봉만 흘러간다. 조회 계층 필터가 미래에 풀려도 세션 지표가 오염되지 않음을 보장한다. """ import pytz from app.services.daytrading import entry_service as es kst = pytz.timezone("Asia/Seoul") today = datetime.now(kst).strftime("%Y%m%d") prev = (datetime.now(kst) - timedelta(days=1)).strftime("%Y%m%d") # 전일 1분봉 120개(= 5분봉 24개 워밍업) + 당일 09:00~09:04 5개(= 5분봉 1개) candles = [_min_candle(prev, f"{9 + i // 60:02d}{i % 60:02d}00", 100.0 + (i % 5)) for i in range(120)] candles += [_min_candle(today, f"090{i}00", 200.0) for i in range(5)] captured = {} async def fake_fetch(code): return candles def fake_vwap(cs): captured["vwap_dates"] = sorted({c.date for c in cs}) return 200.0 def fake_rsi_bb(code, name, candles_5m, price): captured["rsi_bb_bars"] = len(candles_5m) captured["rsi_bb_dates"] = sorted({c.date for c in candles_5m}) captured["price"] = price return None def fake_vwap_sig(code, name, candles_5m, vwap, price): captured["vwap_bars"] = len(candles_5m) return None monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) monkeypatch.setattr(es, "calculate_vwap", fake_vwap) monkeypatch.setattr(es, "check_rsi_bb_signal", fake_rsi_bb) monkeypatch.setattr(es, "check_vwap_bounce_signal", fake_vwap_sig) monkeypatch.setattr(es.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) monkeypatch.setattr(es, "is_vi_active", lambda code: False) es._candle_cache.clear() result = await es._try_enter("005930", "삼성전자", 100_000_000.0, 50_000_000.0) assert result is None # 신호 없음 (스텁이 None 반환) # 워밍업: 지표용 5분봉에 전일 봉이 포함되어 BB(20) 요건을 충족한다 assert captured["rsi_bb_bars"] >= 20, ( f"5분봉 {captured['rsi_bb_bars']}개 — BB(20) 워밍업 불가. " "당일만 쓰면 10:40 이전 RSI_BB 발화가 봉쇄된다" ) assert prev in captured["rsi_bb_dates"] and today in captured["rsi_bb_dates"] assert captured["vwap_bars"] >= 20 # 세션 앵커: VWAP 는 당일 봉만 assert captured["vwap_dates"] == [today], "VWAP 에 전일 누적이 섞이면 안 된다" # 현재가도 당일 마지막 봉 기준 assert captured["price"] == 200.0, "현재가가 전일 종가로 잡히면 안 된다" @pytest.mark.asyncio async def test_entry_path_refuses_when_no_today_candles(monkeypatch): """전일 봉만 돌아오면 진입 판정을 하지 않는다(현재가·VWAP 오염 방지). 조회 계층 필터 덕에 현재는 도달 불가한 경로지만, 휴장일·조회 이상에서 당일 봉이 0건이면 현재가·VWAP·거래량 기준을 만들 수 없으므로 진입을 포기해야 한다. """ import pytz from app.services.daytrading import entry_service as es kst = pytz.timezone("Asia/Seoul") prev = (datetime.now(kst) - timedelta(days=1)).strftime("%Y%m%d") candles = [_min_candle(prev, f"09{i:02d}00", 100.0) for i in range(30)] async def fake_fetch(code): return candles def _boom(*a, **k): raise AssertionError("전일 봉만 있는데 신호 판정을 시도했다") monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) monkeypatch.setattr(es, "check_rsi_bb_signal", _boom) monkeypatch.setattr(es, "check_vwap_bounce_signal", _boom) monkeypatch.setattr(es.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) monkeypatch.setattr(es, "is_vi_active", lambda code: False) es._candle_cache.clear() assert await es._try_enter("005930", "삼성전자", 100_000_000.0, 50_000_000.0) is None def test_orb_collection_still_filters_today_only(): """ORB 수집 경로는 당일 필터를 유지해야 한다 (§7 오염 차단 = F2 의 핵심). 조회 계층이 이미 당일만 주므로 방어적 이중화지만, 그 필터가 풀리면 ORB 고저· 평균분당거래량이 즉시 오염되므로 여기서 걷어내선 안 된다. """ import inspect from app.services.daytrading import entry_service as es src = inspect.getsource(es.collect_opening_ranges) assert "filter_today_candles(parse_minute_candles(raw))" in src, ( "ORB 수집에서 당일 필터가 빠지면 전일 고저·거래량이 ORB 를 오염시킨다" ) # 조회 계층 래퍼는 필터를 걸지 않는다 — 당일 보장은 KIS 래퍼가 이미 한다. # (여기서 다시 걸면 '필터가 두 곳'이 되어 어느 쪽이 진실인지 흐려진다) fetch_src = inspect.getsource(es._fetch_candles_impl) assert "return parse_minute_candles(raw)" in fetch_src # ── M3: 캐시 TTL ≥ 사이클 예산 ────────────────────────────────────────────── def test_candle_cache_ttl_covers_one_cycle(): """TTL 이 선조회+순차스캔 시간보다 짧으면 선조회가 스스로 무효화된다.""" from app.services.daytrading import entry_service as es assert es._CANDLE_CACHE_TTL >= es._SCAN_TIME_BUDGET, ( f"TTL {es._CANDLE_CACHE_TTL}초 < 사이클 예산 {es._SCAN_TIME_BUDGET}초 " "— 앞쪽 선조회분이 순차 루프 시작 전에 만료된다" ) # ── M4: swing 청산 경로도 일일 안전장치를 저장한다 ────────────────────────── def test_swing_exit_persists_daily_guards(): """스윙 손절이 등록한 closed_today 가 재기동 후에도 유지되어야 한다.""" import inspect from app.services.swing import exit_service as ses src = inspect.getsource(ses) i_add = src.index("_dts_cd.closed_today.add(stock_code)") tail = src[i_add:i_add + 800] assert "save_daily_state()" in tail, ( "closed_today 추가 후 저장이 없으면 재기동 시 DT 재매수가 되살아난다" ) # ── M5: DLQ 재처리가 열화된 타입을 되돌리고 실제로 커밋된다 ───────────────── @pytest.mark.asyncio async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch): """`default=str` 로 열화된 payload 를 되돌려 재큐잉하고, 핸들러가 커밋한다. 되돌리지 않으면 asyncpg 가 DateTime 컬럼의 str 을 거부해 3회 재시도 후 **새 DLQ 파일로 다시 쌓인다**(기동마다 증식). """ import json as _json from datetime import datetime as _dt from decimal import Decimal from app.services import db_worker dlq = tmp_path / "dlq" dlq.mkdir() monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") # _save_to_dlq 가 실제로 만드는 형태 (json.dumps(default=str)) original = { "type": "upsert_active_trade", "data": { "stock_code": "005930", "quantity": 4, "entry_price": Decimal("70123.45"), "created_at": _dt(2026, 9, 28, 10, 30, 0), "strategy_name": "day_trade", }, } degraded = _json.loads(_json.dumps({"task": original, "error": "shutdown_drain"}, default=str)) assert isinstance(degraded["task"]["data"]["entry_price"], str) (dlq / "20260928_103000_upsert_active_trade_aaaa1111.json").write_text( _json.dumps(degraded), encoding="utf-8" ) enqueued = [] async def _fake_enqueue(task): enqueued.append(task) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) assert await db_worker.replay_dlq() == 1 data = enqueued[0]["data"] assert isinstance(data["entry_price"], Decimal), "Decimal 이 문자열로 남았다" assert data["entry_price"] == Decimal("70123.45") assert isinstance(data["created_at"], _dt), "datetime 이 문자열로 남았다" assert data["created_at"] == _dt(2026, 9, 28, 10, 30, 0) assert data["quantity"] == 4 # 재큐잉된 그 payload 로 핸들러가 실제로 커밋되는지 (동작 검증) record = _FakeActiveTrade(stock_code="005930", quantity=10) session = _FakeSession(record=record) await db_worker._handle_upsert_active_trade(session, data) assert record.quantity == 4 assert session.commits == 1 @pytest.mark.asyncio async def test_replay_dlq_restores_date_for_bulk_rows(tmp_path, monkeypatch): """bulk_upsert_stock_analysis 처럼 data 가 리스트인 경우도 복원한다.""" import json as _json from app.services import db_worker dlq = tmp_path / "dlq" dlq.mkdir() monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") (dlq / "20260928_110000_bulk_upsert_stock_analysis_bbbb2222.json").write_text( _json.dumps({"task": { "type": "bulk_upsert_stock_analysis", "data": [{"stock_code": "005930", "analysis_date": "2026-09-28"}], }}), encoding="utf-8") enqueued = [] async def _fake_enqueue(task): enqueued.append(task) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) assert await db_worker.replay_dlq() == 1 assert enqueued[0]["data"][0]["analysis_date"] == date(2026, 9, 28) # ── F11: 캐시 수명은 '한 사이클' — 사이클마다 재조회되어야 한다 ───────────── @pytest.mark.asyncio async def test_cache_is_cleared_each_cycle_so_signals_are_reevaluated(monkeypatch): """연속 두 사이클에서 같은 종목이 재조회된다. 시간 TTL(180초)만 두면 30초 주기 스케줄러에서 같은 스냅샷이 5개 사이클을 덮는다. 세 신호 함수는 (candles, orb, current_price) 의 순수 함수라 입력이 같으면 출력도 같으므로, t=0 에 신호가 없으면 t=150 까지 무조건 no-signal 이 되고 진입가·손절· 수량도 최대 3분 묵은 값이 된다. """ from app.services.daytrading import entry_service as es from app.services.daytrading.position_manager import day_trading_state fetches = [] async def fake_fetch(code): fetches.append(code) return [_min_candle(datetime.now().strftime("%Y%m%d"), "090000", 100.0)] monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) monkeypatch.setattr(es.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)) monkeypatch.setattr(es.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) monkeypatch.setattr(es, "is_daytrading_allowed_by_playbook", lambda *a, **k: True) monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})) monkeypatch.setattr(es.korea_investment_api, "get_balance", AsyncMock( return_value={"output2": [{"tot_evlu_amt": "100000000", "prvs_rcdl_excc_amt": "50000000"}]})) monkeypatch.setattr(es.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=50_000_000.0)) async def fake_try_enter(*a, **k): return None monkeypatch.setattr(es, "_try_enter", fake_try_enter) day_trading_state.positions.clear() day_trading_state.closed_today.clear() day_trading_state.daily_pnl = 0.0 day_trading_state.last_total_assets = 100_000_000.0 # 진입 마감(기본 15:00) 이후에 돌려도 통과해야 한다 — 벽시계 고정 _freeze_entry_window(monkeypatch, es) es.set_watchlist([{"code": "005930", "name": "삼성전자"}]) es._candle_cache.clear() # 사이클 1 → 선조회 1회 await es.scan_and_enter() first = len(fetches) assert first >= 1 # 사이클 2 (시간 경과 없음) → 캐시가 비워져 다시 조회되어야 한다 await es.scan_and_enter() assert len(fetches) > first, ( "사이클 경계에서 캐시를 비우지 않으면 같은 스냅샷으로 재평가해 " "최대 3분간 무조건 no-signal 이 된다" ) # 한 사이클 안에서는 선조회분을 재사용한다 (M3 의 목적 유지) assert es._CANDLE_CACHE_TTL >= es._SCAN_TIME_BUDGET es.set_watchlist([]) def test_scan_clears_cache_before_prefetch(): """캐시 clear 가 선조회보다 앞에 와야 한다(같은 사이클 안에서 지워지면 무의미).""" import inspect from app.services.daytrading import entry_service as es src = inspect.getsource(es.scan_and_enter) assert src.index("_candle_cache.clear()") < src.index("_prefetch_minute_charts(")