Skill: tgparkk/kis-trading (version 1.0.0)

--- name: kis-trading description: "한국투자증권(KIS) Open API를 이용한 국내 주식 트레이딩. 잔고 조회, 시세 확인, 매수/매도 주문, 매매 내역, 시장 개황 등. | Korean stock trading via KIS (Korea Investment & Securities) Open API. Balance, quotes, buy/sell orders, trade history, market overview." metadata: openclaw: emoji: "📈" requires: bins: ["python3"] pip: ["requests"] config_keys: - KIS_APP_KEY - KIS_APP_SECRET - KIS_ACCOUNT_NO - KIS_BASE_URL --- # KIS 주식 트레이딩 한국투자증권 Open API를 통한 국내 주식 매매 스킬. Korean stock trading skill using KIS (Korea Investment & Securities) Open API. Supports balance inquiry, real-time quotes, buy/sell orders, trade history, and market overview for stocks listed on KRX (KOSPI/KOSDAQ). ## 설정 config 파일(`~/.kis-trading/config.ini`)에 아래 값을 설정: ```ini [KIS] APP_KEY = your_app_key APP_SECRET = your_app_secret ACCOUNT_NO = 12345678-01 BASE_URL = https://openapi.koreainvestment.com:9443 # 모의투자: https://openapivts.koreainvestment.com:29443 ``` 설정 확인: ```bash python3 scripts/setup.py --config ~/.kis-trading/config.ini --check ``` ## 잔고 조회 "잔고 보여줘", "계좌 잔고", "예수금", "매수 가능 금액" ```bash python3 scripts/balance.py --config ~/.kis-trading/config.ini ``` ## 보유 종목 "보유 종목", "내 주식", "수익률" ```bash python3 scripts/holdings.py --config ~/.kis-trading/config.ini ``` ## 종목 시세 "삼성전자 현재가", "005930 시세", "카카오 주가" ```bash python3 scripts/quote.py --config ~/.kis-trading/config.ini --code 005930 python3 scripts/quote.py --config ~/.kis-trading/config.ini --name 삼성전자 ``` ## 매수/매도 주문 "삼성전자 10주 매수", "카카오 5주 매도" ⚠️ **주문은 반드시 사용자에게 확인을 받은 후 실행할 것!** ```bash # 시장가 매수 python3 scripts/order.py --config ~/.kis-trading/config.ini --side buy --code 005930 --qty 10 --market # 지정가 매수 python3 scripts/order.py --config ~/.kis-trading/config.ini --side buy --code 005930 --qty 10 --price 70000 # 매도 python3 scripts/order.py --config ~/.kis-trading/config.ini --side sell --code 005930 --qty 10 --market ``` 주문 전 반드시: 1. 종목명, 수량, 가격을 사용자에게 보여주고 확인 요청 2. `--dry-run` 으로 주문 내용 미리 확인 가능 3. 확인 후 실제 주문 실행 ## 매매 내역 "매매 내역", "오늘 체결 내역", "주문 내역" ```bash python3 scripts/history.py --config ~/.kis-trading/config.ini python3 scripts/history.py --config ~/.kis-trading/config.ini --start 20240101 --end 20240131 ``` ## 시장 개황 "시장 개황", "거래량 상위", "코스피 지수" ```bash python3 scripts/market.py --config ~/.kis-trading/config.ini --action index python3 scripts/market.py --config ~/.kis-trading/config.ini --action volume-rank ``` ## 주의사항 - 실전 투자 시 반드시 BASE_URL을 실전 URL로 설정 - 모의투자와 실전투자의 TR ID가 다를 수 있음 - API 호출은 초당 20건 제한 (자동 제어됨) - 주문은 **절대** 사용자 확인 없이 실행하지 말 것

path: references/kis-api-guide.md
content: # KIS Open API 엔드포인트 레퍼런스

## 기본 정보
- 실전: `https://openapi.koreainvestment.com:9443`
- 모의: `https://openapivts.koreainvestment.com:29443`
- 인증: OAuth2 Bearer Token (`/oauth2/tokenP`)
- 속도 제한: 초당 20건

## 인증
| 엔드포인트 | 메서드 | 설명 |
|---|---|---|
| `/oauth2/tokenP` | POST | 액세스 토큰 발급 |
| `/uapi/hashkey` | POST | 주문용 해시키 발급 |

## 시세 조회 (GET)
| 엔드포인트 | TR ID | 설명 |
|---|---|---|
| [REDACTED_SECRET] | FHKST01010100 | 주식 현재가 시세 |
| [REDACTED_SECRET] | FHKST01010300 | 현재가 체결 (최근 30건) |
| [REDACTED_SECRET] | FHKST01010400 | 일자별 시세 (최근 30일) |
| [REDACTED_SECRET] | FHKST03010100 | 기간별 시세 (일/주/월/년) |
| [REDACTED_SECRET] | FHPUP02100000 | 업종 지수 |
| [REDACTED_SECRET] | FHPST01710000 | 거래량 순위 |

## 계좌 조회 (GET)
| 엔드포인트 | TR ID | 설명 |
|---|---|---|
| [REDACTED_SECRET] | TTTC8434R | 주식 잔고 조회 |
| [REDACTED_SECRET] | TTTC8908R | 매수 가능 조회 |
| [REDACTED_SECRET] | TTTC0081R | 일별 주문체결 (3개월 이내) |
| [REDACTED_SECRET] | CTSC9215R | 일별 주문체결 (3개월 이전) |
| [REDACTED_SECRET] | TTTC8494R | 실현손익 조회 |
| [REDACTED_SECRET] | TTTC8708R | 기간별 손익 |

## 주문 (POST)
| 엔드포인트 | TR ID | 설명 |
|---|---|---|
| [REDACTED_SECRET] | TTTC0012U | 현금 매수 |
| [REDACTED_SECRET] | TTTC0011U | 현금 매도 |
| [REDACTED_SECRET] | TTTC0013U | 정정/취소 |

### 모의투자 TR ID
| 실전 | 모의 | 설명 |
|---|---|---|
| TTTC0012U | VTTC0802U | 매수 |
| TTTC0011U | VTTC0801U | 매도 |

## 주문구분 코드 (ORD_DVSN)
- `00`: 지정가
- `01`: 시장가
- `02`: 조건부지정가
- `03`: 최유리지정가
- `04`: 최우선지정가

## KRX 호가단위
| 가격 범위 | 호가단위 |
|---|---|
| ~1,000원 | 1원 |
| ~5,000원 | 5원 |
| ~10,000원 | 10원 |
| ~50,000원 | 50원 |
| ~100,000원 | 100원 |
| ~500,000원 | 500원 |
| 500,000원~ | 1,000원 |

## 공통 헤더
```
Content-Type: application/json; charset=utf-8
authorization: Bearer {access_token}
appkey: {APP_KEY}
appsecret: {APP_SECRET}
tr_id: {TR_ID}
custtype: P
```

## 페이징
- `tr_cont`: 연속 거래 키 (D/E: 마지막, F/M: 다음 페이지 존재)
- `CTX_AREA_FK100`, `CTX_AREA_NK100`: 연속 조회 키


path: scripts/balance.py
content: #!/usr/bin/env python3
"""계좌 잔고 조회"""
from typing import Optional, Dict
import argparse
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from kis_common import load_config, get_token, api_get, fmt_price, fmt_rate, add_common_args, safe_int


def get_balance(cfg: dict, token: str) -> Optional[dict]:
    """계좌 잔고 조회"""
    params = {
        "CANO": cfg['account_no'],
        "ACNT_PRDT_CD": cfg['product_code'],
        "AFHR_FLPR_YN": "N",
        "OFL_YN": "",
        "INQR_DVSN": "00",
        "UNPR_DVSN": "01",
        "FUND_STTL_ICLD_YN": "N",
        "FNCG_AMT_AUTO_RDPT_YN": "N",
        "PRCS_DVSN": "00",
        "CTX_AREA_FK100": "",
        "CTX_AREA_NK100": "",
    }
    return api_get(cfg, token, [REDACTED_SECRET], 'TTTC8434R', params)


def main():
    parser = argparse.ArgumentParser(description='계좌 잔고 조회')
    add_common_args(parser)
    args = parser.parse_args()

    cfg = load_config(args.config)
    [REDACTED_SECRET])
    data = get_balance(cfg, token)

    if not data:
        sys.exit(1)

    # 계좌 요약 (output2)
    summary = data.get('output2', [{}])
    if isinstance(summary, list):
        summary = summary[0] if summary else {}

    print("💰 계좌 잔고")
    print(f"  예수금총액: {fmt_price(summary.get('dnca_tot_amt', 0))}")
    print(f"  익일정산금액: {fmt_price(summary.get('nxdy_excc_amt', 0))}")
    print(f"  가수도정산금액(매수가능): {fmt_price(summary.get('prvs_rcdl_excc_amt', 0))}")
    print(f"  총평가금액: {fmt_price(summary.get('tot_evlu_amt', 0))}")
    print(f"  평가손익합계: {fmt_price(summary.get('evlu_pfls_smtl_amt', 0))}")
    print(f"  매입금액합계: {fmt_price(summary.get('pchs_amt_smtl_amt', 0))}")
    print(f"  평가금액합계: {fmt_price(summary.get('evlu_amt_smtl_amt', 0))}")

    # 보유 종목 수
    holdings = data.get('output1', [])
    active = [h for h in holdings if int(str(h.get('hldg_qty', 0)).replace(',', '') or 0) > 0]
    print(f"  보유종목 수: {len(active)}개")


if __name__ == '__main__':
    main()


path: scripts/history.py
content: #!/usr/bin/env python3
"""매매 내역 조회"""
from typing import Optional, Dict
import argparse
import sys
import os
from datetime import datetime

sys.path.insert(0, os.path.dirname(__file__))
from kis_common import load_config, get_token, api_get, fmt_price, fmt_num, add_common_args, safe_int


def get_daily_orders(cfg: dict, token: str, start: str, end: str) -> list:
    """일별 주문체결 조회 (페이지네이션 포함)"""
    all_orders = []
    ctx_fk = ""
    ctx_nk = ""

    while True:
        params = {
            "CANO": cfg['account_no'],
            "ACNT_PRDT_CD": cfg['product_code'],
            "INQR_STRT_DT": start,
            "INQR_END_DT": end,
            "SLL_BUY_DVSN_CD": "00",
            "INQR_DVSN": "01",
            "PDNO": "",
            "CCLD_DVSN": "00",
            "ORD_GNO_BRNO": "",
            "ODNO": "",
            "INQR_DVSN_3": "00",
            "INQR_DVSN_1": "",
            "CTX_AREA_FK100": ctx_fk,
            "CTX_AREA_NK100": ctx_nk,
        }
        data = api_get(cfg, token, [REDACTED_SECRET], 'TTTC0081R', params)
        if not data:
            break

        items = data.get('output1', [])
        all_orders.extend(items)

        tr_cont = data.get('_tr_cont', '')
        if tr_cont in ('F', 'M') and items:
            ctx_fk = data.get('ctx_area_fk100', data.get('CTX_AREA_FK100', ''))
            ctx_nk = data.get('ctx_area_nk100', data.get('CTX_AREA_NK100', ''))
            if not ctx_fk and not ctx_nk:
                break
        else:
            break

    return all_orders


def main():
    parser = argparse.ArgumentParser(description='매매 내역 조회')
    add_common_args(parser)
    today = datetime.today().strftime('%Y%m%d')
    parser.add_argument('--start', default=today, help='조회 시작일 (YYYYMMDD, 기본: 오늘)')
    parser.add_argument('--end', default=today, help='조회 종료일 (YYYYMMDD, 기본: 오늘)')
    args = parser.parse_args()

    cfg = load_config(args.config)
    [REDACTED_SECRET])
    orders = get_daily_orders(cfg, token, args.start, args.end)

    if not orders:
        print(f"📋 매매 내역 없음 ({args.start} ~ {args.end})")
        return

    print(f"📋 매매 내역 ({args.start} ~ {args.end}, {len(orders)}건)")
    print()

    for o in orders:
        name = o.get('prdt_name', o.get('pdno', '???'))
        side = o.get('sll_buy_dvsn_cd_name', o.get('sll_buy_dvsn_cd', ''))
        ord_qty = safe_int(o.get('ord_qty'))
        ccld_qty = safe_int(o.get('tot_ccld_qty'))
        ord_price = safe_int(o.get('ord_unpr'))
        ccld_price = safe_int(o.get('avg_prvs'))
        order_time = o.get('ord_tmd', '')
        status = '체결' if ccld_qty > 0 else '미체결'

        emoji = '🟢' if '매수' in side else '🔴' if '매도' in side else '⚪'
        time_str = f"{order_time[:2]}:{order_time[2:4]}:{order_time[4:6]}" if len(order_time) >= 6 else order_time

        print(f"{emoji} {name} | {side} | {status}")
        print(f"   주문 {fmt_num(ord_qty)}주 @ {fmt_price(ord_price)} | 체결 {fmt_num(ccld_qty)}주 @ {fmt_price(ccld_price)} | {time_str}")
        print()


if __name__ == '__main__':
    main()


path: scripts/holdings.py
content: #!/usr/bin/env python3
"""보유 종목 + 수익률 조회"""
from typing import Optional, Dict
import argparse
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from kis_common import load_config, get_token, api_get, fmt_price, fmt_rate, fmt_num, add_common_args, safe_int, safe_float


def get_holdings(cfg: dict, token: str) -> list:
    """보유 종목 조회 (페이지네이션 포함)"""
    all_holdings = []
    ctx_fk = ""
    ctx_nk = ""

    while True:
        params = {
            "CANO": cfg['account_no'],
            "ACNT_PRDT_CD": cfg['product_code'],
            "AFHR_FLPR_YN": "N",
            "OFL_YN": "",
            "INQR_DVSN": "00",
            "UNPR_DVSN": "01",
            "FUND_STTL_ICLD_YN": "N",
            "FNCG_AMT_AUTO_RDPT_YN": "N",
            "PRCS_DVSN": "00",
            "CTX_AREA_FK100": ctx_fk,
            "CTX_AREA_NK100": ctx_nk,
        }
        data = api_get(cfg, token, [REDACTED_SECRET], 'TTTC8434R', params)
        if not data:
            break

        items = data.get('output1', [])
        all_holdings.extend(items)

        # 연속조회 확인 (F/M = 다음 페이지 있음)
        tr_cont = data.get('_tr_cont', '')
        if tr_cont in ('F', 'M') and items:
            ctx_fk = data.get('ctx_area_fk100', data.get('CTX_AREA_FK100', ''))
            ctx_nk = data.get('ctx_area_nk100', data.get('CTX_AREA_NK100', ''))
            if not ctx_fk and not ctx_nk:
                break
        else:
            break

    return all_holdings, data


def main():
    parser = argparse.ArgumentParser(description='보유 종목 및 수익률 조회')
    add_common_args(parser)
    args = parser.parse_args()

    cfg = load_config(args.config)
    [REDACTED_SECRET])
    holdings_list, last_data = get_holdings(cfg, token)

    if last_data is None:
        sys.exit(1)

    active = [h for h in holdings_list if safe_int(h.get('hldg_qty')) > 0]

    if not active:
        print("📊 보유 종목 없음")
        return

    print(f"📊 보유 종목 ({len(active)}개)")
    print()

    total_purchase = 0
    total_eval = 0
    total_pl = 0

    for h in active:
        name = h.get('prdt_name', '???')
        code = h.get('pdno', '')
        qty = safe_int(h.get('hldg_qty'))
        avg_price = safe_float(h.get('pchs_avg_pric'))
        cur_price = safe_int(h.get('prpr'))
        eval_amt = safe_int(h.get('evlu_amt'))
        pl_amt = safe_int(h.get('evlu_pfls_amt'))
        pl_rate = safe_float(h.get('evlu_pfls_rt'))

        total_purchase += safe_int(h.get('pchs_amt'))
        total_eval += eval_amt
        total_pl += pl_amt

        emoji = '🔴' if pl_amt < 0 else '🟢' if pl_amt > 0 else '⚪'
        print(f"{emoji} {name} ({code})")
        print(f"   {fmt_num(qty)}주 | 평균 {fmt_price(int(avg_price))} → 현재 {fmt_price(cur_price)}")
        print(f"   평가 {fmt_price(eval_amt)} | 손익 {fmt_price(pl_amt)} ({fmt_rate(pl_rate)})")
        print()

    print("─" * 40)
    total_rate = (total_pl / total_purchase * 100) if total_purchase > 0 else 0
    emoji = '🔴' if total_pl < 0 else '🟢' if total_pl > 0 else '⚪'
    print(f"{emoji} 합계: 매입 {fmt_price(total_purchase)} | 평가 {fmt_price(total_eval)} | 손익 {fmt_price(total_pl)} ({fmt_rate(total_rate)})")


if __name__ == '__main__':
    main()


path: scripts/kis_common.py
content: """
KIS API 공통 모듈 - 인증, 설정, API 호출
"""
import os
import sys
import json
import time
import configparser
import requests
from datetime import datetime
from typing import Optional, Dict

# API 호출 속도 제한
_last_api_call_time = None
_MIN_API_INTERVAL = 0.06  # 60ms (초당 ~16건, KIS 제한: 20건)

def load_config(config_path: str) -> dict:
    """설정 파일 로드"""
    config_path = os.path.expanduser(config_path)
    if not os.path.exists(config_path):
        print(f"❌ 설정 파일을 찾을 수 없습니다: {config_path}")
        print(f"📝 설정 파일을 생성하세요:")
        print(f"   mkdir -p ~/.kis-trading")
        print(f"   cp config.ini.example ~/.kis-trading/config.ini")
        sys.exit(1)

    cp = configparser.ConfigParser()
    cp.read(config_path, encoding='utf-8')

    if 'KIS' not in cp:
        print("❌ 설정 파일에 [KIS] 섹션이 없습니다.")
        sys.exit(1)

    section = cp['KIS']
    required = ['APP_KEY', 'APP_SECRET', 'ACCOUNT_NO']
    for key in required:
        if not section.get(key):
            print(f"❌ 설정값 누락: {key}")
            sys.exit(1)

    acct = section['ACCOUNT_NO'].replace('-', '')
    return {
        'app_key': section['APP_KEY'],
        'app_secret': section['APP_SECRET'],
        'account_no': acct[:8],
        'product_code': acct[8:10] if len(acct) >= 10 else '01',
        'base_url': section.get('BASE_URL', 'https://openapi.koreainvestment.com:9443'),
    }


# 토큰 캐시
_token_cache = {'token': None, 'expired': None}
_TOKEN_FILE = os.path.expanduser('~/.kis-trading/token.json')


def _save_token(token: str, expired: str):
    """토큰 파일 저장 (권한 600)"""
    os.makedirs(os.path.dirname(_TOKEN_FILE), exist_ok=True)
    with open(_TOKEN_FILE, 'w') as f:
        json.dump({'token': token, 'expired': expired}, f)
    try:
        os.chmod(_TOKEN_FILE, 0o600)
    except OSError:
        pass


def _load_token() -> Optional[str]:
    """토큰 파일 로드 (유효한 경우만)"""
    try:
        with open(_TOKEN_FILE) as f:
            data = json.load(f)
        if data.get('expired') and datetime.strptime(data['expired'], '%Y-%m-%d %H:%M:%S') > datetime.now():
            return data['token']
    except:
        pass
    return None


def get_token(cfg: dict) -> str:
    """액세스 토큰 발급/캐시"""
    # 캐시 확인
    cached = _load_token()
    if cached:
        return cached

    url = f"{cfg['base_url']}/oauth2/tokenP"
    body = {
        "grant_type": "client_credentials",
        "appkey": cfg['app_key'],
        "appsecret": cfg['app_secret'],
    }
    headers = {"Content-Type": "application/json"}

    resp = requests.post(url, json=body, headers=headers, timeout=10)
    if resp.status_code != 200:
        print(f"❌ 토큰 발급 실패: {resp.status_code} {resp.text}")
        sys.exit(1)

    result = resp.json()
    [REDACTED_SECRET]'access_token']
    expired = result['access_token_token_expired']
    _save_token(token, expired)
    return token


def _wait_rate_limit():
    """API 호출 속도 제한"""
    global _last_api_call_time
    now = time.time()
    if _last_api_call_time and (now - _last_api_call_time) < _MIN_API_INTERVAL:
        time.sleep(_MIN_API_INTERVAL - (now - _last_api_call_time))
    _last_api_call_time = time.time()


def api_get(cfg: dict, token: str, path: str, tr_id: str, params: dict,
            _retried: bool = False) -> Optional[dict]:
    """GET API 호출 (토큰 만료 시 자동 재발급)"""
    _wait_rate_limit()
    tr_id = resolve_tr_id(cfg, tr_id)
    url = f"{cfg['base_url']}{path}"
    headers = {
        "Content-Type": "application/json; charset=utf-8",
        "authorization": f"Bearer {token}",
        "appkey": cfg['app_key'],
        "appsecret": cfg['app_secret'],
        "tr_id": tr_id,
        "custtype": "P",
    }
    resp = requests.get(url, headers=headers, params=params, timeout=10)
    if resp.status_code != 200:
        print(f"❌ API 오류: {resp.status_code} {resp.text[:200]}")
        return None
    data = resp.json()
    # 토큰 만료 감지 → 재발급 후 재시도
    if data.get('msg_cd') in ('EGW00123', 'EGW00121') and not _retried:
        new_token = _force_refresh_token(cfg)
        return api_get(cfg, new_token, path, tr_id, params, _retried=True)
    if data.get('rt_cd') != '0':
        print(f"❌ API 오류: [{data.get('msg_cd')}] {data.get('msg1')}")
        return None
    # 연속조회 키를 data에 포함
    data['_tr_cont'] = resp.headers.get('tr_cont', '')
    return data


def api_post(cfg: dict, token: str, path: str, tr_id: str, body: dict,
             use_hashkey: bool = True, _retried: bool = False) -> Optional[dict]:
    """POST API 호출 (토큰 만료 시 자동 재발급)"""
    _wait_rate_limit()
    tr_id = resolve_tr_id(cfg, tr_id)
    url = f"{cfg['base_url']}{path}"
    headers = {
        "Content-Type": "application/json; charset=utf-8",
        "authorization": f"Bearer {token}",
        "appkey": cfg['app_key'],
        "appsecret": cfg['app_secret'],
        "tr_id": tr_id,
        "custtype": "P",
    }

    # 해시키 설정
    if use_hashkey:
        try:
            hk_resp = requests.post(
                f"{cfg['base_url']}/uapi/hashkey",
                headers=headers, json=body, timeout=5
            )
            if hk_resp.status_code == 200:
                headers['hashkey'] = hk_resp.json().get('HASH', '')
        except:
            pass

    resp = requests.post(url, headers=headers, json=body, timeout=10)
    if resp.status_code != 200:
        print(f"❌ API 오류: {resp.status_code} {resp.text[:200]}")
        return None
    data = resp.json()
    # 토큰 만료 감지 → 재발급 후 재시도
    if data.get('msg_cd') in ('EGW00123', 'EGW00121') and not _retried:
        new_token = _force_refresh_token(cfg)
        return api_post(cfg, new_token, path, tr_id, body, use_hashkey, _retried=True)
    if data.get('rt_cd') != '0':
        print(f"❌ API 오류: [{data.get('msg_cd')}] {data.get('msg1')}")
        return None
    return data


def safe_int(v, default=0):
    """문자열→int 변환 (실패 시 default)"""
    try:
        return int(str(v).replace(',', ''))
    except:
        return default


def safe_float(v, default=0.0):
    """문자열→float 변환 (실패 시 default)"""
    try:
        return float(str(v).replace(',', ''))
    except:
        return default


# 모의투자 TR ID 매핑 (실전 → 모의)
VIRTUAL_TR_ID_MAP = {
    # 주문
    'TTTC0012U': 'VTTC0802U',  # 매수
    'TTTC0011U': 'VTTC0801U',  # 매도
    # 잔고/보유종목 조회
    'TTTC8434R': 'VTTC8434R',
    # 일별 주문체결 조회
    'TTTC0081R': 'VTTC0081R',  # 모의투자 TR ID 동일 패턴
}


def resolve_tr_id(cfg: dict, tr_id: str) -> str:
    """모의투자 환경이면 TR ID를 모의투자용으로 변환"""
    if 'openapivts' in cfg.get('base_url', ''):
        return VIRTUAL_TR_ID_MAP.get(tr_id, tr_id)
    return tr_id


def _force_refresh_token(cfg: dict) -> str:
    """토큰 강제 재발급"""
    # 캐시 파일 삭제
    try:
        os.remove(_TOKEN_FILE)
    except OSError:
        pass
    return get_token(cfg)


def fmt_num(n, suffix='') -> str:
    """숫자 포맷 (천단위 쉼표)"""
    try:
        v = int(str(n).replace(',', ''))
        return f"{v:,}{suffix}"
    except:
        return str(n) + suffix


def fmt_rate(n) -> str:
    """수익률 포맷"""
    try:
        v = float(str(n).replace(',', ''))
        sign = '+' if v > 0 else ''
        return f"{sign}{v:.2f}%"
    except:
        return str(n)


def fmt_price(n) -> str:
    """가격 포맷"""
    return fmt_num(n, '원')


def get_stock_name_from_api(cfg: dict, token: str, code: str) -> str:
    """종목명 조회 (search-stock-info API)"""
    params = {"PRDT_TYPE_CD": "300", "PDNO": code}
    data = api_get(cfg, token, [REDACTED_SECRET], 'CTPF1002R', params)
    if data:
        out = data.get('output', {})
        return out.get('prdt_abrv_name', out.get('prdt_name', code))
    return code


def add_common_args(parser):
    """공통 인자 추가"""
    parser.add_argument('--config', '-c', default='~/.kis-trading/config.ini',
                        help='설정 파일 경로 (기본: ~/.kis-trading/config.ini)')


path: scripts/market.py
content: #!/usr/bin/env python3
"""시장 개황 (지수, 거래량 상위 등)"""
from typing import Optional, Dict
import argparse
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from kis_common import load_config, get_token, api_get, fmt_price, fmt_rate, fmt_num, add_common_args, safe_int, safe_float


def get_index(cfg: dict, token: str, index_code: str) -> Optional[dict]:
    """업종 지수 조회"""
    params = {
        "FID_COND_MRKT_DIV_CODE": "U",
        "FID_INPUT_ISCD": index_code,
    }
    return api_get(cfg, token, [REDACTED_SECRET], 'FHPUP02100000', params)


def get_volume_rank(cfg: dict, token: str, market: str = "0000") -> Optional[dict]:
    """거래량 순위 조회"""
    params = {
        "FID_COND_MRKT_DIV_CODE": "J",
        "FID_COND_SCR_DIV_CODE": "20171",
        "FID_INPUT_ISCD": market,  # 0000:전체, 0001:거래소, 1001:코스닥
        "FID_DIV_CLS_CODE": "0",
        "FID_BLNG_CLS_CODE": "0",  # 0:평균거래량
        "FID_TRGT_CLS_CODE": "111111111",
        "FID_TRGT_EXLS_CLS_CODE": "0000000000",
        "FID_INPUT_PRICE_1": "",
        "FID_INPUT_PRICE_2": "",
        "FID_VOL_CNT": "",
        "FID_INPUT_DATE_1": "",
    }
    return api_get(cfg, token, [REDACTED_SECRET], 'FHPST01710000', params)


def show_index(cfg: dict, token: str):
    """코스피/코스닥 지수 출력"""
    for name, code in [('코스피', '0001'), ('코스닥', '1001')]:
        data = get_index(cfg, token, code)
        if not data:
            continue
        out = data.get('output', {})
        if isinstance(out, list):
            out = out[0] if out else {}

        idx_val = out.get('bstp_nmix_prpr', '0')
        change = out.get('bstp_nmix_prdy_vrss', '0')
        rate = out.get('bstp_nmix_prdy_ctrt', '0')
        vol = out.get('acml_vol', '0')
        sign = out.get('prdy_vrss_sign', '3')

        emoji = {'1': '🔺', '2': '🔼', '4': '🔻', '5': '🔽'}.get(sign, '➡️')
        print(f"{emoji} {name}: {idx_val} ({'+' if safe_float(change) >= 0 else ''}{change}, {fmt_rate(rate)})")
        print(f"   거래량: {fmt_num(vol)}주")


def show_volume_rank(cfg: dict, token: str, limit: int = 15):
    """거래량 상위 종목 출력"""
    data = get_volume_rank(cfg, token)
    if not data:
        return

    items = data.get('output', [])
    if not items:
        print("📊 거래량 데이터 없음")
        return

    print(f"📊 거래량 상위 종목 (상위 {min(limit, len(items))}개)")
    print()

    for i, item in enumerate(items[:limit], 1):
        name = item.get('hts_kor_isnm', '???')
        code = item.get('mksc_shrn_iscd', '')
        price = safe_int(item.get('stck_prpr'))
        change_rate = safe_float(item.get('prdy_ctrt'))
        volume = safe_int(item.get('acml_vol'))
        sign = item.get('prdy_vrss_sign', '3')

        emoji = {'1': '🔺', '2': '🔼', '4': '🔻', '5': '🔽'}.get(sign, '➡️')
        print(f"  {i:2d}. {emoji} {name} ({code}) {fmt_price(price)} ({fmt_rate(change_rate)}) 거래량 {fmt_num(volume)}")


def main():
    parser = argparse.ArgumentParser(description='시장 개황 조회')
    add_common_args(parser)
    parser.add_argument('--action', '-a', default='all',
                        choices=['all', 'index', 'volume-rank'],
                        help='조회 항목 (기본: all)')
    parser.add_argument('--limit', type=int, default=15, help='거래량 순위 표시 개수 (기본: 15)')
    args = parser.parse_args()

    cfg = load_config(args.config)
    [REDACTED_SECRET])

    if args.action in ('all', 'index'):
        print("📈 시장 지수")
        show_index(cfg, token)
        print()

    if args.action in ('all', 'volume-rank'):
        show_volume_rank(cfg, token, args.limit)


if __name__ == '__main__':
    main()


path: scripts/order.py
content: #!/usr/bin/env python3
"""매수/매도 주문 (확인 필수)"""
from typing import Optional, Dict
import argparse
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from kis_common import load_config, get_token, api_post, api_get, fmt_price, fmt_num, add_common_args, get_stock_name_from_api, resolve_tr_id


def get_stock_name(cfg: dict, token: str, code: str) -> str:
    """종목명 조회"""
    return get_stock_name_from_api(cfg, token, code)


def round_to_tick(price: int) -> int:
    """KRX 호가단위로 반올림"""
    if price < 1000: tick = 1
    elif price < 5000: tick = 5
    elif price < 10000: tick = 10
    elif price < 50000: tick = 50
    elif price < 100000: tick = 100
    elif price < 500000: tick = 500
    else: tick = 1000
    return int(round(price / tick) * tick)


def place_order(cfg: dict, token: str, side: str, code: str, qty: int,
                price: int = 0, market: bool = False) -> Optional[dict]:
    """주문 실행"""
    # TR ID는 api_post에서 resolve_tr_id로 자동 변환
    tr_id = "TTTC0012U" if side == 'buy' else "TTTC0011U"

    ord_dvsn = "01" if market else "00"  # 01:시장가, 00:지정가

    if not market and price > 0:
        price = round_to_tick(price)

    body = {
        "CANO": cfg['account_no'],
        "ACNT_PRDT_CD": cfg['product_code'],
        "PDNO": code,
        "ORD_DVSN": ord_dvsn,
        "ORD_QTY": str(qty),
        "ORD_UNPR": str(price if not market else 0),
    }

    return api_post(cfg, token, '/uapi/domestic-stock/v1/trading/order-cash', tr_id, body)


def main():
    parser = argparse.ArgumentParser(description='매수/매도 주문')
    add_common_args(parser)
    parser.add_argument('--side', required=True, choices=['buy', 'sell'], help='매수(buy) 또는 매도(sell)')
    parser.add_argument('--code', required=True, help='종목코드 (6자리)')
    parser.add_argument('--qty', required=True, type=int, help='주문 수량')
    parser.add_argument('--price', type=int, default=0, help='주문 가격 (지정가)')
    parser.add_argument('--market', action='store_true', help='시장가 주문')
    parser.add_argument('--dry-run', action='store_true', help='주문 내용만 확인 (실제 주문 안함)')
    args = parser.parse_args()

    if args.qty <= 0:
        print("❌ 주문 수량은 1 이상이어야 합니다.")
        sys.exit(1)

    if not args.market and args.price <= 0:
        print("❌ 지정가 주문 시 --price를 입력하거나, --market으로 시장가 주문하세요.")
        sys.exit(1)

    cfg = load_config(args.config)
    [REDACTED_SECRET])

    # 종목명 조회
    name = get_stock_name(cfg, token, args.code)
    side_str = '매수' if args.side == 'buy' else '매도'
    price_str = '시장가' if args.market else fmt_price(round_to_tick(args.price))

    print(f"📋 주문 확인")
    print(f"  {'🟢 매수' if args.side == 'buy' else '🔴 매도'}: {name} ({args.code})")
    print(f"  수량: {fmt_num(args.qty)}주")
    print(f"  가격: {price_str}")

    if args.dry_run:
        print(f"\n✅ 드라이런 완료 (실제 주문되지 않음)")
        return

    print(f"\n⚠️  위 내용으로 {side_str} 주문을 실행합니다.")

    result = place_order(cfg, token, args.side, args.code, args.qty, args.price, args.market)

    if result:
        out = result.get('output', {})
        order_no = out.get('ODNO', out.get('odno', ''))
        print(f"\n✅ {side_str} 주문 완료!")
        print(f"  주문번호: {order_no}")
        print(f"  주문시각: {out.get('ORD_TMD', out.get('ord_tmd', ''))}")
    else:
        print(f"\n❌ {side_str} 주문 실패")
        sys.exit(1)


if __name__ == '__main__':
    main()


path: scripts/quote.py
content: #!/usr/bin/env python3
"""종목 시세 조회"""
from typing import Optional, Dict
import argparse
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from kis_common import load_config, get_token, api_get, fmt_price, fmt_rate, fmt_num, add_common_args, get_stock_name_from_api, safe_int, safe_float

# 주요 종목 이름→코드 매핑 (자주 검색하는 종목)
STOCK_NAME_MAP = {
    '삼성전자': '005930', '삼성전자우': '005935',
    'SK하이닉스': '000660', 'LG에너지솔루션': '373220',
    '삼성바이오로직스': '207940', '현대차': '005380', '현대자동차': '005380',
    '기아': '000270', 'NAVER': '035420', '네이버': '035420',
    '카카오': '035720', '셀트리온': '068270',
    'LG화학': '051910', 'POSCO홀딩스': '005490', '포스코홀딩스': '005490',
    '삼성SDI': '006400', '카카오뱅크': '323410',
    '삼성물산': '028260', '한국전력': '015760',
    'KB금융': '105560', '신한지주': '055550', '하나금융지주': '086790',
    'SK이노베이션': '096770', '두산에너빌리티': '034020',
    '카카오페이': '377300', '크래프톤': '259960',
    '엔씨소프트': '036570', '넷마블': '251270',
    'HD현대중공업': '329180', 'HD한국조선해양': '009540',
    '한화오션': '042660', '대한항공': '003490',
    'HLB': '028300', '에코프로': '086520', '에코프로비엠': '247540',
    '포스코퓨처엠': '003670', '알테오젠': '196170',
}


# 코드→이름 역매핑
STOCK_CODE_MAP = {v: k for k, v in STOCK_NAME_MAP.items()}


def get_stock_name_by_code(code: str) -> Optional[str]:
    """종목코드→이름 변환 (내장 맵 사용)"""
    return STOCK_CODE_MAP.get(code)


def get_quote(cfg: dict, token: str, code: str) -> Optional[dict]:
    """현재가 조회"""
    params = {
        "FID_COND_MRKT_DIV_CODE": "J",
        "FID_INPUT_ISCD": code,
    }
    return api_get(cfg, token, [REDACTED_SECRET], 'FHKST01010100', params)


def resolve_code(name: str) -> Optional[str]:
    """종목명→코드 변환"""
    # 정확히 일치
    if name in STOCK_NAME_MAP:
        return STOCK_NAME_MAP[name]
    # 부분 일치
    for k, v in STOCK_NAME_MAP.items():
        if name in k or k in name:
            return v
    return None


def main():
    parser = argparse.ArgumentParser(description='종목 시세 조회')
    add_common_args(parser)
    parser.add_argument('--code', help='종목코드 (6자리)')
    parser.add_argument('--name', help='종목명 (예: 삼성전자)')
    args = parser.parse_args()

    if not args.code and not args.name:
        print("❌ --code 또는 --name 중 하나를 입력하세요.")
        parser.print_help()
        sys.exit(1)

    code = args.code
    if args.name:
        code = resolve_code(args.name)
        if not code:
            print(f"❌ '{args.name}'에 해당하는 종목을 찾을 수 없습니다.")
            print("💡 종목코드를 직접 입력하세요: --code 005930")
            sys.exit(1)

    cfg = load_config(args.config)
    [REDACTED_SECRET])
    data = get_quote(cfg, token, code)

    if not data:
        sys.exit(1)

    out = data.get('output', {})
    name = get_stock_name_by_code(code) or get_stock_name_from_api(cfg, token, code)
    cur_price = safe_int(out.get('stck_prpr'))
    change = safe_int(out.get('prdy_vrss'))
    change_rate = safe_float(out.get('prdy_ctrt'))
    volume = safe_int(out.get('acml_vol'))
    trade_amt = safe_int(out.get('acml_tr_pbmn'))
    high = safe_int(out.get('stck_hgpr'))
    low = safe_int(out.get('stck_lwpr'))
    open_p = safe_int(out.get('stck_oprc'))
    prev_close = safe_int(out.get('stck_sdpr'))
    market_cap = safe_int(out.get('hts_avls'))  # 시가총액(억원)

    sign = out.get('prdy_vrss_sign', '3')
    emoji = {'1': '🔺', '2': '🔼', '4': '🔻', '5': '🔽'}.get(sign, '➡️')

    print(f"{emoji} {name} ({code})")
    print(f"  현재가: {fmt_price(cur_price)} ({'+' if change >= 0 else ''}{fmt_num(change)}원, {fmt_rate(change_rate)})")
    print(f"  시가: {fmt_price(open_p)} | 고가: {fmt_price(high)} | 저가: {fmt_price(low)}")
    print(f"  전일종가: {fmt_price(prev_close)}")
    print(f"  거래량: {fmt_num(volume)}주 | 거래대금: {fmt_num(trade_amt // 1_000_000)}백만원")
    if market_cap:
        print(f"  시가총액: {fmt_num(market_cap)}억원")


if __name__ == '__main__':
    main()


path: scripts/setup.py
content: #!/usr/bin/env python3
"""KIS 트레이딩 설정 확인 및 토큰 발급"""
import argparse
import sys
import os

sys.path.insert(0, os.path.dirname(__file__))
from kis_common import load_config, get_token, add_common_args


def main():
    parser = argparse.ArgumentParser(description='KIS 트레이딩 설정 확인 및 인증 테스트')
    add_common_args(parser)
    parser.add_argument('--check', action='store_true', help='설정값 확인만 (API 호출 없음)')
    args = parser.parse_args()

    cfg = load_config(args.config)

    print("📋 설정 확인")
    print(f"  APP_KEY: {cfg['app_key'][:8]}...")
    print(f"  계좌번호: {cfg['account_no']}-{cfg['product_code']}")
    print(f"  BASE_URL: {cfg['base_url']}")

    is_demo = 'openapivts' in cfg['base_url']
    print(f"  모드: {'🧪 모의투자' if is_demo else '💰 실전투자'}")

    if args.check:
        print("\n✅ 설정값 정상")
        return

    print("\n🔑 토큰 발급 테스트...")
    [REDACTED_SECRET])
    print(f"✅ 토큰 발급 성공: {token[:20]}...")


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)