차트 설정 수정
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
+31
-8
@@ -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;
|
||||||
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
const apply = () => {
|
||||||
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(chartPaneSeparator));
|
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
||||||
}, [appSettingsLoaded, chartPaneSeparator]);
|
managerRef.current?.refreshPaneSeparatorOverlay();
|
||||||
|
slotRefs.current.forEach(slot => slot?.setPaneSeparatorOptions(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)} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,18 +101,25 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ism-color-popover-native">
|
{showCompactNative && (
|
||||||
<label className="ism-color-native-label">
|
<div className="ism-color-popover-native">
|
||||||
<span className="ism-style-label">직접 선택</span>
|
<label className="ism-color-native-label">
|
||||||
<input
|
<span className="ism-style-label">직접 선택</span>
|
||||||
type="color"
|
<input
|
||||||
className="ism-color-picker"
|
type="color"
|
||||||
value={hex6}
|
className="ism-color-picker"
|
||||||
onChange={e => patchHex(e.target.value)}
|
value={hex6}
|
||||||
/>
|
onChange={e => patchHex(e.target.value)}
|
||||||
</label>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
</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,14 +139,16 @@ 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>
|
||||||
<input
|
{showCompactNative && (
|
||||||
type="color"
|
<input
|
||||||
className="ism-color-picker ism-color-picker--compact"
|
type="color"
|
||||||
value={hex6}
|
className="ism-color-picker ism-color-picker--compact"
|
||||||
title="브라우저 색상 선택"
|
value={hex6}
|
||||||
disabled={disabled}
|
title="브라우저 색상 선택"
|
||||||
onChange={e => patchHex(e.target.value)}
|
disabled={disabled}
|
||||||
/>
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
...chartPaneSeparator,
|
cloneChartPaneSeparatorOptions({
|
||||||
mainToIndicator,
|
...chartPaneSeparator,
|
||||||
})}
|
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(
|
||||||
...chartPaneSeparator,
|
cloneChartPaneSeparatorOptions({
|
||||||
indicatorToIndicator,
|
...chartPaneSeparator,
|
||||||
})}
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
<select
|
||||||
<span className="vtd-session-label">투자전략</span>
|
className="vtd-header-select vtd-header-select--strategy"
|
||||||
<select
|
aria-label="투자전략"
|
||||||
className="vtd-session-select"
|
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>
|
||||||
>
|
<option value="REALTIME_TICK">실시간 틱</option>
|
||||||
봉 마감 직후
|
</select>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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>
|
||||||
>
|
<option value="SIGNAL_ONLY">순수 지표 기준</option>
|
||||||
보유 자산 기준
|
</select>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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
|
aria-label="자동매매"
|
||||||
className="vtd-view-toggle"
|
title={
|
||||||
role="group"
|
!paperTradingEnabled
|
||||||
aria-label="자동매매"
|
? '설정 › 가상투자에서 가상매매를 활성화하세요'
|
||||||
title={
|
: undefined
|
||||||
!paperTradingEnabled
|
}
|
||||||
? '설정 › 가상투자에서 가상매매를 활성화하세요'
|
value={paperAutoTradeEnabled ? 'on' : 'off'}
|
||||||
: paperAutoTradeEnabled
|
disabled={!paperTradingEnabled || !onPaperAutoTradeEnabled}
|
||||||
? 'Match 시 서버 자동 체결(모의·자동매매 ON, 화면 불필요)'
|
onChange={e => onPaperAutoTradeEnabled?.(e.target.value === 'on')}
|
||||||
: '수동 주문만 가능'
|
>
|
||||||
}
|
<option value="off">자동매매 OFF</option>
|
||||||
>
|
<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?.(true)}
|
|
||||||
>
|
|
||||||
ON
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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>
|
||||||
>
|
<option value="chart">차트</option>
|
||||||
신호
|
</select>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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>
|
||||||
요약표시
|
<option value="detail">상세표시</option>
|
||||||
</button>
|
</select>
|
||||||
<button
|
|
||||||
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">
|
<select
|
||||||
<span className="vtd-session-label">투자전략</span>
|
className="vtd-header-select vtd-header-select--strategy"
|
||||||
<select
|
aria-label="투자전략"
|
||||||
className="vtd-session-select"
|
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>
|
||||||
>
|
<option value="REALTIME_TICK">실시간 틱</option>
|
||||||
봉 마감 직후
|
</select>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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>
|
||||||
>
|
<option value="SIGNAL_ONLY">순수 지표 기준</option>
|
||||||
보유 자산 기준
|
</select>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
console.warn('[upbitWsBroker] connect failed', err);
|
if (!wsFailureLogged) {
|
||||||
|
wsFailureLogged = true;
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user