- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1979 lines
82 KiB
Python
1979 lines
82 KiB
Python
"""재기동 내구성 회귀 테스트 (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
|
||
|
||
# [H-5 2026-10-02] 워커 본체가 정상 경로(_execute_db_task_impl)와 drain 1건 처리
|
||
# (_drain_one_impl)로 분리됐다. 워커는 cancel 시 _drain_on_shutdown_impl 을 부른다.
|
||
worker = inspect.getsource(db_worker.db_writer_worker)
|
||
assert "_drain_on_shutdown_impl" in worker, "워커가 종료 drain 을 호출하지 않는다"
|
||
assert "_execute_db_task_impl" in worker
|
||
shutdown = inspect.getsource(db_worker._drain_on_shutdown_impl)
|
||
assert "_drain_one_impl" in shutdown
|
||
# [M1 2026-10-02] 커밋 분기는 예산 wait_for 대상인 _drain_commit_impl 로 분리됐다
|
||
assert "_drain_commit_impl" in inspect.getsource(db_worker._drain_one_impl)
|
||
drain = inspect.getsource(db_worker._drain_commit_impl)
|
||
normal = inspect.getsource(db_worker._execute_db_task_impl)
|
||
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} 가 공통 핸들러를 쓰지 않는다"
|
||
# 정상 경로도 같은 핸들러를 써야 한다 (복제 금지)
|
||
assert handler in normal, 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_balance_all_v2", es.korea_investment_api.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유
|
||
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_balance_all_v2", es.korea_investment_api.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유
|
||
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) 를 추가하라"
|
||
)
|