Skip to content

v2.13.0 — 스트리밍·견고성·정리 (Tranche D, 감사 최종) - #31

Merged
gejyn14 merged 37 commits into
mainfrom
feature/v2.13.0-streaming
Jul 20, 2026
Merged

v2.13.0 — 스트리밍·견고성·정리 (Tranche D, 감사 최종)#31
gejyn14 merged 37 commits into
mainfrom
feature/v2.13.0-streaming

Conversation

@gejyn14

@gejyn14 gejyn14 commented Jul 20, 2026

Copy link
Copy Markdown
Owner

2026-07-18 감사의 마지막 트랜치입니다. A→v2.9.0, C→v2.10.0, B→v2.11.0, E→v2.12.0에 이어 D(스트리밍·견고성·정리)로 감사가 닫힙니다.

31 커밋, 테스트 1580 → 1970, ruff clean.

무엇을 고쳤나

WebSocket 인증이 실패를 감지하지 못하고 있었다. streaming.py가 LOGIN 응답에서 code를 읽는데 서버는 return_code를 보낸다. 만료 토큰으로 mock·prod 양쪽 raw 프레임을 캡처해 확정했다 — 인증 실패 분기가 dead code라 실패해도 "인증 성공"을 출력하고 있었다. 캡처 과정에서 prod는 LOGIN 응답 뒤에 SYSTEM 프레임을 보내고 mock은 안 보낸다는 것도 드러났다. watch가 두 번째 recv()를 REG ack로 가정하고 있었으므로, prod에서 그 SYSTEM 프레임을 읽고 "등록 실패"(사유 공란) + exit 0으로 죽는다. mock 테스트로는 원리상 잡을 수 없는 버그다.

table 모드가 실패를 성공으로 보고하고 있었다. 연결 거부, 예상 외 예외, 핸드셰이크 중 종료가 전부 exit 0이었다. $?로 분기하는 스크립트와 에이전트에게는 성공으로 보인다.

0D 호가 스트림이 통째로 정규화되지 않았다. 필드 21(호가시간)과 41-80(10호가 가격·잔량)이 어느 매핑에도 없어 ts=null, 가격은 방향지시자가 붙은 채 음수로 렌더링됐다. history query --from/--to가 0D 이벤트를 100% 조용히 폐기하고 있었다.

mock에서 학습한 미국 거래소가 prod 실주문을 라우팅했다. 거래소 캐시가 도메인 구분 없는 단일 파일이었다.

A005930 형태 코드가 미국으로 라우팅됐다. account balance가 그 형태를 내보내는데, 되먹이면 KR 필터가 경고 없이 삭제돼 전체 미체결이 반환됐다.

그 외: nan/inford_uv:"nan"으로 전송되던 것, auth logout이 폐기하지 않은 키체인 토큰을 지우던 것, migrate_to_profiles가 부분 실패 시 자격증명을 영구 고아로 만들던 것, history export가 상위 디렉터리 없으면 raw traceback을 내던 것, stock search --market etn이 구조적으로 항상 빈 결과이던 것.

코드북 드리프트 — 이번 트랜치의 최대 수확

상수 병합 해저드 테스트에 세 번째 술어(부분 겹침, 양쪽 exclusive)를 추가하고 352개 해저드 쌍에 476개 병합을 전수 수행했다. 결과: 비동일 병합 54개가 기존 스위트를 통과하면서 wire 값을 손상시켰다.

첫 하네스는 "476/476 적발"을 보고했는데, 동일값 120쌍은 원리상 병합해도 동작이 안 바뀌므로 100% 적발은 하네스가 깨졌다는 증거다. 두 이름을 같은 dict에 aliasing한 것이 원인이었다. 이 프로젝트에서 10번째 무력 하네스이고 메커니즘은 매번 다르다.

Breaking

! 표시:

  • ff4fed9--exchange 23곳 all 확대 + 30곳 전부 기본값 KRX→all. bare market rank volume이 통합을 조회한다. 종전 동작은 --exchange KRX 명시. 세 소스(워크북·kwcli spec·kwcli arguments.csv) 일치 확인
  • 15a44ad, 5eb379a — 선택 파라미터 미지정 시 빈 문자열 대신 키 생략
  • 656c7c2 — export ts에 날짜 포함, data.events가 실제 기록 행 수
  • b4025f4 — dashboard stex_tp 1→3

! 미표시지만 breaking(전부 CHANGELOG에 기재): 주문 qty/price 가드(종전 전송되던 값이 exit 1), validate 판정 변화 + qty_ok 키 추가, 0D 이벤트 모양 변경(NDJSON 소비자·기존 녹화파일 영향), sqlite UNIQUE + 기존 파일 자동 마이그레이션, 스트림/watch 종료코드 변경, auth logout 동작, 캐시 TTL 2종.

알려진 한계

실시간 필드 21+09:00이 무조건 붙는데 US 타입(FT)이 같은 ID를 쓴다. 키움 자체 소스가 서로 모순되고(FE의 별도 51020 현지 체결시간 vs 예시에서 20 == 51020) 워크북 예시가 합성이라 확인하지 못했다 — 확인한 게 아니다. 오늘은 도달 불가(run_stream이 US 소켓을 dial하지 않음)지만 FT 녹화 프레임 소비자는 ts를 믿으면 안 된다. 실제 US 프레임 1개면 결판난다.

검증

구현 8청크 각각 implementer → review → fix. 완료 후 각자 격리된 worktree에서 whole-branch 리뷰 3개(교차 영향 / wire 값·스펙 / 테스트 품질·문서).

전 트랜치에서 다섯 번째로, whole-branch 리뷰가 per-task 리뷰가 구조적으로 볼 수 없는 것을 찾았다: ts가 날짜 없이 시각만 담는데 D4의 sqlite UNIQUE가 그 컬럼으로 dedup해 여러 날짜의 같은 시각 이벤트를 조용히 버렸다(0B/0D 재현). 리뷰는 또 mdfy_qty=0(스펙상 "잔량 전부 정정")이 D5 가드에 막힌 것, dashboard가 D7 스윕에서 누락된 것, sent_trnms()trnm 외 전부 버려서 LOGIN 토큰을 통째로 빼도 1940 테스트가 green이던 것을 찾았다. 전부 수정됨.

수정은 전부 프로덕션 코드를 git에서 되돌려 falsify했고(추론 금지), 하네스는 뮤턴트 네거티브 컨트롤 선행 후에만 신뢰했다.

🤖 Generated with Claude Code

gejyn14 and others added 30 commits July 20, 2026 07:45
LOGIN 응답 핸들러가 SYSTEM 프레임의 키(code/message)를 읽고 있어 인증 실패를
한 번도 감지하지 못했다. 실패한 인증에도 "인증 성공"을 출력하고 그대로 진행했다.
실측(.superpowers/sdd/task-22-login-frame-evidence.md)상 LOGIN 응답 키는
return_code/return_msg이고 return_code는 int다.

- LOGIN ack를 return_code로 판정 (str(rc) != "0" 비교로 int/str 모두 처리)
- 인증 실패의 table 모드 종료코드를 0에서 EXIT_AUTH로 교정 (json과 일치)
- 토큰 없음도 table 모드에서 EXIT_AUTH로 종료 (기존 exit 0)
- 등록 실패 시 table 모드도 EXIT_API로 즉시 종료. 기존에는 빨간 글씨만 찍고
  계속 돌아 구독 없는 소켓이 마감시각까지(마감 없으면 영원히) 매달렸다
- 핸드셰이크의 위치 가정 제거: recv_ack()가 PING(에코)/SYSTEM(표시)을 건너뛰고
  return_code를 실은 프레임까지 읽는다. prod는 LOGIN 응답 뒤에 SYSTEM을 보내지만
  mock은 안 보내므로 위치 기반 recv()는 환경마다 다른 프레임을 ack로 읽는다.
  MAX_HANDSHAKE_SKIP으로 상한을 둬 잡담만 보내는 서버에 매달리지 않게 했다
- SYSTEM 출력을 print_system_frame()으로 추출해 핸드셰이크·수신 루프가 공유

테스트: 가짜 websocket(tests/fakes.py)으로 인증 실패/성공, 프레임 순서 교란,
등록 실패, 토큰 없음을 table·json 양쪽에서 고정 (12건).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
watch.py는 테스트가 없었고 어떤 실패 경로에서도 0이 아닌 종료 코드를 내지 않았다.

- LOGIN/REG ack를 recv() 순번으로 집던 것을 streaming.recv_ack()로 교체.
  prod는 LOGIN 응답 뒤에 SYSTEM 프레임을 보내지만 mock은 안 보내므로, 위치로
  읽으면 SYSTEM을 ack로 오독해 return_code 부재 → -1 → 사유 공란 "인증 실패"
  → exit 0으로 끝났다. mock 테스트만으로는 절대 드러나지 않는 경로다
- 실패 종료 코드 부여: 토큰 없음·인증 실패 = 3, 등록 실패·연결 거부·기타 예외 = 2
  (streaming.py의 EXIT_AUTH/EXIT_API와 동일). 서버의 정상 종료는 그대로 0
- import websockets를 try로 감싸 ImportError를 raw traceback 대신 안내 메시지로
  처리하고, ConnectionClosed/ConnectionRefusedError만 잡던 except에 일반 핸들러 추가
- 수신 루프가 SYSTEM 프레임을 조용히 버리던 것을 표시하도록 변경
- -f json/csv는 fail_input으로 거부 (Rich TUI라 NDJSON 계약을 지킬 수 없는데
  조용히 TUI를 띄워 stdout이 비었다). REST 조회 전에 거부한다

테스트: tests/test_watch.py 신규 17건.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
table 모드가 ConnectionRefusedError와 일반 예외에서 (0, None)을 반환해, 거부된
연결과 예기치 못한 오류가 $?로 분기하는 쪽에는 성공한 스트림으로 보였다. json은
이미 2였다. 양 모드를 2로 맞춘다.

서버가 접속만 받고 LOGIN에 답하지 않은 채 끊으면 recv_ack의 recv()가
ConnectionClosedOK를 던져 바깥 핸들러로 새고 exit 0이 됐다. 인증되지 않은 세션은
성공이 아니다. ack를 아예 못 받은 경우(이미 exit 3)와 관측적으로 같은 상황이라
같은 종료 코드 3(AUTH_REQUIRED)을 쓴다. json 쪽의 UPSTREAM_ERROR/2도 여기에 맞췄다.

ConnectionClosedOK는 ConnectionClosed의 하위 클래스인데 두 파일 모두 하나로 묶어
"정상 종료는 실패가 아니다"라는 주석 아래 0을 냈다. 실제 websockets는 정상 종료면
async for를 조용히 끝내고 비정상일 때만 raise하므로 그 핸들러의 실제 트래픽은
비정상 쪽이었다. OK -> 0, Error -> 2로 가른다.

recv_ack는 PING도 SYSTEM도 아니고 return_code도 없는 프레임(즉 REAL)을 루프 바닥
으로 흘려보내 버렸다. watch는 REG ack를 recv_ack로 받으므로 서버가 시세를 밀기
시작하는 바로 그 순간의 틱이 사라졌다. buffer 파라미터로 호출자에게 넘기고,
watch는 Live 시작 직후, streaming은 수신 루프 선두에서 소비한다.

PING과 SYSTEM이 10프레임 예산을 공유해 킵얼라이브 10번만으로 인증 실패가 났다.
버려지는 잡담(SYSTEM)과 버려지지 않는 트래픽(PING·버퍼링된 데이터)의 예산을 분리.

return_code만 보고 아무 ack나 수용하던 것도 고쳤다. REG 자리의 중복 LOGIN ack가
등록 없이 성공으로 통과했다. trnm 완전 일치가 아니라 거부 목록으로 구현한 이유는
실측 근거가 있는 trnm이 LOGIN 응답뿐이어서다 — REG 응답의 trnm 문자열을 등록
성공의 필요조건으로 삼으면 LOW 등급 오독을 HIGH 등급 가용성 문제로 바꾸게 된다.

테스트 1609 -> 1637. 신규 28건은 전부 해당 프로덕션 라인을 되돌려 red를 확인했고
(음성 대조군 포함), json/table 양쪽 갈래를 개별로 고정한다. test_watch.py의
test_normal_close_exits_zero는 ConnectionClosed 핸들러를 고정하는 것처럼 보였지만
프레임 큐 소진으로 StopAsyncIteration이 나 그 핸들러를 한 번도 실행하지 않았다.
경로를 명시하는 이름으로 바꾸고 진짜 핸들러 고정을 따로 추가했다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
주식호가잔량(0D) 프레임이 처음부터 끝까지 정규화되지 않았다. "21"(호가시간)이
시각 ID로 등록돼 있지 않아 모든 0D 이벤트의 ts가 None이었고, 10단계 호가
ID 41~80은 어디에도 없어 숫자 ID 문자열 그대로 샜다. 숫자 분류에도 없어
매도호가가 "-20800" 같은 방향지시자 부호를 단 채 음수 가격으로 보였다.

필드 매핑은 docs/미국 REST API 문서.xlsx 시트 '주식호가잔량(0D)'과 kwcli
0.1.1이 동봉한 kiwoom_api_spec.json의 같은 항목을 대조해 확정했다. 두 소스가
필드별로 일치한다. 41~50 매도호가, 51~60 매수호가, 61~70 매도호가수량,
71~80 매수호가수량. 예시 값이 매도 오름차순·매수 내림차순·ask1>bid1을
만족하는 것으로 방향도 교차검증했다.

- normalize: "21" -> ts, 41~80 -> ask/bid/ask_qty/bid_qty 1~10
- formatters: 가격 41~60은 _ABS_FIELDS(부호=방향지시자), 수량 61~80은
  _SIGNED_FIELDS. 스펙이 가격에만 "부호가 포함된 숫자"를 명시하고, 규격 외
  부호가 나타났을 때 ABS는 abs()로 정보를 버리는 반면 SIGNED는 보존한다
- history: ts를 못 읽어 건너뛴 이벤트를 stderr로 경고 (조용한 폐기 금지).
  stdout(json envelope/NDJSON)은 오염시키지 않는다. 폐기가 시간 필터
  적용 시에만 일어나는 기존 동작은 그대로 두고 테스트로 고정했다

레코더가 만들 수 없는 ts와 REST 필드명(sel_fpr_bid)을 손으로 넣어 이 버그를
가리고 있던 test_history 픽스처 2건을 handle_message 실제 출력에서 파생하도록
교체했다. 수량 분류를 고정하려던 첫 테스트는 스펙 값에 부호가 없어 ABS로
바꿔도 통과하는 무력한 고정이었다 — 두 분류가 갈라지는 유일한 입력인
부호 붙은 수량으로 다시 썼다.
FT(미국주식 10호가)는 0D와 **같은 필드 ID 체계**를 쓴다 (21 시간, 41~50
매도N호가, 51~60 매수N호가, 61~70/71~80 잔량). 따라서 직전 커밋의 0D 매핑이
FT 필드도 이미 커버하고 있었고, 새 ID 매핑은 필요하지 않았다. 스펙 프레임을
handle_message에 통과시켜 확인했다. 남은 결함은 US 고유 요소 둘이다.

- REALTIME_TYPES에 US 타입이 없어 모든 US 이벤트의 type_name이 "?"였다.
  US_REALTIME_TYPES를 따로 두고 type_name_of()가 양쪽을 조회한다
- stexTp(거래소코드)가 버려졌다. US 프레임은 거래소를 values가 아니라 entry
  형제 키로 보낸다. 같은 티커가 거래소별로 갈리므로 event["exchange"]로
  보존한다. 국내 프레임엔 없는 키라 있을 때만 싣는다

FT를 REALTIME_TYPES에 넣지 않은 이유: 이 맵은 stream custom의 입력 검증에도
쓰이는데 run_stream은 /api/dostk/websocket에 고정돼 있고 US 실시간은
/api/us/websocket이다. 넣으면 stream custom FT가 검증을 통과한 뒤 국내
소켓에서 오지 않을 데이터를 기다리며 매달린다 — 지금의 즉시 거부보다 나쁘다.
README의 "WebSocket 19종" 수치도 실제로 구독 불가능한 타입으로 부풀게 된다.

_USD_FIELDS에는 넣지 않았다. 정수 104,004건 전수 비교에서 _fmt_usd와
_fmt_number는 완전히 같고, 소수는 갈리지만 _smart_fmt가 보는 키는 wire ID가
아니라 정규명(ask1)이라 애초에 분류를 타지 않는다. 정규화가 float를 내므로
페니주식 0.0012도 이미 그대로 렌더된다 — 넣었다면 효과 없는 상수만 늘었다.

미검증으로 남긴 것: _iso_datetime은 HHMMSS에 무조건 +09:00을 붙이는데 이
ID들은 US 타입과 공유된다. FE에 "현지 체결시간"(51020)이 따로 있는 것은 KST를
시사하지만, 워크북 예시가 합성 데이터라(FE의 20과 51020이 둘 다 "215300")
값으로 확증할 수 없다. 주석에 위험으로 남겼다.
모의투자에서 학습한 거래소가 실거래 주문의 stex_tp로 그대로 나가던 문제.
캐시 파일이 us_exchanges.json 하나뿐이라 prod/mock이 같은 항목을 공유했다.

- 파일명을 us_exchanges-<domain>.json으로 분리 (config.get_domain_key)
- 항목 형식을 {"exchange":..., "ts":...}로 바꾸고 24시간 지나면 무시
  (틀린 값이 영구화되지 않도록)
- v2.12 이하의 평문 형식은 마이그레이션 없이 버린다 — ts가 없어 신선도를
  알 수 없고 어느 도메인에서 학습했는지도 모른다
- 캐시 파일도 0600으로 조인다

프로필로는 나누지 않는다. 'NVDA가 나스닥 상장'은 계좌가 아니라 시장의
사실이라 같은 도메인의 프로필끼리 공유해도 틀릴 여지가 없다.

기존 캐시 테스트는 같은 도메인 안에서만 왕복해서 스코프 유무를 구분하지
못했다. 도메인을 바꿔 교차로 읽는 테스트를 추가했다.
잔고 응답은 종목코드를 시장구분 접두사와 함께 'A005930'으로 돌려준다.
is_us_symbol이 이걸 6자리 숫자가 아니라고 보고 미국으로 판정해서,
국내 분기는 stk_cd 필터를 조용히 빼먹고(전 종목 반환) 미국 분기는
'A005930'을 ust21050에 보냈다. 감사가 지목한 orders pending 외에
pnl today / pnl by-period / orders executed / orderable margin-qty와
stock·order의 국내 분기 전체가 같은 뿌리다.

- normalize.strip_kr_market_prefix 추가: **영문 1자 + 숫자 6자리**라는
  완성된 모양에만 적용한다. 선행 영문자를 무조건 벗기면 미국 티커가
  깨진다 (NVDA -> VDA, F -> ''). 미국 티커는 숫자로 끝나지 않아 이
  정규식에 걸릴 수 없다
- normalize_record가 stk_cd를 접두사 없이 내보낸다. 원본은 data.raw에 남음
- is_us_symbol과 국내 분기 body 구성 지점에 적용
- order validate의 removeprefix("A")는 죽은 코드가 아니었다 (kt00004 원본
  응답을 보므로). 빼면 보유수량이 0으로 잡히는 걸 확인하고, 가드 있는
  헬퍼로만 교체했다
_save_stock_cache가 fetched_at을 기록만 하고 읽는 곳이 없어서 stocks.json이
만료 없는 영구 캐시였다 — 신규 상장/폐지가 `stock sync`를 손으로 돌리기
전까지 절대 반영되지 않는다. 필드를 지우는 대신 판정에 쓴다.

TTL은 거래소 캐시(24h)보다 길게 잡았다. 상장/폐지는 하루 단위로 흔들리지
않고, 만료 시 재동기화가 ka10099 4회 호출이라 대화형 `stock search`에서
매일 물게 하기엔 비싸다. 이 값은 주문 body에 들어가지 않아 틀렸을 때의
대가도 거래소 캐시보다 작다. 같은 이유로 도메인 분리도 하지 않는다 —
KRX 상장 목록은 계좌·환경과 무관한 공개 참조 데이터다.

fetched_at이 없거나 해석 불가면 신선도를 주장할 수 없으므로 만료 취급.

'A005930' 라우팅 수정에 따라 stock 국내 분기에도 접두사 제거를 적용했다.
--out에 없는 디렉터리를 주면 dest별로 제각각인 예외(sqlite
OperationalError, csv FileNotFoundError)가 raw traceback으로 튀었고
json 모드에서는 envelope 없이 죽었다. recorder.NdjsonRecorder.write와
같은 패턴으로 상위 디렉터리를 만들고, 만들 수 없으면 fail_input으로
INVALID_INPUT(exit 1)을 낸다. 파일을 쓰는 커맨드 중 여기만 빠져 있었다.

sqlite는 csv/parquet과 달리 append인데 PK도 UNIQUE도 없이 무조건
INSERT했다. 같은 구간을 다시 내보내면 행이 그대로 배가 됐다.
UNIQUE(ts, symbol, type, raw_json) + INSERT OR IGNORE로 재-export를
멱등하게 만든다. price/volume을 제약에 넣지 않은 것은 0D처럼 둘 다
NULL인 타입에서 NULL != NULL로 제약이 무력해지기 때문이다.

CREATE TABLE IF NOT EXISTS는 기존 테이블에 새 제약을 붙여주지 않으므로
구버전 파일에서는 INSERT OR IGNORE가 그냥 INSERT로 퇴화한다. 조용히
계속 중복되는 대신 스키마를 승격한다(rename → 새 DDL → INSERT OR
IGNORE SELECT → drop). 이미 쌓인 중복은 이때 한 건으로 접힌다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
auth.load_token은 KIWOOM_TOKEN을 키체인보다 먼저 반환한다. revoke_token은
그 env 토큰으로 폐기 요청을 보낸 뒤 무조건 auth.delete_token으로 키체인의
{profile}:token을 지웠다 — 방금 폐기한 것과 다른, 아직 살아 있는 토큰을
없애 영영 폐기할 수 없게 만들었다. main.py는 안내 문구만 출력했을 뿐
삭제를 건너뛰지 않았고, 그 문구("해당 토큰이 계속 사용됩니다")도 사실과
달랐다. 폐기된 것은 바로 그 env 토큰이다.

키체인 항목은 그것이 방금 폐기한 바로 그 토큰일 때만 지운다. env 토큰과
키체인 토큰이 같은 값이면 죽은 토큰을 남기지 않기 위해 지운다(auth status가
유효한 것처럼 보고하는 것을 막는다). 키체인을 읽되 env를 무시하는
auth.load_keychain_token을 추가했다 — load_token으로는 "지금 쓰는 토큰"과
"키체인에 있는 토큰"을 구분할 수 없다.

revoke_token은 이제 {token_source, keychain_token_deleted}를 돌려주고,
logout은 무엇을 폐기했고 무엇을 지우지 않았는지 정확히 출력한다.
json 모드 envelope에도 두 필드가 실린다.

테스트는 env/키체인 유무 4가지 조합 + 두 값이 같은 경우를 모두 고정한다.
KIWOOM_TOKEN을 세팅하지 않는 테스트는 두 분기를 구분하지 못한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
save_config(cfg)가 keyring 이전 루프보다 먼저 실행됐다. cfg["profiles"]의
존재가 곧 `if "profiles" in cfg: return False` 가드의 마이그레이션 완료
표식이므로, keyring 호출 중 예외가 나면 config.toml에는 이미 표식이 박힌
채로 남는다. 다음 실행은 가드에 걸려 keyring 이전을 영영 재시도하지 않고
bare appkey/secretkey/token이 고아로 남았다.

save_config를 keyring 루프 뒤로 옮겨, 자격증명이 실제로 옮겨간 뒤에만
config가 이전됨으로 표시되게 한다.

keyring 이전과 config 저장 사이에서 죽는 창도 안전하다: 각 키가 set ->
delete 순서라 루프가 멱등하기 때문이다. set 직후에 죽으면 bare 키가 남아
다음 실행이 같은 값으로 다시 set하고 delete하며, config에는 아직 표식이
없으므로 가드에 걸리지 않는다. 이 멱등성을 테스트로 고정했다.

token 이전도 같은 루프로 합쳤다 (순서·동작 동일).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
수량 0/음수, 음수 가격, NaN/Inf가 실제 전송되던 경로를 막는다. base에서
측정한 결과 `ord_qty:"0"`, `ord_uv:"-70000"`, `cncl_qty:"-5"`,
`fc_exmn_amt:"-500000"`이 그대로 나갔고, 미국 경로는 `ord_uv:"nan"`까지
전송됐다. 국내 NaN/Inf는 int() 변환에서 ValueError/OverflowError로
envelope 없이 죽었다.

_mutation.py에 공용 validate_order_qty/validate_order_price를 두고
주식·신용·금현물·미국의 매수/매도/정정/취소가 공유한다. 취소만
allow_zero=True — `--qty 0` = 전량취소가 문서화된 계약이다.

근거는 키움이 배포하는 kwcli 0.1.1의 maps/arguments.csv다: 주문·정정
수량은 type=quantity(positive_int_string, `<= 0` 거부), 취소 수량은
type=cancel_quantity(nonnegative_int_string, 0 허용), 가격은
type=price(price_string, `^\d+$`)로 선언되어 있다. kwcli가
domestic orders cancel만 quantity로 선언해 형제 명령과 어긋나는 부분은
따르지 않았다(우리 기본값 0이 항상 거부된다).

미국 정정(ust20002)은 요청 스펙에 수량 필드가 아예 없어(mdfy_ord_qty는
응답 필드) 0이 아닌 수량을 거부한다. 이전에는 수량을 조용히 버리고
stderr 경고만 냈는데, 그 경고는 수량 미지정 시에도 무조건 떴고 json
소비자에게는 보이지 않아 "수량 5로 정정 성공"으로 읽혔다. 거부 형태는
같은 파일의 cancel이 이미 쓰는 계약을 그대로 따랐다. 정정단가도
0 이하를 거부한다 — 정정에 시장가는 없다.

kwcli에는 미국 API가 0건이라 US 3건은 kwcli가 침묵한다. 침묵을 허용으로
읽지 않고 워크북 요청 스펙에 근거했다.

breaking: 전송되면 안 되는 값만 제거했고 정상 값 집합은 그대로다.
자세한 before/after 측정표는 작업 리포트 참고.
D5가 주문 경로에만 하한을 넣어 프리플라이트와 실주문이 어긋났다.
validate는 qty=0과 음수 가격에 valid: true를 답하는데 실주문은 거부한다 —
사전점검을 믿은 에이전트가 자금 이동 직전에 거부당한다. 프리플라이트의
존재 이유가 실주문 결과 예측이므로 이 드리프트 자체가 결함이다.

임계값을 두 벌 두지 않으려고 술어와 종료를 분리했다. validate_order_*가
fail_input을 부르는 탓에 사전점검이 그대로 재사용할 수 없었으므로(프리플라이트는
exit이 아니라 체크를 false로 만들어야 한다), 판정만 하는 순수 함수
is_valid_order_qty/is_valid_order_price를 아래층으로 빼고 기존 함수는 그 위에
fail_input만 얹었다. 국내 '정수(원)' 규칙도 _is_kr_integer_price로 단일 정의가
됐다. 공용 술어의 임계값을 바꾸면 실주문 테스트와 사전점검 테스트가 함께
깨지는 것을 확인했다.

수량 쪽이 가격보다 나빴다: price는 price_ok라는 자기 체크라도 있었지만
수량은 어떤 체크도 커버하지 않았다. 매수는 est_cost=0이라 'ord_alow_amt >= 0',
매도는 'held >= 0'이 공허하게 참이 됐다 — 이 함수가 price_known에 대해 이미
막고 있던 바로 그 실패 모드다.

NaN/Inf는 프리플라이트가 실주문보다 더 나빴다. `price == int(price)`와
`if price:` 아래 `int(price)`가 각각 터져 stdout이 비었다(envelope-항상 계약
위반). NaN이 truthy라 두 번째 분기를 통과한다.

checks에 qty_ok를 추가했다. 기존 체크 중 유일한 후보인 sufficient_balance는
'잔고 부족'으로 읽혀, 수량이 0인 사용자에게 입금하라고 안내하는 틀린 진단이
된다. 가산적 변경이라 valid=all(checks.values())와 이름으로 읽는 소비자는
그대로이고, exact-dict를 고정하던 AGENTS.md/README/테스트 2건을 갱신했다.
price_known 추가 때와 같은 방식이다.

실패는 기존 계약대로 나타난다: VALIDATION_FAILED + exit 1, 새 오류 코드 없음.
세 클로저 술어를 전부 양방향으로 계산하고, 그 결과를 테스트로 고정한다.

관계 개수 (문자열->문자열 코드북 173개, 순서쌍 29,756개 전수):
  술어1 strict (key,value) subset   : 120쌍 (단방향 120, 양방향 0)
  술어2 keys 부분집합 + 값 상이      : 204쌍 (단방향 112, 양방향 46쌍)
  술어3 부분 겹침 + 양쪽 배타 키     : 78쌍 (대칭)
  값이 완전히 동일(원리상 고정 불가) : 120쌍

술어3은 이전 세션이 "인스턴스가 하나뿐"이라며 미뤘던 것인데, 그 수치는
찾아보지 않아서 생긴 착시였다. 실제로는 78쌍이고 78쌍 전부 양쪽이 실제
커맨드에 배선돼 있다. 가장 조밀한 쌍은 RANK_CHANGE_STK_CND <->
STOCK_CONDITION (공유 14키 전부 값 일치, 배타 키 각각 1개)다.

방어 방식: 개별 값 고정을 54개 새로 쓰는 대신, "이름이 다른 두 코드북이
동치가 되면 실패"라는 불변식 하나로 술어 1/2/3이 잡는 356쌍을 한 번에
막는다. 어떤 병합이든 두 상수를 동치로 만들기 때문이다.

검증(mutation sweep): 476개 병합을 실제로 수행했다. 기존 스위트는 그 중
54개(술어1 25, 술어2 9, 술어3 20)를 통과시켜 조용히 지나쳤고, 새 테스트는
54/54를 전부 잡는다. 값이 원래 같은 120쌍은 양쪽 모두 green으로 남는다
(원리상 구분 불가 — allowlist에 등록하고 이름 규약에 맡긴다).

하네스 주의: 병합 시 두 이름에 **각각 별도 dict 사본**을 줘야 한다. 같은
객체로 alias하면 test_registry_covers_every_wired_mapping의 id() 기반
룩업이 무너져 모든 병합이 코드북과 무관한 이유로 실패한다 — 실제로 이
착각 때문에 476/476 "전부 잡힘"이라는 완전히 무의미한 결과가 한 번 나왔다.
BREAKING: bare 호출의 결과 범위가 바뀐다. `market rank volume` 등 30개
커맨드가 지금까지 KRX 단독(stex_tp=1)을 조회했는데 이제 통합(3)을
조회한다. 사용자가 승인한 의도적 동작 변경이다. 종전 동작이 필요하면
--exchange KRX를 명시할 것.

받아들이는 값 집합은 넓어지기만 했다(breaking 아님):
  23곳: {KRX, NXT}        -> {KRX, NXT, all}
   7곳: {KRX, NXT, all}   -> 변화 없음
  30곳 전부: 기본값 KRX -> all

스펙 근거 (23곳 전부 확대, 제외 0곳):
  ka10016 ka10017 ka10018 ka10019 ka10020 ka10021 ka10022 ka10023
  ka10027 ka10029 ka10031 ka10033 ka10034 ka10035 ka10036 ka10037
  ka10039 ka10062 ka90009 ka10051 ka20002 ka90001 ka90002

세 소스로 확인했고 30/30이 일치한다:
  1. docs/미국 REST API 문서.xlsx — stex_tp Required=Y, 3:통합
  2. kwcli 0.1.1 번들 kiwoom/_data/kiwoom_api_spec.json — Required=Y,
     length=1, 3:통합
  3. kwcli 0.1.1 번들 kiwoom_cli/maps/arguments.csv — 키움 자체 CLI의
     실제 배선. 30/30 전부 choices="KRX|NXT|ALL",
     value_map="KRX=1;NXT=2;ALL=3"
(3)은 산문이 아니라 키움이 배포한 코드의 wire 매핑이라 가장 강한 근거다.
세 소스 간 모순은 없었다.

스펙 원문 표기는 두 가지로 갈리지만(kwcli spec 기준
'1:KRX, 2:NXT 3.통합' 21곳 / '1:KRX, 2:NXT, 3:통합' 9곳) 문장부호 차이일
뿐 값 집합·의미 경계와 무관하다.

EXCHANGE_TWO 제거: market.py 23곳이 유일한 사용처였고 전부 EXCHANGE_ALL로
넘어가 배선이 하나도 남지 않았다. 배선 없는 코드북 상수는 값을 고정할
테스트를 붙일 수 없으면서 잘못 재사용될 표면만 넓힌다(MARKET_SEARCH와
같은 이유). EXCHANGE_ALL의 진짜 부분집합이라 특히 위험했다.

테스트: 전송 body를 캡처한다(옵션 선언 덤프는 커맨드 본문의
EXCHANGE_ALL[stex_tp] 룩업에 구조적으로 눈이 멀어 있다).
  - 사이트 발견 30개 고정 (탐색이 조용히 줄면 전수 테스트가 무력해진다)
  - 30곳 accepted 집합/기본값 고정
  - 30곳 기본 호출이 실제로 stex_tp="3"을 전송하는지 body로 확인
  - 30곳 × KRX/NXT/all 3값 전부 wire 코드 확인 (90 케이스)
  - EXCHANGE_ALL(all=3) vs EXCHANGE_ALL_ZERO(all=0) 구분 고정 —
    키 집합이 같아 병합해도 KeyError가 안 나는 유일한 잔여 해저드
falsify: 프로덕션 코드를 되돌리면 84개가 실패하고, 복원하면 전부 통과.
_sync_stocks의 루프가 ["0","10","8","3"]이라 ka10099에 ETN 시장코드 "60"을
한 번도 요청하지 않았다. 그런데 `stock search --market etn`은 Choice에
광고돼 있고 _filter_map에도 ETN 라벨이 있어서, 사용자에게는 정상 옵션으로
보이면서 **구조적으로 항상 0행**이었다 — 캐시에 ETN이 들어올 경로 자체가
없었다.

ka10099 mrkt_tp 스펙은 "60 : ETN"을 문서화한다(docs/미국 REST API 문서.xlsx
+ kwcli 0.1.1 번들 kiwoom_api_spec.json 이중 확인, Required=Y).

_api_map/_market_label에도 ETN을 추가했다. 없으면 캐시 행의 market이 빈
문자열이 되어 type 쪽으로만 걸러진다.

_kind_label({"A":"주식","Q":"ETN","J":"ELW"})은 그대로 둔다. 도달 불가였던
것은 "Q" 분기 하나뿐이고 A/J는 살아 있다 — 도달 불가를 이유로 룩업을
지우면 살아 있는 두 분기까지 함께 날아간다. 세 분기 유지를 테스트로 고정.

테스트 주의: 처음 짠 search 테스트는 무력했다. fake가 모든 시장코드에 같은
응답을 주기 때문에 루프에서 60을 빼도 통과했다. mrkt_tp="60" 요청에만 ETN
행을 돌려주도록 고쳐 실제로 구분되게 만들었다.
falsify: stock.py를 되돌리면 2개가 실패하고, 복원하면 1912개 통과.

범위 밖(별도 작업 필요): ka10099 스펙은 30(K-OTC), 50(코넥스), 70(손실제한
ETN), 80(금현물), 90(변동성 ETN), 2(인프라투융자), 4(뮤추얼펀드),
5(신주인수권), 6(리츠), 7(신주인수권증서), 9(하이일드펀드)도 문서화하지만
--market Choice에 대응 값이 없어 이번엔 건드리지 않았다.
ka50080(gold chart-minute)의 upd_stkpc_tp와 ka90013(program stock-daily)의
amt_qty_tp/date는 스펙상 Required=N이다. 미지정은 "빈 값을 명시했다"가 아니라
"보내지 않는다"인데, 세 자리 모두 default=""를 그대로 body에 넣어 빈 문자열을
전송하고 있었다. ka10038(rank broker-by-stock)의 선례대로 조건부로 키를 추가한다.

전송 body 변화:
- ka50080 기본: {stk_cd, tic_scope, upd_stkpc_tp:""} → {stk_cd, tic_scope}
- ka90013 기본: {amt_qty_tp:"", stk_cd, date:""} → {stk_cd}

값을 지정한 호출은 그대로다. --price-type/--unit은 자유 텍스트로 유지한다
(Required=N이라 API가 받는 값이 전부 통했으므로 enum을 씌우면 breaking).

Tranche E에서 전송 바이트 변경이라 미뤄둔 항목이다. E가 옛 동작을 고정해둔
테스트 2건은 새 계약으로 옮기고, 키 생략은 옵션 선언이 아니라 전송 body에서
`"key" not in body`로 검증한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sector index만 --sector-code 옵션이라 도움말에 SECTOR_CODE가 나오는데,
나머지 9개 명령은 위치인자라 파라미터 이름 INDS_CD가 그대로 노출됐다.
한 그룹이 사용자에게 두 가지 어휘를 가르치는 셈이었다.

metavar만 바꾼다. 위치인자를 옵션으로 바꾸지는 않는다 — sector index는
전체 업종을 반환해 기본값이 의미가 있으니 옵션이 맞고, 단일 업종 조회
9개는 위치인자가 맞다. 틀린 건 표시되는 이름뿐이다.

전송 body는 그대로다. HEAD와 현재의 sector 11개 호출 body를 캡처해
동일함을 확인했고, 하네스가 눈멀지 않았는지는 inds_cd를 고정값으로
바꾼 대조군이 검출되는 것으로 확인했다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
멱등성 원장은 append-only라 파일도 lookup()의 선형 스캔도 무한히 자랐다.
90일 지난 종결 키를 제거하는 prune()과 이를 부르는 수동 명령을 추가한다.

트리거를 수동 명령 하나로 둔 이유:
원장이 잘리면 lookup()이 None을 반환하고 그 결과는 이미 체결됐을 수 있는
주문의 재전송이다. 실패의 대가가 "파일이 크다"와 비교가 안 된다. record()에서
확률적으로 재작성하는 안은 그 위험을 주문 실행 도중에, 무작위로, 조용히
터뜨린다 — 재현도 사후 추적도 불가능하다. 게다가 record()는 주문이 이미
전송·수락된 뒤에 불리므로 거기서 하는 추가 작업은 "응답은 받았는데 기록
실패" 창을 넓히기만 한다. 반대로 방치의 대가는 작다: --client-order-id를 쓴
주문만, 한 줄 200바이트 남짓으로 쌓여 수천 건이어도 수백 KB다. 대가가
비대칭이라 결정론적이고 감사 가능한 수동 실행을 택했다.

판단은 줄이 아니라 키 단위다. lookup()은 키별 마지막 레코드만 돌려주므로
최종 상태가 근거이고 앞선 줄은 이력이다.
- 최종이 inflight인 키: 나이 무관 통째로 보존
- 최종이 종결이고 오래된 키: 통째로 제거 (그 앞의 inflight 줄까지)
- 해석 불가능한 줄, key/ts 없음: 보존 (지울 근거가 없으면 남긴다)

크래시 안전성: 임시 파일에 쓰고 fsync한 뒤 os.replace로 교체하고 디렉터리도
fsync한다. 어느 시점에 죽어도 원장은 '이전 전체' 또는 '이후 전체'이고 잘린
중간 상태가 없다. 전 구간을 기존 locked()로 감싼다 (재진입 불가라 주문
경로에서는 호출 금지).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
녹화 NDJSON의 ts는 시각만 담는다 (normalize._iso_datetime이 WS의 HHMMSS를
"09:30:15+09:00"으로 렌더한다). 날짜는 파일명에만 있다. export가 그 값을
그대로 쓰는 바람에 DEDUP_COLUMNS(ts, symbol, type, raw_json) + INSERT OR
IGNORE가 날짜맹목이 됐다 — 여러 날을 한 번에 내보내면 날짜만 다른 별개
이벤트가 조용히 한 건으로 접힌다 (재현: 이틀치 동일 내용 → 2건 수집,
1행 저장). idx_events_symbol_ts도 같은 이유로 날짜를 가로질러 무의미했다.

날짜 결합은 **export 값에서만** 한다. 녹화 파일과 history query의 ts는
그대로다 — NDJSON 한 줄의 모양은 AGENTS.md가 문서화한 계약이고 이미 기록된
파일과도 섞이므로, 녹화 출력의 모양을 바꾸는 것 자체가 breaking이다.
--from/--to가 이미 쓰던 _event_dt의 결합 규칙(파일명 날짜 + 시각)을 그대로
재사용한다. 파일명에서 날짜를 못 읽으면 지어내지 않고 녹화 값을 유지한다.

전송/저장 값 변화 (breaking):
- sqlite/csv/parquet의 ts: "11:00:00+09:00" → "2026-07-16T11:00:00+09:00"
- json 모드 data.events: 수집 건수 → 실제 삽입 행수. sqlite 재-export는
  이제 0을 보고한다 (이전엔 테이블에 1행뿐인데 "events: 2"를 냈다)

픽스처 맹목도 함께 고친다: data_setup은 이틀치를 담지만 시각이 전부 달라
날짜 없이도 행이 구분됐다. 시각까지 같은 colliding_days를 추가하고, 범위
필터가 시각이 아니라 날짜로 갈리는지도 별도로 고정한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kt10002/kt10008/kt50002의 Request 스펙은 mdfy_qty에 "단위: 1주, '0' 입력 시
잔량 전부 정정"이라고 명시한다 (docs/미국 REST API 문서.xlsx의 해당 시트).
부분체결 뒤 남은 알 수 없는 잔량을 재호가하는 문서화된 관용구이고, v2.12까지
mdfy_qty="0"이 실제로 전송됐다. D5가 넣은 수량 하한 가드가 기본값
allow_zero=False로 세 정정 경로를 전부 exit 1로 막았다.

세 정정 경로에만 allow_zero=True를 준다. 음수·비유한 값은 그대로 거부되고
(추가 테스트로 고정), 취소의 --qty 0도 그대로 동작한다. 매수/매도는 손대지
않는다 — 거기엔 0의 특수 의미가 없다.

근거가 갈리는 지점을 숨기지 않고 validate_order_qty 독스트링에 적었다:
kwcli 0.1.1은 mdfy_qty를 positive_int_string으로 선언해 0을 거부한다. 다만
kwcli는 여기서 자기 자신과도 어긋나고(domestic cancel만 positive, credit/gold
cancel은 nonnegative), 한 CLI의 입력 정책이 API 스펙 비고를 뒤집을 근거는
되지 못한다고 보고 스펙을 따른다. 구 워크북(키움 REST API 문서.xlsx)에는
mdfy_qty 비고 칸이 아예 없다는 점도 함께 기록했다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`order validate`의 예제가 `-f json`을 서브커맨드 뒤에 붙여 두었다. -f는 루트
옵션이라 그대로 치면 "No such option: -f"로 exit 1이다. 이 독스트링은
--help와 `kiwoom describe`가 에이전트에게 그대로 내보내는 문자열이다.

정정 예제에는 QTY 0의 의미도 함께 적었다 (국내=잔량 전부, 미국=항상 0).

README/AGENTS/vs-official/main.py/order.py의 `kiwoom order ...` 예제 59개를
가짜 클라이언트로 전부 실행해 확인했다. 하네스가 실제로 뭔가를 잡는지는
음성 대조군 2건(수량 0 매수, 미국 정정 수량 5)을 심어 확인했다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
D7이 ka10030의 --exchange 기본값을 KRX(1)에서 통합(3)으로 옮겼을 때
dashboard.py의 하드코딩 "1"만 남았다. 같은 "당일 거래량 상위"가 두 명령에서
서로 다른 목록을 냈다. D7의 스윕이 market.py 밖의 유일한 호출자를 놓쳤다.

전송 body 변화: dashboard의 ka10030 stex_tp "1" → "3" (통합).
리터럴 대신 EXCHANGE_ALL["all"]을 참조해 다음 이동 때 또 갈리지 않게 한다.

두 명령의 ka10030 body를 전부 대조해 남은 차이도 확인했다: mang_stk_incls가
dashboard "1"(exclude-managed) vs market "0"(include-managed)로 다르다. 이건
D7이 만든 드리프트가 아니라 그 이전부터 있던 dashboard의 선택이라 바꾸지
않고, 대신 테스트에 명시적으로 고정했다 — 조용히 두면 다음 드리프트가
"원래 다르던 것"에 섞여 안 보인다.

테스트는 옵션 선언이 아니라 전송 body를 비교한다 (선언만 보면 커맨드 본문의
하드코딩이 보이지 않는다).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FakeWebSocket.sent_trnms()가 trnm 외의 모든 키를 버려서, 핸드셰이크에 대해
스위트가 사실상 아무것도 고정하지 않고 있었다. 실제로 확인한 것: LOGIN에서
token을 streaming.py·watch.py 양쪽에서 통째로 빼고, watch가 종목 BOGUS의
타입 0X를 grp_no=9/refresh=0으로 등록하게 바꾼 상태에서 기존 스위트
1951건이 **전부 통과**한다.

sent_frames()(파싱된 dict 그대로)를 추가하고 모듈별로 고정한다:
- LOGIN 프레임 = {"trnm","token"} 전체 일치 (양쪽 모듈)
- REG 프레임 = trnm/grp_no/refresh/data[item,type] 전체 일치 (양쪽 모듈)
- 계좌성 타입(00)은 item이 비어야 함

함께 덮은, 어떤 테스트도 들어가지 않던 경로:
- watch LOGIN 자리의 reject_trnm 양방향. task-D1-fix-report.md §5는 양방향이
  고정됐다고 적었지만 REG 자리만 있었다. 판별되는 프레임 구성은 [REG_OK]
  하나뿐이다 ([REG_OK, LOGIN_OK]는 거부 유무와 무관하게 exit 2라 무력하다)
- watch의 등록 응답 없음(reg_resp is None) 가드. 큐를 말리면 ConnectionClosed
  쪽으로 새므로 SYSTEM 10건으로 건너뛰기 예산을 소진시켜야 도달한다
- watch 수신 루프의 PING 에코 (핸드셰이크 안의 에코와 다른 자리다)
- 양쪽 모듈의 KeyboardInterrupt → exit 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
두 가지가 사실이 아니었다.

1. "어떤 병합이든 두 상수를 동치로 만든다"는 참이 아니다. 한쪽만 넓히는
   확장(A = {**A, **B} — "빠진 키만 채우자"는 자연스러운 수정)은 A != B로
   남아 동치 불변식에 걸리지 않는다. 그런 확장 12쌍이 test_constant_drift.py
   세 테스트를 전부 통과한다 (12개를 하나씩 심어 확인). 그중
   ACCOUNT_EXCHANGE_WITH_SOR |= EXCHANGE_ALL은 계좌 거래소 값을 KRX/NXT/%
   에서 1/2/3 체계로 갈아치우는 실제로 위험한 변형이다.

   12쌍 전부 스위트 전체로는 잡힌다 (각각 다른 파일의 값 고정이 2~8건 실패).
   구멍은 없지만, 커버리지는 이 파일 하나가 아니라 동치 불변식 + vanished
   검사 + 고정된 술어3 인벤토리 + 다른 파일의 전송 body 고정이 함께 만든다.
   독스트링이 그 몫을 불변식 하나에 전부 돌리고 있었다.

2. 해저드 쌍 수는 356이 아니라 352다 (비순서쌍 118 ∪ 156 ∪ 78, 세 집합이
   서로소라 합집합 = 단순 합). 356은 어느 계산으로도 나오지 않는 숫자였다.
   D7 리포트와 progress.md(둘 다 gitignore)에서도 함께 고쳤다.

동작 변경 없음 — 독스트링만 고친다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ka40001의 etfobjt_idex_cd(Required=Y, Length 3)와 ka90008의 date
(Required=Y, Length 8)가 default="" 그대로 body에 실려 빈 문자열을 전송하고
있었다. 두 워크북(docs/미국 REST API 문서.xlsx, docs/키움 REST API 문서.xlsx)
의 Request 표가 일치한다.

빈 문자열도 "보내지 않음"도 스펙 위반이지만, 빈 값을 명시적으로 주장하는
대신 키를 빼면 서버가 자기 필수필드 오류로 답한다. 같은 파일의
ka10038/ka50080/ka90013 선례와 동일한 처리다.

전송 body 변화:
- ka40001 기본: {stk_cd, etfobjt_idex_cd:"", dt} → {stk_cd, dt}
- ka90008 기본: {amt_qty_tp, stk_cd, date:""} → {amt_qty_tp, stk_cd}
값을 지정한 호출은 그대로다.

D8 트리아지가 근거로 든 ka10038 예시는 잘못된 것이었음을 함께 확인했다:
"기간(dt)값은 빈값('')으로 설정" 문구는 실재하지만 그 필드는 dt이고 코드는
dt:"1"을 보낸다. 실제로 비는 건 strt_dt/end_dt이며 둘 다 Required=N이라
무해하다. 나머지 8개 형제도 Required=N이라 그대로 둔다.

옛 동작을 고정하던 테스트 2건은 값을 지정한 호출로 옮기고, 키 생략은
옵션 선언이 아니라 전송 body에서 `"key" not in body`로 검증한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main.py의 prune-ledger는 LedgerLockBusy를 잡아 "잠시 후 다시 시도하세요"를
안내한다. 그런데 POSIX의 _acquire는 LOCK_NB 없이 flock(LOCK_EX)이라 무한
대기한다 — 동시에 주문이 도는 동안 이 명령은 그 핸들러에 도달하지 못하고
사용자 프롬프트 앞에서 조용히 멈춘다. 재현: 잠금을 쥔 채 prune을 부르면
20초가 지나도 끝나지 않는다(테스트가 그대로 매달렸다).

locked()에 blocking 인자를 추가하고 prune만 blocking=False로 잡는다.
POSIX는 LOCK_EX|LOCK_NB(BlockingIOError → LedgerLockBusy), Windows는
LK_NBLCK. 주문 경로는 기본값 그대로 블로킹이다 — 거기서 즉시 실패로 바꾸면
동시 주문이 정확성을 위해 기다리는 대신 LEDGER_BUSY로 흔들린다. 그 구분이
실제로 유지되는지 별도 테스트로 고정했다(주문 경로는 잠금 해제까지 대기).

AGENTS.md의 잠금 대기 설명도 두 경로를 구분하도록 고쳤다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_codebook_mappings()가 str->str만 모으므로 PROGRAM_MARKET_BY_EXCHANGE
(str->dict, ka90005/ka90010의 mrkt_tp에 실배선)는 이 파일의 세 술어와 동치
불변식 어디에도 잡히지 않는다. 지금은 test_market.py의 전송 body 고정이
대신 막고 있지만, **새 중첩 코드북이 생기면 자동 커버리지가 0인 채로 조용히
늘어난다**. 침묵이 위험이다.

수집기를 재귀시키지 않고 인벤토리를 따로 고정한다. 재귀시키면
`len(maps) == 172`부터 술어 카운트까지 전부 흔들리고, 안쪽 dict끼리의
부분집합 관계는 바깥 상수의 병합 위험과 의미가 달라 같은 축에서 셀 수 없다.

두 방향으로 판별되는지 확인했다: 새 중첩 상수를 추가해도, 기존 상수의 바깥
키를 바꿔도 실패한다. 중첩이 평평한 수집에 섞이지 않는지도 함께 고정한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalize._iso_datetime은 WS 시각 필드(20/21/908)에 무조건 +09:00을 붙인다.
이 ID들은 미국 타입과도 공유되는데(FT의 21, FE의 20) 그 값이 KST인지
현지(ET)인지 스펙으로 확정되지 않는다. 워크북 Response Example이 합성
데이터라(FE의 20 == 51020, FT의 41과 FE의 27이 바이트 동일) 확증도 반증도
불가능하다. 틀렸다면 13~14시간 어긋난다.

normalize.py의 코드 주석에는 이미 이 경고가 있지만, 녹화된 FT/FE 프레임을
history로 읽는 소비자는 그 주석을 볼 일이 없다. AGENTS.md에도 남긴다.

타임존은 추측하지 않는다 — 실제 US 프레임으로 확인되기 전까지 코드는 그대로
둔다. 현재 stream 명령은 국내 소켓만 dial하므로 이 경로로는 도달하지 않는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
트랜치 D(스트리밍·견고성·정리) 마지막 태스크 — 2026-07-18 감사의 마지막
트랜치다. 버전을 2.13.0으로 올리고 CHANGELOG [2.13.0] - 2026-07-20을
확정했다.

CHANGELOG는 breaking을 두 절로 나눴다. 커밋 제목에 `!`가 붙은 5건과,
`fix(...)`로만 돼 있지만 종전에 동작하던 호출의 결과가 바뀌는 7건이다.
후자를 묻어두지 않으려고 절 제목에 표시가 없다는 사실 자체를 적었다.

--exchange는 "받는 값 집합이 넓어지기만 했다"와 "기본값이 바뀌었다"를
따로 적었다. 전자는 breaking이 아니고 후자는 breaking이라, 둘을 합치면
bare 호출의 결과가 달라진다는 사실이 가려진다.

미검증으로 남은 것도 함께 적었다: 실시간 필드 21의 +09:00은 US 타입(FT)이
같은 ID를 쓰는데 키움 소스끼리 어긋나 판별하지 못했다. 확인한 것이 아니라
검증하지 못한 것이라고 썼다.

검증: 1970 passed, ruff clean, `kiwoom --version`이 2.13.0(샌드박스 HOME +
keyring 스텁으로 실행). 실행 전후로 ~/.kiwoom의 파일 목록과 전체 md5가
동일함을 확인했다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gejyn14 and others added 7 commits July 20, 2026 14:18
`-f json`은 루트 옵션인데 describe 3건과 find 1건의 예제가 서브커맨드
뒤에 두고 있었다. 그대로 실행하면 "No such option: -f"로 exit 1이다.
이 문자열은 --help와 `kiwoom describe`가 에이전트에게 그대로 넘기는
값이라 틀린 채로 두면 에이전트가 따라 쓴다. 같은 부류를 order.py에서
30d6c36·da7a751로 이미 고쳤고 남은 3건이다.

검증: 교정형 5건 전부 exit 0, 종전형(`describe --paths -f json`)은
exit 1임을 네거티브 컨트롤로 확인했다.

워크북은 b29e4f2(v2.12.0) 이후 낡아 재생성했다. 셀 90줄 변경 —
`--exchange` [KRX,NXT,all] 기본:all 14→44곳, sector 위치인자 9곳이
SECTOR_CODE로, config prune-ledger 행 추가(232→233).
워크북은 옵션 선언만 기록하므로 본문 룩업에는 구조적으로 눈이 멀다.
실제로 `stock search --market`의 choices는 etn 동작 수정 전후가 같고
`qty_ok`는 워크북에 0회 등장한다. 커버리지 근거로 쓰지 말 것.
normalize_ws_values의 "9001"(종목코드)이 선행 영문자를 무조건 벗겼다.
국내 6자리 코드에서는 우연히 맞지만 그 외에는 값이 깨진다:
NVDA -> VDA, TSLA -> SLA, F -> "", M04020000 -> 04020000.

REST 경로(normalize_record의 stk_cd)는 이미 strip_kr_market_prefix로
고쳐져 있었고("영문 1자 + 숫자 6자리" 모양에만 적용) WS 경로만 옛 패턴이
남아 있었다. 같은 헬퍼를 쓰게 해 접두사 규칙을 단일 소스로 만든다.

두 경로가 같은 입력에 같은 symbol을 내는지 단언하는 테스트를 함께 넣어
다시 갈라지지 않게 한다. 헬퍼의 "무조건 스트립 금지" 주석이 9001을 미해결
버그로 가리키고 있던 것도 정정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
D1이 REG 실패 응답(return_code != 0)은 exit 2로 잡게 만들었지만, ack가
아예 오지 않는 경우는 이월돼 있었다. LOGIN 성공 -> REG 전송 -> 서버가
답 없이 종료하면 수신 루프가 그냥 break해 exit 0이 나갔다. json 모드는
envelope조차 없이 빈 stdout으로 끝나 에이전트에게는 "성공, 이벤트 0건"과
구별되지 않았다 — 구독은 성사된 적이 없는데도.

등록 ack 수신 여부를 추적해, 루프가 어떤 이유로 끝나든(소켓 정상 종료,
--duration/--until 만료, PING 에코 중 종료) ack가 없었으면 exit 2 +
UPSTREAM_ERROR(retryable)로 끝낸다. LOGIN 자리가 이미 같은 상황을
exit 3으로 다루는 것과 대칭이고, 종료 코드는 등록 실패 갈래와 맞춘다.

--duration 만료도 예외로 두지 않는다. 등록이 안 된 채 마감에 도달했다면
스트림은 시작조차 하지 않은 것이다.

BREAKING CHANGE: 등록 ack 없이 끝나던 실행이 exit 0 -> 2. $?로 분기하는
스크립트는 값이 달라진다. ack를 받은 뒤의 정상 종료는 그대로 0이다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dashboard가 ka10030에 mang_stk_incls="1"(exclude-managed)을 하드코딩해
`market rank volume` 기본값 "0"(include-managed)과 갈려 있었다. 두 명령의
body는 이 필드 하나를 빼면 완전히 동일해서, 같은 "당일 거래량 상위"가
서로 다른 목록으로 나왔다.

관리종목은 상장폐지·자본잠식·감사의견 이슈로 거래량이 튀는 날 상위권에
들어오는 종목이라, dashboard 쪽에서 조용히 빠지는 영향이 작지 않다.

b4025f4(stex_tp)와 같은 처리: 리터럴 대신 STOCK_CONDITION 상수를 참조해
market 쪽 기본값이 움직여도 다시 갈리지 않게 한다.

D9가 이 필드를 boolean으로 기록했으나 ka10030에서는 15개 값짜리 종목조건
코드북(STOCK_CONDITION)이다. _constants.py의 해당 주석이 ka10032의 진짜
boolean(MANAGED_STOCK_INCLUDE)과 혼동하지 말라고 이미 경고하고 있다.

테스트는 예외 키를 두지 않고 dash_body == rank_body 전체 동일로 강화했다.

BREAKING CHANGE: dashboard의 거래량 상위 목록에 관리종목이 포함된다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`parametrize(list(ETF_RETURNS_PERIOD.items()))`는 기대값을 검증 대상
상수에서 가져와 자기모순이 없다 — 상수를 어떻게 바꿔도 통과한다
(CLAUDE.md가 경고하는 패턴).

구멍이 실재했음을 실측으로 확인했다. 기본값(week)은 body 테스트가 우연히
지키고 있었지만 나머지 세 값은 무방비였다. 전체 1988개 스위트 기준:
  - year "3" -> "9"            : 1988 passed (미적발)
  - month <-> six-months 맞바꿈 : 1988 passed (미적발)
맞바꾸기가 특히 위험하다. 두 값 다 스펙상 유효한 코드라 API가 200에 정상
응답을 준다 — `--period month`가 6개월 수익률을 돌려줘도 어디에도 오류가
남지 않는다.

리터럴 고정 후 같은 변이 두 개가 각각 1건·2건으로 적발된다.
import가 불필요해진 것 자체가 상수 의존이 끊겼다는 증거다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`parametrize(list(MAP.items()))`는 기대값을 검증 대상 상수에서 가져온다.
상수를 어떻게 바꿔도 자기모순이 없어 항상 통과하므로 아무것도 막지 못한다
(CLAUDE.md 경고 패턴). 23개 상수 29곳을 전수 조사했다.

*** 스윕 방법론이 결론을 뒤집었다. 재현 시 주의할 것. ***
각 상수의 **앞 두 항목**을 맞바꾸는 변이로 돌리면 24/24 CAUGHT, 구멍 0건이
나온다. 이 리포의 코드북은 첫 키가 대개 CLI 기본값(all/kospi/week/limit)이고
기본값은 "default body unchanged" 테스트가 따로 고정하고 있어서, 그 변이는
자기참조 구멍이 아니라 기본값 커버리지를 측정한다. **뒤 두 항목**(비기본값)을
맞바꾸면 같은 스위트에서 구멍 7건이 나온다. _constants.py 밖에 있어 스윕이
건너뛴 ORDER_TYPES까지 별도 측정해 총 8건.

값 맞바꿈을 변이로 쓴 이유: 두 키 모두 스펙상 유효한 코드로 남으므로 서버가
200에 정상 응답을 준다. 오류가 어디에도 남지 않는 형태라 가장 잡기 어렵다.
HumanChoice가 받는 값 집합도 그대로여서 key→value 고정만 분리해 측정된다.

고정한 8건 (변이 → 전체 1988 스위트 green이던 것):
  ORDER_TYPES            ioc↔fok, mid-ioc↔mid-fok  (체결 계약이 다르다)
  ETF_ALL_TAXABLE        taxable↔tax-free          (과세/비과세 역전)
  ETF_ALL_NAV            nav-gt-close↔nav-lt-close (NAV 상회/하회 역전)
  ELW_RIGHT_TYPE_1DIGIT  early-call↔early-put      (콜/풋 역전)
  ETF_ALL_TAX_TYPE       foreign↔foreign-tax-free
  SECTOR_CODES_MARKET    kospi100↔krx100           (3/5/6 결번 매핑)
  ELW_SEARCH_SORT        amount↔days-left
  THEME_LOOKUP_SORT      change-top↔change-bottom  (정렬 방향 역전)

고정 후 같은 변이 9개가 전부 적발되고, 각각 맞바꾼 키에 해당하는 파라미터
2건만 정확히 실패한다(부수 피해 없음). 나머지 17개 상수는 비기본값 변이에서
원래 CAUGHT이며 이번 변경 후에도 그대로다.

import가 8개 불필요해진 것 자체가 상수 의존이 끊겼다는 증거다.

주의: 고친 상수는 더 이상 `list(X.items())` 형태가 아니라 스윕의 탐색
정규식에 걸리지 않는다. 수정 후 스윕을 다시 돌리면 그 상수들은 목록에서
조용히 빠진 채 "구멍 0건"이 나온다 — 검증은 반드시 문제의 맞바꿈을 직접
재적용하는 방식이어야 한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v2.13.0은 아직 태그도 PyPI 배포도 되지 않았고 PR #31이 열려 있으므로,
별도 패치 릴리스를 내지 않고 같은 2.13.0 항목에 접어 넣는다. 버전 번호는
그대로다.

추가한 것:
- Breaking 2건 (stream 등록 ack 종료코드, dashboard 관리종목 포함)
- Fixed 3건 (등록 ack 미수신, WS 9001 심볼 훼손, dashboard/rank 목록 불일치)
- Internal 2건 (자기참조 parametrize 8건 고정, ka10030 body 전 필드 단언)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gejyn14
gejyn14 merged commit bd81e4b into main Jul 20, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant