Files
goldenChart/frontend/src/components/TradeNotificationListPage.tsx
T
2026-06-08 01:11:57 +09:00

756 lines
28 KiB
TypeScript

/**
* 매매 시그널 알림 목록 화면 — 좌: 알림 목록, 우: 매매·호가 (가상매매 우측 패널과 동일)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import {
loadBacktestSettings,
loadPaperSummary,
loadStrategy,
runBacktest,
type PaperSummaryDto,
} from '../utils/backendApi';
import { loadAnalysisCandles } from '../utils/analysisChartData';
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
import { candleTypeToTimeframe } from '../utils/strategyToChartIndicators';
import { normalizeEpochSec } from '../utils/backtestUiUtils';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
import { coerceFiniteNumber } from '../utils/safeFormat';
import {
TradeSplitOrderPanel,
TradeOrderbookPanelContent,
TradeRightPanelTabs,
TradeRightPanelBody,
} from './trade';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import { useAppSettings } from '../hooks/useAppSettings';
import { readStoredBool, storeBool } from './strategyEditor/usePanelResize';
import '../styles/strategyEditorTheme.css';
import '../styles/virtualTradingDashboard.css';
import '../styles/builderPageShell.css';
import '../styles/tradeNotificationList.css';
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
import { VirtualGridScroll, VirtualScroll } from './common/VirtualScroll';
import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker';
import FullscreenChartModal from './tradeNotification/FullscreenChartModal';
import {
loadTradeNotificationGridPreset,
loadTradeNotificationListLayout,
loadTradeNotificationCandleFilter,
loadTradeNotificationListSort,
saveTradeNotificationCandleFilter,
saveTradeNotificationGridPreset,
saveTradeNotificationListLayout,
saveTradeNotificationListSort,
TRADE_NOTIFICATION_CANDLE_FILTER_OPTIONS,
TRADE_NOTIFICATION_READ_FILTER_OPTIONS,
TRADE_NOTIFICATION_SORT_OPTIONS,
type TradeNotificationCandleFilter,
type TradeNotificationListLayout,
type TradeNotificationListSort,
type TradeNotificationReadFilter,
} from '../utils/tradeNotificationListLayout';
import { normalizeStartCandleType } from '../utils/strategyStartNodes';
import {
getTradeNotificationGridPreset,
type TradeNotificationGridPresetId,
} from '../utils/tradeNotificationGridPresets';
import { sortTradeNotifications } from '../utils/tradeListSort';
type RightTab = 'trade' | 'orderbook';
const TNL_RIGHT_OPEN_KEY = 'tnl-right-open';
const REPORT_BAR_COUNT = 300;
const REPORT_INDICATOR_TYPES = [
'RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological',
] as const;
const IcTrash = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
const IcRefresh = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
);
interface Props {
theme: Theme;
onGoToChart: (market: string) => void;
tickers?: Map<string, TickerData>;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
}
function fillBothSides(
market: string,
price: number,
setFillBuy: React.Dispatch<React.SetStateAction<TradeOrderFillRequest | null>>,
setFillSell: React.Dispatch<React.SetStateAction<TradeOrderFillRequest | null>>,
) {
const seq = Date.now();
setFillBuy({ market, price, side: 'buy', seq });
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
}
export const TradeNotificationListPage: React.FC<Props> = ({
theme,
onGoToChart,
tickers,
defaultMarket = 'KRW-BTC',
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
}) => {
const {
allNotifications,
unreadCount,
markAsRead,
markAllAsRead,
deleteNotification,
deleteNotifications,
deleteAllNotifications,
openDetail,
refreshHistory,
} = useTradeNotification();
const [query, setQuery] = useState('');
const normalizedQuery = useMemo(() => query.trim().toLowerCase(), [query]);
const [filter, setFilter] = useState<TradeNotificationReadFilter>('all');
const [listSort, setListSort] = useState<TradeNotificationListSort>(
() => loadTradeNotificationListSort(),
);
const [candleFilter, setCandleFilter] = useState<TradeNotificationCandleFilter>(
() => loadTradeNotificationCandleFilter(),
);
const [listLayout, setListLayout] = useState<TradeNotificationListLayout>(
() => loadTradeNotificationListLayout(),
);
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(
() => loadTradeNotificationGridPreset(),
);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [reportOpen, setReportOpen] = useState(false);
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
const reportGenRef = useRef(0);
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
const [refreshing, setRefreshing] = useState(false);
const [rightOpen, setRightOpen] = useState(() => readStoredBool(TNL_RIGHT_OPEN_KEY, true));
const [fullscreenItem, setFullscreenItem] = useState<TradeNotificationItem | null>(null);
const { settings: appSettings } = useAppSettings();
const { getParams } = useIndicatorSettings();
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
const refreshPaperData = useCallback(async () => {
try {
const s = await loadPaperSummary();
setSummary(s);
} catch {
setSummary(null);
}
onPaperOrderFilled?.();
}, [onPaperOrderFilled]);
useEffect(() => {
void refreshPaperData();
}, [refreshPaperData]);
const tradePrice = useMemo(
() => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
[tickers, selectedMarket],
);
const posQty = useMemo(() => {
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
return coerceFiniteNumber(p?.quantity) ?? 0;
}, [summary?.positions, selectedMarket]);
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket);
const selectedTicker = tickers?.get(selectedMarket);
const orderbookPrevClose = selectedTicker
? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0)
: 0;
const orderbookTickerInfo = selectedTicker ? {
tradePrice: selectedTicker.tradePrice,
changeRate: selectedTicker.changeRate,
changePrice: selectedTicker.changePrice,
accTradePrice24: selectedTicker.accTradePrice24,
accTradeVolume24: selectedTicker.accTradeVolume24,
highPrice: selectedTicker.highPrice,
lowPrice: selectedTicker.lowPrice,
openingPrice: selectedTicker.openingPrice,
} : undefined;
const handleSelectMarket = useCallback((market: string, price?: number) => {
setSelectedMarket(market);
const p = price ?? coerceFiniteNumber(tickers?.get(market)?.tradePrice);
if (p != null && p > 0) {
fillBothSides(market, p, setFillBuy, setFillSell);
}
setRightTab('trade');
}, [tickers]);
/** 알림 목록 아이템 "매매" 버튼 — 우측 패널 열고 해당 종목 현재가 입력 */
const handleTradeFromAlert = useCallback((item: TradeNotificationItem) => {
if (!item.isRead) markAsRead(item.id);
const price = coerceFiniteNumber(tickers?.get(item.market)?.tradePrice)
?? coerceFiniteNumber(item.price);
handleSelectMarket(item.market, price ?? undefined);
setRightOpen(true);
setRightTab('trade');
}, [markAsRead, tickers, handleSelectMarket]);
const selectedCount = selectedId ? 1 : 0;
const handleNotificationSelect = useCallback((item: TradeNotificationItem) => {
if (!item.isRead) markAsRead(item.id);
setSelectedId(prev => {
if (prev === item.id) return null;
const price = coerceFiniteNumber(item.price);
if (price != null && price > 0) {
fillBothSides(item.market, price, setFillBuy, setFillSell);
} else {
handleSelectMarket(item.market);
}
setRightTab('trade');
return item.id;
});
}, [markAsRead, handleSelectMarket]);
const handleReportFromAlert = useCallback(async (item: TradeNotificationItem) => {
if (!item.isRead) markAsRead(item.id);
if (!item.strategyId) {
window.alert('전략 정보가 없어 상세분석 레포트를 생성할 수 없습니다.');
return;
}
if (reportLoadingId) return;
const gen = ++reportGenRef.current;
setReportLoadingId(item.id);
setReportModel(null);
setReportOpen(false);
try {
const strategy = await loadStrategy(item.strategyId);
if (gen !== reportGenRef.current) return;
if (!strategy) {
window.alert('전략을 불러올 수 없습니다.');
return;
}
const market = item.market.startsWith('KRW-') ? item.market : `KRW-${item.market}`;
const timeframe = candleTypeToTimeframe(item.candleType ?? '1m');
const toTimeSec = normalizeEpochSec(item.candleTime);
const bars = await loadAnalysisCandles(market, timeframe, toTimeSec, REPORT_BAR_COUNT);
if (gen !== reportGenRef.current) return;
if (bars.length < 10) {
window.alert('캔들 데이터가 부족해 분석 레포트를 생성할 수 없습니다.');
return;
}
const strategyName = strategy.name || item.strategyName || '전략';
const btSettings = await loadBacktestSettings();
const res = await runBacktest({
strategyId: item.strategyId,
bars: bars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe,
symbol: market,
strategyName,
settings: btSettings,
indicatorParams: Object.fromEntries(
REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record<string, unknown>]),
),
});
if (gen !== reportGenRef.current) return;
if (!res) {
window.alert('백테스트 분석에 실패했습니다.');
return;
}
const model = buildChartBacktestReportModel({
analysis: res.analysis ?? null,
stats: res.stats ?? null,
signals: res.signals,
strategyName,
symbol: market.replace(/^KRW-/, ''),
timeframe,
barCount: bars.length,
createdAt: new Date(item.receivedAt).toISOString(),
});
if (!model) {
window.alert('분석 데이터를 생성할 수 없습니다.');
return;
}
setReportModel(model);
setReportOpen(true);
} catch {
if (gen !== reportGenRef.current) return;
window.alert('상세분석 레포트 생성 중 오류가 발생했습니다.');
} finally {
if (gen === reportGenRef.current) setReportLoadingId(null);
}
}, [markAsRead, reportLoadingId, getParams]);
const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => {
e.stopPropagation();
if (!window.confirm('이 알림을 삭제하시겠습니까?')) return;
await deleteNotification(item.id);
setSelectedId(prev => (prev === item.id ? null : prev));
}, [deleteNotification]);
const handleDeleteSelected = useCallback(async () => {
if (!selectedId) return;
if (!window.confirm('선택한 알림을 삭제하시겠습니까?')) return;
await deleteNotifications([selectedId]);
setSelectedId(null);
}, [selectedId, deleteNotifications]);
const handleDeleteAll = useCallback(async () => {
if (allNotifications.length === 0) return;
if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return;
await deleteAllNotifications();
setSelectedId(null);
}, [allNotifications.length, deleteAllNotifications]);
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setRightTab('trade');
const side = rowType === 'bid' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req);
else setFillSell(req);
}, [selectedMarket]);
const filtered = useMemo(() => {
let items = filter === 'unread'
? allNotifications.filter(n => !n.isRead)
: allNotifications;
if (candleFilter) {
items = items.filter(
n => normalizeStartCandleType(n.candleType) === candleFilter,
);
}
if (normalizedQuery) {
items = items.filter(n => {
const mkt = n.market.toLowerCase();
const sym = n.market.includes('-') ? n.market.split('-')[1].toLowerCase() : mkt;
const strat = (n.strategyName ?? '').toLowerCase();
const sigKo = n.signalType === 'BUY' ? '매수' : '매도';
return (
mkt.includes(normalizedQuery) ||
sym.includes(normalizedQuery) ||
strat.includes(normalizedQuery) ||
sigKo.includes(normalizedQuery)
);
});
}
return items;
}, [allNotifications, filter, candleFilter, normalizedQuery]);
const sorted = useMemo(
() => sortTradeNotifications(filtered, listSort),
[filtered, listSort],
);
const handleListSortChange = useCallback((sort: TradeNotificationListSort) => {
setListSort(sort);
saveTradeNotificationListSort(sort);
}, []);
const handleCandleFilterChange = useCallback((value: TradeNotificationCandleFilter) => {
setCandleFilter(value);
saveTradeNotificationCandleFilter(value);
}, []);
const handleListLayoutChange = useCallback((layout: TradeNotificationListLayout) => {
setListLayout(layout);
saveTradeNotificationListLayout(layout);
}, []);
const handlePageRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
try {
await Promise.all([
refreshHistory({ serverOnly: true }),
refreshPaperData(),
]);
} finally {
setRefreshing(false);
}
}, [refreshing, refreshHistory, refreshPaperData]);
const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
setGridPreset(preset);
saveTradeNotificationGridPreset(preset);
}, []);
const toggleRightPanel = useCallback(() => {
setRightOpen(prev => {
const next = !prev;
storeBool(TNL_RIGHT_OPEN_KEY, next);
return next;
});
}, []);
const isListView = listLayout === 'list';
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
const renderNotificationRow = useCallback((item: TradeNotificationItem) => (
<TradeNotificationListRow
key={item.id}
item={item}
theme={theme}
layoutMode={listLayout}
itemTag="div"
isSelected={selectedId === item.id}
chartsEnabled={isListView}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
onSelect={() => handleNotificationSelect(item)}
onDelete={e => void handleDeleteOne(item, e)}
onDetail={() => {
if (!item.isRead) markAsRead(item.id);
openDetail(item, { centered: true });
}}
onGoToChart={() => {
markAsRead(item.id);
setFullscreenItem(item);
}}
onTrade={() => handleTradeFromAlert(item)}
onReport={() => void handleReportFromAlert(item)}
reportLoading={reportLoadingId === item.id}
tickers={tickers}
/>
), [
theme,
listLayout,
isListView,
selectedId,
chartLiveReceiveHighlight,
handleNotificationSelect,
handleDeleteOne,
markAsRead,
openDetail,
handleTradeFromAlert,
handleReportFromAlert,
reportLoadingId,
tickers,
]);
return (
<>
{fullscreenItem && (
<FullscreenChartModal
item={fullscreenItem}
theme={theme}
onClose={() => setFullscreenItem(null)}
/>
)}
<BacktestAnalysisReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
model={reportModel}
/>
<div
className={[
'tnl-page',
'tnl-page--with-right',
'bps-page--vtd',
'se-page',
`se-page--${theme}`,
'app',
theme,
rightOpen ? '' : 'tnl-page--right-collapsed',
].filter(Boolean).join(' ')}
>
<div className="tnl-body">
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
<div className="tnl-header">
<h1 className="tnl-title">매매 시그널 알림</h1>
<div className="tnl-toolbar">
{/* 검색란 */}
<div className={`tnl-search-box${query ? ' tnl-search-box--active' : ''}`}>
<svg className="tnl-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
className="tnl-search-input"
placeholder="종목·전략 검색"
value={query}
onChange={e => setQuery(e.target.value)}
aria-label="알림 목록 검색"
/>
{query && (
<button
type="button"
className="tnl-search-clear"
onClick={() => setQuery('')}
aria-label="검색어 초기화"
>
</button>
)}
</div>
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}</span>
{selectedCount > 0 && (
<span className="tnl-chip tnl-chip--selected">선택 {selectedCount}</span>
)}
<div className="tnl-toolbar-selects">
<select
className="tnl-toolbar-select"
value={filter}
aria-label="표시 범위"
onChange={e => setFilter(e.target.value as TradeNotificationReadFilter)}
>
{TRADE_NOTIFICATION_READ_FILTER_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<span className="header-toolbar-divider" aria-hidden="true" />
<select
className="tnl-toolbar-select"
value={listSort}
aria-label="목록 정렬"
onChange={e => handleListSortChange(e.target.value as TradeNotificationListSort)}
>
{TRADE_NOTIFICATION_SORT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<span className="header-toolbar-divider" aria-hidden="true" />
<select
className="tnl-toolbar-select"
value={candleFilter}
aria-label="시간봉 필터"
onChange={e => handleCandleFilterChange(e.target.value as TradeNotificationCandleFilter)}
>
<option value="">전체</option>
{TRADE_NOTIFICATION_CANDLE_FILTER_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{unreadCount > 0 && (
<button type="button" className="tnl-btn tnl-btn--primary" onClick={markAllAsRead}>
모두 읽음
</button>
)}
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
<button
type="button"
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('list')}
>
목록형
</button>
<button
type="button"
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('grid')}
>
그리드형
</button>
</div>
<span className="header-toolbar-divider" aria-hidden="true" />
<div className="tnl-toolbar-end">
<button
type="button"
className={['tnl-icon-btn', refreshing ? 'tnl-icon-btn--refreshing' : ''].filter(Boolean).join(' ')}
title="화면 새로고침"
aria-label="화면 새로고침"
disabled={refreshing}
onClick={() => void handlePageRefresh()}
>
<IcRefresh />
</button>
<TradeNotificationGridLayoutPicker
value={gridPreset}
onChange={handleGridPresetChange}
disabled={isListView}
/>
<button
type="button"
className="tnl-icon-btn"
title={selectedCount > 0 ? `선택 알림 삭제 (${selectedCount}건)` : '선택 알림 삭제'}
aria-label={selectedCount > 0 ? `선택 알림 ${selectedCount}건 삭제` : '선택 알림 삭제'}
disabled={selectedCount === 0}
onClick={() => void handleDeleteSelected()}
>
<IcTrash />
</button>
<button
type="button"
className="tnl-icon-btn tnl-icon-btn--danger"
title="모두 삭제"
aria-label="모두 삭제"
disabled={allNotifications.length === 0}
onClick={() => void handleDeleteAll()}
>
<IcTrash />
</button>
</div>
</div>
</div>
<div className={['tnl-list-wrap', !isListView ? 'tnl-list-wrap--grid' : ''].filter(Boolean).join(' ')}>
{sorted.length === 0 ? (
<div className="tnl-empty">
{normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'}
</div>
) : isListView ? (
<VirtualScroll
className="tnl-list tnl-list--gallery vl-scroll"
items={sorted}
estimateSize={384}
measureDynamic
overscan={3}
getItemKey={item => item.id}
role="list"
aria-label="매매 시그널 알림 목록"
>
{item => renderNotificationRow(item)}
</VirtualScroll>
) : (
<VirtualGridScroll
className={[
'tnl-list',
'tnl-list--grid',
'vl-scroll',
`tnl-grid--cols-${gridCols}`,
].join(' ')}
style={{ ['--tnl-grid-cols' as string]: gridCols }}
rowClassName="vl-grid-row tnl-grid-row"
items={sorted}
columns={gridCols}
estimateRowSize={300}
rowGap={14}
measureDynamic
remeasureKey={gridCols}
overscan={2}
getItemKey={item => item.id}
role="list"
aria-label="매매 시그널 알림 그리드"
>
{item => renderNotificationRow(item)}
</VirtualGridScroll>
)}
</div>
</div>
<div className={`tnl-side-wrap tnl-side-wrap--right${rightOpen ? ' tnl-side-wrap--open' : ''}`}>
<button
type="button"
className={`bps-panel-handle bps-panel-handle--right${rightOpen ? ' bps-panel-handle--open' : ''}`}
onClick={toggleRightPanel}
title={rightOpen ? '매매·호가 패널 닫기' : '매매·호가 패널 열기'}
aria-expanded={rightOpen}
aria-label={rightOpen ? '매매·호가 패널 닫기' : '매매·호가 패널 열기'}
>
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{rightOpen ? (
<polyline points="2,1 6,7 2,13" />
) : (
<polyline points="6,1 2,7 6,13" />
)}
</svg>
</button>
<aside
className={`tnl-right trade-right-panel${rightOpen ? ' tnl-right--open' : ''}`}
aria-label="매매·호가"
aria-hidden={!rightOpen}
>
<TradeRightPanelTabs
activeTab={rightTab}
onTabChange={setRightTab}
className="tnl-right-tabs"
/>
<TradeRightPanelBody
anchorRef={orderAnchorRef}
bodyClassName="tnl-right-body"
>
{rightTab === 'trade' ? (
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={m => handleSelectMarket(m)}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData()}
/>
) : (
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
)}
</TradeRightPanelBody>
</aside>
</div>
</div>
</div>
</>
);
};
export default TradeNotificationListPage;