feat(broker): KIS venue parameterization — OrderRequest.venue + EXCG_ID_DVSN_CD (REQ-117/118, BAT-056)

- OrderRequest.venue: str = "KRX" additive 추가 (기존 위치 인수 순서 불변)
- BrokerPort.cancel_order: venue: str = "KRX" 파라미터 추가
- BrokerPort.get_current_price: market: str = "J" 파라미터 추가
- KISBroker.submit_order: EXCG_ID_DVSN_CD=req.venue 항상 명시 전송
- KISBroker.cancel_order: EXCG_ID_DVSN_CD=venue 항상 명시 전송
- KISBroker.get_current_price: FID_COND_MRKT_DIV_CODE=market 파라미터화
- PaperBroker: cancel_order/get_current_price 시그니처 정합 (venue/market 수신·무시)
- poll_fill TR 유지 (TTTC8001R) — NXT 체결 포함 여부 미확인 open item (코드 주석 기록)
- tests/adapters/broker/test_venue.py: REQ-117 AC-1~5 + REQ-118 AC-1~8 신규 17개
- docs: interface-contract.md OrderRequest/BrokerPort 섹션 + change log 추가
- docs: runtime-flows.md venue 전달 경로 명시 + change log 추가

결과: 794 passed, 47 skipped (기존 777+47 대비 신규 17개 추가 전부 통과)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-07-06 23:24:51 +09:00
co-authored by Claude Fable 5
parent dc6d7a943b
commit ee3dc83a93
9 changed files with 638 additions and 11 deletions
@@ -0,0 +1,57 @@
# BAT-056: KIS Broker Venue Parameterization (NXT/KRX)
- ID: bat-056
- Slug: kis-broker-venue
- Status: In Progress
- Date: 2026-07-06
- Owner: trex-core-dev
- Risk: HIGH (라이브 주문 경로 — worktree+PR+QC(opus) 플로)
## REQs
| REQ | Title | Status |
| --- | --- | --- |
| REQ-117 | OrderRequest venue additive 필드 | Approved |
| REQ-118 | KIS 브로커 어댑터 venue 파라미터화 (EXCG_ID_DVSN_CD) | Approved |
## Scope
- `trex_core/application/broker_port.py` — `OrderRequest.venue` 필드 additive 추가
- `trex_core/adapters/broker/kis.py` — submit_order, cancel_order, poll_fill, get_current_price 변경
- `trex_core/adapters/broker/paper.py` — cancel_order 시그니처 정합
- `tests/adapters/broker/` — 신규 venue 테스트 + 기존 회귀
- `docs/interface-contract.md` — OrderRequest venue 필드 + EXCG 명세 업데이트
- `docs/runtime-flows.md` — venue 전달 경로 명시
## Out of Scope
- AMQP wire 계약 변경 (intent 메시지 venue 전달은 tvr 팬-인 batch 별도 범위)
- orb/tsl 소비 경로 (intent, slots wire) 변경
- SOR 동작 지원
- VTS NXT 검증 (단위 테스트 + 실전 소액 스모크 별도)
## Additive Constraint
venue 미지정(기본 KRX) 경로에서 기존 orb/tsl 동작이 byte-동일 유지:
- submit_order: `EXCG_ID_DVSN_CD="KRX"` 추가만 다름 (기존 미전송 → KRX 명시)
- cancel_order: `EXCG_ID_DVSN_CD="KRX"` 추가만 다름
- poll_fill: TR 유지(TTTC8001R/VTTC8001R) — NXT 체결 포함 여부 미확인(open item)
- get_current_price: market 기본값 "J" 유지
## AC Coverage
| AC | REQ | 대상 테스트 위치 |
| --- | --- | --- |
| REQ-117 AC-1 | venue 기본값 KRX | tests/adapters/broker/test_venue.py |
| REQ-117 AC-2 | NXT 지정 | tests/adapters/broker/test_venue.py |
| REQ-117 AC-3 | 기존 회귀 | 전체 pytest |
| REQ-117 AC-4 | 위치 인수 순서 불변 | tests/adapters/broker/test_venue.py |
| REQ-117 AC-5 | paper broker venue 무영향 | tests/adapters/broker/test_venue.py |
| REQ-118 AC-1 | submit KRX EXCG 포함 | tests/adapters/broker/test_venue.py |
| REQ-118 AC-2 | submit NXT EXCG 포함 | tests/adapters/broker/test_venue.py |
| REQ-118 AC-3 | cancel NXT EXCG 포함 | tests/adapters/broker/test_venue.py |
| REQ-118 AC-4 | poll_fill TR 미확인 open item | design.md + 코드 주석 |
| REQ-118 AC-5 | get_current_price 기본 J | tests/adapters/broker/test_venue.py |
| REQ-118 AC-6 | get_current_price UN | tests/adapters/broker/test_venue.py |
| REQ-118 AC-7 | 기존 회귀 (EXCG 추가만 다름) | tests/adapters/broker/test_venue.py |
| REQ-118 AC-8 | paper cancel_order 시그니처 정합 | tests/adapters/broker/test_venue.py |
@@ -0,0 +1,86 @@
# BAT-056 Design
## 1. OrderRequest.venue 필드 (REQ-117)
### 결정
`OrderRequest` frozen dataclass의 **마지막 위치**에 `venue: str = "KRX"` 추가.
```python
@dataclass(frozen=True)
class OrderRequest:
slot_id: str
side: str
symbol: str
qty: int
limit_price: Optional[int] = None
order_type: str = "00"
venue: str = "KRX" # additive (REQ-117): KRX | NXT | SOR
```
- 기존 위치 인수(`slot_id`, `side`, `symbol`, `qty`, `limit_price`, `order_type`) 순서 불변.
- venue 기본값 "KRX"로 기존 orb/tsl 미지정 호출 자동 KRX 적용.
- "SOR" 정의하되 현재 운영 비권고 (OQ-1 잔여, REQ-117 Notes 참조).
## 2. cancel_order 시그니처 venue 파라미터 (REQ-118)
### 결정: 시그니처 venue 파라미터 추가 방식
대안 검토:
- **A안(채택)**: `cancel_order(broker_order_id, *, side, symbol, qty, venue="KRX")` — 호출자가 원주문 venue를 명시 전달. 명시적이고 기본값으로 하위 호환.
- B안: execution context에서 venue 보존 — 더 복잡, 현재 OrderAck에 venue 저장 필요.
A안 채택 이유: 단순성, 기본값 하위 호환, BrokerPort 프로토콜에서 명시적 의미론 확립.
BrokerPort 프로토콜:
```python
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
```
KISBroker.cancel_order body에 `"EXCG_ID_DVSN_CD": venue` 추가.
PaperBroker.cancel_order: 시그니처만 정합, venue 무시.
## 3. poll_fill TR 선택 — open item (REQ-118 AC-4)
### 현황
- 구 TR `TTTC8001R` (mock: `VTTC8001R`): 현재 사용 중. NXT 체결 포함 여부 **미확인**.
- 신 TR `TTTC0081R`: EXCG=ALL 또는 venue 지정으로 NXT 체결 조회 가능(KIS 개발자 센터 문서 기준).
### 확인 시도 결과
웹 접근 불가 환경 — KIS 개발자 센터 문서 직접 조회 불가. 코드/기존 문서 내 관련 근거 없음.
### 결정: 보수 구현 (기존 TR 유지)
- **기존 TR(`TTTC8001R`/`VTTC8001R`) 유지** — KRX 경로 동작 절대 불변.
- 코드 주석에 open item 명시.
- verification에 open item 기록: "NXT 체결 조회는 실측 확인 필요(운영 스모크)".
### 향후 전환 조건 및 방법 (미구현, 운영 협의 후)
- 조건: NXT 체결 주문 발생 후 TTTC8001R 응답에 NXT 체결 미포함 확인(실측).
- 방법: `poll_fill` 파라미터 `venue` 추가 + venue="NXT"이면 TTTC0081R 사용.
- mock(VTS) 대응 주의: VTS TTTC0081R 지원 여부 미확인 → mock일 때 구 TR fallback 고려.
- 이 전환은 별도 batch로 처리한다(REQ-118 AC-4 follow-up).
## 4. get_current_price market 파라미터 (REQ-118 AC-5,6)
```python
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
```
- `FID_COND_MRKT_DIV_CODE` → `market` 값 사용.
- 기본값 "J" → 기존 safety-sell 경로 무영향.
- "UN" 지정 시 나스닥/해외 시세 조회 가능(NXT 연장세션 활용 예정).
BrokerPort 프로토콜:
```python
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
```
## 5. 실측 근거 (design 인용)
- 2026-07-06: 실전 계좌 NXT 주문(TTTC0012U + EXCG=NXT) rt_cd=0, 취소(TTTC0013U + EXCG=NXT) rt_cd=0·잔여 0건 확인.
- 토큰 재요청 1분 rate-limit 확인. 기존 kis.py 토큰 캐시 방식 유지 — 신 TR 전환 시에도 캐시 재사용 경로 유지 필요.
## 6. additive 검증 전략
- venue 미지정 submit_order: 기존 대비 `EXCG_ID_DVSN_CD="KRX"` 필드 추가만 다름 → fixture로 body 캡처 검사(AC-7).
- 기존 `cancel_order` 호출자: keyword-only 파라미터이므로 venue 없이 호출 시 기본값 KRX 자동 적용 → 기존 테스트 회귀 없음.
- 기존 `get_current_price` 호출자: 파라미터 미지정 → market="J" 자동 적용 → 기존 safety-sell 경로 무영향.
@@ -0,0 +1,62 @@
# BAT-056 Planning
## Task Breakdown
### T-1: OrderRequest.venue 필드 추가 (REQ-117)
- 파일: `trex_core/application/broker_port.py`
- 작업: `OrderRequest` frozen dataclass에 `venue: str = "KRX"` 필드 추가 (마지막 위치)
- 제약: 기존 위치 인수 순서(`slot_id`, `side`, `symbol`, `qty`, `limit_price`, `order_type`) 불변
### T-2: BrokerPort.cancel_order 시그니처 venue 파라미터 추가 (REQ-118)
- 파일: `trex_core/application/broker_port.py`
- 작업: `cancel_order` 프로토콜 시그니처에 `venue: str = "KRX"` 파라미터 추가
- 설계 결정: 시그니처 파라미터 추가 방식 채택(호출자 보존 방식 대비 명시적, 기본값으로 하위 호환)
### T-3: KISBroker.submit_order EXCG 명시 전송 (REQ-118 AC-1,2)
- 파일: `trex_core/adapters/broker/kis.py`
- 작업: body에 `"EXCG_ID_DVSN_CD": req.venue` 항상 추가
### T-4: KISBroker.cancel_order venue 파라미터 + EXCG 전송 (REQ-118 AC-3,8)
- 파일: `trex_core/adapters/broker/kis.py`
- 작업: 시그니처에 `venue: str = "KRX"` 추가, body에 `"EXCG_ID_DVSN_CD": venue` 추가
### T-5: KISBroker.poll_fill TR 유지 + open item 기록 (REQ-118 AC-4)
- 파일: `trex_core/adapters/broker/kis.py`
- 작업: 구 TR(TTTC8001R/VTTC8001R) 유지, 코드 주석에 NXT 체결 포함 여부 미확인 명시
- 이유: 웹 접근 불가 환경 — KIS 개발자 센터 문서 확인 불가. 보수 원칙: 기존 TR 유지, open item으로 기록
### T-6: KISBroker.get_current_price market 파라미터 추가 (REQ-118 AC-5,6)
- 파일: `trex_core/adapters/broker/kis.py`
- 작업: 시그니처에 `market: str = "J"` 추가, `FID_COND_MRKT_DIV_CODE` → market 값 사용
### T-7: PaperBroker.cancel_order 시그니처 정합 (REQ-118 AC-8, REQ-117 AC-5)
- 파일: `trex_core/adapters/broker/paper.py`
- 작업: cancel_order 시그니처에 `venue: str = "KRX"` 추가, 내부 로직 무변경(venue 무시)
### T-8: 신규 테스트 작성 (REQ-117 AC-1~5, REQ-118 AC-1~8)
- 파일: `tests/adapters/broker/test_venue.py` (신규)
- 작업: 13개 AC 커버 테스트
### T-9: 기존 테스트 회귀 확인 (REQ-117 AC-3, REQ-118 AC-7)
- 기존 테스트가 `OrderRequest` venue 미지정 경로를 통과하는지 확인
- 기존 submit_order fixture가 EXCG 필드 추가로 인한 페이로드 변화 감지 확인
### T-10: 문서 업데이트
- `docs/interface-contract.md` — OrderRequest venue 필드 추가
- `docs/runtime-flows.md` — venue 전달 경로 명시
## Dependency Order
T-1 → T-2 → T-3, T-4, T-5, T-6 (병렬) → T-7 → T-8 → T-9 → T-10
## Risk Points
1. `cancel_order` 시그니처 변경: BrokerPort 프로토콜 + KIS + paper 3개 파일 정합 필요
2. 기존 테스트 중 `cancel_order` 호출하는 코드 — venue 파라미터 없이 호출해도 기본값 KRX로 동작해야 함
3. `get_current_price` safety-sell 호출 경로: 기본값 "J" 유지로 무영향 확인 필요
## Estimation
- 구현: T-1~T-7 → 약 1시간
- 테스트: T-8~T-9 → 약 30분
- 문서: T-10 → 약 20분
+45
View File
@@ -398,6 +398,50 @@ resume_kill_switch 요청·응답 (REQ-046):
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트.
## `OrderRequest` DTO (BAT-004 / REQ-011, BAT-056 / REQ-117)
포트-어댑터 경계에서 주문 요청을 전달하는 frozen dataclass.
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `slot_id` | str | (필수) | 발원 슬롯 (감사/상관 식별자) |
| `side` | str | (필수) | `'buy'` \| `'sell'` |
| `symbol` | str | (필수) | 종목코드 (KIS `PDNO`) |
| `qty` | int | (필수) | 주문 수량 |
| `limit_price` | int \| None | None | 지정가 (None = 시장가) |
| `order_type` | str | `"00"` | KIS `ORD_DVSN`: 00=지정가, 01=시장가 |
| `venue` | str | `"KRX"` | 거래소 구분 (REQ-117 / BAT-056 additive): `KRX`=정규 KRX, `NXT`=대체거래소(연장세션), `SOR`=최선집행(현재 비권고 — OQ-1 잔여) |
### Additive Notes (REQ-117)
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환.
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않으므로 wire 계약 영향 없음. intent 메시지에서 venue를 전달하는 것은 tvr 팬-인 batch(후속) 범위.
- KIS 어댑터(REQ-118)는 `venue` → `EXCG_ID_DVSN_CD` 로 변환해 KIS HTTP 요청에 항상 명시 전송.
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
### cancel_order
```
cancel_order(broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool
```
- `venue`: 원주문 venue. 취소 시 원주문과 동일한 `EXCG_ID_DVSN_CD`를 사용해야 한다. 기본값 `"KRX"`로 기존 호출자 하위 호환.
### get_current_price
```
get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
```
- `market`: KIS `FID_COND_MRKT_DIV_CODE`. 기본값 `"J"` (KRX 주식) → 기존 safety-sell 경로 무영향. `"UN"` 지정 시 해외/NXT 연장세션 시세 조회 가능.
### KIS 어댑터 EXCG 명시화 (REQ-118)
- `submit_order`: body에 `EXCG_ID_DVSN_CD: req.venue` 항상 명시 전송 (이전: 미전송·서버 기본값 의존 → 명시화로 견고성 향상). 실측(2026-07-06): NXT(EXCG=NXT) 주문 rt_cd=0 확인.
- `cancel_order`: body에 `EXCG_ID_DVSN_CD: venue` 명시 전송. 실측(2026-07-06): NXT 취소 rt_cd=0 확인.
- `poll_fill`: 구 TR(`TTTC8001R`/`VTTC8001R`) 유지. NXT 체결 포함 여부 미확인(OPEN ITEM — 운영 스모크 필요). 확인 후 미포함이면 `TTTC0081R`(EXCG=ALL) 전환 별도 batch 처리.
## `FillStatus` 확장 (BAT-006 / REQ-018)
- `state, filled_qty, avg_price, raw` 외에 다음 필드를 운반한다.
@@ -568,3 +612,4 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
- 2026-06-21 :: BAT-049 (dcy-033) — 알람 채널 `trex.alerts` (topic, durable) 신규 추가. 알람 envelope 표준 JSON 스키마(7필드: source/severity/code/title/fields/occurred_at/schema_version, schema_version="1"), routing key `alarm.{severity}.{source}`, 발행자(모든 모듈)/소비자(trex-notifier), mandatory=False best-effort 정책. trex-core CRITICAL code 10종 초기 배선. 기존 wire 계약 불변(가산·격리). (REQ-098~101)
- 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); 매도 intent 에 **`qty`(부분 매도, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(→inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062)
- 2026-07-04 :: BAT-055 (dcy-001/002 v3 FR-8, REQ-116) — `trex.slots.req` additive 확장: 요청에 선택적 `strategy: str` 필터 추가(exact-match, `filter` AND 조합, `available_slots` 불변 — REQ-073 AC-6); 응답 슬롯 행에 `strategy_name`(null)/`strategy_params`({})/`markets`([]) 3필드 추가(PortfolioItem/hub 정본, safe default). 기존 소비자(orb·tsl) backward compatible(unknown 필드 무시). REQ-074 AC-5 additive 재도입(dcy-025 OQ-4 후속 확정). PortfolioItem에 `strategy_name`/`markets` 필드 추가 + hub_read.py 파싱 확장.
- 2026-07-06 :: BAT-056 (dcy-002 FR-6, REQ-117/118) — `OrderRequest.venue: str = "KRX"` additive 추가(기존 위치 인수 순서 불변 — AMQP wire 계약 영향 없음, 브로커 어댑터 계층 DTO까지만). KIS 어댑터: submit_order/cancel_order body에 `EXCG_ID_DVSN_CD` 항상 명시 전송(기존 미전송 → KRX 명시화 견고성 향상), cancel_order/get_current_price 시그니처 venue/market 파라미터 추가(기본값 KRX/"J" — 기존 호출자 무영향). poll_fill TR 유지(NXT 체결 포함 여부 미확인 open item). paper broker 시그니처 정합(venue/market 무시). 실측 근거: 2026-07-06 NXT 주문/취소 rt_cd=0 확인.
+2
View File
@@ -258,6 +258,7 @@
- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): INACTIVE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→INACTIVE(clear). 롤백(→INACTIVE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가.
- **실행 정책 생애주기(dcy-022/REQ-068)**: `timeout_sec`·`slippage_pct` 는 `limit_price` 와 동일한 생애주기(PENDING_* 진입 시 set, HOLDING/INACTIVE 탈출 시 clear). 슬롯의 `updated_at` 이 dispatch 시각 proxy 로 사용된다(mark_dispatched 가 갱신, stale-alert 와 동일 방법).
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
- **venue 전달 경로(BAT-056/REQ-117/118)**: `OrderRequest.venue`(기본 `"KRX"`) → KIS 어댑터가 `EXCG_ID_DVSN_CD`로 변환해 submit_order/cancel_order body에 항상 명시 전송. NXT 주문이면 `venue="NXT"` 지정 → `EXCG_ID_DVSN_CD=NXT`. 기존 orb/tsl은 venue 미지정(기본 KRX) → 이전 대비 EXCG 필드 추가만 다름(동작 동일). intent 메시지 → venue 전달은 tvr 팬-인 batch(후속) 범위 — 현재 wire 계약 불변.
### 운영자 cancel 백스톱 (dcy-022/REQ-071)
@@ -444,3 +445,4 @@ trex-notifier (단일 소비자) → 텔레그램 등 전달
| delivery_mode | 2 (persistent) | 1 (non-persistent) |
| 소비자 | 전략 모듈(strategy_id 필터) | trex-notifier 단독 |
| 발행 재시도 | 재연결 후 1회 retry | **없음** (best-effort) |
- 2026-07-06 :: BAT-056 (dcy-002 FR-6, REQ-117/118) — 브로커 집행 venue 전달 경로 명시: `OrderRequest.venue`(기본 KRX) → KIS 어댑터 `EXCG_ID_DVSN_CD` 항상 명시 전송(submit_order/cancel_order). `get_current_price` market 파라미터 추가(기본 J). poll_fill TR 유지(NXT 체결 open item). 기존 orb/tsl 경로 무영향.
+342
View File
@@ -0,0 +1,342 @@
"""Venue parameterization tests (REQ-117 AC-1~5, REQ-118 AC-1~8, BAT-056).
All tests stub the HTTP layer (token pre-seeded, httpx faked) and run always
without KIS credentials. Tests verify additive-only changes: existing KRX
path behavior is byte-equivalent to before, and NXT path uses correct EXCG.
REQ-117 AC coverage:
AC-1: venue default KRX → test_order_request_venue_default_krx
AC-2: venue NXT → test_order_request_venue_nxt
AC-3: 기존 회귀 → all existing tests pass (run via pytest)
AC-4: 위치 인수 순서 불변 → test_order_request_positional_args_unchanged
AC-5: paper venue 무영향 → test_paper_submit_nxt_accepted, test_paper_cancel_venue_ignored
REQ-118 AC coverage:
AC-1: submit KRX EXCG → test_submit_order_krx_includes_excg
AC-2: submit NXT EXCG → test_submit_order_nxt_includes_excg
AC-3: cancel NXT EXCG → test_cancel_order_nxt_includes_excg
AC-4: poll_fill TR 미확인 → (open item; see design.md)
AC-5: get_price default J → test_get_current_price_default_market_j
AC-6: get_price UN → test_get_current_price_market_un
AC-7: 기존 회귀 EXCG 추가 → test_submit_order_krx_only_adds_excg_field
AC-8: paper cancel 정합 → test_paper_cancel_venue_ignored
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import httpx
import pytest
from trex_core.adapters.broker import kis as kis_mod
from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.application.broker_port import BrokerPort, OrderRequest
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_broker() -> KISBroker:
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
b._token = "tok"
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
return b
def _live_broker() -> KISBroker:
b = KISBroker("12345678-01", app_key="k", app_secret="s",
base_url=_LIVE_URL, allow_live=True)
b._token = "tok"
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
return b
class _FakeResp:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
# ---------------------------------------------------------------------------
# REQ-117 AC-1: venue default KRX
# ---------------------------------------------------------------------------
def test_order_request_venue_default_krx():
"""REQ-117 AC-1: venue 미지정 시 기본값 KRX."""
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1)
assert req.venue == "KRX"
# ---------------------------------------------------------------------------
# REQ-117 AC-2: venue NXT
# ---------------------------------------------------------------------------
def test_order_request_venue_nxt():
"""REQ-117 AC-2: venue="NXT" 지정 시 NXT."""
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1, venue="NXT")
assert req.venue == "NXT"
# ---------------------------------------------------------------------------
# REQ-117 AC-4: 기존 위치 인수 순서 불변
# ---------------------------------------------------------------------------
def test_order_request_positional_args_unchanged():
"""REQ-117 AC-4: 기존 위치 인수(slot_id, side, symbol, qty) 순서 불변."""
req = OrderRequest("s1", "buy", "005930", 10)
assert req.slot_id == "s1"
assert req.side == "buy"
assert req.symbol == "005930"
assert req.qty == 10
assert req.limit_price is None
assert req.order_type == "00"
assert req.venue == "KRX" # 기본값
def test_order_request_full_positional():
"""기존 위치 인수 + 키워드 혼용 가능 확인."""
req = OrderRequest("s1", "sell", "000660", 5, 50000, "00", "NXT")
assert req.slot_id == "s1"
assert req.side == "sell"
assert req.symbol == "000660"
assert req.qty == 5
assert req.limit_price == 50000
assert req.order_type == "00"
assert req.venue == "NXT"
# ---------------------------------------------------------------------------
# REQ-117 AC-5 + REQ-118 AC-8: paper broker venue 무영향
# ---------------------------------------------------------------------------
def test_paper_submit_nxt_accepted():
"""REQ-117 AC-5: paper broker는 NXT든 KRX든 venue 무관하게 주문 수락."""
b = PaperBroker(fill_after_polls=0)
req_nxt = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1, venue="NXT")
req_krx = OrderRequest(slot_id="s2", side="buy", symbol="005930", qty=1, venue="KRX")
ack_nxt = b.submit_order(req_nxt)
ack_krx = b.submit_order(req_krx)
assert ack_nxt.accepted
assert ack_krx.accepted
def test_paper_cancel_venue_ignored():
"""REQ-117 AC-5 + REQ-118 AC-8: paper cancel_order는 venue 파라미터를 받되 무시한다."""
b = PaperBroker(fill_after_polls=1)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=5)
ack = b.submit_order(req)
# NXT venue로 cancel — paper는 venue 무관하게 취소 성공
result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=5, venue="NXT")
assert result is True
def test_paper_cancel_default_venue():
"""REQ-118 AC-8: venue 미지정(기본 KRX) cancel도 동작 유지."""
b = PaperBroker(fill_after_polls=1)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=3)
ack = b.submit_order(req)
result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=3)
assert result is True
# ---------------------------------------------------------------------------
# REQ-118 AC-1: submit_order KRX EXCG 포함
# ---------------------------------------------------------------------------
def test_submit_order_krx_includes_excg(monkeypatch):
"""REQ-118 AC-1: venue="KRX"(기본) submit 시 EXCG_ID_DVSN_CD="KRX" 포함."""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0", "output": {"ODNO": "0001", "KRX_FWDG_ORD_ORGNO": "06010"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1, limit_price=10000)
b.submit_order(req)
assert captured["body"]["EXCG_ID_DVSN_CD"] == "KRX"
# ---------------------------------------------------------------------------
# REQ-118 AC-2: submit_order NXT EXCG 포함
# ---------------------------------------------------------------------------
def test_submit_order_nxt_includes_excg(monkeypatch):
"""REQ-118 AC-2: venue="NXT" submit 시 EXCG_ID_DVSN_CD="NXT" 포함."""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0", "output": {"ODNO": "0002", "KRX_FWDG_ORD_ORGNO": "06010"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1,
limit_price=10000, venue="NXT")
b.submit_order(req)
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
# ---------------------------------------------------------------------------
# REQ-118 AC-3: cancel_order NXT EXCG 포함
# ---------------------------------------------------------------------------
def test_cancel_order_nxt_includes_excg(monkeypatch):
"""REQ-118 AC-3: venue="NXT" cancel 시 EXCG_ID_DVSN_CD="NXT" 포함."""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0"})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
b.cancel_order("06010:0000123456", side="sell", symbol="005930", qty=10, venue="NXT")
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
def test_cancel_order_krx_includes_excg(monkeypatch):
"""cancel_order 기본(KRX) 시 EXCG_ID_DVSN_CD="KRX" 포함."""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0"})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
b.cancel_order("06010:0000123456", side="sell", symbol="005930", qty=10)
assert captured["body"]["EXCG_ID_DVSN_CD"] == "KRX"
# ---------------------------------------------------------------------------
# REQ-118 AC-5: get_current_price 기본 market=J
# ---------------------------------------------------------------------------
def test_get_current_price_default_market_j(monkeypatch):
"""REQ-118 AC-5: market 미지정 시 FID_COND_MRKT_DIV_CODE=J 전달."""
captured = {}
def fake_get(url, headers=None, params=None, timeout=None):
captured["params"] = params
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": "49000"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "get", fake_get)
price = b.get_current_price("005930")
assert price == Decimal("49000")
assert captured["params"]["FID_COND_MRKT_DIV_CODE"] == "J"
# ---------------------------------------------------------------------------
# REQ-118 AC-6: get_current_price market=UN
# ---------------------------------------------------------------------------
def test_get_current_price_market_un(monkeypatch):
"""REQ-118 AC-6: market="UN" 지정 시 FID_COND_MRKT_DIV_CODE=UN 전달."""
captured = {}
def fake_get(url, headers=None, params=None, timeout=None):
captured["params"] = params
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": "150000"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "get", fake_get)
price = b.get_current_price("005930", market="UN")
assert price == Decimal("150000")
assert captured["params"]["FID_COND_MRKT_DIV_CODE"] == "UN"
# ---------------------------------------------------------------------------
# REQ-118 AC-7: 기존 회귀 — venue 미지정 submit 페이로드는 EXCG 추가만 다름
# ---------------------------------------------------------------------------
def test_submit_order_krx_only_adds_excg_field(monkeypatch):
"""REQ-118 AC-7: venue 미지정 submit body는 기존 대비 EXCG_ID_DVSN_CD="KRX" 추가만 다름.
기존 submit body fields: CANO, ACNT_PRDT_CD, PDNO, ORD_DVSN, ORD_QTY, ORD_UNPR
신규: 위 6개 + EXCG_ID_DVSN_CD="KRX"
"""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0", "output": {"ODNO": "9999", "KRX_FWDG_ORD_ORGNO": ""}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=2, limit_price=50000)
b.submit_order(req)
body = captured["body"]
# 기존 6개 필드 존재 확인
assert body["CANO"] == "12345678"
assert body["ACNT_PRDT_CD"] == "01"
assert body["PDNO"] == "005930"
assert body["ORD_DVSN"] == "00"
assert body["ORD_QTY"] == "2"
assert body["ORD_UNPR"] == "50000"
# 신규 추가 필드 — EXCG_ID_DVSN_CD="KRX"만 다름
assert body["EXCG_ID_DVSN_CD"] == "KRX"
# 7개 필드만 있음 (다른 필드 추가 없음)
assert set(body.keys()) == {
"CANO", "ACNT_PRDT_CD", "PDNO", "ORD_DVSN",
"ORD_QTY", "ORD_UNPR", "EXCG_ID_DVSN_CD",
}
# ---------------------------------------------------------------------------
# BrokerPort 프로토콜 정합 검증
# ---------------------------------------------------------------------------
def test_kis_broker_conforms_to_broker_port():
"""KISBroker가 BrokerPort 프로토콜을 만족하는지 구조적 확인."""
import inspect
# submit_order 시그니처: req: OrderRequest
sig = inspect.signature(KISBroker.submit_order)
assert "req" in sig.parameters
# cancel_order 시그니처: venue 파라미터 있음, 기본값 KRX
sig = inspect.signature(KISBroker.cancel_order)
assert "venue" in sig.parameters
assert sig.parameters["venue"].default == "KRX"
# get_current_price 시그니처: market 파라미터 있음, 기본값 J
sig = inspect.signature(KISBroker.get_current_price)
assert "market" in sig.parameters
assert sig.parameters["market"].default == "J"
def test_paper_broker_conforms_to_broker_port():
"""PaperBroker가 BrokerPort 프로토콜을 만족하는지 구조적 확인."""
import inspect
sig = inspect.signature(PaperBroker.cancel_order)
assert "venue" in sig.parameters
assert sig.parameters["venue"].default == "KRX"
sig = inspect.signature(PaperBroker.get_current_price)
assert "market" in sig.parameters
assert sig.parameters["market"].default == "J"
# ---------------------------------------------------------------------------
# 기존 cancel_order 호출자 호환: venue 없이 호출 가능
# ---------------------------------------------------------------------------
def test_paper_cancel_backward_compat_no_venue():
"""기존 코드가 venue 없이 cancel_order를 호출해도 동작한다(기본값 KRX)."""
b = PaperBroker(fill_after_polls=1)
req = OrderRequest(slot_id="s1", side="sell", symbol="005930", qty=2)
ack = b.submit_order(req)
# 기존 스타일 호출 (venue 없음)
result = b.cancel_order(ack.broker_order_id, side="sell", symbol="005930", qty=2)
assert result is True
+22 -5
View File
@@ -172,6 +172,10 @@ class KISBroker:
"ORD_DVSN": req.order_type,
"ORD_QTY": str(req.qty),
"ORD_UNPR": str(req.limit_price or 0),
# REQ-118: always send EXCG_ID_DVSN_CD from req.venue (default "KRX").
# Previously omitted (server defaulted to KRX); explicit is more robust.
# Real-account NXT order confirmed rt_cd=0 on 2026-07-06.
"EXCG_ID_DVSN_CD": req.venue,
}
try:
resp = httpx.post(
@@ -197,6 +201,9 @@ class KISBroker:
cano, product = self._account()
odno = broker_order_id.split(":")[-1]
today = datetime.now(tz=_KST).strftime("%Y%m%d")
# REQ-118 AC-4 OPEN ITEM: TTTC8001R NXT 체결 포함 여부 미확인 (웹 접근 불가 환경,
# KIS 개발자 센터 문서 조회 불가). 보수 구현: 기존 TR 유지. KRX 경로 동작 절대 불변.
# 향후 NXT 체결 미포함 실측 확인 시 TTTC0081R(EXCG=ALL) 전환 별도 batch 처리.
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
params = {
"CANO": cano, "ACNT_PRDT_CD": product,
@@ -311,14 +318,16 @@ class KISBroker:
out.append(BalanceItem(symbol=symbol, hldg_qty=qty))
return out
def get_current_price(self, symbol: str) -> Optional[Decimal]:
"""Current traded price `stck_prpr` via KIS inquire-price (REQ-038).
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
"""Current traded price `stck_prpr` via KIS inquire-price (REQ-038, REQ-118).
tr_id: `TTTC8397R` (live) / `VTTC8397R` (mock) per REQ-038 AC-7.
tr_id: `FHKST01010100` per REQ-038 AC-7 (live and mock share the same tr_id).
market: KIS FID_COND_MRKT_DIV_CODE (REQ-118). Default "J" (KRX equities)
preserves backward compatibility for existing safety-sell callers.
Returns None on network/parse error or missing price so the
safety-sell worker skips the slot this tick (no false trigger).
"""
params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": symbol}
params = {"FID_COND_MRKT_DIV_CODE": market, "FID_INPUT_ISCD": symbol}
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price",
@@ -338,7 +347,13 @@ class KISBroker:
except (InvalidOperation, ValueError):
return None
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
"""Cancel a previously submitted order (REQ-118).
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD
used at submit time. Default "KRX" preserves backward compatibility.
Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06.
"""
cano, product = self._account()
parts = broker_order_id.split(":")
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
@@ -347,6 +362,8 @@ class KISBroker:
"KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno,
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
# REQ-118: cancel must use the same EXCG_ID_DVSN_CD as the original submit.
"EXCG_ID_DVSN_CD": venue,
}
tr_id = "VTTC0013U" if self._mock else "TTTC0013U"
try:
+10 -3
View File
@@ -105,7 +105,10 @@ class PaperBroker:
)
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
"""Cancel a paper order. venue is accepted for BrokerPort protocol conformance
(REQ-118) but ignored — paper has no exchange concept. Existing behavior preserved.
"""
with self._lock:
o = self._orders.get(broker_order_id)
if o is None or o.filled >= o.qty:
@@ -135,7 +138,11 @@ class PaperBroker:
with self._lock:
self._prices[symbol] = Decimal(str(price))
def get_current_price(self, symbol: str) -> Optional[Decimal]:
"""Return the programmed current price for symbol, or None if unset."""
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
"""Return the programmed current price for symbol, or None if unset.
market accepted for BrokerPort protocol conformance (REQ-118) but
ignored — paper has no market distinction concept.
"""
with self._lock:
return self._prices.get(symbol)
+12 -3
View File
@@ -21,6 +21,7 @@ class OrderRequest:
qty: int
limit_price: Optional[int] = None # None => market order
order_type: str = "00" # KIS ORD_DVSN: 00 limit, 01 market
venue: str = "KRX" # exchange venue (REQ-117): KRX | NXT | SOR
@dataclass(frozen=True)
@@ -93,8 +94,13 @@ class BrokerPort(Protocol):
"""Observe current fill status for a previously accepted order."""
...
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
"""Best-effort cancel. Returns True if the broker accepted the cancel."""
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
"""Best-effort cancel. Returns True if the broker accepted the cancel.
venue: exchange venue of the original order (REQ-118). Cancel must use
the same EXCG_ID_DVSN_CD as the original submit. Default "KRX" preserves
backward compatibility for existing callers (orb/tsl).
"""
...
def get_balance(self) -> list[BalanceItem]:
@@ -106,12 +112,15 @@ class BrokerPort(Protocol):
"""
...
def get_current_price(self, symbol: str) -> Optional[Decimal]:
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
"""Return the current traded price (`stck_prpr`) for a symbol (REQ-038).
Used by the safety-sell worker to evaluate stop-loss/take-profit
thresholds. Returns None when the price is unavailable (network error,
unknown symbol, or — for the paper double — no programmed price).
market: KIS FID_COND_MRKT_DIV_CODE (REQ-118). Default "J" (KRX equities)
preserves backward compatibility. Use "UN" for overseas/NXT extended session.
"""
...