일목균형표 수정
This commit is contained in:
@@ -42,6 +42,7 @@ import type {
|
||||
IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle,
|
||||
} from '../types';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
|
||||
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
||||
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
@@ -112,6 +113,7 @@ export interface ChartSlotHandle {
|
||||
setSeriesPriceLabelsEnabled: (enabled: boolean) => void;
|
||||
/** 거래량 pane on/off */
|
||||
setVolumeVisible: (visible: boolean) => void;
|
||||
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => void;
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
@@ -172,6 +174,8 @@ export interface ChartSlotProps {
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
/** 실시간 차트 화면 표시 여부 (설정 등 다른 메뉴에서 숨김 시 false) */
|
||||
chartVisible?: boolean;
|
||||
/** pane 구분선 */
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||
@@ -195,6 +199,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
compactMode = false,
|
||||
chartLiveReceiveHighlight = true,
|
||||
chartVisible = true,
|
||||
chartPaneSeparator,
|
||||
}, ref) {
|
||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||
@@ -309,6 +314,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
setVolumeVisible: (visible: boolean) => {
|
||||
managerRef.current?.setVolumeVisible(visible);
|
||||
},
|
||||
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => {
|
||||
managerRef.current?.setPaneSeparatorOptions(opts);
|
||||
},
|
||||
}), []);
|
||||
|
||||
// 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영
|
||||
@@ -324,6 +332,11 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
managerRef.current?.setVolumeVisible(chartVolumeVisible);
|
||||
}, [chartVolumeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartPaneSeparator) return;
|
||||
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
}, [chartPaneSeparator]);
|
||||
|
||||
// 외부 심볼 동기화
|
||||
useEffect(() => {
|
||||
if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol);
|
||||
@@ -806,6 +819,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
drawingsVisible={!compactMode}
|
||||
showHoverToolbar={!compactMode}
|
||||
volumeVisible={chartVolumeVisible}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
onCrosshair={data => {
|
||||
setLegend(data);
|
||||
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
|
||||
@@ -824,6 +838,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||
mgr.setVolumeVisible(chartVolumeVisible);
|
||||
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
// Time sync: 가시 시간 범위 변화 구독
|
||||
mgr.subscribeVisibleTimeRange(r => {
|
||||
if (!r || isSyncingTimeRef.current) return;
|
||||
|
||||
@@ -11,9 +11,10 @@ import ColorPickerPanel from './ColorPickerPanel';
|
||||
export interface ColorInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
|
||||
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = false }) => {
|
||||
const { hex6, alpha } = parsePlotColor(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -88,13 +89,15 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="ism-color-wrap" ref={anchorRef}>
|
||||
<div className={`ism-color-wrap${disabled ? ' ism-color-wrap--disabled' : ''}`} ref={anchorRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="ism-color-swatch-btn"
|
||||
title="색상 선택"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setOpen(v => !v);
|
||||
if (!open) requestAnimationFrame(updatePosition);
|
||||
}}
|
||||
@@ -106,12 +109,14 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
|
||||
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}
|
||||
spec={getOpacityPercentSpec(alpha)}
|
||||
disabled={disabled}
|
||||
onChange={patchAlpha}
|
||||
/>
|
||||
<span className="ism-alpha-label">%</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ import NumericParamInput from './NumericParamInput';
|
||||
import PlotLineStylePicker from './PlotLineStylePicker';
|
||||
import ColorInput from './ColorInput';
|
||||
import { colorToRgbaString, cloudColorOpacityPercent } from '../utils/ichimokuConfig';
|
||||
import { resolveHistogramUpColor, resolveHistogramDownColor } from '../utils/plotColorUtils';
|
||||
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
|
||||
const SRC_OPTIONS = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4'];
|
||||
@@ -187,9 +188,29 @@ export const PlotSettingsRow: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
{!inputsOnly && (
|
||||
isHistogram ? (
|
||||
isHistogram && indicatorType === 'MACD' ? (
|
||||
<div className="ism-hist-colors">
|
||||
<div className="ism-hist-color-field">
|
||||
<span className="ism-style-label">상승</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramUpColor(plot)}
|
||||
disabled={!enabled}
|
||||
onChange={v => onPlotStyle(plotIndex, { histogramUpColor: v, color: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-hist-color-field">
|
||||
<span className="ism-style-label">하락</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramDownColor(plot)}
|
||||
disabled={!enabled}
|
||||
onChange={v => onPlotStyle(plotIndex, { histogramDownColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : isHistogram ? (
|
||||
<ColorInput
|
||||
value={plot.color}
|
||||
disabled={!enabled}
|
||||
onChange={v => onPlotStyle(plotIndex, { color: v })}
|
||||
/>
|
||||
) : (
|
||||
@@ -339,45 +360,72 @@ export const SettingsOutputFooter: React.FC<{
|
||||
export const IchimokuCloudSection: React.FC<{
|
||||
cloudColors: IchimokuCloudColors;
|
||||
onChange: (patch: Partial<IchimokuCloudColors>) => void;
|
||||
}> = ({ cloudColors, onChange }) => (
|
||||
<>
|
||||
<div className="ism-ichimoku-cloud-divider" />
|
||||
<div className="ism-ichimoku-cloud-section">
|
||||
<h4 className="ism-ichimoku-cloud-title">구름 영역 색상</h4>
|
||||
<p className="ism-ichimoku-cloud-hint">
|
||||
선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름이 표시됩니다.
|
||||
</p>
|
||||
<div className="ism-ichimoku-cloud-grid">
|
||||
<div className="ism-ichimoku-cloud-card">
|
||||
<span className="ism-ichimoku-cloud-card-label">상승 구름 (선행1 > 선행2)</span>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bullishColor}
|
||||
onChange={v => onChange({ bullishColor: v })}
|
||||
/>
|
||||
}> = ({ cloudColors, onChange }) => {
|
||||
const bullishOn = cloudColors.bullishVisible !== false;
|
||||
const bearishOn = cloudColors.bearishVisible !== false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ism-ichimoku-cloud-divider" />
|
||||
<div className="ism-ichimoku-cloud-section">
|
||||
<h4 className="ism-ichimoku-cloud-title">구름 영역</h4>
|
||||
<p className="ism-ichimoku-cloud-hint">
|
||||
선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름을 그릴 수 있습니다. 상승·하락 구름은 각각 on/off 할 수 있습니다.
|
||||
</p>
|
||||
<div className="ism-ichimoku-cloud-grid">
|
||||
<div className={`ism-ichimoku-cloud-card${bullishOn ? '' : ' ism-ichimoku-cloud-card--off'}`}>
|
||||
<div className="ism-ichimoku-cloud-card-head">
|
||||
<label className="ism-toggle ism-ichimoku-cloud-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bullishOn}
|
||||
onChange={e => onChange({ bullishVisible: e.target.checked })}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<span className="ism-ichimoku-cloud-card-label">상승 구름 (선행1 > 선행2)</span>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bullishColor}
|
||||
disabled={!bullishOn}
|
||||
onChange={v => onChange({ bullishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bullishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bullishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-card">
|
||||
<span className="ism-ichimoku-cloud-card-label">하락 구름 (선행1 < 선행2)</span>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bearishColor}
|
||||
onChange={v => onChange({ bearishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bearishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}%
|
||||
<div className={`ism-ichimoku-cloud-card${bearishOn ? '' : ' ism-ichimoku-cloud-card--off'}`}>
|
||||
<div className="ism-ichimoku-cloud-card-head">
|
||||
<label className="ism-toggle ism-ichimoku-cloud-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bearishOn}
|
||||
onChange={e => onChange({ bearishVisible: e.target.checked })}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<span className="ism-ichimoku-cloud-card-label">하락 구름 (선행1 < 선행2)</span>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bearishColor}
|
||||
disabled={!bearishOn}
|
||||
onChange={v => onChange({ bearishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bearishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 볼린저밴드 백그라운드 (업비트 모습 탭) ───────────────────── */
|
||||
export const BollingerBackgroundRow: React.FC<{
|
||||
|
||||
@@ -9,7 +9,12 @@ import { getPlotLabel } from '../utils/indicatorLabels';
|
||||
import { getHlinePriceSpec } from '../utils/indicatorParamSpec';
|
||||
import NumericParamInput from './NumericParamInput';
|
||||
import { ColorInput } from './IndicatorSettingsSections';
|
||||
import { LINE_WIDTH_OPTIONS, LINE_STYLE_OPTIONS } from '../utils/plotColorUtils';
|
||||
import {
|
||||
LINE_WIDTH_OPTIONS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
resolveHistogramUpColor,
|
||||
resolveHistogramDownColor,
|
||||
} from '../utils/plotColorUtils';
|
||||
import { IchimokuCloudSection } from './IndicatorSettingsSections';
|
||||
import { getIchimokuPlotTitle, type IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
|
||||
@@ -199,12 +204,46 @@ const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps
|
||||
|
||||
<div ref={sectionRef} className="ism-style-section">
|
||||
{plots.map((plot, idx) => (
|
||||
<PlotStyleRow
|
||||
key={plot.id}
|
||||
label={plotLabel(plot)}
|
||||
plot={plot}
|
||||
onStyle={patch => onPlotStyle(idx, patch)}
|
||||
/>
|
||||
indicatorType === 'MACD' && plot.type === 'histogram' ? (
|
||||
<div key={plot.id} className="ism-macd-hist-style-block">
|
||||
<div className="ism-style-row ism-macd-hist-style-title">
|
||||
<div className="ism-plot-title-cell">
|
||||
<span className="ism-plot-title">{plotLabel(plot)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-style-row">
|
||||
<div className="ism-plot-title-cell">
|
||||
<span className="ism-plot-title ism-plot-title--sub">상승 막대</span>
|
||||
</div>
|
||||
<div className="ism-style-field ism-style-field--wide">
|
||||
<span className="ism-style-label">색상</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramUpColor(plot)}
|
||||
onChange={v => onPlotStyle(idx, { histogramUpColor: v, color: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-style-row">
|
||||
<div className="ism-plot-title-cell">
|
||||
<span className="ism-plot-title ism-plot-title--sub">하락 막대</span>
|
||||
</div>
|
||||
<div className="ism-style-field ism-style-field--wide">
|
||||
<span className="ism-style-label">색상</span>
|
||||
<ColorInput
|
||||
value={resolveHistogramDownColor(plot)}
|
||||
onChange={v => onPlotStyle(idx, { histogramDownColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PlotStyleRow
|
||||
key={plot.id}
|
||||
label={plotLabel(plot)}
|
||||
plot={plot}
|
||||
onStyle={patch => onPlotStyle(idx, patch)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
|
||||
{hlines.length > 0 && (
|
||||
|
||||
@@ -252,12 +252,51 @@ function assignLayoutsByPaneIndex(
|
||||
const lay = byPane.get(host.paneIndex);
|
||||
if (!lay || lay.height < MIN_SUB_PANE_HEIGHT) continue;
|
||||
for (const item of slot.items) {
|
||||
if (item.layoutTopY != null) continue;
|
||||
item.layoutTopY = lay.topY;
|
||||
item.layoutHeight = lay.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 화면 순서(위→아래)의 활성 sub-pane ↔ 슬롯 순서 — orphan pane index 와 분리 */
|
||||
function assignLayoutsBySubPaneOrder(
|
||||
slots: VisualSlot[],
|
||||
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
|
||||
): void {
|
||||
for (let i = 0; i < slots.length && i < subLayouts.length; i++) {
|
||||
const lay = subLayouts[i];
|
||||
for (const item of slots[i].items) {
|
||||
if (item.layoutTopY != null) continue;
|
||||
item.layoutTopY = lay.topY;
|
||||
item.layoutHeight = lay.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** paneIndex·orphan pane 으로 어긋난 좌표를 슬롯 순서 기준 sub-pane 위치로 보정 */
|
||||
function reconcileLayoutsWithSubPanes(
|
||||
slots: VisualSlot[],
|
||||
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
|
||||
): void {
|
||||
const TOL = 24;
|
||||
for (let i = 0; i < slots.length && i < subLayouts.length; i++) {
|
||||
const expected = subLayouts[i];
|
||||
for (const item of slots[i].items) {
|
||||
const cur = item.layoutTopY;
|
||||
const curH = item.layoutHeight;
|
||||
if (
|
||||
cur == null
|
||||
|| Math.abs(cur - expected.topY) > TOL
|
||||
|| (curH != null && Math.abs(curH - expected.height) > TOL)
|
||||
) {
|
||||
item.layoutTopY = expected.topY;
|
||||
item.layoutHeight = expected.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPaneItems(
|
||||
manager: ChartManager,
|
||||
containerEl: HTMLElement | null,
|
||||
@@ -301,7 +340,12 @@ function buildPaneItems(
|
||||
}
|
||||
|
||||
const slots = buildVisualSlots(items, indicators);
|
||||
const subLayouts = manager.getIndicatorSubPaneLayouts();
|
||||
assignLayoutsBySubPaneOrder(slots, subLayouts);
|
||||
assignLayoutsByPaneIndex(slots, manager.getPaneLayouts());
|
||||
if (subLayouts.length > 0) {
|
||||
reconcileLayoutsWithSubPanes(slots, subLayouts);
|
||||
}
|
||||
|
||||
if (containerEl?.isConnected) {
|
||||
const missing = items.filter(i => i.layoutTopY == null);
|
||||
@@ -319,7 +363,7 @@ function buildPaneItems(
|
||||
}
|
||||
}
|
||||
|
||||
return items.filter(i => i.layoutTopY != null && i.layoutHeight != null);
|
||||
return items;
|
||||
}
|
||||
|
||||
function paneItemLayout(
|
||||
@@ -457,10 +501,18 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
setTimeout(syncPaneItems, 900),
|
||||
setTimeout(syncPaneItems, 1200),
|
||||
setTimeout(syncPaneItems, 1800),
|
||||
setTimeout(syncPaneItems, 2500),
|
||||
];
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(syncPaneItems);
|
||||
});
|
||||
}, [syncPaneItems]);
|
||||
|
||||
useEffect(() => { scheduleRetry(); return () => retryRef.current.forEach(clearTimeout); }, [scheduleRetry]);
|
||||
useEffect(() => {
|
||||
setPaneItems([]);
|
||||
scheduleRetry();
|
||||
return () => retryRef.current.forEach(clearTimeout);
|
||||
}, [scheduleRetry]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = manager.subscribePaneLayout(() => scheduleRetry());
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import { plotColorCss } from '../utils/plotColorUtils';
|
||||
|
||||
interface Props {
|
||||
manager: ChartManager;
|
||||
options: ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
function applyLineDash(ctx: CanvasRenderingContext2D, style: ChartPaneSeparatorOptions['lineStyle']) {
|
||||
if (style === 'dashed') ctx.setLineDash([6, 4]);
|
||||
else if (style === 'dotted') ctx.setLineDash([2, 3]);
|
||||
else ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = manager.getContainer();
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const cssW = container.clientWidth;
|
||||
const cssH = container.clientHeight;
|
||||
if (cssW <= 0 || cssH <= 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(cssW * dpr);
|
||||
canvas.height = Math.round(cssH * dpr);
|
||||
canvas.style.width = `${cssW}px`;
|
||||
canvas.style.height = `${cssH}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
if (!options.visible) return;
|
||||
|
||||
const layouts = manager.getPaneLayouts()
|
||||
.filter(l => l.height > 8)
|
||||
.sort((a, b) => a.topY - b.topY);
|
||||
if (layouts.length < 2) return;
|
||||
|
||||
ctx.strokeStyle = plotColorCss(options.color);
|
||||
ctx.lineWidth = options.width;
|
||||
applyLineDash(ctx, options.lineStyle);
|
||||
|
||||
for (let i = 0; i < layouts.length - 1; i++) {
|
||||
const y = layouts[i].topY + layouts[i].height;
|
||||
const lineY = y + options.width / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, lineY);
|
||||
ctx.lineTo(cssW, lineY);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [manager, options]);
|
||||
|
||||
useEffect(() => {
|
||||
redraw();
|
||||
const unsub = manager.subscribePaneLayout(redraw);
|
||||
const container = manager.getContainer();
|
||||
const ro = new ResizeObserver(() => redraw());
|
||||
if (container) ro.observe(container);
|
||||
return () => {
|
||||
unsub();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [manager, redraw]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pane-separator-overlay"
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaneSeparatorOverlay;
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
CHART_LEGEND_SETTING_ITEMS,
|
||||
type ChartLegendVisibility,
|
||||
} from '../types/chartLegend';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import {
|
||||
LINE_STYLE_LABELS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
LINE_WIDTH_OPTIONS,
|
||||
} from '../utils/plotColorUtils';
|
||||
import TimezonePicker from './TimezonePicker';
|
||||
import AdminPasswordGate from './AdminPasswordGate';
|
||||
import AdminSettingsPanel from './AdminSettingsPanel';
|
||||
@@ -108,6 +114,8 @@ interface SettingsPageProps {
|
||||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||
chartLegendOptions?: ChartLegendVisibility;
|
||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
paperTradingEnabled?: boolean;
|
||||
onPaperTradingEnabled?: (v: boolean) => void;
|
||||
paperInitialCapital?: number;
|
||||
@@ -638,6 +646,8 @@ interface ChartPanelProps {
|
||||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||
chartLegendOptions?: ChartLegendVisibility;
|
||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
}
|
||||
|
||||
const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
@@ -652,6 +662,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
onChartLiveReceiveHighlight,
|
||||
chartLegendOptions,
|
||||
onChartLegendOptionsChange,
|
||||
chartPaneSeparator,
|
||||
onChartPaneSeparatorChange,
|
||||
}) => {
|
||||
const [upColor, setUp] = useState('#26a69a');
|
||||
const [downColor, setDown] = useState('#ef5350');
|
||||
@@ -758,6 +770,75 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{chartPaneSeparator && onChartPaneSeparatorChange && (
|
||||
<SettingSection title="차트 영역 구분선">
|
||||
<SettingRow
|
||||
label="구분선 표시"
|
||||
desc="캔들·거래량·보조지표 pane 사이에 구분선을 표시합니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={chartPaneSeparator.visible}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
visible: e.target.checked,
|
||||
})}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
{chartPaneSeparator.visible && (
|
||||
<>
|
||||
<SettingRow label="선 색상" desc="pane 사이 구분선 색상입니다.">
|
||||
<div className="stg-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="stg-color"
|
||||
value={chartPaneSeparator.color.startsWith('#')
|
||||
? chartPaneSeparator.color.slice(0, 7)
|
||||
: '#2a2e3a'}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
color: e.target.value,
|
||||
})}
|
||||
/>
|
||||
<span className="stg-color-label">{chartPaneSeparator.color}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 굵기" desc="구분선 두께(px)입니다.">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={chartPaneSeparator.width}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
width: Number(e.target.value) as ChartPaneSeparatorOptions['width'],
|
||||
})}
|
||||
>
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<option key={w} value={w}>{w}px</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 스타일" desc="실선·점선·도트 중 선택합니다.">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={chartPaneSeparator.lineStyle}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
lineStyle: e.target.value as ChartPaneSeparatorOptions['lineStyle'],
|
||||
})}
|
||||
>
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
{chartLegendOptions && onChartLegendOptionsChange && (
|
||||
<SettingSection title="상단 정보 표시">
|
||||
{CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => (
|
||||
@@ -1443,6 +1524,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartLiveReceiveHighlight,
|
||||
chartLegendOptions,
|
||||
onChartLegendOptionsChange,
|
||||
chartPaneSeparator,
|
||||
onChartPaneSeparatorChange,
|
||||
tradeAlertSoundEnabled = true,
|
||||
onTradeAlertSoundEnabled,
|
||||
tradeAlertSound = 'bell',
|
||||
@@ -1533,6 +1616,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
|
||||
chartLegendOptions={chartLegendOptions}
|
||||
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
||||
/>
|
||||
);
|
||||
case 'indicators':
|
||||
|
||||
@@ -363,7 +363,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const handleStartMetaChange = useCallback((meta: Record<string, { candleType: string }>) => {
|
||||
const handleStartMetaChange = useCallback((meta: Record<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
@@ -51,6 +51,8 @@ import CandlePaneControls from './CandlePaneControls';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
|
||||
import { classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
|
||||
|
||||
interface TradingChartProps {
|
||||
bars: OHLCVBar[];
|
||||
@@ -126,6 +128,8 @@ interface TradingChartProps {
|
||||
seriesPriceLabelsEnabled?: boolean;
|
||||
/** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */
|
||||
chartVisible?: boolean;
|
||||
/** pane(캔들·거래량·보조지표) 구분선 */
|
||||
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -156,6 +160,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
showHoverToolbar = true,
|
||||
seriesPriceLabelsEnabled = true,
|
||||
chartVisible = true,
|
||||
paneSeparatorOptions,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -207,6 +212,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||
const indicatorSyncInFlightRef = useRef(false);
|
||||
const indicatorReloadGenRef = useRef(0);
|
||||
const prevMarket = useRef<string>(market);
|
||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||
const lastAppliedMarketRef = useRef<string>('');
|
||||
@@ -281,6 +287,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
managerRef.current?.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
|
||||
}, [seriesPriceLabelsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paneSeparatorOptions) return;
|
||||
managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
|
||||
}, [paneSeparatorOptions]);
|
||||
|
||||
/**
|
||||
* pane 높이 재배분 + 스크롤 컨테이너 확장
|
||||
*
|
||||
@@ -409,18 +420,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
) => {
|
||||
const cover = createIndicatorReloadCover(containerRef.current);
|
||||
const savedLR = mgr.getVisibleLogicalRange();
|
||||
const reloadGen = ++indicatorReloadGenRef.current;
|
||||
|
||||
indicatorSyncInFlightRef.current = true;
|
||||
void mgr.reloadIndicatorsOnly(inds).then(() => {
|
||||
if (reloadGen !== indicatorReloadGenRef.current) {
|
||||
fadeOutIndicatorReloadCover(cover);
|
||||
return;
|
||||
}
|
||||
afterIndicatorPaneMutation(mgr);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (reloadGen !== indicatorReloadGenRef.current) {
|
||||
fadeOutIndicatorReloadCover(cover);
|
||||
return;
|
||||
}
|
||||
restoreLogicalRange(mgr, savedLR);
|
||||
fadeOutIndicatorReloadCover(cover);
|
||||
indicatorSyncInFlightRef.current = false;
|
||||
onComplete?.();
|
||||
}));
|
||||
});
|
||||
}, [applyPaneLayout, afterIndicatorPaneMutation, restoreLogicalRange]);
|
||||
}, [afterIndicatorPaneMutation, restoreLogicalRange]);
|
||||
|
||||
const queueFullReload = useCallback(() => {
|
||||
if (!chartVisibleRef.current) return;
|
||||
@@ -635,6 +655,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined);
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(seriesPriceLabelsEnabled);
|
||||
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
|
||||
onManagerReady(mgr);
|
||||
|
||||
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
||||
@@ -1251,6 +1272,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && paneSeparatorOptions && (
|
||||
<PaneSeparatorOverlay
|
||||
manager={chartMgr}
|
||||
options={paneSeparatorOptions}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && (
|
||||
<DrawingCanvas
|
||||
manager={chartMgr}
|
||||
@@ -1403,7 +1430,7 @@ function sortedParamKey(inds: IndicatorConfig[]): string {
|
||||
*/
|
||||
function styleKey(inds: IndicatorConfig[]): string {
|
||||
return inds.map(i =>
|
||||
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}`
|
||||
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}`
|
||||
).join(';');
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,7 @@ const TrendSearchPage: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={defaults.chartPaneSeparator}
|
||||
tickers={tickers}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
isInTargets={isInTargets}
|
||||
|
||||
@@ -55,6 +55,10 @@ import {
|
||||
} from '../utils/virtualLiveStrategySync';
|
||||
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
||||
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
||||
import {
|
||||
coalesceTargetsToDefaultStrategy,
|
||||
resolveVirtualTargetStrategyId,
|
||||
} from '../utils/virtualTargetStrategy';
|
||||
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
@@ -100,6 +104,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
|
||||
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
||||
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
||||
const chartPaneSeparator = appChartDefaults.chartPaneSeparator;
|
||||
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
|
||||
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
|
||||
|
||||
@@ -136,6 +141,13 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
|
||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||
|
||||
/** 예전 데이터: 종목 strategyId에 전역 ID가 복사된 경우 → 기본 전략 따름(null) */
|
||||
useEffect(() => {
|
||||
if (session.globalStrategyId == null) return;
|
||||
setTargets(prev => coalesceTargetsToDefaultStrategy(prev, session.globalStrategyId));
|
||||
}, [session.globalStrategyId]);
|
||||
|
||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||
|
||||
const strategyNames = useMemo(
|
||||
@@ -160,7 +172,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
const targetRefs = useMemo(() =>
|
||||
targets.map(t => ({
|
||||
market: t.market,
|
||||
strategyId: t.strategyId ?? session.globalStrategyId,
|
||||
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||
})),
|
||||
[targets, session.globalStrategyId]);
|
||||
|
||||
@@ -302,22 +314,24 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
setTargets(prev => prev.map(t =>
|
||||
t.market === market ? { ...t, strategyId } : t,
|
||||
));
|
||||
if (!session.running || !strategyId) return;
|
||||
if (!session.running) return;
|
||||
const effectiveId = resolveVirtualTargetStrategyId({ strategyId }, session.globalStrategyId);
|
||||
if (effectiveId == null) return;
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
market,
|
||||
strategyId,
|
||||
strategyId: effectiveId,
|
||||
isLiveCheck: true,
|
||||
executionType: session.executionType,
|
||||
positionMode: session.positionMode,
|
||||
skipWatchlistSync: true,
|
||||
skipGlobalTemplate: true,
|
||||
});
|
||||
await pinStrategyEvaluationTimeframes(market, strategyId);
|
||||
await pinStrategyEvaluationTimeframes(market, effectiveId);
|
||||
} catch {
|
||||
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
||||
}
|
||||
}, [session, targets]);
|
||||
}, [session]);
|
||||
|
||||
const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => {
|
||||
const normalized = normalizeStartCandleType(candleType);
|
||||
@@ -326,7 +340,9 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
));
|
||||
if (!session.running) return;
|
||||
const target = targets.find(t => t.market === market);
|
||||
const strategyId = target?.strategyId ?? session.globalStrategyId;
|
||||
const strategyId = target
|
||||
? resolveVirtualTargetStrategyId(target, session.globalStrategyId)
|
||||
: session.globalStrategyId;
|
||||
if (!strategyId) return;
|
||||
try {
|
||||
await saveLiveStrategySettings({
|
||||
@@ -469,6 +485,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
tickers={tickers}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
}) => {
|
||||
const timeframe = normalizeChartTimeframe(timeframeRaw);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const { defaults: appDefaults } = useAppSettings();
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -198,6 +200,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
onCandlesReady={onCandlesReady}
|
||||
onAddDrawing={noop}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
paneSeparatorOptions={appDefaults.chartPaneSeparator}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && bars.length === 0 && (
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { memo, useCallback, useState } from 'react';
|
||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||
import {
|
||||
isStartNodeId,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
type HandleSide,
|
||||
type StrategyFlowNodeData,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { getStartCandleTypes } from '../../utils/strategyStartNodes';
|
||||
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
@@ -132,7 +134,9 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
const candleType = d.candleType ?? '1m';
|
||||
const candleTypes = d.candleTypes?.length
|
||||
? d.candleTypes
|
||||
: getStartCandleTypes({ candleType: d.candleType ?? '1m' });
|
||||
const canDelete = id !== '__strategy_start__' && !!d.onDeleteStart;
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
@@ -150,35 +154,32 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-start-head">
|
||||
<div className="se-flow-start-dot" />
|
||||
<span className="se-flow-start-label">START</span>
|
||||
<select
|
||||
className="se-flow-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
onChange={e => d.onStartCandleTypeChange?.(id, e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-del se-flow-del--start"
|
||||
title="START 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
d.onDeleteStart?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div className="se-flow-start-layout">
|
||||
<div className="se-flow-start-title-row">
|
||||
<div className="se-flow-start-title-left">
|
||||
<div className="se-flow-start-dot" />
|
||||
<span className="se-flow-start-label">START</span>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-del se-flow-del--start"
|
||||
title="START 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
d.onDeleteStart?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<StartTimeframeCheckboxes
|
||||
layout="graph"
|
||||
className="se-flow-start-tf-checks-wrap"
|
||||
selected={candleTypes}
|
||||
onChange={types => d.onStartCandleTypesChange?.(id, types)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -206,7 +207,15 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-gate-head">
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
{node.type === 'AND' || node.type === 'OR' ? (
|
||||
<LogicGateOpToggle
|
||||
value={node.type}
|
||||
onChange={op => d.onChangeLogicGateType?.(id, op)}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
)}
|
||||
</div>
|
||||
{(node.children ?? []).length === 0 && (
|
||||
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
||||
|
||||
@@ -94,7 +94,12 @@ function renderNode(node: LogicNode, def: DefType): React.ReactNode {
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const tf = formatStartCandleLabel(node.candleType ?? '1m');
|
||||
const types = node.candleTypes?.length
|
||||
? node.candleTypes
|
||||
: [node.candleType ?? '1m'];
|
||||
const tf = types.length > 1
|
||||
? types.map(formatStartCandleLabel).join(' · ')
|
||||
: formatStartCandleLabel(types[0]);
|
||||
return inner ? (
|
||||
<>
|
||||
<span className="se-formula-tf">[{tf}]</span>
|
||||
@@ -125,11 +130,14 @@ function renderSignalBranches(
|
||||
}
|
||||
|
||||
if (branches.length === 1) {
|
||||
const { candleType, root } = branches[0];
|
||||
const { candleType, candleTypes, root } = branches[0];
|
||||
if (!root) return <span className="se-formula-empty">(연결된 조건 없음)</span>;
|
||||
const tfLabel = candleTypes && candleTypes.length > 1
|
||||
? candleTypes.map(formatStartCandleLabel).join(' · ')
|
||||
: formatStartCandleLabel(candleType);
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-tf">[{formatStartCandleLabel(candleType)}]</span>
|
||||
<span className="se-formula-tf">[{tfLabel}]</span>
|
||||
{' '}
|
||||
{renderNode(root, def)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AND / OR 논리 게이트 — 서로 전환
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { LogicNodeType } from '../../utils/strategyTypes';
|
||||
|
||||
export type LogicGateOp = Extract<LogicNodeType, 'AND' | 'OR'>;
|
||||
|
||||
interface Props {
|
||||
value: LogicGateOp;
|
||||
onChange: (op: LogicGateOp) => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const LogicGateOpToggle: React.FC<Props> = ({ value, onChange, className = '', compact = false }) => {
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-logic-gate-toggle${compact ? ' se-logic-gate-toggle--compact' : ''}${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="논리 연산"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
<div className="se-start-combine-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--and${value === 'AND' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'AND'}
|
||||
onClick={() => onChange('AND')}
|
||||
>
|
||||
AND
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--or${value === 'OR' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'OR'}
|
||||
onClick={() => onChange('OR')}
|
||||
>
|
||||
OR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogicGateOpToggle;
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* START 노드 — 평가 시간봉 다중 선택 (마감봉마다 독립 체크)
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
START_TIMEFRAME_GRAPH_ROWS,
|
||||
STRATEGY_CANDLE_TYPE_OPTIONS,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
formatStrategyCandleLabel,
|
||||
type StrategyCandleType,
|
||||
} from '../../utils/strategyStartNodes';
|
||||
|
||||
interface Props {
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
className?: string;
|
||||
/** 그래프 START: 2행 고정 배치 + 값 라벨(1m…) */
|
||||
layout?: 'default' | 'graph';
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OPTIONS_BY_VALUE = Object.fromEntries(
|
||||
STRATEGY_CANDLE_TYPE_OPTIONS.map(o => [o.value, o]),
|
||||
) as Record<StrategyCandleType, { value: string; label: string }>;
|
||||
|
||||
function CheckItem({
|
||||
value,
|
||||
checked,
|
||||
showShortLabel,
|
||||
onToggle,
|
||||
}: {
|
||||
value: StrategyCandleType;
|
||||
checked: boolean;
|
||||
showShortLabel: boolean;
|
||||
onToggle: (value: string, checked: boolean) => void;
|
||||
}) {
|
||||
const opt = OPTIONS_BY_VALUE[value];
|
||||
return (
|
||||
<label className="se-start-tf-check" title={formatStrategyCandleLabel(value)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onToggle(value, e.target.checked)}
|
||||
/>
|
||||
<span>{showShortLabel ? value : opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const StartTimeframeCheckboxes: React.FC<Props> = ({
|
||||
selected,
|
||||
onChange,
|
||||
className = '',
|
||||
layout = 'default',
|
||||
compact = false,
|
||||
}) => {
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
const toggle = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!selectedSet.has(value)) onChange([...selected, value]);
|
||||
return;
|
||||
}
|
||||
const next = selected.filter(t => t !== value);
|
||||
onChange(next.length > 0 ? next : [value]);
|
||||
};
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
if (layout === 'graph') {
|
||||
return (
|
||||
<div
|
||||
className={`se-start-tf-checks se-start-tf-checks--graph${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
{START_TIMEFRAME_GRAPH_ROWS.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="se-start-tf-checks-row">
|
||||
{row.map(ct => (
|
||||
<CheckItem
|
||||
key={ct}
|
||||
value={ct}
|
||||
checked={selectedSet.has(ct)}
|
||||
showShortLabel
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-start-tf-checks${compact ? ' se-start-tf-checks--compact' : ''}${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<CheckItem
|
||||
key={ct}
|
||||
value={ct}
|
||||
checked={selectedSet.has(ct)}
|
||||
showShortLabel={compact}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartTimeframeCheckboxes;
|
||||
@@ -371,10 +371,12 @@ function StrategyEditorCanvasInner({
|
||||
};
|
||||
}, [emitLayoutChange]);
|
||||
|
||||
const handleStartCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE];
|
||||
const normalized = types.map(normalizeStartCandleType);
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[startId]: { candleType: normalizeStartCandleType(candleType) },
|
||||
[startId]: { candleTypes: normalized, candleType: normalized[0] },
|
||||
});
|
||||
}, [startMeta, onStartMetaChange]);
|
||||
|
||||
@@ -403,7 +405,7 @@ function StrategyEditorCanvasInner({
|
||||
onExtraStartIdsChange?.([...extraStartIds, newId]);
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[newId]: { candleType: DEFAULT_START_CANDLE },
|
||||
[newId]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE },
|
||||
});
|
||||
onExtraRootsChange?.({ ...extraRoots, [newId]: null });
|
||||
positionsRef.current.set(newId, {
|
||||
@@ -675,18 +677,47 @@ function StrategyEditorCanvasInner({
|
||||
}
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.map(o => (
|
||||
o.id === id && (o.type === 'AND' || o.type === 'OR')
|
||||
? { ...o, type: gateType }
|
||||
: o
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (root && findNodeInTree(root, id)) {
|
||||
onChange(updateNode(root, id, n => (
|
||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
||||
)));
|
||||
return;
|
||||
}
|
||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||
if (branch && findNodeInTree(branch, id)) {
|
||||
onExtraRootsChange?.({
|
||||
...extraRoots,
|
||||
[sid]: updateNode(branch, id, n => (
|
||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
||||
)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
onUpdateCondition: handleUpdateCondition,
|
||||
onChangeLogicGateType: handleChangeLogicGateType,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
onStartCandleTypeChange: handleStartCandleTypeChange,
|
||||
onStartCandleTypesChange: handleStartCandleTypesChange,
|
||||
onDeleteStart: handleDeleteStart,
|
||||
}), [
|
||||
handleDelete, handleUpdateCondition, handleDropTarget,
|
||||
handleDragOverTarget, handleDragLeaveTarget,
|
||||
handleStartCandleTypeChange, handleDeleteStart,
|
||||
handleDelete, handleUpdateCondition, handleChangeLogicGateType,
|
||||
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
||||
handleStartCandleTypesChange, handleDeleteStart,
|
||||
]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
type EditorConditionState,
|
||||
addExtraStartSection,
|
||||
updateBranchRoot,
|
||||
updateStartCandleType,
|
||||
updateStartCandleTypes,
|
||||
removeStartSection,
|
||||
collectStartSections,
|
||||
} from '../../utils/strategyConditionSerde';
|
||||
import { STRATEGY_CANDLE_TYPES } from '../../utils/strategyStartNodes';
|
||||
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
AND: '#4caf50',
|
||||
@@ -116,6 +118,13 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
</span>
|
||||
<span className="sp-node-label">{label}</span>
|
||||
<div className="sp-node-actions">
|
||||
{(node.type === 'AND' || node.type === 'OR') && (
|
||||
<LogicGateOpToggle
|
||||
value={node.type}
|
||||
onChange={op => onUpdate({ ...node, type: op })}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{showSettings && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,7 +189,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
|
||||
interface StartSectionBlockProps {
|
||||
startId: string;
|
||||
candleType: string;
|
||||
candleTypes: string[];
|
||||
root: LogicNode | null;
|
||||
canDelete: boolean;
|
||||
signalTab: 'buy' | 'sell';
|
||||
@@ -188,14 +197,14 @@ interface StartSectionBlockProps {
|
||||
dragOverKey: string | null;
|
||||
setDragOverKey: (key: string | null) => void;
|
||||
onRootChange: (root: LogicNode | null) => void;
|
||||
onCandleTypeChange: (candleType: string) => void;
|
||||
onCandleTypesChange: (candleTypes: string[]) => void;
|
||||
onDeleteStart?: () => void;
|
||||
onAddStart?: () => void;
|
||||
}
|
||||
|
||||
function StartSectionBlock({
|
||||
startId,
|
||||
candleType,
|
||||
candleTypes,
|
||||
root,
|
||||
canDelete,
|
||||
signalTab,
|
||||
@@ -203,7 +212,7 @@ function StartSectionBlock({
|
||||
dragOverKey,
|
||||
setDragOverKey,
|
||||
onRootChange,
|
||||
onCandleTypeChange,
|
||||
onCandleTypesChange,
|
||||
onDeleteStart,
|
||||
onAddStart,
|
||||
}: StartSectionBlockProps) {
|
||||
@@ -250,16 +259,11 @@ function StartSectionBlock({
|
||||
<header className="sp-start-head">
|
||||
<span className="sp-start-dot" aria-hidden />
|
||||
<span className="sp-start-label">START</span>
|
||||
<select
|
||||
className="sp-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onChange={e => onCandleTypeChange(e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
<StartTimeframeCheckboxes
|
||||
className="sp-start-tf-checks"
|
||||
selected={candleTypes}
|
||||
onChange={onCandleTypesChange}
|
||||
/>
|
||||
{canDelete && (
|
||||
<button type="button" className="sp-start-del" title="START 삭제" onClick={onDeleteStart}>×</button>
|
||||
)}
|
||||
@@ -277,7 +281,7 @@ function StartSectionBlock({
|
||||
>
|
||||
{!root ? (
|
||||
<div className="sp-drop-empty sp-drop-empty--section">
|
||||
논리 연산자·지표를 드래그하여 [{candleType}] 조건을 구성하세요.
|
||||
논리 연산자·지표를 드래그하여 [{formatStartCandleTypesLabel(candleTypes)}] 조건을 구성하세요.
|
||||
</div>
|
||||
) : (
|
||||
<TreeNodeComp
|
||||
@@ -332,8 +336,8 @@ export default function StrategyListEditor({
|
||||
onEditorStateChange(updateBranchRoot(editorState, startId, root));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
onEditorStateChange(updateStartCandleType(editorState, startId, candleType));
|
||||
const handleCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
onEditorStateChange(updateStartCandleTypes(editorState, startId, candleTypes));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleDeleteStart = useCallback((startId: string) => {
|
||||
@@ -375,7 +379,7 @@ export default function StrategyListEditor({
|
||||
<StartSectionBlock
|
||||
key={section.startId}
|
||||
startId={section.startId}
|
||||
candleType={section.candleType}
|
||||
candleTypes={section.candleTypes}
|
||||
root={section.root}
|
||||
canDelete={section.canDelete}
|
||||
signalTab={signalTab}
|
||||
@@ -383,7 +387,7 @@ export default function StrategyListEditor({
|
||||
dragOverKey={dragOverKey}
|
||||
setDragOverKey={setDragOverKey}
|
||||
onRootChange={root => handleRootChange(section.startId, root)}
|
||||
onCandleTypeChange={ct => handleCandleTypeChange(section.startId, ct)}
|
||||
onCandleTypesChange={types => handleCandleTypesChange(section.startId, types)}
|
||||
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
|
||||
onAddStart={handleAddStart}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -42,6 +43,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
}) => {
|
||||
const chartTf = toChartTimeframe(timeframe);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
@@ -196,6 +198,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
volumeVisible
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -39,6 +40,7 @@ const TrendSearchChartPanel: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
}) => {
|
||||
const chartTf = toChartTimeframe(timeframe);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
@@ -183,6 +185,7 @@ const TrendSearchChartPanel: React.FC<Props> = ({
|
||||
volumeVisible
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
ticker?: TickerData;
|
||||
updatedAt?: number;
|
||||
flash?: boolean;
|
||||
@@ -58,6 +59,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
ticker,
|
||||
updatedAt,
|
||||
flash = false,
|
||||
@@ -139,6 +141,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
/>
|
||||
) : (
|
||||
<TrendSearchCardSignalPanel
|
||||
|
||||
@@ -17,6 +17,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
tickers?: Map<string, TickerData>;
|
||||
lastUpdatedAt?: number;
|
||||
isInTargets?: (market: string) => boolean;
|
||||
@@ -37,6 +38,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
tickers,
|
||||
lastUpdatedAt,
|
||||
isInTargets,
|
||||
@@ -84,6 +86,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
ticker={tickers?.get(row.market)}
|
||||
updatedAt={lastUpdatedAt}
|
||||
flash={flashMarkets?.has(row.market)}
|
||||
|
||||
@@ -43,7 +43,7 @@ const VerificationIssueToastStack: React.FC<Props> = ({ onGoToIssue }) => {
|
||||
<VerificationStageIcon stage={item.stage} size={18} />
|
||||
<div className="vbd-toast-head-text">
|
||||
<p className="vbd-toast-kind">{headline(item.eventType, item.stage, item.previousStage)}</p>
|
||||
<h4 className="vbd-toast-title">{item.title}</h4>
|
||||
<p className="vbd-toast-title">{item.title}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
isVirtualTargetAddAllowed,
|
||||
virtualTargetLimitMessage,
|
||||
} from '../../utils/virtualTargetLimits';
|
||||
import {
|
||||
defaultStrategyOptionLabel,
|
||||
parseTargetStrategySelectValue,
|
||||
targetStrategySelectValue,
|
||||
} from '../../utils/virtualTargetStrategy';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -72,7 +77,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
...targets,
|
||||
{
|
||||
market,
|
||||
strategyId: globalStrategyId,
|
||||
strategyId: null,
|
||||
koreanName: names.koreanName,
|
||||
englishName: names.englishName,
|
||||
},
|
||||
@@ -192,15 +197,19 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
<span className="vtd-target-strategy-label">투자전략</span>
|
||||
<select
|
||||
className="vtd-target-strategy-select"
|
||||
value={item.strategyId ?? globalStrategyId ?? ''}
|
||||
value={targetStrategySelectValue(item)}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onTargetStrategyChange(item.market, v ? Number(v) : null);
|
||||
onTargetStrategyChange(
|
||||
item.market,
|
||||
parseTargetStrategySelectValue(e.target.value),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
<option value={targetStrategySelectValue({ strategyId: null })}>
|
||||
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
||||
</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
ticker?: TickerData;
|
||||
}
|
||||
@@ -68,6 +69,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
chartLiveReceiveHighlight = true,
|
||||
ticker,
|
||||
}) => {
|
||||
@@ -174,6 +176,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartTimeframe={chartTimeframe}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -31,6 +31,7 @@ interface Props {
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
/** 차트 설정 — 지표 가격축 라벨·설명 */
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
/** 전체보기 분할 pane — 캔버스 높이 100% */
|
||||
fillHeight?: boolean;
|
||||
/** 카드 푸터 평가 분봉과 동기화 */
|
||||
@@ -46,6 +47,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
running = false,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
fillHeight = false,
|
||||
chartTimeframe,
|
||||
}) => {
|
||||
@@ -217,6 +219,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
volumeVisible={false}
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import { formatUpdatedTime } from '../../utils/virtualSignalMetrics';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
|
||||
import {
|
||||
defaultStrategyOptionLabel,
|
||||
parseTargetStrategySelectValue,
|
||||
targetStrategySelectValue,
|
||||
} from '../../utils/virtualTargetStrategy';
|
||||
|
||||
interface Props {
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
@@ -31,15 +36,16 @@ const VirtualTargetCardFoot: React.FC<Props> = ({
|
||||
<select
|
||||
className="vtd-card-foot-select"
|
||||
aria-label="투자전략"
|
||||
value={strategyId ?? globalStrategyId ?? ''}
|
||||
value={targetStrategySelectValue({ strategyId })}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
onStrategyChange?.(v ? Number(v) : null);
|
||||
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
<option value={targetStrategySelectValue({ strategyId: null })}>
|
||||
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
||||
</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
ticker?: TickerData;
|
||||
}
|
||||
@@ -58,6 +59,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
chartLiveReceiveHighlight = true,
|
||||
ticker,
|
||||
}) => {
|
||||
@@ -114,6 +116,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
running={running}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
fillHeight
|
||||
chartTimeframe={chartTimeframe}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -28,6 +29,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
viewMode: VirtualCardViewMode;
|
||||
@@ -44,6 +46,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
chartLiveReceiveHighlight = true,
|
||||
tickers,
|
||||
viewMode,
|
||||
@@ -111,7 +114,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
{focusMarket && focusTarget ? (
|
||||
<VirtualTargetFocusView
|
||||
market={focusTarget.market}
|
||||
strategy={strategies.find(s => s.id === (focusTarget.strategyId ?? session.globalStrategyId))}
|
||||
strategy={strategies.find(s => s.id === resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId))}
|
||||
strategies={strategies}
|
||||
strategyId={focusTarget.strategyId}
|
||||
globalStrategyId={session.globalStrategyId}
|
||||
@@ -127,13 +130,14 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
ticker={tickers?.get(focusTarget.market)}
|
||||
/>
|
||||
) : (
|
||||
<div className={`vtd-grid vtd-grid--${viewMode}`}>
|
||||
{targets.map(t => {
|
||||
const strat = strategies.find(s => s.id === (t.strategyId ?? session.globalStrategyId));
|
||||
const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId));
|
||||
return (
|
||||
<VirtualTargetCard
|
||||
key={t.market}
|
||||
@@ -158,6 +162,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
ticker={tickers?.get(t.market)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user