차트 설정 수정

This commit is contained in:
Macbook
2026-06-01 01:35:31 +09:00
parent 332019866a
commit f2208aab99
28 changed files with 546 additions and 333 deletions
+7
View File
@@ -3196,6 +3196,13 @@ html.theme-blue {
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px solid var(--sep); border-bottom: 1px solid var(--sep);
} }
.ism-color-popover-footer {
display: flex;
justify-content: flex-end;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--sep);
}
.ism-color-native-label { .ism-color-native-label {
display: flex; display: flex;
align-items: center; align-items: center;
+29 -6
View File
@@ -84,6 +84,7 @@ import LiveStrategyPanel from './components/LiveStrategyPanel';
import { TradeNotificationProvider } from './contexts/TradeNotificationContext'; import { TradeNotificationProvider } from './contexts/TradeNotificationContext';
import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext'; import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext';
import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier'; import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier';
import type { LiveMarker } from './hooks/useLiveStrategyMarkers';
import { FormalLoginGate } from './components/FormalLoginGate'; import { FormalLoginGate } from './components/FormalLoginGate';
import { import {
isFormalLogin, isFormalLogin,
@@ -999,13 +1000,18 @@ function App() {
// 전략 설정 화면에서 추가/수정된 전략이 즉시 반영되도록 한다. // 전략 설정 화면에서 추가/수정된 전략이 즉시 반영되도록 한다.
const refreshLiveStrategies = useCallback(() => { const refreshLiveStrategies = useCallback(() => {
loadStrategiesForLive() loadStrategiesForLive()
.then(list => setLiveStrategies( .then(list => {
list const rows = list
.filter(s => s.id != null) .filter(s => s.id != null)
.map(s => ({ id: s.id as number, name: s.name })) .map(s => ({ id: s.id as number, name: s.name }));
)) setLiveStrategies(rows);
const sid = appDefaults.liveStrategyId;
if (sid != null && !rows.some(r => r.id === sid)) {
saveAppDef({ liveStrategyId: undefined, liveStrategyCheck: false });
}
})
.catch(() => {}); .catch(() => {});
}, []); }, [appDefaults.liveStrategyId, saveAppDef]);
useEffect(() => { useEffect(() => {
if (!formalLogin) { if (!formalLogin) {
@@ -1016,7 +1022,9 @@ function App() {
}, [menuPage, showLivePanel, formalLogin, refreshLiveStrategies]); }, [menuPage, showLivePanel, formalLogin, refreshLiveStrategies]);
const liveNotifierRef = useRef<LiveSignalNotifierHandle>(null); const liveNotifierRef = useRef<LiveSignalNotifierHandle>(null);
const liveMarkersRef = useRef<LiveMarker[]>([]);
const clearLiveMarkers = useCallback(() => { const clearLiveMarkers = useCallback(() => {
liveMarkersRef.current = [];
liveNotifierRef.current?.clearMarkers(); liveNotifierRef.current?.clearMarkers();
managerRef.current?.clearLiveStrategyMarkers(); managerRef.current?.clearLiveStrategyMarkers();
}, []); }, []);
@@ -1068,9 +1076,16 @@ function App() {
useEffect(() => { useEffect(() => {
if (!appSettingsLoaded) return; if (!appSettingsLoaded) return;
const apply = () => {
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator); managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
managerRef.current?.refreshPaneSeparatorOverlay();
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator)); slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator));
}, [appSettingsLoaded, chartPaneSeparator]); };
apply();
if (menuPage !== 'chart') return;
const raf = requestAnimationFrame(() => requestAnimationFrame(apply));
return () => cancelAnimationFrame(raf);
}, [appSettingsLoaded, menuPage, chartPaneSeparator]);
// mainChartStyle 변경 → ChartManager 즉시 적용 // mainChartStyle 변경 → ChartManager 즉시 적용
useEffect(() => { useEffect(() => {
@@ -1112,6 +1127,12 @@ function App() {
}; };
}, [btMatchesChart, btSignals, btRunSeq, symbol, timeframe, appDefaults.btShowPrice, applyBacktestMarkersWhenReady, syncBacktestMarkers]); }, [btMatchesChart, btSignals, btRunSeq, symbol, timeframe, appDefaults.btShowPrice, applyBacktestMarkersWhenReady, syncBacktestMarkers]);
// 금액 표시 토글 변경 시 실시간 시그널 마커 텍스트 재적용
useEffect(() => {
if (menuPage !== 'chart' || liveMarkersRef.current.length === 0) return;
managerRef.current?.setLiveStrategyMarkers(liveMarkersRef.current, appDefaults.btShowPrice);
}, [menuPage, appDefaults.btShowPrice]);
// ── 시뮬레이션 데이터 (Upbit 아닐 때) ────────────────────────────────── // ── 시뮬레이션 데이터 (Upbit 아닐 때) ──────────────────────────────────
const [simBars, setSimBars] = useState<OHLCVBar[]>(() => const [simBars, setSimBars] = useState<OHLCVBar[]>(() =>
!isUpbitMarket(initial.symbol) ? generateBars(initial.symbol, initial.timeframe) : [] !isUpbitMarket(initial.symbol) ? generateBars(initial.symbol, initial.timeframe) : []
@@ -1751,6 +1772,7 @@ function App() {
popupEnabled={appDefaults.tradeAlertPopup ?? true} popupEnabled={appDefaults.tradeAlertPopup ?? true}
chartMarkersActive={menuPage === 'chart'} chartMarkersActive={menuPage === 'chart'}
onMarkersChange={markers => { onMarkersChange={markers => {
liveMarkersRef.current = markers;
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice); managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
}} }}
/> />
@@ -1989,6 +2011,7 @@ function App() {
onChartPaneSeparatorChange={opts => { onChartPaneSeparatorChange={opts => {
saveAppDef({ chartPaneSeparator: opts }); saveAppDef({ chartPaneSeparator: opts });
managerRef.current?.setPaneSeparatorOptions(opts); managerRef.current?.setPaneSeparatorOptions(opts);
managerRef.current?.refreshPaneSeparatorOverlay();
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts)); slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(opts));
}} }}
paperTradingEnabled={paperTradingEnabled} paperTradingEnabled={paperTradingEnabled}
@@ -96,7 +96,7 @@ export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
</BtField> </BtField>
<BtField <BtField
label="매수/매도 금액 표시" label="매수/매도 금액 표시"
desc="차트 마커에 체결 금액을 함께 표시합니다. ON: '매수 : 1,234,000' / OFF: '매수'만 표시." desc="차트·백테스트 마커에 체결 금액을 함께 표시합니다. 차트 설정과 동일합니다. OFF: '매수'·'매도'만 표시."
> >
<label className="stg-toggle"> <label className="stg-toggle">
<input type="checkbox" checked={btShowPrice} onChange={e => onBtShowPrice?.(e.target.checked)} /> <input type="checkbox" checked={btShowPrice} onChange={e => onBtShowPrice?.(e.target.checked)} />
+1
View File
@@ -347,6 +347,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
}, },
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => { setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => {
managerRef.current?.setPaneSeparatorOptions(opts); managerRef.current?.setPaneSeparatorOptions(opts);
managerRef.current?.refreshPaneSeparatorOverlay();
}, },
setChartTimeFormat: (format: string, tf?: Timeframe) => { setChartTimeFormat: (format: string, tf?: Timeframe) => {
managerRef.current?.setChartTimeFormat(format); managerRef.current?.setChartTimeFormat(format);
+22 -4
View File
@@ -12,9 +12,19 @@ export interface ColorInputProps {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
disabled?: boolean; disabled?: boolean;
/** false — 인라인 네이티브 color input 숨김(설정 화면: OS 피커만 뜨는 문제 방지) */
showCompactNative?: boolean;
/** false — 바깥 클릭으로 닫지 않음(확인·✕·Esc 로만 닫기) */
closeOnClickOutside?: boolean;
} }
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = false }) => { const ColorInput: React.FC<ColorInputProps> = ({
value,
onChange,
disabled = false,
showCompactNative = true,
closeOnClickOutside = false,
}) => {
const { hex6, alpha } = parsePlotColor(value); const { hex6, alpha } = parsePlotColor(value);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null); const anchorRef = useRef<HTMLDivElement>(null);
@@ -51,20 +61,19 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
}, [open, updatePosition]); }, [open, updatePosition]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open || !closeOnClickOutside) return;
const onDocPointer = (e: Event) => { const onDocPointer = (e: Event) => {
const t = e.target as Node; const t = e.target as Node;
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return; if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
setOpen(false); setOpen(false);
}; };
// 설정 모달 등이 bubble 단계에서 stopPropagation 하므로 capture 사용
document.addEventListener('mousedown', onDocPointer, true); document.addEventListener('mousedown', onDocPointer, true);
document.addEventListener('pointerdown', onDocPointer, true); document.addEventListener('pointerdown', onDocPointer, true);
return () => { return () => {
document.removeEventListener('mousedown', onDocPointer, true); document.removeEventListener('mousedown', onDocPointer, true);
document.removeEventListener('pointerdown', onDocPointer, true); document.removeEventListener('pointerdown', onDocPointer, true);
}; };
}, [open]); }, [open, closeOnClickOutside]);
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha)); const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a)); const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
@@ -92,6 +101,7 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
</button> </button>
</div> </div>
{showCompactNative && (
<div className="ism-color-popover-native"> <div className="ism-color-popover-native">
<label className="ism-color-native-label"> <label className="ism-color-native-label">
<span className="ism-style-label"> </span> <span className="ism-style-label"> </span>
@@ -103,7 +113,13 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
/> />
</label> </label>
</div> </div>
)}
<ColorPickerPanel hex6={hex6} onHexChange={patchHex} /> <ColorPickerPanel hex6={hex6} onHexChange={patchHex} />
<div className="ism-color-popover-footer">
<button type="button" className="plsp-popup-ok" onClick={closePopover}>
</button>
</div>
</div> </div>
) : null; ) : null;
@@ -123,6 +139,7 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
> >
<span className="ism-color-swatch-preview" style={{ background: plotColorCss(value) }} /> <span className="ism-color-swatch-preview" style={{ background: plotColorCss(value) }} />
</button> </button>
{showCompactNative && (
<input <input
type="color" type="color"
className="ism-color-picker ism-color-picker--compact" className="ism-color-picker ism-color-picker--compact"
@@ -131,6 +148,7 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
disabled={disabled} disabled={disabled}
onChange={e => patchHex(e.target.value)} onChange={e => patchHex(e.target.value)}
/> />
)}
<NumericParamInput <NumericParamInput
className="ism-input ism-alpha-input" className="ism-input ism-alpha-input"
value={alpha} value={alpha}
@@ -77,14 +77,13 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
const prev = settings; const prev = settings;
if (next.isLiveCheck && next.strategyId != null) { if (next.isLiveCheck && next.strategyId != null) {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId); const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
if (!synced) return; if (!synced) {
window.alert('선택한 전략을 서버에서 찾을 수 없습니다. 전략 목록을 새로고침하거나 다른 전략을 선택해 주세요.');
return;
}
const ok = await warnStrategyTimeframeMismatch(next.strategyId); const ok = await warnStrategyTimeframeMismatch(next.strategyId);
if (!ok) return; if (!ok) return;
try {
await repairStrategyTimeframes(next.strategyId); await repairStrategyTimeframes(next.strategyId);
} catch {
/* repair optional */
}
} }
onSettingsChange?.(next); onSettingsChange?.(next);
setSaving(true); setSaving(true);
@@ -67,7 +67,9 @@ interface MarketSearchPanelProps {
actionMode?: 'favorite' | 'add'; actionMode?: 'favorite' | 'add';
/** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */ /** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void; onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
/** 이미 추가된 종목 (add 모드에서 + 비활성) */ /** actionMode=add 일 때 이미 추가된 종목 클릭 → 투자대상에서 제거 */
onRemove?: (market: string) => void;
/** 이미 추가된 종목 (add 모드에서 체크 표시·클릭 시 제거) */
addedMarkets?: Set<string>; addedMarkets?: Set<string>;
/** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */ /** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */
maxTargetsReached?: boolean; maxTargetsReached?: boolean;
@@ -76,7 +78,7 @@ interface MarketSearchPanelProps {
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '', currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
variant = 'overlay', query: controlledQuery, onQueryChange, variant = 'overlay', query: controlledQuery, onQueryChange,
actionMode = 'favorite', onAdd, addedMarkets, maxTargetsReached = false, actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
}) => { }) => {
const embedded = variant === 'embedded'; const embedded = variant === 'embedded';
const isCompact = useIsCompactDevice(); const isCompact = useIsCompactDevice();
@@ -405,10 +407,10 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
type="button" type="button"
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`} className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
onClick={e => handleAddClick(m.market, e)} onClick={e => handleAddClick(m.market, e)}
disabled={isAdded || addBlocked} disabled={addBlocked}
title={ title={
isAdded isAdded
? '이미 추가됨' ? '투자대상에서 제거'
: addBlocked : addBlocked
? '투자대상 최대 개수에 도달했습니다' ? '투자대상 최대 개수에 도달했습니다'
: '투자대상에 추가' : '투자대상에 추가'
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import type { ChartManager } from '../utils/ChartManager'; import type { ChartManager } from '../utils/ChartManager';
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator'; import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
import { classifyPaneBoundary } from '../types/chartPaneSeparator'; import { classifyPaneBoundary, paneSeparatorOptionsKey } from '../types/chartPaneSeparator';
import { plotColorCss } from '../utils/plotColorUtils'; import { plotColorCss } from '../utils/plotColorUtils';
interface Props { interface Props {
@@ -34,6 +34,7 @@ function strokeBoundary(
const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => { const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const optionsKey = paneSeparatorOptionsKey(options);
const redraw = useCallback(() => { const redraw = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -72,7 +73,7 @@ const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
const y = lower.topY + lower.height; const y = lower.topY + lower.height;
strokeBoundary(ctx, cssW, y, style); strokeBoundary(ctx, cssW, y, style);
} }
}, [manager, options]); }, [manager, options, optionsKey]);
useEffect(() => { useEffect(() => {
redraw(); redraw();
@@ -80,11 +81,15 @@ const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
const container = manager.getContainer(); const container = manager.getContainer();
const ro = new ResizeObserver(() => redraw()); const ro = new ResizeObserver(() => redraw());
if (container) ro.observe(container); if (container) ro.observe(container);
const raf1 = requestAnimationFrame(redraw);
const raf2 = requestAnimationFrame(() => requestAnimationFrame(redraw));
return () => { return () => {
unsub(); unsub();
ro.disconnect(); ro.disconnect();
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
}; };
}, [manager, redraw]); }, [manager, redraw, optionsKey]);
return ( return (
<canvas <canvas
+36 -12
View File
@@ -39,6 +39,8 @@ import {
type ChartLegendVisibility, type ChartLegendVisibility,
} from '../types/chartLegend'; } from '../types/chartLegend';
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator'; import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
import { cloneChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import ColorInput from './ColorInput';
import { import {
LINE_STYLE_LABELS, LINE_STYLE_LABELS,
LINE_STYLE_OPTIONS, LINE_STYLE_OPTIONS,
@@ -724,15 +726,11 @@ function PaneSeparatorStyleFields({
{style.visible && ( {style.visible && (
<> <>
<SettingRow label="선 색상"> <SettingRow label="선 색상">
<div className="stg-color-row"> <ColorInput
<input value={style.color}
type="color" onChange={color => onChange({ ...style, color })}
className="stg-color" showCompactNative={false}
value={style.color.startsWith('#') ? style.color.slice(0, 7) : '#2a2e3a'}
onChange={e => onChange({ ...style, color: e.target.value })}
/> />
<span className="stg-color-label">{style.color}</span>
</div>
</SettingRow> </SettingRow>
<SettingRow label="선 굵기"> <SettingRow label="선 굵기">
<select <select
@@ -789,6 +787,8 @@ interface ChartPanelProps {
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
chartTimeFormat?: string; chartTimeFormat?: string;
onChartTimeFormatChange?: (format: string) => void; onChartTimeFormatChange?: (format: string) => void;
btShowPrice?: boolean;
onBtShowPrice?: (v: boolean) => void;
} }
const ChartPanel: React.FC<ChartPanelProps> = ({ const ChartPanel: React.FC<ChartPanelProps> = ({
@@ -811,6 +811,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
onChartPaneSeparatorChange, onChartPaneSeparatorChange,
chartTimeFormat = 'MM-dd HH:mm', chartTimeFormat = 'MM-dd HH:mm',
onChartTimeFormatChange, onChartTimeFormatChange,
btShowPrice = true,
onBtShowPrice,
}) => { }) => {
const [upColor, setUp] = useState('#26a69a'); const [upColor, setUp] = useState('#26a69a');
const [downColor, setDown] = useState('#ef5350'); const [downColor, setDown] = useState('#ef5350');
@@ -953,6 +955,22 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
</SettingRow> </SettingRow>
</SettingSection> </SettingSection>
<SettingSection title="매매 시그널">
<SettingRow
label="매수·매도 금액 표시"
desc="백테스트·실시간 전략 시그널 마커에 체결 금액을 함께 표시합니다. OFF이면 '매수'·'매도'만 표시합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={btShowPrice}
onChange={e => onBtShowPrice?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
{chartPaneSeparator && onChartPaneSeparatorChange && ( {chartPaneSeparator && onChartPaneSeparatorChange && (
<SettingSection title="차트 영역 구분선"> <SettingSection title="차트 영역 구분선">
<div className="stg-subsection-label"> <div className="stg-subsection-label">
@@ -961,10 +979,12 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
</div> </div>
<PaneSeparatorStyleFields <PaneSeparatorStyleFields
style={chartPaneSeparator.mainToIndicator} style={chartPaneSeparator.mainToIndicator}
onChange={mainToIndicator => onChartPaneSeparatorChange({ onChange={mainToIndicator => onChartPaneSeparatorChange(
cloneChartPaneSeparatorOptions({
...chartPaneSeparator, ...chartPaneSeparator,
mainToIndicator, mainToIndicator,
})} }),
)}
/> />
<div className="stg-subsection-label stg-subsection-label--spaced"> <div className="stg-subsection-label stg-subsection-label--spaced">
@@ -972,10 +992,12 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
</div> </div>
<PaneSeparatorStyleFields <PaneSeparatorStyleFields
style={chartPaneSeparator.indicatorToIndicator} style={chartPaneSeparator.indicatorToIndicator}
onChange={indicatorToIndicator => onChartPaneSeparatorChange({ onChange={indicatorToIndicator => onChartPaneSeparatorChange(
cloneChartPaneSeparatorOptions({
...chartPaneSeparator, ...chartPaneSeparator,
indicatorToIndicator, indicatorToIndicator,
})} }),
)}
/> />
</SettingSection> </SettingSection>
)} )}
@@ -1810,6 +1832,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartPaneSeparatorChange={onChartPaneSeparatorChange} onChartPaneSeparatorChange={onChartPaneSeparatorChange}
chartTimeFormat={chartTimeFormat} chartTimeFormat={chartTimeFormat}
onChartTimeFormatChange={onChartTimeFormatChange} onChartTimeFormatChange={onChartTimeFormatChange}
btShowPrice={btShowPrice}
onBtShowPrice={onBtShowPrice}
/> />
); );
case 'indicators': case 'indicators':
@@ -515,7 +515,7 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!hasRegisteredUser()) return; if (!hasRegisteredUser()) return;
loadStrategies().then(async list => { loadStrategies().then(async list => {
if (list?.length) { if (list?.length) {
setStrategies(list.map(s => ({ const mapped = list.map(s => ({
id: s.id ?? Date.now(), id: s.id ?? Date.now(),
name: s.name, name: s.name,
description: s.description, description: s.description,
@@ -525,7 +525,11 @@ export default function StrategyEditorPage({ theme }: Props) {
enabled: s.enabled ?? true, enabled: s.enabled ?? true,
createdAt: s.createdAt, createdAt: s.createdAt,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
}))); }));
setStrategies(mapped);
setSelectedId(prev =>
prev != null && mapped.some(x => x.id === prev) ? prev : null,
);
} else { } else {
const local = loadStratsLocal(); const local = loadStratsLocal();
if (!local.length) return; if (!local.length) return;
+35 -11
View File
@@ -337,7 +337,6 @@ const TradingChart: React.FC<TradingChartProps> = ({
const el = containerRef.current; const el = containerRef.current;
if (el && !panStateRef.current.active) { if (el && !panStateRef.current.active) {
el.style.cursor = canPanRef.current ? 'grab' : ''; el.style.cursor = canPanRef.current ? 'grab' : '';
el.style.touchAction = canPanRef.current ? 'none' : '';
} }
}, [mode, drawingTool]); }, [mode, drawingTool]);
@@ -365,6 +364,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
useEffect(() => { useEffect(() => {
if (!paneSeparatorOptions) return; if (!paneSeparatorOptions) return;
managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions); managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
managerRef.current?.refreshPaneSeparatorOverlay();
}, [paneSeparatorOptions]); }, [paneSeparatorOptions]);
/** /**
@@ -913,15 +913,29 @@ const TradingChart: React.FC<TradingChartProps> = ({
}; };
container.addEventListener('contextmenu', onContextMenuCapture, { capture: true }); container.addEventListener('contextmenu', onContextMenuCapture, { capture: true });
const wrapper = wrapperRef.current;
const isChartPlotPointer = (clientX: number, clientY: number) => {
const rect = container.getBoundingClientRect();
const chartX = clientX - rect.left;
const chartY = clientY - rect.top;
if (chartX < 0 || chartX > container.clientWidth) return null;
if (chartY < 0 || chartY > container.clientHeight) return null;
return { chartX, chartY };
};
const onPanPointerDown = (e: PointerEvent) => { const onPanPointerDown = (e: PointerEvent) => {
if (!canPanRef.current || e.button !== 0) return; if (!canPanRef.current || e.button !== 0) return;
const rect = container.getBoundingClientRect(); const target = e.target as HTMLElement | null;
const chartX = e.clientX - rect.left; if (target?.closest('.chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) {
const chartY = e.clientY - rect.top; return;
if (mgr.isOnPriceAxis(chartX, chartY) || mgr.isOnTimeAxis(chartY)) return; }
const originPaneIndex = mgr.getPaneIndexAtChartY(chartY); const pt = isChartPlotPointer(e.clientX, e.clientY);
if (!pt) return;
if (mgr.isOnPriceAxis(pt.chartX, pt.chartY) || mgr.isOnTimeAxis(pt.chartY)) return;
const originPaneIndex = mgr.getPaneIndexAtChartY(pt.chartY);
const captureEl = wrapper ?? container;
try { try {
container.setPointerCapture(e.pointerId); captureEl.setPointerCapture(e.pointerId);
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -953,8 +967,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
}); });
}; };
const onPanPointerUp = () => { const onPanPointerUp = (e: PointerEvent) => {
if (!panStateRef.current.active) return; if (!panStateRef.current.active) return;
const captureEl = wrapper ?? container;
try {
if (captureEl.hasPointerCapture(e.pointerId)) {
captureEl.releasePointerCapture(e.pointerId);
}
} catch {
/* ignore */
}
if (panStateRef.current.moved) { if (panStateRef.current.moved) {
suppressClickRef.current = true; suppressClickRef.current = true;
managerRef.current?.notifyViewportChanged(); managerRef.current?.notifyViewportChanged();
@@ -963,8 +985,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
container.style.cursor = canPanRef.current ? 'grab' : ''; container.style.cursor = canPanRef.current ? 'grab' : '';
}; };
container.addEventListener('pointerdown', onPanPointerDown); const panCapture = true;
window.addEventListener('pointermove', onPanPointerMove); const panTarget = wrapper ?? container;
panTarget.addEventListener('pointerdown', onPanPointerDown, panCapture);
window.addEventListener('pointermove', onPanPointerMove, { passive: false });
window.addEventListener('pointerup', onPanPointerUp); window.addEventListener('pointerup', onPanPointerUp);
window.addEventListener('pointercancel', onPanPointerUp); window.addEventListener('pointercancel', onPanPointerUp);
@@ -1122,7 +1146,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
container.removeEventListener('click', handleDrawingCapture, { capture: true }); container.removeEventListener('click', handleDrawingCapture, { capture: true });
container.removeEventListener('contextmenu', onContextMenuCapture, { capture: true }); container.removeEventListener('contextmenu', onContextMenuCapture, { capture: true });
container.removeEventListener('dblclick', onCandlePaneDblClick, { capture: true }); container.removeEventListener('dblclick', onCandlePaneDblClick, { capture: true });
container.removeEventListener('pointerdown', onPanPointerDown); panTarget.removeEventListener('pointerdown', onPanPointerDown, panCapture);
container.removeEventListener('wheel', onWheelCapture, { capture: true }); container.removeEventListener('wheel', onWheelCapture, { capture: true });
window.removeEventListener('pointermove', onPanPointerMove); window.removeEventListener('pointermove', onPanPointerMove);
window.removeEventListener('pointerup', onPanPointerUp); window.removeEventListener('pointerup', onPanPointerUp);
+20 -1
View File
@@ -114,7 +114,26 @@ const VirtualTradingPage: React.FC<Props> = ({
useEffect(() => { useEffect(() => {
void Promise.all([ void Promise.all([
loadStrategies().then(setStrategies).catch(() => setStrategies([])), loadStrategies()
.then(list => {
setStrategies(list);
const validIds = new Set(
list.map(s => s.id).filter((id): id is number => id != null && id > 0),
);
setSession(prev =>
prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId)
? { ...prev, globalStrategyId: null }
: prev,
);
setTargets(prev =>
prev.map(t =>
t.strategyId != null && !validIds.has(t.strategyId)
? { ...t, strategyId: null }
: t,
),
);
})
.catch(() => setStrategies([])),
loadPaperSummary().then(setSummary), loadPaperSummary().then(setSummary),
loadPaperTrades().then(setTrades), loadPaperTrades().then(setTrades),
]).finally(() => setLoading(false)); ]).finally(() => setLoading(false));
@@ -42,103 +42,65 @@ export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
onPaperAutoTradeEnabled, onPaperAutoTradeEnabled,
paperTradingEnabled = true, paperTradingEnabled = true,
}) => ( }) => (
<div className="vtd-header-session"> <div className="vtd-header-session vtd-header-session--compact">
<div className="vtd-session-field">
<span className="vtd-session-label"></span>
<select <select
className="vtd-session-select" className="vtd-header-select vtd-header-select--strategy"
aria-label="투자전략"
value={session.globalStrategyId ?? ''} value={session.globalStrategyId ?? ''}
onChange={e => { onChange={e => {
const v = e.target.value; const v = e.target.value;
onStrategyChange(v ? Number(v) : null); onStrategyChange(v ? Number(v) : null);
}} }}
> >
<option value=""> </option> <option value=""> </option>
{strategies.map(s => ( {strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
</div>
<div className="vtd-session-field"> <select
<span className="vtd-session-label"></span> className="vtd-header-select vtd-header-select--narrow"
<div className="vtd-view-toggle" role="group" aria-label="실행방식"> aria-label="실행방식"
<button value={session.executionType}
type="button" onChange={e => onExecutionTypeChange(e.target.value as VirtualSessionConfig['executionType'])}
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
> >
<option value="CANDLE_CLOSE"> </option>
</button> <option value="REALTIME_TICK"> </option>
<button </select>
type="button"
className={`vtd-view-toggle-btn${session.executionType === 'REALTIME_TICK' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('REALTIME_TICK')}
>
</button>
</div>
</div>
<div className="vtd-session-field"> <select
<span className="vtd-session-label"> </span> className="vtd-header-select vtd-header-select--narrow"
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드"> aria-label="시그널 모드"
<button value={session.positionMode}
type="button" onChange={e => onPositionModeChange(e.target.value as VirtualSessionConfig['positionMode'])}
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('LONG_ONLY')}
> >
<option value="LONG_ONLY"> </option>
</button> <option value="SIGNAL_ONLY"> </option>
<button </select>
type="button"
className={`vtd-view-toggle-btn${session.positionMode === 'SIGNAL_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('SIGNAL_ONLY')}
>
</button>
</div>
</div>
<div className="vtd-session-field"> <select
<span className="vtd-session-label"></span> className="vtd-header-select vtd-header-select--narrow"
<div
className="vtd-view-toggle"
role="group"
aria-label="자동매매" aria-label="자동매매"
title={ title={
!paperTradingEnabled !paperTradingEnabled
? '설정 › 가상투자에서 가상매매를 활성화하세요' ? '설정 › 가상투자에서 가상매매를 활성화하세요'
: paperAutoTradeEnabled : undefined
? 'Match 시 서버 자동 체결(모의·자동매매 ON, 화면 불필요)'
: '수동 주문만 가능'
} }
> value={paperAutoTradeEnabled ? 'on' : 'off'}
<button
type="button"
className={`vtd-view-toggle-btn${paperAutoTradeEnabled ? ' vtd-view-toggle-btn--on' : ''}`}
disabled={!paperTradingEnabled || !onPaperAutoTradeEnabled} disabled={!paperTradingEnabled || !onPaperAutoTradeEnabled}
onClick={() => onPaperAutoTradeEnabled?.(true)} onChange={e => onPaperAutoTradeEnabled?.(e.target.value === 'on')}
> >
ON <option value="off"> OFF</option>
</button> <option value="on"> ON</option>
<button </select>
type="button"
className={`vtd-view-toggle-btn${!paperAutoTradeEnabled ? ' vtd-view-toggle-btn--on' : ''}`}
disabled={!paperTradingEnabled || !onPaperAutoTradeEnabled}
onClick={() => onPaperAutoTradeEnabled?.(false)}
>
OFF
</button>
</div>
</div>
<div className="vtd-session-actions"> <div className="vtd-session-actions vtd-session-actions--compact">
<button <button
type="button" type="button"
className="bps-btn vtd-session-btn vtd-session-btn--start" className="bps-btn vtd-session-btn vtd-session-btn--start"
disabled={session.running} disabled={session.running}
onClick={onStart} onClick={onStart}
title="가상투자 시작"
> >
</button> </button>
@@ -147,6 +109,7 @@ export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
className="bps-btn bps-btn--danger vtd-session-btn" className="bps-btn bps-btn--danger vtd-session-btn"
disabled={!session.running} disabled={!session.running}
onClick={onStop} onClick={onStop}
title="가상투자 종료"
> >
</button> </button>
@@ -165,63 +128,36 @@ export const VirtualViewHeaderControls: React.FC<ViewProps> = ({
onGridPresetChange, onGridPresetChange,
showGridLayoutPicker = true, showGridLayoutPicker = true,
}) => ( }) => (
<div className="vtd-header-view"> <div className="vtd-header-view vtd-header-view--compact">
{showGridLayoutPicker && onGridPresetChange && ( {showGridLayoutPicker && onGridPresetChange && (
<>
<VirtualGridLayoutPicker <VirtualGridLayoutPicker
value={gridPreset} value={gridPreset}
onChange={onGridPresetChange} onChange={onGridPresetChange}
disabled={!showSignalChartToggle} disabled={!showSignalChartToggle}
/> />
<span className="vtd-grid-head-divider" aria-hidden="true" />
</>
)} )}
{showSignalChartToggle && onGlobalDisplayModeChange && ( {showSignalChartToggle && onGlobalDisplayModeChange && (
<> <select
<div className="vtd-grid-head-group" role="group" aria-label="전체 종목 신호·차트"> className="vtd-header-select vtd-header-select--narrow"
<div className="vtd-view-toggle"> aria-label="신호 또는 차트"
<button value={globalDisplayMode}
type="button" onChange={e => onGlobalDisplayModeChange(e.target.value as VirtualCardDisplayMode)}
className={`vtd-view-toggle-btn${globalDisplayMode === 'signal' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onGlobalDisplayModeChange('signal')}
> >
<option value="signal"></option>
</button> <option value="chart"></option>
<button </select>
type="button"
className={`vtd-view-toggle-btn${globalDisplayMode === 'chart' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onGlobalDisplayModeChange('chart')}
>
</button>
</div>
</div>
<span className="vtd-grid-head-divider" aria-hidden="true" />
</>
)} )}
<div className="vtd-grid-head-group" role="group" aria-label="카드 표시 방식"> <select
<div className="vtd-view-toggle"> className="vtd-header-select vtd-header-select--narrow"
<button aria-label="카드 표시 방식"
type="button" value={viewMode}
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`} onChange={e => onViewModeChange(e.target.value as VirtualCardViewMode)}
onClick={() => onViewModeChange('summary')}
> >
<option value="summary"></option>
</button> <option value="detail"></option>
<button </select>
type="button"
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onViewModeChange('detail')}
>
</button>
</div>
</div>
{session.running && ( {session.running && (
<> <span className="vtd-grid-live" title="3초마다 갱신"></span>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<span className="vtd-grid-live"> 3</span>
</>
)} )}
</div> </div>
); );
@@ -88,8 +88,6 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket, virtualTargetMaxCount]); }, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket, virtualTargetMaxCount]);
const handleRemove = useCallback((market: string) => { const handleRemove = useCallback((market: string) => {
const item = targets.find(t => t.market === market);
if (item?.pinned) return;
onTargetsChange(targets.filter(t => t.market !== market)); onTargetsChange(targets.filter(t => t.market !== market));
}, [targets, onTargetsChange]); }, [targets, onTargetsChange]);
@@ -119,6 +117,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
addedMarkets={addedSet} addedMarkets={addedSet}
maxTargetsReached={atTargetLimit} maxTargetsReached={atTargetLimit}
onAdd={handleAdd} onAdd={handleAdd}
onRemove={handleRemove}
onSelect={onSelectMarket} onSelect={onSelectMarket}
onClose={closeDropdown} onClose={closeDropdown}
/> />
@@ -174,9 +173,8 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
<button <button
type="button" type="button"
className="vtd-target-remove" className="vtd-target-remove"
title={item.pinned ? '고정된 종목은 삭제할 수 없습니다' : '목록에서 제거'} title="목록에서 제거"
aria-label="목록에서 제거" aria-label="목록에서 제거"
disabled={!!item.pinned}
onClick={e => { e.stopPropagation(); handleRemove(item.market); }} onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
> >
@@ -12,6 +12,7 @@ interface Props {
onStop: () => void; onStop: () => void;
} }
/** 레거시 — VirtualSessionHeaderControls 와 동일 드롭다운 레이아웃 */
const VirtualSessionToolbar: React.FC<Props> = ({ const VirtualSessionToolbar: React.FC<Props> = ({
session, session,
strategies, strategies,
@@ -21,65 +22,43 @@ const VirtualSessionToolbar: React.FC<Props> = ({
onStart, onStart,
onStop, onStop,
}) => ( }) => (
<div className="vtd-session-toolbar"> <div className="vtd-session-toolbar vtd-header-session--compact">
<div className="vtd-session-field">
<span className="vtd-session-label"></span>
<select <select
className="vtd-session-select" className="vtd-header-select vtd-header-select--strategy"
aria-label="투자전략"
value={session.globalStrategyId ?? ''} value={session.globalStrategyId ?? ''}
onChange={e => { onChange={e => {
const v = e.target.value; const v = e.target.value;
onStrategyChange(v ? Number(v) : null); onStrategyChange(v ? Number(v) : null);
}} }}
> >
<option value=""> </option> <option value=""> </option>
{strategies.map(s => ( {strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
</div>
<div className="vtd-session-field"> <select
<span className="vtd-session-label"></span> className="vtd-header-select vtd-header-select--narrow"
<div className="vtd-view-toggle" role="group" aria-label="실행방식"> aria-label="실행방식"
<button value={session.executionType}
type="button" onChange={e => onExecutionTypeChange(e.target.value as VirtualSessionConfig['executionType'])}
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
> >
<option value="CANDLE_CLOSE"> </option>
</button> <option value="REALTIME_TICK"> </option>
<button </select>
type="button"
className={`vtd-view-toggle-btn${session.executionType === 'REALTIME_TICK' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onExecutionTypeChange('REALTIME_TICK')}
>
</button>
</div>
</div>
<div className="vtd-session-field"> <select
<span className="vtd-session-label"> </span> className="vtd-header-select vtd-header-select--narrow"
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드"> aria-label="시그널 모드"
<button value={session.positionMode}
type="button" onChange={e => onPositionModeChange(e.target.value as VirtualSessionConfig['positionMode'])}
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('LONG_ONLY')}
> >
<option value="LONG_ONLY"> </option>
</button> <option value="SIGNAL_ONLY"> </option>
<button </select>
type="button"
className={`vtd-view-toggle-btn${session.positionMode === 'SIGNAL_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onPositionModeChange('SIGNAL_ONLY')}
>
</button>
</div>
</div>
<div className="vtd-session-actions"> <div className="vtd-session-actions vtd-session-actions--compact">
<button <button
type="button" type="button"
className="bps-btn vtd-session-btn vtd-session-btn--start" className="bps-btn vtd-session-btn vtd-session-btn--start"
@@ -63,10 +63,6 @@ export function useVirtualTargetRegistry() {
const remove = useCallback(async (market: string) => { const remove = useCallback(async (market: string) => {
if (!addedSet.has(market) || busyMarket === market) return; if (!addedSet.has(market) || busyMarket === market) return;
if (targets.find(t => t.market === market)?.pinned) {
window.alert('고정된 종목은 투자대상에서 삭제할 수 없습니다.');
return;
}
setBusyMarket(market); setBusyMarket(market);
try { try {
const next = await removeVirtualTarget(market); const next = await removeVirtualTarget(market);
+20 -4
View File
@@ -117,7 +117,25 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
preferencesHydratedRef.current = true; preferencesHydratedRef.current = true;
} }
await Promise.all([ await Promise.all([
loadStrategies().then(s => { if (!cancelled) setStrategies(s); }).catch(() => { if (!cancelled) setStrategies([]); }), loadStrategies().then(s => {
if (cancelled) return;
setStrategies(s);
const validIds = new Set(
s.map(x => x.id).filter((id): id is number => id != null && id > 0),
);
setSession(prev =>
prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId)
? { ...prev, globalStrategyId: null }
: prev,
);
setTargets(prev =>
prev.map(t =>
t.strategyId != null && !validIds.has(t.strategyId)
? { ...t, strategyId: null }
: t,
),
);
}).catch(() => { if (!cancelled) setStrategies([]); }),
loadPaperSummary().then(s => { if (!cancelled) setSummary(s); }), loadPaperSummary().then(s => { if (!cancelled) setSummary(s); }),
loadPaperTrades().then(t => { if (!cancelled) setTrades(t); }), loadPaperTrades().then(t => { if (!cancelled) setTrades(t); }),
]); ]);
@@ -249,10 +267,8 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
}, [targets, virtualTargetMaxCount]); }, [targets, virtualTargetMaxCount]);
const handleRemoveTarget = useCallback((market: string) => { const handleRemoveTarget = useCallback((market: string) => {
const t = targets.find(x => x.market === market);
if (t?.pinned) return;
setTargets(prev => prev.filter(x => x.market !== market)); setTargets(prev => prev.filter(x => x.market !== market));
}, [targets]); }, []);
const handleTogglePin = useCallback((market: string) => { const handleTogglePin = useCallback((market: string) => {
setTargets(prev => { setTargets(prev => {
+6
View File
@@ -77,6 +77,12 @@
.bps-header--vtd .bps-header-actions { .bps-header--vtd .bps-header-actions {
justify-self: end; justify-self: end;
flex-shrink: 0; flex-shrink: 0;
min-width: 0;
}
.bps-header--vtd .header-toolbar-divider {
height: 20px;
margin: 0 4px;
} }
.bps-btn { .bps-btn {
+85 -11
View File
@@ -1,6 +1,6 @@
/* 가상투자 대시보드 */ /* 가상투자 대시보드 */
/* 타이틀바 — 세션 설정 (중앙 그리드 시작선) */ /* 타이틀바 — 세션 설정 (중앙, 드롭다운 한 줄) */
.vtd-header-session { .vtd-header-session {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -9,6 +9,19 @@
min-width: 0; min-width: 0;
} }
.vtd-header-session--compact {
flex-wrap: nowrap;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.vtd-header-session--compact::-webkit-scrollbar {
display: none;
}
.vtd-header-session .vtd-session-select { .vtd-header-session .vtd-session-select {
min-width: 120px; min-width: 120px;
max-width: 160px; max-width: 160px;
@@ -18,7 +31,36 @@
margin-left: 0; margin-left: 0;
} }
/* 타이틀바 — 뷰 옵션 (우측) */ .vtd-session-actions--compact {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
margin-left: 2px;
}
.vtd-header-select {
min-width: 0;
max-width: 148px;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid var(--se-border);
background: var(--se-panel-card-bg);
color: var(--se-text);
font-size: 11px;
line-height: 1.3;
flex-shrink: 0;
}
.vtd-header-select--strategy {
max-width: 132px;
}
.vtd-header-select--narrow {
max-width: 118px;
}
/* 타이틀바 — 뷰 옵션 (우측, 드롭다운·아이콘 한 줄) */
.vtd-header-view { .vtd-header-view {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -27,6 +69,13 @@
gap: 0; gap: 0;
} }
.vtd-header-view--compact {
flex-wrap: nowrap;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.bps-header--vtd .vtd-grid-live { .bps-header--vtd .vtd-grid-live {
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
@@ -38,17 +87,35 @@
@media (max-width: 1280px) { @media (max-width: 1280px) {
.bps-header--vtd { .bps-header--vtd {
grid-template-columns: 1fr; gap: 6px 8px;
gap: 8px; padding: 6px 12px;
min-height: 44px;
}
.bps-header--vtd .bps-header-left .bps-subtitle {
display: none;
}
.bps-header--vtd .bps-header-center {
min-width: 0;
overflow: hidden;
} }
.bps-header--vtd .bps-header-center,
.bps-header--vtd .bps-header-actions { .bps-header--vtd .bps-header-actions {
justify-self: stretch; flex-shrink: 0;
} }
.vtd-header-view { .vtd-header-select--strategy {
justify-content: flex-start; max-width: 108px;
}
.vtd-header-select--narrow {
max-width: 100px;
}
.vtd-session-btn {
padding: 4px 10px;
font-size: 0.75rem;
} }
} }
@@ -115,13 +182,14 @@
.vtd-session-toolbar { .vtd-session-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center; align-items: center;
gap: 10px 16px; gap: 6px;
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--se-border); border-bottom: 1px solid var(--se-border);
flex-shrink: 0; flex-shrink: 0;
background: var(--se-center-bg, var(--se-panel-card-bg)); background: var(--se-center-bg, var(--se-panel-card-bg));
overflow-x: auto;
} }
.vtd-session-field { .vtd-session-field {
@@ -2644,7 +2712,13 @@
background: color-mix(in srgb, var(--up, #26a69a) 12%, transparent); background: color-mix(in srgb, var(--up, #26a69a) 12%, transparent);
border-color: color-mix(in srgb, var(--up, #26a69a) 45%, var(--se-border, #333)); border-color: color-mix(in srgb, var(--up, #26a69a) 45%, var(--se-border, #333));
color: var(--up, #26a69a); color: var(--up, #26a69a);
cursor: default; cursor: pointer;
}
.msp-add-btn--done:hover:not(:disabled) {
background: color-mix(in srgb, var(--down, #ef5350) 14%, transparent);
border-color: color-mix(in srgb, var(--down, #ef5350) 50%, var(--se-border, #333));
color: var(--down, #ef5350);
} }
.msp-add-btn:disabled { .msp-add-btn:disabled {
+14
View File
@@ -103,6 +103,20 @@ export function classifyPaneBoundary(
} }
/** LWC pane 구분선 — 커스텀 오버레이 사용 시 투명 처리 */ /** LWC pane 구분선 — 커스텀 오버레이 사용 시 투명 처리 */
/** React·ChartManager 동기화용 불변 복제 */
export function cloneChartPaneSeparatorOptions(
opts: ChartPaneSeparatorOptions,
): ChartPaneSeparatorOptions {
return {
mainToIndicator: { ...opts.mainToIndicator },
indicatorToIndicator: { ...opts.indicatorToIndicator },
};
}
export function paneSeparatorOptionsKey(opts: ChartPaneSeparatorOptions): string {
return JSON.stringify(opts);
}
export function applyPaneSeparatorToChartOptions(_opts: ChartPaneSeparatorOptions) { export function applyPaneSeparatorToChartOptions(_opts: ChartPaneSeparatorOptions) {
return { return {
layout: { layout: {
+13 -5
View File
@@ -63,6 +63,7 @@ import type { PlotDef, HLineStyle } from './indicatorRegistry';
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils'; import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
import { import {
applyPaneSeparatorToChartOptions, applyPaneSeparatorToChartOptions,
cloneChartPaneSeparatorOptions,
resolveChartPaneSeparatorOptions, resolveChartPaneSeparatorOptions,
type ChartPaneSeparatorOptions, type ChartPaneSeparatorOptions,
} from '../types/chartPaneSeparator'; } from '../types/chartPaneSeparator';
@@ -311,7 +312,8 @@ export class ChartManager {
}, },
rightPriceScale: { borderColor: t.borderColor }, rightPriceScale: { borderColor: t.borderColor },
// pressedMouseMove: TradingChart 에서 커스텀 패닝 처리 (드로잉·클릭과 충돌 방지) // pressedMouseMove: TradingChart 에서 커스텀 패닝 처리 (드로잉·클릭과 충돌 방지)
handleScroll: { mouseWheel: true, pressedMouseMove: false, horzTouchDrag: true, vertTouchDrag: false }, // horzTouchDrag: 보조 pane 터치 드래그가 메인 timeScale 과 어긋나는 현상 방지 — TradingChart 커스텀 패닝만 사용
handleScroll: { mouseWheel: true, pressedMouseMove: false, horzTouchDrag: false, vertTouchDrag: false },
handleScale: { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true }, handleScale: { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
}); });
@@ -595,8 +597,13 @@ export class ChartManager {
} }
setPaneSeparatorOptions(opts: ChartPaneSeparatorOptions): void { setPaneSeparatorOptions(opts: ChartPaneSeparatorOptions): void {
this._paneSeparatorOptions = opts; this._paneSeparatorOptions = cloneChartPaneSeparatorOptions(opts);
this.chart.applyOptions(applyPaneSeparatorToChartOptions(opts)); this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions));
this._notifyPaneLayout();
}
/** pane 구분선 오버레이 강제 재그리기 (설정 변경·탭 복귀) */
refreshPaneSeparatorOverlay(): void {
this._notifyPaneLayout(); this._notifyPaneLayout();
} }
@@ -1210,9 +1217,10 @@ export class ChartManager {
): void { ): void {
this.liveStrategyMarkers = markers.map(m => { this.liveStrategyMarkers = markers.map(m => {
const buy = m.signal === 'BUY'; const buy = m.signal === 'BUY';
const label = buy ? '매수' : '매도';
const text = showPrice const text = showPrice
? `실시간 ${buy ? '매수' : '매도'} : ${m.price.toLocaleString()}` ? `${label} : ${m.price.toLocaleString()}`
: `실시간 ${buy ? '매수' : '매도'}`; : label;
return { return {
time: m.time, time: m.time,
position: buy ? ('belowBar' as const) : ('aboveBar' as const), position: buy ? ('belowBar' as const) : ('aboveBar' as const),
+47 -7
View File
@@ -90,6 +90,18 @@ async function requestOrThrow<T>(path: string, init?: RequestInit): Promise<T> {
return JSON.parse(text) as T; return JSON.parse(text) as T;
} }
/** 서버에 없는 전략 ID — 삭제·만료 후 반복 404 방지 (태블릿·Safari 네트워크 로그 완화) */
const strategyMissingIds = new Set<number>();
export function isStrategyMissingOnServer(strategyId: number): boolean {
return strategyId > 0 && strategyMissingIds.has(strategyId);
}
function markStrategyMissingFromPath(pathOnly: string): void {
const m = pathOnly.match(/^\/strategies\/(\d+)(?:\/|$)/);
if (m) strategyMissingIds.add(Number(m[1]));
}
async function request<T>(path: string, init?: RequestInit): Promise<T | null> { async function request<T>(path: string, init?: RequestInit): Promise<T | null> {
try { try {
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
@@ -100,8 +112,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T | null> {
if (!res.ok) { if (!res.ok) {
const method = init?.method ?? 'GET'; const method = init?.method ?? 'GET';
const guest403 = res.status === 403 && !hasRegisteredUser(); const guest403 = res.status === 403 && !hasRegisteredUser();
const missing404 = res.status === 404 && method === 'GET'; const pathOnly = path.split('?')[0];
if (!guest403 && !missing404) { if (res.status === 404 && /^\/strategies\/\d+/.test(pathOnly)) {
markStrategyMissingFromPath(pathOnly);
}
const quiet404 = res.status === 404 && (
method === 'GET'
|| pathOnly.includes('/repair-timeframes')
|| /^\/strategies\/\d+/.test(pathOnly)
);
if (!guest403 && !quiet404) {
console.error(`[backendApi] ${method} ${path}${res.status}`); console.error(`[backendApi] ${method} ${path}${res.status}`);
} }
return null; return null;
@@ -1061,7 +1081,7 @@ export async function loadStrategies(): Promise<StrategyDto[]> {
/** 단일 전략 로드 */ /** 단일 전략 로드 */
export async function loadStrategy(id: number): Promise<StrategyDto | null> { export async function loadStrategy(id: number): Promise<StrategyDto | null> {
if (!hasRegisteredUser()) return null; if (!hasRegisteredUser() || id <= 0 || strategyMissingIds.has(id)) return null;
return request<StrategyDto>(`/strategies/${id}`); return request<StrategyDto>(`/strategies/${id}`);
} }
@@ -1401,10 +1421,30 @@ export async function loadStrategyTimeframes(strategyId: number): Promise<string
return list?.length ? list : ['1m']; return list?.length ? list : ['1m'];
} }
/** DB DSL TIMEFRAME 래핑 보정 (3분봉 전략이 1m으로 평가되는 레거시 수정) */ const repairSkipIds = new Set<number>();
export async function repairStrategyTimeframes(strategyId: number): Promise<void> { const repairInflight = new Set<number>();
if (!hasRegisteredUser()) return;
await request(`/strategies/${strategyId}/repair-timeframes`, { method: 'POST' }); /** DB DSL TIMEFRAME 래핑 보정 — 전략 없으면 호출 안 함(404 방지) */
export async function repairStrategyTimeframes(strategyId: number): Promise<boolean> {
if (!hasRegisteredUser() || strategyId <= 0 || strategyMissingIds.has(strategyId)) return false;
if (repairSkipIds.has(strategyId) || repairInflight.has(strategyId)) return false;
repairInflight.add(strategyId);
try {
const exists = await loadStrategy(strategyId);
if (!exists) {
repairSkipIds.add(strategyId);
return false;
}
const ok = await request<StrategyDto>(
`/strategies/${strategyId}/repair-timeframes`,
{ method: 'POST' },
);
if (!ok) repairSkipIds.add(strategyId);
return ok != null;
} finally {
repairInflight.delete(strategyId);
}
} }
/** 차트 실시간 파이프라인 pin (STOMP warm-up) */ /** 차트 실시간 파이프라인 pin (STOMP warm-up) */
+2
View File
@@ -35,6 +35,8 @@ export function formatPlotColor(hex6: string, alphaPercent: number): string {
} }
export function plotColorCss(value: string): string { export function plotColorCss(value: string): string {
const v = value.trim();
if (/^rgba?\(/i.test(v) || /^hsla?\(/i.test(v)) return v;
const { hex6, alpha } = parsePlotColor(value); const { hex6, alpha } = parsePlotColor(value);
if (alpha >= 100) return hex6; if (alpha >= 100) return hex6;
const r = parseInt(hex6.slice(1, 3), 16); const r = parseInt(hex6.slice(1, 3), 16);
+2 -5
View File
@@ -7,12 +7,9 @@ export async function pinStrategyEvaluationTimeframes(
market: string, market: string,
strategyId: number, strategyId: number,
): Promise<string[]> { ): Promise<string[]> {
await syncStrategyTimeframesFromLayoutIfNeeded(strategyId); const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId);
try { if (!synced) return ['1m'];
await repairStrategyTimeframes(strategyId); await repairStrategyTimeframes(strategyId);
} catch {
/* 서버 미배포·권한 등 — timeframes 조회만 진행 */
}
const raw = await loadStrategyTimeframes(strategyId); const raw = await loadStrategyTimeframes(strategyId);
const timeframes = [...new Set(raw.map(tf => normalizeStartCandleType(tf)))]; const timeframes = [...new Set(raw.map(tf => normalizeStartCandleType(tf)))];
if (timeframes.length === 0) timeframes.push('1m'); if (timeframes.length === 0) timeframes.push('1m');
+15 -10
View File
@@ -1,4 +1,11 @@
import { loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi'; import {
isStrategyMissingOnServer,
loadStrategy,
loadStrategyTimeframes,
repairStrategyTimeframes,
saveStrategy,
type StrategyDto,
} from './backendApi';
import { import {
alignBuySellStartCandleTypesForSave, alignBuySellStartCandleTypesForSave,
collectDslBranches, collectDslBranches,
@@ -143,9 +150,10 @@ function buildEditorStateFromStrategy(
*/ */
export async function syncStrategyTimeframesFromLayoutIfNeeded( export async function syncStrategyTimeframesFromLayoutIfNeeded(
strategyId: number, strategyId: number,
strategy?: StrategyDto | null, _strategy?: StrategyDto | null,
): Promise<boolean> { ): Promise<boolean> {
const strat = (await loadStrategy(strategyId)) ?? strategy ?? null; if (isStrategyMissingOnServer(strategyId)) return false;
const strat = await loadStrategy(strategyId);
if (!strat) return false; if (!strat) return false;
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat); const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
@@ -162,13 +170,10 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
const layout = strat.flowLayout; const layout = strat.flowLayout;
if (!layout) { if (!layout) {
try { const repaired = await repairStrategyTimeframes(strategyId);
await repairStrategyTimeframes(strategyId); if (!repaired) return false;
const repaired = await loadStrategyTimeframes(strategyId); const tfs = await loadStrategyTimeframes(strategyId);
return evaluationTimeframesMatch(uiTfs, repaired); return evaluationTimeframesMatch(uiTfs, tfs);
} catch {
return false;
}
} }
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy'); const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
+24 -7
View File
@@ -15,11 +15,11 @@ interface Listener {
handler: RawHandler; handler: RawHandler;
} }
const INITIAL_DELAY = 80; const INITIAL_DELAY = 350;
const RECONNECT_BASE = 2000; const RECONNECT_BASE = 3000;
const RECONNECT_MAX = 30_000; const RECONNECT_MAX = 45_000;
const PING_INTERVAL = 20_000; const PING_INTERVAL = 20_000;
const DISCONNECT_IDLE_MS = 5_000; const DISCONNECT_IDLE_MS = 200;
const refCounts = new Map<UpbitWsChannel, Map<string, number>>(); const refCounts = new Map<UpbitWsChannel, Map<string, number>>();
const listeners = new Set<Listener>(); const listeners = new Set<Listener>();
@@ -33,6 +33,7 @@ let pingTimer: ReturnType<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let retryCount = 0; let retryCount = 0;
let closingIntentionally = false; let closingIntentionally = false;
let wsFailureLogged = false;
function getWsUrl(): string { function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
@@ -175,6 +176,8 @@ function openSocket(): void {
if (listenerCount() === 0) return; if (listenerCount() === 0) return;
teardownSocket(); teardownSocket();
if (!mayOpenSocket()) return;
try { try {
const socket = new WebSocket(getWsUrl()); const socket = new WebSocket(getWsUrl());
ws = socket; ws = socket;
@@ -182,6 +185,7 @@ function openSocket(): void {
socket.onopen = () => { socket.onopen = () => {
retryCount = 0; retryCount = 0;
wsFailureLogged = false;
notifyConnection(true); notifyConnection(true);
sendSubscribe(); sendSubscribe();
clearPing(); clearPing();
@@ -197,7 +201,13 @@ function openSocket(): void {
}; };
socket.onerror = () => { socket.onerror = () => {
console.warn('[upbitWsBroker] socket error'); if (!wsFailureLogged) {
wsFailureLogged = true;
console.warn(
'[upbitWsBroker] WebSocket 연결 실패 — 시세는 REST 폴링으로 대체됩니다. '
+ '서버 nginx에 /upbit-ws 프록시가 있는지 확인하세요.',
);
}
}; };
socket.onclose = () => { socket.onclose = () => {
@@ -207,7 +217,10 @@ function openSocket(): void {
if (!closingIntentionally && listenerCount() > 0) scheduleReconnect(); if (!closingIntentionally && listenerCount() > 0) scheduleReconnect();
}; };
} catch (err) { } catch (err) {
if (!wsFailureLogged) {
wsFailureLogged = true;
console.warn('[upbitWsBroker] connect failed', err); console.warn('[upbitWsBroker] connect failed', err);
}
scheduleReconnect(); scheduleReconnect();
} }
} }
@@ -274,8 +287,12 @@ export function subscribeUpbitWs(
return () => { return () => {
listeners.delete(entry); listeners.delete(entry);
normalized.forEach(code => bumpRef(channel, code, -1)); normalized.forEach(code => bumpRef(channel, code, -1));
if (listenerCount() === 0) scheduleDisconnect(); if (listenerCount() === 0) {
else if (ws?.readyState === WebSocket.OPEN) sendSubscribe(); cancelPendingConnect();
scheduleDisconnect();
} else if (ws?.readyState === WebSocket.OPEN) {
sendSubscribe();
}
}; };
} }
@@ -95,7 +95,6 @@ export async function removeVirtualTarget(market: string): Promise<VirtualTarget
const targets = loadVirtualTargets(); const targets = loadVirtualTargets();
const item = targets.find(t => t.market === market); const item = targets.find(t => t.market === market);
if (!item) return targets; if (!item) return targets;
if (item.pinned) return targets;
const session = loadVirtualSession(); const session = loadVirtualSession();
const next = targets.filter(t => t.market !== market); const next = targets.filter(t => t.market !== market);
+1 -1
View File
@@ -13,7 +13,7 @@ export interface VirtualTargetItem {
strategyId: number | null; strategyId: number | null;
koreanName?: string; koreanName?: string;
englishName?: string; englishName?: string;
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */ /** 투자대상 고정 — 추세검색 자동삭제 등에서 보호 (수동 삭제는 가능, gc_live_strategy_settings.is_pinned) */
pinned?: boolean; pinned?: boolean;
} }