fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
203
backend/tests/conftest.py
Normal file
203
backend/tests/conftest.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""유닛 테스트 계층 공용 안전망 — 실제 KIS 호출·라이브 DB를 구조적으로 차단한다.
|
||||
|
||||
## 왜 필요한가
|
||||
파일별 방어(각 테스트가 자기 목을 다는 방식)는 두 번 실패했다.
|
||||
|
||||
1. `test_daytrading_stale_position_guard.py` — 리팩터링으로 `close_stale_positions()`가
|
||||
`_execute_exit` → `_execute_exit_stale` 로 바뀌자 목이 빗나가 **실제 KIS 매도 주문이
|
||||
2회 전송**됐다.
|
||||
2. `test_daytrading_orb_watchlist_and_capital_split.py` — `get_balance`만 목킹하고
|
||||
`get_buyable_cash()`는 빠뜨려 `_get_access_token → _request_new_token` 으로 내려갔고,
|
||||
`korea_investment_api._handle_rate_limit()`의 "토큰 발급 1분 제한: 69.8초 대기"
|
||||
(`asyncio.sleep`)에 걸려 테스트 하나가 140초를 소비했다. 소켓을 막아도 예외가
|
||||
재시도 로직에 삼켜진 뒤 잠들기 때문에 느려지는 것은 그대로다.
|
||||
|
||||
프로젝트 메모리에는 멀티테넌트 1단계 작업 중 라이브 경로 호출로 **모의투자 1주 오발주**
|
||||
사고가 기록돼 있다. 규약은 "격리 Postgres(`_test` 접미사) + KIS mock"인데 유닛 계층에는
|
||||
`conftest.py`가 없어 강제 수단이 전혀 없었다.
|
||||
|
||||
## 설계 원칙
|
||||
- **그럴듯한 값을 반환하지 않는다.** 모든 차단 대상은 호출되는 즉시 예외를 던진다.
|
||||
조용히 성공하는 스텁은 목킹 누락을 감추고 테스트를 거짓 통과시킨다.
|
||||
- 예외는 `Exception`이 아니라 **`BaseException` 파생**(`KISCallBlocked`)이다.
|
||||
프로덕션 코드 곳곳의 `except Exception:` 재시도/폴백 블록이 삼켜버리면 차단이 무력화되고
|
||||
(위 사례 2처럼) 오히려 느려지기만 한다. `BaseException`이면 통과해서 테스트를 실패시킨다.
|
||||
- **차단은 클래스 메서드에 적용**한다(`KoreaInvestmentAPI.<method>`). 인스턴스가 아니라
|
||||
클래스를 막아야 `from ... import korea_investment_api`로 싱글톤 참조를 미리 캡처한
|
||||
모듈(예: `daytrading/entry_service`, `api/endpoints/portfolio`)이나 멀티테넌트에서 새로
|
||||
만드는 인스턴스까지 전부 덮인다. 개별 테스트의 `patch.object(instance, ...)`는 인스턴스
|
||||
속성으로 클래스 속성을 가리므로 **테스트 쪽 목이 항상 이긴다**(autouse 픽스처가 먼저
|
||||
적용되고 테스트 내 patch가 나중에 적용되는 순서).
|
||||
- **시세·잔고 계열도 차단**한다. 전역 기본 스텁을 주면 기존 테스트의 기대값이 조용히
|
||||
바뀌므로, 값을 주는 대신 막고 각 테스트가 명시적으로 덮게 한다.
|
||||
- `tests/integration/`은 제외한다(격리 컨테이너 Postgres + 자체 목 규약, 별도 conftest).
|
||||
|
||||
## 사용법
|
||||
cd backend
|
||||
DATABASE_URL="postgresql+asyncpg://u:p@127.0.0.1:5432/autostock_test" \
|
||||
python -m pytest tests/ --ignore=tests/integration
|
||||
|
||||
`--allow-network` 를 주면 KIS 차단을 끈다(실 연동 점검 전용, 기본 꺼짐).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 차단 예외 — `except Exception:` 에 삼켜지지 않도록 BaseException 파생
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
class KISCallBlocked(BaseException):
|
||||
"""테스트 중 실제 KIS/주문 경로가 호출됨 — 해당 테스트의 목킹이 누락됐다는 뜻."""
|
||||
|
||||
|
||||
def _blocked(dotted_name: str):
|
||||
"""호출 즉시 실패하는 async 스텁 — 값을 반환하지 않는다(거짓 통과 방지)."""
|
||||
|
||||
async def _boom(*args, **kwargs):
|
||||
raise KISCallBlocked(
|
||||
f"[tests/conftest.py] 실호출 차단: {dotted_name}() 가 목킹되지 않은 채 호출됐습니다. "
|
||||
f"해당 테스트에서 patch.object(...)로 명시적으로 덮으십시오. "
|
||||
f"(실 연동 점검이라면 tests/integration/ 으로 옮기거나 --allow-network 사용)"
|
||||
)
|
||||
|
||||
_boom.__name__ = dotted_name.rsplit(".", 1)[-1]
|
||||
_boom.__qualname__ = dotted_name
|
||||
return _boom
|
||||
|
||||
|
||||
# 차단 대상 — 추측이 아니라 코드에서 정의 위치를 확인한 목록 (2026-09-27 기준).
|
||||
#
|
||||
# app/services/korea_investment_api.py
|
||||
# 174 class KoreaInvestmentAPI
|
||||
# 410 async def _request_new_token
|
||||
# 461 async def _get_access_token
|
||||
# 632 async def get_current_price
|
||||
# 652 async def get_daily_chart
|
||||
# 801 async def get_overseas_price
|
||||
# 822 async def get_minute_chart
|
||||
# 904 async def get_minute_chart_full_day
|
||||
# 1001 async def get_overseas_daily_chart
|
||||
# 1044 async def place_overseas_order
|
||||
# 1087 async def get_balance
|
||||
# 1164 async def get_buyable_cash
|
||||
# 1202 async def get_overseas_balance
|
||||
# 1234 async def place_order
|
||||
# app/services/trade_executor.py
|
||||
# 260 async def safe_place_order
|
||||
# 334 async def safe_place_order_v2
|
||||
_KIS_CLASS_METHODS = (
|
||||
# ── 주문 계열 (실발주 위험) ──
|
||||
"place_order",
|
||||
"place_overseas_order",
|
||||
# ── 토큰 계열 (rate-limit sleep 70초의 근원) ──
|
||||
"_request_new_token",
|
||||
"_get_access_token",
|
||||
# ── 시세·잔고 계열 ──
|
||||
"get_current_price",
|
||||
"get_daily_chart",
|
||||
"get_overseas_price",
|
||||
"get_overseas_daily_chart",
|
||||
"get_minute_chart",
|
||||
"get_minute_chart_full_day",
|
||||
"get_balance",
|
||||
"get_buyable_cash",
|
||||
"get_overseas_balance",
|
||||
)
|
||||
|
||||
_TRADE_EXECUTOR_FUNCS = (
|
||||
"safe_place_order",
|
||||
"safe_place_order_v2",
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# pytest 훅
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--allow-network",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="KIS 실호출 차단을 해제한다 (실 연동 점검 전용 — 라이브 계좌 주문 위험).",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""라이브 DB 가드 — DB 이름이 `_test`로 끝나지 않으면 세션 자체를 중단.
|
||||
|
||||
`tests/integration/conftest.py` 도 동일한 가드를 import 시점에 수행한다. 여기서는
|
||||
유닛 계층까지 포함해 세션 전체에 적용하되, 판정 기준(`settings.DATABASE_URL`의 DB 이름)이
|
||||
같으므로 integration 단독 실행의 기존 동작은 바뀌지 않는다. env만 보던 방식은
|
||||
env 미설정 시 `settings.DATABASE_URL` 기본값(라이브 가능성)으로 조용히 새는 결함이 있어
|
||||
`settings`를 기준으로 본다.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
db_url = settings.DATABASE_URL or os.environ.get("DATABASE_URL") or ""
|
||||
db_name = db_url.rsplit("/", 1)[-1].split("?", 1)[0]
|
||||
if not db_name.endswith("_test"):
|
||||
pytest.exit(
|
||||
"settings.DATABASE_URL의 DB 이름이 '_test'로 끝나지 않습니다"
|
||||
f"(현재: {db_name!r}). 테스트는 반드시 격리 DB(예: autostock_test)를 대상으로 "
|
||||
"실행해야 합니다. 예:\n"
|
||||
' DATABASE_URL="postgresql+asyncpg://u:p@127.0.0.1:5432/autostock_test" '
|
||||
"python -m pytest tests/ --ignore=tests/integration",
|
||||
returncode=1,
|
||||
)
|
||||
|
||||
|
||||
def _is_integration(request) -> bool:
|
||||
"""`tests/integration/` 하위이거나 `integration` 마커가 붙은 테스트인가."""
|
||||
try:
|
||||
path = str(request.node.fspath)
|
||||
except Exception: # pragma: no cover - fspath 미지원 노드 방어
|
||||
path = ""
|
||||
if f"{os.sep}integration{os.sep}" in path:
|
||||
return True
|
||||
return request.node.get_closest_marker("integration") is not None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _block_live_kis(request, monkeypatch):
|
||||
"""모든 유닛 테스트에 자동 적용 — KIS 주문/토큰/시세/잔고 경로를 호출 즉시 실패로 치환.
|
||||
|
||||
적용 순서상 이 픽스처가 먼저 클래스 속성을 덮고, 테스트 본문의
|
||||
`patch.object(instance, ...)` / `patch("...module.attr", ...)` 가 나중에 적용되므로
|
||||
개별 테스트의 명시적 목이 항상 우선한다.
|
||||
"""
|
||||
if request.config.getoption("--allow-network"):
|
||||
return
|
||||
if _is_integration(request):
|
||||
return
|
||||
|
||||
from app.services import korea_investment_api as kis_mod
|
||||
|
||||
for name in _KIS_CLASS_METHODS:
|
||||
if not hasattr(kis_mod.KoreaInvestmentAPI, name):
|
||||
raise AssertionError(
|
||||
f"tests/conftest.py 차단 목록이 코드와 어긋납니다: "
|
||||
f"KoreaInvestmentAPI.{name} 가 존재하지 않습니다(이름 변경/삭제?)."
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
kis_mod.KoreaInvestmentAPI,
|
||||
name,
|
||||
_blocked(f"korea_investment_api.{name}"),
|
||||
raising=True,
|
||||
)
|
||||
|
||||
from app.services import trade_executor as te_mod
|
||||
|
||||
for name in _TRADE_EXECUTOR_FUNCS:
|
||||
if not hasattr(te_mod, name):
|
||||
raise AssertionError(
|
||||
f"tests/conftest.py 차단 목록이 코드와 어긋납니다: "
|
||||
f"trade_executor.{name} 가 존재하지 않습니다(이름 변경/삭제?)."
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
te_mod, name, _blocked(f"trade_executor.{name}"), raising=True
|
||||
)
|
||||
@@ -225,10 +225,14 @@ class TestFallback:
|
||||
assert params.fallback_used is True
|
||||
|
||||
def test_fallback_uses_config_values(self):
|
||||
from app.core.config import settings
|
||||
|
||||
params = _get_fallback_params()
|
||||
# Should match config.py defaults (MAX_POSITION_SIZE=0.06)
|
||||
assert params.position_size == 0.06
|
||||
assert params.atr_stop_multiplier == 2.5
|
||||
# fallback은 하드코딩이 아니라 settings 값을 그대로 읽어야 한다.
|
||||
# config.py 기본값이 바뀌어도(예: MAX_POSITION_SIZE 0.06→0.04) 이 테스트가
|
||||
# 함께 갱신되도록 settings를 직접 참조한다.
|
||||
assert params.position_size == settings.MAX_POSITION_SIZE
|
||||
assert params.atr_stop_multiplier == settings.ATR_STOP_MULTIPLIER
|
||||
|
||||
def test_get_current_without_init(self):
|
||||
"""초기화 없이 호출하면 fallback을 반환해야 한다."""
|
||||
|
||||
@@ -55,8 +55,16 @@ def test_limited_by_available_cash_and_reserve():
|
||||
|
||||
|
||||
def test_limited_by_swing_capital_cap():
|
||||
# 스윙 상한 50% = 500만, 이미 490만 보유 → 잔여 10만
|
||||
portfolio_cache.summary = _summary(stock_value=4_900_000)
|
||||
# 스윙 자본 상한이 구속 조건이 되는 경우를 검증한다.
|
||||
# [2026-09-27] 기존에는 stock_value=4_900_000 을 하드코딩해 "상한 50% = 500만" 을
|
||||
# 전제했으나, SWING_MAX_CAPITAL_RATIO 가 0.50→0.70 으로 바뀌면서(2026-09-23)
|
||||
# 상한이 700만이 되어 종목당 6% 캡(60만)이 먼저 걸렸다 → 8주가 나와 실패.
|
||||
# config 값이 또 바뀌어도 의도가 유지되도록 상한에서 역산한다(잔여 10만 → 1주).
|
||||
from app.core.config import settings
|
||||
|
||||
_total = 10_000_000
|
||||
_held = int(_total * settings.SWING_MAX_CAPITAL_RATIO) - 100_000
|
||||
portfolio_cache.summary = _summary(total_assets=_total, stock_value=_held)
|
||||
with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()):
|
||||
out = _suggest_buy_amount(70_000, "BUY", 0.7, "005930")
|
||||
assert out["suggested_quantity"] == 1
|
||||
|
||||
@@ -139,6 +139,41 @@ from app.models.models import Portfolio, TradeHistory
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture(autouse=True)
|
||||
def bind_mock_kis_to_captured_refs():
|
||||
"""`_mock_kis`를 이미 실제 싱글톤을 캡처한 모듈들에까지 강제 바인딩.
|
||||
|
||||
파일 상단의 `patch("app.services.korea_investment_api.korea_investment_api", _mock_kis)`는
|
||||
**이 모듈이 가장 먼저 import될 때만** 통한다. 전체 스위트 실행에서는 알파벳 순으로 앞선
|
||||
테스트 모듈(`test_adaptive_strategy` 등)이 이미 `app.main`을 import해
|
||||
`app/api/endpoints/portfolio.py:20` 의 `from app.services.korea_investment_api import
|
||||
korea_investment_api` 가 **실제 싱글톤을 자기 모듈 네임스페이스에 캡처**해 둔 상태다.
|
||||
그래서 소스 모듈 속성만 갈아끼운 상단 patch가 새고, `/api/v1/portfolio/summary` 가
|
||||
라이브 `get_balance()` → `_get_access_token()` → `_handle_rate_limit()` 의 70초
|
||||
`asyncio.sleep` 까지 내려갔다.
|
||||
(2026-09-27 측정: 단독 실행 2.9초 / 전체 스위트에서 이 파일 2건이 71초·142초 + 모니터링 5건 실패)
|
||||
|
||||
`tests/conftest.py` 의 autouse 차단 픽스처가 클래스 메서드를 "호출 시 실패"로 덮으므로,
|
||||
복구도 **픽스처 단계**에서 해야 한다(부모 conftest autouse → 모듈 autouse 순서이므로
|
||||
이쪽이 나중에 적용되어 이긴다).
|
||||
"""
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
|
||||
patches = [
|
||||
patch.object(mod, "korea_investment_api", _mock_kis)
|
||||
for name, mod in list(sys.modules.items())
|
||||
if name.startswith("app.")
|
||||
and isinstance(getattr(mod, "korea_investment_api", None), KoreaInvestmentAPI)
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def setup_database():
|
||||
"""각 테스트 전에 테이블 생성, 테스트 후 모든 데이터 정리"""
|
||||
@@ -159,25 +194,39 @@ async def db_session():
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
"""httpx AsyncClient (ASGITransport 기반)
|
||||
"""httpx AsyncClient (ASGITransport 기반) — 실제 X-API-Key 헤더로 인증.
|
||||
|
||||
require_api_key 의존성을 테스트 전용 noop 함수로 대체하여
|
||||
.env SECRET_KEY 값에 무관하게 인증을 통과시킴.
|
||||
[2026-09-27 수정] 선재 결함: 이 픽스처는 `require_api_key` 의존성만
|
||||
noop("test-key" 반환)으로 오버라이드했다. 그런데 portfolio/trades 라우터에는
|
||||
멀티테넌트 레이어(`app/api/__init__.py` → `Depends(deny_non_owner_session_when_mt)`)가
|
||||
추가돼 있고, 그 체인은 `get_current_user`(app/core/tenant_auth.py)에서
|
||||
require_api_key 의 **반환값**을 API 키 또는 세션 토큰으로 다시 해석한다.
|
||||
"test-key" 는 둘 다 아니므로 사용자 해석 실패 → 401 (20건 실패의 단일 원인).
|
||||
|
||||
→ 인증을 무력화하지 않고, 실제 운영과 동일하게 X-API-Key 헤더에 유효한 키를
|
||||
실어 보낸다(`_get_api_key()` = API_KEY 우선, 없으면 SECRET_KEY 폴백).
|
||||
이렇게 하면 require_api_key → get_current_user(API 키 호출자 = legacy owner)
|
||||
→ deny_non_owner_session_when_mt(MULTI_TENANT_ENABLED=False 이므로 통과)
|
||||
전체 체인이 실제로 실행된다. 의존성 오버라이드는 get_db 뿐.
|
||||
"""
|
||||
from app.core.security import require_api_key
|
||||
from app.core.security import _get_api_key
|
||||
|
||||
async def _bypass_api_key():
|
||||
return "test-key"
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers={"X-API-Key": _get_api_key()},
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
app.dependency_overrides[require_api_key] = _bypass_api_key
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def unauthenticated_client():
|
||||
"""인증 헤더가 없는 클라이언트 — require_api_key 가 실제로 걸려 있는지 검증용."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
# 테스트 완료 후 오버라이드 제거
|
||||
app.dependency_overrides.pop(require_api_key, None)
|
||||
|
||||
|
||||
def _reset_portfolio_cache(**overrides):
|
||||
"""포트폴리오 캐시를 테스트에 맞게 초기화하는 헬퍼"""
|
||||
@@ -706,3 +755,40 @@ class TestTradeHistoryEndpoints:
|
||||
response2 = await client.get("/api/v1/trades", params={"status": "FAILED"})
|
||||
assert response2.status_code == 200
|
||||
assert len(response2.json()) == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. 인증 강제 테스트 (require_api_key 가 무력화되지 않았음을 보장)
|
||||
# ===========================================================================
|
||||
class TestApiKeyEnforcement:
|
||||
"""[2026-09-27 추가] client 픽스처가 헤더 인증 방식으로 바뀌었으므로,
|
||||
인증 자체가 살아 있는지를 별도로 고정한다. 과거처럼 require_api_key 를
|
||||
dependency_overrides 로 우회하면 이 테스트가 먼저 깨진다.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path",
|
||||
[
|
||||
("get", "/api/v1/portfolio"),
|
||||
("get", "/api/v1/portfolio/summary"),
|
||||
("get", "/api/v1/trades"),
|
||||
("get", "/api/v1/trades/stats/summary"),
|
||||
("delete", "/api/v1/trades/pending"),
|
||||
("get", "/api/v1/monitoring/health"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_api_key(
|
||||
self, unauthenticated_client: httpx.AsyncClient, method: str, path: str
|
||||
):
|
||||
"""X-API-Key 헤더가 없으면 401"""
|
||||
response = await getattr(unauthenticated_client, method)(path)
|
||||
assert response.status_code == 401, f"{method.upper()} {path} 가 인증 없이 통과했다"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_api_key(self, unauthenticated_client: httpx.AsyncClient):
|
||||
"""잘못된 키도 401 (세션 토큰으로도 해석 불가)"""
|
||||
response = await unauthenticated_client.get(
|
||||
"/api/v1/trades", headers={"X-API-Key": "not-a-valid-key"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -41,28 +41,58 @@ async def test_dt_full_exit_does_not_zero_swing_shares():
|
||||
assert q.await_args.args[0]["quantity"] == 8
|
||||
|
||||
|
||||
def test_lstm_checkpoint_roundtrip_with_weights_only():
|
||||
"""LSTM 체크포인트가 weights_only=True 로 로드 가능해야 한다(앙상블 무력화 방지)."""
|
||||
def test_lstm_checkpoint_roundtrip_with_weights_only(tmp_path: Path):
|
||||
"""LSTM 체크포인트가 weights_only=True 로 로드 가능해야 한다(앙상블 무력화 방지).
|
||||
|
||||
[2026-09-27 수정] 선재 결함: 이 테스트는 `ml_ensemble.LSTMPredictor` 를 임포트했는데
|
||||
그 이름은 git 이력에 존재한 적이 없다(ImportError 로 항상 실패 = 이슈5 불변식이
|
||||
사실상 미검증 상태였음). 실제 래퍼 클래스는 `ml_ensemble.StockLSTM`
|
||||
(app/services/ml_ensemble.py:48, save():163 / load():185).
|
||||
|
||||
불변식은 그대로 유지·강화한다:
|
||||
1) save() 산출물이 `torch.load(..., weights_only=True)` 로 **그대로** 읽혀야 한다
|
||||
(numpy 배열 저장 금지 → list 로 저장, ml_ensemble.py:165 주석).
|
||||
2) load() 는 그 체크포인트를 복원하고 정규화 파라미터를 ndarray 로 되돌려야 한다.
|
||||
|
||||
tmp_path 를 쓰는 것이 중요하다: load() 는 MODEL_DIR 내부 파일에 대해서만
|
||||
`weights_only=False` 폴백을 허용하므로(ml_ensemble.py:194-206), MODEL_DIR 밖에서
|
||||
검증해야 폴백에 가려지지 않고 순수 weights_only 경로가 증명된다.
|
||||
"""
|
||||
torch = pytest.importorskip("torch")
|
||||
from app.services.ml_ensemble import LSTMPredictor
|
||||
from app.services.ml_ensemble import MODEL_DIR, StockLSTM
|
||||
|
||||
p = LSTMPredictor(input_size=4)
|
||||
if p.model is None:
|
||||
m = StockLSTM(input_size=4)
|
||||
if m.model is None:
|
||||
pytest.skip("torch 모델 초기화 불가")
|
||||
p.seq_mean = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
|
||||
p.seq_std = np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
|
||||
m.seq_mean = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
|
||||
m.seq_std = np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32)
|
||||
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / "lstm.pt"
|
||||
p.save(path)
|
||||
ckpt = torch.load(path, map_location="cpu", weights_only=True) # 여기서 예외 없어야 함
|
||||
assert ckpt["seq_mean"] == [1.0, 2.0, 3.0, 4.0]
|
||||
path = tmp_path / "lstm.pt"
|
||||
# MODEL_DIR 밖이어야 load() 의 weights_only=False 폴백이 적용되지 않는다
|
||||
assert not path.resolve().is_relative_to(MODEL_DIR.resolve())
|
||||
|
||||
p2 = LSTMPredictor(input_size=4)
|
||||
assert p2.load(path) is True
|
||||
assert isinstance(p2.seq_mean, np.ndarray)
|
||||
np.testing.assert_allclose(p2.seq_mean, p.seq_mean)
|
||||
m.save(path)
|
||||
assert path.exists(), "save() 가 체크포인트를 만들지 않았다"
|
||||
|
||||
# (1) 폴백 없이 weights_only=True 로 바로 읽혀야 한다
|
||||
ckpt = torch.load(path, map_location="cpu", weights_only=True)
|
||||
assert ckpt["seq_mean"] == [1.0, 2.0, 3.0, 4.0]
|
||||
assert ckpt["seq_std"] == [1.0, 1.0, 1.0, 1.0]
|
||||
assert isinstance(ckpt["seq_mean"], list), "numpy 배열 저장 시 weights_only 로드가 거부된다"
|
||||
assert ckpt["config"]["input_size"] == 4
|
||||
|
||||
# (2) load() 왕복
|
||||
m2 = StockLSTM(input_size=4)
|
||||
assert m2.load(path) is True
|
||||
assert isinstance(m2.seq_mean, np.ndarray)
|
||||
np.testing.assert_allclose(m2.seq_mean, m.seq_mean)
|
||||
np.testing.assert_allclose(m2.seq_std, m.seq_std)
|
||||
|
||||
# (3) 가중치도 동일하게 복원됐는지 (state_dict 왕복 확인)
|
||||
sd1, sd2 = m.model.state_dict(), m2.model.state_dict()
|
||||
assert sd1.keys() == sd2.keys()
|
||||
for k in sd1:
|
||||
assert torch.allclose(sd1[k].cpu(), sd2[k].cpu()), f"가중치 복원 불일치: {k}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -157,13 +187,43 @@ def test_active_trade_patch_supports_quantity():
|
||||
|
||||
|
||||
def test_sync_seeds_active_trade_for_external_positions():
|
||||
"""외부 유입 보유 종목(ActiveTrade 없음)은 동기화 시 자동 등록되어야 손절 관리를 받는다."""
|
||||
"""외부 유입 보유 종목(ActiveTrade 없음)은 동기화 시 자동 등록되어야 손절 관리를 받는다.
|
||||
|
||||
과거 버그(2026-09-22 카카오뱅크 -13%): 다른 인스턴스·증권사 앱에서 매수된
|
||||
포지션은 ActiveTrade 행이 없어 손절·분할익절·트레일링 관리를 전혀 받지 못했고,
|
||||
잔량 추적이 불가해 부분매도가 반복되거나 차단됐다. → sync 시 시딩이 불변식.
|
||||
|
||||
[2026-09-27] 시딩 구현이 `db.add(ActiveTrade(entry_price=Decimal(str(_avg))))`
|
||||
직접 쓰기 → **db_worker 큐 경유**(`enqueue_active_trade_upsert`)로 바뀌었다
|
||||
(CLAUDE.md §3: sync_portfolio 직접 쓰기 예외는 Portfolio 까지만).
|
||||
payload 는 DLQ JSON 직렬화 때문에 Decimal 이 아닌 순수 float/int 를 담고,
|
||||
`_seed_payloads` 에 모은 뒤 commit 성공 후에 큐잉한다.
|
||||
따라서 검사 대상을 'Decimal 생성자 리터럴' → '큐 경유 + payload 필수 키 +
|
||||
commit 이후 큐잉' 으로 갱신했다(불변식에 더 충실). 다음 사람은 문자열만 보고
|
||||
고치지 말고 시딩 동작이 유지되는지를 확인할 것.
|
||||
"""
|
||||
import inspect
|
||||
import re
|
||||
from app.api.endpoints import portfolio as pf
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
assert "외부 유입 포지션 — 동기화 시 자동 등록" in src
|
||||
# 데이트레이딩 태깅 종목은 제외 (엔진이 당일 청산 대상으로 복원해버림)
|
||||
assert '(_h.strategy_type or "swing") == "daytrading"' in src
|
||||
# 평균단가를 진입가로, 손절가는 STOP_LOSS_PERCENT 기준
|
||||
# 손절가는 STOP_LOSS_PERCENT 기준
|
||||
assert "_cfg.STOP_LOSS_PERCENT" in src
|
||||
assert "entry_price=Decimal(str(_avg))" in src
|
||||
# ActiveTrade 쓰기는 db_worker 큐 경유 (직접 INSERT 부활 금지)
|
||||
assert "enqueue_active_trade_upsert" in src, "시딩 경로 소실 — 외부 유입 포지션 무관리"
|
||||
# 주석에 남은 이력 문구는 제외하고 실제 코드만 본다
|
||||
_code = "\n".join(l.split("#")[0] for l in src.splitlines())
|
||||
assert "db.add(ActiveTrade(" not in _code, "ActiveTrade 직접 INSERT 부활 (§3 위반)"
|
||||
# 시딩 payload: 진입가(=평균단가)·잔량·손절가 등 필수 키 + JSON 직렬화 가능 타입
|
||||
m = re.search(r"_seed_payload = \{(.*?)\n\s*\}", src, re.S)
|
||||
assert m, "시딩 payload 리터럴을 찾지 못함"
|
||||
body = m.group(1)
|
||||
for key in ("stock_code", "quantity", "entry_price", "stop_loss", "status", "remaining_pct"):
|
||||
assert f'"{key}"' in body, f"시딩 payload 에 {key} 누락"
|
||||
assert "_avg" in body, "진입가가 평균단가(_avg) 기준이 아님"
|
||||
assert "Decimal" not in body, "payload 는 순수 float/int (DLQ JSON 직렬화 불가)"
|
||||
# 큐잉은 commit 성공 후 — 롤백된 Portfolio 에 ActiveTrade 가 생기면 안 된다
|
||||
assert src.index("await db.commit()") < src.index("_enq_seed_at("), \
|
||||
"commit 전에 시딩 큐잉 — 롤백 시 유령 ActiveTrade"
|
||||
|
||||
@@ -61,7 +61,13 @@ async def test_populate_falls_back_to_latest_analysis_date():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_no_lunch_block_and_cutoff_from_settings():
|
||||
"""점심시간(12:00)에도 VWAP/RSI_BB 스캔이 수행되고, 마감은 설정값(15:00) 기준."""
|
||||
"""점심시간(12:00)에도 VWAP/RSI_BB 스캔이 수행되고, 마감은 설정값(15:00) 기준.
|
||||
|
||||
`scan_and_enter()`는 잔고 조회에서 `get_balance()`와 `get_buyable_cash()`를 **둘 다**
|
||||
호출한다(entry_service.py:344, 350). 후자를 목킹하지 않으면 실제 KIS 토큰 발급
|
||||
경로로 내려가 `_handle_rate_limit()`의 70초 `asyncio.sleep`에 걸리고(테스트 하나가
|
||||
140초), 예외는 `except Exception` 폴백에 삼켜져 통과해 버린다. 둘 다 반드시 목킹한다.
|
||||
"""
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
es.set_watchlist([{"code": "005930", "name": "삼성전자"}])
|
||||
captured = {}
|
||||
@@ -85,6 +91,7 @@ async def test_entry_no_lunch_block_and_cutoff_from_settings():
|
||||
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": "10000000", "prvs_rcdl_excc_amt": "5000000"}]})), \
|
||||
patch.object(es.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=5_000_000.0)), \
|
||||
patch.object(es, "_try_enter", fake_try_enter):
|
||||
captured.clear()
|
||||
return await es.scan_and_enter()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
장중이면 전일 포지션을 즉시 전량 시장가 청산해야 한다.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -12,6 +13,65 @@ from app.services.daytrading import exit_service as es
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
|
||||
|
||||
class LiveCallBlocked(AssertionError):
|
||||
"""테스트가 실제 외부 I/O(KIS/DB)를 호출하려 한 경우 — 목킹 누락 신호."""
|
||||
|
||||
|
||||
def _blocked(name):
|
||||
async def _boom(*a, **k):
|
||||
raise LiveCallBlocked(f"실호출 차단: {name} — 목킹이 누락되었습니다")
|
||||
return _boom
|
||||
|
||||
|
||||
class _EmptySession:
|
||||
"""AsyncSessionLocal 대체 — SELECT 결과가 빈 목록인 세션(네트워크 없음)."""
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def execute(self, *a, **k):
|
||||
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: []))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _block_live_io():
|
||||
"""KIS 실호출·DB 실접속 전면 차단.
|
||||
|
||||
close_stale_positions() 가 타는 외부 I/O 경로를 모두 막는다:
|
||||
- trade_executor.safe_place_order (exit_service._execute_exit_impl 내부 지연 임포트)
|
||||
- korea_investment_api.place_order / place_overseas_order / _get_access_token
|
||||
- korea_investment_api.get_current_price / get_balance / 분봉 조회
|
||||
- app.core.database.AsyncSessionLocal (ActiveTrade 조회)
|
||||
개별 테스트는 필요한 항목만 중첩 patch 로 정상 응답으로 덮어쓴다.
|
||||
"""
|
||||
import app.services.trade_executor as te
|
||||
|
||||
api = es.korea_investment_api
|
||||
patches = [
|
||||
patch.object(te, "safe_place_order", _blocked("safe_place_order")),
|
||||
patch.object(te, "safe_place_order_v2", _blocked("safe_place_order_v2")),
|
||||
patch.object(api, "place_order", _blocked("place_order")),
|
||||
patch.object(api, "place_overseas_order", _blocked("place_overseas_order")),
|
||||
patch.object(api, "_get_access_token", _blocked("_get_access_token")),
|
||||
patch.object(api, "_request_new_token", _blocked("_request_new_token")),
|
||||
patch.object(api, "get_current_price", _blocked("get_current_price")),
|
||||
patch.object(api, "get_balance", _blocked("get_balance")),
|
||||
patch.object(api, "get_minute_chart", _blocked("get_minute_chart")),
|
||||
patch.object(api, "get_minute_chart_full_day", _blocked("get_minute_chart_full_day")),
|
||||
patch("app.core.database.AsyncSessionLocal", _EmptySession),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
|
||||
|
||||
def _pos(entry_dt):
|
||||
return {
|
||||
"stock_name": "LG씨엔에스", "strategy": "day_trade", "entry_price": 70800.0,
|
||||
@@ -20,6 +80,14 @@ def _pos(entry_dt):
|
||||
}
|
||||
|
||||
|
||||
def _impl_call(mock) -> dict:
|
||||
"""_execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale) 호출 인자 → dict."""
|
||||
names = ["stock_code", "pos", "price", "reason", "sell_pct", "is_stale"]
|
||||
d = dict(zip(names, mock.await_args.args))
|
||||
d.update(mock.await_args.kwargs)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean():
|
||||
day_trading_state.positions = {}
|
||||
@@ -38,13 +106,14 @@ async def test_stale_position_closed_in_full():
|
||||
with patch.object(es, "is_market_open", return_value=True), \
|
||||
patch.object(es.korea_investment_api, "get_current_price",
|
||||
AsyncMock(return_value={"output": {"stck_prpr": "71200"}})), \
|
||||
patch.object(es, "_execute_exit", exec_mock):
|
||||
patch.object(es, "_execute_exit_impl", exec_mock):
|
||||
out = await es.close_stale_positions()
|
||||
|
||||
assert len(out) == 1
|
||||
kwargs = exec_mock.await_args.kwargs
|
||||
assert kwargs["sell_pct"] == 1.0 and "전일 미청산" in kwargs["reason"]
|
||||
assert exec_mock.await_args.args[2] == 71200.0 # 현재가로 매도
|
||||
call = _impl_call(exec_mock)
|
||||
assert call["sell_pct"] == 1.0 and "전일 미청산" in call["reason"]
|
||||
assert call["is_stale"] is True # 전일 손익은 당일 집계 제외
|
||||
assert call["price"] == 71200.0 # 현재가로 매도
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -52,7 +121,7 @@ async def test_today_position_untouched():
|
||||
day_trading_state.positions["064400"] = _pos(es._kst_now())
|
||||
exec_mock = AsyncMock()
|
||||
with patch.object(es, "is_market_open", return_value=True), \
|
||||
patch.object(es, "_execute_exit", exec_mock):
|
||||
patch.object(es, "_execute_exit_impl", exec_mock):
|
||||
assert await es.close_stale_positions() == []
|
||||
exec_mock.assert_not_awaited()
|
||||
|
||||
@@ -60,7 +129,6 @@ async def test_today_position_untouched():
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_position_found_from_db_after_midnight_reset():
|
||||
"""자정 reset 으로 인메모리가 비어도 DB(ActiveTrade)에서 전일 포지션을 찾아 청산한다."""
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime as _dt
|
||||
|
||||
day_trading_state.positions = {}
|
||||
@@ -84,12 +152,13 @@ async def test_stale_position_found_from_db_after_midnight_reset():
|
||||
patch("app.core.database.AsyncSessionLocal", _Sess), \
|
||||
patch.object(es.korea_investment_api, "get_current_price",
|
||||
AsyncMock(return_value={"output": {"stck_prpr": "70300"}})), \
|
||||
patch.object(es, "_execute_exit", exec_mock):
|
||||
patch.object(es, "_execute_exit_impl", exec_mock):
|
||||
out = await es.close_stale_positions()
|
||||
|
||||
assert len(out) == 1
|
||||
assert exec_mock.await_args.args[1]["quantity"] == 4
|
||||
assert exec_mock.await_args.kwargs["sell_pct"] == 1.0
|
||||
call = _impl_call(exec_mock)
|
||||
assert call["pos"]["quantity"] == 4
|
||||
assert call["sell_pct"] == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -97,7 +166,7 @@ async def test_no_action_when_market_closed():
|
||||
day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1))
|
||||
exec_mock = AsyncMock()
|
||||
with patch.object(es, "is_market_open", return_value=False), \
|
||||
patch.object(es, "_execute_exit", exec_mock):
|
||||
patch.object(es, "_execute_exit_impl", exec_mock):
|
||||
assert await es.close_stale_positions() == []
|
||||
exec_mock.assert_not_awaited()
|
||||
|
||||
@@ -109,10 +178,10 @@ async def test_falls_back_to_entry_price_when_quote_fails():
|
||||
with patch.object(es, "is_market_open", return_value=True), \
|
||||
patch.object(es.korea_investment_api, "get_current_price",
|
||||
AsyncMock(side_effect=RuntimeError("quote down"))), \
|
||||
patch.object(es, "_execute_exit", exec_mock):
|
||||
patch.object(es, "_execute_exit_impl", exec_mock):
|
||||
out = await es.close_stale_positions()
|
||||
assert len(out) == 1
|
||||
assert exec_mock.await_args.args[2] == 70800.0 # entry_price 폴백
|
||||
assert _impl_call(exec_mock)["price"] == 70800.0 # entry_price 폴백
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -37,6 +37,27 @@ _mock_strategy_cfg.get.side_effect = lambda key, default=None: {
|
||||
"MEAN_REVERSION_MULTIPLIER_WEAK": 1.00,
|
||||
}.get(key, default)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [2026-09-27] import 순서 의존 제거
|
||||
#
|
||||
# 아래 patch.dict("sys.modules", ...) 는 **이 파일이 먼저 import될 때만** 먹는다.
|
||||
# 전체 스위트에서는 알파벳 앞선 테스트들이 이미 app.services.mean_reversion_strategy 를
|
||||
# 로드해 두므로, 그 모듈은 **실제** strategy_cfg 를 전역에 바인딩한 상태가 된다.
|
||||
# 그러면 목이 새어 strategy_cfg.get() → None → settings.MEAN_REVERSION_RSI_THRESHOLD(46)
|
||||
# 이 쓰이고, RSI 임계 30 을 전제한 케이스(test_bull_rsi_too_high: rsi=45)가 깨졌다.
|
||||
# (단독 실행 74 passed / 전체 스위트 1 failed 로 나타난 순서 의존 버그)
|
||||
#
|
||||
# → 모듈 전역 참조를 **테스트 시점에** 덮는 autouse 픽스처로 바꿔, import 순서와 무관하게
|
||||
# 항상 같은 목이 쓰이도록 한다. 기대값은 하나도 바꾸지 않았다.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_mock_strategy_cfg():
|
||||
import app.services.mean_reversion_strategy as _m
|
||||
|
||||
with patch.object(_m, "strategy_cfg", _mock_strategy_cfg):
|
||||
yield
|
||||
|
||||
|
||||
with patch.dict("sys.modules", {
|
||||
"app.core.config": MagicMock(),
|
||||
"app.services.strategy_config_service": MagicMock(strategy_cfg=_mock_strategy_cfg),
|
||||
|
||||
@@ -1,39 +1,138 @@
|
||||
"""감사 P1 수정 검증: MDD 수량 반영 / 전략명 명시 / 국내 DT 태깅."""
|
||||
import inspect
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_mdd_reduction_reflects_quantities():
|
||||
"""MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다."""
|
||||
def _active_trade_payload_scopes(src: str) -> dict:
|
||||
"""`enqueue_active_trade_upsert(<변수>)` 로 넘기는 payload 변수별 '전략명 한정 키' 집합.
|
||||
|
||||
불변식: ActiveTrade upsert 는 반드시 대상 행을 전략명으로 한정해야 한다.
|
||||
- `strategy_name` : 전략명이 있는 행을 동등 매칭
|
||||
- `strategy_name_not` : 전략명이 NULL 인 legacy/동기화 유입 행을 비-DT 조건으로 겨냥
|
||||
둘 중 아무것도 없으면 db_worker 가 '가장 최근 active 행'을 잡아 같은 종목의
|
||||
데이트레이딩 행을 덮어쓴다(= 이 가드가 막으려는 과거 버그).
|
||||
|
||||
[2026-09-27] 구현이 dict 리터럴(`{"strategy_name": x, ...}`) → 변수 조립
|
||||
(`payload["strategy_name"] = x`) 로 바뀌면서 리터럴 문자열 매칭이 깨졌다.
|
||||
변수명·조립 순서에 의존하지 않도록 payload 변수 단위로 키를 수집한다.
|
||||
"""
|
||||
scopes: dict = {}
|
||||
for var in re.findall(r"enqueue_active_trade_upsert\(\s*(_\w+)\s*\)", src):
|
||||
scopes[var] = set(
|
||||
re.findall(
|
||||
rf'{re.escape(var)}\[\s*"(strategy_name|strategy_name_not)"\s*\]\s*=',
|
||||
src,
|
||||
)
|
||||
) | set(
|
||||
# dict 리터럴/update() 로 넣는 경우도 함께 인정
|
||||
re.findall(
|
||||
rf'{re.escape(var)}(?:\s*=|\.update\()\s*\{{[^}}]*?"(strategy_name|strategy_name_not)"\s*:',
|
||||
src,
|
||||
re.S,
|
||||
)
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mdd_reduction_reflects_quantities():
|
||||
"""MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다.
|
||||
|
||||
과거 버그(P1 감사): 축소 매도가 체결됐는데 ActiveTrade.quantity 가 축소 전
|
||||
값으로 남아, 다음 청산이 실제 보유량보다 많이 매도했다(과다매도 → 주문 거부
|
||||
또는 미보유 매도). 그래서 '잔량이 payload 에 실리는지' 를 가드한다.
|
||||
|
||||
[2026-09-27] 구현이 dict 리터럴 `{"quantity": remaining_qty}` →
|
||||
`_sync_active_trade_after_reduction` 내부 헬퍼(`_upsert`/`_upsert_non_dt`)의
|
||||
payload 조립(`"quantity": qty`)으로 바뀌어 문자열 매칭이 깨졌다. 동작은 동일.
|
||||
문자열 대신 **실제로 큐에 실리는 payload** 를 캡처해 검증하도록 갱신 —
|
||||
리팩터링에는 견고하고, 잔량 반영이 빠지면 여전히 실패한다.
|
||||
"""
|
||||
from app.services import mdd_manager as m
|
||||
|
||||
captured: list = []
|
||||
|
||||
async def _cap(payload):
|
||||
captured.append(payload)
|
||||
|
||||
# (1) DT 병존 없음 → 최신 active 행 1건에 잔량·배율 반영
|
||||
with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \
|
||||
patch.object(m, "_dt_position_qty", lambda _code: 0):
|
||||
await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7)
|
||||
assert len(captured) == 1, "축소 반영 payload 가 큐잉되지 않음"
|
||||
assert captured[0].get("quantity") == 7, "ActiveTrade 잔량 미반영 → 다음 청산이 과다매도"
|
||||
assert captured[0]["remaining_pct_multiply"] == pytest.approx(0.7)
|
||||
|
||||
# (2) DT 포지션 병존 → DT 행은 건드리지 않고(비-DT 필터) 스윙 잔량만 반영
|
||||
captured.clear()
|
||||
with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \
|
||||
patch.object(m, "_dt_position_qty", lambda _code: 3):
|
||||
await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7)
|
||||
assert len(captured) == 1
|
||||
assert captured[0].get("quantity") == 7, "DT 병존 경로에서 잔량 미반영"
|
||||
assert captured[0].get("strategy_name_not"), "DT ActiveTrade 행 오염 방지 필터 누락"
|
||||
|
||||
# 두 축소 경로(REDUCE_AND_PAUSE / FORCE_REDUCE) 모두 인메모리 반영을 호출해야 한다
|
||||
src = inspect.getsource(m)
|
||||
assert src.count("_reflect_reduction(stock_code, sell_qty") == 2, "두 축소 경로 모두 반영 필요"
|
||||
assert '"quantity": remaining_qty' in src
|
||||
helper = inspect.getsource(m._reflect_reduction)
|
||||
assert "reflect_sell" in helper and "day_trading_state" in helper
|
||||
assert "remove_position" in helper and 'pos["quantity"] = _left' in helper
|
||||
|
||||
|
||||
def test_swing_exit_always_passes_strategy_name():
|
||||
"""전략명을 비우면 db_worker 가 최신 active 행(=DT)을 닫거나 덮어쓴다."""
|
||||
"""전략명을 비우면 db_worker 가 최신 active 행(=DT)을 닫거나 덮어쓴다.
|
||||
|
||||
과거 버그(P1 감사): 스윙 청산이 전략명 없이 close/upsert 를 큐잉해, 같은 종목에
|
||||
데이트레이딩 행이 있으면 DT 행이 닫히거나 잔량이 덮어써지고 스윙 행은 영구 잔존.
|
||||
|
||||
[2026-09-27] 전략명이 NULL 인 legacy/동기화 유입 행을 'swing' 으로 폴백하면
|
||||
매칭 0건이 되는 문제 때문에 `_at_filter_not_dt` 분기가 추가됐다:
|
||||
- 전략명 있음 → `payload["strategy_name"] = _at_strategy`
|
||||
- 전략명 NULL → `payload["strategy_name_not"] = DT_STRATEGY_NAMES`
|
||||
두 경로 모두 'DT 행을 건드리지 않는다' 는 불변식을 지킨다. 따라서 특정 리터럴
|
||||
대신 **모든 upsert payload 가 전략명으로 한정되는지** 를 검사한다.
|
||||
"""
|
||||
from app.services.swing import exit_service as se
|
||||
src = inspect.getsource(se)
|
||||
assert "strategy_name=None" not in src, "close 호출에 전략명 누락"
|
||||
assert 'strategy_name=_strategy or "swing"' in src
|
||||
assert '"strategy_name": _at_strategy' in src # 트레일링 고점 upsert
|
||||
assert '"strategy_name": _strategy or "swing"' in src # 분할매도 upsert
|
||||
assert 'strategy_name=_strategy or "swing"' in src # 전량청산 close 경로
|
||||
scopes = _active_trade_payload_scopes(src)
|
||||
# 트레일링 고점 upsert + 분할매도 upsert = 최소 2곳
|
||||
assert len(scopes) >= 2, f"ActiveTrade upsert payload 검출 실패: {scopes}"
|
||||
for var, keys in scopes.items():
|
||||
assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염"
|
||||
# NULL 전략명 경로는 DT 제외 필터로 비-DT 행을 겨냥해야 한다
|
||||
assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실"
|
||||
|
||||
|
||||
def test_executor_sell_excludes_daytrading_shares():
|
||||
"""Portfolio 행은 스윙+DT 합계 → 스윙 청산이 DT 보유분까지 팔면 안 된다."""
|
||||
"""Portfolio 행은 스윙+DT 합계 → 스윙 청산이 DT 보유분까지 팔면 안 된다.
|
||||
|
||||
과거 버그(P1 감사): Portfolio 한 행에 스윙+DT 수량이 합산돼 있어 스윙 청산이
|
||||
DT 보유분까지 매도했고, ActiveTrade 갱신도 DT 행을 집어 닫아버렸다.
|
||||
|
||||
[2026-09-27] 분할매도 upsert 가 dict 리터럴 → `_at_payload[...] = ...` 조립으로
|
||||
바뀌고, 전략명 NULL 행을 위한 `strategy_name_not` 분기가 추가됐다(스윙 청산과
|
||||
동일 패턴). 또 캐시 반영용 '행 합산 수량'(_row_total_qty)과 이 전략 보유분(_own)이
|
||||
분리됐다. 검사 방식만 갱신 — 불변식(DT 수량 제외 + DT 행 비오염)은 그대로 본다.
|
||||
"""
|
||||
from app.services import trade_executor as te
|
||||
src = inspect.getsource(te._execute_sell)
|
||||
assert "day_trading_state" in src and "_dt_qty" in src
|
||||
assert "owned_by_daytrading" in src
|
||||
assert 'total_qty = _own' in src
|
||||
# 분할매도 upsert 는 실제 ActiveTrade 행의 전략명(_at_name)을 사용한다
|
||||
assert '"strategy_name": _at_name' in src
|
||||
assert 'total_qty = _own' in src, "DT 수량 차감 후 매도 대상 축소 누락"
|
||||
# 캐시/Portfolio 행 잔량은 행 합산 기준이어야 DT 보유분이 사라지지 않는다
|
||||
assert "_row_total_qty" in src
|
||||
# 분할매도 upsert 는 전략명(또는 비-DT 필터)으로 대상 행을 한정해야 한다
|
||||
scopes = _active_trade_payload_scopes(src)
|
||||
assert scopes, "ActiveTrade upsert payload 검출 실패 — 검사 대상 확인 필요"
|
||||
for var, keys in scopes.items():
|
||||
assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염"
|
||||
assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실"
|
||||
|
||||
|
||||
def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""감사 P2 수정 검증: 주문 멱등성 / shield / 다중행 / v2·수동매도 / 스윙 쿨다운."""
|
||||
import asyncio
|
||||
import inspect
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -63,12 +64,53 @@ async def test_find_recent_order_matches_side_and_qty():
|
||||
assert none is None # 수량 불일치
|
||||
|
||||
|
||||
def test_dt_exit_state_update_is_shielded():
|
||||
@pytest.mark.asyncio
|
||||
async def test_dt_exit_state_update_is_shielded():
|
||||
"""DT 청산의 '체결 이후 상태 반영' 은 취소로부터 보호(asyncio.shield)되어야 한다.
|
||||
|
||||
과거 버그(P2 감사): 스케줄러 단계 타임아웃(_guard)이 매도 주문 직후를 취소하면
|
||||
"브로커는 팔았는데 인메모리 포지션은 그대로" 가 되어 다음 사이클이 같은 물량을
|
||||
다시 팔았다(미보유 매도/중복 매도).
|
||||
|
||||
[2026-09-27] `_execute_exit` 가 `_execute_exit_impl` 로 위임하고 shield 는
|
||||
impl 안 2곳(`_finalize_exit` / `_finalize_exit_stale`)으로 이동 — 동작은 보존됐고
|
||||
`_execute_exit` 본문만 보던 문자열 가드의 **검사 범위**가 어긋났다.
|
||||
이제 문자열이 아니라 **동작**을 본다: 주문 체결 직후 태스크를 취소해도
|
||||
finalize 가 끝까지 완주하는지 확인한다(shield 제거 시 반드시 실패).
|
||||
"""
|
||||
from app.services.daytrading import exit_service as es
|
||||
src = inspect.getsource(es._execute_exit)
|
||||
assert "asyncio.shield(_finalize_exit(" in src
|
||||
|
||||
finished: list = []
|
||||
|
||||
async def _fake_finalize(*args, **kwargs):
|
||||
await asyncio.sleep(0.05) # 취소가 개입할 수 있는 지점
|
||||
finished.append(True) # shield 가 있으면 여기까지 도달
|
||||
return {"stock_code": "005930"}
|
||||
|
||||
with patch.object(es, "_finalize_exit", _fake_finalize), \
|
||||
patch("app.services.trade_executor.safe_place_order",
|
||||
AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "1"}})):
|
||||
task = asyncio.create_task(
|
||||
es._execute_exit("005930", {"quantity": 10}, 1000.0, "테스트 청산")
|
||||
)
|
||||
await asyncio.sleep(0.01) # 주문 완료 후 finalize 진입까지 진행
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await asyncio.sleep(0.1) # shield 된 내부 코루틴 완주 대기
|
||||
|
||||
assert finished, "체결 후 상태 반영이 취소됐다 — shield 누락 (중복 매도 위험)"
|
||||
|
||||
# 상태 반영 구현(인메모리 제거 + DB 기록)이 유지되는지 — 위임 체인을 따라 확인
|
||||
fin = inspect.getsource(es._finalize_exit)
|
||||
if "_finalize_exit_impl" in fin:
|
||||
fin += inspect.getsource(es._finalize_exit_impl)
|
||||
assert "remove_position" in fin and "enqueue_trade_history" in fin
|
||||
# 전일(stale) 청산 경로도 동일하게 보호되어야 한다
|
||||
impl = inspect.getsource(es._execute_exit_impl)
|
||||
assert "asyncio.shield(_finalize_exit_stale(" in impl
|
||||
# shield 는 반드시 주문 전송 '이후' 여야 한다(주문 실패 시 상태 반영 금지)
|
||||
assert impl.index("safe_place_order(") < impl.index("asyncio.shield(")
|
||||
|
||||
|
||||
def test_swing_active_trade_lookup_handles_multiple_rows():
|
||||
|
||||
Reference in New Issue
Block a user