차트 설정 수정
This commit is contained in:
@@ -96,7 +96,7 @@ export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
|
||||
</BtField>
|
||||
<BtField
|
||||
label="매수/매도 금액 표시"
|
||||
desc="차트 마커에 체결 금액을 함께 표시합니다. ON: '매수 : 1,234,000' / OFF: '매수'만 표시."
|
||||
desc="차트·백테스트 마커에 체결 금액을 함께 표시합니다. 차트 설정과 동일합니다. OFF: '매수'·'매도'만 표시."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<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) => {
|
||||
managerRef.current?.setPaneSeparatorOptions(opts);
|
||||
managerRef.current?.refreshPaneSeparatorOverlay();
|
||||
},
|
||||
setChartTimeFormat: (format: string, tf?: Timeframe) => {
|
||||
managerRef.current?.setChartTimeFormat(format);
|
||||
|
||||
@@ -12,9 +12,19 @@ export interface ColorInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
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 [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,20 +61,19 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
|
||||
}, [open, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || !closeOnClickOutside) return;
|
||||
const onDocPointer = (e: Event) => {
|
||||
const t = e.target as Node;
|
||||
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
// 설정 모달 등이 bubble 단계에서 stopPropagation 하므로 capture 사용
|
||||
document.addEventListener('mousedown', onDocPointer, true);
|
||||
document.addEventListener('pointerdown', onDocPointer, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocPointer, true);
|
||||
document.removeEventListener('pointerdown', onDocPointer, true);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, closeOnClickOutside]);
|
||||
|
||||
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
|
||||
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
|
||||
@@ -92,18 +101,25 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="ism-color-popover-native">
|
||||
<label className="ism-color-native-label">
|
||||
<span className="ism-style-label">직접 선택</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ism-color-picker"
|
||||
value={hex6}
|
||||
onChange={e => patchHex(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{showCompactNative && (
|
||||
<div className="ism-color-popover-native">
|
||||
<label className="ism-color-native-label">
|
||||
<span className="ism-style-label">직접 선택</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ism-color-picker"
|
||||
value={hex6}
|
||||
onChange={e => patchHex(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<ColorPickerPanel hex6={hex6} onHexChange={patchHex} />
|
||||
<div className="ism-color-popover-footer">
|
||||
<button type="button" className="plsp-popup-ok" onClick={closePopover}>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -123,14 +139,16 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
|
||||
>
|
||||
<span className="ism-color-swatch-preview" style={{ background: plotColorCss(value) }} />
|
||||
</button>
|
||||
<input
|
||||
type="color"
|
||||
className="ism-color-picker ism-color-picker--compact"
|
||||
value={hex6}
|
||||
title="브라우저 색상 선택"
|
||||
disabled={disabled}
|
||||
onChange={e => patchHex(e.target.value)}
|
||||
/>
|
||||
{showCompactNative && (
|
||||
<input
|
||||
type="color"
|
||||
className="ism-color-picker ism-color-picker--compact"
|
||||
value={hex6}
|
||||
title="브라우저 색상 선택"
|
||||
disabled={disabled}
|
||||
onChange={e => patchHex(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<NumericParamInput
|
||||
className="ism-input ism-alpha-input"
|
||||
value={alpha}
|
||||
|
||||
@@ -77,14 +77,13 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
const prev = settings;
|
||||
if (next.isLiveCheck && next.strategyId != null) {
|
||||
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
|
||||
if (!synced) return;
|
||||
if (!synced) {
|
||||
window.alert('선택한 전략을 서버에서 찾을 수 없습니다. 전략 목록을 새로고침하거나 다른 전략을 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
const ok = await warnStrategyTimeframeMismatch(next.strategyId);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await repairStrategyTimeframes(next.strategyId);
|
||||
} catch {
|
||||
/* repair optional */
|
||||
}
|
||||
await repairStrategyTimeframes(next.strategyId);
|
||||
}
|
||||
onSettingsChange?.(next);
|
||||
setSaving(true);
|
||||
|
||||
@@ -67,7 +67,9 @@ interface MarketSearchPanelProps {
|
||||
actionMode?: 'favorite' | 'add';
|
||||
/** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */
|
||||
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
|
||||
/** 이미 추가된 종목 (add 모드에서 + 비활성) */
|
||||
/** actionMode=add 일 때 이미 추가된 종목 클릭 → 투자대상에서 제거 */
|
||||
onRemove?: (market: string) => void;
|
||||
/** 이미 추가된 종목 (add 모드에서 체크 표시·클릭 시 제거) */
|
||||
addedMarkets?: Set<string>;
|
||||
/** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */
|
||||
maxTargetsReached?: boolean;
|
||||
@@ -76,7 +78,7 @@ interface MarketSearchPanelProps {
|
||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
|
||||
variant = 'overlay', query: controlledQuery, onQueryChange,
|
||||
actionMode = 'favorite', onAdd, addedMarkets, maxTargetsReached = false,
|
||||
actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false,
|
||||
}) => {
|
||||
const embedded = variant === 'embedded';
|
||||
const isCompact = useIsCompactDevice();
|
||||
@@ -405,10 +407,10 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
type="button"
|
||||
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
|
||||
onClick={e => handleAddClick(m.market, e)}
|
||||
disabled={isAdded || addBlocked}
|
||||
disabled={addBlocked}
|
||||
title={
|
||||
isAdded
|
||||
? '이미 추가됨'
|
||||
? '투자대상에서 제거'
|
||||
: addBlocked
|
||||
? '투자대상 최대 개수에 도달했습니다'
|
||||
: '투자대상에 추가'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
|
||||
import { classifyPaneBoundary } from '../types/chartPaneSeparator';
|
||||
import { classifyPaneBoundary, paneSeparatorOptionsKey } from '../types/chartPaneSeparator';
|
||||
import { plotColorCss } from '../utils/plotColorUtils';
|
||||
|
||||
interface Props {
|
||||
@@ -34,6 +34,7 @@ function strokeBoundary(
|
||||
|
||||
const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const optionsKey = paneSeparatorOptionsKey(options);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -72,7 +73,7 @@ const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||
const y = lower.topY + lower.height;
|
||||
strokeBoundary(ctx, cssW, y, style);
|
||||
}
|
||||
}, [manager, options]);
|
||||
}, [manager, options, optionsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
redraw();
|
||||
@@ -80,11 +81,15 @@ const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||
const container = manager.getContainer();
|
||||
const ro = new ResizeObserver(() => redraw());
|
||||
if (container) ro.observe(container);
|
||||
const raf1 = requestAnimationFrame(redraw);
|
||||
const raf2 = requestAnimationFrame(() => requestAnimationFrame(redraw));
|
||||
return () => {
|
||||
unsub();
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
};
|
||||
}, [manager, redraw]);
|
||||
}, [manager, redraw, optionsKey]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
type ChartLegendVisibility,
|
||||
} from '../types/chartLegend';
|
||||
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
|
||||
import { cloneChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import ColorInput from './ColorInput';
|
||||
import {
|
||||
LINE_STYLE_LABELS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
@@ -724,15 +726,11 @@ function PaneSeparatorStyleFields({
|
||||
{style.visible && (
|
||||
<>
|
||||
<SettingRow label="선 색상">
|
||||
<div className="stg-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="stg-color"
|
||||
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>
|
||||
<ColorInput
|
||||
value={style.color}
|
||||
onChange={color => onChange({ ...style, color })}
|
||||
showCompactNative={false}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 굵기">
|
||||
<select
|
||||
@@ -789,6 +787,8 @@ interface ChartPanelProps {
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
chartTimeFormat?: string;
|
||||
onChartTimeFormatChange?: (format: string) => void;
|
||||
btShowPrice?: boolean;
|
||||
onBtShowPrice?: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
@@ -811,6 +811,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
onChartPaneSeparatorChange,
|
||||
chartTimeFormat = 'MM-dd HH:mm',
|
||||
onChartTimeFormatChange,
|
||||
btShowPrice = true,
|
||||
onBtShowPrice,
|
||||
}) => {
|
||||
const [upColor, setUp] = useState('#26a69a');
|
||||
const [downColor, setDown] = useState('#ef5350');
|
||||
@@ -953,6 +955,22 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
</SettingRow>
|
||||
</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 && (
|
||||
<SettingSection title="차트 영역 구분선">
|
||||
<div className="stg-subsection-label">
|
||||
@@ -961,10 +979,12 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
</div>
|
||||
<PaneSeparatorStyleFields
|
||||
style={chartPaneSeparator.mainToIndicator}
|
||||
onChange={mainToIndicator => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
mainToIndicator,
|
||||
})}
|
||||
onChange={mainToIndicator => onChartPaneSeparatorChange(
|
||||
cloneChartPaneSeparatorOptions({
|
||||
...chartPaneSeparator,
|
||||
mainToIndicator,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
<div className="stg-subsection-label stg-subsection-label--spaced">
|
||||
보조지표 간
|
||||
@@ -972,10 +992,12 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
</div>
|
||||
<PaneSeparatorStyleFields
|
||||
style={chartPaneSeparator.indicatorToIndicator}
|
||||
onChange={indicatorToIndicator => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
indicatorToIndicator,
|
||||
})}
|
||||
onChange={indicatorToIndicator => onChartPaneSeparatorChange(
|
||||
cloneChartPaneSeparatorOptions({
|
||||
...chartPaneSeparator,
|
||||
indicatorToIndicator,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</SettingSection>
|
||||
)}
|
||||
@@ -1810,6 +1832,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
||||
chartTimeFormat={chartTimeFormat}
|
||||
onChartTimeFormatChange={onChartTimeFormatChange}
|
||||
btShowPrice={btShowPrice}
|
||||
onBtShowPrice={onBtShowPrice}
|
||||
/>
|
||||
);
|
||||
case 'indicators':
|
||||
|
||||
@@ -515,7 +515,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
if (!hasRegisteredUser()) return;
|
||||
loadStrategies().then(async list => {
|
||||
if (list?.length) {
|
||||
setStrategies(list.map(s => ({
|
||||
const mapped = list.map(s => ({
|
||||
id: s.id ?? Date.now(),
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
@@ -525,7 +525,11 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
enabled: s.enabled ?? true,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
})));
|
||||
}));
|
||||
setStrategies(mapped);
|
||||
setSelectedId(prev =>
|
||||
prev != null && mapped.some(x => x.id === prev) ? prev : null,
|
||||
);
|
||||
} else {
|
||||
const local = loadStratsLocal();
|
||||
if (!local.length) return;
|
||||
|
||||
@@ -337,7 +337,6 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const el = containerRef.current;
|
||||
if (el && !panStateRef.current.active) {
|
||||
el.style.cursor = canPanRef.current ? 'grab' : '';
|
||||
el.style.touchAction = canPanRef.current ? 'none' : '';
|
||||
}
|
||||
}, [mode, drawingTool]);
|
||||
|
||||
@@ -365,6 +364,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
useEffect(() => {
|
||||
if (!paneSeparatorOptions) return;
|
||||
managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
|
||||
managerRef.current?.refreshPaneSeparatorOverlay();
|
||||
}, [paneSeparatorOptions]);
|
||||
|
||||
/**
|
||||
@@ -913,15 +913,29 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
};
|
||||
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) => {
|
||||
if (!canPanRef.current || e.button !== 0) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const chartX = e.clientX - rect.left;
|
||||
const chartY = e.clientY - rect.top;
|
||||
if (mgr.isOnPriceAxis(chartX, chartY) || mgr.isOnTimeAxis(chartY)) return;
|
||||
const originPaneIndex = mgr.getPaneIndexAtChartY(chartY);
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('.chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
container.setPointerCapture(e.pointerId);
|
||||
captureEl.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -953,8 +967,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onPanPointerUp = () => {
|
||||
const onPanPointerUp = (e: PointerEvent) => {
|
||||
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) {
|
||||
suppressClickRef.current = true;
|
||||
managerRef.current?.notifyViewportChanged();
|
||||
@@ -963,8 +985,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
container.style.cursor = canPanRef.current ? 'grab' : '';
|
||||
};
|
||||
|
||||
container.addEventListener('pointerdown', onPanPointerDown);
|
||||
window.addEventListener('pointermove', onPanPointerMove);
|
||||
const panCapture = true;
|
||||
const panTarget = wrapper ?? container;
|
||||
panTarget.addEventListener('pointerdown', onPanPointerDown, panCapture);
|
||||
window.addEventListener('pointermove', onPanPointerMove, { passive: false });
|
||||
window.addEventListener('pointerup', onPanPointerUp);
|
||||
window.addEventListener('pointercancel', onPanPointerUp);
|
||||
|
||||
@@ -1122,7 +1146,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
container.removeEventListener('click', handleDrawingCapture, { capture: true });
|
||||
container.removeEventListener('contextmenu', onContextMenuCapture, { capture: true });
|
||||
container.removeEventListener('dblclick', onCandlePaneDblClick, { capture: true });
|
||||
container.removeEventListener('pointerdown', onPanPointerDown);
|
||||
panTarget.removeEventListener('pointerdown', onPanPointerDown, panCapture);
|
||||
container.removeEventListener('wheel', onWheelCapture, { capture: true });
|
||||
window.removeEventListener('pointermove', onPanPointerMove);
|
||||
window.removeEventListener('pointerup', onPanPointerUp);
|
||||
|
||||
@@ -114,7 +114,26 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
|
||||
useEffect(() => {
|
||||
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),
|
||||
loadPaperTrades().then(setTrades),
|
||||
]).finally(() => setLoading(false));
|
||||
|
||||
@@ -42,103 +42,65 @@ export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
|
||||
onPaperAutoTradeEnabled,
|
||||
paperTradingEnabled = true,
|
||||
}) => (
|
||||
<div className="vtd-header-session">
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-session-select"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="vtd-header-session vtd-header-session--compact">
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--strategy"
|
||||
aria-label="투자전략"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">전략 선택</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">실행방식</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="실행방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
|
||||
>
|
||||
봉 마감 직후
|
||||
</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>
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--narrow"
|
||||
aria-label="실행방식"
|
||||
value={session.executionType}
|
||||
onChange={e => onExecutionTypeChange(e.target.value as VirtualSessionConfig['executionType'])}
|
||||
>
|
||||
<option value="CANDLE_CLOSE">봉 마감 직후</option>
|
||||
<option value="REALTIME_TICK">실시간 틱</option>
|
||||
</select>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">시그널 모드</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onPositionModeChange('LONG_ONLY')}
|
||||
>
|
||||
보유 자산 기준
|
||||
</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>
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--narrow"
|
||||
aria-label="시그널 모드"
|
||||
value={session.positionMode}
|
||||
onChange={e => onPositionModeChange(e.target.value as VirtualSessionConfig['positionMode'])}
|
||||
>
|
||||
<option value="LONG_ONLY">보유 자산 기준</option>
|
||||
<option value="SIGNAL_ONLY">순수 지표 기준</option>
|
||||
</select>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">자동매매</span>
|
||||
<div
|
||||
className="vtd-view-toggle"
|
||||
role="group"
|
||||
aria-label="자동매매"
|
||||
title={
|
||||
!paperTradingEnabled
|
||||
? '설정 › 가상투자에서 가상매매를 활성화하세요'
|
||||
: paperAutoTradeEnabled
|
||||
? 'Match 시 서버 자동 체결(모의·자동매매 ON, 화면 불필요)'
|
||||
: '수동 주문만 가능'
|
||||
}
|
||||
>
|
||||
<button
|
||||
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>
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--narrow"
|
||||
aria-label="자동매매"
|
||||
title={
|
||||
!paperTradingEnabled
|
||||
? '설정 › 가상투자에서 가상매매를 활성화하세요'
|
||||
: undefined
|
||||
}
|
||||
value={paperAutoTradeEnabled ? 'on' : 'off'}
|
||||
disabled={!paperTradingEnabled || !onPaperAutoTradeEnabled}
|
||||
onChange={e => onPaperAutoTradeEnabled?.(e.target.value === 'on')}
|
||||
>
|
||||
<option value="off">자동매매 OFF</option>
|
||||
<option value="on">자동매매 ON</option>
|
||||
</select>
|
||||
|
||||
<div className="vtd-session-actions">
|
||||
<div className="vtd-session-actions vtd-session-actions--compact">
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn vtd-session-btn vtd-session-btn--start"
|
||||
disabled={session.running}
|
||||
onClick={onStart}
|
||||
title="가상투자 시작"
|
||||
>
|
||||
시작
|
||||
</button>
|
||||
@@ -147,6 +109,7 @@ export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
|
||||
className="bps-btn bps-btn--danger vtd-session-btn"
|
||||
disabled={!session.running}
|
||||
onClick={onStop}
|
||||
title="가상투자 종료"
|
||||
>
|
||||
종료
|
||||
</button>
|
||||
@@ -165,63 +128,36 @@ export const VirtualViewHeaderControls: React.FC<ViewProps> = ({
|
||||
onGridPresetChange,
|
||||
showGridLayoutPicker = true,
|
||||
}) => (
|
||||
<div className="vtd-header-view">
|
||||
<div className="vtd-header-view vtd-header-view--compact">
|
||||
{showGridLayoutPicker && onGridPresetChange && (
|
||||
<>
|
||||
<VirtualGridLayoutPicker
|
||||
value={gridPreset}
|
||||
onChange={onGridPresetChange}
|
||||
disabled={!showSignalChartToggle}
|
||||
/>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
</>
|
||||
<VirtualGridLayoutPicker
|
||||
value={gridPreset}
|
||||
onChange={onGridPresetChange}
|
||||
disabled={!showSignalChartToggle}
|
||||
/>
|
||||
)}
|
||||
{showSignalChartToggle && onGlobalDisplayModeChange && (
|
||||
<>
|
||||
<div className="vtd-grid-head-group" role="group" aria-label="전체 종목 신호·차트">
|
||||
<div className="vtd-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${globalDisplayMode === 'signal' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onGlobalDisplayModeChange('signal')}
|
||||
>
|
||||
신호
|
||||
</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" />
|
||||
</>
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--narrow"
|
||||
aria-label="신호 또는 차트"
|
||||
value={globalDisplayMode}
|
||||
onChange={e => onGlobalDisplayModeChange(e.target.value as VirtualCardDisplayMode)}
|
||||
>
|
||||
<option value="signal">신호</option>
|
||||
<option value="chart">차트</option>
|
||||
</select>
|
||||
)}
|
||||
<div className="vtd-grid-head-group" role="group" aria-label="카드 표시 방식">
|
||||
<div className="vtd-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onViewModeChange('summary')}
|
||||
>
|
||||
요약표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onViewModeChange('detail')}
|
||||
>
|
||||
상세표시
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--narrow"
|
||||
aria-label="카드 표시 방식"
|
||||
value={viewMode}
|
||||
onChange={e => onViewModeChange(e.target.value as VirtualCardViewMode)}
|
||||
>
|
||||
<option value="summary">요약표시</option>
|
||||
<option value="detail">상세표시</option>
|
||||
</select>
|
||||
{session.running && (
|
||||
<>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
<span className="vtd-grid-live">실시간 갱신 3초</span>
|
||||
</>
|
||||
<span className="vtd-grid-live" title="3초마다 갱신">실시간</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -88,8 +88,6 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket, virtualTargetMaxCount]);
|
||||
|
||||
const handleRemove = useCallback((market: string) => {
|
||||
const item = targets.find(t => t.market === market);
|
||||
if (item?.pinned) return;
|
||||
onTargetsChange(targets.filter(t => t.market !== market));
|
||||
}, [targets, onTargetsChange]);
|
||||
|
||||
@@ -119,6 +117,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
addedMarkets={addedSet}
|
||||
maxTargetsReached={atTargetLimit}
|
||||
onAdd={handleAdd}
|
||||
onRemove={handleRemove}
|
||||
onSelect={onSelectMarket}
|
||||
onClose={closeDropdown}
|
||||
/>
|
||||
@@ -174,9 +173,8 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-target-remove"
|
||||
title={item.pinned ? '고정된 종목은 삭제할 수 없습니다' : '목록에서 제거'}
|
||||
title="목록에서 제거"
|
||||
aria-label="목록에서 제거"
|
||||
disabled={!!item.pinned}
|
||||
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Props {
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
/** 레거시 — VirtualSessionHeaderControls 와 동일 드롭다운 레이아웃 */
|
||||
const VirtualSessionToolbar: React.FC<Props> = ({
|
||||
session,
|
||||
strategies,
|
||||
@@ -21,65 +22,43 @@ const VirtualSessionToolbar: React.FC<Props> = ({
|
||||
onStart,
|
||||
onStop,
|
||||
}) => (
|
||||
<div className="vtd-session-toolbar">
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-session-select"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="vtd-session-toolbar vtd-header-session--compact">
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--strategy"
|
||||
aria-label="투자전략"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">전략 선택</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">실행방식</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="실행방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.executionType === 'CANDLE_CLOSE' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onExecutionTypeChange('CANDLE_CLOSE')}
|
||||
>
|
||||
봉 마감 직후
|
||||
</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>
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--narrow"
|
||||
aria-label="실행방식"
|
||||
value={session.executionType}
|
||||
onChange={e => onExecutionTypeChange(e.target.value as VirtualSessionConfig['executionType'])}
|
||||
>
|
||||
<option value="CANDLE_CLOSE">봉 마감 직후</option>
|
||||
<option value="REALTIME_TICK">실시간 틱</option>
|
||||
</select>
|
||||
|
||||
<div className="vtd-session-field">
|
||||
<span className="vtd-session-label">시그널 모드</span>
|
||||
<div className="vtd-view-toggle" role="group" aria-label="시그널 모드">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${session.positionMode === 'LONG_ONLY' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onPositionModeChange('LONG_ONLY')}
|
||||
>
|
||||
보유 자산 기준
|
||||
</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>
|
||||
<select
|
||||
className="vtd-header-select vtd-header-select--narrow"
|
||||
aria-label="시그널 모드"
|
||||
value={session.positionMode}
|
||||
onChange={e => onPositionModeChange(e.target.value as VirtualSessionConfig['positionMode'])}
|
||||
>
|
||||
<option value="LONG_ONLY">보유 자산 기준</option>
|
||||
<option value="SIGNAL_ONLY">순수 지표 기준</option>
|
||||
</select>
|
||||
|
||||
<div className="vtd-session-actions">
|
||||
<div className="vtd-session-actions vtd-session-actions--compact">
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn vtd-session-btn vtd-session-btn--start"
|
||||
|
||||
Reference in New Issue
Block a user