diff --git a/.openswarm-preserved b/.openswarm-preserved new file mode 100644 index 0000000..bd23dda --- /dev/null +++ b/.openswarm-preserved @@ -0,0 +1,6 @@ +{ + "issueId": "20f451d1-876e-4d85-bc15-3cbc35481913", + "branchName": "swarm/STO-1580-bug-fix-futures", + "reason": "session did not succeed", + "at": "2026-07-22T03:41:40.583Z" +} \ No newline at end of file diff --git a/docs/verification/STO-1580-paper-futures-orders.md b/docs/verification/STO-1580-paper-futures-orders.md new file mode 100644 index 0000000..dcace60 --- /dev/null +++ b/docs/verification/STO-1580-paper-futures-orders.md @@ -0,0 +1,25 @@ +# STO-1580 paper futures order verification + +## Environment and result + +Live KIS paper calls were unavailable in this worktree because no `KIS_APP_KEY`, +`KIS_APP_SECRET`, or paper account number was present. No `rt_cd=0` result is +claimed. The credential-safe test intercepts HTTP after KIS TR-ID conversion and +verifies the complete JSON body and final TR ID for all four paths with +`KIS_PAPER=1` and `KIS_ACCOUNT_CODE=03`: + +| Path | Expected paper TR_ID | Offline result | +| --- | --- | --- | +| buy | `VTTO1101U` | request body and final TR_ID verified | +| sell | `VTTO1101U` | request body and final TR_ID verified | +| amend | `VTTO1103U` | request body and final TR_ID verified | +| cancel | `VTTO1103U` | request body and final TR_ID verified | + +Run `python -m pytest tests/unit/test_futures_order_api.py::test_paper_daytime_four_paths_resolve_final_tr_ids -q --no-cov`. +The response contract is independently sourced from the official KIS +`open-trading-api` `chk_order.py` and `chk_order_rvsecncl.py` `COLUMN_MAPPING` +dictionaries; tests compare those field sets with this package's TypedDicts. +For live verification, export paper credentials plus `KIS_PAPER=1` and +`KIS_ACCOUNT_CODE=03`, submit buy and sell during the daytime session, then amend +and cancel the returned order numbers; record `rt_cd`, `msg_cd`, and `ODNO` for +each call. Night TR IDs require a real account and are outside paper support. \ No newline at end of file diff --git a/kis_agent/futures/order_api.py b/kis_agent/futures/order_api.py index 3da74f3..9dfc3d4 100644 --- a/kis_agent/futures/order_api.py +++ b/kis_agent/futures/order_api.py @@ -194,8 +194,8 @@ def order( Returns: FuturesOrderResponse: 주문 응답 - - output.odno: 주문번호 - - output.ord_tmd: 주문시각 + - output.ODNO: 주문번호 + - output.ORD_TMD: 주문시각 Example: >>> # 시장가 매수 @@ -205,7 +205,7 @@ def order( ... qty="1", ... price="0" # 시장가 ... ) - >>> print(f"주문번호: {result['output']['odno']}") + >>> print(f"주문번호: {result['output']['ODNO']}") >>> >>> # 지정가 매도 >>> result = agent.futures.order.order( @@ -223,27 +223,39 @@ def order( Warning: 실전 주문 시 반드시 주의하여 사용하세요. 주문 전 inquire_psbl_order()로 주문 가능 수량을 확인하세요. - - 이 메서드의 요청 본문 필드명은 공식 스펙과 일치하지 않는다 - (스펙: CANO/ACNT_PRDT_CD/SHTN_PDNO/UNIT_PRICE). 실전·모의 양쪽에서 - 실패한다. TR_ID만 먼저 바로잡았고 본문 수정은 별도 작업이다. """ if order_type not in ("01", "02"): raise ValueError(f"Invalid order_type: {order_type} (01:매도, 02:매수)") - + if order_cond not in ("0", "1", "2"): + raise ValueError(f"Invalid order_cond: {order_cond} (0:일반, 1:IOC, 2:FOK)") + + is_market = price == "0" + krx_condition = {"0": "0", "1": "3", "2": "4"}[order_cond] + order_division = { + (False, "0"): "01", # 지정가 + (True, "0"): "02", # 시장가 + (False, "1"): "10", # 지정가 IOC + (False, "2"): "11", # 지정가 FOK + (True, "1"): "12", # 시장가 IOC + (True, "2"): "13", # 시장가 FOK + }[(is_market, order_cond)] + + params = { + "ORD_PRCS_DVSN_CD": "02", + "CANO": self._get_account_no(), + "ACNT_PRDT_CD": self._get_account_code(), + "SHTN_PDNO": code, + "SLL_BUY_DVSN_CD": order_type, + "ORD_QTY": qty, + "UNIT_PRICE": price, + "NMPR_TYPE_CD": "02" if is_market else "01", + "KRX_NMPR_CNDT_CD": krx_condition, + "ORD_DVSN_CD": order_division, + } return self._make_request_dict( endpoint=API_ENDPOINTS["FUTURES_ORDER"], tr_id="TTTO1101U", # 매수/매도 공통 (주간) - params={ - "ACNT_NO": self._get_account_no(), - "ACNT_PDNO": self._get_account_code(), - "FUOP_ITEM_CODE": code, - "SLL_BUY_DVSN_CD": order_type, - "ORD_QTY": qty, - "ORD_UNPR": price, - "ORD_DVSN_CD": "01" if price == "0" else "00", # 01:시장가, 00:지정가 - "ORD_CNDI_DVSN_CD": order_cond, - }, + params=params, ) def order_rvsecncl( @@ -266,8 +278,8 @@ def order_rvsecncl( Returns: 정정/취소 응답 - - output.odno: 주문번호 - - output.ord_tmd: 주문시각 + - output.ODNO: 주문번호 + - output.ORD_TMD: 주문시각 Example: >>> # 주문 취소 @@ -289,26 +301,32 @@ def order_rvsecncl( 주간 정정·취소는 모두 TTTO1103U 하나를 쓴다 (모의: VTTO1103U). 정정/취소 구분은 TR_ID가 아니라 본문 필드로 해야 한다. - Warning: - 이 메서드의 요청 본문은 공식 스펙과 일치하지 않는다 — 정정/취소 - 구분 필드(RVSE_CNCL_DVSN_CD)를 보내지 않고, 계좌/가격 필드명도 - 스펙(CANO/ACNT_PRDT_CD/UNIT_PRICE)과 다르다. 실전·모의 양쪽에서 - 실패한다. TR_ID만 먼저 바로잡았고 본문 수정은 별도 작업이다. """ if action not in ("01", "02"): raise ValueError(f"Invalid action: {action} (01:정정, 02:취소)") + effective_price = price if action == "01" else "0" + is_market = effective_price == "0" + + params = { + "ORD_PRCS_DVSN_CD": "02", + "CANO": self._get_account_no(), + "ACNT_PRDT_CD": self._get_account_code(), + "ORGN_ODNO": orgn_odno, + # KIS requires the amend/cancel distinction in the request body; + # TTTO1103U is shared by both actions. + "RVSE_CNCL_DVSN_CD": action, + "ORD_QTY": qty, + "UNIT_PRICE": effective_price, + "NMPR_TYPE_CD": "02" if is_market else "01", + "KRX_NMPR_CNDT_CD": "0", + "RMN_QTY_YN": "Y" if qty == "0" else "N", + "ORD_DVSN_CD": "02" if is_market else "01", + } return self._make_request_dict( endpoint=API_ENDPOINTS["FUTURES_ORDER_RVSECNCL"], tr_id="TTTO1103U", # 정정/취소 공통 (주간) - params={ - "ACNT_NO": self._get_account_no(), - "ACNT_PDNO": self._get_account_code(), - "ORGN_ODNO": orgn_odno, - "ORD_QTY": qty, - "ORD_UNPR": price if action == "01" else "0", # 정정 시에만 가격 사용 - "ORD_DVSN_CD": "01" if price == "0" else "00", - }, + params=params, ) # Helper methods (private) diff --git a/kis_agent/responses/__init__.py b/kis_agent/responses/__init__.py index 1403ce4..772ecba 100644 --- a/kis_agent/responses/__init__.py +++ b/kis_agent/responses/__init__.py @@ -63,6 +63,8 @@ def get_stock_price(code: str) -> StockPriceResponse: FuturesOrderbookResponse, FuturesOrderOutput, FuturesOrderResponse, + FuturesOrderRvsecnclOutput, + FuturesOrderRvsecnclResponse, FuturesPriceOutput, FuturesPriceResponse, FuturesTimeChartResponse, @@ -344,6 +346,8 @@ def get_stock_price(code: str) -> StockPriceResponse: "FuturesTimeChartResponse", "FuturesOrderOutput", "FuturesOrderResponse", + "FuturesOrderRvsecnclOutput", + "FuturesOrderRvsecnclResponse", "FuturesConclusionRow", "FuturesConclusionResponse", "DisplayBoardCallPutRow", diff --git a/kis_agent/responses/futures.py b/kis_agent/responses/futures.py index fd6b94b..2e5e34e 100644 --- a/kis_agent/responses/futures.py +++ b/kis_agent/responses/futures.py @@ -220,12 +220,11 @@ class FuturesTimeChartResponse(BaseResponse): class FuturesOrderOutput(TypedDict, total=False): - """선물옵션 주문 응답 output""" + """KIS 국내 선물옵션 신규 주문 응답 output.""" - odno: str # 주문번호 (Order Number) - ord_tmd: str # 주문시각 (Order Time) - ord_gno_brno: str # 주문채번지점번호 - odno_brno: str # 주문번호지점번호 + KRX_FWDG_ORD_ORGNO: str # 한국거래소전송주문조직번호 + ODNO: str # 주문번호 + ORD_TMD: str # 주문시각 class FuturesOrderResponse(BaseResponse): @@ -234,6 +233,24 @@ class FuturesOrderResponse(BaseResponse): output: FuturesOrderOutput +class FuturesOrderRvsecnclOutput(TypedDict, total=False): + """KIS 국내 선물옵션 정정/취소 응답 output.""" + + ACNT_NAME: str # 계좌명 + TRAD_DVSN_NAME: str # 매매구분명 + ITEM_NAME: str # 종목명 + ORD_TMD: str # 주문시각 + ORD_GNO_BRNO: str # 주문채번지점번호 + ORGN_ODNO: str # 원주문번호 + ODNO: str # 주문번호 + + +class FuturesOrderRvsecnclResponse(BaseResponse): + """선물옵션 정정/취소 응답""" + + output: FuturesOrderRvsecnclOutput + + # ============================================================ # 7. inquire_ccnl() - 선물옵션 체결내역 # ============================================================ @@ -382,6 +399,8 @@ class FuturesDepositResponse(BaseResponse): # 주문/체결 "FuturesOrderOutput", "FuturesOrderResponse", + "FuturesOrderRvsecnclOutput", + "FuturesOrderRvsecnclResponse", "FuturesConclusionRow", "FuturesConclusionResponse", # 전광판 diff --git a/kis_agent/websocket/client.py b/kis_agent/websocket/client.py index 21a376a..58d31a9 100644 --- a/kis_agent/websocket/client.py +++ b/kis_agent/websocket/client.py @@ -13,8 +13,6 @@ import pandas as pd import websockets -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad from ..core.client import KISClient from ..core.constants import WS_REAL_URL @@ -848,6 +846,11 @@ def stocksigningnotice(self, data, key, iv): @staticmethod def aes_cbc_base64_dec(key, iv, cipher_text): + # This legacy websocket feature should not require crypto during + # imports of unrelated REST APIs. + from Crypto.Cipher import AES + from Crypto.Util.Padding import unpad + cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8")) return bytes.decode( unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size) diff --git a/kis_agent/websocket/data_processor.py b/kis_agent/websocket/data_processor.py index 9859242..086d91d 100644 --- a/kis_agent/websocket/data_processor.py +++ b/kis_agent/websocket/data_processor.py @@ -11,8 +11,6 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad logger = logging.getLogger(__name__) @@ -144,6 +142,9 @@ def _decrypt_aes(self, encrypted_data: bytes, key: bytes, iv: bytes) -> str: if not key or not iv: raise ValueError("AES 키 또는 IV가 없습니다") + from Crypto.Cipher import AES + from Crypto.Util.Padding import unpad + cipher = AES.new(key, AES.MODE_CBC, iv) decrypted = unpad(cipher.decrypt(encrypted_data), AES.block_size) return decrypted.decode("utf-8") diff --git a/kis_agent/websocket/ws_agent.py b/kis_agent/websocket/ws_agent.py index f7ac794..3145c27 100644 --- a/kis_agent/websocket/ws_agent.py +++ b/kis_agent/websocket/ws_agent.py @@ -10,8 +10,6 @@ import pytz import websockets -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad from websockets.exceptions import ConnectionClosed from ..core.constants import WS_MOCK_URL, WS_REAL_URL @@ -650,6 +648,9 @@ def _parse_message(self, data: str, json_data: Optional[dict] = None) -> tuple: def _decrypt_aes(self, key: str, iv: str, cipher_text: str) -> str: """AES256 복호화""" + from Crypto.Cipher import AES + from Crypto.Util.Padding import unpad + cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8")) return bytes.decode( unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size) diff --git a/tests/unit/test_futures_order_api.py b/tests/unit/test_futures_order_api.py index 54d298e..133f454 100644 --- a/tests/unit/test_futures_order_api.py +++ b/tests/unit/test_futures_order_api.py @@ -24,12 +24,29 @@ """ import unittest +from types import SimpleNamespace from unittest.mock import Mock import pytest -from kis_agent.core.client import API_ENDPOINTS +from kis_agent.core.client import API_ENDPOINTS, KISClient +from kis_agent.core.config import KISConfig from kis_agent.futures.order_api import FuturesOrderAPI +from kis_agent.responses.futures import ( + FuturesOrderOutput, + FuturesOrderResponse, + FuturesOrderRvsecnclOutput, + FuturesOrderRvsecnclResponse, +) + + +# Independent producer contracts from the official KIS open-trading-api +# examples' chk_order*.py COLUMN_MAPPING dictionaries. +OFFICIAL_ORDER_OUTPUT_FIELDS = {"KRX_FWDG_ORD_ORGNO", "ODNO", "ORD_TMD"} +OFFICIAL_RVSECNCL_OUTPUT_FIELDS = { + "ACNT_NAME", "TRAD_DVSN_NAME", "ITEM_NAME", "ORD_TMD", + "ORD_GNO_BRNO", "ORGN_ODNO", "ODNO", +} class TestFuturesOrderAPI(unittest.TestCase): @@ -204,7 +221,7 @@ def test_order_buy_market_success(self): call_kwargs = self.mock_client.make_request.call_args self.assertEqual(call_kwargs[1]["tr_id"], "TTTO1101U") # 매수/매도 공통 self.assertEqual(call_kwargs[1]["params"]["SLL_BUY_DVSN_CD"], "02") - self.assertEqual(call_kwargs[1]["params"]["ORD_DVSN_CD"], "01") # 시장가 + self.assertEqual(call_kwargs[1]["params"]["ORD_DVSN_CD"], "02") # 시장가 def test_order_buy_limit_success(self): """매수 주문 성공 - 지정가""" @@ -220,7 +237,7 @@ def test_order_buy_limit_success(self): self.assertEqual(result, expected_response) call_kwargs = self.mock_client.make_request.call_args self.assertEqual(call_kwargs[1]["tr_id"], "TTTO1101U") # 매수/매도 공통 - self.assertEqual(call_kwargs[1]["params"]["ORD_DVSN_CD"], "00") # 지정가 + self.assertEqual(call_kwargs[1]["params"]["ORD_DVSN_CD"], "01") # 지정가 def test_order_sell_market_success(self): """매도 주문 성공 - 시장가""" @@ -252,7 +269,7 @@ def test_order_sell_limit_success(self): self.assertEqual(result, expected_response) call_kwargs = self.mock_client.make_request.call_args self.assertEqual(call_kwargs[1]["tr_id"], "TTTO1101U") # 매수/매도 공통 - self.assertEqual(call_kwargs[1]["params"]["ORD_DVSN_CD"], "00") # 지정가 + self.assertEqual(call_kwargs[1]["params"]["ORD_DVSN_CD"], "01") # 지정가 def test_order_invalid_order_type(self): """잘못된 주문 구분""" @@ -276,7 +293,7 @@ def test_order_with_ioc_condition(self): self.assertEqual(result, expected_response) call_kwargs = self.mock_client.make_request.call_args - self.assertEqual(call_kwargs[1]["params"]["ORD_CNDI_DVSN_CD"], "1") + self.assertEqual(call_kwargs[1]["params"]["KRX_NMPR_CNDT_CD"], "3") def test_order_rvsecncl_cancel_success(self): """주문 취소 성공""" @@ -312,7 +329,7 @@ def test_order_rvsecncl_modify_success(self): self.assertEqual(result, expected_response) call_kwargs = self.mock_client.make_request.call_args self.assertEqual(call_kwargs[1]["tr_id"], "TTTO1103U") # 정정/취소 공통 - self.assertEqual(call_kwargs[1]["params"]["ORD_UNPR"], "341.00") + self.assertEqual(call_kwargs[1]["params"]["UNIT_PRICE"], "341.00") def test_order_rvsecncl_invalid_action(self): """잘못된 정정/취소 구분""" @@ -362,9 +379,9 @@ def test_order_tr_id_selection(order_type, expected_tr_id): @pytest.mark.parametrize( "price,expected_ord_dvsn", [ - ("0", "01"), # 시장가 - ("340.50", "00"), # 지정가 - ("341.00", "00"), # 지정가 + ("0", "02"), # 시장가 + ("340.50", "01"), # 지정가 + ("341.00", "01"), # 지정가 ], ) def test_order_type_by_price(price, expected_ord_dvsn): @@ -383,5 +400,182 @@ def test_order_type_by_price(price, expected_ord_dvsn): assert call_kwargs[1]["params"]["ORD_DVSN_CD"] == expected_ord_dvsn +def api_with_capture(): + mock_client = Mock() + mock_client.make_request.return_value = {"rt_cd": "0", "output": {}} + api = FuturesOrderAPI( + client=mock_client, + account_info={"account_no": "12345678", "account_code": "03"}, + enable_cache=False, + ) + return api, mock_client + + +@pytest.mark.parametrize( + "order_type,price,nmpr_type,ord_dvsn", + [ + ("01", "340.50", "01", "01"), # sell limit + ("02", "0", "02", "02"), # buy market + ], +) +def test_order_buy_and_sell_use_official_body_fields( + order_type, price, nmpr_type, ord_dvsn +): + api, mock_client = api_with_capture() + api.order("101S12", order_type, "1", price, "1") + call_kwargs = mock_client.make_request.call_args[1] + params = call_kwargs["params"] + assert call_kwargs["tr_id"] == "TTTO1101U" + legacy_keys = { + "ACNT_NO", + "ACNT_PDNO", + "FUOP_ITEM_CODE", + "ORD_UNPR", + "ORD_CNDI_DVSN_CD", + } + assert legacy_keys.isdisjoint(params), legacy_keys & params.keys() + assert params == { + "ORD_PRCS_DVSN_CD": "02", "CANO": "12345678", "ACNT_PRDT_CD": "03", + "SHTN_PDNO": "101S12", "SLL_BUY_DVSN_CD": order_type, "ORD_QTY": "1", + "UNIT_PRICE": price, "NMPR_TYPE_CD": nmpr_type, + "KRX_NMPR_CNDT_CD": "3", "ORD_DVSN_CD": "12" if price == "0" else "10", + } + + +@pytest.mark.parametrize( + "action,expected_price,expected_ord_dvsn", + [("01", "341.00", "01"), ("02", "0", "02")], + ids=["amend", "cancel"], +) +def test_order_correction_and_cancellation_use_official_body_fields( + action, expected_price, expected_ord_dvsn +): + api, mock_client = api_with_capture() + api.order_rvsecncl("0000123456", "1", action, "341.00") + call_kwargs = mock_client.make_request.call_args[1] + params = call_kwargs["params"] + assert call_kwargs["tr_id"] == "TTTO1103U" + assert params["RVSE_CNCL_DVSN_CD"] == action + assert {"ACNT_NO", "ACNT_PDNO", "ORD_UNPR"}.isdisjoint(params) + assert params == { + "ORD_PRCS_DVSN_CD": "02", "CANO": "12345678", "ACNT_PRDT_CD": "03", "ORGN_ODNO": "0000123456", + "RVSE_CNCL_DVSN_CD": action, "ORD_QTY": "1", + "UNIT_PRICE": expected_price, "NMPR_TYPE_CD": "02" if expected_price == "0" else "01", + "KRX_NMPR_CNDT_CD": "0", "RMN_QTY_YN": "N", "ORD_DVSN_CD": expected_ord_dvsn, + } + + +def test_official_order_response_shape_through_api_return_path(): + api, mock_client = api_with_capture() + # Producer contract: koreainvestment/open-trading-api + # examples_llm/domestic_futureoption/order/chk_order.py COLUMN_MAPPING. + fixture = { + "rt_cd": "0", + "output": { + "KRX_FWDG_ORD_ORGNO": "00001", + "ODNO": "123", + "ORD_TMD": "101530", + }, + } + mock_client.make_request.return_value = fixture + + order_response: FuturesOrderResponse = api.order("101S12", "02", "1", "0") + output = order_response["output"] + assert set(FuturesOrderOutput.__annotations__) == OFFICIAL_ORDER_OUTPUT_FIELDS + assert set(output) == OFFICIAL_ORDER_OUTPUT_FIELDS + assert output["KRX_FWDG_ORD_ORGNO"] == "00001" + assert output["ODNO"] == "123" + assert output["ORD_TMD"] == "101530" + + +def test_official_rvsecncl_response_shape_through_api_return_path(): + api, mock_client = api_with_capture() + # Producer contract: examples_llm/domestic_futureoption/ + # order_rvsecncl/chk_order_rvsecncl.py COLUMN_MAPPING. + expected_output = { + "ACNT_NAME": "선물계좌", + "TRAD_DVSN_NAME": "정정", + "ITEM_NAME": "코스피200 선물", + "ORD_TMD": "101531", + "ORD_GNO_BRNO": "00001", + "ORGN_ODNO": "0000123456", + "ODNO": "0000123457", + } + mock_client.make_request.return_value = {"rt_cd": "0", "output": expected_output} + + response: FuturesOrderRvsecnclResponse = api.order_rvsecncl( + "0000123456", "1", "01", "341.00" + ) + + assert set(FuturesOrderRvsecnclOutput.__annotations__) == OFFICIAL_RVSECNCL_OUTPUT_FIELDS + assert set(response["output"]) == OFFICIAL_RVSECNCL_OUTPUT_FIELDS + assert response["output"] == expected_output + assert "KRX_FWDG_ORD_ORGNO" not in response["output"] + + +def test_paper_daytime_four_paths_resolve_final_tr_ids(monkeypatch): + """Credential-safe dry run: paper buy/sell/amend/cancel resolve to VTTO TR IDs. + + This records the offline verification procedure for KIS_PAPER=1 and + KIS_ACCOUNT_CODE=03. HTTP is intercepted, so no order is submitted. + """ + monkeypatch.setenv("KIS_PAPER", "1") + monkeypatch.setenv("KIS_ACCOUNT_CODE", "03") + monkeypatch.setenv("KIS_APP_KEY", "dry-run-app-key") + monkeypatch.setenv("KIS_APP_SECRET", "dry-run-app-secret") + monkeypatch.setenv("KIS_ACCOUNT_NO", "12345678") + config = KISConfig.from_env() + client = KISClient(config=config, enable_rate_limiter=False, _defer_token=True) + client.token = "dry-run-token" + monkeypatch.setattr(client, "_check_and_refresh_token", lambda: None) + monkeypatch.setattr( + "kis_agent.core.client.getTREnv", + lambda: SimpleNamespace( + my_token="dry-run-token", my_app=config.APP_KEY, my_sec=config.APP_SECRET + ), + ) + requests = [] + + class DryRunResponse: + status_code = 200 + text = '{"rt_cd":"0","output":{}}' + + def json(self): + return {"rt_cd": "0", "output": {}} + + def capture_transport(method, url, **kwargs): + requests.append(kwargs) + return DryRunResponse() + + monkeypatch.setattr("kis_agent.core.client.httpx.request", capture_transport) + api = FuturesOrderAPI( + client=client, + account_info={"account_no": config.ACCOUNT_NO, "account_code": config.ACCOUNT_CODE}, + enable_cache=False, + ) + + for invoke in ( + lambda: api.order("101S12", "02", "1", "0"), + lambda: api.order("101S12", "01", "1", "341.00"), + lambda: api.order_rvsecncl("0000123456", "1", "01", "341.00"), + lambda: api.order_rvsecncl("0000123456", "1", "02", "341.00"), + ): + invoke() + + assert [request["headers"]["tr_id"] for request in requests] == [ + "VTTO1101U", + "VTTO1101U", + "VTTO1103U", + "VTTO1103U", + ] + bodies = [request["json"] or request["params"] for request in requests] + assert bodies == [ + {"ORD_PRCS_DVSN_CD": "02", "CANO": "12345678", "ACNT_PRDT_CD": "03", "SHTN_PDNO": "101S12", "SLL_BUY_DVSN_CD": "02", "ORD_QTY": "1", "UNIT_PRICE": "0", "NMPR_TYPE_CD": "02", "KRX_NMPR_CNDT_CD": "0", "ORD_DVSN_CD": "02"}, + {"ORD_PRCS_DVSN_CD": "02", "CANO": "12345678", "ACNT_PRDT_CD": "03", "SHTN_PDNO": "101S12", "SLL_BUY_DVSN_CD": "01", "ORD_QTY": "1", "UNIT_PRICE": "341.00", "NMPR_TYPE_CD": "01", "KRX_NMPR_CNDT_CD": "0", "ORD_DVSN_CD": "01"}, + {"ORD_PRCS_DVSN_CD": "02", "CANO": "12345678", "ACNT_PRDT_CD": "03", "ORGN_ODNO": "0000123456", "RVSE_CNCL_DVSN_CD": "01", "ORD_QTY": "1", "UNIT_PRICE": "341.00", "NMPR_TYPE_CD": "01", "KRX_NMPR_CNDT_CD": "0", "RMN_QTY_YN": "N", "ORD_DVSN_CD": "01"}, + {"ORD_PRCS_DVSN_CD": "02", "CANO": "12345678", "ACNT_PRDT_CD": "03", "ORGN_ODNO": "0000123456", "RVSE_CNCL_DVSN_CD": "02", "ORD_QTY": "1", "UNIT_PRICE": "0", "NMPR_TYPE_CD": "02", "KRX_NMPR_CNDT_CD": "0", "RMN_QTY_YN": "N", "ORD_DVSN_CD": "02"}, + ] + + if __name__ == "__main__": unittest.main()