전략편집기 횡보구간 컨트롤 추가

This commit is contained in:
Macbook
2026-06-12 09:08:44 +09:00
parent 74b0ea4ab6
commit d741d3fec6
8 changed files with 1601 additions and 19 deletions
+102 -7
View File
@@ -45,6 +45,8 @@ import {
type StartCombineOp,
} from '../utils/strategyStartNodes';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import { buildSidewaysFilterNode } from '../utils/sidewaysFilterPalette';
import {
loadPaletteItems,
type PaletteItem,
@@ -99,7 +101,16 @@ import {
strategyDtoToListExportItem,
} from '../utils/strategyImportExport';
import PaletteChip from './strategyEditor/PaletteChip';
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import {
clampPanelSize,
readStoredBool,
readStoredSize,
storeBool,
storeSize,
usePanelResize,
useRightPanelResize,
} from './strategyEditor/usePanelResize';
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
import ReactDOM from 'react-dom';
import { MarketSearchPanel } from './MarketSearchPanel';
@@ -120,10 +131,21 @@ const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
const LEFT_PANEL_MIN = 220;
const LEFT_PANEL_MAX = 520;
const LEFT_PANEL_DEFAULT = 280;
const RIGHT_PANEL_MIN = 380;
const RIGHT_PANEL_MAX = 560;
const RIGHT_PANEL_DEFAULT = 380;
const TERMINAL_MIN = 88;
const TERMINAL_MAX = 420;
const TERMINAL_DEFAULT = 140;
function readRightPanelWidth(): number {
return clampPanelSize(
readStoredSize('se-right-width', RIGHT_PANEL_DEFAULT),
RIGHT_PANEL_MIN,
RIGHT_PANEL_MAX,
);
}
interface Props {
theme: Theme;
onNavigateToBacktest?: () => void;
@@ -189,7 +211,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite'>('auxiliary');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [paletteSearch, setPaletteSearch] = useState('');
@@ -224,8 +246,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth());
const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const terminalHeightRef = useRef(terminalHeight);
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
@@ -246,6 +271,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const layoutPersistReadyRef = useRef(false);
const STRATEGY_AUTOSAVE_MS = 350;
leftWidthRef.current = leftWidth;
rightWidthRef.current = rightWidth;
terminalHeightRef.current = terminalHeight;
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
@@ -260,6 +286,22 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
v => storeSize('se-left-width', v),
);
const onRightSplitter = useRightPanelResize(
setRightWidth,
() => rightWidthRef.current,
RIGHT_PANEL_MIN,
RIGHT_PANEL_MAX,
v => storeSize('se-right-width', v),
);
const toggleRightPanel = useCallback(() => {
setRightOpen(prev => {
const next = !prev;
storeBool('se-right-open', next);
return next;
});
}, []);
const onTerminalSplitter = usePanelResize(
'horizontal',
setTerminalHeight,
@@ -271,8 +313,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const bodyStyle = useMemo(() => ({
'--se-left-width': `${leftWidth}px`,
'--se-right-width': `${rightWidth}px`,
'--se-terminal-height': `${terminalHeight}px`,
}) as React.CSSProperties, [leftWidth, terminalHeight]);
}) as React.CSSProperties, [leftWidth, rightWidth, terminalHeight]);
const showSnack = (msg: string, ok = true) => {
setSnack({ msg, ok });
@@ -1021,6 +1064,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const applySidewaysFilter = useCallback((filterId: string) => {
const newNode = buildSidewaysFilterNode(filterId, DEF);
if (!newNode) return;
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
scheduleStrategyPersist();
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
const templateRows = useMemo(() => {
@@ -1645,7 +1697,24 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div>
</main>
<aside className="se-right">
{rightOpen && (
<div
className="se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비 조절"
onPointerDown={onRightSplitter}
/>
)}
<div className={`se-side-wrap se-side-wrap--right${rightOpen ? ' se-side-wrap--open' : ''}`}>
<SePanelCollapseHandle
side="right"
open={rightOpen}
onToggle={toggleRightPanel}
label="지표 패널"
/>
<aside className={`se-right se-right--collapsible${rightOpen ? ' se-right--collapsible--open' : ''}`}>
<div className="se-palette-panel">
<div className="se-right-tabs">
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}></button>
@@ -1702,7 +1771,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
<div className="se-palette-subtabs">
<button
type="button"
className={`se-palette-subtab${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
className={`se-palette-subtab se-palette-subtab--aux${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('auxiliary');
setSelectedPaletteKey(null);
@@ -1712,7 +1781,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</button>
<button
type="button"
className={`se-palette-subtab${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
className={`se-palette-subtab se-palette-subtab--composite${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('composite');
setSelectedPaletteKey(null);
@@ -1720,6 +1789,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
>
</button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--range${indicatorSubTab === 'range' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('range');
setSelectedPaletteKey(null);
}}
>
</button>
</div>
{indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab
@@ -1739,7 +1818,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
applyPaletteItem(item);
}}
/>
) : (
) : indicatorSubTab === 'composite' ? (
<IndicatorPaletteTab
kind="composite"
items={compositePalette}
@@ -1757,6 +1836,21 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
applyPaletteItem(item);
}}
/>
) : (
<SidewaysFilterPaletteTab
def={DEF}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('range:')
? selectedPaletteKey.slice('range:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('range', id) : null)}
onAddToCanvas={item => {
selectPalette('range', item.id);
applySidewaysFilter(item.id);
}}
/>
)}
</>
)}
@@ -1821,6 +1915,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div>
</div>
</aside>
</div>
</div>
</div>
</div>
@@ -0,0 +1,43 @@
import React from 'react';
interface Props {
side: 'left' | 'right';
open: boolean;
onToggle: () => void;
label: string;
}
/** 전략편집기 좌·우 패널 접기 핸들 */
export default function SePanelCollapseHandle({ side, open, onToggle, label }: Props) {
return (
<button
type="button"
className={`se-panel-handle se-panel-handle--${side}${open ? ' se-panel-handle--open' : ''}`}
onClick={e => {
e.stopPropagation();
onToggle();
}}
title={open ? `${label} 닫기` : `${label} 열기`}
aria-expanded={open}
aria-label={open ? `${label} 닫기` : `${label} 열기`}
>
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{side === 'left' ? (
open ? <polyline points="6,1 2,7 6,13" /> : <polyline points="2,1 6,7 2,13" />
) : (
open ? <polyline points="2,1 6,7 2,13" /> : <polyline points="6,1 2,7 6,13" />
)}
</svg>
</button>
);
}
@@ -0,0 +1,209 @@
import React, { useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { DefType } from '../../utils/strategyEditorShared';
import {
DEFAULT_SIDEWAYS_FILTER_ITEMS,
filterSidewaysItems,
type SidewaysFilterItem,
type SidewaysFilterMode,
} from '../../utils/sidewaysFilterPalette';
export interface SidewaysFilterDragPayload {
type: 'sidewaysFilter';
value: string;
label: string;
}
interface Props {
def: DefType;
searchQuery: string;
selectedItemId: string | null;
onSelectItem: (id: string | null) => void;
onAddToCanvas: (item: SidewaysFilterItem) => void;
}
function modeIcon(mode: SidewaysFilterMode): { glyph: string; cls: string; aria: string } {
return mode === 'include'
? { glyph: '▣', cls: 'se-range-chip-icon--include', aria: '횡보' }
: { glyph: '⊘', cls: 'se-range-chip-icon--exclude', aria: '제외' };
}
interface HoverHintState {
label: string;
desc: string;
x: number;
y: number;
}
function SidewaysFilterChip({
item,
selected,
onSelect,
onAdd,
onHintChange,
}: {
item: SidewaysFilterItem;
selected: boolean;
onSelect: () => void;
onAdd: () => void;
onHintChange: (hint: HoverHintState | null) => void;
}) {
const rightIcon = modeIcon(item.mode);
const showHint = useCallback((el: HTMLElement) => {
const r = el.getBoundingClientRect();
onHintChange({
label: item.label,
desc: item.desc,
x: r.left + r.width / 2,
y: r.top,
});
}, [item.label, item.desc, onHintChange]);
const hideHint = useCallback(() => onHintChange(null), [onHintChange]);
const onDragStart = (e: React.DragEvent) => {
hideHint();
const payload: SidewaysFilterDragPayload = {
type: 'sidewaysFilter',
value: item.id,
label: item.label,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.effectAllowed = 'copy';
};
const handleClick = () => onSelect();
const handleDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
onAdd();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (selected) onAdd();
else onSelect();
};
return (
<div
draggable
className={`se-palette-card se-palette-card--range se-range-chip${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={e => showHint(e.currentTarget)}
onMouseLeave={hideHint}
onFocus={e => showHint(e.currentTarget)}
onBlur={hideHint}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label={item.label}
>
<div className="se-range-chip-icons">
<span className="se-range-chip-icon se-range-chip-icon--left" aria-hidden></span>
<span
className={`se-range-chip-icon se-range-chip-icon--right ${rightIcon.cls}`}
aria-label={rightIcon.aria}
title=""
>
{rightIcon.glyph}
</span>
</div>
<span className="se-range-chip-name">{item.label}</span>
</div>
);
}
function SidewaysFilterHintTooltip({ hint }: { hint: HoverHintState }) {
return createPortal(
<div
className="se-range-hint-tooltip"
role="tooltip"
style={{
left: hint.x,
top: hint.y,
}}
>
<div className="se-range-hint-tooltip-title">{hint.label}</div>
<div className="se-range-hint-tooltip-desc">{hint.desc}</div>
</div>,
document.body,
);
}
export default function SidewaysFilterPaletteTab({
def,
searchQuery,
selectedItemId,
onSelectItem,
onAddToCanvas,
}: Props) {
void def;
const [modeFilter, setModeFilter] = useState<'all' | SidewaysFilterMode>('all');
const [hoverHint, setHoverHint] = useState<HoverHintState | null>(null);
const filtered = useMemo(
() => filterSidewaysItems(DEFAULT_SIDEWAYS_FILTER_ITEMS, searchQuery, modeFilter),
[searchQuery, modeFilter],
);
return (
<div className="se-palette-section se-palette-section--range se-palette-section--scroll">
{hoverHint && <SidewaysFilterHintTooltip hint={hoverHint} />}
<div className="se-palette-toolbar">
<span className="se-palette-toolbar-title"> </span>
<span className="se-range-count">{filtered.length}</span>
</div>
<div className="se-range-mode-tabs" role="tablist" aria-label="횡보 필터 모드">
<button
type="button"
role="tab"
aria-selected={modeFilter === 'all'}
className={`se-range-mode-tab${modeFilter === 'all' ? ' se-range-mode-tab--on' : ''}`}
onClick={() => setModeFilter('all')}
>
</button>
<button
type="button"
role="tab"
aria-selected={modeFilter === 'include'}
className={`se-range-mode-tab se-range-mode-tab--include${modeFilter === 'include' ? ' se-range-mode-tab--on' : ''}`}
onClick={() => setModeFilter('include')}
>
</button>
<button
type="button"
role="tab"
aria-selected={modeFilter === 'exclude'}
className={`se-range-mode-tab se-range-mode-tab--exclude${modeFilter === 'exclude' ? ' se-range-mode-tab--on' : ''}`}
onClick={() => setModeFilter('exclude')}
>
</button>
</div>
<div className="se-palette-grid se-palette-grid--3 se-range-grid">
{filtered.map(item => (
<SidewaysFilterChip
key={item.id}
item={item}
selected={selectedItemId === item.id}
onSelect={() => onSelectItem(item.id)}
onAdd={() => onAddToCanvas(item)}
onHintChange={setHoverHint}
/>
))}
</div>
{filtered.length === 0 && (
<p className="se-palette-empty"> .</p>
)}
</div>
);
}
@@ -28,6 +28,7 @@ import {
updateNode,
type DefType,
} from '../../utils/strategyEditorShared';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPalette';
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
import {
START_NODE_ID,
@@ -563,18 +564,28 @@ function StrategyEditorCanvasInner({
})));
}, [setNodes]);
const resolvePaletteDropNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
): LogicNode | null => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
const addOrphanAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
const newNode = resolvePaletteDropNode(data);
if (!newNode) return;
positionsRef.current.set(newNode.id, {
x: flowPos.x - FLOW_NODE_W / 2,
y: flowPos.y - FLOW_NODE_H / 2,
});
onOrphansChange([...orphans, newNode]);
scheduleLayoutEmit();
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
const applyDropWithAttach = useCallback((
anchorId: string,
@@ -586,7 +597,8 @@ function StrategyEditorCanvasInner({
return;
}
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
const newNode = resolvePaletteDropNode(data);
if (!newNode) return;
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
@@ -636,7 +648,7 @@ function StrategyEditorCanvasInner({
}
saveAttachHandles(anchorId);
scheduleLayoutEmit();
}, [root, extraRoots, signalTab, def, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit]);
}, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
updateDropPreview(anchorId, flowPos);
@@ -48,6 +48,49 @@ export function usePanelResize(
}, [axis, onResize, getCurrent, min, max, onCommit]);
}
/** 우측 패널 — 스플리터를 왼쪽으로 드래그하면 넓어짐 */
export function useRightPanelResize(
onResize: (next: number) => void,
getCurrent: () => number,
min: number,
max: number,
onCommit?: (value: number) => void,
) {
return useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const startX = e.clientX;
const start = getCurrent();
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('se-splitter--active');
const splitter = e.currentTarget;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
onResize(clamp(start - delta, min, max));
};
const onUp = (ev: PointerEvent) => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
splitter.releasePointerCapture(ev.pointerId);
splitter.classList.remove('se-splitter--active');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
onCommit?.(getCurrent());
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}, [onResize, getCurrent, min, max, onCommit]);
}
export function clampPanelSize(value: number, min: number, max: number): number {
return clamp(value, min, max);
}
export function readStoredSize(key: string, fallback: number): number {
try {
const raw = getUiPreferences().panels?.[key];
+314 -8
View File
@@ -1564,9 +1564,61 @@
.se-formula-ind--ind { color: var(--se-palette-ind-accent); }
/* ── Right palette ── */
.se-side-wrap {
display: flex;
flex-direction: row;
align-items: stretch;
align-self: stretch;
flex-shrink: 0;
min-height: 0;
height: 100%;
position: relative;
z-index: 8;
}
.se-side-wrap--right {
flex-direction: row;
min-width: 16px;
}
.se-panel-handle {
align-self: center;
flex-shrink: 0;
position: relative;
z-index: 20;
width: 16px;
height: 56px;
border: 1px solid var(--se-border);
color: var(--se-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
padding: 0;
background: transparent;
}
.se-panel-handle--right {
margin-right: -1px;
background: var(--se-palette-bg);
border-right: none;
border-radius: 8px 0 0 8px;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
}
.se-panel-handle--right:hover {
color: var(--se-text);
background: color-mix(in srgb, var(--se-bg-muted) 70%, var(--se-palette-bg));
box-shadow: -3px 0 14px rgba(0, 0, 0, 0.45);
}
.se-side-wrap--right.se-side-wrap--open .se-panel-handle--right,
.se-panel-handle--right.se-panel-handle--open {
border-color: color-mix(in srgb, var(--se-accent) 35%, var(--se-border));
}
.se-right {
flex: 0 0 380px;
width: 380px;
display: flex;
flex-direction: column;
min-height: 0;
@@ -1575,6 +1627,39 @@
overflow: hidden;
}
.se-right--collapsible {
width: 0;
flex: 0 0 0;
min-width: 0;
max-width: 0;
transition:
width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
flex-basis 0.22s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.se-right--collapsible.se-right--collapsible--open,
.se-side-wrap--right.se-side-wrap--open .se-right--collapsible {
width: var(--se-right-width, 380px);
flex: 0 0 var(--se-right-width, 380px);
max-width: var(--se-right-width, 380px);
min-width: var(--se-right-width, 380px);
}
.se-side-wrap--right .se-right--collapsible .se-palette-panel {
flex: 1;
min-height: 0;
width: 100%;
min-width: 0;
}
.se-side-wrap--right.se-side-wrap--open .se-right-tabs,
.se-side-wrap--right.se-side-wrap--open .se-right-body,
.se-right--collapsible--open .se-right-tabs,
.se-right--collapsible--open .se-right-body {
min-width: var(--se-right-width, 380px);
}
.se-palette-panel {
flex: 1;
min-height: 0;
@@ -1649,14 +1734,234 @@
color: var(--se-tab-active);
box-shadow: inset 0 -2px 0 var(--se-tab-active);
}
.se-palette-subtab:first-child.se-palette-subtab--on {
.se-palette-subtab--aux.se-palette-subtab--on {
color: var(--se-palette-section-ind, #2dd4bf);
box-shadow: inset 0 -2px 0 var(--se-palette-section-ind, #2dd4bf);
}
.se-palette-subtab:last-child.se-palette-subtab--on {
.se-palette-subtab--composite.se-palette-subtab--on {
color: var(--se-palette-section-composite, #c084fc);
box-shadow: inset 0 -2px 0 var(--se-palette-section-composite, #c084fc);
}
.se-palette-subtab--range.se-palette-subtab--on {
color: var(--se-palette-section-range, #f59e0b);
box-shadow: inset 0 -2px 0 var(--se-palette-section-range, #f59e0b);
}
.se-palette-section--range .se-palette-toolbar-title {
color: var(--se-palette-section-range, #f59e0b);
}
.se-palette-card--range {
border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 35%, var(--se-border));
}
.se-palette-card--range:hover {
border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 55%, var(--se-border));
}
.se-palette-card-icon--range {
background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 18%, transparent);
color: var(--se-palette-section-range, #f59e0b);
}
.se-range-count {
font-size: 0.65rem;
color: var(--se-text-dim);
font-weight: 600;
}
.se-range-mode-tabs {
display: flex;
gap: 4px;
margin: 4px 10px 8px;
flex-shrink: 0;
}
.se-range-mode-tab {
flex: 1;
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--se-border);
background: var(--se-btn-bg);
color: var(--se-text-muted);
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
}
.se-range-mode-tab--on {
border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 50%, transparent);
color: var(--se-palette-section-range, #f59e0b);
background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 10%, var(--se-btn-bg));
}
.se-range-mode-tab--include.se-range-mode-tab--on {
border-color: color-mix(in srgb, #38bdf8 50%, transparent);
color: #38bdf8;
background: color-mix(in srgb, #38bdf8 10%, var(--se-btn-bg));
}
.se-range-mode-tab--exclude.se-range-mode-tab--on {
border-color: color-mix(in srgb, #f87171 50%, transparent);
color: #f87171;
background: color-mix(in srgb, #f87171 10%, var(--se-btn-bg));
}
.se-range-hint {
margin: 4px 10px 8px;
font-size: 0.65rem;
line-height: 1.45;
color: var(--se-text-dim);
}
.se-range-selected-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 10px 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 35%, var(--se-border));
background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 6%, var(--se-panel-card-bg));
}
.se-range-selected-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--se-text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.se-range-category {
margin: 0 10px 12px;
}
.se-range-category-title {
margin: 0 0 6px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--se-text-dim);
}
.se-range-badge {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.se-range-badge--include {
background: color-mix(in srgb, #38bdf8 18%, transparent);
color: #38bdf8;
}
.se-range-badge--exclude {
background: color-mix(in srgb, #f87171 18%, transparent);
color: #f87171;
}
.se-range-chip {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 8px 9px;
min-height: 56px;
}
.se-range-chip-icons {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.se-range-chip-icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 5px;
font-size: 0.72rem;
line-height: 1;
flex-shrink: 0;
}
.se-range-chip-icon--left {
background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 18%, transparent);
color: var(--se-palette-section-range, #f59e0b);
}
.se-range-chip-icon--right.se-range-chip-icon--include {
background: color-mix(in srgb, #38bdf8 18%, transparent);
color: #38bdf8;
}
.se-range-chip-icon--right.se-range-chip-icon--exclude {
background: color-mix(in srgb, #f87171 18%, transparent);
color: #f87171;
}
.se-range-chip-name {
font-size: 0.68rem;
font-weight: 700;
line-height: 1.25;
color: var(--se-text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: keep-all;
}
.se-range-hint-tooltip {
position: fixed;
z-index: 10050;
max-width: min(280px, calc(100vw - 24px));
padding: 9px 11px;
border-radius: 8px;
/* body 포털 — :root(--text/--bg2) 사용 (.se-page 토큰 미상속) */
color: var(--text, #c0caf5);
background: color-mix(in srgb, var(--bg2, #24283b) 94%, var(--bg, #1a1b26));
border: 1px solid color-mix(in srgb, #f59e0b 52%, var(--border, rgba(122, 162, 247, 0.22)));
box-shadow:
var(--popup-shadow, 0 8px 32px rgba(0, 0, 0, 0.55)),
0 0 0 1px color-mix(in srgb, #f59e0b 12%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--text, #c0caf5) 8%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
pointer-events: none;
transform: translate(-50%, calc(-100% - 10px));
}
.se-range-hint-tooltip::after {
content: '';
position: absolute;
left: 50%;
bottom: -5px;
width: 10px;
height: 10px;
background: color-mix(in srgb, var(--bg2, #24283b) 94%, var(--bg, #1a1b26));
border-right: 1px solid color-mix(in srgb, #f59e0b 52%, var(--border, rgba(122, 162, 247, 0.22)));
border-bottom: 1px solid color-mix(in srgb, #f59e0b 52%, var(--border, rgba(122, 162, 247, 0.22)));
transform: translateX(-50%) rotate(45deg);
}
.se-range-hint-tooltip-title {
font-size: 0.74rem;
font-weight: 700;
color: color-mix(in srgb, #f59e0b 72%, var(--text, #c0caf5));
margin-bottom: 4px;
line-height: 1.3;
}
.se-range-hint-tooltip-desc {
font-size: 0.68rem;
line-height: 1.5;
color: var(--text2, #9aa5ce);
}
.se-palette-grid--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.se-palette-toolbar {
display: flex;
@@ -1675,6 +1980,11 @@
}
.se-palette-section--aux .se-palette-toolbar-title { color: var(--se-palette-section-ind); }
.se-palette-section--composite .se-palette-toolbar-title { color: var(--se-palette-section-composite, #c084fc); }
.se-palette-section--range .se-palette-toolbar-title { color: var(--se-palette-section-range, #f59e0b); }
.se-range-grid {
margin: 0 10px 10px;
}
.se-palette-toolbar-actions {
display: flex;
@@ -2098,7 +2408,3 @@
color: var(--se-text-muted);
line-height: 1.5;
}
@media (max-width: 1100px) {
.se-right { flex: 0 0 300px; width: 300px; }
}
+334
View File
@@ -0,0 +1,334 @@
/**
* 횡보(레인지) 필터 팔레트 — 전략편집기 우측 「횡보필터」 탭
* 각 항목은 LogicNode(조건 또는 AND/NOT 조합)를 생성합니다.
*/
import type { LogicNode } from './strategyTypes';
import type { DefType } from './strategyEditorShared';
import { genId } from './strategyNodeIds';
import { THRESHOLD_HL_MID, THRESHOLD_HL_UNDER } from './thresholdSymbols';
export type SidewaysFilterMode = 'include' | 'exclude';
export type SidewaysFilterCategory =
| 'adx'
| 'bollinger'
| 'ma'
| 'ichimoku'
| 'donchian'
| 'oscillator'
| 'dmi'
| 'volume'
| 'combo';
export interface SidewaysFilterItem {
id: string;
label: string;
desc: string;
category: SidewaysFilterCategory;
/** include=횡보 구간만, exclude=횡보 제외(추세만) */
mode: SidewaysFilterMode;
build: (def: DefType) => LogicNode;
}
export const SIDEWAYS_FILTER_CATEGORY_LABEL: Record<SidewaysFilterCategory, string> = {
adx: 'ADX · 추세강도',
bollinger: '볼린저 밴드',
ma: 'MA · 이동평균 수렴',
ichimoku: '일목균형표',
donchian: 'Donchian · 채널',
oscillator: '오실레이터 중립',
dmi: 'DMI · 방향성',
volume: '거래량 · VR',
combo: '복합 횡보 필터',
};
function condition(
indicatorType: string,
conditionType: string,
leftField: string,
rightField: string,
extra?: Partial<NonNullable<LogicNode['condition']>>,
): LogicNode {
return {
id: genId(),
type: 'CONDITION',
condition: {
indicatorType,
conditionType,
leftField,
rightField,
candleRange: 1,
valuePeriodOverride: false,
thresholdOverride: false,
rightPeriodOverride: false,
...extra,
},
};
}
function andNode(...children: LogicNode[]): LogicNode {
return { id: genId(), type: 'AND', children };
}
function orNode(...children: LogicNode[]): LogicNode {
return { id: genId(), type: 'OR', children };
}
function notNode(child: LogicNode): LogicNode {
return { id: genId(), type: 'NOT', children: [child] };
}
function maField(def: DefType, index: number): string {
const p = def.maLines[index] ?? def.maLines[def.maLines.length - 1] ?? 20;
return `MA${p}`;
}
function diffCond(
ind: string,
left: string,
right: string,
op: 'DIFF_LT' | 'DIFF_GT',
compareValue: number,
): LogicNode {
return condition(ind, op, left, right, { compareValue });
}
function rsiBand(def: DefType, low: number, high: number): LogicNode {
return andNode(
condition('RSI', 'GTE', 'RSI_VALUE', `K_${low}`, { compareValue: low }),
condition('RSI', 'LTE', 'RSI_VALUE', `K_${high}`, { compareValue: high }),
);
}
function stochBand(low: number, high: number): LogicNode {
return andNode(
condition('STOCHASTIC', 'GTE', 'STOCH_K', `K_${low}`),
condition('STOCHASTIC', 'LTE', 'STOCH_K', `K_${high}`),
);
}
function cciBand(low: number, high: number): LogicNode {
return andNode(
condition('CCI', 'GTE', 'CCI_VALUE', `K_${low}`),
condition('CCI', 'LTE', 'CCI_VALUE', `K_${high}`),
);
}
function adxWeak(): LogicNode {
return condition('ADX', 'LT', 'ADX_VALUE', THRESHOLD_HL_MID);
}
function adxStrong(): LogicNode {
return condition('ADX', 'GTE', 'ADX_VALUE', THRESHOLD_HL_MID);
}
function adxVeryWeak(): LogicNode {
return condition('ADX', 'LT', 'ADX_VALUE', 'ADX_20');
}
function inCloud(): LogicNode {
return condition('ICHIMOKU', 'IN_CLOUD', 'CLOSE_PRICE', 'CLOSE_PRICE');
}
function bbSqueeze(def: DefType): LogicNode {
void def;
return diffCond('BOLLINGER', 'UPPER_BAND', 'LOWER_BAND', 'DIFF_LT', 2);
}
function maConverge(def: DefType, shortIdx: number, longIdx: number, threshold: number): LogicNode {
return diffCond('MA', maField(def, shortIdx), maField(def, longIdx), 'DIFF_LT', threshold);
}
function dcNarrow(period: number, threshold: number): LogicNode {
return diffCond(
'DONCHIAN',
`DC_UPPER_${period}`,
`DC_LOWER_${period}`,
'DIFF_LT',
threshold,
);
}
function buildInclude(inner: LogicNode): LogicNode {
return inner;
}
function buildExclude(inner: LogicNode): LogicNode {
return notNode(inner);
}
function item(
id: string,
label: string,
desc: string,
category: SidewaysFilterCategory,
mode: SidewaysFilterMode,
inner: LogicNode | ((def: DefType) => LogicNode),
): SidewaysFilterItem {
return {
id,
label,
desc,
category,
mode,
build: def => {
const node = typeof inner === 'function' ? inner(def) : inner;
return mode === 'exclude' ? buildExclude(node) : buildInclude(node);
},
};
}
/** 내장 횡보 필터 목록 */
export const DEFAULT_SIDEWAYS_FILTER_ITEMS: SidewaysFilterItem[] = [
// ── ADX ──
item('adx-lt-25', 'ADX < 25', '평균방향지수 25 미만 — 비추세·횡보', 'adx', 'include', adxWeak()),
item('adx-lt-20', 'ADX < 20', 'ADX 20 미만 — 강한 횡보', 'adx', 'include', adxVeryWeak()),
item('adx-slope-down', 'ADX 하락 중', '추세 강도가 줄어드는 구간', 'adx', 'include',
condition('ADX', 'SLOPE_DOWN', 'ADX_VALUE', 'ADX_VALUE', { slopePeriod: 3 })),
item('adx-gte-25', 'ADX ≥ 25', '추세 형성 구간 — 횡보 제외', 'adx', 'exclude', adxWeak()),
item('adx-gte-25-direct', 'ADX ≥ 25 (추세)', '추세 강도 25 이상', 'adx', 'include', adxStrong()),
item('adx-gte-20', 'ADX ≥ 20', '약한 추세 이상 — 횡보 제외', 'adx', 'exclude', adxVeryWeak()),
// ── 볼린저 ──
item('bb-squeeze', '볼린저 수축', '상·하단밴드 간격 좁음 (Squeeze) — compareValue 조절', 'bollinger', 'include', bbSqueeze),
item('bb-wide', '볼린저 확장', '밴드폭 넓음 — 변동성 확대·횡보 제외', 'bollinger', 'exclude', def => bbSqueeze(def)),
item('bb-close-near-mid', '종가 ≈ 중심선', '종가가 볼린저 중심선 근처', 'bollinger', 'include',
diffCond('BOLLINGER', 'CLOSE_PRICE', 'MIDDLE_BAND', 'DIFF_LT', 1)),
item('bb-close-in-band', '종가 밴드 내부', '하단 이상 & 상단 이하 (밴드 안)', 'bollinger', 'include', andNode(
condition('BOLLINGER', 'GTE', 'CLOSE_PRICE', 'LOWER_BAND'),
condition('BOLLINGER', 'LTE', 'CLOSE_PRICE', 'UPPER_BAND'),
)),
// ── MA ──
item('ma-5-20-narrow', 'MA 단·중 수렴', '단기·중기 MA 간격 좁음 — compareValue 조절', 'ma', 'include',
def => maConverge(def, 0, 1, 1)),
item('ma-5-60-narrow', 'MA 단·장 수렴', '단기·장기 MA 수렴', 'ma', 'include',
def => maConverge(def, 0, 3, 2)),
item('ma-10-20-narrow', 'MA2·MA3 수렴', '중기 MA 두 선 근접', 'ma', 'include',
def => maConverge(def, 1, 2, 1)),
item('ma-spread-wide', 'MA 간격 넓음', 'MA 스프레드 큼 — 추세·횡보 제외', 'ma', 'exclude',
def => maConverge(def, 0, 2, 5)),
// ── 일목 ──
item('ich-in-cloud', '구름 안', '종가가 일목 구름대 내부', 'ichimoku', 'include', inCloud()),
item('ich-not-cloud', '구름 밖', '구름 위 또는 아래 — 횡보 제외', 'ichimoku', 'exclude', inCloud()),
item('ich-span-thin', '구름 얇음', '선행1·선행2 간격 좁음', 'ichimoku', 'include',
diffCond('ICHIMOKU', 'LEADING_SPAN1', 'LEADING_SPAN2', 'DIFF_LT', 1)),
item('ich-above-cloud-excl', '구름 위 (추세)', '강세 구름 위 — 횡보 제외', 'ichimoku', 'exclude', inCloud()),
// ── Donchian ──
item('dc-9-narrow', '9일 채널 좁음', 'Donchian 9일 고저 폭 좁음', 'donchian', 'include', dcNarrow(9, 2)),
item('dc-20-narrow', '20일 채널 좁음', '한 달 박스권 — 채널 폭 좁음', 'donchian', 'include', dcNarrow(20, 3)),
item('dc-20-wide', '20일 채널 넓음', '채널 확장 — 횡보 제외', 'donchian', 'exclude', dcNarrow(20, 3)),
// ── 오실레이터 ──
item('rsi-40-60', 'RSI 40~60', 'RSI 중립대', 'oscillator', 'include', def => rsiBand(def, 40, 60)),
item('rsi-45-55', 'RSI 45~55', 'RSI 좁은 중립대', 'oscillator', 'include', def => rsiBand(def, 45, 55)),
item('rsi-not-neutral', 'RSI 중립 아님', 'RSI 40~60 밖 — 횡보 제외', 'oscillator', 'exclude', def => rsiBand(def, 40, 60)),
item('stoch-40-60', 'Stoch 40~60', '%K 중립대', 'oscillator', 'include', stochBand(40, 60)),
item('stoch-30-70', 'Stoch 30~70', '%K 넓은 중립', 'oscillator', 'include', stochBand(30, 70)),
item('cci-neutral', 'CCI -50~+50', 'CCI 중립 박스', 'oscillator', 'include', cciBand(-50, 50)),
item('cci-near-zero', 'CCI ≈ 0', 'CCI 0선 근처', 'oscillator', 'include',
diffCond('CCI', 'CCI_VALUE', THRESHOLD_HL_MID, 'DIFF_LT', 50)),
item('wr-neutral', 'Williams -60~-40', 'Williams %R 중립', 'oscillator', 'include', andNode(
condition('WILLIAMS_R', 'GTE', 'WILLIAMS_R_VALUE', 'K_-60'),
condition('WILLIAMS_R', 'LTE', 'WILLIAMS_R_VALUE', 'K_-40'),
)),
item('macd-hist-flat', 'MACD 히스토그램 ≈0', '모멘텀 약함', 'oscillator', 'include',
diffCond('MACD', 'HISTOGRAM', 'ZERO_LINE', 'DIFF_LT', 0.5)),
item('trix-near-zero', 'TRIX ≈ 0', 'TRIX 0선 근처', 'oscillator', 'include',
diffCond('TRIX', 'TRIX_VALUE', 'ZERO_LINE', 'DIFF_LT', 0.1)),
item('disp-near-100', '이격도 ≈ 100', '20일 이격도 기준선 근처', 'oscillator', 'include',
diffCond('DISPARITY', 'DISPARITY20', THRESHOLD_HL_MID, 'DIFF_LT', 3)),
// ── DMI ──
item('dmi-no-direction', '+DI ≈ -DI', '방향성 우열 없음', 'dmi', 'include',
diffCond('DMI', 'PDI', 'MDI', 'DIFF_LT', 5)),
item('dmi-both-weak', '+DI·-DI 모두 약함', '양 방향 모두 10 미만', 'dmi', 'include', andNode(
condition('DMI', 'LT', 'PDI', THRESHOLD_HL_UNDER),
condition('DMI', 'LT', 'MDI', THRESHOLD_HL_UNDER),
)),
item('dmi-clear-direction', '방향성 뚜렷', '+DI·-DI 차이 큼 — 횡보 제외', 'dmi', 'exclude',
diffCond('DMI', 'PDI', 'MDI', 'DIFF_LT', 5)),
// ── 거래량 ──
item('vol-below-ma', '거래량 < MA', '평균 대비 거래량 위축', 'volume', 'include',
condition('VOLUME', 'LT', 'VOLUME_VALUE', 'VOLUME_MA')),
item('vol-above-ma', '거래량 > MA', '거래량 증가 — 횡보 제외', 'volume', 'exclude',
condition('VOLUME', 'LT', 'VOLUME_VALUE', 'VOLUME_MA')),
item('vr-near-100', 'VR ≈ 100', 'VR 기준선 근처', 'volume', 'include',
diffCond('VR', 'VR_VALUE', THRESHOLD_HL_MID, 'DIFF_LT', 30)),
// ── 복합 ──
item('combo-adx-cloud', 'ADX<25 + 구름안', '비추세 + 일목 구름대', 'combo', 'include', andNode(adxWeak(), inCloud())),
item('combo-adx-rsi', 'ADX<25 + RSI중립', '비추세 + RSI 40~60', 'combo', 'include', def => andNode(adxWeak(), rsiBand(def, 40, 60))),
item('combo-adx-bb', 'ADX<25 + BB수축', '비추세 + 볼린저 수축', 'combo', 'include', def => andNode(adxWeak(), bbSqueeze(def))),
item('combo-adx-ma', 'ADX<25 + MA수렴', '비추세 + MA 단·중 수렴', 'combo', 'include',
def => andNode(adxWeak(), maConverge(def, 0, 1, 1))),
item('combo-cloud-rsi', '구름안 + RSI중립', '일목 횡보 + RSI 중립', 'combo', 'include', def => andNode(inCloud(), rsiBand(def, 40, 60))),
item('combo-triple', 'ADX+구름+RSI', '3중 횡보 확인', 'combo', 'include', def => andNode(adxWeak(), inCloud(), rsiBand(def, 40, 60))),
item('combo-loose-range', 'ADX<20 또는 구름안', '느슨한 횡보 OR', 'combo', 'include', orNode(adxVeryWeak(), inCloud())),
item('combo-trend-only', '추세만 (3중 제외)', 'NOT(ADX<25 ∧ 구름안 ∧ RSI중립)', 'combo', 'exclude', def =>
andNode(adxWeak(), inCloud(), rsiBand(def, 40, 60))),
item('combo-adx-dc', 'ADX<25 + 20일채널좁음', '비추세 + Donchian 박스', 'combo', 'include',
andNode(adxWeak(), dcNarrow(20, 3))),
item('combo-full-range', '풀 횡보 필터', 'ADX+BB수축+MA수렴+RSI', 'combo', 'include', def => andNode(
adxWeak(),
bbSqueeze(def),
maConverge(def, 0, 1, 1),
rsiBand(def, 45, 55),
)),
];
const FILTER_MAP = new Map(DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => [i.id, i]));
export function getSidewaysFilterItem(id: string): SidewaysFilterItem | undefined {
return FILTER_MAP.get(id);
}
export function buildSidewaysFilterNode(id: string, def: DefType): LogicNode | null {
const item = getSidewaysFilterItem(id);
if (!item) return null;
return item.build(def);
}
export function filterSidewaysItems(
items: SidewaysFilterItem[],
query: string,
modeFilter: 'all' | SidewaysFilterMode,
): SidewaysFilterItem[] {
const q = query.trim().toLowerCase();
return items.filter(i => {
if (modeFilter !== 'all' && i.mode !== modeFilter) return false;
if (!q) return true;
const cat = SIDEWAYS_FILTER_CATEGORY_LABEL[i.category];
return (
i.label.toLowerCase().includes(q)
|| i.desc.toLowerCase().includes(q)
|| i.id.toLowerCase().includes(q)
|| cat.toLowerCase().includes(q)
|| (i.mode === 'include' ? '횡보' : '제외').includes(q)
);
});
}
export function groupSidewaysFiltersByCategory(
items: SidewaysFilterItem[],
): { category: SidewaysFilterCategory; label: string; items: SidewaysFilterItem[] }[] {
const order: SidewaysFilterCategory[] = [
'adx', 'bollinger', 'ma', 'ichimoku', 'donchian', 'oscillator', 'dmi', 'volume', 'combo',
];
const grouped = new Map<SidewaysFilterCategory, SidewaysFilterItem[]>();
for (const i of items) {
const list = grouped.get(i.category) ?? [];
list.push(i);
grouped.set(i.category, list);
}
return order
.filter(c => grouped.has(c))
.map(c => ({
category: c,
label: SIDEWAYS_FILTER_CATEGORY_LABEL[c],
items: grouped.get(c)!,
}));
}