dcy-007(core 자율 안전 매도) 전체 delivery. BAT-010 기반 인프라가 미커밋 상태였고 BAT-011 트리거 레이어와 공유 파일에서 섞여 있어 한 커밋으로 묶음. BAT-010 (REQ-034/035/036/041, release-candidate): - WatchdogStore(application/watchdog.py) + snapshot RPC 워치독 갱신 - Slot stop_loss_price/take_profit_price 필드 + 004_add_price_thresholds.sql - SlotService.sell() 자율 매도 내부 경로 + idempotency - SafetySellConfig env 5종 파싱 BAT-011 (REQ-037/038/039/040/042, release-candidate): - SafetySellService(application/safety_sell.py): forced_close 만기 / stop-loss· take-profit KIS polling / 장 마감 강제 청산 + trex-safety-sell 7번째 스레드 - BrokerPort.get_current_price + KIS inquire-price(TTTC8397R/VTTC8397R, stck_prpr) + paper stub - SlotService.update_thresholds() + intent RPC update_thresholds 액션 (부분 갱신/null 해제, 워치독 last_seen_at 리셋) - baseline 3문서 정합(interface-contract/runtime-flows/project-structure) 거버넌스: REQ-034~042 Implemented 동기화, BAT-010/011 release-candidate, dcy-007 discovery 포함. 검증: 신규 38 테스트 green, 단위 236 passed/23 skipped, 인프라 게이트(PG+RabbitMQ) 259 passed(마이그레이션 001~004 round-trip 포함), ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
101 lines
3.1 KiB
Python
101 lines
3.1 KiB
Python
"""KIS current-price adapter unit tests (REQ-038 / BAT-011).
|
|
|
|
Network is monkeypatched — these run always (no KIS credentials needed).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
import httpx
|
|
|
|
from trex_core.adapters.broker.kis import KISBroker
|
|
from trex_core.adapters.broker.paper import PaperBroker
|
|
|
|
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
|
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
|
|
|
|
|
|
class _FakeResp:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
def _mock_broker():
|
|
return KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
|
|
|
|
def _live_broker():
|
|
return KISBroker("12345678-01", app_key="k", app_secret="s",
|
|
base_url=_LIVE_URL, allow_live=True)
|
|
|
|
|
|
def test_tr_price_live_vs_mock():
|
|
"""AC-7: live uses TTTC8397R, mock uses VTTC8397R."""
|
|
assert _live_broker()._tr_price() == "TTTC8397R"
|
|
assert _mock_broker()._tr_price() == "VTTC8397R"
|
|
|
|
|
|
def test_get_current_price_parses_stck_prpr(monkeypatch):
|
|
"""get_current_price parses output.stck_prpr into a Decimal."""
|
|
captured = {}
|
|
|
|
def fake_get(url, headers=None, params=None, timeout=None):
|
|
captured["url"] = url
|
|
captured["tr_id"] = headers["tr_id"]
|
|
captured["params"] = params
|
|
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": "49000"}})
|
|
|
|
b = _mock_broker()
|
|
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
price = b.get_current_price("005930")
|
|
assert price == Decimal("49000")
|
|
assert captured["tr_id"] == "VTTC8397R"
|
|
assert captured["params"]["FID_INPUT_ISCD"] == "005930"
|
|
|
|
|
|
def test_get_current_price_network_error_returns_none(monkeypatch):
|
|
"""Network error → None (worker skips the slot, no false trigger)."""
|
|
def boom(*a, **kw):
|
|
raise httpx.ConnectError("down")
|
|
|
|
b = _mock_broker()
|
|
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
|
monkeypatch.setattr(httpx, "get", boom)
|
|
assert b.get_current_price("005930") is None
|
|
|
|
|
|
def test_get_current_price_error_rt_cd_returns_none(monkeypatch):
|
|
"""Non-zero rt_cd → None."""
|
|
def fake_get(*a, **kw):
|
|
return _FakeResp({"rt_cd": "1", "msg1": "error"})
|
|
|
|
b = _mock_broker()
|
|
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
assert b.get_current_price("005930") is None
|
|
|
|
|
|
def test_get_current_price_missing_field_returns_none(monkeypatch):
|
|
"""Missing/blank stck_prpr → None."""
|
|
def fake_get(*a, **kw):
|
|
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": ""}})
|
|
|
|
b = _mock_broker()
|
|
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
assert b.get_current_price("005930") is None
|
|
|
|
|
|
class TestPaperCurrentPrice:
|
|
def test_unset_returns_none(self):
|
|
assert PaperBroker().get_current_price("005930") is None
|
|
|
|
def test_set_and_get(self):
|
|
b = PaperBroker()
|
|
b.set_current_price("005930", Decimal("51000"))
|
|
assert b.get_current_price("005930") == Decimal("51000")
|