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
49 changes: 48 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,54 @@

모든 주목할 만한 변경사항이 이 파일에 문서화됩니다.

## [Unreleased]
## [1.9.0] - 2026-08-21

### 🛡️ 주문 안전성 (중요)

레드팀 감사에서 나온 치명 2건을 수정했습니다. 알고리즘 주문을 쓰지 않더라도
**모든 주문 경로에 적용**됩니다.

**주문은 이제 절대 재전송되지 않습니다** (STO-1729)

`KISClient.make_request`는 타임아웃·5xx에 기본 2회까지 재시도했고, 이 정책이
주문 POST에도 그대로 적용됐습니다. 타임아웃은 *응답*에 걸린 것이지 *동작*에
걸린 것이 아닙니다 — 거래소에 도달해 접수된 주문의 응답만 유실됐는데 같은
본문을 다시 보내면 중복 주문이 됩니다. KIS 주문 API는 멱등키를 받지 않아
거래소가 걸러줄 방법도 없습니다.

이제 GET이 아닌 요청은 `retries` 값과 무관하게 1회로 강제됩니다. 응답이 유실되면
주문은 실패로 보고되고, 접수 여부는 `kis order list` / `kis trades`로 확인해야
합니다. 조회 API의 재시도는 그대로입니다.

**집행 원장이 추가됐습니다** (STO-1730)

TWAP/VWAP은 30~120분 블로킹으로 동작합니다. 그 사이 프로세스가 죽으면
(SIGKILL·절전·OOM·에이전트 타임아웃) 이미 나간 주문번호가 메모리와 함께
사라졌습니다.

이제 자식 주문은 거래소가 접수를 확인한 **즉시** JSONL 원장에 flush + fsync
됩니다. 프로세스가 어떻게 죽든 나간 주문은 파일에 남습니다.

```
~/.kis-agent/executions/20260821/20260821-133000-005930-buy-3f9a2c.jsonl
```

- 위치는 `--journal-dir` 또는 `KIS_EXECUTION_JOURNAL_DIR`로 변경
- `result.run_id` / `result.journal_path`, CLI JSON의 `runId` / `journalPath`
- 진행 출력(stderr)에도 주문번호가 찍힙니다 — 원장이 실패해도 스크롤백에는 남습니다
- **미완료 집행 가드**: `end` 레코드가 없는 원장(=죽은 실행)이 같은 종목·**같은
방향**에 있으면 새 집행을 거부하고 이미 나간 주문번호를 보여줍니다. 반대 방향
(청산)은 막지 않습니다. CLI는 `--ignore-incomplete`, Python API는
`check_incomplete=False`로 강행합니다 (`IncompleteExecutionError`)
- 가드가 보여주는 주문번호는 적게 나올 수 있습니다 — 재전송을 하지 않으므로 응답이
유실된 주문은 접수됐더라도 `failed`로 기록됩니다. `kis order list`가 정본입니다
- 정상 완료와 Ctrl+C는 원장을 닫으므로 가드에 걸리지 않습니다. 처리되지 않은
즉사(SIGKILL·OOM·하네스 타임아웃)만 걸립니다
- dry-run 원장은 거래소에 닿지 않으므로 가드 대상이 아닙니다

원장 기록 실패는 주문을 중단시키지 않습니다. 디스크가 찼다고 절반 집행된 부모
주문을 버리는 것이 더 나쁩니다.


### 📈 알고리즘 주문 — TWAP / VWAP (NEW)

Expand Down
85 changes: 85 additions & 0 deletions docs/api/algo-orders.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,91 @@ result = agent.twap_order(
바꾸는 것은 하면 안 되는 종류의 일이다. 폴백이 실제로 일어나면 해당 슬라이스
`message`에 신용 거부 사유와 함께 기록된다.

## 집행 원장 — 죽어도 남는 기록

자식 주문은 거래소가 접수를 확인한 **즉시** JSONL 원장에 기록되고 fsync됩니다.
프로세스가 어떻게 죽든(SIGKILL·절전·OOM·에이전트 타임아웃) 이미 나간 주문은
파일에 남습니다.

```python
result = agent.twap_order("005930", "buy", 1000)
print(result.run_id) # 20260821-133000-005930-buy-3f9a2c
print(result.journal_path) # ~/.kis-agent/executions/20260821/....jsonl
```

```bash
$ cat ~/.kis-agent/executions/20260821/20260821-133000-005930-buy-3f9a2c.jsonl
{"ts": "...", "runId": "...", "event": "start", "code": "005930", "totalQuantity": 1000, ...}
{"ts": "...", "runId": "...", "event": "slice", "index": 0, "quantity": 167, "status": "filled", "orderNo": "0000123456"}
{"ts": "...", "runId": "...", "event": "slice", "index": 1, "quantity": 167, "status": "filled", "orderNo": "0000123457"}
...
{"ts": "...", "runId": "...", "event": "end", "status": "completed", "submittedQuantity": 1000, ...}
```

| 인자 | 기본값 | 설명 |
|:---|:---|:---|
| `journal_dir` | `~/.kis-agent/executions` | 원장 위치. `KIS_EXECUTION_JOURNAL_DIR`로도 지정 |
| `journal_enabled` | `True` | 끄지 말 것 — 죽으면 주문번호 복구 경로가 사라진다 |

### 미완료 집행 가드

`end` 레코드가 없는 원장은 **주문이 이미 나간 채로 프로세스가 죽었다**는 서명입니다.
같은 종목에 그런 기록이 있으면 CLI는 새 집행을 거부합니다:

```bash
$ kis order twap 005930 --side buy --qty 1000 --yes
{
"error": "005930에 완료되지 않은 집행 기록이 1건 있습니다. 이미 나간 주문을 확인한 뒤 진행하세요 (강행하려면 --ignore-incomplete).",
"code": "IncompleteExecutionFound",
"data": {"incompleteRuns": [{"runId": "...", "orderNumbers": ["0000123456"], "submittedQuantity": 167, "totalQuantity": 1000, ...}]}
}
```

이걸 보지 않고 같은 부모 주문을 다시 내는 것이 포지션이 조용히 두 배가 되는 경로입니다.
확인 후 강행하려면 `--ignore-incomplete`. dry-run은 거래소에 닿지 않으므로 가드 대상이 아닙니다.

Python API도 같은 보호를 받습니다 — CLI만 막고 문서화된 API를 열어두면 의미가 없습니다:

```python
from kis_agent.execution import IncompleteExecutionError, find_incomplete_runs

try:
agent.twap_order("005930", "buy", 1000)
except IncompleteExecutionError as e:
for run in e.runs:
print(run.describe()) # 20260821-...: 005930 buy 167/1000주 접수 (주문번호 0000123456)
# 대사한 뒤에만 끈다
agent.twap_order("005930", "buy", 833, check_incomplete=False)
```

`find_incomplete_runs(code)`로 직접 조회할 수도 있습니다.

가드는 **같은 방향**만 막습니다. 크래시한 매수가 청산 매도까지 막으면, 사고 직후
가장 하고 싶은 일을 도구가 방해하는 셈입니다. 중복 위험은 어차피 같은 방향에서 생깁니다.

`order_numbers`는 **적게 나올 수 있습니다.** 주문은 재전송되지 않으므로 응답이 유실된
요청은 거래소가 접수했더라도 `failed`로 기록됩니다. 이 목록은 대사의 출발점이지 완전한
목록이 아닙니다 — `kis order list` / `kis trades`가 정본입니다.

가드는 **당일** 원장만 봅니다. 정상 완료와 Ctrl+C는 원장을 닫으므로 걸리지 않고,
처리되지 않은 즉사만 걸립니다. 어제 죽은 실행은 오늘을 막지 않는데, KRX 당일 주문은
장 마감을 넘기지 못하는 데다 한 번의 크래시가 그 종목을 영구히 막으면 안 되기
때문입니다. 다만 **부분 체결된 포지션은 남으므로**, 크래시 이후에는 잔고를 확인하고
다음 주문 수량을 조정하세요.

원장 기록 실패는 주문을 중단시키지 않습니다 — 디스크가 찼다고 절반 집행된 부모 주문을
버리는 것이 더 나쁩니다. 그래서 진행 출력(stderr)에도 주문번호를 함께 싣습니다.

## 주문은 재전송되지 않는다

`KISClient`는 GET이 아닌 요청을 **절대 재시도하지 않습니다**. 타임아웃은 *응답*에
걸린 것이지 *동작*에 걸린 것이 아니라, 접수된 주문의 응답만 유실됐는데 같은 본문을
다시 보내면 중복 주문이 되기 때문입니다.

응답이 유실되면 슬라이스는 `failed` / `order_rejected`로 기록됩니다. **접수됐는데
실패로 보일 수 있다**는 뜻이므로, 그런 슬라이스가 있으면 `kis order list`나
`kis trades`로 실제 접수 여부를 확인하세요. 조회 API의 재시도는 그대로입니다.

## 결과 읽기

```python
Expand Down
10 changes: 10 additions & 0 deletions docs/cli/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ VWAP은 과거 **완료된** 영업일 분봉만 쓴다. 당일 부분 데이터
| `--no-session-guard` | 정규장(09:00-15:30) 제한 해제 |
| `--profile-days` | 거래량 프로파일 영업일 수 (VWAP 전용, 기본 5) |
| `--dry-run` | 주문을 전송하지 않고 스케줄만 시뮬레이션 |
| `--journal-dir` | 집행 원장 디렉터리 (기본 `~/.kis-agent/executions`) |
| `--no-journal` | 원장 기록 비활성화 (권장하지 않음) |
| `--ignore-incomplete` | 같은 종목의 미완료 집행 기록이 있어도 강행 |

**동작 규칙**

Expand All @@ -243,6 +246,13 @@ VWAP은 과거 **완료된** 영업일 분봉만 쓴다. 당일 부분 데이터
깨지지 않는다.
- 스킵된 수량은 뒤 슬라이스로 이월하지 않는다. `unfilledQuantity`로 보고한다.
- 종료코드: 전량 집행 `0`, 부분 집행/중단 `2`, 인자 오류/예외 `1`.
- 자식 주문은 접수 즉시 `~/.kis-agent/executions/`의 JSONL 원장에 기록된다.
프로세스가 죽어도 나간 주문번호는 파일에 남는다 (`runId`·`journalPath`로 확인).
- 같은 종목·**같은 방향**에 미완료 집행 기록(죽은 실행)이 있으면 새 집행을 거부한다.
반대 방향(청산)은 막지 않는다. 이미 나간 주문을 확인한 뒤 `--ignore-incomplete`로
강행한다. 기록된 주문번호는 적게 나올 수 있으므로 `kis order list`로 대사한다.
- 주문 API는 **재전송하지 않는다**. 응답이 유실되면 슬라이스가 `failed`로 남지만
실제로는 접수됐을 수 있으므로 `kis order list`로 확인한다.

**응답 예시**

Expand Down
2 changes: 1 addition & 1 deletion kis_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
)
from .websocket.client import KisWebSocket

__version__ = "1.8.0"
__version__ = "1.9.0"
__all__ = [
"Agent",
"KisWebSocket",
Expand Down
76 changes: 75 additions & 1 deletion kis_agent/cli/algo_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import sys
from pathlib import Path

__all__ = ["add_algo_parsers", "cmd_order_algo"]

Expand Down Expand Up @@ -100,6 +101,24 @@ def add_algo_parsers(order_sub) -> None:
dest="dry_run",
help="주문을 전송하지 않고 스케줄만 시뮬레이션",
)
oa.add_argument(
"--journal-dir",
default="",
dest="journal_dir",
help="집행 원장 디렉터리 (기본 ~/.kis-agent/executions)",
)
oa.add_argument(
"--no-journal",
action="store_true",
dest="no_journal",
help="집행 원장 기록 비활성화 (권장하지 않음 — 죽으면 주문번호가 사라진다)",
)
oa.add_argument(
"--ignore-incomplete",
action="store_true",
dest="ignore_incomplete",
help="같은 종목의 미완료 집행 기록이 있어도 강행",
)
if algo == "vwap":
oa.add_argument(
"--profile-days",
Expand All @@ -123,6 +142,7 @@ def cmd_order_algo(args, algorithm: str):
# 시점에 일어나므로 테스트의 ``kis_agent.cli.main.*`` 패치도 그대로 먹는다.
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

code = cli_main._resolve(args.code)
side = args.side.lower()
Expand All @@ -133,6 +153,44 @@ def cmd_order_algo(args, algorithm: str):
if order_type in ("01", "03", "05", "06"):
price = 0

journal_dir = Path(args.journal_dir).expanduser() if args.journal_dir else None

# 같은 종목·같은 방향의 미완료 집행이 남아 있으면 먼저 멈춘다. 미완료
# 원장은 "주문이 이미 나간 채로 프로세스가 죽었다"의 서명이고, 그걸 보지
# 않고 같은 부모 주문을 다시 내는 것이 포지션이 조용히 두 배가 되는 경로다.
# 반대 방향은 막지 않는다 — 크래시 직후 가장 하고 싶은 일이 청산이다.
# 토큰을 만들기 전에 검사해 헛된 인증을 피한다.
if not args.dry_run and not args.ignore_incomplete:
incomplete = find_incomplete_runs(code, base_dir=journal_dir, side=side)
if incomplete:
cli_main._out(
{
"error": (
f"{code} {side} 방향에 완료되지 않은 집행 기록이 "
f"{len(incomplete)}건 있습니다. 이미 나간 주문을 "
"확인한 뒤 진행하세요 (kis order list / kis trades로 "
"실제 접수 여부 확인, 강행하려면 --ignore-incomplete)."
),
"code": "IncompleteExecutionFound",
"data": {
"incompleteRuns": [
{
"runId": r.run_id,
"journalPath": str(r.path),
"side": r.side,
"submittedQuantity": r.submitted_quantity,
"totalQuantity": r.total_quantity,
"orderNumbers": r.order_numbers,
"startedAt": r.started_at,
"summary": r.describe(),
}
for r in incomplete
]
},
}
)
sys.exit(1)

agent = cli_main._create_agent()
name = cli_main._get_name(agent, code)
side_label = "매수" if side == "buy" else "매도"
Expand Down Expand Up @@ -179,9 +237,14 @@ def cmd_order_algo(args, algorithm: str):
# 집행은 duration 만큼 블로킹된다. stdout은 최종 JSON 전용으로 두고,
# 진행 상황은 stderr로 흘려보내 LLM 파싱 계약을 깨지 않는다.
def _progress(slice_result):
# 주문번호를 여기 싣는 이유: 원장이 어떤 이유로든 실패해도 터미널
# 스크롤백에는 남아야 한다. 죽은 집행을 대사할 때 이게 유일한 단서일 수 있다.
order_ref = (
f" 주문번호 {slice_result.order_no}" if slice_result.order_no else ""
)
sys.stderr.write(
f" [{algo_label}] 슬라이스 {slice_result.index + 1} "
f"{slice_result.quantity:,}주 → {slice_result.status}"
f"{slice_result.quantity:,}주 → {slice_result.status}{order_ref}"
f"{' (' + slice_result.message + ')' if slice_result.message else ''}\n"
)
sys.stderr.flush()
Expand All @@ -205,8 +268,19 @@ def _progress(slice_result):
"dry_run": args.dry_run,
"restrict_to_session": not args.no_session_guard,
"progress": _progress,
"journal_dir": journal_dir,
"journal_enabled": not args.no_journal,
# CLI는 토큰을 만들기 전에 이미 선검사했다 (--ignore-incomplete도 거기서 처리).
"check_incomplete": False,
}

if not args.no_journal:
sys.stderr.write(
f" [{algo_label}] 집행 원장: "
f"{journal_dir or '~/.kis-agent/executions'} 아래에 기록됩니다\n"
)
sys.stderr.flush()

try:
if algorithm == "twap":
result = run_twap(agent, **common)
Expand Down
Loading
Loading