다중 시간봉 전략 설정기능 추가
This commit is contained in:
@@ -307,8 +307,8 @@ public class LiveStrategyEvaluator {
|
|||||||
log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount);
|
log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams);
|
Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams, market);
|
||||||
Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams);
|
Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams, market);
|
||||||
BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule);
|
BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule);
|
||||||
return builtStrategy;
|
return builtStrategy;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -318,11 +318,12 @@ public class LiveStrategyEvaluator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Rule buildRule(String conditionJson, BarSeries series,
|
private Rule buildRule(String conditionJson, BarSeries series,
|
||||||
Map<String, Map<String, Object>> params) {
|
Map<String, Map<String, Object>> params,
|
||||||
|
String market) {
|
||||||
if (conditionJson == null || conditionJson.isBlank()) return new BooleanRule(false);
|
if (conditionJson == null || conditionJson.isBlank()) return new BooleanRule(false);
|
||||||
try {
|
try {
|
||||||
JsonNode node = objectMapper.readTree(conditionJson);
|
JsonNode node = objectMapper.readTree(conditionJson);
|
||||||
return adapter.toRule(node, series, params);
|
return adapter.toRule(node, series, params, market, ta4jStorage);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Evaluator] 조건 JSON 파싱 실패: {}", e.getMessage());
|
log.warn("[Evaluator] 조건 JSON 파싱 실패: {}", e.getMessage());
|
||||||
return new BooleanRule(false);
|
return new BooleanRule(false);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.goldenchart.service;
|
package com.goldenchart.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.goldenchart.storage.Ta4jStorage;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.ta4j.core.BarSeries;
|
import org.ta4j.core.BarSeries;
|
||||||
@@ -98,56 +99,133 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record RuleBuildContext(
|
||||||
|
BarSeries primarySeries,
|
||||||
|
Map<String, Map<String, Object>> indicatorParams,
|
||||||
|
String market,
|
||||||
|
Ta4jStorage storage
|
||||||
|
) {
|
||||||
|
BarSeries resolveSeries(String candleType) {
|
||||||
|
String ct = LiveStrategyTimeframeService.normalize(candleType);
|
||||||
|
if (storage != null && market != null && !market.isBlank()
|
||||||
|
&& storage.exists(market, ct)) {
|
||||||
|
return storage.getOrCreate(market, ct);
|
||||||
|
}
|
||||||
|
return primarySeries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Rule toRule(JsonNode node, BarSeries series,
|
public Rule toRule(JsonNode node, BarSeries series,
|
||||||
Map<String, Map<String, Object>> indicatorParams) {
|
Map<String, Map<String, Object>> indicatorParams) {
|
||||||
|
return toRule(node, new RuleBuildContext(series, indicatorParams, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Rule toRule(JsonNode node, BarSeries series,
|
||||||
|
Map<String, Map<String, Object>> indicatorParams,
|
||||||
|
String market, Ta4jStorage storage) {
|
||||||
|
return toRule(node, new RuleBuildContext(series, indicatorParams, market, storage));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Rule toRule(JsonNode node, RuleBuildContext ctx) {
|
||||||
if (node == null || node.isNull()) return new BooleanRule(false);
|
if (node == null || node.isNull()) return new BooleanRule(false);
|
||||||
String type = node.path("type").asText("CONDITION");
|
String type = node.path("type").asText("CONDITION");
|
||||||
|
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
case "AND" -> buildAndRule(node, series, indicatorParams);
|
case "AND" -> buildAndRule(node, ctx);
|
||||||
case "OR" -> buildOrRule(node, series, indicatorParams);
|
case "OR" -> buildOrRule(node, ctx);
|
||||||
case "NOT" -> buildNotRule(node, series, indicatorParams);
|
case "NOT" -> buildNotRule(node, ctx);
|
||||||
default -> buildConditionRule(node.path("condition"), series, indicatorParams);
|
case "TIMEFRAME" -> buildTimeframeRule(node, ctx);
|
||||||
|
default -> buildConditionRule(node.path("condition"), ctx.primarySeries(), ctx.indicatorParams());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
|
||||||
|
String ct = node.path("candleType").asText("1m");
|
||||||
|
BarSeries branchSeries = ctx.resolveSeries(ct);
|
||||||
|
RuleBuildContext branchCtx = new RuleBuildContext(
|
||||||
|
branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage());
|
||||||
|
List<Rule> rules = childRules(node, branchCtx);
|
||||||
|
if (rules.isEmpty()) return new BooleanRule(false);
|
||||||
|
Rule inner = rules.get(0);
|
||||||
|
if (branchSeries == ctx.primarySeries()) return inner;
|
||||||
|
return new CrossSeriesRule(inner, branchSeries, ctx.primarySeries());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다른 시간봉 시리즈로 빌드된 Rule — 트리거 시리즈 인덱스에서 해당 분봉 최신봉으로 평가 */
|
||||||
|
private static final class CrossSeriesRule implements Rule {
|
||||||
|
private final Rule inner;
|
||||||
|
private final BarSeries branchSeries;
|
||||||
|
private final BarSeries triggerSeries;
|
||||||
|
|
||||||
|
CrossSeriesRule(Rule inner, BarSeries branchSeries, BarSeries triggerSeries) {
|
||||||
|
this.inner = inner;
|
||||||
|
this.branchSeries = branchSeries;
|
||||||
|
this.triggerSeries = triggerSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||||
|
int evalIndex = branchSeries == triggerSeries
|
||||||
|
? index
|
||||||
|
: branchSeries.getEndIndex();
|
||||||
|
if (evalIndex < 0 || branchSeries.getBarCount() == 0) return false;
|
||||||
|
return inner.isSatisfied(evalIndex, tradingRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── AND / OR / NOT ────────────────────────────────────────────────────────
|
// ── AND / OR / NOT ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Rule buildAndRule(JsonNode node, BarSeries series,
|
private Rule buildAndRule(JsonNode node, RuleBuildContext ctx) {
|
||||||
Map<String, Map<String, Object>> p) {
|
List<Rule> rules = childRules(node, ctx);
|
||||||
List<Rule> rules = childRules(node, series, p);
|
|
||||||
if (rules.isEmpty()) return new BooleanRule(false);
|
if (rules.isEmpty()) return new BooleanRule(false);
|
||||||
Rule result = rules.get(0);
|
Rule result = rules.get(0);
|
||||||
for (int i = 1; i < rules.size(); i++) result = new AndRule(result, rules.get(i));
|
for (int i = 1; i < rules.size(); i++) result = new AndRule(result, rules.get(i));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Rule buildOrRule(JsonNode node, BarSeries series,
|
private Rule buildOrRule(JsonNode node, RuleBuildContext ctx) {
|
||||||
Map<String, Map<String, Object>> p) {
|
List<Rule> rules = childRules(node, ctx);
|
||||||
List<Rule> rules = childRules(node, series, p);
|
|
||||||
if (rules.isEmpty()) return new BooleanRule(false);
|
if (rules.isEmpty()) return new BooleanRule(false);
|
||||||
Rule result = rules.get(0);
|
Rule result = rules.get(0);
|
||||||
for (int i = 1; i < rules.size(); i++) result = new OrRule(result, rules.get(i));
|
for (int i = 1; i < rules.size(); i++) result = new OrRule(result, rules.get(i));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Rule buildNotRule(JsonNode node, BarSeries series,
|
private Rule buildNotRule(JsonNode node, RuleBuildContext ctx) {
|
||||||
Map<String, Map<String, Object>> p) {
|
List<Rule> rules = childRules(node, ctx);
|
||||||
List<Rule> rules = childRules(node, series, p);
|
|
||||||
if (rules.isEmpty()) return new BooleanRule(false);
|
if (rules.isEmpty()) return new BooleanRule(false);
|
||||||
return new NotRule(rules.get(0));
|
return new NotRule(rules.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Rule> childRules(JsonNode node, BarSeries series,
|
private List<Rule> childRules(JsonNode node, RuleBuildContext ctx) {
|
||||||
Map<String, Map<String, Object>> p) {
|
|
||||||
List<Rule> list = new ArrayList<>();
|
List<Rule> list = new ArrayList<>();
|
||||||
JsonNode children = node.path("children");
|
JsonNode children = node.path("children");
|
||||||
if (children.isArray()) {
|
if (children.isArray()) {
|
||||||
for (JsonNode child : children) list.add(toRule(child, series, p));
|
for (JsonNode child : children) list.add(toRule(child, ctx));
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Rule buildAndRule(JsonNode node, BarSeries series,
|
||||||
|
Map<String, Map<String, Object>> p) {
|
||||||
|
return buildAndRule(node, new RuleBuildContext(series, p, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rule buildOrRule(JsonNode node, BarSeries series,
|
||||||
|
Map<String, Map<String, Object>> p) {
|
||||||
|
return buildOrRule(node, new RuleBuildContext(series, p, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rule buildNotRule(JsonNode node, BarSeries series,
|
||||||
|
Map<String, Map<String, Object>> p) {
|
||||||
|
return buildNotRule(node, new RuleBuildContext(series, p, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Rule> childRules(JsonNode node, BarSeries series,
|
||||||
|
Map<String, Map<String, Object>> p) {
|
||||||
|
return childRules(node, new RuleBuildContext(series, p, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
// ── CONDITION ─────────────────────────────────────────────────────────────
|
// ── CONDITION ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Rule buildConditionRule(JsonNode cond, BarSeries series,
|
private Rule buildConditionRule(JsonNode cond, BarSeries series,
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ import {
|
|||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../utils/strategyEditorShared';
|
} from '../utils/strategyEditorShared';
|
||||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||||
|
import {
|
||||||
|
decodeConditionForEditor,
|
||||||
|
encodeConditionForSave,
|
||||||
|
addExtraStartSection,
|
||||||
|
hasMultipleStartSections,
|
||||||
|
normalizeStartCombineOp,
|
||||||
|
updateStartCombineOp,
|
||||||
|
type EditorConditionState,
|
||||||
|
} from '../utils/strategyConditionSerde';
|
||||||
|
import {
|
||||||
|
defaultStartMeta,
|
||||||
|
type StartCombineOp,
|
||||||
|
} from '../utils/strategyStartNodes';
|
||||||
import {
|
import {
|
||||||
COMPOSITE_INDICATOR_ITEMS,
|
COMPOSITE_INDICATOR_ITEMS,
|
||||||
compositePeriodLabel,
|
compositePeriodLabel,
|
||||||
@@ -36,6 +49,7 @@ import {
|
|||||||
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
||||||
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
|
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
|
||||||
import StrategyListEditor from './strategyEditor/StrategyListEditor';
|
import StrategyListEditor from './strategyEditor/StrategyListEditor';
|
||||||
|
import StartCombineOpControl from './strategyEditor/StartCombineOpControl';
|
||||||
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
||||||
import {
|
import {
|
||||||
loadEditorMode,
|
loadEditorMode,
|
||||||
@@ -80,6 +94,34 @@ function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayo
|
|||||||
positions: snap.positions ?? {},
|
positions: snap.positions ?? {},
|
||||||
edgeHandles: snap.edgeHandles ?? {},
|
edgeHandles: snap.edgeHandles ?? {},
|
||||||
orphans: snap.orphans ?? [],
|
orphans: snap.orphans ?? [],
|
||||||
|
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: snap.extraStartIds ?? [],
|
||||||
|
extraRoots: snap.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorState(
|
||||||
|
root: LogicNode | null,
|
||||||
|
layout: Pick<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
|
||||||
|
): EditorConditionState {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
startMeta: layout.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: layout.extraStartIds ?? [],
|
||||||
|
extraRoots: layout.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(layout.startCombineOp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
||||||
|
return {
|
||||||
|
positions: snap.positions ?? {},
|
||||||
|
edgeHandles: snap.edgeHandles ?? {},
|
||||||
|
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: snap.extraStartIds ?? [],
|
||||||
|
extraRoots: snap.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +156,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const [buyLayout, setBuyLayout] = useState(() => ({
|
const [buyLayout, setBuyLayout] = useState(() => ({
|
||||||
positions: initialDraftBuy.positions,
|
positions: initialDraftBuy.positions,
|
||||||
edgeHandles: initialDraftBuy.edgeHandles,
|
edgeHandles: initialDraftBuy.edgeHandles,
|
||||||
|
startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: initialDraftBuy.extraStartIds ?? [],
|
||||||
|
extraRoots: initialDraftBuy.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
|
||||||
}));
|
}));
|
||||||
const [sellLayout, setSellLayout] = useState(() => ({
|
const [sellLayout, setSellLayout] = useState(() => ({
|
||||||
positions: initialDraftSell.positions,
|
positions: initialDraftSell.positions,
|
||||||
edgeHandles: initialDraftSell.edgeHandles,
|
edgeHandles: initialDraftSell.edgeHandles,
|
||||||
|
startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: initialDraftSell.extraStartIds ?? [],
|
||||||
|
extraRoots: initialDraftSell.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
|
||||||
}));
|
}));
|
||||||
const buyLayoutRef = useRef(buyLayout);
|
const buyLayoutRef = useRef(buyLayout);
|
||||||
const sellLayoutRef = useRef(sellLayout);
|
const sellLayoutRef = useRef(sellLayout);
|
||||||
@@ -165,10 +215,28 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const currentOrphans = signalTab === 'buy' ? buyOrphans : sellOrphans;
|
const currentOrphans = signalTab === 'buy' ? buyOrphans : sellOrphans;
|
||||||
const setCurrentOrphans = signalTab === 'buy' ? setBuyOrphans : setSellOrphans;
|
const setCurrentOrphans = signalTab === 'buy' ? setBuyOrphans : setSellOrphans;
|
||||||
|
|
||||||
|
const currentLayout = signalTab === 'buy' ? buyLayout : sellLayout;
|
||||||
|
const setCurrentLayout = signalTab === 'buy' ? setBuyLayout : setSellLayout;
|
||||||
|
|
||||||
|
const buyEditorState = useMemo(
|
||||||
|
() => toEditorState(buyCondition, buyLayout),
|
||||||
|
[buyCondition, buyLayout],
|
||||||
|
);
|
||||||
|
const sellEditorState = useMemo(
|
||||||
|
() => toEditorState(sellCondition, sellLayout),
|
||||||
|
[sellCondition, sellLayout],
|
||||||
|
);
|
||||||
|
const currentEditorState = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||||
|
|
||||||
const selectedLogicNode = useMemo(() => {
|
const selectedLogicNode = useMemo(() => {
|
||||||
if (!selectedNodeId) return null;
|
if (!selectedNodeId) return null;
|
||||||
return findLogicNode(currentRoot, currentOrphans, selectedNodeId);
|
return findLogicNode(
|
||||||
}, [selectedNodeId, currentRoot, currentOrphans]);
|
currentRoot,
|
||||||
|
currentOrphans,
|
||||||
|
selectedNodeId,
|
||||||
|
currentLayout.extraRoots ?? {},
|
||||||
|
);
|
||||||
|
}, [selectedNodeId, currentRoot, currentOrphans, currentLayout.extraRoots]);
|
||||||
|
|
||||||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||||||
|
|
||||||
@@ -197,8 +265,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||||
const emptyBuy = emptySignalFlowLayout();
|
const emptyBuy = emptySignalFlowLayout();
|
||||||
const emptySell = emptySignalFlowLayout();
|
const emptySell = emptySignalFlowLayout();
|
||||||
setBuyLayout(emptyBuy);
|
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||||
setSellLayout(emptySell);
|
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||||
setBuyOrphans([]);
|
setBuyOrphans([]);
|
||||||
setSellOrphans([]);
|
setSellOrphans([]);
|
||||||
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
|
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
|
||||||
@@ -211,10 +279,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const nextBuy = {
|
const nextBuy = {
|
||||||
positions: stored.buy.positions ?? {},
|
positions: stored.buy.positions ?? {},
|
||||||
edgeHandles: stored.buy.edgeHandles ?? {},
|
edgeHandles: stored.buy.edgeHandles ?? {},
|
||||||
|
startMeta: stored.buy.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: stored.buy.extraStartIds ?? [],
|
||||||
|
extraRoots: stored.buy.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
|
||||||
};
|
};
|
||||||
const nextSell = {
|
const nextSell = {
|
||||||
positions: stored.sell.positions ?? {},
|
positions: stored.sell.positions ?? {},
|
||||||
edgeHandles: stored.sell.edgeHandles ?? {},
|
edgeHandles: stored.sell.edgeHandles ?? {},
|
||||||
|
startMeta: stored.sell.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: stored.sell.extraStartIds ?? [],
|
||||||
|
extraRoots: stored.sell.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
|
||||||
};
|
};
|
||||||
setBuyLayout(nextBuy);
|
setBuyLayout(nextBuy);
|
||||||
setSellLayout(nextSell);
|
setSellLayout(nextSell);
|
||||||
@@ -223,8 +299,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
const emptyBuy = emptySignalFlowLayout();
|
const emptyBuy = emptySignalFlowLayout();
|
||||||
const emptySell = emptySignalFlowLayout();
|
const emptySell = emptySignalFlowLayout();
|
||||||
setBuyLayout(emptyBuy);
|
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||||
setSellLayout(emptySell);
|
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||||
setBuyOrphans([]);
|
setBuyOrphans([]);
|
||||||
setSellOrphans([]);
|
setSellOrphans([]);
|
||||||
}
|
}
|
||||||
@@ -232,15 +308,47 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
}, [bumpLayoutSeed]);
|
}, [bumpLayoutSeed]);
|
||||||
|
|
||||||
const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => {
|
const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => {
|
||||||
const next = {
|
const patch = {
|
||||||
positions: snapshot.positions,
|
positions: snapshot.positions,
|
||||||
edgeHandles: snapshot.edgeHandles,
|
edgeHandles: snapshot.edgeHandles,
|
||||||
};
|
};
|
||||||
if (snapshot.tab === 'buy') setBuyLayout(next);
|
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
|
||||||
else setSellLayout(next);
|
else setSellLayout(prev => ({ ...prev, ...patch }));
|
||||||
schedulePersistFlowLayout(layoutStrategyKey);
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||||
|
|
||||||
|
const handleStartMetaChange = useCallback((meta: Record<string, { candleType: string }>) => {
|
||||||
|
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
|
||||||
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
|
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||||
|
|
||||||
|
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
|
||||||
|
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
|
||||||
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
|
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||||
|
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
|
||||||
|
|
||||||
|
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
|
||||||
|
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
|
||||||
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
|
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||||
|
|
||||||
|
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
|
||||||
|
setCurrentRoot(next.root);
|
||||||
|
setCurrentLayout(prev => ({
|
||||||
|
...prev,
|
||||||
|
startMeta: next.startMeta,
|
||||||
|
extraStartIds: next.extraStartIds,
|
||||||
|
extraRoots: next.extraRoots,
|
||||||
|
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
|
||||||
|
}));
|
||||||
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
|
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||||
|
|
||||||
|
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
||||||
|
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
|
||||||
|
}, [handleEditorStateChange, currentEditorState]);
|
||||||
|
|
||||||
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||||
persistFlowLayout(layoutStrategyKey);
|
persistFlowLayout(layoutStrategyKey);
|
||||||
@@ -333,13 +441,39 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectStrategy = (s: StrategyDto) => {
|
const handleSelectStrategy = (s: StrategyDto) => {
|
||||||
|
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
|
||||||
|
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
||||||
|
const stored = loadStrategyFlowLayout(String(s.id));
|
||||||
|
|
||||||
setSelectedId(s.id);
|
setSelectedId(s.id);
|
||||||
setStratName(s.name);
|
setStratName(s.name);
|
||||||
setStratDesc(s.description ?? '');
|
setStratDesc(s.description ?? '');
|
||||||
setBuyCondition(s.buyCondition ?? null);
|
setBuyCondition(buyDecoded.root);
|
||||||
setSellCondition(s.sellCondition ?? null);
|
setSellCondition(sellDecoded.root);
|
||||||
|
setBuyOrphans(stored?.buy.orphans ?? []);
|
||||||
|
setSellOrphans(stored?.sell.orphans ?? []);
|
||||||
|
setBuyLayout({
|
||||||
|
positions: stored?.buy.positions ?? {},
|
||||||
|
edgeHandles: stored?.buy.edgeHandles ?? {},
|
||||||
|
startMeta: stored?.buy.startMeta ?? buyDecoded.startMeta,
|
||||||
|
extraStartIds: stored?.buy.extraStartIds?.length ? stored.buy.extraStartIds : buyDecoded.extraStartIds,
|
||||||
|
extraRoots: stored?.buy.extraRoots && Object.keys(stored.buy.extraRoots).length
|
||||||
|
? stored.buy.extraRoots
|
||||||
|
: buyDecoded.extraRoots,
|
||||||
|
startCombineOp: normalizeStartCombineOp(stored?.buy.startCombineOp ?? buyDecoded.startCombineOp),
|
||||||
|
});
|
||||||
|
setSellLayout({
|
||||||
|
positions: stored?.sell.positions ?? {},
|
||||||
|
edgeHandles: stored?.sell.edgeHandles ?? {},
|
||||||
|
startMeta: stored?.sell.startMeta ?? sellDecoded.startMeta,
|
||||||
|
extraStartIds: stored?.sell.extraStartIds?.length ? stored.sell.extraStartIds : sellDecoded.extraStartIds,
|
||||||
|
extraRoots: stored?.sell.extraRoots && Object.keys(stored.sell.extraRoots).length
|
||||||
|
? stored.sell.extraRoots
|
||||||
|
: sellDecoded.extraRoots,
|
||||||
|
startCombineOp: normalizeStartCombineOp(stored?.sell.startCombineOp ?? sellDecoded.startCombineOp),
|
||||||
|
});
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
applyStoredFlowLayout(String(s.id), signalTab);
|
bumpLayoutSeed(String(s.id), signalTab);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNew = () => {
|
const handleNew = () => {
|
||||||
@@ -354,15 +488,17 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; }
|
if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; }
|
||||||
if (!buyCondition && !sellCondition) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
const encodedBuy = encodeConditionForSave(buyEditorState);
|
||||||
|
const encodedSell = encodeConditionForSave(sellEditorState);
|
||||||
|
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: ApiStrategyDto = {
|
const payload: ApiStrategyDto = {
|
||||||
id: selectedId ?? undefined,
|
id: selectedId ?? undefined,
|
||||||
name: stratName,
|
name: stratName,
|
||||||
description: stratDesc,
|
description: stratDesc,
|
||||||
buyCondition,
|
buyCondition: encodedBuy,
|
||||||
sellCondition,
|
sellCondition: encodedSell,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
const saved = await saveStrategy(payload);
|
const saved = await saveStrategy(payload);
|
||||||
@@ -373,11 +509,11 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const existing = prev.find(s => s.id === selectedId);
|
const existing = prev.find(s => s.id === selectedId);
|
||||||
if (existing && selectedId) {
|
if (existing && selectedId) {
|
||||||
return prev.map(s => s.id === selectedId
|
return prev.map(s => s.id === selectedId
|
||||||
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, updatedAt: saved?.updatedAt ?? now }
|
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
|
||||||
: s);
|
: s);
|
||||||
}
|
}
|
||||||
setSelectedId(dbId);
|
setSelectedId(dbId);
|
||||||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||||||
});
|
});
|
||||||
if (!selectedId) setSelectedId(dbId);
|
if (!selectedId) setSelectedId(dbId);
|
||||||
if (wasDraft) {
|
if (wasDraft) {
|
||||||
@@ -412,10 +548,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
setBuyLayout({
|
setBuyLayout({
|
||||||
positions: layout.buy.positions ?? {},
|
positions: layout.buy.positions ?? {},
|
||||||
edgeHandles: layout.buy.edgeHandles ?? {},
|
edgeHandles: layout.buy.edgeHandles ?? {},
|
||||||
|
startMeta: layout.buy.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: layout.buy.extraStartIds ?? [],
|
||||||
|
extraRoots: layout.buy.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(layout.buy.startCombineOp),
|
||||||
});
|
});
|
||||||
setSellLayout({
|
setSellLayout({
|
||||||
positions: layout.sell.positions ?? {},
|
positions: layout.sell.positions ?? {},
|
||||||
edgeHandles: layout.sell.edgeHandles ?? {},
|
edgeHandles: layout.sell.edgeHandles ?? {},
|
||||||
|
startMeta: layout.sell.startMeta ?? defaultStartMeta(),
|
||||||
|
extraStartIds: layout.sell.extraStartIds ?? [],
|
||||||
|
extraRoots: layout.sell.extraRoots ?? {},
|
||||||
|
startCombineOp: normalizeStartCombineOp(layout.sell.startCombineOp),
|
||||||
});
|
});
|
||||||
setBuyOrphans(layout.buy.orphans ?? []);
|
setBuyOrphans(layout.buy.orphans ?? []);
|
||||||
setSellOrphans(layout.sell.orphans ?? []);
|
setSellOrphans(layout.sell.orphans ?? []);
|
||||||
@@ -427,7 +571,9 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
}, [resetFlowLayout, bumpLayoutSeed, signalTab]);
|
}, [resetFlowLayout, bumpLayoutSeed, signalTab]);
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
const handleExport = useCallback(() => {
|
||||||
if (!buyCondition && !sellCondition) {
|
const encodedBuy = encodeConditionForSave(buyEditorState);
|
||||||
|
const encodedSell = encodeConditionForSave(sellEditorState);
|
||||||
|
if (!encodedBuy && !encodedSell) {
|
||||||
showSnack('내보낼 조건이 없습니다', false);
|
showSnack('내보낼 조건이 없습니다', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -435,8 +581,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const payload = buildStrategyExportPayload({
|
const payload = buildStrategyExportPayload({
|
||||||
name: stratName,
|
name: stratName,
|
||||||
description: stratDesc,
|
description: stratDesc,
|
||||||
buyCondition,
|
buyCondition: encodedBuy,
|
||||||
sellCondition,
|
sellCondition: encodedSell,
|
||||||
flowLayout: {
|
flowLayout: {
|
||||||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||||||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||||||
@@ -446,7 +592,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const safeName = (stratName || '전략').replace(/[^\w\uAC00-\uD7A3-]+/g, '_');
|
const safeName = (stratName || '전략').replace(/[^\w\uAC00-\uD7A3-]+/g, '_');
|
||||||
downloadStrategyJson(`${safeName}_${new Date().toISOString().slice(0, 10)}`, payload);
|
downloadStrategyJson(`${safeName}_${new Date().toISOString().slice(0, 10)}`, payload);
|
||||||
showSnack('JSON으로 내보냈습니다');
|
showSnack('JSON으로 내보냈습니다');
|
||||||
}, [buyCondition, sellCondition, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]);
|
}, [buyEditorState, sellEditorState, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]);
|
||||||
|
|
||||||
const handleImport = useCallback(async () => {
|
const handleImport = useCallback(async () => {
|
||||||
const text = await pickJsonFile();
|
const text = await pickJsonFile();
|
||||||
@@ -459,13 +605,23 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
}
|
}
|
||||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||||
const { data } = result;
|
const { data } = result;
|
||||||
setBuyCondition(data.buyCondition ?? null);
|
const buyDecoded = decodeConditionForEditor(data.buyCondition ?? null);
|
||||||
setSellCondition(data.sellCondition ?? null);
|
const sellDecoded = decodeConditionForEditor(data.sellCondition ?? null);
|
||||||
|
setBuyCondition(buyDecoded.root);
|
||||||
|
setSellCondition(sellDecoded.root);
|
||||||
setStratName(data.name ?? '가져온 전략');
|
setStratName(data.name ?? '가져온 전략');
|
||||||
setStratDesc(data.description ?? '');
|
setStratDesc(data.description ?? '');
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
applyImportedFlowLayout(data.flowLayout);
|
applyImportedFlowLayout(data.flowLayout);
|
||||||
|
setBuyLayout(prev => ({
|
||||||
|
...prev,
|
||||||
|
startCombineOp: normalizeStartCombineOp(data.flowLayout?.buy?.startCombineOp ?? buyDecoded.startCombineOp),
|
||||||
|
}));
|
||||||
|
setSellLayout(prev => ({
|
||||||
|
...prev,
|
||||||
|
startCombineOp: normalizeStartCombineOp(data.flowLayout?.sell?.startCombineOp ?? sellDecoded.startCombineOp),
|
||||||
|
}));
|
||||||
if (data.editorMode === 'list' || data.editorMode === 'graph') {
|
if (data.editorMode === 'list' || data.editorMode === 'graph') {
|
||||||
setEditorMode(data.editorMode);
|
setEditorMode(data.editorMode);
|
||||||
saveEditorMode(data.editorMode);
|
saveEditorMode(data.editorMode);
|
||||||
@@ -514,13 +670,22 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAddStartSection = useCallback(() => {
|
||||||
|
handleEditorStateChange(addExtraStartSection(currentEditorState));
|
||||||
|
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||||
|
}, [handleEditorStateChange, currentEditorState, bumpLayoutSeed, layoutStrategyKey, signalTab]);
|
||||||
|
|
||||||
const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
|
const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
|
||||||
|
if (type === 'start') {
|
||||||
|
handleAddStartSection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined);
|
const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||||||
const root = currentRoot;
|
const root = currentRoot;
|
||||||
if (!root) setCurrentRoot(newNode);
|
if (!root) setCurrentRoot(newNode);
|
||||||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||||
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
|
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
|
||||||
|
|
||||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||||
|
|
||||||
@@ -725,7 +890,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
<div className="se-main-row">
|
<div className="se-main-row">
|
||||||
<main className="se-center">
|
<main className="se-center">
|
||||||
<div className="se-center-panel">
|
<div className="se-center-panel">
|
||||||
<div className="se-center-work">
|
<div className={`se-center-work${editorMode === 'list' ? ' se-center-work--list' : ''}`}>
|
||||||
<div className="se-center-head">
|
<div className="se-center-head">
|
||||||
<div className="se-signal-tabs">
|
<div className="se-signal-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -744,6 +909,12 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{stratName && <span className="se-editing-name">{stratName}</span>}
|
{stratName && <span className="se-editing-name">{stratName}</span>}
|
||||||
|
{hasMultipleStartSections(currentEditorState) && (
|
||||||
|
<StartCombineOpControl
|
||||||
|
value={normalizeStartCombineOp(currentEditorState.startCombineOp)}
|
||||||
|
onChange={handleStartCombineOpChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editorMode === 'graph' ? (
|
{editorMode === 'graph' ? (
|
||||||
@@ -761,6 +932,12 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
onSelectNode={setSelectedNodeId}
|
onSelectNode={setSelectedNodeId}
|
||||||
layoutSeed={layoutSeed}
|
layoutSeed={layoutSeed}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
|
startMeta={currentLayout.startMeta}
|
||||||
|
extraStartIds={currentLayout.extraStartIds}
|
||||||
|
extraRoots={currentLayout.extraRoots}
|
||||||
|
onStartMetaChange={handleStartMetaChange}
|
||||||
|
onExtraStartIdsChange={handleExtraStartIdsChange}
|
||||||
|
onExtraRootsChange={handleExtraRootsChange}
|
||||||
/>
|
/>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
|
|
||||||
@@ -778,8 +955,22 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
setCurrentOrphans(currentOrphans.map(o => (
|
setCurrentOrphans(currentOrphans.map(o => (
|
||||||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||||||
)));
|
)));
|
||||||
} else if (currentRoot) {
|
return;
|
||||||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
}
|
||||||
|
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
|
||||||
|
if (findLogicNode(currentRoot, [], selectedNodeId)) {
|
||||||
|
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||||||
|
if (branch && findLogicNode(branch, [], selectedNodeId)) {
|
||||||
|
handleExtraRootsChange({
|
||||||
|
...(currentLayout.extraRoots ?? {}),
|
||||||
|
[startId]: updateNode(branch, selectedNodeId, n => ({ ...n, condition: c })),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -789,10 +980,13 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<StrategyListEditor
|
<StrategyListEditor
|
||||||
root={currentRoot}
|
editorState={currentEditorState}
|
||||||
signalTab={signalTab}
|
signalTab={signalTab}
|
||||||
def={DEF}
|
def={DEF}
|
||||||
onChange={setCurrentRoot}
|
onEditorStateChange={handleEditorStateChange}
|
||||||
|
onAddStart={handleAddStartSection}
|
||||||
|
orphans={currentOrphans}
|
||||||
|
onOrphansChange={setCurrentOrphans}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -811,6 +1005,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
<LogicExpressionPreview
|
<LogicExpressionPreview
|
||||||
buyCondition={buyCondition}
|
buyCondition={buyCondition}
|
||||||
sellCondition={sellCondition}
|
sellCondition={sellCondition}
|
||||||
|
buyEditorState={buyEditorState}
|
||||||
|
sellEditorState={sellEditorState}
|
||||||
orphanCount={orphanTotal}
|
orphanCount={orphanTotal}
|
||||||
def={DEF}
|
def={DEF}
|
||||||
/>
|
/>
|
||||||
@@ -834,8 +1030,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
onChange={e => setPaletteSearch(e.target.value)}
|
onChange={e => setPaletteSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="se-palette-section se-palette-section--logic">
|
<div className="se-palette-section se-palette-section--logic">
|
||||||
<h3>논리 연산</h3>
|
<h3>시작 · 논리</h3>
|
||||||
<div className="se-palette-grid se-palette-grid--3">
|
<div className="se-palette-grid se-palette-grid--3">
|
||||||
|
<PaletteChip
|
||||||
|
type="start"
|
||||||
|
value="START"
|
||||||
|
label="START"
|
||||||
|
desc="시간봉 시작점"
|
||||||
|
color="logic-start"
|
||||||
|
selected={selectedPaletteKey === 'start:START'}
|
||||||
|
onSelect={() => setSelectedPaletteKey('start:START')}
|
||||||
|
onAdd={() => applyPalette('start', 'START', 'START')}
|
||||||
|
/>
|
||||||
{operators.map(op => (
|
{operators.map(op => (
|
||||||
<PaletteChip
|
<PaletteChip
|
||||||
key={op.value}
|
key={op.value}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from '../utils/strategyPresets';
|
} from '../utils/strategyPresets';
|
||||||
|
|
||||||
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
||||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION';
|
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||||
|
|
||||||
interface ConditionDSL {
|
interface ConditionDSL {
|
||||||
indicatorType: string;
|
indicatorType: string;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import React, { memo, useCallback, useState } from 'react';
|
import React, { memo, useCallback, useState } from 'react';
|
||||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||||
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
import {
|
||||||
|
isStartNodeId,
|
||||||
|
STRATEGY_CANDLE_TYPES,
|
||||||
|
type HandleSide,
|
||||||
|
type StrategyFlowNodeData,
|
||||||
|
} from '../../utils/strategyFlowLayout';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
|
|
||||||
@@ -122,10 +127,14 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
|
|||||||
return { onDragOver, onDragLeave, onDrop };
|
return { onDragOver, onDragLeave, onDrop };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StartNode = memo(function StartNode({ data }: NodeProps) {
|
export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||||
const d = data as StrategyFlowNodeData;
|
const d = data as StrategyFlowNodeData;
|
||||||
const isSell = d.signalTab === 'sell';
|
const isSell = d.signalTab === 'sell';
|
||||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(START_NODE_ID, d);
|
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||||
|
const candleType = d.candleType ?? '1m';
|
||||||
|
const canDelete = id !== '__strategy_start__' && !!d.onDeleteStart;
|
||||||
|
|
||||||
|
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -135,13 +144,41 @@ export const StartNode = memo(function StartNode({ data }: NodeProps) {
|
|||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
<MultiSideHandles
|
<MultiSideHandles
|
||||||
nodeId={START_NODE_ID}
|
nodeId={id}
|
||||||
sourceOnly
|
sourceOnly
|
||||||
activeSourceSide={d.activeSourceSide}
|
activeSourceSide={d.activeSourceSide}
|
||||||
canSourceConnect={d.canSourceConnect !== false}
|
canSourceConnect={d.canSourceConnect !== false}
|
||||||
/>
|
/>
|
||||||
<div className="se-flow-start-dot" />
|
<div className="se-flow-start-head">
|
||||||
<span>START</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import React from 'react';
|
|||||||
import type { LogicNode } from '../../utils/strategyTypes';
|
import type { LogicNode } from '../../utils/strategyTypes';
|
||||||
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
|
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
|
||||||
import { getIndicatorPaletteCategory } from './paletteCategories';
|
import { getIndicatorPaletteCategory } from './paletteCategories';
|
||||||
|
import {
|
||||||
|
collectEditorBranches,
|
||||||
|
normalizeStartCombineOp,
|
||||||
|
type EditorConditionState,
|
||||||
|
} from '../../utils/strategyConditionSerde';
|
||||||
|
import { formatStartCandleLabel } from '../../utils/strategyStartNodes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
buyCondition: LogicNode | null;
|
buyCondition: LogicNode | null;
|
||||||
sellCondition: LogicNode | null;
|
sellCondition: LogicNode | null;
|
||||||
|
buyEditorState?: EditorConditionState;
|
||||||
|
sellEditorState?: EditorConditionState;
|
||||||
orphanCount: number;
|
orphanCount: number;
|
||||||
def: DefType;
|
def: DefType;
|
||||||
}
|
}
|
||||||
@@ -81,34 +89,106 @@ function renderNode(node: LogicNode, def: DefType): React.ReactNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'TIMEFRAME') {
|
||||||
|
const inner = node.children?.[0];
|
||||||
|
const tf = formatStartCandleLabel(node.candleType ?? '1m');
|
||||||
|
return inner ? (
|
||||||
|
<>
|
||||||
|
<span className="se-formula-tf">[{tf}]</span>
|
||||||
|
{' '}
|
||||||
|
{renderNode(inner, def)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="se-formula-empty">[{tf} 빈 조건]</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSignalBranches(
|
||||||
|
editorState: EditorConditionState | undefined,
|
||||||
|
fallbackRoot: LogicNode | null,
|
||||||
|
def: DefType,
|
||||||
|
): React.ReactNode {
|
||||||
|
const branches = editorState
|
||||||
|
? collectEditorBranches(editorState)
|
||||||
|
: fallbackRoot
|
||||||
|
? [{ candleType: '1m', root: fallbackRoot }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (branches.length === 0) {
|
||||||
|
return <span className="se-formula-empty">(연결된 조건 없음)</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branches.length === 1) {
|
||||||
|
const { candleType, root } = branches[0];
|
||||||
|
if (!root) return <span className="se-formula-empty">(연결된 조건 없음)</span>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="se-formula-tf">[{formatStartCandleLabel(candleType)}]</span>
|
||||||
|
{' '}
|
||||||
|
{renderNode(root, def)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combineOp = normalizeStartCombineOp(editorState?.startCombineOp);
|
||||||
|
const combineClass = combineOp === 'AND' ? 'se-formula-logic--and' : 'se-formula-logic--or';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="se-formula-punc">(</span>
|
||||||
|
{branches.map((branch, i) => (
|
||||||
|
<React.Fragment key={`${branch.candleType}-${i}`}>
|
||||||
|
{i > 0 ? (
|
||||||
|
<span className={`se-formula-logic ${combineClass}`}> {combineOp} </span>
|
||||||
|
) : null}
|
||||||
|
{branch.root ? (
|
||||||
|
<>
|
||||||
|
<span className="se-formula-tf">[{formatStartCandleLabel(branch.candleType)}]</span>
|
||||||
|
{' '}
|
||||||
|
{renderNode(branch.root, def)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="se-formula-empty">[{formatStartCandleLabel(branch.candleType)} 빈 조건]</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<span className="se-formula-punc">)</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LogicExpressionPreview({
|
export default function LogicExpressionPreview({
|
||||||
buyCondition,
|
buyCondition,
|
||||||
sellCondition,
|
sellCondition,
|
||||||
|
buyEditorState,
|
||||||
|
sellEditorState,
|
||||||
orphanCount,
|
orphanCount,
|
||||||
def,
|
def,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const hasBody = buyCondition || sellCondition;
|
const hasBody = buyCondition || sellCondition
|
||||||
|
|| (buyEditorState && collectEditorBranches(buyEditorState).some(b => b.root))
|
||||||
|
|| (sellEditorState && collectEditorBranches(sellEditorState).some(b => b.root));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="se-terminal-text se-terminal-text--rich">
|
<div className="se-terminal-text se-terminal-text--rich">
|
||||||
{!hasBody && (
|
{!hasBody && (
|
||||||
<span className="se-formula-empty">(연결된 조건을 구성하세요)</span>
|
<span className="se-formula-empty">(연결된 조건을 구성하세요)</span>
|
||||||
)}
|
)}
|
||||||
{buyCondition && (
|
{(buyCondition || buyEditorState) && (
|
||||||
<div className="se-formula-line">
|
<div className="se-formula-line">
|
||||||
<span className="se-formula-signal se-formula-signal--buy">[매수]</span>
|
<span className="se-formula-signal se-formula-signal--buy">[매수]</span>
|
||||||
{' '}
|
{' '}
|
||||||
{renderNode(buyCondition, def)}
|
{renderSignalBranches(buyEditorState, buyCondition, def)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sellCondition && (
|
{(sellCondition || sellEditorState) && (
|
||||||
<div className="se-formula-line">
|
<div className="se-formula-line">
|
||||||
<span className="se-formula-signal se-formula-signal--sell">[매도]</span>
|
<span className="se-formula-signal se-formula-signal--sell">[매도]</span>
|
||||||
{' '}
|
{' '}
|
||||||
{renderNode(sellCondition, def)}
|
{renderSignalBranches(sellEditorState, sellCondition, def)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{orphanCount > 0 && (
|
{orphanCount > 0 && (
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export interface PaletteDragPayload {
|
export interface PaletteDragPayload {
|
||||||
type: 'operator' | 'indicator';
|
type: 'operator' | 'indicator' | 'start';
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
composite?: boolean;
|
composite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: 'operator' | 'indicator';
|
type: 'operator' | 'indicator' | 'start';
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { StartCombineOp } from '../../utils/strategyStartNodes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: StartCombineOp;
|
||||||
|
onChange: (op: StartCombineOp) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StartCombineOpControl({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="se-start-combine" role="group" aria-label="START 간 연산">
|
||||||
|
<span className="se-start-combine-label">START 간</span>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,13 +43,23 @@ import {
|
|||||||
isDescendant,
|
isDescendant,
|
||||||
isOrphanNode,
|
isOrphanNode,
|
||||||
isPaletteConnectTarget,
|
isPaletteConnectTarget,
|
||||||
|
isStartNodeId,
|
||||||
isValidStrategyConnection,
|
isValidStrategyConnection,
|
||||||
resolveStrategyConnection,
|
resolveStrategyConnection,
|
||||||
resolveOrphanDragConnection,
|
resolveOrphanDragConnection,
|
||||||
logicNodeToFlow,
|
logicNodeToFlow,
|
||||||
|
computeAutoFlowLayout,
|
||||||
spawnPositionNearAnchor,
|
spawnPositionNearAnchor,
|
||||||
|
subtreeToFlatOrphans,
|
||||||
type EdgeHandleBinding,
|
type EdgeHandleBinding,
|
||||||
} from '../../utils/strategyFlowLayout';
|
} from '../../utils/strategyFlowLayout';
|
||||||
|
import {
|
||||||
|
createStartNodeId,
|
||||||
|
defaultStartMeta,
|
||||||
|
DEFAULT_START_CANDLE,
|
||||||
|
normalizeStartCandleType,
|
||||||
|
type StartNodeMeta,
|
||||||
|
} from '../../utils/strategyStartNodes';
|
||||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||||
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
||||||
@@ -90,6 +100,12 @@ interface Props {
|
|||||||
onSelectNode: (id: string | null) => void;
|
onSelectNode: (id: string | null) => void;
|
||||||
layoutSeed: FlowLayoutSeed;
|
layoutSeed: FlowLayoutSeed;
|
||||||
onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void;
|
onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void;
|
||||||
|
startMeta?: Record<string, StartNodeMeta>;
|
||||||
|
extraStartIds?: string[];
|
||||||
|
extraRoots?: Record<string, LogicNode | null>;
|
||||||
|
onStartMetaChange?: (meta: Record<string, StartNodeMeta>) => void;
|
||||||
|
onExtraStartIdsChange?: (ids: string[]) => void;
|
||||||
|
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTreeIds(node: LogicNode | null): string[] {
|
function collectTreeIds(node: LogicNode | null): string[] {
|
||||||
@@ -117,28 +133,76 @@ function saveEdgeHandles(
|
|||||||
function applyTreeConnection(
|
function applyTreeConnection(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
root: LogicNode,
|
root: LogicNode | null,
|
||||||
|
extraRoots: Record<string, LogicNode | null>,
|
||||||
onChange: (root: LogicNode | null) => void,
|
onChange: (root: LogicNode | null) => void,
|
||||||
|
onExtraRootsChange: (roots: Record<string, LogicNode | null>) => void,
|
||||||
) {
|
) {
|
||||||
if (sourceId === START_NODE_ID) {
|
const promoteToStart = (startId: string, node: LogicNode, remainder: LogicNode | null) => {
|
||||||
const { tree, node } = extractNode(root, targetId);
|
if (startId === START_NODE_ID) {
|
||||||
if (!node) return;
|
onChange(remainder ? mergeAtRoot(node, remainder, false) : node);
|
||||||
onChange(node);
|
return;
|
||||||
if (tree && tree.children?.length) {
|
}
|
||||||
onChange(mergeAtRoot(node, tree, false));
|
const branch = extraRoots[startId] ?? null;
|
||||||
|
onExtraRootsChange({
|
||||||
|
...extraRoots,
|
||||||
|
[startId]: branch ? mergeAtRoot(branch, node, false) : node,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isStartNodeId(sourceId)) {
|
||||||
|
if (root && (root.id === targetId || findNodeInTree(root, targetId))) {
|
||||||
|
const { tree, node } = extractNode(root, targetId);
|
||||||
|
if (!node) return;
|
||||||
|
onChange(tree);
|
||||||
|
promoteToStart(sourceId, node, tree);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||||
|
if (!branch || !findNodeInTree(branch, targetId)) continue;
|
||||||
|
const { tree, node } = extractNode(branch, targetId);
|
||||||
|
if (!node) return;
|
||||||
|
onExtraRootsChange({ ...extraRoots, [sid]: tree });
|
||||||
|
promoteToStart(sourceId, node, null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceNode = findNodeInTree(root, sourceId);
|
if (!root) return;
|
||||||
|
const sourceNode = findNodeInTree(root, sourceId)
|
||||||
|
?? Object.values(extraRoots).map(br => findNodeInTree(br, sourceId)).find(Boolean);
|
||||||
if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return;
|
if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return;
|
||||||
if (isDescendant(root, targetId, sourceId)) return;
|
|
||||||
|
|
||||||
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
|
if (findNodeInTree(root, targetId)) {
|
||||||
if (!moved) return;
|
if (isDescendant(root, targetId, sourceId)) return;
|
||||||
const base = afterRemove ?? root;
|
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
|
||||||
if (sourceId === targetId) return;
|
if (!moved) return;
|
||||||
onChange(addChild(base, sourceId, moved));
|
onChange(addChild(afterRemove ?? root, sourceId, moved));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||||
|
if (!branch || !findNodeInTree(branch, targetId)) continue;
|
||||||
|
if (isDescendant(branch, targetId, sourceId)) return;
|
||||||
|
const { tree, node: moved } = extractNode(branch, targetId);
|
||||||
|
if (!moved) return;
|
||||||
|
onExtraRootsChange({ ...extraRoots, [sid]: tree });
|
||||||
|
if (findNodeInTree(root, sourceId)) {
|
||||||
|
onChange(addChild(root, sourceId, moved));
|
||||||
|
} else {
|
||||||
|
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, sourceId));
|
||||||
|
if (parentBranch) {
|
||||||
|
const [psid, pbr] = parentBranch;
|
||||||
|
onExtraRootsChange({
|
||||||
|
...extraRoots,
|
||||||
|
[sid]: tree,
|
||||||
|
[psid]: addChild(pbr!, sourceId, moved),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneEdgeHandles(handles: Map<string, EdgeHandleBinding>, edges: Edge[]) {
|
function pruneEdgeHandles(handles: Map<string, EdgeHandleBinding>, edges: Edge[]) {
|
||||||
@@ -152,11 +216,21 @@ function connectOrphanIntoTree(
|
|||||||
conn: Connection,
|
conn: Connection,
|
||||||
orphan: LogicNode,
|
orphan: LogicNode,
|
||||||
root: LogicNode | null,
|
root: LogicNode | null,
|
||||||
|
extraRoots: Record<string, LogicNode | null>,
|
||||||
onChange: (root: LogicNode | null) => void,
|
onChange: (root: LogicNode | null) => void,
|
||||||
|
onExtraRootsChange: (roots: Record<string, LogicNode | null>) => void,
|
||||||
): void {
|
): void {
|
||||||
const sourceId = conn.source!;
|
const sourceId = conn.source!;
|
||||||
if (sourceId === START_NODE_ID) {
|
if (isStartNodeId(sourceId)) {
|
||||||
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
|
if (sourceId === START_NODE_ID) {
|
||||||
|
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const branch = extraRoots[sourceId] ?? null;
|
||||||
|
onExtraRootsChange({
|
||||||
|
...extraRoots,
|
||||||
|
[sourceId]: branch ? mergeAtRoot(branch, orphan, false) : orphan,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -189,6 +263,12 @@ function StrategyEditorCanvasInner({
|
|||||||
onSelectNode,
|
onSelectNode,
|
||||||
layoutSeed,
|
layoutSeed,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
|
startMeta = defaultStartMeta(),
|
||||||
|
extraStartIds = [],
|
||||||
|
extraRoots = {},
|
||||||
|
onStartMetaChange,
|
||||||
|
onExtraStartIdsChange,
|
||||||
|
onExtraRootsChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { fitView, screenToFlowPosition } = useReactFlow();
|
const { fitView, screenToFlowPosition } = useReactFlow();
|
||||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||||
@@ -212,29 +292,34 @@ function StrategyEditorCanvasInner({
|
|||||||
nodesRef.current = nodes;
|
nodesRef.current = nodes;
|
||||||
edgesRef.current = edges;
|
edgesRef.current = edges;
|
||||||
|
|
||||||
const treeStructureKey = useMemo(
|
const treeStructureKey = useMemo(() => {
|
||||||
() => collectTreeIds(root).join('|'),
|
const parts = [collectTreeIds(root).join('|')];
|
||||||
[root],
|
for (const sid of extraStartIds) parts.push(sid);
|
||||||
);
|
for (const br of Object.values(extraRoots)) parts.push(collectTreeIds(br).join('|'));
|
||||||
|
return parts.join('::');
|
||||||
|
}, [root, extraStartIds, extraRoots]);
|
||||||
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
|
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
|
||||||
|
|
||||||
/** START에서 연결된 트리 전체 (미연결 고아 제외) */
|
/** START에서 연결된 트리 전체 (미연결 고아 제외) */
|
||||||
const treeLinkedIds = useMemo(() => {
|
const treeLinkedIds = useMemo(() => {
|
||||||
const ids = new Set<string>([START_NODE_ID]);
|
const ids = new Set<string>([START_NODE_ID, ...extraStartIds]);
|
||||||
for (const id of collectTreeIds(root)) ids.add(id);
|
for (const id of collectTreeIds(root)) ids.add(id);
|
||||||
|
for (const br of Object.values(extraRoots)) {
|
||||||
|
for (const id of collectTreeIds(br)) ids.add(id);
|
||||||
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}, [root]);
|
}, [root, extraStartIds, extraRoots]);
|
||||||
|
|
||||||
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
|
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
|
||||||
|
|
||||||
const minimapNodeColor = useCallback((node: Node) => {
|
const minimapNodeColor = useCallback((node: Node) => {
|
||||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
if (isStartNodeId(node.id)) return minimapColors.start;
|
||||||
if (node.type === 'condition') return minimapColors.condition;
|
if (node.type === 'condition') return minimapColors.condition;
|
||||||
return minimapColors.logic;
|
return minimapColors.logic;
|
||||||
}, [minimapColors]);
|
}, [minimapColors]);
|
||||||
|
|
||||||
const minimapNodeStrokeColor = useCallback((node: Node) => {
|
const minimapNodeStrokeColor = useCallback((node: Node) => {
|
||||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
if (isStartNodeId(node.id)) return minimapColors.start;
|
||||||
return minimapColors.stroke;
|
return minimapColors.stroke;
|
||||||
}, [minimapColors]);
|
}, [minimapColors]);
|
||||||
|
|
||||||
@@ -285,31 +370,90 @@ function StrategyEditorCanvasInner({
|
|||||||
};
|
};
|
||||||
}, [emitLayoutChange]);
|
}, [emitLayoutChange]);
|
||||||
|
|
||||||
|
const handleStartCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||||
|
onStartMetaChange?.({
|
||||||
|
...startMeta,
|
||||||
|
[startId]: { candleType: normalizeStartCandleType(candleType) },
|
||||||
|
});
|
||||||
|
}, [startMeta, onStartMetaChange]);
|
||||||
|
|
||||||
|
const handleDeleteStart = useCallback((startId: string) => {
|
||||||
|
if (startId === START_NODE_ID) return;
|
||||||
|
const branch = extraRoots[startId];
|
||||||
|
if (branch) {
|
||||||
|
onOrphansChange([...orphans, ...subtreeToFlatOrphans(branch)]);
|
||||||
|
}
|
||||||
|
onExtraStartIdsChange?.(extraStartIds.filter(id => id !== startId));
|
||||||
|
const nextMeta = { ...startMeta };
|
||||||
|
delete nextMeta[startId];
|
||||||
|
onStartMetaChange?.(nextMeta);
|
||||||
|
const nextRoots = { ...extraRoots };
|
||||||
|
delete nextRoots[startId];
|
||||||
|
onExtraRootsChange?.(nextRoots);
|
||||||
|
positionsRef.current.delete(startId);
|
||||||
|
scheduleLayoutEmit();
|
||||||
|
}, [
|
||||||
|
extraRoots, extraStartIds, startMeta, orphans,
|
||||||
|
onOrphansChange, onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const addStartAt = useCallback((flowPos: { x: number; y: number }) => {
|
||||||
|
const newId = createStartNodeId();
|
||||||
|
onExtraStartIdsChange?.([...extraStartIds, newId]);
|
||||||
|
onStartMetaChange?.({
|
||||||
|
...startMeta,
|
||||||
|
[newId]: { candleType: DEFAULT_START_CANDLE },
|
||||||
|
});
|
||||||
|
onExtraRootsChange?.({ ...extraRoots, [newId]: null });
|
||||||
|
positionsRef.current.set(newId, {
|
||||||
|
x: flowPos.x - getNodeDimensions(newId).w / 2,
|
||||||
|
y: flowPos.y - getNodeDimensions(newId).h / 2,
|
||||||
|
});
|
||||||
|
scheduleLayoutEmit();
|
||||||
|
}, [
|
||||||
|
extraStartIds, extraRoots, startMeta,
|
||||||
|
onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDelete = useCallback((id: string) => {
|
const handleDelete = useCallback((id: string) => {
|
||||||
if (id === START_NODE_ID) return;
|
if (isStartNodeId(id)) return;
|
||||||
if (isOrphanNode(orphans, id)) {
|
if (isOrphanNode(orphans, id)) {
|
||||||
onOrphansChange(orphans.filter(o => o.id !== id));
|
onOrphansChange(orphans.filter(o => o.id !== id));
|
||||||
positionsRef.current.delete(id);
|
positionsRef.current.delete(id);
|
||||||
if (selectedNodeId === id) onSelectNode(null);
|
if (selectedNodeId === id) onSelectNode(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!root) return;
|
if (root && (root.id === id || findNodeInTree(root, id))) {
|
||||||
if (root.id === id) {
|
if (root.id === id) {
|
||||||
onChange(null);
|
onChange(null);
|
||||||
onSelectNode(null);
|
} else {
|
||||||
|
onChange(deleteNode(root, id));
|
||||||
|
}
|
||||||
positionsRef.current.delete(id);
|
positionsRef.current.delete(id);
|
||||||
|
if (selectedNodeId === id) onSelectNode(null);
|
||||||
|
scheduleLayoutEmit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = deleteNode(root, id);
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||||
onChange(next);
|
if (!branch || !findNodeInTree(branch, id)) continue;
|
||||||
positionsRef.current.delete(id);
|
if (branch.id === id) {
|
||||||
if (selectedNodeId === id) onSelectNode(null);
|
onExtraRootsChange?.({ ...extraRoots, [sid]: null });
|
||||||
scheduleLayoutEmit();
|
} else {
|
||||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId, scheduleLayoutEmit]);
|
onExtraRootsChange?.({ ...extraRoots, [sid]: deleteNode(branch, id) });
|
||||||
|
}
|
||||||
|
positionsRef.current.delete(id);
|
||||||
|
if (selectedNodeId === id) onSelectNode(null);
|
||||||
|
scheduleLayoutEmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange,
|
||||||
|
onSelectNode, selectedNodeId, scheduleLayoutEmit,
|
||||||
|
]);
|
||||||
|
|
||||||
const deleteSelectedNodes = useCallback(() => {
|
const deleteSelectedNodes = useCallback(() => {
|
||||||
const ids = nodesRef.current
|
const ids = nodesRef.current
|
||||||
.filter(n => n.selected && n.id !== START_NODE_ID)
|
.filter(n => n.selected && !isStartNodeId(n.id))
|
||||||
.map(n => n.id);
|
.map(n => n.id);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
|
|
||||||
@@ -426,6 +570,11 @@ function StrategyEditorCanvasInner({
|
|||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
flowPos: { x: number; y: number },
|
flowPos: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
|
if (data.type === 'start') {
|
||||||
|
addStartAt(flowPos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
@@ -438,20 +587,23 @@ function StrategyEditorCanvasInner({
|
|||||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
|
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!root) {
|
if (isStartNodeId(anchorId)) {
|
||||||
onChange(newNode);
|
if (anchorId === START_NODE_ID) {
|
||||||
saveAttachHandles(START_NODE_ID);
|
onChange(root ? mergeAtRoot(root, newNode, data.type === 'operator') : newNode);
|
||||||
scheduleLayoutEmit();
|
} else {
|
||||||
return;
|
const branch = extraRoots[anchorId] ?? null;
|
||||||
}
|
onExtraRootsChange?.({
|
||||||
if (anchorId === START_NODE_ID) {
|
...extraRoots,
|
||||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
[anchorId]: branch ? mergeAtRoot(branch, newNode, data.type === 'operator') : newNode,
|
||||||
saveAttachHandles(START_NODE_ID);
|
});
|
||||||
|
}
|
||||||
|
saveAttachHandles(anchorId);
|
||||||
scheduleLayoutEmit();
|
scheduleLayoutEmit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorNode = findNodeInTree(root, anchorId);
|
const anchorNode = findNodeInTree(root, anchorId)
|
||||||
|
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
|
||||||
if (anchorNode?.type === 'CONDITION') {
|
if (anchorNode?.type === 'CONDITION') {
|
||||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||||
return;
|
return;
|
||||||
@@ -459,10 +611,21 @@ function StrategyEditorCanvasInner({
|
|||||||
|
|
||||||
if (!canAcceptPaletteAsParent(anchorId, root)) return;
|
if (!canAcceptPaletteAsParent(anchorId, root)) return;
|
||||||
|
|
||||||
onChange(addChild(root, anchorId, newNode));
|
if (root && findNodeInTree(root, anchorId)) {
|
||||||
|
onChange(addChild(root, anchorId, newNode));
|
||||||
|
} else {
|
||||||
|
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId));
|
||||||
|
if (parentBranch) {
|
||||||
|
const [sid, br] = parentBranch;
|
||||||
|
onExtraRootsChange?.({
|
||||||
|
...extraRoots,
|
||||||
|
[sid]: addChild(br!, anchorId, newNode),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
saveAttachHandles(anchorId);
|
saveAttachHandles(anchorId);
|
||||||
scheduleLayoutEmit();
|
scheduleLayoutEmit();
|
||||||
}, [root, signalTab, def, onChange, scheduleLayoutEmit]);
|
}, [root, extraRoots, signalTab, def, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit]);
|
||||||
|
|
||||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||||
updateDropPreview(anchorId, flowPos);
|
updateDropPreview(anchorId, flowPos);
|
||||||
@@ -496,9 +659,20 @@ function StrategyEditorCanvasInner({
|
|||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!root) return;
|
if (root && findNodeInTree(root, id)) {
|
||||||
onChange(updateNode(root, id, n => ({ ...n, condition })));
|
onChange(updateNode(root, id, n => ({ ...n, condition })));
|
||||||
}, [root, orphans, onChange, onOrphansChange]);
|
return;
|
||||||
|
}
|
||||||
|
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||||
|
if (branch && findNodeInTree(branch, id)) {
|
||||||
|
onExtraRootsChange?.({
|
||||||
|
...extraRoots,
|
||||||
|
[sid]: updateNode(branch, id, n => ({ ...n, condition })),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||||
|
|
||||||
const flowCallbacks = useMemo(() => ({
|
const flowCallbacks = useMemo(() => ({
|
||||||
onDelete: handleDelete,
|
onDelete: handleDelete,
|
||||||
@@ -506,7 +680,13 @@ function StrategyEditorCanvasInner({
|
|||||||
onDropTarget: handleDropTarget,
|
onDropTarget: handleDropTarget,
|
||||||
onDragOverTarget: handleDragOverTarget,
|
onDragOverTarget: handleDragOverTarget,
|
||||||
onDragLeaveTarget: handleDragLeaveTarget,
|
onDragLeaveTarget: handleDragLeaveTarget,
|
||||||
}), [handleDelete, handleUpdateCondition, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
onStartCandleTypeChange: handleStartCandleTypeChange,
|
||||||
|
onDeleteStart: handleDeleteStart,
|
||||||
|
}), [
|
||||||
|
handleDelete, handleUpdateCondition, handleDropTarget,
|
||||||
|
handleDragOverTarget, handleDragLeaveTarget,
|
||||||
|
handleStartCandleTypeChange, handleDeleteStart,
|
||||||
|
]);
|
||||||
|
|
||||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||||
root,
|
root,
|
||||||
@@ -516,7 +696,10 @@ function StrategyEditorCanvasInner({
|
|||||||
flowCallbacks,
|
flowCallbacks,
|
||||||
null,
|
null,
|
||||||
orphans,
|
orphans,
|
||||||
), [root, def, signalTab, flowCallbacks, orphans]);
|
startMeta,
|
||||||
|
extraStartIds,
|
||||||
|
extraRoots,
|
||||||
|
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]);
|
||||||
|
|
||||||
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -573,20 +756,22 @@ function StrategyEditorCanvasInner({
|
|||||||
if (!edge?.source || !edge.target) return;
|
if (!edge?.source || !edge.target) return;
|
||||||
|
|
||||||
edgeHandlesRef.current.delete(edgeId);
|
edgeHandlesRef.current.delete(edgeId);
|
||||||
const { root: nextRoot, orphans: nextOrphans } = disconnectTreeEdge(
|
const { root: nextRoot, orphans: nextOrphans, extraRoots: nextExtraRoots } = disconnectTreeEdge(
|
||||||
edge.source,
|
edge.source,
|
||||||
edge.target,
|
edge.target,
|
||||||
root,
|
root,
|
||||||
orphans,
|
orphans,
|
||||||
|
extraRoots,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nextRoot === root && nextOrphans.length === orphans.length) return;
|
if (nextRoot === root && nextOrphans.length === orphans.length && nextExtraRoots === extraRoots) return;
|
||||||
|
|
||||||
onChange(nextRoot);
|
onChange(nextRoot);
|
||||||
onOrphansChange(nextOrphans);
|
onOrphansChange(nextOrphans);
|
||||||
|
onExtraRootsChange?.(nextExtraRoots);
|
||||||
onSelectNode(null);
|
onSelectNode(null);
|
||||||
setEdges(prev => prev.map(e => ({ ...e, selected: false })));
|
setEdges(prev => prev.map(e => ({ ...e, selected: false })));
|
||||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, setEdges]);
|
}, [root, orphans, extraRoots, onChange, onOrphansChange, onExtraRootsChange, onSelectNode, setEdges]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
edgeDisconnectRef.current = handleDisconnectEdge;
|
edgeDisconnectRef.current = handleDisconnectEdge;
|
||||||
@@ -607,8 +792,31 @@ function StrategyEditorCanvasInner({
|
|||||||
};
|
};
|
||||||
}, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]);
|
}, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]);
|
||||||
|
|
||||||
|
const handleAutoLayout = useCallback(() => {
|
||||||
|
const nextPositions = computeAutoFlowLayout(root, extraStartIds, extraRoots, orphans);
|
||||||
|
positionsRef.current = new Map(Object.entries(nextPositions));
|
||||||
|
edgeHandlesRef.current.clear();
|
||||||
|
|
||||||
|
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
|
||||||
|
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
|
||||||
|
|
||||||
|
setNodes(layoutNodes.map(n => ({
|
||||||
|
...n,
|
||||||
|
position: positionsRef.current.get(n.id) ?? n.position,
|
||||||
|
selected: nodesRef.current.find(p => p.id === n.id)?.selected ?? false,
|
||||||
|
})));
|
||||||
|
setEdges(mergedEdges);
|
||||||
|
scheduleLayoutEmit();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fitView({ padding: 0.35, duration: 320 });
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
root, extraStartIds, extraRoots, orphans, rebuildFlow, setNodes, setEdges,
|
||||||
|
scheduleLayoutEmit, fitView,
|
||||||
|
]);
|
||||||
|
|
||||||
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
|
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
|
||||||
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
|
const picked = selNodes.filter(n => !isStartNodeId(n.id));
|
||||||
if (picked.length === 1) onSelectNode(picked[0].id);
|
if (picked.length === 1) onSelectNode(picked[0].id);
|
||||||
else onSelectNode(null);
|
else onSelectNode(null);
|
||||||
if (selEdges.length === 1) {
|
if (selEdges.length === 1) {
|
||||||
@@ -622,8 +830,9 @@ function StrategyEditorCanvasInner({
|
|||||||
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
|
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
|
||||||
root,
|
root,
|
||||||
orphans,
|
orphans,
|
||||||
|
extraRoots,
|
||||||
),
|
),
|
||||||
[root, orphans],
|
[root, orphans, extraRoots],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyResolvedConnection = useCallback((
|
const applyResolvedConnection = useCallback((
|
||||||
@@ -639,7 +848,14 @@ function StrategyEditorCanvasInner({
|
|||||||
|
|
||||||
if (childOrphan) {
|
if (childOrphan) {
|
||||||
onOrphansChange(orphans.filter(o => o.id !== childId));
|
onOrphansChange(orphans.filter(o => o.id !== childId));
|
||||||
connectOrphanIntoTree(wired, childOrphan, root, onChange);
|
connectOrphanIntoTree(
|
||||||
|
wired,
|
||||||
|
childOrphan,
|
||||||
|
root,
|
||||||
|
extraRoots,
|
||||||
|
onChange,
|
||||||
|
next => onExtraRootsChange?.(next),
|
||||||
|
);
|
||||||
scheduleLayoutEmit();
|
scheduleLayoutEmit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -650,10 +866,16 @@ function StrategyEditorCanvasInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!root) return;
|
applyTreeConnection(
|
||||||
applyTreeConnection(parentId, childId, root, onChange);
|
parentId,
|
||||||
|
childId,
|
||||||
|
root,
|
||||||
|
extraRoots,
|
||||||
|
onChange,
|
||||||
|
next => onExtraRootsChange?.(next),
|
||||||
|
);
|
||||||
scheduleLayoutEmit();
|
scheduleLayoutEmit();
|
||||||
}, [root, orphans, onChange, onOrphansChange, scheduleLayoutEmit]);
|
}, [root, orphans, extraRoots, onChange, onExtraRootsChange, onOrphansChange, scheduleLayoutEmit]);
|
||||||
|
|
||||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||||
const positionChanges = changes.filter(
|
const positionChanges = changes.filter(
|
||||||
@@ -720,6 +942,7 @@ function StrategyEditorCanvasInner({
|
|||||||
nodesSnapshot,
|
nodesSnapshot,
|
||||||
root,
|
root,
|
||||||
orphans,
|
orphans,
|
||||||
|
extraRoots,
|
||||||
);
|
);
|
||||||
orphanDragTargetRef.current = resolved;
|
orphanDragTargetRef.current = resolved;
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
@@ -766,13 +989,13 @@ function StrategyEditorCanvasInner({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const onConnect = useCallback((conn: Connection) => {
|
const onConnect = useCallback((conn: Connection) => {
|
||||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
|
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
|
||||||
}, [root, orphans, applyResolvedConnection]);
|
}, [root, orphans, extraRoots, applyResolvedConnection]);
|
||||||
|
|
||||||
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
||||||
const resolved = resolveStrategyConnection(newConnection, root, orphans);
|
const resolved = resolveStrategyConnection(newConnection, root, orphans, extraRoots);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
|
|
||||||
edgeHandlesRef.current.delete(oldEdge.id);
|
edgeHandlesRef.current.delete(oldEdge.id);
|
||||||
@@ -797,7 +1020,7 @@ function StrategyEditorCanvasInner({
|
|||||||
positionsRef.current,
|
positionsRef.current,
|
||||||
edgeHandlesRef.current,
|
edgeHandlesRef.current,
|
||||||
));
|
));
|
||||||
}, [root, orphans, applyResolvedConnection, setEdges]);
|
}, [root, orphans, extraRoots, applyResolvedConnection, setEdges]);
|
||||||
|
|
||||||
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
|
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
|
||||||
|
|
||||||
@@ -815,6 +1038,10 @@ function StrategyEditorCanvasInner({
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
if (data.type === 'start') {
|
||||||
|
addStartAt(flowPos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const drop = resolvePaletteDrop(flowPos);
|
const drop = resolvePaletteDrop(flowPos);
|
||||||
if (drop.mode === 'connect') {
|
if (drop.mode === 'connect') {
|
||||||
applyDropWithAttach(drop.anchorId, data, flowPos);
|
applyDropWithAttach(drop.anchorId, data, flowPos);
|
||||||
@@ -822,7 +1049,7 @@ function StrategyEditorCanvasInner({
|
|||||||
addOrphanAt(data, flowPos);
|
addOrphanAt(data, flowPos);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, resolvePaletteDrop]);
|
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
|
||||||
|
|
||||||
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -917,6 +1144,22 @@ function StrategyEditorCanvasInner({
|
|||||||
nodeColor={minimapNodeColor}
|
nodeColor={minimapNodeColor}
|
||||||
nodeStrokeColor={minimapNodeStrokeColor}
|
nodeStrokeColor={minimapNodeStrokeColor}
|
||||||
/>
|
/>
|
||||||
|
<Panel position="top-right" className="se-canvas-auto-layout">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-canvas-auto-layout-btn"
|
||||||
|
title="자동 정렬"
|
||||||
|
aria-label="노드 자동 정렬"
|
||||||
|
onClick={handleAutoLayout}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<path d="M14 17h7M17.5 14v7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
<Panel position="top-left" className="se-canvas-toolbar">
|
<Panel position="top-left" className="se-canvas-toolbar">
|
||||||
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
||||||
<div className="se-canvas-interaction" role="group" aria-label="캔버스 조작 모드">
|
<div className="se-canvas-interaction" role="group" aria-label="캔버스 조작 모드">
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import {
|
|||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
|
import {
|
||||||
|
type EditorConditionState,
|
||||||
|
addExtraStartSection,
|
||||||
|
updateBranchRoot,
|
||||||
|
updateStartCandleType,
|
||||||
|
removeStartSection,
|
||||||
|
collectStartSections,
|
||||||
|
} from '../../utils/strategyConditionSerde';
|
||||||
|
import { STRATEGY_CANDLE_TYPES } from '../../utils/strategyStartNodes';
|
||||||
|
|
||||||
const NODE_COLORS: Record<string, string> = {
|
const NODE_COLORS: Record<string, string> = {
|
||||||
AND: '#4caf50',
|
AND: '#4caf50',
|
||||||
@@ -17,17 +26,20 @@ const NODE_COLORS: Record<string, string> = {
|
|||||||
NOT: '#ff9800',
|
NOT: '#ff9800',
|
||||||
};
|
};
|
||||||
const IND_COLOR = '#9c27b0';
|
const IND_COLOR = '#9c27b0';
|
||||||
|
const LIST_DROP_KEY = '__list__';
|
||||||
|
|
||||||
interface TreeNodeProps {
|
interface TreeNodeProps {
|
||||||
node: LogicNode;
|
node: LogicNode;
|
||||||
signalType: 'buy' | 'sell';
|
signalType: 'buy' | 'sell';
|
||||||
onUpdate: (n: LogicNode) => void;
|
onUpdate: (n: LogicNode) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string }) => void;
|
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => void;
|
||||||
dragOverId?: string | null;
|
dragOverKey?: string | null;
|
||||||
setDragOverId?: (id: string | null) => void;
|
setDragOverKey?: (key: string | null) => void;
|
||||||
|
dropScope: string;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
def: DefType;
|
def: DefType;
|
||||||
|
onAddStart?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||||
@@ -36,13 +48,16 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDropOnNode,
|
onDropOnNode,
|
||||||
dragOverId,
|
dragOverKey,
|
||||||
setDragOverId,
|
setDragOverKey,
|
||||||
|
dropScope,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
def,
|
def,
|
||||||
|
onAddStart,
|
||||||
}) => {
|
}) => {
|
||||||
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 [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
||||||
const label = node.type === 'CONDITION' && node.condition
|
const label = node.type === 'CONDITION' && node.condition
|
||||||
@@ -57,16 +72,20 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
if (!isLogic) return;
|
if (!isLogic) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDragOverId?.(node.id);
|
setDragOverKey?.(nodeDropKey);
|
||||||
};
|
};
|
||||||
const handleDragLeave = () => setDragOverId?.(null);
|
const handleDragLeave = () => setDragOverKey?.(null);
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
if (!isLogic) return;
|
if (!isLogic) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDragOverId?.(null);
|
setDragOverKey?.(null);
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
|
if (data.type === 'start') {
|
||||||
|
onAddStart?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
onDropOnNode?.(node.id, data);
|
onDropOnNode?.(node.id, data);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -81,7 +100,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`sp-tree-node${dragOverId === node.id ? ' sp-tree-node--over' : ''}`}
|
className={`sp-tree-node${dragOverKey === nodeDropKey ? ' sp-tree-node--over' : ''}`}
|
||||||
style={{ marginLeft: depth > 0 ? 12 : 0 }}
|
style={{ marginLeft: depth > 0 ? 12 : 0 }}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -138,13 +157,15 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
onUpdate={u => handleChildUpdate(child.id, u)}
|
onUpdate={u => handleChildUpdate(child.id, u)}
|
||||||
onDelete={() => handleChildDelete(child.id)}
|
onDelete={() => handleChildDelete(child.id)}
|
||||||
onDropOnNode={onDropOnNode}
|
onDropOnNode={onDropOnNode}
|
||||||
dragOverId={dragOverId}
|
dragOverKey={dragOverKey}
|
||||||
setDragOverId={setDragOverId}
|
setDragOverKey={setDragOverKey}
|
||||||
|
dropScope={dropScope}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
def={def}
|
def={def}
|
||||||
|
onAddStart={onAddStart}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{dragOverId === node.id && (
|
{dragOverKey === nodeDropKey && (
|
||||||
<div className="sp-drop-hint-inner">여기에 드롭하세요</div>
|
<div className="sp-drop-hint-inner">여기에 드롭하세요</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,37 +174,64 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface StartSectionBlockProps {
|
||||||
|
startId: string;
|
||||||
|
candleType: string;
|
||||||
root: LogicNode | null;
|
root: LogicNode | null;
|
||||||
|
canDelete: boolean;
|
||||||
signalTab: 'buy' | 'sell';
|
signalTab: 'buy' | 'sell';
|
||||||
def: DefType;
|
def: DefType;
|
||||||
onChange: (root: LogicNode | null) => void;
|
dragOverKey: string | null;
|
||||||
|
setDragOverKey: (key: string | null) => void;
|
||||||
|
onRootChange: (root: LogicNode | null) => void;
|
||||||
|
onCandleTypeChange: (candleType: string) => void;
|
||||||
|
onDeleteStart?: () => void;
|
||||||
|
onAddStart?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StrategyListEditor({ root, signalTab, def, onChange }: Props) {
|
function StartSectionBlock({
|
||||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
startId,
|
||||||
|
candleType,
|
||||||
|
root,
|
||||||
|
canDelete,
|
||||||
|
signalTab,
|
||||||
|
def,
|
||||||
|
dragOverKey,
|
||||||
|
setDragOverKey,
|
||||||
|
onRootChange,
|
||||||
|
onCandleTypeChange,
|
||||||
|
onDeleteStart,
|
||||||
|
onAddStart,
|
||||||
|
}: StartSectionBlockProps) {
|
||||||
|
const sectionDropKey = `${startId}:__root__`;
|
||||||
|
|
||||||
const applyDrop = useCallback((
|
const applyDrop = useCallback((
|
||||||
targetId: string | null,
|
targetId: string | null,
|
||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
) => {
|
) => {
|
||||||
|
if (data.type === 'start') {
|
||||||
|
onAddStart?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||||
if (!root) {
|
if (!root) {
|
||||||
onChange(newNode);
|
onRootChange(newNode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
onRootChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onChange(addChild(root, targetId, newNode));
|
onRootChange(addChild(root, targetId, newNode));
|
||||||
}, [root, signalTab, def, onChange]);
|
}, [root, signalTab, def, onRootChange, onAddStart]);
|
||||||
|
|
||||||
const handleRootDrop = (e: React.DragEvent) => {
|
const handleSectionDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOverId(null);
|
e.stopPropagation();
|
||||||
|
setDragOverKey(null);
|
||||||
try {
|
try {
|
||||||
applyDrop(null, JSON.parse(e.dataTransfer.getData('application/json')));
|
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
|
applyDrop(null, data);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -193,27 +241,153 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
|
|||||||
applyDrop(targetId, data);
|
applyDrop(targetId, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="sp-start-section">
|
||||||
|
<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>
|
||||||
|
{canDelete && (
|
||||||
|
<button type="button" className="sp-start-del" title="START 삭제" onClick={onDeleteStart}>×</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`sp-start-body${dragOverKey === sectionDropKey ? ' sp-start-body--over' : ''}`}
|
||||||
|
onDragOver={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOverKey(sectionDropKey);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOverKey(null)}
|
||||||
|
onDrop={handleSectionDrop}
|
||||||
|
>
|
||||||
|
{!root ? (
|
||||||
|
<div className="sp-drop-empty sp-drop-empty--section">
|
||||||
|
논리 연산자·지표를 드래그하여 [{candleType}] 조건을 구성하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TreeNodeComp
|
||||||
|
node={root}
|
||||||
|
signalType={signalTab}
|
||||||
|
onUpdate={onRootChange}
|
||||||
|
onDelete={() => onRootChange(null)}
|
||||||
|
onDropOnNode={handleDropOnNode}
|
||||||
|
dragOverKey={dragOverKey}
|
||||||
|
setDragOverKey={setDragOverKey}
|
||||||
|
dropScope={startId}
|
||||||
|
def={def}
|
||||||
|
onAddStart={onAddStart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editorState: EditorConditionState;
|
||||||
|
signalTab: 'buy' | 'sell';
|
||||||
|
def: DefType;
|
||||||
|
onEditorStateChange: (next: EditorConditionState) => void;
|
||||||
|
onAddStart?: () => void;
|
||||||
|
onOrphansChange?: (nodes: LogicNode[]) => void;
|
||||||
|
orphans?: LogicNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StrategyListEditor({
|
||||||
|
editorState,
|
||||||
|
signalTab,
|
||||||
|
def,
|
||||||
|
onEditorStateChange,
|
||||||
|
onAddStart,
|
||||||
|
onOrphansChange,
|
||||||
|
orphans = [],
|
||||||
|
}: Props) {
|
||||||
|
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
|
||||||
|
const sections = collectStartSections(editorState);
|
||||||
|
|
||||||
|
const handleAddStart = useCallback(() => {
|
||||||
|
if (onAddStart) {
|
||||||
|
onAddStart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onEditorStateChange(addExtraStartSection(editorState));
|
||||||
|
}, [onAddStart, onEditorStateChange, editorState]);
|
||||||
|
|
||||||
|
const handleRootChange = useCallback((startId: string, root: LogicNode | null) => {
|
||||||
|
onEditorStateChange(updateBranchRoot(editorState, startId, root));
|
||||||
|
}, [editorState, onEditorStateChange]);
|
||||||
|
|
||||||
|
const handleCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||||
|
onEditorStateChange(updateStartCandleType(editorState, startId, candleType));
|
||||||
|
}, [editorState, onEditorStateChange]);
|
||||||
|
|
||||||
|
const handleDeleteStart = useCallback((startId: string) => {
|
||||||
|
const { state, orphanedNodes } = removeStartSection(editorState, startId);
|
||||||
|
onEditorStateChange(state);
|
||||||
|
if (orphanedNodes.length && onOrphansChange) {
|
||||||
|
onOrphansChange([...orphans, ...orphanedNodes]);
|
||||||
|
}
|
||||||
|
}, [editorState, onEditorStateChange, onOrphansChange, orphans]);
|
||||||
|
|
||||||
|
const handleListDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
setDragOverKey(LIST_DROP_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleListDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOverKey(null);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
|
if (data.type === 'start') {
|
||||||
|
handleAddStart();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="se-list-editor sp-dropzone"
|
className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`}
|
||||||
onDragOver={e => e.preventDefault()}
|
onDragOver={handleListDragOver}
|
||||||
onDrop={handleRootDrop}
|
onDragLeave={() => setDragOverKey(null)}
|
||||||
|
onDrop={handleListDrop}
|
||||||
>
|
>
|
||||||
{!root ? (
|
{sections.map(section => (
|
||||||
<div className="sp-drop-empty">
|
<StartSectionBlock
|
||||||
우측 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요.
|
key={section.startId}
|
||||||
</div>
|
startId={section.startId}
|
||||||
) : (
|
candleType={section.candleType}
|
||||||
<TreeNodeComp
|
root={section.root}
|
||||||
node={root}
|
canDelete={section.canDelete}
|
||||||
signalType={signalTab}
|
signalTab={signalTab}
|
||||||
onUpdate={onChange}
|
|
||||||
onDelete={() => onChange(null)}
|
|
||||||
onDropOnNode={handleDropOnNode}
|
|
||||||
dragOverId={dragOverId}
|
|
||||||
setDragOverId={setDragOverId}
|
|
||||||
def={def}
|
def={def}
|
||||||
|
dragOverKey={dragOverKey}
|
||||||
|
setDragOverKey={setDragOverKey}
|
||||||
|
onRootChange={root => handleRootChange(section.startId, root)}
|
||||||
|
onCandleTypeChange={ct => handleCandleTypeChange(section.startId, ct)}
|
||||||
|
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
|
||||||
|
onAddStart={handleAddStart}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
{sections.length === 0 && (
|
||||||
|
<div className="sp-drop-empty">
|
||||||
|
START를 이 영역에 드래그하거나 우측 패널에서 추가하세요.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -92,12 +92,136 @@
|
|||||||
.se-list-editor {
|
.se-list-editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
margin: 8px 12px 12px;
|
margin: 8px 12px 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
padding-bottom: 28px;
|
||||||
border: 1px dashed var(--se-border);
|
border: 1px dashed var(--se-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--se-center-bg) 92%, var(--se-accent) 8%);
|
background: color-mix(in srgb, var(--se-center-bg) 92%, var(--se-accent) 8%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App.css .sp-dropzone 와 중복 flex/overflow 정리 */
|
||||||
|
.se-list-editor.sp-dropzone {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
padding-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-list-editor--over {
|
||||||
|
border-color: var(--se-node-start-border, var(--se-gold));
|
||||||
|
background: color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 8%, var(--se-center-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-section {
|
||||||
|
border: 1px solid var(--se-node-start-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--se-node-start-bg);
|
||||||
|
box-shadow: 0 0 14px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 14%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--se-node-start-border) 70%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--se-node-start-accent, var(--se-gold));
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 50%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-label {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--se-node-start-accent, var(--se-gold));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-tf {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--se-bg) 75%, transparent);
|
||||||
|
color: var(--se-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-del {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--se-danger) 15%, transparent);
|
||||||
|
color: var(--se-danger);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-body {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
min-height: 56px;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 목록형: START 내부 조건 트리 전체 표시 */
|
||||||
|
.se-list-editor .sp-tree-node {
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-list-editor .sp-node-label {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: unset;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-list-editor .sp-node-children {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-list-editor .sp-cond-editor {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-list-editor .sp-cond-row4 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-start-body--over {
|
||||||
|
background: color-mix(in srgb, var(--se-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-drop-empty--section {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-btn {
|
.se-btn {
|
||||||
@@ -394,16 +518,73 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-center-work--list .se-list-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-center-head {
|
.se-center-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
padding: 8px 12px 0;
|
padding: 8px 12px 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.se-start-combine {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-start-combine-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-start-combine-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--se-btn-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-start-combine-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-start-combine-btn--and.se-start-combine-btn--on {
|
||||||
|
background: color-mix(in srgb, #4caf50 22%, transparent);
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-start-combine-btn--or.se-start-combine-btn--on {
|
||||||
|
background: color-mix(in srgb, #2196f3 22%, transparent);
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-start-combine-btn:hover:not(.se-start-combine-btn--on) {
|
||||||
|
background: color-mix(in srgb, var(--se-accent) 10%, transparent);
|
||||||
|
color: var(--se-text);
|
||||||
|
}
|
||||||
|
|
||||||
.se-signal-tabs { display: flex; gap: 6px; }
|
.se-signal-tabs { display: flex; gap: 6px; }
|
||||||
|
|
||||||
.se-signal-tab {
|
.se-signal-tab {
|
||||||
@@ -500,6 +681,35 @@
|
|||||||
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.se-canvas-auto-layout {
|
||||||
|
margin: 8px 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-canvas-auto-layout-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--se-toolbar-bg);
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||||
|
box-shadow: 0 2px 8px color-mix(in srgb, var(--se-bg) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-canvas-auto-layout-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--se-accent) 12%, var(--se-toolbar-bg));
|
||||||
|
color: var(--se-tab-active);
|
||||||
|
border-color: color-mix(in srgb, var(--se-accent) 35%, var(--se-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-canvas-auto-layout-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
.se-canvas-wrap--pan .react-flow__pane {
|
.se-canvas-wrap--pan .react-flow__pane {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
@@ -652,17 +862,62 @@
|
|||||||
|
|
||||||
.se-flow-node--start {
|
.se-flow-node--start {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 8px;
|
align-items: stretch;
|
||||||
padding: 8px 14px;
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-width: 150px;
|
||||||
background: var(--se-node-start-bg);
|
background: var(--se-node-start-bg);
|
||||||
border: 1px solid var(--se-node-start-border);
|
border: 1px solid var(--se-node-start-border);
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted);
|
||||||
|
box-shadow: 0 0 18px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 20%, transparent);
|
||||||
|
}
|
||||||
|
.se-flow-node--start.se-flow-node--sell-mode {
|
||||||
|
background: linear-gradient(145deg, color-mix(in srgb, var(--se-sell) 18%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--se-sell) 42%, transparent);
|
||||||
|
box-shadow: 0 0 18px color-mix(in srgb, var(--se-sell) 16%, transparent);
|
||||||
|
}
|
||||||
|
.se-flow-start-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.se-flow-start-label {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--se-node-start-accent, var(--se-gold));
|
||||||
|
}
|
||||||
|
.se-flow-start-tf {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 72px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--se-bg) 70%, transparent);
|
||||||
|
color: var(--se-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.se-flow-start-tf:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--se-accent);
|
||||||
|
}
|
||||||
|
.se-flow-del--start {
|
||||||
|
position: static;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.se-flow-start-dot {
|
.se-flow-start-dot {
|
||||||
width: 8px; height: 8px; border-radius: 50%;
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
background: var(--se-text-dim);
|
background: var(--se-node-start-accent, var(--se-gold));
|
||||||
box-shadow: 0 0 6px color-mix(in srgb, var(--se-text-dim) 60%, transparent);
|
box-shadow: 0 0 8px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 55%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-flow-node--logic {
|
.se-flow-node--logic {
|
||||||
@@ -1000,6 +1255,11 @@
|
|||||||
color: var(--se-sell);
|
color: var(--se-sell);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.se-formula-tf {
|
||||||
|
color: var(--se-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
.se-formula-logic {
|
.se-formula-logic {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
|
|||||||
@@ -77,8 +77,9 @@
|
|||||||
--se-node-logic-bg: linear-gradient(145deg, color-mix(in srgb, var(--accent) 18%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
--se-node-logic-bg: linear-gradient(145deg, color-mix(in srgb, var(--accent) 18%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
||||||
--se-node-cond-bg: linear-gradient(145deg, color-mix(in srgb, var(--se-success) 16%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
--se-node-cond-bg: linear-gradient(145deg, color-mix(in srgb, var(--se-success) 16%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
||||||
--se-node-cond-border: color-mix(in srgb, var(--se-success) 42%, transparent);
|
--se-node-cond-border: color-mix(in srgb, var(--se-success) 42%, transparent);
|
||||||
--se-node-start-bg: color-mix(in srgb, var(--bg3) 88%, transparent);
|
--se-node-start-bg: linear-gradient(145deg, color-mix(in srgb, var(--se-gold) 22%, transparent), color-mix(in srgb, var(--bg2) 92%, transparent));
|
||||||
--se-node-start-border: var(--border);
|
--se-node-start-border: color-mix(in srgb, var(--se-gold) 48%, transparent);
|
||||||
|
--se-node-start-accent: var(--se-gold);
|
||||||
--se-btn-bg: color-mix(in srgb, var(--bg2) 90%, transparent);
|
--se-btn-bg: color-mix(in srgb, var(--bg2) 90%, transparent);
|
||||||
--se-btn-gold-bg: linear-gradient(135deg, color-mix(in srgb, var(--se-gold) 25%, transparent), color-mix(in srgb, var(--se-gold) 12%, transparent));
|
--se-btn-gold-bg: linear-gradient(135deg, color-mix(in srgb, var(--se-gold) 25%, transparent), color-mix(in srgb, var(--se-gold) 12%, transparent));
|
||||||
--se-btn-gold-border: color-mix(in srgb, var(--se-gold) 55%, transparent);
|
--se-btn-gold-border: color-mix(in srgb, var(--se-gold) 55%, transparent);
|
||||||
@@ -120,8 +121,9 @@
|
|||||||
--se-node-logic-bg: linear-gradient(145deg, rgba(25, 118, 210, 0.08), #ffffff);
|
--se-node-logic-bg: linear-gradient(145deg, rgba(25, 118, 210, 0.08), #ffffff);
|
||||||
--se-node-cond-bg: linear-gradient(145deg, rgba(46, 125, 50, 0.08), #ffffff);
|
--se-node-cond-bg: linear-gradient(145deg, rgba(46, 125, 50, 0.08), #ffffff);
|
||||||
--se-node-cond-border: rgba(46, 125, 50, 0.35);
|
--se-node-cond-border: rgba(46, 125, 50, 0.35);
|
||||||
--se-node-start-bg: #f5f5f5;
|
--se-node-start-bg: linear-gradient(145deg, rgba(245, 127, 23, 0.14), #ffffff);
|
||||||
--se-node-start-border: rgba(0, 0, 0, 0.12);
|
--se-node-start-border: rgba(245, 127, 23, 0.42);
|
||||||
|
--se-node-start-accent: #e65100;
|
||||||
--se-btn-gold-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04));
|
--se-btn-gold-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04));
|
||||||
--se-btn-gold-border: rgba(25, 118, 210, 0.45);
|
--se-btn-gold-border: rgba(25, 118, 210, 0.45);
|
||||||
--se-btn-gold-text: #1565c0;
|
--se-btn-gold-text: #1565c0;
|
||||||
@@ -163,7 +165,9 @@
|
|||||||
--se-node-logic-bg: linear-gradient(145deg, rgba(94, 181, 255, 0.14), rgba(12, 25, 41, 0.92));
|
--se-node-logic-bg: linear-gradient(145deg, rgba(94, 181, 255, 0.14), rgba(12, 25, 41, 0.92));
|
||||||
--se-node-cond-bg: linear-gradient(145deg, rgba(105, 240, 174, 0.12), rgba(12, 25, 41, 0.92));
|
--se-node-cond-bg: linear-gradient(145deg, rgba(105, 240, 174, 0.12), rgba(12, 25, 41, 0.92));
|
||||||
--se-node-cond-border: rgba(105, 240, 174, 0.38);
|
--se-node-cond-border: rgba(105, 240, 174, 0.38);
|
||||||
--se-node-start-bg: rgba(21, 39, 61, 0.88);
|
--se-node-start-bg: linear-gradient(145deg, rgba(255, 213, 79, 0.18), rgba(12, 25, 41, 0.92));
|
||||||
|
--se-node-start-border: rgba(255, 213, 79, 0.45);
|
||||||
|
--se-node-start-accent: #ffd54f;
|
||||||
--se-btn-gold-text: #ffd54f;
|
--se-btn-gold-text: #ffd54f;
|
||||||
--se-minimap-bg: rgba(12, 25, 41, 0.96);
|
--se-minimap-bg: rgba(12, 25, 41, 0.96);
|
||||||
--se-palette-logic-and-accent: #5eb5ff;
|
--se-palette-logic-and-accent: #5eb5ff;
|
||||||
@@ -191,6 +195,8 @@
|
|||||||
box-shadow: 0 2px 12px rgba(198, 40, 40, 0.06);
|
box-shadow: 0 2px 12px rgba(198, 40, 40, 0.06);
|
||||||
}
|
}
|
||||||
.se-page--light .se-flow-gate-badge { color: #1565c0; }
|
.se-page--light .se-flow-gate-badge { color: #1565c0; }
|
||||||
|
.se-page--light .se-flow-start-label { color: var(--se-node-start-accent, #e65100); }
|
||||||
|
.se-page--light .se-flow-start-dot { background: var(--se-node-start-accent, #e65100); }
|
||||||
.se-page--light .se-flow-cond-badge { color: #2e7d32; }
|
.se-page--light .se-flow-cond-badge { color: #2e7d32; }
|
||||||
.se-page--light .se-flow-node--cross .se-flow-cond-badge { color: #f57f17; }
|
.se-page--light .se-flow-node--cross .se-flow-cond-badge { color: #f57f17; }
|
||||||
.se-page--light .se-flow-node--sell-mode .se-flow-cond-badge { color: #c62828; }
|
.se-page--light .se-flow-node--sell-mode .se-flow-cond-badge { color: #c62828; }
|
||||||
@@ -216,6 +222,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.se-page--blue .se-flow-gate-badge { color: #5eb5ff; }
|
.se-page--blue .se-flow-gate-badge { color: #5eb5ff; }
|
||||||
|
.se-page--blue .se-flow-start-label { color: var(--se-node-start-accent, #ffd54f); }
|
||||||
|
.se-page--blue .se-flow-start-dot { background: var(--se-node-start-accent, #ffd54f); }
|
||||||
.se-page--blue .se-flow-cond-badge { color: #69f0ae; }
|
.se-page--blue .se-flow-cond-badge { color: #69f0ae; }
|
||||||
.se-page--blue .se-flow-handle {
|
.se-page--blue .se-flow-handle {
|
||||||
background: #5eb5ff !important;
|
background: #5eb5ff !important;
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import type { LogicNode } from './strategyTypes';
|
||||||
|
import { generateNodeId } from './strategyTypes';
|
||||||
|
import { subtreeToFlatOrphans } from './strategyFlowLayout';
|
||||||
|
import {
|
||||||
|
DEFAULT_START_CANDLE,
|
||||||
|
DEFAULT_START_COMBINE_OP,
|
||||||
|
START_NODE_ID,
|
||||||
|
createStartNodeId,
|
||||||
|
defaultStartMeta,
|
||||||
|
normalizeStartCandleType,
|
||||||
|
type StartCombineOp,
|
||||||
|
type StartNodeMeta,
|
||||||
|
} from './strategyStartNodes';
|
||||||
|
|
||||||
|
export type EditorConditionState = {
|
||||||
|
root: LogicNode | null;
|
||||||
|
startMeta: Record<string, StartNodeMeta>;
|
||||||
|
extraStartIds: string[];
|
||||||
|
extraRoots: Record<string, LogicNode | null>;
|
||||||
|
/** START 2개 이상일 때 분기 결합 (기본 AND) */
|
||||||
|
startCombineOp?: StartCombineOp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { StartCombineOp };
|
||||||
|
|
||||||
|
export type ConditionBranch = {
|
||||||
|
candleType: string;
|
||||||
|
root: LogicNode | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StartSection = {
|
||||||
|
startId: string;
|
||||||
|
candleType: string;
|
||||||
|
root: LogicNode | null;
|
||||||
|
canDelete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function collectStartSections(state: EditorConditionState): StartSection[] {
|
||||||
|
const sections: StartSection[] = [{
|
||||||
|
startId: START_NODE_ID,
|
||||||
|
candleType: normalizeStartCandleType(state.startMeta[START_NODE_ID]?.candleType),
|
||||||
|
root: state.root,
|
||||||
|
canDelete: false,
|
||||||
|
}];
|
||||||
|
for (const startId of state.extraStartIds) {
|
||||||
|
sections.push({
|
||||||
|
startId,
|
||||||
|
candleType: normalizeStartCandleType(state.startMeta[startId]?.candleType),
|
||||||
|
root: state.extraRoots[startId] ?? null,
|
||||||
|
canDelete: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMultipleStartSections(state: EditorConditionState): boolean {
|
||||||
|
return state.extraStartIds.length >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStartCombineOp(value: string | undefined | null): StartCombineOp {
|
||||||
|
return value === 'OR' ? 'OR' : DEFAULT_START_COMBINE_OP;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStartCombineOp(
|
||||||
|
state: EditorConditionState,
|
||||||
|
op: StartCombineOp,
|
||||||
|
): EditorConditionState {
|
||||||
|
return { ...state, startCombineOp: op };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBranchRoot(
|
||||||
|
state: EditorConditionState,
|
||||||
|
startId: string,
|
||||||
|
root: LogicNode | null,
|
||||||
|
): EditorConditionState {
|
||||||
|
if (startId === START_NODE_ID) {
|
||||||
|
return { ...state, root };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
extraRoots: { ...state.extraRoots, [startId]: root },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStartCandleType(
|
||||||
|
state: EditorConditionState,
|
||||||
|
startId: string,
|
||||||
|
candleType: string,
|
||||||
|
): EditorConditionState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
startMeta: {
|
||||||
|
...state.startMeta,
|
||||||
|
[startId]: { candleType: normalizeStartCandleType(candleType) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addExtraStartSection(state: EditorConditionState): EditorConditionState {
|
||||||
|
const startId = createStartNodeId();
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
extraStartIds: [...state.extraStartIds, startId],
|
||||||
|
startMeta: {
|
||||||
|
...state.startMeta,
|
||||||
|
[startId]: { candleType: DEFAULT_START_CANDLE },
|
||||||
|
},
|
||||||
|
extraRoots: { ...state.extraRoots, [startId]: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStartSection(
|
||||||
|
state: EditorConditionState,
|
||||||
|
startId: string,
|
||||||
|
): { state: EditorConditionState; orphanedNodes: LogicNode[] } {
|
||||||
|
if (startId === START_NODE_ID) {
|
||||||
|
return { state, orphanedNodes: [] };
|
||||||
|
}
|
||||||
|
const branch = state.extraRoots[startId];
|
||||||
|
const nextMeta = { ...state.startMeta };
|
||||||
|
delete nextMeta[startId];
|
||||||
|
const nextRoots = { ...state.extraRoots };
|
||||||
|
delete nextRoots[startId];
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
startMeta: nextMeta,
|
||||||
|
extraStartIds: state.extraStartIds.filter(id => id !== startId),
|
||||||
|
extraRoots: nextRoots,
|
||||||
|
},
|
||||||
|
orphanedNodes: branch ? subtreeToFlatOrphans(branch) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyEditorConditionState(): EditorConditionState {
|
||||||
|
return {
|
||||||
|
root: null,
|
||||||
|
startMeta: defaultStartMeta(),
|
||||||
|
extraStartIds: [],
|
||||||
|
extraRoots: {},
|
||||||
|
startCombineOp: DEFAULT_START_COMBINE_OP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapTimeframe(candleType: string, root: LogicNode): LogicNode {
|
||||||
|
return {
|
||||||
|
id: generateNodeId(),
|
||||||
|
type: 'TIMEFRAME',
|
||||||
|
candleType: normalizeStartCandleType(candleType),
|
||||||
|
children: [root],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
||||||
|
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
||||||
|
const branches = collectEditorBranches(state).filter(b => b.root != null) as Array<{
|
||||||
|
candleType: string;
|
||||||
|
root: LogicNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (branches.length === 0) return null;
|
||||||
|
if (branches.length === 1) {
|
||||||
|
const { candleType, root } = branches[0];
|
||||||
|
if (normalizeStartCandleType(candleType) === DEFAULT_START_CANDLE) return root;
|
||||||
|
return wrapTimeframe(candleType, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combineOp = normalizeStartCombineOp(state.startCombineOp);
|
||||||
|
return {
|
||||||
|
id: generateNodeId(),
|
||||||
|
type: combineOp,
|
||||||
|
children: branches.map(b => wrapTimeframe(b.candleType, b.root)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null {
|
||||||
|
if (dsl.type !== 'AND' && dsl.type !== 'OR') return null;
|
||||||
|
const children = dsl.children ?? [];
|
||||||
|
const allTimeframe = children.length > 0 && children.every(c => c.type === 'TIMEFRAME');
|
||||||
|
if (!allTimeframe) return null;
|
||||||
|
|
||||||
|
const startMeta: Record<string, StartNodeMeta> = {};
|
||||||
|
const extraStartIds: string[] = [];
|
||||||
|
const extraRoots: Record<string, LogicNode | null> = {};
|
||||||
|
let primaryRoot: LogicNode | null = null;
|
||||||
|
|
||||||
|
children.forEach((tf, index) => {
|
||||||
|
const candleType = normalizeStartCandleType(tf.candleType);
|
||||||
|
const branchRoot = tf.children?.[0] ?? null;
|
||||||
|
if (index === 0) {
|
||||||
|
startMeta[START_NODE_ID] = { candleType };
|
||||||
|
primaryRoot = branchRoot;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startId = createStartNodeId();
|
||||||
|
startMeta[startId] = { candleType };
|
||||||
|
extraStartIds.push(startId);
|
||||||
|
extraRoots[startId] = branchRoot;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: primaryRoot,
|
||||||
|
startMeta,
|
||||||
|
extraStartIds,
|
||||||
|
extraRoots,
|
||||||
|
startCombineOp: dsl.type as StartCombineOp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DSL → 편집기 상태 (TIMEFRAME / AND|OR+TIMEFRAME 분해) */
|
||||||
|
export function decodeConditionForEditor(dsl: LogicNode | null): EditorConditionState {
|
||||||
|
if (!dsl) return emptyEditorConditionState();
|
||||||
|
|
||||||
|
const multiStart = parseMultiStartWrapper(dsl);
|
||||||
|
if (multiStart) return multiStart;
|
||||||
|
|
||||||
|
if (dsl.type === 'TIMEFRAME') {
|
||||||
|
return {
|
||||||
|
root: dsl.children?.[0] ?? null,
|
||||||
|
startMeta: {
|
||||||
|
[START_NODE_ID]: { candleType: normalizeStartCandleType(dsl.candleType) },
|
||||||
|
},
|
||||||
|
extraStartIds: [],
|
||||||
|
extraRoots: {},
|
||||||
|
startCombineOp: DEFAULT_START_COMBINE_OP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: dsl,
|
||||||
|
startMeta: defaultStartMeta(),
|
||||||
|
extraStartIds: [],
|
||||||
|
extraRoots: {},
|
||||||
|
startCombineOp: DEFAULT_START_COMBINE_OP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logic Expression / 미리보기용 — START별 평가 분봉과 서브트리 */
|
||||||
|
export function collectEditorBranches(state: EditorConditionState): ConditionBranch[] {
|
||||||
|
const branches: ConditionBranch[] = [];
|
||||||
|
const primaryCt = state.startMeta[START_NODE_ID]?.candleType ?? DEFAULT_START_CANDLE;
|
||||||
|
if (state.root) branches.push({ candleType: primaryCt, root: state.root });
|
||||||
|
|
||||||
|
for (const startId of state.extraStartIds) {
|
||||||
|
const root = state.extraRoots[startId] ?? null;
|
||||||
|
if (root) {
|
||||||
|
branches.push({
|
||||||
|
candleType: state.startMeta[startId]?.candleType ?? DEFAULT_START_CANDLE,
|
||||||
|
root,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return branches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
||||||
|
export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
||||||
|
return collectEditorBranches(decodeConditionForEditor(dsl));
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { LogicNode } from './strategyTypes';
|
import type { LogicNode } from './strategyTypes';
|
||||||
import type { EdgeHandleBinding } from './strategyFlowLayout';
|
import type { EdgeHandleBinding } from './strategyFlowLayout';
|
||||||
|
import { defaultStartMeta, type StartCombineOp, type StartNodeMeta } from './strategyStartNodes';
|
||||||
|
|
||||||
export type SignalFlowLayoutSnapshot = {
|
export type SignalFlowLayoutSnapshot = {
|
||||||
positions: Record<string, { x: number; y: number }>;
|
positions: Record<string, { x: number; y: number }>;
|
||||||
edgeHandles: Record<string, EdgeHandleBinding>;
|
edgeHandles: Record<string, EdgeHandleBinding>;
|
||||||
orphans?: LogicNode[];
|
orphans?: LogicNode[];
|
||||||
|
/** START 노드별 조건 판별 시간봉 */
|
||||||
|
startMeta?: Record<string, StartNodeMeta>;
|
||||||
|
/** 기본 START 외 추가 START 노드 ID (순서 유지) */
|
||||||
|
extraStartIds?: string[];
|
||||||
|
/** 추가 START에 연결된 서브트리 루트 */
|
||||||
|
extraRoots?: Record<string, LogicNode | null>;
|
||||||
|
/** START 2개 이상일 때 분기 결합 (AND | OR) */
|
||||||
|
startCombineOp?: StartCombineOp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FlowLayoutChangePayload = Omit<SignalFlowLayoutSnapshot, 'orphans'> & {
|
export type FlowLayoutChangePayload = Omit<SignalFlowLayoutSnapshot, 'orphans'> & {
|
||||||
@@ -19,7 +28,14 @@ export type StrategyFlowLayoutStore = {
|
|||||||
const STORAGE_KEY = 'gc_se_flow_layout_v1';
|
const STORAGE_KEY = 'gc_se_flow_layout_v1';
|
||||||
|
|
||||||
export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot {
|
export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot {
|
||||||
return { positions: {}, edgeHandles: {}, orphans: [] };
|
return {
|
||||||
|
positions: {},
|
||||||
|
edgeHandles: {},
|
||||||
|
orphans: [],
|
||||||
|
startMeta: defaultStartMeta(),
|
||||||
|
extraStartIds: [],
|
||||||
|
extraRoots: {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore {
|
export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import type { Connection, Edge, Node } from '@xyflow/react';
|
import type { Connection, Edge, Node } from '@xyflow/react';
|
||||||
import type { LogicNode, ConditionDSL } from './strategyTypes';
|
import type { LogicNode, ConditionDSL } from './strategyTypes';
|
||||||
import { nodeToText, type DefType } from './strategyEditorShared';
|
import { nodeToText, type DefType } from './strategyEditorShared';
|
||||||
|
import {
|
||||||
|
START_NODE_ID,
|
||||||
|
isStartNodeId,
|
||||||
|
normalizeStartCandleType,
|
||||||
|
STRATEGY_CANDLE_TYPES,
|
||||||
|
type StartNodeMeta,
|
||||||
|
} from './strategyStartNodes';
|
||||||
|
|
||||||
export const START_NODE_ID = '__strategy_start__';
|
export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES };
|
||||||
|
export type { StartNodeMeta };
|
||||||
|
|
||||||
const H_GAP = 280;
|
const H_GAP = 280;
|
||||||
const V_GAP = 130;
|
const V_GAP = 130;
|
||||||
@@ -22,6 +30,10 @@ export type StrategyFlowNodeData = {
|
|||||||
def?: DefType;
|
def?: DefType;
|
||||||
signalTab?: 'buy' | 'sell';
|
signalTab?: 'buy' | 'sell';
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
/** START 노드 — 조건 판별 시간봉 */
|
||||||
|
candleType?: string;
|
||||||
|
onStartCandleTypeChange?: (startId: string, candleType: 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;
|
||||||
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;
|
||||||
@@ -45,10 +57,15 @@ export function findLogicNode(
|
|||||||
root: LogicNode | null,
|
root: LogicNode | null,
|
||||||
orphans: LogicNode[],
|
orphans: LogicNode[],
|
||||||
id: string,
|
id: string,
|
||||||
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
): LogicNode | null {
|
): LogicNode | null {
|
||||||
if (id === START_NODE_ID) return null;
|
if (isStartNodeId(id)) return null;
|
||||||
const inTree = findNodeInTree(root, id);
|
const inTree = findNodeInTree(root, id);
|
||||||
if (inTree) return inTree;
|
if (inTree) return inTree;
|
||||||
|
for (const extraRoot of Object.values(extraRoots)) {
|
||||||
|
const hit = findNodeInTree(extraRoot, id);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
return orphans.find(o => o.id === id) ?? null;
|
return orphans.find(o => o.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +76,7 @@ export function isPaletteConnectTarget(
|
|||||||
orphans: LogicNode[],
|
orphans: LogicNode[],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (isOrphanNode(orphans, anchorId)) return false;
|
if (isOrphanNode(orphans, anchorId)) return false;
|
||||||
if (anchorId === START_NODE_ID) return true;
|
if (isStartNodeId(anchorId)) return true;
|
||||||
if (!root) return false;
|
if (!root) return false;
|
||||||
return !!findNodeInTree(root, anchorId);
|
return !!findNodeInTree(root, anchorId);
|
||||||
}
|
}
|
||||||
@@ -69,7 +86,7 @@ export function getNodeConnectionCaps(
|
|||||||
nodeId: string,
|
nodeId: string,
|
||||||
logicNode: LogicNode | undefined,
|
logicNode: LogicNode | undefined,
|
||||||
): { canSource: boolean; canTarget: boolean } {
|
): { canSource: boolean; canTarget: boolean } {
|
||||||
if (nodeId === START_NODE_ID) return { canSource: true, canTarget: false };
|
if (nodeId === START_NODE_ID || isStartNodeId(nodeId)) return { canSource: true, canTarget: false };
|
||||||
if (!logicNode) return { canSource: false, canTarget: false };
|
if (!logicNode) return { canSource: false, canTarget: false };
|
||||||
switch (logicNode.type) {
|
switch (logicNode.type) {
|
||||||
case 'CONDITION':
|
case 'CONDITION':
|
||||||
@@ -86,7 +103,7 @@ export function getNodeConnectionCaps(
|
|||||||
|
|
||||||
/** 팔레트 드롭 시 부모(출발) 노드로 쓸 수 있는지 */
|
/** 팔레트 드롭 시 부모(출발) 노드로 쓸 수 있는지 */
|
||||||
export function canAcceptPaletteAsParent(anchorId: string, root: LogicNode | null): boolean {
|
export function canAcceptPaletteAsParent(anchorId: string, root: LogicNode | null): boolean {
|
||||||
if (anchorId === START_NODE_ID) return true;
|
if (isStartNodeId(anchorId)) return true;
|
||||||
if (!root) return false;
|
if (!root) return false;
|
||||||
const node = findNodeInTree(root, anchorId);
|
const node = findNodeInTree(root, anchorId);
|
||||||
if (!node || node.type === 'CONDITION') return false;
|
if (!node || node.type === 'CONDITION') return false;
|
||||||
@@ -102,40 +119,59 @@ export function isValidStrategyConnectionDirected(
|
|||||||
childId: string,
|
childId: string,
|
||||||
root: LogicNode | null,
|
root: LogicNode | null,
|
||||||
orphans: LogicNode[] = [],
|
orphans: LogicNode[] = [],
|
||||||
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!parentId || !childId || parentId === childId) return false;
|
if (!parentId || !childId || parentId === childId) return false;
|
||||||
if (childId === START_NODE_ID) return false;
|
if (isStartNodeId(childId)) return false;
|
||||||
|
|
||||||
const childOrphan = orphans.find(o => o.id === childId);
|
const childOrphan = orphans.find(o => o.id === childId);
|
||||||
const parentOrphan = orphans.find(o => o.id === parentId);
|
const parentOrphan = orphans.find(o => o.id === parentId);
|
||||||
|
|
||||||
if (childOrphan) {
|
if (childOrphan) {
|
||||||
if (parentId === START_NODE_ID) return true;
|
if (isStartNodeId(parentId)) return true;
|
||||||
const parentNode = findNodeInTree(root, parentId);
|
const parentNode = findNodeInTree(root, parentId)
|
||||||
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean)
|
||||||
|
?? null;
|
||||||
if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false;
|
if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentOrphan) {
|
if (parentOrphan) {
|
||||||
if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false;
|
if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false;
|
||||||
const childNode = findNodeInTree(root, childId);
|
const childNode = findNodeInTree(root, childId)
|
||||||
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean)
|
||||||
|
?? null;
|
||||||
if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false;
|
if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false;
|
||||||
if (root && isDescendant(root, childId, parentId)) return false;
|
if (root && isDescendant(root, childId, parentId)) return false;
|
||||||
|
for (const er of Object.values(extraRoots)) {
|
||||||
|
if (er && isDescendant(er, childId, parentId)) return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!root) return false;
|
if (!root && Object.values(extraRoots).every(r => !r)) return false;
|
||||||
|
|
||||||
const parentNode = parentId === START_NODE_ID ? null : findNodeInTree(root, parentId);
|
const parentNode = isStartNodeId(parentId)
|
||||||
const childNode = findNodeInTree(root, childId);
|
? null
|
||||||
if (parentId !== START_NODE_ID && !parentNode) return false;
|
: findNodeInTree(root, parentId)
|
||||||
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean)
|
||||||
|
?? null;
|
||||||
|
const childNode = findNodeInTree(root, childId)
|
||||||
|
?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean)
|
||||||
|
?? null;
|
||||||
|
if (!isStartNodeId(parentId) && !parentNode) return false;
|
||||||
if (!childNode) return false;
|
if (!childNode) return false;
|
||||||
|
|
||||||
const parentCaps = getNodeConnectionCaps(parentId, parentNode ?? undefined);
|
const parentCaps = getNodeConnectionCaps(parentId, parentNode ?? undefined);
|
||||||
const childCaps = getNodeConnectionCaps(childId, childNode);
|
const childCaps = getNodeConnectionCaps(childId, childNode);
|
||||||
if (!parentCaps.canSource || !childCaps.canTarget) return false;
|
if (!parentCaps.canSource || !childCaps.canTarget) return false;
|
||||||
|
|
||||||
if (parentId !== START_NODE_ID && isDescendant(root, childId, parentId)) return false;
|
if (!isStartNodeId(parentId)) {
|
||||||
|
if (root && isDescendant(root, childId, parentId)) return false;
|
||||||
|
for (const er of Object.values(extraRoots)) {
|
||||||
|
if (er && isDescendant(er, childId, parentId)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,34 +180,35 @@ export function isValidStrategyConnection(
|
|||||||
conn: Connection,
|
conn: Connection,
|
||||||
root: LogicNode | null,
|
root: LogicNode | null,
|
||||||
orphans: LogicNode[] = [],
|
orphans: LogicNode[] = [],
|
||||||
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
const { source, target } = conn;
|
const { source, target } = conn;
|
||||||
if (!source || !target) return false;
|
if (!source || !target) return false;
|
||||||
return (
|
return (
|
||||||
isValidStrategyConnectionDirected(source, target, root, orphans)
|
isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots)
|
||||||
|| isValidStrategyConnectionDirected(target, source, root, orphans)
|
|| isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** onConnect용 — 부모·자식 ID (드래그 시작 방향 우선, 불가 시 반대 방향) */
|
|
||||||
export function resolveStrategyConnection(
|
export function resolveStrategyConnection(
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
root: LogicNode | null,
|
root: LogicNode | null,
|
||||||
orphans: LogicNode[] = [],
|
orphans: LogicNode[] = [],
|
||||||
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
): { parentId: string; childId: string } | null {
|
): { parentId: string; childId: string } | null {
|
||||||
const { source, target } = conn;
|
const { source, target } = conn;
|
||||||
if (!source || !target) return null;
|
if (!source || !target) return null;
|
||||||
if (isValidStrategyConnectionDirected(source, target, root, orphans)) {
|
if (isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots)) {
|
||||||
return { parentId: source, childId: target };
|
return { parentId: source, childId: target };
|
||||||
}
|
}
|
||||||
if (isValidStrategyConnectionDirected(target, source, root, orphans)) {
|
if (isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots)) {
|
||||||
return { parentId: target, childId: source };
|
return { parentId: target, childId: source };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeDimensions(nodeId: string): { w: number; h: number } {
|
export function getNodeDimensions(nodeId: string): { w: number; h: number } {
|
||||||
return nodeId === START_NODE_ID
|
return isStartNodeId(nodeId)
|
||||||
? { w: FLOW_START_W, h: FLOW_START_H }
|
? { w: FLOW_START_W, h: FLOW_START_H }
|
||||||
: { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
: { w: FLOW_NODE_W, h: FLOW_NODE_H };
|
||||||
}
|
}
|
||||||
@@ -252,6 +289,7 @@ export function resolveOrphanDragConnection(
|
|||||||
nodes: { id: string; position: { x: number; y: number } }[],
|
nodes: { id: string; position: { x: number; y: number } }[],
|
||||||
root: LogicNode | null,
|
root: LogicNode | null,
|
||||||
orphans: LogicNode[],
|
orphans: LogicNode[],
|
||||||
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
): { parentId: string; childId: string } | null {
|
): { parentId: string; childId: string } | null {
|
||||||
const dim = getNodeDimensions(orphanId);
|
const dim = getNodeDimensions(orphanId);
|
||||||
const center = {
|
const center = {
|
||||||
@@ -269,10 +307,12 @@ export function resolveOrphanDragConnection(
|
|||||||
{ source: hit.id, target: orphanId, sourceHandle: null, targetHandle: null },
|
{ source: hit.id, target: orphanId, sourceHandle: null, targetHandle: null },
|
||||||
root,
|
root,
|
||||||
orphans,
|
orphans,
|
||||||
|
extraRoots,
|
||||||
) ?? resolveStrategyConnection(
|
) ?? resolveStrategyConnection(
|
||||||
{ source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null },
|
{ source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null },
|
||||||
root,
|
root,
|
||||||
orphans,
|
orphans,
|
||||||
|
extraRoots,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +353,7 @@ export type EdgeHandleBinding = {
|
|||||||
|
|
||||||
export const FLOW_NODE_W = 200;
|
export const FLOW_NODE_W = 200;
|
||||||
export const FLOW_NODE_H = 72;
|
export const FLOW_NODE_H = 72;
|
||||||
export const FLOW_START_W = 100;
|
export const FLOW_START_W = 168;
|
||||||
export const FLOW_START_H = 56;
|
export const FLOW_START_H = 56;
|
||||||
|
|
||||||
function nodeCenter(
|
function nodeCenter(
|
||||||
@@ -321,8 +361,8 @@ function nodeCenter(
|
|||||||
positions: Map<string, { x: number; y: number }>,
|
positions: Map<string, { x: number; y: number }>,
|
||||||
): { x: number; y: number } {
|
): { x: number; y: number } {
|
||||||
const p = positions.get(id) ?? { x: 0, y: 0 };
|
const p = positions.get(id) ?? { x: 0, y: 0 };
|
||||||
const w = id === START_NODE_ID ? FLOW_START_W : FLOW_NODE_W;
|
const w = isStartNodeId(id) ? FLOW_START_W : FLOW_NODE_W;
|
||||||
const h = id === START_NODE_ID ? FLOW_START_H : FLOW_NODE_H;
|
const h = isStartNodeId(id) ? FLOW_START_H : FLOW_NODE_H;
|
||||||
return { x: p.x + w / 2, y: p.y + h / 2 };
|
return { x: p.x + w / 2, y: p.y + h / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +509,101 @@ function layoutTree(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_LAYOUT_ORIGIN_X = 48;
|
||||||
|
const AUTO_LAYOUT_ORIGIN_Y = 48;
|
||||||
|
const AUTO_LAYOUT_SECTION_GAP = 72;
|
||||||
|
const AUTO_LAYOUT_START_TREE_GAP = 72;
|
||||||
|
const AUTO_LAYOUT_ORPHAN_GAP = 100;
|
||||||
|
|
||||||
|
function measureSubtreeHeight(node: LogicNode): number {
|
||||||
|
const children = node.children ?? [];
|
||||||
|
if (children.length === 0) return FLOW_NODE_H;
|
||||||
|
const childHeights = children.map(measureSubtreeHeight);
|
||||||
|
return childHeights.reduce((sum, h) => sum + h, 0) + (children.length - 1) * V_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeSubtree(
|
||||||
|
node: LogicNode,
|
||||||
|
x: number,
|
||||||
|
centerY: number,
|
||||||
|
positions: Map<string, { x: number; y: number }>,
|
||||||
|
): void {
|
||||||
|
const children = node.children ?? [];
|
||||||
|
if (children.length === 0) {
|
||||||
|
positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const childHeights = children.map(measureSubtreeHeight);
|
||||||
|
const totalHeight = childHeights.reduce((a, b) => a + b, 0) + (children.length - 1) * V_GAP;
|
||||||
|
let y = centerY - totalHeight / 2;
|
||||||
|
children.forEach((child, i) => {
|
||||||
|
const ch = childHeights[i];
|
||||||
|
placeSubtree(child, x + H_GAP, y + ch / 2, positions);
|
||||||
|
y += ch + V_GAP;
|
||||||
|
});
|
||||||
|
positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxLayoutX(positions: Map<string, { x: number; y: number }>): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const [id, pos] of positions) {
|
||||||
|
const dim = getNodeDimensions(id);
|
||||||
|
max = Math.max(max, pos.x + dim.w);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 그래프 캔버스 자동 정렬 — START별 트리를 좌→우, 형제는 위→아래 배치 */
|
||||||
|
export function computeAutoFlowLayout(
|
||||||
|
root: LogicNode | null,
|
||||||
|
extraStartIds: string[],
|
||||||
|
extraRoots: Record<string, LogicNode | null>,
|
||||||
|
orphans: LogicNode[],
|
||||||
|
): Record<string, { x: number; y: number }> {
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
let yCursor = AUTO_LAYOUT_ORIGIN_Y;
|
||||||
|
|
||||||
|
const placeStartSection = (startId: string, branchRoot: LogicNode | null) => {
|
||||||
|
const startDim = getNodeDimensions(startId);
|
||||||
|
const branchHeight = branchRoot ? measureSubtreeHeight(branchRoot) : 0;
|
||||||
|
const sectionHeight = Math.max(startDim.h, branchHeight);
|
||||||
|
const centerY = yCursor + sectionHeight / 2;
|
||||||
|
|
||||||
|
positions.set(startId, {
|
||||||
|
x: AUTO_LAYOUT_ORIGIN_X,
|
||||||
|
y: centerY - startDim.h / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (branchRoot) {
|
||||||
|
placeSubtree(
|
||||||
|
branchRoot,
|
||||||
|
AUTO_LAYOUT_ORIGIN_X + startDim.w + AUTO_LAYOUT_START_TREE_GAP,
|
||||||
|
centerY,
|
||||||
|
positions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
yCursor += sectionHeight + AUTO_LAYOUT_SECTION_GAP;
|
||||||
|
};
|
||||||
|
|
||||||
|
placeStartSection(START_NODE_ID, root);
|
||||||
|
for (const startId of extraStartIds) {
|
||||||
|
placeStartSection(startId, extraRoots[startId] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
let orphanY = AUTO_LAYOUT_ORIGIN_Y;
|
||||||
|
const orphanX = (positions.size > 0 ? maxLayoutX(positions) : AUTO_LAYOUT_ORIGIN_X) + AUTO_LAYOUT_ORPHAN_GAP;
|
||||||
|
for (const orphan of orphans) {
|
||||||
|
const h = measureSubtreeHeight(orphan);
|
||||||
|
positions.set(orphan.id, { x: orphanX, y: orphanY });
|
||||||
|
orphanY += h + V_GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(positions);
|
||||||
|
}
|
||||||
|
|
||||||
function appendOrphanFlowNodes(
|
function appendOrphanFlowNodes(
|
||||||
orphans: LogicNode[],
|
orphans: LogicNode[],
|
||||||
nodes: Node[],
|
nodes: Node[],
|
||||||
@@ -513,33 +648,69 @@ export function logicNodeToFlow(
|
|||||||
def: DefType,
|
def: DefType,
|
||||||
signalTab: 'buy' | 'sell',
|
signalTab: 'buy' | 'sell',
|
||||||
positions: Map<string, { x: number; y: number }>,
|
positions: Map<string, { x: number; y: number }>,
|
||||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
callbacks: Pick<
|
||||||
|
StrategyFlowNodeData,
|
||||||
|
| 'onDelete'
|
||||||
|
| 'onUpdateCondition'
|
||||||
|
| 'onDropTarget'
|
||||||
|
| 'onDragOverTarget'
|
||||||
|
| 'onDragLeaveTarget'
|
||||||
|
| 'onStartCandleTypeChange'
|
||||||
|
| 'onDeleteStart'
|
||||||
|
>,
|
||||||
dragOverId: string | null = null,
|
dragOverId: string | null = null,
|
||||||
orphans: LogicNode[] = [],
|
orphans: LogicNode[] = [],
|
||||||
|
startMeta: Record<string, StartNodeMeta> = { [START_NODE_ID]: { candleType: '1m' } },
|
||||||
|
extraStartIds: string[] = [],
|
||||||
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
): { nodes: Node[]; edges: Edge[] } {
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
const edges: Edge[] = [];
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
nodes.push({
|
const appendStart = (startId: string, branchRoot: LogicNode | null, defaultY: number) => {
|
||||||
id: START_NODE_ID,
|
const candleType = normalizeStartCandleType(startMeta[startId]?.candleType);
|
||||||
type: 'start',
|
nodes.push({
|
||||||
position: positions.get(START_NODE_ID) ?? { x: 0, y: 220 },
|
id: startId,
|
||||||
data: {
|
type: 'start',
|
||||||
label: 'START',
|
position: positions.get(startId) ?? { x: 0, y: defaultY },
|
||||||
signalTab,
|
data: {
|
||||||
onDropTarget: callbacks.onDropTarget,
|
label: 'START',
|
||||||
onDragOverTarget: callbacks.onDragOverTarget,
|
signalTab,
|
||||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
candleType,
|
||||||
canSourceConnect: true,
|
onStartCandleTypeChange: callbacks.onStartCandleTypeChange,
|
||||||
canTargetConnect: false,
|
onDeleteStart: startId === START_NODE_ID ? undefined : callbacks.onDeleteStart,
|
||||||
} satisfies StrategyFlowNodeData,
|
onDropTarget: callbacks.onDropTarget,
|
||||||
draggable: true,
|
onDragOverTarget: callbacks.onDragOverTarget,
|
||||||
selectable: true,
|
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||||
});
|
canSourceConnect: true,
|
||||||
|
canTargetConnect: false,
|
||||||
|
} satisfies StrategyFlowNodeData,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (root) {
|
if (branchRoot) {
|
||||||
layoutTree(root, START_NODE_ID, 1, 0, 1, nodes, edges, positions, def, signalTab, callbacks, dragOverId);
|
layoutTree(
|
||||||
}
|
branchRoot,
|
||||||
|
startId,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
positions,
|
||||||
|
def,
|
||||||
|
signalTab,
|
||||||
|
callbacks,
|
||||||
|
dragOverId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
appendStart(START_NODE_ID, root, 220);
|
||||||
|
extraStartIds.forEach((startId, index) => {
|
||||||
|
appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140);
|
||||||
|
});
|
||||||
|
|
||||||
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
|
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
|
||||||
|
|
||||||
@@ -663,20 +834,34 @@ function mergeOrphans(existing: LogicNode[], added: LogicNode[]): LogicNode[] {
|
|||||||
function resolveDisconnectEndpoints(
|
function resolveDisconnectEndpoints(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
root: LogicNode,
|
root: LogicNode | null,
|
||||||
orphans: LogicNode[],
|
orphans: LogicNode[],
|
||||||
|
extraRoots: Record<string, LogicNode | null>,
|
||||||
): { parentId: string; childId: string } | null {
|
): { parentId: string; childId: string } | null {
|
||||||
const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null };
|
const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null };
|
||||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots);
|
||||||
if (resolved) return resolved;
|
if (resolved) return resolved;
|
||||||
|
|
||||||
if (sourceId === START_NODE_ID && root.id === targetId) {
|
if (isStartNodeId(sourceId)) {
|
||||||
return { parentId: START_NODE_ID, childId: targetId };
|
const branchRoot = sourceId === START_NODE_ID ? root : extraRoots[sourceId] ?? null;
|
||||||
|
if (branchRoot?.id === targetId) {
|
||||||
|
return { parentId: sourceId, childId: targetId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentInTree = findParentIdInTree(root, targetId);
|
if (root) {
|
||||||
if (parentInTree === sourceId) {
|
const parentInTree = findParentIdInTree(root, targetId);
|
||||||
return { parentId: sourceId, childId: targetId };
|
if (parentInTree === sourceId) {
|
||||||
|
return { parentId: sourceId, childId: targetId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [startId, branchRoot] of Object.entries(extraRoots)) {
|
||||||
|
if (!branchRoot) continue;
|
||||||
|
const parentInTree = findParentIdInTree(branchRoot, targetId, startId);
|
||||||
|
if (parentInTree === sourceId) {
|
||||||
|
return { parentId: sourceId, childId: targetId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -688,36 +873,67 @@ export function isDescendant(root: LogicNode, ancestorId: string, nodeId: string
|
|||||||
return findNodeInTree(ancestor, nodeId) != null && ancestorId !== nodeId;
|
return findNodeInTree(ancestor, nodeId) != null && ancestorId !== nodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 연결선 제거 — 자식 서브트리를 트리에서 분리·미연결(고아)로 → Logic Expression 제외 */
|
|
||||||
export function disconnectTreeEdge(
|
export function disconnectTreeEdge(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
root: LogicNode | null,
|
root: LogicNode | null,
|
||||||
orphans: LogicNode[],
|
orphans: LogicNode[],
|
||||||
): { root: LogicNode | null; orphans: LogicNode[] } {
|
extraRoots: Record<string, LogicNode | null> = {},
|
||||||
if (!root) return { root, orphans };
|
): {
|
||||||
|
root: LogicNode | null;
|
||||||
const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans);
|
orphans: LogicNode[];
|
||||||
if (!endpoints) return { root, orphans };
|
extraRoots: Record<string, LogicNode | null>;
|
||||||
|
} {
|
||||||
|
const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans, extraRoots);
|
||||||
|
if (!endpoints) return { root, orphans, extraRoots };
|
||||||
|
|
||||||
const { parentId, childId } = endpoints;
|
const { parentId, childId } = endpoints;
|
||||||
if (childId === START_NODE_ID) return { root, orphans };
|
if (isStartNodeId(childId)) return { root, orphans, extraRoots };
|
||||||
|
|
||||||
const childInTree = root.id === childId || !!findNodeInTree(root, childId);
|
const inPrimary = root && (root.id === childId || !!findNodeInTree(root, childId));
|
||||||
if (!childInTree) return { root, orphans };
|
const inExtraStartId = Object.entries(extraRoots).find(([, branch]) =>
|
||||||
|
branch && (branch.id === childId || !!findNodeInTree(branch, childId)),
|
||||||
|
)?.[0];
|
||||||
|
|
||||||
if (parentId === START_NODE_ID && root.id === childId) {
|
if (!inPrimary && !inExtraStartId) return { root, orphans, extraRoots };
|
||||||
|
|
||||||
|
if (isStartNodeId(parentId)) {
|
||||||
|
if (parentId === START_NODE_ID && root?.id === childId) {
|
||||||
|
return {
|
||||||
|
root: null,
|
||||||
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)),
|
||||||
|
extraRoots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const branchRoot = extraRoots[parentId] ?? null;
|
||||||
|
if (branchRoot?.id === childId) {
|
||||||
|
const nextExtra = { ...extraRoots, [parentId]: null };
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(branchRoot)),
|
||||||
|
extraRoots: nextExtra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractFrom = inPrimary ? root! : extraRoots[inExtraStartId!]!;
|
||||||
|
const { tree, node } = extractNode(extractFrom, childId);
|
||||||
|
if (!node) return { root, orphans, extraRoots };
|
||||||
|
|
||||||
|
if (inPrimary) {
|
||||||
return {
|
return {
|
||||||
root: null,
|
root: pruneEmptyGates(tree),
|
||||||
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)),
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
|
||||||
|
extraRoots,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tree, node } = extractNode(root, childId);
|
|
||||||
if (!node) return { root, orphans };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root: pruneEmptyGates(tree),
|
root,
|
||||||
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
|
orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)),
|
||||||
|
extraRoots: {
|
||||||
|
...extraRoots,
|
||||||
|
[inExtraStartId!]: pruneEmptyGates(tree),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { generateNodeId } from './strategyTypes';
|
||||||
|
|
||||||
|
export const START_NODE_ID = '__strategy_start__';
|
||||||
|
export const DEFAULT_START_CANDLE = '1m';
|
||||||
|
|
||||||
|
export const STRATEGY_CANDLE_TYPES = [
|
||||||
|
'1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type StrategyCandleType = (typeof STRATEGY_CANDLE_TYPES)[number];
|
||||||
|
|
||||||
|
export type StartNodeMeta = {
|
||||||
|
candleType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** START가 2개 이상일 때 분기 간 결합 연산 */
|
||||||
|
export type StartCombineOp = 'AND' | 'OR';
|
||||||
|
|
||||||
|
export const DEFAULT_START_COMBINE_OP: StartCombineOp = 'AND';
|
||||||
|
|
||||||
|
export function isStartNodeId(id: string): boolean {
|
||||||
|
return id === START_NODE_ID || id.startsWith('__strategy_start_');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStartNodeId(): string {
|
||||||
|
return `__strategy_start_${generateNodeId()}__`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultStartMeta(): Record<string, StartNodeMeta> {
|
||||||
|
return { [START_NODE_ID]: { candleType: DEFAULT_START_CANDLE } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStartCandleType(value: string | undefined | null): string {
|
||||||
|
const raw = (value ?? DEFAULT_START_CANDLE).trim().toLowerCase();
|
||||||
|
if (raw === '1d') return '1d';
|
||||||
|
return (STRATEGY_CANDLE_TYPES as readonly string[]).includes(raw) ? raw : DEFAULT_START_CANDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStartCandleLabel(candleType: string): string {
|
||||||
|
return normalizeStartCandleType(candleType);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// ── DSL 트리 타입 ─────────────────────────────────────────────────────────────
|
// ── DSL 트리 타입 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION';
|
export type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||||
|
|
||||||
export interface ConditionDSL {
|
export interface ConditionDSL {
|
||||||
indicatorType: string;
|
indicatorType: string;
|
||||||
@@ -25,6 +25,8 @@ export interface LogicNode {
|
|||||||
children?: LogicNode[];
|
children?: LogicNode[];
|
||||||
condition?: ConditionDSL;
|
condition?: ConditionDSL;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** TIMEFRAME 노드 — 조건 판별 대상 캔들 타입 (1m, 5m, 1h …) */
|
||||||
|
candleType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrategyDSLDto {
|
export interface StrategyDSLDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user