매매 시그널 알림 화면 수정

This commit is contained in:
Macbook
2026-05-29 00:46:20 +09:00
parent cbad62a5b0
commit e43b5cbd5a
45 changed files with 2186 additions and 415 deletions
+37
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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;
+23
View File
@@ -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':
+1
View File
@@ -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>
+61 -4
View File
@@ -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 &amp; 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 &amp; 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 && (
+12
View File
@@ -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;
+400 -80
View File
@@ -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;
}
}
+110 -21
View File
@@ -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 {
+111 -10
View File
@@ -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 {
+1 -1
View File
@@ -100,7 +100,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
P('메뉴-백테스팅-분석 차트', 'analysis'),
// ── 상단 메뉴바 기타 ──
P('메뉴-알림 목록', 'notifications', '시그널목록'),
P('메뉴-알림목록', 'notifications', '알림목록'),
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
P('메뉴-로그인', 'login', 'auth'),
];
+2
View File
@@ -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 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
+214
View File
@@ -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;
}
+2 -2
View File
@@ -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: '설정 · 차트',
+16 -2
View File
@@ -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용 분기 목록 */
+44 -16
View File
@@ -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');
+14 -70
View File
@@ -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` */
+1 -1
View File
@@ -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) {