일목균형표 수정
This commit is contained in:
@@ -2,10 +2,12 @@ import React, { memo, useCallback, useState } from 'react';
|
||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||
import {
|
||||
isStartNodeId,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
type HandleSide,
|
||||
type StrategyFlowNodeData,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { getStartCandleTypes } from '../../utils/strategyStartNodes';
|
||||
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
@@ -132,7 +134,9 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
const candleType = d.candleType ?? '1m';
|
||||
const candleTypes = d.candleTypes?.length
|
||||
? d.candleTypes
|
||||
: getStartCandleTypes({ candleType: d.candleType ?? '1m' });
|
||||
const canDelete = id !== '__strategy_start__' && !!d.onDeleteStart;
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
@@ -150,35 +154,32 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-start-head">
|
||||
<div className="se-flow-start-dot" />
|
||||
<span className="se-flow-start-label">START</span>
|
||||
<select
|
||||
className="se-flow-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
onChange={e => d.onStartCandleTypeChange?.(id, e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-del se-flow-del--start"
|
||||
title="START 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
d.onDeleteStart?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div className="se-flow-start-layout">
|
||||
<div className="se-flow-start-title-row">
|
||||
<div className="se-flow-start-title-left">
|
||||
<div className="se-flow-start-dot" />
|
||||
<span className="se-flow-start-label">START</span>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-del se-flow-del--start"
|
||||
title="START 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
d.onDeleteStart?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<StartTimeframeCheckboxes
|
||||
layout="graph"
|
||||
className="se-flow-start-tf-checks-wrap"
|
||||
selected={candleTypes}
|
||||
onChange={types => d.onStartCandleTypesChange?.(id, types)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -206,7 +207,15 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-gate-head">
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
{node.type === 'AND' || node.type === 'OR' ? (
|
||||
<LogicGateOpToggle
|
||||
value={node.type}
|
||||
onChange={op => d.onChangeLogicGateType?.(id, op)}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<span className="se-flow-gate-badge">[{node.type}]</span>
|
||||
)}
|
||||
</div>
|
||||
{(node.children ?? []).length === 0 && (
|
||||
<span className="se-flow-gate-hint">조건을 드롭하세요</span>
|
||||
|
||||
@@ -94,7 +94,12 @@ function renderNode(node: LogicNode, def: DefType): React.ReactNode {
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const tf = formatStartCandleLabel(node.candleType ?? '1m');
|
||||
const types = node.candleTypes?.length
|
||||
? node.candleTypes
|
||||
: [node.candleType ?? '1m'];
|
||||
const tf = types.length > 1
|
||||
? types.map(formatStartCandleLabel).join(' · ')
|
||||
: formatStartCandleLabel(types[0]);
|
||||
return inner ? (
|
||||
<>
|
||||
<span className="se-formula-tf">[{tf}]</span>
|
||||
@@ -125,11 +130,14 @@ function renderSignalBranches(
|
||||
}
|
||||
|
||||
if (branches.length === 1) {
|
||||
const { candleType, root } = branches[0];
|
||||
const { candleType, candleTypes, root } = branches[0];
|
||||
if (!root) return <span className="se-formula-empty">(연결된 조건 없음)</span>;
|
||||
const tfLabel = candleTypes && candleTypes.length > 1
|
||||
? candleTypes.map(formatStartCandleLabel).join(' · ')
|
||||
: formatStartCandleLabel(candleType);
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-tf">[{formatStartCandleLabel(candleType)}]</span>
|
||||
<span className="se-formula-tf">[{tfLabel}]</span>
|
||||
{' '}
|
||||
{renderNode(root, def)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AND / OR 논리 게이트 — 서로 전환
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { LogicNodeType } from '../../utils/strategyTypes';
|
||||
|
||||
export type LogicGateOp = Extract<LogicNodeType, 'AND' | 'OR'>;
|
||||
|
||||
interface Props {
|
||||
value: LogicGateOp;
|
||||
onChange: (op: LogicGateOp) => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const LogicGateOpToggle: React.FC<Props> = ({ value, onChange, className = '', compact = false }) => {
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-logic-gate-toggle${compact ? ' se-logic-gate-toggle--compact' : ''}${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="논리 연산"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
<div className="se-start-combine-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--and${value === 'AND' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'AND'}
|
||||
onClick={() => onChange('AND')}
|
||||
>
|
||||
AND
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--or${value === 'OR' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'OR'}
|
||||
onClick={() => onChange('OR')}
|
||||
>
|
||||
OR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogicGateOpToggle;
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* START 노드 — 평가 시간봉 다중 선택 (마감봉마다 독립 체크)
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
START_TIMEFRAME_GRAPH_ROWS,
|
||||
STRATEGY_CANDLE_TYPE_OPTIONS,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
formatStrategyCandleLabel,
|
||||
type StrategyCandleType,
|
||||
} from '../../utils/strategyStartNodes';
|
||||
|
||||
interface Props {
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
className?: string;
|
||||
/** 그래프 START: 2행 고정 배치 + 값 라벨(1m…) */
|
||||
layout?: 'default' | 'graph';
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OPTIONS_BY_VALUE = Object.fromEntries(
|
||||
STRATEGY_CANDLE_TYPE_OPTIONS.map(o => [o.value, o]),
|
||||
) as Record<StrategyCandleType, { value: string; label: string }>;
|
||||
|
||||
function CheckItem({
|
||||
value,
|
||||
checked,
|
||||
showShortLabel,
|
||||
onToggle,
|
||||
}: {
|
||||
value: StrategyCandleType;
|
||||
checked: boolean;
|
||||
showShortLabel: boolean;
|
||||
onToggle: (value: string, checked: boolean) => void;
|
||||
}) {
|
||||
const opt = OPTIONS_BY_VALUE[value];
|
||||
return (
|
||||
<label className="se-start-tf-check" title={formatStrategyCandleLabel(value)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onToggle(value, e.target.checked)}
|
||||
/>
|
||||
<span>{showShortLabel ? value : opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const StartTimeframeCheckboxes: React.FC<Props> = ({
|
||||
selected,
|
||||
onChange,
|
||||
className = '',
|
||||
layout = 'default',
|
||||
compact = false,
|
||||
}) => {
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
const toggle = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!selectedSet.has(value)) onChange([...selected, value]);
|
||||
return;
|
||||
}
|
||||
const next = selected.filter(t => t !== value);
|
||||
onChange(next.length > 0 ? next : [value]);
|
||||
};
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
if (layout === 'graph') {
|
||||
return (
|
||||
<div
|
||||
className={`se-start-tf-checks se-start-tf-checks--graph${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
{START_TIMEFRAME_GRAPH_ROWS.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="se-start-tf-checks-row">
|
||||
{row.map(ct => (
|
||||
<CheckItem
|
||||
key={ct}
|
||||
value={ct}
|
||||
checked={selectedSet.has(ct)}
|
||||
showShortLabel
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-start-tf-checks${compact ? ' se-start-tf-checks--compact' : ''}${className ? ` ${className}` : ''}`}
|
||||
role="group"
|
||||
aria-label="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<CheckItem
|
||||
key={ct}
|
||||
value={ct}
|
||||
checked={selectedSet.has(ct)}
|
||||
showShortLabel={compact}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartTimeframeCheckboxes;
|
||||
@@ -371,10 +371,12 @@ function StrategyEditorCanvasInner({
|
||||
};
|
||||
}, [emitLayoutChange]);
|
||||
|
||||
const handleStartCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE];
|
||||
const normalized = types.map(normalizeStartCandleType);
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[startId]: { candleType: normalizeStartCandleType(candleType) },
|
||||
[startId]: { candleTypes: normalized, candleType: normalized[0] },
|
||||
});
|
||||
}, [startMeta, onStartMetaChange]);
|
||||
|
||||
@@ -403,7 +405,7 @@ function StrategyEditorCanvasInner({
|
||||
onExtraStartIdsChange?.([...extraStartIds, newId]);
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[newId]: { candleType: DEFAULT_START_CANDLE },
|
||||
[newId]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE },
|
||||
});
|
||||
onExtraRootsChange?.({ ...extraRoots, [newId]: null });
|
||||
positionsRef.current.set(newId, {
|
||||
@@ -675,18 +677,47 @@ function StrategyEditorCanvasInner({
|
||||
}
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
const handleChangeLogicGateType = useCallback((id: string, gateType: 'AND' | 'OR') => {
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.map(o => (
|
||||
o.id === id && (o.type === 'AND' || o.type === 'OR')
|
||||
? { ...o, type: gateType }
|
||||
: o
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (root && findNodeInTree(root, id)) {
|
||||
onChange(updateNode(root, id, n => (
|
||||
n.type === 'AND' || n.type === 'OR' ? { ...n, type: gateType } : n
|
||||
)));
|
||||
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
|
||||
)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
onUpdateCondition: handleUpdateCondition,
|
||||
onChangeLogicGateType: handleChangeLogicGateType,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
onStartCandleTypeChange: handleStartCandleTypeChange,
|
||||
onStartCandleTypesChange: handleStartCandleTypesChange,
|
||||
onDeleteStart: handleDeleteStart,
|
||||
}), [
|
||||
handleDelete, handleUpdateCondition, handleDropTarget,
|
||||
handleDragOverTarget, handleDragLeaveTarget,
|
||||
handleStartCandleTypeChange, handleDeleteStart,
|
||||
handleDelete, handleUpdateCondition, handleChangeLogicGateType,
|
||||
handleDropTarget, handleDragOverTarget, handleDragLeaveTarget,
|
||||
handleStartCandleTypesChange, handleDeleteStart,
|
||||
]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
type EditorConditionState,
|
||||
addExtraStartSection,
|
||||
updateBranchRoot,
|
||||
updateStartCandleType,
|
||||
updateStartCandleTypes,
|
||||
removeStartSection,
|
||||
collectStartSections,
|
||||
} from '../../utils/strategyConditionSerde';
|
||||
import { STRATEGY_CANDLE_TYPES } from '../../utils/strategyStartNodes';
|
||||
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
|
||||
import LogicGateOpToggle from './LogicGateOpToggle';
|
||||
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
AND: '#4caf50',
|
||||
@@ -116,6 +118,13 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
</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 })}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{showSettings && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,7 +189,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
|
||||
interface StartSectionBlockProps {
|
||||
startId: string;
|
||||
candleType: string;
|
||||
candleTypes: string[];
|
||||
root: LogicNode | null;
|
||||
canDelete: boolean;
|
||||
signalTab: 'buy' | 'sell';
|
||||
@@ -188,14 +197,14 @@ interface StartSectionBlockProps {
|
||||
dragOverKey: string | null;
|
||||
setDragOverKey: (key: string | null) => void;
|
||||
onRootChange: (root: LogicNode | null) => void;
|
||||
onCandleTypeChange: (candleType: string) => void;
|
||||
onCandleTypesChange: (candleTypes: string[]) => void;
|
||||
onDeleteStart?: () => void;
|
||||
onAddStart?: () => void;
|
||||
}
|
||||
|
||||
function StartSectionBlock({
|
||||
startId,
|
||||
candleType,
|
||||
candleTypes,
|
||||
root,
|
||||
canDelete,
|
||||
signalTab,
|
||||
@@ -203,7 +212,7 @@ function StartSectionBlock({
|
||||
dragOverKey,
|
||||
setDragOverKey,
|
||||
onRootChange,
|
||||
onCandleTypeChange,
|
||||
onCandleTypesChange,
|
||||
onDeleteStart,
|
||||
onAddStart,
|
||||
}: StartSectionBlockProps) {
|
||||
@@ -250,16 +259,11 @@ function StartSectionBlock({
|
||||
<header className="sp-start-head">
|
||||
<span className="sp-start-dot" aria-hidden />
|
||||
<span className="sp-start-label">START</span>
|
||||
<select
|
||||
className="sp-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onChange={e => onCandleTypeChange(e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
<StartTimeframeCheckboxes
|
||||
className="sp-start-tf-checks"
|
||||
selected={candleTypes}
|
||||
onChange={onCandleTypesChange}
|
||||
/>
|
||||
{canDelete && (
|
||||
<button type="button" className="sp-start-del" title="START 삭제" onClick={onDeleteStart}>×</button>
|
||||
)}
|
||||
@@ -277,7 +281,7 @@ function StartSectionBlock({
|
||||
>
|
||||
{!root ? (
|
||||
<div className="sp-drop-empty sp-drop-empty--section">
|
||||
논리 연산자·지표를 드래그하여 [{candleType}] 조건을 구성하세요.
|
||||
논리 연산자·지표를 드래그하여 [{formatStartCandleTypesLabel(candleTypes)}] 조건을 구성하세요.
|
||||
</div>
|
||||
) : (
|
||||
<TreeNodeComp
|
||||
@@ -332,8 +336,8 @@ export default function StrategyListEditor({
|
||||
onEditorStateChange(updateBranchRoot(editorState, startId, root));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
onEditorStateChange(updateStartCandleType(editorState, startId, candleType));
|
||||
const handleCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
onEditorStateChange(updateStartCandleTypes(editorState, startId, candleTypes));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleDeleteStart = useCallback((startId: string) => {
|
||||
@@ -375,7 +379,7 @@ export default function StrategyListEditor({
|
||||
<StartSectionBlock
|
||||
key={section.startId}
|
||||
startId={section.startId}
|
||||
candleType={section.candleType}
|
||||
candleTypes={section.candleTypes}
|
||||
root={section.root}
|
||||
canDelete={section.canDelete}
|
||||
signalTab={signalTab}
|
||||
@@ -383,7 +387,7 @@ export default function StrategyListEditor({
|
||||
dragOverKey={dragOverKey}
|
||||
setDragOverKey={setDragOverKey}
|
||||
onRootChange={root => handleRootChange(section.startId, root)}
|
||||
onCandleTypeChange={ct => handleCandleTypeChange(section.startId, ct)}
|
||||
onCandleTypesChange={types => handleCandleTypesChange(section.startId, types)}
|
||||
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
|
||||
onAddStart={handleAddStart}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user