Files
auto-stock/backend/tests/test_ai_analysis_buy_amount.py
shuim 1b9257c4d2 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>
2026-09-28 08:58:18 +09:00

113 lines
5.0 KiB
Python
Raw 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():
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"]
def test_high_confidence_scales_up_capped_at_120pct():
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
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