Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ docs/

# Serena LSP cache
.serena/

# understand-anything knowledge graph (local analysis artifact, ~450KB)
.understand-anything/

# local tooling config, not part of the project
opencode.json
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,24 @@ exit 2로 하드 실패하지 않고) `checks.price_known`이 `false`가 되어
`data.top_volume`은 계좌와 달리 토큰 유무로 게이팅되지 않고 항상 시도되므로,
실패 시에는 (조회를 아예 안 하는 경우가 없어) 언제나 `null`로 나타납니다.

**v2.14.0부터 `meta.partial_failures`가 실패한 레그를 이름과 오류 코드로
알립니다.** 위의 exit 0 + `null` 계약은 그대로입니다 — 바뀐 것은 그 `null`을
해석할 수 있게 된 것뿐입니다. 한쪽만 실패하면:

```json
{"ok": true, "data": {"kr": {...}, "us": null},
"meta": {"partial_failures": {"us": "UPSTREAM_ERROR"}}}
```

`ok`는 여전히 `true`이고(불변식: `ok ≡ error === null`) exit code도 0입니다.
성공했거나 실패한 레그가 없으면 **키 자체가 없습니다**(`meta.fields_unmatched`와
같은 규약). 값은 `{레그이름: 오류코드}`이고 코드는 위 표와 같은 집합입니다.
레그 이름은 계좌 계열이 `kr`/`us`, `dashboard`가 `account`/`top_volume`입니다.

이것이 필요한 이유: `"us": null` 하나만으로는 **미국 계좌가 없는 것**과
**조회가 실패한 것**을 구분할 수 없었습니다. 국내 계좌만 쓰는 사용자는 미국
레그가 늘 실패하므로 이 구분이 특히 중요합니다.

## 주문 안전장치

| 플래그 | 효과 |
Expand Down
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,58 @@
# Changelog

## [2.14.0] - 2026-07-20

공개 배포를 앞두고 한 감사에서 나온 결함들입니다. 전부 한 가지 모양이었습니다:
**확인하지 않은 것을 확인했다고 보고한다.** 도메인을 해석하지 않고 보고했고,
폐기 응답을 읽지 않고 폐기했다고 했고, 실패한 조회를 실패라고 말하지
않았습니다. 위험한 쪽은 눈에 띄게 틀린 출력이 아니라 **그럴듯한 출력**이었기
때문에 지금까지 드러나지 않았습니다.

### Fixed

- **`config show` / `auth status`가 실제 접속 도메인과 다른 값을 보고했습니다.**
`KIWOOM_DOMAIN`이 설정되면 요청은 그 도메인으로 가는데, 두 명령은
`config.toml`의 값을 읽어 출력했습니다. `config show`로 "모의"를 확인하고
실거래 주문을 넣을 수 있었습니다. 이제 유효 도메인을 보고하며,
`auth status`의 `data.domain`과 `meta.env`는 항상 일치합니다.
- **미국 거래소 캐시가 `-p`로 고른 프로필의 도메인을 따르지 않았습니다.**
v2.13.0이 캐시를 도메인별로 나눴지만 `KIWOOM_DOMAIN` 축만 다뤘고, CLI
`--profile` 축은 놓쳤습니다. 도메인이 다른 프로필이 둘 이상 있고 `-p`로
전환하면, **모의투자에서 학습한 거래소가 실주문의 `stex_tp`로 나갈 수
있었습니다.** 근본 원인은 `config.resolve_profile()`이 자기 docstring이
선언한 우선순위(`--profile` 1순위)를 구현하지 않은 것이라, 호출부가 아니라
그 함수를 고쳤습니다. 캐시 세대를 `us_exchanges2-*`로 올려 오염 가능성이
있는 기존 파일은 읽지 않습니다(최악의 비용은 `usa10098` 재조회 1회).
- **`auth logout`이 폐기 응답을 확인하지 않고 성공을 보고했습니다.** HTTP
4xx/5xx와 `return_code` 8015/8016이 전부 성공으로 처리됐고, 그 뒤 로컬
토큰까지 지워 **서버에 살아 있는 토큰을 두 번 다시 폐기할 수 없게** 만들었습니다.

### Added

- `meta.partial_failures` — 통합 명령에서 한쪽 레그만 실패했을 때 그 이름과
오류 코드를 알립니다(`{"us": "UPSTREAM_ERROR"}`). `data.us: null`이 "미국
계좌가 없다"인지 "조회가 실패했다"인지 구분할 수 없던 문제를 해결합니다.
`ok`/exit code/`data` 모양은 그대로이므로 기존 소비자는 영향받지 않습니다.
실패한 레그가 없으면 키 자체가 없습니다.
- `auth logout --force` — 서버 폐기가 실패해도 로컬 토큰을 지웁니다(서버 도달
불가 시 탈출구). 이때 `revoked`는 `false`로 보고됩니다.
- `config profiles -f json`에 `domain_override` — `KIWOOM_DOMAIN`이 모든
프로필을 덮고 있으면 그 값을, 아니면 `null`. 목록의 `domain`은 설정값을
그대로 유지합니다(행마다 유효 도메인을 계산하면 전부 같은 값이 되어 설정
정보가 사라지기 때문).

### Breaking

- `config show` / `auth status`의 `data.domain`이 `KIWOOM_DOMAIN` 설정 시
다른 값을 냅니다. 이것이 수정의 요지입니다 — 종전 값은 틀린 값이었습니다.
- `auth logout`이 상단 폐기 실패 시 exit 2로 끝나고 로컬 토큰을 지우지
않습니다(종전에는 exit 0 + 삭제).
- `KiwoomClient.revoke_token()`이 `force` 매개변수를 받고 반환 dict에
`revoked` 키가 추가됩니다.
- 미국 거래소 캐시 파일명이 `us_exchanges-*.json` → `us_exchanges2-*.json`.
기존 파일은 마이그레이션하지 않고 읽지 않습니다.
- `config profiles -f json`의 각 행에 `domain_override` 키가 추가됩니다.

## [2.13.0] - 2026-07-20

2026-07-18 감사의 마지막 트랜치입니다. WebSocket 스트리밍의 실패 감지와 종료
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,18 @@ kiwoom auth login

# 3. 끝. 이후 모든 명령은 프롬프트 없이 동작
kiwoom stock info 005930

# 토큰 폐기
kiwoom auth logout
kiwoom auth logout --force # 서버 폐기가 실패해도 로컬 토큰은 지웁니다
```

`auth logout`은 서버 폐기 응답을 확인한 뒤에만 성공을 보고합니다. 폐기가
실패하면 exit 2로 끝나고 **로컬 토큰을 지우지 않습니다** — 지워버리면 서버에
살아 있는 토큰을 두 번 다시 폐기할 수 없기 때문입니다. 서버에 도달할 수 없어
로컬 정리가 막히는 상황에서만 `--force`를 쓰세요. 이때도 `revoked`는 `false`로
보고됩니다(확인하지 않은 것을 성공이라 하지 않습니다).

### 모의투자 vs 실거래

`config setup` 시 도메인을 선택합니다. 이후 변경:
Expand Down Expand Up @@ -220,7 +230,8 @@ kiwoom -p isa config set domain mock # 특정 프로필

# 프로필 전환 / 목록
kiwoom config use isa
kiwoom config profiles
kiwoom config profiles # -f json이면 domain_override로
# KIWOOM_DOMAIN 덮어쓰기를 함께 알립니다

# 특정 프로필로 사용
kiwoom -p isa account balance
Expand Down
2 changes: 1 addition & 1 deletion kiwoom_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Kiwoom Securities REST API CLI."""

__version__ = "2.13.0"
__version__ = "2.14.0"
31 changes: 27 additions & 4 deletions kiwoom_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ def __init__(self, domain: str | None = None, token: str | None = None, profile:
if profile is None:
ctx = click.get_current_context(silent=True)
if ctx and ctx.obj:
profile = ctx.obj.get("profile")
# 루트가 해석해 둔 값을 우선 읽는다. 없으면(루트 콜백을 거치지
# 않는 직접 사용) 원시 플래그로 폴백한다.
profile = ctx.obj.get("resolved_profile") or ctx.obj.get("profile")
self.profile = profile
self.domain = domain or config.get_domain(profile=profile)
self.token = token or auth.load_token(profile=profile)
Expand Down Expand Up @@ -218,11 +220,16 @@ def issue_token(self, appkey: str | None = None, secretkey: str | None = None) -
self.token = token
return token

def revoke_token(self) -> dict[str, Any]:
def revoke_token(self, force: bool = False) -> dict[str, Any]:
"""Revoke the current access token via au10002.

어느 토큰을 폐기했고 키체인 항목을 지웠는지를 돌려준다
(`{"token_source": "env"|"keychain", "keychain_token_deleted": bool}`).
(`{"revoked": bool, "token_source": "env"|"keychain",
"keychain_token_deleted": bool}`).

force=True는 상단 폐기가 실패해도 로컬 정리를 진행한다(서버 도달 불가로
영영 정리를 못 하는 상황의 탈출구). 이때도 revoked를 True로 만들지
않는다 — 확인하지 않은 것을 성공이라 보고하는 것이 애초의 결함이었다.

auth.load_token은 KIWOOM_TOKEN을 키체인보다 먼저 반환하므로, env 토큰을
폐기해 놓고 키체인의 {profile}:token을 지우면 **폐기한 적 없는 다른 살아
Expand All @@ -236,11 +243,26 @@ def revoke_token(self) -> dict[str, Any]:
if not token:
raise click.ClickException("No token to revoke.")

self._http.post(
resp = self._http.post(
"/oauth2/revoke",
headers={"content-type": CONTENT_TYPE, "api-id": "au10002"},
json={"appkey": ak, "secretkey": sk, "token": token},
)
revoked = True
try:
# issue_token과 같은 확인. 종전에는 응답을 이름에 묶지도 않아
# HTTP 4xx/5xx와 return_code 8015/8016이 전부 성공으로 보고됐고,
# 그 뒤 로컬 토큰까지 지워 재폐기를 불가능하게 만들었다.
resp.raise_for_status()
data = resp.json()
rc = data.get("return_code")
if rc is not None and str(rc) != "0":
raise KiwoomAPIError(rc, data.get("return_msg", "토큰 폐기 실패"))
except (httpx.HTTPError, KiwoomAPIError):
if not force:
raise
revoked = False

from_env = bool(env_token) and token == env_token
# 키체인이 방금 폐기한 토큰을 들고 있으면 출처와 무관하게 지운다
# (죽은 토큰을 남기면 auth status가 유효한 것처럼 보고한다).
Expand All @@ -250,6 +272,7 @@ def revoke_token(self) -> dict[str, Any]:
auth.delete_token(profile=self.profile)
self.token = None
return {
"revoked": revoked,
"token_source": "env" if from_env else "keychain",
"keychain_token_deleted": bool(delete_keychain and keychain_token is not None),
}
17 changes: 15 additions & 2 deletions kiwoom_cli/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,24 +86,32 @@ def _unified_structured(market: str, kr_fetch, us_fetch) -> bool:
fmt = _get_format()
if fmt != "json":
return False
from .. import envelope

kr_data = us_data = None
# 실패한 레그를 코드와 함께 모은다 — null이 '그 계좌가 없다'인지
# '조회가 깨졌다'인지 구별할 방법이 이것뿐이다. 코드는 지어내지 않고
# envelope.classify가 쓰는 것과 같은 분류를 쓴다.
failures: dict[str, str] = {}
if market in ("all", "kr"):
try:
kr_data = kr_fetch()
except KiwoomAPIError as e:
if market == "kr":
raise
failures["kr"] = envelope.classify(upstream_code=e.code)[0]
err_console.print(f"[dim]국내 조회 실패: {e}[/]")
if market in ("all", "us"):
try:
us_data = us_fetch()
except KiwoomAPIError as e:
if market == "us":
raise
failures["us"] = envelope.classify(upstream_code=e.code)[0]
err_console.print(f"[dim]미국 조회 실패: {e}[/]")
if market == "all" and kr_data is None and us_data is None:
fail_api("국내/미국 조회가 모두 실패했습니다.")
_output_json({"kr": kr_data, "us": us_data})
_output_json({"kr": kr_data, "us": us_data}, partial_failures=failures or None)
return True


Expand Down Expand Up @@ -137,28 +145,33 @@ def account_list():
@click.option("--delist", "qry_tp", default="all", type=HumanChoice(DELIST_QRY), help="상장폐지조회구분 (all=전체, exclude=제외)")
def balance(market: str, dmst_stex_tp: str, qry_tp: str):
"""계좌 평가현황 — 국내+미국 통합. (kt00004 + ust21070)"""
from .. import envelope

kr_data = us_data = None
failures: dict[str, str] = {}
with KiwoomClient() as c:
if market in ("all", "kr"):
try:
kr_data, _ = c.request("kt00004", {"qry_tp": qry_tp, "dmst_stex_tp": dmst_stex_tp})
except KiwoomAPIError as e:
if market == "kr":
raise
failures["kr"] = envelope.classify(upstream_code=e.code)[0]
err_console.print(f"[dim]국내 잔고 조회 실패: {e}[/]")
if market in ("all", "us"):
try:
us_data = us_account_ops.fetch_balance(c)
except KiwoomAPIError as e:
if market == "us":
raise
failures["us"] = envelope.classify(upstream_code=e.code)[0]
err_console.print(f"[dim]미국 잔고 조회 실패 (미국주식 미개설 계좌일 수 있음): {e}[/]")
if market == "all" and kr_data is None and us_data is None:
fail_api("국내/미국 잔고 조회가 모두 실패했습니다.")
if market == "kr":
print_account_eval(kr_data or {})
else:
print_unified_balance(kr_data, us_data)
print_unified_balance(kr_data, us_data, partial_failures=failures or None)


@account.command("deposit")
Expand Down
9 changes: 8 additions & 1 deletion kiwoom_cli/commands/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from rich.table import Table
from rich.text import Text

from .. import envelope
from ..client import KiwoomClient, KiwoomAPIError
from ..formatters import (
_calc_eval_pl,
Expand Down Expand Up @@ -80,6 +81,9 @@ def dashboard():
movers_items: list[dict[str, Any]] = []
acct_failed = False
movers_failed = False
# 문서화된 3상태를 유지한다: 키 없음 = 시도조차 안 함(토큰 없음),
# null = 시도했고 실패. partial_failures에는 후자만 들어간다.
failures: dict[str, str] = {}

with KiwoomClient() as c:
# Account balance -- skip gracefully when not logged in
Expand All @@ -89,6 +93,7 @@ def dashboard():
except KiwoomAPIError as e:
acct_data = None
acct_failed = True
failures["account"] = envelope.classify(upstream_code=e.code)[0]
err_console.print(f"[dim]계좌 조회 실패: {e}[/]")
else:
acct_data = None
Expand Down Expand Up @@ -127,6 +132,7 @@ def dashboard():
except KiwoomAPIError as e:
movers_data = None
movers_failed = True
failures["top_volume"] = envelope.classify(upstream_code=e.code)[0]
err_console.print(f"[dim]거래량 상위 조회 실패: {e}[/]")

if acct_failed and movers_failed:
Expand All @@ -145,7 +151,8 @@ def dashboard():
combined["top_volume"] = movers_items
elif movers_data:
combined["top_volume"] = movers_data
print_generic_table(combined, title="대시보드")
print_generic_table(combined, title="대시보드",
partial_failures=failures or None)
return

# ── Rich table output ─────────────────────────────────
Expand Down
17 changes: 12 additions & 5 deletions kiwoom_cli/commands/us/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from ._constants import KR_EXCHANGE, US_EXCHANGE

# 거래소 캐시는 도메인(prod/mock)별로 파일이 갈린다 — 아래 _cache_file 주석 참고.
_CACHE_PREFIX = "us_exchanges"
# 세대 표식 "2": v2.13.0의 도메인 분리가 -p 축을 놓쳐, prod에서 학습한 거래소가
# us_exchanges-mock.json에 기록됐을 수 있다. 그 파일들은 마이그레이션하지 않고
# 읽지 않는다 (v2.12 평문 형식을 폐기한 선례와 동일). 최악의 비용은 usa10098
# 재조회 1회다.
_CACHE_PREFIX = "us_exchanges2"
_CACHE_TTL_SEC = 24 * 60 * 60


Expand All @@ -33,15 +37,18 @@ class UsExchangeError(Exception):


def _cache_file():
"""도메인별 캐시 파일 경로 (us_exchanges-prod.json / us_exchanges-mock.json).
"""도메인별 캐시 파일 경로 (us_exchanges2-prod.json / us_exchanges2-mock.json).

파일이 하나뿐이면 모의투자에서 학습한 거래소가 실거래 주문의 stex_tp로
그대로 나간다 — 잘못된 거래소로 실주문이 나가는 경로다.

프로필로는 나누지 않는다. 'NVDA가 나스닥 상장'은 계좌가 아니라 시장의
사실이라, 같은 도메인의 프로필끼리는 공유해도 틀릴 여지가 없다. 반면
도메인은 응답을 주는 상단 서버 자체가 다르다. 프로필별 도메인 차이는
get_domain_key(profile 해석 포함)가 이미 흡수한다.
사실이라, 같은 도메인의 프로필끼리는 공유해도 틀릴 여지가 없다. 도메인은
응답을 주는 서버 자체가 다르므로 반드시 나눈다.

get_domain_key()를 인자 없이 호출하지만, resolve_profile이 Click 컨텍스트의
--profile을 읽으므로 -p로 고른 프로필의 도메인이 반영된다. v2.13.0까지는
그렇지 않아 모의 학습값이 실주문에 실릴 수 있었다.
"""
return config.CACHE_DIR / f"{_CACHE_PREFIX}-{config.get_domain_key()}.json"

Expand Down
18 changes: 17 additions & 1 deletion kiwoom_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,26 @@ def save_config(cfg: dict) -> None:
def resolve_profile(profile: str | None = None) -> str:
"""Resolve the active profile name.

Priority: explicit arg > KIWOOM_PROFILE env > general.default_profile > "default"
Priority: explicit arg > CLI --profile (Click ctx) > KIWOOM_PROFILE env
> general.default_profile > "default"

**CLI 플래그를 여기서 읽는 이유.** 종전에는 이 함수가 뒤의 셋만 구현하고
`--profile`은 호출자가 넘겨줄 때만 반영됐다. 그래서 ctx.obj["profile"]를
읽는 자리가 리포 전역 10곳으로 흩어졌고, 넘기는 것을 잊은 호출부
(us/detect.py)가 조용히 다른 프로필의 도메인으로 캐시를 갈랐다 — 모의에서
학습한 거래소가 실주문에 실릴 수 있었다. 우선순위 자체는 종전 docstring이
이미 선언하던 것이라 계약 변경이 아니라 계약 이행이다.

Click 컨텍스트가 없어도(테스트·라이브러리 사용) 동작해야 하므로
silent=True로 조회하고 obj가 None/빈 dict인 경우를 모두 견딘다.
"""
if profile:
return profile
ctx = click.get_current_context(silent=True)
if ctx is not None and isinstance(ctx.obj, dict):
cli_profile = ctx.obj.get("profile")
if cli_profile:
return cli_profile
env = os.environ.get("KIWOOM_PROFILE")
if env:
return env
Expand Down
Loading
Loading