대시보드
This commit is contained in:
18
.env.example
18
.env.example
@@ -1,5 +1,5 @@
|
||||
# Supabase 환경 설정 예제 파일
|
||||
# 이 파일의 이름을 .env.local 로 변경한 뒤, 실제 값을 채워넣으세요.
|
||||
# Supabase 환경 설정 예제 파일
|
||||
# 이 파일을 .env.local로 복사한 뒤 실제 값을 채워 주세요.
|
||||
# 값 확인: https://supabase.com/dashboard/project/_/settings/api
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
@@ -7,3 +7,17 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 세션 타임아웃(분 단위)
|
||||
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
|
||||
|
||||
# KIS 거래 모드: real(실전) | mock(모의)
|
||||
KIS_TRADING_ENV=real
|
||||
|
||||
# 서버 기본 키를 쓰고 싶은 경우(선택)
|
||||
KIS_APP_KEY_REAL=
|
||||
KIS_APP_SECRET_REAL=
|
||||
KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443
|
||||
KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000
|
||||
|
||||
KIS_APP_KEY_MOCK=
|
||||
KIS_APP_SECRET_MOCK=
|
||||
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
|
||||
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000
|
||||
|
||||
1
.tmp/open-trading-api
Submodule
1
.tmp/open-trading-api
Submodule
Submodule .tmp/open-trading-api added at aea5e779da
@@ -1,115 +1,25 @@
|
||||
/**
|
||||
* @file app/(main)/dashboard/page.tsx
|
||||
* @description 사용자 대시보드 메인 페이지 (보호된 라우트)
|
||||
* @remarks
|
||||
* - [레이어] Pages (Server Component)
|
||||
* - [역할] 사용자 자산 현황, 최근 활동, 통계 요약을 보여줌
|
||||
* - [권한] 로그인한 사용자만 접근 가능 (Middleware에 의해 보호됨)
|
||||
* @description 로그인 사용자 전용 대시보드 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Activity, CreditCard, DollarSign, Users } from "lucide-react";
|
||||
import { DashboardMain } from "@/features/dashboard/components/dashboard-main";
|
||||
|
||||
/**
|
||||
* 대시보드 페이지 (비동기 서버 컴포넌트)
|
||||
* @returns Dashboard Grid Layout
|
||||
* 대시보드 페이지
|
||||
* @returns DashboardMain UI
|
||||
* @see features/dashboard/components/dashboard-main.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// [Step 1] 세션 확인 (Middleware가 1차 방어하지만, 데이터 접근 시 2차 확인)
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.getUser();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">대시보드</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 수익</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$45,231.89</div>
|
||||
<p className="text-xs text-muted-foreground">지난달 대비 +20.1%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">구독자</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+2350</div>
|
||||
<p className="text-xs text-muted-foreground">지난달 대비 +180.1%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">판매량</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+12,234</div>
|
||||
<p className="text-xs text-muted-foreground">지난달 대비 +19%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">현재 활동 중</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+573</div>
|
||||
<p className="text-xs text-muted-foreground">지난 시간 대비 +201</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>개요</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
{/* Chart placeholder */}
|
||||
<div className="h-[200px] w-full bg-slate-100 dark:bg-slate-800 rounded-md flex items-center justify-center text-muted-foreground">
|
||||
차트 영역 (준비 중)
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>최근 활동</CardTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
이번 달 265건의 거래가 있었습니다.
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
비트코인 매수
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">BTC/USDT</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$1,999.00</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
이더리움 매도
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">ETH/USDT</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$39.00</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <DashboardMain />;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function MainLayout({
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
|
||||
<Header user={user} />
|
||||
<div className="flex flex-1">
|
||||
<div className="flex flex-1 pt-16">
|
||||
<Sidebar />
|
||||
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
|
||||
</div>
|
||||
|
||||
78
app/api/kis/domestic/overview/route.ts
Normal file
78
app/api/kis/domestic/overview/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
|
||||
import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticOverview } from "@/lib/kis/domestic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/overview/route.ts
|
||||
* @description 국내주식 종목 상세(현재가 + 차트) API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 국내주식 종목 상세 API
|
||||
* @param request query string의 symbol(6자리 종목코드) 사용
|
||||
* @returns 대시보드 상세 모델
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||
|
||||
if (!/^\d{6}$/.test(symbol)) {
|
||||
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
|
||||
|
||||
try {
|
||||
const overview = await getDomesticOverview(symbol, fallbackMeta, credentials);
|
||||
|
||||
const response: DashboardStockOverviewResponse = {
|
||||
stock: overview.stock,
|
||||
source: "kis",
|
||||
priceSource: overview.priceSource,
|
||||
marketPhase: overview.marketPhase,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더에서 KIS 키를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns credentials
|
||||
*/
|
||||
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
||||
const tradingEnv = normalizeTradingEnv(headers.get("x-kis-trading-env") ?? undefined);
|
||||
|
||||
return {
|
||||
appKey,
|
||||
appSecret,
|
||||
tradingEnv,
|
||||
};
|
||||
}
|
||||
108
app/api/kis/domestic/search/route.ts
Normal file
108
app/api/kis/domestic/search/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
|
||||
import type {
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockSearchResponse,
|
||||
KoreanStockIndexItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SEARCH_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/search/route.ts
|
||||
* @description 국내주식 종목명/종목코드 검색 API
|
||||
* @remarks
|
||||
* - [레이어] API Route
|
||||
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
|
||||
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
|
||||
* - [연관 파일] features/dashboard/data/korean-stocks.ts, features/dashboard/components/dashboard-main.tsx
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
|
||||
/**
|
||||
* 국내주식 검색 API
|
||||
* @param request query string의 q(검색어) 사용
|
||||
* @returns 종목 검색 결과 목록
|
||||
* @see features/dashboard/components/dashboard-main.tsx 검색 폼에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = (searchParams.get("q") ?? "").trim();
|
||||
|
||||
// [Step 2] 검색어가 없으면 빈 목록을 즉시 반환해 불필요한 계산을 줄입니다.
|
||||
if (!query) {
|
||||
const response: DashboardStockSearchResponse = {
|
||||
query,
|
||||
items: [],
|
||||
total: 0,
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const normalized = normalizeKeyword(query);
|
||||
|
||||
// [Step 3] 인덱스에서 코드/이름 포함 여부로 1차 필터링 후 점수를 붙입니다.
|
||||
const ranked = KOREAN_STOCK_INDEX.filter((item) => {
|
||||
const symbol = item.symbol;
|
||||
const name = normalizeKeyword(item.name);
|
||||
return symbol.includes(normalized) || name.includes(normalized);
|
||||
})
|
||||
.map((item) => ({
|
||||
item,
|
||||
score: getSearchScore(item, normalized),
|
||||
}))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.item.market !== b.item.market) return a.item.market.localeCompare(b.item.market);
|
||||
return a.item.name.localeCompare(b.item.name, "ko");
|
||||
});
|
||||
|
||||
// [Step 4] UI에서 필요한 최소 필드만 남겨 SEARCH_LIMIT 만큼 반환합니다.
|
||||
const items: DashboardStockSearchItem[] = ranked.slice(0, SEARCH_LIMIT).map(({ item }) => ({
|
||||
symbol: item.symbol,
|
||||
name: item.name,
|
||||
market: item.market,
|
||||
}));
|
||||
|
||||
const response: DashboardStockSearchResponse = {
|
||||
query,
|
||||
items,
|
||||
total: ranked.length,
|
||||
};
|
||||
|
||||
// [Step 5] DashboardStockSearchResponse 형태로 응답합니다.
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어 정규화(공백 제거 + 소문자)
|
||||
* @param value 원본 문자열
|
||||
* @returns 정규화 문자열
|
||||
* @see app/api/kis/domestic/search/route.ts 한글/영문 검색 비교 정확도를 높입니다.
|
||||
*/
|
||||
function normalizeKeyword(value: string) {
|
||||
return value.replaceAll(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 점수 계산
|
||||
* @param item 종목 인덱스 항목
|
||||
* @param normalizedQuery 정규화된 검색어
|
||||
* @returns 높은 값일수록 우선순위 상위
|
||||
* @see app/api/kis/domestic/search/route.ts 검색 결과 정렬 기준으로 사용합니다.
|
||||
*/
|
||||
function getSearchScore(item: KoreanStockIndexItem, normalizedQuery: string) {
|
||||
const normalizedName = normalizeKeyword(item.name);
|
||||
const normalizedSymbol = item.symbol.toLowerCase();
|
||||
|
||||
if (normalizedSymbol === normalizedQuery) return 120;
|
||||
if (normalizedName === normalizedQuery) return 110;
|
||||
if (normalizedSymbol.startsWith(normalizedQuery)) return 100;
|
||||
if (normalizedName.startsWith(normalizedQuery)) return 90;
|
||||
if (normalizedName.includes(normalizedQuery)) return 70;
|
||||
if (normalizedSymbol.includes(normalizedQuery)) return 60;
|
||||
|
||||
return 0;
|
||||
}
|
||||
64
app/api/kis/revoke/route.ts
Normal file
64
app/api/kis/revoke/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { revokeKisAccessToken } from "@/lib/kis/token";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/revoke/route.ts
|
||||
* @description 사용자 입력 KIS API 키 기반 접근토큰 폐기 라우트
|
||||
*/
|
||||
|
||||
/**
|
||||
* KIS API 접근토큰 폐기
|
||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
||||
* @returns 폐기 성공/실패 정보
|
||||
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - 접근 폐기 버튼 클릭 이벤트
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as Partial<DashboardKisRevokeRequest>;
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey?.trim(),
|
||||
appSecret: body.appSecret?.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
||||
} satisfies DashboardKisRevokeResponse,
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await revokeKisAccessToken(credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message,
|
||||
} satisfies DashboardKisRevokeResponse);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message,
|
||||
} satisfies DashboardKisRevokeResponse,
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardKisRevokeRequest {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: "real" | "mock";
|
||||
}
|
||||
65
app/api/kis/validate/route.ts
Normal file
65
app/api/kis/validate/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getKisAccessToken } from "@/lib/kis/token";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/validate/route.ts
|
||||
* @description 사용자 입력 KIS API 키 검증 라우트
|
||||
*/
|
||||
|
||||
/**
|
||||
* KIS API 키 검증
|
||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
||||
* @returns 검증 성공/실패 정보
|
||||
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis - 검증 버튼 클릭 시 호출
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as Partial<DashboardKisValidateRequest>;
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey?.trim(),
|
||||
appSecret: body.appSecret?.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
||||
} satisfies DashboardKisValidateResponse,
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 검증 단계는 토큰 발급 성공 여부만 확인합니다.
|
||||
await getKisAccessToken(credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
||||
} satisfies DashboardKisValidateResponse);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message,
|
||||
} satisfies DashboardKisValidateResponse,
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardKisValidateRequest {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: "real" | "mock";
|
||||
}
|
||||
67
app/api/kis/ws/approval/route.ts
Normal file
67
app/api/kis/ws/approval/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/ws/approval/route.ts
|
||||
* @description KIS 웹소켓 approval key 발급 라우트
|
||||
*/
|
||||
|
||||
/**
|
||||
* 실시간 웹소켓 승인키 발급
|
||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
||||
* @returns approval key + ws url
|
||||
* @see features/dashboard/components/dashboard-main.tsx connectKisRealtimePrice - 실시간 체결가 구독 진입점
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as Partial<DashboardKisWsApprovalRequest>;
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey?.trim(),
|
||||
appSecret: body.appSecret?.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
||||
} satisfies DashboardKisWsApprovalResponse,
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const approvalKey = await getKisApprovalKey(credentials);
|
||||
const wsUrl = resolveKisWebSocketUrl(credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
approvalKey,
|
||||
wsUrl,
|
||||
message: "KIS 실시간 웹소켓 승인키 발급이 완료되었습니다.",
|
||||
} satisfies DashboardKisWsApprovalResponse);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "웹소켓 승인키 발급 중 오류가 발생했습니다.";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message,
|
||||
} satisfies DashboardKisWsApprovalResponse,
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardKisWsApprovalRequest {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: "real" | "mock";
|
||||
}
|
||||
999
features/dashboard/components/dashboard-main.tsx
Normal file
999
features/dashboard/components/dashboard-main.tsx
Normal file
@@ -0,0 +1,999 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Activity, Search, ShieldCheck, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useKisRuntimeStore,
|
||||
type KisRuntimeCredentials,
|
||||
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardMarketPhase,
|
||||
DashboardKisRevokeResponse,
|
||||
DashboardPriceSource,
|
||||
DashboardKisValidateResponse,
|
||||
DashboardKisWsApprovalResponse,
|
||||
DashboardStockItem,
|
||||
DashboardStockOverviewResponse,
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockSearchResponse,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* @file features/dashboard/components/dashboard-main.tsx
|
||||
* @description 대시보드 메인 UI(검색/시세/차트)
|
||||
*/
|
||||
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
|
||||
function formatPrice(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}원`;
|
||||
}
|
||||
|
||||
function formatVolume(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}주`;
|
||||
}
|
||||
|
||||
function getPriceSourceLabel(source: DashboardPriceSource, marketPhase: DashboardMarketPhase) {
|
||||
switch (source) {
|
||||
case "inquire-overtime-price":
|
||||
return "시간외 현재가(inquire-overtime-price)";
|
||||
case "inquire-ccnl":
|
||||
return marketPhase === "afterHours"
|
||||
? "체결가 폴백(inquire-ccnl)"
|
||||
: "체결가(inquire-ccnl)";
|
||||
default:
|
||||
return "현재가(inquire-price)";
|
||||
}
|
||||
}
|
||||
|
||||
function getMarketPhaseLabel(marketPhase: DashboardMarketPhase) {
|
||||
return marketPhase === "regular" ? "장중(한국시간 09:00~15:30)" : "장외/휴장";
|
||||
}
|
||||
|
||||
/**
|
||||
* 주가 라인 차트(SVG)
|
||||
*/
|
||||
function StockLineChart({ candles }: { candles: StockCandlePoint[] }) {
|
||||
const chart = (() => {
|
||||
const width = 760;
|
||||
const height = 280;
|
||||
const paddingX = 24;
|
||||
const paddingY = 20;
|
||||
const plotWidth = width - paddingX * 2;
|
||||
const plotHeight = height - paddingY * 2;
|
||||
|
||||
const prices = candles.map((item) => item.price);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const range = Math.max(maxPrice - minPrice, 1);
|
||||
|
||||
const points = candles.map((item, index) => {
|
||||
const x = paddingX + (index / Math.max(candles.length - 1, 1)) * plotWidth;
|
||||
const y = paddingY + ((maxPrice - item.price) / range) * plotHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const linePoints = points.map((point) => `${point.x},${point.y}`).join(" ");
|
||||
const firstPoint = points[0];
|
||||
const lastPoint = points[points.length - 1];
|
||||
const areaPoints = `${linePoints} ${lastPoint.x},${height - paddingY} ${firstPoint.x},${height - paddingY}`;
|
||||
|
||||
return { width, height, paddingX, paddingY, minPrice, maxPrice, linePoints, areaPoints };
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="h-[300px] w-full">
|
||||
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-full w-full">
|
||||
<defs>
|
||||
<linearGradient id="priceAreaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-brand-500)" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="var(--color-brand-500)" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.paddingY}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.paddingY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
/>
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.height / 2}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.height / 2}
|
||||
stroke="currentColor"
|
||||
className="text-border/70"
|
||||
/>
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.height - chart.paddingY}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.height - chart.paddingY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
/>
|
||||
|
||||
<polygon points={chart.areaPoints} fill="url(#priceAreaGradient)" />
|
||||
<polyline
|
||||
points={chart.linePoints}
|
||||
fill="none"
|
||||
stroke="var(--color-brand-600)"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{candles[0]?.time}</span>
|
||||
<span>저가 {formatPrice(chart.minPrice)}</span>
|
||||
<span>고가 {formatPrice(chart.maxPrice)}</span>
|
||||
<span>{candles[candles.length - 1]?.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchStockSearch(keyword: string) {
|
||||
const response = await fetch(`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardStockSearchResponse | { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload as DashboardStockSearchResponse;
|
||||
}
|
||||
|
||||
async function fetchStockOverview(symbol: string, credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch(`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardStockOverviewResponse | { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload as DashboardStockOverviewResponse;
|
||||
}
|
||||
|
||||
async function validateKisCredentials(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/validate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisValidateResponse;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 접근토큰 폐기 요청
|
||||
* @param credentials 검증 완료된 KIS 키
|
||||
* @returns 폐기 응답
|
||||
* @see app/api/kis/revoke/route.ts POST - revokeP 폐기 프록시
|
||||
*/
|
||||
async function revokeKisCredentials(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisRevokeResponse;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
const KIS_REALTIME_TR_ID_REAL = "H0UNCNT0";
|
||||
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
|
||||
|
||||
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
|
||||
return tradingEnv === "mock" ? KIS_REALTIME_TR_ID_MOCK : KIS_REALTIME_TR_ID_REAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 웹소켓 승인키를 발급받습니다.
|
||||
* @param credentials 검증 완료된 KIS 키
|
||||
* @returns approval key + ws url
|
||||
* @see app/api/kis/ws/approval/route.ts POST - Approval 발급 프록시
|
||||
*/
|
||||
async function fetchKisWebSocketApproval(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/ws/approval", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
|
||||
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
|
||||
throw new Error(payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 체결가 구독/해제 메시지를 생성합니다.
|
||||
* @param approvalKey websocket 승인키
|
||||
* @param symbol 종목코드
|
||||
* @param trType "1"(구독) | "2"(해제)
|
||||
* @returns websocket 요청 메시지
|
||||
* @see https://github.com/koreainvestment/open-trading-api
|
||||
*/
|
||||
function buildKisRealtimeMessage(
|
||||
approvalKey: string,
|
||||
symbol: string,
|
||||
trId: string,
|
||||
trType: "1" | "2",
|
||||
) {
|
||||
return {
|
||||
header: {
|
||||
approval_key: approvalKey,
|
||||
custtype: "P",
|
||||
tr_type: trType,
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
body: {
|
||||
input: {
|
||||
tr_id: trId,
|
||||
tr_key: symbol,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface KisRealtimeTick {
|
||||
point: StockCandlePoint;
|
||||
price: number;
|
||||
accumulatedVolume: number;
|
||||
tickTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 체결가 원문을 차트 포인트로 변환합니다.
|
||||
* @param raw websocket 수신 원문
|
||||
* @param expectedSymbol 현재 선택 종목코드
|
||||
* @returns 실시간 포인트 또는 null
|
||||
*/
|
||||
function parseKisRealtimeTick(raw: string, expectedSymbol: string, expectedTrId: string): KisRealtimeTick | null {
|
||||
if (!/^([01])\|/.test(raw)) return null;
|
||||
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return null;
|
||||
if (parts[1] !== expectedTrId) return null;
|
||||
|
||||
const tickCount = Number(parts[2] ?? "1");
|
||||
const values = parts[3].split("^");
|
||||
const isBatch = Number.isInteger(tickCount) && tickCount > 1 && values.length % tickCount === 0;
|
||||
const fieldsPerTick = isBatch ? values.length / tickCount : values.length;
|
||||
const baseIndex = isBatch ? (tickCount - 1) * fieldsPerTick : 0;
|
||||
const symbol = values[baseIndex];
|
||||
const hhmmss = values[baseIndex + 1];
|
||||
const price = Number((values[baseIndex + 2] ?? "").replaceAll(",", "").trim());
|
||||
const accumulatedVolume = Number((values[baseIndex + 13] ?? "").replaceAll(",", "").trim());
|
||||
|
||||
if (symbol !== expectedSymbol) return null;
|
||||
if (!Number.isFinite(price) || price <= 0) return null;
|
||||
|
||||
return {
|
||||
point: {
|
||||
time: formatRealtimeTickTime(hhmmss),
|
||||
price,
|
||||
},
|
||||
price,
|
||||
accumulatedVolume: Number.isFinite(accumulatedVolume) && accumulatedVolume > 0 ? accumulatedVolume : 0,
|
||||
tickTime: hhmmss ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formatRealtimeTickTime(hhmmss?: string) {
|
||||
if (!hhmmss || hhmmss.length !== 6) return "실시간";
|
||||
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
function appendRealtimeTick(prev: StockCandlePoint[], next: StockCandlePoint) {
|
||||
if (prev.length === 0) return [next];
|
||||
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.time === next.time) {
|
||||
return [...prev.slice(0, -1), next];
|
||||
}
|
||||
|
||||
return [...prev, next].slice(-80);
|
||||
}
|
||||
|
||||
function toTickOrderValue(hhmmss?: string) {
|
||||
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
|
||||
return Number(hhmmss);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 메인 화면
|
||||
*/
|
||||
export function DashboardMain() {
|
||||
// [State] KIS 키 입력/검증 상태(zustand + persist)
|
||||
const {
|
||||
kisTradingEnvInput,
|
||||
kisAppKeyInput,
|
||||
kisAppSecretInput,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
tradingEnv,
|
||||
setKisTradingEnvInput,
|
||||
setKisAppKeyInput,
|
||||
setKisAppSecretInput,
|
||||
setVerifiedKisSession,
|
||||
invalidateKisVerification,
|
||||
clearKisRuntimeSession,
|
||||
} = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
tradingEnv: state.tradingEnv,
|
||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||
setKisAppKeyInput: state.setKisAppKeyInput,
|
||||
setKisAppSecretInput: state.setKisAppSecretInput,
|
||||
setVerifiedKisSession: state.setVerifiedKisSession,
|
||||
invalidateKisVerification: state.invalidateKisVerification,
|
||||
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
||||
})),
|
||||
);
|
||||
|
||||
// [State] 검증 상태 메시지
|
||||
const [kisStatusMessage, setKisStatusMessage] = useState<string | null>(null);
|
||||
const [kisStatusError, setKisStatusError] = useState<string | null>(null);
|
||||
|
||||
// [State] 검색/선택 데이터
|
||||
const [keyword, setKeyword] = useState("삼성전자");
|
||||
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(null);
|
||||
const [selectedOverviewMeta, setSelectedOverviewMeta] = useState<{
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
fetchedAt: string;
|
||||
} | null>(null);
|
||||
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>([]);
|
||||
const [isRealtimeConnected, setIsRealtimeConnected] = useState(false);
|
||||
const [realtimeError, setRealtimeError] = useState<string | null>(null);
|
||||
const [lastRealtimeTickAt, setLastRealtimeTickAt] = useState<number | null>(null);
|
||||
const [realtimeTickCount, setRealtimeTickCount] = useState(0);
|
||||
|
||||
// [State] 영역별 에러
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
// [State] 비동기 전환 상태
|
||||
const [isValidatingKis, startValidateTransition] = useTransition();
|
||||
const [isRevokingKis, startRevokeTransition] = useTransition();
|
||||
const [isSearching, startSearchTransition] = useTransition();
|
||||
const [isLoadingOverview, startOverviewTransition] = useTransition();
|
||||
|
||||
const realtimeSocketRef = useRef<WebSocket | null>(null);
|
||||
const realtimeApprovalKeyRef = useRef<string | null>(null);
|
||||
const lastRealtimeTickOrderRef = useRef<number>(-1);
|
||||
const isPositive = (selectedStock?.change ?? 0) >= 0;
|
||||
const chartCandles =
|
||||
isRealtimeConnected && realtimeCandles.length > 0 ? realtimeCandles : (selectedStock?.candles ?? []);
|
||||
const apiPriceSourceLabel = selectedOverviewMeta
|
||||
? getPriceSourceLabel(selectedOverviewMeta.priceSource, selectedOverviewMeta.marketPhase)
|
||||
: null;
|
||||
const realtimeTrId = verifiedCredentials ? resolveRealtimeTrId(verifiedCredentials.tradingEnv) : null;
|
||||
const effectivePriceSourceLabel =
|
||||
isRealtimeConnected && lastRealtimeTickAt
|
||||
? `실시간 체결(WebSocket ${realtimeTrId ?? KIS_REALTIME_TR_ID_REAL})`
|
||||
: apiPriceSourceLabel;
|
||||
|
||||
useEffect(() => {
|
||||
setRealtimeCandles([]);
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError(null);
|
||||
setLastRealtimeTickAt(null);
|
||||
setRealtimeTickCount(0);
|
||||
lastRealtimeTickOrderRef.current = -1;
|
||||
}, [selectedStock?.symbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRealtimeConnected || lastRealtimeTickAt) return;
|
||||
|
||||
const noTickTimer = window.setTimeout(() => {
|
||||
setRealtimeError("실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.");
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(noTickTimer);
|
||||
};
|
||||
}, [isRealtimeConnected, lastRealtimeTickAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const symbol = selectedStock?.symbol;
|
||||
|
||||
if (!symbol || !isKisVerified || !verifiedCredentials) {
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError(null);
|
||||
setRealtimeTickCount(0);
|
||||
lastRealtimeTickOrderRef.current = -1;
|
||||
realtimeSocketRef.current?.close();
|
||||
realtimeSocketRef.current = null;
|
||||
realtimeApprovalKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const realtimeTrId = resolveRealtimeTrId(verifiedCredentials.tradingEnv);
|
||||
|
||||
const connectKisRealtimePrice = async () => {
|
||||
try {
|
||||
setRealtimeError(null);
|
||||
setIsRealtimeConnected(false);
|
||||
|
||||
const approval = await fetchKisWebSocketApproval(verifiedCredentials);
|
||||
if (disposed) return;
|
||||
|
||||
realtimeApprovalKeyRef.current = approval.approvalKey ?? null;
|
||||
socket = new WebSocket(`${approval.wsUrl}/tryitout`);
|
||||
realtimeSocketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !realtimeApprovalKeyRef.current) return;
|
||||
|
||||
const subscribeMessage = buildKisRealtimeMessage(realtimeApprovalKeyRef.current, symbol, realtimeTrId, "1");
|
||||
socket?.send(JSON.stringify(subscribeMessage));
|
||||
setIsRealtimeConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
const tick = parseKisRealtimeTick(event.data, symbol, realtimeTrId);
|
||||
if (!tick) return;
|
||||
|
||||
// 지연 도착으로 시간이 역행하는 틱은 무시해 차트 흔들림을 줄입니다.
|
||||
const nextTickOrder = toTickOrderValue(tick.tickTime);
|
||||
if (nextTickOrder > 0 && lastRealtimeTickOrderRef.current > nextTickOrder) {
|
||||
return;
|
||||
}
|
||||
if (nextTickOrder > 0) {
|
||||
lastRealtimeTickOrderRef.current = nextTickOrder;
|
||||
}
|
||||
|
||||
setRealtimeError(null);
|
||||
setLastRealtimeTickAt(Date.now());
|
||||
setRealtimeTickCount((prev) => prev + 1);
|
||||
setRealtimeCandles((prev) => appendRealtimeTick(prev, tick.point));
|
||||
|
||||
// 실시간 체결가를 카드 현재가/등락/거래량에도 반영합니다.
|
||||
setSelectedStock((prev) => {
|
||||
if (!prev || prev.symbol !== symbol) return prev;
|
||||
|
||||
const nextPrice = tick.price;
|
||||
const nextChange = nextPrice - prev.prevClose;
|
||||
const nextChangeRate = prev.prevClose > 0 ? (nextChange / prev.prevClose) * 100 : prev.changeRate;
|
||||
const nextHigh = prev.high > 0 ? Math.max(prev.high, nextPrice) : nextPrice;
|
||||
const nextLow = prev.low > 0 ? Math.min(prev.low, nextPrice) : nextPrice;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentPrice: nextPrice,
|
||||
change: nextChange,
|
||||
changeRate: nextChangeRate,
|
||||
high: nextHigh,
|
||||
low: nextLow,
|
||||
volume: tick.accumulatedVolume > 0 ? tick.accumulatedVolume : prev.volume,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (disposed) return;
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError("실시간 연결 중 오류가 발생했습니다.");
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (disposed) return;
|
||||
setIsRealtimeConnected(false);
|
||||
};
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message =
|
||||
error instanceof Error ? error.message : "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
|
||||
setRealtimeError(message);
|
||||
setIsRealtimeConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connectKisRealtimePrice();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsRealtimeConnected(false);
|
||||
|
||||
const approvalKey = realtimeApprovalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
|
||||
const unsubscribeMessage = buildKisRealtimeMessage(approvalKey, symbol, realtimeTrId, "2");
|
||||
socket.send(JSON.stringify(unsubscribeMessage));
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (realtimeSocketRef.current === socket) {
|
||||
realtimeSocketRef.current = null;
|
||||
}
|
||||
realtimeApprovalKeyRef.current = null;
|
||||
};
|
||||
}, [
|
||||
isKisVerified,
|
||||
selectedStock?.symbol,
|
||||
verifiedCredentials,
|
||||
]);
|
||||
|
||||
const loadOverview = useCallback(
|
||||
async (symbol: string, credentials: KisRuntimeCredentials) => {
|
||||
try {
|
||||
setOverviewError(null);
|
||||
|
||||
const data = await fetchStockOverview(symbol, credentials);
|
||||
setSelectedStock(data.stock);
|
||||
setSelectedOverviewMeta({
|
||||
priceSource: data.priceSource,
|
||||
marketPhase: data.marketPhase,
|
||||
fetchedAt: data.fetchedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "종목 조회 중 오류가 발생했습니다.";
|
||||
setOverviewError(message);
|
||||
setSelectedOverviewMeta(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadSearch = useCallback(
|
||||
async (nextKeyword: string, credentials: KisRuntimeCredentials, pickFirst = false) => {
|
||||
try {
|
||||
setSearchError(null);
|
||||
|
||||
const data = await fetchStockSearch(nextKeyword);
|
||||
setSearchResults(data.items);
|
||||
|
||||
if (pickFirst && data.items[0]) {
|
||||
await loadOverview(data.items[0].symbol, credentials);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "종목 검색 중 오류가 발생했습니다.";
|
||||
setSearchError(message);
|
||||
}
|
||||
},
|
||||
[loadOverview],
|
||||
);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isKisVerified || !verifiedCredentials) {
|
||||
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
startSearchTransition(() => {
|
||||
void loadSearch(keyword, verifiedCredentials, true);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePickStock(item: DashboardStockSearchItem) {
|
||||
if (!isKisVerified || !verifiedCredentials) {
|
||||
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyword(item.name);
|
||||
|
||||
startOverviewTransition(() => {
|
||||
void loadOverview(item.symbol, verifiedCredentials);
|
||||
});
|
||||
}
|
||||
|
||||
function handleValidateKis() {
|
||||
startValidateTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setKisStatusError(null);
|
||||
setKisStatusMessage(null);
|
||||
|
||||
const trimmedAppKey = kisAppKeyInput.trim();
|
||||
const trimmedAppSecret = kisAppSecretInput.trim();
|
||||
|
||||
if (!trimmedAppKey || !trimmedAppSecret) {
|
||||
throw new Error("앱 키와 앱 시크릿을 모두 입력해 주세요.");
|
||||
}
|
||||
|
||||
const credentials: KisRuntimeCredentials = {
|
||||
appKey: trimmedAppKey,
|
||||
appSecret: trimmedAppSecret,
|
||||
tradingEnv: kisTradingEnvInput,
|
||||
};
|
||||
|
||||
const result = await validateKisCredentials(credentials);
|
||||
|
||||
setVerifiedKisSession(credentials, result.tradingEnv);
|
||||
setKisStatusMessage(
|
||||
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`,
|
||||
);
|
||||
|
||||
startSearchTransition(() => {
|
||||
void loadSearch(keyword || "삼성전자", credentials, true);
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
|
||||
|
||||
invalidateKisVerification();
|
||||
setSearchResults([]);
|
||||
setSelectedStock(null);
|
||||
setSelectedOverviewMeta(null);
|
||||
setKisStatusError(message);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevokeKis() {
|
||||
if (!verifiedCredentials) {
|
||||
setKisStatusError("먼저 API 키 검증을 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
startRevokeTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
// 접근 폐기 전, 화면 상태 메시지를 초기화합니다.
|
||||
setKisStatusError(null);
|
||||
setKisStatusMessage(null);
|
||||
|
||||
const result = await revokeKisCredentials(verifiedCredentials);
|
||||
|
||||
// 로그아웃처럼 검증/조회 상태를 초기화합니다.
|
||||
clearKisRuntimeSession(result.tradingEnv);
|
||||
setSearchResults([]);
|
||||
setSelectedStock(null);
|
||||
setSelectedOverviewMeta(null);
|
||||
setSearchError(null);
|
||||
setOverviewError(null);
|
||||
setKisStatusMessage(`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
|
||||
setKisStatusError(message);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* ========== KIS KEY VERIFY SECTION ========== */}
|
||||
<section>
|
||||
<Card className="border-brand-200 bg-gradient-to-r from-brand-50/60 to-background">
|
||||
<CardHeader>
|
||||
<CardTitle>KIS API 키 연결</CardTitle>
|
||||
<CardDescription>
|
||||
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요. 검증에 성공해야 시세 조회가 동작합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">거래 모드</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
||||
className={cn("flex-1", kisTradingEnvInput === "real" ? "bg-brand-600 hover:bg-brand-700" : "")}
|
||||
onClick={() => setKisTradingEnvInput("real")}
|
||||
>
|
||||
실전
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
||||
className={cn("flex-1", kisTradingEnvInput === "mock" ? "bg-brand-600 hover:bg-brand-700" : "")}
|
||||
onClick={() => setKisTradingEnvInput("mock")}
|
||||
>
|
||||
모의
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">KIS App Key</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppKeyInput}
|
||||
onChange={(event) => setKisAppKeyInput(event.target.value)}
|
||||
placeholder="앱 키 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">KIS App Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppSecretInput}
|
||||
onChange={(event) => setKisAppSecretInput(event.target.value)}
|
||||
placeholder="앱 시크릿 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleValidateKis}
|
||||
disabled={isValidatingKis || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()}
|
||||
className="bg-brand-600 hover:bg-brand-700"
|
||||
>
|
||||
{isValidatingKis ? "검증 중..." : "API 키 검증"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRevokeKis}
|
||||
disabled={isRevokingKis || !isKisVerified || !verifiedCredentials}
|
||||
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
|
||||
>
|
||||
{isRevokingKis ? "폐기 중..." : "접근 폐기"}
|
||||
</Button>
|
||||
|
||||
{isKisVerified ? (
|
||||
<span className="rounded-full bg-brand-100 px-3 py-1 text-xs font-semibold text-brand-700">
|
||||
검증 완료 ({tradingEnv === "real" ? "실전" : "모의"})
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">미검증</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{kisStatusError ? <p className="text-sm text-red-600">{kisStatusError}</p> : null}
|
||||
{kisStatusMessage ? <p className="text-sm text-brand-700">{kisStatusMessage}</p> : null}
|
||||
|
||||
<div className="rounded-lg border border-brand-200 bg-brand-50/70 px-3 py-2 text-xs text-brand-800">
|
||||
입력한 API 키는 새로고침 유지를 위해 현재 브라우저 저장소(zustand persist)에만 보관되며, 접근 폐기를 누르면 즉시 초기화됩니다.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ========== DASHBOARD TITLE SECTION ========== */}
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold tracking-tight">국내주식 대시보드</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
종목명 검색, 현재가, 일자별 차트를 한 화면에서 확인합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ========== STOCK SEARCH SECTION ========== */}
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>종목 검색</CardTitle>
|
||||
<CardDescription>종목명 또는 종목코드(예: 삼성전자, 005930)로 검색할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="종목명 / 종목코드 검색"
|
||||
className="pl-9"
|
||||
disabled={!isKisVerified}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="md:min-w-28" disabled={!isKisVerified || isSearching || !keyword.trim()}>
|
||||
{isSearching ? "검색 중..." : "검색"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{!isKisVerified ? (
|
||||
<p className="text-xs text-muted-foreground">상단에서 API 키 검증을 완료해야 검색/시세 기능이 동작합니다.</p>
|
||||
) : searchError ? (
|
||||
<p className="text-sm text-red-600">{searchError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{searchResults.length > 0
|
||||
? `검색 결과 ${searchResults.length}개`
|
||||
: "검색어를 입력하고 엔터를 누르면 종목이 표시됩니다."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
|
||||
{searchResults.map((item) => {
|
||||
const active = item.symbol === selectedStock?.symbol;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.symbol}-${item.market}`}
|
||||
type="button"
|
||||
onClick={() => handlePickStock(item)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors",
|
||||
active
|
||||
? "border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300"
|
||||
: "border-border bg-background hover:bg-muted/60",
|
||||
)}
|
||||
disabled={!isKisVerified}
|
||||
>
|
||||
<p className="text-sm font-semibold">{item.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.symbol} · {item.market}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ========== STOCK OVERVIEW SECTION ========== */}
|
||||
<section className="grid gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{selectedStock?.name ?? "종목을 선택해 주세요"}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedStock ? `${selectedStock.symbol} · ${selectedStock.market}` : "선택된 종목이 없습니다."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{selectedStock && (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-semibold",
|
||||
isPositive
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-900/35 dark:text-brand-300"
|
||||
: "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
)}
|
||||
>
|
||||
{isPositive ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
|
||||
{isPositive ? "+" : ""}
|
||||
{selectedStock.change.toLocaleString()} ({isPositive ? "+" : ""}
|
||||
{selectedStock.changeRate.toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewError ? (
|
||||
<p className="text-sm text-red-600">{overviewError}</p>
|
||||
) : !isKisVerified ? (
|
||||
<p className="text-sm text-muted-foreground">상단에서 API 키 검증을 완료해 주세요.</p>
|
||||
) : isLoadingOverview && !selectedStock ? (
|
||||
<p className="text-sm text-muted-foreground">종목 데이터를 불러오는 중입니다...</p>
|
||||
) : selectedStock ? (
|
||||
<>
|
||||
<p className="mb-4 text-3xl font-extrabold tracking-tight">{formatPrice(selectedStock.currentPrice)}</p>
|
||||
{effectivePriceSourceLabel && selectedOverviewMeta ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-1 text-brand-700">
|
||||
현재가 소스: {effectivePriceSourceLabel}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-1">
|
||||
구간: {getMarketPhaseLabel(selectedOverviewMeta.marketPhase)}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-1">
|
||||
조회시각: {new Date(selectedOverviewMeta.fetchedAt).toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<StockLineChart candles={chartCandles} />
|
||||
{realtimeError ? <p className="mt-3 text-xs text-red-600">{realtimeError}</p> : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">종목을 선택하면 시세와 차트가 표시됩니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">핵심 지표</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<PriceStat label="시가" value={formatPrice(selectedStock?.open ?? 0)} />
|
||||
<PriceStat label="고가" value={formatPrice(selectedStock?.high ?? 0)} />
|
||||
<PriceStat label="저가" value={formatPrice(selectedStock?.low ?? 0)} />
|
||||
<PriceStat label="전일 종가" value={formatPrice(selectedStock?.prevClose ?? 0)} />
|
||||
<PriceStat label="누적 거래량" value={formatVolume(selectedStock?.volume ?? 0)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">연동 상태</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-brand-500" />
|
||||
<p>국내주식 {tradingEnv === "real" ? "실전" : "모의"}투자 API 연결 완료</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className={cn("h-4 w-4", isRealtimeConnected ? "text-brand-500" : "text-muted-foreground")} />
|
||||
<p>
|
||||
실시간 체결가 연결 상태:{" "}
|
||||
{isRealtimeConnected ? "연결됨 (WebSocket)" : "대기 중 (일봉 차트 표시)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<p>마지막 실시간 수신: {lastRealtimeTickAt ? new Date(lastRealtimeTickAt).toLocaleTimeString("ko-KR") : "수신 전"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<p>실시간 틱 수신 수: {realtimeTickCount.toLocaleString("ko-KR")}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-brand-500" />
|
||||
<p>다음 단계: 주문/리스크 제어 API 연결</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21578
features/dashboard/data/korean-stocks.json
Normal file
21578
features/dashboard/data/korean-stocks.json
Normal file
File diff suppressed because it is too large
Load Diff
10
features/dashboard/data/korean-stocks.ts
Normal file
10
features/dashboard/data/korean-stocks.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import rawStocks from "@/features/dashboard/data/korean-stocks.json";
|
||||
import type { KoreanStockIndexItem } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* 국내주식 검색 인덱스(KOSPI + KOSDAQ)
|
||||
* - 파일 원본: korean-stocks.json
|
||||
* - 사용처: /api/kis/domestic/search 라우트의 메모리 검색
|
||||
* @see app/api/kis/domestic/search/route.ts 종목명/종목코드 검색에 사용합니다.
|
||||
*/
|
||||
export const KOREAN_STOCK_INDEX = rawStocks as KoreanStockIndexItem[];
|
||||
126
features/dashboard/data/mock-stocks.ts
Normal file
126
features/dashboard/data/mock-stocks.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @file features/dashboard/data/mock-stocks.ts
|
||||
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
|
||||
* @remarks
|
||||
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
|
||||
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
|
||||
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
|
||||
*/
|
||||
|
||||
import type { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* 대시보드 목업 종목 목록
|
||||
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
|
||||
* @see features/dashboard/components/dashboard-main.tsx 검색/차트/지표 카드의 기본 데이터 소스입니다.
|
||||
*/
|
||||
export const MOCK_STOCKS: DashboardStockItem[] = [
|
||||
{
|
||||
symbol: "005930",
|
||||
name: "삼성전자",
|
||||
market: "KOSPI",
|
||||
currentPrice: 78500,
|
||||
change: 1200,
|
||||
changeRate: 1.55,
|
||||
open: 77300,
|
||||
high: 78900,
|
||||
low: 77000,
|
||||
prevClose: 77300,
|
||||
volume: 15234012,
|
||||
candles: [
|
||||
{ time: "09:00", price: 74400 },
|
||||
{ time: "09:10", price: 74650 },
|
||||
{ time: "09:20", price: 75100 },
|
||||
{ time: "09:30", price: 74950 },
|
||||
{ time: "09:40", price: 75300 },
|
||||
{ time: "09:50", price: 75600 },
|
||||
{ time: "10:00", price: 75400 },
|
||||
{ time: "10:10", price: 75850 },
|
||||
{ time: "10:20", price: 76100 },
|
||||
{ time: "10:30", price: 75950 },
|
||||
{ time: "10:40", price: 76350 },
|
||||
{ time: "10:50", price: 76700 },
|
||||
{ time: "11:00", price: 76900 },
|
||||
{ time: "11:10", price: 77250 },
|
||||
{ time: "11:20", price: 77100 },
|
||||
{ time: "11:30", price: 77400 },
|
||||
{ time: "11:40", price: 77700 },
|
||||
{ time: "11:50", price: 78150 },
|
||||
{ time: "12:00", price: 77900 },
|
||||
{ time: "12:10", price: 78300 },
|
||||
{ time: "12:20", price: 78500 },
|
||||
],
|
||||
},
|
||||
{
|
||||
symbol: "000660",
|
||||
name: "SK하이닉스",
|
||||
market: "KOSPI",
|
||||
currentPrice: 214500,
|
||||
change: -1500,
|
||||
changeRate: -0.69,
|
||||
open: 216000,
|
||||
high: 218000,
|
||||
low: 213000,
|
||||
prevClose: 216000,
|
||||
volume: 3210450,
|
||||
candles: [
|
||||
{ time: "09:00", price: 221000 },
|
||||
{ time: "09:10", price: 220400 },
|
||||
{ time: "09:20", price: 219900 },
|
||||
{ time: "09:30", price: 220200 },
|
||||
{ time: "09:40", price: 219300 },
|
||||
{ time: "09:50", price: 218500 },
|
||||
{ time: "10:00", price: 217900 },
|
||||
{ time: "10:10", price: 218300 },
|
||||
{ time: "10:20", price: 217600 },
|
||||
{ time: "10:30", price: 216900 },
|
||||
{ time: "10:40", price: 216500 },
|
||||
{ time: "10:50", price: 216800 },
|
||||
{ time: "11:00", price: 215900 },
|
||||
{ time: "11:10", price: 215300 },
|
||||
{ time: "11:20", price: 214800 },
|
||||
{ time: "11:30", price: 215100 },
|
||||
{ time: "11:40", price: 214200 },
|
||||
{ time: "11:50", price: 214700 },
|
||||
{ time: "12:00", price: 214300 },
|
||||
{ time: "12:10", price: 214600 },
|
||||
{ time: "12:20", price: 214500 },
|
||||
],
|
||||
},
|
||||
{
|
||||
symbol: "035420",
|
||||
name: "NAVER",
|
||||
market: "KOSPI",
|
||||
currentPrice: 197800,
|
||||
change: 2200,
|
||||
changeRate: 1.12,
|
||||
open: 195500,
|
||||
high: 198600,
|
||||
low: 194900,
|
||||
prevClose: 195600,
|
||||
volume: 1904123,
|
||||
candles: [
|
||||
{ time: "09:00", price: 191800 },
|
||||
{ time: "09:10", price: 192400 },
|
||||
{ time: "09:20", price: 193000 },
|
||||
{ time: "09:30", price: 192700 },
|
||||
{ time: "09:40", price: 193600 },
|
||||
{ time: "09:50", price: 194200 },
|
||||
{ time: "10:00", price: 194000 },
|
||||
{ time: "10:10", price: 194900 },
|
||||
{ time: "10:20", price: 195100 },
|
||||
{ time: "10:30", price: 194700 },
|
||||
{ time: "10:40", price: 195800 },
|
||||
{ time: "10:50", price: 196400 },
|
||||
{ time: "11:00", price: 196100 },
|
||||
{ time: "11:10", price: 196900 },
|
||||
{ time: "11:20", price: 197200 },
|
||||
{ time: "11:30", price: 197000 },
|
||||
{ time: "11:40", price: 197600 },
|
||||
{ time: "11:50", price: 198000 },
|
||||
{ time: "12:00", price: 197400 },
|
||||
{ time: "12:10", price: 198300 },
|
||||
{ time: "12:20", price: 197800 },
|
||||
],
|
||||
},
|
||||
];
|
||||
140
features/dashboard/store/use-kis-runtime-store.ts
Normal file
140
features/dashboard/store/use-kis-runtime-store.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* @file features/dashboard/store/use-kis-runtime-store.ts
|
||||
* @description KIS 키 입력/검증 상태를 zustand로 관리하고 새로고침 시 복원합니다.
|
||||
*/
|
||||
|
||||
export interface KisRuntimeCredentials {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: KisTradingEnv;
|
||||
}
|
||||
|
||||
interface KisRuntimeStoreState {
|
||||
// [State] 입력 폼 상태
|
||||
kisTradingEnvInput: KisTradingEnv;
|
||||
kisAppKeyInput: string;
|
||||
kisAppSecretInput: string;
|
||||
|
||||
// [State] 검증/연동 상태
|
||||
verifiedCredentials: KisRuntimeCredentials | null;
|
||||
isKisVerified: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
}
|
||||
|
||||
interface KisRuntimeStoreActions {
|
||||
/**
|
||||
* 거래 모드 입력값을 변경하고 기존 검증 상태를 무효화합니다.
|
||||
* @param tradingEnv 거래 모드
|
||||
* @see features/dashboard/components/dashboard-main.tsx 거래 모드 버튼 onClick 이벤트
|
||||
*/
|
||||
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
|
||||
/**
|
||||
* 앱 키 입력값을 변경하고 기존 검증 상태를 무효화합니다.
|
||||
* @param appKey 앱 키
|
||||
* @see features/dashboard/components/dashboard-main.tsx App Key onChange 이벤트
|
||||
*/
|
||||
setKisAppKeyInput: (appKey: string) => void;
|
||||
/**
|
||||
* 앱 시크릿 입력값을 변경하고 기존 검증 상태를 무효화합니다.
|
||||
* @param appSecret 앱 시크릿
|
||||
* @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트
|
||||
*/
|
||||
setKisAppSecretInput: (appSecret: string) => void;
|
||||
/**
|
||||
* 검증 성공 상태를 저장합니다.
|
||||
* @param credentials 검증 완료된 키
|
||||
* @param tradingEnv 현재 연동 모드
|
||||
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis
|
||||
*/
|
||||
setVerifiedKisSession: (credentials: KisRuntimeCredentials, tradingEnv: KisTradingEnv) => void;
|
||||
/**
|
||||
* 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다.
|
||||
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch
|
||||
*/
|
||||
invalidateKisVerification: () => void;
|
||||
/**
|
||||
* 접근 폐기 시 입력값/검증값을 모두 제거합니다.
|
||||
* @param tradingEnv 표시용 모드
|
||||
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis
|
||||
*/
|
||||
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: KisRuntimeStoreState = {
|
||||
kisTradingEnvInput: "real",
|
||||
kisAppKeyInput: "",
|
||||
kisAppSecretInput: "",
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
tradingEnv: "real",
|
||||
};
|
||||
|
||||
export const useKisRuntimeStore = create<KisRuntimeStoreState & KisRuntimeStoreActions>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...INITIAL_STATE,
|
||||
|
||||
setKisTradingEnvInput: (tradingEnv) =>
|
||||
set({
|
||||
kisTradingEnvInput: tradingEnv,
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
}),
|
||||
|
||||
setKisAppKeyInput: (appKey) =>
|
||||
set({
|
||||
kisAppKeyInput: appKey,
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
}),
|
||||
|
||||
setKisAppSecretInput: (appSecret) =>
|
||||
set({
|
||||
kisAppSecretInput: appSecret,
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
}),
|
||||
|
||||
setVerifiedKisSession: (credentials, tradingEnv) =>
|
||||
set({
|
||||
verifiedCredentials: credentials,
|
||||
isKisVerified: true,
|
||||
tradingEnv,
|
||||
}),
|
||||
|
||||
invalidateKisVerification: () =>
|
||||
set({
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
}),
|
||||
|
||||
clearKisRuntimeSession: (tradingEnv) =>
|
||||
set({
|
||||
kisTradingEnvInput: tradingEnv,
|
||||
kisAppKeyInput: "",
|
||||
kisAppSecretInput: "",
|
||||
verifiedCredentials: null,
|
||||
isKisVerified: false,
|
||||
tradingEnv,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "autotrade-kis-runtime-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
tradingEnv: state.tradingEnv,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
108
features/dashboard/types/dashboard.types.ts
Normal file
108
features/dashboard/types/dashboard.types.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @file features/dashboard/types/dashboard.types.ts
|
||||
* @description 대시보드(검색/시세/차트)에서 공통으로 쓰는 타입 모음
|
||||
*/
|
||||
|
||||
export type KisTradingEnv = "real" | "mock";
|
||||
export type DashboardPriceSource = "inquire-price" | "inquire-ccnl" | "inquire-overtime-price";
|
||||
export type DashboardMarketPhase = "regular" | "afterHours";
|
||||
|
||||
/**
|
||||
* KOSPI/KOSDAQ 종목 인덱스 항목
|
||||
*/
|
||||
export interface KoreanStockIndexItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
standardCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 1개 점(시점 + 가격)
|
||||
*/
|
||||
export interface StockCandlePoint {
|
||||
time: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 종목 상세 모델
|
||||
*/
|
||||
export interface DashboardStockItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
currentPrice: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
prevClose: number;
|
||||
volume: number;
|
||||
candles: StockCandlePoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 1개 항목
|
||||
*/
|
||||
export interface DashboardStockSearchItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: "KOSPI" | "KOSDAQ";
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 검색 API 응답
|
||||
*/
|
||||
export interface DashboardStockSearchResponse {
|
||||
query: string;
|
||||
items: DashboardStockSearchItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 개요 API 응답
|
||||
*/
|
||||
export interface DashboardStockOverviewResponse {
|
||||
stock: DashboardStockItem;
|
||||
source: "kis";
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
tradingEnv: KisTradingEnv;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 키 검증 API 응답
|
||||
*/
|
||||
export interface DashboardKisValidateResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
message: string;
|
||||
sample?: {
|
||||
symbol: string;
|
||||
name: string;
|
||||
currentPrice: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 키 접근 폐기 API 응답
|
||||
*/
|
||||
export interface DashboardKisRevokeResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 웹소켓 승인키 발급 API 응답
|
||||
*/
|
||||
export interface DashboardKisWsApprovalResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
message: string;
|
||||
approvalKey?: string;
|
||||
wsUrl?: string;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-14 z-30 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:block md:w-64 lg:w-72">
|
||||
<aside className="hidden h-[calc(100vh-4rem)] shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:top-16 md:block md:w-64 lg:w-72">
|
||||
<div className="flex flex-col space-y-1">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isActive = item.matchExact
|
||||
|
||||
142
lib/kis/approval.ts
Normal file
142
lib/kis/approval.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @file lib/kis/approval.ts
|
||||
* @description KIS 웹소켓 approval key 발급/캐시 관리
|
||||
*/
|
||||
|
||||
interface KisApprovalResponse {
|
||||
approval_key?: string;
|
||||
msg1?: string;
|
||||
msg_cd?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface KisApprovalCache {
|
||||
approvalKey: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const approvalCacheMap = new Map<string, KisApprovalCache>();
|
||||
const approvalIssueInFlightMap = new Map<string, Promise<KisApprovalCache>>();
|
||||
const APPROVAL_REFRESH_BUFFER_MS = 60 * 1000;
|
||||
|
||||
function hashKey(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function getApprovalCacheKey(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 웹소켓 approval key 발급
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns approval key + expiresAt
|
||||
* @see app/api/kis/ws/approval/route.ts POST - 실시간 차트 연결 시 호출
|
||||
*/
|
||||
async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<KisApprovalCache> {
|
||||
const config = getKisConfig(credentials);
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/oauth2/Approval`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "client_credentials",
|
||||
appkey: config.appKey,
|
||||
secretkey: config.appSecret,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseApprovalResponse(rawText);
|
||||
|
||||
if (!response.ok || !payload.approval_key) {
|
||||
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
|
||||
: `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 공식 샘플은 1일 단위 재발급을 권장하므로 토큰과 동일하게 보수적으로 23시간 캐시합니다.
|
||||
return {
|
||||
approvalKey: payload.approval_key,
|
||||
expiresAt: Date.now() + 23 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* approval 응답을 안전하게 JSON으로 파싱합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisApprovalResponse
|
||||
*/
|
||||
function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisApprovalResponse;
|
||||
} catch {
|
||||
return {
|
||||
msg1: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹소켓 승인키를 반환합니다.
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns approval key
|
||||
*/
|
||||
export async function getKisApprovalKey(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getApprovalCacheKey(credentials);
|
||||
const cached = approvalCacheMap.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt - APPROVAL_REFRESH_BUFFER_MS > Date.now()) {
|
||||
return cached.approvalKey;
|
||||
}
|
||||
|
||||
const inFlight = approvalIssueInFlightMap.get(cacheKey);
|
||||
if (inFlight) {
|
||||
const shared = await inFlight;
|
||||
return shared.approvalKey;
|
||||
}
|
||||
|
||||
const nextPromise = issueKisApprovalKey(credentials);
|
||||
approvalIssueInFlightMap.set(cacheKey, nextPromise);
|
||||
const next = await nextPromise.finally(() => {
|
||||
approvalIssueInFlightMap.delete(cacheKey);
|
||||
});
|
||||
|
||||
approvalCacheMap.set(cacheKey, next);
|
||||
return next.approvalKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 모드에 맞는 KIS 웹소켓 URL을 반환합니다.
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns websocket url
|
||||
*/
|
||||
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return getKisWebSocketUrl(config.tradingEnv);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인키 캐시를 제거합니다.
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
*/
|
||||
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getApprovalCacheKey(credentials);
|
||||
approvalCacheMap.delete(cacheKey);
|
||||
approvalIssueInFlightMap.delete(cacheKey);
|
||||
}
|
||||
89
lib/kis/client.ts
Normal file
89
lib/kis/client.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { getKisConfig } from "@/lib/kis/config";
|
||||
import { getKisAccessToken } from "@/lib/kis/token";
|
||||
|
||||
/**
|
||||
* @file lib/kis/client.ts
|
||||
* @description KIS REST 공통 클라이언트(실전/모의 공통)
|
||||
*/
|
||||
|
||||
export interface KisApiEnvelope<TOutput> {
|
||||
rt_cd?: string;
|
||||
msg_cd?: string;
|
||||
msg1?: string;
|
||||
output?: TOutput;
|
||||
output1?: unknown;
|
||||
output2?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS GET 호출
|
||||
* @param apiPath REST 경로
|
||||
* @param trId KIS TR ID
|
||||
* @param params 쿼리 파라미터
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns KIS 원본 응답
|
||||
* @see lib/kis/domestic.ts getDomesticQuote/getDomesticDailyPrice - 대시보드 시세 데이터 소스
|
||||
*/
|
||||
export async function kisGet<TOutput>(
|
||||
apiPath: string,
|
||||
trId: string,
|
||||
params: Record<string, string>,
|
||||
credentials?: KisCredentialInput,
|
||||
): Promise<KisApiEnvelope<TOutput>> {
|
||||
const config = getKisConfig(credentials);
|
||||
const token = await getKisAccessToken(credentials);
|
||||
|
||||
const url = new URL(apiPath, config.baseUrl);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== "") url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
authorization: `Bearer ${token}`,
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
tr_id: trId,
|
||||
tr_cont: "",
|
||||
custtype: "P",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseKisEnvelope<TOutput>(rawText);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload.msg1 || rawText.slice(0, 200);
|
||||
throw new Error(detail ? `KIS API 요청 실패 (${response.status}): ${detail}` : `KIS API 요청 실패 (${response.status})`);
|
||||
}
|
||||
|
||||
if (payload.rt_cd && payload.rt_cd !== "0") {
|
||||
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
|
||||
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 응답을 안전하게 JSON으로 파싱합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisApiEnvelope
|
||||
* @see lib/kis/token.ts tryParseTokenResponse - 토큰 발급 응답 파싱 방식과 동일 패턴
|
||||
*/
|
||||
function tryParseKisEnvelope<TOutput>(rawText: string): KisApiEnvelope<TOutput> {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisApiEnvelope<TOutput>;
|
||||
} catch {
|
||||
return {
|
||||
msg1: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 하위 호환(alias)
|
||||
export const kisMockGet = kisGet;
|
||||
122
lib/kis/config.ts
Normal file
122
lib/kis/config.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @file lib/kis/config.ts
|
||||
* @description KIS 거래 환경(real/mock) 설정과 키/도메인 로딩
|
||||
*/
|
||||
|
||||
export type KisTradingEnv = "real" | "mock";
|
||||
|
||||
export interface KisCredentialInput {
|
||||
tradingEnv?: KisTradingEnv;
|
||||
appKey?: string;
|
||||
appSecret?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface KisConfig {
|
||||
tradingEnv: KisTradingEnv;
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
const DEFAULT_KIS_REAL_BASE_URL = "https://openapi.koreainvestment.com:9443";
|
||||
const DEFAULT_KIS_MOCK_BASE_URL = "https://openapivts.koreainvestment.com:29443";
|
||||
const DEFAULT_KIS_REAL_WS_URL = "ws://ops.koreainvestment.com:21000";
|
||||
const DEFAULT_KIS_MOCK_WS_URL = "ws://ops.koreainvestment.com:31000";
|
||||
|
||||
/**
|
||||
* 거래 환경 문자열을 정규화합니다.
|
||||
* @param value 환경값
|
||||
* @returns real | mock
|
||||
*/
|
||||
export function normalizeTradingEnv(value?: string): KisTradingEnv {
|
||||
return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 거래 환경을 반환합니다.
|
||||
* @returns real | mock
|
||||
*/
|
||||
export function getKisTradingEnv() {
|
||||
return normalizeTradingEnv(process.env.KIS_TRADING_ENV);
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 웹소켓 URL을 반환합니다.
|
||||
* @param tradingEnvInput 거래 모드(real/mock)
|
||||
* @returns websocket base url
|
||||
*/
|
||||
export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
|
||||
const tradingEnv = normalizeTradingEnv(tradingEnvInput);
|
||||
|
||||
if (tradingEnv === "real") {
|
||||
return process.env.KIS_WS_URL_REAL ?? DEFAULT_KIS_REAL_WS_URL;
|
||||
}
|
||||
|
||||
return process.env.KIS_WS_URL_MOCK ?? DEFAULT_KIS_MOCK_WS_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 준비 여부를 확인합니다.
|
||||
* @param input 외부(사용자 입력) 키가 있으면 우선 사용
|
||||
* @returns 사용 가능 여부
|
||||
*/
|
||||
export function hasKisConfig(input?: KisCredentialInput) {
|
||||
if (input?.appKey && input?.appSecret) return true;
|
||||
|
||||
const env = getKisTradingEnv();
|
||||
if (env === "real") {
|
||||
return Boolean(process.env.KIS_APP_KEY_REAL && process.env.KIS_APP_SECRET_REAL);
|
||||
}
|
||||
|
||||
return Boolean(process.env.KIS_APP_KEY_MOCK && process.env.KIS_APP_SECRET_MOCK);
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 호출에 필요한 설정을 반환합니다.
|
||||
* @param input 사용자 입력 키(선택)
|
||||
* @returns tradingEnv/appKey/appSecret/baseUrl
|
||||
*/
|
||||
export function getKisConfig(input?: KisCredentialInput): KisConfig {
|
||||
if (input?.appKey && input?.appSecret) {
|
||||
const tradingEnv = normalizeTradingEnv(input.tradingEnv);
|
||||
const baseUrl =
|
||||
input.baseUrl ??
|
||||
(tradingEnv === "real" ? DEFAULT_KIS_REAL_BASE_URL : DEFAULT_KIS_MOCK_BASE_URL);
|
||||
|
||||
return {
|
||||
tradingEnv,
|
||||
appKey: input.appKey,
|
||||
appSecret: input.appSecret,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const tradingEnv = getKisTradingEnv();
|
||||
|
||||
if (tradingEnv === "real") {
|
||||
const appKey = process.env.KIS_APP_KEY_REAL;
|
||||
const appSecret = process.env.KIS_APP_SECRET_REAL;
|
||||
const baseUrl = process.env.KIS_BASE_URL_REAL ?? DEFAULT_KIS_REAL_BASE_URL;
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error(
|
||||
"KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.",
|
||||
);
|
||||
}
|
||||
|
||||
return { tradingEnv, appKey, appSecret, baseUrl };
|
||||
}
|
||||
|
||||
const appKey = process.env.KIS_APP_KEY_MOCK;
|
||||
const appSecret = process.env.KIS_APP_SECRET_MOCK;
|
||||
const baseUrl = process.env.KIS_BASE_URL_MOCK ?? DEFAULT_KIS_MOCK_BASE_URL;
|
||||
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error(
|
||||
"KIS 모의투자 키가 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 환경변수를 설정하세요.",
|
||||
);
|
||||
}
|
||||
|
||||
return { tradingEnv, appKey, appSecret, baseUrl };
|
||||
}
|
||||
357
lib/kis/domestic.ts
Normal file
357
lib/kis/domestic.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import type { DashboardStockItem, StockCandlePoint } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { kisGet } from "@/lib/kis/client";
|
||||
|
||||
/**
|
||||
* @file lib/kis/domestic.ts
|
||||
* @description KIS 국내주식(현재가/일봉) 조회와 대시보드 모델 변환
|
||||
*/
|
||||
|
||||
interface KisDomesticQuoteOutput {
|
||||
hts_kor_isnm?: string;
|
||||
rprs_mrkt_kor_name?: string;
|
||||
bstp_kor_isnm?: string;
|
||||
stck_prpr?: string;
|
||||
prdy_vrss?: string;
|
||||
prdy_vrss_sign?: string;
|
||||
prdy_ctrt?: string;
|
||||
stck_oprc?: string;
|
||||
stck_hgpr?: string;
|
||||
stck_lwpr?: string;
|
||||
stck_sdpr?: string;
|
||||
stck_prdy_clpr?: string;
|
||||
acml_vol?: string;
|
||||
}
|
||||
|
||||
interface KisDomesticCcnlOutput {
|
||||
stck_prpr?: string;
|
||||
prdy_vrss?: string;
|
||||
prdy_vrss_sign?: string;
|
||||
prdy_ctrt?: string;
|
||||
cntg_vol?: string;
|
||||
}
|
||||
|
||||
interface KisDomesticOvertimePriceOutput {
|
||||
ovtm_untp_prpr?: string;
|
||||
ovtm_untp_prdy_vrss?: string;
|
||||
ovtm_untp_prdy_vrss_sign?: string;
|
||||
ovtm_untp_prdy_ctrt?: string;
|
||||
ovtm_untp_vol?: string;
|
||||
ovtm_untp_oprc?: string;
|
||||
ovtm_untp_hgpr?: string;
|
||||
ovtm_untp_lwpr?: string;
|
||||
}
|
||||
|
||||
interface KisDomesticDailyPriceOutput {
|
||||
stck_bsop_date?: string;
|
||||
stck_clpr?: string;
|
||||
}
|
||||
|
||||
interface DashboardStockFallbackMeta {
|
||||
name?: string;
|
||||
market?: "KOSPI" | "KOSDAQ";
|
||||
}
|
||||
|
||||
export type DomesticMarketPhase = "regular" | "afterHours";
|
||||
export type DomesticPriceSource = "inquire-price" | "inquire-ccnl" | "inquire-overtime-price";
|
||||
|
||||
interface DomesticOverviewResult {
|
||||
stock: DashboardStockItem;
|
||||
priceSource: DomesticPriceSource;
|
||||
marketPhase: DomesticMarketPhase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 현재가 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 현재가 output
|
||||
*/
|
||||
export async function getDomesticQuote(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticQuoteOutput>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(credentials),
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
return response.output ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 일자별 시세 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 일봉 output 배열
|
||||
*/
|
||||
export async function getDomesticDailyPrice(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticDailyPriceOutput[]>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: "J",
|
||||
FID_INPUT_ISCD: symbol,
|
||||
FID_PERIOD_DIV_CODE: "D",
|
||||
FID_ORG_ADJ_PRC: "1",
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
return Array.isArray(response.output) ? response.output : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 현재가 체결 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 체결 output
|
||||
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장중 가격 우선 소스
|
||||
*/
|
||||
export async function getDomesticConclusion(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticCcnlOutput | KisDomesticCcnlOutput[]>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-ccnl",
|
||||
"FHKST01010300",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(credentials),
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const output = response.output;
|
||||
if (Array.isArray(output)) return output[0] ?? {};
|
||||
return output ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 시간외 현재가 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns KIS 시간외 현재가 output
|
||||
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장외 가격 우선 소스
|
||||
*/
|
||||
export async function getDomesticOvertimePrice(symbol: string, credentials?: KisCredentialInput) {
|
||||
const response = await kisGet<KisDomesticOvertimePriceOutput>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-overtime-price",
|
||||
"FHPST02300000",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: "J",
|
||||
FID_INPUT_ISCD: symbol,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
return response.output ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재가 + 일봉을 대시보드 모델로 변환
|
||||
* @param symbol 6자리 종목코드
|
||||
* @param fallbackMeta 보정 메타(종목명/시장)
|
||||
* @param credentials 사용자 입력 키
|
||||
* @returns DashboardStockItem
|
||||
*/
|
||||
export async function getDomesticOverview(
|
||||
symbol: string,
|
||||
fallbackMeta?: DashboardStockFallbackMeta,
|
||||
credentials?: KisCredentialInput,
|
||||
): Promise<DomesticOverviewResult> {
|
||||
const marketPhase = getDomesticMarketPhaseInKst();
|
||||
const emptyCcnl: KisDomesticCcnlOutput = {};
|
||||
const emptyOvertime: KisDomesticOvertimePriceOutput = {};
|
||||
|
||||
const [quote, daily, ccnl, overtime] = await Promise.all([
|
||||
getDomesticQuote(symbol, credentials),
|
||||
getDomesticDailyPrice(symbol, credentials),
|
||||
getDomesticConclusion(symbol, credentials).catch(() => emptyCcnl),
|
||||
marketPhase === "afterHours"
|
||||
? getDomesticOvertimePrice(symbol, credentials).catch(() => emptyOvertime)
|
||||
: Promise.resolve(emptyOvertime),
|
||||
]);
|
||||
|
||||
const currentPrice =
|
||||
firstDefinedNumber(
|
||||
toOptionalNumber(ccnl.stck_prpr),
|
||||
toOptionalNumber(overtime.ovtm_untp_prpr),
|
||||
toOptionalNumber(quote.stck_prpr),
|
||||
) ?? 0;
|
||||
|
||||
const currentPriceSource = resolveCurrentPriceSource(marketPhase, overtime, ccnl, quote);
|
||||
|
||||
const rawChange =
|
||||
firstDefinedNumber(
|
||||
toOptionalNumber(ccnl.prdy_vrss),
|
||||
toOptionalNumber(overtime.ovtm_untp_prdy_vrss),
|
||||
toOptionalNumber(quote.prdy_vrss),
|
||||
) ?? 0;
|
||||
|
||||
const signCode =
|
||||
firstDefinedString(ccnl.prdy_vrss_sign, overtime.ovtm_untp_prdy_vrss_sign, quote.prdy_vrss_sign);
|
||||
|
||||
const change = normalizeSignedValue(rawChange, signCode);
|
||||
|
||||
const rawChangeRate =
|
||||
firstDefinedNumber(
|
||||
toOptionalNumber(ccnl.prdy_ctrt),
|
||||
toOptionalNumber(overtime.ovtm_untp_prdy_ctrt),
|
||||
toOptionalNumber(quote.prdy_ctrt),
|
||||
) ?? 0;
|
||||
|
||||
const changeRate = normalizeSignedValue(rawChangeRate, signCode);
|
||||
|
||||
const prevClose = firstPositive(
|
||||
toNumber(quote.stck_sdpr),
|
||||
toNumber(quote.stck_prdy_clpr),
|
||||
Math.max(currentPrice - change, 0),
|
||||
);
|
||||
|
||||
const candles = toCandles(daily, currentPrice);
|
||||
|
||||
return {
|
||||
stock: {
|
||||
symbol,
|
||||
name: quote.hts_kor_isnm?.trim() || fallbackMeta?.name || symbol,
|
||||
market: resolveMarket(quote.rprs_mrkt_kor_name, quote.bstp_kor_isnm, fallbackMeta?.market),
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
open: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_oprc),
|
||||
toNumber(quote.stck_oprc),
|
||||
currentPrice,
|
||||
),
|
||||
high: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_hgpr),
|
||||
toNumber(quote.stck_hgpr),
|
||||
currentPrice,
|
||||
),
|
||||
low: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_lwpr),
|
||||
toNumber(quote.stck_lwpr),
|
||||
currentPrice,
|
||||
),
|
||||
prevClose,
|
||||
volume: firstPositive(
|
||||
toNumber(overtime.ovtm_untp_vol),
|
||||
toNumber(quote.acml_vol),
|
||||
toNumber(ccnl.cntg_vol),
|
||||
),
|
||||
candles,
|
||||
},
|
||||
priceSource: currentPriceSource,
|
||||
marketPhase,
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value?: string) {
|
||||
if (!value) return 0;
|
||||
const normalized = value.replaceAll(",", "").trim();
|
||||
if (!normalized) return 0;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function toOptionalNumber(value?: string) {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.replaceAll(",", "").trim();
|
||||
if (!normalized) return undefined;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function normalizeSignedValue(value: number, signCode?: string) {
|
||||
const abs = Math.abs(value);
|
||||
|
||||
if (signCode === "4" || signCode === "5") return -abs;
|
||||
if (signCode === "1" || signCode === "2") return abs;
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveMarket(...values: Array<string | undefined>) {
|
||||
const merged = values.filter(Boolean).join(" ");
|
||||
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) return "KOSDAQ" as const;
|
||||
return "KOSPI" as const;
|
||||
}
|
||||
|
||||
function toCandles(rows: KisDomesticDailyPriceOutput[], currentPrice: number): StockCandlePoint[] {
|
||||
const parsed = rows
|
||||
.map((row) => ({
|
||||
date: row.stck_bsop_date ?? "",
|
||||
price: toNumber(row.stck_clpr),
|
||||
}))
|
||||
.filter((item) => item.date.length === 8 && item.price > 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(-20)
|
||||
.map((item) => ({
|
||||
time: formatDate(item.date),
|
||||
price: item.price,
|
||||
}));
|
||||
|
||||
if (parsed.length > 0) return parsed;
|
||||
|
||||
return [{ time: "오늘", price: Math.max(currentPrice, 0) }];
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function getDomesticMarketPhaseInKst(now = new Date()): DomesticMarketPhase {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Seoul",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
|
||||
const partMap = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const weekday = partMap.get("weekday");
|
||||
const hour = Number(partMap.get("hour") ?? "0");
|
||||
const minute = Number(partMap.get("minute") ?? "0");
|
||||
const totalMinutes = hour * 60 + minute;
|
||||
|
||||
if (weekday === "Sat" || weekday === "Sun") return "afterHours";
|
||||
if (totalMinutes >= 9 * 60 && totalMinutes < 15 * 60 + 30) return "regular";
|
||||
return "afterHours";
|
||||
}
|
||||
|
||||
function firstDefinedNumber(...values: Array<number | undefined>) {
|
||||
return values.find((value) => value !== undefined);
|
||||
}
|
||||
|
||||
function firstDefinedString(...values: Array<string | undefined>) {
|
||||
return values.find((value) => Boolean(value));
|
||||
}
|
||||
|
||||
function resolveCurrentPriceSource(
|
||||
marketPhase: DomesticMarketPhase,
|
||||
overtime: KisDomesticOvertimePriceOutput,
|
||||
ccnl: KisDomesticCcnlOutput,
|
||||
quote: KisDomesticQuoteOutput,
|
||||
): DomesticPriceSource {
|
||||
const hasOvertimePrice = toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
|
||||
const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined;
|
||||
const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined;
|
||||
|
||||
if (marketPhase === "afterHours") {
|
||||
if (hasOvertimePrice) return "inquire-overtime-price";
|
||||
if (hasCcnlPrice) return "inquire-ccnl";
|
||||
return "inquire-price";
|
||||
}
|
||||
|
||||
if (hasCcnlPrice) return "inquire-ccnl";
|
||||
if (hasQuotePrice) return "inquire-price";
|
||||
return "inquire-price";
|
||||
}
|
||||
|
||||
function resolvePriceMarketDivCode(credentials?: KisCredentialInput) {
|
||||
return credentials?.tradingEnv === "mock" ? "J" : "UN";
|
||||
}
|
||||
|
||||
function firstPositive(...values: number[]) {
|
||||
return values.find((value) => value > 0) ?? 0;
|
||||
}
|
||||
238
lib/kis/token.ts
Normal file
238
lib/kis/token.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
||||
import { getKisConfig } from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @file lib/kis/token.ts
|
||||
* @description KIS access token 발급/캐시 관리(실전/모의 공통)
|
||||
*/
|
||||
|
||||
interface KisTokenResponse {
|
||||
access_token?: string;
|
||||
access_token_token_expired?: string;
|
||||
expires_in?: number;
|
||||
msg1?: string;
|
||||
msg_cd?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface KisTokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface KisRevokeResponse {
|
||||
code?: number | string;
|
||||
message?: string;
|
||||
msg1?: string;
|
||||
}
|
||||
|
||||
const tokenCacheMap = new Map<string, KisTokenCache>();
|
||||
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
|
||||
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
|
||||
|
||||
function hashKey(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function getTokenCacheKey(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS access token 발급
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns token + expiresAt
|
||||
* @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로
|
||||
*/
|
||||
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
|
||||
const config = getKisConfig(credentials);
|
||||
const tokenUrl = `${config.baseUrl}/oauth2/tokenP`;
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "client_credentials",
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseTokenResponse(rawText);
|
||||
|
||||
if (!response.ok || !payload.access_token) {
|
||||
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
const hint = buildTokenIssueHint(detail, config.tradingEnv);
|
||||
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}${hint}`
|
||||
: `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status})${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: payload.access_token,
|
||||
expiresAt: resolveTokenExpiry(payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 실패 시 점검 안내를 생성합니다.
|
||||
* @param detail KIS 응답 메시지
|
||||
* @param tradingEnv 거래 모드(real/mock)
|
||||
* @returns 점검 안내 문자열
|
||||
* @see https://apiportal.koreainvestment.com/apiservice-apiservice
|
||||
*/
|
||||
function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
|
||||
const lower = detail.toLowerCase();
|
||||
|
||||
const keyError =
|
||||
lower.includes("appkey") ||
|
||||
lower.includes("appsecret") ||
|
||||
lower.includes("secret") ||
|
||||
lower.includes("invalid") ||
|
||||
lower.includes("인증");
|
||||
|
||||
if (keyError) {
|
||||
return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱키/시크릿 쌍이 맞는지 확인하세요.`;
|
||||
}
|
||||
|
||||
return " | 점검: KIS API 포털에서 앱 상태(사용 가능/차단)와 실전·모의 구분을 다시 확인하세요.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 응답 문자열을 안전하게 JSON으로 변환합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisTokenResponse
|
||||
*/
|
||||
function tryParseTokenResponse(rawText: string): KisTokenResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisTokenResponse;
|
||||
} catch {
|
||||
// JSON 파싱 실패 시에도 호출부에서 상태코드 기반 에러를 만들 수 있게 기본 객체를 반환합니다.
|
||||
return {
|
||||
msg1: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료시각 계산
|
||||
* @param payload 토큰 응답
|
||||
* @returns epoch ms
|
||||
*/
|
||||
function resolveTokenExpiry(payload: KisTokenResponse) {
|
||||
if (payload.access_token_token_expired) {
|
||||
const parsed = Date.parse(payload.access_token_token_expired.replace(" ", "T"));
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
|
||||
if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
|
||||
return Date.now() + payload.expires_in * 1000;
|
||||
}
|
||||
|
||||
return Date.now() + 23 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* access token 반환(환경/키 단위 메모리 캐시)
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns access token
|
||||
* @see lib/kis/domestic.ts getDomesticOverview - 현재가/일봉 병렬 조회 시 공용 토큰 사용
|
||||
*/
|
||||
export async function getKisAccessToken(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getTokenCacheKey(credentials);
|
||||
const cached = tokenCacheMap.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt - TOKEN_REFRESH_BUFFER_MS > Date.now()) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다.
|
||||
const inFlight = tokenIssueInFlightMap.get(cacheKey);
|
||||
if (inFlight) {
|
||||
const shared = await inFlight;
|
||||
return shared.token;
|
||||
}
|
||||
|
||||
const nextPromise = issueKisToken(credentials);
|
||||
tokenIssueInFlightMap.set(cacheKey, nextPromise);
|
||||
const next = await nextPromise.finally(() => {
|
||||
tokenIssueInFlightMap.delete(cacheKey);
|
||||
});
|
||||
|
||||
tokenCacheMap.set(cacheKey, next);
|
||||
return next.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS access token 폐기 요청
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns 폐기 응답 메시지
|
||||
* @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리
|
||||
*/
|
||||
export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
const cacheKey = getTokenCacheKey(credentials);
|
||||
const token = await getKisAccessToken(credentials);
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/oauth2/revokeP`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
token,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseRevokeResponse(rawText);
|
||||
const code = payload.code != null ? String(payload.code) : "";
|
||||
const isSuccessCode = code === "" || code === "200";
|
||||
|
||||
if (!response.ok || !isSuccessCode) {
|
||||
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
|
||||
: `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
tokenCacheMap.delete(cacheKey);
|
||||
tokenIssueInFlightMap.delete(cacheKey);
|
||||
clearKisApprovalKeyCache(credentials);
|
||||
|
||||
return payload.message ?? "접근토큰 폐기에 성공하였습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 폐기 응답 문자열을 안전하게 JSON으로 변환합니다.
|
||||
* @param rawText fetch 응답 원문
|
||||
* @returns KisRevokeResponse
|
||||
* @see https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/revokeP
|
||||
*/
|
||||
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
|
||||
try {
|
||||
return JSON.parse(rawText) as KisRevokeResponse;
|
||||
} catch {
|
||||
return {
|
||||
message: rawText.slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
799
temp-kis-auth.py
Normal file
799
temp-kis-auth.py
Normal file
@@ -0,0 +1,799 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ====| (REST) 접근 토큰 / (Websocket) 웹소켓 접속키 발급 에 필요한 API 호출 샘플 아래 참고하시기 바랍니다. |=====================
|
||||
# ====| API 호출 공통 함수 포함 |=====================
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from base64 import b64decode
|
||||
from collections import namedtuple
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# pip install requests (패키지설치)
|
||||
import requests
|
||||
|
||||
# 웹 소켓 모듈을 선언한다.
|
||||
import websockets
|
||||
|
||||
# pip install PyYAML (패키지설치)
|
||||
import yaml
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
# pip install pycryptodome
|
||||
from Crypto.Util.Padding import unpad
|
||||
|
||||
clearConsole = lambda: os.system("cls" if os.name in ("nt", "dos") else "clear")
|
||||
|
||||
key_bytes = 32
|
||||
config_root = os.path.join(os.path.expanduser("~"), "KIS", "config")
|
||||
# config_root = "$HOME/KIS/config/" # 토큰 파일이 저장될 폴더, 제3자가 찾기 어렵도록 경로 설정하시기 바랍니다.
|
||||
# token_tmp = config_root + 'KIS000000' # 토큰 로컬저장시 파일 이름 지정, 파일이름을 토큰값이 유추가능한 파일명은 삼가바랍니다.
|
||||
# token_tmp = config_root + 'KIS' + datetime.today().strftime("%Y%m%d%H%M%S") # 토큰 로컬저장시 파일명 년월일시분초
|
||||
token_tmp = os.path.join(
|
||||
config_root, f"KIS{datetime.today().strftime("%Y%m%d")}"
|
||||
) # 토큰 로컬저장시 파일명 년월일
|
||||
|
||||
# 접근토큰 관리하는 파일 존재여부 체크, 없으면 생성
|
||||
if os.path.exists(token_tmp) == False:
|
||||
f = open(token_tmp, "w+")
|
||||
|
||||
# 앱키, 앱시크리트, 토큰, 계좌번호 등 저장관리, 자신만의 경로와 파일명으로 설정하시기 바랍니다.
|
||||
# pip install PyYAML (패키지설치)
|
||||
with open(os.path.join(config_root, "kis_devlp.yaml"), encoding="UTF-8") as f:
|
||||
_cfg = yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
_TRENV = tuple()
|
||||
_last_auth_time = datetime.now()
|
||||
_autoReAuth = False
|
||||
_DEBUG = False
|
||||
_isPaper = False
|
||||
_smartSleep = 0.1
|
||||
|
||||
# 기본 헤더값 정의
|
||||
_base_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/plain",
|
||||
"charset": "UTF-8",
|
||||
"User-Agent": _cfg["my_agent"],
|
||||
}
|
||||
|
||||
|
||||
# 토큰 발급 받아 저장 (토큰값, 토큰 유효시간,1일, 6시간 이내 발급신청시는 기존 토큰값과 동일, 발급시 알림톡 발송)
|
||||
def save_token(my_token, my_expired):
|
||||
# print(type(my_expired), my_expired)
|
||||
valid_date = datetime.strptime(my_expired, "%Y-%m-%d %H:%M:%S")
|
||||
# print('Save token date: ', valid_date)
|
||||
with open(token_tmp, "w", encoding="utf-8") as f:
|
||||
f.write(f"token: {my_token}\n")
|
||||
f.write(f"valid-date: {valid_date}\n")
|
||||
|
||||
|
||||
# 토큰 확인 (토큰값, 토큰 유효시간_1일, 6시간 이내 발급신청시는 기존 토큰값과 동일, 발급시 알림톡 발송)
|
||||
def read_token():
|
||||
try:
|
||||
# 토큰이 저장된 파일 읽기
|
||||
with open(token_tmp, encoding="UTF-8") as f:
|
||||
tkg_tmp = yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
# 토큰 만료 일,시간
|
||||
exp_dt = datetime.strftime(tkg_tmp["valid-date"], "%Y-%m-%d %H:%M:%S")
|
||||
# 현재일자,시간
|
||||
now_dt = datetime.today().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# print('expire dt: ', exp_dt, ' vs now dt:', now_dt)
|
||||
# 저장된 토큰 만료일자 체크 (만료일시 > 현재일시 인경우 보관 토큰 리턴)
|
||||
if exp_dt > now_dt:
|
||||
return tkg_tmp["token"]
|
||||
else:
|
||||
# print('Need new token: ', tkg_tmp['valid-date'])
|
||||
return None
|
||||
except Exception:
|
||||
# print('read token error: ', e)
|
||||
return None
|
||||
|
||||
|
||||
# 토큰 유효시간 체크해서 만료된 토큰이면 재발급처리
|
||||
def _getBaseHeader():
|
||||
if _autoReAuth:
|
||||
reAuth()
|
||||
return copy.deepcopy(_base_headers)
|
||||
|
||||
|
||||
# 가져오기 : 앱키, 앱시크리트, 종합계좌번호(계좌번호 중 숫자8자리), 계좌상품코드(계좌번호 중 숫자2자리), 토큰, 도메인
|
||||
def _setTRENV(cfg):
|
||||
nt1 = namedtuple(
|
||||
"KISEnv",
|
||||
["my_app", "my_sec", "my_acct", "my_prod", "my_htsid", "my_token", "my_url", "my_url_ws"],
|
||||
)
|
||||
d = {
|
||||
"my_app": cfg["my_app"], # 앱키
|
||||
"my_sec": cfg["my_sec"], # 앱시크리트
|
||||
"my_acct": cfg["my_acct"], # 종합계좌번호(8자리)
|
||||
"my_prod": cfg["my_prod"], # 계좌상품코드(2자리)
|
||||
"my_htsid": cfg["my_htsid"], # HTS ID
|
||||
"my_token": cfg["my_token"], # 토큰
|
||||
"my_url": cfg[
|
||||
"my_url"
|
||||
], # 실전 도메인 (https://openapi.koreainvestment.com:9443)
|
||||
"my_url_ws": cfg["my_url_ws"],
|
||||
} # 모의 도메인 (https://openapivts.koreainvestment.com:29443)
|
||||
|
||||
# print(cfg['my_app'])
|
||||
global _TRENV
|
||||
_TRENV = nt1(**d)
|
||||
|
||||
|
||||
def isPaperTrading(): # 모의투자 매매
|
||||
return _isPaper
|
||||
|
||||
|
||||
# 실전투자면 'prod', 모의투자면 'vps'를 셋팅 하시기 바랍니다.
|
||||
def changeTREnv(token_key, svr="prod", product=_cfg["my_prod"]):
|
||||
cfg = dict()
|
||||
|
||||
global _isPaper
|
||||
if svr == "prod": # 실전투자
|
||||
ak1 = "my_app" # 실전투자용 앱키
|
||||
ak2 = "my_sec" # 실전투자용 앱시크리트
|
||||
_isPaper = False
|
||||
_smartSleep = 0.05
|
||||
elif svr == "vps": # 모의투자
|
||||
ak1 = "paper_app" # 모의투자용 앱키
|
||||
ak2 = "paper_sec" # 모의투자용 앱시크리트
|
||||
_isPaper = True
|
||||
_smartSleep = 0.5
|
||||
|
||||
cfg["my_app"] = _cfg[ak1]
|
||||
cfg["my_sec"] = _cfg[ak2]
|
||||
|
||||
if svr == "prod" and product == "01": # 실전투자 주식투자, 위탁계좌, 투자계좌
|
||||
cfg["my_acct"] = _cfg["my_acct_stock"]
|
||||
elif svr == "prod" and product == "03": # 실전투자 선물옵션(파생)
|
||||
cfg["my_acct"] = _cfg["my_acct_future"]
|
||||
elif svr == "prod" and product == "08": # 실전투자 해외선물옵션(파생)
|
||||
cfg["my_acct"] = _cfg["my_acct_future"]
|
||||
elif svr == "prod" and product == "22": # 실전투자 개인연금저축계좌
|
||||
cfg["my_acct"] = _cfg["my_acct_stock"]
|
||||
elif svr == "prod" and product == "29": # 실전투자 퇴직연금계좌
|
||||
cfg["my_acct"] = _cfg["my_acct_stock"]
|
||||
elif svr == "vps" and product == "01": # 모의투자 주식투자, 위탁계좌, 투자계좌
|
||||
cfg["my_acct"] = _cfg["my_paper_stock"]
|
||||
elif svr == "vps" and product == "03": # 모의투자 선물옵션(파생)
|
||||
cfg["my_acct"] = _cfg["my_paper_future"]
|
||||
|
||||
cfg["my_prod"] = product
|
||||
cfg["my_htsid"] = _cfg["my_htsid"]
|
||||
cfg["my_url"] = _cfg[svr]
|
||||
|
||||
try:
|
||||
my_token = _TRENV.my_token
|
||||
except AttributeError:
|
||||
my_token = ""
|
||||
cfg["my_token"] = my_token if token_key else token_key
|
||||
cfg["my_url_ws"] = _cfg["ops" if svr == "prod" else "vops"]
|
||||
|
||||
# print(cfg)
|
||||
_setTRENV(cfg)
|
||||
|
||||
|
||||
def _getResultObject(json_data):
|
||||
_tc_ = namedtuple("res", json_data.keys())
|
||||
|
||||
return _tc_(**json_data)
|
||||
|
||||
|
||||
# Token 발급, 유효기간 1일, 6시간 이내 발급시 기존 token값 유지, 발급시 알림톡 무조건 발송
|
||||
# 모의투자인 경우 svr='vps', 투자계좌(01)이 아닌경우 product='XX' 변경하세요 (계좌번호 뒤 2자리)
|
||||
def auth(svr="prod", product=_cfg["my_prod"], url=None):
|
||||
p = {
|
||||
"grant_type": "client_credentials",
|
||||
}
|
||||
# 개인 환경파일 "kis_devlp.yaml" 파일을 참조하여 앱키, 앱시크리트 정보 가져오기
|
||||
# 개인 환경파일명과 위치는 고객님만 아는 위치로 설정 바랍니다.
|
||||
if svr == "prod": # 실전투자
|
||||
ak1 = "my_app" # 앱키 (실전투자용)
|
||||
ak2 = "my_sec" # 앱시크리트 (실전투자용)
|
||||
elif svr == "vps": # 모의투자
|
||||
ak1 = "paper_app" # 앱키 (모의투자용)
|
||||
ak2 = "paper_sec" # 앱시크리트 (모의투자용)
|
||||
|
||||
# 앱키, 앱시크리트 가져오기
|
||||
p["appkey"] = _cfg[ak1]
|
||||
p["appsecret"] = _cfg[ak2]
|
||||
|
||||
# 기존 발급된 토큰이 있는지 확인
|
||||
saved_token = read_token() # 기존 발급 토큰 확인
|
||||
# print("saved_token: ", saved_token)
|
||||
if saved_token is None: # 기존 발급 토큰 확인이 안되면 발급처리
|
||||
url = f"{_cfg[svr]}/oauth2/tokenP"
|
||||
res = requests.post(
|
||||
url, data=json.dumps(p), headers=_getBaseHeader()
|
||||
) # 토큰 발급
|
||||
rescode = res.status_code
|
||||
if rescode == 200: # 토큰 정상 발급
|
||||
my_token = _getResultObject(res.json()).access_token # 토큰값 가져오기
|
||||
my_expired = _getResultObject(
|
||||
res.json()
|
||||
).access_token_token_expired # 토큰값 만료일시 가져오기
|
||||
save_token(my_token, my_expired) # 새로 발급 받은 토큰 저장
|
||||
else:
|
||||
print("Get Authentification token fail!\nYou have to restart your app!!!")
|
||||
return
|
||||
else:
|
||||
my_token = saved_token # 기존 발급 토큰 확인되어 기존 토큰 사용
|
||||
|
||||
# 발급토큰 정보 포함해서 헤더값 저장 관리, API 호출시 필요
|
||||
changeTREnv(my_token, svr, product)
|
||||
|
||||
_base_headers["authorization"] = f"Bearer {my_token}"
|
||||
_base_headers["appkey"] = _TRENV.my_app
|
||||
_base_headers["appsecret"] = _TRENV.my_sec
|
||||
|
||||
global _last_auth_time
|
||||
_last_auth_time = datetime.now()
|
||||
|
||||
if _DEBUG:
|
||||
print(f"[{_last_auth_time}] => get AUTH Key completed!")
|
||||
|
||||
|
||||
# end of initialize, 토큰 재발급, 토큰 발급시 유효시간 1일
|
||||
# 프로그램 실행시 _last_auth_time에 저장하여 유효시간 체크, 유효시간 만료시 토큰 발급 처리
|
||||
def reAuth(svr="prod", product=_cfg["my_prod"]):
|
||||
n2 = datetime.now()
|
||||
if (n2 - _last_auth_time).seconds >= 86400: # 유효시간 1일
|
||||
auth(svr, product)
|
||||
|
||||
|
||||
def getEnv():
|
||||
return _cfg
|
||||
|
||||
|
||||
def smart_sleep():
|
||||
if _DEBUG:
|
||||
print(f"[RateLimit] Sleeping {_smartSleep}s ")
|
||||
|
||||
time.sleep(_smartSleep)
|
||||
|
||||
|
||||
def getTREnv():
|
||||
return _TRENV
|
||||
|
||||
|
||||
# 주문 API에서 사용할 hash key값을 받아 header에 설정해 주는 함수
|
||||
# 현재는 hash key 필수 사항아님, 생략가능, API 호출과정에서 변조 우려를 하는 경우 사용
|
||||
# Input: HTTP Header, HTTP post param
|
||||
# Output: None
|
||||
def set_order_hash_key(h, p):
|
||||
url = f"{getTREnv().my_url}/uapi/hashkey" # hashkey 발급 API URL
|
||||
|
||||
res = requests.post(url, data=json.dumps(p), headers=h)
|
||||
rescode = res.status_code
|
||||
if rescode == 200:
|
||||
h["hashkey"] = _getResultObject(res.json()).HASH
|
||||
else:
|
||||
print("Error:", rescode)
|
||||
|
||||
|
||||
# API 호출 응답에 필요한 처리 공통 함수
|
||||
class APIResp:
|
||||
def __init__(self, resp):
|
||||
self._rescode = resp.status_code
|
||||
self._resp = resp
|
||||
self._header = self._setHeader()
|
||||
self._body = self._setBody()
|
||||
self._err_code = self._body.msg_cd
|
||||
self._err_message = self._body.msg1
|
||||
|
||||
def getResCode(self):
|
||||
return self._rescode
|
||||
|
||||
def _setHeader(self):
|
||||
fld = dict()
|
||||
for x in self._resp.headers.keys():
|
||||
if x.islower():
|
||||
fld[x] = self._resp.headers.get(x)
|
||||
_th_ = namedtuple("header", fld.keys())
|
||||
|
||||
return _th_(**fld)
|
||||
|
||||
def _setBody(self):
|
||||
_tb_ = namedtuple("body", self._resp.json().keys())
|
||||
|
||||
return _tb_(**self._resp.json())
|
||||
|
||||
def getHeader(self):
|
||||
return self._header
|
||||
|
||||
def getBody(self):
|
||||
return self._body
|
||||
|
||||
def getResponse(self):
|
||||
return self._resp
|
||||
|
||||
def isOK(self):
|
||||
try:
|
||||
if self.getBody().rt_cd == "0":
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
def getErrorCode(self):
|
||||
return self._err_code
|
||||
|
||||
def getErrorMessage(self):
|
||||
return self._err_message
|
||||
|
||||
def printAll(self):
|
||||
print("<Header>")
|
||||
for x in self.getHeader()._fields:
|
||||
print(f"\t-{x}: {getattr(self.getHeader(), x)}")
|
||||
print("<Body>")
|
||||
for x in self.getBody()._fields:
|
||||
print(f"\t-{x}: {getattr(self.getBody(), x)}")
|
||||
|
||||
def printError(self, url):
|
||||
print(
|
||||
"-------------------------------\nError in response: ",
|
||||
self.getResCode(),
|
||||
" url=",
|
||||
url,
|
||||
)
|
||||
print(
|
||||
"rt_cd : ",
|
||||
self.getBody().rt_cd,
|
||||
"/ msg_cd : ",
|
||||
self.getErrorCode(),
|
||||
"/ msg1 : ",
|
||||
self.getErrorMessage(),
|
||||
)
|
||||
print("-------------------------------")
|
||||
|
||||
# end of class APIResp
|
||||
|
||||
|
||||
class APIRespError(APIResp):
|
||||
def __init__(self, status_code, error_text):
|
||||
# 부모 생성자 호출하지 않고 직접 초기화
|
||||
self.status_code = status_code
|
||||
self.error_text = error_text
|
||||
self._error_code = str(status_code)
|
||||
self._error_message = error_text
|
||||
|
||||
def isOK(self):
|
||||
return False
|
||||
|
||||
def getErrorCode(self):
|
||||
return self._error_code
|
||||
|
||||
def getErrorMessage(self):
|
||||
return self._error_message
|
||||
|
||||
def getBody(self):
|
||||
# 빈 객체 리턴 (속성 접근 시 AttributeError 방지)
|
||||
class EmptyBody:
|
||||
def __getattr__(self, name):
|
||||
return None
|
||||
|
||||
return EmptyBody()
|
||||
|
||||
def getHeader(self):
|
||||
# 빈 객체 리턴
|
||||
class EmptyHeader:
|
||||
tr_cont = ""
|
||||
|
||||
def __getattr__(self, name):
|
||||
return ""
|
||||
|
||||
return EmptyHeader()
|
||||
|
||||
def printAll(self):
|
||||
print(f"=== ERROR RESPONSE ===")
|
||||
print(f"Status Code: {self.status_code}")
|
||||
print(f"Error Message: {self.error_text}")
|
||||
print(f"======================")
|
||||
|
||||
def printError(self, url=""):
|
||||
print(f"Error Code : {self.status_code} | {self.error_text}")
|
||||
if url:
|
||||
print(f"URL: {url}")
|
||||
|
||||
|
||||
########### API call wrapping : API 호출 공통
|
||||
|
||||
|
||||
def _url_fetch(
|
||||
api_url, ptr_id, tr_cont, params, appendHeaders=None, postFlag=False, hashFlag=True
|
||||
):
|
||||
url = f"{getTREnv().my_url}{api_url}"
|
||||
|
||||
headers = _getBaseHeader() # 기본 header 값 정리
|
||||
|
||||
# 추가 Header 설정
|
||||
tr_id = ptr_id
|
||||
if ptr_id[0] in ("T", "J", "C"): # 실전투자용 TR id 체크
|
||||
if isPaperTrading(): # 모의투자용 TR id 식별
|
||||
tr_id = "V" + ptr_id[1:]
|
||||
|
||||
headers["tr_id"] = tr_id # 트랜젝션 TR id
|
||||
headers["custtype"] = "P" # 일반(개인고객,법인고객) "P", 제휴사 "B"
|
||||
headers["tr_cont"] = tr_cont # 트랜젝션 TR id
|
||||
|
||||
if appendHeaders is not None:
|
||||
if len(appendHeaders) > 0:
|
||||
for x in appendHeaders.keys():
|
||||
headers[x] = appendHeaders.get(x)
|
||||
|
||||
if _DEBUG:
|
||||
print("< Sending Info >")
|
||||
print(f"URL: {url}, TR: {tr_id}")
|
||||
print(f"<header>\n{headers}")
|
||||
print(f"<body>\n{params}")
|
||||
|
||||
if postFlag:
|
||||
# if (hashFlag): set_order_hash_key(headers, params)
|
||||
res = requests.post(url, headers=headers, data=json.dumps(params))
|
||||
else:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
|
||||
if res.status_code == 200:
|
||||
ar = APIResp(res)
|
||||
if _DEBUG:
|
||||
ar.printAll()
|
||||
return ar
|
||||
else:
|
||||
print("Error Code : " + str(res.status_code) + " | " + res.text)
|
||||
return APIRespError(res.status_code, res.text)
|
||||
|
||||
|
||||
# auth()
|
||||
# print("Pass through the end of the line")
|
||||
|
||||
|
||||
########### New - websocket 대응
|
||||
|
||||
_base_headers_ws = {
|
||||
"content-type": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
def _getBaseHeader_ws():
|
||||
if _autoReAuth:
|
||||
reAuth_ws()
|
||||
|
||||
return copy.deepcopy(_base_headers_ws)
|
||||
|
||||
|
||||
def auth_ws(svr="prod", product=_cfg["my_prod"]):
|
||||
p = {"grant_type": "client_credentials"}
|
||||
if svr == "prod":
|
||||
ak1 = "my_app"
|
||||
ak2 = "my_sec"
|
||||
elif svr == "vps":
|
||||
ak1 = "paper_app"
|
||||
ak2 = "paper_sec"
|
||||
|
||||
p["appkey"] = _cfg[ak1]
|
||||
p["secretkey"] = _cfg[ak2]
|
||||
|
||||
url = f"{_cfg[svr]}/oauth2/Approval"
|
||||
res = requests.post(url, data=json.dumps(p), headers=_getBaseHeader()) # 토큰 발급
|
||||
rescode = res.status_code
|
||||
if rescode == 200: # 토큰 정상 발급
|
||||
approval_key = _getResultObject(res.json()).approval_key
|
||||
else:
|
||||
print("Get Approval token fail!\nYou have to restart your app!!!")
|
||||
return
|
||||
|
||||
changeTREnv(None, svr, product)
|
||||
|
||||
_base_headers_ws["approval_key"] = approval_key
|
||||
|
||||
global _last_auth_time
|
||||
_last_auth_time = datetime.now()
|
||||
|
||||
if _DEBUG:
|
||||
print(f"[{_last_auth_time}] => get AUTH Key completed!")
|
||||
|
||||
|
||||
def reAuth_ws(svr="prod", product=_cfg["my_prod"]):
|
||||
n2 = datetime.now()
|
||||
if (n2 - _last_auth_time).seconds >= 86400:
|
||||
auth_ws(svr, product)
|
||||
|
||||
|
||||
def data_fetch(tr_id, tr_type, params, appendHeaders=None) -> dict:
|
||||
headers = _getBaseHeader_ws() # 기본 header 값 정리
|
||||
|
||||
headers["tr_type"] = tr_type
|
||||
headers["custtype"] = "P"
|
||||
|
||||
if appendHeaders is not None:
|
||||
if len(appendHeaders) > 0:
|
||||
for x in appendHeaders.keys():
|
||||
headers[x] = appendHeaders.get(x)
|
||||
|
||||
if _DEBUG:
|
||||
print("< Sending Info >")
|
||||
print(f"TR: {tr_id}")
|
||||
print(f"<header>\n{headers}")
|
||||
|
||||
inp = {
|
||||
"tr_id": tr_id,
|
||||
}
|
||||
inp.update(params)
|
||||
|
||||
return {"header": headers, "body": {"input": inp}}
|
||||
|
||||
|
||||
# iv, ekey, encrypt 는 각 기능 메소드 파일에 저장할 수 있도록 dict에서 return 하도록
|
||||
def system_resp(data):
|
||||
isPingPong = False
|
||||
isUnSub = False
|
||||
isOk = False
|
||||
tr_msg = None
|
||||
tr_key = None
|
||||
encrypt, iv, ekey = None, None, None
|
||||
|
||||
rdic = json.loads(data)
|
||||
|
||||
tr_id = rdic["header"]["tr_id"]
|
||||
if tr_id != "PINGPONG":
|
||||
tr_key = rdic["header"]["tr_key"]
|
||||
encrypt = rdic["header"]["encrypt"]
|
||||
if rdic.get("body", None) is not None:
|
||||
isOk = True if rdic["body"]["rt_cd"] == "0" else False
|
||||
tr_msg = rdic["body"]["msg1"]
|
||||
# 복호화를 위한 key 를 추출
|
||||
if "output" in rdic["body"]:
|
||||
iv = rdic["body"]["output"]["iv"]
|
||||
ekey = rdic["body"]["output"]["key"]
|
||||
isUnSub = True if tr_msg[:5] == "UNSUB" else False
|
||||
else:
|
||||
isPingPong = True if tr_id == "PINGPONG" else False
|
||||
|
||||
nt2 = namedtuple(
|
||||
"SysMsg",
|
||||
[
|
||||
"isOk",
|
||||
"tr_id",
|
||||
"tr_key",
|
||||
"isUnSub",
|
||||
"isPingPong",
|
||||
"tr_msg",
|
||||
"iv",
|
||||
"ekey",
|
||||
"encrypt",
|
||||
],
|
||||
)
|
||||
d = {
|
||||
"isOk": isOk,
|
||||
"tr_id": tr_id,
|
||||
"tr_key": tr_key,
|
||||
"tr_msg": tr_msg,
|
||||
"isUnSub": isUnSub,
|
||||
"isPingPong": isPingPong,
|
||||
"iv": iv,
|
||||
"ekey": ekey,
|
||||
"encrypt": encrypt,
|
||||
}
|
||||
|
||||
return nt2(**d)
|
||||
|
||||
|
||||
def aes_cbc_base64_dec(key, iv, cipher_text):
|
||||
if key is None or iv is None:
|
||||
raise AttributeError("key and iv cannot be None")
|
||||
|
||||
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
|
||||
return bytes.decode(unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size))
|
||||
|
||||
|
||||
#####
|
||||
open_map: dict = {}
|
||||
|
||||
|
||||
def add_open_map(
|
||||
name: str,
|
||||
request: Callable[[str, str, ...], (dict, list[str])],
|
||||
data: str | list[str],
|
||||
kwargs: dict = None,
|
||||
):
|
||||
if open_map.get(name, None) is None:
|
||||
open_map[name] = {
|
||||
"func": request,
|
||||
"items": [],
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
if type(data) is list:
|
||||
open_map[name]["items"] += data
|
||||
elif type(data) is str:
|
||||
open_map[name]["items"].append(data)
|
||||
|
||||
|
||||
data_map: dict = {}
|
||||
|
||||
|
||||
def add_data_map(
|
||||
tr_id: str,
|
||||
columns: list = None,
|
||||
encrypt: str = None,
|
||||
key: str = None,
|
||||
iv: str = None,
|
||||
):
|
||||
if data_map.get(tr_id, None) is None:
|
||||
data_map[tr_id] = {"columns": [], "encrypt": False, "key": None, "iv": None}
|
||||
|
||||
if columns is not None:
|
||||
data_map[tr_id]["columns"] = columns
|
||||
|
||||
if encrypt is not None:
|
||||
data_map[tr_id]["encrypt"] = encrypt
|
||||
|
||||
if key is not None:
|
||||
data_map[tr_id]["key"] = key
|
||||
|
||||
if iv is not None:
|
||||
data_map[tr_id]["iv"] = iv
|
||||
|
||||
|
||||
class KISWebSocket:
|
||||
api_url: str = ""
|
||||
on_result: Callable[
|
||||
[websockets.ClientConnection, str, pd.DataFrame, dict], None
|
||||
] = None
|
||||
result_all_data: bool = False
|
||||
|
||||
retry_count: int = 0
|
||||
amx_retries: int = 0
|
||||
|
||||
# init
|
||||
def __init__(self, api_url: str, max_retries: int = 3):
|
||||
self.api_url = api_url
|
||||
self.max_retries = max_retries
|
||||
|
||||
# private
|
||||
async def __subscriber(self, ws: websockets.ClientConnection):
|
||||
async for raw in ws:
|
||||
logging.info("received message >> %s" % raw)
|
||||
show_result = False
|
||||
|
||||
df = pd.DataFrame()
|
||||
|
||||
if raw[0] in ["0", "1"]:
|
||||
d1 = raw.split("|")
|
||||
if len(d1) < 4:
|
||||
raise ValueError("data not found...")
|
||||
|
||||
tr_id = d1[1]
|
||||
|
||||
dm = data_map[tr_id]
|
||||
d = d1[3]
|
||||
if dm.get("encrypt", None) == "Y":
|
||||
d = aes_cbc_base64_dec(dm["key"], dm["iv"], d)
|
||||
|
||||
df = pd.read_csv(
|
||||
StringIO(d), header=None, sep="^", names=dm["columns"], dtype=object
|
||||
)
|
||||
|
||||
show_result = True
|
||||
|
||||
else:
|
||||
rsp = system_resp(raw)
|
||||
|
||||
tr_id = rsp.tr_id
|
||||
add_data_map(
|
||||
tr_id=rsp.tr_id, encrypt=rsp.encrypt, key=rsp.ekey, iv=rsp.iv
|
||||
)
|
||||
|
||||
if rsp.isPingPong:
|
||||
print(f"### RECV [PINGPONG] [{raw}]")
|
||||
await ws.pong(raw)
|
||||
print(f"### SEND [PINGPONG] [{raw}]")
|
||||
|
||||
if self.result_all_data:
|
||||
show_result = True
|
||||
|
||||
if show_result is True and self.on_result is not None:
|
||||
self.on_result(ws, tr_id, df, data_map[tr_id])
|
||||
|
||||
async def __runner(self):
|
||||
if len(open_map.keys()) > 40:
|
||||
raise ValueError("Subscription's max is 40")
|
||||
|
||||
url = f"{getTREnv().my_url_ws}{self.api_url}"
|
||||
|
||||
while self.retry_count < self.max_retries:
|
||||
try:
|
||||
async with websockets.connect(url) as ws:
|
||||
# request subscribe
|
||||
for name, obj in open_map.items():
|
||||
await self.send_multiple(
|
||||
ws, obj["func"], "1", obj["items"], obj["kwargs"]
|
||||
)
|
||||
|
||||
# subscriber
|
||||
await asyncio.gather(
|
||||
self.__subscriber(ws),
|
||||
)
|
||||
except Exception as e:
|
||||
print("Connection exception >> ", e)
|
||||
self.retry_count += 1
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# func
|
||||
@classmethod
|
||||
async def send(
|
||||
cls,
|
||||
ws: websockets.ClientConnection,
|
||||
request: Callable[[str, str, ...], (dict, list[str])],
|
||||
tr_type: str,
|
||||
data: str,
|
||||
kwargs: dict = None,
|
||||
):
|
||||
k = {} if kwargs is None else kwargs
|
||||
msg, columns = request(tr_type, data, **k)
|
||||
|
||||
add_data_map(tr_id=msg["body"]["input"]["tr_id"], columns=columns)
|
||||
|
||||
logging.info("send message >> %s" % json.dumps(msg))
|
||||
|
||||
await ws.send(json.dumps(msg))
|
||||
smart_sleep()
|
||||
|
||||
async def send_multiple(
|
||||
self,
|
||||
ws: websockets.ClientConnection,
|
||||
request: Callable[[str, str, ...], (dict, list[str])],
|
||||
tr_type: str,
|
||||
data: list | str,
|
||||
kwargs: dict = None,
|
||||
):
|
||||
if type(data) is str:
|
||||
await self.send(ws, request, tr_type, data, kwargs)
|
||||
elif type(data) is list:
|
||||
for d in data:
|
||||
await self.send(ws, request, tr_type, d, kwargs)
|
||||
else:
|
||||
raise ValueError("data must be str or list")
|
||||
|
||||
@classmethod
|
||||
def subscribe(
|
||||
cls,
|
||||
request: Callable[[str, str, ...], (dict, list[str])],
|
||||
data: list | str,
|
||||
kwargs: dict = None,
|
||||
):
|
||||
add_open_map(request.__name__, request, data, kwargs)
|
||||
|
||||
def unsubscribe(
|
||||
self,
|
||||
ws: websockets.ClientConnection,
|
||||
request: Callable[[str, str, ...], (dict, list[str])],
|
||||
data: list | str,
|
||||
):
|
||||
self.send_multiple(ws, request, "2", data)
|
||||
|
||||
# start
|
||||
def start(
|
||||
self,
|
||||
on_result: Callable[
|
||||
[websockets.ClientConnection, str, pd.DataFrame, dict], None
|
||||
],
|
||||
result_all_data: bool = False,
|
||||
):
|
||||
self.on_result = on_result
|
||||
self.result_all_data = result_all_data
|
||||
try:
|
||||
asyncio.run(self.__runner())
|
||||
except KeyboardInterrupt:
|
||||
print("Closing by KeyboardInterrupt")
|
||||
182
temp-kis-domestic-examples-ws.py
Normal file
182
temp-kis-domestic-examples-ws.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import sys
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.extend(['..', '.'])
|
||||
import kis_auth as ka
|
||||
from domestic_stock_functions_ws import *
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 인증
|
||||
ka.auth()
|
||||
ka.auth_ws()
|
||||
trenv = ka.getTREnv()
|
||||
|
||||
# 웹소켓 선언
|
||||
kws = ka.KISWebSocket(api_url="/tryitout")
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간호가 (KRX) [실시간-004]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=asking_price_krx, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간호가 (NXT)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=asking_price_nxt, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간호가 (통합)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=asking_price_total, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간체결가(KRX) [실시간-003]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=ccnl_krx, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 주식체결통보 [실시간-005]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=ccnl_notice, data=[trenv.my_htsid])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간체결가 (NXT)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=ccnl_nxt, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간체결가 (통합)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=ccnl_total, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간예상체결 (KRX) [실시간-041]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=exp_ccnl_krx, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간예상체결 (NXT)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(
|
||||
request=exp_ccnl_nxt,
|
||||
data=["005930", "000660", "005380"]
|
||||
)
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간예상체결(통합)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=exp_ccnl_total, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내지수 실시간체결 [실시간-026]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=index_ccnl, data=["0001", "0128"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내지수 실시간예상체결 [실시간-027]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=index_exp_ccnl, data=["0001"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내지수 실시간프로그램매매 [실시간-028]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=index_program_trade, data=["0001", "0128"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 장운영정보 (KRX) [실시간-049]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=market_status_krx, data=["417450", "308100"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 장운영정보(NXT)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=market_status_nxt, data=["006220"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 장운영정보(통합)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=market_status_total, data=["158430"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간회원사 (KRX) [실시간-047]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=member_krx, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간회원사 (NXT)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=member_nxt, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간회원사 (통합)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=member_total, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 시간외 실시간호가 (KRX) [실시간-025]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=overtime_asking_price_krx, data=["023460"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 시간외 실시간체결가 (KRX) [실시간-042]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=overtime_ccnl_krx, data=["023460", "199480", "462860", "440790", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 시간외 실시간예상체결 (KRX) [실시간-024]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=overtime_exp_ccnl_krx, data=["023460"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간프로그램매매 (KRX) [실시간-048]
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=program_trade_krx, data=["005930", "000660"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간프로그램매매 (NXT)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=program_trade_nxt, data=["032640", "010950"])
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 실시간시세 > 국내주식 실시간프로그램매매 (통합)
|
||||
##############################################################################################
|
||||
|
||||
kws.subscribe(request=program_trade_total, data=["005930", "000660"])
|
||||
|
||||
|
||||
# 시작
|
||||
def on_result(ws, tr_id, result, data_info):
|
||||
print(result)
|
||||
|
||||
|
||||
kws.start(on_result=on_result)
|
||||
|
||||
2130
temp-kis-domestic-functions-ws.py
Normal file
2130
temp-kis-domestic-functions-ws.py
Normal file
File diff suppressed because it is too large
Load Diff
13463
temp-kis-domestic-functions.py
Normal file
13463
temp-kis-domestic-functions.py
Normal file
File diff suppressed because it is too large
Load Diff
78
temp-kis-inquire-price.py
Normal file
78
temp-kis-inquire-price.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Created on 20250112
|
||||
"""
|
||||
|
||||
|
||||
import sys
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.extend(['../..', '.'])
|
||||
import kis_auth as ka
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
##############################################################################################
|
||||
# [국내주식] 기본시세 > 주식현재가 시세[v1_국내주식-008]
|
||||
##############################################################################################
|
||||
|
||||
# 상수 정의
|
||||
API_URL = "/uapi/domestic-stock/v1/quotations/inquire-price"
|
||||
|
||||
def inquire_price(
|
||||
env_dv: str, # [필수] 실전모의구분 (ex. real:실전, demo:모의)
|
||||
fid_cond_mrkt_div_code: str, # [필수] 조건 시장 분류 코드 (ex. J:KRX, NX:NXT, UN:통합)
|
||||
fid_input_iscd: str # [필수] 입력 종목코드 (ex. 종목코드 (ex 005930 삼성전자), ETN은 종목코드 6자리 앞에 Q 입력 필수)
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
주식 현재가 시세 API입니다. 실시간 시세를 원하신다면 웹소켓 API를 활용하세요.
|
||||
|
||||
※ 종목코드 마스터파일 파이썬 정제코드는 한국투자증권 Github 참고 부탁드립니다.
|
||||
https://github.com/koreainvestment/open-trading-api/tree/main/stocks_info
|
||||
|
||||
Args:
|
||||
env_dv (str): [필수] 실전모의구분 (ex. real:실전, demo:모의)
|
||||
fid_cond_mrkt_div_code (str): [필수] 조건 시장 분류 코드 (ex. J:KRX, NX:NXT, UN:통합)
|
||||
fid_input_iscd (str): [필수] 입력 종목코드 (ex. 종목코드 (ex 005930 삼성전자), ETN은 종목코드 6자리 앞에 Q 입력 필수)
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: 주식 현재가 시세 데이터
|
||||
|
||||
Example:
|
||||
>>> df = inquire_price("real", "J", "005930")
|
||||
>>> print(df)
|
||||
"""
|
||||
|
||||
# 필수 파라미터 검증
|
||||
if env_dv == "" or env_dv is None:
|
||||
raise ValueError("env_dv is required (e.g. 'real:실전, demo:모의')")
|
||||
|
||||
if fid_cond_mrkt_div_code == "" or fid_cond_mrkt_div_code is None:
|
||||
raise ValueError("fid_cond_mrkt_div_code is required (e.g. 'J:KRX, NX:NXT, UN:통합')")
|
||||
|
||||
if fid_input_iscd == "" or fid_input_iscd is None:
|
||||
raise ValueError("fid_input_iscd is required (e.g. '종목코드 (ex 005930 삼성전자), ETN은 종목코드 6자리 앞에 Q 입력 필수')")
|
||||
|
||||
# tr_id 설정
|
||||
if env_dv == "real":
|
||||
tr_id = "FHKST01010100"
|
||||
elif env_dv == "demo":
|
||||
tr_id = "FHKST01010100"
|
||||
else:
|
||||
raise ValueError("env_dv can only be 'real' or 'demo'")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": fid_cond_mrkt_div_code,
|
||||
"FID_INPUT_ISCD": fid_input_iscd
|
||||
}
|
||||
|
||||
res = ka._url_fetch(API_URL, tr_id, "", params)
|
||||
|
||||
if res.isOK():
|
||||
current_data = pd.DataFrame(res.getBody().output, index=[0])
|
||||
return current_data
|
||||
else:
|
||||
res.printError(url=API_URL)
|
||||
return pd.DataFrame()
|
||||
104
temp-kis-kosdaq-code-mst.py
Normal file
104
temp-kis-kosdaq-code-mst.py
Normal file
@@ -0,0 +1,104 @@
|
||||
'''코스닥주식종목코드(kosdaq_code.mst) 정제 파이썬 파일'''
|
||||
|
||||
import pandas as pd
|
||||
import urllib.request
|
||||
import ssl
|
||||
import zipfile
|
||||
import os
|
||||
|
||||
base_dir = os.getcwd()
|
||||
|
||||
def kosdaq_master_download(base_dir, verbose=False):
|
||||
|
||||
cwd = os.getcwd()
|
||||
if (verbose): print(f"current directory is {cwd}")
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
urllib.request.urlretrieve("https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip",
|
||||
base_dir + "\\kosdaq_code.zip")
|
||||
|
||||
os.chdir(base_dir)
|
||||
if (verbose): print(f"change directory to {base_dir}")
|
||||
kosdaq_zip = zipfile.ZipFile('kosdaq_code.zip')
|
||||
kosdaq_zip.extractall()
|
||||
|
||||
kosdaq_zip.close()
|
||||
|
||||
if os.path.exists("kosdaq_code.zip"):
|
||||
os.remove("kosdaq_code.zip")
|
||||
|
||||
def get_kosdaq_master_dataframe(base_dir):
|
||||
file_name = base_dir + "\\kosdaq_code.mst"
|
||||
tmp_fil1 = base_dir + "\\kosdaq_code_part1.tmp"
|
||||
tmp_fil2 = base_dir + "\\kosdaq_code_part2.tmp"
|
||||
|
||||
wf1 = open(tmp_fil1, mode="w")
|
||||
wf2 = open(tmp_fil2, mode="w")
|
||||
|
||||
with open(file_name, mode="r", encoding="cp949") as f:
|
||||
for row in f:
|
||||
rf1 = row[0:len(row) - 222]
|
||||
rf1_1 = rf1[0:9].rstrip()
|
||||
rf1_2 = rf1[9:21].rstrip()
|
||||
rf1_3 = rf1[21:].strip()
|
||||
wf1.write(rf1_1 + ',' + rf1_2 + ',' + rf1_3 + '\n')
|
||||
rf2 = row[-222:]
|
||||
wf2.write(rf2)
|
||||
|
||||
wf1.close()
|
||||
wf2.close()
|
||||
|
||||
part1_columns = ['단축코드','표준코드','한글종목명']
|
||||
df1 = pd.read_csv(tmp_fil1, header=None, names=part1_columns, encoding='cp949')
|
||||
|
||||
field_specs = [2, 1,
|
||||
4, 4, 4, 1, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 9,
|
||||
5, 5, 1, 1, 1,
|
||||
2, 1, 1, 1, 2,
|
||||
2, 2, 3, 1, 3,
|
||||
12, 12, 8, 15, 21,
|
||||
2, 7, 1, 1, 1,
|
||||
1, 9, 9, 9, 5,
|
||||
9, 8, 9, 3, 1,
|
||||
1, 1
|
||||
]
|
||||
|
||||
part2_columns = ['증권그룹구분코드','시가총액 규모 구분 코드 유가',
|
||||
'지수업종 대분류 코드','지수 업종 중분류 코드','지수업종 소분류 코드','벤처기업 여부 (Y/N)',
|
||||
'저유동성종목 여부','KRX 종목 여부','ETP 상품구분코드','KRX100 종목 여부 (Y/N)',
|
||||
'KRX 자동차 여부','KRX 반도체 여부','KRX 바이오 여부','KRX 은행 여부','기업인수목적회사여부',
|
||||
'KRX 에너지 화학 여부','KRX 철강 여부','단기과열종목구분코드','KRX 미디어 통신 여부',
|
||||
'KRX 건설 여부','(코스닥)투자주의환기종목여부','KRX 증권 구분','KRX 선박 구분',
|
||||
'KRX섹터지수 보험여부','KRX섹터지수 운송여부','KOSDAQ150지수여부 (Y,N)','주식 기준가',
|
||||
'정규 시장 매매 수량 단위','시간외 시장 매매 수량 단위','거래정지 여부','정리매매 여부',
|
||||
'관리 종목 여부','시장 경고 구분 코드','시장 경고위험 예고 여부','불성실 공시 여부',
|
||||
'우회 상장 여부','락구분 코드','액면가 변경 구분 코드','증자 구분 코드','증거금 비율',
|
||||
'신용주문 가능 여부','신용기간','전일 거래량','주식 액면가','주식 상장 일자','상장 주수(천)',
|
||||
'자본금','결산 월','공모 가격','우선주 구분 코드','공매도과열종목여부','이상급등종목여부',
|
||||
'KRX300 종목 여부 (Y/N)','매출액','영업이익','경상이익','단기순이익','ROE(자기자본이익률)',
|
||||
'기준년월','전일기준 시가총액 (억)','그룹사 코드','회사신용한도초과여부','담보대출가능여부','대주가능여부'
|
||||
]
|
||||
|
||||
df2 = pd.read_fwf(tmp_fil2, widths=field_specs, names=part2_columns)
|
||||
|
||||
df = pd.merge(df1, df2, how='outer', left_index=True, right_index=True)
|
||||
|
||||
# clean temporary file and dataframe
|
||||
del (df1)
|
||||
del (df2)
|
||||
os.remove(tmp_fil1)
|
||||
os.remove(tmp_fil2)
|
||||
|
||||
print("Done")
|
||||
|
||||
return df
|
||||
|
||||
kosdaq_master_download(base_dir)
|
||||
df = get_kosdaq_master_dataframe(base_dir)
|
||||
|
||||
df.to_excel('kosdaq_code.xlsx',index=False) # 현재 위치에 엑셀파일로 저장
|
||||
df
|
||||
108
temp-kis-kospi-code-mst.py
Normal file
108
temp-kis-kospi-code-mst.py
Normal file
@@ -0,0 +1,108 @@
|
||||
'''코스피주식종목코드(kospi_code.mst) 정제 파이썬 파일'''
|
||||
|
||||
import urllib.request
|
||||
import ssl
|
||||
import zipfile
|
||||
import os
|
||||
import pandas as pd
|
||||
|
||||
base_dir = os.getcwd()
|
||||
|
||||
def kospi_master_download(base_dir, verbose=False):
|
||||
cwd = os.getcwd()
|
||||
if (verbose): print(f"current directory is {cwd}")
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
urllib.request.urlretrieve("https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip",
|
||||
base_dir + "\\kospi_code.zip")
|
||||
|
||||
os.chdir(base_dir)
|
||||
if (verbose): print(f"change directory to {base_dir}")
|
||||
kospi_zip = zipfile.ZipFile('kospi_code.zip')
|
||||
kospi_zip.extractall()
|
||||
|
||||
kospi_zip.close()
|
||||
|
||||
if os.path.exists("kospi_code.zip"):
|
||||
os.remove("kospi_code.zip")
|
||||
|
||||
|
||||
def get_kospi_master_dataframe(base_dir):
|
||||
file_name = base_dir + "\\kospi_code.mst"
|
||||
tmp_fil1 = base_dir + "\\kospi_code_part1.tmp"
|
||||
tmp_fil2 = base_dir + "\\kospi_code_part2.tmp"
|
||||
|
||||
wf1 = open(tmp_fil1, mode="w")
|
||||
wf2 = open(tmp_fil2, mode="w")
|
||||
|
||||
with open(file_name, mode="r", encoding="cp949") as f:
|
||||
for row in f:
|
||||
rf1 = row[0:len(row) - 228]
|
||||
rf1_1 = rf1[0:9].rstrip()
|
||||
rf1_2 = rf1[9:21].rstrip()
|
||||
rf1_3 = rf1[21:].strip()
|
||||
wf1.write(rf1_1 + ',' + rf1_2 + ',' + rf1_3 + '\n')
|
||||
rf2 = row[-228:]
|
||||
wf2.write(rf2)
|
||||
|
||||
wf1.close()
|
||||
wf2.close()
|
||||
|
||||
part1_columns = ['단축코드', '표준코드', '한글명']
|
||||
df1 = pd.read_csv(tmp_fil1, header=None, names=part1_columns, encoding='cp949')
|
||||
|
||||
field_specs = [2, 1, 4, 4, 4,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
1, 9, 5, 5, 1,
|
||||
1, 1, 2, 1, 1,
|
||||
1, 2, 2, 2, 3,
|
||||
1, 3, 12, 12, 8,
|
||||
15, 21, 2, 7, 1,
|
||||
1, 1, 1, 1, 9,
|
||||
9, 9, 5, 9, 8,
|
||||
9, 3, 1, 1, 1
|
||||
]
|
||||
|
||||
part2_columns = ['그룹코드', '시가총액규모', '지수업종대분류', '지수업종중분류', '지수업종소분류',
|
||||
'제조업', '저유동성', '지배구조지수종목', 'KOSPI200섹터업종', 'KOSPI100',
|
||||
'KOSPI50', 'KRX', 'ETP', 'ELW발행', 'KRX100',
|
||||
'KRX자동차', 'KRX반도체', 'KRX바이오', 'KRX은행', 'SPAC',
|
||||
'KRX에너지화학', 'KRX철강', '단기과열', 'KRX미디어통신', 'KRX건설',
|
||||
'Non1', 'KRX증권', 'KRX선박', 'KRX섹터_보험', 'KRX섹터_운송',
|
||||
'SRI', '기준가', '매매수량단위', '시간외수량단위', '거래정지',
|
||||
'정리매매', '관리종목', '시장경고', '경고예고', '불성실공시',
|
||||
'우회상장', '락구분', '액면변경', '증자구분', '증거금비율',
|
||||
'신용가능', '신용기간', '전일거래량', '액면가', '상장일자',
|
||||
'상장주수', '자본금', '결산월', '공모가', '우선주',
|
||||
'공매도과열', '이상급등', 'KRX300', 'KOSPI', '매출액',
|
||||
'영업이익', '경상이익', '당기순이익', 'ROE', '기준년월',
|
||||
'시가총액', '그룹사코드', '회사신용한도초과', '담보대출가능', '대주가능'
|
||||
]
|
||||
|
||||
df2 = pd.read_fwf(tmp_fil2, widths=field_specs, names=part2_columns)
|
||||
|
||||
df = pd.merge(df1, df2, how='outer', left_index=True, right_index=True)
|
||||
|
||||
# clean temporary file and dataframe
|
||||
del (df1)
|
||||
del (df2)
|
||||
os.remove(tmp_fil1)
|
||||
os.remove(tmp_fil2)
|
||||
|
||||
print("Done")
|
||||
|
||||
return df
|
||||
|
||||
|
||||
kospi_master_download(base_dir)
|
||||
df = get_kospi_master_dataframe(base_dir)
|
||||
|
||||
#df3 = df[df['KRX증권'] == 'Y']
|
||||
df3 = df
|
||||
# print(df3[['단축코드', '한글명', 'KRX', 'KRX증권', '기준가', '증거금비율', '상장일자', 'ROE']])
|
||||
df3.to_excel('kospi_code.xlsx',index=False) # 현재 위치에 엑셀파일로 저장
|
||||
df3
|
||||
1823
temp-kis-master/kosdaq_code.mst
Normal file
1823
temp-kis-master/kosdaq_code.mst
Normal file
File diff suppressed because it is too large
Load Diff
BIN
temp-kis-master/kosdaq_code.zip
Normal file
BIN
temp-kis-master/kosdaq_code.zip
Normal file
Binary file not shown.
2486
temp-kis-master/kospi_code.mst
Normal file
2486
temp-kis-master/kospi_code.mst
Normal file
File diff suppressed because it is too large
Load Diff
BIN
temp-kis-master/kospi_code.zip
Normal file
BIN
temp-kis-master/kospi_code.zip
Normal file
Binary file not shown.
36
temp-kis_devlp.yaml
Normal file
36
temp-kis_devlp.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
#홈페이지에서 API서비스 신청시 받은 Appkey, Appsecret 값 설정
|
||||
#실전투자
|
||||
my_app: "앱키"
|
||||
my_sec: "앱키 시크릿"
|
||||
|
||||
#모의투자
|
||||
paper_app: "모의투자 앱키"
|
||||
paper_sec: "모의투자 앱키 시크릿"
|
||||
|
||||
# HTS ID
|
||||
my_htsid: "사용자 HTS ID"
|
||||
|
||||
#계좌번호 앞 8자리
|
||||
my_acct_stock: "증권계좌 8자리"
|
||||
my_acct_future: "선물옵션계좌 8자리"
|
||||
my_paper_stock: "모의투자 증권계좌 8자리"
|
||||
my_paper_future: "모의투자 선물옵션계좌 8자리"
|
||||
|
||||
#계좌번호 뒤 2자리
|
||||
my_prod: "01" # 종합계좌
|
||||
# my_prod: "03" # 국내선물옵션계좌
|
||||
# my_prod: "08" # 해외선물옵션 계좌
|
||||
# my_prod: "22" # 개인연금
|
||||
# my_prod: "29" # 퇴직연금
|
||||
|
||||
#domain infos
|
||||
prod: "https://openapi.koreainvestment.com:9443" # 서비스
|
||||
ops: "ws://ops.koreainvestment.com:21000" # 웹소켓
|
||||
vps: "https://openapivts.koreainvestment.com:29443" # 모의투자 서비스
|
||||
vops: "ws://ops.koreainvestment.com:31000" # 모의투자 웹소켓
|
||||
|
||||
my_token: ""
|
||||
|
||||
# User-Agent; Chrome > F12 개발자 모드 > Console > navigator.userAgent > 자신의 userAgent 확인가능
|
||||
my_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
|
||||
|
||||
99
temp-kospi-master.h
Normal file
99
temp-kospi-master.h
Normal file
@@ -0,0 +1,99 @@
|
||||
/*****************************************************************************
|
||||
* 코스피 종목 코드 파일 구조
|
||||
****************************************************************************/
|
||||
typedef struct
|
||||
{
|
||||
char mksc_shrn_iscd[SZ_SHRNCODE]; /* 단축코드 */
|
||||
char stnd_iscd[SZ_STNDCODE]; /* 표준코드 */
|
||||
char hts_kor_isnm[SZ_KORNAME]; /* 한글종목명 */
|
||||
char scrt_grp_cls_code[2]; /* 증권그룹구분코드 */
|
||||
/* ST:주권 MF:증권투자회사 RT:부동산투자회사 */
|
||||
/* SC:선박투자회사 IF:사회간접자본투융자회사 */
|
||||
/* DR:주식예탁증서 EW:ELW EF:ETF */
|
||||
/* SW:신주인수권증권 SR:신주인수권증서 */
|
||||
/* BC:수익증권 FE:해외ETF FS:외국주권 */
|
||||
char avls_scal_cls_code[1]; /* 시가총액 규모 구분 코드 유가 */
|
||||
/* (0:제외 1:대 2:중 3:소) */
|
||||
char bstp_larg_div_code[4]; /* 지수 업종 대분류 코드 */
|
||||
char bstp_medm_div_code[4]; /* 지수 업종 중분류 코드 */
|
||||
char bstp_smal_div_code[4]; /* 지수 업종 소분류 코드 */
|
||||
char mnin_cls_code_yn[1]; /* 제조업 구분 코드 (Y/N) */
|
||||
char low_current_yn[1]; /* 저유동성종목 여부 */
|
||||
char sprn_strr_nmix_issu_yn[1]; /* 지배 구조 지수 종목 여부 (Y/N) */
|
||||
char kospi200_apnt_cls_code[1]; /* KOSPI200 섹터업종(20110401 변경됨) */
|
||||
/* 0:미분류 1:건설기계 2:조선운송 3:철강소재 */
|
||||
/* 4:에너지화학 5:정보통신 6:금융 7:필수소비재 */
|
||||
/* 8: 자유소비재 */
|
||||
char kospi100_issu_yn[1]; /* KOSPI100여부 */
|
||||
char kospi50_issu_yn[1]; /* KOSPI50 종목 여부 */
|
||||
char krx_issu_yn[1]; /* KRX 종목 여부 */
|
||||
char etp_prod_cls_code[1]; /* ETP 상품구분코드 */
|
||||
/* 0:해당없음 1:투자회사형 2:수익증권형 */
|
||||
/* 3:ETN 4:손실제한ETN */
|
||||
char elw_pblc_yn[1]; /* ELW 발행여부 (Y/N) */
|
||||
char krx100_issu_yn[1]; /* KRX100 종목 여부 (Y/N) */
|
||||
char krx_car_yn[1]; /* KRX 자동차 여부 */
|
||||
char krx_smcn_yn[1]; /* KRX 반도체 여부 */
|
||||
char krx_bio_yn[1]; /* KRX 바이오 여부 */
|
||||
char krx_bank_yn[1]; /* KRX 은행 여부 */
|
||||
char etpr_undt_objt_co_yn[1]; /* 기업인수목적회사여부 */
|
||||
char krx_enrg_chms_yn[1]; /* KRX 에너지 화학 여부 */
|
||||
char krx_stel_yn[1]; /* KRX 철강 여부 */
|
||||
char short_over_cls_code[1]; /* 단기과열종목구분코드 0:해당없음 */
|
||||
/* 1:지정예고 2:지정 3:지정연장(해제연기) */
|
||||
char krx_medi_cmnc_yn[1]; /* KRX 미디어 통신 여부 */
|
||||
char krx_cnst_yn[1]; /* KRX 건설 여부 */
|
||||
char krx_fnnc_svc_yn[1]; /* 삭제됨(20151218) */
|
||||
char krx_scrt_yn [1]; /* KRX 증권 구분 */
|
||||
char krx_ship_yn [1]; /* KRX 선박 구분 */
|
||||
char krx_insu_yn[1]; /* KRX섹터지수 보험여부 */
|
||||
char krx_trnp_yn[1]; /* KRX섹터지수 운송여부 */
|
||||
char sri_nmix_yn[1]; /* SRI 지수여부 (Y,N) */
|
||||
char stck_sdpr[9]; /* 주식 기준가 */
|
||||
char frml_mrkt_deal_qty_unit[5]; /* 정규 시장 매매 수량 단위 */
|
||||
char ovtm_mrkt_deal_qty_unit[5]; /* 시간외 시장 매매 수량 단위 */
|
||||
char trht_yn[1]; /* 거래정지 여부 */
|
||||
char sltr_yn[1]; /* 정리매매 여부 */
|
||||
char mang_issu_yn[1]; /* 관리 종목 여부 */
|
||||
char mrkt_alrm_cls_code[2]; /* 시장 경고 구분 코드 (00:해당없음 01:투자주의 */
|
||||
/* 02:투자경고 03:투자위험 */
|
||||
char mrkt_alrm_risk_adnt_yn[1]; /* 시장 경고위험 예고 여부 */
|
||||
char insn_pbnt_yn[1]; /* 불성실 공시 여부 */
|
||||
char byps_lstn_yn[1]; /* 우회 상장 여부 */
|
||||
char flng_cls_code[2]; /* 락구분 코드 (00:해당사항없음 01:권리락 */
|
||||
/* 02:배당락 03:분배락 04:권배락 05:중간배당락 */
|
||||
/* 06:권리중간배당락 99:기타 */
|
||||
/* S?W,SR,EW는 미해당(SPACE) */
|
||||
char fcam_mod_cls_code[2]; /* 액면가 변경 구분 코드 (00:해당없음 */
|
||||
/* 01:액면분할 02:액면병합 99:기타 */
|
||||
char icic_cls_code[2]; /* 증자 구분 코드 (00:해당없음 01:유상증자 */
|
||||
/* 02:무상증자 03:유무상증자 99:기타) */
|
||||
char marg_rate[3]; /* 증거금 비율 */
|
||||
char crdt_able[1]; /* 신용주문 가능 여부 */
|
||||
char crdt_days[3]; /* 신용기간 */
|
||||
char prdy_vol[12]; /* 전일 거래량 */
|
||||
char stck_fcam[12]; /* 주식 액면가 */
|
||||
char stck_lstn_date[8]; /* 주식 상장 일자 */
|
||||
char lstn_stcn[15]; /* 상장 주수(천) */
|
||||
char cpfn[21]; /* 자본금 */
|
||||
char stac_month[2]; /* 결산 월 */
|
||||
char po_prc[7]; /* 공모 가격 */
|
||||
char prst_cls_code[1]; /* 우선주 구분 코드 (0:해당없음(보통주) */
|
||||
/* 1:구형우선주 2:신형우선주 */
|
||||
char ssts_hot_yn[1]; /* 공매도과열종목여부 */
|
||||
char stange_runup_yn[1]; /* 이상급등종목여부 */
|
||||
char krx300_issu_yn[1]; /* KRX300 종목 여부 (Y/N) */
|
||||
char kospi_issu_yn[1]; /* KOSPI여부 */
|
||||
char sale_account[9]; /* 매출액 */
|
||||
char bsop_prfi[9]; /* 영업이익 */
|
||||
char op_prfi[9]; /* 경상이익 */
|
||||
char thtr_ntin[5]; /* 당기순이익 */
|
||||
char roe[9]; /* ROE(자기자본이익률) */
|
||||
char base_date[8]; /* 기준년월 */
|
||||
char prdy_avls_scal[9]; /* 전일기준 시가총액 (억) */
|
||||
|
||||
char grp_code[3]; /* 그룹사 코드 */
|
||||
char co_crdt_limt_over_yn[1]; /* 회사신용한도초과여부 */
|
||||
char secu_lend_able_yn[1]; /* 담보대출가능여부 */
|
||||
char stln_able_yn[1]; /* 대주가능여부 */
|
||||
} ST_KSP_CODE;
|
||||
Reference in New Issue
Block a user