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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### 🛡️ 주문 안전성 (중요)
Expand Down
47 changes: 46 additions & 1 deletion kis_agent/cli/algo_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -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)
12 changes: 7 additions & 5 deletions kis_agent/cli/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,10 @@
# 명령은 --duration 만큼 블로킹된다. 종료코드: 0 전량, 2 부분/중단, 1 오류.

enum AlgoOrderStatus {
"""모든 슬라이스 집행(또는 시뮬레이션) 완료"""
"""모든 슬라이스 접수 완료 (실주문)"""
completed
"""dry-run 전체 완료 — 주문이 한 건도 전송되지 않았다"""
simulated
"""일부 슬라이스가 스킵되거나 실패"""
partial
"""가드에 걸려 스케줄 도중 중단"""
Expand All @@ -399,8 +401,8 @@
}

enum AlgoSliceStatus {
"""주문 접수됨"""
filled
"""주문 접수됨 (체결 아님 — KIS rt_cd=0은 접수). 구버전 값: filled"""
accepted
"""dry-run 시뮬레이션 (주문 미전송)"""
simulated
"""가드로 건너뜀"""
Expand Down Expand Up @@ -437,7 +439,7 @@
quantity: Int!
"""슬라이스 상태"""
status: AlgoSliceStatus!
"""filled/simulated가 아닐 때의 기계 판독용 사유"""
"""accepted/simulated가 아닐 때의 기계 판독용 사유"""
reason: AlgoSliceReason
"""주문번호"""
orderNo: String
Expand All @@ -462,7 +464,7 @@
dryRun: Boolean!
"""요청한 총 주문수량"""
totalQuantity: Int!
"""실제 집행된 수량"""
"""주문이 접수된 수량 (체결 아님 — 지정가 미체결분 포함. 체결수량은 별도 조회)"""
submittedQuantity: Int!
"""스킵·실패로 집행되지 못한 수량 (뒤 슬라이스로 이월되지 않는다)"""
unfilledQuantity: Int!
Expand Down
2 changes: 2 additions & 0 deletions kis_agent/execution/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
REASON_UPSTREAM_ABORT,
SLICE_CANCELLED,
SLICE_FAILED,
SLICE_ACCEPTED,
SLICE_FILLED,
SLICE_SIMULATED,
SLICE_SKIPPED,
Expand Down Expand Up @@ -71,6 +72,7 @@
"AlgoExecutor",
"AlgoExecutionResult",
"SliceExecution",
"SLICE_ACCEPTED",
"SLICE_FILLED",
"SLICE_SIMULATED",
"SLICE_SKIPPED",
Expand Down
20 changes: 17 additions & 3 deletions kis_agent/execution/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion kis_agent/execution/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 33 additions & 2 deletions kis_agent/execution/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -434,6 +460,8 @@ def run_twap(
),
journal=journal,
)
if overflow_note:
result.notes.insert(0, overflow_note)
_close_journal(journal, result)
return result

Expand Down Expand Up @@ -558,6 +586,7 @@ def run_vwap(
duration=duration,
weights=weights,
)
overflow_note = _session_overflow_note(schedule, restrict_to_session)

plan = {
"orderType": order_type,
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_execution_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_execution_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading