Files
auto-stock/backend/tests/test_swing_exit_hardening_r1_20261009.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

353 lines
18 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.
"""스윙 청산 하드닝 R1 (2026-10-09) — swing/exit_service 회귀 테스트.
#1/#3 실시세 정상 + 일봉/지표 조회 실패 시 폴백 손절이 ATR=None → 일반 손절(-7%)로 고변동 종목을
오손절하던 것 → 대체가 경로와 같은 규칙(캐시 ATR → 느슨한 쪽).
#2 'synced'/'외부 유입' 시딩 행의 시간손절 진입일 보정 + TradeHistory 기반 전량청산 하한.
#4 전량 청산 loss_tracker 승패 = 포지션 누적 순손익(이전 분할 레그 + 이번 레그).
#6 스윙 SELL TradeHistory 의 commission·net_profit_loss(비용 차감).
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 기대값은 settings.* 참조.
"""
import time
from datetime import 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
from app.services.mean_reversion_strategy import ExitSignal
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
import app.services.swing.exit_service as es
for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr",
"_presell_check_failures", "_no_price_skips", "_recent_exits"):
monkeypatch.setattr(es, name, {})
from app.core import log_throttle
log_throttle.reset_for_tests()
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)
import app.services.trade_executor as te
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
class _RowsDB:
"""execute 마다 미리 준비한 행 목록을 .all()/.scalar() 로 돌려주고 문장을 기록한다."""
def __init__(self, *row_sets):
self.row_sets = list(row_sets)
self.statements = []
async def execute(self, stmt):
self.statements.append(stmt)
rows = self.row_sets.pop(0) if self.row_sets else []
return SimpleNamespace(all=lambda: list(rows), scalar=lambda: (rows[0] if rows else None))
class _BoomDB:
async def execute(self, stmt):
raise RuntimeError("db down")
def _sql(stmt) -> str:
return str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
def _env(monkeypatch, *, avg=10000, price, high_vol=True, chart=None, signal=None,
order=None, at_kwargs=None):
"""chart: None=정상 30봉, Exception=조회 실패, list=그 봉들."""
import app.services.swing.exit_service as es
import app.services.trade_executor as te
import app.services.db_worker as dbw
import app.services.portfolio_cache_bridge as pcb
from app.core.timeutil import now_kst
from app.services.daytrading.position_manager import day_trading_state
holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10, average_price=avg,
current_price=price, market="KRX", updated_at=now_kst())
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=2), entry_reason=None,
entry_price=avg)
for k, v in (at_kwargs or {}).items():
setattr(at, k, v)
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at))
api = MagicMock()
rng = 250 if high_vol else 40 # 고변동: ATR≈500(>3%)
bars = [{"stck_clpr": str(price + 100 + (i % 3) * 50), "stck_hgpr": str(price + 100 + rng + (i % 3) * 50),
"stck_lwpr": str(price + 100 - rng + (i % 3) * 50)} for i in range(30)]
if isinstance(chart, Exception):
api.get_daily_chart = AsyncMock(side_effect=chart)
else:
api.get_daily_chart = AsyncMock(return_value={"output2": bars if chart is None else chart})
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}})
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)
if signal is not None:
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: signal)
monkeypatch.setattr(day_trading_state, "positions", {})
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
sent = []
async def _spo(**kw):
sent.append(kw["quantity"])
return order if order is not None else {"rt_cd": "1", "msg_cd": "X", "msg1": "stop here"}
monkeypatch.setattr(te, "safe_place_order", _spo)
history = []
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock(side_effect=lambda p: history.append(p)))
monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock())
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock())
monkeypatch.setattr(pcb, "reflect_sell", AsyncMock())
monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock())
# [R1-b] 보유 집계 SELECT(휴면·페이지 감시)는 _RowsDB 의 순차 결과를 소비하므로 제외 —
# 집계 자체는 test_swing_exit_hardening_r1b_20261009 에서 검증한다.
monkeypatch.setattr(es, "_portfolio_census_v2", AsyncMock(return_value=None))
loss = AsyncMock()
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", loss)
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
return SimpleNamespace(es=es, sent=sent, history=history, loss=loss, at=at)
async def _cycle(es, db=None):
es._recent_exits.clear()
await es.check_and_execute_exit_v2(db if db is not None else MagicMock())
# ═══════════════════════════════════════════════════════════════════════════
# #1 / #3 — 실시세 정상 + 일봉 조회 실패 폴백 손절
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_nonstale_chart_failure_uses_cached_high_vol_atr(monkeypatch):
"""[재현 반전] 고변동 -8%: 정상 주기 보유 → 차트 실패 주기에도 보유(이전: -7% 시장가 오손절)."""
env = _env(monkeypatch, price=9200)
await _cycle(env.es)
assert env.sent == [] and "005930" in env.es._last_atr
env2 = _env(monkeypatch, price=9200, chart=RuntimeError("EGW00201"))
await _cycle(env2.es)
assert env2.sent == []
@pytest.mark.asyncio
async def test_nonstale_short_chart_without_cache_uses_loosest(monkeypatch):
loosest = -max(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL))
tight = -min(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL))
between = int(10000 * (1 + (tight + loosest) / 2 / 100))
env = _env(monkeypatch, price=between, chart=[]) # 20봉 미만
await _cycle(env.es)
assert env.sent == []
beyond = int(10000 * (1 + (loosest - 1) / 100))
env2 = _env(monkeypatch, price=beyond, chart=RuntimeError("down"))
await _cycle(env2.es)
assert env2.sent == [10] # 손절 자체는 계속 동작(try 밖 판정)
@pytest.mark.asyncio
async def test_nonstale_chart_failure_cached_low_vol_keeps_normal_stop(monkeypatch):
floor = -abs(settings.STOP_LOSS_PERCENT)
below = int(10000 * (1 + (floor - 1) / 100))
env = _env(monkeypatch, price=below, chart=RuntimeError("down"))
from app.core.timeutil import now_kst
env.es._last_atr["005930"] = (below * 0.01, time.monotonic(), now_kst()) # ATR 1% 저변동
await _cycle(env.es)
assert env.sent == [10]
def test_nonstale_helper_prefers_current_atr_and_signs_negative(monkeypatch):
import app.services.swing.exit_service as es
# 이번 ATR 이 있으면 기존 _fallback_stop_loss_pct_v2 경로(저변동 → 일반 손절)
assert es._nonstale_fallback_stop_pct_v2("005930", 10.0, 10000.0, None) == -abs(settings.STOP_LOSS_PERCENT)
# 없으면 대체가 규칙(캐시 없음 → 느슨한 쪽)
assert es._nonstale_fallback_stop_pct_v2("005930", None, 10000.0, None) == -max(
abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL))
assert es._nonstale_fallback_stop_pct_v2("005930", "bad", 10000.0, None) < 0
# ═══════════════════════════════════════════════════════════════════════════
# #2 — 시딩 행 시간손절 진입일
# ═══════════════════════════════════════════════════════════════════════════
_EXT = "외부 유입 포지션 — 동기화 시 자동 등록(진입가=평균단가)"
@pytest.mark.asyncio
async def test_v3_synced_row_uses_original_buy_despite_race_closed_row():
"""181710 재현: BUY 10 (9/23) → 분할 SELL 5 (10/6 09:12) → 레이스로 swing 행 closed →
synced 재시딩(10/6 09:18). 이전: created_at(10/6). 이제: 9/23."""
import app.services.swing.exit_service as es
created = datetime(2026, 10, 6, 9, 18)
buy = datetime(2026, 9, 23, 12, 53)
db = _RowsDB([("BUY", 10, 10, buy), ("SELL", 5, 5, datetime(2026, 10, 6, 9, 12))])
at = SimpleNamespace(created_at=created, entry_reason=_EXT, strategy_name="synced")
basis = await es._resolve_time_stop_entry_v3(db, "181710", at)
assert basis.created_at == buy
sql = _sql(db.statements[0])
assert "trade_history.status = 'COMPLETED'" in sql
assert "trade_history.created_at <= '2026-10-06 09:18:00'" in sql
assert "day_trade" in sql # 비-DT 정본 절
@pytest.mark.asyncio
async def test_v3_recognizes_strategy_name_or_either_prefix():
import app.services.swing.exit_service as es
buy = datetime(2026, 9, 1, 10, 0)
for kw in ({"strategy_name": "synced", "entry_reason": None},
{"strategy_name": None, "entry_reason": _EXT},
{"strategy_name": "swing", "entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)"}):
at = SimpleNamespace(created_at=datetime(2026, 10, 6), **kw)
basis = await es._resolve_time_stop_entry_v3(_RowsDB([("BUY", 3, 3, buy)]), "X", at)
assert basis.created_at == buy
@pytest.mark.asyncio
async def test_v3_lower_bound_is_last_flat_sell():
"""이전 포지션이 TradeHistory 상 전량 청산된 뒤의 BUY 만 진입으로 본다."""
import app.services.swing.exit_service as es
rows = [("BUY", 5, 5, datetime(2026, 7, 1)), ("SELL", 5, 5, datetime(2026, 7, 20)),
("BUY", 4, 4, datetime(2026, 9, 2))]
at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced")
basis = await es._resolve_time_stop_entry_v3(_RowsDB(rows), "X", at)
assert basis.created_at == datetime(2026, 9, 2)
@pytest.mark.asyncio
async def test_v3_fully_sold_history_keeps_created_at():
import app.services.swing.exit_service as es
rows = [("BUY", 5, 5, datetime(2026, 7, 1)), ("SELL", 5, 5, datetime(2026, 7, 20))]
at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced")
assert await es._resolve_time_stop_entry_v3(_RowsDB(rows), "X", at) is at
@pytest.mark.asyncio
async def test_v3_non_seeded_row_does_not_query_and_failure_keeps_created_at():
import app.services.swing.exit_service as es
at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason="기술: RSI", strategy_name="swing")
assert await es._resolve_time_stop_entry_v3(_BoomDB(), "X", at) is at # DB 미조회
seeded = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced")
assert await es._resolve_time_stop_entry_v3(_BoomDB(), "X", seeded) is seeded
@pytest.mark.asyncio
async def test_v3_wired_into_time_stop(monkeypatch):
"""synced 행 + 원 BUY 가 SWING_TIME_STOP_DAYS 거래일 이전 → 시간손절 발동(이전: 재시딩일 기준 미발동)."""
from app.core.timeutil import now_kst
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", True, raising=False)
env = _env(monkeypatch, price=10050, high_vol=False, signal=None, order=OK,
at_kwargs={"strategy_name": "synced", "entry_reason": _EXT,
"created_at": now_kst().replace(tzinfo=None) - timedelta(days=1)})
monkeypatch.setattr(env.es, "check_exit_signal", lambda **kw: None)
old_buy = now_kst().replace(tzinfo=None) - timedelta(days=int(settings.SWING_TIME_STOP_DAYS) * 2)
db = _RowsDB([("BUY", 10, 10, old_buy)], []) # ① 시간손절 진입일 ② 누적 실현손익(없음)
await _cycle(env.es, db)
assert env.sent == [10]
assert env.history[0]["exit_reason"].startswith("시간손절")
# ═══════════════════════════════════════════════════════════════════════════
# #4 — 포지션 단위 순손익 승패 / #6 — commission·net 기록
# ═══════════════════════════════════════════════════════════════════════════
def _cost(entry, exit_, q):
from app.services.trade_helpers import calculate_trade_costs
return calculate_trade_costs(entry, exit_, q, True, "KOSPI")["total_cost"]
def test_position_is_loss_v2():
import app.services.swing.exit_service as es
assert es._position_is_loss_v2(8000.0, -410.0) is False
assert es._position_is_loss_v2(None, -410.0) is True
assert es._position_is_loss_v2(-1.0, 0.5) is True
@pytest.mark.asyncio
async def test_prior_realized_net_converts_gross_rows_and_uses_net_rows():
import app.services.swing.exit_service as es
# 행: (net_profit_loss, commission, total_amount, executed_quantity, order_quantity)
# ① 과거 gross 기록(commission NULL): 진입 10,000 → 10,500 × 10 = gross 5,000
# ② 비용 기록 행: net 그대로
rows = [(5000.0, None, 105000.0, 10, 10), (1234.0, 50.0, 99999.0, 5, 5), (None, None, 1, 1, 1)]
at = SimpleNamespace(created_at=datetime(2026, 10, 1), entry_reason=None, strategy_name="swing")
db = _RowsDB(rows)
got = await es._prior_position_realized_net_v2(db, "005930", at, "KRX")
assert got == pytest.approx(5000.0 - _cost(10000.0, 10500.0, 10) + 1234.0)
sql = _sql(db.statements[0])
assert "trade_history.order_type = 'SELL'" in sql and "trade_history.created_at >= '2026-10-01" in sql
@pytest.mark.asyncio
async def test_prior_realized_net_failure_returns_none():
import app.services.swing.exit_service as es
at = SimpleNamespace(created_at=datetime(2026, 10, 1), entry_reason=None, strategy_name="swing")
assert await es._prior_position_realized_net_v2(_BoomDB(), "005930", at, "KRX") is None
assert await es._prior_position_realized_net_v2(_RowsDB(), "005930", None, "KRX") is None
@pytest.mark.asyncio
async def test_full_exit_after_profitable_partials_is_not_loss(monkeypatch):
"""449450 재현: 1차 익절 2회(+3,900/+4,125) 후 잔량 트레일링 소폭 손실 → 포지션은 이익 → 패 아님."""
env = _env(monkeypatch, price=9990, high_vol=False, order=OK,
signal=ExitSignal("SELL_ALL", "전량청산: 트레일링", 1.0))
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False)
db = _RowsDB([(3900.0, None, 0, 0, 0), (4125.0, None, 0, 0, 0)])
await _cycle(env.es, db)
assert env.sent == [10]
env.loss.assert_awaited_once()
assert env.loss.await_args.kwargs["is_loss"] is False
# #6: commission·순손익 기록
h = env.history[0]
assert h["commission"] == _cost(10000.0, 9990.0, 10)
assert h["net_profit_loss"] == pytest.approx((9990 - 10000) * 10 - _cost(10000.0, 9990.0, 10))
@pytest.mark.asyncio
async def test_full_exit_tiny_gross_profit_within_costs_is_loss(monkeypatch):
"""비용 이내 미세 이익(+0.05%) 단일 레그 → 순손익 음수 → 패(§5.1)."""
env = _env(monkeypatch, price=10005, high_vol=False, order=OK,
signal=ExitSignal("SELL_ALL", "BB상단", 1.0))
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False)
await _cycle(env.es, _RowsDB([]))
assert env.loss.await_args.kwargs["is_loss"] is True
@pytest.mark.asyncio
async def test_full_exit_prior_lookup_failure_still_sells_and_records(monkeypatch):
env = _env(monkeypatch, price=9000, high_vol=False, order=OK,
signal=ExitSignal("SELL_ALL", "손절", 1.0))
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False)
await _cycle(env.es, _BoomDB())
assert env.sent == [10]
assert env.loss.await_args.kwargs["is_loss"] is True
@pytest.mark.asyncio
async def test_unknown_reconcile_uses_prior_realized_net(monkeypatch):
import app.services.swing.exit_service as es
import app.services.portfolio_cache_bridge as pcb
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock())
monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock())
monkeypatch.setattr(pcb, "reflect_sell", AsyncMock())
loss = AsyncMock()
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", loss)
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
es._pending_unknown_sells["005930"] = {
"qty": 10, "pre_hldg": 10, "since": time.time(), "price": 9990.0, "avg_price": 10000.0,
"swing_qty": 10, "sell_pct": 1.0, "remaining_pct": 0.5, "original_qty": 20,
"strategy": "swing", "filter_not_dt": False, "had_active_trade": True,
"stock_name": "T", "reason": "트레일링", "market": "KRX", "prior_realized_net": 8000.0,
}
assert await es._reconcile_unknown_sell_v2("005930", 0, 0) is True
assert loss.await_args.kwargs["is_loss"] is False
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)