차트 수정

This commit is contained in:
2026-02-13 16:41:10 +09:00
parent b73867c65d
commit 276ef09d89
8 changed files with 421 additions and 177 deletions

View File

@@ -31,28 +31,28 @@ export function StockHeader({
: "text-foreground";
return (
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4 sm:py-3">
<div className="bg-white px-3 py-1.5 dark:bg-brand-900/22 sm:px-4 sm:py-2">
{/* ========== STOCK SUMMARY ========== */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-lg font-bold leading-tight text-foreground dark:text-brand-50 sm:text-xl">
<h1 className="truncate text-base font-bold leading-tight text-foreground dark:text-brand-50 sm:text-lg">
{stock.name}
</h1>
<span className="mt-0.5 block text-xs text-muted-foreground dark:text-brand-100/70 sm:text-sm">
<span className="mt-0.5 block text-[11px] text-muted-foreground dark:text-brand-100/70 sm:text-xs">
{stock.symbol}/{stock.market}
</span>
</div>
<div className={cn("shrink-0 text-right", colorClass)}>
<span className="block text-2xl font-bold tracking-tight">{price}</span>
<span className="text-xs font-medium sm:text-sm">
<span className="block text-xl font-bold tracking-tight sm:text-2xl">{price}</span>
<span className="text-[11px] font-medium sm:text-xs">
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
</span>
</div>
</div>
{/* ========== STATS ========== */}
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
<div className="mt-1.5 grid grid-cols-3 gap-2 text-xs md:hidden">
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70"></p>
<p className="font-medium text-red-500">{high || "--"}</p>
@@ -67,10 +67,10 @@ export function StockHeader({
</div>
</div>
<Separator className="mt-2 md:hidden" />
<Separator className="mt-1.5 md:hidden" />
{/* ========== DESKTOP STATS ========== */}
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
<div className="hidden items-center justify-end gap-5 pt-1 text-sm md:flex">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70"></span>
<span className="font-medium text-red-500">{high || "--"}</span>

View File

@@ -1,4 +1,6 @@
import { ReactNode } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface DashboardLayoutProps {
@@ -6,14 +8,22 @@ interface DashboardLayoutProps {
chart: ReactNode;
orderBook: ReactNode;
orderForm: ReactNode;
isChartVisible: boolean;
onToggleChart: () => void;
className?: string;
}
/**
* @description 트레이드 본문 레이아웃을 구성합니다. 상단 차트 영역은 보임/숨김 토글을 지원합니다.
* @see features/trade/components/layout/TradeDashboardContent.tsx 상위 컴포넌트에서 차트 토글 상태를 관리하고 본 레이아웃에 전달합니다.
*/
export function DashboardLayout({
header,
chart,
orderBook,
orderForm,
isChartVisible,
onToggleChart,
className,
}: DashboardLayoutProps) {
return (
@@ -35,36 +45,66 @@ export function DashboardLayout({
{/* 2. Main Content Area */}
<div
className={cn(
"flex flex-1 flex-col",
// Mobile: Allow content to flow naturally with spacing
"overflow-visible pb-4 gap-4",
// Desktop: Internal scrolling, horizontal layout, no page spacing
"xl:overflow-hidden xl:flex-row xl:pb-0 xl:gap-0",
"flex-1 min-h-0 overflow-y-auto",
"xl:overflow-hidden",
)}
>
{/* Left Column: Chart & Info */}
<div
className={cn(
"flex flex-col border-border dark:border-brand-800/45",
// Mobile: Fixed height for chart to ensure visibility
"h-[320px] flex-none border-b sm:h-[360px]",
// Desktop: Fill remaining space, remove bottom border, add right border
"xl:flex-1 xl:h-auto xl:min-h-0 xl:min-w-0 xl:border-b-0 xl:border-r",
)}
>
<div className="flex-1 min-h-0">{chart}</div>
{/* Future: Transaction History / Market Depth can go here */}
</div>
<div className="flex min-h-full flex-col xl:h-full xl:min-h-0">
{/* ========== CHART SECTION ========== */}
<section className="flex-none border-b border-border dark:border-brand-800/45">
<div className="flex items-center justify-between gap-2 bg-muted/20 px-3 py-1.5 dark:bg-brand-900/30 sm:px-4">
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground dark:text-brand-50 sm:text-sm">
</p>
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70 sm:text-[11px]">
.
</p>
</div>
{/* UI 흐름: 차트 토글 버튼 -> onToggleChart 호출 -> TradeDashboardContent의 상태 변경 -> 차트 wrapper 높이 반영 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={onToggleChart}
className="h-7 gap-1 border-brand-200 bg-white px-2 text-[11px] text-brand-700 hover:bg-brand-50 dark:border-brand-700/55 dark:bg-brand-900/35 dark:text-brand-100 dark:hover:bg-brand-800/35 sm:h-8 sm:px-3 sm:text-xs"
aria-expanded={isChartVisible}
>
{isChartVisible ? (
<>
<ChevronUp className="h-3.5 w-3.5" />
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" />
</>
)}
</Button>
</div>
{/* Right Column: Order Book & Order Form */}
<div className="flex min-h-0 w-full flex-none flex-col bg-background dark:bg-brand-900/12 xl:w-[460px] xl:pr-2 2xl:w-[500px]">
{/* Top: Order Book (Hoga) */}
<div className="h-[390px] flex-none overflow-hidden border-t border-border dark:border-brand-800/45 sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
{orderBook}
</div>
{/* Bottom: Order Form */}
<div className="flex-none h-auto sm:h-auto xl:h-[380px]">
{orderForm}
<div
className={cn(
"overflow-hidden border-t border-border/70 transition-[max-height,opacity] duration-200 dark:border-brand-800/45",
isChartVisible ? "max-h-[56vh] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="h-[34vh] min-h-[280px] w-full sm:h-[40vh] xl:h-[34vh] 2xl:h-[38vh]">
{chart}
</div>
</div>
</section>
{/* ========== ORDERBOOK + ORDER SECTION ========== */}
<div className="flex flex-1 min-h-0 flex-col xl:flex-row xl:overflow-hidden">
<section className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:flex-1 xl:border-b-0 xl:border-r">
<div className="h-[390px] min-h-0 sm:h-[430px] xl:h-full">
{orderBook}
</div>
</section>
<section className="flex min-h-0 flex-col bg-background dark:bg-brand-900/12 xl:w-[430px] 2xl:w-[470px]">
<div className="min-h-[320px] xl:h-full">{orderForm}</div>
</section>
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
import { StockHeader } from "@/features/trade/components/header/StockHeader";
@@ -41,6 +42,9 @@ export function TradeDashboardContent({
change,
changeRate,
}: TradeDashboardContentProps) {
// [State] 차트 영역 보임/숨김 상태
const [isChartVisible, setIsChartVisible] = useState(false);
return (
<div
className={cn(
@@ -93,6 +97,8 @@ export function TradeDashboardContent({
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
isChartVisible={isChartVisible}
onToggleChart={() => setIsChartVisible((prev) => !prev)}
/>
</div>
);

View File

@@ -28,6 +28,40 @@ interface BookRow {
isHighlighted: boolean;
}
/**
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
*/
function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]) {
return levels.some(
(level) =>
level.askPrice > 0 ||
level.bidPrice > 0 ||
level.askSize > 0 ||
level.bidSize > 0,
);
}
/**
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
* @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다.
*/
function buildFallbackLevelsFromTick(latestTick: DashboardRealtimeTradeTick | null) {
if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"];
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
return [] as DashboardStockOrderBookResponse["levels"];
}
return [
{
askPrice: latestTick.askPrice1,
bidPrice: latestTick.bidPrice1,
askSize: Math.max(latestTick.askSize1, 0),
bidSize: Math.max(latestTick.bidSize1, 0),
},
];
}
// ─── 유틸리티 함수 ──────────────────────────────────────
/** 천단위 구분 포맷 */
@@ -111,7 +145,17 @@ export function OrderBook({
orderBook,
isLoading,
}: OrderBookProps) {
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
const realtimeLevels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
const fallbackLevelsFromTick = useMemo(
() => buildFallbackLevelsFromTick(latestTick),
[latestTick],
);
const levels = useMemo(() => {
if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels;
return fallbackLevelsFromTick;
}, [fallbackLevelsFromTick, realtimeLevels]);
const isTickFallbackActive =
!hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0;
// 체결가: tick에서 우선, 없으면 0
const latestPrice =
@@ -164,8 +208,14 @@ export function OrderBook({
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0;
const totalAsk = orderBook?.totalAskSize ?? 0;
const totalBid = orderBook?.totalBidSize ?? 0;
const totalAsk =
orderBook?.totalAskSize && orderBook.totalAskSize > 0
? orderBook.totalAskSize
: (latestTick?.totalAskSize ?? 0);
const totalBid =
orderBook?.totalBidSize && orderBook.totalBidSize > 0
? orderBook.totalBidSize
: (latestTick?.totalBidSize ?? 0);
const imbalance =
totalAsk + totalBid > 0
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
@@ -181,8 +231,10 @@ export function OrderBook({
</div>
);
}
if (isLoading && !orderBook) return <OrderBookSkeleton />;
if (!orderBook) {
if (isLoading && !orderBook && fallbackLevelsFromTick.length === 0) {
return <OrderBookSkeleton />;
}
if (!orderBook && fallbackLevelsFromTick.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
@@ -210,68 +262,72 @@ export function OrderBook({
{/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1">
<div className="block h-full min-h-0 border-t dark:border-brand-800/45 xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
{/* 호가 테이블 */}
<div className="min-h-0 xl:border-r dark:border-brand-800/45">
<BookHeader />
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
{/* 매도호가 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_320px_168px] xl:overflow-hidden">
{/* 호가 테이블 */}
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
{isTickFallbackActive && (
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
(`H0STOAA0`) . (`H0UNCNT0`)
1 .
</div>
)}
<BookHeader />
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
{/* 매도호가 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */}
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
? fmt(bestAsk)
: "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
{/* 중앙 바: 현재 체결가 */}
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
? fmt(bestAsk)
: "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
{/* 매수호가 */}
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 우측 요약 패널 */}
<div className="hidden xl:block">
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
{/* 매수호가 */}
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 체결 목록 */}
<div className="hidden xl:block">
{/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */}
<div className="min-h-[220px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
<TradeTape ticks={recentTicks} />
</div>
{/* 우측 요약 패널 */}
<div className="hidden xl:block min-h-0">
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
</div>
</TabsContent>
@@ -430,7 +486,7 @@ function SummaryPanel({
totalAsk,
totalBid,
}: {
orderBook: DashboardStockOrderBookResponse;
orderBook: DashboardStockOrderBookResponse | null;
latestTick: DashboardRealtimeTradeTick | null;
spread: number;
imbalance: number;
@@ -441,17 +497,17 @@ function SummaryPanel({
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
<Row
label="실시간"
value={orderBook ? "연결됨" : "끊김"}
tone={orderBook ? "bid" : undefined}
value={orderBook || latestTick ? "연결됨" : "끊김"}
tone={orderBook || latestTick ? "bid" : undefined}
/>
<Row
label="거래량"
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
value={fmt(latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0)}
/>
<Row
label="누적거래량"
value={fmt(
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
)}
/>
<Row
@@ -459,12 +515,12 @@ function SummaryPanel({
value={
latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook.anticipatedChangeRate !== undefined
: orderBook?.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-"
}
/>
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
<Row label="예상체결가" value={fmt(orderBook?.anticipatedPrice ?? 0)} />
<Row
label="매도1호가"
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
@@ -546,17 +602,17 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
return (
<div className="border-t bg-background dark:border-brand-800/45 dark:bg-brand-900/20">
<div className="flex h-full min-h-0 flex-col bg-background dark:bg-brand-900/20">
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
<ScrollArea className="h-[162px]">
<ScrollArea className="min-h-0 flex-1">
<div>
{ticks.length === 0 && (
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
<div className="flex min-h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
.
</div>
)}

View File

@@ -50,7 +50,7 @@ export function TradeSearchSection({
onClearHistory,
}: TradeSearchSectionProps) {
return (
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
<div className="z-30 flex-none border-b bg-background/95 px-3 py-2 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
{/* ========== SEARCH SHELL ========== */}
<div
ref={searchShellRef}