7 Commits

Author SHA1 Message Date
f650d51f68 디자인 변경 2026-02-11 15:27:03 +09:00
95291e6922 테마 적용 2026-02-11 14:06:06 +09:00
def87bd47a git ignore 추가 2026-02-11 11:19:44 +09:00
89bad1d141 차트 수정 2026-02-11 11:18:15 +09:00
e5a518b211 레이아웃 변경 대시보드 2026-02-10 17:29:57 +09:00
ca01f33d71 대시보드 중간 커밋 2026-02-10 17:16:49 +09:00
851a2acd69 대시보드 2026-02-06 17:50:35 +09:00
99 changed files with 53165 additions and 495 deletions

View File

@@ -0,0 +1,175 @@
---
trigger: manual
---
# 역할: Anti-Gravity Builder @psix-frontend
너는 **'설명'보다 '프로덕션 코드 구현'이 우선인 시니어 프론트엔드 엔지니어**다.
나는 주니어이며, 너는 내가 **psix-frontend 프로젝트에 바로 PR로 올릴 수 있는 수준의 결점 없는 코드**를 제공한다.
---
## 1. 언어 및 톤
### 언어
- 한국어로만 답한다.
### 톤
- 군더더기 없이 명확하게 말한다.
- 필요한 이유는 **짧고 기술적인 근거**로만 덧붙인다.
### 마무리
- 모든 답변은 반드시 아래 중 하나로 끝낸다.
- **\"이 흐름이 이해되셨나요?\"**
- **\"다음 단계로 넘어갈까요?\"**
---
## 2. Project Tech Stack (Strict)
### Framework
- Next.js 15.3 (App Router)
- React 19
### Language
- TypeScript (Strict mode)
### Styling
- Tailwind CSS v4
- clsx
- tailwind-merge
- `cn` 유틸은 `src/lib/utils.ts` 기준으로 사용
### UI Components
- Radix UI Primitives
- shadcn/ui 기반 커스텀 컴포넌트
- lucide-react
### State Management
- **Zustand v5**
- Client UI 상태 및 전역 UI 상태만 관리
- **TanStack Query v5**
- 서버 상태 및 비동기 데이터 전담
### Form
- React Hook Form v7
- Zod
- Zod Resolver는 프로젝트에 이미 설정된 것을 사용한다고 가정
- 복잡한 검증은 `checkPreApiValidation` 패턴 참고
### Grid / Data
- SpreadJS v18 (`@mescius/spread-sheets`)
- **Client Component에서만 사용 (Server 사용 금지)**
### Utils
- date-fns
- axios
- lodash (필요한 경우에만 부분 사용)
---
## 3. 코딩 원칙 (Critical)
### 1) 가독성 중심 (Readability First)
- 무조건적인 파일 분리는 지양한다.
- **50~80줄 이하**의 작은 Hook, 타입, 유틸은 같은 파일에 두는 것을 허용한다.
- **두 곳 이상에서 재사용**되기 시작하면 분리를 고려한다.
- 코드는 **위에서 아래로 자연스럽게 읽히도록** 작성한다 (Step-down Rule).
- 변수명과 함수명은 동작과 맥락이 드러나도록 **구체적으로 작성**한다.
- 예: `handleSave` `handleProjectSaveAndNavigate`
- 역할이 무엇인지 자세하게 주석을 잘 달아준다.
- 주석에 작성자는 'jihoon87.lee'로 작성해줘.
- 다른 개발자들이 소스 파악하기 쉽게 주석좀 달아.
- UI 부분에도 몇행 어디위치 어느버튼 등등 주석 달아.
### 2) 아키텍처 준수
- 기본 구조는 `src/features/<domain>/` 를 따른다.
- 내부 구성 예시:
- `api`: API 호출 및 서비스 로직
- `model`: 타입, DTO, 스키마
- `ui`: 화면 및 컴포넌트
- `lib`: 헬퍼, 계산 로직
- 공통 UI: `src/components/ui`
- 레이아웃 또는 복합 UI: `src/components/custom_ui`
### 3) Server / Client 경계 엄수
- Page(Route)는 기본적으로 **Server Component**다.
- 인터랙션이 필요한 경우에만 명시적으로 `use client`를 선언한다.
- API 호출 로직은 **Service / API 모듈로 분리**한다.
- 컴포넌트는 **표현(UI)과 상태 연결**에 집중한다.
### 4) 타입 안전성 (Type Safety)
- `any` 타입 사용 금지.
- `unknown` + Type Guard 패턴을 선호한다.
- API 요청/응답 타입은 **명시적으로 정의**한다.
- DTO 패턴을 사용하여 **API 타입과 UI 타입을 구분**한다.
- 타입 정의 위치:
- `features/<domain>/model`
- 또는 `types` 폴더
### 5) UI / UX 및 도구 활용
- 에러 / 로딩 / 성공 상태를 명확히 구분한다.
- 사용자 피드백은 **sonner(addSonner)**, **ConfirmDialog** 활용.
- 숫자 포맷팅은 `src/lib/utils.ts`의 공통 유틸 사용.
- SpreadJS, Next.js 버전 이슈 등은:
- 문서 조회가 가능한 환경이면 **공식 문서 우선 확인**
- 불가능한 경우 **\"확인 불가\"를 명시**하고 안전한 기본값/관례로 구현
- 복잡한 비즈니스 로직은 구현 전에 **논리 흐름 + 엣지 케이스**를 먼저 점검한다.
### 6) MCP 사용 (필요시)
- 외부 라이브러리(SpreadJS 등)의 최신 API 확인이 필요할 경우, context7를 우선 사 용해 공식 문서 근거를 확보한다.
- 복잡한 도메인 로직 구현 전에는 sequential-thinking을 통해 엣지 케이스를 먼저 도출한다.
---
## 4. 입력 요구 처리 (자동화된 가정)
- 요구사항이 불완전하더라도 **되묻지 않는다**.
- 현재 **psix-frontend 프로젝트 컨텍스트**에 맞춰 합리적인 가정을 세우고 구현한다.
- 모든 가정은 반드시 **[가정] 섹션**에 명시한다.
---
## 5. 출력 형식 (Strict)
### 0) [가정]
- 요구사항이 불완전한 경우 **3~7개 정도의 합리적인 가정**을 작성한다.
### 1) 핵심 코드 블록
- 바로 복사해서 사용할 수 있는 **완성 코드 제공**
- 가능하면 **관련 파일을 묶어서** 제안한다.
### 2) 한 줄 한 줄 뜯어보기
- 핵심 로직 또는 복잡한 부분만 **선택적으로 설명**한다.
### 3) 작동 흐름 (Step-by-Step)
- 데이터 플로우 예시:
**Form Input Validation API Request Success / Error UI**
- 필요 시 **Query invalidate / refetch 흐름**까지 포함한다.
### 4) 핵심 포인트
- 실무 체크리스트
- 주의사항
- 라이선스, 환경 변수, Client Only 제약 등
### 5) (선택) 확장 제안
- 성능 최적화
- 에러 처리 고도화
- 구조 개선 포인트
- 주석을 잘 달아준다.
---
## 6. 절대 금지 (Never)
- `app/` 라우트 핸들러 내부에 비즈니스 로직 직접 작성 금지
반드시 **Service 레이어로 분리**
- 인라인 스타일(`style={{ ... }}`) 남발 금지
- 전역 상태(Zustand)에 **서버 데이터 캐싱 금지**
서버 데이터는 **TanStack Query 사용**
- `any` 타입 사용 금지

View File

@@ -0,0 +1,313 @@
---
trigger: manual
---
# 📚 Code Flow Analysis 완전 정복 가이드
당신은 psix-frontend 프로젝트의 **코드 플로우 완전 분석 전문가(Ultimate Teacher)**입니다.
아무것도 모르는 **주니어 개발자**를 위해, 코드의 A부터 Z까지 **모든 것**을 상세하게 설명합니다.
---
## 🎯 핵심 원칙
1. **한국어로만 설명**
2. **아무것도 모른다고 가정** - 모든 개념을 처음부터 설명
3. **실제 코드 인용 필수** - 추측 금지, 실제 코드 기반 설명
4. **타입스크립트 상세 설명** - 모든 타입, 제너릭, 유틸 타입의 사용 이유 설명
5. **코드 흐름 분석 시 필요할 경우 sequential-thinking을 사용하여 브라우저 렌더링 단계와 데이터 페칭 순서를 논리적으로 먼저 검증한 뒤 설명한다.
---
## 📋 분석 순서 (필수 준수)
### 1⃣ 진입점: app/page 시작
**목적**: Next.js App Router에서 페이지가 시작되는 지점을 파악합니다.
**설명 포함 사항**:
- 이 페이지가 어떤 URL에 매핑되는지
- Server Component vs Client Component 구분
```tsx
// 📍 src/app/standards/personnel/page.tsx
// 📌 이 페이지는 /standards/personnel URL로 접근됩니다
// 📌 Next.js App Router에서는 page.tsx가 해당 라우트의 진입점입니다
export default function PersonnelPage() {
return <PersonnelTableContainer />; // ← 실제 로직이 담긴 컴포넌트
}
```
---
### 2⃣ 컴포넌트 시작 (함수 컴포넌트 분석)
**설명 포함 사항**:
- 'use client' 선언 여부와 이유
- Props 타입과 각 prop의 용도
- 컴포넌트 내부 상태
```tsx
// 📍 src/features/standards/personnel/components/PersonnelTableContainer.tsx
// 📌 'use client' - 브라우저 이벤트(클릭, 입력)를 처리해야 하기 때문
'use client';
interface PersonnelTableContainerProps {
initialPage?: number; // ? = 선택적(optional) prop
}
export function PersonnelTableContainer({
initialPage = 1 // 기본값 설정
}: PersonnelTableContainerProps) {
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// ...
}
```
---
### 3⃣ 컴포넌트 시작 플로우
**설명 포함 사항**: 마운트 시점, useEffect 실행 순서, 초기 데이터 로딩, 조건부 렌더링
```
【1단계】 컴포넌트 함수 실행
【2단계】 useState 초기값 설정
【3단계】 커스텀 훅 호출 (예: useDataTablePersonnel)
【4단계】 첫 번째 렌더 (데이터 없이)
【5단계】 useEffect 실행 (마운트 후)
【6단계】 데이터 fetch 완료 → 리렌더링
```
---
### 4⃣ Hook 호출 및 반환값 분석
**설명 포함 사항**: 훅의 목적, 매개변수/반환값 타입, 내부 로직
```tsx
// 📍 src/features/standards/personnel/hooks/useDataTablePersonnel.ts
interface UseDataTablePersonnelReturn {
data: PersonnelData[] | undefined;
isLoading: boolean;
refetch: () => void;
}
export function useDataTablePersonnel(
params: { page?: number; pageSize?: number } = {}
): UseDataTablePersonnelReturn {
const query = useQuery({
// 📌 queryKey: 캐시 키 (이 키로 데이터를 구분/저장)
queryKey: ['personnel', 'list', { page, pageSize }],
// 📌 queryFn: 실제 데이터를 가져오는 함수
queryFn: async () => {
const response = await personnelApi.getList({ page, pageSize });
return response.data;
},
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
});
return {
data: query.data,
isLoading: query.isLoading,
refetch: query.refetch,
};
}
```
---
### 5⃣ API 호출 → 상태 저장 → 리렌더링 플로우
**데이터 플로우 다이어그램**:
```
【1】 컴포넌트 마운트
【2】 useQuery 내부에서 queryFn 실행
【3】 personnelApi.getList() API 호출
【4】 서버 응답 수신
【5】 TanStack Query 캐시에 데이터 저장
【6】 구독 중인 컴포넌트에 변경 알림 → 리렌더링
```
**API 코드 예시**:
```tsx
// 📍 src/features/standards/personnel/api.ts
// 📌 제너릭 <T> 사용: 어떤 타입이든 data로 받을 수 있음
interface ApiResponse<T> {
data: T;
message: string;
}
export const personnelApi = {
getList: async (params): Promise<ApiResponse<PersonnelListResponse>> => {
const response = await axiosInstance.get('/api/v1/personnel', { params });
return response.data;
},
// 📌 Omit<T, K>: T에서 K 키 제외 (id, createdAt은 서버 생성)
create: async (data: Omit<PersonnelItem, 'id' | 'createdAt'>) => {
return await axiosInstance.post('/api/v1/personnel', data);
},
// 📌 Partial<T>: 모든 속성을 선택적으로 (부분 수정용)
update: async (id: string, data: Partial<PersonnelItem>) => {
return await axiosInstance.patch(`/api/v1/personnel/${id}`, data);
},
};
```
---
### 6⃣ 리렌더링 트리거 상세 분석
| 트리거 | 영향받는 컴포넌트 | 리렌더 조건 |
|--------|-------------------|-------------|
| `query.data` 변경 | `useQuery` 사용 컴포넌트 | 데이터 fetch 완료 |
| `selectedRowIds` 변경 | 해당 selector 사용 컴포넌트 | 행 선택/해제 |
| props 변경 | 자식 컴포넌트 | 부모에서 전달하는 props 변경 |
**Zustand 선택자 예시** (성능 최적화):
```tsx
// 📌 특정 상태만 구독하여 불필요한 리렌더링 방지
export const useSelectedRowIds = () =>
usePersonnelStore((state) => state.selectedRowIds);
```
---
### 7⃣ TypeScript 타입 상세 설명
**제너릭 (Generics)**: 타입을 파라미터처럼 전달
```tsx
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNumber = getFirst<number>([1, 2, 3]); // number | undefined
```
**주요 유틸리티 타입**:
```tsx
interface Person { id: string; name: string; age: number; createdAt: Date; }
// Partial<T> - 모든 속성을 선택적으로 (부분 업데이트용)
type PartialPerson = Partial<Person>;
// Pick<T, K> - 특정 속성만 선택
type PersonName = Pick<Person, 'id' | 'name'>;
// Omit<T, K> - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외)
type PersonWithoutId = Omit<Person, 'id' | 'createdAt'>;
// Record<K, V> - 키-값 쌍의 객체 타입
type Filters = Record<string, string | number>;
```
**타입 가드 (Type Guards)**:
```tsx
// 커스텀 타입 가드 (is 키워드)
function isSuccess(response: SuccessResponse | ErrorResponse): response is SuccessResponse {
return response.success === true;
}
if (isSuccess(response)) {
console.log(response.data); // SuccessResponse로 타입 좁혀짐
}
```
---
## 🎯 분석 체크리스트
### ✅ 필수 포함 사항
- 파일 경로와 라인 번호 명시
- 모든 타입 정의 상세 설명
- 제너릭/유틸리티 타입 사용 이유 설명
- 데이터 플로우 다이어그램 포함
- 리렌더링 조건 표로 정리
- **주석은 한글로** 상세하게
### ❌ 금지 사항
- 추측으로 설명하기
- 코드 없이 설명만 하기
- 타입 설명 생략하기
---
## 📊 응답 템플릿
```markdown
# 🔍 [기능명] 완전 분석
## 1⃣ 진입점: app/page
[코드 + 상세 주석]
## 2⃣ 컴포넌트 시작
[코드 + 상세 주석]
## 3⃣ 컴포넌트 시작 플로우
[플로우 다이어그램 + 코드]
## 4⃣ Hook 호출 및 반환값
[훅 코드 + 타입 설명 + 각 반환값 기능]
## 5⃣ API 호출 → 상태 저장 → 리렌더링
[전체 플로우 다이어그램]
## 6⃣ 리렌더링 트리거
[리렌더 조건 표]
## 7⃣ TypeScript 타입 분석
[제너릭/유틸리티 타입 사용 이유]
```
---
## 🔧 프로젝트 기술 스택
| 분류 | 기술 | 버전 |
|------|------|------|
| 프레임워크 | Next.js (App Router) | 15.3 |
| UI 라이브러리 | React | 19 |
| 언어 | TypeScript | strict mode |
| 서버 상태 | TanStack Query | v5 |
| UI 상태 | Zustand | v5 |
| 폼 관리 | React Hook Form + Zod | v7 |
---
## 📁 프로젝트 구조
```
src/
├── app/ # Next.js App Router 라우트
│ └── [route]/page.tsx # 페이지 컴포넌트
├── components/
│ ├── ui/ # 기본 UI (shadcn 기반)
│ └── custom_ui/ # 복합/레이아웃 컴포넌트
├── features/ # 도메인별 기능 모듈
│ └── [domain]/
│ ├── api.ts # 도메인 API 서비스
│ ├── types.ts # 타입 정의
│ ├── hooks/ # 커스텀 훅
│ ├── components/ # 도메인 컴포넌트
│ └── store/ # Zustand 스토어
├── hooks/ # 공통 훅
├── lib/ # 유틸리티
└── stores/ # 공통 스토어
```

View File

@@ -0,0 +1,341 @@
---
trigger: manual
---
# 🎯 Anti-Gravity 통합 작업 지침서
이 문서는 `.agent/rules/`의 커스텀 룰과 `.agent/skills/`의 Skill들을 **상황별로 자동 조합**하여 최적의 결과를 도출하기 위한 마스터 가이드입니다.
주식 예제는 공식 한국투자증권에서 제공하는 예제를 항상 이용해서 파이선 예제 코드를 참고하여 작성합니다.
공식예제경로: .tmp\open-trading-api
공식 사이트 무조건참고해서 수정해 공식사이트는 여기야 'https://github.com/koreainvestment/open-trading-api'
---
## 📋 작업 유형별 룰+Skill 조합표
| 작업 유형 | 주 룰(Primary) | 보조 룰(Secondary) | 활용 Skill | MCP 도구 |
| ------------------- | ----------------------- | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- |
| **새 기능 개발** | `builder-rule.md` | - | `nextjs-app-router-patterns`<br>`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)<br>`context7` (라이브러리 확인) |
| **코드 분석/이해** | `code-analysis-rule.md` | - | `nextjs-app-router-patterns` (구조 이해) | `sequential-thinking` (플로우 분석) |
| **주석 추가** | `doc-rule.md` | `code-analysis-rule.md` | - | - |
| **리팩토링** | `refactoring-rule.md` | `builder-rule.md` (재구현) | `vercel-react-best-practices` (성능 개선) | `sequential-thinking` (의존성 분석)<br>`context7` (최신 패턴 확인) |
| **성능 최적화** | `builder-rule.md` | `refactoring-rule.md` | `vercel-react-best-practices` | `context7` (최신 최적화 기법) |
| **주석 + 리팩토링** | `refactoring-rule.md` | `doc-rule.md` | `vercel-react-best-practices` | `sequential-thinking` |
---
## 🔄 작업 흐름별 세부 가이드
### 1⃣ 새 기능 개발 (Feature Development)
**트리거 키워드**: "새로운 기능", "컴포넌트 추가", "API 연동", "페이지 생성"
**작업 순서**:
```
[1단계] builder-rule.md 기준으로 구조 설계
[2단계] nextjs-app-router-patterns로 Next.js App Router 패턴 확인
↓ (복잡한 로직이 있다면)
[2-1] sequential-thinking으로 로직 검증
↓ (SpreadJS 등 외부 라이브러리 사용 시)
[2-2] context7로 공식 문서 조회
[3단계] vercel-react-best-practices로 성능 최적화 패턴 적용
[4단계] builder-rule.md의 출력 형식대로 코드 제공
- [가정] 섹션
- 핵심 코드 블록
- 한 줄 한 줄 뜯어보기
- 작동 흐름
- 핵심 포인트
```
**구체적 통합 예시**:
```markdown
# [예시] CreateLeadDialog 컴포넌트 개발
## [1단계] builder-rule.md 적용
- Tech Stack 확인: Next.js 15.3, React 19, TanStack Query v5, Zustand v5
- 폴더 구조: `src/features/leads/components/CreateLeadDialog.tsx`
- 'use client' 필요 (Form 인터랙션)
## [2단계] nextjs-app-router-patterns 참고
- Client Component는 'use client' 선언
- Server Action 사용 시 "use server" 분리
- Suspense 경계 설정
## [2-1] sequential-thinking (복잡한 검증 로직이 있는 경우)
- Form 제출 → 사전 검증 → API 호출 → 성공/실패 처리
- 엣지 케이스: 중복 제출, 네트워크 오류, 필수값 누락
## [3단계] vercel-react-best-practices 적용
- `rerender-memo`: 무거운 Form 로직은 memo로 감싸기
- `client-swr-dedup`: TanStack Query로 중복 요청 방지
- `rendering-conditional-render`: 조건부 렌더링은 삼항 연산자 사용
## [4단계] 최종 코드 출력
- builder-rule.md의 출력 형식 준수
- 주석은 한글로, 작성자 'jihoon87.lee'
```
---
### 2⃣ 코드 분석/이해 (Code Analysis)
**트리거 키워드**: "코드 분석", "흐름 설명", "어떻게 작동", "플로우 파악"
**작업 순서**:
```
[1단계] code-analysis-rule.md의 분석 순서 준수
- 진입점 (app/page)
- 컴포넌트 시작
- Hook 호출
- API → 상태 → 리렌더
- TypeScript 타입 설명
↓ (복잡한 흐름인 경우)
[1-1] sequential-thinking으로 논리적 단계 검증
[2단계] nextjs-app-router-patterns로 Next.js 구조 매핑
- Server Component vs Client Component
- Parallel Routes, Intercepting Routes 등
[3단계] code-analysis-rule.md 응답 템플릿 사용
- 한글 주석
- 플로우 다이어그램
- 리렌더링 조건 표
```
**통합 예시**:
```markdown
# [예시] PersonnelTableContainer 분석
## [적용 룰]
- code-analysis-rule.md: 분석 순서 및 템플릿
- nextjs-app-router-patterns: Server/Client 구분
- sequential-thinking: 데이터 페칭 순서 검증
## [분석 결과]
### 1⃣ 진입점
- URL: /standards/personnel
- Server Component (page.tsx) → Client Component (PersonnelTableContainer)
### 3⃣ 컴포넌트 시작 플로우 (sequential-thinking 검증)
【1단계】useState 초기값 설정
【2단계】useDataTablePersonnel 훅 호출
【3단계】TanStack Query가 queryFn 실행
【4단계】personnelApi.getList() 호출
【5단계】응답 데이터를 Query 캐시에 저장
【6단계】컴포넌트 리렌더링
```
---
### 3⃣ 주석 추가 (Documentation)
**트리거 키워드**: "주석 추가", "문서화", "JSDoc 작성"
**작업 순서**:
```
[1단계] code-analysis-rule.md로 코드 흐름 파악
[2단계] doc-rule.md 규칙 적용
- 파일 상단 TSDoc
- 함수/타입 TSDoc
- Step 주석 (복잡한 함수만)
[3단계] 코드 변경 없이 주석만 추가
```
**통합 예시**:
```typescript
/**
* @file PersonnelTableContainer.tsx
* @description 인사 기준정보 목록 조회 및 관리 컨테이너
* @author jihoon87.lee
* @remarks
* - [레이어] Components (UI)
* - [사용자 행동] 목록 조회 → 검색/필터 → 상세 → 편집/삭제
* - [데이터 흐름] UI → useDataTablePersonnel → personnelApi → TanStack Query 캐시 → UI
* - [연관 파일] useDataTablePersonnel.ts, personnelApi.ts
*/
"use client";
// (code-analysis-rule로 분석 → doc-rule로 주석 추가)
```
---
### 4⃣ 리팩토링 (Refactoring)
**트리거 키워드**: "리팩토링", "구조 개선", "폴더 정리", "성능 개선"
**작업 순서**:
```
[1단계] refactoring-rule.md의 워크플로우 적용
[1-1] sequential-thinking으로 의존성 지도 작성
- 파일 이동 전 영향 범위 분석
- import 경로 변경 목록 작성
[1-2] context7로 최신 폴더 구조 패턴 확인
- TanStack Query v5 권장 구조
- Next.js 15 App Router 최적화
[2단계] refactoring-rule.md의 표준 구조로 재구성
- apis/, hooks/, types/, stores/, components/
[3단계] vercel-react-best-practices로 성능 최적화
- bundle-barrel-imports: 직접 import
- rerender-memo: 불필요한 리렌더 방지
[4단계] builder-rule.md로 재구현 (필요 시)
```
**통합 예시**:
```markdown
# [예시] work-execution 기능 리팩토링
## [1단계] sequential-thinking 의존성 분석
- 현재 파일: workExecutionOld.tsx (800줄, 단일 파일)
- 의존하는 외부 파일: app/standards/work-execution/page.tsx
- 영향받는 import: 3개 파일
## [1-2] context7 최신 패턴 조회
- TanStack Query v5: queryKeys를 별도 파일로 분리 권장
- Next.js 15: Parallel Routes로 loading 상태 분리 가능
## [2단계] refactoring-rule 표준 구조 적용
src/features/standards/work-execution/
├── apis/
│ ├── workExecution.api.ts
│ └── workExecutionForm.adapter.ts
├── hooks/
│ ├── queryKeys.ts
│ └── useWorkExecutionList.ts
├── types/
│ └── workExecution.types.ts
├── stores/
│ └── workExecutionStore.ts
└── components/
├── WorkExecutionContainer.tsx
└── WorkExecutionModal.tsx
## [3단계] vercel-react-best-practices 적용
- bundle-barrel-imports: index.ts 제거, 직접 경로 사용
- rerender-memo: WorkExecutionModal을 React.memo로 감싸기
- async-parallel: API 호출을 Promise.all로 병렬화
```
---
## 🛠️ MCP 도구 활용 가이드
### Sequential Thinking 사용 시점
1. **복잡한 비즈니스 로직 구현 전**
- 예: 다단계 Form 검증, 복잡한 상태 머신
- 목적: 엣지 케이스 사전 도출
2. **리팩토링 시 의존성 분석**
- 예: 파일 이동 시 영향 범위 파악
- 목적: Broken Import 방지
3. **코드 분석 시 데이터 플로우 검증**
- 예: 브라우저 렌더링 단계, 데이터 페칭 순서
- 목적: 논리적 흐름 명확화
### Context7 사용 시점
1. **외부 라이브러리 최신 API 확인**
- 예: SpreadJS v18, TanStack Query v5
- 목적: 공식 문서 기반 정확한 구현
2. **리팩토링 시 최신 패턴 확인**
- 예: Next.js 15 App Router 권장 구조
- 목적: 최신 표준과 프로젝트 룰 결합
3. **성능 최적화 검증**
- 예: React 19 신규 Hook 활용법
- 목적: 최신 기법 적용
---
## 📌 실전 적용 예시
### 예시 1: 새 기능 개발 요청
**사용자 요청**: "리드 생성 모달을 만들어줘"
**AI 작업 프로세스**:
1. `builder-rule.md` 로드 → Tech Stack 확인
2. `nextjs-app-router-patterns` 참고 → Client Component 패턴 확인
3. `vercel-react-best-practices` 적용 → `rerender-memo`, `rendering-conditional-render`
4. `builder-rule.md` 출력 형식으로 코드 제공
### 예시 2: 코드 플로우 분석 요청
**사용자 요청**: "PersonnelTableContainer가 어떻게 작동하는지 설명해줘"
**AI 작업 프로세스**:
1. `code-analysis-rule.md` 로드 → 분석 순서 준수
2. `sequential-thinking` 사용 → 데이터 페칭 순서 검증
3. `nextjs-app-router-patterns` 참고 → Server/Client 구분 설명
4. `code-analysis-rule.md` 템플릿으로 결과 출력
### 예시 3: 리팩토링 요청
**사용자 요청**: "work-execution 폴더를 정리해줘"
**AI 작업 프로세스**:
1. `refactoring-rule.md` 로드 → 워크플로우 확인
2. `sequential-thinking` 사용 → 의존성 지도 작성
3. `context7` 조회 → TanStack Query v5 권장 구조 확인
4. `refactoring-rule.md` 표준 구조로 재구성
5. `vercel-react-best-practices` 적용 → `bundle-barrel-imports`, `rerender-memo`
---
## ✅ 체크리스트
작업 시작 전 항상 확인:
- [ ] 작업 유형이 무엇인가? (개발/분석/주석/리팩토링)
- [ ] 주 룰(Primary)과 보조 룰(Secondary)은?
- [ ] 어떤 Skill을 참고해야 하는가?
- [ ] MCP 도구(sequential-thinking, context7)가 필요한가?
- [ ] 출력 형식은 어떤 룰을 따르는가?
---
## 🎓 학습 자료
- **builder-rule.md**: Tech Stack, 코딩 원칙, 출력 형식
- **code-analysis-rule.md**: 분석 순서, TypeScript 타입 설명
- **doc-rule.md**: TSDoc 형식, Step 주석 규칙
- **refactoring-rule.md**: 폴더 구조, 워크플로우
- **nextjs-app-router-patterns**: Next.js 패턴, Server/Client 구분
- **vercel-react-best-practices**: 성능 최적화 57개 룰

View File

@@ -0,0 +1,94 @@
---
trigger: manual
---
# 역할: Anti-Gravity Refactoring Specialist
당신은 psix-frontend 프로젝트의 **구조적 개선 및 리팩토링 전문가**입니다.
기존 스파게티 코드나 레거시 구조를 **모던하고 유지보수 가능한 표준 구조**로 재설계합니다.
---
## 📥 입력 (Input)
- **FEATURE_ROOT**: 리팩토링할 기능의 루트 폴더 경로
- 예) `src/features/standards/work-execution`
---
## 🎯 목표 (Goal)
1. **표준 폴더 구조 지향**: `apis`, `components`, `hooks`, `stores`, `types` 5대 폴더를 기본으로 구성한다.
2. **유연성 허용**: 필요에 따라 `utils`(유틸리티), `lib`(라이브러리 래퍼), `constants`(상수) 등 보조 폴더 생성을 허용한다.
3. **단일 파일 분해**: 거대한 파일은 기능 단위로 쪼개야 함
4. **배럴 파일 제거**: `index.ts`를 사용한 re-export 패턴을 제거하고 직접 경로(`.../components/MyComponent`)를 사용
---
## 🛠️ MCP 도구 활용 (분석 및 설계 지침)
### 1. Sequential Thinking 활용 (의존성 및 리스크 분석)
- **적용 시점**: `1) FEATURE_ROOT의 기존 구조와 import 의존성 분석` 단계에서 필수 사용
- **수행 작업**:
- 실제 파일을 옮기기 전, `sequential-thinking`을 사용하여 이동할 파일들의 의존성 지도(Dependency Map)를 먼저 그린다.
- 파일 이동 시 영향받는 외부 파일(page.tsx 등)의 리스트를 미리 확보한다.
- 수정해야 할 import 경로가 많은 경우, 논리적 순차 단계를 설정하여 하나씩 해결함으로써 경로 오류(Broken Import)를 방지한다.
### 2. Context7 활용 (기술 표준 검증)
- **적용 시점**: 폴더 구조 재구성 중 최신 라이브러리 패턴이 가이드와 충돌하거나 모호할 때 사용
- **수행 작업**:
- TanStack Query의 최신 v5 권장 폴더 구조나 Next.js 15의 App Router 최적화 기법이 필요할 경우 `context7`을 통해 공식 문서를 조회한다.
- 조회된 최신 표준과 본 룰의 구조(`apis`, `hooks`, `types` 등)를 결합하여 개발자가 유지보수하기 가장 편한 최적의 경로를 도출한다.
---
## 📋 작업 지시 (Workflow)
1. **분석**: `FEATURE_ROOT` 내의 기존 파일 구조와 외부 의존성(import)을 파악한다. (MCP 활용)
2. **구조 설계**:
- **기본 폴더**: `apis`, `hooks`, `types`, `stores`, `components`
- **선택 폴더**: `utils` (순수 함수), `lib` (설정/래퍼), `constants` (상수 데이터)
- 위 기준에 맞춰 파일 분류 계획을 세운다.
3. **이동 및 생성**: 파일을 계획된 폴더로 이동하거나 분리 생성한다.
4. **경로 수정**: 이동된 파일에 맞춰 모든 `import` 경로를 업데이트한다.
5. **청소**: 불필요해진 폴더(구조상 매핑되지 않는 옛 폴더)와 `index.ts` 파일을 삭제한다.
6. **진입점 갱신**: `page.tsx` 등 외부에서 해당 기능을 사용하는 곳의 import 경로를 수정한다.
---
## 🏗️ 권장 파일 구조 (Standard Structure)
```text
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts # API 호출 로직
│ ├── <feature>Form.adapter.ts # Form <-> API 변환
│ └── <feature>List.adapter.ts # List <-> API 변환
├── hooks/
│ ├── queryKeys.ts # Query Key Factory
│ ├── use<Feature>List.ts # 목록 조회 Hooks
│ ├── use<Feature>Mutations.ts # CUD Hooks
│ └── use<Feature>Form.ts # Form Logic Hooks
├── types/
│ ├── api.types.ts # 공통 API 응답 규격
│ ├── <feature>.types.ts # 도메인 Entity
│ └── selectOption.types.ts # 공통 Select Option
├── stores/
│ └── <feature>Store.ts # Zustand Store
├── components/
│ ├── <Feature>Container.tsx # 메인 컨테이너
│ └── <Feature>Modal.tsx # 모달 컴포넌트
├── utils/ # (Optional)
│ └── <feature>Utils.ts # 순수 헬퍼 함수
└── constants/ # (Optional)
└── <feature>.constants.ts # 상수 (components 내부에 둬도 무방)
```
---
## ⚠️ 규칙 (Rules)
1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다.
2. **Naming Convention**:
- 파일명은 **ASCII 영문**만 사용 (한글 금지)
- UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`)
- Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`)
3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다.

View File

@@ -1,9 +1,23 @@
# 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=
NEXT_PUBLIC_SUPABASE_ANON_KEY= 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

6
.gemini/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"tools": {
"approvalMode": "auto_edit",
"allowed": ["run_shell_command"]
}
}

5
.gitignore vendored
View File

@@ -124,3 +124,8 @@ node_modules
# Custom # Custom
# ======================================== # ========================================
.playwright-mcp/ .playwright-mcp/
# ========================================
# Documentation (문서)
# ========================================
docs/

View File

@@ -0,0 +1 @@
{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6IjQ1ZTBmYTczLWI3ZmEtNDg5Mi1iYmZkLTJkYzdlNWQ2YTFhOCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDg3NDg1NywiaWF0IjoxNzcwNzg4NDU3LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.f4XsiK4WgzzBNbGEP5bNnJ9r4yAfGBb8SOwEZ-D0knygsFqSOGsj1QfjjVIBo7lG5AxAwyrIUdoC-rjqIVCc3A","expiresAt":1770874857000}}

1
.tmp/open-trading-api Submodule

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

View File

@@ -12,6 +12,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants"; import { AUTH_ROUTES } from "@/features/auth/constants";
import { Mail } from "lucide-react";
/** /**
* [비밀번호 찾기 페이지] * [비밀번호 찾기 페이지]
@@ -31,10 +32,10 @@ export default async function ForgotPasswordPage({
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />} {message && <FormMessage message={message} />}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70"> <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<span className="text-sm font-semibold">MAIL</span> <Mail className="h-7 w-7 text-white" />
</div> </div>
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">
@@ -59,13 +60,13 @@ export default async function ForgotPasswordPage({
placeholder="name@example.com" placeholder="name@example.com"
autoComplete="email" autoComplete="email"
required required
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
</div> </div>
<Button <Button
formAction={requestPasswordReset} formAction={requestPasswordReset}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white" className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
> >
</Button> </Button>
@@ -74,7 +75,7 @@ export default async function ForgotPasswordPage({
<div className="text-center"> <div className="text-center">
<Link <Link
href={AUTH_ROUTES.LOGIN} href={AUTH_ROUTES.LOGIN}
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white" className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>

View File

@@ -12,17 +12,18 @@ export default async function AuthLayout({
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
return ( return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 dark:from-black dark:via-gray-950 dark:to-gray-900"> <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-brand-50 via-white to-brand-100/60 dark:from-brand-950 dark:via-gray-950 dark:to-brand-900/40">
{/* ========== 헤더 (홈 이동용) ========== */} {/* ========== 헤더 (홈 이동용) ========== */}
<Header user={user} /> <Header user={user} />
{/* ========== 배경 그라디언트 레이어 ========== */} {/* ========== 배경 그라디언트 레이어 ========== */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-brand-200/40 via-brand-100/20 to-transparent dark:from-brand-800/25 dark:via-brand-900/15" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-brand-300/30 via-brand-200/15 to-transparent dark:from-brand-700/20 dark:via-brand-800/10" />
{/* ========== 애니메이션 블러 효과 ========== */} {/* ========== 애니메이션 블러 효과 ========== */}
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" /> <div className="absolute left-1/4 top-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-300/25 blur-3xl dark:bg-brand-700/15" />
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" /> <div className="absolute bottom-1/4 right-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-400/20 blur-3xl delay-700 dark:bg-brand-600/15" />
<div className="absolute right-1/3 top-1/3 h-48 w-48 animate-pulse rounded-full bg-brand-200/30 blur-2xl delay-1000 dark:bg-brand-800/20" />
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */} {/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12"> <main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">

View File

@@ -7,13 +7,13 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import LoginForm from "@/features/auth/components/login-form"; import LoginForm from "@/features/auth/components/login-form";
import { LogIn } from "lucide-react";
/** /**
* [로그인 페이지 컴포넌트] * [로그인 페이지 컴포넌트]
* *
* Modern UI with glassmorphism effect (유리 형태 디자인) * 브랜드 컬러 기반 글래스모피즘 카드 디자인
* - 투명 배경 + 블러 효과로 깊이감 표현 * - 보라색 그라디언트 아이콘 배지
* - 그라디언트 배경으로 생동감 추가
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지 * - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
* *
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용) * @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
@@ -23,36 +23,25 @@ export default async function LoginPage({
}: { }: {
searchParams: Promise<{ message: string }>; searchParams: Promise<{ message: string }>;
}) { }) {
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
const { message } = await searchParams; const { message } = await searchParams;
return ( return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* 에러/성공 메시지 표시 영역 */}
{/* URL 파라미터에 message가 있으면 표시됨 */}
<FormMessage message={message} /> <FormMessage message={message} />
{/* ========== 로그인 카드 (Glassmorphism) ========== */} <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
{/* ========== 카드 헤더 영역 ========== */}
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
{/* 아이콘 배경: 그라디언트 원형 */} <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <LogIn className="h-7 w-7 text-white" />
<span className="text-4xl">👋</span>
</div> </div>
{/* 페이지 제목 */}
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">
! !
</CardTitle> </CardTitle>
{/* 페이지 설명 */}
<CardDescription className="text-base"> <CardDescription className="text-base">
. .
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
<CardContent> <CardContent>
<LoginForm /> <LoginForm />
</CardContent> </CardContent>

View File

@@ -9,6 +9,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { KeyRound } from "lucide-react";
/** /**
* [비밀번호 재설정 페이지] * [비밀번호 재설정 페이지]
@@ -39,10 +40,10 @@ export default async function ResetPasswordPage({
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />} {message && <FormMessage message={message} />}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70"> <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<span className="text-sm font-semibold">PW</span> <KeyRound className="h-7 w-7 text-white" />
</div> </div>
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">

View File

@@ -9,6 +9,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { UserPlus } from "lucide-react";
export default async function SignupPage({ export default async function SignupPage({
searchParams, searchParams,
@@ -19,13 +20,12 @@ export default async function SignupPage({
return ( return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* 메시지 알림 */}
<FormMessage message={message} /> <FormMessage message={message} />
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70"> <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<span className="text-4xl">🚀</span> <UserPlus className="h-7 w-7 text-white" />
</div> </div>
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">
@@ -35,16 +35,14 @@ export default async function SignupPage({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{/* ========== 폼 영역 ========== */}
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<SignupForm /> <SignupForm />
{/* ========== 로그인 링크 ========== */} <p className="text-center text-sm text-muted-foreground">
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
?{" "} ?{" "}
<Link <Link
href={AUTH_ROUTES.LOGIN} href={AUTH_ROUTES.LOGIN}
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white" className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>

View File

@@ -1,222 +1,348 @@
/** /**
* @file app/(home)/page.tsx * @file app/(home)/page.tsx
* @description 서비스 메인 랜딩 페이지 * @description 서비스 메인 랜딩 페이지(Server Component)
* @remarks
* - [레이어] Pages (Server Component)
* - [역할] 비로그인 유저 유입, 브랜드 소개, 로그인/대시보드 유도
* - [구성요소] Header, Hero Section (Spline 3D), Feature Section (Bento Grid)
* - [데이터 흐름] Server Auth Check -> Client Component Props
*/ */
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import type { LucideIcon } from "lucide-react";
import { createClient } from "@/utils/supabase/server"; import {
Activity,
ArrowRight,
ShieldCheck,
Sparkles,
TrendingUp,
Zap,
} from "lucide-react";
import { Header } from "@/features/layout/components/header"; import { Header } from "@/features/layout/components/header";
import { AUTH_ROUTES } from "@/features/auth/constants"; import { AUTH_ROUTES } from "@/features/auth/constants";
import { SplineScene } from "@/features/home/components/spline-scene"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import ShaderBackground from "@/components/ui/shader-background";
import { createClient } from "@/utils/supabase/server";
interface ValuePoint {
value: string;
label: string;
detail: string;
}
interface FeatureItem {
icon: LucideIcon;
eyebrow: string;
title: string;
description: string;
}
interface StartStep {
step: string;
title: string;
description: string;
}
const VALUE_POINTS: ValuePoint[] = [
{
value: "3분",
label: "초기 세팅 시간",
detail: "복잡한 설정 대신 질문 기반으로 빠르게 시작합니다.",
},
{
value: "24시간",
label: "실시간 시장 관제",
detail: "자리 비운 시간에도 규칙 기반으로 자동 대응합니다.",
},
{
value: "0원",
label: "가입 시작 비용",
detail: "부담 없이 계정 생성 후 내 투자 스타일부터 점검합니다.",
},
];
const FEATURE_ITEMS: FeatureItem[] = [
{
icon: ShieldCheck,
eyebrow: "Risk Guard",
title: "손실 방어를 먼저 설계합니다",
description:
"진입보다 중요한 것은 방어입니다. 손절 기준과 노출 한도를 먼저 세워 감정 개입을 줄입니다.",
},
{
icon: TrendingUp,
eyebrow: "Data Momentum",
title: "데이터로 타점을 좁힙니다",
description:
"실시간 데이터 흐름을 읽어 조건이 맞을 때만 실행합니다. 초보도 납득 가능한 근거를 확인할 수 있습니다.",
},
{
icon: Zap,
eyebrow: "Auto Execution",
title: "기회가 오면 즉시 자동 실행합니다",
description:
"규칙이 충족되면 지연 없이 주문이 실행됩니다. 잠자는 시간과 업무 시간에도 전략은 멈추지 않습니다.",
},
];
const START_STEPS: StartStep[] = [
{
step: "STEP 01",
title: "계정 연결",
description: "안내에 따라 거래 계정을 안전하게 연결합니다.",
},
{
step: "STEP 02",
title: "성향 선택",
description: "공격형·균형형·안정형 중 내 스타일을 고릅니다.",
},
{
step: "STEP 03",
title: "자동 실행 시작",
description: "선택한 전략으로 실시간 관제를 바로 시작합니다.",
},
];
/** /**
* 메인 페이지 컴포넌트 (비동기 서버 컴포넌트) * 메인 랜딩 페이지
* @returns Landing Page Elements * @returns 랜딩 UI
* @see layout.tsx - RootLayout 내에서 렌더링 * @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
* @see spline-scene.tsx - 3D 인터랙션
*/ */
export default async function HomePage() { export default async function HomePage() {
// [Step 1] 서버 사이드 인증 상태 확인 // [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
const supabase = await createClient(); const supabase = await createClient();
const { const {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
// [CTA 분기] 로그인 여부에 따라 같은 위치에서 다른 행동으로 자연스럽게 전환합니다.
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
const primaryCtaLabel = user ? "대시보드로 전략 실행하기" : "무료로 시작하고 첫 전략 받기";
const secondaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.LOGIN;
const secondaryCtaLabel = user ? "실시간 상태 확인하기" : "기존 계정으로 로그인";
return ( return (
<div className="flex min-h-screen flex-col overflow-x-hidden"> <div className="flex min-h-screen flex-col overflow-x-hidden bg-transparent">
<Header user={user} showDashboardLink={true} /> <Header user={user} showDashboardLink={true} blendWithBackground={true} />
<main className="flex-1 bg-background pt-16"> <main className="relative isolate flex-1 overflow-hidden pt-16">
{/* Background Pattern */} {/* ========== SHADER BACKGROUND SECTION ========== */}
<div className="absolute inset-0 -z-10 h-full w-full bg-white dark:bg-black bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-size-[14px_24px] mask-[radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" /> <ShaderBackground opacity={1} className="-z-20" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 bg-black/45" />
<div
aria-hidden="true"
className="pointer-events-none absolute -left-40 top-40 -z-10 h-96 w-96 rounded-full bg-brand-500/20 blur-3xl"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-24 top-72 -z-10 h-[26rem] w-[26rem] rounded-full bg-brand-300/20 blur-3xl"
/>
<section className="container relative mx-auto max-w-7xl px-4 pt-12 md:pt-24 lg:pt-32"> {/* ========== HERO SECTION ========== */}
<div className="flex flex-col items-center justify-center text-center"> <section className="container mx-auto max-w-7xl px-4 pb-14 pt-14 md:pt-24">
{/* Badge */} <div className="grid gap-8 lg:grid-cols-12 lg:gap-10">
<div className="mb-6 inline-flex items-center rounded-full border border-brand-200/50 bg-brand-50/50 px-3 py-1 text-sm font-medium text-brand-600 backdrop-blur-md dark:border-brand-800/50 dark:bg-brand-900/50 dark:text-brand-300"> <div className="lg:col-span-7">
<span className="mr-2 flex h-2 w-2 animate-pulse rounded-full bg-brand-500"></span> <span className="inline-flex animate-in fade-in-0 items-center gap-2 rounded-full border border-brand-400/40 bg-brand-500/15 px-4 py-1.5 text-xs font-semibold tracking-wide text-brand-100 backdrop-blur-md duration-700">
The Future of Trading <Sparkles className="h-3.5 w-3.5" />
</div>
</span>
<h1 className="font-heading text-4xl font-black tracking-tight text-foreground sm:text-6xl md:text-7xl lg:text-8xl"> <h1 className="mt-5 animate-in slide-in-from-bottom-4 text-4xl font-black tracking-tight text-white [text-shadow:0_6px_40px_rgba(0,0,0,0.55)] duration-700 md:text-6xl">
<br className="hidden sm:block" /> ,
<span className="animate-gradient-x bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 bg-clip-text text-transparent underline decoration-brand-500/30 decoration-4 underline-offset-8"> <br />
<span className="bg-linear-to-r from-brand-100 via-brand-300 to-brand-500 bg-clip-text text-transparent">
</span> </span>
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground sm:text-xl leading-relaxed break-keep"> <p className="mt-5 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/80 duration-700 md:text-lg">
AutoTrade는 24 .
. <br />
<br className="hidden md:block" /> , , .
.
</p> </p>
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row"> <div className="mt-8 flex animate-in flex-col gap-3 duration-700 sm:flex-row">
{user ? (
<Button <Button
asChild asChild
size="lg" size="lg"
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40" className="group h-12 rounded-full bg-linear-to-r from-brand-500 via-brand-400 to-brand-600 px-8 text-base font-semibold text-white shadow-2xl shadow-brand-800/45 [background-size:200%_200%] animate-gradient-x hover:brightness-110"
> >
<Link href={AUTH_ROUTES.DASHBOARD}> </Link> <Link href={primaryCtaHref}>
{primaryCtaLabel}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Link>
</Button> </Button>
) : (
<Button
asChild
size="lg"
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40"
>
<Link href={AUTH_ROUTES.LOGIN}> </Link>
</Button>
)}
{!user && (
<Button <Button
asChild asChild
variant="outline" variant="outline"
size="lg" size="lg"
className="h-14 rounded-full border-border bg-background/50 px-10 text-lg hover:bg-accent/50 backdrop-blur-sm" className="h-12 rounded-full border-white/35 bg-black/30 px-8 text-base text-white backdrop-blur-md hover:bg-white/10 hover:text-white"
> >
<Link href={AUTH_ROUTES.LOGIN}> </Link> <Link href={secondaryCtaHref}>{secondaryCtaLabel}</Link>
</Button> </Button>
)}
</div> </div>
{/* Spline Scene - Centered & Wide */} <div className="mt-4 flex flex-wrap gap-3 text-xs text-white/70">
<div className="relative mt-16 w-full max-w-5xl"> <span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
<div className="group relative aspect-video w-full overflow-hidden rounded-3xl border border-white/20 bg-linear-to-b from-white/10 to-transparent shadow-2xl backdrop-blur-2xl dark:border-white/10 dark:bg-black/20"> 3
{/* Glow Effect */} </span>
<div className="absolute -inset-1 rounded-3xl bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 opacity-20 blur-2xl transition-opacity duration-500 group-hover:opacity-40" /> <span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
</span>
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
</span>
</div>
</div>
<SplineScene <div className="relative lg:col-span-5">
sceneUrl="https://prod.spline.design/6Wq1Q7YGyM-iab9i/scene.splinecode" <div className="absolute -inset-px rounded-3xl bg-linear-to-br from-brand-300/50 via-brand-600/0 to-brand-600/60 blur-lg" />
className="relative z-10 h-full w-full rounded-2xl" <div className="relative overflow-hidden rounded-3xl border border-white/15 bg-black/35 p-6 shadow-[0_30px_90px_-45px_rgba(0,0,0,0.85)] backdrop-blur-2xl">
/> <div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<p className="text-xs font-semibold tracking-wide text-brand-200">LIVE STRATEGY STATUS</p>
<p className="mt-1 text-lg font-semibold text-white"> </p>
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-brand-500/25 px-3 py-1 text-xs font-semibold text-brand-100">
<Activity className="h-3.5 w-3.5" />
</span>
</div>
{/* [신뢰 포인트] UI 안에서 가치 제안을 한눈에 보여 주기 위해 핵심 상태를 카드형으로 배치합니다. */}
<div className="mt-5 grid gap-3">
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> + </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> , </p>
</div> </div>
</div> </div>
<div className="mt-5 flex items-center gap-2 text-xs text-brand-100/90">
<span className="inline-block h-2 w-2 rounded-full bg-brand-300" />
.
</div>
</div>
</div>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{VALUE_POINTS.map((point) => (
<div
key={point.label}
className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md transition-colors hover:bg-white/10"
>
<p className="text-2xl font-black text-brand-100">{point.value}</p>
<p className="mt-2 text-sm font-semibold text-white">{point.label}</p>
<p className="mt-2 text-xs leading-relaxed text-white/70">{point.detail}</p>
</div>
))}
</div> </div>
</section> </section>
{/* Features Section - Bento Grid */} {/* ========== FEATURE SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 py-24 sm:py-32"> <section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
<div className="mb-16 text-center"> <div className="mx-auto max-w-3xl text-center">
<h2 className="font-heading text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl"> <p className="text-xs font-semibold tracking-widest text-brand-200">WHY JURINI</p>
,{" "} <h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
<span className="text-brand-500"> </span>
</h2>
<p className="mt-4 text-lg text-muted-foreground">
.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-8">
{/* Feature 1 */}
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2">
<div className="relative z-10 flex flex-col justify-between h-full gap-6">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
</div>
<div>
<h3 className="text-2xl font-bold mb-2"> </h3>
<p className="text-muted-foreground text-lg leading-relaxed">
.
<br /> <br />
. <span className="text-brand-200"> </span>
</p> </h2>
</div>
</div>
<div className="absolute top-0 right-0 h-64 w-64 translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
</div> </div>
{/* Feature 2 (Tall) */} <div className="mt-8 grid gap-6 md:grid-cols-3">
<div className="group relative col-span-1 row-span-2 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50"> {FEATURE_ITEMS.map((feature) => {
<div className="relative z-10 flex flex-col h-full gap-6"> const FeatureIcon = feature.icon;
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
<svg return (
className="w-8 h-8" <Card
fill="none" key={feature.title}
viewBox="0 0 24 24" className="group border-white/10 bg-black/25 text-white shadow-none backdrop-blur-md transition-all hover:-translate-y-1 hover:border-brand-400/40 hover:bg-black/35"
stroke="currentColor"
> >
<path <CardHeader>
strokeLinecap="round" <div className="mb-2 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-500/20 text-brand-200 transition-transform group-hover:scale-110">
strokeLinejoin="round" <FeatureIcon className="h-6 w-6" />
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
/>
</svg>
</div> </div>
<h3 className="text-2xl font-bold"> </h3> <p className="text-xs font-semibold tracking-wide text-brand-200">{feature.eyebrow}</p>
<p className="text-muted-foreground text-lg"> <CardTitle className="text-xl leading-snug text-white">{feature.title}</CardTitle>
24 . </CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
{feature.description}
</CardContent>
</Card>
);
})}
</div>
</section>
{/* ========== START STEP SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl md:p-10">
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
<div>
<p className="text-xs font-semibold tracking-widest text-brand-200">GET STARTED</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
<br />
<span className="text-brand-200"> 3 </span>
</h2>
</div>
<p className="max-w-sm text-sm leading-relaxed text-white/70">
, .
</p> </p>
<div className="mt-auto space-y-4 pt-4"> </div>
{[
"추세 추종 전략", <div className="mt-8 grid gap-4 md:grid-cols-3">
"변동성 돌파", {START_STEPS.map((item) => (
"AI 예측 모델", <div key={item.step} className="rounded-2xl border border-white/10 bg-black/30 p-5">
"리스크 관리", <p className="text-xs font-semibold tracking-wide text-brand-200">{item.step}</p>
].map((item) => ( <p className="mt-2 text-lg font-semibold text-white">{item.title}</p>
<div <p className="mt-2 text-sm leading-relaxed text-white/70">{item.description}</p>
key={item}
className="flex items-center gap-3 text-sm font-medium text-foreground/80"
>
<div className="h-2 w-2 rounded-full bg-brand-500" />
{item}
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div className="absolute bottom-0 left-0 h-64 w-64 -translate-x-1/2 translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" /> </section>
</div>
{/* Feature 3 */} {/* ========== CTA SECTION ========== */}
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2"> <section className="container mx-auto max-w-7xl px-4 pb-20">
<div className="relative z-10 flex flex-col justify-between h-full gap-6"> <div className="relative overflow-hidden rounded-3xl border border-brand-300/30 bg-linear-to-r from-brand-600/30 via-brand-500/20 to-brand-700/30 p-8 backdrop-blur-xl md:p-12">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300"> <div
<svg aria-hidden="true"
className="w-8 h-8" className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-brand-200/25 blur-3xl"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/> />
</svg> <div
</div> aria-hidden="true"
className="absolute -bottom-24 -left-16 h-72 w-72 rounded-full bg-brand-700/30 blur-3xl"
/>
<div className="relative z-10 flex flex-col items-center justify-between gap-8 text-center md:flex-row md:text-left">
<div> <div>
<h3 className="text-2xl font-bold mb-2"> </h3> <p className="text-sm font-semibold text-brand-100"> </p>
<p className="text-muted-foreground text-lg leading-relaxed"> <h2 className="mt-2 text-3xl font-black tracking-tight text-white md:text-4xl">
, MDD를 ,
<br /> <br />
.
</h2>
<p className="mt-3 text-sm text-white/75 md:text-base">
.
</p> </p>
</div> </div>
</div>
<div className="absolute right-0 bottom-0 h-40 w-40 translate-x-1/3 translate-y-1/3 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" /> <Button
asChild
size="lg"
className="group h-14 rounded-full bg-white px-9 text-lg font-bold text-brand-700 shadow-xl shadow-black/35 hover:bg-white/90"
>
<Link href={primaryCtaHref}>
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button>
</div> </div>
</div> </div>
</section> </section>

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 { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
import { Activity, CreditCard, DollarSign, Users } from "lucide-react";
/** /**
* 대시보드 페이지 (비동기 서버 컴포넌트) * 대시보드 페이지
* @returns Dashboard Grid Layout * @returns DashboardContainer UI
* @see features/dashboard/components/DashboardContainer.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 <DashboardContainer />;
<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

@@ -1,5 +1,5 @@
import { Header } from "@/features/layout/components/header"; import { Header } from "@/features/layout/components/header";
import { Sidebar } from "@/features/layout/components/sidebar"; import { MobileBottomNav, Sidebar } from "@/features/layout/components/sidebar";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
export default async function MainLayout({ export default async function MainLayout({
@@ -13,12 +13,13 @@ export default async function MainLayout({
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
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-background">
<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="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
</div> </div>
<MobileBottomNav />
</div> </div>
); );
} }

View File

@@ -0,0 +1,98 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
"30m",
"1h",
"1d",
"1w",
];
/**
* @file app/api/kis/domestic/chart/route.ts
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (
searchParams.get("timeframe") ?? "1d"
).trim() as DashboardChartTimeframe;
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json(
{ error: "지원하지 않는 timeframe입니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
try {
const chart = await getDomesticChart(
symbol,
timeframe,
credentials,
cursor,
);
const response: DashboardStockChartResponse = {
symbol,
timeframe,
candles: chart.candles,
nextCursor: chart.nextCursor,
hasMore: chart.hasMore,
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 });
}
}
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,104 @@
import { NextRequest, NextResponse } from "next/server";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const body = (await request.json()) as DashboardStockCashOrderRequest;
// TODO: Validate body fields (symbol, quantity, price, etc.)
if (
!body.symbol ||
!body.accountNo ||
!body.accountProductCode ||
body.quantity <= 0
) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
{ status: 400 },
);
}
const output = await executeOrderCash(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: body.accountNo,
accountProductCode: body.accountProductCode,
},
credentials,
);
const response: DashboardStockCashOrderResponse = {
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
};
return NextResponse.json(response);
} catch (error) {
const message =
error instanceof Error
? error.message
: "주문 전송 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
},
{ status: 500 },
);
}
}
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,165 @@
import { NextRequest, NextResponse } from "next/server";
import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* @file app/api/kis/domestic/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
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 },
);
}
try {
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
const raw = await getDomesticOrderBook(symbol, credentials, {
sessionOverride,
});
const levels = Array.from({ length: 10 }, (_, i) => {
const idx = i + 1;
return {
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
askSize: readOrderBookNumber(
raw,
`askp_rsqn${idx}`,
`ovtm_untp_askp_rsqn${idx}`,
),
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
};
});
const response: DashboardStockOrderBookResponse = {
symbol,
source: "kis",
levels,
totalAskSize: readOrderBookNumber(
raw,
"total_askp_rsqn",
"ovtm_untp_total_askp_rsqn",
"ovtm_total_askp_rsqn",
),
totalBidSize: readOrderBookNumber(
raw,
"total_bidp_rsqn",
"ovtm_untp_total_bidp_rsqn",
"ovtm_total_bidp_rsqn",
),
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
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
: "호가 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
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,
};
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
return parseDomesticKisSession(raw);
}
/**
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
*/
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
const record = raw as Record<string, unknown>;
const value = resolveOrderBookValue(record, keys) ?? "0";
const normalized =
typeof value === "string"
? value.replaceAll(",", "").trim()
: String(value ?? "0");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* @description 호가 응답 필드를 문자열로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
*/
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
const record = raw as Record<string, unknown>;
const value = resolveOrderBookValue(record, keys);
if (value === undefined || value === null) return undefined;
const text = String(value).trim();
return text.length > 0 ? text : undefined;
}
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const direct = record[key];
if (direct !== undefined && direct !== null) return direct;
const upper = record[key.toUpperCase()];
if (upper !== undefined && upper !== null) return upper;
}
return undefined;
}
function resolveBidSizeKeys(index: number) {
if (index === 2) {
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
}
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
}

View File

@@ -0,0 +1,94 @@
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";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* @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 sessionOverride = readSessionOverrideFromHeaders(request.headers);
const overview = await getDomesticOverview(
symbol,
fallbackMeta,
credentials,
{ sessionOverride },
);
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,
};
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
return parseDomesticKisSession(raw);
}

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,58 @@
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/revoke/route.ts
* @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다.
*/
/**
* @description KIS 액세스 토큰 폐기
* @see features/dashboard/components/auth/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisRevokeResponse,
{ status: 400 },
);
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 토큰 폐기 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
}

View File

@@ -0,0 +1,58 @@
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/validate/route.ts
* @description 사용자 입력 KIS API 키를 검증합니다.
*/
/**
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
* @see features/dashboard/components/auth/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisValidateResponse,
{ status: 400 },
);
}
try {
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 키 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
}

View File

@@ -0,0 +1,61 @@
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/ws/approval/route.ts
* @description KIS 웹소켓 승인키와 WS URL을 발급합니다.
*/
/**
* @description 실시간 웹소켓 연결 정보를 발급합니다.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisWsApprovalResponse,
{ status: 400 },
);
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
approvalKey,
wsUrl,
message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "웹소켓 승인키 발급 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
}

View File

@@ -38,16 +38,16 @@
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-brand-50: oklch(0.97 0.02 294); --color-brand-50: var(--brand-50);
--color-brand-100: oklch(0.93 0.05 294); --color-brand-100: var(--brand-100);
--color-brand-200: oklch(0.87 0.1 294); --color-brand-200: var(--brand-200);
--color-brand-300: oklch(0.79 0.15 294); --color-brand-300: var(--brand-300);
--color-brand-400: oklch(0.7 0.2 294); --color-brand-400: var(--brand-400);
--color-brand-500: oklch(0.62 0.24 294); --color-brand-500: var(--brand-500);
--color-brand-600: oklch(0.56 0.26 294); --color-brand-600: var(--brand-600);
--color-brand-700: oklch(0.49 0.24 295); --color-brand-700: var(--brand-700);
--color-brand-800: oklch(0.4 0.2 296); --color-brand-800: var(--brand-800);
--color-brand-900: oklch(0.33 0.14 297); --color-brand-900: var(--brand-900);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -71,6 +71,41 @@
} }
:root { :root {
/* BRAND PALETTE CONTROL
* 이 블록만 수정하면 랜딩/대시보드의 보라 톤이 함께 바뀝니다.
*/
/* 초기 브랜드 보라값(원본 기준) */
--brand-50: oklch(0.97 0.02 294);
--brand-100: oklch(0.93 0.05 294);
--brand-200: oklch(0.87 0.1 294);
--brand-300: oklch(0.79 0.15 294);
--brand-400: oklch(0.7 0.2 294);
--brand-500: oklch(0.62 0.24 294);
--brand-600: oklch(0.56 0.26 294);
--brand-700: oklch(0.49 0.24 295);
--brand-800: oklch(0.4 0.2 296);
--brand-900: oklch(0.33 0.14 297);
/* 차트(canvas): 봉 하락색은 요청대로 파란색 유지 */
--brand-chart-background-light: #ffffff;
--brand-chart-background-dark: #17131e;
--brand-chart-text-light: #6b21a8;
--brand-chart-text-dark: #e9d5ff;
--brand-chart-border-light: #e9d5ff;
--brand-chart-border-dark: rgba(216, 180, 254, 0.3);
--brand-chart-grid-light: #f3e8ff;
--brand-chart-grid-dark: rgba(216, 180, 254, 0.14);
--brand-chart-crosshair-light: #c084fc;
--brand-chart-crosshair-dark: rgba(233, 213, 255, 0.75);
--brand-chart-background: #ffffff;
--brand-chart-down: #2563eb;
--brand-chart-volume-down: rgba(37, 99, 235, 0.45);
--brand-chart-text: #6b21a8;
--brand-chart-border: var(--brand-chart-border-light);
--brand-chart-grid: var(--brand-chart-grid-light);
--brand-chart-crosshair: var(--brand-chart-crosshair-light);
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
@@ -78,7 +113,7 @@
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.56 0.26 294); --primary: var(--brand-600);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
@@ -89,7 +124,7 @@
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.62 0.24 294); --ring: var(--brand-500);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@@ -97,7 +132,7 @@
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.56 0.26 294); --sidebar-primary: var(--brand-600);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
@@ -106,37 +141,45 @@
} }
.dark { .dark {
--background: oklch(0.145 0 0); /* 다크 모드 시인성 개선: 배경 대비는 유지하고, 카드/보더/보조 텍스트를 더 읽기 쉽게 조정 */
--background: oklch(0.17 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.235 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.235 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.56 0.26 294); --primary: var(--brand-600);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.285 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.285 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.83 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.285 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 18%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 22%);
--ring: oklch(0.62 0.24 294); --ring: var(--brand-500);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.235 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.56 0.26 294); --sidebar-primary: var(--brand-600);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.285 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 18%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.78 0 0);
/* 다크 테마용 차트 배경/격자 대비 */
--brand-chart-background: var(--brand-chart-background-dark);
--brand-chart-text: var(--brand-chart-text-dark);
--brand-chart-border: var(--brand-chart-border-dark);
--brand-chart-grid: var(--brand-chart-grid-dark);
--brand-chart-crosshair: var(--brand-chart-crosshair-dark);
} }
@layer base { @layer base {

View File

@@ -13,6 +13,7 @@ import { Geist, Geist_Mono, Outfit } from "next/font/google";
import { QueryProvider } from "@/providers/query-provider"; import { QueryProvider } from "@/providers/query-provider";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SessionManager } from "@/features/auth/components/session-manager"; import { SessionManager } from "@/features/auth/components/session-manager";
import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -32,8 +33,9 @@ const outfit = Outfit({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "AutoTrade", title: "Jurini - 감이 아닌 전략으로 시작하는 자동매매",
description: "Automated Crypto Trading Platform", description:
"주린이를 위한 자동매매 파트너 Jurini. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
}; };
/** /**
@@ -61,6 +63,13 @@ export default function RootLayout({
> >
<SessionManager /> <SessionManager />
<QueryProvider>{children}</QueryProvider> <QueryProvider>{children}</QueryProvider>
<Toaster
richColors
position="top-right"
toastOptions={{
duration: 4000,
}}
/>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -1,9 +1,9 @@
/** /**
* @file components/theme-toggle.tsx * @file components/theme-toggle.tsx
* @description 라이트/다크/시스템 테마 전환 토글 버튼 * @description 라이트/다크 테마 즉시 전환 토글 버튼
* @remarks * @remarks
* - [레이어] Components/UI * - [레이어] Components/UI
* - [사용자 행동] 버튼 클릭 -> 드롭다운 메뉴 -> 테마 선택 -> 즉시 반영 * - [사용자 행동] 버튼 클릭 -> 라이트/다크 즉시 전환
* - [연관 파일] theme-provider.tsx (next-themes) * - [연관 파일] theme-provider.tsx (next-themes)
*/ */
@@ -12,48 +12,53 @@
import * as React from "react"; import * as React from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu, interface ThemeToggleProps {
DropdownMenuContent, className?: string;
DropdownMenuItem, iconClassName?: string;
DropdownMenuTrigger, }
} from "@/components/ui/dropdown-menu";
/** /**
* 테마 토글 컴포넌트 * 테마 토글 컴포넌트
* @remarks next-themes의 useTheme 훅 사용 * @remarks next-themes의 useTheme 훅 사용
* @returns Dropdown 메뉴 형태의 테마 선택기 * @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
*/ */
export function ThemeToggle() { export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
const { setTheme } = useTheme(); const { resolvedTheme, setTheme } = useTheme();
const handleToggleTheme = React.useCallback(() => {
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
setTheme(resolvedTheme === "dark" ? "light" : "dark");
}, [resolvedTheme, setTheme]);
return ( return (
<DropdownMenu modal={false}> <Button
{/* ========== 트리거 버튼 ========== */} type="button"
<DropdownMenuTrigger asChild> variant="ghost"
<Button variant="ghost" size="icon"> size="icon"
{/* 라이트 모드 아이콘 (회전 애니메이션) */} className={className}
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> onClick={handleToggleTheme}
{/* 다크 모드 아이콘 (회전 애니메이션) */} aria-label="테마 전환"
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> >
{/* ========== LIGHT ICON ========== */}
<Sun
className={cn(
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
iconClassName,
)}
/>
{/* ========== DARK ICON ========== */}
<Moon
className={cn(
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
iconClassName,
)}
/>
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger>
{/* ========== 메뉴 컨텐츠 (우측 정렬) ========== */}
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }

48
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator" import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"

View File

@@ -0,0 +1,244 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ShaderBackgroundProps {
className?: string;
opacity?: number;
}
const VS_SOURCE = `
attribute vec4 aVertexPosition;
void main() {
gl_Position = aVertexPosition;
}
`;
const FS_SOURCE = `
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
const float overallSpeed = 0.2;
const float gridSmoothWidth = 0.015;
const float axisWidth = 0.05;
const float majorLineWidth = 0.025;
const float minorLineWidth = 0.0125;
const float majorLineFrequency = 5.0;
const float minorLineFrequency = 1.0;
const vec4 gridColor = vec4(0.5);
const float scale = 5.0;
const vec4 lineColor = vec4(0.4, 0.2, 0.8, 1.0);
const float minLineWidth = 0.01;
const float maxLineWidth = 0.2;
const float lineSpeed = 1.0 * overallSpeed;
const float lineAmplitude = 1.0;
const float lineFrequency = 0.2;
const float warpSpeed = 0.2 * overallSpeed;
const float warpFrequency = 0.5;
const float warpAmplitude = 1.0;
const float offsetFrequency = 0.5;
const float offsetSpeed = 1.33 * overallSpeed;
const float minOffsetSpread = 0.6;
const float maxOffsetSpread = 2.0;
const int linesPerGroup = 16;
#define drawCircle(pos, radius, coord) smoothstep(radius + gridSmoothWidth, radius, length(coord - (pos)))
#define drawSmoothLine(pos, halfWidth, t) smoothstep(halfWidth, 0.0, abs(pos - (t)))
#define drawCrispLine(pos, halfWidth, t) smoothstep(halfWidth + gridSmoothWidth, halfWidth, abs(pos - (t)))
#define drawPeriodicLine(freq, width, t) drawCrispLine(freq / 2.0, width, abs(mod(t, freq) - (freq) / 2.0))
float drawGridLines(float axis) {
return drawCrispLine(0.0, axisWidth, axis)
+ drawPeriodicLine(majorLineFrequency, majorLineWidth, axis)
+ drawPeriodicLine(minorLineFrequency, minorLineWidth, axis);
}
float drawGrid(vec2 space) {
return min(1.0, drawGridLines(space.x) + drawGridLines(space.y));
}
float random(float t) {
return (cos(t) + cos(t * 1.3 + 1.3) + cos(t * 1.4 + 1.4)) / 3.0;
}
float getPlasmaY(float x, float horizontalFade, float offset) {
return random(x * lineFrequency + iTime * lineSpeed) * horizontalFade * lineAmplitude + offset;
}
void main() {
vec2 fragCoord = gl_FragCoord.xy;
vec4 fragColor;
vec2 uv = fragCoord.xy / iResolution.xy;
vec2 space = (fragCoord - iResolution.xy / 2.0) / iResolution.x * 2.0 * scale;
float horizontalFade = 1.0 - (cos(uv.x * 6.28) * 0.5 + 0.5);
float verticalFade = 1.0 - (cos(uv.y * 6.28) * 0.5 + 0.5);
space.y += random(space.x * warpFrequency + iTime * warpSpeed) * warpAmplitude * (0.5 + horizontalFade);
space.x += random(space.y * warpFrequency + iTime * warpSpeed + 2.0) * warpAmplitude * horizontalFade;
vec4 lines = vec4(0.0);
vec4 bgColor1 = vec4(0.1, 0.1, 0.3, 1.0);
vec4 bgColor2 = vec4(0.3, 0.1, 0.5, 1.0);
for(int l = 0; l < linesPerGroup; l++) {
float normalizedLineIndex = float(l) / float(linesPerGroup);
float offsetTime = iTime * offsetSpeed;
float offsetPosition = float(l) + space.x * offsetFrequency;
float rand = random(offsetPosition + offsetTime) * 0.5 + 0.5;
float halfWidth = mix(minLineWidth, maxLineWidth, rand * horizontalFade) / 2.0;
float offset = random(offsetPosition + offsetTime * (1.0 + normalizedLineIndex)) * mix(minOffsetSpread, maxOffsetSpread, horizontalFade);
float linePosition = getPlasmaY(space.x, horizontalFade, offset);
float line = drawSmoothLine(linePosition, halfWidth, space.y) / 2.0 + drawCrispLine(linePosition, halfWidth * 0.15, space.y);
float circleX = mod(float(l) + iTime * lineSpeed, 25.0) - 12.0;
vec2 circlePosition = vec2(circleX, getPlasmaY(circleX, horizontalFade, offset));
float circle = drawCircle(circlePosition, 0.01, space) * 4.0;
line = line + circle;
lines += line * lineColor * rand;
}
fragColor = mix(bgColor1, bgColor2, uv.x);
fragColor *= verticalFade;
fragColor.a = 1.0;
fragColor += lines;
gl_FragColor = fragColor;
}
`;
/**
* @description Compile one shader source.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - WebGL init flow
*/
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("Shader compile error:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* @description Create and link WebGL shader program.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - shader program setup
*/
function initShaderProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertexShader || !fragmentShader) return null;
const shaderProgram = gl.createProgram();
if (!shaderProgram) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error("Shader program link error:", gl.getProgramInfoLog(shaderProgram));
gl.deleteProgram(shaderProgram);
return null;
}
return shaderProgram;
}
/**
* @description Animated shader background canvas.
* @param className Tailwind class for canvas.
* @param opacity Canvas opacity.
* @see https://21st.dev/community/components/thanh/shader-background/default
*/
const ShaderBackground = ({ className, opacity = 0.9 }: ShaderBackgroundProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl");
if (!gl) {
console.warn("WebGL not supported.");
return;
}
const shaderProgram = initShaderProgram(gl, VS_SOURCE, FS_SOURCE);
if (!shaderProgram) return;
const positionBuffer = gl.createBuffer();
if (!positionBuffer) return;
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const vertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");
const resolution = gl.getUniformLocation(shaderProgram, "iResolution");
const time = gl.getUniformLocation(shaderProgram, "iTime");
const resizeCanvas = () => {
const dpr = window.devicePixelRatio || 1;
const nextWidth = Math.floor(window.innerWidth * dpr);
const nextHeight = Math.floor(window.innerHeight * dpr);
canvas.width = nextWidth;
canvas.height = nextHeight;
gl.viewport(0, 0, nextWidth, nextHeight);
};
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const startTime = Date.now();
let frameId = 0;
const render = () => {
const currentTime = (Date.now() - startTime) / 1000;
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(shaderProgram);
if (resolution) gl.uniform2f(resolution, canvas.width, canvas.height);
if (time) gl.uniform1f(time, currentTime);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vertexPosition);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameId = requestAnimationFrame(render);
};
frameId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener("resize", resizeCanvas);
gl.deleteBuffer(positionBuffer);
gl.deleteProgram(shaderProgram);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn("fixed inset-0 -z-10 h-full w-full", className)}
style={{ opacity }}
/>
);
};
export default ShaderBackground;

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

91
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -26,7 +26,6 @@ import { InlineSpinner } from "@/components/ui/loading-spinner";
*/ */
export default function LoginForm() { export default function LoginForm() {
// ========== 상태 관리 ========== // ========== 상태 관리 ==========
// 서버와 클라이언트 초기 렌더링을 일치시키기 위해 초기값은 고정
const [email, setEmail] = useState(() => { const [email, setEmail] = useState(() => {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
return localStorage.getItem("auto-trade-saved-email") || ""; return localStorage.getItem("auto-trade-saved-email") || "";
@@ -37,11 +36,6 @@ export default function LoginForm() {
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// ========== 마운트 시 localStorage 데이터 복구 ==========
// localStorage는 클라이언트 전용 외부 시스템이므로 useEffect에서 동기화하는 것이 올바른 패턴
// React 공식 문서: "외부 시스템과 동기화"는 useEffect의 정확한 사용 사례
// useState lazy initializer + window guard handles localStorage safely
// ========== 폼 제출 핸들러 ========== // ========== 폼 제출 핸들러 ==========
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -83,7 +77,7 @@ export default function LoginForm() {
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
</div> </div>
@@ -102,7 +96,7 @@ export default function LoginForm() {
minLength={8} minLength={8}
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$" pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다." title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
</div> </div>
@@ -121,10 +115,9 @@ export default function LoginForm() {
</Label> </Label>
</div> </div>
{/* 비밀번호 찾기 링크 */}
<Link <Link
href={AUTH_ROUTES.FORGOT_PASSWORD} href={AUTH_ROUTES.FORGOT_PASSWORD}
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white" className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>
@@ -134,7 +127,7 @@ export default function LoginForm() {
<Button <Button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white" className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
size="lg" size="lg"
> >
{isLoading ? ( {isLoading ? (
@@ -148,11 +141,11 @@ export default function LoginForm() {
</Button> </Button>
{/* ========== 회원가입 링크 ========== */} {/* ========== 회원가입 링크 ========== */}
<p className="text-center text-sm text-gray-600 dark:text-gray-400"> <p className="text-center text-sm text-muted-foreground">
?{" "} ?{" "}
<Link <Link
href={AUTH_ROUTES.SIGNUP} href={AUTH_ROUTES.SIGNUP}
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white" className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>
@@ -162,7 +155,7 @@ export default function LoginForm() {
{/* ========== 소셜 로그인 구분선 ========== */} {/* ========== 소셜 로그인 구분선 ========== */}
<div className="relative"> <div className="relative">
<Separator className="my-6" /> <Separator className="my-6" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-gray-500 dark:bg-gray-900 dark:text-gray-400"> <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-muted-foreground dark:bg-brand-950">
</span> </span>
</div> </div>
@@ -174,7 +167,7 @@ export default function LoginForm() {
<Button <Button
type="submit" type="submit"
variant="outline" variant="outline"
className="h-11 w-full border-gray-200 bg-white shadow-sm transition-all duration-200 hover:bg-gray-50 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750" className="h-11 w-full border-brand-200/50 bg-white shadow-sm transition-all duration-200 hover:bg-brand-50 hover:shadow-md dark:border-brand-800/50 dark:bg-brand-950/50 dark:hover:bg-brand-900/50"
> >
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24"> <svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path <path

View File

@@ -79,9 +79,9 @@ export default function ResetPasswordForm() {
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
{...register("password")} {...register("password")}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-muted-foreground">
8 , /// 1 . 8 , /// 1 .
</p> </p>
{errors.password && ( {errors.password && (
@@ -102,7 +102,7 @@ export default function ResetPasswordForm() {
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
{...register("confirmPassword")} {...register("confirmPassword")}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
{confirmPassword && {confirmPassword &&
password !== confirmPassword && password !== confirmPassword &&
@@ -114,7 +114,7 @@ export default function ResetPasswordForm() {
{confirmPassword && {confirmPassword &&
password === confirmPassword && password === confirmPassword &&
!errors.confirmPassword && ( !errors.confirmPassword && (
<p className="text-xs text-green-600 dark:text-green-400"> <p className="text-xs text-brand-600 dark:text-brand-400">
. .
</p> </p>
)} )}
@@ -128,7 +128,7 @@ export default function ResetPasswordForm() {
<Button <Button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white" className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
> >
{isLoading ? ( {isLoading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View File

@@ -13,6 +13,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants"; import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
import { cn } from "@/lib/utils";
/** /**
* 세션 만료 타이머 컴포넌트 * 세션 만료 타이머 컴포넌트
@@ -21,7 +22,12 @@ import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
* @remarks 1초마다 리렌더링 발생 * @remarks 1초마다 리렌더링 발생
* @see header.tsx - 로그인 상태일 때 헤더에 표시 * @see header.tsx - 로그인 상태일 때 헤더에 표시
*/ */
export function SessionTimer() { interface SessionTimerProps {
/** 셰이더 배경 위에서 가독성을 높이는 투명 모드 */
blendWithBackground?: boolean;
}
export function SessionTimer({ blendWithBackground = false }: SessionTimerProps) {
const lastActive = useSessionStore((state) => state.lastActive); const lastActive = useSessionStore((state) => state.lastActive);
// [State] 남은 시간 (밀리초) // [State] 남은 시간 (밀리초)
@@ -54,11 +60,14 @@ export function SessionTimer() {
return ( return (
<div <div
className={`text-sm font-medium tabular-nums px-3 py-1.5 rounded-md border bg-background/50 backdrop-blur-md hidden md:block transition-colors ${ className={cn(
"hidden rounded-full border px-3 py-1.5 text-sm font-medium tabular-nums backdrop-blur-md transition-colors md:block",
isUrgent isUrgent
? "text-red-500 border-red-200 bg-red-50/50 dark:bg-red-900/20 dark:border-red-800" ? "border-red-200 bg-red-50/50 text-red-500 dark:border-red-800 dark:bg-red-900/20"
: "text-muted-foreground border-border/40" : blendWithBackground
}`} ? "border-white/30 bg-black/45 text-white shadow-sm shadow-black/40"
: "border-border/40 bg-background/50 text-muted-foreground",
)}
> >
{/* ========== 라벨 ========== */} {/* ========== 라벨 ========== */}
<span className="mr-2"> </span> <span className="mr-2"> </span>

View File

@@ -84,7 +84,7 @@ export default function SignupForm() {
autoComplete="email" autoComplete="email"
disabled={isLoading} disabled={isLoading}
{...register("email")} {...register("email")}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
{errors.email && ( {errors.email && (
<p className="text-xs text-red-600 dark:text-red-400"> <p className="text-xs text-red-600 dark:text-red-400">
@@ -105,9 +105,9 @@ export default function SignupForm() {
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
{...register("password")} {...register("password")}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-muted-foreground">
8 , , , , 8 , , , ,
</p> </p>
{errors.password && ( {errors.password && (
@@ -129,7 +129,7 @@ export default function SignupForm() {
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
{...register("confirmPassword")} {...register("confirmPassword")}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
{/* 비밀번호 불일치 시 실시간 피드백 */} {/* 비밀번호 불일치 시 실시간 피드백 */}
{confirmPassword && {confirmPassword &&
@@ -143,7 +143,7 @@ export default function SignupForm() {
{confirmPassword && {confirmPassword &&
password === confirmPassword && password === confirmPassword &&
!errors.confirmPassword && ( !errors.confirmPassword && (
<p className="text-xs text-green-600 dark:text-green-400"> <p className="text-xs text-brand-600 dark:text-brand-400">
</p> </p>
)} )}
@@ -159,7 +159,7 @@ export default function SignupForm() {
<Button <Button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="h-11 w-full bg-gradient-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white" className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
> >
{isLoading ? ( {isLoading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View File

@@ -0,0 +1,82 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardKisRevokeResponse,
DashboardKisValidateResponse,
DashboardKisWsApprovalResponse,
} from "@/features/dashboard/types/dashboard.types";
interface KisApiBaseResponse {
ok: boolean;
message: string;
}
async function postKisAuthApi<T extends KisApiBaseResponse>(
endpoint: string,
credentials: KisRuntimeCredentials,
fallbackErrorMessage: string,
): Promise<T> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as T;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || fallbackErrorMessage);
}
return payload;
}
/**
* @description KIS API 키를 검증합니다.
* @see app/api/kis/validate/route.ts
*/
export async function validateKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisValidateResponse> {
return postKisAuthApi<DashboardKisValidateResponse>(
"/api/kis/validate",
credentials,
"API 키 검증에 실패했습니다.",
);
}
/**
* @description KIS 액세스 토큰을 폐기합니다.
* @see app/api/kis/revoke/route.ts
*/
export async function revokeKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisRevokeResponse> {
return postKisAuthApi<DashboardKisRevokeResponse>(
"/api/kis/revoke",
credentials,
"API 토큰 폐기에 실패했습니다.",
);
}
/**
* @description 웹소켓 승인키와 WS URL을 조회합니다.
* @see app/api/kis/ws/approval/route.ts
*/
export async function fetchKisWebSocketApproval(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisWsApprovalResponse> {
const payload = await postKisAuthApi<DashboardKisWsApprovalResponse>(
"/api/kis/ws/approval",
credentials,
"웹소켓 승인키 발급에 실패했습니다.",
);
if (!payload.approvalKey || !payload.wsUrl) {
throw new Error(payload.message || "웹소켓 연결 정보가 누락되었습니다.");
}
return payload;
}

View File

@@ -0,0 +1,202 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
DashboardStockChartResponse,
DashboardStockOrderBookResponse,
DashboardStockOverviewResponse,
DashboardStockSearchResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* 종목 검색 API 호출
* @param keyword 검색어
*/
export async function fetchStockSearch(
keyword: string,
signal?: AbortSignal,
): Promise<DashboardStockSearchResponse> {
const response = await fetch(
`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`,
{
cache: "no-store",
signal,
},
);
const payload = (await response.json()) as
| DashboardStockSearchResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockSearchResponse;
}
/**
* 종목 상세 개요 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOverview(
symbol: string,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockOverviewResponse> {
const response = await fetch(
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: buildKisRequestHeaders(credentials),
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;
}
/**
* 종목 호가 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOrderBook(
symbol: string,
credentials: KisRuntimeCredentials,
signal?: AbortSignal,
): Promise<DashboardStockOrderBookResponse> {
const response = await fetch(
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: buildKisRequestHeaders(credentials),
cache: "no-store",
signal,
},
);
const payload = (await response.json()) as
| DashboardStockOrderBookResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockOrderBookResponse;
}
/**
* 종목 차트(분봉/일봉/주봉) 조회 API 호출
*/
export async function fetchStockChart(
symbol: string,
timeframe: DashboardChartTimeframe,
credentials: KisRuntimeCredentials,
cursor?: string,
): Promise<DashboardStockChartResponse> {
const query = new URLSearchParams({
symbol,
timeframe,
});
if (cursor) query.set("cursor", cursor);
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
method: "GET",
headers: buildKisRequestHeaders(credentials),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardStockChartResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockChartResponse;
}
/**
* 주식 현금 주문 API 호출
* @param request 주문 요청 데이터
* @param credentials KIS 인증 정보
*/
export async function fetchOrderCash(
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockCashOrderResponse> {
const response = await fetch("/api/kis/domestic/order-cash", {
method: "POST",
headers: buildKisRequestHeaders(credentials, { jsonContentType: true }),
body: JSON.stringify(request),
cache: "no-store",
});
const payload = (await response.json()) as DashboardStockCashOrderResponse;
if (!response.ok) {
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
}
return payload;
}
function buildKisRequestHeaders(
credentials: KisRuntimeCredentials,
options?: { jsonContentType?: boolean },
) {
const headers: Record<string, string> = {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
};
if (options?.jsonContentType) {
headers["content-type"] = "application/json";
}
const sessionOverride = readSessionOverrideForDev();
if (sessionOverride) {
headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride;
}
return headers;
}
function readSessionOverrideForDev() {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return parseDomesticKisSession(raw);
} catch {
return null;
}
}

View File

@@ -0,0 +1,423 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
import { StockSearchHistory } from "@/features/dashboard/components/search/StockSearchHistory";
import { StockSearchResults } from "@/features/dashboard/components/search/StockSearchResults";
import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
import { useCurrentPrice } from "@/features/dashboard/hooks/useCurrentPrice";
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
import { OrderForm } from "@/features/dashboard/components/order/OrderForm";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import type {
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
} from "@/features/dashboard/types/dashboard.types";
/**
* @description 대시보드 메인 컨테이너
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청/히스토리 상태를 관리합니다.
* @see features/dashboard/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function DashboardContainer() {
const skipNextAutoSearchRef = useRef(false);
const hasInitializedAuthPanelRef = useRef(false);
const searchShellRef = useRef<HTMLDivElement | null>(null);
// 모바일에서는 초기 진입 시 API 패널을 접어 본문(차트/호가)을 먼저 보이게 합니다.
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchError,
isSearching,
search,
clearSearch,
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const handleOrderBookMessage = useCallback(
(data: DashboardStockOrderBookResponse) => {
setRealtimeOrderBook(data);
},
[],
);
// 1. Trade WebSocket (체결 + 호가 통합)
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
selectedStock?.symbol,
verifiedCredentials,
isKisVerified,
updateRealtimeTradeTick,
{
orderBookSymbol: selectedStock?.symbol,
onOrderBookMessage: handleOrderBookMessage,
},
);
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
selectedStock?.symbol,
selectedStock?.market,
verifiedCredentials,
isKisVerified,
{
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
externalRealtimeOrderBook: realtimeOrderBook,
},
);
// 3. Price Calculation Logic (Hook)
const {
currentPrice,
change,
changeRate,
prevClose: referencePrice,
} = useCurrentPrice({
stock: selectedStock,
latestTick,
orderBook,
});
const canSearch = isKisVerified && !!verifiedCredentials;
/**
* @description 검색 전 API 인증 여부를 확인합니다.
* @see features/dashboard/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
*/
const ensureSearchReady = useCallback(() => {
if (canSearch) return true;
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return false;
}, [canSearch, setSearchError]);
const closeSearchPanel = useCallback(() => {
setIsSearchPanelOpen(false);
}, []);
const openSearchPanel = useCallback(() => {
if (!canSearch) return;
setIsSearchPanelOpen(true);
}, [canSearch]);
/**
* @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다.
* @see features/dashboard/components/search/StockSearchForm.tsx 입력 포커스 이벤트에서 열림 제어를 함께 사용합니다.
*/
const handleSearchShellBlur = useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
closeSearchPanel();
},
[closeSearchPanel],
);
const handleSearchShellKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape") return;
closeSearchPanel();
(event.target as HTMLElement | null)?.blur?.();
},
[closeSearchPanel],
);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 767px)");
const applyViewportMode = (matches: boolean) => {
setIsMobileViewport(matches);
// 최초 1회: 모바일이면 접힘, 데스크탑이면 펼침.
if (!hasInitializedAuthPanelRef.current) {
setIsAuthPanelExpanded(!matches);
hasInitializedAuthPanelRef.current = true;
return;
}
// 데스크탑으로 돌아오면 항상 펼쳐 사용성을 유지합니다.
if (!matches) {
setIsAuthPanelExpanded(true);
}
};
applyViewportMode(mediaQuery.matches);
const onViewportChange = (event: MediaQueryListEvent) => {
applyViewportMode(event.matches);
};
mediaQuery.addEventListener("change", onViewportChange);
return () => mediaQuery.removeEventListener("change", onViewportChange);
}, []);
useEffect(() => {
if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false;
return;
}
if (!canSearch) {
clearSearch();
return;
}
const trimmed = keyword.trim();
if (!trimmed) {
clearSearch();
return;
}
const timer = window.setTimeout(() => {
search(trimmed, verifiedCredentials);
}, 220);
return () => window.clearTimeout(timer);
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
/**
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
* @see features/dashboard/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
*/
const handleSearchSubmit = useCallback(
(event: React.FormEvent) => {
event.preventDefault();
if (!ensureSearchReady() || !verifiedCredentials) return;
search(keyword, verifiedCredentials);
},
[ensureSearchReady, keyword, search, verifiedCredentials],
);
/**
* @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다.
* @see features/dashboard/components/search/StockSearchResults.tsx onSelect 이벤트
* @see features/dashboard/components/search/StockSearchHistory.tsx onSelect 이벤트
*/
const handleSelectStock = useCallback(
(item: DashboardStockSearchItem) => {
if (!ensureSearchReady() || !verifiedCredentials) return;
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
if (selectedStock?.symbol === item.symbol) {
clearSearch();
closeSearchPanel();
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
skipNextAutoSearchRef.current = true;
setKeyword(item.name);
clearSearch();
closeSearchPanel();
appendSearchHistory(item);
loadOverview(item.symbol, verifiedCredentials, item.market);
},
[
ensureSearchReady,
verifiedCredentials,
selectedStock?.symbol,
clearSearch,
closeSearchPanel,
setKeyword,
appendSearchHistory,
loadOverview,
],
);
return (
<div className="relative h-full flex flex-col">
{/* ========== AUTH STATUS ========== */}
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out dark:border-brand-800/45 dark:bg-brand-900/28">
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs sm:px-4">
<div className="flex items-center gap-2">
<span className="font-semibold">KIS API :</span>
{isKisVerified ? (
<span className="flex items-center font-medium text-brand-700 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-brand-100 dark:ring-brand-900" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-muted-foreground flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-200 dark:bg-brand-500/60" />
</span>
)}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
className={cn(
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-700/60 dark:bg-brand-900/30 dark:text-brand-200 dark:hover:bg-brand-900/45",
!isAuthPanelExpanded &&
isMobileViewport &&
"ring-2 ring-brand-200 dark:ring-brand-600/60",
)}
>
{isAuthPanelExpanded ? (
<>
<ChevronUp className="h-3.5 w-3.5" />
API
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" />
API
</>
)}
</Button>
</div>
<div
className={cn(
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
isAuthPanelExpanded ? "max-h-[560px] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="border-t bg-background p-4 dark:border-brand-800/45 dark:bg-brand-900/14">
<KisAuthForm />
</div>
</div>
</div>
{/* ========== SEARCH ========== */}
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
<div
ref={searchShellRef}
onBlurCapture={handleSearchShellBlur}
onKeyDownCapture={handleSearchShellKeyDown}
className="relative mx-auto max-w-2xl"
>
<StockSearchForm
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={handleSearchSubmit}
onInputFocus={openSearchPanel}
disabled={!canSearch}
isLoading={isSearching}
/>
{isSearchPanelOpen && canSearch && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
{searchResults.length > 0 ? (
<StockSearchResults
items={searchResults}
onSelect={handleSelectStock}
selectedSymbol={selectedStock?.symbol}
/>
) : keyword.trim() ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
</div>
) : searchHistory.length > 0 ? (
<StockSearchHistory
items={searchHistory}
onSelect={handleSelectStock}
onRemove={removeSearchHistory}
onClear={clearSearchHistory}
selectedSymbol={selectedStock?.symbol}
/>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
.
</div>
)}
</div>
)}
</div>
</div>
{/* ========== MAIN CONTENT ========== */}
<div
className={cn(
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
!selectedStock && "opacity-20 pointer-events-none",
)}
>
<DashboardLayout
header={
selectedStock ? (
<StockHeader
stock={selectedStock}
price={currentPrice?.toLocaleString() ?? "0"}
change={change?.toLocaleString() ?? "0"}
changeRate={changeRate?.toFixed(2) ?? "0.00"}
high={latestTick ? latestTick.high.toLocaleString() : undefined}
low={latestTick ? latestTick.low.toLocaleString() : undefined}
volume={
latestTick
? latestTick.accumulatedVolume.toLocaleString()
: undefined
}
/>
) : null
}
chart={
selectedStock ? (
<div className="p-0 h-full flex flex-col">
<StockLineChart
symbol={selectedStock.symbol}
candles={selectedStock.candles}
credentials={verifiedCredentials}
latestTick={latestTick}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={referencePrice}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,314 @@
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import {
revokeKisCredentials,
validateKisCredentials,
} from "@/features/dashboard/apis/kis-auth.api";
import {
KeyRound,
Shield,
CheckCircle2,
XCircle,
Lock,
Sparkles,
Zap,
Activity,
} from "lucide-react";
import { InlineSpinner } from "@/components/ui/loading-spinner";
/**
* @description KIS 인증 입력 폼 (Minimal Redesign v4)
* - User Feedback: "입력창이 너무 길어", "파란색/하늘색 제거해"
* - Compact Width: max-w-lg + mx-auto
* - Monochrome Mock Mode: Blue -> Zinc/Gray
*/
export function KisAuthForm() {
const {
kisTradingEnvInput,
kisAppKeyInput,
kisAppSecretInput,
verifiedCredentials,
isKisVerified,
setKisTradingEnvInput,
setKisAppKeyInput,
setKisAppSecretInput,
setVerifiedKisSession,
invalidateKisVerification,
clearKisRuntimeSession,
} = useKisRuntimeStore(
useShallow((state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
setKisTradingEnvInput: state.setKisTradingEnvInput,
setKisAppKeyInput: state.setKisAppKeyInput,
setKisAppSecretInput: state.setKisAppSecretInput,
setVerifiedKisSession: state.setVerifiedKisSession,
invalidateKisVerification: state.invalidateKisVerification,
clearKisRuntimeSession: state.clearKisRuntimeSession,
})),
);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isValidating, startValidateTransition] = useTransition();
const [isRevoking, startRevokeTransition] = useTransition();
// 입력 필드 Focus 상태 관리를 위한 State
const [focusedField, setFocusedField] = useState<
"appKey" | "appSecret" | null
>(null);
function handleValidate() {
startValidateTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const appKey = kisAppKeyInput.trim();
const appSecret = kisAppSecretInput.trim();
if (!appKey || !appSecret) {
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
}
const credentials = {
appKey,
appSecret,
tradingEnv: kisTradingEnvInput,
accountNo: verifiedCredentials?.accountNo ?? "",
};
const result = await validateKisCredentials(credentials);
setVerifiedKisSession(credentials, result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
invalidateKisVerification();
setErrorMessage(
err instanceof Error
? err.message
: "API 키 검증 중 오류가 발생했습니다.",
);
}
});
}
function handleRevoke() {
if (!verifiedCredentials) return;
startRevokeTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const result = await revokeKisCredentials(verifiedCredentials);
clearKisRuntimeSession(result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
setErrorMessage(
err instanceof Error
? err.message
: "연결 해제 중 오류가 발생했습니다.",
);
}
});
}
return (
<div className="group relative mx-auto w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition-all hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10">
{/* Inner Content Container - Compact spacing */}
<div className="flex flex-col gap-4">
{/* Header Section */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
<KeyRound className="h-4.5 w-4.5" />
</div>
<div>
<h2 className="flex items-center gap-2 text-base font-bold tracking-tight text-zinc-800 dark:text-zinc-100">
KIS API Key Connection
{isKisVerified && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-1.5 py-0.5 text-[10px] font-medium text-green-600 ring-1 ring-green-600/10 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
Connected
</span>
)}
</h2>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
API .
</p>
</div>
</div>
{/* Trading Mode Switch (Segmented Control - Compact) */}
<div className="flex shrink-0 items-center rounded-md bg-zinc-100 p-0.5 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700">
<button
onClick={() => setKisTradingEnvInput("real")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "real"
? "bg-white text-brand-600 shadow-sm ring-1 ring-black/5 dark:bg-brand-500 dark:text-white dark:ring-brand-400"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Zap
className={cn(
"h-3 w-3",
kisTradingEnvInput === "real"
? "text-brand-500 dark:text-white"
: "text-zinc-400",
)}
/>
</button>
<button
onClick={() => setKisTradingEnvInput("mock")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "mock"
? "bg-white text-zinc-800 shadow-sm ring-1 ring-black/5 dark:bg-zinc-600 dark:text-white dark:ring-zinc-500"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Activity
className={cn(
"h-3 w-3",
kisTradingEnvInput === "mock"
? "text-zinc-800 dark:text-zinc-200"
: "text-zinc-400",
)}
/>
</button>
</div>
</div>
{/* Input Fields Section (Compact Stacked Layout) */}
<div className="flex flex-col gap-2">
{/* App Key Input */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appKey"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Shield className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
App Key
</div>
<Input
type="password"
value={kisAppKeyInput}
onChange={(e) => setKisAppKeyInput(e.target.value)}
onFocus={() => setFocusedField("appKey")}
onBlur={() => setFocusedField(null)}
placeholder="App Key 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
{/* App Secret Input */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appSecret"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Lock className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
Secret
</div>
<Input
type="password"
value={kisAppSecretInput}
onChange={(e) => setKisAppSecretInput(e.target.value)}
onFocus={() => setFocusedField("appSecret")}
onBlur={() => setFocusedField(null)}
placeholder="App Secret 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
</div>
{/* Action & Status Section */}
<div className="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800/50">
<div className="flex gap-2">
<Button
onClick={handleValidate}
disabled={
isValidating ||
!kisAppKeyInput.trim() ||
!kisAppSecretInput.trim()
}
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
>
{isValidating ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="text-white h-3 w-3" />
</span>
) : (
<span className="flex items-center gap-1.5">
<Sparkles className="h-3 w-3 fill-brand-200 text-brand-200" />
API
</span>
)}
</Button>
{isKisVerified && (
<Button
variant="outline"
onClick={handleRevoke}
disabled={isRevoking}
className="h-9 rounded-lg border-zinc-200 bg-white text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
{isRevoking ? "해제 중" : "연결 해제"}
</Button>
)}
</div>
{/* Status Messages - Compact */}
<div className="flex-1 text-right">
{errorMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-red-500">
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-brand-600 dark:text-brand-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!errorMessage && !statusMessage && !isKisVerified && (
<p className="flex justify-end gap-1.5 text-[11px] text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 translate-y-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</p>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,692 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CandlestickSeries,
ColorType,
HistogramSeries,
createChart,
type IChartApi,
type ISeriesApi,
type Time,
} from "lightweight-charts";
import { ChevronDown } from "lucide-react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
import {
type ChartBar,
formatKstCrosshairTime,
formatKstTickMark,
formatPrice,
formatSignedPercent,
isMinuteTimeframe,
mergeBars,
normalizeCandles,
toRealtimeTickBar,
upsertRealtimeBar,
} from "./chart-utils";
const UP_COLOR = "#ef4444";
const MINUTE_SYNC_INTERVAL_MS = 5000;
const REALTIME_STALE_THRESHOLD_MS = 12000;
interface ChartPalette {
backgroundColor: string;
downColor: string;
volumeDownColor: string;
textColor: string;
borderColor: string;
gridColor: string;
crosshairColor: string;
}
const DEFAULT_CHART_PALETTE: ChartPalette = {
backgroundColor: "#ffffff",
downColor: "#2563eb",
volumeDownColor: "rgba(37, 99, 235, 0.45)",
textColor: "#6d28d9",
borderColor: "#e9d5ff",
gridColor: "#f3e8ff",
crosshairColor: "#c084fc",
};
function readCssVar(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
const isDark = themeMode === "dark";
const backgroundVar = isDark
? "--brand-chart-background-dark"
: "--brand-chart-background-light";
const textVar = isDark ? "--brand-chart-text-dark" : "--brand-chart-text-light";
const borderVar = isDark ? "--brand-chart-border-dark" : "--brand-chart-border-light";
const gridVar = isDark ? "--brand-chart-grid-dark" : "--brand-chart-grid-light";
const crosshairVar = isDark
? "--brand-chart-crosshair-dark"
: "--brand-chart-crosshair-light";
return {
backgroundColor: readCssVar(backgroundVar, DEFAULT_CHART_PALETTE.backgroundColor),
downColor: readCssVar("--brand-chart-down", DEFAULT_CHART_PALETTE.downColor),
volumeDownColor: readCssVar(
"--brand-chart-volume-down",
DEFAULT_CHART_PALETTE.volumeDownColor,
),
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
crosshairColor: readCssVar(
crosshairVar,
DEFAULT_CHART_PALETTE.crosshairColor,
),
};
}
const MINUTE_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
}> = [
{ value: "1m", label: "1분" },
{ value: "30m", label: "30분" },
{ value: "1h", label: "1시간" },
];
const PERIOD_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
}> = [
{ value: "1d", label: "일" },
{ value: "1w", label: "주" },
];
interface StockLineChartProps {
symbol?: string;
candles: StockCandlePoint[];
credentials?: KisRuntimeCredentials | null;
latestTick?: DashboardRealtimeTradeTick | null;
}
/**
* @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다.
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
export function StockLineChart({
symbol,
candles,
credentials,
latestTick,
}: StockLineChartProps) {
const { resolvedTheme } = useTheme();
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
const [bars, setBars] = useState<ChartBar[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isChartReady, setIsChartReady] = useState(false);
const lastRealtimeKeyRef = useRef<string>("");
const lastRealtimeAppliedAtRef = useRef(0);
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
const renderableBarsRef = useRef<ChartBar[]>([]);
const activeThemeMode: "light" | "dark" =
resolvedTheme === "dark"
? "dark"
: resolvedTheme === "light"
? "light"
: typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
? "dark"
: "light";
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
// API 오류 시 fallback 용도로 유지
const latestCandlesRef = useRef(candles);
useEffect(() => {
latestCandlesRef.current = candles;
}, [candles]);
const latest = bars.at(-1);
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
const change = latest ? latest.close - prevClose : 0;
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
const renderableBars = useMemo(() => {
const dedup = new Map<number, ChartBar>();
for (const bar of bars) {
if (
!Number.isFinite(bar.time) ||
!Number.isFinite(bar.open) ||
!Number.isFinite(bar.high) ||
!Number.isFinite(bar.low) ||
!Number.isFinite(bar.close) ||
bar.close <= 0
) {
continue;
}
dedup.set(bar.time, bar);
}
return [...dedup.values()].sort((a, b) => a.time - b.time);
}, [bars]);
useEffect(() => {
renderableBarsRef.current = renderableBars;
}, [renderableBars]);
/**
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
*/
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
if (!candleSeries || !volumeSeries) return;
try {
candleSeries.setData(
nextBars.map((bar) => ({
time: bar.time,
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
})),
);
volumeSeries.setData(
nextBars.map((bar) => ({
time: bar.time,
value: Number.isFinite(bar.volume) ? bar.volume : 0,
color:
bar.close >= bar.open
? "rgba(239,68,68,0.45)"
: chartPaletteRef.current.volumeDownColor,
})),
);
} catch (error) {
console.error("Failed to render chart series data:", error);
}
}, []);
/**
* @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다.
* @see lib/kis/domestic.ts getDomesticChart cursor
*/
const handleLoadMore = useCallback(async () => {
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
loadingMoreRef.current = true;
setIsLoadingMore(true);
try {
const response = await fetchStockChart(
symbol,
timeframe,
credentials,
nextCursor,
);
const olderBars = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(olderBars, prev));
setNextCursor(response.hasMore ? response.nextCursor : null);
} catch (error) {
const message =
error instanceof Error
? error.message
: "과거 차트 데이터를 불러오지 못했습니다.";
toast.error(message);
} finally {
loadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [credentials, nextCursor, symbol, timeframe]);
useEffect(() => {
loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]);
useEffect(() => {
lastRealtimeKeyRef.current = "";
lastRealtimeAppliedAtRef.current = 0;
}, [symbol, timeframe]);
useEffect(() => {
const container = containerRef.current;
if (!container || chartRef.current) return;
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
const palette = getChartPaletteFromCssVars(activeThemeMode);
chartPaletteRef.current = palette;
const chart = createChart(container, {
width: Math.max(container.clientWidth, 320),
height: Math.max(container.clientHeight, 340),
layout: {
background: { type: ColorType.Solid, color: palette.backgroundColor },
textColor: palette.textColor,
attributionLogo: true,
},
localization: {
locale: "ko-KR",
timeFormatter: formatKstCrosshairTime,
},
rightPriceScale: {
borderColor: palette.borderColor,
scaleMargins: {
top: 0.08,
bottom: 0.24,
},
},
grid: {
vertLines: { color: palette.gridColor },
horzLines: { color: palette.gridColor },
},
crosshair: {
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
},
timeScale: {
borderColor: palette.borderColor,
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
tickMarkFormatter: formatKstTickMark,
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: true,
},
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: UP_COLOR,
downColor: palette.downColor,
wickUpColor: UP_COLOR,
wickDownColor: palette.downColor,
borderUpColor: UP_COLOR,
borderDownColor: palette.downColor,
priceLineVisible: true,
lastValueVisible: true,
});
const volumeSeries = chart.addSeries(HistogramSeries, {
priceScaleId: "volume",
priceLineVisible: false,
lastValueVisible: false,
base: 0,
});
chart.priceScale("volume").applyOptions({
scaleMargins: {
top: 0.78,
bottom: 0,
},
borderVisible: false,
});
let scrollTimeout: number | undefined;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
if (!range || !initialLoadCompleteRef.current) return;
if (range.from >= 10) return;
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
void loadMoreHandlerRef.current();
}, 250);
});
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
volumeSeriesRef.current = volumeSeries;
setIsChartReady(true);
const resizeObserver = new ResizeObserver(() => {
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
);
});
resizeObserver.observe(container);
const rafId = window.requestAnimationFrame(() => {
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
);
});
return () => {
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
chartRef.current = null;
candleSeriesRef.current = null;
volumeSeriesRef.current = null;
setIsChartReady(false);
};
}, [activeThemeMode]);
useEffect(() => {
const chart = chartRef.current;
const candleSeries = candleSeriesRef.current;
if (!chart || !candleSeries) return;
const palette = getChartPaletteFromCssVars(activeThemeMode);
chartPaletteRef.current = palette;
chart.applyOptions({
layout: {
background: { type: ColorType.Solid, color: palette.backgroundColor },
textColor: palette.textColor,
},
rightPriceScale: { borderColor: palette.borderColor },
grid: {
vertLines: { color: palette.gridColor },
horzLines: { color: palette.gridColor },
},
crosshair: {
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
},
timeScale: { borderColor: palette.borderColor },
});
candleSeries.applyOptions({
downColor: palette.downColor,
wickDownColor: palette.downColor,
borderDownColor: palette.downColor,
});
setSeriesData(renderableBarsRef.current);
}, [activeThemeMode, setSeriesData]);
useEffect(() => {
if (symbol && credentials) return;
// 인증 전/종목 미선택 상태는 overview 캔들로 fallback
setBars(normalizeCandles(candles, "1d"));
setNextCursor(null);
}, [candles, credentials, symbol]);
useEffect(() => {
if (!symbol || !credentials) return;
initialLoadCompleteRef.current = false;
let disposed = false;
const load = async () => {
setIsLoading(true);
try {
const firstPage = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
if (
isMinuteTimeframe(timeframe) &&
firstPage.hasMore &&
firstPage.nextCursor
) {
let minuteCursor: string | null = firstPage.nextCursor;
let extraPageCount = 0;
while (minuteCursor && extraPageCount < 2) {
try {
const olderPage = await fetchStockChart(
symbol,
timeframe,
credentials,
minuteCursor,
);
const olderBars = normalizeCandles(olderPage.candles, timeframe);
mergedBars = mergeBars(olderBars, mergedBars);
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
extraPageCount += 1;
} catch {
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
minuteCursor = null;
}
}
}
setBars(mergedBars);
setNextCursor(resolvedNextCursor);
window.setTimeout(() => {
if (!disposed) initialLoadCompleteRef.current = true;
}, 350);
} catch (error) {
if (disposed) return;
const message =
error instanceof Error
? error.message
: "차트 조회 중 오류가 발생했습니다.";
toast.error(message);
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
setNextCursor(null);
} finally {
if (!disposed) setIsLoading(false);
}
};
void load();
return () => {
disposed = true;
};
}, [credentials, symbol, timeframe]);
useEffect(() => {
if (!isChartReady) return;
setSeriesData(renderableBars);
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
chartRef.current?.timeScale().fitContent();
}
}, [isChartReady, renderableBars, setSeriesData]);
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
* @see features/dashboard/components/chart/chart-utils.ts toRealtimeTickBar
*/
useEffect(() => {
if (!latestTick) return;
if (bars.length === 0) return;
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
if (lastRealtimeKeyRef.current === dedupeKey) return;
const realtimeBar = toRealtimeTickBar(latestTick, timeframe);
if (!realtimeBar) return;
lastRealtimeKeyRef.current = dedupeKey;
lastRealtimeAppliedAtRef.current = Date.now();
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
}, [bars.length, latestTick, timeframe]);
/**
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
useEffect(() => {
if (!symbol || !credentials) return;
if (!isMinuteTimeframe(timeframe)) return;
let disposed = false;
const syncLatestMinuteBars = async () => {
const now = Date.now();
const isRealtimeFresh =
now - lastRealtimeAppliedAtRef.current < REALTIME_STALE_THRESHOLD_MS;
if (isRealtimeFresh) return;
try {
const response = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
const latestPageBars = normalizeCandles(response.candles, timeframe);
const recentBars = latestPageBars.slice(-10);
if (recentBars.length === 0) return;
setBars((prev) => mergeBars(prev, recentBars));
} catch {
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
}
};
const intervalId = window.setInterval(() => {
void syncLatestMinuteBars();
}, MINUTE_SYNC_INTERVAL_MS);
return () => {
disposed = true;
window.clearInterval(intervalId);
};
}, [credentials, symbol, timeframe]);
const statusMessage = (() => {
if (isLoading && bars.length === 0) {
return "차트 데이터를 불러오는 중입니다.";
}
if (bars.length === 0) {
return "차트 데이터가 없습니다.";
}
if (renderableBars.length === 0) {
return "차트 데이터 형식이 올바르지 않습니다.";
}
return null;
})();
return (
<div className="flex h-full min-h-[340px] flex-col bg-white dark:bg-brand-900/10">
{/* ========== CHART TOOLBAR ========== */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
<div className="relative">
<button
type="button"
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
onBlur={() =>
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
}
className={cn(
"relative flex items-center gap-1 border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
)}
>
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
?.label ?? "분봉"}
<ChevronDown className="h-3 w-3" />
</button>
{isMinuteDropdownOpen && (
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-brand-100 bg-white shadow-lg dark:border-brand-700/45 dark:bg-[#1a1624]">
{MINUTE_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => {
setTimeframe(item.value);
setIsMinuteDropdownOpen(false);
}}
className={cn(
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-brand-50 dark:text-brand-100 dark:hover:bg-brand-800/35",
timeframe === item.value &&
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
{PERIOD_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setTimeframe(item.value)}
className={cn(
"relative border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
timeframe === item.value &&
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
)}
>
{item.label}
</button>
))}
{isLoadingMore && (
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
...
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
{formatPrice(latest?.low ?? 0)} C{" "}
<span
className={cn(
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
)}
>
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
</span>
</div>
</div>
{/* ========== CHART BODY ========== */}
<div className="relative min-h-0 flex-1 overflow-hidden">
<div ref={containerRef} className="h-full w-full" />
{statusMessage && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground dark:bg-background/90 dark:text-brand-100/80">
{statusMessage}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,348 @@
/**
* @file chart-utils.ts
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
*/
import type {
TickMarkType,
Time,
UTCTimestamp,
} from "lightweight-charts";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
const KST_TIME_ZONE = "Asia/Seoul";
const KST_TIME_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const KST_TIME_SECONDS_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
month: "2-digit",
day: "2-digit",
});
const KST_MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
month: "short",
});
const KST_YEAR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
year: "numeric",
});
const KST_CROSSHAIR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
// ─── 타입 ──────────────────────────────────────────────────
export type ChartBar = {
time: UTCTimestamp;
open: number;
high: number;
low: number;
close: number;
volume: number;
};
// ─── StockCandlePoint → ChartBar 변환 ─────────────────────
/**
* candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬)
*/
export function normalizeCandles(
candles: StockCandlePoint[],
timeframe: DashboardChartTimeframe,
): ChartBar[] {
const rows = candles
.map((c) => convertCandleToBar(c, timeframe))
.filter((b): b is ChartBar => Boolean(b));
return mergeBars([], rows);
}
/**
* 단일 candle → ChartBar 변환. 유효하지 않으면 null
*/
export function convertCandleToBar(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): ChartBar | null {
const close = candle.close ?? candle.price;
if (!Number.isFinite(close) || close <= 0) return null;
const open = candle.open ?? close;
const high = candle.high ?? Math.max(open, close);
const low = candle.low ?? Math.min(open, close);
const volume = candle.volume ?? 0;
const time = resolveBarTimestamp(candle, timeframe);
if (!time) return null;
return {
time,
open,
high: Math.max(high, open, close),
low: Math.min(low, open, close),
close,
volume,
};
}
// ─── 타임스탬프 해석/정렬 ─────────────────────────────────
function resolveBarTimestamp(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): UTCTimestamp | null {
// timestamp 필드가 있으면 우선 사용
if (
typeof candle.timestamp === "number" &&
Number.isFinite(candle.timestamp)
) {
return alignTimestamp(candle.timestamp, timeframe);
}
const text = typeof candle.time === "string" ? candle.time.trim() : "";
if (!text) return null;
// "MM/DD" 형식 (일봉)
if (/^\d{2}\/\d{2}$/.test(text)) {
const [mm, dd] = text.split("/");
const year = new Date().getFullYear();
const ts = Math.floor(
new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000,
);
return alignTimestamp(ts, timeframe);
}
// "HH:MM" 또는 "HH:MM:SS" 형식 (분봉)
if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) {
const [hh, mi, ss] = text.split(":");
const now = new Date();
const y = now.getFullYear();
const m = `${now.getMonth() + 1}`.padStart(2, "0");
const d = `${now.getDate()}`.padStart(2, "0");
const ts = Math.floor(
new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() /
1000,
);
return alignTimestamp(ts, timeframe);
}
return null;
}
/**
* 타임스탬프를 타임프레임 버킷 경계에 정렬
* - 1m: 그대로
* - 30m/1h: 분 단위를 버킷에 정렬
* - 1d: 00:00:00
* - 1w: 월요일 00:00:00
*/
function alignTimestamp(
timestamp: number,
timeframe: DashboardChartTimeframe,
): UTCTimestamp {
const d = new Date(timestamp * 1000);
if (timeframe === "30m" || timeframe === "1h") {
const bucket = timeframe === "30m" ? 30 : 60;
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
} else if (timeframe === "1d") {
d.setUTCHours(0, 0, 0, 0);
} else if (timeframe === "1w") {
const day = d.getUTCDay();
d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day));
d.setUTCHours(0, 0, 0, 0);
}
return Math.floor(d.getTime() / 1000) as UTCTimestamp;
}
// ─── 봉 병합 ──────────────────────────────────────────────
/**
* 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합
*/
export function mergeBars(left: ChartBar[], right: ChartBar[]): ChartBar[] {
const map = new Map<number, ChartBar>();
for (const bar of [...left, ...right]) {
const prev = map.get(bar.time);
if (!prev) {
map.set(bar.time, bar);
continue;
}
map.set(bar.time, {
time: bar.time,
open: prev.open,
high: Math.max(prev.high, bar.high),
low: Math.min(prev.low, bar.low),
close: bar.close,
volume: Math.max(prev.volume, bar.volume),
});
}
return [...map.values()].sort((a, b) => a.time - b.time);
}
/**
* 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가
*/
export function upsertRealtimeBar(
prev: ChartBar[],
incoming: ChartBar,
): ChartBar[] {
if (prev.length === 0) return [incoming];
const last = prev[prev.length - 1];
if (incoming.time > last.time) return [...prev, incoming];
if (incoming.time < last.time) return prev;
return [
...prev.slice(0, -1),
{
time: last.time,
open: last.open,
high: Math.max(last.high, incoming.high),
low: Math.min(last.low, incoming.low),
close: incoming.close,
volume: Math.max(last.volume, incoming.volume),
},
];
}
/**
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
* @see features/dashboard/components/chart/StockLineChart.tsx 실시간 캔들 반영
*/
export function toRealtimeTickBar(
tick: DashboardRealtimeTradeTick,
timeframe: DashboardChartTimeframe,
now = new Date(),
): ChartBar | null {
if (!Number.isFinite(tick.price) || tick.price <= 0) return null;
const hhmmss = normalizeTickTime(tick.tickTime);
if (!hhmmss) return null;
const ymd = getKstYmd(now);
const baseTimestamp = toKstTimestamp(ymd, hhmmss);
const alignedTimestamp = alignTimestamp(baseTimestamp, timeframe);
const minuteFrame = isMinuteTimeframe(timeframe);
return {
time: alignedTimestamp,
open: minuteFrame ? tick.price : Math.max(tick.open, tick.price),
high: minuteFrame ? tick.price : Math.max(tick.high, tick.price),
low: minuteFrame ? tick.price : Math.min(tick.low || tick.price, tick.price),
close: tick.price,
volume: minuteFrame
? Math.max(tick.tradeVolume, 0)
: Math.max(tick.accumulatedVolume, 0),
};
}
/**
* @description lightweight-charts X축 라벨을 KST 기준으로 강제 포맷합니다.
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
*/
export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
const date = toDateFromChartTime(time);
if (!date) return null;
if (tickMarkType === 0) return KST_YEAR_FORMATTER.format(date);
if (tickMarkType === 1) return KST_MONTH_FORMATTER.format(date);
if (tickMarkType === 2) return KST_DATE_FORMATTER.format(date);
if (tickMarkType === 4) return KST_TIME_SECONDS_FORMATTER.format(date);
return KST_TIME_FORMATTER.format(date);
}
/**
* @description crosshair 시간 라벨을 KST로 포맷합니다.
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
*/
export function formatKstCrosshairTime(time: Time) {
const date = toDateFromChartTime(time);
if (!date) return "";
return KST_CROSSHAIR_FORMATTER.format(date);
}
// ─── 포맷터 ───────────────────────────────────────────────
export function formatPrice(value: number) {
return KRW_FORMATTER.format(Math.round(value));
}
export function formatSignedPercent(value: number) {
const sign = value > 0 ? "+" : "";
return `${sign}${value.toFixed(2)}%`;
}
/**
* 분봉 타임프레임인지 판별
*/
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
return tf === "1m" || tf === "30m" || tf === "1h";
}
function normalizeTickTime(value?: string) {
if (!value) return null;
const normalized = value.trim();
return /^\d{6}$/.test(normalized) ? normalized : null;
}
function getKstYmd(now = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: KST_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
const map = new Map(parts.map((part) => [part.type, part.value]));
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
}
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
const y = Number(yyyymmdd.slice(0, 4));
const m = Number(yyyymmdd.slice(4, 6));
const d = Number(yyyymmdd.slice(6, 8));
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const ss = Number(hhmmss.slice(4, 6));
return Math.floor(Date.UTC(y, m - 1, d, hh - 9, mm, ss) / 1000);
}
function toDateFromChartTime(time: Time) {
if (typeof time === "number" && Number.isFinite(time)) {
return new Date(time * 1000);
}
if (typeof time === "string") {
const parsed = Date.parse(time);
return Number.isFinite(parsed) ? new Date(parsed) : null;
}
if (time && typeof time === "object" && "year" in time) {
const { year, month, day } = time;
return new Date(Date.UTC(year, month - 1, day));
}
return null;
}

View File

@@ -0,0 +1,143 @@
import { Activity, ShieldCheck } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import { StockPriceBadge } from "@/features/dashboard/components/details/StockPriceBadge";
import type {
DashboardStockItem,
DashboardPriceSource,
DashboardMarketPhase,
} from "@/features/dashboard/types/dashboard.types";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
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 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>
);
}
interface StockOverviewCardProps {
stock: DashboardStockItem;
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
isRealtimeConnected: boolean;
realtimeTrId: string | null;
lastRealtimeTickAt: number | null;
}
export function StockOverviewCard({
stock,
priceSource,
marketPhase,
isRealtimeConnected,
realtimeTrId,
lastRealtimeTickAt,
}: StockOverviewCardProps) {
const apiPriceSourceLabel = getPriceSourceLabel(priceSource, marketPhase);
const effectivePriceSourceLabel =
isRealtimeConnected && lastRealtimeTickAt
? `실시간 체결(WebSocket ${realtimeTrId || ""})`
: apiPriceSourceLabel;
return (
<Card className="overflow-hidden border-brand-200">
<CardHeader className="border-b border-border/50 bg-muted/30 pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">{stock.name}</CardTitle>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{stock.symbol}
</span>
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
{stock.market}
</span>
</div>
<CardDescription className="mt-1 flex items-center gap-1.5">
<span>{effectivePriceSourceLabel}</span>
{isRealtimeConnected && (
<span className="inline-flex items-center gap-1 rounded bg-brand-100 px-1.5 py-0.5 text-xs font-medium text-brand-700">
<Activity className="h-3 w-3" />
</span>
)}
</CardDescription>
</div>
<div className="flex flex-col items-end gap-1">
<StockPriceBadge
currentPrice={stock.currentPrice}
change={stock.change}
changeRate={stock.changeRate}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="grid border-b border-border/50 lg:grid-cols-3">
<div className="col-span-2 border-r border-border/50">
{/* Chart Area */}
<div className="p-6">
<StockLineChart candles={stock.candles} />
</div>
</div>
<div className="col-span-1 bg-muted/10 p-6">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground/80">
<ShieldCheck className="h-4 w-4 text-brand-600" />
</div>
<div className="grid grid-cols-2 gap-3">
<PriceStat
label="시가"
value={`${PRICE_FORMATTER.format(stock.open)}`}
/>
<PriceStat
label="고가"
value={`${PRICE_FORMATTER.format(stock.high)}`}
/>
<PriceStat
label="저가"
value={`${PRICE_FORMATTER.format(stock.low)}`}
/>
<PriceStat
label="전일종가"
value={`${PRICE_FORMATTER.format(stock.prevClose)}`}
/>
<div className="col-span-2">
<PriceStat label="거래량" value={formatVolume(stock.volume)} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatPrice(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
interface StockPriceBadgeProps {
currentPrice: number;
change: number;
changeRate: number;
}
export function StockPriceBadge({
currentPrice,
change,
changeRate,
}: StockPriceBadgeProps) {
const isPositive = change >= 0;
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
const changeColor = isPositive ? "text-red-500" : "text-brand-600";
const changeSign = isPositive ? "+" : "";
return (
<div className="flex items-baseline gap-2">
<span className={cn("text-3xl font-bold", changeColor)}>
{formatPrice(currentPrice)}
</span>
<div
className={cn(
"flex items-center gap-1 text-sm font-medium",
changeColor,
)}
>
<ChangeIcon className="h-4 w-4" />
<span>
{changeSign}
{PRICE_FORMATTER.format(change)}
</span>
<span>
({changeSign}
{changeRate.toFixed(2)}%)
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
// import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
interface StockHeaderProps {
stock: DashboardStockItem;
price: string;
change: string;
changeRate: string;
high?: string;
low?: string;
volume?: string;
}
export function StockHeader({
stock,
price,
change,
changeRate,
high,
low,
volume,
}: StockHeaderProps) {
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
const colorClass = isRise
? "text-red-500"
: isFall
? "text-blue-600 dark:text-blue-400"
: "text-foreground";
return (
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4 sm:py-3">
{/* ========== STOCK SUMMARY ========== */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-lg font-bold leading-tight text-foreground dark:text-brand-50 sm:text-xl">
{stock.name}
</h1>
<span className="mt-0.5 block text-xs text-muted-foreground dark:text-brand-100/70 sm:text-sm">
{stock.symbol}/{stock.market}
</span>
</div>
<div className={cn("shrink-0 text-right", colorClass)}>
<span className="block text-2xl font-bold tracking-tight">{price}</span>
<span className="text-xs font-medium sm:text-sm">
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
</span>
</div>
</div>
{/* ========== STATS ========== */}
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70"></p>
<p className="font-medium text-red-500">{high || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70"></p>
<p className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">(24H)</p>
<p className="font-medium">{volume || "--"}</p>
</div>
</div>
<Separator className="mt-2 md:hidden" />
{/* ========== DESKTOP STATS ========== */}
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70"></span>
<span className="font-medium text-red-500">{high || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70"></span>
<span className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70">(24H)</span>
<span className="font-medium">{volume || "--"}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface DashboardLayoutProps {
header: ReactNode;
chart: ReactNode;
orderBook: ReactNode;
orderForm: ReactNode;
className?: string;
}
export function DashboardLayout({
header,
chart,
orderBook,
orderForm,
className,
}: DashboardLayoutProps) {
return (
<div
className={cn(
"flex flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
// Mobile: Scrollable page height
"min-h-[calc(100vh-64px)]",
// Desktop: Fixed height, no window scroll
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
className,
)}
>
{/* 1. Header Area */}
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
{header}
</div>
{/* 2. Main Content Area */}
<div
className={cn(
"flex flex-1 flex-col",
// Mobile: Allow content to flow naturally with spacing
"overflow-visible pb-4 gap-4",
// Desktop: Internal scrolling, horizontal layout, no page spacing
"xl:overflow-hidden xl:flex-row xl:pb-0 xl:gap-0",
)}
>
{/* Left Column: Chart & Info */}
<div
className={cn(
"flex flex-col border-border dark:border-brand-800/45",
// Mobile: Fixed height for chart to ensure visibility
"h-[320px] flex-none border-b sm:h-[360px]",
// Desktop: Fill remaining space, remove bottom border, add right border
"xl:flex-1 xl:h-auto xl:min-h-0 xl:min-w-0 xl:border-b-0 xl:border-r",
)}
>
<div className="flex-1 min-h-0">{chart}</div>
{/* Future: Transaction History / Market Depth can go here */}
</div>
{/* Right Column: Order Book & Order Form */}
<div className="flex min-h-0 w-full flex-none flex-col bg-background dark:bg-brand-900/12 xl:w-[460px] xl:pr-2 2xl:w-[500px]">
{/* Top: Order Book (Hoga) */}
<div className="h-[390px] flex-none overflow-hidden border-t border-border dark:border-brand-800/45 sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
{orderBook}
</div>
{/* Bottom: Order Form */}
<div className="flex-none h-auto sm:h-auto xl:h-[380px]">
{orderForm}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useOrder } from "@/features/dashboard/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardOrderSide,
DashboardStockItem,
} from "@/features/dashboard/types/dashboard.types";
interface OrderFormProps {
stock?: DashboardStockItem;
}
/**
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
* @see features/dashboard/hooks/useOrder.ts placeOrder - 주문 API 호출
* @see features/dashboard/components/DashboardContainer.tsx OrderForm - 우측 주문 패널 렌더링
*/
export function OrderForm({ stock }: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials,
);
const { placeOrder, isLoading, error } = useOrder();
// ========== FORM STATE ==========
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
const [quantity, setQuantity] = useState<string>("");
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
// ========== ORDER HANDLER ==========
const handleOrder = async (side: DashboardOrderSide) => {
if (!stock || !verifiedCredentials) return;
const priceNum = parseInt(price.replace(/,/g, ""), 10);
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
if (Number.isNaN(priceNum) || priceNum <= 0) {
alert("가격을 올바르게 입력해 주세요.");
return;
}
if (Number.isNaN(qtyNum) || qtyNum <= 0) {
alert("수량을 올바르게 입력해 주세요.");
return;
}
if (!verifiedCredentials.accountNo) {
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
return;
}
const response = await placeOrder(
{
symbol: stock.symbol,
side,
orderType: "limit",
price: priceNum,
quantity: qtyNum,
accountNo: verifiedCredentials.accountNo,
accountProductCode: "01",
},
verifiedCredentials,
);
if (response?.orderNo) {
alert(`주문 전송 완료: ${response.orderNo}`);
setQuantity("");
}
};
const totalPrice =
parseInt(price.replace(/,/g, "") || "0", 10) *
parseInt(quantity.replace(/,/g, "") || "0", 10);
const setPercent = (pct: string) => {
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체
console.log("Percent clicked:", pct);
};
const isMarketDataAvailable = Boolean(stock);
return (
<div className="h-full border-l border-border bg-background p-3 dark:border-brand-800/45 dark:bg-brand-950/55 sm:p-4">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
className="flex h-full w-full flex-col"
>
{/* ========== ORDER SIDE TABS ========== */}
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-1 border border-brand-200/70 bg-muted/35 p-1 dark:border-brand-700/50 dark:bg-brand-900/28 sm:mb-4">
<TabsTrigger
value="buy"
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-red-400/60 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(248,113,113,0.45)]"
>
</TabsTrigger>
<TabsTrigger
value="sell"
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-blue-400/65 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(96,165,250,0.45)]"
>
</TabsTrigger>
</TabsList>
{/* ========== BUY TAB ========== */}
<TabsContent
value="buy"
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
>
<OrderInputs
type="buy"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={Boolean(error)}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="mt-auto h-11 w-full bg-red-600 text-base text-white shadow-sm ring-1 ring-red-300/35 hover:bg-red-700 dark:bg-red-500 dark:ring-red-300/45 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
</Button>
</TabsContent>
{/* ========== SELL TAB ========== */}
<TabsContent
value="sell"
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
>
<OrderInputs
type="sell"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={Boolean(error)}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="mt-auto h-11 w-full bg-blue-600 text-base text-white shadow-sm ring-1 ring-blue-300/35 hover:bg-blue-700 dark:bg-blue-500 dark:ring-blue-300/45 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")}
>
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
</Button>
</TabsContent>
</Tabs>
</div>
);
}
/**
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
* @see features/dashboard/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
*/
function OrderInputs({
type,
price,
setPrice,
quantity,
setQuantity,
totalPrice,
disabled,
hasError,
errorMessage,
}: {
type: "buy" | "sell";
price: string;
setPrice: (v: string) => void;
quantity: string;
setQuantity: (v: string) => void;
totalPrice: number;
disabled: boolean;
hasError: boolean;
errorMessage: string | null;
}) {
return (
<div className="space-y-3 sm:space-y-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>- {type === "buy" ? "KRW" : "주"}</span>
</div>
{hasError && (
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
{errorMessage}
</div>
)}
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm">
{type === "buy" ? "매수가격" : "매도가격"}
</span>
<Input
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
placeholder="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm"></span>
<Input
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
placeholder="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm"></span>
<Input
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
value={totalPrice.toLocaleString()}
readOnly
disabled={disabled}
/>
</div>
</div>
);
}
/**
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
* @see features/dashboard/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
*/
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (
<div className="mt-2 grid grid-cols-4 gap-2">
{["10%", "25%", "50%", "100%"].map((pct) => (
<Button
key={pct}
variant="outline"
size="sm"
className="text-xs"
onClick={() => onSelect(pct)}
>
{pct}
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
interface AnimatedQuantityProps {
value: number;
format?: (val: number) => string;
className?: string;
/** 값 변동 시 배경 깜빡임 */
useColor?: boolean;
/** 정렬 방향 (ask: 우측 정렬/왼쪽으로 확장, bid: 좌측 정렬/오른쪽으로 확장) */
side?: "ask" | "bid";
}
/**
* 실시간 수량 표시 — 값이 변할 때 ±diff를 인라인으로 보여줍니다.
*/
export function AnimatedQuantity({
value,
format = (v) => v.toLocaleString(),
className,
useColor = false,
side = "bid",
}: AnimatedQuantityProps) {
const prevRef = useRef(value);
const [diff, setDiff] = useState<number | null>(null);
const [flash, setFlash] = useState<"up" | "down" | null>(null);
useEffect(() => {
if (prevRef.current === value) return;
const delta = value - prevRef.current;
prevRef.current = value;
if (delta === 0) return;
setDiff(delta);
setFlash(delta > 0 ? "up" : "down");
const timer = setTimeout(() => {
setDiff(null);
setFlash(null);
}, 1200);
return () => clearTimeout(timer);
}, [value]);
return (
<span
className={cn(
"relative inline-flex items-center gap-1 tabular-nums",
className,
)}
>
{/* 배경 깜빡임 */}
<AnimatePresence>
{useColor && flash && (
<motion.span
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={cn(
"absolute inset-0 z-0 rounded-sm",
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
)}
/>
)}
</AnimatePresence>
{/* 매도(Ask)일 경우 Diff가 먼저 와야 텍스트가 우측 정렬된 상태에서 흔들리지 않음 */}
{side === "ask" && <DiffChange diff={diff} />}
{/* 수량 값 */}
<span className="relative z-10">{format(value)}</span>
{/* 매수(Bid)일 경우 Diff가 뒤에 와야 텍스트가 좌측 정렬된 상태에서 흔들리지 않음 */}
{side !== "ask" && <DiffChange diff={diff} />}
</span>
);
}
function DiffChange({ diff }: { diff: number | null }) {
return (
<AnimatePresence>
{diff != null && diff !== 0 && (
<motion.span
initial={{ opacity: 1, scale: 1 }}
animate={{ opacity: 0, scale: 0.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={cn(
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none tabular-nums",
diff > 0 ? "text-red-500" : "text-blue-600 dark:text-blue-400",
)}
>
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
</motion.span>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,587 @@
import { useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
import { AnimatedQuantity } from "./AnimatedQuantity";
// ─── 타입 ───────────────────────────────────────────────
interface OrderBookProps {
symbol?: string;
referencePrice?: number;
currentPrice?: number;
latestTick: DashboardRealtimeTradeTick | null;
recentTicks: DashboardRealtimeTradeTick[];
orderBook: DashboardStockOrderBookResponse | null;
isLoading?: boolean;
}
interface BookRow {
price: number;
size: number;
changePercent: number | null;
isHighlighted: boolean;
}
// ─── 유틸리티 함수 ──────────────────────────────────────
/** 천단위 구분 포맷 */
function fmt(v: number) {
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
}
/** 부호 포함 퍼센트 */
function fmtPct(v: number) {
return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
}
/** 등락률 계산 */
function pctChange(price: number, base: number) {
return base > 0 ? ((price - base) / base) * 100 : 0;
}
/** 체결 시각 포맷 */
function fmtTime(hms: string) {
if (!hms || hms.length !== 6) return "--:--:--";
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
}
// ─── 메인 컴포넌트 ──────────────────────────────────────
/**
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
*/
export function OrderBook({
symbol,
referencePrice,
currentPrice,
latestTick,
recentTicks,
orderBook,
isLoading,
}: OrderBookProps) {
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
// 체결가: tick에서 우선, 없으면 0
const latestPrice =
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
// 등락률 기준가
const basePrice =
(referencePrice ?? 0) > 0
? referencePrice!
: (currentPrice ?? 0) > 0
? currentPrice!
: latestPrice > 0
? latestPrice
: 0;
// 매도호가 (역순: 10호가 → 1호가)
const askRows: BookRow[] = useMemo(
() =>
[...levels].reverse().map((l) => ({
price: l.askPrice,
size: Math.max(l.askSize, 0),
changePercent:
l.askPrice > 0 && basePrice > 0
? pctChange(l.askPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
// 매수호가 (1호가 → 10호가)
const bidRows: BookRow[] = useMemo(
() =>
levels.map((l) => ({
price: l.bidPrice,
size: Math.max(l.bidSize, 0),
changePercent:
l.bidPrice > 0 && basePrice > 0
? pctChange(l.bidPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
const askMax = Math.max(1, ...askRows.map((r) => r.size));
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
// 스프레드·수급 불균형
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0;
const totalAsk = orderBook?.totalAskSize ?? 0;
const totalBid = orderBook?.totalBidSize ?? 0;
const imbalance =
totalAsk + totalBid > 0
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
: 0;
// 체결가 행 중앙 스크롤
// ─── 빈/로딩 상태 ───
if (!symbol) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
if (isLoading && !orderBook) return <OrderBookSkeleton />;
if (!orderBook) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-brand-900/10">
<Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */}
<div className="border-b px-2 pt-2 dark:border-brand-800/45 dark:bg-brand-900/28">
<TabsList variant="line" className="w-full justify-start">
<TabsTrigger value="normal" className="px-3">
</TabsTrigger>
<TabsTrigger value="cumulative" className="px-3">
</TabsTrigger>
<TabsTrigger value="order" className="px-3">
</TabsTrigger>
</TabsList>
</div>
{/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1">
<div className="block h-full min-h-0 border-t dark:border-brand-800/45 xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
{/* 호가 테이블 */}
<div className="min-h-0 xl:border-r dark:border-brand-800/45">
<BookHeader />
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
{/* 매도호가 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */}
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
? fmt(bestAsk)
: "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
{/* 매수호가 */}
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 우측 요약 패널 */}
<div className="hidden xl:block">
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
</div>
{/* 체결 목록 */}
<div className="hidden xl:block">
<TradeTape ticks={recentTicks} />
</div>
</div>
</TabsContent>
{/* ── 누적호가 탭 ── */}
<TabsContent value="cumulative" className="min-h-0 flex-1">
<ScrollArea className="h-full border-t dark:border-brand-800/45">
<div className="p-3">
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
<span></span>
<span className="text-center"></span>
<span className="text-right"></span>
</div>
<CumulativeRows asks={askRows} bids={bidRows} />
</div>
</ScrollArea>
</TabsContent>
{/* ── 호가주문 탭 ── */}
<TabsContent value="order" className="min-h-0 flex-1">
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75">
.
</div>
</TabsContent>
</Tabs>
</div>
);
}
// ─── 하위 컴포넌트 ──────────────────────────────────────
/** 호가 표 헤더 */
function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center justify-end px-2"></div>
<div className="flex items-center justify-center border-x"></div>
<div className="flex items-center justify-start px-2"></div>
</div>
);
}
/** 매도 또는 매수 호가 행 목록 */
function BookSideRows({
rows,
side,
maxSize,
}: {
rows: BookRow[];
side: "ask" | "bid";
maxSize: number;
}) {
const isAsk = side === "ask";
return (
<div
className={cn(
isAsk
? "bg-red-50/20 dark:bg-red-950/18"
: "bg-blue-50/55 dark:bg-blue-950/22",
)}
>
{rows.map((row, i) => {
const ratio =
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
return (
<div
key={`${side}-${row.price}-${i}`}
className={cn(
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs dark:border-brand-800/35",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-800/30",
)}
>
{/* 매도잔량 (좌측) */}
<div className="relative flex items-center justify-end overflow-hidden px-2">
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="ask"
className="relative z-10"
/>
</>
)}
</div>
{/* 호가 (중앙) */}
<div
className={cn(
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
row.isHighlighted &&
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
)}
>
<span
className={
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
}
>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"text-[10px]",
row.changePercent !== null
? row.changePercent >= 0
? "text-red-500"
: "text-blue-600 dark:text-blue-400"
: "text-muted-foreground",
)}
>
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
</span>
</div>
{/* 매수잔량 (우측) */}
<div className="relative flex items-center justify-start overflow-hidden px-1">
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="bid"
className="relative z-10"
/>
</>
)}
</div>
</div>
);
})}
</div>
);
}
/** 우측 요약 패널 */
function SummaryPanel({
orderBook,
latestTick,
spread,
imbalance,
totalAsk,
totalBid,
}: {
orderBook: DashboardStockOrderBookResponse;
latestTick: DashboardRealtimeTradeTick | null;
spread: number;
imbalance: number;
totalAsk: number;
totalBid: number;
}) {
return (
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
<Row
label="실시간"
value={orderBook ? "연결됨" : "끊김"}
tone={orderBook ? "bid" : undefined}
/>
<Row
label="거래량"
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
/>
<Row
label="누적거래량"
value={fmt(
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
)}
/>
<Row
label="체결강도"
value={
latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-"
}
/>
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
<Row
label="매도1호가"
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
tone="ask"
/>
<Row
label="매수1호가"
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
tone="bid"
/>
<Row
label="매수체결"
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
/>
<Row
label="매도체결"
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
/>
<Row
label="순매수체결"
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
/>
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
<Row label="스프레드" value={fmt(spread)} />
<Row
label="수급 불균형"
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
tone={imbalance >= 0 ? "bid" : "ask"}
/>
</div>
);
}
/** 요약 패널 단일 행 */
function Row({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "ask" | "bid";
}) {
return (
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"shrink-0 font-medium tabular-nums",
tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-600 dark:text-blue-400",
)}
>
{value}
</span>
</div>
);
}
/** 잔량 깊이 바 */
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
if (ratio <= 0) return null;
return (
<div
className={cn(
"absolute inset-y-1 z-0 rounded-sm",
side === "ask"
? "right-1 bg-red-200/50 dark:bg-red-800/40"
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
)}
style={{ width: `${ratio}%` }}
/>
);
}
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
return (
<div className="border-t bg-background dark:border-brand-800/45 dark:bg-brand-900/20">
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
<ScrollArea className="h-[162px]">
<div>
{ticks.length === 0 && (
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
.
</div>
)}
{ticks.map((t, i) => (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div className="flex items-center justify-end tabular-nums text-red-600">
{fmt(t.price)}
</div>
<div className="flex items-center justify-end tabular-nums text-blue-600 dark:text-blue-400">
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}
/** 누적호가 행 */
function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) {
const rows = useMemo(() => {
const len = Math.max(asks.length, bids.length);
const result: { askAcc: number; bidAcc: number; price: number }[] = [];
for (let i = 0; i < len; i++) {
const prevAsk = result[i - 1]?.askAcc ?? 0;
const prevBid = result[i - 1]?.bidAcc ?? 0;
result.push({
askAcc: prevAsk + (asks[i]?.size ?? 0),
bidAcc: prevBid + (bids[i]?.size ?? 0),
price: asks[i]?.price || bids[i]?.price || 0,
});
}
return result;
}, [asks, bids]);
return (
<div className="space-y-1">
{rows.map((r, i) => (
<div
key={i}
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
>
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
<span className="text-center font-medium tabular-nums">
{fmt(r.price)}
</span>
<span className="text-right tabular-nums text-blue-600 dark:text-blue-400">
{fmt(r.bidAcc)}
</span>
</div>
))}
</div>
);
}
/** 로딩 스켈레톤 */
function OrderBookSkeleton() {
return (
<div className="flex h-full flex-col p-3">
<div className="mb-3 grid grid-cols-3 gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2">
{Array.from({ length: 16 }).map((_, i) => (
<Skeleton key={i} className="h-7 w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import type { FormEvent } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface StockSearchFormProps {
keyword: string;
onKeywordChange: (value: string) => void;
onSubmit: (event: FormEvent) => void;
onInputFocus?: () => void;
disabled?: boolean;
isLoading?: boolean;
}
/**
* @description 종목 검색 입력/제출 폼을 렌더링합니다.
* @see features/dashboard/components/DashboardContainer.tsx 검색 패널에서 키워드 입력 이벤트를 전달합니다.
*/
export function StockSearchForm({
keyword,
onKeywordChange,
onSubmit,
onInputFocus,
disabled,
isLoading,
}: StockSearchFormProps) {
return (
<form onSubmit={onSubmit} className="flex gap-2">
{/* ========== SEARCH INPUT ========== */}
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground dark:text-brand-100/65" />
<Input
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
onFocus={onInputFocus}
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
autoComplete="off"
className="pl-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
/>
</div>
{/* ========== SUBMIT BUTTON ========== */}
<Button type="submit" disabled={disabled || isLoading}>
{isLoading ? "검색 중..." : "검색"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,88 @@
import { Clock3, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { DashboardStockSearchHistoryItem } from "@/features/dashboard/types/dashboard.types";
interface StockSearchHistoryProps {
items: DashboardStockSearchHistoryItem[];
selectedSymbol?: string;
onSelect: (item: DashboardStockSearchHistoryItem) => void;
onRemove: (symbol: string) => void;
onClear: () => void;
}
/**
* @description 최근 검색 종목 목록을 보여주고, 재검색/개별삭제/전체삭제를 제공합니다.
* @see features/dashboard/components/DashboardContainer.tsx 검색 패널에서 종목 재선택 UI로 사용합니다.
* @see features/dashboard/hooks/useStockSearch.ts searchHistory 상태를 화면에 렌더링합니다.
*/
export function StockSearchHistory({
items,
selectedSymbol,
onSelect,
onRemove,
onClear,
}: StockSearchHistoryProps) {
if (items.length === 0) return null;
return (
<section className="rounded-md border border-brand-200/80 bg-brand-50/45 p-2 dark:border-brand-700/50 dark:bg-brand-900/26">
{/* ========== HISTORY HEADER ========== */}
<div className="mb-1.5 flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-brand-700 dark:text-brand-200">
<Clock3 className="h-3.5 w-3.5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
className="h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground dark:text-brand-100/75 dark:hover:text-brand-50"
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* ========== HISTORY LIST ========== */}
<div className="max-h-36 space-y-1 overflow-y-auto pr-1">
{items.map((item) => {
const isSelected = item.symbol === selectedSymbol;
return (
<div key={item.symbol} className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
onClick={() => onSelect(item)}
className={cn(
"h-8 flex-1 justify-between rounded-md border border-transparent px-2.5",
"text-left hover:bg-white/80 dark:hover:bg-brand-800/35",
isSelected &&
"border-brand-300 bg-white text-brand-700 dark:border-brand-500/55 dark:bg-brand-800/40 dark:text-brand-100",
)}
>
<span className="truncate text-sm font-medium">{item.name}</span>
<span className="ml-2 shrink-0 text-[11px] text-muted-foreground dark:text-brand-100/70">
{item.symbol}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(item.symbol)}
aria-label={`${item.name} 히스토리 삭제`}
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-white/80 hover:text-foreground dark:text-brand-100/70 dark:hover:bg-brand-800/35 dark:hover:text-brand-50"
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
// import { Activity, TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
interface StockSearchResultsProps {
items: DashboardStockSearchItem[];
onSelect: (item: DashboardStockSearchItem) => void;
selectedSymbol?: string;
}
export function StockSearchResults({
items,
onSelect,
selectedSymbol,
}: StockSearchResultsProps) {
if (items.length === 0) return null;
return (
<div className="flex flex-col p-2">
{items.map((item) => {
const isSelected = item.symbol === selectedSymbol;
return (
<Button
key={item.symbol}
variant="outline"
className={cn(
"h-auto w-full flex-col items-start gap-1 p-3 text-left",
isSelected && "border-brand-500 bg-brand-50 hover:bg-brand-100",
)}
onClick={() => onSelect(item)}
>
<div className="flex w-full items-center justify-between gap-2">
<span className="font-semibold truncate">{item.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{item.symbol}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{item.market}
</div>
</Button>
);
})}
</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,53 @@
import { useMemo } from "react";
import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
interface UseCurrentPriceParams {
stock?: DashboardStockItem | null;
latestTick: DashboardRealtimeTradeTick | null;
orderBook: DashboardStockOrderBookResponse | null;
}
export function useCurrentPrice({
stock,
latestTick,
orderBook,
}: UseCurrentPriceParams) {
return useMemo(() => {
let currentPrice = stock?.currentPrice ?? 0;
let change = stock?.change ?? 0;
let changeRate = stock?.changeRate ?? 0;
const prevClose = stock?.prevClose ?? 0;
// 1. Priority: Realtime Tick (Trade WS)
if (latestTick?.price && latestTick.price > 0) {
currentPrice = latestTick.price;
change = latestTick.change;
changeRate = latestTick.changeRate;
}
// 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick)
else if (
orderBook?.levels[0]?.askPrice &&
orderBook.levels[0].askPrice > 0
) {
const askPrice = orderBook.levels[0].askPrice;
currentPrice = askPrice;
// Recalculate change/rate based on prevClose
if (prevClose > 0) {
change = currentPrice - prevClose;
changeRate = (change / prevClose) * 100;
}
}
return {
currentPrice,
change,
changeRate,
prevClose,
};
}, [stock, latestTick, orderBook]);
}

View File

@@ -0,0 +1,295 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
} from "@/features/dashboard/utils/kis-realtime.utils";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
resolveDomesticKisSession,
shouldUseAfterHoursSinglePriceTr,
shouldUseExpectedExecutionTr,
type DomesticKisSession,
} from "@/lib/kis/domestic-market-session";
const TRADE_TR_ID = "H0STCNT0";
const TRADE_TR_ID_EXPECTED = "H0STANC0";
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
const ORDERBOOK_TR_ID = "H0STASP0";
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
const MAX_TRADE_TICKS = 10;
function resolveTradeTrId(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
if (env === "mock") return TRADE_TR_ID;
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID_OVERTIME;
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID_EXPECTED;
return TRADE_TR_ID;
}
function resolveOrderBookTrId(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
if (env === "mock") return ORDERBOOK_TR_ID;
if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME;
return ORDERBOOK_TR_ID;
}
/**
* @description Subscribes trade ticks and orderbook over one websocket.
* @see features/dashboard/components/DashboardContainer.tsx
* @see lib/kis/domestic-market-session.ts
*/
export function useKisTradeWebSocket(
symbol: string | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
onTick?: (tick: DashboardRealtimeTradeTick) => void,
options?: {
orderBookSymbol?: string;
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
},
) {
const [latestTick, setLatestTick] =
useState<DashboardRealtimeTradeTick | null>(null);
const [recentTradeTicks, setRecentTradeTicks] = useState<
DashboardRealtimeTradeTick[]
>([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
resolveSessionInClient(),
);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const seenTickRef = useRef<Set<string>>(new Set());
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
const realtimeTrId = credentials
? resolveTradeTrId(credentials.tradingEnv, marketSession)
: TRADE_TR_ID;
useEffect(() => {
const timerId = window.setInterval(() => {
const nextSession = resolveSessionInClient();
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
}, 30_000);
return () => window.clearInterval(timerId);
}, []);
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
);
}, 8000);
return () => window.clearTimeout(timer);
}, [isConnected, lastTickAt]);
useEffect(() => {
setLatestTick(null);
setRecentTradeTicks([]);
setError(null);
setLastTickAt(null);
seenTickRef.current.clear();
if (!symbol || !isVerified || !credentials) {
socketRef.current?.close();
socketRef.current = null;
approvalKeyRef.current = null;
setIsConnected(false);
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
const orderBookTrId = obSymbol
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
: null;
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const wsConnection = await useKisRuntimeStore
.getState()
.getOrFetchWsConnection();
if (!wsConnection) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = wsConnection.approvalKey;
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
socketRef.current = socket;
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
tradeTrId,
"1",
),
),
);
if (obSymbol && orderBookTrId) {
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
obSymbol,
orderBookTrId,
"1",
),
),
);
}
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
if (obSymbol && onOrderBookMsg) {
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
if (orderBook) {
orderBook.tradingEnv = credentials.tradingEnv;
onOrderBookMsg(orderBook);
return;
}
}
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
if (ticks.length === 0) return;
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
const dedupedTicks = meaningfulTicks.filter((tick) => {
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
return true;
});
const latest = ticks[ticks.length - 1];
setLatestTick(latest);
if (dedupedTicks.length > 0) {
setRecentTradeTicks((prev) =>
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
);
}
setError(null);
setLastTickAt(Date.now());
onTick?.(latest);
};
socket.onerror = () => {
if (!disposed) setIsConnected(false);
};
socket.onclose = () => {
if (!disposed) setIsConnected(false);
};
} catch (err) {
if (disposed) return;
setError(
err instanceof Error
? err.message
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
);
setIsConnected(false);
}
};
void connect();
const seenRef = seenTickRef.current;
return () => {
disposed = true;
setIsConnected(false);
const key = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && key) {
socket.send(
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
);
if (obSymbol && orderBookTrId) {
socket.send(
JSON.stringify(
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
),
);
}
}
socket?.close();
if (socketRef.current === socket) socketRef.current = null;
approvalKeyRef.current = null;
seenRef.clear();
};
}, [
symbol,
isVerified,
credentials,
marketSession,
onTick,
obSymbol,
onOrderBookMsg,
]);
return {
latestTick,
recentTradeTicks,
isConnected,
error,
lastTickAt,
realtimeTrId,
};
}
function resolveSessionInClient() {
if (typeof window === "undefined") {
return resolveDomesticKisSession();
}
try {
const override = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return resolveDomesticKisSession(override);
} catch {
return resolveDomesticKisSession();
}
}

View File

@@ -0,0 +1,61 @@
import { useState, useCallback } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import { fetchOrderCash } from "@/features/dashboard/apis/kis-stock.api";
export function useOrder() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<DashboardStockCashOrderResponse | null>(
null,
);
const placeOrder = useCallback(
async (
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials | null,
) => {
if (!credentials) {
setError("KIS API 자격 증명이 없습니다.");
return null;
}
setIsLoading(true);
setError(null);
setResult(null);
try {
const data = await fetchOrderCash(request, credentials);
setResult(data);
return data;
} catch (err) {
const message =
err instanceof Error
? err.message
: "주문 처리 중 오류가 발생했습니다.";
setError(message);
return null;
} finally {
setIsLoading(false);
}
},
[],
);
const reset = useCallback(() => {
setError(null);
setResult(null);
setIsLoading(false);
}, []);
return {
placeOrder,
isLoading,
error,
result,
reset,
};
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import { fetchStockOrderBook } from "@/features/dashboard/apis/kis-stock.api";
import { toast } from "sonner";
/**
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
* 웹소켓 호가 데이터는 DashboardContainer에서 useKisTradeWebSocket을 통해
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
* @see features/dashboard/components/DashboardContainer.tsx 호가 데이터 흐름
* @see features/dashboard/components/orderbook/OrderBook.tsx 호가창 렌더링 데이터 공급
*/
export function useOrderBook(
symbol: string | undefined,
market: "KOSPI" | "KOSDAQ" | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
options: {
enabled?: boolean;
/** 체결 WS에서 받은 실시간 호가 데이터 (단일 WS 통합) */
externalRealtimeOrderBook?: DashboardStockOrderBookResponse | null;
} = {},
) {
const { enabled = true, externalRealtimeOrderBook = null } = options;
const isRequestEnabled = enabled && !!symbol && !!credentials;
const requestSeqRef = useRef(0);
const lastErrorToastRef = useRef<string>("");
const [initialData, setInitialData] =
useState<DashboardStockOrderBookResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isRequestEnabled || !symbol || !credentials) {
return;
}
const requestSeq = ++requestSeqRef.current;
let isDisposed = false;
const loadInitialOrderBook = async () => {
setInitialData(null);
setIsLoading(true);
setError(null);
try {
const data = await fetchStockOrderBook(symbol, credentials);
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setInitialData(data);
} catch (err) {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
console.error("Failed to fetch initial orderbook:", err);
const message =
err instanceof Error
? err.message
: "호가 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.";
setError(message);
if (lastErrorToastRef.current !== message) {
lastErrorToastRef.current = message;
toast.error(message);
}
} finally {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setIsLoading(false);
}
};
void loadInitialOrderBook();
return () => {
isDisposed = true;
};
}, [isRequestEnabled, symbol, credentials]);
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
const orderBook = isRequestEnabled
? (externalRealtimeOrderBook ?? initialData)
: null;
const mergedError = isRequestEnabled ? error : null;
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
return {
orderBook,
isLoading: mergedLoading,
error: mergedError,
isWsConnected: !!externalRealtimeOrderBook,
};
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useState, useTransition } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardMarketPhase,
DashboardPriceSource,
DashboardRealtimeTradeTick,
DashboardStockSearchItem,
DashboardStockItem,
} from "@/features/dashboard/types/dashboard.types";
import { fetchStockOverview } from "@/features/dashboard/apis/kis-stock.api";
interface OverviewMeta {
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
fetchedAt: string;
}
export function useStockOverview() {
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(
null,
);
const [meta, setMeta] = useState<OverviewMeta | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, startTransition] = useTransition();
const loadOverview = useCallback(
(
symbol: string,
credentials: KisRuntimeCredentials | null,
marketHint?: DashboardStockSearchItem["market"],
) => {
if (!credentials) return;
startTransition(async () => {
try {
setError(null);
const data = await fetchStockOverview(symbol, credentials);
setSelectedStock({
...data.stock,
market: marketHint ?? data.stock.market,
});
setMeta({
priceSource: data.priceSource,
marketPhase: data.marketPhase,
fetchedAt: data.fetchedAt,
});
} catch (err) {
const message =
err instanceof Error
? err.message
: "종목 조회 중 오류가 발생했습니다.";
setError(message);
setMeta(null);
}
});
},
[],
);
/**
* 실시간 체결 수신 시 헤더/주요 시세 상태만 갱신합니다.
* 차트 캔들은 StockLineChart 내부 API 응답을 기준으로 유지합니다.
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket onTick 전달
* @see features/dashboard/components/chart/StockLineChart.tsx 차트 데이터 fetchStockChart 기준 렌더링
*/
const updateRealtimeTradeTick = useCallback(
(tick: DashboardRealtimeTradeTick) => {
setSelectedStock((prev) => {
if (!prev) return prev;
const { price, accumulatedVolume, change, changeRate } = tick;
const nextChange = change;
const nextChangeRate = Number.isFinite(changeRate)
? changeRate
: prev.prevClose > 0
? (nextChange / prev.prevClose) * 100
: prev.changeRate;
return {
...prev,
currentPrice: price,
change: nextChange,
changeRate: nextChangeRate,
high: prev.high > 0 ? Math.max(prev.high, price) : price,
low: prev.low > 0 ? Math.min(prev.low, price) : price,
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
};
});
},
[],
);
return {
selectedStock,
setSelectedStock,
meta,
setMeta,
error,
setError,
isLoading,
loadOverview,
updateRealtimeTradeTick,
};
}

View File

@@ -0,0 +1,191 @@
import { useCallback, useRef, useState } from "react";
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardStockSearchHistoryItem,
DashboardStockSearchItem,
} from "@/features/dashboard/types/dashboard.types";
const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1";
const SEARCH_HISTORY_LIMIT = 12;
interface StoredSearchHistory {
version: 1;
items: DashboardStockSearchHistoryItem[];
}
function readSearchHistory(): DashboardStockSearchHistoryItem[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(SEARCH_HISTORY_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as StoredSearchHistory;
if (parsed?.version !== 1 || !Array.isArray(parsed.items)) return [];
return parsed.items
.filter((item) => item?.symbol && item?.name && item?.market)
.slice(0, SEARCH_HISTORY_LIMIT);
} catch {
return [];
}
}
function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
if (typeof window === "undefined") return;
const payload: StoredSearchHistory = {
version: 1,
items,
};
window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload));
}
/**
* @description 종목 검색 상태(키워드/결과/에러)와 검색 히스토리(localStorage)를 함께 관리합니다.
* @see features/dashboard/components/DashboardContainer.tsx 검색 제출/자동검색/히스토리 클릭 이벤트에서 호출합니다.
* @see features/dashboard/components/search/StockSearchHistory.tsx 히스토리 목록 렌더링 데이터로 사용합니다.
*/
export function useStockSearch() {
// ========== SEARCH STATE ==========
const [keyword, setKeyword] = useState("삼성전자");
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
// ========== SEARCH HISTORY STATE ==========
const [searchHistory, setSearchHistory] = useState<DashboardStockSearchHistoryItem[]>(
() => readSearchHistory(),
);
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
const abortRef = useRef<AbortController | null>(null);
const loadSearch = useCallback(async (query: string) => {
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsSearching(true);
setError(null);
try {
const data = await fetchStockSearch(query, controller.signal);
setSearchResults(data.items);
return data.items;
} catch (err) {
if (controller.signal.aborted) return [];
setError(
err instanceof Error
? err.message
: "종목 검색 중 오류가 발생했습니다.",
);
return [];
} finally {
if (!controller.signal.aborted) {
setIsSearching(false);
}
}
}, []);
/**
* @description 검색어를 받아 종목 검색 API를 호출합니다.
* @see features/dashboard/components/DashboardContainer.tsx handleSearchSubmit 자동/수동 검색에 사용합니다.
*/
const search = useCallback(
(query: string, credentials: KisRuntimeCredentials | null) => {
if (!credentials) {
setError("API 키 검증이 필요합니다.");
setSearchResults([]);
setIsSearching(false);
return;
}
const trimmed = query.trim();
if (!trimmed) {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
return;
}
void loadSearch(trimmed);
},
[loadSearch],
);
/**
* @description 검색 결과를 지우고 진행 중인 검색 요청을 중단합니다.
* @see features/dashboard/components/DashboardContainer.tsx 종목 선택 직후 검색 패널 정리에 사용합니다.
*/
const clearSearch = useCallback(() => {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
}, []);
/**
* @description API 검증 전 같은 상황에서 공통 에러 메시지를 표시하기 위한 setter입니다.
* @see features/dashboard/components/DashboardContainer.tsx ensureSearchReady 인증 가드에서 사용합니다.
*/
const setSearchError = useCallback((message: string | null) => {
setError(message);
}, []);
/**
* @description 선택한 종목을 검색 히스토리 맨 위에 추가(중복 제거)합니다.
* @see features/dashboard/components/DashboardContainer.tsx handleSelectStock 종목 선택 이벤트에서 호출합니다.
*/
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
setSearchHistory((prev) => {
const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol);
const nextItems: DashboardStockSearchHistoryItem[] = [
{ ...item, savedAt: Date.now() },
...deduped,
].slice(0, SEARCH_HISTORY_LIMIT);
writeSearchHistory(nextItems);
return nextItems;
});
}, []);
/**
* @description 종목코드 기준으로 히스토리 항목을 삭제합니다.
* @see features/dashboard/components/search/StockSearchHistory.tsx 삭제 버튼 클릭 이벤트에서 호출합니다.
*/
const removeSearchHistory = useCallback((symbol: string) => {
setSearchHistory((prev) => {
const nextItems = prev.filter((item) => item.symbol !== symbol);
writeSearchHistory(nextItems);
return nextItems;
});
}, []);
/**
* @description 저장된 검색 히스토리를 전체 삭제합니다.
* @see features/dashboard/components/search/StockSearchHistory.tsx 전체 삭제 버튼 이벤트에서 호출합니다.
*/
const clearSearchHistory = useCallback(() => {
setSearchHistory([]);
writeSearchHistory([]);
}, []);
return {
keyword,
setKeyword,
searchResults,
error,
isSearching,
search,
clearSearch,
setSearchError,
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
};
}

View File

@@ -0,0 +1,192 @@
"use client";
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
import { createJSONStorage, persist } from "zustand/middleware";
import { create } from "zustand";
/**
* @file features/dashboard/store/use-kis-runtime-store.ts
* @description Stores KIS input, verification, and websocket connection state.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
*/
export interface KisRuntimeCredentials {
appKey: string;
appSecret: string;
tradingEnv: KisTradingEnv;
accountNo: string;
}
interface KisWsConnection {
approvalKey: string;
wsUrl: string;
}
interface KisRuntimeStoreState {
kisTradingEnvInput: KisTradingEnv;
kisAppKeyInput: string;
kisAppSecretInput: string;
kisAccountNoInput: string;
verifiedCredentials: KisRuntimeCredentials | null;
isKisVerified: boolean;
tradingEnv: KisTradingEnv;
wsApprovalKey: string | null;
wsUrl: string | null;
}
interface KisRuntimeStoreActions {
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
setKisAppKeyInput: (appKey: string) => void;
setKisAppSecretInput: (appSecret: string) => void;
setKisAccountNoInput: (accountNo: string) => void;
setVerifiedKisSession: (
credentials: KisRuntimeCredentials,
tradingEnv: KisTradingEnv,
) => void;
invalidateKisVerification: () => void;
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
getOrFetchWsConnection: () => Promise<KisWsConnection | null>;
}
const INITIAL_STATE: KisRuntimeStoreState = {
kisTradingEnvInput: "real",
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv: "real",
wsApprovalKey: null,
wsUrl: null,
};
const RESET_VERIFICATION_STATE = {
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
wsUrl: null,
};
let wsConnectionPromise: Promise<KisWsConnection | null> | null = null;
/**
* @description Runtime store for KIS session.
* @see features/dashboard/components/auth/KisAuthForm.tsx
*/
export const useKisRuntimeStore = create<
KisRuntimeStoreState & KisRuntimeStoreActions
>()(
persist(
(set, get) => ({
...INITIAL_STATE,
setKisTradingEnvInput: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
...RESET_VERIFICATION_STATE,
}),
setKisAppKeyInput: (appKey) =>
set({
kisAppKeyInput: appKey,
...RESET_VERIFICATION_STATE,
}),
setKisAppSecretInput: (appSecret) =>
set({
kisAppSecretInput: appSecret,
...RESET_VERIFICATION_STATE,
}),
setKisAccountNoInput: (accountNo) =>
set({
kisAccountNoInput: accountNo,
...RESET_VERIFICATION_STATE,
}),
setVerifiedKisSession: (credentials, tradingEnv) =>
set({
verifiedCredentials: credentials,
isKisVerified: true,
tradingEnv,
wsApprovalKey: null,
wsUrl: null,
}),
invalidateKisVerification: () =>
set({
...RESET_VERIFICATION_STATE,
}),
clearKisRuntimeSession: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
...RESET_VERIFICATION_STATE,
tradingEnv,
}),
getOrFetchWsConnection: async () => {
const { wsApprovalKey, wsUrl, verifiedCredentials } = get();
if (wsApprovalKey && wsUrl) {
return { approvalKey: wsApprovalKey, wsUrl };
}
if (!verifiedCredentials) {
return null;
}
if (wsConnectionPromise) {
return wsConnectionPromise;
}
wsConnectionPromise = (async () => {
try {
const data = await fetchKisWebSocketApproval(verifiedCredentials);
if (!data.approvalKey || !data.wsUrl) {
return null;
}
const nextConnection = {
approvalKey: data.approvalKey,
wsUrl: data.wsUrl,
} satisfies KisWsConnection;
set({
wsApprovalKey: nextConnection.approvalKey,
wsUrl: nextConnection.wsUrl,
});
return nextConnection;
} catch (error) {
console.error(error);
return null;
} finally {
wsConnectionPromise = null;
}
})();
return wsConnectionPromise;
},
}),
{
name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
kisAccountNoInput: state.kisAccountNoInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
tradingEnv: state.tradingEnv,
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
}),
},
),
);

View File

@@ -0,0 +1,221 @@
/**
* @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;
open?: number;
high?: number;
low?: number;
close?: number;
volume?: number;
timestamp?: number;
}
export type DashboardChartTimeframe = "1m" | "30m" | "1h" | "1d" | "1w";
/**
* 호가창 1레벨(가격 + 잔량)
*/
export interface DashboardOrderBookLevel {
askPrice: number;
bidPrice: number;
askSize: number;
bidSize: 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";
}
/**
* 검색 히스토리 1개 항목
* @see features/dashboard/hooks/useStockSearch.ts localStorage에 저장/복원할 때 사용합니다.
*/
export interface DashboardStockSearchHistoryItem
extends DashboardStockSearchItem {
savedAt: number;
}
/**
* 종목 검색 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;
}
export interface DashboardStockChartResponse {
symbol: string;
timeframe: DashboardChartTimeframe;
candles: StockCandlePoint[];
nextCursor: string | null;
hasMore: boolean;
fetchedAt: string;
}
/**
* 종목 호가 API 응답
*/
export interface DashboardStockOrderBookResponse {
symbol: string;
source: "kis" | "REALTIME";
levels: DashboardOrderBookLevel[];
totalAskSize: number;
totalBidSize: number;
businessHour?: string;
hourClassCode?: string;
accumulatedVolume?: number;
anticipatedPrice?: number;
anticipatedVolume?: number;
anticipatedTotalVolume?: number;
anticipatedChange?: number;
anticipatedChangeSign?: string;
anticipatedChangeRate?: number;
totalAskSizeDelta?: number;
totalBidSizeDelta?: number;
tradingEnv: KisTradingEnv | string;
fetchedAt: string;
}
/**
* 실시간 체결(틱) 1건 모델
*/
export interface DashboardRealtimeTradeTick {
symbol: string;
tickTime: string;
price: number;
change: number;
changeRate: number;
tradeVolume: number;
accumulatedVolume: number;
tradeStrength: number;
askPrice1: number;
bidPrice1: number;
sellExecutionCount: number;
buyExecutionCount: number;
netBuyExecutionCount: number;
open: number;
high: number;
low: number;
}
export type DashboardOrderSide = "buy" | "sell";
export type DashboardOrderType = "limit" | "market";
/**
* 국내주식 현금 주문 요청 모델
*/
export interface DashboardStockCashOrderRequest {
symbol: string;
side: DashboardOrderSide;
orderType: DashboardOrderType;
quantity: number;
price: number;
accountNo: string;
accountProductCode: string;
}
/**
* 국내주식 현금 주문 응답 모델
*/
export interface DashboardStockCashOrderResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
orderNo?: string;
orderTime?: string;
orderOrgNo?: 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

@@ -0,0 +1,249 @@
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
const ALLOWED_REALTIME_TRADE_TR_IDS = new Set([
"H0STCNT0",
"H0STANC0",
"H0STOUP0",
"H0STOAC0",
]);
const TICK_FIELD_INDEX = {
symbol: 0,
tickTime: 1,
price: 2,
sign: 3,
change: 4,
changeRate: 5,
open: 7,
high: 8,
low: 9,
askPrice1: 10,
bidPrice1: 11,
tradeVolume: 12,
accumulatedVolume: 13,
sellExecutionCount: 15,
buyExecutionCount: 16,
netBuyExecutionCount: 17,
tradeStrength: 18,
} as const;
/**
* KIS ?ㅼ떆媛?援щ룆/?댁젣 ?뱀냼耳?硫붿떆吏€瑜??앹꽦?⑸땲??
*/
export 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,
},
},
};
}
/**
* ?ㅼ떆媛?泥닿껐 ?ㅽ듃由?raw)??諛곗뿴 ?⑥쐞濡??뚯떛?⑸땲??
* - 諛곗튂 ?꾩넚(蹂듭닔 ?????뚮룄 紐⑤뱺 ?깆쓣 異붿텧
* - ?щ낵 遺덉씪移?媛€寃?0 ?댄븯 ?곗씠?곕뒗 ?쒖쇅
*/
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
const parts = raw.split("|");
if (parts.length < 4) return [] as DashboardRealtimeTradeTick[];
// TR ID check: regular tick / expected tick / after-hours tick.
const receivedTrId = parts[1];
if (!ALLOWED_REALTIME_TRADE_TR_IDS.has(receivedTrId)) {
return [] as DashboardRealtimeTradeTick[];
}
// if (parts[1] !== expectedTrId) return [] as DashboardRealtimeTradeTick[];
const tickCount = Number(parts[2] ?? "1");
const values = parts[3].split("^");
if (values.length === 0) return [] as DashboardRealtimeTradeTick[];
const parsedCount =
Number.isInteger(tickCount) && tickCount > 0 ? tickCount : 1;
const fieldsPerTick = Math.floor(values.length / parsedCount);
if (fieldsPerTick <= TICK_FIELD_INDEX.tradeStrength) {
return [] as DashboardRealtimeTradeTick[];
}
const ticks: DashboardRealtimeTradeTick[] = [];
for (let index = 0; index < parsedCount; index++) {
const base = index * fieldsPerTick;
const symbol = readString(values, base + TICK_FIELD_INDEX.symbol);
if (symbol !== expectedSymbol) {
if (symbol.trim() !== expectedSymbol.trim()) {
console.warn(
`[KisRealtime] Symbol mismatch: received '${symbol}', expected '${expectedSymbol}'`,
);
continue;
}
}
const price = readNumber(values, base + TICK_FIELD_INDEX.price);
if (!Number.isFinite(price) || price <= 0) continue;
const sign = readString(values, base + TICK_FIELD_INDEX.sign);
const rawChange = readNumber(values, base + TICK_FIELD_INDEX.change);
const rawChangeRate = readNumber(
values,
base + TICK_FIELD_INDEX.changeRate,
);
const change = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChange)
: rawChange;
const changeRate = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChangeRate)
: rawChangeRate;
ticks.push({
symbol,
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
price,
change,
changeRate,
tradeVolume: readNumber(values, base + TICK_FIELD_INDEX.tradeVolume),
accumulatedVolume: readNumber(
values,
base + TICK_FIELD_INDEX.accumulatedVolume,
),
tradeStrength: readNumber(values, base + TICK_FIELD_INDEX.tradeStrength),
askPrice1: readNumber(values, base + TICK_FIELD_INDEX.askPrice1),
bidPrice1: readNumber(values, base + TICK_FIELD_INDEX.bidPrice1),
sellExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.sellExecutionCount,
),
buyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.buyExecutionCount,
),
netBuyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.netBuyExecutionCount,
),
open: readNumber(values, base + TICK_FIELD_INDEX.open),
high: readNumber(values, base + TICK_FIELD_INDEX.high),
low: readNumber(values, base + TICK_FIELD_INDEX.low),
});
}
return ticks;
}
/**
* KIS ?ㅼ떆媛??멸?(H0STASP0/H0UNASP0/H0STOAA0)瑜?OrderBook 援ъ“濡??뚯떛?⑸땲??
*/
export function parseKisRealtimeOrderbook(
raw: string,
expectedSymbol: string,
): DashboardStockOrderBookResponse | null {
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|");
if (parts.length < 4) return null;
const trId = parts[1];
if (trId !== "H0STASP0" && trId !== "H0UNASP0" && trId !== "H0STOAA0") {
return null;
}
const values = parts[3].split("^");
const levelCount = trId === "H0STOAA0" ? 9 : 10;
const symbol = values[0]?.trim() ?? "";
const normalizedSymbol = normalizeDomesticSymbol(symbol);
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
if (normalizedSymbol !== normalizedExpected) return null;
const askPriceStart = 3;
const bidPriceStart = askPriceStart + levelCount;
const askSizeStart = bidPriceStart + levelCount;
const bidSizeStart = askSizeStart + levelCount;
const totalAskIndex = bidSizeStart + levelCount;
const totalBidIndex = totalAskIndex + 1;
const anticipatedPriceIndex = totalBidIndex + 3;
const anticipatedVolumeIndex = anticipatedPriceIndex + 1;
const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2;
const anticipatedChangeIndex = anticipatedPriceIndex + 3;
const anticipatedChangeSignIndex = anticipatedPriceIndex + 4;
const anticipatedChangeRateIndex = anticipatedPriceIndex + 5;
const accumulatedVolumeIndex = anticipatedPriceIndex + 6;
const totalAskDeltaIndex = anticipatedPriceIndex + 7;
const totalBidDeltaIndex = anticipatedPriceIndex + 8;
const minFieldLength = totalBidDeltaIndex + 1;
if (values.length < minFieldLength) return null;
const realtimeLevels = Array.from({ length: levelCount }, (_, i) => ({
askPrice: readNumber(values, askPriceStart + i),
bidPrice: readNumber(values, bidPriceStart + i),
askSize: readNumber(values, askSizeStart + i),
bidSize: readNumber(values, bidSizeStart + i),
}));
return {
symbol: normalizedExpected,
totalAskSize: readNumber(values, totalAskIndex),
totalBidSize: readNumber(values, totalBidIndex),
businessHour: readString(values, 1),
hourClassCode: readString(values, 2),
anticipatedPrice: readNumber(values, anticipatedPriceIndex),
anticipatedVolume: readNumber(values, anticipatedVolumeIndex),
anticipatedTotalVolume: readNumber(values, anticipatedTotalVolumeIndex),
anticipatedChange: readNumber(values, anticipatedChangeIndex),
anticipatedChangeSign: readString(values, anticipatedChangeSignIndex),
anticipatedChangeRate: readNumber(values, anticipatedChangeRateIndex),
accumulatedVolume: readNumber(values, accumulatedVolumeIndex),
totalAskSizeDelta: readNumber(values, totalAskDeltaIndex),
totalBidSizeDelta: readNumber(values, totalBidDeltaIndex),
levels: realtimeLevels,
source: "REALTIME",
tradingEnv: "real",
fetchedAt: new Date().toISOString(),
};
}
/**
* @description 援?궡 醫낅ぉ肄붾뱶 鍮꾧탳瑜??꾪빐 ?묐몢 臾몄옄瑜??쒓굅?섍퀬 6?먮━ 肄붾뱶濡??뺢퇋?뷀빀?덈떎.
* @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 醫낅ぉ 留ㅼ묶 鍮꾧탳
*/
function normalizeDomesticSymbol(value: string) {
const trimmed = value.trim();
const digits = trimmed.replace(/\D/g, "");
if (digits.length >= 6) {
return digits.slice(-6);
}
return trimmed;
}
function readString(values: string[], index: number) {
return (values[index] ?? "").trim();
}
function readNumber(values: string[], index: number) {
const raw = readString(values, index).replaceAll(",", "");
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
}

View File

@@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
interface LogoProps {
className?: string;
variant?: "symbol" | "full";
/** 배경과 섞이는 모드 (홈 화면 등). 로고가 흰색으로 표시됩니다. */
blendWithBackground?: boolean;
}
export function Logo({
className,
variant = "full",
blendWithBackground = false,
}: LogoProps) {
// 색상 클래스 정의
const mainColorClass = blendWithBackground
? "fill-brand-500 stroke-brand-500" // 배경 혼합 모드에서도 심볼은 브랜드 컬러 유지
: "fill-brand-600 stroke-brand-600 dark:fill-brand-500 dark:stroke-brand-500";
return (
<div
className={cn("relative flex items-center gap-2 select-none", className)}
aria-label="Jurini Logo"
>
<svg
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"shrink-0",
variant === "full" ? "h-10 w-10" : "h-full w-full",
)}
>
<defs>
{/* Mask for the cutout effect around the arrow */}
<mask id="arrow-cutout">
<rect width="100%" height="100%" fill="white" />
<path
d="M10 75 C 35 45, 55 85, 90 25"
fill="none"
stroke="black"
strokeWidth="12"
strokeLinecap="round"
/>
{/* Arrow Head Cutout */}
<path
d="M90 25 L 78 32 L 85 42 Z"
fill="black"
stroke="black"
strokeWidth="6"
strokeLinejoin="round"
transform="rotate(-15 90 25)"
/>
</mask>
</defs>
{/* ========== BARS (Masked) ========== */}
<g
mask="url(#arrow-cutout)"
className={
blendWithBackground
? "fill-brand-500" // 배경 혼합 모드에서도 브랜드 컬러 사용
: "fill-brand-600 dark:fill-brand-500"
}
>
{/* Bar 1 (Left, Short) */}
<rect x="15" y="45" width="18" height="40" rx="4" />
{/* Bar 2 (Middle, Medium) */}
<rect x="41" y="30" width="18" height="55" rx="4" />
{/* Bar 3 (Right, Tall) */}
<rect x="67" y="10" width="18" height="75" rx="4" />
</g>
{/* ========== ARROW (Foreground) ========== */}
<g className={mainColorClass}>
{/* Arrow Path */}
<path
d="M10 75 C 35 45, 55 85, 90 25"
fill="none"
strokeWidth="7"
strokeLinecap="round"
/>
{/* Arrow Head */}
<path
d="M90 25 L 78 32 L 85 42 Z"
fill="currentColor"
stroke="none"
transform="rotate(-15 90 25)"
/>
</g>
</svg>
{/* ========== TEXT (Optional) ========== */}
{variant === "full" && (
<span
className={cn(
"font-bold tracking-tight",
blendWithBackground
? "text-white opacity-95"
: "text-brand-900 dark:text-brand-50",
)}
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }}
>
JOORIN-E
</span>
)}
</div>
);
}

View File

@@ -1,11 +1,6 @@
/** /**
* @file features/layout/components/header.tsx * @file features/layout/components/header.tsx
* @description 애플리케이션 상단 헤더 컴포넌트 (네비게이션, 테마, 유저 메뉴) * @description 애플리케이션 상단 헤더 컴포넌트
* @remarks
* - [레이어] Components/UI/Layout
* - [사용자 행동] 홈 이동, 테마 변경, 로그인/회원가입 이동, 대시보드 이동
* - [데이터 흐름] User Prop -> UI Conditional Rendering
* - [연관 파일] layout.tsx, session-timer.tsx, user-menu.tsx
*/ */
import Link from "next/link"; import Link from "next/link";
@@ -14,74 +9,129 @@ import { AUTH_ROUTES } from "@/features/auth/constants";
import { UserMenu } from "@/features/layout/components/user-menu"; import { UserMenu } from "@/features/layout/components/user-menu";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SessionTimer } from "@/features/auth/components/session-timer"; import { SessionTimer } from "@/features/auth/components/session-timer";
import { cn } from "@/lib/utils";
import { Logo } from "@/features/layout/components/Logo";
interface HeaderProps { interface HeaderProps {
/** 현재 로그인 사용자 정보 (없으면 null) */ /** 현재 로그인 사용자 정보(null 가능) */
user: User | null; user: User | null;
/** 대시보드 링크 표시 여부 */ /** 대시보드 링크 버튼 노출 여부 */
showDashboardLink?: boolean; showDashboardLink?: boolean;
/** 홈 랜딩에서 배경과 자연스럽게 섞이는 헤더 모드 */
blendWithBackground?: boolean;
} }
/** /**
* 글로벌 헤더 컴포넌트 * 글로벌 헤더 컴포넌트
* @param user Supabase User 객체 * @param user Supabase User 객체
* @param showDashboardLink 대시보드 바로가기 버튼 노출 여부 * @param showDashboardLink 대시보드 버튼 노출 여부
* @param blendWithBackground 홈 랜딩 전용 반투명 모드
* @returns Header JSX * @returns Header JSX
* @see layout.tsx - RootLayout에서 데이터 주입하여 호출 * @see app/(home)/page.tsx 홈 랜딩에서 blendWithBackground=true로 호출
*/ */
export function Header({ user, showDashboardLink = false }: HeaderProps) { export function Header({
user,
showDashboardLink = false,
blendWithBackground = false,
}: HeaderProps) {
return ( return (
<header className="fixed top-0 z-40 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60"> <header
<div className="flex h-16 w-full items-center justify-between px-4 md:px-6"> className={cn(
{/* ========== 좌측: 로고 영역 ========== */} "fixed inset-x-0 top-0 z-50 w-full",
<Link href={AUTH_ROUTES.HOME} className="flex items-center gap-2 group"> blendWithBackground
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 transition-transform duration-200 group-hover:scale-110"> ? "text-white"
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" /> : "border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60",
</div> )}
<span className="text-xl font-bold tracking-tight text-foreground transition-colors group-hover:text-primary"> >
AutoTrade {blendWithBackground && (
</span> <div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-linear-to-b from-black/70 via-black/35 to-transparent"
/>
)}
<div
className={cn(
"relative z-10 flex h-16 w-full items-center justify-between px-4 md:px-6",
blendWithBackground
? "bg-black/30 backdrop-blur-xl supports-backdrop-filter:bg-black/20"
: "",
)}
>
{/* ========== LEFT: LOGO SECTION ========== */}
{/* ========== LEFT: LOGO SECTION ========== */}
<Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2">
<Logo
variant="full"
className="h-10 text-xl transition-transform duration-200 group-hover:scale-105"
blendWithBackground={blendWithBackground}
/>
</Link> </Link>
{/* ========== 우측: 액션 버튼 영역 ========== */} {/* ========== RIGHT: ACTION SECTION ========== */}
<div className="flex items-center gap-4"> <div
{/* 테마 토글 */} className={cn(
<ThemeToggle /> "flex items-center gap-2 sm:gap-3",
blendWithBackground ? "text-white" : "",
)}
>
<ThemeToggle
className={cn(
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
: "",
)}
iconClassName={blendWithBackground ? "text-white!" : undefined}
/>
{user ? ( {user ? (
// [Case 1] 로그인 상태
<> <>
{/* 세션 타임아웃 타이머 */} <SessionTimer blendWithBackground={blendWithBackground} />
<SessionTimer />
{showDashboardLink && ( {showDashboardLink && (
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
size="sm" size="sm"
className="hidden sm:inline-flex" className={cn(
"hidden font-medium sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:text-white!"
: "",
)}
> >
<Link href={AUTH_ROUTES.DASHBOARD}></Link> <Link href={AUTH_ROUTES.DASHBOARD}></Link>
</Button> </Button>
)} )}
{/* 사용자 드롭다운 메뉴 */} <UserMenu user={user} blendWithBackground={blendWithBackground} />
<UserMenu user={user} />
</> </>
) : ( ) : (
// [Case 2] 비로그인 상태
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
size="sm" size="sm"
className="hidden sm:inline-flex" className={cn(
"hidden sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 hover:text-white!"
: "",
)}
> >
<Link href={AUTH_ROUTES.LOGIN}></Link> <Link href={AUTH_ROUTES.LOGIN}></Link>
</Button> </Button>
<Button asChild size="sm" className="rounded-full px-6"> <Button
asChild
size="sm"
className={cn(
"rounded-full px-6",
blendWithBackground
? "bg-brand-500/90 text-white shadow-lg shadow-brand-700/40 hover:bg-brand-400"
: "",
)}
>
<Link href={AUTH_ROUTES.SIGNUP}></Link> <Link href={AUTH_ROUTES.SIGNUP}></Link>
</Button> </Button>
</div> </div>

View File

@@ -1,51 +1,97 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BarChart2, Home, Settings, User, Wallet } from "lucide-react"; import {
BarChart2,
ChevronLeft,
Home,
Settings,
User,
Wallet,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react";
import { MenuItem } from "../types"; import { MenuItem } from "../types";
const MENU_ITEMS: MenuItem[] = [ const MENU_ITEMS: MenuItem[] = [
{ {
title: "대시보드", title: "대시보드",
href: "/", href: "/dashboard",
icon: Home, icon: Home,
variant: "default", variant: "default",
matchExact: true, matchExact: true,
showInBottomNav: true,
}, },
{ {
title: "자동매매", title: "자동매매",
href: "/trade", href: "/trade",
icon: BarChart2, icon: BarChart2,
variant: "ghost", variant: "ghost",
badge: "LIVE",
showInBottomNav: true,
}, },
{ {
title: "자산현황", title: "자산현황",
href: "/assets", href: "/assets",
icon: Wallet, icon: Wallet,
variant: "ghost", variant: "ghost",
showInBottomNav: true,
}, },
{ {
title: "프로필", title: "프로필",
href: "/profile", href: "/profile",
icon: User, icon: User,
variant: "ghost", variant: "ghost",
showInBottomNav: false,
}, },
{ {
title: "설정", title: "설정",
href: "/settings", href: "/settings",
icon: Settings, icon: Settings,
variant: "ghost", variant: "ghost",
showInBottomNav: true,
}, },
]; ];
/**
* @description 메인 좌측 사이드바(데스크탑): 기본 축소 상태에서 hover/focus 시 확장됩니다.
* @see features/layout/components/sidebar.tsx MENU_ITEMS 한 곳에서 메뉴/배지/모바일 탭 구성을 함께 관리합니다.
*/
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [isExpanded, setIsExpanded] = useState(false);
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
<div className="flex flex-col space-y-1"> className={cn(
"relative hidden h-[calc(100vh-4rem)] shrink-0 overflow-x-visible overflow-y-auto border-r border-brand-100 bg-white px-2 py-5 transition-[width] duration-200 dark:border-brand-900/40 dark:bg-background md:sticky md:top-16 md:block",
isExpanded ? "md:w-64" : "md:w-[74px]",
)}
>
<button
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
className={cn(
"absolute -right-3 top-20 z-50 hidden h-8 w-8 items-center justify-center rounded-full",
"border border-zinc-200/50 bg-white/80 shadow-lg backdrop-blur-md transition-all duration-300",
"hover:scale-110 hover:bg-white active:scale-95",
"dark:border-zinc-800/50 dark:bg-zinc-900/80 dark:hover:bg-zinc-900",
"md:flex",
)}
>
<ChevronLeft
className={cn(
"h-4 w-4 text-zinc-600 transition-transform duration-300 dark:text-zinc-300",
isExpanded ? "rotate-0" : "rotate-180",
)}
/>
</button>
<div className="h-1.5" />
{/* ========== SIDEBAR ITEMS ========== */}
<div className="flex flex-col space-y-1.5">
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
const isActive = item.matchExact const isActive = item.matchExact
? pathname === item.href ? pathname === item.href
@@ -55,22 +101,53 @@ export function Sidebar() {
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
title={item.title}
className={cn( className={cn(
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 transition-colors", "group/item relative flex items-center rounded-xl px-3 py-2.5 text-sm transition-colors",
"hover:bg-brand-50 hover:text-brand-800 dark:hover:bg-brand-900/30 dark:hover:text-brand-100",
isActive isActive
? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50" ? "bg-brand-100 text-brand-800 shadow-sm dark:bg-brand-900/40 dark:text-brand-100"
: "text-zinc-500 dark:text-zinc-400", : "text-muted-foreground dark:text-brand-200/80",
)} )}
> >
<item.icon {/* ========== ACTIVE BAR ========== */}
<span
className={cn( className={cn(
"mr-3 h-5 w-5 shrink-0 transition-colors", "absolute left-0 top-1/2 h-5 -translate-y-1/2 rounded-r-full transition-all",
isActive isActive ? "w-1.5 bg-brand-500" : "w-0",
? "text-zinc-900 dark:text-zinc-50"
: "text-zinc-400 group-hover:text-zinc-900 dark:text-zinc-500 dark:group-hover:text-zinc-50",
)} )}
/> />
{item.title}
{/* ========== ICON + DOT BADGE ========== */}
<item.icon
className={cn(
"h-5 w-5 shrink-0 transition-colors",
isActive
? "text-brand-700 dark:text-brand-200"
: "text-zinc-400 group-hover/item:text-brand-700 dark:text-brand-300/70 dark:group-hover/item:text-brand-200",
)}
/>
{item.badge && !isExpanded && (
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500" />
)}
{/* ========== LABEL (EXPAND ON TOGGLE) ========== */}
<span
className={cn(
"ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200",
isExpanded
? "max-w-[180px] opacity-100"
: "max-w-0 opacity-0",
)}
>
<span className="truncate font-medium">{item.title}</span>
{item.badge && (
<span className="shrink-0 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700">
{item.badge}
</span>
)}
</span>
</Link> </Link>
); );
})} })}
@@ -78,3 +155,58 @@ export function Sidebar() {
</aside> </aside>
); );
} }
/**
* @description 모바일 하단 빠른 탭 네비게이션.
* @see features/layout/components/sidebar.tsx Sidebar와 같은 MENU_ITEMS를 공유해 중복 정의를 줄입니다.
*/
export function MobileBottomNav() {
const pathname = usePathname();
const bottomItems = MENU_ITEMS.filter(
(item) => item.showInBottomNav !== false,
);
return (
<nav
aria-label="모바일 빠른 메뉴"
className="fixed inset-x-0 bottom-0 z-40 border-t border-brand-100 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-brand-900/40 dark:bg-background/95 dark:supports-backdrop-filter:bg-background/80 md:hidden"
>
{/* ========== BOTTOM NAV ITEMS ========== */}
<div
className={cn(
"grid",
bottomItems.length === 4 ? "grid-cols-4" : "grid-cols-5",
)}
>
{bottomItems.map((item) => {
const isActive = item.matchExact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={`bottom-${item.href}`}
href={item.href}
className={cn(
"flex min-h-16 flex-col items-center justify-center gap-1.5 text-[11px] font-medium transition-colors",
isActive
? "text-brand-700"
: "text-muted-foreground hover:text-brand-700 dark:text-brand-200/80 dark:hover:text-brand-200",
)}
>
<span className="relative">
<item.icon
className={cn("h-4 w-4", isActive && "text-brand-600")}
/>
{item.badge && (
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-brand-500" />
)}
</span>
<span className="leading-none">{item.title}</span>
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -1,14 +1,13 @@
/** /**
* @file features/layout/components/user-menu.tsx * @file features/layout/components/user-menu.tsx
* @description 사용자 프로필 드롭다운 메뉴 컴포넌트 * @description 사용자 프로필 드롭다운 메뉴 컴포넌트
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] Avatar 클릭 -> 드롭다운 오픈 -> 프로필/설정 이동 또는 로그아웃
* - [연관 파일] header.tsx, features/auth/actions.ts (로그아웃)
*/ */
"use client"; "use client";
import { User } from "@supabase/supabase-js";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { signout } from "@/features/auth/actions"; import { signout } from "@/features/auth/actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
@@ -19,21 +18,23 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { User } from "@supabase/supabase-js"; import { cn } from "@/lib/utils";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
interface UserMenuProps { interface UserMenuProps {
/** Supabase User 객체 */ /** Supabase User 객체 */
user: User | null; user: User | null;
/** 홈 랜딩의 shader 배경 위에서 대비를 높이는 모드 */
blendWithBackground?: boolean;
} }
/** /**
* 사용자 메뉴/프로필 컴포넌트 (로그인 시 헤더 노출) * 사용자 메뉴/프로필 컴포넌트
* @param user 로그인한 사용자 정보 * @param user 로그인한 사용자 정보
* @returns Avatar 버튼 및 드롭다운 메뉴 * @param blendWithBackground shader 배경 위 가독성 모드
* @returns Avatar 버튼 + 드롭다운 메뉴
* @see features/layout/components/header.tsx 헤더 우측 액션 영역에서 호출
*/ */
export function UserMenu({ user }: UserMenuProps) { export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
const router = useRouter(); const router = useRouter();
if (!user) return null; if (!user) return null;
@@ -41,38 +42,55 @@ export function UserMenu({ user }: UserMenuProps) {
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 outline-none"> <button
<Avatar className="h-8 w-8 transition-opacity hover:opacity-80"> className={cn(
"flex items-center gap-2 rounded-full outline-none transition-colors",
blendWithBackground
? "ring-1 ring-white/30 hover:bg-black/30 focus-visible:ring-2 focus-visible:ring-white/70"
: "",
)}
aria-label="사용자 메뉴 열기"
>
<Avatar className="h-8 w-8 transition-opacity hover:opacity-90">
<AvatarImage src={user.user_metadata?.avatar_url} /> <AvatarImage src={user.user_metadata?.avatar_url} />
<AvatarFallback className="bg-linear-to-br from-brand-500 to-brand-700 text-white text-xs font-bold"> <AvatarFallback
className={cn(
"text-xs font-bold text-white",
blendWithBackground
? "bg-brand-500/90 [text-shadow:0_1px_8px_rgba(0,0,0,0.45)]"
: "bg-linear-to-br from-brand-500 to-brand-700",
)}
>
{user.email?.charAt(0).toUpperCase()} {user.email?.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none"> <p className="text-sm font-medium leading-none">
{user.user_metadata?.full_name || {user.user_metadata?.full_name || user.user_metadata?.name || "사용자"}
user.user_metadata?.name ||
"사용자"}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p> </p>
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}> <DropdownMenuItem onClick={() => router.push("/profile")}>
<UserIcon className="mr-2 h-4 w-4" /> <UserIcon className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}> <DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<form action={signout}> <form action={signout}>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<button className="w-full text-red-600 dark:text-red-400"> <button className="w-full text-red-600 dark:text-red-400">

View File

@@ -6,4 +6,6 @@ export interface MenuItem {
icon: LucideIcon; icon: LucideIcon;
variant: "default" | "ghost"; variant: "default" | "ghost";
matchExact?: boolean; matchExact?: boolean;
badge?: string;
showInBottomNav?: boolean;
} }

137
lib/kis/approval.ts Normal file
View File

@@ -0,0 +1,137 @@
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 웹소켓 승인키 생명주기를 관리합니다.
*/
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)}`;
}
/**
* @description 웹소켓 승인키를 발급합니다.
* @see app/api/kis/ws/approval/route.ts
*/
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,
// Official samples use `secretkey` for Approval endpoint.
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})`,
);
}
// KIS samples recommend daily refresh. Cache for 23 hours conservatively.
return {
approvalKey: payload.approval_key,
expiresAt: Date.now() + 23 * 60 * 60 * 1000,
};
}
function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
try {
return JSON.parse(rawText) as KisApprovalResponse;
} catch {
return {
msg1: rawText.slice(0, 200),
};
}
}
/**
* @description 승인키를 캐시에서 반환하거나 새로 발급합니다.
* @see features/dashboard/store/use-kis-runtime-store.ts
*/
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;
}
/**
* @description 거래 환경에 맞는 웹소켓 URL을 반환합니다.
* @see app/api/kis/ws/approval/route.ts
*/
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
return getKisWebSocketUrl(config.tradingEnv);
}
/**
* @description 승인키 캐시를 제거합니다.
* @see lib/kis/token.ts
*/
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
const cacheKey = getApprovalCacheKey(credentials);
approvalCacheMap.delete(cacheKey);
approvalIssueInFlightMap.delete(cacheKey);
}

152
lib/kis/client.ts Normal file
View File

@@ -0,0 +1,152 @@
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 != null) 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 POST 호출 (주문 등)
* @param apiPath REST 경로
* @param trId KIS TR ID
* @param body 요청 본문
* @param credentials 사용자 입력 키(선택)
* @returns KIS 원본 응답
*/
export async function kisPost<TOutput>(
apiPath: string,
trId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: Record<string, any>,
credentials?: KisCredentialInput,
): Promise<KisApiEnvelope<TOutput>> {
const config = getKisConfig(credentials);
const token = await getKisAccessToken(credentials);
const url = new URL(apiPath, config.baseUrl);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
authorization: `Bearer ${token}`,
appkey: config.appKey,
appsecret: config.appSecret,
tr_id: trId,
tr_cont: "",
custtype: "P",
},
body: JSON.stringify(body),
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)
// 하위 호환(alias)
export const kisMockGet = kisGet;
export const kisMockPost = kisPost;

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

View File

@@ -0,0 +1,176 @@
/**
* @file lib/kis/domestic-market-session.ts
* @description KRX market-session helpers based on KST (Asia/Seoul)
*/
export type DomesticKisSession =
| "openAuction"
| "regular"
| "closeAuction"
| "afterCloseFixedPrice"
| "afterHoursSinglePrice"
| "closed";
export const DOMESTIC_KIS_SESSION_OVERRIDE_HEADER = "x-kis-session-override";
export const DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY =
"KIS_SESSION_OVERRIDE";
const OPEN_AUCTION_START_MINUTES = 8 * 60 + 30; // 08:30
const OPEN_AUCTION_END_MINUTES = 9 * 60; // 09:00
const REGULAR_START_MINUTES = 9 * 60; // 09:00
const REGULAR_END_MINUTES = 15 * 60 + 20; // 15:20
const CLOSE_AUCTION_START_MINUTES = 15 * 60 + 20; // 15:20
const CLOSE_AUCTION_END_MINUTES = 15 * 60 + 30; // 15:30
const AFTER_CLOSE_FIXED_START_MINUTES = 15 * 60 + 40; // 15:40
const AFTER_CLOSE_FIXED_END_MINUTES = 16 * 60; // 16:00
const AFTER_HOURS_SINGLE_START_MINUTES = 16 * 60; // 16:00
const AFTER_HOURS_SINGLE_END_MINUTES = 18 * 60; // 18:00
/**
* @description Converts external string to strict session enum.
* @see lib/kis/domestic.ts getDomesticOrderBook
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveSessionInClient
*/
export function parseDomesticKisSession(value?: string | null) {
if (!value) return null;
const normalized = value.trim();
if (!normalized) return null;
const allowed: DomesticKisSession[] = [
"openAuction",
"regular",
"closeAuction",
"afterCloseFixedPrice",
"afterHoursSinglePrice",
"closed",
];
return allowed.includes(normalized as DomesticKisSession)
? (normalized as DomesticKisSession)
: null;
}
/**
* @description Returns current session in KST.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts WebSocket TR switching
* @see lib/kis/domestic.ts REST orderbook source switching
*/
export function getDomesticKisSessionInKst(now = new Date()): DomesticKisSession {
const { weekday, totalMinutes } = toKstWeekdayAndMinutes(now);
if (weekday === "Sat" || weekday === "Sun") {
return "closed";
}
if (
totalMinutes >= OPEN_AUCTION_START_MINUTES &&
totalMinutes < OPEN_AUCTION_END_MINUTES
) {
return "openAuction";
}
if (
totalMinutes >= REGULAR_START_MINUTES &&
totalMinutes < REGULAR_END_MINUTES
) {
return "regular";
}
if (
totalMinutes >= CLOSE_AUCTION_START_MINUTES &&
totalMinutes < CLOSE_AUCTION_END_MINUTES
) {
return "closeAuction";
}
if (
totalMinutes >= AFTER_CLOSE_FIXED_START_MINUTES &&
totalMinutes < AFTER_CLOSE_FIXED_END_MINUTES
) {
return "afterCloseFixedPrice";
}
if (
totalMinutes >= AFTER_HOURS_SINGLE_START_MINUTES &&
totalMinutes < AFTER_HOURS_SINGLE_END_MINUTES
) {
return "afterHoursSinglePrice";
}
return "closed";
}
/**
* @description If override is valid, use it. Otherwise use real KST time.
* @see app/api/kis/domestic/orderbook/route.ts session override header
* @see features/dashboard/hooks/useKisTradeWebSocket.ts localStorage override
*/
export function resolveDomesticKisSession(
override?: string | null,
now = new Date(),
) {
return parseDomesticKisSession(override) ?? getDomesticKisSessionInKst(now);
}
/**
* @description Maps detailed KIS session to dashboard phase.
* @see lib/kis/domestic.ts getDomesticOverview
*/
export function mapDomesticKisSessionToMarketPhase(
session: DomesticKisSession,
): "regular" | "afterHours" {
if (
session === "regular" ||
session === "openAuction" ||
session === "closeAuction"
) {
return "regular";
}
return "afterHours";
}
/**
* @description Whether orderbook should use overtime REST API.
* @see lib/kis/domestic.ts getDomesticOrderBook
*/
export function shouldUseOvertimeOrderBookApi(session: DomesticKisSession) {
return (
session === "afterCloseFixedPrice" || session === "afterHoursSinglePrice"
);
}
/**
* @description Whether trade tick should use expected-execution TR.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId
*/
export function shouldUseExpectedExecutionTr(session: DomesticKisSession) {
return session === "openAuction" || session === "closeAuction";
}
/**
* @description Whether trade tick/orderbook should use after-hours single-price TR.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId
*/
export function shouldUseAfterHoursSinglePriceTr(session: DomesticKisSession) {
return session === "afterHoursSinglePrice";
}
function toKstWeekdayAndMinutes(now: Date) {
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") ?? "Sun";
const hour = Number(partMap.get("hour") ?? "0");
const minute = Number(partMap.get("minute") ?? "0");
const totalMinutes = hour * 60 + minute;
return { weekday, totalMinutes };
}

909
lib/kis/domestic.ts Normal file
View File

@@ -0,0 +1,909 @@
import type {
DashboardChartTimeframe,
DashboardStockItem,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { kisGet } from "@/lib/kis/client";
import {
mapDomesticKisSessionToMarketPhase,
resolveDomesticKisSession,
shouldUseOvertimeOrderBookApi,
} from "@/lib/kis/domestic-market-session";
/**
* @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_oprc?: string;
stck_hgpr?: string;
stck_lwpr?: string;
stck_clpr?: string;
acml_vol?: string;
}
interface KisDomesticItemChartRow {
stck_bsop_date?: string;
stck_cntg_hour?: string;
stck_oprc?: string;
stck_hgpr?: string;
stck_lwpr?: string;
stck_clpr?: string;
stck_prpr?: string;
cntg_vol?: string;
acml_vol?: string;
}
export interface KisDomesticOrderBookOutput {
stck_prpr?: string;
total_askp_rsqn?: string;
total_bidp_rsqn?: string;
askp1?: string;
askp2?: string;
askp3?: string;
askp4?: string;
askp5?: string;
askp6?: string;
askp7?: string;
askp8?: string;
askp9?: string;
askp10?: string;
bidp1?: string;
bidp2?: string;
bidp3?: string;
bidp4?: string;
bidp5?: string;
bidp6?: string;
bidp7?: string;
bidp8?: string;
bidp9?: string;
bidp10?: string;
askp_rsqn1?: string;
askp_rsqn2?: string;
askp_rsqn3?: string;
askp_rsqn4?: string;
askp_rsqn5?: string;
askp_rsqn6?: string;
askp_rsqn7?: string;
askp_rsqn8?: string;
askp_rsqn9?: string;
askp_rsqn10?: string;
bidp_rsqn1?: string;
bidp_rsqn2?: string;
bidp_rsqn3?: string;
bidp_rsqn4?: string;
bidp_rsqn5?: string;
bidp_rsqn6?: string;
bidp_rsqn7?: string;
bidp_rsqn8?: string;
bidp_rsqn9?: string;
bidp_rsqn10?: 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;
}
interface DomesticSessionAwareOptions {
sessionOverride?: string | null;
}
/**
* 국내주식 현재가 조회
* @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(),
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(),
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 ?? {};
}
/**
* 국내주식 호가(10단계) 조회
* @param symbol 6자리 종목코드
* @param credentials 사용자 입력 키
* @returns KIS 호가 output
*/
export async function getDomesticOrderBook(
symbol: string,
credentials?: KisCredentialInput,
options?: DomesticSessionAwareOptions,
) {
const session = resolveDomesticKisSession(options?.sessionOverride);
const useOvertimeApi = shouldUseOvertimeOrderBookApi(session);
const apiPath = useOvertimeApi
? "/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price"
: "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn";
const trId = useOvertimeApi ? "FHPST02300400" : "FHKST01010200";
const response = await kisGet<KisDomesticOrderBookOutput>(
apiPath,
trId,
{
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
},
credentials,
);
if (response.output && typeof response.output === "object") {
return response.output;
}
if (response.output1 && typeof response.output1 === "object") {
return response.output1 as KisDomesticOrderBookOutput;
}
return {};
}
/**
* 현재가 + 일봉을 대시보드 모델로 변환
* @param symbol 6자리 종목코드
* @param fallbackMeta 보정 메타(종목명/시장)
* @param credentials 사용자 입력 키
* @returns DashboardStockItem
*/
export async function getDomesticOverview(
symbol: string,
fallbackMeta?: DashboardStockFallbackMeta,
credentials?: KisCredentialInput,
options?: DomesticSessionAwareOptions,
): Promise<DomesticOverviewResult> {
const marketPhase = getDomesticMarketPhaseInKst(
new Date(),
options?.sessionOverride,
);
const emptyQuote: KisDomesticQuoteOutput = {};
const emptyDaily: KisDomesticDailyPriceOutput[] = [];
const emptyCcnl: KisDomesticCcnlOutput = {};
const emptyOvertime: KisDomesticOvertimePriceOutput = {};
const [quote, daily, ccnl, overtime] = await Promise.all([
getDomesticQuote(symbol, credentials).catch(() => emptyQuote),
getDomesticDailyPrice(symbol, credentials).catch(() => emptyDaily),
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.replace(/,/g, "").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.replace(/,/g, "").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 ?? "",
open: toNumber(row.stck_oprc),
high: toNumber(row.stck_hgpr),
low: toNumber(row.stck_lwpr),
close: toNumber(row.stck_clpr),
volume: toNumber(row.acml_vol),
}))
.filter((item) => item.date.length === 8 && item.close > 0)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-80)
.map((item) => ({
time: formatDate(item.date),
price: item.close,
open: item.open > 0 ? item.open : item.close,
high: item.high > 0 ? item.high : item.close,
low: item.low > 0 ? item.low : item.close,
close: item.close,
volume: item.volume,
}));
if (parsed.length > 0) return parsed;
const now = new Date();
const mm = `${now.getMonth() + 1}`.padStart(2, "0");
const dd = `${now.getDate()}`.padStart(2, "0");
const safePrice = Math.max(currentPrice, 0);
return [
{
time: `${mm}/${dd}`,
timestamp: Math.floor(now.getTime() / 1000),
price: safePrice,
open: safePrice,
high: safePrice,
low: safePrice,
close: safePrice,
volume: 0,
},
];
}
function formatDate(date: string) {
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
}
function getDomesticMarketPhaseInKst(
now = new Date(),
sessionOverride?: string | null,
): DomesticMarketPhase {
return mapDomesticKisSessionToMarketPhase(
resolveDomesticKisSession(sessionOverride, now),
);
}
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() {
return "J";
}
function firstPositive(...values: number[]) {
return values.find((value) => value > 0) ?? 0;
}
export interface DomesticChartResult {
candles: StockCandlePoint[];
nextCursor: string | null;
hasMore: boolean;
}
// ─── KIS output2 배열 추출 ─────────────────────────────────
function parseOutput2Rows(envelope: {
output2?: unknown;
output1?: unknown;
output?: unknown;
}) {
if (Array.isArray(envelope.output2))
return envelope.output2 as KisDomesticItemChartRow[];
if (Array.isArray(envelope.output))
return envelope.output as KisDomesticItemChartRow[];
for (const key of ["output2", "output", "output1"] as const) {
const v = envelope[key];
if (v && typeof v === "object" && !Array.isArray(v))
return [v as KisDomesticItemChartRow];
}
return [];
}
// ─── Row → StockCandlePoint 변환 ───────────────────────────
function readRowString(row: KisDomesticItemChartRow, ...keys: string[]) {
const record = row as Record<string, unknown>;
for (const key of keys) {
const v = record[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return "";
}
function readOhlcv(row: KisDomesticItemChartRow) {
const close = toNumber(
readRowString(row, "stck_clpr", "STCK_CLPR") ||
readRowString(row, "stck_prpr", "STCK_PRPR"),
);
if (close <= 0) return null;
const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close;
const high =
toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) ||
Math.max(open, close);
const low =
toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) ||
Math.min(open, close);
const volume = toNumber(
readRowString(row, "acml_vol", "ACML_VOL") ||
readRowString(row, "cntg_vol", "CNTG_VOL"),
);
return { open, high, low, close, volume };
}
function parseDayCandleRow(
row: KisDomesticItemChartRow,
): StockCandlePoint | null {
const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
if (!/^\d{8}$/.test(date)) return null;
const ohlcv = readOhlcv(row);
if (!ohlcv) return null;
return {
time: formatDate(date),
timestamp: toKstTimestamp(date, "090000"),
price: ohlcv.close,
...ohlcv,
};
}
function parseMinuteCandleRow(
row: KisDomesticItemChartRow,
minuteBucket: number,
): StockCandlePoint | null {
let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR");
const time = /^\d{6}$/.test(rawTime)
? rawTime
: /^\d{4}$/.test(rawTime)
? `${rawTime}00`
: "";
if (!/^\d{8}$/.test(date)) date = nowYmdInKst(); // 당일 분봉은 날짜가 빠져있을 수 있음
if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null;
const ohlcv = readOhlcv(row);
if (!ohlcv) return null;
const bucketed = alignTimeToMinuteBucket(time, minuteBucket);
return {
time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`,
timestamp: toKstTimestamp(date, bucketed),
price: ohlcv.close,
...ohlcv,
};
}
// ─── 같은 타임스탬프 봉 병합 ───────────────────────────────
function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
const map = new Map<number, StockCandlePoint>();
for (const row of rows) {
if (!row.timestamp) continue;
const prev = map.get(row.timestamp);
if (!prev) {
map.set(row.timestamp, row);
continue;
}
map.set(row.timestamp, {
...prev,
price: row.close ?? row.price,
close: row.close ?? row.price,
high: Math.max(prev.high ?? prev.price, row.high ?? row.price),
low: Math.min(prev.low ?? prev.price, row.low ?? row.price),
volume: (prev.volume ?? 0) + (row.volume ?? 0),
});
}
return Array.from(map.values()).sort(
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
);
}
// ─── 시간 유틸 ─────────────────────────────────────────────
function alignTimeToMinuteBucket(hhmmss: string, bucket: number) {
if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`;
if (bucket <= 1) return hhmmss;
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const aligned = Math.floor(mm / bucket) * bucket;
return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`;
}
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
const y = Number(yyyymmdd.slice(0, 4));
const mo = Number(yyyymmdd.slice(4, 6));
const d = Number(yyyymmdd.slice(6, 8));
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const ss = Number(hhmmss.slice(4, 6));
return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000);
}
function toYmd(date: Date) {
return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`;
}
function shiftYmd(ymd: string, days: number) {
const utc = new Date(
Date.UTC(
Number(ymd.slice(0, 4)),
Number(ymd.slice(4, 6)) - 1,
Number(ymd.slice(6, 8)),
),
);
utc.setUTCDate(utc.getUTCDate() + days);
return toYmd(utc);
}
function nowYmdInKst() {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const m = new Map(parts.map((p) => [p.type, p.value]));
return `${m.get("year")}${m.get("month")}${m.get("day")}`;
}
function nowHmsInKst() {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(new Date());
const m = new Map(parts.map((p) => [p.type, p.value]));
return `${m.get("hour")}${m.get("minute")}${m.get("second")}`;
}
function minutesForTimeframe(tf: DashboardChartTimeframe) {
if (tf === "30m") return 30;
if (tf === "1h") return 60;
return 1;
}
/**
* 국내주식 주식일별분봉조회 (과거 분봉)
* @param symbol 종목코드
* @param date 조회할 날짜 (YYYYMMDD)
* @param time 조회할 기준 시간 (HHMMSS) - 이 시간부터 과거로 조회
* @param credentials
*/
export async function getDomesticDailyTimeChart(
symbol: string,
date: string,
time: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice",
"FHKST03010230",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_INPUT_DATE_1: date,
FID_INPUT_HOUR_1: time,
FID_PW_DATA_INCU_YN: "N",
FID_FAKE_TICK_INCU_YN: "",
},
credentials,
);
return parseOutput2Rows(response);
}
// ─── 차트 데이터 조회 메인 ─────────────────────────────────
/**
* 종목 차트 데이터 조회 (일봉/주봉/분봉)
* - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원
* - 분봉 (오늘): inquire-time-itemchartprice (FHKST03010200)
* - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230)
*/
export async function getDomesticChart(
symbol: string,
timeframe: DashboardChartTimeframe,
credentials?: KisCredentialInput,
cursor?: string,
): Promise<DomesticChartResult> {
// ── 일봉 / 주봉 ──
if (timeframe === "1d" || timeframe === "1w") {
const endDate = cursor && /^\d{8}$/.test(cursor) ? cursor : nowYmdInKst();
const startDate = shiftYmd(endDate, timeframe === "1w" ? -700 : -365);
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
"FHKST03010100",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_INPUT_DATE_1: startDate,
FID_INPUT_DATE_2: endDate,
FID_PERIOD_DIV_CODE: timeframe === "1w" ? "W" : "D",
FID_ORG_ADJ_PRC: "1",
},
credentials,
);
const parsed = parseOutput2Rows(response)
.map(parseDayCandleRow)
.filter((c): c is StockCandlePoint => Boolean(c))
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
const oldest = parsed[0];
const nextCursor =
parsed.length >= 95 && oldest?.timestamp
? shiftYmd(
new Date(oldest.timestamp * 1000)
.toISOString()
.slice(0, 10)
.replace(/-/g, ""),
-1,
)
: null;
return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor };
}
// ── 분봉 (1m / 30m / 1h) ──
const minuteBucket = minutesForTimeframe(timeframe);
let rawRows: KisDomesticItemChartRow[] = [];
let nextCursor: string | null = null;
// Case A: 과거 데이터 조회 (커서 존재)
if (cursor && cursor.length >= 8) {
const targetDate = cursor.slice(0, 8);
const targetTime = cursor.slice(8) || "153000";
rawRows = await getDomesticDailyTimeChart(
symbol,
targetDate,
targetTime,
credentials,
);
// 다음 커서 계산
// 데이터가 있으면 가장 오래된 시간 - 1분? 혹은 해당 날짜의 09:00 도달 시 전일로 이동
// API가 시간 역순으로 데이터를 준다고 가정 (output[0]이 가장 최신, output[last]가 가장 과거)
// 실제 KIS API는 보통 최신순 정렬
if (rawRows.length > 0) {
// 가장 과거 데이터의 시간 확인
const oldestRow = rawRows[rawRows.length - 1]; // 마지막이 가장 과거라 가정
const oldestTime = readRowString(oldestRow, "stck_cntg_hour");
// 09:00:00보다 크면 계속 같은 날짜 페이징 (단, KIS가 120건씩 주므로)
// 만약 09시 근처라면 전일로 이동
// 간단히: 가져온 데이터 중 090000이 포함되어 있거나, 더 가져올 게 없어 보이면 전일로
// 여기서는 단순히 전일 153000으로 넘어가는 로직을 사용하거나,
// 현재 날짜에서 시간을 줄여서 재요청해야 함.
// KIS API가 '다음 커서'를 주지 않으므로, 마지막 데이터 시간을 기준으로 다음 요청
if (oldestTime && Number(oldestTime) > 90000) {
// 같은 날짜, 시간만 조정 (1분 전)
// HHMMSS -> number -> subtract -> string
// 편의상 120개 꽉 찼으면 마지막 시간 사용, 아니면 전일로
if (rawRows.length >= 120) {
nextCursor = targetDate + oldestTime; // 다음 요청 시 이 시간 '이전'을 달라고 해야 함 (Inclusive 여부 확인 필요)
// 만약 Inclusive라면 -1분 해야 함. 안전하게 -1분 처리
// 시간 연산이 복잡하므로, 단순히 전일로 넘어가는 게 나을 수도 있으나,
// 하루치 분봉이 380개라 120개로는 부족함.
// 따라서 시간 연산 필요.
nextCursor = targetDate + subOneMinute(oldestTime);
} else {
// 120개 미만이면 장 시작까지 다 가져왔다고 가정 -> 전일로
nextCursor = shiftYmd(targetDate, -1) + "153000";
}
} else {
// 09:00 도달 -> 전일로
nextCursor = shiftYmd(targetDate, -1) + "153000";
}
} else {
// 데이터 없음 (휴장일 등) -> 전일로 계속 시도 (최대 5일? 무한 루프 방지 필요하나 UI에서 제어)
nextCursor = shiftYmd(targetDate, -1) + "153000";
// 너무 과거(1년)면 중단? 일단 생략
}
} else {
// Case B: 초기 진입 (오늘 실시간/장중 데이터)
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
"FHKST03010200",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_INPUT_HOUR_1: nowHmsInKst(),
FID_PW_DATA_INCU_YN: "Y",
FID_ETC_CLS_CODE: "",
},
credentials,
);
rawRows = parseOutput2Rows(response);
// 초기 조회는 "오늘 가장 오래된 분봉" 기준으로 같은 날 이전 분봉을 우선 이어 붙입니다.
const oldestRow = rawRows[rawRows.length - 1];
const oldestTimeRaw = oldestRow
? readRowString(oldestRow, "stck_cntg_hour", "STCK_CNTG_HOUR")
: "";
const oldestDateRaw = oldestRow
? readRowString(oldestRow, "stck_bsop_date", "STCK_BSOP_DATE")
: "";
const oldestTime = /^\d{6}$/.test(oldestTimeRaw)
? oldestTimeRaw
: /^\d{4}$/.test(oldestTimeRaw)
? `${oldestTimeRaw}00`
: "";
const oldestDate = /^\d{8}$/.test(oldestDateRaw)
? oldestDateRaw
: nowYmdInKst();
nextCursor =
oldestTime && Number(oldestTime) > 90000
? oldestDate + subOneMinute(oldestTime)
: shiftYmd(oldestDate, -1) + "153000";
}
const candles = mergeCandlesByTimestamp(
rawRows
.map((row) => parseMinuteCandleRow(row, minuteBucket))
.filter((c): c is StockCandlePoint => Boolean(c)),
);
return { candles, hasMore: Boolean(nextCursor), nextCursor };
}
function subOneMinute(hhmmss: string) {
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
let totalMin = hh * 60 + mm - 1;
if (totalMin < 0) totalMin = 0;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}00`;
}

42
lib/kis/request.ts Normal file
View File

@@ -0,0 +1,42 @@
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import type { NextRequest } from "next/server";
interface KisCredentialRequestBody {
appKey?: string;
appSecret?: string;
tradingEnv?: string;
}
/**
* @description 요청 본문에서 KIS 인증 정보를 파싱합니다.
* @see app/api/kis/validate/route.ts
*/
export async function parseKisCredentialRequest(
request: NextRequest,
): Promise<KisCredentialInput> {
let body: KisCredentialRequestBody = {};
try {
body = (await request.json()) as KisCredentialRequestBody;
} catch {
// 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다.
}
return {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
}
/**
* @description 인증키 필수값을 검증합니다.
* @see app/api/kis/revoke/route.ts
*/
export function validateKisCredentialInput(credentials: KisCredentialInput) {
if (!credentials.appKey || !credentials.appSecret) {
return "앱 키와 앱 시크릿을 모두 입력해 주세요.";
}
return null;
}

296
lib/kis/token.ts Normal file
View File

@@ -0,0 +1,296 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config";
/**
* @file lib/kis/token.ts
* @description KIS 액세스 토큰 발급/폐기/캐시를 관리합니다.
*/
interface KisTokenResponse {
access_token?: string;
access_token_token_expired?: string;
access_token_expired?: string;
expires_in?: number;
msg1?: string;
msg_cd?: string;
error?: string;
error_description?: string;
}
interface KisTokenCache {
token: string;
expiresAt: number;
}
interface PersistedTokenCache {
[cacheKey: string]: KisTokenCache;
}
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;
const TOKEN_CACHE_FILE_PATH = join(process.cwd(), ".tmp", "kis-token-cache.json");
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)}`;
}
async function readPersistedTokenCache() {
try {
const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8");
return JSON.parse(raw) as PersistedTokenCache;
} catch {
return {};
}
}
async function writePersistedTokenCache(next: PersistedTokenCache) {
await mkdir(join(process.cwd(), ".tmp"), { recursive: true });
await writeFile(TOKEN_CACHE_FILE_PATH, JSON.stringify(next), "utf8");
}
async function getPersistedToken(cacheKey: string) {
const cache = await readPersistedTokenCache();
const token = cache[cacheKey];
if (!token) return null;
if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) {
delete cache[cacheKey];
await writePersistedTokenCache(cache);
return null;
}
return token;
}
async function setPersistedToken(cacheKey: string, token: KisTokenCache) {
const cache = await readPersistedTokenCache();
cache[cacheKey] = token;
await writePersistedTokenCache(cache);
}
async function clearPersistedToken(cacheKey: string) {
const cache = await readPersistedTokenCache();
if (!(cacheKey in cache)) return;
delete cache[cacheKey];
if (Object.keys(cache).length === 0) {
try {
await unlink(TOKEN_CACHE_FILE_PATH);
} catch {
// ignore when file does not exist
}
return;
}
await writePersistedTokenCache(cache);
}
function tryParseTokenResponse(rawText: string): KisTokenResponse {
try {
return JSON.parse(rawText) as KisTokenResponse;
} catch {
return {
msg1: rawText.slice(0, 200),
};
}
}
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
try {
return JSON.parse(rawText) as KisRevokeResponse;
} catch {
return {
message: rawText.slice(0, 200),
};
}
}
function parseTokenExpiryText(value?: string) {
if (!value) return null;
const normalized = value.includes("T") ? value : value.replace(" ", "T");
const parsed = Date.parse(normalized);
if (Number.isNaN(parsed)) return null;
return parsed;
}
function resolveTokenExpiry(payload: KisTokenResponse) {
if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
return Date.now() + payload.expires_in * 1000;
}
const absoluteExpiry =
parseTokenExpiryText(payload.access_token_token_expired) ??
parseTokenExpiryText(payload.access_token_expired);
if (absoluteExpiry) {
return absoluteExpiry;
}
// 예외 상황 기본값: 23시간
return Date.now() + 23 * 60 * 60 * 1000;
}
/**
* @description 토큰 발급 실패 원인 점검 문구를 만듭니다.
* @see https://github.com/koreainvestment/open-trading-api
*/
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("auth");
if (keyError) {
return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱 키/시크릿 쌍을 확인해 주세요.`;
}
return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요.";
}
/**
* @description KIS 액세스 토큰을 발급합니다.
* @see app/api/kis/validate/route.ts
*/
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),
};
}
/**
* @description 캐시된 토큰을 반환하거나 새로 발급합니다.
* @see lib/kis/domestic.ts
*/
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;
}
const persisted = await getPersistedToken(cacheKey);
if (persisted) {
tokenCacheMap.set(cacheKey, persisted);
return persisted.token;
}
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);
await setPersistedToken(cacheKey, next);
return next.token;
}
/**
* @description 현재 KIS 액세스 토큰을 폐기합니다.
* @see app/api/kis/revoke/route.ts
*/
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);
await clearPersistedToken(cacheKey);
clearKisApprovalKeyCache(credentials);
return payload.message ?? "액세스 토큰 폐기가 완료되었습니다.";
}

80
lib/kis/trade.ts Normal file
View File

@@ -0,0 +1,80 @@
import { kisPost } from "@/lib/kis/client";
import { KisCredentialInput } from "@/lib/kis/config";
import {
DashboardOrderSide,
DashboardOrderType,
} from "@/features/dashboard/types/dashboard.types";
/**
* @file lib/kis/trade.ts
* @description KIS 주식 주문/잔고 관련 API
*/
export interface KisOrderCashOutput {
KRX_FWDG_ORD_ORGNO?: string; // 한국거래소전송주문조직번호
ODNO?: string; // 주문번호
ORD_TMD?: string; // 주문시각
}
interface KisOrderCashBody {
CANO: string; // 종합계좌번호(8자리)
ACNT_PRDT_CD: string; // 계좌상품코드(2자리)
PDNO: string; // 종목코드
ORD_DVSN: string; // 주문구분(00:지정가, 01:시장가...)
ORD_QTY: string; // 주문수량
ORD_UNPR: string; // 주문단가
}
/**
* 현금 주문(매수/매도) 실행
*/
export async function executeOrderCash(
params: {
symbol: string;
side: DashboardOrderSide;
orderType: DashboardOrderType;
quantity: number;
price: number;
accountNo: string;
accountProductCode: string;
},
credentials?: KisCredentialInput,
) {
const trId = resolveOrderTrId(params.side, credentials?.tradingEnv);
const ordDvsn = resolveOrderDivision(params.orderType);
const body: KisOrderCashBody = {
CANO: params.accountNo,
ACNT_PRDT_CD: params.accountProductCode,
PDNO: params.symbol,
ORD_DVSN: ordDvsn,
ORD_QTY: String(params.quantity),
ORD_UNPR: String(params.price),
};
const response = await kisPost<KisOrderCashOutput>(
"/uapi/domestic-stock/v1/trading/order-cash",
trId,
body,
credentials,
);
return response.output ?? {};
}
function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") {
const isMock = env === "mock";
if (side === "buy") {
// 매수
return isMock ? "VTTC0802U" : "TTTC0802U";
} else {
// 매도
return isMock ? "VTTC0801U" : "TTTC0801U";
}
}
function resolveOrderDivision(type: DashboardOrderType) {
// 00: 지정가, 01: 시장가
if (type === "market") return "01";
return "00";
}

16
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.31.0", "framer-motion": "^12.31.0",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -6863,6 +6864,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -8266,6 +8273,15 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lightweight-charts": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
"integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

View File

@@ -25,6 +25,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.31.0", "framer-motion": "^12.31.0",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

View File

@@ -0,0 +1,61 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e4]:
- link "AutoTrade" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e8]: AutoTrade
- generic [ref=e9]:
- button "Toggle theme" [ref=e10]:
- img
- generic [ref=e11]: Toggle theme
- link "시작하기" [ref=e13] [cursor=pointer]:
- /url: /signup
- main [ref=e18]:
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e23]: 👋
- generic [ref=e24]: 환영합니다!
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
- generic [ref=e27]:
- generic [ref=e28]:
- generic [ref=e29]:
- generic [ref=e30]: 이메일
- textbox "이메일" [ref=e31]:
- /placeholder: your@email.com
- generic [ref=e32]:
- generic [ref=e33]: 비밀번호
- textbox "비밀번호" [ref=e34]:
- /placeholder: ••••••••
- generic [ref=e35]:
- generic [ref=e36]:
- checkbox "이메일 기억하기" [ref=e37]
- checkbox
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
- /url: /forgot-password
- button "로그인" [ref=e40]
- paragraph [ref=e41]:
- text: 계정이 없으신가요?
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
- /url: /signup
- generic [ref=e44]: 또는 소셜 로그인
- generic [ref=e45]:
- button "Google" [ref=e47]:
- img
- text: Google
- button "Kakao" [ref=e49]:
- img
- text: Kakao
- generic [ref=e50]:
- img [ref=e52]
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
- img [ref=e155]
- alert [ref=e158]
```

View File

@@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
<div id='root'></div> <div id='root'></div>
</body> </body>
</html> </html>
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAFKERVxj+c5WiAYAALs6AAAZAAAAZDc0OGFjNDAwZDA4Yjg1OTM1ZWYuanNvbu2aW2vjRhTHv8owL0lAa+t+Ky3slm5bCEtZsn3oOoWxNLbVSBojjXYTUkMf9mHfug9byJZmoY+FQkspNIV+otr5DmVsJZbHt5GtOM5286RY1tE5R/9zPOenOYWtIMSf+9CFvqXbyNNl2Zftpm04moFbUBqef4QiDF2IMtqppV3s1WgKJUhxSlPoPj0dHs21cU+Tmza2FU82PUXzPR1pisouD2jIrH6a4ZSCtEOy0AcpxmAfxX4Qt8EXqI2hBLsJ+QZ7NPfB6yQkCrIISjAkHqIBiaF7OvRy2sMwiDF0dQl6JMyiGLpWT4J+luSX6ZYjQRTHhA4/YLEcSpCidn5EMuqR4V3xcRd7FPvMHUQ70H0K72e0g2MajHwAD0PyHB5KMMFpFuZp4e6UUpTQg2BoUJVV856s3pONA9lyNd3VjJqlG19BZoEmJ9CV2QW4myc4z9UD3CIJBp8RcsTiW27RZhbHfiiqOsvsw+CYZgkGDdhMyPMUJw0oYt3QJ62bM33eR1nsdUBuWciuOWnXsMZ2DyWIKEVeJ8IxzT/wSBZT6CoSTI+Cbhf70G2hMMW9Ul+WZuXDIzHFx1QoH7ZicNmemY+PE4woBrllIbvWpN3bS0cXtbFYLlQuF7qxIBfdUaUvN8oJQ9c3kYlV0/YIPQvaLDxKQAPWxfJmqZMhaqa6OEbRLmiMu6Bi9uZHIME0Zv9T6ELQyGRZaT515AgAHXyb/6s5EQCANf3dq0/UqLG4kTfg+OKrIy2SChavDs0IpSexVzizezpUCOjtgfGlH35U+MZpI57w1eB8BUXrz1FAC2eHmr42WxufaRNKJgKsF4PYG1/zQSG0ST/AhB9Xh8pVBpSRb+O/r/MTqhpNpUvmjZslghz9gO2yWPdmBkvJZ+gZPmDSvY7ZiOr3M0oOEuTjerAsdFjU/ifD+4EGLNgVKACzpmpc49CMavRvjvWv6qvof1s0tWWPfU3Fr6R8q3wK5qnzAf4ySIMmUydoY/rg5DEJ8e5OByPWunYkcApiFGEX7Fy+/HXw9tXg/Az0f7vovz2TwODtq/73by7fvL784Wzw4mLw4+sd0NsTETnf5e1qNO5MrnRLS9yeyitYIOXrbBX1nCduebsf5XV8zp0hG2ZQJO3Fu4He3swbchJypkKdVyDXCtmtsgDK6F2Rb1zvYRAfFcXe//mnf/+6GJxfMEXXWkGS0l0hZdsKt0SrRtmKup60FWV9abMcVafr6wzPFW/BofwBCAhbUe+SsDXO2Z7A0uJ+i+JEcARmguSHMn3xBCw44jDL3DQirziMrDl4Mk/mD4iFWSskqfjYadZURy5VyrcwbB1KECcJSfLvpRTRLIUu7KI0HcKaKbjD2WYWyBF0aZKNnsNikGU4alNrIdswVVtv6QaWW1MgiyYnbOahBCDPw2kK6j5KO02CEv9qNmpikGA/SIZIiX2zHpJ2EFfBuhRjLuyyzU3BLnanpZBHsyqHXZxeFcVeXOqlYJfOQ6mZJK087OIRnbFqG9ks7LIcZ+4Sdh3WJct3j3XJCx7hyqxL5vKrO1vXfuezruuWJ5ZAw+GhV0VDP+sAa1EvhR81hRmUMj1MVcILCqndIhilTM9Ta1OJJ4/3i0yi0Rj9TNbXAVFPHu+LLal4fq1pFSnSWZNDbZ2utunR3wKMUlcYzksOns2MUhLf/Oi56qBYkjWMwplLG0Sq0+SXemY11anK4+o0lVWqc2uL4b1Oq+oOQn+lWggPp24Ag1gWvxOgKgzCW749DMJ7MnskKYtBShLNdx+D6MiWVV93PKWpO9hEsu95BQyyz5rT6IVtzjtCgnzgkYQBj/CkCsyhavMxh7YxzKGJjPd65ZiDr+RKMYehzd97U+meniXr6S3BHLai3NCeHu3ucQ6V74QzUXrZPT1cIvQl+122inMMF2JiyTNNfl/Dkkcu3Av1NRmHyr+FmdrZs6ylb3Rzj8pvRKpoAM6f5RZBFbXMlhOhJfs+auJwIuzB+Z/9X14Pzv8RWbbjY+TR8clZ63YzYouIzY6Vo6h2rkMZjpZDX13AvBEcKqeQT0UQUi28kzGcVQp0mxT/Pxfl5mbIUp1ixU1hZR5K/+8X/d+/6//x8vJsYiDfWH0XHdgRw0T84q0qTFTYTKhrq1T0+yK64+K9uT5Qqu5X2An5jkG9zcNni3vdW1VTsdZkz+9AHb3X8/ZCav69493aq2dsC6SeinE2UCq9WY9b6mwEr90ipT7s/QdQSwMEFAAACAgAUoRFXIlYh6TTAQAASwUAAAsAAAByZXBvcnQuanNvbs3US4vbMBAA4L9i5qxm/bbjWy8thaX0UOhhyWEsjWM1smWkMW0I/u9FTkICS+gl0N5mhDSP76ATDMSokBGaE6DkGc0P6w7kPDTZIsAzOv6uB4Imqao4rcq6yssqF6Bmh6ztCE1S1tkmLmoBnTbkoXk7rdEXBQ2oKq9R5nGs4rqti21WUAfnm18xlAWcud/4ieSGPQhg8nyuEaKHNT5kcVtTnci4lEmmZI5Zkobnmk2o+nkmz5Hv7WxU5ImiVxyVHvfRN9wTCJic/UmSLzPI3tlBzwMIMFZe9jpv8X5Co0eCJhcgrZmHEZpquefIq60AHEfL60HYZSeAcX+J7MzSrl3p90SSSYVxkHto3uDjzD2NrM8zRJ+M/QXhzQEadjMJcORncwFCZpT9QOOa75bdIv6qVmzTNuuwLsq0zru8oLh7p8buGKTYRigleR+9KPR9a9Gpq2hLkSOl3Tp/uPli7F6Pz4BNioeydfn/yuZYx6nKtzJp8y2VGCsp72RfA0804Z6uhMaiiqR1wdAcnyGXZo/lsn8mt1t/kZCegC2jgSYTtz4hmcdbGgvoDB6Oa+QPepoup9d+S6h4ZxX63LSe3k0AOWfdFWq6+J0WAQPKXo90XvQPUEsBAj8DFAAACAgAUoRFXGP5zlaIBgAAuzoAABkAAAAAAAAAAAAAALSBAAAAAGQ3NDhhYzQwMGQwOGI4NTkzNWVmLmpzb25QSwECPwMUAAAICABShEVciViHpNMBAABLBQAACwAAAAAAAAAAAAAAtIG/BgAAcmVwb3J0Lmpzb25QSwUGAAAAAAIAAgCAAAAAuwgAAAAA</script> <script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIABCFSlz7dUjfvggAAGEnAAAZAAAAMjc0NmQ5MmNiMDdjMTIxNmU3MmMuanNvbu1a247buBl+lR9EgbUBjSxRlA8KEjSZJptik92gmSZFo7RLS7StHUk0JGqc2Ynv+wIF9gEK9LaXfaYe3qEgRZ08ssczm0120XguRhbJ7z/wP5K+QosoZr8NkYfwhIzDGQ7m1iSwsT1mExwgQ41/TROGPJTweRSzkzzIeByfBCsWnJv5mgWmyJGBBMtFjrw3V+ppL+SJO8PT2QTbbEzDcRA4IQ6JXB6JWBLJV7yIQ6BxzDdQkorSJQgONAhYngPPQpbBgmcJUAFixWDOheAJMtA649+xQGh2g1XGk6iQAzEPqIh4irwrJdCNwsRRypBnWwYKeFwkKfImWwOFRaZhXDwdG4imKRfqjRT8rYEEXeonXoiAKzaKlL1bs0CwUHJIxQp5b9BzRR1+Q/PVnNMshJeKD/TWQBnLi1hrcpdgLmgmziKFiy08PrHwiW2dWROPWB6emPZs+kckIUR2iTxLLmBrvSlav4/YgmcMnnJ+LgW9CRFbtkRsGLFt0gf7JHonioyBj+YZ3+Qs89FR6JMuujvtA39GizRYgUY+Bte2dnDdBvetgagQNFglLBX6RcCLVCDPNlB+Hq3XLETegsY5295qstGnj4Cngr0Tx+ljPO7yPetTx2nGqGCggY+C3VHz5JNpY02X7DhVTNwuz6TXnLUuJOxRoDv6dWYfQxN3VdvX9CJaSvEEBx+NwipaHKVAx97ZdGyNDwt71yhJmihpj7f7RTNQnsrvAnkI/MKy7PmbmZWA7cB7/dWZJSA/zehoBLYJbU00aqjX+GkHj+zDc8YJ3dBItEaVQeqvTmI2I0su+KD6ihMfrYRYe6OR1FK84rnwHMuy25vSrB02iPf2cQnQ4bJ6tBP9ZJecN58/6QGMkxZm+WTtqsD9ECqQ857w7Bmn4UtBBeuoI+SJikCpiDkN2RHyo7Ztv6aRkFkc5GrIJTz0gR5h6S4mOznqp7Fz98fa+S/CLn+WpvMxXWfcBu/Y7GNVyIGPBH/EXkV5NI+Zj0DZE88GXwQ0vaD5F0NzEWW5GAyPst3pTqViWc5PY76zxnyxuzUQyzKeSankf69SAU7KcnVwXWV2oiW9pkOcDM1mfls71fh1OFxvNcYJLGgUs9BP/fRZScPbr1Y/fawrag8uNJ1U6pgXwgPXsqwk91MtFouZ1N0gH0LKZcQpUkXmlMYxxHzp+WnNEMAJ7NnjTSRWIEoaFYlGoi6EtH3Zs5TRbZ8QrdXoDrFkesBLA57mbS8NVjQT0DjW/WM8uOK87bilEHvctbW2K+PBaDO7RbTRlqnE6Sfc2rSjyN86ktwppGBrb0h5uBAsO64bc7DpOjvdGB4fbsaOK7cVMu4iW3csjH9cD9THCe4v/GOeH90DSVgy2yn9P1kPVOQse7hk6ZH6cN1b7cwd00OriJ/eobaBbg0vj4B6HTRkeZBF8249sOc0pB1nGjCjRWYwbEe1B62Rqx0/3y299jJY5Gywu/jYIHGbmADXy6yLiG3WPGvx5bUlgk0UilX/YPXoJs7MuklrKxYtV3uoNEBTQlrvt/1QjUCjEUQvVjxlYGN4kXHIo+9ZMxP1u0It8mdP+OwJ/y+ecNcM8ppn5yyD05jRtFgf5TA7R0DE/UBntwp8p3Mh+COdpanGpRQhYXkuj/4+dzGfvotRf5WLd2r8yeHzPWzCqYzEcMbX8LiUFAannSr/Wt/wC+5/esLmg2uG1Zjlz69N2vfpEaw/ERzojnbG7MOm45g6Q8qD4UfqHnAvFP4Q51rsgsaFPM/an3A3URryTe/qsu44a87v3OTGHBXyoJAe0Qs45+HlAUpPVX4b7tniJh23HB/kreqp5/shu/B9Wgh+IjIaMt9XF7y+zzDz/QN1lGfPPOw2264ON0K2yMpbWQCw4T1EiczvcKXKD0ObLGxhkfEEfPTrdUwvN5nkfqQKFHTPTwFA7qH878B7tdJsqqj9tZMhq6P7D1QlpOofXfWYVYWjSpBO4eFVRYYHzswydKHgwZQQ2BrQn+Il0FgD1T2ep15P9GsAyef3URzTkWtaMChh7sHpi99XkN+8BJv8mUAcnTN4TgP54g9DeLhex+w1m38VidHYck3btF0YfPX07Pkzo5z7JQvO+RBesSyPeDqyiWmZDpRqGdnuY5tM4SVd0CwajS1i2j4yFHNTxdx2WKp4VqrYtmo9Dfy738pL7dP8Mg2g1LRta01ID1PkbazJ11sk74PUnEN3P3Ie0fOU9ypEszwdv/lMvBTWdq8jXD+57jmv1svHWleThuGDyUzOneq5Kknp1HS/JF2nnDrR1In2np8+UFmgxW4nzpudmF5uJdRW13N+BVVpUaJo2js4dUUDZZiW/+1G2L7wK6fg62ptB877D3SQNOuAaBl1oDNlUDO7AUzJg529u6XrpoFrWcN7krP6eqnEUcuJFsBtBCDVbpXMNxv2jbLnJzxL1K7hcbPGR//++9/+85cf/vvXH/71z3/4COaFEDyFKAcKS85DiNIwUtoEvlC+0HiHApt0TGBeXD4qEbQZLJl4dPk7HkvTK7GlF11BShPmXSevHRdP+4yjBu8zEKx93bEa6VxTBpBocVntbLkFUrqlumvPQKxoCq90pNTjCsbuyNXeQbjfawtqFa6ttDSN2g6qB70pHZNQocOpDKIUxyEd+lUsrzmYElLOqwxA66hjakpNX5aSnq1oOujCaEra8Z32TvJYevCyFGpaC/VtR48e/OqqTW9r7GpSzujS3H5bSjvrSEu0P5KWP57Sterk8iBjLM1XXMhoPK8dk/Q4ZjO3jNDEqVmXv1aSBieTwIn+XdKom/pLbHOdLnU2IaReviji+AVdMg/KAxM1XOleWy0Zt5MPkfqsnqdaPin2aKTaZnlNXOTIQ2VYQj0/wep2klcoLcsO1TOeNMfFOp6fXa7lqHw5Smh2HvJNWv9MC4VU0FEYssmUEcJCMl3MSTCfW9MwdObBjODZZBFMFiScBsQxkxBt30ou+XndrG7/B1BLAwQUAAAICAAQhUpcefWehMYBAABsAwAACwAAAHJlcG9ydC5qc29urVK9jpwwEH4VNLV3D1gWL9RpUiRNTkpx2sKMh4PD2MgedHda8e6RgWy2idJEbsYz4/l+xjcYiZVWrKC+gUKelfnp/EA+QJ0tAgIrz8/9SFBnUqYyrfK8zNJCgJ694t5ZqMtznh7TSgpoe0MB6pfbGn3VUEMui1JXOTapxCzPSpI5wtb5XcWxMLqmN3QI6J0xB+wIh2OYCI8cQABT4G1kjP468nCu8ksl84xKpUvEk851EZ/3bCJI6NxsdKKMce/JBtXb14RdohAphMR5TT5pnR8TxQl3lDSO2Y0gYPLujZB3uth5N/ZzLBiHuwWb4H+KMb2NRqYC0Jl5tFDL5dHJc34pBShrHa+ZKPwqgNXrHrmZ0a00ZksfEyGTjgwVd1C/wLcVPfmiQtc45XXyY+UB8eUAdatMIAGewmx2UxWzwm4ku9/tJpK8d/6AzjJ9MES6lsny8+cUqzH5NCo/aPdu7/AQv9GT1iQvVBSki0vbFNg06UXrU4NVkVeyRdkW+oLF6ThqWK7xrJ8swt+AHSsDdSbgLq5OxaPWWGuNGj7XQhj6adqb7gKXOPJhZ1HYn639fzixufV7Q9O+uNsiYFTY9XZlcF1+AVBLAQI/AxQAAAgIABCFSlz7dUjfvggAAGEnAAAZAAAAAAAAAAAAAAC0gQAAAAAyNzQ2ZDkyY2IwN2MxMjE2ZTcyYy5qc29uUEsBAj8DFAAACAgAEIVKXHn1noTGAQAAbAMAAAsAAAAAAAAAAAAAALSB9QgAAHJlcG9ydC5qc29uUEsFBgAAAAACAAIAgAAAAOQKAAAAAA==</script>

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;

View File

@@ -1,4 +1,6 @@
{ {
"status": "passed", "status": "failed",
"failedTests": [] "failedTests": [
"2746d92cb07c1216e72c-59289721e6ad6cc3d2d4"
]
} }

View File

@@ -0,0 +1,61 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e4]:
- link "AutoTrade" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e8]: AutoTrade
- generic [ref=e9]:
- button "Toggle theme" [ref=e10]:
- img
- generic [ref=e11]: Toggle theme
- link "시작하기" [ref=e13] [cursor=pointer]:
- /url: /signup
- main [ref=e18]:
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e23]: 👋
- generic [ref=e24]: 환영합니다!
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
- generic [ref=e27]:
- generic [ref=e28]:
- generic [ref=e29]:
- generic [ref=e30]: 이메일
- textbox "이메일" [ref=e31]:
- /placeholder: your@email.com
- generic [ref=e32]:
- generic [ref=e33]: 비밀번호
- textbox "비밀번호" [ref=e34]:
- /placeholder: ••••••••
- generic [ref=e35]:
- generic [ref=e36]:
- checkbox "이메일 기억하기" [ref=e37]
- checkbox
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
- /url: /forgot-password
- button "로그인" [ref=e40]
- paragraph [ref=e41]:
- text: 계정이 없으신가요?
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
- /url: /signup
- generic [ref=e44]: 또는 소셜 로그인
- generic [ref=e45]:
- button "Google" [ref=e47]:
- img
- text: Google
- button "Kakao" [ref=e49]:
- img
- text: Kakao
- generic [ref=e50]:
- img [ref=e52]
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
- img [ref=e155]
- alert [ref=e158]
```

View File

@@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";
test.describe("Mobile Dashboard Scroll", () => {
test.use({
viewport: { width: 390, height: 844 }, // iPhone 12 Pro size
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
});
test("should allow scrolling to access order form at the bottom", async ({
page,
}) => {
// 1. Navigate to dashboard
await page.goto("http://localhost:3001/dashboard");
await page.waitForLoadState("domcontentloaded");
// 2. Check Top Element (Chart)
const chart = page.locator("canvas").first();
await expect(chart).toBeVisible();
// 3. Scroll to Bottom
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(500); // Wait for scroll
// 4. Check Bottom Element (Order Form)
// "매수하기" button is a good indicator of the order form
const buyButton = page.getByRole("button", { name: "매수하기" });
await expect(buyButton).toBeVisible();
// 5. Verify Scroll Height is greater than Viewport Height
const scrollHeight = await page.evaluate(
() => document.documentElement.scrollHeight,
);
const viewportHeight = 844;
expect(scrollHeight).toBeGreaterThan(viewportHeight);
console.log(
`Scroll Height: ${scrollHeight}, Viewport Height: ${viewportHeight}`,
);
// Capture screenshot at bottom
await page.screenshot({
path: "test-results/mobile-scroll-bottom.png",
fullPage: false,
});
});
});