Files
auto-stock/backend/tests/test_swing_exit_hardening_r1_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

349 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.
"""스윙 청산 하드닝 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"}]})
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