- 스윙 체결가 확인 전 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
459 lines
21 KiB
Python
459 lines
21 KiB
Python
"""[4회차 2026-10-02] 검증 결함 회귀 — H-1 / M-2 / M-3 / L-2 / L-3 / L-4.
|
||
|
||
(M-1 reset 경합·L-1 장외 축소는 tests/test_mdd_reduction_retry_20261002.py)
|
||
주문·취소 종단은 trade_executor 가 실제로 참조하는 `te.korea_investment_api` 에 건다.
|
||
"""
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
|
||
from app.core.config import settings
|
||
from app.services import trade_executor as te
|
||
|
||
|
||
class _MustNotCall(BaseException):
|
||
pass
|
||
|
||
|
||
def _boom(name):
|
||
async def _f(*a, **k):
|
||
raise _MustNotCall(f"{name} 가 호출되면 안 된다")
|
||
return _f
|
||
|
||
|
||
async def _nosleep(*a, **k):
|
||
return None
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# H-1 (a)(b): 매도 가격 — 낡은 배치가 대신 시장가 / 주문 직전 재산정
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
def test_h1_price_policy_pure():
|
||
# 전량 청산 → 시장가
|
||
assert te._resolve_sell_order_price_v2("005930", 70000, True, True, 5.0)[1:] == (None, "market")
|
||
# 손실 청산(부분이어도) → 시장가
|
||
assert te._resolve_sell_order_price_v2("005930", 70000, True, False, -3.0)[1:] == (None, "market")
|
||
# 부분 익절 → 재산정
|
||
assert te._resolve_sell_order_price_v2("005930", 70000, True, False, 4.0)[2] == "limit_refresh"
|
||
# 해외 → 기존 지정가 유지
|
||
ref, px, kind = te._resolve_sell_order_price_v2("AAPL", 100.0, False, True, -5.0)
|
||
assert kind == "limit_overseas" and px == te._adjust_order_price(100.0, "SELL", False)
|
||
|
||
|
||
class _Scal:
|
||
def first(self):
|
||
return None
|
||
|
||
def all(self):
|
||
return []
|
||
|
||
|
||
class _SellDB:
|
||
async def execute(self, stmt):
|
||
return SimpleNamespace(scalars=lambda: _Scal(), scalar_one_or_none=lambda: None)
|
||
|
||
|
||
@pytest.fixture
|
||
def sell_env(monkeypatch):
|
||
import app.services.db_worker as dbw
|
||
from app.services.loss_tracker import loss_tracker
|
||
class _L(list):
|
||
pass
|
||
orders = _L()
|
||
|
||
async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3):
|
||
orders.append({"price": price, "quantity": quantity})
|
||
return {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}}
|
||
monkeypatch.setattr(te, "_place_order_with_retry", _po)
|
||
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True))
|
||
monkeypatch.setattr(te, "_release_order_lock", AsyncMock(return_value=None))
|
||
# [하드닝 R1] 매도 직전 KIS 실보유 상한·공용 청산 쿨다운 — 상한 넉넉히, 쿨다운 상태 격리
|
||
import app.services.swing.exit_service as _es
|
||
monkeypatch.setattr(te, "_recommendation_sell_kis_cap_v2", AsyncMock(return_value=(10**6, 10**6)))
|
||
monkeypatch.setattr(_es, "_recent_exits", {})
|
||
|
||
async def _noop(*a, **k):
|
||
return None
|
||
verifies = []
|
||
|
||
async def _cap_verify(*a, **k):
|
||
verifies.append(k)
|
||
monkeypatch.setattr(te, "_verify_and_sync_execution", _cap_verify)
|
||
monkeypatch.setattr(te, "_verify_and_sync_execution_v2", _cap_verify)
|
||
# [2026-10-08] 매수·매도 경로가 체결 기준 금액·손익 갱신용 v3 를 직접 호출한다(공통 종단)
|
||
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _cap_verify)
|
||
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
|
||
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop)
|
||
monkeypatch.setattr(dbw, "enqueue_close_active_trade", _noop)
|
||
monkeypatch.setattr(dbw, "enqueue_db_write", _noop)
|
||
monkeypatch.setattr(loss_tracker, "record_trade_result_safe", AsyncMock())
|
||
monkeypatch.setattr(loss_tracker, "save_to_db", AsyncMock())
|
||
from app.services.daytrading.position_manager import day_trading_state
|
||
day_trading_state.positions.pop("005930", None)
|
||
orders.verifies = verifies # type: ignore[attr-defined]
|
||
return orders
|
||
|
||
|
||
def _holding(qty=10):
|
||
return SimpleNamespace(quantity=qty, average_price=60000, profit_loss_rate=0,
|
||
strategy_type="swing", stock_name="삼성", current_price=70000)
|
||
|
||
|
||
async def _sell(sell_pct, current_price=70000, avg=60000):
|
||
h = _holding()
|
||
h.average_price = avg
|
||
stock = {"stock_code": "005930", "strategy_type": "swing", "strategy_name": "swing",
|
||
"remaining_pct": 1.0}
|
||
return await te._execute_sell(
|
||
stock, _SellDB(), h, current_price, ["x"], True, "KRX", "005930", "삼성", sell_pct=sell_pct,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1_full_exit_is_market_order(sell_env, monkeypatch):
|
||
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price"))
|
||
await _sell(1.0)
|
||
assert sell_env[-1]["price"] is None, "전량 청산이 낡은 배치가 지정가로 나갔다"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1_partial_take_profit_reprices_before_order(sell_env, monkeypatch):
|
||
monkeypatch.setattr(te.korea_investment_api, "get_current_price",
|
||
AsyncMock(return_value={"output": {"stck_prpr": "65000"}}))
|
||
await _sell(0.5)
|
||
assert sell_env[-1]["price"] == te._adjust_order_price(65000, "SELL", True), \
|
||
"부분 익절 지정가가 주문 직전 현재가로 재산정되지 않았다(배치가 70000 사용)"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1_partial_reprice_failure_falls_back_to_market(sell_env, monkeypatch):
|
||
async def _fail(*a, **k):
|
||
raise RuntimeError("시세 실패")
|
||
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _fail)
|
||
await _sell(0.5)
|
||
assert sell_env[-1]["price"] is None
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# H-1 (c) / L-4: 체결 확인 — 미체결 매도 취소, 불성립 매수 CANCELLED
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
@pytest.fixture
|
||
def verify_env(monkeypatch):
|
||
import app.services.db_worker as dbw
|
||
updates = []
|
||
|
||
async def _upd(order_id, fields):
|
||
updates.append((order_id, dict(fields)))
|
||
monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd)
|
||
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
|
||
|
||
async def _noop(*a, **k):
|
||
return None
|
||
monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop)
|
||
return updates
|
||
|
||
|
||
def _exec(filled, remaining, found=True):
|
||
return AsyncMock(return_value={"ok": True, "found": found, "filled_qty": filled,
|
||
"filled_price": 70000, "remaining_qty": remaining})
|
||
|
||
|
||
async def _flush_tasks():
|
||
import asyncio as _aio
|
||
await _aio.sleep(0)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1r5_market_full_exit_never_cancelled(sell_env, monkeypatch):
|
||
"""[5회차] 시장가(전량·손절) 매도는 미체결 취소 대상이 아니다(종가 단일가·VI·하한가)."""
|
||
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price"))
|
||
await _sell(1.0)
|
||
await _flush_tasks()
|
||
assert sell_env.verifies and sell_env.verifies[-1]["cancel_unfilled_limit"] is False
|
||
assert sell_env.verifies[-1]["rollback_active_trade"] is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1r5_limit_partial_passes_cancel_and_rollback_snapshot(sell_env, monkeypatch):
|
||
monkeypatch.setattr(te.korea_investment_api, "get_current_price",
|
||
AsyncMock(return_value={"output": {"stck_prpr": "65000"}}))
|
||
await _sell(0.5)
|
||
await _flush_tasks()
|
||
k = sell_env.verifies[-1]
|
||
assert k["cancel_unfilled_limit"] is True
|
||
snap = k["rollback_active_trade"]
|
||
assert snap["quantity"] == 10 and snap["remaining_pct"] == 1.0
|
||
assert snap.get("strategy_name") == "swing", "롤백이 다른 전략 행을 덮으면 안 된다"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1r5_v1_verify_never_cancels(verify_env, monkeypatch):
|
||
"""기존 시그니처(_verify_and_sync_execution) = 시장가·종류 불명 경로 → 잔량이 살아 있어도 취소 금지."""
|
||
k = te.korea_investment_api
|
||
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
|
||
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(시장가)"))
|
||
await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "SELL", True, "KRX", 0, delay=0)
|
||
assert verify_env == []
|
||
|
||
|
||
_SNAP = {"stock_code": "005930", "strategy_name": "swing", "remaining_pct": 1.0,
|
||
"quantity": 10, "highest_since_entry": 72000.0}
|
||
|
||
|
||
@pytest.fixture
|
||
def at_capture(monkeypatch):
|
||
import app.services.db_worker as dbw
|
||
cap = []
|
||
|
||
async def _u(p):
|
||
cap.append(dict(p))
|
||
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u)
|
||
return cap
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1r5_limit_unfilled_cancel_rolls_back_active_trade(verify_env, at_capture, monkeypatch):
|
||
k = te.korea_investment_api
|
||
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
|
||
cancel = AsyncMock(return_value={"rt_cd": "0"})
|
||
monkeypatch.setattr(k, "cancel_order_v2", cancel)
|
||
await te._verify_and_sync_execution_v2(
|
||
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
|
||
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP),
|
||
)
|
||
cancel.assert_awaited_once_with("0001", org_no=None, quantity=5, stock_code="005930")
|
||
assert verify_env == [("0001", {"status": "CANCELLED", "executed_quantity": 0})]
|
||
assert at_capture == [_SNAP], "0체결 취소인데 ActiveTrade 가 주문 전 상태로 원복되지 않았다"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1r5_limit_partial_fill_cancel_fixes_quantity(verify_env, at_capture, monkeypatch):
|
||
k = te.korea_investment_api
|
||
monkeypatch.setattr(k, "get_order_execution", _exec(2, 3))
|
||
monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "0"}))
|
||
await te._verify_and_sync_execution_v2(
|
||
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
|
||
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP),
|
||
)
|
||
assert verify_env and verify_env[-1][1]["status"] == "COMPLETED"
|
||
assert verify_env[-1][1]["executed_quantity"] == 2
|
||
assert at_capture == [{"stock_code": "005930", "strategy_name": "swing", "quantity": 8}]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1r5_cancel_failure_logs_error_no_rollback(verify_env, at_capture, monkeypatch, caplog):
|
||
k = te.korea_investment_api
|
||
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
|
||
monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "1", "msg1": "취소불가"}))
|
||
with caplog.at_level("ERROR"):
|
||
await te._verify_and_sync_execution_v2(
|
||
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
|
||
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP),
|
||
)
|
||
assert verify_env == [] and at_capture == []
|
||
assert any("취소 거부" in r.message for r in caplog.records)
|
||
|
||
|
||
def test_l5_lookup_matched_order_is_never_cancel_target():
|
||
import inspect
|
||
src = inspect.getsource(te._execute_sell)
|
||
i = src.index("_cancel_unfilled_limit = (")
|
||
assert 'not order_result.get("_matched_by_lookup")' in src[i:i + 300]
|
||
assert "order_price is not None" in src[i:i + 300] and "not _full_exit" in src[i:i + 300]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_h1_buy_live_remainder_is_not_cancelled(verify_env, monkeypatch):
|
||
k = te.korea_investment_api
|
||
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
|
||
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(BUY)"))
|
||
await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0)
|
||
assert verify_env == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_l4_dead_buy_marked_cancelled(verify_env, monkeypatch):
|
||
k = te.korea_investment_api
|
||
monkeypatch.setattr(k, "get_order_execution", _exec(0, 0))
|
||
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order"))
|
||
await te._verify_and_sync_execution("0002", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0)
|
||
assert verify_env == [("0002", {"status": "CANCELLED", "executed_quantity": 0})]
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# M-3: 일일 스윙 한도에 반영 지연 중인 사이클 매수 합산
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_m3_daily_count_includes_inflight_swing_buys():
|
||
class _DB:
|
||
async def execute(self, stmt):
|
||
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: ["000001"]))
|
||
te._recent_cycle_buys.clear()
|
||
try:
|
||
for code, st in (("000001", "swing"), ("000002", "swing"), ("000003", "manual"), ("AAPL", "swing")):
|
||
te._note_cycle_buy_v2(code, code, 1, 1000)
|
||
te._recent_cycle_buys[code]["strategy_type"] = st
|
||
te._note_cycle_buy_v2("000004", "D", 1, 1000) # 태그 없음(레거시) = 스윙 소유로 셈
|
||
# DB 000001 ∪ 지연 중 스윙 000002·000004 (manual·해외 제외, 000001 중복 제외)
|
||
assert await te._count_swing_buys_today_v2(_DB()) == 3
|
||
finally:
|
||
te._recent_cycle_buys.clear()
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# M-2 / L-2 / L-3: 루프
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
class _LoopDB:
|
||
def __init__(self, held):
|
||
self.held = held
|
||
|
||
async def execute(self, stmt):
|
||
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: list(self.held)))
|
||
|
||
|
||
@pytest.fixture
|
||
def loop_env(monkeypatch):
|
||
monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False)
|
||
monkeypatch.setattr(settings, "MAX_DAILY_TRADES", 2)
|
||
monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (20, 1.0))
|
||
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
|
||
te._order_reject_cooldown.clear()
|
||
te._account_reject_at = None
|
||
te._eval_rotation_offset = 0
|
||
seen = []
|
||
|
||
async def _eat(stock, db):
|
||
seen.append((stock["stock_code"], bool(stock.get("_exit_only"))))
|
||
return {"action": "HOLD", "reason": "x"}
|
||
monkeypatch.setattr(te, "execute_auto_trade", _eat)
|
||
yield seen
|
||
te._order_reject_cooldown.clear()
|
||
te._account_reject_at = None
|
||
te._eval_rotation_offset = 0
|
||
|
||
|
||
def _recs(codes):
|
||
return {"recommendations_by_rule": [{"rule_name": "r", "stocks": [
|
||
{"stock_code": c, "rule_priority": 1, "score": 100 - i} for i, c in enumerate(codes)
|
||
]}]}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("count", [2, None])
|
||
async def test_m2_held_exit_eval_when_buys_blocked(loop_env, monkeypatch, count):
|
||
"""일일 한도 소진(2) / 건수 조회 실패(None) — 신규는 막고 보유는 _exit_only 로 평가."""
|
||
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=count))
|
||
await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"]))
|
||
assert loop_env == [("HELD", True)]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_m2_regime_gate_still_evaluates_held(loop_env, monkeypatch):
|
||
monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (0, 0.0))
|
||
monkeypatch.setattr(te, "_count_swing_buys_today_v2", _boom("count(국면 게이트 시 불필요)"))
|
||
await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"]))
|
||
assert loop_env == [("HELD", True)]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_l2_account_reject_pauses_all_new_buys(loop_env, monkeypatch):
|
||
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
|
||
te._note_order_reject_v2("X", {"rt_cd": "1", "msg_cd": settings.KIS_ACCOUNT_REJECT_MSG_CODES[0]})
|
||
assert te._account_reject_cooldown_remaining_v2() > 0
|
||
await te._execute_auto_trade_background_locked(_recs(["HELD", "N1", "N2"]), _LoopDB(["HELD"]))
|
||
assert loop_env == [("HELD", True)]
|
||
# 종목 단위 거부는 계좌 쿨다운을 걸지 않는다
|
||
te._account_reject_at = None
|
||
te._note_order_reject_v2("Y", {"rt_cd": "1", "msg_cd": "APBK0013"})
|
||
assert te._account_reject_cooldown_remaining_v2() == 0.0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_l3_rotation_offset_reaches_lower_candidates(loop_env, monkeypatch):
|
||
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
|
||
monkeypatch.setattr(settings, "AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE", 2)
|
||
cycles = []
|
||
for _ in range(3):
|
||
loop_env.clear()
|
||
await te._execute_auto_trade_background_locked(_recs(list("ABCDE")), _LoopDB([]))
|
||
cycles.append([c for c, _ in loop_env])
|
||
assert cycles == [["A", "B"], ["C", "D"], ["E", "A"]], cycles
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# safe_place_order(_v2) msg_cd — 전송 전/후 구분 (DT 재시도 판정용)
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# conftest 가 테스트마다 te.safe_place_order 를 차단 스텁으로 바꾸므로, 수집 시점의
|
||
# 원본을 잡아 둔다(KIS 클래스 메서드 차단은 그대로 유지된다).
|
||
from app.services.trade_executor import ( # noqa: E402
|
||
safe_place_order as _REAL_SAFE_PLACE_ORDER,
|
||
safe_place_order_v2 as _REAL_SAFE_PLACE_ORDER_V2,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def safe_env(monkeypatch):
|
||
released = []
|
||
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True))
|
||
|
||
async def _rel(code, typ):
|
||
released.append((code, typ))
|
||
monkeypatch.setattr(te, "_release_order_lock", _rel)
|
||
return released
|
||
|
||
|
||
def _ctx(kis):
|
||
return SimpleNamespace(is_legacy=True, user_id=None, kis=kis)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("variant", ["v1", "v2"])
|
||
async def test_safe_order_lock_busy_has_msg_cd(monkeypatch, variant):
|
||
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=False))
|
||
k = SimpleNamespace(place_order=_boom("place_order"))
|
||
monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order"))
|
||
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
|
||
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
|
||
assert r == {"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("variant", ["v1", "v2"])
|
||
async def test_safe_order_presend_exception(safe_env, monkeypatch, variant):
|
||
def _bad_sem(key):
|
||
raise RuntimeError("semaphore broken")
|
||
monkeypatch.setattr(te, "_get_order_semaphore", _bad_sem)
|
||
k = SimpleNamespace(place_order=_boom("place_order"))
|
||
monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order"))
|
||
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
|
||
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
|
||
assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_PRESEND_EXCEPTION"
|
||
assert safe_env, "락이 해제되지 않았다"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("variant", ["v1", "v2"])
|
||
@pytest.mark.parametrize("mode", ["raise", "none"])
|
||
async def test_safe_order_post_send_unknown(safe_env, monkeypatch, variant, mode):
|
||
async def _po(**kw):
|
||
if mode == "raise":
|
||
raise RuntimeError("read timeout")
|
||
return None
|
||
k = SimpleNamespace(place_order=_po)
|
||
monkeypatch.setattr(te.korea_investment_api, "place_order", _po)
|
||
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
|
||
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
|
||
assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_TRANSPORT_UNKNOWN"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_safe_order_success_passthrough(safe_env, monkeypatch):
|
||
ok = {"rt_cd": "0", "output": {"ODNO": "1"}}
|
||
monkeypatch.setattr(te.korea_investment_api, "place_order", AsyncMock(return_value=ok))
|
||
assert await _REAL_SAFE_PLACE_ORDER("005930", "BUY", 1) == ok
|