338 lines
10 KiB
Python
338 lines
10 KiB
Python
"""KIS adapter tests (REQ-012).
|
|
|
|
Unit tests run always; the sandbox integration test runs only when KIS
|
|
모의투자 credentials are present (TREX_KIS_BROKER_* env / .env).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from trex_core.adapters.broker.kis import KISBroker
|
|
from trex_core.application.broker_port import BrokerBalanceError, OrderRequest
|
|
|
|
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
|
|
|
|
|
def test_account_split_and_mock_tr_ids():
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
assert b._account() == ("12345678", "01")
|
|
assert b._tr_submit("buy") == "VTTC0012U"
|
|
assert b._tr_submit("sell") == "VTTC0011U"
|
|
assert b.name() == "kis-mock"
|
|
|
|
|
|
def test_live_base_url_requires_allow_live():
|
|
with pytest.raises(RuntimeError):
|
|
KISBroker(
|
|
"12345678-01",
|
|
app_key="k",
|
|
app_secret="s",
|
|
base_url="https://openapi.koreainvestment.com:9443",
|
|
)
|
|
# gated on explicitly
|
|
b = KISBroker(
|
|
"12345678-01",
|
|
app_key="k",
|
|
app_secret="s",
|
|
base_url="https://openapi.koreainvestment.com:9443",
|
|
allow_live=True,
|
|
)
|
|
assert b.name() == "kis-live" and b._tr_submit("buy") == "TTTC0012U"
|
|
|
|
|
|
def _kis_env():
|
|
keys = (
|
|
"TREX_KIS_BROKER_BASE_URL",
|
|
"TREX_KIS_BROKER_APP_KEY",
|
|
"TREX_KIS_BROKER_APP_SECRET",
|
|
"TREX_KIS_BROKER_ACCOUNT_REF",
|
|
)
|
|
if not all(os.environ.get(k) for k in keys):
|
|
# try .env via dotenv
|
|
try:
|
|
from dotenv import load_dotenv, find_dotenv
|
|
|
|
load_dotenv(find_dotenv(usecwd=True), override=False)
|
|
except ImportError:
|
|
pass
|
|
if not all(os.environ.get(k) for k in keys):
|
|
pytest.skip("KIS 모의투자 credentials not set; skipping sandbox integration test")
|
|
return {k: os.environ[k] for k in keys}
|
|
|
|
|
|
def test_sandbox_token_and_submit():
|
|
"""Single sandbox round-trip (one token issuance — KIS rate-limits token requests)."""
|
|
import httpx
|
|
|
|
env = _kis_env()
|
|
assert "openapivts" in env["TREX_KIS_BROKER_BASE_URL"], "expect 모의투자 sandbox URL"
|
|
b = KISBroker(
|
|
env["TREX_KIS_BROKER_ACCOUNT_REF"],
|
|
app_key=env["TREX_KIS_BROKER_APP_KEY"],
|
|
app_secret=env["TREX_KIS_BROKER_APP_SECRET"],
|
|
base_url=env["TREX_KIS_BROKER_BASE_URL"],
|
|
timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "10")),
|
|
)
|
|
try:
|
|
token = b._ensure_token()
|
|
except httpx.HTTPStatusError as exc: # KIS EGW00133: token issuance rate-limited
|
|
pytest.skip(f"KIS token rate-limited/unavailable: {exc}")
|
|
assert isinstance(token, str) and len(token) > 0
|
|
|
|
# limit buy 1 share at a low price (sandbox, no real money). Reuses the cached token.
|
|
ack = b.submit_order(
|
|
OrderRequest(
|
|
slot_id="t", side="buy", symbol="005930", qty=1, limit_price=10000, order_type="00"
|
|
)
|
|
)
|
|
assert ack.raw, "expected a KIS response payload"
|
|
if ack.accepted:
|
|
assert ack.broker_order_id
|
|
else:
|
|
assert ack.reason # rejection carries a reason (e.g., 장 미개장/잔고부족)
|
|
|
|
|
|
def test_get_balance_rejects_failure_instead_of_false_empty(monkeypatch):
|
|
"""BAT-057: a rejected KIS response is not a successful empty balance."""
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
|
|
class Response:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"rt_cd": "1", "output1": []}
|
|
|
|
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
|
|
with pytest.raises(BrokerBalanceError, match="broker_rejected"):
|
|
b.get_balance()
|
|
|
|
|
|
def test_get_balance_accepts_validated_empty_balance(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
|
|
class Response:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"rt_cd": "0", "output1": []}
|
|
|
|
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
|
|
assert b.get_balance() == []
|
|
|
|
|
|
def test_get_balance_sanitizes_token_issuance_failure(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(
|
|
b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret"))
|
|
)
|
|
with pytest.raises(BrokerBalanceError) as raised:
|
|
b.get_balance()
|
|
assert raised.value.code == "token_issuance_failed"
|
|
assert "secret" not in str(raised.value)
|
|
|
|
|
|
def test_get_available_cash_uses_cash_only_amount(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
monkeypatch.setattr(b, "get_current_price", lambda _symbol: None)
|
|
|
|
class Response:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {
|
|
"rt_cd": "0",
|
|
"output": {"nrcvb_buy_amt": "695591", "max_buy_amt": "999999"},
|
|
}
|
|
|
|
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
|
|
assert b.get_available_cash() == 695_591
|
|
|
|
|
|
def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.broker.kis.httpx.post",
|
|
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
|
|
)
|
|
|
|
ack = b.submit_order(
|
|
OrderRequest(
|
|
slot_id="slot-1",
|
|
side="buy",
|
|
symbol="005930",
|
|
qty=4,
|
|
limit_price=232_000,
|
|
)
|
|
)
|
|
|
|
assert ack.accepted is True
|
|
assert ack.broker_order_id.startswith("submission-unknown:")
|
|
assert ack.reason == "submit_outcome_unknown:ReadTimeout"
|
|
assert (
|
|
b.cancel_order(
|
|
ack.broker_order_id,
|
|
side="buy",
|
|
symbol="005930",
|
|
qty=4,
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_submit_gateway_5xx_blocks_automatic_duplicate(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
|
|
class Response:
|
|
status_code = 502
|
|
|
|
def json(self):
|
|
return {"error": "upstream_unavailable"}
|
|
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.broker.kis.httpx.post",
|
|
lambda *a, **kw: Response(),
|
|
)
|
|
|
|
ack = b.submit_order(
|
|
OrderRequest(
|
|
slot_id="slot-2",
|
|
side="buy",
|
|
symbol="005930",
|
|
qty=4,
|
|
limit_price=231_500,
|
|
)
|
|
)
|
|
|
|
assert ack.accepted is True
|
|
assert ack.broker_order_id.startswith("submission-unknown:")
|
|
assert ack.reason == "submit_outcome_unknown:http_502"
|
|
|
|
|
|
def test_submit_unknown_is_resolved_by_unique_daily_order(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.broker.kis.httpx.post",
|
|
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
|
|
)
|
|
ack = b.submit_order(
|
|
OrderRequest(
|
|
slot_id="slot-resolve",
|
|
side="buy",
|
|
symbol="005930",
|
|
qty=4,
|
|
limit_price=232_000,
|
|
)
|
|
)
|
|
from trex_core.adapters.broker.kis import _decode_submission_unknown
|
|
|
|
submitted = _decode_submission_unknown(ack.broker_order_id)["submitted_at"]
|
|
from datetime import datetime
|
|
|
|
at = datetime.fromisoformat(submitted)
|
|
row = {
|
|
"pdno": "005930",
|
|
"sll_buy_dvsn_cd": "02",
|
|
"ord_qty": "4",
|
|
"ord_unpr": "232000",
|
|
"ord_dt": at.strftime("%Y%m%d"),
|
|
"ord_tmd": at.strftime("%H%M%S"),
|
|
"ord_gno_brno": "91258",
|
|
"odno": "0012345678",
|
|
"tot_ccld_qty": "0",
|
|
"cncl_yn": "N",
|
|
}
|
|
|
|
class Response:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {"rt_cd": "0", "output1": [row]}
|
|
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.broker.kis.httpx.get",
|
|
lambda *a, **kw: Response(),
|
|
)
|
|
|
|
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
|
|
|
|
assert status.state == "accepted"
|
|
assert status.raw["resolved_broker_order_id"] == "91258:0012345678"
|
|
|
|
|
|
def test_submit_unknown_confirmed_absent_becomes_retryable_reject(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.broker.kis.httpx.post",
|
|
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
|
|
)
|
|
ack = b.submit_order(
|
|
OrderRequest(
|
|
slot_id="slot-absent",
|
|
side="buy",
|
|
symbol="005930",
|
|
qty=4,
|
|
limit_price=232_000,
|
|
)
|
|
)
|
|
|
|
class Response:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {"rt_cd": "0", "output1": []}
|
|
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.broker.kis.httpx.get",
|
|
lambda *a, **kw: Response(),
|
|
)
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.broker.kis._SUBMISSION_LOOKUP_GRACE_SECONDS",
|
|
-1.0,
|
|
)
|
|
|
|
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
|
|
|
|
assert status.state == "rejected"
|
|
assert status.raw == {"reason": "submission_confirmed_absent"}
|
|
|
|
|
|
def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
|
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
|
monkeypatch.setattr(b, "_headers", lambda _tr: {})
|
|
clock = [100.0]
|
|
monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: clock[0])
|
|
calls = []
|
|
|
|
def fail(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
raise httpx.ConnectTimeout("timed out")
|
|
|
|
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail)
|
|
|
|
first = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
|
immediate = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
|
|
|
assert first.state == "unknown"
|
|
assert immediate.state == "accepted"
|
|
assert immediate.raw == {"reason": "fill_poll_backoff"}
|
|
assert len(calls) == 1
|
|
|
|
clock[0] += 31
|
|
again = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
|
assert again.state == "unknown"
|
|
assert len(calls) == 2
|