Files
auto-stock/backend/tests/test_swing_r4_fixes_20261002.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

388 lines
18 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.
"""스윙 도메인 4회차 검증 잔여 결함 회귀 테스트 (2026-10-02).
L-4 현재가 조회 실패(예외·빈 문자열)여도 대체 가격으로 폴백 손절 판정
L-6 손절률·ATR 배수 합리 범위 검증(config 키)
L-3 시간손절 원 진입일 탐색을 마지막 전량 청산 이후 BUY 로 제한
L-7 KRX_EXTRA_HOLIDAYS 파싱 캐시 + 형식 오류 warning 설정값당 1회
규약(CLAUDE.md §4.1): 주문 경로 목은 예외 발생형(_Reached, BaseException). 기대값은 settings.*.
"""
import logging
from datetime import date, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.dialects import postgresql
import app.services.mean_reversion_strategy as mr
from app.core.config import settings
class _Reached(BaseException):
pass
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
monkeypatch.setattr(mr, "_previous_regime", None)
from app.core import log_throttle
log_throttle.reset_for_tests()
import app.services.swing.exit_service as es
monkeypatch.setattr(es, "_last_live_prices", {})
yield
def _no_adaptive(monkeypatch):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
# ═══════════════════════════════════════════════════════════════════════════
# L-4
# ═══════════════════════════════════════════════════════════════════════════
def _harness(monkeypatch, *, holding_price, avg=10000, price_resp=None, price_exc=None,
holding_age_sec=60, entry_days_ago=90):
import app.services.swing.exit_service as es
import app.services.trade_executor as te
from app.core.timeutil import now_kst
# [H-1/M-1] Portfolio 대체가는 updated_at 기준 나이 상한 안이어야 쓰인다
holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10,
average_price=avg, current_price=holding_price, market="KRX",
updated_at=now_kst() - timedelta(seconds=holding_age_sec))
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding]))
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None,
created_at=now_kst() - timedelta(days=entry_days_ago), entry_reason=None)
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at))
api = MagicMock()
if price_exc is not None:
api.get_current_price = AsyncMock(side_effect=price_exc)
else:
api.get_current_price = AsyncMock(return_value=price_resp)
bars = [{"stck_clpr": str(avg), "stck_hgpr": str(avg + 100), "stck_lwpr": str(avg - 100)}
for _ in range(30)]
api.get_daily_chart = AsyncMock(return_value={"output2": bars})
# [C-1 6회차] 국내 매도 직전 KIS 실보유 확인 — 보유 10주 전부 스윙(DT 없음)
api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [
{"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]})
api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유
monkeypatch.setattr(es, "korea_investment_api", api)
monkeypatch.setattr(es, "calculate_atr", lambda h, l, c: avg * 0.01)
monkeypatch.setattr(es, "calculate_rsi", lambda c: 45.0)
monkeypatch.setattr(es, "calculate_bollinger", lambda c: (avg * 1.2, avg * 1.1, avg * 0.9))
sig_calls = []
def _sig(**kw):
sig_calls.append(kw)
return mr.ExitSignal("SELL_PARTIAL", "익절", 0.5)
monkeypatch.setattr(es, "check_exit_signal", _sig)
placed = []
async def _spo(**kw):
placed.append(kw)
raise _Reached("sell")
monkeypatch.setattr(te, "safe_place_order", _spo)
from app.services.daytrading.position_manager import day_trading_state
monkeypatch.setattr(day_trading_state, "positions", {})
es._recent_exits.clear()
return es, api, placed, sig_calls
async def _run(es):
try:
await es.check_and_execute_exit_v2(MagicMock())
except _Reached:
pass
@pytest.mark.asyncio
@pytest.mark.parametrize("kw", [
dict(price_exc=RuntimeError("KIS down")),
dict(price_resp={"output": {"stck_prpr": ""}}),
dict(price_resp={"output": {}}),
dict(price_resp=None),
])
async def test_l4_price_failure_still_runs_fallback_stop(monkeypatch, kw):
_no_adaptive(monkeypatch)
es, api, placed, sig_calls = _harness(monkeypatch, holding_price=8000, **kw) # -20%
await _run(es)
assert len(placed) == 1 and placed[0]["quantity"] == 10 and placed[0]["order_type"] == "SELL"
assert sig_calls == [] # 낡은 가격으로 지표 판정(익절)은 하지 않는다
api.get_daily_chart.assert_not_called()
@pytest.mark.asyncio
async def test_l4_stale_price_never_takes_profit_or_time_stop(monkeypatch):
_no_adaptive(monkeypatch)
# 90일 보유 + 대체 가격 -1% → 실시세라면 시간손절, 낡은 가격이면 판정 안 함
es, _, placed, sig_calls = _harness(monkeypatch, holding_price=9900,
price_exc=RuntimeError("down"))
await _run(es)
assert placed == [] and sig_calls == []
def _cache(es, price, age_sec):
"""실시세 캐시 항목을 age_sec 초 전에 기록된 것으로 심는다."""
import time as _t
from app.core.timeutil import now_kst
es._last_live_prices["005930"] = (float(price), _t.monotonic() - age_sec,
now_kst() - timedelta(seconds=age_sec))
@pytest.mark.asyncio
async def test_l4_newer_cache_beats_older_portfolio(monkeypatch):
"""[H-1/M-1 사양 교체] 캐시·Portfolio 중 **더 최신** 값을 쓴다 — 캐시가 최신."""
_no_adaptive(monkeypatch)
es, _, placed, _ = _harness(monkeypatch, holding_price=10000, holding_age_sec=300,
price_exc=RuntimeError("down"))
_cache(es, 8000, 10)
await _run(es)
assert len(placed) == 1
@pytest.mark.asyncio
async def test_l4_newer_portfolio_beats_older_cache(monkeypatch):
_no_adaptive(monkeypatch)
es, _, placed, _ = _harness(monkeypatch, holding_price=8000, holding_age_sec=10,
price_exc=RuntimeError("down"))
_cache(es, 10000, 300) # 더 오래된 캐시(보합) — 최신 Portfolio(-20%)를 가리면 안 된다
await _run(es)
assert len(placed) == 1
@pytest.mark.asyncio
async def test_l4_aged_out_prices_skip_with_error(monkeypatch, caplog):
_no_adaptive(monkeypatch)
too_old = float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC) + 60
es, _, placed, _ = _harness(monkeypatch, holding_price=8000, holding_age_sec=too_old,
price_exc=RuntimeError("down"))
_cache(es, 8000, too_old)
with caplog.at_level(logging.ERROR, logger=es.logger.name):
await _run(es)
assert placed == []
assert any(r.levelno >= logging.ERROR and "수동 확인" in r.getMessage() for r in caplog.records)
@pytest.mark.asyncio
async def test_l4_portfolio_without_updated_at_is_not_used(monkeypatch):
_no_adaptive(monkeypatch)
es, _, placed, _ = _harness(monkeypatch, holding_price=8000, price_exc=RuntimeError("down"))
es.get_swing_holdings.return_value[0].updated_at = None
await _run(es)
assert placed == []
@pytest.mark.asyncio
async def test_h1_rebuy_does_not_use_previous_position_price(monkeypatch):
"""[재현] 예전 보유분 실시세 9,300 이 캐시에 남은 채 같은 종목 재매수(진입 11,000).
시세 조회 1회 실패 → 이전: 9,300 으로 −15% 판정 → 시장가 전량 오손절 + loss_tracker 오염.
이제: 진입 이전 값은 거부, 재매수 이후 Portfolio 평가가(11,000)로 판정 → 손절 없음."""
_no_adaptive(monkeypatch)
from app.core.timeutil import now_kst
es, _, placed, _ = _harness(monkeypatch, holding_price=11000, avg=11000, holding_age_sec=30,
price_exc=RuntimeError("down"), entry_days_ago=0)
# 진입(ActiveTrade.created_at) = 2분 전, 캐시 = 5분 전(이전 보유분)
import app.services.swing.exit_service as _es
at = await _es.get_swing_active_trade(None, "005930")
at.created_at = now_kst() - timedelta(seconds=120)
_cache(es, 9300, 300)
await _run(es)
assert placed == []
@pytest.mark.asyncio
async def test_h1_rebuy_only_stale_cache_skips(monkeypatch):
"""재매수 후 Portfolio 도 아직 갱신 전(진입 이전 updated_at)이면 판정 skip."""
_no_adaptive(monkeypatch)
from app.core.timeutil import now_kst
es, _, placed, _ = _harness(monkeypatch, holding_price=9300, avg=11000, holding_age_sec=300,
price_exc=RuntimeError("down"), entry_days_ago=0)
at = await es.get_swing_active_trade(None, "005930")
at.created_at = now_kst() - timedelta(seconds=120)
_cache(es, 9300, 300)
await _run(es)
assert placed == []
@pytest.mark.asyncio
async def test_l4_no_price_at_all_logs_error(monkeypatch, caplog):
_no_adaptive(monkeypatch)
es, _, placed, _ = _harness(monkeypatch, holding_price=0, price_exc=RuntimeError("down"))
with caplog.at_level(logging.ERROR, logger=es.logger.name):
await _run(es)
assert placed == []
assert any(r.levelno >= logging.ERROR and "005930" in r.getMessage() for r in caplog.records)
@pytest.mark.asyncio
async def test_l4_live_price_success_is_remembered(monkeypatch):
_no_adaptive(monkeypatch)
es, _, placed, sig_calls = _harness(monkeypatch, holding_price=1,
price_resp={"output": {"stck_prpr": "10500"}})
await _run(es)
assert es._last_live_prices["005930"][0] == 10500 # (가격, monotonic, wall)
assert sig_calls and sig_calls[0]["close"] == 10500 # 실시세면 지표 판정 정상 수행
# ═══════════════════════════════════════════════════════════════════════════
# L-6
# ═══════════════════════════════════════════════════════════════════════════
def _cfg(monkeypatch, values):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: values.get(k))
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
@pytest.mark.parametrize("bad", [-0.5, 0.5, -35.0, 500.0])
def test_l6_stop_loss_out_of_range_falls_back(monkeypatch, bad):
_cfg(monkeypatch, {"STOP_LOSS_PERCENT": bad})
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0)[0] == -abs(settings.STOP_LOSS_PERCENT)
@pytest.mark.parametrize("ok", [-3.0, -settings.SWING_STOP_LOSS_PCT_MIN, -settings.SWING_STOP_LOSS_PCT_MAX])
def test_l6_stop_loss_in_range_accepted(monkeypatch, ok):
_cfg(monkeypatch, {"STOP_LOSS_PERCENT": ok})
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0)[0] == -abs(ok)
@pytest.mark.parametrize("bad", [0.4, 10.5, -2.0])
def test_l6_atr_mult_out_of_range_falls_back(monkeypatch, bad):
_cfg(monkeypatch, {"ATR_STOP_MULTIPLIER": bad})
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0)[1] == float(settings.ATR_STOP_MULTIPLIER)
def test_l6_atr_mult_in_range_accepted(monkeypatch):
_cfg(monkeypatch, {"ATR_STOP_MULTIPLIER": 1.5})
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0)[1] == 1.5
def test_l6_bounds_come_from_settings(monkeypatch):
monkeypatch.setattr(settings, "SWING_STOP_LOSS_PCT_MAX", 5.0)
_cfg(monkeypatch, {"STOP_LOSS_PERCENT": -6.0})
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0)[0] == -abs(settings.STOP_LOSS_PERCENT)
def test_l6_adaptive_out_of_range_uses_lower_source(monkeypatch):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(
fallback_used=False, stop_loss_pct=-0.01, stop_loss_high_vol=-0.01, atr_stop_multiplier=50.0))
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0) == (
-abs(settings.STOP_LOSS_PERCENT), float(settings.ATR_STOP_MULTIPLIER))
@pytest.mark.parametrize("mode", ["aggressive", "normal", "defensive", "crisis"])
def test_l6_all_adaptive_modes_pass_validation(monkeypatch, mode):
"""현행 _MODE_PARAMS 값이 범위 검증에 걸려 조용히 config 로 대체되지 않는다."""
import app.services.adaptive_strategy as ad
p = ad._MODE_PARAMS[ad.TradingMode(mode)] if hasattr(ad, "TradingMode") else ad._MODE_PARAMS[mode]
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(
fallback_used=False, stop_loss_pct=p["stop_loss_pct"],
stop_loss_high_vol=p["stop_loss_high_vol"], atr_stop_multiplier=p["atr_stop_multiplier"]))
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0) == (
-abs(p["stop_loss_pct"]), float(p["atr_stop_multiplier"]))
# ═══════════════════════════════════════════════════════════════════════════
# L-3
# ═══════════════════════════════════════════════════════════════════════════
class _SeqDB:
"""execute 호출 순서대로 scalar 값을 돌려주고 문장을 기록한다."""
def __init__(self, *values):
self.values = list(values)
self.statements = []
async def execute(self, stmt):
self.statements.append(stmt)
v = self.values.pop(0) if self.values else None
return SimpleNamespace(scalar=lambda: v)
def _sql(stmt) -> str:
return str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
_SYNC = "동기화 유입 포지션 (부분매도 시 자동 생성)"
@pytest.mark.asyncio
async def test_l3_buy_search_bounded_by_last_full_close():
import app.services.swing.exit_service as es
created = datetime(2026, 3, 2, 10, 0)
last_close = datetime(2026, 2, 1, 15, 0)
buy = datetime(2026, 2, 10, 9, 30)
db = _SeqDB(last_close, buy)
at = SimpleNamespace(created_at=created, entry_reason=_SYNC)
basis = await es._resolve_time_stop_entry_v2(db, "005930", at)
assert basis.created_at == buy
buy_sql = _sql(db.statements[1])
assert "trade_history.created_at > '2026-02-01 15:00:00'" in buy_sql
closed_sql = _sql(db.statements[0])
assert "active_trades.status = 'closed'" in closed_sql or "status = 'closed'" in closed_sql
@pytest.mark.asyncio
async def test_l3_no_buy_after_close_keeps_created_at():
import app.services.swing.exit_service as es
at = SimpleNamespace(created_at=datetime(2026, 3, 2, 10, 0), entry_reason=_SYNC)
db = _SeqDB(datetime(2026, 2, 1), None) # 전량 청산 이후 BUY 없음
assert await es._resolve_time_stop_entry_v2(db, "005930", at) is at
@pytest.mark.asyncio
async def test_l3_no_closed_row_has_no_lower_bound():
import app.services.swing.exit_service as es
at = SimpleNamespace(created_at=datetime(2026, 3, 2, 10, 0), entry_reason=_SYNC)
db = _SeqDB(None, datetime(2026, 1, 5))
basis = await es._resolve_time_stop_entry_v2(db, "005930", at)
assert basis.created_at == datetime(2026, 1, 5)
assert "trade_history.created_at >" not in _sql(db.statements[1])
@pytest.mark.asyncio
async def test_l3_query_failure_keeps_created_at():
import app.services.swing.exit_service as es
class _Boom:
async def execute(self, stmt):
raise RuntimeError("db down")
at = SimpleNamespace(created_at=datetime(2026, 3, 2, 10, 0), entry_reason=_SYNC)
assert await es._resolve_time_stop_entry_v2(_Boom(), "005930", at) is at
# ═══════════════════════════════════════════════════════════════════════════
# L-7
# ═══════════════════════════════════════════════════════════════════════════
def test_l7_extra_holidays_parsed_once_and_warns_once(monkeypatch, caplog):
from app.core import market_schedule as ms
monkeypatch.setattr(ms, "_extra_holidays_cache", None)
monkeypatch.setattr(settings, "KRX_EXTRA_HOLIDAYS", "2026-10-06,not-a-date", raising=False)
with caplog.at_level(logging.WARNING, logger=ms.__name__):
assert ms.is_krx_holiday(date(2026, 10, 6))
assert ms.is_krx_holiday(date(2026, 10, 6))
ms.count_krx_trading_days_between(date(2026, 9, 1), date(2026, 10, 30))
warns = [r for r in caplog.records if "KRX_EXTRA_HOLIDAYS" in r.getMessage()]
assert len(warns) == 1
def test_l7_cache_follows_setting_changes(monkeypatch):
from app.core import market_schedule as ms
monkeypatch.setattr(ms, "_extra_holidays_cache", None)
monkeypatch.setattr(settings, "KRX_EXTRA_HOLIDAYS", "2026-10-06", raising=False)
assert ms.is_krx_holiday(date(2026, 10, 6))
monkeypatch.setattr(settings, "KRX_EXTRA_HOLIDAYS", "2026-10-07", raising=False)
assert not ms.is_krx_holiday(date(2026, 10, 6))
assert ms.is_krx_holiday(date(2026, 10, 7))
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)