Files
auto-stock/backend/tests/test_ai_analysis_buy_amount.py
shuim 7abbb3411c 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>
2026-09-29 15:49:25 +09:00

140 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI 투자 분석 — 매수 추천 금액 산출 검증 (자동매매 사이징과 동일 기준)."""
from unittest.mock import patch
import pytest
from app.api.endpoints.market_data import _suggest_buy_amount
from app.api.endpoints.portfolio import portfolio_cache
def _summary(total_assets=10_000_000, cash=5_000_000, stock_value=3_000_000):
return {"total_assets": total_assets, "available_cash": cash, "total_value": stock_value}
@pytest.fixture(autouse=True)
def _cache():
portfolio_cache.summary = _summary()
yield
portfolio_cache.summary = None
class _Adp:
fallback_used = False
position_size = 0.06
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")
_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_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")
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():
for rec in ("HOLD", "SELL"):
out = _suggest_buy_amount(70_000, rec, 0.8, "005930")
assert out["suggested_amount"] == 0 and rec in out["sizing_note"]
def test_limited_by_available_cash_and_reserve():
portfolio_cache.summary = _summary(cash=600_000) # 예수금 60만, 최소보유 5% = 50만
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 # 사용 가능 10만 → 1주
def test_limited_by_swing_capital_cap():
# 스윙 자본 상한이 구속 조건이 되는 경우를 검증한다.
# [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
def test_no_balance_info_reports_reason():
portfolio_cache.summary = None
out = _suggest_buy_amount(70_000, "BUY", 0.8, "005930")
assert out["suggested_amount"] == 0 and "잔고" in out["sizing_note"]
# ── 목표가 · 손절가 ─────────────────────────────────────────────────────────
from app.api.endpoints.market_data import _suggest_targets # noqa: E402
def test_targets_use_tighter_stop_and_2r():
"""ATR 손절(2.5×)과 -7% 중 타이트한 쪽(가격 높은 쪽), 목표는 +2R."""
with patch("app.services.adaptive_strategy.get_current_adaptive_params", side_effect=Exception):
# ATR 1,000 → ATR손절 100,000-2,500 = 97,500 / -7% = 93,000 → 97,500 채택
out = _suggest_targets(100_000, atr=1_000.0)
assert out["stop_loss_price"] == 97_500
assert out["target_price"] == 105_000 # 100,000 + 2×2,500
assert out["stop_loss_pct"] == pytest.approx(-2.5, abs=0.01)
assert out["target_pct"] == pytest.approx(5.0, abs=0.01)
assert out["risk_reward"] == 2.0
assert "ATR" in out["target_note"]
def test_percent_stop_wins_when_atr_is_wide():
with patch("app.services.adaptive_strategy.get_current_adaptive_params", side_effect=Exception):
# ATR 5,000 → ATR손절 87,500 < -7% 93,000 → 93,000(타이트) 채택
out = _suggest_targets(100_000, atr=5_000.0)
assert out["stop_loss_price"] == 93_000
assert out["target_price"] == 114_000
assert "-7%" in out["target_note"]
def test_targets_without_atr_fall_back_to_percent():
with patch("app.services.adaptive_strategy.get_current_adaptive_params", side_effect=Exception):
out = _suggest_targets(100_000, atr=None)
assert out["stop_loss_price"] == 93_000 and "ATR 없음" in out["target_note"]
def test_targets_zero_price():
assert _suggest_targets(0, atr=1000.0)["target_price"] == 0