복합지표 추가
This commit is contained in:
@@ -49,6 +49,15 @@ import {
|
||||
loadPaletteItems,
|
||||
type PaletteItem,
|
||||
} from '../utils/strategyPaletteStorage';
|
||||
import {
|
||||
buildStochOverboughtPairTree,
|
||||
findStochPairRootInForest,
|
||||
isStochOverboughtPairPaletteValue,
|
||||
isStochOverboughtPairRoot,
|
||||
patchStochPairInTree,
|
||||
setStochPairSecondary,
|
||||
} from '../utils/stochOverboughtPair';
|
||||
import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings';
|
||||
import {
|
||||
emptySignalFlowLayout,
|
||||
buildStrategyFlowLayoutStore,
|
||||
@@ -302,6 +311,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
);
|
||||
}, [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 layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||||
@@ -447,6 +465,49 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
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) => {
|
||||
setCurrentRoot(next.root);
|
||||
setCurrentLayout(prev => ({
|
||||
@@ -953,8 +1014,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
|
||||
|
||||
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||||
const composite = item.kind === 'composite';
|
||||
const newNode = makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||||
const stochPair = isStochOverboughtPairPaletteValue(item.value);
|
||||
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;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
@@ -1348,11 +1412,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
|
||||
{selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
|
||||
<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
|
||||
cond={selectedLogicNode.condition}
|
||||
signalType={signalTab}
|
||||
def={DEF}
|
||||
stochPairEdit={stochPairEditForSelected}
|
||||
onChange={c => {
|
||||
if (!selectedNodeId) return;
|
||||
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>
|
||||
</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
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
setConditionValuePeriod,
|
||||
usesValuePeriodField,
|
||||
} 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 ComboNumberInput from './ComboNumberInput';
|
||||
|
||||
@@ -83,6 +83,18 @@ export default function ConditionNodeSettings({
|
||||
</div>
|
||||
{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">
|
||||
<span>{compositeElementLabel(condition.indicatorType, 1)} 시간봉</span>
|
||||
<select
|
||||
|
||||
@@ -10,7 +10,12 @@ import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||
import {
|
||||
isStochOverboughtPairRoot,
|
||||
stochPairDisplayName,
|
||||
} from '../../utils/stochOverboughtPair';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
import StochPairNodeSettings from './StochPairNodeSettings';
|
||||
|
||||
const LOGIC_COLORS: Record<string, string> = {
|
||||
AND: '#00aaff',
|
||||
@@ -190,11 +195,13 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
||||
const node = d.logicNode!;
|
||||
const color = LOGIC_COLORS[node.type] ?? '#00aaff';
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const isStochPair = isStochOverboughtPairRoot(node);
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<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}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
@@ -217,10 +224,39 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
)}
|
||||
</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 && (
|
||||
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import PaletteChip from './PaletteChip';
|
||||
import PaletteItemModal from './PaletteItemModal';
|
||||
import type { DefType } from '../../utils/strategyEditorShared';
|
||||
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
||||
import { isStochOverboughtPairPaletteValue } from '../../utils/stochOverboughtPair';
|
||||
import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods';
|
||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
||||
import {
|
||||
createPaletteItemId,
|
||||
@@ -14,6 +16,10 @@ import {
|
||||
|
||||
/** 팔레트 카드 기간 표시 — 항상 보조지표 설정(DEF) 기준 */
|
||||
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') {
|
||||
const d = getCompositeDefaultPeriods(item.value, def);
|
||||
return `${d.short} / ${d.long}`;
|
||||
@@ -164,7 +170,9 @@ export default function IndicatorPaletteTab({
|
||||
label={item.label}
|
||||
desc={item.desc}
|
||||
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)}
|
||||
periodValue={item.period}
|
||||
shortPeriod={item.shortPeriod}
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface PaletteDragPayload {
|
||||
value: string;
|
||||
label: string;
|
||||
composite?: boolean;
|
||||
stochPair?: boolean;
|
||||
secondaryIndicator?: string;
|
||||
period?: number;
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
@@ -21,6 +23,8 @@ interface Props {
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
composite?: boolean;
|
||||
stochPair?: boolean;
|
||||
secondaryIndicator?: string;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onAdd: () => void;
|
||||
@@ -28,12 +32,14 @@ interface Props {
|
||||
|
||||
export default function PaletteChip({
|
||||
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) {
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
const payload: PaletteDragPayload = {
|
||||
type, value, label,
|
||||
composite: composite || undefined,
|
||||
stochPair: stochPair || undefined,
|
||||
secondaryIndicator,
|
||||
period: periodValue,
|
||||
shortPeriod,
|
||||
longPeriod,
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
type PaletteItem,
|
||||
type PaletteItemKind,
|
||||
} from '../../utils/strategyPaletteStorage';
|
||||
import {
|
||||
isStochOverboughtPairPaletteValue,
|
||||
STOCH_PAIR_SECONDARY_OPTIONS,
|
||||
stochPairPaletteDesc,
|
||||
} from '../../utils/stochOverboughtPair';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -31,6 +36,9 @@ export default function PaletteItemModal({
|
||||
const [period, setPeriod] = useState(14);
|
||||
const [shortPeriod, setShortPeriod] = useState(9);
|
||||
const [longPeriod, setLongPeriod] = useState(20);
|
||||
const [secondaryIndicator, setSecondaryIndicator] = useState('CCI');
|
||||
|
||||
const isStochPairEdit = kind === 'composite' && !!initial && isStochOverboughtPairPaletteValue(initial.value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -38,6 +46,10 @@ export default function PaletteItemModal({
|
||||
setValue(initial.value);
|
||||
setLabel(initial.label);
|
||||
setDesc(initial.desc);
|
||||
if (isStochOverboughtPairPaletteValue(initial.value)) {
|
||||
setSecondaryIndicator(initial.secondaryIndicator ?? 'CCI');
|
||||
return;
|
||||
}
|
||||
if (kind === 'auxiliary') {
|
||||
setPeriod(getDefaultIndicatorPeriod(initial.value, def));
|
||||
} else {
|
||||
@@ -85,7 +97,7 @@ export default function PaletteItemModal({
|
||||
window.alert('표시 이름을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (kind === 'composite' && shortPeriod >= longPeriod) {
|
||||
if (kind === 'composite' && !isStochPairEdit && shortPeriod >= longPeriod) {
|
||||
window.alert('단기 기간은 장기 기간보다 작아야 합니다.');
|
||||
return;
|
||||
}
|
||||
@@ -93,9 +105,15 @@ export default function PaletteItemModal({
|
||||
id: initial?.id,
|
||||
value,
|
||||
label: trimmedLabel,
|
||||
desc: desc.trim() || trimmedLabel,
|
||||
desc: isStochPairEdit
|
||||
? stochPairPaletteDesc(secondaryIndicator)
|
||||
: (desc.trim() || trimmedLabel),
|
||||
builtIn: initial?.builtIn,
|
||||
...(kind === 'auxiliary' ? { period } : { shortPeriod, longPeriod }),
|
||||
...(kind === 'auxiliary'
|
||||
? { period }
|
||||
: isStochPairEdit
|
||||
? { secondaryIndicator }
|
||||
: { shortPeriod, longPeriod }),
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
@@ -103,17 +121,42 @@ export default function PaletteItemModal({
|
||||
return (
|
||||
<DraggableModalFrame onClose={onClose} title={title}>
|
||||
<div className="se-modal-body se-palette-item-modal">
|
||||
<label className="se-field-lbl">지표 종류</label>
|
||||
<select
|
||||
className="se-field-inp"
|
||||
value={value}
|
||||
disabled={mode === 'edit'}
|
||||
onChange={e => handleTypeChange(e.target.value)}
|
||||
>
|
||||
{typeOptions.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{!isStochPairEdit && (
|
||||
<>
|
||||
<label className="se-field-lbl">지표 종류</label>
|
||||
<select
|
||||
className="se-field-inp"
|
||||
value={value}
|
||||
disabled={mode === 'edit'}
|
||||
onChange={e => handleTypeChange(e.target.value)}
|
||||
>
|
||||
{typeOptions.map(o => (
|
||||
<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>
|
||||
<input
|
||||
@@ -142,7 +185,7 @@ export default function PaletteItemModal({
|
||||
onChange={setPeriod}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
) : isStochPairEdit ? null : (
|
||||
<>
|
||||
<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,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
applyEdgeHandles,
|
||||
@@ -685,36 +686,59 @@ function StrategyEditorCanvasInner({
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
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)) {
|
||||
onOrphansChange(orphans.map(o => (
|
||||
o.id === id && (o.type === 'AND' || o.type === 'OR')
|
||||
? { ...o, type: gateType }
|
||||
? patchGate(o)
|
||||
: o
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (root && findNodeInTree(root, id)) {
|
||||
onChange(updateNode(root, id, n => (
|
||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
||||
)));
|
||||
onChange(updateNode(root, id, patchGate));
|
||||
return;
|
||||
}
|
||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||
if (branch && findNodeInTree(branch, id)) {
|
||||
onExtraRootsChange?.({
|
||||
...extraRoots,
|
||||
[sid]: updateNode(branch, id, n => (
|
||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
||||
)),
|
||||
[sid]: updateNode(branch, id, patchGate),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [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(() => ({
|
||||
onDelete: handleDelete,
|
||||
onUpdateCondition: handleUpdateCondition,
|
||||
onUpdateStochPairSecondary: handleUpdateStochPairSecondary,
|
||||
onChangeLogicGateType: handleChangeLogicGateType,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
@@ -722,7 +746,7 @@ function StrategyEditorCanvasInner({
|
||||
onStartCandleTypesChange: handleStartCandleTypesChange,
|
||||
onDeleteStart: handleDeleteStart,
|
||||
}), [
|
||||
handleDelete, handleUpdateCondition, handleChangeLogicGateType,
|
||||
handleDelete, handleUpdateCondition, handleUpdateStochPairSecondary, handleChangeLogicGateType,
|
||||
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
||||
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 {
|
||||
CondEditor,
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||
import {
|
||||
isStochOverboughtPairRoot,
|
||||
findStochPairRootInForest,
|
||||
patchStochPairInTree,
|
||||
setStochPairSecondary,
|
||||
stochPairDisplayName,
|
||||
} from '../../utils/stochOverboughtPair';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
import StochPairNodeSettings from './StochPairNodeSettings';
|
||||
import {
|
||||
type EditorConditionState,
|
||||
addExtraStartSection,
|
||||
@@ -44,6 +52,8 @@ interface TreeNodeProps {
|
||||
depth?: number;
|
||||
def: DefType;
|
||||
onAddStart?: () => void;
|
||||
stochPairForest?: (LogicNode | null)[];
|
||||
onStochPairSecondaryChange?: (orNodeId: string, secondary: string) => void;
|
||||
}
|
||||
|
||||
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
@@ -58,19 +68,25 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
depth = 0,
|
||||
def,
|
||||
onAddStart,
|
||||
stochPairForest,
|
||||
onStochPairSecondaryChange,
|
||||
}) => {
|
||||
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
||||
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||||
const nodeDropKey = `${dropScope}:${node.id}`;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const isStochPair = isStochOverboughtPairRoot(node);
|
||||
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
||||
const showStochPairSettings = isStochPair;
|
||||
const label = node.type === 'CONDITION' && node.condition
|
||||
? nodeToText(node, def)
|
||||
: node.type === 'AND'
|
||||
? 'AND (그리고)'
|
||||
: node.type === 'OR'
|
||||
? 'OR (또는)'
|
||||
: 'NOT (반대)';
|
||||
: isStochPair
|
||||
? stochPairDisplayName(node.stochPair!.secondaryIndicatorType)
|
||||
: node.type === 'AND'
|
||||
? 'AND (그리고)'
|
||||
: node.type === 'OR'
|
||||
? 'OR (또는)'
|
||||
: 'NOT (반대)';
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (!isLogic) return;
|
||||
@@ -97,6 +113,18 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
};
|
||||
|
||||
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) =>
|
||||
onUpdate({ ...node, children: (node.children ?? []).map(c => (c.id === childId ? updated : c)) });
|
||||
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 }}>
|
||||
<span className="sp-node-badge" style={{ background: color }}>
|
||||
{node.type === 'CONDITION' && node.condition
|
||||
? getStrategyIndicatorDisplayName(node.condition.indicatorType)
|
||||
: node.type}
|
||||
{isStochPair
|
||||
? '복합'
|
||||
: node.type === 'CONDITION' && node.condition
|
||||
? getStrategyIndicatorDisplayName(node.condition.indicatorType)
|
||||
: node.type}
|
||||
</span>
|
||||
<span className="sp-node-label">{label}</span>
|
||||
<div className="sp-node-actions">
|
||||
{(node.type === 'AND' || node.type === 'OR') && (
|
||||
<LogicGateOpToggle
|
||||
value={node.type}
|
||||
onChange={op => onUpdate({ ...node, type: op })}
|
||||
onChange={op => onUpdate({
|
||||
...node,
|
||||
type: op,
|
||||
stochPair: op === 'OR' ? node.stochPair : undefined,
|
||||
})}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{showSettings && (
|
||||
{(showSettings || showStochPairSettings) && (
|
||||
<button
|
||||
type="button"
|
||||
className="sp-node-settings"
|
||||
@@ -155,10 +189,24 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
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>
|
||||
|
||||
{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 && (
|
||||
@@ -177,6 +225,8 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
depth={depth + 1}
|
||||
def={def}
|
||||
onAddStart={onAddStart}
|
||||
stochPairForest={stochPairForest}
|
||||
onStochPairSecondaryChange={onStochPairSecondaryChange}
|
||||
/>
|
||||
))}
|
||||
{dragOverKey === nodeDropKey && (
|
||||
@@ -255,6 +305,11 @@ function StartSectionBlock({
|
||||
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 (
|
||||
<section className="sp-start-section">
|
||||
<header className="sp-start-head">
|
||||
@@ -296,6 +351,8 @@ function StartSectionBlock({
|
||||
dropScope={startId}
|
||||
def={def}
|
||||
onAddStart={onAddStart}
|
||||
stochPairForest={stochPairForest}
|
||||
onStochPairSecondaryChange={handleStochPairSecondaryChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user