ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

4
mcp/ls-docs-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.python-version

42
mcp/ls-docs-mcp/README.md Normal file
View File

@@ -0,0 +1,42 @@
# LS증권 OpenAPI Docs MCP
LS증권 OpenAPI TR 스펙(`ls-openapi-spec.json`)을 **조회만** 하는 MCP입니다.
키움 `kiwoom-docs` / 한투 `kis-code-assistant`와 같은 역할(문서 검색)이며,
**실제 API 호출·주문·토큰 발급은 하지 않습니다.**
## Tools
| Tool | 용도 |
|------|------|
| `ls_api_stats` | TR 수·카테고리/프로토콜 분포 |
| `search_ls_api` | 이름/설명/필드 키워드 검색 (예: `분봉`, `t8412`, `조건검색`) |
| `get_ls_api` | trCode 상세(requestIo/responseIo/예제/docsUrl) |
| `list_ls_apis` | category·protocol·url 필터 목록 |
## Cursor 등록
프로젝트 `.cursor/mcp.json``ls-docs`로 등록합니다.
Cursor MCP 재로드 후 도구가 보입니다.
```bash
cd /home/hoon/kis_bot/mcp/ls-docs-mcp
uv sync
uv run python server.py --stdio
```
## 데이터 갱신
공식 포털 공개 API에서 스펙을 받아 `data/ls-openapi-spec.json`을 만듭니다
(REST 시세/주문 호출 아님 — 문서 메타만).
```bash
cd /home/hoon/kis_bot/mcp/ls-docs-mcp
uv run python fetch_spec.py
# 로그: 표준출력. 약 1~3분 (TR~360 + 필드 조회, sleep 포함)
```
## 출처
- 그룹 목록: `GET https://openapi.ls-sec.co.kr/api/apis/public?size=500`
- TR 목록: `GET https://openapi.ls-sec.co.kr/api/apis/guide/tr/{apiId}`
- 필드: `GET https://openapi.ls-sec.co.kr/api/apis/guide/tr/property/{trId}`

View File

@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""LS증권 OpenAPI 포털에서 TR 스펙을 수집해 data/ls-openapi-spec.json 을 만든다.
실제 시세/주문 REST 호출이 아니다. 문서 메타(가이드)만 가져온다.
요청 간격은 포털 부하를 피하기 위해 sleep 한다.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from typing import Any
BASE = "https://openapi.ls-sec.co.kr"
# 포털 API 가이드 URL에 쓰이는 고정 group_id (apiservice 쿼리)
DEFAULT_DOCS_GROUP_ID = "73142d9f-1983-48d2-8543-89b75535d34c"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_OUT = os.path.join(SCRIPT_DIR, "data", "ls-openapi-spec.json")
# propertyType 코드 → 짧은 타입 라벨 (문서 가독용)
TYPE_LABEL = {
"A0001": "string",
"A0003": "object",
"A0004": "number",
"A0005": "array",
}
def _http_get_json(url: str, timeout: float = 45.0) -> Any:
req = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": "kis_bot-ls-docs-mcp/0.1 (docs-fetch-only)",
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _strip_html(text: str) -> str:
if not text:
return ""
t = re.sub(r"<br\s*/?>", "\n", str(text), flags=re.I)
t = re.sub(r"<[^>]+>", "", t)
t = t.replace("&nbsp;", " ").replace("&amp;", "&")
return t.strip()
def _clean_field_code(raw: str) -> str:
s = (raw or "").replace("&nbsp;", " ").strip()
s = re.sub(r"^\s*-\s*", "", s)
return s.strip()
def _category_from_group_name(name: str) -> str:
n = (name or "").strip()
if n.startswith("[") and "]" in n:
major = n[1 : n.index("]")]
minor = n[n.index("]") + 1 :].strip()
if minor:
return f"{major}_{minor}".replace(" ", "")
return major
return n or "기타"
def _parse_example(ex: Any) -> Any:
if ex is None:
return None
if isinstance(ex, (dict, list)):
return ex
if isinstance(ex, str):
s = ex.strip()
if not s:
return None
try:
return json.loads(s)
except Exception:
return s
return ex
def _normalize_props(props: list[dict[str, Any]], body_types: set[str]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for p in props:
if not isinstance(p, dict):
continue
bt = str(p.get("bodyType") or "")
if bt not in body_types:
continue
code = _clean_field_code(str(p.get("propertyCd") or ""))
if not code:
continue
ptype = str(p.get("propertyType") or "")
out.append(
{
"itemId": code,
"itemNm": _strip_html(str(p.get("propertyNm") or "")),
"type": TYPE_LABEL.get(ptype, ptype),
"length": p.get("propertyLength") or "",
"required": str(p.get("requireYn") or ""),
"order": str(p.get("propertyOrder") or ""),
"bodyType": bt,
"desc": _strip_html(str(p.get("description") or "")),
}
)
out.sort(key=lambda x: (x.get("order") or "", x.get("itemId") or ""))
return out
def fetch_all(*, sleep_s: float, out_path: str, docs_group_id: str) -> dict[str, Any]:
list_url = f"{BASE}/api/apis/public?page=0&size=500"
print(f"[fetch] groups: {list_url}", flush=True)
groups = _http_get_json(list_url)
if not isinstance(groups, list):
raise RuntimeError("public API list is not a list")
print(f"[fetch] groups={len(groups)}", flush=True)
time.sleep(sleep_s)
apis: dict[str, Any] = {}
dupes: list[str] = []
errors: list[dict[str, str]] = []
for gi, g in enumerate(groups, 1):
if not isinstance(g, dict):
continue
group_id = str(g.get("id") or "").strip()
group_name = str(g.get("name") or "").strip()
access_url = str(g.get("accessUrl") or "").strip()
method = str(g.get("httpMethod") or "POST").strip() or "POST"
domain = str(g.get("domain") or f"{BASE}:8080").strip()
mock_domain = str(g.get("simulatedDomain") or "").strip()
protocol = str(g.get("protocolType") or "REST").strip() or "REST"
content_type = str(g.get("contentType") or "").strip()
description = _strip_html(str(g.get("description") or ""))
category = _category_from_group_name(group_name)
docs_url = (
f"{BASE}/apiservice?group_id={docs_group_id}&api_id={group_id}"
if group_id
else ""
)
print(f"[fetch] ({gi}/{len(groups)}) {group_name} {group_id}", flush=True)
try:
trs = _http_get_json(f"{BASE}/api/apis/guide/tr/{group_id}")
except Exception as e:
errors.append({"group": group_name, "stage": "tr_list", "error": str(e)})
print(f" ! tr_list fail: {e}", flush=True)
time.sleep(sleep_s)
continue
if not isinstance(trs, list):
errors.append({"group": group_name, "stage": "tr_list", "error": "not a list"})
time.sleep(sleep_s)
continue
time.sleep(sleep_s)
for tr in trs:
if not isinstance(tr, dict):
continue
tr_id = str(tr.get("id") or "").strip()
tr_code = str(tr.get("trCode") or "").strip()
tr_name = str(tr.get("trName") or "").strip()
if not tr_code:
continue
tps = str(tr.get("transactionPerSec") or "").strip()
props: list[dict[str, Any]] = []
if tr_id:
try:
raw_props = _http_get_json(f"{BASE}/api/apis/guide/tr/property/{tr_id}")
if isinstance(raw_props, list):
props = [x for x in raw_props if isinstance(x, dict)]
except Exception as e:
errors.append(
{
"group": group_name,
"tr": tr_code,
"stage": "property",
"error": str(e),
}
)
print(f" ! property fail {tr_code}: {e}", flush=True)
time.sleep(sleep_s)
entry = {
"apiId": tr_code,
"apiNm": tr_name or tr_code,
"trCode": tr_code,
"trName": tr_name,
"trId": tr_id,
"method": method,
"url": access_url,
"domain": domain,
"mockDomain": mock_domain,
"protocolType": protocol,
"format": "JSON",
"contentType": content_type,
"category": category,
"groupName": group_name,
"groupId": group_id,
"transactionPerSec": tps,
"docsUrl": docs_url,
"description": description,
"requestIo": _normalize_props(props, {"req_h", "req_b"}),
"responseIo": _normalize_props(props, {"res_h", "res_b"}),
"requestExample": _parse_example(tr.get("reqExample")),
"responseExample": _parse_example(tr.get("resExample")),
}
store_id = tr_code
if store_id in apis:
# 동일 TR이 여러 그룹에 있으면 groupId로 구분
alt = f"{tr_code}@{group_id[:8]}"
dupes.append(f"{tr_code} -> {alt} ({group_name})")
store_id = alt
entry["apiId"] = store_id
apis[store_id] = entry
print(f" + {tr_code} ({tr_name}) fields={len(props)}", flush=True)
meta = {
"fetchedAt": datetime.now(timezone.utc).isoformat(),
"source": BASE,
"groupCount": len(groups),
"trCount": len(apis),
"docsGroupId": docs_group_id,
"duplicateKeys": dupes,
"errors": errors,
}
out = {"_meta": meta, **apis}
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"[done] wrote {out_path} trs={len(apis)} errors={len(errors)}", flush=True)
return meta
def main() -> int:
p = argparse.ArgumentParser(description="Fetch LS OpenAPI docs catalog")
p.add_argument("--out", default=DEFAULT_OUT, help="output JSON path")
p.add_argument(
"--sleep",
type=float,
default=float(os.environ.get("LS_DOCS_FETCH_SLEEP", "0.12")),
help="seconds between HTTP calls (default 0.12)",
)
p.add_argument(
"--docs-group-id",
default=DEFAULT_DOCS_GROUP_ID,
help="apiservice group_id for docsUrl",
)
args = p.parse_args()
try:
fetch_all(sleep_s=max(0.05, args.sleep), out_path=args.out, docs_group_id=args.docs_group_id)
except urllib.error.URLError as e:
print(f"FATAL: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,9 @@
[project]
name = "ls-docs-mcp"
version = "0.1.0"
description = "LS증권 OpenAPI TR 스펙 조회 전용 MCP (호출 없음, 문서 검색만)"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=2.11.3",
]

417
mcp/ls-docs-mcp/server.py Normal file
View File

@@ -0,0 +1,417 @@
"""LS증권 OpenAPI TR 스펙 조회 전용 MCP.
키움 kiwoom-docs-mcp / 한투 kis-code-assistant 와 같이 문서를 정확히 찾아주는 용도.
실제 REST/WS 호출·주문·토큰 발급은 절대 하지 않는다.
"""
from __future__ import annotations
import json
import os
import re
import sys
from functools import lru_cache
from typing import Any, Optional
from fastmcp import FastMCP
VERSION = "0.1.0"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_SPEC = os.path.join(SCRIPT_DIR, "data", "ls-openapi-spec.json")
HEADERISH = {
"content-type",
"authorization",
"tr_cd",
"tr_cont",
"tr_cont_key",
"mac_address",
}
def _strip_html(text: str) -> str:
if not text:
return ""
t = re.sub(r"<br\s*/?>", "\n", str(text), flags=re.I)
t = re.sub(r"<[^>]+>", "", t)
t = t.replace("&nbsp;", " ").replace("&amp;", "&")
return t.strip()
def _parse_example(ex: Any) -> Any:
if ex is None:
return None
if isinstance(ex, (dict, list)):
return ex
if isinstance(ex, str):
s = ex.strip()
if not s:
return None
try:
return json.loads(s)
except Exception:
return s
return ex
def _category_for(api: dict[str, Any]) -> str:
return str(api.get("category") or "기타")
class LSSpecIndex:
"""LS OpenAPI TR 카탈로그 인덱스 (읽기 전용)."""
MAX_SEARCH = 20
def __init__(self, path: str) -> None:
self.path = path
self.apis: dict[str, dict[str, Any]] = {}
self._by_lower: dict[str, list[str]] = {}
self.meta: dict[str, Any] = {}
self._load(path)
def _load(self, path: str) -> None:
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
if not isinstance(raw, dict):
raise ValueError("spec root must be object")
for key, val in raw.items():
if key == "_meta" and isinstance(val, dict):
self.meta = val
continue
if not isinstance(val, dict):
continue
api_id = str(val.get("apiId") or val.get("trCode") or key).strip()
if not api_id:
continue
store_id = api_id
if store_id in self.apis and self.apis[store_id] is not val:
store_id = str(key).strip() or api_id
self.apis[store_id] = val
for alias in {
api_id,
str(val.get("trCode") or ""),
str(key),
}:
a = alias.strip()
if not a:
continue
self._by_lower.setdefault(a.lower(), [])
if store_id not in self._by_lower[a.lower()]:
self._by_lower[a.lower()].append(store_id)
def stats(self) -> dict[str, Any]:
by_cat: dict[str, int] = {}
by_proto: dict[str, int] = {}
by_group: dict[str, int] = {}
for api in self.apis.values():
cat = _category_for(api)
by_cat[cat] = by_cat.get(cat, 0) + 1
proto = str(api.get("protocolType") or "?")
by_proto[proto] = by_proto.get(proto, 0) + 1
gn = str(api.get("groupName") or "?")
by_group[gn] = by_group.get(gn, 0) + 1
return {
"api_count": len(self.apis),
"by_category": dict(sorted(by_cat.items(), key=lambda x: (-x[1], x[0]))),
"by_protocol": dict(sorted(by_proto.items(), key=lambda x: (-x[1], x[0]))),
"by_group": dict(sorted(by_group.items(), key=lambda x: (-x[1], x[0]))),
"spec_path": self.path,
"fetched_at": self.meta.get("fetchedAt"),
"source": self.meta.get("source"),
}
def _summary(self, api: dict[str, Any]) -> dict[str, Any]:
return {
"apiId": api.get("apiId") or api.get("trCode"),
"trCode": api.get("trCode"),
"apiNm": api.get("apiNm") or api.get("trName"),
"method": api.get("method"),
"url": api.get("url"),
"domain": api.get("domain"),
"mockDomain": api.get("mockDomain"),
"protocolType": api.get("protocolType"),
"category": _category_for(api),
"groupName": api.get("groupName"),
"transactionPerSec": api.get("transactionPerSec"),
"docsUrl": api.get("docsUrl"),
"description": _strip_html(api.get("description") or ""),
}
def _filter_io(
self,
rows: Any,
*,
body_fields_only: bool,
) -> list[dict[str, Any]]:
if not isinstance(rows, list):
return []
out: list[dict[str, Any]] = []
for it in rows:
if not isinstance(it, dict):
continue
item_id = str(it.get("itemId") or "")
bt = str(it.get("bodyType") or "")
if body_fields_only:
if bt in {"req_h", "res_h"}:
continue
if item_id.lower() in HEADERISH:
continue
out.append(
{
"itemId": item_id,
"itemNm": it.get("itemNm") or "",
"type": it.get("type") or "",
"length": it.get("length") or "",
"required": it.get("required") or "",
"order": it.get("order") or "",
"bodyType": bt,
"desc": _strip_html(it.get("desc") or ""),
}
)
return out
def _detail(
self,
api: dict[str, Any],
*,
include_examples: bool = True,
body_fields_only: bool = False,
) -> dict[str, Any]:
out = self._summary(api)
out.update(
{
"format": api.get("format"),
"contentType": api.get("contentType"),
"trId": api.get("trId"),
"groupId": api.get("groupId"),
"requestIo": self._filter_io(
api.get("requestIo"), body_fields_only=body_fields_only
),
"responseIo": self._filter_io(
api.get("responseIo"), body_fields_only=body_fields_only
),
}
)
if include_examples:
out["requestExample"] = _parse_example(api.get("requestExample"))
out["responseExample"] = _parse_example(api.get("responseExample"))
return out
def get(self, api_id: str, **kwargs: Any) -> dict[str, Any]:
raw = (api_id or "").strip()
if not raw:
return {"status": "error", "message": "api_id (trCode) required"}
api = self.apis.get(raw)
matches = self._by_lower.get(raw.lower(), [])
if api is None and len(matches) == 1:
api = self.apis.get(matches[0])
elif api is None and len(matches) > 1:
return {
"status": "ambiguous",
"message": f"multiple trCode match: {raw}",
"candidates": matches,
"apis": [self._summary(self.apis[m]) for m in matches if m in self.apis],
}
if not api:
hints = [k for k in self.apis if raw.lower() in k.lower()][:8]
return {
"status": "not_found",
"message": f"trCode not found: {api_id}",
"candidates": hints,
}
return {"status": "success", "api": self._detail(api, **kwargs)}
def list_apis(
self,
*,
category: Optional[str] = None,
protocol: Optional[str] = None,
url_contains: Optional[str] = None,
group_contains: Optional[str] = None,
limit: int = 100,
) -> dict[str, Any]:
limit = max(1, min(int(limit or 100), 400))
rows: list[dict[str, Any]] = []
cat = (category or "").strip()
proto = (protocol or "").strip().upper()
url_q = (url_contains or "").strip().lower()
grp_q = (group_contains or "").strip().lower()
for api in self.apis.values():
if cat and cat not in _category_for(api) and _category_for(api) != cat:
continue
if proto and str(api.get("protocolType") or "").upper() != proto:
continue
if url_q and url_q not in str(api.get("url") or "").lower():
continue
if grp_q and grp_q not in str(api.get("groupName") or "").lower():
continue
rows.append(self._summary(api))
rows.sort(key=lambda x: str(x.get("trCode") or x.get("apiId") or ""))
total = len(rows)
return {
"status": "success",
"total_count": total,
"showing": min(total, limit),
"results": rows[:limit],
}
def search(self, query: str, limit: Optional[int] = None) -> dict[str, Any]:
q = (query or "").strip()
if not q:
return {"status": "error", "message": "query required", "total_count": 0, "results": []}
lim = max(1, min(int(limit or self.MAX_SEARCH), 50))
q_lower = q.lower()
tokens = [t for t in re.split(r"\s+", q_lower) if t]
scored: list[tuple[int, dict[str, Any]]] = []
for api in self.apis.values():
aid = str(api.get("trCode") or api.get("apiId") or "")
anm = str(api.get("apiNm") or api.get("trName") or "")
desc = _strip_html(api.get("description") or "")
url = str(api.get("url") or "")
cat = _category_for(api)
group = str(api.get("groupName") or "")
field_blob = " ".join(
f"{x.get('itemId','')} {x.get('itemNm','')} {x.get('desc','')}"
for x in (api.get("requestIo") or []) + (api.get("responseIo") or [])
if isinstance(x, dict)
)
hay = f"{aid} {anm} {desc} {url} {cat} {group} {field_blob}".lower()
score = 0
if q_lower == aid.lower():
score += 100
elif aid.lower().startswith(q_lower):
score += 60
elif q_lower in aid.lower():
score += 40
if q_lower in anm.lower():
score += 35
if q_lower in desc.lower():
score += 15
if q_lower in url.lower():
score += 20
if q_lower in cat.lower() or q_lower in group.lower():
score += 12
if tokens and all(t in hay for t in tokens):
score += 25 + 5 * len(tokens)
elif tokens:
hit = sum(1 for t in tokens if t in hay)
if hit:
score += hit * 8
else:
continue
elif score == 0:
continue
scored.append((score, self._summary(api)))
scored.sort(key=lambda x: (-x[0], str(x[1].get("trCode") or "")))
results = [row for _, row in scored[:lim]]
if not results:
return {
"status": "no_results",
"message": f"No APIs matched: {query}",
"total_count": 0,
"results": [],
}
return {
"status": "success",
"message": f"Found {len(scored)} (showing {len(results)})",
"total_count": len(scored),
"results": results,
}
@lru_cache(maxsize=1)
def get_index() -> LSSpecIndex:
path = os.environ.get("LS_SPEC_PATH") or DEFAULT_SPEC
return LSSpecIndex(path)
mcp = FastMCP(
name="ls-docs-mcp",
version=VERSION,
instructions=(
"LS증권 OpenAPI TR 스펙 조회 전용 MCP다. "
"사용자가 LS TR 코드(t8412, US3, g3101…), URL, 요청/응답 필드를 물을 때 "
"반드시 이 도구로 스펙을 확인한 뒤 답하라. "
"이 MCP는 실제 API를 호출하지 않는다. 주문/체결/토큰 발급을 수행하지 않는다."
),
)
@mcp.tool(
name="ls_api_stats",
description="LS OpenAPI 스펙 요약(총 TR 수, 카테고리/프로토콜/그룹 분포). 탐색 시작 시 사용.",
)
def ls_api_stats() -> dict[str, Any]:
return {"status": "success", **get_index().stats()}
@mcp.tool(
name="search_ls_api",
description=(
"LS OpenAPI TR을 자연어/키워드로 검색한다. "
"예: '분봉', 't8412', '조건검색', '해외차트', '호가', 'US3'. "
"결과는 요약만 반환하므로, 상세 필드는 get_ls_api(tr_code)를 이어서 호출하라."
),
)
def search_ls_api(query: str, limit: int = 20) -> dict[str, Any]:
return get_index().search(query, limit=limit)
@mcp.tool(
name="get_ls_api",
description=(
"trCode(예: t8412, t1101, US3, g3203)로 LS TR 전체 스펙을 반환한다. "
"method/url/domain/requestIo/responseIo/예제/docsUrl 포함. "
"body_fields_only=true 이면 공통 헤더(content-type, authorization, tr_cd…)를 제외한다."
),
)
def get_ls_api(
tr_code: str,
include_examples: bool = True,
body_fields_only: bool = False,
) -> dict[str, Any]:
return get_index().get(
tr_code,
include_examples=include_examples,
body_fields_only=body_fields_only,
)
@mcp.tool(
name="list_ls_apis",
description=(
"LS TR 목록. category(주식_차트 등), protocol(REST|WEBSOCKET), "
"url_contains(/stock/chart), group_contains(실시간)로 필터. "
"카테고리 목록은 ls_api_stats 참고."
),
)
def list_ls_apis(
category: str = "",
protocol: str = "",
url_contains: str = "",
group_contains: str = "",
limit: int = 100,
) -> dict[str, Any]:
return get_index().list_apis(
category=category or None,
protocol=protocol or None,
url_contains=url_contains or None,
group_contains=group_contains or None,
limit=limit,
)
def main() -> None:
if "--http" in sys.argv:
port = int(os.environ.get("PORT", "8083"))
mcp.run(transport="http", host="127.0.0.1", port=port)
return
mcp.run(transport="stdio")
if __name__ == "__main__":
main()

1341
mcp/ls-docs-mcp/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff