From 8329c351408de34009187faf71d0a25551a73267 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Fri, 28 Aug 2026 14:09:03 +0900 Subject: [PATCH] fix(cli): make the execution result contract honest about acceptance vs fills (STO-1731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KIS rt_cd == '0' means the order was ACCEPTED, not filled. The old contract conflated the two: slice status 'filled' (docstring said '주문 접수됨'), submittedQuantity described as '실제 집행된 수량', and a dry run reporting status 'completed' — three ways for a caller (the LLM included) to read '1,000 shares done' while a resting limit order had filled zero. - slice status 'filled' -> 'accepted' (journal reader keeps reading legacy 'filled' records; SLICE_FILLED stays as a deprecated alias) - dry-run top-level status 'completed' -> 'simulated' (exit code still 0) - submittedQuantity docstring states acceptance, not fills - a schedule that runs past the regular close now warns BEFORE execution: the confirmation prompt shows the slice count and shares that will be lost, and the same warning lands in result.notes for the --yes path (measured: 15:10 + 30min TWAP loses 33%) CHANGELOG 1.10.0 documents the contract changes. Execution tests: 172 passed; the 85 pre-existing local-environment failures are unchanged (verified against the clean tree). --- CHANGELOG.md | 31 ++++++++++++++++++ kis_agent/cli/algo_order.py | 47 ++++++++++++++++++++++++++- kis_agent/cli/schema.py | 12 ++++--- kis_agent/execution/__init__.py | 2 ++ kis_agent/execution/executor.py | 20 ++++++++++-- kis_agent/execution/journal.py | 5 ++- kis_agent/execution/runner.py | 35 ++++++++++++++++++-- tests/unit/test_execution_executor.py | 3 +- tests/unit/test_execution_runner.py | 2 +- 9 files changed, 143 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a305cb5..05d7413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ 모든 주목할 만한 변경사항이 이 파일에 문서화됩니다. +## [1.10.0] - 2026-08-28 + +### 📢 집행 결과 계약 정직화 (STO-1731) — **호출자(LLM 포함) 영향 확인 필수** + +KIS `order_cash`의 `rt_cd == "0"`은 **주문 접수**이지 체결이 아니다. 기존 계약은 +이 둘을 뒤섞어 지정가 미체결 시나리오에서 "체결 완료"로 오독됐다. + +**슬라이스 상태 값 변경: `filled` → `accepted`** + +- `AlgoSliceStatus.filled`는 제거되고 `accepted`로 대체된다. 의미는 동일하다 + (주문 접수됨) — 이름만 정직해졌다. +- 실행 원장(journal) 리더는 구버전 `filled` 레코드도 계속 읽는다. +- `SLICE_FILLED` 상수는 deprecated alias로 남긴다 (`SLICE_ACCEPTED` 권장). + +**dry-run 최상위 상태 변경: `completed` → `simulated`** + +- dry-run의 `AlgoOrderStatus`가 더 이상 `completed`로 위장하지 않는다. +- 종료코드는 여전히 0 (전량 시뮬레이션 = 성공). +- `dryRun: true` 필드는 유지된다. + +**스키마 문구 정정** + +- `submittedQuantity`: "실제 집행된 수량" → "주문이 접수된 수량 (체결 아님 — + 지정가 미체결분 포함. 체결수량은 별도 조회)" + +**마감 초과 사전 경고** + +- 스케줄이 정규장(09:00-15:30)을 넘으면 확인 프롬프트에 "⚠ 마감 초과"가 + 표시되고(슬라이스 수·유실 수량), `--yes` 경로에서도 `result.notes`에 + 동일 경고가 기록된다. (실측: 15:10 + 30분 TWAP은 33% 유실) + ## [1.9.0] - 2026-08-21 ### 🛡️ 주문 안전성 (중요) diff --git a/kis_agent/cli/algo_order.py b/kis_agent/cli/algo_order.py index 826fdd5..a000b92 100644 --- a/kis_agent/cli/algo_order.py +++ b/kis_agent/cli/algo_order.py @@ -143,6 +143,13 @@ def cmd_order_algo(args, algorithm: str): from kis_agent.cli import main as cli_main from kis_agent.execution import run_twap, run_vwap from kis_agent.execution.journal import find_incomplete_runs + from datetime import datetime + + from kis_agent.execution.runner import krx_regular_session + from kis_agent.execution.schedule import ( + build_twap_schedule, + build_vwap_schedule, + ) code = cli_main._resolve(args.code) side = args.side.lower() @@ -228,6 +235,42 @@ def cmd_order_algo(args, algorithm: str): if algorithm == "vwap": details["거래량 프로파일"] = f"과거 {args.profile_days}영업일" + # STO-1731: a schedule that runs past the close loses its tail slices to + # the session guard. The prompt used to show 집행시간 and 정규장 제한 as + # two unrelated strings — multiply them and the operator would have + # approved a 33% shortfall. Compute the real number before asking. + if not args.no_session_guard: + try: + from datetime import timedelta as _td + + begin = datetime.now() # the runner defaults start to now + if algorithm == "twap": + preview = build_twap_schedule( + total_quantity=args.qty, + slices=args.slices, + start=begin, + duration=_td(minutes=args.duration), + ) + else: + preview = build_vwap_schedule( + total_quantity=args.qty, + slices=args.slices, + start=begin, + duration=_td(minutes=args.duration), + weights=None, + ) + outside = [sl for sl in preview if not krx_regular_session(sl.scheduled_at)] + if outside: + lost = sum(sl.quantity for sl in outside) + details["⚠ 마감 초과"] = ( + f"슬라이스 {len(outside)}개({lost:,}주)가 정규장 밖 — " + f"집행되지 않고 유실됩니다" + ) + except ValueError: + # invalid qty/slices/duration — the runner's own validation + # reports it properly; the preview must not mask that error + pass + if not args.yes and not cli_main._confirm_order( f"{algo_label} {side_label}", details ): @@ -298,5 +341,7 @@ def _progress(slice_result): cli_main._out({"data": {"algoOrder": payload}}, args.pretty) # 부분 집행/중단은 종료코드로도 알린다 — 스크립트가 성공으로 오독하면 안 된다. - if result.status not in ("completed",): + # simulated(dry-run 전체 완료)도 성공이다 — STO-1731 이전에는 completed로 + # 위장해 있었을 뿐이다. + if result.status not in ("completed", "simulated"): sys.exit(2) diff --git a/kis_agent/cli/schema.py b/kis_agent/cli/schema.py index aaf0087..d4161ec 100644 --- a/kis_agent/cli/schema.py +++ b/kis_agent/cli/schema.py @@ -388,8 +388,10 @@ # 명령은 --duration 만큼 블로킹된다. 종료코드: 0 전량, 2 부분/중단, 1 오류. enum AlgoOrderStatus { - """모든 슬라이스 집행(또는 시뮬레이션) 완료""" + """모든 슬라이스 접수 완료 (실주문)""" completed + """dry-run 전체 완료 — 주문이 한 건도 전송되지 않았다""" + simulated """일부 슬라이스가 스킵되거나 실패""" partial """가드에 걸려 스케줄 도중 중단""" @@ -399,8 +401,8 @@ } enum AlgoSliceStatus { - """주문 접수됨""" - filled + """주문 접수됨 (체결 아님 — KIS rt_cd=0은 접수). 구버전 값: filled""" + accepted """dry-run 시뮬레이션 (주문 미전송)""" simulated """가드로 건너뜀""" @@ -437,7 +439,7 @@ quantity: Int! """슬라이스 상태""" status: AlgoSliceStatus! - """filled/simulated가 아닐 때의 기계 판독용 사유""" + """accepted/simulated가 아닐 때의 기계 판독용 사유""" reason: AlgoSliceReason """주문번호""" orderNo: String @@ -462,7 +464,7 @@ dryRun: Boolean! """요청한 총 주문수량""" totalQuantity: Int! - """실제 집행된 수량""" + """주문이 접수된 수량 (체결 아님 — 지정가 미체결분 포함. 체결수량은 별도 조회)""" submittedQuantity: Int! """스킵·실패로 집행되지 못한 수량 (뒤 슬라이스로 이월되지 않는다)""" unfilledQuantity: Int! diff --git a/kis_agent/execution/__init__.py b/kis_agent/execution/__init__.py index adf32a1..f472210 100644 --- a/kis_agent/execution/__init__.py +++ b/kis_agent/execution/__init__.py @@ -22,6 +22,7 @@ REASON_UPSTREAM_ABORT, SLICE_CANCELLED, SLICE_FAILED, + SLICE_ACCEPTED, SLICE_FILLED, SLICE_SIMULATED, SLICE_SKIPPED, @@ -71,6 +72,7 @@ "AlgoExecutor", "AlgoExecutionResult", "SliceExecution", + "SLICE_ACCEPTED", "SLICE_FILLED", "SLICE_SIMULATED", "SLICE_SKIPPED", diff --git a/kis_agent/execution/executor.py b/kis_agent/execution/executor.py index 6570004..93f0b34 100644 --- a/kis_agent/execution/executor.py +++ b/kis_agent/execution/executor.py @@ -38,7 +38,12 @@ "NOTE_KEY", ] -SLICE_FILLED = "filled" +# STO-1731: KIS rt_cd == "0" means the order was ACCEPTED, not filled. +# The old value "filled" read as 체결완료 and misled callers on unfilled +# limit orders. New output uses "accepted"; the old name stays as an +# alias so existing imports keep working. +SLICE_ACCEPTED = "accepted" +SLICE_FILLED = SLICE_ACCEPTED # deprecated alias — use SLICE_ACCEPTED SLICE_SIMULATED = "simulated" SLICE_SKIPPED = "skipped" SLICE_FAILED = "failed" @@ -118,7 +123,11 @@ class AlgoExecutionResult: @property def submitted_quantity(self) -> int: - """Shares actually sent to (or simulated against) the exchange.""" + """Shares whose orders were ACCEPTED by the exchange (rt_cd == "0"). + + This is an acceptance count, NOT a fill count — a resting limit + order is accepted with zero shares traded. 체결수량은 별도 조회. + """ return sum( s.quantity for s in self.slices @@ -360,10 +369,15 @@ def run( result.finished_at = self._now() return result - worked = {SLICE_FILLED, SLICE_SIMULATED} + worked = {SLICE_ACCEPTED, SLICE_SIMULATED} result.status = ( "completed" if all(s.status in worked for s in result.slices) else "partial" ) + if result.dry_run and result.status == "completed": + # STO-1731: a dry run must not read as "completed" execution at + # the top level — the first three fields otherwise all say + # "1,000 shares done" while zero orders were sent. + result.status = "simulated" result.finished_at = self._now() return result diff --git a/kis_agent/execution/journal.py b/kis_agent/execution/journal.py index 60f7ac3..d1fc715 100644 --- a/kis_agent/execution/journal.py +++ b/kis_agent/execution/journal.py @@ -297,7 +297,10 @@ def find_incomplete_runs( continue slices = [e for e in events if e.get("event") == EVENT_SLICE] - worked = [s for s in slices if s.get("status") in ("filled", "simulated")] + # "filled" is the pre-STO-1731 legacy value; new journals write "accepted" + worked = [ + s for s in slices if s.get("status") in ("accepted", "filled", "simulated") + ] incomplete.append( IncompleteRun( run_id=start.get("runId") or path.stem, diff --git a/kis_agent/execution/runner.py b/kis_agent/execution/runner.py index f4b27df..87ff47a 100644 --- a/kis_agent/execution/runner.py +++ b/kis_agent/execution/runner.py @@ -11,11 +11,11 @@ from datetime import datetime, timedelta from datetime import time as dt_time from pathlib import Path -from typing import Any, Callable, Dict, Optional, Sequence +from typing import Any, Callable, Dict, List, Optional, Sequence from .executor import NOTE_KEY, AlgoExecutionResult, AlgoExecutor, SliceExecution from .journal import ExecutionJournal, IncompleteExecutionError, find_incomplete_runs -from .schedule import build_twap_schedule, build_vwap_schedule +from .schedule import OrderSlice, build_twap_schedule, build_vwap_schedule from .volume_profile import VolumeProfile, fetch_volume_profile logger = logging.getLogger(__name__) @@ -69,6 +69,31 @@ def krx_regular_session(moment: datetime) -> bool: DEFAULT_CREDIT_TYPE_SELL = "11" # 융자상환매도 + + +def _session_overflow_note( + schedule: List[OrderSlice], restrict_to_session: bool +) -> Optional[str]: + """STO-1731: count slices the session guard will skip, BEFORE running. + + A schedule that starts late in the afternoon silently loses its tail to + the session guard (measured: 15:10 + 30min TWAP lost 33% of the order). + The operator must see that number before approving, and it must survive + into ``result.notes`` for the ``--yes`` path. + """ + if not restrict_to_session: + return None + outside = [s for s in schedule if not krx_regular_session(s.scheduled_at)] + if not outside: + return None + qty = sum(s.quantity for s in outside) + return ( + f"경고: 스케줄 중 {len(outside)}개 슬라이스({qty:,}주)가 정규장" + "(09:00-15:30)을 벗어나 실행되지 않습니다 — 마감 전에 완료할 수 " + "없는 스케줄입니다. duration을 줄이거나 시작 시간을 앞당기세요" + ) + + def _accepted(response: Optional[Dict[str, Any]]) -> bool: """True when KIS acknowledged the order.""" return bool(response) and response.get("rt_cd") == "0" @@ -394,6 +419,7 @@ def run_twap( start=begin, duration=timedelta(minutes=duration_minutes), ) + overflow_note = _session_overflow_note(schedule, restrict_to_session) plan = { "orderType": order_type, @@ -434,6 +460,8 @@ def run_twap( ), journal=journal, ) + if overflow_note: + result.notes.insert(0, overflow_note) _close_journal(journal, result) return result @@ -558,6 +586,7 @@ def run_vwap( duration=duration, weights=weights, ) + overflow_note = _session_overflow_note(schedule, restrict_to_session) plan = { "orderType": order_type, @@ -600,6 +629,8 @@ def run_vwap( journal=journal, ) + if overflow_note: + result.notes.insert(0, overflow_note) if fallback_note: result.notes.insert(0, fallback_note) elif profile.source_dates: diff --git a/tests/unit/test_execution_executor.py b/tests/unit/test_execution_executor.py index f34136c..19b913b 100644 --- a/tests/unit/test_execution_executor.py +++ b/tests/unit/test_execution_executor.py @@ -380,7 +380,8 @@ def test_dry_run_never_touches_the_order_api(self): assert order.calls == [] assert result.dry_run is True - assert result.status == "completed" + # STO-1731: a dry run must not read as "completed" execution + assert result.status == "simulated" assert all(s.status == SLICE_SIMULATED for s in result.slices) assert result.submitted_quantity == 40 diff --git a/tests/unit/test_execution_runner.py b/tests/unit/test_execution_runner.py index 878199c..5082163 100644 --- a/tests/unit/test_execution_runner.py +++ b/tests/unit/test_execution_runner.py @@ -919,7 +919,7 @@ def test_dry_run_is_never_refused(self, tmp_path): journal_dir=tmp_path, executor=instant_executor(agent), ) - assert result.status == "completed" + assert result.status == "simulated" # STO-1731: dry-run is not "completed" def test_opt_out_allows_proceeding(self, tmp_path): self._crash(tmp_path)