Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .openswarm-preserved
Original file line number Diff line number Diff line change
@@ -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"
}
25 changes: 25 additions & 0 deletions docs/verification/STO-1580-paper-futures-orders.md
Original file line number Diff line number Diff line change
@@ -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.
84 changes: 51 additions & 33 deletions kis_agent/futures/order_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ def order(

Returns:
FuturesOrderResponse: 주문 응답
- output.odno: 주문번호
- output.ord_tmd: 주문시각
- output.ODNO: 주문번호
- output.ORD_TMD: 주문시각

Example:
>>> # 시장가 매수
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -266,8 +278,8 @@ def order_rvsecncl(

Returns:
정정/취소 응답
- output.odno: 주문번호
- output.ord_tmd: 주문시각
- output.ODNO: 주문번호
- output.ORD_TMD: 주문시각

Example:
>>> # 주문 취소
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions kis_agent/responses/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ def get_stock_price(code: str) -> StockPriceResponse:
FuturesOrderbookResponse,
FuturesOrderOutput,
FuturesOrderResponse,
FuturesOrderRvsecnclOutput,
FuturesOrderRvsecnclResponse,
FuturesPriceOutput,
FuturesPriceResponse,
FuturesTimeChartResponse,
Expand Down Expand Up @@ -344,6 +346,8 @@ def get_stock_price(code: str) -> StockPriceResponse:
"FuturesTimeChartResponse",
"FuturesOrderOutput",
"FuturesOrderResponse",
"FuturesOrderRvsecnclOutput",
"FuturesOrderRvsecnclResponse",
"FuturesConclusionRow",
"FuturesConclusionResponse",
"DisplayBoardCallPutRow",
Expand Down
29 changes: 24 additions & 5 deletions kis_agent/responses/futures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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() - 선물옵션 체결내역
# ============================================================
Expand Down Expand Up @@ -382,6 +399,8 @@ class FuturesDepositResponse(BaseResponse):
# 주문/체결
"FuturesOrderOutput",
"FuturesOrderResponse",
"FuturesOrderRvsecnclOutput",
"FuturesOrderRvsecnclResponse",
"FuturesConclusionRow",
"FuturesConclusionResponse",
# 전광판
Expand Down
7 changes: 5 additions & 2 deletions kis_agent/websocket/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions kis_agent/websocket/data_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@
수신된 실시간 데이터를 처리하고 분석하는 모듈입니다.
"""

import json
import logging
from base64 import b64decode
from collections import defaultdict
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

Check failure on line 12 in kis_agent/websocket/data_processor.py

View workflow job for this annotation

GitHub Actions / ci / Code Quality (Lint & Format)

ruff (I001)

kis_agent/websocket/data_processor.py:7:1: I001 Import block is un-sorted or un-formatted help: Organize imports

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -144,6 +142,9 @@
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")
Expand Down
5 changes: 3 additions & 2 deletions kis_agent/websocket/ws_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading