알림목록 수정
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 매매 시그널 알림 — 차트 전체화면 모달
|
||||
* 알림 아이템에 이미 표시된 캔들+보조지표 차트를 전체 화면으로 확대 표시
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import { buildStrategyChartIndicators, candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { formatStrategyCandleLabel } from '../../utils/strategyStartNodes';
|
||||
import TradeSignalMiniChart from './TradeSignalMiniChart';
|
||||
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../../utils/tradeSignalColors';
|
||||
import { formatSignalTime } from '../../utils/tradeSignalDisplay';
|
||||
import { formatUpbitKrwPrice } from '../../utils/safeFormat';
|
||||
|
||||
const MAX_INDICATOR_CARDS = 8;
|
||||
|
||||
interface Props {
|
||||
item: TradeNotificationItem;
|
||||
theme: Theme;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FullscreenChartModal: React.FC<Props> = ({ item, theme, onClose }) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const [strategy, setStrategy] = useState<StrategyDto | null>(null);
|
||||
|
||||
/* 전략 로딩 */
|
||||
useEffect(() => {
|
||||
if (!item.strategyId) return;
|
||||
loadStrategy(item.strategyId)
|
||||
.then(s => setStrategy(s))
|
||||
.catch(() => {});
|
||||
}, [item.strategyId]);
|
||||
|
||||
/* 보조지표 config */
|
||||
const allIndicatorConfigs = useMemo(() => {
|
||||
if (!strategy) return [];
|
||||
const all = buildStrategyChartIndicators(strategy, getParams, getVisualConfig);
|
||||
return all.slice(0, MAX_INDICATOR_CARDS);
|
||||
}, [strategy, getParams, getVisualConfig]);
|
||||
|
||||
const timeframe = candleTypeToTimeframe(item.candleType ?? '1m');
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR;
|
||||
const sym = item.market.replace(/^KRW-/, '');
|
||||
const tfLabel = formatStrategyCandleLabel(item.candleType ?? '1m');
|
||||
|
||||
/* ESC 키로 닫기 */
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
/* body 스크롤 막기 */
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tnl-chart-fs-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label="차트 전체화면"
|
||||
>
|
||||
<div
|
||||
className="tnl-chart-fs-inner"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="tnl-chart-fs-header">
|
||||
<div className="tnl-chart-fs-title">
|
||||
<span
|
||||
className={`tnl-chart-fs-signal-badge${isBuy ? ' tnl-chart-fs-signal-badge--buy' : ' tnl-chart-fs-signal-badge--sell'}`}
|
||||
style={{ background: accentColor }}
|
||||
>
|
||||
{item.signalType}
|
||||
</span>
|
||||
<span className="tnl-chart-fs-market">{sym}</span>
|
||||
<span className="tnl-chart-fs-tf">{tfLabel}</span>
|
||||
<span className="tnl-chart-fs-price" style={{ color: accentColor }}>
|
||||
₩{formatUpbitKrwPrice(item.price)}
|
||||
</span>
|
||||
<span className="tnl-chart-fs-time">
|
||||
{formatSignalTime(item.candleTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tnl-chart-fs-actions">
|
||||
{item.strategyName && (
|
||||
<span className="tnl-chart-fs-strategy">{item.strategyName}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-chart-fs-close"
|
||||
onClick={onClose}
|
||||
aria-label="닫기 (ESC)"
|
||||
title="닫기 (ESC)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 바디 */}
|
||||
<div className="tnl-chart-fs-body">
|
||||
<TradeSignalMiniChart
|
||||
market={item.market}
|
||||
timeframe={timeframe}
|
||||
theme={theme}
|
||||
indicators={allIndicatorConfigs}
|
||||
enabled
|
||||
fillHeight
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullscreenChartModal;
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 전략 조건 읽기전용 뷰 — TradeAlertModal 내 "전략 조건" 탭에 표시
|
||||
* LogicNode 트리를 전략 편집기와 유사한 카드 형태로 렌더링한다.
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { CONDITION_LABEL } from '../../utils/strategyTypes';
|
||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||
import { getIndicatorPaletteCategory } from '../strategyEditor/paletteCategories';
|
||||
import { nodeToText } from '../../utils/strategyEditorShared';
|
||||
import { formatStrategyCandleLabel } from '../../utils/strategyStartNodes';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
|
||||
/* ── nodeToText 출력에서 조건대상1 / 조건대상2 분리 ─────────────── */
|
||||
/** nodeToText 가 COND_OPTIONS 라벨로 출력하는 문자열 목록 (긴 것 우선으로 체크) */
|
||||
const COND_SEARCH = [
|
||||
'구름 상향 돌파', '구름 하향 돌파',
|
||||
'선행스팬1 상향돌파 선행스팬2', '선행스팬1 하향돌파 선행스팬2',
|
||||
'선행스팬1 > 선행스팬2', '선행스팬1 < 선행스팬2',
|
||||
'후행스팬 > 가격', '후행스팬 < 가격',
|
||||
'상향 돌파', '하향 돌파',
|
||||
'상승 기울기', '하락 기울기',
|
||||
'이상 (>=)', '이하 (<=)',
|
||||
'초과 (>)', '미만 (<)',
|
||||
'같음 (==)', '다름 (!=)',
|
||||
'차이 > 값', '차이 < 값',
|
||||
'N일 연속 유지',
|
||||
'구름 위', '구름 아래', '구름 안',
|
||||
];
|
||||
|
||||
function splitDescByCondType(descText: string): { left: string; right: string; mid: string } {
|
||||
for (const cond of COND_SEARCH) {
|
||||
const idx = descText.indexOf(cond);
|
||||
if (idx > 0) {
|
||||
return {
|
||||
left: descText.slice(0, idx).trim(),
|
||||
mid: cond,
|
||||
right: descText.slice(idx + cond.length).trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { left: descText, mid: '', right: '' };
|
||||
}
|
||||
|
||||
/* ── 개별 조건 카드 ────────────────────────────────────────────── */
|
||||
const ConditionCard: React.FC<{ node: LogicNode }> = ({ node }) => {
|
||||
const cond = node.condition;
|
||||
if (!cond) return null;
|
||||
|
||||
const indType = cond.indicatorType;
|
||||
const indLabel = getStrategyIndicatorDisplayName(indType);
|
||||
const cat = getIndicatorPaletteCategory(indType);
|
||||
const fullText = nodeToText(node);
|
||||
|
||||
const prefix = `${indLabel} - `;
|
||||
const descText = fullText.startsWith(prefix) ? fullText.slice(prefix.length) : fullText;
|
||||
const { left, mid: condStr, right } = splitDescByCondType(descText);
|
||||
|
||||
const candleRange = cond.candleRange != null && cond.candleRange > 0
|
||||
? `${cond.candleRange}캔들 전`
|
||||
: '현재 캔들';
|
||||
|
||||
const condLabelDisplay = CONDITION_LABEL[cond.conditionType] ?? (condStr || cond.conditionType);
|
||||
|
||||
return (
|
||||
<div className="scv-cond-card">
|
||||
<div className="scv-cond-header">
|
||||
<span className={`se-ind-chip se-ind-chip--${cat} scv-ind-chip`}>{indType}</span>
|
||||
<span className="scv-cond-title">{indLabel} - {descText}</span>
|
||||
</div>
|
||||
<div className="scv-cond-fields">
|
||||
<div className="scv-cond-field">
|
||||
<span className="scv-cond-field-lbl">캔들범위</span>
|
||||
<span className="scv-cond-field-val">{candleRange}</span>
|
||||
</div>
|
||||
{left && (
|
||||
<div className="scv-cond-field">
|
||||
<span className="scv-cond-field-lbl">조건대상1</span>
|
||||
<span className="scv-cond-field-val">{left}</span>
|
||||
</div>
|
||||
)}
|
||||
{right && (
|
||||
<div className="scv-cond-field">
|
||||
<span className="scv-cond-field-lbl">조건대상2</span>
|
||||
<span className="scv-cond-field-val">{right}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="scv-cond-field">
|
||||
<span className="scv-cond-field-lbl">조건</span>
|
||||
<span className="scv-cond-field-val">{condLabelDisplay}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 노드 트리 재귀 렌더러 ─────────────────────────────────────── */
|
||||
function renderNode(node: LogicNode): React.ReactNode {
|
||||
if (node.type === 'CONDITION') {
|
||||
return <ConditionCard key={node.id} node={node} />;
|
||||
}
|
||||
|
||||
if (node.type === 'AND' || node.type === 'OR') {
|
||||
const op = node.type as 'AND' | 'OR';
|
||||
const opKo = op === 'AND' ? 'AND (그리고)' : 'OR (또는)';
|
||||
const children = node.children ?? [];
|
||||
return (
|
||||
<div key={node.id} className={`scv-group scv-group--${op.toLowerCase()}`}>
|
||||
<div className="scv-group-header">
|
||||
<span className={`scv-group-badge scv-group-badge--${op.toLowerCase()}`}>{op}</span>
|
||||
<span className="scv-group-label">{opKo}</span>
|
||||
<div className="scv-group-op-toggle" aria-hidden>
|
||||
<span className={op === 'AND' ? 'scv-op-active' : 'scv-op-dim'}>AND</span>
|
||||
<span className={op === 'OR' ? 'scv-op-active' : 'scv-op-dim'}>OR</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scv-group-body">
|
||||
{children.map(child => renderNode(child))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'NOT') {
|
||||
const inner = node.children?.[0];
|
||||
return (
|
||||
<div key={node.id} className="scv-group scv-group--not">
|
||||
<div className="scv-group-header">
|
||||
<span className="scv-group-badge scv-group-badge--not">NOT</span>
|
||||
<span className="scv-group-label">NOT (부정)</span>
|
||||
</div>
|
||||
<div className="scv-group-body">
|
||||
{inner ? renderNode(inner) : <span className="scv-empty">빈 NOT</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const types = node.candleTypes?.length ? node.candleTypes : [node.candleType ?? '1m'];
|
||||
const tfLabel = types.map(formatStrategyCandleLabel).join(' · ');
|
||||
return (
|
||||
<div key={node.id} className="scv-timeframe">
|
||||
<div className="scv-timeframe-header">
|
||||
<span className="scv-timeframe-dot">●</span>
|
||||
<span>START</span>
|
||||
<span className="scv-timeframe-badge">{tfLabel}</span>
|
||||
</div>
|
||||
{inner
|
||||
? <div className="scv-timeframe-body">{renderNode(inner)}</div>
|
||||
: <span className="scv-empty">빈 조건</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── 매수/매도 조건 트리 ─────────────────────────────────────────── */
|
||||
const ConditionTree: React.FC<{ root: LogicNode | null | undefined }> = ({ root }) => {
|
||||
if (!root) return <p className="scv-empty">조건이 없습니다.</p>;
|
||||
return <div className="scv-tree">{renderNode(root)}</div>;
|
||||
};
|
||||
|
||||
/* ── 메인 컴포넌트 ───────────────────────────────────────────────── */
|
||||
interface Props {
|
||||
strategy: StrategyDto;
|
||||
/** 알림의 신호 타입으로 초기 탭 결정 */
|
||||
signalType?: 'BUY' | 'SELL';
|
||||
}
|
||||
|
||||
const StrategyConditionView: React.FC<Props> = ({ strategy, signalType }) => {
|
||||
const hasBuy = Boolean(strategy.buyCondition);
|
||||
const hasSell = Boolean(strategy.sellCondition);
|
||||
const showBoth = hasBuy && hasSell;
|
||||
|
||||
const [tab, setTab] = React.useState<'buy' | 'sell'>(
|
||||
signalType === 'SELL' && hasSell ? 'sell' : 'buy',
|
||||
);
|
||||
|
||||
const buyRoot = strategy.buyCondition as LogicNode | null | undefined;
|
||||
const sellRoot = strategy.sellCondition as LogicNode | null | undefined;
|
||||
|
||||
return (
|
||||
<div className="scv-root">
|
||||
{showBoth && (
|
||||
<div className="scv-signal-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`scv-signal-tab${tab === 'buy' ? ' scv-signal-tab--active scv-signal-tab--buy' : ''}`}
|
||||
onClick={() => setTab('buy')}
|
||||
>
|
||||
매수 조건 (Entry)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`scv-signal-tab${tab === 'sell' ? ' scv-signal-tab--active scv-signal-tab--sell' : ''}`}
|
||||
onClick={() => setTab('sell')}
|
||||
>
|
||||
매도 조건 (Exit)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showBoth && hasBuy && (
|
||||
<div className="scv-signal-label scv-signal-label--buy">매수 조건 (Entry)</div>
|
||||
)}
|
||||
{!showBoth && hasSell && (
|
||||
<div className="scv-signal-label scv-signal-label--sell">매도 조건 (Exit)</div>
|
||||
)}
|
||||
|
||||
<div className="scv-body">
|
||||
{showBoth ? (
|
||||
tab === 'buy'
|
||||
? <ConditionTree root={buyRoot} />
|
||||
: <ConditionTree root={sellRoot} />
|
||||
) : hasBuy ? (
|
||||
<ConditionTree root={buyRoot} />
|
||||
) : (
|
||||
<ConditionTree root={sellRoot} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyConditionView;
|
||||
@@ -142,6 +142,12 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
}));
|
||||
}, [strategy, getParams, getVisualConfig, isListLayout]);
|
||||
|
||||
/** 통합 차트용: 전략의 모든 보조지표를 하나의 차트에 넘긴다 */
|
||||
const allIndicatorConfigs = useMemo(
|
||||
() => indicatorCards.flatMap(c => c.config),
|
||||
[indicatorCards],
|
||||
);
|
||||
|
||||
const chartExpanded = isListLayout && expandedChartKey != null;
|
||||
const chartActive = isListLayout && chartsEnabled && panelActive;
|
||||
const signalCardEnabled = isListLayout && panelActive && !chartExpanded;
|
||||
@@ -218,6 +224,12 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전략명 태그 — 헤더 바로 아래에 표시 */}
|
||||
<div className="tnl-summary-strategy-tag" title={strategyLabel} aria-label={`전략: ${strategyLabel}`}>
|
||||
<span className="tnl-summary-strategy-tag-ic" aria-hidden>⚡</span>
|
||||
<span className="tnl-summary-strategy-tag-name">{strategyLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="tnl-summary-quote" aria-label="시그널 가격">
|
||||
<div className="tnl-summary-quote-left">
|
||||
<span
|
||||
@@ -342,43 +354,22 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
</TradeNotificationHScrollPane>
|
||||
|
||||
{chartExpanded ? (
|
||||
<TradeNotificationHScrollPane
|
||||
className="tnl-row-gallery-indicators tnl-row-gallery-indicators--expanded"
|
||||
label="차트 전체보기"
|
||||
>
|
||||
<div className="tnl-charts-expanded-inner">
|
||||
{expandedChartKey === 'candle' && (
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={[]}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||
/>
|
||||
)}
|
||||
{expandedIndicator && (
|
||||
<TradeSignalChartCard
|
||||
label={expandedIndicator.label}
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={expandedIndicator.config}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TradeNotificationHScrollPane>
|
||||
/* 전체보기 — 통합 차트 (캔들 + 보조지표) 를 넓게 표시 */
|
||||
<div className="tnl-row-gallery-combined-chart tnl-row-gallery-combined-chart--expanded">
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={allIndicatorConfigs}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TradeNotificationHScrollPane
|
||||
@@ -402,55 +393,30 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
/>
|
||||
</TradeNotificationHScrollPane>
|
||||
|
||||
<div className="tnl-row-gallery-candle" aria-label="캔들 차트">
|
||||
{/* 통합 차트 — 캔들(상단) + 전략 보조지표(하단)
|
||||
key에 indicator ids를 포함하여 전략 로드 후 차트를 완전히 재마운트 */}
|
||||
<div
|
||||
className="tnl-row-gallery-combined-chart"
|
||||
aria-label="캔들 및 보조지표 차트"
|
||||
>
|
||||
<TradeSignalChartCard
|
||||
key={`combined-${item.market}-${chartTf}-${allIndicatorConfigs.map(c => c.id).join(',')}`}
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={[]}
|
||||
indicators={allIndicatorConfigs}
|
||||
enabled={chartActive}
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||
/>
|
||||
{indicatorPlaceholder && (
|
||||
<div className="tnl-combined-chart-placeholder">전략 지표 로딩 중…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TradeNotificationHScrollPane
|
||||
className={[
|
||||
'tnl-row-gallery-indicators',
|
||||
indicatorScrollNeeded ? 'tnl-row-gallery-indicators--scroll' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
label="보조지표"
|
||||
clipPeekLabels={indicatorScrollNeeded}
|
||||
>
|
||||
<div className="tnl-charts-track tnl-charts-track--indicators-only">
|
||||
{indicatorCards.map(card => (
|
||||
<TradeSignalChartCard
|
||||
key={card.id}
|
||||
label={card.label}
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={card.config}
|
||||
enabled={chartActive}
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey(card.id)}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{indicatorPlaceholder && (
|
||||
<section className="tnl-chart-card tnl-chart-card--placeholder">
|
||||
<p className="tnl-muted">전략 지표 로딩 중…</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</TradeNotificationHScrollPane>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user