알림목록 단일 선택모드
This commit is contained in:
@@ -5,7 +5,20 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import type { Theme, TradeOrderFillRequest } from '../types';
|
import type { Theme, TradeOrderFillRequest } from '../types';
|
||||||
import type { TickerData } from '../hooks/useMarketTicker';
|
import type { TickerData } from '../hooks/useMarketTicker';
|
||||||
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
||||||
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
|
import {
|
||||||
|
DEFAULT_BACKTEST_SETTINGS,
|
||||||
|
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 { coerceFiniteNumber } from '../utils/safeFormat';
|
||||||
import {
|
import {
|
||||||
TradeSplitOrderPanel,
|
TradeSplitOrderPanel,
|
||||||
@@ -51,6 +64,10 @@ import { sortTradeNotifications } from '../utils/tradeListSort';
|
|||||||
type RightTab = 'trade' | 'orderbook';
|
type RightTab = 'trade' | 'orderbook';
|
||||||
|
|
||||||
const TNL_RIGHT_OPEN_KEY = 'tnl-right-open';
|
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 = () => (
|
const IcTrash = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
@@ -128,7 +145,11 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
() => loadTradeNotificationGridPreset(),
|
() => loadTradeNotificationGridPreset(),
|
||||||
);
|
);
|
||||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
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 [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
@@ -138,6 +159,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
const [rightOpen, setRightOpen] = useState(() => readStoredBool(TNL_RIGHT_OPEN_KEY, true));
|
const [rightOpen, setRightOpen] = useState(() => readStoredBool(TNL_RIGHT_OPEN_KEY, true));
|
||||||
const [fullscreenItem, setFullscreenItem] = useState<TradeNotificationItem | null>(null);
|
const [fullscreenItem, setFullscreenItem] = useState<TradeNotificationItem | null>(null);
|
||||||
const { settings: appSettings } = useAppSettings();
|
const { settings: appSettings } = useAppSettings();
|
||||||
|
const { getParams } = useIndicatorSettings();
|
||||||
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
|
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
|
||||||
|
|
||||||
const refreshPaperData = useCallback(async () => {
|
const refreshPaperData = useCallback(async () => {
|
||||||
@@ -200,15 +222,12 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
setRightTab('trade');
|
setRightTab('trade');
|
||||||
}, [markAsRead, tickers, handleSelectMarket]);
|
}, [markAsRead, tickers, handleSelectMarket]);
|
||||||
|
|
||||||
const selectedCount = selectedIds.size;
|
const selectedCount = selectedId ? 1 : 0;
|
||||||
|
|
||||||
const handleNotificationSelect = useCallback((item: TradeNotificationItem) => {
|
const handleNotificationSelect = useCallback((item: TradeNotificationItem) => {
|
||||||
if (!item.isRead) markAsRead(item.id);
|
if (!item.isRead) markAsRead(item.id);
|
||||||
setSelectedIds(prev => {
|
setSelectedId(prev => {
|
||||||
const adding = !prev.has(item.id);
|
if (prev === item.id) return null;
|
||||||
const next = new Set(prev);
|
|
||||||
if (adding) {
|
|
||||||
next.add(item.id);
|
|
||||||
const price = coerceFiniteNumber(item.price);
|
const price = coerceFiniteNumber(item.price);
|
||||||
if (price != null && price > 0) {
|
if (price != null && price > 0) {
|
||||||
fillBothSides(item.market, price, setFillBuy, setFillSell);
|
fillBothSides(item.market, price, setFillBuy, setFillSell);
|
||||||
@@ -216,38 +235,110 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
handleSelectMarket(item.market);
|
handleSelectMarket(item.market);
|
||||||
}
|
}
|
||||||
setRightTab('trade');
|
setRightTab('trade');
|
||||||
} else {
|
return item.id;
|
||||||
next.delete(item.id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, [markAsRead, handleSelectMarket]);
|
}, [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 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: DEFAULT_BACKTEST_SETTINGS,
|
||||||
|
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) => {
|
const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!window.confirm('이 알림을 삭제하시겠습니까?')) return;
|
if (!window.confirm('이 알림을 삭제하시겠습니까?')) return;
|
||||||
await deleteNotification(item.id);
|
await deleteNotification(item.id);
|
||||||
setSelectedIds(prev => {
|
setSelectedId(prev => (prev === item.id ? null : prev));
|
||||||
if (!prev.has(item.id)) return prev;
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(item.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [deleteNotification]);
|
}, [deleteNotification]);
|
||||||
|
|
||||||
const handleDeleteSelected = useCallback(async () => {
|
const handleDeleteSelected = useCallback(async () => {
|
||||||
if (selectedCount === 0) return;
|
if (!selectedId) return;
|
||||||
const ids = [...selectedIds];
|
if (!window.confirm('선택한 알림을 삭제하시겠습니까?')) return;
|
||||||
if (!window.confirm(`선택한 알림 ${ids.length}건을 삭제하시겠습니까?`)) return;
|
await deleteNotifications([selectedId]);
|
||||||
await deleteNotifications(ids);
|
setSelectedId(null);
|
||||||
setSelectedIds(new Set());
|
}, [selectedId, deleteNotifications]);
|
||||||
}, [selectedCount, selectedIds, deleteNotifications]);
|
|
||||||
|
|
||||||
const handleDeleteAll = useCallback(async () => {
|
const handleDeleteAll = useCallback(async () => {
|
||||||
if (allNotifications.length === 0) return;
|
if (allNotifications.length === 0) return;
|
||||||
if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return;
|
if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return;
|
||||||
await deleteAllNotifications();
|
await deleteAllNotifications();
|
||||||
setSelectedIds(new Set());
|
setSelectedId(null);
|
||||||
}, [allNotifications.length, deleteAllNotifications]);
|
}, [allNotifications.length, deleteAllNotifications]);
|
||||||
|
|
||||||
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||||
@@ -342,6 +433,11 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
onClose={() => setFullscreenItem(null)}
|
onClose={() => setFullscreenItem(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<BacktestAnalysisReportModal
|
||||||
|
open={reportOpen}
|
||||||
|
onClose={() => setReportOpen(false)}
|
||||||
|
model={reportModel}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'tnl-page',
|
'tnl-page',
|
||||||
@@ -506,7 +602,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
item={item}
|
item={item}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
layoutMode={listLayout}
|
layoutMode={listLayout}
|
||||||
isSelected={selectedIds.has(item.id)}
|
isSelected={selectedId === item.id}
|
||||||
chartsEnabled={isListView}
|
chartsEnabled={isListView}
|
||||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
onSelect={() => handleNotificationSelect(item)}
|
onSelect={() => handleNotificationSelect(item)}
|
||||||
@@ -520,6 +616,8 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
setFullscreenItem(item);
|
setFullscreenItem(item);
|
||||||
}}
|
}}
|
||||||
onTrade={() => handleTradeFromAlert(item)}
|
onTrade={() => handleTradeFromAlert(item)}
|
||||||
|
onReport={() => void handleReportFromAlert(item)}
|
||||||
|
reportLoading={reportLoadingId === item.id}
|
||||||
tickers={tickers}
|
tickers={tickers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ const IcTrash = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const IcReport = () => (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<line x1="10" y1="9" x2="8" y2="9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export type TradeNotificationRowLayout = 'list' | 'grid';
|
export type TradeNotificationRowLayout = 'list' | 'grid';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -53,6 +63,9 @@ interface Props {
|
|||||||
onGoToChart: () => void;
|
onGoToChart: () => void;
|
||||||
/** 매매 버튼 클릭 → 우측 매매 패널 열기 */
|
/** 매매 버튼 클릭 → 우측 매매 패널 열기 */
|
||||||
onTrade?: () => void;
|
onTrade?: () => void;
|
||||||
|
/** 상세분석 레포트 버튼 클릭 */
|
||||||
|
onReport?: () => void;
|
||||||
|
reportLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_INDICATOR_CARDS = 8;
|
const MAX_INDICATOR_CARDS = 8;
|
||||||
@@ -70,6 +83,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
onDetail,
|
onDetail,
|
||||||
onGoToChart,
|
onGoToChart,
|
||||||
onTrade,
|
onTrade,
|
||||||
|
onReport,
|
||||||
|
reportLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
useTradeAlertTimeFormat();
|
useTradeAlertTimeFormat();
|
||||||
const isBuy = item.signalType === 'BUY';
|
const isBuy = item.signalType === 'BUY';
|
||||||
@@ -317,6 +332,18 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
매매
|
매매
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onReport && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['tnl-row-report', reportLoading ? 'tnl-row-report--loading' : ''].filter(Boolean).join(' ')}
|
||||||
|
title="상세분석 레포트"
|
||||||
|
aria-label="상세분석 레포트"
|
||||||
|
disabled={reportLoading}
|
||||||
|
onClick={onReport}
|
||||||
|
>
|
||||||
|
<IcReport />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -774,6 +774,35 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tnl-row-report {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-left: 2px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tnl-row-report:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--se-gold, #e6c200) 14%, transparent);
|
||||||
|
color: var(--se-gold, #e6c200);
|
||||||
|
border-color: color-mix(in srgb, var(--se-gold, #e6c200) 40%, transparent);
|
||||||
|
}
|
||||||
|
.tnl-row-report:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.tnl-row-report--loading svg {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.tnl-charts-expanded-inner {
|
.tnl-charts-expanded-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user