Merge pull request #124 from NomaDamas/fix/issue-99-korean-stock-proxy

Fix Korean stock proxy degraded search and holiday no-data handling
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-16 15:16:18 +09:00 committed by GitHub
commit e1bc04bb0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 231 additions and 28 deletions

View file

@ -190,7 +190,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search'
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
한국 주식 기본정보 endpoint:
@ -199,7 +199,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```

View file

@ -31,7 +31,7 @@ upstream 참고 구현은 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabs
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## 기본정보 예시
@ -40,7 +40,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## 일별 시세 예시
@ -49,7 +49,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## 응답 해석 팁
@ -59,6 +59,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
- `close_price`, `trading_volume`, `market_cap` 은 숫자로 정규화돼 온다.
- `base_date`/`bas_dd` 는 일별 snapshot 날짜다.
- 휴장일/장마감 전에는 빈 결과나 `not_found` 가 나올 수 있다.
- 일부 시장 upstream 이 실패하면 검색 응답에 `upstream.degraded=true``failed_markets` 가 붙을 수 있다.
## 답변 템플릿 권장
@ -72,7 +73,8 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
- 잘못된 `market`, `code`, `bas_dd` 형식은 400
- proxy 서버에 `KRX_API_KEY` 가 없으면 503
- upstream KRX 오류는 502
- 검색 중 일부 시장 upstream 이 실패하면 200 이지만 `upstream.degraded=true` / `failed_markets` 가 함께 온다.
- 모든 요청 시장에서 upstream KRX 조회가 실패하면 502
- 기준일에 종목을 찾지 못하면 404 `not_found`
## 참고 링크

View file

@ -132,19 +132,19 @@ korean-law list
`korean-stock-search` 는 로컬 MCP 설치 대신 **proxy first** 로 사용한다.
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260404'`
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260408'`
- 검색 결과에서 `market`, `code` 를 확인한 뒤 `base-info` 또는 `trade-info` 로 이어간다.
- 사용자 쪽 `KRX_API_KEY` 는 필요 없다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 설정한다.
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```

View file

@ -20,7 +20,7 @@ upstream 설계 참고는 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabs
- "삼성전자 종목코드랑 시장구분 찾아줘"
- "005930 기본정보 보여줘"
- "SK하이닉스 20260404 종가/거래량 알려줘"
- "SK하이닉스 20260408 종가/거래량 알려줘"
- "KOSDAQ 에서 알테오젠 시세 확인해줘"
## When not to use
@ -75,7 +75,7 @@ GET /v1/korean-stock/trade-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
종목 기본정보:
@ -84,7 +84,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
종목 일별 시세:
@ -93,7 +93,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## Response shape
@ -113,7 +113,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"listed_at": "1975-06-11"
}
],
"query": { "q": "삼성전자", "bas_dd": "20260404", "limit": 10 },
"query": { "q": "삼성전자", "bas_dd": "20260408", "limit": 10 },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
@ -135,7 +135,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"par_value": 100,
"listed_shares": 5969782550
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260408" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
@ -148,7 +148,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"base_date": "20260404",
"base_date": "20260408",
"name": "삼성전자",
"close_price": 84000,
"change_price": 1000,
@ -160,7 +160,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"trading_value": 1030000000000,
"market_cap": 500000000000000
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260408" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
@ -168,8 +168,9 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
## Response policy
- 종목명이 모호하면 먼저 `search` 로 시장/종목코드를 좁힌 뒤 `base-info` 또는 `trade-info` 로 들어간다.
- 일부 시장 upstream 이 실패하면 `upstream.degraded=true``failed_markets` 를 보고 부분 장애 여부를 함께 설명한다.
- `trade-info` 결과는 일별 snapshot 이다. 실시간 호가/체결처럼 말하지 않는다.
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다.
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다. 이 경우 `trade-info` 는 502 대신 `not_found` 로 끝날 수 있다.
- 숫자는 사람이 읽기 쉬운 단위(원, 주, 억/조)로 짧게 풀어주되 원본 숫자도 유지한다.
- 답변 말미에 "KRX 공식 데이터 기준 / 투자 조언 아님" 을 짧게 남긴다.
@ -185,7 +186,8 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
- `q`, `market`, `code`, `bas_dd` 형식이 잘못되면 400 응답
- 프록시 서버에 `KRX_API_KEY` 가 없으면 503 응답
- upstream KRX 응답 오류면 502 응답
- 검색 중 일부 시장 upstream 이 실패하면 200 응답이지만 `upstream.degraded=true``failed_markets` 를 함께 반환할 수 있다.
- 모든 요청 시장에서 upstream KRX 조회가 실패하면 502 응답
- 해당 기준일/시장에 종목이 없으면 404 `not_found`
## Done when

View file

@ -116,7 +116,7 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/mfds/food-safety/search' \
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.

View file

@ -165,6 +165,10 @@ async function fetchBaseInfo({ market, basDd = getCurrentKstDate(), codeList = [
async function fetchTradeInfo({ market, basDd = getCurrentKstDate(), codeList, apiKey, fetchImpl = global.fetch }) {
const tradeItems = await krxRequest(buildUrl(KRX_TRADE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
if (tradeItems.length === 0) {
return [];
}
const directlyMatched = tradeItems.filter((item) => matchesCodes(item, codeList));
if (directlyMatched.length > 0) {
return directlyMatched.map((item) => normalizeTradeItem(item, market));
@ -221,6 +225,14 @@ function buildBaseInfoSnapshotCacheKey({ market, basDd }) {
return `krx-base-info:${market}:${basDd}`;
}
function serializeKrxError(error) {
return {
code: error?.code || "proxy_error",
status_code: error?.statusCode || 502,
message: error?.message || "Unknown KRX upstream error."
};
}
async function fetchBaseInfoSnapshot({
market,
basDd,
@ -272,13 +284,20 @@ async function searchStocks({
const successfulResults = settledResults
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
const failedResults = settledResults
.map((result, index) => ({ result, market: markets[index] }))
.filter(({ result }) => result.status === "rejected")
.map(({ result, market }) => ({
market,
...serializeKrxError(result.reason)
}));
if (successfulResults.length === 0) {
const firstFailure = settledResults.find((result) => result.status === "rejected");
throw firstFailure?.reason || new Error("KRX search failed for every market.");
}
return {
const payload = {
items: successfulResults
.flatMap(({ market: entryMarket, items }) =>
items
@ -289,6 +308,17 @@ async function searchStocks({
.slice(0, limit)
.map(({ score, ...item }) => item)
};
if (failedResults.length > 0) {
payload.upstream = {
degraded: true,
requested_markets: markets,
successful_markets: successfulResults.map(({ market: entryMarket }) => entryMarket),
failed_markets: failedResults
};
}
return payload;
}
module.exports = {

View file

@ -2020,9 +2020,9 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
};
}
let items;
let result;
try {
const result = await searchStocks({
result = await searchStocks({
query: normalized.q,
basDd: normalized.basDd,
market: normalized.market,
@ -2031,7 +2031,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
cache,
cacheTtlMs: config.cacheTtlMs
});
items = result.items;
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
@ -2041,7 +2040,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
}
const payload = {
items,
items: result.items,
query: {
q: normalized.q,
bas_dd: normalized.basDd,
@ -2058,7 +2057,13 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
if (result.upstream) {
payload.upstream = result.upstream;
}
if (!result.upstream?.degraded) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
@ -2442,7 +2447,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
reply.code(404);
return {
error: "not_found",
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다.`
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다. 휴장일이거나 데이터가 아직 없을 수 있습니다.`
};
}

View file

@ -172,7 +172,7 @@ test("korean stock search rate limit does not trust spoofed cf-connecting-ip on
assert.equal(second.json().error, "rate_limited");
});
test("korean stock search returns healthy market results when another market upstream fails", async (t) => {
test("korean stock search surfaces degraded upstream metadata when another market fails", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
@ -249,10 +249,124 @@ test("korean stock search returns healthy market results when another market ups
assert.equal(response.json().items[0].market, "KOSPI");
assert.equal(response.json().items[0].code, "005930");
assert.equal(response.json().items[0].name, "삼성전자");
assert.equal(response.json().upstream.degraded, true);
assert.deepEqual(response.json().upstream.requested_markets, ["KOSPI", "KOSDAQ", "KONEX"]);
assert.deepEqual(response.json().upstream.successful_markets, ["KOSPI", "KONEX"]);
assert.deepEqual(response.json().upstream.failed_markets, [
{
market: "KOSDAQ",
code: "upstream_error",
status_code: 502,
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
}
]);
assert.equal(fetchCalls.length, 3);
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock search does not cache degraded responses and retries a recovered market", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
let kosdaqAttempts = 0;
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info") || text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info")) {
kosdaqAttempts += 1;
if (kosdaqAttempts === 1) {
return new Response("boom", {
status: 500,
statusText: "Internal Server Error"
});
}
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7196170005",
ISU_SRT_CD: "196170",
ISU_NM: "알테오젠",
ISU_ABBRV: "알테오젠",
ISU_ENG_NM: "Alteogen",
LIST_DD: "20140509",
MKT_TP_NM: "KOSDAQ",
SECUGRP_NM: "주권",
SECT_TP_NM: "제약",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "500",
LIST_SHRS: "53470829"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().items.length, 0);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(first.json().upstream.degraded, true);
assert.deepEqual(first.json().upstream.failed_markets, [
{
market: "KOSDAQ",
code: "upstream_error",
status_code: 502,
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
}
]);
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, false);
assert.equal(second.json().items.length, 1);
assert.equal(second.json().items[0].market, "KOSDAQ");
assert.equal(second.json().items[0].code, "196170");
assert.equal(kosdaqAttempts, 2);
assert.equal(fetchCalls.length, 4);
});
test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
@ -538,6 +652,56 @@ test("korean stock trade-info endpoint does not relabel an unmatched single-row
assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock trade-info endpoint treats empty trade snapshots as not_found without base-info fallback", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("stk_bydd_trd")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("stk_isu_base_info")) {
throw new Error("base-info fallback should not run for empty trade snapshots");
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, "not_found");
assert.match(response.json().message, /휴장일이거나 데이터가 아직 없을 수 있습니다/);
assert.deepEqual(fetchCalls, [
"https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd?basDd=20260404"
]);
});
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
let providerCalls = 0;
const app = buildServer({