전체적인 리팩토링
This commit is contained in:
@@ -10,6 +10,7 @@ import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-st
|
||||
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
|
||||
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
|
||||
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
|
||||
import { AutotradeControlPanel } from "@/features/autotrade/components/AutotradeControlPanel";
|
||||
import { useStockSearch } from "@/features/trade/hooks/useStockSearch";
|
||||
import { useOrderBook } from "@/features/trade/hooks/useOrderBook";
|
||||
import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket";
|
||||
@@ -40,6 +41,8 @@ export function TradeContainer() {
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
// [State] 선택 종목과 매칭할 보유 종목 목록
|
||||
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
|
||||
// [State] 주문 패널에서 사용할 가용 예수금 스냅샷(원)
|
||||
const [availableCashBalance, setAvailableCashBalance] = useState<number | null>(null);
|
||||
const { verifiedCredentials, isKisVerified, _hasHydrated } =
|
||||
useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
@@ -125,15 +128,18 @@ export function TradeContainer() {
|
||||
const loadHoldingsSnapshot = useCallback(async () => {
|
||||
if (!verifiedCredentials?.accountNo?.trim()) {
|
||||
setHoldings([]);
|
||||
setAvailableCashBalance(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await fetchDashboardBalance(verifiedCredentials);
|
||||
setHoldings(balance.holdings);
|
||||
setHoldings(balance.holdings.filter((item) => item.quantity > 0));
|
||||
setAvailableCashBalance(balance.summary.cashBalance);
|
||||
} catch {
|
||||
// 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다.
|
||||
setHoldings([]);
|
||||
setAvailableCashBalance(null);
|
||||
}
|
||||
}, [verifiedCredentials]);
|
||||
|
||||
@@ -328,6 +334,13 @@ export function TradeContainer() {
|
||||
onClearHistory={clearSearchHistory}
|
||||
/>
|
||||
|
||||
<AutotradeControlPanel
|
||||
selectedStock={selectedStock}
|
||||
latestTick={latestTick}
|
||||
credentials={verifiedCredentials}
|
||||
canTrade={canTrade}
|
||||
/>
|
||||
|
||||
{/* ========== DASHBOARD SECTION ========== */}
|
||||
<TradeDashboardContent
|
||||
selectedStock={selectedStock}
|
||||
@@ -338,6 +351,7 @@ export function TradeContainer() {
|
||||
isOrderBookLoading={isOrderBookLoading}
|
||||
referencePrice={referencePrice}
|
||||
matchedHolding={matchedHolding}
|
||||
availableCashBalance={availableCashBalance}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,10 +39,14 @@ import {
|
||||
CHART_MIN_HEIGHT,
|
||||
DEFAULT_CHART_PALETTE,
|
||||
getChartPaletteFromCssVars,
|
||||
HISTORY_LOAD_TRIGGER_BARS_BEFORE,
|
||||
INITIAL_MINUTE_PREFETCH_BUDGET_MS,
|
||||
MINUTE_SYNC_INTERVAL_MS,
|
||||
MINUTE_TIMEFRAMES,
|
||||
PERIOD_TIMEFRAMES,
|
||||
REALTIME_STALE_THRESHOLD_MS,
|
||||
resolveInitialMinutePrefetchPages,
|
||||
resolveInitialMinuteTargetBars,
|
||||
UP_COLOR,
|
||||
} from "./stock-line-chart-meta";
|
||||
|
||||
@@ -101,6 +105,9 @@ export function StockLineChart({
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
const pendingFitContentRef = useRef(false);
|
||||
const nextCursorRef = useRef<string | null>(null);
|
||||
const autoFillLeftGapRef = useRef(false);
|
||||
|
||||
// API 오류 시 fallback 용도로 유지
|
||||
const latestCandlesRef = useRef(candles);
|
||||
@@ -108,6 +115,10 @@ export function StockLineChart({
|
||||
latestCandlesRef.current = candles;
|
||||
}, [candles]);
|
||||
|
||||
useEffect(() => {
|
||||
nextCursorRef.current = nextCursor;
|
||||
}, [nextCursor]);
|
||||
|
||||
const latest = bars.at(-1);
|
||||
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
|
||||
const change = latest ? latest.close - prevClose : 0;
|
||||
@@ -196,7 +207,13 @@ export function StockLineChart({
|
||||
|
||||
const olderBars = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(olderBars, prev));
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
setNextCursor(
|
||||
response.hasMore &&
|
||||
response.nextCursor &&
|
||||
response.nextCursor !== nextCursor
|
||||
? response.nextCursor
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
@@ -213,6 +230,58 @@ export function StockLineChart({
|
||||
loadMoreHandlerRef.current = handleLoadMore;
|
||||
}, [handleLoadMore]);
|
||||
|
||||
const fillLeftWhitespaceIfNeeded = useCallback(async () => {
|
||||
if (!isMinuteTimeframe(timeframe)) return;
|
||||
if (autoFillLeftGapRef.current) return;
|
||||
if (loadingMoreRef.current) return;
|
||||
if (!nextCursorRef.current) return;
|
||||
|
||||
const chart = chartRef.current;
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
if (!chart || !candleSeries) return;
|
||||
|
||||
autoFillLeftGapRef.current = true;
|
||||
const startedAt = Date.now();
|
||||
let rounds = 0;
|
||||
|
||||
try {
|
||||
while (
|
||||
rounds < 16 &&
|
||||
Date.now() - startedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS
|
||||
) {
|
||||
const range = chart.timeScale().getVisibleLogicalRange();
|
||||
if (!range) break;
|
||||
|
||||
const barsInfo = candleSeries.barsInLogicalRange(range);
|
||||
const hasLeftWhitespace =
|
||||
Boolean(
|
||||
barsInfo &&
|
||||
Number.isFinite(barsInfo.barsBefore) &&
|
||||
barsInfo.barsBefore < 0,
|
||||
) || false;
|
||||
|
||||
if (!hasLeftWhitespace) break;
|
||||
|
||||
const cursorBefore = nextCursorRef.current;
|
||||
if (!cursorBefore) break;
|
||||
|
||||
await loadMoreHandlerRef.current();
|
||||
rounds += 1;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(() => resolve(), 120);
|
||||
});
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
const cursorAfter = nextCursorRef.current;
|
||||
if (!cursorAfter || cursorAfter === cursorBefore) break;
|
||||
}
|
||||
} finally {
|
||||
autoFillLeftGapRef.current = false;
|
||||
}
|
||||
}, [timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRealtimeKeyRef.current = "";
|
||||
lastRealtimeAppliedAtRef.current = 0;
|
||||
@@ -257,7 +326,10 @@ export function StockLineChart({
|
||||
borderColor: palette.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
rightOffset: 4,
|
||||
barSpacing: 6,
|
||||
minBarSpacing: 1,
|
||||
rightBarStaysOnScroll: true,
|
||||
tickMarkFormatter: formatKstTickMark,
|
||||
},
|
||||
handleScroll: {
|
||||
@@ -298,15 +370,29 @@ export function StockLineChart({
|
||||
});
|
||||
|
||||
let scrollTimeout: number | undefined;
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
||||
const handleVisibleLogicalRangeChange = (range: {
|
||||
from: number;
|
||||
to: number;
|
||||
} | null) => {
|
||||
if (!range || !initialLoadCompleteRef.current) return;
|
||||
if (range.from >= 10) return;
|
||||
|
||||
const barsInfo = candleSeries.barsInLogicalRange(range);
|
||||
if (!barsInfo) return;
|
||||
if (
|
||||
Number.isFinite(barsInfo.barsBefore) &&
|
||||
barsInfo.barsBefore > HISTORY_LOAD_TRIGGER_BARS_BEFORE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
void loadMoreHandlerRef.current();
|
||||
}, 250);
|
||||
});
|
||||
};
|
||||
chart
|
||||
.timeScale()
|
||||
.subscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
|
||||
|
||||
chartRef.current = chart;
|
||||
candleSeriesRef.current = candleSeries;
|
||||
@@ -330,6 +416,9 @@ export function StockLineChart({
|
||||
|
||||
return () => {
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
chart
|
||||
.timeScale()
|
||||
.unsubscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
@@ -386,6 +475,8 @@ export function StockLineChart({
|
||||
if (!symbol || !credentials) return;
|
||||
|
||||
initialLoadCompleteRef.current = false;
|
||||
pendingFitContentRef.current = true;
|
||||
autoFillLeftGapRef.current = false;
|
||||
let disposed = false;
|
||||
let initialLoadTimer: number | null = null;
|
||||
|
||||
@@ -401,16 +492,24 @@ export function StockLineChart({
|
||||
? firstPage.nextCursor
|
||||
: null;
|
||||
|
||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||
// 분봉은 시간프레임별 목표 봉 수까지 순차 조회해 초기 과거 가시성을 보강합니다.
|
||||
if (
|
||||
isMinuteTimeframe(timeframe) &&
|
||||
firstPage.hasMore &&
|
||||
firstPage.nextCursor
|
||||
) {
|
||||
const targetBars = resolveInitialMinuteTargetBars(timeframe);
|
||||
const maxPrefetchPages = resolveInitialMinutePrefetchPages(timeframe);
|
||||
const prefetchStartedAt = Date.now();
|
||||
let minuteCursor: string | null = firstPage.nextCursor;
|
||||
let extraPageCount = 0;
|
||||
|
||||
while (minuteCursor && extraPageCount < 2) {
|
||||
while (
|
||||
minuteCursor &&
|
||||
extraPageCount < maxPrefetchPages &&
|
||||
Date.now() - prefetchStartedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS &&
|
||||
mergedBars.length < targetBars
|
||||
) {
|
||||
try {
|
||||
const olderPage = await fetchStockChart(
|
||||
symbol,
|
||||
@@ -421,10 +520,14 @@ export function StockLineChart({
|
||||
|
||||
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = olderPage.hasMore
|
||||
const nextMinuteCursor = olderPage.hasMore
|
||||
? olderPage.nextCursor
|
||||
: null;
|
||||
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
resolvedNextCursor = nextMinuteCursor;
|
||||
minuteCursor =
|
||||
nextMinuteCursor && nextMinuteCursor !== minuteCursor
|
||||
? nextMinuteCursor
|
||||
: null;
|
||||
extraPageCount += 1;
|
||||
} catch {
|
||||
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
|
||||
@@ -469,10 +572,25 @@ export function StockLineChart({
|
||||
if (!isChartReady) return;
|
||||
|
||||
setSeriesData(renderableBars);
|
||||
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
||||
if (renderableBars.length === 0) return;
|
||||
|
||||
if (pendingFitContentRef.current) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
pendingFitContentRef.current = false;
|
||||
} else if (!initialLoadCompleteRef.current) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
if (nextCursorRef.current) {
|
||||
void fillLeftWhitespaceIfNeeded();
|
||||
}
|
||||
}, [
|
||||
fillLeftWhitespaceIfNeeded,
|
||||
isChartReady,
|
||||
renderableBars,
|
||||
setSeriesData,
|
||||
timeframe,
|
||||
]);
|
||||
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
@@ -495,7 +613,7 @@ export function StockLineChart({
|
||||
}, [latestTick, timeframe]);
|
||||
|
||||
/**
|
||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @description 분봉(1m/5m/10m/15m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
|
||||
@@ -150,8 +150,7 @@ function resolveBarTimestamp(
|
||||
|
||||
/**
|
||||
* 타임스탬프를 타임프레임 버킷 경계에 정렬
|
||||
* - 1m: 초/밀리초를 제거해 분 경계에 정렬
|
||||
* - 30m/1h: 분 단위를 버킷에 정렬
|
||||
* - 분봉(1/5/10/15/30/60분): 분 단위를 버킷 경계에 정렬
|
||||
* - 1d: 00:00:00
|
||||
* - 1w: 월요일 00:00:00
|
||||
*/
|
||||
@@ -160,12 +159,14 @@ function alignTimestamp(
|
||||
timeframe: DashboardChartTimeframe,
|
||||
): UTCTimestamp {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const minuteBucket = resolveMinuteBucket(timeframe);
|
||||
|
||||
if (timeframe === "1m") {
|
||||
d.setUTCSeconds(0, 0);
|
||||
} else if (timeframe === "30m" || timeframe === "1h") {
|
||||
const bucket = timeframe === "30m" ? 30 : 60;
|
||||
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
|
||||
if (minuteBucket !== null) {
|
||||
d.setUTCMinutes(
|
||||
Math.floor(d.getUTCMinutes() / minuteBucket) * minuteBucket,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
} else if (timeframe === "1d") {
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
} else if (timeframe === "1w") {
|
||||
@@ -300,7 +301,17 @@ export function formatSignedPercent(value: number) {
|
||||
* 분봉 타임프레임인지 판별
|
||||
*/
|
||||
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
|
||||
return tf === "1m" || tf === "30m" || tf === "1h";
|
||||
return resolveMinuteBucket(tf) !== null;
|
||||
}
|
||||
|
||||
function resolveMinuteBucket(tf: DashboardChartTimeframe): number | null {
|
||||
if (tf === "1m") return 1;
|
||||
if (tf === "5m") return 5;
|
||||
if (tf === "10m") return 10;
|
||||
if (tf === "15m") return 15;
|
||||
if (tf === "30m") return 30;
|
||||
if (tf === "1h") return 60;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTickTime(value?: string) {
|
||||
|
||||
@@ -5,6 +5,8 @@ export const UP_COLOR = "#ef4444";
|
||||
export const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||
export const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
export const CHART_MIN_HEIGHT = 220;
|
||||
export const HISTORY_LOAD_TRIGGER_BARS_BEFORE = 40;
|
||||
export const INITIAL_MINUTE_PREFETCH_BUDGET_MS = 12000;
|
||||
|
||||
export interface ChartPalette {
|
||||
backgroundColor: string;
|
||||
@@ -31,6 +33,9 @@ export const MINUTE_TIMEFRAMES: Array<{
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "1m", label: "1분" },
|
||||
{ value: "5m", label: "5분" },
|
||||
{ value: "10m", label: "10분" },
|
||||
{ value: "15m", label: "15분" },
|
||||
{ value: "30m", label: "30분" },
|
||||
{ value: "1h", label: "1시간" },
|
||||
];
|
||||
@@ -43,6 +48,30 @@ export const PERIOD_TIMEFRAMES: Array<{
|
||||
{ value: "1w", label: "주" },
|
||||
];
|
||||
|
||||
export function resolveInitialMinuteTargetBars(
|
||||
timeframe: DashboardChartTimeframe,
|
||||
) {
|
||||
if (timeframe === "1m") return 260;
|
||||
if (timeframe === "5m") return 240;
|
||||
if (timeframe === "10m") return 220;
|
||||
if (timeframe === "15m") return 200;
|
||||
if (timeframe === "30m") return 180;
|
||||
if (timeframe === "1h") return 260;
|
||||
return 140;
|
||||
}
|
||||
|
||||
export function resolveInitialMinutePrefetchPages(
|
||||
timeframe: DashboardChartTimeframe,
|
||||
) {
|
||||
if (timeframe === "1m") return 24;
|
||||
if (timeframe === "5m") return 28;
|
||||
if (timeframe === "10m") return 32;
|
||||
if (timeframe === "15m") return 36;
|
||||
if (timeframe === "30m") return 44;
|
||||
if (timeframe === "1h") return 80;
|
||||
return 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다.
|
||||
* @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영
|
||||
|
||||
@@ -51,7 +51,7 @@ export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
|
||||
try {
|
||||
const data = await fetchDashboardBalance(credentials);
|
||||
setSummary(data.summary);
|
||||
setHoldings(data.holdings);
|
||||
setHoldings(data.holdings.filter((item) => item.quantity > 0));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
@@ -185,9 +185,10 @@ export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
|
||||
{!isLoading && !error && holdings.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65">
|
||||
<div className="grid min-w-[700px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65">
|
||||
<div>종목명</div>
|
||||
<div className="text-right">보유수량</div>
|
||||
<div className="text-right">매도가능</div>
|
||||
<div className="text-right">평균단가</div>
|
||||
<div className="text-right">현재가</div>
|
||||
<div className="text-right">평가손익</div>
|
||||
@@ -238,7 +239,7 @@ function SummaryItem({
|
||||
/** 보유 종목 행 */
|
||||
function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
|
||||
return (
|
||||
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20">
|
||||
<div className="grid min-w-[700px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20">
|
||||
{/* 종목명 */}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground dark:text-brand-50">
|
||||
@@ -254,6 +255,11 @@ function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
|
||||
{fmt(holding.quantity)}주
|
||||
</div>
|
||||
|
||||
{/* 매도가능수량 */}
|
||||
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
|
||||
{fmt(holding.sellableQuantity)}주
|
||||
</div>
|
||||
|
||||
{/* 평균단가 */}
|
||||
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
|
||||
{fmt(holding.averagePrice)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
interface TradeDashboardContentProps {
|
||||
selectedStock: DashboardStockItem | null;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
availableCashBalance: number | null;
|
||||
verifiedCredentials: KisRuntimeCredentials | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
recentTradeTicks: DashboardRealtimeTradeTick[];
|
||||
@@ -31,6 +32,7 @@ interface TradeDashboardContentProps {
|
||||
export function TradeDashboardContent({
|
||||
selectedStock,
|
||||
matchedHolding,
|
||||
availableCashBalance,
|
||||
verifiedCredentials,
|
||||
latestTick,
|
||||
recentTradeTicks,
|
||||
@@ -78,8 +80,10 @@ export function TradeDashboardContent({
|
||||
}
|
||||
orderForm={
|
||||
<OrderForm
|
||||
key={selectedStock?.symbol ?? "order-form-empty"}
|
||||
stock={selectedStock ?? undefined}
|
||||
matchedHolding={matchedHolding}
|
||||
availableCashBalance={availableCashBalance}
|
||||
/>
|
||||
}
|
||||
isChartVisible={isChartVisible}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { fetchOrderableCashEstimate } from "@/features/trade/apis/kis-stock.api";
|
||||
import { useOrder } from "@/features/trade/hooks/useOrder";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type {
|
||||
@@ -18,6 +19,7 @@ import { cn } from "@/lib/utils";
|
||||
interface OrderFormProps {
|
||||
stock?: DashboardStockItem;
|
||||
matchedHolding?: DashboardHoldingItem | null;
|
||||
availableCashBalance?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +27,11 @@ interface OrderFormProps {
|
||||
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
|
||||
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
|
||||
*/
|
||||
export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
export function OrderForm({
|
||||
stock,
|
||||
matchedHolding,
|
||||
availableCashBalance = null,
|
||||
}: OrderFormProps) {
|
||||
const verifiedCredentials = useKisRuntimeStore(
|
||||
(state) => state.verifiedCredentials,
|
||||
);
|
||||
@@ -37,6 +43,69 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
);
|
||||
const [quantity, setQuantity] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||
const [orderableCash, setOrderableCash] = useState<number | null>(null);
|
||||
const [isOrderableCashLoading, setIsOrderableCashLoading] = useState(false);
|
||||
const stockSymbol = stock?.symbol ?? null;
|
||||
const sellableQuantity = matchedHolding?.sellableQuantity ?? 0;
|
||||
const hasSellableQuantity = sellableQuantity > 0;
|
||||
const effectiveOrderableCash = orderableCash ?? availableCashBalance ?? null;
|
||||
|
||||
// [Effect] 종목/가격 변경 시 매수가능금액(주문가능 예수금)을 다시 조회합니다.
|
||||
useEffect(() => {
|
||||
if (activeTab !== "buy") return;
|
||||
if (!stockSymbol || !verifiedCredentials) {
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setOrderableCash(null);
|
||||
setIsOrderableCashLoading(false);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(resetTimerId);
|
||||
};
|
||||
}
|
||||
|
||||
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
||||
if (Number.isNaN(priceNum) || priceNum <= 0) {
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setOrderableCash(null);
|
||||
setIsOrderableCashLoading(false);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(resetTimerId);
|
||||
};
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
setIsOrderableCashLoading(true);
|
||||
void fetchOrderableCashEstimate(
|
||||
{
|
||||
symbol: stockSymbol,
|
||||
price: priceNum,
|
||||
orderType: "limit",
|
||||
},
|
||||
verifiedCredentials,
|
||||
)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setOrderableCash(Math.max(0, Math.floor(response.orderableCash)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// 조회 실패 시 대시보드 예수금 스냅샷을 fallback으로 사용합니다.
|
||||
setOrderableCash(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setIsOrderableCashLoading(false);
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timerId);
|
||||
};
|
||||
}, [activeTab, stockSymbol, verifiedCredentials, price]);
|
||||
|
||||
// ========== ORDER HANDLER ==========
|
||||
/**
|
||||
@@ -56,6 +125,31 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
alert("수량을 올바르게 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (side === "buy" && effectiveOrderableCash !== null) {
|
||||
const requestedAmount = priceNum * qtyNum;
|
||||
if (requestedAmount > effectiveOrderableCash) {
|
||||
alert(
|
||||
`주문가능 예수금(${effectiveOrderableCash.toLocaleString("ko-KR")}원)을 초과했습니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (side === "sell") {
|
||||
if (!matchedHolding) {
|
||||
alert("보유 종목 정보가 없어 매도 주문을 진행할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sellableQuantity <= 0) {
|
||||
alert("매도가능수량이 0주입니다. 체결/정산 상태를 확인해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (qtyNum > sellableQuantity) {
|
||||
alert(`매도가능수량(${sellableQuantity.toLocaleString("ko-KR")}주)을 초과했습니다.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!verifiedCredentials.accountNo) {
|
||||
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
|
||||
return;
|
||||
@@ -96,11 +190,34 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100;
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return;
|
||||
|
||||
// UI 흐름: 비율 버튼 클릭 -> 주문가능 예수금 기준 계산(매수 탭) -> 주문수량 입력값 반영
|
||||
if (activeTab === "buy") {
|
||||
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
||||
if (Number.isNaN(priceNum) || priceNum <= 0) {
|
||||
alert("가격을 먼저 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectiveOrderableCash === null || effectiveOrderableCash <= 0) {
|
||||
alert("주문가능 예수금을 확인할 수 없어 비율 계산을 할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const calculatedQuantity = Math.floor((effectiveOrderableCash * ratio) / priceNum);
|
||||
if (calculatedQuantity <= 0) {
|
||||
alert("선택한 비율로 주문 가능한 수량이 없습니다. 가격 또는 비율을 조정해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setQuantity(String(calculatedQuantity));
|
||||
return;
|
||||
}
|
||||
|
||||
// UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영
|
||||
if (activeTab === "sell" && matchedHolding?.quantity) {
|
||||
if (activeTab === "sell" && hasSellableQuantity) {
|
||||
const calculatedQuantity = Math.max(
|
||||
1,
|
||||
Math.floor(matchedHolding.quantity * ratio),
|
||||
Math.floor(sellableQuantity * ratio),
|
||||
);
|
||||
setQuantity(String(calculatedQuantity));
|
||||
}
|
||||
@@ -108,6 +225,12 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
|
||||
const isMarketDataAvailable = Boolean(stock);
|
||||
const isBuy = activeTab === "buy";
|
||||
const buyOrderableValue =
|
||||
isOrderableCashLoading
|
||||
? "조회 중..."
|
||||
: effectiveOrderableCash === null
|
||||
? "- KRW"
|
||||
: `${effectiveOrderableCash.toLocaleString("ko-KR")}원`;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
|
||||
@@ -179,9 +302,18 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
orderableValue={buyOrderableValue}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/65">
|
||||
비율 버튼은 주문가능 예수금 기준으로 매수 수량을 계산합니다.
|
||||
</p>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
{!matchedHolding && (
|
||||
<p className="text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
현재 선택한 종목의 보유 수량이 없어 매도 주문을 보낼 수 없습니다.
|
||||
</p>
|
||||
)}
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-red-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(220,38,38,0.4)] ring-1 ring-red-300/30 transition-all hover:bg-red-700 hover:shadow-[0_4px_20px_rgba(220,38,38,0.5)] dark:bg-red-500 dark:ring-red-300/40 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
||||
@@ -212,19 +344,29 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||
disabled={!isMarketDataAvailable}
|
||||
hasError={Boolean(error)}
|
||||
errorMessage={error}
|
||||
orderableValue={
|
||||
matchedHolding
|
||||
? `${sellableQuantity.toLocaleString("ko-KR")}주`
|
||||
: "- 주"
|
||||
}
|
||||
/>
|
||||
<PercentButtons onSelect={setPercent} />
|
||||
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||
<HoldingInfoPanel holding={matchedHolding} />
|
||||
<Button
|
||||
className="h-11 w-full rounded-lg bg-blue-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(37,99,235,0.4)] ring-1 ring-blue-300/30 transition-all hover:bg-blue-700 hover:shadow-[0_4px_20px_rgba(37,99,235,0.5)] dark:bg-blue-500 dark:ring-blue-300/40 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
||||
disabled={isLoading || !isMarketDataAvailable}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!isMarketDataAvailable ||
|
||||
!matchedHolding ||
|
||||
!hasSellableQuantity
|
||||
}
|
||||
onClick={() => handleOrder("sell")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 animate-spin" />
|
||||
) : (
|
||||
"매도하기"
|
||||
hasSellableQuantity ? "매도하기" : "매도가능수량 없음"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,6 +390,7 @@ function OrderInputs({
|
||||
disabled,
|
||||
hasError,
|
||||
errorMessage,
|
||||
orderableValue,
|
||||
}: {
|
||||
type: "buy" | "sell";
|
||||
price: string;
|
||||
@@ -258,6 +401,7 @@ function OrderInputs({
|
||||
disabled: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
orderableValue: string;
|
||||
}) {
|
||||
const labelClass =
|
||||
"text-xs font-medium text-foreground/80 dark:text-brand-100/80 min-w-[56px]";
|
||||
@@ -272,7 +416,7 @@ function OrderInputs({
|
||||
주문가능
|
||||
</span>
|
||||
<span className="font-medium text-foreground dark:text-brand-50">
|
||||
- {type === "buy" ? "KRW" : "주"}
|
||||
{orderableValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -366,6 +510,10 @@ function HoldingInfoPanel({
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
<HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}주`} />
|
||||
<HoldingInfoRow
|
||||
label="매도가능수량"
|
||||
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<HoldingInfoRow
|
||||
label="평균단가"
|
||||
value={`${holding.averagePrice.toLocaleString("ko-KR")}원`}
|
||||
|
||||
@@ -160,7 +160,7 @@ export function BookSideRows({
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
"w-[48px] shrink-0 text-right text-[10px] tabular-nums xl:w-[56px]",
|
||||
getChangeToneClass(row.changeValue),
|
||||
)}
|
||||
>
|
||||
@@ -168,6 +168,14 @@ export function BookSideRows({
|
||||
? "-"
|
||||
: fmtSignedChange(row.changeValue)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-[52px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||
getChangeToneClass(row.changeRate),
|
||||
)}
|
||||
>
|
||||
{row.changeRate === null ? "-" : fmtPct(row.changeRate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface BookRow {
|
||||
price: number;
|
||||
size: number;
|
||||
changeValue: number | null;
|
||||
changeRate: number | null;
|
||||
isHighlighted: boolean;
|
||||
}
|
||||
|
||||
@@ -166,11 +167,13 @@ export function buildBookRows({
|
||||
const price = side === "ask" ? level.askPrice : level.bidPrice;
|
||||
const size = side === "ask" ? level.askSize : level.bidSize;
|
||||
const changeValue = resolvePriceChange(price, basePrice);
|
||||
const changeRate = resolvePriceChangeRate(price, basePrice);
|
||||
|
||||
return {
|
||||
price,
|
||||
size: Math.max(size, 0),
|
||||
changeValue,
|
||||
changeRate,
|
||||
isHighlighted: latestPrice > 0 && price === latestPrice,
|
||||
} satisfies BookRow;
|
||||
});
|
||||
@@ -208,3 +211,10 @@ function resolvePriceChange(price: number, basePrice: number) {
|
||||
}
|
||||
return price - basePrice;
|
||||
}
|
||||
|
||||
function resolvePriceChangeRate(price: number, basePrice: number) {
|
||||
if (price <= 0 || basePrice <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ((price - basePrice) / basePrice) * 100;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user