"""재기동 내구성 회귀 테스트 (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 재처리가 큐 경유). """ import asyncio 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" # failed_at 은 _save_to_dlq 가 항상 기록한다. C2(나이 제한) 이후 상태 덮어쓰기 # 계열은 최근 것만 재큐잉되므로 '방금 실패'로 둔다(나이 정책 자체는 전용 테스트에서 검증). 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", "failed_at": datetime.now().isoformat(), }), 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) async def _no_conflict(task, entry): return False, "" monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict) 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 @pytest.mark.asyncio async def test_truncated_scan_rotates_start_offset_next_cycle(monkeypatch, caplog): """[M-5 동작 검증] 예산에 걸려 잘리면 다음 사이클은 다른 종목부터 스캔한다. 소스에 '_scan_offset' 이 있는지 보는 대신, 두 사이클을 실제로 돌려 **스캔된 종목 순서**가 회전하는지 본다. 잘린 종목 수가 로그에 남는지도 함께 확인한다. """ import logging from app.services.daytrading import entry_service as es from app.services.daytrading.position_manager import day_trading_state scanned_order = [] async def slow_try_enter(code, name, ta, ac, is_lunch_break=False): scanned_order.append(code) await asyncio.sleep(0.05) # 예산을 넘기도록 느리게 return None async def fake_prefetch(codes): return len(codes) monkeypatch.setattr(es, "_try_enter", slow_try_enter) monkeypatch.setattr(es, "_prefetch_minute_charts", fake_prefetch) monkeypatch.setattr(es, "_SCAN_TIME_BUDGET", 0.04) 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)) # 벽시계 의존 제거 — 컷오프(15:00) 이후에 돌리면 조기 리턴해 빈 리스트가 된다 _freeze_entry_window(monkeypatch, es) 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 es._scan_offset = 0 es.set_watchlist([{"code": f"00000{i}", "name": f"종목{i}"} for i in range(4)]) with caplog.at_level(logging.WARNING): await es.scan_and_enter() first_cycle = list(scanned_order) scanned_order.clear() await es.scan_and_enter() second_cycle = list(scanned_order) assert first_cycle and second_cycle assert first_cycle[0] != second_cycle[0], ( f"두 사이클이 같은 종목부터 시작했다 ({first_cycle[0]}) — 뒤쪽 종목이 영구 소외된다" ) assert any("미스캔" in r.getMessage() for r in caplog.records), \ "잘린 종목 수가 로그에 남아야 한다" es.set_watchlist([]) es._scan_offset = 0 # ── 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([]) @pytest.mark.asyncio async def test_watchlist_uses_latest_analysis_date_not_today_only(monkeypatch): """[M-5 동작 검증] 당일 분석이 없어도 최근 분석일 결과로 watchlist 가 채워진다. e0178aa(2026-09-22) 완화. 소스 문자열이 아니라 **결과**로 본다: 조회 결과에서 최신 분석일이 어제여도 종목이 채워져야 한다(아니면 09:00 ORB 수집이 0건이 된다). """ from app.services.daytrading import entry_service as es yesterday = date.today() - timedelta(days=1) class _Row: def __init__(self, code): self.stock_code = code self.stock_name = f"종목{code}" self.score = 5 class _Res: def __init__(self, rows=None, scalar=None): self._rows = rows or [] self._scalar = scalar def all(self): return self._rows def scalar_one_or_none(self): return self._scalar class _Session: def __init__(self): self._n = 0 async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *a, **k): self._n += 1 if self._n == 1: return _Res(scalar=yesterday) # max(analysis_date <= today) = 어제 return _Res(rows=[_Row("005930"), _Row("000660")]) import app.core.database as dbmod monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _Session()) from app.services.daytrading.position_manager import day_trading_state day_trading_state.orb_cache.clear() es.set_watchlist([]) count = await es.populate_daytrading_watchlist() assert count == 2, "당일 분석이 없으면 watchlist 가 비어 ORB 수집이 0건이 된다" assert set(es.get_watchlist()) == {"005930", "000660"} es.set_watchlist([]) 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(): """청산 경로가 변경 직후 저장을 호출하는지 (배선 누락 방지). [M-5] 배선·순서 확인 전용. 저장/복원 동작은 별 테스트가 실제 호출로 검증한다. """ 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 가 재기동 후에도 유지되어야 한다. [M-5] 이것은 **배선 확인**이다(값·분기 판정이 아니다). 저장 동작 자체는 test_daily_guards_restored_on_same_day_restart / test_mdd_reduction_persists_daily_guards 가 실제 호출로 검증한다. 이 경로는 대형 함수 내부의 깊은 분기라 동작 검증에 필요한 목이 과도해지므로, 호출이 남아 있는지만 고정한다. """ 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", "failed_at": _dt.now().isoformat()}, # C2: 나이 제한 통과용(방금 실패) 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) async def _no_conflict(task, entry): return False, "" monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict) 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"}], }, "failed_at": datetime.now().isoformat()}), 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 # autouse 가드가 모듈 속성을 감싸므로 원본을 풀어서 본다 _fn = getattr(es.scan_and_enter, "__wrapped__", es.scan_and_enter) src = inspect.getsource(_fn) assert src.index("_candle_cache.clear()") < src.index("_prefetch_minute_charts(") # ── C2(2026-09-30): replay_dlq 나이 제한 + 상태 충돌 격리 ─────────────────── def _write_dlq(dlq_dir, task_type, data, failed_at, name=None): import json as _json name = name or f"20260930_100000_{task_type}_{abs(hash(str(data))) % 10**8:08x}.json" (dlq_dir / name).write_text(_json.dumps({ "task": {"type": task_type, "data": data}, "error": "shutdown_drain", "failed_at": failed_at.isoformat() if hasattr(failed_at, "isoformat") else failed_at, }), encoding="utf-8") return dlq_dir / name @pytest.fixture def dlq_env(tmp_path, monkeypatch): """DLQ 디렉터리 3종을 tmp 로 돌리고 enqueue 를 캡처한다.""" 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") monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") enqueued = [] async def _fake_enqueue(task): enqueued.append(task) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) # 상태 충돌 확인은 기본적으로 '충돌 없음' (개별 테스트가 필요 시 덮는다) async def _no_conflict(task, entry): return False, "" monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict) return dlq, enqueued @pytest.mark.asyncio async def test_replay_dlq_quarantines_stale_state_task(dlq_env, caplog): """오래된 upsert_active_trade 는 재큐잉하지 않고 expired/ 로 격리된다. 9/25 drain 으로 빠진 payload 가 그 사이 전량 청산된 뒤 9/30 기동에 재큐잉되면, 핸들러가 active 행을 못 찾고 필수 키가 다 있으니 **새 active 행을 INSERT** 한다 → §8.4.1 의 '9/23 유령 행 129개' 실패 모드 자동화. """ import logging from datetime import datetime as _dt, timedelta as _td from app.services import db_worker dlq, enqueued = dlq_env stale = _write_dlq(dlq, "upsert_active_trade", {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, _dt.now() - _td(days=5), name="20260925_100000_upsert_active_trade_a1.json") with caplog.at_level(logging.WARNING): assert await db_worker.replay_dlq() == 0 assert enqueued == [], "오래된 상태 태스크가 재큐잉됐다" assert not stale.exists() assert (dlq / "expired" / stale.name).exists(), "증거 보존을 위해 격리(삭제 금지)" msgs = " ".join(r.getMessage() for r in caplog.records) assert "격리" in msgs and "upsert_active_trade" in msgs and "나이" in msgs @pytest.mark.asyncio async def test_replay_dlq_requeues_recent_state_task(dlq_env): """최근(2시간 이내, 같은 날) 상태 태스크는 정상 재큐잉된다.""" from datetime import datetime as _dt, timedelta as _td from app.services import db_worker dlq, enqueued = dlq_env recent = _write_dlq(dlq, "upsert_active_trade", {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, _dt.now() - _td(minutes=3)) assert await db_worker.replay_dlq() == 1 assert len(enqueued) == 1 and enqueued[0]["type"] == "upsert_active_trade" assert (dlq / "processed" / recent.name).exists() @pytest.mark.asyncio async def test_replay_dlq_record_types_allow_longer_age(dlq_env): """기록 계열은 12시간 전도 허용, 48시간 전은 격리 (타입별 차등).""" from datetime import datetime as _dt, timedelta as _td from app.services import db_worker dlq, enqueued = dlq_env ok = _write_dlq(dlq, "insert_trade_history", {"stock_code": "005930"}, _dt.now() - _td(hours=12), name="a_insert_trade_history_1.json") too_old = _write_dlq(dlq, "insert_trade_history", {"stock_code": "000660"}, _dt.now() - _td(hours=48), name="b_insert_trade_history_2.json") assert await db_worker.replay_dlq() == 1 assert len(enqueued) == 1 assert enqueued[0]["data"]["stock_code"] == "005930" assert (dlq / "processed" / ok.name).exists() assert (dlq / "expired" / too_old.name).exists() @pytest.mark.asyncio async def test_replay_dlq_state_task_without_failed_at_is_quarantined(dlq_env): """failed_at 이 없으면 상태 계열은 보수적으로 격리, 기록 계열은 허용.""" from app.services import db_worker dlq, enqueued = dlq_env st = _write_dlq(dlq, "upsert_portfolio", {"stock_code": "005930", "quantity": 5}, "", name="a_upsert_portfolio_x.json") rec = _write_dlq(dlq, "insert_trade_journal", {"stock_code": "005930"}, "", name="b_insert_trade_journal_y.json") assert await db_worker.replay_dlq() == 1 assert len(enqueued) == 1 and enqueued[0]["type"] == "insert_trade_journal" assert (dlq / "expired" / st.name).exists() assert (dlq / "processed" / rec.name).exists() @pytest.mark.asyncio async def test_replay_dlq_quarantines_on_state_conflict(tmp_path, monkeypatch, caplog): """나이를 통과해도 현재 상태와 충돌하면 격리한다 (유령 행 차단).""" import logging from datetime import datetime as _dt, timedelta as _td 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") monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") enqueued = [] async def _fake_enqueue(task): enqueued.append(task) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) f = _write_dlq(dlq, "upsert_active_trade", {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, _dt.now() - _td(minutes=5)) # active 행 없음 + Portfolio 수량 0 → 유령 생성 위험 class _Sess: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *a, **k): class _S: def first(self_inner): return None class _R: def scalars(self_inner): return _S() return _R() import app.services.db_worker as dbw monkeypatch.setattr(dbw, "AsyncSessionLocal", lambda: _Sess()) with caplog.at_level(logging.WARNING): assert await db_worker.replay_dlq() == 0 assert enqueued == [] assert (dlq / "expired" / f.name).exists() assert "충돌" in " ".join(r.getMessage() for r in caplog.records) @pytest.mark.asyncio async def test_dlq_state_conflict_degrades_to_age_only_on_db_failure(monkeypatch): """DB 판정 실패는 '충돌 아님'으로 돌려 기동을 막지 않는다.""" from app.services import db_worker def _boom(): raise RuntimeError("db down") monkeypatch.setattr(db_worker, "AsyncSessionLocal", _boom) conflict, reason = await db_worker._dlq_state_conflict( {"type": "upsert_active_trade", "data": {"stock_code": "005930"}}, {"failed_at": "2026-09-30T10:00:00"}, ) assert conflict is False and reason == "" # ── M-2: DB 장애 시 충돌 판정 단락 (기동 지연 유계화) ─────────────────────── @pytest.mark.asyncio async def test_conflict_check_short_circuits_after_first_db_failure(tmp_path, monkeypatch, caplog): """DB 가 안 뜬 상태에서 파일마다 접속을 재시도하지 않는다. replay_dlq() 는 lifespan 에서 await 되므로 기동을 막는다. 단락이 없으면 (파일 수 × 접속 타임아웃)만큼 기동이 정지한다. 단락 후에도 **나이 제한은 계속 적용**되어야 한다. """ import logging from datetime import datetime as _dt, timedelta as _td 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") monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") enqueued = [] async def _fake_enqueue(task): enqueued.append(task) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) attempts = [] def _boom_session(): attempts.append(1) raise RuntimeError("connection refused") monkeypatch.setattr(db_worker, "AsyncSessionLocal", _boom_session) # 최근 상태 파일 5개 (모두 충돌 판정 대상) for i in range(5): _write_dlq(dlq, "upsert_active_trade", {"stock_code": f"00593{i}", "quantity": 1, "entry_price": 1000}, _dt.now() - _td(minutes=1), name=f"f{i}_upsert_active_trade_{i}.json") # 오래된 상태 파일 1개 — 단락 후에도 나이 제한으로 격리되어야 한다 old = _write_dlq(dlq, "upsert_active_trade", {"stock_code": "000660", "quantity": 1, "entry_price": 1000}, _dt.now() - _td(days=3), name="z_upsert_active_trade_old.json") with caplog.at_level(logging.WARNING): requeued = await db_worker.replay_dlq() assert len(attempts) == 1, ( f"DB 접속을 {len(attempts)}회 시도했다 — 첫 실패에서 단락해야 한다" ) assert requeued == 5, "최근 파일은 나이 제한만으로 재큐잉된다" assert (dlq / "expired" / old.name).exists(), "단락 후에도 나이 제한은 적용되어야 한다" # 반복 로그 억제: 실패 warning 은 1회만 fails = [r for r in caplog.records if "상태 충돌 확인 실패" in r.getMessage()] assert len(fails) == 1, f"충돌 판정 실패 로그가 {len(fails)}회 — 1회만 남겨야 한다" @pytest.mark.asyncio async def test_conflict_availability_resets_each_replay(tmp_path, monkeypatch): """이전 호출의 단락을 다음 기동이 물려받지 않는다.""" from app.services import db_worker dlq = tmp_path / "dlq" dlq.mkdir() monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) db_worker._dlq_conflict_check_available = False assert await db_worker.replay_dlq() == 0 # 파일 없음 → 조기 반환 # 파일이 하나라도 있으면 리셋된다 from datetime import datetime as _dt _write_dlq(dlq, "insert_trade_journal", {"stock_code": "005930"}, _dt.now()) monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") async def _fake_enqueue(task): return None monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) await db_worker.replay_dlq() assert db_worker._dlq_conflict_check_available is True # ── M-4: close_active_trade 는 오래돼도 재큐잉, 새 포지션에는 걸린다 ──────── @pytest.mark.asyncio async def test_stale_close_active_trade_is_requeued(dlq_env): """야간 재기동(2시간 초과)에도 close 는 재큐잉된다. 격리하면 이미 청산된 포지션의 ActiveTrade 가 active 로 영구 잔존해 §8.4.1 '유령 행' 과 같은 형태가 되고, 그 잔량이 유입 잔고 격리의 '시스템 보유' 하한으로 흘러들어 다른 안전장치를 무력화한다. """ from datetime import datetime as _dt, timedelta as _td from app.services import db_worker dlq, enqueued = dlq_env f = _write_dlq(dlq, "close_active_trade", {"stock_code": "005930", "strategy_name": "day_trade"}, _dt.now() - _td(days=3), name="a_close_active_trade_old.json") assert await db_worker.replay_dlq() == 1 assert enqueued[0]["type"] == "close_active_trade" assert (dlq / "processed" / f.name).exists() @pytest.mark.asyncio async def test_stale_close_is_quarantined_when_newer_position_exists(tmp_path, monkeypatch): """단, 현재 active 행이 failed_at 이후 생성이면(새 포지션) 격리한다.""" from datetime import datetime as _dt, timedelta as _td 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") monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") enqueued = [] async def _fake_enqueue(task): enqueued.append(task) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) failed_at = _dt.now() - _td(days=3) f = _write_dlq(dlq, "close_active_trade", {"stock_code": "005930", "strategy_name": "day_trade"}, failed_at, name="a_close_active_trade_conflict.json") newer = _FakeActiveTrade(stock_code="005930", quantity=5) newer.created_at = _dt.now() - _td(hours=1) # failed_at 이후 = 새 포지션 class _Sess: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *a, **k): rec = newer class _S: def first(self_inner): return rec class _R: def scalars(self_inner): return _S() return _R() monkeypatch.setattr(db_worker, "AsyncSessionLocal", lambda: _Sess()) assert await db_worker.replay_dlq() == 0 assert enqueued == [] assert (dlq / "expired" / f.name).exists() @pytest.mark.asyncio async def test_close_age_limit_tightens_when_conflict_check_unavailable(): """충돌 판정이 단락되면 close 허용 나이가 기록 계열 상한으로 내려간다.""" from app.services import db_worker db_worker._dlq_conflict_check_available = True assert db_worker._dlq_max_age_seconds("close_active_trade") == \ db_worker.DLQ_REPLAY_MAX_AGE_CLOSE_SECONDS db_worker._dlq_conflict_check_available = False assert db_worker._dlq_max_age_seconds("close_active_trade") == \ db_worker.DLQ_REPLAY_MAX_AGE_RECORD_SECONDS # 상태 계열은 단락과 무관하게 2시간 assert db_worker._dlq_max_age_seconds("upsert_active_trade") == \ db_worker.DLQ_REPLAY_MAX_AGE_STATE_SECONDS db_worker._dlq_conflict_check_available = True # ── 재발 방지: 벽시계 의존 차단 (autouse) ─────────────────────────────────── # # 이 파일에서 `scan_and_enter()` 를 시각 고정 없이 호출해 **두 번** 깨졌다 # (컷오프 15:00 이후 실행 시 조기 리턴 → 빈 결과). 개별 테스트의 주의력에 맡기면 # 새 테스트가 같은 함정에 다시 빠지므로, 호출 자체를 구조적으로 막는다. # # 가드는 **현재 시각을 보지 않고 무조건** 실패시킨다. 벽시계를 참고하면 낮에는 # 통과하고 밤에는 깨지는 '운 좋은 통과'가 생겨 재발 방지가 되지 않는다. @pytest.fixture(autouse=True) def _require_frozen_clock_for_time_gated_calls(monkeypatch): """`scan_and_enter()` 호출 시 `_freeze_entry_window` 적용을 강제한다. 청산 계열(`check_and_exit_positions`/`force_close_all`/`close_stale_positions`)도 내부에서 시각을 읽는다(EOD·시간손절). 이 파일에는 현재 호출부가 없지만, 추가할 때는 같은 방식으로 해당 모듈의 `datetime` 을 고정하고 이 가드를 확장할 것. """ from app.services.daytrading import entry_service as es _real_scan = es.scan_and_enter async def _guarded_scan(*args, **kwargs): if es.datetime is datetime: raise AssertionError( "scan_and_enter() 를 시각 고정 없이 호출했다 — 진입 윈도우" "(09:30~DAY_TRADE_ENTRY_CUTOFF) 밖에서는 조기 리턴해 테스트가 시간대에 따라" " 깨진다. 테스트 안에서 `_freeze_entry_window(monkeypatch, es)` 를 먼저 호출하라." ) return await _real_scan(*args, **kwargs) # 원본을 노출해 소스 검사 테스트가 가드가 아닌 실제 함수를 보게 한다 _guarded_scan.__wrapped__ = _real_scan monkeypatch.setattr(es, "scan_and_enter", _guarded_scan) def test_wallclock_guard_rejects_unfrozen_scan(monkeypatch): """가드 자체가 동작하는지 — 시각 고정 없이 호출하면 실패해야 한다.""" from app.services.daytrading import entry_service as es with pytest.raises(AssertionError, match="시각 고정"): asyncio.get_event_loop_policy().new_event_loop().run_until_complete( es.scan_and_enter() ) def test_every_scan_call_site_freezes_the_clock(): """[전수 확인] 이 파일의 scan_and_enter 호출부가 모두 시각을 고정했는지. 가드(autouse)가 런타임에 막지만, 이 테스트는 '어느 테스트가 빠뜨렸는지'를 파일 단위로 드러내 리뷰에서 바로 보이게 한다. """ import pathlib import re src = pathlib.Path(__file__).read_text(encoding="utf-8") # 테스트 함수 단위로 쪼개 scan_and_enter 호출과 freeze 호출을 함께 본다 blocks = re.split(r"\n(?=@pytest\.|def test_|async def test_)", src) offenders = [] for b in blocks: m = re.search(r"(?:async )?def (test_\w+)", b) if not m: continue name = m.group(1) if name in ("test_wallclock_guard_rejects_unfrozen_scan", "test_every_scan_call_site_freezes_the_clock"): continue if "es.scan_and_enter()" in b and "_freeze_entry_window(" not in b: offenders.append(name) assert not offenders, ( f"시각 고정 없이 scan_and_enter() 를 호출하는 테스트: {offenders} — " "_freeze_entry_window(monkeypatch, es) 를 추가하라" )