대시보드

This commit is contained in:
2026-02-06 17:50:35 +09:00
parent 35916430b7
commit 851a2acd69
34 changed files with 45632 additions and 108 deletions

View File

@@ -1,5 +1,5 @@
# Supabase 환경 설정 예제 파일 # Supabase 환경 설정 예제 파일
# 이 파일의 이름을 .env.local 변경한 뒤, 실제 값을 채워넣으세요. # 이 파일을 .env.local로 복사한 뒤 실제 값을 채워세요.
# 값 확인: https://supabase.com/dashboard/project/_/settings/api # 값 확인: https://supabase.com/dashboard/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
@@ -7,3 +7,17 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 세션 타임아웃(분 단위) # 세션 타임아웃(분 단위)
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30 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

Submodule .tmp/open-trading-api added at aea5e779da

View File

@@ -1,115 +1,25 @@
/** /**
* @file app/(main)/dashboard/page.tsx * @file app/(main)/dashboard/page.tsx
* @description 사용자 대시보드 메인 페이지 (보호된 라우트) * @description 로그인 사용자 전용 대시보드 페이지(Server Component)
* @remarks
* - [레이어] Pages (Server Component)
* - [역할] 사용자 자산 현황, 최근 활동, 통계 요약을 보여줌
* - [권한] 로그인한 사용자만 접근 가능 (Middleware에 의해 보호됨)
*/ */
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DashboardMain } from "@/features/dashboard/components/dashboard-main";
import { Activity, CreditCard, DollarSign, Users } from "lucide-react";
/** /**
* 대시보드 페이지 (비동기 서버 컴포넌트) * 대시보드 페이지
* @returns Dashboard Grid Layout * @returns DashboardMain UI
* @see features/dashboard/components/dashboard-main.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다.
*/ */
export default async function DashboardPage() { export default async function DashboardPage() {
// [Step 1] 세션 확인 (Middleware가 1차 방어하지만, 데이터 접근 시 2차 확인) // 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient(); const supabase = await createClient();
await supabase.auth.getUser(); const {
data: { user },
} = await supabase.auth.getUser();
return ( if (!user) redirect("/login");
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2"> return <DashboardMain />;
<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>
);
} }

View File

@@ -15,7 +15,7 @@ export default async function MainLayout({
return ( return (
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black"> <div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
<Header user={user} /> <Header user={user} />
<div className="flex flex-1"> <div className="flex flex-1 pt-16">
<Sidebar /> <Sidebar />
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main> <main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
</div> </div>

View 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,
};
}

View 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;
}

View 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";
}

View 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";
}

View 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";
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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[];

View 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 },
],
},
];

View 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,
}),
},
),
);

View 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;
}

View File

@@ -44,7 +44,7 @@ export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
return ( 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"> <div className="flex flex-col space-y-1">
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
const isActive = item.matchExact const isActive = item.matchExact

142
lib/kis/approval.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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")

View 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)

File diff suppressed because it is too large Load Diff

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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

36
temp-kis_devlp.yaml Normal file
View 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
View 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;