fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류

## 재기동 내구성
- 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>
This commit is contained in:
2026-09-29 15:49:25 +09:00
parent 3fdc19c749
commit 7abbb3411c
27 changed files with 4164 additions and 227 deletions

View File

@@ -104,6 +104,7 @@ _KIS_CLASS_METHODS = (
"get_overseas_daily_chart",
"get_minute_chart",
"get_minute_chart_full_day",
"get_minute_chart_full_day_v2", # 진입 판정 경로(전일 워밍업 포함) — 2026-09-29
"get_balance",
"get_buyable_cash",
"get_overseas_balance",

View File

@@ -24,21 +24,48 @@ class _Adp:
def test_buy_amount_uses_position_cap_and_confidence():
"""[2026-09-28] 적응형 6% 는 MAX_POSITION_SIZE(절대 상한)로 클램프된다.
기대값은 config 를 참조한다(§4.1 — 하드코딩 금지).
"""
from app.core.config import settings
with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()):
out = _suggest_buy_amount(current_price=70_000, recommendation="BUY",
confidence=0.7, stock_code="005930")
# 1,000만 × 6% = 60만, 신뢰도 0.7 → 보정 1.0 → 70,000원 × 8주 = 560,000
assert out["suggested_quantity"] == 8
assert out["suggested_amount"] == 560_000
assert out["position_ratio"] == pytest.approx(5.6, abs=0.01)
assert "6%" in out["sizing_note"]
_cap = settings.MAX_POSITION_SIZE
_expected_qty = int((10_000_000 * _cap) // 70_000)
assert out["suggested_quantity"] == _expected_qty
assert out["suggested_amount"] == _expected_qty * 70_000
assert out["position_ratio"] <= _cap * 100 + 0.01
assert f"{_cap * 100:.0f}%" in out["sizing_note"]
def test_high_confidence_scales_up_capped_at_120pct():
def test_high_confidence_does_not_exceed_hard_cap():
"""신뢰도 상향 보정(×1.2)도 종목당 절대 상한을 넘지 못한다."""
from app.core.config import settings
with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()):
out = _suggest_buy_amount(70_000, "BUY", 1.0, "005930")
# 60만 × min(1.2, 1.0/0.7=1.43) = 72만 → 10주
assert out["suggested_quantity"] == 10
assert out["position_ratio"] <= settings.MAX_POSITION_SIZE * 100 + 0.01
@pytest.mark.parametrize("mode_position_size", [0.06, 0.07]) # Normal / Aggressive
def test_adaptive_modes_clamped_to_hard_cap(mode_position_size):
"""적응형 Normal(6%)·Aggressive(7%) 모드에서도 제안 비중 ≤ MAX_POSITION_SIZE.
[R4 회귀] 표시 경로가 클램프를 빼먹으면 운영자가 화면 금액으로 수동매수해
§5.1 절대 상한(4%)이 깨진다.
"""
from app.core.config import settings
class _M:
fallback_used = False
position_size = mode_position_size
with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_M()):
out = _suggest_buy_amount(70_000, "BUY", 1.0, "005930")
assert out["suggested_amount"] / 10_000_000 <= settings.MAX_POSITION_SIZE + 1e-9
def test_non_buy_recommendation_returns_zero():

View File

@@ -0,0 +1,698 @@
"""DT 진입 워밍업 회귀 복구 + ORB 1분봉 거래량 게이트 (2026-09-29)
배경
----
커밋 `1b9257c`(2026-09-28)가 `korea_investment_api.get_minute_chart_full_day` 에
"당일 봉만" 필터를 넣었다. §7 이 경고하는 오염(전일 캔들이 ORB 고저·평균분당거래량·
VWAP 을 오염)을 막으려는 의도였지만, **지표 워밍업도 함께 끊겼다**: 5분봉
RSI(14)=15봉(75분)·BB(20)=20봉(100분) 요건 때문에 VWAP 전략은 약 10:15, RSI_BB 는
약 10:40 이전에 구조적으로 발화할 수 없었다(`indicators_core.rsi_wilder` 는 봉이
모자라면 `None`, 짧은 기간으로 대체 계산하지 않는다). 원장 실측으로 필터 도입 전에는
09:30·10:02 진입이 실재했음이 확인됐다.
이 파일이 고정하는 것
---------------------
1. 워밍업: 09:30 시점(당일 5분봉 6개)에도 RSI(14)/BB(20)가 계산되어 판정에 도달한다
2. 세션 앵커: VWAP·현재가에는 당일 봉만 흘러간다
3. ORB 레인지·평균분당거래량이 전일 봉에 오염되지 않는다
4. `volume_surge_ratio` 가 세션 경계를 넘지 않는다 (당일 완성봉 2개 미만 → None)
5. ORB 거래량 게이트는 형성 중 봉이 아니라 **마지막 완성 1분봉**을 쓴다
6. DT 청산 경로·레거시 엔드포인트는 여전히 당일 봉만 받는다
"""
import inspect
from datetime import datetime, timedelta
from unittest.mock import AsyncMock
import pytest
import pytz
from app.core.config import settings
from app.services.day_trading_strategy import (
MinuteCandle,
OpeningRange,
_bollinger,
_rsi,
aggregate_to_5min,
calculate_opening_range,
check_orb_signal,
check_rsi_bb_signal,
volume_surge_ratio,
)
_KST = pytz.timezone("Asia/Seoul")
# conftest 의 autouse 차단 픽스처는 `KoreaInvestmentAPI` 의 시세 메서드를 예외 발생형으로
# 덮는다(§4.1). 조회 계층의 **집계/필터 로직 자체**를 검증하려면 원본 함수 객체가 필요하므로
# import 시점(픽스처 적용 전)에 붙잡아 둔다. 실제 네트워크 종단인 `get_minute_chart` 는
# 각 테스트에서 fake 로 덮으므로 실호출은 발생하지 않는다.
from app.services.korea_investment_api import KoreaInvestmentAPI as _KIS # noqa: E402
_ORIG_FULL_DAY = _KIS.get_minute_chart_full_day
_ORIG_FULL_DAY_V2 = _KIS.get_minute_chart_full_day_v2
def _today() -> str:
return datetime.now(_KST).strftime("%Y%m%d")
def _prev() -> str:
return (datetime.now(_KST) - timedelta(days=1)).strftime("%Y%m%d")
def _c(date: str, hhmmss: str, close: float, volume: int = 1000,
open_: float | None = None) -> MinuteCandle:
op = close if open_ is None else open_
return MinuteCandle(
date=date, time=hhmmss,
open=op, high=max(op, close), low=min(op, close), close=close,
volume=volume, cum_volume=volume, cum_amount=float(close * volume),
)
def _minute_seq(date: str, start_h: int, start_m: int, count: int,
close_fn, volume_fn=lambda i: 1000) -> list:
"""`count` 개의 연속 1분봉. close_fn(i)/volume_fn(i) 로 값을 정한다."""
out = []
for i in range(count):
total = start_h * 60 + start_m + i
hh, mm = divmod(total, 60)
out.append(_c(date, f"{hh:02d}{mm:02d}00", close_fn(i), volume_fn(i)))
return out
# ─────────────────────────────────────────────────────────────────────────────
# 1. 워밍업 — 09:30 시점에 RSI(14)/BB(20) 가 계산되어 판정에 도달한다
# ─────────────────────────────────────────────────────────────────────────────
def _warmup_scenario() -> tuple[list, list]:
"""(전일+당일 1분봉, 당일만 1분봉) — 09:30 시점 가정.
당일: 09:00~09:29 (5분봉 6개). 전일 13:00~15:19 (5분봉 28개 워밍업).
가격은 완만한 하락 후 마지막 완성봉에서 급락 → RSI 과매도 + BB 하단 이탈,
형성 중 봉은 양봉. 거래량은 마지막 완성봉이 직전 완성봉의 2배.
"""
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배
today_bars += _minute_seq(
_today(), 9, 20, 5, lambda i: 940.0 - i * 6.0, lambda i: 2000
)
# 09:25~09:29 = 형성 중 봉: 양봉 (반등 시작)
today_bars += [
_c(_today(), "092500", 912.0, 300, open_=910.0),
_c(_today(), "092600", 913.0, 300, open_=912.0),
]
return prev_day + today_bars, today_bars
def test_warmup_enables_rsi_bb_at_0930():
"""전일 봉이 붙으면 09:30 에도 RSI(14)/BB(20) 가 계산된다."""
mixed, today_only = _warmup_scenario()
bars_today = aggregate_to_5min(today_only)
assert len(bars_today) < 20, "전제: 당일만으로는 BB(20) 요건 미달이어야 한다"
assert _rsi(bars_today, 14) is None, "당일 봉만으로는 RSI(14) 계산 불가(회귀 전제)"
assert _bollinger(bars_today, 20) is None
bars_mixed = aggregate_to_5min(mixed)
assert len(bars_mixed) >= 20
assert _rsi(bars_mixed, 14) is not None, "워밍업이 붙으면 RSI 가 계산돼야 한다"
assert _bollinger(bars_mixed, 20) is not None
def test_rsi_bb_signal_reachable_at_0930_only_with_warmup():
"""같은 시장 상황에서 워밍업 유무가 발화 가능/불가를 가른다."""
mixed, today_only = _warmup_scenario()
price = today_only[-1].close
assert check_rsi_bb_signal("005930", "삼성전자",
aggregate_to_5min(today_only), price) is None
sig = check_rsi_bb_signal("005930", "삼성전자",
aggregate_to_5min(mixed), price)
assert sig is not None, "워밍업이 있으면 09:30 에도 RSI_BB 판정에 도달해야 한다"
assert sig.strategy == "RSI_BB"
assert sig.stop_loss < price < sig.target_price
# §4: reason 에 임계값을 하드코딩하지 말고 실효값을 쓴다
assert f"{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}" in sig.reason
# ─────────────────────────────────────────────────────────────────────────────
# 2. 세션 앵커 — VWAP·현재가는 당일 봉만
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_entry_path_requests_prev_day_but_anchors_today(monkeypatch):
"""진입 경로는 전일 포함 시계열을 요청하고, 세션 지표는 당일 봉만 받는다."""
from app.services.daytrading import entry_service as es
mixed, today_only = _warmup_scenario()
raw = [
{
"stck_bsop_date": c.date, "stck_cntg_hour": c.time,
"stck_prpr": c.close, "stck_oprc": c.open,
"stck_hgpr": c.high, "stck_lwpr": c.low,
"cntg_vol": c.volume, "acml_vol": c.cum_volume,
"acml_tr_pbmn": c.cum_amount,
}
for c in mixed
]
calls = {}
async def fake_v2(stock_code, market_open="090000", include_prev_day=False,
prev_day_minutes=0):
calls["include_prev_day"] = include_prev_day
return raw
async def fake_today_only(stock_code, market_open="090000"):
raise AssertionError("진입 경로는 당일 전용 메서드를 쓰면 안 된다(워밍업 소실)")
captured = {}
def fake_vwap(cs):
captured["vwap_dates"] = sorted({c.date for c in cs})
return float(cs[-1].close)
def fake_rsi_bb(code, name, candles_5m, price):
captured["bars"] = len(candles_5m)
captured["bar_dates"] = sorted({c.date for c in candles_5m})
captured["price"] = price
return None
monkeypatch.setattr(es.korea_investment_api, "get_minute_chart_full_day_v2",
fake_v2, raising=False)
monkeypatch.setattr(es.korea_investment_api, "get_minute_chart_full_day",
fake_today_only, raising=False)
monkeypatch.setattr(es, "calculate_vwap", fake_vwap)
monkeypatch.setattr(es, "check_rsi_bb_signal", fake_rsi_bb)
monkeypatch.setattr(es, "check_vwap_bounce_signal",
lambda *a, **k: None)
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 # 스텁이 신호 없음을 반환
assert calls["include_prev_day"] is True
assert captured["bars"] >= 20, "워밍업 시계열이 지표에 전달되지 않았다"
assert _prev() in captured["bar_dates"] and _today() in captured["bar_dates"]
assert captured["vwap_dates"] == [_today()], "VWAP 에 전일 누적이 섞이면 안 된다"
assert captured["price"] == today_only[-1].close, "현재가는 당일 마지막 봉"
# ─────────────────────────────────────────────────────────────────────────────
# 3. ORB 레인지·평균분당거래량 무오염
# ─────────────────────────────────────────────────────────────────────────────
def test_orb_range_not_polluted_by_prev_day():
"""`filter_today_candles` 를 거친 ORB 는 전일 고저·거래량을 담지 않는다."""
from app.services.daytrading.entry_service import filter_today_candles
prev_bars = _minute_seq(_prev(), 9, 0, 10, lambda i: 5000.0 + i * 100,
lambda i: 100_000)
today_bars = _minute_seq(_today(), 9, 0, 10, lambda i: 7000.0 + i,
lambda i: 1_000)
orb_clean = calculate_opening_range(filter_today_candles(prev_bars + today_bars))
orb_today = calculate_opening_range(today_bars)
assert orb_clean == orb_today
assert orb_clean.avg_vol_per_min == 1_000
def test_orb_signal_ignores_prev_day_candles():
"""check_orb_signal 은 전일 봉이 섞여도 당일 봉만으로 판정한다."""
orb = OpeningRange(high=1000.0, low=980.0, total_volume=30_000,
avg_vol_per_min=1_000.0, open_price=990.0)
# 당일: 마지막 완성봉 거래량 2,000(=2배) → 게이트 통과, 직전 5분봉 양봉
today_bars = _minute_seq(_today(), 9, 30, 10, lambda i: 1000.0 + i,
lambda i: 2_000)
today_bars.append(_c(_today(), "094000", 1010.0, 100, open_=1009.0))
prev_bars = _minute_seq(_prev(), 15, 0, 10, lambda i: 500.0,
lambda i: 900_000)
sig_today = check_orb_signal("005930", "삼성전자", today_bars, orb, 1010.0)
sig_mixed = check_orb_signal("005930", "삼성전자", prev_bars + today_bars,
orb, 1010.0)
assert sig_today is not None
assert sig_mixed is not None
assert sig_mixed.reason == sig_today.reason, "전일 봉이 거래량 배율을 바꿨다"
# ─────────────────────────────────────────────────────────────────────────────
# 4. volume_surge_ratio — 세션 경계를 넘지 않는다
# ─────────────────────────────────────────────────────────────────────────────
def test_volume_surge_ratio_does_not_cross_session():
prev_5m = [_c(_prev(), f"15{m:02d}00", 100.0, 1_000_000) for m in (5, 10, 15)]
today_5m = [_c(_today(), "090000", 100.0, 1_000),
_c(_today(), "090500", 101.0, 1_000)]
# 당일 완성봉이 1개(09:00 버킷)뿐 → None (게이트 미통과)
assert volume_surge_ratio(prev_5m + today_5m) is None
# 당일 완성봉 2개 확보 → 당일 봉끼리만 비교
today_5m.append(_c(_today(), "091000", 102.0, 1_000))
ratio = volume_surge_ratio(prev_5m + today_5m)
assert ratio == pytest.approx(1.0), "전일 거래량이 분모로 쓰이면 안 된다"
def test_volume_surge_ratio_uses_completed_bars_only():
"""[F7 회귀 가드] 형성 중 봉([-1])은 비교에서 제외된다."""
bars = [
_c(_today(), "090000", 100.0, 1_000),
_c(_today(), "090500", 101.0, 2_000), # 마지막 완성봉
_c(_today(), "091000", 102.0, 10), # 형성 중 (부분 거래량)
]
assert volume_surge_ratio(bars) == pytest.approx(2.0)
def test_volume_surge_ratio_undated_candles_keep_legacy_behavior():
bars = [_c("", "090000", 100.0, 1_000),
_c("", "090500", 101.0, 2_000),
_c("", "091000", 102.0, 10)]
assert volume_surge_ratio(bars) == pytest.approx(2.0)
# ─────────────────────────────────────────────────────────────────────────────
# 5. ORB 거래량 게이트 — 마지막 완성 1분봉 기준
# ─────────────────────────────────────────────────────────────────────────────
def _orb_fixture() -> OpeningRange:
return OpeningRange(high=1000.0, low=980.0, total_volume=30_000,
avg_vol_per_min=1_000.0, open_price=990.0)
def test_orb_volume_gate_uses_last_completed_minute_bar():
"""형성 중 봉이 절반만 채워져 있어도, 완성봉이 임계를 넘으면 통과한다."""
orb = _orb_fixture()
mult = settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT
completed_vol = int(orb.avg_vol_per_min * mult) + 10
bars = _minute_seq(_today(), 9, 30, 9, lambda i: 1000.0 + i, lambda i: 2_000)
bars.append(_c(_today(), "093900", 1010.0, completed_vol, open_=1009.0))
# 형성 중 봉: 아직 임계 미달 (30초 주기 스캔의 전형)
bars.append(_c(_today(), "094000", 1010.0,
int(orb.avg_vol_per_min * mult) - 1, open_=1009.5))
sig = check_orb_signal("005930", "삼성전자", bars, orb, 1010.0)
assert sig is not None, "형성 중 봉 때문에 탈락하면 실효 요구가 임계값의 ~2배가 된다"
assert f"{mult}" in sig.reason, "§4: reason 에 실효 임계값을 넣어야 한다"
def test_orb_volume_gate_rejects_when_completed_bar_below_threshold():
"""형성 중 봉만 급증해도 통과하지 않는다 (완성봉이 기준)."""
orb = _orb_fixture()
mult = settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT
bars = _minute_seq(_today(), 9, 30, 9, lambda i: 1000.0 + i, lambda i: 2_000)
bars.append(_c(_today(), "093900", 1010.0,
int(orb.avg_vol_per_min * mult) - 1, open_=1009.0))
bars.append(_c(_today(), "094000", 1010.0, 999_999, open_=1009.5))
assert check_orb_signal("005930", "삼성전자", bars, orb, 1010.0) is None
def test_orb_volume_gate_blocks_when_no_completed_bar():
"""당일 봉이 1개뿐(완성봉 없음)이면 게이트 미통과 — 통과 쪽으로 열지 않는다."""
orb = _orb_fixture()
bars = [_c(_today(), "093000", 1010.0, 999_999, open_=1009.0)]
assert check_orb_signal("005930", "삼성전자", bars, orb, 1010.0) is None
assert check_orb_signal("005930", "삼성전자", [], orb, 1010.0) is None
# ─────────────────────────────────────────────────────────────────────────────
# 6. 조회 계층 — 기본 동작(당일만) 보존 / 청산·레거시 경로 무변경
# ─────────────────────────────────────────────────────────────────────────────
def _isolated_api():
"""조회 계층 로직 검증용 독립 인스턴스.
모듈 싱글톤(`korea_investment_api`)을 쓰지 않는다 — 다른 테스트 모듈이 그 이름을
MagicMock 으로 덮으면(전체 스위트 실행 시 실재) 순서 의존으로 깨진다(CLAUDE.md §4.1).
`__init__` 을 건너뛰어 토큰 캐시 로드·httpx 클라이언트 생성도 하지 않는다.
"""
api = _KIS.__new__(_KIS)
api.min_request_interval = 0.0
return api
def _raw(date: str, hhmmss: str, vol: int = 100) -> dict:
return {
"stck_bsop_date": date, "stck_cntg_hour": hhmmss,
"stck_prpr": "1000", "stck_oprc": "1000",
"stck_hgpr": "1001", "stck_lwpr": "999",
"cntg_vol": str(vol), "acml_vol": str(vol), "acml_tr_pbmn": "1000",
}
@pytest.mark.asyncio
async def test_query_layer_default_keeps_today_only(monkeypatch):
"""`get_minute_chart_full_day` 는 전일 봉을 계속 걸러낸다(기존 동작 보존)."""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today, prev = _today(), _prev()
async def fake_page(stock_code, ref_time=None, include_past=True):
# 한 페이지에 당일 09:00~09:02 + 전일 15:18~15:20 이 섞여 돌아오는 상황
return {"output2": [
_raw(today, "090200"), _raw(today, "090100"), _raw(today, "090000"),
_raw(prev, "152000"), _raw(prev, "151900"), _raw(prev, "151800"),
]}
monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day",
_ORIG_FULL_DAY)
monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False)
monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False)
monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None))
monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None))
out = await api.get_minute_chart_full_day("005930")
assert {r["stck_bsop_date"] for r in out} == {today}
assert [r["stck_cntg_hour"] for r in out] == ["090000", "090100", "090200"]
@pytest.mark.asyncio
async def test_query_layer_v2_includes_prev_day_sorted_by_date(monkeypatch):
"""`_v2(include_prev_day=True)` 는 전일 봉을 앞쪽에 붙여 반환한다.
정렬 키가 (영업일자, 시각)이어야 한다 — 시각만 보면 전일 15:20 이 당일 09:00
뒤에 놓여 `candles[-1]`(현재가·형성 중 봉) 기반 계산이 전부 무너진다.
"""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today, prev = _today(), _prev()
async def fake_page(stock_code, ref_time=None, include_past=True):
return {"output2": [
_raw(today, "090200"), _raw(today, "090100"), _raw(today, "090000"),
_raw(prev, "152000"), _raw(prev, "151900"), _raw(prev, "151800"),
]}
monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2",
_ORIG_FULL_DAY_V2)
monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False)
monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False)
monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None))
monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None))
out = await api.get_minute_chart_full_day_v2(
"005930", include_prev_day=True, prev_day_minutes=3
)
keys = [(r["stck_bsop_date"], r["stck_cntg_hour"]) for r in out]
assert keys == [
(prev, "151800"), (prev, "151900"), (prev, "152000"),
(today, "090000"), (today, "090100"), (today, "090200"),
]
def test_exit_and_legacy_paths_stay_today_only():
"""DT 청산·레거시 엔드포인트·ORB 수집은 전일 포함 메서드를 쓰지 않는다."""
from app.api.endpoints import day_trading as legacy
from app.services.daytrading import entry_service as es
from app.services.daytrading import exit_service as xs
exit_src = inspect.getsource(xs)
legacy_src = inspect.getsource(legacy)
assert "get_minute_chart_full_day_v2" not in exit_src, (
"DT 청산 경로에 전일 봉이 들어가면 트레일링·ATR·시간손절이 오염된다"
)
assert "get_minute_chart_full_day_v2" not in legacy_src
assert "get_minute_chart_full_day" in exit_src
orb_src = inspect.getsource(es.collect_opening_ranges)
assert "get_minute_chart_full_day_v2" not in orb_src, (
"ORB 수집이 전일 봉을 받으면 레인지·평균분당거래량이 오염된다(§7)"
)
# 진입 판정 경로만 전일 포함
assert "get_minute_chart_full_day_v2" in inspect.getsource(es._fetch_candles_impl)
@pytest.mark.asyncio
async def test_query_layer_v2_paginates_across_day_boundary(monkeypatch):
"""전일 워밍업 수집이 날짜 경계에서 멈추지 않는다 — **루프 로직만** 검증한다.
다음 페이지 기준 시각을 **시각만으로** 정하면, 당일 09:00 봉이 섞인 페이지에서
`min(times)="090000"` 이 되어 같은 ref 를 재요청하고 `new_added==0` 으로 즉시
중단된다 → 전일 봉이 한 페이지분만 모여 워밍업이 사실상 안 된다.
여기서는 ref 가 **(영업일자, 시각) 최솟값의 시각**으로 갱신되는지를 고정한다.
⚠️ **이 테스트는 KIS 의 계약을 검증하지 못한다.** fake 는 "미래 `ref_time`(전일
14:51 등)을 주면 그 시점 이전의 전일 봉을 돌려준다"는 **가정을 심어 둔 것**이고,
실제 KIS 가 그렇게 응답하는지는 확인되지 않았다(요청 파라미터에 날짜가 없다).
KIS 가 최신 봉을 그대로 돌려주면 프로덕션에서는 `new_added==0` 으로 조기 중단되고
전일 봉은 경계 페이지 1장만 남는다. 확정 근거는 운영 로그
`[분봉] ... 워밍업 5분봉 N봉` 뿐이다.
"""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today, prev = _today(), _prev()
# 전일 14:00~15:20(81봉) + 당일 09:00~09:02(3봉) — 오래된 것 → 최신
prev_minutes = [f"{h:02d}{m:02d}00" for h in (14, 15) for m in range(60)
if not (h == 15 and m > 20)]
ledger = [(prev, t) for t in prev_minutes] + \
[(today, t) for t in ("090000", "090100", "090200")]
requested_refs = []
cursor = {"end": len(ledger)} # KIS 페이지네이션 모사: 뒤에서 30개씩 거슬러 올라감
expected_next_ref = {"value": None}
async def fake_page(stock_code, ref_time=None, include_past=True):
requested_refs.append(ref_time)
if expected_next_ref["value"] is not None:
assert ref_time == expected_next_ref["value"], (
f"ref 가 (영업일자, 시각) 최솟값으로 갱신되지 않았다: "
f"{ref_time} != {expected_next_ref['value']}"
)
end = cursor["end"]
if end <= 0:
return {"output2": []}
window = ledger[max(0, end - 30):end]
cursor["end"] = max(0, end - 30)
expected_next_ref["value"] = min(window)[1] # (date, time) 최솟값의 시각
return {"output2": [_raw(d, t) for d, t in reversed(window)]}
monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2",
_ORIG_FULL_DAY_V2)
monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False)
monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False)
monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None))
monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None))
out = await api.get_minute_chart_full_day_v2(
"005930", include_prev_day=True, prev_day_minutes=60
)
prev_rows = [r for r in out if r["stck_bsop_date"] == prev]
assert len(requested_refs) >= 3, f"페이지네이션이 조기 중단됐다 (ref={requested_refs})"
assert len(prev_rows) >= 60, (
f"전일 봉 {len(prev_rows)}개 < 요청 60 — 날짜 경계에서 멈췄다"
)
keys = [(r["stck_bsop_date"], r["stck_cntg_hour"]) for r in out]
assert keys == sorted(keys), "정렬 키에 영업일자가 빠지면 candles[-1] 이 전일 봉이 된다"
assert keys[-1] == (today, "090200"), "candles[-1] 은 당일 최신 봉이어야 한다"
@pytest.mark.asyncio
async def test_query_layer_v2_stops_once_warmup_is_enough(monkeypatch):
"""요청한 전일 분량을 채우면 더 조회하지 않는다 (KIS 레이트 리밋 보호)."""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today, prev = _today(), _prev()
pages = {"n": 0}
async def fake_page(stock_code, ref_time=None, include_past=True):
pages["n"] += 1
base = 900 - pages["n"] * 30
rows = [_raw(today, "090000")] if pages["n"] == 1 else []
rows += [_raw(prev, f"{13 + (base + i) // 60:02d}{(base + i) % 60:02d}00")
for i in range(30)]
return {"output2": rows}
monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2",
_ORIG_FULL_DAY_V2)
monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False)
monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False)
monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None))
monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None))
await api.get_minute_chart_full_day_v2(
"005930", include_prev_day=True, prev_day_minutes=30
)
assert pages["n"] <= 2, f"필요 분량을 채운 뒤에도 조회가 계속됐다 ({pages['n']}회)"
@pytest.mark.asyncio
async def test_query_layer_v2_skips_prev_day_when_today_is_enough(monkeypatch):
"""장 중반 이후에는 당일 봉만으로 워밍업이 차므로 전일 봉을 더 받지 않는다.
(불필요한 KIS 호출 + MAX_PAGES 소진 방지. 조회 예산이 전일 수집에 쓰이면
당일 봉 수집이 잘릴 수 있다.)
"""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today = _today()
pages = {"n": 0}
async def fake_page(stock_code, ref_time=None, include_past=True):
pages["n"] += 1
# 당일 봉을 30개씩 과거로 내려준다 (13:00 → 09:00 방향)
start = 780 - pages["n"] * 30 # 13:00 = 780분
rows = []
for i in range(30):
total = max(540, start + i) # 09:00 하한
rows.append(_raw(today, f"{total // 60:02d}{total % 60:02d}00"))
return {"output2": list(reversed(rows))}
monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2",
_ORIG_FULL_DAY_V2)
monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False)
monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False)
monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None))
monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None))
out = await api.get_minute_chart_full_day_v2(
"005930", include_prev_day=True, prev_day_minutes=60
)
assert all(r["stck_bsop_date"] == today for r in out)
assert len(out) >= 60
# 09:00 에 도달하면 즉시 중단 — 전일 봉을 찾아 더 거슬러 가지 않는다
assert pages["n"] <= 9, f"당일 봉으로 충분한데 조회가 계속됐다 ({pages['n']}회)"
# ─────────────────────────────────────────────────────────────────────────────
# 7. [M-3] 워밍업 목표는 '분 수'가 아니라 '부족한 5분봉 개수'
# ─────────────────────────────────────────────────────────────────────────────
def _page_fake(pages: dict, rows_fn):
async def fake_page(stock_code, ref_time=None, include_past=True):
pages["n"] += 1
pages["refs"].append(ref_time)
return {"output2": rows_fn(pages["n"])}
return fake_page
def _wire(monkeypatch, kis_mod, api, fake_page):
monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2",
_ORIG_FULL_DAY_V2)
monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False)
monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False)
monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None))
monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None))
@pytest.mark.asyncio
async def test_warmup_stops_as_soon_as_missing_bars_are_covered(monkeypatch):
"""09:30(당일 5분봉 6개)이면 전일 5분봉 14개만 받고 멈춘다.
분 수(150) 기준이면 전일 150봉을 다 받으려 5페이지를 더 쓴다. 봉 수 기준이면
한 페이지(30분=6봉)씩 세 번이면 충족된다.
"""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today, prev = _today(), _prev()
pages = {"n": 0, "refs": []}
def rows(n):
if n == 1: # 당일 09:00~09:29 (5분봉 6개) + 경계 전일 봉
return [_raw(today, f"09{m:02d}00") for m in range(29, -1, -1)]
# 전일 15:20 에서 30분씩 거슬러 (페이지당 5분봉 6개)
start = 920 - (n - 1) * 30 # 15:20 = 920분
return [_raw(prev, f"{(start - i) // 60:02d}{(start - i) % 60:02d}00")
for i in range(30)]
_wire(monkeypatch, kis_mod, api, _page_fake(pages, rows))
out = await api.get_minute_chart_full_day_v2("005930", include_prev_day=True)
prev_buckets = {
(int(r["stck_cntg_hour"][:2]) * 60 + int(r["stck_cntg_hour"][2:4])) // 5
for r in out if r["stck_bsop_date"] == prev
}
assert pages["n"] <= 4, (
f"부족분(14봉)을 채운 뒤에도 조회가 계속됐다 ({pages['n']}페이지) — "
"분 수 기준 종료 조건이 남아 있다"
)
assert len(prev_buckets) >= 3
assert len([r for r in out if r["stck_bsop_date"] == today]) == 30
@pytest.mark.asyncio
async def test_warmup_respects_prev_day_page_cap_for_sparse_stocks(monkeypatch):
"""저유동성 종목(페이지당 봉이 적음)에서도 전일 페이지 상한이 폭주를 막는다."""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today, prev = _today(), _prev()
pages = {"n": 0, "refs": []}
def rows(n):
if n == 1:
return [_raw(today, "090000")]
# 페이지마다 5분봉 1개분(같은 버킷 안 1분봉 2개)만 채워지는 희소 종목
base = 900 - (n - 1) * 5
return [_raw(prev, f"{(base - i) // 60:02d}{(base - i) % 60:02d}00")
for i in range(2)]
_wire(monkeypatch, kis_mod, api, _page_fake(pages, rows))
await api.get_minute_chart_full_day_v2("005930", include_prev_day=True)
assert pages["n"] <= kis_mod.MINUTE_CHART_PREV_DAY_MAX_PAGES + 1, (
f"희소 종목에서 {pages['n']}페이지 — 전일 페이지 상한이 동작하지 않는다"
)
assert pages["n"] < kis_mod.MINUTE_CHART_MAX_PAGES
@pytest.mark.asyncio
async def test_warmup_skipped_entirely_when_today_has_20_bars(monkeypatch):
"""당일 5분봉이 20개 이상이면 전일 페이지를 한 장도 쓰지 않는다."""
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today = _today()
pages = {"n": 0, "refs": []}
def rows(n):
# 09:00~10:39 (5분봉 20개) 를 4페이지에 나눠 준다
start = 639 - (n - 1) * 30 # 10:39
return [_raw(today, f"{max(540, start - i) // 60:02d}"
f"{max(540, start - i) % 60:02d}00") for i in range(30)]
_wire(monkeypatch, kis_mod, api, _page_fake(pages, rows))
out = await api.get_minute_chart_full_day_v2("005930", include_prev_day=True)
assert all(r["stck_bsop_date"] == today for r in out)
assert pages["n"] <= 4, f"당일만으로 20봉이 차는데 {pages['n']}페이지를 썼다"
@pytest.mark.asyncio
async def test_warmup_shortfall_is_logged_as_warning(monkeypatch, caplog):
"""워밍업 미달은 warning 으로 남는다 — 배포 후 M-2 확정의 유일한 근거."""
import logging
from app.services import korea_investment_api as kis_mod
api = _isolated_api()
today = _today()
pages = {"n": 0, "refs": []}
def rows(n):
# 당일 09:00~09:04 (5분봉 1개) 만 존재하고 더 이상 과거가 없는 상황
return [_raw(today, f"090{m}00") for m in range(4, -1, -1)] if n == 1 else []
_wire(monkeypatch, kis_mod, api, _page_fake(pages, rows))
with caplog.at_level(logging.INFO, logger="app.services.korea_investment_api"):
await api.get_minute_chart_full_day_v2("005930", include_prev_day=True)
warn = [r for r in caplog.records if r.levelno >= logging.WARNING
and "워밍업" in r.message]
assert warn, "워밍업 미달이 조용히 지나가면 원인을 추적할 수 없다"
assert str(kis_mod.MINUTE_CHART_WARMUP_BARS_5MIN) in warn[0].message

View File

@@ -0,0 +1,267 @@
"""유입(external) 잔고 격리 — strategy_type='external' 불변식.
배경(2026-09-28 조사): 모의계좌 교체 시 기존 잔고가 계좌에 그대로 편입됐고,
`sync_portfolio()` 가 시스템 매수 여부를 검사하지 않고 전부 'swing' 으로 태깅해
`get_swing_holdings()`(strategy_type=='swing')가 곧바로 손절 대상으로 잡았다.
→ 시스템이 매수한 적 없는 잔고를 전략 손절로 처분(고아 청산 9건 / -153,661원).
사용자 결정: 유입 잔고는 'external' 로 격리하고 자동 손절·익절 대상에서 제외한다
(그 수량은 손절이 걸리지 않으며 운영자가 직접 판단한다).
"""
import inspect
import pytest
from app.api.endpoints import portfolio as pf
from app.api.endpoints.portfolio import (
EXTERNAL_STRATEGY_TYPE,
_classify_holding_strategy_type as _classify,
)
# ── 분류 규칙 (순수 함수) ───────────────────────────────────────────────────
def test_no_system_buy_evidence_is_external():
assert _classify("000100", 8, {}, set()) == EXTERNAL_STRATEGY_TYPE
def test_system_bought_is_swing():
assert _classify("000100", 8, {}, {"000100"}) == "swing"
def test_full_dt_quantity_is_daytrading():
assert _classify("000100", 3, {"000100": 3}, {"000100"}) == "daytrading"
def test_mixed_dt_and_extra_quantity_stays_swing():
"""DT 3주 + 스윙 5주 = 8주 → DT 태깅하면 스윙 5주가 청산 관리를 잃는다."""
assert _classify("000100", 8, {"000100": 3}, {"000100"}) == "swing"
def test_dt_position_without_system_evidence_still_external():
"""DT 인메모리가 비어 있고 근거도 없으면 external — '무조건 swing' 폴백 금지."""
assert _classify("000100", 8, {"000100": 0}, set()) == EXTERNAL_STRATEGY_TYPE
# ── sync_portfolio 불변식 (소스 수준) ──────────────────────────────────────
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 를 근거에서 제외해야 한다"
def test_sync_never_demotes_managed_rows_to_external():
"""운용 중인 swing/daytrading 행을 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
def test_sync_skips_active_trade_seeding_for_external():
"""격리분에 ActiveTrade 를 시딩하면 자동 손절 관리가 되살아난다."""
src = inspect.getsource(pf.sync_portfolio)
assert '(_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE' in src
def test_sync_logs_external_holdings_as_warning():
"""격리 잔고는 조용히 방치되면 안 된다 — warning 으로 노출(debug 금지, §4)."""
src = inspect.getsource(pf.sync_portfolio)
assert "유입 격리 보유" in src
i = src.index("유입 격리 보유")
assert "logger.warning" in src[max(0, i - 200):i]
def test_portfolio_response_exposes_strategy_type():
"""프론트가 격리분을 구분할 수 있어야 한다(필드 노출)."""
assert "strategy_type" in pf.PortfolioResponse.model_fields
def test_summary_exposes_external_aggregate():
src = inspect.getsource(pf.PortfolioCache.cache_summary)
assert '"external_count"' in src and '"external_value"' in src
def test_calculate_portfolio_values_counts_external_separately():
class _H:
def __init__(self, code, qty, price, st):
self.stock_code, self.quantity = code, qty
self.current_price, self.average_price = price, price
self.market, self.strategy_type = "KRX", st
vals = pf._calculate_portfolio_values([
_H("005930", 10, 70_000, "swing"),
_H("000100", 8, 50_000, "external"),
])
assert vals["external_count"] == 1
assert vals["external_value"] == pytest.approx(400_000)
# 총자산·평가액에서 빼지 않는다 — 계좌 전체 리스크는 유입분을 포함해야 한다
assert vals["domestic_value"] == pytest.approx(700_000 + 400_000)
# ── 스윙 청산 대상에서 자동 제외되는지 (코드 변경 없이) ────────────────────
def test_swing_holdings_query_filters_by_swing_only():
from app.services.swing import position_manager as pm
src = inspect.getsource(pm.get_swing_holdings)
assert "Portfolio.strategy_type == STRATEGY_TYPE" in src
assert pm.STRATEGY_TYPE == "swing"
# ── R2: 수동 청산의 연패 기록 ──────────────────────────────────────────────
def test_manual_sell_records_consecutive_loss():
src = inspect.getsource(pf.sell_stock)
assert "record_trade_result_safe" in src, "수동 청산이 연패 카운트에서 빠진다"
i = src.index("record_trade_result_safe")
assert "save_to_db" in src[i:i + 400], "record 후 save_to_db 누락 (§8.1-7)"
# 전량 청산 시에만 + external 제외
assert "quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE" in src
def test_manual_sell_v2_records_consecutive_loss():
from app.api.endpoints import portfolio_v2 as pv2
src = inspect.getsource(pv2.sell_stock_v2)
assert "record_trade_result_safe" in src
i = src.index("record_trade_result_safe")
assert "save_to_db" in src[i:i + 500]
assert 'original_strategy_type != "external"' in src
# ── R3: 스윙 손절 종목의 당일 DT 재진입 차단 ───────────────────────────────
# ── 손절선 이탈 가시화 (집행 없음) ─────────────────────────────────────────
class _H:
def __init__(self, code, qty, avg, cur, st="external", name=None):
self.stock_code, self.quantity = code, qty
self.average_price, self.current_price = avg, cur
self.strategy_type, self.stock_name = st, name or code
self.market = "KRX"
def test_external_breach_detected_beyond_base_stop():
from app.core.config import settings
_sl = abs(settings.STOP_LOSS_PERCENT)
rows = pf._external_stop_breaches([
_H("000100", 8, 10_000, 10_000 * (1 - (_sl + 3) / 100)), # 이탈
_H("009830", 5, 10_000, 10_000 * (1 - (_sl - 2) / 100)), # 미이탈
])
assert [r["stock_code"] for r in rows] == ["000100"]
assert rows[0]["profit_loss_rate"] == pytest.approx(-(_sl + 3), abs=0.01)
assert rows[0]["breach_pct"] < 0
def test_non_external_rows_are_not_reported():
"""스윙 행은 실제 손절 경로가 처리한다 — 이 경고는 격리분 전용."""
assert pf._external_stop_breaches([_H("005930", 10, 10_000, 1_000, st="swing")]) == []
def test_breach_ignores_missing_prices():
assert pf._external_stop_breaches([
_H("000100", 8, 0, 5_000),
_H("009830", 8, 10_000, 0),
_H("241560", 0, 10_000, 1_000),
]) == []
def test_sync_logs_breach_without_executing():
src = inspect.getsource(pf.sync_portfolio)
assert "_external_stop_breaches(" in src
i = src.index("유입 격리분 손절선 이탈")
assert "logger.warning" in src[max(0, i - 200):i], "§4: 이탈 경고는 debug 금지"
# 경고 블록 안에서 매도 주문을 내지 않는다
block = src[i:i + 600]
assert "safe_place_order" not in block and "place_order" not in block
def test_summary_exposes_breach_list():
src = inspect.getsource(pf.PortfolioCache.cache_summary)
assert '"external_stop_breaches"' in src
def _external_row():
from app.models.models import Portfolio
return Portfolio(
stock_code="000100", stock_name="유한양행", quantity=8,
average_price=10_000, current_price=10_000, market="KRX",
strategy_type=EXTERNAL_STRATEGY_TYPE,
)
@pytest.mark.asyncio
async def test_reflect_buy_promotes_external_row_on_swing_buy():
"""격리 행에 스윙 시스템 매수가 들어오면 승격 — 다음 sync 까지 무관리 방지."""
from unittest.mock import AsyncMock, patch
from app.services import portfolio_cache_bridge as bridge
row = _external_row()
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 == "swing"
assert _enq.await_args.args[0]["strategy_type"] == "swing"
@pytest.mark.asyncio
async def test_reflect_buy_keeps_external_on_daytrading_buy():
"""DT 매수는 격리 태그를 바꾸지 않는다(격리 수량이 external 표시에서 사라짐 방지)."""
from unittest.mock import AsyncMock, patch
from app.services import portfolio_cache_bridge as bridge
row = _external_row()
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", "유한양행", 3, 11_000, strategy_type="daytrading")
assert row.strategy_type == EXTERNAL_STRATEGY_TYPE
assert "strategy_type" not in _enq.await_args.args[0]
# ── MDD 강제 축소는 격리분도 대상 (계좌 생존 장치) ─────────────────────────
def test_mdd_reduction_does_not_exclude_external():
"""MDD -10%/-20% 축소는 계좌 전체 리스크 감축이므로 격리분도 매도 대상이다.
평시 전략 손절만 제외한다. `execute_reduction()` 은 KIS 잔고(output1)를 직접
순회하므로 strategy_type 필터가 없어야 축소 목표(30%/50%)를 채울 수 있다.
여기에 strategy_type 제외가 들어오면 -20% 국면에서 줄일 수 있는 포지션이
장부상 줄어들어 리스크 감축이 약해진다.
"""
from app.services import mdd_manager as mm
src = inspect.getsource(mm.MDDManager.execute_reduction)
assert 'balance.get("output1"' in src, "축소 대상 소스가 KIS 잔고가 아니다"
# 'strategy_type' 자체는 TradeHistory 태그("mdd_reduction")로 등장한다 —
# 금지 대상은 격리분 제외 필터다.
assert EXTERNAL_STRATEGY_TYPE not in src, "MDD 축소에서 격리분을 제외하고 있다"
assert "select(Portfolio" not in src, "축소 대상을 Portfolio(전략 태그) 기준으로 좁히고 있다"
def test_swing_full_exit_blocks_same_day_dt_reentry():
from app.services.swing import exit_service as ses
src = inspect.getsource(ses.check_and_execute_exit_v2)
assert "closed_today.add(stock_code)" in src, "전략 간 당일 쿨다운 누락"
i = src.index("closed_today.add(stock_code)")
assert "trade_lock" in src[max(0, i - 300):i], "closed_today 변경은 trade_lock 규약"

View File

@@ -136,9 +136,21 @@ def test_executor_sell_excludes_daytrading_shares():
def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1():
"""DT 태깅이 국내(신규·기존)·해외 4경로 모두에 적용되는지.
[2026-09-28] 유입(external) 격리 도입으로 **신규 행** 2곳의 판정이 인라인 비교에서
순수 함수 `_classify_holding_strategy_type`(= DT 전량 → daytrading, 시스템 매수
근거 없음 → external) 위임으로 바뀌었다. 인라인 비교는 기존 행 갱신부(국내·해외)
2곳에만 남는다. 불변식(4경로 전부 태깅 + `_dt_qty` 가 output1 블록보다 앞)은 동일.
"""
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sync_portfolio)
# 국내 신규/기존 행 + 해외 = 태깅 지점 4곳
assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 4
# 기존 행 갱신부(국내·해외) = 인라인 DT 비교 2곳
assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 2
# 신규 행(국내·해외) = 분류 함수 위임 2곳
assert src.count("_classify_strategy_type(stock_code, quantity)") == 2
# 분류 함수가 DT 전량 판정을 보유
pure_src = inspect.getsource(pf._classify_holding_strategy_type)
assert 'return "daytrading"' in pure_src and "quantity <= _dt_held" in pure_src
# 정의가 output1 블록보다 앞이어야 NameError 가 없다
assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data')

View File

@@ -0,0 +1,109 @@
"""종목당 4% 절대 상한(MAX_POSITION_SIZE) 회귀 테스트 — CLAUDE.md §5.1 / §4.
배경(2026-09-27): 패턴메모리 조정 뒤의 상한 재클램프가 `try` 블록 **안**에 있어
`get_pattern_adjusted_invest_amount()` 가 예외를 던지면 클램프를 건너뛰고
적응형 포지션(6~7%)이 그대로 주문 금액이 됐다. §4 는 "리스크 가드·한도 클램프를
`try` 안에 두지 말 것" 으로 이것을 규칙화했다.
여기서는 두 층을 검증한다.
1) 구조: `_execute_buy` 의 절대 상한 클램프가 패턴메모리 `try` **밖**에 있다
(예외 발생 케이스를 실제 주문 경로 없이 확정적으로 잡는다).
2) 수치: 적응형 Normal(6%)·Aggressive(7%) 산출액이 클램프 후 총자산 대비
MAX_POSITION_SIZE 를 넘지 않는다.
기대값은 전부 `settings.*` 참조다(§4.1 — config 값 하드코딩 금지).
"""
import ast
import inspect
import pytest
from app.core.config import settings
from app.services import trade_executor
from app.services.sizing_core import clamp_by_ratios
# ── 1) 구조 검증 ────────────────────────────────────────────────────────────
def _execute_buy_ast() -> ast.AST:
src = inspect.getsource(trade_executor._execute_buy)
return ast.parse(ast.unparse(ast.parse(_dedent(src))))
def _dedent(src: str) -> str:
import textwrap
return textwrap.dedent(src)
def _contains_call(node: ast.AST, name: str) -> bool:
return any(
isinstance(n, ast.Call)
and (
(isinstance(n.func, ast.Name) and n.func.id == name)
or (isinstance(n.func, ast.Attribute) and n.func.attr == name)
)
for n in ast.walk(node)
)
def _mentions_max_position_size(node: ast.AST) -> bool:
return any(
isinstance(n, ast.Attribute) and n.attr == "MAX_POSITION_SIZE"
for n in ast.walk(node)
)
def test_pattern_memory_clamp_is_outside_try():
"""패턴메모리 호출은 try 안, 절대 상한 클램프는 그 try 뒤의 문장이어야 한다."""
tree = _execute_buy_ast()
fn = tree.body[0]
assert isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef))
body = fn.body
pm_try_idx = None
for idx, stmt in enumerate(body):
if isinstance(stmt, ast.Try) and _contains_call(stmt, "get_pattern_adjusted_invest_amount"):
pm_try_idx = idx
break
assert pm_try_idx is not None, "패턴메모리 조정 try 블록을 찾지 못했다"
# try 블록 안에는 절대 상한 클램프가 없어야 한다
assert not (
_contains_call(body[pm_try_idx], "clamp_by_ratios")
and _mentions_max_position_size(body[pm_try_idx])
), "절대 상한 클램프가 패턴메모리 try 안에 있다 (예외 시 가드가 통과된다)"
# try 이후 문장 중에 MAX_POSITION_SIZE 클램프가 있어야 한다
after = body[pm_try_idx + 1:]
assert any(
_contains_call(s, "clamp_by_ratios") and _mentions_max_position_size(s)
for s in after
), "패턴메모리 try 뒤에 MAX_POSITION_SIZE 재클램프가 없다"
def test_adaptive_position_cap_is_clamped_at_source():
"""적응형 position_size 를 소비할 때 min(..., MAX_POSITION_SIZE) 가 적용된다."""
src = inspect.getsource(trade_executor._execute_buy)
assert "min(_adp.position_size, settings.MAX_POSITION_SIZE)" in src
# ── 2) 수치 검증 ────────────────────────────────────────────────────────────
TOTAL_ASSETS = 10_000_000.0
AVAILABLE_CASH = 10_000_000.0
@pytest.mark.parametrize("mode_position_size", [0.06, 0.07]) # Normal / Aggressive
def test_adaptive_modes_never_exceed_hard_cap(mode_position_size):
"""적응형 6~7% 예산도 최종 클램프 후에는 MAX_POSITION_SIZE 이하."""
adaptive_cap = min(mode_position_size, settings.MAX_POSITION_SIZE)
raw = TOTAL_ASSETS * adaptive_cap
final = clamp_by_ratios(raw, TOTAL_ASSETS, [settings.MAX_POSITION_SIZE], AVAILABLE_CASH)
assert final / TOTAL_ASSETS <= settings.MAX_POSITION_SIZE + 1e-9
def test_pattern_memory_uplift_is_clamped():
"""패턴메모리가 +30% 상향해도 최종 클램프가 상한을 지킨다."""
raw = TOTAL_ASSETS * settings.MAX_POSITION_SIZE * 1.3
final = clamp_by_ratios(raw, TOTAL_ASSETS, [settings.MAX_POSITION_SIZE], AVAILABLE_CASH)
assert final / TOTAL_ASSETS <= settings.MAX_POSITION_SIZE + 1e-9

File diff suppressed because it is too large Load Diff