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