fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전

사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:50:54 +09:00
parent b5e0d6a9bb
commit 9de0715165
19 changed files with 4519 additions and 126 deletions

View File

@@ -168,14 +168,132 @@ def test_manual_sell_closes_active_trade():
assert '"quantity": original_holding_qty - quantity' in src
class _StubActiveTrade:
"""ActiveTrade ORM 대역 — 정합 함수가 실제로 수정하는 필드만 갖는다."""
def __init__(self, stock_code, quantity, strategy_name="MEAN_REVERSION"):
self.stock_code = stock_code
self.quantity = quantity
self.strategy_name = strategy_name
self.status = "active"
def test_sync_reconciles_active_trade_quantity():
"""동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다."""
import inspect
"""동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다.
[2026-09-30 M-5] 종전에는 `inspect.getsource()` + 문자열 매칭이라 정합 루프를
리팩터링하면 빗나갔다(실제로 빗나갔다). 이제 **불일치 상태를 주입하고 정합
함수를 실행해 교정 결과를 확인**한다 — 프로덕션 함수를 그대로 호출한다.
"""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
# 스윙 단일 행이 3주로 기록돼 있는데 실제 보유는 5주 → 5주로 교정돼야 한다
row = _StubActiveTrade("000270", 3)
acts = _reconcile_active_trade_quantities(
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert row.quantity == 5, "잔량 교정이 동작해야 한다 (2026-09-23 픽스의 본래 대상)"
assert row.status == "active"
assert acts == [{"action": "corrected", "stock_code": "000270", "from": 3, "to": 5}]
# 일치하면 아무 것도 하지 않는다
row2 = _StubActiveTrade("000270", 5)
assert _reconcile_active_trade_quantities(
{"000270": [row2]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
) == []
assert row2.quantity == 5
def test_sync_closes_active_trade_without_holding():
"""보유가 없으면 active 행을 closed 로 정리한다 (유령 행 누적 방지)."""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
for tag in ("swing", "daytrading", "manual", "external"):
row = _StubActiveTrade("000270", 3)
acts = _reconcile_active_trade_quantities(
{"000270": [row]}, {}, {"000270": tag}, {"000270": 3}
)
assert row.status == "closed", f"{tag}: 유령 행 정리는 태그와 무관하게 수행"
assert row.quantity == 3, "정리 경로에서 수량은 건드리지 않는다"
assert acts[0]["action"] == "closed"
def test_sync_skips_reconcile_for_multiple_active_rows():
"""스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함 (2026-09-23 원래 규칙)."""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
rows = [_StubActiveTrade("000270", 3), _StubActiveTrade("000270", 2, "day_trade")]
assert _reconcile_active_trade_quantities(
{"000270": rows}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
) == []
assert [r.quantity for r in rows] == [3, 2]
def test_reconcile_skips_external_rows_but_keeps_swing_behaviour():
"""[C-1] external 행만 교정에서 빠진다 — 스윙 교정은 그대로 살아 있다.
external 행 수량에는 시스템이 소유하지 않은 유입분이 섞여 있어, 교정하면 그 값이
다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격된다(고착).
"""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
ext = _StubActiveTrade("105560", 3, "day_trade")
acts = _reconcile_active_trade_quantities(
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
)
assert ext.quantity == 3, "external 행은 교정하지 않는다"
assert acts[0]["action"] == "skipped_unattributable"
assert acts[0]["from"] == 3 and acts[0]["to"] == 6
# [M-3] 태그가 swing 인 혼재 행도 교정하지 않는다 — 여기가 실제 주문 사고 경로다
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
acts = _reconcile_active_trade_quantities(
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert mixed.quantity == 3, "유입분이 섞인 swing 행도 부풀리지 않는다"
assert acts[0]["action"] == "skipped_unattributable"
def test_c1_guard_does_not_weaken_the_20260923_intent():
"""의도 보존 검증 — C-1 가드를 **C-1 이전 규칙으로 되돌려도** 위 의도는 통과한다.
즉 이 테스트 묶음이 깨진다면 그것은 2026-09-23 픽스가 깨진 것이고, C-1 가드
때문이 아니다. 반대로 C-1 회귀(2회 sync 유지)는 가드가 없으면 실패한다
(`tests/test_external_demotion_race.py::test_c1_would_have_regressed_...`).
"""
from unittest.mock import patch
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sync_portfolio)
assert "ActiveTrade 잔량 교정" in src
assert "len(_rows) != 1" in src, "스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함"
assert "_row.status = \"closed\"" in src, "보유 없음 → closed 처리 누락"
def _pre_c1_rule(strategy_type, row_count, actual, system_quantity=None):
# C-1/M-3 이전 규칙: 단일 행 + 보유 있음이면 태그·근거수량 무관 교정
return int(row_count) == 1 and int(actual) > 0
with patch.object(pf, "_should_correct_active_trade_quantity", _pre_c1_rule):
row = _StubActiveTrade("000270", 3)
pf._reconcile_active_trade_quantities(
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert row.quantity == 5, "스윙 잔량 교정은 C-1/M-3 가드와 무관하게 성립한다"
ghost = _StubActiveTrade("000270", 3)
pf._reconcile_active_trade_quantities(
{"000270": [ghost]}, {}, {"000270": "swing"}, {"000270": 3}
)
assert ghost.status == "closed"
# 그리고 이 규칙에서는 유입분이 섞인 행이 부풀려진다 = C-1/M-3 의 인과
ext = _StubActiveTrade("105560", 3, "day_trade")
pf._reconcile_active_trade_quantities(
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
)
assert ext.quantity == 6, "가드가 없으면 C-1 이 재발한다"
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
pf._reconcile_active_trade_quantities(
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert mixed.quantity == 6, "가드가 없으면 M-3 이 재발한다(유입분 과다매도)"
def test_active_trade_patch_supports_quantity():

View File

@@ -0,0 +1,570 @@
"""C1 회귀 테스트 (2026-09-30): 워밍업 시계열이 진입 ATR 을 오염시키던 결함.
증상: 진입 판정 경로는 지표 워밍업을 위해 전일 봉이 섞인 5분봉을 쓴다
(`get_minute_chart_full_day_v2(include_prev_day=True)`). `true_ranges` 가
`TR_i = max(H-L, |H-C_{i-1}|, |L-C_{i-1}|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**
이고, `atr_sma` 는 마지막 14개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기
전(≈10:10)에는 갭 TR 이 윈도에 남았다. 그 ATR 이 손절·목표가를 직접 만든다.
수정: `day_trading_strategy._atr` 이 내부에서 `_last_session_candles()` 로 세션을 끊는다.
임계값은 하나도 바꾸지 않았다 — ATR 에 무엇을 먹이는지만 고쳤다.
고정하는 불변식 3건:
1. 당일 첫 봉이 갭으로 벌어져도 진입 ATR 이 그 갭을 먹지 않는다
2. 진입 ATR 과 청산 트레일링 ATR 이 같은 기준이다 (같은 날 데이터 → 같은 값)
3. RSI(14)/BB(20) 판정은 여전히 워밍업 혜택을 받는다 (09:30 에 판정 도달)
"""
from datetime import time as dtime
import pytest
from app.core.config import settings
from app.services.day_trading_strategy import (
MinuteCandle, _atr, _bollinger, _rsi,
check_day_trade_exit, check_vwap_bounce_signal, check_rsi_bb_signal,
)
PREV = "20260929"
TODAY = "20260930"
def _bar(date: str, hhmm: str, close: float, *, spread: float = 10.0,
volume: int = 1000) -> MinuteCandle:
"""종가 기준 ±spread 의 5분봉 1개."""
return MinuteCandle(
date=date, time=hhmm + "00",
open=close, high=close + spread, low=close - spread, close=close,
volume=volume, cum_volume=0, cum_amount=0.0,
)
def _series(date: str, n: int, base: float, *, step: float = 0.0,
spread: float = 10.0, start_min: int = 0) -> list[MinuteCandle]:
out = []
for i in range(n):
total = 9 * 60 + start_min + i * 5
out.append(_bar(
date, f"{total // 60:02d}{total % 60:02d}",
base + i * step, spread=spread,
))
return out
def _completed_bull_bar(bar: MinuteCandle) -> MinuteCandle:
"""마지막 **완성** 5분봉을 '양봉 + 거래량 급증'으로 만든다.
[2026-09-30] 양봉 판정이 형성 중 버킷(`[-1]`) → 마지막 완성봉(`[-2]`)으로
바뀌었으므로, 신호를 발화시키려면 완성봉 쪽이 양봉이어야 한다.
"""
return MinuteCandle(
date=bar.date, time=bar.time,
open=bar.close - 20, high=bar.high, low=bar.low, close=bar.close,
volume=5000, cum_volume=0, cum_amount=0.0,
)
# ── 불변식 1: 갭 TR 이 진입 ATR 에 들어오지 않는다 ────────────────────────────
def test_entry_atr_ignores_overnight_gap():
"""전일 종가가 어디에 있든 당일 ATR 값은 같다 (갭 TR 미포함)."""
today = _series(TODAY, 20, 10_000.0, spread=10.0)
# 전일 종가가 당일 시가와 비슷한 경우 / 30% 아래인 경우
small_gap = _series(PREV, 20, 9_900.0, spread=10.0, start_min=300) + today
huge_gap = _series(PREV, 20, 7_000.0, spread=10.0, start_min=300) + today
atr_small = _atr(small_gap, 14)
atr_huge = _atr(huge_gap, 14)
atr_today = _atr(today, 14)
assert atr_today is not None
assert atr_small == atr_today, "전일 봉이 진입 ATR 에 섞였다"
assert atr_huge == atr_today, "오버나이트 갭이 진입 ATR 을 부풀렸다"
def test_entry_atr_is_none_before_todays_warmup():
"""당일 봉이 period+1 개 미만이면 None — 워밍업 도입 이전과 같은 동작.
호출부는 이때 고정 폴백(DAY_TRADE_REVERSION_STOP_LOSS_PCT)을 쓴다.
전일 봉으로 ATR 을 '만들어내는' 것이 바로 C1 결함이었다.
"""
today_6 = _series(TODAY, 6, 10_000.0) # 09:30 시점
mixed = _series(PREV, 20, 9_000.0, start_min=300) + today_6
assert _atr(today_6, 14) is None
assert _atr(mixed, 14) is None, "전일 봉으로 ATR 을 합성하고 있다"
def test_vwap_signal_stop_and_target_ignore_prev_day():
"""신호의 손절·목표가가 전일 봉 유무에 영향받지 않는다.
ATR 이 `target = 현재가 + 1.5R` 과 `raw_stop` 에 직접 들어가므로,
같은 당일 데이터에서 두 입력이 같은 값을 내야 한다.
"""
# 하락 후 마지막 봉 양봉 + 거래량 급증 → VWAP 반등 신호 조건
today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0)
for c in today:
c.volume = 1000
today[-2] = _completed_bull_bar(today[-2])
last_close = today[-1].close
today[-1] = MinuteCandle( # 형성 중 봉: 양봉
date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10,
low=last_close - 40, close=last_close, volume=1200,
cum_volume=0, cum_amount=0.0,
)
price = today[-1].close
vwap = price # VWAP 밴드 중앙
# 워밍업은 **갭 없이 이어지는** 전일 하락 봉 — RSI 게이트가 양쪽에서 통과해야
# 손절·목표가 비교가 성립한다(갭을 주면 RSI 가 튀어 신호 자체가 사라진다).
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today
sig_today = check_vwap_bounce_signal("005930", "삼성", today, vwap, price)
sig_mixed = check_vwap_bounce_signal("005930", "삼성", mixed, vwap, price)
assert sig_today is not None and sig_mixed is not None, "테스트 데이터가 신호를 못 만든다"
assert sig_mixed.stop_loss == sig_today.stop_loss, "전일 봉이 진입 손절을 움직였다"
assert sig_mixed.target_price == sig_today.target_price, "전일 봉이 목표가를 움직였다"
def test_rsi_bb_signal_stop_and_target_ignore_prev_day():
"""RSI_BB 경로도 동일 (risk → target 전파 차단 확인)."""
today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0)
today[-2] = _completed_bull_bar(today[-2])
last_close = today[-1].close
today[-1] = MinuteCandle(
date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10,
low=last_close - 40, close=last_close, volume=1200,
cum_volume=0, cum_amount=0.0,
)
price = today[-1].close * 0.97 # BB 하단 근처로 내려 조건 충족
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today
sig_today = check_rsi_bb_signal("005930", "삼성", today, price)
sig_mixed = check_rsi_bb_signal("005930", "삼성", mixed, price)
assert sig_today is not None and sig_mixed is not None, "테스트 데이터가 신호를 못 만든다"
assert sig_mixed.stop_loss == sig_today.stop_loss
assert sig_mixed.target_price == sig_today.target_price
def test_fallback_stop_used_when_todays_atr_unavailable():
"""당일 ATR 이 없으면 고정 폴백 손절폭이 쓰인다 (ATR 합성 금지).
stop = min(현재가×(1−REVERSION_STOP_LOSS_PCT), 현재가×(1−MIN_STOP_LOSS_PCT))
— §7 의 '장중은 느슨한 쪽' 규약. 임계값은 settings 를 참조한다(§4.1).
"""
# 당일 5분봉 21개면 BB(20)·RSI(14)는 되지만 ATR(14)에는 15개가 필요하므로
# 당일 봉을 14개로 맞추고 워밍업으로 RSI/BB 만 채운다.
today = _series(TODAY, 14, 10_300.0, step=-20.0, spread=8.0)
today[-2] = _completed_bull_bar(today[-2])
last_close = today[-1].close
today[-1] = MinuteCandle(
date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10,
low=last_close - 40, close=last_close, volume=1200,
cum_volume=0, cum_amount=0.0,
)
mixed = _series(PREV, 20, 10_700.0, step=-20.0, spread=8.0, start_min=300) + today
price = today[-1].close
assert _atr(mixed, 14) is None, "당일 봉 14개로 ATR 이 나오면 전제가 깨진다"
sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
assert sig is not None, "워밍업 덕에 RSI 판정은 되어야 한다"
expected = min(
price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT),
price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT),
)
assert sig.stop_loss == pytest.approx(round(expected, 0))
# ── 불변식 2: 진입 ATR == 청산 트레일링 ATR ───────────────────────────────────
def test_entry_and_exit_atr_share_one_basis():
"""진입(워밍업 포함 시계열)과 청산(당일 전용 시계열)의 ATR 이 같다.
청산 경로(`daytrading/exit_service`)는 `get_minute_chart_full_day`(당일 전용)를
쓰고 진입 경로는 `_v2(include_prev_day=True)` 를 쓴다. 두 경로가 다른 ATR 을
쓰면 진입 손절과 트레일링 스탑의 기준이 엇갈린다.
"""
today = _series(TODAY, 20, 10_000.0, spread=25.0)
entry_series = _series(PREV, 20, 7_500.0, start_min=300) + today # 진입 경로 입력
exit_series = today # 청산 경로 입력
assert _atr(entry_series, 14) == _atr(exit_series, 14) is not None
def test_trailing_stop_identical_for_both_series():
"""같은 포지션·같은 당일 데이터에서 트레일링 청산 판정이 동일하다."""
today = _series(TODAY, 20, 10_000.0, spread=25.0)
mixed = _series(PREV, 20, 7_500.0, start_min=300) + today
atr = _atr(today, 14)
assert atr is not None
entry_price = 9_000.0
highest = 10_500.0
trail = highest - settings.DAY_TRADE_TRAILING_ATR_MULT * atr
current = trail - 1 # 트레일 바로 아래 → SELL_ALL 기대
kwargs = dict(
entry_price=entry_price, current_price=current,
stop_loss=entry_price * 0.9, target_price=entry_price * 1.5,
highest_since_entry=highest, current_time=dtime(11, 0), entry_time=None,
)
ex_today = check_day_trade_exit(candles_5min=today, **kwargs)
ex_mixed = check_day_trade_exit(candles_5min=mixed, **kwargs)
assert ex_today is not None and ex_today.action == "SELL_ALL"
assert ex_mixed is not None
assert ex_today.action == ex_mixed.action
assert ex_today.reason == ex_mixed.reason, "트레일링 기준값이 시계열에 따라 달라졌다"
# ── 불변식 3: RSI/BB 워밍업 혜택 유지 (C3 목적 보존) ─────────────────────────
def test_rsi_and_bb_still_benefit_from_warmup():
"""09:30(당일 6봉)에도 워밍업으로 RSI(14)/BB(20) 판정이 가능해야 한다."""
today_6 = _series(TODAY, 6, 10_000.0, step=-15.0)
mixed = _series(PREV, 20, 10_200.0, step=-5.0, start_min=300) + today_6
# 당일 봉만으로는 둘 다 불가
assert _rsi(today_6, 14) is None
assert _bollinger(today_6, 20) is None
# 워밍업이 붙으면 판정 도달 — C1 수정이 이걸 깨지 않았는지 확인
assert _rsi(mixed, 14) is not None, "워밍업이 무력화됐다 (C3 목적 상실)"
assert _bollinger(mixed, 20) is not None, "워밍업이 무력화됐다 (C3 목적 상실)"
def test_atr_session_filter_is_wallclock_independent():
"""`_atr` 은 벽시계를 쓰지 않는다 (§2) — **동작으로** 검증한다.
날짜가 1999년이든 2099년이든 '마지막 봉의 영업일자'만으로 세션이 갈려야 한다.
`datetime.now()` 에 의존하면 과거 날짜 입력에서 당일 봉이 0건이 되어 값이 달라진다
(휴장일·재기동·백테스트에서 기준이 흔들리는 것과 같은 증상).
"""
def _pair(prev_date, today_date):
prev = [
MinuteCandle(date=prev_date, time=f"{13 + i // 12:02d}{(i * 5) % 60:02d}00",
open=9_000.0, high=9_020.0, low=8_980.0, close=9_000.0,
volume=10, cum_volume=0, cum_amount=0.0)
for i in range(20)
]
today = [
MinuteCandle(date=today_date, time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00",
open=10_000.0, high=10_025.0, low=9_975.0, close=10_000.0,
volume=10, cum_volume=0, cum_amount=0.0)
for i in range(20)
]
return prev + today
past = _atr(_pair("19990104", "19990105"), 14)
future = _atr(_pair("20990104", "20990105"), 14)
recent = _atr(_pair(PREV, TODAY), 14)
assert past == future == recent is not None, "세션 판별이 벽시계에 의존한다"
# 같은 하루 변동폭(±25)에서 나온 값이므로 갭(1,000원)이 섞이지 않았음도 함께 확인
assert past == pytest.approx(50.0)
# 날짜 필드가 비어 있는 입력(목/레거시)은 기존 동작을 유지한다
undated = [
MinuteCandle(date="", time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00",
open=100.0, high=101.0, low=99.0, close=100.0,
volume=10, cum_volume=0, cum_amount=0.0)
for i in range(20)
]
assert _atr(undated, 14) is not None
# 날짜 필드가 비어 있는 입력(목/레거시)은 기존 동작을 유지한다
undated = [
MinuteCandle(date="", time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00",
open=100.0, high=101.0, low=99.0, close=100.0,
volume=10, cum_volume=0, cum_amount=0.0)
for i in range(20)
]
assert _atr(undated, 14) is not None
# ── 양봉 판정: 형성 중 버킷 → 마지막 완성봉 (2026-09-30) ──────────────────────
def _oversold_today(n: int = 25) -> list[MinuteCandle]:
"""RSI/BB 게이트를 통과할 하락 시계열 (마지막 완성봉은 호출부가 정한다)."""
return _series(TODAY, n, 10_600.0, step=-20.0, spread=8.0)
def _with_forming(bars: list[MinuteCandle], *, bull: bool) -> list[MinuteCandle]:
"""형성 중 버킷([-1])을 양봉/음봉으로 바꾼다 (판정에 영향을 주면 안 된다)."""
out = list(bars)
c = out[-1]
out[-1] = MinuteCandle(
date=c.date, time=c.time,
open=c.close - 30 if bull else c.close + 30,
high=c.close + 10, low=c.close - 40, close=c.close,
volume=1200, cum_volume=0, cum_amount=0.0,
)
return out
def test_bull_check_decided_by_completed_bar_not_forming_bucket():
"""형성 중 버킷이 양봉이든 음봉이든 판정은 **완성봉**으로 결정된다.
30초 주기 스캔에서 같은 5분 버킷을 약 10회 보므로, 형성 중 봉으로 판정하면
같은 시장 상태에 사이클마다 다른 답이 나온다(재현성 없는 게이트).
"""
base = _oversold_today()
base[-2] = _completed_bull_bar(base[-2]) # 완성봉 = 양봉
price = base[-1].close
sig_bull_forming = check_vwap_bounce_signal(
"005930", "삼성", _with_forming(base, bull=True), price, price)
sig_bear_forming = check_vwap_bounce_signal(
"005930", "삼성", _with_forming(base, bull=False), price, price)
assert sig_bull_forming is not None
assert sig_bear_forming is not None, "형성 중 버킷이 음봉이라고 게이트가 닫혔다"
assert sig_bull_forming.stop_loss == sig_bear_forming.stop_loss
assert sig_bull_forming.target_price == sig_bear_forming.target_price
def test_bear_completed_bar_blocks_even_if_forming_bucket_is_bullish():
"""완성봉이 음봉이면 형성 중 버킷이 양봉이어도 차단된다 (역방향 확인)."""
base = _oversold_today()
c = base[-2]
base[-2] = MinuteCandle( # 완성봉 = 음봉 + 거래량 급증
date=c.date, time=c.time, open=c.close + 20, high=c.high, low=c.low,
close=c.close, volume=5000, cum_volume=0, cum_amount=0.0,
)
price = base[-1].close
assert check_vwap_bounce_signal(
"005930", "삼성", _with_forming(base, bull=True), price, price) is None
assert check_rsi_bb_signal(
"005930", "삼성", _with_forming(base, bull=True), price * 0.97) is None
def test_bull_check_uses_same_bar_as_volume_gate():
"""양봉 판정 대상 봉 == 거래량 게이트가 비교한 봉 (`session[-2]`)."""
from app.services.day_trading_strategy import (
_last_completed_candle, volume_surge_ratio,
)
base = _oversold_today()
base[-2] = _completed_bull_bar(base[-2])
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base
assert _last_completed_candle(mixed) is mixed[-2]
assert volume_surge_ratio(mixed) == pytest.approx(
mixed[-2].volume / mixed[-3].volume
)
def test_bull_check_never_crosses_session_boundary():
"""당일 완성봉이 없으면 미통과 — 전일 봉으로 게이트를 열지 않는다.
개장 직후(당일 봉 1개)에는 워밍업 시계열의 마지막 완성봉이 **전일 종가 동시호가
봉**이다. 그걸 보면 게이트가 전일 결과로 열린다(C1 과 같은 함정).
"""
from app.services.day_trading_strategy import _last_completed_candle
prev_bull = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300)
c = prev_bull[-1]
prev_bull[-1] = MinuteCandle( # 전일 마지막 봉을 강한 양봉으로
date=c.date, time=c.time, open=c.close - 100, high=c.close + 10,
low=c.close - 120, close=c.close, volume=9999, cum_volume=0, cum_amount=0.0,
)
today_one = _series(TODAY, 1, 9_000.0)
assert _last_completed_candle(prev_bull + today_one) is None, (
"전일 봉이 '마지막 완성봉'으로 새어 들어왔다"
)
# 데이터 부족은 통과 쪽으로 열지 않는다
assert check_vwap_bounce_signal(
"005930", "삼성", prev_bull + today_one, 9_000.0, 9_000.0) is None
def test_last_completed_candle_is_wallclock_independent():
"""§2 순수성 — 과거/미래 날짜에서도 같은 위치의 봉을 고른다 (동작 검증)."""
from app.services.day_trading_strategy import _last_completed_candle
for d_prev, d_today in (("19990104", "19990105"), ("20990104", "20990105"),
(PREV, TODAY)):
prev = _series(d_prev, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300)
today = _series(d_today, 5, 10_000.0, step=-20.0, spread=8.0)
got = _last_completed_candle(prev + today)
assert got is today[-2], f"{d_today}: 마지막 완성봉 선택이 벽시계에 의존한다"
# ── 진단 정보 (갭·ATR) — 게이트가 아니라 관찰 수단 (2026-09-30) ───────────────
def test_overnight_gap_pct_from_data_only():
"""갭률은 데이터(전일 마지막 종가 vs 당일 첫 시가)로만 계산된다."""
from app.services.day_trading_strategy import _overnight_gap_pct
prev = _series(PREV, 20, 10_000.0, spread=10.0, start_min=300)
today = _series(TODAY, 20, 9_700.0, spread=10.0) # 시가 = 종가 = 9,700
gap = _overnight_gap_pct(prev + today)
assert gap == pytest.approx((9_700 - 10_000) / 10_000 * 100) # -3.00%
# 전일 봉이 없으면 계산 불가 → None (억지로 만들지 않는다)
assert _overnight_gap_pct(today) is None
# 날짜 필드가 빈 목/레거시 입력도 None
undated = [
MinuteCandle(date="", time="0900%02d" % 0, open=100.0, high=101.0,
low=99.0, close=100.0, volume=1, cum_volume=0, cum_amount=0.0)
]
assert _overnight_gap_pct(undated) is None
def test_reason_carries_gap_and_atr_diagnostics():
"""진입 reason 에 갭·ATR 이 **진단 정보**로 남는다 (게이트 아님).
RSI 갭 섭취는 유지 결정이 났고(차트 표준·평균회귀 의도와 방향 일치) 대신 관찰
대상이 됐다. 원장(`trade_history.entry_reason`)만으로 "갭 하락 종목에 쏠렸는가"를
집계할 수 있어야 한다.
"""
base = _oversold_today()
base[-2] = _completed_bull_bar(base[-2])
price = base[-1].close
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base
sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
assert sig is not None
assert "[진단 " in sig.reason, "진단 정보가 게이트 조건과 구분되지 않는다"
assert "갭=" in sig.reason and "%" in sig.reason
assert "ATR=" in sig.reason
sig_rb = check_rsi_bb_signal("005930", "삼성", mixed, price * 0.97)
assert sig_rb is not None and "갭=" in sig_rb.reason and "ATR=" in sig_rb.reason
def test_reason_marks_fallback_stop_when_atr_absent():
"""ATR 이 없으면 reason 이 '폴백손절'임을 밝힌다.
09:30~10:10 은 당일 5분봉 15개 미만이라 ATR 이 없고 손절이
`DAY_TRADE_REVERSION_STOP_LOSS_PCT`(폴백)로 결정된다 — 원장에서 구분되어야 한다.
"""
today = _series(TODAY, 14, 10_300.0, step=-20.0, spread=8.0)
today[-2] = _completed_bull_bar(today[-2])
mixed = _series(PREV, 20, 10_700.0, step=-20.0, spread=8.0, start_min=300) + today
price = today[-1].close
from app.services.day_trading_strategy import _atr as _atr_fn
assert _atr_fn(mixed, 14) is None
sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
assert sig is not None
assert "ATR=없음→폴백손절" in sig.reason
assert sig.stop_loss == pytest.approx(round(min(
price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT),
price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT),
), 0))
def test_diagnostics_do_not_change_any_decision():
"""진단 정보 추가가 판정·손절·목표를 바꾸지 않는다.
`reason` 외의 모든 필드가 전일 봉 유무와 무관하게 동일해야 한다
(진단 문자열만 달라진다).
"""
base = _oversold_today()
base[-2] = _completed_bull_bar(base[-2])
price = base[-1].close
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base
a = check_vwap_bounce_signal("005930", "삼성", base, price, price)
b = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
assert a is not None and b is not None
for field in ("strategy", "entry_price", "stop_loss", "target_price", "confidence"):
assert getattr(a, field) == getattr(b, field), f"{field} 가 달라졌다"
# 당일 전용 입력에는 갭이 없다(계산 불가) → 진단에서 생략된다
assert "갭=" not in a.reason and "갭=" in b.reason
def test_orb_reason_has_atr_but_no_gap():
"""ORB 경로는 당일 전용 시계열이라 갭을 계산할 수 없다 — ATR 만 남는다."""
from app.services.day_trading_strategy import OpeningRange, check_orb_signal
# 당일 1분봉 60개: 09:00~09:59, 마지막 완성 5분봉 양봉 + 거래량 급증
bars = []
for i in range(60):
total = 9 * 60 + i
close = 10_000 + (i if i < 50 else 60 + i)
bars.append(MinuteCandle(
date=TODAY, time=f"{total // 60:02d}{total % 60:02d}00",
open=close - 5, high=close + 5, low=close - 10, close=close,
volume=5000 if i >= 50 else 100, cum_volume=0, cum_amount=0.0,
))
orb = OpeningRange(high=10_020.0, low=9_950.0, total_volume=3000,
avg_vol_per_min=100.0, open_price=10_000.0)
price = bars[-1].close
sig = check_orb_signal("005930", "삼성", bars, orb, price)
if sig is not None: # 조건 미충족이면 이 단정은 의미가 없다
assert "ATR=" in sig.reason
assert "갭=" not in sig.reason
# ── M-1: 1.5R 하한 보장 (2026-09-30) ─────────────────────────────────────────
def _rr(sig) -> float:
"""보상/위험 배수."""
return (sig.target_price - sig.entry_price) / (sig.entry_price - sig.stop_loss)
def _signal_pair(n_today: int):
"""(VWAP 신호, RSI_BB 신호) — 당일 봉 수로 ATR 유무를 가른다.
n_today=25 → 당일 ATR(14) 산출 가능 / n_today=14 → ATR 없음(폴백 손절폭).
"""
today = _series(TODAY, n_today, 10_600.0, step=-20.0, spread=8.0)
today[-2] = _completed_bull_bar(today[-2])
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today
price = today[-1].close
return (
check_vwap_bounce_signal("005930", "삼성", mixed, price, price),
check_rsi_bb_signal("005930", "삼성", mixed, price * 0.97),
)
@pytest.mark.parametrize("n_today,label", [(25, "ATR 있음"), (14, "ATR 폴백")])
def test_target_guarantees_target_multiplier_r(n_today, label):
"""목표가는 항상 위험의 `DAY_TRADE_TARGET_MULTIPLIER` 배 이상이다.
`target_price` 는 `daytrading/exit_service` 의 분할매도 트리거로 실제 쓰인다(§7).
위험보다 작은 목표는 R:R 역전이다 — ATR 유무 양쪽에서 막는다.
"""
sig_vwap, sig_rb = _signal_pair(n_today)
assert sig_vwap is not None and sig_rb is not None, "테스트 데이터가 신호를 못 만든다"
for name, sig in (("VWAP", sig_vwap), ("RSI_BB", sig_rb)):
risk = sig.entry_price - sig.stop_loss
assert risk > 0
assert sig.target_price - sig.entry_price >= risk * settings.DAY_TRADE_TARGET_MULTIPLIER - 1e-6, (
f"{label}/{name}: {_rr(sig):.2f}R < "
f"{settings.DAY_TRADE_TARGET_MULTIPLIER}R "
f"(진입 {sig.entry_price:.0f} 손절 {sig.stop_loss:.0f} 목표 {sig.target_price:.0f})"
)
def test_low_volatility_vwap_entry_is_not_r_inverted():
"""저변동 종목(1.5×ATR < 최소손절폭)에서도 R:R 이 역전되지 않는다.
수정 전 실측: 진입 10,120 / ATR 29 → 손절 -2.00% / 목표 +0.43% = **0.21R**.
손절이 `min()` 으로 -2% 쪽으로 느슨해지는데 목표는 `1.5×ATR` 이라 위험보다 작았다.
"""
sig_vwap, _ = _signal_pair(25)
assert sig_vwap is not None
from app.services.day_trading_strategy import _atr as _atr_fn
today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0)
today[-2] = _completed_bull_bar(today[-2])
atr = _atr_fn(today, 14)
assert atr is not None
# 전제: 이 데이터는 저변동(1.5×ATR < 최소손절폭)이라 옛 공식이면 R:R < 1 이었다
assert settings.DAY_TRADE_TARGET_MULTIPLIER * atr < (
sig_vwap.entry_price * settings.DAY_TRADE_MIN_STOP_LOSS_PCT
)
assert _rr(sig_vwap) >= settings.DAY_TRADE_TARGET_MULTIPLIER - 1e-6
def test_target_uses_the_larger_of_two_candidates():
"""기존 목표식이 더 크면 그 값이 유지된다 (1.5R 하한은 '하한'일 뿐).
RSI_BB 는 `max(bb_middle, 1.5R)` 이므로 BB 중앙선이 멀면 그쪽이 이긴다.
"""
_, sig_rb = _signal_pair(25)
assert sig_rb is not None
assert _rr(sig_rb) > settings.DAY_TRADE_TARGET_MULTIPLIER, (
"BB 중앙선이 1.5R 보다 먼 데이터인데 1.5R 로 깎였다"
)

View File

@@ -0,0 +1,530 @@
"""2026-09-30 감시종목 확대 회귀 테스트 (DAY_TRADE_WATCHLIST_SIZE).
배경: `populate_daytrading_watchlist(max_stocks=20)` 의 20 이 하드코딩이라 ORB 후보
풀을 넓힐 방법이 없었다. 값을 config 로 이관하면서 다음을 고정한다.
1. 상한은 settings 에서 온다 (하드코딩 20 잔재 없음, 명시 인자는 여전히 우선)
2. 확대해도 **리스크 노출은 불변**이다 — 동시 보유·종목당 예산·DT 합산 한도는
감시종목 수를 참조하지 않는다
3. 스캔 예산 불변식 — 감시종목 수는 `_SCAN_TIME_BUDGET` 안에서 소화 가능한
범위여야 한다(값을 올릴 때 이 테스트가 재산정을 강제한다)
CLAUDE.md §4.1: 기대값에 config 값을 하드코딩하지 않고 `settings.*` 를 참조한다.
"""
import inspect
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.compiler import compiles
from app.core.config import settings
@compiles(JSONB, "sqlite")
def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환
return "JSON"
from app.models.models import Base, StockAnalysis # noqa: E402
from app.services.daytrading import entry_service as es # noqa: E402
async def _mk_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[StockAnalysis.__table__]))
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
def _row(code, d, score=3, price=10000):
return StockAnalysis(
stock_code=code, stock_name=f"N{code}", analysis_date=d,
score=score, rsi=40.0, current_price=price,
)
@pytest.fixture(autouse=True)
def _clear_watchlist():
"""모듈 전역 상태를 테스트마다 격리한다 (§4.1: 테스트 순서 의존 방지).
`_scan_offset`·`_rsi_diag_cycles`·`_candle_cache` 는 모듈 전역이라
남겨 두면 다른 파일의 라운드로빈·억제 카운터 테스트를 오염시킨다.
"""
from app.services.daytrading.position_manager import day_trading_state
def _reset():
es.set_watchlist([])
es._candle_cache.clear()
es._scan_offset = 0
es._rsi_diag_cycles = 0
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
_reset()
yield
_reset()
async def _run_scan(fake_try_enter, *, hh: int = 11, mm: int = 0) -> list:
"""`scan_and_enter()` 를 게이트 통과 상태로 1회 실행한다 (동작 검증용 하네스).
§4.1: 주문·잔고·시세 계열은 conftest 가 예외 발생형으로 차단하므로 여기서
명시적으로 목킹한다. 목킹 지점은 실제 호출부와 동일하다.
"""
from datetime import datetime
from unittest.mock import AsyncMock
fake_now = datetime.now(es._KST).replace(hour=hh, minute=mm, second=0)
class _DT(datetime):
@classmethod
def now(cls, tz=None):
return fake_now if tz else fake_now.replace(tzinfo=None)
class _Adp:
fallback_used = False
daytrading_enabled = True
trading_mode = "normal"
with patch.object(es, "datetime", _DT), \
patch.object(es.mdd_manager, "is_short_term_strategy_enabled",
AsyncMock(return_value=True)), \
patch.object(es.mdd_manager, "can_open_new_position",
AsyncMock(return_value=True)), \
patch("app.services.adaptive_strategy.get_current_adaptive_params",
return_value=_Adp()), \
patch.object(es, "is_daytrading_allowed_by_playbook", return_value=True), \
patch.object(es, "check_market_regime",
AsyncMock(return_value={"regime": "neutral"})), \
patch.object(es.korea_investment_api, "get_balance", AsyncMock(
return_value={"output2": [{"tot_evlu_amt": "100000000",
"prvs_rcdl_excc_amt": "90000000"}]})), \
patch.object(es.korea_investment_api, "get_buyable_cash",
AsyncMock(return_value=90_000_000.0)), \
patch.object(es, "_prefetch_minute_charts", AsyncMock(return_value=0)), \
patch.object(es, "_try_enter", fake_try_enter):
return await es.scan_and_enter()
# ── 1. 상한은 settings 에서 온다 ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_watchlist_size_comes_from_settings():
"""인자를 생략하면 settings.DAY_TRADE_WATCHLIST_SIZE 가 상한이 된다."""
engine, Session = await _mk_session()
today = datetime.now(es._KST).date()
async with Session() as s:
# 상한보다 5개 많은 후보를 넣는다 (score 내림차순으로 잘려야 한다)
s.add_all([
_row(f"{i:06d}", today, score=100 - i)
for i in range(settings.DAY_TRADE_WATCHLIST_SIZE + 5)
])
await s.commit()
with patch("app.core.database.AsyncSessionLocal", Session):
n = await es.populate_daytrading_watchlist()
assert n == settings.DAY_TRADE_WATCHLIST_SIZE
assert len(es.get_watchlist()) == settings.DAY_TRADE_WATCHLIST_SIZE
await engine.dispose()
@pytest.mark.asyncio
async def test_watchlist_size_setting_is_honored_when_changed():
"""설정을 바꾸면 그대로 반영된다 (20 하드코딩 잔재 없음)."""
engine, Session = await _mk_session()
today = datetime.now(es._KST).date()
async with Session() as s:
s.add_all([_row(f"{i:06d}", today, score=100 - i) for i in range(40)])
await s.commit()
for size in (7, 33):
with patch.object(settings, "DAY_TRADE_WATCHLIST_SIZE", size), \
patch("app.core.database.AsyncSessionLocal", Session):
es.set_watchlist([])
n = await es.populate_daytrading_watchlist()
assert n == size, f"설정 {size} 가 반영되지 않았다 (하드코딩 상한 의심)"
await engine.dispose()
@pytest.mark.asyncio
async def test_explicit_max_stocks_still_wins():
"""기존 시그니처 보존 — 명시 인자는 settings 보다 우선한다 (§4)."""
engine, Session = await _mk_session()
today = datetime.now(es._KST).date()
async with Session() as s:
s.add_all([_row(f"{i:06d}", today, score=100 - i) for i in range(10)])
await s.commit()
with patch("app.core.database.AsyncSessionLocal", Session):
n = await es.populate_daytrading_watchlist(1, 4)
assert n == 4
with patch("app.core.database.AsyncSessionLocal", Session):
n = await es.populate_daytrading_watchlist(min_score=1, max_stocks=6)
assert n == 6
await engine.dispose()
@pytest.mark.asyncio
async def test_yesterday_fallback_also_uses_setting():
"""당일 분석이 없어 최근 분석일로 폴백하는 경로도 같은 상한을 쓴다."""
engine, Session = await _mk_session()
yday = datetime.now(es._KST).date() - timedelta(days=1)
async with Session() as s:
s.add_all([_row(f"{i:06d}", yday, score=100 - i) for i in range(12)])
await s.commit()
with patch.object(settings, "DAY_TRADE_WATCHLIST_SIZE", 9), \
patch("app.core.database.AsyncSessionLocal", Session):
n = await es.populate_daytrading_watchlist()
assert n == 9
await engine.dispose()
def test_no_hardcoded_default_in_signature():
"""기본값은 None(=settings 위임) 이어야 한다 — 숫자 기본값 복귀 방지.
"settings 를 읽는가"는 소스 문자열이 아니라
`test_watchlist_size_setting_is_honored_when_changed` 가 **동작으로** 검증한다.
"""
sig = inspect.signature(es.populate_daytrading_watchlist)
assert sig.parameters["max_stocks"].default is None
# ── 2. 리스크 노출 불변 ───────────────────────────────────────────────────────
def test_position_and_capital_limits_do_not_depend_on_watchlist():
"""감시종목 수는 동시 보유·예산 한도에 전혀 관여하지 않는다."""
from app.services import day_trading_strategy as dts
assert dts.MAX_POSITIONS == settings.DAY_TRADE_MAX_POSITIONS
# 사이징은 총자산·현금·기존 DT 투입액만의 함수다 (감시종목 수 무관)
sig = inspect.signature(dts.calculate_day_trade_position_size)
assert set(sig.parameters) == {
"total_assets", "available_cash", "current_day_trade_amount",
}
budget = dts.calculate_day_trade_position_size(
total_assets=100_000_000, available_cash=100_000_000, current_day_trade_amount=0,
)
assert budget == pytest.approx(100_000_000 * settings.DAY_TRADE_MAX_CAPITAL_RATIO)
# DT 합산 한도 — 종목당 한도 × 동시 보유 한도가 DT 총한도를 넘지 않는다
assert (
settings.DAY_TRADE_MAX_CAPITAL_RATIO * settings.DAY_TRADE_MAX_POSITIONS
<= settings.DAY_TRADE_MAX_RATIO_DT + 1e-9
)
@pytest.mark.asyncio
async def test_scan_stops_at_position_cap_behaviorally():
"""감시종목이 한도의 4배여도 **실제 진입은 MAX_POSITIONS 에서 멈춘다**.
소스 문자열 매칭이 아니라 `scan_and_enter()` 를 돌려 결과 건수로 확인한다.
"""
from app.services.day_trading_strategy import MAX_POSITIONS
from app.services.daytrading.position_manager import day_trading_state
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
es.set_watchlist([
{"code": f"{i:06d}", "name": f"N{i}"} for i in range(MAX_POSITIONS * 4)
])
async def fake_try_enter(code, name, total_assets, available_cash,
is_lunch_break=False):
# 실제 경로처럼 인메모리 포지션을 늘린다 (루프의 break 조건 대상)
day_trading_state.add_position(code, {
"stock_name": name, "strategy": "VWAP_BOUNCE", "entry_price": 1000.0,
"stop_loss": 980.0, "target_price": 1030.0, "quantity": 1,
"highest_price": 1000.0, "entry_time": None, "reason": "t", "order_id": "",
})
return {"stock_code": code, "quantity": 1, "price": 1000.0}
entries = await _run_scan(fake_try_enter)
assert len(entries) == MAX_POSITIONS, (
f"감시종목 {MAX_POSITIONS * 4}개인데 진입 {len(entries)}건 — 한도 {MAX_POSITIONS} 초과/미달"
)
assert day_trading_state.position_count() == MAX_POSITIONS
day_trading_state.positions.clear()
es.set_watchlist([])
def test_budget_clamp_depends_only_on_its_arguments():
"""사이징 클램프는 인자만의 함수다 — 감시목록 크기가 새어들 여지가 없다."""
args = dict(total_assets=100_000_000, available_cash=100_000_000,
current_day_trade_amount=0)
before = es._clamp_day_trade_budget(10_000_000, **args)
es.set_watchlist([{"code": f"{i:06d}", "name": "x"} for i in range(50)])
after = es._clamp_day_trade_budget(10_000_000, **args)
es.set_watchlist([])
assert before == after == 100_000_000 * settings.DAY_TRADE_MAX_CAPITAL_RATIO
def test_portfolio_holdings_cap_is_unrelated_to_daytrading_watchlist():
"""MAX_HOLDINGS_COUNT(포트폴리오 전체 보유 한도)는 DT 진입 경로에 없다."""
import app.services.daytrading.entry_service as mod
assert "MAX_HOLDINGS_COUNT" not in inspect.getsource(mod)
# ── 3. 스캔 예산 불변식 ───────────────────────────────────────────────────────
def test_candle_cache_ttl_covers_one_cycle():
"""[M3] 캐시 TTL 은 한 사이클 상한보다 커야 한다 (선조회 재조회 방지)."""
assert es._CANDLE_CACHE_TTL >= es._SCAN_TIME_BUDGET
def test_watchlist_size_fits_scan_budget():
"""감시종목 수가 선조회 단계 예산 안에 들어오는지 검증.
비용 모델(2026-09-30 산정):
선조회 wall ≈ 종목수 × 페이지수 × 페이지비용 ÷ _SCAN_PREFETCH_CONCURRENCY
- 페이지비용 = 요청시간 3.0초 + in-loop sleep(`API_MIN_REQUEST_INTERVAL`+0.05)
· 요청시간은 운영 `monitoring/api-stats` avg_duration 실측(재시도 포함)
· sleep 은 `korea_investment_api._get_minute_chart_full_day_impl`
- 페이지수는 시간대 비례(당일 1분봉÷30봉): 09:30 4장 / 14:00 10장 / 15:00 12장
- **판정 기준은 14:00(10장)**: 진입 마감 15:00 전 대부분의 시간을 커버하는 지점이다.
15:00 직전(12장)은 부분 커버리지를 감수하고 라운드로빈에 맡긴다.
DAY_TRADE_WATCHLIST_SIZE 를 올리려면 이 계산을 다시 해야 한다 —
그게 이 테스트의 목적이다.
"""
req_sec = 3.0
page_sec = req_sec + settings.API_MIN_REQUEST_INTERVAL + 0.05
pages_afternoon = 10 # 14:00 기준
max_stocks = int(
es._SCAN_PREFETCH_TOTAL_BUDGET * es._SCAN_PREFETCH_CONCURRENCY
/ (pages_afternoon * page_sec)
)
assert settings.DAY_TRADE_WATCHLIST_SIZE <= max_stocks, (
f"감시종목 {settings.DAY_TRADE_WATCHLIST_SIZE}개는 14:00 기준 선조회 예산 상한 "
f"{max_stocks}개를 초과한다 (페이지 {page_sec:.2f}초 × {pages_afternoon}장, "
f"동시성 {es._SCAN_PREFETCH_CONCURRENCY}, 예산 {es._SCAN_PREFETCH_TOTAL_BUDGET:.0f}초)"
)
def test_per_stock_prefetch_timeout_covers_full_session():
"""장 마감 직전(12페이지) 종목도 per-stock 타임아웃 안에 들어와야 한다.
넘으면 12:00 이후 전 종목이 선조회 타임아웃되고, 순차 루프가 타임아웃 없는
직렬 재조회를 떠안아 오히려 느려진다(2026-09-30 이전 상태).
"""
page_sec = 3.0 + settings.API_MIN_REQUEST_INTERVAL + 0.05
pages_max = 12 # 15:00, 당일 1분봉 360봉 ÷ 30
assert pages_max * page_sec <= es._SCAN_PREFETCH_TIMEOUT, (
f"15:00 기준 종목당 {pages_max * page_sec:.0f}초 > 타임아웃 "
f"{es._SCAN_PREFETCH_TIMEOUT:.0f}초"
)
def test_prefetch_phase_deadline_protects_scan_and_guard():
"""선조회 단계 데드라인이 순차 구간과 스케줄러 가드를 모두 보호한다.
per-stock 타임아웃만으로는 최악의 경우 ceil(N/동시성) 웨이브 × 타임아웃이
_guard(STEP_TIMEOUT=180)를 넘겨 스캔 전체가 조용히 취소된다.
"""
from app.services.daytrading import scheduler as sch
# 단계 데드라인 + 순차 구간 몫 ≤ 사이클 예산 ≤ 스케줄러 가드
assert es._SCAN_PREFETCH_TOTAL_BUDGET < es._SCAN_TIME_BUDGET
assert es._SCAN_TIME_BUDGET < sch.STEP_TIMEOUT
# 순차 구간에 최소 30초(= 최대 5건 주문 여유)를 남긴다
assert es._SCAN_TIME_BUDGET - es._SCAN_PREFETCH_TOTAL_BUDGET >= 30
# 데드라인이 per-stock 타임아웃보다 커야 1웨이브라도 완주한다
assert es._SCAN_PREFETCH_TOTAL_BUDGET > es._SCAN_PREFETCH_TIMEOUT
@pytest.mark.asyncio
async def test_prefetch_phase_returns_within_deadline(monkeypatch):
"""느린 종목이 많아도 선조회 **단계**가 데드라인 안에 반환된다 (동작 검증).
상수만 있고 실제로 걸려 있지 않은 상태를 소스 매칭 없이 잡는다.
"""
import asyncio
import time as _time
slow_started = {"n": 0}
async def slow_fetch(code):
slow_started["n"] += 1
await asyncio.sleep(30) # per-stock 타임아웃보다도 길다
return [object()]
monkeypatch.setattr(es, "_fetch_candles_impl", slow_fetch)
monkeypatch.setattr(es, "_SCAN_PREFETCH_TIMEOUT", 5.0)
monkeypatch.setattr(es, "_SCAN_PREFETCH_TOTAL_BUDGET", 0.3)
es._candle_cache.clear()
t0 = _time.monotonic()
n = await es._prefetch_minute_charts([f"{i:06d}" for i in range(40)])
elapsed = _time.monotonic() - t0
assert elapsed < 3.0, (
f"단계 데드라인(0.3초)이 걸려 있지 않다 — {elapsed:.1f}초 소요 "
f"(per-stock 타임아웃 5초 × 웨이브만큼 걸린 것)"
)
assert n == 0, "타임아웃된 종목이 캐시에 담겨선 안 된다"
assert slow_started["n"] > 0, "선조회가 아예 시작되지 않았다"
es._candle_cache.clear()
def test_prefetch_concurrency_within_api_limits():
"""선조회 동시성이 KIS 레이트리밋·세마포어 안에 있는지.
- 동시성 ≤ API_MAX_CONCURRENT_REQUESTS: 초과분은 세마포어에서 대기만 하므로 무의미
- 실효 요청률 = 동시성 ÷ 페이지비용 ≤ 전역 쓰로틀 상한(1/API_MIN_REQUEST_INTERVAL)
의 50% — 나머지는 스윙 스케줄러·배치·주문 경합 몫으로 남긴다
"""
assert es._SCAN_PREFETCH_CONCURRENCY <= settings.API_MAX_CONCURRENT_REQUESTS
page_sec = 3.0 + settings.API_MIN_REQUEST_INTERVAL + 0.05
req_rate = es._SCAN_PREFETCH_CONCURRENCY / page_sec
throttle_cap = 1.0 / settings.API_MIN_REQUEST_INTERVAL
assert req_rate <= throttle_cap * 0.5, (
f"선조회 요청률 {req_rate:.2f} req/s 가 쓰로틀 상한 {throttle_cap:.2f} req/s 의 "
f"50% 를 넘는다 — 타 스케줄러 경합 여유가 없다"
)
def test_prefetch_is_read_only():
"""선조회는 읽기 전용 — 주문·포지션 변경·DB 쓰기가 섞여선 안 된다.
주문이 순차 루프에만 있어야 MAX_POSITIONS·예산 클램프 순서가 보장된다
(동시성을 올렸으므로 이 불변식이 더 중요해졌다).
"""
src = inspect.getsource(es._prefetch_minute_charts)
for forbidden in (
"safe_place_order", "place_order", "add_position", "enqueue_",
"position_count", "_clamp_day_trade_budget",
):
assert forbidden not in src, f"선조회에 {forbidden} 가 섞였다"
# 순차 진입 루프는 여전히 for 문 안에서 하나씩 await 한다
scan_src = inspect.getsource(es.scan_and_enter)
assert "await _try_enter(" in scan_src
assert "gather" not in scan_src, "진입이 병렬화되면 포지션 한도가 깨진다"
def test_orb_collection_fits_opening_window():
"""ORB 수집(09:00~09:29)이 감시종목 전부를 소화할 수 있는지.
수집은 종목당 **1페이지**(당일 09:00~ 분봉은 30봉 이내) + 0.3초 간격 순차다.
첫 유효 수집은 봉 5개가 모이는 09:05 경이므로 가용 시간은 약 24분.
"""
req_sec = 3.0
per_stock = req_sec + 0.3
window_sec = 24 * 60
max_stocks = int(window_sec / per_stock)
assert settings.DAY_TRADE_WATCHLIST_SIZE <= max_stocks
# 한 사이클 가드(300초) 안에서도 소화 가능해야 중간 취소로 유실되지 않는다
assert settings.DAY_TRADE_WATCHLIST_SIZE <= int(300 / per_stock)
# ── RSI·갭 기저 분포 진단 로깅 (2026-09-30) ───────────────────────────────────
def _mk_5m(date: str, n: int, base: float, step: float = -20.0) -> list:
"""5분봉 n개 (MinuteCandle)."""
from app.services.day_trading_strategy import MinuteCandle
out = []
for i in range(n):
total = 9 * 60 + i * 5
c = base + i * step
out.append(MinuteCandle(
date=date, time=f"{total // 60:02d}{total % 60:02d}00",
open=c, high=c + 10, low=c - 10, close=c,
volume=1000, cum_volume=0, cum_amount=0.0,
))
return out
def test_rsi_diagnostics_logs_one_line_per_cycle_window(caplog):
"""후보 전체의 (RSI, 갭) 분포가 **한 줄로** 남는다 — 통과 여부와 무관하게."""
import logging
es._candle_cache.clear()
es._rsi_diag_cycles = 0
cands = []
for i in range(3):
code = f"00000{i}"
prev = _mk_5m("20260929", 20, 11_000.0)
today = _mk_5m("20260930", 25, 10_600.0 - i * 100)
es._candle_cache[code] = (1e12, prev + today) # 만료 없는 타임스탬프
cands.append((code, f"N{code}"))
with caplog.at_level(logging.INFO, logger=es.logger.name):
es._log_rsi_gap_diagnostics(cands)
msgs = [r.message for r in caplog.records if "RSI 진단" in r.message]
assert len(msgs) == 1, "사이클당 1줄이어야 한다"
line = msgs[0]
assert "n=3" in line
# 임계값은 하드코딩하지 않고 settings 실효값이 찍힌다 (§4)
assert f"RSI≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}" in line
assert f"RSI≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}" in line
assert "갭" in line and "최저 RSI" in line
es._candle_cache.clear()
def test_rsi_diagnostics_is_volume_suppressed(caplog):
"""최초 1회 + N 사이클마다 1회만 남긴다 (로그 폭주 방지)."""
import logging
es._candle_cache.clear()
es._rsi_diag_cycles = 0
es._candle_cache["000001"] = (
1e12, _mk_5m("20260929", 20, 11_000.0) + _mk_5m("20260930", 25, 10_600.0)
)
cands = [("000001", "N")]
with caplog.at_level(logging.INFO, logger=es.logger.name):
for _ in range(es._RSI_DIAG_LOG_EVERY + 1):
es._log_rsi_gap_diagnostics(cands)
msgs = [r.message for r in caplog.records if "RSI 진단" in r.message]
assert len(msgs) == 2, (
f"최초 1회 + {es._RSI_DIAG_LOG_EVERY}회째 = 2줄이어야 하는데 {len(msgs)}줄"
)
es._candle_cache.clear()
es._rsi_diag_cycles = 0
def test_rsi_diagnostics_is_read_only_and_uses_cache_only():
"""진단은 KIS 호출·DB I/O·상태 변경을 하지 않는다 (§3.1)."""
import inspect
src = inspect.getsource(es._log_rsi_gap_diagnostics)
for forbidden in (
"korea_investment_api", "AsyncSessionLocal", "enqueue_", "await",
"set_orb", "add_position", "safe_place_order",
):
assert forbidden not in src, f"진단에 {forbidden} 가 섞였다"
assert "_candle_cache" in src, "선조회 캐시만 읽어야 한다(추가 조회 금지)"
@pytest.mark.asyncio
async def test_diagnostics_failure_does_not_lose_entries(monkeypatch, caplog):
"""진단이 예외를 던져도 진입 결과가 유실되지 않고 warning 이 남는다 (동작 검증)."""
import logging
from app.services.daytrading.position_manager import day_trading_state
def boom(_candidates):
raise RuntimeError("diag boom")
monkeypatch.setattr(es, "_log_rsi_gap_diagnostics", boom)
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
es.set_watchlist([{"code": "000001", "name": "N"}])
async def fake_try_enter(code, name, total_assets, available_cash,
is_lunch_break=False):
return {"stock_code": code, "quantity": 1, "price": 1000.0}
with caplog.at_level(logging.WARNING, logger=es.logger.name):
entries = await _run_scan(fake_try_enter)
assert len(entries) == 1, "진단 예외가 진입 결과를 삼켰다"
assert any("진단 로깅 실패" in r.message for r in caplog.records)
assert all(r.levelno >= logging.WARNING
for r in caplog.records if "진단 로깅 실패" in r.message)
es.set_watchlist([])

View File

@@ -89,16 +89,22 @@ def _warmup_scenario() -> tuple[list, list]:
당일: 09:00~09:29 (5분봉 6개). 전일 13:00~15:19 (5분봉 28개 워밍업).
가격은 완만한 하락 후 마지막 완성봉에서 급락 → RSI 과매도 + BB 하단 이탈,
형성 중 봉은 양봉. 거래량은 마지막 완성봉이 직전 완성봉의 2배.
**그 완성봉이 양봉 마감**. 거래량은 마지막 완성봉이 직전 완성봉의 2배.
[2026-09-30] 원래 시나리오는 반등(양봉)을 **형성 중 버킷**에 두고 있었다. 양봉
판정이 형성 중 버킷 → 마지막 완성봉으로 고쳐졌으므로(같은 버킷을 30초마다 보며
답이 흔들리던 문제) 반등 봉을 완성봉 쪽으로 옮긴다. 이 파일의 목적(워밍업이
09:30 판정을 가능하게 하는가)은 그대로다.
"""
prev_day = _minute_seq(_prev(), 13, 0, 140, lambda i: 1000.0 - i * 0.2)
# 당일 09:00~09:24 (완성 5분봉 5개) — 계속 하락
today_bars = _minute_seq(_today(), 9, 0, 20, lambda i: 972.0 - i * 0.5)
# 09:20~09:24 = 마지막 완성봉: 급락 + 거래량 2배
# 09:20~09:24 = 마지막 완성봉: 급락(직전 버킷 942 → 906) 후 양봉 마감 + 거래량 2배
today_bars += _minute_seq(
_today(), 9, 20, 5, lambda i: 940.0 - i * 6.0, lambda i: 2000
_today(), 9, 20, 5,
lambda i: (906.0, 908.0, 911.0, 914.0, 916.0)[i], lambda i: 2000,
)
# 09:25~09:29 = 형성 중 봉: 양봉 (반등 시작)
# 09:25~09:29 = 형성 중 봉 (양봉 판정 대상이 아니다)
today_bars += [
_c(_today(), "092500", 912.0, 300, open_=910.0),
_c(_today(), "092600", 913.0, 300, open_=912.0),

File diff suppressed because it is too large Load Diff

View File

@@ -45,22 +45,52 @@ def test_dt_position_without_system_evidence_still_external():
# ── sync_portfolio 불변식 (소스 수준) ──────────────────────────────────────
def _compiled(stmt):
from sqlalchemy.dialects import postgresql
return str(stmt.compile(dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True}))
def test_sync_collects_system_buy_evidence_without_time_window():
"""근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다."""
src = inspect.getsource(pf.sync_portfolio)
i = src.index("유입(external) 잔고 격리용")
block = src[i:i + 2500]
assert 'TradeHistory.order_type == "BUY"' in block
assert "recent_point" not in block, "유입 판정에 당일 시간창을 쓰면 안 된다"
assert "_SEEDED_AT_STRATEGY_NAMES" in block, "시딩 ActiveTrade 를 근거에서 제외해야 한다"
"""근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다.
[2026-09-30 M-5] 소스 문자열이 아니라 **컴파일된 SQL** 을 본다.
"""
sql = _compiled(pf._system_buy_codes_stmt())
assert "trade_history.order_type = 'BUY'" in sql
assert "created_at" not in sql and "executed_at" not in sql, (
"유입 판정에 당일 시간창을 쓰면 안 된다"
)
def test_sync_never_demotes_managed_rows_to_external():
"""운용 중인 swing/daytrading 행을 external 로 강등하지 않는다(승격만 허용)."""
def test_sync_demotes_only_through_guarded_helper():
"""[2026-09-30 사용자 결정] 강등을 허용하되 **반드시 레이스 가드를 거친다**.
"스윙으로 편입하는 건 없어야 해" → swing → external 강등이 필요하지만,
조건 없이 강등하면 매수 직후(근거 DB 반영 전) 정상 포지션이 리스크 관리 밖으로
떨어진다. 그래서 강등은 `_should_demote_to_external()` 단일 관문만 통과한다.
"""
src = inspect.getsource(pf.sync_portfolio)
assert '== EXTERNAL_STRATEGY_TYPE and _classified == "swing"' in src
# 강등 코드가 들어오면 (h.strategy_type = external) 패턴으로 나타난다
assert "h.strategy_type = EXTERNAL_STRATEGY_TYPE" not in src
assert "_classify_durable(stock_code, quantity) == \"swing\"" in src, (
"승격 경로 유지 — 단 durable 근거(레지스트리 제외)만 본다 (M-2)"
)
# 국내·해외 두 경로 모두 강등한다
assert src.count("h.strategy_type = EXTERNAL_STRATEGY_TYPE") == 2
# 무조건 강등 금지 — 가드 함수를 거친 분기에서만 대입된다
for seg in src.split("h.strategy_type = EXTERNAL_STRATEGY_TYPE")[:-1]:
assert "_should_demote_to_external(" in seg[-400:], (
"강등 대입 직전 분기가 _should_demote_to_external() 이어야 한다"
)
def test_sync_excludes_legacy_seeded_active_trade_from_evidence():
"""2026-09-29 이전 시딩 행(strategy_name='swing')은 entry_reason 으로 걸러낸다.
걸러내지 않으면 유입 잔고가 자기 시딩 행을 근거로 삼아 격리가 스스로 풀린다.
"""
sql = _compiled(pf._evidence_active_trade_codes_stmt())
assert "NOT IN ('synced')" in sql
assert "NOT LIKE '외부 유입 포지션" in sql
def test_sync_skips_active_trade_seeding_for_external():
@@ -196,13 +226,46 @@ def _external_row():
@pytest.mark.asyncio
async def test_reflect_buy_promotes_external_row_on_swing_buy():
"""격리 행에 스윙 시스템 매수가 들어오면 승격 — 다음 sync 까지 무관리 방지."""
async def test_reflect_buy_refuses_partial_mix_promotion_on_swing_buy():
"""[2026-09-30 정정] 수량이 남아 있는 격리 행은 스윙 매수로도 승격하지 않는다.
종전 동작(무조건 승격)은 **유입분 세탁 경로**였다: Portfolio 는 stock_code
UNIQUE 로 수량이 합산되므로(§3.5) 승격하면 유입 8주까지 스윙 자동 손절 대상이
된다. 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주
→ swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 위반이다.
"""
from unittest.mock import AsyncMock, patch
from app.services import portfolio_cache_bridge as bridge
row = _external_row()
row = _external_row() # external 8주
class _Cache:
holdings = [row]
apply_buy = AsyncMock()
_enq = AsyncMock()
with patch.object(bridge, "_cache", return_value=_Cache()), \
patch("app.services.db_worker.enqueue_portfolio_upsert", _enq):
await bridge.reflect_buy("000100", "유한양행", 5, 11_000, strategy_type="swing")
assert row.strategy_type == EXTERNAL_STRATEGY_TYPE, "부분 혼재는 승격 금지"
assert "strategy_type" not in _enq.await_args.args[0], "DB 태그도 바꾸지 않는다"
@pytest.mark.asyncio
async def test_reflect_buy_promotes_only_when_no_inflow_quantity_remains():
"""유입 잔여 수량이 0 인 행은 승격한다 — 관리 공백을 만들지 않기 위해."""
from unittest.mock import AsyncMock, patch
from app.models.models import Portfolio
from app.services import portfolio_cache_bridge as bridge
row = Portfolio(
stock_code="000100", stock_name="유한양행", quantity=0,
average_price=10_000, current_price=10_000, market="KRX",
strategy_type=EXTERNAL_STRATEGY_TYPE,
)
class _Cache:
holdings = [row]

View File

@@ -16,6 +16,7 @@
추가: F2(ORB 전일 캔들 혼입 차단), F3(종료 drain 이 포지션 추적 계열 커밋),
F4(DLQ 재처리가 큐 경유).
"""
import asyncio
from datetime import date, datetime, time as dtime, timedelta
from unittest.mock import AsyncMock
@@ -382,12 +383,15 @@ async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch):
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")
@@ -399,6 +403,11 @@ async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch):
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
@@ -512,9 +521,69 @@ def test_scan_time_budget_below_guard_timeout():
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, "잘린 종목 수를 로그에 남겨야 한다"
@pytest.mark.asyncio
async def test_truncated_scan_rotates_start_offset_next_cycle(monkeypatch, caplog):
"""[M-5 동작 검증] 예산에 걸려 잘리면 다음 사이클은 다른 종목부터 스캔한다.
소스에 '_scan_offset' 이 있는지 보는 대신, 두 사이클을 실제로 돌려 **스캔된 종목
순서**가 회전하는지 본다. 잘린 종목 수가 로그에 남는지도 함께 확인한다.
"""
import logging
from app.services.daytrading import entry_service as es
from app.services.daytrading.position_manager import day_trading_state
scanned_order = []
async def slow_try_enter(code, name, ta, ac, is_lunch_break=False):
scanned_order.append(code)
await asyncio.sleep(0.05) # 예산을 넘기도록 느리게
return None
async def fake_prefetch(codes):
return len(codes)
monkeypatch.setattr(es, "_try_enter", slow_try_enter)
monkeypatch.setattr(es, "_prefetch_minute_charts", fake_prefetch)
monkeypatch.setattr(es, "_SCAN_TIME_BUDGET", 0.04)
monkeypatch.setattr(es.mdd_manager, "is_short_term_strategy_enabled",
AsyncMock(return_value=True))
monkeypatch.setattr(es.mdd_manager, "can_open_new_position",
AsyncMock(return_value=True))
monkeypatch.setattr(es, "is_daytrading_allowed_by_playbook", lambda *a, **k: True)
monkeypatch.setattr(es, "check_market_regime",
AsyncMock(return_value={"regime": "neutral"}))
monkeypatch.setattr(es.korea_investment_api, "get_balance", AsyncMock(
return_value={"output2": [{"tot_evlu_amt": "100000000",
"prvs_rcdl_excc_amt": "50000000"}]}))
monkeypatch.setattr(es.korea_investment_api, "get_buyable_cash",
AsyncMock(return_value=50_000_000.0))
# 벽시계 의존 제거 — 컷오프(15:00) 이후에 돌리면 조기 리턴해 빈 리스트가 된다
_freeze_entry_window(monkeypatch, es)
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
day_trading_state.daily_pnl = 0.0
day_trading_state.last_total_assets = 100_000_000.0
es._scan_offset = 0
es.set_watchlist([{"code": f"00000{i}", "name": f"종목{i}"} for i in range(4)])
with caplog.at_level(logging.WARNING):
await es.scan_and_enter()
first_cycle = list(scanned_order)
scanned_order.clear()
await es.scan_and_enter()
second_cycle = list(scanned_order)
assert first_cycle and second_cycle
assert first_cycle[0] != second_cycle[0], (
f"두 사이클이 같은 종목부터 시작했다 ({first_cycle[0]}) — 뒤쪽 종목이 영구 소외된다"
)
assert any("미스캔" in r.getMessage() for r in caplog.records), \
"잘린 종목 수가 로그에 남아야 한다"
es.set_watchlist([])
es._scan_offset = 0
# ── F7: 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 ─────────────────
@@ -672,15 +741,61 @@ async def test_watchlist_refresh_preserves_orb_holders(monkeypatch):
es.set_watchlist([])
def test_watchlist_uses_latest_analysis_date_not_today_only():
"""e0178aa(2026-09-22)의 `max(analysis_date <= today)` 완화가 살아 있는지."""
import inspect
@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
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
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():
@@ -835,7 +950,10 @@ async def test_daily_guard_redis_failure_is_not_silent(monkeypatch, caplog):
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
@@ -1232,7 +1350,13 @@ def test_candle_cache_ttl_covers_one_cycle():
# ── M4: swing 청산 경로도 일일 안전장치를 저장한다 ──────────────────────────
def test_swing_exit_persists_daily_guards():
"""스윙 손절이 등록한 closed_today 가 재기동 후에도 유지되어야 한다."""
"""스윙 손절이 등록한 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
@@ -1274,8 +1398,10 @@ async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch)
"strategy_name": "day_trade",
},
}
degraded = _json.loads(_json.dumps({"task": original, "error": "shutdown_drain"},
default=str))
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"
@@ -1288,6 +1414,11 @@ async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch)
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 이 문자열로 남았다"
@@ -1318,7 +1449,7 @@ async def test_replay_dlq_restores_date_for_bulk_rows(tmp_path, monkeypatch):
_json.dumps({"task": {
"type": "bulk_upsert_stock_analysis",
"data": [{"stock_code": "005930", "analysis_date": "2026-09-28"}],
}}), encoding="utf-8")
}, "failed_at": datetime.now().isoformat()}), encoding="utf-8")
enqueued = []
@@ -1400,5 +1531,440 @@ def test_scan_clears_cache_before_prefetch():
import inspect
from app.services.daytrading import entry_service as es
src = inspect.getsource(es.scan_and_enter)
# 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) 를 추가하라"
)