- 스윙 체결가 확인 전 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
197 lines
10 KiB
Python
197 lines
10 KiB
Python
"""스윙 청산 하드닝 R8 (2026-10-10) — 청산 쿨다운이 손절(전량형)을 막던 결함.
|
|
|
|
[MEDIUM] EXIT_COOLDOWN_SEC 쿨다운이 신호 종류를 보지 않아, KIS 주문가능 상한에 걸려 일부만 팔린
|
|
손절의 잔량·1차 익절 직후 급락 손절이 최소 EXIT_COOLDOWN_SEC 동안 보류됐다(§8.1-11 위반).
|
|
이제 국내 전량형(sell_pct>=1.0) 신호는 쿨다운을 우회하고, 분할 신호와 해외는 기존대로 쿨다운.
|
|
과다매도는 매도 직전 KIS 실보유 상한(C-1)이 막는다 — 테스트로 함께 고정한다.
|
|
|
|
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 실패 주입은 예외 발생형,
|
|
쿨다운 길이는 모듈 상수(EXIT_COOLDOWN_SEC) 참조.
|
|
"""
|
|
import time
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import app.services.mean_reversion_strategy as mr
|
|
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
|
|
import app.services.telegram_notifier as tn
|
|
for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr",
|
|
"_presell_check_failures", "_no_price_skips", "_recent_exits",
|
|
"_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last"):
|
|
monkeypatch.setattr(es, name, {})
|
|
monkeypatch.setattr(es, "_exit_run_state_v2", {
|
|
"seq": 0, "ok": True, "error": None, "holdings": 0, "errored": 0, "errors": [],
|
|
})
|
|
# Redis 실접속 차단: 복원은 완료 상태로 두고, 불명 매도 영속화는 호출되면 실패(예외 발생형)
|
|
monkeypatch.setattr(es, "_unknown_sells_restore_state", "ok")
|
|
monkeypatch.setattr(es, "_persist_unknown_sell_v2",
|
|
AsyncMock(side_effect=AssertionError("불명 매도 경로 미예상")))
|
|
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())
|
|
monkeypatch.setattr(tn.telegram_notifier, "send_message",
|
|
AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지")))
|
|
return es
|
|
|
|
|
|
def _env(monkeypatch, *, signal, price=8000, qty=10, kis=(10, 10), remaining_pct=1.0):
|
|
"""평균 10000, 현재가 price, 장부 스윙 qty. check_exit_signal 은 signal 반환.
|
|
kis = (보유, 주문가능) 를 매도 직전 KIS 실보유로 돌려준다(리스트면 호출마다 순서대로)."""
|
|
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=qty, average_price=10000,
|
|
current_price=price, market="KRX", updated_at=now_kst())
|
|
state = {"holding": holding}
|
|
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(side_effect=lambda *a, **k: [state["holding"]]))
|
|
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
|
|
at = SimpleNamespace(strategy_name="swing", remaining_pct=remaining_pct, highest_since_entry=None,
|
|
created_at=now_kst() - timedelta(days=2), entry_reason=None, entry_price=10000)
|
|
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at))
|
|
api = MagicMock()
|
|
bars = [{"stck_clpr": str(price + (i % 3) * 50), "stck_hgpr": str(price + 200 + (i % 3) * 50),
|
|
"stck_lwpr": str(price - 200 + (i % 3) * 50)} for i in range(30)]
|
|
api.get_daily_chart = AsyncMock(return_value={"output2": bars})
|
|
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}})
|
|
api.get_balance = AsyncMock(side_effect=AssertionError("잔고 조회는 _presell_kis_holding_v2 목으로"))
|
|
monkeypatch.setattr(es, "korea_investment_api", api)
|
|
sig = {"s": signal}
|
|
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: sig["s"])
|
|
monkeypatch.setattr(es, "_check_momentum_exit", lambda **kw: sig["s"], raising=False)
|
|
kis_seq = list(kis) if isinstance(kis, list) else None
|
|
kis_state = {"v": kis}
|
|
|
|
async def _presell(code):
|
|
if kis_seq is not None:
|
|
return kis_seq.pop(0)
|
|
return kis_state["v"]
|
|
monkeypatch.setattr(es, "_presell_kis_holding_v2", _presell)
|
|
monkeypatch.setattr(day_trading_state, "positions", {})
|
|
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
|
|
sent = []
|
|
|
|
async def _spo(**kw):
|
|
sent.append((kw["stock_code"], kw["quantity"]))
|
|
return OK
|
|
monkeypatch.setattr(te, "safe_place_order", _spo)
|
|
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock())
|
|
monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock())
|
|
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock())
|
|
monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock())
|
|
monkeypatch.setattr(pcb, "reflect_sell", AsyncMock())
|
|
monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock())
|
|
monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock())
|
|
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
|
|
return SimpleNamespace(es=es, sent=sent, sig=sig, state=state, kis_state=kis_state)
|
|
|
|
|
|
def _mark_recent_exit(es, ago_sec=10):
|
|
assert ago_sec < es.EXIT_COOLDOWN_SEC
|
|
es._recent_exits["005930"] = time.time() - ago_sec
|
|
|
|
|
|
# ── 헬퍼 단위 ────────────────────────────────────────────────────────────────
|
|
|
|
def test_helper_partial_domestic_applies(_reset):
|
|
es = _reset
|
|
assert es._exit_cooldown_applies_v2(ExitSignal("SELL_PARTIAL", "r", 0.5), True) is True
|
|
|
|
|
|
def test_helper_full_domestic_bypasses(_reset):
|
|
es = _reset
|
|
assert es._exit_cooldown_applies_v2(ExitSignal("SELL_ALL", "손절", 1.0), True) is False
|
|
|
|
|
|
def test_helper_overseas_always_applies(_reset):
|
|
"""해외는 매도 직전 실보유 상한이 없으므로 전량형도 쿨다운 유지(과다매도 방지)."""
|
|
es = _reset
|
|
assert es._exit_cooldown_applies_v2(ExitSignal("SELL_ALL", "손절", 1.0), False) is True
|
|
|
|
|
|
def test_helper_unparseable_sell_pct_treated_as_full(_reset):
|
|
es = _reset
|
|
assert es._exit_cooldown_applies_v2(SimpleNamespace(sell_pct=None, reason="x"), True) is False
|
|
assert es._exit_cooldown_applies_v2(SimpleNamespace(sell_pct=float("nan"), reason="x"), True) is False
|
|
|
|
|
|
# ── 통합: 청산 루프 ───────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_loss_not_blocked_by_cooldown(monkeypatch, _reset):
|
|
"""[재현 반전] 이전: 직전 청산 10초 전이면 손절(SELL_ALL)도 continue → 주문 0건."""
|
|
env = _env(monkeypatch, signal=ExitSignal("SELL_ALL", "손절 테스트", 1.0))
|
|
_mark_recent_exit(env.es)
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent == [("005930", 10)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_signal_still_blocked_by_cooldown(monkeypatch, _reset):
|
|
env = _env(monkeypatch, signal=ExitSignal("SELL_PARTIAL", "1차 익절", 0.5), price=11000)
|
|
_mark_recent_exit(env.es)
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_signal_after_cooldown_expires(monkeypatch, _reset):
|
|
env = _env(monkeypatch, signal=ExitSignal("SELL_PARTIAL", "1차 익절", 0.5), price=11000)
|
|
env.es._recent_exits["005930"] = time.time() - env.es.EXIT_COOLDOWN_SEC - 1
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent == [("005930", 5)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capped_stop_loss_remainder_sold_next_cycle_within_cooldown(monkeypatch, _reset):
|
|
"""KIS 주문가능 상한(4주)에 걸려 일부만 팔린 손절 → 다음 주기(쿨다운 안)에 잔량 손절 진행.
|
|
잔량 수량은 KIS 실보유 상한으로만 결정(장부 10주가 낡아도 6주만) — 과다매도 없음."""
|
|
env = _env(monkeypatch, signal=ExitSignal("SELL_ALL", "손절 테스트", 1.0),
|
|
kis=[(10, 4), (6, 6)])
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent == [("005930", 4)]
|
|
assert "005930" in env.es._recent_exits # 쿨다운 기록됨
|
|
# 장부(Portfolio)는 아직 sync 전이라 10주 그대로 — KIS 상한이 6주로 막아야 한다
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent == [("005930", 4), ("005930", 6)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_after_partial_take_profit_not_blocked(monkeypatch, _reset):
|
|
"""1차 익절 직후 급락 → 같은 쿨다운 창 안의 손절이 즉시 나간다(수량은 KIS 실보유 상한)."""
|
|
env = _env(monkeypatch, signal=ExitSignal("SELL_PARTIAL", "1차 익절", 0.5), price=11000,
|
|
kis=[(10, 10), (5, 5)])
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent == [("005930", 5)]
|
|
env.sig["s"] = ExitSignal("SELL_ALL", "손절 테스트", 1.0)
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent[-1] == ("005930", 5)
|
|
assert len(env.sent) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bypass_respects_kis_zero_cap(monkeypatch, _reset):
|
|
"""쿨다운 우회여도 KIS 매도 가능 0 이면 주문하지 않는다(직전 시장가 미체결 등)."""
|
|
env = _env(monkeypatch, signal=ExitSignal("SELL_ALL", "손절 테스트", 1.0), kis=(10, 0))
|
|
_mark_recent_exit(env.es)
|
|
await env.es.check_and_execute_exit_v2(MagicMock())
|
|
assert env.sent == []
|