Files
auto-stock/backend/tests/test_restart_durability_20260928.py
shuim 9de0715165 fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전
사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.

## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
  자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
  system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
  ※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
    _delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
  _sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
  == 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
  승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
  직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
  PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
  (build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
  SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다

## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
  (120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
  타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가

## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
  오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
  클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
  → 진입 ATR == 청산 트레일링 ATR 보장
  ※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
  R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일

## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
  충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
  격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다

## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
  + 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)

테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 16:50:54 +09:00

1971 lines
81 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""재기동 내구성 회귀 테스트 (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) 를 추가하라"
)