매매 시그널 알림 화면 수정
This commit is contained in:
@@ -899,6 +899,28 @@ html.theme-blue {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 캔들 pane 하단 시간축 (거래량·보조지표가 아래에 있을 때) */
|
||||
.candle-pane-time-axis {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85));
|
||||
background: color-mix(in srgb, var(--bg, #131722) 88%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.candle-pane-time-axis__label {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--text2, #787b86);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Loading overlay */
|
||||
/* 과거 데이터 추가 로드 중 표시 (차트 좌상단 소형 배지) */
|
||||
.chart-history-loading {
|
||||
@@ -9376,6 +9398,21 @@ html.theme-blue {
|
||||
.stg-badge--off { background: rgba(158,158,158,0.2); color: var(--text2); margin-left: 8px; }
|
||||
.stg-hint { margin: 0 0 8px; padding: 0 4px; font-size: 12px; color: var(--text2); line-height: 1.5; }
|
||||
|
||||
.stg-chart-time-format {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 360px;
|
||||
}
|
||||
.stg-chart-time-format-custom {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
.stg-chart-time-format-preview {
|
||||
margin-top: 2px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* ── 연결 테스트 버튼 ── */
|
||||
.stg-btn-test {
|
||||
padding: 5px 12px;
|
||||
|
||||
+31
-6
@@ -16,6 +16,11 @@ import FibTZSettingsModal from './components/FibTZSettingsModal';
|
||||
import ChartSlot, { type ChartSlotHandle } from './components/ChartSlot';
|
||||
import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from './utils/layoutTypes';
|
||||
import { generateBars, formatPrice, SYMBOLS } from './utils/dataGenerator';
|
||||
import {
|
||||
normalizeChartTimeFormat,
|
||||
setChartTimeFormat,
|
||||
useChartTimeFormat,
|
||||
} from './utils/chartTimeFormat';
|
||||
import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from './utils/timezone';
|
||||
import { calcStats, type PriceStats } from './utils/calculations';
|
||||
import { getKoreanName } from './utils/marketNameCache';
|
||||
@@ -47,6 +52,10 @@ import { useHistoryLoader, LOAD_MORE_TRIGGER } from './hooks/useHistoryLoader';
|
||||
import { useMarketTicker } from './hooks/useMarketTicker';
|
||||
import { useUpbitOrderbook } from './hooks/useUpbitOrderbook';
|
||||
import { useIndicatorSettings } from './hooks/useIndicatorSettings';
|
||||
import {
|
||||
consumeSkipNextChartDefaultsSync,
|
||||
markChartIndicatorSaveCommitted,
|
||||
} from './utils/indicatorChartDefaultsSync';
|
||||
import {
|
||||
duplicateIndicatorInList,
|
||||
mergeIndicators,
|
||||
@@ -182,6 +191,7 @@ function App() {
|
||||
const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey);
|
||||
const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey);
|
||||
const displayTimezone = useDisplayTimezone();
|
||||
const chartTimeFormat = useChartTimeFormat();
|
||||
|
||||
// DB 로드 전에는 localStorage 기본값, 로드 후 DB 값으로 오버라이드됨
|
||||
const [symbol, setSymbol] = useState(initial.symbol);
|
||||
@@ -253,8 +263,16 @@ function App() {
|
||||
setDisplayTimezone(next);
|
||||
saveAppDef({ displayTimezone: next });
|
||||
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
||||
managerRef.current?.setDisplayTimezone(next, tf);
|
||||
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe]);
|
||||
managerRef.current?.setDisplayTimezone(next, tf, chartTimeFormat);
|
||||
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, chartTimeFormat]);
|
||||
|
||||
const handleChartTimeFormatChange = useCallback((format: string) => {
|
||||
const next = normalizeChartTimeFormat(format);
|
||||
setChartTimeFormat(next);
|
||||
saveAppDef({ chartTimeFormat: next });
|
||||
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, tf, next);
|
||||
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, displayTimezone]);
|
||||
|
||||
const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol;
|
||||
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol);
|
||||
@@ -957,8 +975,8 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (!appSettingsLoaded) return;
|
||||
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, tf);
|
||||
}, [appSettingsLoaded, displayTimezone, timeframe, activeSlotTf, layoutDef.count]);
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, tf, chartTimeFormat);
|
||||
}, [appSettingsLoaded, displayTimezone, chartTimeFormat, timeframe, activeSlotTf, layoutDef.count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appSettingsLoaded) return;
|
||||
@@ -1484,6 +1502,7 @@ function App() {
|
||||
const fromTemplate =
|
||||
isIndicatorSettingsTemplateId(settingsModalId ?? '')
|
||||
|| updated.id.startsWith('template_');
|
||||
markChartIndicatorSaveCommitted();
|
||||
saveParams(updated.type, updated.params);
|
||||
saveVisual(
|
||||
updated.type,
|
||||
@@ -1554,6 +1573,10 @@ function App() {
|
||||
useLayoutEffect(() => {
|
||||
if (!chartVisible) return;
|
||||
if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return;
|
||||
if (consumeSkipNextChartDefaultsSync()) {
|
||||
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
||||
return;
|
||||
}
|
||||
syncChartsFromSavedDefaults();
|
||||
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
||||
}, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
|
||||
@@ -1756,7 +1779,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
|
||||
{menuPage === 'notifications' && (
|
||||
{menuPage === 'notifications' && canMenu('notifications') && (
|
||||
<TradeNotificationListPage
|
||||
theme={theme}
|
||||
tickers={marketTickers}
|
||||
@@ -1885,6 +1908,8 @@ function App() {
|
||||
onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })}
|
||||
displayTimezone={displayTimezone}
|
||||
onDisplayTimezoneChange={handleTimezoneChange}
|
||||
chartTimeFormat={chartTimeFormat}
|
||||
onChartTimeFormatChange={handleChartTimeFormatChange}
|
||||
verificationIssueNotify={appDefaults.verificationIssueNotify}
|
||||
onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })}
|
||||
onFcmTest={async () => {
|
||||
@@ -2256,7 +2281,7 @@ function App() {
|
||||
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
||||
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
||||
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||
syncBacktestMarkersRef.current();
|
||||
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
||||
mgr.subscribeVisibleLogicalRange(r => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 캔들 pane 하단 시간축 — 거래량·보조지표가 아래에 있을 때 캔들 영역 바로 아래에도 시간 표시.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
|
||||
const AXIS_HEIGHT = 22;
|
||||
|
||||
interface CandlePaneTimeAxisProps {
|
||||
manager: ChartManager;
|
||||
containerEl: HTMLElement;
|
||||
}
|
||||
|
||||
const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, containerEl }) => {
|
||||
const [state, setState] = useState<ReturnType<ChartManager['getCandlePaneTimeAxisOverlay']>>(null);
|
||||
|
||||
const sync = useCallback(() => {
|
||||
if (!containerEl.isConnected) return;
|
||||
setState(manager.getCandlePaneTimeAxisOverlay());
|
||||
}, [manager, containerEl]);
|
||||
|
||||
useEffect(() => {
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(containerEl);
|
||||
|
||||
const unsubLayout = manager.subscribePaneLayout(sync);
|
||||
const unsubRange = manager.subscribeViewport(sync);
|
||||
|
||||
const timers = [0, 50, 150, 400].map(ms => window.setTimeout(sync, ms));
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
unsubLayout();
|
||||
unsubRange();
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
}, [manager, containerEl, sync]);
|
||||
|
||||
if (!state || state.labels.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="candle-pane-time-axis"
|
||||
style={{
|
||||
top: state.topY,
|
||||
height: state.height ?? AXIS_HEIGHT,
|
||||
width: state.plotWidth,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{state.labels.map((label, idx) => (
|
||||
<span
|
||||
key={`${label.x}-${idx}`}
|
||||
className="candle-pane-time-axis__label"
|
||||
style={{ left: label.x }}
|
||||
>
|
||||
{label.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CandlePaneTimeAxis;
|
||||
@@ -27,6 +27,7 @@ import { invalidateMarketCache } from '../utils/requestCache';
|
||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||
import { markChartIndicatorSaveCommitted } from '../utils/indicatorChartDefaultsSync';
|
||||
import {
|
||||
duplicateIndicatorInList,
|
||||
mergeIndicators,
|
||||
@@ -568,6 +569,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
|
||||
setSettingsModalId(null);
|
||||
setCtxToolbar(null);
|
||||
markChartIndicatorSaveCommitted();
|
||||
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
|
||||
saveParams(updated.type, updated.params);
|
||||
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CHART_TIME_FORMAT_PRESETS,
|
||||
formatUnixWithChartPattern,
|
||||
isPresetChartTimeFormat,
|
||||
normalizeChartTimeFormat,
|
||||
} from '../utils/chartTimeFormat';
|
||||
|
||||
const CUSTOM_VALUE = '__custom__';
|
||||
|
||||
interface ChartTimeFormatPickerProps {
|
||||
value: string;
|
||||
onChange: (format: string) => void;
|
||||
}
|
||||
|
||||
const ChartTimeFormatPicker: React.FC<ChartTimeFormatPickerProps> = ({ value, onChange }) => {
|
||||
const normalized = normalizeChartTimeFormat(value);
|
||||
const isPreset = isPresetChartTimeFormat(normalized);
|
||||
|
||||
const [selectValue, setSelectValue] = useState(
|
||||
isPreset ? normalized : CUSTOM_VALUE,
|
||||
);
|
||||
const [customText, setCustomText] = useState(
|
||||
isPreset ? '' : normalized,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const n = normalizeChartTimeFormat(value);
|
||||
if (isPresetChartTimeFormat(n)) {
|
||||
setSelectValue(n);
|
||||
setCustomText('');
|
||||
} else {
|
||||
setSelectValue(CUSTOM_VALUE);
|
||||
setCustomText(n);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const fmt = selectValue === CUSTOM_VALUE
|
||||
? normalizeChartTimeFormat(customText)
|
||||
: selectValue;
|
||||
return formatUnixWithChartPattern(Math.floor(Date.now() / 1000), fmt);
|
||||
}, [selectValue, customText]);
|
||||
|
||||
const commitCustom = useCallback(() => {
|
||||
const next = normalizeChartTimeFormat(customText);
|
||||
onChange(next);
|
||||
}, [customText, onChange]);
|
||||
|
||||
return (
|
||||
<div className="stg-chart-time-format">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={selectValue}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
setSelectValue(v);
|
||||
if (v !== CUSTOM_VALUE) {
|
||||
onChange(v);
|
||||
} else if (customText.trim()) {
|
||||
onChange(normalizeChartTimeFormat(customText));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{CHART_TIME_FORMAT_PRESETS.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>직접 입력…</option>
|
||||
</select>
|
||||
{selectValue === CUSTOM_VALUE && (
|
||||
<input
|
||||
type="text"
|
||||
className="stg-input stg-chart-time-format-custom"
|
||||
placeholder="예: yyyy-MM-dd HH:mm"
|
||||
value={customText}
|
||||
onChange={e => setCustomText(e.target.value)}
|
||||
onBlur={commitCustom}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitCustom(); }}
|
||||
/>
|
||||
)}
|
||||
<span className="stg-hint stg-chart-time-format-preview" title="현재 시각 미리보기">
|
||||
미리보기: {preview}
|
||||
</span>
|
||||
<span className="stg-hint">
|
||||
토큰: yyyy, yy, MM, dd, HH, mm, ss
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartTimeFormatPicker;
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
LINE_WIDTH_OPTIONS,
|
||||
} from '../utils/plotColorUtils';
|
||||
import TimezonePicker from './TimezonePicker';
|
||||
import ChartTimeFormatPicker from './ChartTimeFormatPicker';
|
||||
import AdminPasswordGate from './AdminPasswordGate';
|
||||
import AdminSettingsPanel from './AdminSettingsPanel';
|
||||
import {
|
||||
@@ -152,6 +153,8 @@ interface SettingsPageProps {
|
||||
onFcmTest?: () => void;
|
||||
displayTimezone?: string;
|
||||
onDisplayTimezoneChange?: (tz: string) => void;
|
||||
chartTimeFormat?: string;
|
||||
onChartTimeFormatChange?: (format: string) => void;
|
||||
menuPermissions?: Record<string, boolean>;
|
||||
verificationIssueNotify?: boolean;
|
||||
onVerificationIssueNotify?: (v: boolean) => void;
|
||||
@@ -651,6 +654,8 @@ interface ChartPanelProps {
|
||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
chartTimeFormat?: string;
|
||||
onChartTimeFormatChange?: (format: string) => void;
|
||||
}
|
||||
|
||||
const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
@@ -667,6 +672,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
onChartLegendOptionsChange,
|
||||
chartPaneSeparator,
|
||||
onChartPaneSeparatorChange,
|
||||
chartTimeFormat = 'MM-dd HH:mm',
|
||||
onChartTimeFormatChange,
|
||||
}) => {
|
||||
const [upColor, setUp] = useState('#26a69a');
|
||||
const [downColor, setDown] = useState('#ef5350');
|
||||
@@ -678,6 +685,18 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingSection title="시간축 표시">
|
||||
<SettingRow
|
||||
label="시간 포맷"
|
||||
desc="차트 하단 시간축·크로스헤어에 표시되는 날짜·시간 형식입니다. 프리셋을 선택하거나 직접 입력할 수 있습니다."
|
||||
>
|
||||
<ChartTimeFormatPicker
|
||||
value={chartTimeFormat}
|
||||
onChange={fmt => onChartTimeFormatChange?.(fmt)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection title="실시간 차트 데이터">
|
||||
<SettingRow label="데이터 소스" desc="Blueprint 권장: 백엔드 STOMP. 직연결은 업비트 WebSocket을 프론트가 직접 수신합니다.">
|
||||
<select className="stg-select" value={chartRealtimeSource}
|
||||
@@ -1577,6 +1596,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onFcmTest,
|
||||
displayTimezone = 'Asia/Seoul',
|
||||
onDisplayTimezoneChange,
|
||||
chartTimeFormat = 'MM-dd HH:mm',
|
||||
onChartTimeFormatChange,
|
||||
menuPermissions,
|
||||
verificationIssueNotify = true,
|
||||
onVerificationIssueNotify,
|
||||
@@ -1626,6 +1647,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
||||
chartTimeFormat={chartTimeFormat}
|
||||
onChartTimeFormatChange={onChartTimeFormatChange}
|
||||
/>
|
||||
);
|
||||
case 'indicators':
|
||||
|
||||
@@ -162,6 +162,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
|
||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||
{ page: 'notifications', label: '알림목록', icon: <IcNotify /> },
|
||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
||||
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
|
||||
];
|
||||
|
||||
@@ -12,6 +12,8 @@ import { OrderbookPanel } from './OrderbookPanel';
|
||||
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
||||
import { useAppSettings } from '../hooks/useAppSettings';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
import '../styles/tradeNotificationList.css';
|
||||
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
|
||||
@@ -95,6 +97,8 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
|
||||
|
||||
const refreshPaperData = useCallback(async () => {
|
||||
try {
|
||||
@@ -205,43 +209,16 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
||||
|
||||
return (
|
||||
<div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}>
|
||||
<div className={`tnl-page tnl-page--with-right bps-page--vtd se-page se-page--${theme} app ${theme}`}>
|
||||
<div className="tnl-body">
|
||||
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
|
||||
<div className="tnl-header">
|
||||
<div className="tnl-header-row">
|
||||
<div className="tnl-header-intro">
|
||||
<h1 className="tnl-title">매매 시그널 알림</h1>
|
||||
<p className="tnl-sub">
|
||||
{isListView
|
||||
? '각 알림 왼쪽은 요약, 오른쪽은 캔들·지표 차트입니다. 차트 영역은 가로 스크롤로 확인할 수 있습니다.'
|
||||
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 우측 아이콘으로 배치를 변경할 수 있습니다.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="tnl-header-actions">
|
||||
<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>
|
||||
<TradeNotificationGridLayoutPicker
|
||||
value={gridPreset}
|
||||
onChange={handleGridPresetChange}
|
||||
disabled={isListView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="tnl-title">매매 시그널 알림</h1>
|
||||
<p className="tnl-sub">
|
||||
{isListView
|
||||
? '각 알림 왼쪽은 가상매매와 동일한 신호 상세 카드, 오른쪽은 캔들·지표 차트입니다. 영역 우측 버튼으로 가로 스크롤할 수 있습니다.'
|
||||
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 툴바 우측에서 배치를 변경할 수 있습니다.`}
|
||||
</p>
|
||||
<div className="tnl-toolbar">
|
||||
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
||||
<div className="tnl-filter">
|
||||
@@ -268,27 +245,50 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
모두 읽음
|
||||
</button>
|
||||
)}
|
||||
<span className="tnl-toolbar-divider" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-icon-btn"
|
||||
title="미확인 알림 삭제"
|
||||
aria-label="미확인 알림 삭제"
|
||||
disabled={unreadCount === 0}
|
||||
onClick={() => void handleDeleteUnread()}
|
||||
>
|
||||
<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 className="tnl-toolbar-end">
|
||||
<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>
|
||||
<TradeNotificationGridLayoutPicker
|
||||
value={gridPreset}
|
||||
onChange={handleGridPresetChange}
|
||||
disabled={isListView}
|
||||
/>
|
||||
<span className="tnl-toolbar-divider" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-icon-btn"
|
||||
title="미확인 알림 삭제"
|
||||
aria-label="미확인 알림 삭제"
|
||||
disabled={unreadCount === 0}
|
||||
onClick={() => void handleDeleteUnread()}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -312,6 +312,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
layoutMode={listLayout}
|
||||
isSelected={selectedNotifyId === item.id}
|
||||
chartsEnabled={isListView}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
onSelect={() => handleNotificationSelect(item)}
|
||||
onDelete={e => void handleDeleteOne(item, e)}
|
||||
onDetail={() => {
|
||||
@@ -322,6 +323,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
markAsRead(item.id);
|
||||
onGoToChart(item.market);
|
||||
}}
|
||||
tickers={tickers}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from
|
||||
import type { MouseEventParams, Time } from 'lightweight-charts';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
import { useChartTimeFormat } from '../utils/chartTimeFormat';
|
||||
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
|
||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||
|
||||
@@ -45,6 +46,7 @@ function canApplyChartBars(
|
||||
}
|
||||
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
||||
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
||||
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
|
||||
import ChartHoverToolbar from './ChartHoverToolbar';
|
||||
import ChartMagnifier from './ChartMagnifier';
|
||||
import ChartContextMenu from './ChartContextMenu';
|
||||
@@ -216,6 +218,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||
const indicatorSyncInFlightRef = useRef(false);
|
||||
const pendingIndicatorResyncRef = useRef(false);
|
||||
const indicatorReloadGenRef = useRef(0);
|
||||
const prevMarket = useRef<string>(market);
|
||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||
@@ -228,6 +231,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const resizeDebounceRef = useRef<number | null>(null);
|
||||
const chartVisibleRef = useRef(chartVisible);
|
||||
|
||||
const chartTimeFormat = useChartTimeFormat();
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
||||
@@ -456,6 +460,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
});
|
||||
}, [afterIndicatorPaneMutation, restoreLogicalRange]);
|
||||
|
||||
/** 설정 저장 등으로 연속 갱신이 겹칠 때 마지막 상태로 한 번 더 동기화 */
|
||||
const flushPendingIndicatorResync = useCallback((
|
||||
mgr: ChartManager,
|
||||
completedGen: number,
|
||||
) => {
|
||||
if (!pendingIndicatorResyncRef.current) return;
|
||||
if (completedGen !== indicatorReloadGenRef.current) return;
|
||||
pendingIndicatorResyncRef.current = false;
|
||||
const latest = indicatorsRef.current;
|
||||
indicatorSyncInFlightRef.current = true;
|
||||
const flushGen = ++indicatorReloadGenRef.current;
|
||||
reloadIndicatorsWithCover(mgr, latest, () => {
|
||||
if (flushGen !== indicatorReloadGenRef.current) {
|
||||
indicatorSyncInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
syncIndicatorTrackingRefs(latest);
|
||||
indicatorSyncInFlightRef.current = false;
|
||||
});
|
||||
}, [reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
|
||||
|
||||
const queueFullReload = useCallback(() => {
|
||||
if (!chartVisibleRef.current) return;
|
||||
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||
@@ -573,7 +598,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
}
|
||||
mgr.setTheme(th);
|
||||
mgr.setLogScale(ls);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||
commitCandleLayout(mgr);
|
||||
onCandlesReady?.();
|
||||
lastAppliedMarketRef.current = market;
|
||||
@@ -996,8 +1021,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
|
||||
}, [displayTimezone, timeframe]);
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||
}, [displayTimezone, timeframe, chartTimeFormat]);
|
||||
|
||||
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
|
||||
useEffect(() => {
|
||||
@@ -1083,6 +1108,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
prevLogScale.current = logScale;
|
||||
mgr.setLogScale(logScale);
|
||||
} else if (indChanged) {
|
||||
if (indicatorSyncInFlightRef.current) {
|
||||
pendingIndicatorResyncRef.current = true;
|
||||
return;
|
||||
}
|
||||
const prevList = prevIndicatorsListRef.current;
|
||||
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
|
||||
const currPK = paramKey(indicators);
|
||||
@@ -1142,12 +1171,18 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
&& addedConfigs.length === 0
|
||||
) {
|
||||
const savedLR = mgr.getVisibleLogicalRange();
|
||||
const syncGen = ++indicatorReloadGenRef.current;
|
||||
indicatorSyncInFlightRef.current = true;
|
||||
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
|
||||
if (syncGen !== indicatorReloadGenRef.current) {
|
||||
indicatorSyncInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
afterIndicatorPaneMutation(mgr);
|
||||
restoreLogicalRange(mgr, savedLR);
|
||||
syncIndicatorTrackingRefs(indicators);
|
||||
indicatorSyncInFlightRef.current = false;
|
||||
flushPendingIndicatorResync(mgr, syncGen);
|
||||
});
|
||||
} else if (isReorderOnly) {
|
||||
reloadIndicatorsWithCover(mgr, indicators, () => {
|
||||
@@ -1165,7 +1200,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators]);
|
||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators, flushPendingIndicatorResync]);
|
||||
|
||||
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
|
||||
useEffect(() => {
|
||||
@@ -1325,6 +1360,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && (
|
||||
<CandlePaneTimeAxisPortal
|
||||
manager={chartMgr}
|
||||
getContainer={() => containerRef.current}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && paneSeparatorOptions && (
|
||||
<PaneSeparatorOverlay
|
||||
manager={chartMgr}
|
||||
@@ -1397,6 +1438,22 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CandlePaneTimeAxisPortal: React.FC<{
|
||||
manager: ChartManager;
|
||||
getContainer: () => HTMLElement | null;
|
||||
}> = ({ manager, getContainer }) => {
|
||||
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const c = getContainer();
|
||||
if (c) { setEl(c); return; }
|
||||
const tid = setTimeout(() => setEl(getContainer()), 100);
|
||||
return () => clearTimeout(tid);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (!el) return null;
|
||||
return <CandlePaneTimeAxis manager={manager} containerEl={el} />;
|
||||
};
|
||||
|
||||
const CandlePaneControlsPortal: React.FC<{
|
||||
manager: ChartManager;
|
||||
getContainer: () => HTMLElement | null;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 매매 시그널 알림 — 가로 스크롤 영역 + 우측 세로 레일(◀ ▶) 스크롤 버튼
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const SCROLL_STEP_RATIO = 0.85;
|
||||
|
||||
const IcChevronLeft = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="7 2 3 6 7 10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcChevronRight = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="5 2 9 6 5 10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export interface TradeNotificationHScrollPaneProps {
|
||||
className?: string;
|
||||
/** 스크린리더용 영역 설명 */
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const TradeNotificationHScrollPane: React.FC<TradeNotificationHScrollPaneProps> = ({
|
||||
className = '',
|
||||
label,
|
||||
children,
|
||||
}) => {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [canScroll, setCanScroll] = useState(false);
|
||||
const [atStart, setAtStart] = useState(true);
|
||||
const [atEnd, setAtEnd] = useState(true);
|
||||
|
||||
const syncScrollState = useCallback(() => {
|
||||
const el = viewportRef.current;
|
||||
if (!el) return;
|
||||
const max = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
const overflow = max > 2;
|
||||
setCanScroll(overflow);
|
||||
setAtStart(!overflow || el.scrollLeft <= 2);
|
||||
setAtEnd(!overflow || el.scrollLeft >= max - 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current;
|
||||
if (!el) return;
|
||||
syncScrollState();
|
||||
const ro = new ResizeObserver(() => syncScrollState());
|
||||
ro.observe(el);
|
||||
const mo = new MutationObserver(() => syncScrollState());
|
||||
mo.observe(el, { childList: true, subtree: true });
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [syncScrollState, children]);
|
||||
|
||||
const scrollByStep = useCallback((dir: -1 | 1) => {
|
||||
const el = viewportRef.current;
|
||||
if (!el) return;
|
||||
const step = Math.max(120, Math.round(el.clientWidth * SCROLL_STEP_RATIO));
|
||||
el.scrollBy({ left: dir * step, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={['tnl-hscroll-pane', className].filter(Boolean).join(' ')}>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="tnl-hscroll-pane__viewport"
|
||||
aria-label={label}
|
||||
onScroll={syncScrollState}
|
||||
>
|
||||
<div className="tnl-hscroll-pane__content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{canScroll && (
|
||||
<div className="tnl-hscroll-pane__rail" role="group" aria-label={`${label} 좌우 이동`}>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-hscroll-pane__rail-btn"
|
||||
disabled={atStart}
|
||||
title="왼쪽으로"
|
||||
aria-label="왼쪽으로 스크롤"
|
||||
onClick={e => { e.stopPropagation(); scrollByStep(-1); }}
|
||||
>
|
||||
<IcChevronLeft />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-hscroll-pane__rail-btn"
|
||||
disabled={atEnd}
|
||||
title="오른쪽으로"
|
||||
aria-label="오른쪽으로 스크롤"
|
||||
onClick={e => { e.stopPropagation(); scrollByStep(1); }}
|
||||
>
|
||||
<IcChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeNotificationHScrollPane;
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* 매매 시그널 알림 목록 행 — 좌: 요약 카드(고정) · 우: 캔들+지표 카드(가로 스크롤)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import { loadStrategies, loadStrategy } from '../../utils/backendApi';
|
||||
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
||||
import {
|
||||
buildSignalDetailRows,
|
||||
@@ -20,6 +23,8 @@ import {
|
||||
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import TradeSignalChartCard from './TradeSignalChartCard';
|
||||
import TradeNotificationSignalCard from './TradeNotificationSignalCard';
|
||||
import TradeNotificationHScrollPane from './TradeNotificationHScrollPane';
|
||||
|
||||
const IcTrash = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
@@ -38,6 +43,8 @@ interface Props {
|
||||
isSelected: boolean;
|
||||
layoutMode?: TradeNotificationRowLayout;
|
||||
chartsEnabled?: boolean;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
onSelect: () => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
onDetail: () => void;
|
||||
@@ -52,6 +59,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
isSelected,
|
||||
layoutMode = 'list',
|
||||
chartsEnabled = true,
|
||||
chartLiveReceiveHighlight = true,
|
||||
tickers,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDetail,
|
||||
@@ -71,6 +80,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const strategyLabel = item.strategyName?.trim()
|
||||
|| strategy?.name
|
||||
|| strategyRow?.value
|
||||
@@ -81,30 +91,36 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListLayout) return;
|
||||
const el = rowRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0]?.isIntersecting) setInView(true);
|
||||
setInView(entries[0]?.isIntersecting ?? false);
|
||||
},
|
||||
{ rootMargin: '120px 0px', threshold: 0.05 },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [isListLayout]);
|
||||
}, [item.id]);
|
||||
|
||||
const panelActive = isListLayout && inView;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListLayout || !item.strategyId || !chartsEnabled) {
|
||||
if (!panelActive || !isListLayout) {
|
||||
setStrategy(undefined);
|
||||
setStrategies([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadStrategies().then(list => {
|
||||
if (!cancelled) setStrategies(list ?? []);
|
||||
});
|
||||
if (!item.strategyId) return () => { cancelled = true; };
|
||||
void loadStrategy(item.strategyId).then(s => {
|
||||
if (!cancelled && s) setStrategy(s);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [item.strategyId, chartsEnabled, isListLayout]);
|
||||
}, [item.strategyId, panelActive, isListLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedChartKey(null);
|
||||
@@ -121,10 +137,66 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
}));
|
||||
}, [strategy, getParams, getVisualConfig, isListLayout]);
|
||||
|
||||
const chartActive = isListLayout && chartsEnabled && inView;
|
||||
const chartExpanded = isListLayout && expandedChartKey != null;
|
||||
const chartActive = isListLayout && chartsEnabled && panelActive;
|
||||
const signalActive = isListLayout && panelActive && !chartExpanded;
|
||||
const ticker = tickers?.get(item.market);
|
||||
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
|
||||
|
||||
const signalSnapshotEnabled = isListLayout && panelActive && item.strategyId != null;
|
||||
const { snapshot, loading: signalLoading } = useTradeNotificationSignalSnapshot(
|
||||
item.market,
|
||||
item.strategyId,
|
||||
strategy,
|
||||
signalSnapshotEnabled,
|
||||
);
|
||||
|
||||
const [chartActivitySeq, setChartActivitySeq] = useState(0);
|
||||
const bumpChartActivity = useCallback(() => {
|
||||
setChartActivitySeq(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const liveReceiveEnabled = panelActive && chartLiveReceiveHighlight;
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!liveReceiveEnabled) return null;
|
||||
return [
|
||||
ticker?.tradePrice ?? '',
|
||||
ticker?.changeRate ?? '',
|
||||
snapshot?.updatedAt ?? 0,
|
||||
chartActivitySeq,
|
||||
].join('|');
|
||||
}, [
|
||||
liveReceiveEnabled,
|
||||
ticker?.tradePrice,
|
||||
ticker?.changeRate,
|
||||
snapshot?.updatedAt,
|
||||
chartActivitySeq,
|
||||
]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, liveReceiveEnabled);
|
||||
|
||||
/** 차트 영역 카드 수 — 알림상세(1) : 차트카드(N) 동일 비율 배분용 */
|
||||
const chartCardCount = useMemo(() => {
|
||||
if (!isListLayout) return 0;
|
||||
if (chartExpanded) return 1;
|
||||
let n = 1;
|
||||
if (signalActive) n += 1;
|
||||
n += indicatorCards.length;
|
||||
if (item.strategyId != null && indicatorCards.length === 0 && chartActive) n += 1;
|
||||
return n;
|
||||
}, [
|
||||
isListLayout,
|
||||
chartExpanded,
|
||||
signalActive,
|
||||
indicatorCards.length,
|
||||
item.strategyId,
|
||||
chartActive,
|
||||
]);
|
||||
|
||||
const rowGalleryStyle = useMemo(
|
||||
() => ({ '--tnl-chart-card-count': String(chartCardCount) }) as React.CSSProperties,
|
||||
[chartCardCount],
|
||||
);
|
||||
|
||||
const summaryCard = (
|
||||
<article
|
||||
className={[
|
||||
@@ -223,6 +295,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
'tnl-row--grid',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
receiving ? 'tnl-row--receiving' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{summaryCard}
|
||||
@@ -238,45 +311,77 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
'tnl-row--gallery',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
receiving ? 'tnl-row--receiving' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}>
|
||||
{summaryCard}
|
||||
<div
|
||||
className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}
|
||||
style={rowGalleryStyle}
|
||||
>
|
||||
<TradeNotificationHScrollPane
|
||||
className="tnl-row-gallery-detail"
|
||||
label="알림 상세"
|
||||
>
|
||||
{summaryCard}
|
||||
</TradeNotificationHScrollPane>
|
||||
|
||||
{chartExpanded ? (
|
||||
<div className="tnl-charts-expanded" aria-label="차트 전체보기">
|
||||
{expandedChartKey === 'candle' && (
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
market={item.market}
|
||||
timeframe={chartTf}
|
||||
theme={theme}
|
||||
indicators={[]}
|
||||
enabled={chartActive}
|
||||
expanded
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
/>
|
||||
)}
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tnl-charts-scroll" aria-label="캔들·지표 차트">
|
||||
<TradeNotificationHScrollPane
|
||||
className={['tnl-row-gallery-charts', chartExpanded ? 'tnl-row-gallery-charts--expanded' : ''].filter(Boolean).join(' ')}
|
||||
label={chartExpanded ? '차트 전체보기' : '신호·캔들·지표'}
|
||||
>
|
||||
{chartExpanded ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="tnl-charts-track">
|
||||
{signalActive && (
|
||||
<TradeNotificationSignalCard
|
||||
market={item.market}
|
||||
strategyId={item.strategyId}
|
||||
strategy={strategy}
|
||||
strategies={strategies}
|
||||
strategyLabel={strategyLabel}
|
||||
meta={strategyLabel}
|
||||
candleType={item.candleType}
|
||||
theme={theme}
|
||||
ticker={ticker}
|
||||
enabled={signalActive}
|
||||
snapshot={snapshot}
|
||||
signalLoading={signalLoading}
|
||||
onExpandCharts={() => setExpandedChartKey('candle')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TradeSignalChartCard
|
||||
label="캔들"
|
||||
meta={candleKo}
|
||||
@@ -288,6 +393,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey('candle')}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||
/>
|
||||
|
||||
{indicatorCards.map(card => (
|
||||
@@ -303,6 +409,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
expanded={false}
|
||||
onExpand={() => setExpandedChartKey(card.id)}
|
||||
onRestore={() => setExpandedChartKey(null)}
|
||||
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -312,8 +419,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</TradeNotificationHScrollPane>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 매매 시그널 알림 — 가상매매 VirtualTargetCard(신호 패널) 슬롯
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { Theme } from '../../types';
|
||||
import TradeNotificationSignalSummary from './TradeNotificationSignalSummary';
|
||||
|
||||
export interface TradeNotificationSignalCardProps {
|
||||
market: string;
|
||||
strategyId: number | null | undefined;
|
||||
strategy?: StrategyDto;
|
||||
strategies?: StrategyDto[];
|
||||
strategyLabel: string;
|
||||
meta?: string;
|
||||
candleType?: string;
|
||||
theme?: Theme;
|
||||
ticker?: TickerData;
|
||||
enabled?: boolean;
|
||||
snapshot?: VirtualIndicatorSnapshot;
|
||||
signalLoading?: boolean;
|
||||
onExpandCharts?: () => void;
|
||||
}
|
||||
|
||||
const TradeNotificationSignalCard: React.FC<TradeNotificationSignalCardProps> = props => (
|
||||
<div
|
||||
className="tnl-signal-slot"
|
||||
aria-label="실시간 신호 현황"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<TradeNotificationSignalSummary embedded {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TradeNotificationSignalCard;
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 매매 시그널 알림 목록 — 가상매매 종목 카드(요약·신호)와 동일 UI · 실시간 신호 갱신
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { Theme } from '../../types';
|
||||
import VirtualTargetCard, { type VirtualCardDisplayMode } from '../virtual/VirtualTargetCard';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategyId: number | null | undefined;
|
||||
strategy?: StrategyDto;
|
||||
strategies?: StrategyDto[];
|
||||
globalStrategyId?: number | null;
|
||||
candleType?: string;
|
||||
theme?: Theme;
|
||||
ticker?: TickerData;
|
||||
enabled?: boolean;
|
||||
/** tnl-chart-card 본문에 삽입 */
|
||||
embedded?: boolean;
|
||||
/** 부모에서 스냅샷 공유 시(행 테두리 플래시와 중복 폴링 방지) */
|
||||
snapshot?: VirtualIndicatorSnapshot;
|
||||
signalLoading?: boolean;
|
||||
/** 차트 영역 펼쳐보기 (헤더 확대 버튼) */
|
||||
onExpandCharts?: () => void;
|
||||
/** 푸터 전략명 (읽기 전용) */
|
||||
strategyLabel?: string;
|
||||
}
|
||||
|
||||
const TradeNotificationSignalSummary: React.FC<Props> = ({
|
||||
market,
|
||||
strategyId,
|
||||
strategy,
|
||||
strategies = [],
|
||||
globalStrategyId = null,
|
||||
theme = 'dark',
|
||||
ticker,
|
||||
enabled = true,
|
||||
embedded = false,
|
||||
snapshot: snapshotProp,
|
||||
signalLoading: signalLoadingProp,
|
||||
onExpandCharts,
|
||||
strategyLabel,
|
||||
}) => {
|
||||
const active = enabled && strategyId != null;
|
||||
const [displayMode, setDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||
|
||||
const internal = useTradeNotificationSignalSnapshot(
|
||||
market,
|
||||
strategyId,
|
||||
strategy,
|
||||
active && snapshotProp === undefined,
|
||||
);
|
||||
const snapshot = snapshotProp ?? internal.snapshot;
|
||||
const loading = signalLoadingProp ?? internal.loading;
|
||||
|
||||
const strategiesList = useMemo(() => {
|
||||
if (strategies.length > 0) return strategies;
|
||||
return strategy ? [strategy] : [];
|
||||
}, [strategies, strategy]);
|
||||
|
||||
const card = (
|
||||
<VirtualTargetCard
|
||||
market={market}
|
||||
strategy={strategy}
|
||||
strategies={strategiesList}
|
||||
strategyId={strategyId ?? null}
|
||||
globalStrategyId={globalStrategyId}
|
||||
snapshot={snapshot}
|
||||
signalLoading={loading}
|
||||
running={active}
|
||||
liveStatus="live"
|
||||
viewMode="summary"
|
||||
displayMode={displayMode}
|
||||
onDisplayModeChange={embedded ? undefined : setDisplayMode}
|
||||
onEnterFocus={embedded ? undefined : onExpandCharts}
|
||||
headVariant={embedded ? 'inline-quote' : 'default'}
|
||||
theme={theme}
|
||||
ticker={ticker}
|
||||
chartLiveReceiveHighlight
|
||||
readOnlyStrategyLabel={strategyLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div
|
||||
className={`tnl-signal-summary--embedded-wrap se-page se-page--${theme}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{card}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
};
|
||||
|
||||
export default TradeNotificationSignalSummary;
|
||||
@@ -35,6 +35,7 @@ export interface TradeSignalChartCardProps {
|
||||
expanded: boolean;
|
||||
onExpand: () => void;
|
||||
onRestore: () => void;
|
||||
onRealtimeActivity?: () => void;
|
||||
}
|
||||
|
||||
const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
||||
@@ -48,6 +49,7 @@ const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
||||
expanded,
|
||||
onExpand,
|
||||
onRestore,
|
||||
onRealtimeActivity,
|
||||
}) => (
|
||||
<section
|
||||
className={[
|
||||
@@ -67,6 +69,7 @@ const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
||||
indicators={indicators}
|
||||
enabled={enabled}
|
||||
fillHeight={expanded}
|
||||
onRealtimeActivity={onRealtimeActivity}
|
||||
/>
|
||||
{expanded ? (
|
||||
<button
|
||||
|
||||
@@ -25,6 +25,8 @@ interface Props {
|
||||
enabled?: boolean;
|
||||
/** 전체보기 모드 — 부모 높이에 맞춤 */
|
||||
fillHeight?: boolean;
|
||||
/** STOMP/WS 실시간 봉 수신 시 (목록 행 형광 플래시 등) */
|
||||
onRealtimeActivity?: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -36,6 +38,7 @@ const TradeSignalMiniChart: React.FC<Props> = ({
|
||||
indicators = [],
|
||||
enabled = true,
|
||||
fillHeight = false,
|
||||
onRealtimeActivity,
|
||||
}) => {
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -70,11 +73,13 @@ const TradeSignalMiniChart: React.FC<Props> = ({
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, false);
|
||||
}, [applyRealtimeBar]);
|
||||
onRealtimeActivity?.();
|
||||
}, [applyRealtimeBar, onRealtimeActivity]);
|
||||
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, true);
|
||||
}, [applyRealtimeBar]);
|
||||
onRealtimeActivity?.();
|
||||
}, [applyRealtimeBar, onRealtimeActivity]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
|
||||
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
|
||||
import { computeMatchRate, getTrafficLightState } from '../../utils/virtualSignalMetrics';
|
||||
import { buildTrendSearchMetrics } from '../../utils/trendSearchMetrics';
|
||||
@@ -31,6 +32,11 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
|
||||
() => getTrafficLightState(resolvedMatch, metrics),
|
||||
[resolvedMatch, metrics],
|
||||
);
|
||||
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
||||
conditions.length,
|
||||
resolvedMatch,
|
||||
trafficState,
|
||||
]);
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return <p className="vtd-muted vtd-card-empty">조건 데이터 없음</p>;
|
||||
@@ -46,12 +52,18 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
|
||||
<div className="vtd-sig-panel-wrap vtd-sig-panel-wrap--detail">
|
||||
<div className="vtd-sig-panel vtd-sig-panel--detail-top">
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual">
|
||||
<div className="vtd-sig-visual-eq">
|
||||
<div className="vtd-sig-visual" ref={visualRef}>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-eq">
|
||||
<VirtualSignalEqualizer matchRate={resolvedMatch} />
|
||||
</div>
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
|
||||
<div ref={lightRef} className="vtd-sig-light-rail">
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
||||
|
||||
@@ -38,29 +38,28 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
|
||||
return items;
|
||||
}, [litCount, rate]);
|
||||
|
||||
const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
|
||||
/** 신호등 열 높이에 맞춘 20% 눈금 (가상매매·알림 공통) */
|
||||
const ticks = [100, 80, 60, 40, 20, 0];
|
||||
|
||||
return (
|
||||
<div className="vtd-sig-eq">
|
||||
<div className="vtd-sig-eq-scale">
|
||||
<div className="vtd-sig-eq-scale" aria-hidden>
|
||||
{ticks.map(t => (
|
||||
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="vtd-sig-eq-column">
|
||||
<div className="vtd-sig-eq-bar-wrap">
|
||||
<div className="vtd-sig-eq-bar">
|
||||
{segments.map((seg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
'vtd-sig-eq-seg',
|
||||
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
|
||||
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="vtd-sig-eq-bar-wrap">
|
||||
<div className="vtd-sig-eq-bar">
|
||||
{segments.map((seg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
'vtd-sig-eq-seg',
|
||||
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
|
||||
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{rate >= 100 && (
|
||||
<div className="vtd-sig-eq-badge">100% MATCH</div>
|
||||
|
||||
@@ -19,6 +19,9 @@ import VirtualTargetCardFoot from './VirtualTargetCardFoot';
|
||||
|
||||
export type VirtualCardDisplayMode = 'signal' | 'chart';
|
||||
|
||||
/** default: 우측 액션 버튼 · inline-quote: 우측 현재가·등락·실시간 배지 */
|
||||
export type VirtualCardHeadVariant = 'default' | 'inline-quote';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategy: StrategyDto | undefined;
|
||||
@@ -43,6 +46,10 @@ interface Props {
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
ticker?: TickerData;
|
||||
/** 알림 목록 등 — 푸터 전략 셀렉트 읽기 전용 라벨 */
|
||||
readOnlyStrategyLabel?: string;
|
||||
/** 헤더 우측 레이아웃 (알림 목록 신호 슬롯: inline-quote) */
|
||||
headVariant?: VirtualCardHeadVariant;
|
||||
}
|
||||
|
||||
const VirtualTargetCard: React.FC<Props> = ({
|
||||
@@ -69,7 +76,10 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
chartPaneSeparator,
|
||||
chartLiveReceiveHighlight = true,
|
||||
ticker,
|
||||
readOnlyStrategyLabel,
|
||||
headVariant = 'default',
|
||||
}) => {
|
||||
const inlineHeadQuote = headVariant === 'inline-quote';
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
const rows = snapshot?.rows ?? [];
|
||||
@@ -101,6 +111,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
highlightReceiving ? 'vtd-card--receiving' : '',
|
||||
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
||||
isChart ? 'vtd-card--chart-mode' : '',
|
||||
inlineHeadQuote ? 'vtd-card--head-inline-quote' : '',
|
||||
selected ? 'vtd-card--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onSelect}
|
||||
@@ -121,51 +132,60 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vtd-card-head-actions">
|
||||
{onEnterFocus && (
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-card-focus-btn"
|
||||
onClick={e => { e.stopPropagation(); onEnterFocus(); }}
|
||||
title="전체화면 보기 (차트 + 신호)"
|
||||
aria-label="전체화면 보기"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="5,1 1,1 1,5" />
|
||||
<polyline points="9,13 13,13 13,9" />
|
||||
<line x1="1" y1="1" x2="6" y2="6" />
|
||||
<line x1="13" y1="13" x2="8" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange?.('signal')}
|
||||
title="이 종목만 신호 보기"
|
||||
>
|
||||
신호
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange?.('chart')}
|
||||
title="이 종목만 차트 보기"
|
||||
>
|
||||
차트
|
||||
</button>
|
||||
{inlineHeadQuote ? (
|
||||
<div className="vtd-card-head-quote" onClick={e => e.stopPropagation()}>
|
||||
<VirtualTargetQuote market={market} ticker={ticker} compact showPriceLabel={false} />
|
||||
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vtd-card-head-actions">
|
||||
{onEnterFocus && (
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-card-focus-btn"
|
||||
onClick={e => { e.stopPropagation(); onEnterFocus(); }}
|
||||
title="전체화면 보기 (차트 + 신호)"
|
||||
aria-label="전체화면 보기"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="5,1 1,1 1,5" />
|
||||
<polyline points="9,13 13,13 13,9" />
|
||||
<line x1="1" y1="1" x2="6" y2="6" />
|
||||
<line x1="13" y1="13" x2="8" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange?.('signal')}
|
||||
title="이 종목만 신호 보기"
|
||||
>
|
||||
신호
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange?.('chart')}
|
||||
title="이 종목만 차트 보기"
|
||||
>
|
||||
차트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="vtd-card-meta">
|
||||
<VirtualTargetQuote market={market} ticker={ticker} compact />
|
||||
{!isChart && isDetail && evalCount > 0 && (
|
||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||
)}
|
||||
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||
</div>
|
||||
{!inlineHeadQuote && (
|
||||
<div className="vtd-card-meta">
|
||||
<VirtualTargetQuote market={market} ticker={ticker} compact />
|
||||
{!isChart && isDetail && evalCount > 0 && (
|
||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||
)}
|
||||
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isChart ? (
|
||||
<VirtualTargetCardChart
|
||||
@@ -193,6 +213,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
strategyId={strategyId}
|
||||
globalStrategyId={globalStrategyId}
|
||||
onStrategyChange={onStrategyChange}
|
||||
readOnlyStrategyLabel={readOnlyStrategyLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@ interface Props {
|
||||
strategyId: number | null;
|
||||
globalStrategyId: number | null;
|
||||
onStrategyChange?: (strategyId: number | null) => void;
|
||||
/** 변경 불가 시 셀렉트 대신 표시할 전략명 (알림 목록 등) */
|
||||
readOnlyStrategyLabel?: string;
|
||||
}
|
||||
|
||||
/** 카드 하단 — 갱신 시각(좌) · 전략 드롭다운(우) */
|
||||
@@ -23,29 +25,48 @@ const VirtualTargetCardFoot: React.FC<Props> = ({
|
||||
strategyId,
|
||||
globalStrategyId,
|
||||
onStrategyChange,
|
||||
}) => (
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<div className="vtd-card-foot-controls" onClick={e => e.stopPropagation()}>
|
||||
<label className="vtd-card-foot-field">
|
||||
<select
|
||||
className="vtd-card-foot-select"
|
||||
aria-label="투자전략"
|
||||
value={targetStrategySelectValue({ strategyId })}
|
||||
onChange={e => {
|
||||
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
|
||||
}}
|
||||
>
|
||||
<option value={targetStrategySelectValue({ strategyId: null })}>
|
||||
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
||||
</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
readOnlyStrategyLabel,
|
||||
}) => {
|
||||
const resolvedName =
|
||||
readOnlyStrategyLabel?.trim()
|
||||
|| strategies.find(s => s.id === strategyId)?.name
|
||||
|| '';
|
||||
|
||||
return (
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<div className="vtd-card-foot-controls" onClick={e => e.stopPropagation()}>
|
||||
<label className="vtd-card-foot-field">
|
||||
{readOnlyStrategyLabel != null && !onStrategyChange ? (
|
||||
<span
|
||||
className="vtd-card-foot-select vtd-card-foot-select--readonly"
|
||||
title={resolvedName}
|
||||
>
|
||||
{resolvedName || '전략'}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
className="vtd-card-foot-select"
|
||||
aria-label="투자전략"
|
||||
value={targetStrategySelectValue({ strategyId })}
|
||||
disabled={!onStrategyChange}
|
||||
title={!onStrategyChange ? resolvedName : undefined}
|
||||
onChange={e => {
|
||||
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
|
||||
}}
|
||||
>
|
||||
<option value={targetStrategySelectValue({ strategyId: null })}>
|
||||
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
||||
</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetCardFoot;
|
||||
|
||||
@@ -27,9 +27,11 @@ interface Props {
|
||||
ticker?: TickerData;
|
||||
/** 카드 메타 행 — 현재가만 간략 표시 */
|
||||
compact?: boolean;
|
||||
/** compact 시 "현재가" 라벨 표시 (기본 true) */
|
||||
showPriceLabel?: boolean;
|
||||
}
|
||||
|
||||
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }) => {
|
||||
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false, showPriceLabel = true }) => {
|
||||
const code = market.replace(/^KRW-/, '');
|
||||
const change: ChangeDir = ticker?.change ?? 'EVEN';
|
||||
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
|
||||
@@ -54,7 +56,7 @@ const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`vtd-target-quote vtd-target-quote--compact ${flashClass}`.trim()} aria-label="현재가">
|
||||
<span className="vtd-card-price-label">현재가</span>
|
||||
{showPriceLabel && <span className="vtd-card-price-label">현재가</span>}
|
||||
<span className={`vtd-target-price ${colorCls}`}>
|
||||
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
@@ -36,6 +37,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
[metrics, snapshot?.matchRate],
|
||||
);
|
||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
||||
rows.length,
|
||||
matchRate,
|
||||
trafficState,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@@ -58,12 +65,18 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
>
|
||||
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}>
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual">
|
||||
<div className="vtd-sig-visual-eq">
|
||||
<div className="vtd-sig-visual" ref={visualRef}>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-eq">
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
</div>
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
|
||||
<div ref={lightRef} className="vtd-sig-light-rail">
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDetail && (
|
||||
|
||||
@@ -36,6 +36,11 @@ import { resolveChartLegendOptions } from '../types/chartLegend';
|
||||
import type { ChartLegendVisibility } from '../types/chartLegend';
|
||||
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
||||
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
|
||||
import {
|
||||
DEFAULT_CHART_TIME_FORMAT,
|
||||
normalizeChartTimeFormat,
|
||||
setChartTimeFormat,
|
||||
} from '../utils/chartTimeFormat';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
|
||||
import {
|
||||
clampVirtualTargetMax,
|
||||
@@ -170,6 +175,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
||||
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
|
||||
fcmPushEnabled: s.fcmPushEnabled ?? false,
|
||||
displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE),
|
||||
chartTimeFormat: normalizeChartTimeFormat(s.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT),
|
||||
trendSearchSettings: resolveTrendSearchAppSettings(
|
||||
s.trendSearchSettings as Partial<TrendSearchAppSettings> | null | undefined,
|
||||
),
|
||||
@@ -194,6 +200,7 @@ export function useAppSettings(sessionKey = 0) {
|
||||
if (_cache !== null) {
|
||||
setSettings(_cache);
|
||||
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||
setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
||||
setIsLoaded(true);
|
||||
} else {
|
||||
setIsLoaded(false);
|
||||
@@ -202,6 +209,7 @@ export function useAppSettings(sessionKey = 0) {
|
||||
} else if (_cache !== null) {
|
||||
setSettings(_cache);
|
||||
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||
setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
||||
setIsLoaded(true);
|
||||
} else {
|
||||
setIsLoaded(false);
|
||||
@@ -211,6 +219,7 @@ export function useAppSettings(sessionKey = 0) {
|
||||
if (mountedRef.current) {
|
||||
setSettings(data);
|
||||
setDisplayTimezone(normalizeTimezone(data.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||
setChartTimeFormat(normalizeChartTimeFormat(data.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}).catch(err => {
|
||||
@@ -238,6 +247,9 @@ export function useAppSettings(sessionKey = 0) {
|
||||
if (patch.displayTimezone != null) {
|
||||
setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
|
||||
}
|
||||
if (patch.chartTimeFormat != null) {
|
||||
setChartTimeFormat(normalizeChartTimeFormat(patch.chartTimeFormat));
|
||||
}
|
||||
saveAppSettings(patch).then(updated => {
|
||||
if (updated) {
|
||||
_cache = { ...(_cache ?? {}), ...updated };
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 신호등+하단 %/문구(.vtd-sig-light-rail) 높이를 측정해
|
||||
* 이퀄라이저 pane 눈금·막대 높이(--vtd-sig-rail-height)와 동기화
|
||||
*/
|
||||
export function useSignalVisualRailHeight(deps: unknown[] = []) {
|
||||
const visualRef = useRef<HTMLDivElement>(null);
|
||||
const lightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const visual = visualRef.current;
|
||||
const light = lightRef.current;
|
||||
if (!visual || !light) return;
|
||||
|
||||
const sync = () => {
|
||||
const h = Math.ceil(light.getBoundingClientRect().height);
|
||||
if (h > 0) {
|
||||
visual.style.setProperty('--vtd-sig-rail-height', `${h}px`);
|
||||
}
|
||||
};
|
||||
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(light);
|
||||
window.addEventListener('resize', sync);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', sync);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return { visualRef, lightRef };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 매매 시그널 알림 목록 행 — 종목×전략 실시간 신호 스냅샷 (가상매매 카드 요약형과 동일 소스)
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { StrategyDto } from '../utils/backendApi';
|
||||
import {
|
||||
fetchLiveSnapshot,
|
||||
type VirtualIndicatorSnapshot,
|
||||
} from './useVirtualIndicatorSnapshots';
|
||||
|
||||
export function useTradeNotificationSignalSnapshot(
|
||||
market: string,
|
||||
strategyId: number | null | undefined,
|
||||
strategy: StrategyDto | undefined,
|
||||
enabled: boolean,
|
||||
pollMs = 3000,
|
||||
): { snapshot: VirtualIndicatorSnapshot | undefined; loading: boolean } {
|
||||
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const emptyRetryRef = useRef(0);
|
||||
const genRef = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || strategyId == null) {
|
||||
setSnapshot(undefined);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const gen = ++genRef.current;
|
||||
setLoading(true);
|
||||
const pinFirst = emptyRetryRef.current === 0;
|
||||
const snap = await fetchLiveSnapshot(market, strategyId, strategy, pinFirst);
|
||||
if (gen !== genRef.current) return;
|
||||
|
||||
if (snap && snap.rows.length > 0) {
|
||||
emptyRetryRef.current = 0;
|
||||
} else {
|
||||
emptyRetryRef.current += 1;
|
||||
}
|
||||
|
||||
setSnapshot(snap ?? undefined);
|
||||
setLoading(false);
|
||||
}, [market, strategyId, strategy, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || strategyId == null) {
|
||||
setSnapshot(undefined);
|
||||
setLoading(false);
|
||||
emptyRetryRef.current = 0;
|
||||
return;
|
||||
}
|
||||
emptyRetryRef.current = 0;
|
||||
void refresh();
|
||||
const id = window.setInterval(() => void refresh(), pollMs);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
genRef.current += 1;
|
||||
};
|
||||
}, [market, strategyId, enabled, pollMs, refresh]);
|
||||
|
||||
return { snapshot, loading };
|
||||
}
|
||||
@@ -108,7 +108,7 @@ function staticSnapshot(
|
||||
}
|
||||
|
||||
/** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */
|
||||
async function fetchLiveSnapshot(
|
||||
export async function fetchLiveSnapshot(
|
||||
market: string,
|
||||
strategyId: number,
|
||||
strategy: StrategyDto | undefined,
|
||||
|
||||
@@ -111,6 +111,47 @@
|
||||
--vtd-heat-inset: inset 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* BuilderPageShell 없이 bps-page--vtd 만 쓰는 화면(매매 시그널 알림 등) */
|
||||
.bps-page--vtd.se-page--dark,
|
||||
.bps-page--vtd.se-page--blue,
|
||||
.bps-page--vtd:not(.se-page--light) {
|
||||
--vtd-sig-gold-text: color-mix(in srgb, var(--se-gold, #e6c200) 90%, #fff);
|
||||
--vtd-sig-panel-bg: color-mix(in srgb, #0d111f 60%, transparent);
|
||||
--vtd-sig-card-gradient: color-mix(in srgb, #1a2035 92%, #000);
|
||||
--vtd-sig-eq-bg: color-mix(in srgb, #000 35%, transparent);
|
||||
--vtd-heat-track-bg: color-mix(in srgb, #060a14 85%, #000);
|
||||
--vtd-heat-label-color: color-mix(in srgb, var(--se-text, var(--text)) 88%, #fff);
|
||||
--vtd-heat-label-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
--vtd-sig-table-bg: color-mix(in srgb, #0d111f 50%, transparent);
|
||||
--vtd-sig-table-head-bg: color-mix(in srgb, var(--se-gold, #e6c200) 6%, transparent);
|
||||
--vtd-cond-item-bg: color-mix(in srgb, var(--bg2, #1a1b26) 70%, #000);
|
||||
--vtd-light-housing-bg: linear-gradient(180deg, #2a2a2a, #1a1a1a);
|
||||
--vtd-light-housing-border: color-mix(in srgb, #555 80%, #888);
|
||||
--vtd-status-warm: #ffe082;
|
||||
--vtd-status-hot: #82b1ff;
|
||||
--vtd-status-cold: #ff8a80;
|
||||
--vtd-heat-inset: inset 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.bps-page--vtd.se-page--light {
|
||||
--vtd-sig-gold-text: color-mix(in srgb, var(--se-gold, #f57f17) 88%, #212121);
|
||||
--vtd-sig-panel-bg: color-mix(in srgb, var(--se-gold, #f57f17) 5%, #ffffff);
|
||||
--vtd-sig-card-gradient: color-mix(in srgb, var(--se-gold, #f57f17) 7%, #ffffff);
|
||||
--vtd-sig-eq-bg: color-mix(in srgb, var(--bg4, #f5f5f5) 40%, #ffffff);
|
||||
--vtd-heat-track-bg: color-mix(in srgb, var(--bg4, #f5f5f5) 55%, #ffffff);
|
||||
--vtd-heat-label-color: var(--se-text, var(--text));
|
||||
--vtd-heat-label-shadow: none;
|
||||
--vtd-sig-table-bg: #ffffff;
|
||||
--vtd-sig-table-head-bg: color-mix(in srgb, var(--se-gold, #f57f17) 12%, #f5f5f5);
|
||||
--vtd-cond-item-bg: var(--bg2, #ffffff);
|
||||
--vtd-light-housing-bg: linear-gradient(180deg, #eceff1, #cfd8dc);
|
||||
--vtd-light-housing-border: rgba(0, 0, 0, 0.18);
|
||||
--vtd-status-warm: #e65100;
|
||||
--vtd-status-hot: #1565c0;
|
||||
--vtd-status-cold: #c62828;
|
||||
--vtd-heat-inset: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.se-page--light {
|
||||
--se-gold: #f57f17;
|
||||
--se-success: #2e7d32;
|
||||
|
||||
@@ -1,34 +1,341 @@
|
||||
/* 매매 시그널 알림 목록 — 갤러리 행 (요약 카드 + 가로 스크롤 차트) */
|
||||
|
||||
/* 실시간 WS/STOMP 수신 — 목록 행(.tnl-row) 외곽 아웃라인만 형광 연두 (내부 inset 없음) */
|
||||
.tnl-row.tnl-row--receiving {
|
||||
border-color: #69f0ae !important;
|
||||
box-shadow:
|
||||
0 0 0 1px #69f0ae,
|
||||
0 0 10px 4px #69f0ae66,
|
||||
0 0 22px 8px #b9f6ca33;
|
||||
transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* 수신 시 알림 상태 카드(좌측 요약)에는 연두 효과 미적용 */
|
||||
.tnl-row.tnl-row--receiving .tnl-row-gallery-detail .tnl-hscroll-pane,
|
||||
.tnl-row.tnl-row--receiving .tnl-summary-card {
|
||||
box-shadow: none !important;
|
||||
background: var(--se-panel-card-bg, var(--bg2));
|
||||
transition: border-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--receiving .tnl-row-gallery-detail .tnl-hscroll-pane {
|
||||
border-color: var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--receiving .tnl-summary-card--buy {
|
||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--receiving .tnl-summary-card--sell {
|
||||
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--receiving.tnl-row--selected .tnl-summary-card--selected {
|
||||
border-color: var(--accent, #7aa2f7);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||
}
|
||||
|
||||
.tnl-row--gallery {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-row-gallery {
|
||||
--tnl-summary-card-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
max-height: 320px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */
|
||||
.tnl-summary-card {
|
||||
flex: 0 0 544px;
|
||||
width: 544px;
|
||||
/* 좌측 알림 요약 카드 — 고정 너비(모든 행 동일) · 우측은 차트 영역이 나머지 공간 사용 */
|
||||
.tnl-row-gallery-detail {
|
||||
flex: 0 0 var(--tnl-summary-card-width);
|
||||
width: var(--tnl-summary-card-width);
|
||||
min-width: var(--tnl-summary-card-width);
|
||||
max-width: var(--tnl-summary-card-width);
|
||||
}
|
||||
|
||||
.tnl-row-gallery-charts {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
|
||||
flex: 0 0 var(--tnl-summary-card-width);
|
||||
width: var(--tnl-summary-card-width);
|
||||
min-width: var(--tnl-summary-card-width);
|
||||
max-width: var(--tnl-summary-card-width);
|
||||
}
|
||||
|
||||
.tnl-row-gallery--chart-expanded .tnl-row-gallery-charts {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 가로 스크롤 + 우측 세로 레일 버튼 */
|
||||
.tnl-hscroll-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
align-self: flex-start;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__viewport {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--text3) 35%, transparent) transparent;
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__viewport::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__viewport::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text3) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-row-gallery-charts .tnl-hscroll-pane__content {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tnl-row-gallery-charts .tnl-hscroll-pane__viewport {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__rail {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
border-left: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg3, var(--bg2)) 85%, transparent);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__rail-btn {
|
||||
flex: 1 1 0;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 28%, var(--se-border, var(--border)));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg2) 90%, transparent);
|
||||
color: var(--accent, #3f7ef5);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__rail-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent, #3f7ef5) 16%, var(--bg2));
|
||||
border-color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__rail-btn:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.tnl-hscroll-pane__rail-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
color: var(--text3);
|
||||
border-color: var(--se-border, var(--border));
|
||||
}
|
||||
|
||||
/* 좌측 알림 상세(매매 시그널 요약 카드) */
|
||||
.tnl-row-gallery-detail .tnl-hscroll-pane__viewport {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tnl-row-gallery-detail .tnl-hscroll-pane__content {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tnl-row-gallery-detail .tnl-hscroll-pane {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tnl-row-gallery-detail .tnl-summary-card {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 신호상세 — 가상매매 vtd-card 슬롯 (이퀄라이저·히트맵·신호등 최소 너비 확보) */
|
||||
.tnl-signal-slot {
|
||||
flex: 1.35 1 300px;
|
||||
width: auto;
|
||||
min-width: min(100%, 300px);
|
||||
max-width: 42%;
|
||||
height: 280px;
|
||||
max-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .tnl-signal-summary--embedded-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
align-self: stretch;
|
||||
overflow: visible;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card > .vtd-sig-panel-wrap--summary {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-sig-panel--summary {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-sig-visual {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 가상매매와 동일 vtd-sig-* (strategyEditorTheme + virtualTradingDashboard) */
|
||||
|
||||
.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card > .vtd-card-chart-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-card-foot-select:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
color: var(--se-text);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 신호 상세 헤더 — 우측 현재가·등락·실시간 (아이콘/토글 버튼 없음) */
|
||||
.tnl-signal-slot .vtd-card--head-inline-quote .vtd-card-head {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .vtd-card-head-quote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .vtd-card-head-quote .vtd-target-quote--compact {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .vtd-card-head-quote .vtd-target-price {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .vtd-card-head-quote .vtd-target-change {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tnl-signal-slot .vtd-card-head-quote .vtd-live-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-charts-track > .tnl-signal-slot {
|
||||
flex: 1.35 1 300px;
|
||||
min-width: min(100%, 300px);
|
||||
}
|
||||
|
||||
/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */
|
||||
.tnl-summary-card {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #7aa2f7) 28%, var(--se-border, var(--border)));
|
||||
border-radius: 12px;
|
||||
background: var(--se-panel-card-bg, var(--bg2));
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-row-gallery-detail .tnl-summary-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tnl-summary-card--buy {
|
||||
@@ -71,8 +378,10 @@
|
||||
|
||||
.tnl-summary-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -82,6 +391,7 @@
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -91,6 +401,8 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text3, var(--se-text-muted));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -270,25 +582,23 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnl-row-gallery--chart-expanded .tnl-charts-scroll {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tnl-charts-expanded {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
.tnl-charts-expanded-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 4px 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-charts-expanded .tnl-chart-card--expanded {
|
||||
.tnl-row-gallery-charts--expanded .tnl-hscroll-pane__content {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tnl-charts-expanded-inner .tnl-chart-card--expanded {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -296,48 +606,33 @@
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
gap: 8px;
|
||||
padding: 0 4px 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tnl-charts-scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||
}
|
||||
|
||||
.tnl-charts-scroll::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.tnl-charts-scroll::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text3) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tnl-charts-track {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tnl-charts-track > .tnl-chart-card {
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.tnl-chart-card {
|
||||
position: relative;
|
||||
flex: 0 0 300px;
|
||||
width: 300px;
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
height: 280px;
|
||||
max-height: 280px;
|
||||
display: flex;
|
||||
@@ -494,35 +789,20 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 헤더 — 제목(좌) · 목록/그리드 전환(우) */
|
||||
.tnl-header-row {
|
||||
/* 툴바 — 좌: 필터·액션 · 우: 목록/그리드·배치·삭제 */
|
||||
.tnl-page--with-right .tnl-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tnl-header-intro {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tnl-header-intro .tnl-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tnl-header-intro .tnl-sub {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tnl-header-actions {
|
||||
.tnl-toolbar-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.tnl-layout-toggle {
|
||||
@@ -735,30 +1015,70 @@ ul.tnl-list.tnl-list--grid {
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||
}
|
||||
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-summary-card,
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-charts-scroll,
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-charts-expanded {
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-hscroll-pane,
|
||||
.tnl-row--gallery.tnl-row--selected .tnl-summary-card--selected {
|
||||
border-color: var(--accent, #7aa2f7);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||
}
|
||||
|
||||
.tnl-list--gallery .tnl-row--gallery {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@container tnl-main (max-width: 900px) {
|
||||
.tnl-row-gallery {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tnl-summary-card {
|
||||
flex: 0 0 auto;
|
||||
.tnl-row-gallery {
|
||||
--tnl-summary-card-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.tnl-row-gallery-detail,
|
||||
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
|
||||
flex: 0 0 var(--tnl-summary-card-width);
|
||||
width: var(--tnl-summary-card-width);
|
||||
min-width: var(--tnl-summary-card-width);
|
||||
max-width: var(--tnl-summary-card-width);
|
||||
}
|
||||
|
||||
.tnl-row-gallery-charts,
|
||||
.tnl-row-gallery--chart-expanded .tnl-row-gallery-charts {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tnl-charts-scroll,
|
||||
.tnl-charts-expanded {
|
||||
.tnl-hscroll-pane {
|
||||
height: 300px;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.tnl-row-gallery-charts .tnl-hscroll-pane__viewport {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tnl-summary-card {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.tnl-charts-track {
|
||||
min-width: max-content;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.tnl-charts-track > .tnl-chart-card,
|
||||
.tnl-charts-track > .tnl-signal-slot {
|
||||
flex: 0 0 min(300px, 88vw);
|
||||
width: min(300px, 88vw);
|
||||
min-width: min(300px, 88vw);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,6 +1279,19 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtd-card-head-quote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtd-card--head-inline-quote .vtd-card-head-quote .vtd-target-quote--compact {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 카드 내 신호 / 차트 전환 */
|
||||
.vtd-card-mode-toggle {
|
||||
display: inline-flex;
|
||||
@@ -1658,6 +1671,15 @@
|
||||
max-width: 96px;
|
||||
}
|
||||
|
||||
.vtd-card-foot-select--readonly {
|
||||
display: inline-block;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-title {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
@@ -1668,63 +1690,121 @@
|
||||
}
|
||||
|
||||
.vtd-sig-visual {
|
||||
--vtd-sig-rail-height: 164px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 12px 14px;
|
||||
align-items: center;
|
||||
gap: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold) 30%, var(--se-border));
|
||||
border: 1px solid color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 38%, var(--se-border, var(--border)));
|
||||
border-radius: 10px;
|
||||
background: var(--vtd-sig-panel-bg);
|
||||
background: var(--vtd-sig-panel-bg, color-mix(in srgb, #0d111f 60%, transparent));
|
||||
}
|
||||
|
||||
/* 이퀄라이저 · 지표일치율 · 신호등 — 열별 pane 아웃라인 (가상매매·알림 동일) */
|
||||
.vtd-sig-visual-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 32%, var(--se-border, var(--border)));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--vtd-sig-panel-bg, rgba(13, 17, 31, 0.6)) 72%, transparent);
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 8%, transparent);
|
||||
}
|
||||
|
||||
.vtd-sig-visual-eq {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
justify-content: stretch;
|
||||
align-self: center;
|
||||
height: var(--vtd-sig-rail-height);
|
||||
min-height: var(--vtd-sig-rail-height);
|
||||
max-height: var(--vtd-sig-rail-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 세그먼트 이퀄라이저 */
|
||||
.vtd-sig-eq {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
.vtd-sig-visual-heat {
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
min-height: var(--vtd-sig-rail-height);
|
||||
}
|
||||
|
||||
.vtd-sig-eq-column {
|
||||
.vtd-sig-visual-heat .vtd-heat-list {
|
||||
width: 100%;
|
||||
max-height: min(188px, calc(var(--vtd-sig-rail-height) + 48px));
|
||||
margin: auto 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vtd-sig-visual-light {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.vtd-sig-light-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 세그먼트 이퀄라이저 — 신호등+하단문구 높이와 눈금·막대 동기화 */
|
||||
.vtd-sig-visual-eq .vtd-sig-eq {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 40px;
|
||||
max-width: 52px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vtd-sig-eq-scale {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
font-size: 8px;
|
||||
color: var(--se-text-muted);
|
||||
padding: 2px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vtd-sig-eq-tick {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-sig-eq-bar-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 40px;
|
||||
max-width: 52px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vtd-sig-eq-bar {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 2px;
|
||||
height: 180px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold) 40%, var(--se-border));
|
||||
border-radius: 6px;
|
||||
background: var(--vtd-sig-eq-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vtd-sig-eq-seg {
|
||||
@@ -1756,7 +1836,10 @@
|
||||
}
|
||||
|
||||
.vtd-sig-eq-badge {
|
||||
margin-top: 6px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -1765,6 +1848,7 @@
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 */
|
||||
@@ -1941,8 +2025,8 @@
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.vtd-sig-panel--summary .vtd-heat-list {
|
||||
max-height: 160px;
|
||||
.vtd-sig-panel--summary .vtd-sig-visual-heat .vtd-heat-list {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* legacy 조건 목록 (상세 테이블 STATUS 열) */
|
||||
@@ -2047,13 +2131,18 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vtd-sig-visual-light .vtd-sig-light--card-right {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.vtd-sig-light--card-right {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 4px 0 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vtd-sig-light-housing {
|
||||
|
||||
@@ -25,7 +25,14 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
|
||||
import { resolveBbBandBackground } from './bollingerConfig';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
|
||||
import { formatChartAxisPrice } from './dataGenerator';
|
||||
import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
||||
import {
|
||||
DEFAULT_CHART_TIME_FORMAT,
|
||||
formatUnixWithChartPattern,
|
||||
formatLwcTimeWithPattern,
|
||||
formatLwcTickMarkWithPattern,
|
||||
normalizeChartTimeFormat,
|
||||
} from './chartTimeFormat';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
||||
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
|
||||
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
|
||||
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
|
||||
@@ -203,6 +210,7 @@ export class ChartManager {
|
||||
private currentChartType: ChartType = 'candlestick';
|
||||
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
|
||||
private displayTimeframe: Timeframe = '1D';
|
||||
private chartTimeFormat = DEFAULT_CHART_TIME_FORMAT;
|
||||
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
||||
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
||||
/** 백테스팅 매수/매도 시그널 마커 */
|
||||
@@ -461,29 +469,37 @@ export class ChartManager {
|
||||
|
||||
// ─── Display timezone ───────────────────────────────────────────────────
|
||||
private _tickMarkFormatter(): TickMarkFormatter {
|
||||
const tf = this.displayTimeframe;
|
||||
const tz = this.displayTimezone;
|
||||
const fmt = this.chartTimeFormat;
|
||||
return (time, tickMarkType) =>
|
||||
formatLwcTickMark(
|
||||
time as Time,
|
||||
tickMarkType,
|
||||
this.displayTimeframe,
|
||||
this.displayTimezone,
|
||||
);
|
||||
formatLwcTickMarkWithPattern(time as Time, tickMarkType, tf, tz, fmt);
|
||||
}
|
||||
|
||||
private _applyDisplayTimezoneOptions(): void {
|
||||
const tf = this.displayTimeframe;
|
||||
const tz = this.displayTimezone;
|
||||
const fmt = this.chartTimeFormat;
|
||||
const timeFormatter = (time: Time) =>
|
||||
formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
|
||||
formatLwcTimeWithPattern(
|
||||
time as number | { year: number; month: number; day: number },
|
||||
tf,
|
||||
tz,
|
||||
fmt,
|
||||
);
|
||||
this.chart.applyOptions({
|
||||
localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
|
||||
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
|
||||
});
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
setDisplayTimezone(tz: string, timeframe?: Timeframe): void {
|
||||
setDisplayTimezone(tz: string, timeframe?: Timeframe, chartTimeFormat?: string): void {
|
||||
this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
|
||||
if (timeframe) this.displayTimeframe = timeframe;
|
||||
if (chartTimeFormat != null) {
|
||||
this.chartTimeFormat = normalizeChartTimeFormat(chartTimeFormat);
|
||||
}
|
||||
this._applyDisplayTimezoneOptions();
|
||||
}
|
||||
|
||||
@@ -805,12 +821,27 @@ export class ChartManager {
|
||||
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
|
||||
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
|
||||
if (configs.length === 0) return;
|
||||
this.cancelPendingIndicatorUpdates();
|
||||
const loadGen = ++this._dataGeneration;
|
||||
|
||||
let any = false;
|
||||
for (const c of configs) {
|
||||
if (this._detachIndicatorEntry(c.id)) any = true;
|
||||
}
|
||||
if (any) this._trimTrailingEmptySubPanes();
|
||||
await this.addIndicatorsBatch(configs);
|
||||
if (this._indicatorLoadStale(loadGen)) return;
|
||||
|
||||
for (const config of configs) {
|
||||
if (this._indicatorLoadStale(loadGen)) break;
|
||||
await this.addIndicator(config, { skipLayout: true });
|
||||
}
|
||||
if (this._indicatorLoadStale(loadGen)) return;
|
||||
|
||||
this._removeOrphanSubPanes();
|
||||
if (this._activeIndicatorPaneIndices().size > 0) {
|
||||
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
}
|
||||
|
||||
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
||||
@@ -2437,6 +2468,76 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */
|
||||
private _candlePlotWidth(): number {
|
||||
try {
|
||||
const ps = this.mainSeries?.priceScale() ?? this.chart.priceScale('right', 0);
|
||||
const scaleW = ps.width();
|
||||
return Math.max(40, this.container.clientWidth - scaleW);
|
||||
} catch {
|
||||
return Math.max(40, this.container.clientWidth - 56);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캔들 pane 하단 시간축 오버레이 — 거래량·보조지표가 있을 때 캔들 영역 바로 아래에도 시각 표시.
|
||||
*/
|
||||
getCandlePaneTimeAxisOverlay(): {
|
||||
topY: number;
|
||||
height: number;
|
||||
plotWidth: number;
|
||||
labels: Array<{ x: number; text: string }>;
|
||||
} | null {
|
||||
if (!this.mainSeries || this.rawBars.length < 2) return null;
|
||||
|
||||
const layouts = this.getPaneLayouts();
|
||||
const hasLowerPane = layouts.some(l => l.paneIndex > 0 && l.height > 8);
|
||||
if (!hasLowerPane) return null;
|
||||
|
||||
const main = layouts.find(l => l.paneIndex === 0);
|
||||
if (!main || main.height < 48) return null;
|
||||
|
||||
const AXIS_H = 22;
|
||||
const ts = this.chart.timeScale();
|
||||
const lr = ts.getVisibleLogicalRange();
|
||||
if (!lr) return null;
|
||||
|
||||
const plotWidth = this._candlePlotWidth();
|
||||
const from = Math.max(0, Math.ceil(lr.from));
|
||||
const to = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
|
||||
if (to <= from) return null;
|
||||
|
||||
const span = to - from;
|
||||
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
|
||||
const step = Math.max(1, Math.ceil(span / targetCount));
|
||||
|
||||
const labels: Array<{ x: number; text: string }> = [];
|
||||
const fmt = this.chartTimeFormat;
|
||||
const tz = this.displayTimezone;
|
||||
|
||||
for (let i = from; i <= to; i += step) {
|
||||
const bar = this.rawBars[i];
|
||||
if (!bar?.time) continue;
|
||||
const coord = ts.timeToCoordinate(bar.time as Time);
|
||||
if (coord == null) continue;
|
||||
const x = Number(coord);
|
||||
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
|
||||
labels.push({
|
||||
x,
|
||||
text: formatUnixWithChartPattern(bar.time, fmt, tz),
|
||||
});
|
||||
}
|
||||
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
return {
|
||||
topY: main.topY + main.height - AXIS_H,
|
||||
height: AXIS_H,
|
||||
plotWidth,
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
/** 하단 시간축 영역 클릭 여부 */
|
||||
isOnTimeAxis(chartY: number): boolean {
|
||||
try {
|
||||
|
||||
@@ -100,7 +100,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
||||
P('메뉴-백테스팅-분석 차트', 'analysis'),
|
||||
|
||||
// ── 상단 메뉴바 기타 ──
|
||||
P('메뉴-알림 목록', 'notifications', '시그널목록'),
|
||||
P('메뉴-알림목록', 'notifications', '알림목록'),
|
||||
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
|
||||
P('메뉴-로그인', 'login', 'auth'),
|
||||
];
|
||||
|
||||
@@ -444,6 +444,8 @@ export interface AppSettingsDto {
|
||||
fcmPushEnabled?: boolean;
|
||||
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
|
||||
displayTimezone?: string;
|
||||
/** 차트 하단 시간축 포맷 (예: yyyy-MM-dd HH:mm) */
|
||||
chartTimeFormat?: string;
|
||||
/** 추세검색 기본 설정 */
|
||||
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 차트 하단 시간축·크로스헤어 시간 표시 포맷 (앱 설정 chartTimeFormat).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TickMarkType, type Time } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../types';
|
||||
import { getDisplayTimezone, normalizeTimezone } from './timezone';
|
||||
|
||||
export const DEFAULT_CHART_TIME_FORMAT = 'MM-dd HH:mm';
|
||||
|
||||
export interface ChartTimeFormatPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 통상적인 차트 시간축 프리셋 */
|
||||
export const CHART_TIME_FORMAT_PRESETS: ChartTimeFormatPreset[] = [
|
||||
{ id: 'yyyy-MM-dd HH:mm:ss', label: 'yyyy-MM-dd HH:mm:ss' },
|
||||
{ id: 'yyyy-MM-dd HH:mm', label: 'yyyy-MM-dd HH:mm' },
|
||||
{ id: 'yyyy-MM-dd', label: 'yyyy-MM-dd' },
|
||||
{ id: 'MM-dd HH:mm:ss', label: 'MM-dd HH:mm:ss' },
|
||||
{ id: 'MM-dd HH:mm', label: 'MM-dd HH:mm' },
|
||||
{ id: 'dd/MM HH:mm', label: 'dd/MM HH:mm' },
|
||||
{ id: 'dd-MM HH:mm', label: 'dd-MM HH:mm' },
|
||||
{ id: 'HH:mm:ss', label: 'HH:mm:ss' },
|
||||
{ id: 'HH:mm', label: 'HH:mm' },
|
||||
];
|
||||
|
||||
const PRESET_IDS = new Set(CHART_TIME_FORMAT_PRESETS.map(p => p.id));
|
||||
|
||||
let _format = DEFAULT_CHART_TIME_FORMAT;
|
||||
const _listeners = new Set<() => void>();
|
||||
|
||||
export function getChartTimeFormat(): string {
|
||||
return _format;
|
||||
}
|
||||
|
||||
export function setChartTimeFormat(format: string): void {
|
||||
const next = normalizeChartTimeFormat(format);
|
||||
if (_format === next) return;
|
||||
_format = next;
|
||||
_listeners.forEach(l => l());
|
||||
}
|
||||
|
||||
export function subscribeChartTimeFormat(cb: () => void): () => void {
|
||||
_listeners.add(cb);
|
||||
return () => _listeners.delete(cb);
|
||||
}
|
||||
|
||||
export function useChartTimeFormat(): string {
|
||||
return useSyncExternalStore(
|
||||
subscribeChartTimeFormat,
|
||||
getChartTimeFormat,
|
||||
() => DEFAULT_CHART_TIME_FORMAT,
|
||||
);
|
||||
}
|
||||
|
||||
/** 허용 토큰: yyyy yy MM dd HH mm ss */
|
||||
export function normalizeChartTimeFormat(format: string): string {
|
||||
const t = (format || '').trim();
|
||||
if (!t) return DEFAULT_CHART_TIME_FORMAT;
|
||||
if (!/[yMdHms]/.test(t)) return DEFAULT_CHART_TIME_FORMAT;
|
||||
return t.slice(0, 64);
|
||||
}
|
||||
|
||||
export function isPresetChartTimeFormat(format: string): boolean {
|
||||
return PRESET_IDS.has(normalizeChartTimeFormat(format));
|
||||
}
|
||||
|
||||
interface ZonedParts {
|
||||
yyyy: string;
|
||||
yy: string;
|
||||
MM: string;
|
||||
dd: string;
|
||||
HH: string;
|
||||
mm: string;
|
||||
ss: string;
|
||||
}
|
||||
|
||||
function getZonedParts(unixSec: number, tz: string): ZonedParts {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const zone = normalizeTimezone(tz);
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of parts) {
|
||||
if (p.type !== 'literal') map[p.type] = p.value;
|
||||
}
|
||||
const year = map.year ?? '';
|
||||
return {
|
||||
yyyy: year,
|
||||
yy: year.slice(-2),
|
||||
MM: map.month ?? '',
|
||||
dd: map.day ?? '',
|
||||
HH: map.hour ?? '',
|
||||
mm: map.minute ?? '',
|
||||
ss: map.second ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const TOKEN_ORDER = ['yyyy', 'yy', 'MM', 'dd', 'HH', 'mm', 'ss'] as const;
|
||||
|
||||
export function applyChartTimePattern(pattern: string, parts: ZonedParts): string {
|
||||
let out = normalizeChartTimeFormat(pattern);
|
||||
for (const tok of TOKEN_ORDER) {
|
||||
out = out.split(tok).join(parts[tok]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function patternHasDate(pattern: string): boolean {
|
||||
return /yyyy|yy|MM|dd/.test(pattern);
|
||||
}
|
||||
|
||||
function patternHasTime(pattern: string): boolean {
|
||||
return /HH|mm|ss/.test(pattern);
|
||||
}
|
||||
|
||||
/** 패턴에서 날짜·시간 부분 분리 (눈금 타입별 표시용) */
|
||||
export function splitChartTimePattern(pattern: string): { date: string; time: string } {
|
||||
const p = normalizeChartTimeFormat(pattern);
|
||||
if (!patternHasTime(p)) return { date: p, time: '' };
|
||||
if (!patternHasDate(p)) return { date: '', time: p };
|
||||
|
||||
const timeIdx = p.search(/HH|mm|ss/);
|
||||
if (timeIdx < 0) return { date: p, time: '' };
|
||||
|
||||
return {
|
||||
date: p.slice(0, timeIdx).trim(),
|
||||
time: p.slice(timeIdx).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function isDailyTimeframe(tf?: string): boolean {
|
||||
return tf === '1D' || tf === '1W' || tf === '1M';
|
||||
}
|
||||
|
||||
/** unix 초 → 사용자 지정 포맷 */
|
||||
export function formatUnixWithChartPattern(
|
||||
unixSec: number,
|
||||
pattern?: string,
|
||||
tz?: string,
|
||||
): string {
|
||||
if (!unixSec) return '';
|
||||
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||
const parts = getZonedParts(unixSec, zone);
|
||||
return applyChartTimePattern(pattern ?? _format, parts);
|
||||
}
|
||||
|
||||
/** LWC 크로스헤어·timeFormatter */
|
||||
export function formatLwcTimeWithPattern(
|
||||
time: number | { year: number; month: number; day: number },
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
pattern?: string,
|
||||
): string {
|
||||
const fmt = pattern ?? _format;
|
||||
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||
if (typeof time === 'number') {
|
||||
if (isDailyTimeframe(timeframe) && !patternHasTime(fmt)) {
|
||||
return formatUnixWithChartPattern(time, fmt, zone);
|
||||
}
|
||||
return formatUnixWithChartPattern(time, fmt, zone);
|
||||
}
|
||||
const unix = Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000);
|
||||
return formatUnixWithChartPattern(unix, fmt, zone);
|
||||
}
|
||||
|
||||
/** LWC X축 눈금 */
|
||||
export function formatLwcTickMarkWithPattern(
|
||||
time: Time,
|
||||
tickMarkType: TickMarkType,
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
pattern?: string,
|
||||
): string {
|
||||
const fmt = normalizeChartTimeFormat(pattern ?? _format);
|
||||
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||
const unixSec = typeof time === 'number'
|
||||
? time
|
||||
: time && typeof time === 'object' && 'year' in time
|
||||
? Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000)
|
||||
: null;
|
||||
if (unixSec == null) return '';
|
||||
const parts = getZonedParts(unixSec, zone);
|
||||
const { date: datePat, time: timePat } = splitChartTimePattern(fmt);
|
||||
|
||||
switch (tickMarkType) {
|
||||
case TickMarkType.Year:
|
||||
return parts.yyyy || applyChartTimePattern('yyyy', parts);
|
||||
case TickMarkType.Month:
|
||||
if (datePat.includes('MM')) return applyChartTimePattern(datePat.includes('yyyy') ? 'yyyy-MM' : 'MM', parts);
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(new Date(unixSec * 1000));
|
||||
case TickMarkType.DayOfMonth:
|
||||
if (isDailyTimeframe(timeframe) || !patternHasTime(fmt)) {
|
||||
return datePat ? applyChartTimePattern(datePat, parts) : parts.dd;
|
||||
}
|
||||
return datePat ? applyChartTimePattern(datePat, parts) : parts.dd;
|
||||
case TickMarkType.Time:
|
||||
return timePat ? applyChartTimePattern(timePat.replace(/ss/g, ''), parts) : `${parts.HH}:${parts.mm}`;
|
||||
case TickMarkType.TimeWithSeconds:
|
||||
return timePat ? applyChartTimePattern(timePat, parts) : `${parts.HH}:${parts.mm}:${parts.ss}`;
|
||||
default:
|
||||
return formatUnixWithChartPattern(unixSec, fmt, zone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 차트 pane에서 지표 설정 저장 직후 — App 전역 기본값 병합 1회 스킵 */
|
||||
let skipNextChartDefaultsSync = false;
|
||||
|
||||
export function markChartIndicatorSaveCommitted(): void {
|
||||
skipNextChartDefaultsSync = true;
|
||||
}
|
||||
|
||||
export function consumeSkipNextChartDefaultsSync(): boolean {
|
||||
if (!skipNextChartDefaultsSync) return false;
|
||||
skipNextChartDefaultsSync = false;
|
||||
return true;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings', 'verification-board',
|
||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -36,7 +36,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
notifications: '알림',
|
||||
notifications: '알림목록',
|
||||
settings: '설정',
|
||||
settings_general: '설정 · 일반',
|
||||
settings_chart: '설정 · 차트',
|
||||
|
||||
@@ -276,7 +276,8 @@ function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
||||
|
||||
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
||||
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
||||
const branches = collectEditorBranches(state).filter(
|
||||
const synced = syncEditorStateCandleTypesForSave(state);
|
||||
const branches = collectEditorBranches(synced).filter(
|
||||
(b): b is ConditionBranch & { root: LogicNode } => b.root != null,
|
||||
);
|
||||
|
||||
@@ -414,6 +415,15 @@ export function syncBuySellPrimaryStartCandleTypes(
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 직전 — 각 START 분봉을 조건 트리 left/rightCandleType 에 반영 */
|
||||
export function syncEditorStateCandleTypesForSave(state: EditorConditionState): EditorConditionState {
|
||||
let next = state;
|
||||
for (const section of collectStartSections(state)) {
|
||||
next = updateStartCandleTypes(next, section.startId, section.candleTypes);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 저장 직전 — 양쪽 START 분봉 합집합을 각 탭에 반영 (매수만 설정한 경우 매도 DSL도 동기화) */
|
||||
export function alignBuySellStartCandleTypesForSave(
|
||||
buy: EditorConditionState,
|
||||
@@ -423,7 +433,11 @@ export function alignBuySellStartCandleTypesForSave(
|
||||
...getStartCandleTypes(buy.startMeta[START_NODE_ID]),
|
||||
...getStartCandleTypes(sell.startMeta[START_NODE_ID]),
|
||||
]);
|
||||
return syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
|
||||
const synced = syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
|
||||
return {
|
||||
buy: syncEditorStateCandleTypesForSave(synced.buy),
|
||||
sell: syncEditorStateCandleTypesForSave(synced.sell),
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
encodeConditionForSave,
|
||||
mergeStartMetaForLoad,
|
||||
normalizeStartCombineOp,
|
||||
syncEditorStateCandleTypesForSave,
|
||||
type EditorConditionState,
|
||||
} from './strategyConditionSerde';
|
||||
import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
|
||||
@@ -59,8 +60,20 @@ export function collectUiEvaluationTimeframes(
|
||||
return collectFromStrategyDto(strategy);
|
||||
}
|
||||
|
||||
function evaluationTimeframesMatch(uiTfs: string[], serverTfs: string[]): boolean {
|
||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const missingOnServer = uiTfs.some(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
const extraOnServer = [...serverSet].some(tf => !uiSet.has(tf));
|
||||
if (!missingOnServer && !extraOnServer) return true;
|
||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 분봉과 서버 DSL 불일치 시 안내.
|
||||
* UI 분봉과 서버 DSL 불일치 시 자동 동기화 시도 후, 여전히 다르면 안내.
|
||||
* @returns false — 진행 불가(저장 필요)
|
||||
*/
|
||||
export async function warnStrategyTimeframeMismatch(
|
||||
@@ -68,7 +81,8 @@ export async function warnStrategyTimeframeMismatch(
|
||||
strategy?: StrategyDto | null,
|
||||
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||
): Promise<boolean> {
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy, editorState);
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat, editorState);
|
||||
if (uiTfs.length === 0) return true;
|
||||
|
||||
let serverTfs: string[];
|
||||
@@ -77,18 +91,27 @@ export async function warnStrategyTimeframeMismatch(
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||
|
||||
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId, strat);
|
||||
if (synced) {
|
||||
try {
|
||||
serverTfs = await loadStrategyTimeframes(strategyId);
|
||||
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
|
||||
|
||||
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
|
||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||
if (uiSet.size === serverSet.size && [...uiSet].every(tf => serverSet.has(tf))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||
window.alert(
|
||||
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
||||
@@ -105,13 +128,14 @@ function buildEditorStateFromStrategy(
|
||||
(side === 'buy' ? strategy.buyCondition : strategy.sellCondition) as LogicNode | null,
|
||||
);
|
||||
const snap = layout?.[side];
|
||||
return {
|
||||
const merged: EditorConditionState = {
|
||||
root: decoded.root,
|
||||
startMeta: mergeStartMetaForLoad(decoded.startMeta, snap?.startMeta),
|
||||
extraStartIds: snap?.extraStartIds?.length ? snap.extraStartIds : decoded.extraStartIds,
|
||||
extraRoots: snap?.extraRoots ?? decoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(snap?.startCombineOp ?? decoded.startCombineOp),
|
||||
};
|
||||
return syncEditorStateCandleTypesForSave(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +145,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
): Promise<boolean> {
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
const strat = (await loadStrategy(strategyId)) ?? strategy ?? null;
|
||||
if (!strat) return true;
|
||||
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
||||
@@ -133,14 +157,18 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
if (missingOnServer.length === 0) return true;
|
||||
|
||||
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||
|
||||
const layout = strat.flowLayout;
|
||||
if (!layout) {
|
||||
return warnStrategyTimeframeMismatch(strategyId, strat);
|
||||
try {
|
||||
await repairStrategyTimeframes(strategyId);
|
||||
const repaired = await loadStrategyTimeframes(strategyId);
|
||||
return evaluationTimeframesMatch(uiTfs, repaired);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TickMarkType, type Time } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../types';
|
||||
import {
|
||||
formatLwcTickMarkWithPattern,
|
||||
formatLwcTimeWithPattern,
|
||||
getChartTimeFormat,
|
||||
} from './chartTimeFormat';
|
||||
|
||||
export const DEFAULT_DISPLAY_TIMEZONE = 'Asia/Seoul';
|
||||
|
||||
@@ -68,10 +73,6 @@ export function getTimezoneAbbr(tz: string): string {
|
||||
return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ';
|
||||
}
|
||||
|
||||
function isDailyTimeframe(tf?: string): boolean {
|
||||
return tf === '1D' || tf === '1W' || tf === '1M';
|
||||
}
|
||||
|
||||
/** 하단 바·시계 등 HH:mm:ss */
|
||||
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
|
||||
if (!unixSec) return '–';
|
||||
@@ -90,24 +91,7 @@ export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true
|
||||
/** 차트 축·범례용 */
|
||||
export function formatUnixForChart(unixSec: number, timeframe?: Timeframe, tz?: string): string {
|
||||
if (!unixSec) return '';
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const d = new Date(unixSec * 1000);
|
||||
if (isDailyTimeframe(timeframe)) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
return formatLwcTimeWithPattern(unixSec, timeframe, tz ?? _tz, getChartTimeFormat());
|
||||
}
|
||||
|
||||
/** LWC Time → unix 초 (없으면 null) */
|
||||
@@ -126,17 +110,7 @@ export function formatLwcTime(
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
): string {
|
||||
if (typeof time === 'number') {
|
||||
return formatUnixForChart(time, timeframe, tz);
|
||||
}
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const utc = Date.UTC(time.year, time.month - 1, time.day);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(utc));
|
||||
return formatLwcTimeWithPattern(time, timeframe, tz ?? _tz, getChartTimeFormat());
|
||||
}
|
||||
|
||||
/** LWC X축 눈금 — 선택한 표시 시간대 기준 (기본 포맷터는 브라우저 로컬 TZ 사용) */
|
||||
@@ -146,43 +120,13 @@ export function formatLwcTickMark(
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
): string {
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const unixSec = lwcTimeToUnix(time);
|
||||
if (unixSec == null) return '';
|
||||
const d = new Date(unixSec * 1000);
|
||||
|
||||
switch (tickMarkType) {
|
||||
case TickMarkType.Year:
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' }).format(d);
|
||||
case TickMarkType.Month:
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(d);
|
||||
case TickMarkType.DayOfMonth:
|
||||
if (isDailyTimeframe(timeframe)) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, day: 'numeric' }).format(d);
|
||||
case TickMarkType.Time:
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
case TickMarkType.TimeWithSeconds:
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
default:
|
||||
return formatUnixForChart(unixSec, timeframe, zone);
|
||||
}
|
||||
return formatLwcTickMarkWithPattern(
|
||||
time,
|
||||
tickMarkType,
|
||||
timeframe,
|
||||
tz ?? _tz,
|
||||
getChartTimeFormat(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 하단 바 표시: `06:36:00 KST` */
|
||||
|
||||
@@ -91,7 +91,7 @@ export function buildSignalDetailRows(item: TradeSignalDisplaySource): Array<{ l
|
||||
{ label: '종목', value: primary !== secondary ? `${secondary} · ${primary}` : secondary },
|
||||
{ label: '매매 구분', value: getSignalTypeKo(item.signalType), highlight: true },
|
||||
{ label: '기준 가격', value: formatSignalPrice(item.price), highlight: true },
|
||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${item.candleType ?? '1m'}` },
|
||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${formatCandleTypeKo(item.candleType)}` },
|
||||
];
|
||||
|
||||
if (item.receivedAt) {
|
||||
|
||||
Reference in New Issue
Block a user