전략편집기 횡보구간 컨트롤 추가
This commit is contained in:
@@ -45,6 +45,8 @@ import {
|
|||||||
type StartCombineOp,
|
type StartCombineOp,
|
||||||
} from '../utils/strategyStartNodes';
|
} from '../utils/strategyStartNodes';
|
||||||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||||||
|
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
|
||||||
|
import { buildSidewaysFilterNode } from '../utils/sidewaysFilterPalette';
|
||||||
import {
|
import {
|
||||||
loadPaletteItems,
|
loadPaletteItems,
|
||||||
type PaletteItem,
|
type PaletteItem,
|
||||||
@@ -99,7 +101,16 @@ import {
|
|||||||
strategyDtoToListExportItem,
|
strategyDtoToListExportItem,
|
||||||
} from '../utils/strategyImportExport';
|
} from '../utils/strategyImportExport';
|
||||||
import PaletteChip from './strategyEditor/PaletteChip';
|
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 StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||||
@@ -120,10 +131,21 @@ const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
|
|||||||
const LEFT_PANEL_MIN = 220;
|
const LEFT_PANEL_MIN = 220;
|
||||||
const LEFT_PANEL_MAX = 520;
|
const LEFT_PANEL_MAX = 520;
|
||||||
const LEFT_PANEL_DEFAULT = 280;
|
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_MIN = 88;
|
||||||
const TERMINAL_MAX = 420;
|
const TERMINAL_MAX = 420;
|
||||||
const TERMINAL_DEFAULT = 140;
|
const TERMINAL_DEFAULT = 140;
|
||||||
|
|
||||||
|
function readRightPanelWidth(): number {
|
||||||
|
return clampPanelSize(
|
||||||
|
readStoredSize('se-right-width', RIGHT_PANEL_DEFAULT),
|
||||||
|
RIGHT_PANEL_MIN,
|
||||||
|
RIGHT_PANEL_MAX,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
onNavigateToBacktest?: () => void;
|
onNavigateToBacktest?: () => void;
|
||||||
@@ -189,7 +211,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
||||||
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
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 [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||||
const [paletteSearch, setPaletteSearch] = useState('');
|
const [paletteSearch, setPaletteSearch] = useState('');
|
||||||
@@ -224,8 +246,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
|
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
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 [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||||||
const leftWidthRef = useRef(leftWidth);
|
const leftWidthRef = useRef(leftWidth);
|
||||||
|
const rightWidthRef = useRef(rightWidth);
|
||||||
const terminalHeightRef = useRef(terminalHeight);
|
const terminalHeightRef = useRef(terminalHeight);
|
||||||
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||||
const [sellLayout, setSellLayout] = 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 layoutPersistReadyRef = useRef(false);
|
||||||
const STRATEGY_AUTOSAVE_MS = 350;
|
const STRATEGY_AUTOSAVE_MS = 350;
|
||||||
leftWidthRef.current = leftWidth;
|
leftWidthRef.current = leftWidth;
|
||||||
|
rightWidthRef.current = rightWidth;
|
||||||
terminalHeightRef.current = terminalHeight;
|
terminalHeightRef.current = terminalHeight;
|
||||||
|
|
||||||
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
|
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
|
||||||
@@ -260,6 +286,22 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
v => storeSize('se-left-width', v),
|
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(
|
const onTerminalSplitter = usePanelResize(
|
||||||
'horizontal',
|
'horizontal',
|
||||||
setTerminalHeight,
|
setTerminalHeight,
|
||||||
@@ -271,8 +313,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
|
|
||||||
const bodyStyle = useMemo(() => ({
|
const bodyStyle = useMemo(() => ({
|
||||||
'--se-left-width': `${leftWidth}px`,
|
'--se-left-width': `${leftWidth}px`,
|
||||||
|
'--se-right-width': `${rightWidth}px`,
|
||||||
'--se-terminal-height': `${terminalHeight}px`,
|
'--se-terminal-height': `${terminalHeight}px`,
|
||||||
}) as React.CSSProperties, [leftWidth, terminalHeight]);
|
}) as React.CSSProperties, [leftWidth, rightWidth, terminalHeight]);
|
||||||
|
|
||||||
const showSnack = (msg: string, ok = true) => {
|
const showSnack = (msg: string, ok = true) => {
|
||||||
setSnack({ msg, ok });
|
setSnack({ msg, ok });
|
||||||
@@ -1021,6 +1064,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
scheduleStrategyPersist();
|
scheduleStrategyPersist();
|
||||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, 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 templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||||
|
|
||||||
const templateRows = useMemo(() => {
|
const templateRows = useMemo(() => {
|
||||||
@@ -1645,7 +1697,24 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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-palette-panel">
|
||||||
<div className="se-right-tabs">
|
<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>
|
<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">
|
<div className="se-palette-subtabs">
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
setIndicatorSubTab('auxiliary');
|
setIndicatorSubTab('auxiliary');
|
||||||
setSelectedPaletteKey(null);
|
setSelectedPaletteKey(null);
|
||||||
@@ -1712,7 +1781,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
setIndicatorSubTab('composite');
|
setIndicatorSubTab('composite');
|
||||||
setSelectedPaletteKey(null);
|
setSelectedPaletteKey(null);
|
||||||
@@ -1720,6 +1789,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
>
|
>
|
||||||
복합지표
|
복합지표
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{indicatorSubTab === 'auxiliary' ? (
|
{indicatorSubTab === 'auxiliary' ? (
|
||||||
<IndicatorPaletteTab
|
<IndicatorPaletteTab
|
||||||
@@ -1739,7 +1818,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
applyPaletteItem(item);
|
applyPaletteItem(item);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : indicatorSubTab === 'composite' ? (
|
||||||
<IndicatorPaletteTab
|
<IndicatorPaletteTab
|
||||||
kind="composite"
|
kind="composite"
|
||||||
items={compositePalette}
|
items={compositePalette}
|
||||||
@@ -1757,6 +1836,21 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
applyPaletteItem(item);
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1824,6 +1918,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{templateSaveOpen && (
|
{templateSaveOpen && (
|
||||||
<DraggableModalFrame onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title="템플릿 저장">
|
<DraggableModalFrame onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title="템플릿 저장">
|
||||||
|
|||||||
@@ -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,
|
updateNode,
|
||||||
type DefType,
|
type DefType,
|
||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
|
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPalette';
|
||||||
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
||||||
import {
|
import {
|
||||||
START_NODE_ID,
|
START_NODE_ID,
|
||||||
@@ -563,18 +564,28 @@ function StrategyEditorCanvasInner({
|
|||||||
})));
|
})));
|
||||||
}, [setNodes]);
|
}, [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((
|
const addOrphanAt = useCallback((
|
||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
flowPos: { x: number; y: number },
|
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, {
|
positionsRef.current.set(newNode.id, {
|
||||||
x: flowPos.x - FLOW_NODE_W / 2,
|
x: flowPos.x - FLOW_NODE_W / 2,
|
||||||
y: flowPos.y - FLOW_NODE_H / 2,
|
y: flowPos.y - FLOW_NODE_H / 2,
|
||||||
});
|
});
|
||||||
onOrphansChange([...orphans, newNode]);
|
onOrphansChange([...orphans, newNode]);
|
||||||
scheduleLayoutEmit();
|
scheduleLayoutEmit();
|
||||||
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
|
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
|
||||||
|
|
||||||
const applyDropWithAttach = useCallback((
|
const applyDropWithAttach = useCallback((
|
||||||
anchorId: string,
|
anchorId: string,
|
||||||
@@ -586,7 +597,8 @@ function StrategyEditorCanvasInner({
|
|||||||
return;
|
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);
|
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
|
|
||||||
@@ -636,7 +648,7 @@ function StrategyEditorCanvasInner({
|
|||||||
}
|
}
|
||||||
saveAttachHandles(anchorId);
|
saveAttachHandles(anchorId);
|
||||||
scheduleLayoutEmit();
|
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 }) => {
|
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||||
updateDropPreview(anchorId, flowPos);
|
updateDropPreview(anchorId, flowPos);
|
||||||
|
|||||||
@@ -48,6 +48,49 @@ export function usePanelResize(
|
|||||||
}, [axis, onResize, getCurrent, min, max, onCommit]);
|
}, [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 {
|
export function readStoredSize(key: string, fallback: number): number {
|
||||||
try {
|
try {
|
||||||
const raw = getUiPreferences().panels?.[key];
|
const raw = getUiPreferences().panels?.[key];
|
||||||
|
|||||||
@@ -1564,9 +1564,61 @@
|
|||||||
.se-formula-ind--ind { color: var(--se-palette-ind-accent); }
|
.se-formula-ind--ind { color: var(--se-palette-ind-accent); }
|
||||||
|
|
||||||
/* ── Right palette ── */
|
/* ── 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 {
|
.se-right {
|
||||||
flex: 0 0 380px;
|
|
||||||
width: 380px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1575,6 +1627,39 @@
|
|||||||
overflow: hidden;
|
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 {
|
.se-palette-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1649,14 +1734,234 @@
|
|||||||
color: var(--se-tab-active);
|
color: var(--se-tab-active);
|
||||||
box-shadow: inset 0 -2px 0 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);
|
color: var(--se-palette-section-ind, #2dd4bf);
|
||||||
box-shadow: inset 0 -2px 0 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);
|
color: var(--se-palette-section-composite, #c084fc);
|
||||||
box-shadow: inset 0 -2px 0 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 {
|
.se-palette-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1675,6 +1980,11 @@
|
|||||||
}
|
}
|
||||||
.se-palette-section--aux .se-palette-toolbar-title { color: var(--se-palette-section-ind); }
|
.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--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 {
|
.se-palette-toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2098,7 +2408,3 @@
|
|||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.se-right { flex: 0 0 300px; width: 300px; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
# 매매전략 조건 — Ta4j 지원 × 투자전략 DSL 가용 목록
|
||||||
|
|
||||||
|
goldenChart **투자전략 DSL**에서 실제로 선택·평가 가능한 지표와 조건을 **매수 / 매도 / 횡보 필터링** 관점으로 정리한 문서입니다.
|
||||||
|
|
||||||
|
> **평가 원천:** 백엔드 `StrategyDslToTa4jAdapter`가 DSL JSON → Ta4j `Rule`로 변환하여 백테스트·실시간 시그널을 생성합니다.
|
||||||
|
> **관련 문서:** [전략편집기 로직 및 동작흐름.md](./전략편집기%20로직%20및%20동작흐름.md), [백테스팅 시그널 동작방식.md](./백테스팅%20시그널%20동작방식.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. DSL 구조 요약
|
||||||
|
|
||||||
|
### 1.1 논리 트리 노드
|
||||||
|
|
||||||
|
| 노드 타입 | 역할 | 예시 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `AND` | 모든 자식 조건 동시 충족 | 추세 + 모멘텀 + 거래량 동시 확인 |
|
||||||
|
| `OR` | 자식 중 하나만 충족 | RSI 과매도 **또는** 볼린저 하단 터치 |
|
||||||
|
| `NOT` | 자식 조건의 반대 | `NOT(ADX ≥ 25)` → 횡보 구간만 |
|
||||||
|
| `TIMEFRAME` | 특정 분봉에서만 평가 | `[3m]` MA 크로스 |
|
||||||
|
| `CONDITION` | 단일 지표 비교 | `RSI_VALUE CROSS_UP OVERSOLD_30` |
|
||||||
|
|
||||||
|
### 1.2 공통 조건 연산자 (`COMMON_CONDITIONS`)
|
||||||
|
|
||||||
|
모든 일반 지표(밴드·오실레이터·거래량 등)에 공통 적용됩니다.
|
||||||
|
|
||||||
|
| 조건 코드 | UI 라벨 | Ta4j Rule | 의미 |
|
||||||
|
|-----------|---------|-----------|------|
|
||||||
|
| `GT` | 초과(>) | `OverIndicatorRule` | 좌 > 우 |
|
||||||
|
| `LT` | 미만(<) | `UnderIndicatorRule` | 좌 < 우 |
|
||||||
|
| `GTE` | 이상(≥) | Over ∨ Equal | 좌 ≥ 우 |
|
||||||
|
| `LTE` | 이하(≤) | Under ∨ Equal | 좌 ≤ 우 |
|
||||||
|
| `EQ` | 같음(=) | epsilon Equal | 좌 ≈ 우 |
|
||||||
|
| `NEQ` | 다름(≠) | Not Equal | 좌 ≠ 우 |
|
||||||
|
| `CROSS_UP` | 상향 돌파 | `CrossedUpIndicatorRule` | **직전 봉** 좌≤우 → **현재 봉** 좌>우 |
|
||||||
|
| `CROSS_DOWN` | 하향 돌파 | `CrossedDownIndicatorRule` | **직전 봉** 좌≥우 → **현재 봉** 좌<우 |
|
||||||
|
| `SLOPE_UP` | 상승 중 | `IsRisingRule` | `slopePeriod`(기본 3) 구간 연속 상승 |
|
||||||
|
| `SLOPE_DOWN` | 하락 중 | `IsFallingRule` | `slopePeriod` 구간 연속 하락 |
|
||||||
|
| `DIFF_GT` | 차이>값 | abs(좌−우) > `compareValue` | 두 선 간격이 임계값 초과 |
|
||||||
|
| `DIFF_LT` | 차이<값 | abs(좌−우) < `compareValue` | 두 선 간격이 임계값 미만 |
|
||||||
|
| `HOLD_N_DAYS` | N일 유지 | N봉 연속 Over | 좌>우가 `holdDays`(기본 3)봉 유지 |
|
||||||
|
|
||||||
|
**교차(`CROSS_*`) 주의:** 해당 **봉 종가(확정 봉) 기준 1봉**만 true입니다. 차트 SMA·백테스트 MA 기간·워밍업 봉 수가 다르면 교차 시점이 어긋날 수 있습니다.
|
||||||
|
|
||||||
|
### 1.3 일목균형표 전용 조건 (`ICHIMOKU`만)
|
||||||
|
|
||||||
|
| 조건 코드 | 의미 |
|
||||||
|
|-----------|------|
|
||||||
|
| `ABOVE_CLOUD` | 종가 > max(선행1, 선행2) — 구름 위 |
|
||||||
|
| `BELOW_CLOUD` | 종가 < min(선행1, 선행2) — 구름 아래 |
|
||||||
|
| `IN_CLOUD` | 종가가 구름대 내부 |
|
||||||
|
| `CLOUD_BREAK_UP` | 종가가 구름 상단 상향 돌파 |
|
||||||
|
| `CLOUD_BREAK_DOWN` | 종가가 구름 하단 하향 이탈 |
|
||||||
|
| `SPAN1_GT_SPAN2` / `SPAN1_LT_SPAN2` | 선행1 vs 선행2 크기 비교 |
|
||||||
|
| `SPAN1_CROSS_UP_SPAN2` / `SPAN1_CROSS_DOWN_SPAN2` | 선행1↔선행2 교차 |
|
||||||
|
| `LAGGING_GT_PRICE` / `LAGGING_LT_PRICE` | 후행스팬 vs 종가 |
|
||||||
|
|
||||||
|
일목은 위 전용 조건 **+** 공통 조건(전환선↔기준선 `CROSS_UP` 등) 모두 사용 가능합니다.
|
||||||
|
|
||||||
|
### 1.4 복합지표 (2기간 교차)
|
||||||
|
|
||||||
|
팔레트 **「복합지표」** 탭에서 동일 지표의 **단기·장기 두 값**을 비교합니다.
|
||||||
|
|
||||||
|
| 지표 | 좌/우 필드 예 | 용도 |
|
||||||
|
|------|---------------|------|
|
||||||
|
| DONCHIAN | `DC_UPPER_9` ↔ `DC_UPPER_20` | 단기·장기 채널 비교 |
|
||||||
|
| RSI | `RSI_VALUE_9` ↔ `RSI_VALUE_18` | RSI 기간 교차 |
|
||||||
|
| CCI, ADX, Williams %R, TRIX, VR | `*_VALUE_{기간}` | 기간별 강도 비교 |
|
||||||
|
| PSYCHOLOGICAL, INVEST_PSYCHOLOGICAL | `PSY_VALUE_{n}` | 심리도 기간 교차 |
|
||||||
|
| DISPARITY | `DISPARITY5` ↔ `DISPARITY20` | 단기·중기 이격도 |
|
||||||
|
|
||||||
|
요소1·요소2 각각 **별도 TIMEFRAME**(leftCandleType / rightCandleType) 지정 가능 — 멀티 TF 교차 조건.
|
||||||
|
|
||||||
|
### 1.5 DSL 지원 지표 전체 목록
|
||||||
|
|
||||||
|
#### 밴드·추세 (MA_BAND / 밴드 팔레트)
|
||||||
|
|
||||||
|
| DSL 타입 | Ta4j / 차트 | 비고 |
|
||||||
|
|----------|-------------|------|
|
||||||
|
| `MA` | `SMAIndicator` | `MA{n}` = n봉 단순이동평균 (SMA 설정 period1~11 반영) |
|
||||||
|
| `EMA` | `EMAIndicator` | `EMA{n}` |
|
||||||
|
| `BOLLINGER` | `BollingerBands*` | 상·중·하단밴드, 종가 비교 |
|
||||||
|
| `DONCHIAN` | `DonchianChannel*` | `DC_UPPER/Lower/MIDDLE_{9,10,20,55}` |
|
||||||
|
| `NEW_HIGH` | `PreviousValueIndicator` + 최고가 | `NH_PRIOR_{n}` 직전 n봉 최고가 |
|
||||||
|
| `NEW_LOW` | `PreviousValueIndicator` + 최저가 | `NL_PRIOR_{n}` 직전 n봉 최저 |
|
||||||
|
| `ICHIMOKU` | `Ichimoku*Indicator` | 전환·기준·선행·후행 + 구름 조건 |
|
||||||
|
|
||||||
|
#### 오실레이터·모멘텀 (보조지표 팔레트)
|
||||||
|
|
||||||
|
| DSL 타입 | Ta4j 클래스 | 기본 파라미터(편집기) |
|
||||||
|
|----------|-------------|----------------------|
|
||||||
|
| `RSI` | `RSIIndicator` | length 9 (차트 설정 상속 가능) |
|
||||||
|
| `MACD` | `MACDIndicator` + EMA signal | 12 / 26 / 9 |
|
||||||
|
| `CCI` | `CciOnSourceIndicator` | 13 |
|
||||||
|
| `ADX` | `ADXIndicator` | diLength 14, adxSmoothing 14 |
|
||||||
|
| `DMI` | `PlusDIIndicator` / `MinusDIIndicator` | diLength 14 |
|
||||||
|
| `STOCHASTIC` | Slow %K, %D (SMA smoothing) | k 12, smooth 3, d 5 |
|
||||||
|
| `WILLIAMS_R` | `WilliamsRIndicator` | 14 |
|
||||||
|
| `TRIX` | 3×EMA ROC% + signal EMA | 12 / signal 9 |
|
||||||
|
| `OBV` | `OnBalanceVolumeIndicator` + MA | maLength 9 |
|
||||||
|
| `VR` | 커스텀 VR | length 10 |
|
||||||
|
| `DISPARITY` | (종가/MA−1)×100 | ultra 5, short 10, mid 20 |
|
||||||
|
| `PSYCHOLOGICAL` | 상승일 비율 | 12 |
|
||||||
|
| `NEW_PSYCHOLOGICAL` | 신심리도 | 10 |
|
||||||
|
| `INVEST_PSYCHOLOGICAL` | 투자심리도 | 10 |
|
||||||
|
| `VOLUME` | `VolumeIndicator` vs SMA | MA 20 |
|
||||||
|
| `BWI` | `BollingerBandWidthIndicator` (차트) | ⚠️ **DSL 어댑터 미구현** (§7 참고) |
|
||||||
|
| `VOLUME_OSC` | Volume EMA 차이 % (차트) | ⚠️ **DSL 어댑터 미구현** (§7 참고) |
|
||||||
|
|
||||||
|
#### 가격 필드 (여러 지표에서 좌·우 operand로 사용)
|
||||||
|
|
||||||
|
`CLOSE_PRICE`, `OPEN_PRICE`, `HIGH_PRICE`, `LOW_PRICE`, `VOLUME_VALUE`, `VOLUME_MA`
|
||||||
|
|
||||||
|
#### 임계값(hline) 필드
|
||||||
|
|
||||||
|
편집기에서 `HL_OVER`(과열), `HL_MID`(중앙), `HL_UNDER`(침체) 또는 `K_{숫자}` 직접 입력.
|
||||||
|
|
||||||
|
| 지표 | HL_OVER | HL_MID | HL_UNDER |
|
||||||
|
|------|---------|--------|----------|
|
||||||
|
| RSI | 70 | 50 | 30 |
|
||||||
|
| Stochastic | 80 | 50 | 20 |
|
||||||
|
| CCI | 100 | 0 | -100 |
|
||||||
|
| Williams %R | -20 | -50 | -80 |
|
||||||
|
| ADX | 40 | **25** | 20 |
|
||||||
|
| BWI | 80 | 50 | 20 |
|
||||||
|
| VR | 200 | 100 | 50 |
|
||||||
|
| PSYCHOLOGICAL | 75 | 50 | 25 |
|
||||||
|
| NEW_PSYCHOLOGICAL | 50 | 0 | -50 |
|
||||||
|
| INVEST_PSYCHOLOGICAL | 75 | 50 | 25 |
|
||||||
|
| DISPARITY | 105 | 100 | 95 |
|
||||||
|
| MACD / TRIX / VOLUME_OSC | — | 0 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 매수(BUY) 조건 — 지표별 활용
|
||||||
|
|
||||||
|
매수 조건은 `buyCondition` 트리에 설정합니다. 아래는 **전략 편집기 템플릿·일반적 패턴**과 **추천 조건 조합**입니다.
|
||||||
|
|
||||||
|
### 2.1 추세 추종 — 이동평균·밴드
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| MA 골든크로스 | `MA` `CROSS_UP` `MA10` ↔ `MA20` | 단기 SMA가 장기 SMA 상향 돌파 |
|
||||||
|
| 종가 > 장기선 | `MA` `CROSS_UP` `CLOSE_PRICE` ↔ `MA60` | 가격이 장기 추세선 회복 |
|
||||||
|
| EMA 단기 > EMA 장기 | `EMA` `GT` `EMA5` ↔ `EMA20` | 지수이동평균 정배열 진입 |
|
||||||
|
| 볼린저 하단 반등 | `BOLLINGER` `CROSS_UP` `CLOSE_PRICE` ↔ `LOWER_BAND` | 하단밴드 터치 후 종가 회복 |
|
||||||
|
| 볼린저 중심선 돌파 | `BOLLINGER` `CROSS_UP` `CLOSE_PRICE` ↔ `MIDDLE_BAND` | 박스 상단(중심) 돌파 |
|
||||||
|
| 이격도 과매도 탈출 | `DISPARITY` `CROSS_UP` `DISPARITY20` ↔ `OVERSOLD_95` | 20일선 대비 -5% 이하에서 반등 |
|
||||||
|
| 이격도 중립 회복 | `DISPARITY` `CROSS_UP` `DISPARITY10` ↔ `NEUTRAL_100` | 단기 이격 100% 회복 |
|
||||||
|
|
||||||
|
**MA 필드 규칙:** UI의 `MA2(10일)` = DSL `MA10` = **10봉 SMA**. SMA 차트 설정(period1~11)과 전략 편집기 `maLines`가 일치해야 차트·백테스트 값이 맞습니다.
|
||||||
|
|
||||||
|
### 2.2 모멘텀·오실레이터 — 과매도 반등
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | Ta4j |
|
||||||
|
|------|----------|------|
|
||||||
|
| RSI 과매도 | `RSI` `LTE` `RSI_VALUE` ↔ `OVERSOLD_30` | `RSIIndicator` ≤ 30 |
|
||||||
|
| RSI 과매도 **탈출** | `RSI` `CROSS_UP` `RSI_VALUE` ↔ `OVERSOLD_30` | 30선 상향 돌파 (반등 확인) |
|
||||||
|
| Stochastic 골든크로스 | `STOCHASTIC` `CROSS_UP` `STOCH_K` ↔ `STOCH_D` | Slow %K > %D 교차 |
|
||||||
|
| Stochastic 과매도 | `STOCHASTIC` `LTE` `STOCH_K` ↔ `OVERSOLD_20` | %K ≤ 20 |
|
||||||
|
| MACD 상향 돌파 | `MACD` `CROSS_UP` `MACD_LINE` ↔ `SIGNAL_LINE` | MACD 히스토그램 전환 |
|
||||||
|
| MACD 0선 돌파 | `MACD` `CROSS_UP` `MACD_LINE` ↔ `ZERO_LINE` | 추세 전환 초기 |
|
||||||
|
| CCI 과매도 반등 | `CCI` `CROSS_UP` `CCI_VALUE` ↔ `OVERSOLD_NEG100` | -100선 상향 |
|
||||||
|
| Williams %R 과매도 | `WILLIAMS_R` `LTE` `WILLIAMS_R_VALUE` ↔ `OVERSOLD_NEG80` | -80 이하 |
|
||||||
|
| TRIX 시그널 상향 | `TRIX` `CROSS_UP` `TRIX_VALUE` ↔ `TRIX_SIGNAL` | TRIX > signal |
|
||||||
|
| VR 과매도 | `VR` `LTE` `VR_VALUE` ↔ `OVERSOLD_70` | VR ≤ 70 (매수세 약함 구간 반등) |
|
||||||
|
|
||||||
|
### 2.3 추세 강도 확인 — ADX·DMI
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| ADX 추세 시작 | `ADX` `CROSS_UP` `ADX_VALUE` ↔ `ADX_25` | ADX 25 돌파 — 추세 형성 |
|
||||||
|
| ADX 강한 추세 | `ADX` `GTE` `ADX_VALUE` ↔ `ADX_25` | ADX ≥ 25 유지 |
|
||||||
|
| +DI > -DI | `DMI` `GT` `PDI` ↔ `MDI` | 상승 방향 우세 |
|
||||||
|
| +DI 골든크로스 | `DMI` `CROSS_UP` `PDI` ↔ `MDI` | DMI 골든크로스 |
|
||||||
|
|
||||||
|
> **매수 필터 조합 예:** `AND( MA 골든크로스, ADX ≥ 25, +DI > -DI )` — 추세·방향 동시 확인
|
||||||
|
|
||||||
|
### 2.4 돌파·신고가 — Donchian / NEW_HIGH
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 9일 신고가 돌파 | `NEW_HIGH` `CROSS_UP` `CLOSE_PRICE` ↔ `NH_PRIOR_9` | 직전 9봉 최고가 상향 돌파 |
|
||||||
|
| 20일 박스 돌파 | `NEW_HIGH` `CROSS_UP` `CLOSE_PRICE` ↔ `NH_PRIOR_20` | 터틀 S1 / 돈치안 돌파 |
|
||||||
|
| 55일 장기 돌파 | `NEW_HIGH` `CROSS_UP` `CLOSE_PRICE` ↔ `NH_PRIOR_55` | 터틀 S2 |
|
||||||
|
| Donchian 상단 | `DONCHIAN` `CROSS_UP` `CLOSE_PRICE` ↔ `DC_UPPER_20` | 20일 채널 상단 돌파 |
|
||||||
|
| Donchian 단·장기 | `DONCHIAN` `CROSS_UP` `DC_UPPER_9` ↔ `DC_UPPER_20` | 단기 채널이 장기 상단 돌파 |
|
||||||
|
|
||||||
|
`NH_PRIOR_{n}`: **당일 봉 제외** 직전 n봉의 최고가 (`PreviousValueIndicator`).
|
||||||
|
|
||||||
|
### 2.5 거래량·수급
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 거래량 > MA | `VOLUME` `GT` `VOLUME_VALUE` ↔ `VOLUME_MA` | 평균 대비 거래량 증가 |
|
||||||
|
| OBV 상향 | `OBV` `CROSS_UP` `OBV_LINE` ↔ `OBV_SIGNAL` | OBV가 시그널선 돌파 |
|
||||||
|
| OBV > signal | `OBV` `GT` `OBV_LINE` ↔ `OBV_SIGNAL` | 매수세 누적 |
|
||||||
|
|
||||||
|
### 2.6 일목균형표 — 상승 전환
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 전환선 > 기준선 | `ICHIMOKU` `CROSS_UP` `CONVERSION_LINE` ↔ `BASE_LINE` | 전환/기준 골든크로스 |
|
||||||
|
| 구름 상향 돌파 | `ICHIMOKU` `CLOUD_BREAK_UP` | 종가 구름대 상단 돌파 |
|
||||||
|
| 구름 위 | `ICHIMOKU` `ABOVE_CLOUD` | 강세 구간 |
|
||||||
|
| 선행1 > 선행2 | `ICHIMOKU` `SPAN1_GT_SPAN2` | 후행 구름 녹색(강세) |
|
||||||
|
| 후행 > 종가 | `ICHIMOKU` `LAGGING_GT_PRICE` | 후행스팬 지지 |
|
||||||
|
|
||||||
|
### 2.7 심리·이격 복합 (33변곡 템플릿)
|
||||||
|
|
||||||
|
내장 템플릿 **「33변곡 매수」** — AND 조합:
|
||||||
|
|
||||||
|
```
|
||||||
|
RSI CROSS_UP RSI_VALUE ↔ OVERSOLD_30
|
||||||
|
MA CROSS_UP MA5 ↔ MA20
|
||||||
|
MA CROSS_UP CLOSE_PRICE ↔ MA20
|
||||||
|
MACD CROSS_UP MACD_LINE ↔ SIGNAL_LINE
|
||||||
|
DISPARITY CROSS_UP DISPARITY20 ↔ OVERSOLD_95
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 멀티 타임프레임 매수
|
||||||
|
|
||||||
|
```
|
||||||
|
TIMEFRAME [1h] AND (
|
||||||
|
TIMEFRAME [1h] MA CROSS_UP MA20 ↔ MA60 -- 장기 추세
|
||||||
|
TIMEFRAME [3m] MA CROSS_UP MA10 ↔ MA20 -- 단기 진입
|
||||||
|
TIMEFRAME [3m] RSI GT RSI_VALUE ↔ OVERSOLD_30
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
START 노드 또는 `TIMEFRAME` 노드로 `1m` ~ `1M` 분봉 지정. DSL에 없는 분봉은 평가되지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 매도(SELL) 조건 — 지표별 활용
|
||||||
|
|
||||||
|
매도 조건은 `sellCondition` 트리에 설정합니다. `positionMode`에 따라 동작이 달라집니다.
|
||||||
|
|
||||||
|
| positionMode | 동작 |
|
||||||
|
|--------------|------|
|
||||||
|
| `LONG_ONLY` | **보유 포지션이 있을 때만** 매도 시그널 (Ta4j `shouldExit`) |
|
||||||
|
| `SIGNAL_ONLY` | 포지션 무관, exit Rule만 충족하면 **매도 시그널** (지표 연속 관찰·차트 마커용) |
|
||||||
|
|
||||||
|
### 3.1 추세 이탈 — MA·밴드
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| MA 데드크로스 | `MA` `CROSS_DOWN` `MA10` ↔ `MA20` | 단기 SMA 하향 이탈 |
|
||||||
|
| 종가 < 장기선 | `MA` `CROSS_DOWN` `CLOSE_PRICE` ↔ `MA20` | 추세선 이탈 |
|
||||||
|
| 볼린저 상단 과열 | `BOLLINGER` `CROSS_UP` `CLOSE_PRICE` ↔ `UPPER_BAND` | 상단밴드 돌파 (차익) |
|
||||||
|
| 볼린저 중심 이탈 | `BOLLINGER` `CROSS_DOWN` `CLOSE_PRICE` ↔ `MIDDLE_BAND` | 중심선 하향 |
|
||||||
|
| 이격도 과매수 | `DISPARITY` `GTE` `DISPARITY20` ↔ `OVERBOUGHT_105` | +5% 이상 과열 |
|
||||||
|
|
||||||
|
### 3.2 모멘텀·오실레이터 — 과매수·약세
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| RSI 과매수 | `RSI` `GTE` `RSI_VALUE` ↔ `OVERBOUGHT_70` | RSI ≥ 70 |
|
||||||
|
| RSI 과매수 이탈 | `RSI` `CROSS_DOWN` `RSI_VALUE` ↔ `OVERBOUGHT_70` | 70선 하향 (피크 확인) |
|
||||||
|
| Stochastic 데드크로스 | `STOCHASTIC` `CROSS_DOWN` `STOCH_K` ↔ `STOCH_D` | %K <%D |
|
||||||
|
| MACD 하향 | `MACD` `CROSS_DOWN` `MACD_LINE` ↔ `SIGNAL_LINE` | MACD 데드크로스 |
|
||||||
|
| CCI 과매수 | `CCI` `GTE` `CCI_VALUE` ↔ `OVERBOUGHT_100` | CCI ≥ 100 |
|
||||||
|
| Williams %R 과매수 | `WILLIAMS_R` `GTE` `WILLIAMS_R_VALUE` ↔ `OVERBOUGHT_NEG20` | -20 이상 |
|
||||||
|
| TRIX 하향 | `TRIX` `CROSS_DOWN` `TRIX_VALUE` ↔ `TRIX_SIGNAL` | TRIX < signal |
|
||||||
|
|
||||||
|
### 3.3 추세 약화 — ADX·DMI
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| ADX < 25 | `ADX` `LT` `ADX_VALUE` ↔ `ADX_25` | 추세 약화 |
|
||||||
|
| -DI > +DI | `DMI` `GT` `MDI` ↔ `PDI` | 하락 방향 우세 |
|
||||||
|
| DMI 데드크로스 | `DMI` `CROSS_DOWN` `PDI` ↔ `MDI` | +DI가 -DI 하향 이탈 |
|
||||||
|
|
||||||
|
### 3.4 지지 붕괴 — Donchian / NEW_LOW
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 9일 신저가 이탈 | `NEW_LOW` `CROSS_DOWN` `CLOSE_PRICE` ↔ `NL_PRIOR_9` | 단기 지지 붕괴 |
|
||||||
|
| 20일 채널 하단 | `NEW_LOW` `CROSS_DOWN` `CLOSE_PRICE` ↔ `NL_PRIOR_20` | 박스 하단 이탈 |
|
||||||
|
| 터틀 S1 청산 | `DONCHIAN` `CROSS_DOWN` `CLOSE_PRICE` ↔ `DC_LOWER_10` | 10일 최저가 이탈 |
|
||||||
|
| 터틀 S2 청산 | `DONCHIAN` `CROSS_DOWN` `CLOSE_PRICE` ↔ `DC_LOWER_20` | 20일 최저가 이탈 |
|
||||||
|
|
||||||
|
### 3.5 일목 — 약세 전환
|
||||||
|
|
||||||
|
| 패턴 | DSL 예시 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 전환 < 기준 | `ICHIMOKU` `CROSS_DOWN` `CONVERSION_LINE` ↔ `BASE_LINE` | 데드크로스 |
|
||||||
|
| 구름 하향 | `ICHIMOKU` `CLOUD_BREAK_DOWN` | 구름대 하단 이탈 |
|
||||||
|
| 구름 아래 | `ICHIMOKU` `BELOW_CLOUD` | 약세 구간 |
|
||||||
|
| 후행 < 종가 | `ICHIMOKU` `LAGGING_LT_PRICE` | 후행스팬 저항 |
|
||||||
|
|
||||||
|
### 3.6 33변곡 매도 템플릿 (AND)
|
||||||
|
|
||||||
|
```
|
||||||
|
RSI GTE RSI_VALUE ↔ OVERBOUGHT_70
|
||||||
|
MA CROSS_DOWN MA5 ↔ MA20
|
||||||
|
MA CROSS_DOWN CLOSE_PRICE ↔ MA20
|
||||||
|
MACD CROSS_DOWN MACD_LINE ↔ SIGNAL_LINE
|
||||||
|
DISPARITY GTE DISPARITY20 ↔ OVERBOUGHT_105
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 손절·익절 스타일 (DSL만으로)
|
||||||
|
|
||||||
|
| 목적 | 접근 |
|
||||||
|
|------|------|
|
||||||
|
| 추세 추종 청산 | Donchian / NEW_LOW 하단 이탈 |
|
||||||
|
| 과열 익절 | RSI ≥ 70, 볼린저 상단, 이격도 ≥ 105 |
|
||||||
|
| 모멘텀 소멸 | MACD 데드크로스, TRIX 하향 |
|
||||||
|
| 시간 청산 | `HOLD_N_DAYS`는 **유지** 조건용 — 청산 타이머는 별도 로직 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 횡보(레인지) 필터링
|
||||||
|
|
||||||
|
횡보 필터는 **매수·매도 진입을 막거나**, **횡보 전용 역추세 전략**을 만들 때 사용합니다. DSL에는 「횡보」 전용 지표 타입은 없고, 아래 조건을 **`AND` / `NOT` / `OR`** 로 조합합니다.
|
||||||
|
|
||||||
|
### 4.1 ADX — 추세 강도 (가장 일반적)
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| **횡보만** | `ADX` `LT` `ADX_VALUE` ↔ `ADX_25` (또는 `HL_MID`) | ADX < 25 → 비추세 |
|
||||||
|
| **추세만** (횡보 제외) | `NOT( ADX LT ADX_25 )` 또는 `ADX GTE ADX_25` | ADX ≥ 25 |
|
||||||
|
| ADX 하락 중 | `ADX` `SLOPE_DOWN` `ADX_VALUE` | 추세 강도 감소 |
|
||||||
|
|
||||||
|
Ta4j: `ADXIndicator` — Wilder smoothing.
|
||||||
|
|
||||||
|
**적용 예 (추세 매수 + 횡보 필터):**
|
||||||
|
|
||||||
|
```
|
||||||
|
AND(
|
||||||
|
MA CROSS_UP MA10 ↔ MA20, -- 매수 신호
|
||||||
|
ADX GTE ADX_VALUE ↔ ADX_25 -- 횡보 구간 제외
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 볼린저 밴드 폭 (BWI 대체)
|
||||||
|
|
||||||
|
`BWI` 지표는 편집기 UI에 있으나 **DSL 어댑터에서 `BWI_VALUE` 미해석**(§7). 대신 **밴드 폭**을 직접 비교:
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| Squeeze (수축) | `BOLLINGER` `DIFF_LT` `UPPER_BAND` ↔ `LOWER_BAND`, compareValue=좁은값 | 상·하단 간격 좁음 |
|
||||||
|
| Expansion (확장) | `BOLLINGER` `DIFF_GT` `UPPER_BAND` ↔ `LOWER_BAND`, compareValue=넓은값 | 변동성 확대 |
|
||||||
|
|
||||||
|
Ta4j: `BollingerBandsUpper/LowerIndicator`, `StandardDeviationIndicator`.
|
||||||
|
|
||||||
|
### 4.3 MA 수렴 (MA 스프레드)
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| MA 수렴 | `MA` `DIFF_LT` `MA5` ↔ `MA60`, compareValue=작은값 | 단·장기 이격 좁음 |
|
||||||
|
| MA 확산 | `MA` `DIFF_GT` `MA5` ↔ `MA60`, compareValue=큰값 | 추세 뚜렷 |
|
||||||
|
|
||||||
|
또는 `MA5 GT MA20` AND `MA20 GT MA60` (정배열) vs `DIFF_LT MA5 MA20` (횡보).
|
||||||
|
|
||||||
|
### 4.4 일목 구름대
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| 구름 안 (횡보) | `ICHIMOKU` `IN_CLOUD` | 가격이 선행스팬 사이 |
|
||||||
|
| 구름 밖만 거래 | `NOT( ICHIMOKU IN_CLOUD )` 또는 `ABOVE_CLOUD` OR `BELOW_CLOUD` | 방향성 구간만 |
|
||||||
|
| 얇은 구름 | `ICHIMOKU` `DIFF_LT` `LEADING_SPAN1` ↔ `LEADING_SPAN2`, compareValue=작은값 | 선행1·2 근접 |
|
||||||
|
|
||||||
|
### 4.5 Donchian / NEW_HIGH·LOW 채널 폭
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| 좁은 채널 | `DONCHIAN` `DIFF_LT` `DC_UPPER_20` ↔ `DC_LOWER_20`, compareValue=작은값 | 20일 고저 폭 좁음 |
|
||||||
|
| 박스권 | `NEW_HIGH` `DIFF_LT` `NH_PRIOR_20` ↔ `NL_PRIOR_20` (복합 비교) | 신고·신저 gap 좁음 |
|
||||||
|
|
||||||
|
### 4.6 오실레이터 중립대
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| RSI 중립 | `AND( RSI GTE 40, RSI LTE 60 )` — `K_40`, `K_60` 사용 | 40~60 박스 |
|
||||||
|
| Stochastic 중립 | `STOCH_K` `GTE`/`LTE` ↔ `HL_MID` ± 범위 | %K 50 근처 |
|
||||||
|
| CCI 중립 | `CCI` `DIFF_LT` `CCI_VALUE` ↔ `HL_MID`, compareValue=50 | 0선 근처 |
|
||||||
|
| MACD 히스토그램 수렴 | `MACD` `DIFF_LT` `HISTOGRAM` ↔ `ZERO_LINE`, compareValue=작은값 | 모멘텀 약함 |
|
||||||
|
|
||||||
|
### 4.7 DMI — 방향성 없음
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| +DI ≈ -DI | `DMI` `DIFF_LT` `PDI` ↔ `MDI`, compareValue=작은값 | 방향 우열 없음 |
|
||||||
|
| 양 DI 모두 낮음 | `AND( PDI LT HL_UNDER, MDI LT HL_UNDER )` | 힘 없는 횡보 |
|
||||||
|
|
||||||
|
### 4.8 거래량·VR
|
||||||
|
|
||||||
|
| 목적 | 조건 | 해석 |
|
||||||
|
|------|------|------|
|
||||||
|
| 거래량 위축 | `VOLUME` `LT` `VOLUME_VALUE` ↔ `VOLUME_MA` | 평균 이하 |
|
||||||
|
| VR 중립 | `VR` `DIFF_LT` `VR_VALUE` ↔ `NEUTRAL_100`, compareValue=20 | VR ≈ 100 |
|
||||||
|
|
||||||
|
### 4.9 횡보 필터 조합 템플릿
|
||||||
|
|
||||||
|
#### A. 추세 전략에 횡보 제외 (권장)
|
||||||
|
|
||||||
|
```
|
||||||
|
AND(
|
||||||
|
{본래 매수/매도 조건},
|
||||||
|
ADX GTE ADX_VALUE ↔ ADX_25,
|
||||||
|
NOT( ICHIMOKU IN_CLOUD )
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. 횡보 역추세 전용 (볼린저 + RSI)
|
||||||
|
|
||||||
|
```
|
||||||
|
AND(
|
||||||
|
ADX LT ADX_VALUE ↔ ADX_25,
|
||||||
|
BOLLINGER CROSS_UP CLOSE_PRICE ↔ LOWER_BAND, -- 매수 (하단 반등)
|
||||||
|
RSI LTE RSI_VALUE ↔ OVERSOLD_30
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Squeeze 후 돌파 대기
|
||||||
|
|
||||||
|
```
|
||||||
|
AND(
|
||||||
|
BOLLINGER DIFF_LT UPPER_BAND ↔ LOWER_BAND, compareValue=좁은값,
|
||||||
|
ADX LT ADX_25
|
||||||
|
)
|
||||||
|
-- 이후 별도 OR 분기에서 NEW_HIGH 돌파 등 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 논리 조합 · TIMEFRAME · Stoch 페어
|
||||||
|
|
||||||
|
### 5.1 AND / OR / NOT 패턴
|
||||||
|
|
||||||
|
| 전략 유형 | 구조 |
|
||||||
|
|-----------|------|
|
||||||
|
| 보수적 진입 | `AND(추세, 모멘텀, 거래량, NOT 횡보)` |
|
||||||
|
| 다중 신호 | `OR(RSI 매수, MACD 매수, MA 매수)` |
|
||||||
|
| 횡보 제외 | `NOT( ADX LT 25 )` 또는 `NOT( IN_CLOUD )` |
|
||||||
|
| 확인 필터 | `AND( CROSS_UP, HOLD_N_DAYS )` — 돌파 N봉 유지 |
|
||||||
|
|
||||||
|
### 5.2 TIMEFRAME
|
||||||
|
|
||||||
|
- START 노드 또는 `TIMEFRAME` 자식으로 `1m`, `3m`, `5m`, `10m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w`, `1M` 지정
|
||||||
|
- 상위 TF 추세 + 하위 TF 진입이 일반적
|
||||||
|
- 복합지표 `leftCandleType` / `rightCandleType`으로 **서로 다른 분봉** 비교 가능
|
||||||
|
|
||||||
|
### 5.3 Stoch 페어 (`stochPair`)
|
||||||
|
|
||||||
|
OR 루트에 Stochastic 과열/과매도와 **다른 보조지표**를 순차 OR로 묶는 복합 패턴 (편집기 고급 기능).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ta4j 클래스 매핑 (DSL → 백엔드)
|
||||||
|
|
||||||
|
| DSL `indicatorType` | DB `indicatorParams` 키 | 주요 Ta4j 클래스 |
|
||||||
|
|---------------------|-------------------------|------------------|
|
||||||
|
| MA | SMA | `SMAIndicator` |
|
||||||
|
| EMA | SMA (period) | `EMAIndicator` |
|
||||||
|
| RSI | RSI | `RSIIndicator` |
|
||||||
|
| MACD | MACD | `MACDIndicator`, `EMAIndicator` |
|
||||||
|
| CCI | CCI | `CciOnSourceIndicator` |
|
||||||
|
| ADX | ADX | `ADXIndicator` |
|
||||||
|
| DMI | DMI | `PlusDIIndicator`, `MinusDIIndicator` |
|
||||||
|
| STOCHASTIC | Stochastic | `StochasticOscillatorKIndicator`, `SMAIndicator` |
|
||||||
|
| WILLIAMS_R | WilliamsPercentRange | `WilliamsRIndicator` |
|
||||||
|
| TRIX | TRIX | 3×`EMAIndicator` + ROC |
|
||||||
|
| OBV | OBV | `OnBalanceVolumeIndicator` |
|
||||||
|
| BOLLINGER | BollingerBands | `BollingerBands*Indicator` |
|
||||||
|
| DONCHIAN | DonchianChannels | `DonchianChannel*Indicator` |
|
||||||
|
| NEW_HIGH / NEW_LOW | DonchianChannels | `HighestValueIndicator` / `LowestValueIndicator` + `PreviousValueIndicator` |
|
||||||
|
| ICHIMOKU | IchimokuCloud | `Ichimoku*Indicator` |
|
||||||
|
| VR | VR | 커스텀 (IndicatorService 동일) |
|
||||||
|
| DISPARITY | Disparity | 종가/SMA 기반 |
|
||||||
|
| PSYCHOLOGICAL 계열 | Psychological 등 | 커스텀 |
|
||||||
|
| VOLUME | (Volume) | `VolumeIndicator`, `SMAIndicator` |
|
||||||
|
|
||||||
|
**교차·비교 Rule:** `CrossedUpIndicatorRule`, `CrossedDownIndicatorRule`, `OverIndicatorRule`, `UnderIndicatorRule`, `IsRisingRule`, `IsFallingRule`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 알려진 제한 · 미지원
|
||||||
|
|
||||||
|
### 7.1 DSL UI 있으나 어댑터 미구현 ⚠️
|
||||||
|
|
||||||
|
| 지표 | 편집기 필드 | 현상 |
|
||||||
|
|------|-------------|------|
|
||||||
|
| `BWI` | `BWI_VALUE` | `resolveIndicatorField`에 분기 없음 → **종가로 폴백** (warn 로그) |
|
||||||
|
| `VOLUME_OSC` | `VOLUME_OSC_VALUE` | 동일 — **종가로 폴백** |
|
||||||
|
|
||||||
|
차트(`IndicatorService`)에서는 `BollingerBandWidthIndicator`, Volume EMA OSC 정상 계산. **전략 조건에는 §4.2·§4.8의 대체 조합 사용 권장.**
|
||||||
|
|
||||||
|
### 7.2 Ta4j·차트에만 있고 DSL 없음
|
||||||
|
|
||||||
|
`IndicatorController` `/list` 기준 차트 전용 예:
|
||||||
|
|
||||||
|
`WMA`, `HMA`, `VWMA`, `KeltnerChannels`, `BBPercentB`, `StochRSI`, `AwesomeOscillator`, `DPO`, `Momentum`, `ROC`, `TSI`, `Aroon`, `ParabolicSAR`, `Choppiness`, `MassIndex`, `ATR`, `MFI`, `ChaikinMF` 등
|
||||||
|
|
||||||
|
→ 전략 DSL 팔레트에 없으면 **조건으로 사용 불가**.
|
||||||
|
|
||||||
|
### 7.3 기타 주의
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| MA 기간 | `MA{n}` = n봉 SMA. UI 「MA2(10일)」= `MA10` |
|
||||||
|
| 워밍업 | 백테스트는 전략 최대 MA 기간 + 평가 구간(200봉)만큼 워밍업 후 평가 |
|
||||||
|
| CROSS 1봉 | 교차 조건은 해당 봉 1회만 true |
|
||||||
|
| hline | `HL_*` 또는 `K_{숫자}` — 보조지표 설정 `thresholdOverride`로 전략 전용 값 가능 |
|
||||||
|
| NEW_PSYCHOLOGICAL | 복합·조건 지원, 메인 팔레트에는 없을 수 있음 — 템플릿·직접 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 빠른 참조 — 조건 × 지표 매트릭스
|
||||||
|
|
||||||
|
**○** = `INDICATOR_CONDITIONS`에 포함. **◎** = 일목 전용 추가. **△** = UI만 (어댑터 미구현).
|
||||||
|
|
||||||
|
| 지표 | GT/LT/GTE/LTE | CROSS | SLOPE | DIFF | HOLD | 일목전용 |
|
||||||
|
|------|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| MA, EMA | ○ | ○ | ○ | ○ | ○ | |
|
||||||
|
| BOLLINGER | ○ | ○ | ○ | ○ | ○ | |
|
||||||
|
| DONCHIAN, NEW_HIGH, NEW_LOW | ○ | ○ | ○ | ○ | ○ | |
|
||||||
|
| RSI, MACD, CCI, STOCHASTIC | ○ | ○ | ○ | ○ | ○ | |
|
||||||
|
| ADX, DMI, TRIX, OBV, VR | ○ | ○ | ○ | ○ | ○ | |
|
||||||
|
| DISPARITY, PSY* | ○ | ○ | ○ | ○ | ○ | |
|
||||||
|
| VOLUME | ○ | ○ | ○ | ○ | ○ | |
|
||||||
|
| BWI, VOLUME_OSC | △ | △ | △ | △ | △ | |
|
||||||
|
| ICHIMOKU | ○ | ○ | ○ | ○ | ○ | ◎ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 참고 소스
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/utils/strategyTypes.ts` | 지표 목록, `INDICATOR_CONDITIONS`, 필드 옵션 |
|
||||||
|
| `frontend/src/utils/strategyEditorShared.tsx` | 편집기 필드·hline·기본 파라미터 |
|
||||||
|
| `frontend/src/utils/compositeIndicators.ts` | 복합지표 2기간 교차 |
|
||||||
|
| `frontend/src/utils/strategyPresets.ts` | 내장 매수/매도 템플릿 |
|
||||||
|
| `backend/.../StrategyDslToTa4jAdapter.java` | DSL → Ta4j Rule 변환 |
|
||||||
|
| `backend/.../IndicatorService.java` | 차트 지표 계산 (Ta4j) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*문서 버전: goldenChart DSL 기준 (StrategyDslToTa4jAdapter + strategyTypes.ts 동기화)*
|
||||||
Reference in New Issue
Block a user