복합지표 추가

This commit is contained in:
Macbook
2026-06-11 22:53:19 +09:00
parent 280c187021
commit 05c15ec92b
17 changed files with 817 additions and 77 deletions
+84 -3
View File
@@ -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>
+63 -7
View File
@@ -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);
+37
View File
@@ -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[],
+194
View File
@@ -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),
};
}
+84 -22
View File
@@ -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 = {
+7 -2
View File
@@ -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'
+3
View File
@@ -0,0 +1,3 @@
/** 전략 DSL 노드 id — 순환 import 방지를 위해 별도 모듈 */
let _cnt = 0;
export const genId = () => `n_${++_cnt}_${Date.now()}`;
+19 -4
View File
@@ -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,
})); }));
+4
View File
@@ -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 {