복합지표 추가
This commit is contained in:
@@ -49,6 +49,15 @@ import {
|
|||||||
loadPaletteItems,
|
loadPaletteItems,
|
||||||
type PaletteItem,
|
type PaletteItem,
|
||||||
} from '../utils/strategyPaletteStorage';
|
} from '../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
buildStochOverboughtPairTree,
|
||||||
|
findStochPairRootInForest,
|
||||||
|
isStochOverboughtPairPaletteValue,
|
||||||
|
isStochOverboughtPairRoot,
|
||||||
|
patchStochPairInTree,
|
||||||
|
setStochPairSecondary,
|
||||||
|
} from '../utils/stochOverboughtPair';
|
||||||
|
import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings';
|
||||||
import {
|
import {
|
||||||
emptySignalFlowLayout,
|
emptySignalFlowLayout,
|
||||||
buildStrategyFlowLayoutStore,
|
buildStrategyFlowLayoutStore,
|
||||||
@@ -302,6 +311,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
);
|
);
|
||||||
}, [selectedNodeId, currentRoot, currentOrphans, currentLayout.extraRoots]);
|
}, [selectedNodeId, currentRoot, currentOrphans, currentLayout.extraRoots]);
|
||||||
|
|
||||||
|
const stochPairForest = useMemo(
|
||||||
|
() => [
|
||||||
|
currentRoot,
|
||||||
|
...currentOrphans,
|
||||||
|
...Object.values(currentLayout.extraRoots ?? {}),
|
||||||
|
],
|
||||||
|
[currentRoot, currentOrphans, currentLayout.extraRoots],
|
||||||
|
);
|
||||||
|
|
||||||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||||||
|
|
||||||
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||||||
@@ -447,6 +465,49 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
scheduleStrategyPersist();
|
scheduleStrategyPersist();
|
||||||
}, [setCurrentLayout, scheduleStrategyPersist]);
|
}, [setCurrentLayout, scheduleStrategyPersist]);
|
||||||
|
|
||||||
|
const applyStochPairSecondary = useCallback((orNodeId: string, secondary: string) => {
|
||||||
|
if (currentOrphans.some(o => o.id === orNodeId)) {
|
||||||
|
handleOrphansChange(currentOrphans.map(o => (
|
||||||
|
o.id === orNodeId ? setStochPairSecondary(o, secondary, DEF) : o
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentRoot) {
|
||||||
|
const patched = patchStochPairInTree(currentRoot, orNodeId, secondary, DEF);
|
||||||
|
if (patched && patched !== currentRoot) {
|
||||||
|
handleRootChange(patched);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||||||
|
if (!branch) continue;
|
||||||
|
const patched = patchStochPairInTree(branch, orNodeId, secondary, DEF);
|
||||||
|
if (patched && patched !== branch) {
|
||||||
|
handleExtraRootsChange({
|
||||||
|
...(currentLayout.extraRoots ?? {}),
|
||||||
|
[startId]: patched,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
currentRoot, currentOrphans, currentLayout.extraRoots, DEF,
|
||||||
|
handleOrphansChange, handleRootChange, handleExtraRootsChange,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stochPairEditForSelected = useMemo(() => {
|
||||||
|
if (!selectedNodeId || selectedLogicNode?.type !== 'CONDITION' || !selectedLogicNode.condition) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const pairRoot = findStochPairRootInForest(stochPairForest, selectedNodeId);
|
||||||
|
if (!pairRoot?.stochPair) return undefined;
|
||||||
|
return {
|
||||||
|
secondaryIndicator: pairRoot.stochPair.secondaryIndicatorType,
|
||||||
|
stochLocked: selectedLogicNode.condition.indicatorType === 'STOCHASTIC',
|
||||||
|
onSecondaryChange: (next: string) => applyStochPairSecondary(pairRoot.id, next),
|
||||||
|
};
|
||||||
|
}, [selectedNodeId, selectedLogicNode, stochPairForest, applyStochPairSecondary]);
|
||||||
|
|
||||||
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
|
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
|
||||||
setCurrentRoot(next.root);
|
setCurrentRoot(next.root);
|
||||||
setCurrentLayout(prev => ({
|
setCurrentLayout(prev => ({
|
||||||
@@ -953,8 +1014,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
|
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
|
||||||
|
|
||||||
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||||||
const composite = item.kind === 'composite';
|
const stochPair = isStochOverboughtPairPaletteValue(item.value);
|
||||||
const newNode = makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined);
|
const composite = item.kind === 'composite' && !stochPair;
|
||||||
|
const newNode = stochPair
|
||||||
|
? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', DEF)
|
||||||
|
: makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||||||
const root = currentRoot;
|
const root = currentRoot;
|
||||||
if (!root) setCurrentRoot(newNode);
|
if (!root) setCurrentRoot(newNode);
|
||||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||||
@@ -1348,11 +1412,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
|
|
||||||
{selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
|
{selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
|
||||||
<div className="se-node-config-bar">
|
<div className="se-node-config-bar">
|
||||||
<span className="se-node-config-label">{selectedLogicNode.condition.indicatorType}</span>
|
<span className="se-node-config-label">
|
||||||
|
{stochPairEditForSelected && !stochPairEditForSelected.stochLocked
|
||||||
|
? 'Stoch 과열×보조'
|
||||||
|
: selectedLogicNode.condition.indicatorType}
|
||||||
|
</span>
|
||||||
<CondEditor
|
<CondEditor
|
||||||
cond={selectedLogicNode.condition}
|
cond={selectedLogicNode.condition}
|
||||||
signalType={signalTab}
|
signalType={signalTab}
|
||||||
def={DEF}
|
def={DEF}
|
||||||
|
stochPairEdit={stochPairEditForSelected}
|
||||||
onChange={c => {
|
onChange={c => {
|
||||||
if (!selectedNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
||||||
@@ -1382,6 +1451,18 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
|
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedLogicNode && isStochOverboughtPairRoot(selectedLogicNode) && selectedLogicNode.stochPair && (
|
||||||
|
<div className="se-node-config-bar se-node-config-bar--stoch-pair">
|
||||||
|
<span className="se-node-config-label">Stoch 과열×보조</span>
|
||||||
|
<StochPairNodeSettings
|
||||||
|
variant="inline"
|
||||||
|
secondaryIndicator={selectedLogicNode.stochPair.secondaryIndicatorType}
|
||||||
|
onChange={next => applyStochPairSecondary(selectedLogicNode.id, next)}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<StrategyListEditor
|
<StrategyListEditor
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
setConditionValuePeriod,
|
setConditionValuePeriod,
|
||||||
usesValuePeriodField,
|
usesValuePeriodField,
|
||||||
} from '../../utils/conditionPeriods';
|
} from '../../utils/conditionPeriods';
|
||||||
import { compositeDisplayName, compositeElementLabel } from '../../utils/compositeIndicators';
|
import { compositeDisplayName, compositeElementLabel, COMPOSITE_INDICATOR_ITEMS, switchCompositeIndicatorType } from '../../utils/compositeIndicators';
|
||||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
|
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
|
||||||
import ComboNumberInput from './ComboNumberInput';
|
import ComboNumberInput from './ComboNumberInput';
|
||||||
|
|
||||||
@@ -83,6 +83,18 @@ export default function ConditionNodeSettings({
|
|||||||
</div>
|
</div>
|
||||||
{condition.composite ? (
|
{condition.composite ? (
|
||||||
<>
|
<>
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>지표</span>
|
||||||
|
<select
|
||||||
|
className="se-combo-num-select sp-cond-sel"
|
||||||
|
value={condition.indicatorType}
|
||||||
|
onChange={e => onChange(switchCompositeIndicatorType(condition, e.target.value, def))}
|
||||||
|
>
|
||||||
|
{COMPOSITE_INDICATOR_ITEMS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="se-flow-settings-field">
|
<label className="se-flow-settings-field">
|
||||||
<span>{compositeElementLabel(condition.indicatorType, 1)} 시간봉</span>
|
<span>{compositeElementLabel(condition.indicatorType, 1)} 시간봉</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
|||||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
isStochOverboughtPairRoot,
|
||||||
|
stochPairDisplayName,
|
||||||
|
} from '../../utils/stochOverboughtPair';
|
||||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
|
import StochPairNodeSettings from './StochPairNodeSettings';
|
||||||
|
|
||||||
const LOGIC_COLORS: Record<string, string> = {
|
const LOGIC_COLORS: Record<string, string> = {
|
||||||
AND: '#00aaff',
|
AND: '#00aaff',
|
||||||
@@ -190,11 +195,13 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
|||||||
const node = d.logicNode!;
|
const node = d.logicNode!;
|
||||||
const color = LOGIC_COLORS[node.type] ?? '#00aaff';
|
const color = LOGIC_COLORS[node.type] ?? '#00aaff';
|
||||||
const isSell = d.signalTab === 'sell';
|
const isSell = d.signalTab === 'sell';
|
||||||
|
const isStochPair = isStochOverboughtPairRoot(node);
|
||||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`se-flow-node se-flow-node--logic${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
className={`se-flow-node se-flow-node--logic${isStochPair ? ' se-flow-node--stoch-pair' : ''}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}${settingsOpen ? ' se-flow-node--settings-open' : ''}`}
|
||||||
style={{ '--se-gate-color': color } as React.CSSProperties}
|
style={{ '--se-gate-color': color } as React.CSSProperties}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
@@ -217,10 +224,39 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
|||||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isStochPair && node.stochPair && (
|
||||||
|
<div className="se-flow-gate-stoch-pair">
|
||||||
|
<span className="se-flow-gate-stoch-pair-title">
|
||||||
|
{stochPairDisplayName(node.stochPair.secondaryIndicatorType)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings"
|
||||||
|
title="복합지표 설정"
|
||||||
|
aria-label="복합지표 설정"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSettingsOpen(v => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(node.children ?? []).length === 0 && (
|
{(node.children ?? []).length === 0 && (
|
||||||
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||||
|
{settingsOpen && isStochPair && node.stochPair && (
|
||||||
|
<StochPairNodeSettings
|
||||||
|
secondaryIndicator={node.stochPair.secondaryIndicatorType}
|
||||||
|
onChange={sec => d.onUpdateStochPairSecondary?.(id, sec)}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import PaletteChip from './PaletteChip';
|
|||||||
import PaletteItemModal from './PaletteItemModal';
|
import PaletteItemModal from './PaletteItemModal';
|
||||||
import type { DefType } from '../../utils/strategyEditorShared';
|
import type { DefType } from '../../utils/strategyEditorShared';
|
||||||
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
||||||
|
import { isStochOverboughtPairPaletteValue } from '../../utils/stochOverboughtPair';
|
||||||
import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods';
|
import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods';
|
||||||
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
||||||
import {
|
import {
|
||||||
createPaletteItemId,
|
createPaletteItemId,
|
||||||
@@ -14,6 +16,10 @@ import {
|
|||||||
|
|
||||||
/** 팔레트 카드 기간 표시 — 항상 보조지표 설정(DEF) 기준 */
|
/** 팔레트 카드 기간 표시 — 항상 보조지표 설정(DEF) 기준 */
|
||||||
function periodLabel(item: PaletteItem, def: DefType): string {
|
function periodLabel(item: PaletteItem, def: DefType): string {
|
||||||
|
if (item.kind === 'composite' && isStochOverboughtPairPaletteValue(item.value)) {
|
||||||
|
const sec = item.secondaryIndicator ?? 'CCI';
|
||||||
|
return `Stoch + ${getStrategyIndicatorDisplayName(sec)}`;
|
||||||
|
}
|
||||||
if (item.kind === 'composite') {
|
if (item.kind === 'composite') {
|
||||||
const d = getCompositeDefaultPeriods(item.value, def);
|
const d = getCompositeDefaultPeriods(item.value, def);
|
||||||
return `${d.short} / ${d.long}`;
|
return `${d.short} / ${d.long}`;
|
||||||
@@ -164,7 +170,9 @@ export default function IndicatorPaletteTab({
|
|||||||
label={item.label}
|
label={item.label}
|
||||||
desc={item.desc}
|
desc={item.desc}
|
||||||
color={kind === 'composite' ? 'composite' : 'ind'}
|
color={kind === 'composite' ? 'composite' : 'ind'}
|
||||||
composite={kind === 'composite'}
|
composite={kind === 'composite' && !isStochOverboughtPairPaletteValue(item.value)}
|
||||||
|
stochPair={isStochOverboughtPairPaletteValue(item.value)}
|
||||||
|
secondaryIndicator={item.secondaryIndicator}
|
||||||
period={periodLabel(item, def)}
|
period={periodLabel(item, def)}
|
||||||
periodValue={item.period}
|
periodValue={item.period}
|
||||||
shortPeriod={item.shortPeriod}
|
shortPeriod={item.shortPeriod}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface PaletteDragPayload {
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
composite?: boolean;
|
composite?: boolean;
|
||||||
|
stochPair?: boolean;
|
||||||
|
secondaryIndicator?: string;
|
||||||
period?: number;
|
period?: number;
|
||||||
shortPeriod?: number;
|
shortPeriod?: number;
|
||||||
longPeriod?: number;
|
longPeriod?: number;
|
||||||
@@ -21,6 +23,8 @@ interface Props {
|
|||||||
shortPeriod?: number;
|
shortPeriod?: number;
|
||||||
longPeriod?: number;
|
longPeriod?: number;
|
||||||
composite?: boolean;
|
composite?: boolean;
|
||||||
|
stochPair?: boolean;
|
||||||
|
secondaryIndicator?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
@@ -28,12 +32,14 @@ interface Props {
|
|||||||
|
|
||||||
export default function PaletteChip({
|
export default function PaletteChip({
|
||||||
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
|
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
|
||||||
composite = false, selected = false, onSelect, onAdd,
|
composite = false, stochPair = false, secondaryIndicator, selected = false, onSelect, onAdd,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const onDragStart = (e: React.DragEvent) => {
|
const onDragStart = (e: React.DragEvent) => {
|
||||||
const payload: PaletteDragPayload = {
|
const payload: PaletteDragPayload = {
|
||||||
type, value, label,
|
type, value, label,
|
||||||
composite: composite || undefined,
|
composite: composite || undefined,
|
||||||
|
stochPair: stochPair || undefined,
|
||||||
|
secondaryIndicator,
|
||||||
period: periodValue,
|
period: periodValue,
|
||||||
shortPeriod,
|
shortPeriod,
|
||||||
longPeriod,
|
longPeriod,
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
type PaletteItem,
|
type PaletteItem,
|
||||||
type PaletteItemKind,
|
type PaletteItemKind,
|
||||||
} from '../../utils/strategyPaletteStorage';
|
} from '../../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
isStochOverboughtPairPaletteValue,
|
||||||
|
STOCH_PAIR_SECONDARY_OPTIONS,
|
||||||
|
stochPairPaletteDesc,
|
||||||
|
} from '../../utils/stochOverboughtPair';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -31,6 +36,9 @@ export default function PaletteItemModal({
|
|||||||
const [period, setPeriod] = useState(14);
|
const [period, setPeriod] = useState(14);
|
||||||
const [shortPeriod, setShortPeriod] = useState(9);
|
const [shortPeriod, setShortPeriod] = useState(9);
|
||||||
const [longPeriod, setLongPeriod] = useState(20);
|
const [longPeriod, setLongPeriod] = useState(20);
|
||||||
|
const [secondaryIndicator, setSecondaryIndicator] = useState('CCI');
|
||||||
|
|
||||||
|
const isStochPairEdit = kind === 'composite' && !!initial && isStochOverboughtPairPaletteValue(initial.value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -38,6 +46,10 @@ export default function PaletteItemModal({
|
|||||||
setValue(initial.value);
|
setValue(initial.value);
|
||||||
setLabel(initial.label);
|
setLabel(initial.label);
|
||||||
setDesc(initial.desc);
|
setDesc(initial.desc);
|
||||||
|
if (isStochOverboughtPairPaletteValue(initial.value)) {
|
||||||
|
setSecondaryIndicator(initial.secondaryIndicator ?? 'CCI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (kind === 'auxiliary') {
|
if (kind === 'auxiliary') {
|
||||||
setPeriod(getDefaultIndicatorPeriod(initial.value, def));
|
setPeriod(getDefaultIndicatorPeriod(initial.value, def));
|
||||||
} else {
|
} else {
|
||||||
@@ -85,7 +97,7 @@ export default function PaletteItemModal({
|
|||||||
window.alert('표시 이름을 입력해 주세요.');
|
window.alert('표시 이름을 입력해 주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === 'composite' && shortPeriod >= longPeriod) {
|
if (kind === 'composite' && !isStochPairEdit && shortPeriod >= longPeriod) {
|
||||||
window.alert('단기 기간은 장기 기간보다 작아야 합니다.');
|
window.alert('단기 기간은 장기 기간보다 작아야 합니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -93,9 +105,15 @@ export default function PaletteItemModal({
|
|||||||
id: initial?.id,
|
id: initial?.id,
|
||||||
value,
|
value,
|
||||||
label: trimmedLabel,
|
label: trimmedLabel,
|
||||||
desc: desc.trim() || trimmedLabel,
|
desc: isStochPairEdit
|
||||||
|
? stochPairPaletteDesc(secondaryIndicator)
|
||||||
|
: (desc.trim() || trimmedLabel),
|
||||||
builtIn: initial?.builtIn,
|
builtIn: initial?.builtIn,
|
||||||
...(kind === 'auxiliary' ? { period } : { shortPeriod, longPeriod }),
|
...(kind === 'auxiliary'
|
||||||
|
? { period }
|
||||||
|
: isStochPairEdit
|
||||||
|
? { secondaryIndicator }
|
||||||
|
: { shortPeriod, longPeriod }),
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -103,17 +121,42 @@ export default function PaletteItemModal({
|
|||||||
return (
|
return (
|
||||||
<DraggableModalFrame onClose={onClose} title={title}>
|
<DraggableModalFrame onClose={onClose} title={title}>
|
||||||
<div className="se-modal-body se-palette-item-modal">
|
<div className="se-modal-body se-palette-item-modal">
|
||||||
<label className="se-field-lbl">지표 종류</label>
|
{!isStochPairEdit && (
|
||||||
<select
|
<>
|
||||||
className="se-field-inp"
|
<label className="se-field-lbl">지표 종류</label>
|
||||||
value={value}
|
<select
|
||||||
disabled={mode === 'edit'}
|
className="se-field-inp"
|
||||||
onChange={e => handleTypeChange(e.target.value)}
|
value={value}
|
||||||
>
|
disabled={mode === 'edit'}
|
||||||
{typeOptions.map(o => (
|
onChange={e => handleTypeChange(e.target.value)}
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
>
|
||||||
))}
|
{typeOptions.map(o => (
|
||||||
</select>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStochPairEdit && (
|
||||||
|
<>
|
||||||
|
<label className="se-field-lbl">고정 지표</label>
|
||||||
|
<output className="se-field-inp se-field-readout">Stochastic %K (과열선)</output>
|
||||||
|
<label className="se-field-lbl">결합 보조지표</label>
|
||||||
|
<select
|
||||||
|
className="se-field-inp"
|
||||||
|
value={secondaryIndicator}
|
||||||
|
onChange={e => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setSecondaryIndicator(next);
|
||||||
|
setDesc(stochPairPaletteDesc(next));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STOCH_PAIR_SECONDARY_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="se-field-lbl">표시 이름</label>
|
<label className="se-field-lbl">표시 이름</label>
|
||||||
<input
|
<input
|
||||||
@@ -142,7 +185,7 @@ export default function PaletteItemModal({
|
|||||||
onChange={setPeriod}
|
onChange={setPeriod}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : isStochPairEdit ? null : (
|
||||||
<>
|
<>
|
||||||
<label className="se-field-lbl">
|
<label className="se-field-lbl">
|
||||||
단기 기간 (일)
|
단기 기간 (일)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
STOCH_PAIR_SECONDARY_OPTIONS,
|
||||||
|
stochPairSummaryText,
|
||||||
|
} from '../../utils/stochOverboughtPair';
|
||||||
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
secondaryIndicator: string;
|
||||||
|
onChange: (secondaryIndicator: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
popoverClassName?: string;
|
||||||
|
/** 그래프 하단 바 등 인라인 배치 */
|
||||||
|
variant?: 'popover' | 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StochPairNodeSettings({
|
||||||
|
secondaryIndicator,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
popoverClassName = 'se-flow-settings-pop',
|
||||||
|
variant = 'popover',
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant === 'inline') return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
|
}, [onClose, variant]);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<label className="se-flow-settings-field se-flow-settings-field--readonly">
|
||||||
|
<span>고정 지표</span>
|
||||||
|
<output className="se-flow-settings-readout">Stochastic %K (과열선)</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>결합 보조지표</span>
|
||||||
|
<select
|
||||||
|
className="se-combo-num-select sp-cond-sel"
|
||||||
|
value={secondaryIndicator}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{STOCH_PAIR_SECONDARY_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="se-flow-settings-hint">
|
||||||
|
{stochPairSummaryText(secondaryIndicator)}
|
||||||
|
</p>
|
||||||
|
{variant === 'popover' && (
|
||||||
|
<p className="se-flow-settings-hint se-flow-settings-hint--muted">
|
||||||
|
Stochastic은 고정이며, {getStrategyIndicatorDisplayName(secondaryIndicator)}만 변경할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<div className="se-stoch-pair-inline-settings" onClick={e => e.stopPropagation()}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={popoverClassName}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Stoch 과열×보조 복합 설정"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="se-flow-settings-head">
|
||||||
|
<span>Stoch 과열×보조</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings-close"
|
||||||
|
aria-label="닫기"
|
||||||
|
title="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
updateNode,
|
updateNode,
|
||||||
type DefType,
|
type DefType,
|
||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
|
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
||||||
import {
|
import {
|
||||||
START_NODE_ID,
|
START_NODE_ID,
|
||||||
applyEdgeHandles,
|
applyEdgeHandles,
|
||||||
@@ -685,36 +686,59 @@ function StrategyEditorCanvasInner({
|
|||||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||||
|
|
||||||
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
||||||
|
const patchGate = (n: LogicNode) => (
|
||||||
|
n.type === 'AND' || n.type === 'OR'
|
||||||
|
? { ...n, type: gateType, stochPair: gateType === 'OR' ? n.stochPair : undefined }
|
||||||
|
: n
|
||||||
|
);
|
||||||
if (isOrphanNode(orphans, id)) {
|
if (isOrphanNode(orphans, id)) {
|
||||||
onOrphansChange(orphans.map(o => (
|
onOrphansChange(orphans.map(o => (
|
||||||
o.id === id && (o.type === 'AND' || o.type === 'OR')
|
o.id === id && (o.type === 'AND' || o.type === 'OR')
|
||||||
? { ...o, type: gateType }
|
? patchGate(o)
|
||||||
: o
|
: o
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (root && findNodeInTree(root, id)) {
|
if (root && findNodeInTree(root, id)) {
|
||||||
onChange(updateNode(root, id, n => (
|
onChange(updateNode(root, id, patchGate));
|
||||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
|
||||||
)));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||||
if (branch && findNodeInTree(branch, id)) {
|
if (branch && findNodeInTree(branch, id)) {
|
||||||
onExtraRootsChange?.({
|
onExtraRootsChange?.({
|
||||||
...extraRoots,
|
...extraRoots,
|
||||||
[sid]: updateNode(branch, id, n => (
|
[sid]: updateNode(branch, id, patchGate),
|
||||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
|
||||||
)),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||||
|
|
||||||
|
const handleUpdateStochPairSecondary = useCallback((id: string, secondaryIndicator: string) => {
|
||||||
|
const patchPair = (n: LogicNode) => setStochPairSecondary(n, secondaryIndicator, def);
|
||||||
|
if (isOrphanNode(orphans, id)) {
|
||||||
|
onOrphansChange(orphans.map(o => (o.id === id ? patchPair(o) : o)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root && findNodeInTree(root, id)) {
|
||||||
|
onChange(updateNode(root, id, patchPair));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||||
|
if (branch && findNodeInTree(branch, id)) {
|
||||||
|
onExtraRootsChange?.({
|
||||||
|
...extraRoots,
|
||||||
|
[sid]: updateNode(branch, id, patchPair),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [root, extraRoots, orphans, def, onChange, onOrphansChange, onExtraRootsChange]);
|
||||||
|
|
||||||
const flowCallbacks = useMemo(() => ({
|
const flowCallbacks = useMemo(() => ({
|
||||||
onDelete: handleDelete,
|
onDelete: handleDelete,
|
||||||
onUpdateCondition: handleUpdateCondition,
|
onUpdateCondition: handleUpdateCondition,
|
||||||
|
onUpdateStochPairSecondary: handleUpdateStochPairSecondary,
|
||||||
onChangeLogicGateType: handleChangeLogicGateType,
|
onChangeLogicGateType: handleChangeLogicGateType,
|
||||||
onDropTarget: handleDropTarget,
|
onDropTarget: handleDropTarget,
|
||||||
onDragOverTarget: handleDragOverTarget,
|
onDragOverTarget: handleDragOverTarget,
|
||||||
@@ -722,7 +746,7 @@ function StrategyEditorCanvasInner({
|
|||||||
onStartCandleTypesChange: handleStartCandleTypesChange,
|
onStartCandleTypesChange: handleStartCandleTypesChange,
|
||||||
onDeleteStart: handleDeleteStart,
|
onDeleteStart: handleDeleteStart,
|
||||||
}), [
|
}), [
|
||||||
handleDelete, handleUpdateCondition, handleChangeLogicGateType,
|
handleDelete, handleUpdateCondition, handleUpdateStochPairSecondary, handleChangeLogicGateType,
|
||||||
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
||||||
handleStartCandleTypesChange, handleDeleteStart,
|
handleStartCandleTypesChange, handleDeleteStart,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import type { LogicNode } from '../../utils/strategyTypes';
|
import type { LogicNode } from '../../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
CondEditor,
|
CondEditor,
|
||||||
@@ -11,7 +11,15 @@ import {
|
|||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
isStochOverboughtPairRoot,
|
||||||
|
findStochPairRootInForest,
|
||||||
|
patchStochPairInTree,
|
||||||
|
setStochPairSecondary,
|
||||||
|
stochPairDisplayName,
|
||||||
|
} from '../../utils/stochOverboughtPair';
|
||||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
|
import StochPairNodeSettings from './StochPairNodeSettings';
|
||||||
import {
|
import {
|
||||||
type EditorConditionState,
|
type EditorConditionState,
|
||||||
addExtraStartSection,
|
addExtraStartSection,
|
||||||
@@ -44,6 +52,8 @@ interface TreeNodeProps {
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
def: DefType;
|
def: DefType;
|
||||||
onAddStart?: () => void;
|
onAddStart?: () => void;
|
||||||
|
stochPairForest?: (LogicNode | null)[];
|
||||||
|
onStochPairSecondaryChange?: (orNodeId: string, secondary: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||||
@@ -58,19 +68,25 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
def,
|
def,
|
||||||
onAddStart,
|
onAddStart,
|
||||||
|
stochPairForest,
|
||||||
|
onStochPairSecondaryChange,
|
||||||
}) => {
|
}) => {
|
||||||
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
||||||
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||||||
const nodeDropKey = `${dropScope}:${node.id}`;
|
const nodeDropKey = `${dropScope}:${node.id}`;
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const isStochPair = isStochOverboughtPairRoot(node);
|
||||||
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
||||||
|
const showStochPairSettings = isStochPair;
|
||||||
const label = node.type === 'CONDITION' && node.condition
|
const label = node.type === 'CONDITION' && node.condition
|
||||||
? nodeToText(node, def)
|
? nodeToText(node, def)
|
||||||
: node.type === 'AND'
|
: isStochPair
|
||||||
? 'AND (그리고)'
|
? stochPairDisplayName(node.stochPair!.secondaryIndicatorType)
|
||||||
: node.type === 'OR'
|
: node.type === 'AND'
|
||||||
? 'OR (또는)'
|
? 'AND (그리고)'
|
||||||
: 'NOT (반대)';
|
: node.type === 'OR'
|
||||||
|
? 'OR (또는)'
|
||||||
|
: 'NOT (반대)';
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
if (!isLogic) return;
|
if (!isLogic) return;
|
||||||
@@ -97,6 +113,18 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCondChange = (c: NonNullable<LogicNode['condition']>) => onUpdate({ ...node, condition: c });
|
const handleCondChange = (c: NonNullable<LogicNode['condition']>) => onUpdate({ ...node, condition: c });
|
||||||
|
|
||||||
|
const stochPairRoot = stochPairForest && node.condition
|
||||||
|
? findStochPairRootInForest(stochPairForest, node.id)
|
||||||
|
: null;
|
||||||
|
const stochPairEdit = stochPairRoot?.stochPair && node.condition && onStochPairSecondaryChange
|
||||||
|
? {
|
||||||
|
secondaryIndicator: stochPairRoot.stochPair.secondaryIndicatorType,
|
||||||
|
stochLocked: node.condition.indicatorType === 'STOCHASTIC',
|
||||||
|
onSecondaryChange: (next: string) => onStochPairSecondaryChange(stochPairRoot.id, next),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const handleChildUpdate = (childId: string, updated: LogicNode) =>
|
const handleChildUpdate = (childId: string, updated: LogicNode) =>
|
||||||
onUpdate({ ...node, children: (node.children ?? []).map(c => (c.id === childId ? updated : c)) });
|
onUpdate({ ...node, children: (node.children ?? []).map(c => (c.id === childId ? updated : c)) });
|
||||||
const handleChildDelete = (childId: string) =>
|
const handleChildDelete = (childId: string) =>
|
||||||
@@ -112,20 +140,26 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
||||||
<span className="sp-node-badge" style={{ background: color }}>
|
<span className="sp-node-badge" style={{ background: color }}>
|
||||||
{node.type === 'CONDITION' && node.condition
|
{isStochPair
|
||||||
? getStrategyIndicatorDisplayName(node.condition.indicatorType)
|
? '복합'
|
||||||
: node.type}
|
: node.type === 'CONDITION' && node.condition
|
||||||
|
? getStrategyIndicatorDisplayName(node.condition.indicatorType)
|
||||||
|
: node.type}
|
||||||
</span>
|
</span>
|
||||||
<span className="sp-node-label">{label}</span>
|
<span className="sp-node-label">{label}</span>
|
||||||
<div className="sp-node-actions">
|
<div className="sp-node-actions">
|
||||||
{(node.type === 'AND' || node.type === 'OR') && (
|
{(node.type === 'AND' || node.type === 'OR') && (
|
||||||
<LogicGateOpToggle
|
<LogicGateOpToggle
|
||||||
value={node.type}
|
value={node.type}
|
||||||
onChange={op => onUpdate({ ...node, type: op })}
|
onChange={op => onUpdate({
|
||||||
|
...node,
|
||||||
|
type: op,
|
||||||
|
stochPair: op === 'OR' ? node.stochPair : undefined,
|
||||||
|
})}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showSettings && (
|
{(showSettings || showStochPairSettings) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sp-node-settings"
|
className="sp-node-settings"
|
||||||
@@ -155,10 +189,24 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
popoverClassName="sp-node-settings-pop"
|
popoverClassName="sp-node-settings-pop"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{settingsOpen && isStochPair && node.stochPair && (
|
||||||
|
<StochPairNodeSettings
|
||||||
|
secondaryIndicator={node.stochPair.secondaryIndicatorType}
|
||||||
|
onChange={sec => onUpdate(setStochPairSecondary(node, sec, def))}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
popoverClassName="sp-node-settings-pop"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{node.type === 'CONDITION' && node.condition && (
|
{node.type === 'CONDITION' && node.condition && (
|
||||||
<CondEditor cond={node.condition} signalType={signalType} onChange={handleCondChange} def={def} />
|
<CondEditor
|
||||||
|
cond={node.condition}
|
||||||
|
signalType={signalType}
|
||||||
|
onChange={handleCondChange}
|
||||||
|
def={def}
|
||||||
|
stochPairEdit={stochPairEdit}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLogic && (
|
{isLogic && (
|
||||||
@@ -177,6 +225,8 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
def={def}
|
def={def}
|
||||||
onAddStart={onAddStart}
|
onAddStart={onAddStart}
|
||||||
|
stochPairForest={stochPairForest}
|
||||||
|
onStochPairSecondaryChange={onStochPairSecondaryChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{dragOverKey === nodeDropKey && (
|
{dragOverKey === nodeDropKey && (
|
||||||
@@ -255,6 +305,11 @@ function StartSectionBlock({
|
|||||||
applyDrop(targetId, data);
|
applyDrop(targetId, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stochPairForest = useMemo(() => [root], [root]);
|
||||||
|
const handleStochPairSecondaryChange = useCallback((orNodeId: string, secondary: string) => {
|
||||||
|
onRootChange(patchStochPairInTree(root, orNodeId, secondary, def));
|
||||||
|
}, [root, def, onRootChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="sp-start-section">
|
<section className="sp-start-section">
|
||||||
<header className="sp-start-head">
|
<header className="sp-start-head">
|
||||||
@@ -296,6 +351,8 @@ function StartSectionBlock({
|
|||||||
dropScope={startId}
|
dropScope={startId}
|
||||||
def={def}
|
def={def}
|
||||||
onAddStart={onAddStart}
|
onAddStart={onAddStart}
|
||||||
|
stochPairForest={stochPairForest}
|
||||||
|
onStochPairSecondaryChange={handleStochPairSecondaryChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1299,6 +1299,36 @@
|
|||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
color: var(--se-text-dim);
|
color: var(--se-text-dim);
|
||||||
}
|
}
|
||||||
|
.se-flow-settings-hint--muted {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.se-field-readout {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--se-text) 4%, transparent);
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.se-flow-gate-stoch-pair {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--se-border) 70%, transparent);
|
||||||
|
}
|
||||||
|
.se-flow-gate-stoch-pair-title {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--se-palette-composite-accent, #a78bfa);
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.se-flow-node--stoch-pair {
|
||||||
|
min-height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
.se-flow-handle {
|
.se-flow-handle {
|
||||||
width: 9px !important; height: 9px !important;
|
width: 9px !important; height: 9px !important;
|
||||||
@@ -1391,14 +1421,40 @@
|
|||||||
}
|
}
|
||||||
.se-node-config-bar .sp-cond-editor { margin: 0; }
|
.se-node-config-bar .sp-cond-editor { margin: 0; }
|
||||||
|
|
||||||
.sp-cond-row-composite-tf {
|
.sp-cond-readout {
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
width: 100%;
|
||||||
gap: 8px;
|
padding: 6px 8px;
|
||||||
margin-bottom: 8px;
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-input-border);
|
||||||
|
background: color-mix(in srgb, var(--se-text) 4%, transparent);
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
.se-node-config-bar .sp-cond-sel,
|
|
||||||
.se-node-config-bar .sp-cond-num {
|
.se-node-config-bar--stoch-pair {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
.se-stoch-pair-inline-settings {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.se-stoch-pair-inline-settings .se-flow-settings-field {
|
||||||
|
min-width: 140px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.se-stoch-pair-inline-settings .se-flow-settings-hint {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-cond-row-composite-tf {
|
||||||
background: var(--se-input-bg);
|
background: var(--se-input-bg);
|
||||||
border-color: var(--se-input-border);
|
border-color: var(--se-input-border);
|
||||||
color: var(--se-text);
|
color: var(--se-text);
|
||||||
|
|||||||
@@ -282,6 +282,43 @@ export function makeCompositeCondition(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 기간 교차 복합지표 — 지표 종류 변경 (조건·시간봉 유지, 기간은 새 지표 기본값) */
|
||||||
|
export function switchCompositeIndicatorType(
|
||||||
|
cond: ConditionDSL,
|
||||||
|
newIndicatorType: string,
|
||||||
|
def: CompositePeriodDef,
|
||||||
|
): ConditionDSL {
|
||||||
|
if (!cond.composite || newIndicatorType === cond.indicatorType) {
|
||||||
|
return normalizeCompositeCondition(cond);
|
||||||
|
}
|
||||||
|
const { short, long } = getCompositeDefaultPeriods(newIndicatorType, def);
|
||||||
|
const base = {
|
||||||
|
conditionType: cond.conditionType,
|
||||||
|
leftCandleType: cond.leftCandleType ?? DEFAULT_START_CANDLE,
|
||||||
|
rightCandleType: cond.rightCandleType ?? DEFAULT_START_CANDLE,
|
||||||
|
candleRange: cond.candleRange ?? 1,
|
||||||
|
valuePeriodOverride: false as const,
|
||||||
|
rightPeriodOverride: false as const,
|
||||||
|
thresholdOverride: false as const,
|
||||||
|
};
|
||||||
|
if (isDonchianComposite(newIndicatorType)) {
|
||||||
|
return syncDonchianCompositeFields({
|
||||||
|
indicatorType: newIndicatorType,
|
||||||
|
composite: true,
|
||||||
|
...base,
|
||||||
|
leftPeriod: short,
|
||||||
|
rightPeriod: long,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return syncCompositeFields({
|
||||||
|
indicatorType: newIndicatorType,
|
||||||
|
composite: true,
|
||||||
|
...base,
|
||||||
|
leftPeriod: short,
|
||||||
|
rightPeriod: long,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 돈치안 복합 — 조건대상 드롭다운 옵션 */
|
/** 돈치안 복합 — 조건대상 드롭다운 옵션 */
|
||||||
export function getDonchianFieldOpts(
|
export function getDonchianFieldOpts(
|
||||||
allPeriods: number[],
|
allPeriods: number[],
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/** Stochastic 과열(≥80) × 보조지표 중앙선 — 순차 OR 복합 매수 조건 */
|
||||||
|
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
||||||
|
import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods';
|
||||||
|
import { genId } from './strategyNodeIds';
|
||||||
|
import { THRESHOLD_HL_MID, THRESHOLD_HL_OVER } from './thresholdSymbols';
|
||||||
|
|
||||||
|
export const STOCH_OVERBOUGHT_PAIR_VALUE = 'STOCH_OVERBOUGHT_PAIR';
|
||||||
|
|
||||||
|
export const STOCH_PAIR_SECONDARY_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'CCI', label: 'CCI' },
|
||||||
|
{ value: 'RSI', label: 'RSI' },
|
||||||
|
{ value: 'ADX', label: 'ADX' },
|
||||||
|
{ value: 'WILLIAMS_R', label: 'Williams %R' },
|
||||||
|
{ value: 'TRIX', label: 'TRIX' },
|
||||||
|
{ value: 'VR', label: 'VR' },
|
||||||
|
{ value: 'PSYCHOLOGICAL', label: '심리도' },
|
||||||
|
{ value: 'NEW_PSYCHOLOGICAL', label: '신심리도' },
|
||||||
|
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도' },
|
||||||
|
{ value: 'BWI', label: 'BWI' },
|
||||||
|
{ value: 'VOLUME_OSC', label: 'Volume OSC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECONDARY_VALUE_FIELD: Record<string, string> = {
|
||||||
|
RSI: 'RSI_VALUE',
|
||||||
|
CCI: 'CCI_VALUE',
|
||||||
|
ADX: 'ADX_VALUE',
|
||||||
|
WILLIAMS_R: 'WILLIAMS_R_VALUE',
|
||||||
|
TRIX: 'TRIX_VALUE',
|
||||||
|
VR: 'VR_VALUE',
|
||||||
|
PSYCHOLOGICAL: 'PSY_VALUE',
|
||||||
|
NEW_PSYCHOLOGICAL: 'NEW_PSY_VALUE',
|
||||||
|
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
|
||||||
|
BWI: 'BWI_VALUE',
|
||||||
|
VOLUME_OSC: 'VOLUME_OSC_VALUE',
|
||||||
|
};
|
||||||
|
|
||||||
|
function secondaryLabel(indicatorType: string): string {
|
||||||
|
return STOCH_PAIR_SECONDARY_OPTIONS.find(o => o.value === indicatorType)?.label ?? indicatorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStochOverboughtPairPaletteValue(value: string): boolean {
|
||||||
|
return value === STOCH_OVERBOUGHT_PAIR_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStochOverboughtPairRoot(node: LogicNode): boolean {
|
||||||
|
return node.type === 'OR' && !!node.stochPair?.secondaryIndicatorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecondaryValueField(indicatorType: string): string {
|
||||||
|
return SECONDARY_VALUE_FIELD[indicatorType] ?? `${indicatorType}_VALUE`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePairCondition(
|
||||||
|
indicatorType: string,
|
||||||
|
conditionType: string,
|
||||||
|
leftField: string,
|
||||||
|
rightField: string,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
): LogicNode {
|
||||||
|
const base: ConditionDSL = {
|
||||||
|
indicatorType,
|
||||||
|
conditionType,
|
||||||
|
leftField,
|
||||||
|
rightField,
|
||||||
|
candleRange: 1,
|
||||||
|
valuePeriodOverride: false,
|
||||||
|
thresholdOverride: false,
|
||||||
|
rightPeriodOverride: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'CONDITION',
|
||||||
|
condition: initConditionPeriodsInherit(indicatorType, base, def),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Stoch≥과열 AND 보조 CROSS_UP 중앙) OR (보조≥중앙 AND Stoch CROSS_UP 과열) */
|
||||||
|
export function buildStochOverboughtPairTree(
|
||||||
|
secondaryIndicator: string,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
): LogicNode {
|
||||||
|
const sec = STOCH_PAIR_SECONDARY_OPTIONS.some(o => o.value === secondaryIndicator)
|
||||||
|
? secondaryIndicator
|
||||||
|
: 'CCI';
|
||||||
|
const secField = getSecondaryValueField(sec);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'OR',
|
||||||
|
stochPair: { secondaryIndicatorType: sec },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: genId(),
|
||||||
|
type: 'AND',
|
||||||
|
children: [
|
||||||
|
makePairCondition('STOCHASTIC', 'GTE', 'STOCH_K', THRESHOLD_HL_OVER, def),
|
||||||
|
makePairCondition(sec, 'CROSS_UP', secField, THRESHOLD_HL_MID, def),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: genId(),
|
||||||
|
type: 'AND',
|
||||||
|
children: [
|
||||||
|
makePairCondition(sec, 'GTE', secField, THRESHOLD_HL_MID, def),
|
||||||
|
makePairCondition('STOCHASTIC', 'CROSS_UP', 'STOCH_K', THRESHOLD_HL_OVER, def),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보조지표 변경 시 기존 OR·AND 노드 id 유지 */
|
||||||
|
export function setStochPairSecondary(
|
||||||
|
root: LogicNode,
|
||||||
|
secondaryIndicator: string,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
): LogicNode {
|
||||||
|
if (!isStochOverboughtPairRoot(root)) return root;
|
||||||
|
|
||||||
|
const rebuilt = buildStochOverboughtPairTree(secondaryIndicator, def);
|
||||||
|
const preserveIds = (next: LogicNode, prev: LogicNode | undefined): LogicNode => ({
|
||||||
|
...next,
|
||||||
|
id: prev?.id ?? next.id,
|
||||||
|
children: next.children?.map((child, i) =>
|
||||||
|
preserveIds(child, prev?.children?.[i]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return preserveIds(rebuilt, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stochPairDisplayName(secondaryIndicator: string): string {
|
||||||
|
return `Stoch 과열×${secondaryLabel(secondaryIndicator)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stochPairSummaryText(secondaryIndicator: string): string {
|
||||||
|
const sec = secondaryLabel(secondaryIndicator);
|
||||||
|
return `(Stoch≥과열 AND ${sec} 중앙 상향) OR (${sec}≥중앙 AND Stoch 과열 상향)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stochPairPaletteDesc(secondaryIndicator: string): string {
|
||||||
|
const sec = secondaryLabel(secondaryIndicator);
|
||||||
|
return `Stoch≥80 + ${sec} 0선(중앙) 돌파 또는 ${sec}≥중앙 + Stoch 과열 돌파`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeContainsId(node: LogicNode, targetId: string): boolean {
|
||||||
|
if (node.id === targetId) return true;
|
||||||
|
return (node.children ?? []).some(c => nodeContainsId(c, targetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nodeId를 포함하는 Stoch 과열×보조 OR 루트 탐색 */
|
||||||
|
export function findStochPairRootContaining(
|
||||||
|
node: LogicNode | null | undefined,
|
||||||
|
targetId: string,
|
||||||
|
): LogicNode | null {
|
||||||
|
if (!node) return null;
|
||||||
|
if (isStochOverboughtPairRoot(node) && nodeContainsId(node, targetId)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
for (const child of node.children ?? []) {
|
||||||
|
const hit = findStochPairRootContaining(child, targetId);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findStochPairRootInForest(
|
||||||
|
roots: (LogicNode | null | undefined)[],
|
||||||
|
targetId: string,
|
||||||
|
): LogicNode | null {
|
||||||
|
for (const root of roots) {
|
||||||
|
const hit = findStochPairRootContaining(root, targetId);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OR 서브트리 전체를 새 보조지표로 재구성 (노드 id 유지) */
|
||||||
|
export function patchStochPairInTree(
|
||||||
|
root: LogicNode | null,
|
||||||
|
orNodeId: string,
|
||||||
|
secondaryIndicator: string,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
): LogicNode | null {
|
||||||
|
if (!root) return null;
|
||||||
|
if (root.id === orNodeId && isStochOverboughtPairRoot(root)) {
|
||||||
|
return setStochPairSecondary(root, secondaryIndicator, def);
|
||||||
|
}
|
||||||
|
if (!root.children?.length) return root;
|
||||||
|
return {
|
||||||
|
...root,
|
||||||
|
children: root.children.map(c => patchStochPairInTree(c, orNodeId, secondaryIndicator, def) ?? c),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { IndicatorConfig } from '../types/index';
|
|||||||
import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
|
import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
|
||||||
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
|
COMPOSITE_INDICATOR_ITEMS,
|
||||||
compositeDisplayName,
|
compositeDisplayName,
|
||||||
compositeElementLabel,
|
compositeElementLabel,
|
||||||
compositeFieldLabel,
|
compositeFieldLabel,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
normalizeCompositeCondition,
|
normalizeCompositeCondition,
|
||||||
parseDonchianPeriod,
|
parseDonchianPeriod,
|
||||||
parsePeriodFromCompositeField,
|
parsePeriodFromCompositeField,
|
||||||
|
switchCompositeIndicatorType,
|
||||||
syncCompositeFields,
|
syncCompositeFields,
|
||||||
} from '../utils/compositeIndicators';
|
} from '../utils/compositeIndicators';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +23,14 @@ import {
|
|||||||
syncPriceExtremeSimpleFields,
|
syncPriceExtremeSimpleFields,
|
||||||
} from '../utils/priceExtremeIndicators';
|
} from '../utils/priceExtremeIndicators';
|
||||||
import { getStrategyIndicatorDisplayName } from '../utils/strategyPaletteStorage';
|
import { getStrategyIndicatorDisplayName } from '../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
buildStochOverboughtPairTree,
|
||||||
|
isStochOverboughtPairPaletteValue,
|
||||||
|
isStochOverboughtPairRoot,
|
||||||
|
STOCH_PAIR_SECONDARY_OPTIONS,
|
||||||
|
stochPairDisplayName,
|
||||||
|
stochPairSummaryText,
|
||||||
|
} from '../utils/stochOverboughtPair';
|
||||||
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
||||||
import {
|
import {
|
||||||
getCompositeFieldOpts,
|
getCompositeFieldOpts,
|
||||||
@@ -57,6 +67,9 @@ import {
|
|||||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
|
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
|
||||||
|
|
||||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||||
|
import { genId } from './strategyNodeIds';
|
||||||
|
|
||||||
|
export { genId };
|
||||||
|
|
||||||
export interface StrategyDto {
|
export interface StrategyDto {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -78,9 +91,6 @@ export interface ValidationResult {
|
|||||||
|
|
||||||
export type DefType = typeof DEF_DEFAULTS;
|
export type DefType = typeof DEF_DEFAULTS;
|
||||||
|
|
||||||
let _cnt = 0;
|
|
||||||
export const genId = () => `n_${++_cnt}_${Date.now()}`;
|
|
||||||
|
|
||||||
/** @deprecated DB(gc_strategy)만 사용 */
|
/** @deprecated DB(gc_strategy)만 사용 */
|
||||||
export const loadStratsLocal = (): StrategyDto[] => [];
|
export const loadStratsLocal = (): StrategyDto[] => [];
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
@@ -729,6 +739,10 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
|||||||
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
|
return parts.length === 0 ? '(AND 그룹)' : parts.join('\nAND ');
|
||||||
}
|
}
|
||||||
if (node.type === 'OR') {
|
if (node.type === 'OR') {
|
||||||
|
if (isStochOverboughtPairRoot(node)) {
|
||||||
|
const sec = node.stochPair!.secondaryIndicatorType;
|
||||||
|
return `${stochPairDisplayName(sec)}\n${stochPairSummaryText(sec)}`;
|
||||||
|
}
|
||||||
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
||||||
return parts.length === 0 ? '(OR 그룹)' : parts.join('\nOR ');
|
return parts.length === 0 ? '(OR 그룹)' : parts.join('\nOR ');
|
||||||
}
|
}
|
||||||
@@ -818,9 +832,17 @@ interface CondEditorProps {
|
|||||||
signalType: 'buy'|'sell';
|
signalType: 'buy'|'sell';
|
||||||
onChange: (c: ConditionDSL) => void;
|
onChange: (c: ConditionDSL) => void;
|
||||||
def: DefType;
|
def: DefType;
|
||||||
|
/** Stoch 과열×보조 복합 — 캔들범위 대신 보조지표 선택 */
|
||||||
|
stochPairEdit?: {
|
||||||
|
secondaryIndicator: string;
|
||||||
|
onSecondaryChange: (indicator: string) => void;
|
||||||
|
stochLocked?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
|
export const CondEditor: React.FC<CondEditorProps> = ({
|
||||||
|
cond, signalType, onChange, def, stochPairEdit,
|
||||||
|
}) => {
|
||||||
const normalized = normalizeCompositeCondition(cond);
|
const normalized = normalizeCompositeCondition(cond);
|
||||||
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
|
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
|
||||||
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
||||||
@@ -936,14 +958,15 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
</div>
|
</div>
|
||||||
<div className="sp-cond-row4">
|
<div className="sp-cond-row4">
|
||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
<label className="sp-cond-lbl">캔들 범위</label>
|
<label className="sp-cond-lbl">지표</label>
|
||||||
<select className="sp-cond-sel" value={normalized.candleRange ?? 1}
|
<select
|
||||||
onChange={e => onChange({ ...normalized, candleRange: Number(e.target.value) })}>
|
className="sp-cond-sel"
|
||||||
<option value={1}>현재 캔들</option>
|
value={normalized.indicatorType}
|
||||||
<option value={2}>2개 캔들</option>
|
onChange={e => onChange(switchCompositeIndicatorType(normalized, e.target.value, def))}
|
||||||
<option value={3}>3개 캔들</option>
|
>
|
||||||
<option value={4}>4개 캔들</option>
|
{COMPOSITE_INDICATOR_ITEMS.map(o => (
|
||||||
<option value={5}>5개 캔들</option>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
@@ -995,17 +1018,41 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
<div className="sp-cond-editor">
|
<div className="sp-cond-editor">
|
||||||
{/* 4컬럼 row */}
|
{/* 4컬럼 row */}
|
||||||
<div className="sp-cond-row4">
|
<div className="sp-cond-row4">
|
||||||
{/* 캔들 범위 */}
|
{/* 캔들 범위 / Stoch×보조 복합 보조지표 */}
|
||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
<label className="sp-cond-lbl">캔들 범위</label>
|
{stochPairEdit ? (
|
||||||
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
|
stochPairEdit.stochLocked ? (
|
||||||
onChange={e => onChange({ ...cond, candleRange: Number(e.target.value) })}>
|
<>
|
||||||
<option value={1}>현재 캔들</option>
|
<label className="sp-cond-lbl">고정 지표</label>
|
||||||
<option value={2}>2개 캔들</option>
|
<output className="sp-cond-readout">Stochastic %K</output>
|
||||||
<option value={3}>3개 캔들</option>
|
</>
|
||||||
<option value={4}>4개 캔들</option>
|
) : (
|
||||||
<option value={5}>5개 캔들</option>
|
<>
|
||||||
</select>
|
<label className="sp-cond-lbl">보조지표</label>
|
||||||
|
<select
|
||||||
|
className="sp-cond-sel"
|
||||||
|
value={stochPairEdit.secondaryIndicator}
|
||||||
|
onChange={e => stochPairEdit.onSecondaryChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{STOCH_PAIR_SECONDARY_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="sp-cond-lbl">캔들 범위</label>
|
||||||
|
<select className="sp-cond-sel" value={cond.candleRange ?? 1}
|
||||||
|
onChange={e => onChange({ ...cond, candleRange: Number(e.target.value) })}>
|
||||||
|
<option value={1}>현재 캔들</option>
|
||||||
|
<option value={2}>2개 캔들</option>
|
||||||
|
<option value={3}>3개 캔들</option>
|
||||||
|
<option value={4}>4개 캔들</option>
|
||||||
|
<option value={5}>5개 캔들</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 조건대상1 */}
|
{/* 조건대상1 */}
|
||||||
<div className="sp-cond-field">
|
<div className="sp-cond-field">
|
||||||
@@ -1117,17 +1164,29 @@ export type MakeNodeOptions = {
|
|||||||
rightPeriod?: number;
|
rightPeriod?: number;
|
||||||
leftCandleType?: string;
|
leftCandleType?: string;
|
||||||
rightCandleType?: string;
|
rightCandleType?: string;
|
||||||
|
/** Stoch 과열×보조 복합 */
|
||||||
|
stochPair?: boolean;
|
||||||
|
secondaryIndicator?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 팔레트 드래그 payload → makeNode 옵션 */
|
/** 팔레트 드래그 payload → makeNode 옵션 */
|
||||||
export function makeNodeOptionsFromPalette(data: {
|
export function makeNodeOptionsFromPalette(data: {
|
||||||
composite?: boolean;
|
composite?: boolean;
|
||||||
|
stochPair?: boolean;
|
||||||
|
secondaryIndicator?: string;
|
||||||
period?: number;
|
period?: number;
|
||||||
shortPeriod?: number;
|
shortPeriod?: number;
|
||||||
longPeriod?: number;
|
longPeriod?: number;
|
||||||
leftCandleType?: string;
|
leftCandleType?: string;
|
||||||
rightCandleType?: string;
|
rightCandleType?: string;
|
||||||
|
value?: string;
|
||||||
}): MakeNodeOptions | undefined {
|
}): MakeNodeOptions | undefined {
|
||||||
|
if (data.stochPair || (data.value && isStochOverboughtPairPaletteValue(data.value))) {
|
||||||
|
return {
|
||||||
|
stochPair: true,
|
||||||
|
secondaryIndicator: data.secondaryIndicator ?? 'CCI',
|
||||||
|
};
|
||||||
|
}
|
||||||
if (data.composite) {
|
if (data.composite) {
|
||||||
return {
|
return {
|
||||||
composite: true,
|
composite: true,
|
||||||
@@ -1146,6 +1205,9 @@ export const makeNode = (
|
|||||||
options?: MakeNodeOptions,
|
options?: MakeNodeOptions,
|
||||||
): LogicNode => {
|
): LogicNode => {
|
||||||
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
|
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
|
||||||
|
if (options?.stochPair || isStochOverboughtPairPaletteValue(value)) {
|
||||||
|
return buildStochOverboughtPairTree(options?.secondaryIndicator ?? 'CCI', DEF);
|
||||||
|
}
|
||||||
if (options?.composite) {
|
if (options?.composite) {
|
||||||
let condition = makeCompositeCondition(value, signalType, DEF);
|
let condition = makeCompositeCondition(value, signalType, DEF);
|
||||||
condition = {
|
condition = {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export type StrategyFlowNodeData = {
|
|||||||
onDeleteStart?: (startId: string) => void;
|
onDeleteStart?: (startId: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
||||||
|
/** Stoch 과열×보조 복합 — 보조지표 변경 */
|
||||||
|
onUpdateStochPairSecondary?: (nodeId: string, secondaryIndicator: string) => void;
|
||||||
/** AND ↔ OR 전환 */
|
/** AND ↔ OR 전환 */
|
||||||
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
|
onChangeLogicGateType?: (nodeId: string, gateType: 'AND' | 'OR') => void;
|
||||||
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
||||||
@@ -472,7 +474,7 @@ function layoutTree(
|
|||||||
signalTab: 'buy' | 'sell',
|
signalTab: 'buy' | 'sell',
|
||||||
callbacks: Pick<
|
callbacks: Pick<
|
||||||
StrategyFlowNodeData,
|
StrategyFlowNodeData,
|
||||||
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
|
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType'
|
||||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||||
>,
|
>,
|
||||||
dragOverId: string | null,
|
dragOverId: string | null,
|
||||||
@@ -494,6 +496,7 @@ function layoutTree(
|
|||||||
signalTab,
|
signalTab,
|
||||||
onDelete: callbacks.onDelete,
|
onDelete: callbacks.onDelete,
|
||||||
onUpdateCondition: callbacks.onUpdateCondition,
|
onUpdateCondition: callbacks.onUpdateCondition,
|
||||||
|
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
||||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||||
onDropTarget: callbacks.onDropTarget,
|
onDropTarget: callbacks.onDropTarget,
|
||||||
onDragOverTarget: callbacks.onDragOverTarget,
|
onDragOverTarget: callbacks.onDragOverTarget,
|
||||||
@@ -623,7 +626,7 @@ function appendOrphanFlowNodes(
|
|||||||
signalTab: 'buy' | 'sell',
|
signalTab: 'buy' | 'sell',
|
||||||
callbacks: Pick<
|
callbacks: Pick<
|
||||||
StrategyFlowNodeData,
|
StrategyFlowNodeData,
|
||||||
'onDelete' | 'onUpdateCondition' | 'onChangeLogicGateType'
|
'onDelete' | 'onUpdateCondition' | 'onUpdateStochPairSecondary' | 'onChangeLogicGateType'
|
||||||
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
| 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'
|
||||||
>,
|
>,
|
||||||
): void {
|
): void {
|
||||||
@@ -645,6 +648,7 @@ function appendOrphanFlowNodes(
|
|||||||
signalTab,
|
signalTab,
|
||||||
onDelete: callbacks.onDelete,
|
onDelete: callbacks.onDelete,
|
||||||
onUpdateCondition: callbacks.onUpdateCondition,
|
onUpdateCondition: callbacks.onUpdateCondition,
|
||||||
|
onUpdateStochPairSecondary: callbacks.onUpdateStochPairSecondary,
|
||||||
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
onChangeLogicGateType: callbacks.onChangeLogicGateType,
|
||||||
onDropTarget: callbacks.onDropTarget,
|
onDropTarget: callbacks.onDropTarget,
|
||||||
onDragOverTarget: callbacks.onDragOverTarget,
|
onDragOverTarget: callbacks.onDragOverTarget,
|
||||||
@@ -668,6 +672,7 @@ export function logicNodeToFlow(
|
|||||||
StrategyFlowNodeData,
|
StrategyFlowNodeData,
|
||||||
| 'onDelete'
|
| 'onDelete'
|
||||||
| 'onUpdateCondition'
|
| 'onUpdateCondition'
|
||||||
|
| 'onUpdateStochPairSecondary'
|
||||||
| 'onDropTarget'
|
| 'onDropTarget'
|
||||||
| 'onDragOverTarget'
|
| 'onDragOverTarget'
|
||||||
| 'onDragLeaveTarget'
|
| 'onDragLeaveTarget'
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** 전략 DSL 노드 id — 순환 import 방지를 위해 별도 모듈 */
|
||||||
|
let _cnt = 0;
|
||||||
|
export const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (DB ui_preferences) */
|
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (DB ui_preferences) */
|
||||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||||
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
|
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
|
||||||
|
import {
|
||||||
|
STOCH_OVERBOUGHT_PAIR_VALUE,
|
||||||
|
stochPairPaletteDesc,
|
||||||
|
} from './stochOverboughtPair';
|
||||||
|
|
||||||
export type PaletteItemKind = 'auxiliary' | 'composite';
|
export type PaletteItemKind = 'auxiliary' | 'composite';
|
||||||
|
|
||||||
@@ -16,6 +20,8 @@ export interface PaletteItem {
|
|||||||
/** 복합지표 단기·장기 기간 */
|
/** 복합지표 단기·장기 기간 */
|
||||||
shortPeriod?: number;
|
shortPeriod?: number;
|
||||||
longPeriod?: number;
|
longPeriod?: number;
|
||||||
|
/** Stoch 과열×보조 복합 — 기본 보조지표 (CCI 등) */
|
||||||
|
secondaryIndicator?: string;
|
||||||
builtIn?: boolean;
|
builtIn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,20 +46,29 @@ export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
|||||||
{ value: 'NEW_LOW', label: '신저가', desc: '직전 N봉 최저가 이탈 — 종가/저가 vs N일 신저가선 (9·20일)', builtIn: true, period: 9 },
|
{ value: 'NEW_LOW', label: '신저가', desc: '직전 N봉 최저가 이탈 — 종가/저가 vs N일 신저가선 (9·20일)', builtIn: true, period: 9 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] =
|
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||||
COMPOSITE_INDICATOR_ITEMS.map(i => ({
|
{
|
||||||
|
value: STOCH_OVERBOUGHT_PAIR_VALUE,
|
||||||
|
label: 'Stoch 과열×보조',
|
||||||
|
desc: stochPairPaletteDesc('CCI'),
|
||||||
|
builtIn: true,
|
||||||
|
secondaryIndicator: 'CCI',
|
||||||
|
},
|
||||||
|
...COMPOSITE_INDICATOR_ITEMS.map(i => ({
|
||||||
value: i.value,
|
value: i.value,
|
||||||
label: i.label,
|
label: i.label,
|
||||||
desc: i.desc,
|
desc: i.desc,
|
||||||
builtIn: true,
|
builtIn: true,
|
||||||
}));
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
export const AUXILIARY_TYPE_OPTIONS = DEFAULT_AUXILIARY_ITEMS.map(i => ({
|
export const AUXILIARY_TYPE_OPTIONS = DEFAULT_AUXILIARY_ITEMS.map(i => ({
|
||||||
value: i.value,
|
value: i.value,
|
||||||
label: i.label,
|
label: i.label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const COMPOSITE_TYPE_OPTIONS = DEFAULT_COMPOSITE_ITEMS.map(i => ({
|
/** 팔레트 추가 모달 — 동일 지표 2기간 교차만 선택 (Stoch×보조는 내장 칩) */
|
||||||
|
export const COMPOSITE_TYPE_OPTIONS = COMPOSITE_INDICATOR_ITEMS.map(i => ({
|
||||||
value: i.value,
|
value: i.value,
|
||||||
label: i.label,
|
label: i.label,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export interface LogicNode {
|
|||||||
candleType?: string;
|
candleType?: string;
|
||||||
/** TIMEFRAME 노드 — 동일 조건을 여러 분봉 마감마다 평가 */
|
/** TIMEFRAME 노드 — 동일 조건을 여러 분봉 마감마다 평가 */
|
||||||
candleTypes?: string[];
|
candleTypes?: string[];
|
||||||
|
/** Stoch 과열×보조지표 순차 OR 복합 — OR 루트에만 설정 */
|
||||||
|
stochPair?: {
|
||||||
|
secondaryIndicatorType: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrategyDSLDto {
|
export interface StrategyDSLDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user