- 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>
480 lines
22 KiB
Python
480 lines
22 KiB
Python
"""스윙 5·6회차 — 결과 불명 매도·DT 물량 과다매도 방지 회귀 테스트 (2026-10-02).
|
||
|
||
모든 시나리오는 상태를 가진 KIS 시뮬레이터(보유/주문가능 수량이 주문에 따라 실제로 줄어듦)
|
||
위에서 **3주기 이상** 돌린다. 5회차 수정은 확인 대기를 1주기만 보호해 3주기째 DT 물량을 팔았다
|
||
(6회차 C-1 재현 ①②). 6회차 수정:
|
||
(c) 국내 매도 직전 항상 KIS 실보유 확인, 상한 = min(주문가능, 보유) − DT 인메모리
|
||
(a) 결과 불명 체결분을 KIS 보유 감소로 확인해 장부(TradeHistory·reflect_sell·ActiveTrade·
|
||
loss_tracker) 반영 후 그 주기 skip
|
||
(b) 결과 불명 직후 Portfolio sync 요청
|
||
|
||
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order). 기대값은 settings.*.
|
||
"""
|
||
import asyncio
|
||
import logging
|
||
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.core.config import settings
|
||
from app.services.mean_reversion_strategy import ExitSignal
|
||
|
||
UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout", "odno": None}
|
||
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
|
||
|
||
|
||
_FILL_CHECKS: list = []
|
||
|
||
|
||
async def _record_fill_check(order_id, stock_code, stock_name, quantity, price, side, *args, **kwargs):
|
||
"""체결 확인 종단 기록형 목 — 즉시 완료(대기·KIS 조회 없음)."""
|
||
_FILL_CHECKS.append({"order_id": order_id, "stock_code": stock_code, "quantity": quantity,
|
||
"side": side, "kwargs": kwargs})
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset(monkeypatch):
|
||
import app.services.swing.exit_service as es
|
||
monkeypatch.setattr(es, "_pending_unknown_sells", {})
|
||
monkeypatch.setattr(es, "_last_live_prices", {})
|
||
monkeypatch.setattr(es, "_last_atr", {})
|
||
monkeypatch.setattr(es, "_presell_check_failures", {})
|
||
monkeypatch.setattr(es, "_no_price_skips", {})
|
||
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())
|
||
# [하드닝 R2 2026-10-09] 성공한 시장가 매도는 _schedule_swing_sell_fill_check_v2 →
|
||
# spawn_tracked_task_v2(_verify_and_sync_execution_v3(..., delay=3)) 로 **실제** 체결 확인
|
||
# 코루틴을 띄운다. 목이 없으면 테스트 종료 후 pending 으로 남고(“Task was destroyed but it is
|
||
# pending”), 3초가 지나면 get_order_execution(KIS)·enqueue_update_trade_history 를 호출해
|
||
# conftest 차단에만 의존했다(§4.1 위반). 공통 종단(_verify_and_sync_execution_v3)에 기록형 목.
|
||
_FILL_CHECKS.clear()
|
||
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _record_fill_check)
|
||
|
||
|
||
class _Sim:
|
||
"""KIS 계좌 + 앱 장부 시뮬레이터."""
|
||
|
||
def __init__(self, monkeypatch, *, swing, dt, price=8000, avg=10000, signal=None,
|
||
responses=None, fill=None, balance_fail=False):
|
||
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
|
||
|
||
self.es = es
|
||
self.kis = {"hldg": swing + dt, "psbl": swing + dt}
|
||
self.sent = [] # 브로커에 실제 도달한 주문 수량
|
||
self.history = [] # enqueue_trade_history payload
|
||
self.closed = 0
|
||
self.balance_fail = balance_fail
|
||
self.responses = list(responses or [])
|
||
self.fill = fill # 주문별 실제 체결 수량 함수(q, n) → filled
|
||
# 장부: Portfolio 행(sync 없음 — reflect_sell 이 장부 수량만 줄인다)
|
||
self.holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=swing + dt,
|
||
average_price=avg, current_price=price, market="KRX",
|
||
updated_at=now_kst())
|
||
self.at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None,
|
||
created_at=now_kst() - timedelta(days=1), entry_reason=None)
|
||
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(side_effect=lambda db: [self.holding]))
|
||
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
|
||
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(side_effect=lambda db, c: self.at))
|
||
api = MagicMock()
|
||
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}})
|
||
bars = [{"stck_clpr": str(avg), "stck_hgpr": str(avg + 50), "stck_lwpr": str(avg - 50)}
|
||
for _ in range(25)]
|
||
api.get_daily_chart = AsyncMock(return_value={"output2": bars if signal else []})
|
||
api.get_balance = AsyncMock(side_effect=self._balance)
|
||
api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유
|
||
self.api = api
|
||
monkeypatch.setattr(es, "korea_investment_api", api)
|
||
if signal is not None:
|
||
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: signal(kw["remaining_pct"]))
|
||
monkeypatch.setattr(day_trading_state, "positions", {"005930": {"quantity": dt}} if dt else {})
|
||
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
|
||
monkeypatch.setattr(te, "safe_place_order", self._order)
|
||
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock(side_effect=lambda p: self.history.append(p)))
|
||
|
||
async def _close(code, strategy_name=None):
|
||
self.closed += 1
|
||
self.at = None
|
||
monkeypatch.setattr(es, "enqueue_close_active_trade", _close)
|
||
|
||
async def _upsert(p):
|
||
if self.at is not None and "remaining_pct" in p:
|
||
self.at.remaining_pct = p["remaining_pct"]
|
||
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _upsert)
|
||
|
||
async def _reflect(code, q, price, remaining_qty=None):
|
||
self.holding.quantity = max(0, self.holding.quantity - q)
|
||
monkeypatch.setattr(pcb, "reflect_sell", _reflect)
|
||
self.loss = AsyncMock()
|
||
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", self.loss)
|
||
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
|
||
|
||
async def _balance(self):
|
||
if self.balance_fail:
|
||
raise RuntimeError("KIS down")
|
||
return {"rt_cd": "0", "output1": [{"pdno": "005930", "hldg_qty": str(self.kis["hldg"]),
|
||
"ord_psbl_qty": str(self.kis["psbl"])}]}
|
||
|
||
async def _order(self, **kw):
|
||
q = kw["quantity"]
|
||
if q > self.kis["psbl"]:
|
||
return {"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능수량 초과"}
|
||
n = len(self.sent)
|
||
filled = self.fill(q, n) if self.fill else q
|
||
self.kis["hldg"] -= filled
|
||
self.kis["psbl"] -= filled
|
||
self.sent.append(filled)
|
||
return self.responses[n] if n < len(self.responses) else OK
|
||
|
||
async def cycles(self, n):
|
||
for _ in range(n):
|
||
self.es._recent_exits.clear()
|
||
await self.es.check_and_execute_exit_v2(MagicMock())
|
||
|
||
|
||
def _partial_signal(remaining):
|
||
return ExitSignal("SELL_PARTIAL", "1차 익절", 0.5) if remaining > 0.5 else None
|
||
|
||
|
||
# ── C-1 재현 ① : 손절 전량 불명(체결) → 3주기째 DT 물량 매도 ────────────────
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c1_unknown_full_stop_never_sells_dt_shares_over_4_cycles(monkeypatch):
|
||
"""[재현 반전] 스윙 10 + DT 10, 손절 10주 결과 불명(실제 체결). 이전: 3주기째 DT 10주 매도
|
||
(합계 20). 이제: 2주기에 체결분 장부 반영(COMPLETED·close·loss_tracker), 이후 KIS 상한 0."""
|
||
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN])
|
||
await sim.cycles(4)
|
||
assert sum(sim.sent) == 10 and sim.kis["hldg"] == 10 # DT 10주 보존
|
||
done = [h for h in sim.history if h["status"] == "COMPLETED" and h["executed_quantity"] == 10]
|
||
assert len(done) == 1 and "체결 확인" in done[0]["exit_reason"]
|
||
assert sim.closed == 1
|
||
sim.loss.assert_awaited_once()
|
||
assert "005930" not in sim.es._pending_unknown_sells
|
||
|
||
|
||
# ── C-1 재현 ② : 부분익절 불명(체결) → 같은 단계 재발동 → 전량 매도 ────────
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c1_unknown_partial_does_not_refire_stage_over_4_cycles(monkeypatch):
|
||
"""[재현 반전] 스윙 20 + DT 5, 50% 익절 10주 불명(체결). 이전: remaining_pct 미갱신으로
|
||
같은 단계 재발동 → 25주 전량. 이제: 대조 시 remaining_pct=0.5 기록 → 재발동 없음."""
|
||
sim = _Sim(monkeypatch, swing=20, dt=5, price=11500, signal=_partial_signal, responses=[UNKNOWN])
|
||
await sim.cycles(4)
|
||
assert sum(sim.sent) == 10
|
||
assert sim.kis["hldg"] == 15
|
||
assert sim.at.remaining_pct == 0.5
|
||
assert sim.closed == 0
|
||
sim.loss.assert_not_awaited() # 분할매도는 loss_tracker 기록 대상 아님
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c1_unknown_not_filled_resells_once(monkeypatch):
|
||
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[UNKNOWN], fill=lambda q, n: 0 if n == 0 else q)
|
||
await sim.cycles(3)
|
||
assert sim.sent == [0, 10] # 1주기 미체결 → 2주기 재매도(스윙 10주만)
|
||
assert sim.kis["hldg"] == 5
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c1_unknown_partial_fill_reconciled_then_remainder_sold(monkeypatch):
|
||
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN], fill=lambda q, n: 3 if n == 0 else q)
|
||
await sim.cycles(4)
|
||
assert sim.sent == [3, 7]
|
||
assert sim.kis["hldg"] == 10 # DT 10주 보존
|
||
assert any(h["executed_quantity"] == 3 and "체결 확인" in h["exit_reason"] for h in sim.history)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c1_open_order_at_broker_blocks_resell(monkeypatch):
|
||
"""불명 주문이 브로커에 미체결로 남음(보유 불변·주문가능 감소) → 대기 유지, 상한 0."""
|
||
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN], fill=lambda q, n: 0)
|
||
|
||
orig = sim._order
|
||
|
||
async def _open(**kw):
|
||
r = await orig(**kw)
|
||
sim.kis["psbl"] -= kw["quantity"] # 미체결 주문분이 주문가능에서 빠짐
|
||
return r
|
||
import app.services.trade_executor as te
|
||
monkeypatch.setattr(te, "safe_place_order", _open)
|
||
await sim.cycles(3)
|
||
assert len(sim.sent) == 1
|
||
assert "005930" in sim.es._pending_unknown_sells
|
||
|
||
|
||
# ── (c) 불명 없이도: 장부가 부풀어 있으면 KIS 기준 상한 ─────────────────────
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c_inflated_ledger_capped_by_kis(monkeypatch):
|
||
sim = _Sim(monkeypatch, swing=10, dt=5)
|
||
sim.kis = {"hldg": 12, "psbl": 12} # 외부 매도 등으로 실보유 12 (장부 15)
|
||
await sim.cycles(3)
|
||
assert sim.sent == [7] # 12 − DT 5
|
||
assert sim.kis["hldg"] == 5
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c_balance_failure_holds_and_escalates(monkeypatch, caplog):
|
||
sim = _Sim(monkeypatch, swing=10, dt=5, balance_fail=True)
|
||
n = int(settings.SWING_PRESELL_HOLDING_CHECK_ERROR_AFTER)
|
||
with caplog.at_level(logging.WARNING, logger=sim.es.logger.name):
|
||
await sim.cycles(n)
|
||
assert sim.sent == []
|
||
msgs = [r for r in caplog.records if "KIS 잔고 확인 실패" in r.getMessage()]
|
||
assert [r.levelno for r in msgs] == [logging.WARNING] * (n - 1) + [logging.ERROR]
|
||
sim.balance_fail = False
|
||
await sim.cycles(1)
|
||
assert sim.sent == [10]
|
||
assert sim.es._presell_check_failures == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_c_balance_failure_while_pending_holds(monkeypatch):
|
||
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN])
|
||
await sim.cycles(1)
|
||
sim.balance_fail = True
|
||
await sim.cycles(3)
|
||
assert sum(sim.sent) == 10 and "005930" in sim.es._pending_unknown_sells
|
||
sim.balance_fail = False
|
||
await sim.cycles(2)
|
||
assert sum(sim.sent) == 10 and sim.closed == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("bal", [{"rt_cd": "1", "msg1": "err"}, {"rt_cd": "0"}, None])
|
||
async def test_c_bad_balance_response_holds(monkeypatch, bal):
|
||
sim = _Sim(monkeypatch, swing=10, dt=5)
|
||
sim.api.get_balance = AsyncMock(return_value=bal)
|
||
sim.api.get_balance_all_v2 = sim.api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유
|
||
await sim.cycles(2)
|
||
assert sim.sent == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_b_unknown_triggers_portfolio_sync(monkeypatch):
|
||
import app.services.trade_executor as te
|
||
sim = _Sim(monkeypatch, swing=10, dt=0, responses=[UNKNOWN])
|
||
await sim.cycles(1)
|
||
await asyncio.sleep(0)
|
||
te._delayed_sync_portfolio.assert_called()
|
||
|
||
|
||
# ── 분류: 전송 전 실패·업무 거부는 재시도, 불명은 대기 ───────────────────────
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("resp", [
|
||
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT", "msg1": "connect"},
|
||
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CIRCUIT_OPEN", "msg1": "cb"},
|
||
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_EXCEPTION", "msg1": "x"},
|
||
{"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE", "msg1": "token"},
|
||
{"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"},
|
||
{"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능수량 초과"},
|
||
])
|
||
async def test_presend_and_business_reject_retry_next_cycle(monkeypatch, resp):
|
||
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[resp], fill=lambda q, n: 0 if n == 0 else q)
|
||
await sim.cycles(1)
|
||
assert "005930" not in sim.es._pending_unknown_sells
|
||
await sim.cycles(2)
|
||
assert sim.sent == [0, 10] and sim.kis["hldg"] == 5
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_msgcd_less_minus_one_is_pending(monkeypatch):
|
||
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[{"rt_cd": "-1", "msg1": "wrapped"}])
|
||
await sim.cycles(1)
|
||
assert "005930" in sim.es._pending_unknown_sells
|
||
await sim.cycles(2)
|
||
assert sum(sim.sent) == 10 and sim.kis["hldg"] == 5
|
||
|
||
|
||
@pytest.mark.parametrize("resp,expected", [
|
||
({"rt_cd": "0"}, "accepted"),
|
||
(UNKNOWN, "unknown"),
|
||
({"rt_cd": "-1"}, "unknown"),
|
||
(None, "unknown"),
|
||
({}, "unknown"),
|
||
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT"}, "retry"),
|
||
({"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}, "retry"),
|
||
({"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY"}, "retry"),
|
||
({"rt_cd": "-1", "msg1": "order_lock_busy"}, "retry"),
|
||
({"rt_cd": "1", "msg_cd": "APBK0013"}, "retry"),
|
||
])
|
||
def test_classify_swing_sell_response_v2(resp, expected):
|
||
import app.services.swing.exit_service as es
|
||
assert es._classify_swing_sell_response_v2(resp) == expected
|
||
|
||
|
||
# ── H-1(c) 전량 청산 시 캐시 폐기 / M-4 shield ─────────────────────────────
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_close_drops_price_and_atr_cache(monkeypatch):
|
||
sim = _Sim(monkeypatch, swing=10, dt=0)
|
||
import time as _t
|
||
from app.core.timeutil import now_kst
|
||
sim.es._last_atr["005930"] = (100.0, _t.monotonic(), now_kst())
|
||
await sim.cycles(1)
|
||
assert sim.sent == [10] and sim.closed == 1
|
||
assert "005930" not in sim.es._last_live_prices
|
||
assert "005930" not in sim.es._last_atr
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_m4_cancel_during_order_still_records(monkeypatch):
|
||
"""주문 대기 중 루프가 취소돼도 shield 된 작업이 TradeHistory·ActiveTrade 반영까지 완주."""
|
||
sim = _Sim(monkeypatch, swing=10, dt=0)
|
||
gate = asyncio.Event()
|
||
entered = asyncio.Event()
|
||
orig = sim._order
|
||
|
||
async def _slow(**kw):
|
||
entered.set()
|
||
await gate.wait()
|
||
return await orig(**kw)
|
||
import app.services.trade_executor as te
|
||
monkeypatch.setattr(te, "safe_place_order", _slow)
|
||
sim.es._recent_exits.clear()
|
||
task = asyncio.ensure_future(sim.es.check_and_execute_exit_v2(MagicMock()))
|
||
await asyncio.wait_for(entered.wait(), 2)
|
||
task.cancel()
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await task
|
||
gate.set()
|
||
for _ in range(50):
|
||
if sim.closed:
|
||
break
|
||
await asyncio.sleep(0)
|
||
assert len(sim.history) == 1 and sim.closed == 1
|
||
|
||
|
||
# ── [2026-10-09 R1] 연속손실 판정 = 비용 차감 순손익 (CLAUDE.md §5.1) ─────────
|
||
|
||
def _full_signal(remaining):
|
||
return ExitSignal("SELL_ALL", "전량 익절", 1.0)
|
||
|
||
|
||
def _expected_net(avg, price, qty):
|
||
from app.services.trade_helpers import calculate_trade_costs
|
||
costs = calculate_trade_costs(entry_price=avg, exit_price=price, quantity=qty,
|
||
is_domestic=True, market="KOSPI")
|
||
return (price - avg) * qty - costs["total_cost"], costs["total_cost"]
|
||
|
||
|
||
def test_r1_swing_net_pnl_matches_trade_helpers_and_flips_small_gain():
|
||
"""+0.1% 는 총손익 양수지만 왕복 비용(수수료×2+매도세) 차감 후 음수. 비용 정의는
|
||
trade_helpers.calculate_trade_costs 와 동일(KOSPI/KOSDAQ 세율 모두)."""
|
||
import app.services.swing.exit_service as es
|
||
from app.services.trade_helpers import calculate_trade_costs
|
||
for mkt, label in (("KRX", "KOSPI"), ("KOSDAQ", "KOSDAQ")):
|
||
r = es._swing_net_pnl_v2(10000, 10010, 10, mkt, domestic=True)
|
||
c = calculate_trade_costs(entry_price=10000, exit_price=10010, quantity=10,
|
||
is_domestic=True, market=label)
|
||
assert r["gross_profit_loss"] == 100
|
||
assert r["total_cost"] == c["total_cost"]
|
||
assert r["net_profit_loss"] == 100 - c["total_cost"]
|
||
assert r["net_profit_loss"] < 0
|
||
# 해외는 거래세 없음(trade_helpers 와 동일)
|
||
r = es._swing_net_pnl_v2(100, 101, 10, "NASD", domestic=False)
|
||
c = calculate_trade_costs(entry_price=100, exit_price=101, quantity=10, is_domestic=False)
|
||
assert r["total_cost"] == c["total_cost"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_r1_full_exit_within_cost_band_counts_as_loss(monkeypatch):
|
||
"""[재현 반전] 스윙 10주 +0.1% 전량 청산. 이전: is_loss=False(총손익 +100원), net_profit_loss
|
||
에 총손익 기록. 이제: 순손익 기준 is_loss=True, net_profit_loss·commission 은 비용 차감값."""
|
||
avg, price = 10000, 10010
|
||
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
|
||
await sim.cycles(1)
|
||
assert sim.sent == [10]
|
||
net, cost = _expected_net(avg, price, 10)
|
||
assert net < 0 < (price - avg) * 10
|
||
sim.loss.assert_awaited_once()
|
||
assert sim.loss.await_args.kwargs["is_loss"] is True
|
||
sell = [h for h in sim.history if h["order_type"] == "SELL"]
|
||
assert len(sell) == 1
|
||
assert sell[0]["net_profit_loss"] == net
|
||
assert sell[0]["commission"] == cost
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_r1_full_exit_clear_gain_is_not_loss(monkeypatch):
|
||
"""비용을 넘는 이익(+5%)은 여전히 승 — 판정이 과도하게 뒤집히지 않음."""
|
||
avg, price = 10000, 10500
|
||
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
|
||
await sim.cycles(1)
|
||
sim.loss.assert_awaited_once()
|
||
assert sim.loss.await_args.kwargs["is_loss"] is False
|
||
net, _ = _expected_net(avg, price, 10)
|
||
assert [h for h in sim.history if h["order_type"] == "SELL"][0]["net_profit_loss"] == net
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_r1_unknown_sell_reconcile_uses_net_pnl(monkeypatch):
|
||
"""결과 불명 전량 매도가 KIS 잔고로 체결 확인되는 경로도 순손익 기준으로 판정·기록."""
|
||
avg, price = 10000, 10010
|
||
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal,
|
||
responses=[UNKNOWN])
|
||
await sim.cycles(3)
|
||
assert sum(sim.sent) == 10
|
||
sim.loss.assert_awaited_once()
|
||
assert sim.loss.await_args.kwargs["is_loss"] is True
|
||
done = [h for h in sim.history if "체결 확인" in (h.get("exit_reason") or "")]
|
||
assert len(done) == 1
|
||
net, cost = _expected_net(avg, price, 10)
|
||
assert done[0]["net_profit_loss"] == net and done[0]["commission"] == cost
|
||
|
||
|
||
# ── 하드닝 R2 (2026-10-09): 체결 확인 백그라운드 태스크가 테스트 밖으로 새지 않는다 ─────
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_r2_fill_check_terminal_is_mocked_and_task_completes(monkeypatch):
|
||
"""성공 매도 → 체결 확인 예약은 기록형 목 종단으로 수렴하고, swing_sell_fill 태스크가
|
||
테스트 종료 전에 끝난다(실 _verify_and_sync_execution_v3 미실행 → KIS 조회 0)."""
|
||
import app.services.trade_executor as te
|
||
assert te._verify_and_sync_execution_v3 is _record_fill_check
|
||
sim = _Sim(monkeypatch, swing=10, dt=0)
|
||
await sim.cycles(1)
|
||
assert sim.sent == [10] and sim.closed == 1
|
||
assert len(_FILL_CHECKS) == 1
|
||
fc = _FILL_CHECKS[0]
|
||
assert fc["stock_code"] == "005930" and fc["quantity"] == 10 and fc["side"] == "SELL"
|
||
assert fc["kwargs"]["sell_pnl_basis"]["strategy_type"] == "swing"
|
||
# 기록형 목은 즉시 완료 — 한 틱 양보 후 남은 swing_sell_fill 태스크가 없어야 한다
|
||
await asyncio.sleep(0)
|
||
await asyncio.sleep(0)
|
||
pending = [t for t in asyncio.all_tasks()
|
||
if (t.get_name() or "").startswith("swing_sell_fill:") and not t.done()]
|
||
assert pending == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_r2_fill_check_real_coroutine_would_hit_kis_is_not_reached(monkeypatch):
|
||
"""종단 목이 빠지면 실 코루틴이 get_order_execution 을 부른다는 전제를 예외 발생형 목으로
|
||
고정 — 이 파일의 매도 경로가 그 메서드에 도달하지 않음을 증명한다."""
|
||
sim = _Sim(monkeypatch, swing=10, dt=0)
|
||
|
||
async def _boom(*a, **k):
|
||
raise AssertionError("get_order_execution 실호출 경로 도달")
|
||
sim.api.get_order_execution = AsyncMock(side_effect=_boom)
|
||
import app.services.trade_executor as te
|
||
monkeypatch.setattr(te, "korea_investment_api", sim.api, raising=False)
|
||
await sim.cycles(1)
|
||
await asyncio.sleep(0)
|
||
sim.api.get_order_execution.assert_not_called()
|
||
assert len(_FILL_CHECKS) == 1
|
||
|
||
|
||
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)
|