전체적인 리팩토링
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
|
||||
import { KIS_REMEMBER_LOCAL_STORAGE_KEYS } from "@/features/settings/lib/kis-remember-storage";
|
||||
// import { toast } from "sonner"; // Unused for now
|
||||
|
||||
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
|
||||
@@ -33,6 +34,7 @@ const SESSION_RELATED_STORAGE_KEYS = [
|
||||
"session-storage",
|
||||
"auth-storage",
|
||||
"autotrade-kis-runtime-store",
|
||||
...KIS_REMEMBER_LOCAL_STORAGE_KEYS,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
217
features/autotrade/apis/autotrade.api.ts
Normal file
217
features/autotrade/apis/autotrade.api.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 자동매매 프론트엔드가 호출하는 API 클라이언트 모음입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - compile/validate/session/signal 관련 Next API 호출을 캡슐화합니다.
|
||||
* - 공통 응답 파싱/오류 메시지 처리를 제공합니다.
|
||||
*/
|
||||
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { buildKisRequestHeaders } from "@/features/settings/apis/kis-api-utils";
|
||||
import type {
|
||||
AutotradeAiMode,
|
||||
AutotradeCompileResponse,
|
||||
AutotradeCompiledStrategy,
|
||||
AutotradeMarketSnapshot,
|
||||
AutotradeSessionInfo,
|
||||
AutotradeSessionResponse,
|
||||
AutotradeSignalResponse,
|
||||
AutotradeStopReason,
|
||||
AutotradeValidateResponse,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
|
||||
interface AutotradeErrorPayload {
|
||||
ok?: boolean;
|
||||
message?: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
// [목적] UI 설정값을 서버 compile 라우트로 전달해 실행 전략(JSON)을 받습니다.
|
||||
export async function compileAutotradeStrategy(payload: {
|
||||
aiMode: AutotradeAiMode;
|
||||
subscriptionCliVendor?: "auto" | "codex" | "gemini";
|
||||
subscriptionCliModel?: string;
|
||||
prompt: string;
|
||||
selectedTechniques: AutotradeCompiledStrategy["selectedTechniques"];
|
||||
confidenceThreshold: number;
|
||||
}) {
|
||||
const response = await fetch("/api/autotrade/strategies/compile", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseAutotradeResponse<AutotradeCompileResponse>(
|
||||
response,
|
||||
"자동매매 전략 컴파일 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
// [목적] 가용자산/손실한도를 서버에서 동일 규칙으로 계산해 검증 결과를 받습니다.
|
||||
export async function validateAutotradeStrategy(payload: {
|
||||
cashBalance: number;
|
||||
allocationPercent: number;
|
||||
allocationAmount: number;
|
||||
dailyLossPercent: number;
|
||||
dailyLossAmount: number;
|
||||
}) {
|
||||
const response = await fetch("/api/autotrade/strategies/validate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseAutotradeResponse<AutotradeValidateResponse>(
|
||||
response,
|
||||
"자동매매 리스크 검증 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
// [목적] 자동매매 실행 세션을 서버에 등록합니다.
|
||||
export async function startAutotradeSession(
|
||||
payload: {
|
||||
symbol: string;
|
||||
leaderTabId: string;
|
||||
effectiveAllocationAmount: number;
|
||||
effectiveDailyLossLimit: number;
|
||||
strategySummary: string;
|
||||
},
|
||||
credentials: KisRuntimeCredentials,
|
||||
) {
|
||||
const response = await fetch("/api/autotrade/sessions/start", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildKisRequestHeaders(credentials, {
|
||||
jsonContentType: true,
|
||||
includeAccountNo: true,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseAutotradeResponse<AutotradeSessionResponse>(
|
||||
response,
|
||||
"자동매매 세션 시작 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
// [목적] 실행 중 세션 생존 신호를 주기적으로 갱신합니다.
|
||||
export async function heartbeatAutotradeSession(payload: {
|
||||
sessionId: string;
|
||||
leaderTabId: string;
|
||||
}) {
|
||||
const response = await fetch("/api/autotrade/sessions/heartbeat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseAutotradeResponse<AutotradeSessionResponse>(
|
||||
response,
|
||||
"자동매매 heartbeat 전송 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
// [목적] 수동/비상/종료 등 중지 사유를 서버 세션에 반영합니다.
|
||||
export async function stopAutotradeSession(payload: {
|
||||
sessionId?: string;
|
||||
reason?: AutotradeStopReason;
|
||||
}) {
|
||||
const response = await fetch("/api/autotrade/sessions/stop", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseAutotradeResponse<{
|
||||
ok: boolean;
|
||||
session: AutotradeSessionInfo | null;
|
||||
}>(response, "자동매매 세션 종료 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// [목적] 현재 사용자의 실행 중 세션 존재 여부를 조회합니다.
|
||||
export async function fetchActiveAutotradeSession() {
|
||||
const response = await fetch("/api/autotrade/sessions/active", {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseAutotradeResponse<{
|
||||
ok: boolean;
|
||||
session: AutotradeSessionInfo | null;
|
||||
}>(response, "자동매매 세션 조회 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// [목적] 시세 스냅샷 + 전략을 서버에 보내 매수/매도/대기 신호를 생성합니다.
|
||||
export async function generateAutotradeSignal(payload: {
|
||||
aiMode: AutotradeAiMode;
|
||||
subscriptionCliVendor?: "auto" | "codex" | "gemini";
|
||||
subscriptionCliModel?: string;
|
||||
prompt: string;
|
||||
strategy: AutotradeCompiledStrategy;
|
||||
snapshot: AutotradeMarketSnapshot;
|
||||
}) {
|
||||
const response = await fetch("/api/autotrade/signals/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
return parseAutotradeResponse<AutotradeSignalResponse>(
|
||||
response,
|
||||
"자동매매 신호 생성 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
// [목적] 브라우저 종료 직전 stop 요청을 보내기 위한 비동기 beacon 경로입니다.
|
||||
export function sendAutotradeStopBeacon(payload: {
|
||||
sessionId?: string;
|
||||
reason: AutotradeStopReason;
|
||||
}) {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
|
||||
try {
|
||||
const body = JSON.stringify(payload);
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
return navigator.sendBeacon("/api/autotrade/sessions/stop", blob);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseAutotradeResponse<T>(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
): Promise<T> {
|
||||
let payload: unknown = null;
|
||||
|
||||
try {
|
||||
payload = (await response.json()) as unknown;
|
||||
} catch {
|
||||
throw new Error(fallbackMessage);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = payload as AutotradeErrorPayload;
|
||||
throw new Error(errorPayload.message || fallbackMessage);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
1343
features/autotrade/components/AutotradeControlPanel.tsx
Normal file
1343
features/autotrade/components/AutotradeControlPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
37
features/autotrade/components/AutotradeWarningBanner.tsx
Normal file
37
features/autotrade/components/AutotradeWarningBanner.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutotradeWarningBannerProps {
|
||||
visible: boolean;
|
||||
isStopping?: boolean;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function AutotradeWarningBanner({
|
||||
visible,
|
||||
isStopping = false,
|
||||
onStop,
|
||||
}: AutotradeWarningBannerProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-red-300/60 bg-red-600/90 px-3 py-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] sm:px-4">
|
||||
<div className="mx-auto flex w-full max-w-[1800px] items-center gap-3">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<p className="text-xs font-semibold sm:text-sm">
|
||||
자동매매 실행 중: 브라우저/탭 종료 또는 외부 페이지 이동 시 주문이 즉시 중지됩니다.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="ml-auto h-7 bg-white text-red-700 hover:bg-red-50"
|
||||
disabled={isStopping}
|
||||
onClick={onStop}
|
||||
>
|
||||
{isStopping ? "중지 중..." : "비상 중지"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2166
features/autotrade/hooks/useAutotradeEngine.ts
Normal file
2166
features/autotrade/hooks/useAutotradeEngine.ts
Normal file
File diff suppressed because it is too large
Load Diff
212
features/autotrade/stores/use-autotrade-engine-store.ts
Normal file
212
features/autotrade/stores/use-autotrade-engine-store.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
AutotradeCompiledStrategy,
|
||||
AutotradeEngineState,
|
||||
AutotradeRuntimeLog,
|
||||
AutotradeSessionInfo,
|
||||
AutotradeSetupFormValues,
|
||||
AutotradeSignalCandidate,
|
||||
AutotradeValidationResult,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
import { resolveSetupDefaults } from "@/lib/autotrade/strategy";
|
||||
|
||||
interface AutotradeEngineStoreState {
|
||||
panelOpen: boolean;
|
||||
setupForm: AutotradeSetupFormValues;
|
||||
engineState: AutotradeEngineState;
|
||||
isWorking: boolean;
|
||||
|
||||
activeSession: AutotradeSessionInfo | null;
|
||||
compiledStrategy: AutotradeCompiledStrategy | null;
|
||||
validation: AutotradeValidationResult | null;
|
||||
lastSignal: AutotradeSignalCandidate | null;
|
||||
|
||||
orderCountToday: number;
|
||||
cumulativeLossAmount: number;
|
||||
consecutiveFailures: number;
|
||||
lastOrderAtBySymbol: Record<string, number>;
|
||||
|
||||
logs: AutotradeRuntimeLog[];
|
||||
}
|
||||
|
||||
interface AutotradeEngineStoreActions {
|
||||
setPanelOpen: (open: boolean) => void;
|
||||
patchSetupForm: (patch: Partial<AutotradeSetupFormValues>) => void;
|
||||
setEngineState: (state: AutotradeEngineState) => void;
|
||||
setWorking: (working: boolean) => void;
|
||||
|
||||
setActiveSession: (session: AutotradeSessionInfo | null) => void;
|
||||
setCompiledStrategy: (strategy: AutotradeCompiledStrategy | null) => void;
|
||||
setValidation: (validation: AutotradeValidationResult | null) => void;
|
||||
setLastSignal: (signal: AutotradeSignalCandidate | null) => void;
|
||||
|
||||
increaseOrderCount: (count?: number) => void;
|
||||
addLossAmount: (lossAmount: number) => void;
|
||||
setLastOrderAt: (symbol: string, timestampMs: number) => void;
|
||||
increaseFailure: () => void;
|
||||
resetFailure: () => void;
|
||||
|
||||
appendLog: (
|
||||
level: AutotradeRuntimeLog["level"],
|
||||
message: string,
|
||||
options?: {
|
||||
stage?: AutotradeRuntimeLog["stage"];
|
||||
detail?: string | Record<string, unknown>;
|
||||
},
|
||||
) => void;
|
||||
clearRuntime: () => void;
|
||||
}
|
||||
|
||||
const INITIAL_FORM = resolveSetupDefaults();
|
||||
|
||||
const INITIAL_STATE: AutotradeEngineStoreState = {
|
||||
panelOpen: false,
|
||||
setupForm: INITIAL_FORM,
|
||||
engineState: "IDLE",
|
||||
isWorking: false,
|
||||
|
||||
activeSession: null,
|
||||
compiledStrategy: null,
|
||||
validation: null,
|
||||
lastSignal: null,
|
||||
|
||||
orderCountToday: 0,
|
||||
cumulativeLossAmount: 0,
|
||||
consecutiveFailures: 0,
|
||||
lastOrderAtBySymbol: {},
|
||||
|
||||
logs: [],
|
||||
};
|
||||
|
||||
export const useAutotradeEngineStore = create<
|
||||
AutotradeEngineStoreState & AutotradeEngineStoreActions
|
||||
>((set) => ({
|
||||
...INITIAL_STATE,
|
||||
|
||||
setPanelOpen: (open) => {
|
||||
set({ panelOpen: open });
|
||||
},
|
||||
|
||||
patchSetupForm: (patch) => {
|
||||
set((state) => ({
|
||||
setupForm: {
|
||||
...state.setupForm,
|
||||
...patch,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setEngineState: (engineState) => {
|
||||
set({ engineState });
|
||||
},
|
||||
|
||||
setWorking: (isWorking) => {
|
||||
set({ isWorking });
|
||||
},
|
||||
|
||||
setActiveSession: (activeSession) => {
|
||||
set({ activeSession });
|
||||
},
|
||||
|
||||
setCompiledStrategy: (compiledStrategy) => {
|
||||
set({ compiledStrategy });
|
||||
},
|
||||
|
||||
setValidation: (validation) => {
|
||||
set({ validation });
|
||||
},
|
||||
|
||||
setLastSignal: (lastSignal) => {
|
||||
set({ lastSignal });
|
||||
},
|
||||
|
||||
increaseOrderCount: (count = 1) => {
|
||||
set((state) => ({
|
||||
orderCountToday: state.orderCountToday + Math.max(1, count),
|
||||
}));
|
||||
},
|
||||
|
||||
addLossAmount: (lossAmount) => {
|
||||
set((state) => ({
|
||||
cumulativeLossAmount:
|
||||
state.cumulativeLossAmount + Math.max(0, Math.floor(lossAmount)),
|
||||
}));
|
||||
},
|
||||
|
||||
setLastOrderAt: (symbol, timestampMs) => {
|
||||
set((state) => ({
|
||||
lastOrderAtBySymbol: {
|
||||
...state.lastOrderAtBySymbol,
|
||||
[symbol]: timestampMs,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
increaseFailure: () => {
|
||||
set((state) => ({
|
||||
consecutiveFailures: state.consecutiveFailures + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
resetFailure: () => {
|
||||
set({ consecutiveFailures: 0 });
|
||||
},
|
||||
|
||||
appendLog: (level, message, options) => {
|
||||
const entry: AutotradeRuntimeLog = {
|
||||
id: safeLogId(),
|
||||
level,
|
||||
stage: options?.stage,
|
||||
message,
|
||||
detail: normalizeLogDetail(options?.detail),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
logs: [entry, ...state.logs].slice(0, 80),
|
||||
}));
|
||||
},
|
||||
|
||||
clearRuntime: () => {
|
||||
set((state) => ({
|
||||
...state,
|
||||
engineState: "IDLE",
|
||||
isWorking: false,
|
||||
activeSession: null,
|
||||
compiledStrategy: null,
|
||||
validation: null,
|
||||
lastSignal: null,
|
||||
orderCountToday: 0,
|
||||
cumulativeLossAmount: 0,
|
||||
consecutiveFailures: 0,
|
||||
lastOrderAtBySymbol: {},
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
function safeLogId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `autotrade-log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeLogDetail(detail: string | Record<string, unknown> | undefined) {
|
||||
if (!detail) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof detail === "string") {
|
||||
const cleaned = detail.trim();
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(detail, null, 2);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
482
features/autotrade/types/autotrade.types.ts
Normal file
482
features/autotrade/types/autotrade.types.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* @file features/autotrade/types/autotrade.types.ts
|
||||
* @description 자동매매 기능에서 공통으로 사용하는 타입 정의입니다.
|
||||
*/
|
||||
|
||||
export const AUTOTRADE_TECHNIQUE_IDS = [
|
||||
"orb",
|
||||
"vwap_reversion",
|
||||
"volume_breakout",
|
||||
"ma_crossover",
|
||||
"gap_breakout",
|
||||
"intraday_box_reversion",
|
||||
"intraday_breakout_scalp",
|
||||
] as const;
|
||||
|
||||
export type AutotradeTechniqueId = (typeof AUTOTRADE_TECHNIQUE_IDS)[number];
|
||||
|
||||
export interface AutotradeTechniqueOption {
|
||||
id: AutotradeTechniqueId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const AUTOTRADE_TECHNIQUE_OPTIONS: AutotradeTechniqueOption[] = [
|
||||
{
|
||||
id: "orb",
|
||||
label: "ORB(시가 범위 돌파)",
|
||||
description: "시가 근처 범위를 돌파할 때 추세 진입 신호를 확인합니다.",
|
||||
},
|
||||
{
|
||||
id: "vwap_reversion",
|
||||
label: "VWAP 되돌림",
|
||||
description: "VWAP에서 과하게 이탈한 가격이 평균으로 복귀하는 구간을 봅니다.",
|
||||
},
|
||||
{
|
||||
id: "volume_breakout",
|
||||
label: "거래량 돌파",
|
||||
description: "거래량 급증과 함께 방향성이 생기는 순간을 포착합니다.",
|
||||
},
|
||||
{
|
||||
id: "ma_crossover",
|
||||
label: "이동평균 교차",
|
||||
description: "단기/중기 평균선 교차로 추세 전환 여부를 확인합니다.",
|
||||
},
|
||||
{
|
||||
id: "gap_breakout",
|
||||
label: "갭 돌파",
|
||||
description: "갭 상승/하락 이후 추가 돌파 또는 되돌림을 판단합니다.",
|
||||
},
|
||||
{
|
||||
id: "intraday_box_reversion",
|
||||
label: "상승 후 박스권 단타",
|
||||
description:
|
||||
"당일 상승 이후 박스권 횡보 구간에서 상단/하단 왕복(오르락내리락) 단타를 노립니다.",
|
||||
},
|
||||
{
|
||||
id: "intraday_breakout_scalp",
|
||||
label: "상승구간 눌림-재돌파 단타",
|
||||
description:
|
||||
"1분봉 상승 추세에서 저거래량 눌림 후 고점 재돌파(거래량 재유입) 구간을 노립니다.",
|
||||
},
|
||||
];
|
||||
|
||||
export const AUTOTRADE_DEFAULT_TECHNIQUES: AutotradeTechniqueId[] = [
|
||||
"ma_crossover",
|
||||
"vwap_reversion",
|
||||
"intraday_box_reversion",
|
||||
"intraday_breakout_scalp",
|
||||
];
|
||||
|
||||
export type AutotradeEngineState =
|
||||
| "IDLE"
|
||||
| "RUNNING"
|
||||
| "STOPPING"
|
||||
| "STOPPED"
|
||||
| "ERROR";
|
||||
|
||||
export const AUTOTRADE_AI_MODE_IDS = [
|
||||
"auto",
|
||||
"openai_api",
|
||||
"subscription_cli",
|
||||
"rule_fallback",
|
||||
] as const;
|
||||
|
||||
export type AutotradeAiMode = (typeof AUTOTRADE_AI_MODE_IDS)[number];
|
||||
|
||||
export const AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_IDS = [
|
||||
"auto",
|
||||
"codex",
|
||||
"gemini",
|
||||
] as const;
|
||||
|
||||
export type AutotradeSubscriptionCliVendor =
|
||||
(typeof AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_IDS)[number];
|
||||
|
||||
export interface AutotradeSubscriptionCliVendorOption {
|
||||
id: AutotradeSubscriptionCliVendor;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_OPTIONS: AutotradeSubscriptionCliVendorOption[] =
|
||||
[
|
||||
{
|
||||
id: "auto",
|
||||
label: "자동 선택",
|
||||
description: "Codex -> Gemini 순서로 시도합니다.",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex CLI",
|
||||
description: "OpenAI Codex CLI만 사용합니다.",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini CLI",
|
||||
description: "Google Gemini CLI만 사용합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
export interface AutotradeSubscriptionCliModelOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// [출처] 공식 문서 기준 추천 프리셋
|
||||
// - Codex Models: https://developers.openai.com/codex/models
|
||||
// - Gemini CLI model command: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md
|
||||
export const AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS = {
|
||||
codex: [
|
||||
{
|
||||
value: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
description: "Codex 추천 기본형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex",
|
||||
label: "gpt-5.3-codex",
|
||||
description: "Codex 5.3 고성능 라인",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-spark",
|
||||
label: "gpt-5.3-codex-spark",
|
||||
description: "Codex 5.3 경량형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex",
|
||||
label: "gpt-5.2-codex",
|
||||
description: "Codex 5.2 균형형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2",
|
||||
label: "gpt-5.2",
|
||||
description: "Codex 5.2 범용형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max",
|
||||
label: "gpt-5.1-codex-max",
|
||||
description: "문맥 확장형 Codex 5.1",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1",
|
||||
label: "gpt-5.1",
|
||||
description: "Codex 5.1 범용형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex",
|
||||
label: "gpt-5.1-codex",
|
||||
description: "Codex 5.1 기본형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5-codex",
|
||||
label: "gpt-5-codex (안정형)",
|
||||
description: "Codex 안정형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5-codex-mini",
|
||||
label: "gpt-5-codex-mini",
|
||||
description: "Codex 경량형",
|
||||
},
|
||||
{
|
||||
value: "gpt-5",
|
||||
label: "gpt-5",
|
||||
description: "Codex 범용 경량 라인",
|
||||
},
|
||||
] satisfies AutotradeSubscriptionCliModelOption[],
|
||||
gemini: [
|
||||
{
|
||||
value: "auto",
|
||||
label: "auto (권장)",
|
||||
description: "상황에 따라 Pro/Flash 계열을 자동 선택",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.1-pro-preview",
|
||||
label: "gemini-3.1-pro-preview (신규)",
|
||||
description: "Gemini 3.1 고성능 추론/코딩 프리뷰",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.1-flash-lite-preview",
|
||||
label: "gemini-3.1-flash-lite-preview",
|
||||
description: "Gemini 3.1 경량 고속 프리뷰",
|
||||
},
|
||||
{
|
||||
value: "gemini-3-flash-preview",
|
||||
label: "gemini-3-flash-preview",
|
||||
description: "Gemini 3 고속 프리뷰",
|
||||
},
|
||||
{
|
||||
value: "gemini-2.5-pro",
|
||||
label: "gemini-2.5-pro",
|
||||
description: "고난도 추론 중심",
|
||||
},
|
||||
{
|
||||
value: "gemini-2.5-flash",
|
||||
label: "gemini-2.5-flash",
|
||||
description: "속도/품질 균형형",
|
||||
},
|
||||
{
|
||||
value: "gemini-2.5-flash-lite",
|
||||
label: "gemini-2.5-flash-lite",
|
||||
description: "가벼운 작업용 고속 모델",
|
||||
},
|
||||
{
|
||||
value: "gemini-3-pro-preview",
|
||||
label: "gemini-3-pro-preview (종료예정)",
|
||||
description: "공식 문서 기준 2026-03-09 종료 예정 프리뷰",
|
||||
},
|
||||
] satisfies AutotradeSubscriptionCliModelOption[],
|
||||
} as const;
|
||||
|
||||
export interface AutotradeAiModeOption {
|
||||
id: AutotradeAiMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const AUTOTRADE_AI_MODE_OPTIONS: AutotradeAiModeOption[] = [
|
||||
{
|
||||
id: "auto",
|
||||
label: "자동(권장)",
|
||||
description:
|
||||
"OpenAI API 키가 있으면 OpenAI를 사용하고, 없으면 구독형 CLI를 시도합니다. 둘 다 실패하면 규칙 기반으로 전환합니다.",
|
||||
},
|
||||
{
|
||||
id: "openai_api",
|
||||
label: "OpenAI API",
|
||||
description: "서버에서 OpenAI API를 직접 호출해 판단합니다.",
|
||||
},
|
||||
{
|
||||
id: "subscription_cli",
|
||||
label: "구독형 CLI 자동판단",
|
||||
description: "서버에 설치된 Codex/Gemini CLI로 자동 판단을 생성합니다.",
|
||||
},
|
||||
{
|
||||
id: "rule_fallback",
|
||||
label: "규칙 기반",
|
||||
description: "AI 호출 없이 내부 규칙 엔진으로만 판단합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
export type AutotradeStopReason =
|
||||
| "browser_exit"
|
||||
| "external_leave"
|
||||
| "manual"
|
||||
| "emergency"
|
||||
| "heartbeat_timeout";
|
||||
|
||||
export interface AutotradeSetupFormValues {
|
||||
aiMode: AutotradeAiMode;
|
||||
subscriptionCliVendor: AutotradeSubscriptionCliVendor;
|
||||
subscriptionCliModel: string;
|
||||
prompt: string;
|
||||
selectedTechniques: AutotradeTechniqueId[];
|
||||
allocationPercent: number;
|
||||
allocationAmount: number;
|
||||
dailyLossPercent: number;
|
||||
dailyLossAmount: number;
|
||||
confidenceThreshold: number;
|
||||
agreeStopOnExit: boolean;
|
||||
}
|
||||
|
||||
export interface AutotradeCompiledStrategy {
|
||||
provider: "openai" | "fallback" | "subscription_cli";
|
||||
providerVendor?: "codex" | "gemini";
|
||||
providerModel?: string;
|
||||
summary: string;
|
||||
selectedTechniques: AutotradeTechniqueId[];
|
||||
confidenceThreshold: number;
|
||||
maxDailyOrders: number;
|
||||
cooldownSec: number;
|
||||
maxOrderAmountRatio: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AutotradeValidationResult {
|
||||
isValid: boolean;
|
||||
blockedReasons: string[];
|
||||
warnings: string[];
|
||||
cashBalance: number;
|
||||
effectiveAllocationAmount: number;
|
||||
effectiveDailyLossLimit: number;
|
||||
}
|
||||
|
||||
export interface AutotradeMinuteCandle {
|
||||
time: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface AutotradeMinutePatternContext {
|
||||
timeframe: "1m";
|
||||
candleCount: number;
|
||||
impulseDirection: "up" | "down" | "flat";
|
||||
impulseBarCount: number;
|
||||
consolidationBarCount: number;
|
||||
impulseChangeRate?: number;
|
||||
impulseRangePercent?: number;
|
||||
consolidationRangePercent?: number;
|
||||
consolidationCloseClusterPercent?: number;
|
||||
consolidationVolumeRatio?: number;
|
||||
breakoutUpper?: number;
|
||||
breakoutLower?: number;
|
||||
}
|
||||
|
||||
export interface AutotradeBudgetContext {
|
||||
setupAllocationPercent: number;
|
||||
setupAllocationAmount: number;
|
||||
effectiveAllocationAmount: number;
|
||||
strategyMaxOrderAmountRatio: number;
|
||||
effectiveOrderBudgetAmount: number;
|
||||
estimatedBuyUnitCost: number;
|
||||
estimatedBuyableQuantity: number;
|
||||
}
|
||||
|
||||
export interface AutotradePortfolioContext {
|
||||
holdingQuantity: number;
|
||||
sellableQuantity: number;
|
||||
averagePrice: number;
|
||||
estimatedSellableNetAmount?: number;
|
||||
}
|
||||
|
||||
export interface AutotradeExecutionCostProfileSnapshot {
|
||||
buyFeeRate: number;
|
||||
sellFeeRate: number;
|
||||
sellTaxRate: number;
|
||||
}
|
||||
|
||||
export interface AutotradeSessionInfo {
|
||||
sessionId: string;
|
||||
symbol: string;
|
||||
runtimeState: "RUNNING" | "STOPPED";
|
||||
leaderTabId: string;
|
||||
startedAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
endedAt: string | null;
|
||||
stopReason: AutotradeStopReason | null;
|
||||
effectiveAllocationAmount: number;
|
||||
effectiveDailyLossLimit: number;
|
||||
}
|
||||
|
||||
export interface AutotradeMarketSnapshot {
|
||||
symbol: string;
|
||||
stockName?: string;
|
||||
market?: "KOSPI" | "KOSDAQ";
|
||||
requestAtIso?: string;
|
||||
requestAtKst?: string;
|
||||
tickTime?: string;
|
||||
executionClassCode?: string;
|
||||
isExpected?: boolean;
|
||||
trId?: string;
|
||||
currentPrice: number;
|
||||
prevClose?: number;
|
||||
changeRate: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
tradeVolume: number;
|
||||
accumulatedVolume: number;
|
||||
tradeStrength?: number;
|
||||
askPrice1?: number;
|
||||
bidPrice1?: number;
|
||||
askSize1?: number;
|
||||
bidSize1?: number;
|
||||
totalAskSize?: number;
|
||||
totalBidSize?: number;
|
||||
buyExecutionCount?: number;
|
||||
sellExecutionCount?: number;
|
||||
netBuyExecutionCount?: number;
|
||||
spread?: number;
|
||||
spreadRate?: number;
|
||||
dayRangePercent?: number;
|
||||
dayRangePosition?: number;
|
||||
volumeRatio?: number;
|
||||
recentTradeCount?: number;
|
||||
recentTradeVolumeSum?: number;
|
||||
recentAverageTradeVolume?: number;
|
||||
accumulatedVolumeDelta?: number;
|
||||
netBuyExecutionDelta?: number;
|
||||
orderBookImbalance?: number;
|
||||
liquidityDepth?: number;
|
||||
topLevelOrderBookImbalance?: number;
|
||||
buySellExecutionRatio?: number;
|
||||
recentPriceHigh?: number;
|
||||
recentPriceLow?: number;
|
||||
recentPriceRangePercent?: number;
|
||||
recentTradeVolumes?: number[];
|
||||
recentNetBuyTrail?: number[];
|
||||
recentTickAgesSec?: number[];
|
||||
intradayMomentum?: number;
|
||||
recentReturns?: number[];
|
||||
recentPrices: number[];
|
||||
marketDataLatencySec?: number;
|
||||
recentMinuteCandles?: AutotradeMinuteCandle[];
|
||||
minutePatternContext?: AutotradeMinutePatternContext;
|
||||
budgetContext?: AutotradeBudgetContext;
|
||||
portfolioContext?: AutotradePortfolioContext;
|
||||
executionCostProfile?: AutotradeExecutionCostProfileSnapshot;
|
||||
}
|
||||
|
||||
export interface AutotradeProposedOrder {
|
||||
symbol: string;
|
||||
side: "buy" | "sell";
|
||||
orderType: "limit" | "market";
|
||||
price?: number;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export interface AutotradeSignalCandidate {
|
||||
signal: "buy" | "sell" | "hold";
|
||||
confidence: number;
|
||||
reason: string;
|
||||
ttlSec: number;
|
||||
riskFlags: string[];
|
||||
proposedOrder?: AutotradeProposedOrder;
|
||||
source: "openai" | "fallback" | "subscription_cli";
|
||||
providerVendor?: "codex" | "gemini";
|
||||
providerModel?: string;
|
||||
}
|
||||
|
||||
export interface AutotradeRuntimeLog {
|
||||
id: string;
|
||||
level: "info" | "warning" | "error";
|
||||
stage?:
|
||||
| "session"
|
||||
| "strategy_compile"
|
||||
| "strategy_validate"
|
||||
| "signal_request"
|
||||
| "signal_response"
|
||||
| "risk_gate"
|
||||
| "order_execution"
|
||||
| "order_blocked"
|
||||
| "provider_fallback"
|
||||
| "engine_error";
|
||||
message: string;
|
||||
detail?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AutotradeCompileResponse {
|
||||
ok: boolean;
|
||||
compiledStrategy: AutotradeCompiledStrategy;
|
||||
}
|
||||
|
||||
export interface AutotradeValidateResponse {
|
||||
ok: boolean;
|
||||
validation: AutotradeValidationResult;
|
||||
}
|
||||
|
||||
export interface AutotradeSessionResponse {
|
||||
ok: boolean;
|
||||
session: AutotradeSessionInfo;
|
||||
}
|
||||
|
||||
export interface AutotradeSignalResponse {
|
||||
ok: boolean;
|
||||
signal: AutotradeSignalCandidate;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardBalanceResponse,
|
||||
DashboardIndicesResponse,
|
||||
DashboardMarketHubResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
@@ -92,3 +93,31 @@ export async function fetchDashboardActivity(
|
||||
|
||||
return payload as DashboardActivityResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 급등/인기/뉴스 시장 허브 데이터를 조회합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 시장 허브 응답
|
||||
* @see app/api/kis/domestic/market-hub/route.ts 서버 라우트
|
||||
*/
|
||||
export async function fetchDashboardMarketHub(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardMarketHubResponse> {
|
||||
const response = await fetch("/api/kis/domestic/market-hub", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardMarketHubResponse
|
||||
| KisApiErrorPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
resolveKisApiErrorMessage(payload, "시장 허브 조회 중 오류가 발생했습니다."),
|
||||
);
|
||||
}
|
||||
|
||||
return payload as DashboardMarketHubResponse;
|
||||
}
|
||||
|
||||
@@ -46,15 +46,15 @@ export function ActivitySection({
|
||||
const warnings = activity?.warnings ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
<CardHeader className="pb-3">
|
||||
{/* ========== TITLE ========== */}
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardList className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
주문내역 · 매매일지
|
||||
매수 · 매도 기록 (주문내역/매매일지)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
최근 주문 체결 내역과 실현손익 기록을 확인합니다.
|
||||
최근 매수/매도 주문 흐름과 실현손익을 한 번에 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -106,9 +106,19 @@ export function ActivitySection({
|
||||
|
||||
{/* ========== TABS ========== */}
|
||||
<Tabs defaultValue="orders" className="gap-3">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="orders">주문내역 {orders.length}건</TabsTrigger>
|
||||
<TabsTrigger value="journal">매매일지 {journalRows.length}건</TabsTrigger>
|
||||
<TabsList className="h-auto w-full justify-start rounded-xl border border-brand-200/70 bg-background/80 p-1 dark:border-brand-800/50 dark:bg-background/60">
|
||||
<TabsTrigger
|
||||
value="orders"
|
||||
className="h-9 rounded-lg px-3 data-[state=active]:bg-brand-600 data-[state=active]:text-white"
|
||||
>
|
||||
주문내역 {orders.length}건
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="journal"
|
||||
className="h-9 rounded-lg px-3 data-[state=active]:bg-brand-600 data-[state=active]:text-white"
|
||||
>
|
||||
매매일지 {journalRows.length}건
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="orders">
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { BriefcaseBusiness, Gauge, Sparkles } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
|
||||
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
|
||||
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
|
||||
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
|
||||
import { MarketHubSection } from "@/features/dashboard/components/MarketHubSection";
|
||||
import { MarketSummary } from "@/features/dashboard/components/MarketSummary";
|
||||
import { StatusHeader } from "@/features/dashboard/components/StatusHeader";
|
||||
import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview";
|
||||
@@ -70,6 +73,8 @@ export function DashboardContainer() {
|
||||
activityError,
|
||||
balanceError,
|
||||
indicesError,
|
||||
marketHub,
|
||||
marketHubError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
} = useDashboardData(canAccess ? verifiedCredentials : null);
|
||||
@@ -125,6 +130,15 @@ export function DashboardContainer() {
|
||||
wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming,
|
||||
);
|
||||
const effectiveIndicesError = indices.length === 0 ? indicesError : null;
|
||||
const restStatusLabel = isKisRestConnected ? "REST 정상" : "REST 점검 필요";
|
||||
const realtimeStatusLabel = isWsConnected
|
||||
? isRealtimePending
|
||||
? "실시간 대기중"
|
||||
: "실시간 수신중"
|
||||
: "실시간 미연결";
|
||||
const profileStatusLabel = isKisProfileVerified
|
||||
? "계좌 인증 완료"
|
||||
: "계좌 인증 필요";
|
||||
const indicesWarning =
|
||||
indices.length > 0 && indicesError
|
||||
? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다."
|
||||
@@ -181,71 +195,161 @@ export function DashboardContainer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||
{/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */}
|
||||
<StatusHeader
|
||||
summary={mergedSummary}
|
||||
isKisRestConnected={isKisRestConnected}
|
||||
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
|
||||
isRealtimePending={isRealtimePending}
|
||||
isProfileVerified={isKisProfileVerified}
|
||||
verifiedAccountNo={verifiedAccountNo}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
onRefresh={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
<section className="relative mx-auto flex w-full max-w-7xl flex-col gap-4 overflow-hidden p-4 md:p-6">
|
||||
<div className="pointer-events-none absolute -left-24 top-10 h-52 w-52 rounded-full bg-brand-400/15 blur-3xl dark:bg-brand-600/15" />
|
||||
<div className="pointer-events-none absolute -right-28 top-36 h-64 w-64 rounded-full bg-brand-300/20 blur-3xl dark:bg-brand-700/20" />
|
||||
|
||||
{/* ========== 메인 그리드 구성 ========== */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
{/* 왼쪽 섹션: 보유 종목 목록 리스트 */}
|
||||
<HoldingsList
|
||||
holdings={mergedHoldings}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isLoading={isLoading}
|
||||
error={balanceError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
onSelect={setSelectedSymbol}
|
||||
/>
|
||||
<div className="relative rounded-3xl border border-brand-200/70 bg-linear-to-br from-brand-100/70 via-brand-50/30 to-background p-4 shadow-sm dark:border-brand-800/50 dark:from-brand-900/35 dark:via-brand-950/15">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="inline-flex items-center gap-1.5 rounded-full border border-brand-300/70 bg-background/80 px-3 py-1 text-[11px] font-semibold tracking-wide text-brand-700 dark:border-brand-700 dark:bg-brand-950/50 dark:text-brand-300">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
TRADING OVERVIEW
|
||||
</p>
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground md:text-2xl">
|
||||
시장과 내 자산을 한 화면에서 빠르게 확인하세요
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
핵심 지표를 상단에 모으고, 시장 흐름과 자산 상태를 탭으로 분리했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */}
|
||||
<div className="grid gap-4">
|
||||
{/* 시장 지수 현황 (코스피/코스닥) */}
|
||||
<MarketSummary
|
||||
items={indices}
|
||||
isLoading={isLoading}
|
||||
error={effectiveIndicesError}
|
||||
warning={indicesWarning}
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<TopStatusPill label="서버" value={restStatusLabel} ok={isKisRestConnected} />
|
||||
<TopStatusPill
|
||||
label="실시간"
|
||||
value={realtimeStatusLabel}
|
||||
ok={isWsConnected}
|
||||
warn={isRealtimePending}
|
||||
/>
|
||||
<TopStatusPill
|
||||
label="계좌"
|
||||
value={profileStatusLabel}
|
||||
ok={isKisProfileVerified}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="assets" className="relative gap-4">
|
||||
<TabsList className="h-auto w-full justify-start rounded-2xl border border-brand-200/80 bg-background/90 p-1 backdrop-blur-sm dark:border-brand-800/50 dark:bg-background/60">
|
||||
<TabsTrigger
|
||||
value="market"
|
||||
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
|
||||
>
|
||||
<Gauge className="h-4 w-4" />
|
||||
시장 인사이트
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="assets"
|
||||
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
|
||||
>
|
||||
<BriefcaseBusiness className="h-4 w-4" />
|
||||
내 자산
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="market" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
|
||||
<div className="grid gap-4 xl:grid-cols-[1.05fr_1.45fr]">
|
||||
<MarketSummary
|
||||
items={indices}
|
||||
isLoading={isLoading}
|
||||
error={effectiveIndicesError}
|
||||
warning={indicesWarning}
|
||||
isWebSocketReady={isWsConnected}
|
||||
isRealtimePending={isRealtimePending}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
<MarketHubSection
|
||||
marketHub={marketHub}
|
||||
isLoading={isLoading}
|
||||
error={marketHubError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="assets" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
|
||||
<StatusHeader
|
||||
summary={mergedSummary}
|
||||
isKisRestConnected={isKisRestConnected}
|
||||
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
|
||||
isRealtimePending={isRealtimePending}
|
||||
onRetry={() => {
|
||||
isProfileVerified={isKisProfileVerified}
|
||||
verifiedAccountNo={verifiedAccountNo}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
onRefresh={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 선택된 종목의 실시간 상세 요약 정보 */}
|
||||
<StockDetailPreview
|
||||
holding={realtimeSelectedHolding}
|
||||
totalAmount={mergedSummary?.totalAmount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
|
||||
<div className="space-y-4">
|
||||
<ActivitySection
|
||||
activity={activity}
|
||||
isLoading={isLoading}
|
||||
error={activityError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */}
|
||||
<ActivitySection
|
||||
activity={activity}
|
||||
isLoading={isLoading}
|
||||
error={activityError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
<HoldingsList
|
||||
holdings={mergedHoldings}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isLoading={isLoading}
|
||||
error={balanceError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
onSelect={setSelectedSymbol}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:sticky xl:top-5 xl:self-start">
|
||||
<StockDetailPreview
|
||||
holding={realtimeSelectedHolding}
|
||||
totalAmount={mergedSummary?.totalAmount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TopStatusPill({
|
||||
label,
|
||||
value,
|
||||
ok,
|
||||
warn = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
ok: boolean;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
const toneClass = ok
|
||||
? warn
|
||||
? "border-amber-300/70 bg-amber-50/70 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
: "border-emerald-300/70 bg-emerald-50/70 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
||||
: "border-red-300/70 bg-red-50/70 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300";
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border px-3 py-2 ${toneClass}`}>
|
||||
<p className="text-[11px] font-medium opacity-80">{label}</p>
|
||||
<p className="text-xs font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다.
|
||||
* @param realtimeIndices 실시간 지수 맵
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { AlertCircle, Wallet2 } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
|
||||
@@ -59,8 +61,22 @@ export function HoldingsList({
|
||||
onRetry,
|
||||
onSelect,
|
||||
}: HoldingsListProps) {
|
||||
const router = useRouter();
|
||||
const setPendingTarget = useTradeNavigationStore(
|
||||
(state) => state.setPendingTarget,
|
||||
);
|
||||
|
||||
const handleNavigateToTrade = (holding: DashboardHoldingItem) => {
|
||||
setPendingTarget({
|
||||
symbol: holding.symbol,
|
||||
name: holding.name,
|
||||
market: holding.market,
|
||||
});
|
||||
router.push("/trade");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<Card className="h-full border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -113,7 +129,7 @@ export function HoldingsList({
|
||||
|
||||
{/* 종목 리스트 렌더링 영역 */}
|
||||
{holdings.length > 0 && (
|
||||
<ScrollArea className="h-[420px] pr-3">
|
||||
<ScrollArea className="h-[360px] pr-3">
|
||||
<div className="space-y-2">
|
||||
{holdings.map((holding) => (
|
||||
<HoldingItemRow
|
||||
@@ -121,6 +137,7 @@ export function HoldingsList({
|
||||
holding={holding}
|
||||
isSelected={selectedSymbol === holding.symbol}
|
||||
onSelect={onSelect}
|
||||
onNavigateToTrade={handleNavigateToTrade}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -138,6 +155,8 @@ interface HoldingItemRowProps {
|
||||
isSelected: boolean;
|
||||
/** 클릭 핸들러 */
|
||||
onSelect: (symbol: string) => void;
|
||||
/** 거래 페이지 이동 핸들러 */
|
||||
onNavigateToTrade: (holding: DashboardHoldingItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +171,7 @@ function HoldingItemRow({
|
||||
holding,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onNavigateToTrade,
|
||||
}: HoldingItemRowProps) {
|
||||
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
|
||||
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
|
||||
@@ -163,13 +183,16 @@ function HoldingItemRow({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출
|
||||
onClick={() => onSelect(holding.symbol)}
|
||||
// [Step 1] 종목 클릭 시 선택 상태 갱신 후 거래 화면으로 이동
|
||||
onClick={() => {
|
||||
onSelect(holding.symbol);
|
||||
onNavigateToTrade(holding);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden",
|
||||
"relative w-full overflow-hidden rounded-xl border px-3 py-3 text-left shadow-sm transition-all",
|
||||
isSelected
|
||||
? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700"
|
||||
: "border-border/70 bg-background hover:border-brand-200 hover:bg-brand-50/30 dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
|
||||
: "border-border/70 bg-background hover:-translate-y-0.5 hover:border-brand-200 hover:bg-brand-50/30 hover:shadow-md dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
|
||||
)}
|
||||
>
|
||||
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
|
||||
@@ -180,7 +203,8 @@ function HoldingItemRow({
|
||||
{holding.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{holding.symbol} · {holding.market} · {holding.quantity}주
|
||||
{holding.symbol} · {holding.market} · 보유 {holding.quantity}주 · 매도가능{" "}
|
||||
{holding.sellableQuantity}주
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
348
features/dashboard/components/MarketHubSection.tsx
Normal file
348
features/dashboard/components/MarketHubSection.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Flame,
|
||||
Newspaper,
|
||||
RefreshCcw,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type {
|
||||
DashboardMarketHubResponse,
|
||||
DashboardMarketRankItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatSignedCurrency,
|
||||
formatSignedPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarketHubSectionProps {
|
||||
marketHub: DashboardMarketHubResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 시장 탭의 급등/인기/뉴스 요약 섹션입니다.
|
||||
* @remarks UI 흐름: DashboardContainer -> MarketHubSection -> 급등/인기/뉴스 카드 렌더링
|
||||
*/
|
||||
export function MarketHubSection({
|
||||
marketHub,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
}: MarketHubSectionProps) {
|
||||
const router = useRouter();
|
||||
const setPendingTarget = useTradeNavigationStore(
|
||||
(state) => state.setPendingTarget,
|
||||
);
|
||||
const gainers = marketHub?.gainers ?? [];
|
||||
const losers = marketHub?.losers ?? [];
|
||||
const popularByVolume = marketHub?.popularByVolume ?? [];
|
||||
const popularByValue = marketHub?.popularByValue ?? [];
|
||||
const news = marketHub?.news ?? [];
|
||||
const warnings = marketHub?.warnings ?? [];
|
||||
const pulse = marketHub?.pulse;
|
||||
|
||||
const navigateToTrade = (item: DashboardMarketRankItem) => {
|
||||
setPendingTarget({
|
||||
symbol: item.symbol,
|
||||
name: item.name,
|
||||
market: item.market,
|
||||
});
|
||||
router.push("/trade");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Card className="border-brand-200/80 bg-linear-to-br from-brand-100/60 via-brand-50/20 to-background shadow-sm dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
시장 허브
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
급등/급락, 인기 종목, 주요 뉴스를 한 화면에서 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<PulseMetric label="급등주" value={`${pulse?.gainersCount ?? 0}개`} tone="up" />
|
||||
<PulseMetric label="급락주" value={`${pulse?.losersCount ?? 0}개`} tone="down" />
|
||||
<PulseMetric
|
||||
label="인기종목(거래량)"
|
||||
value={`${pulse?.popularByVolumeCount ?? 0}개`}
|
||||
tone="neutral"
|
||||
/>
|
||||
<PulseMetric
|
||||
label="거래대금 상위"
|
||||
value={`${pulse?.popularByValueCount ?? 0}개`}
|
||||
tone="neutral"
|
||||
/>
|
||||
<PulseMetric label="주요 뉴스" value={`${pulse?.newsCount ?? 0}건`} tone="brand" />
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{warnings.map((warning) => (
|
||||
<Badge
|
||||
key={warning}
|
||||
variant="outline"
|
||||
className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{warning}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !marketHub && (
|
||||
<p className="text-sm text-muted-foreground">시장 허브를 불러오는 중입니다.</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
||||
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
시장 허브 다시 불러오기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<RankListCard
|
||||
title="급등주식"
|
||||
description="전일 대비 상승률 상위 종목"
|
||||
icon={Flame}
|
||||
items={gainers}
|
||||
tone="up"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래량"
|
||||
secondaryValue={(item) => `${formatCurrency(item.volume)}주`}
|
||||
/>
|
||||
<RankListCard
|
||||
title="급락주식"
|
||||
description="전일 대비 하락률 상위 종목"
|
||||
icon={TrendingDown}
|
||||
items={losers}
|
||||
tone="down"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래량"
|
||||
secondaryValue={(item) => `${formatCurrency(item.volume)}주`}
|
||||
/>
|
||||
<RankListCard
|
||||
title="인기종목"
|
||||
description="거래량 상위 종목"
|
||||
icon={BarChart3}
|
||||
items={popularByVolume}
|
||||
tone="neutral"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래량"
|
||||
secondaryValue={(item) => `${formatCurrency(item.volume)}주`}
|
||||
/>
|
||||
<RankListCard
|
||||
title="거래대금 상위"
|
||||
description="거래대금 상위 종목"
|
||||
icon={TrendingUp}
|
||||
items={popularByValue}
|
||||
tone="brand"
|
||||
onSelectItem={navigateToTrade}
|
||||
secondaryLabel="거래대금"
|
||||
secondaryValue={(item) => `${formatCurrency(item.tradingValue)}원`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="border-brand-200/70 bg-background/90 dark:border-brand-800/45">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Newspaper className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
주요 뉴스
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
국내 시장 시황/공시 제목을 최신순으로 보여줍니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[220px] pr-3">
|
||||
{news.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">표시할 뉴스가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{news.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-xl border border-border/70 bg-linear-to-br from-background to-brand-50/30 px-3 py-2 dark:from-background dark:to-brand-950/20"
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{item.source} · {item.publishedAt}
|
||||
</p>
|
||||
{item.symbols.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{item.symbols.slice(0, 3).map((symbol) => (
|
||||
<Badge
|
||||
key={`${item.id}-${symbol}`}
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{symbol}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PulseMetric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "up" | "down" | "neutral" | "brand";
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "up"
|
||||
? "border-red-200/70 bg-red-50/70 dark:border-red-900/40 dark:bg-red-950/20"
|
||||
: tone === "down"
|
||||
? "border-blue-200/70 bg-blue-50/70 dark:border-blue-900/40 dark:bg-blue-950/20"
|
||||
: tone === "brand"
|
||||
? "border-brand-200/70 bg-brand-50/70 dark:border-brand-700/60 dark:bg-brand-900/30"
|
||||
: "border-border/70 bg-background/80";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-xl border p-3", toneClass)}>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankListCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
items,
|
||||
tone,
|
||||
onSelectItem,
|
||||
secondaryLabel,
|
||||
secondaryValue,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
items: DashboardMarketRankItem[];
|
||||
tone: "up" | "down" | "neutral" | "brand";
|
||||
onSelectItem: (item: DashboardMarketRankItem) => void;
|
||||
secondaryLabel: string;
|
||||
secondaryValue: (item: DashboardMarketRankItem) => string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "up"
|
||||
? "border-red-200/70 bg-red-50/35 dark:border-red-900/35 dark:bg-red-950/15"
|
||||
: tone === "down"
|
||||
? "border-blue-200/70 bg-blue-50/35 dark:border-blue-900/35 dark:bg-blue-950/15"
|
||||
: tone === "brand"
|
||||
? "border-brand-200/70 bg-brand-50/35 dark:border-brand-800/50 dark:bg-brand-900/20"
|
||||
: "border-border/70 bg-background/90";
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden shadow-sm", toneClass)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[220px] pr-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">표시할 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const toneClass = getChangeToneClass(item.change);
|
||||
return (
|
||||
<div
|
||||
key={`${title}-${item.symbol}-${item.rank}`}
|
||||
className="rounded-xl border border-border/70 bg-background/80 px-3 py-2 shadow-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectItem(item)}
|
||||
className="w-full text-left hover:opacity-90"
|
||||
title={`${item.name} 거래 화면으로 이동`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">{item.name}</p>
|
||||
<p className={cn("text-xs font-medium", toneClass)}>
|
||||
{formatSignedPercent(item.changeRate)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
#{item.rank} · {item.symbol} · {item.market}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
현재가 {formatCurrency(item.price)}원
|
||||
</span>
|
||||
<span className={cn("font-medium", toneClass)}>
|
||||
{formatSignedCurrency(item.change)}원
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{secondaryLabel} {secondaryValue(item)}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -22,6 +23,7 @@ interface MarketSummaryProps {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
warning?: string | null;
|
||||
isWebSocketReady?: boolean;
|
||||
isRealtimePending?: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
@@ -35,22 +37,46 @@ export function MarketSummary({
|
||||
isLoading,
|
||||
error,
|
||||
warning = null,
|
||||
isWebSocketReady = false,
|
||||
isRealtimePending = false,
|
||||
onRetry,
|
||||
}: MarketSummaryProps) {
|
||||
const realtimeBadgeText = isRealtimePending
|
||||
? "실시간 대기중"
|
||||
: isWebSocketReady
|
||||
? "실시간 수신중"
|
||||
: items.length > 0
|
||||
? "REST 데이터"
|
||||
: "데이터 준비중";
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/50 to-background dark:border-brand-800/45 dark:from-brand-950/20 dark:to-background">
|
||||
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-100/65 via-brand-50/30 to-background shadow-sm dark:border-brand-800/45 dark:from-brand-900/35 dark:via-brand-950/20 dark:to-background">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
시장 지수
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-brand-300/70 bg-background/80 text-[11px] font-medium dark:border-brand-700/60 dark:bg-brand-950/30",
|
||||
isRealtimePending
|
||||
? "text-amber-700 dark:text-amber-300"
|
||||
: isWebSocketReady
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{realtimeBadgeText}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>실시간 코스피/코스닥 지수 현황입니다.</CardDescription>
|
||||
<CardDescription>
|
||||
코스피/코스닥 핵심 지수와 전일 대비 흐름을 빠르게 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
{/* ========== LOADING STATE ========== */}
|
||||
{isLoading && items.length === 0 && (
|
||||
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
|
||||
@@ -133,23 +159,23 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
: "text-muted-foreground";
|
||||
|
||||
const bgClass = isUp
|
||||
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
|
||||
? "bg-linear-to-br from-red-50/90 to-background dark:from-red-950/20 dark:to-background border-red-100/80 dark:border-red-900/40"
|
||||
: isDown
|
||||
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
|
||||
: "bg-muted/50 border-border/50";
|
||||
? "bg-linear-to-br from-blue-50/90 to-background dark:from-blue-950/20 dark:to-background border-blue-100/80 dark:border-blue-900/40"
|
||||
: "bg-linear-to-br from-muted/60 to-background border-border/50";
|
||||
|
||||
const flash = usePriceFlash(item.price, item.code);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80",
|
||||
"relative flex flex-col justify-between rounded-2xl border p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md",
|
||||
bgClass,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{item.market}
|
||||
{item.name} ({item.market})
|
||||
</span>
|
||||
{isUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500/70" />
|
||||
@@ -158,7 +184,7 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
|
||||
<div className="relative mt-2 w-fit text-2xl font-bold tracking-tight">
|
||||
{formatCurrency(item.price)}
|
||||
|
||||
{/* Flash Indicator */}
|
||||
@@ -176,14 +202,9 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-2 text-sm font-medium",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
<div className={cn("mt-2 flex items-center gap-2 text-sm font-medium", toneClass)}>
|
||||
<span>{formatSignedCurrency(item.change)}</span>
|
||||
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm">
|
||||
<span className="rounded-md bg-background/70 px-1.5 py-0.5 text-xs shadow-sm">
|
||||
{formatSignedPercent(item.changeRate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -58,153 +59,155 @@ export function StatusHeader({
|
||||
? "수신 대기중"
|
||||
: "연결됨"
|
||||
: "미연결";
|
||||
const displayGrossTotalAmount = hasApiTotalAmount
|
||||
? summary?.apiReportedTotalAmount ?? 0
|
||||
: summary?.totalAmount ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50">
|
||||
{/* ========== BACKGROUND DECORATION ========== */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-r from-brand-100/70 via-brand-50/50 to-transparent dark:from-brand-900/35 dark:via-brand-900/20" />
|
||||
<Card className="relative overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/80 via-background to-brand-50/20 shadow-sm dark:border-brand-800/45 dark:from-brand-900/25 dark:via-background dark:to-background">
|
||||
<div className="pointer-events-none absolute -right-14 -top-14 h-52 w-52 rounded-full bg-brand-300/30 blur-3xl dark:bg-brand-700/20" />
|
||||
<div className="pointer-events-none absolute -left-16 bottom-0 h-44 w-44 rounded-full bg-brand-200/25 blur-3xl dark:bg-brand-800/20" />
|
||||
|
||||
<CardContent className="relative grid gap-3 p-4 md:grid-cols-[1fr_1fr_1fr_auto]">
|
||||
{/* ========== TOTAL ASSET ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">내 자산 (순자산 실시간)</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight">
|
||||
{summary ? `${formatCurrency(summary.totalAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현금(예수금) {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
주식 평가금{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
총예수금(KIS){" "}
|
||||
{summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||
총예수금은 결제 대기 금액이 포함될 수 있어 체감 현금과 다를 수 있습니다.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
순자산(대출 반영){" "}
|
||||
{summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
||||
</p>
|
||||
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
KIS 집계 총자산 {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원
|
||||
<CardContent className="relative space-y-3 p-4 md:p-5">
|
||||
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_auto]">
|
||||
<div className="rounded-2xl border border-brand-200/70 bg-background/85 p-4 shadow-sm dark:border-brand-800/60 dark:bg-brand-950/20">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
TOTAL ASSET
|
||||
</p>
|
||||
) : null}
|
||||
{hasApiNetAssetAmount ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
||||
<p className="mt-2 text-2xl font-bold tracking-tight text-foreground md:text-3xl">
|
||||
{summary ? `${formatCurrency(displayGrossTotalAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
순자산 {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현금 {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"} · 평가금{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ========== PROFIT/LOSS ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">현재 손익</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-xl font-semibold tracking-tight",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||
</p>
|
||||
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
|
||||
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현재 평가금액{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
총 매수금액{" "}
|
||||
{summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== CONNECTION STATUS ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">연결 상태</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isKisRestConnected
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
서버 {isKisRestConnected ? "연결됨" : "연결 끊김"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isWebSocketReady
|
||||
? isRealtimePending
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
|
||||
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
실시간 시세 {realtimeStatusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isProfileVerified
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
계좌 인증 {isProfileVerified ? "완료" : "미완료"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
마지막 업데이트 {updatedLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
계좌 {maskAccountNo(verifiedAccountNo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
대출금 {summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}
|
||||
</p>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/85 p-4 shadow-sm">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
TODAY P/L
|
||||
</p>
|
||||
<p className={cn("mt-2 text-2xl font-bold tracking-tight md:text-3xl", toneClass)}>
|
||||
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||
</p>
|
||||
<p className={cn("mt-1 text-sm font-semibold", toneClass)}>
|
||||
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
매수금 {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"} · 대출금{" "}
|
||||
{summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
|
||||
>
|
||||
<RefreshCcw className={cn("mr-2 h-4 w-4", isRefreshing ? "animate-spin" : "")} />
|
||||
다시 불러오기
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
|
||||
>
|
||||
<Link href="/settings">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
연결 설정
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="mt-1 rounded-xl border border-border/70 bg-muted/30 px-2.5 py-2 text-[11px] text-muted-foreground">
|
||||
<p>업데이트 {updatedLabel}</p>
|
||||
<p>계좌 {maskAccountNo(verifiedAccountNo)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== QUICK ACTIONS ========== */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={cn("h-4 w-4 mr-2", isRefreshing ? "animate-spin" : "")}
|
||||
/>
|
||||
지금 다시 불러오기
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
|
||||
>
|
||||
<Link href="/settings">
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
연결 설정
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<OverviewMetric
|
||||
icon={<Wifi className="h-3.5 w-3.5" />}
|
||||
label="REST 연결"
|
||||
value={isKisRestConnected ? "정상" : "끊김"}
|
||||
toneClass={
|
||||
isKisRestConnected
|
||||
? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
||||
: "border-red-300/70 bg-red-50/60 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300"
|
||||
}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Activity className="h-3.5 w-3.5" />}
|
||||
label="실시간 시세"
|
||||
value={realtimeStatusLabel}
|
||||
toneClass={
|
||||
isWebSocketReady
|
||||
? isRealtimePending
|
||||
? "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
: "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
||||
: "border-slate-300/70 bg-slate-50/60 text-slate-700 dark:border-slate-700/50 dark:bg-slate-900/30 dark:text-slate-300"
|
||||
}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Activity className="h-3.5 w-3.5" />}
|
||||
label="계좌 인증"
|
||||
value={isProfileVerified ? "완료" : "미완료"}
|
||||
toneClass={
|
||||
isProfileVerified
|
||||
? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
||||
: "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Activity className="h-3.5 w-3.5" />}
|
||||
label="총예수금(KIS)"
|
||||
value={summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"}
|
||||
toneClass="border-brand-200/80 bg-brand-50/70 text-brand-700 dark:border-brand-700/60 dark:bg-brand-900/35 dark:text-brand-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
내부 계산 총자산 {formatCurrency(summary?.totalAmount ?? 0)}원 · KIS 총자산{" "}
|
||||
{formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
{hasApiNetAssetAmount ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
toneClass,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
toneClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-xl border px-3 py-2", toneClass)}>
|
||||
<p className="flex items-center gap-1 text-[11px] font-medium opacity-85">
|
||||
{icon}
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 계좌번호를 마스킹해 표시합니다.
|
||||
* @param value 계좌번호(8-2)
|
||||
|
||||
@@ -57,7 +57,7 @@ export function StockDetailPreview({
|
||||
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
|
||||
if (!holding) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
@@ -86,7 +86,7 @@ export function StockDetailPreview({
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
|
||||
{/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */}
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -131,6 +131,10 @@ export function StockDetailPreview({
|
||||
label="보유 수량"
|
||||
value={`${holding.quantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<Metric
|
||||
label="매도가능 수량"
|
||||
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<Metric
|
||||
label="매입 평균가"
|
||||
value={`${formatCurrency(holding.averagePrice)}원`}
|
||||
@@ -171,7 +175,7 @@ export function StockDetailPreview({
|
||||
</div>
|
||||
|
||||
{/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3">
|
||||
<div className="rounded-xl border border-dashed border-brand-300/60 bg-brand-50/40 p-3 dark:border-brand-700/50 dark:bg-brand-900/20">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
|
||||
<MousePointerClick className="h-4 w-4 text-brand-500" />
|
||||
빠른 주문(준비 중)
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
fetchDashboardActivity,
|
||||
fetchDashboardBalance,
|
||||
fetchDashboardIndices,
|
||||
fetchDashboardMarketHub,
|
||||
} from "@/features/dashboard/apis/dashboard.api";
|
||||
import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardBalanceResponse,
|
||||
DashboardIndicesResponse,
|
||||
DashboardMarketHubResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface UseDashboardDataResult {
|
||||
@@ -24,6 +26,8 @@ interface UseDashboardDataResult {
|
||||
activityError: string | null;
|
||||
balanceError: string | null;
|
||||
indicesError: string | null;
|
||||
marketHub: DashboardMarketHubResponse | null;
|
||||
marketHubError: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -50,6 +54,8 @@ export function useDashboardData(
|
||||
const [activityError, setActivityError] = useState<string | null>(null);
|
||||
const [balanceError, setBalanceError] = useState<string | null>(null);
|
||||
const [indicesError, setIndicesError] = useState<string | null>(null);
|
||||
const [marketHub, setMarketHub] = useState<DashboardMarketHubResponse | null>(null);
|
||||
const [marketHubError, setMarketHubError] = useState<string | null>(null);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||
|
||||
const requestSeqRef = useRef(0);
|
||||
@@ -78,6 +84,7 @@ export function useDashboardData(
|
||||
Promise<DashboardBalanceResponse | null>,
|
||||
Promise<DashboardIndicesResponse>,
|
||||
Promise<DashboardActivityResponse | null>,
|
||||
Promise<DashboardMarketHubResponse>,
|
||||
] = [
|
||||
hasAccountNo
|
||||
? fetchDashboardBalance(credentials)
|
||||
@@ -86,9 +93,15 @@ export function useDashboardData(
|
||||
hasAccountNo
|
||||
? fetchDashboardActivity(credentials)
|
||||
: Promise.resolve(null),
|
||||
fetchDashboardMarketHub(credentials),
|
||||
];
|
||||
|
||||
const [balanceResult, indicesResult, activityResult] = await Promise.allSettled(tasks);
|
||||
const [
|
||||
balanceResult,
|
||||
indicesResult,
|
||||
activityResult,
|
||||
marketHubResult,
|
||||
] = await Promise.allSettled(tasks);
|
||||
if (requestSeq !== requestSeqRef.current) return;
|
||||
|
||||
let hasAnySuccess = false;
|
||||
@@ -136,6 +149,18 @@ export function useDashboardData(
|
||||
setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (marketHubResult.status === "fulfilled") {
|
||||
hasAnySuccess = true;
|
||||
setMarketHub(marketHubResult.value);
|
||||
setMarketHubError(null);
|
||||
} else {
|
||||
setMarketHubError(
|
||||
marketHubResult.reason instanceof Error
|
||||
? marketHubResult.reason.message
|
||||
: "시장 허브 조회에 실패했습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasAnySuccess) {
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
}
|
||||
@@ -192,6 +217,8 @@ export function useDashboardData(
|
||||
activityError,
|
||||
balanceError,
|
||||
indicesError,
|
||||
marketHub,
|
||||
marketHubError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface DashboardHoldingItem {
|
||||
name: string;
|
||||
market: DashboardMarket;
|
||||
quantity: number;
|
||||
sellableQuantity: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
evaluationAmount: number;
|
||||
@@ -139,3 +140,56 @@ export interface DashboardActivityResponse {
|
||||
warnings: string[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브(급등/인기/뉴스) 공통 종목 항목
|
||||
*/
|
||||
export interface DashboardMarketRankItem {
|
||||
rank: number;
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: DashboardMarket;
|
||||
price: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
volume: number;
|
||||
tradingValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 주요 뉴스 항목
|
||||
*/
|
||||
export interface DashboardNewsHeadlineItem {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
symbols: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브 요약 지표
|
||||
*/
|
||||
export interface DashboardMarketPulse {
|
||||
gainersCount: number;
|
||||
losersCount: number;
|
||||
popularByVolumeCount: number;
|
||||
popularByValueCount: number;
|
||||
newsCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브 API 응답 모델
|
||||
*/
|
||||
export interface DashboardMarketHubResponse {
|
||||
source: "kis";
|
||||
tradingEnv: KisTradingEnv;
|
||||
gainers: DashboardMarketRankItem[];
|
||||
losers: DashboardMarketRankItem[];
|
||||
popularByVolume: DashboardMarketRankItem[];
|
||||
popularByValue: DashboardMarketRankItem[];
|
||||
news: DashboardNewsHeadlineItem[];
|
||||
pulse: DashboardMarketPulse;
|
||||
warnings: string[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
@@ -93,12 +93,12 @@ export function Logo({
|
||||
{variant === "full" && (
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold tracking-tight",
|
||||
"font-heading font-semibold tracking-tight",
|
||||
blendWithBackground
|
||||
? "text-white opacity-95"
|
||||
: "text-brand-900 dark:text-brand-50",
|
||||
)}
|
||||
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }}
|
||||
style={{ fontSize: "1.35rem" }}
|
||||
>
|
||||
JOORIN-E
|
||||
</span>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { SessionTimer } from "@/features/auth/components/session-timer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/features/layout/components/Logo";
|
||||
|
||||
|
||||
import { MarketIndices } from "@/features/layout/components/market-indices";
|
||||
|
||||
interface HeaderProps {
|
||||
/** 현재 로그인 사용자 정보(null 가능) */
|
||||
user: User | null;
|
||||
@@ -59,7 +62,6 @@ export function Header({
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{/* ========== LEFT: LOGO SECTION ========== */}
|
||||
{/* ========== LEFT: LOGO SECTION ========== */}
|
||||
<Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2">
|
||||
<Logo
|
||||
@@ -69,6 +71,13 @@ export function Header({
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* ========== CENTER: MARKET INDICES ========== */}
|
||||
{!blendWithBackground && user && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<MarketIndices />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== RIGHT: ACTION SECTION ========== */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -141,3 +150,4 @@ export function Header({
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
74
features/layout/components/market-indices.tsx
Normal file
74
features/layout/components/market-indices.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* @file features/layout/components/market-indices.tsx
|
||||
* @description KOSPI/KOSDAQ 지수를 표시하는 UI 컴포넌트
|
||||
*
|
||||
* @description [주요 책임]
|
||||
* - `useMarketIndices` 훅을 사용하여 지수 데이터를 가져옴
|
||||
* - 30초마다 데이터를 자동으로 새로고침
|
||||
* - 로딩 상태일 때 스켈레톤 UI를 표시
|
||||
* - 각 지수 정보를 `MarketIndexItem` 컴포넌트로 렌더링
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useMarketIndices } from "@/features/layout/hooks/use-market-indices";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DomesticMarketIndexResult } from "@/lib/kis/dashboard";
|
||||
|
||||
const MarketIndexItem = ({ index }: { index: DomesticMarketIndexResult }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium">{index.name}</span>
|
||||
<span
|
||||
className={cn("text-sm", {
|
||||
"text-red-500": index.change > 0,
|
||||
"text-blue-500": index.change < 0,
|
||||
})}
|
||||
>
|
||||
{index.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={cn("text-xs", {
|
||||
"text-red-500": index.change > 0,
|
||||
"text-blue-500": index.change < 0,
|
||||
})}
|
||||
>
|
||||
{index.change.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{" "}
|
||||
({index.changeRate.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function MarketIndices() {
|
||||
const { indices, isLoading, fetchIndices, fetchedAt } = useMarketIndices();
|
||||
|
||||
useEffect(() => {
|
||||
fetchIndices();
|
||||
const interval = setInterval(fetchIndices, 30000); // 30초마다 새로고침
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchIndices]);
|
||||
|
||||
if (isLoading && !fetchedAt) {
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden items-center space-x-6 md:flex">
|
||||
{indices.map((index) => (
|
||||
<MarketIndexItem key={index.code} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { KIS_REMEMBER_LOCAL_STORAGE_KEYS } from "@/features/settings/lib/kis-remember-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SESSION_RELATED_STORAGE_KEYS = [
|
||||
"session-storage",
|
||||
"auth-storage",
|
||||
"autotrade-kis-runtime-store",
|
||||
...KIS_REMEMBER_LOCAL_STORAGE_KEYS,
|
||||
] as const;
|
||||
|
||||
interface UserMenuProps {
|
||||
|
||||
98
features/layout/hooks/use-market-indices.ts
Normal file
98
features/layout/hooks/use-market-indices.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file features/layout/hooks/use-market-indices.ts
|
||||
* @description 시장 지수 데이터를 가져오고 상태를 관리하는 커스텀 훅
|
||||
*
|
||||
* @description [주요 책임]
|
||||
* - `useMarketIndicesStore`와 연동하여 상태(지수, 로딩, 에러)를 제공
|
||||
* - KIS 검증 세션이 있을 때 `/api/kis/domestic/indices` API를 인증 헤더와 함께 호출
|
||||
* - API 호출 로직을 `useCallback`으로 메모이제이션하여 성능을 최적화
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
buildKisRequestHeaders,
|
||||
resolveKisApiErrorMessage,
|
||||
type KisApiErrorPayload,
|
||||
} from "@/features/settings/apis/kis-api-utils";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { useMarketIndicesStore } from "@/features/layout/stores/market-indices-store";
|
||||
import type { DomesticMarketIndexResult } from "@/lib/kis/dashboard";
|
||||
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface LegacyMarketIndicesResponse {
|
||||
indices: DomesticMarketIndexResult[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export function useMarketIndices() {
|
||||
const verifiedCredentials = useKisRuntimeStore((state) => state.verifiedCredentials);
|
||||
const isKisVerified = useKisRuntimeStore((state) => state.isKisVerified);
|
||||
|
||||
const {
|
||||
indices,
|
||||
isLoading,
|
||||
error,
|
||||
fetchedAt,
|
||||
setIndices,
|
||||
setLoading,
|
||||
setError,
|
||||
} = useMarketIndicesStore();
|
||||
|
||||
const fetchIndices = useCallback(async () => {
|
||||
// [Step 1] KIS 검증이 안 된 상태에서는 지수 API를 호출하지 않습니다.
|
||||
if (!isKisVerified || !verifiedCredentials) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// [Step 2] 인증 헤더를 포함한 신규 지수 API를 호출합니다.
|
||||
const response = await fetch("/api/kis/domestic/indices", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(verifiedCredentials, {
|
||||
includeSessionOverride: true,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardIndicesResponse
|
||||
| LegacyMarketIndicesResponse
|
||||
| KisApiErrorPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
|
||||
// [Step 3] 신규/레거시 응답 형식을 모두 수용해 스토어에 반영합니다.
|
||||
if ("items" in payload) {
|
||||
setIndices({
|
||||
indices: payload.items,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ("indices" in payload && "fetchedAt" in payload) {
|
||||
setIndices({
|
||||
indices: payload.indices,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("지수 응답 형식이 올바르지 않습니다.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error("An unknown error occurred"));
|
||||
}
|
||||
}, [isKisVerified, setError, setIndices, setLoading, verifiedCredentials]);
|
||||
|
||||
return {
|
||||
indices,
|
||||
isLoading,
|
||||
error,
|
||||
fetchedAt,
|
||||
fetchIndices,
|
||||
};
|
||||
}
|
||||
39
features/layout/stores/market-indices-store.ts
Normal file
39
features/layout/stores/market-indices-store.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @file features/layout/stores/market-indices-store.ts
|
||||
* @description 시장 지수(KOSPI, KOSDAQ) 데이터 상태 관리를 위한 Zustand 스토어
|
||||
*
|
||||
* @description [주요 책임]
|
||||
* - 지수 데이터, 로딩 상태, 에러 정보, 마지막 fetch 시각을 저장
|
||||
* - 상태를 업데이트하는 액션(setIndices, setLoading, setError)을 제공
|
||||
*/
|
||||
import type { DomesticMarketIndexResult } from "@/lib/kis/dashboard";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface MarketIndicesState {
|
||||
indices: DomesticMarketIndexResult[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
fetchedAt: string | null;
|
||||
setIndices: (data: {
|
||||
indices: DomesticMarketIndexResult[];
|
||||
fetchedAt: string;
|
||||
}) => void;
|
||||
setLoading: (isLoading: boolean) => void;
|
||||
setError: (error: Error | null) => void;
|
||||
}
|
||||
|
||||
export const useMarketIndicesStore = create<MarketIndicesState>((set) => ({
|
||||
indices: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchedAt: null,
|
||||
setIndices: (data) =>
|
||||
set({
|
||||
indices: data.indices,
|
||||
fetchedAt: data.fetchedAt,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error, isLoading: false }),
|
||||
}));
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import { useEffect, 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 { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import {
|
||||
revokeKisCredentials,
|
||||
validateKisCredentials,
|
||||
} from "@/features/settings/apis/kis-auth.api";
|
||||
import {
|
||||
getRememberedKisValue,
|
||||
isKisRememberEnabled,
|
||||
setKisRememberEnabled,
|
||||
setRememberedKisValue,
|
||||
} from "@/features/settings/lib/kis-remember-storage";
|
||||
import {
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
@@ -37,6 +44,7 @@ export function KisAuthForm() {
|
||||
kisAppKeyInput,
|
||||
kisAppSecretInput,
|
||||
verifiedAccountNo,
|
||||
hasHydrated,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
setKisTradingEnvInput,
|
||||
@@ -51,6 +59,7 @@ export function KisAuthForm() {
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
verifiedAccountNo: state.verifiedAccountNo,
|
||||
hasHydrated: state._hasHydrated,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||
@@ -66,6 +75,74 @@ export function KisAuthForm() {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isValidating, startValidateTransition] = useTransition();
|
||||
const [isRevoking, startRevokeTransition] = useTransition();
|
||||
// [State] 앱키 입력값을 브라우저 재접속 후 자동 복원할지 여부
|
||||
const [rememberAppKey, setRememberAppKey] = useState(() =>
|
||||
isKisRememberEnabled("appKey"),
|
||||
);
|
||||
// [State] 앱시크릿키 입력값을 브라우저 재접속 후 자동 복원할지 여부
|
||||
const [rememberAppSecret, setRememberAppSecret] = useState(() =>
|
||||
isKisRememberEnabled("appSecret"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || kisAppKeyInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 1] 세션 입력값이 비어 있을 때만 저장된 앱키를 복원합니다.
|
||||
const rememberedAppKey = getRememberedKisValue("appKey");
|
||||
if (rememberedAppKey) {
|
||||
setKisAppKeyInput(rememberedAppKey);
|
||||
}
|
||||
}, [hasHydrated, kisAppKeyInput, setKisAppKeyInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || kisAppSecretInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 1] 세션 입력값이 비어 있을 때만 저장된 앱시크릿키를 복원합니다.
|
||||
const rememberedAppSecret = getRememberedKisValue("appSecret");
|
||||
if (rememberedAppSecret) {
|
||||
setKisAppSecretInput(rememberedAppSecret);
|
||||
}
|
||||
}, [hasHydrated, kisAppSecretInput, setKisAppSecretInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 2] 앱키 기억하기 체크 상태를 저장/해제합니다.
|
||||
setKisRememberEnabled("appKey", rememberAppKey);
|
||||
}, [hasHydrated, rememberAppKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || !rememberAppKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 2] 앱키 입력값이 바뀔 때 기억하기가 켜져 있으면 값을 갱신합니다.
|
||||
setRememberedKisValue("appKey", kisAppKeyInput);
|
||||
}, [hasHydrated, rememberAppKey, kisAppKeyInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 2] 앱시크릿키 기억하기 체크 상태를 저장/해제합니다.
|
||||
setKisRememberEnabled("appSecret", rememberAppSecret);
|
||||
}, [hasHydrated, rememberAppSecret]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || !rememberAppSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 2] 앱시크릿키 입력값이 바뀔 때 기억하기가 켜져 있으면 값을 갱신합니다.
|
||||
setRememberedKisValue("appSecret", kisAppSecretInput);
|
||||
}, [hasHydrated, rememberAppSecret, kisAppSecretInput]);
|
||||
|
||||
function handleValidate() {
|
||||
startValidateTransition(async () => {
|
||||
@@ -243,22 +320,39 @@ export function KisAuthForm() {
|
||||
|
||||
{/* ========== APP KEY INPUTS ========== */}
|
||||
<div className="space-y-3">
|
||||
<CredentialInput
|
||||
id="kis-app-key"
|
||||
label="앱키"
|
||||
placeholder="한국투자증권 앱키 입력"
|
||||
value={kisAppKeyInput}
|
||||
onChange={setKisAppKeyInput}
|
||||
icon={KeySquare}
|
||||
/>
|
||||
<CredentialInput
|
||||
id="kis-app-secret"
|
||||
label="앱시크릿키"
|
||||
placeholder="한국투자증권 앱시크릿키 입력"
|
||||
value={kisAppSecretInput}
|
||||
onChange={setKisAppSecretInput}
|
||||
icon={Lock}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<CredentialInput
|
||||
id="kis-app-key"
|
||||
label="앱키"
|
||||
placeholder="한국투자증권 앱키 입력"
|
||||
value={kisAppKeyInput}
|
||||
onChange={setKisAppKeyInput}
|
||||
icon={KeySquare}
|
||||
/>
|
||||
<RememberCheckbox
|
||||
id="remember-kis-app-key"
|
||||
checked={rememberAppKey}
|
||||
onChange={setRememberAppKey}
|
||||
label="앱키 기억하기"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<CredentialInput
|
||||
id="kis-app-secret"
|
||||
label="앱시크릿키"
|
||||
placeholder="한국투자증권 앱시크릿키 입력"
|
||||
value={kisAppSecretInput}
|
||||
onChange={setKisAppSecretInput}
|
||||
icon={Lock}
|
||||
/>
|
||||
<RememberCheckbox
|
||||
id="remember-kis-app-secret"
|
||||
checked={rememberAppSecret}
|
||||
onChange={setRememberAppSecret}
|
||||
label="앱시크릿키 기억하기"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
@@ -306,3 +400,31 @@ function CredentialInput({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RememberCheckbox({
|
||||
id,
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => onChange(next === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer text-xs font-medium text-zinc-500 dark:text-zinc-300"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
CreditCard,
|
||||
@@ -13,8 +13,15 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { InlineSpinner } from "@/components/ui/loading-spinner";
|
||||
import { validateKisProfile } from "@/features/settings/apis/kis-auth.api";
|
||||
import {
|
||||
getRememberedKisValue,
|
||||
isKisRememberEnabled,
|
||||
setKisRememberEnabled,
|
||||
setRememberedKisValue,
|
||||
} from "@/features/settings/lib/kis-remember-storage";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { SettingsCard } from "./SettingsCard";
|
||||
|
||||
@@ -28,6 +35,7 @@ export function KisProfileForm() {
|
||||
const {
|
||||
kisAccountNoInput,
|
||||
verifiedCredentials,
|
||||
hasHydrated,
|
||||
isKisVerified,
|
||||
isKisProfileVerified,
|
||||
verifiedAccountNo,
|
||||
@@ -38,6 +46,7 @@ export function KisProfileForm() {
|
||||
useShallow((state) => ({
|
||||
kisAccountNoInput: state.kisAccountNoInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
hasHydrated: state._hasHydrated,
|
||||
isKisVerified: state.isKisVerified,
|
||||
isKisProfileVerified: state.isKisProfileVerified,
|
||||
verifiedAccountNo: state.verifiedAccountNo,
|
||||
@@ -50,6 +59,40 @@ export function KisProfileForm() {
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isValidating, startValidateTransition] = useTransition();
|
||||
// [State] 계좌번호 입력값을 브라우저 재접속 후 자동 복원할지 여부
|
||||
const [rememberAccountNo, setRememberAccountNo] = useState(() =>
|
||||
isKisRememberEnabled("accountNo"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || kisAccountNoInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 1] 세션 입력값이 비어 있을 때만 저장된 계좌번호를 복원합니다.
|
||||
const rememberedAccountNo = getRememberedKisValue("accountNo");
|
||||
if (rememberedAccountNo) {
|
||||
setKisAccountNoInput(rememberedAccountNo);
|
||||
}
|
||||
}, [hasHydrated, kisAccountNoInput, setKisAccountNoInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 2] 계좌 기억하기 체크 상태를 저장/해제합니다.
|
||||
setKisRememberEnabled("accountNo", rememberAccountNo);
|
||||
}, [hasHydrated, rememberAccountNo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || !rememberAccountNo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Step 2] 계좌번호 입력값이 바뀔 때 기억하기가 켜져 있으면 값을 갱신합니다.
|
||||
setRememberedKisValue("accountNo", kisAccountNoInput);
|
||||
}, [hasHydrated, rememberAccountNo, kisAccountNoInput]);
|
||||
|
||||
/**
|
||||
* @description 계좌번호 인증을 해제하고 입력값을 비웁니다.
|
||||
@@ -220,6 +263,21 @@ export function KisProfileForm() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-1 pt-0.5">
|
||||
<Checkbox
|
||||
id="remember-kis-account-no"
|
||||
checked={rememberAccountNo}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberAccountNo(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="remember-kis-account-no"
|
||||
className="cursor-pointer text-xs font-medium text-zinc-500 dark:text-zinc-300"
|
||||
>
|
||||
계좌번호 기억하기
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
95
features/settings/lib/kis-remember-storage.ts
Normal file
95
features/settings/lib/kis-remember-storage.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* KIS 설정 화면의 "기억하기" 항목(localStorage) 키와 읽기/쓰기 유틸을 관리합니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - 앱키/앱시크릿키/계좌번호의 기억하기 체크 상태 저장
|
||||
* - 기억하기 값 저장/조회/삭제
|
||||
* - 로그아웃/세션만료 시 일괄 정리할 키 목록 제공
|
||||
*/
|
||||
|
||||
export type KisRememberField = "appKey" | "appSecret" | "accountNo";
|
||||
|
||||
const KIS_REMEMBER_ENABLED_KEY_MAP = {
|
||||
appKey: "autotrade-kis-remember-app-key-enabled",
|
||||
appSecret: "autotrade-kis-remember-app-secret-enabled",
|
||||
accountNo: "autotrade-kis-remember-account-no-enabled",
|
||||
} as const;
|
||||
|
||||
const KIS_REMEMBER_VALUE_KEY_MAP = {
|
||||
appKey: "autotrade-kis-remember-app-key",
|
||||
appSecret: "autotrade-kis-remember-app-secret",
|
||||
accountNo: "autotrade-kis-remember-account-no",
|
||||
} as const;
|
||||
|
||||
export const KIS_REMEMBER_LOCAL_STORAGE_KEYS = [
|
||||
...Object.values(KIS_REMEMBER_ENABLED_KEY_MAP),
|
||||
...Object.values(KIS_REMEMBER_VALUE_KEY_MAP),
|
||||
] as const;
|
||||
|
||||
function getBrowserLocalStorage() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
function readLocalStorage(key: string) {
|
||||
const storage = getBrowserLocalStorage();
|
||||
if (!storage) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return storage.getItem(key) ?? "";
|
||||
}
|
||||
|
||||
function writeLocalStorage(key: string, value: string) {
|
||||
const storage = getBrowserLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage.setItem(key, value);
|
||||
}
|
||||
|
||||
function removeLocalStorage(key: string) {
|
||||
const storage = getBrowserLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage.removeItem(key);
|
||||
}
|
||||
|
||||
export function isKisRememberEnabled(field: KisRememberField) {
|
||||
return readLocalStorage(KIS_REMEMBER_ENABLED_KEY_MAP[field]) === "1";
|
||||
}
|
||||
|
||||
export function setKisRememberEnabled(field: KisRememberField, enabled: boolean) {
|
||||
const enabledKey = KIS_REMEMBER_ENABLED_KEY_MAP[field];
|
||||
if (enabled) {
|
||||
writeLocalStorage(enabledKey, "1");
|
||||
return;
|
||||
}
|
||||
|
||||
removeLocalStorage(enabledKey);
|
||||
removeLocalStorage(KIS_REMEMBER_VALUE_KEY_MAP[field]);
|
||||
}
|
||||
|
||||
export function getRememberedKisValue(field: KisRememberField) {
|
||||
return readLocalStorage(KIS_REMEMBER_VALUE_KEY_MAP[field]);
|
||||
}
|
||||
|
||||
export function setRememberedKisValue(field: KisRememberField, value: string) {
|
||||
const normalized = value.trim();
|
||||
const valueKey = KIS_REMEMBER_VALUE_KEY_MAP[field];
|
||||
|
||||
if (!normalized) {
|
||||
removeLocalStorage(valueKey);
|
||||
return;
|
||||
}
|
||||
|
||||
writeLocalStorage(valueKey, normalized);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardStockCashOrderRequest,
|
||||
DashboardStockCashOrderResponse,
|
||||
DashboardStockOrderableCashRequest,
|
||||
DashboardStockOrderableCashResponse,
|
||||
DashboardStockChartResponse,
|
||||
DashboardStockOrderBookResponse,
|
||||
DashboardStockOverviewResponse,
|
||||
@@ -168,3 +170,34 @@ export async function fetchOrderCash(
|
||||
|
||||
return payload as DashboardStockCashOrderResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매수가능금액(주문가능현금) 조회 API 호출
|
||||
* @param request 종목/가격 기준 조회 요청
|
||||
* @param credentials KIS 인증 정보
|
||||
*/
|
||||
export async function fetchOrderableCashEstimate(
|
||||
request: DashboardStockOrderableCashRequest,
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardStockOrderableCashResponse> {
|
||||
const response = await fetch("/api/kis/domestic/orderable-cash", {
|
||||
method: "POST",
|
||||
headers: buildKisRequestHeaders(credentials, {
|
||||
jsonContentType: true,
|
||||
includeAccountNo: true,
|
||||
includeSessionOverride: true,
|
||||
}),
|
||||
body: JSON.stringify(request),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardStockOrderableCashResponse
|
||||
| KisApiErrorPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveKisApiErrorMessage(payload, "매수가능금액 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
|
||||
return payload as DashboardStockOrderableCashResponse;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-st
|
||||
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
|
||||
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
|
||||
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
|
||||
import { AutotradeControlPanel } from "@/features/autotrade/components/AutotradeControlPanel";
|
||||
import { useStockSearch } from "@/features/trade/hooks/useStockSearch";
|
||||
import { useOrderBook } from "@/features/trade/hooks/useOrderBook";
|
||||
import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket";
|
||||
@@ -40,6 +41,8 @@ export function TradeContainer() {
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
// [State] 선택 종목과 매칭할 보유 종목 목록
|
||||
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
|
||||
// [State] 주문 패널에서 사용할 가용 예수금 스냅샷(원)
|
||||
const [availableCashBalance, setAvailableCashBalance] = useState<number | null>(null);
|
||||
const { verifiedCredentials, isKisVerified, _hasHydrated } =
|
||||
useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
@@ -125,15 +128,18 @@ export function TradeContainer() {
|
||||
const loadHoldingsSnapshot = useCallback(async () => {
|
||||
if (!verifiedCredentials?.accountNo?.trim()) {
|
||||
setHoldings([]);
|
||||
setAvailableCashBalance(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await fetchDashboardBalance(verifiedCredentials);
|
||||
setHoldings(balance.holdings);
|
||||
setHoldings(balance.holdings.filter((item) => item.quantity > 0));
|
||||
setAvailableCashBalance(balance.summary.cashBalance);
|
||||
} catch {
|
||||
// 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다.
|
||||
setHoldings([]);
|
||||
setAvailableCashBalance(null);
|
||||
}
|
||||
}, [verifiedCredentials]);
|
||||
|
||||
@@ -328,6 +334,13 @@ export function TradeContainer() {
|
||||
onClearHistory={clearSearchHistory}
|
||||
/>
|
||||
|
||||
<AutotradeControlPanel
|
||||
selectedStock={selectedStock}
|
||||
latestTick={latestTick}
|
||||
credentials={verifiedCredentials}
|
||||
canTrade={canTrade}
|
||||
/>
|
||||
|
||||
{/* ========== DASHBOARD SECTION ========== */}
|
||||
<TradeDashboardContent
|
||||
selectedStock={selectedStock}
|
||||
@@ -338,6 +351,7 @@ export function TradeContainer() {
|
||||
isOrderBookLoading={isOrderBookLoading}
|
||||
referencePrice={referencePrice}
|
||||
matchedHolding={matchedHolding}
|
||||
availableCashBalance={availableCashBalance}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,10 +39,14 @@ import {
|
||||
CHART_MIN_HEIGHT,
|
||||
DEFAULT_CHART_PALETTE,
|
||||
getChartPaletteFromCssVars,
|
||||
HISTORY_LOAD_TRIGGER_BARS_BEFORE,
|
||||
INITIAL_MINUTE_PREFETCH_BUDGET_MS,
|
||||
MINUTE_SYNC_INTERVAL_MS,
|
||||
MINUTE_TIMEFRAMES,
|
||||
PERIOD_TIMEFRAMES,
|
||||
REALTIME_STALE_THRESHOLD_MS,
|
||||
resolveInitialMinutePrefetchPages,
|
||||
resolveInitialMinuteTargetBars,
|
||||
UP_COLOR,
|
||||
} from "./stock-line-chart-meta";
|
||||
|
||||
@@ -101,6 +105,9 @@ export function StockLineChart({
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
const pendingFitContentRef = useRef(false);
|
||||
const nextCursorRef = useRef<string | null>(null);
|
||||
const autoFillLeftGapRef = useRef(false);
|
||||
|
||||
// API 오류 시 fallback 용도로 유지
|
||||
const latestCandlesRef = useRef(candles);
|
||||
@@ -108,6 +115,10 @@ export function StockLineChart({
|
||||
latestCandlesRef.current = candles;
|
||||
}, [candles]);
|
||||
|
||||
useEffect(() => {
|
||||
nextCursorRef.current = nextCursor;
|
||||
}, [nextCursor]);
|
||||
|
||||
const latest = bars.at(-1);
|
||||
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
|
||||
const change = latest ? latest.close - prevClose : 0;
|
||||
@@ -196,7 +207,13 @@ export function StockLineChart({
|
||||
|
||||
const olderBars = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(olderBars, prev));
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
setNextCursor(
|
||||
response.hasMore &&
|
||||
response.nextCursor &&
|
||||
response.nextCursor !== nextCursor
|
||||
? response.nextCursor
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
@@ -213,6 +230,58 @@ export function StockLineChart({
|
||||
loadMoreHandlerRef.current = handleLoadMore;
|
||||
}, [handleLoadMore]);
|
||||
|
||||
const fillLeftWhitespaceIfNeeded = useCallback(async () => {
|
||||
if (!isMinuteTimeframe(timeframe)) return;
|
||||
if (autoFillLeftGapRef.current) return;
|
||||
if (loadingMoreRef.current) return;
|
||||
if (!nextCursorRef.current) return;
|
||||
|
||||
const chart = chartRef.current;
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
if (!chart || !candleSeries) return;
|
||||
|
||||
autoFillLeftGapRef.current = true;
|
||||
const startedAt = Date.now();
|
||||
let rounds = 0;
|
||||
|
||||
try {
|
||||
while (
|
||||
rounds < 16 &&
|
||||
Date.now() - startedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS
|
||||
) {
|
||||
const range = chart.timeScale().getVisibleLogicalRange();
|
||||
if (!range) break;
|
||||
|
||||
const barsInfo = candleSeries.barsInLogicalRange(range);
|
||||
const hasLeftWhitespace =
|
||||
Boolean(
|
||||
barsInfo &&
|
||||
Number.isFinite(barsInfo.barsBefore) &&
|
||||
barsInfo.barsBefore < 0,
|
||||
) || false;
|
||||
|
||||
if (!hasLeftWhitespace) break;
|
||||
|
||||
const cursorBefore = nextCursorRef.current;
|
||||
if (!cursorBefore) break;
|
||||
|
||||
await loadMoreHandlerRef.current();
|
||||
rounds += 1;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(() => resolve(), 120);
|
||||
});
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
const cursorAfter = nextCursorRef.current;
|
||||
if (!cursorAfter || cursorAfter === cursorBefore) break;
|
||||
}
|
||||
} finally {
|
||||
autoFillLeftGapRef.current = false;
|
||||
}
|
||||
}, [timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRealtimeKeyRef.current = "";
|
||||
lastRealtimeAppliedAtRef.current = 0;
|
||||
@@ -257,7 +326,10 @@ export function StockLineChart({
|
||||
borderColor: palette.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
rightOffset: 4,
|
||||
barSpacing: 6,
|
||||
minBarSpacing: 1,
|
||||
rightBarStaysOnScroll: true,
|
||||
tickMarkFormatter: formatKstTickMark,
|
||||
},
|
||||
handleScroll: {
|
||||
@@ -298,15 +370,29 @@ export function StockLineChart({
|
||||
});
|
||||
|
||||
let scrollTimeout: number | undefined;
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
||||
const handleVisibleLogicalRangeChange = (range: {
|
||||
from: number;
|
||||
to: number;
|
||||
} | null) => {
|
||||
if (!range || !initialLoadCompleteRef.current) return;
|
||||
if (range.from >= 10) return;
|
||||
|
||||
const barsInfo = candleSeries.barsInLogicalRange(range);
|
||||
if (!barsInfo) return;
|
||||
if (
|
||||
Number.isFinite(barsInfo.barsBefore) &&
|
||||
barsInfo.barsBefore > HISTORY_LOAD_TRIGGER_BARS_BEFORE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
void loadMoreHandlerRef.current();
|
||||
}, 250);
|
||||
});
|
||||
};
|
||||
chart
|
||||
.timeScale()
|
||||
.subscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
|
||||
|
||||
chartRef.current = chart;
|
||||
candleSeriesRef.current = candleSeries;
|
||||
@@ -330,6 +416,9 @@ export function StockLineChart({
|
||||
|
||||
return () => {
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
chart
|
||||
.timeScale()
|
||||
.unsubscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
@@ -386,6 +475,8 @@ export function StockLineChart({
|
||||
if (!symbol || !credentials) return;
|
||||
|
||||
initialLoadCompleteRef.current = false;
|
||||
pendingFitContentRef.current = true;
|
||||
autoFillLeftGapRef.current = false;
|
||||
let disposed = false;
|
||||
let initialLoadTimer: number | null = null;
|
||||
|
||||
@@ -401,16 +492,24 @@ export function StockLineChart({
|
||||
? firstPage.nextCursor
|
||||
: null;
|
||||
|
||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||
// 분봉은 시간프레임별 목표 봉 수까지 순차 조회해 초기 과거 가시성을 보강합니다.
|
||||
if (
|
||||
isMinuteTimeframe(timeframe) &&
|
||||
firstPage.hasMore &&
|
||||
firstPage.nextCursor
|
||||
) {
|
||||
const targetBars = resolveInitialMinuteTargetBars(timeframe);
|
||||
const maxPrefetchPages = resolveInitialMinutePrefetchPages(timeframe);
|
||||
const prefetchStartedAt = Date.now();
|
||||
let minuteCursor: string | null = firstPage.nextCursor;
|
||||
let extraPageCount = 0;
|
||||
|
||||
while (minuteCursor && extraPageCount < 2) {
|
||||
while (
|
||||
minuteCursor &&
|
||||
extraPageCount < maxPrefetchPages &&
|
||||
Date.now() - prefetchStartedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS &&
|
||||
mergedBars.length < targetBars
|
||||
) {
|
||||
try {
|
||||
const olderPage = await fetchStockChart(
|
||||
symbol,
|
||||
@@ -421,10 +520,14 @@ export function StockLineChart({
|
||||
|
||||
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = olderPage.hasMore
|
||||
const nextMinuteCursor = olderPage.hasMore
|
||||
? olderPage.nextCursor
|
||||
: null;
|
||||
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
resolvedNextCursor = nextMinuteCursor;
|
||||
minuteCursor =
|
||||
nextMinuteCursor && nextMinuteCursor !== minuteCursor
|
||||
? nextMinuteCursor
|
||||
: null;
|
||||
extraPageCount += 1;
|
||||
} catch {
|
||||
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
|
||||
@@ -469,10 +572,25 @@ export function StockLineChart({
|
||||
if (!isChartReady) return;
|
||||
|
||||
setSeriesData(renderableBars);
|
||||
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
||||
if (renderableBars.length === 0) return;
|
||||
|
||||
if (pendingFitContentRef.current) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
pendingFitContentRef.current = false;
|
||||
} else if (!initialLoadCompleteRef.current) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
if (nextCursorRef.current) {
|
||||
void fillLeftWhitespaceIfNeeded();
|
||||
}
|
||||
}, [
|
||||
fillLeftWhitespaceIfNeeded,
|
||||
isChartReady,
|
||||
renderableBars,
|
||||
setSeriesData,
|
||||
timeframe,
|
||||
]);
|
||||
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
@@ -495,7 +613,7 @@ export function StockLineChart({
|
||||
}, [latestTick, timeframe]);
|
||||
|
||||
/**
|
||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @description 분봉(1m/5m/10m/15m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
|
||||
@@ -150,8 +150,7 @@ function resolveBarTimestamp(
|
||||
|
||||
/**
|
||||
* 타임스탬프를 타임프레임 버킷 경계에 정렬
|
||||
* - 1m: 초/밀리초를 제거해 분 경계에 정렬
|
||||
* - 30m/1h: 분 단위를 버킷에 정렬
|
||||
* - 분봉(1/5/10/15/30/60분): 분 단위를 버킷 경계에 정렬
|
||||
* - 1d: 00:00:00
|
||||
* - 1w: 월요일 00:00:00
|
||||
*/
|
||||
@@ -160,12 +159,14 @@ function alignTimestamp(
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): UTCTimestamp {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const minuteBucket = resolveMinuteBucket(timeframe);
|
||||
|
||||
if (timeframe === "1m") {
|
||||
d.setUTCSeconds(0, 0);
|
||||
} else if (timeframe === "30m" || timeframe === "1h") {
|
||||
const bucket = timeframe === "30m" ? 30 : 60;
|
||||
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
|
||||
if (minuteBucket !== null) {
|
||||
d.setUTCMinutes(
|
||||
Math.floor(d.getUTCMinutes() / minuteBucket) * minuteBucket,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
} else if (timeframe === "1d") {
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
} else if (timeframe === "1w") {
|
||||
@@ -300,7 +301,17 @@ export function formatSignedPercent(value: number) {
|
||||
* 분봉 타임프레임인지 판별
|
||||
*/
|
||||
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
|
||||
return tf === "1m" || tf === "30m" || tf === "1h";
|
||||
return resolveMinuteBucket(tf) !== null;
|
||||
}
|
||||
|
||||
function resolveMinuteBucket(tf: DashboardChartTimeframe): number | null {
|
||||
if (tf === "1m") return 1;
|
||||
if (tf === "5m") return 5;
|
||||
if (tf === "10m") return 10;
|
||||
if (tf === "15m") return 15;
|
||||
if (tf === "30m") return 30;
|
||||
if (tf === "1h") return 60;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTickTime(value?: string) {
|
||||
|
||||
@@ -5,6 +5,8 @@ export const UP_COLOR = "#ef4444";
|
||||
export const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||
export const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
export const CHART_MIN_HEIGHT = 220;
|
||||
export const HISTORY_LOAD_TRIGGER_BARS_BEFORE = 40;
|
||||
export const INITIAL_MINUTE_PREFETCH_BUDGET_MS = 12000;
|
||||
|
||||
export interface ChartPalette {
|
||||
backgroundColor: string;
|
||||
@@ -31,6 +33,9 @@ export const MINUTE_TIMEFRAMES: Array<{
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1m", label: "1분" },
|
||||
{ value: "5m", label: "5분" },
|
||||
{ value: "10m", label: "10분" },
|
||||
{ value: "15m", label: "15분" },
|
||||
{ value: "30m", label: "30분" },
|
||||
{ value: "1h", label: "1시간" },
|
||||
];
|
||||
@@ -43,6 +48,30 @@ export const PERIOD_TIMEFRAMES: Array<{
|
||||
{ value: "1w", label: "주" },
|
||||
];
|
||||
|
||||
export function resolveInitialMinuteTargetBars(
|
||||
timeframe: DashboardChartTimeframe,
|
||||
) {
|
||||
if (timeframe === "1m") return 260;
|
||||
if (timeframe === "5m") return 240;
|
||||
if (timeframe === "10m") return 220;
|
||||
if (timeframe === "15m") return 200;
|
||||
if (timeframe === "30m") return 180;
|
||||
if (timeframe === "1h") return 260;
|
||||
return 140;
|
||||
}
|
||||
|
||||
export function resolveInitialMinutePrefetchPages(
|
||||
timeframe: DashboardChartTimeframe,
|
||||
) {
|
||||
if (timeframe === "1m") return 24;
|
||||
if (timeframe === "5m") return 28;
|
||||
if (timeframe === "10m") return 32;
|
||||
if (timeframe === "15m") return 36;
|
||||
if (timeframe === "30m") return 44;
|
||||
if (timeframe === "1h") return 80;
|
||||
return 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다.
|
||||
* @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영
|
||||
|
||||
@@ -51,7 +51,7 @@ export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
|
||||
try {
|
||||
const data = await fetchDashboardBalance(credentials);
|
||||
setSummary(data.summary);
|
||||
setHoldings(data.holdings);
|
||||
setHoldings(data.holdings.filter((item) => item.quantity > 0));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
@@ -185,9 +185,10 @@ export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
|
||||
{!isLoading && !error && holdings.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65">
|
||||
<div className="grid min-w-[700px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65">
|
||||
<div>종목명</div>
|
||||
<div className="text-right">보유수량</div>
|
||||
<div className="text-right">매도가능</div>
|
||||
<div className="text-right">평균단가</div>
|
||||
<div className="text-right">현재가</div>
|
||||
<div className="text-right">평가손익</div>
|
||||
@@ -238,7 +239,7 @@ function SummaryItem({
|
||||
/** 보유 종목 행 */
|
||||
function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
|
||||
return (
|
||||
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20">
|
||||
<div className="grid min-w-[700px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20">
|
||||
{/* 종목명 */}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground dark:text-brand-50">
|
||||
@@ -254,6 +255,11 @@ function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
|
||||
{fmt(holding.quantity)}주
|
||||
</div>
|
||||
|
||||
{/* 매도가능수량 */}
|
||||
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
|
||||
{fmt(holding.sellableQuantity)}주
|
||||
</div>
|
||||
|
||||
{/* 평균단가 */}
|
||||
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
|
||||
{fmt(holding.averagePrice)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
interface TradeDashboardContentProps {
|
||||
selectedStock: DashboardStockItem | null;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
availableCashBalance: number | null;
|
||||
verifiedCredentials: KisRuntimeCredentials | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
recentTradeTicks: DashboardRealtimeTradeTick[];
|
||||
@@ -31,6 +32,7 @@ interface TradeDashboardContentProps {
|
||||
export function TradeDashboardContent({
|
||||
selectedStock,
|
||||
matchedHolding,
|
||||
availableCashBalance,
|
||||
verifiedCredentials,
|
||||
latestTick,
|
||||
recentTradeTicks,
|
||||
@@ -78,8 +80,10 @@ export function TradeDashboardContent({
|
||||
}
|
||||
orderForm={
|
||||
<OrderForm
|
||||
key={selectedStock?.symbol ?? "order-form-empty"}
|
||||
stock={selectedStock ?? undefined}
|
||||
matchedHolding={matchedHolding}
|
||||
availableCashBalance={availableCashBalance}
|
||||
/>
|
||||
}
|
||||
isChartVisible={isChartVisible}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
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 { fetchOrderableCashEstimate } from "@/features/trade/apis/kis-stock.api";
|
||||
import { useOrder } from "@/features/trade/hooks/useOrder";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type {
|
||||
@@ -18,6 +19,7 @@ import { cn } from "@/lib/utils";
|
||||
interface OrderFormProps {
|
||||
stock?: DashboardStockItem;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
availableCashBalance?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +27,11 @@ interface OrderFormProps {
|
||||
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
|
||||
*/
|
||||
export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
export function OrderForm({
|
||||
stock,
|
||||
matchedHolding,
|
||||
availableCashBalance = null,
|
||||
}: OrderFormProps) {
|
||||
const verifiedCredentials = useKisRuntimeStore(
|
||||
(state) => state.verifiedCredentials,
|
||||
);
|
||||
@@ -37,6 +43,69 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
);
|
||||
const [quantity, setQuantity] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||
const [orderableCash, setOrderableCash] = useState<number | null>(null);
|
||||
const [isOrderableCashLoading, setIsOrderableCashLoading] = useState(false);
|
||||
const stockSymbol = stock?.symbol ?? null;
|
||||
const sellableQuantity = matchedHolding?.sellableQuantity ?? 0;
|
||||
const hasSellableQuantity = sellableQuantity > 0;
|
||||
const effectiveOrderableCash = orderableCash ?? availableCashBalance ?? null;
|
||||
|
||||
// [Effect] 종목/가격 변경 시 매수가능금액(주문가능 예수금)을 다시 조회합니다.
|
||||
useEffect(() => {
|
||||
if (activeTab !== "buy") return;
|
||||
if (!stockSymbol || !verifiedCredentials) {
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setOrderableCash(null);
|
||||
setIsOrderableCashLoading(false);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(resetTimerId);
|
||||
};
|
||||
}
|
||||
|
||||
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
||||
if (Number.isNaN(priceNum) || priceNum <= 0) {
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setOrderableCash(null);
|
||||
setIsOrderableCashLoading(false);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(resetTimerId);
|
||||
};
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
setIsOrderableCashLoading(true);
|
||||
void fetchOrderableCashEstimate(
|
||||
{
|
||||
symbol: stockSymbol,
|
||||
price: priceNum,
|
||||
orderType: "limit",
|
||||
},
|
||||
verifiedCredentials,
|
||||
)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setOrderableCash(Math.max(0, Math.floor(response.orderableCash)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// 조회 실패 시 대시보드 예수금 스냅샷을 fallback으로 사용합니다.
|
||||
setOrderableCash(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setIsOrderableCashLoading(false);
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timerId);
|
||||
};
|
||||
}, [activeTab, stockSymbol, verifiedCredentials, price]);
|
||||
|
||||
// ========== ORDER HANDLER ==========
|
||||
/**
|
||||
@@ -56,6 +125,31 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
alert("수량을 올바르게 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (side === "buy" && effectiveOrderableCash !== null) {
|
||||
const requestedAmount = priceNum * qtyNum;
|
||||
if (requestedAmount > effectiveOrderableCash) {
|
||||
alert(
|
||||
`주문가능 예수금(${effectiveOrderableCash.toLocaleString("ko-KR")}원)을 초과했습니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (side === "sell") {
|
||||
if (!matchedHolding) {
|
||||
alert("보유 종목 정보가 없어 매도 주문을 진행할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sellableQuantity <= 0) {
|
||||
alert("매도가능수량이 0주입니다. 체결/정산 상태를 확인해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (qtyNum > sellableQuantity) {
|
||||
alert(`매도가능수량(${sellableQuantity.toLocaleString("ko-KR")}주)을 초과했습니다.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!verifiedCredentials.accountNo) {
|
||||
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
|
||||
return;
|
||||
@@ -96,11 +190,34 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100;
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return;
|
||||
|
||||
// UI 흐름: 비율 버튼 클릭 -> 주문가능 예수금 기준 계산(매수 탭) -> 주문수량 입력값 반영
|
||||
if (activeTab === "buy") {
|
||||
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
||||
if (Number.isNaN(priceNum) || priceNum <= 0) {
|
||||
alert("가격을 먼저 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectiveOrderableCash === null || effectiveOrderableCash <= 0) {
|
||||
alert("주문가능 예수금을 확인할 수 없어 비율 계산을 할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const calculatedQuantity = Math.floor((effectiveOrderableCash * ratio) / priceNum);
|
||||
if (calculatedQuantity <= 0) {
|
||||
alert("선택한 비율로 주문 가능한 수량이 없습니다. 가격 또는 비율을 조정해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setQuantity(String(calculatedQuantity));
|
||||
return;
|
||||
}
|
||||
|
||||
// UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영
|
||||
if (activeTab === "sell" && matchedHolding?.quantity) {
|
||||
if (activeTab === "sell" && hasSellableQuantity) {
|
||||
const calculatedQuantity = Math.max(
|
||||
1,
|
||||
Math.floor(matchedHolding.quantity * ratio),
|
||||
Math.floor(sellableQuantity * ratio),
|
||||
);
|
||||
setQuantity(String(calculatedQuantity));
|
||||
}
|
||||
@@ -108,6 +225,12 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
|
||||
const isMarketDataAvailable = Boolean(stock);
|
||||
const isBuy = activeTab === "buy";
|
||||
const buyOrderableValue =
|
||||
isOrderableCashLoading
|
||||
? "조회 중..."
|
||||
: effectiveOrderableCash === null
|
||||
? "- KRW"
|
||||
: `${effectiveOrderableCash.toLocaleString("ko-KR")}원`;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
|
||||
@@ -179,9 +302,18 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
orderableValue={buyOrderableValue}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/65">
|
||||
비율 버튼은 주문가능 예수금 기준으로 매수 수량을 계산합니다.
|
||||
</p>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
{!matchedHolding && (
|
||||
<p className="text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
현재 선택한 종목의 보유 수량이 없어 매도 주문을 보낼 수 없습니다.
|
||||
</p>
|
||||
)}
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-red-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(220,38,38,0.4)] ring-1 ring-red-300/30 transition-all hover:bg-red-700 hover:shadow-[0_4px_20px_rgba(220,38,38,0.5)] dark:bg-red-500 dark:ring-red-300/40 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
||||
@@ -212,19 +344,29 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
orderableValue={
|
||||
matchedHolding
|
||||
? `${sellableQuantity.toLocaleString("ko-KR")}주`
|
||||
: "- 주"
|
||||
}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-blue-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(37,99,235,0.4)] ring-1 ring-blue-300/30 transition-all hover:bg-blue-700 hover:shadow-[0_4px_20px_rgba(37,99,235,0.5)] dark:bg-blue-500 dark:ring-blue-300/40 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!isMarketDataAvailable ||
|
||||
!matchedHolding ||
|
||||
!hasSellableQuantity
|
||||
}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 animate-spin" />
|
||||
) : (
|
||||
"매도하기"
|
||||
hasSellableQuantity ? "매도하기" : "매도가능수량 없음"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,6 +390,7 @@ function OrderInputs({
|
||||
disabled,
|
||||
hasError,
|
||||
errorMessage,
|
||||
orderableValue,
|
||||
}: {
|
||||
type: "buy" | "sell";
|
||||
price: string;
|
||||
@@ -258,6 +401,7 @@ function OrderInputs({
|
||||
disabled: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
orderableValue: string;
|
||||
}) {
|
||||
const labelClass =
|
||||
"text-xs font-medium text-foreground/80 dark:text-brand-100/80 min-w-[56px]";
|
||||
@@ -272,7 +416,7 @@ function OrderInputs({
|
||||
주문가능
|
||||
</span>
|
||||
<span className="font-medium text-foreground dark:text-brand-50">
|
||||
- {type === "buy" ? "KRW" : "주"}
|
||||
{orderableValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -366,6 +510,10 @@ function HoldingInfoPanel({
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
<HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}주`} />
|
||||
<HoldingInfoRow
|
||||
label="매도가능수량"
|
||||
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<HoldingInfoRow
|
||||
label="평균단가"
|
||||
value={`${holding.averagePrice.toLocaleString("ko-KR")}원`}
|
||||
|
||||
@@ -160,7 +160,7 @@ export function BookSideRows({
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
"w-[48px] shrink-0 text-right text-[10px] tabular-nums xl:w-[56px]",
|
||||
getChangeToneClass(row.changeValue),
|
||||
)}
|
||||
>
|
||||
@@ -168,6 +168,14 @@ export function BookSideRows({
|
||||
? "-"
|
||||
: fmtSignedChange(row.changeValue)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[52px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
getChangeToneClass(row.changeRate),
|
||||
)}
|
||||
>
|
||||
{row.changeRate === null ? "-" : fmtPct(row.changeRate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface BookRow {
|
||||
price: number;
|
||||
size: number;
|
||||
changeValue: number | null;
|
||||
changeRate: number | null;
|
||||
isHighlighted: boolean;
|
||||
}
|
||||
|
||||
@@ -166,11 +167,13 @@ export function buildBookRows({
|
||||
const price = side === "ask" ? level.askPrice : level.bidPrice;
|
||||
const size = side === "ask" ? level.askSize : level.bidSize;
|
||||
const changeValue = resolvePriceChange(price, basePrice);
|
||||
const changeRate = resolvePriceChangeRate(price, basePrice);
|
||||
|
||||
return {
|
||||
price,
|
||||
size: Math.max(size, 0),
|
||||
changeValue,
|
||||
changeRate,
|
||||
isHighlighted: latestPrice > 0 && price === latestPrice,
|
||||
} satisfies BookRow;
|
||||
});
|
||||
@@ -208,3 +211,10 @@ function resolvePriceChange(price: number, basePrice: number) {
|
||||
}
|
||||
return price - basePrice;
|
||||
}
|
||||
|
||||
function resolvePriceChangeRate(price: number, basePrice: number) {
|
||||
if (price <= 0 || basePrice <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ((price - basePrice) / basePrice) * 100;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,15 @@ export interface StockCandlePoint {
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export type DashboardChartTimeframe = "1m" | "30m" | "1h" | "1d" | "1w";
|
||||
export type DashboardChartTimeframe =
|
||||
| "1m"
|
||||
| "5m"
|
||||
| "10m"
|
||||
| "15m"
|
||||
| "30m"
|
||||
| "1h"
|
||||
| "1d"
|
||||
| "1w";
|
||||
|
||||
/**
|
||||
* 호가창 1레벨(가격 + 잔량)
|
||||
@@ -168,6 +176,29 @@ export interface DashboardRealtimeTradeTick {
|
||||
export type DashboardOrderSide = "buy" | "sell";
|
||||
export type DashboardOrderType = "limit" | "market";
|
||||
|
||||
/**
|
||||
* 국내주식 매수가능금액 조회 요청 모델
|
||||
*/
|
||||
export interface DashboardStockOrderableCashRequest {
|
||||
symbol: string;
|
||||
price: number;
|
||||
orderType?: DashboardOrderType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 매수가능금액 조회 응답 모델
|
||||
*/
|
||||
export interface DashboardStockOrderableCashResponse {
|
||||
ok: boolean;
|
||||
tradingEnv: KisTradingEnv;
|
||||
orderableCash: number;
|
||||
noReceivableBuyAmount: number;
|
||||
maxBuyAmount: number;
|
||||
maxBuyQuantity: number;
|
||||
noReceivableBuyQuantity: number;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 현금 주문 요청 모델
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user