diff --git a/CHANGELOG.md b/CHANGELOG.md index 7904903..a305cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/api/algo-orders.md b/docs/api/algo-orders.md index 12460ed..d78d5a7 100644 --- a/docs/api/algo-orders.md +++ b/docs/api/algo-orders.md @@ -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 diff --git a/docs/cli/usage.md b/docs/cli/usage.md index b6f0ba7..b406327 100644 --- a/docs/cli/usage.md +++ b/docs/cli/usage.md @@ -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` | 같은 종목의 미완료 집행 기록이 있어도 강행 | **동작 규칙** @@ -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`로 확인한다. **응답 예시** diff --git a/kis_agent/__init__.py b/kis_agent/__init__.py index e0afef9..2403d99 100644 --- a/kis_agent/__init__.py +++ b/kis_agent/__init__.py @@ -8,7 +8,7 @@ ) from .websocket.client import KisWebSocket -__version__ = "1.8.0" +__version__ = "1.9.0" __all__ = [ "Agent", "KisWebSocket", diff --git a/kis_agent/cli/algo_order.py b/kis_agent/cli/algo_order.py index 918f739..826fdd5 100644 --- a/kis_agent/cli/algo_order.py +++ b/kis_agent/cli/algo_order.py @@ -6,6 +6,7 @@ """ import sys +from pathlib import Path __all__ = ["add_algo_parsers", "cmd_order_algo"] @@ -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", @@ -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() @@ -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 "매도" @@ -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() @@ -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) diff --git a/kis_agent/core/agent.py b/kis_agent/core/agent.py index 443042a..d9bf85b 100644 --- a/kis_agent/core/agent.py +++ b/kis_agent/core/agent.py @@ -32,6 +32,8 @@ from .technical_analysis import TechnicalAnalysisMixin if TYPE_CHECKING: # pragma: no cover - import cycle guard for type checkers + from pathlib import Path + from ..execution import AlgoExecutionResult @@ -819,6 +821,9 @@ def twap_order( dry_run: bool = False, restrict_to_session: bool = True, progress: Optional[Callable[[Any], None]] = None, + journal_dir: Optional["Path"] = None, + journal_enabled: bool = True, + check_incomplete: bool = True, ) -> "AlgoExecutionResult": """TWAP 주문 - 대량 주문을 지정 시간 동안 균등 분할 집행 @@ -852,6 +857,13 @@ def twap_order( dry_run: True면 주문을 전송하지 않고 스케줄만 시뮬레이션 restrict_to_session: 정규장(09:00-15:30) 밖 슬라이스 스킵 progress: 슬라이스 완료마다 호출되는 콜백 + journal_dir: 집행 원장 디렉터리 + (기본 ~/.kis-agent/executions, KIS_EXECUTION_JOURNAL_DIR로 재정의) + journal_enabled: 자식 주문을 즉시 디스크에 기록. 끄지 말 것 — + 프로세스가 죽으면 나간 주문번호를 복구할 방법이 없어진다 + check_incomplete: 같은 종목의 이전 집행이 원장을 닫지 못하고 + 죽었으면 시작을 거부한다 (IncompleteExecutionError). + 그 실행이 남긴 주문을 대사한 뒤에만 끈다 Returns: AlgoExecutionResult: 집행 결과. status는 completed/partial/ @@ -859,6 +871,8 @@ def twap_order( Raises: ValueError: 수량·시간·가드 인자가 유효 범위를 벗어난 경우 + IncompleteExecutionError: 같은 종목에 완료되지 않은 집행 기록이 + 남아 있는 경우 (check_incomplete=True일 때) Examples: >>> agent = Agent(app_key="...", app_secret="...", account_no="...") @@ -889,6 +903,8 @@ def twap_order( - 미체결 정정/취소는 하지 않는다. 최유리지정가(03)가 기본인 이유 - 스킵된 수량은 뒤 슬라이스로 이월되지 않는다 (``unfilled_quantity``로 보고) + - 자식 주문은 접수 즉시 원장에 기록된다. 프로세스가 죽어도 + ``result.journal_path``의 파일에서 나간 주문을 복구할 수 있다 """ from ..execution import run_twap @@ -912,6 +928,9 @@ def twap_order( dry_run=dry_run, restrict_to_session=restrict_to_session, progress=progress, + journal_dir=journal_dir, + journal_enabled=journal_enabled, + check_incomplete=check_incomplete, ) def vwap_order( @@ -935,6 +954,9 @@ def vwap_order( dry_run: bool = False, restrict_to_session: bool = True, progress: Optional[Callable[[Any], None]] = None, + journal_dir: Optional["Path"] = None, + journal_enabled: bool = True, + check_incomplete: bool = True, ) -> "AlgoExecutionResult": """VWAP 주문 - 과거 거래량 프로파일에 비례해 분할 집행 @@ -966,6 +988,13 @@ def vwap_order( dry_run: True면 주문을 전송하지 않고 스케줄만 시뮬레이션 restrict_to_session: 정규장 밖 슬라이스 스킵 progress: 슬라이스 완료마다 호출되는 콜백 + journal_dir: 집행 원장 디렉터리 + (기본 ~/.kis-agent/executions, KIS_EXECUTION_JOURNAL_DIR로 재정의) + journal_enabled: 자식 주문을 즉시 디스크에 기록. 끄지 말 것 — + 프로세스가 죽으면 나간 주문번호를 복구할 방법이 없어진다 + check_incomplete: 같은 종목의 이전 집행이 원장을 닫지 못하고 + 죽었으면 시작을 거부한다 (IncompleteExecutionError). + 그 실행이 남긴 주문을 대사한 뒤에만 끈다 Returns: AlgoExecutionResult: 집행 결과. ``notes``에 프로파일 출처 @@ -973,6 +1002,8 @@ def vwap_order( Raises: ValueError: 수량·시간·버킷 수가 유효 범위를 벗어난 경우 + IncompleteExecutionError: 같은 종목에 완료되지 않은 집행 기록이 + 남아 있는 경우 (check_incomplete=True일 때) Examples: >>> agent = Agent(app_key="...", app_secret="...", account_no="...") @@ -1016,6 +1047,9 @@ def vwap_order( dry_run=dry_run, restrict_to_session=restrict_to_session, progress=progress, + journal_dir=journal_dir, + journal_enabled=journal_enabled, + check_incomplete=check_incomplete, ) # ============================================================================ diff --git a/kis_agent/core/client.py b/kis_agent/core/client.py index a29d964..90d4706 100644 --- a/kis_agent/core/client.py +++ b/kis_agent/core/client.py @@ -338,7 +338,8 @@ def make_request( tr_id (str): API 트랜잭션 ID params (Dict[str, Any]): API 요청 파라미터 method (str): HTTP 메서드 (기본값: 'GET') - retries (int): 재시도 횟수 (기본값: 5) + retries (int): 재시도 횟수 (기본값: 2). GET이 아닌 요청은 + 중복 주문을 막기 위해 값과 무관하게 1회로 강제된다. headers (Dict[str, str], optional): 추가 HTTP 헤더 Returns: @@ -373,6 +374,25 @@ def make_request( logger.debug(f"요청 헤더: {headers}") logger.debug(f"요청 파라미터: {params}") + # 상태를 바꾸는 요청은 절대 재전송하지 않는다 (STO-1729). + # + # 타임아웃은 *응답*에 걸린 것이지 *동작*에 걸린 것이 아니다. 거래소에 + # 도달해 접수된 주문의 응답만 유실됐는데 같은 본문을 다시 보내면 중복 + # 주문이 된다. KIS 주문 API는 클라이언트 주문 ID(멱등키)를 받지 않으므로 + # 거래소가 중복을 걸러줄 방법도 없다. + # + # 이 저장소의 POST는 전부 주문 계열(`*/order_api.py` 16곳)이고, 토큰 + # 발급은 `core.auth`가 requests.post를 직접 써서 이 경로를 타지 않는다. + # 따라서 메서드 하나로 판정해도 조회 성능에는 영향이 없다. + # 정확히 1회다. `> 1`만 걸러내면 retries=0이 그대로 통과해 루프가 아예 + # 돌지 않고, 주문이 전송되지 않은 채 "Unknown error after retries"로 + # 끝난다 — 호출자는 그것을 전송 실패와 구분할 수 없다. + if method.upper() != "GET" and retries != 1: + logger.debug( + f"[{tr_id}] 상태 변경 요청({method})이므로 전송을 1회로 고정합니다." + ) + retries = 1 + last_exception = None for attempt in range(retries): diff --git a/kis_agent/execution/__init__.py b/kis_agent/execution/__init__.py index 0d60222..adf32a1 100644 --- a/kis_agent/execution/__init__.py +++ b/kis_agent/execution/__init__.py @@ -29,6 +29,13 @@ AlgoExecutor, SliceExecution, ) +from .journal import ( + ExecutionJournal, + IncompleteExecutionError, + IncompleteRun, + find_incomplete_runs, + read_journal, +) from .runner import ( DEFAULT_CREDIT_TYPE_BUY, DEFAULT_CREDIT_TYPE_SELL, @@ -76,6 +83,12 @@ "REASON_INTERRUPTED", "REASON_UPSTREAM_ABORT", "NOTE_KEY", + # journal + "ExecutionJournal", + "IncompleteExecutionError", + "IncompleteRun", + "find_incomplete_runs", + "read_journal", # runner "run_twap", "run_vwap", diff --git a/kis_agent/execution/executor.py b/kis_agent/execution/executor.py index 91f7754..6570004 100644 --- a/kis_agent/execution/executor.py +++ b/kis_agent/execution/executor.py @@ -113,6 +113,8 @@ class AlgoExecutionResult: finished_at: Optional[datetime] = None dry_run: bool = False notes: List[str] = field(default_factory=list) + run_id: str = "" + journal_path: str = "" @property def submitted_quantity(self) -> int: @@ -136,6 +138,8 @@ def to_dict(self) -> Dict[str, Any]: "side": self.side, "status": self.status, "dryRun": self.dry_run, + "runId": self.run_id, + "journalPath": self.journal_path, "totalQuantity": self.total_quantity, "submittedQuantity": self.submitted_quantity, "unfilledQuantity": self.unfilled_quantity, @@ -205,6 +209,7 @@ def run( session_guard: Optional[Callable[[datetime], bool]] = None, progress: Optional[Callable[[SliceExecution], None]] = None, order_kwargs: Optional[Dict[str, Any]] = None, + journal: Optional[Any] = None, ) -> AlgoExecutionResult: """Execute ``schedule``, waiting between slices and applying guards. @@ -227,6 +232,11 @@ def run( progress: Invoked with each :class:`SliceExecution` as it completes. order_kwargs: Extra keyword arguments forwarded to ``order_func`` (order division, exchange, price, ...). + journal: Optional object exposing ``record_slice(dict)``. Each child + order is written to it **before** the progress callback fires, + so a process killed mid-run still leaves the order number on + disk. Journal failures are swallowed by the journal itself — + a full disk must not abandon a half-worked parent order. Returns: The aggregate result. Guard rejections and API failures are recorded @@ -309,6 +319,10 @@ def run( return result result.slices.append(execution) + # Durability before display: if the process dies between these two + # lines, the order number is already on disk. + if journal is not None: + journal.record_slice(execution.to_dict()) if progress: progress(execution) diff --git a/kis_agent/execution/journal.py b/kis_agent/execution/journal.py new file mode 100644 index 0000000..60f7ac3 --- /dev/null +++ b/kis_agent/execution/journal.py @@ -0,0 +1,313 @@ +"""Durable, append-only record of what an algorithmic order actually sent. + +A sliced parent order works for 30 to 120 minutes. In that window the process +can die in ways it never gets to handle — SIGKILL, a closed laptop lid, an OOM, +an agent harness timeout. Everything the run knew then lives only in memory and +in a stdout payload that is written once, at the very end. + +That is the wrong shape for money. This module writes each child order to disk +the instant the exchange acknowledges it, flushed and fsynced, so the answer to +"what went out before it died?" is always a file rather than a guess. The same +file makes the second question answerable too: a run that has no ``end`` record +crashed, and re-running the same parent order blindly would double the position. + +The journal is deliberately dumb — newline-delimited JSON, no index, no schema +migration. It has to survive the process that writes it. +""" + +import json +import logging +import os +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +__all__ = [ + "ExecutionJournal", + "IncompleteRun", + "IncompleteExecutionError", + "default_journal_dir", + "make_run_id", + "read_journal", + "find_incomplete_runs", + "EVENT_START", + "EVENT_SLICE", + "EVENT_END", +] + +EVENT_START = "start" +EVENT_SLICE = "slice" +EVENT_END = "end" + +_ENV_JOURNAL_DIR = "KIS_EXECUTION_JOURNAL_DIR" + + +def default_journal_dir() -> Path: + """Where journals live unless the caller says otherwise. + + Honours ``KIS_EXECUTION_JOURNAL_DIR`` so a deployment can put the record on + a volume that outlives the container running the order. + + Returns: + Base directory for journals. Not created here. + """ + override = os.environ.get(_ENV_JOURNAL_DIR) + if override: + return Path(override).expanduser() + return Path.home() / ".kis-agent" / "executions" + + +def make_run_id(code: str, side: str, now: Optional[datetime] = None) -> str: + """Build a human-scannable, collision-resistant run identifier. + + The timestamp prefix makes a directory listing read chronologically; the + random suffix keeps two runs started in the same second apart. + + Args: + code: Ticker being worked. + side: ``buy`` or ``sell``. + now: Clock override for tests. + + Returns: + Something like ``20260821-133000-005930-buy-3f9a2c``. + """ + stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S") + return f"{stamp}-{code}-{side.lower()}-{uuid.uuid4().hex[:6]}" + + +class IncompleteExecutionError(RuntimeError): + """Raised when a previous run for the same ticker never closed its journal. + + An unclosed journal means orders may already be sitting on the exchange from + a process that died. Working the same parent order again without looking at + them is how a position silently doubles, so this stops the caller rather + than warning them. + + Attributes: + runs: The incomplete runs found, each carrying its order numbers. + """ + + def __init__(self, runs: List["IncompleteRun"]) -> None: + self.runs = runs + summaries = "; ".join(r.describe() for r in runs) + super().__init__( + f"완료되지 않은 집행 기록 {len(runs)}건: {summaries}. " + "이미 나간 주문을 확인한 뒤 진행하세요." + ) + + +@dataclass +class IncompleteRun: + """A journal that was opened but never closed — the run died mid-flight.""" + + run_id: str + path: Path + code: str + side: str + total_quantity: int + submitted_quantity: int + order_numbers: List[str] = field(default_factory=list) + started_at: Optional[str] = None + + def describe(self) -> str: + """One-line summary for an operator staring at a warning.""" + orders = ", ".join(self.order_numbers) if self.order_numbers else "없음" + return ( + f"{self.run_id}: {self.code} {self.side} " + f"{self.submitted_quantity}/{self.total_quantity}주 접수 " + f"(주문번호 {orders})" + ) + + +class ExecutionJournal: + """Append-only JSONL record of one parent order. + + Every :meth:`record` call flushes and fsyncs before returning. That costs a + syscall per child order — irrelevant next to an HTTP round trip, and it is + the whole reason the file is trustworthy after a SIGKILL. + """ + + def __init__(self, path: Path, run_id: str) -> None: + """Bind a journal to a path. Use :meth:`create` to open a fresh one.""" + self.path = path + self.run_id = run_id + self._closed = False + + @classmethod + def create( + cls, + code: str, + side: str, + base_dir: Optional[Path] = None, + now: Optional[datetime] = None, + run_id: Optional[str] = None, + ) -> "ExecutionJournal": + """Open a new journal under ``base_dir/YYYYMMDD/``. + + Args: + code: Ticker being worked. + side: ``buy`` or ``sell``. + base_dir: Journal root. Defaults to :func:`default_journal_dir`. + now: Clock override for tests. + run_id: Explicit run id, mainly for tests. + + Returns: + An open journal. The directory is created if needed. + + Raises: + OSError: If the directory cannot be created. + """ + moment = now or datetime.now() + rid = run_id or make_run_id(code, side, moment) + directory = (base_dir or default_journal_dir()) / moment.strftime("%Y%m%d") + directory.mkdir(parents=True, exist_ok=True) + return cls(path=directory / f"{rid}.jsonl", run_id=rid) + + def record(self, event: str, payload: Dict[str, Any]) -> None: + """Append one event and force it to disk. + + Journal failures never abort an execution — losing the record is bad, + but aborting a half-worked parent order because a disk is full is worse. + The failure is logged at warning level instead. + + Args: + event: One of ``start`` / ``slice`` / ``end``. + payload: Event body. Must be JSON-serialisable. + """ + line = { + "ts": datetime.now().isoformat(), + "runId": self.run_id, + "event": event, + } + line.update(payload) + try: + with self.path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(line, ensure_ascii=False, default=str) + "\n") + fh.flush() + os.fsync(fh.fileno()) + except OSError as e: # noqa: BLE001 - never kill a live order over a log + logger.warning("집행 원장 기록 실패 (%s): %s", self.path, e) + + def record_start(self, payload: Dict[str, Any]) -> None: + """Record the execution plan before the first child order goes out.""" + self.record(EVENT_START, payload) + + def record_slice(self, payload: Dict[str, Any]) -> None: + """Record one child order's outcome, including its order number.""" + self.record(EVENT_SLICE, payload) + + def record_end(self, payload: Dict[str, Any]) -> None: + """Close the journal. A journal without this event means the run died.""" + self.record(EVENT_END, payload) + self._closed = True + + +def read_journal(path: Path) -> List[Dict[str, Any]]: + """Read a journal back, skipping any line torn by an abrupt kill. + + Args: + path: Journal file. + + Returns: + Parsed events in write order. A missing file yields an empty list. + """ + events: List[Dict[str, Any]] = [] + try: + with path.open("r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + # A process killed mid-write leaves a partial final line. + # Everything before it is still good. + logger.warning("집행 원장의 손상된 줄을 건너뜁니다 (%s)", path) + except OSError as e: # noqa: BLE001 + logger.warning("집행 원장을 읽지 못했습니다 (%s): %s", path, e) + return events + + +def find_incomplete_runs( + code: str, + base_dir: Optional[Path] = None, + now: Optional[datetime] = None, + side: Optional[str] = None, +) -> List[IncompleteRun]: + """Find today's journals for ``code`` that never recorded an ``end``. + + An incomplete journal is the signature of a run that died with orders + already on the exchange. Starting another parent order for the same ticker + without looking at it is how a position silently doubles. + + Dry runs are excluded: they never reached the exchange, so an unfinished one + has nothing to reconcile. + + The search is scoped to today on purpose. A clean stop — normal completion + or Ctrl+C — closes its journal, so only an unhandled kill leaves one open; + and KRX day orders do not survive the close, so yesterday's wreckage is not + a reason to block this morning's order. Bounding it this way also keeps a + single crash from blocking the ticker forever. + + Args: + code: Ticker to check. + base_dir: Journal root. Defaults to :func:`default_journal_dir`. + now: Clock override for tests. + side: Restrict to runs in this direction. Pass the side you are about + to work: a crashed buy must not stand between an operator and the + sell that unwinds it, and duplication is a same-side hazard anyway. + ``None`` matches every direction. + + Returns: + Incomplete runs, oldest first. Empty when the directory is missing. + + Note: + ``order_numbers`` can undercount. Orders are never resent, so a request + whose response was lost is recorded as ``failed`` even though the + exchange may have accepted it. Treat the list as a starting point for + reconciliation, not a complete inventory — ``kis order list`` and + ``kis trades`` are authoritative. + """ + moment = now or datetime.now() + directory = (base_dir or default_journal_dir()) / moment.strftime("%Y%m%d") + if not directory.is_dir(): + return [] + + incomplete: List[IncompleteRun] = [] + for path in sorted(directory.glob("*.jsonl")): + events = read_journal(path) + if not events: + continue + if any(e.get("event") == EVENT_END for e in events): + continue + + start = next((e for e in events if e.get("event") == EVENT_START), {}) + if start.get("code") != code: + continue + if side is not None and str(start.get("side", "")).lower() != side.lower(): + continue + if start.get("dryRun"): + # A dry run never reached the exchange, so an unfinished one leaves + # nothing to reconcile and must not block the next real order. + 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")] + incomplete.append( + IncompleteRun( + run_id=start.get("runId") or path.stem, + path=path, + code=start.get("code", code), + side=start.get("side", ""), + total_quantity=int(start.get("totalQuantity") or 0), + submitted_quantity=sum(int(s.get("quantity") or 0) for s in worked), + order_numbers=[s["orderNo"] for s in worked if s.get("orderNo")], + started_at=start.get("ts"), + ) + ) + return incomplete diff --git a/kis_agent/execution/runner.py b/kis_agent/execution/runner.py index 136832d..f4b27df 100644 --- a/kis_agent/execution/runner.py +++ b/kis_agent/execution/runner.py @@ -10,9 +10,11 @@ import logging from datetime import datetime, timedelta from datetime import time as dt_time -from typing import Any, Callable, Dict, Optional +from pathlib import Path +from typing import Any, Callable, Dict, 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 .volume_profile import VolumeProfile, fetch_volume_profile @@ -211,6 +213,87 @@ def _resolve_start(start: Optional[datetime], now: Optional[datetime]) -> dateti return start or now or datetime.now() +def _guard_incomplete_runs( + code: str, + side: str, + journal_dir: Optional[Path], + enabled: bool, + dry_run: bool, +) -> None: + """Refuse to start when a previous run in the same direction never closed. + + Scoped to ``side`` on purpose. Duplication is a same-side hazard, and the + first thing an operator wants after a crashed buy is the sell that unwinds + it — a guard that blocks the remedy is worse than no guard. + + Skipped for dry runs, which never reached the exchange. + + Raises: + IncompleteExecutionError: If an unclosed same-side journal exists. + """ + if not enabled or dry_run: + return + runs = find_incomplete_runs(code, base_dir=journal_dir, side=side) + if runs: + raise IncompleteExecutionError(runs) + + +def _open_journal( + code: str, + side: str, + algorithm: str, + quantity: int, + slices: Sequence[Any], + plan: Dict[str, Any], + journal_dir: Optional[Path], + enabled: bool, +) -> Optional[ExecutionJournal]: + """Open a journal and write the plan before the first order goes out. + + Returns ``None`` when journaling is disabled or the journal cannot be + opened. A missing journal degrades observability, not correctness, so it + must never stop an execution — but the caller surfaces the reason. + """ + if not enabled: + return None + try: + journal = ExecutionJournal.create(code, side, base_dir=journal_dir) + except OSError as e: # noqa: BLE001 - degrade, never block the order + logger.warning("집행 원장을 열지 못했습니다: %s", e) + return None + + journal.record_start( + { + "algorithm": algorithm, + "code": code, + "side": side, + "totalQuantity": quantity, + "sliceCount": len(slices), + "schedule": [ + { + "index": s.index, + "scheduledAt": s.scheduled_at.isoformat(), + "quantity": s.quantity, + } + for s in slices + ], + **plan, + } + ) + return journal + + +def _close_journal( + journal: Optional[ExecutionJournal], result: AlgoExecutionResult +) -> None: + """Stamp the result onto the run and close the journal.""" + if journal is None: + return + result.run_id = journal.run_id + result.journal_path = str(journal.path) + journal.record_end(result.to_dict()) + + def run_twap( agent: Any, code: str, @@ -233,6 +316,9 @@ def run_twap( start: Optional[datetime] = None, progress: Optional[Callable[[SliceExecution], None]] = None, executor: Optional[AlgoExecutor] = None, + journal_dir: Optional[Path] = None, + journal_enabled: bool = True, + check_incomplete: bool = True, ) -> AlgoExecutionResult: """Work a parent order evenly across ``duration_minutes`` (TWAP). @@ -270,6 +356,17 @@ def run_twap( progress: Called with each slice result as it completes. executor: Pre-built executor, mainly for tests. Routing arguments are still forwarded to its order callable. + journal_dir: Where to write the execution journal. Defaults to + ``~/.kis-agent/executions`` (override with + ``KIS_EXECUTION_JOURNAL_DIR``). + journal_enabled: Write a durable record of every child order. Leave it + on unless you have another audit trail — without it, a process that + dies mid-run takes its order numbers with it. + check_incomplete: Refuse to start when a previous run **in the same + direction** for this ticker died without closing its journal. The + opposite direction is never blocked, so an unwind is always + possible. Turn it off only after reconciling the orders that run + left on the exchange. Returns: The aggregate execution result. @@ -287,6 +384,7 @@ def run_twap( if slices <= 0: raise ValueError(f"slices must be positive, got {slices}") funding = _validate_funding(funding) + _guard_incomplete_runs(code, side, journal_dir, check_incomplete, dry_run) runner = executor or _build_executor(agent) begin = _resolve_start(start, None) @@ -297,7 +395,24 @@ def run_twap( duration=timedelta(minutes=duration_minutes), ) - return runner.run( + plan = { + "orderType": order_type, + "price": price, + "exchange": exchange, + "funding": funding, + "creditType": credit_type, + "creditFallbackToCash": credit_fallback_to_cash, + "limitPrice": limit_price, + "onPriceBreach": on_price_breach, + "dryRun": dry_run, + "restrictToSession": restrict_to_session, + "durationMinutes": duration_minutes, + } + journal = _open_journal( + code, side, "twap", quantity, schedule, plan, journal_dir, journal_enabled + ) + + result = runner.run( schedule=schedule, code=code, side=side, @@ -317,7 +432,10 @@ def run_twap( loan_dt, credit_fallback_to_cash, ), + journal=journal, ) + _close_journal(journal, result) + return result def run_vwap( @@ -343,6 +461,9 @@ def run_vwap( start: Optional[datetime] = None, progress: Optional[Callable[[SliceExecution], None]] = None, executor: Optional[AlgoExecutor] = None, + journal_dir: Optional[Path] = None, + journal_enabled: bool = True, + check_incomplete: bool = True, profile: Optional[VolumeProfile] = None, ) -> AlgoExecutionResult: """Work a parent order along the historical intraday volume curve (VWAP). @@ -383,6 +504,17 @@ def run_vwap( progress: Called with each slice result as it completes. executor: Pre-built executor, mainly for tests. Routing arguments are still forwarded to its order callable. + journal_dir: Where to write the execution journal. Defaults to + ``~/.kis-agent/executions`` (override with + ``KIS_EXECUTION_JOURNAL_DIR``). + journal_enabled: Write a durable record of every child order. Leave it + on unless you have another audit trail — without it, a process that + dies mid-run takes its order numbers with it. + check_incomplete: Refuse to start when a previous run **in the same + direction** for this ticker died without closing its journal. The + opposite direction is never blocked, so an unwind is always + possible. Turn it off only after reconciling the orders that run + left on the exchange. profile: Pre-built volume profile, mainly for tests. When omitted the profile is fetched from the agent. @@ -401,6 +533,7 @@ def run_vwap( if slices <= 0: raise ValueError(f"slices must be positive, got {slices}") funding = _validate_funding(funding) + _guard_incomplete_runs(code, side, journal_dir, check_incomplete, dry_run) runner = executor or _build_executor(agent) begin = _resolve_start(start, None) @@ -426,6 +559,24 @@ def run_vwap( weights=weights, ) + plan = { + "orderType": order_type, + "price": price, + "exchange": exchange, + "funding": funding, + "creditType": credit_type, + "creditFallbackToCash": credit_fallback_to_cash, + "limitPrice": limit_price, + "onPriceBreach": on_price_breach, + "dryRun": dry_run, + "restrictToSession": restrict_to_session, + "durationMinutes": duration_minutes, + "profileDays": profile_days, + } + journal = _open_journal( + code, side, "vwap", quantity, schedule, plan, journal_dir, journal_enabled + ) + result = runner.run( schedule=schedule, code=code, @@ -446,6 +597,7 @@ def run_vwap( loan_dt, credit_fallback_to_cash, ), + journal=journal, ) if fallback_note: @@ -455,4 +607,6 @@ def run_vwap( f"거래량 프로파일: {len(profile.source_dates)}개 영업일 평균 " f"({profile.source_dates[0]}~{profile.source_dates[-1]})" ) + # notes가 확정된 뒤에 닫아야 원장의 end 레코드에 함께 남는다. + _close_journal(journal, result) return result diff --git a/pyproject.toml b/pyproject.toml index 2d8da3f..1494a33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "kis-agent" -version = "1.8.0" +version = "1.9.0" description = "한국투자증권 OpenAPI Python Wrapper - Korea Investment & Securities Trading API Client" readme = "README.md" requires-python = ">=3.8" diff --git a/tests/unit/test_cli_algo_order.py b/tests/unit/test_cli_algo_order.py index 8f45232..43dce13 100644 --- a/tests/unit/test_cli_algo_order.py +++ b/tests/unit/test_cli_algo_order.py @@ -4,6 +4,7 @@ """ import json +from pathlib import Path from unittest.mock import patch import pytest @@ -13,6 +14,16 @@ from kis_agent.execution import runner as runner_mod +@pytest.fixture(autouse=True) +def isolated_journal_dir(tmp_path, monkeypatch): + """집행 원장을 임시 디렉터리로 격리한다. + + 이게 없으면 테스트가 사용자의 ~/.kis-agent/executions 에 실제 파일을 쓴다 + (실측으로 65개를 흘린 뒤 추가했다). + """ + monkeypatch.setenv("KIS_EXECUTION_JOURNAL_DIR", str(tmp_path / "journal")) + + @pytest.fixture(autouse=True) def fast_and_open_market(monkeypatch): """스케줄 대기를 즉시 끝내고, 정규장 여부와 무관하게 결정적으로 돌린다. @@ -44,7 +55,11 @@ def order_cash(self, pdno, qty, price, buy_sell, order_type, exchange): "exchange": exchange, } ) - return {"rt_cd": "0", "msg1": "정상", "output": {"ODNO": f"A{len(self.orders)}"}} + return { + "rt_cd": "0", + "msg1": "정상", + "output": {"ODNO": f"A{len(self.orders)}"}, + } class FakeStockAPI: @@ -201,8 +216,19 @@ def test_vwap_prompt_mentions_the_profile_window(self): agent = FakeAgent() args = parse( [ - "order", "vwap", "005930", "--side", "buy", "--qty", "60", - "--duration", "1", "--slices", "2", "--profile-days", "20", + "order", + "vwap", + "005930", + "--side", + "buy", + "--qty", + "60", + "--duration", + "1", + "--slices", + "2", + "--profile-days", + "20", "--dry-run", ] ) @@ -223,8 +249,13 @@ def test_limit_price_guard_blocks_orders(self, capsys): payload, agent, code = run_cli( BASE + [ - "--duration", "1", "--slices", "2", - "--limit-price", "70000", "--yes", + "--duration", + "1", + "--slices", + "2", + "--limit-price", + "70000", + "--yes", ], agent=agent, capsys=capsys, @@ -240,8 +271,13 @@ def test_partial_execution_exits_non_zero(self, capsys): _, _, code = run_cli( BASE + [ - "--duration", "1", "--slices", "2", - "--limit-price", "70000", "--yes", + "--duration", + "1", + "--slices", + "2", + "--limit-price", + "70000", + "--yes", ], agent=agent, capsys=capsys, @@ -252,8 +288,17 @@ def test_partial_execution_exits_non_zero(self, capsys): def test_market_order_type_forces_price_zero(self, capsys): _, agent, _ = run_cli( BASE - + ["--duration", "1", "--slices", "1", "--type", "market", - "--price", "70000", "--yes"], + + [ + "--duration", + "1", + "--slices", + "1", + "--type", + "market", + "--price", + "70000", + "--yes", + ], capsys=capsys, ) assert agent.account_api.orders[0]["price"] == 0 @@ -262,8 +307,17 @@ def test_market_order_type_forces_price_zero(self, capsys): def test_limit_order_type_keeps_the_price(self, capsys): _, agent, _ = run_cli( BASE - + ["--duration", "1", "--slices", "1", "--type", "limit", - "--price", "70000", "--yes"], + + [ + "--duration", + "1", + "--slices", + "1", + "--type", + "limit", + "--price", + "70000", + "--yes", + ], capsys=capsys, ) assert agent.account_api.orders[0]["price"] == 70000 @@ -271,8 +325,18 @@ def test_limit_order_type_keeps_the_price(self, capsys): def test_invalid_quantity_reports_an_error(self, capsys): payload, _, code = run_cli( - ["order", "twap", "005930", "--side", "buy", "--qty", "0", - "--duration", "1", "--yes"], + [ + "order", + "twap", + "005930", + "--side", + "buy", + "--qty", + "0", + "--duration", + "1", + "--yes", + ], capsys=capsys, ) assert code == 1 @@ -282,8 +346,7 @@ def test_invalid_quantity_reports_an_error(self, capsys): class TestLiveExecution: def test_orders_are_placed_when_the_session_guard_is_off(self, capsys): payload, agent, code = run_cli( - BASE - + ["--duration", "1", "--slices", "3", "--yes"], + BASE + ["--duration", "1", "--slices", "3", "--yes"], capsys=capsys, ) assert [o["qty"] for o in agent.account_api.orders] == [20, 20, 20] @@ -293,16 +356,27 @@ def test_orders_are_placed_when_the_session_guard_is_off(self, capsys): def test_sell_side_is_routed_through(self, capsys): _, agent, _ = run_cli( - ["order", "twap", "005930", "--side", "sell", "--qty", "30", - "--duration", "1", "--slices", "1", "--yes"], + [ + "order", + "twap", + "005930", + "--side", + "sell", + "--qty", + "30", + "--duration", + "1", + "--slices", + "1", + "--yes", + ], capsys=capsys, ) assert agent.account_api.orders[0]["buy_sell"] == "SELL" def test_exchange_is_routed_through(self, capsys): _, agent, _ = run_cli( - BASE - + ["--duration", "1", "--slices", "1", "--exchange", "nxt", "--yes"], + BASE + ["--duration", "1", "--slices", "1", "--exchange", "nxt", "--yes"], capsys=capsys, ) assert agent.account_api.orders[0]["exchange"] == "NXT" @@ -332,8 +406,7 @@ def test_closed_market_blocks_every_slice(self, monkeypatch, capsys): def test_no_session_guard_flag_bypasses_the_check(self, monkeypatch, capsys): monkeypatch.setattr(runner_mod, "krx_regular_session", lambda moment: False) _, agent, code = run_cli( - BASE - + ["--duration", "1", "--slices", "2", "--no-session-guard", "--yes"], + BASE + ["--duration", "1", "--slices", "2", "--no-session-guard", "--yes"], capsys=capsys, ) assert len(agent.account_api.orders) == 2 @@ -353,12 +426,23 @@ def _resp(self, bucket): return {"rt_cd": "1", "msg1": "신용융자 매수 불가"} def order_credit_buy( - self, pdno, qty, price, order_type="00", credit_type="21", - exchange="KRX", loan_dt="", + self, + pdno, + qty, + price, + order_type="00", + credit_type="21", + exchange="KRX", + loan_dt="", ): self.credit_buys.append( - {"qty": qty, "credit_type": credit_type, "loan_dt": loan_dt, - "order_type": order_type, "exchange": exchange} + { + "qty": qty, + "credit_type": credit_type, + "loan_dt": loan_dt, + "order_type": order_type, + "exchange": exchange, + } ) return self._resp(self.credit_buys) @@ -398,8 +482,19 @@ def test_credit_type_and_loan_date_reach_the_api(self, capsys): agent = credit_cli_agent() _, agent, _ = run_cli( BASE - + ["--duration", "1", "--slices", "1", "--funding", "credit", - "--credit-type", "22", "--loan-date", "20260821", "--yes"], + + [ + "--duration", + "1", + "--slices", + "1", + "--funding", + "credit", + "--credit-type", + "22", + "--loan-date", + "20260821", + "--yes", + ], agent=agent, capsys=capsys, ) @@ -410,8 +505,22 @@ def test_credit_type_and_loan_date_reach_the_api(self, capsys): def test_credit_sell_uses_the_repayment_endpoint(self, capsys): agent = credit_cli_agent() _, agent, _ = run_cli( - ["order", "twap", "005930", "--side", "sell", "--qty", "30", - "--duration", "1", "--slices", "1", "--funding", "credit", "--yes"], + [ + "order", + "twap", + "005930", + "--side", + "sell", + "--qty", + "30", + "--duration", + "1", + "--slices", + "1", + "--funding", + "credit", + "--yes", + ], agent=agent, capsys=capsys, ) @@ -422,8 +531,16 @@ def test_fallback_flag_switches_to_cash_and_reports_it(self, capsys): agent = credit_cli_agent(credit_accepts=False) payload, agent, code = run_cli( BASE - + ["--duration", "1", "--slices", "2", "--funding", "credit", - "--credit-fallback", "--yes"], + + [ + "--duration", + "1", + "--slices", + "2", + "--funding", + "credit", + "--credit-fallback", + "--yes", + ], agent=agent, capsys=capsys, ) @@ -438,8 +555,17 @@ def test_without_fallback_a_rejected_credit_order_is_not_retried_as_cash( agent = credit_cli_agent(credit_accepts=False) payload, agent, code = run_cli( BASE - + ["--duration", "1", "--slices", "2", "--funding", "credit", - "--max-failures", "5", "--yes"], + + [ + "--duration", + "1", + "--slices", + "2", + "--funding", + "credit", + "--max-failures", + "5", + "--yes", + ], agent=agent, capsys=capsys, ) @@ -451,8 +577,17 @@ def test_prompt_shows_the_funding_source(self): agent = credit_cli_agent() args = parse( BASE - + ["--duration", "1", "--slices", "2", "--funding", "credit", - "--credit-type", "21", "--credit-fallback"] + + [ + "--duration", + "1", + "--slices", + "2", + "--funding", + "credit", + "--credit-type", + "21", + "--credit-fallback", + ] ) captured = {} with patch("kis_agent.cli.main._create_agent", return_value=agent), patch( @@ -484,8 +619,22 @@ class TestVwapCliExecution: def test_vwap_runs_end_to_end_and_reports_the_fallback(self, capsys): # FakeStockAPI가 분봉을 비워 돌려주므로 균등 분할 폴백 경로를 탄다. payload, agent, code = run_cli( - ["order", "vwap", "005930", "--side", "buy", "--qty", "60", - "--duration", "1", "--slices", "3", "--profile-days", "1", "--yes"], + [ + "order", + "vwap", + "005930", + "--side", + "buy", + "--qty", + "60", + "--duration", + "1", + "--slices", + "3", + "--profile-days", + "1", + "--yes", + ], capsys=capsys, ) assert [o["qty"] for o in agent.account_api.orders] == [20, 20, 20] @@ -519,3 +668,162 @@ def test_unexpected_exception_is_reported_as_json_and_exits_1(self, capsys): assert exc.value.code == 1 assert payload["code"] == "ConnectionError" assert "upstream gone" in payload["error"] + + +class TestJournalCli: + def test_result_reports_the_journal_location(self, capsys): + payload, _, _ = run_cli( + BASE + ["--duration", "1", "--slices", "2", "--yes"], capsys=capsys + ) + algo = payload["data"]["algoOrder"] + assert algo["runId"] + assert algo["journalPath"].endswith(".jsonl") + assert Path(algo["journalPath"]).exists() + + def test_progress_line_carries_the_order_number(self, capsys): + run_cli(BASE + ["--duration", "1", "--slices", "2", "--yes"]) + err = capsys.readouterr().err + # 원장이 실패해도 스크롤백에는 남아야 한다. + assert "주문번호 A1" in err + + def test_no_journal_flag_disables_recording(self, capsys): + payload, _, _ = run_cli( + BASE + ["--duration", "1", "--slices", "2", "--no-journal", "--yes"], + capsys=capsys, + ) + assert payload["data"]["algoOrder"]["journalPath"] == "" + + def test_journal_dir_override(self, tmp_path, capsys): + payload, _, _ = run_cli( + BASE + + [ + "--duration", + "1", + "--slices", + "1", + "--journal-dir", + str(tmp_path / "custom"), + "--yes", + ], + capsys=capsys, + ) + assert str(tmp_path / "custom") in payload["data"]["algoOrder"]["journalPath"] + + +class TestIncompleteRunGuard: + def _crash_a_run(self, journal_dir, code="005930"): + """주문을 낸 뒤 죽은 실행을 흉내낸다 (end 레코드 없음).""" + from kis_agent.execution.journal import ExecutionJournal + + j = ExecutionJournal.create(code, "buy", base_dir=Path(journal_dir)) + j.record_start( + {"code": code, "side": "buy", "totalQuantity": 100, "dryRun": False} + ) + j.record_slice( + {"index": 0, "quantity": 30, "status": "filled", "orderNo": "LIVE-1"} + ) + return j + + def test_incomplete_run_blocks_a_new_execution(self, tmp_path, capsys): + self._crash_a_run(tmp_path) + payload, agent, code = run_cli( + BASE + + [ + "--duration", + "1", + "--slices", + "2", + "--journal-dir", + str(tmp_path), + "--yes", + ], + capsys=capsys, + ) + assert agent.account_api.orders == [] # 중복 집행을 막았다 + assert code == 1 + assert payload["code"] == "IncompleteExecutionFound" + runs = payload["data"]["incompleteRuns"] + assert runs[0]["orderNumbers"] == ["LIVE-1"] + assert runs[0]["submittedQuantity"] == 30 + + def test_override_flag_allows_proceeding(self, tmp_path, capsys): + self._crash_a_run(tmp_path) + _, agent, code = run_cli( + BASE + + [ + "--duration", + "1", + "--slices", + "2", + "--journal-dir", + str(tmp_path), + "--ignore-incomplete", + "--yes", + ], + capsys=capsys, + ) + assert len(agent.account_api.orders) == 2 + assert code is None + + def test_dry_run_is_not_blocked(self, tmp_path, capsys): + self._crash_a_run(tmp_path) + payload, _, code = run_cli( + BASE + + [ + "--duration", + "1", + "--slices", + "2", + "--journal-dir", + str(tmp_path), + "--dry-run", + "--yes", + ], + capsys=capsys, + ) + # 모의 실행은 거래소에 닿지 않으므로 막을 이유가 없다. + assert payload["data"]["algoOrder"]["dryRun"] is True + assert code is None + + def test_a_different_ticker_is_not_blocked(self, tmp_path, capsys): + self._crash_a_run(tmp_path, code="000660") + _, agent, code = run_cli( + BASE + + [ + "--duration", + "1", + "--slices", + "1", + "--journal-dir", + str(tmp_path), + "--yes", + ], + capsys=capsys, + ) + assert len(agent.account_api.orders) == 1 + assert code is None + + def test_crashed_buy_does_not_block_a_sell_via_cli(self, tmp_path, capsys): + self._crash_a_run(tmp_path) # buy 방향으로 죽은 실행 + _, agent, code = run_cli( + [ + "order", + "twap", + "005930", + "--side", + "sell", + "--qty", + "60", + "--duration", + "1", + "--slices", + "2", + "--journal-dir", + str(tmp_path), + "--yes", + ], + capsys=capsys, + ) + # 청산 매도는 막히면 안 된다. + assert len(agent.account_api.orders) == 2 + assert code is None diff --git a/tests/unit/test_client_no_retry_on_orders.py b/tests/unit/test_client_no_retry_on_orders.py new file mode 100644 index 0000000..79a3d5e --- /dev/null +++ b/tests/unit/test_client_no_retry_on_orders.py @@ -0,0 +1,152 @@ +"""상태 변경 요청(POST)이 재전송되지 않는지 고정하는 회귀 테스트 (STO-1729). + +타임아웃은 응답에 걸린 것이지 동작에 걸린 것이 아니다. 접수된 주문의 응답만 +유실됐는데 같은 본문을 다시 보내면 중복 주문이 된다. KIS 주문 API는 멱등키를 +받지 않으므로 거래소가 걸러줄 방법도 없다. +""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from kis_agent.core.client import KISClient + + +@pytest.fixture +def client(): + c = KISClient.__new__(KISClient) + c.base_url = "https://example.test" + c.is_real = True + c.verbose = False + c.token_expired = None # 토큰 갱신 경로를 타지 않게 한다 + c._enforce_rate_limit = lambda priority=0: None + return c + + +@pytest.fixture(autouse=True) +def stub_env(): + env = MagicMock(my_token="Bearer x", my_app="k", my_sec="s") + with patch("kis_agent.core.client.getTREnv", return_value=env), patch( + "kis_agent.core.client.resolve_tr_id", side_effect=lambda tr, real: tr + ), patch("kis_agent.core.client.time.sleep"): + yield + + +def _timeout(*args, **kwargs): + raise httpx.ConnectTimeout("timed out") + + +class TestOrderPostIsNeverResent: + def test_post_timeout_sends_exactly_one_request(self, client): + with patch("kis_agent.core.client.httpx.request", side_effect=_timeout) as req: + with pytest.raises(httpx.ConnectTimeout): + client.make_request( + endpoint="/uapi/domestic-stock/v1/trading/order-cash", + tr_id="TTTC0012U", + params={"PDNO": "005930", "ORD_QTY": "10"}, + method="POST", + ) + # 재전송이 한 번이라도 일어나면 중복 주문이다. + assert req.call_count == 1 + + def test_explicit_high_retries_cannot_override_the_rule(self, client): + with patch("kis_agent.core.client.httpx.request", side_effect=_timeout) as req: + with pytest.raises(httpx.ConnectTimeout): + client.make_request( + endpoint="/uapi/domestic-stock/v1/trading/order-cash", + tr_id="TTTC0012U", + params={}, + method="POST", + retries=5, + ) + assert req.call_count == 1 + + def test_http_500_on_post_is_not_resent(self, client): + response = MagicMock(status_code=500, text='{"msg1": "서버 오류"}') + response.json.return_value = {"msg1": "서버 오류"} + with patch("kis_agent.core.client.httpx.request", return_value=response) as req: + result = client.make_request( + endpoint="/uapi/domestic-stock/v1/trading/order-credit", + tr_id="TTTC0052U", + params={}, + method="POST", + ) + assert req.call_count == 1 + # 재전송 대신 오류를 그대로 올려보내 상위가 실패로 판정하게 한다. + assert result is not None + + @pytest.mark.parametrize("method", ["POST", "post", "PUT", "DELETE"]) + def test_every_non_get_method_is_covered(self, client, method): + with patch("kis_agent.core.client.httpx.request", side_effect=_timeout) as req: + with pytest.raises(httpx.ConnectTimeout): + client.make_request( + endpoint="/uapi/x", tr_id="T", params={}, method=method + ) + assert req.call_count == 1 + + +class TestGetStillRetries: + def test_get_timeout_still_retries(self, client): + with patch("kis_agent.core.client.httpx.request", side_effect=_timeout) as req: + with pytest.raises(httpx.ConnectTimeout): + client.make_request( + endpoint="/uapi/domestic-stock/v1/quotations/inquire-price", + tr_id="FHKST01010100", + params={"FID_INPUT_ISCD": "005930"}, + method="GET", + ) + # 조회는 멱등하므로 재시도가 유효하다 — 기본 2회. + assert req.call_count == 2 + + +class TestEveryPostEndpointIsAnOrder: + def test_no_non_order_post_exists_in_the_package(self): + """이 규칙의 전제: 패키지의 모든 POST가 주문 계열이다. + + 조회용 POST가 새로 생기면 재시도가 사라져 성능이 조용히 나빠지므로, + 그때 이 테스트가 먼저 깨져 규칙을 재검토하게 한다. + """ + import pathlib + import re + + root = pathlib.Path(__file__).resolve().parents[2] / "kis_agent" + offenders = [] + for path in root.rglob("*.py"): + if re.search(r'method\s*=\s*"POST"', path.read_text(encoding="utf-8")): + if not path.name.startswith("order_api"): + offenders.append(str(path.relative_to(root))) + assert offenders == [], f"주문이 아닌 POST 발견: {offenders}" + + +class TestRetriesZeroIsNotATrap: + """`retries=0`이 주문을 조용히 안 보내는 일이 없어야 한다. + + `> 1`만 걸러내면 0이 그대로 통과해 `range(0)` 루프가 돌지 않고, 주문이 + 전송되지 않은 채 "Unknown error after retries"로 끝난다. 호출자는 그것을 + 전송 실패와 구분할 수 없다. + """ + + def test_zero_retries_still_sends_the_order_once(self, client): + with patch("kis_agent.core.client.httpx.request", side_effect=_timeout) as req: + with pytest.raises(httpx.ConnectTimeout): + client.make_request( + endpoint="/uapi/domestic-stock/v1/trading/order-cash", + tr_id="TTTC0012U", + params={}, + method="POST", + retries=0, + ) + assert req.call_count == 1 + + def test_one_retry_is_left_alone(self, client): + with patch("kis_agent.core.client.httpx.request", side_effect=_timeout) as req: + with pytest.raises(httpx.ConnectTimeout): + client.make_request( + endpoint="/uapi/domestic-stock/v1/trading/order-cash", + tr_id="TTTC0012U", + params={}, + method="POST", + retries=1, + ) + assert req.call_count == 1 diff --git a/tests/unit/test_execution_journal.py b/tests/unit/test_execution_journal.py new file mode 100644 index 0000000..b88e1a4 --- /dev/null +++ b/tests/unit/test_execution_journal.py @@ -0,0 +1,245 @@ +"""집행 원장 테스트 (STO-1730). + +핵심 계약은 하나다: **프로세스가 어떻게 죽든 이미 나간 주문번호는 디스크에 있다.** +""" + +import json +import signal +import subprocess +import sys +import textwrap +from datetime import datetime +from pathlib import Path + +import pytest + +from kis_agent.execution.journal import ( + EVENT_END, + EVENT_SLICE, + EVENT_START, + ExecutionJournal, + default_journal_dir, + find_incomplete_runs, + make_run_id, + read_journal, +) + +NOW = datetime(2026, 8, 21, 13, 30) + + +class TestRunId: + def test_is_chronologically_sortable(self): + early = make_run_id("005930", "buy", datetime(2026, 8, 21, 9, 0)) + late = make_run_id("005930", "buy", datetime(2026, 8, 21, 15, 0)) + assert early < late + + def test_carries_code_and_side(self): + rid = make_run_id("005930", "SELL", NOW) + assert "005930" in rid and "sell" in rid + + def test_two_runs_in_the_same_second_do_not_collide(self): + a = make_run_id("005930", "buy", NOW) + b = make_run_id("005930", "buy", NOW) + assert a != b + + +class TestDefaultDir: + def test_env_override_wins(self, monkeypatch, tmp_path): + monkeypatch.setenv("KIS_EXECUTION_JOURNAL_DIR", str(tmp_path / "jrnl")) + assert default_journal_dir() == tmp_path / "jrnl" + + def test_falls_back_to_home(self, monkeypatch): + monkeypatch.delenv("KIS_EXECUTION_JOURNAL_DIR", raising=False) + assert default_journal_dir() == Path.home() / ".kis-agent" / "executions" + + def test_tilde_in_override_is_expanded(self, monkeypatch): + monkeypatch.setenv("KIS_EXECUTION_JOURNAL_DIR", "~/somewhere") + assert default_journal_dir() == Path.home() / "somewhere" + + +class TestWriting: + def test_file_lands_under_a_date_directory(self, tmp_path): + j = ExecutionJournal.create("005930", "buy", base_dir=tmp_path, now=NOW) + assert j.path.parent.name == "20260821" + assert j.path.suffix == ".jsonl" + + def test_events_are_appended_in_order(self, tmp_path): + j = ExecutionJournal.create("005930", "buy", base_dir=tmp_path, now=NOW) + j.record_start({"code": "005930", "totalQuantity": 100}) + j.record_slice({"index": 0, "quantity": 50, "orderNo": "A1"}) + j.record_end({"status": "completed"}) + + events = read_journal(j.path) + assert [e["event"] for e in events] == [EVENT_START, EVENT_SLICE, EVENT_END] + assert all(e["runId"] == j.run_id for e in events) + assert events[1]["orderNo"] == "A1" + + def test_each_record_is_flushed_immediately(self, tmp_path): + """다음 record를 기다리지 않고 즉시 읽을 수 있어야 한다.""" + j = ExecutionJournal.create("005930", "buy", base_dir=tmp_path, now=NOW) + j.record_slice({"index": 0, "orderNo": "A1"}) + assert len(read_journal(j.path)) == 1 + + def test_unwritable_journal_does_not_raise(self, tmp_path): + # 디스크가 차서 원장을 못 써도 진행 중인 주문을 중단시켜서는 안 된다. + j = ExecutionJournal(path=tmp_path / "nope" / "deep" / "x.jsonl", run_id="R") + j.record_slice({"index": 0}) # 예외가 나면 실패 + + def test_non_serialisable_payload_falls_back_to_str(self, tmp_path): + j = ExecutionJournal.create("005930", "buy", base_dir=tmp_path, now=NOW) + j.record_slice({"when": datetime(2026, 8, 21, 10, 0)}) + assert "2026-08-21" in read_journal(j.path)[0]["when"] + + +class TestReading: + def test_missing_file_reads_as_empty(self, tmp_path): + assert read_journal(tmp_path / "absent.jsonl") == [] + + def test_torn_final_line_does_not_lose_earlier_records(self, tmp_path): + path = tmp_path / "torn.jsonl" + good = json.dumps({"event": "slice", "orderNo": "A1"}) + path.write_text(good + '\n{"event": "slice", "orderN', encoding="utf-8") + events = read_journal(path) + # SIGKILL이 마지막 줄을 자르더라도 그 앞은 온전해야 한다. + assert len(events) == 1 + assert events[0]["orderNo"] == "A1" + + def test_blank_lines_are_skipped(self, tmp_path): + path = tmp_path / "blanks.jsonl" + path.write_text('\n{"event": "start"}\n\n', encoding="utf-8") + assert len(read_journal(path)) == 1 + + +class TestIncompleteRuns: + def _write(self, tmp_path, *, code, closed, dry_run=False, orders=("A1",)): + j = ExecutionJournal.create(code, "buy", base_dir=tmp_path, now=NOW) + j.record_start( + {"code": code, "side": "buy", "totalQuantity": 100, "dryRun": dry_run} + ) + for i, no in enumerate(orders): + j.record_slice( + {"index": i, "quantity": 30, "status": "filled", "orderNo": no} + ) + if closed: + j.record_end({"status": "completed"}) + return j + + def test_unclosed_run_is_reported(self, tmp_path): + self._write(tmp_path, code="005930", closed=False) + found = find_incomplete_runs("005930", base_dir=tmp_path, now=NOW) + assert len(found) == 1 + assert found[0].order_numbers == ["A1"] + assert found[0].submitted_quantity == 30 + assert found[0].total_quantity == 100 + + def test_closed_run_is_not_reported(self, tmp_path): + self._write(tmp_path, code="005930", closed=True) + assert find_incomplete_runs("005930", base_dir=tmp_path, now=NOW) == [] + + def test_dry_run_is_never_reported(self, tmp_path): + # 모의 실행은 거래소에 닿지 않았으므로 대사할 것이 없다. + self._write(tmp_path, code="005930", closed=False, dry_run=True) + assert find_incomplete_runs("005930", base_dir=tmp_path, now=NOW) == [] + + def test_other_tickers_are_ignored(self, tmp_path): + self._write(tmp_path, code="000660", closed=False) + assert find_incomplete_runs("005930", base_dir=tmp_path, now=NOW) == [] + + def test_missing_directory_reads_as_empty(self, tmp_path): + assert find_incomplete_runs("005930", base_dir=tmp_path / "nope", now=NOW) == [] + + def test_empty_journal_is_skipped(self, tmp_path): + directory = tmp_path / "20260821" + directory.mkdir(parents=True) + (directory / "empty.jsonl").write_text("", encoding="utf-8") + assert find_incomplete_runs("005930", base_dir=tmp_path, now=NOW) == [] + + def test_describe_names_the_order_numbers(self, tmp_path): + self._write(tmp_path, code="005930", closed=False, orders=("A1", "A2")) + summary = find_incomplete_runs("005930", base_dir=tmp_path, now=NOW)[ + 0 + ].describe() + assert "A1" in summary and "A2" in summary + assert "60/100주" in summary + + def test_run_with_no_orders_reports_none(self, tmp_path): + self._write(tmp_path, code="005930", closed=False, orders=()) + found = find_incomplete_runs("005930", base_dir=tmp_path, now=NOW) + assert found[0].submitted_quantity == 0 + assert "없음" in found[0].describe() + + +class TestSurvivesSigkill: + """이 모듈의 존재 이유를 직접 증명한다.""" + + def test_order_numbers_survive_an_unhandled_kill(self, tmp_path): + script = textwrap.dedent( + f""" + import os, signal, sys + sys.path.insert(0, {str(Path(__file__).resolve().parents[2])!r}) + from datetime import datetime + from pathlib import Path + from kis_agent.execution.journal import ExecutionJournal + + j = ExecutionJournal.create( + "005930", "buy", + base_dir=Path({str(tmp_path)!r}), + now=datetime(2026, 8, 21, 13, 30), + run_id="killrun", + ) + j.record_start({{"code": "005930", "side": "buy", "totalQuantity": 100}}) + j.record_slice({{"index": 0, "quantity": 50, "status": "filled", + "orderNo": "LIVE-0001"}}) + # 핸들러가 없는 즉사. finally도, atexit도 돌지 않는다. + os.kill(os.getpid(), signal.SIGKILL) + """ + ) + proc = subprocess.run( + [sys.executable, "-c", script], capture_output=True, timeout=60 + ) + assert proc.returncode == -signal.SIGKILL + + journal = tmp_path / "20260821" / "killrun.jsonl" + events = read_journal(journal) + order_numbers = [e.get("orderNo") for e in events if e.get("orderNo")] + # 프로세스는 죽었지만 나간 주문은 디스크에 남아야 한다. + assert order_numbers == ["LIVE-0001"] + assert not any(e["event"] == EVENT_END for e in events) + + # 그리고 그 기록이 다음 실행을 막아야 한다. + found = find_incomplete_runs("005930", base_dir=tmp_path, now=NOW) + assert found[0].order_numbers == ["LIVE-0001"] + + +class TestSideScoping: + """가드는 같은 방향만 막는다 — 크래시한 매수가 청산 매도를 막으면 안 된다.""" + + def _crash(self, tmp_path, side): + j = ExecutionJournal.create("005930", side, base_dir=tmp_path, now=NOW) + j.record_start( + {"code": "005930", "side": side, "totalQuantity": 100, "dryRun": False} + ) + j.record_slice( + {"index": 0, "quantity": 40, "status": "filled", "orderNo": "X1"} + ) + + def test_same_side_is_found(self, tmp_path): + self._crash(tmp_path, "buy") + found = find_incomplete_runs("005930", base_dir=tmp_path, now=NOW, side="buy") + assert len(found) == 1 + + def test_opposite_side_is_not_found(self, tmp_path): + # 크래시한 매수 이후의 청산 매도는 허용돼야 한다. + self._crash(tmp_path, "buy") + found = find_incomplete_runs("005930", base_dir=tmp_path, now=NOW, side="sell") + assert found == [] + + def test_no_side_matches_every_direction(self, tmp_path): + self._crash(tmp_path, "buy") + self._crash(tmp_path, "sell") + assert len(find_incomplete_runs("005930", base_dir=tmp_path, now=NOW)) == 2 + + def test_side_comparison_is_case_insensitive(self, tmp_path): + self._crash(tmp_path, "BUY") + found = find_incomplete_runs("005930", base_dir=tmp_path, now=NOW, side="buy") + assert len(found) == 1 diff --git a/tests/unit/test_execution_runner.py b/tests/unit/test_execution_runner.py index 129adbb..878199c 100644 --- a/tests/unit/test_execution_runner.py +++ b/tests/unit/test_execution_runner.py @@ -1,6 +1,7 @@ """Unit tests for the agent-facing TWAP/VWAP entry points.""" from datetime import datetime, timedelta +from pathlib import Path import pytest @@ -10,6 +11,16 @@ START = datetime(2026, 8, 21, 10, 0) # Friday, inside the KRX session +@pytest.fixture(autouse=True) +def isolated_journal_dir(tmp_path, monkeypatch): + """집행 원장을 임시 디렉터리로 격리한다. + + 이게 없으면 테스트가 사용자의 ~/.kis-agent/executions 에 실제 파일을 쓴다 + (실측으로 65개를 흘린 뒤 추가했다). + """ + monkeypatch.setenv("KIS_EXECUTION_JOURNAL_DIR", str(tmp_path / "journal")) + + class FakeAccountAPI: def __init__(self): self.orders = [] @@ -687,3 +698,412 @@ def test_open_bell_is_inclusive_and_close_is_exclusive(self): assert krx_regular_session(datetime(2026, 8, 21, 9, 0, 0)) is True assert krx_regular_session(datetime(2026, 8, 21, 15, 29, 59)) is True assert krx_regular_session(datetime(2026, 8, 21, 15, 30, 0)) is False + + +class TestJournalIntegration: + def test_every_placed_order_is_journalled_with_its_order_number(self, tmp_path): + from kis_agent.execution.journal import read_journal + + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=100, + duration_minutes=20, + slices=4, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + + assert result.run_id + assert result.journal_path + events = read_journal(Path(result.journal_path)) + slices = [e for e in events if e["event"] == "slice"] + assert [s["quantity"] for s in slices] == [25, 25, 25, 25] + assert [s["orderNo"] for s in slices] == ["A1", "A2", "A3", "A4"] + + def test_plan_is_recorded_before_the_first_order(self, tmp_path): + from kis_agent.execution.journal import read_journal + + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=100, + duration_minutes=20, + slices=4, + funding="credit", + limit_price=70000, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + events = read_journal(Path(result.journal_path)) + start_event = events[0] + assert start_event["event"] == "start" + assert start_event["funding"] == "credit" + assert start_event["limitPrice"] == 70000 + assert len(start_event["schedule"]) == 4 + + def test_end_record_closes_the_run(self, tmp_path): + from kis_agent.execution.journal import find_incomplete_runs, read_journal + + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=40, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + events = read_journal(Path(result.journal_path)) + assert events[-1]["event"] == "end" + assert events[-1]["submittedQuantity"] == 40 + assert find_incomplete_runs("005930", base_dir=tmp_path) == [] + + def test_vwap_notes_land_in_the_end_record(self, tmp_path): + from kis_agent.execution.journal import read_journal + + agent = FakeAgent() + result = run_vwap( + agent, + code="005930", + side="buy", + quantity=40, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + profile=VolumeProfile(fallback_reason="분봉 없음"), + executor=instant_executor(agent), + ) + events = read_journal(Path(result.journal_path)) + assert any("균등 분할" in n for n in events[-1]["notes"]) + + def test_journal_can_be_disabled(self, tmp_path): + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + journal_enabled=False, + executor=instant_executor(agent), + ) + assert result.run_id == "" + assert result.journal_path == "" + assert list(tmp_path.rglob("*.jsonl")) == [] + + def test_unopenable_journal_does_not_stop_the_order(self, tmp_path, monkeypatch): + from kis_agent.execution import runner as runner_mod + + def explode(*a, **kw): + raise OSError("read-only filesystem") + + monkeypatch.setattr(runner_mod.ExecutionJournal, "create", explode) + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + # 원장을 못 열어도 주문은 나가야 한다 — 관측성 저하이지 정지 사유가 아니다. + assert len(agent.account_api.orders) == 2 + assert result.status == "completed" + assert result.journal_path == "" + + def test_dry_run_journal_is_marked_and_never_blocks_the_next_run(self, tmp_path): + from kis_agent.execution.journal import find_incomplete_runs, read_journal + + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + dry_run=True, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + events = read_journal(Path(result.journal_path)) + assert events[0]["dryRun"] is True + assert find_incomplete_runs("005930", base_dir=tmp_path) == [] + + +class TestIncompleteGuardInRunner: + """CLI뿐 아니라 Python API 호출자도 보호받아야 한다.""" + + def _crash(self, base, code="005930"): + from kis_agent.execution.journal import ExecutionJournal + + j = ExecutionJournal.create(code, "buy", base_dir=base) + j.record_start( + {"code": code, "side": "buy", "totalQuantity": 100, "dryRun": False} + ) + j.record_slice( + {"index": 0, "quantity": 40, "status": "filled", "orderNo": "LIVE-9"} + ) + + def test_run_twap_refuses_after_a_crashed_run(self, tmp_path): + from kis_agent.execution import IncompleteExecutionError + + self._crash(tmp_path) + agent = FakeAgent() + with pytest.raises(IncompleteExecutionError) as exc: + run_twap( + agent, + code="005930", + side="buy", + quantity=100, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + assert agent.account_api.orders == [] + assert exc.value.runs[0].order_numbers == ["LIVE-9"] + assert "LIVE-9" in str(exc.value) + + def test_run_vwap_refuses_too(self, tmp_path): + from kis_agent.execution import IncompleteExecutionError + + self._crash(tmp_path) + agent = FakeAgent() + with pytest.raises(IncompleteExecutionError): + run_vwap( + agent, + code="005930", + side="buy", + quantity=100, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + profile=VolumeProfile(fallback_reason="none"), + executor=instant_executor(agent), + ) + + def test_dry_run_is_never_refused(self, tmp_path): + self._crash(tmp_path) + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + dry_run=True, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + assert result.status == "completed" + + def test_opt_out_allows_proceeding(self, tmp_path): + self._crash(tmp_path) + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + check_incomplete=False, + executor=instant_executor(agent), + ) + assert len(agent.account_api.orders) == 2 + assert result.status == "completed" + + def test_a_different_ticker_is_unaffected(self, tmp_path): + self._crash(tmp_path, code="000660") + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + assert result.status == "completed" + + +class TestJournalClosesOnEveryNormalExit: + """문서가 "정상 종료와 Ctrl+C는 원장을 닫는다"고 주장한다 — 실제로 그런지 고정한다. + + 이 성질이 깨지면 의도적으로 멈춘 운영자가 다음 집행에서 가드에 걸린다. + """ + + def _journal_is_closed(self, result): + from kis_agent.execution.journal import read_journal + + events = read_journal(Path(result.journal_path)) + return any(e["event"] == "end" for e in events) + + def test_ctrl_c_closes_the_journal(self, tmp_path): + calls = {"n": 0} + + def interrupting(code, quantity, side, **kwargs): + calls["n"] += 1 + if calls["n"] == 2: + raise KeyboardInterrupt + return {"rt_cd": "0", "output": {"ODNO": "A1"}} + + agent = FakeAgent() + result = run_twap( + agent, + code="005930", + side="buy", + quantity=40, + duration_minutes=10, + slices=4, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent, order_func=interrupting), + ) + assert result.status == "cancelled" + assert self._journal_is_closed(result) + # 의도적 중단은 다음 집행을 막지 않는다. + from kis_agent.execution.journal import find_incomplete_runs + + assert find_incomplete_runs("005930", base_dir=tmp_path, side="buy") == [] + + def test_aborted_run_closes_the_journal(self, tmp_path): + agent = FakeAgent() + agent.account_api.order_cash = lambda **kw: {"rt_cd": "1", "msg1": "거부"} + result = run_twap( + agent, + code="005930", + side="buy", + quantity=50, + duration_minutes=10, + slices=5, + max_consecutive_failures=2, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + assert result.status == "aborted" + assert self._journal_is_closed(result) + + def test_partial_run_closes_the_journal(self, tmp_path): + agent = FakeAgent(price=71000) + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + limit_price=70000, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + assert result.status == "partial" + assert self._journal_is_closed(result) + + def test_skipped_slices_are_journaled_for_audit(self, tmp_path): + from kis_agent.execution.journal import read_journal + + agent = FakeAgent(price=71000) + result = run_twap( + agent, + code="005930", + side="buy", + quantity=20, + duration_minutes=10, + slices=2, + limit_price=70000, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + slices = [ + e for e in read_journal(Path(result.journal_path)) if e["event"] == "slice" + ] + assert [s["status"] for s in slices] == ["skipped", "skipped"] + assert all(s["reason"] == "price_limit" for s in slices) + + +class TestGuardDoesNotBlockUnwinding: + def test_crashed_buy_does_not_block_a_sell(self, tmp_path): + from kis_agent.execution.journal import ExecutionJournal + + j = ExecutionJournal.create("005930", "buy", base_dir=tmp_path) + j.record_start( + {"code": "005930", "side": "buy", "totalQuantity": 100, "dryRun": False} + ) + j.record_slice( + {"index": 0, "quantity": 40, "status": "filled", "orderNo": "LIVE-9"} + ) + + agent = FakeAgent() + # 크래시한 매수 이후 청산 매도는 통과해야 한다. + result = run_twap( + agent, + code="005930", + side="sell", + quantity=40, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + ) + assert result.status == "completed" + assert len(agent.account_api.orders) == 2 + + def test_crashed_buy_still_blocks_another_buy(self, tmp_path): + from kis_agent.execution import IncompleteExecutionError + from kis_agent.execution.journal import ExecutionJournal + + j = ExecutionJournal.create("005930", "buy", base_dir=tmp_path) + j.record_start( + {"code": "005930", "side": "buy", "totalQuantity": 100, "dryRun": False} + ) + j.record_slice( + {"index": 0, "quantity": 40, "status": "filled", "orderNo": "LIVE-9"} + ) + + agent = FakeAgent() + with pytest.raises(IncompleteExecutionError): + run_twap( + agent, + code="005930", + side="buy", + quantity=40, + duration_minutes=10, + slices=2, + start=START, + journal_dir=tmp_path, + executor=instant_executor(agent), + )