일목균형표 수정

This commit is contained in:
Macbook
2026-05-27 23:36:48 +09:00
parent 9cee6387c3
commit 8cc0d1c88c
73 changed files with 2256 additions and 334 deletions
@@ -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}
/>