전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View File

@@ -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;
/**

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

@@ -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">

View File

@@ -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 실시간 지수 맵

View File

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

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

View File

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

View File

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

View File

@@ -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" />
( )

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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 {

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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>
);

View File

@@ -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
*/

View File

@@ -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) {

View File

@@ -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 차트 생성/테마 반영

View File

@@ -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)}

View File

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

View File

@@ -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")}`}

View File

@@ -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">

View File

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

View File

@@ -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;
}
/**
* 국내주식 현금 주문 요청 모델
*/