## 재기동 내구성 - 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>
140 lines
6.1 KiB
Python
140 lines
6.1 KiB
Python
"""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
|