#!/usr/bin/env python3
"""키움증권 REST API 클라이언트 — READ-ONLY.

⚠️ 매매 절대 원칙 (변경 금지)
이 모듈은 조회 전용입니다. 매수·매도·정정·취소 등 주문성 함수를 절대 추가하지 마십시오.
관련 가드: agent SOUL.md "매매 절대 원칙" 섹션, MEMORY: kiwoom_read_only.md.
워치리스트 도달 등 트리거가 발생해도 동작은 텔레그램 알림(별도 모듈)까지만입니다.

자격증명: /Users/snowoyh/.openclaw/credentials/kiwoom.json
계좌 라벨: '일반', 'ISA', '가희_일반', '가희_ISA' — 각 계좌마다 별도 appkey/secretkey 사용.
신규 계좌 추가 시 `<이름>_<상품>` 컨벤션을 유지해야 owner 그룹 prefix 분기가 자동 동작 (리포트의 본인/가희 블록).
"""
from __future__ import annotations

import json
import http.client
import socket
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone, timedelta
from pathlib import Path

KST = timezone(timedelta(hours=9))

CREDENTIALS = Path('/Users/snowoyh/.openclaw/credentials/kiwoom.json')
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
STATE_DIR = WORKSPACE / 'state'
TOKEN_CACHE_DIR = STATE_DIR / 'kiwoom_tokens'

ENDPOINT_TOKEN = '/oauth2/token'
ENDPOINT_ACNT = '/api/dostk/acnt'
ENDPOINT_STKINFO = '/api/dostk/stkinfo'
ENDPOINT_MRKCOND = '/api/dostk/mrkcond'  # 시세(market condition) — ka10004 호가 등
ENDPOINT_CHART = '/api/dostk/chart'      # 차트 — ka10081 일봉
ENDPOINT_RKINFO = '/api/dostk/rkinfo'    # 순위정보 — ka10023 거래량급증, ka90009 외인기관매매상위
TR_DEPOSIT = 'kt00001'       # 예수금상세현황요청
TR_ACNT_EVAL = 'kt00004'     # 계좌평가현황요청 (acnt_nm·brch_nm 포함)
TR_TRUST_TRADE = 'kt00015'   # 위탁종합거래내역요청 (tp='1' 입출금 — 당일 추가입금/인출 추출용)
TR_ACNT_RETURN = 'kt00016'   # 일별계좌수익률상세현황요청 (기간내총입금·총출금 — 투자원금 산출용)
TR_POSITIONS = 'kt00018'     # 계좌평가잔고내역요청 (종목별 상세, 수수료·세금 포함)
TR_TRADE_DIARY = 'ka10170'   # 당일매매일지요청 (round-trip·풀매도 포함, 잔고에 없는 거래도 잡힘)
TR_ORDER_EXEC = 'kt00007'    # 계좌별주문체결내역상세 (주문번호·시각·체결가·체결수량 단위, REST/영웅문 출처 구분)
TR_OPEN_ORDERS = 'ka10075'   # 미체결요청 (활성 미체결만 — 정정/취소 대상 추출용)
TR_STOCK_INFO = 'ka10001'    # 주식기본정보요청 (현재가·등락률) — 단일 종목
TR_WATCHLIST_INFO = 'ka10095'  # 관심종목정보요청 — 다종목 한번에 (`|` 구분, 19종목 0.05s)
TR_STOCK_LIST = 'ka10099'    # 종목정보 리스트 (종목코드·명 전체)
TR_DAILY_CHART = 'ka10081'   # 주식일봉차트조회 (수정주가 포함, 600일치까지)
TR_MINUTE_CHART = 'ka10080'  # 주식분봉차트조회 (1/3/5/10/15/30/45/60분, 한 호출 ~900봉)
TR_INVESTOR_FLOW = 'ka10059' # 종목별투자자기관별 (외국인·기관 일별 매매)

STOCK_CODES_CACHE = STATE_DIR / 'stock_codes.json'
_HTTP_TRANSIENT_RETRIES = 2
_HTTP_TRANSIENT_BACKOFF_SEC = 0.8


def load_credentials() -> dict:
    if not CREDENTIALS.exists():
        raise SystemExit(
            f'자격증명 파일 없음: {CREDENTIALS}\n'
            f'kiwoom.json.example 복사해서 채우세요.'
        )
    return json.loads(CREDENTIALS.read_text())


def save_credentials(creds: dict) -> None:
    CREDENTIALS.write_text(json.dumps(creds, ensure_ascii=False, indent=2))


def get_account_creds(label: str) -> dict:
    creds = load_credentials()
    accounts = creds.get('accounts', {})
    if label not in accounts:
        raise KeyError(f'알 수 없는 계좌 라벨: {label}. 등록된 라벨: {list(accounts)}')
    return accounts[label]


def base_url() -> str:
    return load_credentials().get('base_url', 'https://api.kiwoom.com').rstrip('/')


def _http_post_json(url: str, body: dict, headers: dict | None = None) -> dict:
    return _http_post_full(url, body, headers)[0]


def _http_post_full(url: str, body: dict, headers: dict | None = None) -> tuple[dict, dict]:
    """POST + return (json body, response headers dict)."""
    data = json.dumps(body).encode('utf-8')
    h = {'Content-Type': 'application/json;charset=UTF-8'}
    if headers:
        h.update(headers)
    req = urllib.request.Request(url, data=data, method='POST', headers=h)
    transient_errors = (
        ConnectionResetError,
        TimeoutError,
        socket.timeout,
        urllib.error.URLError,
        http.client.RemoteDisconnected,
        http.client.IncompleteRead,
    )
    for attempt in range(_HTTP_TRANSIENT_RETRIES + 1):
        try:
            with urllib.request.urlopen(req, timeout=15) as r:
                resp_headers = {k.lower(): v for k, v in r.headers.items()}
                return json.loads(r.read().decode('utf-8', 'ignore')), resp_headers
        except urllib.error.HTTPError as e:
            body_text = e.read().decode('utf-8', 'ignore')
            raise RuntimeError(f'HTTP {e.code} {url}: {body_text}') from e
        except transient_errors:
            if attempt >= _HTTP_TRANSIENT_RETRIES:
                raise
            time.sleep(_HTTP_TRANSIENT_BACKOFF_SEC * (attempt + 1))

    raise RuntimeError(f'HTTP POST failed unexpectedly: {url}')


def _token_cache_path(label: str) -> Path:
    return TOKEN_CACHE_DIR / f'{label}.json'


_TOKEN_LOCKS: dict[str, threading.Lock] = {}
_TOKEN_LOCKS_GUARD = threading.Lock()
# force=True 발급 직후 1초 이내에 같은 label로 다시 force 호출이 들어오면 직전 토큰을 reuse.
# behive_web 페이지 로드 시 4계좌 × 다중 TR 병렬 호출 → 한 호출이 8005 받고 force 재발급한 직후
# 다른 thread가 또 force 재발급하면서 키움이 새 토큰 invalidate 시키는 race를 차단.
_FORCE_REUSE_WINDOW_SEC = 1.0


def _label_lock(label: str) -> threading.Lock:
    with _TOKEN_LOCKS_GUARD:
        lock = _TOKEN_LOCKS.get(label)
        if lock is None:
            lock = threading.Lock()
            _TOKEN_LOCKS[label] = lock
        return lock


def issue_token(label: str, force: bool = False) -> str:
    """OAuth 토큰 발급 + 캐싱. 계좌(라벨) 단위로 분리 저장."""
    cache_path = _token_cache_path(label)
    if not force and cache_path.exists():
        cache = json.loads(cache_path.read_text())
        if cache.get('expires_at', 0) - 60 > time.time():
            return cache['access_token']

    with _label_lock(label):
        if cache_path.exists():
            cache = json.loads(cache_path.read_text())
            valid = cache.get('expires_at', 0) - 60 > time.time()
            if valid:
                if not force:
                    return cache['access_token']
                # force=True여도, 직전 N초 이내 다른 thread가 이미 새로 발급했다면 그걸 재사용.
                if time.time() - cache.get('issued_at_ts', 0) < _FORCE_REUSE_WINDOW_SEC:
                    return cache['access_token']

        acc = get_account_creds(label)
        url = base_url() + ENDPOINT_TOKEN
        body = {
            'grant_type': 'client_credentials',
            'appkey': acc['appkey'],
            'secretkey': acc['secretkey'],
        }
        resp = _http_post_json(url, body)
        token = resp.get('token') or resp.get('access_token')
        expires_dt = resp.get('expires_dt')
        expires_in = resp.get('expires_in')
        if not token:
            raise RuntimeError(f'토큰 발급 실패 ({label}): {resp}')
        if expires_dt:
            try:
                dt = datetime.strptime(expires_dt, '%Y%m%d%H%M%S').replace(tzinfo=KST)
                expires_at = dt.timestamp()
            except ValueError:
                expires_at = time.time() + 1800
        elif expires_in:
            expires_at = time.time() + int(expires_in)
        else:
            expires_at = time.time() + 1800
        now_ts = time.time()
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        cache_path.write_text(json.dumps({
            'label': label,
            'access_token': token,
            'expires_at': expires_at,
            'issued_at': datetime.now(KST).isoformat(),
            'issued_at_ts': now_ts,
            'raw': resp,
        }, ensure_ascii=False, indent=2))
        return token


def auth_headers(label: str, tr_id: str | None = None, cont_yn: str = 'N', next_key: str = '') -> dict:
    """인증·TR 헤더 생성. tr_id는 키움 API ID (예: 'kt00018')."""
    h = {
        'Authorization': f'Bearer {issue_token(label)}',
        'Content-Type': 'application/json;charset=UTF-8',
        'cont-yn': cont_yn,
        'next-key': next_key,
    }
    if tr_id:
        h['api-id'] = tr_id
    return h


def list_accounts() -> list[dict]:
    """credentials에 등록된 계좌 목록 (라벨, 계좌번호) 반환."""
    creds = load_credentials()
    return [
        {'label': label, 'account_no': info.get('account_no', '')}
        for label, info in creds.get('accounts', {}).items()
    ]


def _to_int(s) -> int:
    """키움 응답의 zero-padded string → int. 빈 값은 0."""
    if s is None or s == '':
        return 0
    s = str(s).strip()
    neg = s.startswith('-')
    if neg:
        s = s[1:]
    s = s.lstrip('0') or '0'
    return -int(s) if neg else int(s)


def _to_float(s) -> float:
    if s is None or s == '':
        return 0.0
    return float(str(s).strip())


def _clean_code(s: str) -> str:
    """'A005930' → '005930'. 'A' 접두 제거."""
    s = (s or '').strip()
    return s[1:] if s.startswith('A') else s


def _call(label: str, tr_id: str, body: dict, endpoint: str = ENDPOINT_ACNT) -> dict:
    url = base_url() + endpoint
    resp = _http_post_json(url, body, auth_headers(label, tr_id=tr_id))
    if resp.get('return_code', 0) == 0:
        return resp

    msg = str(resp.get('return_msg') or '')
    if '8005' in msg or 'Token이 유효하지 않습니다' in msg:
        issue_token(label, force=True)
        resp = _http_post_json(url, body, auth_headers(label, tr_id=tr_id))
        if resp.get('return_code', 0) == 0:
            return resp

    raise RuntimeError(f'{tr_id} [{label}] 실패: {resp.get("return_msg")} (full={resp})')


def _call_paginated(label: str, tr_id: str, body: dict, list_field: str,
                     endpoint: str = ENDPOINT_ACNT, max_pages: int = 50) -> dict:
    """list 응답이 페이지로 나뉘어 올 수 있는 TR 호출 (cont-yn/next-key 헤더 페이징).

    명세상 모든 list 반환 TR 응답에 `cont-yn`/`next-key` 헤더 정의됨 — 데이터 많으면 'Y'로
    떨어져 다음 페이지 요청 필요. _call은 1페이지만 받으니 종목·거래 많은 계좌에서 누락 위험.

    동작:
    - 첫 페이지를 받아 base 응답으로 두고, 후속 페이지의 `list_field` 만 누적 합치기.
    - 헤더 외 본문 필드(`tot_*` 합계 등)는 첫 페이지 값 유지 — 키움이 합계를 마지막 페이지에만
      넣을 가능성도 있어 위험하지만, 현재 명세 example 보면 합계는 매 페이지 동일하게 옴.
    - max_pages 안전 cap (무한루프 방지).
    - 8005 토큰 만료는 첫 페이지에서만 자동 재시도 (이후 페이지는 토큰 안정 가정).
    """
    url = base_url() + endpoint
    cont_yn = 'N'
    next_key = ''
    base: dict | None = None
    for page in range(max_pages):
        headers = auth_headers(label, tr_id=tr_id, cont_yn=cont_yn, next_key=next_key)
        resp, resp_headers = _http_post_full(url, body, headers)
        if resp.get('return_code', 0) != 0:
            msg = str(resp.get('return_msg') or '')
            if page == 0 and ('8005' in msg or 'Token이 유효하지 않습니다' in msg):
                issue_token(label, force=True)
                headers = auth_headers(label, tr_id=tr_id, cont_yn=cont_yn, next_key=next_key)
                resp, resp_headers = _http_post_full(url, body, headers)
                if resp.get('return_code', 0) != 0:
                    raise RuntimeError(f'{tr_id} [{label}] 실패: {resp.get("return_msg")}')
            else:
                raise RuntimeError(f'{tr_id} [{label}] page={page} 실패: {resp.get("return_msg")}')
        if base is None:
            base = resp
            base.setdefault(list_field, [])
        else:
            base[list_field].extend(resp.get(list_field) or [])
        cy = (resp_headers.get('cont-yn') or '').strip().upper()
        nk = (resp_headers.get('next-key') or '').strip()
        if cy == 'Y' and nk:
            cont_yn = 'Y'
            next_key = nk
            time.sleep(0.15)
        else:
            return base
    raise RuntimeError(f'{tr_id} [{label}] 페이지 한도({max_pages}) 초과')


# ---- 조회 함수 ----

def get_balance(account_label: str) -> dict:
    """예수금·주문가능금액 등 (kt00001)."""
    resp = _call(account_label, TR_DEPOSIT, {'qry_tp': '3'})
    return {
        'label': account_label,
        'entr': _to_int(resp.get('entr')),                   # 예수금
        'd1_entra': _to_int(resp.get('d1_entra')),           # D+1 예수금
        'd2_entra': _to_int(resp.get('d2_entra')),           # D+2 예수금
        'ord_alow_amt': _to_int(resp.get('ord_alow_amt')),   # 주문가능금액
        'pymn_alow_amt': _to_int(resp.get('pymn_alow_amt')), # 출금가능금액
        'repl_amt': _to_int(resp.get('repl_amt')),           # 대용금
        'raw': resp,
    }


def get_account_summary(account_label: str) -> dict:
    """계좌평가현황 (kt00004) — 고객명·지점·추정총자산 포함."""
    resp = _call(account_label, TR_ACNT_EVAL, {'qry_tp': '0', 'dmst_stex_tp': 'KRX'})
    return {
        'label': account_label,
        'acnt_nm': resp.get('acnt_nm', ''),
        'brch_nm': resp.get('brch_nm', ''),
        'entr': _to_int(resp.get('entr')),
        'd2_entra': _to_int(resp.get('d2_entra')),
        'tot_est_amt': _to_int(resp.get('tot_est_amt')),         # 추정총자산
        'aset_evlt_amt': _to_int(resp.get('aset_evlt_amt')),     # 자산평가액
        'tot_pur_amt': _to_int(resp.get('tot_pur_amt')),         # 총매입금액
        'prsm_dpst_aset_amt': _to_int(resp.get('prsm_dpst_aset_amt')),  # 추정예탁자산
        'raw': resp,
    }


def get_positions(account_label: str, exchange: str = 'KRX') -> list[dict]:
    """보유종목 목록 (kt00018).

    exchange='KRX': 한국거래소 종가 기준 (잔고 평가 표준).
    exchange='NXT': NXT 우선 가격 (NXT 운영시간 8:00~20:00에 살아 움직임). NXT 미상장 종목은 KRX fallback.
    `cur_prc`·`evlt_amt`·`evltv_prft`·`prft_rt`는 키움 서버에서 해당 거래소 가격으로 재계산되어 반환.
    """
    resp = _call_paginated(account_label, TR_POSITIONS,
                            {'qry_tp': '1', 'dmst_stex_tp': exchange},
                            list_field='acnt_evlt_remn_indv_tot')
    items = resp.get('acnt_evlt_remn_indv_tot', []) or []
    out: list[dict] = []
    for it in items:
        qty = _to_int(it.get('rmnd_qty'))
        if qty == 0:
            continue
        cur_price = _to_int(it.get('cur_prc'))
        pred_close = _to_int(it.get('pred_close_pric'))
        day_change = cur_price - pred_close if pred_close else 0
        day_change_pct = (day_change / pred_close * 100) if pred_close else 0.0
        out.append({
            'code': _clean_code(it.get('stk_cd', '')),
            'name': it.get('stk_nm', ''),
            'qty': qty,
            'trde_able_qty': _to_int(it.get('trde_able_qty')),
            'avg_price': _to_int(it.get('pur_pric')),        # 매입평단
            'cur_price': cur_price,
            'pred_close_pric': pred_close,                   # 전일종가
            'day_change': day_change,                        # 당일 등락 (원)
            'day_change_pct': day_change_pct,                # 당일 등락률 (%)
            'pur_amt': _to_int(it.get('pur_amt')),
            'evlt_amt': _to_int(it.get('evlt_amt')),
            'evltv_prft': _to_int(it.get('evltv_prft')),     # 평가손익
            'prft_rt': _to_float(it.get('prft_rt')),         # 수익률 %
            'poss_rt': _to_float(it.get('poss_rt')),         # 보유비중 %
            'tdy_buyq': _to_int(it.get('tdy_buyq')),
            'tdy_sellq': _to_int(it.get('tdy_sellq')),
        })
    out.sort(key=lambda x: x['evlt_amt'], reverse=True)
    return out


def get_positions_all(exchange: str = 'KRX', labels: list[str] | None = None) -> dict:
    """모든 계좌 종목 조회 — {label: [positions]} 반환. exchange는 get_positions와 동일.
    labels 지정 시 해당 라벨만 순회 (owner 단위 부분 갱신용 — behive_web 자동 갱신이 활용)."""
    accs = list_accounts()
    if labels is not None:
        wanted = set(labels)
        accs = [a for a in accs if a['label'] in wanted]
    return {a['label']: get_positions(a['label'], exchange=exchange) for a in accs}


def get_trade_journal(account_label: str, base_dt: str | None = None) -> list[dict]:
    """당일매매일지 (ka10170) — 잔고에 없는 round-trip·풀매도 거래까지 포함.

    base_dt: YYYYMMDD (None이면 KST 오늘).
    ottks_tp='2' (당일매도 전체 — 보유분 매도 포함). '1'은 round-trip만 잡혀 pl_amt=0이 됨 (2026-05-07 사고).
    ch_crd_tp='0'(현금/신용 전체) 고정.
    빈 응답은 stk_cd가 ''인 placeholder 1건이 옴 — 필터링.
    """
    if base_dt is None:
        base_dt = datetime.now(KST).strftime('%Y%m%d')
    body = {'base_dt': base_dt, 'ottks_tp': '2', 'ch_crd_tp': '0'}
    resp = _call_paginated(account_label, TR_TRADE_DIARY, body, list_field='tdy_trde_diary')
    items = resp.get('tdy_trde_diary') or []
    out: list[dict] = []
    for it in items:
        code = _clean_code(it.get('stk_cd', ''))
        name = (it.get('stk_nm') or '').strip()
        if not code or not name:
            continue  # 빈 placeholder
        out.append({
            'code': code,
            'name': name,
            'buy_qty': _to_int(it.get('buy_qty')),
            'buy_avg': _to_int(it.get('buy_avg_pric')),
            'buy_amt': _to_int(it.get('buy_amt')),
            'sell_qty': _to_int(it.get('sell_qty')),
            'sell_avg': _to_int(it.get('sel_avg_pric')),  # 키움 필드명 'sel_avg_pric' (오타 아님)
            'sell_amt': _to_int(it.get('sell_amt')),
            'pl_amt': _to_int(it.get('pl_amt')),          # 실현손익 (수수료·세금 차감)
            'cmsn_tax': _to_int(it.get('cmsn_alm_tax')),  # 수수료+세금
            'prft_rt': _to_float(str(it.get('prft_rt') or '0').lstrip('+')),
        })
    return out


def get_trade_journal_all(base_dt: str | None = None, labels: list[str] | None = None) -> dict:
    """모든 계좌 당일매매일지 — {label: [trades]} 반환.
    labels 지정 시 해당 라벨만 순회 (owner 단위 부분 갱신용)."""
    accs = list_accounts()
    if labels is not None:
        wanted = set(labels)
        accs = [a for a in accs if a['label'] in wanted]
    return {a['label']: get_trade_journal(a['label'], base_dt) for a in accs}


def get_cash_flow(account_label: str, base_dt: str | None = None,
                  strt_dt: str | None = None, end_dt: str | None = None) -> list[dict]:
    """입출금 내역 (kt00015 tp='1').

    base_dt: YYYYMMDD (None이면 KST 오늘). 시작/종료 동일 → 그날치만.
    strt_dt/end_dt: 둘 다 주면 기간 조회 (base_dt 무시).
      ⚠️ **조회기간 1년 제한** — 넘기면 `[2000](501058:조회기간은 1년 이내로만 가능합니다.)`.
      다년치는 연도별로 쪼개 호출한다 (investment_principal.py 참조).
    tp='1'(입출금)·gds_tp='0'(전체)로 매매·환전 등은 제외. KRW만 필터.

    ⚠️ tp='1' 입금에는 **배당금·예탁금이용료(이자)·수익분배금·공모주환불금·단주매각대금**이
    섞여 들어온다. 이건 '내가 넣은 돈'이 아니므로 투자원금 합산에 그대로 쓰면 과대계상된다.
    kt00016의 기간내총입금/총출금은 이걸 이미 제외한 순수 외부 이체액이다 (전 계좌·연도 실증).
    적요 기준 분류는 `investment_principal.is_principal_flow`.

    반환 행:
      - date:    'YYYYMMDD' (trde_dt)
      - time:    'HH:MM:SS' (proc_tm 앞 8자리)
      - io_tp:   'IN' | 'OUT'
      - amount:  거래금액 (원, 양수)
      - signed:  +amount (입금) / -amount (출금) — 합산으로 net cash flow 산출
      - rmrk:    적요명 (예: '이체입금(지급결제)', '배당금입금', '이체출금')
      - balance: 거래 후 예수금잔고 (entra_remn)
    """
    if strt_dt and end_dt:
        fr, to = strt_dt, end_dt
    else:
        if base_dt is None:
            base_dt = datetime.now(KST).strftime('%Y%m%d')
        fr = to = base_dt
    body = {
        'strt_dt': fr,
        'end_dt': to,
        'tp': '1',
        'stk_cd': '',
        'crnc_cd': '',
        'gds_tp': '0',
        'frgn_stex_code': '',
        'dmst_stex_tp': '%',
    }
    resp = _call_paginated(account_label, TR_TRUST_TRADE, body,
                            list_field='trst_ovrl_trde_prps_array')
    items = resp.get('trst_ovrl_trde_prps_array') or []
    out: list[dict] = []
    for it in items:
        crnc = (it.get('crnc_cd') or '').strip()
        if crnc and crnc != 'KRW':
            continue
        io_nm = (it.get('io_tp_nm') or '').strip()
        amount = _to_int(it.get('trde_amt'))
        if amount == 0:
            continue
        # tp='1' 필터에도 io_tp_nm은 한글 라벨로 떨어진다. 입금/출금 키워드로 부호 결정.
        if '입금' in io_nm:
            io_tp, signed = 'IN', amount
        elif '출금' in io_nm:
            io_tp, signed = 'OUT', -amount
        else:
            continue
        proc_tm = (it.get('proc_tm') or '').strip()
        out.append({
            'date': (it.get('trde_dt') or '').strip(),
            'time': proc_tm[:8],
            'io_tp': io_tp,
            'amount': amount,
            'signed': signed,
            'rmrk': (it.get('rmrk_nm') or '').strip(),
            'balance': _to_int(it.get('entra_remn')),
        })
    return out


def get_cash_flow_all(base_dt: str | None = None, labels: list[str] | None = None) -> dict:
    """모든 계좌 당일 입출금 — {label: [flows]}. labels 지정 시 해당 라벨만."""
    accs = list_accounts()
    if labels is not None:
        wanted = set(labels)
        accs = [a for a in accs if a['label'] in wanted]
    return {a['label']: get_cash_flow(a['label'], base_dt) for a in accs}


def get_period_return(account_label: str, fr_dt: str, to_dt: str) -> dict:
    """기간 계좌수익률 상세 (kt00016) — 기간내 총입금·총출금 포함.

    ⚠️ **조회기간 1년 제한** — 넘기면 `[2000](505221:조회기간은 1년이내로만 가능합니다.)`.
    전체기간 투자원금은 연도별 창으로 쪼개 누적해야 한다 (investment_principal.py 참조).

    ⚠️ 응답의 `invt_bsamt`는 "투자원금**평잔**"(기간 평균잔고)이라 누적 투자원금이 아니다 —
    이름이 비슷해 오용하기 쉬우니 반환하지 않는다. 누적 원금은 cash_in − cash_out.

    계좌 개설 전 기간은 에러 없이 전 필드 0으로 응답한다.
    항등식 `net_fr + cash_in − cash_out + eval_pl == net_to` 가 성립 (자체 검증용).

    반환:
      - cash_in/cash_out:  기간내 총입금/총출금 (원)
      - qty_in/qty_out:    기간내 총입고/총출고 (주식 대체 — 0이 아니면 현금 기준 원금이 과소계상)
      - net_fr/net_to:     순자산액계 기초/기말
      - eval_pl:           평가손익
    """
    resp = _call(account_label, TR_ACNT_RETURN, {'fr_dt': fr_dt, 'to_dt': to_dt})
    return {
        'label': account_label,
        'cash_in': _to_int(resp.get('termin_tot_trns')),   # 기간내총입금
        'cash_out': _to_int(resp.get('termin_tot_pymn')),  # 기간내총출금
        'qty_in': _to_int(resp.get('termin_tot_inq')),     # 기간내총입고
        'qty_out': _to_int(resp.get('termin_tot_outq')),   # 기간내총출고
        'net_fr': _to_int(resp.get('tot_amt_fr')),         # 순자산액계_초
        'net_to': _to_int(resp.get('tot_amt_to')),         # 순자산액계_말
        'eval_pl': _to_int(resp.get('evltv_prft')),        # 평가손익
        'raw': resp,
    }


def get_order_executions(account_label: str, base_dt: str | None = None) -> list[dict]:
    """계좌별 주문체결내역 (kt00007) — 주문번호·시각 단위 행 리스트.

    매매 시점 가드(broker 진실 소스 검증)에 사용. ka10170(종목별 일계 집계)과 달리
    개별 주문건마다 ord_tm·ord_no·체결가·체결수량이 분리되어 시간 기반 가드 가능.

    base_dt: YYYYMMDD (None이면 KST 오늘).
    qry_tp='1': 시간 오름차순. 다른 분기 의미는 키움 문서 미확정이라 '1' 고정.

    반환 행 정규화 필드:
      - ord_no:    주문번호 (예: '0374065')
      - ord_tm:    주문시각 'HH:MM:SS' (체결시각 별도 필드 없음, 주문시각 사용)
      - code:      종목코드 (A 접두 제거)
      - name:      종목명
      - side:      'BUY' | 'SELL' | 'UNKNOWN'  (io_tp_nm 에서 추출)
      - order_qty: 주문수량
      - cntr_qty:  체결수량 (0 이면 미체결)
      - cntr_uv:   체결단가 (0 이면 미체결)
      - ord_uv:    주문단가 (시장가는 0)
      - order_type:'시장가' | '지정가' | 기타
      - exchange:  'KRX' | 'NXT' | 'SOR'
      - comm_src:  주문 출처. 'REST API' (우리 매매) vs '영웅문S#' 등 (사용자 직접 매매)
      - mdfy_cncl: 정정/취소 표식 (빈 값이면 정상)
    """
    if base_dt is None:
        base_dt = datetime.now(KST).strftime('%Y%m%d')
    body = {
        'ord_dt': base_dt,
        'qry_tp': '1',
        'stk_bond_tp': '0',
        'sell_tp': '0',
        'stk_cd': '',
        'fr_ord_no': '',
        'dmst_stex_tp': '%',
    }
    resp = _call_paginated(account_label, TR_ORDER_EXEC, body,
                            list_field='acnt_ord_cntr_prps_dtl')
    items = resp.get('acnt_ord_cntr_prps_dtl') or []
    out: list[dict] = []
    for it in items:
        code = _clean_code(it.get('stk_cd', ''))
        name = (it.get('stk_nm') or '').strip()
        if not code or not name:
            continue  # placeholder 행 스킵
        io_tp = (it.get('io_tp_nm') or '').strip()
        if '매도' in io_tp:
            side = 'SELL'
        elif '매수' in io_tp:
            side = 'BUY'
        else:
            side = 'UNKNOWN'
        out.append({
            'ord_no': (it.get('ord_no') or '').strip(),
            'ord_tm': (it.get('ord_tm') or '').strip(),
            'code': code,
            'name': name,
            'side': side,
            'order_qty': _to_int(it.get('ord_qty')),
            'cntr_qty': _to_int(it.get('cntr_qty')),
            'cntr_uv': _to_int(it.get('cntr_uv')),
            'ord_uv': _to_int(it.get('ord_uv')),
            'order_type': (it.get('trde_tp') or '').strip(),
            'exchange': (it.get('dmst_stex_tp') or '').strip(),
            'comm_src': (it.get('comm_ord_tp') or '').strip(),
            'mdfy_cncl': (it.get('mdfy_cncl') or '').strip(),
        })
    return out


_EXCHANGE_CODE_TO_SUFFIX = {
    '0': '_AL',  # 통합 → SOR
    '1': '',     # KRX
    '2': '_NX',  # NXT
}


def get_open_orders(account_label: str, code: str = '',
                     side: str = 'all', exchange: str = 'all') -> list[dict]:
    """미체결 주문 리스트 (ka10075). 정정/취소 대상 후보 추출용.

    조회 전용 — 주문 행위는 orders 모듈을 거쳐야 함.

    side: 'all' | 'buy' | 'sell'
    exchange: 'all' | 'KRX' | 'NXT'

    반환 행 정규화 필드:
      - account:        account_label (호출자 라벨, 키움 응답엔 없음)
      - ord_no:         주문번호
      - code:           종목코드 (A 접두 제거)
      - name:           종목명
      - side:           'BUY' | 'SELL' | 'UNKNOWN'
      - order_qty:      주문수량
      - order_price:    주문가격 (시장가면 0)
      - unfilled_qty:   미체결수량 (취소 시 잔량 = 이 값)
      - order_type:     trde_tp 문자열 ('시장가' / '지정가' 등)
      - exchange:       'KRX' | 'NXT' | '통합' (stex_tp_txt)
      - exchange_code:  '0' | '1' | '2'
      - routing_suffix: '_AL' | '' | '_NX'  (orders.kiwoom_order 가 그대로 받음)
      - status:         ord_stt (예: '접수')
      - order_time:     'HH:MM:SS' (tm)
    """
    side_map = {'all': '0', 'sell': '1', 'buy': '2'}
    exchange_map = {'all': '0', 'KRX': '1', 'NXT': '2'}
    if side not in side_map:
        raise ValueError(f'invalid side: {side!r}')
    if exchange not in exchange_map:
        raise ValueError(f'invalid exchange: {exchange!r}')
    body = {
        'all_stk_tp': '1' if code else '0',
        'trde_tp': side_map[side],
        'stk_cd': code or '',
        'stex_tp': exchange_map[exchange],
    }
    resp = _call_paginated(account_label, TR_OPEN_ORDERS, body, list_field='oso')
    items = resp.get('oso') or []
    out: list[dict] = []
    for it in items:
        stk_code = _clean_code(it.get('stk_cd', ''))
        ord_no = (it.get('ord_no') or '').strip()
        if not stk_code or not ord_no:
            continue
        io_tp = (it.get('io_tp_nm') or '').strip()
        if '매도' in io_tp:
            s = 'SELL'
        elif '매수' in io_tp:
            s = 'BUY'
        else:
            s = 'UNKNOWN'
        ex_code = (it.get('stex_tp') or '').strip()
        out.append({
            'account': account_label,
            'ord_no': ord_no,
            'code': stk_code,
            'name': (it.get('stk_nm') or '').strip(),
            'side': s,
            'order_qty': _to_int(it.get('ord_qty')),
            'order_price': _to_int(it.get('ord_pric')),
            'unfilled_qty': _to_int(it.get('oso_qty')),
            'order_type': (it.get('trde_tp') or '').strip(),
            # 스톱지정가(trde_tp=28) 주문의 조건단가. 일반 주문은 0.
            # 실측 확인: ka10075 응답에 stop_pric 로 실려 온다 (2026-07-30).
            'stop_price': _to_int(it.get('stop_pric')),
            # 현재가 — 조건단가까지 얼마 남았는지 계산용. 부호 포함 문자열('-4805')이라 abs.
            'cur_price': abs(_to_int(it.get('cur_prc'))),
            'exchange': (it.get('stex_tp_txt') or '').strip(),
            'exchange_code': ex_code,
            'routing_suffix': _EXCHANGE_CODE_TO_SUFFIX.get(ex_code, ''),
            'status': (it.get('ord_stt') or '').strip(),
            'order_time': (it.get('tm') or '').strip(),
        })
    return out


def get_open_orders_all(labels: list[str] | None = None,
                         code: str = '', side: str = 'all',
                         exchange: str = 'all') -> list[dict]:
    """모든 계좌의 미체결 주문 통합 리스트 — 정정/취소 자동 매칭에 사용."""
    if labels is None:
        labels = list(load_credentials().get('accounts', {}).keys())
    rows: list[dict] = []
    for label in labels:
        try:
            rows.extend(get_open_orders(label, code=code, side=side, exchange=exchange))
        except Exception as e:
            print(f'[get_open_orders_all] {label} 실패: {e}', file=sys.stderr)
    return rows


# ---- 시세·종목코드 조회 (매매 아님, READ-ONLY) ----

def _default_account_label() -> str:
    """시세 조회는 어느 계좌 토큰으로든 가능 — 첫 계좌를 사용."""
    accounts = list(load_credentials().get('accounts', {}).keys())
    if not accounts:
        raise RuntimeError('등록된 계좌 없음')
    return accounts[0]


_QUOTE_GATE = threading.Lock()
_QUOTE_LAST_TS = 0.0
# ka10001 한도(키움 통상 ~5 req/sec) 안쪽으로 페이싱. 0.22s 간격 → 약 4.5 req/sec.
# behive_web 동시 발사·watchlist_monitor 연사로 1700:허용된 요청 개수 초과(HTTP 429) 빈발 → 단일 진입점에서 직렬화.
_QUOTE_MIN_INTERVAL = 0.22
_QUOTE_BACKOFF_SEC = 1.0


def _is_quote_rate_limit(err_msg: str) -> bool:
    return 'HTTP 429' in err_msg or '허용된 요청 개수' in err_msg or '1700' in err_msg


def get_stock_quote(code: str, account_label: str | None = None, exchange: str = 'AL') -> dict:
    """주식기본정보 조회 (ka10001). 현재가·전일대비·등락률.

    exchange: 'AL'=KRX+NXT 통합(기본·최신가·통합거래량), 'KRX'=KRX 단독, 'NX'=NXT 단독.
    키움 ATS 변경(2025-03 NextTrade 출범) 이후 종목코드 접미사(`_AL`/`_NX`)로 거래소 지정.
    NXT 미상장 종목에 'AL' 호출 시 KRX 값으로 자동 fallback (서남 등 검증 완료).
    """
    global _QUOTE_LAST_TS
    label = account_label or _default_account_label()
    clean = _clean_code(code)
    stk_cd = f'{clean}_{exchange}' if exchange in ('NX', 'AL') else clean
    url = base_url() + ENDPOINT_STKINFO
    body = {'stk_cd': stk_cd}

    resp = None
    last_err: Exception | None = None
    for attempt in range(2):
        with _QUOTE_GATE:
            wait = _QUOTE_MIN_INTERVAL - (time.time() - _QUOTE_LAST_TS)
            if wait > 0:
                time.sleep(wait)
            try:
                resp, _ = _http_post_full(url, body, auth_headers(label, tr_id=TR_STOCK_INFO))
            except RuntimeError as e:
                _QUOTE_LAST_TS = time.time()
                if _is_quote_rate_limit(str(e)) and attempt == 0:
                    last_err = e
                    resp = None
                else:
                    raise
            else:
                _QUOTE_LAST_TS = time.time()
        if resp is None:
            time.sleep(_QUOTE_BACKOFF_SEC)
            continue
        rc = resp.get('return_code', 0)
        if rc != 0 and _is_quote_rate_limit(str(resp.get('return_msg') or '')) and attempt == 0:
            last_err = RuntimeError(f'{TR_STOCK_INFO} rate-limit: {resp.get("return_msg")}')
            time.sleep(_QUOTE_BACKOFF_SEC)
            resp = None
            continue
        break

    if resp is None:
        raise last_err or RuntimeError(f'{TR_STOCK_INFO} 실패: 응답 없음')
    if resp.get('return_code', 0) != 0:
        raise RuntimeError(f'{TR_STOCK_INFO} 실패: {resp.get("return_msg")} (full={resp})')
    # 키움 현재가 응답은 부호 포함 문자열: "+71200", "-71200", "71200"
    cur_str = str(resp.get('cur_prc') or '0').strip()
    pred_str = str(resp.get('pred_pre') or '0').strip()
    return {
        'code': clean,
        'name': (resp.get('stk_nm') or '').strip(),
        'price': abs(_to_int(cur_str)),  # 키움은 하한가·상한가 표시에만 부호 — 절댓값이 현재가
        'change': _to_int(pred_str),     # 전일대비 (부호 있음)
        'change_pct': _to_float(resp.get('flu_rt')),
        'volume': _to_int(resp.get('trde_qty')),
        'raw': resp,
    }


WATCHLIST_BATCH_MAX = 40   # ka10095 한 콜 종목 수 한도 — 초과 시 "허용된 요청 개수 초과[1634]" (2026-06-17, universe 120개에서 발견)


def get_watchlist_quotes(codes: list[str], account_label: str | None = None,
                         exchange: str = 'AL') -> dict[str, dict]:
    """ka10095 관심종목정보요청 — 다종목 시세 batch. 종목 수가 한도(WATCHLIST_BATCH_MAX) 초과 시 자동 분할 호출.

    반환: {clean_code: {price, change, change_pct, name}}. 빈 codes면 빈 dict.
    """
    clean_codes = [_clean_code(c) for c in codes if c]
    if not clean_codes:
        return {}
    label = account_label or _default_account_label()
    suffix = f'_{exchange}' if exchange in ('NX', 'AL') else ''
    out: dict[str, dict] = {}
    for i in range(0, len(clean_codes), WATCHLIST_BATCH_MAX):
        chunk = clean_codes[i:i + WATCHLIST_BATCH_MAX]
        stk_cd_param = '|'.join(f'{c}{suffix}' for c in chunk)
        resp = _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO)
        out.update(_parse_watchlist_rows(resp))
    return out


def _parse_watchlist_rows(resp: dict) -> dict[str, dict]:
    out: dict[str, dict] = {}
    for row in resp.get('atn_stk_infr') or []:
        raw_code = (row.get('stk_cd') or '').strip()
        # 응답의 stk_cd는 요청 그대로 suffix 포함 ('294630_AL'). suffix 제거.
        code = raw_code.split('_', 1)[0]
        if not code:
            continue
        out[code] = {
            'code': code,
            'name': (row.get('stk_nm') or '').strip(),
            'price': abs(_to_int(row.get('cur_prc') or '0')),
            'change': _to_int(row.get('pred_pre') or '0'),
            'change_pct': _to_float(row.get('flu_rt')),
            # OHLC — 오늘 단일 캔들용. 키움 응답은 부호 포함 문자열이라 abs 처리.
            'open': abs(_to_int(row.get('open_pric') or '0')),
            'high': abs(_to_int(row.get('high_pric') or '0')),
            'low': abs(_to_int(row.get('low_pric') or '0')),
            'volume': _to_int(row.get('trde_qty') or '0'),
            # 거래소가 계산한 가격제한폭. 기준가(전일종가)로 정해져 장중 불변.
            # 제한폭은 기준가의 호가단위로 절사한 뒤 ±하는 규칙이라 직접 계산하면
            # ETF·가격대별 호가단위 표를 재현해야 해 틀릴 여지가 있다 → 응답값 그대로 쓴다.
            'upper_limit': abs(_to_int(row.get('upl_pric') or '0')),
            'lower_limit': abs(_to_int(row.get('lst_pric') or '0')),
        }
    return out


def get_quote_book(code: str, account_label: str | None = None) -> dict:  # noqa: E302
    """주식호가 조회 (ka10004) — 10단계 매도/매수 호가 + 잔량.

    매매 아님 — 조회 전용. 호가창 표시·체결 가능성 판단에 사용.
    필드명 함정 (2026-05-07 메모리 reference_kiwoom_endpoints.md):
      - 1호가: `sel_fpr_bid` / `sel_fpr_req`, `buy_fpr_bid` / `buy_fpr_req` (suffix 없음)
      - 2~10호가: `sel_{N}th_pre_bid` / `sel_{N}th_pre_req`, `buy_{N}th_pre_bid` / `buy_{N}th_pre_req`
    가격 응답은 부호 포함 ('+71200') → abs 처리.

    rate limit 보호: ka10001과 동일 `_QUOTE_GATE` 통과 (시세 카테고리 공용).

    반환:
      - code, price(현재가), change(전일대비, 부호), change_pct
      - asks: [{price, qty}] — 1호가(가장 낮은 매도가)부터 10호가 순
      - bids: [{price, qty}] — 1호가(가장 높은 매수가)부터 10호가 순
      - ts: 응답 시각 KST ISO
    """
    global _QUOTE_LAST_TS
    label = account_label or _default_account_label()
    clean = _clean_code(code)
    url = base_url() + ENDPOINT_MRKCOND
    body = {'stk_cd': clean}

    resp: dict | None = None
    last_err: Exception | None = None
    for attempt in range(2):
        with _QUOTE_GATE:
            wait = _QUOTE_MIN_INTERVAL - (time.time() - _QUOTE_LAST_TS)
            if wait > 0:
                time.sleep(wait)
            try:
                resp, _ = _http_post_full(url, body, auth_headers(label, tr_id='ka10004'))
            except RuntimeError as e:
                _QUOTE_LAST_TS = time.time()
                if _is_quote_rate_limit(str(e)) and attempt == 0:
                    last_err = e
                    resp = None
                else:
                    raise
            else:
                _QUOTE_LAST_TS = time.time()
        if resp is None:
            time.sleep(_QUOTE_BACKOFF_SEC)
            continue
        rc = resp.get('return_code', 0)
        if rc != 0 and _is_quote_rate_limit(str(resp.get('return_msg') or '')) and attempt == 0:
            last_err = RuntimeError(f'ka10004 rate-limit: {resp.get("return_msg")}')
            time.sleep(_QUOTE_BACKOFF_SEC)
            resp = None
            continue
        break

    if resp is None:
        raise last_err or RuntimeError('ka10004 실패: 응답 없음')
    if resp.get('return_code', 0) != 0:
        raise RuntimeError(f'ka10004 실패: {resp.get("return_msg")} (full={resp})')

    asks: list[dict] = []
    bids: list[dict] = []

    ap1 = abs(_to_int(resp.get('sel_fpr_bid')))
    aq1 = abs(_to_int(resp.get('sel_fpr_req')))
    if ap1 and aq1:
        asks.append({'price': ap1, 'qty': aq1})
    bp1 = abs(_to_int(resp.get('buy_fpr_bid')))
    bq1 = abs(_to_int(resp.get('buy_fpr_req')))
    if bp1 and bq1:
        bids.append({'price': bp1, 'qty': bq1})
    for i in range(2, 11):
        ap = abs(_to_int(resp.get(f'sel_{i}th_pre_bid')))
        aq = abs(_to_int(resp.get(f'sel_{i}th_pre_req')))
        if ap and aq:
            asks.append({'price': ap, 'qty': aq})
        bp = abs(_to_int(resp.get(f'buy_{i}th_pre_bid')))
        bq = abs(_to_int(resp.get(f'buy_{i}th_pre_req')))
        if bp and bq:
            bids.append({'price': bp, 'qty': bq})

    return {
        'code': clean,
        'price': abs(_to_int(resp.get('cur_prc') or '0')),
        'change': _to_int(resp.get('pred_pre') or '0'),
        'change_pct': _to_float(resp.get('flu_rt')),
        'asks': asks,
        'bids': bids,
        'ts': datetime.now(KST).isoformat(),
        'raw': resp,
    }


def get_stock_basic(code: str, account_label: str | None = None) -> dict:
    """ka10001 기본정보를 분석 보고서용으로 정리.

    get_stock_quote가 시세 4필드(price·change·pct·volume)만 뽑는 반면,
    이 함수는 분석에 필요한 시고저·52주·시총·PER/PBR/ROE/EPS/BPS·회전율까지 노출.
    같은 TR이지만 보고서·뷰가 raw 파싱 코드를 직접 들고 다닐 필요 없게 wrapper로 둠.

    회전율(turnover_rate): trde_qty / flo_stk * 100. ka10001은 일일 회전율 필드 미제공이라
    상장주식수로 직접 계산. flo_stk는 천 주 단위가 아니라 주(株) 단위라 그대로 나눔.
    """
    q = get_stock_quote(code, account_label=account_label, exchange='AL')
    raw = q.get('raw') or {}

    # ka10001 flo_stk는 천 주 단위 (예: 5846279 = 5,846,279,000주)
    listed_thousand = _to_int(raw.get('flo_stk'))
    listed = listed_thousand * 1000
    volume = _to_int(raw.get('trde_qty'))
    turnover_rate = (volume / listed * 100.0) if listed > 0 else 0.0

    return {
        'code': q['code'],
        'name': q['name'],
        'price': q['price'],
        'change': q['change'],
        'change_pct': q['change_pct'],
        'open': abs(_to_int(raw.get('open_pric'))),
        'high': abs(_to_int(raw.get('high_pric'))),
        'low': abs(_to_int(raw.get('low_pric'))),
        'prev_close': abs(_to_int(raw.get('base_pric'))),
        'volume': volume,
        'listed_shares': listed,
        'market_cap': _to_int(raw.get('mac')),       # 시가총액 (억원)
        'high_52w': abs(_to_int(raw.get('250hgst'))),
        'high_52w_dt': (raw.get('250hgst_pric_dt') or '').strip(),
        'low_52w': abs(_to_int(raw.get('250lwst'))),
        'low_52w_dt': (raw.get('250lwst_pric_dt') or '').strip(),
        'per': _to_float(raw.get('per')),
        'pbr': _to_float(raw.get('pbr')),
        'eps': _to_int(raw.get('eps')),
        'bps': _to_int(raw.get('bps')),
        'roe': _to_float(raw.get('roe')),
        'turnover_rate': turnover_rate,
        'foreign_holding_pct': _to_float(raw.get('for_exh_rt')),  # 외국인 보유비율
        # 수익성 (최근 결산 기준, 억원 단위) — EPS×상장주식수 ≈ 당기순이익으로 단위 검증됨
        'sales': _to_int(raw.get('sale_amt')),         # 매출액
        'op_profit': _to_int(raw.get('bus_pro')),      # 영업이익
        'net_profit': _to_int(raw.get('cup_nga')),     # 당기순이익
        'ev_ebitda': _to_float(raw.get('ev')),         # EV/EBITDA 배수
        'capital': _to_int(raw.get('cap')),            # 자본금 (억원)
        'par_value': _to_int(raw.get('fav')),          # 액면가
        'par_unit': (raw.get('fav_unit') or '').strip(),   # 액면가 단위 (원 등)
        'settle_month': _to_int(raw.get('setl_mm')),   # 결산월
        'dist_rate': _to_float(raw.get('dstr_rt')),    # 유통주식 비율 (%)
        'raw': raw,
    }


def get_daily_candles(code: str, count: int = 250, base_dt: str | None = None,
                       account_label: str | None = None) -> list[dict]:
    """주식 일봉 차트 (ka10081). 수정주가 기준.

    base_dt: 'YYYYMMDD' 기준일 (생략 시 키움이 최신일로 처리).
    count: 반환할 최대 일봉 수 (최신부터 잘라서 반환). 키움은 한 호출에 ~600일치.

    반환: 최신순(내림차순)으로 정렬된 list of dict:
      {date, open, high, low, close, volume, value, turnover_rate}
    가격 필드는 모두 abs() 처리 (키움은 상하한 표시에만 부호 사용).
    """
    label = account_label or _default_account_label()
    if not base_dt:
        # ka10081은 base_dt 필수 — 비어있으면 오늘(Asia/Seoul) 채움
        from datetime import datetime
        try:
            from zoneinfo import ZoneInfo
            base_dt = datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y%m%d')
        except Exception:
            base_dt = datetime.now().strftime('%Y%m%d')
    body = {
        'stk_cd': _clean_code(code),
        'base_dt': base_dt,
        'upd_stkpc_tp': '1',  # 수정주가 사용
    }
    resp = _call(label, TR_DAILY_CHART, body, endpoint=ENDPOINT_CHART)
    rows = resp.get('stk_dt_pole_chart_qry') or []
    out: list[dict] = []
    for row in rows[:count]:
        out.append({
            'date': (row.get('dt') or '').strip(),
            'open': abs(_to_int(row.get('open_pric'))),
            'high': abs(_to_int(row.get('high_pric'))),
            'low': abs(_to_int(row.get('low_pric'))),
            'close': abs(_to_int(row.get('cur_prc'))),
            'volume': _to_int(row.get('trde_qty')),
            'value': _to_int(row.get('trde_prica')),       # 거래대금 (백만원)
            'turnover_rate': _to_float(row.get('trde_tern_rt')),  # 일별 회전율 (%)
        })
    return out


def get_minute_candles(code: str, tic_scope: int = 5, today_only: bool = True,
                        account_label: str | None = None, exchange: str = 'KRX') -> list[dict]:
    """주식 분봉 차트 (ka10080). 수정주가 기준.

    tic_scope: 1/3/5/10/15/30/45/60 분. 한 호출에 ~900봉 (5분봉 ≈ 11일치).
    today_only: True 면 오늘 봉만 추출.
    exchange: 'KRX'=KRX 단독(기본) / 'NX'=NXT 단독 / 'AL'=통합(KRX→NXT 연속).
             NX/AL은 stk_cd에 suffix를 붙여 NXT 시간대(15:30~20:00)에도 분봉이 이어진다.

    반환: 시간 오름차순 list of dict — {date, time, open, high, low, close, volume}
    date='YYYYMMDD', time='HHMMSS'.
    """
    label = account_label or _default_account_label()
    suffix = f'_{exchange}' if exchange in ('NX', 'AL') else ''
    body = {
        'stk_cd': _clean_code(code) + suffix,
        'tic_scope': str(tic_scope),
        'upd_stkpc_tp': '1',
    }
    resp = _call(label, TR_MINUTE_CHART, body, endpoint=ENDPOINT_CHART)
    rows = resp.get('stk_min_pole_chart_qry') or []
    from datetime import datetime
    try:
        from zoneinfo import ZoneInfo
        today = datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y%m%d')
    except Exception:
        today = datetime.now().strftime('%Y%m%d')
    out: list[dict] = []
    for row in rows:
        ctm = (row.get('cntr_tm') or '').strip()
        date = ctm[:8] if len(ctm) >= 8 else ''
        if today_only and date != today:
            continue
        out.append({
            'date': date,
            'time': ctm[8:14] if len(ctm) >= 14 else '',
            'open': abs(_to_int(row.get('open_pric'))),
            'high': abs(_to_int(row.get('high_pric'))),
            'low': abs(_to_int(row.get('low_pric'))),
            'close': abs(_to_int(row.get('cur_prc'))),
            'volume': _to_int(row.get('trde_qty')),
        })
    # 키움 응답은 최신순 → 오름차순으로
    out.reverse()
    return out


def get_investor_flow(code: str, days: int = 20, account_label: str | None = None) -> list[dict]:
    """종목별 투자자별 일별 매매 (ka10059). 외국인·기관 수급용.

    days: 최신부터 자를 일수. 키움 응답은 기본 ~100일치.
    unit_tp='1000': 단위 천 주. 수량 부호 = 순매수(+)/순매도(-).
    trde_tp='0': 순매수 기준.

    반환: 최신순 list of dict — {date, close, change, change_pct, volume, individual, foreign, institution, pension, ...}
    수량은 천 주 단위 그대로.
    """
    label = account_label or _default_account_label()
    # ka10059 dt 필수 — 빈 값 불가. 오늘(Asia/Seoul) 기준으로 채움
    from datetime import datetime
    try:
        from zoneinfo import ZoneInfo
        today = datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y%m%d')
    except Exception:
        today = datetime.now().strftime('%Y%m%d')
    body = {
        'dt': today,
        'stk_cd': _clean_code(code),
        'amt_qty_tp': '2',         # 1=금액, 2=수량
        'trde_tp': '0',            # 0=순매수, 1=매수, 2=매도
        'unit_tp': '1000',         # 단위 (천 주)
    }
    resp = _call(label, TR_INVESTOR_FLOW, body, endpoint=ENDPOINT_STKINFO)
    rows = resp.get('stk_invsr_orgn') or []
    out: list[dict] = []
    for row in rows[:days]:
        out.append({
            'date': (row.get('dt') or '').strip(),
            'close': abs(_to_int(row.get('cur_prc'))),
            'change': _to_int(row.get('pred_pre')),
            # flu_rt는 ×100 형태 ('-196' = -1.96%) — _to_float은 그대로 받고 호출자가 /100
            'change_pct_raw': _to_int(row.get('flu_rt')),
            'volume': _to_int(row.get('acc_trde_qty')),
            'individual': _to_int(row.get('ind_invsr')),       # 개인
            'foreign': _to_int(row.get('frgnr_invsr')),        # 외국인
            'institution': _to_int(row.get('orgn')),           # 기관 합계
            'fnnc_invt': _to_int(row.get('fnnc_invt')),        # 금융투자
            'insurance': _to_int(row.get('insrnc')),           # 보험
            'invtrt': _to_int(row.get('invtrt')),              # 투신
            'bank': _to_int(row.get('bank')),                  # 은행
            'pension': _to_int(row.get('penfnd_etc')),         # 연기금
            'private_fund': _to_int(row.get('samo_fund')),     # 사모펀드
            'etc_corp': _to_int(row.get('etc_corp')),          # 기타법인
        })
    return out


def _load_code_cache() -> dict:
    """종목코드 캐시 dict {name: {code, name, market}} 반환. 없으면 빈 dict."""
    if not STOCK_CODES_CACHE.exists():
        return {}
    try:
        return json.loads(STOCK_CODES_CACHE.read_text()).get('stocks', {}) or {}
    except Exception:
        return {}


def get_volume_surge(account_label: str | None = None, market: str = '000',
                     min_qty: str = '50') -> list[dict]:
    """거래량 급증 종목 (ka10023). 급증률 정렬·ETF+ETN+스팩 제외·5천원↑·KRX.
    반환 [{code, name, price, flu_rt(등락률%), surge_rt(급증률%)}] — 조회 전용."""
    label = account_label or _default_account_label()
    body = {'mrkt_tp': market, 'sort_tp': '2', 'tm_tp': '2', 'trde_qty_tp': min_qty,
            'tm': '', 'stk_cnd': '20', 'pric_tp': '6', 'stex_tp': '1'}  # stk_cnd 20=ETF+ETN+스팩제외
    resp = _call(label, 'ka10023', body, ENDPOINT_RKINFO)
    out = []
    for x in resp.get('trde_qty_sdnin', []) or []:
        code = _clean_code(x.get('stk_cd', ''))
        if not code:
            continue
        out.append({'code': code, 'name': x.get('stk_nm', ''),
                    'price': abs(_to_int(x.get('cur_prc'))),
                    'flu_rt': _to_float(x.get('flu_rt')),
                    'surge_rt': _to_float(x.get('sdnin_rt'))})
    return out


def get_foreign_inst_netbuy(account_label: str | None = None, market: str = '000',
                            date: str | None = None) -> dict:
    """외국인·기관 순매수 상위 (ka90009). 금액(천만) 기준.
    반환 {'foreign': [{code,name,amt}], 'inst': [{code,name,amt}]} — 조회 전용."""
    label = account_label or _default_account_label()
    if not date:
        date = datetime.now(KST).strftime('%Y%m%d')
    body = {'mrkt_tp': market, 'amt_qty_tp': '1', 'qry_dt_tp': '1', 'date': date, 'stex_tp': '1'}
    resp = _call(label, 'ka90009', body, ENDPOINT_RKINFO)
    rows = resp.get('frgnr_orgn_trde_upper', []) or []
    foreign, inst = [], []
    for x in rows:
        fc = _clean_code(x.get('for_netprps_stk_cd', ''))
        if fc:
            foreign.append({'code': fc, 'name': x.get('for_netprps_stk_nm', ''),
                            'amt': _to_int(x.get('for_netprps_amt'))})
        ic = _clean_code(x.get('orgn_netprps_stk_cd', ''))
        if ic:
            inst.append({'code': ic, 'name': x.get('orgn_netprps_stk_nm', ''),
                         'amt': _to_int(x.get('orgn_netprps_amt'))})
    return {'foreign': foreign, 'inst': inst}


def refresh_stock_codes(account_label: str | None = None) -> dict:
    """ka10099로 KOSPI+KOSDAQ 전체 상장종목 리스트 캐싱. 페이지네이션 처리.

    캐시 저장 필드 (2026-05-07 확장):
    - code, name, market (기존)
    - nxt_enable: bool — ka10099 응답 nxtEnable=='Y' 변환. NXT 가드 정확화에 사용
    - state: str — 종목상태 (예: '관리종목', '정리매매', '증거금100%')
    - order_warning: str — 투자유의 (0/1/2/3/4/5, ka10099 명세 참조)
    """
    label = account_label or _default_account_label()
    url = base_url() + ENDPOINT_STKINFO
    result: dict[str, dict] = {}
    for mrkt_tp, market_name in [('0', 'KOSPI'), ('10', 'KOSDAQ')]:
        cont_yn = 'N'
        next_key = ''
        for _ in range(50):  # safety cap on pagination loops
            headers = auth_headers(label, tr_id=TR_STOCK_LIST, cont_yn=cont_yn, next_key=next_key)
            resp, resp_headers = _http_post_full(url, {'mrkt_tp': mrkt_tp}, headers)
            if resp.get('return_code', 0) != 0:
                raise RuntimeError(f'{TR_STOCK_LIST} ({market_name}) 실패: {resp.get("return_msg")}')
            items = resp.get('list') or []
            for it in items:
                code = _clean_code(it.get('code', ''))
                name = (it.get('name') or '').strip()
                if code and name:
                    result[name] = {
                        'code': code,
                        'name': name,
                        'market': market_name,
                        'nxt_enable': str(it.get('nxtEnable') or '').strip().upper() == 'Y',
                        'state': (it.get('state') or '').strip(),
                        'order_warning': str(it.get('orderWarning') or '0').strip(),
                    }
            cy = (resp_headers.get('cont-yn') or '').strip().upper()
            nk = (resp_headers.get('next-key') or '').strip()
            if cy == 'Y' and nk:
                cont_yn = 'Y'
                next_key = nk
                time.sleep(0.15)
            else:
                break
    STOCK_CODES_CACHE.parent.mkdir(parents=True, exist_ok=True)
    STOCK_CODES_CACHE.write_text(json.dumps({
        'refreshed_at': datetime.now(KST).isoformat(),
        'count': len(result),
        'stocks': result,
    }, ensure_ascii=False, indent=2))
    return result


def lookup_stock_meta(code: str) -> dict | None:
    """6자리 종목코드 → 캐시된 메타 dict (또는 None).

    캐시는 종목명 기반이라 코드 검색은 O(N). ~3500종목 수준이라 매 호출 직접 순회.
    캐시 미스 또는 캐시 자체가 없으면 None — 호출자가 fallback 결정.
    """
    clean = _clean_code(code or '')
    if not clean:
        return None
    for info in _load_code_cache().values():
        if info.get('code') == clean:
            return info
    return None


def resolve_stock_code(name_or_code: str) -> dict:
    """이름 또는 6자리 코드 → {code, name, market}. 캐시 매칭 실패 시 1회 lazy refresh.

    이름 매칭은 공백 무시 ("은선물" == "은 선물" — KRX 종목명 공백 표기 불일치 흡수).
    """
    s = (name_or_code or '').strip()
    if not s:
        raise ValueError('빈 입력')
    # 6자리 숫자 → 코드로 간주
    if s.isdigit() and len(s) == 6:
        for info in _load_code_cache().values():
            if info.get('code') == s:
                return info
        # 캐시에 없으면 ka10001로 이름만 끌어옴 (KRX 단독 — NXT 미상장 종목 보호)
        q = get_stock_quote(s, exchange='KRX')
        return {'code': s, 'name': q.get('name') or '', 'market': '?'}
    norm = lambda x: ''.join((x or '').split())
    s_n = norm(s)

    def _match(cache: dict) -> tuple[dict | None, list[dict]]:
        # 정확 매칭 (공백 정규화 후)
        for name, info in cache.items():
            if norm(name) == s_n:
                return info, []
        # 부분 매칭 (공백 정규화 후)
        partial = [info for name, info in cache.items() if s_n in norm(name)]
        return None, partial

    cache = _load_code_cache()
    exact, partial = _match(cache)
    if exact:
        return exact
    if len(partial) == 1:
        return partial[0]
    # lazy refresh (캐시 비었거나 매칭 실패 시 1회만)
    if not cache or not partial:
        print(f'[stock-codes] 캐시 갱신 중...', file=sys.stderr)
        refresh_stock_codes()
        cache = _load_code_cache()
        exact, partial = _match(cache)
        if exact:
            return exact
        if len(partial) == 1:
            return partial[0]
    if len(partial) > 1:
        raise KeyError(f'종목 매칭 다중: "{s}" → {[p["name"] for p in partial[:5]]}')
    raise KeyError(f'종목 매칭 실패: "{s}" (캐시 {len(cache)}종목)')


# ---- 주문 함수는 작성하지 않음 (매매 절대 원칙) ----
# DO NOT add: order_buy, order_sell, modify_order, cancel_order, ...
# 어떤 요청이 오더라도 이 모듈에 추가 금지. 별도 안건으로 분리.


def main():
    """간단 CLI: 토큰 발급·계좌 라벨 확인용."""
    cmd = sys.argv[1] if len(sys.argv) > 1 else 'help'
    if cmd == 'token':
        label = sys.argv[2] if len(sys.argv) > 2 else None
        labels = [label] if label else [a['label'] for a in list_accounts()]
        exit_code = 0
        for lb in labels:
            try:
                t = issue_token(lb, force=True)
                print(f'[{lb}] 토큰 발급 성공: {t[:20]}... (캐시: {_token_cache_path(lb)})')
            except Exception as e:
                print(f'[{lb}] 토큰 발급 실패: {e}', file=sys.stderr)
                exit_code = 1
        return exit_code
    if cmd == 'accounts':
        for a in list_accounts():
            print(f"{a['label']}: {a['account_no'] or '(토큰 스코프로 식별, 계좌번호 저장 불필요)'}")
        return 0
    if cmd == 'balance':
        label = sys.argv[2] if len(sys.argv) > 2 else None
        labels = [label] if label else [a['label'] for a in list_accounts()]
        for lb in labels:
            b = get_balance(lb)
            print(f"[{lb}] 예수금 {b['entr']:,}원 · D+2 {b['d2_entra']:,}원 · 주문가능 {b['ord_alow_amt']:,}원 · 대용금 {b['repl_amt']:,}원")
        return 0
    if cmd == 'positions':
        label = sys.argv[2] if len(sys.argv) > 2 else None
        labels = [label] if label else [a['label'] for a in list_accounts()]
        for lb in labels:
            items = get_positions(lb)
            print(f"\n[{lb}] 보유 {len(items)}종목")
            for p in items:
                sign = '+' if p['evltv_prft'] >= 0 else ''
                print(f"  {p['name']:<14} ({p['code']}) {p['qty']:>5}주 평단 {p['avg_price']:>8,} 현재 {p['cur_price']:>8,} 평가 {p['evlt_amt']:>12,} 손익 {sign}{p['evltv_prft']:>10,} ({p['prft_rt']:+.2f}%)")
        return 0
    if cmd == 'summary':
        for lb in [a['label'] for a in list_accounts()]:
            s = get_account_summary(lb)
            print(f"[{lb}] {s['acnt_nm']}({s['brch_nm']}) · 추정총자산 {s['tot_est_amt']:,}원 · 자산평가액 {s['aset_evlt_amt']:,}원 · 매입 {s['tot_pur_amt']:,}원")
        return 0
    if cmd == 'quote':
        if len(sys.argv) < 3:
            print('usage: quote <name_or_code>', file=sys.stderr)
            return 2
        info = resolve_stock_code(sys.argv[2])
        q = get_stock_quote(info['code'])
        sign = '+' if q['change'] >= 0 else ''
        print(f"{q['name']} ({q['code']}) {q['price']:,}원 ({sign}{q['change']:,}원, {q['change_pct']:+.2f}%) · 거래량 {q['volume']:,}")
        return 0
    if cmd == 'resolve':
        if len(sys.argv) < 3:
            print('usage: resolve <name_or_code>', file=sys.stderr)
            return 2
        info = resolve_stock_code(sys.argv[2])
        print(f"{info['name']} ({info['code']}) · {info.get('market','?')}")
        return 0
    if cmd == 'refresh-codes':
        stocks = refresh_stock_codes()
        print(f"종목코드 캐시 갱신 완료: {len(stocks)}종목 → {STOCK_CODES_CACHE}")
        return 0
    if cmd == 'journal':
        # journal [label] [YYYYMMDD] — 당일매매일지 (round-trip·풀매도 포함)
        label = sys.argv[2] if len(sys.argv) > 2 else None
        base_dt = sys.argv[3] if len(sys.argv) > 3 else None
        labels = [label] if label else [a['label'] for a in list_accounts()]
        for lb in labels:
            trades = get_trade_journal(lb, base_dt)
            print(f"\n[{lb}] 매매 {len(trades)}건")
            for t in trades:
                parts = []
                if t['buy_qty']:
                    parts.append(f"매수 {t['buy_qty']:,}주 @ {t['buy_avg']:,}원 ({t['buy_amt']:,}원)")
                if t['sell_qty']:
                    parts.append(f"매도 {t['sell_qty']:,}주 @ {t['sell_avg']:,}원 ({t['sell_amt']:,}원)")
                pl_str = f"실현 {t['pl_amt']:+,}원 ({t['prft_rt']:+.2f}%)" if t['sell_qty'] else ''
                print(f"  {t['name']} ({t['code']}) · {' / '.join(parts)} · 수수료·세금 {t['cmsn_tax']:,}원 {pl_str}")
        return 0
    if cmd == 'cashflow':
        # cashflow [label] [YYYYMMDD] — 당일 입출금 내역 (kt00015 tp='1')
        label = sys.argv[2] if len(sys.argv) > 2 else None
        base_dt = sys.argv[3] if len(sys.argv) > 3 else None
        labels = [label] if label else [a['label'] for a in list_accounts()]
        for lb in labels:
            flows = get_cash_flow(lb, base_dt)
            net = sum(f['signed'] for f in flows)
            cash_in = sum(f['amount'] for f in flows if f['io_tp'] == 'IN')
            cash_out = sum(f['amount'] for f in flows if f['io_tp'] == 'OUT')
            print(f"\n[{lb}] 입출금 {len(flows)}건 · 입금 {cash_in:,}원 · 출금 {cash_out:,}원 · 순 {net:+,}원")
            for f in flows:
                print(f"  {f['time']} · {f['io_tp']:>3} {f['amount']:,}원 · {f['rmrk']}")
        return 0
    if cmd == 'open':
        # open [label] [code] — 미체결 주문 (ka10075)
        label = sys.argv[2] if len(sys.argv) > 2 else None
        code = sys.argv[3] if len(sys.argv) > 3 else ''
        labels = [label] if label else [a['label'] for a in list_accounts()]
        for lb in labels:
            rows = get_open_orders(lb, code=code)
            print(f"\n[{lb}] 미체결 {len(rows)}건")
            for r in rows:
                price = f"{r['order_price']:,}원" if r['order_price'] else r['order_type']
                print(f"  ord_no={r['ord_no']} · {r['name']} ({r['code']}) {r['side']} {r['order_qty']}주 @ {price} · 미체결 {r['unfilled_qty']} · {r['exchange']} · {r['status']} · {r['order_time']}")
        return 0
    print(__doc__, file=sys.stderr)
    print('\nusage: kiwoom_client.py {token [label] | accounts | balance [label] | positions [label] | summary | quote <name|code> | resolve <name|code> | refresh-codes | journal [label] [YYYYMMDD] | cashflow [label] [YYYYMMDD] | open [label] [code]}', file=sys.stderr)
    return 2


if __name__ == '__main__':
    sys.exit(main())
