전략편집기 복합지표 기능 반영

This commit is contained in:
Macbook
2026-05-25 01:34:52 +09:00
parent ca249ad318
commit 20dd257c29
21 changed files with 1781 additions and 183 deletions
@@ -0,0 +1,92 @@
import React, { useEffect, useId, useState } from 'react';
interface Props {
value: number;
options: number[];
min?: number;
max?: number;
allowDecimal?: boolean;
onChange: (value: number) => void;
}
function sanitizeDraft(raw: string, allowDecimal: boolean): string {
if (allowDecimal) {
let s = raw.replace(/[^\d.-]/g, '');
const minus = s.startsWith('-') ? '-' : '';
s = s.replace(/-/g, '');
const dot = s.indexOf('.');
if (dot !== -1) {
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '');
}
return minus + s;
}
return raw.replace(/\D/g, '');
}
function parseDraft(raw: string, allowDecimal: boolean): number | null {
const trimmed = raw.trim();
if (!trimmed || trimmed === '-' || trimmed === '.') return null;
const n = allowDecimal ? parseFloat(trimmed) : parseInt(trimmed, 10);
return Number.isFinite(n) ? n : null;
}
export default function ComboNumberInput({
value,
options,
min = 1,
max = 500,
allowDecimal = false,
onChange,
}: Props) {
const listId = useId().replace(/:/g, '');
const [draft, setDraft] = useState(String(value));
const [focused, setFocused] = useState(false);
useEffect(() => {
if (!focused) setDraft(String(value));
}, [value, focused]);
const commit = () => {
const parsed = parseDraft(draft, allowDecimal);
if (parsed == null) {
setDraft(String(value));
return;
}
const clamped = allowDecimal
? Math.max(min, Math.min(max, parsed))
: Math.max(min, Math.min(max, Math.round(parsed)));
onChange(clamped);
setDraft(String(clamped));
};
const uniqueOptions = [...new Set([...options, value])].sort((a, b) => a - b);
return (
<div className="se-combo-num">
<input
type="text"
className="se-combo-num-input"
inputMode={allowDecimal ? 'decimal' : 'numeric'}
list={listId}
value={draft}
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
commit();
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
/>
<datalist id={listId}>
{uniqueOptions.map(opt => (
<option key={opt} value={String(opt)} />
))}
</datalist>
</div>
);
}
@@ -0,0 +1,123 @@
import React, { useEffect, useRef } from 'react';
import type { ConditionDSL } from '../../utils/strategyTypes';
import type { DefType } from '../../utils/strategyEditorShared';
import {
getConditionRightPeriod,
getConditionThreshold,
getConditionValuePeriod,
getCompositePeriodPresetOptions,
getPeriodPresetOptions,
getThresholdBounds,
getThresholdPresetOptions,
hasEditableThreshold,
setConditionRightPeriod,
setConditionThreshold,
setConditionValuePeriod,
usesValuePeriodField,
} from '../../utils/conditionPeriods';
import { compositeDisplayName, compositeElementLabel } from '../../utils/compositeIndicators';
import ComboNumberInput from './ComboNumberInput';
interface Props {
condition: ConditionDSL;
def: DefType;
onChange: (c: ConditionDSL) => void;
onClose: () => void;
popoverClassName?: string;
}
export default function ConditionNodeSettings({
condition, def, onChange, onClose, popoverClassName = 'se-flow-settings-pop',
}: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
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]);
const rightThreshold = getConditionThreshold(condition, 'right');
const showThreshold = hasEditableThreshold(condition) && rightThreshold != null;
const periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
const thresholdBounds = getThresholdBounds(condition.indicatorType);
return (
<div
ref={ref}
className={popoverClassName}
role="dialog"
aria-label="지표 설정"
onClick={e => e.stopPropagation()}
>
<div className="se-flow-settings-head">
<span> </span>
<button
type="button"
className="se-flow-settings-close"
aria-label="닫기"
title="닫기"
onClick={onClose}
>
×
</button>
</div>
{condition.composite ? (
<>
<label className="se-flow-settings-field">
<span>{compositeElementLabel(condition.indicatorType, 1)} (, )</span>
<ComboNumberInput
key={`composite-left-${condition.indicatorType}-${getConditionValuePeriod(condition, def)}`}
value={getConditionValuePeriod(condition, def)}
options={getCompositePeriodPresetOptions(condition.indicatorType, def, 'left')}
min={1}
max={500}
onChange={p => onChange(setConditionValuePeriod(condition, p, def))}
/>
</label>
<label className="se-flow-settings-field">
<span>{compositeElementLabel(condition.indicatorType, 2)} (, )</span>
<ComboNumberInput
key={`composite-right-${condition.indicatorType}-${getConditionRightPeriod(condition, def)}`}
value={getConditionRightPeriod(condition, def)}
options={getCompositePeriodPresetOptions(condition.indicatorType, def, 'right')}
min={1}
max={500}
onChange={p => onChange(setConditionRightPeriod(condition, p))}
/>
</label>
</>
) : usesValuePeriodField(condition) ? (
<label className="se-flow-settings-field">
<span>{condition.indicatorType} ()</span>
<ComboNumberInput
value={getConditionValuePeriod(condition, def)}
options={periodPresets}
min={1}
max={500}
onChange={p => onChange(setConditionValuePeriod(condition, p, def))}
/>
</label>
) : null}
{showThreshold && rightThreshold != null && (
<label className="se-flow-settings-field">
<span></span>
<ComboNumberInput
value={rightThreshold}
options={thresholdPresets}
min={thresholdBounds.min}
max={thresholdBounds.max}
allowDecimal
onChange={v => onChange(setConditionThreshold(condition, 'right', v))}
/>
</label>
)}
{condition.composite && (
<p className="se-flow-settings-hint">{compositeDisplayName(condition.indicatorType)}</p>
)}
</div>
);
}
@@ -1,6 +1,8 @@
import React, { memo, useCallback } from 'react';
import React, { memo, useCallback, useState } from 'react';
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
import { hasNodeSettings } from '../../utils/conditionPeriods';
import ConditionNodeSettings from './ConditionNodeSettings';
const LOGIC_COLORS: Record<string, string> = {
AND: '#00aaff',
@@ -179,16 +181,23 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, selected }: NodeProps) {
const d = data as StrategyFlowNodeData;
const node = d.logicNode!;
const ind = node.condition?.indicatorType ?? 'COND';
const condType = node.condition?.conditionType ?? '';
const cond = node.condition;
const ind = cond?.indicatorType ?? 'COND';
const condType = cond?.conditionType ?? '';
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
const tone = isCross ? 'cross' : 'value';
const isSell = d.signalTab === 'sell';
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
const [settingsOpen, setSettingsOpen] = useState(false);
const showSettings = cond && hasNodeSettings(cond);
const handleSettingsChange = useCallback((next: NonNullable<typeof cond>) => {
d.onUpdateCondition?.(id, next);
}, [d, id]);
return (
<div
className={`se-flow-node se-flow-node--cond se-flow-node--${tone}${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--cond se-flow-node--${tone}${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' : ''}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
@@ -198,9 +207,38 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
targetOnly
canTargetConnect={d.canTargetConnect !== false}
/>
<div className="se-flow-cond-badge">{ind}</div>
<div className="se-flow-cond-top">
<div className="se-flow-cond-badge">{ind}</div>
<div className="se-flow-cond-actions">
{showSettings && (
<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>
)}
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
</div>
</div>
<div className="se-flow-cond-text">{d.label ?? ind}</div>
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
{settingsOpen && cond && d.def && (
<ConditionNodeSettings
condition={cond}
def={d.def}
onChange={handleSettingsChange}
onClose={() => setSettingsOpen(false)}
/>
)}
</div>
);
});
@@ -4,6 +4,7 @@ export interface PaletteDragPayload {
type: 'operator' | 'indicator';
value: string;
label: string;
composite?: boolean;
}
interface Props {
@@ -13,16 +14,17 @@ interface Props {
desc?: string;
color?: string;
period?: string;
composite?: boolean;
selected?: boolean;
onSelect?: () => void;
onAdd: () => void;
}
export default function PaletteChip({
type, value, label, desc, color, period, selected = false, onSelect, onAdd,
type, value, label, desc, color, period, composite = false, selected = false, onSelect, onAdd,
}: Props) {
const onDragStart = (e: React.DragEvent) => {
const payload: PaletteDragPayload = { type, value, label };
const payload: PaletteDragPayload = { type, value, label, composite: composite || undefined };
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.effectAllowed = 'copy';
};
@@ -51,7 +53,7 @@ export default function PaletteChip({
return (
<div
draggable
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${selected ? ' se-palette-card--selected' : ''}`}
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${composite ? ' se-palette-card--composite' : ''}${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -62,7 +64,9 @@ export default function PaletteChip({
<div className="se-palette-card-head">
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
<div className="se-palette-card-meta">
<span className="se-palette-card-name">{label}</span>
<span className="se-palette-card-name">
{composite ? `${label} + ${label}` : label}
</span>
{desc ? <span className="se-palette-card-desc">{desc}</span> : null}
</div>
</div>
@@ -18,12 +18,13 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { Theme } from '../../types/index';
import type { LogicNode } from '../../utils/strategyTypes';
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
import {
addChild,
deleteNode,
mergeAtRoot,
makeNode,
updateNode,
type DefType,
} from '../../utils/strategyEditorShared';
import {
@@ -408,10 +409,10 @@ function StrategyEditorCanvasInner({
}, [setNodes]);
const addOrphanAt = useCallback((
data: { type: string; value: string; label: string },
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
const newNode = makeNode(data.type, data.value, signalTab, def);
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
positionsRef.current.set(newNode.id, {
x: flowPos.x - FLOW_NODE_W / 2,
y: flowPos.y - FLOW_NODE_H / 2,
@@ -422,10 +423,10 @@ function StrategyEditorCanvasInner({
const applyDropWithAttach = useCallback((
anchorId: string,
data: { type: string; value: string; label: string },
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
const newNode = makeNode(data.type, data.value, signalTab, def);
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
@@ -488,12 +489,24 @@ function StrategyEditorCanvasInner({
}
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
if (isOrphanNode(orphans, id)) {
onOrphansChange(orphans.map(o => (
o.id === id ? { ...o, condition } : o
)));
return;
}
if (!root) return;
onChange(updateNode(root, id, n => ({ ...n, condition })));
}, [root, orphans, onChange, onOrphansChange]);
const flowCallbacks = useMemo(() => ({
onDelete: handleDelete,
onUpdateCondition: handleUpdateCondition,
onDropTarget: handleDropTarget,
onDragOverTarget: handleDragOverTarget,
onDragLeaveTarget: handleDragLeaveTarget,
}), [handleDelete, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
}), [handleDelete, handleUpdateCondition, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
const rebuildFlow = useCallback(() => logicNodeToFlow(
root,
@@ -8,6 +8,8 @@ import {
nodeToText,
type DefType,
} from '../../utils/strategyEditorShared';
import { hasNodeSettings } from '../../utils/conditionPeriods';
import ConditionNodeSettings from './ConditionNodeSettings';
const NODE_COLORS: Record<string, string> = {
AND: '#4caf50',
@@ -41,6 +43,8 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
}) => {
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
const [settingsOpen, setSettingsOpen] = useState(false);
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
const label = node.type === 'CONDITION' && node.condition
? nodeToText(node, def)
: node.type === 'AND'
@@ -83,12 +87,41 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="sp-node-head" style={{ borderLeftColor: color }}>
<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 ? node.condition.indicatorType : node.type}
</span>
<span className="sp-node-label">{label}</span>
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}></button>
<div className="sp-node-actions">
{showSettings && (
<button
type="button"
className="sp-node-settings"
title="지표 설정"
aria-label="지표 설정"
aria-expanded={settingsOpen}
onClick={e => {
e.stopPropagation();
setSettingsOpen(v => !v);
}}
>
<svg width="14" height="14" 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>
)}
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}></button>
</div>
{settingsOpen && node.condition && (
<ConditionNodeSettings
condition={node.condition}
def={def}
onChange={handleCondChange}
onClose={() => setSettingsOpen(false)}
popoverClassName="sp-node-settings-pop"
/>
)}
</div>
{node.type === 'CONDITION' && node.condition && (
@@ -132,9 +165,9 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
const applyDrop = useCallback((
targetId: string | null,
data: { type: string; value: string; label: string },
data: { type: string; value: string; label: string; composite?: boolean },
) => {
const newNode = makeNode(data.type, data.value, signalTab, def);
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
if (!root) {
onChange(newNode);
return;
@@ -156,7 +189,7 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
}
};
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string }) => {
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => {
applyDrop(targetId, data);
};